Compare commits

..

146 Commits

Author SHA1 Message Date
Naiyuan Qing
a9b3d4e6f4 docs: update CLAUDE.md for monorepo architecture
Rewrite architecture section to reflect the three-package monorepo
structure (core/ui/views). Key changes:

- Replace old 4-layer structure (app/core/features/shared) with
  package architecture and platform bridge pattern
- Document store factory pattern (createAuthStore, createWorkspaceStore)
- Document StorageAdapter, NavigationAdapter abstractions
- Update import conventions (@multica/core, @multica/ui, @multica/views)
- Add package boundary rules section
- Update shadcn command for monorepo (npx shadcn add -c apps/web)
- Remove references to deleted dirs (shared/, core/ inside apps/web)
- Keep backend section unchanged (not affected by extraction)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 14:04:06 +08:00
Naiyuan Qing
711ab886e2 Merge pull request #539 from multica-ai/feat/monorepo-extraction
feat: monorepo extraction — packages/core + ui + views
2026-04-09 13:55:34 +08:00
Naiyuan Qing
a092443a09 merge: resolve conflicts with main (search + runtime owner/delete)
- Merge origin/main (4 commits: search, runtime owner, multi-agent fix)
- Migrate new search feature imports to monorepo paths
- Move new runtime mutations to packages/core/runtimes/
- Resolve 5 conflicts in layout, runtime components

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 13:50:36 +08:00
Naiyuan Qing
de73d39310 fix: address code review — SSR safety, missing deps, stale config
Critical:
- Create webStorage adapter (SSR-safe localStorage wrapper)
- Replace bare localStorage in platform/auth.ts and platform/workspace.ts
- Add all missing dependencies to packages/views/package.json
  (sonner, @dnd-kit/*, @tiptap/*, recharts, lowlight, etc.)

Important:
- Delete duplicate apps/web/components/common/actor-avatar.tsx
  (identical to packages/views/common/actor-avatar.tsx)
- Update components.json aliases to point to @multica/ui/*
- Remove empty apps/web/shared/ directory

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 13:41:01 +08:00
LinYushen
ff27a249cc feat(runtime): add owner tracking, filtering, and delete (#535)
Add owner_id to agent_runtime table to track who registered each runtime.
Backend: new delete endpoint with role-based permissions (owner/admin can
delete any, members only their own), list filtering by owner (?owner=me),
and agent dependency check before deletion.
Frontend: Mine/All filter toggle in runtime list, owner display in list
items and detail view, delete button with AlertDialog confirmation.

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 13:38:46 +08:00
Naiyuan Qing
4668aad039 refactor(core): remove platform coupling — StorageAdapter, sonner, barrel cleanup
P0: Replace all localStorage calls in packages/core with StorageAdapter
- Create StorageAdapter interface (getItem/setItem/removeItem)
- Auth store factory now requires storage parameter
- Workspace store factory accepts optional storage parameter
- WSProvider accepts storage prop for token retrieval
- apps/web/platform/ passes localStorage as the web implementation

P1: Remove sonner UI dependency from packages/core
- Replace toast.error() in workspace store with onError callback
- Move sonner import to apps/web/platform/workspace.ts
- Remove sonner from packages/core/package.json dependencies

P2: Delete 5 pure re-export barrel files in apps/web/features/
- features/issues/index.ts, modals/index.ts, navigation/index.ts,
  workspace/index.ts, inbox/index.ts — all had zero consumers
- features/ now only contains auth/ (web-only cookie + initializer)
  and landing/ (web-only pages)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 13:16:51 +08:00
yushen
b484b78cbd fix(search): use rune-based snippet slicing and fix dialog a11y
- extractSnippet now uses rune-based indexing to avoid splitting multi-byte
  UTF-8 characters (CJK safety)
- Move DialogHeader inside DialogContent for correct DOM/a11y structure
- Add cleanup useEffect for debounce timer and abort controller on unmount

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 13:11:07 +08:00
LinYushen
23136da34f feat(search): implement full-text search for issues (#507)
* feat(search): implement full-text search for issues

Add pg_bigm-based full-text search across issue titles and descriptions,
with API endpoint, CLI subcommand, and web Cmd+K search dialog.

- Migration 032: pg_bigm extension + GIN indexes on title/description
- Server: GET /api/issues/search?q=... with pagination and total count
- CLI: `multica issue search <query>` with table/json output
- Web: Cmd+K command palette using cmdk, with debounced search

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

* fix(search): address review feedback on search implementation

1. Escape LIKE special characters (%, _, \) in handler to prevent
   matching anomalies from user input.
2. Wire AbortController signal into searchIssues fetch so in-flight
   requests are actually cancelled on new input.
3. Fix offset=0 falsy check — use !== undefined instead of truthiness.
4. Merge results + count into single query using COUNT(*) OVER()
   window function, eliminating the duplicate DB round-trip.
5. Exclude done/cancelled issues by default; add include_closed
   parameter to API, CLI (--include-closed), and web client.

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

* fix(search): default web search to include all statuses

Pass include_closed: true in the web Cmd+K search so results include
done and cancelled issues by default, matching the reviewer's request.

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

* feat(search): add comment search with snippet extraction

Extend search to cover issue comments in addition to title/description.
Results are deduplicated at the issue level, with match_source and
matched_snippet fields indicating where and what matched.

- Migration 033: pg_bigm GIN index on comment.content
- SQL: EXISTS subquery for comment matching, correlated subquery for
  snippet extraction, 3-tier ranking (title > description > comment)
- Server: SearchIssueResponse with match_source and matched_snippet
- Web: show comment icon + snippet below issue title when matched
- CLI: MATCH column shows source and truncated snippet

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

* feat(search): redesign search dialog to match Linear's spacious style

- Widen dialog from sm (384px) to xl (576px) with top-20% positioning
- Larger search input with icon, generous padding, and ESC hint
- Use cmdk primitives directly for full style control
- Taller result list (400px / 50vh), spacious result items (py-2.5)
- Rounded-lg items with accent highlight on selection
- Cleaner border separator between input and results

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 12:43:21 +08:00
Bohan Jiang
5d1cc2a9bb fix(web): multi-agent sticky card with expand/collapse (#516)
* fix(web): multi-agent sticky card with expand/collapse pattern

- Move sticky positioning to the wrapper div so the entire agent area
  sticks together instead of each card independently
- Show first agent card always visible, with "N more agents working"
  expand button for additional agents
- Remove scrollContainerRef prop (no longer needed with native sticky)
- Simplify SingleAgentLiveCard by removing auto-collapse-on-scroll logic

* fix(web): pin primary agent card to top and drop collapse UI

- Remove the mt-4 wrapper around AgentLiveCard in issue-detail so the
  sticky wrapper is a direct child of the Activity section — sticky now
  has a tall enough parent to stay pinned through TaskRunHistory and
  the full comment timeline
- Simplify multi-agent rendering: only the first running agent sticks
  to the top, any additional agents render below it and scroll with
  the page. Removes the expand/collapse "N more agents working" button
2026-04-09 12:36:43 +08:00
Naiyuan Qing
f41a0cf423 feat(views): extract packages/views — shared business UI + navigation adapter
- Create NavigationAdapter interface (push, replace, back, pathname, searchParams)
- Create AppLink component replacing next/link in 4 files
- Replace useRouter → useNavigation in 3 files (issue-detail, create-issue, create-workspace)
- Create WebNavigationProvider wrapping Next.js useRouter/usePathname/useSearchParams
- Move ~85 feature UI files (issues, editor, modals, my-issues, skills, runtimes) to packages/views/
- Add store singleton registration pattern (registerAuthStore, registerWorkspaceStore)
- Create data-aware wrappers in packages/views/common/ (ActorAvatar, Markdown)
- Update all app-layer imports to @multica/views/*
- Add @source directive for Tailwind to scan views package
- packages/views/ has zero next/* imports

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 11:49:55 +08:00
Naiyuan Qing
35828492d5 feat(ui): extract packages/ui — shared atomic UI layer
- Move 55 shadcn components → packages/ui/components/ui/
- Move lib/utils.ts (cn function) → packages/ui/lib/
- Move 3 DOM hooks (auto-scroll, mobile, scroll-fade) → packages/ui/hooks/
- Extract CSS design tokens (@theme + :root + .dark) → packages/ui/styles/tokens.css
- Refactor 3 common components to pure-props (actor-avatar, mention-hover-card, reaction-bar)
- Move 6 markdown components with renderMention slot for IssueMentionCard decoupling
- Create wrapper components in apps/web/ for data-aware ActorAvatar and Markdown
- Update 116 import paths across apps/web/
- Add @source directives for Tailwind to scan packages

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 10:44:31 +08:00
Naiyuan Qing
e1e7f68330 feat: extract packages/core — Turborepo infrastructure + headless business logic
Phase 1: Monorepo infrastructure
- Add Turborepo with turbo.json pipeline (build, dev, typecheck, test)
- Update pnpm-workspace.yaml to include packages/*
- Create shared TypeScript config (packages/tsconfig)

Phase 2: Extract packages/core (zero react-dom, all-platform reuse)
- Move domain types, API client, logger, utils → packages/core/
- Move TanStack Query modules (issues, inbox, workspace, runtimes)
- Move Zustand stores (auth, workspace, issues, navigation, modals)
- Move realtime sync (WSProvider, hooks, ws-updaters)
- Refactor auth/workspace stores to factory pattern for DI
- Refactor ApiClient with onUnauthorized callback
- Refactor useWorkspaceId to React Context (WorkspaceIdProvider)
- Refactor WSProvider to accept wsUrl + store props
- Create apps/web/platform/ bridge layer (api singleton, store instances)
- Update 91 import paths across apps/web/
- Fix 3 test files for new import paths

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 10:20:00 +08:00
Naiyuan Qing
e2da970344 Merge pull request #530 from multica-ai/feat/drag-upload
feat(editor): drag-and-drop file upload with file card
2026-04-09 09:32:48 +08:00
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
388 changed files with 16823 additions and 5496 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. -->

201
CLAUDE.md
View File

@@ -12,77 +12,162 @@ Multica is an AI-native task management platform — like Linear, but with AI ag
## Architecture
**Go backend + standalone Next.js frontend.**
**Go backend + monorepo frontend with shared packages.**
- `server/` — Go backend (Chi router, sqlc for DB, gorilla/websocket for real-time)
- `apps/web/` — Next.js 16 frontend (App Router) — self-contained, no shared package dependencies
- `apps/web/` — Next.js 16 frontend (App Router)
- `packages/core/` — Headless business logic (zero react-dom, all-platform reuse)
- `packages/ui/` — Atomic UI components (zero business logic)
- `packages/views/` — Shared business pages/components (zero next/* imports)
- `packages/tsconfig/` — Shared TypeScript configuration
### Web App Structure (`apps/web/`)
### Package Architecture
The frontend uses a **feature-based architecture** with four layers:
Three shared packages with single-direction dependencies:
```
packages/
├── core/ # @multica/core — types, API client, stores, queries, mutations, realtime
├── ui/ # @multica/ui — 55 shadcn components, common components, markdown, hooks
├── views/ # @multica/views — issue pages, editor, modals, skills, runtimes, navigation
└── tsconfig/ # @multica/tsconfig — shared TS base configs
```
**Dependency direction:** `views/ → core/ + ui/`. Core and UI are independent of each other. No package imports from `next/*` or `apps/web/`.
**Platform bridge:** `apps/web/platform/` is the only place that touches `process.env`, `next/navigation`, and creates store/api singletons. Each future app (desktop, mobile) provides its own platform layer.
### packages/core/ (`@multica/core`)
Headless business logic. **Zero react-dom, zero localStorage, zero process.env.**
| Module | Purpose | Key exports |
|---|---|---|
| `core/types/` | Domain types + StorageAdapter interface | `Issue`, `Agent`, `Workspace`, `StorageAdapter` |
| `core/api/` | API client class + WS client | `ApiClient`, `WSClient`, `setApiInstance()` |
| `core/auth/` | Auth store factory | `createAuthStore(options)`, `registerAuthStore()` |
| `core/workspace/` | Workspace store factory + actor hooks | `createWorkspaceStore(api)`, `useActorName()` |
| `core/issues/` | Issue queries, mutations, stores, config | `issueListOptions`, `useUpdateIssue`, `useIssueStore` |
| `core/inbox/` | Inbox queries, mutations, WS updaters | `inboxListOptions`, `useMarkInboxRead` |
| `core/runtimes/` | Runtime queries + mutations | `runtimeListOptions`, `useDeleteRuntime` |
| `core/realtime/` | WS provider + sync hooks | `WSProvider`, `useWSEvent`, `useRealtimeSync` |
| `core/hooks.tsx` | Workspace ID context | `useWorkspaceId`, `WorkspaceIdProvider` |
| `core/modals/` | Modal state store | `useModalStore` |
| `core/navigation/` | Navigation state store | `useNavigationStore` |
**Store factory pattern:** Auth and workspace stores are created via factory functions that receive platform-specific dependencies:
```typescript
createAuthStore({ api, storage, onLogin?, onLogout? })
createWorkspaceStore(api, { storage?, onError? })
```
Each app creates its own instances in its platform layer and registers them via `registerAuthStore()` / `registerWorkspaceStore()`.
**StorageAdapter:** All persistent storage goes through a `StorageAdapter` interface (getItem/setItem/removeItem), injected by the platform. Web uses an SSR-safe localStorage wrapper.
### packages/ui/ (`@multica/ui`)
Atomic UI layer. **Zero business logic, zero `@multica/core` imports.**
- `components/ui/` — 55 shadcn components (button, dialog, card, tooltip, sidebar, etc.)
- `components/common/` — Pure-props components (actor-avatar, emoji-picker, reaction-bar, file-upload-button)
- `markdown/` — Markdown renderer with `renderMention` slot for platform-specific mention cards
- `hooks/` — DOM hooks (use-auto-scroll, use-mobile, use-scroll-fade)
- `lib/utils.ts``cn()` function (clsx + tailwind-merge)
- `styles/tokens.css` — Tailwind CSS v4 design tokens (@theme, :root, .dark variables)
### packages/views/ (`@multica/views`)
Shared business UI pages. **Zero `next/*` imports.** Uses `NavigationAdapter` for routing.
- `navigation/``NavigationAdapter` interface, `useNavigation()` hook, `AppLink` component
- `issues/components/` — IssuesPage, IssueDetail, BoardView, ListView, pickers, icons
- `editor/` — ContentEditor, TitleEditor, Tiptap extensions
- `modals/` — CreateIssueModal, CreateWorkspaceModal, ModalRegistry
- `my-issues/`, `skills/`, `runtimes/` — domain pages
- `common/` — Data-aware wrappers (ActorAvatar with useActorName, Markdown with IssueMentionCard)
### apps/web/ (Next.js App)
Thin routing shells + platform-specific code.
```
apps/web/
├── app/ # Routing layer (thin shells — import from features/)
├── features/ # Business logic, organized by domain
├── shared/ # Cross-feature utilities (api client, types, logger)
├── app/ # Next.js route shells (< 15 lines each, import from @multica/views)
├── platform/ # Web platform bridge (api singleton, store instances, navigation, storage)
├── features/
│ ├── auth/ # Web-only: auth-cookie.ts, initializer.tsx
│ ├── landing/ # Web-only: landing pages (uses next/image, next/link)
│ └── search/ # Web-only: search dialog
└── components/ # App-level: theme-provider, multica-icon, locale-sync, loading-indicator
```
**`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:
| 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/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/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.
**`platform/`** — The only code that touches Next.js APIs and browser globals:
- `api.ts` — Creates `ApiClient` singleton with `onUnauthorized` redirect
- `auth.ts``createAuthStore({ api, storage: webStorage, onLogin: setLoggedInCookie })`
- `workspace.ts``createWorkspaceStore(api, { storage: webStorage, onError: toast.error })`
- `ws-provider.tsx` — Wraps `WSProvider` with web-specific WS URL and store instances
- `navigation.tsx``WebNavigationProvider` wrapping Next.js `useRouter`/`usePathname`
- `storage.ts` — SSR-safe `webStorage` adapter (guards `localStorage` with `typeof window` checks)
### 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`).
- **React Context** only for connection lifecycle (`WSProvider` in `features/realtime/`).
- **TanStack Query** for all server state — issues, inbox, members, agents, skills, runtimes. Query definitions in `@multica/core/<domain>/queries.ts`, mutations in `mutations.ts`.
- **Zustand** for client-only state — UI selections (`activeIssueId`), view filters, modal state. Auth and workspace stores use factory pattern with injected dependencies.
- **React Context** for `WorkspaceIdProvider` (provides workspace ID to all dashboard children) and `NavigationProvider` (provides platform-agnostic routing).
- **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)`.
- 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.
**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.
### Import Aliases
**Zustand store conventions:**
- Stores in `@multica/core` hold only client state. Zero direct `api.*` calls — API access is injected via factory.
- Auth/workspace stores are created by platform layer and registered via `registerAuthStore()` / `registerWorkspaceStore()`.
- Other stores (issue, modal, navigation) are plain Zustand stores exported directly.
### Import Conventions
Use `@/` alias (maps to `apps/web/`):
```typescript
import { api } from "@/shared/api";
import type { Issue } from "@/shared/types";
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";
// Core (headless business logic) — from @multica/core
import { issueListOptions } from "@multica/core/issues/queries";
import { useUpdateIssue } from "@multica/core/issues/mutations";
import { useWorkspaceId } from "@multica/core/hooks";
import type { Issue } from "@multica/core/types";
// UI (atomic components) — from @multica/ui
import { Button } from "@multica/ui/components/ui/button";
import { cn } from "@multica/ui/lib/utils";
import { ActorAvatar } from "@multica/ui/components/common/actor-avatar";
// Views (shared pages) — from @multica/views
import { IssuesPage } from "@multica/views/issues/components";
import { useNavigation, AppLink } from "@multica/views/navigation";
import { ModalRegistry } from "@multica/views/modals/registry";
// Platform (web-only singletons) — from @/platform
import { api } from "@/platform/api";
import { useAuthStore } from "@/platform/auth";
import { useWorkspaceStore } from "@/platform/workspace";
// Web-only features — from @/features
import { AuthInitializer } from "@/features/auth";
import { SearchCommand } from "@/features/search";
```
Within a feature, use relative imports. Between features or to shared, use `@/`.
`@/` maps to `apps/web/`. Within a package, use relative imports. Between packages, use `@multica/*`.
### Data Flow
```
Browser → ApiClient (shared/api) → REST API (Chi handlers) → sqlc queries → PostgreSQL
Browser ← WSClient (shared/api) ← WebSocket ← Hub.Broadcast() ← Handlers/TaskService
Browser → useQuery (@multica/core) → ApiClient (@multica/core/api) → REST API → sqlc → PostgreSQL
Browser ← useQuery cache ← invalidateQueries ← WS event handlers ← WSClient ← Hub.Broadcast()
```
Mutations: `useMutation (@multica/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`
@@ -115,13 +200,13 @@ make start # Start backend + frontend together
make stop # Stop app processes for the current checkout
make db-down # Stop the shared PostgreSQL container
# Frontend
# Frontend (all commands go through Turborepo)
pnpm install
pnpm dev:web # Next.js dev server (port 3000)
pnpm build # Build frontend
pnpm typecheck # TypeScript check
pnpm typecheck # TypeScript check (all packages via turbo)
pnpm lint # ESLint via Next.js
pnpm test # TS tests (Vitest)
pnpm test # TS tests (Vitest, via turbo)
# Backend (Go)
make dev # Run Go server (port 8080)
@@ -142,6 +227,9 @@ pnpm --filter @multica/web exec vitest run src/path/to/file.test.ts
# Run a single E2E test (requires backend + frontend running)
pnpm exec playwright test e2e/tests/specific-test.spec.ts
# shadcn (monorepo mode — must specify app)
npx shadcn add badge -c apps/web
# Infrastructure
make db-up # Start shared PostgreSQL (pgvector/pg17 image)
make db-down # Stop shared PostgreSQL
@@ -172,12 +260,21 @@ make start-worktree # Start using .env.worktree
- Treat compatibility code as a maintenance cost, not a default safety mechanism. Avoid "just in case" branches that make the codebase harder to reason about.
- Avoid broad refactors unless required by the task.
### Package Boundary Rules
- `packages/core/` — zero react-dom, zero localStorage (use StorageAdapter), zero process.env, zero UI libraries
- `packages/ui/` — zero `@multica/core` imports (pure UI, no business logic)
- `packages/views/` — zero `next/*` imports (use NavigationAdapter for routing)
- `apps/web/platform/` — the only place for Next.js APIs, env vars, and browser globals
## UI/UX Rules
- 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.
- Prefer shadcn components over custom implementations. Install via `npx shadcn add <component> -c apps/web` (monorepo flag required).
- **Shared UI components** → `packages/ui/components/` — shadcn primitives and pure-props common components.
- **Shared business components** → `packages/views/<domain>/components/` — pages and domain-bound UI.
- **Web-only components** → `apps/web/features/` or `apps/web/components/`.
- 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.
- 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

@@ -12,7 +12,7 @@ vi.mock("next/navigation", () => ({
// Mock auth store
const mockSendCode = vi.fn();
const mockVerifyCode = vi.fn();
vi.mock("@/features/auth", () => ({
vi.mock("@/platform/auth", () => ({
useAuthStore: (selector: (s: any) => any) =>
selector({
sendCode: mockSendCode,
@@ -20,9 +20,14 @@ vi.mock("@/features/auth", () => ({
}),
}));
// Mock auth-cookie
vi.mock("@/features/auth/auth-cookie", () => ({
setLoggedInCookie: vi.fn(),
}));
// Mock workspace store
const mockHydrateWorkspace = vi.fn();
vi.mock("@/features/workspace", () => ({
vi.mock("@/platform/workspace", () => ({
useWorkspaceStore: (selector: (s: any) => any) =>
selector({
hydrateWorkspace: mockHydrateWorkspace,
@@ -30,7 +35,7 @@ vi.mock("@/features/workspace", () => ({
}));
// Mock api
vi.mock("@/shared/api", () => ({
vi.mock("@/platform/api", () => ({
api: {
listWorkspaces: vi.fn().mockResolvedValue([]),
verifyCode: vi.fn(),

View File

@@ -2,9 +2,10 @@
import { Suspense, useState, useEffect, useCallback } from "react";
import { useSearchParams, useRouter } from "next/navigation";
import { useAuthStore } from "@/features/auth";
import { useWorkspaceStore } from "@/features/workspace";
import { api } from "@/shared/api";
import { useAuthStore } from "@/platform/auth";
import { setLoggedInCookie } from "@/features/auth/auth-cookie";
import { useWorkspaceStore } from "@/platform/workspace";
import { api } from "@/platform/api";
import {
Card,
CardHeader,
@@ -12,16 +13,16 @@ import {
CardDescription,
CardContent,
CardFooter,
} from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
} from "@multica/ui/components/ui/card";
import { Input } from "@multica/ui/components/ui/input";
import { Button } from "@multica/ui/components/ui/button";
import { Label } from "@multica/ui/components/ui/label";
import {
InputOTP,
InputOTPGroup,
InputOTPSlot,
} from "@/components/ui/input-otp";
import type { User } from "@/shared/types";
} from "@multica/ui/components/ui/input-otp";
import type { User } from "@multica/core/types";
function validateCliCallback(cliCallback: string): boolean {
try {
@@ -146,6 +147,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 +158,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 +287,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 +328,7 @@ function LoginPageContent() {
)}
</form>
</CardContent>
<CardFooter>
<CardFooter className="flex flex-col gap-3">
<Button
type="submit"
form="login-form"
@@ -316,6 +338,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 {
@@ -16,8 +17,8 @@ import {
SquarePen,
CircleUser,
} from "lucide-react";
import { WorkspaceAvatar } from "@/features/workspace";
import { useIssueDraftStore } from "@/features/issues/stores/draft-store";
import { WorkspaceAvatar } from "@multica/views/workspace/workspace-avatar";
import { useIssueDraftStore } from "@multica/core/issues/stores/draft-store";
import {
Sidebar,
SidebarContent,
@@ -29,7 +30,7 @@ import {
SidebarMenuButton,
SidebarMenuItem,
SidebarRail,
} from "@/components/ui/sidebar";
} from "@multica/ui/components/ui/sidebar";
import {
DropdownMenu,
DropdownMenuContent,
@@ -38,12 +39,14 @@ import {
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Tooltip, TooltipTrigger, TooltipContent } from "@/components/ui/tooltip";
import { useAuthStore } from "@/features/auth";
import { useWorkspaceStore } from "@/features/workspace";
import { useInboxStore } from "@/features/inbox";
import { useModalStore } from "@/features/modals";
} from "@multica/ui/components/ui/dropdown-menu";
import { Tooltip, TooltipTrigger, TooltipContent } from "@multica/ui/components/ui/tooltip";
import { useAuthStore } from "@/platform/auth";
import { useWorkspaceStore } from "@/platform/workspace";
import { useQuery } from "@tanstack/react-query";
import { inboxKeys, deduplicateInboxItems } from "@multica/core/inbox/queries";
import { api } from "@/platform/api";
import { useModalStore } from "@multica/core/modals";
const primaryNav = [
{ href: "/inbox", label: "Inbox", icon: Inbox },
@@ -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,14 +30,11 @@ import type {
Agent,
AgentStatus,
AgentVisibility,
AgentTool,
AgentTrigger,
AgentTriggerType,
AgentTask,
RuntimeDevice,
CreateAgentRequest,
UpdateAgentRequest,
} from "@/shared/types";
} from "@multica/core/types";
import {
Dialog,
DialogContent,
@@ -50,35 +42,38 @@ import {
DialogTitle,
DialogDescription,
DialogFooter,
} from "@/components/ui/dialog";
} from "@multica/ui/components/ui/dialog";
import {
ResizablePanelGroup,
ResizablePanel,
ResizableHandle,
} from "@/components/ui/resizable";
} from "@multica/ui/components/ui/resizable";
import {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
} from "@/components/ui/dropdown-menu";
} from "@multica/ui/components/ui/dropdown-menu";
import {
Popover,
PopoverTrigger,
PopoverContent,
} from "@/components/ui/popover";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
} from "@multica/ui/components/ui/popover";
import { Button } from "@multica/ui/components/ui/button";
import { Input } from "@multica/ui/components/ui/input";
import { Label } from "@multica/ui/components/ui/label";
import { toast } from "sonner";
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 { ActorAvatar } from "@/components/common/actor-avatar";
import { useFileUpload } from "@/shared/hooks/use-file-upload";
import { Skeleton } from "@multica/ui/components/ui/skeleton";
import { api } from "@/platform/api";
import { useAuthStore } from "@/platform/auth";
import { useWorkspaceStore } from "@/platform/workspace";
import { runtimeListOptions } from "@multica/core/runtimes/queries";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useWorkspaceId } from "@multica/core/hooks";
import { issueListOptions } from "@multica/core/issues/queries";
import { skillListOptions, agentListOptions, workspaceKeys } from "@multica/core/workspace/queries";
import { ActorAvatar } from "@multica/views/common/actor-avatar";
import { useFileUpload } from "@multica/core/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);
@@ -1194,7 +734,7 @@ function SettingsTab({
const [visibility, setVisibility] = useState<AgentVisibility>(agent.visibility);
const [maxTasks, setMaxTasks] = useState(agent.max_concurrent_tasks);
const [saving, setSaving] = useState(false);
const { upload, uploading } = useFileUpload();
const { upload, uploading } = useFileUpload(api);
const fileInputRef = useRef<HTMLInputElement>(null);
const handleAvatarUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
@@ -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,13 +1,26 @@
"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 { IssueDetail, StatusIcon, PriorityIcon } from "@/features/issues/components";
import { STATUS_CONFIG, PRIORITY_CONFIG } from "@/features/issues/config";
import { useActorName } from "@/features/workspace";
import { ActorAvatar } from "@/components/common/actor-avatar";
import { useQuery } from "@tanstack/react-query";
import { useWorkspaceId } from "@multica/core/hooks";
import {
inboxListOptions,
deduplicateInboxItems,
} from "@multica/core/inbox/queries";
import {
useMarkInboxRead,
useArchiveInbox,
useMarkAllInboxRead,
useArchiveAllInbox,
useArchiveAllReadInbox,
useArchiveCompletedInbox,
} from "@multica/core/inbox/mutations";
import { IssueDetail, StatusIcon, PriorityIcon } from "@multica/views/issues/components";
import { STATUS_CONFIG, PRIORITY_CONFIG } from "@multica/core/issues/config";
import { useActorName } from "@multica/core/workspace/hooks";
import { ActorAvatar } from "@multica/views/common/actor-avatar";
import { toast } from "sonner";
import {
ArrowRight,
@@ -18,22 +31,21 @@ import {
BookCheck,
ListChecks,
} from "lucide-react";
import type { InboxItem, InboxItemType, IssueStatus, IssuePriority } from "@/shared/types";
import { Button } from "@/components/ui/button";
import type { InboxItem, InboxItemType, IssueStatus, IssuePriority } from "@multica/core/types";
import { Button } from "@multica/ui/components/ui/button";
import {
ResizablePanelGroup,
ResizablePanel,
ResizableHandle,
} from "@/components/ui/resizable";
import { Skeleton } from "@/components/ui/skeleton";
} from "@multica/ui/components/ui/resizable";
import { Skeleton } from "@multica/ui/components/ui/skeleton";
import {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
} from "@/components/ui/dropdown-menu";
import { api } from "@/shared/api";
} from "@multica/ui/components/ui/dropdown-menu";
// ---------------------------------------------------------------------------
// 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,7 +2,9 @@ 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 type { Issue, Comment, TimelineEntry } from "@/shared/types";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { Issue, Comment, TimelineEntry } from "@multica/core/types";
import { WorkspaceIdProvider } from "@multica/core/hooks";
// Mock next/navigation
vi.mock("next/navigation", () => ({
@@ -28,7 +30,7 @@ vi.mock("next/link", () => ({
}));
// Mock auth store
vi.mock("@/features/auth", () => ({
vi.mock("@/platform/auth", () => ({
useAuthStore: (selector: (s: any) => any) =>
selector({
user: { id: "user-1", name: "Test User", email: "test@multica.ai" },
@@ -36,6 +38,121 @@ vi.mock("@/features/auth", () => ({
}),
}));
// Mock @multica/core/workspace (used by @multica/views components)
vi.mock("@multica/core/workspace", () => ({
useWorkspaceStore: Object.assign(
(selector: (s: any) => any) =>
selector({
workspace: { id: "ws-1", name: "Test WS" },
workspaces: [{ id: "ws-1", name: "Test WS" }],
members: [{ user_id: "user-1", name: "Test User", email: "test@multica.ai" }],
agents: [{ id: "agent-1", name: "Claude Agent" }],
}),
{ getState: () => ({
workspace: { id: "ws-1", name: "Test WS" },
workspaces: [{ id: "ws-1", name: "Test WS" }],
members: [{ user_id: "user-1", name: "Test User", email: "test@multica.ai" }],
agents: [{ id: "agent-1", name: "Claude Agent" }],
}),
},
),
registerWorkspaceStore: vi.fn(),
}));
// Mock @multica/core/auth (used by @multica/views components)
vi.mock("@multica/core/auth", () => ({
useAuthStore: Object.assign(
(selector: (s: any) => any) =>
selector({
user: { id: "user-1", name: "Test User", email: "test@multica.ai" },
isLoading: false,
}),
{ getState: () => ({
user: { id: "user-1", name: "Test User", email: "test@multica.ai" },
isLoading: false,
}),
},
),
registerAuthStore: vi.fn(),
createAuthStore: vi.fn(),
}));
// Mock @multica/views/navigation (AppLink used by views components)
vi.mock("@multica/views/navigation", () => ({
AppLink: ({ children, href, ...props }: any) => <a href={href} {...props}>{children}</a>,
useNavigation: () => ({ push: vi.fn(), pathname: "/issues/issue-1" }),
NavigationProvider: ({ children }: { children: React.ReactNode }) => children,
}));
// Mock @multica/views/editor (ContentEditor, TitleEditor used by IssueDetail)
vi.mock("@multica/views/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 || "");
useImperativeHandle(ref, () => ({
getMarkdown: () => valueRef.current,
clearContent: () => { valueRef.current = ""; setValue(""); },
focus: () => {},
}));
return (
<textarea
value={value}
onChange={(e) => {
valueRef.current = e.target.value;
setValue(e.target.value);
onUpdate?.(e.target.value);
}}
onKeyDown={(e) => {
if ((e.metaKey || e.ctrlKey) && e.key === "Enter") {
onSubmit?.();
}
}}
placeholder={placeholder}
data-testid="rich-text-editor"
/>
);
}),
TitleEditor: forwardRef(({ defaultValue, placeholder, onBlur, onChange }: any, ref: any) => {
const valueRef = useRef(defaultValue || "");
const [value, setValue] = useState(defaultValue || "");
useImperativeHandle(ref, () => ({
getText: () => valueRef.current,
focus: () => {},
}));
return (
<input
value={value}
onChange={(e) => {
valueRef.current = e.target.value;
setValue(e.target.value);
onChange?.(e.target.value);
}}
onBlur={() => onBlur?.(valueRef.current)}
placeholder={placeholder}
data-testid="title-editor"
/>
);
}),
}));
// Mock @multica/views/workspace/workspace-avatar
vi.mock("@multica/views/workspace/workspace-avatar", () => ({
WorkspaceAvatar: ({ name }: { name: string }) => <span>{name.charAt(0)}</span>,
}));
// Mock @multica/views/common/actor-avatar
vi.mock("@multica/views/common/actor-avatar", () => ({
ActorAvatar: ({ actorType, actorId }: any) => <span data-testid="actor-avatar">{actorType}:{actorId}</span>,
}));
// Mock @multica/views/common/markdown
vi.mock("@multica/views/common/markdown", () => ({
Markdown: ({ children }: { children: string }) => <div>{children}</div>,
}));
// Mock workspace feature
vi.mock("@/features/workspace", () => ({
useWorkspaceStore: (selector: (s: any) => any) =>
@@ -62,34 +179,47 @@ 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",
},
]);
vi.mock("@/platform/workspace", () => ({
useWorkspaceStore: (selector: (s: any) => any) =>
selector({
workspace: { id: "ws-1", name: "Test WS" },
workspaces: [{ id: "ws-1", name: "Test WS" }],
members: [{ user_id: "user-1", name: "Test User", email: "test@multica.ai" }],
agents: [{ id: "agent-1", name: "Claude Agent" }],
}),
}));
// Mock workspace hooks from core
vi.mock("@multica/core/workspace/hooks", () => ({
useActorName: () => ({
getMemberName: (id: string) => (id === "user-1" ? "Test User" : "Unknown"),
getAgentName: (id: string) => (id === "agent-1" ? "Claude Agent" : "Unknown Agent"),
getActorName: (type: string, id: string) => {
if (type === "member" && id === "user-1") return "Test User";
if (type === "agent" && id === "agent-1") return "Claude Agent";
return "Unknown";
},
getActorInitials: (type: string, id: string) => {
if (type === "member") return "TU";
if (type === "agent") return "CA";
return "??";
},
getActorAvatarUrl: () => null,
}),
}));
// 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() }) },
),
}));
vi.mock("@multica/core/issues", () => ({
useIssueStore: Object.assign(
(selector: (s: any) => any) => selector({ activeIssueId: null }),
{ getState: () => ({ activeIssueId: null, setActiveIssue: vi.fn() }) },
),
}));
@@ -99,6 +229,15 @@ vi.mock("@/features/realtime", () => ({
useWSReconnect: () => {},
}));
// Mock core realtime (hooks now import from @multica/core/realtime)
vi.mock("@multica/core/realtime", () => ({
useWSEvent: () => {},
useWSReconnect: () => {},
useWS: () => ({ subscribe: vi.fn(() => () => {}), onReconnect: vi.fn(() => () => {}) }),
WSProvider: ({ children }: { children: React.ReactNode }) => children,
useRealtimeSync: () => {},
}));
// Mock calendar (react-day-picker needs browser APIs)
vi.mock("@/components/ui/calendar", () => ({
Calendar: () => null,
@@ -106,6 +245,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 || "");
@@ -160,32 +302,73 @@ vi.mock("@/components/markdown", () => ({
Markdown: ({ children }: { children: string }) => <div>{children}</div>,
}));
// Mock api
const mockGetIssue = vi.hoisted(() => vi.fn());
const mockListTimeline = vi.hoisted(() => vi.fn());
const mockCreateComment = vi.hoisted(() => vi.fn());
const mockUpdateComment = vi.hoisted(() => vi.fn());
const mockDeleteComment = vi.hoisted(() => vi.fn());
const mockDeleteIssue = vi.hoisted(() => vi.fn());
const mockUpdateIssue = vi.hoisted(() => vi.fn());
// Mock api (core queries/mutations use @multica/core/api, some components use @/platform/api)
vi.mock("@/shared/api", () => ({
api: {
getIssue: (...args: any[]) => mockGetIssue(...args),
listTimeline: (...args: any[]) => mockListTimeline(...args),
listComments: vi.fn().mockResolvedValue([]),
createComment: (...args: any[]) => mockCreateComment(...args),
updateComment: (...args: any[]) => mockUpdateComment(...args),
deleteComment: (...args: any[]) => mockDeleteComment(...args),
deleteIssue: (...args: any[]) => mockDeleteIssue(...args),
updateIssue: (...args: any[]) => mockUpdateIssue(...args),
listIssueSubscribers: vi.fn().mockResolvedValue([]),
subscribeToIssue: vi.fn().mockResolvedValue(undefined),
unsubscribeFromIssue: vi.fn().mockResolvedValue(undefined),
getActiveTaskForIssue: vi.fn().mockResolvedValue({ task: null }),
listTasksByIssue: vi.fn().mockResolvedValue([]),
listTaskMessages: vi.fn().mockResolvedValue([]),
const mockApiObj = vi.hoisted(() => ({
getIssue: vi.fn(),
listTimeline: vi.fn(),
listComments: vi.fn().mockResolvedValue([]),
createComment: vi.fn(),
updateComment: vi.fn(),
deleteComment: vi.fn(),
deleteIssue: vi.fn(),
updateIssue: vi.fn(),
listIssueSubscribers: vi.fn().mockResolvedValue([]),
subscribeToIssue: vi.fn().mockResolvedValue(undefined),
unsubscribeFromIssue: vi.fn().mockResolvedValue(undefined),
getActiveTasksForIssue: vi.fn().mockResolvedValue({ tasks: [] }),
listTasksByIssue: vi.fn().mockResolvedValue([]),
listTaskMessages: vi.fn().mockResolvedValue([]),
listChildIssues: vi.fn().mockResolvedValue({ issues: [] }),
listIssues: vi.fn().mockResolvedValue({ issues: [], total: 0 }),
uploadFile: vi.fn(),
}));
vi.mock("@multica/core/api", () => ({
api: mockApiObj,
getApi: () => mockApiObj,
setApiInstance: vi.fn(),
}));
vi.mock("@/platform/api", () => ({
api: mockApiObj,
}));
// Mock issue config from core
vi.mock("@multica/core/issues/config", () => ({
ALL_STATUSES: ["backlog", "todo", "in_progress", "in_review", "done", "blocked", "cancelled"],
BOARD_STATUSES: ["backlog", "todo", "in_progress", "in_review", "done", "blocked"],
STATUS_ORDER: ["backlog", "todo", "in_progress", "in_review", "done", "blocked", "cancelled"],
STATUS_CONFIG: {
backlog: { label: "Backlog", iconColor: "text-muted-foreground", hoverBg: "hover:bg-accent" },
todo: { label: "Todo", iconColor: "text-muted-foreground", hoverBg: "hover:bg-accent" },
in_progress: { label: "In Progress", iconColor: "text-warning", hoverBg: "hover:bg-warning/10" },
in_review: { label: "In Review", iconColor: "text-success", hoverBg: "hover:bg-success/10" },
done: { label: "Done", iconColor: "text-info", hoverBg: "hover:bg-info/10" },
blocked: { label: "Blocked", iconColor: "text-destructive", hoverBg: "hover:bg-destructive/10" },
cancelled: { label: "Cancelled", iconColor: "text-muted-foreground", hoverBg: "hover:bg-accent" },
},
PRIORITY_ORDER: ["urgent", "high", "medium", "low", "none"],
PRIORITY_CONFIG: {
urgent: { label: "Urgent", bars: 4, color: "text-destructive" },
high: { label: "High", bars: 3, color: "text-warning" },
medium: { label: "Medium", bars: 2, color: "text-warning" },
low: { label: "Low", bars: 1, color: "text-info" },
none: { label: "No priority", bars: 0, color: "text-muted-foreground" },
},
}));
// Mock modals
vi.mock("@multica/core/modals", () => ({
useModalStore: Object.assign(
() => ({ open: vi.fn() }),
{ getState: () => ({ open: vi.fn() }) },
),
}));
// Mock utils
vi.mock("@multica/core/utils", () => ({
timeAgo: (date: string) => "1d ago",
}));
const mockIssue: Issue = {
@@ -235,14 +418,28 @@ 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}>
<WorkspaceIdProvider wsId="ws-1">
<Suspense fallback={<div>Suspense loading...</div>}>
<IssueDetailPage params={Promise.resolve({ id })} />
</Suspense>
</WorkspaceIdProvider>
</QueryClientProvider>,
);
});
return result!;
@@ -254,8 +451,8 @@ describe("IssueDetailPage", () => {
});
it("renders issue details after loading", async () => {
mockGetIssue.mockResolvedValueOnce(mockIssue);
mockListTimeline.mockResolvedValueOnce(mockTimeline);
mockApiObj.getIssue.mockResolvedValueOnce(mockIssue);
mockApiObj.listTimeline.mockResolvedValueOnce(mockTimeline);
await renderPage();
await waitFor(() => {
@@ -270,8 +467,8 @@ describe("IssueDetailPage", () => {
});
it("renders issue properties sidebar", async () => {
mockGetIssue.mockResolvedValueOnce(mockIssue);
mockListTimeline.mockResolvedValueOnce(mockTimeline);
mockApiObj.getIssue.mockResolvedValueOnce(mockIssue);
mockApiObj.listTimeline.mockResolvedValueOnce(mockTimeline);
await renderPage();
await waitFor(() => {
@@ -283,8 +480,8 @@ describe("IssueDetailPage", () => {
});
it("renders comments", async () => {
mockGetIssue.mockResolvedValueOnce(mockIssue);
mockListTimeline.mockResolvedValueOnce(mockTimeline);
mockApiObj.getIssue.mockResolvedValueOnce(mockIssue);
mockApiObj.listTimeline.mockResolvedValueOnce(mockTimeline);
await renderPage();
await waitFor(() => {
@@ -299,8 +496,8 @@ describe("IssueDetailPage", () => {
it("shows 'Issue not found' for missing issue", async () => {
// issue-detail fetches getIssue, useIssueReactions also fetches getIssue
mockGetIssue.mockRejectedValue(new Error("Not found"));
mockListTimeline.mockRejectedValue(new Error("Not found"));
mockApiObj.getIssue.mockRejectedValue(new Error("Not found"));
mockApiObj.listTimeline.mockRejectedValue(new Error("Not found"));
await renderPage("nonexistent-id");
await waitFor(() => {
@@ -309,8 +506,8 @@ describe("IssueDetailPage", () => {
});
it("submits a new comment", async () => {
mockGetIssue.mockResolvedValueOnce(mockIssue);
mockListTimeline.mockResolvedValueOnce(mockTimeline);
mockApiObj.getIssue.mockResolvedValueOnce(mockIssue);
mockApiObj.listTimeline.mockResolvedValueOnce(mockTimeline);
const newComment: Comment = {
id: "comment-3",
@@ -325,7 +522,7 @@ describe("IssueDetailPage", () => {
created_at: "2026-01-18T00:00:00Z",
updated_at: "2026-01-18T00:00:00Z",
};
mockCreateComment.mockResolvedValueOnce(newComment);
mockApiObj.createComment.mockResolvedValueOnce(newComment);
const user = userEvent.setup();
await renderPage();
@@ -357,8 +554,8 @@ describe("IssueDetailPage", () => {
await user.click(submitBtn);
await waitFor(() => {
expect(mockCreateComment).toHaveBeenCalled();
const [issueId, content] = mockCreateComment.mock.calls[0]!;
expect(mockApiObj.createComment).toHaveBeenCalled();
const [issueId, content] = mockApiObj.createComment.mock.calls[0]!;
expect(issueId).toBe("issue-1");
expect(content).toBe("New test comment");
});
@@ -369,8 +566,8 @@ describe("IssueDetailPage", () => {
});
it("renders breadcrumb navigation", async () => {
mockGetIssue.mockResolvedValueOnce(mockIssue);
mockListTimeline.mockResolvedValueOnce(mockTimeline);
mockApiObj.getIssue.mockResolvedValueOnce(mockIssue);
mockApiObj.listTimeline.mockResolvedValueOnce(mockTimeline);
await renderPage();
await waitFor(() => {

View File

@@ -1,7 +1,7 @@
"use client";
import { use } from "react";
import { IssueDetail } from "@/features/issues/components";
import { IssueDetail } from "@multica/views/issues/components";
export default function IssueDetailPage({
params,

View File

@@ -1,7 +1,9 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import type { Issue } from "@/shared/types";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { Issue } from "@multica/core/types";
import { WorkspaceIdProvider } from "@multica/core/hooks";
// Mock next/navigation
vi.mock("next/navigation", () => ({
@@ -46,6 +48,54 @@ vi.mock("@/features/workspace", () => ({
WorkspaceAvatar: ({ name }: { name: string }) => <span>{name.charAt(0)}</span>,
}));
// Mock @multica/core/auth (used by @multica/views pickers like AssigneePicker)
const mockAuthUser = { id: "user-1", email: "test@test.com", name: "Test User" };
vi.mock("@multica/core/auth", () => ({
useAuthStore: Object.assign(
(selector?: any) => {
const state = { user: mockAuthUser, isAuthenticated: true };
return selector ? selector(state) : state;
},
{ getState: () => ({ user: mockAuthUser, isAuthenticated: true }) },
),
registerAuthStore: vi.fn(),
createAuthStore: vi.fn(),
}));
// Mock @multica/core/workspace (used by @multica/views components)
vi.mock("@multica/core/workspace", () => ({
useWorkspaceStore: Object.assign(
(selector?: any) => {
const state = { workspace: { id: "ws-1", name: "Test", slug: "test" }, agents: [], members: [] };
return selector ? selector(state) : state;
},
{ getState: () => ({ workspace: { id: "ws-1", name: "Test", slug: "test" }, agents: [], members: [] }) },
),
registerWorkspaceStore: vi.fn(),
}));
vi.mock("@/platform/workspace", () => ({
useWorkspaceStore: Object.assign(
(selector?: any) => {
const state = { workspace: { id: "ws-1", name: "Test", slug: "test" }, agents: [], members: [] };
return selector ? selector(state) : state;
},
{ getState: () => ({ workspace: { id: "ws-1", name: "Test", slug: "test" }, agents: [], members: [] }) },
),
}));
// Mock @multica/views/navigation (AppLink used by views components)
vi.mock("@multica/views/navigation", () => ({
AppLink: ({ children, href, ...props }: any) => <a href={href} {...props}>{children}</a>,
useNavigation: () => ({ push: vi.fn(), pathname: "/issues" }),
NavigationProvider: ({ children }: { children: React.ReactNode }) => children,
}));
// Mock @multica/views/workspace/workspace-avatar
vi.mock("@multica/views/workspace/workspace-avatar", () => ({
WorkspaceAvatar: ({ name }: { name: string }) => <span>{name.charAt(0)}</span>,
}));
// Mock WebSocket context
vi.mock("@/features/realtime", () => ({
useWSEvent: vi.fn(),
@@ -59,38 +109,39 @@ vi.mock("sonner", () => ({
toast: { error: vi.fn(), success: vi.fn() },
}));
// Mock api
// Mock api (core queries/mutations use @multica/core/api)
const mockUpdateIssue = vi.fn();
const mockListIssues = vi.hoisted(() => vi.fn().mockResolvedValue({ issues: [], total: 0 }));
vi.mock("@/shared/api", () => ({
vi.mock("@multica/core/api", () => ({
api: {
listIssues: vi.fn().mockResolvedValue({ issues: [], total: 0 }),
listIssues: (...args: any[]) => mockListIssues(...args),
updateIssue: (...args: any[]) => mockUpdateIssue(...args),
listMembers: () => Promise.resolve([]),
listAgents: () => Promise.resolve([]),
},
getApi: () => ({
listIssues: (...args: any[]) => mockListIssues(...args),
updateIssue: (...args: any[]) => mockUpdateIssue(...args),
listMembers: () => Promise.resolve([]),
listAgents: () => Promise.resolve([]),
}),
setApiInstance: vi.fn(),
}));
// Mock the issue store
let mockStoreState: {
issues: Issue[];
loading: boolean;
fetch: () => Promise<void>;
setIssues: (issues: Issue[]) => void;
addIssue: (issue: Issue) => void;
updateIssue: (id: string, updates: Partial<Issue>) => void;
removeIssue: (id: string) => void;
};
vi.mock("@/features/issues/store", () => ({
// Mock issue store — only client state remains
const mockIssueClientState = { activeIssueId: null, setActiveIssue: vi.fn() };
vi.mock("@multica/core/issues", () => ({
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,
@@ -129,12 +180,17 @@ const mockViewState = {
toggleListCollapsed: vi.fn(),
};
vi.mock("@/features/issues/stores/view-store", () => ({
vi.mock("@multica/core/issues/stores/view-store", () => ({
initFilterWorkspaceSync: vi.fn(),
useIssueViewStore: Object.assign(
(selector?: any) => (selector ? selector(mockViewState) : mockViewState),
{ getState: () => mockViewState, setState: vi.fn() },
),
createIssueViewStore: () => ({
getState: () => mockViewState,
setState: vi.fn(),
subscribe: vi.fn(),
}),
SORT_OPTIONS: [
{ value: "position", label: "Manual" },
{ value: "priority", label: "Priority" },
@@ -151,14 +207,36 @@ vi.mock("@/features/issues/stores/view-store", () => ({
}));
// Mock view store context (shared components read from context)
vi.mock("@/features/issues/stores/view-store-context", () => ({
vi.mock("@multica/core/issues/stores/view-store-context", () => ({
ViewStoreProvider: ({ children }: { children: React.ReactNode }) => children,
useViewStore: (selector?: any) => (selector ? selector(mockViewState) : mockViewState),
useViewStoreApi: () => ({ getState: () => mockViewState, setState: vi.fn(), subscribe: vi.fn() }),
}));
// Mock issues scope store
vi.mock("@multica/core/issues/stores/issues-scope-store", () => ({
useIssuesScopeStore: Object.assign(
(selector?: any) => {
const state = { scope: "all", setScope: vi.fn() };
return selector ? selector(state) : state;
},
{ getState: () => ({ scope: "all", setScope: vi.fn() }) },
),
}));
// Mock selection store
vi.mock("@multica/core/issues/stores/selection-store", () => ({
useIssueSelectionStore: Object.assign(
(selector?: any) => {
const state = { selectedIds: new Set(), toggle: vi.fn(), clear: vi.fn(), setAll: vi.fn() };
return selector ? selector(state) : state;
},
{ getState: () => ({ selectedIds: new Set(), toggle: vi.fn(), clear: vi.fn(), setAll: vi.fn() }) },
),
}));
// Mock issue config
vi.mock("@/features/issues/config", () => ({
vi.mock("@multica/core/issues/config", () => ({
ALL_STATUSES: ["backlog", "todo", "in_progress", "in_review", "done", "blocked", "cancelled"],
BOARD_STATUSES: ["backlog", "todo", "in_progress", "in_review", "done", "blocked"],
STATUS_ORDER: ["backlog", "todo", "in_progress", "in_review", "done", "blocked", "cancelled"],
@@ -167,7 +245,7 @@ vi.mock("@/features/issues/config", () => ({
todo: { label: "Todo", iconColor: "text-muted-foreground", hoverBg: "hover:bg-accent" },
in_progress: { label: "In Progress", iconColor: "text-warning", hoverBg: "hover:bg-warning/10" },
in_review: { label: "In Review", iconColor: "text-success", hoverBg: "hover:bg-success/10" },
done: { label: "Done", iconColor: "text-done", hoverBg: "hover:bg-done/10" },
done: { label: "Done", iconColor: "text-info", hoverBg: "hover:bg-info/10" },
blocked: { label: "Blocked", iconColor: "text-destructive", hoverBg: "hover:bg-destructive/10" },
cancelled: { label: "Cancelled", iconColor: "text-muted-foreground", hoverBg: "hover:bg-accent" },
},
@@ -182,6 +260,13 @@ vi.mock("@/features/issues/config", () => ({
}));
// Mock modals
vi.mock("@multica/core/modals", () => ({
useModalStore: Object.assign(
() => ({ open: vi.fn() }),
{ getState: () => ({ open: vi.fn() }) },
),
}));
vi.mock("@/features/modals", () => ({
useModalStore: Object.assign(
() => ({ open: vi.fn() }),
@@ -282,90 +367,86 @@ 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}>
<WorkspaceIdProvider wsId="ws-1">
{ui}
</WorkspaceIdProvider>
</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

@@ -1,6 +1,6 @@
"use client";
import { IssuesPage } from "@/features/issues/components/issues-page";
import { IssuesPage } from "@multica/views/issues/components";
export default function Page() {
return <IssuesPage />;

View File

@@ -3,10 +3,13 @@
import { useEffect } from "react";
import { useRouter, usePathname } from "next/navigation";
import { MulticaIcon } from "@/components/multica-icon";
import { useNavigationStore } from "@/features/navigation";
import { SidebarProvider, SidebarInset } from "@/components/ui/sidebar";
import { useAuthStore } from "@/features/auth";
import { useWorkspaceStore } from "@/features/workspace";
import { useNavigationStore } from "@multica/core/navigation";
import { SidebarProvider, SidebarInset } from "@multica/ui/components/ui/sidebar";
import { useAuthStore } from "@/platform/auth";
import { useWorkspaceStore } from "@/platform/workspace";
import { WorkspaceIdProvider } from "@multica/core/hooks";
import { ModalRegistry } from "@multica/views/modals/registry";
import { SearchCommand } from "@/features/search";
import { AppSidebar } from "./_components/app-sidebar";
export default function DashboardLayout({
@@ -45,13 +48,17 @@ export default function DashboardLayout({
<AppSidebar />
<SidebarInset className="overflow-hidden">
{workspace ? (
children
<WorkspaceIdProvider wsId={workspace.id}>
{children}
<ModalRegistry />
</WorkspaceIdProvider>
) : (
<div className="flex flex-1 items-center justify-center">
<MulticaIcon className="size-6 animate-pulse" />
</div>
)}
</SidebarInset>
<SearchCommand />
</SidebarProvider>
);
}

View File

@@ -0,0 +1,28 @@
import { Skeleton } from "@multica/ui/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

@@ -1,6 +1,6 @@
"use client";
import { MyIssuesPage } from "@/features/my-issues";
import { MyIssuesPage } from "@multica/views/my-issues";
export default function Page() {
return <MyIssuesPage />;

View File

@@ -1 +1 @@
export { RuntimesPage as default } from "@/features/runtimes";
export { RuntimesPage as default } from "@multica/views/runtimes";

View File

@@ -2,14 +2,14 @@
import { useEffect, useRef, useState } from "react";
import { Camera, Loader2, Save } from "lucide-react";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { Input } from "@multica/ui/components/ui/input";
import { Label } from "@multica/ui/components/ui/label";
import { Button } from "@multica/ui/components/ui/button";
import { Card, CardContent } from "@multica/ui/components/ui/card";
import { toast } from "sonner";
import { useAuthStore } from "@/features/auth";
import { api } from "@/shared/api";
import { useFileUpload } from "@/shared/hooks/use-file-upload";
import { useAuthStore } from "@/platform/auth";
import { api } from "@/platform/api";
import { useFileUpload } from "@multica/core/hooks/use-file-upload";
export function AccountTab() {
const user = useAuthStore((s) => s.user);
@@ -17,7 +17,7 @@ export function AccountTab() {
const [profileName, setProfileName] = useState(user?.name ?? "");
const [profileSaving, setProfileSaving] = useState(false);
const { upload, uploading } = useFileUpload();
const { upload, uploading } = useFileUpload(api);
const fileInputRef = useRef<HTMLInputElement>(null);
useEffect(() => {

View File

@@ -1,7 +1,7 @@
"use client";
import { useTheme } from "next-themes";
import { cn } from "@/lib/utils";
import { cn } from "@multica/ui/lib/utils";
const LIGHT_COLORS = {
titleBar: "#e8e8e8",

View File

@@ -2,12 +2,12 @@
import { useState } from "react";
import { Crown, Shield, User, Plus, MoreHorizontal, UserMinus, Users } from "lucide-react";
import { ActorAvatar } from "@/components/common/actor-avatar";
import type { MemberWithUser, MemberRole } from "@/shared/types";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { ActorAvatar } from "@multica/views/common/actor-avatar";
import type { MemberWithUser, MemberRole } from "@multica/core/types";
import { Input } from "@multica/ui/components/ui/input";
import { Button } from "@multica/ui/components/ui/button";
import { Card, CardContent } from "@multica/ui/components/ui/card";
import { Badge } from "@multica/ui/components/ui/badge";
import {
AlertDialog,
AlertDialogContent,
@@ -17,14 +17,14 @@ import {
AlertDialogFooter,
AlertDialogCancel,
AlertDialogAction,
} from "@/components/ui/alert-dialog";
} from "@multica/ui/components/ui/alert-dialog";
import {
Select,
SelectTrigger,
SelectValue,
SelectContent,
SelectItem,
} from "@/components/ui/select";
} from "@multica/ui/components/ui/select";
import {
DropdownMenu,
DropdownMenuTrigger,
@@ -34,11 +34,14 @@ import {
DropdownMenuSub,
DropdownMenuSubTrigger,
DropdownMenuSubContent,
} from "@/components/ui/dropdown-menu";
} from "@multica/ui/components/ui/dropdown-menu";
import { toast } from "sonner";
import { useAuthStore } from "@/features/auth";
import { useWorkspaceStore } from "@/features/workspace";
import { api } from "@/shared/api";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useAuthStore } from "@/platform/auth";
import { useWorkspaceStore } from "@/platform/workspace";
import { useWorkspaceId } from "@multica/core/hooks";
import { memberListOptions, workspaceKeys } from "@multica/core/workspace/queries";
import { api } from "@/platform/api";
const roleConfig: Record<MemberRole, { label: string; icon: typeof Crown; description: string }> = {
owner: { label: "Owner", icon: Crown, description: "Full access, manage all settings" },
@@ -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

@@ -2,19 +2,23 @@
import { useEffect, useState } from "react";
import { Save, Plus, Trash2 } from "lucide-react";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { Input } from "@multica/ui/components/ui/input";
import { Button } from "@multica/ui/components/ui/button";
import { Card, CardContent } from "@multica/ui/components/ui/card";
import { toast } from "sonner";
import { useAuthStore } from "@/features/auth";
import { useWorkspaceStore } from "@/features/workspace";
import { api } from "@/shared/api";
import type { WorkspaceRepo } from "@/shared/types";
import { useQuery } from "@tanstack/react-query";
import { useAuthStore } from "@/platform/auth";
import { useWorkspaceStore } from "@/platform/workspace";
import { useWorkspaceId } from "@multica/core/hooks";
import { memberListOptions } from "@multica/core/workspace/queries";
import { api } from "@/platform/api";
import type { WorkspaceRepo } from "@multica/core/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

@@ -2,18 +2,18 @@
import { useEffect, useState, useCallback } from "react";
import { Key, Trash2, Copy, Check } from "lucide-react";
import { Tooltip, TooltipTrigger, TooltipContent } from "@/components/ui/tooltip";
import type { PersonalAccessToken } from "@/shared/types";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { Tooltip, TooltipTrigger, TooltipContent } from "@multica/ui/components/ui/tooltip";
import type { PersonalAccessToken } from "@multica/core/types";
import { Input } from "@multica/ui/components/ui/input";
import { Button } from "@multica/ui/components/ui/button";
import { Card, CardContent } from "@multica/ui/components/ui/card";
import {
Select,
SelectTrigger,
SelectValue,
SelectContent,
SelectItem,
} from "@/components/ui/select";
} from "@multica/ui/components/ui/select";
import {
Dialog,
DialogContent,
@@ -21,7 +21,7 @@ import {
DialogTitle,
DialogDescription,
DialogFooter,
} from "@/components/ui/dialog";
} from "@multica/ui/components/ui/dialog";
import {
AlertDialog,
AlertDialogAction,
@@ -31,10 +31,10 @@ import {
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { Skeleton } from "@/components/ui/skeleton";
} from "@multica/ui/components/ui/alert-dialog";
import { Skeleton } from "@multica/ui/components/ui/skeleton";
import { toast } from "sonner";
import { api } from "@/shared/api";
import { api } from "@/platform/api";
export function TokensTab() {
const [tokens, setTokens] = useState<PersonalAccessToken[]>([]);

View File

@@ -2,11 +2,11 @@
import { useEffect, useState } from "react";
import { Save, LogOut } from "lucide-react";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { Label } from "@/components/ui/label";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { Input } from "@multica/ui/components/ui/input";
import { Textarea } from "@multica/ui/components/ui/textarea";
import { Label } from "@multica/ui/components/ui/label";
import { Button } from "@multica/ui/components/ui/button";
import { Card, CardContent } from "@multica/ui/components/ui/card";
import {
AlertDialog,
AlertDialogContent,
@@ -16,16 +16,20 @@ import {
AlertDialogFooter,
AlertDialogCancel,
AlertDialogAction,
} from "@/components/ui/alert-dialog";
} from "@multica/ui/components/ui/alert-dialog";
import { toast } from "sonner";
import { useAuthStore } from "@/features/auth";
import { useWorkspaceStore } from "@/features/workspace";
import { api } from "@/shared/api";
import { useQuery } from "@tanstack/react-query";
import { useAuthStore } from "@/platform/auth";
import { useWorkspaceStore } from "@/platform/workspace";
import { useWorkspaceId } from "@multica/core/hooks";
import { memberListOptions } from "@multica/core/workspace/queries";
import { api } from "@/platform/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

@@ -1,8 +1,8 @@
"use client";
import { User, Palette, Key, Settings, Users, FolderGit2 } from "lucide-react";
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
import { useWorkspaceStore } from "@/features/workspace";
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@multica/ui/components/ui/tabs";
import { useWorkspaceStore } from "@/platform/workspace";
import { AccountTab } from "./_components/account-tab";
import { AppearanceTab } from "./_components/general-tab";
import { TokensTab } from "./_components/tokens-tab";

View File

@@ -1 +1 @@
export { SkillsPage as default } from "@/features/skills";
export { SkillsPage as default } from "@multica/views/skills";

View File

@@ -0,0 +1,90 @@
"use client";
import { Suspense, useEffect, useState } from "react";
import { useSearchParams, useRouter } from "next/navigation";
import { useAuthStore } from "@/platform/auth";
import { useWorkspaceStore } from "@/platform/workspace";
import { api } from "@/platform/api";
import {
Card,
CardHeader,
CardTitle,
CardDescription,
CardContent,
} from "@multica/ui/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,152 +1,14 @@
@import "tailwindcss";
@import "tw-animate-css";
@import "shadcn/tailwind.css";
@import "@multica/ui/styles/tokens.css";
@import "./custom.css";
@custom-variant dark (&:is(.dark *));
@theme inline {
--font-heading: var(--font-sans);
--font-sans: var(--font-sans);
--font-mono: var(--font-mono);
--color-sidebar-ring: var(--sidebar-ring);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar: var(--sidebar);
--color-chart-5: var(--chart-5);
--color-chart-4: var(--chart-4);
--color-chart-3: var(--chart-3);
--color-chart-2: var(--chart-2);
--color-chart-1: var(--chart-1);
--color-ring: var(--ring);
--color-input: var(--input);
--color-border: var(--border);
--color-destructive: var(--destructive);
--color-success: var(--success);
--color-warning: var(--warning);
--color-info: var(--info);
--color-done: var(--done);
--color-brand: var(--brand);
--color-brand-foreground: var(--brand-foreground);
--color-priority: var(--priority);
--color-canvas: var(--canvas);
--color-accent-foreground: var(--accent-foreground);
--color-accent: var(--accent);
--color-muted-foreground: var(--muted-foreground);
--color-muted: var(--muted);
--color-secondary-foreground: var(--secondary-foreground);
--color-secondary: var(--secondary);
--color-primary-foreground: var(--primary-foreground);
--color-primary: var(--primary);
--color-popover-foreground: var(--popover-foreground);
--color-popover: var(--popover);
--color-card-foreground: var(--card-foreground);
--color-card: var(--card);
--color-foreground: var(--foreground);
--color-background: var(--background);
--radius-sm: calc(var(--radius) * 0.6);
--radius-md: calc(var(--radius) * 0.8);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) * 1.4);
--radius-2xl: calc(var(--radius) * 1.8);
--radius-3xl: calc(var(--radius) * 2.2);
--radius-4xl: calc(var(--radius) * 2.6);
}
:root {
--background: oklch(1 0 0);
--foreground: oklch(0.141 0.005 285.823);
--card: oklch(1 0 0);
--card-foreground: oklch(0.141 0.005 285.823);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.141 0.005 285.823);
--primary: oklch(0.21 0.006 285.885);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.967 0.001 286.375);
--secondary-foreground: oklch(0.21 0.006 285.885);
--muted: oklch(0.967 0.001 286.375);
--muted-foreground: oklch(0.552 0.016 285.938);
--accent: oklch(0.967 0.001 286.375);
--accent-foreground: oklch(0.21 0.006 285.885);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.92 0.004 286.32);
--input: oklch(0.92 0.004 286.32);
--ring: oklch(0.705 0.015 286.067);
--chart-1: oklch(0.871 0.006 286.286);
--chart-2: oklch(0.552 0.016 285.938);
--chart-3: oklch(0.442 0.017 285.786);
--chart-4: oklch(0.37 0.013 285.805);
--chart-5: oklch(0.274 0.006 286.033);
--radius: 0.625rem;
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.141 0.005 285.823);
--sidebar-primary: oklch(0.21 0.006 285.885);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.95 0.002 286.375);
--sidebar-accent-foreground: oklch(0.21 0.006 285.885);
--sidebar-border: oklch(0.92 0.004 286.32);
--sidebar-ring: oklch(0.705 0.015 286.067);
--brand: oklch(0.55 0.16 255);
--brand-foreground: oklch(0.985 0 0);
--canvas: oklch(0.95 0.002 286);
--success: oklch(0.55 0.16 145);
--warning: oklch(0.75 0.16 85);
--info: oklch(0.55 0.18 250);
--done: oklch(0.55 0.18 300);
--priority: oklch(0.65 0.18 50);
--scrollbar-thumb: oklch(0 0 0 / 10%);
--scrollbar-thumb-hover: oklch(0 0 0 / 18%);
--scrollbar-track: transparent;
}
.dark {
--background: oklch(0.18 0.005 285.823);
--foreground: oklch(0.985 0 0);
--card: oklch(0.21 0.006 285.885);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.21 0.006 285.885);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.92 0.004 286.32);
--primary-foreground: oklch(0.21 0.006 285.885);
--secondary: oklch(0.274 0.006 286.033);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.274 0.006 286.033);
--muted-foreground: oklch(0.705 0.015 286.067);
--accent: oklch(0.274 0.006 286.033);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.552 0.016 285.938);
--chart-1: oklch(0.871 0.006 286.286);
--chart-2: oklch(0.552 0.016 285.938);
--chart-3: oklch(0.442 0.017 285.786);
--chart-4: oklch(0.37 0.013 285.805);
--chart-5: oklch(0.274 0.006 286.033);
--sidebar: oklch(0.21 0.006 285.885);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.274 0.006 286.033);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.552 0.016 285.938);
--brand: oklch(0.65 0.16 255);
--brand-foreground: oklch(0.985 0 0);
--canvas: oklch(0.2 0.005 286);
--success: oklch(0.65 0.15 145);
--warning: oklch(0.70 0.16 85);
--info: oklch(0.65 0.18 250);
--done: oklch(0.65 0.18 300);
--priority: oklch(0.70 0.18 50);
--scrollbar-thumb: oklch(1 0 0 / 8%);
--scrollbar-thumb-hover: oklch(1 0 0 / 18%);
--scrollbar-track: transparent;
}
@source "../../../packages/ui/**/*.tsx";
@source "../../../packages/core/**/*.tsx";
@source "../../../packages/views/**/*.tsx";
@layer base {
* {
@@ -164,4 +26,4 @@
html {
@apply font-sans;
}
}
}

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 { Toaster } from "@multica/ui/components/ui/sonner";
import { cn } from "@multica/ui/lib/utils";
import { QueryProvider } from "@multica/core/provider";
import { AuthInitializer } from "@/features/auth";
import { WSProvider } from "@/features/realtime";
import { ModalRegistry } from "@/features/modals";
import { WebWSProvider } from "@/platform/ws-provider";
import { WebNavigationProvider } from "@/platform/navigation";
import { LocaleSync } from "@/components/locale-sync";
import "./globals.css";
const geist = Geist({ subsets: ["latin"], variable: "--font-sans" });
@@ -50,28 +51,28 @@ 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>
<WebNavigationProvider>
<AuthInitializer>
<WebWSProvider>{children}</WebWSProvider>
</AuthInitializer>
</WebNavigationProvider>
<Toaster />
</QueryProvider>
</ThemeProvider>
</body>
</html>

View File

@@ -13,11 +13,11 @@
"iconLibrary": "lucide",
"rtl": false,
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
"components": "@multica/ui/components",
"utils": "@multica/ui/lib/utils",
"ui": "@multica/ui/components/ui",
"lib": "@multica/ui/lib",
"hooks": "@multica/ui/hooks"
},
"menuColor": "default",
"menuAccent": "subtle",

