Compare commits

..

3 Commits

Author SHA1 Message Date
Jiayuan Zhang
3f87b9fdf2 feat(desktop): rebrand Electron.app Info.plist so dev shows Multica Canary
app.setName() can't override the macOS menu bar title or Cmd+Tab label
— those come from CFBundleName baked into the running bundle's
Info.plist. Patch the bundled Electron.app's plist during `pnpm
dev:desktop` so dev launches read "Multica Canary" everywhere, not
"Electron". Idempotent; unlinks before rewriting so we don't mutate a
pnpm-store inode shared with other projects.
2026-04-17 01:19:46 +08:00
Jiayuan Zhang
cafc6f1969 feat(desktop): allow overriding renderer port via DESKTOP_RENDERER_PORT
Lets a second worktree run `pnpm dev:desktop` while a primary checkout
already holds the default Vite dev port 5173 — required to actually
exercise the "Multica Canary" branding in isolation.
2026-04-17 01:14:08 +08:00
Jiayuan Zhang
434aa5b859 feat(desktop): brand dev build as Multica Canary with bundled icon
pnpm dev:desktop ran under the stock Electron name and default icon,
making it indistinguishable from any other Electron dev app in the dock.
Set a Canary app name + userData path and point the macOS dock icon and
BrowserWindow icon at the bundled resources/icon.png so the dev build is
visually branded.
2026-04-17 01:00:19 +08:00
203 changed files with 2022 additions and 14675 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
@@ -470,63 +385,6 @@ multica config set app_url https://app.example.com
multica config set workspace_id <workspace-id>
```
## Autopilot Commands
Autopilots are scheduled/triggered automations that dispatch agent tasks (either by creating an issue or by running an agent directly).
### List Autopilots
```bash
multica autopilot list
multica autopilot list --status active --output json
```
### Get Autopilot Details
```bash
multica autopilot get <id>
multica autopilot get <id> --output json # includes triggers
```
### Create / Update / Delete
```bash
multica autopilot create \
--title "Nightly bug triage" \
--description "Scan todo issues and prioritize." \
--agent "Lambda" \
--mode create_issue
multica autopilot update <id> --status paused
multica autopilot update <id> --description "New prompt"
multica autopilot delete <id>
```
`--mode` currently only accepts `create_issue` (creates a new issue on each run and assigns it to the agent). The server data model also defines `run_only`, but the daemon task path doesn't yet resolve a workspace for runs without an issue, so it's not exposed by the CLI. `--agent` accepts either a name or UUID.
### Manual Trigger
```bash
multica autopilot trigger <id> # Fires the autopilot once, returns the run
```
### Run History
```bash
multica autopilot runs <id>
multica autopilot runs <id> --limit 50 --output json
```
### Schedule Triggers
```bash
multica autopilot trigger-add <autopilot-id> --cron "0 9 * * 1-5" --timezone "America/New_York"
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" };
});
@@ -123,14 +109,7 @@ function createWindow(): void {
// is derived from the userData path. (Same approach VS Code uses for
// Stable / Insiders coexistence.)
// DESKTOP_APP_SUFFIX lets parallel worktrees run dev Electron side-by-side
// without fighting for the shared single-instance lock. The suffix is
// appended to the app name + userData path, so each worktree gets its own
// lock file. Default (no env var) keeps behavior unchanged — the common
// single-worktree case still lands at "Multica Canary".
const DEV_APP_NAME = process.env.DESKTOP_APP_SUFFIX
? `Multica Canary ${process.env.DESKTOP_APP_SUFFIX}`
: "Multica Canary";
const DEV_APP_NAME = "Multica Canary";
if (is.dev) {
app.setName(DEV_APP_NAME);
@@ -184,13 +163,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,5 +1,6 @@
"use client";
import { useCallback, useState } from "react";
import Image from "next/image";
import Link from "next/link";
import { useAuthStore } from "@multica/core/auth";
@@ -10,6 +11,8 @@ import {
GeminiCliLogo,
OpenClawLogo,
OpenCodeLogo,
GitHubMark,
githubUrl,
heroButtonClassName,
} from "./shared";
@@ -63,14 +66,25 @@ export function LandingHero() {
</svg>
{t.hero.downloadDesktop}
</Link>
<Link
href={githubUrl}
target="_blank"
rel="noreferrer"
className={heroButtonClassName("ghost")}
>
<GitHubMark className="size-4" />
GitHub
</Link>
</div>
<InstallCommand />
</div>
<div className="mt-10 flex flex-wrap items-center justify-center gap-x-6 gap-y-3">
<div className="mt-10 flex items-center justify-center gap-8">
<span className="text-[15px] text-white/50">
{t.hero.worksWith}
</span>
<div className="flex flex-wrap items-center justify-center gap-x-5 gap-y-3">
<div className="flex items-center gap-6">
<div className="flex items-center gap-2.5 text-white/80">
<ClaudeCodeLogo className="size-5" />
<span className="text-[15px] font-medium">Claude Code</span>
@@ -103,6 +117,64 @@ export function LandingHero() {
);
}
const INSTALL_COMMAND =
"curl -fsSL https://raw.githubusercontent.com/multica-ai/multica/main/scripts/install.sh | bash";
function InstallCommand() {
const [copied, setCopied] = useState(false);
const handleCopy = useCallback(async () => {
try {
await navigator.clipboard.writeText(INSTALL_COMMAND);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch {
// ignore
}
}, []);
return (
<div className="mx-auto mt-6 max-w-fit">
<button
type="button"
onClick={handleCopy}
className="group flex items-center gap-3 rounded-lg border border-white/10 bg-white/5 px-4 py-2.5 font-mono text-[13px] text-white/70 backdrop-blur-sm transition-colors hover:border-white/20 hover:bg-white/8 hover:text-white/90"
>
<span className="text-white/40">$</span>
<span className="select-all">{INSTALL_COMMAND}</span>
<span className="ml-1 flex size-5 shrink-0 items-center justify-center text-white/40 transition-colors group-hover:text-white/70">
{copied ? (
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className="size-3.5 text-green-400"
>
<polyline points="20 6 9 17 4 12" />
</svg>
) : (
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className="size-3.5"
>
<rect x="9" y="9" width="13" height="13" rx="2" ry="2" />
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" />
</svg>
)}
</span>
</button>
</div>
);
}
function LandingBackdrop() {
return (
<div className="pointer-events-none absolute inset-0">
@@ -110,6 +182,7 @@ function LandingBackdrop() {
src="/images/landing-bg.jpg"
alt=""
fill
priority
className="object-cover object-center"
/>
</div>
@@ -125,7 +198,6 @@ function ProductImage({ alt }: { alt: string }) {
alt={alt}
width={3532}
height={2382}
priority
className="block h-auto w-full"
sizes="(max-width: 1320px) 100vw, 1320px"
quality={85}

View File

@@ -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

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

View File

@@ -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

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

View File

@@ -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

@@ -1,7 +1,7 @@
"use client";
import { useState, useEffect } from "react";
import { Zap, Play, Clock, Plus, Trash2, CheckCircle2, XCircle, Loader2, Pencil } from "lucide-react";
import { Zap, Play, Pause, Clock, Plus, Trash2, CheckCircle2, XCircle, Loader2, Pencil } from "lucide-react";
import { useQuery } from "@tanstack/react-query";
import { autopilotDetailOptions, autopilotRunsOptions } from "@multica/core/autopilots/queries";
import {
@@ -20,7 +20,6 @@ import { PageHeader } from "../../layout/page-header";
import { ActorAvatar } from "../../common/actor-avatar";
import { Skeleton } from "@multica/ui/components/ui/skeleton";
import { Button } from "@multica/ui/components/ui/button";
import { Switch } from "@multica/ui/components/ui/switch";
import { cn } from "@multica/ui/lib/utils";
import { toast } from "sonner";
import {
@@ -52,9 +51,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 +65,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 +384,9 @@ export function AutopilotDetailPage({ autopilotId }: { autopilotId: string }) {
if (isLoading) {
return (
<div className="flex h-full flex-col">
<div className="flex h-12 shrink-0 items-center gap-2 border-b px-5">
<Skeleton className="h-4 w-4" />
<span className="text-muted-foreground">/</span>
<Skeleton className="h-4 w-32" />
</div>
<div className="flex-1 overflow-y-auto">
<div className="max-w-4xl mx-auto p-6 space-y-8">
<section className="space-y-4">
<Skeleton className="h-3 w-20" />
<div className="grid grid-cols-2 gap-4">
<div className="space-y-1">
<Skeleton className="h-3 w-12" />
<Skeleton className="h-5 w-32" />
</div>
<div className="space-y-1">
<Skeleton className="h-3 w-12" />
<Skeleton className="h-5 w-24" />
</div>
</div>
</section>
<section className="space-y-3">
<Skeleton className="h-4 w-16" />
<Skeleton className="h-10 w-full rounded-md" />
</section>
<section className="space-y-3">
<Skeleton className="h-4 w-24" />
{Array.from({ length: 3 }).map((_, i) => (
<Skeleton key={i} className="h-10 w-full" />
))}
</section>
</div>
</div>
<div className="p-6 space-y-4">
<Skeleton className="h-8 w-64" />
<Skeleton className="h-40 w-full" />
</div>
);
}
@@ -451,8 +420,9 @@ export function AutopilotDetailPage({ autopilotId }: { autopilotId: string }) {
}
};
const handleToggleStatus = (checked: boolean) => {
updateAutopilot.mutate({ id: autopilotId, status: checked ? "active" : "paused" });
const handleToggleStatus = () => {
const newStatus = autopilot.status === "active" ? "paused" : "active";
updateAutopilot.mutate({ id: autopilotId, status: newStatus });
};
return (
@@ -465,29 +435,27 @@ export function AutopilotDetailPage({ autopilotId }: { autopilotId: string }) {
</AppLink>
<span className="text-muted-foreground">/</span>
<h1 className="text-sm font-medium truncate">{autopilot.title}</h1>
<div className="ml-1 flex items-center gap-1.5">
<Switch
size="sm"
checked={autopilot.status === "active"}
onCheckedChange={handleToggleStatus}
disabled={autopilot.status === "archived"}
aria-label={autopilot.status === "active" ? "Pause autopilot" : "Activate autopilot"}
/>
<span className={cn(
"text-xs font-medium capitalize",
autopilot.status === "active" ? "text-emerald-500" :
autopilot.status === "paused" ? "text-amber-500" :
"text-muted-foreground",
)}>
{autopilot.status}
</span>
</div>
<span className={cn(
"ml-1 inline-flex items-center gap-1 rounded px-1.5 py-0.5 text-xs font-medium",
autopilot.status === "active" ? "bg-emerald-500/10 text-emerald-500" :
autopilot.status === "paused" ? "bg-amber-500/10 text-amber-500" :
"bg-muted text-muted-foreground",
)}>
{autopilot.status}
</span>
</div>
<div className="flex items-center gap-2">
<Button size="sm" variant="outline" onClick={() => setEditDialogOpen(true)}>
<Pencil className="h-3.5 w-3.5 mr-1" />
Edit
</Button>
<Button size="sm" variant="outline" onClick={handleToggleStatus}>
{autopilot.status === "active" ? (
<><Pause className="h-3.5 w-3.5 mr-1" /> Pause</>
) : (
<><Play className="h-3.5 w-3.5 mr-1" /> Activate</>
)}
</Button>
<Button size="sm" onClick={handleRunNow} disabled={autopilot.status !== "active" || triggerAutopilot.isPending}>
<Play className="h-3.5 w-3.5 mr-1" />
{triggerAutopilot.isPending ? "Running..." : "Run now"}

View File

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

View File

@@ -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,11 +65,9 @@ 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">
<div className="rounded-lg border bg-card p-3.5 shadow-[0_1px_2px_0_rgba(0,0,0,0.03)] transition-shadow group-hover:shadow-sm">
{/* Row 1: Identifier */}
<p className="text-xs text-muted-foreground">{issue.identifier}</p>
@@ -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)}

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