Compare commits

..

8 Commits

Author SHA1 Message Date
Jiayuan Zhang
0529d28133 feat(terminal): Phase 4 — GC hook, audit log, issue runs entry (MUL-2295)
- terminal.Manager: OnSessionStart/OnSessionStop hooks fire around the
  manager's register/deregister so callers can safely mark/unmark
  external state (GC protection, audit rows) without racing Get/Done.
- daemon: wires the hooks to markActiveEnvRoot(filepath.Dir(workDir))
  so an idle terminal on a done/cancelled issue can't have its workdir
  reclaimed mid-session, and emits structured slog audit records
  (user_id/task_id/duration; no keystrokes — RFC §Auth).
- server: persists every PTY open/close to a new terminal_sessions
  table (migration 091) as the source behind the audit log and the
  new `type=terminal` rows in `multica issue runs`. New endpoint
  GET /api/issues/{id}/terminal-sessions.
- CLI: `multica issue runs` merges the agent task runs feed with the
  terminal sessions feed, sorted by started_at; terminal rows render
  with agent="terminal" and close_reason in the ERROR column. Old
  servers without the endpoint degrade silently.
- server/handler/terminal_ws.go: pass parsed Cols/Rows query values
  to sendOpenToDaemon so the first PTY frame matches the client's
  viewport; post-open resize is now just a defensive patch (addresses
  Emacs's Phase 3 non-blocking nit).

Co-authored-by: multica-agent <github@multica.ai>
2026-05-16 18:55:20 +08:00
Jiayuan Zhang
cd414a52ea feat(cli): multica issue terminal — attach via Phase 2 WS endpoint (MUL-2295)
Phase 3 of MUL-2295. Adds `multica issue terminal <issue-id>` which dials
the Phase 2 /ws/issues/{id}/terminal endpoint, performs first-frame auth
with the existing PAT/JWT, and runs an interactive PTY through the
daemon-side terminal manager from Phase 1. SIGWINCH on unix /
poll on windows pushes resize frames; ssh-style `<enter>~.` detaches.

Co-authored-by: multica-agent <github@multica.ai>
2026-05-16 18:32:00 +08:00
Jiayuan Zhang
f675f03fbb fix(daemon/terminal): Phase 2 review — cleanup order, backpressure, origin (MUL-2295)
Address Phase 2 Round 1 review blockers + security item:

1. Fold daemonws teardown into a single cleanup defer so the order is:
   cancel heartbeat → wait hbDone → clearWSWrites → close(writes) →
   wait writer. The previous LIFO defer ordering let close(writes) run
   before the terminal bridge tore down, so an in-flight terminal pump
   could panic on send-to-closed-channel.

2. Replace silent drop of terminal.data on a saturated daemonws writer
   with real backpressure: pump uses sendWSFrameCtx (blocking with ctx
   escape) and bridge.closeAll now waits for every pump goroutine to
   exit before returning, giving the wakeup loop a hard barrier before
   close(writes).

3. terminalUpgrader now reuses realtime.CheckOrigin instead of
   CheckOrigin: true. The terminal endpoint executes shells; it must be
   at least as strict as the read-only realtime WS.

Tests:
- TestTerminalBridge_DataBackpressureNoSilentDrop pins that a full
  writes channel never loses bytes.
- TestTerminalBridge_TeardownDoesNotPanicOnInFlightSend mirrors the
  wakeup defer sequence (closeAll → close(writes)) and asserts no panic.

Co-authored-by: multica-agent <github@multica.ai>
2026-05-16 18:05:40 +08:00
Jiayuan Zhang
0a97663acb feat(terminal): Phase 2 — daemonws routing + server proxy + Desktop xterm.js (MUL-2295)
Wires the Phase 1 terminal.Manager into the live daemonws transport so a
browser tab on the Multica Desktop app can attach to a PTY running in
the daemon's task workdir. End-to-end frame path:

  Desktop (xterm.js)  → /ws/issues/{id}/terminal (server proxy, cookie
                       or first-frame JWT auth, workspace membership
                       enforced before upgrade)
  → daemonws hub      (new SendToRuntime + TerminalRouter routing
                       terminal.* frames back to the proxy by
                       request_id, then re-keyed on session_id)
  → daemon            (new terminal_bridge.go owns one Manager per WS
                       connection, drains PtySession.Output() into
                       terminal.data frames, surfaces ExitC as
                       terminal.exit; closeAll on WS disconnect)
  → terminal.Manager.OpenWithInfo (new method) — server resolves
    task.work_dir/issue_id/prior_session_id from its DB and embeds
    them on the protocol; daemon trusts the server payload, no
    daemon-local task cache needed.

Auth + ACL:
- Browser proxy enforces workspace membership before the upgrade.
- TerminalOpenPayload carries the resolved task info; cross-workspace
  is structurally impossible at the bridge layer because both
  OpenParams.WorkspaceID and TaskInfo.WorkspaceID come from the same
  server-resolved field.
- terminal_bridge maps terminal.{Manager.OpenWithInfo} errors to the
  protocol error codes (workspace_mismatch / task_not_found /
  unsupported_os / spawn_failed / internal).

Resume:
- Server passes prior_session_id; daemon injects CLAUDE_SESSION_ID +
  MULTICA_{WORKSPACE,ISSUE,TASK,USER}_ID into the PTY env per the RFC.
  `claude --resume \$CLAUDE_SESSION_ID` continues the agent's session.

Lifecycle:
- Daemon installs a fresh terminalBridge per daemonws connection and
  tears every PtySession down on disconnect; session_ids minted on one
  WS cannot be reused on a reconnect because the server-side routing
  registration is also gone.
- daemonws client.send buffer raised from 16 to 256 and read limit
  from 4KB to 64KB so PTY traffic fits without evicting connections
  used for heartbeat / wakeup hints.

Desktop UI:
- packages/views/issues/components/terminal-panel.tsx renders an
  xterm.js console with FitAddon, base64 wire encoding, ResizeObserver
  → terminal.resize, reconnect button, and a clear web-only placeholder
  when window.desktopAPI is absent.
- TerminalPanelSection wrapper hangs collapsed in the issue-detail
  sidebar next to the execution log so bootstrap doesn't run for
  every issue view.

Tests:
- terminal/manager_test.go: OpenWithInfo happy path + cross-workspace
  reject (16 tests total, all -race clean).
- daemonws/terminal_test.go: TerminalRouter request_id → session_id
  re-keying and unknown-session drop.
- daemon/terminal_bridge_test.go: server-supplied workdir round-trips
  through OpenWithInfo, missing-workdir surfaces task_not_found and
  never spawns, data+exit round trip.
- GOOS=windows go test -c clean for both daemon and daemon/terminal.

Phase 1 / 2 / 3 / 4 mapping unchanged: Phase 3 (CLI) and Phase 4
(execenv GC hook + issue runs entry + audit log) are still untouched.

Co-authored-by: multica-agent <github@multica.ai>
2026-05-16 17:45:53 +08:00
Jiayuan Zhang
953fdd5003 fix(daemon/terminal): close re-entry barrier + reap orphan PTY (MUL-2295)
- Manager.Close concurrent re-entry now blocks late callers on closeDone
  so every Close() return shares the "manager drained" guarantee.
- Open cleanup path on lost race with Close calls pty.Wait() to reap the
  child synchronously (waitLoop never runs there).
- Tests: concurrent Close callers all observe drained state; Open cleanup
  invokes pty.Wait at least once.

Co-authored-by: multica-agent <github@multica.ai>
2026-05-16 17:10:22 +08:00
Jiayuan Zhang
e70f44b92b fix(daemon/terminal): lock Done()/Manager.Close finalize order (MUL-2295)
Round 2 review fixes:

1. PtySession finalize sequence is now
   ExitC -> close(output) -> onClose/deregister -> close(done)
   so external waiters (bridge / GC hook / audit) can `<-Done()` and
   immediately query the manager without a race window.

2. Manager.Close now waits for each session's Done() (not just Close())
   so by the time it returns the registry is empty and every session
   is fully finalized.

Adds TestSession_DoneFiresAfterDeregister (locks the ordering contract)
and TestManager_CloseWaitsForSessionFinalize (fakePTY.Wait delay proves
Manager.Close blocks through finalize).

Co-authored-by: multica-agent <github@multica.ai>
2026-05-16 17:00:02 +08:00
Jiayuan Zhang
281f1073b5 fix(daemon/terminal): address Phase 1 review feedback (MUL-2295)
Wires in the four fixes Emacs flagged on the Phase 1 review:

1. Lifecycle: split stop/done with a WaitGroup. readLoop and idleLoop
   exit via <-stop; waitLoop is the finalizer that waits on the WG
   before closing output/done. Eliminates the "send on closed channel"
   race when the output buffer is saturated. Adds a regression test
   that fills output, calls Close, and verifies Done converges + ExitC
   fires before output closes (the doc contract).

2. Errors: Manager.Open wraps spawner errors with double-%w so
   errors.Is matches both ErrSpawnFailed and ErrUnsupportedOS. Adds a
   test with a fake spawner that returns ErrUnsupportedOS.

3. Close path on unix: SIGHUP to the process group, 250ms grace,
   SIGKILL, then close fd — comment now matches behavior. Skips the
   signal+sleep work entirely when the child already exited naturally.
   Manager.Close fans out per-session Close in parallel so the grace
   period doesn't multiply by session count.

4. IdleTimeout semantics: removes the NewManager default that
   silently rewrote 0 to 60min. Zero/negative now disables, per the
   doc comment. Added DefaultIdleTimeout for daemon wiring to opt in
   explicitly.

Verified: go test, go test -race, GOOS=windows go test -c.
Co-authored-by: multica-agent <github@multica.ai>
2026-05-16 16:48:11 +08:00
Jiayuan Zhang
6758feba05 feat(daemon): add terminal Manager + PTY session (Phase 1, MUL-2295)
Daemon-side foundation for the Issue → Terminal feature. Manager owns
the lifecycle of all live PtySessions; sessions spawn a shell on a real
PTY via creack/pty (unix-only — Windows returns ErrUnsupportedOS until
ConPty support lands).

Open enforces the cross-workspace ACL — a client acting in workspace A
cannot attach to a task that belongs to workspace B. Each session
injects CLAUDE_SESSION_ID + MULTICA_{WORKSPACE,ISSUE,TASK,USER}_ID into
the child env so `claude --resume $CLAUDE_SESSION_ID` continues the
same session the agent run was using.

Adds the terminal.* WebSocket message types to server/pkg/protocol so
Phase 2 (daemonws routing) and Phase 3 (CLI) can land without touching
the manager.

Tests cover open, data round-trip, resize, explicit close, idle timeout
sweep, manager shutdown, cross-workspace rejection, and unknown task.
A fake Spawner backed by channels lets tests exercise lifecycle without
forking a real shell.

Co-authored-by: multica-agent <github@multica.ai>
2026-05-16 16:32:39 +08:00
404 changed files with 10945 additions and 30621 deletions

View File

@@ -1,39 +0,0 @@
---
name: web-design-guidelines
description: Review UI code for Web Interface Guidelines compliance. Use when asked to "review my UI", "check accessibility", "audit design", "review UX", or "check my site against best practices".
metadata:
author: vercel
version: "1.0.0"
argument-hint: <file-or-pattern>
---
# Web Interface Guidelines
Review files for compliance with Web Interface Guidelines.
## How It Works
1. Fetch the latest guidelines from the source URL below
2. Read the specified files (or prompt user for files/pattern)
3. Check against all rules in the fetched guidelines
4. Output findings in the terse `file:line` format
## Guidelines Source
Fetch fresh guidelines before each review:
```
https://raw.githubusercontent.com/vercel-labs/web-interface-guidelines/main/command.md
```
Use WebFetch to retrieve the latest rules. The fetched content contains all the rules and output format instructions.
## Usage
When a user provides a file or pattern argument:
1. Fetch guidelines from the source URL above
2. Read the specified files
3. Apply all rules from the fetched guidelines
4. Output findings using the format specified in the guidelines
If no files specified, ask the user which files to review.

View File

@@ -29,22 +29,6 @@ PORT=8080
JWT_SECRET=change-me-in-production
MULTICA_SERVER_URL=ws://localhost:8080/ws
MULTICA_APP_URL=http://localhost:3000
# Public URL the API is reachable at from the open internet (no trailing
# slash). Used to mint absolute webhook URLs for autopilot webhook
# triggers. Leave unset behind a same-origin reverse proxy or for plain
# localhost dev — the frontend will compose the URL from
# window.origin + webhook_path in that case. Headers are intentionally
# not used to derive this value, to avoid Host / X-Forwarded-Host
# spoofing when a self-hosted reverse proxy is not hardened.
MULTICA_PUBLIC_URL=
# Comma-separated CIDR list of reverse proxies whose X-Forwarded-For /
# X-Real-IP headers the per-IP webhook rate limiter is allowed to trust.
# Empty (the default) means "trust no headers" — the limiter uses
# r.RemoteAddr only, which is the safe shape when the backend is
# exposed directly. Set this when running behind nginx/Caddy/Cloudflare:
# e.g. "127.0.0.1/32" for a same-host reverse proxy, or the CDN's
# announced ranges for cloud deployments.
MULTICA_TRUSTED_PROXIES=
MULTICA_DAEMON_CONFIG=
MULTICA_WORKSPACE_ID=
MULTICA_DAEMON_ID=
@@ -112,13 +96,6 @@ CLOUDFRONT_DOMAIN=
# attribute and browsers silently drop such cookies.
COOKIE_DOMAIN=
# AUTH_TOKEN_TTL — auth token lifetime. Accepts Go duration strings (e.g.
# "8760h", "720h30m") or plain integer seconds.
# Default: 2592000 (30 days). Self-hosted deployments on trusted networks can
# set a longer value to reduce re-authentication frequency.
# Note: longer TTL = longer exposure window if a cookie is leaked.
# AUTH_TOKEN_TTL=2592000
# Local file storage (fallback when S3_BUCKET is not set)
LOCAL_UPLOAD_DIR=./data/uploads
LOCAL_UPLOAD_BASE_URL=http://localhost:8080
@@ -126,30 +103,8 @@ LOCAL_UPLOAD_BASE_URL=http://localhost:8080
# Security
# Comma-separated list of allowed origins for CORS and WebSocket connections.
# Defaults to localhost dev origins when unset.
# Example: CORS_ALLOWED_ORIGINS=https://app.multica.ai,https://staging.multica.ai
CORS_ALLOWED_ORIGINS=
# ==================== Rate limiting (optional Redis) ====================
# Per-IP fixed-window rate limiter on the public auth endpoints
# (/auth/send-code, /auth/verify-code, /auth/google). Backed by Redis.
# When REDIS_URL is unset the limiter is a no-op (fail-open) and the
# backend logs "rate limiting disabled: REDIS_URL not configured" at
# startup. The same REDIS_URL is reused by the realtime fan-out hub,
# the PAT cache, and the daemon-token cache.
# REDIS_URL=redis://localhost:6379/0
# Max requests per IP per minute. Defaults are 5 for send-code/google
# and 20 for verify-code.
# RATE_LIMIT_AUTH=5
# RATE_LIMIT_AUTH_VERIFY=20
# Comma-separated CIDRs whose X-Forwarded-For the auth limiter is
# allowed to trust. Empty (default) = never trust XFF, only RemoteAddr.
# REQUIRED behind a reverse proxy — otherwise every real user shares
# the proxy IP and the whole deployment lands in one bucket, turning
# /auth/send-code into 5 req/min site-wide. Use e.g. "127.0.0.1/32,::1/128"
# for same-host Caddy/Nginx, or the CDN's published ranges for ALB/CF.
# This is a separate list from MULTICA_TRUSTED_PROXIES above (which
# governs the autopilot webhook limiter).
# RATE_LIMIT_TRUSTED_PROXIES=
# Example: ALLOWED_ORIGINS=https://app.multica.ai,https://staging.multica.ai
ALLOWED_ORIGINS=
# Realtime metrics endpoint (/health/realtime) access control. See MUL-1342.
# When unset, the endpoint only serves direct loopback (127.0.0.1 / ::1)

View File

@@ -269,37 +269,21 @@ Each profile gets its own config directory (`~/.multica/profiles/<name>/`), daem
## Workspaces
### Working with multiple workspaces
Every command runs against a single workspace. The CLI resolves which one in this order (highest priority first):
1. `--workspace-id <id>` flag on the command
2. `MULTICA_WORKSPACE_ID` environment variable
3. The default workspace stored in your current profile (set by `multica workspace switch` or `multica login`)
`multica workspace switch <id|slug>` is the day-to-day way to change the default workspace. For scripting and headless setups where you don't want any stored state, prefer the `--workspace-id` flag or the env variable. `multica config set workspace_id <id>` is the low-level equivalent of `switch` (it writes the same setting but skips the access check).
If you need full isolation between organizations or accounts — separate tokens, separate daemons, separate config dirs — use `--profile <name>` instead. Each profile keeps its own default workspace.
### List Workspaces
```bash
multica workspace list
multica workspace list --full-id
multica workspace list --output json
```
The current default workspace is marked with `*`. Table output shows short UUID prefixes — pass `--full-id` when you need the canonical UUIDs.
Watched workspaces are marked with `*`. The daemon only processes tasks for watched workspaces.
### Switch Default Workspace
### Watch / Unwatch
```bash
multica workspace switch <workspace-id>
multica workspace switch <slug>
multica workspace watch <workspace-id>
multica workspace unwatch <workspace-id>
```
Verifies you have access to the workspace, then sets it as the default for the current profile. Subsequent commands without `--workspace-id` and `MULTICA_WORKSPACE_ID` target this workspace. Pair `--profile` if you want to change a non-default profile's workspace.
### Get Details
```bash
@@ -307,12 +291,10 @@ multica workspace get <workspace-id>
multica workspace get <workspace-id> --output json
```
Passing no `<workspace-id>` resolves to the current default workspace, so `multica workspace get` doubles as "what workspace am I on?".
### List Members
```bash
multica workspace member list <workspace-id>
multica workspace members <workspace-id>
```
## Issues
@@ -344,7 +326,7 @@ multica issue create --title "Fix login bug" --description "..." --priority high
multica issue create --title "Fix login bug" --assignee-id 5fb87ac7-23b5-4a7a-81fa-ed295a54545d
```
Flags: `--title` (required), `--description`, `--status`, `--priority`, `--assignee` / `--assignee-id`, `--parent`, `--project`, `--due-date`. Pass `--assignee-id <uuid>` (mutually exclusive with `--assignee`) when scripting against the IDs returned by `multica workspace member list --output json` / `multica agent list --output json`.
Flags: `--title` (required), `--description`, `--status`, `--priority`, `--assignee` / `--assignee-id`, `--parent`, `--project`, `--due-date`. Pass `--assignee-id <uuid>` (mutually exclusive with `--assignee`) when scripting against the IDs returned by `multica workspace members --output json` / `multica agent list --output json`.
### Update Issue
@@ -526,8 +508,6 @@ multica config set app_url https://app.example.com
multica config set workspace_id <workspace-id>
```
`config set workspace_id <id>` is the low-level interface — it writes the value verbatim without checking that the workspace exists or that you have access. Prefer `multica workspace switch <id|slug>` for day-to-day workspace changes; it does both checks before saving.
## Autopilot Commands
Autopilots are scheduled/triggered automations that dispatch agent tasks (either by creating an issue or by running an agent directly).

View File

@@ -142,8 +142,6 @@ The `multica` CLI connects your local machine to Multica — authenticate, manag
| `multica daemon status` | Check daemon status |
| `multica setup` | One-command setup for Multica Cloud (configure + login + start daemon) |
| `multica setup self-host` | Same, but for self-hosted deployments |
| `multica workspace list` | List your workspaces (current is marked with `*`) |
| `multica workspace switch <id\|slug>` | Switch the default workspace for this profile |
| `multica issue list` | List issues in your workspace |
| `multica issue create` | Create a new issue |
| `multica update` | Update to the latest version |

View File

@@ -7,7 +7,6 @@ import { setupAutoUpdater } from "./updater";
import { setupDaemonManager } from "./daemon-manager";
import { openExternalSafely, downloadURLSafely } from "./external-url";
import { installContextMenu } from "./context-menu";
import { handleAppShortcut } from "./keyboard-shortcuts";
import { getAppVersion } from "./app-version";
import { loadRuntimeConfig } from "./runtime-config-loader";
import type { RuntimeConfigResult } from "../shared/runtime-config";
@@ -190,67 +189,22 @@ function createWindow(): void {
return { action: "deny" };
});
// Window-level keyboard shortcuts. Calling preventDefault here prevents
// both the renderer keydown AND the application menu accelerator, so
// anything we own here (reload-block, zoom) is the sole handler for
// that combination — no double-fire with the macOS default View menu.
mainWindow.webContents.on("before-input-event", (event, input) => {
if (handleAppShortcut(input, mainWindow!.webContents)) {
event.preventDefault();
// Prevent Cmd+R / Ctrl+R / Shift+Cmd+R / Shift+Ctrl+R / F5 from
// reloading the page. In a desktop app an accidental reload destroys
// in-memory state (tabs, drafts, WS connections) with no URL bar to
// navigate back. DevTools refresh (via the DevTools UI) still works.
mainWindow.webContents.on("before-input-event", (_event, input) => {
if (input.type !== "keyDown") return;
const cmdOrCtrl =
process.platform === "darwin" ? input.meta : input.control;
if (
(cmdOrCtrl && input.key.toLowerCase() === "r") ||
input.key === "F5"
) {
_event.preventDefault();
}
});
// Dev-mode renderer diagnostics. When the renderer crashes hard enough
// that DevTools can't be opened (white screen with no clickable surface),
// the only way to recover the actual JS error is to forward it from the
// main process to the terminal running `make dev`. Without these, the
// user sees only the daemon-manager polling noise (`Render frame was
// disposed before WebFrameMain could be accessed`) which is a downstream
// symptom, not the cause.
//
// Gated by `is.dev` to keep production stderr clean — packaged builds
// don't have a terminal anyway, and we ship to crash-reporting separately.
if (is.dev) {
const log = (tag: string, ...args: unknown[]) =>
process.stderr.write(`[renderer ${tag}] ${args.map(String).join(" ")}\n`);
// Forward every renderer-side console.* call. The detail object also
// carries source URL + line — included so a thrown stack trace from
// window.onerror is traceable back to a file.
mainWindow.webContents.on("console-message", (details) => {
const { level, message, sourceId, lineNumber } = details;
log(level, `${message} (${sourceId}:${lineNumber})`);
});
// Fires when the renderer process dies for any reason (OOM, crash,
// killed). `details.reason` is the discriminator: "crashed", "oom",
// "killed", "abnormal-exit", "launch-failed", etc.
mainWindow.webContents.on("render-process-gone", (_event, details) => {
log("process-gone", JSON.stringify(details));
});
// Fires when loadURL / loadFile can't reach its target (dev server
// not up yet, network blip, file missing). errorCode is a Chromium
// net error number; -3 = ABORTED is normal during HMR and skipped.
mainWindow.webContents.on(
"did-fail-load",
(_event, errorCode, errorDescription, validatedURL, isMainFrame) => {
if (errorCode === -3) return;
log(
"did-fail-load",
`code=${errorCode} desc=${errorDescription} url=${validatedURL} mainFrame=${isMainFrame}`,
);
},
);
// Fires when the preload script throws before the renderer can boot.
// This is the one error class that NEVER reaches DevTools (preload
// runs before any window) — without this listener it's invisible.
mainWindow.webContents.on("preload-error", (_event, preloadPath, error) => {
log("preload-error", `path=${preloadPath} err=${error?.stack ?? error}`);
});
}
installContextMenu(mainWindow.webContents);
if (is.dev && process.env["ELECTRON_RENDERER_URL"]) {

View File

@@ -1,152 +0,0 @@
import { describe, expect, it, vi } from "vitest";
import { handleAppShortcut, type ShortcutInput } from "./keyboard-shortcuts";
function makeWc(initialLevel = 0) {
let level = initialLevel;
return {
getZoomLevel: vi.fn(() => level),
setZoomLevel: vi.fn((next: number) => {
level = next;
}),
currentLevel: () => level,
};
}
function key(
k: string,
mods: Partial<Pick<ShortcutInput, "control" | "meta">> = {},
): ShortcutInput {
return {
type: "keyDown",
key: k,
control: false,
meta: false,
...mods,
};
}
describe("handleAppShortcut — reload blocking", () => {
it("swallows Cmd+R on macOS", () => {
const wc = makeWc();
expect(handleAppShortcut(key("r", { meta: true }), wc, "darwin")).toBe(true);
expect(wc.setZoomLevel).not.toHaveBeenCalled();
});
it("swallows Ctrl+R on Linux/Windows", () => {
const wc = makeWc();
expect(handleAppShortcut(key("r", { control: true }), wc, "linux")).toBe(true);
expect(handleAppShortcut(key("R", { control: true }), wc, "win32")).toBe(true);
});
it("swallows F5 regardless of modifier", () => {
const wc = makeWc();
expect(handleAppShortcut(key("F5"), wc, "darwin")).toBe(true);
});
it("ignores non-keyDown events", () => {
const wc = makeWc();
expect(
handleAppShortcut({ ...key("r", { meta: true }), type: "keyUp" }, wc, "darwin"),
).toBe(false);
});
});
describe("handleAppShortcut — zoom in", () => {
it("zooms in on Cmd+= (unshifted)", () => {
const wc = makeWc(0);
expect(handleAppShortcut(key("=", { meta: true }), wc, "darwin")).toBe(true);
expect(wc.currentLevel()).toBe(0.5);
});
it("zooms in on Cmd++ (Shift+=)", () => {
const wc = makeWc(0);
expect(handleAppShortcut(key("+", { meta: true }), wc, "darwin")).toBe(true);
expect(wc.currentLevel()).toBe(0.5);
});
it("zooms in on Ctrl+= on non-mac", () => {
const wc = makeWc(0);
expect(handleAppShortcut(key("=", { control: true }), wc, "linux")).toBe(true);
expect(wc.currentLevel()).toBe(0.5);
});
it("does nothing without Cmd/Ctrl", () => {
const wc = makeWc(0);
expect(handleAppShortcut(key("="), wc, "darwin")).toBe(false);
expect(wc.setZoomLevel).not.toHaveBeenCalled();
});
it("clamps zoom-in at the upper bound", () => {
const wc = makeWc(4.5);
expect(handleAppShortcut(key("=", { meta: true }), wc, "darwin")).toBe(true);
expect(wc.currentLevel()).toBe(4.5);
});
});
describe("handleAppShortcut — zoom out (regression: MUL-2354)", () => {
it("zooms out on Cmd+- (unshifted)", () => {
const wc = makeWc(1);
expect(handleAppShortcut(key("-", { meta: true }), wc, "darwin")).toBe(true);
expect(wc.currentLevel()).toBe(0.5);
});
it("zooms out on Cmd+_ (Shift+-)", () => {
const wc = makeWc(1);
expect(handleAppShortcut(key("_", { meta: true }), wc, "darwin")).toBe(true);
expect(wc.currentLevel()).toBe(0.5);
});
it("zooms out on Ctrl+- on non-mac", () => {
const wc = makeWc(1);
expect(handleAppShortcut(key("-", { control: true }), wc, "win32")).toBe(true);
expect(wc.currentLevel()).toBe(0.5);
});
it("undoes a prior Cmd+= so the user can return to 100%", () => {
const wc = makeWc(0);
handleAppShortcut(key("=", { meta: true }), wc, "darwin");
expect(wc.currentLevel()).toBe(0.5);
handleAppShortcut(key("-", { meta: true }), wc, "darwin");
expect(wc.currentLevel()).toBe(0);
});
it("clamps zoom-out at the lower bound", () => {
const wc = makeWc(-3);
expect(handleAppShortcut(key("-", { meta: true }), wc, "darwin")).toBe(true);
expect(wc.currentLevel()).toBe(-3);
});
it("does nothing without Cmd/Ctrl", () => {
const wc = makeWc(1);
expect(handleAppShortcut(key("-"), wc, "darwin")).toBe(false);
expect(wc.setZoomLevel).not.toHaveBeenCalled();
});
});
describe("handleAppShortcut — reset zoom", () => {
it("resets to 0 on Cmd+0", () => {
const wc = makeWc(2);
expect(handleAppShortcut(key("0", { meta: true }), wc, "darwin")).toBe(true);
expect(wc.currentLevel()).toBe(0);
});
it("resets to 0 on Ctrl+0", () => {
const wc = makeWc(-1.5);
expect(handleAppShortcut(key("0", { control: true }), wc, "linux")).toBe(true);
expect(wc.currentLevel()).toBe(0);
});
it("ignores plain 0 without modifier", () => {
const wc = makeWc(2);
expect(handleAppShortcut(key("0"), wc, "darwin")).toBe(false);
expect(wc.setZoomLevel).not.toHaveBeenCalled();
});
});
describe("handleAppShortcut — unrelated keys pass through", () => {
it("does not capture plain letters", () => {
const wc = makeWc();
expect(handleAppShortcut(key("a", { meta: true }), wc, "darwin")).toBe(false);
expect(handleAppShortcut(key("k", { meta: true }), wc, "darwin")).toBe(false);
});
});

View File

@@ -1,74 +0,0 @@
import type { WebContents } from "electron";
// Shape of the input subset we read from Electron's `before-input-event`.
// Modeled as a structural type so the handler is unit-testable without a
// real Electron Input instance.
export type ShortcutInput = {
type: string;
key: string;
control: boolean;
meta: boolean;
};
// Subset of WebContents the zoom handler needs. Keeps the test mock tiny.
export type ZoomTarget = Pick<WebContents, "getZoomLevel" | "setZoomLevel">;
// Match Electron's built-in zoomIn/zoomOut roles (Chromium default of 0.5
// per step). Clamp to a range that keeps the UI legible — values outside
// this band turn the workspace into either confetti or a microfiche.
const ZOOM_STEP = 0.5;
const ZOOM_MIN = -3;
const ZOOM_MAX = 4.5;
/**
* Inspect a `before-input-event` key and apply (or block) the matching
* window-level shortcut. Returns `true` when the caller should call
* `event.preventDefault()` — that both swallows the renderer keydown and
* prevents the application menu accelerator from firing, so we don't
* double-trigger zoom on macOS where the default menu also binds these
* keys.
*
* Why we don't rely on the menu's `zoomIn` / `zoomOut` roles: on macOS the
* default `Cmd+-` accelerator does not fire reliably across keyboard
* layouts (issue MUL-2354 — Cmd+= zooms in but Cmd+- doesn't undo it).
* Handling the shortcuts here gives identical behavior on every platform
* and every layout.
*/
export function handleAppShortcut(
input: ShortcutInput,
webContents: ZoomTarget,
platform: NodeJS.Platform = process.platform,
): boolean {
if (input.type !== "keyDown") return false;
const cmdOrCtrl = platform === "darwin" ? input.meta : input.control;
// Block reload — accidental Cmd+R / Ctrl+R / F5 destroys in-memory state
// (tabs, drafts, WS connections) with no URL bar to recover from.
if ((cmdOrCtrl && input.key.toLowerCase() === "r") || input.key === "F5") {
return true;
}
if (!cmdOrCtrl) return false;
// Cmd/Ctrl + "=" (unshifted) or "+" (Shift+=) → zoom in.
if (input.key === "=" || input.key === "+") {
const next = Math.min(webContents.getZoomLevel() + ZOOM_STEP, ZOOM_MAX);
webContents.setZoomLevel(next);
return true;
}
// Cmd/Ctrl + "-" (unshifted) or "_" (Shift+-) → zoom out.
if (input.key === "-" || input.key === "_") {
const next = Math.max(webContents.getZoomLevel() - ZOOM_STEP, ZOOM_MIN);
webContents.setZoomLevel(next);
return true;
}
// Cmd/Ctrl + 0 → reset zoom to 100%.
if (input.key === "0") {
webContents.setZoomLevel(0);
return true;
}
return false;
}

View File

@@ -4,6 +4,7 @@ import {
Play,
Square,
RotateCw,
Server,
Activity,
ScrollText,
} from "lucide-react";
@@ -11,7 +12,15 @@ import { useQuery } from "@tanstack/react-query";
import { useWorkspaceId } from "@multica/core/hooks";
import { runtimeListOptions } from "@multica/core/runtimes";
import { agentTaskSnapshotOptions } from "@multica/core/agents";
import { cn } from "@multica/ui/lib/utils";
import { Button } from "@multica/ui/components/ui/button";
import {
Card,
CardAction,
CardDescription,
CardHeader,
CardTitle,
} from "@multica/ui/components/ui/card";
import {
Dialog,
DialogContent,
@@ -23,13 +32,24 @@ import {
import { toast } from "sonner";
import { DaemonPanel } from "./daemon-panel";
import type { DaemonStatus } from "../../../shared/daemon-types";
import { DAEMON_STATE_LABELS } from "../../../shared/daemon-types";
import {
DAEMON_STATE_COLORS,
DAEMON_STATE_LABELS,
daemonStateDescription,
formatUptime,
} from "../../../shared/daemon-types";
/**
* Desktop-only controls for the daemon embedded in this Electron app. The
* shared runtimes page renders this inside the selected local machine header.
* Header card on the desktop Runtimes page that surfaces the daemon embedded
* in this Electron app. The same daemon process registers N runtimes with the
* server (one per detected CLI), which appear in the runtime list below — so
* this card is the parent control surface for "what's running on this Mac".
*
* Why this lives only on desktop: web users don't have an embedded daemon;
* they bring their own (CLI-launched or remote VM) and just see runtimes in
* the list. The `desktop-runtimes-page` wrapper is the only mount point.
*/
export function DaemonRuntimeActions() {
export function DaemonRuntimeCard() {
const [status, setStatus] = useState<DaemonStatus>({ state: "stopped" });
const [panelOpen, setPanelOpen] = useState(false);
const [actionLoading, setActionLoading] = useState(false);
@@ -37,8 +57,14 @@ export function DaemonRuntimeActions() {
const wsId = useWorkspaceId();
const { data: runtimes = [] } = useQuery(runtimeListOptions(wsId));
// Snapshot also includes each agent's latest terminal; the filter below
// drops anything that isn't running/dispatched, so terminal rows pass
// through harmlessly.
const { data: snapshot = [] } = useQuery(agentTaskSnapshotOptions(wsId));
// Set of runtime IDs registered by THIS daemon (one per detected CLI).
// Used both to count "how many CLIs am I contributing" and to figure
// out which active tasks would be impacted by a Stop.
const localRuntimeIds = useMemo(() => {
if (!status.daemonId) return new Set<string>();
return new Set(
@@ -50,6 +76,10 @@ export function DaemonRuntimeActions() {
const runtimeCount = localRuntimeIds.size;
// Tasks that are actually doing work on this daemon right now —
// running or dispatched. Queued tasks haven't claimed a runtime yet,
// so stopping the daemon won't break them (they'll wait for any
// available daemon). The number drives the Stop-confirmation dialog.
const affectedTasks = useMemo(
() =>
snapshot.filter(
@@ -78,6 +108,9 @@ export function DaemonRuntimeActions() {
}
}, []);
// The actual stop call, separated from the click handler so we can call
// it both from the direct path (no active tasks) and from the confirm
// dialog's confirm button.
const performStop = useCallback(async () => {
setActionLoading(true);
const result = await window.daemonAPI.stop();
@@ -86,6 +119,8 @@ export function DaemonRuntimeActions() {
}
}, []);
// Click on the Stop button. If there's nothing running, just stop;
// otherwise pop a confirm dialog explaining the blast radius.
const handleStopClick = useCallback(() => {
if (affectedTasks.length === 0) {
void performStop();
@@ -101,6 +136,9 @@ export function DaemonRuntimeActions() {
toast.error("Failed to restart daemon", { description: result.error });
return;
}
// Success feedback — the daemon takes a few seconds to come back online,
// and the only other UI signal is the state badge flipping briefly. A
// toast confirms the click was received and tells the user what to expect.
toast.success("Restarting daemon", {
description: "Runtimes will be back online in a few seconds.",
});
@@ -124,64 +162,106 @@ export function DaemonRuntimeActions() {
return (
<>
<div className="flex flex-wrap items-center justify-end gap-1.5">
{isRunning && (
<>
<Button size="sm" variant="ghost" onClick={() => setPanelOpen(true)}>
<ScrollText className="size-3.5 mr-1.5" />
View logs
</Button>
<Button
size="sm"
variant="outline"
onClick={handleRestart}
disabled={actionLoading}
>
<RotateCw className="size-3.5 mr-1.5" />
Restart
</Button>
<Button
size="sm"
variant="destructive"
onClick={handleStopClick}
disabled={actionLoading}
>
<Square className="size-3.5 mr-1.5" />
Stop
</Button>
</>
)}
<Card size="sm">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Server className="size-4 text-muted-foreground" />
Local daemon
<span className="inline-flex items-center gap-1.5 rounded-md border bg-background px-1.5 py-0.5 text-xs font-normal">
<span
className={cn(
"size-1.5 rounded-full",
DAEMON_STATE_COLORS[status.state],
)}
/>
<span
className={cn(
"tabular-nums",
isRunning ? "text-foreground" : "text-muted-foreground",
)}
>
{DAEMON_STATE_LABELS[status.state]}
</span>
{isRunning && status.uptime && (
<span className="text-muted-foreground">
· {formatUptime(status.uptime)}
</span>
)}
</span>
</CardTitle>
<CardDescription>
{daemonStateDescription(status.state, runtimeCount)}
</CardDescription>
<CardAction className="self-center">
<div className="flex items-center gap-1.5">
{isRunning && (
<>
<Button
size="sm"
variant="ghost"
onClick={() => setPanelOpen(true)}
>
<ScrollText className="size-3.5 mr-1.5" />
View logs
</Button>
<Button
size="sm"
variant="outline"
onClick={handleRestart}
disabled={actionLoading}
>
<RotateCw className="size-3.5 mr-1.5" />
Restart
</Button>
<Button
size="sm"
variant="destructive"
onClick={handleStopClick}
disabled={actionLoading}
>
<Square className="size-3.5 mr-1.5" />
Stop
</Button>
</>
)}
{isStopped && (
<Button size="sm" onClick={handleStart} disabled={actionLoading}>
{actionLoading ? (
<Activity className="size-3.5 mr-1.5 animate-pulse" />
) : (
<Play className="size-3.5 mr-1.5" />
)}
Start
</Button>
)}
{isStopped && (
<Button
size="sm"
onClick={handleStart}
disabled={actionLoading}
>
{actionLoading ? (
<Activity className="size-3.5 mr-1.5 animate-pulse" />
) : (
<Play className="size-3.5 mr-1.5" />
)}
Start
</Button>
)}
{isCliMissing && (
<Button
size="sm"
variant="outline"
onClick={handleRetryInstall}
disabled={actionLoading}
>
<RotateCw className="size-3.5 mr-1.5" />
Retry setup
</Button>
)}
{isCliMissing && (
<Button
size="sm"
variant="outline"
onClick={handleRetryInstall}
disabled={actionLoading}
>
<RotateCw className="size-3.5 mr-1.5" />
Retry setup
</Button>
)}
{(isTransitioning || isInstalling) && (
<Button size="sm" variant="outline" disabled>
<Activity className="size-3.5 mr-1.5 animate-pulse" />
{DAEMON_STATE_LABELS[status.state]}
</Button>
)}
</div>
{(isTransitioning || isInstalling) && (
<Button size="sm" variant="outline" disabled>
<Activity className="size-3.5 mr-1.5 animate-pulse" />
{DAEMON_STATE_LABELS[status.state]}
</Button>
)}
</div>
</CardAction>
</CardHeader>
</Card>
<DaemonPanel
open={panelOpen}

View File

@@ -1,6 +1,6 @@
import { useEffect, useState } from "react";
import { RuntimesPage } from "@multica/views/runtimes";
import { DaemonRuntimeActions } from "./daemon-runtime-card";
import { DaemonRuntimeCard } from "./daemon-runtime-card";
import type { DaemonStatus } from "../../../shared/daemon-types";
/**
@@ -32,9 +32,7 @@ export function DesktopRuntimesPage() {
return (
<RuntimesPage
localDaemonId={status.daemonId ?? null}
localMachineName={status.deviceName ?? null}
localMachineActions={<DaemonRuntimeActions />}
topSlot={<DaemonRuntimeCard />}
bootstrapping={bootstrapping}
/>
);

View File

@@ -116,7 +116,7 @@ describe("PageviewTracker", () => {
expect(state.capturePageview).not.toHaveBeenCalled();
});
it("fires pageview when a foreground tab is added (addTab path)", () => {
it("fires pageview when a new tab is opened (openInNewTab / addTab)", () => {
state.byWorkspace = {
acme: {
activeTabId: "tA",
@@ -128,11 +128,7 @@ describe("PageviewTracker", () => {
const { rerender } = render(<PageviewTracker />);
state.capturePageview.mockClear();
// Simulate a foreground new-tab action (e.g. an explicit "Open in new
// tab" toolbar button that passes `{ activate: true }`) — tC is
// appended AND becomes active. `openInNewTab` defaults to background
// (no `setActiveTab`); only the `activate: true` branch produces the
// state change this test exercises.
// Simulate openInNewTab("/acme/agents") → new tab tC added and activated.
state.byWorkspace = {
acme: {
activeTabId: "tC",

View File

@@ -62,25 +62,18 @@ function WindowOverlayInner() {
{overlay.type === "invitations" && <InvitationsPage />}
{overlay.type === "onboarding" && (
<OnboardingFlow
onComplete={(ws, issueId) => {
onComplete={(ws) => {
close();
// Runtime-connected onboarding lands on its single guide
// issue. Runtime-less exits still land on the issues list.
if (ws && issueId) {
push(paths.workspace(ws.slug).issueDetail(issueId));
} else if (ws) {
// Post-onboarding landing is always the workspace issues
// list. The welcome-issue flow moved into a dialog that
// renders on that page (StarterContentPrompt), so the
// flow doesn't need to thread a target issue id back here.
if (ws) {
push(paths.workspace(ws.slug).issues());
} else {
push(paths.root());
}
}}
// Restart the bundled daemon when the user hits Refresh on
// Step 3. The daemon's PATH probe runs once at boot, so a
// newly-installed CLI (Claude / Codex / Cursor) doesn't show
// up until the daemon is bounced.
onRuntimeRefresh={async () => {
await window.daemonAPI?.restart?.();
}}
/>
)}
</div>

View File

@@ -1,16 +0,0 @@
import { useParams, useSearchParams } from "react-router-dom";
import { AttachmentPreviewPage } from "@multica/views/attachments";
import { ErrorBoundary } from "@multica/ui/components/common/error-boundary";
export function AttachmentPreviewRoute() {
const { id } = useParams<{ id: string }>();
const [searchParams] = useSearchParams();
const filename = searchParams.get("name") ?? undefined;
if (!id) return null;
return (
<ErrorBoundary resetKeys={[id]}>
<AttachmentPreviewPage attachmentId={id} filename={filename} />
</ErrorBoundary>
);
}

View File

@@ -1,207 +0,0 @@
import { describe, expect, it, vi, beforeEach } from "vitest";
import { render } from "@testing-library/react";
import { useEffect } from "react";
// Shared in-memory state that the mocked tab store reads / mutates. The test
// records every method call so we can assert openInNewTab does NOT activate
// the new tab (i.e. setActiveTab is never invoked on the same-workspace path).
const state = vi.hoisted(() => ({
activeWorkspaceSlug: "acme" as string | null,
byWorkspace: {
acme: {
activeTabId: "tA",
tabs: [{ id: "tA", path: "/acme/issues" }],
},
} as Record<
string,
{ activeTabId: string; tabs: { id: string; path: string }[] }
>,
openTab: vi.fn<(path: string, title?: string, icon?: string) => string>(),
setActiveTab: vi.fn<(tabId: string) => void>(),
switchWorkspace: vi.fn<(slug: string, openPath?: string) => void>(),
}));
vi.mock("@/stores/tab-store", () => {
const store = {
get activeWorkspaceSlug() {
return state.activeWorkspaceSlug;
},
get byWorkspace() {
return state.byWorkspace;
},
openTab: state.openTab,
setActiveTab: state.setActiveTab,
switchWorkspace: state.switchWorkspace,
};
const useTabStore = Object.assign(
(selector?: (s: typeof store) => unknown) =>
selector ? selector(store) : store,
{ getState: () => store },
);
const getActiveTab = () => {
const slug = state.activeWorkspaceSlug;
if (!slug) return null;
const group = state.byWorkspace[slug];
if (!group) return null;
return group.tabs.find((t) => t.id === group.activeTabId) ?? null;
};
const useActiveTabIdentity = () => ({
slug: state.activeWorkspaceSlug,
tabId: state.activeWorkspaceSlug
? (state.byWorkspace[state.activeWorkspaceSlug]?.activeTabId ?? null)
: null,
});
const useActiveTabRouter = () => null;
const resolveRouteIcon = () => "File";
return {
useTabStore,
getActiveTab,
useActiveTabIdentity,
useActiveTabRouter,
resolveRouteIcon,
};
});
vi.mock("@/stores/window-overlay-store", () => ({
useWindowOverlayStore: Object.assign(
() => null,
{ getState: () => ({ overlay: null, open: vi.fn(), close: vi.fn() }) },
),
}));
vi.mock("@multica/core/auth", () => ({
useAuthStore: Object.assign(
() => null,
{ getState: () => ({ logout: vi.fn() }) },
),
}));
vi.mock("@multica/core/paths", () => ({
isReservedSlug: (s: string) =>
["login", "workspaces", "invite", "onboarding", "invitations"].includes(s),
}));
// DesktopNavigationProvider reads window.desktopAPI.runtimeConfig synchronously.
beforeEach(() => {
state.openTab.mockReset();
state.setActiveTab.mockReset();
state.switchWorkspace.mockReset();
state.openTab.mockImplementation(() => "tNew");
state.activeWorkspaceSlug = "acme";
state.byWorkspace = {
acme: {
activeTabId: "tA",
tabs: [{ id: "tA", path: "/acme/issues" }],
},
};
Object.defineProperty(window, "desktopAPI", {
configurable: true,
value: {
runtimeConfig: { ok: true, config: { appUrl: "https://app.example" } },
},
});
});
import {
DesktopNavigationProvider,
TabNavigationProvider,
} from "./navigation";
import { useNavigation } from "@multica/views/navigation";
function captureAdapter(onAdapter: (adapter: ReturnType<typeof useNavigation>) => void) {
function Probe() {
const nav = useNavigation();
useEffect(() => {
onAdapter(nav);
}, [nav]);
return null;
}
return Probe;
}
describe("DesktopNavigationProvider.openInNewTab", () => {
it("opens a background tab (no setActiveTab) for a same-workspace path", () => {
let adapter: ReturnType<typeof useNavigation> | null = null;
const Probe = captureAdapter((a) => {
adapter = a;
});
render(
<DesktopNavigationProvider>
<Probe />
</DesktopNavigationProvider>,
);
expect(adapter).not.toBeNull();
adapter!.openInNewTab!("/acme/agents", "Agents");
expect(state.openTab).toHaveBeenCalledWith("/acme/agents", "Agents", "File");
expect(state.setActiveTab).not.toHaveBeenCalled();
expect(state.switchWorkspace).not.toHaveBeenCalled();
});
it("activates the new tab when opts.activate is true (foreground)", () => {
let adapter: ReturnType<typeof useNavigation> | null = null;
const Probe = captureAdapter((a) => {
adapter = a;
});
render(
<DesktopNavigationProvider>
<Probe />
</DesktopNavigationProvider>,
);
adapter!.openInNewTab!("/acme/agents", "Agents", { activate: true });
expect(state.openTab).toHaveBeenCalledWith("/acme/agents", "Agents", "File");
expect(state.setActiveTab).toHaveBeenCalledWith("tNew");
expect(state.switchWorkspace).not.toHaveBeenCalled();
});
it("delegates to switchWorkspace for a cross-workspace path", () => {
let adapter: ReturnType<typeof useNavigation> | null = null;
const Probe = captureAdapter((a) => {
adapter = a;
});
render(
<DesktopNavigationProvider>
<Probe />
</DesktopNavigationProvider>,
);
adapter!.openInNewTab!("/butter/inbox");
expect(state.switchWorkspace).toHaveBeenCalledWith("butter", "/butter/inbox");
expect(state.openTab).not.toHaveBeenCalled();
expect(state.setActiveTab).not.toHaveBeenCalled();
});
});
describe("TabNavigationProvider.openInNewTab", () => {
function renderTabProvider() {
let adapter: ReturnType<typeof useNavigation> | null = null;
const Probe = captureAdapter((a) => {
adapter = a;
});
const fakeRouter = {
state: { location: { pathname: "/acme/issues", search: "" } },
subscribe: () => () => {},
navigate: vi.fn(),
} as unknown as Parameters<typeof TabNavigationProvider>[0]["router"];
render(
<TabNavigationProvider router={fakeRouter}>
<Probe />
</TabNavigationProvider>,
);
return () => adapter!;
}
it("opens a background tab (no setActiveTab) for a same-workspace path", () => {
const getAdapter = renderTabProvider();
getAdapter().openInNewTab!("/acme/agents", "Agents");
expect(state.openTab).toHaveBeenCalledWith("/acme/agents", "Agents", "File");
expect(state.setActiveTab).not.toHaveBeenCalled();
expect(state.switchWorkspace).not.toHaveBeenCalled();
});
it("activates the new tab when opts.activate is true (foreground)", () => {
const getAdapter = renderTabProvider();
getAdapter().openInNewTab!("/acme/agents", "Agents", { activate: true });
expect(state.openTab).toHaveBeenCalledWith("/acme/agents", "Agents", "File");
expect(state.setActiveTab).toHaveBeenCalledWith("tNew");
expect(state.switchWorkspace).not.toHaveBeenCalled();
});
});

View File

@@ -178,16 +178,9 @@ export function DesktopNavigationProvider({
},
pathname: location.pathname,
searchParams: new URLSearchParams(location.search),
openInNewTab: (
path: string,
title?: string,
opts?: { activate?: boolean },
) => {
openInNewTab: (path: string, title?: string) => {
// Cross-workspace "open in new tab" switches workspace and opens
// the path there (focus follows the user); same-workspace defaults
// to background tab (browser cmd+click semantics). Callers that
// represent an explicit "Open in new tab" CTA pass `activate: true`
// to bring the new tab to the foreground.
// 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) {
@@ -195,10 +188,8 @@ export function DesktopNavigationProvider({
return;
}
const icon = resolveRouteIcon(path);
const newId = store.openTab(path, title ?? path, icon);
if (opts?.activate && newId) {
store.setActiveTab(newId);
}
const tabId = store.openTab(path, title ?? path, icon);
if (tabId) store.setActiveTab(tabId);
},
getShareableUrl: (path: string) => `${appUrl}${path}`,
}),
@@ -250,11 +241,7 @@ export function TabNavigationProvider({
back: () => router.navigate(-1),
pathname: location.pathname,
searchParams: new URLSearchParams(location.search),
openInNewTab: (
path: string,
title?: string,
opts?: { activate?: boolean },
) => {
openInNewTab: (path: string, title?: string) => {
const slug = extractWorkspaceSlug(path);
const store = useTabStore.getState();
if (slug && slug !== store.activeWorkspaceSlug) {
@@ -262,10 +249,8 @@ export function TabNavigationProvider({
return;
}
const icon = resolveRouteIcon(path);
const newId = store.openTab(path, title ?? path, icon);
if (opts?.activate && newId) {
store.setActiveTab(newId);
}
const tabId = store.openTab(path, title ?? path, icon);
if (tabId) store.setActiveTab(tabId);
},
getShareableUrl: (path: string) => `${appUrl}${path}`,
}),

View File

@@ -13,7 +13,6 @@ import { SkillDetailPage } from "./pages/skill-detail-page";
import { AgentDetailPage } from "./pages/agent-detail-page";
import { MemberDetailPage } from "./pages/member-detail-page";
import { RuntimeDetailPage } from "./pages/runtime-detail-page";
import { AttachmentPreviewRoute } from "./pages/attachment-preview-page";
import { IssuesPage } from "@multica/views/issues/components";
import { ProjectsPage } from "@multica/views/projects/components";
import { DashboardPage } from "@multica/views/dashboard";
@@ -161,11 +160,6 @@ export const appRoutes: RouteObject[] = [
handle: { title: "Squad" },
},
{ path: "inbox", element: <InboxPage />, handle: { title: "Inbox" } },
{
path: "attachments/:id/preview",
element: <AttachmentPreviewRoute />,
handle: { title: "Attachment" },
},
{
path: "usage",
element: <DashboardPage />,

View File

@@ -35,7 +35,7 @@ multica issue assign MUL-42 --to alice
multica issue assign MUL-42 --to-id 5fb87ac7-23b5-4a7a-81fa-ed295a54545d
```
`--to` takes a member username or an agent name (fuzzy match). When names overlap — e.g. an agent `J` alongside `Cursor - J` — pass `--to-id <uuid>` instead, using the `user_id` (member) or `id` (agent) from `multica workspace member list --output json` / `multica agent list --output json`. UUID matching is strict and unambiguous, which is what you want from scripts and from agents driving the CLI. `--to` and `--to-id` are mutually exclusive.
`--to` takes a member username or an agent name (fuzzy match). When names overlap — e.g. an agent `J` alongside `Cursor - J` — pass `--to-id <uuid>` instead, using the `user_id` (member) or `id` (agent) from `multica workspace members --output json` / `multica agent list --output json`. UUID matching is strict and unambiguous, which is what you want from scripts and from agents driving the CLI. `--to` and `--to-id` are mutually exclusive.
Unassign:

View File

@@ -35,7 +35,7 @@ multica issue assign MUL-42 --to alice
multica issue assign MUL-42 --to-id 5fb87ac7-23b5-4a7a-81fa-ed295a54545d
```
`--to` 后跟成员用户名或智能体名字(模糊匹配)。如果工作区里有同名 / 互相含子串的成员或智能体(例如 agent `J` 旁边还有 `Cursor - J`),改用 `--to-id <uuid>`UUID 来自 `multica workspace member list --output json` 的 `user_id` 或 `multica agent list --output json` 的 `id`,是唯一精确的方式,特别适合脚本和驱动 CLI 的智能体。`--to` 和 `--to-id` 互斥。
`--to` 后跟成员用户名或智能体名字(模糊匹配)。如果工作区里有同名 / 互相含子串的成员或智能体(例如 agent `J` 旁边还有 `Cursor - J`),改用 `--to-id <uuid>`UUID 来自 `multica workspace members --output json` 的 `user_id` 或 `multica agent list --output json` 的 `id`,是唯一精确的方式,特别适合脚本和驱动 CLI 的智能体。`--to` 和 `--to-id` 互斥。
取消分配:

View File

@@ -1,6 +1,6 @@
---
title: Autopilots
description: Let agents start work on a cron schedule, an inbound webhook, or trigger once manually via the UI or CLI.
description: Let agents start work on a cron schedule or trigger once manually via the UI or CLI.
---
import { Callout } from "fumadocs-ui/components/callout";
@@ -16,13 +16,13 @@ Create a new autopilot on the workspace's **Autopilot** page. You set:
- **Priority** — inherited by the `task` it produces (same semantics as issue priority)
- **Description / prompt** — the work description the agent receives each run
- **Execution mode** — see below
- **Triggers** — at least one `schedule` (cron + timezone) or `webhook`
- **Triggers** — at least one `schedule` (cron + timezone)
## Pick an execution mode
An autopilot has two execution modes. **Start with "create issue" mode.**
- **Create issue mode** (`create_issue`) — default, **recommended**. Each trigger first creates an issue in the workspace (the title currently supports a single placeholder, `{{date}}`, which interpolates to the UTC date in `YYYY-MM-DD` format; any other `{{...}}` token is rejected at create-time so a typo cannot silently land as the literal string in your issue titles), then assigns the issue to the agent through the normal assignment flow. All work lands on the issue board with the same history, comments, and status as a manually assigned issue.
- **Create issue mode** (`create_issue`) — default, **recommended**. Each trigger first creates an issue in the workspace (the title supports interpolation like `{{date}}`), then assigns the issue to the agent through the normal assignment flow. All work lands on the issue board with the same history, comments, and status as a manually assigned issue.
- **Run-only mode** (`run_only`) — skips issue creation and enqueues a `task` directly. The run is invisible on the board — you can only see it in the autopilot's run history.
## Run it on a schedule
@@ -50,109 +50,15 @@ multica autopilot trigger <autopilot-id>
A manual trigger goes through the exact same execution flow as a `schedule` trigger — only the `source` field on the run record is marked `manual`.
## Trigger from a webhook
Autopilots can also fire on inbound HTTP webhooks. Add a **Webhook** trigger
on the autopilot detail page; Multica generates a unique URL of the shape:
```
https://<your-multica-host>/api/webhooks/autopilots/awt_…
```
POST any JSON to that URL — Multica records a run with `source = webhook`,
stores the body as the run's `trigger_payload`, and dispatches the agent
exactly the way a schedule trigger would.
```bash
curl -X POST "$MULTICA_WEBHOOK_URL" \
-H "Content-Type: application/json" \
-d '{"event":"demo.received","eventPayload":{"message":"hello"}}'
```
In **create issue mode**, the inbound payload is appended to the new issue's
description so the agent can read it inline. In **run-only mode**, the
payload is part of the run context the daemon hands the agent.
### Payload shape
You can send your own envelope:
```json
{ "event": "github.pull_request.opened", "eventPayload": { } }
```
…or any JSON object/array. Multica normalizes it into an internal envelope:
```json
{
"event": "<inferred>",
"eventPayload": <your body>,
"request": { "receivedAt": "<rfc3339>", "contentType": "application/json" }
}
```
When you don't provide an `event` field, Multica infers it from common
headers and body fields (`X-GitHub-Event` + body `action`,
`X-Gitlab-Event`, `X-Event-Type`, body `event`/`type`/`action`). When
nothing matches, the event is `webhook.received`.
When configuring GitHub or similar sources, set the content type to
`application/json` — form-encoded webhook payloads are not accepted.
### URL is a bearer secret
The generated URL **is** the credential. Anyone with it can fire the
autopilot. Treat it like a token:
- **Don't paste it into public issue threads, screenshots, or chat history.**
- **Rotate it if it leaks** — click "Rotate URL" on the trigger row, or run
`multica autopilot trigger-rotate-url <autopilot-id> <trigger-id>`. The
old URL stops working immediately.
- For sources that require strong source authentication, wait for
per-trigger HMAC signature verification; this v1 URL is bearer-only.
- Workspace members who can view the autopilot can read its webhook URLs
for now — tighter per-role secret visibility is a follow-up.
### Status-code semantics
Multica returns `200 OK` with a `status` field for normal no-op outcomes so
your provider's webhook-retry machinery doesn't keep hammering the URL:
- `{"status":"accepted","run_id":"…","autopilot_id":"…","trigger_id":"…"}`
— a run was dispatched.
- `{"status":"skipped","run_id":"…","reason":"agent runtime is offline at dispatch time"}`
— the assignee's runtime is offline; recorded as a `skipped` run.
- `{"status":"ignored","reason":"trigger_disabled"}` — the trigger is disabled.
- `{"status":"ignored","reason":"autopilot_paused"}` — the autopilot is paused.
- `{"status":"ignored","reason":"autopilot_archived"}` — the autopilot is archived.
Non-2xx responses cover real failures:
- `400` — invalid JSON, scalar body, or empty body.
- `404` — unknown token (`{"error":"webhook not found"}`).
- `413` — payload exceeded 256 KiB.
- `429` — per-token rate limit exceeded (defaults to 60 req/min).
### Self-hosted: configure your public URL
When `MULTICA_PUBLIC_URL` is set on the server (e.g. `https://multica.example.com`),
the trigger response includes an absolute `webhook_url` and the UI shows a
ready-to-copy URL. Without it, the UI composes the URL from the client's
API origin — which is fine for desktop and same-origin web, but not for
custom self-hosted reverse proxies. Multica deliberately does not derive
the public host from `Host` / `X-Forwarded-Host` headers so a misconfigured
reverse proxy cannot trick the server into minting webhook URLs pointing at
an attacker-controlled host.
## View run history
Every trigger produces a **run record**, visible on the "History" tab of the autopilot detail page:
- Trigger source (`schedule` / `manual` / `webhook`)
- Trigger source (`schedule` / `manual`)
- Start time, completion time
- Status (`issue_created` / `running` / `completed` / `failed` / `skipped`)
- Status (`issue_created` / `running` / `completed` / `failed`)
- The linked issue (create issue mode) or `task` (run-only mode)
- Failure reason (if failed or skipped)
- Failure reason (if failed)
## What happens when an autopilot fails
@@ -166,11 +72,7 @@ Why no auto-retry: autopilots are already periodic, so adding system-level retri
## What's not yet available
**API-kind triggers are not wired up.** The trigger schema reserves an `api`
kind, but no ingress route fires it; the UI shows a Deprecated badge for
existing rows and offers no copy/rotate affordances. Per-trigger HMAC
signature verification, IP allowlists, and provider-specific event presets
are tracked as follow-ups; v1 URLs are bearer-only.
**Webhook and API triggers are not available yet.** The autopilot trigger schema reserves `webhook` and `api` types, but **they are not wired up to any ingress route** — the UI can create triggers of either type, but they will not actually fire. Today, **only `schedule` and manual triggers are end-to-end usable.**
## Next

View File

@@ -1,6 +1,6 @@
---
title: Autopilots
description: 让智能体按 cron 定时自己开工,或在 webhook 到来时被触发——也可以通过 UI / CLI 手动触发一次。
description: 让智能体按 cron 定时自己开工——或通过 UI / CLI 手动触发一次。
---
import { Callout } from "fumadocs-ui/components/callout";
@@ -16,13 +16,13 @@ Autopilots 让 [智能体](/agents) **按调度自动开工**——配好 cron
- **优先级** — 继承给它产生的 `task`(语义同 issue 优先级)
- **描述 / Prompt** — 智能体每次执行拿到的工作说明
- **执行模式** — 见下节
- **触发器** — 至少加一条 `schedule`cron + 时区)或 `webhook`
- **触发器** — 至少加一条 `schedule`cron + 时区)
## 选择执行模式
Autopilot 有两种执行模式,**建议从"先建 issue 模式"开始**
- **先建 issue 模式**`create_issue`)—— 默认,**推荐**。每次触发先在工作区里建一个 issue标题目前只支持一个占位符 `{{date}}`,会插值成 UTC 日期 `YYYY-MM-DD`;其他 `{{...}}` 形式的占位符会在创建时被拒绝,避免拼错以后悄无声息地把原文当成 issue 标题),再按分配流程把 issue 派给智能体。所有工作都落在 issue 看板上,历史、评论、状态和手动分配的 issue 完全一致。
- **先建 issue 模式**`create_issue`)—— 默认,**推荐**。每次触发先在工作区里建一个 issue标题支持 `{{date}}` 这样的插值),再按分配流程把 issue 派给智能体。所有工作都落在 issue 看板上,历史、评论、状态和手动分配的 issue 完全一致。
- **直跑模式**`run_only`)—— 不建 issue直接入队一个 `task`。看板上看不到这一次运行——只能在 Autopilot 的运行历史里看到。
## 让它按时间跑
@@ -50,105 +50,15 @@ multica autopilot trigger <autopilot-id>
手动触发走和 `schedule` 触发完全相同的执行流程,只是运行记录里 `source` 字段标为 `manual`。
## 通过 Webhook 触发
Autopilot 也可以由入站 HTTP webhook 触发。在详情页添加一个 **Webhook**
触发器Multica 会生成一个唯一的 URL
```
https://<你的 Multica host>/api/webhooks/autopilots/awt_…
```
向这个 URL POST 任意 JSON——Multica 会记录一条 `source = webhook` 的
run把请求体保存为 run 的 `trigger_payload`,然后按和 schedule 触发器
完全一致的方式派发给智能体。
```bash
curl -X POST "$MULTICA_WEBHOOK_URL" \
-H "Content-Type: application/json" \
-d '{"event":"demo.received","eventPayload":{"message":"hello"}}'
```
在**先建 issue 模式**下,入站 payload 会附加在新 issue 的描述里供智能体
直接读到;**直跑模式**下payload 也会随 run 一并交给 daemon。
### Payload 形态
可以发自己的封装:
```json
{ "event": "github.pull_request.opened", "eventPayload": { } }
```
也可以直接发任意 JSON 对象 / 数组。Multica 会规范化为内部封装:
```json
{
"event": "<推断>",
"eventPayload": <你的 body>,
"request": { "receivedAt": "<rfc3339>", "contentType": "application/json" }
}
```
不带 `event` 字段时Multica 会按以下顺序从常见 header 和 body 字段
推断:`X-GitHub-Event` + body `action``X-Gitlab-Event`、
`X-Event-Type`、body 里的 `event` / `type` / `action`。都不命中时事件
名退化为 `webhook.received`。
配置 GitHub 之类的来源时,请把 content type 设为 `application/json`——
表单编码的 webhook payload 在 v1 里不接受。
### URL 即 bearer secret
生成的 URL **就是凭证**,谁拿到都能触发这个 Autopilot。请按 token 对待:
- **不要贴到公开 issue 评论、截图、聊天记录里。**
- **泄漏后立即重新生成**——在触发器上点"重新生成 URL",或运行
`multica autopilot trigger-rotate-url <autopilot-id> <trigger-id>`。
旧 URL 立即失效。
- 对需要强来源认证的源,等 per-trigger HMAC 签名校验上线v1 URL 仅
bearer。
- 当前能查看 Autopilot 的工作区成员都能看到它的 webhook URL——更细的
权限可见性是后续工作。
### 状态码语义
正常的 no-op 路径都返回 `200 OK` 加 `status` 字段,避免外部 webhook 重试
机制反复打:
- `{"status":"accepted","run_id":"…","autopilot_id":"…","trigger_id":"…"}`
—— 已派发一次 run。
- `{"status":"skipped","run_id":"…","reason":"agent runtime is offline at dispatch time"}`
—— 受派智能体的 runtime 离线,记为 `skipped` run。
- `{"status":"ignored","reason":"trigger_disabled"}` —— 触发器已禁用。
- `{"status":"ignored","reason":"autopilot_paused"}` —— Autopilot 已暂停。
- `{"status":"ignored","reason":"autopilot_archived"}` —— Autopilot 已归档。
非 2xx 是真正的失败:
- `400` —— 无效 JSON、scalar body、空 body。
- `404` —— 未知 token`{"error":"webhook not found"}`)。
- `413` —— 请求体超过 256 KiB。
- `429` —— 单 token 速率限制(默认 60 次 / 分钟)。
### 自托管:配置公开 URL
服务端设置 `MULTICA_PUBLIC_URL`(例如 `https://multica.example.com`)后,
触发器响应里会带绝对的 `webhook_url`UI 直接显示可复制的 URL。没设
时 UI 会用客户端的 API origin 拼出 URL——desktop 和同源 web 没问题,
但自定义反向代理就不行了。Multica **故意不**从 `Host` /
`X-Forwarded-Host` header 推断公开主机,避免反代配置失误时被诱导生成
指向攻击者域名的 webhook URL。
## 看运行历史
每次触发都会产生一条**运行记录**run可以在 Autopilot 详情页的"历史"tab 看到:
- 触发源(`schedule` / `manual` / `webhook`
- 触发源(`schedule` / `manual`
- 开始时间、完成时间
- 状态(`issue_created` / `running` / `completed` / `failed` / `skipped`
- 状态(`issue_created` / `running` / `completed` / `failed`
- 关联的 issue先建 issue 模式)或 `task`(直跑模式)
- 失败原因(失败或跳过时
- 失败原因(如果失败)
## Autopilot 失败会怎样
@@ -162,10 +72,7 @@ curl -X POST "$MULTICA_WEBHOOK_URL" \
## 暂不可用的能力
**API 类型触发器尚未接入。** 触发器 schema 里留了 `api` 类型但没有
入站路由会触发它UI 会给已有的此类记录打 Deprecated 标签,也不显示
copy / rotate 操作。Per-trigger HMAC 签名校验、IP allowlist、按提供方
的事件预设是后续工作v1 URL 仅 bearer。
**Webhook 和 API 触发暂不可用**。Autopilot 的触发器类型在 schema 里留了 `webhook` 和 `api` 两种,但**还没接入站路由**——UI 可以创建这两类触发器,不会真的触发。目前**只有 `schedule` 和手动触发是端到端可用的**。
## 下一步

View File

@@ -39,7 +39,7 @@ For the difference between token types, see [Authentication and tokens](/auth-to
|---|---|
| `multica workspace list` | List every workspace you can access |
| `multica workspace get <slug>` | Show details for one workspace |
| `multica workspace member list` | List members of the current workspace |
| `multica workspace members` | List members of the current workspace |
| `multica workspace update <id> --name "..." [--description "..."] [--context "..."] [--issue-prefix "..."]` | Update workspace metadata (admin/owner). Long fields accept `--description-stdin` / `--context-stdin`. |
## Issues and projects

View File

@@ -39,7 +39,7 @@ Token 类型的详细区分见 [认证与令牌](/auth-tokens)。
|---|---|
| `multica workspace list` | 列出你有权访问的所有工作区 |
| `multica workspace get <slug>` | 查看一个工作区的详情 |
| `multica workspace member list` | 列出当前工作区的成员 |
| `multica workspace members` | 列出当前工作区的成员 |
| `multica workspace update <id> --name "..." [--description "..."] [--context "..."] [--issue-prefix "..."]` | 修改 workspace 元数据admin/owner 权限)。长文本可用 `--description-stdin` / `--context-stdin`。 |
## Issue 和 Project

View File

@@ -210,7 +210,7 @@ multica workspace get <workspace-id> --output json
### List Members
```bash
multica workspace member list <workspace-id>
multica workspace members <workspace-id>
```
### Update Workspace
@@ -267,7 +267,7 @@ multica issue create --title "Fix login bug" --description "..." --priority high
multica issue create --title "Fix login bug" --assignee-id 5fb87ac7-23b5-4a7a-81fa-ed295a54545d
```
Flags: `--title` (required), `--description`, `--status`, `--priority`, `--assignee` / `--assignee-id`, `--parent`, `--project`, `--due-date`. 脚本里如果已经拿到了 UUID例如来自 `multica workspace member list --output json`),传 `--assignee-id <uuid>`(与 `--assignee` 互斥)以精确锁定。
Flags: `--title` (required), `--description`, `--status`, `--priority`, `--assignee` / `--assignee-id`, `--parent`, `--project`, `--due-date`. 脚本里如果已经拿到了 UUID例如来自 `multica workspace members --output json`),传 `--assignee-id <uuid>`(与 `--assignee` 互斥)以精确锁定。
### Update Issue

View File

@@ -99,7 +99,7 @@ Assign the issue to the agent you just created — click its avatar in the web U
multica issue assign MUL-1 --to my-agent-name
```
`--to` takes the **name** of an agent or member. A substring match works — if the agent is called `my-code-reviewer`, `reviewer` resolves to it. If your workspace has overlapping names, pass `--to-id <uuid>` instead (mutually exclusive with `--to`); look up the UUID via `multica agent list --output json` or `multica workspace member list --output json`.
`--to` takes the **name** of an agent or member. A substring match works — if the agent is called `my-code-reviewer`, `reviewer` resolves to it. If your workspace has overlapping names, pass `--to-id <uuid>` instead (mutually exclusive with `--to`); look up the UUID via `multica agent list --output json` or `multica workspace members --output json`.
**What happens next from the daemon**:

View File

@@ -99,7 +99,7 @@ multica issue create --title "给 README 加一段 ASCII 架构图"
multica issue assign MUL-1 --to my-agent-name
```
`--to` 后面填智能体或成员的**名字**,子串就行——如果智能体叫 `my-code-reviewer`,填 `reviewer` 也能命中。如果工作区里名字相互重叠或冲突,改用 `--to-id <uuid>`(与 `--to` 互斥UUID 来自 `multica agent list --output json` 或 `multica workspace member list --output json`。
`--to` 后面填智能体或成员的**名字**,子串就行——如果智能体叫 `my-code-reviewer`,填 `reviewer` 也能命中。如果工作区里名字相互重叠或冲突,改用 `--to-id <uuid>`(与 `--to` 互斥UUID 来自 `multica agent list --output json` 或 `multica workspace members --output json`。
**接下来守护进程会**

View File

@@ -70,7 +70,7 @@ If logic appears in both apps, it MUST be extracted to a shared package. There a
### Issue keys
Every issue has a human-readable key like `MUL-123`: workspace `issue_prefix` (uppercase letters and digits, typically 3 chars, max 10) + sequence number. Workspace admins can change the prefix in Settings → General; changing it renumbers every existing issue, so external references that embed the old prefix (PR titles, branch names, links in docs and chat) stop resolving.
Every issue has a human-readable key like `MUL-123`: workspace `issue_prefix` (3 letters, uppercase) + sequence number. The prefix is set at workspace creation and is never changed afterward.
### Comments in code

View File

@@ -70,7 +70,7 @@ monorepo 的包边界是硬约束:
### Issue 编号
每个 issue 有人类可读的编号,比如 `MUL-123`:工作区 `issue_prefix`大写字母和数字,通常 3 个字符,最长 10 个)+ 流水号。工作区管理员可以在 Settings → General 中修改前缀;修改会让所有现有 issue 重新编号外部引用——PR 标题、分支名、文档与聊天里的链接——里的旧前缀会失效
每个 issue 有人类可读的编号,比如 `MUL-123`:工作区 `issue_prefix`3 个大写字母)+ 流水号。前缀在工作区创建时定,之后不可改
### 代码注释

View File

@@ -128,25 +128,6 @@ Three allowlist layers combine by priority. **If any layer is set to a non-empty
**Invite flows themselves do not check the signup allowlist** — but the invitee must still be able to **sign in** before accepting the invite. If they already have a Multica account (for example from another workspace), they can accept directly, unaffected by the allowlist; **if they have never signed up**, the first step of sign-in (requesting a verification code) still passes through the allowlist check, and an email rejected by `ALLOW_SIGNUP=false` or by `ALLOWED_EMAILS` / `ALLOWED_EMAIL_DOMAINS` **cannot finish signup, and therefore cannot accept the invite**.
## Rate limiting (optional Redis)
Public auth endpoints — `/auth/send-code`, `/auth/verify-code`, `/auth/google` — have per-IP fixed-window rate limiting in front of them. The limiter is backed by Redis. When `REDIS_URL` is unset the middleware is a **no-op** (fail-open) and the backend logs `rate limiting disabled: REDIS_URL not configured` at startup.
| Variable | Default | Description |
|---|---|---|
| `REDIS_URL` | empty | Redis connection URL (for example `redis://localhost:6379/0`). When unset, rate limiting on auth endpoints is disabled. The same Redis is also used by the realtime hub fan-out, the PAT cache, and the daemon-token cache — they all fall back to in-memory / direct-DB mode when unset |
| `RATE_LIMIT_AUTH` | `5` | Max requests per IP per minute against `/auth/send-code` and `/auth/google` |
| `RATE_LIMIT_AUTH_VERIFY` | `20` | Max requests per IP per minute against `/auth/verify-code` |
| `RATE_LIMIT_TRUSTED_PROXIES` | empty | Comma-separated CIDRs whose `X-Forwarded-For` header the limiter is allowed to trust. Empty (the default) means **never trust XFF** — the limiter only uses the direct connection's `RemoteAddr` |
When a request is over the limit, the server replies with `429 Too Many Requests`, `Retry-After: 60`, and body `{"error":"too many requests"}`.
<Callout type="warning">
**Behind a reverse proxy you must set `RATE_LIMIT_TRUSTED_PROXIES`.** Otherwise every real user shares the proxy's IP from the backend's point of view, the whole deployment ends up in one bucket, and `/auth/send-code` becomes 5 req/min for the entire site. Typical values: `127.0.0.1/32,::1/128` for a same-host Caddy / Nginx; the CDN's published ranges for Cloudflare / ALB / CloudFront. Only IPs whose `RemoteAddr` falls inside one of these CIDRs may use `X-Forwarded-For` to identify the client.
</Callout>
This separate `RATE_LIMIT_TRUSTED_PROXIES` is **not** the same as `MULTICA_TRUSTED_PROXIES`, which controls the autopilot-webhook limiter (`/api/webhooks/autopilots/{token}`). Each limiter parses its own list, so a deployment behind a proxy should set both.
## Daemon tuning parameters
The daemon runs on the user's local machine, and its config is read from local environment variables too. The common ones:

View File

@@ -128,25 +128,6 @@ Multica 存储用户上传的附件(评论里的图片、文件等)。**优
**邀请流程本身不检查 signup 白名单**——但被邀请人必须先能**登录**才能接受邀请。如果对方已经有 Multica 账号(比如在其他工作区注册过),可以直接接受,不受白名单影响;**如果对方还没注册过**,他们登录的第一步(发送验证码)仍然会过白名单检查,被 `ALLOW_SIGNUP=false` 或 `ALLOWED_EMAILS` / `ALLOWED_EMAIL_DOMAINS` 拒绝的邮箱**无法完成注册,也就没法接受邀请**。
## 速率限制(可选 Redis
公开认证端点——`/auth/send-code`、`/auth/verify-code`、`/auth/google`——前面挂了按 IP 的固定窗口限流。限流器后端是 Redis。`REDIS_URL` 不设时中间件**直通**fail-open后端启动会打日志 `rate limiting disabled: REDIS_URL not configured`。
| 环境变量 | 默认值 | 说明 |
|---|---|---|
| `REDIS_URL` | 空 | Redis 连接 URL例如 `redis://localhost:6379/0`)。不设时认证端点的限流功能直接关闭。同一个 Redis 也被实时事件 fan-out、PAT 缓存、守护进程 token 缓存复用;不设时这些组件分别回落到内存模式 / 直查 DB |
| `RATE_LIMIT_AUTH` | `5` | 单 IP 每分钟对 `/auth/send-code` 和 `/auth/google` 的最大请求数 |
| `RATE_LIMIT_AUTH_VERIFY` | `20` | 单 IP 每分钟对 `/auth/verify-code` 的最大请求数 |
| `RATE_LIMIT_TRUSTED_PROXIES` | 空 | 逗号分隔的 CIDR 列表,列在内的来源 IP 才允许通过 `X-Forwarded-For` 标识客户端。默认空 = **永不信任 XFF**,限流器只看直连的 `RemoteAddr` |
被限流的请求会返回 `429 Too Many Requests`,带 `Retry-After: 60` 头和 `{"error":"too many requests"}` 响应体。
<Callout type="warning">
**部署在反向代理后面时必须设 `RATE_LIMIT_TRUSTED_PROXIES`。** 否则在后端看来所有真实用户都共用代理那个 IP整个部署落到同一个桶里`/auth/send-code` 会变成全站每分钟只能发 5 次。常见值:本机 Caddy / Nginx 用 `127.0.0.1/32,::1/128`Cloudflare / ALB / CloudFront 用各家公开的 CDN IP 段。只有 `RemoteAddr` 落在这些 CIDR 内的请求才被允许通过 `X-Forwarded-For` 改写客户端 IP。
</Callout>
这里的 `RATE_LIMIT_TRUSTED_PROXIES` 和 `MULTICA_TRUSTED_PROXIES` **不是同一个**变量——后者控制的是 autopilot webhook 端点(`/api/webhooks/autopilots/{token}`)的限流器。两个限流器各自读各自的列表,部署在代理后面的实例需要两个都配上。
## 守护进程的调节参数
守护进程跑在用户本地机器上,配置也是读本地环境变量。常用的几个:

View File

@@ -1,169 +0,0 @@
---
title: Install an agent runtime
description: Multica drives whichever AI coding tools you have on your machine. This page shows you how to install each of the 11 supported tools so the daemon can detect them.
---
import { Callout } from "fumadocs-ui/components/callout";
A **runtime** in Multica is the daemon on your machine paired with one AI coding tool the daemon found on your `PATH`. If the onboarding "Connect a runtime" step shows **No supported tools detected**, it means the daemon scanned `PATH` and didn't find any of the 11 tools it knows how to drive. Install one (or several) of the tools below, then come back to the step and re-scan — the runtime will show up within a few seconds.
This page is the install-side companion to:
- [Daemon and runtimes](/daemon-runtimes) — how detection works
- [AI coding tools matrix](/providers) — what each tool can and can't do (session resumption, MCP, model selection)
<Callout type="info">
The Multica server never sees your API keys or the tools themselves. Everything below — installation, authentication, model access — lives on your local machine. If something fails, it's almost always a local problem.
</Callout>
## Before you start
Two prerequisites apply to **every** tool below:
1. **The Multica daemon must be running.** Either run `multica daemon start` after installing the [Multica CLI](/cli), or use the [Multica desktop app](/desktop-app), which launches the daemon automatically. Without a running daemon there is nothing to detect tools.
2. **The tool's binary must be reachable on `PATH`.** The daemon shells out to each tool by name (see the **Daemon looks for** column in each section). If `which <name>` doesn't find it in your terminal, the daemon won't find it either. After installing, open a fresh terminal (or restart the daemon) so the new `PATH` entry is picked up.
After installing a tool, restart the daemon:
```bash
multica daemon restart
```
Or, in the desktop app, just relaunch the app. The daemon re-scans `PATH` on every start.
## The 11 supported tools
Listed roughly from most to least common. Pick whichever ones you already have credentials for — you don't need all 11.
### Claude Code (Anthropic)
The most complete integration. Session resumption works, MCP works, and it's the **only one of the 11 that actually consumes the `mcp_config` field** on agents (see the [matrix](/providers#mcp-configuration-only-claude-code-actually-reads-it)).
| | |
|---|---|
| Daemon looks for | `claude` |
| Install | Follow the official guide at [claude.com/claude-code](https://www.claude.com/claude-code). The standard route is the npm package `@anthropic-ai/claude-code` (Node.js 18+ required). |
| Authentication | Run `claude` once and follow the in-CLI login flow, or set `ANTHROPIC_API_KEY`. |
| Notes | First-choice recommendation for new users. |
### Codex (OpenAI)
JSON-RPC 2.0 transport with finer-grained approval gates. **Session resumption code exists but is currently unreachable** — pick Claude Code or one of the ACP family if you need resume.
| | |
|---|---|
| Daemon looks for | `codex` |
| Install | Follow the official guide at [github.com/openai/codex](https://github.com/openai/codex). The standard route is the npm package `@openai/codex`. |
| Authentication | `codex login` (browser-based) or `OPENAI_API_KEY`. |
### Cursor (Anysphere)
The CLI counterpart to the Cursor editor. **Session resumption is broken** — Cursor's CLI doesn't return a session id, so the value you pass on resume is always invalid.
| | |
|---|---|
| Daemon looks for | `cursor-agent` |
| Install | Install the [Cursor editor](https://cursor.com/) and then the CLI per their docs at [docs.cursor.com](https://docs.cursor.com/). The binary name is `cursor-agent`, not `cursor`. |
| Authentication | Sign in through the Cursor editor; the CLI reuses that session. |
### GitHub Copilot
Model routing goes through your GitHub account entitlement — the tool doesn't pick a model itself; GitHub decides which model you get.
| | |
|---|---|
| Daemon looks for | `copilot` |
| Install | See GitHub's CLI docs at [github.com/github/copilot-cli](https://github.com/github/copilot-cli). |
| Authentication | Browser-based GitHub login through the CLI. |
| Notes | Requires an active GitHub Copilot subscription on the signed-in account. |
### Gemini (Google)
Supports the Gemini 2.5 and 3 series. No session resumption, no MCP — suitable for one-shot tasks.
| | |
|---|---|
| Daemon looks for | `gemini` |
| Install | Follow the official guide at [github.com/google-gemini/gemini-cli](https://github.com/google-gemini/gemini-cli). The standard route is the npm package `@google/gemini-cli`. |
| Authentication | `gemini` will prompt for a Google account login, or set `GEMINI_API_KEY`. |
### OpenCode (SST)
Open-source CLI agent. Dynamically discovers available models from its own configuration file — good fit for users who want to bring their own model catalog.
| | |
|---|---|
| Daemon looks for | `opencode` |
| Install | Follow the official guide at [opencode.ai](https://opencode.ai/) or the GitHub repo at [github.com/sst/opencode](https://github.com/sst/opencode). The typical route is the install script or the npm package. |
| Authentication | Configure your model provider(s) per OpenCode's docs (Anthropic, OpenAI, etc.). |
### Kiro CLI (Amazon)
ACP-over-stdio transport. Session resumption works through ACP `session/load`; skills are copied into `.kiro/skills/`.
| | |
|---|---|
| Daemon looks for | `kiro-cli` |
| Install | See the Kiro docs at [kiro.dev](https://kiro.dev/). The binary name is `kiro-cli`, not `kiro`. |
| Authentication | AWS-account-based; follow Kiro's own onboarding. |
### Kimi (Moonshot)
ACP-protocol agent, primarily aimed at the Chinese market. Skills live under `.kimi/skills/` (native discovery).
| | |
|---|---|
| Daemon looks for | `kimi` |
| Install | Follow the official guide at [github.com/MoonshotAI/kimi-cli](https://github.com/MoonshotAI/kimi-cli). |
| Authentication | Moonshot API key, configured per the vendor's docs. |
### Hermes (Nous Research)
ACP-protocol agent (shares the transport with Kimi). Session resumption works. The skill injection path falls back to the generic `.agent_context/skills/` — verify your skills are loading before relying on them.
| | |
|---|---|
| Daemon looks for | `hermes` |
| Install | See Nous Research's repository at [github.com/NousResearch](https://github.com/NousResearch) for the latest CLI distribution. |
| Authentication | Per the vendor's docs. |
### OpenClaw
Open-source CLI agent orchestrator. **Model is bound at the agent layer** (`openclaw agents add --model`) — it can't be overridden per task, and you can't pass `--model` or `--system-prompt` from Multica.
| | |
|---|---|
| Daemon looks for | `openclaw` |
| Install | See the project at [github.com/openclaw-org/openclaw](https://github.com/openclaw-org/openclaw) (community-maintained). |
| Authentication | Configure the underlying model provider per OpenClaw's docs. |
### Pi (Inflection AI)
Minimalist. **Session resumption is unusual** — the resume id is the path to a session file on disk, not a string id.
| | |
|---|---|
| Daemon looks for | `pi` |
| Install | See Inflection's CLI docs at [pi.ai](https://pi.ai/). |
| Authentication | Per the vendor's docs. |
## After installing
1. **Confirm the binary is on `PATH`.** Open a fresh terminal and run `which <name>` (for example `which claude`, `which cursor-agent`, `which kiro-cli`). If it prints a path, the daemon will find it. If it prints nothing, fix your shell `PATH` first (the typical cause is a per-shell rc file that wasn't reloaded).
2. **Restart the daemon.** `multica daemon restart`, or relaunch the desktop app. The daemon only scans `PATH` at startup.
3. **Check the Runtimes page.** In the Multica UI, the **Runtimes** page should now list one row per `(workspace × tool)` combination. If the row says "offline", see [Daemon and runtimes → When a runtime is marked offline](/daemon-runtimes#when-a-runtime-is-marked-offline).
4. **Go back to onboarding.** The "Connect a runtime" step polls and will pick up the new runtime within a few seconds — no need to refresh.
## Troubleshooting
- **`which` finds the binary but the daemon doesn't.** The daemon was started with an older `PATH`. Restart it.
- **The binary exists but launching fails.** Run the tool's own `--version` or `--help` once from the terminal — most failures here are missing auth, expired tokens, or a Node.js / runtime mismatch.
- **The Runtimes page shows the row, but tasks fail immediately.** Check `multica daemon logs -f` while triggering a task. The daemon surfaces the tool's own error output.
For broader symptoms, see the [Troubleshooting guide](/troubleshooting).
## Next
- [Daemon and runtimes](/daemon-runtimes) — how detection, heartbeats, and offline handling work
- [AI coding tools matrix](/providers) — capability differences once a tool is connected
- [Creating and configuring agents](/agents-create) — pick a tool for your agent and start running tasks

View File

@@ -1,169 +0,0 @@
---
title: 安装一个 Agent 运行时
description: Multica 驱动本机上已安装的 AI 编程工具。这一页讲清楚怎么安装目前支持的 11 款工具,让守护进程能扫到。
---
import { Callout } from "fumadocs-ui/components/callout";
在 Multica 里,一个**运行时**runtime就是你机器上的守护进程配上守护进程在 `PATH` 里扫到的某一款 AI 编程工具。如果 onboarding 的 "连接运行时" 这一步显示 **未检测到支持的工具**,说明守护进程扫了 `PATH`,但 11 款它认得的工具一个都没找到。装下面任意一款(或几款),回到这一步重新扫描,几秒内运行时就会出现。
这一页是装机的入口,和它配套的是:
- [守护进程与运行时](/zh/daemon-runtimes) — 检测是怎么工作的
- [AI 编程工具矩阵](/zh/providers) — 每款工具的能力差异会话续接、MCP、模型选择
<Callout type="info">
Multica 服务器从不接触你的 API key也不接触工具本身。下面这些操作 —— 安装、登录、模型访问 —— 全部发生在你本机。出问题几乎都是本地问题。
</Callout>
## 开始前
下面每一款工具都有两个共同前提:
1. **Multica 守护进程在运行。** 装完 [Multica CLI](/zh/cli) 后跑 `multica daemon start`;或者用 [Multica 桌面端](/zh/desktop-app),它启动时自动拉起守护进程。守护进程没起来,就没人去扫工具。
2. **工具的可执行文件在 `PATH` 上。** 守护进程通过名字 shell out 调起工具(见每一节里 **守护进程扫描** 那行的命令名)。终端里 `which <名字>` 找不到,守护进程也找不到。装完后打开新终端(或者重启守护进程),让新的 `PATH` 生效。
装完一款工具后,重启守护进程:
```bash
multica daemon restart
```
桌面端的话,重启 app 即可。守护进程只在启动时扫一次 `PATH`。
## 11 款支持的工具
大致按常见程度排序。挑你已经有账号 / API key 的那几款就行 —— 不需要 11 个全装。
### Claude CodeAnthropic
集成最完整的一款。会话续接好用MCP 好用,而且 **11 款里只有它真正会读 agent 配置里的 `mcp_config` 字段**(见[矩阵](/zh/providers))。
| | |
|---|---|
| 守护进程扫描 | `claude` |
| 安装 | 看官方指引 [claude.com/claude-code](https://www.claude.com/claude-code)。常见装法是 npm 包 `@anthropic-ai/claude-code`(需要 Node.js 18+)。 |
| 认证 | 跑一次 `claude`,跟着 CLI 里的登录流程走;或者设置 `ANTHROPIC_API_KEY`。 |
| 备注 | 新用户首选。 |
### CodexOpenAI
JSON-RPC 2.0 传输,审批粒度更细。**会话续接的代码在,但调不到** —— 要续接的话选 Claude Code 或 ACP 系列。
| | |
|---|---|
| 守护进程扫描 | `codex` |
| 安装 | 看官方指引 [github.com/openai/codex](https://github.com/openai/codex)。常见装法是 npm 包 `@openai/codex`。 |
| 认证 | `codex login`(浏览器登录),或 `OPENAI_API_KEY`。 |
### CursorAnysphere
Cursor 编辑器的 CLI 对应物。**会话续接是坏的** —— Cursor CLI 不返回 session id你传过去的续接 id 永远无效。
| | |
|---|---|
| 守护进程扫描 | `cursor-agent` |
| 安装 | 先装 [Cursor 编辑器](https://cursor.com/),再按 [docs.cursor.com](https://docs.cursor.com/) 的说明装 CLI。可执行文件叫 `cursor-agent`,不是 `cursor`。 |
| 认证 | 在 Cursor 编辑器里登录CLI 复用同一份会话。 |
### GitHub Copilot
模型走的是你 GitHub 账号的 entitlement —— 工具自己不挑模型GitHub 决定你拿到哪个模型。
| | |
|---|---|
| 守护进程扫描 | `copilot` |
| 安装 | 看 GitHub 的 CLI 文档 [github.com/github/copilot-cli](https://github.com/github/copilot-cli)。 |
| 认证 | CLI 里走 GitHub 浏览器登录。 |
| 备注 | 登录账号必须有有效的 GitHub Copilot 订阅。 |
### GeminiGoogle
支持 Gemini 2.5 和 3 系列。没有会话续接,没有 MCP —— 适合一次性、无需上下文记忆的任务。
| | |
|---|---|
| 守护进程扫描 | `gemini` |
| 安装 | 看官方指引 [github.com/google-gemini/gemini-cli](https://github.com/google-gemini/gemini-cli)。常见装法是 npm 包 `@google/gemini-cli`。 |
| 认证 | 跑 `gemini` 会提示 Google 账号登录,或设置 `GEMINI_API_KEY`。 |
### OpenCodeSST
开源 CLI agent。会从自己的配置文件里动态发现可用模型 —— 适合想自己掌控模型清单的用户。
| | |
|---|---|
| 守护进程扫描 | `opencode` |
| 安装 | 看官方指引 [opencode.ai](https://opencode.ai/) 或仓库 [github.com/sst/opencode](https://github.com/sst/opencode)。一般是装脚本或 npm 包。 |
| 认证 | 按 OpenCode 的文档配你自己的模型供应商Anthropic、OpenAI 等)。 |
### Kiro CLIAmazon
ACP-over-stdio 传输。会话续接通过 ACP `session/load` 工作skills 拷到 `.kiro/skills/`。
| | |
|---|---|
| 守护进程扫描 | `kiro-cli` |
| 安装 | 看 Kiro 的文档 [kiro.dev](https://kiro.dev/)。可执行文件叫 `kiro-cli`,不是 `kiro`。 |
| 认证 | 基于 AWS 账号,按 Kiro 自己的引导走。 |
### KimiMoonshot
ACP 协议 agent主要面向中国市场。Skills 放在 `.kimi/skills/`(原生发现路径)。
| | |
|---|---|
| 守护进程扫描 | `kimi` |
| 安装 | 看官方指引 [github.com/MoonshotAI/kimi-cli](https://github.com/MoonshotAI/kimi-cli)。 |
| 认证 | Moonshot API key按厂商文档配置。 |
### HermesNous Research
ACP 协议 agent和 Kimi 共享传输层。会话续接可用。Skill 注入用的是通用回退路径 `.agent_context/skills/` —— 用之前先验证 skills 真的被加载了。
| | |
|---|---|
| 守护进程扫描 | `hermes` |
| 安装 | 看 Nous Research 的仓库 [github.com/NousResearch](https://github.com/NousResearch) 获取最新 CLI。 |
| 认证 | 按厂商文档。 |
### OpenClaw
开源 CLI agent 编排器。**模型绑在 agent 层**`openclaw agents add --model`)—— 不能按任务覆盖,从 Multica 也传不了 `--model` / `--system-prompt`。
| | |
|---|---|
| 守护进程扫描 | `openclaw` |
| 安装 | 看项目 [github.com/openclaw-org/openclaw](https://github.com/openclaw-org/openclaw)(社区维护)。 |
| 认证 | 按 OpenClaw 的文档配底层模型供应商。 |
### PiInflection AI
极简风格。**会话续接的方式不太一样** —— resume id 是磁盘上的会话文件路径,不是字符串 id。
| | |
|---|---|
| 守护进程扫描 | `pi` |
| 安装 | 看 Inflection 的 CLI 文档 [pi.ai](https://pi.ai/)。 |
| 认证 | 按厂商文档。 |
## 装完之后
1. **确认可执行文件在 `PATH` 上。** 开一个新终端,跑 `which <名字>`(比如 `which claude`、`which cursor-agent`、`which kiro-cli`)。打印出路径,守护进程就找得到;什么都不打印,先修 shell 的 `PATH`(最常见原因是 rc 文件没重新加载)。
2. **重启守护进程。** `multica daemon restart`,或者重启桌面端。守护进程只在启动时扫一次 `PATH`。
3. **看 Runtimes 页面。** Multica UI 的 **Runtimes** 页应该会出现一行 `(工作区 × 工具)`。如果显示 "offline",看[守护进程与运行时 → 运行时何时被标记为离线](/zh/daemon-runtimes#运行时何时被标记为离线)。
4. **回到 onboarding。** "连接运行时" 这一步会一直轮询,几秒内就能扫到新运行时,不需要手动刷新。
## 排错
- **`which` 找得到,但守护进程找不到。** 守护进程是用旧 `PATH` 启的,重启它。
- **可执行文件在,但启动就失败。** 在终端单独跑一次工具的 `--version` 或 `--help`绝大多数失败都是登录没做、token 过期、Node.js / 运行时版本不对。
- **Runtimes 页面看到行,但任务一跑就失败。** 一边触发任务一边跑 `multica daemon logs -f`。守护进程会把工具自己的报错原样吐出来。
更宽的症状看[排错指南](/zh/troubleshooting)。
## 接下来
- [守护进程与运行时](/zh/daemon-runtimes) — 检测、心跳、离线处理
- [AI 编程工具矩阵](/zh/providers) — 工具连上之后的能力差异
- [创建并配置智能体](/zh/agents-create) — 给你的 agent 挑一款工具,开始跑任务

View File

@@ -19,7 +19,6 @@
"squads",
"---How agents run---",
"daemon-runtimes",
"install-agent-runtime",
"tasks",
"providers",
"---Collaborating with agents---",

View File

@@ -45,10 +45,6 @@ Once it's up:
- **Frontend**: [http://localhost:3000](http://localhost:3000)
- **Backend**: [http://localhost:8080](http://localhost:8080)
<Callout type="info">
**Ports listen on `127.0.0.1` only.** `docker-compose.selfhost.yml` binds every published port to loopback — `ss -tlnp` will not show `0.0.0.0:8080`, and the services are unreachable from other machines by design. The default `JWT_SECRET` and Postgres credentials must never sit on the open internet. For cross-machine access, front the stack with a reverse proxy that terminates TLS — see [Step 5b — Cross-machine: front with a reverse proxy](#5b-cross-machine-front-with-a-reverse-proxy).
</Callout>
## 2. Important: keep production safety on
<Callout type="warning">
@@ -103,53 +99,21 @@ Open [http://localhost:3000](http://localhost:3000):
## 5. Point the CLI at your own server
The CLI install is the same as in [Cloud quickstart → 2. Install the CLI](/cloud-quickstart#2-install-the-multica-cli) — Homebrew / script / PowerShell, pick one.
The CLI install is the same as in [Cloud quickstart → 2. Install the CLI](/cloud-quickstart#2-install-the-multica-cli) — Homebrew / script / PowerShell, pick one. Once installed, **use the self-host variant of the setup command**:
### 5a. Same machine
```bash
multica setup self-host --server-url http://<your-server-address>:8080 --app-url http://<your-server-address>:3000
```
If the CLI and the server run on the same host, the defaults already work:
If you're running everything on one local machine:
```bash
multica setup self-host
```
That points the CLI at `http://localhost:8080` (backend) and `http://localhost:3000` (frontend), takes you through browser login, stores the PAT locally, and **starts the daemon automatically**.
That defaults to `http://localhost:8080` (backend) and `http://localhost:3000` (frontend).
### 5b. Cross-machine: front with a reverse proxy
Because the compose stack only listens on `127.0.0.1`, a daemon on a different machine cannot reach `http://<server-ip>:8080` directly — and you do not want it to, since the default `JWT_SECRET` would otherwise be reachable from the open internet. Put a reverse proxy on the server that terminates TLS and forwards to `127.0.0.1:8080` (backend) and `127.0.0.1:3000` (frontend), then point the CLI at the public HTTPS URL:
```bash
multica setup self-host \
--server-url https://<your-domain> \
--app-url https://<your-domain>
```
A minimal Caddyfile that fronts both the frontend and the backend (with WebSocket support, which the daemon and the web app both need) on a single hostname:
```nginx
multica.example.com {
# WebSocket route — must come before the catch-all
@ws path /ws /ws/*
handle @ws {
reverse_proxy 127.0.0.1:8080 {
flush_interval -1
}
}
# Backend API
handle /api/* {
reverse_proxy 127.0.0.1:8080
}
# Everything else → frontend
reverse_proxy 127.0.0.1:3000
}
```
After bringing the proxy up, set `FRONTEND_ORIGIN=https://multica.example.com` in the server's `.env` and restart the backend — otherwise the WebSocket origin check will reject the browser ([Troubleshooting → WebSocket can't connect](/troubleshooting#websocket-cant-connect)).
[Cloudflare Tunnel](https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/) is another solid option — it gives you TLS and a public hostname without exposing any port on the host at all. An Nginx equivalent (separate `app.` / `api.` hostnames, `proxy_set_header Upgrade` for WebSockets) works just as well; the key requirements are TLS termination and forwarding the `Upgrade` header on `/ws`.
`setup self-host` takes you through browser login, stores the PAT locally, and **starts the daemon automatically**.
## 6. Create an agent + assign your first task

View File

@@ -44,10 +44,6 @@ make selfhost
- **前端**[http://localhost:3000](http://localhost:3000)
- **后端**[http://localhost:8080](http://localhost:8080)
<Callout type="info">
**所有端口只监听 `127.0.0.1`。** `docker-compose.selfhost.yml` 把每个 publish 出来的端口都绑到 loopback —— `ss -tlnp` 不会看到 `0.0.0.0:8080`,外网/其它机器默认根本连不上。这是为了避免默认 `JWT_SECRET` 和 Postgres 凭据被直接暴露到公网。要做跨机访问,请用反向代理在前面终结 TLS详见下方 [Step 5b —— 跨机访问:用反向代理把服务挡在前面](#5b-跨机访问用反向代理把服务挡在前面)。
</Callout>
## 2. 重要:保持生产安全配置
<Callout type="warning">
@@ -102,53 +98,21 @@ RESEND_FROM_EMAIL=noreply@yourdomain.com # 同时作为 SMTP From: 头
## 5. 连接命令行工具到你自己的 server
命令行装法和 [Cloud 快速上手 → 2. 装命令行工具](/cloud-quickstart#2-装-multica-命令行工具) 一样——Homebrew / 脚本 / PowerShell 任选。
命令行装法和 [Cloud 快速上手 → 2. 装命令行工具](/cloud-quickstart#2-装-multica-命令行工具) 一样——Homebrew / 脚本 / PowerShell 任选。装好之后,**用 self-host 版本的 setup 命令**
### 5a. 同一台机器
```bash
multica setup self-host --server-url http://<你的服务器地址>:8080 --app-url http://<你的服务器地址>:3000
```
CLI 和 server 在同一台机器上时,默认参数就够用
本地就是一台电脑跑整套的话
```bash
multica setup self-host
```
会自动连 `http://localhost:8080`backend+ `http://localhost:3000`frontend,引导你在浏览器里登录、把 PAT 存到本地、**自动启动守护进程**
默认连 `http://localhost:8080`backend+ `http://localhost:3000`frontend
### 5b. 跨机访问:用反向代理把服务挡在前面
因为 compose 默认只监听 `127.0.0.1`,从别的机器跑的 daemon 是连不上 `http://<server-ip>:8080` 的——这也是有意为之,否则默认 `JWT_SECRET` 等于直接暴露在公网。正确做法是在 server 上跑一个反向代理Caddy / nginx / Cloudflare Tunnel由它终结 TLS再反代到 `127.0.0.1:8080`backend和 `127.0.0.1:3000`frontend。然后把 CLI 指到公开的 HTTPS 域名:
```bash
multica setup self-host \
--server-url https://<你的域名> \
--app-url https://<你的域名>
```
最小可用的 Caddyfile单域名同时挂前后端带 WebSocket 转发daemon 和网页端都依赖):
```nginx
multica.example.com {
# WebSocket 路由——必须在 catch-all 之前
@ws path /ws /ws/*
handle @ws {
reverse_proxy 127.0.0.1:8080 {
flush_interval -1
}
}
# Backend API
handle /api/* {
reverse_proxy 127.0.0.1:8080
}
# 其它请求 → 前端
reverse_proxy 127.0.0.1:3000
}
```
代理起好之后,记得在 server 的 `.env` 里把 `FRONTEND_ORIGIN` 设成 `https://multica.example.com` 并重启后端,否则 WebSocket 的 origin 校验会把浏览器拒掉(见 [故障排查 → WebSocket 连不上](/troubleshooting#websocket-连不上))。
[Cloudflare Tunnel](https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/) 也是不错的选择——它直接给一个公开域名 + TLShost 上不用对外暴露任何端口。Nginx 也能做(分 `app.` / `api.` 两个域名 + `proxy_set_header Upgrade` 转 WebSocket关键就是终结 TLS、并在 `/ws` 上转发 `Upgrade` 头。
`setup self-host` 会让你在浏览器里完成登录,把 PAT 存到本地,**自动启动守护进程**。
## 6. 创建智能体 + 分配第一个任务

View File

@@ -126,7 +126,7 @@ There is currently no unarchive command; create a new squad if you need the rout
| `multica squad member remove <id> --member-id <uuid> --type agent\|member` | Remove a member (the leader cannot be removed — change leader first) |
| `multica squad activity <issue-id> <action\|no_action\|failed> --reason "..."` | Recorded by the leader agent at the end of every turn |
`--leader` accepts an agent name or UUID; for everything else, IDs come from `multica agent list --output json`, `multica workspace member list --output json`, and `multica squad list --output json`.
`--leader` accepts an agent name or UUID; for everything else, IDs come from `multica agent list --output json`, `multica workspace members --output json`, and `multica squad list --output json`.
## Next

View File

@@ -126,7 +126,7 @@ multica squad member add <squad-id> --member-id <agent-or-user-uuid> --type agen
| `multica squad member remove <id> --member-id <uuid> --type agent\|member` | 移除成员(**不能移除队长**——先换队长)|
| `multica squad activity <issue-id> <action\|no_action\|failed> --reason "..."` | 队长每次结束前由它自己调用 |
`--leader` 接受智能体名字或 UUID其它 ID 从 `multica agent list --output json`、`multica workspace member list --output json`、`multica squad list --output json` 拿。
`--leader` 接受智能体名字或 UUID其它 ID 从 `multica agent list --output json`、`multica workspace members --output json`、`multica squad list --output json` 拿。
## 下一步

View File

@@ -13,7 +13,7 @@ Three things get decided when you create a workspace:
- **Workspace name** — the display name members see. Spaces and non-ASCII characters are allowed. You can change it later.
- **Slug** — the string used in the workspace URL. Lowercase letters and digits only (joined with `-`). **It cannot be changed after creation**, so pick carefully. If the slug is taken or hits a system-reserved word, the create screen will ask you to choose another.
- **Issue prefix** — the prefix for every issue number in the workspace (the `MUL` in `MUL-123`). Uppercase letters and digits, up to 10 characters.
- **Issue prefix** — the prefix for every issue number in the workspace (the `MUL` in `MUL-123`). Use uppercase letters.
<Callout type="warning">
**Avoid changing the issue prefix.** Issue numbers are rendered with the current prefix — change it and `MUL-5` instantly becomes `NEW-5`. Every external link, Slack mention, and historical reference in comments breaks against the old number. Treat the issue prefix as "set at creation, never touched."

View File

@@ -13,7 +13,7 @@ import { Callout } from "fumadocs-ui/components/callout";
- **工作区名字** — 给成员看的显示名称,可以包含空格和中文。后续随时能改。
- **Slug短链标识符** — 工作区 URL 中使用的字符串,只能是小写字母和数字(用 `-` 连接)。**创建后不能改**,提前想好。如果 slug 已被占用或命中系统保留词,创建界面会让你换一个。
- **Issue 前缀** — 工作区里所有 issue 编号的前缀(比如 `MUL-123` 里的 `MUL`)。只能是大写字母和数字,最长 10 个字符
- **Issue 前缀** — 工作区里所有 issue 编号的前缀(比如 `MUL-123` 里的 `MUL`)。使用大写字母。
<Callout type="warning">
**尽量不要修改 issue 前缀。** 系统在展示 issue 编号时会用当前的前缀——改了之后,`MUL-5` 会立刻变成 `NEW-5`。所有外部链接、Slack 提及、评论里的历史引用都会对不上旧编号。把 issue 前缀当成"创建后不改"的设计来对待。

View File

@@ -1,6 +1,6 @@
"use client";
import { useEffect, useRef } from "react";
import { useEffect } from "react";
import { useRouter } from "next/navigation";
import { useQuery } from "@tanstack/react-query";
import { useAuthStore } from "@multica/core/auth";
@@ -17,9 +17,9 @@ import { CliInstallInstructions, OnboardingFlow } from "@multica/views/onboardin
* web (matching `WindowOverlay` on desktop); content is the shared
* `<OnboardingFlow />`. Kept minimal — guard on auth, render, exit.
*
* On complete: runtime-connected onboarding may provide a guide issue id;
* navigate there. Otherwise land on the workspace issues list, or root if
* the flow never produced a workspace.
* On complete: if a workspace was just created, navigate into it;
* otherwise fall back to root (proxy / landing picks the user's first ws
* or bounces to onboarding if still zero).
*
* `CliInstallInstructions` is passed in as the `runtimeInstructions`
* slot so the flow can render it inside the CLI dialog. The commands it
@@ -34,14 +34,6 @@ export default function OnboardingPage() {
...workspaceListOptions(),
enabled: !!user,
});
// The bootstrap path calls refreshMe() before returning, which flips
// hasOnboarded to true while the page is still mounted. Without this
// flag the guard below races onComplete: the guard's router.replace
// (issues list) can overtake onComplete's router.push (guide issue),
// dropping the user on the wrong destination. Marking the page as
// "completing" right before onComplete navigates keeps the guard
// silent for the in-flight transition.
const completingRef = useRef(false);
useEffect(() => {
if (isLoading || !user) {
@@ -49,7 +41,6 @@ export default function OnboardingPage() {
return;
}
if (!workspacesFetched) return;
if (completingRef.current) return;
// Bounce out only when onboarding genuinely doesn't apply: the user is
// already onboarded. We deliberately don't bounce on `workspaces.length`
// here — Step 3 of the flow creates a workspace mid-onboarding, and a
@@ -71,14 +62,12 @@ export default function OnboardingPage() {
return (
<div className="h-full overflow-y-auto bg-background">
<OnboardingFlow
onComplete={(ws, issueId) => {
// Runtime-connected onboarding now creates one focused
// onboarding issue. Skip/runtime-less exits still land on the
// workspace issues list.
completingRef.current = true;
if (ws && issueId) {
router.push(paths.workspace(ws.slug).issueDetail(issueId));
} else if (ws) {
onComplete={(ws) => {
// No more firstIssueId handoff — the welcome issue is created
// inside the workspace via StarterContentPrompt, not during
// onboarding. Always land on the workspace issues list (or
// root if the flow never produced a workspace).
if (ws) {
router.push(paths.workspace(ws.slug).issues());
} else {
router.push(paths.root());

View File

@@ -1,26 +0,0 @@
"use client";
import { use } from "react";
import { useSearchParams } from "next/navigation";
import { AttachmentPreviewPage } from "@multica/views/attachments";
import { ErrorBoundary } from "@multica/ui/components/common/error-boundary";
// Lives at /:slug/attachments/:id/preview — OUTSIDE the (dashboard) group on
// purpose. The dashboard layout adds a left sidebar + top chrome; this page
// wants the full viewport for the HTML iframe. Workspace resolution still
// happens in the parent [workspaceSlug] layout so useWorkspaceId() works.
export default function AttachmentPreviewWebPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = use(params);
const search = useSearchParams();
const filename = search.get("name") ?? undefined;
return (
<ErrorBoundary resetKeys={[id]}>
<AttachmentPreviewPage attachmentId={id} filename={filename} />
</ErrorBoundary>
);
}

View File

@@ -284,59 +284,6 @@ export function createEnDict(allowSignup: boolean): LandingDict {
fixes: "Bug Fixes",
},
entries: [
{
version: "0.3.3",
date: "2026-05-19",
title: "Project Timelines, Runtime Setup & Clearer Issue Work",
changes: [],
features: [
"Projects now have a Gantt view for scheduled work, with updates that stay in sync as plans change",
"Workspace admins can change the issue key prefix from settings",
"The CLI can switch between workspaces and show the current workspace",
"Agents can read issue threads from the most recent discussion first, making follow-up work easier to route and review",
"Usage now includes a one-day view plus weekly trends that respect the selected timezone",
"Agent detail pages now work as an issue board for that specific agent",
],
improvements: [
"The onboarding flow now asks one focused question at a time and can guide runtime setup with fewer manual steps",
"My Issues now includes squad-assigned work and labels the team-related tab more clearly",
"Agent execution logs can be sorted in either direction when reviewing a run",
],
fixes: [
"HTML previews open more predictably from desktop, close the full-screen modal when needed, and support in-page links",
"HTML source view and attachment previews are easier to inspect, including opening content in a new tab",
"Create-issue prompts no longer keep stale manual draft text when switching modes",
"Runtime tasks now find the right workspace instructions and skills from the task folder",
"Self-hosted teams can set how long auth sessions last",
],
},
{
version: "0.3.2",
date: "2026-05-18",
title:
"Webhook Autopilots, Clearer Workboards & Better Runtime Control",
changes: [],
features: [
"Autopilots can now start from webhook events, show delivery history, and replay a delivery when a connected system needs another attempt",
"Issue boards can group work by assignee, show linked pull request status, and include start dates for clearer planning",
"Runtime pages now have a redesigned machine view plus time and task trends in usage charts",
"Skills can be copied from local runtimes in bulk, making workspace setup faster",
"HTML attachments and HTML code blocks can be previewed directly inside issue discussions",
],
improvements: [
"Failed issue actions now show clearer error messages so teams can understand what happened without digging through logs",
"GitHub-linked pull requests now surface CI and merge-conflict status inside Multica",
"Self-hosted deployments get safer defaults and clearer guidance for reverse proxies, auth limits, and local-only services",
"Search results are ranked more usefully and include better snippets",
],
fixes: [
"Autopilot-created issues can repeat reliably and are attributed to the right assignee agent",
"Runtime setup now prefers the local machine by default and uses cleaner labels in machine lists",
"Squad pages scroll correctly and show which members are already working",
"Desktop zoom shortcuts work again across the common keyboard combinations",
"Auth, dependency, and local-service updates improve the safety of hosted and self-hosted deployments",
],
},
{
version: "0.3.1",
date: "2026-05-15",

View File

@@ -284,58 +284,6 @@ export function createZhDict(allowSignup: boolean): LandingDict {
fixes: "问题修复",
},
entries: [
{
version: "0.3.3",
date: "2026-05-19",
title: "项目时间线、运行环境设置与更清晰的任务协作",
changes: [],
features: [
"项目现在提供甘特图视图,用于查看有排期的工作,并会在计划变化时实时同步",
"Workspace 管理员可以在设置中调整 Issue 编号前缀",
"命令行可以切换 workspace 并查看当前 workspace",
"Agent 现在可以优先读取最新的 Issue 讨论线程,后续跟进和审查更贴近当前上下文",
"Usage 新增 1 天视图和按周趋势,并会遵循所选时区",
"Agent 详情页现在是对应智能体的 Issue 看板",
],
improvements: [
"Onboarding 改为一次回答一个问题,并能用更少步骤引导 runtime 设置",
"My Issues 会包含分配给小队的工作,相关标签也更容易理解",
"查看智能体执行日志时可以切换排序方向,回看运行过程更方便",
],
fixes: [
"桌面端打开 HTML 预览更稳定,必要时会关闭全屏窗口,并支持页面内链接跳转",
"HTML 源码视图和附件预览更容易检查,也可以把内容打开到新标签页",
"切换创建 Issue 模式时,提示词里不再残留旧的手写草稿",
"Runtime 任务会从任务目录读取正确的 workspace 指令和 skills",
"自托管团队可以设置登录会话有效期",
],
},
{
version: "0.3.2",
date: "2026-05-18",
title: "Webhook 自动任务、更清晰的工作看板与更稳的运行环境",
changes: [],
features: [
"Autopilot 现在可以由 webhook 事件触发,并能查看投递记录,在外部系统需要时重新投递一次",
"Issue 看板支持按负责人分组,展示关联 Pull Request 状态,并加入开始日期,排期更清楚",
"Runtime 页面升级了机器视图,并在用量图表中加入时间和任务趋势",
"Skills 支持从本地 runtime 批量复制到 workspace团队初始化更快",
"HTML 附件和 HTML 代码块可以直接在 Issue 讨论中预览",
],
improvements: [
"Issue 操作失败时会显示更明确的错误原因,团队不用翻日志也能理解发生了什么",
"关联 GitHub 的 Pull Request 会在 Multica 内展示 CI 和合并冲突状态",
"自托管部署获得更安全的默认配置,并补充反向代理、登录限制和本地服务的说明",
"搜索结果排序更准确,也会展示更有帮助的摘要片段",
],
fixes: [
"Autopilot 创建 Issue 时可以稳定重复触发,并正确归属到负责的 assignee agent",
"Runtime 设置默认优先选择本地机器,机器列表中的名称也更清晰",
"Squad 页面可以正常滚动,并能看到成员当前是否已经在处理工作",
"桌面端缩放快捷键在常见组合下恢复正常",
"登录、安全补丁和本地服务配置更新,让托管版和自托管部署都更安全",
],
},
{
version: "0.3.1",
date: "2026-05-15",

View File

@@ -1,13 +1,5 @@
# Self-hosting Docker Compose — starts PostgreSQL, backend, and frontend.
#
# Services bind to 127.0.0.1 only. For cross-machine or public access, front
# them with a reverse proxy (Caddy / nginx / Cloudflare Tunnel) that terminates
# TLS and forwards to 127.0.0.1:8080 (backend) and 127.0.0.1:3000 (frontend).
# Do NOT change these bindings to 0.0.0.0 — Docker bypasses host firewalls
# (UFW/iptables) by default, so the raw ports would be exposed to the internet
# with the default JWT_SECRET and Postgres credentials. See:
# apps/docs/content/docs/self-host-quickstart.mdx
#
# Usage:
# cp .env.example .env
# # Edit .env — change JWT_SECRET at minimum
@@ -26,7 +18,7 @@ services:
POSTGRES_USER: ${POSTGRES_USER:-multica}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-multica}
ports:
- "127.0.0.1:${POSTGRES_PORT:-5432}:5432"
- "${POSTGRES_PORT:-5432}:5432"
volumes:
- pgdata:/var/lib/postgresql/data
restart: unless-stopped
@@ -42,7 +34,7 @@ services:
postgres:
condition: service_healthy
ports:
- "127.0.0.1:${PORT:-8080}:8080"
- "${PORT:-8080}:8080"
volumes:
- backend_uploads:/app/data/uploads
environment:
@@ -76,19 +68,6 @@ services:
ALLOWED_EMAIL_DOMAINS: ${ALLOWED_EMAIL_DOMAINS:-}
GITHUB_APP_SLUG: ${GITHUB_APP_SLUG:-}
GITHUB_WEBHOOK_SECRET: ${GITHUB_WEBHOOK_SECRET:-}
# Public URL the API is reachable at from the open internet, no
# trailing slash. Used to mint absolute webhook URLs for autopilot
# webhook triggers. Leave unset behind a same-origin reverse proxy
# (e.g. plain localhost dev); the frontend will compose the URL
# from window.origin + webhook_path in that case. Headers are
# intentionally NOT used to derive this value, to avoid Host /
# X-Forwarded-Host spoofing on misconfigured proxies.
MULTICA_PUBLIC_URL: ${MULTICA_PUBLIC_URL:-}
# Comma-separated CIDRs whose source IP is allowed to set
# X-Forwarded-For / X-Real-IP for the webhook per-IP rate limiter.
# Empty default = headers ignored, RemoteAddr used. Set e.g.
# "127.0.0.1/32" when running behind a same-host reverse proxy.
MULTICA_TRUSTED_PROXIES: ${MULTICA_TRUSTED_PROXIES:-}
restart: unless-stopped
frontend:
@@ -96,7 +75,7 @@ services:
depends_on:
- backend
ports:
- "127.0.0.1:${FRONTEND_PORT:-3000}:3000"
- "${FRONTEND_PORT:-3000}:3000"
environment:
HOSTNAME: "0.0.0.0"
restart: unless-stopped

View File

@@ -8,7 +8,7 @@ services:
POSTGRES_USER: ${POSTGRES_USER:-multica}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-multica}
ports:
- "127.0.0.1:5432:5432"
- "5432:5432"
volumes:
- pgdata:/var/lib/postgresql/data

View File

@@ -1,111 +0,0 @@
import { test, expect } from "@playwright/test";
import { TestApiClient } from "./fixtures";
// Smoke test for Onboarding V2: verifies the new per-question flow
// renders and captures screenshots for review. Uses a unique email
// per run so the user is always a fresh, un-onboarded user landing
// on /onboarding.
const EMAIL = `onboarding-v2-${Date.now()}@localhost`;
const SHOTS_DIR = "/tmp/onboarding-v2-shots";
test.use({ viewport: { width: 1440, height: 900 } });
test("onboarding v2 — welcome → source → role → use_case (skip path)", async ({ page }) => {
const api = new TestApiClient();
await api.login(EMAIL, "OBv2 Tester");
const token = api.getToken();
await page.goto("/login");
await page.evaluate((t) => {
localStorage.setItem("multica_token", t);
}, token);
await page.goto("/onboarding");
await page.waitForLoadState("networkidle");
// 1. Welcome screen
await expect(page.getByRole("button", { name: "Continue on web" })).toBeVisible({ timeout: 15000 });
await page.screenshot({ path: `${SHOTS_DIR}/01-welcome.png`, fullPage: false });
// Click Start exploring to advance to Source
await page.getByRole("button", { name: "Continue on web" }).click();
// 2. Source step
await expect(page.getByText("How did you hear about Multica?")).toBeVisible({ timeout: 10000 });
await expect(page.getByText(`Step 1 of 6`)).toBeVisible();
await page.waitForTimeout(500);
await page.screenshot({ path: `${SHOTS_DIR}/02-source.png` });
// Pick Friends/colleagues then click Continue to advance.
await page.getByRole("radio", { name: /Friends or colleagues/i }).click();
await page.getByRole("button", { name: "Continue" }).click();
// 3. Role step
await expect(page.getByText("Which best describes you?")).toBeVisible({ timeout: 10000 });
await expect(page.getByText(`Step 2 of 6`)).toBeVisible();
await page.waitForTimeout(500);
await page.screenshot({ path: `${SHOTS_DIR}/03-role.png` });
// Skip role
await page.getByRole("button", { name: "Skip" }).click();
// 4. Use case step
await expect(page.getByText("What do you want to use Multica for?")).toBeVisible({ timeout: 10000 });
await expect(page.getByText(`Step 3 of 6`)).toBeVisible();
await page.waitForTimeout(500);
await page.screenshot({ path: `${SHOTS_DIR}/04-use-case.png` });
// Pick ship_code then Continue → workspace step.
await page.getByRole("radio", { name: /Ship code with AI agents/i }).click();
await page.getByRole("button", { name: "Continue" }).click();
// 5. Workspace step (legacy)
await expect(page.getByRole("heading", { name: /Name your workspace/i })).toBeVisible({ timeout: 10000 });
await page.screenshot({ path: `${SHOTS_DIR}/05-workspace.png` });
});
test("onboarding v2 — rage-skip all 3 questions", async ({ page }) => {
const api = new TestApiClient();
await api.login(`rage-skip-${Date.now()}@localhost`, "Rage Skipper");
const token = api.getToken();
await page.goto("/login");
await page.evaluate((t) => localStorage.setItem("multica_token", t), token);
await page.goto("/onboarding");
await page.waitForLoadState("networkidle");
await page.getByRole("button", { name: "Continue on web" }).click();
await expect(page.getByText("How did you hear about Multica?")).toBeVisible({ timeout: 10000 });
// Skip × 3
await page.getByRole("button", { name: "Skip" }).click();
await expect(page.getByText("Which best describes you?")).toBeVisible({ timeout: 10000 });
await page.getByRole("button", { name: "Skip" }).click();
await expect(page.getByText("What do you want to use Multica for?")).toBeVisible({ timeout: 10000 });
await page.getByRole("button", { name: "Skip" }).click();
// Lands on workspace step
await expect(page.getByRole("heading", { name: /Name your workspace/i })).toBeVisible({ timeout: 10000 });
await page.screenshot({ path: `${SHOTS_DIR}/06-after-rage-skip.png` });
});
test("onboarding v2 — zh-Hans renders Chinese labels", async ({ page, context }) => {
await context.addCookies([
{ name: "multica-locale", value: "zh-Hans", url: "http://localhost:13442" },
]);
const api = new TestApiClient();
await api.login(`zh-${Date.now()}@localhost`, "中文用户");
const token = api.getToken();
await page.goto("/login");
await page.evaluate((t) => localStorage.setItem("multica_token", t), token);
await page.goto("/onboarding");
await page.waitForLoadState("networkidle");
await page.getByRole("button").first().click().catch(() => {});
// Source screen — Chinese question
await expect(page.getByText("你是从哪里了解到 Multica 的?")).toBeVisible({ timeout: 10000 });
await page.waitForTimeout(500);
await page.screenshot({ path: `${SHOTS_DIR}/07-source-zh.png` });
});

View File

@@ -3,7 +3,3 @@ export {
type AgentsScope,
type AgentsViewState,
} from "./view-store";
export {
useTranscriptViewStore,
type TranscriptSortDirection,
} from "./transcript-view-store";

View File

@@ -1,22 +0,0 @@
import { beforeEach, describe, expect, it } from "vitest";
import { useTranscriptViewStore } from "./transcript-view-store";
beforeEach(() => {
useTranscriptViewStore.setState({ sortDirection: "chronological" });
});
describe("useTranscriptViewStore", () => {
it("defaults to chronological so existing readers see no behavior change", () => {
expect(useTranscriptViewStore.getState().sortDirection).toBe("chronological");
});
it("setSortDirection switches between the two known directions", () => {
const { setSortDirection } = useTranscriptViewStore.getState();
setSortDirection("newest_first");
expect(useTranscriptViewStore.getState().sortDirection).toBe("newest_first");
setSortDirection("chronological");
expect(useTranscriptViewStore.getState().sortDirection).toBe("chronological");
});
});

View File

@@ -1,26 +0,0 @@
"use client";
import { create } from "zustand";
import { createJSONStorage, persist } from "zustand/middleware";
import { defaultStorage } from "../../platform/storage";
export type TranscriptSortDirection = "chronological" | "newest_first";
interface TranscriptViewState {
sortDirection: TranscriptSortDirection;
setSortDirection: (dir: TranscriptSortDirection) => void;
}
export const useTranscriptViewStore = create<TranscriptViewState>()(
persist(
(set) => ({
sortDirection: "chronological",
setSortDirection: (sortDirection) => set({ sortDirection }),
}),
{
name: "multica_transcript_view",
storage: createJSONStorage(() => defaultStorage),
partialize: (state) => ({ sortDirection: state.sortDirection }),
},
),
);

View File

@@ -62,7 +62,6 @@ describe("ApiClient", () => {
});
await client.updateAutopilotTrigger("ap-1", "tr-1", { enabled: false });
await client.deleteAutopilotTrigger("ap-1", "tr-1");
await client.rotateAutopilotTriggerWebhookToken("ap-1", "tr-1");
const calls = fetchMock.mock.calls.map(([url, init]) => ({
url,
@@ -105,10 +104,6 @@ describe("ApiClient", () => {
body: JSON.stringify({ enabled: false }),
},
{ url: "https://api.example.test/api/autopilots/ap-1/triggers/tr-1", method: "DELETE" },
{
url: "https://api.example.test/api/autopilots/ap-1/triggers/tr-1/rotate-webhook-token",
method: "POST",
},
]);
});

View File

@@ -89,8 +89,6 @@ import type {
ListAutopilotsResponse,
GetAutopilotResponse,
ListAutopilotRunsResponse,
ListWebhookDeliveriesResponse,
WebhookDelivery,
NotificationPreferenceResponse,
NotificationPreferences,
GitHubPullRequest,
@@ -98,7 +96,6 @@ import type {
GitHubConnectResponse,
Squad,
SquadMember,
SquadMemberStatusListResponse,
} from "../types";
import type { OnboardingCompletionPath } from "../onboarding/types";
import { type Logger, noopLogger } from "../logger";
@@ -122,19 +119,11 @@ import {
EMPTY_CREATE_AGENT_FROM_TEMPLATE_RESPONSE,
EMPTY_GROUPED_ISSUES_RESPONSE,
EMPTY_LIST_ISSUES_RESPONSE,
EMPTY_SQUAD_MEMBER_STATUS_LIST,
EMPTY_TIMELINE_ENTRIES,
EMPTY_LIST_WEBHOOK_DELIVERIES_RESPONSE,
EMPTY_WEBHOOK_DELIVERY,
GroupedIssuesResponseSchema,
ListIssuesResponseSchema,
ListWebhookDeliveriesResponseSchema,
OnboardingNoRuntimeBootstrapResponseSchema,
OnboardingRuntimeBootstrapResponseSchema,
SquadMemberStatusListResponseSchema,
SubscribersListSchema,
TimelineEntriesSchema,
WebhookDeliveryResponseSchema,
} from "./schemas";
/** Identifies the calling client to the server.
@@ -162,30 +151,6 @@ export interface LoginResponse {
user: User;
}
export interface OnboardingRuntimeBootstrapResponse {
workspace_id: string;
agent_id: string;
issue_id: string;
}
const EMPTY_ONBOARDING_RUNTIME_BOOTSTRAP_RESPONSE:
OnboardingRuntimeBootstrapResponse = {
workspace_id: "",
agent_id: "",
issue_id: "",
};
export interface OnboardingNoRuntimeBootstrapResponse {
workspace_id: string;
issue_id: string;
}
const EMPTY_ONBOARDING_NO_RUNTIME_BOOTSTRAP_RESPONSE:
OnboardingNoRuntimeBootstrapResponse = {
workspace_id: "",
issue_id: "",
};
// --- Starter content (post-onboarding import) -----------------------------
// Shape mirrors the Go request/response in handler/onboarding.go.
//
@@ -440,43 +405,6 @@ export class ApiClient {
});
}
async bootstrapOnboardingRuntime(payload: {
workspace_id: string;
runtime_id: string;
}): Promise<OnboardingRuntimeBootstrapResponse> {
const raw = await this.fetch<unknown>(
"/api/me/onboarding/runtime-bootstrap",
{
method: "POST",
body: JSON.stringify(payload),
},
);
return parseWithFallback(
raw,
OnboardingRuntimeBootstrapResponseSchema,
EMPTY_ONBOARDING_RUNTIME_BOOTSTRAP_RESPONSE,
{ endpoint: "POST /api/me/onboarding/runtime-bootstrap" },
);
}
async bootstrapOnboardingNoRuntime(payload: {
workspace_id: string;
}): Promise<OnboardingNoRuntimeBootstrapResponse> {
const raw = await this.fetch<unknown>(
"/api/me/onboarding/no-runtime-bootstrap",
{
method: "POST",
body: JSON.stringify(payload),
},
);
return parseWithFallback(
raw,
OnboardingNoRuntimeBootstrapResponseSchema,
EMPTY_ONBOARDING_NO_RUNTIME_BOOTSTRAP_RESPONSE,
{ endpoint: "POST /api/me/onboarding/no-runtime-bootstrap" },
);
}
async joinCloudWaitlist(payload: {
email: string;
reason?: string;
@@ -543,9 +471,7 @@ export class ApiClient {
if (params?.assignee_ids?.length) search.set("assignee_ids", params.assignee_ids.join(","));
if (params?.creator_id) search.set("creator_id", params.creator_id);
if (params?.project_id) search.set("project_id", params.project_id);
if (params?.involves_user_id) search.set("involves_user_id", params.involves_user_id);
if (params?.open_only) search.set("open_only", "true");
if (params?.scheduled) search.set("scheduled", "true");
const path = `/api/issues?${search}`;
const raw = await this.fetch<unknown>(path);
return parseWithFallback(raw, ListIssuesResponseSchema, EMPTY_LIST_ISSUES_RESPONSE, {
@@ -565,7 +491,6 @@ export class ApiClient {
if (params.assignee_ids?.length) search.set("assignee_ids", params.assignee_ids.join(","));
if (params.creator_id) search.set("creator_id", params.creator_id);
if (params.project_id) search.set("project_id", params.project_id);
if (params.involves_user_id) search.set("involves_user_id", params.involves_user_id);
if (params.assignee_filters?.length) {
search.set("assignee_filters", params.assignee_filters.map((f) => `${f.type}:${f.id}`).join(","));
}
@@ -1168,7 +1093,7 @@ export class ApiClient {
});
}
async updateWorkspace(id: string, data: { name?: string; description?: string; context?: string; settings?: Record<string, unknown>; repos?: WorkspaceRepo[]; issue_prefix?: string }): Promise<Workspace> {
async updateWorkspace(id: string, data: { name?: string; description?: string; context?: string; settings?: Record<string, unknown>; repos?: WorkspaceRepo[] }): Promise<Workspace> {
return this.fetch(`/api/workspaces/${id}`, {
method: "PATCH",
body: JSON.stringify(data),
@@ -1613,17 +1538,6 @@ export class ApiClient {
return this.fetch(`/api/squads/${squadId}/members/role`, { method: "PATCH", body: JSON.stringify(data) });
}
// Per-squad members status snapshot: one row per member with derived
// working/idle/offline/unstable plus the issues each agent is currently
// running. Parsed with a lenient schema so a new server-side status
// value or extra field can't white-screen the Squad page (#2143).
async getSquadMemberStatus(squadId: string): Promise<SquadMemberStatusListResponse> {
const raw = await this.fetch<unknown>(`/api/squads/${squadId}/members/status`);
return parseWithFallback(raw, SquadMemberStatusListResponseSchema, EMPTY_SQUAD_MEMBER_STATUS_LIST, {
endpoint: "GET /api/squads/:id/members/status",
}) as SquadMemberStatusListResponse;
}
// Autopilots
async listAutopilots(params?: { status?: string }): Promise<ListAutopilotsResponse> {
const search = new URLSearchParams();
@@ -1664,13 +1578,6 @@ export class ApiClient {
return this.fetch(`/api/autopilots/${id}/runs?${search}`);
}
// Returns a single run including its full trigger_payload. List responses
// omit trigger_payload to keep them small (a webhook envelope can be
// up to 256 KiB × limit rows), so the detail view fetches via this route.
async getAutopilotRun(autopilotId: string, runId: string): Promise<AutopilotRun> {
return this.fetch(`/api/autopilots/${autopilotId}/runs/${runId}`);
}
async createAutopilotTrigger(autopilotId: string, data: CreateAutopilotTriggerRequest): Promise<AutopilotTrigger> {
return this.fetch(`/api/autopilots/${autopilotId}/triggers`, {
method: "POST",
@@ -1689,74 +1596,6 @@ export class ApiClient {
await this.fetch(`/api/autopilots/${autopilotId}/triggers/${triggerId}`, { method: "DELETE" });
}
async rotateAutopilotTriggerWebhookToken(
autopilotId: string,
triggerId: string,
): Promise<AutopilotTrigger> {
return this.fetch(
`/api/autopilots/${autopilotId}/triggers/${triggerId}/rotate-webhook-token`,
{ method: "POST" },
);
}
// Webhook deliveries — list is slim (no raw_body / selected_headers /
// response_body); detail returns the full row. Both responses are parsed
// through a lenient schema so an unknown server-side `status` /
// `signature_status` value degrades to a generic row instead of dropping
// the whole list.
async listAutopilotDeliveries(
autopilotId: string,
params?: { limit?: number; offset?: number },
): Promise<ListWebhookDeliveriesResponse> {
const search = new URLSearchParams();
if (params?.limit) search.set("limit", params.limit.toString());
if (params?.offset) search.set("offset", params.offset.toString());
const raw = await this.fetch<unknown>(
`/api/autopilots/${autopilotId}/deliveries?${search}`,
);
return parseWithFallback(
raw,
ListWebhookDeliveriesResponseSchema,
EMPTY_LIST_WEBHOOK_DELIVERIES_RESPONSE,
{ endpoint: "GET /api/autopilots/:id/deliveries" },
);
}
async getAutopilotDelivery(
autopilotId: string,
deliveryId: string,
): Promise<WebhookDelivery> {
const raw = await this.fetch<unknown>(
`/api/autopilots/${autopilotId}/deliveries/${deliveryId}`,
);
return parseWithFallback(
raw,
WebhookDeliveryResponseSchema,
{ ...EMPTY_WEBHOOK_DELIVERY, id: deliveryId, autopilot_id: autopilotId },
{ endpoint: "GET /api/autopilots/:id/deliveries/:deliveryId" },
);
}
// Replay creates a NEW delivery row referencing the original via
// `replayed_from_delivery_id`. Server rejects replays of
// signature-invalid / rejected deliveries with 400 — the UI keeps the
// button disabled for those rows, but the server is the source of truth.
async replayAutopilotDelivery(
autopilotId: string,
deliveryId: string,
): Promise<WebhookDelivery> {
const raw = await this.fetch<unknown>(
`/api/autopilots/${autopilotId}/deliveries/${deliveryId}/replay`,
{ method: "POST" },
);
return parseWithFallback(
raw,
WebhookDeliveryResponseSchema,
{ ...EMPTY_WEBHOOK_DELIVERY, autopilot_id: autopilotId },
{ endpoint: "POST /api/autopilots/:id/deliveries/:deliveryId/replay" },
);
}
// GitHub integration
async getGitHubConnectURL(workspaceId: string): Promise<GitHubConnectResponse> {
return this.fetch(`/api/workspaces/${workspaceId}/github/connect`);

View File

@@ -13,8 +13,6 @@ export type {
} from "./client";
export { parseWithFallback, setSchemaLogger } from "./schema";
export type { ParseOptions } from "./schema";
export { DuplicateIssueErrorBodySchema } from "./schemas";
export type { DuplicateIssueErrorBody } from "./schemas";
export { WSClient } from "./ws-client";
import type { ApiClient as ApiClientType } from "./client";

View File

@@ -198,68 +198,6 @@ describe("ApiClient schema fallback", () => {
});
});
describe("listAutopilotDeliveries", () => {
it("falls back to an empty list when the body is null", async () => {
stubFetchJson(null);
const client = new ApiClient("https://api.example.test");
const res = await client.listAutopilotDeliveries("ap-1");
expect(res).toEqual({ deliveries: [], total: 0 });
});
it("falls back to an empty list when `deliveries` is not an array", async () => {
stubFetchJson({ deliveries: "not-an-array", total: 0 });
const client = new ApiClient("https://api.example.test");
const res = await client.listAutopilotDeliveries("ap-1");
expect(res).toEqual({ deliveries: [], total: 0 });
});
it("accepts an unknown future status value rather than dropping the row", async () => {
// Server-side enum drift (e.g. new `quarantined` state). The list
// must still surface the row; downstream UI code's `default` arm
// handles unknown values with a generic visual.
stubFetchJson({
deliveries: [
{
id: "d-1",
workspace_id: "ws-1",
autopilot_id: "ap-1",
trigger_id: "t-1",
provider: "github",
event: "pull_request.opened",
dedupe_key: "abc",
dedupe_source: "x-github-delivery",
signature_status: "valid",
status: "quarantined",
attempt_count: 1,
content_type: "application/json",
response_status: 200,
autopilot_run_id: null,
replayed_from_delivery_id: null,
error: null,
received_at: "2026-01-01T00:00:00Z",
last_attempt_at: "2026-01-01T00:00:00Z",
created_at: "2026-01-01T00:00:00Z",
},
],
total: 1,
});
const client = new ApiClient("https://api.example.test");
const res = await client.listAutopilotDeliveries("ap-1");
expect(res.deliveries).toHaveLength(1);
expect(res.deliveries[0]?.status).toBe("quarantined");
});
});
describe("getAutopilotDelivery", () => {
it("falls back to a placeholder carrying the requested id", async () => {
stubFetchJson({ wrong: "shape" });
const client = new ApiClient("https://api.example.test");
const detail = await client.getAutopilotDelivery("ap-1", "d-1");
expect(detail.id).toBe("d-1");
expect(detail.autopilot_id).toBe("ap-1");
});
});
describe("createAgentFromTemplate", () => {
it("falls back to an empty agent when the response is malformed", async () => {
// The agent was created server-side even though the client can't

View File

@@ -1,51 +0,0 @@
import { describe, expect, it } from "vitest";
import { DuplicateIssueErrorBodySchema } from "./schemas";
// The duplicate-issue branch in create-issue.tsx feeds ApiError.body
// (typed as `unknown`) through this schema. Any future server drift that
// loses the contract MUST fail the parse so the UI falls back to a normal
// error toast instead of rendering an empty / partial duplicate card.
describe("DuplicateIssueErrorBodySchema", () => {
const valid = {
code: "active_duplicate_issue",
error: "An active issue with this title already exists: MUL-12 Login bug",
issue: {
id: "11111111-1111-1111-1111-111111111111",
identifier: "MUL-12",
title: "Login bug",
},
};
it("accepts a well-formed body", () => {
expect(DuplicateIssueErrorBodySchema.safeParse(valid).success).toBe(true);
});
it("accepts unknown extra fields via .loose()", () => {
const forwardCompat = {
...valid,
hint: "Try a different title",
issue: { ...valid.issue, workspace_id: "ws-1", status: "todo" },
};
expect(DuplicateIssueErrorBodySchema.safeParse(forwardCompat).success).toBe(true);
});
it("rejects a renamed code (so renames degrade to the generic toast)", () => {
const renamed = { ...valid, code: "duplicate_issue" };
expect(DuplicateIssueErrorBodySchema.safeParse(renamed).success).toBe(false);
});
it("rejects a missing issue object", () => {
const { issue: _omit, ...without } = valid;
expect(DuplicateIssueErrorBodySchema.safeParse(without).success).toBe(false);
});
it("rejects a non-string issue.id", () => {
const broken = { ...valid, issue: { ...valid.issue, id: 42 } };
expect(DuplicateIssueErrorBodySchema.safeParse(broken).success).toBe(false);
});
it("accepts a missing error field (it is optional)", () => {
const { error: _omit, ...without } = valid;
expect(DuplicateIssueErrorBodySchema.safeParse(without).success).toBe(true);
});
});

View File

@@ -7,9 +7,7 @@ import type {
CreateAgentFromTemplateResponse,
GroupedIssuesResponse,
ListIssuesResponse,
ListWebhookDeliveriesResponse,
TimelineEntry,
WebhookDelivery,
} from "../types";
// ---------------------------------------------------------------------------
@@ -150,7 +148,6 @@ const IssueSchema = z.object({
parent_issue_id: z.string().nullable(),
project_id: z.string().nullable(),
position: z.number(),
start_date: z.string().nullable(),
due_date: z.string().nullable(),
reactions: z.array(z.unknown()).optional(),
labels: z.array(z.unknown()).optional(),
@@ -198,17 +195,6 @@ export const ChildIssuesResponseSchema = z.object({
issues: z.array(IssueSchema).default([]),
}).loose();
export const OnboardingRuntimeBootstrapResponseSchema = z.object({
workspace_id: z.string(),
agent_id: z.string(),
issue_id: z.string(),
}).loose();
export const OnboardingNoRuntimeBootstrapResponseSchema = z.object({
workspace_id: z.string(),
issue_id: z.string(),
}).loose();
// ---------------------------------------------------------------------------
// Workspace dashboard schemas
//
@@ -346,140 +332,3 @@ export const EMPTY_CREATE_AGENT_FROM_TEMPLATE_RESPONSE: CreateAgentFromTemplateR
imported_skill_ids: [],
reused_skill_ids: [],
};
// Squad member status — backs the Squad detail page's Members tab. status
// is `string | null` (not the narrow `SquadMemberStatusValue` union) so a
// new server-side status doesn't fail the parse; the UI defaults to a
// neutral pill for unknown values.
const SquadActiveIssueBriefSchema = z.object({
issue_id: z.string(),
identifier: z.string(),
title: z.string(),
issue_status: z.string(),
}).loose();
const SquadMemberStatusSchema = z.object({
member_type: z.string(),
member_id: z.string(),
status: z.string().nullable().optional().transform((v) => v ?? null),
active_issues: z.array(SquadActiveIssueBriefSchema).default([]),
last_active_at: z.string().nullable().optional().transform((v) => v ?? null),
}).loose();
export const SquadMemberStatusListResponseSchema = z.object({
members: z.array(SquadMemberStatusSchema).default([]),
}).loose();
export const EMPTY_SQUAD_MEMBER_STATUS_LIST = { members: [] };
// ---------------------------------------------------------------------------
// Structured error body — POST /api/workspaces/:wsId/issues 409 conflict.
//
// When the server detects an active issue with the same title in the same
// workspace, it returns `{ code: "active_duplicate_issue", error, issue }`
// instead of letting the create through. The UI uses the embedded issue ref
// to offer "view existing" rather than dropping the user into a generic
// "create failed" toast.
//
// Strict guarantees:
// - `code` is a literal so a future server rename (e.g. `duplicate_issue`)
// fails the parse and falls back to a normal error toast — drift never
// ships as a broken duplicate UI.
// - `issue` is required; without an id/identifier/title the "view existing"
// button has nothing to point at, so we'd rather fall back than guess.
// - `issue.status` is intentionally OMITTED: the duplicate toast doesn't
// render a StatusIcon (which has no fallback for unknown enum values),
// so a future server-side rename of `status` must not knock this branch
// out. `.loose()` lets the field pass through unchanged for any other
// consumer.
// ---------------------------------------------------------------------------
export const DuplicateIssueErrorBodySchema = z.object({
code: z.literal("active_duplicate_issue"),
error: z.string().optional(),
issue: z.object({
id: z.string(),
identifier: z.string(),
title: z.string(),
}).loose(),
}).loose();
export interface DuplicateIssueErrorBody {
code: "active_duplicate_issue";
error?: string;
issue: {
id: string;
identifier: string;
title: string;
};
}
// ---------------------------------------------------------------------------
// Webhook delivery schemas — backing the Autopilot Deliveries section. Enums
// (`status`, `signature_status`, `provider`) are kept as `z.string()` so a
// future server-side value (e.g. a Stripe provider, a new dedupe state)
// degrades to a generic UI fallback rather than collapsing the list into
// the empty array. `.loose()` lets unknown fields pass through, matching
// the rule used by every other endpoint here.
// ---------------------------------------------------------------------------
const WebhookDeliverySchema = z.object({
id: z.string(),
workspace_id: z.string(),
autopilot_id: z.string(),
trigger_id: z.string(),
provider: z.string(),
event: z.string(),
dedupe_key: z.string().nullable(),
dedupe_source: z.string().nullable(),
signature_status: z.string(),
status: z.string(),
attempt_count: z.number().default(0),
content_type: z.string().nullable(),
response_status: z.number().nullable(),
autopilot_run_id: z.string().nullable(),
replayed_from_delivery_id: z.string().nullable(),
error: z.string().nullable(),
received_at: z.string(),
last_attempt_at: z.string(),
created_at: z.string(),
// Detail-only fields. The list endpoint omits them; the detail endpoint
// populates raw_body / selected_headers / response_body.
selected_headers: z.record(z.string(), z.unknown()).nullable().optional(),
raw_body: z.string().nullable().optional(),
response_body: z.string().nullable().optional(),
}).loose();
export const ListWebhookDeliveriesResponseSchema = z.object({
deliveries: z.array(WebhookDeliverySchema).default([]),
total: z.number().default(0),
}).loose();
export const WebhookDeliveryResponseSchema = WebhookDeliverySchema;
export const EMPTY_LIST_WEBHOOK_DELIVERIES_RESPONSE: ListWebhookDeliveriesResponse = {
deliveries: [],
total: 0,
};
export const EMPTY_WEBHOOK_DELIVERY: WebhookDelivery = {
id: "",
workspace_id: "",
autopilot_id: "",
trigger_id: "",
provider: "",
event: "",
dedupe_key: null,
dedupe_source: null,
signature_status: "not_required",
status: "queued",
attempt_count: 0,
content_type: null,
response_status: null,
autopilot_run_id: null,
replayed_from_delivery_id: null,
error: null,
received_at: "",
last_attempt_at: "",
created_at: "",
};

View File

@@ -1,11 +1,4 @@
export {
autopilotKeys,
autopilotListOptions,
autopilotDetailOptions,
autopilotRunsOptions,
autopilotDeliveriesOptions,
autopilotDeliveryOptions,
} from "./queries";
export { autopilotKeys, autopilotListOptions, autopilotDetailOptions, autopilotRunsOptions } from "./queries";
export {
useCreateAutopilot,
useUpdateAutopilot,
@@ -14,7 +7,4 @@ export {
useCreateAutopilotTrigger,
useUpdateAutopilotTrigger,
useDeleteAutopilotTrigger,
useRotateAutopilotTriggerWebhookToken,
useReplayAutopilotDelivery,
} from "./mutations";
export { buildAutopilotWebhookUrl } from "./webhook";

View File

@@ -128,32 +128,3 @@ export function useDeleteAutopilotTrigger() {
},
});
}
export function useRotateAutopilotTriggerWebhookToken() {
const qc = useQueryClient();
const wsId = useWorkspaceId();
return useMutation({
mutationFn: ({ autopilotId, triggerId }: { autopilotId: string; triggerId: string }) =>
api.rotateAutopilotTriggerWebhookToken(autopilotId, triggerId),
onSettled: (_data, _err, vars) => {
qc.invalidateQueries({ queryKey: autopilotKeys.detail(wsId, vars.autopilotId) });
},
});
}
// Replay re-dispatches a previously-recorded delivery. The server creates
// a new delivery row (with `replayed_from_delivery_id`) and synchronously
// kicks off a new autopilot run. We invalidate both deliveries and runs so
// the new delivery and any resulting run show up immediately.
export function useReplayAutopilotDelivery() {
const qc = useQueryClient();
const wsId = useWorkspaceId();
return useMutation({
mutationFn: ({ autopilotId, deliveryId }: { autopilotId: string; deliveryId: string }) =>
api.replayAutopilotDelivery(autopilotId, deliveryId),
onSettled: (_data, _err, vars) => {
qc.invalidateQueries({ queryKey: autopilotKeys.deliveries(wsId, vars.autopilotId) });
qc.invalidateQueries({ queryKey: autopilotKeys.runs(wsId, vars.autopilotId) });
},
});
}

View File

@@ -8,12 +8,6 @@ export const autopilotKeys = {
[...autopilotKeys.all(wsId), "detail", id] as const,
runs: (wsId: string, id: string) =>
[...autopilotKeys.all(wsId), "runs", id] as const,
run: (wsId: string, autopilotId: string, runId: string) =>
[...autopilotKeys.all(wsId), "runs", autopilotId, runId] as const,
deliveries: (wsId: string, id: string) =>
[...autopilotKeys.all(wsId), "deliveries", id] as const,
delivery: (wsId: string, autopilotId: string, deliveryId: string) =>
[...autopilotKeys.all(wsId), "deliveries", autopilotId, deliveryId] as const,
};
export function autopilotListOptions(wsId: string) {
@@ -38,52 +32,3 @@ export function autopilotRunsOptions(wsId: string, id: string) {
select: (data) => data.runs,
});
}
// autopilotRunOptions fetches a single run with its full trigger_payload.
// The list endpoint (autopilotRunsOptions) omits trigger_payload to keep
// list responses small; callers (e.g. the run-detail dialog) use this
// query on demand when the user opens a run.
export function autopilotRunOptions(
wsId: string,
autopilotId: string,
runId: string,
options?: { enabled?: boolean },
) {
return queryOptions({
queryKey: autopilotKeys.run(wsId, autopilotId, runId),
queryFn: () => api.getAutopilotRun(autopilotId, runId),
enabled: options?.enabled ?? true,
});
}
// autopilotDeliveriesOptions powers the Deliveries section in the autopilot
// detail page. The list is slim — raw_body / selected_headers / response_body
// are omitted server-side. Detail rows are fetched on-demand when the user
// expands a row (see autopilotDeliveryOptions).
export function autopilotDeliveriesOptions(
wsId: string,
autopilotId: string,
options?: { enabled?: boolean },
) {
return queryOptions({
queryKey: autopilotKeys.deliveries(wsId, autopilotId),
queryFn: () => api.listAutopilotDeliveries(autopilotId),
select: (data) => data.deliveries,
enabled: options?.enabled ?? true,
});
}
// autopilotDeliveryOptions fetches the full delivery row including raw_body
// and headers subset. Used by the detail dialog opened from a list row.
export function autopilotDeliveryOptions(
wsId: string,
autopilotId: string,
deliveryId: string,
options?: { enabled?: boolean },
) {
return queryOptions({
queryKey: autopilotKeys.delivery(wsId, autopilotId, deliveryId),
queryFn: () => api.getAutopilotDelivery(autopilotId, deliveryId),
enabled: options?.enabled ?? true,
});
}

View File

@@ -1,73 +0,0 @@
import { describe, expect, it } from "vitest";
import { buildAutopilotWebhookUrl } from "./webhook";
import type { AutopilotTrigger } from "../types";
const baseTrigger: AutopilotTrigger = {
id: "t1",
autopilot_id: "a1",
kind: "webhook",
enabled: true,
cron_expression: null,
timezone: null,
next_run_at: null,
webhook_token: "awt_abc",
webhook_path: "/api/webhooks/autopilots/awt_abc",
webhook_url: null,
label: null,
last_fired_at: null,
created_at: "",
updated_at: "",
};
describe("buildAutopilotWebhookUrl", () => {
it("returns the server-provided webhook_url verbatim when present", () => {
expect(
buildAutopilotWebhookUrl({
trigger: { ...baseTrigger, webhook_url: "https://custom.example/api/webhooks/autopilots/awt_abc" },
}),
).toBe("https://custom.example/api/webhooks/autopilots/awt_abc");
});
it("composes from apiBaseUrl + webhook_path", () => {
expect(
buildAutopilotWebhookUrl({ trigger: baseTrigger, apiBaseUrl: "https://api.example" }),
).toBe("https://api.example/api/webhooks/autopilots/awt_abc");
});
it("strips trailing slash on apiBaseUrl", () => {
expect(
buildAutopilotWebhookUrl({ trigger: baseTrigger, apiBaseUrl: "https://api.example/" }),
).toBe("https://api.example/api/webhooks/autopilots/awt_abc");
});
it("falls back to currentOrigin when apiBaseUrl is empty", () => {
expect(
buildAutopilotWebhookUrl({
trigger: baseTrigger,
apiBaseUrl: "",
currentOrigin: "https://app.example",
}),
).toBe("https://app.example/api/webhooks/autopilots/awt_abc");
});
it("composes from token when webhook_path is missing", () => {
expect(
buildAutopilotWebhookUrl({
trigger: { ...baseTrigger, webhook_path: null },
apiBaseUrl: "https://api.example",
}),
).toBe("https://api.example/api/webhooks/autopilots/awt_abc");
});
it("returns null for non-webhook trigger", () => {
expect(
buildAutopilotWebhookUrl({
trigger: { ...baseTrigger, kind: "schedule", webhook_token: null, webhook_path: null },
}),
).toBeNull();
});
it("returns relative path when no base or origin available", () => {
expect(buildAutopilotWebhookUrl({ trigger: baseTrigger })).toBe("/api/webhooks/autopilots/awt_abc");
});
});

View File

@@ -1,43 +0,0 @@
import type { AutopilotTrigger } from "../types";
/**
* Compose a usable absolute webhook URL for a webhook trigger.
*
* Resolution order:
* 1. trigger.webhook_url — present only when MULTICA_PUBLIC_URL is set on the
* server. This is the authoritative form when available.
* 2. apiBaseUrl + webhook_path — desktop apps and self-host setups where the
* server didn't mint an absolute URL but the client knows its API origin.
* 3. currentOrigin + webhook_path — browser fallback when getBaseUrl() is
* empty (e.g. same-origin Next.js dev).
*
* Returns null when the trigger has no token / path yet (a new trigger that
* hasn't been written back to the cache, or a non-webhook trigger).
*/
export function buildAutopilotWebhookUrl(params: {
trigger: Pick<AutopilotTrigger, "kind" | "webhook_token" | "webhook_path" | "webhook_url">;
apiBaseUrl?: string;
currentOrigin?: string;
}): string | null {
const { trigger, apiBaseUrl, currentOrigin } = params;
if (trigger.kind !== "webhook") return null;
if (typeof trigger.webhook_url === "string" && trigger.webhook_url) {
return trigger.webhook_url;
}
const path =
(typeof trigger.webhook_path === "string" && trigger.webhook_path) ||
(trigger.webhook_token ? `/api/webhooks/autopilots/${trigger.webhook_token}` : null);
if (!path) return null;
const base = stripTrailingSlash(apiBaseUrl) || stripTrailingSlash(currentOrigin);
if (!base) return path; // last resort — relative path will still work in-browser
return base + path;
}
function stripTrailingSlash(s: string | undefined): string {
if (!s) return "";
return s.endsWith("/") ? s.slice(0, -1) : s;
}

View File

@@ -1,2 +1 @@
export * from "./queries";
export * from "./pull-request-status";

View File

@@ -1,146 +0,0 @@
import { describe, expect, it } from "vitest";
import {
derivePullRequestStatusKind,
derivePullRequestProgressSegments,
shouldShowPullRequestStats,
type PullRequestStatusInput,
} from "./pull-request-status";
const base: PullRequestStatusInput = { state: "open" };
describe("derivePullRequestStatusKind", () => {
it("closed beats every other signal", () => {
expect(
derivePullRequestStatusKind({
state: "closed",
mergeable_state: "dirty",
checks_failed: 99,
checks_pending: 99,
checks_passed: 99,
}),
).toBe("closed");
});
it("merged beats every other signal except closed", () => {
expect(
derivePullRequestStatusKind({
state: "merged",
mergeable_state: "dirty",
checks_failed: 5,
}),
).toBe("merged");
});
it("dirty conflicts wins over check signals", () => {
expect(
derivePullRequestStatusKind({
...base,
mergeable_state: "dirty",
checks_passed: 3,
}),
).toBe("conflicts");
});
it("any failed check beats pending and passed", () => {
expect(
derivePullRequestStatusKind({
...base,
checks_failed: 1,
checks_pending: 3,
checks_passed: 5,
}),
).toBe("checks_failed");
});
it("pending beats passed when no failure", () => {
expect(
derivePullRequestStatusKind({
...base,
checks_pending: 1,
checks_passed: 5,
}),
).toBe("checks_pending");
});
it("all-passed is checks_passed regardless of mergeable=clean", () => {
expect(
derivePullRequestStatusKind({
...base,
mergeable_state: "clean",
checks_passed: 5,
}),
).toBe("checks_passed");
});
it("clean + no suites is ready-to-merge", () => {
expect(
derivePullRequestStatusKind({ ...base, mergeable_state: "clean" }),
).toBe("ready");
});
it("opaque mergeable values render as unknown", () => {
for (const m of ["blocked", "behind", "unstable", "has_hooks", "unknown", null, undefined]) {
expect(derivePullRequestStatusKind({ ...base, mergeable_state: m })).toBe("unknown");
}
});
});
describe("derivePullRequestProgressSegments", () => {
it("returns null for terminal PRs (merged / closed)", () => {
expect(derivePullRequestProgressSegments({ state: "merged", checks_passed: 5 })).toBeNull();
expect(derivePullRequestProgressSegments({ state: "closed", checks_failed: 3 })).toBeNull();
});
it("returns null when no suite has been observed", () => {
expect(derivePullRequestProgressSegments({ ...base })).toBeNull();
expect(
derivePullRequestProgressSegments({ ...base, checks_failed: 0, checks_pending: 0, checks_passed: 0 }),
).toBeNull();
});
it("orders segments failed → pending → passed (failure leftmost)", () => {
const segs = derivePullRequestProgressSegments({
...base,
checks_failed: 1,
checks_pending: 2,
checks_passed: 3,
});
expect(segs).not.toBeNull();
expect(segs!.map((s) => s.kind)).toEqual(["failed", "pending", "passed"]);
});
it("emits a zero-width segment-free output (no entry with ratio 0)", () => {
const segs = derivePullRequestProgressSegments({
...base,
checks_failed: 0,
checks_pending: 0,
checks_passed: 4,
});
expect(segs).toEqual([{ kind: "passed", ratio: 1 }]);
});
it("ratios sum to ~1 across segments", () => {
const segs = derivePullRequestProgressSegments({
...base,
checks_failed: 1,
checks_pending: 1,
checks_passed: 2,
})!;
const total = segs.reduce((acc, s) => acc + s.ratio, 0);
expect(total).toBeCloseTo(1, 6);
});
});
describe("shouldShowPullRequestStats", () => {
it("hides when every field is 0 or missing (legacy backend)", () => {
expect(shouldShowPullRequestStats({})).toBe(false);
expect(shouldShowPullRequestStats({ additions: 0, deletions: 0, changed_files: 0 })).toBe(false);
});
it("shows when at least one number is non-zero", () => {
expect(shouldShowPullRequestStats({ additions: 1 })).toBe(true);
expect(shouldShowPullRequestStats({ deletions: 1 })).toBe(true);
expect(shouldShowPullRequestStats({ changed_files: 1 })).toBe(true);
expect(shouldShowPullRequestStats({ additions: 437, deletions: 6, changed_files: 6 })).toBe(true);
});
});

View File

@@ -1,101 +0,0 @@
import type { GitHubPullRequest } from "../types";
// Status kinds rendered in the PR sidebar row's detail line. Order in the
// pass-through table matters — the first matching rule wins. The order is
// chosen so terminal PR states (closed / merged) short-circuit before any
// transient CI/conflict signal, since those signals are no longer actionable
// on a terminal PR.
//
// Priority (high → low):
// 1. closed (not merged) → status_closed
// 2. merged → status_merged
// 3. mergeable_state = "dirty" → status_conflicts
// 4. any failed suite → status_checks_failed
// 5. any pending suite → status_checks_pending
// 6. any passed suite → status_checks_passed
// 7. no suite + mergeable=clean → status_ready
// 8. otherwise → status_unknown
//
// Note: this table is the single source of truth for the sidebar PR row. The
// older row-with-badges implementation used a separate "hide status row for
// terminal PRs" branch — the current row renders
// with status_closed / status_merged text, never falling through to a
// conflicts / checks line on a terminal PR. Keep this priority order in sync
// with the i18n keys `pull_request_card_status_*` and with the progress-strip
// derivation in `derivePullRequestProgressSegments` (terminal kinds get a
// solid bar; the rest map onto the per-suite counts).
export type PullRequestStatusKind =
| "closed"
| "merged"
| "conflicts"
| "checks_failed"
| "checks_pending"
| "checks_passed"
| "ready"
| "unknown";
export interface PullRequestStatusInput {
state: GitHubPullRequest["state"];
mergeable_state?: string | null;
checks_failed?: number;
checks_pending?: number;
checks_passed?: number;
}
export function derivePullRequestStatusKind(input: PullRequestStatusInput): PullRequestStatusKind {
if (input.state === "closed") return "closed";
if (input.state === "merged") return "merged";
if (input.mergeable_state === "dirty") return "conflicts";
if ((input.checks_failed ?? 0) > 0) return "checks_failed";
if ((input.checks_pending ?? 0) > 0) return "checks_pending";
if ((input.checks_passed ?? 0) > 0) return "checks_passed";
if (input.mergeable_state === "clean") return "ready";
return "unknown";
}
export interface PullRequestProgressSegment {
kind: "failed" | "pending" | "passed";
ratio: number;
}
// Segmented progress bar input. Returns null when:
// - the PR is terminal (closed/merged) — the card paints a solid bar
// in a state-specific color, no segmentation needed;
// - no check_suite has been observed (total === 0) — the card hides
// the bar entirely.
// Otherwise emits the segments left-to-right: failed → pending → passed.
// "Failure first" is intentional: problems should be visible before signal
// that everything is fine.
export function derivePullRequestProgressSegments(
input: PullRequestStatusInput,
): PullRequestProgressSegment[] | null {
if (input.state === "closed" || input.state === "merged") return null;
const failed = input.checks_failed ?? 0;
const pending = input.checks_pending ?? 0;
const passed = input.checks_passed ?? 0;
const total = failed + pending + passed;
if (total === 0) return null;
const segments: PullRequestProgressSegment[] = [];
if (failed > 0) segments.push({ kind: "failed", ratio: failed / total });
if (pending > 0) segments.push({ kind: "pending", ratio: pending / total });
if (passed > 0) segments.push({ kind: "passed", ratio: passed / total });
return segments;
}
export interface PullRequestStatsInput {
additions?: number;
deletions?: number;
changed_files?: number;
}
// shouldShowPullRequestStats encodes the "old backend → new frontend" guard:
// when the backend that served this PR row doesn't know about the stats
// columns yet, every numeric field defaults to 0. Rendering "+0 0 · 0 files"
// in that case would be a lie (the PR almost certainly has real changes),
// so we hide the entire stats row until at least one signal is non-zero.
export function shouldShowPullRequestStats(input: PullRequestStatsInput): boolean {
const a = input.additions ?? 0;
const d = input.deletions ?? 0;
const f = input.changed_files ?? 0;
return a + d + f > 0;
}

View File

@@ -162,9 +162,5 @@ export function cleanupDeletedIssueCaches(
qc.invalidateQueries({ queryKey: issueKeys.childProgress(wsId) });
qc.invalidateQueries({ queryKey: issueKeys.list(wsId) });
qc.invalidateQueries({ queryKey: issueKeys.myAll(wsId) });
// Project Gantt cache lives outside `myAll`, so it needs an explicit
// refresh when an issue is removed — the deleted row may have been a
// scheduled bar visible right now.
qc.invalidateQueries({ queryKey: issueKeys.projectGanttAll(wsId) });
invalidateDeletedIssueDependentCaches(qc, wsId);
}

View File

@@ -181,7 +181,6 @@ export function useCreateIssue() {
qc.invalidateQueries({ queryKey: issueKeys.list(wsId) });
qc.invalidateQueries({ queryKey: issueKeys.assigneeGroupsAll(wsId) });
qc.invalidateQueries({ queryKey: issueKeys.myAssigneeGroupsAll(wsId) });
qc.invalidateQueries({ queryKey: issueKeys.projectGanttAll(wsId) });
},
});
}
@@ -258,7 +257,6 @@ export function useUpdateIssue() {
qc.invalidateQueries({ queryKey: issueKeys.list(wsId) });
qc.invalidateQueries({ queryKey: issueKeys.assigneeGroupsAll(wsId) });
qc.invalidateQueries({ queryKey: issueKeys.myAssigneeGroupsAll(wsId) });
qc.invalidateQueries({ queryKey: issueKeys.projectGanttAll(wsId) });
// Refresh the issue's attachments cache when the description editor
// bound new uploads — the description editor reads `issueAttachments`
// to resolve text-preview Eye gates, and unlike other mutations this
@@ -342,7 +340,6 @@ export function useDeleteIssue() {
qc.invalidateQueries({ queryKey: issueKeys.list(wsId) });
qc.invalidateQueries({ queryKey: issueKeys.assigneeGroupsAll(wsId) });
qc.invalidateQueries({ queryKey: issueKeys.myAssigneeGroupsAll(wsId) });
qc.invalidateQueries({ queryKey: issueKeys.projectGanttAll(wsId) });
if (ctx?.metadata) invalidateDeletedIssueParentCaches(qc, wsId, ctx.metadata);
},
});
@@ -402,7 +399,6 @@ export function useBatchUpdateIssues() {
qc.invalidateQueries({ queryKey: issueKeys.list(wsId) });
qc.invalidateQueries({ queryKey: issueKeys.assigneeGroupsAll(wsId) });
qc.invalidateQueries({ queryKey: issueKeys.myAssigneeGroupsAll(wsId) });
qc.invalidateQueries({ queryKey: issueKeys.projectGanttAll(wsId) });
if (ctx?.affectedParentIds && ctx.affectedParentIds.size > 0) {
for (const parentId of ctx.affectedParentIds) {
qc.invalidateQueries({
@@ -505,7 +501,6 @@ export function useBatchDeleteIssues() {
qc.invalidateQueries({ queryKey: issueKeys.list(wsId) });
qc.invalidateQueries({ queryKey: issueKeys.assigneeGroupsAll(wsId) });
qc.invalidateQueries({ queryKey: issueKeys.myAssigneeGroupsAll(wsId) });
qc.invalidateQueries({ queryKey: issueKeys.projectGanttAll(wsId) });
if (ctx?.parentIssueIds && ctx.parentIssueIds.size > 0) {
invalidateDeletedIssueParentCaches(qc, wsId, {
parentIssueIds: Array.from(ctx.parentIssueIds),

View File

@@ -1,131 +0,0 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { QueryClient } from "@tanstack/react-query";
import { setApiInstance } from "../api";
import type { ApiClient } from "../api/client";
import type { Issue, ListIssuesParams, ListIssuesResponse } from "../types";
import {
PROJECT_GANTT_MAX_ISSUES,
PROJECT_GANTT_PAGE_LIMIT,
issueKeys,
projectGanttIssuesOptions,
} from "./queries";
const WS_ID = "ws-1";
const PROJECT_ID = "project-1";
function makeIssue(idx: number): Issue {
return {
id: `issue-${idx}`,
workspace_id: WS_ID,
number: idx,
identifier: `MUL-${idx}`,
title: `Issue ${idx}`,
description: null,
status: "todo",
priority: "none",
assignee_type: null,
assignee_id: null,
creator_type: "member",
creator_id: "user-1",
parent_issue_id: null,
project_id: PROJECT_ID,
position: idx,
start_date: "2026-05-01T00:00:00Z",
due_date: null,
labels: [],
created_at: "2025-01-01T00:00:00Z",
updated_at: "2025-01-01T00:00:00Z",
};
}
// Type-only shim — only the methods the queries.ts code path under test calls.
function installFakeApi(listIssues: (params?: ListIssuesParams) => Promise<ListIssuesResponse>) {
setApiInstance({ listIssues } as unknown as ApiClient);
}
describe("projectGanttIssuesOptions", () => {
let qc: QueryClient;
beforeEach(() => {
qc = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
});
afterEach(() => {
qc.clear();
vi.restoreAllMocks();
});
it("returns the first page directly when it fits under PROJECT_GANTT_PAGE_LIMIT", async () => {
const listIssues = vi
.fn<(params?: ListIssuesParams) => Promise<ListIssuesResponse>>()
.mockResolvedValue({
issues: [makeIssue(1), makeIssue(2)],
total: 2,
});
installFakeApi(listIssues);
const data = await qc.fetchQuery(projectGanttIssuesOptions(WS_ID, PROJECT_ID));
expect(listIssues).toHaveBeenCalledTimes(1);
expect(listIssues).toHaveBeenCalledWith({
project_id: PROJECT_ID,
scheduled: true,
limit: PROJECT_GANTT_PAGE_LIMIT,
offset: 0,
});
expect(data).toHaveLength(2);
});
it("loops through pages until total is satisfied (no silent truncation)", async () => {
const total = PROJECT_GANTT_PAGE_LIMIT + 7;
const firstPage = Array.from({ length: PROJECT_GANTT_PAGE_LIMIT }, (_, i) =>
makeIssue(i),
);
const secondPage = Array.from({ length: 7 }, (_, i) =>
makeIssue(PROJECT_GANTT_PAGE_LIMIT + i),
);
const listIssues = vi
.fn<(params?: ListIssuesParams) => Promise<ListIssuesResponse>>()
.mockImplementation(async (params) => {
if (!params) throw new Error("expected params");
const offset = params.offset ?? 0;
if (offset === 0)
return { issues: firstPage, total };
if (offset === PROJECT_GANTT_PAGE_LIMIT)
return { issues: secondPage, total };
throw new Error(`unexpected offset ${offset}`);
});
installFakeApi(listIssues);
const data = await qc.fetchQuery(projectGanttIssuesOptions(WS_ID, PROJECT_ID));
expect(listIssues).toHaveBeenCalledTimes(2);
expect(data).toHaveLength(total);
});
it("stops looping when the server reports a smaller-than-limit page (safety net for total drift)", async () => {
// Server says `total` is huge but only ever returns short pages — the
// loop must terminate on the first short page to avoid an infinite fetch.
const listIssues = vi
.fn<(params?: ListIssuesParams) => Promise<ListIssuesResponse>>()
.mockResolvedValue({
issues: [makeIssue(1)],
total: PROJECT_GANTT_MAX_ISSUES,
});
installFakeApi(listIssues);
const data = await qc.fetchQuery(projectGanttIssuesOptions(WS_ID, PROJECT_ID));
expect(listIssues).toHaveBeenCalledTimes(1);
expect(data).toHaveLength(1);
});
it("uses the project-scoped Gantt cache key", () => {
const options = projectGanttIssuesOptions(WS_ID, PROJECT_ID);
expect(options.queryKey).toEqual(issueKeys.projectGantt(WS_ID, PROJECT_ID));
});
});

View File

@@ -28,17 +28,6 @@ export const issueKeys = {
scope: string,
filter: AssigneeGroupedIssuesFilter,
) => [...issueKeys.myAssigneeGroupsAll(wsId), scope, filter] as const,
/** All Project Gantt queries — prefix-match key for cross-project invalidation. */
projectGanttAll: (wsId: string) =>
[...issueKeys.all(wsId), "project-gantt"] as const,
/**
* Per-project Gantt issue list (scheduled-only). Uses its own cache key
* rather than reusing the bucketed `myList` cache so WS handlers and
* cache helpers don't have to special-case a non-bucketed shape under
* the `my` prefix.
*/
projectGantt: (wsId: string, projectId: string) =>
[...issueKeys.projectGanttAll(wsId), projectId] as const,
detail: (wsId: string, id: string) =>
[...issueKeys.all(wsId), "detail", id] as const,
children: (wsId: string, id: string) =>
@@ -66,7 +55,7 @@ export const issueKeys = {
export type MyIssuesFilter = Pick<
ListIssuesParams,
"assignee_id" | "assignee_ids" | "creator_id" | "project_id" | "involves_user_id"
"assignee_id" | "assignee_ids" | "creator_id" | "project_id"
>;
export type AssigneeGroupedIssuesFilter = Omit<
@@ -153,59 +142,6 @@ export function myIssueListOptions(
});
}
/**
* Page size for the scheduled-issue fetch. The Gantt view always pulls every
* scheduled issue (no client pagination), so this is just the chunk size we
* use to walk the server's `(limit, offset)` window until we hit `total`.
*/
export const PROJECT_GANTT_PAGE_LIMIT = 500;
/**
* Paranoia cap on the loop in {@link fetchProjectGanttIssues}. Real projects
* shouldn't come close to this — a single project carrying 50k scheduled
* issues is already a product problem, not a Gantt-rendering one — but the
* guard prevents a buggy server `total` from spinning the loop forever.
*/
export const PROJECT_GANTT_MAX_ISSUES = 10_000;
async function fetchProjectGanttIssues(projectId: string) {
const issues = [];
let offset = 0;
while (offset < PROJECT_GANTT_MAX_ISSUES) {
const res = await api.listIssues({
project_id: projectId,
scheduled: true,
limit: PROJECT_GANTT_PAGE_LIMIT,
offset,
});
issues.push(...res.issues);
if (res.issues.length < PROJECT_GANTT_PAGE_LIMIT) break;
if (issues.length >= res.total) break;
offset += PROJECT_GANTT_PAGE_LIMIT;
}
return issues;
}
/**
* One-shot fetch of every scheduled issue (`start_date` or `due_date` set)
* for a project. The Project Gantt view consumes this directly — no status
* bucketing, no client-side pagination, no Load-all affordance — because
* the scheduled subset is bounded enough to come back in a small handful of
* requests.
*
* Backed by `GET /api/issues?scheduled=true&project_id=…`; the SQL filter
* mirrors the same `(start_date IS NOT NULL OR due_date IS NOT NULL)`
* predicate the Gantt view applies on the client. Pages are walked until
* `total` is reached so an oversized project can't silently lose bars past
* the first page.
*/
export function projectGanttIssuesOptions(wsId: string, projectId: string) {
return queryOptions({
queryKey: issueKeys.projectGantt(wsId, projectId),
queryFn: () => fetchProjectGanttIssues(projectId),
});
}
export function myIssueAssigneeGroupsOptions(
wsId: string,
scope: string,

View File

@@ -23,8 +23,6 @@ const _actorIssuesViewStore = createStore<ActorIssuesViewState>()(
persist(
(set) => ({
...viewStoreSlice(set as unknown as StoreApi<IssueViewState>["setState"]),
// Actor tasks panel is list-only; override the slice's "board" default.
viewMode: "list",
scope: "assigned" as ActorIssuesScope,
setScope: (scope: ActorIssuesScope) => set({ scope }),
}),

View File

@@ -9,7 +9,6 @@ const RESET_STATE = {
priority: "none" as const,
assigneeType: undefined,
assigneeId: undefined,
startDate: null,
dueDate: null,
},
lastAssigneeType: undefined,

View File

@@ -11,7 +11,6 @@ interface IssueDraft {
priority: IssuePriority;
assigneeType?: IssueAssigneeType;
assigneeId?: string;
startDate: string | null;
dueDate: string | null;
}
@@ -22,7 +21,6 @@ const EMPTY_DRAFT: IssueDraft = {
priority: "none",
assigneeType: undefined,
assigneeId: undefined,
startDate: null,
dueDate: null,
};

View File

@@ -9,17 +9,15 @@ import { ALL_STATUSES } from "../config";
import { createWorkspaceAwareStorage, registerForWorkspaceRehydration } from "../../platform/workspace-storage";
import { defaultStorage } from "../../platform/storage";
export type ViewMode = "board" | "list" | "gantt";
export type GanttZoom = "day" | "week" | "month";
export type ViewMode = "board" | "list";
export type IssueGrouping = "status" | "assignee";
export type SortField = "position" | "priority" | "start_date" | "due_date" | "created_at" | "title";
export type SortField = "position" | "priority" | "due_date" | "created_at" | "title";
export type SortDirection = "asc" | "desc";
export interface CardProperties {
priority: boolean;
description: boolean;
assignee: boolean;
startDate: boolean;
dueDate: boolean;
project: boolean;
childProgress: boolean;
@@ -34,7 +32,6 @@ export interface ActorFilterValue {
export const SORT_OPTIONS: { value: SortField; label: string }[] = [
{ value: "position", label: "Manual" },
{ value: "priority", label: "Priority" },
{ value: "start_date", label: "Start date" },
{ value: "due_date", label: "Due date" },
{ value: "created_at", label: "Created date" },
{ value: "title", label: "Title" },
@@ -49,7 +46,6 @@ export const CARD_PROPERTY_OPTIONS: { key: keyof CardProperties; label: string }
{ key: "priority", label: "Priority" },
{ key: "description", label: "Description" },
{ key: "assignee", label: "Assignee" },
{ key: "startDate", label: "Start date" },
{ key: "dueDate", label: "Due date" },
{ key: "project", label: "Project" },
{ key: "labels", label: "Labels" },
@@ -71,11 +67,7 @@ export interface IssueViewState {
sortDirection: SortDirection;
cardProperties: CardProperties;
listCollapsedStatuses: IssueStatus[];
ganttZoom: GanttZoom;
ganttShowCompleted: boolean;
setViewMode: (mode: ViewMode) => void;
setGanttZoom: (zoom: GanttZoom) => void;
toggleGanttShowCompleted: () => void;
setGrouping: (grouping: IssueGrouping) => void;
toggleStatusFilter: (status: IssueStatus) => void;
togglePriorityFilter: (priority: IssuePriority) => void;
@@ -111,20 +103,14 @@ export const viewStoreSlice = (set: StoreApi<IssueViewState>["setState"]): Issue
priority: true,
description: true,
assignee: true,
startDate: true,
dueDate: true,
project: true,
childProgress: true,
labels: true,
},
listCollapsedStatuses: [],
ganttZoom: "week",
ganttShowCompleted: false,
setViewMode: (mode) => set({ viewMode: mode }),
setGanttZoom: (zoom) => set({ ganttZoom: zoom }),
toggleGanttShowCompleted: () =>
set((state) => ({ ganttShowCompleted: !state.ganttShowCompleted })),
setGrouping: (grouping) => set({ grouping }),
toggleStatusFilter: (status) =>
set((state) => ({
@@ -242,8 +228,6 @@ export const viewStorePersistOptions = (name: string) => ({
sortDirection: state.sortDirection,
cardProperties: state.cardProperties,
listCollapsedStatuses: state.listCollapsedStatuses,
ganttZoom: state.ganttZoom,
ganttShowCompleted: state.ganttShowCompleted,
}),
// Default Zustand merge is shallow, so a persisted `cardProperties` snapshot
// saved before a new toggle was introduced wins entirely and the new key is

View File

@@ -6,12 +6,7 @@ import {
agentTaskSnapshotKeys,
agentTasksKeys,
} from "../agents/queries";
import {
onIssueCreated,
onIssueDeleted,
onIssueLabelsChanged,
onIssueUpdated,
} from "./ws-updaters";
import { onIssueDeleted, onIssueLabelsChanged } from "./ws-updaters";
import { issueKeys } from "./queries";
import { labelKeys } from "../labels/queries";
import type {
@@ -69,7 +64,6 @@ const baseIssue: Issue = {
parent_issue_id: null,
project_id: null,
position: 0,
start_date: null,
due_date: null,
labels: [labelA],
created_at: "2025-01-01T00:00:00Z",
@@ -156,25 +150,6 @@ describe("onIssueLabelsChanged", () => {
const detail = qc.getQueryData<Issue>(issueKeys.detail(WS_ID, ISSUE_ID));
expect(detail?.labels).toEqual([labelB]);
});
it("patches the Project Gantt cache so label filters react in place", () => {
const PROJECT_ID = "project-1";
qc.setQueryData<Issue[]>(issueKeys.projectGantt(WS_ID, PROJECT_ID), [
baseIssue,
otherIssue,
]);
onIssueLabelsChanged(qc, WS_ID, ISSUE_ID, [labelB]);
const gantt = qc.getQueryData<Issue[]>(
issueKeys.projectGantt(WS_ID, PROJECT_ID),
);
expect(gantt?.find((i) => i.id === ISSUE_ID)?.labels).toEqual([labelB]);
// Other issues in the same cache must not have their labels mutated.
expect(gantt?.find((i) => i.id === OTHER_ISSUE_ID)?.labels).toEqual([
labelA,
]);
});
});
describe("onIssueDeleted", () => {
@@ -416,38 +391,3 @@ describe("onIssueDeleted", () => {
expect(qc.getQueryData(issueKeys.tasks(ISSUE_ID))).toBeUndefined();
});
});
// Regression coverage for the Project Gantt cache. The Gantt view rides its
// own dedicated cache (server-filtered to `scheduled=true`); every WS-driven
// path that can shift Gantt membership has to invalidate the prefix or the
// timeline goes stale.
describe("project gantt cache invalidation", () => {
const PROJECT_ID = "project-1";
let qc: QueryClient;
beforeEach(() => {
qc = new QueryClient();
qc.setQueryData<Issue[]>(
issueKeys.projectGantt(WS_ID, PROJECT_ID),
[baseIssue],
);
});
it("invalidates the project Gantt cache on issue:created", () => {
onIssueCreated(qc, WS_ID, otherIssue);
expectInvalidated(qc, issueKeys.projectGantt(WS_ID, PROJECT_ID));
});
it("invalidates the project Gantt cache on issue:updated", () => {
onIssueUpdated(qc, WS_ID, {
id: ISSUE_ID,
start_date: "2026-01-01T00:00:00Z",
});
expectInvalidated(qc, issueKeys.projectGantt(WS_ID, PROJECT_ID));
});
it("invalidates the project Gantt cache on issue:deleted", () => {
onIssueDeleted(qc, WS_ID, ISSUE_ID);
expectInvalidated(qc, issueKeys.projectGantt(WS_ID, PROJECT_ID));
});
});

View File

@@ -21,11 +21,6 @@ export function onIssueCreated(
qc.invalidateQueries({ queryKey: issueKeys.myAll(wsId) });
qc.invalidateQueries({ queryKey: issueKeys.assigneeGroupsAll(wsId) });
qc.invalidateQueries({ queryKey: issueKeys.myAssigneeGroupsAll(wsId) });
// Refresh every Project Gantt cache that might be observing this issue.
// We invalidate the whole prefix rather than the issue's own project
// because a fresh issue isn't necessarily scheduled yet; the active Gantt
// page (if any) will refetch and pick it up if it qualifies.
qc.invalidateQueries({ queryKey: issueKeys.projectGanttAll(wsId) });
if (issue.parent_issue_id) {
qc.invalidateQueries({ queryKey: issueKeys.children(wsId, issue.parent_issue_id) });
qc.invalidateQueries({ queryKey: issueKeys.childProgress(wsId) });
@@ -57,12 +52,6 @@ export function onIssueUpdated(
qc.invalidateQueries({ queryKey: issueKeys.myAll(wsId) });
qc.invalidateQueries({ queryKey: issueKeys.assigneeGroupsAll(wsId) });
qc.invalidateQueries({ queryKey: issueKeys.myAssigneeGroupsAll(wsId) });
// Any field change can shift Gantt membership — start_date / due_date may
// have moved in or out of the `scheduled` set, project_id may have
// changed, or the row that is in the cache may need to mirror updated
// metadata (title, status, assignee). Cheaper to invalidate the prefix
// than to mirror the server filter here.
qc.invalidateQueries({ queryKey: issueKeys.projectGanttAll(wsId) });
qc.setQueryData<Issue>(issueKeys.detail(wsId, issue.id), (old) =>
old ? { ...old, ...issue } : old,
);
@@ -114,20 +103,6 @@ export function onIssueLabelsChanged(
qc.setQueryData<IssueLabelsResponse>(labelKeys.byIssue(wsId, issueId), (old) =>
old ? { ...old, labels } : old,
);
// Patch the Project Gantt caches in-place: the Gantt view applies
// `labelFilters` to the row data, so a stale `labels` array would silently
// hide or surface bars after another tab/agent attached or detached a
// label. Mutating in place (instead of invalidating) avoids a refetch of
// the entire scheduled set on every label toggle.
for (const [key, data] of qc.getQueriesData<Issue[]>({
queryKey: issueKeys.projectGanttAll(wsId),
})) {
if (!data) continue;
const next = data.map((issue) =>
issue.id === issueId ? { ...issue, labels } : issue,
);
qc.setQueryData<Issue[]>(key, next);
}
qc.invalidateQueries({ queryKey: issueKeys.myAll(wsId) });
qc.invalidateQueries({ queryKey: issueKeys.assigneeGroupsAll(wsId) });
qc.invalidateQueries({ queryKey: issueKeys.myAssigneeGroupsAll(wsId) });

View File

@@ -2,15 +2,13 @@ export type {
OnboardingStep,
OnboardingCompletionPath,
QuestionnaireAnswers,
Source,
TeamSize,
Role,
UseCase,
} from "./types";
export {
saveQuestionnaire,
completeOnboarding,
bootstrapRuntimeOnboarding,
bootstrapNoRuntimeOnboarding,
joinCloudWaitlist,
} from "./store";
export { ONBOARDING_STEP_ORDER } from "./step-order";

View File

@@ -3,145 +3,113 @@ import { recommendTemplate } from "./recommend-template";
import type { Role, UseCase } from "./types";
const ALL_USE_CASES: UseCase[] = [
"ship_code",
"manage_team",
"personal_tasks",
"plan_research",
"write_publish",
"automate_ops",
"evaluate",
"coding",
"planning",
"writing_research",
"explore",
"other",
];
const ALL_ROLES: Role[] = [
"engineer",
"product",
"designer",
"founder",
"marketing",
"writer",
"research",
"ops",
"student",
"other",
];
describe("recommendTemplate (v2)", () => {
describe("engineer × use_case tiebreaker", () => {
it.each<UseCase>(["manage_team", "plan_research"])(
"engineer × %s → planning",
describe("recommendTemplate", () => {
describe("identity fallbacks — role alone decides", () => {
it.each(ALL_USE_CASES)(
"role=other (use_case=%s) → assistant",
(use_case) => {
expect(recommendTemplate({ role: "engineer", use_case })).toBe(
"planning",
expect(recommendTemplate({ role: "other", use_case })).toBe(
"assistant",
);
},
);
it("engineer × write_publish → writing", () => {
expect(
recommendTemplate({ role: "engineer", use_case: "write_publish" }),
).toBe("writing");
});
it.each<UseCase>([
"ship_code",
"personal_tasks",
"automate_ops",
"evaluate",
"other",
])("engineer × %s → coding", (use_case) => {
expect(recommendTemplate({ role: "engineer", use_case })).toBe("coding");
});
it("engineer × null → coding", () => {
expect(recommendTemplate({ role: "engineer", use_case: null })).toBe(
"coding",
);
});
});
describe("product × use_case", () => {
it("product × ship_code → coding", () => {
expect(
recommendTemplate({ role: "product", use_case: "ship_code" }),
).toBe("coding");
});
it.each<UseCase>(["manage_team", "plan_research", "evaluate", "other"])(
"product × %s → planning",
it.each(ALL_USE_CASES)(
"role=founder (use_case=%s) → assistant",
(use_case) => {
expect(recommendTemplate({ role: "product", use_case })).toBe(
"planning",
expect(recommendTemplate({ role: "founder", use_case })).toBe(
"assistant",
);
},
);
it("product × null → planning", () => {
expect(recommendTemplate({ role: "product", use_case: null })).toBe(
"planning",
);
});
});
describe("marketing × use_case", () => {
it.each<UseCase>(["write_publish", "plan_research"])(
"marketing × %s → writing",
it.each(ALL_USE_CASES)(
"role=writer (use_case=%s) → writing",
(use_case) => {
expect(recommendTemplate({ role: "marketing", use_case })).toBe(
expect(recommendTemplate({ role: "writer", use_case })).toBe(
"writing",
);
},
);
it("marketing × manage_team → planning", () => {
});
describe("developer × use_case tiebreaker", () => {
it("developer × planning → planning", () => {
expect(
recommendTemplate({ role: "marketing", use_case: "manage_team" }),
recommendTemplate({ role: "developer", use_case: "planning" }),
).toBe("planning");
});
it.each<UseCase>([
"coding",
"writing_research",
"explore",
"other",
])("developer × %s → coding", (use_case) => {
expect(recommendTemplate({ role: "developer", use_case })).toBe(
"coding",
);
});
it("developer × null use_case → coding (default)", () => {
expect(
recommendTemplate({ role: "developer", use_case: null }),
).toBe("coding");
});
});
describe("product_lead × use_case tiebreaker", () => {
it("product_lead × coding → coding", () => {
expect(
recommendTemplate({ role: "product_lead", use_case: "coding" }),
).toBe("coding");
});
it.each<UseCase>([
"planning",
"writing_research",
"explore",
"other",
])("product_lead × %s → planning", (use_case) => {
expect(recommendTemplate({ role: "product_lead", use_case })).toBe(
"planning",
);
});
it("product_lead × null use_case → planning (default)", () => {
expect(
recommendTemplate({ role: "product_lead", use_case: null }),
).toBe("planning");
});
});
describe("single-template roles", () => {
it.each(ALL_USE_CASES)("writer × %s → writing", (use_case) => {
expect(recommendTemplate({ role: "writer", use_case })).toBe("writing");
});
it.each(ALL_USE_CASES)("designer × %s → assistant", (use_case) => {
expect(recommendTemplate({ role: "designer", use_case })).toBe(
describe("unanswered questionnaire", () => {
it("null role → assistant regardless of use_case", () => {
expect(recommendTemplate({ role: null, use_case: null })).toBe(
"assistant",
);
});
it.each(ALL_USE_CASES)("research × %s → planning", (use_case) => {
expect(recommendTemplate({ role: "research", use_case })).toBe(
"planning",
);
});
it.each<Role>(["founder", "ops", "student", "other"])(
"%s → assistant",
(role) => {
expect(recommendTemplate({ role, use_case: null })).toBe("assistant");
},
);
});
describe("role skipped — use_case fallback", () => {
it("null × ship_code → coding", () => {
expect(recommendTemplate({ role: null, use_case: "ship_code" })).toBe(
"coding",
);
});
it("null × write_publish → writing", () => {
expect(
recommendTemplate({ role: null, use_case: "write_publish" }),
).toBe("writing");
});
it.each<UseCase>(["manage_team", "plan_research"])(
"null × %s → planning",
(use_case) => {
expect(recommendTemplate({ role: null, use_case })).toBe("planning");
},
);
it("both null → assistant", () => {
expect(recommendTemplate({ role: null, use_case: null })).toBe(
expect(recommendTemplate({ role: null, use_case: "coding" })).toBe(
"assistant",
);
});
});
describe("exhaustive role coverage", () => {
it.each(ALL_ROLES)("role=%s returns a valid template id", (role) => {
const roles: Role[] = [
"developer",
"product_lead",
"writer",
"founder",
"other",
];
it.each(roles)("role=%s returns a valid template id", (role) => {
const result = recommendTemplate({ role, use_case: null });
expect(["coding", "planning", "writing", "assistant"]).toContain(result);
});

View File

@@ -1,69 +1,41 @@
import type { QuestionnaireAnswers, Role, UseCase } from "./types";
import type { QuestionnaireAnswers } from "./types";
/**
* Identifier for the four legacy onboarding agent templates. Keep in
* sync with the template registry inside StepAgent in
* Identifier for the four agent templates offered during onboarding Step 4.
* Keep in sync with the template registry inside StepAgent in
* `packages/views/onboarding/steps/step-agent.tsx`.
*/
export type AgentTemplateId = "coding" | "planning" | "writing" | "assistant";
/**
* Pick a recommended agent template based on the v2 questionnaire
* (role × use_case). Role is the primary signal; use_case is a
* tiebreaker for roles that legitimately split between templates
* (engineer / product / marketing).
* Pick a recommended agent template for a user based on their
* questionnaire answers. Role is treated as the primary signal (who the
* user is); use_case is only a tiebreaker for roles that legitimately
* split between templates (developer / product_lead).
*
* Fallback chain when role is skipped or null:
* 1. Derive from use_case alone.
* 2. Both unknown → `assistant` (the generic default).
* `role = other` and `role = founder` both fall back to the generic
* Assistant: "other" means the user declined to claim a role, and
* "founder" means they wear every hat, so a single specialized agent is
* a poor default.
*
* Pure / deterministic — safe to call on every render.
*/
export function recommendTemplate(
answers: Pick<QuestionnaireAnswers, "role" | "use_case">,
): AgentTemplateId {
const role: Role | null = answers.role;
const useCase: UseCase | null = answers.use_case;
const { role, use_case } = answers;
if (role === null) return fallbackFromUseCase(useCase);
if (role === "other" || role === "founder") return "assistant";
if (role === "writer") return "writing";
switch (role) {
case "engineer":
if (useCase === "manage_team" || useCase === "plan_research")
return "planning";
if (useCase === "write_publish") return "writing";
return "coding";
case "product":
if (useCase === "ship_code") return "coding";
return "planning";
case "designer":
return "assistant";
case "writer":
return "writing";
case "marketing":
if (useCase === "write_publish" || useCase === "plan_research")
return "writing";
return "planning";
case "research":
return "planning";
case "founder":
case "ops":
case "student":
case "other":
return "assistant";
if (role === "developer") {
return use_case === "planning" ? "planning" : "coding";
}
}
function fallbackFromUseCase(useCase: UseCase | null): AgentTemplateId {
switch (useCase) {
case "ship_code":
return "coding";
case "write_publish":
return "writing";
case "manage_team":
case "plan_research":
return "planning";
default:
return "assistant";
if (role === "product_lead") {
return use_case === "coding" ? "coding" : "planning";
}
// Unknown / null role — user hasn't answered Q2 yet.
return "assistant";
}

View File

@@ -15,10 +15,9 @@ import type { OnboardingStep } from "./types";
* as progress toward completing setup.
*/
export const ONBOARDING_STEP_ORDER: readonly OnboardingStep[] = [
"source",
"role",
"use_case",
"questionnaire",
"workspace",
"runtime",
"teammate",
"agent",
"first_issue",
] as const;

View File

@@ -4,16 +4,14 @@ import { setPersonProperties } from "../analytics";
import type { OnboardingCompletionPath, QuestionnaireAnswers } from "./types";
/**
* Persist questionnaire answers (one or more slots at a time — each
* onboarding step PATCHes its own slot) and sync the refreshed user
* into the auth store. Source of truth is
* `user.onboarding_questionnaire` (JSONB on the server). No
* client-side cache here.
* Persist Q1/Q2/Q3 answers and sync the refreshed user into the auth
* store. Source of truth is `user.onboarding_questionnaire` (JSONB on
* the server). No client-side cache here.
*
* Resume-by-step is intentionally not persisted: every onboarding
* entry starts at Welcome. Answered slots are pre-filled on
* re-entry; skipped slots are treated as fresh (the user can answer
* this time).
* entry starts at Welcome. The questionnaire is the only piece of
* progress that survives a re-entry — it pre-fills Step 1 so the
* user doesn't re-answer.
*/
export async function saveQuestionnaire(
answers: Partial<QuestionnaireAnswers>,
@@ -21,11 +19,12 @@ export async function saveQuestionnaire(
const user = await api.patchOnboarding({ questionnaire: answers });
useAuthStore.getState().setUser(user);
// Mirror the three cohort signals into person properties so every
// PostHog event on this user can be broken down by source / role /
// use_case without re-joining the DB.
if (answers.source || answers.role || answers.use_case) {
// PostHog event on this user can be broken down by role / use_case /
// team_size without re-joining the DB. Matches the $set block the
// server writes alongside `onboarding_questionnaire_submitted`.
if (answers.team_size || answers.role || answers.use_case) {
setPersonProperties({
...(answers.source ? { source: answers.source } : {}),
...(answers.team_size ? { team_size: answers.team_size } : {}),
...(answers.role ? { role: answers.role } : {}),
...(answers.use_case ? { use_case: answers.use_case } : {}),
});
@@ -53,38 +52,6 @@ export async function completeOnboarding(
await useAuthStore.getState().refreshMe();
}
/**
* Runtime-connected onboarding path. The server creates or reuses the
* default Multica Helper agent and the single onboarding issue, marks
* onboarding complete, and suppresses the older starter-content prompt.
*/
export async function bootstrapRuntimeOnboarding(
workspaceId: string,
runtimeId: string,
): Promise<{ workspace_id: string; agent_id: string; issue_id: string }> {
const result = await api.bootstrapOnboardingRuntime({
workspace_id: workspaceId,
runtime_id: runtimeId,
});
await useAuthStore.getState().refreshMe();
return result;
}
/**
* Runtime-skipped onboarding path. The server creates or reuses one
* self-serve onboarding issue, marks onboarding complete, and suppresses
* the older starter-content prompt so the user is not flooded with tasks.
*/
export async function bootstrapNoRuntimeOnboarding(
workspaceId: string,
): Promise<{ workspace_id: string; issue_id: string }> {
const result = await api.bootstrapOnboardingNoRuntime({
workspace_id: workspaceId,
});
await useAuthStore.getState().refreshMe();
return result;
}
/**
* Records interest in cloud runtimes. Pure side effect — does NOT
* complete onboarding; the user still has to pick a real Step 3

View File

@@ -1,11 +1,8 @@
export type OnboardingStep =
| "welcome"
| "source"
| "role"
| "use_case"
| "questionnaire"
| "workspace"
| "runtime"
| "teammate"
| "agent"
| "first_issue";
@@ -16,64 +13,33 @@ export type OnboardingStep =
* `OnboardingPath*` constants in `server/internal/analytics/events.go`.
*/
export type OnboardingCompletionPath =
| "full"
| "runtime_skipped"
| "cloud_waitlist"
| "skip_existing"
| "invite_accept";
| "full" // Reached Step 5 (first_issue) with a runtime connected
| "runtime_skipped" // Step 3 skipped (no runtime) but still completed
| "cloud_waitlist" // Submitted the cloud waitlist form and skipped Step 3
| "skip_existing" // "I've done this before" from Welcome
| "invite_accept"; // Accepted at least one invite from /invitations
export type Source =
| "friends_colleagues"
| "search"
| "social_x"
| "social_linkedin"
| "social_youtube"
| "social_other"
| "blog_newsletter"
| "ai_assistant"
| "from_work"
| "event_conference"
| "dont_remember"
| "other";
export type TeamSize = "solo" | "team" | "other";
export type Role =
| "engineer"
| "product"
| "designer"
| "founder"
| "marketing"
| "developer"
| "product_lead"
| "writer"
| "research"
| "ops"
| "student"
| "founder"
| "other";
export type UseCase =
| "ship_code"
| "manage_team"
| "personal_tasks"
| "plan_research"
| "write_publish"
| "automate_ops"
| "evaluate"
| "coding"
| "planning"
| "writing_research"
| "explore"
| "other";
/**
* v2 questionnaire shape. `*_skipped: true` distinguishes an explicit
* Skip click from a slot the user never reached. Both states are
* "unknown" for recommendation purposes; the skip marker exists for
* analytics and so future re-prompts can avoid nagging users who
* already declined.
*/
export interface QuestionnaireAnswers {
source: Source | null;
source_other: string | null;
source_skipped: boolean;
team_size: TeamSize | null;
team_size_other: string | null;
role: Role | null;
role_other: string | null;
role_skipped: boolean;
use_case: UseCase | null;
use_case_other: string | null;
use_case_skipped: boolean;
version: 2;
}

View File

@@ -15,7 +15,6 @@
"./api": "./api/index.ts",
"./api/client": "./api/client.ts",
"./api/schema": "./api/schema.ts",
"./api/schemas": "./api/schemas.ts",
"./api/ws-client": "./api/ws-client.ts",
"./config": "./config/index.ts",
"./auth": "./auth/index.ts",

View File

@@ -22,7 +22,6 @@ describe("paths.workspace(slug)", () => {
expect(ws.squads()).toBe("/acme/squads");
expect(ws.squadDetail("sq_1")).toBe("/acme/squads/sq_1");
expect(ws.settings()).toBe("/acme/settings");
expect(ws.attachmentPreview("att_42")).toBe("/acme/attachments/att_42/preview");
});
it("URL-encodes special characters in ids", () => {

View File

@@ -37,7 +37,6 @@ function workspaceScoped(slug: string) {
skills: () => `${ws}/skills`,
skillDetail: (id: string) => `${ws}/skills/${encode(id)}`,
settings: () => `${ws}/settings`,
attachmentPreview: (id: string) => `${ws}/attachments/${encode(id)}/preview`,
};
}

View File

@@ -102,8 +102,8 @@ describe("useRealtimeSync — ws instance change", () => {
rerender({ ws: ws2 });
// Should have called invalidateQueries for all workspace-scoped keys
// (12 workspace-scoped + 1 workspaceKeys.list() = 13 calls)
expect(invalidateSpy).toHaveBeenCalledTimes(13);
// (11 workspace-scoped + 1 workspaceKeys.list() = 12 calls)
expect(invalidateSpy).toHaveBeenCalledTimes(12);
});
it("does not re-invalidate when rerendered with the same ws instance", () => {

View File

@@ -1,18 +1,8 @@
import { QueryClient } from "@tanstack/react-query";
import { describe, expect, it, vi } from "vitest";
import { chatKeys } from "../chat/queries";
import { issueKeys } from "../issues/queries";
import { workspaceKeys } from "../workspace/queries";
import type {
ChatDonePayload,
ChatMessage,
ChatPendingTask,
Workspace,
} from "../types";
import {
applyChatDoneToCache,
applyWorkspaceUpdatedToCache,
} from "./use-realtime-sync";
import type { ChatDonePayload, ChatMessage, ChatPendingTask } from "../types";
import { applyChatDoneToCache } from "./use-realtime-sync";
const sessionId = "session-1";
const taskId = "task-1";
@@ -125,78 +115,3 @@ describe("applyChatDoneToCache", () => {
expect(qc.getQueryData<ChatPendingTask>(pendingKey)).toEqual({});
});
});
describe("applyWorkspaceUpdatedToCache", () => {
const wsId = "ws-1";
function workspace(overrides: Partial<Workspace> = {}): Workspace {
return {
id: wsId,
name: "Test",
slug: "test",
description: null,
context: null,
settings: {},
repos: [],
issue_prefix: "TES",
created_at: "2026-05-18T00:00:00Z",
updated_at: "2026-05-18T00:00:00Z",
...overrides,
};
}
it("invalidates issue cache when issue_prefix changes", () => {
const qc = createQueryClient();
qc.setQueryData<Workspace[]>(workspaceKeys.list(), [
workspace({ issue_prefix: "TES" }),
]);
const invalidate = vi.spyOn(qc, "invalidateQueries");
applyWorkspaceUpdatedToCache(qc, {
workspace: workspace({ issue_prefix: "NEW" }),
});
expect(invalidate).toHaveBeenCalledWith({
queryKey: issueKeys.all(wsId),
});
expect(invalidate).toHaveBeenCalledWith({
queryKey: workspaceKeys.list(),
});
});
it("does not invalidate issue cache when only non-prefix fields change", () => {
const qc = createQueryClient();
qc.setQueryData<Workspace[]>(workspaceKeys.list(), [
workspace({ issue_prefix: "TES", name: "Old name" }),
]);
const invalidate = vi.spyOn(qc, "invalidateQueries");
applyWorkspaceUpdatedToCache(qc, {
workspace: workspace({ issue_prefix: "TES", name: "New name" }),
});
expect(invalidate).not.toHaveBeenCalledWith({
queryKey: issueKeys.all(wsId),
});
expect(invalidate).toHaveBeenCalledWith({
queryKey: workspaceKeys.list(),
});
});
it("invalidates issue cache when the workspace isn't in the cached list yet", () => {
// Conservative: a workspace appearing for the first time may correspond
// to issue queries that were primed without ever seeing the (possibly
// changing) prefix. Erring on the side of refresh keeps identifiers
// accurate at minimal cost.
const qc = createQueryClient();
const invalidate = vi.spyOn(qc, "invalidateQueries");
applyWorkspaceUpdatedToCache(qc, {
workspace: workspace({ issue_prefix: "NEW" }),
});
expect(invalidate).toHaveBeenCalledWith({
queryKey: issueKeys.all(wsId),
});
});
});

View File

@@ -31,14 +31,12 @@ import { onInboxNew, onInboxInvalidate, onInboxIssueStatusChanged, onInboxIssueD
import { inboxKeys } from "../inbox/queries";
import { notificationPreferenceOptions } from "../notification-preferences/queries";
import { workspaceKeys, workspaceListOptions } from "../workspace/queries";
import type { Workspace } from "../types/workspace";
import { chatKeys } from "../chat/queries";
import { useChatStore } from "../chat";
import { resolvePostAuthDestination, useHasOnboarded } from "../paths";
import type {
MemberAddedPayload,
WorkspaceDeletedPayload,
WorkspaceUpdatedPayload,
MemberRemovedPayload,
IssueUpdatedPayload,
IssueCreatedPayload,
@@ -109,36 +107,6 @@ export function applyChatDoneToCache(
qc.invalidateQueries({ queryKey: chatKeys.pendingTask(sessionId) });
}
/**
* Apply a workspace:updated event to the cache. Always refreshes the
* workspace list. If the incoming `issue_prefix` differs from what's
* currently cached, also invalidates issueKeys.all for that workspace,
* since every issue's rendered identifier (`MUL-123`) is recomputed from
* the workspace prefix at read time. Without this, the UI keeps showing
* the old `OLD-N` keys until the next hard refresh.
*
* If the workspace isn't in the cached list (first observation), we
* conservatively invalidate — the prefix is effectively "new" relative to
* what's cached, so any issues already loaded under the old prefix would
* be stale anyway.
*/
export function applyWorkspaceUpdatedToCache(
qc: QueryClient,
payload: WorkspaceUpdatedPayload,
): void {
const next = payload.workspace;
if (next?.id) {
const cached =
qc
.getQueryData<Workspace[]>(workspaceKeys.list())
?.find((w) => w.id === next.id) ?? null;
if (!cached || cached.issue_prefix !== next.issue_prefix) {
qc.invalidateQueries({ queryKey: issueKeys.all(next.id) });
}
}
qc.invalidateQueries({ queryKey: workspaceKeys.list() });
}
/**
* Invalidates all workspace-scoped queries. Used after reconnect and when a
* new WSClient instance is detected (workspace switch) to recover events
@@ -151,7 +119,6 @@ function invalidateWorkspaceScopedQueries(qc: QueryClient): void {
qc.invalidateQueries({ queryKey: inboxKeys.all(wsId) });
qc.invalidateQueries({ queryKey: workspaceKeys.agents(wsId) });
qc.invalidateQueries({ queryKey: workspaceKeys.members(wsId) });
qc.invalidateQueries({ queryKey: workspaceKeys.squads(wsId) });
qc.invalidateQueries({ queryKey: workspaceKeys.skills(wsId) });
qc.invalidateQueries({ queryKey: projectKeys.all(wsId) });
qc.invalidateQueries({ queryKey: runtimeKeys.all(wsId) });
@@ -210,24 +177,12 @@ export function useRealtimeSync(
},
agent: () => {
const wsId = getCurrentWsId();
if (wsId) {
qc.invalidateQueries({ queryKey: workspaceKeys.agents(wsId) });
// Squad members status is derived per agent, so any agent
// change (status flip, archive, runtime swap) needs to refresh
// the per-squad members-status cache. Prefix-matches both the
// squad list and every squadMemberStatus query.
qc.invalidateQueries({ queryKey: workspaceKeys.squads(wsId) });
}
if (wsId) qc.invalidateQueries({ queryKey: workspaceKeys.agents(wsId) });
},
member: () => {
const wsId = getCurrentWsId();
if (wsId) qc.invalidateQueries({ queryKey: workspaceKeys.members(wsId) });
},
// workspace:updated is handled by the specific handler below
// (compares prefixes to decide whether to also invalidate issues).
// This generic fallback still fires for workspace:deleted (paired
// with the specific navigation handler) and any future workspace:*
// events without dedicated handlers.
workspace: () => {
qc.invalidateQueries({ queryKey: workspaceKeys.list() });
},
@@ -265,14 +220,7 @@ export function useRealtimeSync(
},
daemon: () => {
const wsId = getCurrentWsId();
if (wsId) {
qc.invalidateQueries({ queryKey: runtimeKeys.all(wsId) });
// Runtime online/offline transitions move the derived status
// for every agent that hosts on this runtime, which shifts the
// working/idle/offline pill on the squad page. Same prefix
// invalidation pattern as the agent handler above.
qc.invalidateQueries({ queryKey: workspaceKeys.squads(wsId) });
}
if (wsId) qc.invalidateQueries({ queryKey: runtimeKeys.all(wsId) });
},
autopilot: () => {
const wsId = getCurrentWsId();
@@ -314,14 +262,6 @@ export function useRealtimeSync(
// every list-of-tasks query stale" so cache stays fresh even
// when the relevant component isn't currently mounted.
qc.invalidateQueries({ queryKey: ["issues", "tasks"] });
// Per-issue token usage card (issue-detail right rail). Same
// shape as the tasks invalidation above — any task lifecycle
// event shifts the aggregated usage numbers.
qc.invalidateQueries({ queryKey: ["issues", "usage"] });
// Squad members-status reads the same task lifecycle to flip
// working ↔ idle for each agent member. Prefix-matches every
// mounted squad-page's members-status query in O(1).
qc.invalidateQueries({ queryKey: workspaceKeys.squads(wsId) });
},
};
@@ -340,7 +280,6 @@ export function useRealtimeSync(
// Event types handled by specific handlers below -- skip generic refresh
const specificEvents = new Set([
"workspace:updated",
"issue:updated", "issue:created", "issue:deleted", "issue_labels:changed", "inbox:new",
"comment:created", "comment:updated", "comment:deleted",
"comment:resolved", "comment:unresolved",
@@ -578,10 +517,6 @@ export function useRealtimeSync(
}
};
const unsubWsUpdated = ws.on("workspace:updated", (p) => {
applyWorkspaceUpdatedToCache(qc, p as WorkspaceUpdatedPayload);
});
const unsubWsDeleted = ws.on("workspace:deleted", (p) => {
const { workspace_id } = p as WorkspaceDeletedPayload;
// Event payload has UUID; look up slug from cached workspace list
@@ -891,7 +826,6 @@ export function useRealtimeSync(
unsubIssueReactionRemoved();
unsubSubscriberAdded();
unsubSubscriberRemoved();
unsubWsUpdated();
unsubWsDeleted();
unsubMemberRemoved();
unsubMemberAdded();

View File

@@ -14,17 +14,6 @@ export const runtimeLocalSkillsKeys = {
const POLL_INTERVAL_MS = 500;
const POLL_TIMEOUT_MS = 30_000;
// Import timeout is longer than discovery because old daemons (pre-batch) pop
// only one import per heartbeat cycle (~15s). With 10 queued imports the 10th
// can wait up to 150s in pending before being claimed, plus up to 60s for
// the daemon to actually run the import.
//
// Timeout invariant: IMPORT_POLL_TIMEOUT_MS must exceed
// runtimeLocalSkillPendingTimeout + runtimeLocalSkillRunningTimeout
// (server/internal/handler/runtime_local_skills.go).
// See also IMPORT_CONCURRENCY in packages/views/.../runtime-local-skill-import-panel.tsx
// and maxLocalSkillImportBatch in server/internal/handler/daemon.go.
const IMPORT_POLL_TIMEOUT_MS = 4 * 60_000; // 4 minutes
export async function resolveRuntimeLocalSkills(
runtimeId: string,
@@ -60,7 +49,7 @@ export async function resolveRuntimeLocalSkillImport(
let current = initial;
while (current.status === "pending" || current.status === "running") {
if (Date.now() - start > IMPORT_POLL_TIMEOUT_MS) {
if (Date.now() - start > POLL_TIMEOUT_MS) {
throw new Error("runtime local skill import timed out");
}
await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS));

View File

@@ -12,7 +12,6 @@ export interface CreateIssueRequest {
assignee_id?: string;
parent_issue_id?: string;
project_id?: string;
start_date?: string;
due_date?: string;
attachment_ids?: string[];
}
@@ -25,7 +24,6 @@ export interface UpdateIssueRequest {
assignee_type?: IssueAssigneeType | null;
assignee_id?: string | null;
position?: number;
start_date?: string | null;
due_date?: string | null;
parent_issue_id?: string | null;
project_id?: string | null;
@@ -45,23 +43,7 @@ export interface ListIssuesParams {
assignee_ids?: string[];
creator_id?: string;
project_id?: string;
/**
* Widen the assignee filter to issues where the user is the *indirect*
* assignee — assignee is one of the user's owned agents, or a squad that
* involves the user (human member / leader-via-owned-agent / agent member
* owned by the user). Direct member assignment is intentionally excluded:
* `involves_user_id` and `assignee_id=<user>` (tab "Assigned to me") produce
* disjoint result sets by construction.
*/
involves_user_id?: string;
open_only?: boolean;
/**
* Restrict the result to issues with at least one of `start_date` /
* `due_date` set. Used by the Project Gantt view so it doesn't have to
* page through every issue on the project just to discard the unscheduled
* majority on the client.
*/
scheduled?: boolean;
}
export interface IssueActorRef {
@@ -81,8 +63,6 @@ export interface ListGroupedIssuesParams {
assignee_ids?: string[];
creator_id?: string;
project_id?: string;
/** See `ListIssuesParams.involves_user_id` — same semantics. */
involves_user_id?: string;
assignee_filters?: IssueActorRef[];
include_no_assignee?: boolean;
creator_filters?: IssueActorRef[];
@@ -130,8 +110,6 @@ export interface ListIssuesCache {
export interface SearchIssueResult extends Issue {
match_source: "title" | "description" | "comment";
matched_snippet?: string;
matched_description_snippet?: string;
matched_comment_snippet?: string;
}
export interface SearchIssuesResponse {

View File

@@ -4,16 +4,7 @@ export type AutopilotExecutionMode = "create_issue" | "run_only";
export type AutopilotTriggerKind = "schedule" | "webhook" | "api";
// `skipped` is emitted by the backend pre-flight admission check
// (assignee runtime offline at dispatch time, MUL-1899). The frontend MUST
// handle it explicitly — falling through to a generic case used to show
// the run as still-pending which masked the no-op.
export type AutopilotRunStatus =
| "issue_created"
| "running"
| "completed"
| "failed"
| "skipped";
export type AutopilotRunStatus = "issue_created" | "running" | "skipped" | "completed" | "failed";
export type AutopilotRunSource = "schedule" | "manual" | "webhook" | "api";
@@ -42,14 +33,6 @@ export interface AutopilotTrigger {
timezone: string | null;
next_run_at: string | null;
webhook_token: string | null;
// webhook_path is computed server-side from webhook_token (always
// "/api/webhooks/autopilots/{token}"). Optional so older servers can be
// talked to gracefully.
webhook_path?: string | null;
// webhook_url is only present when MULTICA_PUBLIC_URL is configured
// server-side. Clients fall back to composing from getBaseUrl/origin +
// webhook_path when this is missing.
webhook_url?: string | null;
label: string | null;
last_fired_at: string | null;
created_at: string;
@@ -117,52 +100,3 @@ export interface ListAutopilotRunsResponse {
runs: AutopilotRun[];
total: number;
}
// Webhook delivery enum is server-canonical. The frontend MUST `default`
// any switch on it to a generic fallback — see API Response Compatibility
// rules in CLAUDE.md. PR1 collapsed `skipped` into `dispatched` (the run
// itself carries the skip state); a future server may add new values.
export type WebhookDeliveryStatus =
| "queued"
| "dispatched"
| "rejected"
| "ignored"
| "failed";
export type WebhookSignatureStatus =
| "not_required"
| "valid"
| "invalid"
| "missing";
export interface WebhookDelivery {
id: string;
workspace_id: string;
autopilot_id: string;
trigger_id: string;
provider: string;
event: string;
dedupe_key: string | null;
dedupe_source: string | null;
signature_status: WebhookSignatureStatus;
status: WebhookDeliveryStatus;
attempt_count: number;
content_type: string | null;
response_status: number | null;
autopilot_run_id: string | null;
replayed_from_delivery_id: string | null;
error: string | null;
received_at: string;
last_attempt_at: string;
created_at: string;
// Detail-only fields. The list endpoint omits these to keep the wire
// size bounded (raw_body alone can be up to 256 KiB per delivery).
selected_headers?: Record<string, unknown> | null;
raw_body?: string | null;
response_body?: string | null;
}
export interface ListWebhookDeliveriesResponse {
deliveries: WebhookDelivery[];
total: number;
}

View File

@@ -1,16 +1,5 @@
export type GitHubPullRequestState = "open" | "closed" | "merged" | "draft";
/** Aggregated CI status for a PR's current head SHA, computed server-side from
* the latest check_suite per app. `null` when no completed suite has been seen
* yet (e.g. PR just opened, or repository has no CI configured). */
export type GitHubPullRequestChecksConclusion = "passed" | "failed" | "pending";
/** Raw mirror of GitHub's `mergeable_state`. The UI only surfaces `clean` and
* `dirty`; the other values (`blocked`, `behind`, `unstable`, `unknown`,
* `has_hooks`, `draft`) round-trip but render as unknown to avoid asserting
* "conflicts" for blocking reasons that aren't actual conflicts. */
export type GitHubMergeableState = string;
export interface GitHubInstallation {
id: string;
workspace_id: string;
@@ -37,20 +26,6 @@ export interface GitHubPullRequest {
closed_at: string | null;
pr_created_at: string;
pr_updated_at: string;
/** Optional; older backends omit this field. */
mergeable_state?: GitHubMergeableState | null;
/** Optional; older backends omit this field. */
checks_conclusion?: GitHubPullRequestChecksConclusion | null;
/** Per-suite counts that feed the segmented progress bar. Older backends
* omit these; treat absence as 0 (the card renders only when sum > 0). */
checks_passed?: number;
checks_failed?: number;
checks_pending?: number;
/** Diff stats from GitHub's `pull_request` payload. Older backends omit
* these fields; we treat 0/0/0 as "unknown" and hide the stats row. */
additions?: number;
deletions?: number;
changed_files?: number;
}
export interface ListGitHubInstallationsResponse {

View File

@@ -8,7 +8,6 @@ export type InboxItemType =
| "assignee_changed"
| "status_changed"
| "priority_changed"
| "start_date_changed"
| "due_date_changed"
| "new_comment"
| "mentioned"

View File

@@ -79,9 +79,7 @@ export type {
export type { PinnedItem, PinnedItemType, CreatePinRequest, ReorderPinsRequest } from "./pin";
export type {
GitHubInstallation,
GitHubMergeableState,
GitHubPullRequest,
GitHubPullRequestChecksConclusion,
GitHubPullRequestState,
ListGitHubInstallationsResponse,
GitHubConnectResponse,
@@ -102,10 +100,6 @@ export type {
ListAutopilotsResponse,
GetAutopilotResponse,
ListAutopilotRunsResponse,
WebhookDelivery,
WebhookDeliveryStatus,
WebhookSignatureStatus,
ListWebhookDeliveriesResponse,
} from "./autopilot";
export type {
Squad,
@@ -119,8 +113,4 @@ export type {
RemoveSquadMemberRequest,
UpdateSquadMemberRoleRequest,
CreateSquadActivityLogRequest,
SquadMemberStatusValue,
SquadActiveIssueBrief,
SquadMemberStatus,
SquadMemberStatusListResponse,
} from "./squad";

View File

@@ -38,7 +38,6 @@ export interface Issue {
parent_issue_id: string | null;
project_id: string | null;
position: number;
start_date: string | null;
due_date: string | null;
reactions?: IssueReaction[];
labels?: Label[];

View File

@@ -76,32 +76,3 @@ export interface CreateSquadActivityLogRequest {
outcome: SquadActivityOutcome;
details?: unknown;
}
// SquadMemberStatus mirrors the four-way bucket the back-end derives in
// handler/squad.go::deriveSquadMemberStatus. Kept as a string union here
// (rather than re-derived from snapshot data) so the squad page can render
// the freshest server-side judgement without re-fetching the agent
// snapshot / runtime list.
export type SquadMemberStatusValue = "working" | "idle" | "offline" | "unstable";
export interface SquadActiveIssueBrief {
issue_id: string;
identifier: string;
title: string;
issue_status: string;
}
export interface SquadMemberStatus {
member_type: SquadMemberType;
member_id: string;
// Human members are returned with status === null so the UI can render
// them in the same list without showing a status pill (v1 has no
// presence signal for humans).
status: SquadMemberStatusValue | null;
active_issues: SquadActiveIssueBrief[];
last_active_at: string | null;
}
export interface SquadMemberStatusListResponse {
members: SquadMemberStatus[];
}

View File

@@ -10,11 +10,6 @@ export const workspaceKeys = {
myInvitations: () => ["invitations", "mine"] as const,
agents: (wsId: string) => ["workspaces", wsId, "agents"] as const,
squads: (wsId: string) => ["workspaces", wsId, "squads"] as const,
// Per-squad member status. Lives under the workspace key tree so
// workspace switches naturally drop the cache, and so a broad
// `["workspaces", wsId, "squads"]` invalidation covers it.
squadMemberStatus: (wsId: string, squadId: string) =>
["workspaces", wsId, "squads", squadId, "members-status"] as const,
skills: (wsId: string) => ["workspaces", wsId, "skills"] as const,
assigneeFrequency: (wsId: string) => ["workspaces", wsId, "assignee-frequency"] as const,
};
@@ -57,20 +52,6 @@ export function squadListOptions(wsId: string) {
});
}
// Per-squad members status snapshot. The freshness signal is the WS task /
// agent / runtime invalidation wired in use-realtime-sync (which broadly
// invalidates `["workspaces", wsId, "squads"]`); the staleTime is a
// tab-focus safety net.
export function squadMemberStatusOptions(wsId: string, squadId: string) {
return queryOptions({
queryKey: workspaceKeys.squadMemberStatus(wsId, squadId),
queryFn: () => api.getSquadMemberStatus(squadId),
enabled: !!wsId && !!squadId,
staleTime: 30 * 1000,
refetchOnWindowFocus: true,
});
}
export function skillListOptions(wsId: string) {
return queryOptions({
queryKey: workspaceKeys.skills(wsId),

View File

@@ -40,7 +40,7 @@ function ActorAvatar({
// Squads (a group, non-human) get a square tile so they don't read as
// a single person; everyone else stays round.
isSquad ? "rounded-md" : "rounded-full",
(!avatarUrl || imgError) && "bg-muted text-muted-foreground",
"bg-muted text-muted-foreground",
className
)}
style={{ width: size, height: size, fontSize: size * 0.45 }}

View File

@@ -122,7 +122,7 @@ function SelectItem({
)}
{...props}
>
<SelectPrimitive.ItemText className="flex flex-1 shrink-0 items-center gap-2 whitespace-nowrap">
<SelectPrimitive.ItemText className="flex flex-1 shrink-0 gap-2 whitespace-nowrap">
{children}
</SelectPrimitive.ItemText>
<SelectPrimitive.ItemIndicator

View File

@@ -60,20 +60,6 @@ export interface MarkdownProps {
* When provided, enables file card preprocessing and rendering.
*/
cdnDomain?: string
/**
* Optional override for the image renderer. When provided, replaces the
* default `<img>` with constrained sizing. The views-package wrapper uses
* this to inject the unified `<Attachment>` component so chat messages get
* the same hover toolbar / lightbox / preview-modal treatment as comments.
*/
renderImage?: (props: { src: string; alt: string }) => React.ReactNode
/**
* Optional override for the file-card renderer. When provided, replaces
* the simplified card chrome (filename + download button) with whatever
* the caller supplies. Used the same way as `renderImage` to bridge into
* the views-package `<Attachment>` component.
*/
renderFileCard?: (props: { href: string; filename: string }) => React.ReactNode
}
// Sanitization schema — extends GitHub defaults to allow code highlighting classes
@@ -127,8 +113,6 @@ function createComponents(
onUrlClick?: (url: string) => void,
onFileClick?: (path: string) => void,
renderMention?: (props: { type: string; id: string }) => React.ReactNode,
renderImage?: (props: { src: string; alt: string }) => React.ReactNode,
renderFileCard?: (props: { href: string; filename: string }) => React.ReactNode,
): Partial<Components> {
const baseComponents: Partial<Components> = {
// FileCard: intercept <div data-type="fileCard"> from preprocessFileCards
@@ -138,9 +122,6 @@ function createComponents(
const rawHref = (node?.properties?.dataHref as string) || ''
const href = isAllowedFileCardHref(rawHref) ? rawHref : ''
const filename = (node?.properties?.dataFilename as string) || ''
if (renderFileCard) {
return <>{renderFileCard({ href, filename })}</>
}
return (
<div className="my-1 flex items-center gap-2 rounded-md border border-border bg-muted/50 px-2.5 py-1 transition-colors hover:bg-muted">
<FileText className="size-4 shrink-0 text-muted-foreground" />
@@ -162,19 +143,14 @@ function createComponents(
return <div {...props}>{children}</div>
},
// Images: render uploaded images with constrained sizing
img: ({ src, alt }) => {
if (renderImage) {
return <>{renderImage({ src: typeof src === 'string' ? src : '', alt: alt ?? '' })}</>
}
return (
<img
src={src}
alt={alt ?? ""}
className="max-w-full h-auto rounded-md my-2"
loading="lazy"
/>
)
},
img: ({ src, alt }) => (
<img
src={src}
alt={alt ?? ""}
className="max-w-full h-auto rounded-md my-2"
loading="lazy"
/>
),
// Links: Make clickable with callbacks, or render as mention
a: ({ href, children }) => {
// Mention links: mention://member/id, mention://agent/id, mention://issue/id, mention://all/all
@@ -408,13 +384,11 @@ export function Markdown({
onUrlClick,
onFileClick,
renderMention,
renderImage,
renderFileCard,
cdnDomain
}: MarkdownProps): React.JSX.Element {
const components = React.useMemo(
() => createComponents(mode, onUrlClick, onFileClick, renderMention, renderImage, renderFileCard),
[mode, onUrlClick, onFileClick, renderMention, renderImage, renderFileCard]
() => createComponents(mode, onUrlClick, onFileClick, renderMention),
[mode, onUrlClick, onFileClick, renderMention]
)
// Preprocess: convert mention shortcodes, raw URLs, and file cards to renderable content

View File

@@ -240,7 +240,7 @@ function AvatarEditor({
if (!canEdit) {
return (
<div className="h-14 w-14 shrink-0 overflow-hidden rounded-lg">
<div className="h-14 w-14 shrink-0 overflow-hidden rounded-lg bg-muted">
<ActorAvatar
actorType="agent"
actorId={agent.id}
@@ -271,7 +271,7 @@ function AvatarEditor({
type="button"
// rounded-lg matches the standard agent avatar treatment used in
// list rows. Avoid rounded-full — circles are reserved for humans.
className="group relative h-14 w-14 shrink-0 overflow-hidden rounded-lg focus:outline-none focus-visible:ring-2 focus-visible:ring-ring"
className="group relative h-14 w-14 shrink-0 overflow-hidden rounded-lg bg-muted focus:outline-none focus-visible:ring-2 focus-visible:ring-ring"
onClick={() => fileInputRef.current?.click()}
disabled={uploading}
aria-label={t(($) => $.inspector.change_avatar_aria)}

View File

@@ -73,7 +73,7 @@ export function AvatarPicker({ value, onChange, size = 56 }: AvatarPickerProps)
"group relative h-full w-full overflow-hidden rounded-lg outline-none transition-colors",
"focus-visible:ring-2 focus-visible:ring-ring",
hasValue
? "border"
? "border bg-muted"
: "border border-dashed bg-muted/40 hover:bg-muted",
)}
aria-label={

View File

@@ -71,12 +71,8 @@ export function CustomArgsTab({
try {
await onSave({ custom_args: currentArgs });
toast.success(t(($) => $.tab_body.custom_args.saved_toast));
} catch (err) {
toast.error(
err instanceof Error && err.message
? err.message
: t(($) => $.tab_body.custom_args.save_failed_toast),
);
} catch {
toast.error(t(($) => $.tab_body.custom_args.save_failed_toast));
} finally {
setSaving(false);
}

View File

@@ -114,12 +114,8 @@ export function EnvTab({
try {
await onSave({ custom_env: currentEnvMap });
toast.success(t(($) => $.tab_body.env.saved_toast));
} catch (err) {
toast.error(
err instanceof Error && err.message
? err.message
: t(($) => $.tab_body.env.save_failed_toast),
);
} catch {
toast.error(t(($) => $.tab_body.env.save_failed_toast));
} finally {
setSaving(false);
}

View File

@@ -1,73 +0,0 @@
"use client";
/**
* AttachmentPreviewPage — full-page HTML attachment viewer.
*
* Destination for `openInNewTab` from HtmlAttachmentPreview's toolbar. The
* inline preview (HtmlAttachmentPreview) renders the same content in a 480px
* card with a hover toolbar; this is the same content edge-to-edge so the
* user can resize / interact with the document at full size.
*
* Same security posture as the inline preview: iframe sandbox is
* "allow-scripts" only — no allow-same-origin, no allow-top-navigation. The
* iframe runs in an opaque origin and cannot reach cookies, localStorage,
* parent, or top-level navigation.
*
* The route is workspace-scoped (`/{slug}/attachments/{id}/preview`) for
* tenancy isolation; the `/api/attachments/{id}/content` proxy itself is
* already auth-checked, so the slug is purely a URL contract.
*/
import { useEffect } from "react";
import { useT } from "../i18n";
import { useAttachmentHtmlText } from "../editor/hooks/use-attachment-html-text";
import { withFragmentNavShim } from "../editor/utils/iframe-fragment-nav";
interface AttachmentPreviewPageProps {
attachmentId: string;
/** Optional display name. Falls back to a generic label and is only used
* for the document title — never echoed into the iframe sandbox. */
filename?: string;
}
export function AttachmentPreviewPage({
attachmentId,
filename,
}: AttachmentPreviewPageProps) {
const { t } = useT("editor");
const query = useAttachmentHtmlText(attachmentId);
// Set document.title so desktop's MutationObserver-based tab title picks
// up the filename. Web shows the same string in the browser tab.
useEffect(() => {
if (filename) document.title = filename;
}, [filename]);
const text = query.data?.text;
const isLoading = query.isLoading;
const isError = !isLoading && (!!query.error || !text);
return (
<div className="flex h-full w-full flex-col bg-background">
{isLoading ? (
<div className="flex flex-1 items-center justify-center text-sm text-muted-foreground">
{t(($) => $.attachment.preview_loading)}
</div>
) : isError ? (
<div
className="flex flex-1 items-center justify-center px-4 text-sm text-muted-foreground"
data-testid="attachment-preview-page-error"
>
{t(($) => $.attachment.preview_failed)}
</div>
) : (
<iframe
srcDoc={withFragmentNavShim(text)}
sandbox="allow-scripts"
title={filename ?? "HTML attachment"}
className="flex-1 w-full border-0 bg-background"
/>
)}
</div>
);
}

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