mirror of
https://github.com/multica-ai/multica.git
synced 2026-06-17 19:59:20 +02:00
Compare commits
1 Commits
v0.1.20
...
agent/lamb
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
58a8655b43 |
@@ -29,7 +29,6 @@ RESEND_FROM_EMAIL=noreply@multica.ai
|
||||
GOOGLE_CLIENT_ID=
|
||||
GOOGLE_CLIENT_SECRET=
|
||||
GOOGLE_REDIRECT_URI=http://localhost:3000/auth/callback
|
||||
NEXT_PUBLIC_GOOGLE_CLIENT_ID=
|
||||
|
||||
# S3 / CloudFront
|
||||
S3_BUCKET=
|
||||
|
||||
34
.github/PULL_REQUEST_TEMPLATE.md
vendored
34
.github/PULL_REQUEST_TEMPLATE.md
vendored
@@ -1,34 +0,0 @@
|
||||
## What
|
||||
|
||||
<!-- What does this PR do? Keep it to 1-3 sentences. -->
|
||||
|
||||
## Why
|
||||
|
||||
<!-- Why is this change needed? Link the related issue. -->
|
||||
|
||||
Closes #<!-- issue number -->
|
||||
|
||||
## Type of Change
|
||||
|
||||
- [ ] Bug fix
|
||||
- [ ] New feature
|
||||
- [ ] Refactor / code improvement
|
||||
- [ ] Documentation
|
||||
- [ ] CI / infrastructure
|
||||
- [ ] Other (describe below)
|
||||
|
||||
## How to Test
|
||||
|
||||
<!-- How can a reviewer verify this works? Steps, commands, or screenshots. -->
|
||||
|
||||
## Checklist
|
||||
|
||||
- [ ] `make check` passes (typecheck, unit tests, Go tests, E2E)
|
||||
- [ ] Changes follow existing code patterns and conventions
|
||||
- [ ] No unrelated changes included
|
||||
|
||||
## AI Disclosure (optional)
|
||||
|
||||
<!-- If AI tools were used: -->
|
||||
<!-- - Which tool? (e.g., Claude Code, Copilot, Cursor) -->
|
||||
<!-- - What prompt did you use? Sharing your prompt helps others learn and lets reviewers understand intent. -->
|
||||
67
CLAUDE.md
67
CLAUDE.md
@@ -24,94 +24,65 @@ The frontend uses a **feature-based architecture** with four layers:
|
||||
```
|
||||
apps/web/
|
||||
├── app/ # Routing layer (thin shells — import from features/)
|
||||
├── core/ # Headless business logic (TanStack Query, zero JSX, zero react-dom)
|
||||
├── features/ # UI business components, organized by domain
|
||||
├── features/ # Business logic, organized by domain
|
||||
├── shared/ # Cross-feature utilities (api client, types, logger)
|
||||
```
|
||||
|
||||
**`app/`** — Next.js App Router pages. Route files should be thin: import and re-export from `features/`. Layout components and route-specific glue (redirects, auth guards) live here. Shared layout components (e.g. `app-sidebar`) stay in `app/(dashboard)/_components/`.
|
||||
|
||||
**`core/`** — Headless business logic. Query key factories, `queryOptions`, mutation hooks, WS cache updaters. **No JSX, no react-dom.** Designed for future extraction to `packages/core/` in a monorepo.
|
||||
|
||||
| Module | Purpose | Key exports |
|
||||
|---|---|---|
|
||||
| `core/issues/` | Issue queries, mutations, WS updaters | `issueListOptions`, `useUpdateIssue`, `onIssueUpdated` |
|
||||
| `core/inbox/` | Inbox queries, mutations, WS updaters | `inboxListOptions`, `useMarkInboxRead` |
|
||||
| `core/workspace/` | Member/agent/skill queries, workspace mutations | `memberListOptions`, `agentListOptions` |
|
||||
| `core/runtimes/` | Runtime queries | `runtimeListOptions` |
|
||||
| `core/query-client.ts` | QueryClient factory | `createQueryClient` |
|
||||
| `core/provider.tsx` | QueryClientProvider wrapper | `QueryProvider` |
|
||||
| `core/hooks.ts` | Shared hooks | `useWorkspaceId` |
|
||||
|
||||
**`features/`** — Domain modules with UI components, client-only stores, and config:
|
||||
**`features/`** — Domain modules, each with its own components, hooks, stores, and config:
|
||||
|
||||
| Feature | Purpose | Exports |
|
||||
|---|---|---|
|
||||
| `features/auth/` | Authentication state | `useAuthStore`, `AuthInitializer` |
|
||||
| `features/workspace/` | Workspace identity + UI | `useWorkspaceStore` (client-only: workspace/workspaces), `useActorName` |
|
||||
| `features/issues/` | Issue UI components + client state | `useIssueStore` (client-only: activeIssueId), icons, pickers, config |
|
||||
| `features/workspace/` | Workspace, members, agents | `useWorkspaceStore`, `useActorName` |
|
||||
| `features/issues/` | Issue state, components, config | `useIssueStore`, icons, pickers, status/priority config |
|
||||
| `features/inbox/` | Inbox notification state | `useInboxStore` |
|
||||
| `features/realtime/` | WebSocket connection + sync | `WSProvider`, `useWSEvent`, `useRealtimeSync` |
|
||||
| `features/modals/` | Modal registry and state | Modal store and components |
|
||||
| `features/skills/` | Skill management | Skill components |
|
||||
|
||||
**`shared/`** — Code used across multiple features (will migrate to `core/` in Phase 5):
|
||||
**`shared/`** — Code used across multiple features:
|
||||
- `shared/api/` — `ApiClient` (REST) and `WSClient` (WebSocket) for backend communication, plus the `api` singleton.
|
||||
- `shared/types/` — Domain types (Issue, Agent, Workspace, etc.) and WebSocket event types.
|
||||
- `shared/logger.ts` — Logger utility.
|
||||
|
||||
### State Management
|
||||
|
||||
- **TanStack Query** for all server state — issues, inbox, members, agents, skills, runtimes. Query definitions live in `core/<domain>/queries.ts`, mutations in `core/<domain>/mutations.ts`.
|
||||
- **Zustand** for client-only state — UI selections (`activeIssueId`), view filters, modal state, workspace identity, navigation. No API calls in Zustand stores.
|
||||
- **Zustand** for global client state — one store per feature domain (`features/auth/store.ts`, `features/workspace/store.ts`, `features/issues/store.ts`, `features/inbox/store.ts`).
|
||||
- **React Context** only for connection lifecycle (`WSProvider` in `features/realtime/`).
|
||||
- **Local `useState`** for component-scoped UI state (forms, modals, filters).
|
||||
- Do not use React Context for data that can be a zustand store.
|
||||
|
||||
**TanStack Query conventions:**
|
||||
- `staleTime: Infinity` — WS events handle cache freshness, no polling or refetch-on-focus.
|
||||
- WS events trigger `queryClient.invalidateQueries()` (preferred) or `queryClient.setQueryData()` for granular updates.
|
||||
- All workspace-scoped query keys include `wsId` — workspace switch automatically uses new cache.
|
||||
- Mutations use `onMutate` for optimistic updates + `onError` for rollback + `onSettled` for invalidation.
|
||||
- Components access QueryClient via `useQueryClient()` hook. Non-React contexts (e.g. Tiptap plugin callbacks) receive QueryClient via closure from the parent React component — never use module-level singletons.
|
||||
|
||||
**Zustand store conventions:**
|
||||
- Stores hold only client state (UI selections, persisted preferences). Zero `api.*` calls in stores.
|
||||
- Import via `useAuthStore(selector)` or `useWorkspaceStore(selector)`.
|
||||
**Store conventions:**
|
||||
- One store per feature domain. Import via `useAuthStore(selector)` or `useWorkspaceStore(selector)`.
|
||||
- Stores must not call `useRouter` or any React hooks — keep navigation in components.
|
||||
- `useWorkspaceStore` manages workspace identity (`workspace`, `workspaces`, `api.setWorkspaceId`, localStorage). Server data (members, agents, skills) is in TanStack Query, not the store.
|
||||
- Cross-store reads use `useOtherStore.getState()` inside actions (not hooks).
|
||||
- Dependency direction: `workspace` → `auth`, `realtime` → `auth`, `issues` → `workspace`. Never reverse.
|
||||
|
||||
### Import Aliases
|
||||
|
||||
Use `@/` alias (maps to `apps/web/`) and `@core/` alias (maps to `apps/web/core/`):
|
||||
Use `@/` alias (maps to `apps/web/`):
|
||||
```typescript
|
||||
// Core (headless business logic)
|
||||
import { issueListOptions, issueKeys } from "@core/issues/queries";
|
||||
import { useUpdateIssue, useCreateIssue } from "@core/issues/mutations";
|
||||
import { memberListOptions, agentListOptions } from "@core/workspace/queries";
|
||||
import { useWorkspaceId } from "@core/hooks";
|
||||
|
||||
// Shared (api client, types)
|
||||
import { api } from "@/shared/api";
|
||||
import type { Issue } from "@/shared/types";
|
||||
|
||||
// Features (UI components, client stores)
|
||||
import { useAuthStore } from "@/features/auth";
|
||||
import { useWorkspaceStore } from "@/features/workspace";
|
||||
import { useIssueStore } from "@/features/issues";
|
||||
import { useInboxStore } from "@/features/inbox";
|
||||
import { useWSEvent } from "@/features/realtime";
|
||||
import { StatusIcon } from "@/features/issues/components";
|
||||
```
|
||||
|
||||
Within a feature, use relative imports. Between features or to shared, use `@/`. For core modules, use `@core/`.
|
||||
Within a feature, use relative imports. Between features or to shared, use `@/`.
|
||||
|
||||
### Data Flow
|
||||
|
||||
```
|
||||
Browser → useQuery (core/) → ApiClient (shared/api) → REST API (Chi handlers) → sqlc queries → PostgreSQL
|
||||
Browser ← useQuery cache ← invalidateQueries ← WS event handlers ← WSClient ← Hub.Broadcast()
|
||||
Browser → ApiClient (shared/api) → REST API (Chi handlers) → sqlc queries → PostgreSQL
|
||||
Browser ← WSClient (shared/api) ← WebSocket ← Hub.Broadcast() ← Handlers/TaskService
|
||||
```
|
||||
|
||||
Mutations: `useMutation (core/)` → optimistic cache update → API call → onSettled invalidation.
|
||||
WS events: `use-realtime-sync.ts` → `queryClient.invalidateQueries()` for most events, `setQueryData()` for granular issue/inbox updates.
|
||||
|
||||
### Backend Structure (`server/`)
|
||||
|
||||
- **Entry points** (`cmd/`): `server` (HTTP API), `multica` (CLI — daemon, agent management, config), `migrate`
|
||||
@@ -206,7 +177,7 @@ make start-worktree # Start using .env.worktree
|
||||
- Prefer shadcn components over custom implementations. Install missing components via `npx shadcn add`.
|
||||
- **Feature-specific components** → `features/<domain>/components/` — issue icons, pickers, and other domain-bound UI live inside their feature module.
|
||||
- Use shadcn design tokens for styling (e.g. `bg-primary`, `text-muted-foreground`, `text-destructive`). Avoid hardcoded color values (e.g. `text-red-500`, `bg-gray-100`).
|
||||
- Do not introduce extra state (useState, context, reducers) unless explicitly required by the design. Server data goes through TanStack Query (`core/`), client-only shared state through Zustand, React Context only for connection lifecycle.
|
||||
- Do not introduce extra state (useState, context, reducers) unless explicitly required by the design. Prefer zustand stores for shared state over React Context.
|
||||
- Pay close attention to **overflow** (truncate long text, scrollable containers), **alignment**, and **spacing** consistency.
|
||||
- When unsure about interaction or state design, ask — the user will provide direction.
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ The `multica` CLI connects your local machine to Multica. It handles authenticat
|
||||
|
||||
```bash
|
||||
brew tap multica-ai/tap
|
||||
brew install multica
|
||||
brew install multica-cli
|
||||
```
|
||||
|
||||
### Build from Source
|
||||
|
||||
171
CLI_INSTALL.md
171
CLI_INSTALL.md
@@ -1,171 +0,0 @@
|
||||
# Multica CLI — Installation Guide for AI Agents
|
||||
|
||||
> **Audience:** This document is designed for AI agents (Claude Code, Codex, etc.) to read and execute step-by-step. Each step includes the exact shell commands to run, expected outcomes, and error handling.
|
||||
|
||||
## One-Line Instruction for Users
|
||||
|
||||
Give this instruction to your AI agent:
|
||||
|
||||
```
|
||||
Fetch https://github.com/multica-ai/multica/blob/main/CLI_INSTALL.md and follow the instructions to install Multica CLI, log in, and start the daemon on this machine.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Step 1: Check if Multica CLI is already installed
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
multica version
|
||||
```
|
||||
|
||||
- **If it prints a version string** (e.g. `multica v0.x.x`): skip to **Step 3**.
|
||||
- **If command not found**: continue to **Step 2**.
|
||||
|
||||
---
|
||||
|
||||
## Step 2: Install the Multica CLI
|
||||
|
||||
### Option A: Homebrew (preferred)
|
||||
|
||||
Check if Homebrew is available:
|
||||
|
||||
```bash
|
||||
which brew
|
||||
```
|
||||
|
||||
If `brew` is found, install via Homebrew:
|
||||
|
||||
```bash
|
||||
brew tap multica-ai/tap && brew install multica
|
||||
```
|
||||
|
||||
Then verify:
|
||||
|
||||
```bash
|
||||
multica version
|
||||
```
|
||||
|
||||
If the version prints successfully, skip to **Step 3**.
|
||||
|
||||
### Option B: Download from GitHub Releases (no Homebrew)
|
||||
|
||||
If Homebrew is not available, download the binary directly.
|
||||
|
||||
Detect OS and architecture, then download the correct archive:
|
||||
|
||||
```bash
|
||||
OS=$(uname -s | tr '[:upper:]' '[:lower:]') # "darwin" or "linux"
|
||||
ARCH=$(uname -m) # "x86_64" or "arm64"
|
||||
|
||||
# Normalize architecture name
|
||||
if [ "$ARCH" = "x86_64" ]; then
|
||||
ARCH="amd64"
|
||||
fi
|
||||
|
||||
# Get the latest release tag from GitHub
|
||||
LATEST=$(curl -sI https://github.com/multica-ai/multica/releases/latest | grep -i '^location:' | sed 's/.*tag\///' | tr -d '\r\n')
|
||||
|
||||
# Download and extract
|
||||
curl -sL "https://github.com/multica-ai/multica/releases/download/${LATEST}/multica_${OS}_${ARCH}.tar.gz" -o /tmp/multica.tar.gz
|
||||
tar -xzf /tmp/multica.tar.gz -C /tmp multica
|
||||
sudo mv /tmp/multica /usr/local/bin/multica
|
||||
rm /tmp/multica.tar.gz
|
||||
```
|
||||
|
||||
Verify:
|
||||
|
||||
```bash
|
||||
multica version
|
||||
```
|
||||
|
||||
**If this fails:**
|
||||
- Check that `/usr/local/bin` is in `$PATH`.
|
||||
- On Linux, you may need `chmod +x /usr/local/bin/multica`.
|
||||
- If `sudo` is not available, install to a user-writable directory: `mv /tmp/multica ~/.local/bin/multica` and ensure `~/.local/bin` is in `$PATH`.
|
||||
|
||||
---
|
||||
|
||||
## Step 3: Log in
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
multica login
|
||||
```
|
||||
|
||||
**Important:** This command opens a browser window for OAuth authentication. Tell the user:
|
||||
|
||||
> "A browser window will open for Multica login. Please complete the authentication in your browser, then come back here."
|
||||
|
||||
Wait for the command to complete. It will automatically discover and watch all workspaces the user belongs to.
|
||||
|
||||
Verify:
|
||||
|
||||
```bash
|
||||
multica auth status
|
||||
```
|
||||
|
||||
Expected output should show the authenticated user and server URL.
|
||||
|
||||
**If login fails:**
|
||||
- If no browser is available (headless environment), the user can generate a Personal Access Token at `https://app.multica.ai/settings` and run: `multica login --token`
|
||||
- If the server URL needs to be customized: `multica config set server_url <url>` before logging in.
|
||||
|
||||
---
|
||||
|
||||
## Step 4: Start the daemon
|
||||
|
||||
First, check if the daemon is already running:
|
||||
|
||||
```bash
|
||||
multica daemon status
|
||||
```
|
||||
|
||||
- **If status is "running"**: skip to **Step 5**.
|
||||
- **If status is "stopped"**: start it:
|
||||
|
||||
```bash
|
||||
multica daemon start
|
||||
```
|
||||
|
||||
Wait 3 seconds, then verify:
|
||||
|
||||
```bash
|
||||
multica daemon status
|
||||
```
|
||||
|
||||
Expected output should show `running` status with detected agents (e.g. `claude`, `codex`).
|
||||
|
||||
**If daemon fails to start:**
|
||||
- Check logs: `multica daemon logs`
|
||||
- If a port conflict occurs, the daemon may already be running under a different profile.
|
||||
- If no agents are detected, ensure at least one AI CLI (`claude` or `codex`) is installed and on the `$PATH`.
|
||||
|
||||
---
|
||||
|
||||
## Step 5: Verify everything is working
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
multica daemon status
|
||||
```
|
||||
|
||||
Confirm:
|
||||
1. Status is `running`
|
||||
2. At least one agent is listed (e.g. `claude`, `codex`)
|
||||
3. At least one workspace is being watched
|
||||
|
||||
If the agents list is empty, tell the user:
|
||||
|
||||
> "The Multica daemon is running but no AI agent CLIs were detected. Please install at least one: [Claude Code](https://docs.anthropic.com/en/docs/claude-code) (`claude`) or [Codex](https://github.com/openai/codex) (`codex`), then restart the daemon with `multica daemon stop && multica daemon start`."
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
When all steps are complete, inform the user:
|
||||
|
||||
> "Multica CLI is installed and the daemon is running. Agents in your workspaces can now execute tasks on this machine. You can manage workspaces with `multica workspace list` and view daemon logs with `multica daemon logs -f`."
|
||||
221
LICENSE
221
LICENSE
@@ -1,44 +1,199 @@
|
||||
# Open Source License
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
Multica is licensed under a modified version of the Apache License 2.0, with the following additional conditions:
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Multica may be utilized commercially, including as a backend service for
|
||||
other applications or as a task management platform for enterprises.
|
||||
Should the conditions below be met, a commercial license must be obtained
|
||||
from the producer:
|
||||
1. Definitions.
|
||||
|
||||
a. Hosted or embedded service: Unless explicitly authorized by Multica
|
||||
in writing, you may not use the Multica source code to provide a
|
||||
hosted service to third parties, or embed Multica as a component of
|
||||
a product or service that is sold, licensed, or otherwise
|
||||
commercially distributed to third parties.
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
- This restriction applies to offering Multica (in whole or
|
||||
substantial part) as a SaaS platform, a managed service, or as
|
||||
an integrated component within another commercial offering.
|
||||
- Internal use within a single organization (including multiple
|
||||
workspaces) does not require a commercial license.
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
b. LOGO and copyright information: In the process of using Multica's
|
||||
frontend, you may not remove or modify the LOGO or copyright
|
||||
information in the Multica console or applications. This restriction
|
||||
is inapplicable to uses of Multica that do not involve its frontend.
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
- Frontend Definition: For the purposes of this license, the
|
||||
"frontend" of Multica includes all components located in the
|
||||
`apps/web/` directory when running Multica from the raw source
|
||||
code, or the "web" image when running Multica with Docker.
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
2. As a contributor, you should agree that:
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
a. The producer can adjust the open-source agreement to be more strict
|
||||
or relaxed as deemed necessary.
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
b. Your contributed code may be used for commercial purposes, including
|
||||
but not limited to its cloud business operations.
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
Apart from the specific conditions mentioned above, all other rights and
|
||||
restrictions follow the Apache License 2.0. Detailed information about the
|
||||
Apache License 2.0 can be found at http://www.apache.org/licenses/LICENSE-2.0.
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
© 2025 Multica, Inc.
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to the Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by the Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding any notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
APPENDIX: How to apply the Apache License to your work.
|
||||
|
||||
To apply the Apache License to your work, attach the following
|
||||
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||
replaced with your own identifying information. (Don't include
|
||||
the brackets!) The text should be enclosed in the appropriate
|
||||
comment syntax for the file format. Please also get an
|
||||
"Implied Patent License" from your patent counsel.
|
||||
|
||||
Copyright 2025 Multica
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
|
||||
9
Makefile
9
Makefile
@@ -69,12 +69,7 @@ stop:
|
||||
@echo "Stopping services..."
|
||||
@-lsof -ti:$(PORT) | xargs kill -9 2>/dev/null
|
||||
@-lsof -ti:$(FRONTEND_PORT) | xargs kill -9 2>/dev/null
|
||||
@case "$(DATABASE_URL)" in \
|
||||
""|*@localhost:*|*@localhost/*|*@127.0.0.1:*|*@127.0.0.1/*|*@\[::1\]:*|*@\[::1\]/*) \
|
||||
echo "✓ App processes stopped. Shared PostgreSQL is still running on localhost:$(POSTGRES_PORT)." ;; \
|
||||
*) \
|
||||
echo "✓ App processes stopped. Remote PostgreSQL was not affected." ;; \
|
||||
esac
|
||||
@echo "✓ App processes stopped. Shared PostgreSQL is still running on localhost:5432."
|
||||
|
||||
# Full verification: typecheck + unit tests + Go tests + E2E
|
||||
check:
|
||||
@@ -143,12 +138,10 @@ COMMIT ?= $(shell git rev-parse --short HEAD 2>/dev/null || echo unknown)
|
||||
build:
|
||||
cd server && go build -o bin/server ./cmd/server
|
||||
cd server && go build -ldflags "-X main.version=$(VERSION) -X main.commit=$(COMMIT)" -o bin/multica ./cmd/multica
|
||||
cd server && go build -o bin/migrate ./cmd/migrate
|
||||
|
||||
test:
|
||||
$(REQUIRE_ENV)
|
||||
@bash scripts/ensure-postgres.sh "$(ENV_FILE)"
|
||||
cd server && go run ./cmd/migrate up
|
||||
cd server && go test ./...
|
||||
|
||||
# Database
|
||||
|
||||
20
README.md
20
README.md
@@ -14,8 +14,8 @@
|
||||
|
||||
**Your next 10 hires won't be human.**
|
||||
|
||||
The open-source managed agents platform.<br/>
|
||||
Turn coding agents into real teammates — assign tasks, track progress, compound skills.
|
||||
Open-source platform that turns coding agents into real teammates.<br/>
|
||||
Assign tasks, track progress, compound skills — manage your human + agent workforce in one place.
|
||||
|
||||
[](https://github.com/multica-ai/multica/actions/workflows/ci.yml)
|
||||
[](https://opensource.org/licenses/Apache-2.0)
|
||||
@@ -31,7 +31,7 @@ Turn coding agents into real teammates — assign tasks, track progress, compoun
|
||||
|
||||
Multica turns coding agents into real teammates. Assign issues to an agent like you'd assign to a colleague — they'll pick up the work, write code, report blockers, and update statuses autonomously.
|
||||
|
||||
No more copy-pasting prompts. No more babysitting runs. Your agents show up on the board, participate in conversations, and compound reusable skills over time. Think of it as open-source infrastructure for managed agents — vendor-neutral, self-hosted, and designed for human + AI teams. Works with **Claude Code** and **Codex**.
|
||||
No more copy-pasting prompts. No more babysitting runs. Your agents show up on the board, participate in conversations, and compound reusable skills over time. Works with **Claude Code** and **Codex**.
|
||||
|
||||
<p align="center">
|
||||
<img src="docs/assets/hero-screenshot.png" alt="Multica board view" width="800">
|
||||
@@ -39,8 +39,6 @@ No more copy-pasting prompts. No more babysitting runs. Your agents show up on t
|
||||
|
||||
## Features
|
||||
|
||||
Multica manages the full agent lifecycle: from task assignment to execution monitoring to skill reuse.
|
||||
|
||||
- **Agents as Teammates** — assign to an agent like you'd assign to a colleague. They have profiles, show up on the board, post comments, create issues, and report blockers proactively.
|
||||
- **Autonomous Execution** — set it and forget it. Full task lifecycle management (enqueue, claim, start, complete/fail) with real-time progress streaming via WebSocket.
|
||||
- **Reusable Skills** — every solution becomes a reusable skill for the whole team. Deployments, migrations, code reviews — skills compound your team's capabilities over time.
|
||||
@@ -72,14 +70,6 @@ See the [Self-Hosting Guide](SELF_HOSTING.md) for full instructions.
|
||||
|
||||
The `multica` CLI connects your local machine to Multica — authenticate, manage workspaces, and run the agent daemon.
|
||||
|
||||
**Option A — paste this to your coding agent (Claude Code, Codex, etc.):**
|
||||
|
||||
```
|
||||
Fetch https://github.com/multica-ai/multica/blob/main/CLI_INSTALL.md and follow the instructions to install Multica CLI, log in, and start the daemon on this machine.
|
||||
```
|
||||
|
||||
**Option B — install manually:**
|
||||
|
||||
```bash
|
||||
# Install
|
||||
brew tap multica-ai/tap
|
||||
@@ -158,3 +148,7 @@ make start
|
||||
```
|
||||
|
||||
See [CONTRIBUTING.md](CONTRIBUTING.md) for the full development workflow, worktree support, testing, and troubleshooting.
|
||||
|
||||
## License
|
||||
|
||||
[Apache 2.0](LICENSE)
|
||||
|
||||
@@ -14,8 +14,8 @@
|
||||
|
||||
**你的下一批员工,不是人类。**
|
||||
|
||||
开源的 Managed Agents 平台。<br/>
|
||||
将编码 Agent 变成真正的队友——分配任务、跟踪进度、积累技能。
|
||||
开源平台,将编码 Agent 变成真正的队友。<br/>
|
||||
分配任务、跟踪进度、积累技能——在一个地方管理你的人类 + Agent 团队。
|
||||
|
||||
[](https://github.com/multica-ai/multica/actions/workflows/ci.yml)
|
||||
[](https://opensource.org/licenses/Apache-2.0)
|
||||
@@ -31,7 +31,7 @@
|
||||
|
||||
Multica 将编码 Agent 变成真正的队友。像分配给同事一样分配给 Agent——它们会自主接手工作、编写代码、报告阻塞问题、更新状态。
|
||||
|
||||
不再需要复制粘贴 prompt,不再需要盯着运行过程。你的 Agent 出现在看板上、参与对话、随着时间积累可复用的技能。可以理解为开源的 Managed Agents 基础设施——厂商中立、可自部署、专为人类 + AI 团队设计。支持 **Claude Code** 和 **Codex**。
|
||||
不再需要复制粘贴 prompt,不再需要盯着运行过程。你的 Agent 出现在看板上、参与对话、随着时间积累可复用的技能。支持 **Claude Code** 和 **Codex**。
|
||||
|
||||
<p align="center">
|
||||
<img src="docs/assets/hero-screenshot.png" alt="Multica 看板视图" width="800">
|
||||
@@ -39,8 +39,6 @@ Multica 将编码 Agent 变成真正的队友。像分配给同事一样分配
|
||||
|
||||
## 功能特性
|
||||
|
||||
Multica 管理完整的 Agent 生命周期:从任务分配到执行监控再到技能复用。
|
||||
|
||||
- **Agent 即队友** — 像分配给同事一样分配给 Agent。它们有个人档案、出现在看板上、发表评论、创建 Issue、主动报告阻塞问题。
|
||||
- **自主执行** — 设置后无需管理。完整的任务生命周期管理(排队、认领、执行、完成/失败),通过 WebSocket 实时推送进度。
|
||||
- **可复用技能** — 每个解决方案都成为全团队可复用的技能。部署、数据库迁移、代码审查——技能让团队能力随时间持续增长。
|
||||
@@ -72,14 +70,6 @@ make start # 启动应用
|
||||
|
||||
`multica` CLI 将你的本地机器连接到 Multica — 用于认证、管理工作区和运行 Agent daemon。
|
||||
|
||||
**方式 A — 将以下指令粘贴给你的 coding agent(Claude Code、Codex 等):**
|
||||
|
||||
```
|
||||
Fetch https://github.com/multica-ai/multica/blob/main/CLI_INSTALL.md and follow the instructions to install Multica CLI, log in, and start the daemon on this machine.
|
||||
```
|
||||
|
||||
**方式 B — 手动安装:**
|
||||
|
||||
```bash
|
||||
# 安装
|
||||
brew tap multica-ai/tap
|
||||
|
||||
@@ -257,14 +257,8 @@ Each team member who wants to run AI agents locally needs to:
|
||||
|
||||
```bash
|
||||
# Point CLI to your server
|
||||
#
|
||||
# For production deployments with TLS:
|
||||
export MULTICA_APP_URL=https://app.example.com
|
||||
export MULTICA_SERVER_URL=wss://api.example.com/ws
|
||||
#
|
||||
# For local deployments without TLS:
|
||||
# export MULTICA_APP_URL=http://localhost:3000
|
||||
# export MULTICA_SERVER_URL=ws://localhost:8080/ws
|
||||
|
||||
# Login (opens browser)
|
||||
multica login
|
||||
@@ -273,8 +267,6 @@ Each team member who wants to run AI agents locally needs to:
|
||||
multica daemon start
|
||||
```
|
||||
|
||||
> **Note:** Use `https://` and `wss://` for production deployments behind a TLS-terminating reverse proxy. For local or development deployments without TLS, use `http://` and `ws://` instead.
|
||||
|
||||
The daemon auto-detects installed agent CLIs and registers itself with the server. When an agent is assigned a task in Multica, the daemon picks it up, creates an isolated workspace, runs the agent, and reports results back.
|
||||
|
||||
## Upgrading
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { Suspense, useState, useEffect, useCallback } from "react";
|
||||
import { useSearchParams, useRouter } from "next/navigation";
|
||||
import { useAuthStore, setLoggedInCookie } from "@/features/auth";
|
||||
import { useAuthStore } from "@/features/auth";
|
||||
import { useWorkspaceStore } from "@/features/workspace";
|
||||
import { api } from "@/shared/api";
|
||||
import {
|
||||
@@ -146,10 +146,6 @@ function LoginPageContent() {
|
||||
return;
|
||||
}
|
||||
const { token } = await api.verifyCode(email, value);
|
||||
// Persist session in the browser so the web app stays logged in
|
||||
localStorage.setItem("multica_token", token);
|
||||
api.setToken(token);
|
||||
setLoggedInCookie();
|
||||
const cliState = searchParams.get("cli_state") || "";
|
||||
redirectToCliCallback(cliCallback, token, cliState);
|
||||
return;
|
||||
@@ -286,22 +282,6 @@ function LoginPageContent() {
|
||||
);
|
||||
}
|
||||
|
||||
const googleClientId = process.env.NEXT_PUBLIC_GOOGLE_CLIENT_ID;
|
||||
|
||||
const handleGoogleLogin = () => {
|
||||
if (!googleClientId) return;
|
||||
const redirectUri = `${window.location.origin}/auth/callback`;
|
||||
const params = new URLSearchParams({
|
||||
client_id: googleClientId,
|
||||
redirect_uri: redirectUri,
|
||||
response_type: "code",
|
||||
scope: "openid email profile",
|
||||
access_type: "offline",
|
||||
prompt: "select_account",
|
||||
});
|
||||
window.location.href = `https://accounts.google.com/o/oauth2/v2/auth?${params}`;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center">
|
||||
<Card className="w-full max-w-sm">
|
||||
@@ -327,7 +307,7 @@ function LoginPageContent() {
|
||||
)}
|
||||
</form>
|
||||
</CardContent>
|
||||
<CardFooter className="flex flex-col gap-3">
|
||||
<CardFooter>
|
||||
<Button
|
||||
type="submit"
|
||||
form="login-form"
|
||||
@@ -337,46 +317,6 @@ function LoginPageContent() {
|
||||
>
|
||||
{submitting ? "Sending code..." : "Continue"}
|
||||
</Button>
|
||||
{googleClientId && (
|
||||
<>
|
||||
<div className="relative w-full">
|
||||
<div className="absolute inset-0 flex items-center">
|
||||
<span className="w-full border-t" />
|
||||
</div>
|
||||
<div className="relative flex justify-center text-xs uppercase">
|
||||
<span className="bg-card px-2 text-muted-foreground">or</span>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
size="lg"
|
||||
onClick={handleGoogleLogin}
|
||||
disabled={submitting}
|
||||
>
|
||||
<svg className="mr-2 h-4 w-4" viewBox="0 0 24 24">
|
||||
<path
|
||||
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92a5.06 5.06 0 0 1-2.2 3.32v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.1z"
|
||||
fill="#4285F4"
|
||||
/>
|
||||
<path
|
||||
d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"
|
||||
fill="#34A853"
|
||||
/>
|
||||
<path
|
||||
d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"
|
||||
fill="#FBBC05"
|
||||
/>
|
||||
<path
|
||||
d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"
|
||||
fill="#EA4335"
|
||||
/>
|
||||
</svg>
|
||||
Continue with Google
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import Link from "next/link";
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
import {
|
||||
@@ -43,9 +42,7 @@ import {
|
||||
import { Tooltip, TooltipTrigger, TooltipContent } from "@/components/ui/tooltip";
|
||||
import { useAuthStore } from "@/features/auth";
|
||||
import { useWorkspaceStore } from "@/features/workspace";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { inboxKeys, deduplicateInboxItems } from "@core/inbox/queries";
|
||||
import { api } from "@/shared/api";
|
||||
import { useInboxStore } from "@/features/inbox";
|
||||
import { useModalStore } from "@/features/modals";
|
||||
|
||||
const primaryNav = [
|
||||
@@ -76,16 +73,7 @@ export function AppSidebar() {
|
||||
const workspaces = useWorkspaceStore((s) => s.workspaces);
|
||||
const switchWorkspace = useWorkspaceStore((s) => s.switchWorkspace);
|
||||
|
||||
const wsId = workspace?.id;
|
||||
const { data: inboxItems = [] } = useQuery({
|
||||
queryKey: wsId ? inboxKeys.list(wsId) : ["inbox", "disabled"],
|
||||
queryFn: () => api.listInbox(),
|
||||
enabled: !!wsId,
|
||||
});
|
||||
const unreadCount = React.useMemo(
|
||||
() => deduplicateInboxItems(inboxItems).filter((i) => !i.read).length,
|
||||
[inboxItems],
|
||||
);
|
||||
const unreadCount = useInboxStore((s) => s.unreadCount());
|
||||
|
||||
const logout = () => {
|
||||
router.push("/");
|
||||
@@ -144,7 +132,6 @@ export function AppSidebar() {
|
||||
key={ws.id}
|
||||
onClick={() => {
|
||||
if (ws.id !== workspace?.id) {
|
||||
router.push("/issues");
|
||||
switchWorkspace(ws.id);
|
||||
}
|
||||
}}
|
||||
|
||||
@@ -8,10 +8,15 @@ import {
|
||||
Monitor,
|
||||
Plus,
|
||||
ListTodo,
|
||||
Wrench,
|
||||
FileText,
|
||||
BookOpenText,
|
||||
MessageSquare,
|
||||
Timer,
|
||||
Trash2,
|
||||
Save,
|
||||
Key,
|
||||
Link2,
|
||||
Clock,
|
||||
CheckCircle2,
|
||||
XCircle,
|
||||
@@ -30,6 +35,9 @@ import type {
|
||||
Agent,
|
||||
AgentStatus,
|
||||
AgentVisibility,
|
||||
AgentTool,
|
||||
AgentTrigger,
|
||||
AgentTriggerType,
|
||||
AgentTask,
|
||||
RuntimeDevice,
|
||||
CreateAgentRequest,
|
||||
@@ -67,11 +75,8 @@ import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { api } from "@/shared/api";
|
||||
import { useAuthStore } from "@/features/auth";
|
||||
import { useWorkspaceStore } from "@/features/workspace";
|
||||
import { runtimeListOptions } from "@core/runtimes/queries";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useWorkspaceId } from "@core/hooks";
|
||||
import { issueListOptions } from "@core/issues/queries";
|
||||
import { skillListOptions, agentListOptions, workspaceKeys } from "@core/workspace/queries";
|
||||
import { useRuntimeStore } from "@/features/runtimes";
|
||||
import { useIssueStore } from "@/features/issues";
|
||||
import { ActorAvatar } from "@/components/common/actor-avatar";
|
||||
import { useFileUpload } from "@/shared/hooks/use-file-upload";
|
||||
|
||||
@@ -143,6 +148,10 @@ function CreateAgentDialog({
|
||||
description: description.trim(),
|
||||
runtime_id: selectedRuntime.id,
|
||||
visibility,
|
||||
triggers: [
|
||||
{ id: generateId(), type: "on_assign", enabled: true, config: {} },
|
||||
{ id: generateId(), type: "on_comment", enabled: true, config: {} },
|
||||
],
|
||||
});
|
||||
onClose();
|
||||
} catch (err) {
|
||||
@@ -434,9 +443,8 @@ function SkillsTab({
|
||||
}: {
|
||||
agent: Agent;
|
||||
}) {
|
||||
const qc = useQueryClient();
|
||||
const wsId = useWorkspaceId();
|
||||
const { data: workspaceSkills = [] } = useQuery(skillListOptions(wsId));
|
||||
const workspaceSkills = useWorkspaceStore((s) => s.skills);
|
||||
const refreshAgents = useWorkspaceStore((s) => s.refreshAgents);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [showPicker, setShowPicker] = useState(false);
|
||||
|
||||
@@ -448,7 +456,7 @@ function SkillsTab({
|
||||
try {
|
||||
const newIds = [...agent.skills.map((s) => s.id), skillId];
|
||||
await api.setAgentSkills(agent.id, { skill_ids: newIds });
|
||||
qc.invalidateQueries({ queryKey: workspaceKeys.agents(wsId) });
|
||||
await refreshAgents();
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : "Failed to add skill");
|
||||
} finally {
|
||||
@@ -462,7 +470,7 @@ function SkillsTab({
|
||||
try {
|
||||
const newIds = agent.skills.filter((s) => s.id !== skillId).map((s) => s.id);
|
||||
await api.setAgentSkills(agent.id, { skill_ids: newIds });
|
||||
qc.invalidateQueries({ queryKey: workspaceKeys.agents(wsId) });
|
||||
await refreshAgents();
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : "Failed to remove skill");
|
||||
} finally {
|
||||
@@ -588,6 +596,459 @@ function SkillsTab({
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tools Tab
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function AddToolDialog({
|
||||
onClose,
|
||||
onAdd,
|
||||
}: {
|
||||
onClose: () => void;
|
||||
onAdd: (tool: AgentTool) => void;
|
||||
}) {
|
||||
const [name, setName] = useState("");
|
||||
const [description, setDescription] = useState("");
|
||||
const [authType, setAuthType] = useState<"oauth" | "api_key" | "none">("api_key");
|
||||
|
||||
const handleAdd = () => {
|
||||
if (!name.trim()) return;
|
||||
onAdd({
|
||||
id: generateId(),
|
||||
name: name.trim(),
|
||||
description: description.trim(),
|
||||
auth_type: authType,
|
||||
connected: false,
|
||||
config: {},
|
||||
});
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open onOpenChange={(v) => { if (!v) onClose(); }}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-sm">Add Tool</DialogTitle>
|
||||
<DialogDescription className="text-xs">
|
||||
Connect an external tool for this agent to use.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<Label className="text-xs text-muted-foreground">Tool Name</Label>
|
||||
<Input
|
||||
autoFocus
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="e.g. Google Search, Slack, GitHub"
|
||||
className="mt-1"
|
||||
onKeyDown={(e) => e.key === "Enter" && handleAdd()}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs text-muted-foreground">Description</Label>
|
||||
<Input
|
||||
type="text"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder="What does this tool do?"
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs text-muted-foreground">Authentication</Label>
|
||||
<div className="mt-1.5 flex gap-2">
|
||||
{(["api_key", "oauth", "none"] as const).map((type) => (
|
||||
<Button
|
||||
key={type}
|
||||
variant={authType === type ? "outline" : "ghost"}
|
||||
size="xs"
|
||||
onClick={() => setAuthType(type)}
|
||||
className={`flex-1 ${
|
||||
authType === type
|
||||
? "border-primary bg-primary/5 font-medium"
|
||||
: ""
|
||||
}`}
|
||||
>
|
||||
{type === "api_key" ? "API Key" : type === "oauth" ? "OAuth" : "None"}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="ghost" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleAdd}
|
||||
disabled={!name.trim()}
|
||||
>
|
||||
Add
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function ToolsTab({
|
||||
agent,
|
||||
onSave,
|
||||
}: {
|
||||
agent: Agent;
|
||||
onSave: (tools: AgentTool[]) => Promise<void>;
|
||||
}) {
|
||||
const [tools, setTools] = useState<AgentTool[]>(agent.tools ?? []);
|
||||
const [showAdd, setShowAdd] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setTools(agent.tools ?? []);
|
||||
}, [agent.id, agent.tools]);
|
||||
|
||||
const isDirty = JSON.stringify(tools) !== JSON.stringify(agent.tools ?? []);
|
||||
|
||||
const handleSave = async () => {
|
||||
setSaving(true);
|
||||
try {
|
||||
await onSave(tools);
|
||||
} catch {
|
||||
// toast handled by parent
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleConnect = (toolId: string) => {
|
||||
setTools((prev) =>
|
||||
prev.map((t) => (t.id === toolId ? { ...t, connected: !t.connected } : t)),
|
||||
);
|
||||
};
|
||||
|
||||
const removeTool = (toolId: string) => {
|
||||
setTools((prev) => prev.filter((t) => t.id !== toolId));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold">Tools</h3>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
External tools and APIs this agent can use during task execution.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{isDirty && (
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
disabled={saving}
|
||||
size="xs"
|
||||
>
|
||||
<Save className="h-3 w-3" />
|
||||
{saving ? "Saving..." : "Save"}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="xs"
|
||||
onClick={() => setShowAdd(true)}
|
||||
>
|
||||
<Plus className="h-3 w-3" />
|
||||
Add Tool
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{tools.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center rounded-lg border border-dashed py-12">
|
||||
<Wrench className="h-8 w-8 text-muted-foreground/40" />
|
||||
<p className="mt-3 text-sm text-muted-foreground">No tools configured</p>
|
||||
<Button
|
||||
onClick={() => setShowAdd(true)}
|
||||
size="xs"
|
||||
className="mt-3"
|
||||
>
|
||||
<Plus className="h-3 w-3" />
|
||||
Add Tool
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{tools.map((tool) => (
|
||||
<div
|
||||
key={tool.id}
|
||||
className="flex items-center gap-3 rounded-lg border px-4 py-3"
|
||||
>
|
||||
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-muted">
|
||||
{tool.auth_type === "oauth" ? (
|
||||
<Link2 className="h-4 w-4 text-muted-foreground" />
|
||||
) : tool.auth_type === "api_key" ? (
|
||||
<Key className="h-4 w-4 text-muted-foreground" />
|
||||
) : (
|
||||
<Wrench className="h-4 w-4 text-muted-foreground" />
|
||||
)}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-sm font-medium">{tool.name}</div>
|
||||
{tool.description && (
|
||||
<div className="text-xs text-muted-foreground truncate">
|
||||
{tool.description}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
onClick={() => toggleConnect(tool.id)}
|
||||
className={
|
||||
tool.connected
|
||||
? "bg-success/10 text-success"
|
||||
: "bg-muted text-muted-foreground hover:bg-accent"
|
||||
}
|
||||
>
|
||||
{tool.connected ? "Connected" : "Connect"}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
onClick={() => removeTool(tool.id)}
|
||||
className="text-muted-foreground hover:text-destructive"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showAdd && (
|
||||
<AddToolDialog
|
||||
onClose={() => setShowAdd(false)}
|
||||
onAdd={(tool) => setTools((prev) => [...prev, tool])}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Triggers Tab
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function TriggersTab({
|
||||
agent,
|
||||
onSave,
|
||||
}: {
|
||||
agent: Agent;
|
||||
onSave: (triggers: AgentTrigger[]) => Promise<void>;
|
||||
}) {
|
||||
const [triggers, setTriggers] = useState<AgentTrigger[]>(agent.triggers ?? []);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setTriggers(agent.triggers ?? []);
|
||||
}, [agent.id, agent.triggers]);
|
||||
|
||||
const isDirty = JSON.stringify(triggers) !== JSON.stringify(agent.triggers ?? []);
|
||||
|
||||
const handleSave = async () => {
|
||||
setSaving(true);
|
||||
try {
|
||||
await onSave(triggers);
|
||||
} catch {
|
||||
// toast handled by parent
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleTrigger = (triggerId: string) => {
|
||||
setTriggers((prev) =>
|
||||
prev.map((t) => (t.id === triggerId ? { ...t, enabled: !t.enabled } : t)),
|
||||
);
|
||||
};
|
||||
|
||||
const removeTrigger = (triggerId: string) => {
|
||||
setTriggers((prev) => prev.filter((t) => t.id !== triggerId));
|
||||
};
|
||||
|
||||
const addTrigger = (type: AgentTriggerType) => {
|
||||
const newTrigger: AgentTrigger = {
|
||||
id: generateId(),
|
||||
type,
|
||||
enabled: true,
|
||||
config: type === "scheduled" ? { cron: "0 9 * * 1-5", timezone: "UTC" } : {},
|
||||
};
|
||||
setTriggers((prev) => [...prev, newTrigger]);
|
||||
};
|
||||
|
||||
const updateTriggerConfig = (triggerId: string, config: Record<string, unknown>) => {
|
||||
setTriggers((prev) =>
|
||||
prev.map((t) => (t.id === triggerId ? { ...t, config } : t)),
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold">Triggers</h3>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
Configure when this agent should start working.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{isDirty && (
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
disabled={saving}
|
||||
size="xs"
|
||||
>
|
||||
<Save className="h-3 w-3" />
|
||||
{saving ? "Saving..." : "Save"}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
{triggers.map((trigger) => (
|
||||
<div
|
||||
key={trigger.id}
|
||||
className="rounded-lg border px-4 py-3"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-muted">
|
||||
{trigger.type === "on_assign" ? (
|
||||
<Bot className="h-4 w-4 text-muted-foreground" />
|
||||
) : trigger.type === "on_comment" ? (
|
||||
<MessageSquare className="h-4 w-4 text-muted-foreground" />
|
||||
) : (
|
||||
<Timer className="h-4 w-4 text-muted-foreground" />
|
||||
)}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-sm font-medium">
|
||||
{trigger.type === "on_assign"
|
||||
? "On Issue Assign"
|
||||
: trigger.type === "on_comment"
|
||||
? "On Comment"
|
||||
: "Scheduled"}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{trigger.type === "on_assign"
|
||||
? "Runs when an issue is assigned to this agent"
|
||||
: trigger.type === "on_comment"
|
||||
? "Runs when a member comments on the agent's issue"
|
||||
: `Cron: ${(trigger.config as { cron?: string }).cron ?? "Not set"}`}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => toggleTrigger(trigger.id)}
|
||||
className={`relative h-5 w-9 rounded-full transition-colors ${
|
||||
trigger.enabled ? "bg-primary" : "bg-muted"
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`absolute top-0.5 h-4 w-4 rounded-full bg-white shadow-sm transition-transform ${
|
||||
trigger.enabled ? "left-4.5" : "left-0.5"
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
onClick={() => removeTrigger(trigger.id)}
|
||||
className="text-muted-foreground hover:text-destructive"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{trigger.type === "scheduled" && (
|
||||
<div className="mt-3 grid grid-cols-2 gap-3 pl-12">
|
||||
<div>
|
||||
<Label className="text-xs text-muted-foreground">
|
||||
Cron Expression
|
||||
</Label>
|
||||
<Input
|
||||
type="text"
|
||||
value={(trigger.config as { cron?: string }).cron ?? ""}
|
||||
onChange={(e) =>
|
||||
updateTriggerConfig(trigger.id, {
|
||||
...trigger.config,
|
||||
cron: e.target.value,
|
||||
})
|
||||
}
|
||||
placeholder="0 9 * * 1-5"
|
||||
className="mt-1 text-xs font-mono"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs text-muted-foreground">
|
||||
Timezone
|
||||
</Label>
|
||||
<Input
|
||||
type="text"
|
||||
value={(trigger.config as { timezone?: string }).timezone ?? ""}
|
||||
onChange={(e) =>
|
||||
updateTriggerConfig(trigger.id, {
|
||||
...trigger.config,
|
||||
timezone: e.target.value,
|
||||
})
|
||||
}
|
||||
placeholder="UTC"
|
||||
className="mt-1 text-xs"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="xs"
|
||||
onClick={() => addTrigger("on_assign")}
|
||||
className="border-dashed text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<Bot className="h-3 w-3" />
|
||||
Add On Assign
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="xs"
|
||||
onClick={() => addTrigger("on_comment")}
|
||||
className="border-dashed text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<MessageSquare className="h-3 w-3" />
|
||||
Add On Comment
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="xs"
|
||||
onClick={() => addTrigger("scheduled")}
|
||||
className="border-dashed text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<Timer className="h-3 w-3" />
|
||||
Add Scheduled
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tasks Tab
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -595,8 +1056,7 @@ function SkillsTab({
|
||||
function TasksTab({ agent }: { agent: Agent }) {
|
||||
const [tasks, setTasks] = useState<AgentTask[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const wsId = useWorkspaceId();
|
||||
const { data: issues = [] } = useQuery(issueListOptions(wsId));
|
||||
const issues = useIssueStore((s) => s.issues);
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(true);
|
||||
@@ -899,11 +1359,13 @@ function SettingsTab({
|
||||
// Agent Detail
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type DetailTab = "instructions" | "skills" | "tasks" | "settings";
|
||||
type DetailTab = "instructions" | "skills" | "tools" | "triggers" | "tasks" | "settings";
|
||||
|
||||
const detailTabs: { id: DetailTab; label: string; icon: typeof FileText }[] = [
|
||||
{ id: "instructions", label: "Instructions", icon: FileText },
|
||||
{ id: "skills", label: "Skills", icon: BookOpenText },
|
||||
{ id: "tools", label: "Tools", icon: Wrench },
|
||||
{ id: "triggers", label: "Triggers", icon: Timer },
|
||||
{ id: "tasks", label: "Tasks", icon: ListTodo },
|
||||
{ id: "settings", label: "Settings", icon: Settings },
|
||||
];
|
||||
@@ -1017,6 +1479,18 @@ function AgentDetail({
|
||||
{activeTab === "skills" && (
|
||||
<SkillsTab agent={agent} />
|
||||
)}
|
||||
{activeTab === "tools" && (
|
||||
<ToolsTab
|
||||
agent={agent}
|
||||
onSave={(tools) => onUpdate(agent.id, { tools })}
|
||||
/>
|
||||
)}
|
||||
{activeTab === "triggers" && (
|
||||
<TriggersTab
|
||||
agent={agent}
|
||||
onSave={(triggers) => onUpdate(agent.id, { triggers })}
|
||||
/>
|
||||
)}
|
||||
{activeTab === "tasks" && <TasksTab agent={agent} />}
|
||||
{activeTab === "settings" && (
|
||||
<SettingsTab
|
||||
@@ -1070,17 +1544,21 @@ function AgentDetail({
|
||||
export default function AgentsPage() {
|
||||
const isLoading = useAuthStore((s) => s.isLoading);
|
||||
const workspace = useWorkspaceStore((s) => s.workspace);
|
||||
const qc = useQueryClient();
|
||||
const wsId = useWorkspaceId();
|
||||
const { data: agents = [] } = useQuery(agentListOptions(wsId));
|
||||
const agents = useWorkspaceStore((s) => s.agents);
|
||||
const refreshAgents = useWorkspaceStore((s) => s.refreshAgents);
|
||||
const [selectedId, setSelectedId] = useState<string>("");
|
||||
const [showArchived, setShowArchived] = useState(false);
|
||||
const [showCreate, setShowCreate] = useState(false);
|
||||
const { data: runtimes = [] } = useQuery(runtimeListOptions(wsId));
|
||||
const runtimes = useRuntimeStore((s) => s.runtimes);
|
||||
const fetchRuntimes = useRuntimeStore((s) => s.fetchRuntimes);
|
||||
const { defaultLayout, onLayoutChanged } = useDefaultLayout({
|
||||
id: "multica_agents_layout",
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (workspace) fetchRuntimes();
|
||||
}, [workspace, fetchRuntimes]);
|
||||
|
||||
const filteredAgents = useMemo(
|
||||
() => showArchived ? agents.filter((a) => !!a.archived_at) : agents.filter((a) => !a.archived_at),
|
||||
[agents, showArchived],
|
||||
@@ -1097,14 +1575,14 @@ export default function AgentsPage() {
|
||||
|
||||
const handleCreate = async (data: CreateAgentRequest) => {
|
||||
const agent = await api.createAgent(data);
|
||||
qc.invalidateQueries({ queryKey: workspaceKeys.agents(wsId) });
|
||||
await refreshAgents();
|
||||
setSelectedId(agent.id);
|
||||
};
|
||||
|
||||
const handleUpdate = async (id: string, data: Record<string, unknown>) => {
|
||||
try {
|
||||
await api.updateAgent(id, data as UpdateAgentRequest);
|
||||
qc.invalidateQueries({ queryKey: workspaceKeys.agents(wsId) });
|
||||
await refreshAgents();
|
||||
toast.success("Agent updated");
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : "Failed to update agent");
|
||||
@@ -1115,7 +1593,7 @@ export default function AgentsPage() {
|
||||
const handleArchive = async (id: string) => {
|
||||
try {
|
||||
await api.archiveAgent(id);
|
||||
qc.invalidateQueries({ queryKey: workspaceKeys.agents(wsId) });
|
||||
await refreshAgents();
|
||||
toast.success("Agent archived");
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : "Failed to archive agent");
|
||||
@@ -1125,7 +1603,7 @@ export default function AgentsPage() {
|
||||
const handleRestore = async (id: string) => {
|
||||
try {
|
||||
await api.restoreAgent(id);
|
||||
qc.invalidateQueries({ queryKey: workspaceKeys.agents(wsId) });
|
||||
await refreshAgents();
|
||||
toast.success("Agent restored");
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : "Failed to restore agent");
|
||||
|
||||
@@ -1,22 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback, useMemo } from "react";
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { useDefaultLayout } from "react-resizable-panels";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useWorkspaceId } from "@core/hooks";
|
||||
import {
|
||||
inboxListOptions,
|
||||
deduplicateInboxItems,
|
||||
} from "@core/inbox/queries";
|
||||
import {
|
||||
useMarkInboxRead,
|
||||
useArchiveInbox,
|
||||
useMarkAllInboxRead,
|
||||
useArchiveAllInbox,
|
||||
useArchiveAllReadInbox,
|
||||
useArchiveCompletedInbox,
|
||||
} from "@core/inbox/mutations";
|
||||
import { useInboxStore } from "@/features/inbox";
|
||||
import { IssueDetail, StatusIcon, PriorityIcon } from "@/features/issues/components";
|
||||
import { STATUS_CONFIG, PRIORITY_CONFIG } from "@/features/issues/config";
|
||||
import { useActorName } from "@/features/workspace";
|
||||
@@ -46,6 +33,7 @@ import {
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { api } from "@/shared/api";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
@@ -247,9 +235,8 @@ export default function InboxPage() {
|
||||
window.history.replaceState(null, "", url);
|
||||
}, []);
|
||||
|
||||
const wsId = useWorkspaceId();
|
||||
const { data: rawItems = [], isLoading: loading } = useQuery(inboxListOptions(wsId));
|
||||
const items = useMemo(() => deduplicateInboxItems(rawItems), [rawItems]);
|
||||
const items = useInboxStore((s) => s.dedupedItems());
|
||||
const loading = useInboxStore((s) => s.loading);
|
||||
|
||||
const { defaultLayout, onLayoutChanged } = useDefaultLayout({
|
||||
id: "multica_inbox_layout",
|
||||
@@ -258,58 +245,74 @@ export default function InboxPage() {
|
||||
const selected = items.find((i) => (i.issue_id ?? i.id) === selectedKey) ?? null;
|
||||
const unreadCount = items.filter((i) => !i.read).length;
|
||||
|
||||
const markReadMutation = useMarkInboxRead();
|
||||
const archiveMutation = useArchiveInbox();
|
||||
const markAllReadMutation = useMarkAllInboxRead();
|
||||
const archiveAllMutation = useArchiveAllInbox();
|
||||
const archiveAllReadMutation = useArchiveAllReadInbox();
|
||||
const archiveCompletedMutation = useArchiveCompletedInbox();
|
||||
|
||||
// Click-to-read: select + auto-mark-read
|
||||
const handleSelect = (item: InboxItem) => {
|
||||
const handleSelect = async (item: InboxItem) => {
|
||||
setSelectedKey(item.issue_id ?? item.id);
|
||||
if (!item.read) {
|
||||
markReadMutation.mutate(item.id, {
|
||||
onError: () => toast.error("Failed to mark as read"),
|
||||
});
|
||||
useInboxStore.getState().markRead(item.id);
|
||||
try {
|
||||
await api.markInboxRead(item.id);
|
||||
} catch {
|
||||
// Rollback: refetch to get server truth
|
||||
useInboxStore.getState().fetch();
|
||||
toast.error("Failed to mark as read");
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleArchive = (id: string) => {
|
||||
const archived = items.find((i) => i.id === id);
|
||||
if (archived && (archived.issue_id ?? archived.id) === selectedKey) setSelectedKey("");
|
||||
archiveMutation.mutate(id, {
|
||||
onError: () => toast.error("Failed to archive"),
|
||||
});
|
||||
const handleArchive = async (id: string) => {
|
||||
try {
|
||||
await api.archiveInbox(id);
|
||||
useInboxStore.getState().archive(id);
|
||||
const archived = items.find((i) => i.id === id);
|
||||
if (archived && (archived.issue_id ?? archived.id) === selectedKey) setSelectedKey("");
|
||||
} catch {
|
||||
toast.error("Failed to archive");
|
||||
}
|
||||
};
|
||||
|
||||
// Batch operations
|
||||
const handleMarkAllRead = () => {
|
||||
markAllReadMutation.mutate(undefined, {
|
||||
onError: () => toast.error("Failed to mark all as read"),
|
||||
});
|
||||
const handleMarkAllRead = async () => {
|
||||
try {
|
||||
useInboxStore.getState().markAllRead();
|
||||
await api.markAllInboxRead();
|
||||
} catch {
|
||||
toast.error("Failed to mark all as read");
|
||||
useInboxStore.getState().fetch();
|
||||
}
|
||||
};
|
||||
|
||||
const handleArchiveAll = () => {
|
||||
setSelectedKey("");
|
||||
archiveAllMutation.mutate(undefined, {
|
||||
onError: () => toast.error("Failed to archive all"),
|
||||
});
|
||||
const handleArchiveAll = async () => {
|
||||
try {
|
||||
useInboxStore.getState().archiveAll();
|
||||
setSelectedKey("");
|
||||
await api.archiveAllInbox();
|
||||
} catch {
|
||||
toast.error("Failed to archive all");
|
||||
useInboxStore.getState().fetch();
|
||||
}
|
||||
};
|
||||
|
||||
const handleArchiveAllRead = () => {
|
||||
const readKeys = items.filter((i) => i.read).map((i) => i.issue_id ?? i.id);
|
||||
if (readKeys.includes(selectedKey)) setSelectedKey("");
|
||||
archiveAllReadMutation.mutate(undefined, {
|
||||
onError: () => toast.error("Failed to archive read items"),
|
||||
});
|
||||
const handleArchiveAllRead = async () => {
|
||||
try {
|
||||
const readKeys = items.filter((i) => i.read).map((i) => i.issue_id ?? i.id);
|
||||
useInboxStore.getState().archiveAllRead();
|
||||
if (readKeys.includes(selectedKey)) setSelectedKey("");
|
||||
await api.archiveAllReadInbox();
|
||||
} catch {
|
||||
toast.error("Failed to archive read items");
|
||||
useInboxStore.getState().fetch();
|
||||
}
|
||||
};
|
||||
|
||||
const handleArchiveCompleted = () => {
|
||||
setSelectedKey("");
|
||||
archiveCompletedMutation.mutate(undefined, {
|
||||
onError: () => toast.error("Failed to archive completed"),
|
||||
});
|
||||
const handleArchiveCompleted = async () => {
|
||||
try {
|
||||
await api.archiveCompletedInbox();
|
||||
setSelectedKey("");
|
||||
await useInboxStore.getState().fetch();
|
||||
} catch {
|
||||
toast.error("Failed to archive completed");
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
|
||||
@@ -2,7 +2,6 @@ import { Suspense, forwardRef, useRef, useState, useImperativeHandle } from "rea
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, waitFor, act, fireEvent } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import type { Issue, Comment, TimelineEntry } from "@/shared/types";
|
||||
|
||||
// Mock next/navigation
|
||||
@@ -63,11 +62,34 @@ vi.mock("@/features/workspace", () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock issue store — only client state remains (activeIssueId)
|
||||
// Mock issue store — supply a stable full issue object so storeIssue
|
||||
// doesn't create a new reference each render (avoids infinite effect loop)
|
||||
// and has all required fields for rendering.
|
||||
const stableStoreIssues = vi.hoisted(() => [
|
||||
{
|
||||
id: "issue-1",
|
||||
workspace_id: "ws-1",
|
||||
number: 1,
|
||||
identifier: "TES-1",
|
||||
title: "Implement authentication",
|
||||
description: "Add JWT auth to the backend",
|
||||
status: "in_progress",
|
||||
priority: "high",
|
||||
assignee_type: "member",
|
||||
assignee_id: "user-1",
|
||||
creator_type: "member",
|
||||
creator_id: "user-1",
|
||||
parent_issue_id: null,
|
||||
position: 0,
|
||||
due_date: "2026-06-01T00:00:00Z",
|
||||
created_at: "2026-01-15T00:00:00Z",
|
||||
updated_at: "2026-01-20T00:00:00Z",
|
||||
},
|
||||
]);
|
||||
vi.mock("@/features/issues", () => ({
|
||||
useIssueStore: Object.assign(
|
||||
(selector: (s: any) => any) => selector({ activeIssueId: null }),
|
||||
{ getState: () => ({ activeIssueId: null, setActiveIssue: vi.fn() }) },
|
||||
(selector: (s: any) => any) => selector({ issues: stableStoreIssues }),
|
||||
{ getState: () => ({ issues: stableStoreIssues, addIssue: vi.fn(), updateIssue: vi.fn(), removeIssue: vi.fn() }) },
|
||||
),
|
||||
}));
|
||||
|
||||
@@ -84,9 +106,6 @@ vi.mock("@/components/ui/calendar", () => ({
|
||||
|
||||
// Mock ContentEditor (Tiptap needs real DOM)
|
||||
vi.mock("@/features/editor", () => ({
|
||||
ReadonlyContent: ({ content }: { content: string }) => (
|
||||
<div data-testid="readonly-content">{content}</div>
|
||||
),
|
||||
ContentEditor: forwardRef(({ defaultValue, onUpdate, placeholder, onSubmit }: any, ref: any) => {
|
||||
const valueRef = useRef(defaultValue || "");
|
||||
const [value, setValue] = useState(defaultValue || "");
|
||||
@@ -163,10 +182,9 @@ vi.mock("@/shared/api", () => ({
|
||||
listIssueSubscribers: vi.fn().mockResolvedValue([]),
|
||||
subscribeToIssue: vi.fn().mockResolvedValue(undefined),
|
||||
unsubscribeFromIssue: vi.fn().mockResolvedValue(undefined),
|
||||
getActiveTasksForIssue: vi.fn().mockResolvedValue({ tasks: [] }),
|
||||
getActiveTaskForIssue: vi.fn().mockResolvedValue({ task: null }),
|
||||
listTasksByIssue: vi.fn().mockResolvedValue([]),
|
||||
listTaskMessages: vi.fn().mockResolvedValue([]),
|
||||
listChildIssues: vi.fn().mockResolvedValue({ issues: [] }),
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -217,26 +235,14 @@ const mockTimeline: TimelineEntry[] = [
|
||||
|
||||
import IssueDetailPage from "./page";
|
||||
|
||||
function createTestQueryClient() {
|
||||
return new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: false, gcTime: 0 },
|
||||
mutations: { retry: false },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// React 19 use(Promise) needs the promise to resolve within act + Suspense
|
||||
async function renderPage(id = "issue-1") {
|
||||
const queryClient = createTestQueryClient();
|
||||
let result: ReturnType<typeof render>;
|
||||
await act(async () => {
|
||||
result = render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<Suspense fallback={<div>Suspense loading...</div>}>
|
||||
<IssueDetailPage params={Promise.resolve({ id })} />
|
||||
</Suspense>
|
||||
</QueryClientProvider>,
|
||||
<Suspense fallback={<div>Suspense loading...</div>}>
|
||||
<IssueDetailPage params={Promise.resolve({ id })} />
|
||||
</Suspense>,
|
||||
);
|
||||
});
|
||||
return result!;
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import type { Issue } from "@/shared/types";
|
||||
|
||||
// Mock next/navigation
|
||||
@@ -62,28 +61,36 @@ vi.mock("sonner", () => ({
|
||||
|
||||
// Mock api
|
||||
const mockUpdateIssue = vi.fn();
|
||||
const mockListIssues = vi.hoisted(() => vi.fn().mockResolvedValue({ issues: [], total: 0 }));
|
||||
|
||||
vi.mock("@/shared/api", () => ({
|
||||
api: {
|
||||
listIssues: (...args: any[]) => mockListIssues(...args),
|
||||
listIssues: vi.fn().mockResolvedValue({ issues: [], total: 0 }),
|
||||
updateIssue: (...args: any[]) => mockUpdateIssue(...args),
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock issue store — only client state remains
|
||||
const mockIssueClientState = { activeIssueId: null, setActiveIssue: vi.fn() };
|
||||
// Mock the issue store
|
||||
let mockStoreState: {
|
||||
issues: Issue[];
|
||||
loading: boolean;
|
||||
fetch: () => Promise<void>;
|
||||
setIssues: (issues: Issue[]) => void;
|
||||
addIssue: (issue: Issue) => void;
|
||||
updateIssue: (id: string, updates: Partial<Issue>) => void;
|
||||
removeIssue: (id: string) => void;
|
||||
};
|
||||
|
||||
vi.mock("@/features/issues/store", () => ({
|
||||
useIssueStore: Object.assign(
|
||||
(selector?: any) => (selector ? selector(mockIssueClientState) : mockIssueClientState),
|
||||
{ getState: () => mockIssueClientState },
|
||||
(selector?: any) => (selector ? selector(mockStoreState) : mockStoreState),
|
||||
{ getState: () => mockStoreState },
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("@/features/issues", () => ({
|
||||
useIssueStore: Object.assign(
|
||||
(selector?: any) => (selector ? selector(mockIssueClientState) : mockIssueClientState),
|
||||
{ getState: () => mockIssueClientState },
|
||||
(selector?: any) => (selector ? selector(mockStoreState) : mockStoreState),
|
||||
{ getState: () => mockStoreState },
|
||||
),
|
||||
StatusIcon: () => null,
|
||||
PriorityIcon: () => null,
|
||||
@@ -275,80 +282,90 @@ const mockIssues: Issue[] = [
|
||||
|
||||
import IssuesPage from "./page";
|
||||
|
||||
function renderWithQuery(ui: React.ReactElement) {
|
||||
const qc = new QueryClient({ defaultOptions: { queries: { retry: false, gcTime: 0 }, mutations: { retry: false } } });
|
||||
return render(<QueryClientProvider client={qc}>{ui}</QueryClientProvider>);
|
||||
}
|
||||
|
||||
describe("IssuesPage", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockListIssues.mockResolvedValue({ issues: [], total: 0 });
|
||||
mockStoreState = {
|
||||
issues: [],
|
||||
loading: true,
|
||||
fetch: vi.fn(),
|
||||
setIssues: vi.fn(),
|
||||
addIssue: vi.fn(),
|
||||
updateIssue: vi.fn(),
|
||||
removeIssue: vi.fn(),
|
||||
};
|
||||
mockViewState.viewMode = "board";
|
||||
mockViewState.statusFilters = [];
|
||||
mockViewState.priorityFilters = [];
|
||||
});
|
||||
|
||||
it("shows loading state initially", () => {
|
||||
renderWithQuery(<IssuesPage />);
|
||||
mockStoreState.loading = true;
|
||||
mockStoreState.issues = [];
|
||||
render(<IssuesPage />);
|
||||
expect(screen.getAllByRole("generic").some(el => el.getAttribute("data-slot") === "skeleton")).toBe(true);
|
||||
});
|
||||
|
||||
it("renders issues in board view after loading", async () => {
|
||||
// issueListOptions makes 2 calls: open_only + closed page. Return issues for open, empty for closed.
|
||||
mockListIssues.mockImplementation((params: any) =>
|
||||
Promise.resolve(params?.open_only ? { issues: mockIssues, total: mockIssues.length } : { issues: [], total: 0 }),
|
||||
);
|
||||
mockStoreState.loading = false;
|
||||
mockStoreState.issues = mockIssues;
|
||||
|
||||
renderWithQuery(<IssuesPage />);
|
||||
render(<IssuesPage />);
|
||||
|
||||
await screen.findByText("Implement auth");
|
||||
expect(screen.getByText("Implement auth")).toBeInTheDocument();
|
||||
expect(screen.getByText("Design landing page")).toBeInTheDocument();
|
||||
expect(screen.getByText("Write tests")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders board columns", async () => {
|
||||
mockListIssues.mockImplementation((params: any) =>
|
||||
Promise.resolve(params?.open_only ? { issues: mockIssues, total: mockIssues.length } : { issues: [], total: 0 }),
|
||||
);
|
||||
mockStoreState.loading = false;
|
||||
mockStoreState.issues = mockIssues;
|
||||
|
||||
renderWithQuery(<IssuesPage />);
|
||||
render(<IssuesPage />);
|
||||
|
||||
await screen.findByText("Backlog");
|
||||
expect(screen.getAllByText("Backlog").length).toBeGreaterThanOrEqual(1);
|
||||
expect(screen.getAllByText("Todo").length).toBeGreaterThanOrEqual(1);
|
||||
expect(screen.getAllByText("In Progress").length).toBeGreaterThanOrEqual(1);
|
||||
expect(screen.getAllByText("In Review").length).toBeGreaterThanOrEqual(1);
|
||||
expect(screen.getAllByText("Done").length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
it("shows workspace breadcrumb", async () => {
|
||||
renderWithQuery(<IssuesPage />);
|
||||
it("shows workspace breadcrumb", () => {
|
||||
mockStoreState.loading = false;
|
||||
mockStoreState.issues = [];
|
||||
|
||||
await screen.findByText("Issues");
|
||||
render(<IssuesPage />);
|
||||
|
||||
expect(screen.getByText("Issues")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows scope buttons", async () => {
|
||||
renderWithQuery(<IssuesPage />);
|
||||
it("shows scope buttons", () => {
|
||||
mockStoreState.loading = false;
|
||||
mockStoreState.issues = [];
|
||||
|
||||
await screen.findByText("All");
|
||||
render(<IssuesPage />);
|
||||
|
||||
expect(screen.getByText("All")).toBeInTheDocument();
|
||||
expect(screen.getByText("Members")).toBeInTheDocument();
|
||||
expect(screen.getByText("Agents")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows filter and display icon buttons", async () => {
|
||||
mockListIssues.mockImplementation((params: any) =>
|
||||
Promise.resolve(params?.open_only ? { issues: mockIssues, total: mockIssues.length } : { issues: [], total: 0 }),
|
||||
);
|
||||
it("shows filter and display icon buttons", () => {
|
||||
mockStoreState.loading = false;
|
||||
mockStoreState.issues = mockIssues;
|
||||
|
||||
renderWithQuery(<IssuesPage />);
|
||||
render(<IssuesPage />);
|
||||
|
||||
await screen.findByText("Implement auth");
|
||||
// Filter and Display are now icon-only buttons, verify they render as buttons
|
||||
const buttons = screen.getAllByRole("button");
|
||||
expect(buttons.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("shows empty board view when no issues exist", () => {
|
||||
renderWithQuery(<IssuesPage />);
|
||||
mockStoreState.loading = false;
|
||||
mockStoreState.issues = [];
|
||||
|
||||
render(<IssuesPage />);
|
||||
|
||||
// Should still render the board/list view, not a "no issues" message
|
||||
expect(screen.queryByText("No matching issues")).not.toBeInTheDocument();
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
|
||||
export default function DashboardLoading() {
|
||||
return (
|
||||
<div className="flex flex-1 min-h-0 flex-col">
|
||||
{/* Header skeleton */}
|
||||
<div className="flex h-12 shrink-0 items-center gap-2 border-b px-4">
|
||||
<Skeleton className="h-5 w-5 rounded" />
|
||||
<Skeleton className="h-4 w-32" />
|
||||
</div>
|
||||
{/* Toolbar skeleton */}
|
||||
<div className="flex h-12 shrink-0 items-center justify-between border-b px-4">
|
||||
<Skeleton className="h-5 w-24" />
|
||||
<Skeleton className="h-8 w-24" />
|
||||
</div>
|
||||
{/* Content skeleton */}
|
||||
<div className="flex-1 p-4 space-y-3">
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<div key={i} className="flex items-center gap-3">
|
||||
<Skeleton className="h-4 w-4 rounded" />
|
||||
<Skeleton className="h-4 flex-1 max-w-md" />
|
||||
<Skeleton className="h-4 w-16" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -36,11 +36,8 @@ import {
|
||||
DropdownMenuSubContent,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { toast } from "sonner";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useAuthStore } from "@/features/auth";
|
||||
import { useWorkspaceStore } from "@/features/workspace";
|
||||
import { useWorkspaceId } from "@core/hooks";
|
||||
import { memberListOptions, workspaceKeys } from "@core/workspace/queries";
|
||||
import { api } from "@/shared/api";
|
||||
|
||||
const roleConfig: Record<MemberRole, { label: string; icon: typeof Crown; description: string }> = {
|
||||
@@ -143,9 +140,8 @@ function MemberRow({
|
||||
export function MembersTab() {
|
||||
const user = useAuthStore((s) => s.user);
|
||||
const workspace = useWorkspaceStore((s) => s.workspace);
|
||||
const qc = useQueryClient();
|
||||
const wsId = useWorkspaceId();
|
||||
const { data: members = [] } = useQuery(memberListOptions(wsId));
|
||||
const members = useWorkspaceStore((s) => s.members);
|
||||
const refreshMembers = useWorkspaceStore((s) => s.refreshMembers);
|
||||
|
||||
const [inviteEmail, setInviteEmail] = useState("");
|
||||
const [inviteRole, setInviteRole] = useState<MemberRole>("member");
|
||||
@@ -172,7 +168,7 @@ export function MembersTab() {
|
||||
});
|
||||
setInviteEmail("");
|
||||
setInviteRole("member");
|
||||
qc.invalidateQueries({ queryKey: workspaceKeys.members(wsId) });
|
||||
await refreshMembers();
|
||||
toast.success("Member added");
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : "Failed to add member");
|
||||
@@ -186,7 +182,7 @@ export function MembersTab() {
|
||||
setMemberActionId(memberId);
|
||||
try {
|
||||
await api.updateMember(workspace.id, memberId, { role });
|
||||
qc.invalidateQueries({ queryKey: workspaceKeys.members(wsId) });
|
||||
await refreshMembers();
|
||||
toast.success("Role updated");
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : "Failed to update member");
|
||||
@@ -205,7 +201,7 @@ export function MembersTab() {
|
||||
setMemberActionId(member.id);
|
||||
try {
|
||||
await api.deleteMember(workspace.id, member.id);
|
||||
qc.invalidateQueries({ queryKey: workspaceKeys.members(wsId) });
|
||||
await refreshMembers();
|
||||
toast.success("Member removed");
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : "Failed to remove member");
|
||||
|
||||
@@ -6,19 +6,15 @@ import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { toast } from "sonner";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useAuthStore } from "@/features/auth";
|
||||
import { useWorkspaceStore } from "@/features/workspace";
|
||||
import { useWorkspaceId } from "@core/hooks";
|
||||
import { memberListOptions } from "@core/workspace/queries";
|
||||
import { api } from "@/shared/api";
|
||||
import type { WorkspaceRepo } from "@/shared/types";
|
||||
|
||||
export function RepositoriesTab() {
|
||||
const user = useAuthStore((s) => s.user);
|
||||
const workspace = useWorkspaceStore((s) => s.workspace);
|
||||
const wsId = useWorkspaceId();
|
||||
const { data: members = [] } = useQuery(memberListOptions(wsId));
|
||||
const members = useWorkspaceStore((s) => s.members);
|
||||
const updateWorkspace = useWorkspaceStore((s) => s.updateWorkspace);
|
||||
|
||||
const [repos, setRepos] = useState<WorkspaceRepo[]>(workspace?.repos ?? []);
|
||||
|
||||
@@ -18,18 +18,14 @@ import {
|
||||
AlertDialogAction,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { toast } from "sonner";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useAuthStore } from "@/features/auth";
|
||||
import { useWorkspaceStore } from "@/features/workspace";
|
||||
import { useWorkspaceId } from "@core/hooks";
|
||||
import { memberListOptions } from "@core/workspace/queries";
|
||||
import { api } from "@/shared/api";
|
||||
|
||||
export function WorkspaceTab() {
|
||||
const user = useAuthStore((s) => s.user);
|
||||
const workspace = useWorkspaceStore((s) => s.workspace);
|
||||
const wsId = useWorkspaceId();
|
||||
const { data: members = [] } = useQuery(memberListOptions(wsId));
|
||||
const members = useWorkspaceStore((s) => s.members);
|
||||
const updateWorkspace = useWorkspaceStore((s) => s.updateWorkspace);
|
||||
const leaveWorkspace = useWorkspaceStore((s) => s.leaveWorkspace);
|
||||
const deleteWorkspace = useWorkspaceStore((s) => s.deleteWorkspace);
|
||||
|
||||
@@ -1,90 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { Suspense, useEffect, useState } from "react";
|
||||
import { useSearchParams, useRouter } from "next/navigation";
|
||||
import { useAuthStore } from "@/features/auth";
|
||||
import { useWorkspaceStore } from "@/features/workspace";
|
||||
import { api } from "@/shared/api";
|
||||
import {
|
||||
Card,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
CardDescription,
|
||||
CardContent,
|
||||
} from "@/components/ui/card";
|
||||
import { Loader2 } from "lucide-react";
|
||||
|
||||
function CallbackContent() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const loginWithGoogle = useAuthStore((s) => s.loginWithGoogle);
|
||||
const hydrateWorkspace = useWorkspaceStore((s) => s.hydrateWorkspace);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
const code = searchParams.get("code");
|
||||
if (!code) {
|
||||
setError("Missing authorization code");
|
||||
return;
|
||||
}
|
||||
|
||||
const errorParam = searchParams.get("error");
|
||||
if (errorParam) {
|
||||
setError(errorParam === "access_denied" ? "Access denied" : errorParam);
|
||||
return;
|
||||
}
|
||||
|
||||
const redirectUri = `${window.location.origin}/auth/callback`;
|
||||
|
||||
loginWithGoogle(code, redirectUri)
|
||||
.then(async () => {
|
||||
const wsList = await api.listWorkspaces();
|
||||
const lastWsId = localStorage.getItem("multica_workspace_id");
|
||||
await hydrateWorkspace(wsList, lastWsId);
|
||||
router.push("/issues");
|
||||
})
|
||||
.catch((err) => {
|
||||
setError(err instanceof Error ? err.message : "Login failed");
|
||||
});
|
||||
}, [searchParams, loginWithGoogle, hydrateWorkspace, router]);
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center">
|
||||
<Card className="w-full max-w-sm">
|
||||
<CardHeader className="text-center">
|
||||
<CardTitle className="text-2xl">Login Failed</CardTitle>
|
||||
<CardDescription>{error}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex justify-center">
|
||||
<a href="/login" className="text-primary underline-offset-4 hover:underline">
|
||||
Back to login
|
||||
</a>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center">
|
||||
<Card className="w-full max-w-sm">
|
||||
<CardHeader className="text-center">
|
||||
<CardTitle className="text-2xl">Signing in...</CardTitle>
|
||||
<CardDescription>Please wait while we complete your login</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex justify-center">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function CallbackPage() {
|
||||
return (
|
||||
<Suspense fallback={null}>
|
||||
<CallbackContent />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
@@ -1,13 +1,12 @@
|
||||
import type { Metadata, Viewport } from "next";
|
||||
import { cookies } from "next/headers";
|
||||
import { Geist, Geist_Mono } from "next/font/google";
|
||||
import { ThemeProvider } from "@/components/theme-provider";
|
||||
import { Toaster } from "@/components/ui/sonner";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { QueryProvider } from "@core/provider";
|
||||
import { AuthInitializer } from "@/features/auth";
|
||||
import { WSProvider } from "@/features/realtime";
|
||||
import { ModalRegistry } from "@/features/modals";
|
||||
import { LocaleSync } from "@/components/locale-sync";
|
||||
import "./globals.css";
|
||||
|
||||
const geist = Geist({ subsets: ["latin"], variable: "--font-sans" });
|
||||
@@ -51,27 +50,28 @@ export const metadata: Metadata = {
|
||||
},
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
export default async function RootLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const cookieStore = await cookies();
|
||||
const locale = cookieStore.get("multica-locale")?.value;
|
||||
const lang = locale === "zh" ? "zh" : "en";
|
||||
|
||||
return (
|
||||
<html
|
||||
lang="en"
|
||||
lang={lang}
|
||||
suppressHydrationWarning
|
||||
className={cn("antialiased font-sans h-full", geist.variable, geistMono.variable)}
|
||||
>
|
||||
<body className="h-full overflow-hidden">
|
||||
<LocaleSync />
|
||||
<ThemeProvider>
|
||||
<QueryProvider>
|
||||
<AuthInitializer>
|
||||
<WSProvider>{children}</WSProvider>
|
||||
</AuthInitializer>
|
||||
<ModalRegistry />
|
||||
<Toaster />
|
||||
</QueryProvider>
|
||||
<AuthInitializer>
|
||||
<WSProvider>{children}</WSProvider>
|
||||
</AuthInitializer>
|
||||
<ModalRegistry />
|
||||
<Toaster />
|
||||
</ThemeProvider>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -2,11 +2,9 @@
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
import { Users } from "lucide-react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { HoverCard, HoverCardTrigger, HoverCardContent } from "@/components/ui/hover-card";
|
||||
import { ActorAvatar } from "@/components/common/actor-avatar";
|
||||
import { useWorkspaceId } from "@core/hooks";
|
||||
import { memberListOptions, agentListOptions } from "@core/workspace/queries";
|
||||
import { useWorkspaceStore } from "@/features/workspace";
|
||||
|
||||
interface MentionHoverCardProps {
|
||||
type: string;
|
||||
@@ -15,9 +13,8 @@ interface MentionHoverCardProps {
|
||||
}
|
||||
|
||||
function MentionHoverCard({ type, id, children }: MentionHoverCardProps) {
|
||||
const wsId = useWorkspaceId();
|
||||
const { data: members = [] } = useQuery(memberListOptions(wsId));
|
||||
const { data: agents = [] } = useQuery(agentListOptions(wsId));
|
||||
const members = useWorkspaceStore((s) => s.members);
|
||||
const agents = useWorkspaceStore((s) => s.agents);
|
||||
|
||||
if (type === "all") {
|
||||
return (
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
|
||||
/**
|
||||
* Reads the locale cookie on the client and updates <html lang>.
|
||||
* This avoids calling cookies() in the root Server Component layout,
|
||||
* which would mark the entire app as dynamic and disable the Router Cache.
|
||||
*/
|
||||
export function LocaleSync() {
|
||||
useEffect(() => {
|
||||
const match = document.cookie.match(/(?:^|;\s*)multica-locale=(\w+)/);
|
||||
const locale = match?.[1];
|
||||
if (locale === "zh") {
|
||||
document.documentElement.lang = "zh";
|
||||
}
|
||||
}, []);
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useWorkspaceStore } from "@/features/workspace";
|
||||
|
||||
/**
|
||||
* Returns the current workspace ID.
|
||||
*
|
||||
* Bridge hook: reads from Zustand workspace store now.
|
||||
* Phase 3 will switch to core/workspace/store.ts — signature stays the same.
|
||||
*/
|
||||
export function useWorkspaceId(): string {
|
||||
const workspaceId = useWorkspaceStore((s) => s.workspace?.id);
|
||||
if (!workspaceId) {
|
||||
throw new Error("useWorkspaceId: no workspace selected");
|
||||
}
|
||||
return workspaceId;
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
export {
|
||||
inboxKeys,
|
||||
inboxListOptions,
|
||||
deduplicateInboxItems,
|
||||
} from "./queries";
|
||||
|
||||
export {
|
||||
useMarkInboxRead,
|
||||
useArchiveInbox,
|
||||
useMarkAllInboxRead,
|
||||
useArchiveAllInbox,
|
||||
useArchiveAllReadInbox,
|
||||
useArchiveCompletedInbox,
|
||||
} from "./mutations";
|
||||
|
||||
export { onInboxNew, onInboxInvalidate, onInboxIssueStatusChanged } from "./ws-updaters";
|
||||
@@ -1,113 +0,0 @@
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { api } from "@/shared/api";
|
||||
import { inboxKeys } from "./queries";
|
||||
import { useWorkspaceId } from "@core/hooks";
|
||||
import type { InboxItem } from "@/shared/types";
|
||||
|
||||
export function useMarkInboxRead() {
|
||||
const qc = useQueryClient();
|
||||
const wsId = useWorkspaceId();
|
||||
return useMutation({
|
||||
mutationFn: (id: string) => api.markInboxRead(id),
|
||||
onMutate: async (id) => {
|
||||
await qc.cancelQueries({ queryKey: inboxKeys.list(wsId) });
|
||||
const prev = qc.getQueryData<InboxItem[]>(inboxKeys.list(wsId));
|
||||
qc.setQueryData<InboxItem[]>(inboxKeys.list(wsId), (old) =>
|
||||
old?.map((item) => (item.id === id ? { ...item, read: true } : item)),
|
||||
);
|
||||
return { prev };
|
||||
},
|
||||
onError: (_err, _id, ctx) => {
|
||||
if (ctx?.prev) qc.setQueryData(inboxKeys.list(wsId), ctx.prev);
|
||||
},
|
||||
onSettled: () => {
|
||||
qc.invalidateQueries({ queryKey: inboxKeys.list(wsId) });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useArchiveInbox() {
|
||||
const qc = useQueryClient();
|
||||
const wsId = useWorkspaceId();
|
||||
return useMutation({
|
||||
mutationFn: (id: string) => api.archiveInbox(id),
|
||||
onMutate: async (id) => {
|
||||
await qc.cancelQueries({ queryKey: inboxKeys.list(wsId) });
|
||||
const prev = qc.getQueryData<InboxItem[]>(inboxKeys.list(wsId));
|
||||
// Archive all items for the same issue (same behavior as store)
|
||||
const target = prev?.find((i) => i.id === id);
|
||||
const issueId = target?.issue_id;
|
||||
qc.setQueryData<InboxItem[]>(inboxKeys.list(wsId), (old) =>
|
||||
old?.map((item) =>
|
||||
item.id === id || (issueId && item.issue_id === issueId)
|
||||
? { ...item, archived: true }
|
||||
: item,
|
||||
),
|
||||
);
|
||||
return { prev };
|
||||
},
|
||||
onError: (_err, _id, ctx) => {
|
||||
if (ctx?.prev) qc.setQueryData(inboxKeys.list(wsId), ctx.prev);
|
||||
},
|
||||
onSettled: () => {
|
||||
qc.invalidateQueries({ queryKey: inboxKeys.list(wsId) });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useMarkAllInboxRead() {
|
||||
const qc = useQueryClient();
|
||||
const wsId = useWorkspaceId();
|
||||
return useMutation({
|
||||
mutationFn: () => api.markAllInboxRead(),
|
||||
onMutate: async () => {
|
||||
await qc.cancelQueries({ queryKey: inboxKeys.list(wsId) });
|
||||
const prev = qc.getQueryData<InboxItem[]>(inboxKeys.list(wsId));
|
||||
qc.setQueryData<InboxItem[]>(inboxKeys.list(wsId), (old) =>
|
||||
old?.map((item) =>
|
||||
!item.archived ? { ...item, read: true } : item,
|
||||
),
|
||||
);
|
||||
return { prev };
|
||||
},
|
||||
onError: (_err, _vars, ctx) => {
|
||||
if (ctx?.prev) qc.setQueryData(inboxKeys.list(wsId), ctx.prev);
|
||||
},
|
||||
onSettled: () => {
|
||||
qc.invalidateQueries({ queryKey: inboxKeys.list(wsId) });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useArchiveAllInbox() {
|
||||
const qc = useQueryClient();
|
||||
const wsId = useWorkspaceId();
|
||||
return useMutation({
|
||||
mutationFn: () => api.archiveAllInbox(),
|
||||
onSettled: () => {
|
||||
qc.invalidateQueries({ queryKey: inboxKeys.list(wsId) });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useArchiveAllReadInbox() {
|
||||
const qc = useQueryClient();
|
||||
const wsId = useWorkspaceId();
|
||||
return useMutation({
|
||||
mutationFn: () => api.archiveAllReadInbox(),
|
||||
onSettled: () => {
|
||||
qc.invalidateQueries({ queryKey: inboxKeys.list(wsId) });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useArchiveCompletedInbox() {
|
||||
const qc = useQueryClient();
|
||||
const wsId = useWorkspaceId();
|
||||
return useMutation({
|
||||
mutationFn: () => api.archiveCompletedInbox(),
|
||||
onSettled: () => {
|
||||
qc.invalidateQueries({ queryKey: inboxKeys.list(wsId) });
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
import { queryOptions } from "@tanstack/react-query";
|
||||
import { api } from "@/shared/api";
|
||||
import type { InboxItem } from "@/shared/types";
|
||||
|
||||
export const inboxKeys = {
|
||||
all: (wsId: string) => ["inbox", wsId] as const,
|
||||
list: (wsId: string) => [...inboxKeys.all(wsId), "list"] as const,
|
||||
};
|
||||
|
||||
export function inboxListOptions(wsId: string) {
|
||||
return queryOptions({
|
||||
queryKey: inboxKeys.list(wsId),
|
||||
queryFn: () => api.listInbox(),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Deduplicate inbox items by issue_id (one entry per issue, Linear-style).
|
||||
* Exported for consumers to use in useMemo — not in queryOptions select
|
||||
* (to avoid new array references on every cache update).
|
||||
*/
|
||||
export function deduplicateInboxItems(items: InboxItem[]): InboxItem[] {
|
||||
const active = items.filter((i) => !i.archived);
|
||||
const groups = new Map<string, InboxItem[]>();
|
||||
for (const item of active) {
|
||||
const key = item.issue_id ?? item.id;
|
||||
const group = groups.get(key) ?? [];
|
||||
group.push(item);
|
||||
groups.set(key, group);
|
||||
}
|
||||
const merged: InboxItem[] = [];
|
||||
for (const group of groups.values()) {
|
||||
group.sort(
|
||||
(a, b) =>
|
||||
new Date(b.created_at).getTime() - new Date(a.created_at).getTime(),
|
||||
);
|
||||
if (group[0]) merged.push(group[0]);
|
||||
}
|
||||
return merged.sort(
|
||||
(a, b) =>
|
||||
new Date(b.created_at).getTime() - new Date(a.created_at).getTime(),
|
||||
);
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
import type { QueryClient } from "@tanstack/react-query";
|
||||
import { inboxKeys } from "./queries";
|
||||
import type { InboxItem, IssueStatus } from "@/shared/types";
|
||||
|
||||
export function onInboxNew(
|
||||
qc: QueryClient,
|
||||
wsId: string,
|
||||
_item: InboxItem,
|
||||
) {
|
||||
// Use invalidateQueries instead of setQueryData — triggers a refetch that
|
||||
// reliably notifies all observers. The inbox list is small so this is cheap.
|
||||
qc.invalidateQueries({ queryKey: inboxKeys.list(wsId) });
|
||||
}
|
||||
|
||||
export function onInboxIssueStatusChanged(
|
||||
qc: QueryClient,
|
||||
wsId: string,
|
||||
issueId: string,
|
||||
status: IssueStatus,
|
||||
) {
|
||||
qc.setQueryData<InboxItem[]>(inboxKeys.list(wsId), (old) =>
|
||||
old?.map((i) =>
|
||||
i.issue_id === issueId ? { ...i, issue_status: status } : i,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
export function onInboxInvalidate(qc: QueryClient, wsId: string) {
|
||||
qc.invalidateQueries({ queryKey: inboxKeys.list(wsId) });
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
export { createQueryClient } from "./query-client";
|
||||
export { QueryProvider } from "./provider";
|
||||
export { useWorkspaceId } from "./hooks";
|
||||
@@ -1,29 +0,0 @@
|
||||
export {
|
||||
issueKeys,
|
||||
issueListOptions,
|
||||
issueDetailOptions,
|
||||
issueTimelineOptions,
|
||||
issueReactionsOptions,
|
||||
issueSubscribersOptions,
|
||||
} from "./queries";
|
||||
|
||||
export {
|
||||
useLoadMoreDoneIssues,
|
||||
useCreateIssue,
|
||||
useUpdateIssue,
|
||||
useDeleteIssue,
|
||||
useBatchUpdateIssues,
|
||||
useBatchDeleteIssues,
|
||||
useCreateComment,
|
||||
useUpdateComment,
|
||||
useDeleteComment,
|
||||
useToggleCommentReaction,
|
||||
useToggleIssueReaction,
|
||||
useToggleIssueSubscriber,
|
||||
} from "./mutations";
|
||||
|
||||
export {
|
||||
onIssueCreated,
|
||||
onIssueUpdated,
|
||||
onIssueDeleted,
|
||||
} from "./ws-updaters";
|
||||
@@ -1,495 +0,0 @@
|
||||
import { useState, useCallback } from "react";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { api } from "@/shared/api";
|
||||
import { issueKeys, CLOSED_PAGE_SIZE } from "./queries";
|
||||
import { useWorkspaceId } from "@core/hooks";
|
||||
import type { Issue, IssueReaction } from "@/shared/types";
|
||||
import type {
|
||||
CreateIssueRequest,
|
||||
UpdateIssueRequest,
|
||||
ListIssuesResponse,
|
||||
} from "@/shared/types";
|
||||
import type { TimelineEntry, IssueSubscriber, Reaction } from "@/shared/types";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Shared mutation variable types — used by both mutation hooks and
|
||||
// useMutationState consumers to keep the type assertion in sync.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type ToggleCommentReactionVars = {
|
||||
commentId: string;
|
||||
emoji: string;
|
||||
existing: Reaction | undefined;
|
||||
};
|
||||
|
||||
export type ToggleIssueReactionVars = {
|
||||
emoji: string;
|
||||
existing: IssueReaction | undefined;
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Done issue pagination
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function useLoadMoreDoneIssues() {
|
||||
const qc = useQueryClient();
|
||||
const wsId = useWorkspaceId();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const cache = qc.getQueryData<ListIssuesResponse>(issueKeys.list(wsId));
|
||||
const doneLoaded = cache
|
||||
? cache.issues.filter((i) => i.status === "done").length
|
||||
: 0;
|
||||
const doneTotal = cache?.doneTotal ?? 0;
|
||||
const hasMore = doneLoaded < doneTotal;
|
||||
|
||||
const loadMore = useCallback(async () => {
|
||||
if (isLoading || !hasMore) return;
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const res = await api.listIssues({
|
||||
status: "done",
|
||||
limit: CLOSED_PAGE_SIZE,
|
||||
offset: doneLoaded,
|
||||
});
|
||||
qc.setQueryData<ListIssuesResponse>(issueKeys.list(wsId), (old) => {
|
||||
if (!old) return old;
|
||||
const existingIds = new Set(old.issues.map((i) => i.id));
|
||||
const newIssues = res.issues.filter((i) => !existingIds.has(i.id));
|
||||
return {
|
||||
...old,
|
||||
issues: [...old.issues, ...newIssues],
|
||||
doneTotal: res.total,
|
||||
};
|
||||
});
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [qc, wsId, doneLoaded, hasMore, isLoading]);
|
||||
|
||||
return { loadMore, hasMore, isLoading, doneTotal };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Issue CRUD
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function useCreateIssue() {
|
||||
const qc = useQueryClient();
|
||||
const wsId = useWorkspaceId();
|
||||
return useMutation({
|
||||
mutationFn: (data: CreateIssueRequest) => api.createIssue(data),
|
||||
onSuccess: (newIssue) => {
|
||||
qc.setQueryData<ListIssuesResponse>(issueKeys.list(wsId), (old) =>
|
||||
old && !old.issues.some((i) => i.id === newIssue.id)
|
||||
? {
|
||||
...old,
|
||||
issues: [...old.issues, newIssue],
|
||||
total: old.total + 1,
|
||||
doneTotal: (old.doneTotal ?? 0) + (newIssue.status === "done" ? 1 : 0),
|
||||
}
|
||||
: old,
|
||||
);
|
||||
// Invalidate parent's children query so sub-issues list updates immediately
|
||||
if (newIssue.parent_issue_id) {
|
||||
qc.invalidateQueries({ queryKey: issueKeys.children(wsId, newIssue.parent_issue_id) });
|
||||
}
|
||||
},
|
||||
onSettled: () => {
|
||||
qc.invalidateQueries({ queryKey: issueKeys.list(wsId) });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateIssue() {
|
||||
const qc = useQueryClient();
|
||||
const wsId = useWorkspaceId();
|
||||
return useMutation({
|
||||
mutationFn: ({ id, ...data }: { id: string } & UpdateIssueRequest) =>
|
||||
api.updateIssue(id, data),
|
||||
onMutate: ({ id, ...data }) => {
|
||||
// Fire-and-forget cancelQueries — keeps onMutate synchronous so the
|
||||
// cache update happens in the same tick as mutate(). Awaiting would
|
||||
// yield to the event loop, letting @dnd-kit reset its visual state
|
||||
// before the optimistic update lands.
|
||||
qc.cancelQueries({ queryKey: issueKeys.list(wsId) });
|
||||
const prevList = qc.getQueryData<ListIssuesResponse>(issueKeys.list(wsId));
|
||||
const prevDetail = qc.getQueryData<Issue>(issueKeys.detail(wsId, id));
|
||||
|
||||
// Resolve parent_issue_id from the freshest source so we can keep the
|
||||
// parent's children cache in sync (used by the parent issue's
|
||||
// sub-issues list).
|
||||
const parentId =
|
||||
prevDetail?.parent_issue_id ??
|
||||
prevList?.issues.find((i) => i.id === id)?.parent_issue_id ??
|
||||
null;
|
||||
const prevChildren = parentId
|
||||
? qc.getQueryData<Issue[]>(issueKeys.children(wsId, parentId))
|
||||
: undefined;
|
||||
|
||||
qc.setQueryData<ListIssuesResponse>(issueKeys.list(wsId), (old) =>
|
||||
old
|
||||
? {
|
||||
...old,
|
||||
issues: old.issues.map((i) =>
|
||||
i.id === id ? { ...i, ...data } : i,
|
||||
),
|
||||
}
|
||||
: old,
|
||||
);
|
||||
qc.setQueryData<Issue>(issueKeys.detail(wsId, id), (old) =>
|
||||
old ? { ...old, ...data } : old,
|
||||
);
|
||||
if (parentId) {
|
||||
qc.setQueryData<Issue[]>(
|
||||
issueKeys.children(wsId, parentId),
|
||||
(old) =>
|
||||
old?.map((c) => (c.id === id ? { ...c, ...data } : c)),
|
||||
);
|
||||
}
|
||||
return { prevList, prevDetail, prevChildren, parentId, id };
|
||||
},
|
||||
onError: (_err, _vars, ctx) => {
|
||||
if (ctx?.prevList) qc.setQueryData(issueKeys.list(wsId), ctx.prevList);
|
||||
if (ctx?.prevDetail)
|
||||
qc.setQueryData(issueKeys.detail(wsId, ctx.id), ctx.prevDetail);
|
||||
if (ctx?.parentId && ctx.prevChildren !== undefined) {
|
||||
qc.setQueryData(
|
||||
issueKeys.children(wsId, ctx.parentId),
|
||||
ctx.prevChildren,
|
||||
);
|
||||
}
|
||||
},
|
||||
onSettled: (_data, _err, vars, ctx) => {
|
||||
qc.invalidateQueries({ queryKey: issueKeys.detail(wsId, vars.id) });
|
||||
qc.invalidateQueries({ queryKey: issueKeys.list(wsId) });
|
||||
if (ctx?.parentId) {
|
||||
qc.invalidateQueries({
|
||||
queryKey: issueKeys.children(wsId, ctx.parentId),
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteIssue() {
|
||||
const qc = useQueryClient();
|
||||
const wsId = useWorkspaceId();
|
||||
return useMutation({
|
||||
mutationFn: (id: string) => api.deleteIssue(id),
|
||||
onMutate: async (id) => {
|
||||
await qc.cancelQueries({ queryKey: issueKeys.list(wsId) });
|
||||
const prevList = qc.getQueryData<ListIssuesResponse>(issueKeys.list(wsId));
|
||||
qc.setQueryData<ListIssuesResponse>(issueKeys.list(wsId), (old) => {
|
||||
if (!old) return old;
|
||||
const deleted = old.issues.find((i) => i.id === id);
|
||||
return {
|
||||
...old,
|
||||
issues: old.issues.filter((i) => i.id !== id),
|
||||
total: old.total - 1,
|
||||
doneTotal: (old.doneTotal ?? 0) - (deleted?.status === "done" ? 1 : 0),
|
||||
};
|
||||
});
|
||||
qc.removeQueries({ queryKey: issueKeys.detail(wsId, id) });
|
||||
return { prevList };
|
||||
},
|
||||
onError: (_err, _id, ctx) => {
|
||||
if (ctx?.prevList) qc.setQueryData(issueKeys.list(wsId), ctx.prevList);
|
||||
},
|
||||
onSettled: () => {
|
||||
qc.invalidateQueries({ queryKey: issueKeys.list(wsId) });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useBatchUpdateIssues() {
|
||||
const qc = useQueryClient();
|
||||
const wsId = useWorkspaceId();
|
||||
return useMutation({
|
||||
mutationFn: ({
|
||||
ids,
|
||||
updates,
|
||||
}: {
|
||||
ids: string[];
|
||||
updates: UpdateIssueRequest;
|
||||
}) => api.batchUpdateIssues(ids, updates),
|
||||
onMutate: async ({ ids, updates }) => {
|
||||
await qc.cancelQueries({ queryKey: issueKeys.list(wsId) });
|
||||
const prevList = qc.getQueryData<ListIssuesResponse>(issueKeys.list(wsId));
|
||||
qc.setQueryData<ListIssuesResponse>(issueKeys.list(wsId), (old) =>
|
||||
old
|
||||
? {
|
||||
...old,
|
||||
issues: old.issues.map((i) =>
|
||||
ids.includes(i.id) ? { ...i, ...updates } : i,
|
||||
),
|
||||
}
|
||||
: old,
|
||||
);
|
||||
return { prevList };
|
||||
},
|
||||
onError: (_err, _vars, ctx) => {
|
||||
if (ctx?.prevList) qc.setQueryData(issueKeys.list(wsId), ctx.prevList);
|
||||
},
|
||||
onSettled: () => {
|
||||
qc.invalidateQueries({ queryKey: issueKeys.list(wsId) });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useBatchDeleteIssues() {
|
||||
const qc = useQueryClient();
|
||||
const wsId = useWorkspaceId();
|
||||
return useMutation({
|
||||
mutationFn: (ids: string[]) => api.batchDeleteIssues(ids),
|
||||
onMutate: async (ids) => {
|
||||
await qc.cancelQueries({ queryKey: issueKeys.list(wsId) });
|
||||
const prevList = qc.getQueryData<ListIssuesResponse>(issueKeys.list(wsId));
|
||||
qc.setQueryData<ListIssuesResponse>(issueKeys.list(wsId), (old) => {
|
||||
if (!old) return old;
|
||||
const idSet = new Set(ids);
|
||||
const doneDeleted = old.issues.filter(
|
||||
(i) => idSet.has(i.id) && i.status === "done",
|
||||
).length;
|
||||
return {
|
||||
...old,
|
||||
issues: old.issues.filter((i) => !idSet.has(i.id)),
|
||||
total: old.total - ids.length,
|
||||
doneTotal: (old.doneTotal ?? 0) - doneDeleted,
|
||||
};
|
||||
});
|
||||
return { prevList };
|
||||
},
|
||||
onError: (_err, _ids, ctx) => {
|
||||
if (ctx?.prevList) qc.setQueryData(issueKeys.list(wsId), ctx.prevList);
|
||||
},
|
||||
onSettled: () => {
|
||||
qc.invalidateQueries({ queryKey: issueKeys.list(wsId) });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Comments / Timeline
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function useCreateComment(issueId: string) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({
|
||||
content,
|
||||
type,
|
||||
parentId,
|
||||
attachmentIds,
|
||||
}: {
|
||||
content: string;
|
||||
type?: string;
|
||||
parentId?: string;
|
||||
attachmentIds?: string[];
|
||||
}) => api.createComment(issueId, content, type, parentId, attachmentIds),
|
||||
onSuccess: (comment) => {
|
||||
qc.setQueryData<TimelineEntry[]>(
|
||||
issueKeys.timeline(issueId),
|
||||
(old) => {
|
||||
if (!old) return old;
|
||||
const entry: TimelineEntry = {
|
||||
type: "comment",
|
||||
id: comment.id,
|
||||
actor_type: comment.author_type,
|
||||
actor_id: comment.author_id,
|
||||
content: comment.content,
|
||||
parent_id: comment.parent_id,
|
||||
comment_type: comment.type,
|
||||
reactions: comment.reactions ?? [],
|
||||
attachments: comment.attachments ?? [],
|
||||
created_at: comment.created_at,
|
||||
updated_at: comment.updated_at,
|
||||
};
|
||||
if (old.some((e) => e.id === comment.id)) return old;
|
||||
return [...old, entry];
|
||||
},
|
||||
);
|
||||
},
|
||||
onSettled: () => {
|
||||
qc.invalidateQueries({ queryKey: issueKeys.timeline(issueId) });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateComment(issueId: string) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ commentId, content }: { commentId: string; content: string }) =>
|
||||
api.updateComment(commentId, content),
|
||||
onMutate: async ({ commentId, content }) => {
|
||||
await qc.cancelQueries({ queryKey: issueKeys.timeline(issueId) });
|
||||
const prev = qc.getQueryData<TimelineEntry[]>(issueKeys.timeline(issueId));
|
||||
qc.setQueryData<TimelineEntry[]>(
|
||||
issueKeys.timeline(issueId),
|
||||
(old) =>
|
||||
old?.map((e) => (e.id === commentId ? { ...e, content } : e)),
|
||||
);
|
||||
return { prev };
|
||||
},
|
||||
onError: (_err, _vars, ctx) => {
|
||||
if (ctx?.prev)
|
||||
qc.setQueryData(issueKeys.timeline(issueId), ctx.prev);
|
||||
},
|
||||
onSettled: () => {
|
||||
qc.invalidateQueries({ queryKey: issueKeys.timeline(issueId) });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteComment(issueId: string) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (commentId: string) => api.deleteComment(commentId),
|
||||
onMutate: async (commentId) => {
|
||||
await qc.cancelQueries({ queryKey: issueKeys.timeline(issueId) });
|
||||
const prev = qc.getQueryData<TimelineEntry[]>(issueKeys.timeline(issueId));
|
||||
|
||||
// Cascade: collect all child comment IDs
|
||||
const toRemove = new Set<string>([commentId]);
|
||||
if (prev) {
|
||||
let changed = true;
|
||||
while (changed) {
|
||||
changed = false;
|
||||
for (const e of prev) {
|
||||
if (e.parent_id && toRemove.has(e.parent_id) && !toRemove.has(e.id)) {
|
||||
toRemove.add(e.id);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
qc.setQueryData<TimelineEntry[]>(
|
||||
issueKeys.timeline(issueId),
|
||||
(old) => old?.filter((e) => !toRemove.has(e.id)),
|
||||
);
|
||||
return { prev };
|
||||
},
|
||||
onError: (_err, _id, ctx) => {
|
||||
if (ctx?.prev)
|
||||
qc.setQueryData(issueKeys.timeline(issueId), ctx.prev);
|
||||
},
|
||||
onSettled: () => {
|
||||
qc.invalidateQueries({ queryKey: issueKeys.timeline(issueId) });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useToggleCommentReaction(issueId: string) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationKey: ["toggleCommentReaction", issueId] as const,
|
||||
mutationFn: async ({
|
||||
commentId,
|
||||
emoji,
|
||||
existing,
|
||||
}: ToggleCommentReactionVars) => {
|
||||
if (existing) {
|
||||
await api.removeReaction(commentId, emoji);
|
||||
return null;
|
||||
}
|
||||
return api.addReaction(commentId, emoji);
|
||||
},
|
||||
onSettled: () => {
|
||||
qc.invalidateQueries({ queryKey: issueKeys.timeline(issueId) });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Issue-level Reactions
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function useToggleIssueReaction(issueId: string) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationKey: ["toggleIssueReaction", issueId] as const,
|
||||
mutationFn: async ({
|
||||
emoji,
|
||||
existing,
|
||||
}: ToggleIssueReactionVars) => {
|
||||
if (existing) {
|
||||
await api.removeIssueReaction(issueId, emoji);
|
||||
return null;
|
||||
}
|
||||
return api.addIssueReaction(issueId, emoji);
|
||||
},
|
||||
onSettled: () => {
|
||||
qc.invalidateQueries({ queryKey: issueKeys.reactions(issueId) });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Issue Subscribers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function useToggleIssueSubscriber(issueId: string) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async ({
|
||||
userId,
|
||||
userType,
|
||||
subscribed,
|
||||
}: {
|
||||
userId: string;
|
||||
userType: "member" | "agent";
|
||||
subscribed: boolean;
|
||||
}) => {
|
||||
if (subscribed) {
|
||||
await api.unsubscribeFromIssue(issueId, userId, userType);
|
||||
} else {
|
||||
await api.subscribeToIssue(issueId, userId, userType);
|
||||
}
|
||||
},
|
||||
onMutate: async ({ userId, userType, subscribed }) => {
|
||||
await qc.cancelQueries({ queryKey: issueKeys.subscribers(issueId) });
|
||||
const prev = qc.getQueryData<IssueSubscriber[]>(
|
||||
issueKeys.subscribers(issueId),
|
||||
);
|
||||
|
||||
if (subscribed) {
|
||||
qc.setQueryData<IssueSubscriber[]>(
|
||||
issueKeys.subscribers(issueId),
|
||||
(old) =>
|
||||
old?.filter(
|
||||
(s) => !(s.user_id === userId && s.user_type === userType),
|
||||
),
|
||||
);
|
||||
} else {
|
||||
const temp: IssueSubscriber = {
|
||||
issue_id: issueId,
|
||||
user_type: userType,
|
||||
user_id: userId,
|
||||
reason: "manual",
|
||||
created_at: new Date().toISOString(),
|
||||
};
|
||||
qc.setQueryData<IssueSubscriber[]>(
|
||||
issueKeys.subscribers(issueId),
|
||||
(old) => {
|
||||
if (
|
||||
old?.some(
|
||||
(s) => s.user_id === userId && s.user_type === userType,
|
||||
)
|
||||
)
|
||||
return old;
|
||||
return [...(old ?? []), temp];
|
||||
},
|
||||
);
|
||||
}
|
||||
return { prev };
|
||||
},
|
||||
onError: (_err, _vars, ctx) => {
|
||||
if (ctx?.prev)
|
||||
qc.setQueryData(issueKeys.subscribers(issueId), ctx.prev);
|
||||
},
|
||||
onSettled: () => {
|
||||
qc.invalidateQueries({ queryKey: issueKeys.subscribers(issueId) });
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -1,81 +0,0 @@
|
||||
import { queryOptions } from "@tanstack/react-query";
|
||||
import { api } from "@/shared/api";
|
||||
|
||||
export const issueKeys = {
|
||||
all: (wsId: string) => ["issues", wsId] as const,
|
||||
list: (wsId: string) => [...issueKeys.all(wsId), "list"] as const,
|
||||
detail: (wsId: string, id: string) =>
|
||||
[...issueKeys.all(wsId), "detail", id] as const,
|
||||
children: (wsId: string, id: string) =>
|
||||
[...issueKeys.all(wsId), "children", id] as const,
|
||||
timeline: (issueId: string) => ["issues", "timeline", issueId] as const,
|
||||
reactions: (issueId: string) => ["issues", "reactions", issueId] as const,
|
||||
subscribers: (issueId: string) =>
|
||||
["issues", "subscribers", issueId] as const,
|
||||
};
|
||||
|
||||
export const CLOSED_PAGE_SIZE = 50;
|
||||
|
||||
/**
|
||||
* CACHE SHAPE NOTE: The raw cache stores ListIssuesResponse ({ issues, total, doneTotal }),
|
||||
* but `select` transforms it to Issue[] for consumers. Mutations and ws-updaters
|
||||
* must use setQueryData<ListIssuesResponse>(...) — NOT setQueryData<Issue[]>.
|
||||
*
|
||||
* Fetches all open issues + first page of done issues. Use useLoadMoreDoneIssues()
|
||||
* to paginate additional done items into the cache.
|
||||
*/
|
||||
export function issueListOptions(wsId: string) {
|
||||
return queryOptions({
|
||||
queryKey: issueKeys.list(wsId),
|
||||
queryFn: async () => {
|
||||
const [openRes, closedRes] = await Promise.all([
|
||||
api.listIssues({ open_only: true }),
|
||||
api.listIssues({ status: "done", limit: CLOSED_PAGE_SIZE, offset: 0 }),
|
||||
]);
|
||||
return {
|
||||
issues: [...openRes.issues, ...closedRes.issues],
|
||||
total: openRes.total + closedRes.total,
|
||||
doneTotal: closedRes.total,
|
||||
};
|
||||
},
|
||||
select: (data) => data.issues,
|
||||
});
|
||||
}
|
||||
|
||||
export function issueDetailOptions(wsId: string, id: string) {
|
||||
return queryOptions({
|
||||
queryKey: issueKeys.detail(wsId, id),
|
||||
queryFn: () => api.getIssue(id),
|
||||
});
|
||||
}
|
||||
|
||||
export function childIssuesOptions(wsId: string, id: string) {
|
||||
return queryOptions({
|
||||
queryKey: issueKeys.children(wsId, id),
|
||||
queryFn: () => api.listChildIssues(id).then((r) => r.issues),
|
||||
});
|
||||
}
|
||||
|
||||
export function issueTimelineOptions(issueId: string) {
|
||||
return queryOptions({
|
||||
queryKey: issueKeys.timeline(issueId),
|
||||
queryFn: () => api.listTimeline(issueId),
|
||||
});
|
||||
}
|
||||
|
||||
export function issueReactionsOptions(issueId: string) {
|
||||
return queryOptions({
|
||||
queryKey: issueKeys.reactions(issueId),
|
||||
queryFn: async () => {
|
||||
const issue = await api.getIssue(issueId);
|
||||
return issue.reactions ?? [];
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function issueSubscribersOptions(issueId: string) {
|
||||
return queryOptions({
|
||||
queryKey: issueKeys.subscribers(issueId),
|
||||
queryFn: () => api.listIssueSubscribers(issueId),
|
||||
});
|
||||
}
|
||||
@@ -1,97 +0,0 @@
|
||||
import type { QueryClient } from "@tanstack/react-query";
|
||||
import { issueKeys } from "./queries";
|
||||
import type { Issue } from "@/shared/types";
|
||||
import type { ListIssuesResponse } from "@/shared/types";
|
||||
|
||||
export function onIssueCreated(
|
||||
qc: QueryClient,
|
||||
wsId: string,
|
||||
issue: Issue,
|
||||
) {
|
||||
qc.setQueryData<ListIssuesResponse>(issueKeys.list(wsId), (old) => {
|
||||
if (!old || old.issues.some((i) => i.id === issue.id)) return old;
|
||||
return {
|
||||
...old,
|
||||
issues: [...old.issues, issue],
|
||||
total: old.total + 1,
|
||||
doneTotal: (old.doneTotal ?? 0) + (issue.status === "done" ? 1 : 0),
|
||||
};
|
||||
});
|
||||
if (issue.parent_issue_id) {
|
||||
qc.invalidateQueries({ queryKey: issueKeys.children(wsId, issue.parent_issue_id) });
|
||||
}
|
||||
}
|
||||
|
||||
export function onIssueUpdated(
|
||||
qc: QueryClient,
|
||||
wsId: string,
|
||||
issue: Partial<Issue> & { id: string },
|
||||
) {
|
||||
// Look up the parent before mutating list state, so we can also keep the
|
||||
// parent's children cache in sync (powers the sub-issues list shown on
|
||||
// the parent issue page).
|
||||
const listData = qc.getQueryData<ListIssuesResponse>(issueKeys.list(wsId));
|
||||
const detailData = qc.getQueryData<Issue>(issueKeys.detail(wsId, issue.id));
|
||||
const parentId =
|
||||
issue.parent_issue_id ??
|
||||
detailData?.parent_issue_id ??
|
||||
listData?.issues.find((i) => i.id === issue.id)?.parent_issue_id ??
|
||||
null;
|
||||
|
||||
qc.setQueryData<ListIssuesResponse>(issueKeys.list(wsId), (old) => {
|
||||
if (!old) return old;
|
||||
const prev = old.issues.find((i) => i.id === issue.id);
|
||||
const wasDone = prev?.status === "done";
|
||||
const isDone = issue.status === "done";
|
||||
// Only adjust doneTotal when status field is present and actually changed
|
||||
let doneDelta = 0;
|
||||
if (issue.status !== undefined) {
|
||||
if (!wasDone && isDone) doneDelta = 1;
|
||||
else if (wasDone && !isDone) doneDelta = -1;
|
||||
}
|
||||
return {
|
||||
...old,
|
||||
issues: old.issues.map((i) =>
|
||||
i.id === issue.id ? { ...i, ...issue } : i,
|
||||
),
|
||||
doneTotal: (old.doneTotal ?? 0) + doneDelta,
|
||||
};
|
||||
});
|
||||
qc.setQueryData<Issue>(issueKeys.detail(wsId, issue.id), (old) =>
|
||||
old ? { ...old, ...issue } : old,
|
||||
);
|
||||
if (parentId) {
|
||||
qc.setQueryData<Issue[]>(issueKeys.children(wsId, parentId), (old) =>
|
||||
old?.map((c) => (c.id === issue.id ? { ...c, ...issue } : c)),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function onIssueDeleted(
|
||||
qc: QueryClient,
|
||||
wsId: string,
|
||||
issueId: string,
|
||||
) {
|
||||
// Look up the issue before removing it to check for parent_issue_id
|
||||
const listData = qc.getQueryData<ListIssuesResponse>(issueKeys.list(wsId));
|
||||
const deleted = listData?.issues.find((i) => i.id === issueId);
|
||||
|
||||
qc.setQueryData<ListIssuesResponse>(issueKeys.list(wsId), (old) => {
|
||||
if (!old) return old;
|
||||
const del = old.issues.find((i) => i.id === issueId);
|
||||
return {
|
||||
...old,
|
||||
issues: old.issues.filter((i) => i.id !== issueId),
|
||||
total: old.total - 1,
|
||||
doneTotal: (old.doneTotal ?? 0) - (del?.status === "done" ? 1 : 0),
|
||||
};
|
||||
});
|
||||
qc.removeQueries({ queryKey: issueKeys.detail(wsId, issueId) });
|
||||
qc.removeQueries({ queryKey: issueKeys.timeline(issueId) });
|
||||
qc.removeQueries({ queryKey: issueKeys.reactions(issueId) });
|
||||
qc.removeQueries({ queryKey: issueKeys.subscribers(issueId) });
|
||||
qc.removeQueries({ queryKey: issueKeys.children(wsId, issueId) });
|
||||
if (deleted?.parent_issue_id) {
|
||||
qc.invalidateQueries({ queryKey: issueKeys.children(wsId, deleted.parent_issue_id) });
|
||||
}
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { QueryClientProvider } from "@tanstack/react-query";
|
||||
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
|
||||
import { createQueryClient } from "./query-client";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
export function QueryProvider({ children }: { children: ReactNode }) {
|
||||
const [queryClient] = useState(createQueryClient);
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
{children}
|
||||
<ReactQueryDevtools initialIsOpen={false} />
|
||||
</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
import { QueryClient } from "@tanstack/react-query";
|
||||
|
||||
export function createQueryClient(): QueryClient {
|
||||
return new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
staleTime: Infinity,
|
||||
gcTime: 10 * 60 * 1000, // 10 minutes
|
||||
refetchOnWindowFocus: false,
|
||||
refetchOnReconnect: true,
|
||||
retry: 1,
|
||||
},
|
||||
mutations: {
|
||||
retry: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
export { runtimeKeys, runtimeListOptions } from "./queries";
|
||||
@@ -1,14 +0,0 @@
|
||||
import { queryOptions } from "@tanstack/react-query";
|
||||
import { api } from "@/shared/api";
|
||||
|
||||
export const runtimeKeys = {
|
||||
all: (wsId: string) => ["runtimes", wsId] as const,
|
||||
list: (wsId: string) => [...runtimeKeys.all(wsId), "list"] as const,
|
||||
};
|
||||
|
||||
export function runtimeListOptions(wsId: string) {
|
||||
return queryOptions({
|
||||
queryKey: runtimeKeys.list(wsId),
|
||||
queryFn: () => api.listRuntimes({ workspace_id: wsId }),
|
||||
});
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
export {
|
||||
workspaceKeys,
|
||||
workspaceListOptions,
|
||||
memberListOptions,
|
||||
agentListOptions,
|
||||
skillListOptions,
|
||||
} from "./queries";
|
||||
|
||||
export {
|
||||
useCreateWorkspace,
|
||||
useLeaveWorkspace,
|
||||
useDeleteWorkspace,
|
||||
} from "./mutations";
|
||||
@@ -1,34 +0,0 @@
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { api } from "@/shared/api";
|
||||
import { workspaceKeys } from "./queries";
|
||||
|
||||
export function useCreateWorkspace() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (data: { name: string; slug: string; description?: string }) =>
|
||||
api.createWorkspace(data),
|
||||
onSettled: () => {
|
||||
qc.invalidateQueries({ queryKey: workspaceKeys.list() });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useLeaveWorkspace() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (workspaceId: string) => api.leaveWorkspace(workspaceId),
|
||||
onSettled: () => {
|
||||
qc.invalidateQueries({ queryKey: workspaceKeys.list() });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteWorkspace() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (workspaceId: string) => api.deleteWorkspace(workspaceId),
|
||||
onSettled: () => {
|
||||
qc.invalidateQueries({ queryKey: workspaceKeys.list() });
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
import { queryOptions } from "@tanstack/react-query";
|
||||
import { api } from "@/shared/api";
|
||||
|
||||
export const workspaceKeys = {
|
||||
all: (wsId: string) => ["workspaces", wsId] as const,
|
||||
list: () => ["workspaces", "list"] as const,
|
||||
members: (wsId: string) => ["workspaces", wsId, "members"] as const,
|
||||
agents: (wsId: string) => ["workspaces", wsId, "agents"] as const,
|
||||
skills: (wsId: string) => ["workspaces", wsId, "skills"] as const,
|
||||
};
|
||||
|
||||
export function workspaceListOptions() {
|
||||
return queryOptions({
|
||||
queryKey: workspaceKeys.list(),
|
||||
queryFn: () => api.listWorkspaces(),
|
||||
});
|
||||
}
|
||||
|
||||
export function memberListOptions(wsId: string) {
|
||||
return queryOptions({
|
||||
queryKey: workspaceKeys.members(wsId),
|
||||
queryFn: () => api.listMembers(wsId),
|
||||
});
|
||||
}
|
||||
|
||||
export function agentListOptions(wsId: string) {
|
||||
return queryOptions({
|
||||
queryKey: workspaceKeys.agents(wsId),
|
||||
queryFn: () =>
|
||||
api.listAgents({ workspace_id: wsId, include_archived: true }),
|
||||
});
|
||||
}
|
||||
|
||||
export function skillListOptions(wsId: string) {
|
||||
return queryOptions({
|
||||
queryKey: workspaceKeys.skills(wsId),
|
||||
queryFn: () => api.listSkills(),
|
||||
});
|
||||
}
|
||||
@@ -1,3 +1,2 @@
|
||||
export { useAuthStore } from "./store";
|
||||
export { AuthInitializer } from "./initializer";
|
||||
export { setLoggedInCookie } from "./auth-cookie";
|
||||
|
||||
@@ -12,7 +12,6 @@ interface AuthState {
|
||||
initialize: () => Promise<void>;
|
||||
sendCode: (email: string) => Promise<void>;
|
||||
verifyCode: (email: string, code: string) => Promise<User>;
|
||||
loginWithGoogle: (code: string, redirectUri: string) => Promise<User>;
|
||||
logout: () => void;
|
||||
setUser: (user: User) => void;
|
||||
}
|
||||
@@ -54,15 +53,6 @@ export const useAuthStore = create<AuthState>((set) => ({
|
||||
return user;
|
||||
},
|
||||
|
||||
loginWithGoogle: async (code: string, redirectUri: string) => {
|
||||
const { token, user } = await api.googleLogin(code, redirectUri);
|
||||
localStorage.setItem("multica_token", token);
|
||||
api.setToken(token);
|
||||
setLoggedInCookie();
|
||||
set({ user });
|
||||
return user;
|
||||
},
|
||||
|
||||
logout: () => {
|
||||
localStorage.removeItem("multica_token");
|
||||
api.setToken(null);
|
||||
|
||||
@@ -353,8 +353,7 @@
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.rich-text-editor s,
|
||||
.rich-text-editor del {
|
||||
.rich-text-editor s {
|
||||
text-decoration: line-through;
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
|
||||
@@ -34,7 +34,6 @@ import {
|
||||
import { useEditor, EditorContent } from "@tiptap/react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { UploadResult } from "@/shared/hooks/use-file-upload";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { createEditorExtensions } from "./extensions";
|
||||
import { uploadAndInsertFile } from "./extensions/file-upload";
|
||||
import { preprocessMarkdown } from "./utils/preprocess";
|
||||
@@ -95,8 +94,6 @@ const ContentEditor = forwardRef<ContentEditorRef, ContentEditorProps>(
|
||||
onBlurRef.current = onBlur;
|
||||
onUploadFileRef.current = onUploadFile;
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const editor = useEditor({
|
||||
immediatelyRender: false,
|
||||
editable,
|
||||
@@ -105,7 +102,6 @@ const ContentEditor = forwardRef<ContentEditorRef, ContentEditorProps>(
|
||||
extensions: createEditorExtensions({
|
||||
editable,
|
||||
placeholder: placeholderText,
|
||||
queryClient,
|
||||
onSubmitRef,
|
||||
onUploadFileRef,
|
||||
}),
|
||||
|
||||
@@ -76,7 +76,6 @@ const ImageExtension = Image.extend({
|
||||
export interface EditorExtensionsOptions {
|
||||
editable: boolean;
|
||||
placeholder?: string;
|
||||
queryClient?: import("@tanstack/react-query").QueryClient;
|
||||
onSubmitRef?: RefObject<(() => void) | undefined>;
|
||||
onUploadFileRef?: RefObject<
|
||||
((file: File) => Promise<UploadResult | null>) | undefined
|
||||
@@ -108,7 +107,7 @@ export function createEditorExtensions(
|
||||
Markdown,
|
||||
BaseMentionExtension.configure({
|
||||
HTMLAttributes: { class: "mention" },
|
||||
...(editable && options.queryClient ? { suggestion: createMentionSuggestion(options.queryClient) } : {}),
|
||||
...(editable ? { suggestion: createMentionSuggestion() } : {}),
|
||||
}),
|
||||
];
|
||||
|
||||
|
||||
@@ -10,11 +10,8 @@ import {
|
||||
} from "react";
|
||||
import { ReactRenderer } from "@tiptap/react";
|
||||
import { computePosition, offset, flip, shift } from "@floating-ui/dom";
|
||||
import type { QueryClient } from "@tanstack/react-query";
|
||||
import { useWorkspaceStore } from "@/features/workspace";
|
||||
import { issueKeys } from "@core/issues/queries";
|
||||
import { workspaceKeys } from "@core/workspace/queries";
|
||||
import type { Issue, ListIssuesResponse, MemberWithUser, Agent } from "@/shared/types";
|
||||
import { useIssueStore } from "@/features/issues";
|
||||
import { ActorAvatar } from "@/components/common/actor-avatar";
|
||||
import { StatusIcon } from "@/features/issues/components/status-icon";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
@@ -213,19 +210,14 @@ function MentionRow({
|
||||
// Suggestion config factory
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function createMentionSuggestion(qc: QueryClient): Omit<
|
||||
export function createMentionSuggestion(): Omit<
|
||||
SuggestionOptions<MentionItem>,
|
||||
"editor"
|
||||
> {
|
||||
return {
|
||||
items: ({ query }) => {
|
||||
const wsId = useWorkspaceStore.getState().workspace?.id;
|
||||
const members: MemberWithUser[] = wsId ? qc.getQueryData(workspaceKeys.members(wsId)) ?? [] : [];
|
||||
const agents: Agent[] = wsId ? qc.getQueryData(workspaceKeys.agents(wsId)) ?? [] : [];
|
||||
const issues: Issue[] = wsId
|
||||
? qc.getQueryData<ListIssuesResponse>(issueKeys.list(wsId))?.issues ?? []
|
||||
: [];
|
||||
|
||||
const { members, agents } = useWorkspaceStore.getState();
|
||||
const { issues } = useIssueStore.getState();
|
||||
const q = query.toLowerCase();
|
||||
|
||||
// Show "All members" option when query is empty or matches "all"
|
||||
|
||||
@@ -20,9 +20,7 @@
|
||||
|
||||
import { NodeViewWrapper } from "@tiptap/react";
|
||||
import type { NodeViewProps } from "@tiptap/react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { issueListOptions } from "@core/issues/queries";
|
||||
import { useWorkspaceId } from "@core/hooks";
|
||||
import { useIssueStore } from "@/features/issues/store";
|
||||
import { StatusIcon } from "@/features/issues/components/status-icon";
|
||||
|
||||
export function MentionView({ node }: NodeViewProps) {
|
||||
@@ -50,9 +48,7 @@ function IssueMention({
|
||||
issueId: string;
|
||||
fallbackLabel?: string;
|
||||
}) {
|
||||
const wsId = useWorkspaceId();
|
||||
const { data: issues = [] } = useQuery(issueListOptions(wsId));
|
||||
const issue = issues.find((i) => i.id === issueId);
|
||||
const issue = useIssueStore((s) => s.issues.find((i) => i.id === issueId));
|
||||
|
||||
const handleClick = (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
@@ -9,4 +9,3 @@ export {
|
||||
type TitleEditorRef,
|
||||
} from "./title-editor";
|
||||
export { copyMarkdown } from "./utils/clipboard";
|
||||
export { ReadonlyContent } from "./readonly-content";
|
||||
|
||||
@@ -1,177 +0,0 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* ReadonlyContent — lightweight markdown renderer for readonly content display.
|
||||
*
|
||||
* Replaces <ContentEditor editable={false}> for comment cards and other
|
||||
* read-only surfaces. Uses react-markdown instead of a full Tiptap/ProseMirror
|
||||
* instance, eliminating EditorView, Plugin, and NodeView overhead.
|
||||
*
|
||||
* Visual parity with ContentEditor is achieved by:
|
||||
* - Wrapping output in <div class="rich-text-editor readonly"> so the same
|
||||
* content-editor.css rules apply to standard HTML tags
|
||||
* - Using the same preprocessMarkdown pipeline (mention shortcodes + linkify)
|
||||
* - Using lowlight for code highlighting (same engine as Tiptap's CodeBlockLowlight)
|
||||
* so .hljs-* CSS rules from content-editor.css produce identical colors
|
||||
* - Rendering mentions with the same IssueMentionCard component and .mention class
|
||||
*/
|
||||
|
||||
import { useMemo } from "react";
|
||||
import ReactMarkdown, {
|
||||
defaultUrlTransform,
|
||||
type Components,
|
||||
} from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import rehypeRaw from "rehype-raw";
|
||||
import { createLowlight, common } from "lowlight";
|
||||
import { toHtml } from "hast-util-to-html";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { IssueMentionCard } from "@/features/issues/components/issue-mention-card";
|
||||
import { preprocessMarkdown } from "./utils/preprocess";
|
||||
import "./content-editor.css";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Lowlight — same engine + language set as Tiptap's CodeBlockLowlight
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const lowlight = createLowlight(common);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// URL transform — allow mention:// protocol through react-markdown's sanitizer
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function urlTransform(url: string): string {
|
||||
if (url.startsWith("mention://")) return url;
|
||||
return defaultUrlTransform(url);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Custom react-markdown components
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const components: Partial<Components> = {
|
||||
// Links — route mention:// to mention components, others open in new tab
|
||||
a: ({ href, children }) => {
|
||||
if (href?.startsWith("mention://")) {
|
||||
const match = href.match(
|
||||
/^mention:\/\/(member|agent|issue|all)\/(.+)$/,
|
||||
);
|
||||
if (match?.[1] === "issue" && match[2]) {
|
||||
const label =
|
||||
typeof children === "string"
|
||||
? children
|
||||
: Array.isArray(children)
|
||||
? children.join("")
|
||||
: undefined;
|
||||
// Wrap in inline span for vertical alignment (mimics Tiptap's NodeViewWrapper)
|
||||
return (
|
||||
<span
|
||||
className="inline align-middle"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
window.open(`/issues/${match[2]}`, "_blank", "noopener,noreferrer");
|
||||
}}
|
||||
>
|
||||
<IssueMentionCard issueId={match[2]} fallbackLabel={label} />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
// Member / agent / all mentions
|
||||
return <span className="mention">{children}</span>;
|
||||
}
|
||||
|
||||
// Regular links — open in new tab (matches ContentEditor readonly behavior)
|
||||
return (
|
||||
<a
|
||||
href={href}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
if (href) window.open(href, "_blank", "noopener,noreferrer");
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
);
|
||||
},
|
||||
|
||||
// Images — constrain width (matches Tiptap Image extension inline style)
|
||||
img: ({ src, alt, ...props }) => (
|
||||
<img
|
||||
src={src}
|
||||
alt={alt ?? ""}
|
||||
style={{ maxWidth: "100%", height: "auto" }}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
|
||||
// Tables — wrap in tableWrapper div for border/radius/scroll (matches Tiptap)
|
||||
table: ({ children }) => (
|
||||
<div className="tableWrapper">
|
||||
<table>{children}</table>
|
||||
</div>
|
||||
),
|
||||
|
||||
// Code — lowlight highlighting for blocks, plain render for inline
|
||||
code: ({ className, children, node, ...props }) => {
|
||||
const lang = /language-(\w+)/.exec(className || "")?.[1];
|
||||
const isBlock =
|
||||
node?.position &&
|
||||
node.position.start.line !== node.position.end.line;
|
||||
|
||||
if (!isBlock && !lang) {
|
||||
// Inline code — CSS handles styling via .rich-text-editor code
|
||||
return <code {...props}>{children}</code>;
|
||||
}
|
||||
|
||||
// Block code — highlight with lowlight, output hljs classes
|
||||
const code = String(children).replace(/\n$/, "");
|
||||
try {
|
||||
const tree = lang
|
||||
? lowlight.highlight(lang, code)
|
||||
: lowlight.highlightAuto(code);
|
||||
return (
|
||||
<code
|
||||
className={cn("hljs", lang && `language-${lang}`)}
|
||||
dangerouslySetInnerHTML={{ __html: toHtml(tree) }}
|
||||
/>
|
||||
);
|
||||
} catch {
|
||||
// Fallback — render without highlighting
|
||||
return (
|
||||
<code className={className} {...props}>
|
||||
{children}
|
||||
</code>
|
||||
);
|
||||
}
|
||||
},
|
||||
|
||||
// Pre — pass through (CSS handles styling via .rich-text-editor pre)
|
||||
pre: ({ children }) => <pre>{children}</pre>,
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Component
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface ReadonlyContentProps {
|
||||
content: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function ReadonlyContent({ content, className }: ReadonlyContentProps) {
|
||||
const processed = useMemo(() => preprocessMarkdown(content), [content]);
|
||||
|
||||
return (
|
||||
<div className={cn("rich-text-editor readonly text-sm", className)}>
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[[remarkGfm, { singleTilde: false }]]}
|
||||
rehypePlugins={[rehypeRaw]}
|
||||
urlTransform={urlTransform}
|
||||
components={components}
|
||||
>
|
||||
{processed}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,13 +1 @@
|
||||
// Inbox server state is managed by TanStack Query.
|
||||
// See core/inbox/ for queries, mutations, and WS updaters.
|
||||
export {
|
||||
inboxKeys,
|
||||
inboxListOptions,
|
||||
deduplicateInboxItems,
|
||||
useMarkInboxRead,
|
||||
useArchiveInbox,
|
||||
useMarkAllInboxRead,
|
||||
useArchiveAllInbox,
|
||||
useArchiveAllReadInbox,
|
||||
useArchiveCompletedInbox,
|
||||
} from "@core/inbox";
|
||||
export { useInboxStore } from "./store";
|
||||
|
||||
127
apps/web/features/inbox/store.ts
Normal file
127
apps/web/features/inbox/store.ts
Normal file
@@ -0,0 +1,127 @@
|
||||
"use client";
|
||||
|
||||
import { create } from "zustand";
|
||||
import type { InboxItem, IssueStatus } from "@/shared/types";
|
||||
import { toast } from "sonner";
|
||||
import { api } from "@/shared/api";
|
||||
import { createLogger } from "@/shared/logger";
|
||||
|
||||
const logger = createLogger("inbox-store");
|
||||
|
||||
/**
|
||||
* Deduplicate inbox items by issue_id (one entry per issue, Linear-style),
|
||||
* keep latest, sort by time DESC.
|
||||
* Memoized by reference — returns the same array if `items` hasn't changed.
|
||||
*/
|
||||
let _prevItems: InboxItem[] = [];
|
||||
let _prevDeduped: InboxItem[] = [];
|
||||
|
||||
function deduplicateInboxItems(items: InboxItem[]): InboxItem[] {
|
||||
if (items === _prevItems) return _prevDeduped;
|
||||
_prevItems = items;
|
||||
|
||||
const active = items.filter((i) => !i.archived);
|
||||
const groups = new Map<string, InboxItem[]>();
|
||||
active.forEach((item) => {
|
||||
const key = item.issue_id ?? item.id;
|
||||
const group = groups.get(key) ?? [];
|
||||
group.push(item);
|
||||
groups.set(key, group);
|
||||
});
|
||||
const merged: InboxItem[] = [];
|
||||
groups.forEach((group) => {
|
||||
const sorted = group.sort(
|
||||
(a, b) =>
|
||||
new Date(b.created_at).getTime() - new Date(a.created_at).getTime(),
|
||||
);
|
||||
if (sorted[0]) merged.push(sorted[0]);
|
||||
});
|
||||
_prevDeduped = merged.sort(
|
||||
(a, b) =>
|
||||
new Date(b.created_at).getTime() - new Date(a.created_at).getTime(),
|
||||
);
|
||||
return _prevDeduped;
|
||||
}
|
||||
|
||||
interface InboxState {
|
||||
items: InboxItem[];
|
||||
loading: boolean;
|
||||
fetch: () => Promise<void>;
|
||||
setItems: (items: InboxItem[]) => void;
|
||||
addItem: (item: InboxItem) => void;
|
||||
markRead: (id: string) => void;
|
||||
archive: (id: string) => void;
|
||||
markAllRead: () => void;
|
||||
archiveAll: () => void;
|
||||
archiveAllRead: () => void;
|
||||
updateIssueStatus: (issueId: string, status: IssueStatus) => void;
|
||||
dedupedItems: () => InboxItem[];
|
||||
unreadCount: () => number;
|
||||
}
|
||||
|
||||
export const useInboxStore = create<InboxState>((set, get) => ({
|
||||
items: [],
|
||||
loading: true,
|
||||
|
||||
fetch: async () => {
|
||||
logger.debug("fetch start");
|
||||
const isInitialLoad = get().items.length === 0;
|
||||
if (isInitialLoad) set({ loading: true });
|
||||
try {
|
||||
const data = await api.listInbox();
|
||||
logger.info("fetched", data.length, "items");
|
||||
set({ items: data, loading: false });
|
||||
} catch (err) {
|
||||
logger.error("fetch failed", err);
|
||||
toast.error("Failed to load inbox");
|
||||
if (isInitialLoad) set({ loading: false });
|
||||
}
|
||||
},
|
||||
|
||||
setItems: (items) => set({ items }),
|
||||
addItem: (item) =>
|
||||
set((s) => ({
|
||||
items: s.items.some((i) => i.id === item.id)
|
||||
? s.items
|
||||
: [item, ...s.items],
|
||||
})),
|
||||
markRead: (id) =>
|
||||
set((s) => ({
|
||||
items: s.items.map((i) => (i.id === id ? { ...i, read: true } : i)),
|
||||
})),
|
||||
archive: (id) =>
|
||||
set((s) => {
|
||||
const target = s.items.find((i) => i.id === id);
|
||||
const issueId = target?.issue_id;
|
||||
return {
|
||||
items: s.items.map((i) =>
|
||||
i.id === id || (issueId && i.issue_id === issueId)
|
||||
? { ...i, archived: true }
|
||||
: i,
|
||||
),
|
||||
};
|
||||
}),
|
||||
markAllRead: () =>
|
||||
set((s) => ({
|
||||
items: s.items.map((i) => (!i.archived ? { ...i, read: true } : i)),
|
||||
})),
|
||||
archiveAll: () =>
|
||||
set((s) => ({
|
||||
items: s.items.map((i) => (!i.archived ? { ...i, archived: true } : i)),
|
||||
})),
|
||||
archiveAllRead: () =>
|
||||
set((s) => ({
|
||||
items: s.items.map((i) =>
|
||||
i.read && !i.archived ? { ...i, archived: true } : i
|
||||
),
|
||||
})),
|
||||
updateIssueStatus: (issueId, status) =>
|
||||
set((s) => ({
|
||||
items: s.items.map((i) =>
|
||||
i.issue_id === issueId ? { ...i, issue_status: status } : i
|
||||
),
|
||||
})),
|
||||
dedupedItems: () => deduplicateInboxItems(get().items),
|
||||
unreadCount: () =>
|
||||
get().dedupedItems().filter((i) => !i.read).length,
|
||||
}));
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback, useRef } from "react";
|
||||
import { Bot, ChevronRight, ChevronDown, Loader2, ArrowDown, Brain, AlertCircle, Clock, CheckCircle2, XCircle, Square, Maximize2 } from "lucide-react";
|
||||
import { Bot, ChevronRight, ChevronUp, Loader2, ArrowDown, Brain, AlertCircle, Clock, CheckCircle2, XCircle, Square } from "lucide-react";
|
||||
import { api } from "@/shared/api";
|
||||
import { useWSEvent } from "@/features/realtime";
|
||||
import type { TaskMessagePayload, TaskCompletedPayload, TaskFailedPayload, TaskCancelledPayload } from "@/shared/types/events";
|
||||
@@ -12,7 +12,6 @@ import { ActorAvatar } from "@/components/common/actor-avatar";
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
|
||||
import { useActorName } from "@/features/workspace";
|
||||
import { redactSecrets } from "../utils/redact";
|
||||
import { AgentTranscriptDialog } from "./agent-transcript-dialog";
|
||||
|
||||
// ─── Shared types & helpers ─────────────────────────────────────────────────
|
||||
|
||||
@@ -96,51 +95,49 @@ function buildTimeline(msgs: TaskMessagePayload[]): TimelineItem[] {
|
||||
return items.sort((a, b) => a.seq - b.seq);
|
||||
}
|
||||
|
||||
// ─── Per-task state ─────────────────────────────────────────────────────────
|
||||
|
||||
interface TaskState {
|
||||
task: AgentTask;
|
||||
items: TimelineItem[];
|
||||
}
|
||||
|
||||
// ─── AgentLiveCard (real-time view for multiple agents) ───────────────────
|
||||
// ─── AgentLiveCard (real-time view) ────────────────────────────────────────
|
||||
|
||||
interface AgentLiveCardProps {
|
||||
issueId: string;
|
||||
/** Scroll container ref — used to auto-collapse timeline on outer scroll. */
|
||||
agentName?: string;
|
||||
/** Scroll container ref — needed for sticky sentinel detection. */
|
||||
scrollContainerRef?: React.RefObject<HTMLDivElement | null>;
|
||||
}
|
||||
|
||||
export function AgentLiveCard({ issueId, scrollContainerRef }: AgentLiveCardProps) {
|
||||
export function AgentLiveCard({ issueId, agentName, scrollContainerRef }: AgentLiveCardProps) {
|
||||
const { getActorName } = useActorName();
|
||||
const [taskStates, setTaskStates] = useState<Map<string, TaskState>>(new Map());
|
||||
const [activeTask, setActiveTask] = useState<AgentTask | null>(null);
|
||||
const [items, setItems] = useState<TimelineItem[]>([]);
|
||||
const [elapsed, setElapsed] = useState("");
|
||||
const [autoScroll, setAutoScroll] = useState(true);
|
||||
const [cancelling, setCancelling] = useState(false);
|
||||
const [isStuck, setIsStuck] = useState(false);
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const sentinelRef = useRef<HTMLDivElement>(null);
|
||||
const seenSeqs = useRef(new Set<string>());
|
||||
|
||||
// Fetch active tasks on mount
|
||||
// Check for active task on mount
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
api.getActiveTasksForIssue(issueId).then(({ tasks }) => {
|
||||
if (cancelled || tasks.length === 0) return;
|
||||
const newStates = new Map<string, TaskState>();
|
||||
const loadPromises = tasks.map(async (task) => {
|
||||
try {
|
||||
const msgs = await api.listTaskMessages(task.id);
|
||||
const timeline = buildTimeline(msgs);
|
||||
for (const m of msgs) seenSeqs.current.add(`${m.task_id}:${m.seq}`);
|
||||
newStates.set(task.id, { task, items: timeline });
|
||||
} catch {
|
||||
newStates.set(task.id, { task, items: [] });
|
||||
api.getActiveTaskForIssue(issueId).then(({ task }) => {
|
||||
if (!cancelled) {
|
||||
setActiveTask(task);
|
||||
if (task) {
|
||||
api.listTaskMessages(task.id).then((msgs) => {
|
||||
if (!cancelled) {
|
||||
const timeline = buildTimeline(msgs);
|
||||
setItems(timeline);
|
||||
for (const m of msgs) seenSeqs.current.add(`${m.task_id}:${m.seq}`);
|
||||
}
|
||||
}).catch(console.error);
|
||||
}
|
||||
});
|
||||
Promise.all(loadPromises).then(() => {
|
||||
if (!cancelled) setTaskStates(newStates);
|
||||
});
|
||||
}
|
||||
}).catch(console.error);
|
||||
|
||||
return () => { cancelled = true; };
|
||||
}, [issueId]);
|
||||
|
||||
// Handle real-time task messages — route by task_id
|
||||
// Handle real-time task messages
|
||||
useWSEvent(
|
||||
"task:message",
|
||||
useCallback((payload: unknown) => {
|
||||
@@ -150,124 +147,110 @@ export function AgentLiveCard({ issueId, scrollContainerRef }: AgentLiveCardProp
|
||||
if (seenSeqs.current.has(key)) return;
|
||||
seenSeqs.current.add(key);
|
||||
|
||||
const item: TimelineItem = {
|
||||
seq: msg.seq,
|
||||
type: msg.type,
|
||||
tool: msg.tool,
|
||||
content: msg.content,
|
||||
input: msg.input,
|
||||
output: msg.output,
|
||||
};
|
||||
|
||||
setTaskStates((prev) => {
|
||||
const next = new Map(prev);
|
||||
const existing = next.get(msg.task_id);
|
||||
if (existing) {
|
||||
const items = [...existing.items, item].sort((a, b) => a.seq - b.seq);
|
||||
next.set(msg.task_id, { ...existing, items });
|
||||
}
|
||||
// If we don't have this task yet, the dispatch handler will pick it up
|
||||
setItems((prev) => {
|
||||
const item: TimelineItem = {
|
||||
seq: msg.seq,
|
||||
type: msg.type,
|
||||
tool: msg.tool,
|
||||
content: msg.content,
|
||||
input: msg.input,
|
||||
output: msg.output,
|
||||
};
|
||||
const next = [...prev, item];
|
||||
next.sort((a, b) => a.seq - b.seq);
|
||||
return next;
|
||||
});
|
||||
}, [issueId]),
|
||||
);
|
||||
|
||||
// Handle task end events — remove only the specific task
|
||||
const handleTaskEnd = useCallback((payload: unknown) => {
|
||||
const p = payload as { task_id: string; issue_id: string };
|
||||
if (p.issue_id !== issueId) return;
|
||||
setTaskStates((prev) => {
|
||||
const next = new Map(prev);
|
||||
next.delete(p.task_id);
|
||||
return next;
|
||||
});
|
||||
}, [issueId]);
|
||||
|
||||
useWSEvent("task:completed", handleTaskEnd);
|
||||
useWSEvent("task:failed", handleTaskEnd);
|
||||
useWSEvent("task:cancelled", handleTaskEnd);
|
||||
|
||||
// Pick up newly dispatched tasks
|
||||
// Handle task completion/failure
|
||||
useWSEvent(
|
||||
"task:dispatch",
|
||||
useCallback(() => {
|
||||
api.getActiveTasksForIssue(issueId).then(({ tasks }) => {
|
||||
setTaskStates((prev) => {
|
||||
const next = new Map(prev);
|
||||
for (const task of tasks) {
|
||||
if (!next.has(task.id)) {
|
||||
next.set(task.id, { task, items: [] });
|
||||
}
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}).catch(console.error);
|
||||
"task:completed",
|
||||
useCallback((payload: unknown) => {
|
||||
const p = payload as TaskCompletedPayload;
|
||||
if (p.issue_id !== issueId) return;
|
||||
setActiveTask(null);
|
||||
setItems([]);
|
||||
seenSeqs.current.clear();
|
||||
setCancelling(false);
|
||||
}, [issueId]),
|
||||
);
|
||||
|
||||
if (taskStates.size === 0) return null;
|
||||
|
||||
const entries = Array.from(taskStates.values());
|
||||
|
||||
return (
|
||||
<div className="mt-4 space-y-2">
|
||||
{entries.map(({ task, items }) => (
|
||||
<SingleAgentLiveCard
|
||||
key={task.id}
|
||||
task={task}
|
||||
items={items}
|
||||
issueId={issueId}
|
||||
agentName={task.agent_id ? getActorName("agent", task.agent_id) : "Agent"}
|
||||
scrollContainerRef={scrollContainerRef}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
useWSEvent(
|
||||
"task:failed",
|
||||
useCallback((payload: unknown) => {
|
||||
const p = payload as TaskFailedPayload;
|
||||
if (p.issue_id !== issueId) return;
|
||||
setActiveTask(null);
|
||||
setItems([]);
|
||||
seenSeqs.current.clear();
|
||||
setCancelling(false);
|
||||
}, [issueId]),
|
||||
);
|
||||
}
|
||||
|
||||
// ─── SingleAgentLiveCard (one card per running task) ──────────────────────
|
||||
useWSEvent(
|
||||
"task:cancelled",
|
||||
useCallback((payload: unknown) => {
|
||||
const p = payload as TaskCancelledPayload;
|
||||
if (p.issue_id !== issueId) return;
|
||||
setActiveTask(null);
|
||||
setItems([]);
|
||||
seenSeqs.current.clear();
|
||||
setCancelling(false);
|
||||
}, [issueId]),
|
||||
);
|
||||
|
||||
interface SingleAgentLiveCardProps {
|
||||
task: AgentTask;
|
||||
items: TimelineItem[];
|
||||
issueId: string;
|
||||
agentName: string;
|
||||
scrollContainerRef?: React.RefObject<HTMLDivElement | null>;
|
||||
}
|
||||
|
||||
function SingleAgentLiveCard({ task, items, issueId, agentName, scrollContainerRef }: SingleAgentLiveCardProps) {
|
||||
const [elapsed, setElapsed] = useState("");
|
||||
const [open, setOpen] = useState(false);
|
||||
const [autoScroll, setAutoScroll] = useState(true);
|
||||
const [cancelling, setCancelling] = useState(false);
|
||||
const [transcriptOpen, setTranscriptOpen] = useState(false);
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const ignoreScrollRef = useRef(false);
|
||||
// Pick up new tasks — skip if we're already showing an active task to avoid
|
||||
// replacing its timeline mid-execution (per-issue serialization in the
|
||||
// backend prevents this race, but this is a defensive safeguard).
|
||||
useWSEvent(
|
||||
"task:dispatch",
|
||||
useCallback(() => {
|
||||
if (activeTask) return;
|
||||
api.getActiveTaskForIssue(issueId).then(({ task }) => {
|
||||
if (task) {
|
||||
setActiveTask(task);
|
||||
setItems([]);
|
||||
seenSeqs.current.clear();
|
||||
}
|
||||
}).catch(console.error);
|
||||
}, [issueId, activeTask]),
|
||||
);
|
||||
|
||||
// Elapsed time
|
||||
useEffect(() => {
|
||||
if (!task.started_at && !task.dispatched_at) return;
|
||||
const startRef = task.started_at ?? task.dispatched_at!;
|
||||
if (!activeTask?.started_at && !activeTask?.dispatched_at) return;
|
||||
const startRef = activeTask.started_at ?? activeTask.dispatched_at!;
|
||||
setElapsed(formatElapsed(startRef));
|
||||
const interval = setInterval(() => setElapsed(formatElapsed(startRef)), 1000);
|
||||
return () => clearInterval(interval);
|
||||
}, [task.started_at, task.dispatched_at]);
|
||||
}, [activeTask?.started_at, activeTask?.dispatched_at]);
|
||||
|
||||
// Auto-collapse timeline when outer scroll container scrolls
|
||||
// Sentinel pattern: detect when the card is scrolled past and becomes "stuck"
|
||||
useEffect(() => {
|
||||
const container = scrollContainerRef?.current;
|
||||
if (!container) return;
|
||||
const sentinel = sentinelRef.current;
|
||||
const root = scrollContainerRef?.current;
|
||||
if (!sentinel || !root || !activeTask) {
|
||||
setIsStuck(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const handleOuterScroll = () => {
|
||||
if (ignoreScrollRef.current) return;
|
||||
setOpen(false);
|
||||
};
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
if (entries[0]) setIsStuck(!entries[0].isIntersecting);
|
||||
},
|
||||
{ root, threshold: 0, rootMargin: "-40px 0px 0px 0px" },
|
||||
);
|
||||
|
||||
container.addEventListener("scroll", handleOuterScroll, { passive: true });
|
||||
return () => container.removeEventListener("scroll", handleOuterScroll);
|
||||
}, [scrollContainerRef]);
|
||||
observer.observe(sentinel);
|
||||
return () => observer.disconnect();
|
||||
}, [scrollContainerRef, activeTask]);
|
||||
|
||||
// Auto-scroll timeline to bottom
|
||||
const scrollToCard = useCallback(() => {
|
||||
sentinelRef.current?.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||
}, []);
|
||||
|
||||
// Auto-scroll
|
||||
useEffect(() => {
|
||||
if (autoScroll && scrollRef.current) {
|
||||
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
|
||||
@@ -280,92 +263,94 @@ function SingleAgentLiveCard({ task, items, issueId, agentName, scrollContainerR
|
||||
setAutoScroll(scrollHeight - scrollTop - clientHeight < 40);
|
||||
}, []);
|
||||
|
||||
const toggleOpen = useCallback(() => {
|
||||
if (!open) {
|
||||
ignoreScrollRef.current = true;
|
||||
setTimeout(() => { ignoreScrollRef.current = false; }, 300);
|
||||
}
|
||||
setOpen(!open);
|
||||
}, [open]);
|
||||
|
||||
const handleCancel = useCallback(async () => {
|
||||
if (cancelling) return;
|
||||
if (!activeTask || cancelling) return;
|
||||
setCancelling(true);
|
||||
try {
|
||||
await api.cancelTask(issueId, task.id);
|
||||
await api.cancelTask(issueId, activeTask.id);
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : "Failed to cancel task");
|
||||
setCancelling(false);
|
||||
}
|
||||
}, [task.id, issueId, cancelling]);
|
||||
}, [activeTask, issueId, cancelling]);
|
||||
|
||||
if (!activeTask) return null;
|
||||
|
||||
const toolCount = items.filter((i) => i.type === "tool_use").length;
|
||||
const name = (activeTask.agent_id ? getActorName("agent", activeTask.agent_id) : agentName) ?? "Agent";
|
||||
|
||||
return (
|
||||
<div className="sticky top-4 z-10 rounded-lg border border-info/20 bg-info/5 backdrop-blur-sm">
|
||||
{/* Header — click to toggle timeline */}
|
||||
<div
|
||||
className="group flex items-center gap-2 px-3 py-2 cursor-pointer select-none text-muted-foreground hover:text-foreground transition-colors"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-expanded={open}
|
||||
onClick={toggleOpen}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
toggleOpen();
|
||||
}
|
||||
}}
|
||||
>
|
||||
{task.agent_id ? (
|
||||
<ActorAvatar actorType="agent" actorId={task.agent_id} size={20} />
|
||||
) : (
|
||||
<div className="flex items-center justify-center h-5 w-5 rounded-full shrink-0 bg-info/10 text-info">
|
||||
<Bot className="h-3 w-3" />
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-1.5 text-xs min-w-0">
|
||||
<Loader2 className="h-3 w-3 animate-spin text-info shrink-0" />
|
||||
<span className="font-medium text-foreground truncate">{agentName} is working</span>
|
||||
<span className="text-muted-foreground tabular-nums shrink-0">{elapsed}</span>
|
||||
{toolCount > 0 && (
|
||||
<span className="text-muted-foreground shrink-0">{toolCount} tools</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="ml-auto flex items-center gap-1 shrink-0">
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); setTranscriptOpen(true); }}
|
||||
className="flex items-center justify-center rounded p-1 text-muted-foreground hover:text-foreground hover:bg-accent/50 transition-colors"
|
||||
title="Expand transcript"
|
||||
>
|
||||
<Maximize2 className="h-3 w-3" />
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); handleCancel(); }}
|
||||
disabled={cancelling}
|
||||
className="flex items-center gap-1 rounded px-1.5 py-0.5 text-xs text-muted-foreground hover:text-destructive hover:bg-destructive/10 transition-colors disabled:opacity-50"
|
||||
title="Stop agent"
|
||||
>
|
||||
{cancelling ? <Loader2 className="h-3 w-3 animate-spin" /> : <Square className="h-3 w-3" />}
|
||||
<span>Stop</span>
|
||||
</button>
|
||||
<ChevronDown className={cn("h-3.5 w-3.5 transition-transform", open && "rotate-180")} />
|
||||
</div>
|
||||
</div>
|
||||
<>
|
||||
{/* Sentinel — zero-height element that IntersectionObserver watches */}
|
||||
<div ref={sentinelRef} className="mt-4 h-0 pointer-events-none" aria-hidden />
|
||||
|
||||
{/* Timeline — grid-rows animation for smooth collapse/expand */}
|
||||
<div
|
||||
className={cn(
|
||||
"grid transition-[grid-template-rows] duration-200 ease-out",
|
||||
open ? "grid-rows-[1fr]" : "grid-rows-[0fr]",
|
||||
"rounded-lg border transition-all duration-200",
|
||||
isStuck
|
||||
? "sticky top-4 z-10 shadow-md border-brand/30 bg-brand/10 backdrop-blur-md"
|
||||
: "border-info/20 bg-info/5",
|
||||
)}
|
||||
>
|
||||
<div className="overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-2 px-3 py-2">
|
||||
{activeTask.agent_id ? (
|
||||
<ActorAvatar actorType="agent" actorId={activeTask.agent_id} size={20} />
|
||||
) : (
|
||||
<div className={cn(
|
||||
"flex items-center justify-center h-5 w-5 rounded-full shrink-0",
|
||||
isStuck ? "bg-brand/15 text-brand" : "bg-info/10 text-info",
|
||||
)}>
|
||||
<Bot className="h-3 w-3" />
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-1.5 text-xs font-medium min-w-0">
|
||||
<Loader2 className={cn("h-3 w-3 animate-spin shrink-0", isStuck ? "text-brand" : "text-info")} />
|
||||
<span className="truncate">{name} is working</span>
|
||||
</div>
|
||||
<span className="ml-auto text-xs text-muted-foreground tabular-nums shrink-0">{elapsed}</span>
|
||||
{!isStuck && toolCount > 0 && (
|
||||
<span className="text-xs text-muted-foreground shrink-0">
|
||||
{toolCount} tool {toolCount === 1 ? "call" : "calls"}
|
||||
</span>
|
||||
)}
|
||||
{isStuck ? (
|
||||
<button
|
||||
onClick={scrollToCard}
|
||||
className="flex items-center gap-1 rounded px-1.5 py-0.5 text-xs text-muted-foreground hover:text-foreground transition-colors shrink-0"
|
||||
title="Scroll to live card"
|
||||
>
|
||||
<ChevronUp className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={handleCancel}
|
||||
disabled={cancelling}
|
||||
className="flex items-center gap-1 rounded px-1.5 py-0.5 text-xs text-muted-foreground hover:text-destructive hover:bg-destructive/10 transition-colors disabled:opacity-50 shrink-0"
|
||||
title="Stop agent"
|
||||
>
|
||||
{cancelling ? (
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
) : (
|
||||
<Square className="h-3 w-3" />
|
||||
)}
|
||||
<span>Stop</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Timeline content — collapses when stuck */}
|
||||
<div
|
||||
className={cn(
|
||||
"overflow-hidden transition-all duration-200",
|
||||
isStuck ? "max-h-0 opacity-0" : "max-h-[20rem] opacity-100",
|
||||
)}
|
||||
>
|
||||
{items.length > 0 && (
|
||||
<div
|
||||
ref={scrollRef}
|
||||
onScroll={handleScroll}
|
||||
className="relative max-h-80 overflow-y-auto overscroll-y-contain border-t border-info/10 px-3 py-2 space-y-0.5"
|
||||
className="relative max-h-80 overflow-y-auto border-t border-info/10 px-3 py-2 space-y-0.5"
|
||||
>
|
||||
{items.map((item, idx) => (
|
||||
<TimelineRow key={`${item.seq}-${idx}`} item={item} />
|
||||
@@ -373,8 +358,7 @@ function SingleAgentLiveCard({ task, items, issueId, agentName, scrollContainerR
|
||||
|
||||
{!autoScroll && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onClick={() => {
|
||||
if (scrollRef.current) {
|
||||
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
|
||||
setAutoScroll(true);
|
||||
@@ -390,17 +374,7 @@ function SingleAgentLiveCard({ task, items, issueId, agentName, scrollContainerR
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Fullscreen transcript dialog */}
|
||||
<AgentTranscriptDialog
|
||||
open={transcriptOpen}
|
||||
onOpenChange={setTranscriptOpen}
|
||||
task={task}
|
||||
items={items}
|
||||
agentName={agentName}
|
||||
isLive
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -469,10 +443,8 @@ export function TaskRunHistory({ issueId }: TaskRunHistoryProps) {
|
||||
}
|
||||
|
||||
function TaskRunEntry({ task }: { task: AgentTask }) {
|
||||
const { getActorName } = useActorName();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [items, setItems] = useState<TimelineItem[] | null>(null);
|
||||
const [transcriptOpen, setTranscriptOpen] = useState(false);
|
||||
|
||||
const loadMessages = useCallback(() => {
|
||||
if (items !== null) return; // already loaded
|
||||
@@ -508,24 +480,6 @@ function TaskRunEntry({ task }: { task: AgentTask }) {
|
||||
<span className={cn("ml-auto capitalize", task.status === "completed" ? "text-success" : "text-destructive")}>
|
||||
{task.status}
|
||||
</span>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
// Load messages before opening the transcript dialog
|
||||
if (items === null) {
|
||||
api.listTaskMessages(task.id).then((msgs) => {
|
||||
setItems(buildTimeline(msgs));
|
||||
setTranscriptOpen(true);
|
||||
}).catch(console.error);
|
||||
} else {
|
||||
setTranscriptOpen(true);
|
||||
}
|
||||
}}
|
||||
className="flex items-center justify-center rounded p-0.5 text-muted-foreground hover:text-foreground hover:bg-accent/50 transition-colors"
|
||||
title="Expand transcript"
|
||||
>
|
||||
<Maximize2 className="h-3 w-3" />
|
||||
</button>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent>
|
||||
<div className="ml-5 mt-1 max-h-64 overflow-y-auto rounded border bg-muted/30 px-3 py-2 space-y-0.5">
|
||||
@@ -543,17 +497,6 @@ function TaskRunEntry({ task }: { task: AgentTask }) {
|
||||
)}
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
|
||||
{/* Fullscreen transcript dialog */}
|
||||
{items !== null && (
|
||||
<AgentTranscriptDialog
|
||||
open={transcriptOpen}
|
||||
onOpenChange={setTranscriptOpen}
|
||||
task={task}
|
||||
items={items}
|
||||
agentName={task.agent_id ? getActorName("agent", task.agent_id) : "Agent"}
|
||||
/>
|
||||
)}
|
||||
</Collapsible>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,628 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useRef, useCallback, useEffect } from "react";
|
||||
import {
|
||||
Bot,
|
||||
ChevronRight,
|
||||
Brain,
|
||||
AlertCircle,
|
||||
CheckCircle2,
|
||||
XCircle,
|
||||
X,
|
||||
Loader2,
|
||||
Clock,
|
||||
Copy,
|
||||
Check,
|
||||
Monitor,
|
||||
Cloud,
|
||||
Cpu,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog";
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
|
||||
import { ActorAvatar } from "@/components/common/actor-avatar";
|
||||
import { api } from "@/shared/api";
|
||||
import type { AgentTask, Agent, AgentRuntime } from "@/shared/types/agent";
|
||||
import { redactSecrets } from "../utils/redact";
|
||||
|
||||
// ─── Types ──────────────────────────────────────────────────────────────────
|
||||
|
||||
interface TimelineItem {
|
||||
seq: number;
|
||||
type: "tool_use" | "tool_result" | "thinking" | "text" | "error";
|
||||
tool?: string;
|
||||
content?: string;
|
||||
input?: Record<string, unknown>;
|
||||
output?: string;
|
||||
}
|
||||
|
||||
interface AgentTranscriptDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
task: AgentTask;
|
||||
items: TimelineItem[];
|
||||
agentName: string;
|
||||
isLive?: boolean;
|
||||
}
|
||||
|
||||
// ─── Color mapping for timeline segments ────────────────────────────────────
|
||||
|
||||
type EventColor = "agent" | "thinking" | "tool" | "result" | "error";
|
||||
|
||||
function getEventColor(item: TimelineItem): EventColor {
|
||||
switch (item.type) {
|
||||
case "text":
|
||||
return "agent";
|
||||
case "thinking":
|
||||
return "thinking";
|
||||
case "tool_use":
|
||||
return "tool";
|
||||
case "tool_result":
|
||||
return "result";
|
||||
case "error":
|
||||
return "error";
|
||||
default:
|
||||
return "result";
|
||||
}
|
||||
}
|
||||
|
||||
const colorClasses: Record<EventColor, { bg: string; bgActive: string; label: string }> = {
|
||||
agent: { bg: "bg-emerald-400/60", bgActive: "bg-emerald-500", label: "bg-emerald-500" },
|
||||
thinking: { bg: "bg-violet-400/60", bgActive: "bg-violet-500", label: "bg-violet-500/20 text-violet-700 dark:text-violet-300" },
|
||||
tool: { bg: "bg-blue-400/60", bgActive: "bg-blue-500", label: "bg-blue-500/20 text-blue-700 dark:text-blue-300" },
|
||||
result: { bg: "bg-slate-300/60 dark:bg-slate-600/60", bgActive: "bg-slate-400 dark:bg-slate-500", label: "bg-muted text-muted-foreground" },
|
||||
error: { bg: "bg-red-400/60", bgActive: "bg-red-500", label: "bg-red-500/20 text-red-700 dark:text-red-300" },
|
||||
};
|
||||
|
||||
// ─── Helpers ────────────────────────────────────────────────────────────────
|
||||
|
||||
function getEventLabel(item: TimelineItem): string {
|
||||
switch (item.type) {
|
||||
case "text":
|
||||
return "Agent";
|
||||
case "thinking":
|
||||
return "Thinking";
|
||||
case "tool_use":
|
||||
return item.tool ?? "Tool";
|
||||
case "tool_result":
|
||||
return item.tool ? `${item.tool}` : "Result";
|
||||
case "error":
|
||||
return "Error";
|
||||
default:
|
||||
return "Event";
|
||||
}
|
||||
}
|
||||
|
||||
function getEventSummary(item: TimelineItem): string {
|
||||
switch (item.type) {
|
||||
case "text":
|
||||
return item.content?.split("\n").filter(Boolean).pop() ?? "";
|
||||
case "thinking":
|
||||
return item.content?.slice(0, 200) ?? "";
|
||||
case "tool_use": {
|
||||
if (!item.input) return "";
|
||||
const inp = item.input as Record<string, string>;
|
||||
if (inp.query) return inp.query;
|
||||
if (inp.file_path) return shortenPath(inp.file_path);
|
||||
if (inp.path) return shortenPath(inp.path);
|
||||
if (inp.pattern) return inp.pattern;
|
||||
if (inp.description) return String(inp.description);
|
||||
if (inp.command) {
|
||||
const cmd = String(inp.command);
|
||||
return cmd.length > 120 ? cmd.slice(0, 120) + "..." : cmd;
|
||||
}
|
||||
if (inp.prompt) {
|
||||
const p = String(inp.prompt);
|
||||
return p.length > 120 ? p.slice(0, 120) + "..." : p;
|
||||
}
|
||||
if (inp.skill) return String(inp.skill);
|
||||
for (const v of Object.values(inp)) {
|
||||
if (typeof v === "string" && v.length > 0 && v.length < 120) return v;
|
||||
}
|
||||
return "";
|
||||
}
|
||||
case "tool_result":
|
||||
return item.output?.slice(0, 200) ?? "";
|
||||
case "error":
|
||||
return item.content ?? "";
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
function shortenPath(p: string): string {
|
||||
const parts = p.split("/");
|
||||
if (parts.length <= 3) return p;
|
||||
return ".../" + parts.slice(-2).join("/");
|
||||
}
|
||||
|
||||
function formatDuration(start: string, end: string): string {
|
||||
const ms = new Date(end).getTime() - new Date(start).getTime();
|
||||
const seconds = Math.floor(ms / 1000);
|
||||
if (seconds < 60) return `${seconds}s`;
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const secs = seconds % 60;
|
||||
return `${minutes}m ${secs}s`;
|
||||
}
|
||||
|
||||
function formatElapsedMs(ms: number): string {
|
||||
const seconds = Math.floor(ms / 1000);
|
||||
if (seconds < 60) return `${seconds}s`;
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const secs = seconds % 60;
|
||||
return `${minutes}m ${secs}s`;
|
||||
}
|
||||
|
||||
// ─── Main dialog ────────────────────────────────────────────────────────────
|
||||
|
||||
export function AgentTranscriptDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
task,
|
||||
items,
|
||||
agentName,
|
||||
isLive = false,
|
||||
}: AgentTranscriptDialogProps) {
|
||||
const [selectedIdx, setSelectedIdx] = useState<number | null>(null);
|
||||
const [elapsed, setElapsed] = useState("");
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [agentInfo, setAgentInfo] = useState<Agent | null>(null);
|
||||
const [runtimeInfo, setRuntimeInfo] = useState<AgentRuntime | null>(null);
|
||||
const eventRefs = useRef<Map<number, HTMLDivElement>>(new Map());
|
||||
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Fetch agent and runtime metadata when dialog opens
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
let cancelled = false;
|
||||
|
||||
if (task.agent_id) {
|
||||
api.getAgent(task.agent_id).then((agent) => {
|
||||
if (!cancelled) setAgentInfo(agent);
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
if (task.runtime_id) {
|
||||
api.listRuntimes().then((runtimes) => {
|
||||
if (cancelled) return;
|
||||
const rt = runtimes.find((r) => r.id === task.runtime_id);
|
||||
if (rt) setRuntimeInfo(rt);
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
return () => { cancelled = true; };
|
||||
}, [open, task.agent_id, task.runtime_id]);
|
||||
|
||||
// Elapsed time for live tasks
|
||||
useEffect(() => {
|
||||
if (!isLive || (!task.started_at && !task.dispatched_at)) return;
|
||||
const startRef = task.started_at ?? task.dispatched_at!;
|
||||
const update = () => setElapsed(formatElapsedMs(Date.now() - new Date(startRef).getTime()));
|
||||
update();
|
||||
const interval = setInterval(update, 1000);
|
||||
return () => clearInterval(interval);
|
||||
}, [isLive, task.started_at, task.dispatched_at]);
|
||||
|
||||
// Click a timeline segment → scroll to event
|
||||
const handleSegmentClick = useCallback((idx: number) => {
|
||||
setSelectedIdx(idx);
|
||||
const el = eventRefs.current.get(idx);
|
||||
if (el) {
|
||||
el.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Copy all events as text
|
||||
const handleCopyAll = useCallback(() => {
|
||||
const text = items
|
||||
.map((item) => {
|
||||
const label = getEventLabel(item);
|
||||
const summary = getEventSummary(item);
|
||||
return `[${label}] ${summary}`;
|
||||
})
|
||||
.join("\n");
|
||||
navigator.clipboard.writeText(text).then(() => {
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
});
|
||||
}, [items]);
|
||||
|
||||
// Duration
|
||||
const duration =
|
||||
task.started_at && task.completed_at
|
||||
? formatDuration(task.started_at, task.completed_at)
|
||||
: isLive
|
||||
? elapsed
|
||||
: null;
|
||||
|
||||
const toolCount = items.filter((i) => i.type === "tool_use").length;
|
||||
|
||||
// Status display
|
||||
const statusBadge = isLive ? (
|
||||
<span className="inline-flex items-center gap-1 rounded-full bg-info/15 px-2 py-0.5 text-xs font-medium text-info">
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
Running
|
||||
</span>
|
||||
) : task.status === "completed" ? (
|
||||
<span className="inline-flex items-center gap-1 rounded-full bg-success/15 px-2 py-0.5 text-xs font-medium text-success">
|
||||
<CheckCircle2 className="h-3 w-3" />
|
||||
Completed
|
||||
</span>
|
||||
) : task.status === "failed" ? (
|
||||
<span className="inline-flex items-center gap-1 rounded-full bg-destructive/15 px-2 py-0.5 text-xs font-medium text-destructive">
|
||||
<XCircle className="h-3 w-3" />
|
||||
Failed
|
||||
</span>
|
||||
) : (
|
||||
<span className="inline-flex items-center gap-1 rounded-full bg-muted px-2 py-0.5 text-xs font-medium text-muted-foreground capitalize">
|
||||
{task.status}
|
||||
</span>
|
||||
);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent
|
||||
className="!max-w-4xl !w-[calc(100vw-4rem)] !max-h-[calc(100vh-4rem)] !h-[calc(100vh-4rem)] flex flex-col !p-0 !gap-0 overflow-hidden"
|
||||
showCloseButton={false}
|
||||
>
|
||||
<DialogTitle className="sr-only">Agent Execution Transcript</DialogTitle>
|
||||
|
||||
{/* ── Header ─────────────────────────────────────────────── */}
|
||||
<div className="border-b px-4 py-3 shrink-0 space-y-2">
|
||||
{/* Top row: agent name, status, actions */}
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
{task.agent_id ? (
|
||||
<ActorAvatar actorType="agent" actorId={task.agent_id} size={24} />
|
||||
) : (
|
||||
<div className="flex items-center justify-center h-6 w-6 rounded-full bg-info/10 text-info">
|
||||
<Bot className="h-3.5 w-3.5" />
|
||||
</div>
|
||||
)}
|
||||
<span className="font-medium text-sm">{agentName}</span>
|
||||
</div>
|
||||
|
||||
{statusBadge}
|
||||
|
||||
<div className="ml-auto flex items-center gap-1">
|
||||
<button
|
||||
onClick={handleCopyAll}
|
||||
className="flex items-center gap-1 rounded px-2 py-1 text-xs text-muted-foreground hover:text-foreground hover:bg-accent transition-colors"
|
||||
>
|
||||
{copied ? <Check className="h-3 w-3" /> : <Copy className="h-3 w-3" />}
|
||||
{copied ? "Copied" : "Copy all"}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onOpenChange(false)}
|
||||
className="flex items-center justify-center rounded p-1 text-muted-foreground hover:text-foreground hover:bg-accent transition-colors"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Metadata chips row */}
|
||||
<div className="flex items-center gap-2 flex-wrap text-xs">
|
||||
{/* Runtime provider */}
|
||||
{runtimeInfo?.provider && (
|
||||
<MetadataChip icon={<Cpu className="h-3 w-3" />}>
|
||||
{formatProvider(runtimeInfo.provider)}
|
||||
</MetadataChip>
|
||||
)}
|
||||
|
||||
{/* Runtime environment */}
|
||||
{runtimeInfo && (
|
||||
<MetadataChip
|
||||
icon={runtimeInfo.runtime_mode === "cloud" ? <Cloud className="h-3 w-3" /> : <Monitor className="h-3 w-3" />}
|
||||
>
|
||||
{runtimeInfo.name}
|
||||
<span className="text-muted-foreground/60 ml-0.5">({runtimeInfo.runtime_mode})</span>
|
||||
</MetadataChip>
|
||||
)}
|
||||
|
||||
{/* Agent type / description */}
|
||||
{agentInfo?.description && (
|
||||
<MetadataChip icon={<Bot className="h-3 w-3" />}>
|
||||
{agentInfo.description.length > 40 ? agentInfo.description.slice(0, 40) + "..." : agentInfo.description}
|
||||
</MetadataChip>
|
||||
)}
|
||||
|
||||
{/* Duration */}
|
||||
{duration && (
|
||||
<MetadataChip icon={<Clock className="h-3 w-3" />}>
|
||||
{duration}
|
||||
</MetadataChip>
|
||||
)}
|
||||
|
||||
{/* Event counts */}
|
||||
{toolCount > 0 && (
|
||||
<MetadataChip>{toolCount} tool calls</MetadataChip>
|
||||
)}
|
||||
<MetadataChip>{items.length} events</MetadataChip>
|
||||
|
||||
{/* Created time */}
|
||||
{task.created_at && (
|
||||
<MetadataChip>
|
||||
{new Date(task.created_at).toLocaleString(undefined, {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
})}
|
||||
</MetadataChip>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Timeline progress bar ─────────────────────────────── */}
|
||||
{items.length > 0 && (
|
||||
<div className="border-b px-4 py-2.5 shrink-0">
|
||||
<TimelineBar
|
||||
items={items}
|
||||
selectedIdx={selectedIdx}
|
||||
onSegmentClick={handleSegmentClick}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Event list ─────────────────────────────────────────── */}
|
||||
<div
|
||||
ref={scrollContainerRef}
|
||||
className="flex-1 overflow-y-auto min-h-0"
|
||||
>
|
||||
{items.length === 0 ? (
|
||||
<div className="flex items-center justify-center h-full text-sm text-muted-foreground">
|
||||
{isLive ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
Waiting for events...
|
||||
</div>
|
||||
) : (
|
||||
"No execution data recorded."
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y">
|
||||
{items.map((item, idx) => (
|
||||
<TranscriptEventRow
|
||||
key={`${item.seq}-${idx}`}
|
||||
ref={(el) => {
|
||||
if (el) eventRefs.current.set(idx, el);
|
||||
else eventRefs.current.delete(idx);
|
||||
}}
|
||||
item={item}
|
||||
index={idx}
|
||||
isSelected={selectedIdx === idx}
|
||||
onClick={() => setSelectedIdx(idx === selectedIdx ? null : idx)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Timeline bar (colored segments) ────────────────────────────────────────
|
||||
|
||||
// ─── Metadata chip ──────────────────────────────────────────────────────────
|
||||
|
||||
function MetadataChip({ icon, children }: { icon?: React.ReactNode; children: React.ReactNode }) {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1 rounded-md border bg-muted/50 px-2 py-0.5 text-[11px] text-muted-foreground">
|
||||
{icon}
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function formatProvider(provider: string): string {
|
||||
const map: Record<string, string> = {
|
||||
claude: "Claude Code",
|
||||
"claude-code": "Claude Code",
|
||||
codex: "Codex",
|
||||
};
|
||||
return map[provider.toLowerCase()] ?? provider;
|
||||
}
|
||||
|
||||
// ─── Timeline bar (colored segments) ────────────────────────────────────────
|
||||
|
||||
function TimelineBar({
|
||||
items,
|
||||
selectedIdx,
|
||||
onSegmentClick,
|
||||
}: {
|
||||
items: TimelineItem[];
|
||||
selectedIdx: number | null;
|
||||
onSegmentClick: (idx: number) => void;
|
||||
}) {
|
||||
// Group consecutive items of the same color into segments for cleaner display
|
||||
const segments: { startIdx: number; endIdx: number; color: EventColor; count: number }[] = [];
|
||||
let currentColor: EventColor | null = null;
|
||||
let currentStart = 0;
|
||||
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
const item = items[i]!;
|
||||
const color = getEventColor(item);
|
||||
if (color !== currentColor) {
|
||||
if (currentColor !== null) {
|
||||
segments.push({ startIdx: currentStart, endIdx: i - 1, color: currentColor, count: i - currentStart });
|
||||
}
|
||||
currentColor = color;
|
||||
currentStart = i;
|
||||
}
|
||||
}
|
||||
if (currentColor !== null) {
|
||||
segments.push({ startIdx: currentStart, endIdx: items.length - 1, color: currentColor, count: items.length - currentStart });
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex gap-0.5 h-5 rounded overflow-hidden" role="navigation" aria-label="Timeline">
|
||||
{segments.map((seg, segIdx) => {
|
||||
const isSelected = selectedIdx !== null && selectedIdx >= seg.startIdx && selectedIdx <= seg.endIdx;
|
||||
const color = colorClasses[seg.color];
|
||||
// Width proportional to number of events in segment
|
||||
const widthPercent = (seg.count / items.length) * 100;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={segIdx}
|
||||
className={cn(
|
||||
"h-full transition-all duration-150 hover:opacity-80 relative group",
|
||||
isSelected ? color.bgActive : color.bg,
|
||||
"min-w-[4px]",
|
||||
)}
|
||||
style={{ width: `${Math.max(widthPercent, 0.5)}%` }}
|
||||
onClick={() => onSegmentClick(seg.startIdx)}
|
||||
title={`${getEventLabel(items[seg.startIdx]!)}${seg.count > 1 ? ` (+${seg.count - 1} more)` : ""}`}
|
||||
>
|
||||
{/* Tooltip on hover */}
|
||||
<div className="absolute bottom-full left-1/2 -translate-x-1/2 mb-1 hidden group-hover:block z-10 pointer-events-none">
|
||||
<div className="rounded bg-popover border px-2 py-1 text-[10px] text-popover-foreground shadow-md whitespace-nowrap">
|
||||
{getEventLabel(items[seg.startIdx]!)}
|
||||
{seg.count > 1 && <span className="text-muted-foreground ml-1">+{seg.count - 1}</span>}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Transcript event row ───────────────────────────────────────────────────
|
||||
|
||||
interface TranscriptEventRowProps {
|
||||
item: TimelineItem;
|
||||
index: number;
|
||||
isSelected: boolean;
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
const TranscriptEventRow = ({
|
||||
ref,
|
||||
item,
|
||||
index,
|
||||
isSelected,
|
||||
onClick,
|
||||
}: TranscriptEventRowProps & { ref?: React.Ref<HTMLDivElement> }) => {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const color = getEventColor(item);
|
||||
const label = getEventLabel(item);
|
||||
const summary = getEventSummary(item);
|
||||
|
||||
const hasDetail =
|
||||
(item.type === "tool_use" && item.input && Object.keys(item.input).length > 0) ||
|
||||
(item.type === "tool_result" && item.output && item.output.length > 0) ||
|
||||
(item.type === "thinking" && item.content && item.content.length > 0) ||
|
||||
(item.type === "text" && item.content && item.content.split("\n").length > 1) ||
|
||||
(item.type === "error" && item.content && item.content.length > 0);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"group transition-colors",
|
||||
isSelected && "bg-accent/50",
|
||||
)}
|
||||
>
|
||||
<Collapsible open={expanded} onOpenChange={setExpanded}>
|
||||
<div className="flex items-start gap-2 px-4 py-2">
|
||||
{/* Type label badge */}
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex items-center shrink-0 rounded px-1.5 py-0.5 text-[11px] font-medium mt-0.5 min-w-[60px] justify-center",
|
||||
colorClasses[color].label,
|
||||
)}
|
||||
>
|
||||
{item.type === "thinking" && <Brain className="h-3 w-3 mr-1 shrink-0" />}
|
||||
{item.type === "error" && <AlertCircle className="h-3 w-3 mr-1 shrink-0" />}
|
||||
{label}
|
||||
</span>
|
||||
|
||||
{/* Summary */}
|
||||
<CollapsibleTrigger
|
||||
className={cn(
|
||||
"flex-1 text-left text-xs min-w-0 py-0.5 transition-colors",
|
||||
hasDetail ? "cursor-pointer hover:text-foreground" : "cursor-default",
|
||||
item.type === "error" ? "text-destructive" : "text-muted-foreground",
|
||||
)}
|
||||
disabled={!hasDetail}
|
||||
>
|
||||
<div className="flex items-start gap-1.5">
|
||||
{hasDetail && (
|
||||
<ChevronRight
|
||||
className={cn(
|
||||
"h-3 w-3 shrink-0 mt-0.5 text-muted-foreground/50 transition-transform",
|
||||
expanded && "rotate-90",
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
<span className="truncate">{summary || "(empty)"}</span>
|
||||
</div>
|
||||
</CollapsibleTrigger>
|
||||
|
||||
{/* Seq number / index */}
|
||||
<span className="shrink-0 text-[10px] text-muted-foreground/50 tabular-nums mt-1">
|
||||
#{item.seq}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Expanded detail */}
|
||||
{hasDetail && (
|
||||
<CollapsibleContent>
|
||||
<div className="px-4 pb-3">
|
||||
<div className="ml-[72px] rounded bg-muted/40 border">
|
||||
<EventDetailContent item={item} />
|
||||
</div>
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
)}
|
||||
</Collapsible>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ─── Event detail content ───────────────────────────────────────────────────
|
||||
|
||||
function EventDetailContent({ item }: { item: TimelineItem }) {
|
||||
switch (item.type) {
|
||||
case "tool_use":
|
||||
return (
|
||||
<pre className="max-h-60 overflow-auto p-3 text-[11px] text-muted-foreground whitespace-pre-wrap break-all">
|
||||
{item.input ? redactSecrets(JSON.stringify(item.input, null, 2)) : ""}
|
||||
</pre>
|
||||
);
|
||||
case "tool_result":
|
||||
return (
|
||||
<pre className="max-h-60 overflow-auto p-3 text-[11px] text-muted-foreground whitespace-pre-wrap break-all">
|
||||
{item.output
|
||||
? item.output.length > 4000
|
||||
? redactSecrets(item.output.slice(0, 4000)) + "\n... (truncated)"
|
||||
: redactSecrets(item.output)
|
||||
: ""}
|
||||
</pre>
|
||||
);
|
||||
case "thinking":
|
||||
return (
|
||||
<pre className="max-h-60 overflow-auto p-3 text-[11px] text-muted-foreground whitespace-pre-wrap break-words">
|
||||
{item.content ?? ""}
|
||||
</pre>
|
||||
);
|
||||
case "text":
|
||||
return (
|
||||
<pre className="max-h-60 overflow-auto p-3 text-[11px] text-muted-foreground whitespace-pre-wrap break-words">
|
||||
{item.content ?? ""}
|
||||
</pre>
|
||||
);
|
||||
case "error":
|
||||
return (
|
||||
<pre className="max-h-60 overflow-auto p-3 text-[11px] text-destructive whitespace-pre-wrap break-words">
|
||||
{item.content ?? ""}
|
||||
</pre>
|
||||
);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -21,8 +21,9 @@ import {
|
||||
} from "@/components/ui/popover";
|
||||
import type { UpdateIssueRequest } from "@/shared/types";
|
||||
import { ALL_STATUSES, STATUS_CONFIG, PRIORITY_ORDER, PRIORITY_CONFIG } from "@/features/issues/config";
|
||||
import { useIssueStore } from "@/features/issues/store";
|
||||
import { useIssueSelectionStore } from "@/features/issues/stores/selection-store";
|
||||
import { useBatchUpdateIssues, useBatchDeleteIssues } from "@core/issues/mutations";
|
||||
import { api } from "@/shared/api";
|
||||
import { StatusIcon } from "./status-icon";
|
||||
import { PriorityIcon } from "./priority-icon";
|
||||
import { AssigneePicker } from "./pickers";
|
||||
@@ -36,31 +37,46 @@ export function BatchActionToolbar() {
|
||||
const [priorityOpen, setPriorityOpen] = useState(false);
|
||||
const [assigneeOpen, setAssigneeOpen] = useState(false);
|
||||
const [deleteOpen, setDeleteOpen] = useState(false);
|
||||
const batchUpdate = useBatchUpdateIssues();
|
||||
const batchDelete = useBatchDeleteIssues();
|
||||
const loading = batchUpdate.isPending || batchDelete.isPending;
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
if (count === 0) return null;
|
||||
|
||||
const ids = Array.from(selectedIds);
|
||||
|
||||
const handleBatchUpdate = async (updates: Partial<UpdateIssueRequest>) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
await batchUpdate.mutateAsync({ ids, updates });
|
||||
await api.batchUpdateIssues(ids, updates);
|
||||
for (const id of ids) {
|
||||
useIssueStore.getState().updateIssue(id, updates);
|
||||
}
|
||||
toast.success(`Updated ${count} issue${count > 1 ? "s" : ""}`);
|
||||
} catch {
|
||||
toast.error("Failed to update issues");
|
||||
api.listIssues({ limit: 200 }).then((res) => {
|
||||
useIssueStore.getState().setIssues(res.issues);
|
||||
}).catch(console.error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleBatchDelete = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
await batchDelete.mutateAsync(ids);
|
||||
await api.batchDeleteIssues(ids);
|
||||
for (const id of ids) {
|
||||
useIssueStore.getState().removeIssue(id);
|
||||
}
|
||||
clear();
|
||||
toast.success(`Deleted ${count} issue${count > 1 ? "s" : ""}`);
|
||||
} catch {
|
||||
toast.error("Failed to delete issues");
|
||||
api.listIssues({ limit: 200 }).then((res) => {
|
||||
useIssueStore.getState().setIssues(res.issues);
|
||||
}).catch(console.error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setDeleteOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -2,14 +2,14 @@
|
||||
|
||||
import { useCallback, memo } from "react";
|
||||
import Link from "next/link";
|
||||
import { useSortable, defaultAnimateLayoutChanges } from "@dnd-kit/sortable";
|
||||
import type { AnimateLayoutChanges } from "@dnd-kit/sortable";
|
||||
import { useSortable } from "@dnd-kit/sortable";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
import { toast } from "sonner";
|
||||
import type { Issue, UpdateIssueRequest } from "@/shared/types";
|
||||
import { CalendarDays } from "lucide-react";
|
||||
import { ActorAvatar } from "@/components/common/actor-avatar";
|
||||
import { useUpdateIssue } from "@core/issues/mutations";
|
||||
import { api } from "@/shared/api";
|
||||
import { useIssueStore } from "@/features/issues/store";
|
||||
import { PriorityIcon } from "./priority-icon";
|
||||
import { PriorityPicker, AssigneePicker, DueDatePicker } from "./pickers";
|
||||
import { PRIORITY_CONFIG } from "@/features/issues/config";
|
||||
@@ -46,15 +46,16 @@ export const BoardCardContent = memo(function BoardCardContent({
|
||||
const storeProperties = useViewStore((s) => s.cardProperties);
|
||||
const priorityCfg = PRIORITY_CONFIG[issue.priority];
|
||||
|
||||
const updateIssueMutation = useUpdateIssue();
|
||||
const handleUpdate = useCallback(
|
||||
(updates: Partial<UpdateIssueRequest>) => {
|
||||
updateIssueMutation.mutate(
|
||||
{ id: issue.id, ...updates },
|
||||
{ onError: () => toast.error("Failed to update issue") },
|
||||
);
|
||||
const prev = { ...issue };
|
||||
useIssueStore.getState().updateIssue(issue.id, updates);
|
||||
api.updateIssue(issue.id, updates).catch(() => {
|
||||
useIssueStore.getState().updateIssue(issue.id, prev);
|
||||
toast.error("Failed to update issue");
|
||||
});
|
||||
},
|
||||
[issue.id, updateIssueMutation],
|
||||
[issue],
|
||||
);
|
||||
|
||||
const showPriority = storeProperties.priority;
|
||||
@@ -167,12 +168,6 @@ export const BoardCardContent = memo(function BoardCardContent({
|
||||
);
|
||||
});
|
||||
|
||||
const animateLayoutChanges: AnimateLayoutChanges = (args) => {
|
||||
const { isSorting, wasDragging } = args;
|
||||
if (isSorting || wasDragging) return false;
|
||||
return defaultAnimateLayoutChanges(args);
|
||||
};
|
||||
|
||||
export const DraggableBoardCard = memo(function DraggableBoardCard({ issue }: { issue: Issue }) {
|
||||
const {
|
||||
attributes,
|
||||
@@ -184,7 +179,6 @@ export const DraggableBoardCard = memo(function DraggableBoardCard({ issue }: {
|
||||
} = useSortable({
|
||||
id: issue.id,
|
||||
data: { status: issue.status },
|
||||
animateLayoutChanges,
|
||||
});
|
||||
|
||||
const style = {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, type ReactNode } from "react";
|
||||
import { useMemo } from "react";
|
||||
import { EyeOff, MoreHorizontal, Plus } from "lucide-react";
|
||||
import { Tooltip, TooltipTrigger, TooltipContent } from "@/components/ui/tooltip";
|
||||
import { useDroppable } from "@dnd-kit/core";
|
||||
@@ -15,35 +15,32 @@ import {
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { STATUS_CONFIG } from "@/features/issues/config";
|
||||
import { useModalStore } from "@/features/modals";
|
||||
import { useViewStoreApi } from "@/features/issues/stores/view-store-context";
|
||||
import { useViewStore, useViewStoreApi } from "@/features/issues/stores/view-store-context";
|
||||
import { sortIssues } from "@/features/issues/utils/sort";
|
||||
import { StatusIcon } from "./status-icon";
|
||||
import { DraggableBoardCard } from "./board-card";
|
||||
|
||||
export function BoardColumn({
|
||||
status,
|
||||
issueIds,
|
||||
issueMap,
|
||||
totalCount,
|
||||
footer,
|
||||
issues,
|
||||
}: {
|
||||
status: IssueStatus;
|
||||
issueIds: string[];
|
||||
issueMap: Map<string, Issue>;
|
||||
totalCount?: number;
|
||||
footer?: ReactNode;
|
||||
issues: Issue[];
|
||||
}) {
|
||||
const cfg = STATUS_CONFIG[status];
|
||||
const { setNodeRef, isOver } = useDroppable({ id: status });
|
||||
const viewStoreApi = useViewStoreApi();
|
||||
const sortBy = useViewStore((s) => s.sortBy);
|
||||
const sortDirection = useViewStore((s) => s.sortDirection);
|
||||
|
||||
// Resolve IDs to Issue objects, preserving parent-provided order
|
||||
const resolvedIssues = useMemo(
|
||||
() =>
|
||||
issueIds.flatMap((id) => {
|
||||
const issue = issueMap.get(id);
|
||||
return issue ? [issue] : [];
|
||||
}),
|
||||
[issueIds, issueMap],
|
||||
const sortedIssues = useMemo(
|
||||
() => sortIssues(issues, sortBy, sortDirection),
|
||||
[issues, sortBy, sortDirection]
|
||||
);
|
||||
|
||||
const sortedIds = useMemo(
|
||||
() => sortedIssues.map((i) => i.id),
|
||||
[sortedIssues]
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -56,7 +53,7 @@ export function BoardColumn({
|
||||
{cfg.label}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{totalCount ?? issueIds.length}
|
||||
{issues.length}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -100,17 +97,16 @@ export function BoardColumn({
|
||||
isOver ? "bg-accent/60" : ""
|
||||
}`}
|
||||
>
|
||||
<SortableContext items={issueIds} strategy={verticalListSortingStrategy}>
|
||||
{resolvedIssues.map((issue) => (
|
||||
<SortableContext items={sortedIds} strategy={verticalListSortingStrategy}>
|
||||
{sortedIssues.map((issue) => (
|
||||
<DraggableBoardCard key={issue.id} issue={issue} />
|
||||
))}
|
||||
</SortableContext>
|
||||
{issueIds.length === 0 && (
|
||||
{issues.length === 0 && (
|
||||
<p className="py-8 text-center text-xs text-muted-foreground">
|
||||
No issues
|
||||
</p>
|
||||
)}
|
||||
{footer}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useCallback, useMemo, useEffect, useRef } from "react";
|
||||
import { useState, useCallback, useMemo } from "react";
|
||||
import {
|
||||
DndContext,
|
||||
DragOverlay,
|
||||
@@ -12,13 +12,10 @@ import {
|
||||
type CollisionDetection,
|
||||
type DragStartEvent,
|
||||
type DragEndEvent,
|
||||
type DragOverEvent,
|
||||
} from "@dnd-kit/core";
|
||||
import { arrayMove } from "@dnd-kit/sortable";
|
||||
import { Eye, Loader2, MoreHorizontal } from "lucide-react";
|
||||
import { Eye, MoreHorizontal } from "lucide-react";
|
||||
import type { Issue, IssueStatus } from "@/shared/types";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useLoadMoreDoneIssues } from "@core/issues/mutations";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuTrigger,
|
||||
@@ -26,37 +23,11 @@ import {
|
||||
DropdownMenuItem,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { ALL_STATUSES, STATUS_CONFIG } from "@/features/issues/config";
|
||||
import { useViewStoreApi, useViewStore } from "@/features/issues/stores/view-store-context";
|
||||
import type { SortField, SortDirection } from "@/features/issues/stores/view-store";
|
||||
import { sortIssues } from "@/features/issues/utils/sort";
|
||||
import { useViewStoreApi } from "@/features/issues/stores/view-store-context";
|
||||
import { StatusIcon } from "./status-icon";
|
||||
import { BoardColumn } from "./board-column";
|
||||
import { BoardCardContent } from "./board-card";
|
||||
|
||||
/** Sentinel that triggers `onVisible` when scrolled into view. */
|
||||
function InfiniteScrollSentinel({ onVisible, loading }: { onVisible: () => void; loading: boolean }) {
|
||||
const sentinelRef = useRef<HTMLDivElement>(null);
|
||||
const onVisibleRef = useRef(onVisible);
|
||||
onVisibleRef.current = onVisible;
|
||||
|
||||
useEffect(() => {
|
||||
const node = sentinelRef.current;
|
||||
if (!node) return;
|
||||
const observer = new IntersectionObserver(
|
||||
([entry]) => { if (entry?.isIntersecting) onVisibleRef.current(); },
|
||||
{ rootMargin: "100px" },
|
||||
);
|
||||
observer.observe(node);
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div ref={sentinelRef} className="flex items-center justify-center py-2">
|
||||
{loading && <Loader2 className="size-3 animate-spin text-muted-foreground" />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const COLUMN_IDS = new Set<string>(ALL_STATUSES);
|
||||
|
||||
const kanbanCollision: CollisionDetection = (args) => {
|
||||
@@ -73,47 +44,13 @@ const kanbanCollision: CollisionDetection = (args) => {
|
||||
return closestCenter(args);
|
||||
};
|
||||
|
||||
/** Build column ID arrays from TQ issue data, respecting current sort. */
|
||||
function buildColumns(
|
||||
issues: Issue[],
|
||||
visibleStatuses: IssueStatus[],
|
||||
sortBy: SortField,
|
||||
sortDirection: SortDirection,
|
||||
): Record<IssueStatus, string[]> {
|
||||
const cols = {} as Record<IssueStatus, string[]>;
|
||||
for (const status of visibleStatuses) {
|
||||
const sorted = sortIssues(
|
||||
issues.filter((i) => i.status === status),
|
||||
sortBy,
|
||||
sortDirection,
|
||||
);
|
||||
cols[status] = sorted.map((i) => i.id);
|
||||
}
|
||||
return cols;
|
||||
}
|
||||
|
||||
/** Compute a float position for `activeId` based on its neighbors in `ids`. */
|
||||
function computePosition(ids: string[], activeId: string, issueMap: Map<string, Issue>): number {
|
||||
const idx = ids.indexOf(activeId);
|
||||
if (idx === -1) return 0;
|
||||
const getPos = (id: string) => issueMap.get(id)?.position ?? 0;
|
||||
if (ids.length === 1) return issueMap.get(activeId)?.position ?? 0;
|
||||
if (idx === 0) return getPos(ids[1]!) - 1;
|
||||
if (idx === ids.length - 1) return getPos(ids[idx - 1]!) + 1;
|
||||
return (getPos(ids[idx - 1]!) + getPos(ids[idx + 1]!)) / 2;
|
||||
}
|
||||
|
||||
/** Find which column (status) contains a given ID (issue or column droppable). */
|
||||
function findColumn(
|
||||
columns: Record<IssueStatus, string[]>,
|
||||
id: string,
|
||||
visibleStatuses: IssueStatus[],
|
||||
): IssueStatus | null {
|
||||
if (visibleStatuses.includes(id as IssueStatus)) return id as IssueStatus;
|
||||
for (const [status, ids] of Object.entries(columns)) {
|
||||
if (ids.includes(id)) return status as IssueStatus;
|
||||
}
|
||||
return null;
|
||||
/** Compute a float position to place an item at `targetIndex` within `siblings`. */
|
||||
function computePosition(siblings: Issue[], targetIndex: number): number {
|
||||
if (siblings.length === 0) return 0;
|
||||
if (targetIndex <= 0) return siblings[0]!.position - 1;
|
||||
if (targetIndex >= siblings.length)
|
||||
return siblings[siblings.length - 1]!.position + 1;
|
||||
return (siblings[targetIndex - 1]!.position + siblings[targetIndex]!.position) / 2;
|
||||
}
|
||||
|
||||
export function BoardView({
|
||||
@@ -133,53 +70,7 @@ export function BoardView({
|
||||
newPosition?: number
|
||||
) => void;
|
||||
}) {
|
||||
const sortBy = useViewStore((s) => s.sortBy);
|
||||
const sortDirection = useViewStore((s) => s.sortDirection);
|
||||
const { loadMore, hasMore, isLoading: loadingMore, doneTotal } = useLoadMoreDoneIssues();
|
||||
|
||||
// --- Drag state ---
|
||||
const [activeIssue, setActiveIssue] = useState<Issue | null>(null);
|
||||
const isDraggingRef = useRef(false);
|
||||
|
||||
// --- Local columns state ---
|
||||
// Between drags: follows TQ via useEffect.
|
||||
// During drag: local-only, driven by onDragOver/onDragEnd.
|
||||
const [columns, setColumns] = useState<Record<IssueStatus, string[]>>(() =>
|
||||
buildColumns(issues, visibleStatuses, sortBy, sortDirection),
|
||||
);
|
||||
const columnsRef = useRef(columns);
|
||||
columnsRef.current = columns;
|
||||
|
||||
useEffect(() => {
|
||||
if (!isDraggingRef.current) {
|
||||
setColumns(buildColumns(issues, visibleStatuses, sortBy, sortDirection));
|
||||
}
|
||||
}, [issues, visibleStatuses, sortBy, sortDirection]);
|
||||
|
||||
// After a cross-column move, lock for one animation frame so dnd-kit's
|
||||
// collision detection can stabilize before processing the next move.
|
||||
// Without this, collision oscillates: A→B→A→B… until React bails out.
|
||||
const recentlyMovedRef = useRef(false);
|
||||
useEffect(() => {
|
||||
const id = requestAnimationFrame(() => {
|
||||
recentlyMovedRef.current = false;
|
||||
});
|
||||
return () => cancelAnimationFrame(id);
|
||||
}, [columns]);
|
||||
|
||||
// --- Issue map ---
|
||||
// Frozen during drag so BoardColumn/DraggableBoardCard props stay
|
||||
// referentially stable even if a TQ refetch lands mid-drag.
|
||||
const issueMap = useMemo(() => {
|
||||
const map = new Map<string, Issue>();
|
||||
for (const issue of issues) map.set(issue.id, issue);
|
||||
return map;
|
||||
}, [issues]);
|
||||
|
||||
const issueMapRef = useRef(issueMap);
|
||||
if (!isDraggingRef.current) {
|
||||
issueMapRef.current = issueMap;
|
||||
}
|
||||
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, {
|
||||
@@ -187,100 +78,89 @@ export function BoardView({
|
||||
})
|
||||
);
|
||||
|
||||
// Pre-sort issues by position per status for position calculations
|
||||
const issuesByStatus = useMemo(() => {
|
||||
const map: Record<string, Issue[]> = {};
|
||||
for (const status of visibleStatuses) {
|
||||
map[status] = issues
|
||||
.filter((i) => i.status === status)
|
||||
.sort((a, b) => a.position - b.position);
|
||||
}
|
||||
return map;
|
||||
}, [issues, visibleStatuses]);
|
||||
|
||||
const handleDragStart = useCallback(
|
||||
(event: DragStartEvent) => {
|
||||
isDraggingRef.current = true;
|
||||
const issue = issueMapRef.current.get(event.active.id as string) ?? null;
|
||||
setActiveIssue(issue);
|
||||
const issue = issues.find((i) => i.id === event.active.id);
|
||||
if (issue) setActiveIssue(issue);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const handleDragOver = useCallback(
|
||||
(event: DragOverEvent) => {
|
||||
const { active, over } = event;
|
||||
if (!over || recentlyMovedRef.current) return;
|
||||
|
||||
const activeId = active.id as string;
|
||||
const overId = over.id as string;
|
||||
|
||||
setColumns((prev) => {
|
||||
const activeCol = findColumn(prev, activeId, visibleStatuses);
|
||||
const overCol = findColumn(prev, overId, visibleStatuses);
|
||||
if (!activeCol || !overCol || activeCol === overCol) return prev;
|
||||
|
||||
recentlyMovedRef.current = true;
|
||||
const oldIds = prev[activeCol]!.filter((id) => id !== activeId);
|
||||
const newIds = [...prev[overCol]!];
|
||||
const overIndex = newIds.indexOf(overId);
|
||||
const insertIndex = overIndex >= 0 ? overIndex : newIds.length;
|
||||
newIds.splice(insertIndex, 0, activeId);
|
||||
return { ...prev, [activeCol]: oldIds, [overCol]: newIds };
|
||||
});
|
||||
},
|
||||
[visibleStatuses],
|
||||
[issues]
|
||||
);
|
||||
|
||||
const handleDragEnd = useCallback(
|
||||
(event: DragEndEvent) => {
|
||||
const { active, over } = event;
|
||||
isDraggingRef.current = false;
|
||||
setActiveIssue(null);
|
||||
const { active, over } = event;
|
||||
if (!over || active.id === over.id) return;
|
||||
|
||||
const resetColumns = () =>
|
||||
setColumns(buildColumns(issues, visibleStatuses, sortBy, sortDirection));
|
||||
const issueId = active.id as string;
|
||||
const currentIssue = issues.find((i) => i.id === issueId);
|
||||
if (!currentIssue) return;
|
||||
|
||||
if (!over) {
|
||||
resetColumns();
|
||||
return;
|
||||
// Determine target status
|
||||
let targetStatus: IssueStatus;
|
||||
let overIsColumn = false;
|
||||
|
||||
if (visibleStatuses.includes(over.id as IssueStatus)) {
|
||||
targetStatus = over.id as IssueStatus;
|
||||
overIsColumn = true;
|
||||
} else {
|
||||
const targetIssue = issues.find((i) => i.id === over.id);
|
||||
if (!targetIssue) return;
|
||||
targetStatus = targetIssue.status;
|
||||
}
|
||||
|
||||
const activeId = active.id as string;
|
||||
const overId = over.id as string;
|
||||
// Get sorted siblings in the target column (excluding the dragged item)
|
||||
const siblings = (issuesByStatus[targetStatus] ?? []).filter(
|
||||
(i) => i.id !== issueId
|
||||
);
|
||||
|
||||
const cols = columnsRef.current;
|
||||
const activeCol = findColumn(cols, activeId, visibleStatuses);
|
||||
const overCol = findColumn(cols, overId, visibleStatuses);
|
||||
if (!activeCol || !overCol) {
|
||||
resetColumns();
|
||||
return;
|
||||
}
|
||||
// Compute new position
|
||||
let newPosition: number;
|
||||
|
||||
// Same-column reorder
|
||||
let finalColumns = cols;
|
||||
if (activeCol === overCol) {
|
||||
const ids = cols[activeCol]!;
|
||||
const oldIndex = ids.indexOf(activeId);
|
||||
const newIndex = ids.indexOf(overId);
|
||||
if (oldIndex !== -1 && newIndex !== -1 && oldIndex !== newIndex) {
|
||||
const reordered = arrayMove(ids, oldIndex, newIndex);
|
||||
finalColumns = { ...cols, [activeCol]: reordered };
|
||||
setColumns(finalColumns);
|
||||
if (overIsColumn) {
|
||||
// Dropped on empty area of column → append to end
|
||||
newPosition = computePosition(siblings, siblings.length);
|
||||
} else {
|
||||
// Dropped on a specific card → insert at that card's index
|
||||
const overIndex = siblings.findIndex((i) => i.id === over.id);
|
||||
if (overIndex === -1) {
|
||||
newPosition = computePosition(siblings, siblings.length);
|
||||
} else {
|
||||
const isSameColumn = currentIssue.status === targetStatus;
|
||||
const overIssuePosition = siblings[overIndex]!.position;
|
||||
|
||||
if (isSameColumn && currentIssue.position < overIssuePosition) {
|
||||
// Moving down → insert after the over card
|
||||
newPosition = computePosition(siblings, overIndex + 1);
|
||||
} else {
|
||||
// Moving up or cross-column → insert before the over card
|
||||
newPosition = computePosition(siblings, overIndex);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const finalCol = findColumn(finalColumns, activeId, visibleStatuses);
|
||||
if (!finalCol) {
|
||||
resetColumns();
|
||||
return;
|
||||
}
|
||||
|
||||
const map = issueMapRef.current;
|
||||
const finalIds = finalColumns[finalCol]!;
|
||||
const newPosition = computePosition(finalIds, activeId, map);
|
||||
const currentIssue = map.get(activeId);
|
||||
|
||||
// Skip if nothing changed
|
||||
if (
|
||||
currentIssue &&
|
||||
currentIssue.status === finalCol &&
|
||||
currentIssue.status === targetStatus &&
|
||||
currentIssue.position === newPosition
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
onMoveIssue(activeId, finalCol, newPosition);
|
||||
onMoveIssue(issueId, targetStatus, newPosition);
|
||||
},
|
||||
[issues, visibleStatuses, sortBy, sortDirection, onMoveIssue],
|
||||
[issues, issuesByStatus, onMoveIssue, visibleStatuses]
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -288,7 +168,6 @@ export function BoardView({
|
||||
sensors={sensors}
|
||||
collisionDetection={kanbanCollision}
|
||||
onDragStart={handleDragStart}
|
||||
onDragOver={handleDragOver}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
<div className="flex flex-1 min-h-0 gap-4 overflow-x-auto p-4">
|
||||
@@ -296,14 +175,7 @@ export function BoardView({
|
||||
<BoardColumn
|
||||
key={status}
|
||||
status={status}
|
||||
issueIds={columns[status] ?? []}
|
||||
issueMap={issueMapRef.current}
|
||||
totalCount={status === "done" ? doneTotal : undefined}
|
||||
footer={
|
||||
status === "done" && hasMore ? (
|
||||
<InfiniteScrollSentinel onVisible={loadMore} loading={loadingMore} />
|
||||
) : undefined
|
||||
}
|
||||
issues={issues.filter((i) => i.status === status)}
|
||||
/>
|
||||
))}
|
||||
|
||||
@@ -315,9 +187,9 @@ export function BoardView({
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DragOverlay dropAnimation={null}>
|
||||
<DragOverlay>
|
||||
{activeIssue ? (
|
||||
<div className="w-[280px] rotate-2 scale-105 cursor-grabbing opacity-90 shadow-lg shadow-black/10">
|
||||
<div className="w-[280px] rotate-1 cursor-grabbing opacity-95 shadow-md">
|
||||
<BoardCardContent issue={activeIssue} />
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
@@ -30,7 +30,7 @@ import { QuickEmojiPicker } from "@/components/common/quick-emoji-picker";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useActorName } from "@/features/workspace";
|
||||
import { timeAgo } from "@/shared/utils";
|
||||
import { ContentEditor, type ContentEditorRef, copyMarkdown, ReadonlyContent } from "@/features/editor";
|
||||
import { ContentEditor, type ContentEditorRef, copyMarkdown } from "@/features/editor";
|
||||
import { FileUploadButton } from "@/components/common/file-upload-button";
|
||||
import { useFileUpload } from "@/shared/hooks/use-file-upload";
|
||||
import { ReplyInput } from "./reply-input";
|
||||
@@ -148,8 +148,6 @@ function CommentRow({
|
||||
};
|
||||
|
||||
const reactions = entry.reactions ?? [];
|
||||
const contentText = entry.content ?? "";
|
||||
const isLongContent = contentText.length > 500 || contentText.split("\n").length > 8;
|
||||
|
||||
return (
|
||||
<div className={`py-3${isTemp ? " opacity-60" : ""}`}>
|
||||
@@ -247,14 +245,14 @@ function CommentRow({
|
||||
) : (
|
||||
<>
|
||||
<div className="mt-1.5 pl-8 text-sm leading-relaxed text-foreground/85">
|
||||
<ReadonlyContent content={entry.content ?? ""} />
|
||||
<ContentEditor defaultValue={entry.content ?? ""} editable={false} />
|
||||
</div>
|
||||
{!isTemp && (
|
||||
<ReactionBar
|
||||
reactions={reactions}
|
||||
currentUserId={currentUserId}
|
||||
onToggle={(emoji) => onToggleReaction(entry.id, emoji)}
|
||||
hideAddButton={!isLongContent}
|
||||
hideAddButton
|
||||
className="mt-1.5 pl-8"
|
||||
/>
|
||||
)}
|
||||
@@ -332,8 +330,6 @@ function CommentCard({
|
||||
const replyCount = allNestedReplies.length;
|
||||
const contentPreview = (entry.content ?? "").replace(/\n/g, " ").slice(0, 80);
|
||||
const reactions = entry.reactions ?? [];
|
||||
const contentText = entry.content ?? "";
|
||||
const isLongContent = contentText.length > 500 || contentText.split("\n").length > 8;
|
||||
|
||||
const isHighlighted = highlightedCommentId === entry.id;
|
||||
|
||||
@@ -455,14 +451,13 @@ function CommentCard({
|
||||
) : (
|
||||
<>
|
||||
<div className="pl-10 text-sm leading-relaxed text-foreground/85">
|
||||
<ReadonlyContent content={entry.content ?? ""} />
|
||||
<ContentEditor defaultValue={entry.content ?? ""} editable={false} />
|
||||
</div>
|
||||
{!isTemp && (
|
||||
<ReactionBar
|
||||
reactions={reactions}
|
||||
currentUserId={currentUserId}
|
||||
onToggle={(emoji) => onToggleReaction(entry.id, emoji)}
|
||||
hideAddButton={!isLongContent}
|
||||
className="mt-1.5 pl-10"
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -16,15 +16,10 @@ function CommentInput({ issueId, onSubmit }: CommentInputProps) {
|
||||
const editorRef = useRef<ContentEditorRef>(null);
|
||||
const [isEmpty, setIsEmpty] = useState(true);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [attachmentIds, setAttachmentIds] = useState<string[]>([]);
|
||||
const { uploadWithToast } = useFileUpload();
|
||||
|
||||
const handleUpload = async (file: File) => {
|
||||
const result = await uploadWithToast(file, { issueId });
|
||||
if (result) {
|
||||
setAttachmentIds((prev) => [...prev, result.id]);
|
||||
}
|
||||
return result;
|
||||
return await uploadWithToast(file, { issueId });
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
@@ -32,10 +27,9 @@ function CommentInput({ issueId, onSubmit }: CommentInputProps) {
|
||||
if (!content || submitting) return;
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await onSubmit(content, attachmentIds.length > 0 ? attachmentIds : undefined);
|
||||
await onSubmit(content);
|
||||
editorRef.current?.clearContent();
|
||||
setIsEmpty(true);
|
||||
setAttachmentIds([]);
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
|
||||
@@ -13,7 +13,6 @@ import {
|
||||
Link2,
|
||||
MoreHorizontal,
|
||||
PanelRight,
|
||||
Plus,
|
||||
Trash2,
|
||||
UserMinus,
|
||||
Users,
|
||||
@@ -58,80 +57,22 @@ import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Command, CommandInput, CommandList, CommandEmpty, CommandGroup, CommandItem } from "@/components/ui/command";
|
||||
import { AvatarGroup, AvatarGroupCount } from "@/components/ui/avatar";
|
||||
import { ActorAvatar } from "@/components/common/actor-avatar";
|
||||
import type { Issue, UpdateIssueRequest, IssueStatus, IssuePriority, TimelineEntry } from "@/shared/types";
|
||||
import type { UpdateIssueRequest, IssueStatus, IssuePriority, TimelineEntry } from "@/shared/types";
|
||||
import { ALL_STATUSES, STATUS_CONFIG, PRIORITY_ORDER, PRIORITY_CONFIG } from "@/features/issues/config";
|
||||
import { StatusIcon, PriorityIcon, DueDatePicker, AssigneePicker, canAssignAgent } from "@/features/issues/components";
|
||||
import { CommentCard } from "./comment-card";
|
||||
import { CommentInput } from "./comment-input";
|
||||
import { AgentLiveCard, TaskRunHistory } from "./agent-live-card";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { api } from "@/shared/api";
|
||||
import { useAuthStore } from "@/features/auth";
|
||||
import { useWorkspaceStore, useActorName } from "@/features/workspace";
|
||||
import { useWorkspaceId } from "@core/hooks";
|
||||
import { issueListOptions, issueDetailOptions, childIssuesOptions } from "@core/issues/queries";
|
||||
import { memberListOptions, agentListOptions } from "@core/workspace/queries";
|
||||
import { useUpdateIssue, useDeleteIssue } from "@core/issues/mutations";
|
||||
import { useIssueStore } from "@/features/issues";
|
||||
import { useIssueTimeline } from "@/features/issues/hooks/use-issue-timeline";
|
||||
import { useIssueReactions } from "@/features/issues/hooks/use-issue-reactions";
|
||||
import { useIssueSubscribers } from "@/features/issues/hooks/use-issue-subscribers";
|
||||
import { ReactionBar } from "@/components/common/reaction-bar";
|
||||
import { useFileUpload } from "@/shared/hooks/use-file-upload";
|
||||
import { useModalStore } from "@/features/modals";
|
||||
import { timeAgo } from "@/shared/utils";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/**
|
||||
* Tiny circular progress ring used in the "Sub-issue of …" line and the
|
||||
* Sub-issues section header. Renders an open ring when in-progress and
|
||||
* fills to a solid arc when complete.
|
||||
*/
|
||||
function ProgressRing({
|
||||
done,
|
||||
total,
|
||||
size = 12,
|
||||
}: {
|
||||
done: number;
|
||||
total: number;
|
||||
size?: number;
|
||||
}) {
|
||||
const stroke = 1.5;
|
||||
const radius = (size - stroke) / 2;
|
||||
const circumference = 2 * Math.PI * radius;
|
||||
const ratio = total > 0 ? Math.min(done / total, 1) : 0;
|
||||
const offset = circumference * (1 - ratio);
|
||||
const isComplete = total > 0 && done >= total;
|
||||
return (
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox={`0 0 ${size} ${size}`}
|
||||
className={isComplete ? "text-info" : "text-primary"}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<circle
|
||||
cx={size / 2}
|
||||
cy={size / 2}
|
||||
r={radius}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeOpacity="0.25"
|
||||
strokeWidth={stroke}
|
||||
/>
|
||||
<circle
|
||||
cx={size / 2}
|
||||
cy={size / 2}
|
||||
r={radius}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={stroke}
|
||||
strokeDasharray={circumference}
|
||||
strokeDashoffset={offset}
|
||||
strokeLinecap="round"
|
||||
transform={`rotate(-90 ${size / 2} ${size / 2})`}
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function shortDate(date: string | null): string {
|
||||
if (!date) return "—";
|
||||
@@ -234,13 +175,12 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo
|
||||
const router = useRouter();
|
||||
const user = useAuthStore((s) => s.user);
|
||||
const workspace = useWorkspaceStore((s) => s.workspace);
|
||||
|
||||
// Issue navigation — read from TQ list cache
|
||||
const wsId = useWorkspaceId();
|
||||
const { data: members = [] } = useQuery(memberListOptions(wsId));
|
||||
const { data: agents = [] } = useQuery(agentListOptions(wsId));
|
||||
const members = useWorkspaceStore((s) => s.members);
|
||||
const agents = useWorkspaceStore((s) => s.agents);
|
||||
const currentMemberRole = members.find((m) => m.user_id === user?.id)?.role;
|
||||
const { data: allIssues = [] } = useQuery(issueListOptions(wsId));
|
||||
|
||||
// Issue navigation
|
||||
const allIssues = useIssueStore((s) => s.issues);
|
||||
const currentIndex = allIssues.findIndex((i) => i.id === id);
|
||||
const prevIssue = currentIndex > 0 ? allIssues[currentIndex - 1] : null;
|
||||
const nextIssue = currentIndex < allIssues.length - 1 ? allIssues[currentIndex + 1] : null;
|
||||
@@ -256,14 +196,32 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo
|
||||
const [propertiesOpen, setPropertiesOpen] = useState(true);
|
||||
const [detailsOpen, setDetailsOpen] = useState(true);
|
||||
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
||||
const [showScrollBottom, setShowScrollBottom] = useState(false);
|
||||
const [highlightedId, setHighlightedId] = useState<string | null>(null);
|
||||
const didHighlightRef = useRef<string | null>(null);
|
||||
|
||||
// Issue data from TQ — uses detail query, seeded from list cache if available
|
||||
const { data: issue = null, isLoading: issueLoading } = useQuery({
|
||||
...issueDetailOptions(wsId, id),
|
||||
initialData: () => allIssues.find((i) => i.id === id),
|
||||
});
|
||||
// Single source of truth: read issue directly from global store
|
||||
const issue = useIssueStore((s) => s.issues.find((i) => i.id === id)) ?? null;
|
||||
const [issueLoading, setIssueLoading] = useState(!issue);
|
||||
|
||||
// If issue isn't in the store yet, fetch and upsert it
|
||||
useEffect(() => {
|
||||
if (issue) {
|
||||
setIssueLoading(false);
|
||||
return;
|
||||
}
|
||||
setIssueLoading(true);
|
||||
api
|
||||
.getIssue(id)
|
||||
.then((iss) => {
|
||||
useIssueStore.getState().addIssue(iss);
|
||||
})
|
||||
.catch((e) => {
|
||||
console.error(e);
|
||||
toast.error("Failed to load issue");
|
||||
})
|
||||
.finally(() => setIssueLoading(false));
|
||||
}, [id, !!issue]);
|
||||
|
||||
// Custom hooks — encapsulate timeline, reactions, subscribers
|
||||
const {
|
||||
@@ -280,25 +238,6 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo
|
||||
subscribers, loading: subscribersLoading, isSubscribed, toggleSubscribe: handleToggleSubscribe, toggleSubscriber,
|
||||
} = useIssueSubscribers(id, user?.id);
|
||||
|
||||
// Sub-issue queries
|
||||
const parentIssueId = issue?.parent_issue_id;
|
||||
const { data: parentIssue = null } = useQuery({
|
||||
...issueDetailOptions(wsId, parentIssueId ?? ""),
|
||||
enabled: !!parentIssueId,
|
||||
initialData: () => allIssues.find((i) => i.id === parentIssueId),
|
||||
});
|
||||
const { data: childIssues = [] } = useQuery({
|
||||
...childIssuesOptions(wsId, id),
|
||||
enabled: !!issue,
|
||||
});
|
||||
// Parent's children — used to render the "x/y" progress next to the
|
||||
// "Sub-issue of …" breadcrumb under the title.
|
||||
const { data: parentChildIssues = [] } = useQuery({
|
||||
...childIssuesOptions(wsId, parentIssueId ?? ""),
|
||||
enabled: !!parentIssueId,
|
||||
});
|
||||
const [subIssuesCollapsed, setSubIssuesCollapsed] = useState(false);
|
||||
|
||||
const loading = issueLoading;
|
||||
|
||||
// Scroll to highlighted comment once timeline loads (fire only once per highlightCommentId)
|
||||
@@ -317,17 +256,35 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo
|
||||
}
|
||||
}, [highlightCommentId, timeline.length]);
|
||||
|
||||
// Issue field updates via TQ mutation (optimistic update + rollback in mutation hook)
|
||||
const updateIssueMutation = useUpdateIssue();
|
||||
// Track scroll position for jump-to-bottom button
|
||||
useEffect(() => {
|
||||
const container = scrollContainerRef.current;
|
||||
if (!container) return;
|
||||
const onScroll = () => {
|
||||
const { scrollTop, scrollHeight, clientHeight } = container;
|
||||
setShowScrollBottom(scrollHeight - scrollTop - clientHeight > 200);
|
||||
};
|
||||
container.addEventListener("scroll", onScroll, { passive: true });
|
||||
onScroll();
|
||||
return () => container.removeEventListener("scroll", onScroll);
|
||||
}, []);
|
||||
|
||||
const scrollToBottom = useCallback(() => {
|
||||
scrollContainerRef.current?.scrollTo({ top: scrollContainerRef.current.scrollHeight, behavior: "smooth" });
|
||||
}, []);
|
||||
|
||||
// Issue field updates — write directly to the global store (single source of truth)
|
||||
const handleUpdateField = useCallback(
|
||||
(updates: Partial<UpdateIssueRequest>) => {
|
||||
if (!issue) return;
|
||||
updateIssueMutation.mutate(
|
||||
{ id, ...updates },
|
||||
{ onError: () => toast.error("Failed to update issue") },
|
||||
);
|
||||
const prev = { ...issue };
|
||||
useIssueStore.getState().updateIssue(id, updates);
|
||||
api.updateIssue(id, updates).catch(() => {
|
||||
useIssueStore.getState().updateIssue(id, prev);
|
||||
toast.error("Failed to update issue");
|
||||
});
|
||||
},
|
||||
[issue, id, updateIssueMutation],
|
||||
[issue, id],
|
||||
);
|
||||
|
||||
const descEditorRef = useRef<ContentEditorRef>(null);
|
||||
@@ -336,11 +293,11 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo
|
||||
[uploadWithToast, id],
|
||||
);
|
||||
|
||||
const deleteIssueMutation = useDeleteIssue();
|
||||
const handleDelete = async () => {
|
||||
setDeleting(true);
|
||||
try {
|
||||
await deleteIssueMutation.mutateAsync(issue!.id);
|
||||
await api.deleteIssue(issue!.id);
|
||||
useIssueStore.getState().removeIssue(issue!.id);
|
||||
toast.success("Issue deleted");
|
||||
if (onDelete) onDelete();
|
||||
else router.push("/issues");
|
||||
@@ -434,17 +391,6 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo
|
||||
<ChevronRight className="h-3 w-3 text-muted-foreground/50 shrink-0" />
|
||||
</>
|
||||
)}
|
||||
{parentIssue && (
|
||||
<>
|
||||
<Link
|
||||
href={`/issues/${parentIssue.id}`}
|
||||
className="text-muted-foreground hover:text-foreground transition-colors truncate shrink-0"
|
||||
>
|
||||
{parentIssue.identifier}
|
||||
</Link>
|
||||
<ChevronRight className="h-3 w-3 text-muted-foreground/50 shrink-0" />
|
||||
</>
|
||||
)}
|
||||
<span className="truncate text-muted-foreground">
|
||||
{issue.identifier}
|
||||
</span>
|
||||
@@ -615,17 +561,6 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo
|
||||
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
{/* Create sub-issue */}
|
||||
<DropdownMenuItem onClick={() => {
|
||||
useModalStore.getState().open("create-issue", {
|
||||
parent_issue_id: issue.id,
|
||||
parent_issue_identifier: issue.identifier,
|
||||
});
|
||||
}}>
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
Create sub-issue
|
||||
</DropdownMenuItem>
|
||||
|
||||
{/* Copy link */}
|
||||
<DropdownMenuItem onClick={() => {
|
||||
navigator.clipboard.writeText(window.location.href);
|
||||
@@ -706,31 +641,6 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo
|
||||
}}
|
||||
/>
|
||||
|
||||
{parentIssue && (
|
||||
<Link
|
||||
href={`/issues/${parentIssue.id}`}
|
||||
className="mt-2 inline-flex max-w-full items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors group/parent"
|
||||
>
|
||||
<span className="font-medium shrink-0">Sub-issue of</span>
|
||||
<StatusIcon status={parentIssue.status} className="h-3.5 w-3.5 shrink-0" />
|
||||
<span className="tabular-nums shrink-0">{parentIssue.identifier}</span>
|
||||
<span className="truncate group-hover/parent:text-foreground">
|
||||
{parentIssue.title}
|
||||
</span>
|
||||
{parentChildIssues.length > 0 && (() => {
|
||||
const done = parentChildIssues.filter((c) => c.status === "done").length;
|
||||
return (
|
||||
<span className="ml-1 inline-flex items-center gap-1 rounded-full bg-muted/60 px-1.5 py-0.5 shrink-0">
|
||||
<ProgressRing done={done} total={parentChildIssues.length} size={11} />
|
||||
<span className="tabular-nums text-[10.5px] font-medium">
|
||||
{done}/{parentChildIssues.length}
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
})()}
|
||||
</Link>
|
||||
)}
|
||||
|
||||
<ContentEditor
|
||||
ref={descEditorRef}
|
||||
key={id}
|
||||
@@ -761,122 +671,6 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Sub-issues — Linear-style */}
|
||||
{childIssues.length === 0 && (
|
||||
<div className="mt-6">
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors"
|
||||
onClick={() =>
|
||||
useModalStore.getState().open("create-issue", {
|
||||
parent_issue_id: issue.id,
|
||||
parent_issue_identifier: issue.identifier,
|
||||
})
|
||||
}
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
<span>Add sub-issues</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{childIssues.length > 0 && (() => {
|
||||
const doneCount = childIssues.filter((c) => c.status === "done").length;
|
||||
return (
|
||||
<div className="mt-10">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSubIssuesCollapsed((v) => !v)}
|
||||
className="flex items-center gap-1.5 text-sm font-medium text-foreground hover:text-foreground/80 transition-colors"
|
||||
>
|
||||
<ChevronDown
|
||||
className={cn(
|
||||
"h-3.5 w-3.5 text-muted-foreground transition-transform",
|
||||
subIssuesCollapsed && "-rotate-90",
|
||||
)}
|
||||
/>
|
||||
<span>Sub-issues</span>
|
||||
</button>
|
||||
<div className="inline-flex items-center gap-1.5 rounded-full bg-muted/60 px-2 py-0.5">
|
||||
<ProgressRing done={doneCount} total={childIssues.length} size={11} />
|
||||
<span className="text-[11px] text-muted-foreground tabular-nums font-medium">
|
||||
{doneCount}/{childIssues.length}
|
||||
</span>
|
||||
</div>
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<button
|
||||
type="button"
|
||||
className="ml-auto inline-flex h-7 w-7 items-center justify-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground transition-colors"
|
||||
onClick={() =>
|
||||
useModalStore.getState().open("create-issue", {
|
||||
parent_issue_id: issue.id,
|
||||
parent_issue_identifier: issue.identifier,
|
||||
})
|
||||
}
|
||||
aria-label="Add sub-issue"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
<TooltipContent side="bottom">Add sub-issue</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
{/* List */}
|
||||
{!subIssuesCollapsed && (
|
||||
<div className="overflow-hidden rounded-lg border bg-card/30 divide-y divide-border/60">
|
||||
{childIssues.map((child) => {
|
||||
const isDone =
|
||||
child.status === "done" || child.status === "cancelled";
|
||||
return (
|
||||
<Link
|
||||
key={child.id}
|
||||
href={`/issues/${child.id}`}
|
||||
className="flex items-center gap-2.5 px-3 py-2 hover:bg-accent/50 transition-colors group/row"
|
||||
>
|
||||
<StatusIcon
|
||||
status={child.status}
|
||||
className="h-[15px] w-[15px] shrink-0"
|
||||
/>
|
||||
<span className="text-[11px] text-muted-foreground tabular-nums font-medium shrink-0">
|
||||
{child.identifier}
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
"text-sm truncate flex-1",
|
||||
isDone
|
||||
? "text-muted-foreground"
|
||||
: "group-hover/row:text-foreground",
|
||||
)}
|
||||
>
|
||||
{child.title}
|
||||
</span>
|
||||
{child.assignee_type && child.assignee_id ? (
|
||||
<ActorAvatar
|
||||
actorType={child.assignee_type}
|
||||
actorId={child.assignee_id}
|
||||
size={20}
|
||||
className="shrink-0"
|
||||
/>
|
||||
) : (
|
||||
<span
|
||||
aria-hidden
|
||||
className="h-5 w-5 rounded-full border border-dashed border-muted-foreground/30 shrink-0"
|
||||
/>
|
||||
)}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
|
||||
<div className="my-8 border-t" />
|
||||
|
||||
{/* Activity / Comments */}
|
||||
@@ -979,6 +773,7 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo
|
||||
{/* Agent live output */}
|
||||
<AgentLiveCard
|
||||
issueId={id}
|
||||
agentName={issue.assignee_type === "agent" && issue.assignee_id ? getActorName("agent", issue.assignee_id) : undefined}
|
||||
scrollContainerRef={scrollContainerRef}
|
||||
/>
|
||||
|
||||
@@ -1123,6 +918,20 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* Jump to bottom button */}
|
||||
{showScrollBottom && (
|
||||
<div className="sticky bottom-4 flex justify-center pointer-events-none">
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
className="pointer-events-auto shadow-md"
|
||||
onClick={scrollToBottom}
|
||||
>
|
||||
<ChevronDown className="mr-1 h-3.5 w-3.5" />
|
||||
Jump to bottom
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</ResizablePanel>
|
||||
@@ -1211,26 +1020,6 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo
|
||||
</div>}
|
||||
</div>
|
||||
|
||||
{/* Parent issue */}
|
||||
{parentIssue && (
|
||||
<div>
|
||||
<div className="text-xs font-medium mb-2 flex items-center gap-1">
|
||||
<ChevronRight className="h-3.5 w-3.5 shrink-0 text-muted-foreground rotate-90" />
|
||||
Parent issue
|
||||
</div>
|
||||
<div className="pl-2">
|
||||
<Link
|
||||
href={`/issues/${parentIssue.id}`}
|
||||
className="flex items-center gap-1.5 rounded-md px-2 py-1.5 -mx-2 text-xs hover:bg-accent/50 transition-colors group"
|
||||
>
|
||||
<StatusIcon status={parentIssue.status} className="h-3.5 w-3.5 shrink-0" />
|
||||
<span className="text-muted-foreground shrink-0">{parentIssue.identifier}</span>
|
||||
<span className="truncate group-hover:text-foreground">{parentIssue.title}</span>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Details section */}
|
||||
<div>
|
||||
<button
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { issueListOptions } from "@core/issues/queries";
|
||||
import { useWorkspaceId } from "@core/hooks";
|
||||
import { useIssueStore } from "@/features/issues/store";
|
||||
import { StatusIcon } from "./status-icon";
|
||||
|
||||
interface IssueMentionCardProps {
|
||||
@@ -13,19 +11,15 @@ interface IssueMentionCardProps {
|
||||
}
|
||||
|
||||
export function IssueMentionCard({ issueId, fallbackLabel }: IssueMentionCardProps) {
|
||||
const wsId = useWorkspaceId();
|
||||
const { data: issues = [] } = useQuery(issueListOptions(wsId));
|
||||
const issue = issues.find((i) => i.id === issueId);
|
||||
const issue = useIssueStore((s) => s.issues.find((i) => i.id === issueId));
|
||||
|
||||
if (!issue) {
|
||||
return (
|
||||
<Link
|
||||
href={`/issues/${issueId}`}
|
||||
className="issue-mention inline-flex items-center gap-1.5 rounded-md border mx-0.5 px-2 py-0.5 text-xs hover:bg-accent transition-colors cursor-pointer max-w-72"
|
||||
className="text-primary font-medium cursor-pointer hover:underline"
|
||||
>
|
||||
<span className="font-medium text-muted-foreground">
|
||||
{fallbackLabel ?? issueId.slice(0, 8)}
|
||||
</span>
|
||||
{fallbackLabel ?? issueId.slice(0, 8)}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
@@ -33,11 +27,11 @@ export function IssueMentionCard({ issueId, fallbackLabel }: IssueMentionCardPro
|
||||
return (
|
||||
<Link
|
||||
href={`/issues/${issueId}`}
|
||||
className="issue-mention inline-flex items-center gap-1.5 rounded-md border mx-0.5 px-2 py-0.5 text-xs hover:bg-accent transition-colors cursor-pointer max-w-72"
|
||||
className="inline-flex items-center gap-1.5 rounded-md border px-2 py-0.5 text-sm hover:bg-accent transition-colors cursor-pointer no-underline"
|
||||
>
|
||||
<StatusIcon status={issue.status} className="h-3.5 w-3.5 shrink-0" />
|
||||
<span className="font-medium text-muted-foreground shrink-0">{issue.identifier}</span>
|
||||
<span className="text-foreground truncate">{issue.title}</span>
|
||||
<StatusIcon status={issue.status} className="h-3.5 w-3.5" />
|
||||
<span className="font-medium text-muted-foreground">{issue.identifier}</span>
|
||||
<span className="text-foreground">{issue.title}</span>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -43,9 +43,7 @@ import {
|
||||
PRIORITY_CONFIG,
|
||||
} from "@/features/issues/config";
|
||||
import { StatusIcon, PriorityIcon } from "@/features/issues/components";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useWorkspaceId } from "@core/hooks";
|
||||
import { memberListOptions, agentListOptions } from "@core/workspace/queries";
|
||||
import { useWorkspaceStore } from "@/features/workspace";
|
||||
import { ActorAvatar } from "@/components/common/actor-avatar";
|
||||
import {
|
||||
useIssueViewStore,
|
||||
@@ -157,9 +155,8 @@ function ActorSubContent({
|
||||
noAssigneeCount?: number;
|
||||
}) {
|
||||
const [search, setSearch] = useState("");
|
||||
const wsId = useWorkspaceId();
|
||||
const { data: members = [] } = useQuery(memberListOptions(wsId));
|
||||
const { data: agents = [] } = useQuery(agentListOptions(wsId));
|
||||
const members = useWorkspaceStore((s) => s.members);
|
||||
const agents = useWorkspaceStore((s) => s.agents);
|
||||
const query = search.toLowerCase();
|
||||
const filteredMembers = members.filter((m) =>
|
||||
m.name.toLowerCase().includes(query),
|
||||
|
||||
@@ -5,7 +5,7 @@ import { toast } from "sonner";
|
||||
import { ChevronRight, ListTodo } from "lucide-react";
|
||||
import type { IssueStatus } from "@/shared/types";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useIssueStore } from "@/features/issues/store";
|
||||
import { useIssueViewStore, initFilterWorkspaceSync } from "@/features/issues/stores/view-store";
|
||||
import { useIssuesScopeStore } from "@/features/issues/stores/issues-scope-store";
|
||||
import { ViewStoreProvider } from "@/features/issues/stores/view-store-context";
|
||||
@@ -13,9 +13,7 @@ import { filterIssues } from "@/features/issues/utils/filter";
|
||||
import { BOARD_STATUSES } from "@/features/issues/config";
|
||||
import { useWorkspaceStore } from "@/features/workspace";
|
||||
import { WorkspaceAvatar } from "@/features/workspace";
|
||||
import { useWorkspaceId } from "@core/hooks";
|
||||
import { issueListOptions } from "@core/issues/queries";
|
||||
import { useUpdateIssue } from "@core/issues/mutations";
|
||||
import { api } from "@/shared/api";
|
||||
import { useIssueSelectionStore } from "@/features/issues/stores/selection-store";
|
||||
import { IssuesHeader } from "./issues-header";
|
||||
import { BoardView } from "./board-view";
|
||||
@@ -23,9 +21,8 @@ import { ListView } from "./list-view";
|
||||
import { BatchActionToolbar } from "./batch-action-toolbar";
|
||||
|
||||
export function IssuesPage() {
|
||||
const wsId = useWorkspaceId();
|
||||
const { data: allIssues = [], isLoading: loading } = useQuery(issueListOptions(wsId));
|
||||
|
||||
const allIssues = useIssueStore((s) => s.issues);
|
||||
const loading = useIssueStore((s) => s.loading);
|
||||
const workspace = useWorkspaceStore((s) => s.workspace);
|
||||
const scope = useIssuesScopeStore((s) => s.scope);
|
||||
const viewMode = useIssueViewStore((s) => s.viewMode);
|
||||
@@ -67,7 +64,6 @@ export function IssuesPage() {
|
||||
return BOARD_STATUSES.filter((s) => !visibleStatuses.includes(s));
|
||||
}, [visibleStatuses]);
|
||||
|
||||
const updateIssueMutation = useUpdateIssue();
|
||||
const handleMoveIssue = useCallback(
|
||||
(issueId: string, newStatus: IssueStatus, newPosition?: number) => {
|
||||
// Auto-switch to manual sort so drag ordering is preserved
|
||||
@@ -82,12 +78,16 @@ export function IssuesPage() {
|
||||
};
|
||||
if (newPosition !== undefined) updates.position = newPosition;
|
||||
|
||||
updateIssueMutation.mutate(
|
||||
{ id: issueId, ...updates },
|
||||
{ onError: () => toast.error("Failed to move issue") },
|
||||
);
|
||||
useIssueStore.getState().updateIssue(issueId, updates);
|
||||
|
||||
api.updateIssue(issueId, updates).catch(() => {
|
||||
toast.error("Failed to move issue");
|
||||
api.listIssues({ limit: 200 }).then((res) => {
|
||||
useIssueStore.getState().setIssues(res.issues);
|
||||
}).catch(console.error);
|
||||
});
|
||||
},
|
||||
[updateIssueMutation],
|
||||
[]
|
||||
);
|
||||
|
||||
if (loading) {
|
||||
|
||||
@@ -3,11 +3,8 @@
|
||||
import { useState } from "react";
|
||||
import { Lock, UserMinus } from "lucide-react";
|
||||
import type { Agent, IssueAssigneeType, UpdateIssueRequest } from "@/shared/types";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useAuthStore } from "@/features/auth";
|
||||
import { useActorName } from "@/features/workspace";
|
||||
import { useWorkspaceId } from "@core/hooks";
|
||||
import { memberListOptions, agentListOptions } from "@core/workspace/queries";
|
||||
import { useWorkspaceStore, useActorName } from "@/features/workspace";
|
||||
import { ActorAvatar } from "@/components/common/actor-avatar";
|
||||
import {
|
||||
PropertyPicker,
|
||||
@@ -47,9 +44,8 @@ export function AssigneePicker({
|
||||
const setOpen = controlledOnOpenChange ?? setInternalOpen;
|
||||
const [filter, setFilter] = useState("");
|
||||
const user = useAuthStore((s) => s.user);
|
||||
const wsId = useWorkspaceId();
|
||||
const { data: members = [] } = useQuery(memberListOptions(wsId));
|
||||
const { data: agents = [] } = useQuery(agentListOptions(wsId));
|
||||
const members = useWorkspaceStore((s) => s.members);
|
||||
const agents = useWorkspaceStore((s) => s.agents);
|
||||
const { getActorName } = useActorName();
|
||||
|
||||
const currentMember = members.find((m) => m.user_id === user?.id);
|
||||
|
||||
@@ -38,7 +38,6 @@ function ReplyInput({
|
||||
const [isEmpty, setIsEmpty] = useState(true);
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [attachmentIds, setAttachmentIds] = useState<string[]>([]);
|
||||
const { uploadWithToast } = useFileUpload();
|
||||
|
||||
useEffect(() => {
|
||||
@@ -53,11 +52,7 @@ function ReplyInput({
|
||||
}, []);
|
||||
|
||||
const handleUpload = async (file: File) => {
|
||||
const result = await uploadWithToast(file, { issueId });
|
||||
if (result) {
|
||||
setAttachmentIds((prev) => [...prev, result.id]);
|
||||
}
|
||||
return result;
|
||||
return await uploadWithToast(file, { issueId });
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
@@ -65,10 +60,9 @@ function ReplyInput({
|
||||
if (!content || submitting) return;
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await onSubmit(content, attachmentIds.length > 0 ? attachmentIds : undefined);
|
||||
await onSubmit(content);
|
||||
editorRef.current?.clearContent();
|
||||
setIsEmpty(true);
|
||||
setAttachmentIds([]);
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
|
||||
@@ -1,32 +1,41 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { useQuery, useQueryClient, useMutationState } from "@tanstack/react-query";
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import type { IssueReaction } from "@/shared/types";
|
||||
import type {
|
||||
IssueReactionAddedPayload,
|
||||
IssueReactionRemovedPayload,
|
||||
} from "@/shared/types";
|
||||
import { issueReactionsOptions, issueKeys } from "@core/issues/queries";
|
||||
import { useToggleIssueReaction, type ToggleIssueReactionVars } from "@core/issues/mutations";
|
||||
import { api } from "@/shared/api";
|
||||
import { toast } from "sonner";
|
||||
import { useWSEvent, useWSReconnect } from "@/features/realtime";
|
||||
|
||||
export function useIssueReactions(issueId: string, userId?: string) {
|
||||
const qc = useQueryClient();
|
||||
const { data: serverReactions = [], isLoading: loading } = useQuery(
|
||||
issueReactionsOptions(issueId),
|
||||
);
|
||||
const [reactions, setReactions] = useState<IssueReaction[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const toggleMutation = useToggleIssueReaction(issueId);
|
||||
// Initial fetch
|
||||
useEffect(() => {
|
||||
setReactions([]);
|
||||
setLoading(true);
|
||||
api
|
||||
.getIssue(issueId)
|
||||
.then((iss) => setReactions(iss.reactions ?? []))
|
||||
.catch((e) => {
|
||||
console.error(e);
|
||||
toast.error("Failed to load reactions");
|
||||
})
|
||||
.finally(() => setLoading(false));
|
||||
}, [issueId]);
|
||||
|
||||
// Reconnect recovery
|
||||
useWSReconnect(
|
||||
useCallback(() => {
|
||||
qc.invalidateQueries({ queryKey: issueKeys.reactions(issueId) });
|
||||
}, [qc, issueId]),
|
||||
api.getIssue(issueId).then((iss) => setReactions(iss.reactions ?? [])).catch(console.error);
|
||||
}, [issueId]),
|
||||
);
|
||||
|
||||
// --- WS event handlers (update server cache for other users' actions) ---
|
||||
// --- WS event handlers ---
|
||||
|
||||
useWSEvent(
|
||||
"issue_reaction:added",
|
||||
@@ -34,16 +43,13 @@ export function useIssueReactions(issueId: string, userId?: string) {
|
||||
(payload: unknown) => {
|
||||
const { reaction, issue_id } = payload as IssueReactionAddedPayload;
|
||||
if (issue_id !== issueId) return;
|
||||
qc.setQueryData<IssueReaction[]>(
|
||||
issueKeys.reactions(issueId),
|
||||
(old) => {
|
||||
if (!old) return old;
|
||||
if (old.some((r) => r.id === reaction.id)) return old;
|
||||
return [...old, reaction];
|
||||
},
|
||||
);
|
||||
if (reaction.actor_type === "member" && reaction.actor_id === userId) return;
|
||||
setReactions((prev) => {
|
||||
if (prev.some((r) => r.id === reaction.id)) return prev;
|
||||
return [...prev, reaction];
|
||||
});
|
||||
},
|
||||
[qc, issueId],
|
||||
[issueId, userId],
|
||||
),
|
||||
);
|
||||
|
||||
@@ -53,85 +59,53 @@ export function useIssueReactions(issueId: string, userId?: string) {
|
||||
(payload: unknown) => {
|
||||
const p = payload as IssueReactionRemovedPayload;
|
||||
if (p.issue_id !== issueId) return;
|
||||
qc.setQueryData<IssueReaction[]>(
|
||||
issueKeys.reactions(issueId),
|
||||
(old) =>
|
||||
old?.filter(
|
||||
(r) =>
|
||||
!(
|
||||
r.emoji === p.emoji &&
|
||||
r.actor_type === p.actor_type &&
|
||||
r.actor_id === p.actor_id
|
||||
),
|
||||
),
|
||||
if (p.actor_type === "member" && p.actor_id === userId) return;
|
||||
setReactions((prev) =>
|
||||
prev.filter(
|
||||
(r) => !(r.emoji === p.emoji && r.actor_type === p.actor_type && r.actor_id === p.actor_id),
|
||||
),
|
||||
);
|
||||
},
|
||||
[qc, issueId],
|
||||
[issueId, userId],
|
||||
),
|
||||
);
|
||||
|
||||
// --- Optimistic UI derivation ---
|
||||
// Instead of writing temp data into the cache (which races with WS events),
|
||||
// derive optimistic state at render time from pending mutation variables.
|
||||
|
||||
const pendingVars = useMutationState({
|
||||
filters: {
|
||||
mutationKey: ["toggleIssueReaction", issueId],
|
||||
status: "pending",
|
||||
},
|
||||
select: (m) =>
|
||||
m.state.variables as ToggleIssueReactionVars | undefined,
|
||||
});
|
||||
|
||||
const reactions = useMemo(() => {
|
||||
if (pendingVars.length === 0) return serverReactions;
|
||||
|
||||
let result = [...serverReactions];
|
||||
for (const vars of pendingVars) {
|
||||
if (!vars) continue;
|
||||
if (vars.existing) {
|
||||
// Pending removal
|
||||
result = result.filter((r) => r.id !== vars.existing!.id);
|
||||
} else {
|
||||
// Pending add — skip if server already has it (WS arrived first)
|
||||
const alreadyExists = result.some(
|
||||
(r) =>
|
||||
r.emoji === vars.emoji &&
|
||||
r.actor_type === "member" &&
|
||||
r.actor_id === userId,
|
||||
);
|
||||
if (!alreadyExists) {
|
||||
result = [
|
||||
...result,
|
||||
{
|
||||
id: `optimistic-${vars.emoji}`,
|
||||
issue_id: issueId,
|
||||
actor_type: "member",
|
||||
actor_id: userId ?? "",
|
||||
emoji: vars.emoji,
|
||||
created_at: "",
|
||||
},
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}, [serverReactions, pendingVars, issueId, userId]);
|
||||
|
||||
// --- Mutation ---
|
||||
|
||||
const toggleReaction = useCallback(
|
||||
async (emoji: string) => {
|
||||
if (!userId) return;
|
||||
const existing = serverReactions.find(
|
||||
(r) =>
|
||||
r.emoji === emoji &&
|
||||
r.actor_type === "member" &&
|
||||
r.actor_id === userId,
|
||||
const existing = reactions.find(
|
||||
(r) => r.emoji === emoji && r.actor_type === "member" && r.actor_id === userId,
|
||||
);
|
||||
toggleMutation.mutate({ emoji, existing });
|
||||
if (existing) {
|
||||
setReactions((prev) => prev.filter((r) => r.id !== existing.id));
|
||||
try {
|
||||
await api.removeIssueReaction(issueId, emoji);
|
||||
} catch {
|
||||
setReactions((prev) => [...prev, existing]);
|
||||
toast.error("Failed to remove reaction");
|
||||
}
|
||||
} else {
|
||||
const temp: IssueReaction = {
|
||||
id: `temp-${Date.now()}`,
|
||||
issue_id: issueId,
|
||||
actor_type: "member",
|
||||
actor_id: userId,
|
||||
emoji,
|
||||
created_at: new Date().toISOString(),
|
||||
};
|
||||
setReactions((prev) => [...prev, temp]);
|
||||
try {
|
||||
const reaction = await api.addIssueReaction(issueId, emoji);
|
||||
setReactions((prev) => prev.map((r) => (r.id === temp.id ? reaction : r)));
|
||||
} catch {
|
||||
setReactions((prev) => prev.filter((r) => r.id !== temp.id));
|
||||
toast.error("Failed to add reaction");
|
||||
}
|
||||
}
|
||||
},
|
||||
[userId, serverReactions, toggleMutation],
|
||||
[issueId, userId, reactions],
|
||||
);
|
||||
|
||||
return { reactions, loading, toggleReaction };
|
||||
|
||||
@@ -1,29 +1,38 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback } from "react";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import type { IssueSubscriber } from "@/shared/types";
|
||||
import type {
|
||||
SubscriberAddedPayload,
|
||||
SubscriberRemovedPayload,
|
||||
} from "@/shared/types";
|
||||
import { issueSubscribersOptions, issueKeys } from "@core/issues/queries";
|
||||
import { useToggleIssueSubscriber } from "@core/issues/mutations";
|
||||
import { api } from "@/shared/api";
|
||||
import { toast } from "sonner";
|
||||
import { useWSEvent, useWSReconnect } from "@/features/realtime";
|
||||
|
||||
export function useIssueSubscribers(issueId: string, userId?: string) {
|
||||
const qc = useQueryClient();
|
||||
const { data: subscribers = [], isLoading: loading } = useQuery(
|
||||
issueSubscribersOptions(issueId),
|
||||
);
|
||||
const [subscribers, setSubscribers] = useState<IssueSubscriber[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const toggleMutation = useToggleIssueSubscriber(issueId);
|
||||
// Initial fetch
|
||||
useEffect(() => {
|
||||
setSubscribers([]);
|
||||
setLoading(true);
|
||||
api
|
||||
.listIssueSubscribers(issueId)
|
||||
.then((subs) => setSubscribers(subs))
|
||||
.catch((e) => {
|
||||
console.error(e);
|
||||
toast.error("Failed to load subscribers");
|
||||
})
|
||||
.finally(() => setLoading(false));
|
||||
}, [issueId]);
|
||||
|
||||
// Reconnect recovery
|
||||
useWSReconnect(
|
||||
useCallback(() => {
|
||||
qc.invalidateQueries({ queryKey: issueKeys.subscribers(issueId) });
|
||||
}, [qc, issueId]),
|
||||
api.listIssueSubscribers(issueId).then(setSubscribers).catch(console.error);
|
||||
}, [issueId]),
|
||||
);
|
||||
|
||||
// --- WS event handlers ---
|
||||
@@ -34,31 +43,21 @@ export function useIssueSubscribers(issueId: string, userId?: string) {
|
||||
(payload: unknown) => {
|
||||
const p = payload as SubscriberAddedPayload;
|
||||
if (p.issue_id !== issueId) return;
|
||||
qc.setQueryData<IssueSubscriber[]>(
|
||||
issueKeys.subscribers(issueId),
|
||||
(old) => {
|
||||
if (!old) return old;
|
||||
if (
|
||||
old.some(
|
||||
(s) =>
|
||||
s.user_id === p.user_id && s.user_type === p.user_type,
|
||||
)
|
||||
)
|
||||
return old;
|
||||
return [
|
||||
...old,
|
||||
{
|
||||
issue_id: p.issue_id,
|
||||
user_type: p.user_type as "member" | "agent",
|
||||
user_id: p.user_id,
|
||||
reason: p.reason as IssueSubscriber["reason"],
|
||||
created_at: new Date().toISOString(),
|
||||
},
|
||||
];
|
||||
},
|
||||
);
|
||||
setSubscribers((prev) => {
|
||||
if (prev.some((s) => s.user_id === p.user_id && s.user_type === p.user_type)) return prev;
|
||||
return [
|
||||
...prev,
|
||||
{
|
||||
issue_id: p.issue_id,
|
||||
user_type: p.user_type as "member" | "agent",
|
||||
user_id: p.user_id,
|
||||
reason: p.reason as IssueSubscriber["reason"],
|
||||
created_at: new Date().toISOString(),
|
||||
},
|
||||
];
|
||||
});
|
||||
},
|
||||
[qc, issueId],
|
||||
[issueId],
|
||||
),
|
||||
);
|
||||
|
||||
@@ -68,16 +67,11 @@ export function useIssueSubscribers(issueId: string, userId?: string) {
|
||||
(payload: unknown) => {
|
||||
const p = payload as SubscriberRemovedPayload;
|
||||
if (p.issue_id !== issueId) return;
|
||||
qc.setQueryData<IssueSubscriber[]>(
|
||||
issueKeys.subscribers(issueId),
|
||||
(old) =>
|
||||
old?.filter(
|
||||
(s) =>
|
||||
!(s.user_id === p.user_id && s.user_type === p.user_type),
|
||||
),
|
||||
setSubscribers((prev) =>
|
||||
prev.filter((s) => !(s.user_id === p.user_id && s.user_type === p.user_type)),
|
||||
);
|
||||
},
|
||||
[qc, issueId],
|
||||
[issueId],
|
||||
),
|
||||
);
|
||||
|
||||
@@ -88,29 +82,50 @@ export function useIssueSubscribers(issueId: string, userId?: string) {
|
||||
);
|
||||
|
||||
const toggleSubscriber = useCallback(
|
||||
async (
|
||||
subUserId: string,
|
||||
userType: "member" | "agent",
|
||||
currentlySubscribed: boolean,
|
||||
) => {
|
||||
toggleMutation.mutate({
|
||||
userId: subUserId,
|
||||
userType,
|
||||
subscribed: currentlySubscribed,
|
||||
});
|
||||
async (subUserId: string, userType: "member" | "agent", currentlySubscribed: boolean) => {
|
||||
if (currentlySubscribed) {
|
||||
// Optimistic remove + rollback on error
|
||||
const removed = subscribers.find(
|
||||
(s) => s.user_id === subUserId && s.user_type === userType,
|
||||
);
|
||||
setSubscribers((prev) =>
|
||||
prev.filter((s) => !(s.user_id === subUserId && s.user_type === userType)),
|
||||
);
|
||||
try {
|
||||
await api.unsubscribeFromIssue(issueId, subUserId, userType);
|
||||
} catch {
|
||||
if (removed) setSubscribers((prev) => [...prev, removed]);
|
||||
toast.error("Failed to update subscriber");
|
||||
}
|
||||
} else {
|
||||
// Optimistic add
|
||||
const tempSub: IssueSubscriber = {
|
||||
issue_id: issueId,
|
||||
user_type: userType,
|
||||
user_id: subUserId,
|
||||
reason: "manual" as const,
|
||||
created_at: new Date().toISOString(),
|
||||
};
|
||||
setSubscribers((prev) => {
|
||||
if (prev.some((s) => s.user_id === subUserId && s.user_type === userType)) return prev;
|
||||
return [...prev, tempSub];
|
||||
});
|
||||
try {
|
||||
await api.subscribeToIssue(issueId, subUserId, userType);
|
||||
} catch {
|
||||
setSubscribers((prev) =>
|
||||
prev.filter((s) => !(s.user_id === subUserId && s.user_type === userType && s.reason === "manual")),
|
||||
);
|
||||
toast.error("Failed to update subscriber");
|
||||
}
|
||||
}
|
||||
},
|
||||
[toggleMutation],
|
||||
[issueId, subscribers],
|
||||
);
|
||||
|
||||
const toggleSubscribe = useCallback(() => {
|
||||
if (userId) toggleSubscriber(userId, "member", isSubscribed);
|
||||
}, [userId, isSubscribed, toggleSubscriber]);
|
||||
|
||||
return {
|
||||
subscribers,
|
||||
loading,
|
||||
isSubscribed,
|
||||
toggleSubscribe,
|
||||
toggleSubscriber,
|
||||
};
|
||||
return { subscribers, loading, isSubscribed, toggleSubscribe, toggleSubscriber };
|
||||
}
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useCallback, useMemo } from "react";
|
||||
import { useQuery, useQueryClient, useMutationState } from "@tanstack/react-query";
|
||||
import type { Comment, TimelineEntry, Reaction } from "@/shared/types";
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import type { Comment, TimelineEntry } from "@/shared/types";
|
||||
import type {
|
||||
CommentCreatedPayload,
|
||||
CommentUpdatedPayload,
|
||||
@@ -11,14 +10,7 @@ import type {
|
||||
ReactionAddedPayload,
|
||||
ReactionRemovedPayload,
|
||||
} from "@/shared/types";
|
||||
import { issueTimelineOptions, issueKeys } from "@core/issues/queries";
|
||||
import {
|
||||
useCreateComment,
|
||||
useUpdateComment,
|
||||
useDeleteComment,
|
||||
useToggleCommentReaction,
|
||||
type ToggleCommentReactionVars,
|
||||
} from "@core/issues/mutations";
|
||||
import { api } from "@/shared/api";
|
||||
import { useWSEvent, useWSReconnect } from "@/features/realtime";
|
||||
import { toast } from "sonner";
|
||||
|
||||
@@ -38,22 +30,29 @@ function commentToTimelineEntry(c: Comment): TimelineEntry {
|
||||
}
|
||||
|
||||
export function useIssueTimeline(issueId: string, userId?: string) {
|
||||
const qc = useQueryClient();
|
||||
const { data: timeline = [], isLoading: loading } = useQuery(
|
||||
issueTimelineOptions(issueId),
|
||||
);
|
||||
const [timeline, setTimeline] = useState<TimelineEntry[]>([]);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const createCommentMutation = useCreateComment(issueId);
|
||||
const updateCommentMutation = useUpdateComment(issueId);
|
||||
const deleteCommentMutation = useDeleteComment(issueId);
|
||||
const toggleReactionMutation = useToggleCommentReaction(issueId);
|
||||
// Initial fetch + reset on id change
|
||||
useEffect(() => {
|
||||
setTimeline([]);
|
||||
setLoading(true);
|
||||
api
|
||||
.listTimeline(issueId)
|
||||
.then((entries) => setTimeline(entries))
|
||||
.catch((e) => {
|
||||
console.error(e);
|
||||
toast.error("Failed to load activity");
|
||||
})
|
||||
.finally(() => setLoading(false));
|
||||
}, [issueId]);
|
||||
|
||||
// Reconnect recovery
|
||||
useWSReconnect(
|
||||
useCallback(() => {
|
||||
qc.invalidateQueries({ queryKey: issueKeys.timeline(issueId) });
|
||||
}, [qc, issueId]),
|
||||
api.listTimeline(issueId).then(setTimeline).catch(console.error);
|
||||
}, [issueId]),
|
||||
);
|
||||
|
||||
// --- WS event handlers ---
|
||||
@@ -64,16 +63,13 @@ export function useIssueTimeline(issueId: string, userId?: string) {
|
||||
(payload: unknown) => {
|
||||
const { comment } = payload as CommentCreatedPayload;
|
||||
if (comment.issue_id !== issueId) return;
|
||||
qc.setQueryData<TimelineEntry[]>(
|
||||
issueKeys.timeline(issueId),
|
||||
(old) => {
|
||||
if (!old) return old;
|
||||
if (old.some((e) => e.id === comment.id)) return old;
|
||||
return [...old, commentToTimelineEntry(comment)];
|
||||
},
|
||||
);
|
||||
if (comment.author_type === "member" && comment.author_id === userId) return;
|
||||
setTimeline((prev) => {
|
||||
if (prev.some((e) => e.id === comment.id)) return prev;
|
||||
return [...prev, commentToTimelineEntry(comment)];
|
||||
});
|
||||
},
|
||||
[qc, issueId],
|
||||
[issueId, userId],
|
||||
),
|
||||
);
|
||||
|
||||
@@ -83,16 +79,12 @@ export function useIssueTimeline(issueId: string, userId?: string) {
|
||||
(payload: unknown) => {
|
||||
const { comment } = payload as CommentUpdatedPayload;
|
||||
if (comment.issue_id === issueId) {
|
||||
qc.setQueryData<TimelineEntry[]>(
|
||||
issueKeys.timeline(issueId),
|
||||
(old) =>
|
||||
old?.map((e) =>
|
||||
e.id === comment.id ? commentToTimelineEntry(comment) : e,
|
||||
),
|
||||
setTimeline((prev) =>
|
||||
prev.map((e) => (e.id === comment.id ? commentToTimelineEntry(comment) : e)),
|
||||
);
|
||||
}
|
||||
},
|
||||
[qc, issueId],
|
||||
[issueId],
|
||||
),
|
||||
);
|
||||
|
||||
@@ -102,31 +94,23 @@ export function useIssueTimeline(issueId: string, userId?: string) {
|
||||
(payload: unknown) => {
|
||||
const { comment_id, issue_id } = payload as CommentDeletedPayload;
|
||||
if (issue_id === issueId) {
|
||||
qc.setQueryData<TimelineEntry[]>(
|
||||
issueKeys.timeline(issueId),
|
||||
(old) => {
|
||||
if (!old) return old;
|
||||
const idsToRemove = new Set<string>([comment_id]);
|
||||
let added = true;
|
||||
while (added) {
|
||||
added = false;
|
||||
for (const e of old) {
|
||||
if (
|
||||
e.parent_id &&
|
||||
idsToRemove.has(e.parent_id) &&
|
||||
!idsToRemove.has(e.id)
|
||||
) {
|
||||
idsToRemove.add(e.id);
|
||||
added = true;
|
||||
}
|
||||
setTimeline((prev) => {
|
||||
const idsToRemove = new Set<string>([comment_id]);
|
||||
let added = true;
|
||||
while (added) {
|
||||
added = false;
|
||||
for (const e of prev) {
|
||||
if (e.parent_id && idsToRemove.has(e.parent_id) && !idsToRemove.has(e.id)) {
|
||||
idsToRemove.add(e.id);
|
||||
added = true;
|
||||
}
|
||||
}
|
||||
return old.filter((e) => !idsToRemove.has(e.id));
|
||||
},
|
||||
);
|
||||
}
|
||||
return prev.filter((e) => !idsToRemove.has(e.id));
|
||||
});
|
||||
}
|
||||
},
|
||||
[qc, issueId],
|
||||
[issueId],
|
||||
),
|
||||
);
|
||||
|
||||
@@ -138,16 +122,12 @@ export function useIssueTimeline(issueId: string, userId?: string) {
|
||||
if (p.issue_id !== issueId) return;
|
||||
const entry = p.entry;
|
||||
if (!entry || !entry.id) return;
|
||||
qc.setQueryData<TimelineEntry[]>(
|
||||
issueKeys.timeline(issueId),
|
||||
(old) => {
|
||||
if (!old) return old;
|
||||
if (old.some((e) => e.id === entry.id)) return old;
|
||||
return [...old, entry];
|
||||
},
|
||||
);
|
||||
setTimeline((prev) => {
|
||||
if (prev.some((e) => e.id === entry.id)) return prev;
|
||||
return [...prev, entry];
|
||||
});
|
||||
},
|
||||
[qc, issueId],
|
||||
[issueId],
|
||||
),
|
||||
);
|
||||
|
||||
@@ -157,18 +137,17 @@ export function useIssueTimeline(issueId: string, userId?: string) {
|
||||
(payload: unknown) => {
|
||||
const { reaction, issue_id } = payload as ReactionAddedPayload;
|
||||
if (issue_id !== issueId) return;
|
||||
qc.setQueryData<TimelineEntry[]>(
|
||||
issueKeys.timeline(issueId),
|
||||
(old) =>
|
||||
old?.map((e) => {
|
||||
if (e.id !== reaction.comment_id) return e;
|
||||
const existing = e.reactions ?? [];
|
||||
if (existing.some((r) => r.id === reaction.id)) return e;
|
||||
return { ...e, reactions: [...existing, reaction] };
|
||||
}),
|
||||
if (reaction.actor_type === "member" && reaction.actor_id === userId) return;
|
||||
setTimeline((prev) =>
|
||||
prev.map((e) => {
|
||||
if (e.id !== reaction.comment_id) return e;
|
||||
const existing = e.reactions ?? [];
|
||||
if (existing.some((r) => r.id === reaction.id)) return e;
|
||||
return { ...e, reactions: [...existing, reaction] };
|
||||
}),
|
||||
);
|
||||
},
|
||||
[qc, issueId],
|
||||
[issueId, userId],
|
||||
),
|
||||
);
|
||||
|
||||
@@ -178,26 +157,20 @@ export function useIssueTimeline(issueId: string, userId?: string) {
|
||||
(payload: unknown) => {
|
||||
const p = payload as ReactionRemovedPayload;
|
||||
if (p.issue_id !== issueId) return;
|
||||
qc.setQueryData<TimelineEntry[]>(
|
||||
issueKeys.timeline(issueId),
|
||||
(old) =>
|
||||
old?.map((e) => {
|
||||
if (e.id !== p.comment_id) return e;
|
||||
return {
|
||||
...e,
|
||||
reactions: (e.reactions ?? []).filter(
|
||||
(r) =>
|
||||
!(
|
||||
r.emoji === p.emoji &&
|
||||
r.actor_type === p.actor_type &&
|
||||
r.actor_id === p.actor_id
|
||||
),
|
||||
),
|
||||
};
|
||||
}),
|
||||
if (p.actor_type === "member" && p.actor_id === userId) return;
|
||||
setTimeline((prev) =>
|
||||
prev.map((e) => {
|
||||
if (e.id !== p.comment_id) return e;
|
||||
return {
|
||||
...e,
|
||||
reactions: (e.reactions ?? []).filter(
|
||||
(r) => !(r.emoji === p.emoji && r.actor_type === p.actor_type && r.actor_id === p.actor_id),
|
||||
),
|
||||
};
|
||||
}),
|
||||
);
|
||||
},
|
||||
[qc, issueId],
|
||||
[issueId, userId],
|
||||
),
|
||||
);
|
||||
|
||||
@@ -208,9 +181,10 @@ export function useIssueTimeline(issueId: string, userId?: string) {
|
||||
if (!content.trim() || submitting || !userId) return;
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await createCommentMutation.mutateAsync({
|
||||
content,
|
||||
attachmentIds,
|
||||
const comment = await api.createComment(issueId, content, undefined, undefined, attachmentIds);
|
||||
setTimeline((prev) => {
|
||||
if (prev.some((e) => e.id === comment.id)) return prev;
|
||||
return [...prev, commentToTimelineEntry(comment)];
|
||||
});
|
||||
} catch {
|
||||
toast.error("Failed to send comment");
|
||||
@@ -218,121 +192,151 @@ export function useIssueTimeline(issueId: string, userId?: string) {
|
||||
setSubmitting(false);
|
||||
}
|
||||
},
|
||||
[userId, submitting, createCommentMutation],
|
||||
[issueId, userId],
|
||||
);
|
||||
|
||||
const submitReply = useCallback(
|
||||
async (parentId: string, content: string, attachmentIds?: string[]) => {
|
||||
if (!content.trim() || !userId) return;
|
||||
try {
|
||||
await createCommentMutation.mutateAsync({
|
||||
content,
|
||||
type: "comment",
|
||||
parentId,
|
||||
attachmentIds,
|
||||
const comment = await api.createComment(issueId, content, "comment", parentId, attachmentIds);
|
||||
setTimeline((prev) => {
|
||||
if (prev.some((e) => e.id === comment.id)) return prev;
|
||||
return [...prev, commentToTimelineEntry(comment)];
|
||||
});
|
||||
} catch {
|
||||
toast.error("Failed to send reply");
|
||||
}
|
||||
},
|
||||
[userId, createCommentMutation],
|
||||
[issueId, userId],
|
||||
);
|
||||
|
||||
const editComment = useCallback(
|
||||
async (commentId: string, content: string) => {
|
||||
// Optimistic: update content immediately
|
||||
let prevContent: string | undefined;
|
||||
setTimeline((prev) =>
|
||||
prev.map((e) => {
|
||||
if (e.id !== commentId) return e;
|
||||
prevContent = e.content;
|
||||
return { ...e, content, updated_at: new Date().toISOString() };
|
||||
}),
|
||||
);
|
||||
try {
|
||||
await updateCommentMutation.mutateAsync({ commentId, content });
|
||||
const updated = await api.updateComment(commentId, content);
|
||||
setTimeline((prev) =>
|
||||
prev.map((e) => (e.id === updated.id ? commentToTimelineEntry(updated) : e)),
|
||||
);
|
||||
} catch {
|
||||
// Rollback
|
||||
if (prevContent !== undefined) {
|
||||
setTimeline((prev) =>
|
||||
prev.map((e) => (e.id === commentId ? { ...e, content: prevContent! } : e)),
|
||||
);
|
||||
}
|
||||
toast.error("Failed to update comment");
|
||||
}
|
||||
},
|
||||
[updateCommentMutation],
|
||||
[],
|
||||
);
|
||||
|
||||
const deleteComment = useCallback(
|
||||
async (commentId: string) => {
|
||||
// Capture entries for rollback
|
||||
let removedEntries: TimelineEntry[] = [];
|
||||
setTimeline((prev) => {
|
||||
const idsToRemove = new Set<string>([commentId]);
|
||||
let added = true;
|
||||
while (added) {
|
||||
added = false;
|
||||
for (const e of prev) {
|
||||
if (e.parent_id && idsToRemove.has(e.parent_id) && !idsToRemove.has(e.id)) {
|
||||
idsToRemove.add(e.id);
|
||||
added = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
removedEntries = prev.filter((e) => idsToRemove.has(e.id));
|
||||
return prev.filter((e) => !idsToRemove.has(e.id));
|
||||
});
|
||||
try {
|
||||
await deleteCommentMutation.mutateAsync(commentId);
|
||||
await api.deleteComment(commentId);
|
||||
} catch {
|
||||
// Rollback: re-add removed entries
|
||||
setTimeline((prev) => [...prev, ...removedEntries]);
|
||||
toast.error("Failed to delete comment");
|
||||
}
|
||||
},
|
||||
[deleteCommentMutation],
|
||||
[],
|
||||
);
|
||||
|
||||
// --- Optimistic UI derivation for comment reactions ---
|
||||
// Instead of writing temp data into the cache (which races with WS events),
|
||||
// derive optimistic state at render time from pending mutation variables.
|
||||
|
||||
const pendingReactionVars = useMutationState({
|
||||
filters: {
|
||||
mutationKey: ["toggleCommentReaction", issueId],
|
||||
status: "pending",
|
||||
},
|
||||
select: (m) =>
|
||||
m.state.variables as ToggleCommentReactionVars | undefined,
|
||||
});
|
||||
|
||||
const optimisticTimeline = useMemo(() => {
|
||||
if (pendingReactionVars.length === 0) return timeline;
|
||||
|
||||
return timeline.map((entry) => {
|
||||
const pendingForEntry = pendingReactionVars.filter(
|
||||
(v) => v && v.commentId === entry.id,
|
||||
);
|
||||
if (pendingForEntry.length === 0) return entry;
|
||||
|
||||
let reactions = entry.reactions ?? [];
|
||||
for (const vars of pendingForEntry) {
|
||||
if (!vars) continue;
|
||||
if (vars.existing) {
|
||||
// Pending removal
|
||||
reactions = reactions.filter((r) => r.id !== vars.existing!.id);
|
||||
} else {
|
||||
// Pending add — skip if server already has it (WS arrived first)
|
||||
const alreadyExists = reactions.some(
|
||||
(r) =>
|
||||
r.emoji === vars.emoji &&
|
||||
r.actor_type === "member" &&
|
||||
r.actor_id === userId,
|
||||
);
|
||||
if (!alreadyExists) {
|
||||
reactions = [
|
||||
...reactions,
|
||||
{
|
||||
id: `optimistic-${vars.emoji}`,
|
||||
comment_id: vars.commentId,
|
||||
actor_type: "member",
|
||||
actor_id: userId ?? "",
|
||||
emoji: vars.emoji,
|
||||
created_at: "",
|
||||
},
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
return { ...entry, reactions };
|
||||
});
|
||||
}, [timeline, pendingReactionVars, userId]);
|
||||
|
||||
const toggleReaction = useCallback(
|
||||
async (commentId: string, emoji: string) => {
|
||||
if (!userId) return;
|
||||
// Read from server timeline (not optimistic) to find the real reaction
|
||||
const entry = timeline.find((e) => e.id === commentId);
|
||||
const existing: Reaction | undefined = (entry?.reactions ?? []).find(
|
||||
(r) =>
|
||||
r.emoji === emoji &&
|
||||
r.actor_type === "member" &&
|
||||
r.actor_id === userId,
|
||||
const existing = (entry?.reactions ?? []).find(
|
||||
(r) => r.emoji === emoji && r.actor_type === "member" && r.actor_id === userId,
|
||||
);
|
||||
toggleReactionMutation.mutate({ commentId, emoji, existing });
|
||||
if (existing) {
|
||||
setTimeline((prev) =>
|
||||
prev.map((e) => {
|
||||
if (e.id !== commentId) return e;
|
||||
return { ...e, reactions: (e.reactions ?? []).filter((r) => r.id !== existing.id) };
|
||||
}),
|
||||
);
|
||||
try {
|
||||
await api.removeReaction(commentId, emoji);
|
||||
} catch {
|
||||
setTimeline((prev) =>
|
||||
prev.map((e) => {
|
||||
if (e.id !== commentId) return e;
|
||||
return { ...e, reactions: [...(e.reactions ?? []), existing] };
|
||||
}),
|
||||
);
|
||||
toast.error("Failed to remove reaction");
|
||||
}
|
||||
} else {
|
||||
const tempReaction = {
|
||||
id: `temp-${Date.now()}`,
|
||||
comment_id: commentId,
|
||||
actor_type: "member",
|
||||
actor_id: userId,
|
||||
emoji,
|
||||
created_at: new Date().toISOString(),
|
||||
};
|
||||
setTimeline((prev) =>
|
||||
prev.map((e) => {
|
||||
if (e.id !== commentId) return e;
|
||||
return { ...e, reactions: [...(e.reactions ?? []), tempReaction] };
|
||||
}),
|
||||
);
|
||||
try {
|
||||
const reaction = await api.addReaction(commentId, emoji);
|
||||
setTimeline((prev) =>
|
||||
prev.map((e) => {
|
||||
if (e.id !== commentId) return e;
|
||||
return {
|
||||
...e,
|
||||
reactions: (e.reactions ?? []).map((r) => (r.id === tempReaction.id ? reaction : r)),
|
||||
};
|
||||
}),
|
||||
);
|
||||
} catch {
|
||||
setTimeline((prev) =>
|
||||
prev.map((e) => {
|
||||
if (e.id !== commentId) return e;
|
||||
return { ...e, reactions: (e.reactions ?? []).filter((r) => r.id !== tempReaction.id) };
|
||||
}),
|
||||
);
|
||||
toast.error("Failed to add reaction");
|
||||
}
|
||||
}
|
||||
},
|
||||
[userId, timeline, toggleReactionMutation],
|
||||
[userId, timeline],
|
||||
);
|
||||
|
||||
return {
|
||||
timeline: optimisticTimeline,
|
||||
timeline,
|
||||
loading,
|
||||
submitting,
|
||||
submitComment,
|
||||
|
||||
@@ -1,13 +1,57 @@
|
||||
"use client";
|
||||
|
||||
import { create } from "zustand";
|
||||
import type { Issue } from "@/shared/types";
|
||||
import { toast } from "sonner";
|
||||
import { api } from "@/shared/api";
|
||||
import { createLogger } from "@/shared/logger";
|
||||
|
||||
interface IssueClientState {
|
||||
const logger = createLogger("issue-store");
|
||||
|
||||
interface IssueState {
|
||||
issues: Issue[];
|
||||
loading: boolean;
|
||||
activeIssueId: string | null;
|
||||
fetch: () => Promise<void>;
|
||||
setIssues: (issues: Issue[]) => void;
|
||||
addIssue: (issue: Issue) => void;
|
||||
updateIssue: (id: string, updates: Partial<Issue>) => void;
|
||||
removeIssue: (id: string) => void;
|
||||
setActiveIssue: (id: string | null) => void;
|
||||
}
|
||||
|
||||
export const useIssueStore = create<IssueClientState>((set) => ({
|
||||
export const useIssueStore = create<IssueState>((set, get) => ({
|
||||
issues: [],
|
||||
loading: true,
|
||||
activeIssueId: null,
|
||||
|
||||
fetch: async () => {
|
||||
logger.debug("fetch start");
|
||||
const isInitialLoad = get().issues.length === 0;
|
||||
if (isInitialLoad) set({ loading: true });
|
||||
try {
|
||||
const res = await api.listIssues({ limit: 200 });
|
||||
logger.info("fetched", res.issues.length, "issues");
|
||||
set({ issues: res.issues, loading: false });
|
||||
} catch (err) {
|
||||
logger.error("fetch failed", err);
|
||||
toast.error("Failed to load issues");
|
||||
if (isInitialLoad) set({ loading: false });
|
||||
}
|
||||
},
|
||||
|
||||
setIssues: (issues) => set({ issues }),
|
||||
addIssue: (issue) =>
|
||||
set((s) => ({
|
||||
issues: s.issues.some((i) => i.id === issue.id)
|
||||
? s.issues
|
||||
: [...s.issues, issue],
|
||||
})),
|
||||
updateIssue: (id, updates) =>
|
||||
set((s) => ({
|
||||
issues: s.issues.map((i) => (i.id === id ? { ...i, ...updates } : i)),
|
||||
})),
|
||||
removeIssue: (id) =>
|
||||
set((s) => ({ issues: s.issues.filter((i) => i.id !== id) })),
|
||||
setActiveIssue: (id) => set({ activeIssueId: id }),
|
||||
}));
|
||||
|
||||
@@ -131,7 +131,7 @@ export const en: LandingDict = {
|
||||
{
|
||||
title: "Create your first agent",
|
||||
description:
|
||||
"Give it a name, write instructions, and attach skills. Agents automatically activate on assignment, on comment, or on mention.",
|
||||
"Give it a name, write instructions, attach skills, and set triggers. Choose when it activates: on assignment, on comment, or on mention.",
|
||||
},
|
||||
{
|
||||
title: "Assign an issue and watch it work",
|
||||
@@ -272,53 +272,6 @@ export const en: LandingDict = {
|
||||
title: "Changelog",
|
||||
subtitle: "New updates and improvements to Multica.",
|
||||
entries: [
|
||||
{
|
||||
version: "0.1.9",
|
||||
date: "2026-04-08",
|
||||
title: "Sub-Issues, TanStack Query & Usage Tracking",
|
||||
changes: [
|
||||
"Sub-issue support — create, view, and manage child issues within any issue",
|
||||
"Full migration to TanStack Query for server state (issues, inbox, workspace, runtimes)",
|
||||
"Per-task token usage tracking across all agent providers",
|
||||
"Multiple agents can now run concurrently on the same issue",
|
||||
"Board view: Done column shows total count with infinite scroll",
|
||||
"ReadonlyContent component for lightweight Markdown display in comments",
|
||||
"Optimistic UI updates for reactions and mutations with rollback",
|
||||
"WebSocket-driven cache invalidation replaces polling and refetch-on-focus",
|
||||
"Browser session persists during CLI login flow",
|
||||
"Daemon reuses existing worktrees by updating to latest remote",
|
||||
"Fixed slow tab switching caused by dynamic root layout",
|
||||
],
|
||||
},
|
||||
{
|
||||
version: "0.1.8",
|
||||
date: "2026-04-07",
|
||||
title: "OAuth, OpenClaw & Issue Loading",
|
||||
changes: [
|
||||
"Google OAuth login",
|
||||
"OpenClaw runtime support for running agents on OpenClaw infrastructure",
|
||||
"Redesigned agent live card — always sticky with manual expand/collapse toggle",
|
||||
"Load all open issues without pagination limit; closed issues paginate on scroll",
|
||||
"JWT and CloudFront cookie expiration extended from 72 hours to 30 days",
|
||||
"Remember last selected workspace after re-login",
|
||||
"Daemon ensures multica CLI is on PATH in agent task environment",
|
||||
"PR template and CLI install guide for agent-driven setup",
|
||||
],
|
||||
},
|
||||
{
|
||||
version: "0.1.7",
|
||||
date: "2026-04-05",
|
||||
title: "Comment Pagination & CLI Polish",
|
||||
changes: [
|
||||
"Comment list pagination in both the API and CLI",
|
||||
"Inbox archive now dismisses all items for the same issue at once",
|
||||
"CLI help output overhauled to match gh CLI style with examples",
|
||||
"Attachments use UUIDv7 as S3 key and auto-link on issue/comment creation",
|
||||
"@mention assigned agents on done or cancelled issues",
|
||||
"Reply @mention inheritance skips when the reply only mentions members",
|
||||
"Worktree setup preserves existing .env.worktree variables",
|
||||
],
|
||||
},
|
||||
{
|
||||
version: "0.1.6",
|
||||
date: "2026-04-03",
|
||||
@@ -403,7 +356,7 @@ export const en: LandingDict = {
|
||||
title: "Core Platform",
|
||||
changes: [
|
||||
"Multi-workspace switching and creation",
|
||||
"Agent management UI with skills",
|
||||
"Agent management UI with skills, tools, and triggers",
|
||||
"Unified agent SDK supporting Claude Code and Codex backends",
|
||||
"Comment CRUD with real-time WebSocket updates",
|
||||
"Task service layer and daemon REST protocol",
|
||||
|
||||
@@ -272,53 +272,6 @@ export const zh: LandingDict = {
|
||||
title: "\u66f4\u65b0\u65e5\u5fd7",
|
||||
subtitle: "Multica \u7684\u6700\u65b0\u66f4\u65b0\u548c\u6539\u8fdb\u3002",
|
||||
entries: [
|
||||
{
|
||||
version: "0.1.9",
|
||||
date: "2026-04-08",
|
||||
title: "子 Issue、TanStack Query 与用量追踪",
|
||||
changes: [
|
||||
"子 Issue 支持——在任意 Issue 内创建、查看和管理子任务",
|
||||
"全面迁移至 TanStack Query 管理服务端状态(Issue、收件箱、工作区、运行时)",
|
||||
"按任务维度追踪所有 Agent 提供商的 token 用量",
|
||||
"同一 Issue 支持多个 Agent 并发执行",
|
||||
"看板视图:Done 列显示总数并支持无限滚动",
|
||||
"新增 ReadonlyContent 组件,轻量渲染评论中的 Markdown",
|
||||
"表情反应和变更操作支持乐观更新与回滚",
|
||||
"WebSocket 驱动缓存失效,替代轮询和焦点刷新",
|
||||
"CLI 登录流程中浏览器会话保持不丢失",
|
||||
"守护进程复用已有 worktree 时自动拉取最新远程代码",
|
||||
"修复动态根布局导致的标签页切换卡顿问题",
|
||||
],
|
||||
},
|
||||
{
|
||||
version: "0.1.8",
|
||||
date: "2026-04-07",
|
||||
title: "OAuth、OpenClaw 与 Issue 加载优化",
|
||||
changes: [
|
||||
"支持 Google OAuth 登录",
|
||||
"新增 OpenClaw 运行时,支持在 OpenClaw 基础设施上运行 Agent",
|
||||
"Agent 实时卡片重新设计——始终吸顶,支持手动展开/收起",
|
||||
"打开的 Issue 不再分页限制全量加载,已关闭的 Issue 滚动分页",
|
||||
"JWT 和 CloudFront Cookie 有效期从 72 小时延长至 30 天",
|
||||
"重新登录后记住上次选择的工作区",
|
||||
"守护进程确保 Agent 任务环境中 multica CLI 在 PATH 上",
|
||||
"新增 PR 模板和面向 Agent 的 CLI 安装指南",
|
||||
],
|
||||
},
|
||||
{
|
||||
version: "0.1.7",
|
||||
date: "2026-04-05",
|
||||
title: "评论分页与 CLI 优化",
|
||||
changes: [
|
||||
"评论列表支持分页,API 和 CLI 均已适配",
|
||||
"收件箱归档操作现在一次性归档同一 Issue 的所有通知",
|
||||
"CLI 帮助输出重新设计,匹配 gh CLI 风格并增加示例",
|
||||
"附件使用 UUIDv7 作为 S3 key,创建 Issue/评论时自动关联附件",
|
||||
"支持在已完成或已取消的 Issue 上 @提及已分配的 Agent",
|
||||
"回复仅 @提及成员时跳过父级提及继承逻辑",
|
||||
"Worktree 环境配置保留已有的 .env.worktree 变量",
|
||||
],
|
||||
},
|
||||
{
|
||||
version: "0.1.6",
|
||||
date: "2026-04-03",
|
||||
|
||||
@@ -30,11 +30,9 @@ import { TitleEditor } from "@/features/editor";
|
||||
import { StatusIcon, PriorityIcon } from "@/features/issues/components";
|
||||
import { ALL_STATUSES, STATUS_CONFIG, PRIORITY_ORDER, PRIORITY_CONFIG } from "@/features/issues/config";
|
||||
import { useWorkspaceStore, useActorName } from "@/features/workspace";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useWorkspaceId } from "@core/hooks";
|
||||
import { memberListOptions, agentListOptions } from "@core/workspace/queries";
|
||||
import { useIssueStore } from "@/features/issues";
|
||||
import { useIssueDraftStore } from "@/features/issues/stores/draft-store";
|
||||
import { useCreateIssue } from "@core/issues/mutations";
|
||||
import { api } from "@/shared/api";
|
||||
import { useFileUpload } from "@/shared/hooks/use-file-upload";
|
||||
import { FileUploadButton } from "@/components/common/file-upload-button";
|
||||
import { ActorAvatar } from "@/components/common/actor-avatar";
|
||||
@@ -70,9 +68,8 @@ function PillButton({
|
||||
export function CreateIssueModal({ onClose, data }: { onClose: () => void; data?: Record<string, unknown> | null }) {
|
||||
const router = useRouter();
|
||||
const workspaceName = useWorkspaceStore((s) => s.workspace?.name);
|
||||
const wsId = useWorkspaceId();
|
||||
const { data: members = [] } = useQuery(memberListOptions(wsId));
|
||||
const { data: agents = [] } = useQuery(agentListOptions(wsId));
|
||||
const members = useWorkspaceStore((s) => s.members);
|
||||
const agents = useWorkspaceStore((s) => s.agents);
|
||||
const { getActorName } = useActorName();
|
||||
|
||||
const draft = useIssueDraftStore((s) => s.draft);
|
||||
@@ -96,16 +93,9 @@ export function CreateIssueModal({ onClose, data }: { onClose: () => void; data?
|
||||
// Due date popover
|
||||
const [dueDateOpen, setDueDateOpen] = useState(false);
|
||||
|
||||
// File upload — collect attachment IDs so we can link them after issue creation.
|
||||
const [attachmentIds, setAttachmentIds] = useState<string[]>([]);
|
||||
// File upload
|
||||
const { uploadWithToast } = useFileUpload();
|
||||
const handleUpload = async (file: File) => {
|
||||
const result = await uploadWithToast(file);
|
||||
if (result) {
|
||||
setAttachmentIds((prev) => [...prev, result.id]);
|
||||
}
|
||||
return result;
|
||||
};
|
||||
const handleUpload = (file: File) => uploadWithToast(file);
|
||||
|
||||
const assigneeQuery = assigneeFilter.toLowerCase();
|
||||
const filteredMembers = members.filter((m) => m.name.toLowerCase().includes(assigneeQuery));
|
||||
@@ -128,12 +118,11 @@ export function CreateIssueModal({ onClose, data }: { onClose: () => void; data?
|
||||
};
|
||||
const updateDueDate = (v: string | null) => { setDueDate(v); setDraft({ dueDate: v }); };
|
||||
|
||||
const createIssueMutation = useCreateIssue();
|
||||
const handleSubmit = async () => {
|
||||
if (!title.trim() || submitting) return;
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const issue = await createIssueMutation.mutateAsync({
|
||||
const issue = await api.createIssue({
|
||||
title: title.trim(),
|
||||
description: descEditorRef.current?.getMarkdown()?.trim() || undefined,
|
||||
status,
|
||||
@@ -141,9 +130,8 @@ export function CreateIssueModal({ onClose, data }: { onClose: () => void; data?
|
||||
assignee_type: assigneeType,
|
||||
assignee_id: assigneeId,
|
||||
due_date: dueDate || undefined,
|
||||
attachment_ids: attachmentIds.length > 0 ? attachmentIds : undefined,
|
||||
parent_issue_id: (data?.parent_issue_id as string) || undefined,
|
||||
});
|
||||
useIssueStore.getState().addIssue(issue);
|
||||
clearDraft();
|
||||
onClose();
|
||||
toast.custom((t) => (
|
||||
@@ -197,13 +185,7 @@ export function CreateIssueModal({ onClose, data }: { onClose: () => void; data?
|
||||
<div className="flex items-center gap-1.5 text-xs">
|
||||
<span className="text-muted-foreground">{workspaceName}</span>
|
||||
<ChevronRight className="size-3 text-muted-foreground/50" />
|
||||
{typeof data?.parent_issue_identifier === "string" && (
|
||||
<>
|
||||
<span className="text-muted-foreground">{data.parent_issue_identifier}</span>
|
||||
<ChevronRight className="size-3 text-muted-foreground/50" />
|
||||
</>
|
||||
)}
|
||||
<span className="font-medium">{data?.parent_issue_id ? "New sub-issue" : "New issue"}</span>
|
||||
<span className="font-medium">New issue</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Tooltip>
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { toast } from "sonner";
|
||||
import { ArrowLeft } from "lucide-react";
|
||||
import { Input } from "@/components/ui/input";
|
||||
@@ -19,7 +18,6 @@ import { useWorkspaceStore } from "@/features/workspace";
|
||||
const SLUG_REGEX = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
||||
|
||||
export function CreateWorkspaceModal({ onClose }: { onClose: () => void }) {
|
||||
const router = useRouter();
|
||||
const [name, setName] = useState("");
|
||||
const [slug, setSlug] = useState("");
|
||||
const [creating, setCreating] = useState(false);
|
||||
@@ -52,7 +50,6 @@ export function CreateWorkspaceModal({ onClose }: { onClose: () => void }) {
|
||||
slug: slug.trim(),
|
||||
});
|
||||
onClose();
|
||||
router.push("/issues");
|
||||
await switchWorkspace(ws.id);
|
||||
} catch {
|
||||
toast.error("Failed to create workspace");
|
||||
|
||||
@@ -8,8 +8,7 @@ import type { IssueStatus } from "@/shared/types";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { useAuthStore } from "@/features/auth";
|
||||
import { useWorkspaceStore, WorkspaceAvatar } from "@/features/workspace";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { agentListOptions } from "@core/workspace/queries";
|
||||
import { useIssueStore } from "@/features/issues/store";
|
||||
import { filterIssues } from "@/features/issues/utils/filter";
|
||||
import { BOARD_STATUSES } from "@/features/issues/config";
|
||||
import { ViewStoreProvider } from "@/features/issues/stores/view-store-context";
|
||||
@@ -18,18 +17,16 @@ import { BoardView } from "@/features/issues/components/board-view";
|
||||
import { ListView } from "@/features/issues/components/list-view";
|
||||
import { BatchActionToolbar } from "@/features/issues/components/batch-action-toolbar";
|
||||
import { registerViewStoreForWorkspaceSync } from "@/features/issues/stores/view-store";
|
||||
import { useWorkspaceId } from "@core/hooks";
|
||||
import { issueListOptions } from "@core/issues/queries";
|
||||
import { useUpdateIssue } from "@core/issues/mutations";
|
||||
import { api } from "@/shared/api";
|
||||
import { myIssuesViewStore } from "../stores/my-issues-view-store";
|
||||
import { MyIssuesHeader } from "./my-issues-header";
|
||||
|
||||
export function MyIssuesPage() {
|
||||
const user = useAuthStore((s) => s.user);
|
||||
const workspace = useWorkspaceStore((s) => s.workspace);
|
||||
const wsId = useWorkspaceId();
|
||||
const { data: agents = [] } = useQuery(agentListOptions(wsId));
|
||||
const { data: allIssues = [], isLoading: loading } = useQuery(issueListOptions(wsId));
|
||||
const agents = useWorkspaceStore((s) => s.agents);
|
||||
const allIssues = useIssueStore((s) => s.issues);
|
||||
const loading = useIssueStore((s) => s.loading);
|
||||
|
||||
const viewMode = useStore(myIssuesViewStore, (s) => s.viewMode);
|
||||
const statusFilters = useStore(myIssuesViewStore, (s) => s.statusFilters);
|
||||
@@ -108,7 +105,6 @@ export function MyIssuesPage() {
|
||||
return BOARD_STATUSES.filter((s) => !visibleStatuses.includes(s));
|
||||
}, [visibleStatuses]);
|
||||
|
||||
const updateIssueMutation = useUpdateIssue();
|
||||
const handleMoveIssue = useCallback(
|
||||
(issueId: string, newStatus: IssueStatus, newPosition?: number) => {
|
||||
const viewState = myIssuesViewStore.getState();
|
||||
@@ -122,12 +118,16 @@ export function MyIssuesPage() {
|
||||
};
|
||||
if (newPosition !== undefined) updates.position = newPosition;
|
||||
|
||||
updateIssueMutation.mutate(
|
||||
{ id: issueId, ...updates },
|
||||
{ onError: () => toast.error("Failed to move issue") },
|
||||
);
|
||||
useIssueStore.getState().updateIssue(issueId, updates);
|
||||
|
||||
api.updateIssue(issueId, updates).catch(() => {
|
||||
toast.error("Failed to move issue");
|
||||
api.listIssues({ limit: 200 }).then((res) => {
|
||||
useIssueStore.getState().setIssues(res.issues);
|
||||
}).catch(console.error);
|
||||
});
|
||||
},
|
||||
[updateIssueMutation],
|
||||
[],
|
||||
);
|
||||
|
||||
if (loading) {
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useEffect } from "react";
|
||||
import type { WSEventType } from "@/shared/types";
|
||||
import { useWS } from "./provider";
|
||||
|
||||
type EventHandler = (payload: unknown, actorId?: string) => void;
|
||||
type EventHandler = (payload: unknown) => void;
|
||||
|
||||
/**
|
||||
* Hook that subscribes to a WebSocket event and calls the handler.
|
||||
|
||||
@@ -22,7 +22,7 @@ const WS_URL =
|
||||
? `${window.location.protocol === "https:" ? "wss:" : "ws:"}//${window.location.host}/ws`
|
||||
: "ws://localhost:8080/ws");
|
||||
|
||||
type EventHandler = (payload: unknown, actorId?: string) => void;
|
||||
type EventHandler = (payload: unknown) => void;
|
||||
|
||||
interface WSContextValue {
|
||||
subscribe: (event: WSEventType, handler: EventHandler) => () => void;
|
||||
|
||||
@@ -1,21 +1,14 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import type { WSClient } from "@/shared/api";
|
||||
import { toast } from "sonner";
|
||||
import { useIssueStore } from "@/features/issues";
|
||||
import { useInboxStore } from "@/features/inbox";
|
||||
import { useWorkspaceStore } from "@/features/workspace";
|
||||
import { useAuthStore } from "@/features/auth";
|
||||
import { createLogger } from "@/shared/logger";
|
||||
import { issueKeys } from "@core/issues/queries";
|
||||
import {
|
||||
onIssueCreated,
|
||||
onIssueUpdated,
|
||||
onIssueDeleted,
|
||||
} from "@core/issues/ws-updaters";
|
||||
import { onInboxNew, onInboxInvalidate, onInboxIssueStatusChanged } from "@core/inbox/ws-updaters";
|
||||
import { inboxKeys } from "@core/inbox/queries";
|
||||
import { workspaceKeys } from "@core/workspace/queries";
|
||||
import { api } from "@/shared/api";
|
||||
import type {
|
||||
MemberAddedPayload,
|
||||
WorkspaceDeletedPayload,
|
||||
@@ -24,16 +17,6 @@ import type {
|
||||
IssueCreatedPayload,
|
||||
IssueDeletedPayload,
|
||||
InboxNewPayload,
|
||||
CommentCreatedPayload,
|
||||
CommentUpdatedPayload,
|
||||
CommentDeletedPayload,
|
||||
ActivityCreatedPayload,
|
||||
ReactionAddedPayload,
|
||||
ReactionRemovedPayload,
|
||||
IssueReactionAddedPayload,
|
||||
IssueReactionRemovedPayload,
|
||||
SubscriberAddedPayload,
|
||||
SubscriberRemovedPayload,
|
||||
} from "@/shared/types";
|
||||
|
||||
const logger = createLogger("realtime-sync");
|
||||
@@ -46,36 +29,37 @@ const logger = createLogger("realtime-sync");
|
||||
* - Debounce per-prefix prevents rapid-fire refetches (e.g. bulk issue updates)
|
||||
* - Precise handlers only for side effects (toast, navigation, self-check)
|
||||
*
|
||||
* Per-issue events (comments, activity, reactions, subscribers) are handled
|
||||
* both here (invalidation fallback) and by per-page useWSEvent hooks (granular
|
||||
* updates). Daemon events are handled by individual components only.
|
||||
* Per-page events (comments, activity, subscribers, daemon) are still handled
|
||||
* by individual components via useWSEvent — not here.
|
||||
*/
|
||||
export function useRealtimeSync(ws: WSClient | null) {
|
||||
const qc = useQueryClient();
|
||||
// Main sync: onAny → refreshMap with debounce
|
||||
useEffect(() => {
|
||||
if (!ws) return;
|
||||
|
||||
// Event types handled by specific handlers below — skip generic refresh
|
||||
const specificEvents = new Set([
|
||||
"issue:updated", "issue:created", "issue:deleted", "inbox:new",
|
||||
]);
|
||||
|
||||
const refreshMap: Record<string, () => void> = {
|
||||
inbox: () => {
|
||||
const wsId = useWorkspaceStore.getState().workspace?.id;
|
||||
if (wsId) onInboxInvalidate(qc, wsId);
|
||||
},
|
||||
agent: () => {
|
||||
const wsId = useWorkspaceStore.getState().workspace?.id;
|
||||
if (wsId) qc.invalidateQueries({ queryKey: workspaceKeys.agents(wsId) });
|
||||
},
|
||||
member: () => {
|
||||
const wsId = useWorkspaceStore.getState().workspace?.id;
|
||||
if (wsId) qc.invalidateQueries({ queryKey: workspaceKeys.members(wsId) });
|
||||
},
|
||||
inbox: () => void useInboxStore.getState().fetch(),
|
||||
agent: () => void useWorkspaceStore.getState().refreshAgents(),
|
||||
member: () => void useWorkspaceStore.getState().refreshMembers(),
|
||||
workspace: () => {
|
||||
qc.invalidateQueries({ queryKey: workspaceKeys.list() });
|
||||
},
|
||||
skill: () => {
|
||||
const wsId = useWorkspaceStore.getState().workspace?.id;
|
||||
if (wsId) qc.invalidateQueries({ queryKey: workspaceKeys.skills(wsId) });
|
||||
// Lightweight: only re-fetch workspace list, don't hydrate everything.
|
||||
// workspace:deleted is handled by a precise side-effect handler below.
|
||||
api.listWorkspaces().then((wsList) => {
|
||||
const current = useWorkspaceStore.getState().workspace;
|
||||
const updated = current
|
||||
? wsList.find((w) => w.id === current.id)
|
||||
: null;
|
||||
if (updated) useWorkspaceStore.getState().updateWorkspace(updated);
|
||||
}).catch((err) => {
|
||||
logger.error("workspace refresh failed", err);
|
||||
});
|
||||
},
|
||||
skill: () => void useWorkspaceStore.getState().refreshSkills(),
|
||||
};
|
||||
|
||||
const timers = new Map<string, ReturnType<typeof setTimeout>>();
|
||||
@@ -91,121 +75,42 @@ export function useRealtimeSync(ws: WSClient | null) {
|
||||
);
|
||||
};
|
||||
|
||||
// Event types handled by specific handlers below — skip generic refresh
|
||||
const specificEvents = new Set([
|
||||
"issue:updated", "issue:created", "issue:deleted", "inbox:new",
|
||||
"comment:created", "comment:updated", "comment:deleted",
|
||||
"activity:created",
|
||||
"reaction:added", "reaction:removed",
|
||||
"issue_reaction:added", "issue_reaction:removed",
|
||||
"subscriber:added", "subscriber:removed",
|
||||
]);
|
||||
|
||||
const unsubAny = ws.onAny((msg) => {
|
||||
const myUserId = useAuthStore.getState().user?.id;
|
||||
if (msg.actor_id && msg.actor_id === myUserId) {
|
||||
logger.debug("skipping self-event", msg.type);
|
||||
return;
|
||||
}
|
||||
if (specificEvents.has(msg.type)) return;
|
||||
const prefix = msg.type.split(":")[0] ?? "";
|
||||
const refresh = refreshMap[prefix];
|
||||
if (refresh) debouncedRefresh(prefix, refresh);
|
||||
});
|
||||
|
||||
// --- Specific event handlers (granular cache updates) ---
|
||||
// No self-event filtering: actor_id identifies the USER, not the TAB.
|
||||
// Filtering by actor_id would block other tabs of the same user.
|
||||
// Instead, both mutations and WS handlers use dedup checks to be idempotent.
|
||||
// --- Specific event handlers (granular updates, no full refetch) ---
|
||||
|
||||
const unsubIssueUpdated = ws.on("issue:updated", (p) => {
|
||||
const { issue } = p as IssueUpdatedPayload;
|
||||
if (!issue?.id) return;
|
||||
const wsId = useWorkspaceStore.getState().workspace?.id;
|
||||
if (wsId) {
|
||||
onIssueUpdated(qc, wsId, issue);
|
||||
if (issue.status) {
|
||||
onInboxIssueStatusChanged(qc, wsId, issue.id, issue.status);
|
||||
}
|
||||
useIssueStore.getState().updateIssue(issue.id, issue);
|
||||
if (issue.status) {
|
||||
useInboxStore.getState().updateIssueStatus(issue.id, issue.status);
|
||||
}
|
||||
});
|
||||
|
||||
const unsubIssueCreated = ws.on("issue:created", (p) => {
|
||||
const { issue } = p as IssueCreatedPayload;
|
||||
if (!issue) return;
|
||||
const wsId = useWorkspaceStore.getState().workspace?.id;
|
||||
if (wsId) onIssueCreated(qc, wsId, issue);
|
||||
if (issue) useIssueStore.getState().addIssue(issue);
|
||||
});
|
||||
|
||||
const unsubIssueDeleted = ws.on("issue:deleted", (p) => {
|
||||
const { issue_id } = p as IssueDeletedPayload;
|
||||
if (!issue_id) return;
|
||||
const wsId = useWorkspaceStore.getState().workspace?.id;
|
||||
if (wsId) onIssueDeleted(qc, wsId, issue_id);
|
||||
if (issue_id) useIssueStore.getState().removeIssue(issue_id);
|
||||
});
|
||||
|
||||
const unsubInboxNew = ws.on("inbox:new", (p) => {
|
||||
const { item } = p as InboxNewPayload;
|
||||
if (!item) return;
|
||||
const wsId = useWorkspaceStore.getState().workspace?.id;
|
||||
if (wsId) onInboxNew(qc, wsId, item);
|
||||
});
|
||||
|
||||
// --- Timeline event handlers (global fallback) ---
|
||||
// These events are also handled granularly by useIssueTimeline when
|
||||
// IssueDetail is mounted. This global handler ensures the timeline cache
|
||||
// is invalidated even when IssueDetail is unmounted, so stale data
|
||||
// isn't served on next mount (staleTime: Infinity relies on this).
|
||||
|
||||
const invalidateTimeline = (issueId: string) => {
|
||||
qc.invalidateQueries({ queryKey: issueKeys.timeline(issueId) });
|
||||
};
|
||||
|
||||
const unsubCommentCreated = ws.on("comment:created", (p) => {
|
||||
const { comment } = p as CommentCreatedPayload;
|
||||
if (comment?.issue_id) invalidateTimeline(comment.issue_id);
|
||||
});
|
||||
|
||||
const unsubCommentUpdated = ws.on("comment:updated", (p) => {
|
||||
const { comment } = p as CommentUpdatedPayload;
|
||||
if (comment?.issue_id) invalidateTimeline(comment.issue_id);
|
||||
});
|
||||
|
||||
const unsubCommentDeleted = ws.on("comment:deleted", (p) => {
|
||||
const { issue_id } = p as CommentDeletedPayload;
|
||||
if (issue_id) invalidateTimeline(issue_id);
|
||||
});
|
||||
|
||||
const unsubActivityCreated = ws.on("activity:created", (p) => {
|
||||
const { issue_id } = p as ActivityCreatedPayload;
|
||||
if (issue_id) invalidateTimeline(issue_id);
|
||||
});
|
||||
|
||||
const unsubReactionAdded = ws.on("reaction:added", (p) => {
|
||||
const { issue_id } = p as ReactionAddedPayload;
|
||||
if (issue_id) invalidateTimeline(issue_id);
|
||||
});
|
||||
|
||||
const unsubReactionRemoved = ws.on("reaction:removed", (p) => {
|
||||
const { issue_id } = p as ReactionRemovedPayload;
|
||||
if (issue_id) invalidateTimeline(issue_id);
|
||||
});
|
||||
|
||||
// --- Issue-level reactions & subscribers (global fallback) ---
|
||||
|
||||
const unsubIssueReactionAdded = ws.on("issue_reaction:added", (p) => {
|
||||
const { issue_id } = p as IssueReactionAddedPayload;
|
||||
if (issue_id) qc.invalidateQueries({ queryKey: issueKeys.reactions(issue_id) });
|
||||
});
|
||||
|
||||
const unsubIssueReactionRemoved = ws.on("issue_reaction:removed", (p) => {
|
||||
const { issue_id } = p as IssueReactionRemovedPayload;
|
||||
if (issue_id) qc.invalidateQueries({ queryKey: issueKeys.reactions(issue_id) });
|
||||
});
|
||||
|
||||
const unsubSubscriberAdded = ws.on("subscriber:added", (p) => {
|
||||
const { issue_id } = p as SubscriberAddedPayload;
|
||||
if (issue_id) qc.invalidateQueries({ queryKey: issueKeys.subscribers(issue_id) });
|
||||
});
|
||||
|
||||
const unsubSubscriberRemoved = ws.on("subscriber:removed", (p) => {
|
||||
const { issue_id } = p as SubscriberRemovedPayload;
|
||||
if (issue_id) qc.invalidateQueries({ queryKey: issueKeys.subscribers(issue_id) });
|
||||
if (item) useInboxStore.getState().addItem(item);
|
||||
});
|
||||
|
||||
// --- Side-effect handlers (toast, navigation) ---
|
||||
@@ -247,23 +152,13 @@ export function useRealtimeSync(ws: WSClient | null) {
|
||||
unsubIssueCreated();
|
||||
unsubIssueDeleted();
|
||||
unsubInboxNew();
|
||||
unsubCommentCreated();
|
||||
unsubCommentUpdated();
|
||||
unsubCommentDeleted();
|
||||
unsubActivityCreated();
|
||||
unsubReactionAdded();
|
||||
unsubReactionRemoved();
|
||||
unsubIssueReactionAdded();
|
||||
unsubIssueReactionRemoved();
|
||||
unsubSubscriberAdded();
|
||||
unsubSubscriberRemoved();
|
||||
unsubWsDeleted();
|
||||
unsubMemberRemoved();
|
||||
unsubMemberAdded();
|
||||
timers.forEach(clearTimeout);
|
||||
timers.clear();
|
||||
};
|
||||
}, [ws, qc]);
|
||||
}, [ws]);
|
||||
|
||||
// Reconnect → refetch all data to recover missed events
|
||||
useEffect(() => {
|
||||
@@ -272,20 +167,18 @@ export function useRealtimeSync(ws: WSClient | null) {
|
||||
const unsub = ws.onReconnect(async () => {
|
||||
logger.info("reconnected, refetching all data");
|
||||
try {
|
||||
const wsId = useWorkspaceStore.getState().workspace?.id;
|
||||
if (wsId) {
|
||||
qc.invalidateQueries({ queryKey: issueKeys.all(wsId) });
|
||||
qc.invalidateQueries({ queryKey: inboxKeys.all(wsId) });
|
||||
qc.invalidateQueries({ queryKey: workspaceKeys.agents(wsId) });
|
||||
qc.invalidateQueries({ queryKey: workspaceKeys.members(wsId) });
|
||||
qc.invalidateQueries({ queryKey: workspaceKeys.skills(wsId) });
|
||||
}
|
||||
qc.invalidateQueries({ queryKey: workspaceKeys.list() });
|
||||
await Promise.all([
|
||||
useIssueStore.getState().fetch(),
|
||||
useInboxStore.getState().fetch(),
|
||||
useWorkspaceStore.getState().refreshAgents(),
|
||||
useWorkspaceStore.getState().refreshMembers(),
|
||||
useWorkspaceStore.getState().refreshSkills(),
|
||||
]);
|
||||
} catch (e) {
|
||||
logger.error("reconnect refetch failed", e);
|
||||
}
|
||||
});
|
||||
|
||||
return unsub;
|
||||
}, [ws, qc]);
|
||||
}, [ws]);
|
||||
}
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useCallback } from "react";
|
||||
import { useEffect, useCallback } from "react";
|
||||
import { Server } from "lucide-react";
|
||||
import { useDefaultLayout } from "react-resizable-panels";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
ResizablePanelGroup,
|
||||
ResizablePanel,
|
||||
@@ -11,35 +10,38 @@ import {
|
||||
} from "@/components/ui/resizable";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { useAuthStore } from "@/features/auth";
|
||||
import { useWorkspaceId } from "@core/hooks";
|
||||
import { runtimeListOptions, runtimeKeys } from "@core/runtimes/queries";
|
||||
import { useWorkspaceStore } from "@/features/workspace";
|
||||
import { useWSEvent } from "@/features/realtime";
|
||||
import { useRuntimeStore } from "../store";
|
||||
import { RuntimeList } from "./runtime-list";
|
||||
import { RuntimeDetail } from "./runtime-detail";
|
||||
|
||||
export default function RuntimesPage() {
|
||||
const isLoading = useAuthStore((s) => s.isLoading);
|
||||
const wsId = useWorkspaceId();
|
||||
const qc = useQueryClient();
|
||||
const { data: runtimes = [], isLoading: fetching } = useQuery(runtimeListOptions(wsId));
|
||||
const [selectedId, setSelectedId] = useState("");
|
||||
const workspace = useWorkspaceStore((s) => s.workspace);
|
||||
const runtimes = useRuntimeStore((s) => s.runtimes);
|
||||
const selectedId = useRuntimeStore((s) => s.selectedId);
|
||||
const fetching = useRuntimeStore((s) => s.fetching);
|
||||
const fetchRuntimes = useRuntimeStore((s) => s.fetchRuntimes);
|
||||
const setSelectedId = useRuntimeStore((s) => s.setSelectedId);
|
||||
|
||||
const { defaultLayout, onLayoutChanged } = useDefaultLayout({
|
||||
id: "multica_runtimes_layout",
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (workspace) fetchRuntimes();
|
||||
}, [workspace, fetchRuntimes]);
|
||||
|
||||
// Re-fetch on daemon register/deregister events.
|
||||
// Heartbeat events are not broadcast over WS, so no handler needed.
|
||||
const handleDaemonEvent = useCallback(() => {
|
||||
qc.invalidateQueries({ queryKey: runtimeKeys.list(wsId) });
|
||||
}, [qc, wsId]);
|
||||
fetchRuntimes();
|
||||
}, [fetchRuntimes]);
|
||||
|
||||
useWSEvent("daemon:register", handleDaemonEvent);
|
||||
|
||||
// Auto-select first runtime if nothing selected
|
||||
const effectiveSelectedId = selectedId && runtimes.some((r) => r.id === selectedId)
|
||||
? selectedId
|
||||
: runtimes[0]?.id ?? "";
|
||||
const selected = runtimes.find((r) => r.id === effectiveSelectedId) ?? null;
|
||||
const selected = runtimes.find((r) => r.id === selectedId) ?? null;
|
||||
|
||||
if (isLoading || fetching) {
|
||||
return (
|
||||
@@ -93,7 +95,7 @@ export default function RuntimesPage() {
|
||||
>
|
||||
<RuntimeList
|
||||
runtimes={runtimes}
|
||||
selectedId={effectiveSelectedId}
|
||||
selectedId={selectedId}
|
||||
onSelect={setSelectedId}
|
||||
/>
|
||||
</ResizablePanel>
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
export { RuntimesPage } from "./components";
|
||||
export { useRuntimeStore } from "./store";
|
||||
|
||||
70
apps/web/features/runtimes/store.ts
Normal file
70
apps/web/features/runtimes/store.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
"use client";
|
||||
|
||||
import { create } from "zustand";
|
||||
import type { AgentRuntime } from "@/shared/types";
|
||||
import { api } from "@/shared/api";
|
||||
import { useWorkspaceStore } from "@/features/workspace";
|
||||
|
||||
interface RuntimeState {
|
||||
runtimes: AgentRuntime[];
|
||||
selectedId: string;
|
||||
fetching: boolean;
|
||||
}
|
||||
|
||||
interface RuntimeActions {
|
||||
fetchRuntimes: () => Promise<void>;
|
||||
setSelectedId: (id: string) => void;
|
||||
/** Patch a single runtime in-place (e.g. status/last_seen_at from WS event). */
|
||||
patchRuntime: (id: string, updates: Partial<AgentRuntime>) => void;
|
||||
/** Replace the full runtimes list (used on daemon:register events). */
|
||||
setRuntimes: (runtimes: AgentRuntime[]) => void;
|
||||
}
|
||||
|
||||
type RuntimeStore = RuntimeState & RuntimeActions;
|
||||
|
||||
export const useRuntimeStore = create<RuntimeStore>((set, get) => ({
|
||||
// State
|
||||
runtimes: [],
|
||||
selectedId: "",
|
||||
fetching: true,
|
||||
|
||||
// Actions
|
||||
fetchRuntimes: async () => {
|
||||
const workspace = useWorkspaceStore.getState().workspace;
|
||||
if (!workspace) return;
|
||||
try {
|
||||
const data = await api.listRuntimes({ workspace_id: workspace.id });
|
||||
const { selectedId } = get();
|
||||
set({
|
||||
runtimes: data,
|
||||
fetching: false,
|
||||
// Auto-select first if nothing selected
|
||||
selectedId: selectedId && data.some((r) => r.id === selectedId)
|
||||
? selectedId
|
||||
: data[0]?.id ?? "",
|
||||
});
|
||||
} catch {
|
||||
set({ fetching: false });
|
||||
}
|
||||
},
|
||||
|
||||
setSelectedId: (id) => set({ selectedId: id }),
|
||||
|
||||
patchRuntime: (id, updates) => {
|
||||
set((state) => ({
|
||||
runtimes: state.runtimes.map((r) =>
|
||||
r.id === id ? { ...r, ...updates } : r,
|
||||
),
|
||||
}));
|
||||
},
|
||||
|
||||
setRuntimes: (runtimes) => {
|
||||
const { selectedId } = get();
|
||||
set({
|
||||
runtimes,
|
||||
selectedId: selectedId && runtimes.some((r) => r.id === selectedId)
|
||||
? selectedId
|
||||
: runtimes[0]?.id ?? "",
|
||||
});
|
||||
},
|
||||
}));
|
||||
@@ -33,10 +33,8 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { toast } from "sonner";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { api } from "@/shared/api";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useAuthStore } from "@/features/auth";
|
||||
import { useWorkspaceId } from "@core/hooks";
|
||||
import { skillListOptions, workspaceKeys } from "@core/workspace/queries";
|
||||
import { useWorkspaceStore } from "@/features/workspace";
|
||||
|
||||
import { FileTree } from "./file-tree";
|
||||
import { FileViewer } from "./file-viewer";
|
||||
@@ -348,8 +346,6 @@ function SkillDetail({
|
||||
onUpdate: (id: string, data: UpdateSkillRequest) => Promise<void>;
|
||||
onDelete: (id: string) => Promise<void>;
|
||||
}) {
|
||||
const qc = useQueryClient();
|
||||
const wsId = useWorkspaceId();
|
||||
const [name, setName] = useState(skill.name);
|
||||
const [description, setDescription] = useState(skill.description);
|
||||
const [content, setContent] = useState(skill.content);
|
||||
@@ -374,12 +370,12 @@ function SkillDetail({
|
||||
setSelectedPath(SKILL_MD);
|
||||
setLoadingFiles(true);
|
||||
api.getSkill(skill.id).then((full) => {
|
||||
qc.invalidateQueries({ queryKey: workspaceKeys.skills(wsId) });
|
||||
useWorkspaceStore.getState().upsertSkill(full);
|
||||
setFiles((full.files ?? []).map((f) => ({ path: f.path, content: f.content })));
|
||||
}).catch((e) => {
|
||||
toast.error(e instanceof Error ? e.message : "Failed to load skill files");
|
||||
}).finally(() => setLoadingFiles(false));
|
||||
}, [skill.id, qc, wsId]);
|
||||
}, [skill.id]);
|
||||
|
||||
// Build the virtual file map
|
||||
const fileMap = useMemo(() => buildFileMap(content, files), [content, files]);
|
||||
@@ -614,9 +610,10 @@ function SkillDetail({
|
||||
|
||||
export default function SkillsPage() {
|
||||
const isLoading = useAuthStore((s) => s.isLoading);
|
||||
const qc = useQueryClient();
|
||||
const wsId = useWorkspaceId();
|
||||
const { data: skills = [] } = useQuery(skillListOptions(wsId));
|
||||
const skills = useWorkspaceStore((s) => s.skills);
|
||||
const refreshSkills = useWorkspaceStore((s) => s.refreshSkills);
|
||||
const upsertSkill = useWorkspaceStore((s) => s.upsertSkill);
|
||||
const removeSkill = useWorkspaceStore((s) => s.removeSkill);
|
||||
const [selectedId, setSelectedId] = useState<string>("");
|
||||
const [showCreate, setShowCreate] = useState(false);
|
||||
const { defaultLayout, onLayoutChanged } = useDefaultLayout({
|
||||
@@ -631,22 +628,22 @@ export default function SkillsPage() {
|
||||
|
||||
const handleCreate = async (data: CreateSkillRequest) => {
|
||||
const skill = await api.createSkill(data);
|
||||
qc.invalidateQueries({ queryKey: workspaceKeys.skills(wsId) });
|
||||
upsertSkill(skill);
|
||||
setSelectedId(skill.id);
|
||||
toast.success("Skill created");
|
||||
};
|
||||
|
||||
const handleImport = async (url: string) => {
|
||||
const skill = await api.importSkill({ url });
|
||||
qc.invalidateQueries({ queryKey: workspaceKeys.skills(wsId) });
|
||||
upsertSkill(skill);
|
||||
setSelectedId(skill.id);
|
||||
toast.success("Skill imported");
|
||||
};
|
||||
|
||||
const handleUpdate = async (id: string, data: UpdateSkillRequest) => {
|
||||
try {
|
||||
await api.updateSkill(id, data);
|
||||
qc.invalidateQueries({ queryKey: workspaceKeys.skills(wsId) });
|
||||
const updated = await api.updateSkill(id, data);
|
||||
upsertSkill(updated);
|
||||
toast.success("Skill saved");
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : "Failed to save skill");
|
||||
@@ -661,7 +658,7 @@ export default function SkillsPage() {
|
||||
const remaining = skills.filter((s) => s.id !== id);
|
||||
setSelectedId(remaining[0]?.id ?? "");
|
||||
}
|
||||
qc.invalidateQueries({ queryKey: workspaceKeys.skills(wsId) });
|
||||
removeSkill(id);
|
||||
toast.success("Skill deleted");
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : "Failed to delete skill");
|
||||
|
||||
@@ -1,13 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useWorkspaceId } from "@core/hooks";
|
||||
import { memberListOptions, agentListOptions } from "@core/workspace/queries";
|
||||
import { useWorkspaceStore } from "./store";
|
||||
|
||||
export function useActorName() {
|
||||
const wsId = useWorkspaceId();
|
||||
const { data: members = [] } = useQuery(memberListOptions(wsId));
|
||||
const { data: agents = [] } = useQuery(agentListOptions(wsId));
|
||||
const members = useWorkspaceStore((s) => s.members);
|
||||
const agents = useWorkspaceStore((s) => s.agents);
|
||||
|
||||
const getMemberName = (userId: string) => {
|
||||
const m = members.find((m) => m.user_id === userId);
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import { create } from "zustand";
|
||||
import type { Workspace } from "@/shared/types";
|
||||
import type { Workspace, MemberWithUser, Agent, Skill } from "@/shared/types";
|
||||
import { useIssueStore } from "@/features/issues";
|
||||
import { useInboxStore } from "@/features/inbox";
|
||||
import { useRuntimeStore } from "@/features/runtimes";
|
||||
import { toast } from "sonner";
|
||||
import { api } from "@/shared/api";
|
||||
import { createLogger } from "@/shared/logger";
|
||||
@@ -11,21 +14,30 @@ const logger = createLogger("workspace-store");
|
||||
interface WorkspaceState {
|
||||
workspace: Workspace | null;
|
||||
workspaces: Workspace[];
|
||||
members: MemberWithUser[];
|
||||
agents: Agent[];
|
||||
skills: Skill[];
|
||||
}
|
||||
|
||||
interface WorkspaceActions {
|
||||
hydrateWorkspace: (
|
||||
wsList: Workspace[],
|
||||
preferredWorkspaceId?: string | null,
|
||||
) => Workspace | null;
|
||||
switchWorkspace: (workspaceId: string) => void;
|
||||
) => Promise<Workspace | null>;
|
||||
switchWorkspace: (workspaceId: string) => Promise<void>;
|
||||
refreshWorkspaces: () => Promise<Workspace[]>;
|
||||
updateWorkspace: (ws: Workspace) => void;
|
||||
refreshMembers: () => Promise<void>;
|
||||
updateAgent: (id: string, updates: Partial<Agent>) => void;
|
||||
refreshAgents: () => Promise<void>;
|
||||
refreshSkills: () => Promise<void>;
|
||||
upsertSkill: (skill: Skill) => void;
|
||||
removeSkill: (id: string) => void;
|
||||
createWorkspace: (data: {
|
||||
name: string;
|
||||
slug: string;
|
||||
description?: string;
|
||||
}) => Promise<Workspace>;
|
||||
updateWorkspace: (ws: Workspace) => void;
|
||||
leaveWorkspace: (workspaceId: string) => Promise<void>;
|
||||
deleteWorkspace: (workspaceId: string) => Promise<void>;
|
||||
clearWorkspace: () => void;
|
||||
@@ -37,9 +49,12 @@ export const useWorkspaceStore = create<WorkspaceStore>((set, get) => ({
|
||||
// State
|
||||
workspace: null,
|
||||
workspaces: [],
|
||||
members: [],
|
||||
agents: [],
|
||||
skills: [],
|
||||
|
||||
// Actions
|
||||
hydrateWorkspace: (wsList, preferredWorkspaceId) => {
|
||||
hydrateWorkspace: async (wsList, preferredWorkspaceId) => {
|
||||
set({ workspaces: wsList });
|
||||
|
||||
const nextWorkspace =
|
||||
@@ -52,35 +67,56 @@ export const useWorkspaceStore = create<WorkspaceStore>((set, get) => ({
|
||||
if (!nextWorkspace) {
|
||||
api.setWorkspaceId(null);
|
||||
localStorage.removeItem("multica_workspace_id");
|
||||
set({ workspace: null });
|
||||
set({ workspace: null, members: [], agents: [], skills: [] });
|
||||
return null;
|
||||
}
|
||||
|
||||
api.setWorkspaceId(nextWorkspace.id);
|
||||
localStorage.setItem("multica_workspace_id", nextWorkspace.id);
|
||||
set({ workspace: nextWorkspace });
|
||||
logger.debug("hydrate workspace", nextWorkspace.name, nextWorkspace.id);
|
||||
|
||||
// Members, agents, skills, issues, inbox are all managed by TanStack Query.
|
||||
// They auto-fetch when components mount with the workspace ID in their query key.
|
||||
logger.debug("hydrate workspace", nextWorkspace.name, nextWorkspace.id);
|
||||
const [nextMembers, nextAgents, nextSkills] = await Promise.all([
|
||||
api.listMembers(nextWorkspace.id).catch((e) => {
|
||||
logger.error("failed to load members", e);
|
||||
toast.error("Failed to load members");
|
||||
return [] as MemberWithUser[];
|
||||
}),
|
||||
api.listAgents({ workspace_id: nextWorkspace.id, include_archived: true }).catch((e) => {
|
||||
logger.error("failed to load agents", e);
|
||||
toast.error("Failed to load agents");
|
||||
return [] as Agent[];
|
||||
}),
|
||||
api.listSkills().catch(() => [] as Skill[]),
|
||||
useIssueStore.getState().fetch().catch(() => {}),
|
||||
useInboxStore.getState().fetch().catch(() => {}),
|
||||
]);
|
||||
logger.info("hydrate complete", "members:", nextMembers.length, "agents:", nextAgents.length);
|
||||
set({ members: nextMembers, agents: nextAgents, skills: nextSkills });
|
||||
|
||||
return nextWorkspace;
|
||||
},
|
||||
|
||||
switchWorkspace: (workspaceId) => {
|
||||
switchWorkspace: async (workspaceId) => {
|
||||
logger.info("switching to", workspaceId);
|
||||
const { workspaces, hydrateWorkspace } = get();
|
||||
const ws = workspaces.find((item) => item.id === workspaceId);
|
||||
if (!ws) return;
|
||||
|
||||
// Switch identity FIRST — api client, localStorage, and the
|
||||
// workspace object in this store — so that any in-flight refetch
|
||||
// (e.g. triggered by a WS event during the async gap) already
|
||||
// targets the new workspace.
|
||||
api.setWorkspaceId(ws.id);
|
||||
localStorage.setItem("multica_workspace_id", ws.id);
|
||||
|
||||
// All data caches (issues, inbox, members, agents, skills, runtimes)
|
||||
// are managed by TanStack Query, keyed by wsId — auto-refetch on switch.
|
||||
set({ workspace: ws });
|
||||
// Clear ALL stale data across every store before hydrating.
|
||||
useIssueStore.getState().setIssues([]);
|
||||
useInboxStore.getState().setItems([]);
|
||||
useRuntimeStore.getState().setRuntimes([]);
|
||||
set({ workspace: ws, members: [], agents: [], skills: [] });
|
||||
|
||||
hydrateWorkspace(workspaces, ws.id);
|
||||
await hydrateWorkspace(workspaces, ws.id);
|
||||
},
|
||||
|
||||
refreshWorkspaces: async () => {
|
||||
@@ -88,7 +124,7 @@ export const useWorkspaceStore = create<WorkspaceStore>((set, get) => ({
|
||||
const storedWorkspaceId = localStorage.getItem("multica_workspace_id");
|
||||
try {
|
||||
const wsList = await api.listWorkspaces();
|
||||
hydrateWorkspace(wsList, workspace?.id ?? storedWorkspaceId);
|
||||
await hydrateWorkspace(wsList, workspace?.id ?? storedWorkspaceId);
|
||||
return wsList;
|
||||
} catch (e) {
|
||||
logger.error("failed to refresh workspaces", e);
|
||||
@@ -97,6 +133,77 @@ export const useWorkspaceStore = create<WorkspaceStore>((set, get) => ({
|
||||
}
|
||||
},
|
||||
|
||||
refreshMembers: async () => {
|
||||
const { workspace } = get();
|
||||
if (!workspace) return;
|
||||
try {
|
||||
const members = await api.listMembers(workspace.id);
|
||||
set({ members });
|
||||
} catch (e) {
|
||||
logger.error("failed to refresh members", e);
|
||||
toast.error("Failed to refresh members");
|
||||
}
|
||||
},
|
||||
|
||||
updateAgent: (id, updates) =>
|
||||
set((s) => ({
|
||||
agents: s.agents.map((a) => (a.id === id ? { ...a, ...updates } : a)),
|
||||
})),
|
||||
|
||||
refreshAgents: async () => {
|
||||
const { workspace } = get();
|
||||
if (!workspace) return;
|
||||
try {
|
||||
const agents = await api.listAgents({ workspace_id: workspace.id, include_archived: true });
|
||||
set({ agents });
|
||||
} catch (e) {
|
||||
logger.error("failed to refresh agents", e);
|
||||
toast.error("Failed to refresh agents");
|
||||
}
|
||||
},
|
||||
|
||||
refreshSkills: async () => {
|
||||
const { workspace, skills: existing } = get();
|
||||
if (!workspace) return;
|
||||
try {
|
||||
const fetched = await api.listSkills();
|
||||
// listSkills doesn't include files — preserve files from existing entries
|
||||
const filesById = new Map(
|
||||
existing.filter((s) => s.files?.length).map((s) => [s.id, s.files]),
|
||||
);
|
||||
const merged = fetched.map((s) => ({
|
||||
...s,
|
||||
files: s.files ?? filesById.get(s.id) ?? [],
|
||||
}));
|
||||
set({ skills: merged });
|
||||
} catch (e) {
|
||||
logger.error("failed to refresh skills", e);
|
||||
toast.error("Failed to refresh skills");
|
||||
}
|
||||
},
|
||||
|
||||
upsertSkill: (skill) => {
|
||||
set((state) => {
|
||||
const idx = state.skills.findIndex((s) => s.id === skill.id);
|
||||
if (idx >= 0) {
|
||||
const next = [...state.skills];
|
||||
next[idx] = skill;
|
||||
return { skills: next };
|
||||
}
|
||||
return { skills: [...state.skills, skill] };
|
||||
});
|
||||
},
|
||||
|
||||
removeSkill: (id) => {
|
||||
set((state) => ({ skills: state.skills.filter((s) => s.id !== id) }));
|
||||
},
|
||||
|
||||
createWorkspace: async (data) => {
|
||||
const ws = await api.createWorkspace(data);
|
||||
set((state) => ({ workspaces: [...state.workspaces, ws] }));
|
||||
return ws;
|
||||
},
|
||||
|
||||
updateWorkspace: (ws) => {
|
||||
set((state) => ({
|
||||
workspace: state.workspace?.id === ws.id ? ws : state.workspace,
|
||||
@@ -106,19 +213,13 @@ export const useWorkspaceStore = create<WorkspaceStore>((set, get) => ({
|
||||
}));
|
||||
},
|
||||
|
||||
createWorkspace: async (data) => {
|
||||
const ws = await api.createWorkspace(data);
|
||||
set((state) => ({ workspaces: [...state.workspaces, ws] }));
|
||||
return ws;
|
||||
},
|
||||
|
||||
leaveWorkspace: async (workspaceId) => {
|
||||
await api.leaveWorkspace(workspaceId);
|
||||
const { workspace, hydrateWorkspace } = get();
|
||||
const wsList = await api.listWorkspaces();
|
||||
const preferredWorkspaceId =
|
||||
workspace?.id === workspaceId ? null : (workspace?.id ?? null);
|
||||
hydrateWorkspace(wsList, preferredWorkspaceId);
|
||||
await hydrateWorkspace(wsList, preferredWorkspaceId);
|
||||
},
|
||||
|
||||
deleteWorkspace: async (workspaceId) => {
|
||||
@@ -127,11 +228,11 @@ export const useWorkspaceStore = create<WorkspaceStore>((set, get) => ({
|
||||
const wsList = await api.listWorkspaces();
|
||||
const preferredWorkspaceId =
|
||||
workspace?.id === workspaceId ? null : (workspace?.id ?? null);
|
||||
hydrateWorkspace(wsList, preferredWorkspaceId);
|
||||
await hydrateWorkspace(wsList, preferredWorkspaceId);
|
||||
},
|
||||
|
||||
clearWorkspace: () => {
|
||||
api.setWorkspaceId(null);
|
||||
set({ workspace: null, workspaces: [] });
|
||||
set({ workspace: null, workspaces: [], members: [], agents: [], skills: [] });
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -7,24 +7,7 @@ config({ path: resolve(__dirname, "../../.env") });
|
||||
|
||||
const remoteApiUrl = process.env.REMOTE_API_URL || "http://localhost:8080";
|
||||
|
||||
// Parse hostnames from CORS_ALLOWED_ORIGINS so that Next.js dev server
|
||||
// allows cross-origin HMR / webpack requests (e.g. from Tailscale IPs).
|
||||
const allowedDevOrigins = process.env.CORS_ALLOWED_ORIGINS
|
||||
? process.env.CORS_ALLOWED_ORIGINS.split(",")
|
||||
.map((origin) => {
|
||||
try {
|
||||
return new URL(origin.trim()).host;
|
||||
} catch {
|
||||
return origin.trim();
|
||||
}
|
||||
})
|
||||
.filter(Boolean)
|
||||
: undefined;
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
...(allowedDevOrigins && allowedDevOrigins.length > 0
|
||||
? { allowedDevOrigins }
|
||||
: {}),
|
||||
images: {
|
||||
formats: ["image/avif", "image/webp"],
|
||||
qualities: [75, 80, 85],
|
||||
|
||||
@@ -18,12 +18,11 @@
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@emoji-mart/data": "^1.2.1",
|
||||
"@floating-ui/dom": "^1.7.6",
|
||||
"@tanstack/react-query": "^5.96.2",
|
||||
"@tanstack/react-query-devtools": "^5.96.2",
|
||||
"@tiptap/extension-code-block-lowlight": "^3.22.1",
|
||||
"@tiptap/extension-image": "^3.22.1",
|
||||
"@tiptap/extension-link": "^3.22.1",
|
||||
"@tiptap/extension-mention": "^3.22.1",
|
||||
"@tiptap/suggestion": "^3.22.1",
|
||||
"@tiptap/extension-placeholder": "^3.22.1",
|
||||
"@tiptap/extension-table": "^3.22.1",
|
||||
"@tiptap/extension-table-cell": "^3.22.1",
|
||||
@@ -34,7 +33,6 @@
|
||||
"@tiptap/pm": "^3.22.1",
|
||||
"@tiptap/react": "^3.22.1",
|
||||
"@tiptap/starter-kit": "^3.22.1",
|
||||
"@tiptap/suggestion": "^3.22.1",
|
||||
"@types/linkify-it": "^5.0.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
|
||||
@@ -114,8 +114,7 @@ export class ApiClient {
|
||||
if (!res.ok) {
|
||||
if (res.status === 401) this.handleUnauthorized();
|
||||
const message = await this.parseErrorMessage(res, `API error: ${res.status} ${res.statusText}`);
|
||||
const logLevel = res.status === 404 ? "warn" : "error";
|
||||
this.logger[logLevel](`← ${res.status} ${path}`, { rid, duration: `${Date.now() - start}ms`, error: message });
|
||||
this.logger.error(`← ${res.status} ${path}`, { rid, duration: `${Date.now() - start}ms`, error: message });
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
@@ -144,13 +143,6 @@ export class ApiClient {
|
||||
});
|
||||
}
|
||||
|
||||
async googleLogin(code: string, redirectUri: string): Promise<LoginResponse> {
|
||||
return this.fetch("/auth/google", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ code, redirect_uri: redirectUri }),
|
||||
});
|
||||
}
|
||||
|
||||
async getMe(): Promise<User> {
|
||||
return this.fetch("/api/me");
|
||||
}
|
||||
@@ -172,7 +164,6 @@ export class ApiClient {
|
||||
if (params?.status) search.set("status", params.status);
|
||||
if (params?.priority) search.set("priority", params.priority);
|
||||
if (params?.assignee_id) search.set("assignee_id", params.assignee_id);
|
||||
if (params?.open_only) search.set("open_only", "true");
|
||||
return this.fetch(`/api/issues?${search}`);
|
||||
}
|
||||
|
||||
@@ -196,10 +187,6 @@ export class ApiClient {
|
||||
});
|
||||
}
|
||||
|
||||
async listChildIssues(id: string): Promise<{ issues: Issue[] }> {
|
||||
return this.fetch(`/api/issues/${id}/children`);
|
||||
}
|
||||
|
||||
async deleteIssue(id: string): Promise<void> {
|
||||
await this.fetch(`/api/issues/${id}`, { method: "DELETE" });
|
||||
}
|
||||
@@ -384,7 +371,7 @@ export class ApiClient {
|
||||
return this.fetch(`/api/agents/${agentId}/tasks`);
|
||||
}
|
||||
|
||||
async getActiveTasksForIssue(issueId: string): Promise<{ tasks: AgentTask[] }> {
|
||||
async getActiveTaskForIssue(issueId: string): Promise<{ task: AgentTask | null }> {
|
||||
return this.fetch(`/api/issues/${issueId}/active-task`);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { WSMessage, WSEventType } from "@/shared/types";
|
||||
import { type Logger, noopLogger } from "@/shared/logger";
|
||||
|
||||
type EventHandler = (payload: unknown, actorId?: string) => void;
|
||||
type EventHandler = (payload: unknown) => void;
|
||||
|
||||
export class WSClient {
|
||||
private ws: WebSocket | null = null;
|
||||
@@ -53,7 +53,7 @@ export class WSClient {
|
||||
const eventHandlers = this.handlers.get(msg.type);
|
||||
if (eventHandlers) {
|
||||
for (const handler of eventHandlers) {
|
||||
handler(msg.payload, msg.actor_id);
|
||||
handler(msg.payload);
|
||||
}
|
||||
}
|
||||
for (const handler of this.anyHandlers) {
|
||||
|
||||
@@ -4,6 +4,8 @@ export type AgentRuntimeMode = "local" | "cloud";
|
||||
|
||||
export type AgentVisibility = "workspace" | "private";
|
||||
|
||||
export type AgentTriggerType = "on_assign" | "on_comment" | "scheduled";
|
||||
|
||||
export interface RuntimeDevice {
|
||||
id: string;
|
||||
workspace_id: string;
|
||||
@@ -21,6 +23,22 @@ export interface RuntimeDevice {
|
||||
|
||||
export type AgentRuntime = RuntimeDevice;
|
||||
|
||||
export interface AgentTool {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
auth_type: "oauth" | "api_key" | "none";
|
||||
connected: boolean;
|
||||
config: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface AgentTrigger {
|
||||
id: string;
|
||||
type: AgentTriggerType;
|
||||
enabled: boolean;
|
||||
config: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface AgentTask {
|
||||
id: string;
|
||||
agent_id: string;
|
||||
@@ -51,6 +69,8 @@ export interface Agent {
|
||||
max_concurrent_tasks: number;
|
||||
owner_id: string | null;
|
||||
skills: Skill[];
|
||||
tools: AgentTool[];
|
||||
triggers: AgentTrigger[];
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
archived_at: string | null;
|
||||
@@ -66,6 +86,8 @@ export interface CreateAgentRequest {
|
||||
runtime_config?: Record<string, unknown>;
|
||||
visibility?: AgentVisibility;
|
||||
max_concurrent_tasks?: number;
|
||||
tools?: AgentTool[];
|
||||
triggers?: AgentTrigger[];
|
||||
}
|
||||
|
||||
export interface UpdateAgentRequest {
|
||||
@@ -78,6 +100,8 @@ export interface UpdateAgentRequest {
|
||||
visibility?: AgentVisibility;
|
||||
status?: AgentStatus;
|
||||
max_concurrent_tasks?: number;
|
||||
tools?: AgentTool[];
|
||||
triggers?: AgentTrigger[];
|
||||
}
|
||||
|
||||
// Skills
|
||||
|
||||
@@ -11,7 +11,6 @@ export interface CreateIssueRequest {
|
||||
assignee_id?: string;
|
||||
parent_issue_id?: string;
|
||||
due_date?: string;
|
||||
attachment_ids?: string[];
|
||||
}
|
||||
|
||||
export interface UpdateIssueRequest {
|
||||
@@ -23,7 +22,6 @@ export interface UpdateIssueRequest {
|
||||
assignee_id?: string | null;
|
||||
position?: number;
|
||||
due_date?: string | null;
|
||||
parent_issue_id?: string | null;
|
||||
}
|
||||
|
||||
export interface ListIssuesParams {
|
||||
@@ -33,14 +31,11 @@ export interface ListIssuesParams {
|
||||
status?: IssueStatus;
|
||||
priority?: IssuePriority;
|
||||
assignee_id?: string;
|
||||
open_only?: boolean;
|
||||
}
|
||||
|
||||
export interface ListIssuesResponse {
|
||||
issues: Issue[];
|
||||
total: number;
|
||||
/** True total of done issues in the workspace (for load-more pagination). Not returned by backend API — set by the frontend query function. */
|
||||
doneTotal?: number;
|
||||
}
|
||||
|
||||
export interface UpdateMeRequest {
|
||||
|
||||
@@ -4,6 +4,9 @@ export type {
|
||||
AgentStatus,
|
||||
AgentRuntimeMode,
|
||||
AgentVisibility,
|
||||
AgentTriggerType,
|
||||
AgentTool,
|
||||
AgentTrigger,
|
||||
AgentTask,
|
||||
AgentRuntime,
|
||||
RuntimeDevice,
|
||||
|
||||
@@ -58,6 +58,8 @@ export const mockAgents: Agent[] = [
|
||||
max_concurrent_tasks: 3,
|
||||
owner_id: null,
|
||||
skills: [],
|
||||
tools: [],
|
||||
triggers: [],
|
||||
created_at: "2026-01-01T00:00:00Z",
|
||||
updated_at: "2026-01-01T00:00:00Z",
|
||||
archived_at: null,
|
||||
@@ -83,6 +85,8 @@ export const mockAuthValue: Record<string, any> = {
|
||||
leaveWorkspace: vi.fn(),
|
||||
deleteWorkspace: vi.fn(),
|
||||
refreshWorkspaces: vi.fn(),
|
||||
refreshMembers: vi.fn(),
|
||||
refreshAgents: vi.fn(),
|
||||
getMemberName: (userId: string) => {
|
||||
const m = mockMembers.find((m) => m.user_id === userId);
|
||||
return m?.name ?? "Unknown";
|
||||
|
||||
@@ -28,9 +28,6 @@
|
||||
"paths": {
|
||||
"@/*": [
|
||||
"./*"
|
||||
],
|
||||
"@core/*": [
|
||||
"./core/*"
|
||||
]
|
||||
},
|
||||
"noEmit": true,
|
||||
|
||||
@@ -13,7 +13,6 @@ export default defineConfig({
|
||||
resolve: {
|
||||
alias: {
|
||||
"@": path.resolve(__dirname, "."),
|
||||
"@core": path.resolve(__dirname, "core"),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,511 +0,0 @@
|
||||
# Board DnD Rewrite — dnd-kit Multi-Container Sortable
|
||||
|
||||
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
||||
|
||||
**Goal:** Rewrite the Kanban board drag-and-drop to use dnd-kit's multi-container sortable pattern correctly — onDragOver for live cross-column movement, local state during drag, insertion indicators, and smooth animations.
|
||||
|
||||
**Architecture:** Replace the current "TQ-cache-driven + pendingMove patch" with a "local-state-driven during drag, TQ sync on drop" model. During drag, a local `columns` state (Record<IssueStatus, string[]>) controls which IDs each SortableContext sees. onDragOver moves IDs between columns in real-time. onDragEnd computes final position and fires the mutation. Between drags, local state follows TQ data via useEffect.
|
||||
|
||||
**Tech Stack:** @dnd-kit/core ^6.3.1, @dnd-kit/sortable ^10.0.0, @dnd-kit/utilities ^3.2.2, TanStack Query, React useState
|
||||
|
||||
---
|
||||
|
||||
## Current State (files to modify)
|
||||
|
||||
| File | Current Role | Change |
|
||||
|------|-------------|--------|
|
||||
| `features/issues/components/board-view.tsx` | DndContext + onDragEnd only + pendingMove | **Rewrite**: local columns state, onDragOver, onDragEnd, improved DragOverlay |
|
||||
| `features/issues/components/board-column.tsx` | Receives Issue[], sorts internally, useDroppable | **Rewrite**: receives sorted Issue[] from parent, no internal sorting, insertion indicator |
|
||||
| `features/issues/components/board-card.tsx` | useSortable with defaults | **Modify**: custom animateLayoutChanges |
|
||||
| `features/issues/components/issues-page.tsx` | handleMoveIssue callback | **Minor**: adjust callback signature |
|
||||
|
||||
Files NOT changed: `mutations.ts`, `ws-updaters.ts`, `use-realtime-sync.ts`, `view-store.ts`, `sort.ts`
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Rewrite board-view.tsx — Local State + onDragOver + onDragEnd
|
||||
|
||||
**Files:**
|
||||
- Rewrite: `apps/web/features/issues/components/board-view.tsx`
|
||||
|
||||
This is the core task. The entire DnD orchestration logic changes.
|
||||
|
||||
### Data Model
|
||||
|
||||
```typescript
|
||||
// Local state: maps status → ordered array of issue IDs
|
||||
// This is the ONLY source of truth for card positions during drag
|
||||
type Columns = Record<IssueStatus, string[]>;
|
||||
```
|
||||
|
||||
### Step 1: Replace pendingMove with local columns state
|
||||
|
||||
Remove `pendingMove` + `displayIssues` + the clearing useEffect. Replace with:
|
||||
|
||||
```typescript
|
||||
// Build columns from TQ issues + view sort settings
|
||||
function buildColumns(
|
||||
issues: Issue[],
|
||||
visibleStatuses: IssueStatus[],
|
||||
sortBy: SortField,
|
||||
sortDirection: SortDirection,
|
||||
): Columns {
|
||||
const cols: Columns = {} as Columns;
|
||||
for (const status of visibleStatuses) {
|
||||
const sorted = sortIssues(
|
||||
issues.filter((i) => i.status === status),
|
||||
sortBy,
|
||||
sortDirection,
|
||||
);
|
||||
cols[status] = sorted.map((i) => i.id);
|
||||
}
|
||||
return cols;
|
||||
}
|
||||
```
|
||||
|
||||
In the component:
|
||||
|
||||
```typescript
|
||||
const sortBy = useViewStore((s) => s.sortBy);
|
||||
const sortDirection = useViewStore((s) => s.sortDirection);
|
||||
|
||||
// Local columns state — follows TQ between drags, local during drag
|
||||
const [columns, setColumns] = useState<Columns>(() =>
|
||||
buildColumns(issues, visibleStatuses, sortBy, sortDirection)
|
||||
);
|
||||
const isDragging = useRef(false);
|
||||
|
||||
// Sync from TQ when NOT dragging
|
||||
useEffect(() => {
|
||||
if (!isDragging.current) {
|
||||
setColumns(buildColumns(issues, visibleStatuses, sortBy, sortDirection));
|
||||
}
|
||||
}, [issues, visibleStatuses, sortBy, sortDirection]);
|
||||
```
|
||||
|
||||
`issueMap` for O(1) lookup (needed by BoardColumn to get Issue objects from IDs):
|
||||
|
||||
```typescript
|
||||
const issueMap = useMemo(() => {
|
||||
const map = new Map<string, Issue>();
|
||||
for (const issue of issues) map.set(issue.id, issue);
|
||||
return map;
|
||||
}, [issues]);
|
||||
```
|
||||
|
||||
### Step 2: Implement findColumn helper
|
||||
|
||||
```typescript
|
||||
/** Find which column (status) contains a given ID (issue or column). */
|
||||
function findColumn(columns: Columns, id: string, visibleStatuses: IssueStatus[]): IssueStatus | null {
|
||||
// Is it a column ID itself?
|
||||
if (visibleStatuses.includes(id as IssueStatus)) return id as IssueStatus;
|
||||
// Search columns for the item
|
||||
for (const [status, ids] of Object.entries(columns)) {
|
||||
if (ids.includes(id)) return status as IssueStatus;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
```
|
||||
|
||||
### Step 3: Implement onDragStart
|
||||
|
||||
```typescript
|
||||
const handleDragStart = useCallback((event: DragStartEvent) => {
|
||||
isDragging.current = true;
|
||||
const issue = issueMap.get(event.active.id as string) ?? null;
|
||||
setActiveIssue(issue);
|
||||
}, [issueMap]);
|
||||
```
|
||||
|
||||
### Step 4: Implement onDragOver — the key missing piece
|
||||
|
||||
This fires continuously during drag. When the pointer crosses into a different column or hovers over a different card, we move the dragged ID in local state. This makes SortableContext aware of the new item → cards shift to make room.
|
||||
|
||||
```typescript
|
||||
const handleDragOver = useCallback((event: DragOverEvent) => {
|
||||
const { active, over } = event;
|
||||
if (!over) return;
|
||||
|
||||
const activeId = active.id as string;
|
||||
const overId = over.id as string;
|
||||
|
||||
const activeCol = findColumn(columns, activeId, visibleStatuses);
|
||||
const overCol = findColumn(columns, overId, visibleStatuses);
|
||||
if (!activeCol || !overCol || activeCol === overCol) return;
|
||||
|
||||
// Cross-column move: remove from old column, insert into new column
|
||||
setColumns((prev) => {
|
||||
const oldIds = prev[activeCol]!.filter((id) => id !== activeId);
|
||||
const newIds = [...prev[overCol]!];
|
||||
|
||||
// Insert position: if over a card, insert at that index; if over column, append
|
||||
const overIndex = newIds.indexOf(overId);
|
||||
const insertIndex = overIndex >= 0 ? overIndex : newIds.length;
|
||||
newIds.splice(insertIndex, 0, activeId);
|
||||
|
||||
return { ...prev, [activeCol]: oldIds, [overCol]: newIds };
|
||||
});
|
||||
}, [columns, visibleStatuses]);
|
||||
```
|
||||
|
||||
### Step 5: Implement onDragEnd — persist to server
|
||||
|
||||
```typescript
|
||||
const handleDragEnd = useCallback((event: DragEndEvent) => {
|
||||
const { active, over } = event;
|
||||
isDragging.current = false;
|
||||
setActiveIssue(null);
|
||||
|
||||
if (!over) {
|
||||
// Cancelled — reset to TQ state
|
||||
setColumns(buildColumns(issues, visibleStatuses, sortBy, sortDirection));
|
||||
return;
|
||||
}
|
||||
|
||||
const activeId = active.id as string;
|
||||
const overId = over.id as string;
|
||||
|
||||
const activeCol = findColumn(columns, activeId, visibleStatuses);
|
||||
const overCol = findColumn(columns, overId, visibleStatuses);
|
||||
if (!activeCol || !overCol) return;
|
||||
|
||||
// Same column reorder
|
||||
if (activeCol === overCol) {
|
||||
const ids = columns[activeCol]!;
|
||||
const oldIndex = ids.indexOf(activeId);
|
||||
const newIndex = ids.indexOf(overId);
|
||||
if (oldIndex !== newIndex) {
|
||||
const reordered = arrayMove(ids, oldIndex, newIndex);
|
||||
setColumns((prev) => ({ ...prev, [activeCol]: reordered }));
|
||||
}
|
||||
}
|
||||
|
||||
// Compute final position from the local column order
|
||||
const finalCol = findColumn(columns, activeId, visibleStatuses);
|
||||
if (!finalCol) return;
|
||||
|
||||
// After potential same-col reorder, re-read columns
|
||||
// (for same-col we just did setColumns above, but it's async;
|
||||
// however we can compute from the intended final order)
|
||||
let finalIds: string[];
|
||||
if (activeCol === overCol) {
|
||||
const ids = columns[activeCol]!;
|
||||
const oldIndex = ids.indexOf(activeId);
|
||||
const newIndex = ids.indexOf(overId);
|
||||
finalIds = oldIndex !== newIndex ? arrayMove(ids, oldIndex, newIndex) : ids;
|
||||
} else {
|
||||
finalIds = columns[finalCol]!;
|
||||
}
|
||||
|
||||
const newPosition = computePosition(finalIds, activeId, issues);
|
||||
const currentIssue = issueMap.get(activeId);
|
||||
|
||||
// Skip if nothing changed
|
||||
if (currentIssue && currentIssue.status === finalCol && currentIssue.position === newPosition) return;
|
||||
|
||||
onMoveIssue(activeId, finalCol, newPosition);
|
||||
}, [columns, issues, visibleStatuses, sortBy, sortDirection, issueMap, onMoveIssue]);
|
||||
```
|
||||
|
||||
### Step 6: Update computePosition to work with ID arrays
|
||||
|
||||
The current `computePosition` takes `Issue[]` and a target index. Rewrite to take `string[]` (IDs) + the active ID + the issue map:
|
||||
|
||||
```typescript
|
||||
/** Compute a float position for `activeId` based on its neighbors in `ids`. */
|
||||
function computePosition(ids: string[], activeId: string, allIssues: Issue[]): number {
|
||||
const idx = ids.indexOf(activeId);
|
||||
if (idx === -1) return 0;
|
||||
|
||||
const getPos = (id: string) => allIssues.find((i) => i.id === id)?.position ?? 0;
|
||||
|
||||
if (ids.length === 1) return 0;
|
||||
if (idx === 0) return getPos(ids[1]!) - 1;
|
||||
if (idx === ids.length - 1) return getPos(ids[idx - 1]!) + 1;
|
||||
return (getPos(ids[idx - 1]!) + getPos(ids[idx + 1]!)) / 2;
|
||||
}
|
||||
```
|
||||
|
||||
### Step 7: Update DragOverlay styling
|
||||
|
||||
```typescript
|
||||
<DragOverlay dropAnimation={null}>
|
||||
{activeIssue ? (
|
||||
<div className="w-[280px] rotate-2 scale-105 cursor-grabbing opacity-90 shadow-lg shadow-black/10">
|
||||
<BoardCardContent issue={activeIssue} />
|
||||
</div>
|
||||
) : null}
|
||||
</DragOverlay>
|
||||
```
|
||||
|
||||
Key change: `dropAnimation={null}` prevents the overlay from animating back to origin on drop — the card is already in the right position via local state.
|
||||
|
||||
### Step 8: Wire it all together
|
||||
|
||||
Pass `columns` + `issueMap` to `BoardColumn` instead of `issues`:
|
||||
|
||||
```tsx
|
||||
{visibleStatuses.map((status) => (
|
||||
<BoardColumn
|
||||
key={status}
|
||||
status={status}
|
||||
issueIds={columns[status] ?? []}
|
||||
issueMap={issueMap}
|
||||
/>
|
||||
))}
|
||||
```
|
||||
|
||||
### Step 9: Run typecheck
|
||||
|
||||
Run: `pnpm typecheck`
|
||||
Expected: May have errors in board-column.tsx (prop changes) — that's Task 2.
|
||||
|
||||
### Step 10: Commit
|
||||
|
||||
```bash
|
||||
git add apps/web/features/issues/components/board-view.tsx
|
||||
git commit -m "refactor(board): rewrite DnD with local state + onDragOver for live cross-column sorting"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: Rewrite board-column.tsx — Receive IDs + issueMap, Add Insertion Indicator
|
||||
|
||||
**Files:**
|
||||
- Rewrite: `apps/web/features/issues/components/board-column.tsx`
|
||||
|
||||
### Step 1: Change props from `issues: Issue[]` to `issueIds: string[]` + `issueMap: Map<string, Issue>`
|
||||
|
||||
The column no longer does its own sorting — the parent provides IDs in the correct order. The column just resolves IDs to Issue objects and renders them.
|
||||
|
||||
```typescript
|
||||
export function BoardColumn({
|
||||
status,
|
||||
issueIds,
|
||||
issueMap,
|
||||
}: {
|
||||
status: IssueStatus;
|
||||
issueIds: string[];
|
||||
issueMap: Map<string, Issue>;
|
||||
}) {
|
||||
const cfg = STATUS_CONFIG[status];
|
||||
const { setNodeRef, isOver } = useDroppable({ id: status });
|
||||
const viewStoreApi = useViewStoreApi();
|
||||
|
||||
// Resolve IDs to Issue objects (IDs are already sorted by parent)
|
||||
const resolvedIssues = useMemo(
|
||||
() => issueIds.flatMap((id) => {
|
||||
const issue = issueMap.get(id);
|
||||
return issue ? [issue] : [];
|
||||
}),
|
||||
[issueIds, issueMap],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={`flex w-[280px] shrink-0 flex-col rounded-xl ${cfg.columnBg} p-2`}>
|
||||
<div className="mb-2 flex items-center justify-between px-1.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`inline-flex items-center gap-1.5 rounded px-2 py-0.5 text-xs font-semibold ${cfg.badgeBg} ${cfg.badgeText}`}>
|
||||
<StatusIcon status={status} className="h-3 w-3" inheritColor />
|
||||
{cfg.label}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{issueIds.length}
|
||||
</span>
|
||||
</div>
|
||||
{/* Right: add + menu — keep as-is */}
|
||||
<div className="flex items-center gap-1">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
render={
|
||||
<Button variant="ghost" size="icon-sm" className="rounded-full text-muted-foreground">
|
||||
<MoreHorizontal className="size-3.5" />
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={() => viewStoreApi.getState().hideStatus(status)}>
|
||||
<EyeOff className="size-3.5" />
|
||||
Hide column
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className="rounded-full text-muted-foreground"
|
||||
onClick={() => useModalStore.getState().open("create-issue", { status })}
|
||||
>
|
||||
<Plus className="size-3.5" />
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<TooltipContent>Add issue</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
className={`min-h-[200px] flex-1 space-y-2 overflow-y-auto rounded-lg p-1 transition-colors ${
|
||||
isOver ? "bg-accent/60" : ""
|
||||
}`}
|
||||
>
|
||||
<SortableContext items={issueIds} strategy={verticalListSortingStrategy}>
|
||||
{resolvedIssues.map((issue) => (
|
||||
<DraggableBoardCard key={issue.id} issue={issue} />
|
||||
))}
|
||||
</SortableContext>
|
||||
{issueIds.length === 0 && (
|
||||
<p className="py-8 text-center text-xs text-muted-foreground">
|
||||
No issues
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
Key changes:
|
||||
- No more `useViewStore` for sort — parent handles sorting
|
||||
- No more internal `sortIssues` call
|
||||
- Uses `issueIds` for SortableContext (already in correct order)
|
||||
- Count shows `issueIds.length` instead of `issues.length`
|
||||
|
||||
### Step 2: Run typecheck
|
||||
|
||||
Run: `pnpm typecheck`
|
||||
Expected: PASS (or errors in issues-page.tsx — Task 4)
|
||||
|
||||
### Step 3: Commit
|
||||
|
||||
```bash
|
||||
git add apps/web/features/issues/components/board-column.tsx
|
||||
git commit -m "refactor(board): BoardColumn receives sorted IDs from parent, no internal sorting"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: Modify board-card.tsx — Custom animateLayoutChanges
|
||||
|
||||
**Files:**
|
||||
- Modify: `apps/web/features/issues/components/board-card.tsx`
|
||||
|
||||
### Step 1: Add custom animateLayoutChanges
|
||||
|
||||
When a card is dragged across containers, dnd-kit triggers a layout animation on the "entering" card. The default `defaultAnimateLayoutChanges` animates this, causing a jarring jump. We disable animation for the frame when `wasDragging` is true (the card just landed in a new container).
|
||||
|
||||
```typescript
|
||||
import { useSortable, defaultAnimateLayoutChanges } from "@dnd-kit/sortable";
|
||||
import type { AnimateLayoutChanges } from "@dnd-kit/sortable";
|
||||
|
||||
const animateLayoutChanges: AnimateLayoutChanges = (args) => {
|
||||
const { isSorting, wasDragging } = args;
|
||||
if (isSorting || wasDragging) return false;
|
||||
return defaultAnimateLayoutChanges(args);
|
||||
};
|
||||
```
|
||||
|
||||
Update useSortable call:
|
||||
|
||||
```typescript
|
||||
const {
|
||||
attributes,
|
||||
listeners,
|
||||
setNodeRef,
|
||||
transform,
|
||||
transition,
|
||||
isDragging,
|
||||
} = useSortable({
|
||||
id: issue.id,
|
||||
data: { status: issue.status },
|
||||
animateLayoutChanges,
|
||||
});
|
||||
```
|
||||
|
||||
### Step 2: Run typecheck
|
||||
|
||||
Run: `pnpm typecheck`
|
||||
Expected: PASS
|
||||
|
||||
### Step 3: Commit
|
||||
|
||||
```bash
|
||||
git add apps/web/features/issues/components/board-card.tsx
|
||||
git commit -m "refactor(board): custom animateLayoutChanges to prevent jarring cross-column animation"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: Adjust issues-page.tsx — Minor Callback Cleanup
|
||||
|
||||
**Files:**
|
||||
- Modify: `apps/web/features/issues/components/issues-page.tsx`
|
||||
|
||||
### Step 1: Update handleMoveIssue
|
||||
|
||||
The callback shape stays the same (`issueId, newStatus, newPosition`), but the auto-switch-to-manual-sort logic should move into board-view or stay here. Keep it here for now since it's a view-level concern.
|
||||
|
||||
No functional change needed — the `onMoveIssue` prop signature is unchanged. Just verify that `BoardView`'s new props are correct:
|
||||
|
||||
```tsx
|
||||
<BoardView
|
||||
issues={issues}
|
||||
allIssues={scopedIssues}
|
||||
visibleStatuses={visibleStatuses}
|
||||
hiddenStatuses={hiddenStatuses}
|
||||
onMoveIssue={handleMoveIssue}
|
||||
/>
|
||||
```
|
||||
|
||||
`BoardView` still receives `issues` (filtered+scoped from TQ) and `onMoveIssue`. The internal state management changes are encapsulated.
|
||||
|
||||
### Step 2: Run full typecheck + test
|
||||
|
||||
Run: `pnpm typecheck && pnpm test`
|
||||
Expected: PASS
|
||||
|
||||
### Step 3: Commit
|
||||
|
||||
```bash
|
||||
git add apps/web/features/issues/components/issues-page.tsx
|
||||
git commit -m "refactor(board): verify issues-page props match new BoardView interface"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 5: Manual QA Checklist
|
||||
|
||||
After all code changes, verify these scenarios in the browser:
|
||||
|
||||
1. **Same-column reorder**: Drag a card up/down within one column → cards shift to make room during drag → drop → position persists after refresh
|
||||
2. **Cross-column move**: Drag card from Todo to In Progress → card appears in target column DURING drag → target column cards shift → drop → status + position persist
|
||||
3. **Drop on empty column**: Drag card to an empty column → card lands there
|
||||
4. **Cancel drag**: Start dragging, press Escape → card returns to original position, no mutation fired
|
||||
5. **Rapid sequential drags**: Drag card A, drop, immediately drag card B → no flicker or stale state
|
||||
6. **WebSocket update during drag**: Have another user change an issue → board updates correctly after drag ends (not during)
|
||||
7. **Sort mode switch**: Drag should auto-switch to "Manual" sort → verify after drag, sort dropdown shows "Manual"
|
||||
8. **DragOverlay**: Dragged card should have visible shadow, slight rotation, slight scale up
|
||||
9. **Hidden columns panel**: Still shows correct counts, "Show column" still works
|
||||
|
||||
---
|
||||
|
||||
## Summary of Architecture Change
|
||||
|
||||
```
|
||||
BEFORE (broken):
|
||||
TQ cache → issues prop → displayIssues (with pendingMove patch) → BoardColumn sorts internally
|
||||
onDragEnd → pendingMove + mutate → TQ updates → useEffect clears pendingMove
|
||||
Problem: dual optimistic update, fire-and-forget cancelQueries race, no onDragOver
|
||||
|
||||
AFTER (correct):
|
||||
TQ cache → issues prop → buildColumns() → local columns state (when not dragging)
|
||||
onDragStart → isDragging=true, freeze local state
|
||||
onDragOver → move IDs between columns in local state → SortableContext sees new items → cards shift
|
||||
onDragEnd → compute position from local order → mutate → isDragging=false → TQ catches up → local follows
|
||||
Problem: none — single source of truth during drag (local), single source of truth between drags (TQ)
|
||||
```
|
||||
38
pnpm-lock.yaml
generated
38
pnpm-lock.yaml
generated
@@ -75,12 +75,6 @@ importers:
|
||||
'@floating-ui/dom':
|
||||
specifier: ^1.7.6
|
||||
version: 1.7.6
|
||||
'@tanstack/react-query':
|
||||
specifier: ^5.96.2
|
||||
version: 5.96.2(react@19.2.3)
|
||||
'@tanstack/react-query-devtools':
|
||||
specifier: ^5.96.2
|
||||
version: 5.96.2(@tanstack/react-query@5.96.2(react@19.2.3))(react@19.2.3)
|
||||
'@tiptap/extension-code-block-lowlight':
|
||||
specifier: ^3.22.1
|
||||
version: 3.22.1(@tiptap/core@3.22.1(@tiptap/pm@3.22.1))(@tiptap/extension-code-block@3.22.1(@tiptap/core@3.22.1(@tiptap/pm@3.22.1))(@tiptap/pm@3.22.1))(@tiptap/pm@3.22.1)(highlight.js@11.11.1)(lowlight@3.3.0)
|
||||
@@ -1294,23 +1288,6 @@ packages:
|
||||
'@tailwindcss/postcss@4.2.2':
|
||||
resolution: {integrity: sha512-n4goKQbW8RVXIbNKRB/45LzyUqN451deQK0nzIeauVEqjlI49slUlgKYJM2QyUzap/PcpnS7kzSUmPb1sCRvYQ==}
|
||||
|
||||
'@tanstack/query-core@5.96.2':
|
||||
resolution: {integrity: sha512-hzI6cTVh4KNRk8UtoIBS7Lv9g6BnJPXvBKsvYH1aGWvv0347jT3BnSvztOE+kD76XGvZnRC/t6qdW1CaIfwCeA==}
|
||||
|
||||
'@tanstack/query-devtools@5.96.2':
|
||||
resolution: {integrity: sha512-vBTB1Qhbm3nHSbEUtQwks/EdcAtFfEapr1WyBW4w2ExYKuXVi3jIxUIHf5MlSltiHuL7zNyUuanqT/7sI2sb6g==}
|
||||
|
||||
'@tanstack/react-query-devtools@5.96.2':
|
||||
resolution: {integrity: sha512-nTFKLGuTOFvmFRvcyZ3ArWC/DnMNPoBh6h/2yD6rsf7TCTJCQt+oUWOp2uKPTIuEPtF/vN9Kw5tl5mD1Kbposw==}
|
||||
peerDependencies:
|
||||
'@tanstack/react-query': ^5.96.2
|
||||
react: ^18 || ^19
|
||||
|
||||
'@tanstack/react-query@5.96.2':
|
||||
resolution: {integrity: sha512-sYyzzJT4G0g02azzJ8o55VFFV31XvFpdUpG+unxS0vSaYsJnSPKGoI6WdPwUucJL1wpgGfwfmntNX/Ub1uOViA==}
|
||||
peerDependencies:
|
||||
react: ^18 || ^19
|
||||
|
||||
'@testing-library/dom@10.4.1':
|
||||
resolution: {integrity: sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==}
|
||||
engines: {node: '>=18'}
|
||||
@@ -4940,21 +4917,6 @@ snapshots:
|
||||
postcss: 8.5.8
|
||||
tailwindcss: 4.2.2
|
||||
|
||||
'@tanstack/query-core@5.96.2': {}
|
||||
|
||||
'@tanstack/query-devtools@5.96.2': {}
|
||||
|
||||
'@tanstack/react-query-devtools@5.96.2(@tanstack/react-query@5.96.2(react@19.2.3))(react@19.2.3)':
|
||||
dependencies:
|
||||
'@tanstack/query-devtools': 5.96.2
|
||||
'@tanstack/react-query': 5.96.2(react@19.2.3)
|
||||
react: 19.2.3
|
||||
|
||||
'@tanstack/react-query@5.96.2(react@19.2.3)':
|
||||
dependencies:
|
||||
'@tanstack/query-core': 5.96.2
|
||||
react: 19.2.3
|
||||
|
||||
'@testing-library/dom@10.4.1':
|
||||
dependencies:
|
||||
'@babel/code-frame': 7.29.0
|
||||
|
||||
@@ -100,8 +100,6 @@ pnpm test || { EXIT_CODE=1; exit 1; }
|
||||
# --------------------------------------------------------------------------
|
||||
echo ""
|
||||
echo "==> [3/5] Go tests..."
|
||||
echo "==> Running database migrations..."
|
||||
(cd server && go run ./cmd/migrate up) || { EXIT_CODE=1; exit 1; }
|
||||
(cd server && go test ./...) || { EXIT_CODE=1; exit 1; }
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
@@ -17,88 +17,26 @@ set +a
|
||||
POSTGRES_DB="${POSTGRES_DB:-multica}"
|
||||
POSTGRES_USER="${POSTGRES_USER:-multica}"
|
||||
POSTGRES_PASSWORD="${POSTGRES_PASSWORD:-multica}"
|
||||
DATABASE_URL="${DATABASE_URL:-}"
|
||||
|
||||
export PGPASSWORD="$POSTGRES_PASSWORD"
|
||||
|
||||
db_host=""
|
||||
db_port="${POSTGRES_PORT:-5432}"
|
||||
db_name="$POSTGRES_DB"
|
||||
echo "==> Ensuring shared PostgreSQL container is running on localhost:5432..."
|
||||
docker compose up -d postgres
|
||||
|
||||
parse_database_url() {
|
||||
local rest authority hostport path port_part
|
||||
echo "==> Waiting for PostgreSQL to be ready..."
|
||||
until docker compose exec -T postgres pg_isready -U "$POSTGRES_USER" -d postgres > /dev/null 2>&1; do
|
||||
sleep 1
|
||||
done
|
||||
|
||||
rest="${DATABASE_URL#*://}"
|
||||
rest="${rest%%\?*}"
|
||||
authority="${rest%%/*}"
|
||||
path="${rest#*/}"
|
||||
echo "==> Ensuring database '$POSTGRES_DB' exists..."
|
||||
db_exists="$(docker compose exec -T postgres \
|
||||
psql -U "$POSTGRES_USER" -d postgres -Atqc "SELECT 1 FROM pg_database WHERE datname = '$POSTGRES_DB'")"
|
||||
|
||||
if [ "$authority" = "$rest" ]; then
|
||||
path=""
|
||||
fi
|
||||
|
||||
hostport="${authority##*@}"
|
||||
|
||||
if [[ "$hostport" == \[* ]]; then
|
||||
db_host="${hostport#\[}"
|
||||
db_host="${db_host%%]*}"
|
||||
port_part="${hostport#*\]}"
|
||||
if [[ "$port_part" == :* ]] && [ -n "${port_part#:}" ]; then
|
||||
db_port="${port_part#:}"
|
||||
fi
|
||||
else
|
||||
db_host="${hostport%%:*}"
|
||||
if [[ "$hostport" == *:* ]] && [ -n "${hostport##*:}" ]; then
|
||||
db_port="${hostport##*:}"
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ -n "$path" ]; then
|
||||
db_name="${path%%/*}"
|
||||
fi
|
||||
}
|
||||
|
||||
if [ -n "$DATABASE_URL" ]; then
|
||||
parse_database_url
|
||||
if [ "$db_exists" != "1" ]; then
|
||||
docker compose exec -T postgres \
|
||||
psql -U "$POSTGRES_USER" -d postgres -v ON_ERROR_STOP=1 \
|
||||
-c "CREATE DATABASE \"$POSTGRES_DB\"" \
|
||||
> /dev/null
|
||||
fi
|
||||
|
||||
is_local() {
|
||||
[ -z "$DATABASE_URL" ] || [ "$db_host" = "localhost" ] || [ "$db_host" = "127.0.0.1" ] || [ "$db_host" = "::1" ]
|
||||
}
|
||||
|
||||
if is_local; then
|
||||
# ---------- Local: use Docker ----------
|
||||
echo "==> Ensuring shared PostgreSQL container is running on localhost:5432..."
|
||||
docker compose up -d postgres
|
||||
|
||||
echo "==> Waiting for PostgreSQL to be ready..."
|
||||
until docker compose exec -T postgres pg_isready -U "$POSTGRES_USER" -d postgres > /dev/null 2>&1; do
|
||||
sleep 1
|
||||
done
|
||||
|
||||
echo "==> Ensuring database '$POSTGRES_DB' exists..."
|
||||
db_exists="$(docker compose exec -T postgres \
|
||||
psql -U "$POSTGRES_USER" -d postgres -Atqc "SELECT 1 FROM pg_database WHERE datname = '$POSTGRES_DB'")"
|
||||
|
||||
if [ "$db_exists" != "1" ]; then
|
||||
docker compose exec -T postgres \
|
||||
psql -U "$POSTGRES_USER" -d postgres -v ON_ERROR_STOP=1 \
|
||||
-c "CREATE DATABASE \"$POSTGRES_DB\"" \
|
||||
> /dev/null
|
||||
fi
|
||||
|
||||
echo "✓ PostgreSQL ready (local Docker). Database: $POSTGRES_DB"
|
||||
else
|
||||
# ---------- Remote: skip Docker, verify connectivity ----------
|
||||
echo "==> Remote database detected (host: $db_host). Skipping Docker."
|
||||
if command -v pg_isready > /dev/null 2>&1; then
|
||||
echo "==> Waiting for PostgreSQL at $db_host:$db_port to be ready..."
|
||||
until pg_isready -d "$DATABASE_URL" > /dev/null 2>&1; do
|
||||
sleep 1
|
||||
done
|
||||
echo "✓ PostgreSQL ready (remote: $db_host:$db_port). Database: $db_name"
|
||||
else
|
||||
echo "==> pg_isready not found. Skipping remote connectivity preflight."
|
||||
echo "✓ PostgreSQL configured (remote: $db_host:$db_port). Database: $db_name"
|
||||
fi
|
||||
fi
|
||||
echo "✓ PostgreSQL ready. Application database: $POSTGRES_DB"
|
||||
|
||||
@@ -17,7 +17,7 @@ import (
|
||||
|
||||
var agentCmd = &cobra.Command{
|
||||
Use: "agent",
|
||||
Short: "Work with agents",
|
||||
Short: "Manage agents",
|
||||
}
|
||||
|
||||
var agentListCmd = &cobra.Command{
|
||||
@@ -29,7 +29,7 @@ var agentListCmd = &cobra.Command{
|
||||
var agentGetCmd = &cobra.Command{
|
||||
Use: "get <id>",
|
||||
Short: "Get agent details",
|
||||
Args: exactArgs(1),
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: runAgentGet,
|
||||
}
|
||||
|
||||
@@ -42,28 +42,28 @@ var agentCreateCmd = &cobra.Command{
|
||||
var agentUpdateCmd = &cobra.Command{
|
||||
Use: "update <id>",
|
||||
Short: "Update an agent",
|
||||
Args: exactArgs(1),
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: runAgentUpdate,
|
||||
}
|
||||
|
||||
var agentArchiveCmd = &cobra.Command{
|
||||
Use: "archive <id>",
|
||||
Short: "Archive an agent",
|
||||
Args: exactArgs(1),
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: runAgentArchive,
|
||||
}
|
||||
|
||||
var agentRestoreCmd = &cobra.Command{
|
||||
Use: "restore <id>",
|
||||
Short: "Restore an archived agent",
|
||||
Args: exactArgs(1),
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: runAgentRestore,
|
||||
}
|
||||
|
||||
var agentTasksCmd = &cobra.Command{
|
||||
Use: "tasks <id>",
|
||||
Short: "List tasks for an agent",
|
||||
Args: exactArgs(1),
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: runAgentTasks,
|
||||
}
|
||||
|
||||
@@ -77,14 +77,14 @@ var agentSkillsCmd = &cobra.Command{
|
||||
var agentSkillsListCmd = &cobra.Command{
|
||||
Use: "list <agent-id>",
|
||||
Short: "List skills assigned to an agent",
|
||||
Args: exactArgs(1),
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: runAgentSkillsList,
|
||||
}
|
||||
|
||||
var agentSkillsSetCmd = &cobra.Command{
|
||||
Use: "set <agent-id>",
|
||||
Short: "Set skills for an agent (replaces all current assignments)",
|
||||
Args: exactArgs(1),
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: runAgentSkillsSet,
|
||||
}
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user