View File

@@ -1,89 +0,0 @@
"use client";
import type { ReactNode } from "react";
import { Users } from "lucide-react";
import { HoverCard, HoverCardTrigger, HoverCardContent } from "@/components/ui/hover-card";
import { ActorAvatar } from "@/components/common/actor-avatar";
import { useWorkspaceStore } from "@/features/workspace";
interface MentionHoverCardProps {
type: string;
id: string;
children: ReactNode;
}
function MentionHoverCard({ type, id, children }: MentionHoverCardProps) {
const members = useWorkspaceStore((s) => s.members);
const agents = useWorkspaceStore((s) => s.agents);
if (type === "all") {
return (
<HoverCard>
<HoverCardTrigger render={<span />} className="cursor-default">
{children}
</HoverCardTrigger>
<HoverCardContent align="start" className="w-auto min-w-48 max-w-72">
<div className="flex items-center gap-2.5">
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-primary/10">
<Users className="h-4 w-4 text-primary" />
</div>
<div className="min-w-0">
<p className="text-sm font-medium">All members</p>
<p className="text-xs text-muted-foreground">Notifies all workspace members</p>
</div>
</div>
</HoverCardContent>
</HoverCard>
);
}
if (type === "member") {
const member = members.find((m) => m.user_id === id);
if (!member) return <>{children}</>;
return (
<HoverCard>
<HoverCardTrigger render={<span />} className="cursor-default">
{children}
</HoverCardTrigger>
<HoverCardContent align="start" className="w-auto min-w-48 max-w-72">
<div className="flex items-center gap-2.5">
<ActorAvatar actorType="member" actorId={id} size={32} />
<div className="min-w-0">
<p className="text-sm font-medium truncate">{member.name}</p>
<p className="text-xs text-muted-foreground truncate">{member.email}</p>
</div>
</div>
</HoverCardContent>
</HoverCard>
);
}
if (type === "agent") {
const agent = agents.find((a) => a.id === id);
if (!agent) return <>{children}</>;
return (
<HoverCard>
<HoverCardTrigger render={<span />} className="cursor-default">
{children}
</HoverCardTrigger>
<HoverCardContent align="start" className="w-auto min-w-48 max-w-72">
<div className="flex items-center gap-2.5">
<ActorAvatar actorType="agent" actorId={id} size={32} />
<div className="min-w-0">
<p className="text-sm font-medium truncate">{agent.name}</p>
{agent.description && (
<p className="text-xs text-muted-foreground truncate">{agent.description}</p>
)}
</div>
</div>
</HoverCardContent>
</HoverCard>
);
}
return <>{children}</>;
}
export { MentionHoverCard };

