Compare commits

...

133 Commits

Author SHA1 Message Date
Naiyuan Qing
b3fa5557ca merge: resolve conflict with main (import useModalStore)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 09:29:12 +08:00
Naiyuan Qing
19a1bbba4a feat(editor): drag-and-drop file upload with file card display
- Add drag-and-drop overlay with brand color visual feedback
- Images: inline rendering with blob preview → real URL replacement
- Non-images: file card node (spinner → filename card with download button)
- File card markdown roundtrip: [name](url) ↔ fileCard node via preprocessor
- Fix: double-upload on drag (check defaultPrevented)
- Fix: drop overlay not clearing (global drop/dragend listener)
- Fix: drop replacing existing content (use posAtCoords for drop position)
- Fix: multi-file drop position drift (only first file uses drop pos)
- Fix: same-name file upload conflict (use uploadId instead of filename)
- Fix: image upload descendants traversal not stopping (add found flag)
- Fix: parent comment edit missing onUploadFile prop
- Remove: attachment section UI (files live in markdown)
- Remove: file type whitelist (accept all types like Linear)
- Remove: console.log perf logs from production code

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 09:26:12 +08:00
Jiayuan Zhang
f57cf44eba Merge pull request #526 from multica-ai/forrestchang-patch-1
doc: remove license section
2026-04-09 03:11:17 +08:00
Jiayuan Zhang
ae797811d2 doc: remove license section
Removed License section from README.md
2026-04-09 03:11:03 +08:00
Jiayuan Zhang
7d01cf8c68 Merge pull request #525 from multica-ai/agent/emacs/readme-managed-agents
docs: position Multica as open-source managed agents platform
2026-04-09 03:09:45 +08:00
Jiayuan Zhang
e79eabcc18 docs: position Multica as open-source managed agents platform
- Update subtitle: "The open-source managed agents platform"
- Add managed agents positioning to "What is Multica?" section
- Add lifecycle summary line above Features list
- Mirror all changes in Chinese README

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 03:07:58 +08:00
Jiayuan Zhang
d2e4b9753d feat(issues): add fullscreen agent execution transcript view (#524)
* feat(issues): add fullscreen agent execution transcript view

Adds a new "expand" button (Maximize2 icon) to both the live agent card
and execution history entries. Clicking it opens a fullscreen dialog with:

- A colored timeline progress bar showing execution flow at a glance
  (green = agent text, violet = thinking, blue = tool calls,
   gray = results, red = errors)
- Detailed event list with type labels, summaries, and expandable detail
- Click-to-scroll: clicking a timeline segment scrolls to that event
- Copy-all button for the full transcript

Inspired by Anthropic's Cloud Managed Agents session transcript UI.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat(issues): add runtime and agent metadata to transcript dialog

Adds metadata chips to the transcript dialog header showing:
- Runtime provider (e.g., "Claude Code", "Codex")
- Runtime environment name + mode (local/cloud)
- Agent description
- Duration, tool count, event count, and creation time

Metadata is fetched on dialog open via existing API endpoints.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 02:58:04 +08:00
Jiayuan Zhang
fab17b48b3 Merge pull request #520 from multica-ai/license/refine-commercial-restriction
chore(license): refine commercial restriction to target SaaS/resale only
2026-04-08 23:48:22 +08:00
Jiayuan Zhang
4f8969ef52 chore(license): refine commercial restriction to target SaaS/resale only
Replace "multi-tenant environment" restriction with "hosted or embedded
service" restriction. Internal use with multiple workspaces is now
explicitly allowed. Only providing Multica as a hosted service to third
parties or embedding it in a commercial product requires a license.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 23:47:05 +08:00
Jiayuan Zhang
2e5b8b9a87 Merge pull request #518 from multica-ai/license/modified-apache-2.0
chore: update LICENSE to modified Apache 2.0
2026-04-08 21:19:54 +08:00
Jiayuan Zhang
f4ba27f2f5 chore: update LICENSE to modified Apache 2.0 with commercial restrictions
Replace standard Apache 2.0 with a modified version that adds:
- Multi-tenant SaaS restriction (requires commercial license)
- Frontend LOGO/copyright protection
- Contributor agreement for relicensing rights

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 21:15:58 +08:00
Bohan Jiang
e6f840ca11 chore(issues): shrink add sub-issue label and remove jump-to-bottom button (#515) 2026-04-08 19:07:35 +08:00
Bohan Jiang
aa770f2333 feat(issues): show "Add sub-issues" button when no sub-issues exist (#511)
Previously the sub-issues section only rendered when child issues were
present. This adds a Linear-style "+ Add sub-issues" button below the
description area so users can create sub-issues from an empty state.
2026-04-08 18:51:03 +08:00
Bohan Jiang
bd6731525e fix(issues): polish sub-issue UI and sync parent children cache (#506)
- Add Linear-style "Sub-issue of …" breadcrumb under the title with a
  parent progress ring
- Refresh sub-issues section: progress ring badge, identifier column,
  bordered list, collapse toggle, dashed assignee placeholder
- useUpdateIssue + onIssueUpdated WS handler now also patch and
  invalidate the parent's children query so sub-issue status/assignee
  changes show up on the parent page without a refresh
2026-04-08 17:04:55 +08:00
Bohan Jiang
68d052625c docs(web): add v0.1.9 changelog entry for 2026-04-08 (#504) 2026-04-08 17:03:50 +08:00
Bohan Jiang
3d053345fd perf(web): fix slow tab switching by removing dynamic root layout (#502)
The root layout called `await cookies()` to read the locale, which
marked the entire app as dynamic. In Next.js 16, dynamic pages have
Router Cache staleTime=0, causing a fresh RSC server roundtrip on
every navigation — the root cause of ~400ms tab switching delays.

- Remove cookies() from root layout, making it static
- Add LocaleSync client component to read locale cookie on the client
- Add loading.tsx skeleton for dashboard routes as a loading fallback
2026-04-08 16:49:25 +08:00
Bohan Jiang
180c6966db fix(issues): polish sub-issues section design to match Linear (#503)
- Add chevron collapse indicator in header
- Show completion progress (done/total) with tabular-nums
- Use left border indentation for child items (tree view)
- Increase icon size, row padding, and spacing
- Larger + button with better hover state
- Only show section when child issues exist
2026-04-08 16:47:07 +08:00
LinYushen
0c45864ef0 fix(board): show total count in Done column and infinite scroll (#501)
* fix(board): show total count in Done column header and auto-load on scroll

- Column header now shows server-side doneTotal instead of loaded count
- Replace "Load more" button with IntersectionObserver sentinel for
  infinite scroll in the Done column

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(board): move sentinel below imports and stabilize observer

- Move InfiniteScrollSentinel after all import statements
- Use callback ref to avoid recreating IntersectionObserver on every render

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(board): add optional chaining for IntersectionObserver entry

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 16:33:17 +08:00
Bohan Jiang
c6ba954eb8 fix(issues): move sub-issues to content area and fix real-time refresh (#500)
1. Move sub-issues section from sidebar to main content area (below
   description), matching Linear's layout. Shows status icon, title,
   and assignee avatar for each child issue.

2. Fix real-time refresh: invalidate parent's childIssuesOptions query
   in useCreateIssue mutation (onSuccess), onIssueCreated WS handler,
   and onIssueDeleted WS handler so sub-issues list updates immediately
   without page refresh.
2026-04-08 16:31:49 +08:00
LinYushen
76354cd968 fix(board): show total count in Done column and infinite scroll (#498)
* fix(board): show total count in Done column header and auto-load on scroll

- Column header now shows server-side doneTotal instead of loaded count
- Replace "Load more" button with IntersectionObserver sentinel for
  infinite scroll in the Done column

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(board): move sentinel below imports and stabilize observer

- Move InfiniteScrollSentinel after all import statements
- Use callback ref to avoid recreating IntersectionObserver on every render

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 16:15:15 +08:00
Bohan Jiang
4bdb86057e fix(issues): use TanStack Query for sub-issue data fetching (#499)
The sub-issue code was using direct `api` calls, but the codebase was
refactored to TanStack Query and the `api` import was removed from
issue-detail.tsx, causing a build error on Vercel.

Replace useState+useEffect with useQuery for both parent and child
issue fetching, consistent with the TQ migration.
2026-04-08 16:10:40 +08:00
Bohan Jiang
a8a8ff6eca feat(issues): add sub-issue support (#483)
* feat(issues): add sub-issue support

- Backend: Add ListChildIssues SQL query, add parent_issue_id to UpdateIssue,
  add GET /api/issues/{id}/children endpoint
- Frontend: Display parent issue breadcrumb and link in issue detail sidebar,
  show child issues list with status icons, add "Create sub-issue" action in
  dropdown menu and sidebar, pass parent_issue_id through create issue modal
- Update test mocks for new API method

* fix(issues): add parent validation, cycle detection, and improve child refresh

- CreateIssue: validate parent issue exists in the same workspace
- UpdateIssue: validate parent exists, prevent self-referencing, detect
  circular parent chains (up to 10 levels deep)
- Frontend: derive child issues from store when available instead of
  refetching on every global issue count change
2026-04-08 15:57:13 +08:00
Naiyuan Qing
0dcaa60919 Merge pull request #496 from multica-ai/refactor/reaction-ui-optimistic
refactor(web): migrate reaction optimistic updates to UI pattern
2026-04-08 15:43:51 +08:00
Naiyuan Qing
17e37ec4db fix(web): address review — shared types and stable optimistic data
- Extract ToggleCommentReactionVars and ToggleIssueReactionVars shared
  types so mutation definitions and useMutationState consumers stay in
  sync without as-casts on inline types
- Replace new Date().toISOString() with empty string in optimistic
  reaction objects to avoid unstable references in useMemo

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 15:41:16 +08:00
Naiyuan Qing
060afc848c refactor(web): migrate reaction optimistic updates from cache to UI pattern
Replace cache-level optimistic updates (onMutate with temp IDs) with
TQ v5's UI-level pattern (useMutationState + render-time derivation)
for both issue-level and comment-level reaction toggles.

The cache-level approach caused a race condition: temp IDs in the cache
couldn't be deduplicated against real IDs from WS events, causing
reaction counts to briefly flash incorrect values (e.g. 0→1→2→1).

The UI pattern keeps the cache clean (always server-confirmed data) and
derives optimistic state at render time from pending mutation variables.
WS events safely update the cache without conflicting with temp data.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 15:35:52 +08:00
Naiyuan Qing
1903b886f6 Merge pull request #494 from multica-ai/fix/inbox-stale-timeline-cache
fix(web): add global WS handlers for per-issue cache invalidation
2026-04-08 15:23:45 +08:00
Naiyuan Qing
240813c605 fix(web): add global WS handlers for per-issue cache invalidation
Per-issue WS events (comments, activities, reactions, subscribers) were
only handled by component-level useWSEvent hooks that unsubscribe on
unmount. With staleTime: Infinity, this left timeline/reactions/subscribers
caches silently stale — reopening an issue served cached data without
refetching, causing missing comments in inbox and issue detail views.

Add global fallback handlers in useRealtimeSync that invalidateQueries
for the affected issue on every per-issue WS event, ensuring caches are
marked stale even when IssueDetail is unmounted.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 15:21:14 +08:00
LinYushen
7d74b1f0b9 Merge pull request #495 from multica-ai/revert-477-feat/structured-ticket-search
Revert "feat(issues): add structured ticket search"
2026-04-08 15:15:25 +08:00
LinYushen
39ca8ed9e8 Revert "feat(issues): add structured ticket search" 2026-04-08 15:15:08 +08:00
LinYushen
3c08395741 Merge pull request #477 from pseudoyu/feat/structured-ticket-search
feat(issues): add structured ticket search
2026-04-08 15:02:43 +08:00
LinYushen
ec934f3a8b fix(web): add load-more pagination for Done column on issue board (#492)
* fix(web): add load-more pagination for Done column on issue board

The Done column was capped at 50 issues with no way to load more.
Track doneTotal in the TQ cache and add a useLoadMoreDoneIssues hook
that fetches the next page and merges it into the unified issue cache.
The Done column now shows a "Load more" button when there are
additional items.

- shared/types/api.ts: add doneTotal to ListIssuesResponse
- core/issues/queries.ts: store doneTotal from the done-status response
- core/issues/mutations.ts: add useLoadMoreDoneIssues hook, update
  create/delete mutations to maintain doneTotal
- core/issues/ws-updaters.ts: maintain doneTotal on WS events
- features/issues/components/board-column.tsx: accept optional footer
- features/issues/components/board-view.tsx: render Load more button
  in Done column

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(web): address review issues in done-column load-more

1. Fix total over-counting: loadMore no longer inflates total since
   the initial query already includes all done issues in total count.
2. Fix onIssueUpdated: maintain doneTotal when issue status changes
   to/from done via WS events.
3. Make doneTotal optional in ListIssuesResponse since it's a
   frontend-only field not returned by the backend API. All reads
   now use ?? 0 fallback.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 14:58:51 +08:00
Naiyuan Qing
25cf64588d feat(issues): add attachment section with image grid and file cards
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 14:55:56 +08:00
Naiyuan Qing
301a4a3882 feat(editor): add drag-and-drop visual overlay and file type validation
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 14:50:29 +08:00
Naiyuan Qing
102b19d948 feat(upload): add file type whitelist aligned with Agent readability
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 14:44:33 +08:00
zerone0x
a7afd4b959 feat: wire allowedDevOrigins from CORS_ALLOWED_ORIGINS for non-localhost dev access (#355)
* feat: add allowedDevOrigins to Next.js config for non-localhost dev access

Wire CORS_ALLOWED_ORIGINS env var into Next.js allowedDevOrigins config
so that cross-origin HMR/webpack requests from Tailscale or other
non-localhost IPs are not blocked during development.

Fixes #317

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: keep port in allowedDevOrigins

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-08 14:39:01 +08:00
Bohan Jiang
8403c97688 Merge pull request #482 from multica-ai/agent/j/674c6839
feat(usage): add per-task token usage tracking
2026-04-08 14:16:59 +08:00
LinYushen
7df5750979 fix(daemon): update existing worktree to latest remote on reuse (#489)
* fix(daemon): update existing worktree to latest remote on reuse

When an agent receives a new task on the same issue, the execution
environment is reused and the repo worktree already exists on disk.
Previously, `multica repo checkout` would fail because `git worktree add`
cannot create a path that already exists — so the agent worked on stale
code from the prior task.

Now `CreateWorktree` detects existing worktrees and updates them:
fetch origin, reset working tree, then checkout a new branch from the
latest remote default branch. The previous task's branch is preserved.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(daemon): propagate actual branch name and use correct ref in worktree reuse

- Return (string, error) from updateExistingWorktree so collision-retried
  branch name propagates to WorktreeResult
- Use baseRef directly instead of origin/baseRef — bare clone refspec maps
  remote branches to local refs, so remote-tracking refs may not exist
- Remove redundant fetch (worktree shares object store with bare clone)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 14:13:44 +08:00
Naiyuan Qing
990cc8b3ae Merge pull request #488 from multica-ai/fix/ws-self-event-idempotent
fix(web): replace WS self-event filtering with idempotent cache updates
2026-04-08 14:00:18 +08:00
Naiyuan Qing
7ee2450297 Merge pull request #487 from multica-ai/NevilleQingNY/readonly-markdown
perf(editor): replace readonly Tiptap instances with react-markdown
2026-04-08 13:57:46 +08:00
Naiyuan Qing
d58f6cdb33 fix(web): replace actor_id self-event filtering with idempotent cache updates
actor_id identifies the user, not the browser tab. Filtering WS events
by actor_id broke multi-tab sync — other tabs of the same user would
silently miss updates. Instead, make all WS cache handlers idempotent
(dedup checks on add, no-op on duplicate merge/filter) so mutations and
WS events coexist safely without filtering.

- WSClient: pass actor_id to event handlers for future per-handler use
- use-realtime-sync: remove isSelf() gating from onAny and specific handlers
- useCreateIssue: add .some() dedup guard + onSettled invalidation
- use-issue-reactions: remove payload-level self-filter (dedup already present)
- use-issue-timeline: remove payload-level self-filter on comment:created,
  reaction:added, reaction:removed (dedup already present)
- Clean up useCallback deps that no longer reference userId

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 13:57:24 +08:00
Naiyuan Qing
af156040cb test(issues): add ReadonlyContent mock to issue detail tests
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 13:55:25 +08:00
Naiyuan Qing
5e770b2e2f fix(editor): align IssueMentionCard styling and behavior with Tiptap
- Remove align-middle from IssueMentionCard (alignment is container's job)
- Add inline align-middle wrapper span in ReadonlyContent for vertical alignment
- Add img component with max-width constraint to prevent overflow
- Issue mention clicks open in new tab (matches Tiptap behavior)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 13:47:03 +08:00
Naiyuan Qing
92e76dea81 refactor(issues): use ReadonlyContent for comment readonly display
Replace ContentEditor editable={false} with lightweight ReadonlyContent
in comment cards. Each comment no longer creates a full ProseMirror
instance for readonly display.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 13:47:03 +08:00
Naiyuan Qing
4df32a853b feat(editor): add ReadonlyContent component for lightweight markdown display
- Add del selector to strikethrough CSS for react-markdown compatibility
- Create ReadonlyContent using react-markdown + lowlight + content-editor.css
- Export from editor module index

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 13:47:03 +08:00
Jiang Bohan
fa0c0fe747 fix(usage): address review feedback — independent usage reporting + all providers
1. Separate ReportTaskUsage endpoint (POST /api/daemon/tasks/{id}/usage)
   so usage is captured independently of complete/fail — fixes usage loss
   for failed/blocked tasks.

2. Add usage tracking for all four providers:
   - Claude: already done (stream-json message.usage)
   - OpenCode: extract from step_finish.part.tokens
   - OpenClaw: extract from step_end.data token fields
   - Codex: extract from turn/completed and task_complete usage fields

3. Remove usage from CompleteTask payload — all usage goes through the
   dedicated endpoint now.
2026-04-08 13:23:54 +08:00
Jiang Bohan
8a8d3ea20e feat(usage): add per-task token usage tracking
Extract token usage from Claude Code's stream-json output in real-time
during task execution, replacing the inaccurate global JSONL log scanner.

- New `task_usage` table: tracks (task_id, provider, model) level usage
- Agent SDK: parse `message.usage` from assistant messages, accumulate
  per-model and return in Result
- Daemon: convert agent usage to entries, send with CompleteTask
- Server: store usage on task completion, expose workspace-level
  aggregation APIs (GET /api/usage/daily, GET /api/usage/summary)
2026-04-08 13:08:15 +08:00
Jiayuan Zhang
88c2f4ddc4 Merge pull request #479 from multica-ai/fix/cli-web-shared-login-state
fix(auth): persist browser session during CLI login flow
2026-04-08 12:50:53 +08:00
Bohan Jiang
98af9f442c Merge pull request #471 from multica-ai/agent/j/959392dd
feat: support multiple agents running on same issue
2026-04-08 12:45:56 +08:00
pseudoyu
34c39b765e feat(issues): add structured ticket search 2026-04-08 11:30:53 +08:00
Naiyuan Qing
efe131591f Merge pull request #472 from multica-ai/feat/tanstack-query-migration
feat(web): migrate server state from Zustand to TanStack Query (Phase 0-4)
2026-04-08 10:46:35 +08:00
Naiyuan Qing
104bbbef41 fix(web): prevent useWorkspaceId crash in AppSidebar (re-apply after merge revert)
AppSidebar renders before workspace hydrates. useWorkspaceId() throws
when workspace is null. Fix: read workspace?.id directly from store,
use enabled guard on inbox query. This fix was in commit 030627c but
got reverted by subsequent merge with main.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 10:44:56 +08:00
Naiyuan Qing
eed8e36a69 fix(test): update mockListIssues for two-phase fetch (open_only + closed)
issueListOptions now makes 2 api.listIssues calls (open_only + closed page).
Tests that mock the response must return data only for the open_only call,
otherwise issues appear twice in the merged result.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 10:41:29 +08:00
Naiyuan Qing
8cf78b7a47 Merge remote-tracking branch 'origin/main' into feat/tanstack-query-migration
# Conflicts:
#	apps/web/app/(dashboard)/agents/page.tsx
2026-04-08 10:35:28 +08:00
Naiyuan Qing
862b85e064 fix(web): DnD local-state overlay, onSettled list invalidation, WS self-event filter
- Board DnD: use local pendingMove state for instant card placement,
  bypassing TQ's async setQueryData notification delay
- useUpdateIssue: add list invalidation to onSettled (was only detail)
- use-realtime-sync: add isSelf check to specific issue WS handlers
  (prevents redundant cache writes for own mutations)
- Clean up debug console.logs from board-view, issues-page, mutations

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 10:25:35 +08:00
Jiayuan Zhang
857ec7d4d4 fix(auth): persist browser session during CLI login flow
When authenticating via CLI, the login page called api.verifyCode()
directly and redirected to the CLI callback without saving the JWT
to localStorage or setting the logged-in cookie. This meant the
browser had no session after CLI login, forcing users to log in
again when visiting multica.ai.

Now the token is saved to localStorage and the cookie is set before
redirecting to the CLI callback, so both CLI and web app share the
same authentication.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 10:22:19 +08:00
devv-eve
7c79611309 refactor: remove agent triggers config field (#469)
* refactor: remove agent triggers config field

Remove the triggers field from agent configuration. The on_assign,
on_comment, and on_mention behaviors are now always enabled (hardcoded),
as decided in the Agentflow design discussion (MUL-372).

Changes:
- Database: migration 032 drops triggers column from agent table
- Backend: remove triggers from create/update agent APIs and response
- Backend: simplify trigger-checking logic to always-enabled
- Frontend: remove TriggersTab UI and AgentTrigger types
- Tests: remove trigger config unit tests (no longer configurable)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* refactor: also remove agent tools config field

Remove the tools field from agent configuration alongside triggers.
The tools field was a placeholder — stored in the DB and shown in the
UI but never passed to the daemon or used at runtime.

- Database: migration 032 now also drops tools column
- Backend: remove tools from create/update agent APIs and response
- Frontend: remove ToolsTab UI, AgentTool type, and tools tab
- Update landing page copy

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(test): remove tools/triggers columns from test fixtures

The test fixtures still referenced the dropped tools and triggers
columns when inserting agent rows, causing CI failures.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Devv <devv@Devvs-Mac-mini.local>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 22:02:28 +08:00
Naiyuan Qing
99dad49052 fix(core): add onSettled invalidation to all optimistic mutations + enable refetchOnReconnect
P0: Add onSettled: invalidateQueries to 10 mutations that had onMutate
optimistic updates but no server confirmation. With staleTime: Infinity,
missing onSettled means cache could permanently drift from server state.

Mutations fixed:
- useDeleteIssue, useBatchDeleteIssues (issue list)
- useUpdateComment, useDeleteComment, useToggleCommentReaction (timeline)
- useToggleIssueReaction (reactions)
- useToggleIssueSubscriber (subscribers)
- useMarkInboxRead, useArchiveInbox, useMarkAllInboxRead (inbox)

P2: Change refetchOnReconnect from false to true as safety net
for HTTP reconnection before WS reconnection.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 19:07:37 +08:00
Naiyuan Qing
6296629831 fix: restore TQ consumer migrations lost during merge with main
The merge with origin/main (fe9479d6) silently reverted all consumer-side
migrations, leaving core/ as dead code. Restored all 39 files from
pre-merge commit 6032b5df, plus main's trigger.config null fix for
agents page.

Verified: 59 @core/ imports across features/ and app/, all stores
gutted/deleted, realtime sync uses queryClient not Zustand.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 18:59:09 +08:00
Naiyuan Qing
7ed565da6b docs: update CLAUDE.md for TanStack Query architecture + restore @core alias
- Add core/ layer documentation (queries, mutations, WS updaters)
- Rewrite State Management section: TQ for server state, Zustand for client-only
- Update features table: reflect gutted stores (issues, inbox, workspace)
- Add @core/* import alias examples
- Update Data Flow diagram to include TQ layer
- Restore @core/* path alias in tsconfig + vitest (lost during merge)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 18:49:23 +08:00
Naiyuan Qing
030627c8c5 fix(web): prevent useWorkspaceId crash in AppSidebar before workspace hydration
AppSidebar renders outside the workspace guard in dashboard layout.
On first login, workspace hasn't hydrated yet → useWorkspaceId() throws.
Fix: read workspace?.id directly from store, use enabled guard on inbox query.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 18:39:32 +08:00
Naiyuan Qing
fe9479d6fc Merge remote-tracking branch 'origin/main' into feat/tanstack-query-migration
# Conflicts:
#	apps/web/features/issues/components/batch-action-toolbar.tsx
#	apps/web/features/issues/components/issues-page.tsx
#	apps/web/features/issues/store.ts
2026-04-07 18:39:13 +08:00
Jiang Bohan
b94108768e feat: support multiple agents running concurrently on the same issue
- Relax ClaimAgentTask SQL constraint from per-issue to per-(issue, agent)
  serialization, allowing different agents to run in parallel on the same issue
- Update GetActiveTaskForIssue API to return all active tasks (array) instead of
  just the first one
- Refactor AgentLiveCard to render one card per active task, routing WebSocket
  messages by task_id for independent timelines
- Fix shouldEnqueueOnComment to use per-agent dedup so a mentioned agent's
  pending task doesn't block the assigned agent's on_comment trigger

Closes MUL-160
2026-04-07 18:19:57 +08:00
Naiyuan Qing
348133b63d merge: resolve conflicts with main (open_only pagination)
- Resolve issues/store.ts: keep client-only store, port pagination
  strategy (open_only + closed page) to core/issues/queries.ts
- Resolve issues-page.tsx, batch-action-toolbar.tsx: keep TQ mutations
- Auto-merge agents/page.tsx trigger null fix

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 18:08:35 +08:00
Naiyuan Qing
6032b5dfcb fix: mention closure, onSettled invalidation, cleanup singleton
- Fix Tiptap mention: pass QueryClient via closure from ContentEditor
  instead of getQueryClient() singleton (resolves @mention empty list)
- Add onSettled invalidation to useUpdateIssue (prevents cache drift
  with staleTime: Infinity + self-event WS filter)
- Add cache shape comment to issueListOptions (select transforms
  ListIssuesResponse → Issue[], but cache stores raw response)
- Memoize sidebar inbox dedup computation
- Remove dead getQueryClient/setQueryClient singleton + window property
- Remove ActorSync component and _members/_agents Zustand mirror
  (superseded by closure approach)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 17:53:49 +08:00
Bohan Jiang
23198f3c26 Merge pull request #461 from multica-ai/agent/j/70455bdb
fix(daemon): correct duplicate sub-step lettering in workflow instructions
2026-04-07 17:29:46 +08:00
Naiyuan Qing
e40341ab73 feat(core): migrate workspace + runtimes to TanStack Query (Phase 3+4)
- Create core/workspace/ with queries (members, agents, skills, list) and mutations
- Create core/runtimes/ with queries
- Migrate 11 consumer files from useWorkspaceStore.members/agents/skills to useQuery
- Replace all WS refreshMap entries with qc.invalidateQueries
- Simplify workspace store: delete members/agents/skills fields + refresh methods,
  hydrateWorkspace becomes synchronous (TQ auto-fetches on component mount)
- Delete useRuntimeStore (no consumers left), runtimes-page uses local useState + TQ
- Remove workspace→runtime cross-store dependency
- Clean up dead test helper mocks

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 17:19:52 +08:00
Bohan Jiang
c695de5314 Merge pull request #468 from multica-ai/agent/j/272bc2a3
docs(web): add v0.1.8 changelog entry
2026-04-07 17:07:02 +08:00
Jiang Bohan
d6b59aade6 docs(web): add v0.1.8 changelog entry for 2026-04-07 2026-04-07 17:03:41 +08:00
Naiyuan Qing
1d812bd446 feat(core/inbox): migrate inbox to TanStack Query (Phase 2)
- Create core/inbox/ with queries, mutations, ws-updaters
- Migrate inbox page: useQuery + mutation hooks replace useInboxStore + api.*
- Migrate sidebar unread badge to read from TQ cache
- Delete useInboxStore (127 lines) — inbox has no client-only state
- Remove inbox deps from workspace store (hydrate + switch)
- Fix WS sync: use useQueryClient() instead of getQueryClient() singleton
  to ensure WS handlers write to the same QueryClient instance that
  components read from (singleton is unreliable under Next.js HMR)
- Add onInboxIssueStatusChanged for issue status sync in inbox items

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 16:56:47 +08:00
devv-eve
abcc7bf3cd feat(issues): load all open issues without limit, paginate closed (#459)
- Add ListOpenIssues SQL query (excludes done/cancelled, no LIMIT)
- Add CountIssues SQL query for true total count
- Backend: support open_only=true param, fix total to return real count
- Frontend: two-phase fetch in issue store (all open + first 50 closed)
- Add fetchMoreClosed action for paginated closed issue loading
- Replace all hardcoded limit:200 with store.fetch() calls

Resolves MUL-369

Co-authored-by: Devv <devv@Devvs-Mac-mini.local>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 00:59:03 -07:00
Naiyuan Qing
06fa65d4b5 test(issues): clean up dead useIssueStore mocks from tests
Remove mock issues[] and server state fields from useIssueStore mocks
since the store now only holds activeIssueId. Data flows through
TanStack Query (mockListIssues) not the store.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 15:52:15 +08:00
Bohan Jiang
9d1570b301 Merge pull request #465 from multica-ai/agent/j/ffea36be
fix(auth): move Google callback to correct route path
2026-04-07 15:48:30 +08:00
Jiang Bohan
7f2ea9857d fix(auth): move Google callback page to correct route path
The callback page was placed under app/(auth)/callback/ — a Next.js
route group — which mapped to /callback instead of /auth/callback.
Move it to app/auth/callback/ so the URL matches the Google OAuth
redirect URI.
2026-04-07 15:47:44 +08:00
Naiyuan Qing
1ad057fb0f refactor(issues): migrate all consumers to TanStack Query (Phase 1, Commits 5-10)
- Migrate issue-detail.tsx: useQuery for issue data, useUpdateIssue/useDeleteIssue
- Migrate issues-page.tsx, my-issues-page.tsx, board-card.tsx: useQuery for list
- Migrate batch-action-toolbar.tsx, create-issue.tsx: mutation hooks
- Migrate edge consumers: mention-suggestion, mention-view, agents page, issue-mention-card
- Remove Zustand writes from WS sync (TQ cache is now sole source of truth)
- Remove useIssueStore.fetch() dependency from workspace store
- Gut useIssueStore to client-only: { activeIssueId, setActiveIssue }
- Update test wrappers with QueryClientProvider

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 15:46:08 +08:00
Bohan Jiang
b85c068e83 Merge pull request #464 from multica-ai/agent/j/272bc2a3
docs(web): add v0.1.7 changelog entry
2026-04-07 15:37:23 +08:00
Jiang Bohan
30cda933bc docs(web): add v0.1.7 changelog entry for 2026-04-05 2026-04-07 15:36:32 +08:00
Jiang Bohan
b5537077bc Merge branch 'main' of https://github.com/multica-ai/multica into agent/j/272bc2a3 2026-04-07 15:35:38 +08:00
Bohan Jiang
638033c9ff Merge pull request #462 from multica-ai/agent/j/ffea36be
feat(auth): add Google OAuth login
2026-04-07 15:32:08 +08:00
Naiyuan Qing
7560f7be85 feat(core/issues): add TanStack Query layer and rewrite hooks (Phase 1, Commits 1-4)
- Add getQueryClient() singleton for non-React contexts (WS handlers, Zustand)
- Create issue query key factory + 5 queryOptions
- Create 11 mutation hooks with optimistic updates and rollback
- Create WS cache updaters + dual-write in use-realtime-sync
- Rewrite useIssueTimeline, useIssueReactions, useIssueSubscribers to TQ
  (return types unchanged, consumers unaffected)
- Add QueryClientProvider wrapper to issue detail tests

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 15:30:42 +08:00
Bohan Jiang
b84104b421 Merge pull request #463 from multica-ai/fix/agent-trigger-config-nullable-type
fix(types): make AgentTrigger.config nullable
2026-04-07 15:27:11 +08:00
Jiang Bohan
0c92fb2674 fix(types): make AgentTrigger.config nullable to match API reality
The API can return `config: null` for non-scheduled triggers, but the
type was `Record<string, unknown>` which doesn't reflect this. Update
to `Record<string, unknown> | null` so TypeScript catches unsafe access
at compile time.

Follow-up to #415.
2026-04-07 15:25:29 +08:00
Jiang Bohan
14fe8e9df9 feat(auth): add Google OAuth login
Support Google login that links to existing accounts by email.
When a user who registered via email OTP signs in with Google using
the same email, they are linked to the same account.

Backend:
- Add POST /auth/google endpoint that exchanges Google auth code for
  tokens, fetches user profile, and calls findOrCreateUser()
- Updates user name and avatar from Google profile on first Google login

Frontend:
- Add "Continue with Google" button on login page (shown when
  NEXT_PUBLIC_GOOGLE_CLIENT_ID is configured)
- Add /auth/callback page to handle Google OAuth redirect
- Add loginWithGoogle to auth store and API client
2026-04-07 15:25:26 +08:00
Bohan Jiang
f9c0fcba24 Merge pull request #415 from cocovs/codex/fix-agent-trigger-null-config-crash
fix(web): prevent Agents trigger crash when config is null
2026-04-07 15:24:15 +08:00
Jiang Bohan
47917825d1 fix(daemon): correct duplicate sub-step lettering in workflow instructions
When repos are present, sub-steps c/d/e/f are now distinct instead of
having two 'c' steps. Each branch (with/without repos) now has its own
complete set of correctly lettered sub-steps.
2026-04-07 15:22:02 +08:00
Bohan Jiang
eab5f8e7e8 Merge pull request #457 from multica-ai/agent/j/4420d1bf
fix(daemon): ensure multica CLI is on PATH in agent task environment
2026-04-07 15:03:56 +08:00
Jiang Bohan
9495179923 fix(daemon): ensure multica CLI is on PATH in agent task environment
Prepend the directory of the running multica binary to PATH in the
agent's environment variables. This fixes the issue where isolated
runtimes (e.g. Codex sandbox) cannot find the multica CLI, causing
agent tasks to fail immediately with "command not found: multica".

Closes #451
2026-04-07 15:01:48 +08:00
Bohan Jiang
f16b36fbc8 Merge pull request #456 from multica-ai/agent/j/25583cc6
feat(agent): add OpenClaw runtime support
2026-04-07 14:53:53 +08:00
Jiang Bohan
dd2ce90b1d fix(agent): address openclaw review feedback
- Remove duplicate extractOCToolOutput, reuse extractToolOutput from opencode.go
- Rename extractEventText → openclawExtractText to avoid package-level name collisions
- Add clarifying comments for error status stickiness and result event behavior
- Remove redundant extractOCToolOutput tests (already covered by opencode tests)
2026-04-07 14:52:54 +08:00
Bohan Jiang
88b87e2fa6 Merge pull request #455 from multica-ai/agent/j/653cfab4
fix(triggers): remove assignee skip in on_mention trigger
2026-04-07 14:49:45 +08:00
Naiyuan Qing
2be9f6cd2f feat(web): add TanStack Query infrastructure (Phase 0)
- Install @tanstack/react-query v5 + devtools
- Create core/query-client.ts with WS-optimized defaults (staleTime: Infinity)
- Create QueryProvider and wire into root layout
- Add @core/* path alias to tsconfig + vitest
- Add useWorkspaceId() bridge hook for query key scoping

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 14:43:51 +08:00
Jiang Bohan
5cf4ba803d feat(agent): add OpenClaw runtime support
Add OpenClaw as a fourth supported agent runtime alongside Claude Code,
Codex, and OpenCode. OpenClaw CLI (`openclaw agent -p ... --output-format
stream-json`) is integrated via the same Backend interface pattern.

Changes:
- Add openclawBackend in server/pkg/agent/openclaw.go with NDJSON
  event stream parsing (text, thinking, tool_call, error, step, result)
- Register "openclaw" in the agent factory (agent.go)
- Add MULTICA_OPENCLAW_PATH / MULTICA_OPENCLAW_MODEL env var detection
  in daemon config
- Include "openclaw" in AGENTS.md config injection alongside codex/opencode
- Add comprehensive unit tests for all event handlers and processEvents
2026-04-07 14:40:51 +08:00
Jiang Bohan
cfb0365cb3 fix(triggers): remove assignee skip in enqueueMentionedAgentTasks
The assignee check in enqueueMentionedAgentTasks silently skipped
explicit @mentions when the target agent was the issue assignee in
a non-terminal status. This broke the review-rejection-retry loop:
when a reviewer rejected a PR and @mentioned the developer agent,
the mention was skipped because the developer was the assignee.

The downstream HasPendingTaskForIssueAndAgent check already prevents
duplicate queued tasks, making the assignee skip redundant. Removing
it ensures explicit @mentions always fire regardless of assignee status.

Closes #431
2026-04-07 14:36:08 +08:00
devv-eve
81d430d870 Merge pull request #445 from sunjie21/main
fix(auth): extend JWT and CloudFront cookie expiration from 72h to 30 days
2026-04-06 23:34:15 -07:00
Bohan Jiang
96d81f9836 Merge pull request #454 from multica-ai/agent/j/ea6693b0
fix(daemon): add missing CLI commands to agent instructions
2026-04-07 14:23:24 +08:00
Naiyuan Qing
5fe1ec806d docs: add TanStack Query migration plan
Phase 0-5 plan for migrating server state from Zustand to TanStack Query,
extracting headless business logic to core/ directory.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 14:20:43 +08:00
Bohan Jiang
2f63714dba Merge pull request #410 from jtsang4/main
fix(build): include migrate binary in make build
2026-04-07 14:18:57 +08:00
Bohan Jiang
4cf18e122d Merge pull request #413 from cocovs/codex/fix-daemon-pid-minus1
fix(cli): preserve daemon pid before releasing child process
2026-04-07 14:18:12 +08:00
Jiang Bohan
02a7598906 fix(daemon): add missing CLI commands to agent instructions
Add 5 missing commands to buildMetaSkillContent() so agents can
discover them:

Read:
- workspace members — query member IDs for mentions
- repo checkout — listed in command reference, not just prose

Write:
- issue create — create sub-issues and new tasks
- issue assign — assign/unassign issues
- issue comment delete — remove erroneous comments
2026-04-07 14:13:26 +08:00
Junlong
0263ecce9e Docs: fix self hosting local deploy protocol (#433)
* fix: skip Docker check in ensure-postgres.sh when remote DATABASE_URL is set

When DATABASE_URL points to a non-localhost host, the script now skips
all Docker operations and only verifies remote DB connectivity via
pg_isready directly.

* fix: honor DATABASE_URL for remote postgres preflight

* fix(make): clarify stop output for remote database

* docs: add local deployment protocol guidance to SELF_HOSTING.md

Clarify that local deployments without TLS should use http:// and ws://
instead of https:// and wss://.

---------

Co-authored-by: Junlong Liu <junlong.liu@shopee.com>
2026-04-07 14:08:06 +08:00
yihong
d450b3d454 fix: run make test command (#449)
Signed-off-by: yihong0618 <zouzou0208@gmail.com>
2026-04-07 13:52:52 +08:00
Bohan Jiang
f1140222a1 Merge pull request #441 from quake/docs/cli-install-guide
docs: add CLI_INSTALL.md for agent-driven setup and update READMEs
2026-04-07 13:50:06 +08:00
诺墨
66067a267a fix(makefile): binary build missing for migration (#447)
Signed-off-by: 诺墨 <normal@normalcoder.com>
2026-04-07 13:47:05 +08:00
Bohan Jiang
76c6b41033 Merge pull request #453 from multica-ai/agent/j/4c35cc35
docs: add PR template
2026-04-07 13:32:38 +08:00
Jiang Bohan
29507a2e3a docs: add AI prompt field to PR template
Encourage contributors to share the prompt they used when AI tools
were involved, helping reviewers understand intent and enabling
knowledge sharing across the community.
2026-04-07 13:30:54 +08:00
Jiang Bohan
ceec6d3795 docs: add PR template
Adds a structured PR template requiring change description, motivation,
type classification, test plan, and an optional AI disclosure field.
Part of the Phase 1 community management improvements (MUL-320).
2026-04-07 13:23:17 +08:00
Naiyuan Qing
08ba74b399 Merge pull request #309 from multica-ai/agent/lambda/83f444ab
fix(web): navigate to /issues when switching workspaces
2026-04-07 10:30:14 +08:00
Naiyuan Qing
ed7a288946 fix(web): prevent 404 on workspace switch and downgrade 404 log level
- Skip issue refetch when store is cleared during workspace switch by
  tracking which issue was already loaded (loadedIdRef pattern)
- Downgrade 404 responses from logger.error to logger.warn in ApiClient
  since resource-not-found is a normal business response, not a bug

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 10:26:53 +08:00
Naiyuan Qing
a26f9e965b Merge pull request #448 from multica-ai/refactor/agent-live-card-sticky
refactor(web): redesign agent live card sticky behavior
2026-04-07 09:55:08 +08:00
Naiyuan Qing
6574d68d2b refactor(web): redesign agent live card — always sticky with manual toggle
Replace the oscillation-prone IntersectionObserver/sentinel pattern with
a simpler always-sticky collapsible card. The card defaults to collapsed
(mini bar) and users toggle it manually. Outer scroll auto-collapses the
timeline to stay out of the way, with scroll-chaining prevention via
overscroll-behavior-y: contain.

Key changes:
- Remove sentinel, IntersectionObserver, and bidirectional isStuck state
- Always sticky at top-4 with unified info color scheme
- Manual toggle via clickable header with grid-rows animation
- Auto-collapse on outer scroll (one-way, prevents oscillation)
- Consolidate three task-end handlers into single handleTaskEnd
- Add hover interaction (muted-foreground → foreground)
- Add aria-expanded for accessibility

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 09:47:02 +08:00
sunjie21
3bf094ebf7 fix(auth): extend JWT and CloudFront cookie expiration from 72h to 30 days
Reduces login frequency for users by increasing token lifetime.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 21:48:31 +08:00
quake
72da372eba docs: add CLI_INSTALL.md for agent-driven setup and update READMEs
Add a structured installation guide (CLI_INSTALL.md) designed for AI agents
to fetch and execute step-by-step: install CLI, authenticate, and start the
daemon. Update README and README.zh-CN CLI sections with an agent-friendly
paste option alongside the existing manual instructions.

Also fix brew formula name in CLI_AND_DAEMON.md (multica-cli → multica) to
match .goreleaser.yml.
2026-04-06 21:15:30 +09:00
Jiayuan Zhang
5fba76f010 fix(web): remember last selected workspace after re-login (#435)
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 01:18:44 +08:00
LinYushen
09565bc40f Merge pull request #426 from multica-ai/fix/attachment-upload-linking
fix(attachment): use UUIDv7 as S3 key and link attachments on issue/comment creation
2026-04-05 08:04:11 +08:00
yushen
4036d64996 fix(attachment): use UUIDv7 as S3 key and link attachments on issue/comment creation
- Use google/uuid NewV7() for attachment ID and S3 file key instead of
  random hex, so the S3 object name matches the attachment record ID
- Add LinkAttachmentsToIssue query to associate orphaned attachments
  with a newly created issue
- Pass attachment_ids in CreateIssue request so uploads during issue
  creation (before the issue exists) get linked after commit
- Collect and pass attachment IDs in comment-input and reply-input
  so comment creation properly links uploaded files

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 07:55:17 +08:00
LinYushen
5b0a537302 Merge pull request #425 from multica-ai/agent/eve/90e2273d
refactor(cli): improve help UX with examples and arg validation
2026-04-05 07:05:23 +08:00
yushen
0d9d4e6b69 merge: resolve conflicts with origin/main in help.go
Keep branch additions (errSilent, exactArgs, examples template blocks)
that were added in the CLI help UX improvement.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 07:03:03 +08:00
yushen
4c0dbbf1c8 refactor(cli): improve help UX — add examples support, show help on arg errors
- Add EXAMPLES section to leaf and sub help templates (gh CLI style)
- Add example to attachment download command
- Simplify attachment download description
- Show help output when required args are missing (error first, then help)
- Replace cobra.ExactArgs with custom exactArgs that prints help on failure

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 07:00:19 +08:00
devv-eve
52a9a6ae5f refactor(cli): overhaul help output to match gh CLI style (#423)
* refactor(cli): overhaul help output to match gh CLI style

- Add gh-style grouped help with CORE/RUNTIME/ADDITIONAL COMMANDS sections
- Use UPPERCASE section headers (USAGE, FLAGS, EXAMPLES, LEARN MORE)
- Format commands as "name:  description" with automatic alignment
- Add ENVIRONMENT VARIABLES and EXAMPLES sections to root help
- Apply consistent templates to root, subcommand, and leaf commands
- Update descriptions from "Manage X" to "Work with X" for gh parity

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(execenv): add explicit instruction for agents to always use multica CLI

Agents were using curl/wget to access Multica attachment URLs directly,
which fails due to authentication. Add a prominent "Important" section
to the generated CLAUDE.md template that explicitly prohibits direct
HTTP access and instructs agents to escalate missing CLI functionality
to their workspace owner.

---------

Co-authored-by: Devv <devv@Devvs-Mac-mini.local>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 15:30:40 -07:00
Devv
d6a5ba4d5e fix(execenv): add explicit instruction for agents to always use multica CLI
Agents were using curl/wget to access Multica attachment URLs directly,
which fails due to authentication. Add a prominent "Important" section
to the generated CLAUDE.md template that explicitly prohibits direct
HTTP access and instructs agents to escalate missing CLI functionality
to their workspace owner.
2026-04-04 15:27:50 -07:00
Devv
4afef09a03 refactor(cli): overhaul help output to match gh CLI style
- Add gh-style grouped help with CORE/RUNTIME/ADDITIONAL COMMANDS sections
- Use UPPERCASE section headers (USAGE, FLAGS, EXAMPLES, LEARN MORE)
- Format commands as "name:  description" with automatic alignment
- Add ENVIRONMENT VARIABLES and EXAMPLES sections to root help
- Apply consistent templates to root, subcommand, and leaf commands
- Update descriptions from "Manage X" to "Work with X" for gh parity

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 15:10:59 -07:00
Jiayuan Zhang
0771c15a59 fix(trigger): skip parent mention inheritance when reply @mentions only members (#421)
When a reply in a thread explicitly mentions only non-agent entities
(members or issues), do not inherit agent mentions from the parent
comment. This prevents false agent triggers when a user is directing
their reply at other people (e.g. "cc @Someone") rather than requesting
work from agents mentioned in the thread root.

Fixes MUL-324
2026-04-05 04:44:24 +08:00
Jiayuan Zhang
3a96567fc1 fix(web): remove duplicate emoji button on comment card (#419)
* fix(web): remove duplicate emoji button on parent comment card

The parent CommentCard rendered two emoji pickers: one in the header
toolbar (QuickEmojiPicker) and another inside ReactionBar (which has
its own QuickEmojiPicker when hideAddButton is not set). Added
hideAddButton to the parent's ReactionBar, matching the pattern
already used in CommentRow for replies.

* fix(web): show emoji button at bottom for long comments

For short comments, the emoji picker only appears in the top-right
toolbar. For long comments (>500 chars or >8 newlines), the ReactionBar
also shows an add button at the bottom so users don't have to scroll
back up to add reactions.
2026-04-05 04:17:36 +08:00
周阳
9d9e0317c0 fix(web): handle null trigger config in agents page 2026-04-04 22:15:15 +08:00
周阳
5f2ac17129 fix(cli): preserve daemon pid before releasing child process 2026-04-04 21:37:40 +08:00
jtsang4
4df3a52c4e fix(build): include migrate binary in make build 2026-04-04 19:12:17 +08:00
Jiayuan Zhang
9aee403ff9 Merge pull request #405 from multica-ai/agent/lambda/8bf9407f
fix(comment): allow @mention of assigned agent on done/cancelled issues
2026-04-04 13:31:07 +08:00
Jiayuan Zhang
7883fe7bd7 Merge pull request #406 from multica-ai/fix/setup-worktree-env-overwrite
fix: make setup-worktree preserve existing .env.worktree
2026-04-04 13:26:17 +08:00
Jiayuan
cbfb7d58b6 fix: make setup-worktree preserve existing .env.worktree
setup-worktree was using FORCE=1 to unconditionally regenerate
.env.worktree, which overwrites any manual edits (e.g. switching
to the main DB). Now it only generates the file if it doesn't exist.
2026-04-04 13:23:14 +08:00
Jiayuan
2832a06fe3 fix(comment): allow @mention of assigned agent on done/cancelled issues
The assignee was unconditionally skipped in the mention path, assuming
on_comment would handle it. But on_comment is suppressed for terminal
statuses (done/cancelled), so an explicit @mention of the assignee had
no effect. Now only skip the assignee dedup when on_comment will
actually fire (non-terminal status).
2026-04-04 13:14:09 +08:00
Jiang Bohan
2787bd60be docs(web): add v0.1.6 changelog entry for 2026-04-03 2026-04-03 16:38:04 +08:00
Jiang Bohan
e879d82e7d Merge branch 'main' of https://github.com/multica-ai/multica into agent/j/272bc2a3 2026-04-03 16:37:12 +08:00
Jiang Bohan
ad0615a08f docs(web): add v0.1.5 changelog entry for 2026-04-02 2026-04-03 15:38:38 +08:00
Jiayuan
b1f7364097 fix(web): navigate to /issues when switching workspaces
When switching workspaces while on a detail page (e.g. /issues/[id]),
the store clears old data and the page tries to fetch the old resource
with the new workspace context, causing a 404 error. Navigate to the
issues list before switching to avoid referencing stale resources.
2026-04-02 00:15:47 +08:00
162 changed files with 10511 additions and 2630 deletions

View File

@@ -29,6 +29,7 @@ RESEND_FROM_EMAIL=noreply@multica.ai
GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET=
GOOGLE_REDIRECT_URI=http://localhost:3000/auth/callback
NEXT_PUBLIC_GOOGLE_CLIENT_ID=
# S3 / CloudFront
S3_BUCKET=

34
.github/PULL_REQUEST_TEMPLATE.md vendored Normal file
View File

@@ -0,0 +1,34 @@
## 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,65 +24,94 @@ The frontend uses a **feature-based architecture** with four layers:
```
apps/web/
├── app/ # Routing layer (thin shells — import from features/)
├── features/ # Business logic, organized by domain
├── core/ # Headless business logic (TanStack Query, zero JSX, zero react-dom)
├── features/ # UI business components, 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/`.
**`features/`** — Domain modules, each with its own components, hooks, stores, and config:
**`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:
| Feature | Purpose | Exports |
|---|---|---|
| `features/auth/` | Authentication state | `useAuthStore`, `AuthInitializer` |
| `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/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/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:
**`shared/`** — Code used across multiple features (will migrate to `core/` in Phase 5):
- `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
- **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`).
- **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.
- **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.
**Store conventions:**
- One store per feature domain. Import via `useAuthStore(selector)` or `useWorkspaceStore(selector)`.
**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)`.
- Stores must not call `useRouter` or any React hooks — keep navigation in components.
- Cross-store reads use `useOtherStore.getState()` inside actions (not hooks).
- Dependency direction: `workspace``auth`, `realtime``auth`, `issues``workspace`. Never reverse.
- `useWorkspaceStore` manages workspace identity (`workspace`, `workspaces`, `api.setWorkspaceId`, localStorage). Server data (members, agents, skills) is in TanStack Query, not the store.
### Import Aliases
Use `@/` alias (maps to `apps/web/`):
Use `@/` alias (maps to `apps/web/`) and `@core/` alias (maps to `apps/web/core/`):
```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 `@/`.
Within a feature, use relative imports. Between features or to shared, use `@/`. For core modules, use `@core/`.
### Data Flow
```
Browser → ApiClient (shared/api) → REST API (Chi handlers) → sqlc queries → PostgreSQL
Browser ← WSClient (shared/api) ← WebSocket ← Hub.Broadcast() ← Handlers/TaskService
Browser → useQuery (core/) → ApiClient (shared/api) → REST API (Chi handlers) → sqlc queries → PostgreSQL
Browser ← useQuery cache ← invalidateQueries ← WS event handlers ← WSClient ← Hub.Broadcast()
```
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`
@@ -177,7 +206,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. Prefer zustand stores for shared state over React Context.
- 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.
- 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-cli
brew install multica
```
### Build from Source

171
CLI_INSTALL.md Normal file
View File

@@ -0,0 +1,171 @@
# 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,199 +1,44 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
# Open Source License
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
Multica is licensed under a modified version of the Apache License 2.0, with the following additional conditions:
1. Definitions.
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:
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
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.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
- 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.
"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.
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.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
- 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.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
2. As a contributor, you should agree that:
"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.
a. The producer can adjust the open-source agreement to be more strict
or relaxed as deemed necessary.
"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).
b. Your contributed code may be used for commercial purposes, including
but not limited to its cloud business operations.
"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.
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.
"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.
© 2025 Multica, Inc.

View File

@@ -69,7 +69,12 @@ stop:
@echo "Stopping services..."
@-lsof -ti:$(PORT) | xargs kill -9 2>/dev/null
@-lsof -ti:$(FRONTEND_PORT) | xargs kill -9 2>/dev/null
@echo "✓ App processes stopped. Shared PostgreSQL is still running on localhost:5432."
@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
# Full verification: typecheck + unit tests + Go tests + E2E
check:
@@ -98,8 +103,12 @@ check-main:
@ENV_FILE=$(MAIN_ENV_FILE) bash scripts/check.sh
setup-worktree:
@echo "==> Generating $(WORKTREE_ENV_FILE) with unique ports..."
@FORCE=1 bash scripts/init-worktree-env.sh $(WORKTREE_ENV_FILE)
@if [ ! -f "$(WORKTREE_ENV_FILE)" ]; then \
echo "==> Generating $(WORKTREE_ENV_FILE) with unique ports..."; \
bash scripts/init-worktree-env.sh $(WORKTREE_ENV_FILE); \
else \
echo "==> Using existing $(WORKTREE_ENV_FILE)"; \
fi
@$(MAKE) setup ENV_FILE=$(WORKTREE_ENV_FILE)
start-worktree:
@@ -134,10 +143,12 @@ 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.**
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.
The open-source managed agents platform.<br/>
Turn coding agents into real teammates — assign tasks, track progress, compound skills.
[![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 @@ Assign tasks, track progress, compound skills — manage your human + agent work
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. 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. 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**.
<p align="center">
<img src="docs/assets/hero-screenshot.png" alt="Multica board view" width="800">
@@ -39,6 +39,8 @@ 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.
@@ -70,6 +72,14 @@ 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
@@ -148,7 +158,3 @@ 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 @@
**你的下一批员工,不是人类。**
开源平台,将编码 Agent 变成真正的队友。<br/>
分配任务、跟踪进度、积累技能——在一个地方管理你的人类 + Agent 团队
开源的 Managed Agents 平台。<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 出现在看板上、参与对话、随着时间积累可复用的技能。支持 **Claude Code****Codex**
不再需要复制粘贴 prompt不再需要盯着运行过程。你的 Agent 出现在看板上、参与对话、随着时间积累可复用的技能。可以理解为开源的 Managed Agents 基础设施——厂商中立、可自部署、专为人类 + AI 团队设计。支持 **Claude Code****Codex**
<p align="center">
<img src="docs/assets/hero-screenshot.png" alt="Multica 看板视图" width="800">
@@ -39,6 +39,8 @@ Multica 将编码 Agent 变成真正的队友。像分配给同事一样分配
## 功能特性
Multica 管理完整的 Agent 生命周期:从任务分配到执行监控再到技能复用。
- **Agent 即队友** — 像分配给同事一样分配给 Agent。它们有个人档案、出现在看板上、发表评论、创建 Issue、主动报告阻塞问题。
- **自主执行** — 设置后无需管理。完整的任务生命周期管理(排队、认领、执行、完成/失败),通过 WebSocket 实时推送进度。
- **可复用技能** — 每个解决方案都成为全团队可复用的技能。部署、数据库迁移、代码审查——技能让团队能力随时间持续增长。
@@ -70,6 +72,14 @@ 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,8 +257,14 @@ 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
@@ -267,6 +273,8 @@ 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 } from "@/features/auth";
import { useAuthStore, setLoggedInCookie } from "@/features/auth";
import { useWorkspaceStore } from "@/features/workspace";
import { api } from "@/shared/api";
import {
@@ -146,6 +146,10 @@ 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;
@@ -153,7 +157,8 @@ function LoginPageContent() {
await verifyCode(email, value);
const wsList = await api.listWorkspaces();
await hydrateWorkspace(wsList);
const lastWsId = localStorage.getItem("multica_workspace_id");
await hydrateWorkspace(wsList, lastWsId);
router.push(searchParams.get("next") || "/issues");
} catch (err) {
setError(
@@ -281,6 +286,22 @@ 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">
@@ -306,7 +327,7 @@ function LoginPageContent() {
)}
</form>
</CardContent>
<CardFooter>
<CardFooter className="flex flex-col gap-3">
<Button
type="submit"
form="login-form"
@@ -316,6 +337,46 @@ 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,5 +1,6 @@
"use client";
import React from "react";
import Link from "next/link";
import { usePathname, useRouter } from "next/navigation";
import {
@@ -42,7 +43,9 @@ import {
import { Tooltip, TooltipTrigger, TooltipContent } from "@/components/ui/tooltip";
import { useAuthStore } from "@/features/auth";
import { useWorkspaceStore } from "@/features/workspace";
import { useInboxStore } from "@/features/inbox";
import { useQuery } from "@tanstack/react-query";
import { inboxKeys, deduplicateInboxItems } from "@core/inbox/queries";
import { api } from "@/shared/api";
import { useModalStore } from "@/features/modals";
const primaryNav = [
@@ -73,7 +76,16 @@ export function AppSidebar() {
const workspaces = useWorkspaceStore((s) => s.workspaces);
const switchWorkspace = useWorkspaceStore((s) => s.switchWorkspace);
const unreadCount = useInboxStore((s) => s.unreadCount());
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 logout = () => {
router.push("/");
@@ -132,6 +144,7 @@ export function AppSidebar() {
key={ws.id}
onClick={() => {
if (ws.id !== workspace?.id) {
router.push("/issues");
switchWorkspace(ws.id);
}
}}

View File

@@ -8,15 +8,10 @@ import {
Monitor,
Plus,
ListTodo,
Wrench,
FileText,
BookOpenText,
MessageSquare,
Timer,
Trash2,
Save,
Key,
Link2,
Clock,
CheckCircle2,
XCircle,
@@ -35,9 +30,6 @@ import type {
Agent,
AgentStatus,
AgentVisibility,
AgentTool,
AgentTrigger,
AgentTriggerType,
AgentTask,
RuntimeDevice,
CreateAgentRequest,
@@ -75,8 +67,11 @@ import { Skeleton } from "@/components/ui/skeleton";
import { api } from "@/shared/api";
import { useAuthStore } from "@/features/auth";
import { useWorkspaceStore } from "@/features/workspace";
import { useRuntimeStore } from "@/features/runtimes";
import { useIssueStore } from "@/features/issues";
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 { ActorAvatar } from "@/components/common/actor-avatar";
import { useFileUpload } from "@/shared/hooks/use-file-upload";
@@ -148,10 +143,6 @@ 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) {
@@ -443,8 +434,9 @@ function SkillsTab({
}: {
agent: Agent;
}) {
const workspaceSkills = useWorkspaceStore((s) => s.skills);
const refreshAgents = useWorkspaceStore((s) => s.refreshAgents);
const qc = useQueryClient();
const wsId = useWorkspaceId();
const { data: workspaceSkills = [] } = useQuery(skillListOptions(wsId));
const [saving, setSaving] = useState(false);
const [showPicker, setShowPicker] = useState(false);
@@ -456,7 +448,7 @@ function SkillsTab({
try {
const newIds = [...agent.skills.map((s) => s.id), skillId];
await api.setAgentSkills(agent.id, { skill_ids: newIds });
await refreshAgents();
qc.invalidateQueries({ queryKey: workspaceKeys.agents(wsId) });
} catch (e) {
toast.error(e instanceof Error ? e.message : "Failed to add skill");
} finally {
@@ -470,7 +462,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 });
await refreshAgents();
qc.invalidateQueries({ queryKey: workspaceKeys.agents(wsId) });
} catch (e) {
toast.error(e instanceof Error ? e.message : "Failed to remove skill");
} finally {
@@ -596,459 +588,6 @@ 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
// ---------------------------------------------------------------------------
@@ -1056,7 +595,8 @@ function TriggersTab({
function TasksTab({ agent }: { agent: Agent }) {
const [tasks, setTasks] = useState<AgentTask[]>([]);
const [loading, setLoading] = useState(true);
const issues = useIssueStore((s) => s.issues);
const wsId = useWorkspaceId();
const { data: issues = [] } = useQuery(issueListOptions(wsId));
useEffect(() => {
setLoading(true);
@@ -1359,13 +899,11 @@ function SettingsTab({
// Agent Detail
// ---------------------------------------------------------------------------
type DetailTab = "instructions" | "skills" | "tools" | "triggers" | "tasks" | "settings";
type DetailTab = "instructions" | "skills" | "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 },
];
@@ -1479,18 +1017,6 @@ 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
@@ -1544,21 +1070,17 @@ function AgentDetail({
export default function AgentsPage() {
const isLoading = useAuthStore((s) => s.isLoading);
const workspace = useWorkspaceStore((s) => s.workspace);
const agents = useWorkspaceStore((s) => s.agents);
const refreshAgents = useWorkspaceStore((s) => s.refreshAgents);
const qc = useQueryClient();
const wsId = useWorkspaceId();
const { data: agents = [] } = useQuery(agentListOptions(wsId));
const [selectedId, setSelectedId] = useState<string>("");
const [showArchived, setShowArchived] = useState(false);
const [showCreate, setShowCreate] = useState(false);
const runtimes = useRuntimeStore((s) => s.runtimes);
const fetchRuntimes = useRuntimeStore((s) => s.fetchRuntimes);
const { data: runtimes = [] } = useQuery(runtimeListOptions(wsId));
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],
@@ -1575,14 +1097,14 @@ export default function AgentsPage() {
const handleCreate = async (data: CreateAgentRequest) => {
const agent = await api.createAgent(data);
await refreshAgents();
qc.invalidateQueries({ queryKey: workspaceKeys.agents(wsId) });
setSelectedId(agent.id);
};
const handleUpdate = async (id: string, data: Record<string, unknown>) => {
try {
await api.updateAgent(id, data as UpdateAgentRequest);
await refreshAgents();
qc.invalidateQueries({ queryKey: workspaceKeys.agents(wsId) });
toast.success("Agent updated");
} catch (e) {
toast.error(e instanceof Error ? e.message : "Failed to update agent");
@@ -1593,7 +1115,7 @@ export default function AgentsPage() {
const handleArchive = async (id: string) => {
try {
await api.archiveAgent(id);
await refreshAgents();
qc.invalidateQueries({ queryKey: workspaceKeys.agents(wsId) });
toast.success("Agent archived");
} catch (e) {
toast.error(e instanceof Error ? e.message : "Failed to archive agent");
@@ -1603,7 +1125,7 @@ export default function AgentsPage() {
const handleRestore = async (id: string) => {
try {
await api.restoreAgent(id);
await refreshAgents();
qc.invalidateQueries({ queryKey: workspaceKeys.agents(wsId) });
toast.success("Agent restored");
} catch (e) {
toast.error(e instanceof Error ? e.message : "Failed to restore agent");

View File

@@ -1,9 +1,22 @@
"use client";
import { useState, useEffect, useCallback } from "react";
import { useState, useEffect, useCallback, useMemo } from "react";
import { useSearchParams } from "next/navigation";
import { useDefaultLayout } from "react-resizable-panels";
import { useInboxStore } from "@/features/inbox";
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 { IssueDetail, StatusIcon, PriorityIcon } from "@/features/issues/components";
import { STATUS_CONFIG, PRIORITY_CONFIG } from "@/features/issues/config";
import { useActorName } from "@/features/workspace";
@@ -33,7 +46,6 @@ import {
DropdownMenuItem,
DropdownMenuSeparator,
} from "@/components/ui/dropdown-menu";
import { api } from "@/shared/api";
// ---------------------------------------------------------------------------
// Helpers
@@ -235,8 +247,9 @@ export default function InboxPage() {
window.history.replaceState(null, "", url);
}, []);
const items = useInboxStore((s) => s.dedupedItems());
const loading = useInboxStore((s) => s.loading);
const wsId = useWorkspaceId();
const { data: rawItems = [], isLoading: loading } = useQuery(inboxListOptions(wsId));
const items = useMemo(() => deduplicateInboxItems(rawItems), [rawItems]);
const { defaultLayout, onLayoutChanged } = useDefaultLayout({
id: "multica_inbox_layout",
@@ -245,74 +258,58 @@ 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 = async (item: InboxItem) => {
const handleSelect = (item: InboxItem) => {
setSelectedKey(item.issue_id ?? item.id);
if (!item.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");
}
markReadMutation.mutate(item.id, {
onError: () => toast.error("Failed to mark as read"),
});
}
};
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");
}
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"),
});
};
// Batch operations
const handleMarkAllRead = async () => {
try {
useInboxStore.getState().markAllRead();
await api.markAllInboxRead();
} catch {
toast.error("Failed to mark all as read");
useInboxStore.getState().fetch();
}
const handleMarkAllRead = () => {
markAllReadMutation.mutate(undefined, {
onError: () => toast.error("Failed to mark all as read"),
});
};
const handleArchiveAll = async () => {
try {
useInboxStore.getState().archiveAll();
setSelectedKey("");
await api.archiveAllInbox();
} catch {
toast.error("Failed to archive all");
useInboxStore.getState().fetch();
}
const handleArchiveAll = () => {
setSelectedKey("");
archiveAllMutation.mutate(undefined, {
onError: () => toast.error("Failed to archive all"),
});
};
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 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 handleArchiveCompleted = async () => {
try {
await api.archiveCompletedInbox();
setSelectedKey("");
await useInboxStore.getState().fetch();
} catch {
toast.error("Failed to archive completed");
}
const handleArchiveCompleted = () => {
setSelectedKey("");
archiveCompletedMutation.mutate(undefined, {
onError: () => toast.error("Failed to archive completed"),
});
};
if (loading) {

View File

@@ -2,6 +2,7 @@ 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
@@ -62,34 +63,11 @@ vi.mock("@/features/workspace", () => ({
}),
}));
// 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",
},
]);
// Mock issue store — only client state remains (activeIssueId)
vi.mock("@/features/issues", () => ({
useIssueStore: Object.assign(
(selector: (s: any) => any) => selector({ issues: stableStoreIssues }),
{ getState: () => ({ issues: stableStoreIssues, addIssue: vi.fn(), updateIssue: vi.fn(), removeIssue: vi.fn() }) },
(selector: (s: any) => any) => selector({ activeIssueId: null }),
{ getState: () => ({ activeIssueId: null, setActiveIssue: vi.fn() }) },
),
}));
@@ -106,6 +84,9 @@ 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 || "");
@@ -182,9 +163,10 @@ vi.mock("@/shared/api", () => ({
listIssueSubscribers: vi.fn().mockResolvedValue([]),
subscribeToIssue: vi.fn().mockResolvedValue(undefined),
unsubscribeFromIssue: vi.fn().mockResolvedValue(undefined),
getActiveTaskForIssue: vi.fn().mockResolvedValue({ task: null }),
getActiveTasksForIssue: vi.fn().mockResolvedValue({ tasks: [] }),
listTasksByIssue: vi.fn().mockResolvedValue([]),
listTaskMessages: vi.fn().mockResolvedValue([]),
listChildIssues: vi.fn().mockResolvedValue({ issues: [] }),
},
}));
@@ -235,14 +217,26 @@ 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(
<Suspense fallback={<div>Suspense loading...</div>}>
<IssueDetailPage params={Promise.resolve({ id })} />
</Suspense>,
<QueryClientProvider client={queryClient}>
<Suspense fallback={<div>Suspense loading...</div>}>
<IssueDetailPage params={Promise.resolve({ id })} />
</Suspense>
</QueryClientProvider>,
);
});
return result!;

View File

@@ -1,6 +1,7 @@
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
@@ -61,36 +62,28 @@ 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: vi.fn().mockResolvedValue({ issues: [], total: 0 }),
listIssues: (...args: any[]) => mockListIssues(...args),
updateIssue: (...args: any[]) => mockUpdateIssue(...args),
},
}));
// 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;
};
// Mock issue store — only client state remains
const mockIssueClientState = { activeIssueId: null, setActiveIssue: vi.fn() };
vi.mock("@/features/issues/store", () => ({
useIssueStore: Object.assign(
(selector?: any) => (selector ? selector(mockStoreState) : mockStoreState),
{ getState: () => mockStoreState },
(selector?: any) => (selector ? selector(mockIssueClientState) : mockIssueClientState),
{ getState: () => mockIssueClientState },
),
}));
vi.mock("@/features/issues", () => ({
useIssueStore: Object.assign(
(selector?: any) => (selector ? selector(mockStoreState) : mockStoreState),
{ getState: () => mockStoreState },
(selector?: any) => (selector ? selector(mockIssueClientState) : mockIssueClientState),
{ getState: () => mockIssueClientState },
),
StatusIcon: () => null,
PriorityIcon: () => null,
@@ -282,90 +275,80 @@ 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();
mockStoreState = {
issues: [],
loading: true,
fetch: vi.fn(),
setIssues: vi.fn(),
addIssue: vi.fn(),
updateIssue: vi.fn(),
removeIssue: vi.fn(),
};
mockListIssues.mockResolvedValue({ issues: [], total: 0 });
mockViewState.viewMode = "board";
mockViewState.statusFilters = [];
mockViewState.priorityFilters = [];
});
it("shows loading state initially", () => {
mockStoreState.loading = true;
mockStoreState.issues = [];
render(<IssuesPage />);
renderWithQuery(<IssuesPage />);
expect(screen.getAllByRole("generic").some(el => el.getAttribute("data-slot") === "skeleton")).toBe(true);
});
it("renders issues in board view after loading", async () => {
mockStoreState.loading = false;
mockStoreState.issues = mockIssues;
// 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 }),
);
render(<IssuesPage />);
renderWithQuery(<IssuesPage />);
expect(screen.getByText("Implement auth")).toBeInTheDocument();
await screen.findByText("Implement auth");
expect(screen.getByText("Design landing page")).toBeInTheDocument();
expect(screen.getByText("Write tests")).toBeInTheDocument();
});
it("renders board columns", async () => {
mockStoreState.loading = false;
mockStoreState.issues = mockIssues;
mockListIssues.mockImplementation((params: any) =>
Promise.resolve(params?.open_only ? { issues: mockIssues, total: mockIssues.length } : { issues: [], total: 0 }),
);
render(<IssuesPage />);
renderWithQuery(<IssuesPage />);
expect(screen.getAllByText("Backlog").length).toBeGreaterThanOrEqual(1);
await screen.findByText("Backlog");
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", () => {
mockStoreState.loading = false;
mockStoreState.issues = [];
it("shows workspace breadcrumb", async () => {
renderWithQuery(<IssuesPage />);
render(<IssuesPage />);
expect(screen.getByText("Issues")).toBeInTheDocument();
await screen.findByText("Issues");
});
it("shows scope buttons", () => {
mockStoreState.loading = false;
mockStoreState.issues = [];
it("shows scope buttons", async () => {
renderWithQuery(<IssuesPage />);
render(<IssuesPage />);
expect(screen.getByText("All")).toBeInTheDocument();
await screen.findByText("All");
expect(screen.getByText("Members")).toBeInTheDocument();
expect(screen.getByText("Agents")).toBeInTheDocument();
});
it("shows filter and display icon buttons", () => {
mockStoreState.loading = false;
mockStoreState.issues = mockIssues;
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 }),
);
render(<IssuesPage />);
renderWithQuery(<IssuesPage />);
// Filter and Display are now icon-only buttons, verify they render as buttons
await screen.findByText("Implement auth");
const buttons = screen.getAllByRole("button");
expect(buttons.length).toBeGreaterThan(0);
});
it("shows empty board view when no issues exist", () => {
mockStoreState.loading = false;
mockStoreState.issues = [];
render(<IssuesPage />);
renderWithQuery(<IssuesPage />);
// Should still render the board/list view, not a "no issues" message
expect(screen.queryByText("No matching issues")).not.toBeInTheDocument();

View File

@@ -0,0 +1,28 @@
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,8 +36,11 @@ 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 }> = {
@@ -140,8 +143,9 @@ function MemberRow({
export function MembersTab() {
const user = useAuthStore((s) => s.user);
const workspace = useWorkspaceStore((s) => s.workspace);
const members = useWorkspaceStore((s) => s.members);
const refreshMembers = useWorkspaceStore((s) => s.refreshMembers);
const qc = useQueryClient();
const wsId = useWorkspaceId();
const { data: members = [] } = useQuery(memberListOptions(wsId));
const [inviteEmail, setInviteEmail] = useState("");
const [inviteRole, setInviteRole] = useState<MemberRole>("member");
@@ -168,7 +172,7 @@ export function MembersTab() {
});
setInviteEmail("");
setInviteRole("member");
await refreshMembers();
qc.invalidateQueries({ queryKey: workspaceKeys.members(wsId) });
toast.success("Member added");
} catch (e) {
toast.error(e instanceof Error ? e.message : "Failed to add member");
@@ -182,7 +186,7 @@ export function MembersTab() {
setMemberActionId(memberId);
try {
await api.updateMember(workspace.id, memberId, { role });
await refreshMembers();
qc.invalidateQueries({ queryKey: workspaceKeys.members(wsId) });
toast.success("Role updated");
} catch (e) {
toast.error(e instanceof Error ? e.message : "Failed to update member");
@@ -201,7 +205,7 @@ export function MembersTab() {
setMemberActionId(member.id);
try {
await api.deleteMember(workspace.id, member.id);
await refreshMembers();
qc.invalidateQueries({ queryKey: workspaceKeys.members(wsId) });
toast.success("Member removed");
} catch (e) {
toast.error(e instanceof Error ? e.message : "Failed to remove member");

View File

@@ -6,15 +6,19 @@ 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 members = useWorkspaceStore((s) => s.members);
const wsId = useWorkspaceId();
const { data: members = [] } = useQuery(memberListOptions(wsId));
const updateWorkspace = useWorkspaceStore((s) => s.updateWorkspace);
const [repos, setRepos] = useState<WorkspaceRepo[]>(workspace?.repos ?? []);

View File

@@ -18,14 +18,18 @@ 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 members = useWorkspaceStore((s) => s.members);
const wsId = useWorkspaceId();
const { data: members = [] } = useQuery(memberListOptions(wsId));
const updateWorkspace = useWorkspaceStore((s) => s.updateWorkspace);
const leaveWorkspace = useWorkspaceStore((s) => s.leaveWorkspace);
const deleteWorkspace = useWorkspaceStore((s) => s.deleteWorkspace);

View File

@@ -0,0 +1,90 @@
"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,12 +1,13 @@
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" });
@@ -50,28 +51,27 @@ export const metadata: Metadata = {
},
};
export default async function RootLayout({
export default 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={lang}
lang="en"
suppressHydrationWarning
className={cn("antialiased font-sans h-full", geist.variable, geistMono.variable)}
>
<body className="h-full overflow-hidden">
<LocaleSync />
<ThemeProvider>
<AuthInitializer>
<WSProvider>{children}</WSProvider>
</AuthInitializer>
<ModalRegistry />
<Toaster />
<QueryProvider>
<AuthInitializer>
<WSProvider>{children}</WSProvider>
</AuthInitializer>
<ModalRegistry />
<Toaster />
</QueryProvider>
</ThemeProvider>
</body>
</html>

View File

@@ -2,9 +2,11 @@
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 { useWorkspaceStore } from "@/features/workspace";
import { useWorkspaceId } from "@core/hooks";
import { memberListOptions, agentListOptions } from "@core/workspace/queries";
interface MentionHoverCardProps {
type: string;
@@ -13,8 +15,9 @@ interface MentionHoverCardProps {
}
function MentionHoverCard({ type, id, children }: MentionHoverCardProps) {
const members = useWorkspaceStore((s) => s.members);
const agents = useWorkspaceStore((s) => s.agents);
const wsId = useWorkspaceId();
const { data: members = [] } = useQuery(memberListOptions(wsId));
const { data: agents = [] } = useQuery(agentListOptions(wsId));
if (type === "all") {
return (

View File

@@ -0,0 +1,20 @@
"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;
}

17
apps/web/core/hooks.ts Normal file
View File

@@ -0,0 +1,17 @@
"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

@@ -0,0 +1,16 @@
export {
inboxKeys,
inboxListOptions,
deduplicateInboxItems,
} from "./queries";
export {
useMarkInboxRead,
useArchiveInbox,
useMarkAllInboxRead,
useArchiveAllInbox,
useArchiveAllReadInbox,
useArchiveCompletedInbox,
} from "./mutations";
export { onInboxNew, onInboxInvalidate, onInboxIssueStatusChanged } from "./ws-updaters";

View File

@@ -0,0 +1,113 @@
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

@@ -0,0 +1,43 @@
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

@@ -0,0 +1,30 @@
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) });
}

3
apps/web/core/index.ts Normal file
View File

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

View File

@@ -0,0 +1,29 @@
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

@@ -0,0 +1,495 @@
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

@@ -0,0 +1,81 @@
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

@@ -0,0 +1,97 @@
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

@@ -0,0 +1,17 @@
"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

@@ -0,0 +1,18 @@
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

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

View File

@@ -0,0 +1,14 @@
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

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

View File

@@ -0,0 +1,34 @@
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

@@ -0,0 +1,39 @@
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,2 +1,3 @@
export { useAuthStore } from "./store";
export { AuthInitializer } from "./initializer";
export { setLoggedInCookie } from "./auth-cookie";

View File

@@ -12,6 +12,7 @@ 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;
}
@@ -36,7 +37,6 @@ export const useAuthStore = create<AuthState>((set) => ({
api.setToken(null);
api.setWorkspaceId(null);
localStorage.removeItem("multica_token");
localStorage.removeItem("multica_workspace_id");
set({ user: null, isLoading: false });
}
},
@@ -54,9 +54,17 @@ 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");
localStorage.removeItem("multica_workspace_id");
api.setToken(null);
api.setWorkspaceId(null);
clearLoggedInCookie();

View File

@@ -353,7 +353,8 @@
font-style: italic;
}
.rich-text-editor s {
.rich-text-editor s,
.rich-text-editor del {
text-decoration: line-through;
color: var(--muted-foreground);
}
@@ -370,16 +371,46 @@
vertical-align: middle;
}
/* Images — shared styling for both editing and readonly */
/* Block-level NodeViews (fileCard) need to override the inline default above */
.rich-text-editor .file-card-node {
display: block !important;
}
/* Images — generic fallback (non-NodeView contexts) */
.rich-text-editor img {
max-width: 100%;
height: auto;
border-radius: var(--radius);
margin: 0.5rem 0;
}
/* Uploading image placeholder — data-uploading attribute managed by ProseMirror schema */
.rich-text-editor img[data-uploading] {
opacity: 0.5;
/* Image NodeView — centered block with max-width cap */
.rich-text-editor .image-node {
display: block !important;
text-align: center;
}
.rich-text-editor .image-figure {
position: relative;
display: inline-block;
max-width: min(100%, 640px);
margin: 0.75rem 0;
}
.rich-text-editor .image-figure.image-selected .image-content {
outline: 2px solid var(--brand);
outline-offset: 2px;
}
.rich-text-editor .image-content {
display: block;
width: 100%;
height: auto;
border-radius: var(--radius);
}
.rich-text-editor .image-uploading {
opacity: 0.5;
animation: rte-upload-pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
}
@@ -387,3 +418,63 @@
0%, 100% { opacity: 0.5; }
50% { opacity: 0.3; }
}
/* Readonly — zoom cursor on clickable images */
.rich-text-editor.readonly .image-figure {
cursor: zoom-in;
}
/* Image toolbar — dark pill, top-right corner, appears on hover */
.rich-text-editor .image-toolbar {
position: absolute;
top: 0.5rem;
right: 0.5rem;
display: flex;
gap: 1px;
padding: 0.25rem;
background: color-mix(in srgb, black 75%, transparent);
backdrop-filter: blur(8px);
border-radius: var(--radius);
opacity: 0;
transition: opacity 0.15s;
z-index: 1;
}
.image-figure:hover .image-toolbar {
opacity: 1;
}
.rich-text-editor .image-toolbar button {
display: flex;
align-items: center;
justify-content: center;
width: 1.75rem;
height: 1.75rem;
border-radius: calc(var(--radius) - 2px);
color: white;
transition: background 0.15s;
}
.rich-text-editor .image-toolbar button:hover {
background: color-mix(in srgb, white 15%, transparent);
}
/* Drag-and-drop overlay */
.editor-drop-overlay {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
border: 2px dashed color-mix(in srgb, var(--brand) 40%, transparent);
border-radius: var(--radius);
background: color-mix(in srgb, var(--brand) 3%, transparent);
z-index: 10;
pointer-events: none;
}
.editor-drop-overlay p {
font-size: 0.875rem;
font-weight: 500;
color: color-mix(in srgb, var(--brand) 60%, transparent);
}

View File

@@ -30,10 +30,12 @@ import {
useEffect,
useImperativeHandle,
useRef,
useState,
} from "react";
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";
@@ -81,6 +83,7 @@ const ContentEditor = forwardRef<ContentEditorRef, ContentEditorProps>(
},
ref,
) {
const [dragOver, setDragOver] = useState(false);
const debounceRef = useRef<ReturnType<typeof setTimeout>>(undefined);
const onUpdateRef = useRef(onUpdate);
const onSubmitRef = useRef(onSubmit);
@@ -94,6 +97,8 @@ const ContentEditor = forwardRef<ContentEditorRef, ContentEditorProps>(
onBlurRef.current = onBlur;
onUploadFileRef.current = onUploadFile;
const queryClient = useQueryClient();
const editor = useEditor({
immediatelyRender: false,
editable,
@@ -102,6 +107,7 @@ const ContentEditor = forwardRef<ContentEditorRef, ContentEditorProps>(
extensions: createEditorExtensions({
editable,
placeholder: placeholderText,
queryClient,
onSubmitRef,
onUploadFileRef,
}),
@@ -160,6 +166,17 @@ const ContentEditor = forwardRef<ContentEditorRef, ContentEditorProps>(
};
}, []);
// Always clear drag overlay on any drop/dragend anywhere in the document
useEffect(() => {
const clear = () => setDragOver(false);
document.addEventListener("drop", clear);
document.addEventListener("dragend", clear);
return () => {
document.removeEventListener("drop", clear);
document.removeEventListener("dragend", clear);
};
}, []);
// Readonly content update: when defaultValue changes and editor is readonly,
// re-set the content (e.g. after editing a comment, the readonly view updates)
useEffect(() => {
@@ -191,7 +208,47 @@ const ContentEditor = forwardRef<ContentEditorRef, ContentEditorProps>(
if (!editor) return null;
return <EditorContent editor={editor} />;
return (
<div
className={cn("relative min-h-full", dragOver && "editor-drag-over")}
onDragEnter={(e) => {
e.preventDefault();
if (editable && e.dataTransfer.types.includes("Files"))
setDragOver(true);
}}
onDragOver={(e) => {
e.preventDefault();
}}
onDragLeave={(e) => {
if (!e.currentTarget.contains(e.relatedTarget as Node))
setDragOver(false);
}}
onDrop={(e) => {
const alreadyHandled = e.nativeEvent.defaultPrevented;
e.preventDefault();
setDragOver(false);
// Only upload if ProseMirror didn't already handle the drop.
// When drop lands on the editor area, ProseMirror's handleDrop
// processes it and calls preventDefault on the native event.
// This fallback only fires when the overlay intercepted the drop.
if (alreadyHandled) return;
const files = e.dataTransfer?.files;
if (files?.length && editor && onUploadFileRef.current) {
const endPos = editor.state.doc.content.size;
for (const file of Array.from(files)) {
uploadAndInsertFile(editor, file, onUploadFileRef.current, endPos);
}
}
}}
>
<EditorContent editor={editor} />
{dragOver && (
<div className="editor-drop-overlay">
<p>Drop files to upload</p>
</div>
)}
</div>
);
},
);

View File

@@ -0,0 +1,166 @@
"use client";
/**
* FileCard — Tiptap node extension for rendering uploaded non-image files
* as styled cards instead of plain markdown links.
*
* Markdown serialization: `[filename](href)` — standard link syntax.
* Preprocessing in preprocess.ts converts standalone CDN file links back
* to fileCard HTML on load, completing the roundtrip.
*/
import { Node, mergeAttributes } from "@tiptap/core";
import { ReactNodeViewRenderer, NodeViewWrapper } from "@tiptap/react";
import type { NodeViewProps } from "@tiptap/react";
import { FileText, Loader2, Download } from "lucide-react";
// ---------------------------------------------------------------------------
// CDN URL detection
// ---------------------------------------------------------------------------
const IMAGE_EXTS = /\.(png|jpe?g|gif|webp|svg|ico|bmp|tiff?)$/i;
/** Check if a URL points to our upload CDN (CloudFront or S3 bucket). */
export function isCdnUrl(url: string): boolean {
try {
const u = new URL(url);
return (
u.hostname.endsWith(".copilothub.ai") ||
u.hostname.endsWith(".amazonaws.com")
);
} catch {
return false;
}
}
/** Check if a CDN URL is a non-image file that should render as a file card. */
export function isFileCardUrl(url: string): boolean {
return isCdnUrl(url) && !IMAGE_EXTS.test(new URL(url).pathname);
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function formatFileSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
// ---------------------------------------------------------------------------
// React NodeView
// ---------------------------------------------------------------------------
function FileCardView({ node }: NodeViewProps) {
const href = (node.attrs.href as string) || "";
const filename = (node.attrs.filename as string) || "";
const fileSize = node.attrs.fileSize as number;
const uploading = node.attrs.uploading as boolean;
const openFile = () => {
window.open(href, "_blank", "noopener,noreferrer");
};
return (
<NodeViewWrapper as="div" className="file-card-node" data-type="fileCard">
{/* eslint-disable-next-line jsx-a11y/no-static-element-interactions */}
<div
className="my-1 flex items-center gap-2 rounded-md border border-border bg-muted/50 px-2.5 py-1 transition-colors hover:bg-muted"
contentEditable={false}
onMouseDown={(e) => e.stopPropagation()}
>
{uploading ? (
<Loader2 className="size-4 shrink-0 animate-spin text-muted-foreground" />
) : (
<FileText className="size-4 shrink-0 text-muted-foreground" />
)}
<div className="min-w-0 flex-1">
<p className="truncate text-sm">{uploading ? `Uploading ${filename}` : filename}</p>
</div>
{!uploading && href && (
<button
type="button"
className="shrink-0 rounded-md p-1 text-muted-foreground transition-colors hover:bg-secondary hover:text-foreground"
onMouseDown={(e) => {
e.preventDefault();
e.stopPropagation();
openFile();
}}
>
<Download className="size-3.5" />
</button>
)}
</div>
</NodeViewWrapper>
);
}
// ---------------------------------------------------------------------------
// Tiptap Node Extension
// ---------------------------------------------------------------------------
export const FileCardExtension = Node.create({
name: "fileCard",
group: "block",
atom: true,
addAttributes() {
return {
href: {
default: "",
rendered: false, // Don't put href on DOM — prevents link behavior
},
filename: {
default: "",
rendered: false,
},
fileSize: {
default: 0,
rendered: false,
},
uploading: {
default: false,
rendered: false,
},
uploadId: {
default: null,
rendered: false,
},
};
},
parseHTML() {
return [
{
tag: 'div[data-type="fileCard"]',
getAttrs: (el) => ({
href: (el as HTMLElement).getAttribute("data-href"),
filename: (el as HTMLElement).getAttribute("data-filename"),
}),
},
];
},
renderHTML({ node, HTMLAttributes }) {
return [
"div",
mergeAttributes(HTMLAttributes, {
"data-type": "fileCard",
"data-href": node.attrs.href,
"data-filename": node.attrs.filename,
}),
];
},
// Markdown serialization: fileCard → [filename](href)
renderMarkdown: (node: any) => {
const { href, filename } = node.attrs || {};
return `[${filename || "file"}](${href})`;
},
addNodeView() {
return ReactNodeViewRenderer(FileCardView);
},
});

View File

@@ -2,6 +2,42 @@ import { Extension } from "@tiptap/core";
import { Plugin, PluginKey } from "@tiptap/pm/state";
import type { UploadResult } from "@/shared/hooks/use-file-upload";
/** Find and remove a fileCard node by uploadId. */
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function removeUploadingFileCard(editor: any, uploadId: string) {
const { tr } = editor.state;
let deleted = false;
editor.state.doc.descendants((node: any, pos: number) => {
if (deleted) return false;
if (node.type.name === "fileCard" && node.attrs.uploadId === uploadId) {
tr.delete(pos, pos + node.nodeSize);
deleted = true;
return false;
}
});
if (deleted) editor.view.dispatch(tr);
}
/** Update a fileCard node from uploading state to final state with real URL. */
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function finalizeFileCard(editor: any, uploadId: string, href: string) {
const { tr } = editor.state;
let updated = false;
editor.state.doc.descendants((node: any, nodePos: number) => {
if (updated) return false;
if (node.type.name === "fileCard" && node.attrs.uploadId === uploadId) {
tr.setNodeMarkup(nodePos, undefined, {
...node.attrs,
href,
uploading: false,
});
updated = true;
return false;
}
});
if (updated) editor.view.dispatch(tr);
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function removeImageBySrc(editor: any, src: string) {
if (!editor) return;
@@ -44,7 +80,9 @@ export async function uploadAndInsertFile(
const result = await handler(file);
if (result) {
const { tr } = editor.state;
let found = false;
editor.state.doc.descendants((node: { type: { name: string }; attrs: { src: string } }, nodePos: number) => {
if (found) return false;
if (node.type.name === "image" && node.attrs.src === blobUrl) {
tr.setNodeMarkup(nodePos, undefined, {
...node.attrs,
@@ -52,9 +90,11 @@ export async function uploadAndInsertFile(
alt: result.filename,
uploading: false,
});
found = true;
return false;
}
});
editor.view.dispatch(tr);
if (found) editor.view.dispatch(tr);
} else {
removeImageBySrc(editor, blobUrl);
}
@@ -64,14 +104,25 @@ export async function uploadAndInsertFile(
URL.revokeObjectURL(blobUrl);
}
} else {
// Non-image: upload first, then insert link
const result = await handler(file);
if (!result) return;
const linkText = `[${result.filename}](${result.link})`;
// Non-image: insert skeleton fileCard → upload finalize with real URL
const uploadId = crypto.randomUUID();
const cardAttrs = { filename: file.name, href: "", fileSize: file.size, uploading: true, uploadId };
const insertContent = { type: "fileCard", attrs: cardAttrs };
if (pos !== undefined) {
editor.chain().focus().insertContentAt(pos, linkText).run();
editor.chain().focus().insertContentAt(pos, insertContent).run();
} else {
editor.chain().focus().insertContent(linkText).run();
editor.chain().focus().insertContent(insertContent).run();
}
try {
const result = await handler(file);
if (result) {
finalizeFileCard(editor, uploadId, result.link);
} else {
removeUploadingFileCard(editor, uploadId);
}
} catch {
removeUploadingFileCard(editor, uploadId);
}
}
}
@@ -104,11 +155,21 @@ export function createFileUploadExtension(
handleFiles(files);
return true;
},
handleDrop(_view, event) {
const files = (event as DragEvent).dataTransfer?.files;
handleDrop(view, event) {
const dragEvent = event as DragEvent;
const files = dragEvent.dataTransfer?.files;
if (!files?.length) return false;
if (!onUploadFileRef.current) return false;
handleFiles(files);
const handler = onUploadFileRef.current;
if (!handler) return false;
// Resolve drop position from mouse coordinates.
// Only the first file uses the drop position; subsequent files
// append to the end to avoid stale position issues.
const dropPos = view.posAtCoords({ left: dragEvent.clientX, top: dragEvent.clientY });
const fileArray = Array.from(files);
for (let i = 0; i < fileArray.length; i++) {
const insertPos = i === 0 ? dropPos?.pos : undefined;
uploadAndInsertFile(editor, fileArray[i]!, handler, insertPos);
}
return true;
},
},

View File

@@ -0,0 +1,146 @@
"use client";
import { useEffect, useState } from "react";
import { createPortal } from "react-dom";
import { NodeViewWrapper } from "@tiptap/react";
import type { NodeViewProps } from "@tiptap/react";
import {
Maximize2,
Download,
Link as LinkIcon,
Trash2,
} from "lucide-react";
import { toast } from "sonner";
import { cn } from "@/lib/utils";
// ---------------------------------------------------------------------------
// Lightbox — full-screen image preview (ESC or click backdrop to close)
// ---------------------------------------------------------------------------
function ImageLightbox({
src,
alt,
onClose,
}: {
src: string;
alt: string;
onClose: () => void;
}) {
useEffect(() => {
const handler = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
document.addEventListener("keydown", handler);
return () => document.removeEventListener("keydown", handler);
}, [onClose]);
return createPortal(
// eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/80 cursor-zoom-out"
onClick={onClose}
>
{/* eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions */}
<img
src={src}
alt={alt}
className="max-h-[90vh] max-w-[90vw] rounded-lg object-contain"
onClick={(e) => e.stopPropagation()}
/>
</div>,
document.body,
);
}
// ---------------------------------------------------------------------------
// Image NodeView — renders img with hover toolbar + lightbox
// ---------------------------------------------------------------------------
function ImageView({ node, editor, selected, deleteNode }: NodeViewProps) {
const src = node.attrs.src as string;
const alt = (node.attrs.alt as string) || "";
const title = node.attrs.title as string | undefined;
const uploading = node.attrs.uploading as boolean;
const [lightbox, setLightbox] = useState(false);
const isEditable = editor.isEditable;
const handleView = () => setLightbox(true);
const handleDownload = () => {
// Cross-origin CDN images can't be fetched as blob (CORS),
// and <a download> is ignored for cross-origin URLs.
// Open in new tab — user can right-click → Save As.
window.open(src, "_blank", "noopener,noreferrer");
};
const handleCopyLink = async () => {
try {
await navigator.clipboard.writeText(src);
toast.success("Link copied");
} catch {
toast.error("Failed to copy link");
}
};
return (
<NodeViewWrapper className="image-node">
{/* eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions */}
<figure
className={cn(
"image-figure",
selected && isEditable && "image-selected",
)}
contentEditable={false}
onClick={!isEditable && !uploading ? handleView : undefined}
>
<img
src={src}
alt={alt}
title={title || undefined}
className={cn("image-content", uploading && "image-uploading")}
draggable={false}
/>
{!uploading && (
<div
className="image-toolbar"
onMouseDown={(e) => e.stopPropagation()}
onClick={(e) => e.stopPropagation()}
>
<button type="button" onClick={handleView} title="View image">
<Maximize2 className="size-3.5" />
</button>
<button type="button" onClick={handleDownload} title="Download">
<Download className="size-3.5" />
</button>
<button
type="button"
onClick={handleCopyLink}
title="Copy link"
>
<LinkIcon className="size-3.5" />
</button>
{isEditable && (
<button
type="button"
onClick={() => deleteNode()}
title="Delete"
>
<Trash2 className="size-3.5" />
</button>
)}
</div>
)}
</figure>
{lightbox && (
<ImageLightbox
src={src}
alt={alt}
onClose={() => setLightbox(false)}
/>
)}
</NodeViewWrapper>
);
}
export { ImageView, ImageLightbox };

View File

@@ -41,6 +41,8 @@ import { CodeBlockView } from "./code-block-view";
import { createMarkdownPasteExtension } from "./markdown-paste";
import { createSubmitExtension } from "./submit-shortcut";
import { createFileUploadExtension } from "./file-upload";
import { FileCardExtension } from "./file-card";
import { ImageView } from "./image-view";
const lowlight = createLowlight(common);
@@ -67,15 +69,18 @@ const ImageExtension = Image.extend({
},
};
},
addNodeView() {
return ReactNodeViewRenderer(ImageView);
},
}).configure({
inline: false,
allowBase64: false,
HTMLAttributes: { style: "max-width: 100%; height: auto;" },
});
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
@@ -105,9 +110,10 @@ export function createEditorExtensions(
TableHeader,
TableCell,
Markdown,
FileCardExtension,
BaseMentionExtension.configure({
HTMLAttributes: { class: "mention" },
...(editable ? { suggestion: createMentionSuggestion() } : {}),
...(editable && options.queryClient ? { suggestion: createMentionSuggestion(options.queryClient) } : {}),
}),
];

View File

@@ -10,8 +10,11 @@ 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 { useIssueStore } from "@/features/issues";
import { issueKeys } from "@core/issues/queries";
import { workspaceKeys } from "@core/workspace/queries";
import type { Issue, ListIssuesResponse, MemberWithUser, Agent } from "@/shared/types";
import { ActorAvatar } from "@/components/common/actor-avatar";
import { StatusIcon } from "@/features/issues/components/status-icon";
import { Badge } from "@/components/ui/badge";
@@ -210,14 +213,19 @@ function MentionRow({
// Suggestion config factory
// ---------------------------------------------------------------------------
export function createMentionSuggestion(): Omit<
export function createMentionSuggestion(qc: QueryClient): Omit<
SuggestionOptions<MentionItem>,
"editor"
> {
return {
items: ({ query }) => {
const { members, agents } = useWorkspaceStore.getState();
const { issues } = useIssueStore.getState();
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 q = query.toLowerCase();
// Show "All members" option when query is empty or matches "all"

View File

@@ -20,7 +20,9 @@
import { NodeViewWrapper } from "@tiptap/react";
import type { NodeViewProps } from "@tiptap/react";
import { useIssueStore } from "@/features/issues/store";
import { useQuery } from "@tanstack/react-query";
import { issueListOptions } from "@core/issues/queries";
import { useWorkspaceId } from "@core/hooks";
import { StatusIcon } from "@/features/issues/components/status-icon";
export function MentionView({ node }: NodeViewProps) {
@@ -48,7 +50,9 @@ function IssueMention({
issueId: string;
fallbackLabel?: string;
}) {
const issue = useIssueStore((s) => s.issues.find((i) => i.id === issueId));
const wsId = useWorkspaceId();
const { data: issues = [] } = useQuery(issueListOptions(wsId));
const issue = issues.find((i) => i.id === issueId);
const handleClick = (e: React.MouseEvent) => {
e.preventDefault();

View File

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

View File

@@ -0,0 +1,245 @@
"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, useState } 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 { Maximize2, Download, Link as LinkIcon, FileText } from "lucide-react";
import { toast } from "sonner";
import { cn } from "@/lib/utils";
import { IssueMentionCard } from "@/features/issues/components/issue-mention-card";
import { ImageLightbox } from "./extensions/image-view";
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 — centered with toolbar + lightbox (matches Tiptap ImageView NodeView)
img: function ReadonlyImage({ src, alt }) {
const [lightbox, setLightbox] = useState(false);
const imgSrc = typeof src === "string" ? src : "";
const imgAlt = alt ?? "";
const handleView = () => setLightbox(true);
const handleDownload = () => {
window.open(imgSrc, "_blank", "noopener,noreferrer");
};
const handleCopyLink = async () => {
try {
await navigator.clipboard.writeText(imgSrc);
toast.success("Link copied");
} catch {
toast.error("Failed to copy link");
}
};
return (
<span className="image-node">
{/* eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions */}
<span className="image-figure" onClick={handleView}>
<img src={imgSrc} alt={imgAlt} className="image-content" draggable={false} />
{/* eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions */}
<span
className="image-toolbar"
onMouseDown={(e) => e.stopPropagation()}
onClick={(e) => e.stopPropagation()}
>
<button type="button" onClick={handleView} title="View image">
<Maximize2 className="size-3.5" />
</button>
<button type="button" onClick={handleDownload} title="Download">
<Download className="size-3.5" />
</button>
<button type="button" onClick={handleCopyLink} title="Copy link">
<LinkIcon className="size-3.5" />
</button>
</span>
</span>
{lightbox && (
<ImageLightbox src={imgSrc} alt={imgAlt} onClose={() => setLightbox(false)} />
)}
</span>
);
},
// FileCard — intercept <div data-type="fileCard"> from preprocessMarkdown
div: ({ node, children, ...props }) => {
const dataType = node?.properties?.dataType as string | undefined;
if (dataType === "fileCard") {
const href = (node?.properties?.dataHref as string) || "";
const filename = (node?.properties?.dataFilename as string) || "";
return (
<div className="my-1 flex items-center gap-2 rounded-md border border-border bg-muted/50 px-2.5 py-1 transition-colors hover:bg-muted">
<FileText className="size-4 shrink-0 text-muted-foreground" />
<div className="min-w-0 flex-1">
<p className="truncate text-sm">{filename}</p>
</div>
{href && (
<button
type="button"
className="shrink-0 rounded-md p-1 text-muted-foreground transition-colors hover:bg-secondary hover:text-foreground"
onClick={() => window.open(href, "_blank", "noopener,noreferrer")}
>
<Download className="size-3.5" />
</button>
)}
</div>
);
}
return <div {...props}>{children}</div>;
},
// 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,5 +1,6 @@
import { preprocessLinks } from "@/components/markdown/linkify";
import { preprocessMentionShortcodes } from "@/components/markdown/mentions";
import { isFileCardUrl } from "../extensions/file-card";
/**
* Preprocess a markdown string before loading into Tiptap via contentType: 'markdown'.
@@ -8,17 +9,40 @@ import { preprocessMentionShortcodes } from "@/components/markdown/mentions";
* It does NOT convert to HTML — that was the old markdownToHtml.ts pipeline which
* was deleted in the April 2026 refactor.
*
* Two string→string transforms on raw Markdown:
* Three string→string transforms on raw Markdown:
* 1. Legacy mention shortcodes [@ id="..." label="..."] → [@Label](mention://member/id)
* (old serialization format in database, migrated on read)
* 2. Raw URLs → markdown links via linkify-it (so they render as clickable Link nodes)
*
* After this, @tiptap/markdown's parse() handles everything else: headings, lists,
* tables, code blocks, and our custom mention tokenizer ([@Name](mention://type/id)).
* 3. CDN file links on their own line → HTML div for fileCard node parsing
*/
export function preprocessMarkdown(markdown: string): string {
if (!markdown) return "";
const step1 = preprocessMentionShortcodes(markdown);
const step2 = preprocessLinks(step1);
return step2;
const step3 = preprocessFileCards(step2);
return step3;
}
/**
* Convert standalone `[name](cdnUrl)` lines into HTML that Tiptap's fileCard
* parseHTML can recognise. Only matches non-image CDN URLs on their own line.
*
* Input: `[report.pdf](https://multica-static.copilothub.ai/xxx.pdf)`
* Output: `<div data-type="fileCard" data-href="url" data-filename="report.pdf"></div>`
*/
const FILE_LINK_LINE = /^\[([^\]]+)\]\((https?:\/\/[^)]+)\)$/;
function preprocessFileCards(markdown: string): string {
return markdown
.split("\n")
.map((line) => {
const trimmed = line.trim();
const match = trimmed.match(FILE_LINK_LINE);
if (!match) return line;
const filename = match[1]!;
const url = match[2]!;
if (!isFileCardUrl(url)) return line;
return `<div data-type="fileCard" data-href="${url}" data-filename="${filename}"></div>`;
})
.join("\n");
}

View File

@@ -1 +1,13 @@
export { useInboxStore } from "./store";
// 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";

View File

@@ -1,127 +0,0 @@
"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, ChevronUp, Loader2, ArrowDown, Brain, AlertCircle, Clock, CheckCircle2, XCircle, Square } from "lucide-react";
import { Bot, ChevronRight, ChevronDown, Loader2, ArrowDown, Brain, AlertCircle, Clock, CheckCircle2, XCircle, Square, Maximize2 } from "lucide-react";
import { api } from "@/shared/api";
import { useWSEvent } from "@/features/realtime";
import type { TaskMessagePayload, TaskCompletedPayload, TaskFailedPayload, TaskCancelledPayload } from "@/shared/types/events";
@@ -12,6 +12,7 @@ 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 ─────────────────────────────────────────────────
@@ -95,49 +96,51 @@ function buildTimeline(msgs: TaskMessagePayload[]): TimelineItem[] {
return items.sort((a, b) => a.seq - b.seq);
}
// ─── AgentLiveCard (real-time view) ────────────────────────────────────────
// ─── Per-task state ─────────────────────────────────────────────────────────
interface TaskState {
task: AgentTask;
items: TimelineItem[];
}
// ─── AgentLiveCard (real-time view for multiple agents) ───────────────────
interface AgentLiveCardProps {
issueId: string;
agentName?: string;
/** Scroll container ref — needed for sticky sentinel detection. */
/** Scroll container ref — used to auto-collapse timeline on outer scroll. */
scrollContainerRef?: React.RefObject<HTMLDivElement | null>;
}
export function AgentLiveCard({ issueId, agentName, scrollContainerRef }: AgentLiveCardProps) {
export function AgentLiveCard({ issueId, scrollContainerRef }: AgentLiveCardProps) {
const { getActorName } = useActorName();
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 [taskStates, setTaskStates] = useState<Map<string, TaskState>>(new Map());
const seenSeqs = useRef(new Set<string>());
// Check for active task on mount
// Fetch active tasks on mount
useEffect(() => {
let cancelled = false;
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);
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: [] });
}
}
});
Promise.all(loadPromises).then(() => {
if (!cancelled) setTaskStates(newStates);
});
}).catch(console.error);
return () => { cancelled = true; };
}, [issueId]);
// Handle real-time task messages
// Handle real-time task messages — route by task_id
useWSEvent(
"task:message",
useCallback((payload: unknown) => {
@@ -147,110 +150,124 @@ export function AgentLiveCard({ issueId, agentName, scrollContainerRef }: AgentL
if (seenSeqs.current.has(key)) return;
seenSeqs.current.add(key);
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);
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
return next;
});
}, [issueId]),
);
// Handle task completion/failure
useWSEvent(
"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]),
);
// 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:failed",
useCallback((payload: unknown) => {
const p = payload as TaskFailedPayload;
if (p.issue_id !== issueId) return;
setActiveTask(null);
setItems([]);
seenSeqs.current.clear();
setCancelling(false);
}, [issueId]),
);
useWSEvent("task:completed", handleTaskEnd);
useWSEvent("task:failed", handleTaskEnd);
useWSEvent("task:cancelled", handleTaskEnd);
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]),
);
// 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).
// Pick up newly dispatched tasks
useWSEvent(
"task:dispatch",
useCallback(() => {
if (activeTask) return;
api.getActiveTaskForIssue(issueId).then(({ task }) => {
if (task) {
setActiveTask(task);
setItems([]);
seenSeqs.current.clear();
}
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);
}, [issueId, activeTask]),
}, [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>
);
}
// ─── SingleAgentLiveCard (one card per running task) ──────────────────────
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);
// Elapsed time
useEffect(() => {
if (!activeTask?.started_at && !activeTask?.dispatched_at) return;
const startRef = activeTask.started_at ?? activeTask.dispatched_at!;
if (!task.started_at && !task.dispatched_at) return;
const startRef = task.started_at ?? task.dispatched_at!;
setElapsed(formatElapsed(startRef));
const interval = setInterval(() => setElapsed(formatElapsed(startRef)), 1000);
return () => clearInterval(interval);
}, [activeTask?.started_at, activeTask?.dispatched_at]);
}, [task.started_at, task.dispatched_at]);
// Sentinel pattern: detect when the card is scrolled past and becomes "stuck"
// Auto-collapse timeline when outer scroll container scrolls
useEffect(() => {
const sentinel = sentinelRef.current;
const root = scrollContainerRef?.current;
if (!sentinel || !root || !activeTask) {
setIsStuck(false);
return;
}
const container = scrollContainerRef?.current;
if (!container) return;
const observer = new IntersectionObserver(
(entries) => {
if (entries[0]) setIsStuck(!entries[0].isIntersecting);
},
{ root, threshold: 0, rootMargin: "-40px 0px 0px 0px" },
);
const handleOuterScroll = () => {
if (ignoreScrollRef.current) return;
setOpen(false);
};
observer.observe(sentinel);
return () => observer.disconnect();
}, [scrollContainerRef, activeTask]);
container.addEventListener("scroll", handleOuterScroll, { passive: true });
return () => container.removeEventListener("scroll", handleOuterScroll);
}, [scrollContainerRef]);
const scrollToCard = useCallback(() => {
sentinelRef.current?.scrollIntoView({ behavior: "smooth", block: "center" });
}, []);
// Auto-scroll
// Auto-scroll timeline to bottom
useEffect(() => {
if (autoScroll && scrollRef.current) {
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
@@ -263,94 +280,92 @@ export function AgentLiveCard({ issueId, agentName, scrollContainerRef }: AgentL
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 (!activeTask || cancelling) return;
if (cancelling) return;
setCancelling(true);
try {
await api.cancelTask(issueId, activeTask.id);
await api.cancelTask(issueId, task.id);
} catch (e) {
toast.error(e instanceof Error ? e.message : "Failed to cancel task");
setCancelling(false);
}
}, [activeTask, issueId, cancelling]);
if (!activeTask) return null;
}, [task.id, issueId, cancelling]);
const toolCount = items.filter((i) => i.type === "tool_use").length;
const name = (activeTask.agent_id ? getActorName("agent", activeTask.agent_id) : agentName) ?? "Agent";
return (
<>
{/* Sentinel — zero-height element that IntersectionObserver watches */}
<div ref={sentinelRef} className="mt-4 h-0 pointer-events-none" aria-hidden />
<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={cn(
"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",
)}
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();
}
}}
>
{/* 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>
{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>
<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 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>
{/* 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",
)}
>
{/* 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]",
)}
>
<div className="overflow-hidden">
{items.length > 0 && (
<div
ref={scrollRef}
onScroll={handleScroll}
className="relative max-h-80 overflow-y-auto border-t border-info/10 px-3 py-2 space-y-0.5"
className="relative max-h-80 overflow-y-auto overscroll-y-contain border-t border-info/10 px-3 py-2 space-y-0.5"
>
{items.map((item, idx) => (
<TimelineRow key={`${item.seq}-${idx}`} item={item} />
@@ -358,7 +373,8 @@ export function AgentLiveCard({ issueId, agentName, scrollContainerRef }: AgentL
{!autoScroll && (
<button
onClick={() => {
onClick={(e) => {
e.stopPropagation();
if (scrollRef.current) {
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
setAutoScroll(true);
@@ -374,7 +390,17 @@ export function AgentLiveCard({ issueId, agentName, scrollContainerRef }: AgentL
)}
</div>
</div>
</>
{/* Fullscreen transcript dialog */}
<AgentTranscriptDialog
open={transcriptOpen}
onOpenChange={setTranscriptOpen}
task={task}
items={items}
agentName={agentName}
isLive
/>
</div>
);
}
@@ -443,8 +469,10 @@ 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
@@ -480,6 +508,24 @@ 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">
@@ -497,6 +543,17 @@ 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

@@ -0,0 +1,628 @@
"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,9 +21,8 @@ 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 { api } from "@/shared/api";
import { useBatchUpdateIssues, useBatchDeleteIssues } from "@core/issues/mutations";
import { StatusIcon } from "./status-icon";
import { PriorityIcon } from "./priority-icon";
import { AssigneePicker } from "./pickers";
@@ -37,46 +36,31 @@ export function BatchActionToolbar() {
const [priorityOpen, setPriorityOpen] = useState(false);
const [assigneeOpen, setAssigneeOpen] = useState(false);
const [deleteOpen, setDeleteOpen] = useState(false);
const [loading, setLoading] = useState(false);
const batchUpdate = useBatchUpdateIssues();
const batchDelete = useBatchDeleteIssues();
const loading = batchUpdate.isPending || batchDelete.isPending;
if (count === 0) return null;
const ids = Array.from(selectedIds);
const handleBatchUpdate = async (updates: Partial<UpdateIssueRequest>) => {
setLoading(true);
try {
await api.batchUpdateIssues(ids, updates);
for (const id of ids) {
useIssueStore.getState().updateIssue(id, updates);
}
await batchUpdate.mutateAsync({ ids, 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 api.batchDeleteIssues(ids);
for (const id of ids) {
useIssueStore.getState().removeIssue(id);
}
await batchDelete.mutateAsync(ids);
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 } from "@dnd-kit/sortable";
import { useSortable, defaultAnimateLayoutChanges } from "@dnd-kit/sortable";
import type { AnimateLayoutChanges } 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 { api } from "@/shared/api";
import { useIssueStore } from "@/features/issues/store";
import { useUpdateIssue } from "@core/issues/mutations";
import { PriorityIcon } from "./priority-icon";
import { PriorityPicker, AssigneePicker, DueDatePicker } from "./pickers";
import { PRIORITY_CONFIG } from "@/features/issues/config";
@@ -46,16 +46,15 @@ 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>) => {
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");
});
updateIssueMutation.mutate(
{ id: issue.id, ...updates },
{ onError: () => toast.error("Failed to update issue") },
);
},
[issue],
[issue.id, updateIssueMutation],
);
const showPriority = storeProperties.priority;
@@ -168,6 +167,12 @@ 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,
@@ -179,6 +184,7 @@ 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 } from "react";
import { useMemo, type ReactNode } from "react";
import { EyeOff, MoreHorizontal, Plus } from "lucide-react";
import { Tooltip, TooltipTrigger, TooltipContent } from "@/components/ui/tooltip";
import { useDroppable } from "@dnd-kit/core";
@@ -15,32 +15,35 @@ import {
} from "@/components/ui/dropdown-menu";
import { STATUS_CONFIG } from "@/features/issues/config";
import { useModalStore } from "@/features/modals";
import { useViewStore, useViewStoreApi } from "@/features/issues/stores/view-store-context";
import { sortIssues } from "@/features/issues/utils/sort";
import { useViewStoreApi } from "@/features/issues/stores/view-store-context";
import { StatusIcon } from "./status-icon";
import { DraggableBoardCard } from "./board-card";
export function BoardColumn({
status,
issues,
issueIds,
issueMap,
totalCount,
footer,
}: {
status: IssueStatus;
issues: Issue[];
issueIds: string[];
issueMap: Map<string, Issue>;
totalCount?: number;
footer?: ReactNode;
}) {
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);
const sortedIssues = useMemo(
() => sortIssues(issues, sortBy, sortDirection),
[issues, sortBy, sortDirection]
);
const sortedIds = useMemo(
() => sortedIssues.map((i) => i.id),
[sortedIssues]
// 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],
);
return (
@@ -53,7 +56,7 @@ export function BoardColumn({
{cfg.label}
</span>
<span className="text-xs text-muted-foreground">
{issues.length}
{totalCount ?? issueIds.length}
</span>
</div>
@@ -97,16 +100,17 @@ export function BoardColumn({
isOver ? "bg-accent/60" : ""
}`}
>
<SortableContext items={sortedIds} strategy={verticalListSortingStrategy}>
{sortedIssues.map((issue) => (
<SortableContext items={issueIds} strategy={verticalListSortingStrategy}>
{resolvedIssues.map((issue) => (
<DraggableBoardCard key={issue.id} issue={issue} />
))}
</SortableContext>
{issues.length === 0 && (
{issueIds.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 } from "react";
import { useState, useCallback, useMemo, useEffect, useRef } from "react";
import {
DndContext,
DragOverlay,
@@ -12,10 +12,13 @@ import {
type CollisionDetection,
type DragStartEvent,
type DragEndEvent,
type DragOverEvent,
} from "@dnd-kit/core";
import { Eye, MoreHorizontal } from "lucide-react";
import { arrayMove } from "@dnd-kit/sortable";
import { Eye, Loader2, 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,
@@ -23,11 +26,37 @@ import {
DropdownMenuItem,
} from "@/components/ui/dropdown-menu";
import { ALL_STATUSES, STATUS_CONFIG } from "@/features/issues/config";
import { useViewStoreApi } from "@/features/issues/stores/view-store-context";
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 { 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) => {
@@ -44,13 +73,47 @@ const kanbanCollision: CollisionDetection = (args) => {
return closestCenter(args);
};
/** 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;
/** 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;
}
export function BoardView({
@@ -70,7 +133,53 @@ 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, {
@@ -78,89 +187,100 @@ 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) => {
const issue = issues.find((i) => i.id === event.active.id);
if (issue) setActiveIssue(issue);
isDraggingRef.current = true;
const issue = issueMapRef.current.get(event.active.id as string) ?? null;
setActiveIssue(issue);
},
[issues]
[],
);
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],
);
const handleDragEnd = useCallback(
(event: DragEndEvent) => {
setActiveIssue(null);
const { active, over } = event;
if (!over || active.id === over.id) return;
isDraggingRef.current = false;
setActiveIssue(null);
const issueId = active.id as string;
const currentIssue = issues.find((i) => i.id === issueId);
if (!currentIssue) return;
const resetColumns = () =>
setColumns(buildColumns(issues, visibleStatuses, sortBy, sortDirection));
// 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;
if (!over) {
resetColumns();
return;
}
// Get sorted siblings in the target column (excluding the dragged item)
const siblings = (issuesByStatus[targetStatus] ?? []).filter(
(i) => i.id !== issueId
);
const activeId = active.id as string;
const overId = over.id as string;
// Compute new position
let newPosition: number;
const cols = columnsRef.current;
const activeCol = findColumn(cols, activeId, visibleStatuses);
const overCol = findColumn(cols, overId, visibleStatuses);
if (!activeCol || !overCol) {
resetColumns();
return;
}
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);
}
// 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);
}
}
// Skip if nothing changed
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);
if (
currentIssue.status === targetStatus &&
currentIssue &&
currentIssue.status === finalCol &&
currentIssue.position === newPosition
) {
return;
}
onMoveIssue(issueId, targetStatus, newPosition);
onMoveIssue(activeId, finalCol, newPosition);
},
[issues, issuesByStatus, onMoveIssue, visibleStatuses]
[issues, visibleStatuses, sortBy, sortDirection, onMoveIssue],
);
return (
@@ -168,6 +288,7 @@ 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">
@@ -175,7 +296,14 @@ export function BoardView({
<BoardColumn
key={status}
status={status}
issues={issues.filter((i) => i.status === status)}
issueIds={columns[status] ?? []}
issueMap={issueMapRef.current}
totalCount={status === "done" ? doneTotal : undefined}
footer={
status === "done" && hasMore ? (
<InfiniteScrollSentinel onVisible={loadMore} loading={loadingMore} />
) : undefined
}
/>
))}
@@ -187,9 +315,9 @@ export function BoardView({
)}
</div>
<DragOverlay>
<DragOverlay dropAnimation={null}>
{activeIssue ? (
<div className="w-[280px] rotate-1 cursor-grabbing opacity-95 shadow-md">
<div className="w-[280px] rotate-2 scale-105 cursor-grabbing opacity-90 shadow-lg shadow-black/10">
<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 } from "@/features/editor";
import { ContentEditor, type ContentEditorRef, copyMarkdown, ReadonlyContent } 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,6 +148,8 @@ 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" : ""}`}>
@@ -245,14 +247,14 @@ function CommentRow({
) : (
<>
<div className="mt-1.5 pl-8 text-sm leading-relaxed text-foreground/85">
<ContentEditor defaultValue={entry.content ?? ""} editable={false} />
<ReadonlyContent content={entry.content ?? ""} />
</div>
{!isTemp && (
<ReactionBar
reactions={reactions}
currentUserId={currentUserId}
onToggle={(emoji) => onToggleReaction(entry.id, emoji)}
hideAddButton
hideAddButton={!isLongContent}
className="mt-1.5 pl-8"
/>
)}
@@ -330,6 +332,8 @@ 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;
@@ -434,6 +438,7 @@ function CommentCard({
defaultValue={entry.content ?? ""}
placeholder="Edit comment..."
onSubmit={saveEdit}
onUploadFile={(file) => uploadWithToast(file, { issueId })}
debounceMs={100}
/>
</div>
@@ -451,13 +456,14 @@ function CommentCard({
) : (
<>
<div className="pl-10 text-sm leading-relaxed text-foreground/85">
<ContentEditor defaultValue={entry.content ?? ""} editable={false} />
<ReadonlyContent content={entry.content ?? ""} />
</div>
{!isTemp && (
<ReactionBar
reactions={reactions}
currentUserId={currentUserId}
onToggle={(emoji) => onToggleReaction(entry.id, emoji)}
hideAddButton={!isLongContent}
className="mt-1.5 pl-10"
/>
)}

View File

@@ -16,10 +16,15 @@ 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) => {
return await uploadWithToast(file, { issueId });
const result = await uploadWithToast(file, { issueId });
if (result) {
setAttachmentIds((prev) => [...prev, result.id]);
}
return result;
};
const handleSubmit = async () => {
@@ -27,9 +32,10 @@ function CommentInput({ issueId, onSubmit }: CommentInputProps) {
if (!content || submitting) return;
setSubmitting(true);
try {
await onSubmit(content);
await onSubmit(content, attachmentIds.length > 0 ? attachmentIds : undefined);
editorRef.current?.clearContent();
setIsEmpty(true);
setAttachmentIds([]);
} finally {
setSubmitting(false);
}

View File

@@ -13,6 +13,7 @@ import {
Link2,
MoreHorizontal,
PanelRight,
Plus,
Trash2,
UserMinus,
Users,
@@ -57,22 +58,81 @@ 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 { UpdateIssueRequest, IssueStatus, IssuePriority, TimelineEntry } from "@/shared/types";
import type { Issue, 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 { api } from "@/shared/api";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useAuthStore } from "@/features/auth";
import { useWorkspaceStore, useActorName } from "@/features/workspace";
import { useIssueStore } from "@/features/issues";
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 { 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 { api } from "@/shared/api";
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 "—";
@@ -175,17 +235,20 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo
const router = useRouter();
const user = useAuthStore((s) => s.user);
const workspace = useWorkspaceStore((s) => s.workspace);
const members = useWorkspaceStore((s) => s.members);
const agents = useWorkspaceStore((s) => s.agents);
const currentMemberRole = members.find((m) => m.user_id === user?.id)?.role;
// Issue navigation
const allIssues = useIssueStore((s) => s.issues);
// Issue navigation — read from TQ list cache
const wsId = useWorkspaceId();
const { data: members = [] } = useQuery(memberListOptions(wsId));
const { data: agents = [] } = useQuery(agentListOptions(wsId));
const currentMemberRole = members.find((m) => m.user_id === user?.id)?.role;
const { data: allIssues = [] } = useQuery(issueListOptions(wsId));
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;
const { getActorName } = useActorName();
const { uploadWithToast } = useFileUpload();
const queryClient = useQueryClient();
const { defaultLayout, onLayoutChanged } = useDefaultLayout({
id: layoutId,
});
@@ -196,32 +259,14 @@ 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);
// 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]);
// 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),
});
// Custom hooks — encapsulate timeline, reactions, subscribers
const {
@@ -238,6 +283,25 @@ 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)
@@ -256,35 +320,17 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo
}
}, [highlightCommentId, timeline.length]);
// 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)
// Issue field updates via TQ mutation (optimistic update + rollback in mutation hook)
const updateIssueMutation = useUpdateIssue();
const handleUpdateField = useCallback(
(updates: Partial<UpdateIssueRequest>) => {
if (!issue) return;
const prev = { ...issue };
useIssueStore.getState().updateIssue(id, updates);
api.updateIssue(id, updates).catch(() => {
useIssueStore.getState().updateIssue(id, prev);
toast.error("Failed to update issue");
});
updateIssueMutation.mutate(
{ id, ...updates },
{ onError: () => toast.error("Failed to update issue") },
);
},
[issue, id],
[issue, id, updateIssueMutation],
);
const descEditorRef = useRef<ContentEditorRef>(null);
@@ -293,11 +339,11 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo
[uploadWithToast, id],
);
const deleteIssueMutation = useDeleteIssue();
const handleDelete = async () => {
setDeleting(true);
try {
await api.deleteIssue(issue!.id);
useIssueStore.getState().removeIssue(issue!.id);
await deleteIssueMutation.mutateAsync(issue!.id);
toast.success("Issue deleted");
if (onDelete) onDelete();
else router.push("/issues");
@@ -391,6 +437,17 @@ 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>
@@ -561,6 +618,17 @@ 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);
@@ -641,6 +709,31 @@ 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}
@@ -671,6 +764,122 @@ 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 */}
@@ -773,7 +982,6 @@ 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}
/>
@@ -918,20 +1126,6 @@ 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>
@@ -1020,6 +1214,26 @@ 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,7 +1,9 @@
"use client";
import Link from "next/link";
import { useIssueStore } from "@/features/issues/store";
import { useQuery } from "@tanstack/react-query";
import { issueListOptions } from "@core/issues/queries";
import { useWorkspaceId } from "@core/hooks";
import { StatusIcon } from "./status-icon";
interface IssueMentionCardProps {
@@ -11,15 +13,19 @@ interface IssueMentionCardProps {
}
export function IssueMentionCard({ issueId, fallbackLabel }: IssueMentionCardProps) {
const issue = useIssueStore((s) => s.issues.find((i) => i.id === issueId));
const wsId = useWorkspaceId();
const { data: issues = [] } = useQuery(issueListOptions(wsId));
const issue = issues.find((i) => i.id === issueId);
if (!issue) {
return (
<Link
href={`/issues/${issueId}`}
className="text-primary font-medium cursor-pointer hover:underline"
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"
>
{fallbackLabel ?? issueId.slice(0, 8)}
<span className="font-medium text-muted-foreground">
{fallbackLabel ?? issueId.slice(0, 8)}
</span>
</Link>
);
}
@@ -27,11 +33,11 @@ export function IssueMentionCard({ issueId, fallbackLabel }: IssueMentionCardPro
return (
<Link
href={`/issues/${issueId}`}
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"
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"
>
<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>
<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>
</Link>
);
}

View File

@@ -43,7 +43,9 @@ import {
PRIORITY_CONFIG,
} from "@/features/issues/config";
import { StatusIcon, PriorityIcon } from "@/features/issues/components";
import { useWorkspaceStore } from "@/features/workspace";
import { useQuery } from "@tanstack/react-query";
import { useWorkspaceId } from "@core/hooks";
import { memberListOptions, agentListOptions } from "@core/workspace/queries";
import { ActorAvatar } from "@/components/common/actor-avatar";
import {
useIssueViewStore,
@@ -155,8 +157,9 @@ function ActorSubContent({
noAssigneeCount?: number;
}) {
const [search, setSearch] = useState("");
const members = useWorkspaceStore((s) => s.members);
const agents = useWorkspaceStore((s) => s.agents);
const wsId = useWorkspaceId();
const { data: members = [] } = useQuery(memberListOptions(wsId));
const { data: agents = [] } = useQuery(agentListOptions(wsId));
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 { useIssueStore } from "@/features/issues/store";
import { useQuery } from "@tanstack/react-query";
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,7 +13,9 @@ 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 { api } from "@/shared/api";
import { useWorkspaceId } from "@core/hooks";
import { issueListOptions } from "@core/issues/queries";
import { useUpdateIssue } from "@core/issues/mutations";
import { useIssueSelectionStore } from "@/features/issues/stores/selection-store";
import { IssuesHeader } from "./issues-header";
import { BoardView } from "./board-view";
@@ -21,8 +23,9 @@ import { ListView } from "./list-view";
import { BatchActionToolbar } from "./batch-action-toolbar";
export function IssuesPage() {
const allIssues = useIssueStore((s) => s.issues);
const loading = useIssueStore((s) => s.loading);
const wsId = useWorkspaceId();
const { data: allIssues = [], isLoading: loading } = useQuery(issueListOptions(wsId));
const workspace = useWorkspaceStore((s) => s.workspace);
const scope = useIssuesScopeStore((s) => s.scope);
const viewMode = useIssueViewStore((s) => s.viewMode);
@@ -64,6 +67,7 @@ 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
@@ -78,16 +82,12 @@ export function IssuesPage() {
};
if (newPosition !== undefined) updates.position = newPosition;
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.mutate(
{ id: issueId, ...updates },
{ onError: () => toast.error("Failed to move issue") },
);
},
[]
[updateIssueMutation],
);
if (loading) {

View File

@@ -3,8 +3,11 @@
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 { useWorkspaceStore, useActorName } from "@/features/workspace";
import { useActorName } from "@/features/workspace";
import { useWorkspaceId } from "@core/hooks";
import { memberListOptions, agentListOptions } from "@core/workspace/queries";
import { ActorAvatar } from "@/components/common/actor-avatar";
import {
PropertyPicker,
@@ -44,8 +47,9 @@ export function AssigneePicker({
const setOpen = controlledOnOpenChange ?? setInternalOpen;
const [filter, setFilter] = useState("");
const user = useAuthStore((s) => s.user);
const members = useWorkspaceStore((s) => s.members);
const agents = useWorkspaceStore((s) => s.agents);
const wsId = useWorkspaceId();
const { data: members = [] } = useQuery(memberListOptions(wsId));
const { data: agents = [] } = useQuery(agentListOptions(wsId));
const { getActorName } = useActorName();
const currentMember = members.find((m) => m.user_id === user?.id);

View File

@@ -38,6 +38,7 @@ 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(() => {
@@ -52,7 +53,11 @@ function ReplyInput({
}, []);
const handleUpload = async (file: File) => {
return await uploadWithToast(file, { issueId });
const result = await uploadWithToast(file, { issueId });
if (result) {
setAttachmentIds((prev) => [...prev, result.id]);
}
return result;
};
const handleSubmit = async () => {
@@ -60,9 +65,10 @@ function ReplyInput({
if (!content || submitting) return;
setSubmitting(true);
try {
await onSubmit(content);
await onSubmit(content, attachmentIds.length > 0 ? attachmentIds : undefined);
editorRef.current?.clearContent();
setIsEmpty(true);
setAttachmentIds([]);
} finally {
setSubmitting(false);
}

View File

@@ -1,41 +1,32 @@
"use client";
import { useState, useEffect, useCallback } from "react";
import { useCallback, useMemo } from "react";
import { useQuery, useQueryClient, useMutationState } from "@tanstack/react-query";
import type { IssueReaction } from "@/shared/types";
import type {
IssueReactionAddedPayload,
IssueReactionRemovedPayload,
} from "@/shared/types";
import { api } from "@/shared/api";
import { toast } from "sonner";
import { issueReactionsOptions, issueKeys } from "@core/issues/queries";
import { useToggleIssueReaction, type ToggleIssueReactionVars } from "@core/issues/mutations";
import { useWSEvent, useWSReconnect } from "@/features/realtime";
export function useIssueReactions(issueId: string, userId?: string) {
const [reactions, setReactions] = useState<IssueReaction[]>([]);
const [loading, setLoading] = useState(true);
const qc = useQueryClient();
const { data: serverReactions = [], isLoading: loading } = useQuery(
issueReactionsOptions(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]);
const toggleMutation = useToggleIssueReaction(issueId);
// Reconnect recovery
useWSReconnect(
useCallback(() => {
api.getIssue(issueId).then((iss) => setReactions(iss.reactions ?? [])).catch(console.error);
}, [issueId]),
qc.invalidateQueries({ queryKey: issueKeys.reactions(issueId) });
}, [qc, issueId]),
);
// --- WS event handlers ---
// --- WS event handlers (update server cache for other users' actions) ---
useWSEvent(
"issue_reaction:added",
@@ -43,13 +34,16 @@ export function useIssueReactions(issueId: string, userId?: string) {
(payload: unknown) => {
const { reaction, issue_id } = payload as IssueReactionAddedPayload;
if (issue_id !== issueId) return;
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.setQueryData<IssueReaction[]>(
issueKeys.reactions(issueId),
(old) => {
if (!old) return old;
if (old.some((r) => r.id === reaction.id)) return old;
return [...old, reaction];
},
);
},
[issueId, userId],
[qc, issueId],
),
);
@@ -59,53 +53,85 @@ export function useIssueReactions(issueId: string, userId?: string) {
(payload: unknown) => {
const p = payload as IssueReactionRemovedPayload;
if (p.issue_id !== issueId) return;
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.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
),
),
);
},
[issueId, userId],
[qc, issueId],
),
);
// --- 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 = reactions.find(
(r) => r.emoji === emoji && r.actor_type === "member" && r.actor_id === userId,
const existing = serverReactions.find(
(r) =>
r.emoji === emoji &&
r.actor_type === "member" &&
r.actor_id === userId,
);
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");
}
}
toggleMutation.mutate({ emoji, existing });
},
[issueId, userId, reactions],
[userId, serverReactions, toggleMutation],
);
return { reactions, loading, toggleReaction };

View File

@@ -1,38 +1,29 @@
"use client";
import { useState, useEffect, useCallback } from "react";
import { useCallback } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import type { IssueSubscriber } from "@/shared/types";
import type {
SubscriberAddedPayload,
SubscriberRemovedPayload,
} from "@/shared/types";
import { api } from "@/shared/api";
import { toast } from "sonner";
import { issueSubscribersOptions, issueKeys } from "@core/issues/queries";
import { useToggleIssueSubscriber } from "@core/issues/mutations";
import { useWSEvent, useWSReconnect } from "@/features/realtime";
export function useIssueSubscribers(issueId: string, userId?: string) {
const [subscribers, setSubscribers] = useState<IssueSubscriber[]>([]);
const [loading, setLoading] = useState(true);
const qc = useQueryClient();
const { data: subscribers = [], isLoading: loading } = useQuery(
issueSubscribersOptions(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]);
const toggleMutation = useToggleIssueSubscriber(issueId);
// Reconnect recovery
useWSReconnect(
useCallback(() => {
api.listIssueSubscribers(issueId).then(setSubscribers).catch(console.error);
}, [issueId]),
qc.invalidateQueries({ queryKey: issueKeys.subscribers(issueId) });
}, [qc, issueId]),
);
// --- WS event handlers ---
@@ -43,21 +34,31 @@ export function useIssueSubscribers(issueId: string, userId?: string) {
(payload: unknown) => {
const p = payload as SubscriberAddedPayload;
if (p.issue_id !== issueId) return;
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.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(),
},
];
},
);
},
[issueId],
[qc, issueId],
),
);
@@ -67,11 +68,16 @@ export function useIssueSubscribers(issueId: string, userId?: string) {
(payload: unknown) => {
const p = payload as SubscriberRemovedPayload;
if (p.issue_id !== issueId) return;
setSubscribers((prev) =>
prev.filter((s) => !(s.user_id === p.user_id && s.user_type === p.user_type)),
qc.setQueryData<IssueSubscriber[]>(
issueKeys.subscribers(issueId),
(old) =>
old?.filter(
(s) =>
!(s.user_id === p.user_id && s.user_type === p.user_type),
),
);
},
[issueId],
[qc, issueId],
),
);
@@ -82,50 +88,29 @@ export function useIssueSubscribers(issueId: string, userId?: string) {
);
const toggleSubscriber = useCallback(
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");
}
}
async (
subUserId: string,
userType: "member" | "agent",
currentlySubscribed: boolean,
) => {
toggleMutation.mutate({
userId: subUserId,
userType,
subscribed: currentlySubscribed,
});
},
[issueId, subscribers],
[toggleMutation],
);
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,7 +1,8 @@
"use client";
import { useState, useEffect, useCallback } from "react";
import type { Comment, TimelineEntry } from "@/shared/types";
import { useState, useCallback, useMemo } from "react";
import { useQuery, useQueryClient, useMutationState } from "@tanstack/react-query";
import type { Comment, TimelineEntry, Reaction } from "@/shared/types";
import type {
CommentCreatedPayload,
CommentUpdatedPayload,
@@ -10,7 +11,14 @@ import type {
ReactionAddedPayload,
ReactionRemovedPayload,
} from "@/shared/types";
import { api } from "@/shared/api";
import { issueTimelineOptions, issueKeys } from "@core/issues/queries";
import {
useCreateComment,
useUpdateComment,
useDeleteComment,
useToggleCommentReaction,
type ToggleCommentReactionVars,
} from "@core/issues/mutations";
import { useWSEvent, useWSReconnect } from "@/features/realtime";
import { toast } from "sonner";
@@ -30,29 +38,22 @@ function commentToTimelineEntry(c: Comment): TimelineEntry {
}
export function useIssueTimeline(issueId: string, userId?: string) {
const [timeline, setTimeline] = useState<TimelineEntry[]>([]);
const qc = useQueryClient();
const { data: timeline = [], isLoading: loading } = useQuery(
issueTimelineOptions(issueId),
);
const [submitting, setSubmitting] = useState(false);
const [loading, setLoading] = useState(true);
// 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]);
const createCommentMutation = useCreateComment(issueId);
const updateCommentMutation = useUpdateComment(issueId);
const deleteCommentMutation = useDeleteComment(issueId);
const toggleReactionMutation = useToggleCommentReaction(issueId);
// Reconnect recovery
useWSReconnect(
useCallback(() => {
api.listTimeline(issueId).then(setTimeline).catch(console.error);
}, [issueId]),
qc.invalidateQueries({ queryKey: issueKeys.timeline(issueId) });
}, [qc, issueId]),
);
// --- WS event handlers ---
@@ -63,13 +64,16 @@ export function useIssueTimeline(issueId: string, userId?: string) {
(payload: unknown) => {
const { comment } = payload as CommentCreatedPayload;
if (comment.issue_id !== issueId) return;
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.setQueryData<TimelineEntry[]>(
issueKeys.timeline(issueId),
(old) => {
if (!old) return old;
if (old.some((e) => e.id === comment.id)) return old;
return [...old, commentToTimelineEntry(comment)];
},
);
},
[issueId, userId],
[qc, issueId],
),
);
@@ -79,12 +83,16 @@ export function useIssueTimeline(issueId: string, userId?: string) {
(payload: unknown) => {
const { comment } = payload as CommentUpdatedPayload;
if (comment.issue_id === issueId) {
setTimeline((prev) =>
prev.map((e) => (e.id === comment.id ? commentToTimelineEntry(comment) : e)),
qc.setQueryData<TimelineEntry[]>(
issueKeys.timeline(issueId),
(old) =>
old?.map((e) =>
e.id === comment.id ? commentToTimelineEntry(comment) : e,
),
);
}
},
[issueId],
[qc, issueId],
),
);
@@ -94,23 +102,31 @@ export function useIssueTimeline(issueId: string, userId?: string) {
(payload: unknown) => {
const { comment_id, issue_id } = payload as CommentDeletedPayload;
if (issue_id === issueId) {
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;
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;
}
}
}
}
return prev.filter((e) => !idsToRemove.has(e.id));
});
return old.filter((e) => !idsToRemove.has(e.id));
},
);
}
},
[issueId],
[qc, issueId],
),
);
@@ -122,12 +138,16 @@ export function useIssueTimeline(issueId: string, userId?: string) {
if (p.issue_id !== issueId) return;
const entry = p.entry;
if (!entry || !entry.id) return;
setTimeline((prev) => {
if (prev.some((e) => e.id === entry.id)) return prev;
return [...prev, entry];
});
qc.setQueryData<TimelineEntry[]>(
issueKeys.timeline(issueId),
(old) => {
if (!old) return old;
if (old.some((e) => e.id === entry.id)) return old;
return [...old, entry];
},
);
},
[issueId],
[qc, issueId],
),
);
@@ -137,17 +157,18 @@ export function useIssueTimeline(issueId: string, userId?: string) {
(payload: unknown) => {
const { reaction, issue_id } = payload as ReactionAddedPayload;
if (issue_id !== issueId) return;
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.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] };
}),
);
},
[issueId, userId],
[qc, issueId],
),
);
@@ -157,20 +178,26 @@ export function useIssueTimeline(issueId: string, userId?: string) {
(payload: unknown) => {
const p = payload as ReactionRemovedPayload;
if (p.issue_id !== issueId) return;
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.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
),
),
};
}),
);
},
[issueId, userId],
[qc, issueId],
),
);
@@ -181,10 +208,9 @@ export function useIssueTimeline(issueId: string, userId?: string) {
if (!content.trim() || submitting || !userId) return;
setSubmitting(true);
try {
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)];
await createCommentMutation.mutateAsync({
content,
attachmentIds,
});
} catch {
toast.error("Failed to send comment");
@@ -192,151 +218,121 @@ export function useIssueTimeline(issueId: string, userId?: string) {
setSubmitting(false);
}
},
[issueId, userId],
[userId, submitting, createCommentMutation],
);
const submitReply = useCallback(
async (parentId: string, content: string, attachmentIds?: string[]) => {
if (!content.trim() || !userId) return;
try {
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)];
await createCommentMutation.mutateAsync({
content,
type: "comment",
parentId,
attachmentIds,
});
} catch {
toast.error("Failed to send reply");
}
},
[issueId, userId],
[userId, createCommentMutation],
);
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 {
const updated = await api.updateComment(commentId, content);
setTimeline((prev) =>
prev.map((e) => (e.id === updated.id ? commentToTimelineEntry(updated) : e)),
);
await updateCommentMutation.mutateAsync({ commentId, content });
} 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 api.deleteComment(commentId);
await deleteCommentMutation.mutateAsync(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 = (entry?.reactions ?? []).find(
(r) => r.emoji === emoji && r.actor_type === "member" && r.actor_id === userId,
const existing: Reaction | undefined = (entry?.reactions ?? []).find(
(r) =>
r.emoji === emoji &&
r.actor_type === "member" &&
r.actor_id === userId,
);
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");
}
}
toggleReactionMutation.mutate({ commentId, emoji, existing });
},
[userId, timeline],
[userId, timeline, toggleReactionMutation],
);
return {
timeline,
timeline: optimisticTimeline,
loading,
submitting,
submitComment,

View File

@@ -1,57 +1,13 @@
"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";
const logger = createLogger("issue-store");
interface IssueState {
issues: Issue[];
loading: boolean;
interface IssueClientState {
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<IssueState>((set, get) => ({
issues: [],
loading: true,
export const useIssueStore = create<IssueClientState>((set) => ({
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, attach skills, and set triggers. Choose when it activates: on assignment, on comment, or on mention.",
"Give it a name, write instructions, and attach skills. Agents automatically activate on assignment, on comment, or on mention.",
},
{
title: "Assign an issue and watch it work",
@@ -272,6 +272,53 @@ 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",
@@ -356,7 +403,7 @@ export const en: LandingDict = {
title: "Core Platform",
changes: [
"Multi-workspace switching and creation",
"Agent management UI with skills, tools, and triggers",
"Agent management UI with skills",
"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,6 +272,53 @@ 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,9 +30,11 @@ 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 { useIssueStore } from "@/features/issues";
import { useQuery } from "@tanstack/react-query";
import { useWorkspaceId } from "@core/hooks";
import { memberListOptions, agentListOptions } from "@core/workspace/queries";
import { useIssueDraftStore } from "@/features/issues/stores/draft-store";
import { api } from "@/shared/api";
import { useCreateIssue } from "@core/issues/mutations";
import { useFileUpload } from "@/shared/hooks/use-file-upload";
import { FileUploadButton } from "@/components/common/file-upload-button";
import { ActorAvatar } from "@/components/common/actor-avatar";
@@ -68,8 +70,9 @@ 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 members = useWorkspaceStore((s) => s.members);
const agents = useWorkspaceStore((s) => s.agents);
const wsId = useWorkspaceId();
const { data: members = [] } = useQuery(memberListOptions(wsId));
const { data: agents = [] } = useQuery(agentListOptions(wsId));
const { getActorName } = useActorName();
const draft = useIssueDraftStore((s) => s.draft);
@@ -93,9 +96,16 @@ export function CreateIssueModal({ onClose, data }: { onClose: () => void; data?
// Due date popover
const [dueDateOpen, setDueDateOpen] = useState(false);
// File upload
// File upload — collect attachment IDs so we can link them after issue creation.
const [attachmentIds, setAttachmentIds] = useState<string[]>([]);
const { uploadWithToast } = useFileUpload();
const handleUpload = (file: File) => uploadWithToast(file);
const handleUpload = async (file: File) => {
const result = await uploadWithToast(file);
if (result) {
setAttachmentIds((prev) => [...prev, result.id]);
}
return result;
};
const assigneeQuery = assigneeFilter.toLowerCase();
const filteredMembers = members.filter((m) => m.name.toLowerCase().includes(assigneeQuery));
@@ -118,11 +128,12 @@ 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 api.createIssue({
const issue = await createIssueMutation.mutateAsync({
title: title.trim(),
description: descEditorRef.current?.getMarkdown()?.trim() || undefined,
status,
@@ -130,8 +141,9 @@ 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) => (
@@ -185,7 +197,13 @@ 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" />
<span className="font-medium">New issue</span>
{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>
</div>
<div className="flex items-center gap-1">
<Tooltip>

View File

@@ -1,6 +1,7 @@
"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";
@@ -18,6 +19,7 @@ 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);
@@ -50,6 +52,7 @@ 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,7 +8,8 @@ import type { IssueStatus } from "@/shared/types";
import { Skeleton } from "@/components/ui/skeleton";
import { useAuthStore } from "@/features/auth";
import { useWorkspaceStore, WorkspaceAvatar } from "@/features/workspace";
import { useIssueStore } from "@/features/issues/store";
import { useQuery } from "@tanstack/react-query";
import { agentListOptions } from "@core/workspace/queries";
import { filterIssues } from "@/features/issues/utils/filter";
import { BOARD_STATUSES } from "@/features/issues/config";
import { ViewStoreProvider } from "@/features/issues/stores/view-store-context";
@@ -17,16 +18,18 @@ 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 { api } from "@/shared/api";
import { useWorkspaceId } from "@core/hooks";
import { issueListOptions } from "@core/issues/queries";
import { useUpdateIssue } from "@core/issues/mutations";
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 agents = useWorkspaceStore((s) => s.agents);
const allIssues = useIssueStore((s) => s.issues);
const loading = useIssueStore((s) => s.loading);
const wsId = useWorkspaceId();
const { data: agents = [] } = useQuery(agentListOptions(wsId));
const { data: allIssues = [], isLoading: loading } = useQuery(issueListOptions(wsId));
const viewMode = useStore(myIssuesViewStore, (s) => s.viewMode);
const statusFilters = useStore(myIssuesViewStore, (s) => s.statusFilters);
@@ -105,6 +108,7 @@ 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();
@@ -118,16 +122,12 @@ export function MyIssuesPage() {
};
if (newPosition !== undefined) updates.position = newPosition;
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.mutate(
{ id: issueId, ...updates },
{ onError: () => toast.error("Failed to move issue") },
);
},
[],
[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) => void;
type EventHandler = (payload: unknown, actorId?: string) => 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) => void;
type EventHandler = (payload: unknown, actorId?: string) => void;
interface WSContextValue {
subscribe: (event: WSEventType, handler: EventHandler) => () => void;

View File

@@ -1,14 +1,21 @@
"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 { api } from "@/shared/api";
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 type {
MemberAddedPayload,
WorkspaceDeletedPayload,
@@ -17,6 +24,16 @@ import type {
IssueCreatedPayload,
IssueDeletedPayload,
InboxNewPayload,
CommentCreatedPayload,
CommentUpdatedPayload,
CommentDeletedPayload,
ActivityCreatedPayload,
ReactionAddedPayload,
ReactionRemovedPayload,
IssueReactionAddedPayload,
IssueReactionRemovedPayload,
SubscriberAddedPayload,
SubscriberRemovedPayload,
} from "@/shared/types";
const logger = createLogger("realtime-sync");
@@ -29,37 +46,36 @@ 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-page events (comments, activity, subscribers, daemon) are still handled
* by individual components via useWSEvent — not here.
* 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.
*/
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: () => void useInboxStore.getState().fetch(),
agent: () => void useWorkspaceStore.getState().refreshAgents(),
member: () => void useWorkspaceStore.getState().refreshMembers(),
workspace: () => {
// 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);
});
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) });
},
workspace: () => {
qc.invalidateQueries({ queryKey: workspaceKeys.list() });
},
skill: () => {
const wsId = useWorkspaceStore.getState().workspace?.id;
if (wsId) qc.invalidateQueries({ queryKey: workspaceKeys.skills(wsId) });
},
skill: () => void useWorkspaceStore.getState().refreshSkills(),
};
const timers = new Map<string, ReturnType<typeof setTimeout>>();
@@ -75,42 +91,121 @@ 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 updates, no full refetch) ---
// --- 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.
const unsubIssueUpdated = ws.on("issue:updated", (p) => {
const { issue } = p as IssueUpdatedPayload;
if (!issue?.id) return;
useIssueStore.getState().updateIssue(issue.id, issue);
if (issue.status) {
useInboxStore.getState().updateIssueStatus(issue.id, issue.status);
const wsId = useWorkspaceStore.getState().workspace?.id;
if (wsId) {
onIssueUpdated(qc, wsId, issue);
if (issue.status) {
onInboxIssueStatusChanged(qc, wsId, issue.id, issue.status);
}
}
});
const unsubIssueCreated = ws.on("issue:created", (p) => {
const { issue } = p as IssueCreatedPayload;
if (issue) useIssueStore.getState().addIssue(issue);
if (!issue) return;
const wsId = useWorkspaceStore.getState().workspace?.id;
if (wsId) onIssueCreated(qc, wsId, issue);
});
const unsubIssueDeleted = ws.on("issue:deleted", (p) => {
const { issue_id } = p as IssueDeletedPayload;
if (issue_id) useIssueStore.getState().removeIssue(issue_id);
if (!issue_id) return;
const wsId = useWorkspaceStore.getState().workspace?.id;
if (wsId) onIssueDeleted(qc, wsId, issue_id);
});
const unsubInboxNew = ws.on("inbox:new", (p) => {
const { item } = p as InboxNewPayload;
if (item) useInboxStore.getState().addItem(item);
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) });
});
// --- Side-effect handlers (toast, navigation) ---
@@ -152,13 +247,23 @@ 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]);
}, [ws, qc]);
// Reconnect → refetch all data to recover missed events
useEffect(() => {
@@ -167,18 +272,20 @@ export function useRealtimeSync(ws: WSClient | null) {
const unsub = ws.onReconnect(async () => {
logger.info("reconnected, refetching all data");
try {
await Promise.all([
useIssueStore.getState().fetch(),
useInboxStore.getState().fetch(),
useWorkspaceStore.getState().refreshAgents(),
useWorkspaceStore.getState().refreshMembers(),
useWorkspaceStore.getState().refreshSkills(),
]);
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() });
} catch (e) {
logger.error("reconnect refetch failed", e);
}
});
return unsub;
}, [ws]);
}, [ws, qc]);
}

