Compare commits

..

2 Commits

Author SHA1 Message Date
Jiayuan Zhang
d47be55372 fix(autopilot): address code review — restrict run_only, validate workspace on update
Code review caught two issues with the initial CLI PR:

1. run_only mode is broken end-to-end. The daemon-side
   resolveTaskWorkspaceID() in internal/handler/daemon.go only resolves
   workspace from issue/chat, so run_only tasks (which have neither)
   return 404 from /start. BuildPrompt() would also emit an empty issue
   ID. The service-level resolver in internal/service/task.go already
   handles AutopilotRunID, but the daemon endpoint uses the handler
   copy. Fixing that path is out of scope for the CLI PR; drop
   run_only from the CLI and docs so we don't recommend a mode that
   cannot complete. Server continues to accept it for the existing UI.

2. UpdateAutopilot did not verify that a new assignee_id belongs to
   the workspace, unlike CreateAutopilot. This let a PATCH swap in an
   agent from a different workspace. Mirror the same
   GetAgentInWorkspace check.
2026-04-17 10:36:09 +08:00
Jiayuan Zhang
63daa7a112 feat(cli): add autopilot commands
Expose the existing autopilot REST API through the multica CLI so
users and agents can list, get, create, update, delete, trigger, and
inspect autopilots, plus manage their triggers (schedule/webhook/api).

Also surface the read + core write commands in the agent meta skill
prompt so agents discover them without needing --help.

- new cmd_autopilot.go (+ test) wiring /api/autopilots endpoints
- add APIClient.PatchJSON (autopilot update uses PATCH)
- expose autopilot in CORE COMMANDS group
- extend runtime_config.go meta skill with autopilot entries
- document autopilot command group in CLI_AND_DAEMON.md
2026-04-17 10:21:41 +08:00
190 changed files with 1602 additions and 12876 deletions

View File

@@ -4,23 +4,8 @@ POSTGRES_USER=multica
POSTGRES_PASSWORD=multica
POSTGRES_PORT=5432
DATABASE_URL=postgres://multica:multica@localhost:5432/multica?sslmode=disable
# Optional pgxpool tuning. Defaults are 25 / 5 per pod and are usually fine.
# You can also set pool_max_conns / pool_min_conns as query params on
# DATABASE_URL; env vars below take precedence over URL params.
# DATABASE_MAX_CONNS=25
# DATABASE_MIN_CONNS=5
# Server
# APP_ENV gates dev-only auth shortcuts (primarily the 888888 master code).
# - Docker self-host: docker-compose.selfhost.yml already pins APP_ENV to
# "production" by default, so 888888 is DISABLED — a public instance can't
# be logged into with any email + 888888.
# - Local dev (make dev): leave APP_ENV unset so 888888 works out of the box.
# - Docker self-host on a private network you fully control, or evaluation
# without Resend: set APP_ENV=development to re-enable 888888. Do NOT
# enable on a publicly reachable instance.
# See SELF_HOSTING.md for the full login setup.
APP_ENV=
PORT=8080
JWT_SECRET=change-me-in-production
MULTICA_SERVER_URL=ws://localhost:8080/ws
@@ -37,8 +22,7 @@ MULTICA_CODEX_WORKDIR=
MULTICA_CODEX_TIMEOUT=20m
# Email (Resend)
# For local/dev use, leave RESEND_API_KEY empty — codes print to stdout, and
# master code 888888 works (only when APP_ENV != "production"; see above).
# For local/dev use, leave RESEND_API_KEY empty — codes print to stdout, and master code 888888 works.
# For production, set your Resend API key and change RESEND_FROM_EMAIL to a domain verified in your Resend account.
RESEND_API_KEY=
RESEND_FROM_EMAIL=noreply@multica.ai
@@ -56,13 +40,6 @@ CLOUDFRONT_KEY_PAIR_ID=
CLOUDFRONT_PRIVATE_KEY_SECRET=multica/cloudfront-signing-key
CLOUDFRONT_PRIVATE_KEY=
CLOUDFRONT_DOMAIN=
# COOKIE_DOMAIN — optional Domain attribute on session + CloudFront cookies.
# Leave empty for single-host deployments (localhost, LAN IP, or a single
# hostname) — session cookies become host-only, which is what the browser
# wants. Only set it when the frontend and backend sit on different
# subdomains of one registered domain (e.g. ".example.com"). Do NOT set it
# to an IP address: RFC 6265 forbids IP literals in the cookie Domain
# attribute and browsers silently drop such cookies.
COOKIE_DOMAIN=
# Local file storage (fallback when S3_BUCKET is not set)
@@ -86,18 +63,3 @@ NEXT_PUBLIC_WS_URL=
# 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
# ==================== Self-hosting: Control Signups (fixes #930) ====================
# Set to "false" to completely disable new user signups (recommended for private instances)
ALLOW_SIGNUP=true
# Must match ALLOW_SIGNUP for the UI to reflect the same signup setting.
# Note: in typical Next.js builds, NEXT_PUBLIC_* values are baked into the client bundle,
# so changing this usually requires rebuilding/redeploying the frontend (not just restarting the backend).
NEXT_PUBLIC_ALLOW_SIGNUP=true
# Optional: Only allow emails from these domains (comma-separated)
ALLOWED_EMAIL_DOMAINS=
# Optional: Only allow these exact email addresses (comma-separated)
ALLOWED_EMAILS=

View File

@@ -30,7 +30,7 @@ jobs:
run: pnpm install
- name: Build, type check, and test
run: pnpm exec turbo build typecheck test --filter='!@multica/docs'
run: pnpm build && pnpm typecheck && pnpm test
backend:
runs-on: ubuntu-latest

View File

@@ -3,8 +3,7 @@ name: Release
on:
push:
tags:
- "v[0-9]+.[0-9]+.[0-9]+"
- "v[0-9]+.[0-9]+.[0-9]+-*"
- "v*"
permissions:
contents: write
@@ -18,15 +17,6 @@ jobs:
with:
fetch-depth: 0
- name: Validate tag name
run: |
tag="${GITHUB_REF_NAME}"
echo "Triggered by tag: $tag"
if [[ "$tag" == *-dirty* ]]; then
echo "::error::Refusing to release from dirty tag '$tag'."
exit 1
fi
- name: Setup Go
uses: actions/setup-go@v5
with:

View File

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

View File

@@ -278,7 +278,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 +293,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
@@ -332,27 +332,6 @@ multica issue comment add <issue-id> --parent <comment-id> --content "Thanks!"
multica issue comment delete <comment-id>
```
### Subscribers
```bash
# List subscribers of an issue
multica issue subscriber list <issue-id>
# Subscribe yourself to an issue
multica issue subscriber add <issue-id>
# Subscribe another member or agent by name
multica issue subscriber add <issue-id> --user "Lambda"
# Unsubscribe yourself
multica issue subscriber remove <issue-id>
# Unsubscribe another member or agent
multica issue subscriber remove <issue-id> --user "Lambda"
```
Subscribers receive notifications about issue activity (new comments, status changes, etc.). Without `--user`, the command acts on the caller.
### Execution History
```bash
@@ -370,70 +349,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
@@ -517,16 +432,15 @@ multica autopilot runs <id>
multica autopilot runs <id> --limit 50 --output json
```
### Schedule Triggers
### Triggers (Schedule / Webhook / API)
```bash
multica autopilot trigger-add <autopilot-id> --cron "0 9 * * 1-5" --timezone "America/New_York"
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>
```
Only cron-based `schedule` triggers are currently exposed via the CLI. The data model also defines `webhook` and `api` kinds, but there is no server endpoint that fires them yet, so they're not surfaced here.
## Other Commands
```bash

View File

@@ -66,8 +66,7 @@ selfhost:
echo " Frontend: http://localhost:$${FRONTEND_PORT:-3000}"; \
echo " Backend: http://localhost:$${PORT:-8080}"; \
echo ""; \
echo "Log in: configure RESEND_API_KEY in .env for email codes,"; \
echo " or set APP_ENV=development in .env (private networks only) to enable code 888888."; \
echo "Log in with any email + verification code: 888888"; \
echo ""; \
echo "Next — install the CLI and connect your machine:"; \
echo " brew install multica-ai/tap/multica"; \

View File

@@ -26,7 +26,7 @@ multica setup self-host
This clones the repository, starts all services via Docker Compose, installs the `multica` CLI, then configures it for localhost.
Open http://localhost:3000. To log in, configure `RESEND_API_KEY` in `.env` for email-based codes (recommended), or set `APP_ENV=development` in `.env` to enable the dev master code **`888888`**. See [Step 2 — Log In](#step-2--log-in) for details.
Open http://localhost:3000, log in with any email + verification code **`888888`**.
> **Prerequisites:** Docker and Docker Compose must be installed. The script checks for this and provides install links if missing.
>
@@ -63,13 +63,9 @@ Once ready:
### Step 2 — Log In
Open http://localhost:3000 in your browser. The Docker self-host stack defaults to `APP_ENV=production` (set in `docker-compose.selfhost.yml`), so the dev master code is **disabled by default** for safety on public deployments. Pick one of the following to log in:
Open http://localhost:3000 in your browser. Enter any email address and use verification code **`888888`** to log in.
- **Recommended (production):** configure `RESEND_API_KEY` in `.env`, then restart the backend. Real verification codes will be sent to the email address you enter. See [Advanced Configuration → Email](SELF_HOSTING_ADVANCED.md#email-required-for-authentication).
- **Evaluation / private network:** set `APP_ENV=development` in `.env` and restart the backend. Verification code **`888888`** will then work for any email address.
- **Without configuring either:** the verification code is generated server-side and printed to the backend container logs (look for `[DEV] Verification code for ...:`). Useful for one-off testing on a single machine.
> **Warning:** do **not** set `APP_ENV=development` on a publicly reachable instance — anyone who knows an email address can then log in with `888888`.
> This master code works in all non-production environments (i.e. when `APP_ENV` is not set to `production`). For production, configure an email provider — see [Advanced Configuration](SELF_HOSTING_ADVANCED.md#email-required-for-authentication).
### Step 3 — Install CLI & Start Daemon

View File

@@ -14,15 +14,6 @@ All configuration is done via environment variables. Copy `.env.example` as a st
| `JWT_SECRET` | **Must change from default.** Secret key for signing JWT tokens. Use a long random string. | `openssl rand -hex 32` |
| `FRONTEND_ORIGIN` | URL where the frontend is served (used for CORS) | `https://app.example.com` |
### Database Pool Tuning (Optional)
These have sensible defaults and only need to be set when tuning a large or constrained deployment. Precedence (highest first): env var → `pool_*` query params on `DATABASE_URL` → built-in default.
| Variable | Description | Default |
|----------|-------------|---------|
| `DATABASE_MAX_CONNS` | pgxpool max connections per pod. `pod_count × DATABASE_MAX_CONNS` should stay well below the Postgres `max_connections` ceiling. With a connection pooler (PgBouncer / RDS Proxy / Supavisor) in front, this can be raised significantly. | `25` |
| `DATABASE_MIN_CONNS` | pgxpool warm baseline connections per pod. Auto-clamped to `DATABASE_MAX_CONNS`. | `5` |
### Email (Required for Authentication)
Multica uses email-based magic link authentication via [Resend](https://resend.com).
@@ -32,7 +23,7 @@ Multica uses email-based magic link authentication via [Resend](https://resend.c
| `RESEND_API_KEY` | Your Resend API key |
| `RESEND_FROM_EMAIL` | Sender email address (default: `noreply@multica.ai`) |
> **Note:** The dev master verification code `888888` is gated by `APP_ENV != "production"`. The Docker self-host stack defaults to `APP_ENV=production` (so `888888` is disabled), which protects publicly reachable instances. For local development without email configured, set `APP_ENV=development` in your `.env` to enable `888888` — never do this on a public instance.
> **Note:** For local/development deployments without email configured, you can use the master verification code `888888` to log in.
### Google OAuth (Optional)
@@ -53,14 +44,7 @@ For file uploads and attachments, configure S3 and CloudFront:
| `CLOUDFRONT_DOMAIN` | CloudFront distribution domain |
| `CLOUDFRONT_KEY_PAIR_ID` | CloudFront key pair ID for signed URLs |
| `CLOUDFRONT_PRIVATE_KEY` | CloudFront private key (PEM format) |
### Cookies
| Variable | Description |
|----------|-------------|
| `COOKIE_DOMAIN` | Optional `Domain` attribute for session + CloudFront cookies. **Leave empty** for single-host deployments (localhost, LAN IP, or a single hostname). Only set it when the frontend and backend sit on different subdomains of one registered domain (e.g. `.example.com`). **Do not use an IP literal** — RFC 6265 forbids IP addresses in the cookie `Domain` attribute and browsers will drop such `Set-Cookie` headers. |
The `Secure` flag on session cookies is derived automatically from the scheme of `FRONTEND_ORIGIN`: HTTPS origins get `Secure` cookies; plain-HTTP origins (LAN / private-network self-host) get non-secure cookies so the browser can actually store them.
| `COOKIE_DOMAIN` | Domain for CloudFront auth cookies |
### Server

View File

@@ -42,10 +42,4 @@ publish:
provider: github
owner: multica-ai
repo: multica
# Align with our CLI release flow which pre-creates a *published* GitHub
# Release via `gh release create`. The electron-builder default of
# `releaseType: 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.
releaseType: release
npmRebuild: false

View File

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

View File

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

View File

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

View File

@@ -1,11 +1,10 @@
import { app, BrowserWindow, ipcMain, nativeImage } from "electron";
import { app, shell, BrowserWindow, ipcMain, nativeImage } from "electron";
import { homedir } from "os";
import { join } from "path";
import { electronApp, optimizer, is } from "@electron-toolkit/utils";
import fixPath from "fix-path";
import { setupAutoUpdater } from "./updater";
import { setupDaemonManager } from "./daemon-manager";
import { openExternalSafely } from "./external-url";
// Bundled icon used for dev-mode dock/taskbar branding. In production the
// app bundle icon (from electron-builder) wins; this path is only consumed
@@ -49,19 +48,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
@@ -105,7 +91,7 @@ function createWindow(): void {
});
mainWindow.webContents.setWindowOpenHandler((details) => {
openExternalSafely(details.url);
shell.openExternal(details.url);
return { action: "deny" };
});
@@ -184,13 +170,9 @@ if (!gotTheLock) {
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

View File

@@ -1,21 +1,9 @@
import { autoUpdater } from "electron-updater";
import { app, BrowserWindow, ipcMain } from "electron";
import { BrowserWindow, ipcMain } from "electron";
autoUpdater.autoDownload = false;
autoUpdater.autoInstallOnAppQuit = true;
const STARTUP_CHECK_DELAY_MS = 5_000;
const PERIODIC_CHECK_INTERVAL_MS = 60 * 60 * 1000; // 1 hour
export type ManualUpdateCheckResult =
| {
ok: true;
currentVersion: string;
latestVersion: string;
available: boolean;
}
| { ok: false; error: string };
export function setupAutoUpdater(getMainWindow: () => BrowserWindow | null): void {
autoUpdater.on("update-available", (info) => {
const win = getMainWindow();
@@ -49,42 +37,10 @@ export function setupAutoUpdater(getMainWindow: () => BrowserWindow | null): voi
autoUpdater.quitAndInstall(false, true);
});
ipcMain.handle("updater:check", async (): Promise<ManualUpdateCheckResult> => {
try {
const result = await autoUpdater.checkForUpdates();
const currentVersion = app.getVersion();
// Trust electron-updater's own decision rather than re-deriving it from
// a version-string compare. The two diverge for pre-release channels,
// staged rollouts, downgrades, and minimum-system-version gates — in
// those cases updateInfo.version differs from app.getVersion() but no
// `update-available` event fires, so showing "available" here would
// promise a download prompt that never appears.
return {
ok: true,
currentVersion,
latestVersion: result?.updateInfo.version ?? currentVersion,
available: result?.isUpdateAvailable ?? false,
};
} catch (err) {
return {
ok: false,
error: err instanceof Error ? err.message : String(err),
};
}
});
// Initial check shortly after startup so we don't block boot.
// Check for updates after a short delay to avoid blocking startup
setTimeout(() => {
autoUpdater.checkForUpdates().catch((err) => {
console.error("Failed to check for updates:", err);
});
}, STARTUP_CHECK_DELAY_MS);
// Background poll so long-running sessions still pick up new releases
// without requiring the user to restart the app.
setInterval(() => {
autoUpdater.checkForUpdates().catch((err) => {
console.error("Periodic update check failed:", err);
});
}, PERIODIC_CHECK_INTERVAL_MS);
}, 5000);
}

View File

@@ -3,8 +3,6 @@ import { ElectronAPI } from "@electron-toolkit/preload";
interface DesktopAPI {
/** Listen for auth token delivered via deep link. Returns an unsubscribe function. */
onAuthToken: (callback: (token: string) => void) => () => void;
/** Listen for invitation IDs delivered via deep link. Returns an unsubscribe function. */
onInviteOpen: (callback: (invitationId: string) => void) => () => void;
/** Open a URL in the default browser. */
openExternal: (url: string) => Promise<void>;
/** Hide macOS traffic lights for full-screen modals; restore when false. */
@@ -53,10 +51,6 @@ interface UpdaterAPI {
onUpdateDownloaded: (callback: () => void) => () => void;
downloadUpdate: () => Promise<void>;
installUpdate: () => Promise<void>;
checkForUpdates: () => Promise<
| { ok: true; currentVersion: string; latestVersion: string; available: boolean }
| { ok: false; error: string }
>;
}
declare global {

View File

@@ -11,15 +11,6 @@ const desktopAPI = {
ipcRenderer.removeListener("auth:token", handler);
};
},
/** Listen for invitation IDs delivered via deep link */
onInviteOpen: (callback: (invitationId: string) => void) => {
const handler = (_event: Electron.IpcRendererEvent, invitationId: string) =>
callback(invitationId);
ipcRenderer.on("invite:open", handler);
return () => {
ipcRenderer.removeListener("invite:open", handler);
};
},
/** Open a URL in the default browser */
openExternal: (url: string) => ipcRenderer.invoke("shell:openExternal", url),
/** Toggle immersive mode — hide macOS traffic lights for full-screen modals */
@@ -96,10 +87,6 @@ const updaterAPI = {
},
downloadUpdate: () => ipcRenderer.invoke("updater:download"),
installUpdate: () => ipcRenderer.invoke("updater:install"),
checkForUpdates: (): Promise<
| { ok: true; currentVersion: string; latestVersion: string; available: boolean }
| { ok: false; error: string }
> => ipcRenderer.invoke("updater:check"),
};
if (process.contextIsolated) {

View File

@@ -1,4 +1,4 @@
import { useEffect, useLayoutEffect, useRef, useState } from "react";
import { useEffect, useRef, useState } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { CoreProvider } from "@multica/core/platform";
import { useAuthStore } from "@multica/core/auth";
@@ -11,8 +11,6 @@ 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);
@@ -33,17 +31,6 @@ function AppContent() {
window.daemonAPI.setTargetApiUrl(DAEMON_TARGET_API_URL);
}, []);
// Listen for invite IDs delivered via deep link (multica://invite/<id>).
// We open the overlay regardless of login state — if the user isn't logged
// in, InvitePage's queries will fail and render the "not found" state,
// which is acceptable; the expected pre-flight happens in the web app
// (login + next=/invite/... dance) before the deep link is ever dispatched.
useEffect(() => {
return window.desktopAPI.onInviteOpen((invitationId) => {
useWindowOverlayStore.getState().open({ type: "invite", invitationId });
});
}, []);
// Listen for auth token delivered via deep link (multica://auth/callback?token=...).
// daemonAPI.syncToken is handled separately by the [user] effect below, which
// fires whenever a user logs in (deep link, session restore, account switch).
@@ -96,40 +83,22 @@ function AppContent() {
});
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;
// Validate persisted tab paths against the current user's workspace list.
// Tabs survive across app restarts and account switches (persisted to
// localStorage `multica_tabs`), so a tab path like `/naiyuan/issues` may
// reference a workspace the current user can't access — showing
// NoAccessPage every time they open the app.
//
// Run synchronously in render phase rather than in useEffect so the first
// render already sees validated tabs. useEffect runs AFTER commit, which
// means the initial render would briefly show NoAccessPage before the
// effect resets the tab. Zustand supports render-phase setState; the
// validator is idempotent (exits early if nothing changed) so this
// doesn't loop.
if (workspaces) {
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]);
useTabStore.getState().validateWorkspaceSlugs(validSlugs);
}
// 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
@@ -166,14 +135,9 @@ function AppContent() {
const DAEMON_TARGET_API_URL =
import.meta.env.VITE_API_URL || "http://localhost:8080";
// On logout, wipe desktop-only in-memory state and stop the daemon so that
// a subsequent login as a different user never inherits the previous user's
// tabs, overlay, or credentials. Zustand persist only writes to localStorage;
// useLogout clears the storage key, but the live stores stay populated until
// we explicitly reset them here.
// On logout, clear any cached PAT and stop the daemon so that a subsequent
// login as a different user never inherits the previous user's credentials.
async function handleDaemonLogout() {
useTabStore.getState().reset();
useWindowOverlayStore.getState().close();
try {
await window.daemonAPI.clearToken();
} catch {

View File

@@ -18,7 +18,6 @@ import { getCurrentSlug, subscribeToCurrentSlug } from "@multica/core/platform";
import { DesktopNavigationProvider } from "@/platform/navigation";
import { TabBar } from "./tab-bar";
import { TabContent } from "./tab-content";
import { WindowOverlay } from "./window-overlay";
function SidebarTopBar() {
const { canGoBack, canGoForward, goBack, goForward } = useTabHistory();
@@ -114,8 +113,7 @@ export function DesktopShell() {
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. */}
users are routed to /workspaces/new by IndexRedirect. */}
<WorkspaceSlugProvider slug={slug}>
<div className="flex h-screen">
<SidebarProvider className="flex-1">
@@ -134,7 +132,6 @@ export function DesktopShell() {
</div>
{slug && <ModalRegistry />}
{slug && <SearchCommand />}
<WindowOverlay />
</WorkspaceSlugProvider>
</DesktopNavigationProvider>
);

View File

@@ -29,8 +29,8 @@ 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";
import { isGlobalPath, paths } from "@multica/core/paths";
const TAB_ICONS: Record<string, LucideIcon> = {
Inbox,
@@ -67,13 +67,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 +125,22 @@ 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();
// Inherit the active tab's workspace. Terminal/IDE convention: new tab
// opens in the same context as the active one. Read the slug from the
// active tab's path directly rather than from getCurrentSlug(), because
// that singleton is "last tab to render" (non-deterministic with N tabs
// mounted under <Activity>), while activeTabId is the unambiguous truth.
// Falls back to "/" (→ IndexRedirect → first workspace) when the active
// tab is on a global route (e.g. /workspaces/new, /login).
const { tabs, activeTabId } = useTabStore.getState();
const activePath = tabs.find((t) => t.id === activeTabId)?.path ?? "/";
let slug: string | null = null;
if (activePath !== "/" && !isGlobalPath(activePath)) {
slug = activePath.split("/").filter(Boolean)[0] ?? null;
}
const path = slug ? paths.workspace(slug).issues() : "/";
const tabId = addTab(path, "Issues", resolveRouteIcon(path));
if (tabId) setActiveTab(tabId);
setActiveTab(tabId);
};
return (
@@ -143,17 +155,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 +195,7 @@ export function TabBar() {
))}
</SortableContext>
</DndContext>
{group && <NewTabButton />}
<NewTabButton />
</div>
);
}