View File

@@ -1,7 +1,7 @@
"use client";
import { Spinner } from "@/components/spinner";
import { cn } from "@/lib/utils";
import { cn } from "@multica/ui/lib/utils";
export type LoadingVariant = "generating" | "streaming";

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

View File

@@ -1,243 +1 @@
import * as React from 'react'
import { codeToHtml, bundledLanguages, type BundledLanguage } from 'shiki'
import { Copy, Check } from "lucide-react"
import { Button } from "@/components/ui/button"
import { Tooltip, TooltipTrigger, TooltipContent } from "@/components/ui/tooltip"
import { cn } from '@/lib/utils'
export interface CodeBlockProps {
code: string
language?: string
className?: string
/**
* Render mode affects code block styling:
* - 'terminal': Minimal, keeps control chars visible
* - 'minimal': Clean code, basic styling
* - 'full': Rich styling with background, copy button, etc.
*/
mode?: 'terminal' | 'minimal' | 'full'
}
// Map common aliases to Shiki language names
const LANGUAGE_ALIASES: Record<string, BundledLanguage> = {
js: 'javascript',
ts: 'typescript',
py: 'python',
sh: 'bash',
zsh: 'bash',
yml: 'yaml',
rb: 'ruby',
rs: 'rust',
kt: 'kotlin',
'objective-c': 'objc',
objc: 'objc'
}
// Simple LRU cache for highlighted code
const highlightCache = new Map<string, string>()
const CACHE_MAX_SIZE = 200
function getCacheKey(code: string, lang: string): string {
return `${lang}:${code}`
}
function isValidLanguage(lang: string): lang is BundledLanguage {
const normalized = LANGUAGE_ALIASES[lang] || lang
return normalized in bundledLanguages
}
/**
* CodeBlock - Syntax highlighted code block using Shiki
*
* Uses Shiki dual themes with CSS variables for light/dark switching.
* No JS-based dark mode detection needed — theme switching is handled
* entirely via CSS (see globals.css for .shiki/.dark .shiki rules).
*
* @see https://shiki.style/guide/dual-themes
*/
export function CodeBlock({
code,
language = 'text',
className,
mode = 'full'
}: CodeBlockProps): React.JSX.Element {
const [highlighted, setHighlighted] = React.useState<string | null>(null)
const [isLoading, setIsLoading] = React.useState(true)
const [copied, setCopied] = React.useState(false)
// Resolve language alias - keep as string to allow 'text' fallback
const langLower = language.toLowerCase()
const resolvedLang: string = LANGUAGE_ALIASES[langLower] || langLower
React.useEffect(() => {
let cancelled = false
async function highlight(): Promise<void> {
const cacheKey = getCacheKey(code, resolvedLang)
const cached = highlightCache.get(cacheKey)
if (cached) {
if (!cancelled) {
setHighlighted(cached)
setIsLoading(false)
}
return
}
try {
// Use valid language or fallback to plaintext
const lang = isValidLanguage(resolvedLang) ? resolvedLang : 'text'
// Dual themes: Shiki outputs CSS variables for both themes in one pass.
// CSS handles switching via .dark selector (see globals.css).
const html = await codeToHtml(code, {
lang,
themes: {
light: 'github-light',
dark: 'github-dark',
},
defaultColor: false,
})
// Cache the result
if (highlightCache.size >= CACHE_MAX_SIZE) {
const firstKey = highlightCache.keys().next().value
if (firstKey) highlightCache.delete(firstKey)
}
highlightCache.set(cacheKey, html)
if (!cancelled) {
setHighlighted(html)
setIsLoading(false)
}
} catch (error) {
// Fallback to plain text on error
console.warn(`Shiki highlighting failed for language "${resolvedLang}":`, error)
if (!cancelled) {
setHighlighted(null)
setIsLoading(false)
}
}
}
highlight()
return () => {
cancelled = true
}
}, [code, resolvedLang])
const handleCopy = React.useCallback(async () => {
try {
await navigator.clipboard.writeText(code)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
} catch (err) {
console.error('Failed to copy code:', err)
}
}, [code])
// Terminal mode: raw monospace with minimal styling
if (mode === 'terminal') {
return (
<pre className={cn('font-mono text-sm whitespace-pre-wrap', className)}>
<code className="font-mono">{code}</code>
</pre>
)
}
// Minimal mode: just syntax highlighting, no chrome
if (mode === 'minimal') {
if (isLoading || !highlighted) {
return (
<pre className={cn('font-mono text-sm whitespace-pre-wrap', className)}>
<code className="font-mono">{code}</code>
</pre>
)
}
return (
<div
className={cn(
'font-mono text-sm [&_pre]:!bg-transparent [&_pre]:!p-0 [&_pre]:whitespace-pre-wrap [&_pre]:break-all [&_code]:!bg-transparent [&_code]:font-mono [&_pre]:font-mono',
className
)}
dangerouslySetInnerHTML={{ __html: highlighted }}
/>
)
}
// Full mode: rich styling with header and copy button
return (
<div
className={cn(
'relative group rounded-lg overflow-hidden border bg-muted/30 mb-4 last:mb-0',
className
)}
>
{/* Language label + copy button */}
<div className="flex items-center justify-between px-3 py-1.5 bg-muted/50 border-b text-xs">
<span className="text-muted-foreground font-medium uppercase tracking-wide">
{resolvedLang !== 'text' ? resolvedLang : 'plain text'}
</span>
<Tooltip>
<TooltipTrigger
render={
<Button
variant="ghost"
size="icon-xs"
onClick={handleCopy}
className="opacity-0 group-hover:opacity-100 transition-opacity text-muted-foreground hover:text-foreground"
aria-label="Copy code"
>
{copied ? (
<Check className="size-3.5 text-success" />
) : (
<Copy className="size-3.5" />
)}
</Button>
}
/>
<TooltipContent>Copy code</TooltipContent>
</Tooltip>
</div>
{/* Code content */}
<div className="p-3 overflow-x-auto">
{isLoading || !highlighted ? (
<pre className="font-mono text-sm whitespace-pre-wrap break-all">
<code className="font-mono">{code}</code>
</pre>
) : (
<div
className="font-mono text-sm [&_pre]:!bg-transparent [&_pre]:!m-0 [&_pre]:!p-0 [&_pre]:whitespace-pre-wrap [&_pre]:break-all [&_code]:!bg-transparent [&_code]:font-mono [&_pre]:font-mono"
dangerouslySetInnerHTML={{ __html: highlighted }}
/>
)}
</div>
</div>
)
}
/**
* InlineCode - Styled inline code span
* Features: subtle background (3%), subtle border (5%), 75% opacity text
*/
export function InlineCode({
children,
className
}: {
children: React.ReactNode
className?: string
}): React.JSX.Element {
return (
<code
className={cn(
'px-1.5 py-0.5 rounded bg-foreground/[0.03] border border-foreground/[0.05] font-mono text-sm text-foreground/75',
className
)}
>
{children}
</code>
)
}
export { CodeBlock, InlineCode, type CodeBlockProps } from '@multica/ui/markdown'

View File

