Compare commits

...

64 Commits

Author SHA1 Message Date
Jiayuan Zhang
3c97efc2e4 fix(daemon): symlink Codex sessions dir to shared home for discoverability
Per-task CODEX_HOME isolated session logs in per-task directories, making
them invisible from the global ~/.codex/sessions/ where users expect to
find them. Symlink the sessions directory back to the shared home so
Codex writes session logs to the global location while keeping skills
isolated per task.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 14:56:33 +08:00
Bohan Jiang
ca7ba48934 fix(agents): invalidate runtimes cache on daemon events (#624)
The Agents page never received runtime cache updates when daemons
registered or deregistered, causing the Create Agent dialog to show
"No runtime available" even when runtimes existed. This happened because
daemon events were only handled by the Runtimes page component, not
globally.

- Add daemon:register to the centralized realtime sync refresh map
- Skip daemon:heartbeat in the generic handler to avoid excessive refetches
- Invalidate runtimes on WS reconnect alongside other workspace data
- Show a loading indicator in the Create Agent dialog while runtimes load
2026-04-10 14:50:34 +08:00
Yevanchen
63895343e3 Fix Claude stream-json startup hangs (#592) 2026-04-10 14:42:28 +08:00
Bohan Jiang
88982ad23f feat(issues): display token usage per issue in detail sidebar (#581)
* feat(issues): display token usage per issue in detail sidebar

Add a new "Token usage" section to the issue detail right sidebar that
shows aggregated input/output tokens, cache tokens, and run count across
all tasks for the issue. Backed by a new SQL query and API endpoint.

* fix(db): add index on agent_task_queue(issue_id) for usage queries

The GetIssueUsageSummary query joins agent_task_queue filtered by
issue_id across all statuses. The existing partial index (migration 022)
only covers queued/dispatched rows, so completed tasks require a
sequential scan. Add a general index to prevent performance degradation
as task volume grows.
2026-04-10 14:34:32 +08:00
LinYushen
7620a5a7e9 fix(search): LOWER/LIKE for pg_bigm 1.2 index compatibility (#621)
* fix(search): use LOWER/LIKE instead of ILIKE for pg_bigm 1.2 compatibility

pg_bigm 1.2 on RDS does not support ILIKE index scans. Replace all
ILIKE expressions with LOWER(column) LIKE LOWER(pattern) so the GIN
indexes are utilized. Rebuild gin_bigm_ops indexes on LOWER() expressions.

Closes MUL-482

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

* fix(search): lowercase pattern in Go, add buildSearchQuery unit tests

- Lowercase phrase/terms in Go (strings.ToLower) so SQL only needs
  LOWER() on the column side, avoiding redundant per-query LOWER() on
  the pattern
- Add 5 unit tests for buildSearchQuery asserting SQL shape: no ILIKE,
  LOWER on columns only, lowercased args, multi-term AND, number match,
  include-closed flag, special char escaping

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-10 14:29:00 +08:00
CheinTian
289e3c3ad0 feat(agents): enable changing runtime (#617) 2026-04-10 13:54:46 +08:00
Naiyuan Qing
e867076bde Merge pull request #616 from multica-ai/refactor/extract-chat-and-shared-ui
refactor: extract chat to shared packages + cleanup
2026-04-10 11:36:17 +08:00
Naiyuan Qing
303a4b3144 chore(ui): configure shadcn at packages/ui level
Add components.json to packages/ui so shadcn components can be installed
directly into the shared UI package instead of going through apps/web.
Add a root pnpm ui:add script as the canonical install command.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 11:33:49 +08:00
Naiyuan Qing
0998a3a87d fix(desktop): allow tab buttons to receive clicks above drag region
Move WebkitAppRegion="no-drag" from the tab bar container to individual
buttons (TabItem and NewTabButton). This lets the empty space between
tabs remain part of the window drag region while still making the tabs
themselves clickable.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 11:33:43 +08:00
Naiyuan Qing
5878bddd6b refactor(core): move my-issues view store to packages/core/issues/stores
The my-issues view store is shared client state that doesn't depend on
any UI library. Move it from packages/views/my-issues/stores/ to
packages/core/issues/stores/ to follow the no-duplication rule and keep
state factories together with related issue stores.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 11:33:37 +08:00
Naiyuan Qing
102831919c refactor(chat): address code review feedback
- Document wsId/header coupling in chat queries (cache key vs API call)
- Extract finalizePending helper to reduce duplication across 4 WS handlers
- Store chat store handle in module-level variable for consistency with
  auth/workspace stores in CoreProvider
- Remove redundant ./chat/store package export (covered by ./chat barrel)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 11:28:12 +08:00
Naiyuan Qing
1dd8ca86c3 chore(web): remove unused Spinner, LoadingIndicator, and ThemeToggle
These components had zero consumers in the entire repo. Verified by
grep across both apps and all shared packages — they were dead code
left over from earlier iterations. The shadcn ui/spinner.tsx in
packages/ui is a separate component (Loader2-based) and is unaffected.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 11:14:52 +08:00
Naiyuan Qing
aa6577c5b7 refactor(chat): extract chat data layer to packages/core/chat
Move chat queries, mutations, and store from apps/web/core/chat/ and
apps/web/features/chat/store.ts to packages/core/chat/. Refactor store
to use createChatStore({ storage }) factory pattern (mirrors auth store)
so it works in both web (localStorage) and desktop (Electron) without
direct browser API access. Register chat store in CoreProvider.initCore.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 11:14:36 +08:00
Naiyuan Qing
ef1db9e754 Merge pull request #613 from multica-ai/feat/tab-persist-and-polish
feat(desktop): tab persistence + last-tab close button fix
2026-04-10 10:50:04 +08:00
Naiyuan Qing
2d8c0a2d60 fix(desktop): hide close button when only one tab remains
Prevent showing the X button on hover for the last tab, since closing
it just replaces with a default tab — misleading UX.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 10:47:55 +08:00
Naiyuan Qing
5647c129da feat(desktop): persist tab state across app restarts
Add Zustand persist middleware to tab store so open tabs survive app
restarts. Uses merge callback to rebuild memory routers from persisted
paths on rehydration. History stacks start fresh (matches browser
"restore tabs" behavior).

- partialize: strips router/historyIndex/historyLength (not serializable)
- merge: recreates routers via createTabRouter(path), validates activeTabId
- version: 1 for future migration support

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 10:47:44 +08:00
Naiyuan Qing
254871635e Merge pull request #612 from multica-ai/feat/per-tab-memory-router
feat(desktop): per-tab memory router + test infrastructure + CLAUDE.md rewrite
2026-04-10 10:38:06 +08:00
Naiyuan Qing
cb81aa48d3 feat(desktop): add project detail route
Wire /projects/:id in desktop router with ProjectDetailPage wrapper
(dynamic document title). Add FolderKanban icon mapping for project
tabs.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 10:35:02 +08:00
Naiyuan Qing
6340b560c7 docs: rewrite CLAUDE.md — remove code details, add decision principles
Strip ~150 lines of code-level details (module tables, file trees,
import examples) that get outdated. Add no-duplication rule, test
architecture principles, and TDD workflow guidance.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 10:34:56 +08:00
Naiyuan Qing
cc5e2e1712 test(views): rewrite shared component tests in packages/views
Move test ownership to where the code lives. LoginPage (28 tests),
IssuesPage (6 tests), IssueDetail (10 tests) now tested in
packages/views without framework-specific mocks. Old web tests
for shared components removed.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 10:34:49 +08:00
Naiyuan Qing
b067eee487 chore: set up test infrastructure for shared packages
Add vitest configs to packages/core and packages/views. Test deps
added to pnpm catalog for unified versioning. Web test deps migrated
to catalog references. pnpm test now discovers all packages.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 10:34:03 +08:00
Naiyuan Qing
1f9ce6582c refactor(desktop): update shell, tab-bar, and login for tab-based architecture
DesktopLayout → DesktopShell, AppContent handles auth routing at top
level, tab-bar and tab-sync adapted for per-tab memory routers.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 10:33:56 +08:00
Naiyuan Qing
a4383e051f refactor(desktop): per-tab memory router with Activity-based state preservation
Each tab gets its own createMemoryRouter instance. React Activity API
preserves DOM and React state for hidden tabs. Navigation adapters
split into root-level (sidebar/modals) and per-tab providers.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 10:33:48 +08:00
Naiyuan Qing
c1b1a55808 Merge pull request #609 from multica-ai/fix/cross-platform-auth-search
refactor: extract shared cross-platform components
2026-04-10 09:50:12 +08:00
Naiyuan Qing
547b8839b2 refactor(auth): consolidate web login into shared LoginPage component
Extend shared LoginPage with CLI callback, workspace preference, and
token callback props. Web login page reduced from 393 lines to 52-line
thin wrapper.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 09:45:17 +08:00
Naiyuan Qing
4c88a1318d chore(web): remove dead markdown component directory
The entire apps/web/components/markdown/ directory was unused —
all consumers already import from @multica/views/common/markdown.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 09:44:57 +08:00
Naiyuan Qing
fb1554c0bf refactor(layout): extract DashboardGuard as shared guard + provider wrapper
Both web and desktop had independent guard + WorkspaceIdProvider logic.
Extract into a single DashboardGuard component so future changes only
need one update.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 09:43:18 +08:00
Naiyuan Qing
33768a2d3a fix(runtimes): accept wsId as parameter instead of requiring WorkspaceIdProvider
useMyRuntimesNeedUpdate and useUpdatableRuntimeIds now take wsId as an
argument so they work safely outside WorkspaceIdProvider (e.g. in sidebar).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 09:27:47 +08:00
Naiyuan Qing
05067f4960 refactor(search): extract search to packages/views for cross-platform reuse
Moved SearchCommand, SearchTrigger, and search store from apps/web/features/
to packages/views/search/. Replaced useRouter (next/navigation) with the
existing useNavigation() abstraction. Wired search into desktop layout.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 09:27:36 +08:00
Naiyuan Qing
715f196434 fix(auth): wire onLogout callback to auth store and let guard handle redirect
CoreProvider.initCore() was not passing onLogin/onLogout to createAuthStore,
so the web cookie was never cleared on logout. The sidebar also hardcoded
push("/") which redirected to /issues on desktop via the index route.

Now the guard handles platform-specific redirect (web→"/", desktop→"/login").

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 09:27:16 +08:00
Naiyuan Qing
add8bf9f4f Merge pull request #608 from multica-ai/feat/desktop-app
feat(desktop): add Electron desktop app + monorepo extraction
2026-04-10 08:30:09 +08:00
Naiyuan Qing
ba32f3a187 chore: add shared ESLint config + enforce strict tsconfig across packages
- Add @multica/eslint-config package (base, react, next configs)
- Replace `next lint` (removed in Next.js 16) with `eslint .`
- Add lint scripts to all packages and desktop app
- Add noUnusedLocals, noUnusedParameters, noImplicitReturns to base tsconfig
- Fix all resulting TS/ESLint errors (unused imports, missing returns,
  stale eslint-disable comments from legacy eslint-config-next)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 08:27:29 +08:00
Naiyuan Qing
a8c3137f3b Merge remote-tracking branch 'origin/main' into feat/desktop-app
# Conflicts:
#	apps/web/app/(dashboard)/layout.tsx
#	apps/web/app/globals.css
#	apps/web/app/layout.tsx
#	apps/web/core/chat/mutations.ts
#	apps/web/core/chat/queries.ts
#	apps/web/features/chat/components/chat-message-list.tsx
#	apps/web/features/chat/components/chat-window.tsx
#	apps/web/features/landing/components/landing-footer.tsx
#	packages/core/package.json
#	packages/views/layout/app-sidebar.tsx
2026-04-10 08:01:19 +08:00
Naiyuan Qing
79b4c75303 fix: pre-resolve merge conflicts with origin/main
Prepare for merge by integrating main's new features into the
extracted shared packages architecture:
- Chat feature (ChatFab, ChatWindow) added to web dashboard extra slot
- Sidebar redesign (3-group nav, search slot, user footer, runtime updates)
- WorkspaceIdProvider moved outside SidebarInset for extra components
- Social links, twitter metadata, showDevtools, latestCliVersion

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 07:59:29 +08:00
Naiyuan Qing
18b16f2936 docs: trim CLAUDE.md — remove implementation details, keep development conventions
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 07:43:31 +08:00
Naiyuan Qing
8567dacd55 docs: update CLAUDE.md with desktop app architecture and cross-platform development guide
- Add monorepo tooling section (pnpm catalog, Turborepo, Internal Packages pattern)
- Document apps/desktop/ full structure (tab system, navigation adapter, build config)
- Add NavigationAdapter API documentation with openInNewTab/getShareableUrl
- Add cross-platform development rules (how to add pages, wire routes, handle titles)
- Document CSS architecture (shared imports, tokens, base styles, @source directives)
- Add desktop build commands (pnpm build, pnpm package, .env.production)
- Update package descriptions to reflect extracted modules (layout, auth, settings, agents, inbox)
- Update import conventions to include desktop patterns

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 07:39:52 +08:00
Naiyuan Qing
a012d912fe feat(desktop): add tab system with document.title sync + upgrade shared LoginPage
Tab system:
- Tab store with open/add/close/switch actions
- document.title as single source of truth for tab titles (MutationObserver)
- Route-level default titles via react-router handle.title + TitleSync
- useDocumentTitle hook for dynamic titles (e.g. issue detail)
- Tab bar with fixed-width tabs, fade mask, hover-to-close

Login upgrade:
- Upgrade shared LoginPage with InputOTP, cooldown resend, Google OAuth support
- Google OAuth controlled via optional google prop (desktop omits it)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 07:05:07 +08:00
Naiyuan Qing
042985d961 fix(desktop): resolve cross-platform boundary violations and deduplicate shared code
- Extract MulticaIcon and ThemeProvider to packages/ui (remove duplication)
- Extract shared CSS (scrollbar, shiki, entrance-spin) to packages/ui/styles/base.css
- Add NavigationAdapter.openInNewTab/getShareableUrl for platform-agnostic navigation
- Fix window.open() / window.location.href in shared views to use NavigationAdapter
- Add resolve.dedupe for React in electron-vite config
- Fix desktop tsconfig (noImplicitAny: true)
- Use catalog: for all desktop dependencies
- Add shadcn + tw-animate-css to desktop dependencies (fix phantom deps)
- Add typecheck scripts to all shared packages

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 07:04:53 +08:00
Jiayuan Zhang
02cdfcb93f feat(search): improve ranking with ILIKE, identifier search, multi-word support (#601)
* feat(search): improve ranking with ILIKE, identifier search, multi-word support

- Replace LIKE with ILIKE for case-insensitive matching
- Support identifier search (e.g. "MUL-123" or bare "123")
- Refine sorting tiers: number match > exact title > title starts with >
  title contains > all words in title > description > comment
- Add status-based tiebreaker (active issues rank higher)
- Support multi-word search where all terms must match somewhere
- Move search query from sqlc to dynamic SQL for flexibility

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

* fix(search): fix parameter type error for single-word queries

Only allocate per-term SQL parameters when there are multiple search
terms. For single-word queries, the phrase parameter already covers
the search — unused term params caused PostgreSQL error
"could not determine data type of parameter $3".

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-10 02:43:33 +08:00
Jiayuan Zhang
25080c6719 feat(chat): add session history panel to view archived conversations (#602)
Support viewing historical/archived chat sessions in the Master Agent chat
window. Previously, only active sessions were visible and archived ones were
permanently hidden.

Changes:
- Add ListAllChatSessionsByCreator SQL query (no status filter)
- Add ?status=all query param to GET /api/chat/sessions endpoint
- Add history button in chat header that opens a session list panel
- Sessions grouped by Active/Archived with archive action on active ones
- Clicking an archived session loads its messages in read-only mode
- Chat input disabled with "This session is archived" placeholder

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 02:40:55 +08:00
Jiayuan Zhang
89fd2ce96e refactor(views): reuse AssigneePicker in CreateIssueModal (#599)
* refactor(views): reuse AssigneePicker in CreateIssueModal

Replace the hand-rolled inline assignee Popover in CreateIssueModal with
the shared AssigneePicker component. This fixes missing features (private
agent permission checks, lock icon, disabled state, selection checkmark)
and ensures consistent behavior across all assignee dropdowns.

* refactor(views): consolidate all picker components across the codebase

Enhance shared pickers (StatusPicker, PriorityPicker, DueDatePicker,
ProjectPicker) with triggerRender, controlled open/onOpenChange, and
align props — matching the AssigneePicker API.

Replace inline implementations in:
- create-issue.tsx: Status, Priority, DueDate, Project (4 pickers)
- issue-detail.tsx sidebar: Status, Priority (2 pickers)
- batch-action-toolbar.tsx: Status, Priority (2 pickers)

StatusPicker now has its first consumer (was defined but unused).
Removes ~200 lines of duplicated picker code.
2026-04-10 02:18:49 +08:00
Jiayuan Zhang
7d5db1ce8b feat(sidebar): redesign layout for better space and grouping (#597)
* feat(sidebar): redesign sidebar layout for better space usage and grouping

- Split header into two rows: workspace switcher (full width) + search bar with new issue button
- Regroup navigation: Personal (Inbox, My Issues) + Workspace with label (Issues, Projects, Agents, Runtimes, Skills)
- Move Settings to SidebarFooter (like Linear)
- Search now renders as a full-width input-style button with ⌘K hint

Closes MUL-441

* fix(sidebar): style ⌘K shortcut as bordered badge matching project conventions

Use bordered kbd badge (bg-muted, border, font-mono) consistent with
search-command.tsx pattern. Render ⌘ symbol slightly larger for readability.

* feat(sidebar): add user profile info to footer

Show user avatar, name and email at the bottom of the sidebar
with a dropdown menu for logout, similar to the Lumis reference design.

* refactor(sidebar): move Settings back to Workspace nav, footer shows only user info

Settings is a navigable page that belongs with other nav items.
Footer now cleanly separates identity (user profile) from navigation.

* refactor(sidebar): split Workspace into Workspace + Configure groups

Split 6-item Workspace group into two cleaner groups:
- Workspace: Issues, Projects, Agents (core collaboration)
- Configure: Runtimes, Skills, Settings (infrastructure/admin)

* fix(sidebar): align search bar with nav items

Remove extra px-2 from search container and change button px-2.5 to px-2
so the search icon aligns at the same left offset as nav item icons.

* refactor(sidebar): make search and new issue regular menu items

Replace bordered input-style search bar and icon button with
SidebarMenuButton components so they share the same visual weight,
padding, and hover behavior as all other nav items.
2026-04-10 02:15:44 +08:00
Jiayuan Zhang
825e40358b feat(search): highlight matching keywords in search results (#598)
Add a HighlightText component that highlights the search query in both
issue titles and comment snippets using case-insensitive matching with
yellow highlight styling for light and dark modes.

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 01:49:19 +08:00
Jiayuan Zhang
b5cccc8ac6 feat(landing): add OpenClaw and OpenCode to landing page (#596)
* feat(landing): add OpenClaw and OpenCode to landing page

The landing page hero "Works with" section and i18n text only listed
Claude Code and Codex. Updated to include all four supported runtimes:
Claude Code, Codex, OpenClaw, and OpenCode.

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

* feat(landing): remove X (Twitter) button from header nav

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-10 01:07:46 +08:00
Bohan Jiang
aec07456fc fix(realtime): add PAT auth support to WebSocket endpoint (#568) (#587)
The /ws endpoint only accepted JWT tokens while REST /api/* routes
accepted both JWTs and PATs (mul_*). Add PATResolver interface and
wire it into HandleWebSocket so PAT holders can use WebSocket streaming.

Also update README (en + zh-CN) to list OpenClaw and OpenCode as
supported agent runtimes alongside Claude Code and Codex.
2026-04-09 19:18:57 +08:00
Bohan Jiang
6209e2f3ae fix(server): allow deleting runtimes when all bound agents are archived (#589)
Previously, runtimes could never be deleted once an agent was created
because agents can only be archived (not deleted) and the count check
included archived agents. Now the check only counts active agents, and
archived agents are cleaned up before runtime deletion.
2026-04-09 19:17:54 +08:00
Naiyuan Qing
0a5a3b2450 Merge pull request #584 from multica-ai/NevilleQingNY/search-btn-ghost
fix(web): use ghost style for sidebar search button
2026-04-09 18:45:37 +08:00
Naiyuan Qing
90b2cb7848 fix(web): use ghost style for sidebar search button
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 18:44:34 +08:00
Naiyuan Qing
bb34bd3db9 Merge pull request #583 from multica-ai/NevilleQingNY/sidebar-search-btn
feat(web): add search button to sidebar header
2026-04-09 18:39:55 +08:00
Naiyuan Qing
7950ac72af feat(web): add search button to sidebar header + restore turbo globalEnv
Add a visible search trigger button next to the create-issue button in
the sidebar header, improving search discoverability (previously only
accessible via ⌘K). Search dialog open state is shared via a Zustand
store so both the button and keyboard shortcut work.

Also restores turbo.json globalEnv config (FRONTEND_PORT, etc.) that was
accidentally dropped during the monorepo extraction, fixing worktree
port conflicts.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 18:35:22 +08:00
Bohan Jiang
db55b79aa1 fix(web): align changelog versions with GitHub release tags (#582)
* docs(web): add v0.1.9 changelog entry for 2026-04-08

* docs(web): add v0.1.10 changelog entry for 2026-04-09

* fix(web): align changelog versions with GitHub release tags
2026-04-09 18:29:38 +08:00
Naiyuan Qing
d911cdf5ac refactor: extract all shared logic to packages — apps are now thin routing shells
- Add CoreProvider to @multica/core/platform — single component for API/stores/WS/QueryClient init
- Delete 13 platform files across web (6) and desktop (7), each app keeps only navigation.tsx
- Extract AppSidebar + DashboardLayout to @multica/views/layout
- Extract LoginPage to @multica/views/auth
- Extract AgentsPage (1,279 lines) to @multica/views/agents (11 files)
- Extract InboxPage (468 lines) to @multica/views/inbox (5 files)
- Extract SettingsPage + 6 tabs (1,277 lines) to @multica/views/settings (9 files)
- Fix AppLink to use forwardRef for Base UI render prop compatibility
- Fix Tailwind @source to scan .ts files (status config with bg-info/bg-warning)
- Suppress next-themes React 19 script tag warning
- Add WebProviders wrapper for Server→Client function passing
- Wire all desktop routes to shared views, remove PlaceholderPage
- Net: +106 / -4,094 lines

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 16:45:41 +08:00
Naiyuan Qing
83769c4780 fix(desktop): add type=submit to login buttons
base-ui Button defaults to type="button", which doesn't trigger form
onSubmit. Explicit type="submit" fixes the click-to-submit flow.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 15:01:23 +08:00
Naiyuan Qing
848d79df11 fix(desktop): remove type:module — Electron main/preload are CJS
Root cause: "type": "module" made Node.js treat all .js as ESM, but
Electron loads preload via require() (CJS). Removing it makes .js
default to CJS, which is what Electron expects. No rollup overrides needed.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 14:55:44 +08:00
Naiyuan Qing
1caa7f6324 fix(desktop): preload .cjs output for ESM package + CORS for electron dev
- Preload output as .cjs so Node.js treats it as CJS regardless of
  "type": "module" in package.json
- Add electron-vite dev server ports (5173, 5174) to default CORS origins

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 14:53:15 +08:00
Naiyuan Qing
0e0c5f4cdb fix(desktop): force preload CJS output and fix CSS @source paths
- Preload must be CJS (Electron loads it via require), force format: "cjs"
  and entryFileNames: "[name].js" so output matches main's reference
- @source paths were 4 levels up but need 5 (src/renderer/src/ to root)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 14:47:46 +08:00
Naiyuan Qing
bea274492c fix(desktop): use localStorage instead of electron-store
Electron renderer IS a browser — localStorage works natively, no need
for electron-store in preload. Removes the preload module loading issue
and eliminates an unnecessary dependency + IPC bridge.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 14:46:00 +08:00
Naiyuan Qing
f7c1ae4d77 fix(desktop): move AuthInitializer to App root to prevent init deadlock
AuthInitializer was inside DashboardShell which has an isLoading early
return — the initializer never rendered, so isLoading never became false.
Moved to App.tsx (same as web's root layout) so it always executes.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 14:43:00 +08:00
Naiyuan Qing
784111a498 fix(desktop): fix tsconfig path alias and AppLink children type error
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 14:37:33 +08:00
Naiyuan Qing
77f48d9f26 feat(desktop): add CSS, router, pages, and app entry with provider nesting
- globals.css with Tailwind + design tokens from @multica/ui
- Hash router with dashboard shell, issues, my-issues, runtimes, skills pages
- Login page with email OTP flow (no Google OAuth)
- IssueDetailPage wrapper extracting route param for IssueDetail
- App.tsx with ThemeProvider > QueryProvider > RouterProvider nesting
- main.tsx without StrictMode to avoid Zustand double-render issues

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 14:35:51 +08:00
Naiyuan Qing
dafd51e327 feat(desktop): add title bar, dashboard shell, sidebar, and shared components
- multica-icon: copied from web, zero platform-specific deps
- theme-provider: next-themes + TooltipProvider wrapper
- title-bar: draggable frameless title bar with macOS traffic light inset
- app-sidebar: adapted from web — uses @multica/views/navigation instead of next/link
- dashboard-shell: root layout with auth guard, sidebar, outlet, and workspace provider

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 14:33:20 +08:00
Naiyuan Qing
f9eeafb568 feat(desktop): add renderer platform layer — storage, api, auth, ws, navigation
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 14:30:12 +08:00
Naiyuan Qing
4585306bfc feat(desktop): frameless window with hiddenInset title bar and electron-store preload bridge
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 14:28:18 +08:00
Naiyuan Qing
74cc1d488e chore(desktop): scaffold electron-vite desktop app with monorepo config
- Scaffold apps/desktop/ using electron-vite react-ts template
- Configure electron.vite.config.ts with externalizeDeps, React, Tailwind CSS v4
- Wire up @multica/core, @multica/ui, @multica/views workspace dependencies
- Configure electron-builder.yml for mac/linux/win packaging
- Add @tailwindcss/vite to pnpm catalog
- Add dev:desktop script and electron to onlyBuiltDependencies in root package.json
- Clean up generated boilerplate, keep minimal placeholder renderer

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 14:26:30 +08:00
215 changed files with 13916 additions and 4754 deletions

File diff suppressed because one or more lines are too long

385
CLAUDE.md
View File

@@ -12,184 +12,47 @@ Multica is an AI-native task management platform — like Linear, but with AI ag
## Architecture
**Go backend + monorepo frontend with shared packages.**
**Go backend + monorepo frontend (pnpm workspaces + Turborepo) with shared packages.**
- `server/` — Go backend (Chi router, sqlc for DB, gorilla/websocket for real-time)
- `apps/web/` — Next.js 16 frontend (App Router)
- `apps/web/` — Next.js frontend (App Router)
- `apps/desktop/` — Electron desktop app (electron-vite)
- `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/views/` — Shared business pages/components (zero next/* imports, zero react-router imports)
- `packages/tsconfig/` — Shared TypeScript configuration
### Package Architecture
### Key Architectural Decisions
Three shared packages with single-direction dependencies:
**Internal Packages pattern** — all shared packages export raw `.ts`/`.tsx` files (no pre-compilation). The consuming app's bundler compiles them directly. This gives zero-config HMR and instant go-to-definition.
```
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/*`, `react-router-dom`, or app-specific code.
**Dependency direction:** `views/ → core/ + ui/`. Core and UI are independent of each other. No package imports from `next/*` or `apps/web/`.
**Platform bridge:** `packages/core/platform/` provides `CoreProvider` — initializes API client, auth/workspace stores, WS connection, and QueryClient. Each app wraps its root with `<CoreProvider>` and provides its own `NavigationAdapter` for routing.
**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/ # 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
```
**`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)
**pnpm catalog** `pnpm-workspace.yaml` defines `catalog:` for version pinning. All shared deps use `catalog:` references to guarantee a single version across all packages. When adding new shared deps (including test deps), add to catalog first.
### State Management
- **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).
The architecture relies on a strict split between server state and client state. Mixing them is the most common way to break it.
**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.
- **TanStack Query owns all server state.** Issues, users, workspaces, inbox — anything fetched from the API lives in the Query cache. WS events keep it fresh via invalidation; no polling, no `staleTime` workarounds.
- **Zustand owns all client state.** UI selections, filters, drafts, modal state, navigation history. Stores live in `packages/core/` (never in `packages/views/`) so both apps share them.
- **React Context** is reserved for cross-cutting platform plumbing — `WorkspaceIdProvider`, `NavigationProvider`. Don't reach for it for general state.
- **Auth and workspace stores are the only stores allowed to call `api.*` directly**, because they manage critical state that must exist before queries can run. They're created via factory + injected dependencies, registered by the platform layer.
**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.
**Hard rules — these are how the architecture stays coherent:**
### Import Conventions
- **Never duplicate server data into Zustand.** If it came from the API, it belongs in the Query cache. Copying it into a store creates two sources of truth and they will drift.
- **Workspace-scoped queries must key on `wsId`.** This is what makes workspace switching automatic — the cache key changes, the right data appears, no manual invalidation needed.
- **Mutations are optimistic by default.** Apply the change locally, send the request, roll back on failure, invalidate on settle. The user shouldn't wait for the server.
- **WS events invalidate queries — they never write to stores directly.** This keeps the cache as the single source of truth and avoids race conditions.
- **Persist what's worth preserving across restarts** (user preferences, drafts, tab layout). **Don't persist ephemeral UI state** (modal open/close, transient selections) or server data.
```typescript
// 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";
**Common Zustand footguns to avoid:**
// 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";
```
`@/` maps to `apps/web/`. Within a package, use relative imports. Between packages, use `@multica/*`.
### Data Flow
```
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`
- **Handlers** (`internal/handler/`): One file per domain (issue, comment, agent, auth, daemon, etc.). Each handler holds `Queries`, `DB`, `Hub`, and `TaskService`.
- **Real-time** (`internal/realtime/`): Hub manages WebSocket clients. Server broadcasts events; inbound WS message routing is still TODO.
- **Auth** (`internal/auth/` + `internal/middleware/`): JWT (HS256). Middleware sets `X-User-ID` and `X-User-Email` headers. Login creates user on-the-fly if not found.
- **Task lifecycle** (`internal/service/task.go`): Orchestrates agent work — enqueue → claim → start → complete/fail. Syncs issue status automatically and broadcasts WS events at each transition.
- **Agent SDK** (`pkg/agent/`): Unified `Backend` interface for executing prompts via Claude Code or Codex. Each backend spawns its CLI and streams results via `Session.Messages` + `Session.Result` channels.
- **Daemon** (`internal/daemon/`): Local agent runtime — auto-detects available CLIs (claude, codex), registers runtimes, polls for tasks, routes by provider.
- **CLI** (`internal/cli/`): Shared helpers for the `multica` CLI — API client, config management, output formatting.
- **Events** (`internal/events/`): Internal event bus for decoupled communication between handlers and services.
- **Logging** (`internal/logger/`): Structured logging via slog. `LOG_LEVEL` env var controls level (debug, info, warn, error).
- **Database**: PostgreSQL with pgvector extension (`pgvector/pgvector:pg17`). sqlc generates Go code from SQL in `pkg/db/queries/``pkg/db/generated/`. Migrations in `migrations/`.
- **Routes** (`cmd/server/router.go`): Public routes (auth, health, ws) + protected routes (require JWT) + daemon routes (unauthenticated, separate auth model).
### Multi-tenancy
All queries filter by `workspace_id`. Membership checks gate access. `X-Workspace-ID` header routes requests to the correct workspace.
### Agent Assignees
Assignees are polymorphic — can be a member or an agent. `assignee_type` + `assignee_id` on issues. Agents render with distinct styling (purple background, robot icon).
- Selectors must return stable references. Returning a freshly built object or array on every call (e.g. `s => ({ a: s.a, b: s.b })` or `s => s.items.map(...)`) triggers infinite re-renders. Either select primitives separately or use shallow comparison.
- Hooks that need workspace context should accept `wsId` as a parameter, not call `useWorkspaceId()` internally — this lets them work outside the `WorkspaceIdProvider` (e.g. in a sidebar that renders before workspace is loaded).
## Commands
@@ -203,10 +66,11 @@ make db-down # Stop the shared PostgreSQL container
# 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 (all packages via turbo)
pnpm lint # ESLint via Next.js
pnpm test # TS tests (Vitest, via turbo)
pnpm dev:desktop # Electron dev (electron-vite, HMR)
pnpm build # Build all frontend apps
pnpm typecheck # TypeScript check (all packages + apps via turbo)
pnpm lint # ESLint
pnpm test # TS tests (Vitest, all packages + apps via turbo)
# Backend (Go)
make dev # Run Go server (port 8080)
@@ -218,17 +82,23 @@ make sqlc # Regenerate sqlc code after editing SQL in server/pkg/db/
make migrate-up # Run database migrations
make migrate-down # Rollback migrations
# Run a single TS test (works for any package with a test script)
pnpm --filter @multica/views exec vitest run auth/login-page.test.tsx
pnpm --filter @multica/core exec vitest run runtimes/version.test.ts
pnpm --filter @multica/web exec vitest run app/\(auth\)/login/page.test.tsx
# Run a single Go test
cd server && go test ./internal/handler/ -run TestName
# Run a single TS test
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
# Desktop build & package
pnpm --filter @multica/desktop build # Compile TS → JS (reads .env.production)
pnpm --filter @multica/desktop package # Package into .app/.dmg/.exe (current platform only)
# shadcn — config lives in packages/ui/components.json (Base UI variant, base-nova style)
pnpm ui:add badge # Adds component to packages/ui/components/ui/
# Infrastructure
make db-up # Start shared PostgreSQL (pgvector/pg17 image)
@@ -257,52 +127,129 @@ make start-worktree # Start using .env.worktree
- Prefer existing patterns/components over introducing parallel abstractions.
- Unless the user explicitly asks for backwards compatibility, do **not** add compatibility layers, fallback paths, dual-write logic, legacy adapters, or temporary shims.
- If a flow or API is being replaced and the product is not yet live, prefer removing the old path instead of preserving both old and new behavior.
- 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
These are hard constraints. Violating them breaks the cross-platform architecture:
- `packages/core/` — zero react-dom, zero localStorage (use StorageAdapter), zero process.env, zero UI libraries. **All shared Zustand stores live here**, even view-related ones (filters, view modes) — stores are pure state, not UI.
- `packages/ui/` — zero `@multica/core` imports (pure UI, no business logic).
- `packages/views/` — zero `next/*` imports, zero `react-router-dom` imports, zero stores. Use `NavigationAdapter` for all routing.
- `apps/web/platform/` — the only place for Next.js APIs (`next/navigation`).
- `apps/desktop/src/renderer/src/platform/` — the only place for react-router-dom navigation wiring.
### The No-Duplication Rule
**If the same logic exists in both apps, it must be extracted to a shared package.**
This applies to everything: components, hooks, guards, providers, utility functions. The decision process:
1. Does this code depend on Next.js or Electron APIs? → Keep in the respective app.
2. Does it depend on `react-router-dom` or `next/navigation`? → Keep in app's `platform/` layer.
3. Everything else → belongs in `packages/core/` (headless logic) or `packages/views/` (UI components).
When the two apps need different behavior for the same concept (e.g., different loading UI), extract the shared logic into a component with props/slots for the differences. Don't duplicate the logic.
### Cross-Platform Development Rules
When adding a new page or feature:
1. **New page component** → add to `packages/views/<domain>/`. Never import from `next/*` or `react-router-dom`.
2. **Wire it in both apps** → add a route in `apps/web/app/` (Next.js page file) AND in the desktop router.
3. **Navigation** → use `useNavigation().push()` or `<AppLink>`. Never use framework-specific link/router APIs in shared code.
4. **Shared guards/providers** → use `DashboardGuard` from `packages/views/layout/`. Don't create separate guard logic per app.
5. **Platform-specific UI** → if a feature is web-only or desktop-only, keep it in the respective app. Use props slots (`extra`, `topSlot`) on shared layout components to inject platform-specific UI.
6. **New hooks that need workspace context** → accept `wsId` as parameter instead of reading from `useWorkspaceId()` Context, so they work both inside and outside `WorkspaceIdProvider`.
### CSS Architecture
Both apps share the same CSS foundation from `packages/ui/styles/`.
- **Design tokens** → use semantic tokens (`bg-background`, `text-muted-foreground`). Never use hardcoded Tailwind colors (`text-red-500`, `bg-gray-100`).
- **Shared styles** → `packages/ui/styles/`. Never duplicate scrollbar styling, keyframes, or base layer rules in app CSS.
- **`@source` directives** → both apps scan shared packages so Tailwind sees all class names.
## UI/UX Rules
- 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`).
- Prefer shadcn components over custom implementations. Install via `pnpm ui:add <component>` from project root — adds to `packages/ui/components/ui/`. All components use Base UI primitives (`@base-ui/react`), not Radix.
- Use shadcn design tokens for styling. Avoid hardcoded color values.
- 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.
- **If a component is identical between web and desktop, it belongs in a shared package.** Do not copy-paste between apps.
## Testing Rules
- **TypeScript**: Vitest. Mock external/third-party dependencies only.
- **Go**: Standard `go test`. Tests should create their own fixture data in a test database.
### Where to write tests
Tests follow the code, not the app. This is the most important testing principle in this monorepo:
| What you're testing | Where the test lives | Why |
|---|---|---|
| Shared business logic (stores, queries, hooks) | `packages/core/*.test.ts` | No DOM needed, pure logic |
| Shared UI components (pages, forms, modals) | `packages/views/*.test.tsx` | jsdom, no framework mocks |
| Platform-specific wiring (cookies, redirects, searchParams) | `apps/web/*.test.tsx` or `apps/desktop/` | Needs framework-specific mocks |
| End-to-end user flows | `e2e/*.spec.ts` | Real browser, real backend |
**Never test shared component behavior in an app's test file.** If a test requires mocking `next/navigation` or `react-router-dom` to test a component from `@multica/views`, the test is in the wrong place — move it to `packages/views/` and mock `@multica/core` instead.
### Test infrastructure
- `packages/core/` — Vitest, Node environment (no DOM)
- `packages/views/` — Vitest, jsdom environment, `@testing-library/react`
- `apps/web/` — Vitest, jsdom environment, framework-specific mocks
- `e2e/` — Playwright
- `server/` — Go standard `go test`
All test deps are in the pnpm catalog for unified versioning.
### Mocking conventions
- Mock `@multica/core` stores with `vi.hoisted()` + `Object.assign(selectorFn, { getState })` pattern (Zustand stores are both callable and have `.getState()`).
- Mock `@multica/core/api` for API calls.
- In `packages/views/` tests: never mock `next/*` or `react-router-dom` — those don't exist here.
- In `apps/web/` tests: mock framework-specific APIs only for platform-specific behavior.
### TDD workflow
1. Write failing test in the **correct package** first.
2. Write implementation.
3. Run `pnpm test` (Turborepo discovers all packages).
4. Green → done.
### Go tests
Standard `go test`. Tests should create their own fixture data in a test database.
### E2E tests
E2E tests should be self-contained. Use the `TestApiClient` fixture for data setup/teardown:
```typescript
import { loginAsDefault, createTestApi } from "./helpers";
import type { TestApiClient } from "./fixtures";
let api: TestApiClient;
test.beforeEach(async ({ page }) => {
api = await createTestApi();
await loginAsDefault(page);
});
test.afterEach(async () => {
await api.cleanup();
});
test("example", async ({ page }) => {
const issue = await api.createIssue("Test Issue");
await page.goto(`/issues/${issue.id}`);
});
```
## Commit Rules
- Use atomic commits grouped by logical intent.
- Conventional format:
- `feat(scope): ...`
- `fix(scope): ...`
- `refactor(scope): ...`
- `docs: ...`
- `test(scope): ...`
- `chore(scope): ...`
## CLI Release
**Prerequisite:** A CLI release must accompany every Production deployment. When deploying to Production, always release a new CLI version as part of the process.
1. Create a tag on the `main` branch: `git tag v0.x.x`
2. Push the tag: `git push origin v0.x.x`
3. GitHub Actions automatically triggers `release.yml`: runs Go tests → GoReleaser builds multi-platform binaries → publishes to GitHub Releases + Homebrew tap
By default, bump the patch version each release (e.g. `v0.1.12``v0.1.13`), unless the user specifies a specific version.
- Conventional format: `feat(scope)`, `fix(scope)`, `refactor(scope)`, `docs`, `test(scope)`, `chore(scope)`.
## Minimum Pre-Push Checks
@@ -315,7 +262,7 @@ Run verification only when the user explicitly asks for it.
For targeted checks when requested:
```bash
pnpm typecheck # TypeScript type errors only
pnpm test # TS unit tests only (Vitest)
pnpm test # TS unit tests only (Vitest, all packages)
make test # Go tests only
pnpm exec playwright test # E2E only (requires backend + frontend running)
```
@@ -328,43 +275,29 @@ After writing or modifying code, always run the full verification pipeline:
make check
```
This runs all checks in sequence:
1. TypeScript typecheck (`pnpm typecheck`)
2. TypeScript unit tests (`pnpm test`)
3. Go tests (`go test ./...`)
4. E2E tests (auto-starts backend + frontend if needed, runs Playwright)
**Workflow:**
- Write code to satisfy the requirement
- Run `make check`
- If any step fails, read the error output, fix the code, and re-run `make check`
- If any step fails, read the error output, fix the code, and re-run
- Repeat until all checks pass
- Only then consider the task complete
**Quick iteration:** If you know only TypeScript or Go is affected, run individual checks first for faster feedback, then finish with a full `make check` before marking work complete.
## E2E Test Patterns
## CLI Release
E2E tests should be self-contained. Use the `TestApiClient` fixture for data setup/teardown:
**Prerequisite:** A CLI release must accompany every Production deployment.
```typescript
import { loginAsDefault, createTestApi } from "./helpers";
import type { TestApiClient } from "./fixtures";
1. Create a tag on the `main` branch: `git tag v0.x.x`
2. Push the tag: `git push origin v0.x.x`
3. GitHub Actions automatically triggers `release.yml`: runs Go tests → GoReleaser builds multi-platform binaries → publishes to GitHub Releases + Homebrew tap
let api: TestApiClient;
By default, bump the patch version each release (e.g. `v0.1.12``v0.1.13`), unless the user specifies a specific version.
test.beforeEach(async ({ page }) => {
api = await createTestApi(); // logged-in API client
await loginAsDefault(page); // browser session
});
## Multi-tenancy
test.afterEach(async () => {
await api.cleanup(); // delete any data created during the test
});
All queries filter by `workspace_id`. Membership checks gate access. `X-Workspace-ID` header routes requests to the correct workspace.
test("example", async ({ page }) => {
const issue = await api.createIssue("Test Issue"); // create via API
await page.goto(`/issues/${issue.id}`); // test via UI
// api.cleanup() in afterEach removes the issue
});
```
## Agent Assignees
Assignees are polymorphic — can be a member or an agent. `assignee_type` + `assignee_id` on issues. Agents render with distinct styling (purple background, robot icon).

View File

@@ -31,7 +31,7 @@ Turn coding agents into real teammates — assign tasks, track progress, compoun
Multica turns coding agents into real teammates. Assign issues to an agent like you'd assign to a colleague — they'll pick up the work, write code, report blockers, and update statuses autonomously.
No more copy-pasting prompts. No more babysitting runs. Your agents show up on the board, participate in conversations, and compound reusable skills over time. Think of it as open-source infrastructure for managed agents — vendor-neutral, self-hosted, and designed for human + AI teams. Works with **Claude Code** and **Codex**.
No more copy-pasting prompts. No more babysitting runs. Your agents show up on the board, participate in conversations, and compound reusable skills over time. Think of it as open-source infrastructure for managed agents — vendor-neutral, self-hosted, and designed for human + AI teams. Works with **Claude Code**, **Codex**, **OpenClaw**, and **OpenCode**.
<p align="center">
<img src="docs/assets/hero-screenshot.png" alt="Multica board view" width="800">
@@ -72,7 +72,7 @@ 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.):**
**Option A — paste this to your coding agent (Claude Code, Codex, OpenClaw, OpenCode, 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.
@@ -90,7 +90,7 @@ multica login
multica daemon start
```
The daemon auto-detects available agent CLIs (`claude`, `codex`) on your PATH. When an agent is assigned a task, the daemon creates an isolated environment, runs the agent, and reports results back.
The daemon auto-detects available agent CLIs (`claude`, `codex`, `openclaw`, `opencode`) on your PATH. When an agent is assigned a task, the daemon creates an isolated environment, runs the agent, and reports results back.
See the [CLI and Daemon Guide](CLI_AND_DAEMON.md) for the full command reference, daemon configuration, and advanced usage.
@@ -105,7 +105,7 @@ multica login # Authenticate with your Multica account
multica daemon start # Start the local agent runtime
```
The daemon runs in the background and keeps your machine connected to Multica. It auto-detects agent CLIs (`claude`, `codex`) available on your PATH.
The daemon runs in the background and keeps your machine connected to Multica. It auto-detects agent CLIs (`claude`, `codex`, `openclaw`, `opencode`) available on your PATH.
### 2. Verify your runtime
@@ -115,7 +115,7 @@ Open your workspace in the Multica web app. Navigate to **Settings → Runtimes*
### 3. Create an agent
Go to **Settings → Agents** and click **New Agent**. Pick the runtime you just connected and choose a provider (Claude Code or Codex). Give your agent a name — this is how it will appear on the board, in comments, and in assignments.
Go to **Settings → Agents** and click **New Agent**. Pick the runtime you just connected and choose a provider (Claude Code, Codex, OpenClaw, or OpenCode). Give your agent a name — this is how it will appear on the board, in comments, and in assignments.
### 4. Assign your first task
@@ -133,7 +133,8 @@ That's it! Your agent is now part of the team. 🎉
┌──────┴───────┐
│ Agent Daemon │ (runs on your machine)
Claude/Codex │
│Claude/Codex/
│OpenClaw/Code │
└──────────────┘
```
@@ -142,7 +143,7 @@ That's it! Your agent is now part of the team. 🎉
| Frontend | Next.js 16 (App Router) |
| Backend | Go (Chi router, sqlc, gorilla/websocket) |
| Database | PostgreSQL 17 with pgvector |
| Agent Runtime | Local daemon executing Claude Code or Codex |
| Agent Runtime | Local daemon executing Claude Code, Codex, OpenClaw, or OpenCode |
## Development

View File

@@ -31,7 +31,7 @@
Multica 将编码 Agent 变成真正的队友。像分配给同事一样分配给 Agent——它们会自主接手工作、编写代码、报告阻塞问题、更新状态。
不再需要复制粘贴 prompt不再需要盯着运行过程。你的 Agent 出现在看板上、参与对话、随着时间积累可复用的技能。可以理解为开源的 Managed Agents 基础设施——厂商中立、可自部署、专为人类 + AI 团队设计。支持 **Claude Code****Codex**
不再需要复制粘贴 prompt不再需要盯着运行过程。你的 Agent 出现在看板上、参与对话、随着时间积累可复用的技能。可以理解为开源的 Managed Agents 基础设施——厂商中立、可自部署、专为人类 + AI 团队设计。支持 **Claude Code**、**Codex**、**OpenClaw****OpenCode**
<p align="center">
<img src="docs/assets/hero-screenshot.png" alt="Multica 看板视图" width="800">
@@ -72,7 +72,7 @@ make start # 启动应用
`multica` CLI 将你的本地机器连接到 Multica — 用于认证、管理工作区和运行 Agent daemon。
**方式 A — 将以下指令粘贴给你的 coding agentClaude Code、Codex 等):**
**方式 A — 将以下指令粘贴给你的 coding agentClaude Code、Codex、OpenClaw、OpenCode 等):**
```
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.
@@ -90,7 +90,7 @@ multica login
multica daemon start
```
daemon 会自动检测 PATH 中可用的 Agent CLI`claude``codex`)。当 Agent 被分配任务时daemon 会创建隔离环境、运行 Agent、并将结果回传。
daemon 会自动检测 PATH 中可用的 Agent CLI`claude``codex``openclaw``opencode`)。当 Agent 被分配任务时daemon 会创建隔离环境、运行 Agent、并将结果回传。
完整命令参考请参阅 [CLI 与 Daemon 指南](CLI_AND_DAEMON.md)。
@@ -105,7 +105,7 @@ multica login # 使用你的 Multica 账号认证
multica daemon start # 启动本地 Agent 运行时
```
daemon 在后台运行,保持你的机器与 Multica 的连接。它会自动检测 PATH 中可用的 Agent CLI`claude``codex`)。
daemon 在后台运行,保持你的机器与 Multica 的连接。它会自动检测 PATH 中可用的 Agent CLI`claude``codex``openclaw``opencode`)。
### 2. 确认运行时已连接
@@ -115,7 +115,7 @@ daemon 在后台运行,保持你的机器与 Multica 的连接。它会自动
### 3. 创建 Agent
进入 **设置 → Agents**,点击 **新建 Agent**。选择你刚连接的 Runtime选择 ProviderClaude CodeCodex),并为 Agent 起个名字——它将以这个名字出现在看板、评论和任务分配中。
进入 **设置 → Agents**,点击 **新建 Agent**。选择你刚连接的 Runtime选择 ProviderClaude Code、Codex、OpenClaw 或 OpenCode并为 Agent 起个名字——它将以这个名字出现在看板、评论和任务分配中。
### 4. 分配你的第一个任务
@@ -133,7 +133,8 @@ daemon 在后台运行,保持你的机器与 Multica 的连接。它会自动
┌──────┴───────┐
│ Agent Daemon │ (运行在你的机器上)
Claude/Codex │
│Claude/Codex/
│OpenClaw/Code │
└──────────────┘
```
@@ -142,7 +143,7 @@ daemon 在后台运行,保持你的机器与 Multica 的连接。它会自动
| 前端 | Next.js 16 (App Router) |
| 后端 | Go (Chi router, sqlc, gorilla/websocket) |
| 数据库 | PostgreSQL 17 with pgvector |
| Agent 运行时 | 本地 daemon 执行 Claude CodeCodex |
| Agent 运行时 | 本地 daemon 执行 Claude Code、Codex、OpenClaw 或 OpenCode |
## 开发

6
apps/desktop/.gitignore vendored Normal file
View File

@@ -0,0 +1,6 @@
node_modules
dist
out
.DS_Store
.eslintcache
*.log*

View File

@@ -0,0 +1,31 @@
appId: ai.multica.desktop
productName: Multica
directories:
buildResources: build
files:
- "!**/.vscode/*"
- "!src/*"
- "!electron.vite.config.*"
- "!{.eslintignore,.eslintrc.cjs,.prettierignore,.prettierrc.yaml,dev-app-update.yml,CHANGELOG.md,README.md}"
- "!{tsconfig.json,tsconfig.node.json,tsconfig.web.json}"
asarUnpack:
- resources/**
mac:
entitlementsInherit: build/entitlements.mac.plist
target:
- dmg
- zip
artifactName: ${name}-${version}-${arch}.${ext}
notarize: false
dmg:
artifactName: ${name}-${version}.${ext}
linux:
target:
- AppImage
- deb
artifactName: ${name}-${version}-${arch}.${ext}
win:
target:
- nsis
artifactName: ${name}-${version}-setup.${ext}
npmRebuild: false

View File

@@ -0,0 +1,22 @@
import { resolve } from "path";
import { defineConfig, externalizeDepsPlugin } from "electron-vite";
import react from "@vitejs/plugin-react";
import tailwindcss from "@tailwindcss/vite";
export default defineConfig({
main: {
plugins: [externalizeDepsPlugin()],
},
preload: {
plugins: [externalizeDepsPlugin()],
},
renderer: {
plugins: [react(), tailwindcss()],
resolve: {
alias: {
"@": resolve("src/renderer/src"),
},
dedupe: ["react", "react-dom"],
},
},
});

View File

@@ -0,0 +1,6 @@
import reactConfig from "@multica/eslint-config/react";
export default [
...reactConfig,
{ ignores: ["out/", "dist/"] },
];

44
apps/desktop/package.json Normal file
View File

@@ -0,0 +1,44 @@
{
"name": "@multica/desktop",
"version": "0.1.0",
"private": true,
"main": "./out/main/index.js",
"scripts": {
"dev": "electron-vite dev",
"build": "electron-vite build",
"typecheck:node": "tsc --noEmit -p tsconfig.node.json --composite false",
"typecheck:web": "tsc --noEmit -p tsconfig.web.json --composite false",
"typecheck": "pnpm run typecheck:node && pnpm run typecheck:web",
"preview": "electron-vite preview",
"package": "electron-builder",
"lint": "eslint .",
"postinstall": "electron-builder install-app-deps"
},
"dependencies": {
"@electron-toolkit/preload": "^3.0.2",
"@electron-toolkit/utils": "^4.0.0",
"@multica/core": "workspace:*",
"@multica/ui": "workspace:*",
"@multica/views": "workspace:*",
"react-router-dom": "^7.6.0",
"shadcn": "^4.1.0",
"sonner": "^2.0.7",
"tw-animate-css": "^1.4.0"
},
"devDependencies": {
"@multica/tsconfig": "workspace:*",
"@electron-toolkit/tsconfig": "^2.0.0",
"@tailwindcss/vite": "^4",
"@types/node": "catalog:",
"@types/react": "catalog:",
"@types/react-dom": "catalog:",
"@vitejs/plugin-react": "^5.1.1",
"electron": "^39.2.6",
"electron-builder": "^26.0.12",
"electron-vite": "^5.0.0",
"react": "catalog:",
"react-dom": "catalog:",
"tailwindcss": "^4",
"typescript": "catalog:"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

View File

@@ -0,0 +1,55 @@
import { app, shell, BrowserWindow } from "electron";
import { join } from "path";
import { electronApp, optimizer, is } from "@electron-toolkit/utils";
let mainWindow: BrowserWindow | null = null;
function createWindow(): void {
mainWindow = new BrowserWindow({
width: 1280,
height: 800,
minWidth: 900,
minHeight: 600,
titleBarStyle: "hiddenInset",
trafficLightPosition: { x: 16, y: 13 },
show: false,
autoHideMenuBar: true,
webPreferences: {
preload: join(__dirname, "../preload/index.js"),
sandbox: false,
},
});
mainWindow.on("ready-to-show", () => {
mainWindow?.show();
});
mainWindow.webContents.setWindowOpenHandler((details) => {
shell.openExternal(details.url);
return { action: "deny" };
});
if (is.dev && process.env["ELECTRON_RENDERER_URL"]) {
mainWindow.loadURL(process.env["ELECTRON_RENDERER_URL"]);
} else {
mainWindow.loadFile(join(__dirname, "../renderer/index.html"));
}
}
app.whenReady().then(() => {
electronApp.setAppUserModelId("ai.multica.desktop");
app.on("browser-window-created", (_, window) => {
optimizer.watchWindowShortcuts(window);
});
createWindow();
app.on("activate", () => {
if (BrowserWindow.getAllWindows().length === 0) createWindow();
});
});
app.on("window-all-closed", () => {
if (process.platform !== "darwin") app.quit();
});

9
apps/desktop/src/preload/index.d.ts vendored Normal file
View File

@@ -0,0 +1,9 @@
import { ElectronAPI } from "@electron-toolkit/preload";
declare global {
interface Window {
electron: ElectronAPI;
}
}
export {};

View File

@@ -0,0 +1,9 @@
import { contextBridge } from "electron";
import { electronAPI } from "@electron-toolkit/preload";
if (process.contextIsolated) {
contextBridge.exposeInMainWorld("electron", electronAPI);
} else {
// @ts-expect-error - fallback for non-isolated context
window.electron = electronAPI;
}

View File

@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en" class="h-full">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Multica</title>
</head>
<body class="h-full overflow-hidden antialiased font-sans">
<div id="root" class="h-full"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

View File

@@ -0,0 +1,37 @@
import { CoreProvider } from "@multica/core/platform";
import { useAuthStore } from "@multica/core/auth";
import { ThemeProvider } from "@multica/ui/components/common/theme-provider";
import { MulticaIcon } from "@multica/ui/components/common/multica-icon";
import { Toaster } from "sonner";
import { DesktopLoginPage } from "./pages/login";
import { DesktopShell } from "./components/desktop-layout";
function AppContent() {
const user = useAuthStore((s) => s.user);
const isLoading = useAuthStore((s) => s.isLoading);
if (isLoading) {
return (
<div className="flex h-screen items-center justify-center">
<MulticaIcon className="size-6 animate-pulse" />
</div>
);
}
if (!user) return <DesktopLoginPage />;
return <DesktopShell />;
}
export default function App() {
return (
<ThemeProvider>
<CoreProvider
apiBaseUrl={import.meta.env.VITE_API_URL || "http://localhost:8080"}
wsUrl={import.meta.env.VITE_WS_URL || "ws://localhost:8080/ws"}
>
<AppContent />
</CoreProvider>
<Toaster />
</ThemeProvider>
);
}

View File

@@ -0,0 +1,102 @@
import { useEffect } from "react";
import { ChevronLeft, ChevronRight } from "lucide-react";
import { useTabHistory } from "@/hooks/use-tab-history";
import { useActiveTitleSync } from "@/hooks/use-tab-sync";
import { useTabStore, resolveRouteIcon } from "@/stores/tab-store";
import { SidebarProvider } from "@multica/ui/components/ui/sidebar";
import { ModalRegistry } from "@multica/views/modals/registry";
import { AppSidebar, DashboardGuard } from "@multica/views/layout";
import { SearchCommand, SearchTrigger } from "@multica/views/search";
import { ChatFab, ChatWindow } from "@multica/views/chat";
import { DesktopNavigationProvider } from "@/platform/navigation";
import { MulticaIcon } from "@multica/ui/components/common/multica-icon";
import { TabBar } from "./tab-bar";
import { TabContent } from "./tab-content";
function SidebarTopBar() {
const { canGoBack, canGoForward, goBack, goForward } = useTabHistory();
return (
<div
className="h-12 shrink-0 flex items-center justify-end px-2"
style={{ WebkitAppRegion: "drag" } as React.CSSProperties}
>
<div
className="flex items-center gap-0.5"
style={{ WebkitAppRegion: "no-drag" } as React.CSSProperties}
>
<button
onClick={goBack}
disabled={!canGoBack}
className="flex size-7 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:opacity-30 disabled:pointer-events-none"
>
<ChevronLeft className="size-4" />
</button>
<button
onClick={goForward}
disabled={!canGoForward}
className="flex size-7 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:opacity-30 disabled:pointer-events-none"
>
<ChevronRight className="size-4" />
</button>
</div>
</div>
);
}
function useInternalLinkHandler() {
useEffect(() => {
const handler = (e: Event) => {
const path = (e as CustomEvent).detail?.path;
if (!path) return;
const icon = resolveRouteIcon(path);
const store = useTabStore.getState();
const tabId = store.openTab(path, path, icon);
store.setActiveTab(tabId);
};
window.addEventListener("multica:navigate", handler);
return () => window.removeEventListener("multica:navigate", handler);
}, []);
}
export function DesktopShell() {
useInternalLinkHandler();
useActiveTitleSync();
return (
<DesktopNavigationProvider>
<DashboardGuard
loginPath="/login"
loadingFallback={
<div className="flex h-screen items-center justify-center">
<MulticaIcon className="size-6 animate-pulse" />
</div>
}
>
<div className="flex h-screen">
<SidebarProvider className="flex-1">
<AppSidebar topSlot={<SidebarTopBar />} searchSlot={<SearchTrigger />} />
{/* Right side: header + content container */}
<div className="flex flex-1 min-w-0 flex-col">
{/* Tab bar + drag region */}
<header
className="h-12 shrink-0"
style={{ WebkitAppRegion: "drag" } as React.CSSProperties}
>
<TabBar />
</header>
{/* Content area with inset styling */}
<div className="flex flex-1 min-h-0 flex-col overflow-hidden mr-2 mb-2 ml-0.5 rounded-xl shadow-sm bg-background">
<TabContent />
</div>
</div>
</SidebarProvider>
</div>
<ModalRegistry />
<SearchCommand />
<ChatWindow />
<ChatFab />
</DashboardGuard>
</DesktopNavigationProvider>
);
}

View File

@@ -0,0 +1,112 @@
import {
Inbox,
CircleUser,
ListTodo,
Bot,
Monitor,
BookOpenText,
Settings,
X,
Plus,
type LucideIcon,
} from "lucide-react";
import { cn } from "@multica/ui/lib/utils";
import { useTabStore, resolveRouteIcon, type Tab } from "@/stores/tab-store";
const TAB_ICONS: Record<string, LucideIcon> = {
Inbox,
CircleUser,
ListTodo,
Bot,
Monitor,
BookOpenText,
Settings,
};
function TabItem({ tab, isActive, isOnly }: { tab: Tab; isActive: boolean; isOnly: boolean }) {
const setActiveTab = useTabStore((s) => s.setActiveTab);
const closeTab = useTabStore((s) => s.closeTab);
const Icon = TAB_ICONS[tab.icon];
const handleClick = () => {
if (isActive) return;
setActiveTab(tab.id);
// No navigate() — Activity handles visibility
};
const handleClose = (e: React.MouseEvent) => {
e.stopPropagation();
closeTab(tab.id);
// No navigate() — store handles activeTabId switch
};
return (
<button
onClick={handleClick}
style={{ WebkitAppRegion: "no-drag" } as React.CSSProperties}
className={cn(
"group flex h-7 w-40 items-center gap-1.5 rounded-md px-2 text-xs transition-colors",
"select-none cursor-default",
isActive
? "bg-sidebar-accent font-medium text-sidebar-accent-foreground"
: "bg-sidebar-accent/50 text-muted-foreground hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
)}
>
{Icon && <Icon className="size-3.5 shrink-0" />}
<span
className="min-w-0 flex-1 overflow-hidden whitespace-nowrap text-left"
style={{
maskImage: "linear-gradient(to right, black calc(100% - 12px), transparent)",
WebkitMaskImage: "linear-gradient(to right, black calc(100% - 12px), transparent)",
}}
>
{tab.title}
</span>
{!isOnly && (
<span
onClick={handleClose}
className="hidden size-3.5 shrink-0 items-center justify-center rounded-sm text-muted-foreground transition-colors group-hover:flex hover:bg-muted-foreground/20 hover:text-foreground"
>
<X className="size-2.5" />
</span>
)}
</button>
);
}
function NewTabButton() {
const addTab = useTabStore((s) => s.addTab);
const setActiveTab = useTabStore((s) => s.setActiveTab);
const handleClick = () => {
const path = "/issues";
const tabId = addTab(path, "Issues", resolveRouteIcon(path));
setActiveTab(tabId);
// No navigate() — new tab's router starts at /issues automatically
};
return (
<button
onClick={handleClick}
style={{ WebkitAppRegion: "no-drag" } as React.CSSProperties}
className="flex size-7 shrink-0 items-center justify-center rounded-md text-muted-foreground/70 transition-colors hover:bg-muted/50 hover:text-muted-foreground"
>
<Plus className="size-3.5" />
</button>
);
}
export function TabBar() {
const tabs = useTabStore((s) => s.tabs);
const activeTabId = useTabStore((s) => s.activeTabId);
return (
<div className="flex h-full items-center gap-0.5 px-2 justify-start">
{tabs.map((tab) => (
<TabItem key={tab.id} tab={tab} isActive={tab.id === activeTabId} isOnly={tabs.length === 1} />
))}
<NewTabButton />
</div>
);
}

View File

@@ -0,0 +1,43 @@
import { Activity, useEffect } from "react";
import { RouterProvider } from "react-router-dom";
import { useTabStore } from "@/stores/tab-store";
import { TabNavigationProvider } from "@/platform/navigation";
import { useTabRouterSync } from "@/hooks/use-tab-router-sync";
/** Inner wrapper rendered inside each tab's RouterProvider. */
function TabRouterInner({ tabId }: { tabId: string }) {
const tab = useTabStore((s) => s.tabs.find((t) => t.id === tabId));
useTabRouterSync(tabId, tab!.router);
return null;
}
/**
* Renders all tabs using Activity for state preservation.
* Only the active tab is visible; hidden tabs keep their DOM and React state.
*/
export function TabContent() {
const tabs = useTabStore((s) => s.tabs);
const activeTabId = useTabStore((s) => s.activeTabId);
// Sync document.title when switching tabs
useEffect(() => {
const tab = tabs.find((t) => t.id === activeTabId);
if (tab) document.title = tab.title;
}, [activeTabId, tabs]);
return (
<>
{tabs.map((tab) => (
<Activity
key={tab.id}
mode={tab.id === activeTabId ? "visible" : "hidden"}
>
<TabNavigationProvider router={tab.router}>
<RouterProvider router={tab.router} />
<TabRouterInner tabId={tab.id} />
</TabNavigationProvider>
</Activity>
))}
</>
);
}

View File

@@ -0,0 +1 @@
/// <reference types="vite/client" />

View File

@@ -0,0 +1,17 @@
@import "tailwindcss";
@import "tw-animate-css";
@import "shadcn/tailwind.css";
@import "@multica/ui/styles/tokens.css";
@import "@multica/ui/styles/base.css";
@custom-variant dark (&:is(.dark *));
@source "../../../../../packages/ui/**/*.tsx";
@source "../../../../../packages/core/**/*.{ts,tsx}";
@source "../../../../../packages/views/**/*.{ts,tsx}";
@source "./**/*.tsx";
/* Desktop-specific: override sidebar container padding for traffic light layout */
[data-slot="sidebar-container"] {
padding: 0 !important;
}

View File

@@ -0,0 +1,8 @@
import { useEffect } from "react";
/** Sets document.title. The tab system observes this automatically. */
export function useDocumentTitle(title: string) {
useEffect(() => {
if (title) document.title = title;
}, [title]);
}

View File

@@ -0,0 +1,40 @@
import { useCallback } from "react";
import type { DataRouter } from "react-router-dom";
import { useTabStore } from "@/stores/tab-store";
/**
* Shared hint map so useTabRouterSync can distinguish back vs forward POP.
* Set before calling router.navigate(-1 | 1), read in the synchronous subscription.
*/
export const popDirectionHints = new Map<DataRouter, "back" | "forward">();
/**
* Per-tab back/forward navigation derived from the active tab's history state.
* Replaces the old global useNavigationHistory() hook.
*/
export function useTabHistory() {
// Return the actual tab object from the store — stable reference.
// Do NOT create a new object in the selector (causes infinite re-renders).
const activeTab = useTabStore((s) =>
s.tabs.find((t) => t.id === s.activeTabId),
);
const canGoBack = (activeTab?.historyIndex ?? 0) > 0;
const canGoForward =
(activeTab?.historyIndex ?? 0) < (activeTab?.historyLength ?? 1) - 1;
const goBack = useCallback(() => {
if (!activeTab || activeTab.historyIndex <= 0) return;
popDirectionHints.set(activeTab.router, "back");
activeTab.router.navigate(-1);
}, [activeTab]);
const goForward = useCallback(() => {
if (!activeTab || activeTab.historyIndex >= activeTab.historyLength - 1)
return;
popDirectionHints.set(activeTab.router, "forward");
activeTab.router.navigate(1);
}, [activeTab]);
return { canGoBack, canGoForward, goBack, goForward };
}

View File

@@ -0,0 +1,49 @@
import { useEffect, useRef } from "react";
import type { DataRouter } from "react-router-dom";
import { useTabStore, resolveRouteIcon } from "@/stores/tab-store";
import { popDirectionHints } from "./use-tab-history";
/**
* Subscribe to a tab's memory router and sync path + history tracking
* back into the tab store.
*
* Called once per tab inside its RouterProvider subtree.
*/
export function useTabRouterSync(tabId: string, router: DataRouter) {
const indexRef = useRef(0);
const lengthRef = useRef(1);
useEffect(() => {
// Sync initial state
const initialPath = router.state.location.pathname;
const store = useTabStore.getState();
store.updateTab(tabId, { path: initialPath, icon: resolveRouteIcon(initialPath) });
const unsubscribe = router.subscribe((state) => {
const { pathname } = state.location;
const action = state.historyAction;
if (action === "PUSH") {
indexRef.current += 1;
lengthRef.current = indexRef.current + 1;
} else if (action === "POP") {
// Determine direction from the hint set by goBack/goForward
const hint = popDirectionHints.get(router);
popDirectionHints.delete(router);
if (hint === "forward") {
indexRef.current = Math.min(indexRef.current + 1, lengthRef.current - 1);
} else {
// Default to back
indexRef.current = Math.max(0, indexRef.current - 1);
}
}
// REPLACE: index and length stay the same
const store = useTabStore.getState();
store.updateTab(tabId, { path: pathname, icon: resolveRouteIcon(pathname) });
store.updateTabHistory(tabId, indexRef.current, lengthRef.current);
});
return unsubscribe;
}, [tabId, router]);
}

View File

@@ -0,0 +1,29 @@
import { useEffect } from "react";
import { useTabStore } from "@/stores/tab-store";
/**
* Watches document.title via MutationObserver and updates the active tab's title.
*
* Pages set document.title via TitleSync (route handle.title) or useDocumentTitle().
* This observer picks up the change and syncs it to the tab store.
*/
export function useActiveTitleSync() {
useEffect(() => {
const observer = new MutationObserver(() => {
const title = document.title;
if (!title) return;
const { tabs, activeTabId } = useTabStore.getState();
const activeTab = tabs.find((t) => t.id === activeTabId);
if (activeTab && activeTab.title !== title) {
useTabStore.getState().updateTab(activeTabId, { title });
}
});
const titleEl = document.querySelector("title");
if (titleEl) {
observer.observe(titleEl, { childList: true, characterData: true, subtree: true });
}
return () => observer.disconnect();
}, []);
}

View File

@@ -0,0 +1,5 @@
import ReactDOM from "react-dom/client";
import App from "./App";
import "./globals.css";
ReactDOM.createRoot(document.getElementById("root")!).render(<App />);

View File

@@ -0,0 +1,17 @@
import { useParams } from "react-router-dom";
import { useQuery } from "@tanstack/react-query";
import { IssueDetail } from "@multica/views/issues/components";
import { useWorkspaceId } from "@multica/core/hooks";
import { issueDetailOptions } from "@multica/core/issues/queries";
import { useDocumentTitle } from "@/hooks/use-document-title";
export function IssueDetailPage() {
const { id } = useParams<{ id: string }>();
const wsId = useWorkspaceId();
const { data: issue } = useQuery(issueDetailOptions(wsId, id!));
useDocumentTitle(issue ? `${issue.identifier}: ${issue.title}` : "Issue");
if (!id) return null;
return <IssueDetail issueId={id} />;
}

View File

@@ -0,0 +1,20 @@
import { LoginPage } from "@multica/views/auth";
import { MulticaIcon } from "@multica/ui/components/common/multica-icon";
export function DesktopLoginPage() {
return (
<div className="flex h-screen flex-col">
{/* Traffic light inset */}
<div
className="h-[38px] shrink-0"
style={{ WebkitAppRegion: "drag" } as React.CSSProperties}
/>
<LoginPage
logo={<MulticaIcon bordered size="lg" />}
onSuccess={() => {
// Auth store update triggers AppContent re-render → shows DesktopShell
}}
/>
</div>
);
}

View File

@@ -0,0 +1,17 @@
import { useParams } from "react-router-dom";
import { useQuery } from "@tanstack/react-query";
import { ProjectDetail } from "@multica/views/projects/components";
import { useWorkspaceId } from "@multica/core/hooks";
import { projectDetailOptions } from "@multica/core/projects/queries";
import { useDocumentTitle } from "@/hooks/use-document-title";
export function ProjectDetailPage() {
const { id } = useParams<{ id: string }>();
const wsId = useWorkspaceId();
const { data: project } = useQuery(projectDetailOptions(wsId, id!));
useDocumentTitle(project ? `${project.icon || "📁"} ${project.title}` : "Project");
if (!id) return null;
return <ProjectDetail projectId={id} />;
}

View File

@@ -0,0 +1,116 @@
import { useEffect, useMemo, useState } from "react";
import type { DataRouter } from "react-router-dom";
import {
NavigationProvider,
type NavigationAdapter,
} from "@multica/views/navigation";
import { useAuthStore } from "@multica/core/auth";
import { useTabStore, resolveRouteIcon } from "@/stores/tab-store";
/**
* Root-level navigation provider for components outside the per-tab RouterProviders
* (sidebar, search dialog, modals, etc.).
*
* Reads from the active tab's memory router via router.subscribe().
* Does NOT use any react-router hooks — it's above all RouterProviders.
*/
export function DesktopNavigationProvider({
children,
}: {
children: React.ReactNode;
}) {
const activeTab = useTabStore((s) => s.tabs.find((t) => t.id === s.activeTabId));
const [pathname, setPathname] = useState(activeTab?.path ?? "/issues");
// Subscribe to the active tab's router for pathname updates
useEffect(() => {
if (!activeTab) return;
setPathname(activeTab.router.state.location.pathname);
return activeTab.router.subscribe((state) => {
setPathname(state.location.pathname);
});
}, [activeTab?.id]); // eslint-disable-line react-hooks/exhaustive-deps
const adapter: NavigationAdapter = useMemo(
() => ({
push: (path: string) => {
if (path === "/login") {
// DashboardGuard token expired — force back to login screen
useAuthStore.getState().logout();
return;
}
const tab = useTabStore.getState().tabs.find(
(t) => t.id === useTabStore.getState().activeTabId,
);
tab?.router.navigate(path);
},
replace: (path: string) => {
const tab = useTabStore.getState().tabs.find(
(t) => t.id === useTabStore.getState().activeTabId,
);
tab?.router.navigate(path, { replace: true });
},
back: () => {
const tab = useTabStore.getState().tabs.find(
(t) => t.id === useTabStore.getState().activeTabId,
);
tab?.router.navigate(-1);
},
pathname,
searchParams: new URLSearchParams(),
openInNewTab: (path: string, title?: string) => {
const icon = resolveRouteIcon(path);
const store = useTabStore.getState();
const tabId = store.openTab(path, title ?? path, icon);
store.setActiveTab(tabId);
},
getShareableUrl: (path: string) => `https://www.multica.ai${path}`,
}),
[pathname],
);
return <NavigationProvider value={adapter}>{children}</NavigationProvider>;
}
/**
* Per-tab navigation provider rendered inside each tab's Activity wrapper.
* Subscribes to the tab's own router for up-to-date pathname.
*
* This is what @multica/views page components read via useNavigation().
*/
export function TabNavigationProvider({
router,
children,
}: {
router: DataRouter;
children: React.ReactNode;
}) {
const [location, setLocation] = useState(router.state.location);
useEffect(() => {
setLocation(router.state.location);
return router.subscribe((state) => {
setLocation(state.location);
});
}, [router]);
const adapter: NavigationAdapter = useMemo(
() => ({
push: (path: string) => router.navigate(path),
replace: (path: string) => router.navigate(path, { replace: true }),
back: () => router.navigate(-1),
pathname: location.pathname,
searchParams: new URLSearchParams(location.search),
openInNewTab: (path: string, title?: string) => {
const icon = resolveRouteIcon(path);
const store = useTabStore.getState();
const newTabId = store.openTab(path, title ?? path, icon);
store.setActiveTab(newTabId);
},
getShareableUrl: (path: string) => `https://www.multica.ai${path}`,
}),
[router, location],
);
return <NavigationProvider value={adapter}>{children}</NavigationProvider>;
}

View File

@@ -0,0 +1,99 @@
import { useEffect } from "react";
import {
createMemoryRouter,
Navigate,
Outlet,
useMatches,
} from "react-router-dom";
import type { RouteObject } from "react-router-dom";
import { IssueDetailPage } from "./pages/issue-detail-page";
import { ProjectDetailPage } from "./pages/project-detail-page";
import { IssuesPage } from "@multica/views/issues/components";
import { ProjectsPage } from "@multica/views/projects/components";
import { MyIssuesPage } from "@multica/views/my-issues";
import { RuntimesPage } from "@multica/views/runtimes";
import { SkillsPage } from "@multica/views/skills";
import { AgentsPage } from "@multica/views/agents";
import { InboxPage } from "@multica/views/inbox";
import { SettingsPage } from "@multica/views/settings";
/**
* Sets document.title from the deepest matched route's handle.title.
* The tab system observes document.title via MutationObserver.
* Pages with dynamic titles (e.g. issue detail) override by setting
* document.title directly via useDocumentTitle().
*/
function TitleSync() {
const matches = useMatches();
const title = [...matches]
.reverse()
.find((m) => (m.handle as { title?: string })?.title)
?.handle as { title?: string } | undefined;
useEffect(() => {
if (title?.title) document.title = title.title;
}, [title?.title]);
return null;
}
/** Wrapper that renders route children + TitleSync */
function PageShell() {
return (
<>
<TitleSync />
<Outlet />
</>
);
}
/** Route definitions shared by all tabs (no layout wrapper). */
export const appRoutes: RouteObject[] = [
{
element: <PageShell />,
children: [
{ index: true, element: <Navigate to="/issues" replace /> },
{ path: "issues", element: <IssuesPage />, handle: { title: "Issues" } },
{
path: "issues/:id",
element: <IssueDetailPage />,
handle: { title: "Issue" },
},
{
path: "projects",
element: <ProjectsPage />,
handle: { title: "Projects" },
},
{
path: "projects/:id",
element: <ProjectDetailPage />,
handle: { title: "Project" },
},
{
path: "my-issues",
element: <MyIssuesPage />,
handle: { title: "My Issues" },
},
{
path: "runtimes",
element: <RuntimesPage />,
handle: { title: "Runtimes" },
},
{ path: "skills", element: <SkillsPage />, handle: { title: "Skills" } },
{ path: "agents", element: <AgentsPage />, handle: { title: "Agents" } },
{ path: "inbox", element: <InboxPage />, handle: { title: "Inbox" } },
{
path: "settings",
element: <SettingsPage />,
handle: { title: "Settings" },
},
],
},
];
/** Create an independent memory router for a tab. */
export function createTabRouter(initialPath: string) {
return createMemoryRouter(appRoutes, {
initialEntries: [initialPath],
});
}

View File

@@ -0,0 +1,185 @@
import { create } from "zustand";
import { persist } from "zustand/middleware";
import type { DataRouter } from "react-router-dom";
import { createTabRouter } from "../routes";
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export interface Tab {
id: string;
path: string;
title: string;
icon: string;
router: DataRouter;
historyIndex: number;
historyLength: number;
}
interface TabStore {
tabs: Tab[];
activeTabId: string;
/** Open a background tab. Deduplicates by path. Returns the tab id. */
openTab: (path: string, title: string, icon: string) => string;
/** Always create a new tab (no dedup). Returns the tab id. */
addTab: (path: string, title: string, icon: string) => string;
/** Close a tab. Disposes router. */
closeTab: (tabId: string) => void;
/** Switch to a tab by id. */
setActiveTab: (tabId: string) => void;
/** Update a tab's metadata (path, title, icon — partial). */
updateTab: (tabId: string, patch: Partial<Pick<Tab, "path" | "title" | "icon">>) => void;
/** Update a tab's history tracking. */
updateTabHistory: (tabId: string, historyIndex: number, historyLength: number) => void;
}
// ---------------------------------------------------------------------------
// Route → icon mapping (title comes from document.title, not from here)
// ---------------------------------------------------------------------------
const ROUTE_ICONS: Record<string, string> = {
"/inbox": "Inbox",
"/my-issues": "CircleUser",
"/issues": "ListTodo",
"/projects": "FolderKanban",
"/agents": "Bot",
"/runtimes": "Monitor",
"/skills": "BookOpenText",
"/settings": "Settings",
};
/** Resolve a route icon. Title is NOT determined here — it comes from document.title. */
export function resolveRouteIcon(pathname: string): string {
return ROUTE_ICONS[pathname]
?? (pathname.startsWith("/issues/") ? "ListTodo" : undefined)
?? (pathname.startsWith("/projects/") ? "FolderKanban" : undefined)
?? "ListTodo";
}
// ---------------------------------------------------------------------------
// Store
// ---------------------------------------------------------------------------
const DEFAULT_PATH = "/issues";
function createId(): string {
return crypto.randomUUID();
}
function makeTab(path: string, title: string, icon: string): Tab {
return {
id: createId(),
path,
title,
icon,
router: createTabRouter(path),
historyIndex: 0,
historyLength: 1,
};
}
const initialTab = makeTab(DEFAULT_PATH, "Issues", resolveRouteIcon(DEFAULT_PATH));
export const useTabStore = create<TabStore>()(
persist(
(set, get) => ({
tabs: [initialTab],
activeTabId: initialTab.id,
openTab(path, title, icon) {
const { tabs } = get();
const existing = tabs.find((t) => t.path === path);
if (existing) return existing.id;
const tab = makeTab(path, title, icon);
set({ tabs: [...tabs, tab] });
return tab.id;
},
addTab(path, title, icon) {
const tab = makeTab(path, title, icon);
set((s) => ({ tabs: [...s.tabs, tab] }));
return tab.id;
},
closeTab(tabId) {
const { tabs, activeTabId } = get();
const closingTab = tabs.find((t) => t.id === tabId);
// Never close the last tab — replace with default
if (tabs.length === 1) {
closingTab?.router.dispose();
const fresh = makeTab(DEFAULT_PATH, "Issues", resolveRouteIcon(DEFAULT_PATH));
set({ tabs: [fresh], activeTabId: fresh.id });
return;
}
const idx = tabs.findIndex((t) => t.id === tabId);
if (idx === -1) return;
closingTab?.router.dispose();
const next = tabs.filter((t) => t.id !== tabId);
if (tabId === activeTabId) {
const newActive = next[Math.min(idx, next.length - 1)];
set({ tabs: next, activeTabId: newActive.id });
} else {
set({ tabs: next });
}
},
setActiveTab(tabId) {
set({ activeTabId: tabId });
},
updateTab(tabId, patch) {
set((s) => ({
tabs: s.tabs.map((t) =>
t.id === tabId ? { ...t, ...patch } : t,
),
}));
},
updateTabHistory(tabId, historyIndex, historyLength) {
set((s) => ({
tabs: s.tabs.map((t) =>
t.id === tabId ? { ...t, historyIndex, historyLength } : t,
),
}));
},
}),
{
name: "multica_tabs",
version: 1,
partialize: (state) => ({
tabs: state.tabs.map(
({ router, historyIndex, historyLength, ...rest }) => rest,
),
activeTabId: state.activeTabId,
}),
merge: (persistedState, currentState) => {
const persisted = persistedState as
| Pick<TabStore, "tabs" | "activeTabId">
| undefined;
if (!persisted?.tabs?.length) return currentState;
const tabs: Tab[] = persisted.tabs.map((tab) => ({
...tab,
router: createTabRouter(tab.path),
historyIndex: 0,
historyLength: 1,
}));
// Validate activeTabId — fall back to first tab if stale
const activeTabId = tabs.some((t) => t.id === persisted.activeTabId)
? persisted.activeTabId
: tabs[0].id;
return { ...currentState, tabs, activeTabId };
},
},
),
);

View File

@@ -0,0 +1,4 @@
{
"files": [],
"references": [{ "path": "./tsconfig.node.json" }, { "path": "./tsconfig.web.json" }]
}

View File

@@ -0,0 +1,8 @@
{
"extends": "@electron-toolkit/tsconfig/tsconfig.node.json",
"include": ["electron.vite.config.*", "src/main/**/*", "src/preload/**/*"],
"compilerOptions": {
"composite": true,
"types": ["electron-vite/node"]
}
}

View File

@@ -0,0 +1,20 @@
{
"extends": "@electron-toolkit/tsconfig/tsconfig.web.json",
"include": [
"src/renderer/src/env.d.ts",
"src/renderer/src/**/*",
"src/renderer/src/**/*.tsx",
"src/preload/*.d.ts"
],
"compilerOptions": {
"composite": true,
"noImplicitAny": true,
"jsx": "react-jsx",
"baseUrl": ".",
"paths": {
"@/*": [
"src/renderer/src/*"
]
}
}
}

View File

@@ -2,40 +2,54 @@ import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
const { mockSendCode, mockVerifyCode, mockHydrateWorkspace } = vi.hoisted(
() => ({
mockSendCode: vi.fn(),
mockVerifyCode: vi.fn(),
mockHydrateWorkspace: vi.fn(),
}),
);
// Mock next/navigation
vi.mock("next/navigation", () => ({
useRouter: () => ({ push: vi.fn() }),
useRouter: () => ({ push: vi.fn(), replace: vi.fn() }),
usePathname: () => "/login",
useSearchParams: () => new URLSearchParams(),
}));
// Mock auth store
const mockSendCode = vi.fn();
const mockVerifyCode = vi.fn();
vi.mock("@/platform/auth", () => ({
useAuthStore: (selector: (s: any) => any) =>
selector({
sendCode: mockSendCode,
verifyCode: mockVerifyCode,
}),
}));
// Mock auth store — shared LoginPage uses getState().sendCode/verifyCode,
// web wrapper uses useAuthStore((s) => s.user/isLoading)
vi.mock("@multica/core/auth", () => {
const authState = {
sendCode: mockSendCode,
verifyCode: mockVerifyCode,
user: null,
isLoading: false,
};
const useAuthStore = Object.assign(
(selector: (s: typeof authState) => unknown) => selector(authState),
{ getState: () => authState },
);
return { useAuthStore };
});
// Mock auth-cookie
vi.mock("@/features/auth/auth-cookie", () => ({
setLoggedInCookie: vi.fn(),
}));
// Mock workspace store
const mockHydrateWorkspace = vi.fn();
vi.mock("@/platform/workspace", () => ({
useWorkspaceStore: (selector: (s: any) => any) =>
selector({
hydrateWorkspace: mockHydrateWorkspace,
}),
}));
// Mock workspace store — shared LoginPage uses getState().hydrateWorkspace
vi.mock("@multica/core/workspace", () => {
const wsState = { hydrateWorkspace: mockHydrateWorkspace };
const useWorkspaceStore = Object.assign(
(selector: (s: typeof wsState) => unknown) => selector(wsState),
{ getState: () => wsState },
);
return { useWorkspaceStore };
});
// Mock api
vi.mock("@/platform/api", () => ({
vi.mock("@multica/core/api", () => ({
api: {
listWorkspaces: vi.fn().mockResolvedValue([]),
verifyCode: vi.fn(),
@@ -54,8 +68,8 @@ describe("LoginPage", () => {
it("renders login form with email input and continue button", () => {
render(<LoginPage />);
expect(screen.getByText("Multica")).toBeInTheDocument();
expect(screen.getByText("Turn coding agents into real teammates")).toBeInTheDocument();
expect(screen.getByText("Sign in to Multica")).toBeInTheDocument();
expect(screen.getByText("Enter your email to get a login code")).toBeInTheDocument();
expect(screen.getByLabelText("Email")).toBeInTheDocument();
expect(
screen.getByRole("button", { name: "Continue" })

View File

@@ -1,390 +1,58 @@
"use client";
import { Suspense, useState, useEffect, useCallback } from "react";
import { Suspense, useEffect } from "react";
import { useSearchParams, useRouter } from "next/navigation";
import { useAuthStore } from "@/platform/auth";
import { useAuthStore } from "@multica/core/auth";
import { setLoggedInCookie } from "@/features/auth/auth-cookie";
import { useWorkspaceStore } from "@/platform/workspace";
import { api } from "@/platform/api";
import {
Card,
CardHeader,
CardTitle,
CardDescription,
CardContent,
CardFooter,
} 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 "@multica/ui/components/ui/input-otp";
import type { User } from "@multica/core/types";
import { LoginPage, validateCliCallback } from "@multica/views/auth";
function validateCliCallback(cliCallback: string): boolean {
try {
const cbUrl = new URL(cliCallback);
if (cbUrl.protocol !== "http:") return false;
if (cbUrl.hostname !== "localhost" && cbUrl.hostname !== "127.0.0.1")
return false;
return true;
} catch {
return false;
}
}
function redirectToCliCallback(
cliCallback: string,
token: string,
cliState: string
) {
const separator = cliCallback.includes("?") ? "&" : "?";
window.location.href = `${cliCallback}${separator}token=${encodeURIComponent(token)}&state=${encodeURIComponent(cliState)}`;
}
const googleClientId = process.env.NEXT_PUBLIC_GOOGLE_CLIENT_ID;
function LoginPageContent() {
const router = useRouter();
const user = useAuthStore((s) => s.user);
const isLoading = useAuthStore((s) => s.isLoading);
const sendCode = useAuthStore((s) => s.sendCode);
const verifyCode = useAuthStore((s) => s.verifyCode);
const hydrateWorkspace = useWorkspaceStore((s) => s.hydrateWorkspace);
const searchParams = useSearchParams();
// Already authenticated — redirect to dashboard
const cliCallbackRaw = searchParams.get("cli_callback");
const cliState = searchParams.get("cli_state") || "";
const nextUrl = searchParams.get("next") || "/issues";
// Already authenticated — redirect to dashboard (skip if CLI callback)
useEffect(() => {
if (!isLoading && user && !searchParams.get("cli_callback")) {
router.replace(searchParams.get("next") || "/issues");
if (!isLoading && user && !cliCallbackRaw) {
router.replace(nextUrl);
}
}, [isLoading, user, router, searchParams]);
}, [isLoading, user, router, nextUrl, cliCallbackRaw]);
const [step, setStep] = useState<"email" | "code" | "cli_confirm">("email");
const [email, setEmail] = useState("");
const [code, setCode] = useState("");
const [error, setError] = useState("");
const [submitting, setSubmitting] = useState(false);
const [cooldown, setCooldown] = useState(0);
const [existingUser, setExistingUser] = useState<User | null>(null);
// Check for existing session when CLI callback is present.
useEffect(() => {
const cliCallback = searchParams.get("cli_callback");
if (!cliCallback) return;
const token = localStorage.getItem("multica_token");
if (!token) return;
if (!validateCliCallback(cliCallback)) return;
// Verify the existing token is still valid.
api.setToken(token);
api
.getMe()
.then((user) => {
setExistingUser(user);
setStep("cli_confirm");
})
.catch(() => {
// Token expired/invalid — clear and fall through to normal login.
api.setToken(null);
localStorage.removeItem("multica_token");
});
}, [searchParams]);
useEffect(() => {
if (cooldown <= 0) return;
const timer = setTimeout(() => setCooldown((c) => c - 1), 1000);
return () => clearTimeout(timer);
}, [cooldown]);
const handleCliAuthorize = async () => {
const cliCallback = searchParams.get("cli_callback");
const token = localStorage.getItem("multica_token");
if (!cliCallback || !token) return;
const cliState = searchParams.get("cli_state") || "";
setSubmitting(true);
redirectToCliCallback(cliCallback, token, cliState);
};
const handleSendCode = async (e?: React.FormEvent) => {
e?.preventDefault();
if (!email) {
setError("Email is required");
return;
}
setError("");
setSubmitting(true);
try {
await sendCode(email);
setStep("code");
setCode("");
setCooldown(10);
} catch (err) {
setError(
err instanceof Error
? err.message
: "Failed to send code. Make sure the server is running."
);
} finally {
setSubmitting(false);
}
};
const handleVerifyCode = useCallback(
async (value: string) => {
if (value.length !== 6) return;
setError("");
setSubmitting(true);
try {
const cliCallback = searchParams.get("cli_callback");
if (cliCallback) {
if (!validateCliCallback(cliCallback)) {
setError("Invalid callback URL");
setSubmitting(false);
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;
}
await verifyCode(email, value);
const wsList = await api.listWorkspaces();
const lastWsId = localStorage.getItem("multica_workspace_id");
await hydrateWorkspace(wsList, lastWsId);
router.push(searchParams.get("next") || "/issues");
} catch (err) {
setError(
err instanceof Error ? err.message : "Invalid or expired code"
);
setCode("");
setSubmitting(false);
}
},
[email, verifyCode, hydrateWorkspace, router, searchParams]
);
const handleResend = async () => {
if (cooldown > 0) return;
setError("");
try {
await sendCode(email);
setCooldown(10);
} catch (err) {
setError(
err instanceof Error ? err.message : "Failed to resend code"
);
}
};
// CLI confirm step: user is already logged in, just authorize.
if (step === "cli_confirm" && existingUser) {
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">Authorize CLI</CardTitle>
<CardDescription>
Allow the CLI to access Multica as{" "}
<span className="font-medium text-foreground">
{existingUser.email}
</span>
?
</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-3">
<Button
onClick={handleCliAuthorize}
disabled={submitting}
className="w-full"
size="lg"
>
{submitting ? "Authorizing..." : "Authorize"}
</Button>
<Button
variant="ghost"
className="w-full"
onClick={() => {
setExistingUser(null);
setStep("email");
}}
>
Use a different account
</Button>
</CardContent>
</Card>
</div>
);
}
if (step === "code") {
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">Check your email</CardTitle>
<CardDescription>
We sent a verification code to{" "}
<span className="font-medium text-foreground">{email}</span>
</CardDescription>
</CardHeader>
<CardContent className="flex flex-col items-center gap-4">
<InputOTP
maxLength={6}
value={code}
onChange={(value) => {
setCode(value);
if (value.length === 6) handleVerifyCode(value);
}}
disabled={submitting}
>
<InputOTPGroup>
<InputOTPSlot index={0} />
<InputOTPSlot index={1} />
<InputOTPSlot index={2} />
<InputOTPSlot index={3} />
<InputOTPSlot index={4} />
<InputOTPSlot index={5} />
</InputOTPGroup>
</InputOTP>
{error && (
<p className="text-sm text-destructive">{error}</p>
)}
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<button
type="button"
onClick={handleResend}
disabled={cooldown > 0}
className="text-primary underline-offset-4 hover:underline disabled:text-muted-foreground disabled:no-underline disabled:cursor-not-allowed"
>
{cooldown > 0 ? `Resend in ${cooldown}s` : "Resend code"}
</button>
</div>
</CardContent>
<CardFooter>
<Button
variant="ghost"
className="w-full"
onClick={() => {
setStep("email");
setCode("");
setError("");
}}
>
Back
</Button>
</CardFooter>
</Card>
</div>
);
}
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}`;
};
const lastWorkspaceId =
typeof window !== "undefined"
? localStorage.getItem("multica_workspace_id")
: null;
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">Multica</CardTitle>
<CardDescription>Turn coding agents into real teammates</CardDescription>
</CardHeader>
<CardContent>
<form id="login-form" onSubmit={handleSendCode} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="email">Email</Label>
<Input
id="email"
type="email"
placeholder="you@example.com"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
/>
</div>
{error && (
<p className="text-sm text-destructive">{error}</p>
)}
</form>
</CardContent>
<CardFooter className="flex flex-col gap-3">
<Button
type="submit"
form="login-form"
disabled={submitting}
className="w-full"
size="lg"
>
{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>
<LoginPage
onSuccess={() => router.push(nextUrl)}
google={
googleClientId
? {
clientId: googleClientId,
redirectUri: `${window.location.origin}/auth/callback`,
}
: undefined
}
cliCallback={
cliCallbackRaw && validateCliCallback(cliCallbackRaw)
? { url: cliCallbackRaw, state: cliState }
: undefined
}
lastWorkspaceId={lastWorkspaceId}
onTokenObtained={setLoggedInCookie}
/>
);
}
export default function LoginPage() {
export default function Page() {
return (
<Suspense fallback={null}>
<LoginPageContent />

View File

@@ -1,12 +0,0 @@
export default function AgentDetailPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
return (
<div className="p-6">
<h1 className="text-2xl font-bold">Agent Detail</h1>
<p className="mt-2 text-muted-foreground">Agent status and task history</p>
</div>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,10 +0,0 @@
export default function BoardPage() {
return (
<div className="p-6">
<h1 className="text-2xl font-bold">Board</h1>
<p className="mt-2 text-muted-foreground">
Kanban board view coming soon.
</p>
</div>
);
}

View File

@@ -1,468 +1 @@
"use client";
import { useState, useEffect, useCallback, useMemo } from "react";
import { useSearchParams } from "next/navigation";
import { useDefaultLayout } from "react-resizable-panels";
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,
MoreHorizontal,
Inbox,
CheckCheck,
Archive,
BookCheck,
ListChecks,
} from "lucide-react";
import type { InboxItem, InboxItemType, IssueStatus, IssuePriority } from "@multica/core/types";
import { Button } from "@multica/ui/components/ui/button";
import {
ResizablePanelGroup,
ResizablePanel,
ResizableHandle,
} from "@multica/ui/components/ui/resizable";
import { Skeleton } from "@multica/ui/components/ui/skeleton";
import {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
} from "@multica/ui/components/ui/dropdown-menu";
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
const typeLabels: Record<InboxItemType, string> = {
issue_assigned: "Assigned",
unassigned: "Unassigned",
assignee_changed: "Assignee changed",
status_changed: "Status changed",
priority_changed: "Priority changed",
due_date_changed: "Due date changed",
new_comment: "New comment",
mentioned: "Mentioned",
review_requested: "Review requested",
task_completed: "Task completed",
task_failed: "Task failed",
agent_blocked: "Agent blocked",
agent_completed: "Agent completed",
reaction_added: "Reacted",
};
function timeAgo(dateStr: string): string {
const diff = Date.now() - new Date(dateStr).getTime();
const minutes = Math.floor(diff / 60000);
if (minutes < 1) return "just now";
if (minutes < 60) return `${minutes}m`;
const hours = Math.floor(minutes / 60);
if (hours < 24) return `${hours}h`;
const days = Math.floor(hours / 24);
return `${days}d`;
}
function shortDate(dateStr: string): string {
if (!dateStr) return "";
return new Date(dateStr).toLocaleDateString("en-US", {
month: "short",
day: "numeric",
});
}
// ---------------------------------------------------------------------------
// InboxDetailLabel — renders rich subtitle per notification type
// ---------------------------------------------------------------------------
function InboxDetailLabel({ item }: { item: InboxItem }) {
const { getActorName } = useActorName();
const details = item.details ?? {};
switch (item.type) {
case "status_changed": {
if (!details.to) return <span>{typeLabels[item.type]}</span>;
const label = STATUS_CONFIG[details.to as IssueStatus]?.label ?? details.to;
return (
<span className="inline-flex items-center gap-1">
Set status to
<StatusIcon status={details.to as IssueStatus} className="h-3 w-3" />
{label}
</span>
);
}
case "priority_changed": {
if (!details.to) return <span>{typeLabels[item.type]}</span>;
const label = PRIORITY_CONFIG[details.to as IssuePriority]?.label ?? details.to;
return (
<span className="inline-flex items-center gap-1">
Set priority to
<PriorityIcon priority={details.to as IssuePriority} className="h-3 w-3" />
{label}
</span>
);
}
case "issue_assigned": {
if (details.new_assignee_id) {
return <span>Assigned to {getActorName(details.new_assignee_type ?? "member", details.new_assignee_id)}</span>;
}
return <span>{typeLabels[item.type]}</span>;
}
case "unassigned":
return <span>Removed assignee</span>;
case "assignee_changed": {
if (details.new_assignee_id) {
return <span>Assigned to {getActorName(details.new_assignee_type ?? "member", details.new_assignee_id)}</span>;
}
return <span>{typeLabels[item.type]}</span>;
}
case "due_date_changed": {
if (details.to) return <span>Set due date to {shortDate(details.to)}</span>;
return <span>Removed due date</span>;
}
case "new_comment": {
if (item.body) return <span>{item.body}</span>;
return <span>{typeLabels[item.type]}</span>;
}
case "reaction_added": {
const emoji = details.emoji;
if (emoji) return <span>Reacted {emoji} to your comment</span>;
return <span>{typeLabels[item.type]}</span>;
}
default:
return <span>{typeLabels[item.type] ?? item.type}</span>;
}
}
// ---------------------------------------------------------------------------
// InboxListItem
// ---------------------------------------------------------------------------
function InboxListItem({
item,
isSelected,
onClick,
onArchive,
}: {
item: InboxItem;
isSelected: boolean;
onClick: () => void;
onArchive: () => void;
}) {
return (
<button
onClick={onClick}
className={`group flex w-full items-center gap-3 px-4 py-2.5 text-left transition-colors ${
isSelected ? "bg-accent" : "hover:bg-accent/50"
}`}
>
<ActorAvatar
actorType={item.actor_type ?? item.recipient_type}
actorId={item.actor_id ?? item.recipient_id}
size={28}
/>
<div className="min-w-0 flex-1">
<div className="flex items-center justify-between gap-2">
<div className="flex min-w-0 items-center gap-1.5">
{!item.read && (
<span className="h-1.5 w-1.5 shrink-0 rounded-full bg-brand" />
)}
<span
className={`truncate text-sm ${!item.read ? "font-medium" : "text-muted-foreground"}`}
>
{item.title}
</span>
</div>
<div className="flex shrink-0 items-center gap-1">
<span
role="button"
tabIndex={-1}
title="Archive"
onClick={(e) => {
e.stopPropagation();
onArchive();
}}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.stopPropagation();
onArchive();
}
}}
className="hidden rounded p-0.5 text-muted-foreground hover:bg-accent hover:text-foreground group-hover:inline-flex"
>
<Archive className="h-3.5 w-3.5" />
</span>
{item.issue_status && (
<StatusIcon status={item.issue_status} className="h-3.5 w-3.5 shrink-0" />
)}
</div>
</div>
<div className="mt-0.5 flex items-center justify-between gap-2">
<p className={`min-w-0 overflow-hidden text-ellipsis whitespace-nowrap text-xs ${item.read ? "text-muted-foreground/60" : "text-muted-foreground"}`}>
<InboxDetailLabel item={item} />
</p>
<span className={`shrink-0 text-xs ${item.read ? "text-muted-foreground/60" : "text-muted-foreground"}`}>
{timeAgo(item.created_at)}
</span>
</div>
</div>
</button>
);
}
// ---------------------------------------------------------------------------
// Page
// ---------------------------------------------------------------------------
export default function InboxPage() {
const searchParams = useSearchParams();
const urlIssue = searchParams.get("issue") ?? "";
const [selectedKey, setSelectedKeyState] = useState(() => urlIssue);
// Sync from URL when searchParams change (e.g. Next.js navigation)
useEffect(() => {
setSelectedKeyState(urlIssue);
}, [urlIssue]);
const setSelectedKey = useCallback((key: string) => {
setSelectedKeyState(key);
const url = key ? `/inbox?issue=${key}` : "/inbox";
window.history.replaceState(null, "", url);
}, []);
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",
});
const selected = items.find((i) => (i.issue_id ?? i.id) === selectedKey) ?? null;
const unreadCount = items.filter((i) => !i.read).length;
const markReadMutation = useMarkInboxRead();
const archiveMutation = useArchiveInbox();
const markAllReadMutation = useMarkAllInboxRead();
const archiveAllMutation = useArchiveAllInbox();
const archiveAllReadMutation = useArchiveAllReadInbox();
const archiveCompletedMutation = useArchiveCompletedInbox();
// Click-to-read: select + auto-mark-read
const handleSelect = (item: InboxItem) => {
setSelectedKey(item.issue_id ?? item.id);
if (!item.read) {
markReadMutation.mutate(item.id, {
onError: () => toast.error("Failed to mark as read"),
});
}
};
const handleArchive = (id: string) => {
const archived = items.find((i) => i.id === id);
if (archived && (archived.issue_id ?? archived.id) === selectedKey) setSelectedKey("");
archiveMutation.mutate(id, {
onError: () => toast.error("Failed to archive"),
});
};
// Batch operations
const handleMarkAllRead = () => {
markAllReadMutation.mutate(undefined, {
onError: () => toast.error("Failed to mark all as read"),
});
};
const handleArchiveAll = () => {
setSelectedKey("");
archiveAllMutation.mutate(undefined, {
onError: () => toast.error("Failed to archive all"),
});
};
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 = () => {
setSelectedKey("");
archiveCompletedMutation.mutate(undefined, {
onError: () => toast.error("Failed to archive completed"),
});
};
if (loading) {
return (
<ResizablePanelGroup orientation="horizontal" className="flex-1 min-h-0" defaultLayout={defaultLayout} onLayoutChanged={onLayoutChanged}>
<ResizablePanel id="list" defaultSize={320} minSize={240} maxSize={480} groupResizeBehavior="preserve-pixel-size">
<div className="flex flex-col border-r h-full">
<div className="flex h-12 shrink-0 items-center border-b px-4">
<Skeleton className="h-5 w-16" />
</div>
<div className="flex-1 min-h-0 overflow-y-auto space-y-1 p-2">
{Array.from({ length: 5 }).map((_, i) => (
<div key={i} className="flex items-center gap-3 px-4 py-2.5">
<Skeleton className="h-7 w-7 shrink-0 rounded-full" />
<div className="flex-1 space-y-2">
<Skeleton className="h-4 w-3/4" />
<Skeleton className="h-3 w-1/2" />
</div>
</div>
))}
</div>
</div>
</ResizablePanel>
<ResizableHandle />
<ResizablePanel id="detail" minSize="40%">
<div className="p-6">
<Skeleton className="h-6 w-48" />
<Skeleton className="mt-4 h-4 w-32" />
</div>
</ResizablePanel>
</ResizablePanelGroup>
);
}
return (
<ResizablePanelGroup orientation="horizontal" className="flex-1 min-h-0" defaultLayout={defaultLayout} onLayoutChanged={onLayoutChanged}>
<ResizablePanel id="list" defaultSize={320} minSize={240} maxSize={480} groupResizeBehavior="preserve-pixel-size">
{/* Left column — inbox list */}
<div className="flex flex-col border-r h-full">
<div className="flex h-12 shrink-0 items-center justify-between border-b px-4">
<div className="flex items-center gap-2">
<h1 className="text-sm font-semibold">Inbox</h1>
{unreadCount > 0 && (
<span className="text-xs text-muted-foreground">
{unreadCount}
</span>
)}
</div>
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button
variant="ghost"
size="icon-xs"
className="text-muted-foreground"
/>
}
>
<MoreHorizontal className="h-4 w-4" />
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-auto">
<DropdownMenuItem onClick={handleMarkAllRead}>
<CheckCheck className="h-4 w-4" />
Mark all as read
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={handleArchiveAll}>
<Archive className="h-4 w-4" />
Archive all
</DropdownMenuItem>
<DropdownMenuItem onClick={handleArchiveAllRead}>
<BookCheck className="h-4 w-4" />
Archive all read
</DropdownMenuItem>
<DropdownMenuItem onClick={handleArchiveCompleted}>
<ListChecks className="h-4 w-4" />
Archive completed
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
<div className="flex-1 min-h-0 overflow-y-auto">
{items.length === 0 ? (
<div className="flex flex-col items-center justify-center py-16 text-muted-foreground">
<Inbox className="mb-3 h-8 w-8 text-muted-foreground/50" />
<p className="text-sm">No notifications</p>
</div>
) : (
<div>
{items.map((item) => (
<InboxListItem
key={item.id}
item={item}
isSelected={(item.issue_id ?? item.id) === selectedKey}
onClick={() => handleSelect(item)}
onArchive={() => handleArchive(item.id)}
/>
))}
</div>
)}
</div>
</div>
</ResizablePanel>
<ResizableHandle />
<ResizablePanel id="detail" minSize="40%">
{/* Right column — detail */}
<div className="flex flex-col min-h-0 h-full">
{selected?.issue_id ? (
<IssueDetail
key={selected.id}
issueId={selected.issue_id}
defaultSidebarOpen={false}
layoutId="multica_inbox_issue_detail_layout"
highlightCommentId={selected.details?.comment_id ?? undefined}
onDelete={() => {
handleArchive(selected.id);
}}
/>
) : selected ? (
<div className="p-6">
<h2 className="text-lg font-semibold">{selected.title}</h2>
<p className="mt-1 text-sm text-muted-foreground">
{typeLabels[selected.type]} · {timeAgo(selected.created_at)}
</p>
{selected.body && (
<div className="mt-4 whitespace-pre-wrap text-sm leading-relaxed text-foreground/80">
{selected.body}
</div>
)}
<div className="mt-4">
<Button
variant="outline"
size="sm"
onClick={() => handleArchive(selected.id)}
>
<Archive className="mr-1.5 h-3.5 w-3.5" />
Archive
</Button>
</div>
</div>
) : (
<div className="flex h-full flex-col items-center justify-center text-muted-foreground">
<Inbox className="mb-3 h-10 w-10 text-muted-foreground/30" />
<p className="text-sm">
{items.length === 0
? "Your inbox is empty"
: "Select a notification to view details"}
</p>
</div>
)}
</div>
</ResizablePanel>
</ResizablePanelGroup>
);
}
export { InboxPage as default } from "@multica/views/inbox";

View File

@@ -1,581 +0,0 @@
import { Suspense, forwardRef, useRef, useState, useImperativeHandle } from "react";
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, waitFor, act, fireEvent } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { Issue, Comment, TimelineEntry } from "@multica/core/types";
import { WorkspaceIdProvider } from "@multica/core/hooks";
// Mock next/navigation
vi.mock("next/navigation", () => ({
useRouter: () => ({ push: vi.fn() }),
usePathname: () => "/issues/issue-1",
}));
// Mock next/link
vi.mock("next/link", () => ({
default: ({
children,
href,
...props
}: {
children: React.ReactNode;
href: string;
[key: string]: any;
}) => (
<a href={href} {...props}>
{children}
</a>
),
}));
// Mock auth store
vi.mock("@/platform/auth", () => ({
useAuthStore: (selector: (s: any) => any) =>
selector({
user: { id: "user-1", name: "Test User", email: "test@multica.ai" },
isLoading: false,
}),
}));
// 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) =>
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" }],
}),
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,
}),
}));
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({ 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() }) },
),
}));
// Mock ws-context
vi.mock("@/features/realtime", () => ({
useWSEvent: () => {},
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,
}));
// 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 || "");
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 Markdown renderer
vi.mock("@/components/markdown", () => ({
Markdown: ({ children }: { children: string }) => <div>{children}</div>,
}));
// Mock api (core queries/mutations use @multica/core/api, some components use @/platform/api)
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 = {
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,
project_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",
};
const mockTimeline: TimelineEntry[] = [
{
type: "comment",
id: "comment-1",
actor_type: "member",
actor_id: "user-1",
content: "Started working on this",
parent_id: null,
created_at: "2026-01-16T00:00:00Z",
updated_at: "2026-01-16T00:00:00Z",
comment_type: "comment",
},
{
type: "comment",
id: "comment-2",
actor_type: "agent",
actor_id: "agent-1",
content: "I can help with this",
parent_id: null,
created_at: "2026-01-17T00:00:00Z",
updated_at: "2026-01-17T00:00:00Z",
comment_type: "comment",
},
];
import IssueDetailPage from "./page";
function createTestQueryClient() {
return new QueryClient({
defaultOptions: {
queries: { retry: false, gcTime: 0 },
mutations: { retry: false },
},
});
}
// React 19 use(Promise) needs the promise to resolve within act + Suspense
async function renderPage(id = "issue-1") {
const queryClient = createTestQueryClient();
let result: ReturnType<typeof render>;
await act(async () => {
result = render(
<QueryClientProvider client={queryClient}>
<WorkspaceIdProvider wsId="ws-1">
<Suspense fallback={<div>Suspense loading...</div>}>
<IssueDetailPage params={Promise.resolve({ id })} />
</Suspense>
</WorkspaceIdProvider>
</QueryClientProvider>,
);
});
return result!;
}
describe("IssueDetailPage", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("renders issue details after loading", async () => {
mockApiObj.getIssue.mockResolvedValueOnce(mockIssue);
mockApiObj.listTimeline.mockResolvedValueOnce(mockTimeline);
await renderPage();
await waitFor(() => {
expect(
screen.getAllByText("Implement authentication").length,
).toBeGreaterThanOrEqual(1);
});
expect(
screen.getByText("Add JWT auth to the backend"),
).toBeInTheDocument();
});
it("renders issue properties sidebar", async () => {
mockApiObj.getIssue.mockResolvedValueOnce(mockIssue);
mockApiObj.listTimeline.mockResolvedValueOnce(mockTimeline);
await renderPage();
await waitFor(() => {
expect(screen.getByText("Properties")).toBeInTheDocument();
});
expect(screen.getByText("In Progress")).toBeInTheDocument();
expect(screen.getByText("High")).toBeInTheDocument();
});
it("renders comments", async () => {
mockApiObj.getIssue.mockResolvedValueOnce(mockIssue);
mockApiObj.listTimeline.mockResolvedValueOnce(mockTimeline);
await renderPage();
await waitFor(() => {
expect(
screen.getByText("Started working on this"),
).toBeInTheDocument();
});
expect(screen.getByText("I can help with this")).toBeInTheDocument();
expect(screen.getAllByText("Activity").length).toBeGreaterThanOrEqual(1);
});
it("shows 'Issue not found' for missing issue", async () => {
// issue-detail fetches getIssue, useIssueReactions also fetches getIssue
mockApiObj.getIssue.mockRejectedValue(new Error("Not found"));
mockApiObj.listTimeline.mockRejectedValue(new Error("Not found"));
await renderPage("nonexistent-id");
await waitFor(() => {
expect(screen.getByText("This issue does not exist or has been deleted in this workspace.")).toBeInTheDocument();
});
});
it("submits a new comment", async () => {
mockApiObj.getIssue.mockResolvedValueOnce(mockIssue);
mockApiObj.listTimeline.mockResolvedValueOnce(mockTimeline);
const newComment: Comment = {
id: "comment-3",
issue_id: "issue-1",
content: "New test comment",
type: "comment",
author_type: "member",
author_id: "user-1",
parent_id: null,
reactions: [],
attachments: [],
created_at: "2026-01-18T00:00:00Z",
updated_at: "2026-01-18T00:00:00Z",
};
mockApiObj.createComment.mockResolvedValueOnce(newComment);
const user = userEvent.setup();
await renderPage();
await waitFor(() => {
expect(
screen.getByPlaceholderText("Leave a comment..."),
).toBeInTheDocument();
});
const commentInput = screen.getByPlaceholderText("Leave a comment...");
// Use fireEvent to update the textarea value and trigger onUpdate
await act(async () => {
fireEvent.change(commentInput, { target: { value: "New test comment" } });
});
// Find the submit button associated with the "Leave a comment..." input.
// Multiple ArrowUp buttons exist (one per ReplyInput), so we find the
// button within the same ReplyInput container as our textarea.
const allArrowUpBtns = screen.getAllByRole("button").filter(
(btn) => btn.querySelector(".lucide-arrow-up") !== null,
);
// The bottom "Leave a comment..." ReplyInput renders last, so its button is last
const submitBtn = allArrowUpBtns[allArrowUpBtns.length - 1]!;
await waitFor(() => {
expect(submitBtn).not.toBeDisabled();
});
await user.click(submitBtn);
await waitFor(() => {
expect(mockApiObj.createComment).toHaveBeenCalled();
const [issueId, content] = mockApiObj.createComment.mock.calls[0]!;
expect(issueId).toBe("issue-1");
expect(content).toBe("New test comment");
});
await waitFor(() => {
expect(screen.getByText("New test comment")).toBeInTheDocument();
});
});
it("renders breadcrumb navigation", async () => {
mockApiObj.getIssue.mockResolvedValueOnce(mockIssue);
mockApiObj.listTimeline.mockResolvedValueOnce(mockTimeline);
await renderPage();
await waitFor(() => {
expect(screen.getByText("Test WS")).toBeInTheDocument();
});
const wsLink = screen.getByText("Test WS");
expect(wsLink.closest("a")).toHaveAttribute("href", "/issues");
});
});

View File

@@ -1,69 +1,18 @@
"use client";
import { useEffect } from "react";
import { useRouter, usePathname } from "next/navigation";
import { MulticaIcon } from "@/components/multica-icon";
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";
import { ChatFab, ChatWindow } from "@/features/chat";
export default function DashboardLayout({
children,
}: {
children: React.ReactNode;
}) {
const router = useRouter();
const pathname = usePathname();
const user = useAuthStore((s) => s.user);
const isLoading = useAuthStore((s) => s.isLoading);
const workspace = useWorkspaceStore((s) => s.workspace);
useEffect(() => {
if (!isLoading && !user) {
router.push("/");
}
}, [user, isLoading, router]);
useEffect(() => {
useNavigationStore.getState().onPathChange(pathname);
}, [pathname]);
if (isLoading) {
return (
<div className="flex h-screen items-center justify-center">
<MulticaIcon className="size-6" />
</div>
);
}
if (!user) return null;
if (!workspace) {
return (
<div className="flex h-svh items-center justify-center">
<MulticaIcon className="size-6 animate-pulse" />
</div>
);
}
import { DashboardLayout } from "@multica/views/layout";
import { MulticaIcon } from "@multica/ui/components/common/multica-icon";
import { SearchCommand, SearchTrigger } from "@multica/views/search";
import { ChatFab, ChatWindow } from "@multica/views/chat";
export default function Layout({ children }: { children: React.ReactNode }) {
return (
<WorkspaceIdProvider wsId={workspace.id}>
<SidebarProvider className="h-svh">
<AppSidebar />
<SidebarInset className="overflow-hidden">
{children}
<ModalRegistry />
</SidebarInset>
<ChatWindow />
<ChatFab />
<SearchCommand />
</SidebarProvider>
</WorkspaceIdProvider>
<DashboardLayout
loadingIndicator={<MulticaIcon className="size-6" />}
searchSlot={<SearchTrigger />}
extra={<><SearchCommand /><ChatWindow /><ChatFab /></>}
>
{children}
</DashboardLayout>
);
}

View File

@@ -1,71 +1 @@
"use client";
import { User, Palette, Key, Settings, Users, FolderGit2 } from "lucide-react";
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";
import { WorkspaceTab } from "./_components/workspace-tab";
import { MembersTab } from "./_components/members-tab";
import { RepositoriesTab } from "./_components/repositories-tab";
const accountTabs = [
{ value: "profile", label: "Profile", icon: User },
{ value: "appearance", label: "Appearance", icon: Palette },
{ value: "tokens", label: "API Tokens", icon: Key },
];
const workspaceTabs = [
{ value: "workspace", label: "General", icon: Settings },
{ value: "repositories", label: "Repositories", icon: FolderGit2 },
{ value: "members", label: "Members", icon: Users },
];
export default function SettingsPage() {
const workspaceName = useWorkspaceStore((s) => s.workspace?.name);
return (
<Tabs defaultValue="profile" orientation="vertical" className="flex-1 min-h-0 gap-0">
{/* Left nav */}
<div className="w-52 shrink-0 border-r overflow-y-auto p-4">
<h1 className="text-sm font-semibold mb-4 px-2">Settings</h1>
<TabsList variant="line" className="flex-col items-stretch">
{/* My Account group */}
<span className="px-2 pb-1 pt-2 text-xs font-medium text-muted-foreground">
My Account
</span>
{accountTabs.map((tab) => (
<TabsTrigger key={tab.value} value={tab.value}>
<tab.icon className="h-4 w-4" />
{tab.label}
</TabsTrigger>
))}
{/* Workspace group */}
<span className="px-2 pb-1 pt-4 text-xs font-medium text-muted-foreground truncate">
{workspaceName ?? "Workspace"}
</span>
{workspaceTabs.map((tab) => (
<TabsTrigger key={tab.value} value={tab.value}>
<tab.icon className="h-4 w-4" />
{tab.label}
</TabsTrigger>
))}
</TabsList>
</div>
{/* Right content */}
<div className="flex-1 min-w-0 overflow-y-auto">
<div className="w-full max-w-3xl mx-auto p-6">
<TabsContent value="profile"><AccountTab /></TabsContent>
<TabsContent value="appearance"><AppearanceTab /></TabsContent>
<TabsContent value="tokens"><TokensTab /></TabsContent>
<TabsContent value="workspace"><WorkspaceTab /></TabsContent>
<TabsContent value="repositories"><RepositoriesTab /></TabsContent>
<TabsContent value="members"><MembersTab /></TabsContent>
</div>
</div>
</Tabs>
);
}
export { SettingsPage as default } from "@multica/views/settings";

View File

@@ -2,9 +2,9 @@
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 { useAuthStore } from "@multica/core/auth";
import { useWorkspaceStore } from "@multica/core/workspace";
import { api } from "@multica/core/api";
import {
Card,
CardHeader,

View File

@@ -1,41 +1,5 @@
/* =============================================================================
* Multica Web — Custom styles (non-shadcn)
* Multica Web — Custom styles (non-shadcn, web-only)
* Shared styles (shiki, entrance-spin, sidebar, sonner, scrollbar) are in
* @multica/ui/styles/base.css
* ============================================================================= */
/* Shiki dual themes: CSS-only light/dark switching via CSS variables */
/* @see https://shiki.style/guide/dual-themes */
.shiki,
.shiki span {
color: var(--shiki-light);
}
.dark .shiki,
.dark .shiki span {
color: var(--shiki-dark) !important;
}
/* Multica icon: entrance spin animation */
@keyframes entrance-spin {
0% { transform: rotate(0deg); opacity: 0; }
50% { opacity: 1; }
100% { transform: rotate(360deg); opacity: 1; }
}
.animate-entrance-spin {
animation: entrance-spin 0.6s ease-out forwards;
}
/* Sidebar: open triggers (dropdown/popover) get active background */
[data-sidebar="menu-button"][data-popup-open] {
background-color: var(--sidebar-accent);
color: var(--sidebar-accent-foreground);
}
/* Sonner toast: align icon to first line of text, not vertically centered */
[data-sonner-toast] {
align-items: flex-start !important;
}
[data-sonner-toast] [data-icon] {
margin-top: 2.5px;
}

View File

@@ -2,6 +2,7 @@
@import "tw-animate-css";
@import "shadcn/tailwind.css";
@import "../../../packages/ui/styles/tokens.css";
@import "../../../packages/ui/styles/base.css";
@import "./custom.css";
@custom-variant dark (&:is(.dark *));
@@ -9,21 +10,3 @@
@source "../../../packages/ui/**/*.{ts,tsx}";
@source "../../../packages/core/**/*.{ts,tsx}";
@source "../../../packages/views/**/*.{ts,tsx}";
@layer base {
* {
@apply border-border outline-ring/50;
scrollbar-width: thin;
scrollbar-color: var(--scrollbar-thumb) var(--scrollbar-track);
}
*::-webkit-scrollbar { width: 6px; height: 6px; }
*::-webkit-scrollbar-track { background: var(--scrollbar-track); }
*::-webkit-scrollbar-thumb { background: var(--scrollbar-thumb); border-radius: 3px; }
*::-webkit-scrollbar-thumb:hover { background: var(--scrollbar-thumb-hover); }
body {
@apply bg-background text-foreground;
}
html {
@apply font-sans;
}
}

View File

@@ -3,10 +3,7 @@ import { Geist, Geist_Mono } from "next/font/google";
import { ThemeProvider } from "@/components/theme-provider";
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 { WebWSProvider } from "@/platform/ws-provider";
import { WebNavigationProvider } from "@/platform/navigation";
import { WebProviders } from "@/components/web-providers";
import { LocaleSync } from "@/components/locale-sync";
import "./globals.css";
@@ -67,14 +64,10 @@ export default function RootLayout({
<body className="h-full overflow-hidden">
<LocaleSync />
<ThemeProvider>
<QueryProvider showDevtools={process.env.NEXT_PUBLIC_DEVTOOLS !== "false"}>
<WebNavigationProvider>
<AuthInitializer>
<WebWSProvider>{children}</WebWSProvider>
</AuthInitializer>
</WebNavigationProvider>
<Toaster />
</QueryProvider>
<WebProviders>
{children}
</WebProviders>
<Toaster />
</ThemeProvider>
</body>
</html>

View File

@@ -1,30 +0,0 @@
"use client";
import { Spinner } from "@/components/spinner";
import { cn } from "@multica/ui/lib/utils";
export type LoadingVariant = "generating" | "streaming";
interface LoadingIndicatorProps {
variant: LoadingVariant;
className?: string;
}
const VARIANT_TEXT: Record<LoadingVariant, string> = {
generating: "Generating...",
streaming: "Streaming...",
};
/**
* Unified loading indicator for chat.
* Use "generating" when waiting for AI response (no content yet).
* Use "streaming" when content is actively being received.
*/
export function LoadingIndicator({ variant, className }: LoadingIndicatorProps) {
return (
<div className={cn("flex items-center gap-2 py-1 text-muted-foreground", className)}>
<Spinner className="text-xs" />
<span className="text-xs">{VARIANT_TEXT[variant]}</span>
</div>
);
}

View File

@@ -1 +0,0 @@
export { CodeBlock, InlineCode, type CodeBlockProps } from '@multica/ui/markdown'

View File

@@ -1,43 +0,0 @@
import * as React from 'react'
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
/**
* Default renderMention that delegates to IssueMentionCard for issue mentions
* and renders a styled span for other mention types.
*/
function defaultRenderMention({ type, id }: { type: string; id: string }): React.ReactNode {
if (type === 'issue') {
return <IssueMentionCard issueId={id} />
}
return null
}
/**
* App-level Markdown wrapper that injects IssueMentionCard via renderMention.
* Callers that need custom mention rendering can pass their own renderMention prop.
*/
export function Markdown(props: MarkdownProps): React.JSX.Element {
return <MarkdownBase renderMention={defaultRenderMention} {...props} />
}
export const MemoizedMarkdown = React.memo(Markdown, (prevProps, nextProps) => {
if (prevProps.id && nextProps.id) {
return (
prevProps.id === nextProps.id &&
prevProps.children === nextProps.children &&
prevProps.mode === nextProps.mode
)
}
return prevProps.children === nextProps.children && prevProps.mode === nextProps.mode
})
MemoizedMarkdown.displayName = 'MemoizedMarkdown'

View File

@@ -1,22 +0,0 @@
import * as React from 'react'
import {
StreamingMarkdown as StreamingMarkdownBase,
type StreamingMarkdownProps as StreamingMarkdownBaseProps
} from '@multica/ui/markdown'
import { IssueMentionCard } from '@multica/views/issues/components'
export type StreamingMarkdownProps = StreamingMarkdownBaseProps
function defaultRenderMention({ type, id }: { type: string; id: string }): React.ReactNode {
if (type === 'issue') {
return <IssueMentionCard issueId={id} />
}
return null
}
/**
* App-level StreamingMarkdown wrapper that injects IssueMentionCard via renderMention.
*/
export function StreamingMarkdown(props: StreamingMarkdownProps): React.JSX.Element {
return <StreamingMarkdownBase renderMention={defaultRenderMention} {...props} />
}

View File

@@ -1,5 +0,0 @@
export { Markdown, MemoizedMarkdown, type MarkdownProps, type RenderMode } from './Markdown'
export { CodeBlock, InlineCode, type CodeBlockProps } from './CodeBlock'
export { StreamingMarkdown, type StreamingMarkdownProps } from './StreamingMarkdown'
export { preprocessLinks, detectLinks, hasLinks } from './linkify'
export { preprocessMentionShortcodes } from './mentions'

View File

@@ -1 +0,0 @@
export { preprocessLinks, detectLinks, hasLinks } from '@multica/ui/markdown'

View File

@@ -1 +0,0 @@
export { preprocessMentionShortcodes } from '@multica/ui/markdown'

View File

@@ -1,47 +0,0 @@
/**
* Spinner — 3x3 grid pulse for **active processing / execution** states.
*
* Use when the system is actively doing work or waiting for human action
* (streaming content, generating responses, awaiting approval).
* For passive content-loading states, use `<Loading />` instead.
*
* Inherits color from `currentColor` (use Tailwind `text-*`).
* Scales with font-size (use Tailwind `text-*` for size).
*/
import { cn } from "@multica/ui/lib/utils"
export interface SpinnerProps {
/** Additional className for styling (color via text-*, size via Tailwind text-*) */
className?: string
}
const DELAYS = [0.2, 0.3, 0.4, 0.1, 0.2, 0.3, 0, 0.1, 0.2]
const cubeStyle: React.CSSProperties = {
backgroundColor: "currentColor",
animation: "spinner-grid 1.3s infinite ease-in-out",
transform: "scale3d(0.5, 0.5, 1)",
}
export function Spinner({ className }: SpinnerProps) {
return (
<span
className={cn(className)}
role="status"
aria-label="Loading"
style={{
display: "inline-grid",
gridTemplateColumns: "repeat(3, 1fr)",
width: "1em",
height: "1em",
gap: "0.08em",
}}
>
{DELAYS.map((delay, i) => (
<span key={i} style={{ ...cubeStyle, animationDelay: `${delay}s` }} />
))}
<style>{`@keyframes spinner-grid{0%,70%,100%{transform:scale3d(.5,.5,1)}35%{transform:scale3d(0,0,1)}}`}</style>
</span>
)
}

View File

@@ -1,25 +1,16 @@
"use client"
import { ThemeProvider as NextThemesProvider } from "next-themes"
import { TooltipProvider } from "@multica/ui/components/ui/tooltip"
// Re-export the shared ThemeProvider from @multica/ui
export { ThemeProvider } from "@multica/ui/components/common/theme-provider"
function ThemeProvider({
children,
...props
}: React.ComponentProps<typeof NextThemesProvider>) {
return (
<NextThemesProvider
attribute="class"
defaultTheme="system"
enableSystem
disableTransitionOnChange
{...props}
>
<TooltipProvider delay={500}>
{children}
</TooltipProvider>
</NextThemesProvider>
)
// Suppress React 19 false-positive about next-themes' inline <script>.
// The script works correctly; React 19 just warns about any <script> in components.
// See: https://github.com/pacocoursey/next-themes/issues/337
if (typeof window !== "undefined" && process.env.NODE_ENV === "development") {
const orig = console.error;
console.error = (...args: unknown[]) => {
if (typeof args[0] === "string" && args[0].includes("Encountered a script tag"))
return;
orig.apply(console, args);
};
}
export { ThemeProvider }

View File

@@ -1,40 +0,0 @@
"use client"
import { useTheme } from "next-themes"
import { Sun, Moon, Monitor } from "lucide-react"
import {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
} from "@multica/ui/components/ui/dropdown-menu"
import { SidebarMenuButton } from "@multica/ui/components/ui/sidebar"
export function ThemeToggle() {
const { setTheme } = useTheme()
return (
<DropdownMenu>
<DropdownMenuTrigger
render={
<SidebarMenuButton>
<Sun className="dark:hidden" />
<Moon className="hidden dark:block" />
<span>Theme</span>
</SidebarMenuButton>
}
/>
<DropdownMenuContent side="top" align="start">
<DropdownMenuItem onClick={() => setTheme("light")}>
<Sun /> Light
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setTheme("dark")}>
<Moon /> Dark
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setTheme("system")}>
<Monitor /> System
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)
}

View File

@@ -0,0 +1,21 @@
"use client";
import { CoreProvider } from "@multica/core/platform";
import { WebNavigationProvider } from "@/platform/navigation";
import {
setLoggedInCookie,
clearLoggedInCookie,
} from "@/features/auth/auth-cookie";
export function WebProviders({ children }: { children: React.ReactNode }) {
return (
<CoreProvider
apiBaseUrl={process.env.NEXT_PUBLIC_API_URL}
wsUrl={process.env.NEXT_PUBLIC_WS_URL}
onLogin={setLoggedInCookie}
onLogout={clearLoggedInCookie}
>
<WebNavigationProvider>{children}</WebNavigationProvider>
</CoreProvider>
);
}

View File

@@ -1,19 +0,0 @@
import type { QueryClient } from "@tanstack/react-query";
import type { ChatMessage } from "@multica/core/types";
import { chatKeys } from "./queries";
export function onChatMessageCreated(
qc: QueryClient,
sessionId: string,
message: ChatMessage,
) {
qc.setQueryData<ChatMessage[]>(chatKeys.messages(sessionId), (old) => {
if (!old) return old;
if (old.some((m) => m.id === message.id)) return old;
return [...old, message];
});
}
export function onChatDone(qc: QueryClient, sessionId: string) {
qc.invalidateQueries({ queryKey: chatKeys.messages(sessionId) });
}

View File

@@ -0,0 +1,12 @@
import nextConfig from "@multica/eslint-config/next";
export default [
...nextConfig,
{ ignores: [".next/"] },
{
files: ["**/*.test.{ts,tsx}", "**/test/**/*.{ts,tsx}"],
rules: {
"react/display-name": "off",
},
},
];

View File

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

View File

@@ -1,50 +0,0 @@
"use client";
import { useEffect, type ReactNode } from "react";
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");
/**
* Initializes auth + workspace state from localStorage on mount.
* Fires getMe() and listWorkspaces() in parallel when a cached token exists.
*/
export function AuthInitializer({ children }: { children: ReactNode }) {
useEffect(() => {
const token = localStorage.getItem("multica_token");
if (!token) {
clearLoggedInCookie();
useAuthStore.setState({ isLoading: false });
return;
}
api.setToken(token);
const wsId = localStorage.getItem("multica_workspace_id");
// Fire getMe and listWorkspaces in parallel
const mePromise = api.getMe();
const wsPromise = api.listWorkspaces();
Promise.all([mePromise, wsPromise])
.then(([user, wsList]) => {
setLoggedInCookie();
useAuthStore.setState({ user, isLoading: false });
useWorkspaceStore.getState().hydrateWorkspace(wsList, wsId);
})
.catch((err) => {
logger.error("auth init failed", err);
api.setToken(null);
api.setWorkspaceId(null);
localStorage.removeItem("multica_token");
localStorage.removeItem("multica_workspace_id");
clearLoggedInCookie();
useAuthStore.setState({ user: null, isLoading: false });
});
}, []);
return <>{children}</>;
}

View File

@@ -1,70 +0,0 @@
import { create } from "zustand";
const AGENT_STORAGE_KEY = "multica:chat:selectedAgentId";
const SESSION_STORAGE_KEY = "multica:chat:activeSessionId";
function readStored(key: string): string | null {
if (typeof window === "undefined") return null;
return localStorage.getItem(key);
}
export interface ChatTimelineItem {
seq: number;
type: "tool_use" | "tool_result" | "thinking" | "text" | "error";
tool?: string;
content?: string;
input?: Record<string, unknown>;
output?: string;
}
interface ChatState {
isOpen: boolean;
isFullscreen: boolean;
activeSessionId: string | null;
pendingTaskId: string | null;
selectedAgentId: string | null;
timelineItems: ChatTimelineItem[];
setOpen: (open: boolean) => void;
toggle: () => void;
toggleFullscreen: () => void;
setActiveSession: (id: string | null) => void;
setPendingTask: (taskId: string | null) => void;
setSelectedAgentId: (id: string) => void;
addTimelineItem: (item: ChatTimelineItem) => void;
clearTimeline: () => void;
}
export const useChatStore = create<ChatState>((set) => ({
isOpen: false,
isFullscreen: false,
activeSessionId: readStored(SESSION_STORAGE_KEY),
pendingTaskId: null,
selectedAgentId: readStored(AGENT_STORAGE_KEY),
timelineItems: [],
setOpen: (open) => set({ isOpen: open, ...(open ? {} : { isFullscreen: false }) }),
toggle: () => set((s) => ({ isOpen: !s.isOpen, ...(s.isOpen ? { isFullscreen: false } : {}) })),
toggleFullscreen: () => set((s) => ({ isFullscreen: !s.isFullscreen })),
setActiveSession: (id) => {
if (id) {
localStorage.setItem(SESSION_STORAGE_KEY, id);
} else {
localStorage.removeItem(SESSION_STORAGE_KEY);
}
set({ activeSessionId: id });
},
setPendingTask: (taskId) => set({ pendingTaskId: taskId, timelineItems: [] }),
setSelectedAgentId: (id) => {
localStorage.setItem(AGENT_STORAGE_KEY, id);
set({ selectedAgentId: id });
},
addTimelineItem: (item) =>
set((s) => {
if (s.timelineItems.some((t) => t.seq === item.seq)) return s;
return {
timelineItems: [...s.timelineItems, item].sort(
(a, b) => a.seq - b.seq,
),
};
}),
clearTimeline: () => set({ timelineItems: [] }),
}));

View File

@@ -1,7 +1,7 @@
"use client";
import Link from "next/link";
import { useAuthStore } from "@/features/auth";
import { useAuthStore } from "@multica/core/auth";
import { useLocale } from "../i18n";
import { GitHubMark, githubUrl, heroButtonClassName } from "./shared";

View File

@@ -1,9 +1,9 @@
"use client";
import Link from "next/link";
import { MulticaIcon } from "@/components/multica-icon";
import { MulticaIcon } from "@multica/ui/components/common/multica-icon";
import { cn } from "@multica/ui/lib/utils";
import { useAuthStore } from "@/features/auth";
import { useAuthStore } from "@multica/core/auth";
import { XMark, GitHubMark, githubUrl, twitterUrl } from "./shared";
import { useLocale, locales, localeLabels } from "../i18n";

View File

@@ -1,11 +1,11 @@
"use client";
import Link from "next/link";
import { MulticaIcon } from "@/components/multica-icon";
import { MulticaIcon } from "@multica/ui/components/common/multica-icon";
import { cn } from "@multica/ui/lib/utils";
import { useAuthStore } from "@/features/auth";
import { useAuthStore } from "@multica/core/auth";
import { useLocale } from "../i18n";
import { GitHubMark, XMark, githubUrl, twitterUrl, headerButtonClassName } from "./shared";
import { GitHubMark, githubUrl, headerButtonClassName } from "./shared";
export function LandingHeader({
variant = "dark",
@@ -44,14 +44,6 @@ export function LandingHeader({
</Link>
<div className="flex items-center gap-2.5 sm:gap-3">
<Link
href={twitterUrl}
target="_blank"
rel="noreferrer"
className={headerButtonClassName("ghost", variant)}
>
<XMark className="size-3.5" />
</Link>
<Link
href={githubUrl}
target="_blank"

View File

@@ -2,11 +2,13 @@
import Image from "next/image";
import Link from "next/link";
import { useAuthStore } from "@/features/auth";
import { useAuthStore } from "@multica/core/auth";
import { useLocale } from "../i18n";
import {
ClaudeCodeLogo,
CodexLogo,
OpenClawLogo,
OpenCodeLogo,
GitHubMark,
githubUrl,
heroButtonClassName,
@@ -65,6 +67,14 @@ export function LandingHero() {
<CodexLogo className="size-5" />
<span className="text-[15px] font-medium">Codex</span>
</div>
<div className="flex items-center gap-2.5 text-white/80">
<OpenClawLogo className="size-5" />
<span className="text-[15px] font-medium">OpenClaw</span>
</div>
<div className="flex items-center gap-2.5 text-white/80">
<OpenCodeLogo className="size-5" />
<span className="text-[15px] font-medium">OpenCode</span>
</div>
</div>
</div>

View File

@@ -75,6 +75,81 @@ export function CodexLogo({ className }: { className?: string }) {
);
}
export function OpenClawLogo({ className }: { className?: string }) {
return (
<svg
viewBox="0 0 16 16"
aria-hidden="true"
className={className}
fill="none"
>
<g fill="#3a0a0d">
<rect x="1" y="5" width="1" height="3" />
<rect x="2" y="4" width="1" height="1" />
<rect x="2" y="8" width="1" height="1" />
<rect x="3" y="3" width="1" height="1" />
<rect x="3" y="9" width="1" height="1" />
<rect x="4" y="2" width="1" height="1" />
<rect x="4" y="10" width="1" height="1" />
<rect x="5" y="2" width="6" height="1" />
<rect x="11" y="2" width="1" height="1" />
<rect x="12" y="3" width="1" height="1" />
<rect x="12" y="9" width="1" height="1" />
<rect x="13" y="4" width="1" height="1" />
<rect x="13" y="8" width="1" height="1" />
<rect x="14" y="5" width="1" height="3" />
<rect x="5" y="11" width="6" height="1" />
<rect x="4" y="12" width="1" height="1" />
<rect x="11" y="12" width="1" height="1" />
<rect x="3" y="13" width="1" height="1" />
<rect x="12" y="13" width="1" height="1" />
<rect x="5" y="14" width="6" height="1" />
</g>
<g fill="#ff4f40">
<rect x="5" y="3" width="6" height="1" />
<rect x="4" y="4" width="8" height="1" />
<rect x="3" y="5" width="10" height="1" />
<rect x="3" y="6" width="10" height="1" />
<rect x="3" y="7" width="10" height="1" />
<rect x="4" y="8" width="8" height="1" />
<rect x="5" y="9" width="6" height="1" />
<rect x="5" y="12" width="6" height="1" />
<rect x="6" y="13" width="4" height="1" />
</g>
<g fill="#ff775f">
<rect x="1" y="6" width="2" height="1" />
<rect x="2" y="5" width="1" height="1" />
<rect x="2" y="7" width="1" height="1" />
<rect x="13" y="6" width="2" height="1" />
<rect x="13" y="5" width="1" height="1" />
<rect x="13" y="7" width="1" height="1" />
</g>
<g fill="#081016">
<rect x="6" y="5" width="1" height="1" />
<rect x="9" y="5" width="1" height="1" />
</g>
<g fill="#f5fbff">
<rect x="6" y="4" width="1" height="1" />
<rect x="9" y="4" width="1" height="1" />
</g>
</svg>
);
}
export function OpenCodeLogo({ className }: { className?: string }) {
return (
<svg
viewBox="0 0 24 24"
aria-hidden="true"
className={className}
fill="none"
>
<path d="M18 18H6V6H18V18Z" fill="#CFCECD" />
<path d="M18 3H6V18H18V3ZM24 24H0V0H24V24Z" fill="#656363" />
</svg>
);
}
export function headerButtonClassName(
tone: "ghost" | "solid",
variant: "dark" | "light" = "dark",

View File

@@ -107,7 +107,7 @@ export const en: LandingDict = {
{
title: "Auto-detection & plug-and-play",
description:
"Multica detects available CLIs like Claude Code and Codex automatically. Connect a machine, and it\u2019s ready to work.",
"Multica detects available CLIs like Claude Code, Codex, OpenClaw, and OpenCode automatically. Connect a machine, and it\u2019s ready to work.",
},
],
},
@@ -126,7 +126,7 @@ export const en: LandingDict = {
{
title: "Install the CLI & connect your machine",
description:
"Run multica login to authenticate, then multica daemon start. The daemon auto-detects Claude Code and Codex on your machine \u2014 plug in and go.",
"Run multica login to authenticate, then multica daemon start. The daemon auto-detects Claude Code, Codex, OpenClaw, and OpenCode on your machine \u2014 plug in and go.",
},
{
title: "Create your first agent",
@@ -181,7 +181,7 @@ export const en: LandingDict = {
{
question: "What coding agents does Multica support?",
answer:
"Multica currently supports Claude Code and OpenAI Codex out of the box. The daemon auto-detects whichever CLIs you have installed. More backends are on the roadmap \u2014 and since it\u2019s open source, you can add your own.",
"Multica currently supports Claude Code, Codex, OpenClaw, and OpenCode out of the box. The daemon auto-detects whichever CLIs you have installed. Since it\u2019s open source, you can also add your own backends.",
},
{
question: "Do I need to self-host, or is there a cloud version?",
@@ -190,7 +190,7 @@ export const en: LandingDict = {
},
{
question:
"How is this different from just using Claude Code or Codex directly?",
"How is this different from just using coding agents directly?",
answer:
"Coding agents are great at executing. Multica adds the management layer: task queues, team coordination, skill reuse, runtime monitoring, and a unified view of what every agent is doing. Think of it as the project manager for your agents.",
},
@@ -273,7 +273,7 @@ export const en: LandingDict = {
subtitle: "New updates and improvements to Multica.",
entries: [
{
version: "0.1.10",
version: "0.1.21",
date: "2026-04-09",
title: "Projects, Search & Monorepo",
changes: [
@@ -292,7 +292,7 @@ export const en: LandingDict = {
],
},
{
version: "0.1.9",
version: "0.1.20",
date: "2026-04-08",
title: "Sub-Issues, TanStack Query & Usage Tracking",
changes: [
@@ -310,7 +310,7 @@ export const en: LandingDict = {
],
},
{
version: "0.1.8",
version: "0.1.18",
date: "2026-04-07",
title: "OAuth, OpenClaw & Issue Loading",
changes: [
@@ -325,7 +325,7 @@ export const en: LandingDict = {
],
},
{
version: "0.1.7",
version: "0.1.17",
date: "2026-04-05",
title: "Comment Pagination & CLI Polish",
changes: [
@@ -339,7 +339,7 @@ export const en: LandingDict = {
],
},
{
version: "0.1.6",
version: "0.1.15",
date: "2026-04-03",
title: "Editor Overhaul & Agent Lifecycle",
changes: [
@@ -355,7 +355,7 @@ export const en: LandingDict = {
],
},
{
version: "0.1.5",
version: "0.1.14",
date: "2026-04-02",
title: "Mentions & Permissions",
changes: [
@@ -372,7 +372,7 @@ export const en: LandingDict = {
],
},
{
version: "0.1.4",
version: "0.1.13",
date: "2026-04-01",
title: "My Issues & i18n",
changes: [

View File

@@ -107,7 +107,7 @@ export const zh: LandingDict = {
{
title: "\u81ea\u52a8\u68c0\u6d4b\u4e0e\u5373\u63d2\u5373\u7528",
description:
"Multica \u81ea\u52a8\u68c0\u6d4b Claude Code \u548c Codex \u7b49\u53ef\u7528 CLI\u3002\u8fde\u63a5\u4e00\u53f0\u673a\u5668\uff0c\u5373\u53ef\u5f00\u59cb\u5de5\u4f5c\u3002",
"Multica \u81ea\u52a8\u68c0\u6d4b Claude Code\u3001Codex\u3001OpenClaw \u548c OpenCode \u7b49\u53ef\u7528 CLI\u3002\u8fde\u63a5\u4e00\u53f0\u673a\u5668\uff0c\u5373\u53ef\u5f00\u59cb\u5de5\u4f5c\u3002",
},
],
},
@@ -126,7 +126,7 @@ export const zh: LandingDict = {
{
title: "\u5b89\u88c5 CLI \u5e76\u8fde\u63a5\u4f60\u7684\u673a\u5668",
description:
"\u8fd0\u884c multica login \u8fdb\u884c\u8ba4\u8bc1\uff0c\u7136\u540e multica daemon start\u3002\u5b88\u62a4\u8fdb\u7a0b\u81ea\u52a8\u68c0\u6d4b\u4f60\u673a\u5668\u4e0a\u7684 Claude Code \u548c Codex\u2014\u2014\u63d2\u4e0a\u5c31\u7528\u3002",
"\u8fd0\u884c multica login \u8fdb\u884c\u8ba4\u8bc1\uff0c\u7136\u540e multica daemon start\u3002\u5b88\u62a4\u8fdb\u7a0b\u81ea\u52a8\u68c0\u6d4b\u4f60\u673a\u5668\u4e0a\u7684 Claude Code\u3001Codex\u3001OpenClaw \u548c OpenCode\u2014\u2014\u63d2\u4e0a\u5c31\u7528\u3002",
},
{
title: "\u521b\u5efa\u4f60\u7684\u7b2c\u4e00\u4e2a Agent",
@@ -181,7 +181,7 @@ export const zh: LandingDict = {
{
question: "Multica \u652f\u6301\u54ea\u4e9b\u7f16\u7801 Agent\uff1f",
answer:
"Multica \u76ee\u524d\u5f00\u7bb1\u5373\u7528\u652f\u6301 Claude Code \u548c OpenAI Codex\u3002\u5b88\u62a4\u8fdb\u7a0b\u81ea\u52a8\u68c0\u6d4b\u4f60\u5b89\u88c5\u7684 CLI\u3002\u66f4\u591a\u540e\u7aef\u5728\u8def\u7ebf\u56fe\u4e0a\u2014\u2014\u800c\u4e14\u56e0\u4e3a\u5f00\u6e90\uff0c\u4f60\u4e5f\u53ef\u4ee5\u81ea\u5df1\u6dfb\u52a0\u3002",
"Multica \u76ee\u524d\u5f00\u7bb1\u5373\u7528\u652f\u6301 Claude Code\u3001Codex\u3001OpenClaw \u548c OpenCode\u3002\u5b88\u62a4\u8fdb\u7a0b\u81ea\u52a8\u68c0\u6d4b\u4f60\u5b89\u88c5\u7684 CLI\u3002\u56e0\u4e3a\u5f00\u6e90\uff0c\u4f60\u4e5f\u53ef\u4ee5\u81ea\u5df1\u6dfb\u52a0\u540e\u7aef\u3002",
},
{
question: "\u9700\u8981\u81ea\u6258\u7ba1\u5417\uff0c\u8fd8\u662f\u6709\u4e91\u7248\u672c\uff1f",
@@ -190,7 +190,7 @@ export const zh: LandingDict = {
},
{
question:
"\u8fd9\u548c\u76f4\u63a5\u7528 Claude Code \u6216 Codex \u6709\u4ec0\u4e48\u533a\u522b\uff1f",
"\u8fd9\u548c\u76f4\u63a5\u7528\u7f16\u7801 Agent \u6709\u4ec0\u4e48\u533a\u522b\uff1f",
answer:
"\u7f16\u7801 Agent \u64c5\u957f\u6267\u884c\u3002Multica \u6dfb\u52a0\u7684\u662f\u7ba1\u7406\u5c42\uff1a\u4efb\u52a1\u961f\u5217\u3001\u56e2\u961f\u534f\u4f5c\u3001\u6280\u80fd\u590d\u7528\u3001\u8fd0\u884c\u65f6\u76d1\u63a7\uff0c\u4ee5\u53ca\u6bcf\u4e2a Agent \u5728\u505a\u4ec0\u4e48\u7684\u7edf\u4e00\u89c6\u56fe\u3002\u628a\u5b83\u60f3\u8c61\u6210\u4f60\u7684 Agent \u7684\u9879\u76ee\u7ecf\u7406\u3002",
},
@@ -273,7 +273,7 @@ export const zh: LandingDict = {
subtitle: "Multica \u7684\u6700\u65b0\u66f4\u65b0\u548c\u6539\u8fdb\u3002",
entries: [
{
version: "0.1.10",
version: "0.1.21",
date: "2026-04-09",
title: "项目、搜索与 Monorepo",
changes: [
@@ -292,7 +292,7 @@ export const zh: LandingDict = {
],
},
{
version: "0.1.9",
version: "0.1.20",
date: "2026-04-08",
title: "子 Issue、TanStack Query 与用量追踪",
changes: [
@@ -310,7 +310,7 @@ export const zh: LandingDict = {
],
},
{
version: "0.1.8",
version: "0.1.18",
date: "2026-04-07",
title: "OAuth、OpenClaw 与 Issue 加载优化",
changes: [
@@ -325,7 +325,7 @@ export const zh: LandingDict = {
],
},
{
version: "0.1.7",
version: "0.1.17",
date: "2026-04-05",
title: "评论分页与 CLI 优化",
changes: [
@@ -339,7 +339,7 @@ export const zh: LandingDict = {
],
},
{
version: "0.1.6",
version: "0.1.15",
date: "2026-04-03",
title: "编辑器重构与 Agent 生命周期",
changes: [
@@ -355,7 +355,7 @@ export const zh: LandingDict = {
],
},
{
version: "0.1.5",
version: "0.1.14",
date: "2026-04-02",
title: "提及与权限",
changes: [
@@ -372,7 +372,7 @@ export const zh: LandingDict = {
],
},
{
version: "0.1.4",
version: "0.1.13",
date: "2026-04-01",
title: "\u6211\u7684 Issue \u4e0e\u56fd\u9645\u5316",
changes: [

View File

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

View File

@@ -8,7 +8,7 @@
"build": "next build",
"start": "next start",
"typecheck": "tsc --noEmit",
"lint": "next lint",
"lint": "eslint .",
"test": "vitest run"
},
"dependencies": {
@@ -69,15 +69,15 @@
},
"devDependencies": {
"@tailwindcss/postcss": "catalog:",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.1",
"@testing-library/jest-dom": "catalog:",
"@testing-library/react": "catalog:",
"@testing-library/user-event": "catalog:",
"@types/react": "catalog:",
"@types/react-dom": "catalog:",
"@vitejs/plugin-react": "^6.0.1",
"jsdom": "^29.0.1",
"@vitejs/plugin-react": "catalog:",
"jsdom": "catalog:",
"tailwindcss": "catalog:",
"typescript": "catalog:",
"vitest": "^4.1.0"
"vitest": "catalog:"
}
}

View File

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

View File

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

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

View File

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

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

@@ -66,7 +66,7 @@ export const mockAgents: Agent[] = [
];
// Mock auth context value
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export const mockAuthValue: Record<string, any> = {
user: mockUser,
workspace: mockWorkspace,

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,868 @@
# Monorepo Full Extraction Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
**Goal:** 让每个 app 只剩路由定义 + NavigationAdapter + 真正独有的功能landing page、title bar、cookie。所有业务逻辑、UI、状态管理、API、WS 全部在共享包里,零重复。
**核心洞察:** Electron renderer 就是浏览器。localStorage、fetch、WebSocket 和 Next.js 客户端页面完全一样。URL 是环境配置不是 app 差异。所以除了 NavigationAdapter路由框架不同没有任何东西需要在每个 app 里单独写。
**Architecture:** `@multica/core` 自带完整初始化API、stores、WS不需要每个 app 调用 factory。`@multica/views` 包含所有页面和 layout。每个 app 只提供路由壳子。
**Tech Stack:** React 19, TanStack Query, Zustand, Tailwind CSS v4, shadcn/ui, TypeScript strict mode.
**Branch:** `feat/monorepo-extraction` (from latest `feat/desktop-app`)
---
## Work Breakdown
| Phase | Tasks | What it achieves |
|---|---|---|
| Phase 1: Core 自包含初始化 | 1-2 | core 自己初始化 API/stores/WSapp 不需要写任何 platform 代码 |
| Phase 2: Sidebar & Layout | 3-5 | 共享 AppSidebar + DashboardLayout删除两端重复 |
| Phase 3: Login | 6-7 | 共享 LoginPage + AuthInitializer |
| Phase 4: Agents | 8-10 | 1,279 行 → 共享模块 |
| Phase 5: Inbox | 11-13 | 468 行 → 共享模块 |
| Phase 6: Settings | 14-16 | 1,277 行 → 共享模块 |
| Phase 7: 清理 | 17-18 | 删除所有 platform 目录、placeholder、死代码 |
---
## Phase 1: Core 自包含初始化
### 设计思路
现在每个 app 都要手动调用 `new ApiClient()``createAuthStore()``createWorkspaceStore()`、包 `<WSProvider>`。但这些逻辑在两个 app 里完全一样。
方案:`@multica/core` 导出一个 `<CoreProvider>` 包裹整个应用。它内部自动完成所有初始化。配置通过环境变量(`VITE_API_URL` / `NEXT_PUBLIC_API_URL`)或 prop 注入。SSR-safe 的 localStorage wrapper 内置到 core 里作为默认 storage`typeof window` 守卫对 Electron 无害)。
```tsx
// 任何 app 的根组件,只需要这样:
<CoreProvider
apiBaseUrl={import.meta.env.VITE_API_URL ?? ""}
wsUrl={import.meta.env.VITE_WS_URL ?? "ws://localhost:8080/ws"}
onLogin={setLoggedInCookie} // 可选Web 独有
onLogout={clearLoggedInCookie} // 可选Web 独有
>
{children}
</CoreProvider>
```
Desktop 更简单(没有可选回调):
```tsx
<CoreProvider
apiBaseUrl={import.meta.env.VITE_API_URL ?? "http://localhost:8080"}
wsUrl={import.meta.env.VITE_WS_URL ?? "ws://localhost:8080/ws"}
>
{children}
</CoreProvider>
```
### Task 1: 在 `@multica/core` 里创建 CoreProvider
**Files:**
- Create: `packages/core/platform/storage.ts` — 内置 SSR-safe localStorage
- Create: `packages/core/platform/core-provider.tsx` — CoreProvider 组件
- Create: `packages/core/platform/auth-initializer.tsx` — 共享 AuthInitializer
- Create: `packages/core/platform/types.ts` — CoreProviderProps
- Create: `packages/core/platform/index.ts` — barrel export
- Modify: `packages/core/package.json` — add `"./platform"` export
**Step 1: Create built-in SSR-safe storage**
```typescript
// packages/core/platform/storage.ts
import type { StorageAdapter } from "../types/storage";
/** SSR-safe localStorage. Works in both Next.js (SSR) and Electron (always client). */
export const defaultStorage: 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); },
};
```
**Step 2: Create types**
```typescript
// packages/core/platform/types.ts
export interface CoreProviderProps {
children: React.ReactNode;
/** API base URL. Default: "" (same-origin). */
apiBaseUrl?: string;
/** WebSocket URL. Default: "ws://localhost:8080/ws". */
wsUrl?: string;
/** Called after successful login (e.g. set cookie for Next.js middleware). */
onLogin?: () => void;
/** Called after logout (e.g. clear cookie). */
onLogout?: () => void;
}
```
**Step 3: Create AuthInitializer**
Merge the identical logic from both apps. Uses `defaultStorage`, reads from existing singletons.
```typescript
// packages/core/platform/auth-initializer.tsx
import { useEffect, type ReactNode } from "react";
import { getApi } from "../api";
import { useAuthStore } from "../auth";
import { useWorkspaceStore } from "../workspace";
import { createLogger } from "../logger";
import { defaultStorage } from "./storage";
const logger = createLogger("auth");
export function AuthInitializer({
children,
onLogin,
onLogout,
}: {
children: ReactNode;
onLogin?: () => void;
onLogout?: () => void;
}) {
useEffect(() => {
const token = defaultStorage.getItem("multica_token");
if (!token) {
onLogout?.();
useAuthStore.setState({ isLoading: false });
return;
}
const api = getApi();
api.setToken(token);
const wsId = defaultStorage.getItem("multica_workspace_id");
Promise.all([api.getMe(), api.listWorkspaces()])
.then(([user, wsList]) => {
onLogin?.();
useAuthStore.setState({ user, isLoading: false });
useWorkspaceStore.getState().hydrateWorkspace(wsList, wsId);
})
.catch((err) => {
logger.error("auth init failed", err);
api.setToken(null);
api.setWorkspaceId(null);
defaultStorage.removeItem("multica_token");
defaultStorage.removeItem("multica_workspace_id");
onLogout?.();
useAuthStore.setState({ user: null, isLoading: false });
});
}, []);
return <>{children}</>;
}
```
**Step 4: Create CoreProvider**
This is the one component that wires everything together. Each app wraps its root with this.
```typescript
// packages/core/platform/core-provider.tsx
"use client";
import { type ReactNode, useMemo } from "react";
import { ApiClient } from "../api/client";
import { setApiInstance } from "../api";
import { createAuthStore, registerAuthStore } from "../auth";
import { createWorkspaceStore, registerWorkspaceStore } from "../workspace";
import { WSProvider } from "../realtime";
import { QueryProvider } from "../provider";
import { createLogger } from "../logger";
import { defaultStorage } from "./storage";
import { AuthInitializer } from "./auth-initializer";
import type { CoreProviderProps } from "./types";
// Module-level singletons — created once, shared across renders.
let initialized = false;
let authStore: ReturnType<typeof createAuthStore>;
let workspaceStore: ReturnType<typeof createWorkspaceStore>;
function initCore(apiBaseUrl: string) {
if (initialized) return;
const api = new ApiClient(apiBaseUrl, {
logger: createLogger("api"),
onUnauthorized: () => {
defaultStorage.removeItem("multica_token");
defaultStorage.removeItem("multica_workspace_id");
},
});
setApiInstance(api);
// Hydrate token from storage
const token = defaultStorage.getItem("multica_token");
if (token) api.setToken(token);
const wsId = defaultStorage.getItem("multica_workspace_id");
if (wsId) api.setWorkspaceId(wsId);
authStore = createAuthStore({ api, storage: defaultStorage });
registerAuthStore(authStore);
workspaceStore = createWorkspaceStore(api, {
storage: defaultStorage,
});
registerWorkspaceStore(workspaceStore);
initialized = true;
}
export function CoreProvider({
children,
apiBaseUrl = "",
wsUrl = "ws://localhost:8080/ws",
onLogin,
onLogout,
}: CoreProviderProps) {
// Initialize singletons on first render
useMemo(() => initCore(apiBaseUrl), [apiBaseUrl]);
return (
<QueryProvider>
<AuthInitializer onLogin={onLogin} onLogout={onLogout}>
<WSProvider
wsUrl={wsUrl}
authStore={authStore}
workspaceStore={workspaceStore}
storage={defaultStorage}
>
{children}
</WSProvider>
</AuthInitializer>
</QueryProvider>
);
}
```
**Step 5: Barrel export + package.json**
```typescript
// packages/core/platform/index.ts
export { CoreProvider } from "./core-provider";
export type { CoreProviderProps } from "./types";
export { AuthInitializer } from "./auth-initializer";
export { defaultStorage } from "./storage";
```
Add to `packages/core/package.json` exports:
```json
"./platform": "./platform/index.ts"
```
**Step 6: Run typecheck**
Run: `pnpm typecheck`
Expected: PASS
**Step 7: Commit**
```bash
git add packages/core/platform/ packages/core/package.json
git commit -m "feat(core): add CoreProvider — single component for full app initialization"
```
---
### Task 2: Migrate both apps to CoreProvider
**Files:**
- Modify: `apps/web/app/layout.tsx` — replace all providers with `<CoreProvider>`
- Modify: `apps/desktop/src/renderer/src/App.tsx` — replace all providers with `<CoreProvider>`
- Delete: `apps/web/platform/api.ts`
- Delete: `apps/web/platform/auth.ts`
- Delete: `apps/web/platform/workspace.ts`
- Delete: `apps/web/platform/storage.ts`
- Delete: `apps/web/platform/ws-provider.tsx`
- Delete: `apps/web/features/auth/initializer.tsx`
- Delete: `apps/desktop/src/renderer/src/platform/api.ts`
- Delete: `apps/desktop/src/renderer/src/platform/auth.ts`
- Delete: `apps/desktop/src/renderer/src/platform/workspace.ts`
- Delete: `apps/desktop/src/renderer/src/platform/storage.ts`
- Delete: `apps/desktop/src/renderer/src/platform/ws-provider.tsx`
- Delete: `apps/desktop/src/renderer/src/platform/auth-initializer.tsx`
- Keep: `apps/web/platform/navigation.tsx` — NavigationAdapter (唯一不可共享)
- Keep: `apps/desktop/src/renderer/src/platform/navigation.tsx` — NavigationAdapter
- Keep: `apps/web/features/auth/auth-cookie.ts` — Web 独有
**Step 1: Update web root layout**
```typescript
// apps/web/app/layout.tsx
import { CoreProvider } from "@multica/core/platform";
import { WebNavigationProvider } from "@/platform/navigation";
import { setLoggedInCookie, clearLoggedInCookie } from "@/features/auth/auth-cookie";
import { ThemeProvider } from "@/components/theme-provider";
import { Toaster } from "sonner";
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en" suppressHydrationWarning>
<body>
<ThemeProvider>
<CoreProvider
apiBaseUrl={process.env.NEXT_PUBLIC_API_URL}
wsUrl={process.env.NEXT_PUBLIC_WS_URL}
onLogin={setLoggedInCookie}
onLogout={clearLoggedInCookie}
>
<WebNavigationProvider>
{children}
</WebNavigationProvider>
</CoreProvider>
<Toaster />
</ThemeProvider>
</body>
</html>
);
}
```
**Step 2: Update desktop App.tsx**
```typescript
// apps/desktop/src/renderer/src/App.tsx
import { RouterProvider } from "react-router-dom";
import { CoreProvider } from "@multica/core/platform";
import { ThemeProvider } from "./components/theme-provider";
import { Toaster } from "sonner";
import { router } from "./router";
export function App() {
return (
<ThemeProvider>
<CoreProvider
apiBaseUrl={import.meta.env.VITE_API_URL}
wsUrl={import.meta.env.VITE_WS_URL}
>
<RouterProvider router={router} />
</CoreProvider>
<Toaster />
</ThemeProvider>
);
}
```
**Step 3: Fix all `@/platform/*` imports across both apps**
Search all files for:
- `from "@/platform/api"``from "@multica/core/api"` (use singleton proxy `api`)
- `from "@/platform/auth"``from "@multica/core/auth"` (use singleton `useAuthStore`)
- `from "@/platform/workspace"``from "@multica/core/workspace"` (use singleton `useWorkspaceStore`)
These singletons already exist and are registered by CoreProvider on init. Every component can import them directly from core.
**Step 4: Delete all platform files except navigation**
Web — delete entire `apps/web/platform/` except `navigation.tsx`. Flatten:
```
apps/web/platform/navigation.tsx → keep (only file left)
```
Desktop — delete entire `apps/desktop/.../platform/` except `navigation.tsx`. Flatten:
```
apps/desktop/.../platform/navigation.tsx → keep (only file left)
```
**Step 5: Run typecheck + tests**
Run: `pnpm typecheck && pnpm test`
Expected: PASS
**Step 6: Commit**
```bash
git commit -m "refactor: migrate both apps to CoreProvider — delete all platform duplication"
```
---
## Phase 2: Sidebar & Layout
### Task 3: Extract `AppSidebar` to `@multica/views/layout`
**Why:** Web and Desktop sidebars are 99% identical (239 vs 236 lines). Only difference: `Link`/`usePathname`/`useRouter` (web) vs `AppLink`/`useNavigation` (desktop). Since `useNavigation` + `AppLink` is the abstraction in views, the desktop version is already the correct shared version.
**Files:**
- Create: `packages/views/layout/app-sidebar.tsx` — copy from desktop version
- Create: `packages/views/layout/index.ts`
- Modify: `packages/views/package.json` (add `"./layout"` export)
- Modify: `apps/web/app/(dashboard)/layout.tsx` — import from views
- Modify: `apps/desktop/src/renderer/src/components/dashboard-shell.tsx` — import from views
- Delete: `apps/web/app/(dashboard)/_components/app-sidebar.tsx`
- Delete: `apps/desktop/src/renderer/src/components/app-sidebar.tsx`
**Step 1: Create shared AppSidebar**
Copy desktop `app-sidebar.tsx` into `packages/views/layout/app-sidebar.tsx`. Key changes:
- `import { useAuthStore } from "@multica/core/auth"` (singleton)
- `import { useWorkspaceStore } from "@multica/core/workspace"` (singleton)
- `import { api } from "@multica/core/api"` (singleton proxy)
- `import { useNavigation, AppLink } from "../navigation"` (relative within views)
- `import { useModalStore } from "@multica/core/modals"`
- All `@multica/ui` imports unchanged
**Step 2: Barrel export + package.json**
```typescript
// packages/views/layout/index.ts
export { AppSidebar } from "./app-sidebar";
```
Add to `packages/views/package.json`:
```json
"./layout": "./layout/index.ts"
```
**Step 3: Update both apps, delete old files**
**Step 4: Run typecheck**
Run: `pnpm typecheck`
**Step 5: Commit**
```bash
git commit -m "refactor(views): extract shared AppSidebar to @multica/views/layout"
```
---
### Task 4: Extract `DashboardLayout` to `@multica/views/layout`
**Why:** Both apps have identical dashboard shell: auth guard → loading → sidebar + workspace provider + content. Only differences: web has `SearchCommand`, desktop has `TitleBar`. These are slots.
**Files:**
- Create: `packages/views/layout/dashboard-layout.tsx`
- Modify: `packages/views/layout/index.ts` (add export)
- Modify: `apps/web/app/(dashboard)/layout.tsx` (~10 lines after)
- Modify: `apps/desktop/src/renderer/src/components/dashboard-shell.tsx` (~10 lines after)
**Step 1: Create shared DashboardLayout**
```typescript
// packages/views/layout/dashboard-layout.tsx
"use client";
import { useEffect, type ReactNode } from "react";
import { useNavigationStore } from "@multica/core/navigation";
import { SidebarProvider, SidebarInset } from "@multica/ui/components/ui/sidebar";
import { WorkspaceIdProvider } from "@multica/core/hooks";
import { useAuthStore } from "@multica/core/auth";
import { useWorkspaceStore } from "@multica/core/workspace";
import { ModalRegistry } from "../modals/registry";
import { useNavigation } from "../navigation";
import { AppSidebar } from "./app-sidebar";
interface DashboardLayoutProps {
children: ReactNode;
/** Above sidebar (e.g. desktop TitleBar) */
header?: ReactNode;
/** Sibling of SidebarInset (e.g. web SearchCommand) */
extra?: ReactNode;
/** Loading indicator */
loadingIndicator?: ReactNode;
/** Redirect path when not authenticated. Default: "/" */
loginPath?: string;
}
export function DashboardLayout({
children, header, extra, loadingIndicator, loginPath = "/",
}: DashboardLayoutProps) {
const { pathname, push } = useNavigation();
const user = useAuthStore((s) => s.user);
const isLoading = useAuthStore((s) => s.isLoading);
const workspace = useWorkspaceStore((s) => s.workspace);
useEffect(() => {
if (!isLoading && !user) push(loginPath);
}, [user, isLoading, push, loginPath]);
useEffect(() => {
useNavigationStore.getState().onPathChange(pathname);
}, [pathname]);
if (isLoading) {
return (
<div className="flex h-screen flex-col">
{header}
<div className="flex flex-1 items-center justify-center">
{loadingIndicator}
</div>
</div>
);
}
if (!user) return null;
return (
<div className="flex h-screen flex-col">
{header}
<div className="flex flex-1 min-h-0">
<SidebarProvider className="flex-1">
<AppSidebar />
<SidebarInset className="overflow-hidden">
{workspace ? (
<WorkspaceIdProvider wsId={workspace.id}>
{children}
<ModalRegistry />
</WorkspaceIdProvider>
) : (
<div className="flex flex-1 items-center justify-center">
{loadingIndicator}
</div>
)}
</SidebarInset>
{extra}
</SidebarProvider>
</div>
</div>
);
}
```
**Step 2: Slim down web layout**
```typescript
// apps/web/app/(dashboard)/layout.tsx
"use client";
import { DashboardLayout } from "@multica/views/layout";
import { MulticaIcon } from "@/components/multica-icon";
import { SearchCommand } from "@/features/search";
export default function Layout({ children }: { children: React.ReactNode }) {
return (
<DashboardLayout
loadingIndicator={<MulticaIcon className="size-6" />}
extra={<SearchCommand />}
>
{children}
</DashboardLayout>
);
}
```
**Step 3: Slim down desktop shell**
```typescript
// apps/desktop/src/renderer/src/components/dashboard-shell.tsx
import { Outlet } from "react-router-dom";
import { DesktopNavigationProvider } from "@/platform/navigation";
import { DashboardLayout } from "@multica/views/layout";
import { TitleBar } from "./title-bar";
import { MulticaIcon } from "./multica-icon";
export function DashboardShell() {
return (
<DesktopNavigationProvider>
<DashboardLayout
header={<TitleBar />}
loginPath="/login"
loadingIndicator={<MulticaIcon className="size-6" />}
>
<Outlet />
</DashboardLayout>
</DesktopNavigationProvider>
);
}
```
**Step 4: Run typecheck**
Run: `pnpm typecheck`
**Step 5: Commit**
```bash
git commit -m "refactor(views): extract shared DashboardLayout to @multica/views/layout"
```
---
### Task 5: Build + smoke test
Run: `pnpm build && make check`
Fix any issues, commit:
```bash
git commit -m "fix: fixups from layout extraction"
```
---
## Phase 3: Shared Login Page
### Task 6: Extract `LoginPage` to `@multica/views/auth`
**Why:** Desktop login (139 lines) is a simple email/code form. Web login (393 lines) has extra: CLI callback, Google OAuth, OTP component. Strategy: extract the core email/code form to views. Desktop uses it directly. Web keeps its own richer version (too different to merge).
**Files:**
- Create: `packages/views/auth/login-page.tsx`
- Create: `packages/views/auth/index.ts`
- Modify: `packages/views/package.json` (add `"./auth"` export)
- Modify: `apps/desktop/src/renderer/src/pages/login.tsx` (~10 lines after)
**Step 1: Create shared LoginPage**
Props: `logo?: ReactNode`, `onSuccess: () => void`. Internally uses `useAuthStore`/`useWorkspaceStore`/`api` from core singletons.
**Step 2: Update desktop login**
```typescript
import { useNavigate } from "react-router-dom";
import { LoginPage } from "@multica/views/auth";
import { MulticaIcon } from "../components/multica-icon";
import { TitleBar } from "../components/title-bar";
export function DesktopLoginPage() {
const navigate = useNavigate();
return (
<div className="flex h-screen flex-col">
<TitleBar />
<LoginPage
logo={<MulticaIcon bordered size="lg" />}
onSuccess={() => navigate("/issues", { replace: true })}
/>
</div>
);
}
```
Web login stays as-is (CLI callback + Google OAuth = web-only features).
**Step 3: Run typecheck**
**Step 4: Commit**
```bash
git commit -m "feat(views): extract shared LoginPage to @multica/views/auth"
```
---
### Task 7: Verify login flow in both apps
Run: `pnpm typecheck && pnpm test`
---
## Phase 4: Extract Agents Page (1,279 lines → shared module)
### Task 8: Create `@multica/views/agents`
**Files:**
- Create: `packages/views/agents/config.ts` — statusConfig, taskStatusConfig
- Create: `packages/views/agents/components/agents-page.tsx` — main page
- Create: `packages/views/agents/components/create-agent-dialog.tsx`
- Create: `packages/views/agents/components/agent-list-item.tsx`
- Create: `packages/views/agents/components/agent-detail.tsx`
- Create: `packages/views/agents/components/tabs/instructions-tab.tsx`
- Create: `packages/views/agents/components/tabs/skills-tab.tsx`
- Create: `packages/views/agents/components/tabs/tasks-tab.tsx`
- Create: `packages/views/agents/components/tabs/settings-tab.tsx`
- Create: `packages/views/agents/components/index.ts`
- Create: `packages/views/agents/index.ts`
- Modify: `packages/views/package.json` (add `"./agents"` export)
**Key migration:** All `@/platform/*` imports → `@multica/core/*` singletons. All `@multica/ui` and `@multica/core` imports stay as-is. `@multica/views` imports become relative.
**Step 1:** Extract config → components → barrel
**Step 2:** Run `pnpm typecheck`
**Step 3:** Commit
```bash
git commit -m "feat(views): extract agents page to @multica/views/agents"
```
---
### Task 9: Wire web agents route
```typescript
// apps/web/app/(dashboard)/agents/page.tsx — 1 line replaces 1,279
export { AgentsPage as default } from "@multica/views/agents";
```
Commit: `refactor(web): replace agents page with @multica/views/agents import`
---
### Task 10: Wire desktop agents route
```typescript
// router.tsx
import { AgentsPage } from "@multica/views/agents";
{ path: "agents", element: <AgentsPage /> },
```
Commit: `feat(desktop): wire agents page from @multica/views`
---
## Phase 5: Extract Inbox Page (468 lines → shared module)
### Task 11: Create `@multica/views/inbox`
**Files:**
- Create: `packages/views/inbox/components/inbox-page.tsx`
- Create: `packages/views/inbox/components/inbox-list-item.tsx`
- Create: `packages/views/inbox/components/inbox-detail-label.tsx`
- Create: `packages/views/inbox/components/index.ts`
- Create: `packages/views/inbox/index.ts`
- Modify: `packages/views/package.json` (add `"./inbox"` export)
**Key migration:**
- `import { useSearchParams } from "next/navigation"``import { useNavigation } from "../navigation"` — use `searchParams` from adapter
- `window.history.replaceState(null, "", url)``replace(url)` from `useNavigation()`
- `@/platform/*``@multica/core/*` singletons
Commit: `feat(views): extract inbox page to @multica/views/inbox`
---
### Task 12: Wire web inbox route
```typescript
// apps/web/app/(dashboard)/inbox/page.tsx — 1 line replaces 468
export { InboxPage as default } from "@multica/views/inbox";
```
Commit: `refactor(web): replace inbox page with @multica/views/inbox import`
---
### Task 13: Wire desktop inbox route
```typescript
import { InboxPage } from "@multica/views/inbox";
{ path: "inbox", element: <InboxPage /> },
```
Commit: `feat(desktop): wire inbox page from @multica/views`
---
## Phase 6: Extract Settings Page (1,277 lines → shared module)
### Task 14: Create `@multica/views/settings`
**Files:**
- Create: `packages/views/settings/components/settings-page.tsx`
- Create: `packages/views/settings/components/account-tab.tsx`
- Create: `packages/views/settings/components/appearance-tab.tsx`
- Create: `packages/views/settings/components/tokens-tab.tsx`
- Create: `packages/views/settings/components/workspace-tab.tsx`
- Create: `packages/views/settings/components/members-tab.tsx`
- Create: `packages/views/settings/components/repositories-tab.tsx`
- Create: `packages/views/settings/components/index.ts`
- Create: `packages/views/settings/index.ts`
- Modify: `packages/views/package.json` (add `"./settings"` export)
**Key migration:** Same pattern — `@/platform/*``@multica/core/*` singletons.
Commit: `feat(views): extract settings page to @multica/views/settings`
---
### Task 15: Wire web settings route
```typescript
// apps/web/app/(dashboard)/settings/page.tsx — 1 line replaces 1,277 (page + 6 tabs)
export { SettingsPage as default } from "@multica/views/settings";
```
Delete `apps/web/app/(dashboard)/settings/_components/` (all 6 files).
Commit: `refactor(web): replace settings page with @multica/views/settings import`
---
### Task 16: Wire desktop settings route
```typescript
import { SettingsPage } from "@multica/views/settings";
{ path: "settings", element: <SettingsPage /> },
```
Commit: `feat(desktop): wire settings page from @multica/views`
---
## Phase 7: Cleanup
### Task 17: Delete dead code
- Delete `apps/desktop/src/renderer/src/pages/placeholder.tsx`
- Delete `apps/web/platform/` directory entirely (only `navigation.tsx` remains — move to `apps/web/app/` or `apps/web/lib/`)
- Delete `apps/desktop/src/renderer/src/platform/` directory (only `navigation.tsx` remains — move)
- Remove unused imports across both apps
- Clean up `apps/web/features/auth/` — only `auth-cookie.ts` should remain
Commit: `chore: delete dead platform code after monorepo extraction`
---
### Task 18: Full verification
Run: `make check`
Expected: ALL PASS
---
## Final Architecture
### Each app after extraction
```
apps/web/
├── app/
│ ├── layout.tsx # CoreProvider + WebNavigationProvider + ThemeProvider
│ ├── (auth)/login/page.tsx # Web 独有CLI callback, Google OAuth
│ ├── (dashboard)/
│ │ ├── layout.tsx # DashboardLayout + SearchCommand (10 行)
│ │ ├── issues/page.tsx # 1 行 re-export
│ │ ├── agents/page.tsx # 1 行 re-export
│ │ ├── inbox/page.tsx # 1 行 re-export
│ │ ├── settings/page.tsx # 1 行 re-export
│ │ └── ... (all 1-line)
│ └── (landing)/ # Web 独有
├── lib/
│ └── navigation.tsx # WebNavigationProvider唯一平台代码
├── features/
│ ├── auth/auth-cookie.ts # Web 独有
│ ├── landing/ # Web 独有
│ └── search/ # Web 独有
└── components/ # theme, icon, loading (少量)
apps/desktop/
├── src/main/ # Electron 主进程
├── src/preload/ # preload bridge
├── src/renderer/src/
│ ├── App.tsx # CoreProvider + RouterProvider + ThemeProvider
│ ├── router.tsx # 路由表(全部 @multica/views/*
│ ├── lib/
│ │ └── navigation.tsx # DesktopNavigationProvider唯一平台代码
│ ├── components/
│ │ ├── dashboard-shell.tsx # DashboardLayout + TitleBar (10 行)
│ │ ├── title-bar.tsx # Desktop 独有
│ │ └── multica-icon.tsx # Desktop 独有
│ └── pages/
│ └── login.tsx # LoginPage + TitleBar (10 行)
```
### 数字对比
| 指标 | 之前 | 之后 |
|------|------|------|
| Web platform 文件 | 6 个 | 1 个 (navigation.tsx) |
| Desktop platform 文件 | 7 个 | 1 个 (navigation.tsx) |
| Web agents/page.tsx | 1,279 行 | 1 行 |
| Web inbox/page.tsx | 468 行 | 1 行 |
| Web settings/ 总计 | 1,277 行 | 1 行 |
| Web sidebar | 239 行 | 0 (共享) |
| Desktop sidebar | 236 行 (重复) | 0 (共享) |
| Desktop placeholders | 3 个 | 0 |
| 共享 views 模块 | 7 个 | 12 个 |
| 两端重复代码 | ~1,500 行 | 0 行 |

View File

@@ -5,16 +5,19 @@
"type": "module",
"scripts": {
"dev:web": "turbo dev --filter=@multica/web",
"dev:desktop": "turbo dev --filter=@multica/desktop",
"build": "turbo build",
"typecheck": "turbo typecheck",
"test": "turbo test",
"lint": "turbo lint",
"clean": "turbo clean && rm -rf node_modules"
"clean": "turbo clean && rm -rf node_modules",
"ui:add": "cd packages/ui && npx shadcn@latest add"
},
"packageManager": "pnpm@10.28.2",
"pnpm": {
"onlyBuiltDependencies": [
"esbuild"
"esbuild",
"electron"
],
"overrides": {
"@types/react": "catalog:",

View File

@@ -30,6 +30,7 @@ import type {
CreatePersonalAccessTokenRequest,
CreatePersonalAccessTokenResponse,
RuntimeUsage,
IssueUsageSummary,
RuntimeHourlyActivity,
RuntimePing,
RuntimeUpdate,
@@ -418,6 +419,10 @@ export class ApiClient {
return this.fetch(`/api/issues/${issueId}/task-runs`);
}
async getIssueUsage(issueId: string): Promise<IssueUsageSummary> {
return this.fetch(`/api/issues/${issueId}/usage`);
}
async cancelTask(issueId: string, taskId: string): Promise<AgentTask> {
return this.fetch(`/api/issues/${issueId}/tasks/${taskId}/cancel`, {
method: "POST",
@@ -608,8 +613,9 @@ export class ApiClient {
}
// Chat Sessions
async listChatSessions(): Promise<ChatSession[]> {
return this.fetch("/api/chat/sessions");
async listChatSessions(params?: { status?: string }): Promise<ChatSession[]> {
const query = params?.status ? `?status=${params.status}` : "";
return this.fetch(`/api/chat/sessions${query}`);
}
async getChatSession(id: string): Promise<ChatSession> {

View File

@@ -0,0 +1,38 @@
export { createChatStore } from "./store";
export type { ChatStoreOptions, ChatState, ChatTimelineItem } from "./store";
import type { createChatStore as CreateChatStoreFn } from "./store";
type ChatStoreInstance = ReturnType<typeof CreateChatStoreFn>;
/** Module-level singleton — set once at app boot via `registerChatStore()`. */
let _store: ChatStoreInstance | null = null;
/**
* Register the chat store instance created by the app.
* Must be called at boot before any component renders.
*/
export function registerChatStore(store: ChatStoreInstance) {
_store = store;
}
/**
* Singleton accessor — a Zustand hook backed by the registered instance.
* Supports `useChatStore(selector)` and `useChatStore.getState()`.
*/
export const useChatStore: ChatStoreInstance = new Proxy(
(() => {}) as unknown as ChatStoreInstance,
{
apply(_target, _thisArg, args) {
if (!_store)
throw new Error(
"Chat store not initialised — call registerChatStore() first",
);
return (_store as unknown as (...a: unknown[]) => unknown)(...args);
},
get(_target, prop) {
if (!_store) return undefined;
return Reflect.get(_store, prop);
},
},
);

View File

@@ -1,6 +1,6 @@
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { api } from "@/platform/api";
import { useWorkspaceId } from "@multica/core/hooks";
import { api } from "../api";
import { useWorkspaceId } from "../hooks";
import { chatKeys } from "./queries";
export function useCreateChatSession() {
@@ -12,6 +12,7 @@ export function useCreateChatSession() {
api.createChatSession(data),
onSettled: () => {
qc.invalidateQueries({ queryKey: chatKeys.sessions(wsId) });
qc.invalidateQueries({ queryKey: chatKeys.allSessions(wsId) });
},
});
}
@@ -24,6 +25,7 @@ export function useArchiveChatSession() {
mutationFn: (sessionId: string) => api.archiveChatSession(sessionId),
onSettled: () => {
qc.invalidateQueries({ queryKey: chatKeys.sessions(wsId) });
qc.invalidateQueries({ queryKey: chatKeys.allSessions(wsId) });
},
});
}

View File

@@ -1,9 +1,17 @@
import { queryOptions } from "@tanstack/react-query";
import { api } from "@/platform/api";
import { api } from "../api";
// NOTE on workspace scoping:
// `wsId` is used only as part of queryKey for cache isolation per workspace.
// The actual workspace context comes from ApiClient's X-Workspace-ID header,
// which is set by useWorkspaceStore.switchWorkspace(). Callers must ensure the
// header is in sync with the wsId they pass here — otherwise cache writes will
// be misattributed during a workspace switch race window.
export const chatKeys = {
all: (wsId: string) => ["chat", wsId] as const,
sessions: (wsId: string) => [...chatKeys.all(wsId), "sessions"] as const,
allSessions: (wsId: string) => [...chatKeys.all(wsId), "sessions", "all"] as const,
session: (wsId: string, id: string) => [...chatKeys.all(wsId), "session", id] as const,
messages: (sessionId: string) => ["chat", "messages", sessionId] as const,
};
@@ -16,6 +24,14 @@ export function chatSessionsOptions(wsId: string) {
});
}
export function allChatSessionsOptions(wsId: string) {
return queryOptions({
queryKey: chatKeys.allSessions(wsId),
queryFn: () => api.listChatSessions({ status: "all" }),
staleTime: Infinity,
});
}
export function chatSessionOptions(wsId: string, id: string) {
return queryOptions({
queryKey: chatKeys.session(wsId, id),

View File

@@ -0,0 +1,83 @@
import { create } from "zustand";
import type { StorageAdapter } from "../types";
const AGENT_STORAGE_KEY = "multica:chat:selectedAgentId";
const SESSION_STORAGE_KEY = "multica:chat:activeSessionId";
export interface ChatTimelineItem {
seq: number;
type: "tool_use" | "tool_result" | "thinking" | "text" | "error";
tool?: string;
content?: string;
input?: Record<string, unknown>;
output?: string;
}
export interface ChatState {
isOpen: boolean;
isFullscreen: boolean;
activeSessionId: string | null;
pendingTaskId: string | null;
selectedAgentId: string | null;
showHistory: boolean;
timelineItems: ChatTimelineItem[];
setOpen: (open: boolean) => void;
toggle: () => void;
toggleFullscreen: () => void;
setActiveSession: (id: string | null) => void;
setPendingTask: (taskId: string | null) => void;
setSelectedAgentId: (id: string) => void;
setShowHistory: (show: boolean) => void;
addTimelineItem: (item: ChatTimelineItem) => void;
clearTimeline: () => void;
}
export interface ChatStoreOptions {
storage: StorageAdapter;
}
export function createChatStore(options: ChatStoreOptions) {
const { storage } = options;
return create<ChatState>((set) => ({
isOpen: false,
isFullscreen: false,
activeSessionId: storage.getItem(SESSION_STORAGE_KEY),
pendingTaskId: null,
selectedAgentId: storage.getItem(AGENT_STORAGE_KEY),
showHistory: false,
timelineItems: [],
setOpen: (open) =>
set({ isOpen: open, ...(open ? {} : { isFullscreen: false }) }),
toggle: () =>
set((s) => ({
isOpen: !s.isOpen,
...(s.isOpen ? { isFullscreen: false } : {}),
})),
toggleFullscreen: () => set((s) => ({ isFullscreen: !s.isFullscreen })),
setActiveSession: (id) => {
if (id) {
storage.setItem(SESSION_STORAGE_KEY, id);
} else {
storage.removeItem(SESSION_STORAGE_KEY);
}
set({ activeSessionId: id });
},
setPendingTask: (taskId) => set({ pendingTaskId: taskId, timelineItems: [] }),
setSelectedAgentId: (id) => {
storage.setItem(AGENT_STORAGE_KEY, id);
set({ selectedAgentId: id });
},
setShowHistory: (show) => set({ showHistory: show }),
addTimelineItem: (item) =>
set((s) => {
if (s.timelineItems.some((t) => t.seq === item.seq)) return s;
return {
timelineItems: [...s.timelineItems, item].sort(
(a, b) => a.seq - b.seq,
),
};
}),
clearTimeline: () => set({ timelineItems: [] }),
}));
}

View File

@@ -0,0 +1,3 @@
import reactConfig from "@multica/eslint-config/react";
export default [...reactConfig];

View File

@@ -12,6 +12,7 @@ export const issueKeys = {
reactions: (issueId: string) => ["issues", "reactions", issueId] as const,
subscribers: (issueId: string) =>
["issues", "subscribers", issueId] as const,
usage: (issueId: string) => ["issues", "usage", issueId] as const,
};
export const CLOSED_PAGE_SIZE = 50;
@@ -79,3 +80,10 @@ export function issueSubscribersOptions(issueId: string) {
queryFn: () => api.listIssueSubscribers(issueId),
});
}
export function issueUsageOptions(issueId: string) {
return queryOptions({
queryKey: issueKeys.usage(issueId),
queryFn: () => api.getIssueUsage(issueId),
});
}

View File

@@ -6,6 +6,11 @@ export {
useViewStoreApi,
} from "./view-store-context";
export { useIssuesScopeStore, type IssuesScope } from "./issues-scope-store";
export {
myIssuesViewStore,
type MyIssuesViewState,
type MyIssuesScope,
} from "./my-issues-view-store";
export {
useIssueViewStore,
createIssueViewStore,

View File

@@ -6,7 +6,7 @@ import {
type IssueViewState,
viewStoreSlice,
viewStorePersistOptions,
} from "@multica/core/issues/stores/view-store";
} from "./view-store";
export type MyIssuesScope = "assigned" | "created" | "agents";

View File

@@ -3,6 +3,11 @@
"version": "0.0.0",
"private": true,
"type": "module",
"scripts": {
"typecheck": "tsc --noEmit",
"lint": "eslint .",
"test": "vitest run"
},
"exports": {
".": "./index.ts",
"./types": "./types/index.ts",
@@ -29,6 +34,9 @@
"./inbox/queries": "./inbox/queries.ts",
"./inbox/mutations": "./inbox/mutations.ts",
"./inbox/ws-updaters": "./inbox/ws-updaters.ts",
"./chat": "./chat/index.ts",
"./chat/queries": "./chat/queries.ts",
"./chat/mutations": "./chat/mutations.ts",
"./runtimes": "./runtimes/index.ts",
"./runtimes/queries": "./runtimes/queries.ts",
"./runtimes/mutations": "./runtimes/mutations.ts",
@@ -46,7 +54,8 @@
"./provider": "./provider.tsx",
"./logger": "./logger.ts",
"./utils": "./utils.ts",
"./constants/*": "./constants/*.ts"
"./constants/*": "./constants/*.ts",
"./platform": "./platform/index.ts"
},
"dependencies": {
"@tanstack/react-query": "catalog:",
@@ -59,6 +68,7 @@
"devDependencies": {
"@multica/tsconfig": "workspace:*",
"@types/react": "catalog:",
"typescript": "catalog:"
"typescript": "catalog:",
"vitest": "catalog:"
}
}

View File

@@ -0,0 +1,54 @@
"use client";
import { useEffect, type ReactNode } from "react";
import { getApi } from "../api";
import { useAuthStore } from "../auth";
import { useWorkspaceStore } from "../workspace";
import { createLogger } from "../logger";
import { defaultStorage } from "./storage";
import type { StorageAdapter } from "../types/storage";
const logger = createLogger("auth");
export function AuthInitializer({
children,
onLogin,
onLogout,
storage = defaultStorage,
}: {
children: ReactNode;
onLogin?: () => void;
onLogout?: () => void;
storage?: StorageAdapter;
}) {
useEffect(() => {
const token = storage.getItem("multica_token");
if (!token) {
onLogout?.();
useAuthStore.setState({ isLoading: false });
return;
}
const api = getApi();
api.setToken(token);
const wsId = storage.getItem("multica_workspace_id");
Promise.all([api.getMe(), api.listWorkspaces()])
.then(([user, wsList]) => {
onLogin?.();
useAuthStore.setState({ user, isLoading: false });
useWorkspaceStore.getState().hydrateWorkspace(wsList, wsId);
})
.catch((err) => {
logger.error("auth init failed", err);
api.setToken(null);
api.setWorkspaceId(null);
storage.removeItem("multica_token");
storage.removeItem("multica_workspace_id");
onLogout?.();
useAuthStore.setState({ user: null, isLoading: false });
});
}, []);
return <>{children}</>;
}

View File

@@ -0,0 +1,85 @@
"use client";
import { useMemo } from "react";
import { ApiClient } from "../api/client";
import { setApiInstance } from "../api";
import { createAuthStore, registerAuthStore } from "../auth";
import { createWorkspaceStore, registerWorkspaceStore } from "../workspace";
import { createChatStore, registerChatStore } from "../chat";
import { WSProvider } from "../realtime";
import { QueryProvider } from "../provider";
import { createLogger } from "../logger";
import { defaultStorage } from "./storage";
import { AuthInitializer } from "./auth-initializer";
import type { CoreProviderProps } from "./types";
import type { StorageAdapter } from "../types/storage";
// Module-level singletons — created once at first render, never recreated.
// Vite HMR preserves module-level state, so these survive hot reloads.
let initialized = false;
let authStore: ReturnType<typeof createAuthStore>;
let workspaceStore: ReturnType<typeof createWorkspaceStore>;
let chatStore: ReturnType<typeof createChatStore>;
function initCore(
apiBaseUrl: string,
storage: StorageAdapter,
onLogin?: () => void,
onLogout?: () => void,
) {
if (initialized) return;
const api = new ApiClient(apiBaseUrl, {
logger: createLogger("api"),
onUnauthorized: () => {
storage.removeItem("multica_token");
storage.removeItem("multica_workspace_id");
},
});
setApiInstance(api);
// Hydrate token from storage
const token = storage.getItem("multica_token");
if (token) api.setToken(token);
const wsId = storage.getItem("multica_workspace_id");
if (wsId) api.setWorkspaceId(wsId);
authStore = createAuthStore({ api, storage, onLogin, onLogout });
registerAuthStore(authStore);
workspaceStore = createWorkspaceStore(api, { storage });
registerWorkspaceStore(workspaceStore);
chatStore = createChatStore({ storage });
registerChatStore(chatStore);
initialized = true;
}
export function CoreProvider({
children,
apiBaseUrl = "",
wsUrl = "ws://localhost:8080/ws",
storage = defaultStorage,
onLogin,
onLogout,
}: CoreProviderProps) {
// Initialize singletons on first render only. Dependencies are read-once:
// apiBaseUrl, storage, and callbacks are set at app boot and never change at runtime.
// eslint-disable-next-line react-hooks/exhaustive-deps
useMemo(() => initCore(apiBaseUrl, storage, onLogin, onLogout), []);
return (
<QueryProvider>
<AuthInitializer onLogin={onLogin} onLogout={onLogout} storage={storage}>
<WSProvider
wsUrl={wsUrl}
authStore={authStore}
workspaceStore={workspaceStore}
storage={storage}
>
{children}
</WSProvider>
</AuthInitializer>
</QueryProvider>
);
}

View File

@@ -0,0 +1,4 @@
export { CoreProvider } from "./core-provider";
export type { CoreProviderProps } from "./types";
export { AuthInitializer } from "./auth-initializer";
export { defaultStorage } from "./storage";

View File

@@ -1,10 +1,7 @@
import type { StorageAdapter } from "@multica/core/types/storage";
import type { StorageAdapter } from "../types/storage";
/**
* SSR-safe localStorage wrapper.
* Returns null / no-ops when running on the server (typeof window === "undefined").
*/
export const webStorage: StorageAdapter = {
/** SSR-safe localStorage. Works in both Next.js (SSR) and Electron (always client). */
export const defaultStorage: StorageAdapter = {
getItem: (k) =>
typeof window !== "undefined" ? localStorage.getItem(k) : null,
setItem: (k, v) => {

View File

@@ -0,0 +1,15 @@
import type { StorageAdapter } from "../types/storage";
export interface CoreProviderProps {
children: React.ReactNode;
/** API base URL. Default: "" (same-origin). */
apiBaseUrl?: string;
/** WebSocket URL. Default: "ws://localhost:8080/ws". */
wsUrl?: string;
/** Storage adapter. Default: SSR-safe localStorage wrapper. */
storage?: StorageAdapter;
/** Called after successful login (e.g. set cookie for Next.js middleware). */
onLogin?: () => void;
/** Called after logout (e.g. clear cookie). */
onLogout?: () => void;
}

View File

@@ -9,6 +9,7 @@ import type { WorkspaceStore } from "../workspace/store";
import { createLogger } from "../logger";
import { issueKeys } from "../issues/queries";
import { projectKeys } from "../projects/queries";
import { runtimeKeys } from "../runtimes/queries";
import {
onIssueCreated,
onIssueUpdated,
@@ -54,7 +55,8 @@ export interface RealtimeSyncStores {
*
* Per-issue events (comments, activity, reactions, subscribers) are handled
* both here (invalidation fallback) and by per-page useWSEvent hooks (granular
* updates). Daemon events are handled by individual components only.
* updates). Daemon register events invalidate runtimes globally; heartbeats
* are skipped to avoid excessive refetches.
*
* @param ws - WebSocket client instance (null when not yet connected)
* @param stores - Platform-created Zustand store instances for auth and workspace
@@ -95,6 +97,10 @@ export function useRealtimeSync(
const wsId = workspaceStore.getState().workspace?.id;
if (wsId) qc.invalidateQueries({ queryKey: projectKeys.all(wsId) });
},
daemon: () => {
const wsId = workspaceStore.getState().workspace?.id;
if (wsId) qc.invalidateQueries({ queryKey: runtimeKeys.all(wsId) });
},
};
const timers = new Map<string, ReturnType<typeof setTimeout>>();
@@ -118,6 +124,7 @@ export function useRealtimeSync(
"reaction:added", "reaction:removed",
"issue_reaction:added", "issue_reaction:removed",
"subscriber:added", "subscriber:removed",
"daemon:heartbeat",
]);
const unsubAny = ws.onAny((msg) => {
@@ -300,6 +307,7 @@ export function useRealtimeSync(
qc.invalidateQueries({ queryKey: workspaceKeys.members(wsId) });
qc.invalidateQueries({ queryKey: workspaceKeys.skills(wsId) });
qc.invalidateQueries({ queryKey: projectKeys.all(wsId) });
qc.invalidateQueries({ queryKey: runtimeKeys.all(wsId) });
}
qc.invalidateQueries({ queryKey: workspaceKeys.list() });
} catch (e) {

View File

@@ -1,7 +1,6 @@
import { useMemo } from "react";
import { useQuery } from "@tanstack/react-query";
import { useAuthStore } from "../auth";
import { useWorkspaceId } from "../hooks";
import type { AgentRuntime } from "../types";
import { runtimeListOptions, latestCliVersionOptions } from "./queries";
@@ -39,11 +38,14 @@ function runtimeNeedsUpdate(
/**
* Returns true if the current user has any local runtime with an outdated CLI version.
* Accepts wsId as parameter so callers outside WorkspaceIdProvider can use it safely.
*/
export function useMyRuntimesNeedUpdate(): boolean {
const wsId = useWorkspaceId();
export function useMyRuntimesNeedUpdate(wsId: string | undefined): boolean {
const userId = useAuthStore((s) => s.user?.id);
const { data: runtimes } = useQuery(runtimeListOptions(wsId));
const { data: runtimes } = useQuery({
...runtimeListOptions(wsId ?? ""),
enabled: !!wsId,
});
const { data: latestVersion } = useQuery(latestCliVersionOptions());
if (!runtimes || !latestVersion || !userId) return false;
@@ -53,11 +55,14 @@ export function useMyRuntimesNeedUpdate(): boolean {
/**
* Returns a Set of runtime IDs that belong to the current user and have updates available.
* Accepts wsId as parameter so callers outside WorkspaceIdProvider can use it safely.
*/
export function useUpdatableRuntimeIds(): Set<string> {
const wsId = useWorkspaceId();
export function useUpdatableRuntimeIds(wsId: string | undefined): Set<string> {
const userId = useAuthStore((s) => s.user?.id);
const { data: runtimes } = useQuery(runtimeListOptions(wsId));
const { data: runtimes } = useQuery({
...runtimeListOptions(wsId ?? ""),
enabled: !!wsId,
});
const { data: latestVersion } = useQuery(latestCliVersionOptions());
return useMemo(() => {

View File

@@ -138,6 +138,14 @@ export interface RuntimePing {
updated_at: string;
}
export interface IssueUsageSummary {
total_input_tokens: number;
total_output_tokens: number;
total_cache_read_tokens: number;
total_cache_write_tokens: number;
task_count: number;
}
export interface RuntimeUsage {
runtime_id: string;
date: string;

View File

@@ -20,6 +20,7 @@ export type {
RuntimePingStatus,
RuntimeUpdate,
RuntimeUpdateStatus,
IssueUsageSummary,
} from "./agent";
export type { Workspace, WorkspaceRepo, Member, MemberRole, User, MemberWithUser } from "./workspace";
export type { InboxItem, InboxSeverity, InboxItemType } from "./inbox";

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