View File

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

View File

@@ -1,86 +0,0 @@
import { useCallback, useState } from "react";
import { AlertCircle, ArrowDownToLine, Check, Loader2 } from "lucide-react";
import { Button } from "@multica/ui/components/ui/button";
type CheckState =
| { status: "idle" }
| { status: "checking" }
| { status: "up-to-date"; currentVersion: string }
| { status: "available"; latestVersion: string }
| { status: "error"; message: string };
export function UpdatesSettingsTab() {
const [state, setState] = useState<CheckState>({ status: "idle" });
const handleCheck = useCallback(async () => {
setState({ status: "checking" });
const result = await window.updater.checkForUpdates();
if (!result.ok) {
setState({ status: "error", message: result.error });
return;
}
setState(
result.available
? { status: "available", latestVersion: result.latestVersion }
: { status: "up-to-date", currentVersion: result.currentVersion },
);
}, []);
return (
<div>
<h2 className="text-lg font-semibold">Updates</h2>
<p className="text-sm text-muted-foreground mt-1">
The desktop app checks for new versions automatically once an hour and
shortly after launch.
</p>
<div className="mt-6 divide-y">
<div className="flex items-start justify-between gap-6 py-4">
<div className="min-w-0">
<p className="text-sm font-medium">Check for updates</p>
<p className="text-sm text-muted-foreground mt-0.5">
Trigger a check now instead of waiting for the next automatic
poll. Available updates appear as a notification in the corner.
</p>
{state.status === "up-to-date" && (
<p className="text-sm text-muted-foreground mt-2 inline-flex items-center gap-1.5">
<Check className="size-3.5 text-success" />
You&apos;re on the latest version (v{state.currentVersion}).
</p>
)}
{state.status === "available" && (
<p className="text-sm text-muted-foreground mt-2 inline-flex items-center gap-1.5">
<ArrowDownToLine className="size-3.5 text-primary" />
v{state.latestVersion} is available see the download prompt
in the corner.
</p>
)}
{state.status === "error" && (
<p className="text-sm text-destructive mt-2 inline-flex items-center gap-1.5">
<AlertCircle className="size-3.5" />
{state.message}
</p>
)}
</div>
<div className="shrink-0">
<Button
variant="outline"
size="sm"
onClick={handleCheck}
disabled={state.status === "checking"}
>
{state.status === "checking" ? (
<>
<Loader2 className="size-3.5 animate-spin" />
Checking
</>
) : (
"Check now"
)}
</Button>
</div>
</div>
</div>
</div>
);
}

View File

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

View File