View File

@@ -1,8 +1,9 @@
"use client";
import { useEffect, useCallback } from "react";
import { useState, useCallback } from "react";
import { Server } from "lucide-react";
import { useDefaultLayout } from "react-resizable-panels";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import {
ResizablePanelGroup,
ResizablePanel,
@@ -10,38 +11,35 @@ import {
} from "@/components/ui/resizable";
import { Skeleton } from "@/components/ui/skeleton";
import { useAuthStore } from "@/features/auth";
import { useWorkspaceStore } from "@/features/workspace";
import { useWorkspaceId } from "@core/hooks";
import { runtimeListOptions, runtimeKeys } from "@core/runtimes/queries";
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 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 wsId = useWorkspaceId();
const qc = useQueryClient();
const { data: runtimes = [], isLoading: fetching } = useQuery(runtimeListOptions(wsId));
const [selectedId, setSelectedId] = useState("");
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(() => {
fetchRuntimes();
}, [fetchRuntimes]);
qc.invalidateQueries({ queryKey: runtimeKeys.list(wsId) });
}, [qc, wsId]);
useWSEvent("daemon:register", handleDaemonEvent);
const selected = runtimes.find((r) => r.id === selectedId) ?? null;
// 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;
if (isLoading || fetching) {
return (
@@ -95,7 +93,7 @@ export default function RuntimesPage() {
>
<RuntimeList
runtimes={runtimes}
selectedId={selectedId}
selectedId={effectiveSelectedId}
onSelect={setSelectedId}
/>
</ResizablePanel>

View File

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

View File

@@ -1,70 +0,0 @@
"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,8 +33,10 @@ 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 { useWorkspaceStore } from "@/features/workspace";
import { useWorkspaceId } from "@core/hooks";
import { skillListOptions, workspaceKeys } from "@core/workspace/queries";
import { FileTree } from "./file-tree";
import { FileViewer } from "./file-viewer";
@@ -346,6 +348,8 @@ 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);
@@ -370,12 +374,12 @@ function SkillDetail({
setSelectedPath(SKILL_MD);
setLoadingFiles(true);
api.getSkill(skill.id).then((full) => {
useWorkspaceStore.getState().upsertSkill(full);
qc.invalidateQueries({ queryKey: workspaceKeys.skills(wsId) });
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]);
}, [skill.id, qc, wsId]);
// Build the virtual file map
const fileMap = useMemo(() => buildFileMap(content, files), [content, files]);
@@ -610,10 +614,9 @@ function SkillDetail({
export default function SkillsPage() {
const isLoading = useAuthStore((s) => s.isLoading);
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 qc = useQueryClient();
const wsId = useWorkspaceId();
const { data: skills = [] } = useQuery(skillListOptions(wsId));
const [selectedId, setSelectedId] = useState<string>("");
const [showCreate, setShowCreate] = useState(false);
const { defaultLayout, onLayoutChanged } = useDefaultLayout({
@@ -628,22 +631,22 @@ export default function SkillsPage() {
const handleCreate = async (data: CreateSkillRequest) => {
const skill = await api.createSkill(data);
upsertSkill(skill);
qc.invalidateQueries({ queryKey: workspaceKeys.skills(wsId) });
setSelectedId(skill.id);
toast.success("Skill created");
};
const handleImport = async (url: string) => {
const skill = await api.importSkill({ url });
upsertSkill(skill);
qc.invalidateQueries({ queryKey: workspaceKeys.skills(wsId) });
setSelectedId(skill.id);
toast.success("Skill imported");
};
const handleUpdate = async (id: string, data: UpdateSkillRequest) => {
try {
const updated = await api.updateSkill(id, data);
upsertSkill(updated);
await api.updateSkill(id, data);
qc.invalidateQueries({ queryKey: workspaceKeys.skills(wsId) });
toast.success("Skill saved");
} catch (e) {
toast.error(e instanceof Error ? e.message : "Failed to save skill");
@@ -658,7 +661,7 @@ export default function SkillsPage() {
const remaining = skills.filter((s) => s.id !== id);
setSelectedId(remaining[0]?.id ?? "");
}
removeSkill(id);
qc.invalidateQueries({ queryKey: workspaceKeys.skills(wsId) });
toast.success("Skill deleted");
} catch (e) {
toast.error(e instanceof Error ? e.message : "Failed to delete skill");

View File

@@ -1,10 +1,13 @@
"use client";
import { useWorkspaceStore } from "./store";
import { useQuery } from "@tanstack/react-query";
import { useWorkspaceId } from "@core/hooks";
import { memberListOptions, agentListOptions } from "@core/workspace/queries";
export function useActorName() {
const members = useWorkspaceStore((s) => s.members);
const agents = useWorkspaceStore((s) => s.agents);
const wsId = useWorkspaceId();
const { data: members = [] } = useQuery(memberListOptions(wsId));
const { data: agents = [] } = useQuery(agentListOptions(wsId));
const getMemberName = (userId: string) => {
const m = members.find((m) => m.user_id === userId);

View File

@@ -1,10 +1,7 @@
"use client";
import { create } from "zustand";
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 type { Workspace } from "@/shared/types";
import { toast } from "sonner";
import { api } from "@/shared/api";
import { createLogger } from "@/shared/logger";
@@ -14,30 +11,21 @@ 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,
) => Promise<Workspace | null>;
switchWorkspace: (workspaceId: string) => Promise<void>;
) => Workspace | null;
switchWorkspace: (workspaceId: string) => void;
refreshWorkspaces: () => Promise<Workspace[]>;
refreshMembers: () => Promise<void>;
updateAgent: (id: string, updates: Partial<Agent>) => void;
refreshAgents: () => Promise<void>;
refreshSkills: () => Promise<void>;
upsertSkill: (skill: Skill) => void;
removeSkill: (id: string) => void;
updateWorkspace: (ws: Workspace) => 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;
@@ -49,12 +37,9 @@ export const useWorkspaceStore = create<WorkspaceStore>((set, get) => ({
// State
workspace: null,
workspaces: [],
members: [],
agents: [],
skills: [],
// Actions
hydrateWorkspace: async (wsList, preferredWorkspaceId) => {
hydrateWorkspace: (wsList, preferredWorkspaceId) => {
set({ workspaces: wsList });
const nextWorkspace =
@@ -67,56 +52,35 @@ export const useWorkspaceStore = create<WorkspaceStore>((set, get) => ({
if (!nextWorkspace) {
api.setWorkspaceId(null);
localStorage.removeItem("multica_workspace_id");
set({ workspace: null, members: [], agents: [], skills: [] });
set({ workspace: null });
return null;
}
api.setWorkspaceId(nextWorkspace.id);
localStorage.setItem("multica_workspace_id", nextWorkspace.id);
set({ workspace: nextWorkspace });
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 });
// 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.
return nextWorkspace;
},
switchWorkspace: async (workspaceId) => {
switchWorkspace: (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);
// Clear ALL stale data across every store before hydrating.
useIssueStore.getState().setIssues([]);
useInboxStore.getState().setItems([]);
useRuntimeStore.getState().setRuntimes([]);
set({ workspace: ws, members: [], agents: [], skills: [] });
// All data caches (issues, inbox, members, agents, skills, runtimes)
// are managed by TanStack Query, keyed by wsId — auto-refetch on switch.
set({ workspace: ws });
await hydrateWorkspace(workspaces, ws.id);
hydrateWorkspace(workspaces, ws.id);
},
refreshWorkspaces: async () => {
@@ -124,7 +88,7 @@ export const useWorkspaceStore = create<WorkspaceStore>((set, get) => ({
const storedWorkspaceId = localStorage.getItem("multica_workspace_id");
try {
const wsList = await api.listWorkspaces();
await hydrateWorkspace(wsList, workspace?.id ?? storedWorkspaceId);
hydrateWorkspace(wsList, workspace?.id ?? storedWorkspaceId);
return wsList;
} catch (e) {
logger.error("failed to refresh workspaces", e);
@@ -133,77 +97,6 @@ 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,
@@ -213,13 +106,19 @@ 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);
await hydrateWorkspace(wsList, preferredWorkspaceId);
hydrateWorkspace(wsList, preferredWorkspaceId);
},
deleteWorkspace: async (workspaceId) => {
@@ -228,12 +127,11 @@ export const useWorkspaceStore = create<WorkspaceStore>((set, get) => ({
const wsList = await api.listWorkspaces();
const preferredWorkspaceId =
workspace?.id === workspaceId ? null : (workspace?.id ?? null);
await hydrateWorkspace(wsList, preferredWorkspaceId);
hydrateWorkspace(wsList, preferredWorkspaceId);
},
clearWorkspace: () => {
api.setWorkspaceId(null);
localStorage.removeItem("multica_workspace_id");
set({ workspace: null, workspaces: [], members: [], agents: [], skills: [] });
set({ workspace: null, workspaces: [] });
},
}));

View File

@@ -7,7 +7,24 @@ 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,11 +18,12 @@
"@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",
@@ -33,6 +34,7 @@
"@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,7 +114,8 @@ export class ApiClient {
if (!res.ok) {
if (res.status === 401) this.handleUnauthorized();
const message = await this.parseErrorMessage(res, `API error: ${res.status} ${res.statusText}`);
this.logger.error(`${res.status} ${path}`, { rid, duration: `${Date.now() - start}ms`, error: message });
const logLevel = res.status === 404 ? "warn" : "error";
this.logger[logLevel](`${res.status} ${path}`, { rid, duration: `${Date.now() - start}ms`, error: message });
throw new Error(message);
}
@@ -143,6 +144,13 @@ 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");
}
@@ -164,6 +172,7 @@ 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}`);
}
@@ -187,6 +196,10 @@ 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" });
}
@@ -371,7 +384,7 @@ export class ApiClient {
return this.fetch(`/api/agents/${agentId}/tasks`);
}
async getActiveTaskForIssue(issueId: string): Promise<{ task: AgentTask | null }> {
async getActiveTasksForIssue(issueId: string): Promise<{ tasks: AgentTask[] }> {
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) => void;
type EventHandler = (payload: unknown, actorId?: string) => 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);
handler(msg.payload, msg.actor_id);
}
}
for (const handler of this.anyHandlers) {

View File

@@ -0,0 +1,8 @@
import { describe, it, expect } from "vitest";
import { MAX_FILE_SIZE } from "../upload";
describe("upload constants", () => {
it("exports MAX_FILE_SIZE as 100MB", () => {
expect(MAX_FILE_SIZE).toBe(100 * 1024 * 1024);
});
});

View File

@@ -0,0 +1 @@
export const MAX_FILE_SIZE = 100 * 1024 * 1024; // 100 MB

View File

@@ -4,8 +4,7 @@ import { useState, useCallback } from "react";
import { toast } from "sonner";
import { api } from "@/shared/api";
import type { Attachment } from "@/shared/types";
const MAX_FILE_SIZE = 100 * 1024 * 1024; // 100 MB
import { MAX_FILE_SIZE } from "@/shared/constants/upload";
export interface UploadResult {
id: string;

View File

@@ -4,8 +4,6 @@ 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;
@@ -23,22 +21,6 @@ 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;
@@ -69,8 +51,6 @@ 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;
@@ -86,8 +66,6 @@ export interface CreateAgentRequest {
runtime_config?: Record<string, unknown>;
visibility?: AgentVisibility;
max_concurrent_tasks?: number;
tools?: AgentTool[];
triggers?: AgentTrigger[];
}
export interface UpdateAgentRequest {
@@ -100,8 +78,6 @@ export interface UpdateAgentRequest {
visibility?: AgentVisibility;
status?: AgentStatus;
max_concurrent_tasks?: number;
tools?: AgentTool[];
triggers?: AgentTrigger[];
}
// Skills

View File

@@ -11,6 +11,7 @@ export interface CreateIssueRequest {
assignee_id?: string;
parent_issue_id?: string;
due_date?: string;
attachment_ids?: string[];
}
export interface UpdateIssueRequest {
@@ -22,6 +23,7 @@ export interface UpdateIssueRequest {
assignee_id?: string | null;
position?: number;
due_date?: string | null;
parent_issue_id?: string | null;
}
export interface ListIssuesParams {
@@ -31,11 +33,14 @@ 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,9 +4,6 @@ export type {
AgentStatus,
AgentRuntimeMode,
AgentVisibility,
AgentTriggerType,
AgentTool,
AgentTrigger,
AgentTask,
AgentRuntime,
RuntimeDevice,

View File

@@ -58,8 +58,6 @@ 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,
@@ -85,8 +83,6 @@ 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,6 +28,9 @@
"paths": {
"@/*": [
"./*"
],
"@core/*": [
"./core/*"
]
},
"noEmit": true,

Some files were not shown because too many files have changed in this diff Show More