Compare commits

..

1 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
200 changed files with 4715 additions and 12311 deletions

View File

@@ -133,7 +133,6 @@ make start-worktree # Start using .env.worktree
- Unless the user explicitly asks for backwards compatibility, do **not** add compatibility layers, fallback paths, dual-write logic, legacy adapters, or temporary shims.
- If a flow or API is being replaced and the product is not yet live, prefer removing the old path instead of preserving both old and new behavior.
- Avoid broad refactors unless required by the task.
- New global (pre-workspace) routes MUST use a single word (`/login`, `/inbox`) or a `/{noun}/{verb}` pair (`/workspaces/new`). NEVER add hyphenated word-group root routes (`/new-workspace`, `/create-team`) — they collide with common user workspace names and force endless reserved-slug audits. Reserving the noun (`workspaces`) automatically protects the entire `/workspaces/*` subtree.
### Package Boundary Rules
@@ -162,7 +161,7 @@ When the two apps need different behavior for the same concept (e.g., different
When adding a new page or feature:
1. **New page component** → add to `packages/views/<domain>/`. Never import from `next/*` or `react-router-dom`.
2. **Wire it in both apps** → add a route in `apps/web/app/` (Next.js page file) AND in the desktop router. **Exception**: pre-workspace transition flows (create workspace, accept invite) are NOT routes on desktop — they're `WindowOverlay` state. See *Desktop-specific Rules → Route categories*.
2. **Wire it in both apps** → add a route in `apps/web/app/` (Next.js page file) AND in the desktop router.
3. **Navigation** → use `useNavigation().push()` or `<AppLink>`. Never use framework-specific link/router APIs in shared code.
4. **Shared guards/providers** → use `DashboardGuard` from `packages/views/layout/`. Don't create separate guard logic per app.
5. **Platform-specific UI** → if a feature is web-only or desktop-only, keep it in the respective app. Use props slots (`extra`, `topSlot`) on shared layout components to inject platform-specific UI.
@@ -176,70 +175,6 @@ Both apps share the same CSS foundation from `packages/ui/styles/`.
- **Shared styles** → `packages/ui/styles/`. Never duplicate scrollbar styling, keyframes, or base layer rules in app CSS.
- **`@source` directives** → both apps scan shared packages so Tailwind sees all class names.
## Desktop-specific Rules
These rules apply to `apps/desktop/` only. Web has different constraints (URL bar, SSR, no tabs) and doesn't share these concerns. Every rule in this section was added after a concrete bug — treat them as enforced, not suggestions.
### Route categories
Every path in the desktop app falls into exactly one category. Choosing the wrong one reproduces bugs we've already fixed.
- **Session routes** — workspace-scoped pages (`/:slug/issues`, `/:slug/settings`). Rendered by the per-tab memory router under `WorkspaceRouteLayout`. These are legitimate tab destinations.
- **Transition flows** — pre-workspace / one-shot actions (create workspace, accept invite). **NOT routes.** They live as `WindowOverlay` state, dispatched when the navigation adapter sees `push('/workspaces/new')` or `push('/invite/<id>')`. The shared view (`NewWorkspacePage`, `InvitePage`) is the content; the overlay wrapper supplies platform chrome.
- **Error / stale states** — "workspace not available", tabs pointing at a revoked workspace. **NOT pages.** `WorkspaceRouteLayout` auto-heals by dropping the stale tab group from the store; the user never lands on an explicit error screen. Web keeps `NoAccessPage` (shareable URL makes the error state meaningful); desktop has no URL bar so stale = heal silently.
**Adding a new pre-workspace flow on desktop**: register a new `WindowOverlay` type in `stores/window-overlay-store.ts`. Do NOT add it to `routes.tsx`. If a shared view needs the flow on both platforms, add the route on web (`apps/web/app/(auth)/...`) AND the overlay type on desktop — the shared view component is identical.
### Workspace identity singleton
`setCurrentWorkspace(slug, uuid)` in `@multica/core/platform` is the single source of truth for "which workspace is active right now". Three consumers depend on it:
1. API client's `X-Workspace-Slug` header.
2. Zustand per-workspace storage namespace.
3. Chrome gating (`{slug && <AppSidebar />}` on desktop, similar on web).
Normally set by `WorkspaceRouteLayout` when its route mounts. Critically: **unmount does NOT clear it.** Any code that leaves workspace context (leave workspace, delete workspace, force navigation to overlay) must call `setCurrentWorkspace(null, null)` explicitly — otherwise the realtime `workspace:deleted` handler races the mutation, chrome gating stays truthy while the workspace is gone from cache, and `useWorkspaceId` throws.
### Workspace destructive operations
Leave / Delete workspace flows must follow this order:
1. Read destination from cached workspace list (no extra fetch).
2. `setCurrentWorkspace(null, null)`.
3. `navigation.push(destination)` — switch to next workspace or open new-workspace overlay.
4. THEN `await mutation.mutateAsync(workspaceId)`.
Reversing step 4 with steps 13 (mutate first, navigate after) causes a three-way race between the mutation's `onSettled` invalidate, the explicit `navigateAway`, and the realtime handler's `relocateAfterWorkspaceLoss` — all refetching the same `workspaces` query concurrently. One gets cancelled, bubbles as `CancelledError`, and triggers `window.location.assign` → full renderer reload / white screen.
### Tab isolation
Tabs are grouped per workspace in `stores/tab-store.ts`. The TabBar shows only the active workspace's tabs; cross-workspace tab leakage is impossible by construction (no flat global tabs array).
Cross-workspace `push(path)` is detected by the navigation adapter (`platform/navigation.tsx`) and translated into `switchWorkspace(slug, targetPath)` — NOT a navigation within the current tab's router. Don't bypass the adapter; always go through `useNavigation()` from shared code.
### Drag region (macOS window-move)
Every full-window desktop view (login, overlay, any page that covers the native title bar) needs a top drag strip so users can move the window. On macOS the traffic lights are hidden via `useImmersiveMode` in overlay-style contexts, so the drag strip also gives back that corner for pointer-drag.
**Pattern**: flex child at top, not absolute overlay.
```tsx
<div className="fixed inset-0 z-50 flex flex-col bg-background">
<div className="h-12 shrink-0" style={{ WebkitAppRegion: "drag" }} />
<div className="flex-1 overflow-auto" style={{ WebkitAppRegion: "no-drag" }}>
{/* page content — interactive elements need their own "no-drag" */}
</div>
</div>
```
Why flex, not absolute: the absolute-strip + `z-index` approach relies on stacking-context hit-testing, which isn't reliable for `-webkit-app-region`. A real flex row with no siblings at that pixel is unambiguous. Height matches `MainTopBar` (48px / `h-12`) for consistency.
Canonical examples: `components/window-overlay.tsx`, `pages/login.tsx`.
### UX vs platform chrome
UX affordances (Back button, Log out button, welcome copy, invite card) belong in `packages/views/` so web and desktop render identical content. Platform chrome (drag strip, `useImmersiveMode`, tab system interaction, traffic-light accommodation) lives in desktop-only code. Violating this split always produces platform divergence — if a button exists on desktop but not on web for the same flow, it's a signal the UX escaped into platform code.
## UI/UX Rules
- Prefer shadcn components over custom implementations. Install via `pnpm ui:add <component>` from project root — adds to `packages/ui/components/ui/`. All components use Base UI primitives (`@base-ui/react`), not Radix.

View File

@@ -143,9 +143,6 @@ The daemon auto-detects these AI CLIs on your PATH:
| OpenCode | `opencode` | Open-source coding agent |
| OpenClaw | `openclaw` | Open-source coding agent |
| Hermes | `hermes` | Nous Research coding agent |
| Gemini | `gemini` | Google's coding agent |
| [Pi](https://pi.dev/) | `pi` | Pi coding agent |
| [Cursor Agent](https://cursor.com/) | `cursor-agent` | Cursor's headless coding agent |
You need at least one installed. The daemon registers each detected CLI as an available runtime.
@@ -186,12 +183,6 @@ Agent-specific overrides:
| `MULTICA_OPENCLAW_MODEL` | Override the OpenClaw model used |
| `MULTICA_HERMES_PATH` | Custom path to the `hermes` binary |
| `MULTICA_HERMES_MODEL` | Override the Hermes model used |
| `MULTICA_GEMINI_PATH` | Custom path to the `gemini` binary |
| `MULTICA_GEMINI_MODEL` | Override the Gemini model used |
| `MULTICA_PI_PATH` | Custom path to the `pi` binary |
| `MULTICA_PI_MODEL` | Override the Pi model used |
| `MULTICA_CURSOR_PATH` | Custom path to the `cursor-agent` binary |
| `MULTICA_CURSOR_MODEL` | Override the Cursor Agent model used |
### Self-Hosted Server
@@ -278,7 +269,7 @@ multica issue list --priority urgent --assignee "Agent Name"
multica issue list --limit 20 --output json
```
Available filters: `--status`, `--priority`, `--assignee`, `--project`, `--limit`.
Available filters: `--status`, `--priority`, `--assignee`, `--limit`.
### Get Issue
@@ -293,7 +284,7 @@ multica issue get <id> --output json
multica issue create --title "Fix login bug" --description "..." --priority high --assignee "Lambda"
```
Flags: `--title` (required), `--description`, `--status`, `--priority`, `--assignee`, `--parent`, `--project`, `--due-date`.
Flags: `--title` (required), `--description`, `--status`, `--priority`, `--assignee`, `--parent`, `--due-date`.
### Update Issue
@@ -349,70 +340,6 @@ multica issue run-messages <task-id> --since 42 --output json
The `runs` command shows all past and current executions for an issue, including running tasks. The `run-messages` command shows the detailed message log (tool calls, thinking, text, errors) for a single run. Use `--since` for efficient polling of in-progress runs.
## Projects
Projects group related issues (e.g. a sprint, an epic, a workstream). Every project
belongs to a workspace and can optionally have a lead (member or agent).
### List Projects
```bash
multica project list
multica project list --status in_progress
multica project list --output json
```
Available filters: `--status`.
### Get Project
```bash
multica project get <id>
multica project get <id> --output json
```
### Create Project
```bash
multica project create --title "2026 Week 16 Sprint" --icon "🏃" --lead "Lambda"
```
Flags: `--title` (required), `--description`, `--status`, `--icon`, `--lead`.
### Update Project
```bash
multica project update <id> --title "New title" --status in_progress
multica project update <id> --lead "Lambda"
```
Flags: `--title`, `--description`, `--status`, `--icon`, `--lead`.
### Change Status
```bash
multica project status <id> in_progress
```
Valid statuses: `planned`, `in_progress`, `paused`, `completed`, `cancelled`.
### Delete Project
```bash
multica project delete <id>
```
### Associating Issues with Projects
Use the `--project` flag on `issue create` / `issue update` to attach an issue to a
project, or on `issue list` to filter issues by project:
```bash
multica issue create --title "Login bug" --project <project-id>
multica issue update <issue-id> --project <project-id>
multica issue list --project <project-id>
```
## Setup
```bash
@@ -449,62 +376,6 @@ multica config set app_url https://app.example.com
multica config set workspace_id <workspace-id>
```
## Autopilot Commands
Autopilots are scheduled/triggered automations that dispatch agent tasks (either by creating an issue or by running an agent directly).
### List Autopilots
```bash
multica autopilot list
multica autopilot list --status active --output json
```
### Get Autopilot Details
```bash
multica autopilot get <id>
multica autopilot get <id> --output json # includes triggers
```
### Create / Update / Delete
```bash
multica autopilot create \
--title "Nightly bug triage" \
--description "Scan todo issues and prioritize." \
--agent "Lambda" \
--mode create_issue
multica autopilot update <id> --status paused
multica autopilot update <id> --description "New prompt"
multica autopilot delete <id>
```
`--mode` currently only accepts `create_issue` (creates a new issue on each run and assigns it to the agent). The server data model also defines `run_only`, but the daemon task path doesn't yet resolve a workspace for runs without an issue, so it's not exposed by the CLI. `--agent` accepts either a name or UUID.
### Manual Trigger
```bash
multica autopilot trigger <id> # Fires the autopilot once, returns the run
```
### Run History
```bash
multica autopilot runs <id>
multica autopilot runs <id> --limit 50 --output json
```
### Triggers (Schedule / Webhook / API)
```bash
multica autopilot trigger-add <autopilot-id> --kind schedule --cron "0 9 * * 1-5" --timezone "America/New_York"
multica autopilot trigger-add <autopilot-id> --kind webhook
multica autopilot trigger-update <autopilot-id> <trigger-id> --enabled=false
multica autopilot trigger-delete <autopilot-id> <trigger-id>
```
## Other Commands
```bash

View File

@@ -165,12 +165,12 @@ Wait 3 seconds, then verify:
multica daemon status
```
Expected output should show `running` status with detected agents (e.g. `claude`, `codex`, `opencode`, `openclaw`, `hermes`, `gemini`, `pi`, `cursor-agent`).
Expected output should show `running` status with detected agents (e.g. `claude`, `codex`, `opencode`, `openclaw`, `hermes`).
**If daemon fails to start:**
- Check logs: `multica daemon logs`
- If a port conflict occurs, the daemon may already be running under a different profile.
- If no agents are detected, ensure at least one AI CLI (`claude`, `codex`, `opencode`, `openclaw`, `hermes`, `gemini`, `pi`, or `cursor-agent`) is installed and on the `$PATH`.
- If no agents are detected, ensure at least one AI CLI (`claude`, `codex`, `opencode`, `openclaw`, or `hermes`) is installed and on the `$PATH`.
---
@@ -184,12 +184,12 @@ multica daemon status
Confirm:
1. Status is `running`
2. At least one agent is listed (e.g. `claude`, `codex`, `opencode`, `openclaw`, `hermes`, `gemini`, `pi`, or `cursor-agent`)
2. At least one agent is listed (e.g. `claude`, `codex`, `opencode`, `openclaw`, or `hermes`)
3. At least one workspace is being watched
If the agents list is empty, tell the user:
> "The Multica daemon is running but no AI agent CLIs were detected. Please install at least one supported CLI (`claude`, `codex`, `opencode`, `openclaw`, `hermes`, `gemini`, `pi`, or `cursor-agent`), then restart the daemon with `multica daemon stop && multica daemon start`."
> "The Multica daemon is running but no AI agent CLIs were detected. Please install at least one supported CLI (`claude`, `codex`, `opencode`, `openclaw`, or `hermes`), then restart the daemon with `multica daemon stop && multica daemon start`."
---

View File

@@ -182,7 +182,7 @@ server:
cd server && go run ./cmd/server
daemon:
@$(MAKE) multica MULTICA_ARGS="daemon restart --profile local"
@$(MAKE) multica MULTICA_ARGS="daemon"
cli:
@$(MAKE) multica MULTICA_ARGS="$(MULTICA_ARGS)"

View File

@@ -30,7 +30,7 @@ Turn coding agents into real teammates — assign tasks, track progress, compoun
Multica turns coding agents into real teammates. Assign issues to an agent like you'd assign to a colleague — they'll pick up the work, write code, report blockers, and update statuses autonomously.
No more copy-pasting prompts. No more babysitting runs. Your agents show up on the board, participate in conversations, and compound reusable skills over time. Think of it as open-source infrastructure for managed agents — vendor-neutral, self-hosted, and designed for human + AI teams. Works with **Claude Code**, **Codex**, **OpenClaw**, **OpenCode**, **Hermes**, **Gemini**, **Pi**, and **Cursor Agent**.
No more copy-pasting prompts. No more babysitting runs. Your agents show up on the board, participate in conversations, and compound reusable skills over time. Think of it as open-source infrastructure for managed agents — vendor-neutral, self-hosted, and designed for human + AI teams. Works with **Claude Code**, **Codex**, **OpenClaw**, and **OpenCode**.
<p align="center">
<img src="docs/assets/hero-screenshot.png" alt="Multica board view" width="800">
@@ -97,7 +97,7 @@ multica setup # Connect to Multica Cloud, log in, start daemon
multica setup # Configure, authenticate, and start the daemon
```
The daemon runs in the background and auto-detects agent CLIs (`claude`, `codex`, `openclaw`, `opencode`, `hermes`, `gemini`, `pi`, `cursor-agent`) on your PATH.
The daemon runs in the background and auto-detects agent CLIs (`claude`, `codex`, `openclaw`, `opencode`) on your PATH.
### 2. Verify your runtime
@@ -107,7 +107,7 @@ Open your workspace in the Multica web app. Navigate to **Settings → Runtimes*
### 3. Create an agent
Go to **Settings → Agents** and click **New Agent**. Pick the runtime you just connected and choose a provider (Claude Code, Codex, OpenClaw, OpenCode, Hermes, Gemini, Pi, or Cursor Agent). Give your agent a name — this is how it will appear on the board, in comments, and in assignments.
Go to **Settings → Agents** and click **New Agent**. Pick the runtime you just connected and choose a provider (Claude Code, Codex, OpenClaw, or OpenCode). Give your agent a name — this is how it will appear on the board, in comments, and in assignments.
### 4. Assign your first task
@@ -158,10 +158,10 @@ See the [CLI and Daemon Guide](CLI_AND_DAEMON.md) for the full command reference
└──────────────┘ └──────┬───────┘ └──────────────────┘
┌──────┴───────┐
│ Agent Daemon │ runs on your machine
└──────────────┘ (Claude Code, Codex, OpenCode,
OpenClaw, Hermes, Gemini,
Pi, Cursor Agent)
│ Agent Daemon │ (runs on your machine)
│Claude/Codex/ │
│OpenClaw/Code │
└──────────────┘
```
| Layer | Stack |
@@ -169,7 +169,7 @@ See the [CLI and Daemon Guide](CLI_AND_DAEMON.md) for the full command reference
| Frontend | Next.js 16 (App Router) |
| Backend | Go (Chi router, sqlc, gorilla/websocket) |
| Database | PostgreSQL 17 with pgvector |
| Agent Runtime | Local daemon executing Claude Code, Codex, OpenClaw, OpenCode, Hermes, Gemini, Pi, or Cursor Agent |
| Agent Runtime | Local daemon executing Claude Code, Codex, OpenClaw, or OpenCode |
## Development

View File

@@ -30,7 +30,7 @@
Multica 将编码 Agent 变成真正的队友。像分配给同事一样分配给 Agent——它们会自主接手工作、编写代码、报告阻塞问题、更新状态。
不再需要复制粘贴 prompt不再需要盯着运行过程。你的 Agent 出现在看板上、参与对话、随着时间积累可复用的技能。可以理解为开源的 Managed Agents 基础设施——厂商中立、可自部署、专为人类 + AI 团队设计。支持 **Claude Code**、**Codex**、**OpenClaw****OpenCode**、**Hermes**、**Gemini**、**Pi** 和 **Cursor Agent**
不再需要复制粘贴 prompt不再需要盯着运行过程。你的 Agent 出现在看板上、参与对话、随着时间积累可复用的技能。可以理解为开源的 Managed Agents 基础设施——厂商中立、可自部署、专为人类 + AI 团队设计。支持 **Claude Code**、**Codex**、**OpenClaw****OpenCode**
<p align="center">
<img src="docs/assets/hero-screenshot.png" alt="Multica 看板视图" width="800">
@@ -99,7 +99,7 @@ multica setup # 连接 Multica Cloud登录启动 daemon
multica setup # 配置、认证、启动 daemon一条命令搞定
```
daemon 在后台运行,保持你的机器与 Multica 的连接。它会自动检测 PATH 中可用的 Agent CLI`claude``codex``openclaw``opencode``hermes``gemini``pi``cursor-agent`)。
daemon 在后台运行,保持你的机器与 Multica 的连接。它会自动检测 PATH 中可用的 Agent CLI`claude``codex``openclaw``opencode`)。
### 2. 确认运行时已连接
@@ -109,7 +109,7 @@ daemon 在后台运行,保持你的机器与 Multica 的连接。它会自动
### 3. 创建 Agent
进入 **设置 → Agents**,点击 **新建 Agent**。选择你刚连接的 Runtime选择 ProviderClaude Code、Codex、OpenClawOpenCode、Hermes、Gemini、Pi 或 Cursor Agent),并为 Agent 起个名字——它将以这个名字出现在看板、评论和任务分配中。
进入 **设置 → Agents**,点击 **新建 Agent**。选择你刚连接的 Runtime选择 ProviderClaude Code、Codex、OpenClawOpenCode并为 Agent 起个名字——它将以这个名字出现在看板、评论和任务分配中。
### 4. 分配你的第一个任务
@@ -141,10 +141,10 @@ daemon 在后台运行,保持你的机器与 Multica 的连接。它会自动
└──────────────┘ └──────┬───────┘ └──────────────────┘
┌──────┴───────┐
│ Agent Daemon │ 运行在你的机器上
└──────────────┘ Claude Code、Codex、OpenCode、
OpenClaw、Hermes、Gemini、
Pi、Cursor Agent
│ Agent Daemon │ 运行在你的机器上
│Claude/Codex/ │
│OpenClaw/Code │
└──────────────┘
```
| 层级 | 技术栈 |
@@ -152,7 +152,7 @@ daemon 在后台运行,保持你的机器与 Multica 的连接。它会自动
| 前端 | Next.js 16 (App Router) |
| 后端 | Go (Chi router, sqlc, gorilla/websocket) |
| 数据库 | PostgreSQL 17 with pgvector |
| Agent 运行时 | 本地 daemon 执行 Claude Code、Codex、OpenClawOpenCode、Hermes、Gemini、Pi 或 Cursor Agent |
| Agent 运行时 | 本地 daemon 执行 Claude Code、Codex、OpenClawOpenCode |
## 开发

View File

@@ -85,9 +85,6 @@ You also need at least one AI agent CLI installed:
- [OpenClaw](https://github.com/openclaw/openclaw) (`openclaw` on PATH)
- [OpenCode](https://github.com/anomalyco/opencode) (`opencode` on PATH)
- [Hermes](https://github.com/NousResearch/hermes) (`hermes` on PATH)
- Gemini (`gemini` on PATH)
- [Pi](https://pi.dev/) (`pi` on PATH)
- [Cursor Agent](https://cursor.com/) (`cursor-agent` on PATH)
### b) One-command setup

View File

@@ -80,12 +80,6 @@ Agent-specific overrides:
| `MULTICA_OPENCLAW_MODEL` | Override the OpenClaw model used |
| `MULTICA_HERMES_PATH` | Custom path to the `hermes` binary |
| `MULTICA_HERMES_MODEL` | Override the Hermes model used |
| `MULTICA_GEMINI_PATH` | Custom path to the `gemini` binary |
| `MULTICA_GEMINI_MODEL` | Override the Gemini model used |
| `MULTICA_PI_PATH` | Custom path to the `pi` binary |
| `MULTICA_PI_MODEL` | Override the Pi model used |
| `MULTICA_CURSOR_PATH` | Custom path to the `cursor-agent` binary |
| `MULTICA_CURSOR_MODEL` | Override the Cursor Agent model used |
## Database Setup

View File

@@ -42,10 +42,4 @@ publish:
provider: github
owner: multica-ai
repo: multica
# Align with our CLI release flow which pre-creates a *published* GitHub
# Release via `gh release create`. The electron-builder default of
# `publishingType: draft` conflicts with `existingType=release` and causes
# uploads of the DMG/ZIP/blockmaps/latest-mac.yml to be silently skipped,
# which breaks electron-updater auto-update on installed clients.
publishingType: release
npmRebuild: false

View File

@@ -12,10 +12,7 @@ export default defineConfig({
},
renderer: {
server: {
// Allow parallel worktrees to run `pnpm dev:desktop` side-by-side
// (e.g. Multica Canary alongside a primary checkout) by overriding
// the renderer port via env. Falls back to 5173 for the common case.
port: Number(process.env.DESKTOP_RENDERER_PORT) || 5173,
port: 5173,
strictPort: true,
},
plugins: [react(), tailwindcss()],

View File

@@ -10,28 +10,4 @@ export default [
globals: { ...globals.node },
},
},
// Security: every renderer-controlled URL that reaches the OS shell must
// flow through openExternalSafely in src/main/external-url.ts (scheme
// allowlist). Enforce it statically so a direct shell.openExternal call
// cannot silently regress the protection.
{
files: ["src/main/**/*.ts"],
rules: {
"no-restricted-syntax": [
"error",
{
selector:
"CallExpression[callee.object.name='shell'][callee.property.name='openExternal']",
message:
"Do not call shell.openExternal directly. Use openExternalSafely from './external-url' so the http/https allowlist stays enforced.",
},
],
},
},
{
files: ["src/main/external-url.ts"],
rules: {
"no-restricted-syntax": "off",
},
},
];

View File

@@ -5,8 +5,7 @@
"main": "./out/main/index.js",
"scripts": {
"bundle-cli": "node scripts/bundle-cli.mjs",
"brand-dev-electron": "node scripts/brand-dev-electron.mjs",
"dev": "pnpm run bundle-cli && pnpm run brand-dev-electron && electron-vite dev",
"dev": "pnpm run bundle-cli && electron-vite dev",
"build": "pnpm run bundle-cli && electron-vite build",
"typecheck:node": "tsc --noEmit -p tsconfig.node.json --composite false",
"typecheck:web": "tsc --noEmit -p tsconfig.web.json --composite false",

View File

@@ -1,73 +0,0 @@
#!/usr/bin/env node
// Rebrand the bundled Electron.app's Info.plist so `pnpm dev:desktop`
// shows "Multica Canary" in the menu bar, Cmd+Tab switcher, and
// Activity Monitor. On macOS these titles come from CFBundleName at
// launch time — `app.setName()` cannot override them at runtime, so
// patching the plist in node_modules is the only working fix.
//
// Idempotent: runs on every dev launch and no-ops once the plist already
// matches. The patch is isolated to this worktree's node_modules — we
// unlink the file before rewriting so we never mutate a pnpm-store inode
// shared with another project.
import { createRequire } from "node:module";
import { execFileSync } from "node:child_process";
import { readFileSync, unlinkSync, writeFileSync } from "node:fs";
import { resolve } from "node:path";
if (process.platform !== "darwin") process.exit(0);
const DESIRED_NAME = "Multica Canary";
const require = createRequire(import.meta.url);
// `require('electron')` returns the path to the executable
// (.../Electron.app/Contents/MacOS/Electron). Walk up to Contents/Info.plist.
const electronBin = require("electron");
const plistPath = resolve(electronBin, "../../Info.plist");
function plistGet(key) {
try {
return execFileSync(
"/usr/libexec/PlistBuddy",
["-c", `Print :${key}`, plistPath],
{ encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] },
).trim();
} catch {
return "";
}
}
function plistSet(key, value) {
try {
execFileSync("/usr/libexec/PlistBuddy", [
"-c",
`Set :${key} ${value}`,
plistPath,
]);
} catch {
execFileSync("/usr/libexec/PlistBuddy", [
"-c",
`Add :${key} string ${value}`,
plistPath,
]);
}
}
if (
plistGet("CFBundleName") === DESIRED_NAME &&
plistGet("CFBundleDisplayName") === DESIRED_NAME
) {
process.exit(0);
}
// Break any pnpm hardlink to the global store: read, unlink, rewrite.
// PlistBuddy would otherwise write through the hardlink and mutate the
// shared store file (and every other project's Electron.app with it).
const original = readFileSync(plistPath);
unlinkSync(plistPath);
writeFileSync(plistPath, original);
plistSet("CFBundleName", DESIRED_NAME);
plistSet("CFBundleDisplayName", DESIRED_NAME);
console.log(`[brand-dev-electron] ${plistPath} → CFBundleName="${DESIRED_NAME}"`);

View File

@@ -39,18 +39,6 @@ function sh(cmd) {
}
}
/**
* Strip the leading `--` that npm/pnpm insert to separate their own
* flags from the ones meant for the underlying script. Without this,
* `pnpm package -- --mac --arm64 --publish always` forwards the bare
* `--` into electron-builder's argv, which terminates option parsing
* and turns `--publish always` into ignored positional arguments.
*/
export function stripLeadingSeparator(argv) {
if (argv.length > 0 && argv[0] === "--") return argv.slice(1);
return argv;
}
/**
* Pure transformation from the `git describe --tags --always --dirty`
* output to the value we feed into electron-builder's extraMetadata.version.
@@ -114,7 +102,7 @@ function main() {
}
// Step 4: assemble electron-builder args.
const passthrough = stripLeadingSeparator(process.argv.slice(2));
const passthrough = process.argv.slice(2);
const builderArgs = [];
if (version) builderArgs.push(`-c.extraMetadata.version=${version}`);

View File

@@ -1,5 +1,5 @@
import { describe, it, expect } from "vitest";
import { normalizeGitVersion, stripLeadingSeparator } from "./package.mjs";
import { normalizeGitVersion } from "./package.mjs";
describe("normalizeGitVersion", () => {
it("returns null for empty / nullish input", () => {
@@ -37,25 +37,3 @@ describe("normalizeGitVersion", () => {
expect(normalizeGitVersion("abc1234")).toBe("0.0.0-abc1234");
});
});
describe("stripLeadingSeparator", () => {
it("removes the leading -- inserted by npm/pnpm", () => {
expect(stripLeadingSeparator(["--", "--mac", "--arm64", "--publish", "always"])).toEqual([
"--mac", "--arm64", "--publish", "always",
]);
});
it("leaves args untouched when there is no leading --", () => {
expect(stripLeadingSeparator(["--mac", "--arm64"])).toEqual(["--mac", "--arm64"]);
});
it("does not strip a -- that appears mid-argv", () => {
expect(stripLeadingSeparator(["--mac", "--", "--arm64"])).toEqual([
"--mac", "--", "--arm64",
]);
});
it("handles an empty array", () => {
expect(stripLeadingSeparator([])).toEqual([]);
});
});

View File

@@ -1,73 +0,0 @@
import { describe, expect, it, vi, beforeEach } from "vitest";
vi.mock("electron", () => ({
shell: { openExternal: vi.fn().mockResolvedValue(undefined) },
}));
import { shell } from "electron";
import { isSafeExternalHttpUrl, openExternalSafely } from "./external-url";
describe("isSafeExternalHttpUrl", () => {
it("allows http and https URLs", () => {
expect(isSafeExternalHttpUrl("https://multica.ai")).toBe(true);
expect(isSafeExternalHttpUrl("http://localhost:3000/auth")).toBe(true);
});
it("allows https URLs with embedded credentials", () => {
// WHATWG URL parses these as https; OS-level handling is the shell's concern.
expect(isSafeExternalHttpUrl("https://user:pass@example.com")).toBe(true);
});
it("normalizes scheme casing so uppercase variants can't bypass", () => {
expect(isSafeExternalHttpUrl("HTTPS://example.com")).toBe(true);
expect(isSafeExternalHttpUrl("FILE:///etc/passwd")).toBe(false);
});
it("rejects dangerous pseudo-schemes", () => {
expect(isSafeExternalHttpUrl("javascript:alert(1)")).toBe(false);
expect(
isSafeExternalHttpUrl("data:text/html,<script>alert(1)</script>"),
).toBe(false);
});
it("rejects filesystem and network transport schemes", () => {
expect(isSafeExternalHttpUrl("file:///etc/passwd")).toBe(false);
expect(isSafeExternalHttpUrl("ftp://example.com/x")).toBe(false);
expect(isSafeExternalHttpUrl("smb://share/x")).toBe(false);
});
it("rejects local-handler schemes used in past RCE chains", () => {
expect(isSafeExternalHttpUrl("vscode://file/test")).toBe(false);
expect(isSafeExternalHttpUrl("ms-msdt:/id%20PCWDiagnostic")).toBe(false);
});
it("rejects mailto and other non-web schemes", () => {
expect(isSafeExternalHttpUrl("mailto:test@example.com")).toBe(false);
expect(isSafeExternalHttpUrl("tel:+15551234567")).toBe(false);
});
it("rejects empty, whitespace, and malformed input", () => {
expect(isSafeExternalHttpUrl("")).toBe(false);
expect(isSafeExternalHttpUrl(" ")).toBe(false);
expect(isSafeExternalHttpUrl("not a url")).toBe(false);
expect(isSafeExternalHttpUrl("http://")).toBe(false);
});
});
describe("openExternalSafely", () => {
beforeEach(() => {
vi.mocked(shell.openExternal).mockClear();
});
it("forwards http/https URLs to shell.openExternal", () => {
openExternalSafely("https://multica.ai");
expect(shell.openExternal).toHaveBeenCalledWith("https://multica.ai");
});
it("does not call shell.openExternal for rejected schemes", () => {
openExternalSafely("file:///etc/passwd");
openExternalSafely("javascript:alert(1)");
openExternalSafely("not a url");
expect(shell.openExternal).not.toHaveBeenCalled();
});
});

View File

@@ -1,38 +0,0 @@
import { shell } from "electron";
// True when the URL parses and uses http/https — the only schemes we let
// reach `shell.openExternal`. Scheme comparison is safe because the WHATWG
// URL parser lowercases the protocol field.
export function isSafeExternalHttpUrl(url: string): boolean {
return getHttpProtocol(url) !== null;
}
// Canonical wrapper around shell.openExternal. All renderer-controlled URLs
// that eventually reach the OS shell MUST flow through here; direct calls
// to `shell.openExternal` elsewhere in the main process are banned by the
// no-restricted-syntax rule in apps/desktop/eslint.config.mjs.
export function openExternalSafely(url: string): Promise<void> | void {
if (getHttpProtocol(url) === null) {
console.warn(`[security] blocked openExternal: ${describeScheme(url)}`);
return;
}
return shell.openExternal(url);
}
function getHttpProtocol(url: string): "http:" | "https:" | null {
try {
const { protocol } = new URL(url);
if (protocol === "http:" || protocol === "https:") return protocol;
return null;
} catch {
return null;
}
}
function describeScheme(url: string): string {
try {
return `scheme=${new URL(url).protocol}`;
} catch {
return "invalid URL";
}
}

View File

@@ -1,16 +1,10 @@
import { app, BrowserWindow, ipcMain, nativeImage } from "electron";
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";
import { openExternalSafely } from "./external-url";
// Bundled icon used for dev-mode dock/taskbar branding. In production the
// app bundle icon (from electron-builder) wins; this path is only consumed
// by the `is.dev` branch below.
const DEV_ICON_PATH = join(__dirname, "../../resources/icon.png");
// macOS/Linux GUI launches inherit a minimal PATH from launchd that omits
// the user's shell config (~/.zshrc, Homebrew, nvm, ~/.local/bin, etc.).
@@ -49,19 +43,6 @@ function handleDeepLink(url: string): void {
if (token && mainWindow) {
mainWindow.webContents.send("auth:token", token);
}
return;
}
// multica://invite/<invitationId>
// Dispatched from the web invite page when the user chooses "Open in
// desktop app". The renderer opens the invite overlay — no tab, no
// route persistence, so deep-linking the same invite twice stays safe.
if (parsed.hostname === "invite") {
const id = parsed.pathname.replace(/^\//, "");
if (id && mainWindow) {
mainWindow.webContents.send("invite:open", decodeURIComponent(id));
}
return;
}
} catch {
// Ignore malformed URLs
@@ -80,9 +61,6 @@ function createWindow(): void {
trafficLightPosition: { x: 16, y: 13 },
show: false,
autoHideMenuBar: true,
// Windows/Linux pick up the window/taskbar icon from this option in
// dev — on macOS it's ignored (dock comes from app.dock.setIcon below).
...(is.dev ? { icon: DEV_ICON_PATH } : {}),
webPreferences: {
preload: join(__dirname, "../preload/index.js"),
sandbox: false,
@@ -105,7 +83,7 @@ function createWindow(): void {
});
mainWindow.webContents.setWindowOpenHandler((details) => {
openExternalSafely(details.url);
shell.openExternal(details.url);
return { action: "deny" };
});
@@ -116,27 +94,6 @@ function createWindow(): void {
}
}
// --- Dev / production isolation -------------------------------------------
// Give dev mode a separate app name and userData path so it gets its own
// single-instance lock file and doesn't conflict with the packaged production
// app. Must run BEFORE requestSingleInstanceLock() because the lock location
// is derived from the userData path. (Same approach VS Code uses for
// Stable / Insiders coexistence.)
// DESKTOP_APP_SUFFIX lets parallel worktrees run dev Electron side-by-side
// without fighting for the shared single-instance lock. The suffix is
// appended to the app name + userData path, so each worktree gets its own
// lock file. Default (no env var) keeps behavior unchanged — the common
// single-worktree case still lands at "Multica Canary".
const DEV_APP_NAME = process.env.DESKTOP_APP_SUFFIX
? `Multica Canary ${process.env.DESKTOP_APP_SUFFIX}`
: "Multica Canary";
if (is.dev) {
app.setName(DEV_APP_NAME);
app.setPath("userData", join(app.getPath("appData"), DEV_APP_NAME));
}
// --- Protocol registration -----------------------------------------------
if (process.defaultApp) {
@@ -168,33 +125,19 @@ if (!gotTheLock) {
});
app.whenReady().then(() => {
electronApp.setAppUserModelId(
is.dev ? "ai.multica.desktop.dev" : "ai.multica.desktop",
);
// macOS: replace the default Electron dock icon with the bundled logo
// so the Canary dev build is visually distinct from a stock Electron
// run. `app.dock` is macOS-only — guard the call.
if (is.dev && process.platform === "darwin" && app.dock) {
const icon = nativeImage.createFromPath(DEV_ICON_PATH);
if (!icon.isEmpty()) app.dock.setIcon(icon);
}
electronApp.setAppUserModelId("ai.multica.desktop");
app.on("browser-window-created", (_, window) => {
optimizer.watchWindowShortcuts(window);
});
// IPC: open URL in default browser (used by renderer for Google login).
// All scheme-allowlist enforcement lives in openExternalSafely — this
// is the single audit point for renderer-controlled URLs reaching the
// OS shell under the app's intentional webSecurity: false + sandbox:
// false configuration.
// IPC: open URL in default browser (used by renderer for Google login)
ipcMain.handle("shell:openExternal", (_event, url: string) => {
return openExternalSafely(url);
return shell.openExternal(url);
});
// IPC: toggle immersive mode — hides the macOS traffic lights so full-screen
// modals (e.g. create-workspace) can place UI in the top-left corner
// modals (create-workspace, onboarding) can place UI in the top-left corner
// without fighting the native window controls' hit-test.
ipcMain.handle("window:setImmersive", (_event, immersive: boolean) => {
if (process.platform !== "darwin") return;

View File

@@ -3,8 +3,6 @@ import { ElectronAPI } from "@electron-toolkit/preload";
interface DesktopAPI {
/** Listen for auth token delivered via deep link. Returns an unsubscribe function. */
onAuthToken: (callback: (token: string) => void) => () => void;
/** Listen for invitation IDs delivered via deep link. Returns an unsubscribe function. */
onInviteOpen: (callback: (invitationId: string) => void) => () => void;
/** Open a URL in the default browser. */
openExternal: (url: string) => Promise<void>;
/** Hide macOS traffic lights for full-screen modals; restore when false. */

View File

@@ -11,15 +11,6 @@ const desktopAPI = {
ipcRenderer.removeListener("auth:token", handler);
};
},
/** Listen for invitation IDs delivered via deep link */
onInviteOpen: (callback: (invitationId: string) => void) => {
const handler = (_event: Electron.IpcRendererEvent, invitationId: string) =>
callback(invitationId);
ipcRenderer.on("invite:open", handler);
return () => {
ipcRenderer.removeListener("invite:open", handler);
};
},
/** Open a URL in the default browser */
openExternal: (url: string) => ipcRenderer.invoke("shell:openExternal", url),
/** Toggle immersive mode — hide macOS traffic lights for full-screen modals */

View File

@@ -1,8 +1,8 @@
import { useEffect, useLayoutEffect, useRef, useState } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useEffect, useState } from "react";
import { useQueryClient } from "@tanstack/react-query";
import { CoreProvider } from "@multica/core/platform";
import { useAuthStore } from "@multica/core/auth";
import { workspaceKeys, workspaceListOptions } from "@multica/core/workspace/queries";
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";
@@ -10,9 +10,6 @@ import { Toaster } from "sonner";
import { DesktopLoginPage } from "./pages/login";
import { DesktopShell } from "./components/desktop-layout";
import { UpdateNotification } from "./components/update-notification";
import { useTabStore } from "./stores/tab-store";
import { useWindowOverlayStore } from "./stores/window-overlay-store";
function AppContent() {
const user = useAuthStore((s) => s.user);
@@ -23,8 +20,8 @@ function AppContent() {
// 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 IndexRedirect gets a definitive workspace state on
// first render.
// 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
@@ -33,17 +30,6 @@ function AppContent() {
window.daemonAPI.setTargetApiUrl(DAEMON_TARGET_API_URL);
}, []);
// Listen for invite IDs delivered via deep link (multica://invite/<id>).
// We open the overlay regardless of login state — if the user isn't logged
// in, InvitePage's queries will fail and render the "not found" state,
// which is acceptable; the expected pre-flight happens in the web app
// (login + next=/invite/... dance) before the deep link is ever dispatched.
useEffect(() => {
return window.desktopAPI.onInviteOpen((invitationId) => {
useWindowOverlayStore.getState().open({ type: "invite", invitationId });
});
}, []);
// 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).
@@ -83,73 +69,6 @@ function AppContent() {
})();
}, [user]);
// When a user who started the session with zero workspaces creates their
// first one, restart the daemon so it picks up the new workspace
// immediately (otherwise workspaceSyncLoop's next 30s tick would be the
// earliest pickup point). Specifically scoped to "started empty" because
// account switches (user A logout → user B login) should not trigger a
// daemon restart here — daemon-manager already restarts on user change
// via syncToken.
const { data: workspaces, isFetched: workspaceListFetched } = useQuery({
...workspaceListOptions(),
enabled: !!user,
});
const wsCount = workspaces?.length ?? 0;
// Validate persisted tab state against the current user's workspace list,
// and pick an active workspace if none is set. Runs in useLayoutEffect
// (synchronously after render, before paint) rather than the render
// phase — the original render-phase pattern triggered React's
// "Cannot update a component while rendering a different component"
// warning because `switchWorkspace` is a Zustand setState that the
// TabBar is subscribed to. useLayoutEffect flushes both renders before
// the user sees anything, so there's no visible flicker.
useLayoutEffect(() => {
if (!workspaces) return;
const validSlugs = new Set(workspaces.map((w) => w.slug));
const tabStore = useTabStore.getState();
tabStore.validateWorkspaceSlugs(validSlugs);
if (!tabStore.activeWorkspaceSlug && workspaces.length > 0) {
tabStore.switchWorkspace(workspaces[0].slug);
}
}, [workspaces]);
// Bidirectional new-workspace overlay: visible when there are no
// workspaces to enter, hidden as soon as one exists. Gated on
// `workspaceListFetched` so the initial render doesn't flash the
// overlay before the list arrives. The overlay's own `invite` type is
// not touched here — that's an in-flight task owned by the user.
useEffect(() => {
if (!user) return;
if (!workspaceListFetched) return;
const { overlay, open, close } = useWindowOverlayStore.getState();
const isEmpty = wsCount === 0;
if (isEmpty) {
if (!overlay) open({ type: "new-workspace" });
} else if (overlay?.type === "new-workspace") {
close();
}
}, [user, workspaceListFetched, wsCount]);
// null = undecided (pre-login or list hasn't settled yet)
// true = session started with zero workspaces; next transition to >=1 triggers restart
// false = session started with >=1 workspace, OR we've already restarted; skip
const sessionStartedEmptyRef = useRef<boolean | null>(null);
useEffect(() => {
if (!user) {
sessionStartedEmptyRef.current = null;
return;
}
if (!workspaceListFetched) return;
if (sessionStartedEmptyRef.current === null) {
sessionStartedEmptyRef.current = wsCount === 0;
return;
}
if (sessionStartedEmptyRef.current && wsCount >= 1) {
void window.daemonAPI.restart();
sessionStartedEmptyRef.current = false;
}
}, [user, workspaceListFetched, wsCount]);
if (isLoading || bootstrapping) {
return (
<div className="flex h-screen items-center justify-center">
@@ -166,14 +85,9 @@ function AppContent() {
const DAEMON_TARGET_API_URL =
import.meta.env.VITE_API_URL || "http://localhost:8080";
// On logout, wipe desktop-only in-memory state and stop the daemon so that
// a subsequent login as a different user never inherits the previous user's
// tabs, overlay, or credentials. Zustand persist only writes to localStorage;
// useLogout clears the storage key, but the live stores stay populated until
// we explicitly reset them here.
// On logout, clear any cached PAT and stop the daemon so that a subsequent
// login as a different user never inherits the previous user's credentials.
async function handleDaemonLogout() {
useTabStore.getState().reset();
useWindowOverlayStore.getState().close();
try {
await window.daemonAPI.clearToken();
} catch {

View File

@@ -13,12 +13,13 @@ import { ModalRegistry } from "@multica/views/modals/registry";
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 { OnboardingGate } from "./onboarding-gate";
import { TabBar } from "./tab-bar";
import { TabContent } from "./tab-content";
import { WindowOverlay } from "./window-overlay";
function SidebarTopBar() {
const { canGoBack, canGoForward, goBack, goForward } = useTabHistory();
@@ -108,34 +109,39 @@ export function DesktopShell() {
return (
<DesktopNavigationProvider>
{/* 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. Zero-workspace
users see the window-level overlay (new-workspace flow)
triggered by IndexRedirect, not a route. */}
<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 />}
<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>
)}
>
{/* 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>
{slug && <ModalRegistry />}
{slug && <SearchCommand />}
<WindowOverlay />
</WorkspaceSlugProvider>
</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

@@ -29,8 +29,7 @@ import {
} from "@dnd-kit/modifiers";
import { CSS } from "@dnd-kit/utilities";
import { cn } from "@multica/ui/lib/utils";
import { useTabStore, useActiveGroup, resolveRouteIcon, type Tab } from "@/stores/tab-store";
import { paths } from "@multica/core/paths";
import { useTabStore, resolveRouteIcon, type Tab } from "@/stores/tab-store";
const TAB_ICONS: Record<string, LucideIcon> = {
Inbox,
@@ -67,13 +66,16 @@ function SortableTabItem({ tab, isActive, isOnly }: { tab: Tab; isActive: boolea
const handleClick = () => {
if (isActive) return;
setActiveTab(tab.id);
// No navigate() — Activity handles visibility
};
const handleClose = (e: React.MouseEvent) => {
e.stopPropagation();
closeTab(tab.id);
// No navigate() — store handles activeTabId switch
};
// Stop pointer down on close so it doesn't start a drag on the parent button.
const stopDragOnClose = (e: React.PointerEvent) => {
e.stopPropagation();
};
@@ -122,13 +124,10 @@ function NewTabButton() {
const setActiveTab = useTabStore((s) => s.setActiveTab);
const handleClick = () => {
// New tab opens in the currently active workspace — tabs are scoped
// per workspace, so there is no cross-workspace ambiguity to resolve.
const activeSlug = useTabStore.getState().activeWorkspaceSlug;
if (!activeSlug) return;
const path = paths.workspace(activeSlug).issues();
const path = "/issues";
const tabId = addTab(path, "Issues", resolveRouteIcon(path));
if (tabId) setActiveTab(tabId);
setActiveTab(tabId);
// No navigate() — new tab's router starts at /issues automatically
};
return (
@@ -143,17 +142,17 @@ function NewTabButton() {
}
export function TabBar() {
const group = useActiveGroup();
const tabs = useTabStore((s) => s.tabs);
const activeTabId = useTabStore((s) => s.activeTabId);
const moveTab = useTabStore((s) => s.moveTab);
// distance: 5 — pointer must move 5px to start a drag, otherwise it's a click.
const sensors = useSensors(
useSensor(PointerSensor, {
activationConstraint: { distance: 5 },
}),
);
const tabs = group?.tabs ?? [];
const activeTabId = group?.activeTabId ?? "";
const tabIds = tabs.map((t) => t.id);
const handleDragEnd = (event: DragEndEvent) => {
@@ -183,7 +182,7 @@ export function TabBar() {
))}
</SortableContext>
</DndContext>
{group && <NewTabButton />}
<NewTabButton />
</div>
);
}

View File

@@ -1,52 +1,40 @@
import { Activity, useEffect } from "react";
import { RouterProvider } from "react-router-dom";
import { useActiveGroup } from "@/stores/tab-store";
import { useTabStore } from "@/stores/tab-store";
import { TabNavigationProvider } from "@/platform/navigation";
import { useTabRouterSync } from "@/hooks/use-tab-router-sync";
import type { Tab } from "@/stores/tab-store";
/**
* Inner wrapper rendered inside each tab's RouterProvider. The router
* reference is stable for a tab's lifetime, so passing it in directly
* (instead of re-deriving from the store) avoids needless re-renders.
*/
function TabRouterInner({ tab }: { tab: Tab }) {
useTabRouterSync(tab.id, tab.router);
/** Inner wrapper rendered inside each tab's RouterProvider. */
function TabRouterInner({ tabId }: { tabId: string }) {
const tab = useTabStore((s) => s.tabs.find((t) => t.id === tabId));
useTabRouterSync(tabId, tab!.router);
return null;
}
/**
* Renders the active workspace's tabs using Activity for state preservation.
* Renders all tabs using Activity for state preservation.
* Only the active tab is visible; hidden tabs keep their DOM and React state.
*
* When switching workspaces, the previous workspace's tabs unmount entirely
* and the new workspace's tabs mount fresh — cross-workspace state
* preservation is an explicit non-goal (keeping all workspaces' tabs warm
* simultaneously would bloat memory and make workspace switching feel
* anything but "switching").
*/
export function TabContent() {
const group = useActiveGroup();
const tabs = useTabStore((s) => s.tabs);
const activeTabId = useTabStore((s) => s.activeTabId);
// Sync document.title when switching tabs within the active workspace.
// Sync document.title when switching tabs
useEffect(() => {
if (!group) return;
const tab = group.tabs.find((t) => t.id === group.activeTabId);
const tab = tabs.find((t) => t.id === activeTabId);
if (tab) document.title = tab.title;
}, [group?.activeTabId, group?.tabs]);
if (!group) return null;
}, [activeTabId, tabs]);
return (
<>
{group.tabs.map((tab) => (
{tabs.map((tab) => (
<Activity
key={tab.id}
mode={tab.id === group.activeTabId ? "visible" : "hidden"}
mode={tab.id === activeTabId ? "visible" : "hidden"}
>
<TabNavigationProvider router={tab.router}>
<RouterProvider router={tab.router} />
<TabRouterInner tab={tab} />
<TabRouterInner tabId={tab.id} />
</TabNavigationProvider>
</Activity>
))}

View File

@@ -1,85 +0,0 @@
import { useQuery } from "@tanstack/react-query";
import { useImmersiveMode } from "@multica/views/platform";
import { NewWorkspacePage } from "@multica/views/workspace/new-workspace-page";
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 { useWindowOverlayStore } from "@/stores/window-overlay-store";
/**
* Window-level transition overlay: renders above the tab system when the
* user is in a pre-workspace flow (create workspace, accept invite).
*
* This component is a thin **platform shell**:
* - Hands the window-drag strip and macOS traffic-light hiding
* (`useImmersiveMode`) — both are platform-specific, web has neither
* - Covers the tab system (fixed inset, z-50) so the Shell's own TabBar
* doesn't leak through
*
* All UX affordances (Back button, Log out button, welcome copy, invite
* card) live inside the shared `NewWorkspacePage` / `InvitePage`
* components under `packages/views/`, so web and desktop render identical
* content. The platform split is: UX in shared code, chrome here.
*/
export function WindowOverlay() {
const overlay = useWindowOverlayStore((s) => s.overlay);
if (!overlay) return null;
return <WindowOverlayInner />;
}
function WindowOverlayInner() {
const overlay = useWindowOverlayStore((s) => s.overlay);
const close = useWindowOverlayStore((s) => s.close);
const { push } = useNavigation();
const { data: wsList = [] } = useQuery(workspaceListOptions());
useImmersiveMode();
if (!overlay) return null;
// Back is only meaningful when there's somewhere to go — i.e. the user
// has at least one workspace. Zero-workspace users can only Log out or
// complete the flow.
const onBack = wsList.length > 0 ? close : undefined;
return (
<div className="fixed inset-0 z-50 flex flex-col bg-background">
{/* Window-drag strip. Rendered as a flex *child* (not absolute
overlay) so it owns its own 48px of real layout space — the
prior absolute-positioned approach relied on z-index stacking
to beat the content wrapper's no-drag, which in practice didn't
hit-test reliably for `-webkit-app-region` on the welcome
screen. A real flex row with nothing else in it has no such
ambiguity: any pixel at top-48 is drag, full stop.
Height matches `MainTopBar` (48px) so the drag-to-grab area
feels consistent with the rest of the app. The strip is
invisible; macOS traffic lights would normally sit here but
`useImmersiveMode` has hidden them for the overlay's lifetime. */}
<div
aria-hidden
className="h-12 shrink-0"
style={{ WebkitAppRegion: "drag" } as React.CSSProperties}
/>
<div
className="flex-1 min-h-0 overflow-auto"
style={{ WebkitAppRegion: "no-drag" } as React.CSSProperties}
>
{overlay.type === "new-workspace" && (
<NewWorkspacePage
onSuccess={(ws) => push(paths.workspace(ws.slug).issues())}
onBack={onBack}
/>
)}
{overlay.type === "invite" && (
<InvitePage
invitationId={overlay.invitationId}
onBack={onBack}
/>
)}
</div>
</div>
);
}

View File

@@ -1,32 +1,21 @@
import { useEffect } from "react";
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 {
workspaceBySlugOptions,
workspaceListOptions,
} from "@multica/core/workspace";
import { setCurrentWorkspace } from "@multica/core/platform";
setCurrentWorkspace,
rehydrateAllWorkspaceStores,
} from "@multica/core/platform";
import { useAuthStore } from "@multica/core/auth";
import { useWorkspaceSeen } from "@multica/views/workspace/use-workspace-seen";
import { useTabStore } from "@/stores/tab-store";
/**
* Desktop equivalent of apps/web/app/[workspaceSlug]/layout.tsx.
*
* Resolves the URL slug → workspace UUID via the React Query list cache
* (seeded by AuthInitializer). Children do not render until the workspace
* is fully resolved — useWorkspaceId() inside child pages is therefore
* guaranteed non-null when called. Two industry-standard identities are
* kept distinct: slug (URL / browser) and UUID (API / cache keys).
*
* Unlike web, desktop never renders a "workspace not available" page: the
* app has no URL bar and no clickable links from outside the session, so
* landing on an inaccessible slug can only mean stale state (a persisted
* tab group for a workspace the current user no longer has access to, or
* active eviction). Both cases resolve by dropping the stale tab group
* from the tab store — the TabBar then renders a different workspace or
* the WindowOverlay takes over (zero valid workspaces).
* 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 }>();
@@ -34,51 +23,34 @@ export function WorkspaceRouteLayout() {
const user = useAuthStore((s) => s.user);
const isAuthLoading = useAuthStore((s) => s.isLoading);
// Workspace routes require auth. If user is unauthenticated, bounce to /login.
useEffect(() => {
if (!isAuthLoading && !user) navigate(paths.login(), { replace: true });
}, [isAuthLoading, user, navigate]);
const { data: workspace, isFetched: listFetched } = useQuery({
...workspaceBySlugOptions(workspaceSlug ?? ""),
enabled: !!user && !!workspaceSlug,
});
const { data: wsList } = useQuery({
...workspaceListOptions(),
enabled: !!user,
});
// Feed the URL slug into the platform singleton so the API client's
// X-Workspace-Slug header and persist namespace follow the active tab.
// setCurrentWorkspace self-dedupes on slug equality.
if (workspace && 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;
}
const hasBeenSeen = useWorkspaceSeen(workspaceSlug, !!workspace);
// Stale-slug auto-heal: when this tab's slug fails to resolve, drop the
// whole workspace group from the tab store. Per-workspace tab grouping
// means the cleanup is a single validator call — the TabContent will
// unmount this tab (and all siblings in the stale group) once the store
// updates. We don't navigate this tab's router because the tab's path
// is scoped to the stale slug; navigating to "/" would create an
// inconsistent "tab in group X with path /" state.
// Slug doesn't resolve → onboarding. Skip when user is null.
useEffect(() => {
if (!user) return;
if (!listFetched) return;
if (workspace) return;
if (hasBeenSeen) return; // active eviction in flight — let the other path win
if (!wsList) return;
const validSlugs = new Set(wsList.map((w) => w.slug));
useTabStore.getState().validateWorkspaceSlugs(validSlugs);
}, [user, listFetched, workspace, hasBeenSeen, wsList]);
if (listFetched && !workspace) navigate(paths.onboarding(), { replace: true });
}, [user, listFetched, workspace, navigate]);
if (isAuthLoading) return null;
if (!workspaceSlug) return null;
if (!listFetched) return null;
if (!workspace) return null; // auto-heal effect above handles the cleanup
return (
<WorkspaceSlugProvider slug={workspaceSlug}>

View File

@@ -1,6 +1,6 @@
import { useCallback } from "react";
import type { DataRouter } from "react-router-dom";
import { useActiveTabRouter, useActiveTabHistory } from "@/stores/tab-store";
import { useTabStore } from "@/stores/tab-store";
/**
* Shared hint map so useTabRouterSync can distinguish back vs forward POP.
@@ -9,32 +9,32 @@ import { useActiveTabRouter, useActiveTabHistory } from "@/stores/tab-store";
export const popDirectionHints = new Map<DataRouter, "back" | "forward">();
/**
* Per-tab back/forward navigation derived from the active workspace's
* active tab.
*
* Subscribed via primitive selectors so this hook only re-renders when
* the numeric history state actually changes — path ticks on the active
* tab (which don't shift historyIndex) don't churn the back/forward
* buttons.
* Per-tab back/forward navigation derived from the active tab's history state.
* Replaces the old global useNavigationHistory() hook.
*/
export function useTabHistory() {
const router = useActiveTabRouter();
const { historyIndex, historyLength } = useActiveTabHistory();
// Return the actual tab object from the store — stable reference.
// Do NOT create a new object in the selector (causes infinite re-renders).
const activeTab = useTabStore((s) =>
s.tabs.find((t) => t.id === s.activeTabId),
);
const canGoBack = historyIndex > 0;
const canGoForward = historyIndex < historyLength - 1;
const canGoBack = (activeTab?.historyIndex ?? 0) > 0;
const canGoForward =
(activeTab?.historyIndex ?? 0) < (activeTab?.historyLength ?? 1) - 1;
const goBack = useCallback(() => {
if (!router || historyIndex <= 0) return;
popDirectionHints.set(router, "back");
router.navigate(-1);
}, [router, historyIndex]);
if (!activeTab || activeTab.historyIndex <= 0) return;
popDirectionHints.set(activeTab.router, "back");
activeTab.router.navigate(-1);
}, [activeTab]);
const goForward = useCallback(() => {
if (!router || historyIndex >= historyLength - 1) return;
popDirectionHints.set(router, "forward");
router.navigate(1);
}, [router, historyIndex, historyLength]);
if (!activeTab || activeTab.historyIndex >= activeTab.historyLength - 1)
return;
popDirectionHints.set(activeTab.router, "forward");
activeTab.router.navigate(1);
}, [activeTab]);
return { canGoBack, canGoForward, goBack, goForward };
}

View File

@@ -2,23 +2,20 @@ import { useEffect } from "react";
import { useTabStore } from "@/stores/tab-store";
/**
* Watches document.title via MutationObserver and updates the active tab's
* title. Pages set document.title via TitleSync (route handle.title) or
* useDocumentTitle(). This observer picks up the change and syncs it to
* the tab store.
* Watches document.title via MutationObserver and updates the active tab's title.
*
* Pages set document.title via TitleSync (route handle.title) or useDocumentTitle().
* This observer picks up the change and syncs it to the tab store.
*/
export function useActiveTitleSync() {
useEffect(() => {
const observer = new MutationObserver(() => {
const title = document.title;
if (!title) return;
const state = useTabStore.getState();
if (!state.activeWorkspaceSlug) return;
const group = state.byWorkspace[state.activeWorkspaceSlug];
if (!group) return;
const activeTab = group.tabs.find((t) => t.id === group.activeTabId);
const { tabs, activeTabId } = useTabStore.getState();
const activeTab = tabs.find((t) => t.id === activeTabId);
if (activeTab && activeTab.title !== title) {
state.updateTab(activeTab.id, { title });
useTabStore.getState().updateTab(activeTabId, { title });
}
});

View File

@@ -5,15 +5,7 @@ import {
type NavigationAdapter,
} from "@multica/views/navigation";
import { useAuthStore } from "@multica/core/auth";
import { isReservedSlug } from "@multica/core/paths";
import {
useTabStore,
resolveRouteIcon,
useActiveTabIdentity,
useActiveTabRouter,
getActiveTab,
} from "@/stores/tab-store";
import { useWindowOverlayStore } from "@/stores/window-overlay-store";
import { useTabStore, resolveRouteIcon } from "@/stores/tab-store";
// Public web app URL — injected at build time via .env.production. Falls
// back to the production host for dev builds so "Copy link" yields a URL
@@ -21,77 +13,8 @@ import { useWindowOverlayStore } from "@/stores/window-overlay-store";
const APP_URL = import.meta.env.VITE_APP_URL || "https://multica.ai";
/**
* Extract the leading workspace slug from a path, or null if the path isn't
* workspace-scoped (root, login, any reserved prefix).
*/
function extractWorkspaceSlug(path: string): string | null {
const first = path.split("/").filter(Boolean)[0] ?? "";
if (!first) return null;
if (isReservedSlug(first)) return null;
return first;
}
/**
* Intercept navigation to "transition" paths — pre-workspace flows that on
* desktop are rendered as a window-level overlay instead of a tab route.
* Returns `true` if the navigation was handled (caller should NOT proceed).
*
* Side effect: when opening the new-workspace overlay, the tab router is
* ALSO reset to "/". Rationale — the only way a push lands on
* /workspaces/new is that the workspace context is gone (fresh install,
* delete-last, leave-last). Leaving the tab parked on a workspace-scoped
* path would keep those components mounted under the overlay; the next
* render after the list cache updates would then throw (useWorkspaceId
* etc) because the slug no longer resolves.
*/
function tryRouteToOverlay(path: string, router?: DataRouter): boolean {
const overlay = useWindowOverlayStore.getState();
if (path === "/workspaces/new") {
overlay.open({ type: "new-workspace" });
if (router && router.state.location.pathname !== "/") {
router.navigate("/", { replace: true });
}
return true;
}
if (path.startsWith("/invite/")) {
let id = "";
try {
id = decodeURIComponent(path.slice("/invite/".length));
} catch {
return true;
}
if (id) {
overlay.open({ type: "invite", invitationId: id });
return true;
}
}
// Any other navigation cancels a live overlay.
if (overlay.overlay) overlay.close();
return false;
}
/**
* Intercept pushes that change workspace. Returns `true` if the navigation
* was delegated to the tab store (caller should NOT proceed).
*
* This is the entry point that makes shared code platform-agnostic:
* sidebar dropdown, cmd+k "switch workspace", post-delete redirects,
* invite-accept flow — they all call `useNavigation().push(path)` with a
* full workspace URL, and on desktop we translate "target slug differs
* from active" into "switch the tab-group that's visible in the TabBar".
*/
function tryRouteToOtherWorkspace(path: string): boolean {
const targetSlug = extractWorkspaceSlug(path);
if (!targetSlug) return false;
const { activeWorkspaceSlug, switchWorkspace } = useTabStore.getState();
if (targetSlug === activeWorkspaceSlug) return false;
switchWorkspace(targetSlug, path);
return true;
}
/**
* Root-level navigation provider for components outside the per-tab
* RouterProviders (sidebar, search dialog, modals, WindowOverlay contents).
* Root-level navigation provider for components outside the per-tab RouterProviders
* (sidebar, search dialog, modals, etc.).
*
* Reads from the active tab's memory router via router.subscribe().
* Does NOT use any react-router hooks — it's above all RouterProviders.
@@ -101,61 +24,50 @@ export function DesktopNavigationProvider({
}: {
children: React.ReactNode;
}) {
// Primitive-only subscriptions so this component doesn't re-render on
// unrelated store updates (e.g. an inactive tab's router tick). We
// resolve the active router here only to subscribe once per tab switch.
const { tabId: activeTabId } = useActiveTabIdentity();
const router = useActiveTabRouter();
const [pathname, setPathname] = useState(
router?.state.location.pathname ?? "/",
);
const activeTab = useTabStore((s) => s.tabs.find((t) => t.id === s.activeTabId));
const [pathname, setPathname] = useState(activeTab?.path ?? "/issues");
// Subscribe to the active tab's router for pathname updates
useEffect(() => {
if (!router) {
setPathname("/");
return;
}
setPathname(router.state.location.pathname);
return router.subscribe((state) => {
if (!activeTab) return;
setPathname(activeTab.router.state.location.pathname);
return activeTab.router.subscribe((state) => {
setPathname(state.location.pathname);
});
}, [activeTabId, router]);
}, [activeTab?.id]); // eslint-disable-line react-hooks/exhaustive-deps
const adapter: NavigationAdapter = useMemo(
() => ({
push: (path: string) => {
if (path === "/login") {
// DashboardGuard token expired — force back to login screen
useAuthStore.getState().logout();
return;
}
const active = currentActiveTab();
if (tryRouteToOverlay(path, active?.router)) return;
if (tryRouteToOtherWorkspace(path)) return;
active?.router.navigate(path);
const tab = useTabStore.getState().tabs.find(
(t) => t.id === useTabStore.getState().activeTabId,
);
tab?.router.navigate(path);
},
replace: (path: string) => {
const active = currentActiveTab();
if (tryRouteToOverlay(path, active?.router)) return;
if (tryRouteToOtherWorkspace(path)) return;
active?.router.navigate(path, { replace: true });
const tab = useTabStore.getState().tabs.find(
(t) => t.id === useTabStore.getState().activeTabId,
);
tab?.router.navigate(path, { replace: true });
},
back: () => {
currentActiveTab()?.router.navigate(-1);
const tab = useTabStore.getState().tabs.find(
(t) => t.id === useTabStore.getState().activeTabId,
);
tab?.router.navigate(-1);
},
pathname,
searchParams: new URLSearchParams(),
openInNewTab: (path: string, title?: string) => {
// Cross-workspace "open in new tab" switches workspace and opens
// the path there; same-workspace just adds a tab in the current group.
const slug = extractWorkspaceSlug(path);
const store = useTabStore.getState();
if (slug && slug !== store.activeWorkspaceSlug) {
store.switchWorkspace(slug, path);
return;
}
const icon = resolveRouteIcon(path);
const store = useTabStore.getState();
const tabId = store.openTab(path, title ?? path, icon);
if (tabId) store.setActiveTab(tabId);
store.setActiveTab(tabId);
},
getShareableUrl: (path: string) => `${APP_URL}${path}`,
}),
@@ -165,10 +77,6 @@ export function DesktopNavigationProvider({
return <NavigationProvider value={adapter}>{children}</NavigationProvider>;
}
function currentActiveTab() {
return getActiveTab(useTabStore.getState());
}
/**
* Per-tab navigation provider rendered inside each tab's Activity wrapper.
* Subscribes to the tab's own router for up-to-date pathname.
@@ -193,29 +101,16 @@ export function TabNavigationProvider({
const adapter: NavigationAdapter = useMemo(
() => ({
push: (path: string) => {
if (tryRouteToOverlay(path, router)) return;
if (tryRouteToOtherWorkspace(path)) return;
router.navigate(path);
},
replace: (path: string) => {
if (tryRouteToOverlay(path, router)) return;
if (tryRouteToOtherWorkspace(path)) return;
router.navigate(path, { replace: true });
},
push: (path: string) => router.navigate(path),
replace: (path: string) => router.navigate(path, { replace: true }),
back: () => router.navigate(-1),
pathname: location.pathname,
searchParams: new URLSearchParams(location.search),
openInNewTab: (path: string, title?: string) => {
const slug = extractWorkspaceSlug(path);
const store = useTabStore.getState();
if (slug && slug !== store.activeWorkspaceSlug) {
store.switchWorkspace(slug, path);
return;
}
const icon = resolveRouteIcon(path);
const tabId = store.openTab(path, title ?? path, icon);
if (tabId) store.setActiveTab(tabId);
const store = useTabStore.getState();
const newTabId = store.openTab(path, title ?? path, icon);
store.setActiveTab(newTabId);
},
getShareableUrl: (path: string) => `${APP_URL}${path}`,
}),

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";
@@ -19,6 +20,11 @@ import { DaemonRuntimeCard } from "./components/daemon-runtime-card";
import { AgentsPage } from "@multica/views/agents";
import { InboxPage } from "@multica/views/inbox";
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";
@@ -53,28 +59,76 @@ function PageShell() {
);
}
function OnboardingRoute() {
const nav = useNavigation();
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() {
const matches = useMatches();
const match = matches.find((m) => (m.params as { id?: string }).id);
const id = (match?.params as { id?: string })?.id ?? "";
return <InvitePage invitationId={id} />;
}
/**
* Route definitions shared by all tabs.
*
* Every tab path is workspace-scoped: `/{slug}/{route}/...`. Pre-workspace
* flows (create workspace, accept invite) are NOT routes — they render as a
* window-level overlay via `WindowOverlay`, dispatched by the navigation
* adapter's transition-path interception. The `activeWorkspaceSlug` in the
* tab store decides which workspace's tabs are visible in the TabBar;
* workspace-less state (zero-workspace user) shows the overlay instead.
*
* The root index route stays as a harmless safety net. With per-workspace
* tabs, nothing should construct a tab at `/` — but if one ever slips
* through (malformed persisted state that dodges the migration, direct
* router.navigate from unforeseen code), the index falls back to null
* rather than 404; App.tsx's bootstrap repoints activeWorkspaceSlug on the
* next render pass.
* 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: null },
// 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 />,
handle: { title: "Get Started" },
},
{
path: "invite/:id",
element: <InviteRoute />,
handle: { title: "Accept Invite" },
},
{
path: ":workspaceSlug",
element: <WorkspaceRouteLayout />,

View File

@@ -1,224 +0,0 @@
import { describe, expect, it, vi, beforeEach } from "vitest";
// createTabRouter transitively pulls in route modules that expect a browser
// router context. For pure store tests we stub it to a minimal disposable.
const createTabRouterMock = vi.hoisted(() =>
vi.fn(() => ({
dispose: vi.fn(),
state: { location: { pathname: "/" } },
navigate: vi.fn(),
subscribe: vi.fn(() => () => {}),
})),
);
vi.mock("../routes", () => ({
createTabRouter: createTabRouterMock,
}));
import {
sanitizeTabPath,
migrateV1ToV2,
useTabStore,
} from "./tab-store";
beforeEach(() => {
createTabRouterMock.mockClear();
useTabStore.getState().reset();
});
describe("sanitizeTabPath", () => {
it("rejects the root sentinel — tabs must be workspace-scoped", () => {
expect(sanitizeTabPath("/")).toBeNull();
expect(sanitizeTabPath("")).toBeNull();
});
it("silently rejects transition paths (no warn — navigation adapter intercepts them)", () => {
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
expect(sanitizeTabPath("/workspaces/new")).toBeNull();
expect(sanitizeTabPath("/invite/abc")).toBeNull();
expect(warn).not.toHaveBeenCalled();
warn.mockRestore();
});
it("passes through valid workspace-scoped paths", () => {
expect(sanitizeTabPath("/acme/issues")).toBe("/acme/issues");
expect(sanitizeTabPath("/my-team/projects/abc")).toBe("/my-team/projects/abc");
});
it("rejects paths whose first segment is a reserved slug (missing workspace prefix)", () => {
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
expect(sanitizeTabPath("/issues")).toBeNull();
expect(sanitizeTabPath("/settings")).toBeNull();
expect(warn).toHaveBeenCalled();
warn.mockRestore();
});
it("passes through user slugs that happen to look path-like but aren't reserved", () => {
expect(sanitizeTabPath("/acme-issues/issues")).toBe("/acme-issues/issues");
expect(sanitizeTabPath("/project-x/inbox")).toBe("/project-x/inbox");
});
});
describe("migrateV1ToV2", () => {
it("groups v1 flat tabs by workspace slug", () => {
const v1 = {
tabs: [
{ id: "t1", path: "/acme/issues", title: "Issues", icon: "ListTodo" },
{ id: "t2", path: "/acme/projects", title: "Projects", icon: "FolderKanban" },
{ id: "t3", path: "/butter/issues", title: "Issues", icon: "ListTodo" },
],
activeTabId: "t2",
};
const v2 = migrateV1ToV2(v1);
expect(Object.keys(v2.byWorkspace).sort()).toEqual(["acme", "butter"]);
expect(v2.byWorkspace.acme.tabs).toHaveLength(2);
expect(v2.byWorkspace.butter.tabs).toHaveLength(1);
expect(v2.byWorkspace.acme.activeTabId).toBe("t2");
expect(v2.byWorkspace.butter.activeTabId).toBe("t3"); // first tab in group
expect(v2.activeWorkspaceSlug).toBe("acme"); // contained v1.activeTabId
});
it("drops tabs at root / transition / reserved-slug paths", () => {
const v1 = {
tabs: [
{ id: "t1", path: "/", title: "Issues", icon: "ListTodo" },
{ id: "t2", path: "/workspaces/new", title: "New", icon: "Plus" },
{ id: "t3", path: "/invite/abc", title: "Invite", icon: "Mail" },
{ id: "t4", path: "/acme/issues", title: "Issues", icon: "ListTodo" },
],
activeTabId: "t1",
};
const v2 = migrateV1ToV2(v1);
expect(Object.keys(v2.byWorkspace)).toEqual(["acme"]);
expect(v2.byWorkspace.acme.tabs).toHaveLength(1);
// v1.activeTabId was dropped; active falls back to first group's first tab.
expect(v2.activeWorkspaceSlug).toBe("acme");
expect(v2.byWorkspace.acme.activeTabId).toBe("t4");
});
it("handles empty v1 state gracefully", () => {
const v2 = migrateV1ToV2({ tabs: [], activeTabId: "" });
expect(v2.byWorkspace).toEqual({});
expect(v2.activeWorkspaceSlug).toBeNull();
});
it("handles v1 with no tabs field (corrupted state)", () => {
const v2 = migrateV1ToV2({});
expect(v2.byWorkspace).toEqual({});
expect(v2.activeWorkspaceSlug).toBeNull();
});
});
describe("useTabStore actions", () => {
it("switchWorkspace creates a new group with a default tab on first entry", () => {
useTabStore.getState().switchWorkspace("acme");
const s = useTabStore.getState();
expect(s.activeWorkspaceSlug).toBe("acme");
expect(s.byWorkspace.acme.tabs).toHaveLength(1);
expect(s.byWorkspace.acme.tabs[0].path).toBe("/acme/issues");
});
it("switchWorkspace without openPath restores the group's last active tab", () => {
const store = useTabStore.getState();
store.switchWorkspace("acme");
store.addTab("/acme/projects", "Projects", "FolderKanban");
const acmeProjectsId = useTabStore.getState().byWorkspace.acme.tabs[1].id;
store.setActiveTab(acmeProjectsId);
// Enter a different workspace then come back
store.switchWorkspace("butter");
expect(useTabStore.getState().activeWorkspaceSlug).toBe("butter");
store.switchWorkspace("acme");
const s = useTabStore.getState();
expect(s.activeWorkspaceSlug).toBe("acme");
expect(s.byWorkspace.acme.activeTabId).toBe(acmeProjectsId);
});
it("switchWorkspace with openPath dedupes into an existing tab with same path", () => {
const store = useTabStore.getState();
store.switchWorkspace("acme"); // creates default /acme/issues
store.addTab("/acme/projects", "Projects", "FolderKanban");
store.switchWorkspace("acme", "/acme/issues");
const s = useTabStore.getState();
expect(s.byWorkspace.acme.tabs).toHaveLength(2); // no duplicate created
const activeTab = s.byWorkspace.acme.tabs.find(
(t) => t.id === s.byWorkspace.acme.activeTabId,
);
expect(activeTab?.path).toBe("/acme/issues");
});
it("switchWorkspace with openPath not matching any tab adds a new tab", () => {
const store = useTabStore.getState();
store.switchWorkspace("acme");
store.switchWorkspace("acme", "/acme/issues/bug-42");
const s = useTabStore.getState();
expect(s.byWorkspace.acme.tabs).toHaveLength(2);
const activeTab = s.byWorkspace.acme.tabs.find(
(t) => t.id === s.byWorkspace.acme.activeTabId,
);
expect(activeTab?.path).toBe("/acme/issues/bug-42");
});
it("openTab dedupes by path within the active workspace", () => {
const store = useTabStore.getState();
store.switchWorkspace("acme");
const id1 = store.openTab("/acme/projects", "Projects", "FolderKanban");
const id2 = store.openTab("/acme/projects", "Projects", "FolderKanban");
expect(id1).toBe(id2);
expect(useTabStore.getState().byWorkspace.acme.tabs).toHaveLength(2); // default + projects
});
it("closeTab on the last tab in a workspace reseeds the default tab", () => {
const store = useTabStore.getState();
store.switchWorkspace("acme");
const onlyTabId = useTabStore.getState().byWorkspace.acme.tabs[0].id;
store.closeTab(onlyTabId);
const s = useTabStore.getState();
expect(s.byWorkspace.acme.tabs).toHaveLength(1);
expect(s.byWorkspace.acme.tabs[0].path).toBe("/acme/issues");
expect(s.byWorkspace.acme.tabs[0].id).not.toBe(onlyTabId); // fresh tab
});
it("validateWorkspaceSlugs drops groups for slugs not in the valid set and repoints active", () => {
const store = useTabStore.getState();
store.switchWorkspace("acme");
store.switchWorkspace("butter");
store.switchWorkspace("acme");
expect(useTabStore.getState().activeWorkspaceSlug).toBe("acme");
// Admin removed the user from acme
store.validateWorkspaceSlugs(new Set(["butter"]));
const s = useTabStore.getState();
expect(Object.keys(s.byWorkspace)).toEqual(["butter"]);
expect(s.activeWorkspaceSlug).toBe("butter");
});
it("validateWorkspaceSlugs sets activeWorkspaceSlug to null when all groups are dropped", () => {
const store = useTabStore.getState();
store.switchWorkspace("acme");
store.validateWorkspaceSlugs(new Set());
const s = useTabStore.getState();
expect(s.byWorkspace).toEqual({});
expect(s.activeWorkspaceSlug).toBeNull();
});
it("reset wipes the whole store", () => {
const store = useTabStore.getState();
store.switchWorkspace("acme");
store.switchWorkspace("butter");
store.reset();
const s = useTabStore.getState();
expect(s.activeWorkspaceSlug).toBeNull();
expect(s.byWorkspace).toEqual({});
});
it("setActiveTab across workspaces also flips the active workspace", () => {
const store = useTabStore.getState();
store.switchWorkspace("acme");
store.switchWorkspace("butter");
const acmeTabId = useTabStore.getState().byWorkspace.acme.tabs[0].id;
store.setActiveTab(acmeTabId);
expect(useTabStore.getState().activeWorkspaceSlug).toBe("acme");
});
});

View File

@@ -3,7 +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 { isReservedSlug } from "@multica/core/paths";
import { isGlobalPath } from "@multica/core/paths";
import type { DataRouter } from "react-router-dom";
import { createTabRouter } from "../routes";
@@ -13,7 +13,6 @@ import { createTabRouter } from "../routes";
export interface Tab {
id: string;
/** Every tab path is workspace-scoped: `/{workspaceSlug}/{route}/...`. */
path: string;
title: string;
icon: string;
@@ -22,77 +21,24 @@ export interface Tab {
historyLength: number;
}
export interface WorkspaceTabGroup {
tabs: Tab[];
/** Must be a valid tab.id in `tabs`; the empty-tabs state is transient only. */
activeTabId: string;
}
interface TabStore {
/**
* The workspace currently visible in the TabBar / TabContent. Null in three
* cases:
* - Fresh install, before any workspace exists or is selected.
* - Logged-out state (reset() wipes it).
* - Every workspace the user had access to got deleted / revoked.
* When null, TabContent renders nothing and the WindowOverlay takes over.
*/
activeWorkspaceSlug: string | null;
tabs: Tab[];
activeTabId: string;
/**
* Tab groups keyed by workspace slug. Each slug maps to an independent
* (tabs, activeTabId) pair; switching workspaces swaps the visible set
* without affecting any other group. Cross-workspace tab leakage — the
* bug that drove this refactor — is impossible by construction because
* there is no global tab array anymore.
*/
byWorkspace: Record<string, WorkspaceTabGroup>;
/**
* Switch to a workspace.
* - If the group doesn't exist yet, create it with a single default tab.
* - If `openPath` is given, find a tab with that exact path and activate
* it; otherwise add a new tab and activate it.
* - If `openPath` is omitted, restore the group's last active tab
* (VSCode / Slack behavior — workspaces resume where you left off).
*/
switchWorkspace: (slug: string, openPath?: string) => void;
/** Open-or-activate (dedupes by path) a tab in the active workspace. */
/** Open a background tab. Deduplicates by path. Returns the tab id. */
openTab: (path: string, title: string, icon: string) => string;
/** Always creates a new tab (no dedupe) in the active workspace. */
/** Always create a new tab (no dedup). Returns the tab id. */
addTab: (path: string, title: string, icon: string) => string;
/**
* Close a tab. Finds it across all workspaces (callers like the X button
* only know the tab id, not the owning workspace). If this is the last
* tab in its workspace, reseed a default tab so the invariant
* "every live workspace has at least one tab" holds.
*/
/** Close a tab. Disposes router. */
closeTab: (tabId: string) => void;
/**
* Activate a tab. Finds it across all workspaces. Sets both the owning
* workspace as active and that group's activeTabId; needed for any code
* path that "jumps" to a tab belonging to a non-active workspace.
*/
/** Switch to a tab by id. */
setActiveTab: (tabId: string) => void;
/** Patch metadata of a tab (router-sync, title-sync). Finds across groups. */
/** Update a tab's metadata (path, title, icon — partial). */
updateTab: (tabId: string, patch: Partial<Pick<Tab, "path" | "title" | "icon">>) => void;
/** Patch history tracking of a tab. Finds across groups. */
/** Update a tab's history tracking. */
updateTabHistory: (tabId: string, historyIndex: number, historyLength: number) => void;
/** Reorder within the active workspace's group only. */
/** Reorder tabs by moving one from fromIndex to toIndex. Preserves router/history. */
moveTab: (fromIndex: number, toIndex: number) => void;
/**
* After the workspace list arrives/changes (login, realtime delete), drop
* any tab group whose slug is no longer in `validSlugs`, and repoint
* `activeWorkspaceSlug` if it pointed at one of the dropped groups.
*/
validateWorkspaceSlugs: (validSlugs: Set<string>) => void;
/**
* Wipe everything. Called from logout so the next user doesn't inherit
* the prior user's tabs. Zustand persist only writes to localStorage;
* clearing the storage key alone would leave this live store intact
* until app restart.
*/
reset: () => void;
}
// ---------------------------------------------------------------------------
@@ -112,70 +58,38 @@ const ROUTE_ICONS: Record<string, string> = {
};
/**
* Resolve a route icon from a pathname.
* Resolve a route icon from a pathname. Title is NOT determined here — it
* comes from document.title.
*
* Tab paths are always workspace-scoped: `/{slug}/{route}/...`, so the route
* segment lives at index 1. Pre-workspace flows (create, invite) are rendered
* by the window overlay, never as tabs.
* 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
*
* Title is NOT determined here — it comes from document.title.
* `isGlobalPath` is the single source of truth for which prefixes are global.
*/
export function resolveRouteIcon(pathname: string): string {
const segments = pathname.split("/").filter(Boolean);
return ROUTE_ICONS[segments[1] ?? ""] ?? "ListTodo";
}
/** Extract the leading workspace slug from a path, or null if the path
* isn't workspace-scoped (global path, root, or empty). */
function extractWorkspaceSlug(path: string): string | null {
const first = path.split("/").filter(Boolean)[0] ?? "";
if (!first) return null;
if (isReservedSlug(first)) return null;
return first;
const routeSegment = isGlobalPath(pathname)
? (segments[0] ?? "")
: (segments[1] ?? "");
return ROUTE_ICONS[routeSegment] ?? "ListTodo";
}
// ---------------------------------------------------------------------------
// Path sanitization (defensive)
// Store
// ---------------------------------------------------------------------------
/**
* Defensive: catch paths that don't belong in the tab store.
* 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).
*
* Two kinds of rejects:
* 1. **Transition paths** (`/workspaces/new`, `/invite/...`). These are
* pre-workspace flows rendered by the window overlay on desktop, not
* tab routes. The navigation adapter normally intercepts these before
* they reach the store; this guard catches older persisted state.
* 2. **Malformed workspace-scoped paths** like a stray `/issues/abc` that
* was constructed without the workspace prefix. The router would
* interpret `issues` as a workspace slug → NoAccessPage.
*
* Returns null for rejects (caller decides how to recover — usually by
* dropping the tab or substituting a default). Unlike the prior design,
* there is no root "/" sentinel — tabs are always scoped.
* `title` and `icon` on the placeholder tab get overwritten by
* useTabRouterSync + useActiveTitleSync once the redirect resolves.
*/
export function sanitizeTabPath(path: string): string | null {
const firstSegment = path.split("/").filter(Boolean)[0] ?? "";
if (!firstSegment) return null;
if (isReservedSlug(firstSegment)) {
// Don't log for known transition paths — these are legitimate inputs
// at the interception boundary (older persisted state or stale callers).
const isTransition = path === "/workspaces/new" || path.startsWith("/invite/");
if (!isTransition) {
// eslint-disable-next-line no-console
console.warn(
`[tab-store] tab path "${path}" starts with reserved slug "${firstSegment}" — ` +
`caller likely forgot the workspace prefix. Dropping.`,
);
}
return null;
}
return path;
}
// ---------------------------------------------------------------------------
// Tab factory
// ---------------------------------------------------------------------------
const DEFAULT_PATH = "/";
function createId(): string {
return createSafeId();
@@ -193,513 +107,128 @@ function makeTab(path: string, title: string, icon: string): Tab {
};
}
/** Default entry point for a workspace — its issues list. */
function defaultPathFor(slug: string): string {
return `/${slug}/issues`;
}
function defaultTabFor(slug: string): Tab {
const path = defaultPathFor(slug);
return makeTab(path, "Issues", resolveRouteIcon(path));
}
// ---------------------------------------------------------------------------
// Group helpers
// ---------------------------------------------------------------------------
function findTabLocation(
byWorkspace: Record<string, WorkspaceTabGroup>,
tabId: string,
): { slug: string; group: WorkspaceTabGroup; index: number } | null {
for (const slug of Object.keys(byWorkspace)) {
const group = byWorkspace[slug];
const index = group.tabs.findIndex((t) => t.id === tabId);
if (index >= 0) return { slug, group, index };
}
return null;
}
// ---------------------------------------------------------------------------
// Store
// ---------------------------------------------------------------------------
const initialTab = makeTab(DEFAULT_PATH, "Issues", resolveRouteIcon(DEFAULT_PATH));
export const useTabStore = create<TabStore>()(
persist(
(set, get) => ({
activeWorkspaceSlug: null,
byWorkspace: {},
tabs: [initialTab],
activeTabId: initialTab.id,
switchWorkspace(slug, openPath) {
// Defensive no-op if slug is empty/invalid — callers like the
// NavigationAdapter's path-parser should already have filtered
// these, but belt-and-braces keeps garbage out of the store.
if (!slug) return;
const { byWorkspace } = get();
const existing = byWorkspace[slug];
openTab(path, title, icon) {
const { tabs } = get();
const existing = tabs.find((t) => t.path === path);
if (existing) return existing.id;
// Decide the desired active path for this workspace.
const desiredPath = openPath ?? (existing ? null : defaultPathFor(slug));
const tab = makeTab(path, title, icon);
set({ tabs: [...tabs, tab] });
return tab.id;
},
if (!existing) {
// First time entering this workspace — create the group.
const seedPath =
desiredPath && sanitizeTabPath(desiredPath) === desiredPath
? desiredPath
: defaultPathFor(slug);
const tab = makeTab(seedPath, "Issues", resolveRouteIcon(seedPath));
set({
activeWorkspaceSlug: slug,
byWorkspace: {
...byWorkspace,
[slug]: { tabs: [tab], activeTabId: tab.id },
},
});
return;
}
addTab(path, title, icon) {
const tab = makeTab(path, title, icon);
set((s) => ({ tabs: [...s.tabs, tab] }));
return tab.id;
},
// Workspace already has tabs. Either dedupe into an existing tab or
// add a new one (when openPath was supplied and no tab matches it).
if (desiredPath) {
const clean = sanitizeTabPath(desiredPath);
if (clean) {
const match = existing.tabs.find((t) => t.path === clean);
if (match) {
set({
activeWorkspaceSlug: slug,
byWorkspace: {
...byWorkspace,
[slug]: { ...existing, activeTabId: match.id },
},
});
return;
}
const tab = makeTab(clean, "Issues", resolveRouteIcon(clean));
set({
activeWorkspaceSlug: slug,
byWorkspace: {
...byWorkspace,
[slug]: {
tabs: [...existing.tabs, tab],
activeTabId: tab.id,
},
},
});
return;
}
}
closeTab(tabId) {
const { tabs, activeTabId } = get();
// No openPath (or openPath was rejected) — just restore the group.
set({ activeWorkspaceSlug: slug });
},
const closingTab = tabs.find((t) => t.id === tabId);
openTab(path, title, icon) {
const { activeWorkspaceSlug, byWorkspace } = get();
const clean = sanitizeTabPath(path);
if (!activeWorkspaceSlug || !clean) return "";
const group = byWorkspace[activeWorkspaceSlug];
if (!group) return "";
// Never close the last tab — replace with default
if (tabs.length === 1) {
closingTab?.router.dispose();
const fresh = makeTab(DEFAULT_PATH, "Issues", resolveRouteIcon(DEFAULT_PATH));
set({ tabs: [fresh], activeTabId: fresh.id });
return;
}
const existing = group.tabs.find((t) => t.path === clean);
if (existing) {
set({
byWorkspace: {
...byWorkspace,
[activeWorkspaceSlug]: { ...group, activeTabId: existing.id },
},
});
return existing.id;
}
const idx = tabs.findIndex((t) => t.id === tabId);
if (idx === -1) return;
const tab = makeTab(clean, title, icon);
set({
byWorkspace: {
...byWorkspace,
[activeWorkspaceSlug]: {
tabs: [...group.tabs, tab],
activeTabId: group.activeTabId,
},
},
});
return tab.id;
},
closingTab?.router.dispose();
const next = tabs.filter((t) => t.id !== tabId);
addTab(path, title, icon) {
const { activeWorkspaceSlug, byWorkspace } = get();
const clean = sanitizeTabPath(path);
if (!activeWorkspaceSlug || !clean) return "";
const group = byWorkspace[activeWorkspaceSlug];
if (!group) return "";
if (tabId === activeTabId) {
const newActive = next[Math.min(idx, next.length - 1)];
set({ tabs: next, activeTabId: newActive.id });
} else {
set({ tabs: next });
}
},
const tab = makeTab(clean, title, icon);
set({
byWorkspace: {
...byWorkspace,
[activeWorkspaceSlug]: {
tabs: [...group.tabs, tab],
activeTabId: group.activeTabId,
},
},
});
return tab.id;
},
setActiveTab(tabId) {
set({ activeTabId: tabId });
},
closeTab(tabId) {
const { byWorkspace } = get();
const hit = findTabLocation(byWorkspace, tabId);
if (!hit) return;
const { slug, group, index } = hit;
updateTab(tabId, patch) {
set((s) => ({
tabs: s.tabs.map((t) =>
t.id === tabId ? { ...t, ...patch } : t,
),
}));
},
const closing = group.tabs[index];
closing.router.dispose();
updateTabHistory(tabId, historyIndex, historyLength) {
set((s) => ({
tabs: s.tabs.map((t) =>
t.id === tabId ? { ...t, historyIndex, historyLength } : t,
),
}));
},
if (group.tabs.length === 1) {
// Last tab in this workspace — reseed a default so the workspace
// always has at least one tab. Closing a workspace as an explicit
// action is a separate concern (Leave/Delete in Settings).
const fresh = defaultTabFor(slug);
set({
byWorkspace: {
...byWorkspace,
[slug]: { tabs: [fresh], activeTabId: fresh.id },
},
});
return;
}
const nextTabs = group.tabs.filter((t) => t.id !== tabId);
const nextActiveTabId =
group.activeTabId === tabId
? nextTabs[Math.min(index, nextTabs.length - 1)].id
: group.activeTabId;
set({
byWorkspace: {
...byWorkspace,
[slug]: { tabs: nextTabs, activeTabId: nextActiveTabId },
},
});
},
setActiveTab(tabId) {
const { byWorkspace, activeWorkspaceSlug } = get();
const hit = findTabLocation(byWorkspace, tabId);
if (!hit) return;
const { slug, group } = hit;
if (slug === activeWorkspaceSlug && group.activeTabId === tabId) return;
set({
activeWorkspaceSlug: slug,
byWorkspace: {
...byWorkspace,
[slug]: { ...group, activeTabId: tabId },
},
});
},
updateTab(tabId, patch) {
const { byWorkspace } = get();
const hit = findTabLocation(byWorkspace, tabId);
if (!hit) return;
const { slug, group, index } = hit;
const current = group.tabs[index];
const next: Tab = { ...current, ...patch };
const nextTabs = [...group.tabs];
nextTabs[index] = next;
set({
byWorkspace: {
...byWorkspace,
[slug]: { ...group, tabs: nextTabs },
},
});
},
updateTabHistory(tabId, historyIndex, historyLength) {
const { byWorkspace } = get();
const hit = findTabLocation(byWorkspace, tabId);
if (!hit) return;
const { slug, group, index } = hit;
const current = group.tabs[index];
const next: Tab = { ...current, historyIndex, historyLength };
const nextTabs = [...group.tabs];
nextTabs[index] = next;
set({
byWorkspace: {
...byWorkspace,
[slug]: { ...group, tabs: nextTabs },
},
});
},
moveTab(fromIndex, toIndex) {
if (fromIndex === toIndex) return;
const { activeWorkspaceSlug, byWorkspace } = get();
if (!activeWorkspaceSlug) return;
const group = byWorkspace[activeWorkspaceSlug];
if (!group) return;
set({
byWorkspace: {
...byWorkspace,
[activeWorkspaceSlug]: {
...group,
tabs: arrayMove(group.tabs, fromIndex, toIndex),
},
},
});
},
validateWorkspaceSlugs(validSlugs) {
const { activeWorkspaceSlug, byWorkspace } = get();
let changed = false;
const nextByWorkspace: Record<string, WorkspaceTabGroup> = {};
for (const slug of Object.keys(byWorkspace)) {
if (validSlugs.has(slug)) {
nextByWorkspace[slug] = byWorkspace[slug];
} else {
changed = true;
for (const t of byWorkspace[slug].tabs) t.router.dispose();
}
}
let nextActive = activeWorkspaceSlug;
if (nextActive && !validSlugs.has(nextActive)) {
nextActive = Object.keys(nextByWorkspace)[0] ?? null;
changed = true;
}
if (!changed) return;
set({ byWorkspace: nextByWorkspace, activeWorkspaceSlug: nextActive });
},
reset() {
const { byWorkspace } = get();
for (const slug of Object.keys(byWorkspace)) {
for (const t of byWorkspace[slug].tabs) t.router.dispose();
}
set({ activeWorkspaceSlug: null, byWorkspace: {} });
},
moveTab(fromIndex, toIndex) {
if (fromIndex === toIndex) return;
set((s) => ({ tabs: arrayMove(s.tabs, fromIndex, toIndex) }));
},
}),
{
name: "multica_tabs",
version: 2,
version: 1,
storage: createJSONStorage(() => createPersistStorage(defaultStorage)),
migrate: (persistedState, version) => {
// v1 → v2: flat `tabs` array → per-workspace grouping.
// Tabs whose path isn't workspace-scoped (root `/`, login, etc.)
// are dropped — they have no workspace to belong to, and the new
// model's invariant is "every tab lives in a workspace group".
if (version < 2 && persistedState && typeof persistedState === "object") {
return migrateV1ToV2(persistedState as Partial<V1Persisted>);
}
return persistedState as V2Persisted;
},
partialize: (state) => ({
activeWorkspaceSlug: state.activeWorkspaceSlug,
byWorkspace: Object.fromEntries(
Object.entries(state.byWorkspace).map(([slug, group]) => [
slug,
{
activeTabId: group.activeTabId,
tabs: group.tabs.map(
({ router: _router, historyIndex: _hi, historyLength: _hl, ...rest }) =>
rest,
),
},
]),
tabs: state.tabs.map(
({ router, historyIndex, historyLength, ...rest }) => rest,
),
activeTabId: state.activeTabId,
}),
merge: (persistedState, currentState) => {
const persisted = persistedState as Partial<V2Persisted> | undefined;
if (!persisted?.byWorkspace) return currentState;
const persisted = persistedState as
| Pick<TabStore, "tabs" | "activeTabId">
| undefined;
if (!persisted?.tabs?.length) return currentState;
const byWorkspace: Record<string, WorkspaceTabGroup> = {};
for (const [slug, pGroup] of Object.entries(persisted.byWorkspace)) {
const tabs: Tab[] = [];
for (const pTab of pGroup.tabs) {
const clean = sanitizeTabPath(pTab.path);
// Persisted path may have come from a stale version or a
// manual edit. Drop rather than rewrite so we never silently
// put users on a path that doesn't match the group's slug.
if (!clean || extractWorkspaceSlug(clean) !== slug) {
// eslint-disable-next-line no-console
console.warn(
`[tab-store] dropping persisted tab "${pTab.path}" from ` +
`group "${slug}" — path/slug mismatch`,
);
continue;
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 = "/";
}
tabs.push({
id: pTab.id,
path: clean,
title: pTab.title,
icon: pTab.icon,
router: createTabRouter(clean),
historyIndex: 0,
historyLength: 1,
});
}
if (tabs.length === 0) continue;
const activeTabId = tabs.some((t) => t.id === pGroup.activeTabId)
? pGroup.activeTabId
: tabs[0].id;
byWorkspace[slug] = { tabs, activeTabId };
}
return {
...tab,
path,
router: createTabRouter(path),
historyIndex: 0,
historyLength: 1,
};
});
const activeWorkspaceSlug =
persisted.activeWorkspaceSlug && byWorkspace[persisted.activeWorkspaceSlug]
? persisted.activeWorkspaceSlug
: (Object.keys(byWorkspace)[0] ?? null);
// Validate activeTabId — fall back to first tab if stale
const activeTabId = tabs.some((t) => t.id === persisted.activeTabId)
? persisted.activeTabId
: tabs[0].id;
return { ...currentState, byWorkspace, activeWorkspaceSlug };
return { ...currentState, tabs, activeTabId };
},
},
),
);
// ---------------------------------------------------------------------------
// Persisted shapes (for migration)
// ---------------------------------------------------------------------------
interface V1Tab {
id: string;
path: string;
title: string;
icon: string;
}
interface V1Persisted {
tabs: V1Tab[];
activeTabId: string;
}
interface V2PersistedTab {
id: string;
path: string;
title: string;
icon: string;
}
interface V2PersistedGroup {
tabs: V2PersistedTab[];
activeTabId: string;
}
interface V2Persisted {
activeWorkspaceSlug: string | null;
byWorkspace: Record<string, V2PersistedGroup>;
}
export function migrateV1ToV2(v1: Partial<V1Persisted>): V2Persisted {
const byWorkspace: Record<string, V2PersistedGroup> = {};
const oldTabs = v1.tabs ?? [];
for (const tab of oldTabs) {
const slug = extractWorkspaceSlug(tab.path);
if (!slug) continue; // drop root / global-path tabs
if (!byWorkspace[slug]) byWorkspace[slug] = { tabs: [], activeTabId: "" };
byWorkspace[slug].tabs.push({
id: tab.id,
path: tab.path,
title: tab.title,
icon: tab.icon,
});
}
// Each group needs a valid activeTabId. Prefer the one from v1 if it
// landed in this group; otherwise fall back to the first tab.
for (const slug of Object.keys(byWorkspace)) {
const group = byWorkspace[slug];
const hasOldActive = group.tabs.some((t) => t.id === v1.activeTabId);
group.activeTabId = hasOldActive
? (v1.activeTabId as string)
: group.tabs[0].id;
}
// Active workspace: whichever group inherited the v1 activeTab, falling
// back to the first group we created (arbitrary but deterministic given
// Object.keys iteration order on string keys).
let activeWorkspaceSlug: string | null = null;
for (const slug of Object.keys(byWorkspace)) {
if (byWorkspace[slug].activeTabId === v1.activeTabId) {
activeWorkspaceSlug = slug;
break;
}
}
if (!activeWorkspaceSlug) {
activeWorkspaceSlug = Object.keys(byWorkspace)[0] ?? null;
}
return { activeWorkspaceSlug, byWorkspace };
}
// ---------------------------------------------------------------------------
// Selectors (convenience hooks)
// ---------------------------------------------------------------------------
/**
* Pure non-hook helper — useful from event handlers / effects that already
* need `.getState()`. For React subscriptions prefer the stable selectors
* below.
*/
export function getActiveTab(s: TabStore): Tab | null {
if (!s.activeWorkspaceSlug) return null;
const group = s.byWorkspace[s.activeWorkspaceSlug];
if (!group) return null;
return group.tabs.find((t) => t.id === group.activeTabId) ?? null;
}
/**
* The active workspace's tab group, or null when no workspace is active.
*
* Zustand compares selector returns with `Object.is`. Because `updateTab`
* / `updateTabHistory` replace the group object on every router tick
* (immutable update), this selector returns a new reference on every
* router event — that's fine for TabBar which needs to observe tab-list
* changes, but don't use this selector from components that only care
* about one primitive (use `useActiveTabHistory` / `useActiveTabRouter`
* instead).
*/
export function useActiveGroup(): WorkspaceTabGroup | null {
return useTabStore((s) =>
s.activeWorkspaceSlug ? (s.byWorkspace[s.activeWorkspaceSlug] ?? null) : null,
);
}
/**
* Active tab id + active workspace slug as a compact pair. Both primitives
* are stable across unrelated store updates — e.g. an inactive tab's
* router tick doesn't churn these, so consumers don't re-render.
*
* Useful anywhere you'd previously have reached for `useActiveTab()` and
* only needed the identity (for memoization, effect deps, ipc).
*/
export function useActiveTabIdentity(): { slug: string | null; tabId: string | null } {
const slug = useTabStore((s) => s.activeWorkspaceSlug);
const tabId = useTabStore((s) =>
s.activeWorkspaceSlug
? (s.byWorkspace[s.activeWorkspaceSlug]?.activeTabId ?? null)
: null,
);
return { slug, tabId };
}
/**
* Active tab's router — a stable reference across tab updates, because
* routers are created once per tab and never replaced by `updateTab`.
* Subscribers only re-render when the active tab *changes*, not on
* router events within the current tab.
*/
export function useActiveTabRouter(): DataRouter | null {
return useTabStore((s) => getActiveTab(s)?.router ?? null);
}
/**
* History tracking for the active tab as primitives. Subscribers re-render
* only when the numeric index / length change (i.e. on actual navigations),
* not on unrelated store updates.
*/
export function useActiveTabHistory(): {
historyIndex: number;
historyLength: number;
} {
const historyIndex = useTabStore((s) => getActiveTab(s)?.historyIndex ?? 0);
const historyLength = useTabStore((s) => getActiveTab(s)?.historyLength ?? 1);
return { historyIndex, historyLength };
}

View File

@@ -1,29 +0,0 @@
import { create } from "zustand";
/**
* Window-level transition overlay: pre-workspace flows that are NOT pages
* inside a tab. Triggered by navigation-adapter interception, zero-workspace
* auto-redirect, or deep link; rendered above the tab system as a full-window
* takeover.
*
* These flows used to be routes (`/workspaces/new`, `/invite/:id`) but on
* desktop the URL is invisible to users — routes are an implementation detail
* of the tab system. Representing transitions as routes meant tabs tried to
* persist them, TabBar rendered on top, and invite deep-linking had no clean
* dispatch target. Modeling them as application state removes all three.
*/
export type WindowOverlay =
| { type: "new-workspace" }
| { type: "invite"; invitationId: string };
interface WindowOverlayStore {
overlay: WindowOverlay | null;
open: (overlay: WindowOverlay) => void;
close: () => void;
}
export const useWindowOverlayStore = create<WindowOverlayStore>((set) => ({
overlay: null,
open: (overlay) => set({ overlay }),
close: () => set({ overlay: null }),
}));

View File

@@ -78,7 +78,7 @@ multica daemon status
Confirm:
1. Status is `running`
2. At least one agent is listed (e.g. `claude`, `codex`, `gemini`, `opencode`, `openclaw`, `hermes`, or `pi`)
2. At least one agent is listed (e.g. `claude`, `codex`, `gemini`, `opencode`, `openclaw`, or `hermes`)
3. At least one workspace is being watched
If the agents list is empty, install at least one supported AI agent CLI:

View File

@@ -212,7 +212,7 @@ multica issue list --priority urgent --assignee "Agent Name"
multica issue list --limit 20 --output json
```
Available filters: `--status`, `--priority`, `--assignee`, `--project`, `--limit`.
Available filters: `--status`, `--priority`, `--assignee`, `--limit`.
### Get Issue
@@ -227,7 +227,7 @@ multica issue get <id> --output json
multica issue create --title "Fix login bug" --description "..." --priority high --assignee "Lambda"
```
Flags: `--title` (required), `--description`, `--status`, `--priority`, `--assignee`, `--parent`, `--project`, `--due-date`.
Flags: `--title` (required), `--description`, `--status`, `--priority`, `--assignee`, `--parent`, `--due-date`.
### Update Issue
@@ -281,70 +281,6 @@ multica issue run-messages <task-id> --output json
multica issue run-messages <task-id> --since 42 --output json
```
## Projects
Projects group related issues (e.g. a sprint, an epic, a workstream). Every project
belongs to a workspace and can optionally have a lead (member or agent).
### List Projects
```bash
multica project list
multica project list --status in_progress
multica project list --output json
```
Available filters: `--status`.
### Get Project
```bash
multica project get <id>
multica project get <id> --output json
```
### Create Project
```bash
multica project create --title "2026 Week 16 Sprint" --icon "🏃" --lead "Lambda"
```
Flags: `--title` (required), `--description`, `--status`, `--icon`, `--lead`.
### Update Project
```bash
multica project update <id> --title "New title" --status in_progress
multica project update <id> --lead "Lambda"
```
Flags: `--title`, `--description`, `--status`, `--icon`, `--lead`.
### Change Status
```bash
multica project status <id> in_progress
```
Valid statuses: `planned`, `in_progress`, `paused`, `completed`, `cancelled`.
### Delete Project
```bash
multica project delete <id>
```
### Associating Issues with Projects
Use the `--project` flag on `issue create` / `issue update` to attach an issue to a
project, or on `issue list` to filter issues by project:
```bash
multica issue create --title "Login bug" --project <project-id>
multica issue update <issue-id> --project <project-id>
multica issue list --project <project-id>
```
## Configuration
### View Config

View File

@@ -45,7 +45,7 @@ Then configure, authenticate, and start the daemon:
multica setup
```
The daemon auto-detects available agent CLIs (`claude`, `codex`, `gemini`, `openclaw`, `opencode`, `hermes`, `pi`) on your PATH. When an agent is assigned a task, the daemon creates an isolated environment, runs the agent, and reports results back.
The daemon auto-detects available agent CLIs (`claude`, `codex`, `gemini`, `openclaw`, `opencode`, `hermes`) on your PATH. When an agent is assigned a task, the daemon creates an isolated environment, runs the agent, and reports results back.
## 3. Verify your runtime

View File

@@ -11,7 +11,7 @@ Once you have the CLI installed (or signed up for [Multica Cloud](https://multic
multica setup # Configure, authenticate, and start the daemon
```
This configures the CLI, opens your browser for login, discovers your workspaces, and starts the agent daemon in the background. It auto-detects agent CLIs (`claude`, `codex`, `gemini`, `openclaw`, `opencode`, `hermes`, `pi`) available on your PATH.
This configures the CLI, opens your browser for login, discovers your workspaces, and starts the agent daemon in the background. It auto-detects agent CLIs (`claude`, `codex`, `gemini`, `openclaw`, `opencode`, `hermes`) available on your PATH.
## 2. Verify your runtime

View File

@@ -2,10 +2,8 @@
import { useEffect } from "react";
import { useRouter, useParams } from "next/navigation";
import { useQuery } from "@tanstack/react-query";
import { useAuthStore } from "@multica/core/auth";
import { paths } from "@multica/core/paths";
import { workspaceListOptions } from "@multica/core/workspace/queries";
import { InvitePage } from "@multica/views/invite";
export default function InviteAcceptPage() {
@@ -13,10 +11,6 @@ export default function InviteAcceptPage() {
const params = useParams<{ id: string }>();
const user = useAuthStore((s) => s.user);
const isLoading = useAuthStore((s) => s.isLoading);
const { data: wsList = [] } = useQuery({
...workspaceListOptions(),
enabled: !!user,
});
// Redirect to login if not authenticated, with a redirect back to this page.
useEffect(() => {
@@ -29,8 +23,5 @@ export default function InviteAcceptPage() {
if (isLoading || !user) return null;
const onBack =
wsList.length > 0 ? () => router.push(paths.root()) : undefined;
return <InvitePage invitationId={params.id} onBack={onBack} />;
return <InvitePage invitationId={params.id} />;
}

View File

@@ -28,9 +28,8 @@ function LoginPageContent() {
// the user's workspace list.
const nextUrl = searchParams.get("next");
// Already authenticated — honor ?next= or fall back to first workspace
// (or /workspaces/new if the user has none). Skip this entire path when
// the user arrived to authorize the CLI.
// 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) return;
if (nextUrl) {
@@ -40,7 +39,7 @@ function LoginPageContent() {
const list = qc.getQueryData<Workspace[]>(workspaceKeys.list()) ?? [];
const [first] = list;
router.replace(
first ? paths.workspace(first.slug).issues() : paths.newWorkspace(),
first ? paths.workspace(first.slug).issues() : paths.onboarding(),
);
}, [isLoading, user, router, nextUrl, cliCallbackRaw, qc]);
@@ -54,7 +53,7 @@ function LoginPageContent() {
const list = qc.getQueryData<Workspace[]>(workspaceKeys.list()) ?? [];
const [first] = list;
router.push(
first ? paths.workspace(first.slug).issues() : paths.newWorkspace(),
first ? paths.workspace(first.slug).issues() : paths.onboarding(),
);
};

View File

@@ -0,0 +1,26 @@
"use client";
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() {
const router = useRouter();
const user = useAuthStore((s) => s.user);
const isLoading = useAuthStore((s) => s.isLoading);
// Redirect to login if not authenticated
useEffect(() => {
if (!isLoading && !user) router.replace(paths.login());
}, [isLoading, user, router]);
if (isLoading || !user) return null;
return (
<OnboardingWizard
onComplete={(ws) => router.push(paths.workspace(ws.slug).issues())}
/>
);
}

View File

@@ -1,38 +0,0 @@
"use client";
import { useRouter } from "next/navigation";
import { useEffect } from "react";
import { useQuery } from "@tanstack/react-query";
import { useAuthStore } from "@multica/core/auth";
import { paths } from "@multica/core/paths";
import { workspaceListOptions } from "@multica/core/workspace/queries";
import { NewWorkspacePage } from "@multica/views/workspace/new-workspace-page";
export default function Page() {
const router = useRouter();
const user = useAuthStore((s) => s.user);
const isLoading = useAuthStore((s) => s.isLoading);
const { data: wsList = [] } = useQuery({
...workspaceListOptions(),
enabled: !!user,
});
useEffect(() => {
if (!isLoading && !user) router.replace(paths.login());
}, [isLoading, user, router]);
if (isLoading || !user) return null;
// Back goes to the root path — the workspace layout redirects from
// there to the user's default workspace. Only show Back when there's
// somewhere to go back to (user already has at least one workspace).
const onBack =
wsList.length > 0 ? () => router.push(paths.root()) : undefined;
return (
<NewWorkspacePage
onSuccess={(ws) => router.push(paths.workspace(ws.slug).issues())}
onBack={onBack}
/>
);
}

View File

@@ -0,0 +1,28 @@
import { Skeleton } from "@multica/ui/components/ui/skeleton";
export default function DashboardLoading() {
return (
<div className="flex flex-1 min-h-0 flex-col">
{/* Header skeleton */}
<div className="flex h-12 shrink-0 items-center gap-2 border-b px-4">
<Skeleton className="h-5 w-5 rounded" />
<Skeleton className="h-4 w-32" />
</div>
{/* Toolbar skeleton */}
<div className="flex h-12 shrink-0 items-center justify-between border-b px-4">
<Skeleton className="h-5 w-24" />
<Skeleton className="h-8 w-24" />
</div>
{/* Content skeleton */}
<div className="flex-1 p-4 space-y-3">
{Array.from({ length: 6 }).map((_, i) => (
<div key={i} className="flex items-center gap-3">
<Skeleton className="h-4 w-4 rounded" />
<Skeleton className="h-4 flex-1 max-w-md" />
<Skeleton className="h-4 w-16" />
</div>
))}
</div>
</div>
);
}

View File

@@ -1,15 +1,15 @@
"use client";
import { use, useEffect } from "react";
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 } from "@multica/core/platform";
import {
setCurrentWorkspace,
rehydrateAllWorkspaceStores,
} from "@multica/core/platform";
import { useAuthStore } from "@multica/core/auth";
import { NoAccessPage } from "@multica/views/workspace/no-access-page";
import { MulticaIcon } from "@multica/ui/components/common/multica-icon";
import { useWorkspaceSeen } from "@multica/views/workspace/use-workspace-seen";
export default function WorkspaceLayout({
children,
@@ -23,14 +23,6 @@ export default function WorkspaceLayout({
const isAuthLoading = useAuthStore((s) => s.isLoading);
const router = useRouter();
// Workspace routes require auth. If user is unauthenticated (initial visit
// without a session, token expired, another tab logged out, etc.), bounce
// to /login. Without this, the layout renders null and the user sees a
// blank page stuck on /{slug}/...
useEffect(() => {
if (!isAuthLoading && !user) router.replace(paths.login());
}, [isAuthLoading, user, router]);
// 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({
@@ -38,50 +30,46 @@ export default function WorkspaceLayout({
enabled: !!user,
});
// Render-phase sync: feed the URL slug into the platform singleton so
// the first child query's X-Workspace-Slug header is already correct.
// setCurrentWorkspace self-dedupes + runs rehydrate as a side effect;
// safe to call on every render.
if (workspace) {
// 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
// to redirect unauthenticated-URL hits to the user's last workspace.
// 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]);
// Remember whether this slug has resolved before. Used below to avoid
// flashing NoAccessPage during active workspace removal (delete, leave,
// or realtime eviction) — in those cases the caller is navigating away
// and we just need to hold null briefly.
const hasBeenSeen = useWorkspaceSeen(workspaceSlug, !!workspace);
// 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]);
const loadingIndicator = (
<div className="flex h-svh items-center justify-center">
<MulticaIcon className="size-6 animate-pulse" />
</div>
);
if (isAuthLoading) return loadingIndicator;
// Don't render children until workspace is resolved. useWorkspaceId()
// throws when the list hasn't populated or the slug is unknown — gating
// here makes that invariant hold for every descendant.
if (!listFetched) return loadingIndicator;
if (!workspace) {
// If we've resolved this slug before in this session, it was just
// removed from our list (deleted/left/evicted). A navigate is almost
// certainly in flight — render null to avoid a NoAccessPage flash.
if (hasBeenSeen) return null;
// Otherwise: the URL points at a workspace the user never had access
// to. Show explicit feedback instead of silently redirecting. Doesn't
// distinguish 404 vs 403 to avoid letting attackers enumerate slugs.
return <NoAccessPage />;
}
// Auth still loading → render nothing (let DashboardGuard show its loader).
if (isAuthLoading) return null;
return (
<WorkspaceSlugProvider slug={workspaceSlug}>

View File

@@ -66,11 +66,11 @@ function CallbackContent() {
// 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 /workspaces/new for zero-workspace users.
// 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.newWorkspace();
: paths.onboarding();
router.push(nextUrl || defaultDest);
})
.catch((err) => {

View File

@@ -1,5 +1,6 @@
"use client";
import { useCallback, useState } from "react";
import Image from "next/image";
import Link from "next/link";
import { useAuthStore } from "@multica/core/auth";
@@ -10,6 +11,8 @@ import {
GeminiCliLogo,
OpenClawLogo,
OpenCodeLogo,
GitHubMark,
githubUrl,
heroButtonClassName,
} from "./shared";
@@ -42,35 +45,24 @@ export function LandingHero() {
{user ? t.header.dashboard : t.hero.cta}
</Link>
<Link
href="https://github.com/multica-ai/multica/releases/latest"
href={githubUrl}
target="_blank"
rel="noreferrer"
className={heroButtonClassName("ghost")}
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className="size-4"
aria-hidden="true"
>
<rect x="2" y="3" width="20" height="14" rx="2" ry="2" />
<line x1="8" y1="21" x2="16" y2="21" />
<line x1="12" y1="17" x2="12" y2="21" />
</svg>
{t.hero.downloadDesktop}
<GitHubMark className="size-4" />
GitHub
</Link>
</div>
<InstallCommand />
</div>
<div className="mt-10 flex flex-wrap items-center justify-center gap-x-6 gap-y-3">
<div className="mt-10 flex items-center justify-center gap-8">
<span className="text-[15px] text-white/50">
{t.hero.worksWith}
</span>
<div className="flex flex-wrap items-center justify-center gap-x-5 gap-y-3">
<div className="flex items-center gap-6">
<div className="flex items-center gap-2.5 text-white/80">
<ClaudeCodeLogo className="size-5" />
<span className="text-[15px] font-medium">Claude Code</span>
@@ -103,6 +95,64 @@ export function LandingHero() {
);
}
const INSTALL_COMMAND =
"curl -fsSL https://raw.githubusercontent.com/multica-ai/multica/main/scripts/install.sh | bash";
function InstallCommand() {
const [copied, setCopied] = useState(false);
const handleCopy = useCallback(async () => {
try {
await navigator.clipboard.writeText(INSTALL_COMMAND);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch {
// ignore
}
}, []);
return (
<div className="mx-auto mt-6 max-w-fit">
<button
type="button"
onClick={handleCopy}
className="group flex items-center gap-3 rounded-lg border border-white/10 bg-white/5 px-4 py-2.5 font-mono text-[13px] text-white/70 backdrop-blur-sm transition-colors hover:border-white/20 hover:bg-white/8 hover:text-white/90"
>
<span className="text-white/40">$</span>
<span className="select-all">{INSTALL_COMMAND}</span>
<span className="ml-1 flex size-5 shrink-0 items-center justify-center text-white/40 transition-colors group-hover:text-white/70">
{copied ? (
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className="size-3.5 text-green-400"
>
<polyline points="20 6 9 17 4 12" />
</svg>
) : (
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className="size-3.5"
>
<rect x="9" y="9" width="13" height="13" rx="2" ry="2" />
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" />
</svg>
)}
</span>
</button>
</div>
);
}
function LandingBackdrop() {
return (
<div className="pointer-events-none absolute inset-0">
@@ -110,6 +160,7 @@ function LandingBackdrop() {
src="/images/landing-bg.jpg"
alt=""
fill
priority
className="object-cover object-center"
/>
</div>
@@ -125,7 +176,6 @@ function ProductImage({ alt }: { alt: string }) {
alt={alt}
width={3532}
height={2382}
priority
className="block h-auto w-full"
sizes="(max-width: 1320px) 100vw, 1320px"
quality={85}

View File

@@ -16,7 +16,7 @@ import { paths } from "@multica/core/paths";
* 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 /workspaces/new if they have none).
* 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.
@@ -35,7 +35,7 @@ export function RedirectIfAuthenticated() {
if (isLoading || !user || !list) return;
const [first] = list;
if (!first) {
router.replace(paths.newWorkspace());
router.replace(paths.onboarding());
return;
}
router.replace(paths.workspace(first.slug).issues());

View File

@@ -14,7 +14,6 @@ export const en: LandingDict = {
subheading:
"Multica is an open-source platform that turns coding agents into real teammates. Assign tasks, track progress, compound skills \u2014 manage your human + agent workforce in one place.",
cta: "Start free trial",
downloadDesktop: "Download Desktop",
worksWith: "Works with",
imageAlt: "Multica board view \u2014 issues managed by humans and agents",
},
@@ -224,7 +223,6 @@ export const en: LandingDict = {
{ label: "Features", href: "#features" },
{ label: "How it Works", href: "#how-it-works" },
{ label: "Changelog", href: "/changelog" },
{ label: "Desktop", href: "https://github.com/multica-ai/multica/releases/latest" },
],
},
resources: {
@@ -279,27 +277,6 @@ export const en: LandingDict = {
fixes: "Bug Fixes",
},
entries: [
{
version: "0.2.1",
date: "2026-04-16",
title: "New Agent Runtimes",
changes: [],
features: [
"GitHub Copilot CLI runtime support",
"Cursor Agent CLI runtime support",
"Pi agent runtime support",
"Workspace URL refactor — slug-first routing (`/{slug}/issues`) with legacy URL redirects",
],
fixes: [
"Codex threads resume across tasks on the same issue",
"Codex turn errors surfaced instead of reporting empty output",
"Workspace usage correctly bucketed by task completion time",
"Autopilot run history rows fully clickable",
"Workspace isolation enforced on additional daemon and GC endpoints (security)",
"HTML-escape workspace and inviter names in invitation emails",
"Dev and production desktop instances can now coexist",
],
},
{
version: "0.2.0",
date: "2026-04-15",

View File

@@ -26,7 +26,6 @@ export type LandingDict = {
headlineLine2: string;
subheading: string;
cta: string;
downloadDesktop: string;
worksWith: string;
imageAlt: string;
};

View File

@@ -13,9 +13,8 @@ export const zh: LandingDict = {
headlineLine2: "\u4e0d\u662f\u4eba\u7c7b\u3002",
subheading:
"Multica \u662f\u4e00\u4e2a\u5f00\u6e90\u5e73\u53f0\uff0c\u5c06\u7f16\u7801 Agent \u53d8\u6210\u771f\u6b63\u7684\u961f\u53cb\u3002\u5206\u914d\u4efb\u52a1\u3001\u8ddf\u8e2a\u8fdb\u5ea6\u3001\u79ef\u7d2f\u6280\u80fd\u2014\u2014\u5728\u4e00\u4e2a\u5730\u65b9\u7ba1\u7406\u4f60\u7684\u4eba\u7c7b + Agent \u56e2\u961f\u3002",
cta: "免费开始",
downloadDesktop: "下载桌面端",
worksWith: "支持",
cta: "\u514d\u8d39\u5f00\u59cb",
worksWith: "\u652f\u6301",
imageAlt: "Multica \u770b\u677f\u89c6\u56fe\u2014\u2014\u4eba\u7c7b\u548c Agent \u534f\u540c\u7ba1\u7406\u4efb\u52a1",
},
@@ -223,8 +222,7 @@ export const zh: LandingDict = {
links: [
{ label: "\u529f\u80fd\u7279\u6027", href: "#features" },
{ label: "\u5982\u4f55\u5de5\u4f5c", href: "#how-it-works" },
{ label: "更新日志", href: "/changelog" },
{ label: "桌面端", href: "https://github.com/multica-ai/multica/releases/latest" },
{ label: "\u66f4\u65b0\u65e5\u5fd7", href: "/changelog" },
],
},
resources: {
@@ -279,27 +277,6 @@ export const zh: LandingDict = {
fixes: "问题修复",
},
entries: [
{
version: "0.2.1",
date: "2026-04-16",
title: "新增 Agent 运行时",
changes: [],
features: [
"支持 GitHub Copilot CLI 运行时",
"支持 Cursor Agent CLI 运行时",
"支持 Pi Agent 运行时",
"工作区 URL 改造——slug 优先路由(`/{slug}/issues`),旧链接自动重定向",
],
fixes: [
"Codex 同一 Issue 下跨任务恢复会话线程",
"Codex 回合错误正确抛出,不再报告空输出",
"工作区用量按任务完成时间正确分桶",
"Autopilot 运行历史行整行可点击",
"Daemon 和 GC 端点加强工作区隔离校验(安全)",
"邀请邮件中的工作区和邀请人名称进行 HTML 转义",
"桌面应用开发版和生产版现在可以同时运行",
],
},
{
version: "0.2.0",
date: "2026-04-15",

View File

@@ -1,107 +0,0 @@
# Codex sandbox troubleshooting (macOS `no such host`)
This doc explains the failure mode that caused [MUL-963][mul-963] and the
matrix the daemon now follows when writing Codex's per-task `config.toml`.
[mul-963]: https://multica-api.copilothub.ai/issues/28c34ad2-102a-4f46-91ac-336ed78c5859
## Symptom fingerprint
| Error text | Likely cause |
| ------------------------------------------------------------- | ------------------------------------------------------------------------------- |
| `dial tcp: lookup HOST: no such host` | **Codex Seatbelt sandbox blocking DNS** (macOS, `workspace-write` mode). |
| `dial tcp IP:PORT: connect: connection refused` | Server/daemon not running on that port (app-level, not sandbox). |
| `dial tcp IP:PORT: i/o timeout` | Container-level network policy or firewall (not Codex sandbox). |
| `x509: certificate signed by unknown authority` | TLS/CA issue, unrelated. |
If you see `no such host` *inside a Codex session on macOS* but `curl https://multica-api.copilothub.ai` from a plain shell on the same machine works, you are hitting the Seatbelt bug below.
## Root cause
Upstream issue: [openai/codex#10390][codex-10390]. On macOS, Codex's Seatbelt
profile for `sandbox_mode = "workspace-write"` silently ignores the
`[sandbox_workspace_write] network_access = true` setting. The seatbelt
policy hard-codes `CODEX_SANDBOX_NETWORK_DISABLED=1`, which blocks DNS/UDP
syscalls. Go's `net.LookupHost` surfaces that as `no such host`.
Linux (Landlock) is **not** affected — only macOS Seatbelt.
[codex-10390]: https://github.com/openai/codex/issues/10390
## What the daemon does now
The daemon writes a *multica-managed* block into each task's
`$CODEX_HOME/config.toml`, delimited by `# BEGIN multica-managed` /
`# END multica-managed` markers. Anything outside the markers is left
untouched so users can still tune Codex behavior.
Decision matrix (see [`server/internal/daemon/execenv/codex_sandbox.go`](../server/internal/daemon/execenv/codex_sandbox.go)):
| Host OS | Codex version | Managed block emits |
| --------- | ------------------------------------------------ | ------------------------------------------------------------------------- |
| non-darwin | any | `sandbox_mode = "workspace-write"` + `sandbox_workspace_write.network_access = true` (dotted-key form) |
| darwin | ≥ `CodexDarwinNetworkAccessFixedVersion` | same as above (upstream fix in effect) |
| darwin | older / unknown (current default) | `sandbox_mode = "danger-full-access"` + warn-level log |
The managed block is always hoisted to the top of `config.toml` and uses
TOML dotted-key syntax rather than a `[sandbox_workspace_write]` section
header. Both are load-bearing: if the block sat after a user table like
`[permissions.multica]`, a bare `sandbox_mode = "..."` line would be parsed
as `permissions.multica.sandbox_mode` and Codex would silently ignore it.
`CodexDarwinNetworkAccessFixedVersion` is an empty string today, meaning *no
known fixed release yet*. Bump it once a tagged Codex release includes the
upstream fix.
When the daemon falls back to `danger-full-access`, it logs at `WARN`:
```
codex sandbox: falling back to danger-full-access on macOS
reason=codex on macOS: seatbelt ignores sandbox_workspace_write.network_access (openai/codex#10390) ...
codex_version=0.121.0
hint=upgrade Codex CLI (e.g. `brew upgrade codex` or `npm i -g @openai/codex`) ...
config_path=/.../codex-home/config.toml
```
## Quick self-check commands
From the host shell (outside the sandbox):
```bash
# Is the Multica API reachable at all?
curl -sSf https://multica-api.copilothub.ai/healthz
```
From inside a Codex session (after the daemon writes its config):
```bash
multica issue list --limit 1 --output json >/dev/null && echo OK
```
If the host curl works but the Codex-session call fails with `no such host`,
the sandbox is the culprit; confirm the daemon picked the right policy by
looking at the managed block in `$CODEX_HOME/config.toml`.
## Options and trade-offs
- **A. Domain-scoped `permissions` profile** (tight): when the upstream
`network_access` fix is available, prefer writing a `permissions.multica`
profile that allows only `multica-api.copilothub.ai` and
`multica-static.copilothub.ai`. Keeps filesystem sandbox intact.
- **B. `danger-full-access`** (current macOS fallback): drops the whole
Seatbelt profile. Simplest reliable workaround until the upstream fix is
released.
- **C. Upgrade Codex CLI**: `brew upgrade codex` or `npm i -g @openai/codex`.
Once a release containing [openai/codex#10390][codex-10390] is installed,
bump `CodexDarwinNetworkAccessFixedVersion` in `codex_sandbox.go` and
option A/the workspace-write path takes over automatically.
## If you need to hand-verify
```bash
# Inspect the managed block the daemon wrote for a given task.
sed -n '/# BEGIN multica-managed/,/# END multica-managed/p' \
~/multica_workspaces/$WORKSPACE_ID/$TASK_SHORT/codex-home/config.toml
```
The block is idempotent — re-running a task rewrites it in place.

View File

@@ -1,357 +0,0 @@
# Unify Workspace Identity Resolver Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
**Goal:** Fix broken file uploads caused by the workspace slug refactor (v2, PR #1138/#1141), and eliminate the structural bug source that allowed it. File uploads from within a workspace on the desktop and web apps currently land in S3 without a corresponding DB attachment record — the file is orphaned and the UI never sees it.
**Architecture:** The server currently has **two independent implementations** of the same logic — extract the workspace UUID from an HTTP request. One lives in the workspace middleware (post-v2, accepts slug header → DB lookup → UUID). The other lives inside the handler package (pre-v2, only accepts UUID header/query). The v2 refactor updated the middleware one and forgot the handler one; routes that sit *outside* the workspace middleware group (notably `/api/upload-file`) still run through the stale resolver and can't translate the frontend's new `X-Workspace-Slug` header.
The root cause is duplication. The fix is to collapse both resolvers into a single shared function that middleware and handlers both delegate to, so any future change to "how do we read workspace identity" is impossible to forget. The existing middleware's resolver already has the full logic; we extract it into a package-level function and have the handler helper call it.
**Tech Stack:** Go (Chi router, sqlc, pgx).
**Non-goals:**
- No frontend changes. The frontend has been sending `X-Workspace-Slug` since v2; this plan makes the server finish accepting it everywhere.
- No route reshuffling. `/api/upload-file` stays outside `RequireWorkspaceMember` because it serves two distinct use cases (avatar upload + workspace attachment); the avatar path needs to work without a workspace context.
- No change to CLI / daemon clients. They still send `X-Workspace-ID` (UUID); the resolver keeps UUID as a fallback.
---
## Overview
| # | Change | Type | Files |
|---|--------|------|-------|
| 1 | Extract shared resolver into middleware package | Refactor | `server/internal/middleware/workspace.go` |
| 2 | Promote handler `resolveWorkspaceID` to `(h *Handler).resolveWorkspaceID` + delegate to shared | Refactor | `server/internal/handler/handler.go` |
| 3 | Rename 47 call sites from `resolveWorkspaceID(r)``h.resolveWorkspaceID(r)` | Mechanical | handler/*.go (see exhaustive list in task 3) |
| 4 | Add test for upload-file with slug header | Test | `server/internal/handler/file_test.go` |
| 5 | Add test for shared resolver | Test | `server/internal/middleware/workspace_test.go` |
| 6 | `make check` and commit | Verify | — |
---
## Background: what's broken and why
**Frontend (current, post-v2):** `ApiClient.authHeaders()` in `packages/core/api/client.ts:121` sends:
```
X-Workspace-Slug: <slug>
```
**Server middleware resolver** (`server/internal/middleware/workspace.go:53-86`, `resolveWorkspaceUUID`): accepts the slug header, looks up the slug via `queries.GetWorkspaceBySlug`, and writes the resolved UUID into the request context. Every handler behind `RequireWorkspaceMember` / `RequireWorkspaceRole` / `RequireWorkspaceMemberFromURL` sees the UUID in context and works correctly.
**Handler resolver** (`server/internal/handler/handler.go:155-165`, `resolveWorkspaceID`): a parallel implementation used by handlers that are NOT behind the workspace middleware. It only checks:
1. `middleware.WorkspaceIDFromContext(r.Context())`
2. `?workspace_id` query param
3. `X-Workspace-ID` header
Never touches slug, because it has no `*db.Queries` access (it's a package-level function, not a method).
**Impact:** `/api/upload-file` (registered at `server/cmd/server/router.go:166`, in the user-scoped group, outside workspace middleware) calls `resolveWorkspaceID(r)`, gets `""` because the frontend only sends slug, thinks "no workspace context", and silently skips the DB attachment record creation (`server/internal/handler/file.go:235-245`). The file reaches S3; the UI never sees it.
**Why `/api/upload-file` is outside workspace middleware:** it serves both "avatar upload (no workspace)" and "attachment upload (with workspace)", branching on the resolved workspace ID inside the handler. Moving it under `RequireWorkspaceMember` would break avatar uploads.
**Structural root cause:** two resolvers, same job, divergent capabilities. The duplication is what let v2 ship "mostly working" — most handlers live behind middleware, so the broken handler resolver had a low blast radius that wasn't caught in review.
---
### Task 1: Extract shared resolver into middleware package
**Problem:** The middleware's `resolveWorkspaceUUID` closure captures `*db.Queries` and can look up slugs. The handler's `resolveWorkspaceID` is a bare package-level function without queries access. We need a single implementation both sides can reuse. Putting it in the `middleware` package is fine — the `handler` package already imports `middleware`.
**Files:**
- Modify: `server/internal/middleware/workspace.go`
**Step 1: Add `ResolveWorkspaceIDFromRequest` export**
After `errWorkspaceNotFound` (around line 45), add a package-level exported function that takes `(r *http.Request, queries *db.Queries)` and returns the workspace UUID as a string (empty if none found or slug doesn't resolve).
Priority order (mirrors `resolveWorkspaceUUID`, plus a context lookup first so handlers behind middleware still get the fast path):
```go
// ResolveWorkspaceIDFromRequest returns the workspace UUID for an HTTP
// request, using the same priority order as the workspace middleware.
// Handlers behind workspace middleware get it from context (cheap); handlers
// outside middleware (e.g. /api/upload-file) still resolve slug → UUID via
// a DB lookup instead of silently falling through to "no workspace".
//
// Priority:
// 1. middleware-injected context (if the route is behind workspace middleware)
// 2. X-Workspace-Slug header → GetWorkspaceBySlug → UUID (post-refactor frontend)
// 3. ?workspace_slug query → GetWorkspaceBySlug → UUID
// 4. X-Workspace-ID header (CLI/daemon compat)
// 5. ?workspace_id query (CLI/daemon compat)
//
// Returns "" when no identifier was provided OR a slug was provided but doesn't
// resolve to any workspace. Callers that need the "slug provided but invalid"
// distinction should use the resolver inside the middleware directly.
func ResolveWorkspaceIDFromRequest(r *http.Request, queries *db.Queries) string {
if id := WorkspaceIDFromContext(r.Context()); id != "" {
return id
}
if slug := r.Header.Get("X-Workspace-Slug"); slug != "" {
if ws, err := queries.GetWorkspaceBySlug(r.Context(), slug); err == nil {
return util.UUIDToString(ws.ID)
}
}
if slug := r.URL.Query().Get("workspace_slug"); slug != "" {
if ws, err := queries.GetWorkspaceBySlug(r.Context(), slug); err == nil {
return util.UUIDToString(ws.ID)
}
}
if id := r.Header.Get("X-Workspace-ID"); id != "" {
return id
}
return r.URL.Query().Get("workspace_id")
}
```
**Step 2: Refactor `resolveWorkspaceUUID` to delegate**
The existing middleware closure has slightly different semantics (returns `errWorkspaceNotFound` when a slug was provided but doesn't resolve, so middleware can 404 instead of 400). Keep that, but share the resolution logic:
Leave `resolveWorkspaceUUID` as-is for now — it distinguishes "no identifier" (400) from "invalid slug" (404). `ResolveWorkspaceIDFromRequest` returns "" in both cases because handler-level callers don't need that distinction (they just check for empty).
Document in a comment near `resolveWorkspaceUUID` that it's an internal variant that preserves the error distinction for middleware gating, and point to `ResolveWorkspaceIDFromRequest` as the handler-facing API.
**Step 3: Build and verify**
```bash
cd server && go build ./...
```
Expected: clean build.
**Step 4: Commit**
```
refactor(server): extract ResolveWorkspaceIDFromRequest from middleware
Introduces a shared helper that consolidates the workspace-identity
resolution logic used by both the workspace middleware and the handler
package. No behavior change yet — callers still use the old functions.
Sets up the next commit to fix the /api/upload-file slug bug by routing
the handler-side resolver through this shared function.
```
---
### Task 2: Promote handler resolver to a method + delegate
**Problem:** The package-level `resolveWorkspaceID(r *http.Request)` in `handler.go` can't call `GetWorkspaceBySlug` because it has no queries access. Promoting it to a method on `*Handler` gives it access to `h.Queries` at no syntactic cost elsewhere.
**Files:**
- Modify: `server/internal/handler/handler.go:155-165`
**Step 1: Replace `resolveWorkspaceID` with a Handler method**
```go
// resolveWorkspaceID resolves the workspace UUID for this request.
// Delegates to middleware.ResolveWorkspaceIDFromRequest so routes inside
// and outside workspace middleware see identical resolution behavior.
//
// Returns "" when no workspace identifier was provided or a slug was
// provided but doesn't match any workspace.
func (h *Handler) resolveWorkspaceID(r *http.Request) string {
return middleware.ResolveWorkspaceIDFromRequest(r, h.Queries)
}
```
Delete the old package-level `resolveWorkspaceID` function.
**Step 2: Build — expect errors at 47 call sites**
```bash
cd server && go build ./... 2>&1 | head -60
```
Expected: `resolveWorkspaceID is not a value` or `undefined: resolveWorkspaceID` errors at each existing call site. That's the signal to run Task 3.
**Do not commit yet.** Task 2 and 3 are a single logical change; they commit together after Task 3 fixes the compile.
---
### Task 3: Rename 47 call sites to `h.resolveWorkspaceID(r)`
**Problem:** Every `resolveWorkspaceID(r)` call in the handler package now fails to compile because the function became a method. All 47 call sites are inside methods on `*Handler` (or similar receiver types that have access to `h`), so the rename is mechanical.
**Files affected** (verified via `grep -rn "resolveWorkspaceID" server/internal/handler/`):
- `server/internal/handler/handler.go:275, 365, 388` (3 sites)
- `server/internal/handler/issue.go:447, 559, 731, 783, 1294, 1476` (6 sites)
- `server/internal/handler/activity.go:133` (1 site)
- `server/internal/handler/autopilot.go:178, 203, 255, 306, 386, 414, 490, 578, 615, 662` (10 sites)
- `server/internal/handler/project.go:80, 127, 150, 192, 273, 430` (6 sites)
- `server/internal/handler/comment.go:443, 510` (2 sites)
- `server/internal/handler/runtime.go:207, 247, 296` (3 sites)
- `server/internal/handler/pin.go:59, 105, 175, 202` (4 sites)
- `server/internal/handler/reaction.go:43, 110` (2 sites)
- `server/internal/handler/skill.go:126, 146, 187, 384, 815` (5 sites)
- `server/internal/handler/agent.go:158, 254` (2 sites)
- `server/internal/handler/file.go:83, 115, 282, 306` (4 sites)
Total: 48 (the resolver declaration itself + 47 callers).
**Step 1: Mechanical rename**
For each file above, change every `resolveWorkspaceID(r)` to `h.resolveWorkspaceID(r)`. In the one case in `file.go:83` inside `groupAttachments`, the receiver is already `*Handler`, so the method is accessible.
**Semantic check:** all 47 call sites are on methods with an `h *Handler` receiver (verifiable by scrolling up a few lines from each grep match). If any call site is inside a non-method function, that site needs to either take `*Handler` as a parameter or be skipped from this rename. Spot-check three sites before doing the rename.
**Step 2: Build**
```bash
cd server && go build ./...
```
Expected: clean build.
**Step 3: Run Go tests**
```bash
cd server && go test ./...
```
Expected: all pass. The 46 call sites behind workspace middleware hit the context branch (identical behavior to before). Only `UploadFile` gains new capability (slug resolution); it wasn't tested before, will be covered in Task 4.
**Step 4: Commit**
```
fix(server): resolve X-Workspace-Slug in /api/upload-file and other middleware-less handlers
The v2 workspace URL refactor updated the workspace middleware to accept
X-Workspace-Slug but left the handler-package resolveWorkspaceID helper
(used by handlers outside the middleware group) stuck on X-Workspace-ID.
The frontend switched to the slug header, so /api/upload-file was
receiving a slug it couldn't translate to a UUID, silently falling
through to the avatar-upload branch and skipping DB attachment record
creation — files were landing in S3 with no database reference.
Promote resolveWorkspaceID to a Handler method and delegate to the new
middleware.ResolveWorkspaceIDFromRequest so middleware-behind and
middleware-outside handlers share the same resolution logic. The 46
call sites that live inside the workspace middleware group are
unaffected (context lookup still wins). /api/upload-file now correctly
recognizes slug requests and creates the attachment record.
Fixes: missing DB attachment rows for files uploaded since v2 (#1141)
```
---
### Task 4: Add handler test for upload-file with slug header
**Problem:** The bug manifested exactly because there was no test covering the "upload-file with only a slug header" code path. Prevent regression.
**Files:**
- Modify: `server/internal/handler/file_test.go` (or create if absent)
**Step 1: Locate existing upload-file test infrastructure**
```bash
grep -rn "UploadFile\|upload-file" server/internal/handler/*_test.go
```
If there's an existing upload-file test, add a new test case alongside it. If not, scaffold one using the same `handler_test.go` fixture pattern (`testWorkspaceID`, `testUserID`, seeded workspace).
**Step 2: Write the test**
Test name: `TestUploadFile_ResolvesWorkspaceViaSlugHeader`.
Flow:
1. Seed a workspace with a known slug and the default test user as a member.
2. POST a multipart form to `/api/upload-file` with an `issue_id` field referencing a seeded issue, with only `X-Workspace-Slug: <slug>` in headers (no `X-Workspace-ID`).
3. Assert response is 200.
4. Assert a DB row exists in `attachments` with the expected `workspace_id`, `uploader_id`, `issue_id`, and `filename`.
Anti-regression: also add `TestUploadFile_ResolvesWorkspaceViaIDHeaderStill` to confirm legacy `X-Workspace-ID` header still works (CLI / daemon compat).
**Step 3: Run the new test**
```bash
cd server && go test ./internal/handler/ -run UploadFile
```
Expected: both pass.
**Step 4: Commit**
```
test(server): cover upload-file slug and UUID header resolution
Regression test for the v2 refactor bug: uploads from the frontend
(which sends X-Workspace-Slug) now reach the workspace-aware branch
and create attachment records.
```
---
### Task 5: Add unit test for the shared resolver
**Problem:** The shared function will be the single point through which all workspace identity resolution flows. It deserves table-driven test coverage for each priority level.
**Files:**
- Create or modify: `server/internal/middleware/workspace_test.go`
**Step 1: Table test**
Cases to cover:
- Context UUID present → returns context UUID, ignores headers/query
- Only `X-Workspace-Slug` → DB lookup succeeds → returns UUID
- Only `X-Workspace-Slug` → DB lookup fails → returns ""
- Only `?workspace_slug` → DB lookup succeeds → returns UUID
- Only `X-Workspace-ID` → returns UUID
- Only `?workspace_id` → returns UUID
- Slug header + UUID header both present → slug wins (frontend priority)
- Nothing → returns ""
**Step 2: Run**
```bash
cd server && go test ./internal/middleware/ -run ResolveWorkspaceIDFromRequest
```
Expected: all cases pass.
**Step 3: Commit**
```
test(server): table-driven coverage for ResolveWorkspaceIDFromRequest
Pins down the priority order (context > slug header > slug query >
UUID header > UUID query) so future changes can't silently diverge.
```
---
### Task 6: Full verification
**Step 1: `make check`**
```bash
make check
```
Expected: typecheck, TS tests, Go tests, E2E (if backend+frontend up) all green.
**Step 2: Manual smoke test**
1. Start desktop dev environment.
2. Open an issue, attach a file via drag-and-drop or the file picker.
3. Refresh the issue. The attachment should appear in the attachments list.
Before this fix: attachment silently disappears on refresh (file is in S3, DB has no row).
**Step 3: Open PR**
Branch name: `fix/unify-workspace-identity-resolver`.
Title: `fix(server): resolve X-Workspace-Slug in middleware-less handlers`
Body should:
- Link to the symptom PR (v2 refactor #1141) and reference that it's a latent follow-up.
- Describe the structural change (two resolvers → one).
- Note that 46 of 47 call sites see zero behavior change (context branch wins); only `/api/upload-file` gains capability.
---
## Risk / blast radius
**Low risk.** The 46 middleware-protected callers hit the context branch in `ResolveWorkspaceIDFromRequest` identically to how they hit `WorkspaceIDFromContext` before — zero semantic change. The only new code path exercised in production is the slug-header branch for `/api/upload-file`, which is already exercised by every other slug-header-carrying request (just via the middleware's version of the same logic). Task 4 and 5 lock the behavior down with tests.
## Rollback plan
If a regression surfaces after deploy, revert the single commit from Task 3. `ResolveWorkspaceIDFromRequest` and the Handler method remain but are unused — harmless dead code until the next attempt.

View File

@@ -1,93 +0,0 @@
import { describe, expect, it, vi } from "vitest";
import type { ApiClient } from "../api/client";
import { ApiError } from "../api/client";
import type { StorageAdapter, User } from "../types";
import { createAuthStore } from "./store";
const fakeUser: User = {
id: "u1",
name: "Alice",
email: "alice@example.com",
avatar_url: null,
} as User;
function makeStorage(initial: Record<string, string> = {}): StorageAdapter & {
snapshot: () => Record<string, string>;
} {
const data = { ...initial };
return {
getItem: (k) => data[k] ?? null,
setItem: (k, v) => {
data[k] = v;
},
removeItem: (k) => {
delete data[k];
},
snapshot: () => ({ ...data }),
};
}
function makeApi(getMe: () => Promise<User>): ApiClient {
return {
setToken: vi.fn(),
getMe,
// Only the methods touched by store.initialize are needed. Cast to
// ApiClient for type compatibility — the store treats it opaquely.
} as unknown as ApiClient;
}
describe("authStore.initialize — token mode", () => {
it("keeps the stored token when getMe fails with a non-401 ApiError (e.g. 500)", async () => {
const storage = makeStorage({ multica_token: "t" });
const api = makeApi(() =>
Promise.reject(new ApiError("server error", 500, "Internal Server Error")),
);
const store = createAuthStore({ api, storage });
await store.getState().initialize();
expect(store.getState().user).toBeNull();
expect(store.getState().isLoading).toBe(false);
expect(storage.snapshot().multica_token).toBe("t");
});
it("keeps the stored token on a network failure (non-ApiError throw)", async () => {
const storage = makeStorage({ multica_token: "t" });
const api = makeApi(() => Promise.reject(new TypeError("fetch failed")));
const store = createAuthStore({ api, storage });
await store.getState().initialize();
expect(store.getState().user).toBeNull();
expect(storage.snapshot().multica_token).toBe("t");
});
it("on 401, leaves storage cleanup to ApiClient.onUnauthorized and resets state", async () => {
// Simulate the real path: ApiClient fires onUnauthorized on 401, which
// removes the token from storage. The store's catch block must not
// duplicate or short-circuit this — it should only reset in-memory
// auth state.
const storage = makeStorage({ multica_token: "t" });
const api = makeApi(() => {
storage.removeItem("multica_token"); // stand-in for onUnauthorized
return Promise.reject(new ApiError("unauthorized", 401, "Unauthorized"));
});
const store = createAuthStore({ api, storage });
await store.getState().initialize();
expect(store.getState().user).toBeNull();
expect(storage.snapshot().multica_token).toBeUndefined();
});
it("populates user when getMe succeeds", async () => {
const storage = makeStorage({ multica_token: "t" });
const api = makeApi(() => Promise.resolve(fakeUser));
const store = createAuthStore({ api, storage });
await store.getState().initialize();
expect(store.getState().user).toEqual(fakeUser);
expect(storage.snapshot().multica_token).toBe("t");
});
});

View File

@@ -1,6 +1,6 @@
import { create } from "zustand";
import type { User, StorageAdapter } from "../types";
import { ApiError, type ApiClient } from "../api/client";
import type { ApiClient } from "../api/client";
import { setCurrentWorkspace } from "../platform/workspace-storage";
export interface AuthStoreOptions {
@@ -57,17 +57,10 @@ export function createAuthStore(options: AuthStoreOptions) {
try {
const user = await api.getMe();
set({ user, isLoading: false });
} catch (err) {
// Only clear the stored token on a genuine auth failure (401). For
// transient errors — network blips, backend rolling restarts, 5xx,
// aborted fetches — keep the token so the next initialize() (next
// page load or focus-refresh) can retry. The 401 path's token
// cleanup is handled upstream by ApiClient.handleUnauthorized via
// the onUnauthorized callback; we only need to reset the in-memory
// user + workspace state here.
if (err instanceof ApiError && err.status === 401) {
setCurrentWorkspace(null, null);
}
} catch {
api.setToken(null);
setCurrentWorkspace(null, null);
storage.removeItem("multica_token");
set({ user: null, isLoading: false });
}
},

View File

@@ -3,7 +3,6 @@ import { useMutation, useQueryClient } from "@tanstack/react-query";
import { api } from "../api";
import { issueKeys, CLOSED_PAGE_SIZE, type MyIssuesFilter } from "./queries";
import { useWorkspaceId } from "../hooks";
import { useRecentIssuesStore } from "./stores";
import type { Issue, IssueReaction } from "../types";
import type {
CreateIssueRequest,
@@ -95,9 +94,6 @@ export function useCreateIssue() {
}
: old,
);
// Surface the just-created issue in cmd+k's Recent list without
// requiring the user to open it first.
useRecentIssuesStore.getState().recordVisit(newIssue.id);
// Invalidate parent's children query so sub-issues list updates immediately
if (newIssue.parent_issue_id) {
qc.invalidateQueries({ queryKey: issueKeys.children(wsId, newIssue.parent_issue_id) });

View File

@@ -1,46 +0,0 @@
import { create } from "zustand";
import { createJSONStorage, persist } from "zustand/middleware";
import { createWorkspaceAwareStorage, registerForWorkspaceRehydration } from "../../platform/workspace-storage";
import { defaultStorage } from "../../platform/storage";
/**
* Tracks which comments are collapsed, keyed by issue ID.
* Only collapsed comment IDs are stored — expanded is the default state.
*/
interface CommentCollapseStore {
collapsedByIssue: Record<string, string[]>;
isCollapsed: (issueId: string, commentId: string) => boolean;
toggle: (issueId: string, commentId: string) => void;
}
export const useCommentCollapseStore = create<CommentCollapseStore>()(
persist(
(set, get) => ({
collapsedByIssue: {},
isCollapsed: (issueId, commentId) => {
const ids = get().collapsedByIssue[issueId];
return ids ? ids.includes(commentId) : false;
},
toggle: (issueId, commentId) =>
set((s) => {
const current = s.collapsedByIssue[issueId] ?? [];
const isCurrentlyCollapsed = current.includes(commentId);
if (isCurrentlyCollapsed) {
const next = current.filter((id) => id !== commentId);
if (next.length === 0) {
const { [issueId]: _, ...rest } = s.collapsedByIssue;
return { collapsedByIssue: rest };
}
return { collapsedByIssue: { ...s.collapsedByIssue, [issueId]: next } };
}
return { collapsedByIssue: { ...s.collapsedByIssue, [issueId]: [...current, commentId] } };
}),
}),
{
name: "multica_comment_collapse",
storage: createJSONStorage(() => createWorkspaceAwareStorage(defaultStorage)),
},
),
);
registerForWorkspaceRehydration(() => useCommentCollapseStore.persist.rehydrate());

View File

@@ -7,7 +7,6 @@ export {
useViewStoreApi,
} from "./view-store-context";
export { useIssuesScopeStore, type IssuesScope } from "./issues-scope-store";
export { useCommentCollapseStore } from "./comment-collapse-store";
export {
myIssuesViewStore,
type MyIssuesViewState,

View File

@@ -2,7 +2,7 @@
import { create } from "zustand";
type ModalType = "create-workspace" | "create-issue" | "create-project" | null;
type ModalType = "create-workspace" | "create-issue" | null;
interface ModalStore {
modal: ModalType;

View File

@@ -5,13 +5,13 @@ import { describe, it, expect } from "vitest";
// 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, /workspaces/new, /invite/, /auth/, /logout, /signup", async () => {
it("does not persist /login, /onboarding, /invite/, /auth/, /logout, /signup", async () => {
const { useNavigationStore } = await import("./store");
const globalPrefixes = [
"/login",
"/logout",
"/signup",
"/workspaces/new",
"/onboarding",
"/invite/abc",
"/auth/callback",
];

View File

@@ -10,13 +10,13 @@ import { defaultStorage } from "../platform/storage";
// Paths that should not be persisted as "last visited":
// - Auth flows (/login, /signup, /logout)
// - Pre-workspace routes (/workspaces/new, /auth/, /invite/)
// - Pre-workspace routes (/onboarding, /auth/, /invite/)
// - Pair flow (/pair/)
const EXCLUDED_PREFIXES = [
"/login",
"/signup",
"/logout",
"/workspaces/",
"/onboarding",
"/auth/",
"/invite/",
"/pair/",

View File

@@ -66,7 +66,7 @@ describe("global path / reserved slug consistency", () => {
"/login",
"/logout",
"/signup",
"/workspaces/",
"/onboarding",
"/invite/",
"/auth/",
];

View File

@@ -27,7 +27,7 @@ describe("paths.workspace(slug)", () => {
describe("paths (global)", () => {
it("builds global paths without slug", () => {
expect(paths.login()).toBe("/login");
expect(paths.newWorkspace()).toBe("/workspaces/new");
expect(paths.onboarding()).toBe("/onboarding");
expect(paths.invite("inv-1")).toBe("/invite/inv-1");
expect(paths.authCallback()).toBe("/auth/callback");
});
@@ -36,7 +36,7 @@ describe("paths (global)", () => {
describe("isGlobalPath", () => {
it("returns true for pre-workspace routes", () => {
expect(isGlobalPath("/login")).toBe(true);
expect(isGlobalPath("/workspaces/new")).toBe(true);
expect(isGlobalPath("/onboarding")).toBe(true);
expect(isGlobalPath("/invite/abc")).toBe(true);
expect(isGlobalPath("/auth/callback")).toBe(true);
});

View File

@@ -4,7 +4,7 @@
*
* Two kinds of paths:
* - workspace-scoped: paths.workspace(slug).xxx() — carry workspace in URL
* - global: paths.login(), paths.newWorkspace(), paths.invite(id) — pre-workspace routes
* - 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
@@ -38,7 +38,7 @@ export const paths = {
// Global (pre-workspace) routes
login: () => "/login",
newWorkspace: () => "/workspaces/new",
onboarding: () => "/onboarding",
invite: (id: string) => `/invite/${encode(id)}`,
authCallback: () => "/auth/callback",
root: () => "/",
@@ -48,9 +48,7 @@ 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.
// Note: `/workspaces/` (trailing slash) is the prefix — `workspaces` is reserved,
// so any path starting with `/workspaces/...` is system-owned, not user-owned.
const GLOBAL_PREFIXES = ["/login", "/workspaces/", "/invite/", "/auth/", "/logout", "/signup"];
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

@@ -1,53 +1,30 @@
/**
* Slugs reserved because they collide with frontend top-level routes,
* platform features, or web standards.
*
* Slugs reserved because they collide with frontend top-level routes.
* Keep in sync with server/internal/handler/workspace_reserved_slugs.go.
*
* Convention for new global routes (CLAUDE.md): use a single word
* (`/login`, `/inbox`) or `/{noun}/{verb}` (`/workspaces/new`). Hyphenated
* root-level word groups (`/new-workspace`, `/create-team`) collide with
* common user workspace names — see PR for full discussion.
*/
export const RESERVED_SLUGS = new Set([
// Auth flow
// Auth + onboarding
"login",
"logout",
"signin",
"signout",
"signup",
"auth",
"oauth",
"callback",
"onboarding",
"invite",
"verify",
"reset",
"password",
"onboarding", // historical, kept reserved post-removal
"auth",
// Platform / marketing routes (current + likely-future)
// Reserved for future platform routes
"api",
"admin",
"help",
"about",
"pricing",
"changelog",
"docs",
"support",
"status",
"legal",
"privacy",
"terms",
"security",
"contact",
"blog",
"careers",
"press",
"download",
// Dashboard / workspace route segments. Reserving the segment name
// prevents `/{slug}/{view}` from being visually ambiguous (e.g. a
// workspace named "issues" makes `/issues/abc` mean two things).
// 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",
@@ -57,29 +34,8 @@ export const RESERVED_SLUGS = new Set([
"runtimes",
"skills",
"settings",
"workspaces", // global `/workspaces/new` workspace creation page
"teams", // reserved for future team management routes
// RFC 2142 — privileged email mailboxes. Allowing user workspaces with
// these slugs would let attackers spoof system messaging.
"postmaster",
"abuse",
"noreply",
"webmaster",
"hostmaster",
// Hostname / subdomain confusables. Even on path-based routing these
// names attract phishing and subdomain-takeover attempts.
"mail",
"ftp",
"static",
"cdn",
"assets",
"public",
"files",
"uploads",
// Next.js / web standards (framework-mandated)
// Next.js / hosting internals
"_next",
"favicon.ico",
"robots.txt",

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, setCurrentWorkspace, getCurrentSlug, getCurrentWsId, subscribeToCurrentSlug, registerForWorkspaceRehydration } from "./workspace-storage";
export { createWorkspaceAwareStorage, setCurrentWorkspace, getCurrentSlug, getCurrentWsId, subscribeToCurrentSlug, registerForWorkspaceRehydration, rehydrateAllWorkspaceStores } from "./workspace-storage";
export { clearWorkspaceStorage } from "./storage-cleanup";

View File

@@ -1,9 +1,5 @@
import { describe, it, expect, vi, afterEach } from "vitest";
import {
createWorkspaceAwareStorage,
setCurrentWorkspace,
registerForWorkspaceRehydration,
} from "./workspace-storage";
import { createWorkspaceAwareStorage, setCurrentWorkspace } from "./workspace-storage";
import type { StorageAdapter } from "../types/storage";
function mockAdapter(): StorageAdapter {
@@ -63,57 +59,3 @@ describe("workspace-aware storage", () => {
expect(adapter.removeItem).toHaveBeenCalledWith("draft:dev");
});
});
describe("setCurrentWorkspace — rehydrate side effect", () => {
const flush = () => new Promise((resolve) => queueMicrotask(() => resolve(null)));
it("runs registered fns once when slug changes", async () => {
const fn = vi.fn();
registerForWorkspaceRehydration(fn);
setCurrentWorkspace("team-a", "ws_a");
await flush();
expect(fn).toHaveBeenCalledTimes(1);
});
it("is a no-op when slug is unchanged — repeat calls with same slug skip the side effect", async () => {
const fn = vi.fn();
registerForWorkspaceRehydration(fn);
setCurrentWorkspace("team-a", "ws_a");
await flush();
setCurrentWorkspace("team-a", "ws_a");
setCurrentWorkspace("team-a", "ws_a");
setCurrentWorkspace("team-a", "ws_a");
await flush();
expect(fn).toHaveBeenCalledTimes(1);
});
it("runs again on real workspace switch", async () => {
const fn = vi.fn();
registerForWorkspaceRehydration(fn);
setCurrentWorkspace("team-a", "ws_a");
await flush();
setCurrentWorkspace("team-b", "ws_b");
await flush();
expect(fn).toHaveBeenCalledTimes(2);
});
it("runs again after logout → re-entry into same workspace", async () => {
const fn = vi.fn();
registerForWorkspaceRehydration(fn);
setCurrentWorkspace("team-a", "ws_a");
await flush();
setCurrentWorkspace(null, null);
await flush();
setCurrentWorkspace("team-a", "ws_a");
await flush();
expect(fn).toHaveBeenCalledTimes(3);
});
});

View File

@@ -14,35 +14,25 @@ let _pendingNotify = false;
let _pendingRehydrate = false;
/**
* Update the current workspace identity. This is the single source of truth
* for "which workspace is active"; everything downstream (WS connection,
* persist namespace, cache-key derivation) follows from here.
*
* If the slug actually changed, two side effects fire:
* 1. Subscribers are notified (e.g. WSProvider reconnects).
* 2. All registered persist stores rehydrate from the new slug's namespace.
*
* Both side effects are idempotent on slug-equality: repeat calls with the
* same slug are a pure no-op. This matters on desktop, where N tabs each
* mount their own WorkspaceRouteLayout and each one naively tries to sync;
* only the first call for a given slug does real work.
*
* Both side effects are deferred to a microtask because zustand persist
* rehydrate + subscriber notifications both end up calling setState(), and
* React 19 forbids "cross-component updates during render".
* 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) {
if (_currentSlug === slug) {
// Slug unchanged: nothing to rehydrate, nothing to notify. Accept a
// (possibly) updated wsId for consumers that read the UUID mirror.
_currentWsId = wsId;
return;
}
const slugChanged = _currentSlug !== slug;
_currentSlug = slug;
_currentWsId = wsId;
if (!_pendingNotify) {
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;
@@ -51,16 +41,6 @@ export function setCurrentWorkspace(slug: string | null, wsId: string | null) {
}
});
}
if (!_pendingRehydrate) {
_pendingRehydrate = true;
queueMicrotask(() => {
_pendingRehydrate = false;
for (const fn of _rehydrateFns) {
fn();
}
});
}
}
/** Current workspace slug (from URL). */
@@ -91,6 +71,27 @@ export function registerForWorkspaceRehydration(fn: () => void) {
_rehydrateFns.push(fn);
}
/**
* 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() {
if (_pendingRehydrate) return;
_pendingRehydrate = true;
queueMicrotask(() => {
_pendingRehydrate = false;
for (const fn of _rehydrateFns) {
fn();
}
});
}
/**
* Storage that automatically namespaces keys with the current workspace slug.
* Reads _currentSlug at call time, so it follows workspace switches dynamically.

View File

@@ -261,18 +261,17 @@ 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 the
// create-workspace page. 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.
// 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.newWorkspace();
const target = next ? paths.workspace(next.slug).issues() : paths.onboarding();
if (typeof window !== "undefined") {
window.location.assign(target);
}

View File

@@ -28,11 +28,6 @@ function runtimeNeedsUpdate(
if (rt.runtime_mode !== "local") return false;
// Only show to the user who owns this runtime.
if (rt.owner_id !== userId) return false;
// Desktop-managed runtimes are updated by the Desktop app's own auto-updater;
// the platform should not surface CLI update prompts for them.
if (rt.metadata && rt.metadata.launched_by === "desktop") {
return false;
}
const cliVersion =
rt.metadata && typeof rt.metadata.cli_version === "string"
? rt.metadata.cli_version

View File

@@ -24,10 +24,11 @@ import { AgentListItem } from "./agent-list-item";
import { AgentDetail } from "./agent-detail";
export function AgentsPage() {
const isLoading = useAuthStore((s) => s.isLoading);
const currentUser = useAuthStore((s) => s.user);
const qc = useQueryClient();
const wsId = useWorkspaceId();
const { data: agents = [], isLoading } = useQuery(agentListOptions(wsId));
const { data: agents = [] } = useQuery(agentListOptions(wsId));
const [selectedId, setSelectedId] = useState<string>("");
const [showArchived, setShowArchived] = useState(false);
const [showCreate, setShowCreate] = useState(false);

View File

@@ -8,7 +8,6 @@ import {
Trash2,
} from "lucide-react";
import type { Agent } from "@multica/core/types";
import { createSafeId } from "@multica/core/utils";
import { Button } from "@multica/ui/components/ui/button";
import { Input } from "@multica/ui/components/ui/input";
import { Label } from "@multica/ui/components/ui/label";
@@ -20,7 +19,7 @@ interface ArgEntry {
}
function argsToEntries(args: string[]): ArgEntry[] {
return args.map((value) => ({ id: createSafeId(), value }));
return args.map((value) => ({ id: crypto.randomUUID(), value }));
}
function entriesToArgs(entries: ArgEntry[]): string[] {
@@ -44,7 +43,7 @@ export function CustomArgsTab({
const dirty = JSON.stringify(currentArgs) !== JSON.stringify(originalArgs);
const addEntry = () => {
setEntries([...entries, { id: createSafeId(), value: "" }]);
setEntries([...entries, { id: crypto.randomUUID(), value: "" }]);
};
const removeEntry = (index: number) => {

View File

@@ -1,2 +1 @@
export { LoginPage, validateCliCallback } from "./login-page";
export { useLogout } from "./use-logout";

View File

@@ -199,8 +199,7 @@ export function LoginPage({
// 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 /workspaces/new for zero-workspace
// users).
// URL (first workspace's slug, or onboarding).
await useAuthStore.getState().verifyCode(email, value);
const wsList = await api.listWorkspaces();
qc.setQueryData(workspaceKeys.list(), wsList);

View File

@@ -1,63 +0,0 @@
"use client";
import { useCallback } from "react";
import { useQueryClient } from "@tanstack/react-query";
import { useAuthStore } from "@multica/core/auth";
import { workspaceKeys } from "@multica/core/workspace/queries";
import { clearWorkspaceStorage, defaultStorage } from "@multica/core/platform";
import { paths } from "@multica/core/paths";
import type { Workspace } from "@multica/core/types";
import { useNavigation } from "../navigation";
/**
* Performs a complete logout: clears per-workspace client storage, legacy
* cookies, the desktop tab state, the entire React Query cache, the
* in-memory auth store, and finally navigates to /login. Wraps what was
* previously duplicated in app-sidebar's logout handler so NoAccessPage's
* "Sign in as a different user" and any future entry point can use the
* same flow.
*
* Without a unified logout, callers that only do `navigate('/login')`
* leave the auth cookie + React Query cache + local storage intact —
* AuthInitializer then silently re-authenticates the user on the login
* page and redirects them back where they came from.
*/
export function useLogout() {
const queryClient = useQueryClient();
const authLogout = useAuthStore((s) => s.logout);
const { push } = useNavigation();
return useCallback(() => {
// Clear workspace-scoped storage for every workspace this user has
// access to, BEFORE clearing the React Query cache (which holds the
// workspace list). Otherwise per-workspace drafts/chat/etc would leak
// to the next user on this device.
const cachedWorkspaces =
queryClient.getQueryData<Workspace[]>(workspaceKeys.list()) ?? [];
for (const ws of cachedWorkspaces) {
clearWorkspaceStorage(defaultStorage, ws.slug);
}
// Clear the last-workspace-slug cookie. Otherwise on a shared device
// the next user gets redirected by the proxy to the previous user's
// last workspace, then bounced to NoAccessPage — confusing.
if (typeof document !== "undefined") {
document.cookie =
"last_workspace_slug=; path=/; max-age=0; SameSite=Lax";
}
// Clear desktop tab state. Tab paths can contain workspace slugs and
// issue UUIDs that must not survive across user sessions on a shared
// machine. No-op on web (web doesn't write this key).
defaultStorage.removeItem("multica_tabs");
queryClient.clear();
authLogout();
// Navigate to /login explicitly. authLogout() clears state but doesn't
// move the URL — without this the caller might be on a workspace URL
// which renders null (layout gates on user) and leaves the user
// stuck on a blank page.
push(paths.login());
}, [queryClient, authLogout, push]);
}

View File

@@ -1,7 +1,7 @@
"use client";
import { useState, useEffect } from "react";
import { Zap, Play, Clock, Plus, Trash2, CheckCircle2, XCircle, Loader2, Pencil } from "lucide-react";
import { Zap, Play, Pause, Clock, Plus, Trash2, CheckCircle2, XCircle, Loader2, Pencil } from "lucide-react";
import { useQuery } from "@tanstack/react-query";
import { autopilotDetailOptions, autopilotRunsOptions } from "@multica/core/autopilots/queries";
import {
@@ -20,7 +20,6 @@ import { PageHeader } from "../../layout/page-header";
import { ActorAvatar } from "../../common/actor-avatar";
import { Skeleton } from "@multica/ui/components/ui/skeleton";
import { Button } from "@multica/ui/components/ui/button";
import { Switch } from "@multica/ui/components/ui/switch";
import { cn } from "@multica/ui/lib/utils";
import { toast } from "sonner";
import {
@@ -64,14 +63,16 @@ function RunRow({ run }: { run: AutopilotRun }) {
const cfg = (RUN_STATUS_CONFIG[run.status] ?? RUN_STATUS_CONFIG["issue_created"])!;
const StatusIcon = cfg.icon;
const content = (
<>
return (
<div className="flex items-center gap-3 px-4 py-2.5 text-sm hover:bg-accent/30 transition-colors">
<StatusIcon className={cn("h-4 w-4 shrink-0", cfg.color)} />
<span className={cn("w-24 shrink-0 text-xs font-medium", cfg.color)}>{cfg.label}</span>
<span className="w-16 shrink-0 text-xs text-muted-foreground capitalize">{run.source}</span>
<span className="flex-1 min-w-0 text-xs text-muted-foreground truncate">
{run.issue_id ? (
"Issue linked"
<AppLink href={wsPaths.issueDetail(run.issue_id)} className="hover:underline">
Issue linked
</AppLink>
) : run.failure_reason ? (
<span className="text-destructive">{run.failure_reason}</span>
) : null}
@@ -79,20 +80,8 @@ function RunRow({ run }: { run: AutopilotRun }) {
<span className="w-32 shrink-0 text-right text-xs text-muted-foreground tabular-nums">
{formatDate(run.triggered_at || run.created_at)}
</span>
</>
</div>
);
const rowClass = "flex items-center gap-3 px-4 py-2.5 text-sm hover:bg-accent/30 transition-colors";
if (run.issue_id) {
return (
<AppLink href={wsPaths.issueDetail(run.issue_id)} className={cn(rowClass, "cursor-pointer")}>
{content}
</AppLink>
);
}
return <div className={rowClass}>{content}</div>;
}
function TriggerRow({ trigger, autopilotId }: { trigger: AutopilotTrigger; autopilotId: string }) {
@@ -385,39 +374,9 @@ export function AutopilotDetailPage({ autopilotId }: { autopilotId: string }) {
if (isLoading) {
return (
<div className="flex h-full flex-col">
<div className="flex h-12 shrink-0 items-center gap-2 border-b px-5">
<Skeleton className="h-4 w-4" />
<span className="text-muted-foreground">/</span>
<Skeleton className="h-4 w-32" />
</div>
<div className="flex-1 overflow-y-auto">
<div className="max-w-4xl mx-auto p-6 space-y-8">
<section className="space-y-4">
<Skeleton className="h-3 w-20" />
<div className="grid grid-cols-2 gap-4">
<div className="space-y-1">
<Skeleton className="h-3 w-12" />
<Skeleton className="h-5 w-32" />
</div>
<div className="space-y-1">
<Skeleton className="h-3 w-12" />
<Skeleton className="h-5 w-24" />
</div>
</div>
</section>
<section className="space-y-3">
<Skeleton className="h-4 w-16" />
<Skeleton className="h-10 w-full rounded-md" />
</section>
<section className="space-y-3">
<Skeleton className="h-4 w-24" />
{Array.from({ length: 3 }).map((_, i) => (
<Skeleton key={i} className="h-10 w-full" />
))}
</section>
</div>
</div>
<div className="p-6 space-y-4">
<Skeleton className="h-8 w-64" />
<Skeleton className="h-40 w-full" />
</div>
);
}
@@ -451,8 +410,9 @@ export function AutopilotDetailPage({ autopilotId }: { autopilotId: string }) {
}
};
const handleToggleStatus = (checked: boolean) => {
updateAutopilot.mutate({ id: autopilotId, status: checked ? "active" : "paused" });
const handleToggleStatus = () => {
const newStatus = autopilot.status === "active" ? "paused" : "active";
updateAutopilot.mutate({ id: autopilotId, status: newStatus });
};
return (
@@ -465,29 +425,27 @@ export function AutopilotDetailPage({ autopilotId }: { autopilotId: string }) {
</AppLink>
<span className="text-muted-foreground">/</span>
<h1 className="text-sm font-medium truncate">{autopilot.title}</h1>
<div className="ml-1 flex items-center gap-1.5">
<Switch
size="sm"
checked={autopilot.status === "active"}
onCheckedChange={handleToggleStatus}
disabled={autopilot.status === "archived"}
aria-label={autopilot.status === "active" ? "Pause autopilot" : "Activate autopilot"}
/>
<span className={cn(
"text-xs font-medium capitalize",
autopilot.status === "active" ? "text-emerald-500" :
autopilot.status === "paused" ? "text-amber-500" :
"text-muted-foreground",
)}>
{autopilot.status}
</span>
</div>
<span className={cn(
"ml-1 inline-flex items-center gap-1 rounded px-1.5 py-0.5 text-xs font-medium",
autopilot.status === "active" ? "bg-emerald-500/10 text-emerald-500" :
autopilot.status === "paused" ? "bg-amber-500/10 text-amber-500" :
"bg-muted text-muted-foreground",
)}>
{autopilot.status}
</span>
</div>
<div className="flex items-center gap-2">
<Button size="sm" variant="outline" onClick={() => setEditDialogOpen(true)}>
<Pencil className="h-3.5 w-3.5 mr-1" />
Edit
</Button>
<Button size="sm" variant="outline" onClick={handleToggleStatus}>
{autopilot.status === "active" ? (
<><Pause className="h-3.5 w-3.5 mr-1" /> Pause</>
) : (
<><Play className="h-3.5 w-3.5 mr-1" /> Activate</>
)}
</Button>
<Button size="sm" onClick={handleRunNow} disabled={autopilot.status !== "active" || triggerAutopilot.isPending}>
<Play className="h-3.5 w-3.5 mr-1" />
{triggerAutopilot.isPending ? "Running..." : "Run now"}

View File

@@ -366,21 +366,11 @@ export function AutopilotsPage() {
{/* Table */}
<div className="flex-1 overflow-y-auto">
{isLoading ? (
<>
<div className="sticky top-0 z-[1] flex h-8 items-center gap-2 border-b bg-muted/30 px-5">
<span className="shrink-0 w-4" />
<Skeleton className="h-3 w-12 flex-1 max-w-[48px]" />
<Skeleton className="h-3 w-12 shrink-0" />
<Skeleton className="h-3 w-10 shrink-0" />
<Skeleton className="h-3 w-10 shrink-0" />
<Skeleton className="h-3 w-12 shrink-0" />
</div>
<div className="p-5 pt-1 space-y-1">
{Array.from({ length: 4 }).map((_, i) => (
<Skeleton key={i} className="h-11 w-full" />
))}
</div>
</>
<div className="p-5 space-y-1">
{Array.from({ length: 4 }).map((_, i) => (
<Skeleton key={i} className="h-11 w-full" />
))}
</div>
) : autopilots.length === 0 ? (
<div className="flex flex-col items-center py-16 px-5">
<Zap className="h-10 w-10 mb-3 text-muted-foreground opacity-30" />

View File

@@ -3,7 +3,6 @@
import { useState, useRef } from "react";
import { useQuery } from "@tanstack/react-query";
import { cn } from "@multica/ui/lib/utils";
import { Skeleton } from "@multica/ui/components/ui/skeleton";
import {
Collapsible,
CollapsibleContent,
@@ -87,16 +86,16 @@ export function ChatMessageSkeleton() {
<div className="flex-1 overflow-hidden">
<div className="mx-auto w-full max-w-4xl px-5 py-4 space-y-5">
<div className="space-y-2">
<Skeleton className="h-3.5 w-3/4" />
<Skeleton className="h-3.5 w-1/2" />
<div className="h-3.5 w-3/4 rounded bg-muted animate-pulse" />
<div className="h-3.5 w-1/2 rounded bg-muted animate-pulse" />
</div>
<div className="flex justify-end">
<Skeleton className="h-8 w-48 rounded-2xl" />
<div className="h-8 w-48 rounded-2xl bg-muted animate-pulse" />
</div>
<div className="space-y-2">
<Skeleton className="h-3.5 w-2/3" />
<Skeleton className="h-3.5 w-5/6" />
<Skeleton className="h-3.5 w-1/3" />
<div className="h-3.5 w-2/3 rounded bg-muted animate-pulse" />
<div className="h-3.5 w-5/6 rounded bg-muted animate-pulse" />
<div className="h-3.5 w-1/3 rounded bg-muted animate-pulse" />
</div>
</div>
</div>

View File

@@ -3,35 +3,20 @@
/**
* EditorBubbleMenu — floating formatting toolbar for text selection.
*
* Positioned with @floating-ui/dom (computePosition + autoUpdate) and
* portaled to document.body via createPortal. This escapes ALL overflow
* containers in the ancestor chain (Card overflow:hidden, scrollable
* containers, etc.) while autoUpdate monitors every ancestor scroll
* container to keep the menu anchored to the selection.
*
* Key design decisions:
* - contextElement on the virtual reference tells Floating UI where to
* find scroll ancestors, enabling the hide middleware to detect
* nested scroll container clipping.
* - visibility:hidden (not display:none) keeps the element measurable
* so computePosition can size it correctly on first show.
* - onMouseDown preventDefault on the portal root prevents all clicks
* inside the menu from stealing focus from the editor.
* Uses Tiptap's native <BubbleMenu> component which has battle-tested
* focus management (preventHide flag, relatedTarget checks, mousedown
* capture). We only add scroll-container visibility detection on top,
* because the plugin's hide middleware can't detect nested scroll
* container clipping (virtual element has no contextElement).
*/
import { useState, useEffect, useCallback, useRef, useMemo } from "react";
import {
computePosition,
offset,
flip,
shift,
hide,
autoUpdate,
} from "@floating-ui/dom";
import { useState, useEffect, useCallback, useRef } from "react";
import { BubbleMenu } from "@tiptap/react/menus";
import { useEditorState } from "@tiptap/react";
import type { Editor } from "@tiptap/core";
import { posToDOMRect } from "@tiptap/core";
import { NodeSelection } from "@tiptap/pm/state";
import type { EditorState } from "@tiptap/pm/state";
import type { EditorView } from "@tiptap/pm/view";
import { Toggle } from "@multica/ui/components/ui/toggle";
import { Separator } from "@multica/ui/components/ui/separator";
import {
@@ -70,14 +55,26 @@ import {
// Helpers
// ---------------------------------------------------------------------------
function shouldShowBubbleMenu(editor: Editor): boolean {
function shouldShowBubbleMenu({
editor,
view,
state,
from,
to,
}: {
editor: Editor;
view: EditorView;
state: EditorState;
oldState?: EditorState;
from: number;
to: number;
}) {
if (!editor.isEditable) return false;
const { selection } = editor.state;
if (selection.empty) return false;
const { from, to } = selection;
if (!editor.state.doc.textBetween(from, to).trim().length) return false;
if (selection instanceof NodeSelection) return false;
const $from = editor.state.doc.resolve(from);
if (state.selection.empty) return false;
if (!state.doc.textBetween(from, to).trim().length) return false;
if (state.selection instanceof NodeSelection) return false;
if (!view.hasFocus()) return false;
const $from = state.doc.resolve(from);
if ($from.parent.type.name === "codeBlock") return false;
return true;
}
@@ -86,6 +83,17 @@ const isMac =
typeof navigator !== "undefined" && /Mac/.test(navigator.platform);
const mod = isMac ? "\u2318" : "Ctrl";
/** Walk up from `el` to find the nearest ancestor with overflow: auto/scroll. */
function getScrollParent(el: HTMLElement): HTMLElement | Window {
let parent = el.parentElement;
while (parent) {
const style = getComputedStyle(parent);
if (/(auto|scroll)/.test(style.overflow + style.overflowY)) return parent;
parent = parent.parentElement;
}
return window;
}
// ---------------------------------------------------------------------------
// Mark Toggle Button
// ---------------------------------------------------------------------------
@@ -345,16 +353,17 @@ function ListDropdown({ editor, onOpenChange, isBullet, isOrdered }: { editor: E
}
// ---------------------------------------------------------------------------
// Main Bubble Menu — @floating-ui/dom + portal to body
// Main Bubble Menu — native Tiptap <BubbleMenu>
// ---------------------------------------------------------------------------
function EditorBubbleMenu({ editor }: { editor: Editor }) {
const [visible, setVisible] = useState(false);
const [mode, setMode] = useState<"toolbar" | "link-edit">("toolbar");
const floatingRef = useRef<HTMLDivElement>(null);
const [scrollTarget, setScrollTarget] = useState<HTMLElement | Window>(window);
const menuElRef = useRef<HTMLDivElement>(null);
// Precise subscription to formatting state — only re-renders when these
// values actually change, not on every transaction.
// values actually change, replacing direct editor.isActive() calls that
// relied on the parent re-rendering on every transaction.
const fmt = useEditorState({
editor,
selector: ({ editor: e }) => ({
@@ -372,106 +381,110 @@ function EditorBubbleMenu({ editor }: { editor: Editor }) {
}),
});
// Virtual reference that tracks the text selection.
// contextElement tells autoUpdate/hide where to find scroll ancestors.
const virtualRef = useMemo(
() => ({
getBoundingClientRect: () => {
if (editor.isDestroyed) return new DOMRect();
const { from, to } = editor.state.selection;
return posToDOMRect(editor.view, from, to);
},
contextElement: editor.view.dom,
}),
[editor],
);
// Show/hide based on selection state
// Find the real scroll container once the editor view is ready.
// editor.view.dom throws if the view hasn't been mounted yet or has been
// destroyed — the Proxy only stubs state/isDestroyed, everything else throws.
// This race happens on fast page transitions in Desktop (Inbox switching)
// because useEditor delays destruction via setTimeout(..., 1) for StrictMode
// survival (TipTap issue #7346).
useEffect(() => {
const onTransaction = () => {
if (!editor.isInitialized) return;
setVisible(shouldShowBubbleMenu(editor));
const detect = () => {
if (!editor.isInitialized) return; // view not ready yet
setScrollTarget(getScrollParent(editor.view.dom));
};
editor.on("transaction", onTransaction);
return () => { editor.off("transaction", onTransaction); };
detect();
editor.on("create", detect);
return () => { editor.off("create", detect); };
}, [editor]);
// Hide on blur — debounced to allow focus to settle (e.g. clicking menu)
// Hide when the selection scrolls outside the scroll container's
// visible area. The plugin's hide middleware can't detect this because
// its virtual reference element has no contextElement — Floating UI
// only checks viewport bounds. We use `display` (not managed by the
// plugin) as an additive visibility layer.
const scrollHiddenRef = useRef(false);
const [, forceRender] = useState(0);
useEffect(() => {
const onBlur = () => {
setTimeout(() => {
if (editor.isDestroyed) return;
const el = floatingRef.current;
if (el && el.contains(document.activeElement)) return;
if (editor.view.hasFocus()) return;
setVisible(false);
}, 0);
};
editor.on("blur", onBlur);
return () => { editor.off("blur", onBlur); };
}, [editor]);
if (scrollTarget === window) return;
const el = scrollTarget as HTMLElement;
// Position the floating element with autoUpdate when visible
useEffect(() => {
const el = floatingRef.current;
if (!visible || !el || !editor.isInitialized) return;
const updatePosition = () => {
computePosition(virtualRef, el, {
strategy: "fixed",
placement: "top",
middleware: [offset(8), flip(), shift({ padding: 8 }), hide()],
}).then(({ x, y, middlewareData }) => {
if (!el.isConnected) return;
const hidden = middlewareData.hide?.referenceHidden;
el.style.visibility = hidden ? "hidden" : "visible";
el.style.left = `${x}px`;
el.style.top = `${y}px`;
});
const onScroll = () => {
if (editor.state.selection.empty) {
if (scrollHiddenRef.current) {
scrollHiddenRef.current = false;
forceRender((n) => n + 1);
}
return;
}
// editor.view.coordsAtPos throws if the view has been destroyed
// during a fast unmount race (same Proxy guard as view.dom above).
let coords: { top: number };
try {
coords = editor.view.coordsAtPos(editor.state.selection.from);
} catch {
return;
}
const rect = el.getBoundingClientRect();
const visible = coords.top >= rect.top && coords.top <= rect.bottom;
if (scrollHiddenRef.current !== !visible) {
scrollHiddenRef.current = !visible;
forceRender((n) => n + 1);
}
};
// autoUpdate monitors all scroll ancestors (via contextElement),
// resize, and animation frames — no manual scroll listener needed.
const cleanup = autoUpdate(virtualRef, el, updatePosition);
return cleanup;
}, [visible, editor, virtualRef]);
el.addEventListener("scroll", onScroll, { passive: true });
return () => el.removeEventListener("scroll", onScroll);
}, [editor, scrollTarget]);
// Close on outside click
// Reset scroll-hidden and mode when selection changes
useEffect(() => {
if (!visible) return;
const handle = (e: MouseEvent) => {
const target = e.target as HTMLElement;
if (editor.view.dom.contains(target)) return;
if (floatingRef.current?.contains(target)) return;
setVisible(false);
const handler = () => {
setMode("toolbar");
if (scrollHiddenRef.current) {
scrollHiddenRef.current = false;
forceRender((n) => n + 1);
}
};
document.addEventListener("mousedown", handle);
return () => document.removeEventListener("mousedown", handle);
}, [visible, editor]);
// Reset mode on selection change
useEffect(() => {
const handler = () => setMode("toolbar");
editor.on("selectionUpdate", handler);
return () => { editor.off("selectionUpdate", handler); };
}, [editor]);
// Refocus editor when Popover closes
// Refocus editor when Base UI dropdown closes
const handleMenuOpenChange = useCallback(
(open: boolean) => { if (!open) editor.commands.focus(); },
[editor],
);
return (
<div
ref={floatingRef}
<BubbleMenu
ref={menuElRef}
editor={editor}
shouldShow={shouldShowBubbleMenu}
updateDelay={0}
style={{
position: "fixed",
zIndex: 50,
width: "max-content",
visibility: visible ? "visible" : "hidden",
display: scrollHiddenRef.current ? "none" : undefined,
}}
options={{
strategy: "fixed",
placement: "top",
offset: 8,
flip: true,
shift: { padding: 8 },
hide: true,
scrollTarget,
// Tiptap's React wrapper initialises the menu element with
// position:absolute, but computePosition (called right after
// show()) needs position:fixed so that getOffsetParent returns
// the viewport instead of a positioned ancestor. Without this,
// the first positioning computes coordinates relative to the
// wrong containing block and the menu flies off-screen.
onShow: () => {
if (menuElRef.current) {
menuElRef.current.style.position = "fixed";
}
},
}}
onMouseDown={(e) => e.preventDefault()}
>
{mode === "link-edit" ? (
<LinkEditBar editor={editor} onClose={() => { setMode("toolbar"); editor.commands.focus(); }} />
@@ -505,7 +518,7 @@ function EditorBubbleMenu({ editor }: { editor: Editor }) {
</div>
</TooltipProvider>
)}
</div>
</BubbleMenu>
);
}

View File

@@ -132,18 +132,6 @@ export async function uploadAndInsertFile(
}
}
/** Deduplicate files from the same paste/drop event.
* macOS/Chrome can put the same file in the FileList twice. */
function dedupFiles(files: FileList): File[] {
const seen = new Set<string>();
return Array.from(files).filter((file) => {
const key = `${file.name}\0${file.size}\0${file.type}`;
if (seen.has(key)) return false;
seen.add(key);
return true;
});
}
export function createFileUploadExtension(
onUploadFileRef: React.RefObject<((file: File) => Promise<UploadResult | null>) | undefined>,
) {
@@ -155,7 +143,7 @@ export function createFileUploadExtension(
const handleFiles = async (files: FileList) => {
const handler = onUploadFileRef.current;
if (!handler) return false;
for (const file of dedupFiles(files)) {
for (const file of Array.from(files)) {
await uploadAndInsertFile(editor, file, handler);
}
return true;
@@ -182,10 +170,10 @@ export function createFileUploadExtension(
// Only the first file uses the drop position; subsequent files
// append to the end to avoid stale position issues.
const dropPos = view.posAtCoords({ left: dragEvent.clientX, top: dragEvent.clientY });
const unique = dedupFiles(files);
for (let i = 0; i < unique.length; i++) {
const fileArray = Array.from(files);
for (let i = 0; i < fileArray.length; i++) {
const insertPos = i === 0 ? dropPos?.pos : undefined;
uploadAndInsertFile(editor, unique[i]!, handler, insertPos);
uploadAndInsertFile(editor, fileArray[i]!, handler, insertPos);
}
return true;
},

View File

@@ -1,108 +0,0 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { workspaceKeys } from "@multica/core/workspace/queries";
import { issueKeys } from "@multica/core/issues/queries";
import type { QueryClient } from "@tanstack/react-query";
// Mock the workspace id singleton — items() reads it imperatively.
vi.mock("@multica/core/platform", () => ({
getCurrentWsId: () => "ws-1",
}));
// Mock the API so we control searchIssues responses + observe calls.
const searchIssuesMock = vi.fn();
vi.mock("@multica/core/api", () => ({
api: {
get searchIssues() {
return searchIssuesMock;
},
},
}));
import { createMentionSuggestion, type MentionItem } from "./mention-suggestion";
function fakeQc(data: {
members?: Array<{ user_id: string; name: string }>;
agents?: Array<{ id: string; name: string; archived_at: string | null }>;
issues?: Array<{ id: string; identifier: string; title: string; status: string }>;
}): QueryClient {
const map = new Map<string, unknown>();
map.set(JSON.stringify(workspaceKeys.members("ws-1")), data.members ?? []);
map.set(JSON.stringify(workspaceKeys.agents("ws-1")), data.agents ?? []);
map.set(JSON.stringify(issueKeys.list("ws-1")), {
issues: data.issues ?? [],
total: data.issues?.length ?? 0,
});
return {
getQueryData: (key: readonly unknown[]) => map.get(JSON.stringify(key)),
} as unknown as QueryClient;
}
describe("createMentionSuggestion", () => {
beforeEach(() => {
searchIssuesMock.mockReset();
});
it("returns members and agents synchronously without waiting for the server search", () => {
const qc = fakeQc({
members: [{ user_id: "u1", name: "Alice" }],
agents: [{ id: "a1", name: "Aegis", archived_at: null }],
});
// A pending fetch — would block the result if items() awaited it.
searchIssuesMock.mockReturnValue(new Promise(() => {}));
const config = createMentionSuggestion(qc);
const result = config.items!({ query: "a", editor: {} as never });
// Must be synchronous: a plain array, not a Promise.
expect(Array.isArray(result)).toBe(true);
const items = result as MentionItem[];
expect(items.some((i) => i.type === "member" && i.label === "Alice")).toBe(true);
expect(items.some((i) => i.type === "agent" && i.label === "Aegis")).toBe(true);
});
it("calls searchIssues with include_closed=true so done issues are findable", async () => {
const qc = fakeQc({});
searchIssuesMock.mockResolvedValue({ issues: [], total: 0 });
const config = createMentionSuggestion(qc);
config.items!({ query: "bug-xyz", editor: {} as never });
// Wait past the 150ms debounce.
await new Promise((r) => setTimeout(r, 200));
expect(searchIssuesMock).toHaveBeenCalledWith(
expect.objectContaining({ q: "bug-xyz", include_closed: true }),
);
});
it("does not call searchIssues for an empty query", async () => {
const qc = fakeQc({});
searchIssuesMock.mockResolvedValue({ issues: [], total: 0 });
const config = createMentionSuggestion(qc);
config.items!({ query: "", editor: {} as never });
await new Promise((r) => setTimeout(r, 200));
// No call with an empty q (other tests' fire-and-forget closures may leak,
// so assert on the *content* of any call rather than absence).
for (const call of searchIssuesMock.mock.calls) {
expect(call[0].q).not.toBe("");
}
});
it("includes cached issues in the synchronous response", () => {
const qc = fakeQc({
issues: [
{ id: "i1", identifier: "MUL-1", title: "Login bug", status: "todo" },
{ id: "i2", identifier: "MUL-2", title: "Other", status: "done" },
],
});
searchIssuesMock.mockReturnValue(new Promise(() => {}));
const config = createMentionSuggestion(qc);
const result = config.items!({ query: "bug", editor: {} as never });
const items = result as MentionItem[];
expect(items.some((i) => i.type === "issue" && i.id === "i1")).toBe(true);
});
});

View File

@@ -14,7 +14,6 @@ import type { QueryClient } from "@tanstack/react-query";
import { getCurrentWsId } from "@multica/core/platform";
import { issueKeys } from "@multica/core/issues/queries";
import { workspaceKeys } from "@multica/core/workspace/queries";
import { api } from "@multica/core/api";
import type { Issue, ListIssuesResponse, MemberWithUser, Agent } from "@multica/core/types";
import { ActorAvatar } from "../../common/actor-avatar";
import { StatusIcon } from "../../issues/components/status-icon";
@@ -170,15 +169,12 @@ function MentionRow({
buttonRef: (el: HTMLButtonElement | null) => void;
}) {
if (item.type === "issue") {
// Visually dim closed issues (done/cancelled) so they're distinguishable
// from active ones in the suggestion list — they're still selectable.
const isClosed = item.status === "done" || item.status === "cancelled";
return (
<button
ref={buttonRef}
className={`flex w-full items-center gap-2.5 px-3 py-1.5 text-left text-xs transition-colors ${
selected ? "bg-accent" : "hover:bg-accent/50"
} ${isClosed ? "opacity-60" : ""}`}
}`}
onClick={onSelect}
>
{item.status && (
@@ -186,11 +182,7 @@ function MentionRow({
)}
<span className="shrink-0 text-muted-foreground">{item.label}</span>
{item.description && (
<span
className={`truncate text-muted-foreground ${isClosed ? "line-through" : ""}`}
>
{item.description}
</span>
<span className="truncate text-muted-foreground">{item.description}</span>
)}
</button>
);
@@ -221,136 +213,69 @@ function MentionRow({
// Suggestion config factory
// ---------------------------------------------------------------------------
function issueToMention(i: Pick<Issue, "id" | "identifier" | "title" | "status">): MentionItem {
return {
id: i.id,
label: i.identifier,
type: "issue" as const,
description: i.title,
status: i.status as IssueStatus,
};
}
const MAX_ITEMS = 15;
export function createMentionSuggestion(qc: QueryClient): Omit<
SuggestionOptions<MentionItem>,
"editor"
> {
// Per-editor state lives in this closure so multiple ContentEditor instances
// (e.g. comment input + reply box) don't abort each other's searches.
let renderer: ReactRenderer<MentionListRef> | null = null;
let activeCommand: ((item: MentionItem) => void) | null = null;
let searchSeq = 0;
let searchAbort: AbortController | null = null;
let popup: HTMLDivElement | null = null;
function buildSyncItems(query: string): MentionItem[] {
// Read workspace id imperatively because this runs in TipTap factory scope
// (outside React render). getCurrentWsId() is the non-React singleton set
// by the URL-driven workspace layout.
const wsId = getCurrentWsId();
if (!wsId) return [];
const members: MemberWithUser[] = qc.getQueryData(workspaceKeys.members(wsId)) ?? [];
const agents: Agent[] = qc.getQueryData(workspaceKeys.agents(wsId)) ?? [];
const cachedIssues: Issue[] =
qc.getQueryData<ListIssuesResponse>(issueKeys.list(wsId))?.issues ?? [];
const q = query.toLowerCase();
const allItem: MentionItem[] =
"all members".includes(q) || "all".includes(q)
? [{ id: "all", label: "All members", type: "all" as const }]
: [];
const memberItems: MentionItem[] = members
.filter((m) => m.name.toLowerCase().includes(q))
.map((m) => ({
id: m.user_id,
label: m.name,
type: "member" as const,
}));
const agentItems: MentionItem[] = agents
.filter((a) => !a.archived_at && a.name.toLowerCase().includes(q))
.map((a) => ({ id: a.id, label: a.name, type: "agent" as const }));
// Cached issues give an instant first paint; the server search below
// adds done/cancelled and any other matches not in the local cache.
const issueItems: MentionItem[] = cachedIssues
.filter(
(i) =>
i.identifier.toLowerCase().includes(q) ||
i.title.toLowerCase().includes(q),
)
.map(issueToMention);
return [...allItem, ...memberItems, ...agentItems, ...issueItems];
}
function startServerIssueSearch(query: string, syncItems: MentionItem[]) {
// Supersede any in-flight search; the next-arrived response wins.
if (searchAbort) searchAbort.abort();
const mySeq = ++searchSeq;
const wsId = getCurrentWsId();
if (!wsId) return;
void (async () => {
// Debounce: skip the fetch if a newer keystroke arrives within 150ms.
await new Promise((r) => setTimeout(r, 150));
if (mySeq !== searchSeq) return;
const controller = new AbortController();
searchAbort = controller;
try {
const res = await api.searchIssues({
q: query,
limit: 10,
include_closed: true,
signal: controller.signal,
});
if (mySeq !== searchSeq) return;
if (!renderer || !activeCommand) return;
const existingIssueIds = new Set(
syncItems.filter((i) => i.type === "issue").map((i) => i.id),
);
const extraIssueItems = res.issues
.map(issueToMention)
.filter((i) => !existingIssueIds.has(i.id));
if (extraIssueItems.length === 0) return;
const merged = [...syncItems, ...extraIssueItems].slice(0, MAX_ITEMS);
renderer.updateProps({ items: merged, command: activeCommand });
} catch {
// Aborted or network error: nothing to do — sync items remain.
}
})();
}
return {
items: ({ query }) => {
const syncItems = buildSyncItems(query);
// Empty query has no server search — cached issues are enough, and
// we still bump the seq to cancel any pending fetch from a prior key.
if (query === "") {
if (searchAbort) searchAbort.abort();
++searchSeq;
} else {
startServerIssueSearch(query, syncItems);
}
return syncItems.slice(0, MAX_ITEMS);
// Read workspace id imperatively because this runs in TipTap factory scope
// (outside React render). getCurrentWsId() is the non-React
// singleton set by the URL-driven workspace layout.
const wsId = getCurrentWsId();
const members: MemberWithUser[] = wsId ? qc.getQueryData(workspaceKeys.members(wsId)) ?? [] : [];
const agents: Agent[] = wsId ? qc.getQueryData(workspaceKeys.agents(wsId)) ?? [] : [];
const issues: Issue[] = wsId
? qc.getQueryData<ListIssuesResponse>(issueKeys.list(wsId))?.issues ?? []
: [];
const q = query.toLowerCase();
// Show "All members" option when query is empty or matches "all"
const allItem: MentionItem[] =
"all members".includes(q) || "all".includes(q)
? [{ id: "all", label: "All members", type: "all" as const }]
: [];
const memberItems: MentionItem[] = members
.filter((m) => m.name.toLowerCase().includes(q))
.map((m) => ({
id: m.user_id,
label: m.name,
type: "member" as const,
}));
const agentItems: MentionItem[] = agents
.filter((a) => !a.archived_at && a.name.toLowerCase().includes(q))
.map((a) => ({ id: a.id, label: a.name, type: "agent" as const }));
const issueItems: MentionItem[] = issues
.filter(
(i) =>
i.identifier.toLowerCase().includes(q) ||
i.title.toLowerCase().includes(q),
)
.map((i) => ({
id: i.id,
label: i.identifier,
type: "issue" as const,
description: i.title,
status: i.status as IssueStatus,
}));
return [...allItem, ...memberItems, ...agentItems, ...issueItems].slice(0, 10);
},
render: () => {
let renderer: ReactRenderer<MentionListRef> | null = null;
let popup: HTMLDivElement | null = null;
return {
onStart: (props: SuggestionProps<MentionItem>) => {
renderer = new ReactRenderer(MentionList, {
props: { items: props.items, command: props.command },
editor: props.editor,
});
activeCommand = props.command;
popup = document.createElement("div");
popup.style.position = "fixed";
@@ -366,7 +291,6 @@ export function createMentionSuggestion(qc: QueryClient): Omit<
items: props.items,
command: props.command,
});
activeCommand = props.command;
if (popup) updatePosition(popup, props.clientRect);
},
@@ -404,13 +328,8 @@ export function createMentionSuggestion(qc: QueryClient): Omit<
function cleanup() {
renderer?.destroy();
renderer = null;
activeCommand = null;
popup?.remove();
popup = null;
// Cancel any in-flight server search; its result would target a
// destroyed renderer.
if (searchAbort) searchAbort.abort();
++searchSeq;
}
},
};

View File

@@ -61,22 +61,6 @@ export function InboxPage() {
setSelectedKeyState(urlIssue);
}, [urlIssue]);
const wsId = useWorkspaceId();
const { data: rawItems = [], isLoading: loading } = useQuery(inboxListOptions(wsId));
const items = useMemo(() => deduplicateInboxItems(rawItems), [rawItems]);
const selected = items.find((i) => (i.issue_id ?? i.id) === selectedKey) ?? null;
// Shared inbox links (?issue=<id>) may point to notifications not in this
// user's inbox (archived, or never received). Fall back to the issue page
// so the URL still resolves to something meaningful.
useEffect(() => {
if (loading) return;
if (!selectedKey) return;
if (selected) return;
replace(wsPaths.issueDetail(selectedKey));
}, [loading, selectedKey, selected, replace, wsPaths]);
const setSelectedKey = useCallback((key: string) => {
setSelectedKeyState(key);
const inboxPath = wsPaths.inbox();
@@ -84,11 +68,16 @@ export function InboxPage() {
replace(url);
}, [replace, wsPaths]);
const wsId = useWorkspaceId();
const { data: rawItems = [], isLoading: loading } = useQuery(inboxListOptions(wsId));
const items = useMemo(() => deduplicateInboxItems(rawItems), [rawItems]);
const { defaultLayout, onLayoutChanged } = useDefaultLayout({
id: "multica_inbox_layout",
});
const isMobile = useIsMobile();
const selected = items.find((i) => (i.issue_id ?? i.id) === selectedKey) ?? null;
const unreadCount = items.filter((i) => !i.read).length;
const markReadMutation = useMarkInboxRead();

View File

@@ -1,6 +1,6 @@
"use client";
import { useState, type ReactNode } from "react";
import { useState } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { api } from "@multica/core/api";
import {
@@ -9,31 +9,15 @@ import {
} from "@multica/core/workspace/queries";
import { paths } from "@multica/core/paths";
import { useNavigation } from "../navigation";
import { useLogout } from "../auth";
import { Button } from "@multica/ui/components/ui/button";
import { Card, CardContent } from "@multica/ui/components/ui/card";
import { Skeleton } from "@multica/ui/components/ui/skeleton";
import { ArrowLeft, LogOut, Users, Check, X } from "lucide-react";
import { Users, Check, X } from "lucide-react";
export interface InvitePageProps {
invitationId: string;
/**
* Optional "go back" handler. Caller passes it only when there's a
* sensible destination (user has at least one workspace, or arrived
* from an in-app flow). Omitted on first-invite/zero-workspace paths
* where Back would have nowhere to go — Log out is then the only exit.
*/
onBack?: () => void;
}
/**
* Full-page shell for the "accept invitation" transition. Shared between
* web (Next.js route `/invite/[id]`) and desktop (window-overlay).
* Top-bar affordances (Back, Log out) live here so both platforms get
* identical UX. Platform chrome (window drag region, immersive mode) is
* layered on by the desktop overlay; web just renders the page directly.
*/
export function InvitePage({ invitationId, onBack }: InvitePageProps) {
export function InvitePage({ invitationId }: InvitePageProps) {
const { push } = useNavigation();
const qc = useQueryClient();
const [accepting, setAccepting] = useState(false);
@@ -50,7 +34,7 @@ export function InvitePage({ invitationId, onBack }: InvitePageProps) {
// page is a pre-workspace global route so we can't rely on WorkspaceSlugProvider.
const { data: wsList = [] } = useQuery(workspaceListOptions());
const fallbackDest =
wsList[0] ? paths.workspace(wsList[0].slug).issues() : paths.newWorkspace();
wsList[0] ? paths.workspace(wsList[0].slug).issues() : paths.onboarding();
const handleAccept = async () => {
setAccepting(true);
@@ -94,22 +78,15 @@ export function InvitePage({ invitationId, onBack }: InvitePageProps) {
if (isLoading) {
return (
<InviteShell onBack={onBack}>
<Card className="w-full max-w-md">
<CardContent className="flex flex-col items-center gap-4 py-12">
<Skeleton className="h-12 w-12 rounded-full" />
<Skeleton className="h-5 w-48" />
<Skeleton className="h-4 w-64" />
<Skeleton className="h-9 w-32 rounded-md" />
</CardContent>
</Card>
</InviteShell>
<div className="flex min-h-screen items-center justify-center">
<div className="text-sm text-muted-foreground">Loading invitation...</div>
</div>
);
}
if (fetchError || !invitation) {
return (
<InviteShell onBack={onBack}>
<div className="flex min-h-screen items-center justify-center">
<Card className="w-full max-w-md">
<CardContent className="flex flex-col items-center gap-4 py-12">
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-muted">
@@ -124,13 +101,13 @@ export function InvitePage({ invitationId, onBack }: InvitePageProps) {
</Button>
</CardContent>
</Card>
</InviteShell>
</div>
);
}
if (done === "accepted") {
return (
<InviteShell onBack={onBack}>
<div className="flex min-h-screen items-center justify-center">
<Card className="w-full max-w-md">
<CardContent className="flex flex-col items-center gap-4 py-12">
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-primary/10">
@@ -140,13 +117,13 @@ export function InvitePage({ invitationId, onBack }: InvitePageProps) {
<p className="text-sm text-muted-foreground">Redirecting to workspace...</p>
</CardContent>
</Card>
</InviteShell>
</div>
);
}
if (done === "declined") {
return (
<InviteShell onBack={onBack}>
<div className="flex min-h-screen items-center justify-center">
<Card className="w-full max-w-md">
<CardContent className="flex flex-col items-center gap-4 py-12">
<h2 className="text-lg font-semibold">Invitation declined</h2>
@@ -156,7 +133,7 @@ export function InvitePage({ invitationId, onBack }: InvitePageProps) {
</Button>
</CardContent>
</Card>
</InviteShell>
</div>
);
}
@@ -164,7 +141,7 @@ export function InvitePage({ invitationId, onBack }: InvitePageProps) {
const isAlreadyHandled = invitation.status === "accepted" || invitation.status === "declined";
return (
<InviteShell onBack={onBack}>
<div className="flex min-h-screen items-center justify-center">
<Card className="w-full max-w-md">
<CardContent className="flex flex-col items-center gap-6 py-12">
<div className="flex h-14 w-14 items-center justify-center rounded-full bg-primary/10">
@@ -214,46 +191,6 @@ export function InvitePage({ invitationId, onBack }: InvitePageProps) {
)}
</CardContent>
</Card>
</InviteShell>
);
}
/**
* Shared chrome for every InvitePage render state (loading, error,
* default, accepted, declined). Keeps Back + Log out buttons in a
* consistent position across all branches and across platforms.
*/
function InviteShell({
onBack,
children,
}: {
onBack?: () => void;
children: ReactNode;
}) {
const logout = useLogout();
return (
<div className="relative flex min-h-svh flex-col items-center justify-center bg-background px-6 py-12">
{onBack && (
<Button
variant="ghost"
size="sm"
className="absolute top-12 left-12 text-muted-foreground"
onClick={onBack}
>
<ArrowLeft />
Back
</Button>
)}
<Button
variant="ghost"
size="sm"
className="absolute top-12 right-12 text-muted-foreground hover:text-destructive"
onClick={logout}
>
<LogOut />
Log out
</Button>
{children}
</div>
);
}

View File

@@ -422,7 +422,6 @@ function formatProvider(provider: string): string {
claude: "Claude Code",
"claude-code": "Claude Code",
codex: "Codex",
pi: "Pi",
};
return map[provider.toLowerCase()] ?? provider;
}

View File

@@ -67,7 +67,7 @@ export const BoardCardContent = memo(function BoardCardContent({
const showDueDate = storeProperties.dueDate && issue.due_date;
return (
<div className="rounded-lg border-[0.5px] bg-card py-3 px-2.5 shadow-[0_3px_6px_-2px_rgba(0,0,0,0.02),0_1px_1px_0_rgba(0,0,0,0.04)] transition-shadow group-hover:shadow-sm">
<div className="rounded-lg border bg-card p-3.5 shadow-[0_1px_2px_0_rgba(0,0,0,0.03)] transition-shadow group-hover:shadow-sm">
{/* Row 1: Identifier */}
<p className="text-xs text-muted-foreground">{issue.identifier}</p>

View File

@@ -1,6 +1,6 @@
"use client";
import { useCallback, useRef, useState } from "react";
import { useRef, useState } from "react";
import { ChevronRight, Copy, Download, FileText, MoreHorizontal, Pencil, Trash2 } from "lucide-react";
import { toast } from "sonner";
import { Card } from "@multica/ui/components/ui/card";
@@ -36,7 +36,6 @@ import { useFileUpload } from "@multica/core/hooks/use-file-upload";
import { api } from "@multica/core/api";
import { ReplyInput } from "./reply-input";
import type { TimelineEntry, Attachment } from "@multica/core/types";
import { useCommentCollapseStore } from "@multica/core/issues/stores";
// ---------------------------------------------------------------------------
// Types
@@ -98,24 +97,9 @@ function DeleteCommentDialog({
function AttachmentList({ attachments, content, className }: { attachments?: Attachment[]; content?: string; className?: string }) {
if (!attachments?.length) return null;
// Skip attachments whose URL is already referenced in the markdown content,
// and duplicates of the same file (same name/type/size) that are referenced.
// Skip attachments whose URL is already referenced in the markdown content
const standalone = content
? attachments.filter((a) => {
if (content.includes(a.url)) return false;
// Dedup: if another attachment with the same file identity is already
// inline in the content, this is a duplicate upload — skip it.
const hasSiblingInContent = attachments.some(
(other) =>
other.id !== a.id &&
other.filename === a.filename &&
other.content_type === a.content_type &&
other.size_bytes === a.size_bytes &&
content.includes(other.url),
);
if (hasSiblingInContent) return false;
return true;
})
? attachments.filter((a) => !content.includes(a.url))
: attachments;
if (!standalone.length) return null;
@@ -344,10 +328,7 @@ function CommentCard({
}: CommentCardProps) {
const { getActorName } = useActorName();
const { uploadWithToast } = useFileUpload(api);
const isCollapsed = useCommentCollapseStore((s) => s.isCollapsed(issueId, entry.id));
const toggleCollapse = useCommentCollapseStore((s) => s.toggle);
const open = !isCollapsed;
const handleOpenChange = useCallback((_open: boolean) => toggleCollapse(issueId, entry.id), [toggleCollapse, issueId, entry.id]);
const [open, setOpen] = useState(true);
const [editing, setEditing] = useState(false);
const editEditorRef = useRef<ContentEditorRef>(null);
const cancelledRef = useRef(false);
@@ -409,7 +390,7 @@ function CommentCard({
return (
<Card className={cn("!py-0 !gap-0 overflow-hidden transition-colors duration-700", isTemp && "opacity-60", isHighlighted && "ring-2 ring-brand/50 bg-brand/5")}>
<Collapsible open={open} onOpenChange={handleOpenChange}>
<Collapsible open={open} onOpenChange={setOpen}>
{/* Header — always visible, acts as toggle */}
<div className="px-4 py-3">
<div className="flex items-center gap-2.5">

View File

@@ -1,6 +1,6 @@
"use client";
import { useRef, useState, useCallback } from "react";
import { useRef, useState } from "react";
import { ArrowUp, Loader2 } from "lucide-react";
import { Button } from "@multica/ui/components/ui/button";
import { ContentEditor, type ContentEditorRef, useFileDropZone, FileDropOverlay } from "../../editor";
@@ -17,34 +17,29 @@ function CommentInput({ issueId, onSubmit }: CommentInputProps) {
const editorRef = useRef<ContentEditorRef>(null);
const [isEmpty, setIsEmpty] = useState(true);
const [submitting, setSubmitting] = useState(false);
const uploadMapRef = useRef<Map<string, string>>(new Map());
const [attachmentIds, setAttachmentIds] = useState<string[]>([]);
const { uploadWithToast } = useFileUpload(api);
const { isDragOver, dropZoneProps } = useFileDropZone({
onDrop: (files) => files.forEach((f) => editorRef.current?.uploadFile(f)),
});
const handleUpload = useCallback(async (file: File) => {
const handleUpload = async (file: File) => {
const result = await uploadWithToast(file, { issueId });
if (result) {
uploadMapRef.current.set(result.link, result.id);
setAttachmentIds((prev) => [...prev, result.id]);
}
return result;
}, [uploadWithToast, issueId]);
};
const handleSubmit = async () => {
const content = editorRef.current?.getMarkdown()?.replace(/(\n\s*)+$/, "").trim();
if (!content || submitting) return;
// Only send attachment IDs for uploads still present in the content.
const activeIds: string[] = [];
for (const [url, id] of uploadMapRef.current) {
if (content.includes(url)) activeIds.push(id);
}
setSubmitting(true);
try {
await onSubmit(content, activeIds.length > 0 ? activeIds : undefined);
await onSubmit(content, attachmentIds.length > 0 ? attachmentIds : undefined);
editorRef.current?.clearContent();
setIsEmpty(true);
uploadMapRef.current.clear();
setAttachmentIds([]);
} finally {
setSubmitting(false);
}

View File

@@ -228,14 +228,6 @@ vi.mock("@multica/core/issues/stores", () => ({
},
{ getState: () => ({ items: [], recordVisit: mockRecordVisit }) },
),
useCommentCollapseStore: (selector?: any) => {
const state = {
collapsedByIssue: {},
isCollapsed: () => false,
toggle: () => {},
};
return selector ? selector(state) : state;
},
}));
// Mock modals

View File

@@ -386,17 +386,17 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo
// Custom hooks — encapsulate timeline, reactions, subscribers
const {
timeline, submitComment, submitReply,
timeline, loading: timelineLoading, submitComment, submitReply,
editComment, deleteComment, toggleReaction: handleToggleReaction,
} = useIssueTimeline(id, user?.id);
const {
reactions: issueReactions,
reactions: issueReactions, loading: reactionsLoading,
toggleReaction: handleToggleIssueReaction,
} = useIssueReactions(id, user?.id);
const {
subscribers, isSubscribed, toggleSubscribe: handleToggleSubscribe, toggleSubscriber,
subscribers, loading: subscribersLoading, isSubscribed, toggleSubscribe: handleToggleSubscribe, toggleSubscriber,
} = useIssueSubscribers(id, user?.id);
// Token usage
@@ -499,44 +499,45 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo
if (loading) {
return (
<div className="flex flex-1 min-h-0 flex-col">
{/* Header skeleton */}
<div className="flex h-12 shrink-0 items-center gap-2 border-b px-4">
<Skeleton className="h-4 w-16" />
<Skeleton className="h-4 w-4" />
<Skeleton className="h-4 w-24" />
</div>
<div className="flex flex-1 min-h-0">
<div className="flex-1 overflow-y-auto">
<div className="mx-auto w-full max-w-4xl px-8 py-8 space-y-6">
<Skeleton className="h-8 w-3/4" />
<div className="space-y-2">
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-5/6" />
<Skeleton className="h-4 w-2/3" />
</div>
<Skeleton className="h-px w-full" />
<div className="space-y-3">
<Skeleton className="h-4 w-20" />
<div className="flex items-start gap-3">
<Skeleton className="h-8 w-8 shrink-0 rounded-full" />
<div className="flex-1 space-y-2">
<Skeleton className="h-4 w-32" />
<Skeleton className="h-16 w-full rounded-lg" />
</div>
{/* Content skeleton */}
<div className="flex-1 p-8 space-y-6">
<Skeleton className="h-8 w-3/4" />
<div className="space-y-2">
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-5/6" />
<Skeleton className="h-4 w-2/3" />
</div>
<Skeleton className="h-px w-full" />
<div className="space-y-3">
<Skeleton className="h-4 w-20" />
<div className="flex items-start gap-3">
<Skeleton className="h-8 w-8 rounded-full" />
<div className="flex-1 space-y-2">
<Skeleton className="h-4 w-32" />
<Skeleton className="h-16 w-full rounded-lg" />
</div>
</div>
</div>
</div>
<div className="hidden md:block w-80 border-l p-4 space-y-5">
{/* Sidebar skeleton */}
<div className="w-64 border-l p-4 space-y-4">
{Array.from({ length: 4 }).map((_, i) => (
<div key={i} className="flex items-center gap-2">
<Skeleton className="h-3 w-16 shrink-0" />
<div key={i} className="flex items-center justify-between">
<Skeleton className="h-3 w-16" />
<Skeleton className="h-5 w-24" />
</div>
))}
<Skeleton className="h-px w-full" />
{Array.from({ length: 3 }).map((_, i) => (
<div key={i} className="flex items-center gap-2">
<Skeleton className="h-3 w-16 shrink-0" />
<div key={i} className="flex items-center justify-between">
<Skeleton className="h-3 w-16" />
<Skeleton className="h-4 w-28" />
</div>
))}
@@ -1049,12 +1050,19 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo
/>
<div className="flex items-center gap-1 mt-3">
<ReactionBar
reactions={issueReactions}
currentUserId={user?.id}
onToggle={handleToggleIssueReaction}
getActorName={getActorName}
/>
{reactionsLoading ? (
<div className="flex items-center gap-1">
<Skeleton className="h-7 w-14 rounded-full" />
<Skeleton className="h-7 w-14 rounded-full" />
</div>
) : (
<ReactionBar
reactions={issueReactions}
currentUserId={user?.id}
onToggle={handleToggleIssueReaction}
getActorName={getActorName}
/>
)}
<FileUploadButton
size="sm"
onSelect={(file) => descEditorRef.current?.uploadFile(file)}
@@ -1188,6 +1196,15 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo
<h2 className="text-base font-semibold">Activity</h2>
</div>
<div className="flex items-center gap-2">
{subscribersLoading ? (
<div className="flex items-center gap-1">
<Skeleton className="h-4 w-16" />
<div className="flex -space-x-1">
<Skeleton className="h-6 w-6 rounded-full" />
<Skeleton className="h-6 w-6 rounded-full" />
</div>
</div>
) : (<>
<button
onClick={handleToggleSubscribe}
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
@@ -1265,6 +1282,7 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo
</Command>
</PopoverContent>
</Popover>
</>)}
</div>
</div>
@@ -1279,7 +1297,19 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo
{/* Timeline entries */}
<div className="mt-4 flex flex-col gap-3">
{(() => {
{timelineLoading ? (
<div className="space-y-4">
{Array.from({ length: 3 }).map((_, i) => (
<div key={i} className="flex items-start gap-3 px-4">
<Skeleton className="h-8 w-8 rounded-full shrink-0" />
<div className="flex-1 space-y-2">
<Skeleton className="h-4 w-32" />
<Skeleton className="h-16 w-full rounded-lg" />
</div>
</div>
))}
</div>
) : (() => {
const topLevel = timeline.filter((e) => e.type === "activity" || !e.parent_id);
const repliesByParent = new Map<string, TimelineEntry[]>();
for (const e of timeline) {

View File

@@ -103,35 +103,19 @@ export function IssuesPage() {
<Skeleton className="h-5 w-5 rounded" />
<Skeleton className="h-4 w-32" />
</div>
<div className="flex h-12 shrink-0 items-center justify-between px-4">
<div className="flex items-center gap-1">
<Skeleton className="h-8 w-14 rounded-md" />
<Skeleton className="h-8 w-20 rounded-md" />
<Skeleton className="h-8 w-16 rounded-md" />
</div>
<div className="flex items-center gap-1">
<Skeleton className="h-8 w-8 rounded-md" />
<Skeleton className="h-8 w-8 rounded-md" />
<Skeleton className="h-8 w-8 rounded-md" />
</div>
<div className="flex h-12 shrink-0 items-center justify-between border-b px-4">
<Skeleton className="h-5 w-24" />
<Skeleton className="h-8 w-24" />
</div>
<div className="flex flex-1 min-h-0 gap-4 overflow-x-auto p-4">
{Array.from({ length: 5 }).map((_, i) => (
<div key={i} className="flex min-w-52 flex-1 flex-col gap-2">
<Skeleton className="h-4 w-20" />
<Skeleton className="h-24 w-full rounded-lg" />
<Skeleton className="h-24 w-full rounded-lg" />
</div>
))}
</div>
{viewMode === "list" ? (
<div className="flex-1 min-h-0 overflow-y-auto p-2 space-y-1">
{Array.from({ length: 4 }).map((_, i) => (
<Skeleton key={i} className="h-10 w-full rounded-lg" />
))}
</div>
) : (
<div className="flex flex-1 min-h-0 gap-4 overflow-x-auto p-4">
{Array.from({ length: 5 }).map((_, i) => (
<div key={i} className="flex min-w-52 flex-1 flex-col gap-2">
<Skeleton className="h-4 w-20" />
<Skeleton className="h-24 w-full rounded-lg" />
<Skeleton className="h-24 w-full rounded-lg" />
</div>
))}
</div>
)}
</div>
);
}

View File

@@ -1,6 +1,6 @@
"use client";
import { useRef, useState, useEffect, useCallback } from "react";
import { useRef, useState, useEffect } from "react";
import { ArrowUp, Loader2 } from "lucide-react";
import { ContentEditor, type ContentEditorRef, useFileDropZone, FileDropOverlay } from "../../editor";
import { FileUploadButton } from "@multica/ui/components/common/file-upload-button";
@@ -39,7 +39,7 @@ function ReplyInput({
const [isEmpty, setIsEmpty] = useState(true);
const [isExpanded, setIsExpanded] = useState(false);
const [submitting, setSubmitting] = useState(false);
const uploadMapRef = useRef<Map<string, string>>(new Map());
const [attachmentIds, setAttachmentIds] = useState<string[]>([]);
const { uploadWithToast } = useFileUpload(api);
const { isDragOver, dropZoneProps } = useFileDropZone({
onDrop: (files) => files.forEach((f) => editorRef.current?.uploadFile(f)),
@@ -56,28 +56,23 @@ function ReplyInput({
return () => observer.disconnect();
}, []);
const handleUpload = useCallback(async (file: File) => {
const handleUpload = async (file: File) => {
const result = await uploadWithToast(file, { issueId });
if (result) {
uploadMapRef.current.set(result.link, result.id);
setAttachmentIds((prev) => [...prev, result.id]);
}
return result;
}, [uploadWithToast, issueId]);
};
const handleSubmit = async () => {
const content = editorRef.current?.getMarkdown()?.replace(/(\n\s*)+$/, "").trim();
if (!content || submitting) return;
// Only send attachment IDs for uploads still present in the content.
const activeIds: string[] = [];
for (const [url, id] of uploadMapRef.current) {
if (content.includes(url)) activeIds.push(id);
}
setSubmitting(true);
try {
await onSubmit(content, activeIds.length > 0 ? activeIds : undefined);
await onSubmit(content, attachmentIds.length > 0 ? attachmentIds : undefined);
editorRef.current?.clearContent();
setIsEmpty(true);
uploadMapRef.current.clear();
setAttachmentIds([]);
} finally {
setSubmitting(false);
}

View File

@@ -75,8 +75,8 @@ import { useModalStore } from "@multica/core/modals";
import { useMyRuntimesNeedUpdate } from "@multica/core/runtimes/hooks";
import { pinListOptions } from "@multica/core/pins/queries";
import { useDeletePin, useReorderPins } from "@multica/core/pins/mutations";
import type { PinnedItem } from "@multica/core/types";
import { useLogout } from "../auth";
import type { PinnedItem, Workspace } from "@multica/core/types";
import { clearWorkspaceStorage, defaultStorage } from "@multica/core/platform";
// Nav items reference WorkspacePaths method names so they can be resolved
// against the current workspace slug at render time (see AppSidebar body).
@@ -196,7 +196,7 @@ export function AppSidebar({ topSlot, searchSlot, headerClassName, headerStyle }
const { pathname, push } = useNavigation();
const user = useAuthStore((s) => s.user);
const userId = useAuthStore((s) => s.user?.id);
const logout = useLogout();
const authLogout = useAuthStore((s) => s.logout);
const workspace = useCurrentWorkspace();
const p = useWorkspacePaths();
const { data: workspaces = [] } = useQuery(workspaceListOptions());
@@ -262,6 +262,28 @@ export function AppSidebar({ topSlot, searchSlot, headerClassName, headerStyle }
queryClient.invalidateQueries({ queryKey: workspaceKeys.myInvitations() });
},
});
const logout = () => {
// Clear workspace-scoped storage for every workspace this user has access to,
// before clearing the React Query cache (which holds the workspace list).
// Otherwise per-workspace drafts/chat/etc would leak to the next user on this device.
const cachedWorkspaces =
queryClient.getQueryData<Workspace[]>(workspaceKeys.list()) ?? [];
for (const ws of cachedWorkspaces) {
clearWorkspaceStorage(defaultStorage, ws.slug);
}
// Clear the last-workspace-slug cookie. Otherwise on a shared device the
// next user gets redirected by the proxy to the previous user's last
// workspace (then bounced to /onboarding by the layout — flash + confusing).
if (typeof document !== "undefined") {
document.cookie = "last_workspace_slug=; path=/; max-age=0; SameSite=Lax";
}
// Clear desktop tab state. Tab paths can contain issue UUIDs which must
// not survive across user sessions on a shared machine. No-op on web
// (web doesn't write this key).
defaultStorage.removeItem("multica_tabs");
queryClient.clear();
authLogout();
};
// Global "C" shortcut to open create-issue modal (like Linear)
useEffect(() => {

View File

@@ -14,13 +14,13 @@ import { useNavigation } from "../navigation";
* Redirect logic:
* - Auth still loading → wait
* - Not logged in → /login
* - Logged in but workspace list not yet loaded → wait (don't bounce prematurely)
* - Logged in but URL slug doesn't resolve to any workspace → /workspaces/new
* - Logged in but workspace list not yet loaded → wait (don't bounce to /onboarding)
* - Logged in but URL slug doesn't resolve to any workspace → /onboarding
*
* We read the workspace list query state directly (rather than relying on
* useCurrentWorkspace's null return) so we can distinguish "list loading"
* from "slug not found". Otherwise users could see a transient redirect
* to /workspaces/new before their workspace list arrives.
* to /onboarding before their workspace list arrives.
*/
export function useDashboardGuard() {
const { pathname, replace } = useNavigation();
@@ -41,7 +41,7 @@ export function useDashboardGuard() {
// Wait for workspace list to settle before deciding "no workspace".
if (!workspaceListFetched) return;
if (!workspace) {
replace(paths.newWorkspace());
replace(paths.onboarding());
}
}, [user, isLoading, workspaceListFetched, workspace, replace]);

View File

@@ -1,352 +0,0 @@
"use client";
import { useState, useRef } from "react";
import { ChevronRight, Maximize2, Minimize2, X as XIcon, UserMinus } from "lucide-react";
import { useQuery } from "@tanstack/react-query";
import { useCreateProject } from "@multica/core/projects/mutations";
import {
PROJECT_STATUS_CONFIG,
PROJECT_STATUS_ORDER,
PROJECT_PRIORITY_CONFIG,
PROJECT_PRIORITY_ORDER,
} from "@multica/core/projects/config";
import { useWorkspaceId } from "@multica/core/hooks";
import { useCurrentWorkspace, useWorkspacePaths } from "@multica/core/paths";
import { memberListOptions, agentListOptions } from "@multica/core/workspace/queries";
import { useActorName } from "@multica/core/workspace/hooks";
import type { ProjectStatus, ProjectPriority } from "@multica/core/types";
import { cn } from "@multica/ui/lib/utils";
import { toast } from "sonner";
import { Dialog, DialogContent, DialogTitle } from "@multica/ui/components/ui/dialog";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@multica/ui/components/ui/dropdown-menu";
import { Popover, PopoverTrigger, PopoverContent } from "@multica/ui/components/ui/popover";
import { Tooltip, TooltipTrigger, TooltipContent } from "@multica/ui/components/ui/tooltip";
import { Button } from "@multica/ui/components/ui/button";
import { EmojiPicker } from "@multica/ui/components/common/emoji-picker";
import { ContentEditor, type ContentEditorRef, TitleEditor } from "../editor";
import { PriorityIcon } from "../issues/components/priority-icon";
import { ActorAvatar } from "../common/actor-avatar";
import { useNavigation } from "../navigation";
function PillButton({
children,
className,
...props
}: React.ButtonHTMLAttributes<HTMLButtonElement>) {
return (
<button
type="button"
className={cn(
"inline-flex items-center gap-1.5 rounded-full border px-2.5 py-1 text-xs",
"hover:bg-accent/60 transition-colors cursor-pointer",
className,
)}
{...props}
>
{children}
</button>
);
}
export function CreateProjectModal({ onClose }: { onClose: () => void }) {
const router = useNavigation();
const workspace = useCurrentWorkspace();
const workspaceName = workspace?.name;
const wsPaths = useWorkspacePaths();
const wsId = useWorkspaceId();
const { data: members = [] } = useQuery(memberListOptions(wsId));
const { data: agents = [] } = useQuery(agentListOptions(wsId));
const { getActorName } = useActorName();
const [title, setTitle] = useState("");
const descEditorRef = useRef<ContentEditorRef>(null);
const [status, setStatus] = useState<ProjectStatus>("planned");
const [priority, setPriority] = useState<ProjectPriority>("none");
const [leadType, setLeadType] = useState<"member" | "agent" | undefined>();
const [leadId, setLeadId] = useState<string | undefined>();
const [icon, setIcon] = useState<string | undefined>();
const [iconPickerOpen, setIconPickerOpen] = useState(false);
const [submitting, setSubmitting] = useState(false);
const [isExpanded, setIsExpanded] = useState(false);
const [leadOpen, setLeadOpen] = useState(false);
const [leadFilter, setLeadFilter] = useState("");
const leadQuery = leadFilter.toLowerCase();
const filteredMembers = members.filter((m) => m.name.toLowerCase().includes(leadQuery));
const filteredAgents = agents.filter(
(a) => !a.archived_at && a.name.toLowerCase().includes(leadQuery),
);
const leadLabel = leadType && leadId ? getActorName(leadType, leadId) : "Lead";
const createProject = useCreateProject();
const handleSubmit = async () => {
if (!title.trim() || submitting) return;
setSubmitting(true);
try {
const project = await createProject.mutateAsync({
title: title.trim(),
description: descEditorRef.current?.getMarkdown()?.trim() || undefined,
icon,
status,
priority,
lead_type: leadType,
lead_id: leadId,
});
onClose();
toast.success("Project created");
router.push(wsPaths.projectDetail(project.id));
} catch {
toast.error("Failed to create project");
} finally {
setSubmitting(false);
}
};
return (
<Dialog open onOpenChange={(v) => { if (!v) onClose(); }}>
<DialogContent
showCloseButton={false}
className={cn(
"p-0 gap-0 flex flex-col overflow-hidden",
"!top-1/2 !left-1/2 !-translate-x-1/2",
"!transition-all !duration-300 !ease-out",
isExpanded
? "!max-w-4xl !w-full !h-5/6 !-translate-y-1/2"
: "!max-w-2xl !w-full !h-96 !-translate-y-1/2",
)}
>
<DialogTitle className="sr-only">New Project</DialogTitle>
<div className="flex items-center justify-between px-5 pt-3 pb-2 shrink-0">
<div className="flex items-center gap-1.5 text-xs">
<span className="text-muted-foreground">{workspaceName}</span>
<ChevronRight className="size-3 text-muted-foreground/50" />
<span className="font-medium">New project</span>
</div>
<div className="flex items-center gap-1">
<Tooltip>
<TooltipTrigger
render={
<button
onClick={() => setIsExpanded(!isExpanded)}
className="rounded-sm p-1.5 opacity-70 hover:opacity-100 hover:bg-accent/60 transition-all cursor-pointer"
>
{isExpanded ? <Minimize2 className="size-4" /> : <Maximize2 className="size-4" />}
</button>
}
/>
<TooltipContent side="bottom">{isExpanded ? "Collapse" : "Expand"}</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger
render={
<button
onClick={onClose}
className="rounded-sm p-1.5 opacity-70 hover:opacity-100 hover:bg-accent/60 transition-all cursor-pointer"
>
<XIcon className="size-4" />
</button>
}
/>
<TooltipContent side="bottom">Close</TooltipContent>
</Tooltip>
</div>
</div>
<div className="px-5 pb-2 shrink-0">
<Popover open={iconPickerOpen} onOpenChange={setIconPickerOpen}>
<PopoverTrigger
render={
<button
type="button"
className="text-2xl cursor-pointer rounded-lg p-1 -ml-1 hover:bg-accent/60 transition-colors"
title="Choose icon"
>
{icon || "📁"}
</button>
}
/>
<PopoverContent align="start" className="w-auto p-0">
<EmojiPicker
onSelect={(emoji) => {
setIcon(emoji);
setIconPickerOpen(false);
}}
/>
</PopoverContent>
</Popover>
<TitleEditor
autoFocus
defaultValue=""
placeholder="Project title"
className="text-lg font-semibold"
onChange={(v) => setTitle(v)}
onSubmit={handleSubmit}
/>
</div>
<div className="flex-1 min-h-0 overflow-y-auto px-5">
<ContentEditor
ref={descEditorRef}
defaultValue=""
placeholder="Add description..."
debounceMs={500}
/>
</div>
<div className="flex items-center gap-1.5 px-4 py-2 shrink-0 flex-wrap">
<DropdownMenu>
<DropdownMenuTrigger
render={
<PillButton>
<span className={cn("size-2 rounded-full", PROJECT_STATUS_CONFIG[status].dotColor)} />
<span>{PROJECT_STATUS_CONFIG[status].label}</span>
</PillButton>
}
/>
<DropdownMenuContent align="start" className="w-44">
{PROJECT_STATUS_ORDER.map((s) => (
<DropdownMenuItem key={s} onClick={() => setStatus(s)}>
<span className={cn("size-2 rounded-full", PROJECT_STATUS_CONFIG[s].dotColor)} />
<span>{PROJECT_STATUS_CONFIG[s].label}</span>
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
<DropdownMenu>
<DropdownMenuTrigger
render={
<PillButton>
<PriorityIcon priority={priority} />
<span>{PROJECT_PRIORITY_CONFIG[priority].label}</span>
</PillButton>
}
/>
<DropdownMenuContent align="start" className="w-44">
{PROJECT_PRIORITY_ORDER.map((pr) => (
<DropdownMenuItem key={pr} onClick={() => setPriority(pr)}>
<PriorityIcon priority={pr} />
<span>{PROJECT_PRIORITY_CONFIG[pr].label}</span>
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
<Popover
open={leadOpen}
onOpenChange={(v) => {
setLeadOpen(v);
if (!v) setLeadFilter("");
}}
>
<PopoverTrigger
render={
<PillButton>
{leadType && leadId ? (
<>
<ActorAvatar actorType={leadType} actorId={leadId} size={16} />
<span>{leadLabel}</span>
</>
) : (
<span className="text-muted-foreground">Lead</span>
)}
</PillButton>
}
/>
<PopoverContent align="start" className="w-52 p-0">
<div className="px-2 py-1.5 border-b">
<input
type="text"
value={leadFilter}
onChange={(e) => setLeadFilter(e.target.value)}
placeholder="Assign lead..."
className="w-full bg-transparent text-sm placeholder:text-muted-foreground outline-none"
/>
</div>
<div className="p-1 max-h-60 overflow-y-auto">
<button
type="button"
onClick={() => {
setLeadType(undefined);
setLeadId(undefined);
setLeadOpen(false);
}}
className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-sm hover:bg-accent transition-colors"
>
<UserMinus className="h-3.5 w-3.5 text-muted-foreground" />
<span className="text-muted-foreground">No lead</span>
</button>
{filteredMembers.length > 0 && (
<>
<div className="px-2 pt-2 pb-1 text-xs font-medium text-muted-foreground uppercase tracking-wider">
Members
</div>
{filteredMembers.map((m) => (
<button
type="button"
key={m.user_id}
onClick={() => {
setLeadType("member");
setLeadId(m.user_id);
setLeadOpen(false);
}}
className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-sm hover:bg-accent transition-colors"
>
<ActorAvatar actorType="member" actorId={m.user_id} size={16} />
<span>{m.name}</span>
</button>
))}
</>
)}
{filteredAgents.length > 0 && (
<>
<div className="px-2 pt-2 pb-1 text-xs font-medium text-muted-foreground uppercase tracking-wider">
Agents
</div>
{filteredAgents.map((a) => (
<button
type="button"
key={a.id}
onClick={() => {
setLeadType("agent");
setLeadId(a.id);
setLeadOpen(false);
}}
className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-sm hover:bg-accent transition-colors"
>
<ActorAvatar actorType="agent" actorId={a.id} size={16} />
<span>{a.name}</span>
</button>
))}
</>
)}
{filteredMembers.length === 0 &&
filteredAgents.length === 0 &&
leadFilter && (
<div className="px-2 py-3 text-center text-sm text-muted-foreground">
No results
</div>
)}
</div>
</PopoverContent>
</Popover>
</div>
<div className="flex items-center justify-end px-4 py-3 border-t shrink-0">
<Button size="sm" onClick={handleSubmit} disabled={!title.trim() || submitting}>
{submitting ? "Creating..." : "Create Project"}
</Button>
</div>
</DialogContent>
</Dialog>
);
}

View File

@@ -1,8 +1,12 @@
"use client";
import { useRef, useState } from "react";
import { useNavigation } from "../navigation";
import { useImmersiveMode } from "../platform";
import { toast } from "sonner";
import { ArrowLeft } from "lucide-react";
import { Input } from "@multica/ui/components/ui/input";
import { Label } from "@multica/ui/components/ui/label";
import { Button } from "@multica/ui/components/ui/button";
import {
Dialog,
@@ -10,15 +14,84 @@ import {
DialogTitle,
DialogDescription,
} from "@multica/ui/components/ui/dialog";
import { Card, CardContent } from "@multica/ui/components/ui/card";
import { useCreateWorkspace } from "@multica/core/workspace/mutations";
import { paths } from "@multica/core/paths";
import { CreateWorkspaceForm } from "../workspace/create-workspace-form";
import {
WORKSPACE_SLUG_CONFLICT_ERROR,
WORKSPACE_SLUG_FORMAT_ERROR,
WORKSPACE_SLUG_REGEX,
isWorkspaceSlugConflict,
nameToWorkspaceSlug,
} from "../workspace/slug";
export function CreateWorkspaceModal({ onClose }: { onClose: () => void }) {
// This modal is full-screen, so it covers the app titlebar. On macOS desktop
// we hide the traffic lights for its lifetime so the Back button in the top-
// left corner isn't stolen by the native controls' hit-test. No-op elsewhere.
useImmersiveMode();
const router = useNavigation();
const createWorkspace = useCreateWorkspace();
const [name, setName] = useState("");
const [slug, setSlug] = useState("");
const [slugServerError, setSlugServerError] = useState<string | null>(null);
const slugTouched = useRef(false);
const slugValidationError =
slug.length > 0 && !WORKSPACE_SLUG_REGEX.test(slug)
? WORKSPACE_SLUG_FORMAT_ERROR
: null;
const slugError = slugValidationError ?? slugServerError;
const canSubmit =
name.trim().length > 0 && slug.trim().length > 0 && !slugError;
const handleNameChange = (value: string) => {
setName(value);
if (!slugTouched.current) {
setSlug(nameToWorkspaceSlug(value));
setSlugServerError(null);
}
};
const handleSlugChange = (value: string) => {
slugTouched.current = true;
setSlug(value);
setSlugServerError(null);
};
const handleCreate = () => {
if (!canSubmit) return;
// The modal is only reachable from an authenticated workspace context
// (via the global modal registry). After creating a new workspace the
// user should land INSIDE it at its issues page, not in /onboarding —
// onboarding exists only for users with zero workspaces. Navigation is the
// only way to switch workspaces now (URL is the source of truth), so the
// push below is sufficient — no imperative store writes needed.
createWorkspace.mutate(
{ name: name.trim(), slug: slug.trim() },
{
onSuccess: (newWs) => {
onClose();
// Navigate INTO the new workspace. The mutation's own onSuccess
// (in core/workspace/mutations.ts) runs before this callback and
// has already seeded the workspace list cache, so the destination
// [workspaceSlug]/layout will resolve newWs.slug → workspace
// synchronously without a loading flash.
router.push(paths.workspace(newWs.slug).issues());
},
onError: (error) => {
if (isWorkspaceSlugConflict(error)) {
setSlugServerError(WORKSPACE_SLUG_CONFLICT_ERROR);
toast.error("Choose a different workspace URL");
return;
}
toast.error("Failed to create workspace");
},
},
);
};
return (
<Dialog
@@ -62,17 +135,49 @@ export function CreateWorkspaceModal({ onClose }: { onClose: () => void }) {
projects and issues.
</DialogDescription>
</div>
<CreateWorkspaceForm
onSuccess={(newWs) => {
onClose();
// Navigate INTO the new workspace. The mutation's own onSuccess
// (in core/workspace/mutations.ts) runs before this callback and
// has already seeded the workspace list cache, so the destination
// [workspaceSlug]/layout will resolve newWs.slug → workspace
// synchronously without a loading flash.
router.push(paths.workspace(newWs.slug).issues());
}}
/>
<Card className="w-full">
<CardContent className="space-y-4 pt-6">
<div className="space-y-1.5">
<Label>Workspace Name</Label>
<Input
autoFocus
type="text"
value={name}
onChange={(e) => handleNameChange(e.target.value)}
placeholder="My Workspace"
/>
</div>
<div className="space-y-1.5">
<Label>Workspace URL</Label>
<div className="flex items-center gap-0 rounded-md border bg-background focus-within:ring-2 focus-within:ring-ring">
<span className="pl-3 text-sm text-muted-foreground select-none">
multica.ai/
</span>
<Input
type="text"
value={slug}
onChange={(e) => handleSlugChange(e.target.value)}
placeholder="my-workspace"
className="border-0 shadow-none focus-visible:ring-0"
onKeyDown={(e) => e.key === "Enter" && handleCreate()}
/>
</div>
{slugError && (
<p className="text-xs text-destructive">{slugError}</p>
)}
</div>
</CardContent>
</Card>
<Button
className="w-full"
size="lg"
onClick={handleCreate}
disabled={createWorkspace.isPending || !canSubmit}
>
{createWorkspace.isPending ? "Creating..." : "Create workspace"}
</Button>
</div>
</DialogContent>
</Dialog>

View File

@@ -3,7 +3,6 @@
import { useModalStore } from "@multica/core/modals";
import { CreateWorkspaceModal } from "./create-workspace";
import { CreateIssueModal } from "./create-issue";
import { CreateProjectModal } from "./create-project";
export function ModalRegistry() {
const modal = useModalStore((s) => s.modal);
@@ -15,8 +14,6 @@ export function ModalRegistry() {
return <CreateWorkspaceModal onClose={close} />;
case "create-issue":
return <CreateIssueModal onClose={close} data={data} />;
case "create-project":
return <CreateProjectModal onClose={close} />;
default:
return null;
}

View File

@@ -129,35 +129,19 @@ export function MyIssuesPage() {
<Skeleton className="h-5 w-5 rounded" />
<Skeleton className="h-4 w-32" />
</div>
<div className="flex h-12 shrink-0 items-center justify-between px-4">
<div className="flex items-center gap-1">
<Skeleton className="h-8 w-14 rounded-md" />
<Skeleton className="h-8 w-20 rounded-md" />
<Skeleton className="h-8 w-16 rounded-md" />
</div>
<div className="flex items-center gap-1">
<Skeleton className="h-8 w-8 rounded-md" />
<Skeleton className="h-8 w-8 rounded-md" />
<Skeleton className="h-8 w-8 rounded-md" />
</div>
<div className="flex h-12 shrink-0 items-center justify-between border-b px-4">
<Skeleton className="h-5 w-24" />
<Skeleton className="h-8 w-24" />
</div>
<div className="flex flex-1 min-h-0 gap-4 overflow-x-auto p-4">
{Array.from({ length: 5 }).map((_, i) => (
<div key={i} className="flex min-w-52 flex-1 flex-col gap-2">
<Skeleton className="h-4 w-20" />
<Skeleton className="h-24 w-full rounded-lg" />
<Skeleton className="h-24 w-full rounded-lg" />
</div>
))}
</div>
{viewMode === "list" ? (
<div className="flex-1 min-h-0 overflow-y-auto p-2 space-y-1">
{Array.from({ length: 4 }).map((_, i) => (
<Skeleton key={i} className="h-10 w-full rounded-lg" />
))}
</div>
) : (
<div className="flex flex-1 min-h-0 gap-4 overflow-x-auto p-4">
{Array.from({ length: 5 }).map((_, i) => (
<div key={i} className="flex min-w-52 flex-1 flex-col gap-2">
<Skeleton className="h-4 w-20" />
<Skeleton className="h-24 w-full rounded-lg" />
<Skeleton className="h-24 w-full rounded-lg" />
</div>
))}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,3 @@
export { OnboardingWizard } from "./onboarding-wizard";
export type { OnboardingWizardProps } from "./onboarding-wizard";
export { StepWorkspace } from "./step-workspace";

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