Compare commits

..

30 Commits

Author SHA1 Message Date
Jiang Bohan
815a84bb55 fix(agent): surface codex turn errors instead of reporting empty output
When codex emits `turn/completed` with `status="failed"` or a terminal
top-level `error` notification, the daemon previously treated the turn
as successfully completed, saw no accumulated text, and surfaced the
generic "codex returned empty output" — hiding the real reason (auth,
sandbox, API error, etc.).

Capture `turn.error.message` on failed turns and the `error.message`
from non-retrying top-level error notifications, then propagate them
through `Result.Error` with `finalStatus="failed"` so the daemon's
default branch reports the actual cause.
2026-04-16 16:35:25 +08:00
Naiyuan Qing
b5c6a9b8f0 fix(desktop): reserve traffic-light space and surface trigger when sidebar is hidden (#1144)
The top bar pads `pl-20` for the macOS traffic lights only when
`state === "collapsed"`, but the shadcn sidebar also hides itself in
mobile mode (<768px) where `state` stays `"expanded"` and only
`isMobile` flips. In that case tabs slid under the traffic lights and
no UI affordance existed to bring the sidebar back (since the in-sidebar
trigger went off-canvas with it).

Treat both as "sidebar not in main flow", apply the padding, and render
a `SidebarTrigger` in the header (with `no-drag` so the window drag
region doesn't swallow the click).

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 13:57:51 +08:00
Junghwan
7395b51aee fix(agent): apply filterCustomArgs to hermes backend for parity (#1122)
Every other backend (claude, codex, opencode, openclaw, gemini) filters
opts.CustomArgs through a per-backend blocked map so protocol-critical
flags can't be overridden via the Create Agent UI. The hermes backend
appended CustomArgs directly to argv, so any future flag we add to the
map would be silently bypassed here.

Add hermesBlockedArgs (with 'acp' as the pinned subcommand) and route
CustomArgs through filterCustomArgs. Behaviour is identical for today's
use cases; the change prevents accidental protocol-flag overrides and
brings hermes in line with the other five backends.

Closes #1113

Co-authored-by: shaun0927 <shaun0927@users.noreply.github.com>
2026-04-16 13:53:53 +08:00
Bohan Jiang
ce52374d5d test(daemon): add cross-workspace regression for GetIssueGCCheck (#1143)
Adds TestGetIssueGCCheck_WithDaemonToken_CrossWorkspace alongside the
existing TestGetTaskStatus_WithDaemonToken_CrossWorkspace, covering:

- daemon token scoped to a different workspace → 404 (matches the
  "issue not found" status, so no UUID enumeration oracle)
- daemon token scoped to the issue's workspace → 200 with status and
  updated_at fields populated

Follow-up to #1121, which fixed the underlying IDOR reported in #1112
but did not ship a regression test. This gates the class of bug at CI
so the next handler to forget requireDaemonWorkspaceAccess will be
caught before merge.
2026-04-16 13:49:54 +08:00
Naiyuan Qing
441554a520 fix(inbox): read workspace ID from request context (#1142)
After the slug-first URL refactor, the frontend sends X-Workspace-Slug
and the workspace middleware resolves it into a UUID stored in the
request context. The inbox handlers still read X-Workspace-ID directly
from the request header, which is now absent, so every inbox query ran
with an empty workspace_id and returned zero rows.

Switch all six inbox handlers to ctxWorkspaceID(r.Context()), matching
the pattern already used by chat / issue / project / autopilot. No
frontend changes required — the slug header path was already correct.

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 13:46:39 +08:00
Junghwan
93cf95f799 fix(security): enforce workspace access on GetIssueGCCheck (#1121)
The daemon GC check endpoint did not verify the caller's access to the
issue's workspace, letting a daemon token or PAT scoped to workspace A
read issue status/updated_at for any issue UUID across the instance.

Mirror the pattern used by every other handler in daemon.go: look up
the issue's workspace and gate on requireDaemonWorkspaceAccess.

Closes #1112

Co-authored-by: shaun0927 <shaun0927@users.noreply.github.com>
2026-04-16 13:43:17 +08:00
Naiyuan Qing
fe358feff0 Reapply "feat: workspace URL refactor v2 + rollback-safe compat layer (#1138)" (#1139) (#1141)
This reverts commit b30fd98605.
2026-04-16 13:16:35 +08:00
Bohan Jiang
a71aa6c544 fix(email): sanitize invitation Subject and lock behavior with tests (#1140)
Follow-up to #1126 (which closed the HTML-injection vector in the Body).

The Subject line is not HTML-rendered, so html.EscapeString would leak
literal entities into recipient inboxes. Instead:

- Strip control characters from workspace/inviter names (defense in depth
  even though Resend also filters CR/LF).
- Cap each field at 60 runes so an attacker can't stuff a full phishing
  pitch into a workspace name that gets sent from noreply@multica.ai.

Also extracts buildInvitationParams to make the sanitization logic
testable without mocking the Resend SDK, and adds a test covering:
  - HTML escape behavior for script/attribute/anchor injection payloads
  - Subject stripping of \r\n\t and other unicode controls
  - Subject NOT being HTML-escaped (so "Acme & Co." stays literal)
  - Subject length bounds
  - Benign inputs pass through unchanged

Adds a note on SendVerificationCode that its body uses only
server-generated content, to prevent the same pitfall from creeping in.

Refs #1117
2026-04-16 13:02:12 +08:00
Junghwan
1b30ad0ba6 fix(email): HTML-escape workspace/inviter names in invitation email (#1126)
* fix(email): HTML-escape workspace/inviter names in invitation email

SendInvitationEmail interpolated workspaceName and inviterName directly
into the HTML body via fmt.Sprintf with no escaping. A workspace owner
who sets a name like '</h2><a href="https://evil.example">Click</a>'
can break the email structure and inject attacker-controlled links that
appear as part of the official Multica invitation.

Escape both values with html.EscapeString before interpolation. The
Subject line also gets the escaped variants since some transports render
HTML-entity-like sequences.

Closes #1117

* fix(email): use raw names in Subject, keep HTML-escape for body only

Email Subject is a plain-text context — applying html.EscapeString
turns "A&B" into "A&amp;B" and "O'Brien" into "O&#39;Brien" in the
recipient's inbox. Keep the escape for the Html body where it prevents
injection, but use the original values in Subject.

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

---------

Co-authored-by: shaun0927 <shaun0927@users.noreply.github.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 12:57:16 +08:00
Naiyuan Qing
b30fd98605 Revert "feat: workspace URL refactor v2 + rollback-safe compat layer (#1138)" (#1139)
This reverts commit 75d12c26c5.
2026-04-16 12:26:40 +08:00
Naiyuan Qing
75d12c26c5 feat: workspace URL refactor v2 + rollback-safe compat layer (#1138)
* Reapply "feat: workspace URL refactor + slug-first API identity (#1131)" (#1137)

This reverts commit 9b94914bc8.

* compat: legacy URL redirect + localStorage double-write for safe rollback

The first attempt at this refactor (#1131) was reverted because existing
users on old URLs (/issues, /projects, etc.) hit 404 immediately after
deploy, and rolling back left them with empty dashboards — the legacy
code reads localStorage["multica_workspace_id"] to attach a workspace
to API requests, but the new code had stopped writing that key.

Two compat layers added on top of the restored refactor:

1. proxy.ts now intercepts legacy route prefixes (/issues/*, /projects/*,
   /agents/*, /inbox/*, /my-issues/*, /autopilots/*, /runtimes/*,
   /skills/*, /settings/*). Logged-in users with a last_workspace_slug
   cookie are 302'd to /{slug}/{rest}, preserving their deep link. Users
   without the cookie bounce through / where the landing page picks a
   workspace client-side. Unauthenticated users go to /login.

2. Both layouts now double-write the workspace id to the legacy
   localStorage key on every workspace entry. New code ignores this key
   — it exists solely so that if this PR ever gets reverted again, the
   legacy build reading the key would still find the correct workspace
   and avoid the empty-dashboard symptom users saw during the rollback.

Net effect: any direction of deploy ↔ rollback is now cache-compatible,
and any direction of old bookmark → new route resolves without 404.

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

* fix(platform): defer rehydrateAllWorkspaceStores to a microtask

Same React 19 render-phase restriction that forced setCurrentWorkspace
to defer its subscriber notifications. rehydrateAllWorkspaceStores
synchronously calls each persist store's rehydrate, which setState()s
the store, which schedules updates on any subscribed component. When
the workspace layout's render-phase ref guard invoked this, React
complained that SearchCommand (a store subscriber) couldn't be
re-rendered while WorkspaceLayout was still rendering.

Fix: queueMicrotask the rehydrate loop and add a pending-flag guard so
rapid workspace switches coalesce into one rehydrate on the final slug.
Persist stores tolerate one microtask of staleness — they hold UI
preferences, not correctness-critical state.

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-16 12:23:41 +08:00
Naiyuan Qing
9b94914bc8 Revert "feat: workspace URL refactor + slug-first API identity (#1131)" (#1137)
This reverts commit 59ace95a1e.
2026-04-16 11:56:15 +08:00
Naiyuan Qing
59ace95a1e feat: workspace URL refactor + slug-first API identity (#1131)
* feat: workspace URL refactor + slug-first API identity

Make the URL the single source of truth for workspace identity.
All workspace-scoped URLs now carry the workspace slug as the first
path segment (/{slug}/issues, /{slug}/projects, etc.), matching the
industry standard (Linear, Notion, Vercel, GitHub).

## Key architectural changes

**URL-driven workspace identity:**
- Web routes moved under app/[workspaceSlug]/(dashboard)/
- Desktop routes nested under /:workspaceSlug
- paths.ts builder centralises all URL construction
- reserved-slugs validation (backend + frontend + DB migration audit)

**Slug-first API contract:**
- Frontend sends X-Workspace-Slug header (from URL) instead of X-Workspace-ID (UUID)
- Backend middleware resolves slug → UUID via GetWorkspaceBySlug, falls back to
  X-Workspace-ID for CLI/daemon backwards compatibility
- WebSocket auth accepts ?workspace_slug query param with SlugResolver callback

**State cleanup:**
- Deleted: useWorkspaceStore (Zustand mirror), switchWorkspace/hydrateWorkspace/
  clearWorkspace, localStorage["multica_workspace_id"], api._workspaceId
- useCurrentWorkspace() derives from URL slug + React Query workspace list
- useWorkspaceId() is now a bridge hook (no Context, derives from useCurrentWorkspace)
- WorkspaceIdProvider removed from DashboardGuard
- Paired module vars (slug + UUID) in workspace-storage.ts for non-React consumers

**Layout simplified:**
- Render-phase ref guard sets workspace context synchronously (no async gate)
- DashboardGuard handles auth redirect, loading state, and workspace resolution
- Subscriber notifications deferred via queueMicrotask (React 19 compat)
- persist namespace uses slug (immutable) instead of UUID

## Issues resolved

MUL-43 (share links), MUL-509 (mobile workspace switch), MUL-723 (workspace in URL),
MUL-727 (create workspace flash), MUL-728 (delete workspace no-navigate),
MUL-820 (sidebar Join not switching)

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

* fix: resolve code review C3/C4/C5/C6 — desktop deadlock + hardcoded paths

C3: Desktop OnboardingGate was calling useCurrentWorkspace() outside
WorkspaceSlugProvider → always null → permanent onboarding deadlock.
Rewrite to use useQuery(workspaceListOptions()) which reads React Query
cache directly without slug context. Remove DashboardGuard from
DesktopShell (auth gating handled by AppContent, workspace routing by
WorkspaceRouteLayout per-tab).

C4: Landing page "Dashboard" links hardcoded /issues (no longer valid).
Changed to / — proxy handles redirect to /{lastSlug}/issues.

C5: autopilots-page.tsx had one hardcoded /autopilots/${id} link.
Changed to wsPaths.autopilotDetail(id).

C6: inbox-page.tsx hardcoded /inbox paths. Changed to wsPaths.inbox().

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

* fix(desktop): wrap shell in WorkspaceSlugProvider from module var

AppSidebar calls useWorkspacePaths() → useRequiredWorkspaceSlug() which
throws outside WorkspaceSlugProvider. In the desktop shell, the sidebar
renders at the shell level (outside any tab's WorkspaceRouteLayout).

Fix: DesktopShell reads the current slug via useSyncExternalStore on
the workspace-storage singleton. When slug is available, wraps the
entire shell in WorkspaceSlugProvider. When null (first mount before
any tab's WorkspaceRouteLayout sets it), shows a loading spinner.

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

* fix(desktop): migrate old tab paths + fix shell slug deadlock

Tab store rehydration: old-format paths like "/issues/abc" (missing
workspace slug prefix) are reset to "/" so IndexRedirect picks the
correct workspace. Detection: if the first segment is a known route
name (issues, projects, etc.) rather than a workspace slug, it's an
old-format path.

Desktop shell: TabContent must always render (not gated behind slug
check) so WorkspaceRouteLayout can mount and call setCurrentWorkspace.
Only sidebar and shell-level UI (chat, modals, search) gate on slug
being present.

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-16 11:53:09 +08:00
Naiyuan Qing
3c46c5baa3 fix(editor): add dirty check and allow clearing description (#1132)
Two editor bugs fixed:

1. Descriptions saved unnecessarily on every document change (no dirty
   check). Added onCreate baseline capture + string comparison in the
   debounced onUpdate handler so mutations only fire when content
   actually changes.

2. Clearing a description didn't persist — empty string was converted
   to undefined via `md || undefined`, causing the field to be omitted
   from the API request. Changed to `md` so empty strings reach the
   backend and clear the description via COALESCE.

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 11:46:43 +08:00
Naiyuan Qing
c38af55a8e refactor(ui): comprehensive UI craft review — sidebar, headers, detail panels (#1087)
Sidebar:
- Pinned items: StatusIcon for issues, emoji for projects, sm size, mask gradient text fade
- Pinned items: inline X close button (hidden → flex on hover, desktop tab pattern)
- Pinned section: collapsible with chevron + hover count
- Remove unused canvas token

Global components:
- PageHeader: shared component with built-in mobile SidebarTrigger (md:hidden)
- Replace header divs in all 11 dashboard pages with PageHeader
- Remove standalone mobile trigger bar from DashboardLayout
- Tooltip: 200ms delay, remove arrow, popover/border style
- Search dialog: add finalFocus={false}
- SidebarInset: remove shadow-sm
- Button sizing: icon-xs → icon-sm across all non-editor contexts

Issue Detail:
- Simplify breadcrumb to workspace > identifier
- Extract sidebarContent variable shared between ResizablePanel and mobile Sheet
- All sidebar sections collapsible (Properties, Parent issue, Details, Token usage)
- Auto-close sidebar on mobile breakpoint
- Collapsible section headers: text before chevron, !size-3 stroke-[2.5], hover bg

Project Detail:
- Match Issue Detail layout pattern (header inside left ResizablePanel)
- Extract sidebarContent, add mobile Sheet support
- All sidebar sections collapsible (Properties, Progress, Description)
- Header: move three-dot menu to right button group, unified breadcrumb layout
- Auto-close sidebar on mobile breakpoint

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 21:50:00 +08:00
Bohan Jiang
df920e8641 fix(daemon): normalize repo URL and clarify reposVersion intent (#1090)
- TrimSpace incoming repoURL in ensureRepoReady to prevent unnecessary
  server refreshes when CLI passes URLs with whitespace
- Add comment on reposVersion field clarifying it is stored for future
  version-based skip optimization
- Add concurrency safety comment on syncWorkspacesFromAPI skip logic
- Add test for URL trimming fast-path behavior
2026-04-15 19:14:26 +08:00
Black
0427fd8cc7 fix(daemon): refresh workspace repos on checkout miss (#1085)
Co-authored-by: black-fe <black-fe@gate.me>
2026-04-15 19:10:54 +08:00
Jiayuan Zhang
d930bcaa18 feat(server): trigger agent when issue moves out of backlog (#1006)
* feat(server): trigger agent when issue moves out of backlog

When a member moves an agent-assigned issue from "backlog" to an active
status (e.g. "todo", "in_progress"), enqueue an agent task so the agent
starts working. This lets backlog act as a parking lot where issues can
be assigned to agents without immediately triggering execution.

Applies to both single and batch issue updates.

* fix(server): treat backlog as parking lot — no trigger on create/assign

Address review feedback: creating or assigning an agent to a backlog
issue no longer triggers immediate execution. Only moving out of backlog
to an active status triggers the agent, producing exactly one task.

- shouldEnqueueAgentTask now gates on backlog status
- backlog→active trigger uses isAgentAssigneeReady directly
- Added TestBacklogNoTriggerOnCreate test
- Updated TestBacklogToTodoTriggersAgent to assert exactly 1 task
  across the full create→move path (no manual cleanup)

* feat(ui): show toast hint when assigning agent to backlog issue

Users may not know that backlog issues won't trigger agent execution
until moved to an active status. Show an actionable toast with a
"Move to Todo" button when:

- Assigning an agent to a backlog issue in the detail page
- Creating a backlog issue with an agent assignee

* feat(ui): add "Don't show again" option to backlog agent toast

Users who understand the backlog parking lot behavior can dismiss the
hint permanently. Uses localStorage to persist the preference.

* feat(ui): replace backlog agent toast with AlertDialog

Use a modal dialog instead of a toast notification so users must
explicitly acknowledge the hint. The dialog offers three options:
- "Move to Todo" — changes status and triggers the agent
- "Keep in Backlog" — dismisses without action
- "Don't show again" — persists dismissal in localStorage

* fix(ui): improve backlog agent dialog

* fix(ui): close create dialog behind hint, use checkbox for don't-show-again

1. Create Issue dialog now closes when the backlog agent hint appears,
   so only the hint dialog is visible (not stacked behind).
2. "Don't show again" is now a checkbox instead of a separate button.
   When checked, clicking either "Keep in Backlog" or "Move to Todo"
   persists the preference.

* fix(ui): smooth backlog agent hint dialog

* fix(test): add useUpdateIssue mock to create-issue test

The test mock for @multica/core/issues/mutations was missing the
useUpdateIssue export that create-issue.tsx now imports, causing
CI failure.
2026-04-15 19:07:48 +08:00
Bohan Jiang
5a44c255fe docs: add v0.2.0 changelog entry (2026-04-15) (#1078) 2026-04-15 19:06:12 +08:00
devv-eve
8a55473bb8 fix(desktop): evaluate daemon spawn env lazily to pick up PATH fix (#1088)
DESKTOP_SPAWN_ENV was a top-level const in daemon-manager.ts that
snapshotted process.env at module load. Because ESM imports are hoisted
and evaluated before main/index.ts runs fix-path, the snapshot captured
launchd's minimal PATH — missing ~/.local/bin, Homebrew, etc. The main
process then had the corrected PATH, but every spawned daemon inherited
the stale one and failed with "no agent CLI found" on fresh GUI launches.

Convert it to desktopSpawnEnv() so process.env is read at call time,
after fix-path has already updated it.

Co-authored-by: Devv <devv@Devvs-Mac-mini.local>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 18:49:49 +08:00
LinYushen
ce94c80f5a fix(desktop): read VITE_APP_URL for Google login external redirect (#1086)
The desktop login was reading VITE_WEB_URL, which is defined nowhere
in the committed env files. In production builds the variable was
undefined, so Google login opened http://localhost:3000/login?platform=desktop
instead of https://multica.ai/login?platform=desktop.

Switch to VITE_APP_URL, which is already set in apps/desktop/.env.production
and is the same variable platform/navigation.tsx uses for shareable links.

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 18:05:58 +08:00
LinYushen
176f1bfdbb refactor(desktop): keep only create-workspace step in onboarding (#1083)
Fresh desktop accounts no longer need to walk through runtime, agent,
and get-started steps before reaching the app. Once the workspace is
created, the onboarding gate hands off directly to the main shell.
Web onboarding is unchanged.

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 17:57:47 +08:00
Jiayuan Zhang
a81a6b1578 feat(github): add deployment type dropdown to issue templates (#1080)
Add a required dropdown field asking whether the user is on multica.ai
(hosted) or self-hosted, to both bug report and feature request templates.
2026-04-15 17:46:19 +08:00
LinYushen
0e8a7b1734 fix(desktop): make packaged app usable for fresh accounts (#1074)
* feat(desktop): add macOS app icon

Replace the default electron-vite scaffold icon with the Multica asterisk
icon. Adds build/icon.icns so electron-builder picks it up automatically
via the `buildResources: build` config — no YAML change needed.

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

* fix(desktop): run electron-vite build inside package script

The package wrapper only ran bundle-cli.mjs and electron-builder, so
electron-builder silently packaged whatever was already in out/. On a
fresh checkout (or after a partial build) this shipped an app with a
missing renderer bundle, which white-screens on launch.

Add an explicit `electron-vite build` step between bundle-cli and
electron-builder so `pnpm package` is self-contained.

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

* fix(desktop): restore shell PATH in main process for GUI launches

macOS/Linux GUI launches inherit a minimal PATH from launchd that omits
~/.zshrc, Homebrew, nvm, ~/.local/bin, and other shell config. Child
processes spawned from the main process — including the bundled multica
CLI used by daemon-manager — inherit the same stripped PATH, so the CLI
fails to locate agent binaries like claude, codex, opencode, etc. with
"no agent CLI found: … ensure it is on PATH".

Use `fix-path` to recover the real shell PATH at startup, then prepend
common install locations (/opt/homebrew/bin, /usr/local/bin,
~/.local/bin) as a fallback for broken shell rc or non-interactive
$SHELL. Runs before setupDaemonManager so every subsequent spawn sees
the corrected PATH.

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

* fix(desktop): show onboarding wizard when authed user has no workspace

Desktop is a single-shell architecture — every route, including
/onboarding, lives inside DashboardGuard. The guard returns its loading
fallback whenever workspace is null, so a fresh account that logs in
with no workspaces ends up stuck on the spinner forever: the
`replace(onboardingPath)` redirect navigates the tab router, but
DashboardGuard still blocks its children because workspace is still
null.

Handle the empty-workspace case in DesktopShell itself: render
OnboardingWizard as a full-screen takeover, bypassing DashboardGuard.
A ref-based flag freezes the "needs onboarding" decision at first
mount so creating a workspace mid-wizard (step 0) doesn't unmount the
wizard and dump the user into the main shell before steps 1-3
(runtime, agent, get started) finish.

Also add a local `bootstrapping` flag in AppContent so DesktopShell
doesn't mount until the deep-link login chain (loginWithToken →
syncToken → listWorkspaces → hydrateWorkspace) fully resolves. Without
it, the shell would briefly see `!workspace` before hydration lands,
causing users with existing workspaces to flash the wizard (or, with
the ref freeze, get stuck in it permanently).

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

* refactor(desktop): extract OnboardingGate with test coverage

Pull the "render onboarding wizard when authed user has no workspace"
logic out of DesktopShell into a dedicated OnboardingGate component.
Replaces the ref-based freeze with a lazy useState initializer
(`useState(() => !hasWorkspace)`), which is React's idiomatic pattern
for "capture a value once at mount". The freeze semantics are unchanged:
creating a workspace in step 0 of the wizard must not unmount it,
because steps 1-3 still need to run; only `onComplete` flips the gate
back to the main shell.

Also de-duplicates the wrapping DesktopNavigationProvider — both branches
of the shell now share a single provider instead of re-mounting one per
branch.

Wire up jsdom + @testing-library/react in the desktop vitest config
(mirroring packages/views) and add three deterministic tests covering:
  1. children render when hasWorkspace is true at mount
  2. wizard stays mounted when hasWorkspace flips to true mid-flow
  3. onComplete transitions the gate to children

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

* refactor(desktop): drop redundant syncToken call in deep-link login

daemonAPI.syncToken was called twice on a deep-link login: once inside
the deep-link handler's bootstrapping chain, and again in the
useEffect([user]) that reacts to the user state change. Both calls spawn
a multica CLI subprocess over IPC, wasting ~1-2s of startup time on the
critical login path.

Keep the [user] effect (it covers the session-restore path too) and
drop the explicit call from the deep-link handler. Net effect: login
latency shrinks, behavior is unchanged.

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-15 17:27:43 +08:00
croatialu
621526b38d fix(selfhost): persist local uploads for docker deployment (#1061)
* fix(selfhost): persist local uploads and proxy file routes

* fix(selfhost): keep local uploads across container recreation

* docs(selfhost): restore relative local upload dir example
2026-04-15 17:17:16 +08:00
Jiayuan Zhang
244434bcfa docs: add full-stack isolated testing guide to CONTRIBUTING.md (#1076)
Document the complete workflow for running backend, frontend, and daemon
from source in a fully isolated environment. Covers dynamic profile
naming, automated auth, Desktop app testing, and cleanup — all without
touching the system CLI config or production environment.
2026-04-15 17:16:46 +08:00
Bohan Jiang
970b7fd1d3 fix(cli): use .zip archive for Windows in multica update (#1075)
GoReleaser produces .zip for Windows and .tar.gz for other platforms,
but the update command hardcoded .tar.gz for all platforms, causing a
404 error on Windows.

- Select .zip extension when runtime.GOOS is "windows"
- Add extractBinaryFromZip() for zip archive extraction
- Use "multica.exe" as the binary name on Windows

Closes #1072
2026-04-15 17:16:36 +08:00
pradeep7127
f76e3fb8f4 fix(make): run migrations before starting server in 'make start' (#1069)
Ensures the database schema is always up to date when starting the app,
preventing silent API failures caused by missing columns after pulling
latest changes.

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-15 17:10:08 +08:00
Bohan Jiang
b6d30c0e00 feat(agent): log full command line at debug level when spawning agents (#1071)
Add a debug-level log line in every agent backend (claude, codex,
opencode, openclaw, gemini, hermes) that prints the executable path
and full argument list when spawning the agent process. Helps diagnose
custom args, model overrides, and other CLI flag issues.
2026-04-15 16:21:55 +08:00
Bohan Jiang
129a8b927f fix(views): auto-split whitespace in custom args entries (#1065)
Users naturally type `--model claude-sonnet-4-20250514` on one line,
but the backend needs them as separate tokens. Now `entriesToArgs`
splits each entry by whitespace before saving, so the API receives
`["--model", "claude-sonnet-4-20250514"]` instead of a single string.

Also updated placeholder and description to show the natural input
format.
2026-04-15 15:17:06 +08:00
183 changed files with 7397 additions and 1731 deletions

View File

@@ -3,6 +3,17 @@ description: Report a bug — something that's broken, crashes, or behaves incor
title: "[Bug]: "
labels: ["bug"]
body:
- type: dropdown
id: deployment
attributes:
label: Deployment type
description: Are you using the hosted version or a self-hosted instance?
options:
- multica.ai (hosted)
- Self-hosted
validations:
required: true
- type: textarea
id: description
attributes:

View File

@@ -3,6 +3,17 @@ description: Suggest a new feature or improvement.
title: "[Feature]: "
labels: ["enhancement"]
body:
- type: dropdown
id: deployment
attributes:
label: Deployment type
description: Are you using the hosted version or a self-hosted instance?
options:
- multica.ai (hosted)
- Self-hosted
validations:
required: true
- type: textarea
id: description
attributes:

View File

@@ -9,6 +9,7 @@ It covers:
- isolated worktree development
- the shared PostgreSQL model
- testing and verification
- full-stack isolated testing (backend + frontend + daemon from source)
- troubleshooting and destructive reset options
## Development Model
@@ -308,6 +309,199 @@ make daemon
The daemon authenticates using the CLI's stored token (`multica login`).
It registers runtimes for all watched workspaces from the CLI config.
## Full-Stack Isolated Testing
This section covers running the complete stack (backend, frontend, daemon) from
source in a fully isolated environment. Useful for testing end-to-end changes
that span multiple components, or for automated CI/AI workflows that need zero
human intervention.
### Why Not Just `make daemon`?
`make daemon` uses the system-installed CLI's stored token and connects to
whatever server is configured in `~/.multica/config.json`. That's fine for
day-to-day development against a shared server, but for fully isolated testing
you need:
- a local backend and frontend (from source)
- a local daemon (from source) with its own profile
- automated authentication (no browser login)
- no interference with your production CLI config
### Dynamic Profile Naming
Each worktree must use a unique daemon profile to avoid collisions when
multiple features run in parallel.
The profile name is derived from the worktree directory using the same
slug + hash pattern as `scripts/init-worktree-env.sh`:
```bash
WORKTREE_DIR="$(basename "$PWD")"
SLUG="$(printf '%s' "$WORKTREE_DIR" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9]/_/g; s/__*/_/g; s/^_//; s/_$//')"
HASH="$(printf '%s' "$PWD" | cksum | awk '{print $1}')"
OFFSET=$((HASH % 1000))
PROFILE="dev-${SLUG}-${OFFSET}"
```
Example: worktree at `../multica-feat-auth` produces profile
`dev-multica_feat_auth-347`, matching that worktree's port and database
allocation.
### Start the Isolated Environment
Run all steps from the worktree root (where the Makefile is).
#### 1. Start backend, frontend, and database
```bash
make dev
```
Wait for the backend to be healthy:
```bash
PORT=$(grep '^PORT=' .env.worktree 2>/dev/null || grep '^PORT=' .env | head -1 | cut -d= -f2)
PORT=${PORT:-8080}
SERVER="http://localhost:${PORT}"
for i in $(seq 1 30); do
curl -sf "$SERVER/health" > /dev/null 2>&1 && break
sleep 2
done
```
#### 2. Create a test user and token (automated auth)
In non-production environments the verification code is fixed at `888888`:
```bash
curl -s -X POST "$SERVER/auth/send-code" \
-H "Content-Type: application/json" \
-d '{"email": "dev@localhost"}'
JWT=$(curl -s -X POST "$SERVER/auth/verify-code" \
-H "Content-Type: application/json" \
-d '{"email": "dev@localhost", "code": "888888"}' | jq -r '.token')
PAT=$(curl -s -X POST "$SERVER/api/tokens" \
-H "Authorization: Bearer $JWT" \
-H "Content-Type: application/json" \
-d '{"name": "auto-dev", "expires_in_days": 365}' | jq -r '.token')
```
#### 3. Create a workspace
```bash
WS=$(curl -s -X POST "$SERVER/api/workspaces" \
-H "Authorization: Bearer $PAT" \
-H "Content-Type: application/json" \
-d '{"name": "Dev", "slug": "dev"}' | jq -r '.id')
```
#### 4. Compute profile name and write CLI config
```bash
# Compute profile (see Dynamic Profile Naming above)
WORKTREE_DIR="$(basename "$PWD")"
SLUG="$(printf '%s' "$WORKTREE_DIR" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9]/_/g; s/__*/_/g; s/^_//; s/_$//')"
HASH="$(printf '%s' "$PWD" | cksum | awk '{print $1}')"
OFFSET=$((HASH % 1000))
PROFILE="dev-${SLUG}-${OFFSET}"
FRONTEND_PORT=$(grep '^FRONTEND_PORT=' .env.worktree 2>/dev/null || grep '^FRONTEND_PORT=' .env | head -1 | cut -d= -f2)
FRONTEND_PORT=${FRONTEND_PORT:-3000}
CONFIG_DIR="$HOME/.multica/profiles/$PROFILE"
mkdir -p "$CONFIG_DIR"
cat > "$CONFIG_DIR/config.json" << EOF
{
"server_url": "$SERVER",
"app_url": "http://localhost:${FRONTEND_PORT}",
"token": "$PAT",
"workspace_id": "$WS",
"watched_workspaces": [{"id": "$WS", "name": "Dev"}]
}
EOF
```
#### 5. Start the daemon from source
```bash
make cli ARGS="daemon start --profile $PROFILE"
```
The daemon runs from the current worktree's Go source, connecting to the
local backend. Agent-executed `multica` commands automatically use the same
binary (the daemon prepends its own directory to `PATH`).
### Stop the Isolated Environment
```bash
# Compute profile (same formula)
PROFILE="dev-$(printf '%s' "$(basename "$PWD")" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9]/_/g; s/__*/_/g; s/^_//; s/_$//')-$(( $(printf '%s' "$PWD" | cksum | awk '{print $1}') % 1000 ))"
# 1. Stop daemon
make cli ARGS="daemon stop --profile $PROFILE"
# 2. Stop backend + frontend
make stop # main checkout
make stop-worktree # worktree checkout
# 3. (Optional) Stop shared PostgreSQL
make db-down
# 4. (Optional) Clean build artifacts
make clean
# 5. (Optional) Remove profile config
rm -rf "$HOME/.multica/profiles/$PROFILE"
```
### Desktop App Local Testing
To test the Electron desktop app against a local backend:
```bash
# After backend is running (make dev)
pnpm dev:desktop
```
This automatically:
1. Compiles the `multica` CLI from `server/cmd/multica` into
`apps/desktop/resources/bin/multica`
2. Creates an isolated profile named `desktop-localhost-<PORT>`
3. Starts and manages its own daemon instance
4. Connects to the local backend
Login in the Desktop UI with `dev@localhost` and code `888888`.
If the backend runs on a non-default port (worktree), create
`apps/desktop/.env.development.local`:
```bash
VITE_API_URL=http://localhost:<backend-port>
VITE_WS_URL=ws://localhost:<backend-port>/ws
```
### Isolation Guarantee
Nothing in this flow touches the system-installed `multica` or the default
`~/.multica/config.json`:
| Resource | System / Production | Local Dev (per-worktree) |
|---|---|---|
| Config | `~/.multica/config.json` | `~/.multica/profiles/dev-<slug>-<hash>/config.json` |
| Daemon PID | `~/.multica/daemon.pid` | `~/.multica/profiles/dev-<slug>-<hash>/daemon.pid` |
| Health port | `19514` | `19514 + 1 + (name_hash % 1000)` |
| Workspaces dir | `~/multica_workspaces/` | `~/multica_workspaces_dev-<slug>-<hash>/` |
| Database | remote / production | local Docker: `multica_<slug>_<hash>` |
| Desktop profile | `desktop-api.multica.ai` | `desktop-localhost-<port>` |
Multiple worktrees can run simultaneously without conflict.
## Troubleshooting
### Missing Env File

View File

@@ -0,0 +1,383 @@
# Architecture Audit — Workspace & Realtime Cache
> 基于代码审计整理的 4 个任务。优先级P0 一个、P1 一个、P2 两个。每个任务都包含问题、根因、受影响的 issue、复现步骤、修复方案、改动范围。
---
## 任务 1 — [P0] 空闲后列表数据陈旧
**关联 issue**[#951](https://github.com/multica-ai/multica/issues/951)
### 问题
用户登录后静置一段时间Issue 列表里缺失一部分数据(其他成员期间新建/变更的 issue 不出现)。登出再登入可以恢复。`ec5af33b` 声称 "Closes #951",但 issue 仍为 OPEN 状态 —— 因为它只修了 401 一种场景,没修 WS 半开这一种。
### 根因
系统把 cache 新鲜度的全部责任压给了 WebSocket 推送:
- `packages/core/query-client.ts:7``staleTime: Infinity`cache 永不主动过期
- `packages/core/query-client.ts:9``refetchOnWindowFocus: false`tab 重新获得焦点也不 refetch
- 依赖 WS 推送 `issue:created` / `issue:updated` 事件 invalidate cache
但 WS 层存在一个**不对称**
- **服务端**`server/internal/realtime/hub.go:83-96, 420-475` 有 54s ping / 60s pongWait会清理死连接
- **客户端**`packages/core/api/ws-client.ts`142 行全貌)**完全没有心跳检测**,只靠 `onclose` 事件触发重连
浏览器原生 `WebSocket` API 不把 ping/pong 帧暴露给 JS所以 JS 层无法主动探测 "半开" 连接。当 NAT / 负载均衡器 / 笔记本睡眠导致 TCP 连接被静默切断时:
1. 浏览器 `readyState` 仍是 `OPEN`
2. `onclose` 不触发
3. `ws-client.ts:70-73` 的 3 秒重连逻辑不跑
4. `packages/core/realtime/use-realtime-sync.ts:462-487``onReconnect` 全量 invalidate 不跑
5. 期间的 WS 事件进黑洞
6. cache 保持旧快照
### 复现
**浏览器 DevTools 里的 "Block request URL" 不行** —— 那会触发 `onclose`,走正常重连 → 不复现。真正的半开需要在网络层静默丢包。
**方法 A推荐最接近真实场景**macOS 用 pfctl 丢包
```bash
# 假设后端在 8080
sudo pfctl -E
echo "block drop out quick proto tcp to any port 8080" | sudo pfctl -f -
# 观察:
# - Console 里没有 "disconnected, reconnecting in 3s" 日志
# - Network 里 WS 连接仍显示 Pending / 101
# 用另一个账号/CLI 创建一个 issue
# 回到原客户端: 列表不更新
# 登出再登入: 列表恢复完整
sudo pfctl -d # 解除
```
**方法 B不动网络**:临时修改代码,在 `packages/core/api/ws-client.ts:52``onmessage` 处理器里加一行 `return;` 在前面,吞掉所有入站消息。效果等价于半开。
### 修复方案(三个选项,推荐 C
#### 选项 A — 浏览器端心跳探活(治本,改动大)
`ws-client.ts` 加客户端侧的心跳检测:记录 `lastMessageTime`,定时器检查若超过 N 秒没收到任何消息就主动 `ws.close()`,触发现有重连逻辑。
- 优点:从根本上解决半开问题
- 缺点:浏览器原生 API 没有 ping 能力,需要服务端配合发"应用层 heartbeat"消息供客户端更新 `lastMessageTime`;服务端改 + 客户端改
#### 选项 B — Page Visibility API 触发 invalidate治标改动小
`packages/core/platform/core-provider.tsx``visibilitychange` 监听tab 重新可见时强制 `queryClient.invalidateQueries({ queryKey: issueKeys.all(wsId) })`(及其他关键 key
- 优点:~10 行代码,能兜住 80% 场景(睡眠、切后台 tab
- 缺点treats symptom, 不是真正的半开检测;对"一直保持 tab 可见但网络层断了"的场景无效
#### 选项 C — **A + B 组合**(推荐)
- 短期上 B立刻止血
- 中期上 A把 cache 新鲜度从"只信 WS"改成"WS 是优化Visibility 是兜底"
- 可选加 `refetchOnWindowFocus: true` 或把 `staleTime` 改成一个有限值(比如 5 min作为第三层保险
### 改动范围
| 方案 | 文件 | 改动规模 |
|---|---|---|
| B | `packages/core/platform/core-provider.tsx` | ~10 行 |
| A 客户端 | `packages/core/api/ws-client.ts` | ~30 行 |
| A 服务端 | `server/internal/realtime/hub.go` | 加 app-level heartbeat message |
### 验证
修完之后:
1. 跑方法 A 复现流程,确认数据不再丢失
2. 加 e2e 测试:模拟 `document.dispatchEvent(new Event('visibilitychange'))` + 验证 issue list 被 refetch
---
## 任务 2 — [P1] Workspace 不在 URL 路径中
**关联 issue**MUL-723slug 不在 URL、MUL-43切换 workspace 报错、MUL-509手机端无法切换
> **注意**:审计中提到的 MUL-43 / MUL-476 issue 编号需要当面核对一次 —— agent 查询 GitHub 后返回的标题对不上(看起来是别的 PR。交接时请让执行人以具体症状为准。
### 问题
当前 workspace 身份完全靠 `X-Workspace-ID` HTTP header + Zustand store + localStorage 承载URL 里没有 workspace 信息。所有路径都是 `/issues``/issues/:id` 这种 workspace-agnostic 的。
### 根因
**数据库和 API 已经支持 slug**
- `server/migrations/001_init.up.sql:15-23` — workspace 表有 `slug TEXT UNIQUE NOT NULL`
- `server/pkg/db/queries/workspace.sql:11-13` — 有 `GetWorkspaceBySlug` 查询
- `packages/core/types/workspace.ts:8-19` — Workspace 类型里有 slug 字段
**但前端路由和导航层没用它**
- Web 路由:`apps/web/app/(dashboard)/` 下 25 个 route file 都是 workspace-implicit
- Desktop 路由:`apps/desktop/src/renderer/src/routes.tsx:71-143` 同样
- Navigation 适配器 `apps/web/platform/navigation.tsx` 直接透传 `router.push`,没有任何 workspace 前缀逻辑
**workspace 切换只靠 sidebar UI**`packages/views/layout/app-sidebar.tsx:284-286`
```tsx
if (ws.id !== workspace?.id) {
push("/issues"); // 硬跳 /issuesworkspace-implicit
switchWorkspace(ws); // 然后改 store
}
```
这种设计使得:
- 手机端因为没 sidebar UI也没 URL 层切换入口,**完全切不了 workspace**MUL-509
-`/issues/xxx` 链接发给处于不同 workspace 的同事,会打开错误 workspace 下的 issue或找不到报错MUL-43 系列)
- 分享链接没有 workspace 上下文,接收方必须先手动切对 workspace
### 复现
1. **MUL-723**:登录 → 观察地址栏,没有任何 workspace 标识
2. **MUL-43**
- 加入两个 workspace A 和 B
- 在 A 中打开某个 issue `/issues/abc123`
- 切到 BURL 不变 → 访问失败 / 显示错数据
3. **MUL-509**:手机浏览器打开,尝试切 workspace → 无法切换UI 不显示 sidebar 触发器或触发器无法切)
### 修复方案(三个选项,推荐 A
#### 选项 A — `/ws/:slug/...` URL 前缀(根本方案,推荐)
所有路径加上 workspace slug 前缀。例如 `/issues/abc123``/ws/my-team/issues/abc123`
**要改的地方**
1. **Web 路由目录结构**`apps/web/app/(dashboard)/` 下全部搬到 `apps/web/app/(dashboard)/ws/[slug]/...`~25 个文件)
2. **Desktop 路由**`apps/desktop/src/renderer/src/routes.tsx:71-143` 给所有路径加 `/ws/:slug` 前缀
3. **Navigation 适配器**
- `apps/web/platform/navigation.tsx``push(path)` 内部前置 `/ws/${workspace.slug}``pathname` 读取时去掉前缀
- `apps/desktop/src/renderer/src/platform/navigation.tsx` — 同上
4. **Sidebar 切换逻辑**`packages/views/layout/app-sidebar.tsx:284-286` 改成 `push('/ws/${ws.slug}/issues')`(或依赖适配器自动加前缀就不用改)
5. **服务端中间件**`server/internal/middleware/workspace.go:41-46` 增加 "从 URL path 解析 slug → 查 ID → 校验 membership" 的逻辑header 继续作为 fallback迁移期兼容
**预计改动**~50-100 个文件(大部分是 route 搬迁,不是逻辑改动)、~5-7 人天
**不改也能工作的部分**
- `packages/core/api/client.ts` — 仍旧走 header不用改
- 所有 `packages/views/` 下的组件 —— 它们用 `useNavigation().push()` 抽象,适配器层处理前缀就行
**风险**
- 旧的 bookmark URL 失效(如果产品还没正式 ship问题不大
- E2E 测试需要更新所有 URL 断言
#### 选项 B — `?ws=slug` query param折中
URL 形如 `/issues?ws=my-team`。改动更小(~30 个文件URL 丑但向后兼容。推荐度低于 A。
#### 选项 C — 只修症状不动架构
`switchWorkspace` 和各个 query 之间加 debounce、error boundary 等 workaround。不解决根因技术债越攒越多。**不推荐**。
### 改动范围(选项 A
| 模块 | 文件数 | 备注 |
|---|---|---|
| Web routes | ~25 | 目录搬迁 |
| Desktop routes | 1 | 路径前缀 |
| Navigation adapters | 2 | 前缀逻辑 |
| Server middleware | 1-2 | slug → ID 解析 |
| 组件(不用改) | 30-40 | 用 `useNavigation` 的不受影响 |
| E2E tests | 20-30 | URL 断言更新 |
---
## 任务 3 — [P1] Workspace 切换时 navigation 状态未隔离
**关联 issue**MUL-43切换报错、MUL-476本地缓存未按 workspace 隔离)
> 同上,这两个编号建议交接时核对症状。
### 问题
绝大多数 workspace-scoped 的 Zustand store 都正确使用了 `createWorkspaceAwareStorage`key 后缀加 wsId 自动隔离),但 **`useNavigationStore` 是个例外**:它持久化了 `lastPath`,但用的是 global storage切换 workspace 后里面仍是上个 workspace 的路径。
### 根因
**`packages/core/navigation/store.ts:15-31`**
```typescript
export const useNavigationStore = create<NavigationState>()(
persist(
(set) => ({
lastPath: "/issues",
onPathChange: (path) => { /* ... */ set({ lastPath: path }); },
}),
{
name: "multica_navigation",
storage: createJSONStorage(() => createPersistStorage(defaultStorage)), // ← 这里用的是 global不是 workspace-aware
partialize: (state) => ({ lastPath: state.lastPath }),
}
)
);
// ← 没有调 registerForWorkspaceRehydration
```
**对比:其他 store 都是正确的**
| Store | 是否 workspace-aware | 是否注册 rehydration |
|---|---|---|
| useNavigationStore | ❌ | ❌ |
| useIssuesScopeStore | ✅ | ✅ |
| useIssueDraftStore | ✅ | ✅ |
| useRecentIssuesStore | ✅ | ✅ |
| useIssueViewStore | ✅ | ✅ |
| myIssuesViewStore | ✅ | ✅ |
| useChatStore | ✅(手动用 wsKey| ✅ |
另外 `packages/core/platform/storage-cleanup.ts:10-19``WORKSPACE_SCOPED_KEYS` 列表里也漏了 `multica_navigation`
**现有的 workaround**`packages/views/layout/app-sidebar.tsx:285` 切 workspace 时硬跳到 `/issues`,正是为了绕开这个 bug。修好 navigation store 之后这行 hack 可以删掉。
### 复现
1. 在 workspace A 中打开一个具体 issue `/issues/abc123`
2. 切到 workspace B
3. 观察:如果没有 sidebar 的硬跳 workaround会尝试恢复到 `/issues/abc123`,但那个 issue 不属于 B导致 404 或错误
目前因为有硬跳 workaround症状表现为"切 workspace 后总是回到 issue 首页"—— 这本身也是 bug用户期望记住上次位置
### 修复方案(推荐 Option C组合
**三处改动**
1. `packages/core/navigation/store.ts:28` —— 把 `createPersistStorage(defaultStorage)` 改成 `createWorkspaceAwareStorage(defaultStorage)`
2. 同文件在末尾加:`registerForWorkspaceRehydration(() => useNavigationStore.persist.rehydrate());`
3. `packages/core/platform/storage-cleanup.ts:10-19``WORKSPACE_SCOPED_KEYS` 数组里加 `"multica_navigation"`
**可选**:清理 `packages/views/layout/app-sidebar.tsx:285``push("/issues")` workaround改完之后不再需要
### 改动范围
| 文件 | 改动 |
|---|---|
| `packages/core/navigation/store.ts` | 改 storage 类型、加 rehydration 注册(~3 行) |
| `packages/core/platform/storage-cleanup.ts` | 数组加一行 |
| `packages/core/platform/workspace-storage.test.ts` | 加 rehydration 的单测 |
| `packages/views/layout/app-sidebar.tsx`(可选) | 移除硬跳 workaround |
**风险**:极低。只是把 navigation store 对齐到其他 store 已经在用的模式。
---
## 任务 4 — [P2] Workspace 生命周期副作用散落
**关联 issue**MUL-727创建后闪页、MUL-728删除确认、MUL-820接受邀请不自动切
### 问题
创建 / 删除 / 切换 / 加入 workspace 的副作用分散在 mutation 的 `onSuccess` 和各处 UI 回调里,没有统一抽象。几个具体 bug
### 4.1 MUL-727 — 创建 workspace 后闪一下 `/issues` 再跳 `/onboarding`
**根因**:两个 `onSuccess` 回调同时跑,顺序不确定。
- `packages/core/workspace/mutations.ts:7-21``useCreateWorkspace.onSuccess` 里调了 `switchWorkspace(newWs)` —— 同步改 Zustand`/issues` 路由开始用新 workspace 渲染
- `packages/views/modals/create-workspace.tsx:68-70` 的 UI `onSuccess` 里调了 `router.push("/onboarding")` —— 异步 schedule 导航
于是:`/issues` 先渲染(闪一下)→ 导航到 `/onboarding`
**修复**:把 `switchWorkspace` 从 mutation 里拿出来,让 UI 层主导。在 `create-workspace.tsx``onSuccess` 里先 `switchWorkspace``push`,保证同一个微任务里完成。
**文件**`packages/core/workspace/mutations.ts``packages/views/modals/create-workspace.tsx`、可能 `packages/views/onboarding/step-workspace.tsx`
### 4.2 MUL-728 — 删除 workspace 的"缺少确认"
**核查结果**`packages/views/settings/components/workspace-tab.tsx:102-119, 236-255` **已经有 AlertDialog 确认**了。
**真实问题**:删除成功后**没有导航**,用户停在 `/settings`,而当前 workspace 已经是删除后系统挑的另一个。
**修复**:在 `handleDeleteWorkspace``onConfirm` 成功分支里加 `push("/issues")`
**文件**`packages/views/settings/components/workspace-tab.tsx`(加一行)
### 4.3 MUL-820 — 接受邀请不自动切换 workspace
**核查结果**:有两条路径:
-`/invite/:id` 独立页(`packages/views/invite/invite-page.tsx:32-52`)是**正确的**accept → switchWorkspace → push("/issues")
-**Sidebar 下拉里的 "Join" 按钮**`packages/views/layout/app-sidebar.tsx:203-209, 321-324`**是错的**:只 invalidate cache不切也不跳
**修复(推荐 Option 2**Sidebar 的 "Join" 改成跳转到 `/invite/:id` 页面,不再就地接受。单一入口、单一行为。
```tsx
<DropdownMenuItem onClick={() => push(`/invite/${inv.id}`)}>
{inv.workspace_name}
</DropdownMenuItem>
```
**文件**`packages/views/layout/app-sidebar.tsx`~10 行)
### 复现
| Issue | 步骤 |
|---|---|
| MUL-727 | 创建新 workspace → 仔细看是否闪了一下 `/issues` 再跳 `/onboarding` |
| MUL-728 | 删除当前 workspace → 观察删完后是否留在 `/settings` 页面BUG: 没有自动跳走) |
| MUL-820 | 被邀请用户登录 → sidebar 下拉 → 点 "Join" → 观察当前 workspace 是否切过去BUG: 不切)|
### 长期架构建议(可选)
抽一个 `useWorkspaceLifecycle` hook 统一管这些副作用。Agent 报告里有完整设计,文件:`packages/core/workspace/hooks.ts`(新建)。但建议先修 MUL-727/728/820 三个具体 bughook 抽象作为后续迭代。
### 改动范围
| Issue | 文件 | 改动规模 |
|---|---|---|
| MUL-727 | mutations.ts + create-workspace.tsx | ~10 行 |
| MUL-728 | workspace-tab.tsx | ~1 行 |
| MUL-820 | app-sidebar.tsx | ~10 行 |
---
## 总览
| 任务 | Issue | 优先级 | 预估规模 | 风险 |
|---|---|---|---|---|
| 1. WS 半开 + 陈旧 cache | #951 | **P0** | Option B ~10 行Option C ~1-2 天 | 低 |
| 2. Workspace URL 化 | MUL-723/43/509 | P1 | 5-7 人天(大部分是搬迁)| 中影响面大、e2e 要改)|
| 3. Navigation store 隔离 | MUL-43/476 | P1 | ~0.5 天 | 低 |
| 4. Workspace 生命周期 bug | MUL-727/728/820 | P2 | ~1 天 | 低 |
### 建议推进顺序
1. **立刻做**:任务 1 的 Option Bvisibilitychange 触发 invalidate—— 代码最少、收益最明显,能当天止血
2. **同步开始**:任务 3navigation store 隔离)—— 影响小、风险低、顺便清掉一个 workaround
3. **规划立项**:任务 2URL 化)—— 大改造,需要单独开一个 iteration
4. **次要修补**:任务 4 的三个小 bug —— 可以拆成独立 PR各自 review
### 重要澄清
- **Issue 编号核对**MUL-43 / MUL-476 的编号需要核对一次agent 查询 GitHub 返回的标题看起来对不上(可能是内部 issue tracker 编号 vs GitHub 编号混用)。以症状为准。
- **MUL-728 实际状态**:确认对话框已经存在,真实缺的是"删除后跳走"。
- **MUL-820 实际状态**`/invite/:id` 页面路径工作正常,只是 sidebar 下拉按钮坏了。
### 所有关键代码位置索引
```
packages/core/query-client.ts:7-10 # staleTime: Infinity
packages/core/api/ws-client.ts:1-142 # 客户端 WS无心跳
packages/core/realtime/use-realtime-sync.ts:462-487 # onReconnect 全量 invalidate
packages/core/platform/core-provider.tsx # 加 visibilitychange 的位置
packages/core/navigation/store.ts:15-31 # lastPath 未隔离
packages/core/platform/storage-cleanup.ts:10-19 # WORKSPACE_SCOPED_KEYS
packages/core/workspace/store.ts:43-77 # hydrateWorkspace / switchWorkspace
packages/core/workspace/mutations.ts:7-57 # create/leave/delete 三个 mutation
packages/views/layout/app-sidebar.tsx:203-324 # 侧边栏切 workspace、接受邀请入口
packages/views/modals/create-workspace.tsx:63-82 # 创建 workspace 入口
packages/views/settings/components/workspace-tab.tsx:102-119 # 删除 workspace 入口
packages/views/invite/invite-page.tsx:32-52 # 接受邀请正确实现参考
server/internal/realtime/hub.go:83-96 # 服务端 WS 心跳
server/internal/middleware/workspace.go:41-46 # wsId resolution
server/migrations/001_init.up.sql:15-23 # workspace.slug 已存在
```

View File

@@ -104,6 +104,8 @@ start:
@echo "Backend: http://localhost:$(PORT)"
@echo "Frontend: http://localhost:$(FRONTEND_PORT)"
@bash scripts/ensure-postgres.sh "$(ENV_FILE)"
@echo "Running migrations..."
cd server && go run ./cmd/migrate up
@echo "Starting backend and frontend..."
@trap 'kill 0' EXIT; \
(cd server && go run ./cmd/server) & \

Binary file not shown.

BIN
apps/desktop/build/icon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 121 KiB

BIN
apps/desktop/build/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

View File

@@ -29,6 +29,7 @@
"@multica/ui": "workspace:*",
"@multica/views": "workspace:*",
"electron-updater": "^6.8.3",
"fix-path": "^5.0.0",
"react-router-dom": "^7.6.0",
"shadcn": "^4.1.0",
"sonner": "^2.0.7",
@@ -38,6 +39,8 @@
"@electron-toolkit/tsconfig": "^2.0.0",
"@multica/tsconfig": "workspace:*",
"@tailwindcss/vite": "^4",
"@testing-library/jest-dom": "catalog:",
"@testing-library/react": "catalog:",
"@types/node": "catalog:",
"@types/react": "catalog:",
"@types/react-dom": "catalog:",
@@ -45,6 +48,7 @@
"electron": "^39.2.6",
"electron-builder": "^26.0.12",
"electron-vite": "^5.0.0",
"jsdom": "catalog:",
"react": "catalog:",
"react-dom": "catalog:",
"tailwindcss": "^4",

Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 KiB

After

Width:  |  Height:  |  Size: 735 KiB

View File

@@ -5,13 +5,21 @@
// binary via the `main.version` ldflag — so a single `vX.Y.Z` tag push
// produces matching CLI and Desktop versions.
//
// Runs the existing bundle-cli.mjs first (so the Go binary is compiled
// and copied into resources/bin/), then invokes electron-builder with
// `-c.extraMetadata.version=<derived>` so the override applies at build
// time without mutating the tracked package.json.
// Runs bundle-cli.mjs first (so the Go binary is compiled and copied
// into resources/bin/), then `electron-vite build` to produce the
// main/preload/renderer bundles under out/, then invokes electron-builder
// with `-c.extraMetadata.version=<derived>` so the override applies at
// build time without mutating the tracked package.json.
//
// The electron-vite step is important: electron-builder only packages
// whatever is already in out/, so skipping it (or relying on stale
// artifacts from a prior partial build) ships an app with missing
// renderer code and white-screens on launch.
//
// Extra CLI args after `pnpm package --` are forwarded to electron-builder
// unchanged (e.g. `--mac --arm64`).
// unchanged (e.g. `--mac --arm64`). For an unsigned local smoke-test
// build, set `CSC_IDENTITY_AUTO_DISCOVERY=false` so electron-builder falls
// back to an ad-hoc signature instead of requiring a Developer ID cert.
//
// The `normalizeGitVersion` helper is exported so tests can cover the
// version-derivation logic without shelling out.
@@ -64,7 +72,26 @@ function main() {
cwd: desktopRoot,
});
// Step 2: derive the version that should be written into the app.
// Step 2: build the Electron main/preload/renderer bundles. Without
// this step electron-builder silently packages whatever is already in
// out/, which on a fresh checkout (or after a partial build) ships an
// app that white-screens because the renderer bundle is missing.
const viteResult = spawnSync("electron-vite", ["build"], {
stdio: "inherit",
cwd: desktopRoot,
});
if (viteResult.error) {
console.error(
"[package] failed to spawn electron-vite:",
viteResult.error.message,
);
process.exit(1);
}
if (viteResult.status !== 0) {
process.exit(viteResult.status ?? 1);
}
// Step 3: derive the version that should be written into the app.
const version = deriveVersion();
if (version) {
console.log(`[package] Desktop version → ${version} (from git describe)`);
@@ -74,12 +101,12 @@ function main() {
);
}
// Step 3: assemble electron-builder args.
// Step 4: assemble electron-builder args.
const passthrough = process.argv.slice(2);
const builderArgs = [];
if (version) builderArgs.push(`-c.extraMetadata.version=${version}`);
// Step 4: gracefully degrade for local dev builds. electron-builder.yml
// Step 5: gracefully degrade for local dev builds. electron-builder.yml
// sets `notarize: true` so real releases notarize in-build (keeping the
// stapled .app consistent with latest-mac.yml's SHA512). But a mac dev
// who just wants to smoke-test a local package doesn't have Apple
@@ -95,7 +122,7 @@ function main() {
builderArgs.push(...passthrough);
// Step 5: invoke electron-builder. pnpm puts node_modules/.bin on PATH
// Step 6: invoke electron-builder. pnpm puts node_modules/.bin on PATH
// for the script run, so spawnSync finds the binary without needing a
// shell wrapper (avoids any risk of argv interpolation).
const result = spawnSync("electron-builder", builderArgs, {

View File

@@ -598,11 +598,12 @@ function profileArgs(active: ActiveProfile): string[] {
// Env passed to every CLI child so the daemon process knows it was spawned
// by the Desktop app. The server uses this to mark runtimes as managed and
// hide CLI self-update UI.
const DESKTOP_SPAWN_ENV = {
...process.env,
MULTICA_LAUNCHED_BY: "desktop",
};
// hide CLI self-update UI. Computed lazily so it picks up the PATH fix
// applied by fix-path in main/index.ts — as a top-level const it would
// snapshot process.env at import time, before that block runs.
function desktopSpawnEnv(): NodeJS.ProcessEnv {
return { ...process.env, MULTICA_LAUNCHED_BY: "desktop" };
}
async function startDaemon(): Promise<{ success: boolean; error?: string }> {
const bin = await resolveCliBinary();
@@ -624,7 +625,7 @@ async function startDaemon(): Promise<{ success: boolean; error?: string }> {
execFile(
bin,
args,
{ timeout: 20_000, env: DESKTOP_SPAWN_ENV },
{ timeout: 20_000, env: desktopSpawnEnv() },
(err) => {
if (err) {
currentState = "stopped";

View File

@@ -1,9 +1,31 @@
import { app, shell, BrowserWindow, ipcMain } from "electron";
import { homedir } from "os";
import { join } from "path";
import { electronApp, optimizer, is } from "@electron-toolkit/utils";
import fixPath from "fix-path";
import { setupAutoUpdater } from "./updater";
import { setupDaemonManager } from "./daemon-manager";
// macOS/Linux GUI launches inherit a minimal PATH from launchd that omits
// the user's shell config (~/.zshrc, Homebrew, nvm, ~/.local/bin, etc.).
// Run the user's login shell once to recover the real PATH so the bundled
// multica CLI can find agent binaries like claude/codex/opencode. Must run
// before any child_process.spawn / execFile call in the main process —
// ES module imports are hoisted, so this block executes before createWindow
// or any daemon-manager spawn.
if (process.platform !== "win32") {
fixPath();
// Fallback: prepend common install locations in case fix-path came up
// short (broken shell rc, non-interactive $SHELL, missing entries). Safe
// to duplicate — PATH lookups short-circuit on first match.
const fallbackPaths = [
"/opt/homebrew/bin",
"/usr/local/bin",
join(homedir(), ".local/bin"),
];
process.env.PATH = `${fallbackPaths.join(":")}:${process.env.PATH ?? ""}`;
}
const PROTOCOL = "multica";
let mainWindow: BrowserWindow | null = null;

View File

@@ -1,7 +1,8 @@
import { useEffect } from "react";
import { useEffect, useState } from "react";
import { useQueryClient } from "@tanstack/react-query";
import { CoreProvider } from "@multica/core/platform";
import { useAuthStore } from "@multica/core/auth";
import { useWorkspaceStore } from "@multica/core/workspace";
import { workspaceKeys } from "@multica/core/workspace/queries";
import { api } from "@multica/core/api";
import { ThemeProvider } from "@multica/ui/components/common/theme-provider";
import { MulticaIcon } from "@multica/ui/components/common/multica-icon";
@@ -13,6 +14,15 @@ import { UpdateNotification } from "./components/update-notification";
function AppContent() {
const user = useAuthStore((s) => s.user);
const isLoading = useAuthStore((s) => s.isLoading);
const qc = useQueryClient();
// Deep-link login runs loginWithToken → syncToken → listWorkspaces →
// setQueryData sequentially. loginWithToken sets user+isLoading=false
// as soon as getMe resolves, which would cause DesktopShell to mount
// before the workspace list is hydrated and briefly see `!workspace`.
// This local flag keeps the loading screen up until the whole chain
// finishes, so the shell's "needs onboarding?" check gets a definitive
// workspace state on first render.
const [bootstrapping, setBootstrapping] = useState(false);
// Tell the main process which backend URL we talk to, so daemon-manager
// can pick the matching CLI profile (server_url from ~/.multica config).
@@ -20,20 +30,28 @@ function AppContent() {
window.daemonAPI.setTargetApiUrl(DAEMON_TARGET_API_URL);
}, []);
// Listen for auth token delivered via deep link (multica://auth/callback?token=...)
// Listen for auth token delivered via deep link (multica://auth/callback?token=...).
// daemonAPI.syncToken is handled separately by the [user] effect below, which
// fires whenever a user logs in (deep link, session restore, account switch).
useEffect(() => {
return window.desktopAPI.onAuthToken(async (token) => {
setBootstrapping(true);
try {
const loggedIn = await useAuthStore.getState().loginWithToken(token);
await window.daemonAPI.syncToken(token, loggedIn.id);
await useAuthStore.getState().loginWithToken(token);
// Seed React Query cache with the workspace list so the index-route
// redirect (routes.tsx `IndexRedirect`) can resolve the initial
// destination without a second fetch. Workspace side-effects
// (setCurrentWorkspace, persist namespace) are synced later by
// WorkspaceRouteLayout when the URL resolves.
const wsList = await api.listWorkspaces();
const lastWsId = localStorage.getItem("multica_workspace_id");
useWorkspaceStore.getState().hydrateWorkspace(wsList, lastWsId);
qc.setQueryData(workspaceKeys.list(), wsList);
} catch {
// Token invalid or expired — user stays on login page
} finally {
setBootstrapping(false);
}
});
}, []);
}, [qc]);
// Sync token and start the daemon whenever the user logs in.
useEffect(() => {
@@ -51,7 +69,7 @@ function AppContent() {
})();
}, [user]);
if (isLoading) {
if (isLoading || bootstrapping) {
return (
<div className="flex h-screen items-center justify-center">
<MulticaIcon className="size-6 animate-pulse" />

View File

@@ -1,4 +1,4 @@
import { useEffect } from "react";
import { useEffect, useSyncExternalStore } from "react";
import { ChevronLeft, ChevronRight } from "lucide-react";
import { cn } from "@multica/ui/lib/utils";
import { useTabHistory } from "@/hooks/use-tab-history";
@@ -6,14 +6,18 @@ import { useActiveTitleSync } from "@/hooks/use-tab-sync";
import { useTabStore, resolveRouteIcon } from "@/stores/tab-store";
import {
SidebarProvider,
SidebarTrigger,
useSidebar,
} from "@multica/ui/components/ui/sidebar";
import { ModalRegistry } from "@multica/views/modals/registry";
import { AppSidebar, DashboardGuard } from "@multica/views/layout";
import { AppSidebar } from "@multica/views/layout";
import { SearchCommand, SearchTrigger } from "@multica/views/search";
import { ChatFab, ChatWindow } from "@multica/views/chat";
import { StepWorkspace } from "@multica/views/onboarding";
import { WorkspaceSlugProvider } from "@multica/core/paths";
import { getCurrentSlug, subscribeToCurrentSlug } from "@multica/core/platform";
import { DesktopNavigationProvider } from "@/platform/navigation";
import { MulticaIcon } from "@multica/ui/components/common/multica-icon";
import { OnboardingGate } from "./onboarding-gate";
import { TabBar } from "./tab-bar";
import { TabContent } from "./tab-content";
@@ -51,17 +55,28 @@ function SidebarTopBar() {
}
// The main area's top bar doubles as a window drag region. When the sidebar
// is collapsed, we pad the left side so tabs don't land under the macOS
// traffic lights (which live at roughly x=16..68 and always hit-test above HTML).
// is not occupying main-flow width — either user-collapsed (offcanvas) or
// auto-hidden in mobile mode (<768px, becomes a sheet drawer) — we pad the
// left side so tabs don't land under the macOS traffic lights (which live at
// roughly x=16..68 and always hit-test above HTML), and surface a trigger so
// the sidebar can be brought back without keyboard shortcut.
function MainTopBar() {
const { state } = useSidebar();
const sidebarCollapsed = state === "collapsed";
const { state, isMobile } = useSidebar();
const sidebarHidden = state === "collapsed" || isMobile;
return (
<header
className={cn("h-12 shrink-0", sidebarCollapsed && "pl-20")}
className={cn(
"h-12 shrink-0 flex items-center gap-2",
sidebarHidden && "pl-20",
)}
style={{ WebkitAppRegion: "drag" } as React.CSSProperties}
>
{sidebarHidden && (
<SidebarTrigger
style={{ WebkitAppRegion: "no-drag" } as React.CSSProperties}
/>
)}
<TabBar />
</header>
);
@@ -86,34 +101,47 @@ export function DesktopShell() {
useInternalLinkHandler();
useActiveTitleSync();
// Reactive read of current workspace slug from the platform singleton.
// On first mount, slug is null until WorkspaceRouteLayout (inside the tab
// router) sets it. Once set, the sidebar and other shell-level components
// can resolve workspace-scoped paths via useWorkspacePaths().
const slug = useSyncExternalStore(subscribeToCurrentSlug, getCurrentSlug, () => null);
return (
<DesktopNavigationProvider>
<DashboardGuard
loginPath="/login"
loadingFallback={
<div className="flex h-screen items-center justify-center">
<MulticaIcon className="size-6 animate-pulse" />
<OnboardingGate
onboarding={(onComplete) => (
<div className="flex min-h-screen items-center justify-center overflow-auto bg-background px-6 py-12">
<StepWorkspace onNext={onComplete} />
</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">
<MainTopBar />
{/* Content area with inset styling — relative so ChatWindow/ChatFab are constrained here */}
<div className="relative flex flex-1 min-h-0 flex-col overflow-hidden mr-2 mb-2 ml-0.5 rounded-xl shadow-sm bg-background">
<TabContent />
<ChatWindow />
<ChatFab />
{/* WorkspaceSlugProvider accepts null — components that need slug
use useWorkspaceSlug() (nullable) or useRequiredWorkspaceSlug()
(throws). TabContent MUST always render so the tab router can
mount WorkspaceRouteLayout, which calls setCurrentWorkspace()
to populate the slug. The sidebar gates on slug being present
to avoid the useRequiredWorkspaceSlug throw. */}
<WorkspaceSlugProvider slug={slug}>
<div className="flex h-screen">
<SidebarProvider className="flex-1">
{slug && <AppSidebar topSlot={<SidebarTopBar />} searchSlot={<SearchTrigger />} />}
{/* Right side: header + content container */}
<div className="flex flex-1 min-w-0 flex-col">
<MainTopBar />
{/* Content area with inset styling — relative so ChatWindow/ChatFab are constrained here */}
<div className="relative flex flex-1 min-h-0 flex-col overflow-hidden mr-2 mb-2 ml-0.5 rounded-xl shadow-sm bg-background">
<TabContent />
{slug && <ChatWindow />}
{slug && <ChatFab />}
</div>
</div>
</div>
</SidebarProvider>
</div>
<ModalRegistry />
<SearchCommand />
</DashboardGuard>
</SidebarProvider>
</div>
{slug && <ModalRegistry />}
{slug && <SearchCommand />}
</WorkspaceSlugProvider>
</OnboardingGate>
</DesktopNavigationProvider>
);
}

View File

@@ -0,0 +1,99 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, act } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { workspaceKeys } from "@multica/core/workspace/queries";
import { OnboardingGate } from "./onboarding-gate";
// Prevent actual API calls — the tests seed data via setQueryData.
vi.mock("@multica/core/api", () => ({
api: {
listWorkspaces: vi.fn().mockResolvedValue([]),
},
}));
function createTestQueryClient(
workspaces: Array<{ id: string; slug: string }> = [],
) {
const qc = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
// Seed the workspace list so the gate can read it synchronously.
qc.setQueryData(workspaceKeys.list(), workspaces);
return qc;
}
function renderGate(
qc: QueryClient,
onboarding?: (onComplete: () => void) => React.ReactNode,
) {
return render(
<QueryClientProvider client={qc}>
<OnboardingGate
onboarding={
onboarding ??
((onComplete) => (
<button type="button" data-testid="finish" onClick={onComplete}>
wizard
</button>
))
}
>
<div data-testid="main">main shell</div>
</OnboardingGate>
</QueryClientProvider>,
);
}
describe("OnboardingGate", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("renders children when workspaces exist in cache", () => {
const qc = createTestQueryClient([{ id: "ws-1", slug: "my-team" }]);
renderGate(qc);
expect(screen.getByTestId("main")).toBeInTheDocument();
expect(screen.queryByText("wizard")).not.toBeInTheDocument();
});
it("renders onboarding when workspace list is empty", () => {
const qc = createTestQueryClient([]);
renderGate(qc);
expect(screen.getByText("wizard")).toBeInTheDocument();
expect(screen.queryByTestId("main")).not.toBeInTheDocument();
});
it("keeps the wizard mounted even after workspaces appear in cache mid-flow", () => {
const qc = createTestQueryClient([]);
renderGate(qc);
expect(screen.getByText("wizard")).toBeInTheDocument();
// Simulate the onboarding wizard creating a workspace mid-flow.
act(() => {
qc.setQueryData(workspaceKeys.list(), [
{ id: "ws-new", slug: "new-team" },
]);
});
// Wizard should still be visible — only onComplete dismisses it.
expect(screen.getByText("wizard")).toBeInTheDocument();
expect(screen.queryByTestId("main")).not.toBeInTheDocument();
});
it("transitions to children after the wizard calls onComplete", () => {
const qc = createTestQueryClient([]);
renderGate(qc);
expect(screen.getByTestId("finish")).toBeInTheDocument();
act(() => {
screen.getByTestId("finish").click();
});
expect(screen.getByTestId("main")).toBeInTheDocument();
expect(screen.queryByTestId("finish")).not.toBeInTheDocument();
});
});

View File

@@ -0,0 +1,40 @@
import { useState, type ReactNode } from "react";
import { useQuery } from "@tanstack/react-query";
import { workspaceListOptions } from "@multica/core/workspace/queries";
/**
* Renders `onboarding` as a full-screen takeover when the user has no
* workspaces, otherwise renders `children`.
*
* Reads the workspace list directly from React Query — this works regardless
* of whether a WorkspaceSlugProvider is mounted, unlike useCurrentWorkspace()
* which depends on slug context from the router tree.
*
* The onboarding decision is frozen at first mount via the lazy useState
* initializer: this way the onboarding wizard controls its own exit by
* calling the `onComplete` callback, instead of being unmounted the moment
* the workspace list updates mid-flow (e.g. after the user creates their
* first workspace in step 1 but still has steps 2-3 to complete).
*
* The frozen decision only triggers when the initial query has settled AND
* the list is empty. While the list is loading, children are rendered
* (the shell shows its own loading state).
*/
export function OnboardingGate({
onboarding,
children,
}: {
onboarding: (onComplete: () => void) => ReactNode;
children: ReactNode;
}) {
const { data: workspaces, isFetched } = useQuery(workspaceListOptions());
const hasWorkspaces = !isFetched || (workspaces?.length ?? 0) > 0;
const [initialNeedsOnboarding] = useState(() => !hasWorkspaces);
const [onboardingDone, setOnboardingDone] = useState(false);
if (initialNeedsOnboarding && !onboardingDone) {
return <>{onboarding(() => setOnboardingDone(true))}</>;
}
return <>{children}</>;
}

View File

@@ -0,0 +1,60 @@
import { useEffect, useRef } from "react";
import { Outlet, useNavigate, useParams } from "react-router-dom";
import { useQuery } from "@tanstack/react-query";
import { WorkspaceSlugProvider, paths } from "@multica/core/paths";
import { workspaceBySlugOptions } from "@multica/core/workspace";
import {
setCurrentWorkspace,
rehydrateAllWorkspaceStores,
} from "@multica/core/platform";
import { useAuthStore } from "@multica/core/auth";
/**
* Desktop equivalent of apps/web/app/[workspaceSlug]/layout.tsx.
*
* Reads :workspaceSlug from react-router params, resolves it to a Workspace
* object via the React Query list cache, and syncs the URL-derived workspace
* into the platform singleton (slug + UUID). Children (DashboardGuard +
* dashboard layout) handle auth check, loading, and workspace-not-found.
*/
export function WorkspaceRouteLayout() {
const { workspaceSlug } = useParams<{ workspaceSlug: string }>();
const navigate = useNavigate();
const user = useAuthStore((s) => s.user);
const isAuthLoading = useAuthStore((s) => s.isLoading);
const { data: workspace, isFetched: listFetched } = useQuery({
...workspaceBySlugOptions(workspaceSlug ?? ""),
enabled: !!user && !!workspaceSlug,
});
// Render-phase sync (same pattern as web layout).
const syncedSlugRef = useRef<string | null>(null);
if (workspace && workspaceSlug && syncedSlugRef.current !== workspaceSlug) {
setCurrentWorkspace(workspaceSlug, workspace.id);
rehydrateAllWorkspaceStores();
// Double-write legacy localStorage key for rollback compatibility — see
// apps/web/app/[workspaceSlug]/layout.tsx for the full rationale.
try {
localStorage.setItem("multica_workspace_id", workspace.id);
} catch {
// non-critical
}
syncedSlugRef.current = workspaceSlug;
}
// Slug doesn't resolve → onboarding. Skip when user is null.
useEffect(() => {
if (!user) return;
if (listFetched && !workspace) navigate(paths.onboarding(), { replace: true });
}, [user, listFetched, workspace, navigate]);
if (isAuthLoading) return null;
if (!workspaceSlug) return null;
return (
<WorkspaceSlugProvider slug={workspaceSlug}>
<Outlet />
</WorkspaceSlugProvider>
);
}

View File

@@ -1,11 +1,9 @@
import { LoginPage } from "@multica/views/auth";
import { MulticaIcon } from "@multica/ui/components/common/multica-icon";
const WEB_URL = import.meta.env.VITE_WEB_URL || "http://localhost:3000";
const WEB_URL = import.meta.env.VITE_APP_URL || "http://localhost:3000";
export function DesktopLoginPage() {
const lastWorkspaceId = localStorage.getItem("multica_workspace_id");
const handleGoogleLogin = () => {
// Open web login page in the default browser with platform=desktop flag.
// The web callback will redirect back via multica:// deep link with the token.
@@ -23,9 +21,9 @@ export function DesktopLoginPage() {
/>
<LoginPage
logo={<MulticaIcon bordered size="lg" />}
lastWorkspaceId={lastWorkspaceId}
onSuccess={() => {
// Auth store update triggers AppContent re-render → shows DesktopShell
// Auth store update triggers AppContent re-render → shows DesktopShell.
// Initial workspace navigation happens in routes.tsx via IndexRedirect.
}}
onGoogleLogin={handleGoogleLogin}
/>

View File

@@ -6,6 +6,7 @@ import {
useMatches,
} from "react-router-dom";
import type { RouteObject } from "react-router-dom";
import { useQuery } from "@tanstack/react-query";
import { IssueDetailPage } from "./pages/issue-detail-page";
import { ProjectDetailPage } from "./pages/project-detail-page";
import { AutopilotDetailPage } from "./pages/autopilot-detail-page";
@@ -22,8 +23,11 @@ import { SettingsPage } from "@multica/views/settings";
import { OnboardingWizard } from "@multica/views/onboarding";
import { InvitePage } from "@multica/views/invite";
import { useNavigation } from "@multica/views/navigation";
import { paths } from "@multica/core/paths";
import { workspaceListOptions } from "@multica/core/workspace/queries";
import { Server } from "lucide-react";
import { DaemonSettingsTab } from "./components/daemon-settings-tab";
import { WorkspaceRouteLayout } from "./components/workspace-route-layout";
/**
* Sets document.title from the deepest matched route's handle.title.
@@ -57,7 +61,37 @@ function PageShell() {
function OnboardingRoute() {
const nav = useNavigation();
return <OnboardingWizard onComplete={() => nav.push("/issues")} />;
return (
<OnboardingWizard
onComplete={(ws) => nav.push(paths.workspace(ws.slug).issues())}
/>
);
}
/**
* Root index route: resolves the URL-less `/` path to a concrete destination.
*
* Runs both on first login (App.tsx seeded the cache) and on app reopen
* (AuthInitializer seeded the cache). Reading from React Query avoids
* duplicate fetches across tabs — each tab's memory router hits this
* component independently but the query is deduped.
*
* Sends first-time users without any workspace to onboarding, everyone
* else to their first workspace's issues page. Persisted tab paths that
* already carry a workspace slug bypass this component entirely.
*/
function IndexRedirect() {
const { data: wsList, isFetched } = useQuery(workspaceListOptions());
// Wait for the query to settle so we don't redirect to onboarding on
// the initial render before the seeded/fetched data arrives.
if (!isFetched) return null;
const firstWorkspace = wsList?.[0];
if (firstWorkspace) {
return <Navigate to={paths.workspace(firstWorkspace.slug).issues()} replace />;
}
return <Navigate to={paths.onboarding()} replace />;
}
function InviteRoute() {
@@ -67,51 +101,24 @@ function InviteRoute() {
return <InvitePage invitationId={id} />;
}
/** Route definitions shared by all tabs (no layout wrapper). */
/**
* Route definitions shared by all tabs.
*
* Structure mirrors the web app's [workspaceSlug]/... layout: all dashboard
* pages live under /:workspaceSlug, with WorkspaceRouteLayout resolving the
* slug to a workspace and syncing side-effects (api client, persist namespace,
* Zustand mirror). Global (pre-workspace) routes — onboarding and invite —
* sit at the top level alongside the workspace 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: "autopilots",
element: <AutopilotsPage />,
handle: { title: "Autopilot" },
},
{
path: "autopilots/:id",
element: <AutopilotDetailPage />,
handle: { title: "Autopilot" },
},
{
path: "my-issues",
element: <MyIssuesPage />,
handle: { title: "My Issues" },
},
{
path: "runtimes",
element: <RuntimesPage topSlot={<DaemonRuntimeCard />} />,
handle: { title: "Runtimes" },
},
{ path: "skills", element: <SkillsPage />, handle: { title: "Skills" } },
{ path: "agents", element: <AgentsPage />, handle: { title: "Agents" } },
{ path: "inbox", element: <InboxPage />, handle: { title: "Inbox" } },
// Top-level index: no slug yet. `IndexRedirect` reads the workspace
// list from React Query cache (seeded by AuthInitializer on reopen
// or App.tsx on deep-link login) and bounces to the first
// workspace's issues page — or onboarding if the user has none.
{ index: true, element: <IndexRedirect /> },
{
path: "onboarding",
element: <OnboardingRoute />,
@@ -123,20 +130,66 @@ export const appRoutes: RouteObject[] = [
handle: { title: "Accept Invite" },
},
{
path: "settings",
element: (
<SettingsPage
extraAccountTabs={[
{
value: "daemon",
label: "Daemon",
icon: Server,
content: <DaemonSettingsTab />,
},
]}
/>
),
handle: { title: "Settings" },
path: ":workspaceSlug",
element: <WorkspaceRouteLayout />,
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: "autopilots",
element: <AutopilotsPage />,
handle: { title: "Autopilot" },
},
{
path: "autopilots/:id",
element: <AutopilotDetailPage />,
handle: { title: "Autopilot" },
},
{
path: "my-issues",
element: <MyIssuesPage />,
handle: { title: "My Issues" },
},
{
path: "runtimes",
element: <RuntimesPage topSlot={<DaemonRuntimeCard />} />,
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
extraAccountTabs={[
{
value: "daemon",
label: "Daemon",
icon: Server,
content: <DaemonSettingsTab />,
},
]}
/>
),
handle: { title: "Settings" },
},
],
},
],
},

View File

@@ -3,6 +3,7 @@ import { createJSONStorage, persist } from "zustand/middleware";
import { arrayMove } from "@dnd-kit/sortable";
import { createPersistStorage, defaultStorage } from "@multica/core/platform";
import { createSafeId } from "@multica/core/utils";
import { isGlobalPath } from "@multica/core/paths";
import type { DataRouter } from "react-router-dom";
import { createTabRouter } from "../routes";
@@ -45,29 +46,50 @@ interface TabStore {
// ---------------------------------------------------------------------------
const ROUTE_ICONS: Record<string, string> = {
"/inbox": "Inbox",
"/my-issues": "CircleUser",
"/issues": "ListTodo",
"/projects": "FolderKanban",
"/agents": "Bot",
"/runtimes": "Monitor",
"/skills": "BookOpenText",
"/settings": "Settings",
inbox: "Inbox",
"my-issues": "CircleUser",
issues: "ListTodo",
projects: "FolderKanban",
autopilots: "ListTodo",
agents: "Bot",
runtimes: "Monitor",
skills: "BookOpenText",
settings: "Settings",
};
/** Resolve a route icon. Title is NOT determined here — it comes from document.title. */
/**
* Resolve a route icon from a pathname. Title is NOT determined here — it
* comes from document.title.
*
* Path shape after the workspace URL refactor:
* - workspace-scoped: `/{workspaceSlug}/{route}/...` → use segment index 1
* - global (onboarding/invite/auth/login): `/{route}/...` → use segment index 0
*
* `isGlobalPath` is the single source of truth for which prefixes are global.
*/
export function resolveRouteIcon(pathname: string): string {
return ROUTE_ICONS[pathname]
?? (pathname.startsWith("/issues/") ? "ListTodo" : undefined)
?? (pathname.startsWith("/projects/") ? "FolderKanban" : undefined)
?? "ListTodo";
const segments = pathname.split("/").filter(Boolean);
const routeSegment = isGlobalPath(pathname)
? (segments[0] ?? "")
: (segments[1] ?? "");
return ROUTE_ICONS[routeSegment] ?? "ListTodo";
}
// ---------------------------------------------------------------------------
// Store
// ---------------------------------------------------------------------------
const DEFAULT_PATH = "/issues";
/**
* Sentinel path for new tabs with no explicit destination. The tab store is
* workspace-implicit — it doesn't know which workspace is active, so it can't
* build a `/:slug/issues` path itself. Instead we hand off to the router: `/`
* matches the top-level index route, which redirects to the workspace default
* (slug-aware redirect lives in routes.tsx / App.tsx).
*
* `title` and `icon` on the placeholder tab get overwritten by
* useTabRouterSync + useActiveTitleSync once the redirect resolves.
*/
const DEFAULT_PATH = "/";
function createId(): string {
return createSafeId();
@@ -177,12 +199,28 @@ export const useTabStore = create<TabStore>()(
| undefined;
if (!persisted?.tabs?.length) return currentState;
const tabs: Tab[] = persisted.tabs.map((tab) => ({
...tab,
router: createTabRouter(tab.path),
historyIndex: 0,
historyLength: 1,
}));
const tabs: Tab[] = persisted.tabs.map((tab) => {
// Migration: pre-refactor tab paths like "/issues/abc" lack a
// workspace slug prefix. These would 404 in the new router.
// Reset to "/" so IndexRedirect picks the right workspace.
let path = tab.path;
if (path !== "/" && !isGlobalPath(path)) {
const segments = path.split("/").filter(Boolean);
const firstSegment = segments[0] ?? "";
// If the first segment IS a known route name (e.g. "issues",
// "projects"), it's an old-format path missing the slug prefix.
if (ROUTE_ICONS[firstSegment]) {
path = "/";
}
}
return {
...tab,
path,
router: createTabRouter(path),
historyIndex: 0,
historyLength: 1,
};
});
// Validate activeTabId — fall back to first tab if stale
const activeTabId = tabs.some((t) => t.id === persisted.activeTabId)

View File

@@ -0,0 +1 @@
import "@testing-library/jest-dom/vitest";

View File

@@ -4,7 +4,8 @@
"src/renderer/src/env.d.ts",
"src/renderer/src/**/*",
"src/renderer/src/**/*.tsx",
"src/preload/*.d.ts"
"src/preload/*.d.ts",
"test/setup.ts"
],
"compilerOptions": {
"composite": true,

View File

@@ -1,10 +1,13 @@
import { defineConfig } from "vitest/config";
import react from "@vitejs/plugin-react";
export default defineConfig({
plugins: [react()],
test: {
globals: true,
include: ["src/**/*.test.ts", "scripts/**/*.test.mjs"],
environment: "node",
include: ["src/**/*.test.{ts,tsx}", "scripts/**/*.test.mjs"],
environment: "jsdom",
setupFiles: ["./test/setup.ts"],
passWithNoTests: true,
},
});

View File

@@ -3,6 +3,7 @@
import { useEffect } from "react";
import { useRouter, useParams } from "next/navigation";
import { useAuthStore } from "@multica/core/auth";
import { paths } from "@multica/core/paths";
import { InvitePage } from "@multica/views/invite";
export default function InviteAcceptPage() {
@@ -14,7 +15,9 @@ export default function InviteAcceptPage() {
// Redirect to login if not authenticated, with a redirect back to this page.
useEffect(() => {
if (!isLoading && !user) {
router.replace(`/login?next=/invite/${params.id}`);
router.replace(
`${paths.login()}?next=${encodeURIComponent(paths.invite(params.id))}`,
);
}
}, [isLoading, user, router, params.id]);

View File

@@ -11,13 +11,10 @@ function createWrapper() {
);
}
const { mockSendCode, mockVerifyCode, mockHydrateWorkspace } = vi.hoisted(
() => ({
mockSendCode: vi.fn(),
mockVerifyCode: vi.fn(),
mockHydrateWorkspace: vi.fn(),
}),
);
const { mockSendCode, mockVerifyCode } = vi.hoisted(() => ({
mockSendCode: vi.fn(),
mockVerifyCode: vi.fn(),
}));
// Mock next/navigation
vi.mock("next/navigation", () => ({
@@ -47,16 +44,6 @@ vi.mock("@/features/auth/auth-cookie", () => ({
setLoggedInCookie: vi.fn(),
}));
// 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("@multica/core/api", () => ({
api: {

View File

@@ -2,8 +2,11 @@
import { Suspense, useEffect } from "react";
import { useSearchParams, useRouter } from "next/navigation";
import { useQueryClient } from "@tanstack/react-query";
import { useAuthStore } from "@multica/core/auth";
import { useWorkspaceStore } from "@multica/core/workspace";
import { workspaceKeys } from "@multica/core/workspace/queries";
import { paths } from "@multica/core/paths";
import type { Workspace } from "@multica/core/types";
import { setLoggedInCookie } from "@/features/auth/auth-cookie";
import { LoginPage, validateCliCallback } from "@multica/views/auth";
@@ -11,6 +14,7 @@ const googleClientId = process.env.NEXT_PUBLIC_GOOGLE_CLIENT_ID;
function LoginPageContent() {
const router = useRouter();
const qc = useQueryClient();
const user = useAuthStore((s) => s.user);
const isLoading = useAuthStore((s) => s.isLoading);
const searchParams = useSearchParams();
@@ -18,30 +22,46 @@ function LoginPageContent() {
const cliCallbackRaw = searchParams.get("cli_callback");
const cliState = searchParams.get("cli_state") || "";
const platform = searchParams.get("platform");
const nextUrl = searchParams.get("next") || "/issues";
// `next` carries a protected URL the user was originally headed to
// (e.g. /invite/{id}). With URL-driven workspaces there is no legacy
// "/issues" default — if `next` is absent we decide after login based on
// the user's workspace list.
const nextUrl = searchParams.get("next");
// Already authenticated — redirect to dashboard (skip if CLI callback)
// Already authenticated — honor ?next= or fall back to first workspace /
// onboarding. Skip this entire path when the user arrived to authorize the CLI.
useEffect(() => {
if (!isLoading && user && !cliCallbackRaw) {
if (isLoading || !user || cliCallbackRaw) return;
if (nextUrl) {
router.replace(nextUrl);
return;
}
}, [isLoading, user, router, nextUrl, cliCallbackRaw]);
const lastWorkspaceId =
typeof window !== "undefined"
? localStorage.getItem("multica_workspace_id")
: null;
const list = qc.getQueryData<Workspace[]>(workspaceKeys.list()) ?? [];
const [first] = list;
router.replace(
first ? paths.workspace(first.slug).issues() : paths.onboarding(),
);
}, [isLoading, user, router, nextUrl, cliCallbackRaw, qc]);
const handleSuccess = () => {
const ws = useWorkspaceStore.getState().workspace;
router.push(ws ? nextUrl : "/onboarding");
if (nextUrl) {
router.push(nextUrl);
return;
}
// The LoginPage view populates the workspace list cache before calling
// onSuccess, so it's safe to read here.
const list = qc.getQueryData<Workspace[]>(workspaceKeys.list()) ?? [];
const [first] = list;
router.push(
first ? paths.workspace(first.slug).issues() : paths.onboarding(),
);
};
// Build Google OAuth state: encode platform + next URL so the callback
// can redirect to the right place after login.
const googleState = [
platform === "desktop" ? "platform:desktop" : "",
nextUrl !== "/issues" ? `next:${nextUrl}` : "",
nextUrl ? `next:${nextUrl}` : "",
]
.filter(Boolean)
.join(",") || undefined;
@@ -63,7 +83,6 @@ function LoginPageContent() {
? { url: cliCallbackRaw, state: cliState }
: undefined
}
lastWorkspaceId={lastWorkspaceId}
onTokenObtained={setLoggedInCookie}
/>
);

View File

@@ -3,6 +3,7 @@
import { useEffect } from "react";
import { useRouter } from "next/navigation";
import { useAuthStore } from "@multica/core/auth";
import { paths } from "@multica/core/paths";
import { OnboardingWizard } from "@multica/views/onboarding";
export default function OnboardingPage() {
@@ -12,12 +13,14 @@ export default function OnboardingPage() {
// Redirect to login if not authenticated
useEffect(() => {
if (!isLoading && !user) router.replace("/login");
if (!isLoading && !user) router.replace(paths.login());
}, [isLoading, user, router]);
if (isLoading || !user) return null;
return (
<OnboardingWizard onComplete={() => router.push("/issues")} />
<OnboardingWizard
onComplete={(ws) => router.push(paths.workspace(ws.slug).issues())}
/>
);
}

View File

@@ -1,5 +1,6 @@
import type { Metadata } from "next";
import { MulticaLanding } from "@/features/landing/components/multica-landing";
import { RedirectIfAuthenticated } from "@/features/landing/components/redirect-if-authenticated";
export const metadata: Metadata = {
title: {
@@ -19,5 +20,10 @@ export const metadata: Metadata = {
};
export default function LandingPage() {
return <MulticaLanding />;
return (
<>
<RedirectIfAuthenticated />
<MulticaLanding />
</>
);
}

View File

@@ -11,8 +11,6 @@ export default function Layout({ children }: { children: React.ReactNode }) {
loadingIndicator={<MulticaIcon className="size-6" />}
searchSlot={<SearchTrigger />}
extra={<><SearchCommand /><ChatWindow /><ChatFab /></>}
onboardingPath="/onboarding"
loginPath="/login"
>
{children}
</DashboardLayout>

View File

@@ -0,0 +1,79 @@
"use client";
import { use, useEffect, useRef } from "react";
import { useQuery } from "@tanstack/react-query";
import { useRouter } from "next/navigation";
import { WorkspaceSlugProvider, paths } from "@multica/core/paths";
import { workspaceBySlugOptions } from "@multica/core/workspace";
import {
setCurrentWorkspace,
rehydrateAllWorkspaceStores,
} from "@multica/core/platform";
import { useAuthStore } from "@multica/core/auth";
export default function WorkspaceLayout({
children,
params,
}: {
children: React.ReactNode;
params: Promise<{ workspaceSlug: string }>;
}) {
const { workspaceSlug } = use(params);
const user = useAuthStore((s) => s.user);
const isAuthLoading = useAuthStore((s) => s.isLoading);
const router = useRouter();
// Resolve workspace by slug from the React Query list cache.
// Enabled only when user is authenticated — otherwise the list query isn't seeded.
const { data: workspace, isFetched: listFetched } = useQuery({
...workspaceBySlugOptions(workspaceSlug),
enabled: !!user,
});
// Render-phase sync: set the current workspace slug + UUID into the
// platform singleton BEFORE children render. This ensures the first
// child query's X-Workspace-Slug header is already correct.
// The ref guard prevents re-running on every render.
const syncedSlugRef = useRef<string | null>(null);
if (workspace && syncedSlugRef.current !== workspaceSlug) {
setCurrentWorkspace(workspaceSlug, workspace.id);
rehydrateAllWorkspaceStores();
syncedSlugRef.current = workspaceSlug;
}
// Cookie write (last_workspace_slug) — proxy reads it on next page load.
// ALSO write legacy localStorage["multica_workspace_id"] for forward/back
// compatibility: if this version ever gets reverted to the pre-refactor
// build, the legacy code reads that localStorage key to know which
// workspace to attach to API requests. Without double-writing, a rollback
// would leave returning users with empty data (API calls would have no
// X-Workspace-ID header). Forward compatible — new code ignores this key.
useEffect(() => {
if (!workspace || typeof document === "undefined") return;
const oneYear = 60 * 60 * 24 * 365;
const secure = location.protocol === "https:" ? "; Secure" : "";
document.cookie = `last_workspace_slug=${encodeURIComponent(workspaceSlug)}; path=/; max-age=${oneYear}; SameSite=Lax${secure}`;
try {
localStorage.setItem("multica_workspace_id", workspace.id);
} catch {
// localStorage may be unavailable in restricted contexts; non-critical.
}
}, [workspace, workspaceSlug]);
// Slug doesn't match any workspace the user has access to → onboarding.
// Wait for the list query to settle so we don't bounce on first render.
// Skip when user is null — DashboardGuard handles the /login redirect.
useEffect(() => {
if (!user) return;
if (listFetched && !workspace) router.replace(paths.onboarding());
}, [user, listFetched, workspace, router]);
// Auth still loading → render nothing (let DashboardGuard show its loader).
if (isAuthLoading) return null;
return (
<WorkspaceSlugProvider slug={workspaceSlug}>
{children}
</WorkspaceSlugProvider>
);
}

View File

@@ -4,8 +4,8 @@ import { Suspense, useEffect, useState } from "react";
import { useSearchParams, useRouter } from "next/navigation";
import { useQueryClient } from "@tanstack/react-query";
import { useAuthStore } from "@multica/core/auth";
import { useWorkspaceStore } from "@multica/core/workspace";
import { workspaceKeys } from "@multica/core/workspace/queries";
import { paths } from "@multica/core/paths";
import { api } from "@multica/core/api";
import {
Card,
@@ -22,7 +22,6 @@ function CallbackContent() {
const searchParams = useSearchParams();
const qc = useQueryClient();
const loginWithGoogle = useAuthStore((s) => s.loginWithGoogle);
const hydrateWorkspace = useWorkspaceStore((s) => s.hydrateWorkspace);
const [error, setError] = useState("");
const [desktopToken, setDesktopToken] = useState<string | null>(null);
@@ -64,17 +63,21 @@ function CallbackContent() {
.then(async () => {
const wsList = await api.listWorkspaces();
qc.setQueryData(workspaceKeys.list(), wsList);
const lastWsId = localStorage.getItem("multica_workspace_id");
const ws = await hydrateWorkspace(wsList, lastWsId);
// Honor the ?next= redirect if present (e.g. /invite/{id})
const defaultDest = ws ? "/issues" : "/onboarding";
// URL is now the source of truth for the current workspace — the
// [workspaceSlug]/layout syncs stores + cookie once we navigate.
// Honor ?next= first (e.g. came from /invite/{id}), otherwise land
// in the first workspace's issues, or /onboarding if the user has none.
const [first] = wsList;
const defaultDest = first
? paths.workspace(first.slug).issues()
: paths.onboarding();
router.push(nextUrl || defaultDest);
})
.catch((err) => {
setError(err instanceof Error ? err.message : "Login failed");
});
}
}, [searchParams, loginWithGoogle, hydrateWorkspace, router, qc]);
}, [searchParams, loginWithGoogle, router, qc]);
if (desktopToken) {
return (
@@ -111,7 +114,7 @@ function CallbackContent() {
<CardDescription>{error}</CardDescription>
</CardHeader>
<CardContent className="flex justify-center">
<a href="/login" className="text-primary underline-offset-4 hover:underline">
<a href={paths.login()} className="text-primary underline-offset-4 hover:underline">
Back to login
</a>
</CardContent>

View File

@@ -41,7 +41,7 @@ export function HowItWorksSection() {
</div>
<div className="mt-14 flex flex-wrap items-center gap-4">
<Link href={user ? "/issues" : "/login"} className={heroButtonClassName("solid")}>
<Link href={user ? "/" : "/login"} className={heroButtonClassName("solid")}>
{user ? t.header.dashboard : t.howItWorks.cta}
</Link>
<Link

View File

@@ -48,7 +48,7 @@ export function LandingFooter() {
</div>
<div className="mt-6">
<Link
href={user ? "/issues" : "/login"}
href={user ? "/" : "/login"}
className="inline-flex items-center justify-center rounded-[11px] bg-white px-5 py-2.5 text-[13px] font-semibold text-[#0a0d12] transition-colors hover:bg-white/88"
>
{user ? t.header.dashboard : t.footer.cta}

View File

@@ -54,7 +54,7 @@ export function LandingHeader({
{t.header.github}
</Link>
<Link
href={user ? "/issues" : "/login"}
href={user ? "/" : "/login"}
className={headerButtonClassName("solid", variant)}
>
{user ? t.header.dashboard : t.header.login}

View File

@@ -41,7 +41,7 @@ export function LandingHero() {
</p>
<div className="mt-8 flex flex-wrap items-center justify-center gap-3">
<Link href={user ? "/issues" : "/login"} className={heroButtonClassName("solid")}>
<Link href={user ? "/" : "/login"} className={heroButtonClassName("solid")}>
{user ? t.header.dashboard : t.hero.cta}
</Link>
<Link

View File

@@ -0,0 +1,45 @@
"use client";
import { useEffect } from "react";
import { useRouter } from "next/navigation";
import { useQuery } from "@tanstack/react-query";
import { useAuthStore } from "@multica/core/auth";
import { workspaceListOptions } from "@multica/core/workspace";
import { paths } from "@multica/core/paths";
/**
* Client-side fallback redirect for authenticated visitors on the landing page.
*
* The primary path for logged-in users hitting `/` is a server-side redirect
* in the Next.js proxy/middleware, driven by the `last_workspace_slug` cookie.
* That cookie is set by the workspace layout on every visit. But on *first
* login* — before the user has ever visited a workspace — the cookie is
* absent, so the proxy falls through to the landing page. This component
* covers that gap: once auth is resolved and the workspace list has loaded,
* push the user into their workspace (or onboarding if they have none).
*
* Renders nothing. Uses `router.replace` so the landing page never enters
* browser history for authenticated users.
*/
export function RedirectIfAuthenticated() {
const router = useRouter();
const user = useAuthStore((s) => s.user);
const isLoading = useAuthStore((s) => s.isLoading);
const { data: list } = useQuery({
...workspaceListOptions(),
enabled: !!user,
});
useEffect(() => {
if (isLoading || !user || !list) return;
const [first] = list;
if (!first) {
router.replace(paths.onboarding());
return;
}
router.replace(paths.workspace(first.slug).issues());
}, [isLoading, user, list, router]);
return null;
}

View File

@@ -277,6 +277,31 @@ export const en: LandingDict = {
fixes: "Bug Fixes",
},
entries: [
{
version: "0.2.0",
date: "2026-04-15",
title: "Desktop App, Autopilot & Invitations",
changes: [],
features: [
"Desktop app for macOS — native Electron app with tab system, built-in daemon management, immersive mode, and auto-update",
"Autopilot — scheduled and triggered automations for AI agents",
"Workspace invitations with email notifications and dedicated accept page",
"Custom CLI arguments per agent for advanced runtime configuration",
"Chat redesign with unread tracking and improved session management",
"Create Agent dialog shows runtime owner with Mine/All filter",
],
improvements: [
"Inter font with CJK fallback and automatic CJK+Latin spacing",
"Sidebar user menu redesigned as full-row popover",
"WebSocket ping/pong heartbeat to detect dead connections",
"Members can now create agents and manage their own skills",
],
fixes: [
"Agent now triggered on reply in threads where it already participated",
"Self-hosting: local uploads persist in Docker, WebSocket URL auto-derived for LAN access",
"Stale cmd+k recent issues resolved",
],
},
{
version: "0.1.33",
date: "2026-04-14",

View File

@@ -277,6 +277,31 @@ export const zh: LandingDict = {
fixes: "问题修复",
},
entries: [
{
version: "0.2.0",
date: "2026-04-15",
title: "桌面应用、Autopilot 与邀请",
changes: [],
features: [
"macOS 桌面应用——原生 Electron 应用,支持标签页系统、内置 Daemon 管理、沉浸模式和自动更新",
"Autopilot——Agent 定时和触发式自动化任务",
"工作区邀请,支持邮件通知和专用接受页面",
"Agent 自定义 CLI 参数,支持高级运行时配置",
"聊天界面重设计,新增未读追踪和会话管理优化",
"创建 Agent 对话框显示运行时所有者和 Mine/All 筛选",
],
improvements: [
"Inter 字体 + CJK 回退,中英文自动间距",
"侧边栏用户菜单改为整行弹出面板",
"WebSocket ping/pong 心跳检测断线连接",
"普通成员现在可以创建 Agent 和管理自己的 Skills",
],
fixes: [
"Agent 在已参与的线程收到回复时正确触发",
"自部署Docker 本地上传文件持久化WebSocket URL 自动适配局域网",
"Cmd+K 最近 Issue 列表状态过期",
],
},
{
version: "0.1.33",
date: "2026-04-14",

View File

@@ -1,10 +1,81 @@
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
import { NextResponse, type NextRequest } from "next/server";
// Old workspace-scoped route segments that existed before the URL refactor
// (pre-#1131). Any URL with these as the FIRST segment is a legacy URL that
// needs to be rewritten to /{slug}/{route}/... so old bookmarks, deep links,
// and post-revert-and-reapply users don't hit 404.
const LEGACY_ROUTE_SEGMENTS = new Set([
"issues",
"projects",
"agents",
"inbox",
"my-issues",
"autopilots",
"runtimes",
"skills",
"settings",
]);
// Next.js 16 renamed `middleware` → `proxy`. The runtime API is identical.
export function proxy(req: NextRequest) {
const { pathname } = req.nextUrl;
const hasSession = req.cookies.has("multica_logged_in");
const lastSlug = req.cookies.get("last_workspace_slug")?.value;
// --- Legacy URL redirect: /issues/... → /{slug}/issues/... ---
// Old bookmarks and clients that hit us before the slug migration would
// otherwise 404 since the route moved under [workspaceSlug].
const firstSegment = pathname.split("/")[1] ?? "";
if (LEGACY_ROUTE_SEGMENTS.has(firstSegment)) {
const url = req.nextUrl.clone();
if (!hasSession) {
url.pathname = "/login";
return NextResponse.redirect(url);
}
if (lastSlug) {
// Preserve deep-link path + query: /issues/abc → /{lastSlug}/issues/abc
url.pathname = `/${lastSlug}${pathname}`;
return NextResponse.redirect(url);
}
// Logged-in but no cookie yet (first login since slug migration, or
// cookie cleared). Bounce to root; the root-path logic below picks a
// workspace and writes the cookie, then future hits short-circuit here.
url.pathname = "/";
return NextResponse.redirect(url);
}
// --- Root path: redirect logged-in users to their last workspace ---
if (pathname === "/") {
if (!hasSession) return NextResponse.next();
if (lastSlug) {
const url = req.nextUrl.clone();
url.pathname = `/${lastSlug}/issues`;
return NextResponse.redirect(url);
}
// No last_workspace_slug cookie → let landing page pick the first workspace
// client-side (features/landing/components/redirect-if-authenticated.tsx).
return NextResponse.next();
}
export function proxy(_request: NextRequest) {
return NextResponse.next();
}
export const config = {
matcher: ["/"],
matcher: [
"/",
"/issues/:path*",
"/projects/:path*",
"/agents/:path*",
"/inbox/:path*",
"/my-issues/:path*",
"/autopilots/:path*",
"/runtimes/:path*",
"/skills/:path*",
"/settings/:path*",
],
};

View File

@@ -78,7 +78,6 @@ export const mockAuthValue: Record<string, any> = {
isLoading: false,
login: vi.fn(),
logout: vi.fn(),
switchWorkspace: vi.fn(),
updateWorkspace: vi.fn(),
updateCurrentUser: vi.fn(),
getMemberName: (userId: string) => {

View File

@@ -36,6 +36,8 @@ services:
condition: service_healthy
ports:
- "${PORT:-8080}:8080"
volumes:
- backend_uploads:/app/data/uploads
environment:
DATABASE_URL: postgres://${POSTGRES_USER:-multica}:${POSTGRES_PASSWORD:-multica}@postgres:5432/${POSTGRES_DB:-multica}?sslmode=disable
PORT: "8080"
@@ -72,3 +74,4 @@ services:
volumes:
pgdata:
backend_uploads:

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,109 @@
# Workspace URL 化重构 — 项目汇报
**日期**2026-04-15
**作者**Naiyuan
**状态**:调研完成,待评审
---
## 一、为什么要做
当前 workspace 上下文完全靠 `X-Workspace-ID` HTTP header + Zustand store + localStorage 承载URL 里**不含任何 workspace 信息**。所有路径都是 `/issues``/issues/:id` 这种 workspace-agnostic 的。
这个设计已经在产品里直接表现为 3 个已知问题:
1. **分享链接不可靠**MUL-43`/issues/abc` 发给另一个成员,会用他自己 localStorage 里的 workspace 去解析,导致 404 或看到错误 workspace 的数据
2. **手机端无法切 workspace**MUL-509切换只靠 sidebar UI手机端不展开 sidebar 就没有切换入口
3. **多 tab 互相覆盖**`multica_workspace_id` 是全局 localStorage key两个 tab 打开不同 workspace 会互相污染
除了这 3 个显性 bug架构上的"多份 workspace 状态拷贝互相同步"也带来一些隐性问题(创建 workspace 闪页、切换 workspace 时 cache 竞态等),积累时间越长后续改动越难。
行业惯例Linear / Notion / Vercel / GitHub都是 `/{workspace-slug}/...` 的 URL 形态,把 URL 当作 workspace 的唯一来源。这是我们应该对齐的最佳实践。
## 二、调研结论
### 好消息:基础设施已经就位
- 数据库 `workspace.slug` 字段已经存在(`TEXT UNIQUE NOT NULL`),用户创建时手动指定且不可修改
- 后端已有 `GetWorkspaceBySlug` 查询
- 前端 `Workspace` 类型已包含 `slug` 字段
- Web 端认证已经切换为 HttpOnly cookie 模式Next.js middleware 可读到登录态
也就是说这次改造**不需要大量后端改动**,主要是前端路由和状态管理的重新组织。
### 坏消息:范围比最初估计大
初看以为只是"URL 前缀加个 slug",调研后发现必须一起做的事情有:
1. **URL 路由重组**web 端所有 dashboard 路由迁到 `app/[workspaceSlug]/(dashboard)/*`desktop 端所有 react-router 路由加 `/:workspaceSlug` 前缀
2. **状态管理清理**:删除 `useWorkspaceStore.workspace` 作为独立状态,改为从 URL 派生;删除 `hydrateWorkspace` / `switchWorkspace` actions切 workspace 变成纯导航);删除 `localStorage["multica_workspace_id"]`
3. **所有路径引用替换**`push("/issues")` 改为 path builder`paths.issues()`),影响 ~25 个组件文件
4. **Mutation 副作用重构**`useCreateWorkspace` / `useLeaveWorkspace` / `useDeleteWorkspace` 里的 `switchWorkspace` 调用全部移除(这些调用正是 MUL-727 闪页、MUL-728 删除后不跳转、MUL-820 接受邀请不切 workspace 等一系列 bug 的根因)
5. **桌面端 tab 系统适配**tab 路径天然包含 workspace切 workspace = 开新 tab 或导航,不再有全局切换动作
6. **Shareable URL 修复**:桌面端 `getShareableUrl` 当前生成 `https://www.multica.ai/issues/abc`(缺 slug需要更新
7. **后端保留词校验**slug 不能和前端顶级路由冲突(`login``onboarding``invite``api``settings` 等),后端创建时校验
8. **内部 markdown 链接兼容**issue 评论里写的 `[foo](/issues/abc)` 触发的 `multica:navigate` 事件需要自动补当前 workspace slug
### 不需要改的(边界已确认)
- 邮件邀请链接 `/invite/{id}` — 接受邀请是 pre-workspace 流程,不需要 slug
- `mention://type/id` 协议 — 只存 UUIDworkspace-agnostic
- CLI 登录 URL — `/login` 也是 pre-workspace不需要 slug
- 后端 API 路径 — 保持 `/api/workspaces/{id}`slug 仅用于前端 URL
- 桌面端 `multica://auth/callback` — 认证回调,不涉及 workspace
## 三、方案要点
**核心原则**URL 是 workspace 上下文的唯一 source of truth其他状态都是派生态。
**URL 形状**`/{workspace-slug}/issues/{id}` (和 Linear / Notion 一致)
**切换 workspace = 导航**sidebar 下拉改为 `<Link href="/{new-slug}/issues">`,不再有命令式的 `switchWorkspace` 函数。这样一次性消除前面列出的一大批 mutation 副作用 bug。
**预估影响面**~30-35 个文件,其中约 20 个是机械替换hardcoded 路径 → path builder真正需要思考的核心逻辑改动集中在 5-6 个文件。
**一个 PR 合并**中间状态不可运行URL 结构是原子变化),不拆 PR。worktree 里充分开发和自测,一次 review 合并。
## 四、执行与测试计划
### 执行阶段
1. **本周内**:完成方案详细实施文档(精确到文件 / 行号 / 代码片段)
2. **下一步**:在独立 worktree 上开发AI 辅助写代码,过程中人工 review
3. **开发完成后**:本地跑全套验证(`make check` — TypeScript + 单测 + Go 测试 + E2E
### 测试阶段
1. **本地自测**
- 已知功能路径(创建 / 浏览 / 搜索 issue切换 workspace接受邀请分享链接
- 已知 bug 场景MUL-43 / MUL-509 / MUL-727 / MUL-820逐一验证已修复
- 多 tab 场景(两个 tab 打开不同 workspace 互不影响)
2. **测试环境部署**:本地通过后发测试环境,全员试用几天,观察:
- 是否有回归(特别是导航流、创建/删除 workspace、邀请流程
- URL 使用感受(分享、收藏、刷新)
3. **灰度 / 生产**:测试环境稳定后推生产
### 风险提示
- **唯一的硬中断点**:现有的 `/issues` 等 URL 在重构后会 404产品还没正式 ship、用户量可忽略所以不做兼容性重定向
- **E2E 测试断言**:约 20-30 处 URL 断言需要更新
- **后端保留词清单**:如果现有 workspace 里有名字撞到保留词的(例如正好叫 `settings`),需要提前 migrate可能性极低因 slug 限制较严)
## 五、附注
这次重构会**顺带修掉**以下已登记 issue不需要单独开 PR
| Issue | 修复方式 |
|---|---|
| MUL-43切换 workspace 报错 / 分享链接失效) | URL 带 slug根本解决 |
| MUL-509手机端无法切 workspace | 切换变导航,手机能点链接就能切 |
| MUL-723workspace 不在 URL | 核心目标 |
| MUL-727创建 workspace 闪 /issues | 删除 mutation 里的 switchWorkspace 副作用 |
| MUL-728删除 workspace 后留在 /settings | 删除成功后 navigate 到下一个 workspace |
| MUL-820sidebar Join 不切 workspace | Join 改成跳转到 `/invite/{id}` 走统一路径 |
不在本次范围内的Issue #951WebSocket 半开导致 cache 陈旧)—— 这是 realtime 层独立问题,单独 PR 处理。
---
**当前状态**:准备进入详细实施方案撰写,预计完成后再同步一次。

View File

@@ -24,10 +24,12 @@ test.describe("Authentication", () => {
await page.goto("/login");
await page.evaluate(() => {
localStorage.removeItem("multica_token");
localStorage.removeItem("multica_workspace_id");
});
await page.goto("/issues");
// Visit a workspace-scoped route; DashboardGuard should redirect to /login.
// The slug here need not exist — the guard runs before workspace resolution
// for unauthenticated users.
await page.goto("/e2e-workspace/issues");
await page.waitForURL("**/login", { timeout: 10000 });
});

View File

@@ -16,8 +16,9 @@ test.describe("Comments", () => {
});
test("can add a comment on an issue", async ({ page }) => {
// Wait for issues to load and click first one
const issueLink = page.locator('a[href^="/issues/"]').first();
// Wait for issues to load and click first one. `*=` matches both legacy
// `/issues/{id}` and URL-refactored `/{slug}/issues/{id}` hrefs.
const issueLink = page.locator('a[href*="/issues/"]').first();
await expect(issueLink).toBeVisible({ timeout: 5000 });
await issueLink.click();
await page.waitForURL(/\/issues\/[\w-]+/);
@@ -42,7 +43,7 @@ test.describe("Comments", () => {
});
test("comment submit button is disabled when empty", async ({ page }) => {
const issueLink = page.locator('a[href^="/issues/"]').first();
const issueLink = page.locator('a[href*="/issues/"]').first();
await expect(issueLink).toBeVisible({ timeout: 5000 });
await issueLink.click();
await page.waitForURL(/\/issues\/[\w-]+/);

View File

@@ -7,7 +7,10 @@
import "./env";
import pg from "pg";
const API_BASE = process.env.NEXT_PUBLIC_API_URL ?? `http://localhost:${process.env.PORT ?? "8080"}`;
// `||` (not `??`) so an empty `NEXT_PUBLIC_API_URL=` in .env still falls
// back to localhost. dotenv sets unset-vs-empty both as "" — treating them
// the same matches user intent.
const API_BASE = process.env.NEXT_PUBLIC_API_URL || `http://localhost:${process.env.PORT || "8080"}`;
const DATABASE_URL = process.env.DATABASE_URL ?? "postgres://multica:multica@localhost:5432/multica?sslmode=disable";
interface TestWorkspace {
@@ -18,6 +21,7 @@ interface TestWorkspace {
export class TestApiClient {
private token: string | null = null;
private workspaceSlug: string | null = null;
private workspaceId: string | null = null;
private createdIssueIds: string[] = [];
@@ -86,11 +90,16 @@ export class TestApiClient {
this.workspaceId = id;
}
setWorkspaceSlug(slug: string) {
this.workspaceSlug = slug;
}
async ensureWorkspace(name = "E2E Workspace", slug = "e2e-workspace") {
const workspaces = await this.getWorkspaces();
const workspace = workspaces.find((item) => item.slug === slug) ?? workspaces[0];
if (workspace) {
this.workspaceId = workspace.id;
this.workspaceSlug = workspace.slug;
return workspace;
}
@@ -150,7 +159,8 @@ export class TestApiClient {
...((init?.headers as Record<string, string>) ?? {}),
};
if (this.token) headers["Authorization"] = `Bearer ${this.token}`;
if (this.workspaceId) headers["X-Workspace-ID"] = this.workspaceId;
if (this.workspaceSlug) headers["X-Workspace-Slug"] = this.workspaceSlug;
else if (this.workspaceId) headers["X-Workspace-ID"] = this.workspaceId;
return fetch(`${API_BASE}${path}`, { ...init, headers });
}
}

View File

@@ -9,19 +9,25 @@ const DEFAULT_E2E_WORKSPACE = "e2e-workspace";
* Log in as the default E2E user and ensure the workspace exists first.
* Authenticates via API (send-code → DB read → verify-code), then injects
* the token into localStorage so the browser session is authenticated.
*
* Returns the E2E workspace slug so callers can build workspace-scoped URLs.
*/
export async function loginAsDefault(page: Page) {
export async function loginAsDefault(page: Page): Promise<string> {
const api = new TestApiClient();
await api.login(DEFAULT_E2E_EMAIL, DEFAULT_E2E_NAME);
await api.ensureWorkspace("E2E Workspace", DEFAULT_E2E_WORKSPACE);
const workspace = await api.ensureWorkspace(
"E2E Workspace",
DEFAULT_E2E_WORKSPACE,
);
const token = api.getToken();
await page.goto("/login");
await page.evaluate((t) => {
localStorage.setItem("multica_token", t);
}, token);
await page.goto("/issues");
await page.goto(`/${workspace.slug}/issues`);
await page.waitForURL("**/issues", { timeout: 10000 });
return workspace.slug;
}
/**

View File

@@ -65,8 +65,10 @@ test.describe("Issues", () => {
// Reload to see the new issue
await page.reload();
// Navigate to the issue detail
const issueLink = page.locator(`a[href="/issues/${issue.id}"]`);
// Navigate to the issue detail. Use a suffix match so the selector works
// whether the href is legacy `/issues/{id}` or URL-refactored
// `/{slug}/issues/{id}`.
const issueLink = page.locator(`a[href$="/issues/${issue.id}"]`);
await expect(issueLink).toBeVisible({ timeout: 5000 });
await issueLink.click();

View File

@@ -66,6 +66,7 @@ import type {
} from "../types";
import { type Logger, noopLogger } from "../logger";
import { createRequestId } from "../utils";
import { getCurrentSlug } from "../platform/workspace-storage";
export interface ApiClientOptions {
logger?: Logger;
@@ -92,7 +93,6 @@ export class ApiError extends Error {
export class ApiClient {
private baseUrl: string;
private token: string | null = null;
private workspaceId: string | null = null;
private logger: Logger;
private options: ApiClientOptions;
@@ -110,10 +110,6 @@ export class ApiClient {
this.token = token;
}
setWorkspaceId(id: string | null) {
this.workspaceId = id;
}
private readCsrfToken(): string | null {
if (typeof document === "undefined") return null;
const match = document.cookie
@@ -125,7 +121,8 @@ export class ApiClient {
private authHeaders(): Record<string, string> {
const headers: Record<string, string> = {};
if (this.token) headers["Authorization"] = `Bearer ${this.token}`;
if (this.workspaceId) headers["X-Workspace-ID"] = this.workspaceId;
const slug = getCurrentSlug();
if (slug) headers["X-Workspace-Slug"] = slug;
const csrf = this.readCsrfToken();
if (csrf) headers["X-CSRF-Token"] = csrf;
return headers;
@@ -133,7 +130,10 @@ export class ApiClient {
private handleUnauthorized() {
this.token = null;
this.workspaceId = null;
// Workspace id is owned by the URL-driven workspace-storage singleton
// (set by [workspaceSlug]/layout.tsx). On 401, the auth flow navigates
// to /login which leaves the workspace route, and the next workspace
// entry will overwrite the id. No clear needed here.
this.options.onUnauthorized?.();
}
@@ -231,8 +231,7 @@ export class ApiClient {
const search = new URLSearchParams();
if (params?.limit) search.set("limit", String(params.limit));
if (params?.offset) search.set("offset", String(params.offset));
const wsId = params?.workspace_id ?? this.workspaceId;
if (wsId) search.set("workspace_id", wsId);
if (params?.workspace_id) search.set("workspace_id", params.workspace_id);
if (params?.status) search.set("status", params.status);
if (params?.priority) search.set("priority", params.priority);
if (params?.assignee_id) search.set("assignee_id", params.assignee_id);
@@ -263,9 +262,7 @@ export class ApiClient {
}
async createIssue(data: CreateIssueRequest): Promise<Issue> {
const search = new URLSearchParams();
if (this.workspaceId) search.set("workspace_id", this.workspaceId);
return this.fetch(`/api/issues?${search}`, {
return this.fetch("/api/issues", {
method: "POST",
body: JSON.stringify(data),
});
@@ -396,8 +393,7 @@ export class ApiClient {
// Agents
async listAgents(params?: { workspace_id?: string; include_archived?: boolean }): Promise<Agent[]> {
const search = new URLSearchParams();
const wsId = params?.workspace_id ?? this.workspaceId;
if (wsId) search.set("workspace_id", wsId);
if (params?.workspace_id) search.set("workspace_id", params.workspace_id);
if (params?.include_archived) search.set("include_archived", "true");
return this.fetch(`/api/agents?${search}`);
}
@@ -430,8 +426,7 @@ export class ApiClient {
async listRuntimes(params?: { workspace_id?: string; owner?: "me" }): Promise<AgentRuntime[]> {
const search = new URLSearchParams();
const wsId = params?.workspace_id ?? this.workspaceId;
if (wsId) search.set("workspace_id", wsId);
if (params?.workspace_id) search.set("workspace_id", params.workspace_id);
if (params?.owner) search.set("owner", params.owner);
return this.fetch(`/api/runtimes?${search}`);
}
@@ -788,9 +783,7 @@ export class ApiClient {
}
async createProject(data: CreateProjectRequest): Promise<Project> {
const search = new URLSearchParams();
if (this.workspaceId) search.set("workspace_id", this.workspaceId);
return this.fetch(`/api/projects?${search}`, {
return this.fetch("/api/projects", {
method: "POST",
body: JSON.stringify(data),
});

View File

@@ -7,7 +7,7 @@ export class WSClient {
private ws: WebSocket | null = null;
private baseUrl: string;
private token: string | null = null;
private workspaceId: string | null = null;
private workspaceSlug: string | null = null;
private cookieAuth = false;
private handlers = new Map<WSEventType, Set<EventHandler>>();
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
@@ -22,9 +22,9 @@ export class WSClient {
this.cookieAuth = options?.cookieAuth ?? false;
}
setAuth(token: string | null, workspaceId: string) {
setAuth(token: string | null, workspaceSlug: string) {
this.token = token;
this.workspaceId = workspaceId;
this.workspaceSlug = workspaceSlug;
}
connect() {
@@ -33,8 +33,8 @@ export class WSClient {
// proxies, CDNs, and browser history. In cookie mode the HttpOnly cookie
// is sent automatically with the upgrade request. In token mode the token
// is delivered as the first WebSocket message after the connection opens.
if (this.workspaceId)
url.searchParams.set("workspace_id", this.workspaceId);
if (this.workspaceSlug)
url.searchParams.set("workspace_slug", this.workspaceSlug);
this.ws = new WebSocket(url.toString());

View File

@@ -1,6 +1,7 @@
import { create } from "zustand";
import type { User, StorageAdapter } from "../types";
import type { ApiClient } from "../api/client";
import { setCurrentWorkspace } from "../platform/workspace-storage";
export interface AuthStoreOptions {
api: ApiClient;
@@ -58,7 +59,7 @@ export function createAuthStore(options: AuthStoreOptions) {
set({ user, isLoading: false });
} catch {
api.setToken(null);
api.setWorkspaceId(null);
setCurrentWorkspace(null, null);
storage.removeItem("multica_token");
set({ user: null, isLoading: false });
}
@@ -107,7 +108,7 @@ export function createAuthStore(options: AuthStoreOptions) {
}
storage.removeItem("multica_token");
api.setToken(null);
api.setWorkspaceId(null);
setCurrentWorkspace(null, null);
onLogout?.();
set({ user: null });
},

View File

@@ -3,10 +3,10 @@ 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.
// The actual workspace context comes from ApiClient's X-Workspace-Slug header,
// which is set by the URL-driven [workspaceSlug] layout. 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,

View File

@@ -1,6 +1,6 @@
import { create } from "zustand";
import type { StorageAdapter } from "../types";
import { getCurrentWorkspaceId, registerForWorkspaceRehydration } from "../platform/workspace-storage";
import { getCurrentSlug, registerForWorkspaceRehydration } from "../platform/workspace-storage";
import { createLogger } from "../logger";
const logger = createLogger("chat.store");
@@ -90,8 +90,8 @@ export function createChatStore(options: ChatStoreOptions) {
const { storage } = options;
const wsKey = (base: string) => {
const wsId = getCurrentWorkspaceId();
return wsId ? `${base}:${wsId}` : base;
const slug = getCurrentSlug();
return slug ? `${base}:${slug}` : base;
};
const store = create<ChatState>((set, get) => ({

View File

@@ -1,25 +1,17 @@
"use client";
import { createContext, useContext } from "react";
const WorkspaceIdContext = createContext<string | null>(null);
export function WorkspaceIdProvider({
wsId,
children,
}: {
wsId: string;
children: React.ReactNode;
}) {
return (
<WorkspaceIdContext.Provider value={wsId}>
{children}
</WorkspaceIdContext.Provider>
);
}
import { useCurrentWorkspace } from "./paths/hooks";
/**
* Returns the current workspace UUID. Throws if called outside a workspace route.
*
* Implementation: derives from useCurrentWorkspace() (URL slug + React Query list).
* No longer backed by a React Context — the WorkspaceIdProvider has been removed
* as part of the slug-first refactor. The throw semantics are preserved so existing
* callers that depend on non-null don't need guard code.
*/
export function useWorkspaceId(): string {
const wsId = useContext(WorkspaceIdContext);
if (!wsId) throw new Error("useWorkspaceId: no workspace selected — wrap in WorkspaceIdProvider");
return wsId;
const ws = useCurrentWorkspace();
if (!ws) throw new Error("useWorkspaceId: no workspace selected — ensure component renders inside a workspace route");
return ws.id;
}

View File

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

View File

@@ -17,8 +17,7 @@ export {
createIssueViewStore,
viewStoreSlice,
viewStorePersistOptions,
registerViewStoreForWorkspaceSync,
initFilterWorkspaceSync,
useClearFiltersOnWorkspaceChange,
SORT_OPTIONS,
CARD_PROPERTY_OPTIONS,
type ViewMode,

View File

@@ -1,5 +1,6 @@
"use client";
import { useEffect, useRef } from "react";
import { create } from "zustand";
import { createStore, type StoreApi } from "zustand/vanilla";
import { createJSONStorage, persist } from "zustand/middleware";
@@ -215,43 +216,23 @@ export const useIssueViewStore = create<IssueViewState>()(
registerForWorkspaceRehydration(() => useIssueViewStore.persist.rehydrate());
// Clear filters on all registered view stores when workspace switches.
const _syncedStores = new Set<StoreApi<IssueViewState>>();
let _workspaceSyncInitialized = false;
/**
* Register a view store to clear filters on workspace switch.
* Clears the given view store's filters whenever the workspace id changes.
*
* @param store - The view store to register.
* @param subscribeToWorkspace - Optional: a function that subscribes to workspace
* changes and calls the callback with the new workspace ID. The app layer should
* provide this to avoid a circular dependency on the workspace store.
* Example: `(cb) => useWorkspaceStore.subscribe(s => cb(s.workspace?.id))`
* URL-driven: wsId arrives from `useWorkspaceId()` (Context fed by the
* `[workspaceSlug]` route). We track the previous id via ref so the first
* render doesn't wipe persisted filters — clearing only fires on transitions
* from one defined workspace to another.
*/
export function registerViewStoreForWorkspaceSync(
store: StoreApi<IssueViewState>,
subscribeToWorkspace?: (callback: (workspaceId: string | undefined) => void) => void,
export function useClearFiltersOnWorkspaceChange(
store: StoreApi<IssueViewState> | { getState: () => IssueViewState },
wsId: string | undefined,
) {
_syncedStores.add(store);
if (_workspaceSyncInitialized) return;
_workspaceSyncInitialized = true;
if (subscribeToWorkspace) {
let prevId: string | undefined;
subscribeToWorkspace((id) => {
if (prevId && id !== prevId) {
for (const s of _syncedStores) s.getState().clearFilters();
}
prevId = id;
});
}
// TODO: If no subscribeToWorkspace is provided, the workspace sync is a no-op.
// The app layer (apps/web) should call this with the workspace store subscription
// to wire up filter clearing on workspace switch.
const prevIdRef = useRef<string | undefined>(undefined);
useEffect(() => {
if (prevIdRef.current && wsId && wsId !== prevIdRef.current) {
store.getState().clearFilters();
}
prevIdRef.current = wsId;
}, [wsId, store]);
}
/** Backward-compatible alias — registers the global singleton for workspace sync. */
export const initFilterWorkspaceSync = (
subscribeToWorkspace?: (callback: (workspaceId: string | undefined) => void) => void,
) =>
registerViewStoreForWorkspaceSync(useIssueViewStore, subscribeToWorkspace);

View File

@@ -0,0 +1,36 @@
import { describe, it, expect } from "vitest";
// EXCLUDED_PREFIXES is private to store.ts but checked here via behavior.
// We assert that every global path prefix is also excluded from lastPath
// persistence — otherwise lastPath could contain /login etc, and on next
// app load we'd "restore" a user to the login page.
describe("useNavigationStore.lastPath excludes global paths", () => {
it("does not persist /login, /onboarding, /invite/, /auth/, /logout, /signup", async () => {
const { useNavigationStore } = await import("./store");
const globalPrefixes = [
"/login",
"/logout",
"/signup",
"/onboarding",
"/invite/abc",
"/auth/callback",
];
for (const path of globalPrefixes) {
// Reset to a known sentinel so we can detect any write.
useNavigationStore.setState({ lastPath: "/sentinel" });
useNavigationStore.getState().onPathChange(path);
expect(
useNavigationStore.getState().lastPath,
`${path} should not be persisted as lastPath (would restore user to a global route)`,
).toBe("/sentinel");
}
});
it("does persist workspace-scoped paths", async () => {
const { useNavigationStore } = await import("./store");
useNavigationStore.setState({ lastPath: null });
useNavigationStore.getState().onPathChange("/acme/issues");
expect(useNavigationStore.getState().lastPath).toBe("/acme/issues");
});
});

View File

@@ -2,21 +2,35 @@
import { create } from "zustand";
import { createJSONStorage, persist } from "zustand/middleware";
import { createPersistStorage } from "../platform/persist-storage";
import {
createWorkspaceAwareStorage,
registerForWorkspaceRehydration,
} from "../platform/workspace-storage";
import { defaultStorage } from "../platform/storage";
const EXCLUDED_PREFIXES = ["/login", "/pair/", "/invite/"];
// Paths that should not be persisted as "last visited":
// - Auth flows (/login, /signup, /logout)
// - Pre-workspace routes (/onboarding, /auth/, /invite/)
// - Pair flow (/pair/)
const EXCLUDED_PREFIXES = [
"/login",
"/signup",
"/logout",
"/onboarding",
"/auth/",
"/invite/",
"/pair/",
];
interface NavigationState {
lastPath: string;
lastPath: string | null;
onPathChange: (path: string) => void;
}
export const useNavigationStore = create<NavigationState>()(
persist(
(set) => ({
lastPath: "/issues",
lastPath: null,
onPathChange: (path: string) => {
if (!EXCLUDED_PREFIXES.some((prefix) => path.startsWith(prefix))) {
set({ lastPath: path });
@@ -25,8 +39,11 @@ export const useNavigationStore = create<NavigationState>()(
}),
{
name: "multica_navigation",
storage: createJSONStorage(() => createPersistStorage(defaultStorage)),
storage: createJSONStorage(() => createWorkspaceAwareStorage(defaultStorage)),
partialize: (state) => ({ lastPath: state.lastPath }),
}
)
},
),
);
// Workspace-aware: re-read lastPath when current workspace changes.
registerForWorkspaceRehydration(() => useNavigationStore.persist.rehydrate());

View File

@@ -55,6 +55,7 @@
"./realtime": "./realtime/index.ts",
"./navigation": "./navigation/index.ts",
"./modals": "./modals/index.ts",
"./paths": "./paths/index.ts",
"./hooks": "./hooks.tsx",
"./hooks/*": "./hooks/*.ts",
"./query-client": "./query-client.ts",

View File

@@ -0,0 +1,93 @@
import { describe, it, expect } from "vitest";
import { paths, isGlobalPath } from "./paths";
import { RESERVED_SLUGS } from "./reserved-slugs";
// C4 — link-handler's WORKSPACE_ROUTE_SEGMENTS must match paths.workspace's
// parameterless method names. We can't import WORKSPACE_ROUTE_SEGMENTS here
// because link-handler is in packages/views (no inverse import allowed), so
// we hardcode the expected list and assert paths.workspace produces the same
// keys. If you change either, BOTH need to be updated — the test catches drift.
describe("paths.workspace() shape", () => {
it("exposes the expected parameterless workspace route methods", () => {
const ws = paths.workspace("__probe__");
const parameterlessRoutes = Object.entries(ws)
.filter(([, fn]) => typeof fn === "function" && fn.length === 0)
.map(([key]) => key);
expect(new Set(parameterlessRoutes)).toEqual(
new Set([
"root",
"issues",
"projects",
"autopilots",
"agents",
"inbox",
"myIssues",
"runtimes",
"skills",
"settings",
]),
);
});
it("each parameterless route emits /{slug}/{segment}", () => {
const ws = paths.workspace("acme");
// Check that none of the parameterless paths embed a leaked literal
// and that their second URL segment matches the method name's kebab-case.
const expectedSegments: Array<[string, string]> = [
["issues", "issues"],
["projects", "projects"],
["autopilots", "autopilots"],
["agents", "agents"],
["inbox", "inbox"],
["myIssues", "my-issues"],
["runtimes", "runtimes"],
["skills", "skills"],
["settings", "settings"],
];
const wsAsAny = ws as unknown as Record<string, () => string>;
for (const [method, segment] of expectedSegments) {
const fn = wsAsAny[method];
expect(typeof fn).toBe("function");
expect(fn!()).toBe(`/acme/${segment}`);
}
});
});
// C5 — invariants between the global/reserved lists.
describe("global path / reserved slug consistency", () => {
// If a path is "global" (never workspace-scoped), the slug name underlying it
// must be reserved — otherwise a user could create a workspace with that slug
// and shadow the global route's URL space.
//
// GLOBAL_PREFIXES from paths.ts is private — we re-derive the list from
// probing isGlobalPath. Order matters: keep this list in sync with paths.ts.
const globalPrefixes = [
"/login",
"/logout",
"/signup",
"/onboarding",
"/invite/",
"/auth/",
];
it("isGlobalPath agrees with the canonical global prefix list", () => {
for (const prefix of globalPrefixes) {
expect(isGlobalPath(prefix)).toBe(true);
}
expect(isGlobalPath("/acme/issues")).toBe(false);
expect(isGlobalPath("/")).toBe(false);
});
it("every global prefix's first path segment is a reserved slug", () => {
for (const prefix of globalPrefixes) {
const firstSegment = prefix.split("/").filter(Boolean)[0];
if (!firstSegment) continue;
expect(
RESERVED_SLUGS.has(firstSegment),
`'${firstSegment}' is a global path prefix but not a reserved slug — ` +
`a workspace could be created with this slug and shadow the global route`,
).toBe(true);
}
});
});

View File

@@ -0,0 +1,70 @@
"use client";
import { createContext, useContext, type ReactNode } from "react";
import { useQuery } from "@tanstack/react-query";
import type { Workspace } from "../types";
import { workspaceListOptions } from "../workspace/queries";
import { paths, type WorkspacePaths } from "./paths";
/**
* Context for the current workspace slug (read from URL by the platform layer).
*
* apps/web populates this from Next.js `params.workspaceSlug` in
* [workspaceSlug]/layout.tsx. apps/desktop populates it from react-router's
* `useParams()` in the workspace route layout.
*
* packages/core/ cannot import next/navigation or react-router-dom directly,
* so the slug arrives via this Context — mirroring how WorkspaceIdProvider
* already works for workspace IDs.
*/
const WorkspaceSlugContext = createContext<string | null>(null);
export function WorkspaceSlugProvider({
slug,
children,
}: {
slug: string | null;
children: ReactNode;
}) {
return (
<WorkspaceSlugContext.Provider value={slug}>
{children}
</WorkspaceSlugContext.Provider>
);
}
/** Current workspace slug from URL, or null outside workspace-scoped routes. */
export function useWorkspaceSlug(): string | null {
return useContext(WorkspaceSlugContext);
}
/** Same as useWorkspaceSlug, but throws if called outside a workspace route. */
export function useRequiredWorkspaceSlug(): string {
const slug = useWorkspaceSlug();
if (!slug) {
throw new Error(
"useRequiredWorkspaceSlug called outside a workspace-scoped route",
);
}
return slug;
}
/**
* The currently-selected workspace, derived from URL slug + React Query list.
* Returns null if slug is missing or doesn't match any workspace in the list.
*/
export function useCurrentWorkspace(): Workspace | null {
const slug = useWorkspaceSlug();
const { data: list = [] } = useQuery(workspaceListOptions());
if (!slug) return null;
return list.find((w) => w.slug === slug) ?? null;
}
/**
* Path builder bound to the current workspace. Throws if called outside a
* workspace route — for cross-workspace links use paths.workspace(slug) directly.
*/
export function useWorkspacePaths(): WorkspacePaths {
const slug = useRequiredWorkspaceSlug();
return paths.workspace(slug);
}

View File

@@ -0,0 +1,10 @@
export { paths, isGlobalPath } from "./paths";
export type { WorkspacePaths } from "./paths";
export { RESERVED_SLUGS, isReservedSlug } from "./reserved-slugs";
export {
WorkspaceSlugProvider,
useWorkspaceSlug,
useRequiredWorkspaceSlug,
useCurrentWorkspace,
useWorkspacePaths,
} from "./hooks";

View File

@@ -0,0 +1,48 @@
import { describe, it, expect } from "vitest";
import { paths, isGlobalPath } from "./paths";
describe("paths.workspace(slug)", () => {
const ws = paths.workspace("acme");
it("builds dashboard paths with slug prefix", () => {
expect(ws.issues()).toBe("/acme/issues");
expect(ws.issueDetail("abc-123")).toBe("/acme/issues/abc-123");
expect(ws.projects()).toBe("/acme/projects");
expect(ws.projectDetail("p1")).toBe("/acme/projects/p1");
expect(ws.autopilots()).toBe("/acme/autopilots");
expect(ws.autopilotDetail("a1")).toBe("/acme/autopilots/a1");
expect(ws.agents()).toBe("/acme/agents");
expect(ws.inbox()).toBe("/acme/inbox");
expect(ws.myIssues()).toBe("/acme/my-issues");
expect(ws.runtimes()).toBe("/acme/runtimes");
expect(ws.skills()).toBe("/acme/skills");
expect(ws.settings()).toBe("/acme/settings");
});
it("URL-encodes special characters in ids", () => {
expect(ws.issueDetail("id with space")).toBe("/acme/issues/id%20with%20space");
});
});
describe("paths (global)", () => {
it("builds global paths without slug", () => {
expect(paths.login()).toBe("/login");
expect(paths.onboarding()).toBe("/onboarding");
expect(paths.invite("inv-1")).toBe("/invite/inv-1");
expect(paths.authCallback()).toBe("/auth/callback");
});
});
describe("isGlobalPath", () => {
it("returns true for pre-workspace routes", () => {
expect(isGlobalPath("/login")).toBe(true);
expect(isGlobalPath("/onboarding")).toBe(true);
expect(isGlobalPath("/invite/abc")).toBe(true);
expect(isGlobalPath("/auth/callback")).toBe(true);
});
it("returns false for workspace-scoped paths", () => {
expect(isGlobalPath("/acme/issues")).toBe(false);
expect(isGlobalPath("/")).toBe(false);
});
});

View File

@@ -0,0 +1,55 @@
/**
* Centralized URL path builder. All navigation in shared packages (packages/views)
* MUST go through this module — no hardcoded string paths.
*
* Two kinds of paths:
* - workspace-scoped: paths.workspace(slug).xxx() — carry workspace in URL
* - global: paths.login(), paths.onboarding(), paths.invite(id) — pre-workspace routes
*
* Why pure functions + builder pattern:
* - Changing a route shape (e.g. adding workspace slug prefix) becomes a single-file edit
* - IDs are always URL-encoded here so callers can't forget
* - Zero runtime deps means this module is safe in Node (tests) and browsers
*/
const encode = (id: string) => encodeURIComponent(id);
function workspaceScoped(slug: string) {
const ws = `/${encode(slug)}`;
return {
root: () => `${ws}/issues`,
issues: () => `${ws}/issues`,
issueDetail: (id: string) => `${ws}/issues/${encode(id)}`,
projects: () => `${ws}/projects`,
projectDetail: (id: string) => `${ws}/projects/${encode(id)}`,
autopilots: () => `${ws}/autopilots`,
autopilotDetail: (id: string) => `${ws}/autopilots/${encode(id)}`,
agents: () => `${ws}/agents`,
inbox: () => `${ws}/inbox`,
myIssues: () => `${ws}/my-issues`,
runtimes: () => `${ws}/runtimes`,
skills: () => `${ws}/skills`,
settings: () => `${ws}/settings`,
};
}
export const paths = {
workspace: workspaceScoped,
// Global (pre-workspace) routes
login: () => "/login",
onboarding: () => "/onboarding",
invite: (id: string) => `/invite/${encode(id)}`,
authCallback: () => "/auth/callback",
root: () => "/",
};
export type WorkspacePaths = ReturnType<typeof workspaceScoped>;
// Prefixes — not slug names — because we match against full URL paths.
// A path is global if it equals or begins with any of these.
const GLOBAL_PREFIXES = ["/login", "/onboarding", "/invite/", "/auth/", "/logout", "/signup"];
export function isGlobalPath(path: string): boolean {
return GLOBAL_PREFIXES.some((p) => path === p || path.startsWith(p));
}

View File

@@ -0,0 +1,49 @@
/**
* Slugs reserved because they collide with frontend top-level routes.
* Keep in sync with server/internal/handler/workspace_reserved_slugs.go.
*/
export const RESERVED_SLUGS = new Set([
// Auth + onboarding
"login",
"logout",
"signup",
"onboarding",
"invite",
"auth",
// Reserved for future platform routes
"api",
"admin",
"help",
"about",
"pricing",
"changelog",
// Dashboard route segments. Even though Next.js's route specificity
// would technically resolve /{slug}/{view} correctly, having a workspace
// slug equal to a route name (e.g. slug="issues") makes URLs visually
// ambiguous — /issues/abc reads as either "issue abc in workspace
// 'issues'" or "issue abc in some workspace". Reserve to avoid the
// ambiguity entirely.
"issues",
"projects",
"autopilots",
"agents",
"inbox",
"my-issues",
"runtimes",
"skills",
"settings",
// Next.js / hosting internals
"_next",
"favicon.ico",
"robots.txt",
"sitemap.xml",
"manifest.json",
".well-known",
]);
export function isReservedSlug(slug: string): boolean {
return RESERVED_SLUGS.has(slug);
}

View File

@@ -4,11 +4,11 @@ import { useEffect, type ReactNode } from "react";
import { useQueryClient } from "@tanstack/react-query";
import { getApi } from "../api";
import { useAuthStore } from "../auth";
import { useWorkspaceStore } from "../workspace";
import { configStore } from "../config";
import { workspaceKeys } from "../workspace/queries";
import { createLogger } from "../logger";
import { defaultStorage } from "./storage";
import { setCurrentWorkspace } from "./workspace-storage";
import type { StorageAdapter } from "../types/storage";
const logger = createLogger("auth");
@@ -30,7 +30,6 @@ export function AuthInitializer({
useEffect(() => {
const api = getApi();
const wsId = storage.getItem("multica_workspace_id");
// Fetch app config (CDN domain, etc.) in the background — non-blocking.
api.getConfig().then((cfg) => {
@@ -40,12 +39,16 @@ export function AuthInitializer({
if (cookieAuth) {
// Cookie mode: the HttpOnly cookie is sent automatically by the browser.
// Call the API to check if the session is still valid.
//
// Seed the workspace list into React Query so the URL-driven layout can
// resolve the slug without a second fetch. The active workspace itself
// is derived from the URL by [workspaceSlug]/layout.tsx — no imperative
// selection here.
Promise.all([api.getMe(), api.listWorkspaces()])
.then(([user, wsList]) => {
onLogin?.();
useAuthStore.setState({ user, isLoading: false });
qc.setQueryData(workspaceKeys.list(), wsList);
useWorkspaceStore.getState().hydrateWorkspace(wsList, wsId);
})
.catch((err) => {
logger.error("cookie auth init failed", err);
@@ -69,16 +72,15 @@ export function AuthInitializer({
.then(([user, wsList]) => {
onLogin?.();
useAuthStore.setState({ user, isLoading: false });
// Seed React Query cache so components don't need a second fetch
// Seed React Query cache so the URL-driven layout can resolve the
// slug without a second fetch.
qc.setQueryData(workspaceKeys.list(), wsList);
useWorkspaceStore.getState().hydrateWorkspace(wsList, wsId);
})
.catch((err) => {
logger.error("auth init failed", err);
api.setToken(null);
api.setWorkspaceId(null);
setCurrentWorkspace(null, null);
storage.removeItem("multica_token");
storage.removeItem("multica_workspace_id");
onLogout?.();
useAuthStore.setState({ user: null, isLoading: false });
});

View File

@@ -4,7 +4,6 @@ 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";
@@ -18,7 +17,6 @@ import type { StorageAdapter } from "../types/storage";
// 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,
@@ -33,7 +31,6 @@ function initCore(
logger: createLogger("api"),
onUnauthorized: () => {
storage.removeItem("multica_token");
storage.removeItem("multica_workspace_id");
},
});
setApiInstance(api);
@@ -43,15 +40,14 @@ function initCore(
const token = storage.getItem("multica_token");
if (token) api.setToken(token);
}
const wsId = storage.getItem("multica_workspace_id");
if (wsId) api.setWorkspaceId(wsId);
// Workspace identity is URL-driven: the [workspaceSlug] layout resolves
// the slug and calls setCurrentWorkspace(slug, wsId) on mount. The api
// client reads the slug from that singleton for the X-Workspace-Slug
// header. No boot-time hydration from storage is required.
authStore = createAuthStore({ api, storage, onLogin, onLogout, cookieAuth });
registerAuthStore(authStore);
workspaceStore = createWorkspaceStore(api, { storage });
registerWorkspaceStore(workspaceStore);
chatStore = createChatStore({ storage });
registerChatStore(chatStore);
@@ -78,7 +74,6 @@ export function CoreProvider({
<WSProvider
wsUrl={wsUrl}
authStore={authStore}
workspaceStore={workspaceStore}
storage={storage}
cookieAuth={cookieAuth}
>

View File

@@ -3,5 +3,5 @@ export type { CoreProviderProps } from "./types";
export { AuthInitializer } from "./auth-initializer";
export { defaultStorage } from "./storage";
export { createPersistStorage } from "./persist-storage";
export { createWorkspaceAwareStorage, setCurrentWorkspaceId, getCurrentWorkspaceId, registerForWorkspaceRehydration, rehydrateAllWorkspaceStores } from "./workspace-storage";
export { createWorkspaceAwareStorage, setCurrentWorkspace, getCurrentSlug, getCurrentWsId, subscribeToCurrentSlug, registerForWorkspaceRehydration, rehydrateAllWorkspaceStores } from "./workspace-storage";
export { clearWorkspaceStorage } from "./storage-cleanup";

View File

@@ -19,6 +19,7 @@ describe("clearWorkspaceStorage", () => {
expect(adapter.removeItem).toHaveBeenCalledWith("multica:chat:activeSessionId:ws_123");
expect(adapter.removeItem).toHaveBeenCalledWith("multica:chat:drafts:ws_123");
expect(adapter.removeItem).toHaveBeenCalledWith("multica:chat:expanded:ws_123");
expect(adapter.removeItem).toHaveBeenCalledTimes(8);
expect(adapter.removeItem).toHaveBeenCalledWith("multica_navigation:ws_123");
expect(adapter.removeItem).toHaveBeenCalledTimes(9);
});
});

View File

@@ -1,7 +1,7 @@
import type { StorageAdapter } from "../types/storage";
/**
* Keys that are namespaced per workspace (stored as `${key}:${wsId}`).
* Keys that are namespaced per workspace (stored as `${key}:${slug}`).
*
* IMPORTANT: When adding a new workspace-scoped persist store or storage key,
* add its key here so that workspace deletion and logout properly clean it up.
@@ -16,14 +16,15 @@ const WORKSPACE_SCOPED_KEYS = [
"multica:chat:activeSessionId",
"multica:chat:drafts",
"multica:chat:expanded",
"multica_navigation",
];
/** Remove all workspace-scoped storage entries for the given workspace. */
/** Remove all workspace-scoped storage entries for the given workspace slug. */
export function clearWorkspaceStorage(
adapter: StorageAdapter,
wsId: string,
slug: string,
) {
for (const key of WORKSPACE_SCOPED_KEYS) {
adapter.removeItem(`${key}:${wsId}`);
adapter.removeItem(`${key}:${slug}`);
}
}

View File

@@ -1,5 +1,5 @@
import { describe, it, expect, vi, afterEach } from "vitest";
import { createWorkspaceAwareStorage, setCurrentWorkspaceId } from "./workspace-storage";
import { createWorkspaceAwareStorage, setCurrentWorkspace } from "./workspace-storage";
import type { StorageAdapter } from "../types/storage";
function mockAdapter(): StorageAdapter {
@@ -12,50 +12,50 @@ function mockAdapter(): StorageAdapter {
}
afterEach(() => {
setCurrentWorkspaceId(null);
setCurrentWorkspace(null, null);
});
describe("workspace-aware storage", () => {
it("uses plain key when no workspace is set", () => {
const adapter = mockAdapter();
setCurrentWorkspaceId(null);
setCurrentWorkspace(null, null);
const storage = createWorkspaceAwareStorage(adapter);
storage.setItem("draft", "data");
expect(adapter.setItem).toHaveBeenCalledWith("draft", "data");
});
it("namespaces key when workspace is set", () => {
it("namespaces key with slug when workspace is set", () => {
const adapter = mockAdapter();
setCurrentWorkspaceId("ws_abc");
setCurrentWorkspace("acme", "ws_abc");
const storage = createWorkspaceAwareStorage(adapter);
storage.setItem("draft", "data");
expect(adapter.setItem).toHaveBeenCalledWith("draft:ws_abc", "data");
expect(adapter.setItem).toHaveBeenCalledWith("draft:acme", "data");
storage.getItem("draft");
expect(adapter.getItem).toHaveBeenCalledWith("draft:ws_abc");
expect(adapter.getItem).toHaveBeenCalledWith("draft:acme");
});
it("follows workspace changes dynamically", () => {
const adapter = mockAdapter();
const storage = createWorkspaceAwareStorage(adapter);
setCurrentWorkspaceId("ws_1");
setCurrentWorkspace("team-a", "ws_1");
storage.setItem("draft", "v1");
expect(adapter.setItem).toHaveBeenCalledWith("draft:ws_1", "v1");
expect(adapter.setItem).toHaveBeenCalledWith("draft:team-a", "v1");
setCurrentWorkspaceId("ws_2");
setCurrentWorkspace("team-b", "ws_2");
storage.setItem("draft", "v2");
expect(adapter.setItem).toHaveBeenCalledWith("draft:ws_2", "v2");
expect(adapter.setItem).toHaveBeenCalledWith("draft:team-b", "v2");
});
it("removeItem uses current workspace", () => {
it("removeItem uses current workspace slug", () => {
const adapter = mockAdapter();
setCurrentWorkspaceId("ws_x");
setCurrentWorkspace("dev", "ws_x");
const storage = createWorkspaceAwareStorage(adapter);
storage.removeItem("draft");
expect(adapter.removeItem).toHaveBeenCalledWith("draft:ws_x");
expect(adapter.removeItem).toHaveBeenCalledWith("draft:dev");
});
});

View File

@@ -1,11 +1,69 @@
import type { StateStorage } from "zustand/middleware";
import type { StorageAdapter } from "../types/storage";
// Paired module vars — always set/cleared together by the workspace layout.
// _currentSlug is the primary identifier (matches the URL segment).
// _currentWsId is derived (from the React Query workspace list) and used for
// query keys and path-embedded API calls where UUID is required.
let _currentSlug: string | null = null;
let _currentWsId: string | null = null;
const _rehydrateFns: Array<() => void> = [];
export function setCurrentWorkspaceId(wsId: string | null) {
const _rehydrateFns: Array<() => void> = [];
const _slugSubscribers = new Set<(slug: string | null) => void>();
let _pendingNotify = false;
let _pendingRehydrate = false;
/**
* Set both the current workspace slug and UUID at once.
* Called by the workspace layout's render-phase ref guard.
* Notifies slug subscribers (e.g. WSProvider via useSyncExternalStore).
*/
export function setCurrentWorkspace(slug: string | null, wsId: string | null) {
const slugChanged = _currentSlug !== slug;
_currentSlug = slug;
_currentWsId = wsId;
if (slugChanged && !_pendingNotify) {
_pendingNotify = true;
// Defer and deduplicate subscriber notifications:
// 1. Defer: avoids "cannot update component B while rendering A"
// (React 19 render-phase restriction).
// 2. Deduplicate: rapid A→B switches only notify once with the
// final slug, avoiding a wasted WS connect+disconnect cycle.
// The module vars are already updated synchronously above, so
// authHeaders() and getCurrentSlug() return the correct value
// immediately — subscribers are only for async consumers like
// WSProvider that need to reconnect the WebSocket.
queueMicrotask(() => {
_pendingNotify = false;
const current = _currentSlug;
for (const fn of _slugSubscribers) {
fn(current);
}
});
}
}
/** Current workspace slug (from URL). */
export function getCurrentSlug(): string | null {
return _currentSlug;
}
/** Current workspace UUID (derived from slug + workspace list cache). */
export function getCurrentWsId(): string | null {
return _currentWsId;
}
/**
* Subscribe to changes of the current workspace slug. Returns an unsubscribe
* function. Designed for React's `useSyncExternalStore` (WSProvider reconnect).
*/
export function subscribeToCurrentSlug(
fn: (slug: string | null) => void,
): () => void {
_slugSubscribers.add(fn);
return () => {
_slugSubscribers.delete(fn);
};
}
/** Register a persist store's rehydrate function to be called on workspace switch. */
@@ -13,24 +71,34 @@ export function registerForWorkspaceRehydration(fn: () => void) {
_rehydrateFns.push(fn);
}
/** Rehydrate all registered workspace-scoped persist stores from the new namespace. */
/**
* Rehydrate all registered workspace-scoped persist stores from the new
* namespace. Deferred to a microtask + deduplicated for the same reason
* as slug subscriber notification: Zustand persist rehydrate synchronously
* setState()s the store, which schedules updates on any component
* subscribed to that store. Calling this from a component's render phase
* would violate React 19's "no cross-component updates during render"
* rule. Persist stores can tolerate one microtask of staleness — they're
* UI preferences, not security-critical state.
*/
export function rehydrateAllWorkspaceStores() {
for (const fn of _rehydrateFns) {
fn();
}
}
export function getCurrentWorkspaceId(): string | null {
return _currentWsId;
if (_pendingRehydrate) return;
_pendingRehydrate = true;
queueMicrotask(() => {
_pendingRehydrate = false;
for (const fn of _rehydrateFns) {
fn();
}
});
}
/**
* Storage that automatically namespaces keys with the current workspace ID.
* Reads _currentWsId at call time, so it follows workspace switches dynamically.
* Storage that automatically namespaces keys with the current workspace slug.
* Reads _currentSlug at call time, so it follows workspace switches dynamically.
*/
export function createWorkspaceAwareStorage(adapter: StorageAdapter): StateStorage {
const resolve = (key: string) =>
_currentWsId ? `${key}:${_currentWsId}` : key;
_currentSlug ? `${key}:${_currentSlug}` : key;
return {
getItem: (key) => adapter.getItem(resolve(key)),

View File

@@ -6,13 +6,17 @@ import {
useEffect,
useState,
useCallback,
useSyncExternalStore,
type ReactNode,
} from "react";
import { WSClient } from "../api/ws-client";
import type { WSEventType, StorageAdapter } from "../types";
import type { StoreApi, UseBoundStore } from "zustand";
import type { AuthState } from "../auth/store";
import type { WorkspaceStore } from "../workspace/store";
import {
getCurrentSlug,
subscribeToCurrentSlug,
} from "../platform/workspace-storage";
import { createLogger } from "../logger";
import { useRealtimeSync, type RealtimeSyncStores } from "./use-realtime-sync";
@@ -31,8 +35,6 @@ export interface WSProviderProps {
wsUrl: string;
/** Platform-created auth store instance */
authStore: UseBoundStore<StoreApi<AuthState>>;
/** Platform-created workspace store instance */
workspaceStore: UseBoundStore<StoreApi<WorkspaceStore>>;
/** Platform-specific storage adapter for reading auth tokens */
storage: StorageAdapter;
/** When true, use HttpOnly cookies instead of token query param for WS auth. */
@@ -45,17 +47,25 @@ export function WSProvider({
children,
wsUrl,
authStore,
workspaceStore,
storage,
cookieAuth,
onToast,
}: WSProviderProps) {
const user = authStore((s) => s.user);
const workspace = workspaceStore((s) => s.workspace);
// Reactive read of the current workspace slug (URL-driven singleton in
// packages/core/platform/workspace-storage.ts). When the workspace switches,
// the useEffect below tears down the old WS connection and opens a new one
// bound to the new workspace slug. SSR snapshot is `null` because this
// provider only renders client-side under CoreProvider.
const wsSlug = useSyncExternalStore(
subscribeToCurrentSlug,
getCurrentSlug,
() => null,
);
const [wsClient, setWsClient] = useState<WSClient | null>(null);
useEffect(() => {
if (!user || !workspace) return;
if (!user || !wsSlug) return;
// In token mode we need a token from storage; in cookie mode the HttpOnly
// cookie is sent automatically with the WS upgrade request.
@@ -66,7 +76,7 @@ export function WSProvider({
logger: createLogger("ws"),
cookieAuth,
});
ws.setAuth(token, workspace.id);
ws.setAuth(token, wsSlug);
setWsClient(ws);
ws.connect();
@@ -74,9 +84,9 @@ export function WSProvider({
ws.disconnect();
setWsClient(null);
};
}, [user, workspace, wsUrl, storage, cookieAuth]);
}, [user, wsSlug, wsUrl, storage, cookieAuth]);
const stores: RealtimeSyncStores = { authStore, workspaceStore };
const stores: RealtimeSyncStores = { authStore };
// Centralized WS -> store sync (uses state so it re-subscribes when WS changes)
useRealtimeSync(wsClient, stores, onToast);

View File

@@ -5,10 +5,10 @@ import { useQueryClient } from "@tanstack/react-query";
import type { WSClient } from "../api/ws-client";
import type { StoreApi, UseBoundStore } from "zustand";
import type { AuthState } from "../auth/store";
import type { WorkspaceStore } from "../workspace/store";
import { createLogger } from "../logger";
import { clearWorkspaceStorage } from "../platform/storage-cleanup";
import { defaultStorage } from "../platform/storage";
import { getCurrentWsId, getCurrentSlug } from "../platform/workspace-storage";
import { issueKeys } from "../issues/queries";
import { projectKeys } from "../projects/queries";
import { pinKeys } from "../pins/queries";
@@ -23,6 +23,7 @@ import { onInboxNew, onInboxInvalidate, onInboxIssueStatusChanged } from "../inb
import { inboxKeys } from "../inbox/queries";
import { workspaceKeys, workspaceListOptions } from "../workspace/queries";
import { chatKeys } from "../chat/queries";
import { paths } from "../paths";
import type {
MemberAddedPayload,
WorkspaceDeletedPayload,
@@ -54,7 +55,6 @@ const logger = createLogger("realtime-sync");
export interface RealtimeSyncStores {
authStore: UseBoundStore<StoreApi<AuthState>>;
workspaceStore: UseBoundStore<StoreApi<WorkspaceStore>>;
}
/**
@@ -79,7 +79,7 @@ export function useRealtimeSync(
stores: RealtimeSyncStores,
onToast?: (message: string, type?: "info" | "error") => void,
) {
const { authStore, workspaceStore } = stores;
const { authStore } = stores;
const qc = useQueryClient();
// Main sync: onAny -> refreshMap with debounce
useEffect(() => {
@@ -87,39 +87,39 @@ export function useRealtimeSync(
const refreshMap: Record<string, () => void> = {
inbox: () => {
const wsId = workspaceStore.getState().workspace?.id;
const wsId = getCurrentWsId();
if (wsId) onInboxInvalidate(qc, wsId);
},
agent: () => {
const wsId = workspaceStore.getState().workspace?.id;
const wsId = getCurrentWsId();
if (wsId) qc.invalidateQueries({ queryKey: workspaceKeys.agents(wsId) });
},
member: () => {
const wsId = workspaceStore.getState().workspace?.id;
const wsId = getCurrentWsId();
if (wsId) qc.invalidateQueries({ queryKey: workspaceKeys.members(wsId) });
},
workspace: () => {
qc.invalidateQueries({ queryKey: workspaceKeys.list() });
},
skill: () => {
const wsId = workspaceStore.getState().workspace?.id;
const wsId = getCurrentWsId();
if (wsId) qc.invalidateQueries({ queryKey: workspaceKeys.skills(wsId) });
},
project: () => {
const wsId = workspaceStore.getState().workspace?.id;
const wsId = getCurrentWsId();
if (wsId) qc.invalidateQueries({ queryKey: projectKeys.all(wsId) });
},
pin: () => {
const wsId = workspaceStore.getState().workspace?.id;
const wsId = getCurrentWsId();
const userId = authStore.getState().user?.id;
if (wsId && userId) qc.invalidateQueries({ queryKey: pinKeys.all(wsId, userId) });
},
daemon: () => {
const wsId = workspaceStore.getState().workspace?.id;
const wsId = getCurrentWsId();
if (wsId) qc.invalidateQueries({ queryKey: runtimeKeys.all(wsId) });
},
autopilot: () => {
const wsId = workspaceStore.getState().workspace?.id;
const wsId = getCurrentWsId();
if (wsId) qc.invalidateQueries({ queryKey: autopilotKeys.all(wsId) });
},
};
@@ -166,7 +166,7 @@ export function useRealtimeSync(
const unsubIssueUpdated = ws.on("issue:updated", (p) => {
const { issue } = p as IssueUpdatedPayload;
if (!issue?.id) return;
const wsId = workspaceStore.getState().workspace?.id;
const wsId = getCurrentWsId();
if (wsId) {
onIssueUpdated(qc, wsId, issue);
if (issue.status) {
@@ -178,21 +178,21 @@ export function useRealtimeSync(
const unsubIssueCreated = ws.on("issue:created", (p) => {
const { issue } = p as IssueCreatedPayload;
if (!issue) return;
const wsId = workspaceStore.getState().workspace?.id;
const wsId = getCurrentWsId();
if (wsId) onIssueCreated(qc, wsId, issue);
});
const unsubIssueDeleted = ws.on("issue:deleted", (p) => {
const { issue_id } = p as IssueDeletedPayload;
if (!issue_id) return;
const wsId = workspaceStore.getState().workspace?.id;
const wsId = getCurrentWsId();
if (wsId) onIssueDeleted(qc, wsId, issue_id);
});
const unsubInboxNew = ws.on("inbox:new", (p) => {
const { item } = p as InboxNewPayload;
if (!item) return;
const wsId = workspaceStore.getState().workspace?.id;
const wsId = getCurrentWsId();
if (wsId) onInboxNew(qc, wsId, item);
});
@@ -260,16 +260,34 @@ export function useRealtimeSync(
// --- Side-effect handlers (toast, navigation) ---
// After the current workspace disappears (deleted or we were kicked out),
// navigate to another workspace the user still has access to, or to
// onboarding. We use a full-page navigation: this reliably tears down any
// in-flight queries / subscriptions tied to the dead workspace without
// relying on framework-specific routers from here in core.
const relocateAfterWorkspaceLoss = async (lostWsId: string) => {
const wsList = await qc.fetchQuery({
...workspaceListOptions(),
staleTime: 0,
});
const next = wsList.find((w) => w.id !== lostWsId);
const target = next ? paths.workspace(next.slug).issues() : paths.onboarding();
if (typeof window !== "undefined") {
window.location.assign(target);
}
};
const unsubWsDeleted = ws.on("workspace:deleted", (p) => {
const { workspace_id } = p as WorkspaceDeletedPayload;
clearWorkspaceStorage(defaultStorage, workspace_id);
const currentWs = workspaceStore.getState().workspace;
if (currentWs?.id === workspace_id) {
// Event payload has UUID; look up slug from cached workspace list
// since clearWorkspaceStorage keys are namespaced by slug.
const wsList = qc.getQueryData<{ id: string; slug: string }[]>(workspaceKeys.list()) ?? [];
const deletedSlug = wsList.find((w) => w.id === workspace_id)?.slug;
if (deletedSlug) clearWorkspaceStorage(defaultStorage, deletedSlug);
if (getCurrentWsId() === workspace_id) {
logger.warn("current workspace deleted, switching");
onToast?.("This workspace was deleted", "info");
qc.fetchQuery({ ...workspaceListOptions(), staleTime: 0 }).then((wsList) => {
workspaceStore.getState().hydrateWorkspace(wsList);
});
relocateAfterWorkspaceLoss(workspace_id);
}
});
@@ -277,13 +295,14 @@ export function useRealtimeSync(
const { user_id } = p as MemberRemovedPayload;
const myUserId = authStore.getState().user?.id;
if (user_id === myUserId) {
const wsId = workspaceStore.getState().workspace?.id;
if (wsId) clearWorkspaceStorage(defaultStorage, wsId);
logger.warn("removed from workspace, switching");
onToast?.("You were removed from this workspace", "info");
qc.fetchQuery({ ...workspaceListOptions(), staleTime: 0 }).then((wsList) => {
workspaceStore.getState().hydrateWorkspace(wsList);
});
const slug = getCurrentSlug();
const wsId = getCurrentWsId();
if (slug && wsId) {
clearWorkspaceStorage(defaultStorage, slug);
logger.warn("removed from workspace, switching");
onToast?.("You were removed from this workspace", "info");
relocateAfterWorkspaceLoss(wsId);
}
}
});
@@ -312,14 +331,14 @@ export function useRealtimeSync(
// invitation:accepted / declined / revoked — refresh invitation lists
const unsubInvitationAccepted = ws.on("invitation:accepted", () => {
const currentWsId = workspaceStore.getState().workspace?.id;
const currentWsId = getCurrentWsId();
if (currentWsId) {
qc.invalidateQueries({ queryKey: workspaceKeys.invitations(currentWsId) });
qc.invalidateQueries({ queryKey: workspaceKeys.members(currentWsId) });
}
});
const unsubInvitationDeclined = ws.on("invitation:declined", () => {
const currentWsId = workspaceStore.getState().workspace?.id;
const currentWsId = getCurrentWsId();
if (currentWsId) {
qc.invalidateQueries({ queryKey: workspaceKeys.invitations(currentWsId) });
}
@@ -357,11 +376,11 @@ export function useRealtimeSync(
// Helpers reused by chat lifecycle handlers.
const invalidatePendingAggregate = () => {
const id = workspaceStore.getState().workspace?.id;
const id = getCurrentWsId();
if (id) qc.invalidateQueries({ queryKey: chatKeys.pendingTasks(id) });
};
const invalidateSessionLists = () => {
const id = workspaceStore.getState().workspace?.id;
const id = getCurrentWsId();
if (id) {
qc.invalidateQueries({ queryKey: chatKeys.sessions(id) });
qc.invalidateQueries({ queryKey: chatKeys.allSessions(id) });
@@ -457,7 +476,7 @@ export function useRealtimeSync(
timers.forEach(clearTimeout);
timers.clear();
};
}, [ws, qc, authStore, workspaceStore, onToast]);
}, [ws, qc, authStore, onToast]);
// Reconnect -> refetch all data to recover missed events
useEffect(() => {
@@ -466,7 +485,7 @@ export function useRealtimeSync(
const unsub = ws.onReconnect(async () => {
logger.info("reconnected, refetching all data");
try {
const wsId = workspaceStore.getState().workspace?.id;
const wsId = getCurrentWsId();
if (wsId) {
qc.invalidateQueries({ queryKey: issueKeys.all(wsId) });
qc.invalidateQueries({ queryKey: inboxKeys.all(wsId) });
@@ -484,5 +503,5 @@ export function useRealtimeSync(
});
return unsub;
}, [ws, qc, workspaceStore]);
}, [ws, qc]);
}

View File

@@ -1,41 +1,3 @@
export * from "./store";
export * from "./queries";
export * from "./mutations";
export * from "./hooks";
import type { createWorkspaceStore as CreateWorkspaceStoreFn } from "./store";
type WorkspaceStoreInstance = ReturnType<typeof CreateWorkspaceStoreFn>;
/** Module-level singleton — set once at app boot via `registerWorkspaceStore()`. */
let _store: WorkspaceStoreInstance | null = null;
/**
* Register the workspace store instance created by the app.
* Must be called at boot before any component renders.
*/
export function registerWorkspaceStore(store: WorkspaceStoreInstance) {
_store = store;
}
/**
* Singleton accessor — a Zustand hook backed by the registered instance.
* Supports `useWorkspaceStore(selector)` and `useWorkspaceStore.getState()`.
*/
export const useWorkspaceStore: WorkspaceStoreInstance = new Proxy(
(() => {}) as unknown as WorkspaceStoreInstance,
{
apply(_target, _thisArg, args) {
if (!_store)
throw new Error(
"Workspace store not initialised — call registerWorkspaceStore() first",
);
return (_store as unknown as (...a: unknown[]) => unknown)(...args);
},
get(_target, prop) {
// Allow property inspection (HMR/React Refresh) before registration
if (!_store) return undefined;
return Reflect.get(_store, prop);
},
},
);

View File

@@ -1,18 +1,23 @@
import { useMutation, useQueryClient } from "@tanstack/react-query";
import type { Workspace } from "../types";
import { api } from "../api";
import { workspaceKeys, workspaceListOptions } from "./queries";
import { useWorkspaceStore } from "./index";
import { workspaceKeys } from "./queries";
export function useCreateWorkspace() {
const qc = useQueryClient();
return useMutation({
mutationFn: (data: { name: string; slug: string; description?: string }) =>
api.createWorkspace(data),
// Seed the workspace list cache BEFORE callers navigate to /{newWs.slug}/issues.
// The destination [workspaceSlug]/layout queries by slug from this cache;
// without seeding, it would briefly show "loading" before the background
// invalidation completes. TanStack Query guarantees this onSuccess runs
// before mutateAsync's resolver / before any callback-style onSuccess
// passed to mutate(), so any caller that navigates after the mutation
// resolves will see the seeded data synchronously. Switching workspaces
// is pure navigation now — no imperative store writes needed.
onSuccess: (newWs) => {
// Add to cache before switching so sidebar list is consistent on first render
qc.setQueryData(workspaceKeys.list(), (old: Workspace[] = []) => [...old, newWs]);
useWorkspaceStore.getState().switchWorkspace(newWs);
},
onSettled: () => {
qc.invalidateQueries({ queryKey: workspaceKeys.list() });
@@ -24,14 +29,6 @@ export function useLeaveWorkspace() {
const qc = useQueryClient();
return useMutation({
mutationFn: (workspaceId: string) => api.leaveWorkspace(workspaceId),
onSuccess: async (_, workspaceId) => {
const currentWsId = useWorkspaceStore.getState().workspace?.id;
if (currentWsId === workspaceId) {
// staleTime: 0 forces a real network fetch — cache still has the left workspace
const wsList = await qc.fetchQuery({ ...workspaceListOptions(), staleTime: 0 });
useWorkspaceStore.getState().hydrateWorkspace(wsList);
}
},
onSettled: () => {
qc.invalidateQueries({ queryKey: workspaceKeys.list() });
},
@@ -42,14 +39,6 @@ export function useDeleteWorkspace() {
const qc = useQueryClient();
return useMutation({
mutationFn: (workspaceId: string) => api.deleteWorkspace(workspaceId),
onSuccess: async (_, workspaceId) => {
const currentWsId = useWorkspaceStore.getState().workspace?.id;
if (currentWsId === workspaceId) {
// staleTime: 0 forces a real network fetch — cache still has the deleted workspace
const wsList = await qc.fetchQuery({ ...workspaceListOptions(), staleTime: 0 });
useWorkspaceStore.getState().hydrateWorkspace(wsList);
}
},
onSettled: () => {
qc.invalidateQueries({ queryKey: workspaceKeys.list() });
},

View File

@@ -1,5 +1,6 @@
import { queryOptions } from "@tanstack/react-query";
import { api } from "../api";
import type { Workspace } from "../types";
export const workspaceKeys = {
all: (wsId: string) => ["workspaces", wsId] as const,
@@ -19,6 +20,14 @@ export function workspaceListOptions() {
});
}
/** Resolves the workspace whose slug matches, from the cached workspace list. */
export function workspaceBySlugOptions(slug: string) {
return queryOptions({
...workspaceListOptions(),
select: (list: Workspace[]) => list.find((w) => w.slug === slug) ?? null,
});
}
export function memberListOptions(wsId: string) {
return queryOptions({
queryKey: workspaceKeys.members(wsId),

View File

@@ -1,92 +0,0 @@
import { create } from "zustand";
import type { Workspace, StorageAdapter } from "../types";
import type { ApiClient } from "../api/client";
import { createLogger } from "../logger";
import { setCurrentWorkspaceId, rehydrateAllWorkspaceStores } from "../platform/workspace-storage";
const logger = createLogger("workspace-store");
interface WorkspaceStoreOptions {
storage?: StorageAdapter;
}
interface WorkspaceState {
workspace: Workspace | null;
}
interface WorkspaceActions {
/**
* Pick a workspace from a list and set it as current.
* The list itself is NOT stored here — it lives in React Query.
*/
hydrateWorkspace: (
wsList: Workspace[],
preferredWorkspaceId?: string | null,
) => Workspace | null;
/** Switch to a workspace. Caller provides the full object (from React Query). */
switchWorkspace: (ws: Workspace) => void;
/** Update current workspace data in place (e.g. after rename). */
updateWorkspace: (ws: Workspace) => void;
clearWorkspace: () => void;
}
export type WorkspaceStore = WorkspaceState & WorkspaceActions;
export function createWorkspaceStore(api: ApiClient, options?: WorkspaceStoreOptions) {
const storage = options?.storage;
return create<WorkspaceStore>((set) => ({
// Only the currently selected workspace (UI state).
// The workspace list is server state and lives in React Query.
workspace: null,
hydrateWorkspace: (wsList, preferredWorkspaceId) => {
const nextWorkspace =
(preferredWorkspaceId
? wsList.find((item) => item.id === preferredWorkspaceId)
: null) ??
wsList[0] ??
null;
if (!nextWorkspace) {
api.setWorkspaceId(null);
setCurrentWorkspaceId(null);
rehydrateAllWorkspaceStores();
storage?.removeItem("multica_workspace_id");
set({ workspace: null });
return null;
}
api.setWorkspaceId(nextWorkspace.id);
setCurrentWorkspaceId(nextWorkspace.id);
rehydrateAllWorkspaceStores();
storage?.setItem("multica_workspace_id", nextWorkspace.id);
set({ workspace: nextWorkspace });
logger.debug("hydrate workspace", nextWorkspace.name, nextWorkspace.id);
return nextWorkspace;
},
switchWorkspace: (ws) => {
logger.info("switching to", ws.id);
api.setWorkspaceId(ws.id);
setCurrentWorkspaceId(ws.id);
rehydrateAllWorkspaceStores();
storage?.setItem("multica_workspace_id", ws.id);
set({ workspace: ws });
},
updateWorkspace: (ws) => {
set((state) => ({
workspace: state.workspace?.id === ws.id ? ws : state.workspace,
}));
},
clearWorkspace: () => {
api.setWorkspaceId(null);
setCurrentWorkspaceId(null);
rehydrateAllWorkspaceStores();
set({ workspace: null });
},
}));
}

View File

@@ -353,7 +353,7 @@ function SidebarInset({ className, ...props }: React.ComponentProps<"main">) {
<main
data-slot="sidebar-inset"
className={cn(
"relative flex w-full flex-1 flex-col bg-background md:peer-data-[variant=inset]:m-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow-sm md:peer-data-[variant=inset]:peer-data-[state=collapsed]:ml-2",
"relative flex w-full flex-1 flex-col bg-background md:peer-data-[variant=inset]:m-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:peer-data-[state=collapsed]:ml-2",
className
)}
{...props}

View File

@@ -5,7 +5,7 @@ import { Tooltip as TooltipPrimitive } from "@base-ui/react/tooltip"
import { cn } from "@multica/ui/lib/utils"
function TooltipProvider({
delay = 0,
delay = 200,
...props
}: TooltipPrimitive.Provider.Props) {
return (
@@ -50,13 +50,12 @@ function TooltipContent({
<TooltipPrimitive.Popup
data-slot="tooltip-content"
className={cn(
"z-50 inline-flex w-fit max-w-xs origin-(--transform-origin) items-center gap-1.5 rounded-md bg-foreground px-3 py-1.5 text-xs text-background has-data-[slot=kbd]:pr-1.5 data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 **:data-[slot=kbd]:relative **:data-[slot=kbd]:isolate **:data-[slot=kbd]:z-50 **:data-[slot=kbd]:rounded-sm data-[state=delayed-open]:animate-in data-[state=delayed-open]:fade-in-0 data-[state=delayed-open]:zoom-in-95 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
"z-50 inline-flex w-fit max-w-xs origin-(--transform-origin) items-center gap-1.5 rounded-lg border border-border bg-popover px-2.5 py-1 text-xs text-popover-foreground has-data-[slot=kbd]:pr-1.5 data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 **:data-[slot=kbd]:relative **:data-[slot=kbd]:isolate **:data-[slot=kbd]:z-50 **:data-[slot=kbd]:rounded-sm data-[state=delayed-open]:animate-in data-[state=delayed-open]:fade-in-0 data-[state=delayed-open]:zoom-in-95 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
className
)}
{...props}
>
{children}
<TooltipPrimitive.Arrow className="z-50 size-2.5 translate-y-[calc(-50%-2px)] rotate-45 rounded-[2px] bg-foreground fill-foreground data-[side=bottom]:top-1 data-[side=inline-end]:top-1/2! data-[side=inline-end]:-left-1 data-[side=inline-end]:-translate-y-1/2 data-[side=inline-start]:top-1/2! data-[side=inline-start]:-right-1 data-[side=inline-start]:-translate-y-1/2 data-[side=left]:top-1/2! data-[side=left]:-right-1 data-[side=left]:-translate-y-1/2 data-[side=right]:top-1/2! data-[side=right]:-left-1 data-[side=right]:-translate-y-1/2 data-[side=top]:-bottom-2.5" />
</TooltipPrimitive.Popup>
</TooltipPrimitive.Positioner>
</TooltipPrimitive.Portal>

View File

@@ -27,7 +27,6 @@
--color-brand: var(--brand);
--color-brand-foreground: var(--brand-foreground);
--color-priority: var(--priority);
--color-canvas: var(--canvas);
--color-accent-foreground: var(--accent-foreground);
--color-accent: var(--accent);
--color-muted-foreground: var(--muted-foreground);
@@ -86,7 +85,6 @@
--sidebar-ring: oklch(0.705 0.015 286.067);
--brand: oklch(0.55 0.16 255);
--brand-foreground: oklch(0.985 0 0);
--canvas: oklch(0.95 0.002 286);
--success: oklch(0.55 0.16 145);
--warning: oklch(0.75 0.16 85);
--info: oklch(0.55 0.18 250);
@@ -130,7 +128,6 @@
--sidebar-ring: oklch(0.552 0.016 285.938);
--brand: oklch(0.65 0.16 255);
--brand-foreground: oklch(0.985 0 0);
--canvas: oklch(0.2 0.005 286);
--success: oklch(0.65 0.15 145);
--warning: oklch(0.70 0.16 85);
--info: oklch(0.65 0.18 250);

View File

@@ -18,6 +18,7 @@ import { runtimeListOptions } from "@multica/core/runtimes/queries";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useWorkspaceId } from "@multica/core/hooks";
import { agentListOptions, memberListOptions, workspaceKeys } from "@multica/core/workspace/queries";
import { PageHeader } from "../../layout/page-header";
import { CreateAgentDialog } from "./create-agent-dialog";
import { AgentListItem } from "./agent-list-item";
import { AgentDetail } from "./agent-detail";
@@ -140,28 +141,28 @@ export function AgentsPage() {
<ResizablePanel id="list" defaultSize={280} minSize={240} maxSize={400} groupResizeBehavior="preserve-pixel-size">
{/* Left column — agent list */}
<div className="overflow-y-auto h-full border-r">
<div className="flex h-12 items-center justify-between border-b px-4">
<PageHeader className="justify-between">
<h1 className="text-sm font-semibold">Agents</h1>
<div className="flex items-center gap-1">
{archivedCount > 0 && (
<Button
variant={showArchived ? "secondary" : "ghost"}
size="icon-xs"
size="icon-sm"
onClick={() => setShowArchived(!showArchived)}
title={showArchived ? "Show active agents" : "Show archived agents"}
>
<Archive className="h-4 w-4 text-muted-foreground" />
<Archive className="text-muted-foreground" />
</Button>
)}
<Button
variant="ghost"
size="icon-xs"
size="icon-sm"
onClick={() => setShowCreate(true)}
>
<Plus className="h-4 w-4 text-muted-foreground" />
<Plus className="text-muted-foreground" />
</Button>
</div>
</div>
</PageHeader>
{filteredAgents.length === 0 ? (
<div className="flex flex-col items-center justify-center px-4 py-12">
<Bot className="h-8 w-8 text-muted-foreground/40" />

View File

@@ -125,7 +125,7 @@ export function SkillsTab({
</div>
<Button
variant="ghost"
size="icon-xs"
size="icon-sm"
onClick={() => handleRemove(skill.id)}
disabled={saving}
className="text-muted-foreground hover:text-destructive"

View File

@@ -8,7 +8,6 @@ import userEvent from "@testing-library/user-event";
const mockSendCode = vi.hoisted(() => vi.fn());
const mockVerifyCode = vi.hoisted(() => vi.fn());
const mockHydrateWorkspace = vi.hoisted(() => vi.fn());
const mockApiListWorkspaces = vi.hoisted(() => vi.fn());
const mockApiVerifyCode = vi.hoisted(() => vi.fn());
const mockApiSetToken = vi.hoisted(() => vi.fn());
@@ -39,20 +38,6 @@ vi.mock("@multica/core/auth", () => ({
),
}));
vi.mock("@multica/core/workspace", () => ({
useWorkspaceStore: Object.assign(
(selector?: (s: unknown) => unknown) => {
const state = { hydrateWorkspace: mockHydrateWorkspace };
return selector ? selector(state) : state;
},
{
getState: () => ({
hydrateWorkspace: mockHydrateWorkspace,
}),
},
),
}));
vi.mock("@multica/core/api", () => ({
api: {
listWorkspaces: mockApiListWorkspaces,
@@ -224,11 +209,10 @@ describe("LoginPage", () => {
// Code verification
// -------------------------------------------------------------------------
it("calls verifyCode, listWorkspaces, hydrateWorkspace, then onSuccess", async () => {
it("calls verifyCode, seeds workspace list cache, then onSuccess", async () => {
mockSendCode.mockResolvedValueOnce(undefined);
mockVerifyCode.mockResolvedValueOnce(undefined);
mockApiListWorkspaces.mockResolvedValueOnce([{ id: "ws-1" }]);
mockHydrateWorkspace.mockReturnValueOnce({ id: "ws-1" });
render(<LoginPage onSuccess={onSuccess} />);
@@ -253,9 +237,11 @@ describe("LoginPage", () => {
"123456",
);
expect(mockApiListWorkspaces).toHaveBeenCalled();
expect(mockHydrateWorkspace).toHaveBeenCalledWith(
// The workspace list is seeded into React Query so onSuccess can read
// it synchronously to compute a destination URL.
expect(mockSetQueryData).toHaveBeenCalledWith(
expect.arrayContaining(["workspaces", "list"]),
[{ id: "ws-1" }],
undefined,
);
expect(onSuccess).toHaveBeenCalled();
});
@@ -620,7 +606,6 @@ describe("LoginPage", () => {
mockSendCode.mockResolvedValueOnce(undefined);
mockVerifyCode.mockResolvedValueOnce(undefined);
mockApiListWorkspaces.mockResolvedValueOnce([{ id: "ws-1" }]);
mockHydrateWorkspace.mockReturnValueOnce({ id: "ws-1" });
const onTokenObtained = vi.fn();
render(
@@ -674,43 +659,6 @@ describe("LoginPage", () => {
).toBeInTheDocument();
});
// -------------------------------------------------------------------------
// lastWorkspaceId
// -------------------------------------------------------------------------
it("passes lastWorkspaceId to hydrateWorkspace", async () => {
mockSendCode.mockResolvedValueOnce(undefined);
mockVerifyCode.mockResolvedValueOnce(undefined);
mockApiListWorkspaces.mockResolvedValueOnce([
{ id: "ws-1" },
{ id: "ws-2" },
]);
mockHydrateWorkspace.mockReturnValueOnce({ id: "ws-2" });
render(
<LoginPage onSuccess={onSuccess} lastWorkspaceId="ws-2" />,
);
const user = userEvent.setup();
await user.type(screen.getByLabelText(/email/i), "test@example.com");
await user.click(screen.getByRole("button", { name: /continue/i }));
await waitFor(() => {
expect(
screen.getByText(/check your email/i),
).toBeInTheDocument();
});
const otpInput = getOTPInput();
await user.type(otpInput, "123456");
await waitFor(() => {
expect(mockHydrateWorkspace).toHaveBeenCalledWith(
[{ id: "ws-1" }, { id: "ws-2" }],
"ws-2",
);
});
});
});
// ---------------------------------------------------------------------------

View File

@@ -19,7 +19,6 @@ import {
InputOTPSlot,
} from "@multica/ui/components/ui/input-otp";
import { useAuthStore } from "@multica/core/auth";
import { useWorkspaceStore } from "@multica/core/workspace";
import { workspaceKeys } from "@multica/core/workspace/queries";
import { api } from "@multica/core/api";
import type { User } from "@multica/core/types";
@@ -45,14 +44,13 @@ interface CliCallbackConfig {
interface LoginPageProps {
/** Logo element rendered above the title */
logo?: ReactNode;
/** Called after successful login + workspace hydration */
/** Called after successful login. The workspace list is seeded into React
* Query before this fires, so the caller can compute a destination URL. */
onSuccess: () => void;
/** Google OAuth config. Omit to disable Google login. */
google?: GoogleAuthConfig;
/** CLI callback config for authorizing CLI tools. */
cliCallback?: CliCallbackConfig;
/** Preferred workspace ID to restore after login. */
lastWorkspaceId?: string | null;
/** Called after a token is obtained (e.g. to set cookies). */
onTokenObtained?: () => void;
/** Override Google login handler (e.g. desktop opens browser externally). When provided, renders the Google button even if `google` config is omitted. */
@@ -98,7 +96,6 @@ export function LoginPage({
onSuccess,
google,
cliCallback,
lastWorkspaceId,
onTokenObtained,
onGoogleLogin,
}: LoginPageProps) {
@@ -200,11 +197,12 @@ export function LoginPage({
return;
}
// Normal path
// Normal path: seed the workspace list into the Query cache so the
// caller's onSuccess can read it synchronously to compute a destination
// URL (first workspace's slug, or onboarding).
await useAuthStore.getState().verifyCode(email, value);
const wsList = await api.listWorkspaces();
qc.setQueryData(workspaceKeys.list(), wsList);
useWorkspaceStore.getState().hydrateWorkspace(wsList, lastWorkspaceId);
onTokenObtained?.();
onSuccess();
} catch (err) {
@@ -215,7 +213,7 @@ export function LoginPage({
setLoading(false);
}
},
[email, onSuccess, cliCallback, lastWorkspaceId, onTokenObtained, qc],
[email, onSuccess, cliCallback, onTokenObtained, qc],
);
const handleResend = async () => {

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