@@ -2,14 +2,11 @@ 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 { 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 { useWorkspaceSeen } from "@multica/views/workspace/use-workspace-seen";
import { useTabStore } from "@/stores/tab-store";
/**
* Desktop equivalent of apps/web/app/[workspaceSlug]/layout.tsx.
@@ -20,13 +17,9 @@ import { useTabStore } from "@/stores/tab-store";
* 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).
* If the slug doesn't resolve to any workspace the user has access to,
* we render NoAccessPage instead of silently redirecting — users get
* explicit feedback for stale bookmarks or revoked access.
*/
export function WorkspaceRouteLayout() {
const { workspaceSlug } = useParams<{ workspaceSlug: string }>();
@@ -34,7 +27,10 @@ export function WorkspaceRouteLayout() {
const user = useAuthStore((s) => s.user);
const isAuthLoading = useAuthStore((s) => s.isLoading);
// Workspace routes require auth. If user is unauthenticated, bounce to /login.
// Workspace routes require auth. If user is unauthenticated (token
// expired, logged out from another tab, etc.), bounce to /login.
// Without this, the layout renders null and the user sees a blank page
// stuck on /{slug}/...
useEffect(() => {
if (!isAuthLoading && !user) navigate(paths.login(), { replace: true });
}, [isAuthLoading, user, navigate]);
@@ -44,41 +40,36 @@ export function WorkspaceRouteLayout() {
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.
// setCurrentWorkspace self-dedupes on slug equality — safe to call on
// every render (matters on desktop, where N tabs each mount their own
// layout). Rehydrate is the singleton's internal side effect.
if (workspace && workspaceSlug) {
setCurrentWorkspace(workspaceSlug, workspace.id);
}
// Remember whether this slug has resolved before (see hook docs). Gates
// the NoAccessPage render below so active workspace removal doesn't
// flash "Workspace not available" before the navigate lands.
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;
// Don't render children until workspace is resolved. useWorkspaceId()
// throws when the workspace list hasn't populated or the slug is
// unknown — gating here is the single point where that invariant is
// enforced, so every descendant can call useWorkspaceId() safely.
if (!listFetched) return null;
if (!workspace) return null; // auto-heal effect above handles the cleanup
if (!workspace) {
// Active workspace just removed (delete/leave/realtime eviction) —
// navigate is in flight; hold null briefly instead of flashing
// NoAccessPage.
if (hasBeenSeen) return null;
// Genuinely inaccessible slug (stale bookmark, revoked access, or a
// link from a former teammate's workspace) → explicit feedback.
return <NoAccessPage />;
}
return (
<WorkspaceSlugProvider slug={workspaceSlug}>

View File

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

View File

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

View File

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

View File

@@ -6,6 +6,7 @@ import {
useMatches,
} from "react-router-dom";
import type { RouteObject } from "react-router-dom";
import { useQuery } from "@tanstack/react-query";
import { IssueDetailPage } from "./pages/issue-detail-page";
import { ProjectDetailPage } from "./pages/project-detail-page";
import { AutopilotDetailPage } from "./pages/autopilot-detail-page";
@@ -19,9 +20,13 @@ 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 { Download, Server } from "lucide-react";
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 { Server } from "lucide-react";
import { DaemonSettingsTab } from "./components/daemon-settings-tab";
import { UpdatesSettingsTab } from "./components/updates-settings-tab";
import { WorkspaceRouteLayout } from "./components/workspace-route-layout";
/**
@@ -54,28 +59,77 @@ function PageShell() {
);
}
function NewWorkspaceRoute() {
const nav = useNavigation();
return (
<NewWorkspacePage
onSuccess={(ws) => nav.push(paths.workspace(ws.slug).issues())}
/>
);
}
/**
* Root index route: resolves the URL-less `/` path to a concrete destination.
*
* Runs both on first login (App.tsx seeded the cache) and on app reopen
* (AuthInitializer seeded the cache). Reading from React Query avoids
* duplicate fetches across tabs — each tab's memory router hits this
* component independently but the query is deduped.
*
* Sends first-time users without any workspace to /workspaces/new,
* everyone else to their first workspace's issues page. Persisted tab
* paths that already carry a workspace slug bypass this component
* entirely.
*/
function IndexRedirect() {
const { data: wsList, isFetched } = useQuery(workspaceListOptions());
// Wait for the query to settle so we don't redirect to /workspaces/new
// on the initial render before the seeded/fetched data arrives.
if (!isFetched) return null;
const firstWorkspace = wsList?.[0];
if (firstWorkspace) {
return <Navigate to={paths.workspace(firstWorkspace.slug).issues()} replace />;
}
return <Navigate to={paths.newWorkspace()} replace />;
}
function InviteRoute() {
const matches = useMatches();
const match = matches.find((m) => (m.params as { id?: string }).id);
const id = (match?.params as { id?: string })?.id ?? "";
return <InvitePage invitationId={id} />;
}
/**
* Route definitions shared by all tabs.
*
* Every tab path is workspace-scoped: `/{slug}/{route}/...`. Pre-workspace
* flows (create workspace, accept invite) are NOT routes — they render as a
* window-level overlay via `WindowOverlay`, dispatched by the navigation
* adapter's transition-path interception. The `activeWorkspaceSlug` in the
* tab store decides which workspace's tabs are visible in the TabBar;
* workspace-less state (zero-workspace user) shows the overlay instead.
*
* The root index route stays as a harmless safety net. With per-workspace
* tabs, nothing should construct a tab at `/` — but if one ever slips
* through (malformed persisted state that dodges the migration, direct
* router.navigate from unforeseen code), the index falls back to null
* rather than 404; App.tsx's bootstrap repoints activeWorkspaceSlug on the
* next render pass.
* Structure mirrors the web app's [workspaceSlug]/... layout: all dashboard
* pages live under /:workspaceSlug, with WorkspaceRouteLayout resolving the
* slug to a workspace and syncing side-effects (api client, persist namespace,
* Zustand mirror). Global (pre-workspace) routes — workspaces/new and invite —
* sit at the top level alongside the workspace wrapper.
*/
export const appRoutes: RouteObject[] = [
{
element: <PageShell />,
children: [
{ index: true, element: null },
// Top-level index: no slug yet. `IndexRedirect` reads the workspace
// list from React Query cache (seeded by AuthInitializer on reopen
// or App.tsx on deep-link login) and bounces to the first
// workspace's issues page — or /workspaces/new if the user has none.
{ index: true, element: <IndexRedirect /> },
{
path: "workspaces/new",
element: <NewWorkspaceRoute />,
handle: { title: "Create Workspace" },
},
{
path: "invite/:id",
element: <InviteRoute />,
handle: { title: "Accept Invite" },
},
{
path: ":workspaceSlug",
element: <WorkspaceRouteLayout />,
@@ -131,12 +185,6 @@ export const appRoutes: RouteObject[] = [
icon: Server,
content: <DaemonSettingsTab />,
},
{
value: "updates",
label: "Updates",
icon: Download,
content: <UpdatesSettingsTab />,
},
]}
/>
),

View File

@@ -1,42 +1,23 @@
import { describe, expect, it, vi, beforeEach } from "vitest";
import { describe, expect, it, vi } 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(() => () => {}),
})),
);
// router context. For pure-function tests we stub it out.
vi.mock("../routes", () => ({
createTabRouter: createTabRouterMock,
createTabRouter: vi.fn(() => ({ dispose: vi.fn() })),
}));
import {
sanitizeTabPath,
migrateV1ToV2,
useTabStore,
} from "./tab-store";
beforeEach(() => {
createTabRouterMock.mockClear();
useTabStore.getState().reset();
});
import { sanitizeTabPath } from "./tab-store";
describe("sanitizeTabPath", () => {
it("rejects the root sentinel — tabs must be workspace-scoped", () => {
expect(sanitizeTabPath("/")).toBeNull();
expect(sanitizeTabPath("")).toBeNull();
it("passes through root sentinel", () => {
expect(sanitizeTabPath("/")).toBe("/");
});
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 global paths", () => {
expect(sanitizeTabPath("/login")).toBe("/login");
expect(sanitizeTabPath("/workspaces/new")).toBe("/workspaces/new");
expect(sanitizeTabPath("/invite/abc")).toBe("/invite/abc");
expect(sanitizeTabPath("/auth/callback")).toBe("/auth/callback");
});
it("passes through valid workspace-scoped paths", () => {
@@ -44,181 +25,21 @@ describe("sanitizeTabPath", () => {
expect(sanitizeTabPath("/my-team/projects/abc")).toBe("/my-team/projects/abc");
});
it("rejects paths whose first segment is a reserved slug (missing workspace prefix)", () => {
it("rejects paths whose first segment is a reserved slug", () => {
// A stray "/issues" (pre-refactor leftover, missing workspace prefix)
// would be interpreted as workspaceSlug="issues" → NoAccessPage.
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
expect(sanitizeTabPath("/issues")).toBeNull();
expect(sanitizeTabPath("/settings")).toBeNull();
expect(sanitizeTabPath("/issues")).toBe("/");
expect(sanitizeTabPath("/issues/abc-123")).toBe("/");
expect(sanitizeTabPath("/settings")).toBe("/");
expect(warn).toHaveBeenCalled();
warn.mockRestore();
});
it("passes through user slugs that happen to look path-like but aren't reserved", () => {
// A workspace owner could legitimately pick "acme-issues" or
// "project-x" as their slug — sanitize must not touch these.
expect(sanitizeTabPath("/acme-issues/issues")).toBe("/acme-issues/issues");
expect(sanitizeTabPath("/project-x/inbox")).toBe("/project-x/inbox");
});
});
describe("migrateV1ToV2", () => {
it("groups v1 flat tabs by workspace slug", () => {
const v1 = {
tabs: [
{ id: "t1", path: "/acme/issues", title: "Issues", icon: "ListTodo" },
{ id: "t2", path: "/acme/projects", title: "Projects", icon: "FolderKanban" },
{ id: "t3", path: "/butter/issues", title: "Issues", icon: "ListTodo" },
],
activeTabId: "t2",
};
const v2 = migrateV1ToV2(v1);
expect(Object.keys(v2.byWorkspace).sort()).toEqual(["acme", "butter"]);
expect(v2.byWorkspace.acme.tabs).toHaveLength(2);
expect(v2.byWorkspace.butter.tabs).toHaveLength(1);
expect(v2.byWorkspace.acme.activeTabId).toBe("t2");
expect(v2.byWorkspace.butter.activeTabId).toBe("t3"); // first tab in group
expect(v2.activeWorkspaceSlug).toBe("acme"); // contained v1.activeTabId
});
it("drops tabs at root / transition / reserved-slug paths", () => {
const v1 = {
tabs: [
{ id: "t1", path: "/", title: "Issues", icon: "ListTodo" },
{ id: "t2", path: "/workspaces/new", title: "New", icon: "Plus" },
{ id: "t3", path: "/invite/abc", title: "Invite", icon: "Mail" },
{ id: "t4", path: "/acme/issues", title: "Issues", icon: "ListTodo" },
],
activeTabId: "t1",
};
const v2 = migrateV1ToV2(v1);
expect(Object.keys(v2.byWorkspace)).toEqual(["acme"]);
expect(v2.byWorkspace.acme.tabs).toHaveLength(1);
// v1.activeTabId was dropped; active falls back to first group's first tab.
expect(v2.activeWorkspaceSlug).toBe("acme");
expect(v2.byWorkspace.acme.activeTabId).toBe("t4");
});
it("handles empty v1 state gracefully", () => {
const v2 = migrateV1ToV2({ tabs: [], activeTabId: "" });
expect(v2.byWorkspace).toEqual({});
expect(v2.activeWorkspaceSlug).toBeNull();
});
it("handles v1 with no tabs field (corrupted state)", () => {
const v2 = migrateV1ToV2({});
expect(v2.byWorkspace).toEqual({});
expect(v2.activeWorkspaceSlug).toBeNull();
});
});
describe("useTabStore actions", () => {
it("switchWorkspace creates a new group with a default tab on first entry", () => {
useTabStore.getState().switchWorkspace("acme");
const s = useTabStore.getState();
expect(s.activeWorkspaceSlug).toBe("acme");
expect(s.byWorkspace.acme.tabs).toHaveLength(1);
expect(s.byWorkspace.acme.tabs[0].path).toBe("/acme/issues");
});
it("switchWorkspace without openPath restores the group's last active tab", () => {
const store = useTabStore.getState();
store.switchWorkspace("acme");
store.addTab("/acme/projects", "Projects", "FolderKanban");
const acmeProjectsId = useTabStore.getState().byWorkspace.acme.tabs[1].id;
store.setActiveTab(acmeProjectsId);
// Enter a different workspace then come back
store.switchWorkspace("butter");
expect(useTabStore.getState().activeWorkspaceSlug).toBe("butter");
store.switchWorkspace("acme");
const s = useTabStore.getState();
expect(s.activeWorkspaceSlug).toBe("acme");
expect(s.byWorkspace.acme.activeTabId).toBe(acmeProjectsId);
});
it("switchWorkspace with openPath dedupes into an existing tab with same path", () => {
const store = useTabStore.getState();
store.switchWorkspace("acme"); // creates default /acme/issues
store.addTab("/acme/projects", "Projects", "FolderKanban");
store.switchWorkspace("acme", "/acme/issues");
const s = useTabStore.getState();
expect(s.byWorkspace.acme.tabs).toHaveLength(2); // no duplicate created
const activeTab = s.byWorkspace.acme.tabs.find(
(t) => t.id === s.byWorkspace.acme.activeTabId,
);
expect(activeTab?.path).toBe("/acme/issues");
});
it("switchWorkspace with openPath not matching any tab adds a new tab", () => {
const store = useTabStore.getState();
store.switchWorkspace("acme");
store.switchWorkspace("acme", "/acme/issues/bug-42");
const s = useTabStore.getState();
expect(s.byWorkspace.acme.tabs).toHaveLength(2);
const activeTab = s.byWorkspace.acme.tabs.find(
(t) => t.id === s.byWorkspace.acme.activeTabId,
);
expect(activeTab?.path).toBe("/acme/issues/bug-42");
});
it("openTab dedupes by path within the active workspace", () => {
const store = useTabStore.getState();
store.switchWorkspace("acme");
const id1 = store.openTab("/acme/projects", "Projects", "FolderKanban");
const id2 = store.openTab("/acme/projects", "Projects", "FolderKanban");
expect(id1).toBe(id2);
expect(useTabStore.getState().byWorkspace.acme.tabs).toHaveLength(2); // default + projects
});
it("closeTab on the last tab in a workspace reseeds the default tab", () => {
const store = useTabStore.getState();
store.switchWorkspace("acme");
const onlyTabId = useTabStore.getState().byWorkspace.acme.tabs[0].id;
store.closeTab(onlyTabId);
const s = useTabStore.getState();
expect(s.byWorkspace.acme.tabs).toHaveLength(1);
expect(s.byWorkspace.acme.tabs[0].path).toBe("/acme/issues");
expect(s.byWorkspace.acme.tabs[0].id).not.toBe(onlyTabId); // fresh tab
});
it("validateWorkspaceSlugs drops groups for slugs not in the valid set and repoints active", () => {
const store = useTabStore.getState();
store.switchWorkspace("acme");
store.switchWorkspace("butter");
store.switchWorkspace("acme");
expect(useTabStore.getState().activeWorkspaceSlug).toBe("acme");
// Admin removed the user from acme
store.validateWorkspaceSlugs(new Set(["butter"]));
const s = useTabStore.getState();
expect(Object.keys(s.byWorkspace)).toEqual(["butter"]);
expect(s.activeWorkspaceSlug).toBe("butter");
});
it("validateWorkspaceSlugs sets activeWorkspaceSlug to null when all groups are dropped", () => {
const store = useTabStore.getState();
store.switchWorkspace("acme");
store.validateWorkspaceSlugs(new Set());
const s = useTabStore.getState();
expect(s.byWorkspace).toEqual({});
expect(s.activeWorkspaceSlug).toBeNull();
});
it("reset wipes the whole store", () => {
const store = useTabStore.getState();
store.switchWorkspace("acme");
store.switchWorkspace("butter");
store.reset();
const s = useTabStore.getState();
expect(s.activeWorkspaceSlug).toBeNull();
expect(s.byWorkspace).toEqual({});
});
it("setActiveTab across workspaces also flips the active workspace", () => {
const store = useTabStore.getState();
store.switchWorkspace("acme");
store.switchWorkspace("butter");
const acmeTabId = useTabStore.getState().byWorkspace.acme.tabs[0].id;
store.setActiveTab(acmeTabId);
expect(useTabStore.getState().activeWorkspaceSlug).toBe("acme");
});
});

View File

@@ -3,7 +3,7 @@ import { createJSONStorage, persist } from "zustand/middleware";
import { arrayMove } from "@dnd-kit/sortable";
import { createPersistStorage, defaultStorage } from "@multica/core/platform";
import { createSafeId } from "@multica/core/utils";
import { isReservedSlug } from "@multica/core/paths";
import { isGlobalPath, isReservedSlug } from "@multica/core/paths";
import type { DataRouter } from "react-router-dom";
import { createTabRouter } from "../routes";
@@ -13,7 +13,6 @@ import { createTabRouter } from "../routes";
export interface Tab {
id: string;
/** Every tab path is workspace-scoped: `/{workspaceSlug}/{route}/...`. */
path: string;
title: string;
icon: string;
@@ -22,77 +21,33 @@ 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.
* Reset any tab whose first path segment references a workspace slug the
* current user doesn't have access to. Called after login + workspace list
* is populated (and on every subsequent list change, e.g. realtime
* workspace:deleted). Stale tabs get reset to `/` so IndexRedirect picks
* a valid workspace; tabs on global paths (/login, /workspaces/new, etc.)
* are untouched.
*/
validateWorkspaceSlugs: (validSlugs: Set<string>) => void;
/**
* Wipe everything. Called from logout so the next user doesn't inherit
* the prior user's tabs. Zustand persist only writes to localStorage;
* clearing the storage key alone would leave this live store intact
* until app restart.
*/
reset: () => void;
}
// ---------------------------------------------------------------------------
@@ -112,594 +67,232 @@ const ROUTE_ICONS: Record<string, string> = {
};
/**
* Resolve a route icon from a pathname.
* Resolve a route icon from a pathname. Title is NOT determined here — it
* comes from document.title.
*
* Tab paths are always workspace-scoped: `/{slug}/{route}/...`, so the route
* segment lives at index 1. Pre-workspace flows (create, invite) are rendered
* by the window overlay, never as tabs.
* Path shape after the workspace URL refactor:
* - workspace-scoped: `/{workspaceSlug}/{route}/...` → use segment index 1
* - global (workspaces/new, invite, auth, login): `/{route}/...` → use segment index 0
*
* Title is NOT determined here — it comes from document.title.
* `isGlobalPath` is the single source of truth for which prefixes are global.
*/
export function resolveRouteIcon(pathname: string): string {
const segments = pathname.split("/").filter(Boolean);
return ROUTE_ICONS[segments[1] ?? ""] ?? "ListTodo";
}
/** Extract the leading workspace slug from a path, or null if the path
* isn't workspace-scoped (global path, root, or empty). */
function extractWorkspaceSlug(path: string): string | null {
const first = path.split("/").filter(Boolean)[0] ?? "";
if (!first) return null;
if (isReservedSlug(first)) return null;
return first;
}
// ---------------------------------------------------------------------------
// Path sanitization (defensive)
// ---------------------------------------------------------------------------
/**
* 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
// ---------------------------------------------------------------------------
function createId(): string {
return createSafeId();
}
function makeTab(path: string, title: string, icon: string): Tab {
return {
id: createId(),
path,
title,
icon,
router: createTabRouter(path),
historyIndex: 0,
historyLength: 1,
};
}
/** 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;
const routeSegment = isGlobalPath(pathname)
? (segments[0] ?? "")
: (segments[1] ?? "");
return ROUTE_ICONS[routeSegment] ?? "ListTodo";
}
// ---------------------------------------------------------------------------
// Store
// ---------------------------------------------------------------------------
/**
* Sentinel path for new tabs with no explicit destination. The tab store is
* workspace-implicit — it doesn't know which workspace is active, so it can't
* build a `/:slug/issues` path itself. Instead we hand off to the router: `/`
* matches the top-level index route, which redirects to the workspace default
* (slug-aware redirect lives in routes.tsx / App.tsx).
*
* `title` and `icon` on the placeholder tab get overwritten by
* useTabRouterSync + useActiveTitleSync once the redirect resolves.
*/
const DEFAULT_PATH = "/";
function createId(): string {
return createSafeId();
}
/**
* Defensive: catch tab paths that were constructed without a workspace slug
* (e.g. a hardcoded "/issues" leftover from before the URL refactor). Such
* paths would get matched as `workspaceSlug="issues"` by the router and
* render NoAccessPage. Sanitize by falling back to "/" (IndexRedirect picks
* a valid workspace).
*
* Passes through:
* - "/" and global paths (/login, /workspaces/new, /invite/..., /auth/...)
* - workspace-scoped paths whose first segment is not a reserved word
*
* Rejects (and rewrites to "/"):
* - Paths whose first segment is a reserved slug (=/=workspace slug), which
* means the caller forgot to prefix the workspace. Logs a warning so the
* buggy call site is easy to find.
*/
export function sanitizeTabPath(path: string): string {
if (path === DEFAULT_PATH || isGlobalPath(path)) return path;
const firstSegment = path.split("/").filter(Boolean)[0] ?? "";
if (isReservedSlug(firstSegment)) {
// eslint-disable-next-line no-console
console.warn(
`[tab-store] tab path "${path}" starts with reserved slug "${firstSegment}" — ` +
`caller likely forgot the workspace prefix. Falling back to "/".`,
);
return DEFAULT_PATH;
}
return path;
}
function makeTab(path: string, title: string, icon: string): Tab {
const safePath = sanitizeTabPath(path);
return {
id: createId(),
path: safePath,
title,
icon,
router: createTabRouter(safePath),
historyIndex: 0,
historyLength: 1,
};
}
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;
}
moveTab(fromIndex, toIndex) {
if (fromIndex === toIndex) return;
set((s) => ({ tabs: arrayMove(s.tabs, fromIndex, toIndex) }));
},
const nextTabs = group.tabs.filter((t) => t.id !== tabId);
const nextActiveTabId =
group.activeTabId === tabId
? nextTabs[Math.min(index, nextTabs.length - 1)].id
: group.activeTabId;
validateWorkspaceSlugs(validSlugs) {
const { tabs } = get();
let changed = false;
const nextTabs = tabs.map((t) => {
// Skip tabs on non-workspace-scoped paths — nothing to validate.
if (t.path === "/" || isGlobalPath(t.path)) return t;
set({
byWorkspace: {
...byWorkspace,
[slug]: { tabs: nextTabs, activeTabId: nextActiveTabId },
},
});
},
const firstSegment = t.path.split("/").filter(Boolean)[0] ?? "";
if (validSlugs.has(firstSegment)) return t;
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 },
},
});
},
// Stale slug: dispose the old router and replace with a fresh one
// pointing at `/`. IndexRedirect will send the tab to a valid
// workspace (or /workspaces/new if the user now has none).
changed = true;
t.router.dispose();
return {
...t,
path: DEFAULT_PATH,
title: "Issues",
icon: resolveRouteIcon(DEFAULT_PATH),
router: createTabRouter(DEFAULT_PATH),
historyIndex: 0,
historyLength: 1,
};
});
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: {} });
},
if (!changed) return;
set({ tabs: nextTabs });
},
}),
{
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) => {
// Sanitize persisted paths against reserved-slug rules. Catches
// both pre-refactor paths like "/issues/abc" (missing workspace
// slug) and any other malformed paths that slipped past the
// write-time guard. The defense across makeTab + merge + runtime
// validate ensures stale or malformed paths never reach the
// router.
const path = sanitizeTabPath(tab.path);
return {
...tab,
path,
router: createTabRouter(path),
historyIndex: 0,
historyLength: 1,
};
});
const activeWorkspaceSlug =
persisted.activeWorkspaceSlug && byWorkspace[persisted.activeWorkspaceSlug]
? persisted.activeWorkspaceSlug
: (Object.keys(byWorkspace)[0] ?? null);
// Validate activeTabId — fall back to first tab if stale
const activeTabId = tabs.some((t) => t.id === persisted.activeTabId)
? persisted.activeTabId
: tabs[0].id;
return { ...currentState, byWorkspace, activeWorkspaceSlug };
return { ...currentState, tabs, activeTabId };
},
},
),
);
// ---------------------------------------------------------------------------
// Persisted shapes (for migration)
// ---------------------------------------------------------------------------
interface V1Tab {
id: string;
path: string;
title: string;
icon: string;
}
interface V1Persisted {
tabs: V1Tab[];
activeTabId: string;
}
interface V2PersistedTab {
id: string;
path: string;
title: string;
icon: string;
}
interface V2PersistedGroup {
tabs: V2PersistedTab[];
activeTabId: string;
}
interface V2Persisted {
activeWorkspaceSlug: string | null;
byWorkspace: Record<string, V2PersistedGroup>;
}
export function migrateV1ToV2(v1: Partial<V1Persisted>): V2Persisted {
const byWorkspace: Record<string, V2PersistedGroup> = {};
const oldTabs = v1.tabs ?? [];
for (const tab of oldTabs) {
const slug = extractWorkspaceSlug(tab.path);
if (!slug) continue; // drop root / global-path tabs
if (!byWorkspace[slug]) byWorkspace[slug] = { tabs: [], activeTabId: "" };
byWorkspace[slug].tabs.push({
id: tab.id,
path: tab.path,
title: tab.title,
icon: tab.icon,
});
}
// Each group needs a valid activeTabId. Prefer the one from v1 if it
// landed in this group; otherwise fall back to the first tab.
for (const slug of Object.keys(byWorkspace)) {
const group = byWorkspace[slug];
const hasOldActive = group.tabs.some((t) => t.id === v1.activeTabId);
group.activeTabId = hasOldActive
? (v1.activeTabId as string)
: group.tabs[0].id;
}
// Active workspace: whichever group inherited the v1 activeTab, falling
// back to the first group we created (arbitrary but deterministic given
// Object.keys iteration order on string keys).
let activeWorkspaceSlug: string | null = null;
for (const slug of Object.keys(byWorkspace)) {
if (byWorkspace[slug].activeTabId === v1.activeTabId) {
activeWorkspaceSlug = slug;
break;
}
}
if (!activeWorkspaceSlug) {
activeWorkspaceSlug = Object.keys(byWorkspace)[0] ?? null;
}
return { activeWorkspaceSlug, byWorkspace };
}
// ---------------------------------------------------------------------------
// Selectors (convenience hooks)
// ---------------------------------------------------------------------------
/**
* Pure non-hook helper — useful from event handlers / effects that already
* need `.getState()`. For React subscriptions prefer the stable selectors
* below.
*/
export function getActiveTab(s: TabStore): Tab | null {
if (!s.activeWorkspaceSlug) return null;
const group = s.byWorkspace[s.activeWorkspaceSlug];
if (!group) return null;
return group.tabs.find((t) => t.id === group.activeTabId) ?? null;
}
/**
* The active workspace's tab group, or null when no workspace is active.
*
* Zustand compares selector returns with `Object.is`. Because `updateTab`
* / `updateTabHistory` replace the group object on every router tick
* (immutable update), this selector returns a new reference on every
* router event — that's fine for TabBar which needs to observe tab-list
* changes, but don't use this selector from components that only care
* about one primitive (use `useActiveTabHistory` / `useActiveTabRouter`
* instead).
*/
export function useActiveGroup(): WorkspaceTabGroup | null {
return useTabStore((s) =>
s.activeWorkspaceSlug ? (s.byWorkspace[s.activeWorkspaceSlug] ?? null) : null,
);
}
/**
* Active tab id + active workspace slug as a compact pair. Both primitives
* are stable across unrelated store updates — e.g. an inactive tab's
* router tick doesn't churn these, so consumers don't re-render.
*
* Useful anywhere you'd previously have reached for `useActiveTab()` and
* only needed the identity (for memoization, effect deps, ipc).
*/
export function useActiveTabIdentity(): { slug: string | null; tabId: string | null } {
const slug = useTabStore((s) => s.activeWorkspaceSlug);
const tabId = useTabStore((s) =>
s.activeWorkspaceSlug
? (s.byWorkspace[s.activeWorkspaceSlug]?.activeTabId ?? null)
: null,
);
return { slug, tabId };
}
/**
* Active tab's router — a stable reference across tab updates, because
* routers are created once per tab and never replaced by `updateTab`.
* Subscribers only re-render when the active tab *changes*, not on
* router events within the current tab.
*/
export function useActiveTabRouter(): DataRouter | null {
return useTabStore((s) => getActiveTab(s)?.router ?? null);
}
/**
* History tracking for the active tab as primitives. Subscribers re-render
* only when the numeric index / length change (i.e. on actual navigations),
* not on unrelated store updates.
*/
export function useActiveTabHistory(): {
historyIndex: number;
historyLength: number;
} {
const historyIndex = useTabStore((s) => getActiveTab(s)?.historyIndex ?? 0);
const historyLength = useTabStore((s) => getActiveTab(s)?.historyLength ?? 1);
return { historyIndex, historyLength };
}

View File

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

View File

@@ -0,0 +1,7 @@
import type { ReactNode } from "react";
import { HomeLayout } from "fumadocs-ui/layouts/home";
import { baseOptions } from "@/app/layout.config";
export default function Layout({ children }: { children: ReactNode }) {
return <HomeLayout {...baseOptions}>{children}</HomeLayout>;
}

View File

@@ -0,0 +1,29 @@
import Link from "next/link";
export default function HomePage() {
return (
<main className="flex min-h-screen flex-col items-center justify-center gap-6 text-center px-4">
<h1 className="text-4xl font-bold tracking-tight sm:text-5xl">
Multica Documentation
</h1>
<p className="max-w-2xl text-lg text-fd-muted-foreground">
The open-source managed agents platform. Turn coding agents into real
teammates assign tasks, track progress, compound skills.
</p>
<div className="flex gap-4">
<Link
href="/docs"
className="inline-flex items-center rounded-md bg-fd-primary px-6 py-3 text-sm font-medium text-fd-primary-foreground transition-colors hover:bg-fd-primary/90"
>
Get Started
</Link>
<Link
href="https://github.com/multica-ai/multica"
className="inline-flex items-center rounded-md border border-fd-border px-6 py-3 text-sm font-medium transition-colors hover:bg-fd-accent"
>
GitHub
</Link>
</div>
</main>
);
}

View File