@@ -1,332 +1,36 @@
import * as React from 'react'
import ReactMarkdown, { type Components, defaultUrlTransform } from 'react-markdown'
import rehypeRaw from 'rehype-raw'
import remarkGfm from 'remark-gfm'
import { cn } from '@/lib/utils'
import { CodeBlock, InlineCode } from './CodeBlock'
import { preprocessLinks } from './linkify'
import { preprocessMentionShortcodes } from './mentions'
import { IssueMentionCard } from '@/features/issues/components/issue-mention-card'
import {
Markdown as MarkdownBase,
MemoizedMarkdown as MemoizedMarkdownBase,
type MarkdownProps as MarkdownBaseProps,
type RenderMode
} from '@multica/ui/markdown'
import { IssueMentionCard } from '@multica/views/issues/components'
export type { RenderMode }
export type MarkdownProps = MarkdownBaseProps
/**
* Render modes for markdown content:
*
* - 'terminal': Raw output with minimal formatting, control chars visible
* Best for: Debug output, raw logs, when you want to see exactly what's there
*
* - 'minimal': Clean rendering with syntax highlighting but no extra chrome
* Best for: Chat messages, inline content, when you want readability without clutter
*
* - 'full': Rich rendering with beautiful tables, styled code blocks, proper typography
* Best for: Documentation, long-form content, when presentation matters
* Default renderMention that delegates to IssueMentionCard for issue mentions
* and renders a styled span for other mention types.
*/
export type RenderMode = 'terminal' | 'minimal' | 'full'
export interface MarkdownProps {
children: string
/**
* Render mode controlling formatting level
* @default 'minimal'
*/
mode?: RenderMode
className?: string
/**
* Message ID for memoization (optional)
* When provided, memoizes parsed blocks to avoid re-parsing during streaming
*/
id?: string
/**
* Callback when a URL is clicked
*/
onUrlClick?: (url: string) => void
/**
* Callback when a file path is clicked
*/
onFileClick?: (path: string) => void
function defaultRenderMention({ type, id }: { type: string; id: string }): React.ReactNode {
if (type === 'issue') {
return <IssueMentionCard issueId={id} />
}
return null
}
/**
* Custom URL transform that allows mention:// protocol (used for @mentions)
* while keeping the default security for all other URLs.
* App-level Markdown wrapper that injects IssueMentionCard via renderMention.
* Callers that need custom mention rendering can pass their own renderMention prop.
*/
function urlTransform(url: string): string {
if (url.startsWith('mention://')) return url
return defaultUrlTransform(url)
export function Markdown(props: MarkdownProps): React.JSX.Element {
return <MarkdownBase renderMention={defaultRenderMention} {...props} />
}
// File path detection regex - matches paths starting with /, ~/, or ./
const FILE_PATH_REGEX =
/^(?:\/|~\/|\.\/)[\w\-./@]+\.(?:ts|tsx|js|jsx|mjs|cjs|md|json|yaml|yml|py|go|rs|css|scss|less|html|htm|txt|log|sh|bash|zsh|swift|kt|java|c|cpp|h|hpp|rb|php|xml|toml|ini|cfg|conf|env|sql|graphql|vue|svelte|astro|prisma)$/i
/**
* Create custom components based on render mode
*/
function createComponents(
mode: RenderMode,
onUrlClick?: (url: string) => void,
onFileClick?: (path: string) => void
): Partial<Components> {
const baseComponents: Partial<Components> = {
// Images: render uploaded images with constrained sizing
img: ({ src, alt }) => (
<img
src={src}
alt={alt ?? ""}
className="max-w-full h-auto rounded-md my-2"
loading="lazy"
/>
),
// Links: Make clickable with callbacks, or render as mention
a: ({ href, children }) => {
// Mention links: mention://member/id, mention://agent/id, mention://issue/id, mention://all/all
if (href?.startsWith('mention://')) {
const mentionMatch = href.match(/^mention:\/\/(member|agent|issue|all)\/(.+)$/)
if (mentionMatch?.[1] === 'issue' && mentionMatch[2]) {
const label = typeof children === 'string' ? children : Array.isArray(children) ? children.join('') : undefined
return <IssueMentionCard issueId={mentionMatch[2]} fallbackLabel={label} />
}
return (
<span className="text-primary font-semibold mx-0.5">
{children}
</span>
)
}
const handleClick = (e: React.MouseEvent): void => {
e.preventDefault()
if (href) {
// Check if it's a file path
if (FILE_PATH_REGEX.test(href) && onFileClick) {
onFileClick(href)
} else if (onUrlClick) {
onUrlClick(href)
} else {
// Default: open in new window
window.open(href, '_blank', 'noopener,noreferrer')
}
}
}
return (
<a
href={href}
onClick={handleClick}
className="text-primary hover:underline cursor-pointer"
>
{children}
</a>
)
}
}
// Terminal mode: minimal formatting
if (mode === 'terminal') {
return {
...baseComponents,
// No special code handling - just monospace
code: ({ children }) => <code className="font-mono">{children}</code>,
pre: ({ children }) => <pre className="font-mono whitespace-pre-wrap my-2">{children}</pre>,
// Minimal paragraph spacing
p: ({ children }) => <p className="my-1">{children}</p>,
// Simple lists
ul: ({ children }) => <ul className="list-disc list-inside my-1">{children}</ul>,
ol: ({ children }) => <ol className="list-decimal list-inside my-1">{children}</ol>,
li: ({ children }) => <li className="my-0.5">{children}</li>,
// Plain tables
table: ({ children }) => <table className="my-2 font-mono text-sm">{children}</table>,
th: ({ children }) => <th className="text-left pr-4">{children}</th>,
td: ({ children }) => <td className="pr-4">{children}</td>
}
}
// Minimal mode: clean with syntax highlighting
if (mode === 'minimal') {
return {
...baseComponents,
// Inline code
code: ({ className, children, ...props }) => {
const match = /language-(\w+)/.exec(className || '')
const isBlock =
'node' in props && props.node?.position?.start.line !== props.node?.position?.end.line
// Block code - use CodeBlock with full mode
if (match || isBlock) {
const code = String(children).replace(/\n$/, '')
return <CodeBlock code={code} language={match?.[1]} mode="full" className="my-1" />
}
// Inline code
return <InlineCode>{children}</InlineCode>
},
pre: ({ children }) => <>{children}</>,
// Comfortable paragraph spacing
p: ({ children }) => <p className="my-2 leading-relaxed">{children}</p>,
// Styled lists
ul: ({ children }) => (
<ul className="my-2 space-y-1 ps-4 pe-2 list-disc marker:text-muted-foreground">
{children}
</ul>
),
ol: ({ children }) => <ol className="my-2 space-y-1 pl-6 list-decimal">{children}</ol>,
li: ({ children }) => <li>{children}</li>,
// Clean tables
table: ({ children }) => (
<div className="my-3 overflow-x-auto">
<table className="min-w-full text-sm">{children}</table>
</div>
),
thead: ({ children }) => <thead className="border-b">{children}</thead>,
th: ({ children }) => (
<th className="text-left py-2 px-3 font-semibold text-muted-foreground">{children}</th>
),
td: ({ children }) => <td className="py-2 px-3 border-b border-border/50">{children}</td>,
// Headings - H1/H2 same size, differentiated by weight
h1: ({ children }) => <h1 className="font-sans text-base font-bold mt-5 mb-3">{children}</h1>,
h2: ({ children }) => (
<h2 className="font-sans text-base font-semibold mt-4 mb-3">{children}</h2>
),
h3: ({ children }) => (
<h3 className="font-sans text-sm font-semibold mt-4 mb-2">{children}</h3>
),
// Blockquotes
blockquote: ({ children }) => (
<blockquote className="border-l-2 border-muted-foreground/30 pl-3 my-2 text-muted-foreground italic">
{children}
</blockquote>
),
// Horizontal rules
hr: () => <hr className="my-4 border-border" />,
// Strong/emphasis
strong: ({ children }) => <strong className="font-semibold">{children}</strong>,
em: ({ children }) => <em className="italic">{children}</em>
}
}
// Full mode: rich styling
return {
...baseComponents,
// Full code blocks with copy button
code: ({ className, children, ...props }) => {
const match = /language-(\w+)/.exec(className || '')
const isBlock =
'node' in props && props.node?.position?.start.line !== props.node?.position?.end.line
if (match || isBlock) {
const code = String(children).replace(/\n$/, '')
return <CodeBlock code={code} language={match?.[1]} mode="full" className="my-1" />
}
return <InlineCode>{children}</InlineCode>
},
pre: ({ children }) => <>{children}</>,
// Rich paragraph spacing
p: ({ children }) => <p className="my-3 leading-relaxed">{children}</p>,
// Styled lists
ul: ({ children }) => (
<ul className="my-3 space-y-1.5 ps-4 pe-2 list-disc marker:text-muted-foreground">
{children}
</ul>
),
ol: ({ children }) => <ol className="my-3 space-y-1.5 pl-6 list-decimal">{children}</ol>,
li: ({ children }) => <li className="leading-relaxed">{children}</li>,
// Beautiful tables
table: ({ children }) => (
<div className="my-4 overflow-x-auto rounded-md border">
<table className="min-w-full divide-y divide-border">{children}</table>
</div>
),
thead: ({ children }) => <thead className="bg-muted/50">{children}</thead>,
tbody: ({ children }) => <tbody className="divide-y divide-border">{children}</tbody>,
th: ({ children }) => <th className="text-left py-3 px-4 font-semibold text-sm">{children}</th>,
td: ({ children }) => <td className="py-3 px-4 text-sm">{children}</td>,
tr: ({ children }) => <tr className="hover:bg-muted/30 transition-colors">{children}</tr>,
// Rich headings
h1: ({ children }) => <h1 className="font-sans text-base font-bold mt-7 mb-4">{children}</h1>,
h2: ({ children }) => (
<h2 className="font-sans text-base font-semibold mt-6 mb-3">{children}</h2>
),
h3: ({ children }) => <h3 className="font-sans text-sm font-semibold mt-5 mb-3">{children}</h3>,
h4: ({ children }) => <h4 className="text-sm font-semibold mt-3 mb-1">{children}</h4>,
// Styled blockquotes
blockquote: ({ children }) => (
<blockquote className="border-l-4 border-foreground/30 bg-muted/30 pl-4 pr-3 py-2 my-3 rounded-r-md">
{children}
</blockquote>
),
// Task lists (GFM)
input: ({ type, checked }) => {
if (type === 'checkbox') {
return (
<input
type="checkbox"
checked={checked}
readOnly
className="mr-2 rounded border-muted-foreground"
/>
)
}
return <input type={type} />
},
// Horizontal rules
hr: () => <hr className="my-6 border-border" />,
// Strong/emphasis
strong: ({ children }) => <strong className="font-semibold">{children}</strong>,
em: ({ children }) => <em className="italic">{children}</em>,
del: ({ children }) => <del className="line-through text-muted-foreground">{children}</del>
}
}
/**
* Markdown - Customizable markdown renderer with multiple render modes
*
* Features:
* - Three render modes: terminal, minimal, full
* - Syntax highlighting via Shiki
* - GFM support (tables, task lists, strikethrough)
* - Clickable links and file paths
* - Memoization for streaming performance
*/
export function Markdown({
children,
mode = 'minimal',
className,
onUrlClick,
onFileClick
}: MarkdownProps): React.JSX.Element {
const components = React.useMemo(
() => createComponents(mode, onUrlClick, onFileClick),
[mode, onUrlClick, onFileClick]
)
// Preprocess: convert mention shortcodes and raw URLs/file paths to markdown links
const processedContent = React.useMemo(
() => preprocessLinks(preprocessMentionShortcodes(children)),
[children]
)
return (
<div className={cn('markdown-content break-words', className)}>
<ReactMarkdown
remarkPlugins={[[remarkGfm, { singleTilde: false }]]}
rehypePlugins={[rehypeRaw]}
urlTransform={urlTransform}
components={components}
>
{processedContent}
</ReactMarkdown>
</div>
)
}
/**
* MemoizedMarkdown - Optimized for streaming scenarios
*
* Splits content into blocks and memoizes each block separately,
* so only new/changed blocks re-render during streaming.
*/
export const MemoizedMarkdown = React.memo(Markdown, (prevProps, nextProps) => {
// If id is provided, use it for memoization
if (prevProps.id && nextProps.id) {
return (
prevProps.id === nextProps.id &&
@@ -334,7 +38,6 @@ export const MemoizedMarkdown = React.memo(Markdown, (prevProps, nextProps) => {
prevProps.mode === nextProps.mode
)
}
// Otherwise compare content and mode
return prevProps.children === nextProps.children && prevProps.mode === nextProps.mode
})
MemoizedMarkdown.displayName = 'MemoizedMarkdown'

View File

@@ -1,225 +1,22 @@
import * as React from 'react'
import { Markdown, type RenderMode } from './Markdown'
import {
StreamingMarkdown as StreamingMarkdownBase,
type StreamingMarkdownProps as StreamingMarkdownBaseProps
} from '@multica/ui/markdown'
import { IssueMentionCard } from '@multica/views/issues/components'
export interface StreamingMarkdownProps {
content: string
isStreaming: boolean
mode?: RenderMode
className?: string
onUrlClick?: (url: string) => void
onFileClick?: (path: string) => void
}
export type StreamingMarkdownProps = StreamingMarkdownBaseProps
interface Block {
content: string
isCodeBlock: boolean
function defaultRenderMention({ type, id }: { type: string; id: string }): React.ReactNode {
if (type === 'issue') {
return <IssueMentionCard issueId={id} />
}
return null
}
/**
* djb2 hash (XOR variant) by Daniel J. Bernstein.
* Used to generate stable React keys for completed content blocks.
*
* - 5381: empirically chosen initial value that produces fewer collisions
* - (hash << 5) + hash: equivalent to hash * 33
* - ^ charCode: XOR variant, favored by Bernstein over additive version
* - >>> 0: convert to unsigned 32-bit integer
*
* Not cryptographic — just fast with good distribution for short strings.
* @see http://www.cse.yorku.ca/~oz/hash.html
* App-level StreamingMarkdown wrapper that injects IssueMentionCard via renderMention.
*/
function simpleHash(str: string): string {
let hash = 5381
for (let i = 0; i < str.length; i++) {
hash = ((hash << 5) + hash) ^ str.charCodeAt(i)
}
return (hash >>> 0).toString(36)
}
/**
* Split content into blocks (paragraphs and code blocks)
*
* Block boundaries:
* - Double newlines (paragraph separators)
* - Code fences (```)
*
* This is intentionally simple - just string scanning, no regex per line.
*/
function splitIntoBlocks(content: string): Block[] {
const blocks: Block[] = []
const lines = content.split('\n')
let currentBlock = ''
let inCodeBlock = false
let inMathBlock = false
for (let i = 0; i < lines.length; i++) {
const line = lines[i] ?? ''
// Check for code fence (``` at start of line, optionally followed by language)
if (line.startsWith('```')) {
if (!inCodeBlock) {
// Starting a code block - flush current paragraph first
if (currentBlock.trim()) {
blocks.push({ content: currentBlock.trim(), isCodeBlock: false })
currentBlock = ''
}
inCodeBlock = true
currentBlock = line + '\n'
} else {
// Ending a code block
currentBlock += line
blocks.push({ content: currentBlock, isCodeBlock: true })
currentBlock = ''
inCodeBlock = false
}
} else if (inCodeBlock) {
// Inside code block - append line
currentBlock += line + '\n'
// Check for display math fence ($$)
} else if (line.trim() === '$$') {
if (!inMathBlock) {
// Starting a math block - flush current paragraph first
if (currentBlock.trim()) {
blocks.push({ content: currentBlock.trim(), isCodeBlock: false })
currentBlock = ''
}
inMathBlock = true
currentBlock = line + '\n'
} else {
// Ending a math block
currentBlock += line
blocks.push({ content: currentBlock, isCodeBlock: false })
currentBlock = ''
inMathBlock = false
}
} else if (inMathBlock) {
// Inside math block - append line (don't split on blank lines)
currentBlock += line + '\n'
} else if (line === '') {
// Empty line outside code block = paragraph boundary
if (currentBlock.trim()) {
blocks.push({ content: currentBlock.trim(), isCodeBlock: false })
currentBlock = ''
}
} else {
// Regular text line
if (currentBlock) {
currentBlock += '\n' + line
} else {
currentBlock = line
}
}
}
// Flush remaining content
if (currentBlock) {
blocks.push({
content: inCodeBlock || inMathBlock ? currentBlock : currentBlock.trim(),
isCodeBlock: inCodeBlock
})
}
return blocks
}
/**
* Memoized block component
*
* Only re-renders if content or mode changes.
* The key is assigned by the parent based on content hash,
* so identical content won't even attempt to render.
*/
const MemoizedBlock = React.memo(
function Block({
content,
mode,
className,
onUrlClick,
onFileClick
}: {
content: string
mode: RenderMode
className?: string
onUrlClick?: (url: string) => void
onFileClick?: (path: string) => void
}) {
return (
<Markdown mode={mode} className={className} onUrlClick={onUrlClick} onFileClick={onFileClick}>
{content}
</Markdown>
)
},
(prev, next) => {
// Only re-render if content actually changed
return prev.content === next.content && prev.mode === next.mode && prev.className === next.className
}
)
MemoizedBlock.displayName = 'MemoizedBlock'
/**
* StreamingMarkdown - Optimized markdown renderer for streaming content
*
* Splits content into blocks (paragraphs, code blocks) and memoizes each block
* independently. Only the last (active) block re-renders during streaming.
*
* Key insight: Completed blocks get a content-hash as their React key.
* Same content = same key = React skips re-render entirely.
*
* @example
* Content: "Hello\n\n```js\ncode\n```\n\nMore..."
*
* Block 1: "Hello" -> key="block-abc123" -> memoized
* Block 2: "```js\ncode\n```" -> key="block-xyz789" -> memoized
* Block 3: "More..." -> key="active-2" -> re-renders
*/
export function StreamingMarkdown({
content,
isStreaming,
mode = 'minimal',
className,
onUrlClick,
onFileClick
}: StreamingMarkdownProps): React.JSX.Element {
// Split into blocks - memoized to avoid recomputation
// Must be called unconditionally to satisfy Rules of Hooks
const blocks = React.useMemo(
() => (isStreaming ? splitIntoBlocks(content) : []),
[content, isStreaming]
)
// Not streaming - use simple Markdown (no block splitting needed)
if (!isStreaming) {
return (
<Markdown mode={mode} className={className} onUrlClick={onUrlClick} onFileClick={onFileClick}>
{content}
</Markdown>
)
}
// Empty content - return null, let parent handle loading indicator
if (blocks.length === 0) {
return <></>
}
return (
<>
{blocks.map((block, i) => {
const isLastBlock = i === blocks.length - 1
// Complete blocks use content hash as key -> stable identity -> memoized
// Last block uses "active" prefix -> always re-renders on content change
const key = isLastBlock ? `active-${i}` : `block-${i}-${simpleHash(block.content)}`
return (
<MemoizedBlock
key={key}
content={block.content}
mode={mode}
className={className}
onUrlClick={onUrlClick}
onFileClick={onFileClick}
/>
)
})}
</>
)
export function StreamingMarkdown(props: StreamingMarkdownProps): React.JSX.Element {
return <StreamingMarkdownBase renderMention={defaultRenderMention} {...props} />
}

View File

@@ -1,215 +1 @@
import LinkifyIt from 'linkify-it'
/**
* Linkify - URL and file path detection for markdown preprocessing
*
* Uses linkify-it (12M downloads/week) for battle-tested URL detection,
* plus custom regex for local file paths.
*/
// Initialize linkify-it with default settings (fuzzy URLs, emails enabled)
const linkify = new LinkifyIt()
// File path regex - detects /path, ~/path, ./path with common extensions
// Matches paths that start with /, ~/, or ./ followed by path chars and a file extension
const FILE_PATH_REGEX =
/(?:^|[\s([{<])((\/|~\/|\.\/)[\w\-./@]+\.(?:ts|tsx|js|jsx|mjs|cjs|md|json|yaml|yml|py|go|rs|css|scss|less|html|htm|txt|log|sh|bash|zsh|swift|kt|java|c|cpp|h|hpp|rb|php|xml|toml|ini|cfg|conf|env|sql|graphql|vue|svelte|astro|prisma|dockerfile|makefile|gitignore))(?=[\s)\]}.,;:!?>]|$)/gi
interface DetectedLink {
type: 'url' | 'email' | 'file'
text: string
url: string
start: number
end: number
}
interface CodeRange {
start: number
end: number
}
/**
* Find all code block and inline code ranges in text
* These ranges should be excluded from link detection
*/
function findCodeRanges(text: string): CodeRange[] {
const ranges: CodeRange[] = []
// Find fenced code blocks (```...```)
const fencedRegex = /```[\s\S]*?```/g
let match
while ((match = fencedRegex.exec(text)) !== null) {
ranges.push({ start: match.index, end: match.index + match[0].length })
}
// Find display math blocks ($$...$$)
const displayMathRegex = /\$\$[\s\S]*?\$\$/g
while ((match = displayMathRegex.exec(text)) !== null) {
const pos = match.index
const insideOther = ranges.some((r) => pos >= r.start && pos < r.end)
if (!insideOther) {
ranges.push({ start: pos, end: pos + match[0].length })
}
}
// Find inline math ($...$)
const inlineMathRegex = /(?<!\$)\$(?!\$)([^\$\n]+)\$(?!\$)/g
while ((match = inlineMathRegex.exec(text)) !== null) {
const pos = match.index
const insideOther = ranges.some((r) => pos >= r.start && pos < r.end)
if (!insideOther) {
ranges.push({ start: pos, end: pos + match[0].length })
}
}
// Find inline code (`...`)
// But skip escaped backticks and code inside fenced blocks
const inlineRegex = /(?<!`)`(?!`)([^`\n]+)`(?!`)/g
while ((match = inlineRegex.exec(text)) !== null) {
const pos = match.index
// Check if this is inside a fenced block or math block
const insideOther = ranges.some((r) => pos >= r.start && pos < r.end)
if (!insideOther) {
ranges.push({ start: pos, end: pos + match[0].length })
}
}
return ranges
}
/**
* Check if a position is inside any code range
*/
function isInsideCode(pos: number, ranges: CodeRange[]): boolean {
return ranges.some((r) => pos >= r.start && pos < r.end)
}
/**
* Check if a link at given position is already a markdown link
* Looks for patterns like [text](url) or [text][ref]
*/
function isAlreadyLinked(text: string, linkStart: number, linkEnd: number): boolean {
// Check if preceded by ]( which indicates we're inside a markdown link href
// Pattern: [text](URL) - we're checking if URL is our link
const before = text.slice(Math.max(0, linkStart - 2), linkStart)
if (before.endsWith('](')) return true
// Check if preceded by ][ for reference links
if (before.endsWith('][')) return true
// Check if the link text is wrapped in []
// Pattern: [URL](href) - URL is being used as link text
const charBefore = text[linkStart - 1]
const charAfter = text[linkEnd]
if (charBefore === '[' && charAfter === ']') return true
return false
}
/**
* Check if ranges overlap
*/
function rangesOverlap(
a: { start: number; end: number },
b: { start: number; end: number }
): boolean {
return a.start < b.end && b.start < a.end
}
/**
* Detect all links (URLs, emails, file paths) in text
*/
export function detectLinks(text: string): DetectedLink[] {
const links: DetectedLink[] = []
// 1. Detect URLs and emails with linkify-it
const urlMatches = linkify.match(text) || []
for (const match of urlMatches) {
links.push({
type: match.schema === 'mailto:' ? 'email' : 'url',
text: match.text,
url: match.url,
start: match.index,
end: match.lastIndex
})
}
// 2. Detect file paths with custom regex
// Reset regex state
FILE_PATH_REGEX.lastIndex = 0
let fileMatch
while ((fileMatch = FILE_PATH_REGEX.exec(text)) !== null) {
const path = fileMatch[1]
if (!path) continue // Skip if no capture group
// Calculate actual start position (after any leading whitespace/punctuation)
const fullMatch = fileMatch[0]
const pathOffset = fullMatch.indexOf(path)
const start = fileMatch.index + pathOffset
// Check for overlaps with URL matches (URLs take precedence)
const pathRange = { start, end: start + path.length }
const overlapsUrl = links.some((link) => rangesOverlap(pathRange, link))
if (overlapsUrl) continue
links.push({
type: 'file',
text: path,
url: path, // File paths are passed as-is to onFileClick handler
start,
end: start + path.length
})
}
// Sort by position
return links.sort((a, b) => a.start - b.start)
}
/**
* Preprocess text to convert raw URLs and file paths into markdown links
* Skips code blocks and already-linked content
*/
export function preprocessLinks(text: string): string {
// Quick check - if no potential links, return early
if (!linkify.pretest(text) && !/[~/.]\//.test(text)) {
return text
}
const codeRanges = findCodeRanges(text)
const links = detectLinks(text)
if (links.length === 0) return text
// Build result, converting raw links to markdown links
let result = ''
let lastIndex = 0
for (const link of links) {
// Skip if inside code block
if (isInsideCode(link.start, codeRanges)) continue
// Skip if already a markdown link
if (isAlreadyLinked(text, link.start, link.end)) continue
// Add text before this link
result += text.slice(lastIndex, link.start)
// Convert to markdown link
result += `[${link.text}](${link.url})`
lastIndex = link.end
}
// Add remaining text
result += text.slice(lastIndex)
return result
}
/**
* Test if text contains any detectable links
* Useful for optimization - skip preprocessing if no links present
*/
export function hasLinks(text: string): boolean {
return linkify.pretest(text) || /[~/.]\/[\w]/.test(text)
}
export { preprocessLinks, detectLinks, hasLinks } from '@multica/ui/markdown'

View File

@@ -1,25 +1 @@
/**
* Convert legacy mention shortcodes [@ id="UUID" label="LABEL"] to the
* standard markdown link format [@LABEL](mention://member/UUID).
*
* These shortcodes exist in older database records from a previous mention
* serialization format. This function normalises them so downstream parsers
* (Tiptap @tiptap/markdown, react-markdown) only need to handle one syntax.
*/
export function preprocessMentionShortcodes(text: string): string {
if (!text.includes("[@ ")) return text;
return text.replace(
/\[@\s+([^\]]*)\]/g,
(match, attrString: string) => {
const attrs: Record<string, string> = {};
const re = /(\w+)="([^"]*)"/g;
let m;
while ((m = re.exec(attrString)) !== null) {
if (m[1] && m[2] !== undefined) attrs[m[1]] = m[2];
}
const { id, label } = attrs;
if (!id || !label) return match;
return `[@${label}](mention://member/${id})`;
},
);
}
export { preprocessMentionShortcodes } from '@multica/ui/markdown'

View File

@@ -1,5 +1,5 @@
import { useState, useEffect } from "react";
import { cn } from "@/lib/utils";
import { cn } from "@multica/ui/lib/utils";
interface MulticaIconProps extends React.ComponentProps<"span"> {
/**

View File

@@ -8,7 +8,7 @@
* Inherits color from `currentColor` (use Tailwind `text-*`).
* Scales with font-size (use Tailwind `text-*` for size).
*/
import { cn } from "@/lib/utils"
import { cn } from "@multica/ui/lib/utils"
export interface SpinnerProps {
/** Additional className for styling (color via text-*, size via Tailwind text-*) */

View File

@@ -1,7 +1,7 @@
"use client"
import { ThemeProvider as NextThemesProvider } from "next-themes"
import { TooltipProvider } from "@/components/ui/tooltip"
import { TooltipProvider } from "@multica/ui/components/ui/tooltip"
function ThemeProvider({
children,

View File

@@ -7,8 +7,8 @@ import {
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
} from "@/components/ui/dropdown-menu"
import { SidebarMenuButton } from "@/components/ui/sidebar"
} from "@multica/ui/components/ui/dropdown-menu"
import { SidebarMenuButton } from "@multica/ui/components/ui/sidebar"
export function ThemeToggle() {
const { setTheme } = useTheme()

View File

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

View File

@@ -1,10 +1,10 @@
"use client";
import { useEffect, type ReactNode } from "react";
import { useAuthStore } from "./store";
import { useWorkspaceStore } from "@/features/workspace";
import { api } from "@/shared/api";
import { createLogger } from "@/shared/logger";
import { useAuthStore } from "@/platform/auth";
import { useWorkspaceStore } from "@/platform/workspace";
import { api } from "@/platform/api";
import { createLogger } from "@multica/core/logger";
import { setLoggedInCookie, clearLoggedInCookie } from "./auth-cookie";
const logger = createLogger("auth");

View File

@@ -1,69 +0,0 @@
"use client";
import { create } from "zustand";
import type { User } from "@/shared/types";
import { api } from "@/shared/api";
import { setLoggedInCookie, clearLoggedInCookie } from "./auth-cookie";
interface AuthState {
user: User | null;
isLoading: boolean;
initialize: () => Promise<void>;
sendCode: (email: string) => Promise<void>;
verifyCode: (email: string, code: string) => Promise<User>;
logout: () => void;
setUser: (user: User) => void;
}
export const useAuthStore = create<AuthState>((set) => ({
user: null,
isLoading: true,
initialize: async () => {
const token = localStorage.getItem("multica_token");
if (!token) {
set({ isLoading: false });
return;
}
api.setToken(token);
try {
const user = await api.getMe();
set({ user, isLoading: false });
} catch {
api.setToken(null);
api.setWorkspaceId(null);
localStorage.removeItem("multica_token");
localStorage.removeItem("multica_workspace_id");
set({ user: null, isLoading: false });
}
},
sendCode: async (email: string) => {
await api.sendCode(email);
},
verifyCode: async (email: string, code: string) => {
const { token, user } = await api.verifyCode(email, code);
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();
set({ user: null });
},
setUser: (user: User) => {
set({ user });
},
}));

View File

@@ -1,24 +0,0 @@
import { preprocessLinks } from "@/components/markdown/linkify";
import { preprocessMentionShortcodes } from "@/components/markdown/mentions";
/**
* Preprocess a markdown string before loading into Tiptap via contentType: 'markdown'.
*
* This is the ONLY transform applied before @tiptap/markdown parses the content.
* 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:
* 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)).
*/
export function preprocessMarkdown(markdown: string): string {
if (!markdown) return "";
const step1 = preprocessMentionShortcodes(markdown);
const step2 = preprocessLinks(step1);
return step2;
}

View File

@@ -1 +0,0 @@
export { useInboxStore } from "./store";

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,261 +0,0 @@
"use client";
import { useState, useCallback, useMemo } from "react";
import {
DndContext,
DragOverlay,
PointerSensor,
useSensor,
useSensors,
pointerWithin,
closestCenter,
type CollisionDetection,
type DragStartEvent,
type DragEndEvent,
} from "@dnd-kit/core";
import { Eye, MoreHorizontal } from "lucide-react";
import type { Issue, IssueStatus } from "@/shared/types";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
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 { StatusIcon } from "./status-icon";
import { BoardColumn } from "./board-column";
import { BoardCardContent } from "./board-card";
const COLUMN_IDS = new Set<string>(ALL_STATUSES);
const kanbanCollision: CollisionDetection = (args) => {
const pointer = pointerWithin(args);
if (pointer.length > 0) {
// Prefer card collisions over column collisions so that
// dragging down within a column finds the target card
// instead of the column droppable.
const cards = pointer.filter((c) => !COLUMN_IDS.has(c.id as string));
if (cards.length > 0) return cards;
}
// Fallback: closestCenter finds the nearest card even when
// the pointer is in a gap between cards (common when dragging down).
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;
}
export function BoardView({
issues,
allIssues,
visibleStatuses,
hiddenStatuses,
onMoveIssue,
}: {
issues: Issue[];
allIssues: Issue[];
visibleStatuses: IssueStatus[];
hiddenStatuses: IssueStatus[];
onMoveIssue: (
issueId: string,
newStatus: IssueStatus,
newPosition?: number
) => void;
}) {
const [activeIssue, setActiveIssue] = useState<Issue | null>(null);
const sensors = useSensors(
useSensor(PointerSensor, {
activationConstraint: { distance: 5 },
})
);
// 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);
},
[issues]
);
const handleDragEnd = useCallback(
(event: DragEndEvent) => {
setActiveIssue(null);
const { active, over } = event;
if (!over || active.id === over.id) return;
const issueId = active.id as string;
const currentIssue = issues.find((i) => i.id === issueId);
if (!currentIssue) return;
// Determine target status
let targetStatus: IssueStatus;
let overIsColumn = false;
if (visibleStatuses.includes(over.id as IssueStatus)) {
targetStatus = over.id as IssueStatus;
overIsColumn = true;
} else {
const targetIssue = issues.find((i) => i.id === over.id);
if (!targetIssue) return;
targetStatus = targetIssue.status;
}
// Get sorted siblings in the target column (excluding the dragged item)
const siblings = (issuesByStatus[targetStatus] ?? []).filter(
(i) => i.id !== issueId
);
// Compute new position
let newPosition: number;
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);
}
}
}
// Skip if nothing changed
if (
currentIssue.status === targetStatus &&
currentIssue.position === newPosition
) {
return;
}
onMoveIssue(issueId, targetStatus, newPosition);
},
[issues, issuesByStatus, onMoveIssue, visibleStatuses]
);
return (
<DndContext
sensors={sensors}
collisionDetection={kanbanCollision}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
>
<div className="flex flex-1 min-h-0 gap-4 overflow-x-auto p-4">
{visibleStatuses.map((status) => (
<BoardColumn
key={status}
status={status}
issues={issues.filter((i) => i.status === status)}
/>
))}
{hiddenStatuses.length > 0 && (
<HiddenColumnsPanel
hiddenStatuses={hiddenStatuses}
issues={allIssues}
/>
)}
</div>
<DragOverlay>
{activeIssue ? (
<div className="w-[280px] rotate-1 cursor-grabbing opacity-95 shadow-md">
<BoardCardContent issue={activeIssue} />
</div>
) : null}
</DragOverlay>
</DndContext>
);
}
function HiddenColumnsPanel({
hiddenStatuses,
issues,
}: {
hiddenStatuses: IssueStatus[];
issues: Issue[];
}) {
const viewStoreApi = useViewStoreApi();
return (
<div className="flex w-[240px] shrink-0 flex-col">
<div className="mb-2 flex items-center gap-2 px-1">
<span className="text-sm font-medium text-muted-foreground">
Hidden columns
</span>
</div>
<div className="flex-1 space-y-0.5">
{hiddenStatuses.map((status) => {
const cfg = STATUS_CONFIG[status];
const count = issues.filter((i) => i.status === status).length;
return (
<div
key={status}
className="flex items-center justify-between rounded-lg px-2.5 py-2 hover:bg-muted/50"
>
<div className="flex items-center gap-2">
<StatusIcon status={status} className="h-3.5 w-3.5" />
<span className="text-sm">{cfg.label}</span>
</div>
<div className="flex items-center gap-1.5">
<span className="text-xs text-muted-foreground">{count}</span>
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button
variant="ghost"
size="icon-sm"
className="rounded-full text-muted-foreground"
>
<MoreHorizontal className="size-3.5" />
</Button>
}
/>
<DropdownMenuContent align="end">
<DropdownMenuItem
onClick={() =>
viewStoreApi.getState().showStatus(status)
}
>
<Eye className="size-3.5" />
Show column
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
);
})}
</div>
</div>
);
}

