Compare commits

..

1 Commits

Author SHA1 Message Date
Jiayuan
58a8655b43 fix(web): remember last selected workspace after re-login
Stop clearing multica_workspace_id from localStorage on logout so it
persists as a preference hint. On fresh login, pass the stored ID to
hydrateWorkspace so the user returns to their last workspace instead
of always landing on the first one.
2026-04-06 00:59:35 +08:00
153 changed files with 2606 additions and 9146 deletions

View File

@@ -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=

View File

@@ -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. -->

View File

@@ -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.

View File

@@ -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

View File

@@ -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
View File

@@ -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.

View File

@@ -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

View File

@@ -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.
[![CI](https://github.com/multica-ai/multica/actions/workflows/ci.yml/badge.svg)](https://github.com/multica-ai/multica/actions/workflows/ci.yml)
[![License](https://img.shields.io/badge/License-Apache_2.0-blue.svg)](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)

View File

@@ -14,8 +14,8 @@
**你的下一批员工,不是人类。**
开源的 Managed Agents 平台。<br/>
将编码 Agent 变成真正的队友——分配任务、跟踪进度、积累技能。
开源平台,将编码 Agent 变成真正的队友。<br/>
分配任务、跟踪进度、积累技能——在一个地方管理你的人类 + Agent 团队
[![CI](https://github.com/multica-ai/multica/actions/workflows/ci.yml/badge.svg)](https://github.com/multica-ai/multica/actions/workflows/ci.yml)
[![License](https://img.shields.io/badge/License-Apache_2.0-blue.svg)](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 agentClaude 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

View File

@@ -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

View File

@@ -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>

View File

@@ -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);
}
}}

View File

@@ -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");

View File

@@ -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) {

View File

@@ -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!;

View File

@@ -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();

View File

@@ -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>
);
}

View File

@@ -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");

View File

@@ -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 ?? []);

View File

@@ -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);

View File

@@ -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>
);
}

View File

@@ -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>

View File

@@ -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 (

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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";

View File

@@ -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) });
},
});
}

View File

@@ -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(),
);
}

View File

@@ -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) });
}

View File

@@ -1,3 +0,0 @@
export { createQueryClient } from "./query-client";
export { QueryProvider } from "./provider";
export { useWorkspaceId } from "./hooks";

View File

@@ -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";

View File

@@ -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) });
},
});
}

View File

@@ -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),
});
}

View File

@@ -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) });
}
}

View File

@@ -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>
);
}

View File

@@ -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,
},
},
});
}

View File

@@ -1 +0,0 @@
export { runtimeKeys, runtimeListOptions } from "./queries";

View File

@@ -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 }),
});
}

View File

@@ -1,13 +0,0 @@
export {
workspaceKeys,
workspaceListOptions,
memberListOptions,
agentListOptions,
skillListOptions,
} from "./queries";
export {
useCreateWorkspace,
useLeaveWorkspace,
useDeleteWorkspace,
} from "./mutations";

View File

@@ -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() });
},
});
}

View File

@@ -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(),
});
}

View File

@@ -1,3 +1,2 @@
export { useAuthStore } from "./store";
export { AuthInitializer } from "./initializer";
export { setLoggedInCookie } from "./auth-cookie";

View File

@@ -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);

View File

@@ -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);
}

View File

@@ -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,
}),

View File

@@ -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() } : {}),
}),
];

View File

@@ -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"

View File

@@ -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();

View File

@@ -9,4 +9,3 @@ export {
type TitleEditorRef,
} from "./title-editor";
export { copyMarkdown } from "./utils/clipboard";
export { ReadonlyContent } from "./readonly-content";

View File

@@ -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>
);
}

View File

@@ -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";

View 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,
}));

View File

@@ -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>
);
}

View File

@@ -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;
}
}

View File

@@ -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);
}
};

View File

@@ -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 = {

View File

@@ -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>
);

View File

@@ -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}

View File

@@ -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"
/>
)}

View File

@@ -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);
}

View File

@@ -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

View File

@@ -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>
);
}

View File

@@ -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),

View File

@@ -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) {

View File

@@ -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);

View File

@@ -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);
}

View File

@@ -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 };

View File

@@ -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 };
}

View File

@@ -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,

View File

@@ -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 }),
}));

View File

@@ -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",

View File

@@ -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",

View File

@@ -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>

View File

@@ -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");

View File

@@ -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) {

View File

@@ -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.

View File

@@ -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;

View File

@@ -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]);
}

View File

@@ -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>

View File

@@ -1 +1,2 @@
export { RuntimesPage } from "./components";
export { useRuntimeStore } from "./store";

View 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 ?? "",
});
},
}));

View File

@@ -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");

View File

@@ -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);

View File

@@ -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: [] });
},
}));

View File

@@ -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],

View File

@@ -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",

View File

@@ -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`);
}

View File

@@ -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) {

View File

@@ -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

View File

@@ -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 {

View File

@@ -4,6 +4,9 @@ export type {
AgentStatus,
AgentRuntimeMode,
AgentVisibility,
AgentTriggerType,
AgentTool,
AgentTrigger,
AgentTask,
AgentRuntime,
RuntimeDevice,

View File

@@ -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";

View File

@@ -28,9 +28,6 @@
"paths": {
"@/*": [
"./*"
],
"@core/*": [
"./core/*"
]
},
"noEmit": true,

View File

@@ -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

View File

@@ -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
View File

@@ -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

View File

@@ -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; }
# --------------------------------------------------------------------------

View File

@@ -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"

View File

@@ -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