@@ -10,7 +10,7 @@ import defaultMdxComponents from "fumadocs-ui/mdx";
import type { Metadata } from "next";
export default async function Page(props: {
params: Promise<{ slug: string[] }>;
params: Promise<{ slug?: string[] }>;
}) {
const params = await props.params;
const page = source.getPage(params.slug);
@@ -29,12 +29,12 @@ export default async function Page(props: {
);
}
export function generateStaticParams() {
return source.generateParams().filter((p) => p.slug.length > 0);
export async function generateStaticParams() {
return source.generateParams();
}
export async function generateMetadata(props: {
params: Promise<{ slug: string[] }>;
params: Promise<{ slug?: string[] }>;
}): Promise<Metadata> {
const params = await props.params;
const page = source.getPage(params.slug);

View File

@@ -0,0 +1,12 @@
import { DocsLayout } from "fumadocs-ui/layouts/docs";
import type { ReactNode } from "react";
import { baseOptions } from "@/app/layout.config";
import { source } from "@/lib/source";
export default function Layout({ children }: { children: ReactNode }) {
return (
<DocsLayout tree={source.pageTree} {...baseOptions}>
{children}
</DocsLayout>
);
}

View File

@@ -1,4 +1,5 @@
import type { BaseLayoutProps } from "fumadocs-ui/layouts/shared";
import { BookOpen, Terminal, Rocket, Code } from "lucide-react";
export const baseOptions: BaseLayoutProps = {
nav: {
@@ -7,6 +8,11 @@ export const baseOptions: BaseLayoutProps = {
),
},
links: [
{
text: "Documentation",
url: "/docs",
active: "nested-url",
},
{
text: "GitHub",
url: "https://github.com/multica-ai/multica",

View File

@@ -1,10 +1,7 @@
import "./global.css";
import { RootProvider } from "fumadocs-ui/provider";
import { DocsLayout } from "fumadocs-ui/layouts/docs";
import type { ReactNode } from "react";
import type { Metadata } from "next";
import { baseOptions } from "@/app/layout.config";
import { source } from "@/lib/source";
export const metadata: Metadata = {
title: {
@@ -19,11 +16,7 @@ export default function Layout({ children }: { children: ReactNode }) {
return (
<html lang="en" suppressHydrationWarning>
<body>
<RootProvider>
<DocsLayout tree={source.pageTree} {...baseOptions}>
{children}
</DocsLayout>
</RootProvider>
<RootProvider>{children}</RootProvider>
</body>
</html>
);

View File

@@ -1,18 +0,0 @@
import Link from "next/link";
export default function NotFound() {
return (
<main className="flex flex-1 flex-col items-center justify-center gap-4 px-4 py-24 text-center">
<h1 className="text-3xl font-semibold">Page not found</h1>
<p className="text-fd-muted-foreground">
The page you are looking for doesn&apos;t exist.
</p>
<Link
href="/"
className="inline-flex items-center rounded-md bg-fd-primary px-4 py-2 text-sm font-medium text-fd-primary-foreground transition-colors hover:bg-fd-primary/90"
>
Back to docs
</Link>
</main>
);
}

View File

@@ -1,37 +0,0 @@
import { source } from "@/lib/source";
import {
DocsPage,
DocsBody,
DocsDescription,
DocsTitle,
} from "fumadocs-ui/page";
import { notFound } from "next/navigation";
import defaultMdxComponents from "fumadocs-ui/mdx";
import type { Metadata } from "next";
export default function Page() {
const page = source.getPage([]);
if (!page) notFound();
const MDX = page.data.body;
return (
<DocsPage toc={page.data.toc}>
<DocsTitle>{page.data.title}</DocsTitle>
<DocsDescription>{page.data.description}</DocsDescription>
<DocsBody>
<MDX components={{ ...defaultMdxComponents }} />
</DocsBody>
</DocsPage>
);
}
export function generateMetadata(): Metadata {
const page = source.getPage([]);
if (!page) notFound();
return {
title: page.data.title,
description: page.data.description,
};
}

View File

@@ -68,7 +68,7 @@ multica setup
This configures the CLI for Multica Cloud, opens your browser for login, discovers your workspaces, and starts the agent daemon.
For self-hosted servers, use `multica setup self-host` instead. See [Self-Hosting](/getting-started/self-hosting) for details.
For self-hosted servers, use `multica setup self-host` instead. See [Self-Hosting](/docs/getting-started/self-hosting) for details.
## Verify

View File

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

View File

@@ -31,7 +31,7 @@ curl -fsSL https://raw.githubusercontent.com/multica-ai/multica/main/scripts/ins
multica setup self-host
```
This clones the repo, starts all services, installs the CLI, and configures it for localhost. Then open http://localhost:3000 and pick a login method: configure `RESEND_API_KEY` in `.env` for email-based codes (recommended), or set `APP_ENV=development` in `.env` to enable the dev master code **`888888`**. See [Step 2 — Log In](#step-2--log-in) for details.
This clones the repo, starts all services, installs the CLI, and configures it for localhost. Then open http://localhost:3000 log in with any email + code **`888888`**.
<Callout>
If the self-host server is already running and you only need the CLI on a macOS/Linux machine, install it with Homebrew: `brew install multica-ai/tap/multica`.
@@ -64,14 +64,10 @@ If you prefer running the Docker Compose steps manually: `cp .env.example .env`,
### Step 2 — Log In
Open http://localhost:3000. The Docker self-host stack defaults to `APP_ENV=production` (set in `docker-compose.selfhost.yml`), so the dev master code is **disabled by default** for safety on public deployments. Pick one of the following to log in:
- **Recommended (production):** configure `RESEND_API_KEY` in `.env`, then restart the backend. Real verification codes will be sent to the email address you enter. See [Configuration](#configuration) below.
- **Evaluation / private network:** set `APP_ENV=development` in `.env` and restart the backend. Verification code **`888888`** will then work for any email address.
- **Without configuring either:** the verification code is generated server-side and printed to the backend container logs (look for `[DEV] Verification code for ...:`). Useful for one-off testing on a single machine.
Open http://localhost:3000. Enter any email address and use verification code **`888888`** to log in.
<Callout>
**Warning:** do **not** set `APP_ENV=development` on a publicly reachable instance — anyone who knows an email address can then log in with `888888`.
This master code works in all non-production environments (when `APP_ENV` is not set to `production`). For production, configure an email provider — see [Configuration](#configuration) below.
</Callout>
### Step 3 — Install CLI & Start Daemon
@@ -202,14 +198,7 @@ For file uploads and attachments, configure S3 and CloudFront:
| `CLOUDFRONT_DOMAIN` | CloudFront distribution domain |
| `CLOUDFRONT_KEY_PAIR_ID` | CloudFront key pair ID for signed URLs |
| `CLOUDFRONT_PRIVATE_KEY` | CloudFront private key (PEM format) |
### Cookies
| Variable | Description |
|----------|-------------|
| `COOKIE_DOMAIN` | Optional `Domain` attribute for session + CloudFront cookies. **Leave empty** for single-host deployments (localhost, LAN IP, or a single hostname). Only set it when the frontend and backend sit on different subdomains of one registered domain (e.g. `.example.com`). **Do not use an IP literal** — RFC 6265 forbids IP addresses in the cookie `Domain` attribute and browsers will drop such `Set-Cookie` headers. |
The `Secure` flag on session cookies is derived automatically from the scheme of `FRONTEND_ORIGIN`: HTTPS origins get `Secure` cookies; plain-HTTP origins (LAN / private-network self-host) get non-secure cookies so the browser can actually store them.
| `COOKIE_DOMAIN` | Domain for CloudFront auth cookies |
### Server

View File

@@ -41,7 +41,7 @@ No more copy-pasting prompts. No more babysitting runs. Your agents show up on t
## Next Steps
- [Cloud Quickstart](/getting-started/cloud-quickstart)
- [Self-Hosting](/getting-started/self-hosting)
- [CLI Installation](/cli/installation)
- [Contributing](/developers/contributing)
- [Cloud Quickstart](/docs/getting-started/cloud-quickstart)
- [Self-Hosting](/docs/getting-started/self-hosting)
- [CLI Installation](/docs/cli/installation)
- [Contributing](/docs/developers/contributing)

View File

@@ -2,6 +2,6 @@ import { docs } from "@/.source";
import { loader } from "fumadocs-core/source";
export const source = loader({
baseUrl: "/",
baseUrl: "/docs",
source: docs.toFumadocsSource(),
});

View File

@@ -5,7 +5,6 @@ const withMDX = createMDX();
/** @type {import('next').NextConfig} */
const config = {
reactStrictMode: true,
basePath: "/docs",
};
export default withMDX(config);

View File

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

View File

@@ -11,51 +11,32 @@ function createWrapper() {
);
}
const {
mockSendCode,
mockVerifyCode,
mockIssueCliToken,
searchParamsState,
authStateRef,
} = vi.hoisted(() => ({
const { mockSendCode, mockVerifyCode } = vi.hoisted(() => ({
mockSendCode: vi.fn(),
mockVerifyCode: vi.fn(),
mockIssueCliToken: vi.fn(),
searchParamsState: { params: new URLSearchParams() },
authStateRef: {
state: {
sendCode: vi.fn(),
verifyCode: vi.fn(),
user: null as null | { id: string; email: string },
isLoading: false,
},
},
}));
// Mock next/navigation
vi.mock("next/navigation", () => ({
useRouter: () => ({ push: vi.fn(), replace: vi.fn() }),
usePathname: () => "/login",
useSearchParams: () => searchParamsState.params,
useSearchParams: () => new URLSearchParams(),
}));
// Mock auth store — shared LoginPage uses getState().sendCode/verifyCode,
// web wrapper uses useAuthStore((s) => s.user/isLoading). Keep the real
// sanitizeNextUrl so the redirect-sanitization rules are exercised rather
// than silently drifting behind a mock reimplementation.
vi.mock("@multica/core/auth", async () => {
const actual =
await vi.importActual<typeof import("@multica/core/auth")>(
"@multica/core/auth",
);
authStateRef.state.sendCode = mockSendCode;
authStateRef.state.verifyCode = mockVerifyCode;
// web wrapper uses useAuthStore((s) => s.user/isLoading)
vi.mock("@multica/core/auth", () => {
const authState = {
sendCode: mockSendCode,
verifyCode: mockVerifyCode,
user: null,
isLoading: false,
};
const useAuthStore = Object.assign(
(selector: (s: typeof authStateRef.state) => unknown) =>
selector(authStateRef.state),
{ getState: () => authStateRef.state },
(selector: (s: typeof authState) => unknown) => selector(authState),
{ getState: () => authState },
);
return { ...actual, useAuthStore };
return { useAuthStore };
});
// Mock auth-cookie
@@ -70,7 +51,6 @@ vi.mock("@multica/core/api", () => ({
verifyCode: vi.fn(),
setToken: vi.fn(),
getMe: vi.fn(),
issueCliToken: mockIssueCliToken,
},
}));
@@ -79,9 +59,6 @@ import LoginPage from "./page";
describe("LoginPage", () => {
beforeEach(() => {
vi.clearAllMocks();
searchParamsState.params = new URLSearchParams();
authStateRef.state.user = null;
authStateRef.state.isLoading = false;
});
it("renders login form with email input and continue button", () => {
@@ -154,44 +131,4 @@ describe("LoginPage", () => {
expect(screen.getByText("Network error")).toBeInTheDocument();
});
});
// Regression: MUL-1080 — if the user is already authenticated on the web
// and the Desktop app redirects them to /login?platform=desktop, the web
// must exchange the cookie session for a bearer token and hand it off via
// the multica:// deep link, not silently redirect to the workspace page.
it("mints a token and deep-links to Desktop when already logged in with platform=desktop", async () => {
searchParamsState.params = new URLSearchParams({ platform: "desktop" });
authStateRef.state.user = { id: "u1", email: "test@multica.ai" };
mockIssueCliToken.mockImplementation(() =>
Promise.resolve({ token: "handoff-jwt" }),
);
const hrefSetter = vi.fn();
const originalLocation = window.location;
Object.defineProperty(window, "location", {
configurable: true,
value: { ...originalLocation, set href(value: string) { hrefSetter(value); } },
});
try {
render(<LoginPage />, { wrapper: createWrapper() });
await waitFor(() => {
expect(mockIssueCliToken).toHaveBeenCalledTimes(1);
});
await waitFor(() => {
expect(hrefSetter).toHaveBeenCalledWith(
"multica://auth/callback?token=handoff-jwt",
);
});
expect(
await screen.findByRole("button", { name: "Open Multica Desktop" }),
).toBeInTheDocument();
} finally {
Object.defineProperty(window, "location", {
configurable: true,
value: originalLocation,
});
}
});
});

View File

@@ -1,22 +1,12 @@
"use client";
import { Suspense, useEffect, useState } from "react";
import { Suspense, useEffect } from "react";
import { useSearchParams, useRouter } from "next/navigation";
import { useQueryClient } from "@tanstack/react-query";
import { sanitizeNextUrl, useAuthStore } from "@multica/core/auth";
import { useAuthStore } from "@multica/core/auth";
import { workspaceKeys } from "@multica/core/workspace/queries";
import { paths } from "@multica/core/paths";
import { api } from "@multica/core/api";
import type { Workspace } from "@multica/core/types";
import {
Card,
CardHeader,
CardTitle,
CardDescription,
CardContent,
} from "@multica/ui/components/ui/card";
import { Button } from "@multica/ui/components/ui/button";
import { Loader2 } from "lucide-react";
import { setLoggedInCookie } from "@/features/auth/auth-cookie";
import { LoginPage, validateCliCallback } from "@multica/views/auth";
@@ -32,39 +22,17 @@ function LoginPageContent() {
const cliCallbackRaw = searchParams.get("cli_callback");
const cliState = searchParams.get("cli_state") || "";
const platform = searchParams.get("platform");
const isDesktopHandoff = platform === "desktop" && !cliCallbackRaw;
// `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. Sanitize first so a crafted `?next=https://evil`
// cannot bounce the user off-origin after a successful login.
const nextUrl = sanitizeNextUrl(searchParams.get("next"));
const [desktopToken, setDesktopToken] = useState<string | null>(null);
const [desktopError, setDesktopError] = useState("");
// the user's workspace list.
const nextUrl = searchParams.get("next");
// Already authenticated — honor ?next= or fall back to first workspace
// (or /workspaces/new if the user has none). Skip this entire path when
// the user arrived to authorize the CLI.
useEffect(() => {
if (isLoading || !user || cliCallbackRaw) return;
if (isDesktopHandoff) {
// Desktop opened the browser for login but the web session is already
// authenticated — mint a bearer token from the cookie session and hand
// it off via deep link instead of silently redirecting to the workspace.
api
.issueCliToken()
.then(({ token }) => {
setDesktopToken(token);
window.location.href = `multica://auth/callback?token=${encodeURIComponent(token)}`;
})
.catch((err) => {
setDesktopError(
err instanceof Error ? err.message : "Failed to prepare Desktop sign-in",
);
});
return;
}
if (nextUrl) {
router.replace(nextUrl);
return;
@@ -74,7 +42,7 @@ function LoginPageContent() {
router.replace(
first ? paths.workspace(first.slug).issues() : paths.newWorkspace(),
);
}, [isLoading, user, router, nextUrl, cliCallbackRaw, isDesktopHandoff, qc]);
}, [isLoading, user, router, nextUrl, cliCallbackRaw, qc]);
const handleSuccess = () => {
if (nextUrl) {
@@ -99,52 +67,6 @@ function LoginPageContent() {
.filter(Boolean)
.join(",") || undefined;
// While the desktop handoff is in progress (or has produced a token/error),
// render a dedicated screen instead of flashing the login form or redirecting
// away to a workspace page.
if (isDesktopHandoff && user) {
if (desktopError) {
return (
<div className="flex min-h-screen items-center justify-center">
<Card className="w-full max-w-sm">
<CardHeader className="text-center">
<CardTitle className="text-2xl">Sign-in Failed</CardTitle>
<CardDescription>{desktopError}</CardDescription>
</CardHeader>
</Card>
</div>
);
}
return (
<div className="flex min-h-screen items-center justify-center">
<Card className="w-full max-w-sm">
<CardHeader className="text-center">
<CardTitle className="text-2xl">Opening Multica</CardTitle>
<CardDescription>
{desktopToken
? "You should see a prompt to open the Multica desktop app. If nothing happens, click the button below."
: "Preparing Desktop sign-in..."}
</CardDescription>
</CardHeader>
<CardContent className="flex justify-center">
{desktopToken ? (
<Button
variant="outline"
onClick={() => {
window.location.href = `multica://auth/callback?token=${encodeURIComponent(desktopToken)}`;
}}
>
Open Multica Desktop
</Button>
) : (
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
)}
</CardContent>
</Card>
</div>
);
}
return (
<LoginPage
onSuccess={handleSuccess}

View File

@@ -2,20 +2,14 @@
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());
@@ -23,16 +17,9 @@ export default function Page() {
if (isLoading || !user) return null;
// Back goes to the root path — the workspace layout redirects from
// there to the user's default workspace. Only show Back when there's
// somewhere to go back to (user already has at least one workspace).
const onBack =
wsList.length > 0 ? () => router.push(paths.root()) : undefined;
return (
<NewWorkspacePage
onSuccess={(ws) => router.push(paths.workspace(ws.slug).issues())}
onBack={onBack}
/>
);
}

View File

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

View File

@@ -8,7 +8,6 @@ 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({
@@ -61,17 +60,11 @@ export default function WorkspaceLayout({
// 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;
if (isAuthLoading) return null;
// 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 (!listFetched) return null;
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

View File

@@ -1,86 +0,0 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, waitFor } from "@testing-library/react";
import { paths } from "@multica/core/paths";
const { mockPush, mockSearchParams, mockLoginWithGoogle, mockListWorkspaces } =
vi.hoisted(() => ({
mockPush: vi.fn(),
mockSearchParams: new URLSearchParams(),
mockLoginWithGoogle: vi.fn(),
mockListWorkspaces: vi.fn(),
}));
vi.mock("next/navigation", () => ({
useRouter: () => ({ push: mockPush }),
useSearchParams: () => mockSearchParams,
}));
vi.mock("@tanstack/react-query", () => ({
useQueryClient: () => ({ setQueryData: vi.fn() }),
}));
// Preserve the real sanitizeNextUrl so the "drop unsafe ?next=" behavior is
// exercised rather than silently diverging from the source of truth.
vi.mock("@multica/core/auth", async () => {
const actual =
await vi.importActual<typeof import("@multica/core/auth")>(
"@multica/core/auth",
);
return {
...actual,
useAuthStore: (selector: (s: unknown) => unknown) =>
selector({ loginWithGoogle: mockLoginWithGoogle }),
};
});
vi.mock("@multica/core/workspace/queries", () => ({
workspaceKeys: { list: () => ["workspaces"] },
}));
vi.mock("@multica/core/api", () => ({
api: {
listWorkspaces: mockListWorkspaces,
googleLogin: vi.fn(),
},
}));
import CallbackPage from "./page";
describe("CallbackPage", () => {
beforeEach(() => {
vi.clearAllMocks();
mockSearchParams.forEach((_v, k) => mockSearchParams.delete(k));
mockSearchParams.set("code", "test-code");
mockLoginWithGoogle.mockResolvedValue(undefined);
mockListWorkspaces.mockResolvedValue([]);
});
it("falls back to paths.newWorkspace() when no next= is present and the user has no workspace", async () => {
render(<CallbackPage />);
await waitFor(() => {
expect(mockPush).toHaveBeenCalledWith(paths.newWorkspace());
});
});
it("ignores unsafe next= targets from the OAuth state and still lands on the default destination", async () => {
mockSearchParams.set("state", "next:https://evil.example");
render(<CallbackPage />);
await waitFor(() => {
expect(mockPush).toHaveBeenCalledWith(paths.newWorkspace());
});
expect(mockPush).not.toHaveBeenCalledWith("https://evil.example");
});
it("honors a safe next= target (e.g. /invite/{id})", async () => {
mockSearchParams.set("state", "next:/invite/abc123");
render(<CallbackPage />);
await waitFor(() => {
expect(mockPush).toHaveBeenCalledWith("/invite/abc123");
});
});
});

View File

@@ -3,7 +3,7 @@
import { Suspense, useEffect, useState } from "react";
import { useSearchParams, useRouter } from "next/navigation";
import { useQueryClient } from "@tanstack/react-query";
import { sanitizeNextUrl, useAuthStore } from "@multica/core/auth";
import { useAuthStore } from "@multica/core/auth";
import { workspaceKeys } from "@multica/core/workspace/queries";
import { paths } from "@multica/core/paths";
import { api } from "@multica/core/api";
@@ -42,9 +42,7 @@ function CallbackContent() {
const stateParts = state.split(",");
const isDesktop = stateParts.includes("platform:desktop");
const nextPart = stateParts.find((p) => p.startsWith("next:"));
// Strip "next:" prefix, then drop anything that isn't a safe relative path
// so an attacker-controlled `state=next:https://evil` cannot redirect here.
const nextUrl = sanitizeNextUrl(nextPart ? nextPart.slice(5) : null);
const nextUrl = nextPart ? nextPart.slice(5) : null; // strip "next:" prefix
const redirectUri = `${window.location.origin}/auth/callback`;

View File

@@ -1,8 +1,6 @@
import { githubUrl } from "../components/shared";
import type { LandingDict } from "./types";
export const ALLOW_SIGNUP = process.env.NEXT_PUBLIC_ALLOW_SIGNUP !== "false";
export const en: LandingDict = {
header: {
github: "GitHub",
@@ -122,10 +120,9 @@ export const en: LandingDict = {
headlineFaded: "in the next hour.",
steps: [
{
title: ALLOW_SIGNUP ? "Sign up & create your workspace" : "Login to your workspace",
description: ALLOW_SIGNUP
? "Enter your email, verify with a code, and you\u2019re in. Your workspace is created automatically \u2014 no setup wizard, no configuration forms."
: "Enter your email, verify with a code, and you\u2019re logged into your workspace \u2014 no setup wizard, no configuration forms.",
title: "Sign up & create your workspace",
description:
"Enter your email, verify with a code, and you\u2019re in. Your workspace is created automatically \u2014 no setup wizard, no configuration forms.",
},
{
title: "Install the CLI & connect your machine",
@@ -282,48 +279,6 @@ export const en: LandingDict = {
fixes: "Bug Fixes",
},
entries: [
{
version: "0.2.7",
date: "2026-04-18",
title: "Sub-Issues from Editor, Self-Host Gating & MCP",
changes: [],
features: [
"Create sub-issue directly from selected text in the editor bubble menu",
"Self-hosted instance gating — `ALLOW_SIGNUP` and `ALLOWED_EMAIL_*` env vars to restrict account creation",
"Per-agent `mcp_config` field to restore MCP access",
"Desktop app hourly update poll with manual check button in settings",
],
fixes: [
"Session hand-off to desktop when already logged in on web",
"Open redirect vulnerability on `?next=` validated",
"OpenClaw stops passing unsupported flags and properly delivers AgentInstructions",
],
},
{
version: "0.2.5",
date: "2026-04-17",
title: "CLI Autopilot, Cmd+K & Daemon Identity",
changes: [],
features: [
"CLI `autopilot` commands for managing scheduled and triggered automations",
"CLI `issue subscriber` commands for subscription management",
"Cmd+K palette extended — theme toggle, quick new issue/project, copy link, switch workspace",
"Project and sub-issue progress as optional card properties on the issue list",
"Persistent daemon UUID identity — CLI and desktop share one daemon across restarts and machine moves",
"Sole-owner workspace leave preflight check",
"Persist comment collapse state across sessions",
],
fixes: [
"Agents now triggered on comments regardless of issue status",
"Codex sandbox config fixed for macOS network access",
"Editor bubble menu rewritten with @floating-ui/dom for reliable scroll hiding",
"Autopilot creator automatically subscribed to autopilot-created issues",
"Autopilot workspace ID correctly resolved for run-only tasks",
"Desktop restricts `shell.openExternal` to http/https schemes (security)",
"Duplicate agent names return 409 instead of silently failing",
"New tabs in desktop inherit current workspace",
],
},
{
version: "0.2.1",
date: "2026-04-16",

View File

@@ -1,8 +1,6 @@
import { githubUrl } from "../components/shared";
import type { LandingDict } from "./types";
export const ALLOW_SIGNUP = process.env.NEXT_PUBLIC_ALLOW_SIGNUP !== "false";
export const zh: LandingDict = {
header: {
github: "GitHub",
@@ -122,10 +120,9 @@ export const zh: LandingDict = {
headlineFaded: "\u53ea\u9700\u4e00\u5c0f\u65f6\u3002",
steps: [
{
title: ALLOW_SIGNUP ? "注册并创建您的工作空间" : "登录到您的工作空间",
description: ALLOW_SIGNUP
? "输入您的邮箱,验证代码后即可使用。工作空间会自动创建——无需设置向导或配置表单。"
: "输入您的邮箱,验证代码后即可登录到您的工作空间——无需设置向导或配置表单。",
title: "\u6ce8\u518c\u5e76\u521b\u5efa\u5de5\u4f5c\u533a",
description:
"\u8f93\u5165\u90ae\u7bb1\uff0c\u9a8c\u8bc1\u7801\u786e\u8ba4\uff0c\u5373\u53ef\u8fdb\u5165\u3002\u5de5\u4f5c\u533a\u81ea\u52a8\u521b\u5efa\u2014\u2014\u65e0\u9700\u8bbe\u7f6e\u5411\u5bfc\uff0c\u65e0\u9700\u914d\u7f6e\u8868\u5355\u3002",
},
{
title: "\u5b89\u88c5 CLI \u5e76\u8fde\u63a5\u4f60\u7684\u673a\u5668",
@@ -282,48 +279,6 @@ export const zh: LandingDict = {
fixes: "问题修复",
},
entries: [
{
version: "0.2.7",
date: "2026-04-18",
title: "编辑器创建子 Issue、自部署门禁与 MCP",
changes: [],
features: [
"直接从编辑器气泡菜单将选中文本创建为子 Issue",
"自部署实例账户门禁——`ALLOW_SIGNUP` 和 `ALLOWED_EMAIL_*` 环境变量限制注册",
"Agent 新增 `mcp_config` 字段恢复 MCP 支持",
"桌面应用每小时检查更新,设置中新增手动检查按钮",
],
fixes: [
"网页已登录时将会话交接给桌面应用",
"修复 `?next=` 开放重定向漏洞",
"OpenClaw 停止传递不支持的参数,正确传递 AgentInstructions",
],
},
{
version: "0.2.5",
date: "2026-04-17",
title: "CLI Autopilot、Cmd+K 与 Daemon 身份",
changes: [],
features: [
"CLI `autopilot` 命令,管理定时和触发式自动化",
"CLI `issue subscriber` 订阅管理命令",
"Cmd+K 命令面板扩展——主题切换、快速创建 Issue/项目、复制链接、切换工作区",
"Issue 列表卡片可选显示项目和子 Issue 进度",
"Daemon 持久化 UUID 身份——CLI 和桌面应用共用同一个 daemon跨重启和机器迁移保持一致",
"唯一所有者退出工作区的前置检查",
"评论折叠状态跨会话持久化",
],
fixes: [
"Agent 现在在任意 Issue 状态下都会响应评论触发",
"修复 Codex 沙箱在 macOS 上的网络访问配置",
"编辑器气泡菜单改用 @floating-ui/dom 重写,滚动时正确隐藏",
"Autopilot 创建者自动订阅其生成的 Issue",
"Autopilot run-only 任务正确解析工作区 ID",
"桌面应用 `shell.openExternal` 限制仅允许 http/https 协议(安全)",
"重名 Agent 创建返回 409 而非静默失败",
"桌面应用新建标签页继承当前工作区",
],
},
{
version: "0.2.1",
date: "2026-04-16",

View File

@@ -6,7 +6,6 @@ import { resolve } from "path";
config({ path: resolve(__dirname, "../../.env") });
const remoteApiUrl = process.env.REMOTE_API_URL || "http://localhost:8080";
const docsUrl = process.env.DOCS_URL || "http://localhost:4000";
// Parse hostnames from CORS_ALLOWED_ORIGINS so that Next.js dev server
// allows cross-origin HMR / webpack requests (e.g. from Tailscale IPs).
@@ -33,39 +32,24 @@ const nextConfig: NextConfig = {
qualities: [75, 80, 85],
},
async rewrites() {
return {
// Run before file-system routes so /docs isn't shadowed by the
// [workspaceSlug] dynamic segment.
beforeFiles: [
{
source: "/docs",
destination: `${docsUrl}/docs`,
},
{
source: "/docs/:path*",
destination: `${docsUrl}/docs/:path*`,
},
],
afterFiles: [
{
source: "/api/:path*",
destination: `${remoteApiUrl}/api/:path*`,
},
{
source: "/ws",
destination: `${remoteApiUrl}/ws`,
},
{
source: "/auth/:path*",
destination: `${remoteApiUrl}/auth/:path*`,
},
{
source: "/uploads/:path*",
destination: `${remoteApiUrl}/uploads/:path*`,
},
],
fallback: [],
};
return [
{
source: "/api/:path*",
destination: `${remoteApiUrl}/api/:path*`,
},
{
source: "/ws",
destination: `${remoteApiUrl}/ws`,
},
{
source: "/auth/:path*",
destination: `${remoteApiUrl}/auth/:path*`,
},
{
source: "/uploads/:path*",
destination: `${remoteApiUrl}/uploads/:path*`,
},
];
},
};

View File

@@ -59,7 +59,6 @@ export const mockAgents: Agent[] = [
custom_env_redacted: false,
visibility: "workspace",
max_concurrent_tasks: 3,
model: "",
owner_id: null,
skills: [],
created_at: "2026-01-01T00:00:00Z",

View File

@@ -21,7 +21,6 @@ services:
- "${POSTGRES_PORT:-5432}:5432"
volumes:
- pgdata:/var/lib/postgresql/data
restart: unless-stopped
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-multica} -d ${POSTGRES_DB:-multica}"]
interval: 5s
@@ -56,9 +55,7 @@ services:
CLOUDFRONT_KEY_PAIR_ID: ${CLOUDFRONT_KEY_PAIR_ID:-}
CLOUDFRONT_PRIVATE_KEY: ${CLOUDFRONT_PRIVATE_KEY:-}
COOKIE_DOMAIN: ${COOKIE_DOMAIN:-}
APP_ENV: ${APP_ENV:-production}
MULTICA_APP_URL: ${MULTICA_APP_URL:-http://localhost:3000}
restart: unless-stopped
frontend:
build:
@@ -74,7 +71,6 @@ services:
- "${FRONTEND_PORT:-3000}:3000"
environment:
HOSTNAME: "0.0.0.0"
restart: unless-stopped
volumes:
pgdata:

View File

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

View File

@@ -35,7 +35,6 @@ import type {
RuntimeHourlyActivity,
RuntimePing,
RuntimeUpdate,
RuntimeModelListRequest,
TimelineEntry,
AssigneeFrequencyEntry,
TaskMessagePayload,
@@ -471,17 +470,6 @@ export class ApiClient {
return this.fetch(`/api/runtimes/${runtimeId}/update/${updateId}`);
}
async initiateListModels(runtimeId: string): Promise<RuntimeModelListRequest> {
return this.fetch(`/api/runtimes/${runtimeId}/models`, { method: "POST" });
}
async getListModelsResult(
runtimeId: string,
requestId: string,
): Promise<RuntimeModelListRequest> {
return this.fetch(`/api/runtimes/${runtimeId}/models/${requestId}`);
}
async listAgentTasks(agentId: string): Promise<AgentTask[]> {
return this.fetch(`/api/agents/${agentId}/tasks`);
}

View File

@@ -1,6 +1,5 @@
export { createAuthStore } from "./store";
export type { AuthStoreOptions, AuthState } from "./store";
export { sanitizeNextUrl } from "./utils";
import type { createAuthStore as CreateAuthStoreFn } from "./store";

View File

@@ -1,45 +0,0 @@
import { describe, expect, it } from "vitest";
import { sanitizeNextUrl } from "./utils";
describe("sanitizeNextUrl", () => {
it("accepts single-slash relative paths", () => {
expect(sanitizeNextUrl("/issues")).toBe("/issues");
expect(sanitizeNextUrl("/invite/123")).toBe("/invite/123");
expect(sanitizeNextUrl("/issues?tab=assigned#top")).toBe(
"/issues?tab=assigned#top",
);
});
it("returns null for null or empty input", () => {
expect(sanitizeNextUrl(null)).toBeNull();
expect(sanitizeNextUrl("")).toBeNull();
});
it("rejects absolute URLs", () => {
expect(sanitizeNextUrl("https://evil.example")).toBeNull();
expect(sanitizeNextUrl("http://evil.example/path")).toBeNull();
});
it("rejects javascript: and other non-http schemes", () => {
// Caught by the leading-slash rule, but named here so future edits
// to the regex don't silently drop protection against this vector.
expect(sanitizeNextUrl("javascript:alert(1)")).toBeNull();
expect(sanitizeNextUrl("data:text/html,<script>")).toBeNull();
});
it("rejects protocol-relative URLs", () => {
expect(sanitizeNextUrl("//evil.example")).toBeNull();
expect(sanitizeNextUrl("//evil.example/path")).toBeNull();
});
it("rejects paths containing backslashes", () => {
expect(sanitizeNextUrl("/\\evil.example")).toBeNull();
expect(sanitizeNextUrl("\\\\evil.example")).toBeNull();
});
it("rejects paths containing control characters", () => {
expect(sanitizeNextUrl("/safe\u0000bad")).toBeNull();
expect(sanitizeNextUrl("/safe\tbad")).toBeNull();
expect(sanitizeNextUrl("/safe\r\nbad")).toBeNull();
});
});

View File

@@ -1,20 +0,0 @@
/**
* Validate a post-login redirect URL and return it only if safe to follow.
*
* Only single-slash relative paths (e.g. `/invite/abc`) are accepted. Returns
* `null` for unsafe or empty input — call sites decide the fallback so this
* helper never overloads a specific path with "user did not pass next".
*
* Rejects:
* - `null` / empty string
* - absolute URLs (`https://evil.com`, `javascript:alert(1)`, …)
* - protocol-relative URLs (`//evil.com`)
* - paths containing backslashes (Windows-style or `/\\host`)
* - paths containing ASCII control characters (`\x00``\x1f`)
*/
export function sanitizeNextUrl(raw: string | null): string | null {
if (!raw) return null;
if (!raw.startsWith("/") || raw.startsWith("//")) return null;
if (/[\x00-\x1f\\]/.test(raw)) return null;
return raw;
}

View File

@@ -18,8 +18,6 @@ export interface CardProperties {
description: boolean;
assignee: boolean;
dueDate: boolean;
project: boolean;
childProgress: boolean;
}
export interface ActorFilterValue {
@@ -40,8 +38,6 @@ export const CARD_PROPERTY_OPTIONS: { key: keyof CardProperties; label: string }
{ key: "description", label: "Description" },
{ key: "assignee", label: "Assignee" },
{ key: "dueDate", label: "Due date" },
{ key: "project", label: "Project" },
{ key: "childProgress", label: "Sub-issue progress" },
];
export interface IssueViewState {
@@ -90,8 +86,6 @@ export const viewStoreSlice = (set: StoreApi<IssueViewState>["setState"]): Issue
description: true,
assignee: true,
dueDate: true,
project: true,
childProgress: true,
},
listCollapsedStatuses: [],

View File

@@ -28,9 +28,6 @@ export const RESERVED_SLUGS = new Set([
// Platform / marketing routes (current + likely-future)
"api",
"admin",
"multica", // brand name — prevent impersonation workspaces
"www", // hostname confusable; never a legitimate workspace slug
"new", // ambiguous verb-as-slug; reserved for future global create routes
"help",
"about",
"pricing",

View File

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

View File

@@ -1,4 +1,3 @@
export * from "./queries";
export * from "./mutations";
export * from "./hooks";
export * from "./models";

View File

@@ -1,52 +0,0 @@
import { queryOptions } from "@tanstack/react-query";
import { api } from "../api";
import type { RuntimeModelsResult } from "../types/agent";
export const runtimeModelsKeys = {
all: () => ["runtimes", "models"] as const,
forRuntime: (runtimeId: string) =>
[...runtimeModelsKeys.all(), runtimeId] as const,
};
const POLL_INTERVAL_MS = 500;
const POLL_TIMEOUT_MS = 30_000;
// resolveRuntimeModels initiates a list-models request against the daemon
// (via heartbeat piggyback) and polls until the daemon reports back or
// the request times out. Returns both the models list and a
// `supported` flag: `supported=false` means the provider ignores
// per-agent model selection entirely (hermes today) — the UI uses
// this to disable its dropdown instead of accepting a value that
// wouldn't be honoured at runtime.
export async function resolveRuntimeModels(
runtimeId: string,
): Promise<RuntimeModelsResult> {
const initial = await api.initiateListModels(runtimeId);
const start = Date.now();
let current = initial;
while (current.status === "pending" || current.status === "running") {
if (Date.now() - start > POLL_TIMEOUT_MS) {
throw new Error("model discovery timed out");
}
await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS));
current = await api.getListModelsResult(runtimeId, initial.id);
}
if (current.status === "failed" || current.status === "timeout") {
throw new Error(current.error || "model discovery failed");
}
return { models: current.models ?? [], supported: current.supported };
}
export function runtimeModelsOptions(runtimeId: string | null | undefined) {
return queryOptions({
queryKey: runtimeId
? runtimeModelsKeys.forRuntime(runtimeId)
: runtimeModelsKeys.all(),
queryFn: () => resolveRuntimeModels(runtimeId as string),
enabled: Boolean(runtimeId),
// Models rarely change; cache for 60s to match the server-side
// cache in agent.ListModels.
staleTime: 60_000,
retry: false,
});
}

View File

@@ -11,7 +11,6 @@ export interface RuntimeDevice {
name: string;
runtime_mode: AgentRuntimeMode;
provider: string;
launch_header: string;
status: "online" | "offline";
device_info: string;
metadata: Record<string, unknown>;
@@ -54,7 +53,6 @@ export interface Agent {
visibility: AgentVisibility;
status: AgentStatus;
max_concurrent_tasks: number;
model: string;
owner_id: string | null;
skills: Skill[];
created_at: string;
@@ -74,7 +72,6 @@ export interface CreateAgentRequest {
custom_args?: string[];
visibility?: AgentVisibility;
max_concurrent_tasks?: number;
model?: string;
}
export interface UpdateAgentRequest {
@@ -89,7 +86,6 @@ export interface UpdateAgentRequest {
visibility?: AgentVisibility;
status?: AgentStatus;
max_concurrent_tasks?: number;
model?: string;
}
// Skills
@@ -190,36 +186,3 @@ export interface RuntimeUpdate {
created_at: string;
updated_at: string;
}
export interface RuntimeModel {
id: string;
label: string;
provider?: string;
default?: boolean;
}
export type RuntimeModelListStatus =
| "pending"
| "running"
| "completed"
| "failed"
| "timeout";
export interface RuntimeModelListRequest {
id: string;
runtime_id: string;
status: RuntimeModelListStatus;
models?: RuntimeModel[];
supported: boolean;
error?: string;
created_at: string;
updated_at: string;
}
// Result shape returned by resolveRuntimeModels — includes the
// "supported" bit so the UI can distinguish "no models discovered"
// from "provider does not honour per-agent model selection".
export interface RuntimeModelsResult {
models: RuntimeModel[];
supported: boolean;
}

View File

@@ -20,10 +20,6 @@ export type {
RuntimePingStatus,
RuntimeUpdate,
RuntimeUpdateStatus,
RuntimeModel,
RuntimeModelListRequest,
RuntimeModelListStatus,
RuntimeModelsResult,
IssueUsageSummary,
} from "./agent";
export type { Workspace, WorkspaceRepo, Member, MemberRole, User, MemberWithUser, Invitation } from "./workspace";

View File

@@ -178,7 +178,6 @@ export function AgentDetail({
{activeTab === "custom_args" && (
<CustomArgsTab
agent={agent}
runtimeDevice={runtimeDevice}
onSave={(updates) => onUpdate(agent.id, updates)}
/>
)}

View File

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

View File

@@ -4,7 +4,6 @@ import { useState, useEffect, useMemo } from "react";
import { Cloud, ChevronDown, Globe, Lock, Loader2 } from "lucide-react";
import { ProviderLogo } from "../../runtimes/components/provider-logo";
import { ActorAvatar } from "../../common/actor-avatar";
import { ModelDropdown } from "./model-dropdown";
import type {
AgentVisibility,
RuntimeDevice,
@@ -49,7 +48,6 @@ export function CreateAgentDialog({
const [name, setName] = useState("");
const [description, setDescription] = useState("");
const [visibility, setVisibility] = useState<AgentVisibility>("private");
const [model, setModel] = useState("");
const [creating, setCreating] = useState(false);
const [runtimeOpen, setRuntimeOpen] = useState(false);
const [runtimeFilter, setRuntimeFilter] = useState<RuntimeFilter>("mine");
@@ -91,7 +89,6 @@ export function CreateAgentDialog({
description: description.trim(),
runtime_id: selectedRuntime.id,
visibility,
model: model.trim() || undefined,
});
onClose();
} catch (err) {
@@ -278,14 +275,6 @@ export function CreateAgentDialog({
</PopoverContent>
</Popover>
</div>
<ModelDropdown
runtimeId={selectedRuntime?.id ?? null}
runtimeOnline={selectedRuntime?.status === "online"}
value={model}
onChange={setModel}
disabled={!selectedRuntime}
/>
</div>
<DialogFooter>

View File

@@ -1,252 +0,0 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { ChevronDown, Cpu, Loader2, Plus, Check, Info } from "lucide-react";
import { runtimeModelsOptions } from "@multica/core/runtimes";
import type { RuntimeModel } from "@multica/core/types";
import {
Popover,
PopoverTrigger,
PopoverContent,
} from "@multica/ui/components/ui/popover";
import { Input } from "@multica/ui/components/ui/input";
import { Label } from "@multica/ui/components/ui/label";
// ModelDropdown renders a searchable, creatable model picker for an agent.
// It fetches the supported-model catalog from the selected runtime — the
// daemon enumerates models on demand via heartbeat piggyback. Providers
// that don't honour per-agent model selection at runtime (currently
// hermes) return supported=false, and the dropdown renders disabled
// with an explanation instead of silently accepting a value the
// backend would ignore.
export function ModelDropdown({
runtimeId,
runtimeOnline,
value,
onChange,
disabled,
}: {
runtimeId: string | null;
runtimeOnline: boolean;
value: string;
onChange: (value: string) => void;
disabled?: boolean;
}) {
const [open, setOpen] = useState(false);
const [search, setSearch] = useState("");
const modelsQuery = useQuery(
runtimeModelsOptions(runtimeOnline ? runtimeId : null),
);
const supported = modelsQuery.data?.supported ?? true;
const models = modelsQuery.data?.models ?? [];
const defaultModel = useMemo(() => models.find((m) => m.default), [models]);
const grouped = useMemo(() => groupByProvider(models), [models]);
// When the selected runtime reports it doesn't support per-agent
// model selection, clear any previously-saved value so we don't
// persist a ghost configuration that never takes effect.
useEffect(() => {
if (!supported && value !== "") {
onChange("");
}
}, [supported, value, onChange]);
const filtered = useMemo(() => {
if (!search.trim()) return grouped;
const needle = search.toLowerCase();
const out: Record<string, RuntimeModel[]> = {};
for (const [provider, list] of Object.entries(grouped)) {
const matches = list.filter(
(m) =>
m.id.toLowerCase().includes(needle) ||
m.label.toLowerCase().includes(needle),
);
if (matches.length > 0) out[provider] = matches;
}
return out;
}, [grouped, search]);
const trimmedSearch = search.trim();
const exactMatch = models.some(
(m) => m.id === trimmedSearch || m.label === trimmedSearch,
);
const canCreate = trimmedSearch.length > 0 && !exactMatch;
const select = (id: string) => {
onChange(id);
setOpen(false);
setSearch("");
};
const triggerLabel =
value ||
(disabled
? "Select a runtime first"
: runtimeOnline
? defaultModel
? `Default — ${defaultModel.label}`
: "Default (provider)"
: "Runtime offline — enter manually");
if (!supported && !modelsQuery.isLoading) {
// Provider doesn't honour per-agent model selection — show a
// clearly-disabled state so the user knows why the control is
// inert. (Hermes reads its model from ~/.hermes/.env.)
return (
<div className="min-w-0">
<Label className="text-xs text-muted-foreground">Model</Label>
<div className="mt-1.5 flex items-start gap-2 rounded-lg border border-dashed border-border bg-muted/30 px-3 py-2.5 text-sm text-muted-foreground">
<Info className="mt-0.5 h-4 w-4 shrink-0" />
<div className="min-w-0">
<div>Model selection is managed by this runtime.</div>
<div className="mt-0.5 text-xs">
Configure the model on the runtime host (e.g. Hermes reads it
from its own config file).
</div>
</div>
</div>
</div>
);
}
return (
<div className="min-w-0">
<div className="flex items-center justify-between">
<Label className="text-xs text-muted-foreground">Model</Label>
{modelsQuery.isError && (
<span className="text-xs text-muted-foreground">discovery failed</span>
)}
</div>
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger
disabled={disabled}
className="flex w-full min-w-0 items-center gap-3 rounded-lg border border-border bg-background px-3 py-2.5 mt-1.5 text-left text-sm transition-colors hover:bg-muted disabled:pointer-events-none disabled:opacity-50"
>
<Cpu className="h-4 w-4 shrink-0 text-muted-foreground" />
<div className="min-w-0 flex-1">
<div className="truncate font-medium">
{triggerLabel}
</div>
{value && (
<div className="truncate text-xs text-muted-foreground">
{modelLabel(models, value)}
</div>
)}
</div>
<ChevronDown
className={`h-4 w-4 shrink-0 text-muted-foreground transition-transform ${open ? "rotate-180" : ""}`}
/>
</PopoverTrigger>
<PopoverContent
align="start"
className="w-[var(--anchor-width)] p-0 overflow-hidden"
>
<div className="border-b border-border p-2">
<Input
autoFocus
placeholder="Search or type a model ID"
value={search}
onChange={(e) => setSearch(e.target.value)}
className="h-8"
/>
</div>
<div className="max-h-72 overflow-y-auto p-1">
{modelsQuery.isLoading && (
<div className="flex items-center gap-2 px-3 py-6 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" />
Discovering models
</div>
)}
{!modelsQuery.isLoading &&
Object.entries(filtered).map(([provider, list]) => (
<div key={provider} className="mb-1">
{provider && (
<div className="px-2 pt-1.5 pb-0.5 text-xs font-medium uppercase tracking-wide text-muted-foreground">
{provider}
</div>
)}
{list.map((m) => (
<button
key={m.id}
onClick={() => select(m.id)}
className={`flex w-full items-center gap-2 rounded-md px-3 py-2 text-left text-sm transition-colors ${
m.id === value ? "bg-accent" : "hover:bg-accent/50"
}`}
>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-1.5">
<span className="truncate font-medium">{m.label}</span>
{m.default && (
<span className="shrink-0 rounded bg-primary/10 px-1.5 py-0.5 text-xs font-medium text-primary">
default
</span>
)}
</div>
{m.label !== m.id && (
<div className="truncate text-xs text-muted-foreground">
{m.id}
</div>
)}
</div>
{m.id === value && (
<Check className="h-4 w-4 shrink-0 text-primary" />
)}
</button>
))}
</div>
))}
{!modelsQuery.isLoading &&
Object.keys(filtered).length === 0 &&
!canCreate && (
<div className="px-3 py-6 text-center text-sm text-muted-foreground">
No models available.
</div>
)}
{canCreate && (
<button
onClick={() => select(trimmedSearch)}
className="flex w-full items-center gap-2 rounded-md px-3 py-2 text-left text-sm text-primary transition-colors hover:bg-accent/50"
>
<Plus className="h-4 w-4 shrink-0" />
<span className="truncate">
Use {trimmedSearch}
</span>
</button>
)}
{value && (
<button
onClick={() => select("")}
className="mt-1 flex w-full items-center gap-2 border-t border-border px-3 py-2 text-left text-xs text-muted-foreground transition-colors hover:bg-accent/50"
>
Clear selection (use provider default)
</button>
)}
</div>
</PopoverContent>
</Popover>
</div>
);
}
function groupByProvider(models: RuntimeModel[]): Record<string, RuntimeModel[]> {
const out: Record<string, RuntimeModel[]> = {};
for (const m of models) {
const key = m.provider ?? "";
if (!out[key]) out[key] = [];
out[key].push(m);
}
return out;
}
function modelLabel(models: RuntimeModel[], id: string): string {
const found = models.find((m) => m.id === id);
if (!found) return "custom";
return found.provider ? found.provider : "model";
}

View File

@@ -7,8 +7,7 @@ import {
Plus,
Trash2,
} from "lucide-react";
import type { Agent, RuntimeDevice } from "@multica/core/types";
import { createSafeId } from "@multica/core/utils";
import type { Agent } from "@multica/core/types";
import { Button } from "@multica/ui/components/ui/button";
import { Input } from "@multica/ui/components/ui/input";
import { Label } from "@multica/ui/components/ui/label";
@@ -20,7 +19,7 @@ interface ArgEntry {
}
function argsToEntries(args: string[]): ArgEntry[] {
return args.map((value) => ({ id: createSafeId(), value }));
return args.map((value) => ({ id: crypto.randomUUID(), value }));
}
function entriesToArgs(entries: ArgEntry[]): string[] {
@@ -29,11 +28,9 @@ function entriesToArgs(entries: ArgEntry[]): string[] {
export function CustomArgsTab({
agent,
runtimeDevice,
onSave,
}: {
agent: Agent;
runtimeDevice?: RuntimeDevice;
onSave: (updates: Partial<Agent>) => Promise<void>;
}) {
const [entries, setEntries] = useState<ArgEntry[]>(
@@ -46,7 +43,7 @@ export function CustomArgsTab({
const dirty = JSON.stringify(currentArgs) !== JSON.stringify(originalArgs);
const addEntry = () => {
setEntries([...entries, { id: createSafeId(), value: "" }]);
setEntries([...entries, { id: crypto.randomUUID(), value: "" }]);
};
const removeEntry = (index: number) => {
@@ -71,8 +68,6 @@ export function CustomArgsTab({
}
};
const launchHeader = runtimeDevice?.launch_header;
return (
<div className="max-w-lg space-y-4">
<div className="flex items-center justify-between">
@@ -81,17 +76,9 @@ export function CustomArgsTab({
Custom Arguments
</Label>
<p className="text-xs text-muted-foreground mt-0.5">
Additional CLI arguments appended to the agent command at launch.
Supported flags depend on the agent's CLI.
Additional CLI arguments appended to the agent command at launch
(e.g. --model claude-sonnet-4-20250514)
</p>
{launchHeader && (
<p className="mt-2 text-xs text-muted-foreground">
Launch mode:{" "}
<code className="rounded bg-muted px-1 py-0.5 font-mono text-[11px]">
{launchHeader} &lt;your args&gt;
</code>
</p>
)}
</div>
<Button
type="button"
@@ -111,7 +98,7 @@ export function CustomArgsTab({
<Input
value={entry.value}
onChange={(e) => updateEntry(index, e.target.value)}
placeholder="--flag value"
placeholder="--model claude-sonnet-4-20250514"
className="flex-1 font-mono text-xs"
/>
<button

View File

@@ -23,7 +23,6 @@ import { api } from "@multica/core/api";
import { useFileUpload } from "@multica/core/hooks/use-file-upload";
import { ActorAvatar } from "../../../common/actor-avatar";
import { ProviderLogo } from "../../../runtimes/components/provider-logo";
import { ModelDropdown } from "../model-dropdown";
type RuntimeFilter = "mine" | "all";
@@ -45,7 +44,6 @@ export function SettingsTab({
const [visibility, setVisibility] = useState<AgentVisibility>(agent.visibility);
const [maxTasks, setMaxTasks] = useState(agent.max_concurrent_tasks);
const [selectedRuntimeId, setSelectedRuntimeId] = useState(agent.runtime_id);
const [model, setModel] = useState(agent.model ?? "");
const [runtimeOpen, setRuntimeOpen] = useState(false);
const [runtimeFilter, setRuntimeFilter] = useState<RuntimeFilter>("mine");
const [saving, setSaving] = useState(false);
@@ -92,8 +90,7 @@ export function SettingsTab({
description !== (agent.description ?? "") ||
visibility !== agent.visibility ||
maxTasks !== agent.max_concurrent_tasks ||
selectedRuntimeId !== agent.runtime_id ||
model !== (agent.model ?? "");
selectedRuntimeId !== agent.runtime_id;
const handleSave = async () => {
if (!name.trim()) {
@@ -109,7 +106,6 @@ export function SettingsTab({
visibility,
max_concurrent_tasks: maxTasks,
runtime_id: selectedRuntimeId,
model,
});
toast.success("Settings saved");
} catch {
@@ -325,14 +321,6 @@ export function SettingsTab({
</Popover>
</div>
<ModelDropdown
runtimeId={selectedRuntime?.id ?? null}
runtimeOnline={selectedRuntime?.status === "online"}
value={model}
onChange={setModel}
disabled={!selectedRuntime}
/>
<Button onClick={handleSave} disabled={!dirty || saving} size="sm">
{saving ? <Loader2 className="h-3.5 w-3.5 mr-1.5 animate-spin" /> : <Save className="h-3.5 w-3.5 mr-1.5" />}
Save Changes

View File

@@ -1,175 +0,0 @@
// @vitest-environment jsdom
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, waitFor } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { Agent, AgentTask, Issue } from "@multica/core/types";
const mockListAgentTasks = vi.hoisted(() => vi.fn());
const mockListIssues = vi.hoisted(() => vi.fn());
vi.mock("@multica/core/hooks", () => ({
useWorkspaceId: () => "ws-1",
}));
vi.mock("@multica/core/paths", async () => {
const actual = await vi.importActual<typeof import("@multica/core/paths")>(
"@multica/core/paths",
);
return {
...actual,
useWorkspacePaths: () => actual.paths.workspace("test"),
};
});
vi.mock("@multica/core/api", () => ({
api: {
listAgentTasks: (...args: unknown[]) => mockListAgentTasks(...args),
listIssues: (...args: unknown[]) => mockListIssues(...args),
},
}));
vi.mock("../../../navigation", () => ({
AppLink: ({ children, href, ...props }: any) => (
<a href={href} {...props}>
{children}
</a>
),
}));
import { TasksTab } from "./tasks-tab";
const agent: Agent = {
id: "agent-1",
workspace_id: "ws-1",
runtime_id: "runtime-1",
name: "Agent",
description: "",
instructions: "",
avatar_url: null,
runtime_mode: "local",
runtime_config: {},
custom_env: {},
custom_args: [],
custom_env_redacted: false,
visibility: "workspace",
status: "idle",
max_concurrent_tasks: 1,
model: "",
owner_id: null,
skills: [],
created_at: "2026-04-16T00:00:00Z",
updated_at: "2026-04-16T00:00:00Z",
archived_at: null,
archived_by: null,
};
function renderTasksTab(tasks: AgentTask[], issues: Issue[]) {
mockListAgentTasks.mockResolvedValue(tasks);
mockListIssues.mockImplementation(
({ open_only, status }: { open_only?: boolean; status?: string }) =>
Promise.resolve({
issues: open_only ? issues : status === "done" ? [] : [],
total: open_only ? issues.length : 0,
}),
);
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
});
return render(
<QueryClientProvider client={queryClient}>
<TasksTab agent={agent} />
</QueryClientProvider>,
);
}
describe("TasksTab", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("uses workspace-scoped issue detail paths when issue data is loaded", async () => {
renderTasksTab(
[
{
id: "task-1",
agent_id: "agent-1",
runtime_id: "runtime-1",
issue_id: "issue-1",
status: "queued",
priority: 1,
dispatched_at: null,
started_at: null,
completed_at: null,
result: null,
error: null,
created_at: "2026-04-16T00:00:00Z",
},
],
[
{
id: "issue-1",
workspace_id: "ws-1",
number: 1,
identifier: "MUL-1",
title: "Fix agent task routing",
description: "",
status: "todo",
priority: "medium",
assignee_type: null,
assignee_id: null,
creator_type: "member",
creator_id: "user-1",
parent_issue_id: null,
project_id: null,
position: 1,
due_date: null,
created_at: "2026-04-16T00:00:00Z",
updated_at: "2026-04-16T00:00:00Z",
},
],
);
const title = await screen.findByText("Fix agent task routing");
const link = title.closest("a");
expect(link?.getAttribute("href")).toBe("/test/issues/issue-1");
});
it("keeps task rows clickable when the issue is missing from the list query", async () => {
renderTasksTab(
[
{
id: "task-2",
agent_id: "agent-1",
runtime_id: "runtime-1",
issue_id: "12345678-fallback",
status: "completed",
priority: 1,
dispatched_at: null,
started_at: null,
completed_at: "2026-04-16T01:00:00Z",
result: null,
error: null,
created_at: "2026-04-16T00:00:00Z",
},
],
[],
);
await waitFor(() => {
expect(mockListAgentTasks).toHaveBeenCalledWith("agent-1");
});
const title = await screen.findByText("Issue 12345678...");
const link = title.closest("a");
expect(link?.getAttribute("href")).toBe("/test/issues/12345678-fallback");
});
});

View File

@@ -6,17 +6,14 @@ import type { Agent, AgentTask } from "@multica/core/types";
import { Skeleton } from "@multica/ui/components/ui/skeleton";
import { api } from "@multica/core/api";
import { useWorkspaceId } from "@multica/core/hooks";
import { useWorkspacePaths } from "@multica/core/paths";
import { issueListOptions } from "@multica/core/issues/queries";
import { useQuery } from "@tanstack/react-query";
import { AppLink } from "../../../navigation";
import { taskStatusConfig } from "../../config";
export function TasksTab({ agent }: { agent: Agent }) {
const [tasks, setTasks] = useState<AgentTask[]>([]);
const [loading, setLoading] = useState(true);
const wsId = useWorkspaceId();
const paths = useWorkspacePaths();
const { data: issues = [] } = useQuery(issueListOptions(wsId));
useEffect(() => {
@@ -85,16 +82,18 @@ export function TasksTab({ agent }: { agent: Agent }) {
const issue = issueMap.get(task.issue_id);
const isActive = task.status === "running" || task.status === "dispatched";
const isRunning = task.status === "running";
const rowClassName = `flex items-center gap-3 rounded-lg border px-4 py-3 transition-shadow hover:shadow-sm ${
isRunning
? "border-success/40 bg-success/5"
: task.status === "dispatched"
? "border-info/40 bg-info/5"
: ""
}`;
const content = (
<>
return (
<div
key={task.id}
className={`flex items-center gap-3 rounded-lg border px-4 py-3 ${
isRunning
? "border-success/40 bg-success/5"
: task.status === "dispatched"
? "border-info/40 bg-info/5"
: ""
}`}
>
<Icon
className={`h-4 w-4 shrink-0 ${config.color} ${
isRunning ? "animate-spin" : ""
@@ -111,7 +110,7 @@ export function TasksTab({ agent }: { agent: Agent }) {
{issue?.title ?? `Issue ${task.issue_id.slice(0, 8)}...`}
</span>
</div>
<div className="mt-0.5 text-xs text-muted-foreground">
<div className="text-xs text-muted-foreground mt-0.5">
{isRunning && task.started_at
? `Started ${new Date(task.started_at).toLocaleString()}`
: task.status === "dispatched" && task.dispatched_at
@@ -126,17 +125,7 @@ export function TasksTab({ agent }: { agent: Agent }) {
<span className={`shrink-0 text-xs font-medium ${config.color}`}>
{config.label}
</span>
</>
);
return (
<AppLink
key={task.id}
href={paths.issueDetail(task.issue_id)}
className={`${rowClassName} text-foreground no-underline hover:no-underline`}
>
{content}
</AppLink>
</div>
);
})}
</div>

View File

@@ -52,9 +52,9 @@ function formatDate(date: string): string {
});
}
const RUN_STATUS_CONFIG: Record<string, { label: string; color: string; icon: typeof CheckCircle2; spin?: boolean }> = {
const RUN_STATUS_CONFIG: Record<string, { label: string; color: string; icon: typeof CheckCircle2 }> = {
issue_created: { label: "Issue Created", color: "text-blue-500", icon: Clock },
running: { label: "Running", color: "text-blue-500", icon: Loader2, spin: true },
running: { label: "Running", color: "text-blue-500", icon: Loader2 },
completed: { label: "Completed", color: "text-emerald-500", icon: CheckCircle2 },
failed: { label: "Failed", color: "text-destructive", icon: XCircle },
};
@@ -66,7 +66,7 @@ function RunRow({ run }: { run: AutopilotRun }) {
const content = (
<>
<StatusIcon className={cn("h-4 w-4 shrink-0", cfg.color, cfg.spin && "animate-spin")} />
<StatusIcon className={cn("h-4 w-4 shrink-0", cfg.color)} />
<span className={cn("w-24 shrink-0 text-xs font-medium", cfg.color)}>{cfg.label}</span>
<span className="w-16 shrink-0 text-xs text-muted-foreground capitalize">{run.source}</span>
<span className="flex-1 min-w-0 text-xs text-muted-foreground truncate">
@@ -385,39 +385,9 @@ export function AutopilotDetailPage({ autopilotId }: { autopilotId: string }) {
if (isLoading) {
return (
<div className="flex h-full flex-col">
<div className="flex h-12 shrink-0 items-center gap-2 border-b px-5">
<Skeleton className="h-4 w-4" />
<span className="text-muted-foreground">/</span>
<Skeleton className="h-4 w-32" />
</div>
<div className="flex-1 overflow-y-auto">
<div className="max-w-4xl mx-auto p-6 space-y-8">
<section className="space-y-4">
<Skeleton className="h-3 w-20" />
<div className="grid grid-cols-2 gap-4">
<div className="space-y-1">
<Skeleton className="h-3 w-12" />
<Skeleton className="h-5 w-32" />
</div>
<div className="space-y-1">
<Skeleton className="h-3 w-12" />
<Skeleton className="h-5 w-24" />
</div>
</div>
</section>
<section className="space-y-3">
<Skeleton className="h-4 w-16" />
<Skeleton className="h-10 w-full rounded-md" />
</section>
<section className="space-y-3">
<Skeleton className="h-4 w-24" />
{Array.from({ length: 3 }).map((_, i) => (
<Skeleton key={i} className="h-10 w-full" />
))}
</section>
</div>
</div>
<div className="p-6 space-y-4">
<Skeleton className="h-8 w-64" />
<Skeleton className="h-40 w-full" />
</div>
);
}

View File

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

View File

@@ -15,7 +15,7 @@ export type TriggerFrequency = "hourly" | "daily" | "weekdays" | "weekly" | "cus
export interface TriggerConfig {
frequency: TriggerFrequency;
time: string; // HH:MM
daysOfWeek: number[]; // 0=Sun … 6=Sat — used when frequency === "weekly"
dayOfWeek: number; // 0=Sun … 6=Sat
cronExpression: string; // only used when frequency === "custom"
timezone: string; // IANA
}
@@ -28,7 +28,7 @@ const FREQUENCIES: { value: TriggerFrequency; label: string }[] = [
{ value: "hourly", label: "Hourly" },
{ value: "daily", label: "Daily" },
{ value: "weekdays", label: "Weekdays" },
{ value: "weekly", label: "Days" },
{ value: "weekly", label: "Weekly" },
{ value: "custom", label: "Custom" },
];
@@ -102,22 +102,12 @@ export function getDefaultTriggerConfig(): TriggerConfig {
return {
frequency: "daily",
time: "09:00",
daysOfWeek: [1],
dayOfWeek: 1,
cronExpression: "0 9 * * 1-5",
timezone: getLocalTimezone(),
};
}
function sortedDays(days: number[]): number[] {
return [...new Set(days)].sort((a, b) => a - b);
}
function formatDayList(days: number[]): string {
const sorted = sortedDays(days);
if (sorted.length === 0) return "—";
return sorted.map((d) => DAYS_OF_WEEK[d]).join(", ");
}
export function toCronExpression(cfg: TriggerConfig): string {
const [h, m] = cfg.time.split(":");
const hour = parseInt(h ?? "9", 10);
@@ -129,11 +119,8 @@ export function toCronExpression(cfg: TriggerConfig): string {
return `${min} ${hour} * * *`;
case "weekdays":
return `${min} ${hour} * * 1-5`;
case "weekly": {
const days = sortedDays(cfg.daysOfWeek);
const dow = days.length > 0 ? days.join(",") : "1";
return `${min} ${hour} * * ${dow}`;
}
case "weekly":
return `${min} ${hour} * * ${cfg.dayOfWeek}`;
case "custom":
return cfg.cronExpression;
}
@@ -151,7 +138,7 @@ export function describeTrigger(cfg: TriggerConfig): string {
case "weekdays":
return `Runs weekdays at ${formatTime12h(cfg.time)} ${offset}`;
case "weekly":
return `Runs every ${formatDayList(cfg.daysOfWeek)} at ${formatTime12h(cfg.time)} ${offset}`;
return `Runs every ${DAYS_OF_WEEK[cfg.dayOfWeek]} at ${formatTime12h(cfg.time)} ${offset}`;
case "custom":
return `Custom schedule: ${cfg.cronExpression}`;
}
@@ -264,39 +251,26 @@ export function TriggerConfigSection({
)}
</div>
{/* Day-of-week multi-selector for weekly */}
{/* Day-of-week selector for weekly */}
{config.frequency === "weekly" && (
<div>
<label className="text-xs text-muted-foreground">Days</label>
<label className="text-xs text-muted-foreground">Day</label>
<div className="flex gap-1 mt-1">
{DAYS_OF_WEEK.map((day, i) => {
const selected = config.daysOfWeek.includes(i);
return (
<button
key={day}
type="button"
aria-pressed={selected}
className={cn(
"rounded-md px-2.5 py-1 text-xs font-medium transition-colors",
selected
? "bg-foreground text-background"
: "bg-muted text-muted-foreground hover:text-foreground",
)}
onClick={() => {
const next = selected
? config.daysOfWeek.filter((d) => d !== i)
: [...config.daysOfWeek, i];
// Keep at least one day selected so the cron stays valid.
onChange({
...config,
daysOfWeek: next.length > 0 ? next : config.daysOfWeek,
});
}}
>
{day}
</button>
);
})}
{DAYS_OF_WEEK.map((day, i) => (
<button
key={day}
type="button"
className={cn(
"rounded-md px-2.5 py-1 text-xs font-medium transition-colors",
config.dayOfWeek === i
? "bg-foreground text-background"
: "bg-muted text-muted-foreground hover:text-foreground",
)}
onClick={() => onChange({ ...config, dayOfWeek: i })}
>
{day}
</button>
))}
</div>
</div>
)}

View File

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

View File

@@ -34,16 +34,11 @@ export function useChatResize(
if (!parent) return;
const update = () => {
const maxW = Math.floor(parent.clientWidth * MAX_RATIO);
const maxH = Math.floor(parent.clientHeight * MAX_RATIO);
setBoundsReady(true); // idempotent once true
// Only trigger a re-render if the bounds actually changed. Without this
// guard, any spurious ResizeObserver notification (including sub-pixel
// layout jitter during mount) schedules a setState that feeds back into
// the observer, producing "Maximum update depth exceeded".
const prev = boundsRef.current;
if (prev.maxW === maxW && prev.maxH === maxH) return;
boundsRef.current = { maxW, maxH };
boundsRef.current = {
maxW: Math.floor(parent.clientWidth * MAX_RATIO),
maxH: Math.floor(parent.clientHeight * MAX_RATIO),
};
setBoundsReady(true);
setRevision((r) => r + 1);
};

View File

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

View File

@@ -75,12 +75,6 @@ interface ContentEditorProps {
showBubbleMenu?: boolean;
/** When true, bare Enter submits (chat-style). Mod-Enter always submits. */
submitOnEnter?: boolean;
/**
* ID of the issue this editor belongs to. When set, the bubble menu exposes
* a "Create sub-issue from selection" action that parents the new issue
* under this ID and replaces the selection with a mention link.
*/
currentIssueId?: string;
}
interface ContentEditorRef {
@@ -110,7 +104,6 @@ const ContentEditor = forwardRef<ContentEditorRef, ContentEditorProps>(
onUploadFile,
showBubbleMenu = true,
submitOnEnter = false,
currentIssueId,
},
ref,
) {
@@ -265,9 +258,7 @@ const ContentEditor = forwardRef<ContentEditorRef, ContentEditorProps>(
onMouseDown={handleContainerMouseDown}
>
<EditorContent className="flex-1 min-h-full" editor={editor} />
{editable && showBubbleMenu && (
<EditorBubbleMenu editor={editor} currentIssueId={currentIssueId} />
)}
{editable && showBubbleMenu && <EditorBubbleMenu editor={editor} />}
<LinkHoverCard {...hover} />
</div>
);

View File

@@ -1,20 +0,0 @@
import { Extension } from "@tiptap/core";
/**
* Escape → blur the editor. Without this, pressing ESC inside the
* contenteditable does nothing (browsers don't blur contenteditables by
* default), leaving users stuck in the editor with no keyboard escape hatch.
*/
export function createBlurShortcutExtension() {
return Extension.create({
name: "blurShortcut",
addKeyboardShortcuts() {
return {
Escape: ({ editor }) => {
editor.commands.blur();
return true;
},
};
},
});
}

View File

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

View File

@@ -40,7 +40,6 @@ import { createMentionSuggestion } from "./mention-suggestion";
import { CodeBlockView } from "./code-block-view";
import { createMarkdownPasteExtension } from "./markdown-paste";
import { createSubmitExtension } from "./submit-shortcut";
import { createBlurShortcutExtension } from "./blur-shortcut";
import { createFileUploadExtension } from "./file-upload";
import { FileCardExtension } from "./file-card";
import { ImageView } from "./image-view";
@@ -138,7 +137,6 @@ export function createEditorExtensions(
},
{ submitOnEnter: options.submitOnEnter ?? false },
),
createBlurShortcutExtension(),
createFileUploadExtension(options.onUploadFileRef!),
);
}

View File

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

View File

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

View File

@@ -8,12 +8,9 @@ import { CSS } from "@dnd-kit/utilities";
import { toast } from "sonner";
import type { Issue, UpdateIssueRequest } from "@multica/core/types";
import { CalendarDays } from "lucide-react";
import { useQuery } from "@tanstack/react-query";
import { ActorAvatar } from "../../common/actor-avatar";
import { useUpdateIssue } from "@multica/core/issues/mutations";
import { useWorkspacePaths } from "@multica/core/paths";
import { useWorkspaceId } from "@multica/core/hooks";
import { projectListOptions } from "@multica/core/projects/queries";
import { PriorityIcon } from "./priority-icon";
import { PriorityPicker, AssigneePicker, DueDatePicker } from "./pickers";
import { PRIORITY_CONFIG } from "@multica/core/issues/config";
@@ -52,12 +49,6 @@ export const BoardCardContent = memo(function BoardCardContent({
}) {
const storeProperties = useViewStore((s) => s.cardProperties);
const priorityCfg = PRIORITY_CONFIG[issue.priority];
const wsId = useWorkspaceId();
const { data: projects = [] } = useQuery({
...projectListOptions(wsId),
enabled: storeProperties.project && !!issue.project_id,
});
const project = issue.project_id ? projects.find((p) => p.id === issue.project_id) : undefined;
const updateIssueMutation = useUpdateIssue();
const handleUpdate = useCallback(
@@ -74,8 +65,6 @@ export const BoardCardContent = memo(function BoardCardContent({
const showDescription = storeProperties.description && issue.description;
const showAssignee = storeProperties.assignee && issue.assignee_type && issue.assignee_id;
const showDueDate = storeProperties.dueDate && issue.due_date;
const showProject = storeProperties.project && project;
const showChildProgress = storeProperties.childProgress && childProgress;
return (
<div className="rounded-lg border-[0.5px] bg-card py-3 px-2.5 shadow-[0_3px_6px_-2px_rgba(0,0,0,0.02),0_1px_1px_0_rgba(0,0,0,0.04)] transition-shadow group-hover:shadow-sm">
@@ -87,23 +76,13 @@ export const BoardCardContent = memo(function BoardCardContent({
{issue.title}
</p>
{/* Sub-issue progress + project */}
{(showChildProgress || showProject) && (
<div className="mt-1.5 flex items-center gap-1.5 flex-wrap">
{showChildProgress && (
<div className="inline-flex items-center gap-1 rounded-full bg-muted/60 px-1.5 py-0.5">
<ProgressRing done={childProgress!.done} total={childProgress!.total} size={14} />
<span className="text-[11px] text-muted-foreground tabular-nums font-medium">
{childProgress!.done}/{childProgress!.total}
</span>
</div>
)}
{showProject && (
<span className="inline-flex items-center gap-1 rounded-full bg-muted/60 px-1.5 py-0.5 text-[11px] text-muted-foreground max-w-[160px]">
<span aria-hidden="true" className="shrink-0">{project!.icon || "📁"}</span>
<span className="truncate">{project!.title}</span>
</span>
)}
{/* Sub-issue progress */}
{childProgress && (
<div className="mt-1.5 inline-flex items-center gap-1 rounded-full bg-muted/60 px-1.5 py-0.5">
<ProgressRing done={childProgress.done} total={childProgress.total} size={14} />
<span className="text-[11px] text-muted-foreground tabular-nums font-medium">
{childProgress.done}/{childProgress.total}
</span>
</div>
)}

View File

@@ -98,24 +98,9 @@ function DeleteCommentDialog({
function AttachmentList({ attachments, content, className }: { attachments?: Attachment[]; content?: string; className?: string }) {
if (!attachments?.length) return null;
// Skip attachments whose URL is already referenced in the markdown content,
// and duplicates of the same file (same name/type/size) that are referenced.
// Skip attachments whose URL is already referenced in the markdown content
const standalone = content
? attachments.filter((a) => {
if (content.includes(a.url)) return false;
// Dedup: if another attachment with the same file identity is already
// inline in the content, this is a duplicate upload — skip it.
const hasSiblingInContent = attachments.some(
(other) =>
other.id !== a.id &&
other.filename === a.filename &&
other.content_type === a.content_type &&
other.size_bytes === a.size_bytes &&
content.includes(other.url),
);
if (hasSiblingInContent) return false;
return true;
})
? attachments.filter((a) => !content.includes(a.url))
: attachments;
if (!standalone.length) return null;
@@ -291,7 +276,6 @@ function CommentRow({
onSubmit={saveEdit}
onUploadFile={(file) => uploadWithToast(file, { issueId })}
debounceMs={100}
currentIssueId={issueId}
/>
</div>
<div className="flex items-center justify-between mt-2">
@@ -512,7 +496,6 @@ function CommentCard({
onSubmit={saveEdit}
onUploadFile={(file) => uploadWithToast(file, { issueId })}
debounceMs={100}
currentIssueId={issueId}
/>
</div>
<div className="flex items-center justify-between mt-2">

View File

@@ -1,10 +1,8 @@
"use client";
import { useRef, useState, useCallback } from "react";
import { ArrowUp, Loader2, Maximize2, Minimize2 } from "lucide-react";
import { useRef, useState } from "react";
import { ArrowUp, Loader2 } from "lucide-react";
import { Button } from "@multica/ui/components/ui/button";
import { Tooltip, TooltipTrigger, TooltipContent } from "@multica/ui/components/ui/tooltip";
import { cn } from "@multica/ui/lib/utils";
import { ContentEditor, type ContentEditorRef, useFileDropZone, FileDropOverlay } from "../../editor";
import { FileUploadButton } from "@multica/ui/components/common/file-upload-button";
import { useFileUpload } from "@multica/core/hooks/use-file-upload";
@@ -19,35 +17,29 @@ function CommentInput({ issueId, onSubmit }: CommentInputProps) {
const editorRef = useRef<ContentEditorRef>(null);
const [isEmpty, setIsEmpty] = useState(true);
const [submitting, setSubmitting] = useState(false);
const [isExpanded, setIsExpanded] = useState(false);
const uploadMapRef = useRef<Map<string, string>>(new Map());
const [attachmentIds, setAttachmentIds] = useState<string[]>([]);
const { uploadWithToast } = useFileUpload(api);
const { isDragOver, dropZoneProps } = useFileDropZone({
onDrop: (files) => files.forEach((f) => editorRef.current?.uploadFile(f)),
});
const handleUpload = useCallback(async (file: File) => {
const handleUpload = async (file: File) => {
const result = await uploadWithToast(file, { issueId });
if (result) {
uploadMapRef.current.set(result.link, result.id);
setAttachmentIds((prev) => [...prev, result.id]);
}
return result;
}, [uploadWithToast, issueId]);
};
const handleSubmit = async () => {
const content = editorRef.current?.getMarkdown()?.replace(/(\n\s*)+$/, "").trim();
if (!content || submitting) return;
// Only send attachment IDs for uploads still present in the content.
const activeIds: string[] = [];
for (const [url, id] of uploadMapRef.current) {
if (content.includes(url)) activeIds.push(id);
}
setSubmitting(true);
try {
await onSubmit(content, activeIds.length > 0 ? activeIds : undefined);
await onSubmit(content, attachmentIds.length > 0 ? attachmentIds : undefined);
editorRef.current?.clearContent();
setIsEmpty(true);
uploadMapRef.current.clear();
setAttachmentIds([]);
} finally {
setSubmitting(false);
}
@@ -56,10 +48,7 @@ function CommentInput({ issueId, onSubmit }: CommentInputProps) {
return (
<div
{...dropZoneProps}
className={cn(
"relative flex flex-col rounded-lg bg-card pb-8 ring-1 ring-border",
isExpanded ? "h-[70vh]" : "max-h-56",
)}
className="relative flex max-h-56 flex-col rounded-lg bg-card pb-8 ring-1 ring-border"
>
<div className="flex-1 min-h-0 overflow-y-auto px-3 py-2">
<ContentEditor
@@ -69,27 +58,9 @@ function CommentInput({ issueId, onSubmit }: CommentInputProps) {
onSubmit={handleSubmit}
onUploadFile={handleUpload}
debounceMs={100}
currentIssueId={issueId}
/>
</div>
<div className="absolute bottom-1 right-1.5 flex items-center gap-1">
<Tooltip>
<TooltipTrigger
render={
<button
type="button"
onClick={() => {
setIsExpanded((v) => !v);
editorRef.current?.focus();
}}
className="rounded-sm p-1.5 text-muted-foreground opacity-70 hover:opacity-100 hover:bg-accent/60 transition-all cursor-pointer"
>
{isExpanded ? <Minimize2 className="size-4" /> : <Maximize2 className="size-4" />}
</button>
}
/>
<TooltipContent side="top">{isExpanded ? "Collapse" : "Expand"}</TooltipContent>
</Tooltip>
<FileUploadButton
size="sm"
onSelect={(file) => editorRef.current?.uploadFile(file)}

View File

@@ -386,17 +386,17 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo
// Custom hooks — encapsulate timeline, reactions, subscribers
const {
timeline, submitComment, submitReply,
timeline, loading: timelineLoading, submitComment, submitReply,
editComment, deleteComment, toggleReaction: handleToggleReaction,
} = useIssueTimeline(id, user?.id);
const {
reactions: issueReactions,
reactions: issueReactions, loading: reactionsLoading,
toggleReaction: handleToggleIssueReaction,
} = useIssueReactions(id, user?.id);
const {
subscribers, isSubscribed, toggleSubscribe: handleToggleSubscribe, toggleSubscriber,
subscribers, loading: subscribersLoading, isSubscribed, toggleSubscribe: handleToggleSubscribe, toggleSubscriber,
} = useIssueSubscribers(id, user?.id);
// Token usage
@@ -499,44 +499,45 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo
if (loading) {
return (
<div className="flex flex-1 min-h-0 flex-col">
{/* Header skeleton */}
<div className="flex h-12 shrink-0 items-center gap-2 border-b px-4">
<Skeleton className="h-4 w-16" />
<Skeleton className="h-4 w-4" />
<Skeleton className="h-4 w-24" />
</div>
<div className="flex flex-1 min-h-0">
<div className="flex-1 overflow-y-auto">
<div className="mx-auto w-full max-w-4xl px-8 py-8 space-y-6">
<Skeleton className="h-8 w-3/4" />
<div className="space-y-2">
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-5/6" />
<Skeleton className="h-4 w-2/3" />
</div>
<Skeleton className="h-px w-full" />
<div className="space-y-3">
<Skeleton className="h-4 w-20" />
<div className="flex items-start gap-3">
<Skeleton className="h-8 w-8 shrink-0 rounded-full" />
<div className="flex-1 space-y-2">
<Skeleton className="h-4 w-32" />
<Skeleton className="h-16 w-full rounded-lg" />
</div>
{/* Content skeleton */}
<div className="flex-1 p-8 space-y-6">
<Skeleton className="h-8 w-3/4" />
<div className="space-y-2">
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-5/6" />
<Skeleton className="h-4 w-2/3" />
</div>
<Skeleton className="h-px w-full" />
<div className="space-y-3">
<Skeleton className="h-4 w-20" />
<div className="flex items-start gap-3">
<Skeleton className="h-8 w-8 rounded-full" />
<div className="flex-1 space-y-2">
<Skeleton className="h-4 w-32" />
<Skeleton className="h-16 w-full rounded-lg" />
</div>
</div>
</div>
</div>
<div className="hidden md:block w-80 border-l p-4 space-y-5">
{/* Sidebar skeleton */}
<div className="w-64 border-l p-4 space-y-4">
{Array.from({ length: 4 }).map((_, i) => (
<div key={i} className="flex items-center gap-2">
<Skeleton className="h-3 w-16 shrink-0" />
<div key={i} className="flex items-center justify-between">
<Skeleton className="h-3 w-16" />
<Skeleton className="h-5 w-24" />
</div>
))}
<Skeleton className="h-px w-full" />
{Array.from({ length: 3 }).map((_, i) => (
<div key={i} className="flex items-center gap-2">
<Skeleton className="h-3 w-16 shrink-0" />
<div key={i} className="flex items-center justify-between">
<Skeleton className="h-3 w-16" />
<Skeleton className="h-4 w-28" />
</div>
))}
@@ -697,12 +698,9 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo
<ChevronRight className="h-3 w-3 text-muted-foreground/50 shrink-0" />
</>
)}
<span className="shrink-0 text-muted-foreground">
<span className="shrink-0">
{issue.identifier}
</span>
<span className="truncate font-medium text-foreground">
{issue.title}
</span>
</div>
<div className="flex items-center gap-1 shrink-0">
<Tooltip>
@@ -1049,16 +1047,22 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo
onUpdate={(md) => handleUpdateField({ description: md })}
onUploadFile={handleDescriptionUpload}
debounceMs={1500}
currentIssueId={id}
/>
<div className="flex items-center gap-1 mt-3">
<ReactionBar
reactions={issueReactions}
currentUserId={user?.id}
onToggle={handleToggleIssueReaction}
getActorName={getActorName}
/>
{reactionsLoading ? (
<div className="flex items-center gap-1">
<Skeleton className="h-7 w-14 rounded-full" />
<Skeleton className="h-7 w-14 rounded-full" />
</div>
) : (
<ReactionBar
reactions={issueReactions}
currentUserId={user?.id}
onToggle={handleToggleIssueReaction}
getActorName={getActorName}
/>
)}
<FileUploadButton
size="sm"
onSelect={(file) => descEditorRef.current?.uploadFile(file)}
@@ -1192,6 +1196,15 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo
<h2 className="text-base font-semibold">Activity</h2>
</div>
<div className="flex items-center gap-2">
{subscribersLoading ? (
<div className="flex items-center gap-1">
<Skeleton className="h-4 w-16" />
<div className="flex -space-x-1">
<Skeleton className="h-6 w-6 rounded-full" />
<Skeleton className="h-6 w-6 rounded-full" />
</div>
</div>
) : (<>
<button
onClick={handleToggleSubscribe}
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
@@ -1269,23 +1282,34 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo
</Command>
</PopoverContent>
</Popover>
</>)}
</div>
</div>
{/* Agent live output — sticky inside the Activity section so it
stays pinned while scrolling through TaskRunHistory + comments.
Keyed by issue id so switching issues remounts the card and
clears any in-flight task state from the previous issue. */}
<AgentLiveCard key={id} issueId={id} />
stays pinned while scrolling through TaskRunHistory + comments. */}
<AgentLiveCard issueId={id} />
{/* Agent execution history */}
<div className="mt-3">
<TaskRunHistory key={id} issueId={id} />
<TaskRunHistory issueId={id} />
</div>
{/* Timeline entries */}
<div className="mt-4 flex flex-col gap-3">
{(() => {
{timelineLoading ? (
<div className="space-y-4">
{Array.from({ length: 3 }).map((_, i) => (
<div key={i} className="flex items-start gap-3 px-4">
<Skeleton className="h-8 w-8 rounded-full shrink-0" />
<div className="flex-1 space-y-2">
<Skeleton className="h-4 w-32" />
<Skeleton className="h-16 w-full rounded-lg" />
</div>
</div>
))}
</div>
) : (() => {
const topLevel = timeline.filter((e) => e.type === "activity" || !e.parent_id);
const repliesByParent = new Map<string, TimelineEntry[]>();
for (const e of timeline) {

View File

@@ -108,7 +108,7 @@ const mockViewState = {
includeNoProject: false,
sortBy: "position" as const,
sortDirection: "asc" as const,
cardProperties: { priority: true, description: true, assignee: true, dueDate: true, project: true, childProgress: true },
cardProperties: { priority: true, description: true, assignee: true, dueDate: true },
listCollapsedStatuses: [] as string[],
setViewMode: vi.fn(),
toggleStatusFilter: vi.fn(),
@@ -152,8 +152,6 @@ vi.mock("@multica/core/issues/stores/view-store", () => ({
{ key: "description", label: "Description" },
{ key: "assignee", label: "Assignee" },
{ key: "dueDate", label: "Due date" },
{ key: "project", label: "Project" },
{ key: "childProgress", label: "Sub-issue progress" },
],
}));

View File

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

View File

@@ -1,15 +1,11 @@
"use client";
import { memo } from "react";
import { useQuery } from "@tanstack/react-query";
import { AppLink } from "../../navigation";
import type { Issue } from "@multica/core/types";
import { ActorAvatar } from "../../common/actor-avatar";
import { useIssueSelectionStore } from "@multica/core/issues/stores/selection-store";
import { useWorkspacePaths } from "@multica/core/paths";
import { useWorkspaceId } from "@multica/core/hooks";
import { useViewStore } from "@multica/core/issues/stores/view-store-context";
import { projectListOptions } from "@multica/core/projects/queries";
import { PriorityIcon } from "./priority-icon";
import { ProgressRing } from "./progress-ring";
@@ -35,18 +31,6 @@ export const ListRow = memo(function ListRow({
const selected = useIssueSelectionStore((s) => s.selectedIds.has(issue.id));
const toggle = useIssueSelectionStore((s) => s.toggle);
const p = useWorkspacePaths();
const storeProperties = useViewStore((s) => s.cardProperties);
const wsId = useWorkspaceId();
const { data: projects = [] } = useQuery({
...projectListOptions(wsId),
enabled: storeProperties.project && !!issue.project_id,
});
const project = issue.project_id ? projects.find((pr) => pr.id === issue.project_id) : undefined;
const showProject = storeProperties.project && project;
const showChildProgress = storeProperties.childProgress && childProgress;
const showAssignee = storeProperties.assignee && issue.assignee_type && issue.assignee_id;
const showDueDate = storeProperties.dueDate && issue.due_date;
return (
<div
@@ -77,30 +61,24 @@ export const ListRow = memo(function ListRow({
</span>
<span className="flex min-w-0 flex-1 items-center gap-1.5">
<span className="truncate">{issue.title}</span>
{showChildProgress && (
{childProgress && (
<span className="inline-flex shrink-0 items-center gap-1 rounded-full bg-muted/60 px-1.5 py-0.5">
<ProgressRing done={childProgress!.done} total={childProgress!.total} size={14} />
<ProgressRing done={childProgress.done} total={childProgress.total} size={14} />
<span className="text-[11px] text-muted-foreground tabular-nums font-medium">
{childProgress!.done}/{childProgress!.total}
{childProgress.done}/{childProgress.total}
</span>
</span>
)}
</span>
{showProject && (
<span className="inline-flex shrink-0 items-center gap-1 text-xs text-muted-foreground max-w-[140px]">
<span aria-hidden="true" className="shrink-0">{project!.icon || "📁"}</span>
<span className="truncate">{project!.title}</span>
</span>
)}
{showDueDate && (
{issue.due_date && (
<span className="shrink-0 text-xs text-muted-foreground">
{formatDate(issue.due_date!)}
{formatDate(issue.due_date)}
</span>
)}
{showAssignee && (
{issue.assignee_type && issue.assignee_id && (
<ActorAvatar
actorType={issue.assignee_type!}
actorId={issue.assignee_id!}
actorType={issue.assignee_type}
actorId={issue.assignee_id}
size={20}
/>
)}

View File

@@ -1,10 +1,9 @@
"use client";
import { useRef, useState, useEffect, useCallback } from "react";
import { ArrowUp, Loader2, Maximize2, Minimize2 } from "lucide-react";
import { useRef, useState, useEffect } from "react";
import { ArrowUp, Loader2 } from "lucide-react";
import { ContentEditor, type ContentEditorRef, useFileDropZone, FileDropOverlay } from "../../editor";
import { FileUploadButton } from "@multica/ui/components/common/file-upload-button";
import { Tooltip, TooltipTrigger, TooltipContent } from "@multica/ui/components/ui/tooltip";
import { ActorAvatar } from "../../common/actor-avatar";
import { useFileUpload } from "@multica/core/hooks/use-file-upload";
import { api } from "@multica/core/api";
@@ -38,10 +37,9 @@ function ReplyInput({
const editorRef = useRef<ContentEditorRef>(null);
const measureRef = useRef<HTMLDivElement>(null);
const [isEmpty, setIsEmpty] = useState(true);
const [hasOverflowContent, setHasOverflowContent] = useState(false);
const [isExpanded, setIsExpanded] = useState(false);
const [submitting, setSubmitting] = useState(false);
const uploadMapRef = useRef<Map<string, string>>(new Map());
const [attachmentIds, setAttachmentIds] = useState<string[]>([]);
const { uploadWithToast } = useFileUpload(api);
const { isDragOver, dropZoneProps } = useFileDropZone({
onDrop: (files) => files.forEach((f) => editorRef.current?.uploadFile(f)),
@@ -52,34 +50,29 @@ function ReplyInput({
if (!el) return;
const observer = new ResizeObserver((entries) => {
const entry = entries[0];
if (entry) setHasOverflowContent(entry.contentRect.height > 32);
if (entry) setIsExpanded(entry.contentRect.height > 32);
});
observer.observe(el);
return () => observer.disconnect();
}, []);
const handleUpload = useCallback(async (file: File) => {
const handleUpload = async (file: File) => {
const result = await uploadWithToast(file, { issueId });
if (result) {
uploadMapRef.current.set(result.link, result.id);
setAttachmentIds((prev) => [...prev, result.id]);
}
return result;
}, [uploadWithToast, issueId]);
};
const handleSubmit = async () => {
const content = editorRef.current?.getMarkdown()?.replace(/(\n\s*)+$/, "").trim();
if (!content || submitting) return;
// Only send attachment IDs for uploads still present in the content.
const activeIds: string[] = [];
for (const [url, id] of uploadMapRef.current) {
if (content.includes(url)) activeIds.push(id);
}
setSubmitting(true);
try {
await onSubmit(content, activeIds.length > 0 ? activeIds : undefined);
await onSubmit(content, attachmentIds.length > 0 ? attachmentIds : undefined);
editorRef.current?.clearContent();
setIsEmpty(true);
uploadMapRef.current.clear();
setAttachmentIds([]);
} finally {
setSubmitting(false);
}
@@ -99,10 +92,8 @@ function ReplyInput({
{...dropZoneProps}
className={cn(
"relative min-w-0 flex-1 flex flex-col",
isExpanded
? "h-[60vh]"
: size === "sm" ? "max-h-40" : "max-h-56",
(hasOverflowContent || isExpanded) && "pb-7",
size === "sm" ? "max-h-40" : "max-h-56",
isExpanded && "pb-7",
)}
>
<div className="flex-1 min-h-0 overflow-y-auto pr-14">
@@ -114,28 +105,10 @@ function ReplyInput({
onSubmit={handleSubmit}
onUploadFile={handleUpload}
debounceMs={100}
currentIssueId={issueId}
/>
</div>
</div>
<div className="absolute bottom-0 right-0 flex items-center gap-1 text-muted-foreground transition-colors group-focus-within/editor:text-foreground">
<Tooltip>
<TooltipTrigger
render={
<button
type="button"
onClick={() => {
setIsExpanded((v) => !v);
editorRef.current?.focus();
}}
className="inline-flex h-6 w-6 items-center justify-center rounded-sm opacity-70 hover:opacity-100 hover:bg-accent/60 transition-all cursor-pointer"
>
{isExpanded ? <Minimize2 className="h-3.5 w-3.5" /> : <Maximize2 className="h-3.5 w-3.5" />}
</button>
}
/>
<TooltipContent side="top">{isExpanded ? "Collapse" : "Expand"}</TooltipContent>
</Tooltip>
<FileUploadButton
size="sm"
onSelect={(file) => editorRef.current?.uploadFile(file)}

View File

@@ -1,6 +1,6 @@
"use client";
import React, { useCallback, useEffect, useRef, useState } from "react";
import React, { useCallback, useEffect, useRef } from "react";
import { cn } from "@multica/ui/lib/utils";
import { AppLink, useNavigation } from "../navigation";
import {
@@ -78,16 +78,6 @@ import { useDeletePin, useReorderPins } from "@multica/core/pins/mutations";
import type { PinnedItem } from "@multica/core/types";
import { useLogout } from "../auth";
// Stable empty arrays for query defaults. Using an inline `= []` default on
// `useQuery` creates a new array reference on every render when `data` is
// undefined (e.g. query disabled or loading) — which in turn breaks any
// `useEffect`/`useMemo` that depends on the value, and can trigger infinite
// re-render loops when the effect itself calls `setState`.
const EMPTY_PINS: PinnedItem[] = [];
const EMPTY_WORKSPACES: Awaited<ReturnType<typeof api.listWorkspaces>> = [];
const EMPTY_INVITATIONS: Awaited<ReturnType<typeof api.listMyInvitations>> = [];
const EMPTY_INBOX: Awaited<ReturnType<typeof api.listInbox>> = [];
// Nav items reference WorkspacePaths method names so they can be resolved
// against the current workspace slug at render time (see AppSidebar body).
// Only parameterless paths are valid nav destinations.
@@ -149,7 +139,7 @@ function SortablePinItem({ pin, href, pathname, onUnpin }: { pin: PinnedItem; hr
<SidebarMenuButton
size="sm"
isActive={isActive}
render={<AppLink href={href} draggable={false} />}
render={<AppLink href={href} />}
onClick={(event) => {
if (wasDragged.current) {
wasDragged.current = false;
@@ -157,10 +147,7 @@ function SortablePinItem({ pin, href, pathname, onUnpin }: { pin: PinnedItem; hr
return;
}
}}
className={cn(
"text-muted-foreground hover:not-data-active:bg-sidebar-accent/70 data-active:bg-sidebar-accent data-active:text-sidebar-accent-foreground",
isDragging && "pointer-events-none",
)}
className="text-muted-foreground hover:not-data-active:bg-sidebar-accent/70 data-active:bg-sidebar-accent data-active:text-sidebar-accent-foreground"
>
{pin.item_type === "issue" && pin.status ? (
/* Override parent [&_svg]:size-4 — pinned items need smaller icons to match sm size */
@@ -212,11 +199,11 @@ export function AppSidebar({ topSlot, searchSlot, headerClassName, headerStyle }
const logout = useLogout();
const workspace = useCurrentWorkspace();
const p = useWorkspacePaths();
const { data: workspaces = EMPTY_WORKSPACES } = useQuery(workspaceListOptions());
const { data: myInvitations = EMPTY_INVITATIONS } = useQuery(myInvitationListOptions());
const { data: workspaces = [] } = useQuery(workspaceListOptions());
const { data: myInvitations = [] } = useQuery(myInvitationListOptions());
const wsId = workspace?.id;
const { data: inboxItems = EMPTY_INBOX } = useQuery({
const { data: inboxItems = [] } = useQuery({
queryKey: wsId ? inboxKeys.list(wsId) : ["inbox", "disabled"],
queryFn: () => api.listInbox(),
enabled: !!wsId,
@@ -226,42 +213,24 @@ export function AppSidebar({ topSlot, searchSlot, headerClassName, headerStyle }
[inboxItems],
);
const hasRuntimeUpdates = useMyRuntimesNeedUpdate(wsId);
const { data: pinnedItems = EMPTY_PINS } = useQuery({
const { data: pinnedItems = [] } = useQuery({
...pinListOptions(wsId ?? "", userId ?? ""),
enabled: !!wsId && !!userId,
});
const deletePin = useDeletePin();
const reorderPins = useReorderPins();
const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 5 } }));
// Local presentational copy of pinnedItems for drop-animation stability.
// Follows TQ at rest; frozen during a drag gesture so a mid-drag cache
// write (our own optimistic update, or a WS refetch) cannot reorder the
// DOM under dnd-kit while its drop animation is still interpolating.
const [localPinned, setLocalPinned] = useState<PinnedItem[]>(pinnedItems);
const isDraggingRef = useRef(false);
useEffect(() => {
if (!isDraggingRef.current) {
setLocalPinned(pinnedItems);
}
}, [pinnedItems]);
const handleDragStart = useCallback(() => {
isDraggingRef.current = true;
}, []);
const handleDragEnd = useCallback(
(event: DragEndEvent) => {
isDraggingRef.current = false;
const { active, over } = event;
if (!over || active.id === over.id) return;
const oldIndex = localPinned.findIndex((p) => p.id === active.id);
const newIndex = localPinned.findIndex((p) => p.id === over.id);
const oldIndex = pinnedItems.findIndex((p) => p.id === active.id);
const newIndex = pinnedItems.findIndex((p) => p.id === over.id);
if (oldIndex === -1 || newIndex === -1) return;
const reordered = arrayMove(localPinned, oldIndex, newIndex);
setLocalPinned(reordered);
const reordered = arrayMove(pinnedItems, oldIndex, newIndex);
reorderPins.mutate(reordered);
},
[localPinned, reorderPins],
[pinnedItems, reorderPins],
);
const queryClient = useQueryClient();
@@ -476,7 +445,7 @@ export function AppSidebar({ topSlot, searchSlot, headerClassName, headerStyle }
</SidebarGroupContent>
</SidebarGroup>
{localPinned.length > 0 && (
{pinnedItems.length > 0 && (
<Collapsible defaultOpen>
<SidebarGroup className="group/pinned">
<SidebarGroupLabel
@@ -485,14 +454,14 @@ export function AppSidebar({ topSlot, searchSlot, headerClassName, headerStyle }
>
<span>Pinned</span>
<ChevronRight className="!size-3 ml-1 stroke-[2.5] transition-transform duration-200 group-data-[panel-open]/trigger:rotate-90" />
<span className="ml-auto text-[10px] text-muted-foreground opacity-0 transition-opacity group-hover/pinned:opacity-100">{localPinned.length}</span>
<span className="ml-auto text-[10px] text-muted-foreground opacity-0 transition-opacity group-hover/pinned:opacity-100">{pinnedItems.length}</span>
</SidebarGroupLabel>
<CollapsibleContent>
<SidebarGroupContent>
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragStart={handleDragStart} onDragEnd={handleDragEnd}>
<SortableContext items={localPinned.map((p) => p.id)} strategy={verticalListSortingStrategy}>
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
<SortableContext items={pinnedItems.map((p) => p.id)} strategy={verticalListSortingStrategy}>
<SidebarMenu className="gap-0.5">
{localPinned.map((pin: PinnedItem) => (
{pinnedItems.map((pin: PinnedItem) => (
<SortablePinItem
key={pin.id}
pin={pin}

View File

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

View File

@@ -50,6 +50,7 @@
"@multica/core": "workspace:*",
"@multica/ui": "workspace:*",
"@tiptap/core": "^3.22.1",
"@tiptap/extension-bubble-menu": "^3.22.1",
"@tiptap/extension-code-block-lowlight": "^3.22.1",
"@tiptap/extension-document": "^3.22.1",
"@tiptap/extension-image": "^3.22.1",

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