View File

@@ -1,37 +0,0 @@
"use client";
import Link from "next/link";
import { useIssueStore } from "@/features/issues/store";
import { StatusIcon } from "./status-icon";
interface IssueMentionCardProps {
issueId: string;
/** Fallback text when issue is not in store (e.g. "MUL-7") */
fallbackLabel?: string;
}
export function IssueMentionCard({ issueId, fallbackLabel }: IssueMentionCardProps) {
const issue = useIssueStore((s) => s.issues.find((i) => i.id === issueId));
if (!issue) {
return (
<Link
href={`/issues/${issueId}`}
className="text-primary font-medium cursor-pointer hover:underline"
>
{fallbackLabel ?? issueId.slice(0, 8)}
</Link>
);
}
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"
>
<StatusIcon status={issue.status} className="h-3.5 w-3.5" />
<span className="font-medium text-muted-foreground">{issue.identifier}</span>
<span className="text-foreground">{issue.title}</span>
</Link>
);
}

View File

@@ -1,112 +0,0 @@
"use client";
import { useState, useEffect, useCallback } from "react";
import type { IssueReaction } from "@/shared/types";
import type {
IssueReactionAddedPayload,
IssueReactionRemovedPayload,
} from "@/shared/types";
import { api } from "@/shared/api";
import { toast } from "sonner";
import { useWSEvent, useWSReconnect } from "@/features/realtime";
export function useIssueReactions(issueId: string, userId?: string) {
const [reactions, setReactions] = useState<IssueReaction[]>([]);
const [loading, setLoading] = useState(true);
// Initial fetch
useEffect(() => {
setReactions([]);
setLoading(true);
api
.getIssue(issueId)
.then((iss) => setReactions(iss.reactions ?? []))
.catch((e) => {
console.error(e);
toast.error("Failed to load reactions");
})
.finally(() => setLoading(false));
}, [issueId]);
// Reconnect recovery
useWSReconnect(
useCallback(() => {
api.getIssue(issueId).then((iss) => setReactions(iss.reactions ?? [])).catch(console.error);
}, [issueId]),
);
// --- WS event handlers ---
useWSEvent(
"issue_reaction:added",
useCallback(
(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];
});
},
[issueId, userId],
),
);
useWSEvent(
"issue_reaction:removed",
useCallback(
(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),
),
);
},
[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,
);
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");
}
}
},
[issueId, userId, reactions],
);
return { reactions, loading, toggleReaction };
}

View File

@@ -1,131 +0,0 @@
"use client";
import { useState, useEffect, useCallback } from "react";
import type { IssueSubscriber } from "@/shared/types";
import type {
SubscriberAddedPayload,
SubscriberRemovedPayload,
} from "@/shared/types";
import { api } from "@/shared/api";
import { toast } from "sonner";
import { useWSEvent, useWSReconnect } from "@/features/realtime";
export function useIssueSubscribers(issueId: string, userId?: string) {
const [subscribers, setSubscribers] = useState<IssueSubscriber[]>([]);
const [loading, setLoading] = useState(true);
// Initial fetch
useEffect(() => {
setSubscribers([]);
setLoading(true);
api
.listIssueSubscribers(issueId)
.then((subs) => setSubscribers(subs))
.catch((e) => {
console.error(e);
toast.error("Failed to load subscribers");
})
.finally(() => setLoading(false));
}, [issueId]);
// Reconnect recovery
useWSReconnect(
useCallback(() => {
api.listIssueSubscribers(issueId).then(setSubscribers).catch(console.error);
}, [issueId]),
);
// --- WS event handlers ---
useWSEvent(
"subscriber:added",
useCallback(
(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(),
},
];
});
},
[issueId],
),
);
useWSEvent(
"subscriber:removed",
useCallback(
(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)),
);
},
[issueId],
),
);
// --- Mutations ---
const isSubscribed = subscribers.some(
(s) => s.user_type === "member" && s.user_id === userId,
);
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");
}
}
},
[issueId, subscribers],
);
const toggleSubscribe = useCallback(() => {
if (userId) toggleSubscriber(userId, "member", isSubscribed);
}, [userId, isSubscribed, toggleSubscriber]);
return { subscribers, loading, isSubscribed, toggleSubscribe, toggleSubscriber };
}

View File

@@ -1,348 +0,0 @@
"use client";
import { useState, useEffect, useCallback } from "react";
import type { Comment, TimelineEntry } from "@/shared/types";
import type {
CommentCreatedPayload,
CommentUpdatedPayload,
CommentDeletedPayload,
ActivityCreatedPayload,
ReactionAddedPayload,
ReactionRemovedPayload,
} from "@/shared/types";
import { api } from "@/shared/api";
import { useWSEvent, useWSReconnect } from "@/features/realtime";
import { toast } from "sonner";
function commentToTimelineEntry(c: Comment): TimelineEntry {
return {
type: "comment",
id: c.id,
actor_type: c.author_type,
actor_id: c.author_id,
content: c.content,
parent_id: c.parent_id,
created_at: c.created_at,
updated_at: c.updated_at,
comment_type: c.type,
reactions: c.reactions ?? [],
};
}
export function useIssueTimeline(issueId: string, userId?: string) {
const [timeline, setTimeline] = useState<TimelineEntry[]>([]);
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]);
// Reconnect recovery
useWSReconnect(
useCallback(() => {
api.listTimeline(issueId).then(setTimeline).catch(console.error);
}, [issueId]),
);
// --- WS event handlers ---
useWSEvent(
"comment:created",
useCallback(
(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)];
});
},
[issueId, userId],
),
);
useWSEvent(
"comment:updated",
useCallback(
(payload: unknown) => {
const { comment } = payload as CommentUpdatedPayload;
if (comment.issue_id === issueId) {
setTimeline((prev) =>
prev.map((e) => (e.id === comment.id ? commentToTimelineEntry(comment) : e)),
);
}
},
[issueId],
),
);
useWSEvent(
"comment:deleted",
useCallback(
(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;
}
}
}
return prev.filter((e) => !idsToRemove.has(e.id));
});
}
},
[issueId],
),
);
useWSEvent(
"activity:created",
useCallback(
(payload: unknown) => {
const p = payload as ActivityCreatedPayload;
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];
});
},
[issueId],
),
);
useWSEvent(
"reaction:added",
useCallback(
(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] };
}),
);
},
[issueId, userId],
),
);
useWSEvent(
"reaction:removed",
useCallback(
(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),
),
};
}),
);
},
[issueId, userId],
),
);
// --- Mutation functions ---
const submitComment = useCallback(
async (content: string, attachmentIds?: 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)];
});
} catch {
toast.error("Failed to send comment");
} finally {
setSubmitting(false);
}
},
[issueId, userId],
);
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)];
});
} catch {
toast.error("Failed to send reply");
}
},
[issueId, userId],
);
const editComment = useCallback(
async (commentId: string, content: string) => {
// Optimistic: update content immediately
let prevContent: string | undefined;
setTimeline((prev) =>
prev.map((e) => {
if (e.id !== commentId) return e;
prevContent = e.content;
return { ...e, content, updated_at: new Date().toISOString() };
}),
);
try {
const updated = await api.updateComment(commentId, content);
setTimeline((prev) =>
prev.map((e) => (e.id === updated.id ? commentToTimelineEntry(updated) : e)),
);
} catch {
// Rollback
if (prevContent !== undefined) {
setTimeline((prev) =>
prev.map((e) => (e.id === commentId ? { ...e, content: prevContent! } : e)),
);
}
toast.error("Failed to update comment");
}
},
[],
);
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);
} catch {
// Rollback: re-add removed entries
setTimeline((prev) => [...prev, ...removedEntries]);
toast.error("Failed to delete comment");
}
},
[],
);
const toggleReaction = useCallback(
async (commentId: string, emoji: string) => {
if (!userId) return;
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,
);
if (existing) {
setTimeline((prev) =>
prev.map((e) => {
if (e.id !== commentId) return e;
return { ...e, reactions: (e.reactions ?? []).filter((r) => r.id !== existing.id) };
}),
);
try {
await api.removeReaction(commentId, emoji);
} catch {
setTimeline((prev) =>
prev.map((e) => {
if (e.id !== commentId) return e;
return { ...e, reactions: [...(e.reactions ?? []), existing] };
}),
);
toast.error("Failed to remove reaction");
}
} else {
const tempReaction = {
id: `temp-${Date.now()}`,
comment_id: commentId,
actor_type: "member",
actor_id: userId,
emoji,
created_at: new Date().toISOString(),
};
setTimeline((prev) =>
prev.map((e) => {
if (e.id !== commentId) return e;
return { ...e, reactions: [...(e.reactions ?? []), tempReaction] };
}),
);
try {
const reaction = await api.addReaction(commentId, emoji);
setTimeline((prev) =>
prev.map((e) => {
if (e.id !== commentId) return e;
return {
...e,
reactions: (e.reactions ?? []).map((r) => (r.id === tempReaction.id ? reaction : r)),
};
}),
);
} catch {
setTimeline((prev) =>
prev.map((e) => {
if (e.id !== commentId) return e;
return { ...e, reactions: (e.reactions ?? []).filter((r) => r.id !== tempReaction.id) };
}),
);
toast.error("Failed to add reaction");
}
}
},
[userId, timeline],
);
return {
timeline,
loading,
submitting,
submitComment,
submitReply,
editComment,
deleteComment,
toggleReaction,
};
}

View File

@@ -1,5 +0,0 @@
export { useIssueStore } from "./store";
export { useIssueViewStore, createIssueViewStore } from "./stores/view-store";
export { ViewStoreProvider, useViewStore } from "./stores/view-store-context";
export { StatusIcon, PriorityIcon, StatusPicker, PriorityPicker, AssigneePicker } from "./components";
export * from "./config";

View File

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

@@ -1,7 +1,7 @@
"use client";
import { useState } from "react";
import { cn } from "@/lib/utils";
import { cn } from "@multica/ui/lib/utils";
import { useLocale } from "../i18n";
export function FAQSection() {

View File

@@ -18,15 +18,14 @@ import {
Sparkles,
UserMinus,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { cn } from "@multica/ui/lib/utils";
import { ImageIcon } from "./shared";
import { useLocale } from "../i18n";
import type { LandingDict } from "../i18n";
import { StatusIcon } from "@/features/issues/components/status-icon";
import { PriorityIcon } from "@/features/issues/components/priority-icon";
import { STATUS_CONFIG } from "@/features/issues/config/status";
import { PRIORITY_CONFIG } from "@/features/issues/config/priority";
import type { IssueStatus, IssuePriority } from "@/shared/types";
import { StatusIcon, PriorityIcon } from "@multica/views/issues/components";
import { STATUS_CONFIG } from "@multica/core/issues/config/status";
import { PRIORITY_CONFIG } from "@multica/core/issues/config/priority";
import type { IssueStatus, IssuePriority } from "@multica/core/types";
/* ------------------------------------------------------------------ */
/* Mock ActorAvatar — mirrors the real ActorAvatar styling exactly */

View File

@@ -2,7 +2,7 @@
import Link from "next/link";
import { MulticaIcon } from "@/components/multica-icon";
import { cn } from "@/lib/utils";
import { cn } from "@multica/ui/lib/utils";
import { useAuthStore } from "@/features/auth";
import { useLocale, locales, localeLabels } from "../i18n";

View File

@@ -2,7 +2,7 @@
import Link from "next/link";
import { MulticaIcon } from "@/components/multica-icon";
import { cn } from "@/lib/utils";
import { cn } from "@multica/ui/lib/utils";
import { useAuthStore } from "@/features/auth";
import { useLocale } from "../i18n";
import { GitHubMark, githubUrl, headerButtonClassName } from "./shared";

View File

@@ -1,4 +1,4 @@
import { cn } from "@/lib/utils";
import { cn } from "@multica/ui/lib/utils";
export const githubUrl = "https://github.com/multica-ai/multica";

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

@@ -1,2 +0,0 @@
export { useModalStore } from "./store";
export { ModalRegistry } from "./registry";

View File

@@ -1,2 +0,0 @@
export { WSProvider, useWS } from "./provider";
export { useWSEvent, useWSReconnect } from "./hooks";

View File

@@ -1,91 +0,0 @@
"use client";
import {
createContext,
useContext,
useEffect,
useState,
useRef,
useCallback,
type ReactNode,
} from "react";
import { WSClient } from "@/shared/api";
import type { WSEventType } from "@/shared/types";
import { useAuthStore } from "@/features/auth";
import { useWorkspaceStore } from "@/features/workspace";
import { createLogger } from "@/shared/logger";
import { useRealtimeSync } from "./use-realtime-sync";
const WS_URL =
process.env.NEXT_PUBLIC_WS_URL ||
(typeof window !== "undefined"
? `${window.location.protocol === "https:" ? "wss:" : "ws:"}//${window.location.host}/ws`
: "ws://localhost:8080/ws");
type EventHandler = (payload: unknown) => void;
interface WSContextValue {
subscribe: (event: WSEventType, handler: EventHandler) => () => void;
onReconnect: (callback: () => void) => () => void;
}
const WSContext = createContext<WSContextValue | null>(null);
export function WSProvider({ children }: { children: ReactNode }) {
const user = useAuthStore((s) => s.user);
const workspace = useWorkspaceStore((s) => s.workspace);
const [wsClient, setWsClient] = useState<WSClient | null>(null);
const wsRef = useRef<WSClient | null>(null);
useEffect(() => {
if (!user || !workspace) return;
const token = localStorage.getItem("multica_token");
if (!token) return;
const ws = new WSClient(WS_URL, { logger: createLogger("ws") });
ws.setAuth(token, workspace.id);
wsRef.current = ws;
setWsClient(ws);
ws.connect();
return () => {
ws.disconnect();
wsRef.current = null;
setWsClient(null);
};
}, [user, workspace]);
// Centralized WS → store sync (uses state so it re-subscribes when WS changes)
useRealtimeSync(wsClient);
const subscribe = useCallback(
(event: WSEventType, handler: EventHandler) => {
const ws = wsRef.current;
if (!ws) return () => {};
return ws.on(event, handler);
},
[],
);
const onReconnectCb = useCallback(
(callback: () => void) => {
const ws = wsRef.current;
if (!ws) return () => {};
return ws.onReconnect(callback);
},
[],
);
return (
<WSContext.Provider value={{ subscribe, onReconnect: onReconnectCb }}>
{children}
</WSContext.Provider>
);
}
export function useWS() {
const ctx = useContext(WSContext);
if (!ctx) throw new Error("useWS must be used within WSProvider");
return ctx;
}

View File

@@ -1,184 +0,0 @@
"use client";
import { useEffect } from "react";
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 type {
MemberAddedPayload,
WorkspaceDeletedPayload,
MemberRemovedPayload,
IssueUpdatedPayload,
IssueCreatedPayload,
IssueDeletedPayload,
InboxNewPayload,
} from "@/shared/types";
const logger = createLogger("realtime-sync");
/**
* Centralized WS → store sync. Called once from WSProvider.
*
* Uses the "WS as invalidation signal + refetch" pattern:
* - onAny handler extracts event prefix and calls the matching store refresh
* - 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.
*/
export function useRealtimeSync(ws: WSClient | null) {
// 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);
});
},
skill: () => void useWorkspaceStore.getState().refreshSkills(),
};
const timers = new Map<string, ReturnType<typeof setTimeout>>();
const debouncedRefresh = (prefix: string, fn: () => void) => {
const existing = timers.get(prefix);
if (existing) clearTimeout(existing);
timers.set(
prefix,
setTimeout(() => {
timers.delete(prefix);
fn();
}, 100),
);
};
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) ---
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 unsubIssueCreated = ws.on("issue:created", (p) => {
const { issue } = p as IssueCreatedPayload;
if (issue) useIssueStore.getState().addIssue(issue);
});
const unsubIssueDeleted = ws.on("issue:deleted", (p) => {
const { issue_id } = p as IssueDeletedPayload;
if (issue_id) useIssueStore.getState().removeIssue(issue_id);
});
const unsubInboxNew = ws.on("inbox:new", (p) => {
const { item } = p as InboxNewPayload;
if (item) useInboxStore.getState().addItem(item);
});
// --- Side-effect handlers (toast, navigation) ---
const unsubWsDeleted = ws.on("workspace:deleted", (p) => {
const { workspace_id } = p as WorkspaceDeletedPayload;
const currentWs = useWorkspaceStore.getState().workspace;
if (currentWs?.id === workspace_id) {
logger.warn("current workspace deleted, switching");
toast.info("This workspace was deleted");
useWorkspaceStore.getState().refreshWorkspaces();
}
});
const unsubMemberRemoved = ws.on("member:removed", (p) => {
const { user_id } = p as MemberRemovedPayload;
const myUserId = useAuthStore.getState().user?.id;
if (user_id === myUserId) {
logger.warn("removed from workspace, switching");
toast.info("You were removed from this workspace");
useWorkspaceStore.getState().refreshWorkspaces();
}
});
const unsubMemberAdded = ws.on("member:added", (p) => {
const { member, workspace_name } = p as MemberAddedPayload;
const myUserId = useAuthStore.getState().user?.id;
if (member.user_id === myUserId) {
useWorkspaceStore.getState().refreshWorkspaces();
toast.info(
`You were invited to ${workspace_name ?? "a workspace"}`,
);
}
});
return () => {
unsubAny();
unsubIssueUpdated();
unsubIssueCreated();
unsubIssueDeleted();
unsubInboxNew();
unsubWsDeleted();
unsubMemberRemoved();
unsubMemberAdded();
timers.forEach(clearTimeout);
timers.clear();
};
}, [ws]);
// Reconnect → refetch all data to recover missed events
useEffect(() => {
if (!ws) return;
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(),
]);
} catch (e) {
logger.error("reconnect refetch failed", e);
}
});
return unsub;
}, [ws]);
}

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

@@ -0,0 +1,198 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import { useRouter } from "next/navigation";
import { Loader2, MessageSquare, SearchIcon } from "lucide-react";
import { Command as CommandPrimitive } from "cmdk";
import type { SearchIssueResult } from "@multica/core/types";
import { api } from "@/platform/api";
import { StatusIcon } from "@multica/views/issues/components";
import { STATUS_CONFIG } from "@multica/core/issues/config";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
} from "@multica/ui/components/ui/dialog";
export function SearchCommand() {
const router = useRouter();
const [open, setOpen] = useState(false);
const [query, setQuery] = useState("");
const [results, setResults] = useState<SearchIssueResult[]>([]);
const [isLoading, setIsLoading] = useState(false);
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const abortRef = useRef<AbortController | null>(null);
// Global Cmd+K / Ctrl+K shortcut
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "k" && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
setOpen((prev) => !prev);
}
};
document.addEventListener("keydown", handleKeyDown);
return () => document.removeEventListener("keydown", handleKeyDown);
}, []);
// Cleanup debounce/abort on unmount
useEffect(() => {
return () => {
if (debounceRef.current) clearTimeout(debounceRef.current);
if (abortRef.current) abortRef.current.abort();
};
}, []);
// Reset state when dialog closes
useEffect(() => {
if (!open) {
setQuery("");
setResults([]);
setIsLoading(false);
}
}, [open]);
const search = useCallback((q: string) => {
if (debounceRef.current) clearTimeout(debounceRef.current);
if (abortRef.current) abortRef.current.abort();
if (!q.trim()) {
setResults([]);
setIsLoading(false);
return;
}
setIsLoading(true);
debounceRef.current = setTimeout(async () => {
const controller = new AbortController();
abortRef.current = controller;
try {
const res = await api.searchIssues({
q: q.trim(),
limit: 20,
include_closed: true,
signal: controller.signal,
});
if (!controller.signal.aborted) {
setResults(res.issues);
setIsLoading(false);
}
} catch {
if (!controller.signal.aborted) {
setIsLoading(false);
}
}
}, 300);
}, []);
const handleValueChange = useCallback(
(value: string) => {
setQuery(value);
search(value);
},
[search],
);
const handleSelect = useCallback(
(issueId: string) => {
setOpen(false);
router.push(`/issues/${issueId}`);
},
[router],
);
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent
className="top-[20%] translate-y-0 overflow-hidden rounded-xl! p-0 sm:max-w-xl!"
showCloseButton={false}
>
<DialogHeader className="sr-only">
<DialogTitle>Search Issues</DialogTitle>
<DialogDescription>
Search issues by title, description, or comments
</DialogDescription>
</DialogHeader>
<CommandPrimitive
shouldFilter={false}
className="flex size-full flex-col overflow-hidden rounded-xl bg-popover text-popover-foreground"
>
{/* Search input */}
<div className="flex items-center gap-3 border-b px-4 py-3">
<SearchIcon className="size-5 shrink-0 text-muted-foreground" />
<CommandPrimitive.Input
placeholder="Type a command or search..."
value={query}
onValueChange={handleValueChange}
className="flex-1 bg-transparent text-sm outline-none placeholder:text-muted-foreground"
/>
<kbd className="hidden shrink-0 rounded bg-muted px-1.5 py-0.5 text-[10px] font-medium text-muted-foreground sm:inline">
ESC
</kbd>
</div>
{/* Results list */}
<CommandPrimitive.List className="max-h-[min(400px,50vh)] overflow-y-auto overflow-x-hidden">
{isLoading && (
<div className="flex items-center justify-center py-10">
<Loader2 className="size-5 animate-spin text-muted-foreground" />
</div>
)}
{!isLoading && query.trim() && results.length === 0 && (
<CommandPrimitive.Empty className="py-10 text-center text-sm text-muted-foreground">
No issues found.
</CommandPrimitive.Empty>
)}
{!isLoading && results.length > 0 && (
<CommandPrimitive.Group className="p-2">
{results.map((issue) => (
<CommandPrimitive.Item
key={issue.id}
value={issue.id}
onSelect={handleSelect}
className="flex cursor-default select-none flex-col gap-1 rounded-lg px-3 py-2.5 text-sm outline-none data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 data-selected:bg-accent"
>
<div className="flex items-center gap-2.5">
<StatusIcon
status={issue.status}
className="size-4 shrink-0"
/>
<span className="text-xs text-muted-foreground shrink-0">
{issue.identifier}
</span>
<span className="truncate">{issue.title}</span>
<span
className={`ml-auto text-xs shrink-0 ${STATUS_CONFIG[issue.status].iconColor}`}
>
{STATUS_CONFIG[issue.status].label}
</span>
</div>
{issue.match_source === "comment" &&
issue.matched_snippet && (
<div className="flex items-start gap-2 pl-[26px]">
<MessageSquare className="size-3 shrink-0 text-muted-foreground mt-0.5" />
<span className="text-xs text-muted-foreground truncate">
{issue.matched_snippet}
</span>
</div>
)}
</CommandPrimitive.Item>
))}
</CommandPrimitive.Group>
)}
{!isLoading && !query.trim() && (
<div className="py-10 text-center text-sm text-muted-foreground">
Type to search issues...
</div>
)}
</CommandPrimitive.List>
</CommandPrimitive>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1 @@
export { SearchCommand } from "./components/search-command";

View File

@@ -1,3 +0,0 @@
export { useWorkspaceStore } from "./store";
export { useActorName } from "./hooks";
export { WorkspaceAvatar } from "./components/workspace-avatar";

View File

@@ -1,239 +0,0 @@
"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 { toast } from "sonner";
import { api } from "@/shared/api";
import { createLogger } from "@/shared/logger";
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>;
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;
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;
}
type WorkspaceStore = WorkspaceState & WorkspaceActions;
export const useWorkspaceStore = create<WorkspaceStore>((set, get) => ({
// State
workspace: null,
workspaces: [],
members: [],
agents: [],
skills: [],
// Actions
hydrateWorkspace: async (wsList, preferredWorkspaceId) => {
set({ workspaces: wsList });
const nextWorkspace =
(preferredWorkspaceId
? wsList.find((item) => item.id === preferredWorkspaceId)
: null) ??
wsList[0] ??
null;
if (!nextWorkspace) {
api.setWorkspaceId(null);
localStorage.removeItem("multica_workspace_id");
set({ workspace: null, members: [], agents: [], skills: [] });
return null;
}
api.setWorkspaceId(nextWorkspace.id);
localStorage.setItem("multica_workspace_id", nextWorkspace.id);
set({ workspace: nextWorkspace });
logger.debug("hydrate workspace", nextWorkspace.name, nextWorkspace.id);
const [nextMembers, nextAgents, nextSkills] = await Promise.all([
api.listMembers(nextWorkspace.id).catch((e) => {
logger.error("failed to load members", e);
toast.error("Failed to load members");
return [] as MemberWithUser[];
}),
api.listAgents({ workspace_id: nextWorkspace.id, include_archived: true }).catch((e) => {
logger.error("failed to load agents", e);
toast.error("Failed to load agents");
return [] as Agent[];
}),
api.listSkills().catch(() => [] as Skill[]),
useIssueStore.getState().fetch().catch(() => {}),
useInboxStore.getState().fetch().catch(() => {}),
]);
logger.info("hydrate complete", "members:", nextMembers.length, "agents:", nextAgents.length);
set({ members: nextMembers, agents: nextAgents, skills: nextSkills });
return nextWorkspace;
},
switchWorkspace: async (workspaceId) => {
logger.info("switching to", workspaceId);
const { workspaces, hydrateWorkspace } = get();
const ws = workspaces.find((item) => item.id === workspaceId);
if (!ws) return;
// Switch identity FIRST — api client, localStorage, and the
// workspace object in this store — so that any in-flight refetch
// (e.g. triggered by a WS event during the async gap) already
// targets the new workspace.
api.setWorkspaceId(ws.id);
localStorage.setItem("multica_workspace_id", ws.id);
// Clear ALL stale data across every store before hydrating.
useIssueStore.getState().setIssues([]);
useInboxStore.getState().setItems([]);
useRuntimeStore.getState().setRuntimes([]);
set({ workspace: ws, members: [], agents: [], skills: [] });
await hydrateWorkspace(workspaces, ws.id);
},
refreshWorkspaces: async () => {
const { workspace, hydrateWorkspace } = get();
const storedWorkspaceId = localStorage.getItem("multica_workspace_id");
try {
const wsList = await api.listWorkspaces();
await hydrateWorkspace(wsList, workspace?.id ?? storedWorkspaceId);
return wsList;
} catch (e) {
logger.error("failed to refresh workspaces", e);
toast.error("Failed to refresh workspaces");
return get().workspaces;
}
},
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,
workspaces: state.workspaces.map((item) =>
item.id === ws.id ? ws : item,
),
}));
},
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);
},
deleteWorkspace: async (workspaceId) => {
await api.deleteWorkspace(workspaceId);
const { workspace, hydrateWorkspace } = get();
const wsList = await api.listWorkspaces();
const preferredWorkspaceId =
workspace?.id === workspaceId ? null : (workspace?.id ?? null);
await hydrateWorkspace(wsList, preferredWorkspaceId);
},
clearWorkspace: () => {
api.setWorkspaceId(null);
localStorage.removeItem("multica_workspace_id");
set({ workspace: null, workspaces: [], members: [], agents: [], skills: [] });
},
}));

View File

