mirror of
https://github.com/multica-ai/multica.git
synced 2026-06-17 19:59:20 +02:00
Compare commits
1 Commits
agent/lamb
...
fix/self-h
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
be8a2040f5 |
12
.env.example
12
.env.example
@@ -7,8 +7,10 @@ DATABASE_URL=postgres://multica:multica@localhost:5432/multica?sslmode=disable
|
||||
|
||||
# Server
|
||||
PORT=8080
|
||||
APP_ENV=
|
||||
TASK_DOMAIN=localhost
|
||||
JWT_SECRET=change-me-in-production
|
||||
MULTICA_SERVER_URL=ws://localhost:8080/ws
|
||||
MULTICA_SERVER_URL=http://localhost:8080
|
||||
MULTICA_APP_URL=http://localhost:3000
|
||||
MULTICA_DAEMON_CONFIG=
|
||||
MULTICA_WORKSPACE_ID=
|
||||
@@ -55,11 +57,9 @@ ALLOWED_ORIGINS=
|
||||
# Frontend
|
||||
FRONTEND_PORT=3000
|
||||
FRONTEND_ORIGIN=http://localhost:3000
|
||||
# Leave empty — auto-derived from page origin in browser, set by Makefile for local dev.
|
||||
# Only set explicitly if frontend and backend are on different domains.
|
||||
NEXT_PUBLIC_API_URL=
|
||||
NEXT_PUBLIC_WS_URL=
|
||||
NEXT_PUBLIC_API_URL=http://localhost:8080
|
||||
NEXT_PUBLIC_WS_URL=ws://localhost:8080/ws
|
||||
|
||||
# Remote API (optional) — set to proxy local frontend to a remote backend
|
||||
# Leave empty to use local backend (localhost:8080)
|
||||
# REMOTE_API_URL=https://multica-api.copilothub.ai
|
||||
REMOTE_API_URL=http://localhost:8080
|
||||
|
||||
11
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
11
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
@@ -3,17 +3,6 @@ description: Report a bug — something that's broken, crashes, or behaves incor
|
||||
title: "[Bug]: "
|
||||
labels: ["bug"]
|
||||
body:
|
||||
- type: dropdown
|
||||
id: deployment
|
||||
attributes:
|
||||
label: Deployment type
|
||||
description: Are you using the hosted version or a self-hosted instance?
|
||||
options:
|
||||
- multica.ai (hosted)
|
||||
- Self-hosted
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: description
|
||||
attributes:
|
||||
|
||||
11
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
11
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
@@ -3,17 +3,6 @@ description: Suggest a new feature or improvement.
|
||||
title: "[Feature]: "
|
||||
labels: ["enhancement"]
|
||||
body:
|
||||
- type: dropdown
|
||||
id: deployment
|
||||
attributes:
|
||||
label: Deployment type
|
||||
description: Are you using the hosted version or a self-hosted instance?
|
||||
options:
|
||||
- multica.ai (hosted)
|
||||
- Self-hosted
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: description
|
||||
attributes:
|
||||
|
||||
67
CLAUDE.md
67
CLAUDE.md
@@ -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 1–3 (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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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`."
|
||||
|
||||
---
|
||||
|
||||
|
||||
194
CONTRIBUTING.md
194
CONTRIBUTING.md
@@ -9,7 +9,6 @@ It covers:
|
||||
- isolated worktree development
|
||||
- the shared PostgreSQL model
|
||||
- testing and verification
|
||||
- full-stack isolated testing (backend + frontend + daemon from source)
|
||||
- troubleshooting and destructive reset options
|
||||
|
||||
## Development Model
|
||||
@@ -309,199 +308,6 @@ make daemon
|
||||
The daemon authenticates using the CLI's stored token (`multica login`).
|
||||
It registers runtimes for all watched workspaces from the CLI config.
|
||||
|
||||
## Full-Stack Isolated Testing
|
||||
|
||||
This section covers running the complete stack (backend, frontend, daemon) from
|
||||
source in a fully isolated environment. Useful for testing end-to-end changes
|
||||
that span multiple components, or for automated CI/AI workflows that need zero
|
||||
human intervention.
|
||||
|
||||
### Why Not Just `make daemon`?
|
||||
|
||||
`make daemon` uses the system-installed CLI's stored token and connects to
|
||||
whatever server is configured in `~/.multica/config.json`. That's fine for
|
||||
day-to-day development against a shared server, but for fully isolated testing
|
||||
you need:
|
||||
|
||||
- a local backend and frontend (from source)
|
||||
- a local daemon (from source) with its own profile
|
||||
- automated authentication (no browser login)
|
||||
- no interference with your production CLI config
|
||||
|
||||
### Dynamic Profile Naming
|
||||
|
||||
Each worktree must use a unique daemon profile to avoid collisions when
|
||||
multiple features run in parallel.
|
||||
|
||||
The profile name is derived from the worktree directory using the same
|
||||
slug + hash pattern as `scripts/init-worktree-env.sh`:
|
||||
|
||||
```bash
|
||||
WORKTREE_DIR="$(basename "$PWD")"
|
||||
SLUG="$(printf '%s' "$WORKTREE_DIR" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9]/_/g; s/__*/_/g; s/^_//; s/_$//')"
|
||||
HASH="$(printf '%s' "$PWD" | cksum | awk '{print $1}')"
|
||||
OFFSET=$((HASH % 1000))
|
||||
PROFILE="dev-${SLUG}-${OFFSET}"
|
||||
```
|
||||
|
||||
Example: worktree at `../multica-feat-auth` produces profile
|
||||
`dev-multica_feat_auth-347`, matching that worktree's port and database
|
||||
allocation.
|
||||
|
||||
### Start the Isolated Environment
|
||||
|
||||
Run all steps from the worktree root (where the Makefile is).
|
||||
|
||||
#### 1. Start backend, frontend, and database
|
||||
|
||||
```bash
|
||||
make dev
|
||||
```
|
||||
|
||||
Wait for the backend to be healthy:
|
||||
|
||||
```bash
|
||||
PORT=$(grep '^PORT=' .env.worktree 2>/dev/null || grep '^PORT=' .env | head -1 | cut -d= -f2)
|
||||
PORT=${PORT:-8080}
|
||||
SERVER="http://localhost:${PORT}"
|
||||
|
||||
for i in $(seq 1 30); do
|
||||
curl -sf "$SERVER/health" > /dev/null 2>&1 && break
|
||||
sleep 2
|
||||
done
|
||||
```
|
||||
|
||||
#### 2. Create a test user and token (automated auth)
|
||||
|
||||
In non-production environments the verification code is fixed at `888888`:
|
||||
|
||||
```bash
|
||||
curl -s -X POST "$SERVER/auth/send-code" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"email": "dev@localhost"}'
|
||||
|
||||
JWT=$(curl -s -X POST "$SERVER/auth/verify-code" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"email": "dev@localhost", "code": "888888"}' | jq -r '.token')
|
||||
|
||||
PAT=$(curl -s -X POST "$SERVER/api/tokens" \
|
||||
-H "Authorization: Bearer $JWT" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"name": "auto-dev", "expires_in_days": 365}' | jq -r '.token')
|
||||
```
|
||||
|
||||
#### 3. Create a workspace
|
||||
|
||||
```bash
|
||||
WS=$(curl -s -X POST "$SERVER/api/workspaces" \
|
||||
-H "Authorization: Bearer $PAT" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"name": "Dev", "slug": "dev"}' | jq -r '.id')
|
||||
```
|
||||
|
||||
#### 4. Compute profile name and write CLI config
|
||||
|
||||
```bash
|
||||
# Compute profile (see Dynamic Profile Naming above)
|
||||
WORKTREE_DIR="$(basename "$PWD")"
|
||||
SLUG="$(printf '%s' "$WORKTREE_DIR" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9]/_/g; s/__*/_/g; s/^_//; s/_$//')"
|
||||
HASH="$(printf '%s' "$PWD" | cksum | awk '{print $1}')"
|
||||
OFFSET=$((HASH % 1000))
|
||||
PROFILE="dev-${SLUG}-${OFFSET}"
|
||||
|
||||
FRONTEND_PORT=$(grep '^FRONTEND_PORT=' .env.worktree 2>/dev/null || grep '^FRONTEND_PORT=' .env | head -1 | cut -d= -f2)
|
||||
FRONTEND_PORT=${FRONTEND_PORT:-3000}
|
||||
|
||||
CONFIG_DIR="$HOME/.multica/profiles/$PROFILE"
|
||||
mkdir -p "$CONFIG_DIR"
|
||||
|
||||
cat > "$CONFIG_DIR/config.json" << EOF
|
||||
{
|
||||
"server_url": "$SERVER",
|
||||
"app_url": "http://localhost:${FRONTEND_PORT}",
|
||||
"token": "$PAT",
|
||||
"workspace_id": "$WS",
|
||||
"watched_workspaces": [{"id": "$WS", "name": "Dev"}]
|
||||
}
|
||||
EOF
|
||||
```
|
||||
|
||||
#### 5. Start the daemon from source
|
||||
|
||||
```bash
|
||||
make cli ARGS="daemon start --profile $PROFILE"
|
||||
```
|
||||
|
||||
The daemon runs from the current worktree's Go source, connecting to the
|
||||
local backend. Agent-executed `multica` commands automatically use the same
|
||||
binary (the daemon prepends its own directory to `PATH`).
|
||||
|
||||
### Stop the Isolated Environment
|
||||
|
||||
```bash
|
||||
# Compute profile (same formula)
|
||||
PROFILE="dev-$(printf '%s' "$(basename "$PWD")" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9]/_/g; s/__*/_/g; s/^_//; s/_$//')-$(( $(printf '%s' "$PWD" | cksum | awk '{print $1}') % 1000 ))"
|
||||
|
||||
# 1. Stop daemon
|
||||
make cli ARGS="daemon stop --profile $PROFILE"
|
||||
|
||||
# 2. Stop backend + frontend
|
||||
make stop # main checkout
|
||||
make stop-worktree # worktree checkout
|
||||
|
||||
# 3. (Optional) Stop shared PostgreSQL
|
||||
make db-down
|
||||
|
||||
# 4. (Optional) Clean build artifacts
|
||||
make clean
|
||||
|
||||
# 5. (Optional) Remove profile config
|
||||
rm -rf "$HOME/.multica/profiles/$PROFILE"
|
||||
```
|
||||
|
||||
### Desktop App Local Testing
|
||||
|
||||
To test the Electron desktop app against a local backend:
|
||||
|
||||
```bash
|
||||
# After backend is running (make dev)
|
||||
pnpm dev:desktop
|
||||
```
|
||||
|
||||
This automatically:
|
||||
|
||||
1. Compiles the `multica` CLI from `server/cmd/multica` into
|
||||
`apps/desktop/resources/bin/multica`
|
||||
2. Creates an isolated profile named `desktop-localhost-<PORT>`
|
||||
3. Starts and manages its own daemon instance
|
||||
4. Connects to the local backend
|
||||
|
||||
Login in the Desktop UI with `dev@localhost` and code `888888`.
|
||||
|
||||
If the backend runs on a non-default port (worktree), create
|
||||
`apps/desktop/.env.development.local`:
|
||||
|
||||
```bash
|
||||
VITE_API_URL=http://localhost:<backend-port>
|
||||
VITE_WS_URL=ws://localhost:<backend-port>/ws
|
||||
```
|
||||
|
||||
### Isolation Guarantee
|
||||
|
||||
Nothing in this flow touches the system-installed `multica` or the default
|
||||
`~/.multica/config.json`:
|
||||
|
||||
| Resource | System / Production | Local Dev (per-worktree) |
|
||||
|---|---|---|
|
||||
| Config | `~/.multica/config.json` | `~/.multica/profiles/dev-<slug>-<hash>/config.json` |
|
||||
| Daemon PID | `~/.multica/daemon.pid` | `~/.multica/profiles/dev-<slug>-<hash>/daemon.pid` |
|
||||
| Health port | `19514` | `19514 + 1 + (name_hash % 1000)` |
|
||||
| Workspaces dir | `~/multica_workspaces/` | `~/multica_workspaces_dev-<slug>-<hash>/` |
|
||||
| Database | remote / production | local Docker: `multica_<slug>_<hash>` |
|
||||
| Desktop profile | `desktop-api.multica.ai` | `desktop-localhost-<port>` |
|
||||
|
||||
Multiple worktrees can run simultaneously without conflict.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Missing Env File
|
||||
|
||||
15
Caddyfile
Normal file
15
Caddyfile
Normal file
@@ -0,0 +1,15 @@
|
||||
{$TASK_DOMAIN} {
|
||||
@next_static path /_next/static/*
|
||||
header @next_static Cache-Control "public, max-age=31536000, immutable, no-transform"
|
||||
|
||||
reverse_proxy /api/* backend:8080
|
||||
reverse_proxy /auth/send-code backend:8080
|
||||
reverse_proxy /auth/verify-code backend:8080
|
||||
reverse_proxy /auth/google backend:8080
|
||||
reverse_proxy /auth/logout backend:8080
|
||||
reverse_proxy /ws backend:8080
|
||||
reverse_proxy /ws/* backend:8080
|
||||
reverse_proxy /health backend:8080
|
||||
reverse_proxy /health/* backend:8080
|
||||
reverse_proxy frontend:3000
|
||||
}
|
||||
@@ -37,9 +37,11 @@ RUN pnpm install --frozen-lockfile --offline
|
||||
# Set build-time env: tells Next.js rewrites to proxy API calls to the backend service
|
||||
ARG REMOTE_API_URL=http://backend:8080
|
||||
ARG NEXT_PUBLIC_GOOGLE_CLIENT_ID
|
||||
ARG NEXT_PUBLIC_API_URL
|
||||
ARG NEXT_PUBLIC_WS_URL
|
||||
ENV REMOTE_API_URL=$REMOTE_API_URL
|
||||
ENV NEXT_PUBLIC_GOOGLE_CLIENT_ID=$NEXT_PUBLIC_GOOGLE_CLIENT_ID
|
||||
ENV NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL
|
||||
ENV NEXT_PUBLIC_WS_URL=$NEXT_PUBLIC_WS_URL
|
||||
ENV STANDALONE=true
|
||||
|
||||
|
||||
@@ -1,383 +0,0 @@
|
||||
# Architecture Audit — Workspace & Realtime Cache
|
||||
|
||||
> 基于代码审计整理的 4 个任务。优先级:P0 一个、P1 一个、P2 两个。每个任务都包含问题、根因、受影响的 issue、复现步骤、修复方案、改动范围。
|
||||
|
||||
---
|
||||
|
||||
## 任务 1 — [P0] 空闲后列表数据陈旧
|
||||
|
||||
**关联 issue**:[#951](https://github.com/multica-ai/multica/issues/951)
|
||||
|
||||
### 问题
|
||||
|
||||
用户登录后静置一段时间,Issue 列表里缺失一部分数据(其他成员期间新建/变更的 issue 不出现)。登出再登入可以恢复。`ec5af33b` 声称 "Closes #951",但 issue 仍为 OPEN 状态 —— 因为它只修了 401 一种场景,没修 WS 半开这一种。
|
||||
|
||||
### 根因
|
||||
|
||||
系统把 cache 新鲜度的全部责任压给了 WebSocket 推送:
|
||||
|
||||
- `packages/core/query-client.ts:7` — `staleTime: Infinity`,cache 永不主动过期
|
||||
- `packages/core/query-client.ts:9` — `refetchOnWindowFocus: false`,tab 重新获得焦点也不 refetch
|
||||
- 依赖 WS 推送 `issue:created` / `issue:updated` 事件 invalidate cache
|
||||
|
||||
但 WS 层存在一个**不对称**:
|
||||
|
||||
- **服务端**:`server/internal/realtime/hub.go:83-96, 420-475` 有 54s ping / 60s pongWait,会清理死连接
|
||||
- **客户端**:`packages/core/api/ws-client.ts`(142 行全貌)**完全没有心跳检测**,只靠 `onclose` 事件触发重连
|
||||
|
||||
浏览器原生 `WebSocket` API 不把 ping/pong 帧暴露给 JS,所以 JS 层无法主动探测 "半开" 连接。当 NAT / 负载均衡器 / 笔记本睡眠导致 TCP 连接被静默切断时:
|
||||
|
||||
1. 浏览器 `readyState` 仍是 `OPEN`
|
||||
2. `onclose` 不触发
|
||||
3. `ws-client.ts:70-73` 的 3 秒重连逻辑不跑
|
||||
4. `packages/core/realtime/use-realtime-sync.ts:462-487` 的 `onReconnect` 全量 invalidate 不跑
|
||||
5. 期间的 WS 事件进黑洞
|
||||
6. cache 保持旧快照
|
||||
|
||||
### 复现
|
||||
|
||||
**浏览器 DevTools 里的 "Block request URL" 不行** —— 那会触发 `onclose`,走正常重连 → 不复现。真正的半开需要在网络层静默丢包。
|
||||
|
||||
**方法 A(推荐,最接近真实场景)**:macOS 用 pfctl 丢包
|
||||
|
||||
```bash
|
||||
# 假设后端在 8080
|
||||
sudo pfctl -E
|
||||
echo "block drop out quick proto tcp to any port 8080" | sudo pfctl -f -
|
||||
|
||||
# 观察:
|
||||
# - Console 里没有 "disconnected, reconnecting in 3s" 日志
|
||||
# - Network 里 WS 连接仍显示 Pending / 101
|
||||
# 用另一个账号/CLI 创建一个 issue
|
||||
# 回到原客户端: 列表不更新
|
||||
# 登出再登入: 列表恢复完整
|
||||
|
||||
sudo pfctl -d # 解除
|
||||
```
|
||||
|
||||
**方法 B(不动网络)**:临时修改代码,在 `packages/core/api/ws-client.ts:52` 的 `onmessage` 处理器里加一行 `return;` 在前面,吞掉所有入站消息。效果等价于半开。
|
||||
|
||||
### 修复方案(三个选项,推荐 C)
|
||||
|
||||
#### 选项 A — 浏览器端心跳探活(治本,改动大)
|
||||
|
||||
在 `ws-client.ts` 加客户端侧的心跳检测:记录 `lastMessageTime`,定时器检查若超过 N 秒没收到任何消息就主动 `ws.close()`,触发现有重连逻辑。
|
||||
|
||||
- 优点:从根本上解决半开问题
|
||||
- 缺点:浏览器原生 API 没有 ping 能力,需要服务端配合发"应用层 heartbeat"消息供客户端更新 `lastMessageTime`;服务端改 + 客户端改
|
||||
|
||||
#### 选项 B — Page Visibility API 触发 invalidate(治标,改动小)
|
||||
|
||||
在 `packages/core/platform/core-provider.tsx` 加 `visibilitychange` 监听,tab 重新可见时强制 `queryClient.invalidateQueries({ queryKey: issueKeys.all(wsId) })`(及其他关键 key)。
|
||||
|
||||
- 优点:~10 行代码,能兜住 80% 场景(睡眠、切后台 tab)
|
||||
- 缺点:treats symptom, 不是真正的半开检测;对"一直保持 tab 可见但网络层断了"的场景无效
|
||||
|
||||
#### 选项 C — **A + B 组合**(推荐)
|
||||
|
||||
- 短期上 B,立刻止血
|
||||
- 中期上 A,把 cache 新鲜度从"只信 WS"改成"WS 是优化,Visibility 是兜底"
|
||||
- 可选加 `refetchOnWindowFocus: true` 或把 `staleTime` 改成一个有限值(比如 5 min),作为第三层保险
|
||||
|
||||
### 改动范围
|
||||
|
||||
| 方案 | 文件 | 改动规模 |
|
||||
|---|---|---|
|
||||
| B | `packages/core/platform/core-provider.tsx` | ~10 行 |
|
||||
| A 客户端 | `packages/core/api/ws-client.ts` | ~30 行 |
|
||||
| A 服务端 | `server/internal/realtime/hub.go` | 加 app-level heartbeat message |
|
||||
|
||||
### 验证
|
||||
|
||||
修完之后:
|
||||
|
||||
1. 跑方法 A 复现流程,确认数据不再丢失
|
||||
2. 加 e2e 测试:模拟 `document.dispatchEvent(new Event('visibilitychange'))` + 验证 issue list 被 refetch
|
||||
|
||||
---
|
||||
|
||||
## 任务 2 — [P1] Workspace 不在 URL 路径中
|
||||
|
||||
**关联 issue**:MUL-723(slug 不在 URL)、MUL-43(切换 workspace 报错)、MUL-509(手机端无法切换)
|
||||
|
||||
> **注意**:审计中提到的 MUL-43 / MUL-476 issue 编号需要当面核对一次 —— agent 查询 GitHub 后返回的标题对不上(看起来是别的 PR)。交接时请让执行人以具体症状为准。
|
||||
|
||||
### 问题
|
||||
|
||||
当前 workspace 身份完全靠 `X-Workspace-ID` HTTP header + Zustand store + localStorage 承载,URL 里没有 workspace 信息。所有路径都是 `/issues`、`/issues/:id` 这种 workspace-agnostic 的。
|
||||
|
||||
### 根因
|
||||
|
||||
**数据库和 API 已经支持 slug**:
|
||||
|
||||
- `server/migrations/001_init.up.sql:15-23` — workspace 表有 `slug TEXT UNIQUE NOT NULL`
|
||||
- `server/pkg/db/queries/workspace.sql:11-13` — 有 `GetWorkspaceBySlug` 查询
|
||||
- `packages/core/types/workspace.ts:8-19` — Workspace 类型里有 slug 字段
|
||||
|
||||
**但前端路由和导航层没用它**:
|
||||
|
||||
- Web 路由:`apps/web/app/(dashboard)/` 下 25 个 route file 都是 workspace-implicit
|
||||
- Desktop 路由:`apps/desktop/src/renderer/src/routes.tsx:71-143` 同样
|
||||
- Navigation 适配器 `apps/web/platform/navigation.tsx` 直接透传 `router.push`,没有任何 workspace 前缀逻辑
|
||||
|
||||
**workspace 切换只靠 sidebar UI**(`packages/views/layout/app-sidebar.tsx:284-286`):
|
||||
|
||||
```tsx
|
||||
if (ws.id !== workspace?.id) {
|
||||
push("/issues"); // 硬跳 /issues(workspace-implicit!)
|
||||
switchWorkspace(ws); // 然后改 store
|
||||
}
|
||||
```
|
||||
|
||||
这种设计使得:
|
||||
|
||||
- 手机端因为没 sidebar UI,也没 URL 层切换入口,**完全切不了 workspace**(MUL-509)
|
||||
- 把 `/issues/xxx` 链接发给处于不同 workspace 的同事,会打开错误 workspace 下的 issue,或找不到报错(MUL-43 系列)
|
||||
- 分享链接没有 workspace 上下文,接收方必须先手动切对 workspace
|
||||
|
||||
### 复现
|
||||
|
||||
1. **MUL-723**:登录 → 观察地址栏,没有任何 workspace 标识
|
||||
2. **MUL-43**:
|
||||
- 加入两个 workspace A 和 B
|
||||
- 在 A 中打开某个 issue `/issues/abc123`
|
||||
- 切到 B,URL 不变 → 访问失败 / 显示错数据
|
||||
3. **MUL-509**:手机浏览器打开,尝试切 workspace → 无法切换(UI 不显示 sidebar 触发器或触发器无法切)
|
||||
|
||||
### 修复方案(三个选项,推荐 A)
|
||||
|
||||
#### 选项 A — `/ws/:slug/...` URL 前缀(根本方案,推荐)
|
||||
|
||||
所有路径加上 workspace slug 前缀。例如 `/issues/abc123` → `/ws/my-team/issues/abc123`。
|
||||
|
||||
**要改的地方**:
|
||||
|
||||
1. **Web 路由目录结构**:`apps/web/app/(dashboard)/` 下全部搬到 `apps/web/app/(dashboard)/ws/[slug]/...`(~25 个文件)
|
||||
2. **Desktop 路由**:`apps/desktop/src/renderer/src/routes.tsx:71-143` 给所有路径加 `/ws/:slug` 前缀
|
||||
3. **Navigation 适配器**:
|
||||
- `apps/web/platform/navigation.tsx` — `push(path)` 内部前置 `/ws/${workspace.slug}`,`pathname` 读取时去掉前缀
|
||||
- `apps/desktop/src/renderer/src/platform/navigation.tsx` — 同上
|
||||
4. **Sidebar 切换逻辑**:`packages/views/layout/app-sidebar.tsx:284-286` 改成 `push('/ws/${ws.slug}/issues')`(或依赖适配器自动加前缀就不用改)
|
||||
5. **服务端中间件**:`server/internal/middleware/workspace.go:41-46` 增加 "从 URL path 解析 slug → 查 ID → 校验 membership" 的逻辑,header 继续作为 fallback(迁移期兼容)
|
||||
|
||||
**预计改动**:~50-100 个文件(大部分是 route 搬迁,不是逻辑改动)、~5-7 人天
|
||||
|
||||
**不改也能工作的部分**:
|
||||
- `packages/core/api/client.ts` — 仍旧走 header,不用改
|
||||
- 所有 `packages/views/` 下的组件 —— 它们用 `useNavigation().push()` 抽象,适配器层处理前缀就行
|
||||
|
||||
**风险**:
|
||||
- 旧的 bookmark URL 失效(如果产品还没正式 ship,问题不大)
|
||||
- E2E 测试需要更新所有 URL 断言
|
||||
|
||||
#### 选项 B — `?ws=slug` query param(折中)
|
||||
|
||||
URL 形如 `/issues?ws=my-team`。改动更小(~30 个文件),URL 丑但向后兼容。推荐度低于 A。
|
||||
|
||||
#### 选项 C — 只修症状不动架构
|
||||
|
||||
在 `switchWorkspace` 和各个 query 之间加 debounce、error boundary 等 workaround。不解决根因,技术债越攒越多。**不推荐**。
|
||||
|
||||
### 改动范围(选项 A)
|
||||
|
||||
| 模块 | 文件数 | 备注 |
|
||||
|---|---|---|
|
||||
| Web routes | ~25 | 目录搬迁 |
|
||||
| Desktop routes | 1 | 路径前缀 |
|
||||
| Navigation adapters | 2 | 前缀逻辑 |
|
||||
| Server middleware | 1-2 | slug → ID 解析 |
|
||||
| 组件(不用改) | 30-40 | 用 `useNavigation` 的不受影响 |
|
||||
| E2E tests | 20-30 | URL 断言更新 |
|
||||
|
||||
---
|
||||
|
||||
## 任务 3 — [P1] Workspace 切换时 navigation 状态未隔离
|
||||
|
||||
**关联 issue**:MUL-43(切换报错)、MUL-476(本地缓存未按 workspace 隔离)
|
||||
|
||||
> 同上,这两个编号建议交接时核对症状。
|
||||
|
||||
### 问题
|
||||
|
||||
绝大多数 workspace-scoped 的 Zustand store 都正确使用了 `createWorkspaceAwareStorage`(key 后缀加 wsId 自动隔离),但 **`useNavigationStore` 是个例外**:它持久化了 `lastPath`,但用的是 global storage,切换 workspace 后里面仍是上个 workspace 的路径。
|
||||
|
||||
### 根因
|
||||
|
||||
**`packages/core/navigation/store.ts:15-31`**:
|
||||
|
||||
```typescript
|
||||
export const useNavigationStore = create<NavigationState>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
lastPath: "/issues",
|
||||
onPathChange: (path) => { /* ... */ set({ lastPath: path }); },
|
||||
}),
|
||||
{
|
||||
name: "multica_navigation",
|
||||
storage: createJSONStorage(() => createPersistStorage(defaultStorage)), // ← 这里用的是 global,不是 workspace-aware
|
||||
partialize: (state) => ({ lastPath: state.lastPath }),
|
||||
}
|
||||
)
|
||||
);
|
||||
// ← 没有调 registerForWorkspaceRehydration
|
||||
```
|
||||
|
||||
**对比:其他 store 都是正确的**:
|
||||
|
||||
| Store | 是否 workspace-aware | 是否注册 rehydration |
|
||||
|---|---|---|
|
||||
| useNavigationStore | ❌ | ❌ |
|
||||
| useIssuesScopeStore | ✅ | ✅ |
|
||||
| useIssueDraftStore | ✅ | ✅ |
|
||||
| useRecentIssuesStore | ✅ | ✅ |
|
||||
| useIssueViewStore | ✅ | ✅ |
|
||||
| myIssuesViewStore | ✅ | ✅ |
|
||||
| useChatStore | ✅(手动用 wsKey)| ✅ |
|
||||
|
||||
另外 `packages/core/platform/storage-cleanup.ts:10-19` 的 `WORKSPACE_SCOPED_KEYS` 列表里也漏了 `multica_navigation`。
|
||||
|
||||
**现有的 workaround**:`packages/views/layout/app-sidebar.tsx:285` 切 workspace 时硬跳到 `/issues`,正是为了绕开这个 bug。修好 navigation store 之后这行 hack 可以删掉。
|
||||
|
||||
### 复现
|
||||
|
||||
1. 在 workspace A 中打开一个具体 issue `/issues/abc123`
|
||||
2. 切到 workspace B
|
||||
3. 观察:如果没有 sidebar 的硬跳 workaround,会尝试恢复到 `/issues/abc123`,但那个 issue 不属于 B,导致 404 或错误
|
||||
|
||||
目前因为有硬跳 workaround,症状表现为"切 workspace 后总是回到 issue 首页"—— 这本身也是 bug(用户期望记住上次位置)。
|
||||
|
||||
### 修复方案(推荐 Option C:组合)
|
||||
|
||||
**三处改动**:
|
||||
|
||||
1. `packages/core/navigation/store.ts:28` —— 把 `createPersistStorage(defaultStorage)` 改成 `createWorkspaceAwareStorage(defaultStorage)`
|
||||
2. 同文件在末尾加:`registerForWorkspaceRehydration(() => useNavigationStore.persist.rehydrate());`
|
||||
3. `packages/core/platform/storage-cleanup.ts:10-19` 的 `WORKSPACE_SCOPED_KEYS` 数组里加 `"multica_navigation"`
|
||||
|
||||
**可选**:清理 `packages/views/layout/app-sidebar.tsx:285` 的 `push("/issues")` workaround(改完之后不再需要)。
|
||||
|
||||
### 改动范围
|
||||
|
||||
| 文件 | 改动 |
|
||||
|---|---|
|
||||
| `packages/core/navigation/store.ts` | 改 storage 类型、加 rehydration 注册(~3 行) |
|
||||
| `packages/core/platform/storage-cleanup.ts` | 数组加一行 |
|
||||
| `packages/core/platform/workspace-storage.test.ts` | 加 rehydration 的单测 |
|
||||
| `packages/views/layout/app-sidebar.tsx`(可选) | 移除硬跳 workaround |
|
||||
|
||||
**风险**:极低。只是把 navigation store 对齐到其他 store 已经在用的模式。
|
||||
|
||||
---
|
||||
|
||||
## 任务 4 — [P2] Workspace 生命周期副作用散落
|
||||
|
||||
**关联 issue**:MUL-727(创建后闪页)、MUL-728(删除确认)、MUL-820(接受邀请不自动切)
|
||||
|
||||
### 问题
|
||||
|
||||
创建 / 删除 / 切换 / 加入 workspace 的副作用分散在 mutation 的 `onSuccess` 和各处 UI 回调里,没有统一抽象。几个具体 bug:
|
||||
|
||||
### 4.1 MUL-727 — 创建 workspace 后闪一下 `/issues` 再跳 `/onboarding`
|
||||
|
||||
**根因**:两个 `onSuccess` 回调同时跑,顺序不确定。
|
||||
|
||||
- `packages/core/workspace/mutations.ts:7-21` 的 `useCreateWorkspace.onSuccess` 里调了 `switchWorkspace(newWs)` —— 同步改 Zustand,`/issues` 路由开始用新 workspace 渲染
|
||||
- `packages/views/modals/create-workspace.tsx:68-70` 的 UI `onSuccess` 里调了 `router.push("/onboarding")` —— 异步 schedule 导航
|
||||
|
||||
于是:`/issues` 先渲染(闪一下)→ 导航到 `/onboarding`。
|
||||
|
||||
**修复**:把 `switchWorkspace` 从 mutation 里拿出来,让 UI 层主导。在 `create-workspace.tsx` 的 `onSuccess` 里先 `switchWorkspace` 再 `push`,保证同一个微任务里完成。
|
||||
|
||||
**文件**:`packages/core/workspace/mutations.ts`、`packages/views/modals/create-workspace.tsx`、可能 `packages/views/onboarding/step-workspace.tsx`
|
||||
|
||||
### 4.2 MUL-728 — 删除 workspace 的"缺少确认"
|
||||
|
||||
**核查结果**:`packages/views/settings/components/workspace-tab.tsx:102-119, 236-255` **已经有 AlertDialog 确认**了。
|
||||
|
||||
**真实问题**:删除成功后**没有导航**,用户停在 `/settings`,而当前 workspace 已经是删除后系统挑的另一个。
|
||||
|
||||
**修复**:在 `handleDeleteWorkspace` 的 `onConfirm` 成功分支里加 `push("/issues")`。
|
||||
|
||||
**文件**:`packages/views/settings/components/workspace-tab.tsx`(加一行)
|
||||
|
||||
### 4.3 MUL-820 — 接受邀请不自动切换 workspace
|
||||
|
||||
**核查结果**:有两条路径:
|
||||
|
||||
- ✅ `/invite/:id` 独立页(`packages/views/invite/invite-page.tsx:32-52`)是**正确的**:accept → switchWorkspace → push("/issues")
|
||||
- ❌ **Sidebar 下拉里的 "Join" 按钮**(`packages/views/layout/app-sidebar.tsx:203-209, 321-324`)**是错的**:只 invalidate cache,不切也不跳
|
||||
|
||||
**修复(推荐 Option 2)**:Sidebar 的 "Join" 改成跳转到 `/invite/:id` 页面,不再就地接受。单一入口、单一行为。
|
||||
|
||||
```tsx
|
||||
<DropdownMenuItem onClick={() => push(`/invite/${inv.id}`)}>
|
||||
{inv.workspace_name}
|
||||
</DropdownMenuItem>
|
||||
```
|
||||
|
||||
**文件**:`packages/views/layout/app-sidebar.tsx`(~10 行)
|
||||
|
||||
### 复现
|
||||
|
||||
| Issue | 步骤 |
|
||||
|---|---|
|
||||
| MUL-727 | 创建新 workspace → 仔细看是否闪了一下 `/issues` 再跳 `/onboarding` |
|
||||
| MUL-728 | 删除当前 workspace → 观察删完后是否留在 `/settings` 页面(BUG: 没有自动跳走) |
|
||||
| MUL-820 | 被邀请用户登录 → sidebar 下拉 → 点 "Join" → 观察当前 workspace 是否切过去(BUG: 不切)|
|
||||
|
||||
### 长期架构建议(可选)
|
||||
|
||||
抽一个 `useWorkspaceLifecycle` hook 统一管这些副作用。Agent 报告里有完整设计,文件:`packages/core/workspace/hooks.ts`(新建)。但建议先修 MUL-727/728/820 三个具体 bug,hook 抽象作为后续迭代。
|
||||
|
||||
### 改动范围
|
||||
|
||||
| Issue | 文件 | 改动规模 |
|
||||
|---|---|---|
|
||||
| MUL-727 | mutations.ts + create-workspace.tsx | ~10 行 |
|
||||
| MUL-728 | workspace-tab.tsx | ~1 行 |
|
||||
| MUL-820 | app-sidebar.tsx | ~10 行 |
|
||||
|
||||
---
|
||||
|
||||
## 总览
|
||||
|
||||
| 任务 | Issue | 优先级 | 预估规模 | 风险 |
|
||||
|---|---|---|---|---|
|
||||
| 1. WS 半开 + 陈旧 cache | #951 | **P0** | Option B ~10 行;Option C ~1-2 天 | 低 |
|
||||
| 2. Workspace URL 化 | MUL-723/43/509 | P1 | 5-7 人天(大部分是搬迁)| 中(影响面大、e2e 要改)|
|
||||
| 3. Navigation store 隔离 | MUL-43/476 | P1 | ~0.5 天 | 低 |
|
||||
| 4. Workspace 生命周期 bug | MUL-727/728/820 | P2 | ~1 天 | 低 |
|
||||
|
||||
### 建议推进顺序
|
||||
|
||||
1. **立刻做**:任务 1 的 Option B(visibilitychange 触发 invalidate)—— 代码最少、收益最明显,能当天止血
|
||||
2. **同步开始**:任务 3(navigation store 隔离)—— 影响小、风险低、顺便清掉一个 workaround
|
||||
3. **规划立项**:任务 2(URL 化)—— 大改造,需要单独开一个 iteration
|
||||
4. **次要修补**:任务 4 的三个小 bug —— 可以拆成独立 PR,各自 review
|
||||
|
||||
### 重要澄清
|
||||
|
||||
- **Issue 编号核对**:MUL-43 / MUL-476 的编号需要核对一次,agent 查询 GitHub 返回的标题看起来对不上(可能是内部 issue tracker 编号 vs GitHub 编号混用)。以症状为准。
|
||||
- **MUL-728 实际状态**:确认对话框已经存在,真实缺的是"删除后跳走"。
|
||||
- **MUL-820 实际状态**:`/invite/:id` 页面路径工作正常,只是 sidebar 下拉按钮坏了。
|
||||
|
||||
### 所有关键代码位置索引
|
||||
|
||||
```
|
||||
packages/core/query-client.ts:7-10 # staleTime: Infinity
|
||||
packages/core/api/ws-client.ts:1-142 # 客户端 WS,无心跳
|
||||
packages/core/realtime/use-realtime-sync.ts:462-487 # onReconnect 全量 invalidate
|
||||
packages/core/platform/core-provider.tsx # 加 visibilitychange 的位置
|
||||
packages/core/navigation/store.ts:15-31 # lastPath 未隔离
|
||||
packages/core/platform/storage-cleanup.ts:10-19 # WORKSPACE_SCOPED_KEYS
|
||||
packages/core/workspace/store.ts:43-77 # hydrateWorkspace / switchWorkspace
|
||||
packages/core/workspace/mutations.ts:7-57 # create/leave/delete 三个 mutation
|
||||
packages/views/layout/app-sidebar.tsx:203-324 # 侧边栏切 workspace、接受邀请入口
|
||||
packages/views/modals/create-workspace.tsx:63-82 # 创建 workspace 入口
|
||||
packages/views/settings/components/workspace-tab.tsx:102-119 # 删除 workspace 入口
|
||||
packages/views/invite/invite-page.tsx:32-52 # 接受邀请正确实现参考
|
||||
|
||||
server/internal/realtime/hub.go:83-96 # 服务端 WS 心跳
|
||||
server/internal/middleware/workspace.go:41-46 # wsId resolution
|
||||
server/migrations/001_init.up.sql:15-23 # workspace.slug 已存在
|
||||
```
|
||||
4
Makefile
4
Makefile
@@ -104,8 +104,6 @@ start:
|
||||
@echo "Backend: http://localhost:$(PORT)"
|
||||
@echo "Frontend: http://localhost:$(FRONTEND_PORT)"
|
||||
@bash scripts/ensure-postgres.sh "$(ENV_FILE)"
|
||||
@echo "Running migrations..."
|
||||
cd server && go run ./cmd/migrate up
|
||||
@echo "Starting backend and frontend..."
|
||||
@trap 'kill 0' EXIT; \
|
||||
(cd server && go run ./cmd/server) & \
|
||||
@@ -182,7 +180,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)"
|
||||
|
||||
16
README.md
16
README.md
@@ -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
|
||||
|
||||
|
||||
@@ -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,选择 Provider(Claude Code、Codex、OpenClaw、OpenCode、Hermes、Gemini、Pi 或 Cursor Agent),并为 Agent 起个名字——它将以这个名字出现在看板、评论和任务分配中。
|
||||
进入 **设置 → Agents**,点击 **新建 Agent**。选择你刚连接的 Runtime,选择 Provider(Claude Code、Codex、OpenClaw 或 OpenCode),并为 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、OpenClaw、OpenCode、Hermes、Gemini、Pi 或 Cursor Agent |
|
||||
| Agent 运行时 | 本地 daemon 执行 Claude Code、Codex、OpenClaw 或 OpenCode |
|
||||
|
||||
## 开发
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Binary file not shown.
Binary file not shown.
|
Before Width: | Height: | Size: 121 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 35 KiB |
@@ -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
|
||||
|
||||
@@ -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()],
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
@@ -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",
|
||||
@@ -30,7 +29,6 @@
|
||||
"@multica/ui": "workspace:*",
|
||||
"@multica/views": "workspace:*",
|
||||
"electron-updater": "^6.8.3",
|
||||
"fix-path": "^5.0.0",
|
||||
"react-router-dom": "^7.6.0",
|
||||
"shadcn": "^4.1.0",
|
||||
"sonner": "^2.0.7",
|
||||
@@ -40,8 +38,6 @@
|
||||
"@electron-toolkit/tsconfig": "^2.0.0",
|
||||
"@multica/tsconfig": "workspace:*",
|
||||
"@tailwindcss/vite": "^4",
|
||||
"@testing-library/jest-dom": "catalog:",
|
||||
"@testing-library/react": "catalog:",
|
||||
"@types/node": "catalog:",
|
||||
"@types/react": "catalog:",
|
||||
"@types/react-dom": "catalog:",
|
||||
@@ -49,7 +45,6 @@
|
||||
"electron": "^39.2.6",
|
||||
"electron-builder": "^26.0.12",
|
||||
"electron-vite": "^5.0.0",
|
||||
"jsdom": "catalog:",
|
||||
"react": "catalog:",
|
||||
"react-dom": "catalog:",
|
||||
"tailwindcss": "^4",
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 735 KiB After Width: | Height: | Size: 35 KiB |
@@ -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}"`);
|
||||
@@ -5,21 +5,13 @@
|
||||
// binary via the `main.version` ldflag — so a single `vX.Y.Z` tag push
|
||||
// produces matching CLI and Desktop versions.
|
||||
//
|
||||
// Runs bundle-cli.mjs first (so the Go binary is compiled and copied
|
||||
// into resources/bin/), then `electron-vite build` to produce the
|
||||
// main/preload/renderer bundles under out/, then invokes electron-builder
|
||||
// with `-c.extraMetadata.version=<derived>` so the override applies at
|
||||
// build time without mutating the tracked package.json.
|
||||
//
|
||||
// The electron-vite step is important: electron-builder only packages
|
||||
// whatever is already in out/, so skipping it (or relying on stale
|
||||
// artifacts from a prior partial build) ships an app with missing
|
||||
// renderer code and white-screens on launch.
|
||||
// Runs the existing bundle-cli.mjs first (so the Go binary is compiled
|
||||
// and copied into resources/bin/), then invokes electron-builder with
|
||||
// `-c.extraMetadata.version=<derived>` so the override applies at build
|
||||
// time without mutating the tracked package.json.
|
||||
//
|
||||
// Extra CLI args after `pnpm package --` are forwarded to electron-builder
|
||||
// unchanged (e.g. `--mac --arm64`). For an unsigned local smoke-test
|
||||
// build, set `CSC_IDENTITY_AUTO_DISCOVERY=false` so electron-builder falls
|
||||
// back to an ad-hoc signature instead of requiring a Developer ID cert.
|
||||
// unchanged (e.g. `--mac --arm64`).
|
||||
//
|
||||
// The `normalizeGitVersion` helper is exported so tests can cover the
|
||||
// version-derivation logic without shelling out.
|
||||
@@ -39,18 +31,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.
|
||||
@@ -84,26 +64,7 @@ function main() {
|
||||
cwd: desktopRoot,
|
||||
});
|
||||
|
||||
// Step 2: build the Electron main/preload/renderer bundles. Without
|
||||
// this step electron-builder silently packages whatever is already in
|
||||
// out/, which on a fresh checkout (or after a partial build) ships an
|
||||
// app that white-screens because the renderer bundle is missing.
|
||||
const viteResult = spawnSync("electron-vite", ["build"], {
|
||||
stdio: "inherit",
|
||||
cwd: desktopRoot,
|
||||
});
|
||||
if (viteResult.error) {
|
||||
console.error(
|
||||
"[package] failed to spawn electron-vite:",
|
||||
viteResult.error.message,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
if (viteResult.status !== 0) {
|
||||
process.exit(viteResult.status ?? 1);
|
||||
}
|
||||
|
||||
// Step 3: derive the version that should be written into the app.
|
||||
// Step 2: derive the version that should be written into the app.
|
||||
const version = deriveVersion();
|
||||
if (version) {
|
||||
console.log(`[package] Desktop version → ${version} (from git describe)`);
|
||||
@@ -113,12 +74,12 @@ function main() {
|
||||
);
|
||||
}
|
||||
|
||||
// Step 4: assemble electron-builder args.
|
||||
const passthrough = stripLeadingSeparator(process.argv.slice(2));
|
||||
// Step 3: assemble electron-builder args.
|
||||
const passthrough = process.argv.slice(2);
|
||||
const builderArgs = [];
|
||||
if (version) builderArgs.push(`-c.extraMetadata.version=${version}`);
|
||||
|
||||
// Step 5: gracefully degrade for local dev builds. electron-builder.yml
|
||||
// Step 4: gracefully degrade for local dev builds. electron-builder.yml
|
||||
// sets `notarize: true` so real releases notarize in-build (keeping the
|
||||
// stapled .app consistent with latest-mac.yml's SHA512). But a mac dev
|
||||
// who just wants to smoke-test a local package doesn't have Apple
|
||||
@@ -134,7 +95,7 @@ function main() {
|
||||
|
||||
builderArgs.push(...passthrough);
|
||||
|
||||
// Step 6: invoke electron-builder. pnpm puts node_modules/.bin on PATH
|
||||
// Step 5: invoke electron-builder. pnpm puts node_modules/.bin on PATH
|
||||
// for the script run, so spawnSync finds the binary without needing a
|
||||
// shell wrapper (avoids any risk of argv interpolation).
|
||||
const result = spawnSync("electron-builder", builderArgs, {
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -598,12 +598,11 @@ function profileArgs(active: ActiveProfile): string[] {
|
||||
|
||||
// Env passed to every CLI child so the daemon process knows it was spawned
|
||||
// by the Desktop app. The server uses this to mark runtimes as managed and
|
||||
// hide CLI self-update UI. Computed lazily so it picks up the PATH fix
|
||||
// applied by fix-path in main/index.ts — as a top-level const it would
|
||||
// snapshot process.env at import time, before that block runs.
|
||||
function desktopSpawnEnv(): NodeJS.ProcessEnv {
|
||||
return { ...process.env, MULTICA_LAUNCHED_BY: "desktop" };
|
||||
}
|
||||
// hide CLI self-update UI.
|
||||
const DESKTOP_SPAWN_ENV = {
|
||||
...process.env,
|
||||
MULTICA_LAUNCHED_BY: "desktop",
|
||||
};
|
||||
|
||||
async function startDaemon(): Promise<{ success: boolean; error?: string }> {
|
||||
const bin = await resolveCliBinary();
|
||||
@@ -625,7 +624,7 @@ async function startDaemon(): Promise<{ success: boolean; error?: string }> {
|
||||
execFile(
|
||||
bin,
|
||||
args,
|
||||
{ timeout: 20_000, env: desktopSpawnEnv() },
|
||||
{ timeout: 20_000, env: DESKTOP_SPAWN_ENV },
|
||||
(err) => {
|
||||
if (err) {
|
||||
currentState = "stopped";
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
@@ -1,36 +1,8 @@
|
||||
import { app, BrowserWindow, ipcMain, nativeImage } from "electron";
|
||||
import { homedir } from "os";
|
||||
import { app, shell, BrowserWindow, ipcMain } from "electron";
|
||||
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.).
|
||||
// Run the user's login shell once to recover the real PATH so the bundled
|
||||
// multica CLI can find agent binaries like claude/codex/opencode. Must run
|
||||
// before any child_process.spawn / execFile call in the main process —
|
||||
// ES module imports are hoisted, so this block executes before createWindow
|
||||
// or any daemon-manager spawn.
|
||||
if (process.platform !== "win32") {
|
||||
fixPath();
|
||||
// Fallback: prepend common install locations in case fix-path came up
|
||||
// short (broken shell rc, non-interactive $SHELL, missing entries). Safe
|
||||
// to duplicate — PATH lookups short-circuit on first match.
|
||||
const fallbackPaths = [
|
||||
"/opt/homebrew/bin",
|
||||
"/usr/local/bin",
|
||||
join(homedir(), ".local/bin"),
|
||||
];
|
||||
process.env.PATH = `${fallbackPaths.join(":")}:${process.env.PATH ?? ""}`;
|
||||
}
|
||||
|
||||
const PROTOCOL = "multica";
|
||||
|
||||
@@ -49,19 +21,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 +39,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 +61,7 @@ function createWindow(): void {
|
||||
});
|
||||
|
||||
mainWindow.webContents.setWindowOpenHandler((details) => {
|
||||
openExternalSafely(details.url);
|
||||
shell.openExternal(details.url);
|
||||
return { action: "deny" };
|
||||
});
|
||||
|
||||
@@ -116,27 +72,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 +103,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;
|
||||
|
||||
2
apps/desktop/src/preload/index.d.ts
vendored
2
apps/desktop/src/preload/index.d.ts
vendored
@@ -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. */
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { useEffect, useLayoutEffect, useRef, useState } from "react";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useEffect } from "react";
|
||||
import { CoreProvider } from "@multica/core/platform";
|
||||
import { useAuthStore } from "@multica/core/auth";
|
||||
import { workspaceKeys, workspaceListOptions } from "@multica/core/workspace/queries";
|
||||
import { useWorkspaceStore } from "@multica/core/workspace";
|
||||
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,22 +9,10 @@ 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);
|
||||
const isLoading = useAuthStore((s) => s.isLoading);
|
||||
const qc = useQueryClient();
|
||||
// Deep-link login runs loginWithToken → syncToken → listWorkspaces →
|
||||
// setQueryData sequentially. loginWithToken sets user+isLoading=false
|
||||
// as soon as getMe resolves, which would cause DesktopShell to mount
|
||||
// before the workspace list is hydrated and briefly see `!workspace`.
|
||||
// This local flag keeps the loading screen up until the whole chain
|
||||
// finishes, so IndexRedirect gets a definitive workspace state on
|
||||
// first render.
|
||||
const [bootstrapping, setBootstrapping] = useState(false);
|
||||
|
||||
// Tell the main process which backend URL we talk to, so daemon-manager
|
||||
// can pick the matching CLI profile (server_url from ~/.multica config).
|
||||
@@ -33,39 +20,20 @@ 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).
|
||||
// Listen for auth token delivered via deep link (multica://auth/callback?token=...)
|
||||
useEffect(() => {
|
||||
return window.desktopAPI.onAuthToken(async (token) => {
|
||||
setBootstrapping(true);
|
||||
try {
|
||||
await useAuthStore.getState().loginWithToken(token);
|
||||
// Seed React Query cache with the workspace list so the index-route
|
||||
// redirect (routes.tsx `IndexRedirect`) can resolve the initial
|
||||
// destination without a second fetch. Workspace side-effects
|
||||
// (setCurrentWorkspace, persist namespace) are synced later by
|
||||
// WorkspaceRouteLayout when the URL resolves.
|
||||
const loggedIn = await useAuthStore.getState().loginWithToken(token);
|
||||
await window.daemonAPI.syncToken(token, loggedIn.id);
|
||||
const wsList = await api.listWorkspaces();
|
||||
qc.setQueryData(workspaceKeys.list(), wsList);
|
||||
const lastWsId = localStorage.getItem("multica_workspace_id");
|
||||
useWorkspaceStore.getState().hydrateWorkspace(wsList, lastWsId);
|
||||
} catch {
|
||||
// Token invalid or expired — user stays on login page
|
||||
} finally {
|
||||
setBootstrapping(false);
|
||||
}
|
||||
});
|
||||
}, [qc]);
|
||||
}, []);
|
||||
|
||||
// Sync token and start the daemon whenever the user logs in.
|
||||
useEffect(() => {
|
||||
@@ -83,74 +51,7 @@ 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) {
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex h-screen items-center justify-center">
|
||||
<MulticaIcon className="size-6 animate-pulse" />
|
||||
@@ -166,14 +67,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 {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useSyncExternalStore } from "react";
|
||||
import { useEffect } from "react";
|
||||
import { ChevronLeft, ChevronRight } from "lucide-react";
|
||||
import { cn } from "@multica/ui/lib/utils";
|
||||
import { useTabHistory } from "@/hooks/use-tab-history";
|
||||
@@ -6,19 +6,16 @@ import { useActiveTitleSync } from "@/hooks/use-tab-sync";
|
||||
import { useTabStore, resolveRouteIcon } from "@/stores/tab-store";
|
||||
import {
|
||||
SidebarProvider,
|
||||
SidebarTrigger,
|
||||
useSidebar,
|
||||
} from "@multica/ui/components/ui/sidebar";
|
||||
import { ModalRegistry } from "@multica/views/modals/registry";
|
||||
import { AppSidebar } from "@multica/views/layout";
|
||||
import { AppSidebar, DashboardGuard } from "@multica/views/layout";
|
||||
import { SearchCommand, SearchTrigger } from "@multica/views/search";
|
||||
import { ChatFab, ChatWindow } from "@multica/views/chat";
|
||||
import { WorkspaceSlugProvider } from "@multica/core/paths";
|
||||
import { getCurrentSlug, subscribeToCurrentSlug } from "@multica/core/platform";
|
||||
import { DesktopNavigationProvider } from "@/platform/navigation";
|
||||
import { MulticaIcon } from "@multica/ui/components/common/multica-icon";
|
||||
import { TabBar } from "./tab-bar";
|
||||
import { TabContent } from "./tab-content";
|
||||
import { WindowOverlay } from "./window-overlay";
|
||||
|
||||
function SidebarTopBar() {
|
||||
const { canGoBack, canGoForward, goBack, goForward } = useTabHistory();
|
||||
@@ -54,28 +51,17 @@ function SidebarTopBar() {
|
||||
}
|
||||
|
||||
// The main area's top bar doubles as a window drag region. When the sidebar
|
||||
// is not occupying main-flow width — either user-collapsed (offcanvas) or
|
||||
// auto-hidden in mobile mode (<768px, becomes a sheet drawer) — we pad the
|
||||
// left side so tabs don't land under the macOS traffic lights (which live at
|
||||
// roughly x=16..68 and always hit-test above HTML), and surface a trigger so
|
||||
// the sidebar can be brought back without keyboard shortcut.
|
||||
// is collapsed, we pad the left side so tabs don't land under the macOS
|
||||
// traffic lights (which live at roughly x=16..68 and always hit-test above HTML).
|
||||
function MainTopBar() {
|
||||
const { state, isMobile } = useSidebar();
|
||||
const sidebarHidden = state === "collapsed" || isMobile;
|
||||
const { state } = useSidebar();
|
||||
const sidebarCollapsed = state === "collapsed";
|
||||
|
||||
return (
|
||||
<header
|
||||
className={cn(
|
||||
"h-12 shrink-0 flex items-center gap-2",
|
||||
sidebarHidden && "pl-20",
|
||||
)}
|
||||
className={cn("h-12 shrink-0", sidebarCollapsed && "pl-20")}
|
||||
style={{ WebkitAppRegion: "drag" } as React.CSSProperties}
|
||||
>
|
||||
{sidebarHidden && (
|
||||
<SidebarTrigger
|
||||
style={{ WebkitAppRegion: "no-drag" } as React.CSSProperties}
|
||||
/>
|
||||
)}
|
||||
<TabBar />
|
||||
</header>
|
||||
);
|
||||
@@ -100,42 +86,34 @@ export function DesktopShell() {
|
||||
useInternalLinkHandler();
|
||||
useActiveTitleSync();
|
||||
|
||||
// Reactive read of current workspace slug from the platform singleton.
|
||||
// On first mount, slug is null until WorkspaceRouteLayout (inside the tab
|
||||
// router) sets it. Once set, the sidebar and other shell-level components
|
||||
// can resolve workspace-scoped paths via useWorkspacePaths().
|
||||
const slug = useSyncExternalStore(subscribeToCurrentSlug, getCurrentSlug, () => null);
|
||||
|
||||
return (
|
||||
<DesktopNavigationProvider>
|
||||
{/* 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}>
|
||||
<DashboardGuard
|
||||
loginPath="/login"
|
||||
loadingFallback={
|
||||
<div className="flex h-screen items-center justify-center">
|
||||
<MulticaIcon className="size-6 animate-pulse" />
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="flex h-screen">
|
||||
<SidebarProvider className="flex-1">
|
||||
{slug && <AppSidebar topSlot={<SidebarTopBar />} searchSlot={<SearchTrigger />} />}
|
||||
<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 />}
|
||||
<ChatWindow />
|
||||
<ChatFab />
|
||||
</div>
|
||||
</div>
|
||||
</SidebarProvider>
|
||||
</div>
|
||||
{slug && <ModalRegistry />}
|
||||
{slug && <SearchCommand />}
|
||||
<WindowOverlay />
|
||||
</WorkspaceSlugProvider>
|
||||
<ModalRegistry />
|
||||
<SearchCommand />
|
||||
</DashboardGuard>
|
||||
</DesktopNavigationProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
))}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -1,88 +0,0 @@
|
||||
import { useEffect } 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,
|
||||
workspaceListOptions,
|
||||
} from "@multica/core/workspace";
|
||||
import { setCurrentWorkspace } 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).
|
||||
*/
|
||||
export function WorkspaceRouteLayout() {
|
||||
const { workspaceSlug } = useParams<{ workspaceSlug: string }>();
|
||||
const navigate = useNavigate();
|
||||
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) {
|
||||
setCurrentWorkspace(workspaceSlug, workspace.id);
|
||||
}
|
||||
|
||||
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.
|
||||
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 (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}>
|
||||
<Outlet />
|
||||
</WorkspaceSlugProvider>
|
||||
);
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { LoginPage } from "@multica/views/auth";
|
||||
import { MulticaIcon } from "@multica/ui/components/common/multica-icon";
|
||||
|
||||
const WEB_URL = import.meta.env.VITE_APP_URL || "http://localhost:3000";
|
||||
const WEB_URL = import.meta.env.VITE_WEB_URL || "http://localhost:3000";
|
||||
|
||||
export function DesktopLoginPage() {
|
||||
const lastWorkspaceId = localStorage.getItem("multica_workspace_id");
|
||||
|
||||
const handleGoogleLogin = () => {
|
||||
// Open web login page in the default browser with platform=desktop flag.
|
||||
// The web callback will redirect back via multica:// deep link with the token.
|
||||
@@ -21,9 +23,9 @@ export function DesktopLoginPage() {
|
||||
/>
|
||||
<LoginPage
|
||||
logo={<MulticaIcon bordered size="lg" />}
|
||||
lastWorkspaceId={lastWorkspaceId}
|
||||
onSuccess={() => {
|
||||
// Auth store update triggers AppContent re-render → shows DesktopShell.
|
||||
// Initial workspace navigation happens in routes.tsx via IndexRedirect.
|
||||
// Auth store update triggers AppContent re-render → shows DesktopShell
|
||||
}}
|
||||
onGoogleLogin={handleGoogleLogin}
|
||||
/>
|
||||
|
||||
@@ -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}`,
|
||||
}),
|
||||
|
||||
@@ -19,9 +19,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 { Server } from "lucide-react";
|
||||
import { DaemonSettingsTab } from "./components/daemon-settings-tab";
|
||||
import { WorkspaceRouteLayout } from "./components/workspace-route-layout";
|
||||
|
||||
/**
|
||||
* Sets document.title from the deepest matched route's handle.title.
|
||||
@@ -53,89 +55,88 @@ function PageShell() {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
function OnboardingRoute() {
|
||||
const nav = useNavigation();
|
||||
return <OnboardingWizard onComplete={() => nav.push("/issues")} />;
|
||||
}
|
||||
|
||||
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 (no layout wrapper). */
|
||||
export const appRoutes: RouteObject[] = [
|
||||
{
|
||||
element: <PageShell />,
|
||||
children: [
|
||||
{ index: true, element: null },
|
||||
{ index: true, element: <Navigate to="/issues" replace /> },
|
||||
{ path: "issues", element: <IssuesPage />, handle: { title: "Issues" } },
|
||||
{
|
||||
path: ":workspaceSlug",
|
||||
element: <WorkspaceRouteLayout />,
|
||||
children: [
|
||||
{ index: true, element: <Navigate to="issues" replace /> },
|
||||
{ path: "issues", element: <IssuesPage />, handle: { title: "Issues" } },
|
||||
{
|
||||
path: "issues/:id",
|
||||
element: <IssueDetailPage />,
|
||||
handle: { title: "Issue" },
|
||||
},
|
||||
{
|
||||
path: "projects",
|
||||
element: <ProjectsPage />,
|
||||
handle: { title: "Projects" },
|
||||
},
|
||||
{
|
||||
path: "projects/:id",
|
||||
element: <ProjectDetailPage />,
|
||||
handle: { title: "Project" },
|
||||
},
|
||||
{
|
||||
path: "autopilots",
|
||||
element: <AutopilotsPage />,
|
||||
handle: { title: "Autopilot" },
|
||||
},
|
||||
{
|
||||
path: "autopilots/:id",
|
||||
element: <AutopilotDetailPage />,
|
||||
handle: { title: "Autopilot" },
|
||||
},
|
||||
{
|
||||
path: "my-issues",
|
||||
element: <MyIssuesPage />,
|
||||
handle: { title: "My Issues" },
|
||||
},
|
||||
{
|
||||
path: "runtimes",
|
||||
element: <RuntimesPage topSlot={<DaemonRuntimeCard />} />,
|
||||
handle: { title: "Runtimes" },
|
||||
},
|
||||
{ path: "skills", element: <SkillsPage />, handle: { title: "Skills" } },
|
||||
{ path: "agents", element: <AgentsPage />, handle: { title: "Agents" } },
|
||||
{ path: "inbox", element: <InboxPage />, handle: { title: "Inbox" } },
|
||||
{
|
||||
path: "settings",
|
||||
element: (
|
||||
<SettingsPage
|
||||
extraAccountTabs={[
|
||||
{
|
||||
value: "daemon",
|
||||
label: "Daemon",
|
||||
icon: Server,
|
||||
content: <DaemonSettingsTab />,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
),
|
||||
handle: { title: "Settings" },
|
||||
},
|
||||
],
|
||||
path: "issues/:id",
|
||||
element: <IssueDetailPage />,
|
||||
handle: { title: "Issue" },
|
||||
},
|
||||
{
|
||||
path: "projects",
|
||||
element: <ProjectsPage />,
|
||||
handle: { title: "Projects" },
|
||||
},
|
||||
{
|
||||
path: "projects/:id",
|
||||
element: <ProjectDetailPage />,
|
||||
handle: { title: "Project" },
|
||||
},
|
||||
{
|
||||
path: "autopilots",
|
||||
element: <AutopilotsPage />,
|
||||
handle: { title: "Autopilot" },
|
||||
},
|
||||
{
|
||||
path: "autopilots/:id",
|
||||
element: <AutopilotDetailPage />,
|
||||
handle: { title: "Autopilot" },
|
||||
},
|
||||
{
|
||||
path: "my-issues",
|
||||
element: <MyIssuesPage />,
|
||||
handle: { title: "My Issues" },
|
||||
},
|
||||
{
|
||||
path: "runtimes",
|
||||
element: <RuntimesPage topSlot={<DaemonRuntimeCard />} />,
|
||||
handle: { title: "Runtimes" },
|
||||
},
|
||||
{ path: "skills", element: <SkillsPage />, handle: { title: "Skills" } },
|
||||
{ path: "agents", element: <AgentsPage />, handle: { title: "Agents" } },
|
||||
{ path: "inbox", element: <InboxPage />, handle: { title: "Inbox" } },
|
||||
{
|
||||
path: "onboarding",
|
||||
element: <OnboardingRoute />,
|
||||
handle: { title: "Get Started" },
|
||||
},
|
||||
{
|
||||
path: "invite/:id",
|
||||
element: <InviteRoute />,
|
||||
handle: { title: "Accept Invite" },
|
||||
},
|
||||
{
|
||||
path: "settings",
|
||||
element: (
|
||||
<SettingsPage
|
||||
extraAccountTabs={[
|
||||
{
|
||||
value: "daemon",
|
||||
label: "Daemon",
|
||||
icon: Server,
|
||||
content: <DaemonSettingsTab />,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
),
|
||||
handle: { title: "Settings" },
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -3,7 +3,6 @@ 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 type { DataRouter } from "react-router-dom";
|
||||
import { createTabRouter } from "../routes";
|
||||
|
||||
@@ -13,7 +12,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 +20,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;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -100,82 +45,29 @@ interface TabStore {
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const ROUTE_ICONS: Record<string, string> = {
|
||||
inbox: "Inbox",
|
||||
"my-issues": "CircleUser",
|
||||
issues: "ListTodo",
|
||||
projects: "FolderKanban",
|
||||
autopilots: "ListTodo",
|
||||
agents: "Bot",
|
||||
runtimes: "Monitor",
|
||||
skills: "BookOpenText",
|
||||
settings: "Settings",
|
||||
"/inbox": "Inbox",
|
||||
"/my-issues": "CircleUser",
|
||||
"/issues": "ListTodo",
|
||||
"/projects": "FolderKanban",
|
||||
"/agents": "Bot",
|
||||
"/runtimes": "Monitor",
|
||||
"/skills": "BookOpenText",
|
||||
"/settings": "Settings",
|
||||
};
|
||||
|
||||
/**
|
||||
* Resolve a route icon from a pathname.
|
||||
*
|
||||
* 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.
|
||||
*
|
||||
* Title is NOT determined here — it comes from document.title.
|
||||
*/
|
||||
/** Resolve a route icon. Title is NOT determined here — it comes from document.title. */
|
||||
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;
|
||||
return ROUTE_ICONS[pathname]
|
||||
?? (pathname.startsWith("/issues/") ? "ListTodo" : undefined)
|
||||
?? (pathname.startsWith("/projects/") ? "FolderKanban" : undefined)
|
||||
?? "ListTodo";
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Path sanitization (defensive)
|
||||
// Store
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Defensive: catch paths that don't belong in the tab store.
|
||||
*
|
||||
* 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.
|
||||
*/
|
||||
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 = "/issues";
|
||||
|
||||
function createId(): string {
|
||||
return createSafeId();
|
||||
@@ -193,513 +85,112 @@ 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;
|
||||
}
|
||||
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 };
|
||||
}
|
||||
const tabs: Tab[] = persisted.tabs.map((tab) => ({
|
||||
...tab,
|
||||
router: createTabRouter(tab.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 };
|
||||
}
|
||||
|
||||
@@ -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 }),
|
||||
}));
|
||||
@@ -1 +0,0 @@
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
@@ -4,8 +4,7 @@
|
||||
"src/renderer/src/env.d.ts",
|
||||
"src/renderer/src/**/*",
|
||||
"src/renderer/src/**/*.tsx",
|
||||
"src/preload/*.d.ts",
|
||||
"test/setup.ts"
|
||||
"src/preload/*.d.ts"
|
||||
],
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
|
||||
@@ -1,13 +1,10 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
import react from "@vitejs/plugin-react";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
test: {
|
||||
globals: true,
|
||||
include: ["src/**/*.test.{ts,tsx}", "scripts/**/*.test.mjs"],
|
||||
environment: "jsdom",
|
||||
setupFiles: ["./test/setup.ts"],
|
||||
include: ["src/**/*.test.ts", "scripts/**/*.test.mjs"],
|
||||
environment: "node",
|
||||
passWithNoTests: true,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -315,6 +315,8 @@ api.example.com {
|
||||
}
|
||||
```
|
||||
|
||||
For a single-domain setup, route the frontend and backend through one hostname and forward `/api`, `/auth`, `/ws`, and `/health` to the backend while sending everything else to the frontend. This repository now includes a root `Caddyfile` and `docker-compose.selfhost.yml` service for that pattern.
|
||||
|
||||
### Nginx
|
||||
|
||||
```nginx
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -2,10 +2,7 @@
|
||||
|
||||
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,24 +10,15 @@ 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(() => {
|
||||
if (!isLoading && !user) {
|
||||
router.replace(
|
||||
`${paths.login()}?next=${encodeURIComponent(paths.invite(params.id))}`,
|
||||
);
|
||||
router.replace(`/login?next=/invite/${params.id}`);
|
||||
}
|
||||
}, [isLoading, user, router, params.id]);
|
||||
|
||||
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} />;
|
||||
}
|
||||
|
||||
@@ -11,10 +11,13 @@ function createWrapper() {
|
||||
);
|
||||
}
|
||||
|
||||
const { mockSendCode, mockVerifyCode } = vi.hoisted(() => ({
|
||||
mockSendCode: vi.fn(),
|
||||
mockVerifyCode: vi.fn(),
|
||||
}));
|
||||
const { mockSendCode, mockVerifyCode, mockHydrateWorkspace } = vi.hoisted(
|
||||
() => ({
|
||||
mockSendCode: vi.fn(),
|
||||
mockVerifyCode: vi.fn(),
|
||||
mockHydrateWorkspace: vi.fn(),
|
||||
}),
|
||||
);
|
||||
|
||||
// Mock next/navigation
|
||||
vi.mock("next/navigation", () => ({
|
||||
@@ -44,6 +47,16 @@ vi.mock("@/features/auth/auth-cookie", () => ({
|
||||
setLoggedInCookie: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock workspace store — shared LoginPage uses getState().hydrateWorkspace
|
||||
vi.mock("@multica/core/workspace", () => {
|
||||
const wsState = { hydrateWorkspace: mockHydrateWorkspace };
|
||||
const useWorkspaceStore = Object.assign(
|
||||
(selector: (s: typeof wsState) => unknown) => selector(wsState),
|
||||
{ getState: () => wsState },
|
||||
);
|
||||
return { useWorkspaceStore };
|
||||
});
|
||||
|
||||
// Mock api
|
||||
vi.mock("@multica/core/api", () => ({
|
||||
api: {
|
||||
|
||||
@@ -2,11 +2,8 @@
|
||||
|
||||
import { Suspense, useEffect } from "react";
|
||||
import { useSearchParams, useRouter } from "next/navigation";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { useAuthStore } from "@multica/core/auth";
|
||||
import { workspaceKeys } from "@multica/core/workspace/queries";
|
||||
import { paths } from "@multica/core/paths";
|
||||
import type { Workspace } from "@multica/core/types";
|
||||
import { useWorkspaceStore } from "@multica/core/workspace";
|
||||
import { setLoggedInCookie } from "@/features/auth/auth-cookie";
|
||||
import { LoginPage, validateCliCallback } from "@multica/views/auth";
|
||||
|
||||
@@ -14,7 +11,6 @@ const googleClientId = process.env.NEXT_PUBLIC_GOOGLE_CLIENT_ID;
|
||||
|
||||
function LoginPageContent() {
|
||||
const router = useRouter();
|
||||
const qc = useQueryClient();
|
||||
const user = useAuthStore((s) => s.user);
|
||||
const isLoading = useAuthStore((s) => s.isLoading);
|
||||
const searchParams = useSearchParams();
|
||||
@@ -22,47 +18,30 @@ function LoginPageContent() {
|
||||
const cliCallbackRaw = searchParams.get("cli_callback");
|
||||
const cliState = searchParams.get("cli_state") || "";
|
||||
const platform = searchParams.get("platform");
|
||||
// `next` carries a protected URL the user was originally headed to
|
||||
// (e.g. /invite/{id}). With URL-driven workspaces there is no legacy
|
||||
// "/issues" default — if `next` is absent we decide after login based on
|
||||
// the user's workspace list.
|
||||
const nextUrl = searchParams.get("next");
|
||||
const nextUrl = searchParams.get("next") || "/issues";
|
||||
|
||||
// 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 — redirect to dashboard (skip if CLI callback)
|
||||
useEffect(() => {
|
||||
if (isLoading || !user || cliCallbackRaw) return;
|
||||
if (nextUrl) {
|
||||
if (!isLoading && user && !cliCallbackRaw) {
|
||||
router.replace(nextUrl);
|
||||
return;
|
||||
}
|
||||
const list = qc.getQueryData<Workspace[]>(workspaceKeys.list()) ?? [];
|
||||
const [first] = list;
|
||||
router.replace(
|
||||
first ? paths.workspace(first.slug).issues() : paths.newWorkspace(),
|
||||
);
|
||||
}, [isLoading, user, router, nextUrl, cliCallbackRaw, qc]);
|
||||
}, [isLoading, user, router, nextUrl, cliCallbackRaw]);
|
||||
|
||||
const lastWorkspaceId =
|
||||
typeof window !== "undefined"
|
||||
? localStorage.getItem("multica_workspace_id")
|
||||
: null;
|
||||
|
||||
const handleSuccess = () => {
|
||||
if (nextUrl) {
|
||||
router.push(nextUrl);
|
||||
return;
|
||||
}
|
||||
// The LoginPage view populates the workspace list cache before calling
|
||||
// onSuccess, so it's safe to read here.
|
||||
const list = qc.getQueryData<Workspace[]>(workspaceKeys.list()) ?? [];
|
||||
const [first] = list;
|
||||
router.push(
|
||||
first ? paths.workspace(first.slug).issues() : paths.newWorkspace(),
|
||||
);
|
||||
const ws = useWorkspaceStore.getState().workspace;
|
||||
router.push(ws ? nextUrl : "/onboarding");
|
||||
};
|
||||
|
||||
// Build Google OAuth state: encode platform + next URL so the callback
|
||||
// can redirect to the right place after login.
|
||||
const googleState = [
|
||||
platform === "desktop" ? "platform:desktop" : "",
|
||||
nextUrl ? `next:${nextUrl}` : "",
|
||||
nextUrl !== "/issues" ? `next:${nextUrl}` : "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(",") || undefined;
|
||||
@@ -84,6 +63,7 @@ function LoginPageContent() {
|
||||
? { url: cliCallbackRaw, state: cliState }
|
||||
: undefined
|
||||
}
|
||||
lastWorkspaceId={lastWorkspaceId}
|
||||
onTokenObtained={setLoggedInCookie}
|
||||
/>
|
||||
);
|
||||
|
||||
23
apps/web/app/(auth)/onboarding/page.tsx
Normal file
23
apps/web/app/(auth)/onboarding/page.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useAuthStore } from "@multica/core/auth";
|
||||
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("/login");
|
||||
}, [isLoading, user, router]);
|
||||
|
||||
if (isLoading || !user) return null;
|
||||
|
||||
return (
|
||||
<OnboardingWizard onComplete={() => router.push("/issues")} />
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -11,6 +11,8 @@ export default function Layout({ children }: { children: React.ReactNode }) {
|
||||
loadingIndicator={<MulticaIcon className="size-6" />}
|
||||
searchSlot={<SearchTrigger />}
|
||||
extra={<><SearchCommand /><ChatWindow /><ChatFab /></>}
|
||||
onboardingPath="/onboarding"
|
||||
loginPath="/login"
|
||||
>
|
||||
{children}
|
||||
</DashboardLayout>
|
||||
28
apps/web/app/(dashboard)/loading.tsx
Normal file
28
apps/web/app/(dashboard)/loading.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
import type { Metadata } from "next";
|
||||
import { MulticaLanding } from "@/features/landing/components/multica-landing";
|
||||
import { RedirectIfAuthenticated } from "@/features/landing/components/redirect-if-authenticated";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: {
|
||||
@@ -20,10 +19,5 @@ export const metadata: Metadata = {
|
||||
};
|
||||
|
||||
export default function LandingPage() {
|
||||
return (
|
||||
<>
|
||||
<RedirectIfAuthenticated />
|
||||
<MulticaLanding />
|
||||
</>
|
||||
);
|
||||
return <MulticaLanding />;
|
||||
}
|
||||
|
||||
@@ -1,91 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { use, useEffect } 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 { 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,
|
||||
params,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
params: Promise<{ workspaceSlug: string }>;
|
||||
}) {
|
||||
const { workspaceSlug } = use(params);
|
||||
const user = useAuthStore((s) => s.user);
|
||||
const isAuthLoading = useAuthStore((s) => s.isLoading);
|
||||
const router = useRouter();
|
||||
|
||||
// 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({
|
||||
...workspaceBySlugOptions(workspaceSlug),
|
||||
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) {
|
||||
setCurrentWorkspace(workspaceSlug, workspace.id);
|
||||
}
|
||||
|
||||
// Cookie write (last_workspace_slug) — proxy reads it on next page load
|
||||
// to redirect unauthenticated-URL hits to the user's last workspace.
|
||||
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}`;
|
||||
}, [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);
|
||||
|
||||
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 />;
|
||||
}
|
||||
|
||||
return (
|
||||
<WorkspaceSlugProvider slug={workspaceSlug}>
|
||||
{children}
|
||||
</WorkspaceSlugProvider>
|
||||
);
|
||||
}
|
||||
@@ -4,8 +4,8 @@ import { Suspense, useEffect, useState } from "react";
|
||||
import { useSearchParams, useRouter } from "next/navigation";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { useAuthStore } from "@multica/core/auth";
|
||||
import { useWorkspaceStore } from "@multica/core/workspace";
|
||||
import { workspaceKeys } from "@multica/core/workspace/queries";
|
||||
import { paths } from "@multica/core/paths";
|
||||
import { api } from "@multica/core/api";
|
||||
import {
|
||||
Card,
|
||||
@@ -22,6 +22,7 @@ function CallbackContent() {
|
||||
const searchParams = useSearchParams();
|
||||
const qc = useQueryClient();
|
||||
const loginWithGoogle = useAuthStore((s) => s.loginWithGoogle);
|
||||
const hydrateWorkspace = useWorkspaceStore((s) => s.hydrateWorkspace);
|
||||
const [error, setError] = useState("");
|
||||
const [desktopToken, setDesktopToken] = useState<string | null>(null);
|
||||
|
||||
@@ -63,21 +64,17 @@ function CallbackContent() {
|
||||
.then(async () => {
|
||||
const wsList = await api.listWorkspaces();
|
||||
qc.setQueryData(workspaceKeys.list(), wsList);
|
||||
// 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.
|
||||
const [first] = wsList;
|
||||
const defaultDest = first
|
||||
? paths.workspace(first.slug).issues()
|
||||
: paths.newWorkspace();
|
||||
const lastWsId = localStorage.getItem("multica_workspace_id");
|
||||
const ws = await hydrateWorkspace(wsList, lastWsId);
|
||||
// Honor the ?next= redirect if present (e.g. /invite/{id})
|
||||
const defaultDest = ws ? "/issues" : "/onboarding";
|
||||
router.push(nextUrl || defaultDest);
|
||||
})
|
||||
.catch((err) => {
|
||||
setError(err instanceof Error ? err.message : "Login failed");
|
||||
});
|
||||
}
|
||||
}, [searchParams, loginWithGoogle, router, qc]);
|
||||
}, [searchParams, loginWithGoogle, hydrateWorkspace, router, qc]);
|
||||
|
||||
if (desktopToken) {
|
||||
return (
|
||||
@@ -114,7 +111,7 @@ function CallbackContent() {
|
||||
<CardDescription>{error}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex justify-center">
|
||||
<a href={paths.login()} className="text-primary underline-offset-4 hover:underline">
|
||||
<a href="/login" className="text-primary underline-offset-4 hover:underline">
|
||||
Back to login
|
||||
</a>
|
||||
</CardContent>
|
||||
|
||||
@@ -41,7 +41,7 @@ export function HowItWorksSection() {
|
||||
</div>
|
||||
|
||||
<div className="mt-14 flex flex-wrap items-center gap-4">
|
||||
<Link href={user ? "/" : "/login"} className={heroButtonClassName("solid")}>
|
||||
<Link href={user ? "/issues" : "/login"} className={heroButtonClassName("solid")}>
|
||||
{user ? t.header.dashboard : t.howItWorks.cta}
|
||||
</Link>
|
||||
<Link
|
||||
|
||||
@@ -48,7 +48,7 @@ export function LandingFooter() {
|
||||
</div>
|
||||
<div className="mt-6">
|
||||
<Link
|
||||
href={user ? "/" : "/login"}
|
||||
href={user ? "/issues" : "/login"}
|
||||
className="inline-flex items-center justify-center rounded-[11px] bg-white px-5 py-2.5 text-[13px] font-semibold text-[#0a0d12] transition-colors hover:bg-white/88"
|
||||
>
|
||||
{user ? t.header.dashboard : t.footer.cta}
|
||||
|
||||
@@ -54,7 +54,7 @@ export function LandingHeader({
|
||||
{t.header.github}
|
||||
</Link>
|
||||
<Link
|
||||
href={user ? "/" : "/login"}
|
||||
href={user ? "/issues" : "/login"}
|
||||
className={headerButtonClassName("solid", variant)}
|
||||
>
|
||||
{user ? t.header.dashboard : t.header.login}
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -38,39 +41,28 @@ export function LandingHero() {
|
||||
</p>
|
||||
|
||||
<div className="mt-8 flex flex-wrap items-center justify-center gap-3">
|
||||
<Link href={user ? "/" : "/login"} className={heroButtonClassName("solid")}>
|
||||
<Link href={user ? "/issues" : "/login"} className={heroButtonClassName("solid")}>
|
||||
{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}
|
||||
|
||||
@@ -1,45 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useAuthStore } from "@multica/core/auth";
|
||||
import { workspaceListOptions } from "@multica/core/workspace";
|
||||
import { paths } from "@multica/core/paths";
|
||||
|
||||
/**
|
||||
* Client-side fallback redirect for authenticated visitors on the landing page.
|
||||
*
|
||||
* The primary path for logged-in users hitting `/` is a server-side redirect
|
||||
* in the Next.js proxy/middleware, driven by the `last_workspace_slug` cookie.
|
||||
* That cookie is set by the workspace layout on every visit. But on *first
|
||||
* login* — before the user has ever visited a workspace — the cookie is
|
||||
* absent, so the proxy falls through to the landing page. This component
|
||||
* covers that gap: once auth is resolved and the workspace list has loaded,
|
||||
* push the user into their workspace (or /workspaces/new if they have none).
|
||||
*
|
||||
* Renders nothing. Uses `router.replace` so the landing page never enters
|
||||
* browser history for authenticated users.
|
||||
*/
|
||||
export function RedirectIfAuthenticated() {
|
||||
const router = useRouter();
|
||||
const user = useAuthStore((s) => s.user);
|
||||
const isLoading = useAuthStore((s) => s.isLoading);
|
||||
|
||||
const { data: list } = useQuery({
|
||||
...workspaceListOptions(),
|
||||
enabled: !!user,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (isLoading || !user || !list) return;
|
||||
const [first] = list;
|
||||
if (!first) {
|
||||
router.replace(paths.newWorkspace());
|
||||
return;
|
||||
}
|
||||
router.replace(paths.workspace(first.slug).issues());
|
||||
}, [isLoading, user, list, router]);
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -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,52 +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",
|
||||
title: "Desktop App, Autopilot & Invitations",
|
||||
changes: [],
|
||||
features: [
|
||||
"Desktop app for macOS — native Electron app with tab system, built-in daemon management, immersive mode, and auto-update",
|
||||
"Autopilot — scheduled and triggered automations for AI agents",
|
||||
"Workspace invitations with email notifications and dedicated accept page",
|
||||
"Custom CLI arguments per agent for advanced runtime configuration",
|
||||
"Chat redesign with unread tracking and improved session management",
|
||||
"Create Agent dialog shows runtime owner with Mine/All filter",
|
||||
],
|
||||
improvements: [
|
||||
"Inter font with CJK fallback and automatic CJK+Latin spacing",
|
||||
"Sidebar user menu redesigned as full-row popover",
|
||||
"WebSocket ping/pong heartbeat to detect dead connections",
|
||||
"Members can now create agents and manage their own skills",
|
||||
],
|
||||
fixes: [
|
||||
"Agent now triggered on reply in threads where it already participated",
|
||||
"Self-hosting: local uploads persist in Docker, WebSocket URL auto-derived for LAN access",
|
||||
"Stale cmd+k recent issues resolved",
|
||||
],
|
||||
},
|
||||
{
|
||||
version: "0.1.33",
|
||||
date: "2026-04-14",
|
||||
|
||||
@@ -26,7 +26,6 @@ export type LandingDict = {
|
||||
headlineLine2: string;
|
||||
subheading: string;
|
||||
cta: string;
|
||||
downloadDesktop: string;
|
||||
worksWith: string;
|
||||
imageAlt: string;
|
||||
};
|
||||
|
||||
@@ -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,52 +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",
|
||||
title: "桌面应用、Autopilot 与邀请",
|
||||
changes: [],
|
||||
features: [
|
||||
"macOS 桌面应用——原生 Electron 应用,支持标签页系统、内置 Daemon 管理、沉浸模式和自动更新",
|
||||
"Autopilot——Agent 定时和触发式自动化任务",
|
||||
"工作区邀请,支持邮件通知和专用接受页面",
|
||||
"Agent 自定义 CLI 参数,支持高级运行时配置",
|
||||
"聊天界面重设计,新增未读追踪和会话管理优化",
|
||||
"创建 Agent 对话框显示运行时所有者和 Mine/All 筛选",
|
||||
],
|
||||
improvements: [
|
||||
"Inter 字体 + CJK 回退,中英文自动间距",
|
||||
"侧边栏用户菜单改为整行弹出面板",
|
||||
"WebSocket ping/pong 心跳检测断线连接",
|
||||
"普通成员现在可以创建 Agent 和管理自己的 Skills",
|
||||
],
|
||||
fixes: [
|
||||
"Agent 在已参与的线程收到回复时正确触发",
|
||||
"自部署:Docker 本地上传文件持久化,WebSocket URL 自动适配局域网",
|
||||
"Cmd+K 最近 Issue 列表状态过期",
|
||||
],
|
||||
},
|
||||
{
|
||||
version: "0.1.33",
|
||||
date: "2026-04-14",
|
||||
|
||||
@@ -1,81 +1,10 @@
|
||||
import { NextResponse, type NextRequest } from "next/server";
|
||||
|
||||
// Old workspace-scoped route segments that existed before the URL refactor
|
||||
// (pre-#1131). Any URL with these as the FIRST segment is a legacy URL that
|
||||
// needs to be rewritten to /{slug}/{route}/... so old bookmarks, deep links,
|
||||
// and post-revert-and-reapply users don't hit 404.
|
||||
const LEGACY_ROUTE_SEGMENTS = new Set([
|
||||
"issues",
|
||||
"projects",
|
||||
"agents",
|
||||
"inbox",
|
||||
"my-issues",
|
||||
"autopilots",
|
||||
"runtimes",
|
||||
"skills",
|
||||
"settings",
|
||||
]);
|
||||
|
||||
// Next.js 16 renamed `middleware` → `proxy`. The runtime API is identical.
|
||||
export function proxy(req: NextRequest) {
|
||||
const { pathname } = req.nextUrl;
|
||||
const hasSession = req.cookies.has("multica_logged_in");
|
||||
const lastSlug = req.cookies.get("last_workspace_slug")?.value;
|
||||
|
||||
// --- Legacy URL redirect: /issues/... → /{slug}/issues/... ---
|
||||
// Old bookmarks and clients that hit us before the slug migration would
|
||||
// otherwise 404 since the route moved under [workspaceSlug].
|
||||
const firstSegment = pathname.split("/")[1] ?? "";
|
||||
if (LEGACY_ROUTE_SEGMENTS.has(firstSegment)) {
|
||||
const url = req.nextUrl.clone();
|
||||
|
||||
if (!hasSession) {
|
||||
url.pathname = "/login";
|
||||
return NextResponse.redirect(url);
|
||||
}
|
||||
|
||||
if (lastSlug) {
|
||||
// Preserve deep-link path + query: /issues/abc → /{lastSlug}/issues/abc
|
||||
url.pathname = `/${lastSlug}${pathname}`;
|
||||
return NextResponse.redirect(url);
|
||||
}
|
||||
|
||||
// Logged-in but no cookie yet (first login since slug migration, or
|
||||
// cookie cleared). Bounce to root; the root-path logic below picks a
|
||||
// workspace and writes the cookie, then future hits short-circuit here.
|
||||
url.pathname = "/";
|
||||
return NextResponse.redirect(url);
|
||||
}
|
||||
|
||||
// --- Root path: redirect logged-in users to their last workspace ---
|
||||
if (pathname === "/") {
|
||||
if (!hasSession) return NextResponse.next();
|
||||
|
||||
if (lastSlug) {
|
||||
const url = req.nextUrl.clone();
|
||||
url.pathname = `/${lastSlug}/issues`;
|
||||
return NextResponse.redirect(url);
|
||||
}
|
||||
|
||||
// No last_workspace_slug cookie → let landing page pick the first workspace
|
||||
// client-side (features/landing/components/redirect-if-authenticated.tsx).
|
||||
return NextResponse.next();
|
||||
}
|
||||
import { NextResponse } from "next/server";
|
||||
import type { NextRequest } from "next/server";
|
||||
|
||||
export function proxy(_request: NextRequest) {
|
||||
return NextResponse.next();
|
||||
}
|
||||
|
||||
export const config = {
|
||||
matcher: [
|
||||
"/",
|
||||
"/issues/:path*",
|
||||
"/projects/:path*",
|
||||
"/agents/:path*",
|
||||
"/inbox/:path*",
|
||||
"/my-issues/:path*",
|
||||
"/autopilots/:path*",
|
||||
"/runtimes/:path*",
|
||||
"/skills/:path*",
|
||||
"/settings/:path*",
|
||||
],
|
||||
matcher: ["/"],
|
||||
};
|
||||
|
||||
@@ -55,7 +55,6 @@ export const mockAgents: Agent[] = [
|
||||
runtime_mode: "cloud",
|
||||
runtime_config: {},
|
||||
custom_env: {},
|
||||
custom_args: [],
|
||||
custom_env_redacted: false,
|
||||
visibility: "workspace",
|
||||
max_concurrent_tasks: 3,
|
||||
@@ -78,6 +77,7 @@ export const mockAuthValue: Record<string, any> = {
|
||||
isLoading: false,
|
||||
login: vi.fn(),
|
||||
logout: vi.fn(),
|
||||
switchWorkspace: vi.fn(),
|
||||
updateWorkspace: vi.fn(),
|
||||
updateCurrentUser: vi.fn(),
|
||||
getMemberName: (userId: string) => {
|
||||
|
||||
@@ -5,8 +5,8 @@
|
||||
# # Edit .env — change JWT_SECRET at minimum
|
||||
# docker compose -f docker-compose.selfhost.yml up -d
|
||||
#
|
||||
# Frontend: http://localhost:3000
|
||||
# Backend: http://localhost:8080 (also used by CLI/daemon)
|
||||
# Frontend: https://$TASK_DOMAIN (via Caddy reverse proxy)
|
||||
# Backend: internal on backend:8080 (health exposed at /health through Caddy)
|
||||
|
||||
name: multica
|
||||
|
||||
@@ -35,14 +35,13 @@ services:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
ports:
|
||||
- "${PORT:-8080}:8080"
|
||||
volumes:
|
||||
- backend_uploads:/app/data/uploads
|
||||
- "127.0.0.1:${PORT:-8080}:8080"
|
||||
environment:
|
||||
DATABASE_URL: postgres://${POSTGRES_USER:-multica}:${POSTGRES_PASSWORD:-multica}@postgres:5432/${POSTGRES_DB:-multica}?sslmode=disable
|
||||
APP_ENV: ${APP_ENV:-}
|
||||
PORT: "8080"
|
||||
JWT_SECRET: ${JWT_SECRET:-change-me-in-production}
|
||||
FRONTEND_ORIGIN: ${FRONTEND_ORIGIN:-http://localhost:3000}
|
||||
FRONTEND_ORIGIN: ${FRONTEND_ORIGIN:-https://${TASK_DOMAIN:-localhost}}
|
||||
CORS_ALLOWED_ORIGINS: ${CORS_ALLOWED_ORIGINS:-}
|
||||
RESEND_API_KEY: ${RESEND_API_KEY:-}
|
||||
RESEND_FROM_EMAIL: ${RESEND_FROM_EMAIL:-noreply@multica.ai}
|
||||
@@ -62,16 +61,35 @@ services:
|
||||
context: .
|
||||
dockerfile: Dockerfile.web
|
||||
args:
|
||||
REMOTE_API_URL: http://backend:8080
|
||||
REMOTE_API_URL: ${REMOTE_API_URL:-http://backend:8080}
|
||||
NEXT_PUBLIC_GOOGLE_CLIENT_ID: ${NEXT_PUBLIC_GOOGLE_CLIENT_ID:-}
|
||||
NEXT_PUBLIC_API_URL: ${NEXT_PUBLIC_API_URL:-}
|
||||
NEXT_PUBLIC_WS_URL: ${NEXT_PUBLIC_WS_URL:-}
|
||||
depends_on:
|
||||
- backend
|
||||
ports:
|
||||
- "${FRONTEND_PORT:-3000}:3000"
|
||||
- "127.0.0.1:${FRONTEND_PORT:-3000}:3000"
|
||||
environment:
|
||||
HOSTNAME: "0.0.0.0"
|
||||
NEXT_PUBLIC_API_URL: ${NEXT_PUBLIC_API_URL:-}
|
||||
NEXT_PUBLIC_WS_URL: ${NEXT_PUBLIC_WS_URL:-}
|
||||
|
||||
caddy:
|
||||
image: caddy:2-alpine
|
||||
depends_on:
|
||||
- frontend
|
||||
- backend
|
||||
ports:
|
||||
- "80:80"
|
||||
- "443:443"
|
||||
environment:
|
||||
TASK_DOMAIN: ${TASK_DOMAIN:-localhost}
|
||||
volumes:
|
||||
- ./Caddyfile:/etc/caddy/Caddyfile:ro
|
||||
- caddy_data:/data
|
||||
- caddy_config:/config
|
||||
|
||||
volumes:
|
||||
pgdata:
|
||||
backend_uploads:
|
||||
caddy_data:
|
||||
caddy_config:
|
||||
|
||||
@@ -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.
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -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.
|
||||
@@ -1,109 +0,0 @@
|
||||
# Workspace URL 化重构 — 项目汇报
|
||||
|
||||
**日期**:2026-04-15
|
||||
**作者**:Naiyuan
|
||||
**状态**:调研完成,待评审
|
||||
|
||||
---
|
||||
|
||||
## 一、为什么要做
|
||||
|
||||
当前 workspace 上下文完全靠 `X-Workspace-ID` HTTP header + Zustand store + localStorage 承载,URL 里**不含任何 workspace 信息**。所有路径都是 `/issues`、`/issues/:id` 这种 workspace-agnostic 的。
|
||||
|
||||
这个设计已经在产品里直接表现为 3 个已知问题:
|
||||
|
||||
1. **分享链接不可靠**(MUL-43):`/issues/abc` 发给另一个成员,会用他自己 localStorage 里的 workspace 去解析,导致 404 或看到错误 workspace 的数据
|
||||
2. **手机端无法切 workspace**(MUL-509):切换只靠 sidebar UI,手机端不展开 sidebar 就没有切换入口
|
||||
3. **多 tab 互相覆盖**:`multica_workspace_id` 是全局 localStorage key,两个 tab 打开不同 workspace 会互相污染
|
||||
|
||||
除了这 3 个显性 bug,架构上的"多份 workspace 状态拷贝互相同步"也带来一些隐性问题(创建 workspace 闪页、切换 workspace 时 cache 竞态等),积累时间越长后续改动越难。
|
||||
|
||||
行业惯例(Linear / Notion / Vercel / GitHub)都是 `/{workspace-slug}/...` 的 URL 形态,把 URL 当作 workspace 的唯一来源。这是我们应该对齐的最佳实践。
|
||||
|
||||
## 二、调研结论
|
||||
|
||||
### 好消息:基础设施已经就位
|
||||
|
||||
- 数据库 `workspace.slug` 字段已经存在(`TEXT UNIQUE NOT NULL`),用户创建时手动指定且不可修改
|
||||
- 后端已有 `GetWorkspaceBySlug` 查询
|
||||
- 前端 `Workspace` 类型已包含 `slug` 字段
|
||||
- Web 端认证已经切换为 HttpOnly cookie 模式,Next.js middleware 可读到登录态
|
||||
|
||||
也就是说这次改造**不需要大量后端改动**,主要是前端路由和状态管理的重新组织。
|
||||
|
||||
### 坏消息:范围比最初估计大
|
||||
|
||||
初看以为只是"URL 前缀加个 slug",调研后发现必须一起做的事情有:
|
||||
|
||||
1. **URL 路由重组**:web 端所有 dashboard 路由迁到 `app/[workspaceSlug]/(dashboard)/*`;desktop 端所有 react-router 路由加 `/:workspaceSlug` 前缀
|
||||
2. **状态管理清理**:删除 `useWorkspaceStore.workspace` 作为独立状态,改为从 URL 派生;删除 `hydrateWorkspace` / `switchWorkspace` actions(切 workspace 变成纯导航);删除 `localStorage["multica_workspace_id"]`
|
||||
3. **所有路径引用替换**:`push("/issues")` 改为 path builder(`paths.issues()`),影响 ~25 个组件文件
|
||||
4. **Mutation 副作用重构**:`useCreateWorkspace` / `useLeaveWorkspace` / `useDeleteWorkspace` 里的 `switchWorkspace` 调用全部移除(这些调用正是 MUL-727 闪页、MUL-728 删除后不跳转、MUL-820 接受邀请不切 workspace 等一系列 bug 的根因)
|
||||
5. **桌面端 tab 系统适配**:tab 路径天然包含 workspace,切 workspace = 开新 tab 或导航,不再有全局切换动作
|
||||
6. **Shareable URL 修复**:桌面端 `getShareableUrl` 当前生成 `https://www.multica.ai/issues/abc`(缺 slug),需要更新
|
||||
7. **后端保留词校验**:slug 不能和前端顶级路由冲突(`login`、`onboarding`、`invite`、`api`、`settings` 等),后端创建时校验
|
||||
8. **内部 markdown 链接兼容**:issue 评论里写的 `[foo](/issues/abc)` 触发的 `multica:navigate` 事件需要自动补当前 workspace slug
|
||||
|
||||
### 不需要改的(边界已确认)
|
||||
|
||||
- 邮件邀请链接 `/invite/{id}` — 接受邀请是 pre-workspace 流程,不需要 slug
|
||||
- `mention://type/id` 协议 — 只存 UUID,workspace-agnostic
|
||||
- CLI 登录 URL — `/login` 也是 pre-workspace,不需要 slug
|
||||
- 后端 API 路径 — 保持 `/api/workspaces/{id}`,slug 仅用于前端 URL
|
||||
- 桌面端 `multica://auth/callback` — 认证回调,不涉及 workspace
|
||||
|
||||
## 三、方案要点
|
||||
|
||||
**核心原则**:URL 是 workspace 上下文的唯一 source of truth,其他状态都是派生态。
|
||||
|
||||
**URL 形状**:`/{workspace-slug}/issues/{id}` (和 Linear / Notion 一致)
|
||||
|
||||
**切换 workspace = 导航**:sidebar 下拉改为 `<Link href="/{new-slug}/issues">`,不再有命令式的 `switchWorkspace` 函数。这样一次性消除前面列出的一大批 mutation 副作用 bug。
|
||||
|
||||
**预估影响面**:~30-35 个文件,其中约 20 个是机械替换(hardcoded 路径 → path builder),真正需要思考的核心逻辑改动集中在 5-6 个文件。
|
||||
|
||||
**一个 PR 合并**:中间状态不可运行(URL 结构是原子变化),不拆 PR。worktree 里充分开发和自测,一次 review 合并。
|
||||
|
||||
## 四、执行与测试计划
|
||||
|
||||
### 执行阶段
|
||||
|
||||
1. **本周内**:完成方案详细实施文档(精确到文件 / 行号 / 代码片段)
|
||||
2. **下一步**:在独立 worktree 上开发,AI 辅助写代码,过程中人工 review
|
||||
3. **开发完成后**:本地跑全套验证(`make check` — TypeScript + 单测 + Go 测试 + E2E)
|
||||
|
||||
### 测试阶段
|
||||
|
||||
1. **本地自测**:
|
||||
- 已知功能路径(创建 / 浏览 / 搜索 issue,切换 workspace,接受邀请,分享链接)
|
||||
- 已知 bug 场景(MUL-43 / MUL-509 / MUL-727 / MUL-820)逐一验证已修复
|
||||
- 多 tab 场景(两个 tab 打开不同 workspace 互不影响)
|
||||
2. **测试环境部署**:本地通过后发测试环境,全员试用几天,观察:
|
||||
- 是否有回归(特别是导航流、创建/删除 workspace、邀请流程)
|
||||
- URL 使用感受(分享、收藏、刷新)
|
||||
3. **灰度 / 生产**:测试环境稳定后推生产
|
||||
|
||||
### 风险提示
|
||||
|
||||
- **唯一的硬中断点**:现有的 `/issues` 等 URL 在重构后会 404(产品还没正式 ship、用户量可忽略,所以不做兼容性重定向)
|
||||
- **E2E 测试断言**:约 20-30 处 URL 断言需要更新
|
||||
- **后端保留词清单**:如果现有 workspace 里有名字撞到保留词的(例如正好叫 `settings`),需要提前 migrate(可能性极低,因 slug 限制较严)
|
||||
|
||||
## 五、附注
|
||||
|
||||
这次重构会**顺带修掉**以下已登记 issue,不需要单独开 PR:
|
||||
|
||||
| Issue | 修复方式 |
|
||||
|---|---|
|
||||
| MUL-43(切换 workspace 报错 / 分享链接失效) | URL 带 slug,根本解决 |
|
||||
| MUL-509(手机端无法切 workspace) | 切换变导航,手机能点链接就能切 |
|
||||
| MUL-723(workspace 不在 URL) | 核心目标 |
|
||||
| MUL-727(创建 workspace 闪 /issues) | 删除 mutation 里的 switchWorkspace 副作用 |
|
||||
| MUL-728(删除 workspace 后留在 /settings) | 删除成功后 navigate 到下一个 workspace |
|
||||
| MUL-820(sidebar Join 不切 workspace) | Join 改成跳转到 `/invite/{id}` 走统一路径 |
|
||||
|
||||
不在本次范围内的:Issue #951(WebSocket 半开导致 cache 陈旧)—— 这是 realtime 层独立问题,单独 PR 处理。
|
||||
|
||||
---
|
||||
|
||||
**当前状态**:准备进入详细实施方案撰写,预计完成后再同步一次。
|
||||
@@ -24,12 +24,10 @@ test.describe("Authentication", () => {
|
||||
await page.goto("/login");
|
||||
await page.evaluate(() => {
|
||||
localStorage.removeItem("multica_token");
|
||||
localStorage.removeItem("multica_workspace_id");
|
||||
});
|
||||
|
||||
// Visit a workspace-scoped route; DashboardGuard should redirect to /login.
|
||||
// The slug here need not exist — the guard runs before workspace resolution
|
||||
// for unauthenticated users.
|
||||
await page.goto("/e2e-workspace/issues");
|
||||
await page.goto("/issues");
|
||||
await page.waitForURL("**/login", { timeout: 10000 });
|
||||
});
|
||||
|
||||
|
||||
@@ -16,9 +16,8 @@ test.describe("Comments", () => {
|
||||
});
|
||||
|
||||
test("can add a comment on an issue", async ({ page }) => {
|
||||
// Wait for issues to load and click first one. `*=` matches both legacy
|
||||
// `/issues/{id}` and URL-refactored `/{slug}/issues/{id}` hrefs.
|
||||
const issueLink = page.locator('a[href*="/issues/"]').first();
|
||||
// Wait for issues to load and click first one
|
||||
const issueLink = page.locator('a[href^="/issues/"]').first();
|
||||
await expect(issueLink).toBeVisible({ timeout: 5000 });
|
||||
await issueLink.click();
|
||||
await page.waitForURL(/\/issues\/[\w-]+/);
|
||||
@@ -43,7 +42,7 @@ test.describe("Comments", () => {
|
||||
});
|
||||
|
||||
test("comment submit button is disabled when empty", async ({ page }) => {
|
||||
const issueLink = page.locator('a[href*="/issues/"]').first();
|
||||
const issueLink = page.locator('a[href^="/issues/"]').first();
|
||||
await expect(issueLink).toBeVisible({ timeout: 5000 });
|
||||
await issueLink.click();
|
||||
await page.waitForURL(/\/issues\/[\w-]+/);
|
||||
|
||||
@@ -7,10 +7,7 @@
|
||||
import "./env";
|
||||
import pg from "pg";
|
||||
|
||||
// `||` (not `??`) so an empty `NEXT_PUBLIC_API_URL=` in .env still falls
|
||||
// back to localhost. dotenv sets unset-vs-empty both as "" — treating them
|
||||
// the same matches user intent.
|
||||
const API_BASE = process.env.NEXT_PUBLIC_API_URL || `http://localhost:${process.env.PORT || "8080"}`;
|
||||
const API_BASE = process.env.NEXT_PUBLIC_API_URL ?? `http://localhost:${process.env.PORT ?? "8080"}`;
|
||||
const DATABASE_URL = process.env.DATABASE_URL ?? "postgres://multica:multica@localhost:5432/multica?sslmode=disable";
|
||||
|
||||
interface TestWorkspace {
|
||||
@@ -21,7 +18,6 @@ interface TestWorkspace {
|
||||
|
||||
export class TestApiClient {
|
||||
private token: string | null = null;
|
||||
private workspaceSlug: string | null = null;
|
||||
private workspaceId: string | null = null;
|
||||
private createdIssueIds: string[] = [];
|
||||
|
||||
@@ -90,16 +86,11 @@ export class TestApiClient {
|
||||
this.workspaceId = id;
|
||||
}
|
||||
|
||||
setWorkspaceSlug(slug: string) {
|
||||
this.workspaceSlug = slug;
|
||||
}
|
||||
|
||||
async ensureWorkspace(name = "E2E Workspace", slug = "e2e-workspace") {
|
||||
const workspaces = await this.getWorkspaces();
|
||||
const workspace = workspaces.find((item) => item.slug === slug) ?? workspaces[0];
|
||||
if (workspace) {
|
||||
this.workspaceId = workspace.id;
|
||||
this.workspaceSlug = workspace.slug;
|
||||
return workspace;
|
||||
}
|
||||
|
||||
@@ -159,8 +150,7 @@ export class TestApiClient {
|
||||
...((init?.headers as Record<string, string>) ?? {}),
|
||||
};
|
||||
if (this.token) headers["Authorization"] = `Bearer ${this.token}`;
|
||||
if (this.workspaceSlug) headers["X-Workspace-Slug"] = this.workspaceSlug;
|
||||
else if (this.workspaceId) headers["X-Workspace-ID"] = this.workspaceId;
|
||||
if (this.workspaceId) headers["X-Workspace-ID"] = this.workspaceId;
|
||||
return fetch(`${API_BASE}${path}`, { ...init, headers });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,25 +9,19 @@ const DEFAULT_E2E_WORKSPACE = "e2e-workspace";
|
||||
* Log in as the default E2E user and ensure the workspace exists first.
|
||||
* Authenticates via API (send-code → DB read → verify-code), then injects
|
||||
* the token into localStorage so the browser session is authenticated.
|
||||
*
|
||||
* Returns the E2E workspace slug so callers can build workspace-scoped URLs.
|
||||
*/
|
||||
export async function loginAsDefault(page: Page): Promise<string> {
|
||||
export async function loginAsDefault(page: Page) {
|
||||
const api = new TestApiClient();
|
||||
await api.login(DEFAULT_E2E_EMAIL, DEFAULT_E2E_NAME);
|
||||
const workspace = await api.ensureWorkspace(
|
||||
"E2E Workspace",
|
||||
DEFAULT_E2E_WORKSPACE,
|
||||
);
|
||||
await api.ensureWorkspace("E2E Workspace", DEFAULT_E2E_WORKSPACE);
|
||||
|
||||
const token = api.getToken();
|
||||
await page.goto("/login");
|
||||
await page.evaluate((t) => {
|
||||
localStorage.setItem("multica_token", t);
|
||||
}, token);
|
||||
await page.goto(`/${workspace.slug}/issues`);
|
||||
await page.goto("/issues");
|
||||
await page.waitForURL("**/issues", { timeout: 10000 });
|
||||
return workspace.slug;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -65,10 +65,8 @@ test.describe("Issues", () => {
|
||||
// Reload to see the new issue
|
||||
await page.reload();
|
||||
|
||||
// Navigate to the issue detail. Use a suffix match so the selector works
|
||||
// whether the href is legacy `/issues/{id}` or URL-refactored
|
||||
// `/{slug}/issues/{id}`.
|
||||
const issueLink = page.locator(`a[href$="/issues/${issue.id}"]`);
|
||||
// Navigate to the issue detail
|
||||
const issueLink = page.locator(`a[href="/issues/${issue.id}"]`);
|
||||
await expect(issueLink).toBeVisible({ timeout: 5000 });
|
||||
await issueLink.click();
|
||||
|
||||
|
||||
@@ -66,7 +66,6 @@ import type {
|
||||
} from "../types";
|
||||
import { type Logger, noopLogger } from "../logger";
|
||||
import { createRequestId } from "../utils";
|
||||
import { getCurrentSlug } from "../platform/workspace-storage";
|
||||
|
||||
export interface ApiClientOptions {
|
||||
logger?: Logger;
|
||||
@@ -93,6 +92,7 @@ export class ApiError extends Error {
|
||||
export class ApiClient {
|
||||
private baseUrl: string;
|
||||
private token: string | null = null;
|
||||
private workspaceId: string | null = null;
|
||||
private logger: Logger;
|
||||
private options: ApiClientOptions;
|
||||
|
||||
@@ -110,6 +110,10 @@ export class ApiClient {
|
||||
this.token = token;
|
||||
}
|
||||
|
||||
setWorkspaceId(id: string | null) {
|
||||
this.workspaceId = id;
|
||||
}
|
||||
|
||||
private readCsrfToken(): string | null {
|
||||
if (typeof document === "undefined") return null;
|
||||
const match = document.cookie
|
||||
@@ -121,8 +125,7 @@ export class ApiClient {
|
||||
private authHeaders(): Record<string, string> {
|
||||
const headers: Record<string, string> = {};
|
||||
if (this.token) headers["Authorization"] = `Bearer ${this.token}`;
|
||||
const slug = getCurrentSlug();
|
||||
if (slug) headers["X-Workspace-Slug"] = slug;
|
||||
if (this.workspaceId) headers["X-Workspace-ID"] = this.workspaceId;
|
||||
const csrf = this.readCsrfToken();
|
||||
if (csrf) headers["X-CSRF-Token"] = csrf;
|
||||
return headers;
|
||||
@@ -130,10 +133,7 @@ export class ApiClient {
|
||||
|
||||
private handleUnauthorized() {
|
||||
this.token = null;
|
||||
// Workspace id is owned by the URL-driven workspace-storage singleton
|
||||
// (set by [workspaceSlug]/layout.tsx). On 401, the auth flow navigates
|
||||
// to /login which leaves the workspace route, and the next workspace
|
||||
// entry will overwrite the id. No clear needed here.
|
||||
this.workspaceId = null;
|
||||
this.options.onUnauthorized?.();
|
||||
}
|
||||
|
||||
@@ -231,7 +231,8 @@ export class ApiClient {
|
||||
const search = new URLSearchParams();
|
||||
if (params?.limit) search.set("limit", String(params.limit));
|
||||
if (params?.offset) search.set("offset", String(params.offset));
|
||||
if (params?.workspace_id) search.set("workspace_id", params.workspace_id);
|
||||
const wsId = params?.workspace_id ?? this.workspaceId;
|
||||
if (wsId) search.set("workspace_id", wsId);
|
||||
if (params?.status) search.set("status", params.status);
|
||||
if (params?.priority) search.set("priority", params.priority);
|
||||
if (params?.assignee_id) search.set("assignee_id", params.assignee_id);
|
||||
@@ -262,7 +263,9 @@ export class ApiClient {
|
||||
}
|
||||
|
||||
async createIssue(data: CreateIssueRequest): Promise<Issue> {
|
||||
return this.fetch("/api/issues", {
|
||||
const search = new URLSearchParams();
|
||||
if (this.workspaceId) search.set("workspace_id", this.workspaceId);
|
||||
return this.fetch(`/api/issues?${search}`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
@@ -393,7 +396,8 @@ export class ApiClient {
|
||||
// Agents
|
||||
async listAgents(params?: { workspace_id?: string; include_archived?: boolean }): Promise<Agent[]> {
|
||||
const search = new URLSearchParams();
|
||||
if (params?.workspace_id) search.set("workspace_id", params.workspace_id);
|
||||
const wsId = params?.workspace_id ?? this.workspaceId;
|
||||
if (wsId) search.set("workspace_id", wsId);
|
||||
if (params?.include_archived) search.set("include_archived", "true");
|
||||
return this.fetch(`/api/agents?${search}`);
|
||||
}
|
||||
@@ -426,7 +430,8 @@ export class ApiClient {
|
||||
|
||||
async listRuntimes(params?: { workspace_id?: string; owner?: "me" }): Promise<AgentRuntime[]> {
|
||||
const search = new URLSearchParams();
|
||||
if (params?.workspace_id) search.set("workspace_id", params.workspace_id);
|
||||
const wsId = params?.workspace_id ?? this.workspaceId;
|
||||
if (wsId) search.set("workspace_id", wsId);
|
||||
if (params?.owner) search.set("owner", params.owner);
|
||||
return this.fetch(`/api/runtimes?${search}`);
|
||||
}
|
||||
@@ -783,7 +788,9 @@ export class ApiClient {
|
||||
}
|
||||
|
||||
async createProject(data: CreateProjectRequest): Promise<Project> {
|
||||
return this.fetch("/api/projects", {
|
||||
const search = new URLSearchParams();
|
||||
if (this.workspaceId) search.set("workspace_id", this.workspaceId);
|
||||
return this.fetch(`/api/projects?${search}`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
|
||||
@@ -7,7 +7,7 @@ export class WSClient {
|
||||
private ws: WebSocket | null = null;
|
||||
private baseUrl: string;
|
||||
private token: string | null = null;
|
||||
private workspaceSlug: string | null = null;
|
||||
private workspaceId: string | null = null;
|
||||
private cookieAuth = false;
|
||||
private handlers = new Map<WSEventType, Set<EventHandler>>();
|
||||
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
@@ -22,9 +22,9 @@ export class WSClient {
|
||||
this.cookieAuth = options?.cookieAuth ?? false;
|
||||
}
|
||||
|
||||
setAuth(token: string | null, workspaceSlug: string) {
|
||||
setAuth(token: string | null, workspaceId: string) {
|
||||
this.token = token;
|
||||
this.workspaceSlug = workspaceSlug;
|
||||
this.workspaceId = workspaceId;
|
||||
}
|
||||
|
||||
connect() {
|
||||
@@ -33,8 +33,8 @@ export class WSClient {
|
||||
// proxies, CDNs, and browser history. In cookie mode the HttpOnly cookie
|
||||
// is sent automatically with the upgrade request. In token mode the token
|
||||
// is delivered as the first WebSocket message after the connection opens.
|
||||
if (this.workspaceSlug)
|
||||
url.searchParams.set("workspace_slug", this.workspaceSlug);
|
||||
if (this.workspaceId)
|
||||
url.searchParams.set("workspace_id", this.workspaceId);
|
||||
|
||||
this.ws = new WebSocket(url.toString());
|
||||
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user