@@ -7,7 +7,25 @@ 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 = {
transpilePackages: ["@multica/core", "@multica/ui", "@multica/views"],
...(allowedDevOrigins && allowedDevOrigins.length > 0
? { allowedDevOrigins }
: {}),
images: {
formats: ["image/avif", "image/webp"],
qualities: [75, 80, 85],

View File

@@ -12,17 +12,21 @@
"test": "vitest run"
},
"dependencies": {
"@multica/core": "workspace:*",
"@multica/ui": "workspace:*",
"@multica/views": "workspace:*",
"@base-ui/react": "^1.3.0",
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@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 +37,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",

29
apps/web/platform/api.ts Normal file
View File

@@ -0,0 +1,29 @@
import { ApiClient } from "@multica/core/api/client";
import { setApiInstance } from "@multica/core/api";
import { createLogger } from "@multica/core/logger";
const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || "";
export const api = new ApiClient(API_BASE_URL, {
logger: createLogger("api"),
onUnauthorized: () => {
if (typeof window !== "undefined") {
localStorage.removeItem("multica_token");
localStorage.removeItem("multica_workspace_id");
if (window.location.pathname !== "/") {
window.location.href = "/";
}
}
},
});
// Register as the global singleton for @multica/core queries/mutations
setApiInstance(api);
// Hydrate from localStorage
if (typeof window !== "undefined") {
const token = localStorage.getItem("multica_token");
if (token) api.setToken(token);
const wsId = localStorage.getItem("multica_workspace_id");
if (wsId) api.setWorkspaceId(wsId);
}

16
apps/web/platform/auth.ts Normal file
View File

@@ -0,0 +1,16 @@
import { createAuthStore, registerAuthStore } from "@multica/core/auth";
import { api } from "./api";
import { webStorage } from "./storage";
import {
setLoggedInCookie,
clearLoggedInCookie,
} from "../features/auth/auth-cookie";
export const useAuthStore = createAuthStore({
api,
storage: webStorage,
onLogin: setLoggedInCookie,
onLogout: clearLoggedInCookie,
});
registerAuthStore(useAuthStore);

View File

@@ -0,0 +1,3 @@
export { api } from "./api";
export { useAuthStore } from "./auth";
export { useWorkspaceStore } from "./workspace";

View File

@@ -0,0 +1,40 @@
"use client";
import { Suspense } from "react";
import { useRouter, usePathname, useSearchParams } from "next/navigation";
import {
NavigationProvider,
type NavigationAdapter,
} from "@multica/views/navigation";
function NavigationProviderInner({
children,
}: {
children: React.ReactNode;
}) {
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
const adapter: NavigationAdapter = {
push: router.push,
replace: router.replace,
back: router.back,
pathname,
searchParams: new URLSearchParams(searchParams.toString()),
};
return <NavigationProvider value={adapter}>{children}</NavigationProvider>;
}
export function WebNavigationProvider({
children,
}: {
children: React.ReactNode;
}) {
return (
<Suspense>
<NavigationProviderInner>{children}</NavigationProviderInner>
</Suspense>
);
}

View File

@@ -0,0 +1,16 @@
import type { StorageAdapter } from "@multica/core/types/storage";
/**
* SSR-safe localStorage wrapper.
* Returns null / no-ops when running on the server (typeof window === "undefined").
*/
export const webStorage: StorageAdapter = {
getItem: (k) =>
typeof window !== "undefined" ? localStorage.getItem(k) : null,
setItem: (k, v) => {
if (typeof window !== "undefined") localStorage.setItem(k, v);
},
removeItem: (k) => {
if (typeof window !== "undefined") localStorage.removeItem(k);
},
};

View File

@@ -0,0 +1,11 @@
import { createWorkspaceStore, registerWorkspaceStore } from "@multica/core/workspace";
import { toast } from "sonner";
import { api } from "./api";
import { webStorage } from "./storage";
export const useWorkspaceStore = createWorkspaceStore(api, {
storage: webStorage,
onError: (msg) => toast.error(msg),
});
registerWorkspaceStore(useWorkspaceStore);

View File

@@ -0,0 +1,26 @@
"use client";
import { WSProvider } from "@multica/core/realtime";
import { useAuthStore } from "./auth";
import { useWorkspaceStore } from "./workspace";
import { webStorage } from "./storage";
import { toast } from "sonner";
const WS_URL = process.env.NEXT_PUBLIC_WS_URL || "ws://localhost:8080/ws";
export function WebWSProvider({ children }: { children: React.ReactNode }) {
return (
<WSProvider
wsUrl={WS_URL}
authStore={useAuthStore}
workspaceStore={useWorkspaceStore}
storage={webStorage}
onToast={(message, type) => {
if (type === "error") toast.error(message);
else toast.info(message);
}}
>
{children}
</WSProvider>
);
}

View File

@@ -1,23 +0,0 @@
import { createLogger } from "@/shared/logger";
import { ApiClient } from "./client";
export { ApiClient } from "./client";
export type { LoginResponse } from "./client";
export { WSClient } from "./ws-client";
const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || "";
export const api = new ApiClient(API_BASE_URL, { logger: createLogger("api") });
// Initialize token from localStorage on load
if (typeof window !== "undefined") {
const token = localStorage.getItem("multica_token");
if (token) {
api.setToken(token);
}
const wsId = localStorage.getItem("multica_workspace_id");
if (wsId) {
api.setWorkspaceId(wsId);
}
}

View File

@@ -1,7 +1,7 @@
import React from "react";
import { vi } from "vitest";
import { render, type RenderOptions } from "@testing-library/react";
import type { User, Workspace, MemberWithUser, Agent } from "@/shared/types";
import type { User, Workspace, MemberWithUser, Agent } from "@multica/core/types";
// Mock user
export const mockUser: User = {
@@ -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

@@ -13,6 +13,7 @@ export default defineConfig({
resolve: {
alias: {
"@": path.resolve(__dirname, "."),
"@core": path.resolve(__dirname, "core"),
},
},
});

421
docs/design.md Normal file
View File

@@ -0,0 +1,421 @@
# Multica Design System
本文档定义 Multica 的视觉语言和交互规范。所有 UI 开发以此为准。
---
## 1. 设计哲学
三条核心原则:
1. **克制即高级。** 默认做减法。每个元素必须有存在的理由——多余的分割线、装饰性图标、"以防万一"的提示文字,都是噪音。留白本身就是设计。
2. **层次靠灰度,颜色是信号。** 界面的主体是中性色。颜色只在需要传递语义时出现(状态、品牌、错误)。如果两个区域在视觉上竞争注意力,解法是让一个退后,而不是两个都加色。
3. **一致性大于个性。** 同类交互必须有相同的视觉反馈。一个 hover 效果在 sidebar、dropdown、table row 里应该"感觉一样"。这种一致性通过 token 而非硬编码实现。
---
## 2. 颜色体系
基于 OKLCh 色彩空间,通过 CSS 变量定义。所有颜色使用 shadcn token**禁止硬编码 Tailwind 色值**(如 `text-gray-500``bg-blue-600`)。
### 2.1 中性色阶梯
界面 90% 的面积由中性色构成。灰度等级即信息层级:
| 角色 | Light Token | Dark Token | 用途 |
|------|-------------|------------|------|
| 背景 | `background` | `background` | 页面底色 |
| 卡片/浮层 | `card` / `popover` | `card` / `popover` | 容器表面 |
| 次级表面 | `muted` / `secondary` | `muted` / `secondary` | hover 背景、标签底色 |
| 边框 | `border` | `border` | 分隔线、输入框边框 |
| 输入框边框 | `input` | `input` | 比 border 略重 |
| 主要文字 | `foreground` | `foreground` | 标题、正文 |
| 次要文字 | `muted-foreground` | `muted-foreground` | 描述、元数据、placeholder |
| 最强调文字 | `primary` | `primary` | 按钮文字(反色)、关键标签 |
**规则:** 同一屏幕内,文字颜色最多使用 3 个层级(`foreground` / `muted-foreground` / 某个语义色)。超过 3 级说明层次设计有问题。
### 2.2 语义色
颜色只用于传递含义,不做装饰:
| Token | 含义 | 使用场景 |
|-------|------|----------|
| `brand` | 品牌标识 | Logo、品牌按钮、极少量强调 |
| `destructive` | 危险/错误 | 删除按钮、表单校验错误、危险操作 |
| `success` | 成功 | 状态标签(完成、已解决) |
| `warning` | 警告 | 注意状态、到期提醒 |
| `info` | 信息 | 提示、链接、次要信息标记 |
| `priority` | 优先级 | 高优先级标签 |
**规则:**
- 语义色主要用于小面积元素badge、icon、border。大面积着色用该色的 10%-20% 透明度变体(如 `bg-destructive/10`)。
- 每屏同时出现的语义色不宜超过 2-3 种。如果一个界面同时有红黄绿蓝紫,说明信息密度过高,需要重新组织。
### 2.3 暗色模式
暗色模式不是简单的反转。它是独立设计的一套配色:
- 背景使用深灰(`oklch(0.18 ...)`),不是纯黑——纯黑在 LCD 屏上刺眼。
- 边框使用 `oklch(1 0 0 / 10%)`(白色 10% 透明度),比 light 模式更微妙。
- 语义色在 dark 模式下适当提亮(如 `success``0.55` 提到 `0.65`),保证对比度。
- 所有 UI 变更必须同时在两个模式下验证。
---
## 3. 字体规范
### 3.1 字体家族
| 角色 | 字体 | 用途 |
|------|------|------|
| 正文/UI | Geist Sans (`--font-sans`) | 所有界面文字的默认字体 |
| 代码/数据 | Geist Mono (`--font-mono`) | 代码块、ID、时间戳、等宽数据 |
| 标题 | `--font-heading`= `--font-sans` | 页面标题、区块标题 |
### 3.2 字号纪律
**整个项目只使用 3 个核心字号 + 1 个特殊字号:**
| Tailwind Class | 大小 | 角色 | 使用场景 |
|----------------|------|------|----------|
| `text-base` (16px) | 正文 | 页面标题、主要内容 | 页面标题、编辑器正文、空状态说明 |
| `text-sm` (14px) | 默认 | 界面的主力字号 | 菜单项、按钮、表单、列表项、正文 |
| `text-xs` (12px) | 辅助 | 元数据、标签 | badge 文字、时间戳、状态栏、次要信息 |
| `text-[0.8rem]` | 过渡 | 仅限 sm 按钮 | shadcn button size="sm" 专用 |
**禁止:**
- 使用 `text-lg``text-xl``text-2xl` 等——任务管理工具追求信息密度,不需要大字号。
- 使用任意像素值如 `text-[11px]``text-[13px]`——坚持 Tailwind 内置 scale。
- 在同一个区块里混用超过 2 个字号。如果需要第 3 个字号来区分层次,先试试用 `font-medium` vs `font-normal``text-muted-foreground` 来解决。
### 3.3 字重
只使用两个:
| 字重 | 用途 |
|------|------|
| `font-normal` (400) | 正文、描述、大部分文字 |
| `font-medium` (500) | 标签、按钮、导航项、标题、选中状态 |
**禁止** `font-bold` / `font-semibold`——它们在 Geist 字体下显得突兀,破坏界面的"轻"感。如果需要更强的强调,用更大的字号或 `foreground` 色值,而不是加粗。
---
## 4. 间距体系
基于 Tailwind 的 4px 基础网格。间距传递信息——它不只是"好看",而是告诉用户"什么属于什么"。
### 4.1 间距语义
| 间距 | Tailwind | 含义 |
|------|----------|------|
| 4px | `gap-1` / `p-1` | **紧密关联** — icon 与文字、label 与值 |
| 6px | `gap-1.5` / `p-1.5` | **组件内部** — 按钮内部 padding、列表项间距 |
| 8px | `gap-2` / `p-2` | **同组不同项** — 表单字段间、列表项间 |
| 12px | `gap-3` / `p-3` | **小节内** — 卡片内部 padding |
| 16px | `gap-4` / `p-4` | **组间分隔** — 不同区块之间 |
| 24px | `gap-6` / `p-6` | **大节分隔** — 页面主要区域间 |
**规则:如果需要分割线,说明间距不够。** 优先通过增大间距来分隔内容,而不是加 `<Separator />`。分割线应该是最后手段。
### 4.2 容器策略(按优先级排序)
当需要在视觉上分隔两个区域时:
1. **仅间距** — 增大两个区域的间距(首选)
2. **单条分割线** — 一根细线 `border-border`
3. **背景色变化** — 一个区域用 `bg-muted``bg-card`
4. **完整卡片** — border + radius + padding最重手段
用最轻的工具完成分隔。
---
## 5. 交互状态
这是设计一致性的核心。每种状态必须在所有组件中表现一致。
### 5.1 状态层级概览
```
默认 (rest) → hover → active/pressed → selected/active → focused → disabled
```
### 5.2 Hover 状态
Hover 是"我注意到你了",视觉变化应该轻微、即时:
| 元素类型 | Hover 效果 | Token |
|----------|-----------|-------|
| 列表项/菜单项 | 背景变浅灰 | `hover:bg-muted` |
| Ghost 按钮 | 背景变浅灰 + 文字变前景色 | `hover:bg-muted hover:text-foreground` |
| 次要按钮 | 背景加深 20% | `hover:bg-secondary/80` |
| 主按钮 | 背景加深 20% | `hover:bg-primary/80` |
| 文字链接 | 下划线出现 | `hover:underline` |
| Tab 标签 | 文字从次要变主要 | `hover:text-foreground`(从 `text-muted-foreground` |
| 图标按钮 | 背景变浅灰 | `hover:bg-muted` |
| 危险按钮 | 背景透明度加深 | `hover:bg-destructive/20` |
**规则:**
- hover 时不改变尺寸(无 `scale`)、不加阴影(无 `shadow`)。
- hover 的背景色永远比 selected/active 更淡。这样用户能区分"悬停"和"已选中"。
- 所有 hover 使用 `transition-colors``transition-all`,时长由 Tailwind 默认值150ms处理不需要自定义。
### 5.3 Active / Selected 状态
Active 是"我已经被选中了",视觉比 hover 更重:
| 元素类型 | Active 效果 | Token |
|----------|------------|-------|
| Sidebar 菜单项 | 背景 + 文字加重 + font-medium | `data-active:bg-sidebar-accent data-active:font-medium` |
| Tab | 下方指示条 + 文字变前景色 + font-medium | `data-[state=active]:text-foreground` |
| 列表选中行 | 背景加深 | `bg-muted``bg-accent` |
| Toggle | 背景反色 | `data-[state=on]:bg-primary data-[state=on]:text-primary-foreground` |
**关键区分:** Hover = `bg-muted`Active = `bg-muted` + `font-medium` + `text-foreground`。Active 始终比 hover 多一个视觉维度(字重或颜色变化),而不仅仅是背景更深。
### 5.3.1 Active 不被 Hover 覆盖
这是最容易出 bug 的地方:用户 hover 到一个已选中的项目上hover 样式覆盖了 active 样式,导致选中态"闪回"普通 hover 态,视觉上像取消了选中。
**原则Active 状态在任何时候都必须保持可辨识——包括被 hover 时。**
实现方式:
**方式一Active 使用 hover 不涉及的维度**
如果 hover 只改背景,那 active 用字重 + 文字颜色来区分。即使 hover 背景叠上去,字重和颜色不变,用户仍能识别"这个是选中的"
```
// ✅ hover 只管背景active 靠字重和颜色
hover:bg-muted // hover浅灰背景
data-active:font-medium data-active:text-foreground // active字重+颜色hover 不会覆盖)
```
**方式二Active + Hover 组合样式**
当 active 也用了背景色时,需要显式定义 "active 且 hover" 的复合状态,确保 hover 不会把 active 的背景拉回低层级:
```tsx
// ✅ 显式处理 active+hover 复合态
cn(
"hover:bg-muted/50", // 普通 hover
"data-active:bg-muted data-active:text-foreground", // active
"data-active:hover:bg-muted" // active+hover保持 active 背景,不降级
)
```
```tsx
// ❌ 反例hover 覆盖 active
cn(
"hover:bg-muted/50", // hover 背景比 active 更淡
"data-active:bg-muted", // active 背景
// 没有处理复合态 → hover 到 active 项时背景从 muted 闪回 muted/50
)
```
**方式三CSS 选择器优先级**
利用 `:not()` 让 hover 只作用于非 active 的元素:
```
// ✅ hover 不作用于 active 项
[data-active]:bg-muted [data-active]:text-foreground
not-data-active:hover:bg-muted/50
```
**检查方法:** 写完任何带 hover + active 状态的组件后,必须手动验证——先点击选中一项,然后鼠标移到该项上再移开,确认视觉不会"闪烁"或"降级"。
### 5.4 Pressed 状态
物理反馈感——按下按钮时有微小的位移:
```
active:not-aria-[haspopup]:translate-y-px
```
这个 1px 的下移在 shadcn button 上已全局配置。对于触发弹出菜单的按钮不添加(因为弹出即松开,位移会闪烁)。
### 5.5 Focus 状态
Focus 为键盘导航服务。所有可交互元素统一使用:
```
focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50
```
- 使用 `focus-visible`(非 `focus`),避免鼠标点击时出现 focus ring。
- ring 颜色使用 `ring` token中灰不跟组件颜色走——保持全局一致。
### 5.6 Disabled 状态
```
disabled:pointer-events-none disabled:opacity-50
```
简单统一。不需要为每个组件定制 disabled 样式。
### 5.7 Error / Invalid 状态
```
aria-invalid:border-destructive aria-invalid:ring-destructive/20
```
- 使用 `aria-invalid` 属性触发,与表单校验库自然对接。
- 只改变边框和 ring不改背景。错误信息用内联文字展示不用 toast 或 alert banner。
---
## 6. 图标规范
### 6.1 图标库
统一使用 **Lucide React**`lucide-react`)。
禁止混用其他图标库Heroicons、Phosphor 等),也禁止自制 SVG 图标(除非 Lucide 确实没有合适的)。
### 6.2 图标尺寸
图标尺寸与组件尺寸绑定:
| 组件尺寸 | 图标尺寸 | 示例 |
|----------|---------|------|
| xsh-6 | `size-3` (12px) | 紧凑按钮、badge 内图标 |
| smh-7 | `size-3.5` (14px) | 小按钮、紧凑列表 |
| defaulth-8 | `size-4` (16px) | 标准按钮、菜单项、表格操作 |
| lgh-9 | `size-4` (16px) | 大按钮(图标不需要更大) |
**规则:**
- 独立装饰性图标(如空状态插图)最大 `size-8` (32px)。
- 所有图标默认继承父元素文字颜色。需要弱化时用 `text-muted-foreground`
- 图标与文字的间距:`gap-1`xs/ `gap-1.5`sm/default/ `gap-2`(宽松排列)。
### 6.3 图标颜色
- **导航/操作图标:** `text-muted-foreground`hover 时跟随文字变为 `text-foreground`
- **状态图标:** 使用对应语义色(如 `text-success``text-destructive`
- **Active 状态图标:** `text-foreground`
---
## 7. 圆角规范
基于 `--radius: 0.625rem`10px的动态 scale
| Token | 值 | 用途 |
|-------|-----|------|
| `rounded-sm` | 6px | Checkbox、小标签 |
| `rounded-md` | 8px | 输入框、小按钮、dropdown item |
| `rounded-lg` | 10px | 标准按钮、卡片、dialog |
| `rounded-xl` | 14px | 大卡片、sheet |
| `rounded-full` | 999px | 头像、pill badge |
**禁止** 硬编码像素值如 `rounded-[6px]`(除非 shadcn 组件内部需要响应式计算如 `rounded-[min(var(--radius-md),12px)]`)。
---
## 8. 动效规范
### 8.1 原则
- **快速、克制。** 动效是为了帮助用户理解变化,不是展示技术。
- **淡入淡出优先。** 元素出现/消失优先用 opacity 过渡,而不是滑动。
- **无弹跳。** 不使用 spring / bounce 缓动。缓动曲线统一用 `ease-out`
### 8.2 时长
| 场景 | 时长 | 示例 |
|------|------|------|
| 颜色/透明度变化 | 150ms | hover 背景变化、文字颜色变化 |
| 展开/收起 | 200ms | accordion、collapsible |
| 弹层出入 | 150-200ms | dialog、dropdown、popover |
| 页面切换 | 无动效 | 路由跳转无过渡动画 |
### 8.3 使用的 transition
| Tailwind Class | 用途 |
|----------------|------|
| `transition-colors` | 纯颜色变化hover、active— 首选 |
| `transition-all` | 多属性同时变化 |
| `transition-opacity` | 元素淡入淡出 |
| `transition-transform` | 位移动画pressed 效果) |
---
## 9. 组件使用规范
### 9.1 shadcn 优先
所有 UI 组件优先使用已安装的 shadcn 组件55 个可用)。新增 UI 需求时:
1. 先查 shadcn 是否有对应组件 → `npx shadcn add <component>`
2. 需要变体 → 用 CVA 在现有组件上扩展
3. 确实没有 → 自建组件,但必须遵循本规范的 token / 交互状态
### 9.2 按钮层级
从最强调到最弱:
| 变体 | 视觉重量 | 使用场景 |
|------|---------|----------|
| `default`primary | ██████ | 页面主操作(每屏最多 1 个) |
| `outline` | ████░░ | 次要操作 |
| `secondary` | ███░░░ | 辅助操作、工具栏 |
| `ghost` | █░░░░░ | 图标按钮、内联操作、紧凑工具栏 |
| `destructive` | ████░░ | 删除、危险操作(红色调) |
| `link` | █░░░░░ | 内联文字链接 |
**规则:** 一个视图里的 primary 按钮最多 1 个。其他都用更弱的变体。如果有多个同等重要的操作,全部用 `outline``secondary`
### 9.3 Dropdown / Popover
- 内容宽度使用 `w-auto`**禁止** 固定宽度如 `w-52``w-56`(会导致文字换行)。
- 菜单项统一 `text-sm`,图标 `size-4`
- 选中项通过 checkmark 图标或左侧指示条标记,不改变背景色。
- 危险操作项使用 `text-destructive`,放在最底部,上方用分割线隔开。
### 9.4 表单输入
- 输入框统一使用 `border-input` 边框focus 时 `border-ring` + ring。
- Label 使用 `text-sm font-medium`
- 描述/帮助文字使用 `text-xs text-muted-foreground`
- 错误信息使用 `text-xs text-destructive`,放在输入框正下方。
---
## 10. 反模式清单
以下做法**禁止**出现在代码中:
| 禁止 | 原因 | 替代 |
|------|------|------|
| 硬编码颜色 `text-red-500``bg-gray-100` | 破坏主题一致性 | 使用 token`text-destructive``bg-muted` |
| 任意像素 `text-[11px]``w-[137px]` | 脱离设计系统 | 使用 Tailwind 内置 scale |
| `font-bold` / `font-semibold` | 过重,破坏轻感 | `font-medium` + `text-foreground` |
| `text-lg` / `text-xl` / `text-2xl` | 信息密度型工具不需要大字 | `text-base` 已是最大 |
| `shadow-sm` / `shadow-md` / `shadow-lg` | 拟物风格,与扁平设计冲突 | 使用 `border` 分隔层级 |
| hover 时 `scale-105` | 突兀,与克制风格冲突 | `hover:bg-muted` |
| 多色 gradient 背景 | 装饰性,分散注意力 | 纯色 token |
| Skeleton loading | 与简洁风格不匹配 | Spinner`Loader2Icon animate-spin`)或内联 loading 文字 |
| Toast 做操作确认 | 转瞬即逝,用户容易错过 | 内联状态文字或 Sonner 仅用于错误/重要提示 |
| 固定宽度 dropdown `w-52` | 文字换行不可控 | `w-auto` |
| 纯黑背景 `#000` / `oklch(0 0 0)` | LCD 上刺眼 | Dark 模式用深灰 `background` token |
---
## 11. 检查清单
在提交任何 UI 变更前,过一遍:
- [ ] 所有颜色是否使用 token有没有硬编码
- [ ] 字号是否只在 `text-xs` / `text-sm` / `text-base` 范围内?
- [ ] 字重是否只用了 `font-normal``font-medium`
- [ ] Hover 状态是否比 active 状态更淡?
- [ ] Active 项被 hover 时active 样式是否仍然可辨识(不被 hover 覆盖)?
- [ ] 图标尺寸是否与组件尺寸匹配?
- [ ] 间距是否使用 Tailwind 内置 scale无任意值
- [ ] Dark 模式下是否正常?
- [ ] 有没有不必要的分割线(可以用间距替代)?
- [ ] Dropdown / Popover 是否 `w-auto`
- [ ] 一个视图里 primary 按钮是否不超过 1 个?

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,511 @@
# Board DnD Rewrite — dnd-kit Multi-Container Sortable
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
**Goal:** Rewrite the Kanban board drag-and-drop to use dnd-kit's multi-container sortable pattern correctly — onDragOver for live cross-column movement, local state during drag, insertion indicators, and smooth animations.
**Architecture:** Replace the current "TQ-cache-driven + pendingMove patch" with a "local-state-driven during drag, TQ sync on drop" model. During drag, a local `columns` state (Record<IssueStatus, string[]>) controls which IDs each SortableContext sees. onDragOver moves IDs between columns in real-time. onDragEnd computes final position and fires the mutation. Between drags, local state follows TQ data via useEffect.
**Tech Stack:** @dnd-kit/core ^6.3.1, @dnd-kit/sortable ^10.0.0, @dnd-kit/utilities ^3.2.2, TanStack Query, React useState
---
## Current State (files to modify)
| File | Current Role | Change |
|------|-------------|--------|
| `features/issues/components/board-view.tsx` | DndContext + onDragEnd only + pendingMove | **Rewrite**: local columns state, onDragOver, onDragEnd, improved DragOverlay |
| `features/issues/components/board-column.tsx` | Receives Issue[], sorts internally, useDroppable | **Rewrite**: receives sorted Issue[] from parent, no internal sorting, insertion indicator |
| `features/issues/components/board-card.tsx` | useSortable with defaults | **Modify**: custom animateLayoutChanges |
| `features/issues/components/issues-page.tsx` | handleMoveIssue callback | **Minor**: adjust callback signature |
Files NOT changed: `mutations.ts`, `ws-updaters.ts`, `use-realtime-sync.ts`, `view-store.ts`, `sort.ts`
---
## Task 1: Rewrite board-view.tsx — Local State + onDragOver + onDragEnd
**Files:**
- Rewrite: `apps/web/features/issues/components/board-view.tsx`
This is the core task. The entire DnD orchestration logic changes.
### Data Model
```typescript
// Local state: maps status → ordered array of issue IDs
// This is the ONLY source of truth for card positions during drag
type Columns = Record<IssueStatus, string[]>;
```
### Step 1: Replace pendingMove with local columns state
Remove `pendingMove` + `displayIssues` + the clearing useEffect. Replace with:
```typescript
// Build columns from TQ issues + view sort settings
function buildColumns(
issues: Issue[],
visibleStatuses: IssueStatus[],
sortBy: SortField,
sortDirection: SortDirection,
): Columns {
const cols: Columns = {} as Columns;
for (const status of visibleStatuses) {
const sorted = sortIssues(
issues.filter((i) => i.status === status),
sortBy,
sortDirection,
);
cols[status] = sorted.map((i) => i.id);
}
return cols;
}
```
In the component:
```typescript
const sortBy = useViewStore((s) => s.sortBy);
const sortDirection = useViewStore((s) => s.sortDirection);
// Local columns state — follows TQ between drags, local during drag
const [columns, setColumns] = useState<Columns>(() =>
buildColumns(issues, visibleStatuses, sortBy, sortDirection)
);
const isDragging = useRef(false);
// Sync from TQ when NOT dragging
useEffect(() => {
if (!isDragging.current) {
setColumns(buildColumns(issues, visibleStatuses, sortBy, sortDirection));
}
}, [issues, visibleStatuses, sortBy, sortDirection]);
```
`issueMap` for O(1) lookup (needed by BoardColumn to get Issue objects from IDs):
```typescript
const issueMap = useMemo(() => {
const map = new Map<string, Issue>();
for (const issue of issues) map.set(issue.id, issue);
return map;
}, [issues]);
```
### Step 2: Implement findColumn helper
```typescript
/** Find which column (status) contains a given ID (issue or column). */
function findColumn(columns: Columns, id: string, visibleStatuses: IssueStatus[]): IssueStatus | null {
// Is it a column ID itself?
if (visibleStatuses.includes(id as IssueStatus)) return id as IssueStatus;
// Search columns for the item
for (const [status, ids] of Object.entries(columns)) {
if (ids.includes(id)) return status as IssueStatus;
}
return null;
}
```
### Step 3: Implement onDragStart
```typescript
const handleDragStart = useCallback((event: DragStartEvent) => {
isDragging.current = true;
const issue = issueMap.get(event.active.id as string) ?? null;
setActiveIssue(issue);
}, [issueMap]);
```
### Step 4: Implement onDragOver — the key missing piece
This fires continuously during drag. When the pointer crosses into a different column or hovers over a different card, we move the dragged ID in local state. This makes SortableContext aware of the new item → cards shift to make room.
```typescript
const handleDragOver = useCallback((event: DragOverEvent) => {
const { active, over } = event;
if (!over) return;
const activeId = active.id as string;
const overId = over.id as string;
const activeCol = findColumn(columns, activeId, visibleStatuses);
const overCol = findColumn(columns, overId, visibleStatuses);
if (!activeCol || !overCol || activeCol === overCol) return;
// Cross-column move: remove from old column, insert into new column
setColumns((prev) => {
const oldIds = prev[activeCol]!.filter((id) => id !== activeId);
const newIds = [...prev[overCol]!];
// Insert position: if over a card, insert at that index; if over column, append
const overIndex = newIds.indexOf(overId);
const insertIndex = overIndex >= 0 ? overIndex : newIds.length;
newIds.splice(insertIndex, 0, activeId);
return { ...prev, [activeCol]: oldIds, [overCol]: newIds };
});
}, [columns, visibleStatuses]);
```
### Step 5: Implement onDragEnd — persist to server
```typescript
const handleDragEnd = useCallback((event: DragEndEvent) => {
const { active, over } = event;
isDragging.current = false;
setActiveIssue(null);
if (!over) {
// Cancelled — reset to TQ state
setColumns(buildColumns(issues, visibleStatuses, sortBy, sortDirection));
return;
}
const activeId = active.id as string;
const overId = over.id as string;
const activeCol = findColumn(columns, activeId, visibleStatuses);
const overCol = findColumn(columns, overId, visibleStatuses);
if (!activeCol || !overCol) return;
// Same column reorder
if (activeCol === overCol) {
const ids = columns[activeCol]!;
const oldIndex = ids.indexOf(activeId);
const newIndex = ids.indexOf(overId);
if (oldIndex !== newIndex) {
const reordered = arrayMove(ids, oldIndex, newIndex);
setColumns((prev) => ({ ...prev, [activeCol]: reordered }));
}
}
// Compute final position from the local column order
const finalCol = findColumn(columns, activeId, visibleStatuses);
if (!finalCol) return;
// After potential same-col reorder, re-read columns
// (for same-col we just did setColumns above, but it's async;
// however we can compute from the intended final order)
let finalIds: string[];
if (activeCol === overCol) {
const ids = columns[activeCol]!;
const oldIndex = ids.indexOf(activeId);
const newIndex = ids.indexOf(overId);
finalIds = oldIndex !== newIndex ? arrayMove(ids, oldIndex, newIndex) : ids;
} else {
finalIds = columns[finalCol]!;
}
const newPosition = computePosition(finalIds, activeId, issues);
const currentIssue = issueMap.get(activeId);
// Skip if nothing changed
if (currentIssue && currentIssue.status === finalCol && currentIssue.position === newPosition) return;
onMoveIssue(activeId, finalCol, newPosition);
}, [columns, issues, visibleStatuses, sortBy, sortDirection, issueMap, onMoveIssue]);
```
### Step 6: Update computePosition to work with ID arrays
The current `computePosition` takes `Issue[]` and a target index. Rewrite to take `string[]` (IDs) + the active ID + the issue map:
```typescript
/** Compute a float position for `activeId` based on its neighbors in `ids`. */
function computePosition(ids: string[], activeId: string, allIssues: Issue[]): number {
const idx = ids.indexOf(activeId);
if (idx === -1) return 0;
const getPos = (id: string) => allIssues.find((i) => i.id === id)?.position ?? 0;
if (ids.length === 1) return 0;
if (idx === 0) return getPos(ids[1]!) - 1;
if (idx === ids.length - 1) return getPos(ids[idx - 1]!) + 1;
return (getPos(ids[idx - 1]!) + getPos(ids[idx + 1]!)) / 2;
}
```
### Step 7: Update DragOverlay styling
```typescript
<DragOverlay dropAnimation={null}>
{activeIssue ? (
<div className="w-[280px] rotate-2 scale-105 cursor-grabbing opacity-90 shadow-lg shadow-black/10">
<BoardCardContent issue={activeIssue} />
</div>
) : null}
</DragOverlay>
```
Key change: `dropAnimation={null}` prevents the overlay from animating back to origin on drop — the card is already in the right position via local state.
### Step 8: Wire it all together
Pass `columns` + `issueMap` to `BoardColumn` instead of `issues`:
```tsx
{visibleStatuses.map((status) => (
<BoardColumn
key={status}
status={status}
issueIds={columns[status] ?? []}
issueMap={issueMap}
/>
))}
```
### Step 9: Run typecheck
Run: `pnpm typecheck`
Expected: May have errors in board-column.tsx (prop changes) — that's Task 2.
### Step 10: Commit
```bash
git add apps/web/features/issues/components/board-view.tsx
git commit -m "refactor(board): rewrite DnD with local state + onDragOver for live cross-column sorting"
```
---
## Task 2: Rewrite board-column.tsx — Receive IDs + issueMap, Add Insertion Indicator
**Files:**
- Rewrite: `apps/web/features/issues/components/board-column.tsx`
### Step 1: Change props from `issues: Issue[]` to `issueIds: string[]` + `issueMap: Map<string, Issue>`
The column no longer does its own sorting — the parent provides IDs in the correct order. The column just resolves IDs to Issue objects and renders them.
```typescript
export function BoardColumn({
status,
issueIds,
issueMap,
}: {
status: IssueStatus;
issueIds: string[];
issueMap: Map<string, Issue>;
}) {
const cfg = STATUS_CONFIG[status];
const { setNodeRef, isOver } = useDroppable({ id: status });
const viewStoreApi = useViewStoreApi();
// Resolve IDs to Issue objects (IDs are already sorted by parent)
const resolvedIssues = useMemo(
() => issueIds.flatMap((id) => {
const issue = issueMap.get(id);
return issue ? [issue] : [];
}),
[issueIds, issueMap],
);
return (
<div className={`flex w-[280px] shrink-0 flex-col rounded-xl ${cfg.columnBg} p-2`}>
<div className="mb-2 flex items-center justify-between px-1.5">
<div className="flex items-center gap-2">
<span className={`inline-flex items-center gap-1.5 rounded px-2 py-0.5 text-xs font-semibold ${cfg.badgeBg} ${cfg.badgeText}`}>
<StatusIcon status={status} className="h-3 w-3" inheritColor />
{cfg.label}
</span>
<span className="text-xs text-muted-foreground">
{issueIds.length}
</span>
</div>
{/* Right: add + menu — keep as-is */}
<div className="flex items-center gap-1">
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button variant="ghost" size="icon-sm" className="rounded-full text-muted-foreground">
<MoreHorizontal className="size-3.5" />
</Button>
}
/>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => viewStoreApi.getState().hideStatus(status)}>
<EyeOff className="size-3.5" />
Hide column
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<Tooltip>
<TooltipTrigger
render={
<Button
variant="ghost"
size="icon-sm"
className="rounded-full text-muted-foreground"
onClick={() => useModalStore.getState().open("create-issue", { status })}
>
<Plus className="size-3.5" />
</Button>
}
/>
<TooltipContent>Add issue</TooltipContent>
</Tooltip>
</div>
</div>
<div
ref={setNodeRef}
className={`min-h-[200px] flex-1 space-y-2 overflow-y-auto rounded-lg p-1 transition-colors ${
isOver ? "bg-accent/60" : ""
}`}
>
<SortableContext items={issueIds} strategy={verticalListSortingStrategy}>
{resolvedIssues.map((issue) => (
<DraggableBoardCard key={issue.id} issue={issue} />
))}
</SortableContext>
{issueIds.length === 0 && (
<p className="py-8 text-center text-xs text-muted-foreground">
No issues
</p>
)}
</div>
</div>
);
}
```
Key changes:
- No more `useViewStore` for sort — parent handles sorting
- No more internal `sortIssues` call
- Uses `issueIds` for SortableContext (already in correct order)
- Count shows `issueIds.length` instead of `issues.length`
### Step 2: Run typecheck
Run: `pnpm typecheck`
Expected: PASS (or errors in issues-page.tsx — Task 4)
### Step 3: Commit
```bash
git add apps/web/features/issues/components/board-column.tsx
git commit -m "refactor(board): BoardColumn receives sorted IDs from parent, no internal sorting"
```
---
## Task 3: Modify board-card.tsx — Custom animateLayoutChanges
**Files:**
- Modify: `apps/web/features/issues/components/board-card.tsx`
### Step 1: Add custom animateLayoutChanges
When a card is dragged across containers, dnd-kit triggers a layout animation on the "entering" card. The default `defaultAnimateLayoutChanges` animates this, causing a jarring jump. We disable animation for the frame when `wasDragging` is true (the card just landed in a new container).
```typescript
import { useSortable, defaultAnimateLayoutChanges } from "@dnd-kit/sortable";
import type { AnimateLayoutChanges } from "@dnd-kit/sortable";
const animateLayoutChanges: AnimateLayoutChanges = (args) => {
const { isSorting, wasDragging } = args;
if (isSorting || wasDragging) return false;
return defaultAnimateLayoutChanges(args);
};
```
Update useSortable call:
```typescript
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({
id: issue.id,
data: { status: issue.status },
animateLayoutChanges,
});
```
### Step 2: Run typecheck
Run: `pnpm typecheck`
Expected: PASS
### Step 3: Commit
```bash
git add apps/web/features/issues/components/board-card.tsx
git commit -m "refactor(board): custom animateLayoutChanges to prevent jarring cross-column animation"
```
---
## Task 4: Adjust issues-page.tsx — Minor Callback Cleanup
**Files:**
- Modify: `apps/web/features/issues/components/issues-page.tsx`
### Step 1: Update handleMoveIssue
The callback shape stays the same (`issueId, newStatus, newPosition`), but the auto-switch-to-manual-sort logic should move into board-view or stay here. Keep it here for now since it's a view-level concern.
No functional change needed — the `onMoveIssue` prop signature is unchanged. Just verify that `BoardView`'s new props are correct:
```tsx
<BoardView
issues={issues}
allIssues={scopedIssues}
visibleStatuses={visibleStatuses}
hiddenStatuses={hiddenStatuses}
onMoveIssue={handleMoveIssue}
/>
```
`BoardView` still receives `issues` (filtered+scoped from TQ) and `onMoveIssue`. The internal state management changes are encapsulated.
### Step 2: Run full typecheck + test
Run: `pnpm typecheck && pnpm test`
Expected: PASS
### Step 3: Commit
```bash
git add apps/web/features/issues/components/issues-page.tsx
git commit -m "refactor(board): verify issues-page props match new BoardView interface"
```
---
## Task 5: Manual QA Checklist
After all code changes, verify these scenarios in the browser:
1. **Same-column reorder**: Drag a card up/down within one column → cards shift to make room during drag → drop → position persists after refresh
2. **Cross-column move**: Drag card from Todo to In Progress → card appears in target column DURING drag → target column cards shift → drop → status + position persist
3. **Drop on empty column**: Drag card to an empty column → card lands there
4. **Cancel drag**: Start dragging, press Escape → card returns to original position, no mutation fired
5. **Rapid sequential drags**: Drag card A, drop, immediately drag card B → no flicker or stale state
6. **WebSocket update during drag**: Have another user change an issue → board updates correctly after drag ends (not during)
7. **Sort mode switch**: Drag should auto-switch to "Manual" sort → verify after drag, sort dropdown shows "Manual"
8. **DragOverlay**: Dragged card should have visible shadow, slight rotation, slight scale up
9. **Hidden columns panel**: Still shows correct counts, "Show column" still works
---
## Summary of Architecture Change
```
BEFORE (broken):
TQ cache → issues prop → displayIssues (with pendingMove patch) → BoardColumn sorts internally
onDragEnd → pendingMove + mutate → TQ updates → useEffect clears pendingMove
Problem: dual optimistic update, fire-and-forget cancelQueries race, no onDragOver
AFTER (correct):
TQ cache → issues prop → buildColumns() → local columns state (when not dragging)
onDragStart → isDragging=true, freeze local state
onDragOver → move IDs between columns in local state → SortableContext sees new items → cards shift
onDragEnd → compute position from local order → mutate → isDragging=false → TQ catches up → local follows
Problem: none — single source of truth during drag (local), single source of truth between drags (TQ)
```

View File

@@ -0,0 +1,227 @@
# Drag & Drop Upload Enhancement — Revised Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
**Goal:** Clean drag-and-drop upload with visual feedback. Images render inline, non-images show as file cards. No file type restrictions (match Linear). No separate attachment section (URLs live in markdown).
**Architecture:** Frontend-only. Images use existing `![](url)` markdown. Non-images use `[name](url)` markdown, rendered as a styled card via Tiptap NodeView when URL matches our CDN. Backend unchanged.
**Tech Stack:** Tiptap ProseMirror, React, Tailwind CSS, shadcn tokens
---
## What We Keep (from previous work)
- **Drag overlay** — `content-editor.tsx` drag handlers + `content-editor.css` overlay styles
- **Image upload flow** — blob preview → upload → replace with real URL (existing `file-upload.ts`)
- **Non-image upload placeholder** — `⏳ Uploading filename...` → replaced with link (existing `file-upload.ts`)
- **`MAX_FILE_SIZE`** — 100MB limit
## What We Remove (redundant)
| File | What to remove |
|------|----------------|
| `attachment-section.tsx` | **Delete entire file** |
| `issue-detail.tsx` | attachment query, delete mutation, handleImageRemoved, AttachmentSection JSX, onImageRemoved prop, all `["attachments"]` cache invalidation, onUploadSuccess on CommentInput, `api` import (if unused after) |
| `content-editor.tsx` | `onImageRemoved` prop, `onImageRemovedRef` |
| `extensions/index.ts` | `onImageRemovedRef` option |
| `extensions/file-upload.ts` | `collectImageSrcs`, `imageRemovalTracker` plugin, `isAllowedFileType` check + import, `toast` import |
| `shared/constants/upload.ts` | Everything except `MAX_FILE_SIZE` — remove `ALLOWED_MIME_PATTERNS`, `FILE_INPUT_ACCEPT`, `EXTENSION_MIME_MAP`, `isAllowedFileType`, `matchesMimePattern` |
| `shared/constants/__tests__/upload.test.ts` | All tests except MAX_FILE_SIZE |
| `shared/hooks/use-file-upload.ts` | `isAllowedFileType` import + check |
| `components/common/file-upload-button.tsx` | `FILE_INPUT_ACCEPT` import + `accept` attribute |
| `comment-input.tsx` | `onUploadSuccess` prop |
## What We Add (new)
**File Card Node** — a Tiptap custom node that renders `[name](url)` as a styled card when the URL matches our CDN (`multica-static.copilothub.ai` or S3 bucket domain).
```
Editor view: ┌──────────────────────────┐
│ 📄 report.pdf ⬇ │
└──────────────────────────┘
Markdown storage: [report.pdf](https://multica-static.copilothub.ai/xxx.pdf)
```
- Only for non-image CDN URLs (images stay as `![](url)`)
- Regular external links (github.com, etc.) stay as normal links
- Card shows: file type icon + filename + download button
- Readonly mode shows the same card
---
## Task 1: Remove Redundant Code
**Files to modify:**
- Delete: `apps/web/features/issues/components/attachment-section.tsx`
- Modify: `apps/web/features/issues/components/issue-detail.tsx`
- Modify: `apps/web/features/issues/components/comment-input.tsx`
- Modify: `apps/web/features/editor/content-editor.tsx`
- Modify: `apps/web/features/editor/extensions/index.ts`
- Modify: `apps/web/features/editor/extensions/file-upload.ts`
- Modify: `apps/web/shared/constants/upload.ts`
- Modify: `apps/web/shared/constants/__tests__/upload.test.ts`
- Modify: `apps/web/shared/hooks/use-file-upload.ts`
- Modify: `apps/web/components/common/file-upload-button.tsx`
**What to do:**
1. Delete `attachment-section.tsx`
2. `issue-detail.tsx`: Remove AttachmentSection import, attachment useQuery, deleteAttachment useMutation, handleImageRemoved, onImageRemoved prop, all `["attachments"]` invalidation in handleDescriptionUpload (revert to simple `uploadWithToast` call), remove onUploadSuccess from CommentInput
3. `comment-input.tsx`: Remove `onUploadSuccess` prop
4. `content-editor.tsx`: Remove `onImageRemoved` prop + ref + wiring
5. `extensions/index.ts`: Remove `onImageRemovedRef` from interface + call
6. `extensions/file-upload.ts`: Remove `collectImageSrcs`, `imageRemovalTracker` plugin, `onImageRemovedRef` param, `isAllowedFileType` import + check, `toast` import (keep `toast` if still used — check)
7. `shared/constants/upload.ts`: Keep only `MAX_FILE_SIZE`. Delete everything else.
8. `shared/constants/__tests__/upload.test.ts`: Keep only `MAX_FILE_SIZE` test
9. `shared/hooks/use-file-upload.ts`: Remove `isAllowedFileType` import + check. Import `MAX_FILE_SIZE` stays.
10. `file-upload-button.tsx`: Remove `FILE_INPUT_ACCEPT` import + `accept` attribute
**Verification:**
```bash
pnpm typecheck && pnpm test
```
**Commit:** `refactor(upload): remove attachment section and file type whitelist`
---
## Task 2: File Card Tiptap Node
**Files:**
- Create: `apps/web/features/editor/extensions/file-card.ts`
- Create: `apps/web/features/editor/extensions/file-card-view.tsx`
- Modify: `apps/web/features/editor/extensions/index.ts`
- Modify: `apps/web/features/editor/content-editor.css`
**Design:**
The node intercepts markdown links `[name](url)` where URL matches our CDN, and renders them as a card NodeView.
```typescript
// Detection: URL starts with CDN domain or known S3 bucket pattern
function isCdnFileUrl(url: string): boolean {
try {
const u = new URL(url);
return u.hostname.endsWith('.copilothub.ai') || u.hostname.endsWith('.amazonaws.com');
} catch {
return false;
}
}
// Only match non-image files (images stay as ![](url))
function isFileCardLink(url: string): boolean {
return isCdnFileUrl(url) && !isImageUrl(url);
}
```
**Node spec:**
- Node name: `fileCard`
- Attrs: `href`, `filename`
- Markdown serialize: `[filename](href)`
- Markdown parse: detect `[text](cdnUrl)` where cdnUrl is non-image CDN link
- NodeView: React component with file icon + name + download button
**Card UI (React NodeView):**
```tsx
<div className="file-card">
<FileText className="h-4 w-4 text-muted-foreground" />
<span className="truncate text-sm">{filename}</span>
<a href={href} download={filename} className="...">
<Download className="h-3.5 w-3.5" />
</a>
</div>
```
**CSS:**
```css
.file-card {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.375rem 0.75rem;
border: 1px solid hsl(var(--border));
border-radius: var(--radius);
background: hsl(var(--accent) / 0.1);
margin: 0.25rem 0;
max-width: 100%;
}
```
**Verification:**
```bash
pnpm typecheck && pnpm test
```
Manual:
1. Upload a PDF → card appears in editor (not plain link)
2. Upload a .go file → card appears
3. Upload an image → still renders inline (not as card)
4. Paste an external link → still renders as normal link (not card)
5. Save and reload → card still displays correctly
6. Switch to readonly mode → card still displays
**Commit:** `feat(editor): render CDN file links as styled cards`
---
## Task 3: Update Non-Image Upload to Use File Card
**Files:**
- Modify: `apps/web/features/editor/extensions/file-upload.ts`
Currently the non-image upload path inserts a markdown string `[name](url)`. After Task 2 adds the fileCard node, this should insert a `fileCard` node directly instead:
```typescript
// Instead of:
const linkText = `[${result.filename}](${result.link})`;
replacePlaceholder(editor, placeholder, linkText);
// Insert fileCard node:
replacePlaceholder(editor, placeholder, "");
editor.chain().focus().insertContent({
type: "fileCard",
attrs: { href: result.link, filename: result.filename },
}).run();
```
**Verification:**
```bash
pnpm typecheck && pnpm test
```
Manual: Upload a PDF → placeholder appears → replaced with file card (not plain text link)
**Commit:** `feat(upload): insert file card node for non-image uploads`
---
## Task 4: Full Verification
```bash
pnpm typecheck && pnpm test
```
Manual test all upload flows:
1. Drag image → overlay → drop → inline image with pulse → real image
2. Drag PDF → overlay → drop → placeholder → file card
3. Drag .mp4 → uploads normally (no type restriction) → file card
4. Paste image → inline image
5. Click 📎 → file picker shows all types → upload works
6. Readonly mode → cards and images display correctly
7. Save → reload → everything persists
**Commit:** fix any issues found
---
## Expected Outcome
| Before (current) | After |
|-------------------|-------|
| File type whitelist blocks .mp4/.zip/etc | All files accepted (like Linear) |
| Attachment Section below description | Gone — files live in markdown |
| Non-image files as plain `[name](url)` text | Styled file card with icon + download |
| Image removal tracker + attachment cache | Gone — simpler code |
| ~300 lines of attachment UI code | Deleted |
| ~100 lines of whitelist code | Replaced by 1 line: `MAX_FILE_SIZE` |

View File

@@ -0,0 +1,452 @@
# Image View Enhancement Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
**Goal:** Add image hover toolbar (view/download/copy image/copy link/delete), lightbox preview, and smart sizing (centered, max-width capped) — matching Linear's image UX.
**Architecture:** Convert the Image extension from default `<img>` rendering to a React NodeView (`image-view.tsx`). The NodeView wraps `<img>` in a `<figure>` with a hover toolbar and lightbox portal. CSS handles centering and size cap. No new npm dependencies.
**Tech Stack:** Tiptap `ReactNodeViewRenderer`, lucide-react, sonner (toast), CSS, `createPortal` for lightbox
---
## Task 1: Create Image NodeView Component
**Files:**
- Create: `apps/web/features/editor/extensions/image-view.tsx`
**Step 1: Create the ImageView component**
```tsx
// apps/web/features/editor/extensions/image-view.tsx
"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,
Copy,
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}
>
<img
src={src}
alt={alt}
className="max-h-[90vh] max-w-[90vw] rounded-lg object-contain"
/>
</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 = async () => {
try {
const res = await fetch(src);
const blob = await res.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = alt || "image";
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
} catch {
window.open(src, "_blank", "noopener,noreferrer");
}
};
const handleCopyImage = async () => {
try {
const res = await fetch(src);
const blob = await res.blob();
await navigator.clipboard.write([
new ClipboardItem({ [blob.type]: blob }),
]);
toast.success("Image copied");
} catch {
// Fallback: copy link (Safari doesn't support async clipboard image)
await navigator.clipboard.writeText(src);
toast.success("Link copied");
}
};
const handleCopyLink = async () => {
await navigator.clipboard.writeText(src);
toast.success("Link copied");
};
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={handleCopyImage}
title="Copy image"
>
<Copy 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 };
```
**Step 2: Verify file created**
Run: `ls apps/web/features/editor/extensions/image-view.tsx`
Expected: file exists
---
## Task 2: Wire Up NodeView in Image Extension
**Files:**
- Modify: `apps/web/features/editor/extensions/index.ts:59-75`
**Step 1: Add import**
At the top of `index.ts`, after the existing imports, add:
```typescript
import { ImageView } from "./image-view";
```
**Step 2: Update ImageExtension — add NodeView, remove inline style**
Replace the `ImageExtension` definition (lines 59-75) with:
```typescript
const ImageExtension = Image.extend({
addAttributes() {
return {
...this.parent?.(),
uploading: {
default: false,
renderHTML: (attrs: Record<string, unknown>) =>
attrs.uploading ? { "data-uploading": "" } : {},
parseHTML: (el: HTMLElement) => el.hasAttribute("data-uploading"),
},
};
},
addNodeView() {
return ReactNodeViewRenderer(ImageView);
},
}).configure({
inline: false,
allowBase64: false,
});
```
Key changes:
- Added `addNodeView()` — images now render via React component
- Removed `HTMLAttributes: { style: "max-width: 100%; height: auto;" }` — sizing is now in CSS
**Step 3: Run typecheck**
Run: `pnpm typecheck`
Expected: PASS
**Step 4: Commit**
```bash
git add apps/web/features/editor/extensions/image-view.tsx apps/web/features/editor/extensions/index.ts
git commit -m "feat(editor): add Image NodeView with toolbar and lightbox
- React NodeView renders images with hover toolbar (view/download/copy/link/delete)
- Lightbox portal for full-screen preview (ESC or click to close)
- Copy image with clipboard API (fallback to copy link on Safari)
- Delete button in edit mode only
- Readonly: click image opens lightbox"
```
---
## Task 3: Update Image CSS — Centering, sizing, toolbar, lightbox
**Files:**
- Modify: `apps/web/features/editor/content-editor.css:379-395`
**Step 1: Replace image CSS rules**
Replace lines 379-395 (from `/* Images — shared styling */` through the `@keyframes` block) with:
```css
/* Images — generic fallback (non-NodeView contexts) */
.rich-text-editor img {
max-width: 100%;
height: auto;
border-radius: var(--radius);
margin: 0.5rem 0;
}
/* 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;
}
@keyframes rte-upload-pulse {
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 */
.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;
}
.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;
}
.image-toolbar button:hover {
background: color-mix(in srgb, white 15%, transparent);
}
```
**Step 2: Run typecheck**
Run: `pnpm typecheck`
Expected: PASS
**Step 3: Commit**
```bash
git add apps/web/features/editor/content-editor.css
git commit -m "style(editor): add image centering, sizing cap, and toolbar styles
- Images centered with max-width 640px cap (smart sizing)
- Dark hover toolbar with blur backdrop
- Selection outline for edit mode
- Zoom cursor for readonly mode
- Upload pulse animation preserved"
```
---
## Task 4: Full Verification
**Step 1: Run all checks**
Run: `pnpm typecheck && pnpm test`
Expected: all pass
**Step 2: Manual verification checklist**
Test in browser:
| # | Test | Expected |
|---|------|----------|
| 1 | Upload large screenshot | Centered, max 640px wide |
| 2 | Upload small image (< 300px) | Natural size, centered |
| 3 | Drag image into editor | Blob preview with pulse → real image |
| 4 | Hover image | Dark toolbar appears top-right (5 buttons edit, 4 readonly) |
| 5 | Toolbar → View image | Full-screen lightbox opens |
| 6 | Lightbox → ESC | Closes |
| 7 | Lightbox → click backdrop | Closes |
| 8 | Toolbar → Download | Browser downloads the image |
| 9 | Toolbar → Copy image | Toast "Image copied", image in clipboard |
| 10 | Toolbar → Copy link | Toast "Link copied", URL in clipboard |
| 11 | Toolbar → Delete | Image removed from editor |
| 12 | Click image (edit mode) | Blue selection outline appears |
| 13 | Select image → Backspace | Image deleted |
| 14 | Click image (readonly mode) | Opens lightbox directly |
| 15 | Readonly toolbar | No Delete button, other 4 buttons work |
| 16 | Save → reload | Images persist with correct styling |
**Step 3: Fix any issues, re-run checks**
Run: `pnpm typecheck && pnpm test`
**Step 4: Commit fixes (if any)**
---
## Architecture Notes
### Why NodeView instead of CSS-only?
The toolbar buttons (view/download/copy/delete) require interactive React components overlaid on the image. CSS-only can handle sizing/centering but cannot add click handlers. A NodeView is the standard Tiptap pattern for this — same as `CodeBlockView` (copy button) and `FileCardView` (download button) already in the codebase.
### Upload flow compatibility
The existing upload flow in `file-upload.ts` uses `tr.setNodeMarkup()` to update image attributes after upload. This works with NodeView because ProseMirror attribute changes trigger React re-renders via `ReactNodeViewRenderer`. Same mechanism used by `FileCardView`'s `finalizeFileCard()`.
### Markdown serialization
No changes needed. Images serialize as `![alt](url)` — standard markdown. The NodeView only affects editor rendering, not serialization. No width/height stored in markdown (sizing is purely CSS).
### Lightbox implementation
Uses `createPortal` to render outside the editor DOM tree, with a keyboard listener for ESC. Intentionally NOT using shadcn Dialog to keep it minimal — no focus trapping or complex accessibility needed for a simple image preview overlay.
### Browser compatibility: Copy image
`navigator.clipboard.write()` with `ClipboardItem` works in Chrome/Edge. Safari requires the clipboard write to be in the same user gesture (no async fetch before write), so it falls back to copying the link URL with a toast notification.
---
## Expected Outcome
| Before | After |
|--------|-------|
| Images stretch to 100% width, left-aligned | Centered, capped at 640px |
| No hover actions on images | 5-button toolbar: View, Download, Copy, Link, Delete |
| No image preview | Click-to-zoom lightbox (ESC/click to close) |
| Readonly images are static | Click to zoom, hover for toolbar (minus Delete) |
| Delete image: select + backspace only | Toolbar Delete button + keyboard |
| No visual selection feedback | Blue outline on selected image |

View File

@@ -0,0 +1,489 @@
# Monorepo Extraction Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
**Goal:** Extract shared code into monorepo packages (`packages/core/`, `packages/ui/`, `packages/views/`), set up Turborepo, ensure `apps/web/` runs identically.
**Architecture:** Three packages, single-direction dependencies: `views/ → core/ + ui/`. Core is headless (zero react-dom). UI is atomic (zero business logic). Views is shared pages/components.
**Tech Stack:** pnpm workspaces + catalog, Turborepo, TypeScript internal packages (export TS source, no build), Tailwind CSS v4, shadcn/ui.
**Scope:** Monorepo extraction only. Desktop app is a separate future plan.
**Branch:** `feat/monorepo-extraction` (from latest `main` at f57cf44e)
---
## Work Breakdown
| Category | Files | Nature |
|---|---|---|
| Pure file moves | ~170 | Copy + fix relative imports |
| Code changes needed | ~17 | ApiClient callback, store factories, props refactor, nav adapter |
| Bulk import updates | ~140 consumer files | Mechanical find-and-replace |
| New files to create | ~15 | package.json, tsconfig, turbo.json, platform layer, nav adapter |
---
## Phase 1: Infrastructure (Tasks 1-3)
### Task 1: Turborepo + workspace
**Files:**
- Modify: `pnpm-workspace.yaml` — add `"packages/*"` to packages list, add `@tanstack/react-query` to catalog
- Create: `turbo.json`
- Modify: `package.json` (root) — add turbo devDep, update scripts to use turbo
- Modify: `.gitignore` — add `.turbo`
**turbo.json:**
```json
{
"$schema": "https://turbo.build/schema.json",
"tasks": {
"build": {
"dependsOn": ["^build"],
"inputs": ["src/**", "app/**", "**/*.ts", "**/*.tsx", "**/*.css"],
"outputs": [".next/**", "!.next/cache/**", "dist/**"]
},
"dev": { "cache": false, "persistent": true },
"typecheck": { "dependsOn": ["^typecheck"] },
"test": { "dependsOn": ["^typecheck"] },
"lint": { "dependsOn": ["^typecheck"] }
}
}
```
**Verify:** `pnpm typecheck` passes through turbo.
**Commit:** `chore: add Turborepo and configure workspace for packages/*`
---
### Task 2: Shared TypeScript config
**Files:**
- Create: `packages/tsconfig/package.json`
- Create: `packages/tsconfig/base.json`
- Create: `packages/tsconfig/react-library.json`
**base.json** — strict, ESNext, bundler resolution, declaration maps.
**react-library.json** — extends base, adds jsx: react-jsx and DOM lib.
All other packages will `"extends": "@multica/tsconfig/react-library.json"`.
**Commit:** `chore: add shared TypeScript config package`
---
### Task 3: Clean up empty package dirs
**Action:** `rm -rf packages/sdk packages/types packages/utils packages/ui`
These are leftover empty dirs (only contain node_modules).
---
## Phase 2: packages/core/ (Tasks 4-10)
### Task 4: Scaffold + move types/utils/logger
**Files:**
- Create: `packages/core/package.json` (name: @multica/core, deps: react, zustand, @tanstack/react-query, sonner)
- Create: `packages/core/tsconfig.json` (extends @multica/tsconfig/react-library.json)
- Move: `apps/web/shared/types/``packages/core/types/` (11 files, no changes needed)
- Move: `apps/web/shared/logger.ts``packages/core/logger.ts` (no changes)
- Move: `apps/web/shared/utils.ts``packages/core/utils.ts` (no changes)
**Verify:** `cd packages/core && npx tsc --noEmit`
---
### Task 5: Move API client (with onUnauthorized abstraction)
**Files:**
- Move: `apps/web/shared/api/ws-client.ts``packages/core/api/ws-client.ts` (no changes)
- Move: `apps/web/shared/api/client.ts``packages/core/api/client.ts` (**3 changes**)
- Create: `packages/core/api/index.ts`
**Code changes in client.ts:**
1. `import type { ... } from "@/shared/types"``from "../types"`
2. `import { ... } from "@/shared/logger"``from "../logger"`
3. Add `onUnauthorized?: () => void` to options, replace `handleUnauthorized()` body:
```typescript
// Before: localStorage.removeItem + window.location.href = "/"
// After: this.token = null; this.workspaceId = null; this.options.onUnauthorized?.();
```
**NOT moved:** `apps/web/shared/api/index.ts` (the singleton) — replaced by `apps/web/platform/api.ts` in Task 9.
---
### Task 6: Move stores
**Pure moves (fix imports only):**
- `features/issues/store.ts` → `packages/core/issues/store.ts`
- `features/issues/config/*.ts` → `packages/core/issues/config/` — fix `@/shared/types` → `../../types`
- `features/issues/stores/view-store.ts` → `packages/core/issues/stores/view-store.ts` — fix imports
- `features/issues/stores/view-store-context.tsx` → `packages/core/issues/stores/view-store-context.tsx`
- `features/issues/stores/draft-store.ts` → `packages/core/issues/stores/draft-store.ts`
- `features/issues/stores/issues-scope-store.ts` → `packages/core/issues/stores/issues-scope-store.ts`
- `features/issues/stores/selection-store.ts` → `packages/core/issues/stores/selection-store.ts`
- `features/navigation/store.ts` → `packages/core/navigation/store.ts` (no changes)
- `features/modals/store.ts` → `packages/core/modals/store.ts` (no changes)
**Factory refactor (code changes):**
- `features/auth/store.ts` → `packages/core/auth/store.ts` — change to `createAuthStore({ api, onLogin?, onLogout? })` factory
- `features/workspace/store.ts` → `packages/core/workspace/store.ts` — change to `createWorkspaceStore(api)` factory
**Also move:**
- `features/workspace/hooks.ts` → `packages/core/workspace/hooks.ts` — fix imports to relative
**view-store.ts special handling:** The dynamic `import("@/features/workspace")` for workspace sync — change to accept workspace store instance via `registerViewStoreForWorkspaceSync(viewStore, workspaceStore)`.
---
### Task 7: Move TanStack Query modules
**Pure moves (fix import paths only):**
- `apps/web/core/issues/{queries,mutations,ws-updaters}.ts` → `packages/core/issues/`
- `apps/web/core/inbox/{queries,mutations,ws-updaters}.ts` → `packages/core/inbox/`
- `apps/web/core/workspace/{queries,mutations}.ts` → `packages/core/workspace/`
- `apps/web/core/runtimes/queries.ts` → `packages/core/runtimes/`
- `apps/web/core/query-client.ts` → `packages/core/query-client.ts`
- `apps/web/core/provider.tsx` → `packages/core/provider.tsx`
All changes: `@/shared/api` → `../api`, `@/shared/types` → `../types`, `@core/xxx` → `./xxx` or `../xxx`
**Code change:**
- `apps/web/core/hooks.ts` → `packages/core/hooks.ts` — refactor `useWorkspaceId()` to use React Context instead of importing workspace store directly:
```typescript
const WorkspaceIdContext = createContext<string | null>(null);
export function WorkspaceIdProvider({ wsId, children }) { ... }
export function useWorkspaceId() { return useContext(WorkspaceIdContext); }
```
---
### Task 8: Move realtime + shared hooks
**Pure moves (fix imports):**
- `features/realtime/hooks.ts` → `packages/core/realtime/hooks.ts`
- `features/realtime/use-realtime-sync.ts` → `packages/core/realtime/use-realtime-sync.ts`
- `shared/hooks/use-file-upload.ts` → `packages/core/hooks/use-file-upload.ts`
**Code change:**
- `features/realtime/provider.tsx` → `packages/core/realtime/provider.tsx` — accept `wsUrl` prop instead of reading `process.env.NEXT_PUBLIC_WS_URL`
**Note:** `use-realtime-sync.ts` needs auth/workspace store access. Since these are now factories, the realtime provider should receive the store instances. Simplest: WSProvider accepts `authStore` and `workspaceStore` props, passes them to `useRealtimeSync`.
---
### Task 9: Create platform bridge in apps/web/
**New files (all new code):**
- `apps/web/platform/api.ts` — creates api singleton with `NEXT_PUBLIC_API_URL`, `onUnauthorized` with `window.location.href`
- `apps/web/platform/auth.ts` — `export const useAuthStore = createAuthStore({ api, onLogin: setLoggedInCookie, onLogout: clearLoggedInCookie })`
- `apps/web/platform/workspace.ts` — `export const useWorkspaceStore = createWorkspaceStore(api)`
- `apps/web/platform/index.ts` — re-exports
---
### Task 10: Update imports in apps/web/ + delete old files
**Bulk find-and-replace across ~94 files:**
| Pattern | Replacement |
|---|---|
| `@/shared/types` | `@multica/core/types` |
| `@/shared/api"` (singleton usage) | `@/platform/api"` |
| `@/shared/logger` | `@multica/core/logger` |
| `@/shared/utils` | `@multica/core/utils` |
| `@/shared/hooks/` | `@multica/core/hooks/` |
| `@core/` | `@multica/core/` |
| `@/features/auth"` (useAuthStore) | `@/platform/auth"` |
| `@/features/workspace"` (useWorkspaceStore) | `@/platform/workspace"` |
| `@/features/workspace"` (useActorName) | `@multica/core/workspace/hooks"` |
| `@/features/realtime` | `@multica/core/realtime` |
| `@/features/navigation` | `@multica/core/navigation` |
| `@/features/modals"` (store) | `@multica/core/modals"` |
| `@/features/issues/store` | `@multica/core/issues` |
| `@/features/issues/stores/` | `@multica/core/issues/stores/` |
| `@/features/issues/config` | `@multica/core/issues/config` |
**Also:**
- Add `"@multica/core": "workspace:*"` to `apps/web/package.json`
- Add `transpilePackages: ["@multica/core"]` to `next.config.ts`
- Remove `"@core/*"` alias from `apps/web/tsconfig.json`
**Delete old files:**
```
apps/web/shared/types/, apps/web/shared/api/, apps/web/shared/logger.ts,
apps/web/shared/utils.ts, apps/web/shared/hooks/, apps/web/core/,
features/auth/store.ts, features/workspace/store.ts, features/workspace/hooks.ts,
features/realtime/, features/navigation/store.ts, features/modals/store.ts,
features/issues/store.ts, features/issues/stores/, features/issues/config/
```
**Keep:** `features/auth/auth-cookie.ts`, `features/auth/initializer.tsx`, `features/landing/`
**Verify:** `pnpm typecheck && pnpm test`
**Commit:** `feat(core): extract packages/core — headless business logic layer`
---
## Phase 3: packages/ui/ (Tasks 11-16)
### Task 11: Scaffold packages/ui/
**Files:**
- Create: `packages/ui/package.json` (name: @multica/ui, deps: all @radix-ui/*, clsx, tailwind-merge, lucide-react, emoji-mart, react-markdown, shiki, etc.)
- Create: `packages/ui/tsconfig.json` (extends shared config, with `@/lib/utils`, `@/hooks/*`, `@/components/ui/*` path aliases for internal shadcn imports)
- Create: `packages/ui/components.json` (shadcn config for this package)
---
### Task 12: Move shadcn + lib + hooks
**Pure moves (no code changes):**
- `apps/web/components/ui/*.tsx` (56 files) → `packages/ui/components/ui/`
- `apps/web/lib/utils.ts` → `packages/ui/lib/utils.ts`
- `apps/web/hooks/{use-auto-scroll,use-mobile,use-scroll-fade}.ts` → `packages/ui/hooks/`
---
### Task 13: Extract CSS tokens
- Copy `@theme inline { ... }` + `:root` + `.dark` blocks from `globals.css` → `packages/ui/styles/tokens.css`
- Update `globals.css`: replace inline tokens with `@import "@multica/ui/styles/tokens.css"` + add `@source` directives for packages
---
### Task 14: Refactor + move common components
**Code changes (3 files):**
- `actor-avatar.tsx` — remove `useActorName()`, accept `name/initials/avatarUrl/isAgent` props
- `mention-hover-card.tsx` — remove `useQuery`, accept resolved data props
- `reaction-bar.tsx` — remove `useActorName()`, add `getActorName` prop
**Pure moves (3 files):**
- `file-upload-button.tsx`, `emoji-picker.tsx`, `quick-emoji-picker.tsx` → direct copy
All go to `packages/ui/components/common/`.
---
### Task 15: Move markdown components
**Code change (1 file):**
- `Markdown.tsx` — add `renderMention?: (props: { type: string; id: string }) => ReactNode` prop, remove hardcoded `IssueMentionCard` import
**Pure moves (5 files):**
- `CodeBlock.tsx`, `StreamingMarkdown.tsx`, `linkify.ts`, `mentions.ts`, `index.ts`
All go to `packages/ui/markdown/`.
---
### Task 16: Update imports + delete old files
**Bulk find-and-replace across ~118 files:**
| Pattern | Replacement |
|---|---|
| `@/components/ui/` | `@multica/ui/components/ui/` |
| `@/components/common/` | `@multica/ui/components/common/` |
| `@/components/markdown` | `@multica/ui/markdown` |
| `@/lib/utils` | `@multica/ui/lib/utils` |
| `@/hooks/use-mobile` | `@multica/ui/hooks/use-mobile` |
| `@/hooks/use-auto-scroll` | `@multica/ui/hooks/use-auto-scroll` |
| `@/hooks/use-scroll-fade` | `@multica/ui/hooks/use-scroll-fade` |
**Also:**
- Add `"@multica/ui": "workspace:*"` to `apps/web/package.json`
- Add `"@multica/ui"` to `transpilePackages` in `next.config.ts`
- Update `apps/web/components.json` aliases to point to `@multica/ui`
**Delete:** `components/ui/`, `components/common/`, `components/markdown/`, `hooks/`, `lib/utils.ts`
**Keep:** `components/{theme-provider,theme-toggle,multica-icon,loading-indicator,spinner,locale-sync}.tsx`
**Verify:** `pnpm typecheck && pnpm test`
**Commit:** `feat(ui): extract packages/ui — shared atomic UI layer`
---
## Phase 4: packages/views/ + navigation (Tasks 17-22)
### Task 17: Create navigation adapter
**New files (all new code, ~60 lines total):**
- `packages/views/package.json` (deps: @multica/core, @multica/ui, @dnd-kit/*, @tiptap/*, sonner, recharts)
- `packages/views/tsconfig.json`
- `packages/views/navigation/types.ts` — `NavigationAdapter` interface (push, replace, back, pathname, searchParams)
- `packages/views/navigation/context.tsx` — `NavigationProvider` + `useNavigation()` hook
- `packages/views/navigation/app-link.tsx` — `<AppLink>` component (replaces `next/link`)
- `packages/views/navigation/index.ts`
---
### Task 18: Create WebNavigationProvider
**New file:**
- `apps/web/platform/navigation.tsx` — wraps `useRouter`/`usePathname`/`useSearchParams` into `NavigationAdapter`
Wire into dashboard layout.
---
### Task 19: Move feature UI components
**Next.js decouple (7 files, ~2 lines each):**
| File | Import change | JSX change |
|---|---|---|
| `issue-mention-card.tsx` | `next/link` → `../navigation` | `<Link` → `<AppLink` |
| `board-card.tsx` | same | same |
| `list-row.tsx` | same | same |
| `issue-detail.tsx` | `next/link` + `next/navigation` → `../navigation` | `<Link` → `<AppLink`, `router.push` → `nav.push` |
| `create-issue.tsx` | `next/navigation` → `../navigation` | `router.push` → `nav.push` |
| `create-workspace.tsx` | same | same |
**Pure moves (~85 files, fix import paths only):**
- `features/issues/components/` (24 files) → `packages/views/issues/components/`
- `features/issues/hooks/` (3 files) → `packages/views/issues/hooks/`
- `features/issues/utils/` (5 files) → `packages/views/issues/utils/`
- `features/editor/` (16 files incl CSS) → `packages/views/editor/`
- `features/modals/{create-issue,create-workspace,registry}.tsx` → `packages/views/modals/`
- `features/my-issues/` (4 files) → `packages/views/my-issues/`
- `features/skills/` (5 files) → `packages/views/skills/`
- `features/runtimes/` (16 files) → `packages/views/runtimes/`
- `features/workspace/components/workspace-avatar.tsx` → `packages/views/workspace/`
---
### Task 20: Extract fat pages
Move logic from page.tsx files into packages/views/:
| Page | Lines | Target |
|---|---|---|
| `(dashboard)/agents/page.tsx` | 1,280 | `packages/views/agents/agents-page.tsx` |
| `(dashboard)/inbox/page.tsx` | 468 | `packages/views/inbox/inbox-page.tsx` |
| `(auth)/login/page.tsx` | 389 | `packages/views/auth/login-page.tsx` |
Each original page.tsx becomes a 3-line thin shell:
```typescript
"use client";
import { AgentsPage } from "@multica/views/agents";
export default function Page() { return <AgentsPage />; }
```
Login page: pass `googleClientId` as prop instead of reading env var.
---
### Task 21: Update imports + delete old files
**Bulk find-and-replace across ~18 files:**
| Pattern | Replacement |
|---|---|
| `@/features/issues/components` | `@multica/views/issues/components` |
| `@/features/issues/hooks/` | `@multica/views/issues/hooks/` |
| `@/features/editor` | `@multica/views/editor` |
| `@/features/modals/` (components) | `@multica/views/modals/` |
| `@/features/my-issues` | `@multica/views/my-issues` |
| `@/features/skills` | `@multica/views/skills` |
| `@/features/runtimes` | `@multica/views/runtimes` |
**Also:**
- Add `"@multica/views": "workspace:*"` to `apps/web/package.json`
- Add `"@multica/views"` to `transpilePackages`
- Add `@source "../../packages/views/**/*.tsx"` to `globals.css`
**Delete old feature files.**
**Verify:** `pnpm typecheck && pnpm test`
**Commit:** `feat(views): extract packages/views — shared business UI + navigation adapter`
---
### Task 22: Final verification
```bash
make check # typecheck + unit tests + Go tests + E2E
cd apps/web && npx shadcn@latest add --dry-run badge # shadcn CLI works
# Package constraints
grep -r "@multica/core" packages/ui/ || echo "PASS: ui/ has zero core imports"
grep -r "react-dom" packages/core/ || echo "PASS: core/ has zero react-dom"
grep -r "from \"next/" packages/views/ || echo "PASS: views/ has zero next/* imports"
```
**Commit:** `chore: monorepo extraction complete — all checks pass`
---
## Final Directory Structure
```
multica/
├── packages/
│ ├── tsconfig/ # Shared TS config
│ ├── core/ # @multica/core — 三端共用 (零 react-dom)
│ │ ├── api/ # ApiClient class + WSClient
│ │ ├── types/ # 所有领域类型
│ │ ├── auth/ # createAuthStore factory
│ │ ├── workspace/ # createWorkspaceStore factory + useActorName
│ │ ├── issues/ # stores, config, queries, mutations, ws-updaters
│ │ ├── inbox/ # queries, mutations, ws-updaters
│ │ ├── runtimes/ # queries
│ │ ├── realtime/ # WSProvider, hooks, sync
│ │ ├── navigation/ # useNavigationStore
│ │ ├── modals/ # useModalStore
│ │ └── hooks.ts # useWorkspaceId (Context-based)
│ ├── ui/ # @multica/ui — Web+Desktop 共用 (零业务逻辑)
│ │ ├── components/ui/ # 56 shadcn 组件
│ │ ├── components/common/ # actor-avatar, emoji-picker... (纯 props)
│ │ ├── markdown/ # Markdown, StreamingMarkdown (renderMention slot)
│ │ ├── hooks/ # use-auto-scroll, use-mobile, use-scroll-fade
│ │ ├── lib/utils.ts # cn()
│ │ └── styles/tokens.css
│ └── views/ # @multica/views — Web+Desktop 共用页面
│ ├── navigation/ # NavigationAdapter + AppLink
│ ├── issues/ # IssuesPage, IssueDetail, BoardView...
│ ├── editor/ # ContentEditor, TitleEditor
│ ├── modals/ # CreateIssue, CreateWorkspace
│ ├── agents/ # AgentsPage (从 1280 行 page.tsx 提取)
│ ├── inbox/ # InboxPage (从 468 行 page.tsx 提取)
│ ├── auth/ # LoginPage (从 389 行 page.tsx 提取)
│ ├── my-issues/ # MyIssuesPage
│ ├── skills/ # SkillsPage
│ └── runtimes/ # RuntimesPage
├── apps/
│ └── web/
│ ├── app/ # Next.js 路由薄壳 (每个 page < 15 行)
│ ├── platform/ # Web 平台适配 (api 单例, auth store, nav provider)
│ ├── features/
│ │ ├── auth/ # auth-cookie.ts (Web 独有) + initializer.tsx
│ │ └── landing/ # Landing 页面 (Web 独有, 用 next/image)
│ └── components/ # theme-provider, multica-icon 等 app 级组件
├── turbo.json
└── pnpm-workspace.yaml
```
---
## Execution Order & Commits
| # | Commit | 影响范围 | 风险 |
|---|---|---|---|
| 1 | `chore: Turborepo + workspace` | 配置文件 | 低 |
| 2 | `chore: shared TypeScript config` | 新文件 | 低 |
| 3 | `feat(core): extract packages/core` | 94 文件 import 变更 | 中 — 最大批量替换 |
| 4 | `feat(ui): extract packages/ui` | 118 文件 import 变更 | 中 — 最多文件 |
| 5 | `feat(views): extract packages/views` | 18 文件 + 3 胖壳 | 中 |
| 6 | `chore: final verification` | 0 | 低 |

View File

@@ -4,12 +4,12 @@
"private": true,
"type": "module",
"scripts": {
"dev:web": "pnpm --filter @multica/web dev",
"build": "pnpm --filter @multica/web build",
"typecheck": "pnpm --filter @multica/web typecheck",
"test": "pnpm --filter @multica/web test",
"lint": "pnpm --filter @multica/web lint",
"clean": "pnpm --filter @multica/web clean && rm -rf node_modules"
"dev:web": "turbo dev --filter=@multica/web",
"build": "turbo build",
"typecheck": "turbo typecheck",
"test": "turbo test",
"lint": "turbo lint",
"clean": "turbo clean && rm -rf node_modules"
},
"packageManager": "pnpm@10.28.2",
"pnpm": {
@@ -26,6 +26,7 @@
"@types/node": "catalog:",
"@types/pg": "^8.20.0",
"pg": "^8.20.0",
"turbo": "^2.5.4",
"typescript": "catalog:"
}
}

View File

@@ -3,6 +3,7 @@ import type {
CreateIssueRequest,
UpdateIssueRequest,
ListIssuesResponse,
SearchIssuesResponse,
UpdateMeRequest,
CreateMemberRequest,
UpdateMemberRequest,
@@ -35,8 +36,13 @@ import type {
TimelineEntry,
TaskMessagePayload,
Attachment,
} from "@/shared/types";
import { type Logger, noopLogger } from "@/shared/logger";
} from "../types";
import { type Logger, noopLogger } from "../logger";
export interface ApiClientOptions {
logger?: Logger;
onUnauthorized?: () => void;
}
export interface LoginResponse {
token: string;
@@ -48,9 +54,11 @@ export class ApiClient {
private token: string | null = null;
private workspaceId: string | null = null;
private logger: Logger;
private options: ApiClientOptions;
constructor(baseUrl: string, options?: { logger?: Logger }) {
constructor(baseUrl: string, options?: ApiClientOptions) {
this.baseUrl = baseUrl;
this.options = options ?? {};
this.logger = options?.logger ?? noopLogger;
}
@@ -70,15 +78,9 @@ export class ApiClient {
}
private handleUnauthorized() {
if (typeof window !== "undefined") {
localStorage.removeItem("multica_token");
localStorage.removeItem("multica_workspace_id");
this.token = null;
this.workspaceId = null;
if (window.location.pathname !== "/") {
window.location.href = "/";
}
}
this.token = null;
this.workspaceId = null;
this.options.onUnauthorized?.();
}
private async parseErrorMessage(res: Response, fallback: string): Promise<string> {
@@ -114,7 +116,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 +146,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,9 +174,18 @@ 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}`);
}
async searchIssues(params: { q: string; limit?: number; offset?: number; include_closed?: boolean; signal?: AbortSignal }): Promise<SearchIssuesResponse> {
const search = new URLSearchParams({ q: params.q });
if (params.limit !== undefined) search.set("limit", String(params.limit));
if (params.offset !== undefined) search.set("offset", String(params.offset));
if (params.include_closed) search.set("include_closed", "true");
return this.fetch(`/api/issues/search?${search}`, params.signal ? { signal: params.signal } : undefined);
}
async getIssue(id: string): Promise<Issue> {
return this.fetch(`/api/issues/${id}`);
}
@@ -187,6 +206,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" });
}
@@ -325,13 +348,18 @@ export class ApiClient {
return this.fetch(`/api/agents/${id}/restore`, { method: "POST" });
}
async listRuntimes(params?: { workspace_id?: string }): Promise<AgentRuntime[]> {
async listRuntimes(params?: { workspace_id?: string; owner?: "me" }): Promise<AgentRuntime[]> {
const search = new URLSearchParams();
const wsId = params?.workspace_id ?? this.workspaceId;
if (wsId) search.set("workspace_id", wsId);
if (params?.owner) search.set("owner", params.owner);
return this.fetch(`/api/runtimes?${search}`);
}
async deleteRuntime(runtimeId: string): Promise<void> {
await this.fetch(`/api/runtimes/${runtimeId}`, { method: "DELETE" });
}
async getRuntimeUsage(runtimeId: string, params?: { days?: number }): Promise<RuntimeUsage[]> {
const search = new URLSearchParams();
if (params?.days) search.set("days", String(params.days));
@@ -371,7 +399,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

@@ -0,0 +1,31 @@
export { ApiClient } from "./client";
export type { ApiClientOptions } from "./client";
export { WSClient } from "./ws-client";
import type { ApiClient as ApiClientType } from "./client";
/** Module-level singleton — set once at app boot via `setApiInstance()`. */
let _api: ApiClientType | null = null;
export function setApiInstance(instance: ApiClientType) {
_api = instance;
}
/** Returns the shared ApiClient singleton. Throws if not yet initialised. */
export function getApi(): ApiClientType {
if (!_api) throw new Error("ApiClient not initialised — call setApiInstance() first");
return _api;
}
/**
* Convenience re-export: a proxy that forwards every property access to the
* singleton so existing call-sites (`api.listIssues(...)`) keep working.
*/
export const api = new Proxy({} as ApiClientType, {
get(_target, prop, receiver) {
// Allow property inspection (HMR/React Refresh) before initialisation
if (!_api) return undefined;
const value = Reflect.get(_api, prop, receiver);
return typeof value === "function" ? value.bind(_api) : value;
},
});

View File

@@ -1,7 +1,7 @@
import type { WSMessage, WSEventType } from "@/shared/types";
import { type Logger, noopLogger } from "@/shared/logger";
import type { WSMessage, WSEventType } from "../types/events";
import { type Logger, noopLogger } from "../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,39 @@
export { createAuthStore } from "./store";
export type { AuthStoreOptions, AuthState } from "./store";
import type { createAuthStore as CreateAuthStoreFn } from "./store";
type AuthStoreInstance = ReturnType<typeof CreateAuthStoreFn>;
/** Module-level singleton — set once at app boot via `registerAuthStore()`. */
let _store: AuthStoreInstance | null = null;
/**
* Register the auth store instance created by the app.
* Must be called at boot before any component renders.
*/
export function registerAuthStore(store: AuthStoreInstance) {
_store = store;
}
/**
* Singleton accessor — a Zustand hook backed by the registered instance.
* Supports `useAuthStore(selector)` and `useAuthStore.getState()`.
*/
export const useAuthStore: AuthStoreInstance = new Proxy(
(() => {}) as unknown as AuthStoreInstance,
{
apply(_target, _thisArg, args) {
if (!_store)
throw new Error(
"Auth store not initialised — call registerAuthStore() first",
);
return (_store as unknown as (...a: unknown[]) => unknown)(...args);
},
get(_target, prop) {
// Allow property inspection (HMR/React Refresh) before registration
if (!_store) return undefined;
return Reflect.get(_store, prop);
},
},
);

View File

@@ -0,0 +1,85 @@
import { create } from "zustand";
import type { User, StorageAdapter } from "../types";
import type { ApiClient } from "../api/client";
export interface AuthStoreOptions {
api: ApiClient;
storage: StorageAdapter;
onLogin?: () => void;
onLogout?: () => void;
}
export interface AuthState {
user: User | null;
isLoading: boolean;
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;
}
export function createAuthStore(options: AuthStoreOptions) {
const { api, storage, onLogin, onLogout } = options;
return create<AuthState>((set) => ({
user: null,
isLoading: true,
initialize: async () => {
const token = storage.getItem("multica_token");
if (!token) {
set({ isLoading: false });
return;
}
api.setToken(token);
try {
const user = await api.getMe();
set({ user, isLoading: false });
} catch {
api.setToken(null);
api.setWorkspaceId(null);
storage.removeItem("multica_token");
set({ user: null, isLoading: false });
}
},
sendCode: async (email: string) => {
await api.sendCode(email);
},
verifyCode: async (email: string, code: string) => {
const { token, user } = await api.verifyCode(email, code);
storage.setItem("multica_token", token);
api.setToken(token);
onLogin?.();
set({ user });
return user;
},
loginWithGoogle: async (code: string, redirectUri: string) => {
const { token, user } = await api.googleLogin(code, redirectUri);
storage.setItem("multica_token", token);
api.setToken(token);
onLogin?.();
set({ user });
return user;
},
logout: () => {
storage.removeItem("multica_token");
api.setToken(null);
api.setWorkspaceId(null);
onLogout?.();
set({ user: null });
},
setUser: (user: User) => {
set({ user });
},
}));
}

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