mirror of
https://github.com/multica-ai/multica.git
synced 2026-06-26 17:09:14 +02:00
Compare commits
1 Commits
fix/cloud-
...
fix/webhoo
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
948186a069 |
@@ -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.
|
||||
@@ -112,13 +112,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
|
||||
@@ -161,7 +154,7 @@ CORS_ALLOWED_ORIGINS=
|
||||
# `Authorization: Bearer <token>`.
|
||||
# REALTIME_METRICS_TOKEN=
|
||||
|
||||
# GitHub App integration (Settings → GitHub "Connect GitHub")
|
||||
# GitHub App integration (Settings → Integrations "Connect GitHub")
|
||||
# Both must be set for the Connect button to enable and for webhooks to be
|
||||
# accepted; leave empty to disable the integration. See docs/github-integration.
|
||||
# GITHUB_APP_SLUG is the tail of https://github.com/apps/<slug>.
|
||||
|
||||
2
.github/PULL_REQUEST_TEMPLATE.md
vendored
2
.github/PULL_REQUEST_TEMPLATE.md
vendored
@@ -40,7 +40,7 @@ Closes #
|
||||
- [ ] I have added or updated tests where applicable
|
||||
- [ ] If this change affects the UI, I have included before/after screenshots
|
||||
- [ ] I have updated relevant documentation to reflect my changes
|
||||
- [ ] If I added a new runtime / coding tool / UI tab, I synced the change to **landing copy** (`apps/web/features/landing/i18n/`) and **relevant docs** (`apps/docs/content/docs/`)
|
||||
- [ ] If I added a new runtime / coding tool / UI tab, I synced the change to **landing copy** (`apps/web/features/landing/i18n/`), **starter-content** (`packages/views/onboarding/utils/starter-content-content-*.ts`), and **relevant docs** (`apps/docs/content/docs/`)
|
||||
- [ ] If this PR touches Chinese product copy, I checked it against `apps/docs/content/docs/developers/conventions.zh.mdx` (terminology, mixed-rule for `task` / `issue` / `skill`)
|
||||
- [ ] I have considered and documented any risks above
|
||||
- [ ] I will address all reviewer comments before requesting merge
|
||||
|
||||
17
.github/workflows/ci.yml
vendored
17
.github/workflows/ci.yml
vendored
@@ -91,20 +91,3 @@ jobs:
|
||||
|
||||
- name: Test
|
||||
run: cd server && go test ./...
|
||||
|
||||
installer:
|
||||
# Stub-driven shell tests for scripts/install.sh. Kept off the heavy
|
||||
# backend job so installer regressions surface independently, and
|
||||
# exercised on macOS too because the installer targets macOS/Homebrew
|
||||
# and `tar` / `sed` / `mktemp` differ between BSD and GNU userlands.
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: [ubuntu-latest, macos-latest]
|
||||
runs-on: ${{ matrix.os }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Test shell installers
|
||||
run: bash scripts/install.test.sh
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -373,44 +355,9 @@ Valid statuses: `backlog`, `todo`, `in_progress`, `in_review`, `done`, `blocked`
|
||||
### Comments
|
||||
|
||||
```bash
|
||||
# List comments — flat timeline, chronological. Hard cap of 2000 rows; on
|
||||
# long-running issues prefer one of the thread-aware reads below to keep
|
||||
# context windows tight.
|
||||
# List comments
|
||||
multica issue comment list <issue-id>
|
||||
|
||||
# Single thread (root + every descendant). Anchor may be the root itself
|
||||
# or any reply inside the thread — the server walks up to the root.
|
||||
multica issue comment list <issue-id> --thread <comment-id>
|
||||
|
||||
# Single thread, capped to the N most recent replies. The thread root is
|
||||
# always included (even with --tail 0), so an agent landing on a long
|
||||
# thread keeps the "what is this about" context without dragging hundreds
|
||||
# of replies into its prompt.
|
||||
multica issue comment list <issue-id> --thread <comment-id> --tail 30
|
||||
|
||||
# Scroll older replies inside the same thread. --before / --before-id are
|
||||
# the reply cursor that the previous response emitted on stderr as
|
||||
# `Next reply cursor: --before <ts> --before-id <reply-id>`.
|
||||
multica issue comment list <issue-id> --thread <comment-id> --tail 30 \
|
||||
--before <ts> --before-id <reply-id>
|
||||
|
||||
# Most recently active threads (root + every descendant), grouped by
|
||||
# thread. Returns N complete conversational arcs, oldest-active first so
|
||||
# the freshest thread sits closest to "now" in an agent prompt.
|
||||
multica issue comment list <issue-id> --recent 20
|
||||
|
||||
# Scroll older threads. Under --recent, --before / --before-id are a
|
||||
# THREAD cursor (thread last_activity_at + root id), emitted on stderr as
|
||||
# `Next thread cursor: --before <ts> --before-id <root-id>`.
|
||||
multica issue comment list <issue-id> --recent 20 \
|
||||
--before <ts> --before-id <root-id>
|
||||
|
||||
# Incremental polling. Combines with --thread or --recent; filters out
|
||||
# replies created on or before <ts> from the page (the thread root is
|
||||
# exempt so the agent always gets context).
|
||||
multica issue comment list <issue-id> --thread <comment-id> --tail 30 \
|
||||
--since <RFC3339-timestamp>
|
||||
|
||||
# Add a comment
|
||||
multica issue comment add <issue-id> --content "Looks good, merging now"
|
||||
|
||||
@@ -421,29 +368,6 @@ multica issue comment add <issue-id> --parent <comment-id> --content "Thanks!"
|
||||
multica issue comment delete <comment-id>
|
||||
```
|
||||
|
||||
**`--before` / `--before-id` semantics depend on the paging mode**, by
|
||||
design — same flag, different scope:
|
||||
|
||||
| Mode | What the cursor walks | stderr label |
|
||||
| --- | --- | --- |
|
||||
| `--recent N` | Older *threads* (last_activity_at, root_id) | `Next thread cursor` |
|
||||
| `--thread <id> --tail N` | Older *replies* inside that thread (created_at, id) | `Next reply cursor` |
|
||||
|
||||
Outside those two modes (`--thread` without `--tail`, or no `--thread`
|
||||
and no `--recent`) the cursor flags are rejected so they cannot silently
|
||||
no-op. The server emits the cursor headers (`X-Multica-Next-Before` /
|
||||
`X-Multica-Next-Before-Id`) only when an older page actually exists —
|
||||
exact-boundary pages (e.g. `--tail 3` on a thread with exactly 3
|
||||
replies) intentionally return no cursor so callers stop paginating.
|
||||
|
||||
When `--since` is combined with `--recent` or `--thread --tail`, the
|
||||
server additionally suppresses the cursor once the cursor target itself
|
||||
is older than `since`. Older pages walk strictly older rows, so they
|
||||
cannot satisfy `> since` either — emitting a cursor there would just
|
||||
hand back root-only pages until the caller reaches the start of the
|
||||
thread / issue. Incremental polling stops at the first page whose
|
||||
cursor target falls before the watermark.
|
||||
|
||||
### Subscribers
|
||||
|
||||
```bash
|
||||
@@ -584,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).
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -79,7 +79,7 @@ For file uploads and attachments, configure S3 and (optionally) CloudFront:
|
||||
| `S3_BUCKET` | Bucket name only (e.g. `my-bucket`). Do **not** include the `.s3.<region>.amazonaws.com` suffix — the server constructs the public URL from `S3_BUCKET` + `S3_REGION` |
|
||||
| `S3_REGION` | AWS region (default: `us-west-2`). Must match the bucket's actual region — used for both SDK signing and public URLs |
|
||||
| `AWS_ACCESS_KEY_ID` / `AWS_SECRET_ACCESS_KEY` | Static credentials. When both are unset, the AWS SDK default credential chain is used |
|
||||
| `AWS_ENDPOINT_URL` | Custom S3-compatible endpoint (e.g. MinIO, R2, B2). Setting this switches to path-style URLs |
|
||||
| `AWS_ENDPOINT_URL` | Custom S3-compatible endpoint (e.g. MinIO, R2, B2). Setting this switches the public URL to path-style |
|
||||
| `CLOUDFRONT_DOMAIN` | CloudFront distribution domain — when set, public URLs use this host instead of the S3 host |
|
||||
| `CLOUDFRONT_KEY_PAIR_ID` | CloudFront key pair ID for signed URLs |
|
||||
| `CLOUDFRONT_PRIVATE_KEY` | CloudFront private key (PEM format) |
|
||||
|
||||
@@ -200,57 +200,6 @@ function createWindow(): void {
|
||||
}
|
||||
});
|
||||
|
||||
// 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"]) {
|
||||
|
||||
@@ -13,6 +13,7 @@ import { ModalRegistry } from "@multica/views/modals/registry";
|
||||
import { AppSidebar } from "@multica/views/layout";
|
||||
import { SearchCommand, SearchTrigger } from "@multica/views/search";
|
||||
import { ChatFab, ChatWindow } from "@multica/views/chat";
|
||||
import { StarterContentPrompt } from "@multica/views/onboarding";
|
||||
import { WorkspaceSlugProvider, paths, useCurrentWorkspace } from "@multica/core/paths";
|
||||
import { getCurrentSlug, subscribeToCurrentSlug } from "@multica/core/platform";
|
||||
import { useDesktopUnreadBadge } from "@multica/views/platform";
|
||||
@@ -168,6 +169,7 @@ export function DesktopShell() {
|
||||
</div>
|
||||
{slug && <ModalRegistry />}
|
||||
{slug && <SearchCommand />}
|
||||
{slug && <StarterContentPrompt />}
|
||||
<WindowOverlay />
|
||||
</WorkspaceSlugProvider>
|
||||
</DesktopNavigationProvider>
|
||||
|
||||
@@ -19,28 +19,10 @@ import type { DaemonStatus } from "../../../shared/daemon-types";
|
||||
*/
|
||||
export function DesktopRuntimesPage() {
|
||||
const [status, setStatus] = useState<DaemonStatus>({ state: "stopped" });
|
||||
// Remember the last known daemonId/deviceName. After the daemon is
|
||||
// stopped, `status.daemonId` goes back to undefined — without this
|
||||
// sticky cache the local row would either disappear or get reclassified
|
||||
// as a remote machine (since `isCurrent` requires a daemonId match),
|
||||
// taking the Start button with it.
|
||||
const [lastIdentity, setLastIdentity] = useState<{
|
||||
daemonId: string | null;
|
||||
deviceName: string | null;
|
||||
}>({ daemonId: null, deviceName: null });
|
||||
|
||||
useEffect(() => {
|
||||
const apply = (s: DaemonStatus) => {
|
||||
setStatus(s);
|
||||
if (s.daemonId) {
|
||||
setLastIdentity({
|
||||
daemonId: s.daemonId,
|
||||
deviceName: s.deviceName ?? null,
|
||||
});
|
||||
}
|
||||
};
|
||||
window.daemonAPI.getStatus().then(apply);
|
||||
return window.daemonAPI.onStatusChange(apply);
|
||||
window.daemonAPI.getStatus().then(setStatus);
|
||||
return window.daemonAPI.onStatusChange(setStatus);
|
||||
}, []);
|
||||
|
||||
const bootstrapping =
|
||||
@@ -50,14 +32,9 @@ export function DesktopRuntimesPage() {
|
||||
|
||||
return (
|
||||
<RuntimesPage
|
||||
localDaemonId={status.daemonId ?? lastIdentity.daemonId}
|
||||
localMachineName={status.deviceName ?? lastIdentity.deviceName}
|
||||
localDaemonId={status.daemonId ?? null}
|
||||
localMachineName={status.deviceName ?? null}
|
||||
localMachineActions={<DaemonRuntimeActions />}
|
||||
// Desktop owns a local machine for the lifetime of the app, even
|
||||
// while the daemon is stopped or hasn't registered yet. The shared
|
||||
// page synthesizes a placeholder local row when no real runtime
|
||||
// matches, so the Start button is always reachable.
|
||||
hasLocalMachine
|
||||
bootstrapping={bootstrapping}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -1,151 +0,0 @@
|
||||
import { describe, expect, it, vi, beforeEach } from "vitest";
|
||||
import { render, fireEvent, within } from "@testing-library/react";
|
||||
|
||||
type MockTab = {
|
||||
id: string;
|
||||
path: string;
|
||||
title: string;
|
||||
icon: string;
|
||||
pinned: boolean;
|
||||
};
|
||||
|
||||
const state = vi.hoisted(() => ({
|
||||
activeWorkspaceSlug: "acme" as string | null,
|
||||
byWorkspace: {
|
||||
acme: {
|
||||
activeTabId: "tA",
|
||||
tabs: [
|
||||
{ id: "tA", path: "/acme/issues", title: "Issues", icon: "ListTodo", pinned: false },
|
||||
{ id: "tB", path: "/acme/projects", title: "Projects", icon: "ListTodo", pinned: false },
|
||||
] as MockTab[],
|
||||
},
|
||||
} as Record<string, { activeTabId: string; tabs: MockTab[] }>,
|
||||
togglePin: vi.fn<(tabId: string) => void>(),
|
||||
closeTab: vi.fn<(tabId: string) => void>(),
|
||||
setActiveTab: vi.fn<(tabId: string) => void>(),
|
||||
moveTab: vi.fn<(from: number, to: number) => void>(),
|
||||
addTab: vi.fn<(path: string, title: string, icon: string) => string>(),
|
||||
}));
|
||||
|
||||
vi.mock("@/stores/tab-store", () => {
|
||||
const store = {
|
||||
get activeWorkspaceSlug() {
|
||||
return state.activeWorkspaceSlug;
|
||||
},
|
||||
get byWorkspace() {
|
||||
return state.byWorkspace;
|
||||
},
|
||||
togglePin: state.togglePin,
|
||||
closeTab: state.closeTab,
|
||||
setActiveTab: state.setActiveTab,
|
||||
moveTab: state.moveTab,
|
||||
addTab: state.addTab,
|
||||
};
|
||||
const useTabStore = Object.assign(
|
||||
(selector?: (s: typeof store) => unknown) =>
|
||||
selector ? selector(store) : store,
|
||||
{ getState: () => store },
|
||||
);
|
||||
const useActiveGroup = () =>
|
||||
state.activeWorkspaceSlug
|
||||
? (state.byWorkspace[state.activeWorkspaceSlug] ?? null)
|
||||
: null;
|
||||
const resolveRouteIcon = () => "ListTodo";
|
||||
return { useTabStore, useActiveGroup, resolveRouteIcon };
|
||||
});
|
||||
|
||||
vi.mock("@multica/core/paths", () => ({
|
||||
paths: {
|
||||
workspace: (slug: string) => ({
|
||||
issues: () => `/${slug}/issues`,
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
import { TabBar } from "./tab-bar";
|
||||
|
||||
function reset() {
|
||||
state.activeWorkspaceSlug = "acme";
|
||||
state.byWorkspace = {
|
||||
acme: {
|
||||
activeTabId: "tA",
|
||||
tabs: [
|
||||
{ id: "tA", path: "/acme/issues", title: "Issues", icon: "ListTodo", pinned: false },
|
||||
{ id: "tB", path: "/acme/projects", title: "Projects", icon: "ListTodo", pinned: false },
|
||||
],
|
||||
},
|
||||
};
|
||||
state.togglePin.mockReset();
|
||||
state.closeTab.mockReset();
|
||||
state.setActiveTab.mockReset();
|
||||
state.moveTab.mockReset();
|
||||
state.addTab.mockReset();
|
||||
}
|
||||
|
||||
beforeEach(reset);
|
||||
|
||||
describe("TabBar hover action buttons", () => {
|
||||
it("renders a Pin button on every unpinned tab and an Unpin button on every pinned tab", () => {
|
||||
state.byWorkspace.acme.tabs = [
|
||||
{ id: "tA", path: "/acme/issues", title: "Issues", icon: "ListTodo", pinned: true },
|
||||
{ id: "tB", path: "/acme/projects", title: "Projects", icon: "ListTodo", pinned: false },
|
||||
];
|
||||
const { getAllByLabelText } = render(<TabBar />);
|
||||
expect(getAllByLabelText("Unpin tab")).toHaveLength(1);
|
||||
expect(getAllByLabelText("Pin tab")).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("clicking the Pin button calls togglePin for the tab", () => {
|
||||
const { getAllByLabelText } = render(<TabBar />);
|
||||
const pinButtons = getAllByLabelText("Pin tab");
|
||||
fireEvent.click(pinButtons[1]); // click Pin on tB (Projects)
|
||||
expect(state.togglePin).toHaveBeenCalledWith("tB");
|
||||
});
|
||||
|
||||
it("clicking the Unpin button on a pinned tab calls togglePin", () => {
|
||||
state.byWorkspace.acme.tabs = [
|
||||
{ id: "tA", path: "/acme/issues", title: "Issues", icon: "ListTodo", pinned: true },
|
||||
{ id: "tB", path: "/acme/projects", title: "Projects", icon: "ListTodo", pinned: false },
|
||||
];
|
||||
const { getByLabelText } = render(<TabBar />);
|
||||
fireEvent.click(getByLabelText("Unpin tab"));
|
||||
expect(state.togglePin).toHaveBeenCalledWith("tA");
|
||||
});
|
||||
|
||||
it("hides the X close button on a pinned tab but keeps it on an unpinned tab", () => {
|
||||
state.byWorkspace.acme.tabs = [
|
||||
{ id: "tA", path: "/acme/issues", title: "Issues", icon: "ListTodo", pinned: true },
|
||||
{ id: "tB", path: "/acme/projects", title: "Projects", icon: "ListTodo", pinned: false },
|
||||
];
|
||||
const { queryAllByLabelText } = render(<TabBar />);
|
||||
// Only the unpinned tab exposes a Close affordance — pinned tab requires
|
||||
// explicit Unpin first (RFC §3 D3c FINAL).
|
||||
expect(queryAllByLabelText("Close tab")).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("keeps the full title visible on a pinned tab (no icon-only collapse)", () => {
|
||||
state.byWorkspace.acme.tabs = [
|
||||
{ id: "tA", path: "/acme/issues", title: "Issues", icon: "ListTodo", pinned: true },
|
||||
];
|
||||
const { getByLabelText } = render(<TabBar />);
|
||||
const pinnedTab = getByLabelText("Issues (pinned)");
|
||||
expect(within(pinnedTab).getByText("Issues")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("renders the Pin glyph as the leading icon on a pinned tab and the route icon on an unpinned tab", () => {
|
||||
state.byWorkspace.acme.tabs = [
|
||||
{ id: "tA", path: "/acme/issues", title: "Issues", icon: "ListTodo", pinned: true },
|
||||
{ id: "tB", path: "/acme/projects", title: "Projects", icon: "ListTodo", pinned: false },
|
||||
];
|
||||
const { getByLabelText } = render(<TabBar />);
|
||||
const pinnedTab = getByLabelText("Issues (pinned)");
|
||||
const unpinnedTab = getByLabelText("Projects");
|
||||
// lucide-react renders the icon name into the class list. The leading
|
||||
// slot icon is size-3.5; the hover Pin/Unpin action button is size-2.5,
|
||||
// so we qualify on size to avoid matching the action glyph.
|
||||
expect(pinnedTab.querySelector(".lucide-pin.size-3\\.5")).toBeTruthy();
|
||||
expect(pinnedTab.querySelector(".lucide-list-todo")).toBeNull();
|
||||
expect(unpinnedTab.querySelector(".lucide-list-todo.size-3\\.5")).toBeTruthy();
|
||||
expect(unpinnedTab.querySelector(".lucide-pin.size-3\\.5")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,3 @@
|
||||
import { Fragment } from "react";
|
||||
import {
|
||||
Inbox,
|
||||
CircleUser,
|
||||
@@ -9,8 +8,6 @@ import {
|
||||
Settings,
|
||||
X,
|
||||
Plus,
|
||||
Pin,
|
||||
PinOff,
|
||||
type LucideIcon,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
@@ -31,20 +28,8 @@ import {
|
||||
restrictToParentElement,
|
||||
} from "@dnd-kit/modifiers";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
import {
|
||||
ContextMenu,
|
||||
ContextMenuContent,
|
||||
ContextMenuItem,
|
||||
ContextMenuSeparator,
|
||||
ContextMenuTrigger,
|
||||
} from "@multica/ui/components/ui/context-menu";
|
||||
import { cn } from "@multica/ui/lib/utils";
|
||||
import {
|
||||
useTabStore,
|
||||
useActiveGroup,
|
||||
resolveRouteIcon,
|
||||
type Tab,
|
||||
} from "@/stores/tab-store";
|
||||
import { useTabStore, useActiveGroup, resolveRouteIcon, type Tab } from "@/stores/tab-store";
|
||||
import { paths } from "@multica/core/paths";
|
||||
|
||||
const TAB_ICONS: Record<string, LucideIcon> = {
|
||||
@@ -57,23 +42,9 @@ const TAB_ICONS: Record<string, LucideIcon> = {
|
||||
Settings,
|
||||
};
|
||||
|
||||
function SortableTabItem({
|
||||
tab,
|
||||
isActive,
|
||||
isOnly,
|
||||
}: {
|
||||
tab: Tab;
|
||||
isActive: boolean;
|
||||
/**
|
||||
* True iff this is the only tab in the workspace. Hiding X on the last
|
||||
* tab matches existing behavior and avoids the surprise of the store's
|
||||
* last-tab reseed kicking in. Pinned tabs always hide X (RFC §3 D3c).
|
||||
*/
|
||||
isOnly: boolean;
|
||||
}) {
|
||||
function SortableTabItem({ tab, isActive, isOnly }: { tab: Tab; isActive: boolean; isOnly: boolean }) {
|
||||
const setActiveTab = useTabStore((s) => s.setActiveTab);
|
||||
const closeTab = useTabStore((s) => s.closeTab);
|
||||
const togglePin = useTabStore((s) => s.togglePin);
|
||||
|
||||
const {
|
||||
attributes,
|
||||
@@ -84,11 +55,7 @@ function SortableTabItem({
|
||||
isDragging,
|
||||
} = useSortable({ id: tab.id });
|
||||
|
||||
// Pinned tabs swap the route icon for a Pin glyph as the static "I am
|
||||
// pinned" indicator (RFC §3 D1v-iv FINAL). The route information is still
|
||||
// present in the title, and this avoids a hard left accent border that read
|
||||
// as visually heavy in light mode.
|
||||
const LeadingIcon = tab.pinned ? Pin : TAB_ICONS[tab.icon];
|
||||
const Icon = TAB_ICONS[tab.icon];
|
||||
|
||||
const style = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
@@ -107,30 +74,17 @@ function SortableTabItem({
|
||||
closeTab(tab.id);
|
||||
};
|
||||
|
||||
const handleTogglePin = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
togglePin(tab.id);
|
||||
};
|
||||
|
||||
const stopDragOnAction = (e: React.PointerEvent) => {
|
||||
const stopDragOnClose = (e: React.PointerEvent) => {
|
||||
e.stopPropagation();
|
||||
};
|
||||
|
||||
// Pinned tabs keep their full title (RFC §3 D1v-ii FINAL). The only visual
|
||||
// differences vs. unpinned tabs are the leading Pin icon (swapped in above)
|
||||
// and the suppressed X (closing requires explicit Unpin). Pin/Unpin is
|
||||
// reachable via the hover action button below and the right-click menu.
|
||||
const showCloseButton = !tab.pinned && !isOnly;
|
||||
|
||||
const tabButton = (
|
||||
return (
|
||||
<button
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
onClick={handleClick}
|
||||
aria-label={tab.pinned ? `${tab.title} (pinned)` : tab.title}
|
||||
title={tab.pinned ? `${tab.title} (pinned)` : undefined}
|
||||
className={cn(
|
||||
"group flex h-7 w-40 items-center gap-1.5 rounded-md px-2 text-xs transition-colors",
|
||||
"select-none cursor-default",
|
||||
@@ -140,7 +94,7 @@ function SortableTabItem({
|
||||
isDragging && "opacity-60",
|
||||
)}
|
||||
>
|
||||
{LeadingIcon && <LeadingIcon className="size-3.5 shrink-0" />}
|
||||
{Icon && <Icon className="size-3.5 shrink-0" />}
|
||||
<span
|
||||
className="min-w-0 flex-1 overflow-hidden whitespace-nowrap text-left"
|
||||
style={{
|
||||
@@ -150,22 +104,10 @@ function SortableTabItem({
|
||||
>
|
||||
{tab.title}
|
||||
</span>
|
||||
<span
|
||||
onClick={handleTogglePin}
|
||||
onPointerDown={stopDragOnAction}
|
||||
role="button"
|
||||
aria-label={tab.pinned ? "Unpin tab" : "Pin tab"}
|
||||
title={tab.pinned ? "Unpin tab" : "Pin tab"}
|
||||
className="hidden size-3.5 shrink-0 items-center justify-center rounded-sm text-muted-foreground transition-colors group-hover:flex hover:bg-muted-foreground/20 hover:text-foreground"
|
||||
>
|
||||
{tab.pinned ? <PinOff className="size-2.5" /> : <Pin className="size-2.5" />}
|
||||
</span>
|
||||
{showCloseButton && (
|
||||
{!isOnly && (
|
||||
<span
|
||||
onClick={handleClose}
|
||||
onPointerDown={stopDragOnAction}
|
||||
role="button"
|
||||
aria-label="Close tab"
|
||||
onPointerDown={stopDragOnClose}
|
||||
className="hidden size-3.5 shrink-0 items-center justify-center rounded-sm text-muted-foreground transition-colors group-hover:flex hover:bg-muted-foreground/20 hover:text-foreground"
|
||||
>
|
||||
<X className="size-2.5" />
|
||||
@@ -173,36 +115,6 @@ function SortableTabItem({
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
|
||||
return (
|
||||
<ContextMenu>
|
||||
<ContextMenuTrigger render={tabButton} />
|
||||
<ContextMenuContent>
|
||||
<ContextMenuItem onClick={() => togglePin(tab.id)}>
|
||||
{tab.pinned ? (
|
||||
<>
|
||||
<PinOff />
|
||||
Unpin tab
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Pin />
|
||||
Pin tab
|
||||
</>
|
||||
)}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuItem
|
||||
variant="destructive"
|
||||
disabled={tab.pinned || isOnly}
|
||||
onClick={() => closeTab(tab.id)}
|
||||
>
|
||||
<X />
|
||||
Close tab
|
||||
</ContextMenuItem>
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
);
|
||||
}
|
||||
|
||||
function NewTabButton() {
|
||||
@@ -243,17 +155,12 @@ export function TabBar() {
|
||||
const tabs = group?.tabs ?? [];
|
||||
const activeTabId = group?.activeTabId ?? "";
|
||||
const tabIds = tabs.map((t) => t.id);
|
||||
const pinnedCount = tabs.filter((t) => t.pinned).length;
|
||||
const unpinnedCount = tabs.length - pinnedCount;
|
||||
|
||||
const handleDragEnd = (event: DragEndEvent) => {
|
||||
const { active, over } = event;
|
||||
if (!over || active.id === over.id) return;
|
||||
const from = tabs.findIndex((t) => t.id === active.id);
|
||||
const to = tabs.findIndex((t) => t.id === over.id);
|
||||
// The store clamps the destination to within the source tab's zone
|
||||
// (pinned vs unpinned), so this call is safe even when the user tries
|
||||
// to drag across the boundary — the tab will land at the boundary.
|
||||
if (from !== -1 && to !== -1) moveTab(from, to);
|
||||
};
|
||||
|
||||
@@ -266,22 +173,13 @@ export function TabBar() {
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
<SortableContext items={tabIds} strategy={horizontalListSortingStrategy}>
|
||||
{tabs.map((tab, index) => (
|
||||
<Fragment key={tab.id}>
|
||||
<SortableTabItem
|
||||
tab={tab}
|
||||
isActive={tab.id === activeTabId}
|
||||
isOnly={tabs.length === 1}
|
||||
/>
|
||||
{tab.pinned &&
|
||||
index === pinnedCount - 1 &&
|
||||
unpinnedCount > 0 && (
|
||||
<div
|
||||
aria-hidden
|
||||
className="mx-1 h-4 w-px bg-border"
|
||||
/>
|
||||
)}
|
||||
</Fragment>
|
||||
{tabs.map((tab) => (
|
||||
<SortableTabItem
|
||||
key={tab.id}
|
||||
tab={tab}
|
||||
isActive={tab.id === activeTabId}
|
||||
isOnly={tabs.length === 1}
|
||||
/>
|
||||
))}
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -1,355 +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).
|
||||
type MockRouter = {
|
||||
state: { location: { pathname: string } };
|
||||
navigate: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
|
||||
type MockTab = {
|
||||
id: string;
|
||||
path: string;
|
||||
pinned: boolean;
|
||||
router: MockRouter;
|
||||
};
|
||||
|
||||
function makeMockRouter(pathname: string): MockRouter {
|
||||
return {
|
||||
state: { location: { pathname } },
|
||||
navigate: vi.fn(),
|
||||
};
|
||||
}
|
||||
|
||||
const state = vi.hoisted(() => ({
|
||||
activeWorkspaceSlug: "acme" as string | null,
|
||||
byWorkspace: {
|
||||
acme: {
|
||||
activeTabId: "tA",
|
||||
tabs: [
|
||||
{
|
||||
id: "tA",
|
||||
path: "/acme/issues",
|
||||
pinned: false,
|
||||
router: makeMockRouter("/acme/issues"),
|
||||
},
|
||||
] as MockTab[],
|
||||
},
|
||||
} as Record<string, { activeTabId: string; tabs: MockTab[] }>,
|
||||
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",
|
||||
pinned: false,
|
||||
router: makeMockRouter("/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("DesktopNavigationProvider.push with pinned active tab", () => {
|
||||
function pinActive(pathname: string) {
|
||||
state.byWorkspace.acme.tabs[0] = {
|
||||
id: "tA",
|
||||
path: pathname,
|
||||
pinned: true,
|
||||
router: makeMockRouter(pathname),
|
||||
};
|
||||
}
|
||||
|
||||
it("redirects push to a new foreground tab when pathname differs", () => {
|
||||
pinActive("/acme/issues");
|
||||
let adapter: ReturnType<typeof useNavigation> | null = null;
|
||||
const Probe = captureAdapter((a) => {
|
||||
adapter = a;
|
||||
});
|
||||
render(
|
||||
<DesktopNavigationProvider>
|
||||
<Probe />
|
||||
</DesktopNavigationProvider>,
|
||||
);
|
||||
adapter!.push("/acme/projects");
|
||||
expect(state.openTab).toHaveBeenCalledWith("/acme/projects", "/acme/projects", "File");
|
||||
expect(state.setActiveTab).toHaveBeenCalledWith("tNew");
|
||||
});
|
||||
|
||||
it("allows in-tab navigation when only search/hash changes", () => {
|
||||
pinActive("/acme/issues");
|
||||
let adapter: ReturnType<typeof useNavigation> | null = null;
|
||||
const Probe = captureAdapter((a) => {
|
||||
adapter = a;
|
||||
});
|
||||
render(
|
||||
<DesktopNavigationProvider>
|
||||
<Probe />
|
||||
</DesktopNavigationProvider>,
|
||||
);
|
||||
adapter!.push("/acme/issues?filter=open");
|
||||
// Pathname unchanged → pinned interception declines and falls through to
|
||||
// the router's own navigate — openTab / setActiveTab must not fire.
|
||||
expect(state.openTab).not.toHaveBeenCalled();
|
||||
expect(state.setActiveTab).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("leaves cross-workspace push to the workspace switcher (not pin)", () => {
|
||||
pinActive("/acme/issues");
|
||||
let adapter: ReturnType<typeof useNavigation> | null = null;
|
||||
const Probe = captureAdapter((a) => {
|
||||
adapter = a;
|
||||
});
|
||||
render(
|
||||
<DesktopNavigationProvider>
|
||||
<Probe />
|
||||
</DesktopNavigationProvider>,
|
||||
);
|
||||
adapter!.push("/butter/inbox");
|
||||
// Cross-workspace push runs through tryRouteToOtherWorkspace before
|
||||
// tryRouteToPinnedNewTab, so switchWorkspace wins.
|
||||
expect(state.switchWorkspace).toHaveBeenCalledWith("butter", "/butter/inbox");
|
||||
expect(state.openTab).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();
|
||||
});
|
||||
});
|
||||
|
||||
describe("TabNavigationProvider.push with pinned active tab", () => {
|
||||
type ProviderRouter = Parameters<typeof TabNavigationProvider>[0]["router"];
|
||||
|
||||
function renderPinnedTabProvider(pathname: string) {
|
||||
// The active tab and the per-tab router must share the same pathname:
|
||||
// tryRouteToPinnedNewTab reads the *active tab's* router for the current
|
||||
// pathname (so query-only pushes routed via React Router still compare
|
||||
// correctly), while the TabNavigationProvider falls back to *its own*
|
||||
// router.navigate when no interception fires. In real desktop usage they
|
||||
// are the same router instance; this helper mirrors that invariant.
|
||||
const fakeRouter = {
|
||||
state: { location: { pathname, search: "" } },
|
||||
subscribe: () => () => {},
|
||||
navigate: vi.fn(),
|
||||
} as unknown as ProviderRouter;
|
||||
state.byWorkspace.acme.tabs[0] = {
|
||||
id: "tA",
|
||||
path: pathname,
|
||||
pinned: true,
|
||||
router: fakeRouter as unknown as MockRouter,
|
||||
};
|
||||
|
||||
let adapter: ReturnType<typeof useNavigation> | null = null;
|
||||
const Probe = captureAdapter((a) => {
|
||||
adapter = a;
|
||||
});
|
||||
render(
|
||||
<TabNavigationProvider router={fakeRouter}>
|
||||
<Probe />
|
||||
</TabNavigationProvider>,
|
||||
);
|
||||
return { getAdapter: () => adapter!, fakeRouter };
|
||||
}
|
||||
|
||||
it("redirects push to a new foreground tab when pathname differs", () => {
|
||||
const { getAdapter, fakeRouter } = renderPinnedTabProvider("/acme/issues");
|
||||
getAdapter().push("/acme/projects");
|
||||
expect(state.openTab).toHaveBeenCalledWith("/acme/projects", "/acme/projects", "File");
|
||||
expect(state.setActiveTab).toHaveBeenCalledWith("tNew");
|
||||
// Pinned interception short-circuits — the per-tab router must NOT
|
||||
// navigate, otherwise the pinned tab itself would move off its path.
|
||||
expect(fakeRouter.navigate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("allows in-tab navigation when only search/hash changes", () => {
|
||||
const { getAdapter, fakeRouter } = renderPinnedTabProvider("/acme/issues");
|
||||
getAdapter().push("/acme/issues?filter=open");
|
||||
// Same pathname → pinned interception declines, push falls through to
|
||||
// the tab's own router.navigate, and no new tab is opened.
|
||||
expect(state.openTab).not.toHaveBeenCalled();
|
||||
expect(state.setActiveTab).not.toHaveBeenCalled();
|
||||
expect(fakeRouter.navigate).toHaveBeenCalledWith("/acme/issues?filter=open");
|
||||
});
|
||||
});
|
||||
@@ -108,37 +108,6 @@ function tryRouteToOtherWorkspace(path: string): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Intercept pushes originating in a pinned tab and force them into a new
|
||||
* tab. Returns `true` if the navigation was redirected (caller should NOT
|
||||
* proceed). Pathname-only changes (search / hash / same-page state) are
|
||||
* allowed through so pinned filter / drawer / form-state interactions
|
||||
* still work — see RFC §3 D2a (FINAL: any pathname change → new tab) and
|
||||
* D2b (FINAL: same pathname → allowed in pinned tab).
|
||||
*
|
||||
* Dedupe is preserved (D4a): `openTab` activates an existing same-path tab
|
||||
* if one exists, otherwise creates a new one. The newly-focused tab is
|
||||
* activated foreground — a pinned-tab push is an explicit user action, not
|
||||
* a background cmd+click, so the focus follows.
|
||||
*/
|
||||
function tryRouteToPinnedNewTab(path: string): boolean {
|
||||
const store = useTabStore.getState();
|
||||
const active = getActiveTab(store);
|
||||
if (!active?.pinned) return false;
|
||||
|
||||
// Use the live router pathname rather than `active.path` so query-only
|
||||
// navigations performed via React Router (which only sync pathname back
|
||||
// to the store) still compare correctly.
|
||||
const currentPathname = active.router.state.location.pathname;
|
||||
const newPathname = path.split("?")[0].split("#")[0];
|
||||
if (currentPathname === newPathname) return false;
|
||||
|
||||
const icon = resolveRouteIcon(path);
|
||||
const newId = store.openTab(path, path, icon);
|
||||
if (newId) store.setActiveTab(newId);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Root-level navigation provider for components outside the per-tab
|
||||
* RouterProviders (sidebar, search dialog, modals, WindowOverlay contents).
|
||||
@@ -196,7 +165,6 @@ export function DesktopNavigationProvider({
|
||||
const active = currentActiveTab();
|
||||
if (tryRouteToOverlay(path, active?.router)) return;
|
||||
if (tryRouteToOtherWorkspace(path)) return;
|
||||
if (tryRouteToPinnedNewTab(path)) return;
|
||||
active?.router.navigate(path);
|
||||
},
|
||||
replace: (path: string) => {
|
||||
@@ -210,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) {
|
||||
@@ -227,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}`,
|
||||
}),
|
||||
@@ -272,7 +231,6 @@ export function TabNavigationProvider({
|
||||
push: (path: string) => {
|
||||
if (tryRouteToOverlay(path, router)) return;
|
||||
if (tryRouteToOtherWorkspace(path)) return;
|
||||
if (tryRouteToPinnedNewTab(path)) return;
|
||||
router.navigate(path);
|
||||
},
|
||||
replace: (path: string) => {
|
||||
@@ -283,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) {
|
||||
@@ -295,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}`,
|
||||
}),
|
||||
|
||||
@@ -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 />,
|
||||
|
||||
@@ -17,7 +17,6 @@ vi.mock("../routes", () => ({
|
||||
import {
|
||||
sanitizeTabPath,
|
||||
migrateV1ToV2,
|
||||
migrateV2ToV3,
|
||||
useTabStore,
|
||||
} from "./tab-store";
|
||||
|
||||
@@ -278,155 +277,3 @@ describe("useTabStore actions", () => {
|
||||
expect(useTabStore.getState().activeWorkspaceSlug).toBe("acme");
|
||||
});
|
||||
});
|
||||
|
||||
describe("togglePin", () => {
|
||||
it("flips a tab's pinned state", () => {
|
||||
const store = useTabStore.getState();
|
||||
store.switchWorkspace("acme");
|
||||
const tabId = useTabStore.getState().byWorkspace.acme.tabs[0].id;
|
||||
expect(useTabStore.getState().byWorkspace.acme.tabs[0].pinned).toBe(false);
|
||||
|
||||
store.togglePin(tabId);
|
||||
expect(useTabStore.getState().byWorkspace.acme.tabs[0].pinned).toBe(true);
|
||||
|
||||
store.togglePin(tabId);
|
||||
expect(useTabStore.getState().byWorkspace.acme.tabs[0].pinned).toBe(false);
|
||||
});
|
||||
|
||||
it("moves a newly-pinned tab to the start of the pinned zone", () => {
|
||||
const store = useTabStore.getState();
|
||||
store.switchWorkspace("acme"); // creates default unpinned tab at index 0
|
||||
store.addTab("/acme/projects", "Projects", "FolderKanban");
|
||||
store.addTab("/acme/agents", "Agents", "Bot");
|
||||
const agentsId = useTabStore.getState().byWorkspace.acme.tabs[2].id;
|
||||
|
||||
store.togglePin(agentsId);
|
||||
const tabs = useTabStore.getState().byWorkspace.acme.tabs;
|
||||
expect(tabs[0].id).toBe(agentsId);
|
||||
expect(tabs[0].pinned).toBe(true);
|
||||
expect(tabs[1].pinned).toBe(false);
|
||||
expect(tabs[2].pinned).toBe(false);
|
||||
});
|
||||
|
||||
it("appends a second pinned tab after the first pinned tab", () => {
|
||||
const store = useTabStore.getState();
|
||||
store.switchWorkspace("acme");
|
||||
store.addTab("/acme/projects", "Projects", "FolderKanban");
|
||||
store.addTab("/acme/agents", "Agents", "Bot");
|
||||
const projectsId = useTabStore.getState().byWorkspace.acme.tabs[1].id;
|
||||
const agentsId = useTabStore.getState().byWorkspace.acme.tabs[2].id;
|
||||
|
||||
store.togglePin(agentsId);
|
||||
store.togglePin(projectsId);
|
||||
|
||||
// Both pinned, in the order they were pinned (agents first, projects
|
||||
// second), then the unpinned default tab.
|
||||
const tabs = useTabStore.getState().byWorkspace.acme.tabs;
|
||||
expect(tabs.map((t) => t.id)).toEqual([
|
||||
agentsId,
|
||||
projectsId,
|
||||
tabs[2].id,
|
||||
]);
|
||||
expect(tabs.map((t) => t.pinned)).toEqual([true, true, false]);
|
||||
});
|
||||
|
||||
it("returns an unpinned tab to the start of the unpinned zone", () => {
|
||||
const store = useTabStore.getState();
|
||||
store.switchWorkspace("acme");
|
||||
store.addTab("/acme/projects", "Projects", "FolderKanban");
|
||||
const issuesId = useTabStore.getState().byWorkspace.acme.tabs[0].id;
|
||||
const projectsId = useTabStore.getState().byWorkspace.acme.tabs[1].id;
|
||||
|
||||
// Pin both, then unpin one.
|
||||
store.togglePin(issuesId);
|
||||
store.togglePin(projectsId);
|
||||
store.togglePin(issuesId);
|
||||
|
||||
const tabs = useTabStore.getState().byWorkspace.acme.tabs;
|
||||
expect(tabs.map((t) => t.id)).toEqual([projectsId, issuesId]);
|
||||
expect(tabs.map((t) => t.pinned)).toEqual([true, false]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("moveTab boundary clamp", () => {
|
||||
it("clamps a pinned-tab move so it never crosses into the unpinned zone", () => {
|
||||
const store = useTabStore.getState();
|
||||
store.switchWorkspace("acme");
|
||||
store.addTab("/acme/projects", "Projects", "FolderKanban");
|
||||
store.addTab("/acme/agents", "Agents", "Bot");
|
||||
const issuesId = useTabStore.getState().byWorkspace.acme.tabs[0].id;
|
||||
|
||||
store.togglePin(issuesId); // [issues(pinned), projects, agents]
|
||||
|
||||
// User tries to drag the pinned tab to index 2 (unpinned zone end).
|
||||
store.moveTab(0, 2);
|
||||
const tabs = useTabStore.getState().byWorkspace.acme.tabs;
|
||||
// It should be clamped to index 0 — the only pinned slot — i.e. unchanged.
|
||||
expect(tabs[0].id).toBe(issuesId);
|
||||
expect(tabs.map((t) => t.pinned)).toEqual([true, false, false]);
|
||||
});
|
||||
|
||||
it("clamps an unpinned-tab move so it never crosses into the pinned zone", () => {
|
||||
const store = useTabStore.getState();
|
||||
store.switchWorkspace("acme");
|
||||
store.addTab("/acme/projects", "Projects", "FolderKanban");
|
||||
store.addTab("/acme/agents", "Agents", "Bot");
|
||||
const issuesId = useTabStore.getState().byWorkspace.acme.tabs[0].id;
|
||||
const agentsId = useTabStore.getState().byWorkspace.acme.tabs[2].id;
|
||||
|
||||
store.togglePin(issuesId); // [issues(pinned), projects, agents]
|
||||
|
||||
// User tries to drag agents (index 2) to index 0 (pinned zone).
|
||||
store.moveTab(2, 0);
|
||||
const tabs = useTabStore.getState().byWorkspace.acme.tabs;
|
||||
// Clamped to index 1 — start of the unpinned zone.
|
||||
expect(tabs[0].id).toBe(issuesId);
|
||||
expect(tabs[1].id).toBe(agentsId);
|
||||
expect(tabs.map((t) => t.pinned)).toEqual([true, false, false]);
|
||||
});
|
||||
|
||||
it("reorders freely within the same zone", () => {
|
||||
const store = useTabStore.getState();
|
||||
store.switchWorkspace("acme");
|
||||
store.addTab("/acme/projects", "Projects", "FolderKanban");
|
||||
store.addTab("/acme/agents", "Agents", "Bot");
|
||||
|
||||
// All unpinned; move agents (2) to position 0.
|
||||
store.moveTab(2, 0);
|
||||
const tabs = useTabStore.getState().byWorkspace.acme.tabs;
|
||||
expect(tabs.map((t) => t.path)).toEqual([
|
||||
"/acme/agents",
|
||||
"/acme/issues",
|
||||
"/acme/projects",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("migrateV2ToV3", () => {
|
||||
it("adds pinned=false to every persisted tab", () => {
|
||||
const v2 = {
|
||||
activeWorkspaceSlug: "acme",
|
||||
byWorkspace: {
|
||||
acme: {
|
||||
activeTabId: "t1",
|
||||
tabs: [
|
||||
{ id: "t1", path: "/acme/issues", title: "Issues", icon: "ListTodo" },
|
||||
{ id: "t2", path: "/acme/projects", title: "Projects", icon: "FolderKanban" },
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
const v3 = migrateV2ToV3(v2);
|
||||
expect(v3.activeWorkspaceSlug).toBe("acme");
|
||||
expect(v3.byWorkspace.acme.tabs).toEqual([
|
||||
{ id: "t1", path: "/acme/issues", title: "Issues", icon: "ListTodo", pinned: false },
|
||||
{ id: "t2", path: "/acme/projects", title: "Projects", icon: "FolderKanban", pinned: false },
|
||||
]);
|
||||
});
|
||||
|
||||
it("handles missing byWorkspace gracefully", () => {
|
||||
const v3 = migrateV2ToV3({ activeWorkspaceSlug: null } as Parameters<typeof migrateV2ToV3>[0]);
|
||||
expect(v3.byWorkspace).toEqual({});
|
||||
expect(v3.activeWorkspaceSlug).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -20,14 +20,6 @@ export interface Tab {
|
||||
router: DataRouter;
|
||||
historyIndex: number;
|
||||
historyLength: number;
|
||||
/**
|
||||
* Pinned tabs render at the left of the tab bar as icon-only, suppress the
|
||||
* X close button, and turn any `navigation.push()` originating in them into
|
||||
* an `openInNewTab()` so they stay parked on their original path. Pinning
|
||||
* is invariant-preserving: pinned tabs always come before unpinned tabs in
|
||||
* a workspace's `tabs` array; `togglePin` / `moveTab` enforce this.
|
||||
*/
|
||||
pinned: boolean;
|
||||
}
|
||||
|
||||
export interface WorkspaceTabGroup {
|
||||
@@ -86,20 +78,8 @@ interface TabStore {
|
||||
updateTab: (tabId: string, patch: Partial<Pick<Tab, "path" | "title" | "icon">>) => void;
|
||||
/** Patch history tracking of a tab. Finds across groups. */
|
||||
updateTabHistory: (tabId: string, historyIndex: number, historyLength: number) => void;
|
||||
/**
|
||||
* Reorder within the active workspace's group only. Clamped so a tab can
|
||||
* never cross the pinned / unpinned boundary — a drag that would move a
|
||||
* pinned tab into the unpinned zone (or vice versa) is dropped at the
|
||||
* boundary instead. This keeps the "pinned tabs first" invariant without
|
||||
* requiring callers to know about it.
|
||||
*/
|
||||
/** Reorder within the active workspace's group only. */
|
||||
moveTab: (fromIndex: number, toIndex: number) => void;
|
||||
/**
|
||||
* Flip a tab's pinned state. Pinning moves it to the end of the pinned
|
||||
* zone; unpinning moves it to the start of the unpinned zone. Both
|
||||
* preserve the "pinned tabs before unpinned tabs" invariant.
|
||||
*/
|
||||
togglePin: (tabId: string) => void;
|
||||
/**
|
||||
* After the workspace list arrives/changes (login, realtime delete), drop
|
||||
* any tab group whose slug is no longer in `validSlugs`, and repoint
|
||||
@@ -210,17 +190,9 @@ function makeTab(path: string, title: string, icon: string): Tab {
|
||||
router: createTabRouter(path),
|
||||
historyIndex: 0,
|
||||
historyLength: 1,
|
||||
pinned: false,
|
||||
};
|
||||
}
|
||||
|
||||
/** Index of the first unpinned tab in a group (== pinned count). */
|
||||
function pinnedBoundary(tabs: Tab[]): number {
|
||||
let i = 0;
|
||||
while (i < tabs.length && tabs[i].pinned) i++;
|
||||
return i;
|
||||
}
|
||||
|
||||
/** Default entry point for a workspace — its issues list. */
|
||||
function defaultPathFor(slug: string): string {
|
||||
return `/${slug}/issues`;
|
||||
@@ -481,63 +453,17 @@ export const useTabStore = create<TabStore>()(
|
||||
if (!activeWorkspaceSlug) return;
|
||||
const group = byWorkspace[activeWorkspaceSlug];
|
||||
if (!group) return;
|
||||
if (fromIndex < 0 || fromIndex >= group.tabs.length) return;
|
||||
|
||||
// Clamp the drop position to within the source tab's group (pinned vs
|
||||
// unpinned) so the "pinned tabs first" invariant survives drag-reorder.
|
||||
// Pinned zone is [0, boundary); unpinned zone is [boundary, length).
|
||||
const boundary = pinnedBoundary(group.tabs);
|
||||
const source = group.tabs[fromIndex];
|
||||
let clampedTo: number;
|
||||
if (source.pinned) {
|
||||
// boundary is exclusive upper bound for pinned-zone indices.
|
||||
clampedTo = Math.max(0, Math.min(toIndex, boundary - 1));
|
||||
} else {
|
||||
clampedTo = Math.max(boundary, Math.min(toIndex, group.tabs.length - 1));
|
||||
}
|
||||
if (clampedTo === fromIndex) return;
|
||||
set({
|
||||
byWorkspace: {
|
||||
...byWorkspace,
|
||||
[activeWorkspaceSlug]: {
|
||||
...group,
|
||||
tabs: arrayMove(group.tabs, fromIndex, clampedTo),
|
||||
tabs: arrayMove(group.tabs, fromIndex, toIndex),
|
||||
},
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
togglePin(tabId) {
|
||||
const { byWorkspace } = get();
|
||||
const hit = findTabLocation(byWorkspace, tabId);
|
||||
if (!hit) return;
|
||||
const { slug, group, index } = hit;
|
||||
const current = group.tabs[index];
|
||||
const nextTab: Tab = { ...current, pinned: !current.pinned };
|
||||
|
||||
// Remove from current position, then insert at the new zone boundary:
|
||||
// pinning → end of pinned zone (just before first unpinned tab)
|
||||
// unpinning → start of unpinned zone (right after last pinned tab)
|
||||
const withoutCurrent = [
|
||||
...group.tabs.slice(0, index),
|
||||
...group.tabs.slice(index + 1),
|
||||
];
|
||||
const newBoundary = pinnedBoundary(withoutCurrent);
|
||||
const insertAt = newBoundary;
|
||||
const nextTabs = [
|
||||
...withoutCurrent.slice(0, insertAt),
|
||||
nextTab,
|
||||
...withoutCurrent.slice(insertAt),
|
||||
];
|
||||
|
||||
set({
|
||||
byWorkspace: {
|
||||
...byWorkspace,
|
||||
[slug]: { ...group, tabs: nextTabs },
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
validateWorkspaceSlugs(validSlugs) {
|
||||
const { activeWorkspaceSlug, byWorkspace } = get();
|
||||
let changed = false;
|
||||
@@ -571,23 +497,17 @@ export const useTabStore = create<TabStore>()(
|
||||
}),
|
||||
{
|
||||
name: "multica_tabs",
|
||||
version: 3,
|
||||
version: 2,
|
||||
storage: createJSONStorage(() => createPersistStorage(defaultStorage)),
|
||||
migrate: (persistedState, version) => {
|
||||
// v1 → v2: flat `tabs` array → per-workspace grouping.
|
||||
// Tabs whose path isn't workspace-scoped (root `/`, login, etc.)
|
||||
// are dropped — they have no workspace to belong to, and the new
|
||||
// model's invariant is "every tab lives in a workspace group".
|
||||
let state = persistedState;
|
||||
if (version < 2 && state && typeof state === "object") {
|
||||
state = migrateV1ToV2(state as Partial<V1Persisted>);
|
||||
if (version < 2 && persistedState && typeof persistedState === "object") {
|
||||
return migrateV1ToV2(persistedState as Partial<V1Persisted>);
|
||||
}
|
||||
// v2 → v3: introduce `Tab.pinned`. Existing tabs default to
|
||||
// unpinned; pin ordering invariant trivially holds (no pinned tabs).
|
||||
if (version < 3 && state && typeof state === "object") {
|
||||
state = migrateV2ToV3(state as V2Persisted);
|
||||
}
|
||||
return state as V3Persisted;
|
||||
return persistedState as V2Persisted;
|
||||
},
|
||||
partialize: (state) => ({
|
||||
activeWorkspaceSlug: state.activeWorkspaceSlug,
|
||||
@@ -597,19 +517,15 @@ export const useTabStore = create<TabStore>()(
|
||||
{
|
||||
activeTabId: group.activeTabId,
|
||||
tabs: group.tabs.map(
|
||||
({
|
||||
router: _router,
|
||||
historyIndex: _hi,
|
||||
historyLength: _hl,
|
||||
...rest
|
||||
}) => rest,
|
||||
({ router: _router, historyIndex: _hi, historyLength: _hl, ...rest }) =>
|
||||
rest,
|
||||
),
|
||||
},
|
||||
]),
|
||||
),
|
||||
}),
|
||||
merge: (persistedState, currentState) => {
|
||||
const persisted = persistedState as Partial<V3Persisted> | undefined;
|
||||
const persisted = persistedState as Partial<V2Persisted> | undefined;
|
||||
if (!persisted?.byWorkspace) return currentState;
|
||||
|
||||
const byWorkspace: Record<string, WorkspaceTabGroup> = {};
|
||||
@@ -636,14 +552,9 @@ export const useTabStore = create<TabStore>()(
|
||||
router: createTabRouter(clean),
|
||||
historyIndex: 0,
|
||||
historyLength: 1,
|
||||
pinned: pTab.pinned === true,
|
||||
});
|
||||
}
|
||||
if (tabs.length === 0) continue;
|
||||
// Enforce the "pinned first" invariant on rehydration in case a
|
||||
// user (or a buggy older write) persisted the pinned tabs out of
|
||||
// order. Stable sort preserves intra-group order.
|
||||
tabs.sort((a, b) => (a.pinned === b.pinned ? 0 : a.pinned ? -1 : 1));
|
||||
const activeTabId = tabs.some((t) => t.id === pGroup.activeTabId)
|
||||
? pGroup.activeTabId
|
||||
: tabs[0].id;
|
||||
@@ -694,38 +605,6 @@ interface V2Persisted {
|
||||
byWorkspace: Record<string, V2PersistedGroup>;
|
||||
}
|
||||
|
||||
interface V3PersistedTab {
|
||||
id: string;
|
||||
path: string;
|
||||
title: string;
|
||||
icon: string;
|
||||
pinned: boolean;
|
||||
}
|
||||
|
||||
interface V3PersistedGroup {
|
||||
tabs: V3PersistedTab[];
|
||||
activeTabId: string;
|
||||
}
|
||||
|
||||
interface V3Persisted {
|
||||
activeWorkspaceSlug: string | null;
|
||||
byWorkspace: Record<string, V3PersistedGroup>;
|
||||
}
|
||||
|
||||
export function migrateV2ToV3(v2: V2Persisted): V3Persisted {
|
||||
const byWorkspace: Record<string, V3PersistedGroup> = {};
|
||||
for (const [slug, group] of Object.entries(v2.byWorkspace ?? {})) {
|
||||
byWorkspace[slug] = {
|
||||
activeTabId: group.activeTabId,
|
||||
tabs: group.tabs.map((t) => ({ ...t, pinned: false })),
|
||||
};
|
||||
}
|
||||
return {
|
||||
activeWorkspaceSlug: v2.activeWorkspaceSlug ?? null,
|
||||
byWorkspace,
|
||||
};
|
||||
}
|
||||
|
||||
export function migrateV1ToV2(v1: Partial<V1Persisted>): V2Persisted {
|
||||
const byWorkspace: Record<string, V2PersistedGroup> = {};
|
||||
const oldTabs = v1.tabs ?? [];
|
||||
|
||||
@@ -5,7 +5,7 @@ description: "An agent is a first-class member of a Multica workspace — it can
|
||||
|
||||
import { Callout } from "fumadocs-ui/components/callout";
|
||||
|
||||
An agent is a **first-class member** of a Multica [workspace](/workspaces) — like a human, it can be [assigned issues](/assigning-issues), speak up in [comments](/comments), be [`@`-mentioned](/mentioning-agents), and lead a [project](/projects). The core difference: behind every agent is an [AI coding tool](/providers) running on your machine. Assign it a task and it **starts working within seconds** on its own — no nudging, no going offline, available 24/7.
|
||||
An agent is a **first-class member** of a Multica [workspace](/workspaces) — like a human, it can be [assigned issues](/assigning-issues), speak up in [comments](/comments), be [`@`-mentioned](/mentioning-agents), and lead a [project](/issues). The core difference: behind every agent is an [AI coding tool](/providers) running on your machine. Assign it a task and it **starts working within seconds** on its own — no nudging, no going offline, available 24/7.
|
||||
|
||||
## What an agent can do
|
||||
|
||||
@@ -14,7 +14,7 @@ Agents use the same "member" surface as humans, and the UI barely distinguishes
|
||||
- **[Be assigned issues](/assigning-issues)** — once set as the assignee, it starts working automatically
|
||||
- **[Be `@`-mentioned](/mentioning-agents)** — write `@agent-name` in a comment and it wakes up to read that comment
|
||||
- **Post [comments](/comments)** — it reports progress and replies to people under the issue
|
||||
- **Lead a [project](/projects)** — it can be set as project lead, same as a human
|
||||
- **Lead a [project](/issues)** — it can be set as project lead, same as a human
|
||||
- **Open [issues](/issues) itself** — while running a task, if it spots a related problem, it can create a new issue directly
|
||||
|
||||
From the collaboration view, an agent is just a member of the workspace — its name sits in the same member list as humans, usually with a small robot icon in front.
|
||||
|
||||
@@ -5,7 +5,7 @@ description: 智能体(agent)是 Multica 工作区里的一等公民成员
|
||||
|
||||
import { Callout } from "fumadocs-ui/components/callout";
|
||||
|
||||
智能体(agent)是 Multica [工作区](/workspaces) 里的**一等公民成员**——和人一样能被 [分配 issue](/assigning-issues)、在 [评论](/comments) 里发言、被 [`@` 点名](/mentioning-agents)、作为 [project](/projects) 的负责人。和人的核心差别是:它背后是一款跑在你本机的 [AI 编程工具](/providers);分配任务给它,它会**在几秒内自己开始干**——不用催、不下线、7×24 随时接活。
|
||||
智能体(agent)是 Multica [工作区](/workspaces) 里的**一等公民成员**——和人一样能被 [分配 issue](/assigning-issues)、在 [评论](/comments) 里发言、被 [`@` 点名](/mentioning-agents)、作为 [project](/issues) 的负责人。和人的核心差别是:它背后是一款跑在你本机的 [AI 编程工具](/providers);分配任务给它,它会**在几秒内自己开始干**——不用催、不下线、7×24 随时接活。
|
||||
|
||||
## 智能体能做什么
|
||||
|
||||
@@ -14,7 +14,7 @@ import { Callout } from "fumadocs-ui/components/callout";
|
||||
- **[被分配 issue](/assigning-issues)** —— 作为 assignee,分配后它会自动开工
|
||||
- **[被 `@` 点名](/mentioning-agents)** —— 在评论里写 `@agent-name`,它会被立刻唤醒去看这条评论
|
||||
- **发 [评论](/comments)** —— 它会在 issue 底下汇报进展、回复别人
|
||||
- **作为 [project](/projects) 的负责人** —— 和人一样能被设为 project lead
|
||||
- **作为 [project](/issues) 的负责人** —— 和人一样能被设为 project lead
|
||||
- **自己开 [issue](/issues)** —— 跑任务时如果发现了关联问题,它能直接创建新的 issue
|
||||
|
||||
从协作视图上看,智能体就是工作区里的一个成员;它和人的名字排在同一张成员列表里,只是前面通常有一个机器人图标。
|
||||
|
||||
@@ -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:
|
||||
|
||||
|
||||
@@ -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` 互斥。
|
||||
|
||||
取消分配:
|
||||
|
||||
|
||||
@@ -22,7 +22,7 @@ Create a new autopilot on the workspace's **Autopilot** page. You set:
|
||||
|
||||
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
|
||||
|
||||
@@ -22,7 +22,7 @@ Autopilots 让 [智能体](/agents) **按调度自动开工**——配好 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 的运行历史里看到。
|
||||
|
||||
## 让它按时间跑
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -72,7 +72,7 @@ multica daemon status
|
||||
|
||||
In the web UI, go to **Settings → Runtimes**. The daemon you just started should appear as one or more active runtimes — one per AI coding tool installed locally.
|
||||
|
||||
If it shows as offline, don't panic — see [Troubleshooting → Daemon can't connect to the server](/troubleshooting#daemon-cant-connect-to-the-server).
|
||||
If it shows as offline, don't panic — see [Troubleshooting → Daemon can't reach the server](/troubleshooting#daemon-cant-reach-the-server).
|
||||
|
||||
## 5. Create an agent
|
||||
|
||||
@@ -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**:
|
||||
|
||||
|
||||
@@ -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`。
|
||||
|
||||
**接下来守护进程会**:
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -70,7 +70,7 @@ monorepo 的包边界是硬约束:
|
||||
|
||||
### Issue 编号
|
||||
|
||||
每个 issue 有人类可读的编号,比如 `MUL-123`:工作区 `issue_prefix`(大写字母和数字,通常 3 个字符,最长 10 个)+ 流水号。工作区管理员可以在 Settings → General 中修改前缀;修改会让所有现有 issue 重新编号,外部引用——PR 标题、分支名、文档与聊天里的链接——里的旧前缀会失效。
|
||||
每个 issue 有人类可读的编号,比如 `MUL-123`:工作区 `issue_prefix`(3 个大写字母)+ 流水号。前缀在工作区创建时定,之后不可改。
|
||||
|
||||
### 代码注释
|
||||
|
||||
|
||||
@@ -180,12 +180,12 @@ The [GitHub PR ↔ issue integration](/github-integration) needs two variables.
|
||||
|
||||
| Variable | Default | Description |
|
||||
|---|---|---|
|
||||
| `GITHUB_APP_SLUG` | empty | The slug of your GitHub App (the tail of `https://github.com/apps/<slug>`). Drives the Settings → GitHub install button URL |
|
||||
| `GITHUB_APP_SLUG` | empty | The slug of your GitHub App (the tail of `https://github.com/apps/<slug>`). Drives the Settings → Integrations install button URL |
|
||||
| `GITHUB_WEBHOOK_SECRET` | empty | The Webhook secret you set on the GitHub App. Used for HMAC-SHA256 verification of every `pull_request` / `installation` delivery, and as the HMAC key for the setup-callback state token |
|
||||
|
||||
**Behavior when either is unset:**
|
||||
|
||||
- `Connect GitHub` in Settings → GitHub is **disabled** and shows a "not configured" hint to admins.
|
||||
- `Connect GitHub` in Settings → Integrations is **disabled** and shows a "not configured" hint to admins.
|
||||
- The `/api/webhooks/github` endpoint returns **`503 github webhooks not configured`** — Multica refuses to process events with no secret rather than treating every signature as valid.
|
||||
|
||||
**Note:** `GITHUB_WEBHOOK_SECRET` is reused as the signing key for the install-flow state token, so operators only need to manage one secret. It is **not** the GitHub App's *Client* secret — Client secrets are OAuth-related and not used by this integration. See [GitHub integration → Self-host setup](/github-integration#self-host-setup) for the full walkthrough.
|
||||
|
||||
@@ -180,12 +180,12 @@ Multica 存储用户上传的附件(评论里的图片、文件等)。**优
|
||||
|
||||
| 环境变量 | 默认值 | 说明 |
|
||||
|---|---|---|
|
||||
| `GITHUB_APP_SLUG` | 空 | 你的 GitHub App slug(`https://github.com/apps/<slug>` 的尾部)。Settings → GitHub 里安装按钮的跳转 URL 用它拼 |
|
||||
| `GITHUB_APP_SLUG` | 空 | 你的 GitHub App slug(`https://github.com/apps/<slug>` 的尾部)。Settings → Integrations 里安装按钮的跳转 URL 用它拼 |
|
||||
| `GITHUB_WEBHOOK_SECRET` | 空 | 你在 GitHub App 上设置的 Webhook secret。每条 `pull_request` / `installation` delivery 都用它做 HMAC-SHA256 校验;同一个值也用作 setup 回调里 state token 的签名密钥 |
|
||||
|
||||
**任一变量未设时:**
|
||||
|
||||
- Settings → GitHub 里 `Connect GitHub` 按钮 **disable**,对 admin 显示「not configured」提示
|
||||
- Settings → Integrations 里 `Connect GitHub` 按钮 **disable**,对 admin 显示「not configured」提示
|
||||
- `/api/webhooks/github` 直接返回 **`503 github webhooks not configured`**——secret 没配置时 Multica 拒绝处理任何 webhook 事件,而不是把所有签名当 valid
|
||||
|
||||
**注意:** `GITHUB_WEBHOOK_SECRET` 同时被复用为 install 流程里 state token 的签名密钥,所以运维只需要维护一个 secret。它**不是** GitHub App 的 *Client* secret——Client secret 是 OAuth 用的,和本集成无关。完整配置流程见 [GitHub 集成 → Self-Host 配置](/github-integration#self-host-配置)。
|
||||
|
||||
@@ -219,7 +219,7 @@ For file uploads and attachments, configure S3 and (optionally) CloudFront:
|
||||
| `S3_BUCKET` | Bucket name only (e.g. `my-bucket`). Do **not** include the `.s3.<region>.amazonaws.com` suffix — the server constructs the public URL from `S3_BUCKET` + `S3_REGION` |
|
||||
| `S3_REGION` | AWS region (default: `us-west-2`). Must match the bucket's actual region — used for both SDK signing and public URLs |
|
||||
| `AWS_ACCESS_KEY_ID` / `AWS_SECRET_ACCESS_KEY` | Static credentials. When both are unset, the AWS SDK default credential chain is used |
|
||||
| `AWS_ENDPOINT_URL` | Custom S3-compatible endpoint (e.g. MinIO, R2, B2). Setting this switches to path-style URLs |
|
||||
| `AWS_ENDPOINT_URL` | Custom S3-compatible endpoint (e.g. MinIO, R2, B2). Setting this switches the public URL to path-style |
|
||||
| `CLOUDFRONT_DOMAIN` | CloudFront distribution domain — when set, public URLs use this host instead of the S3 host |
|
||||
| `CLOUDFRONT_KEY_PAIR_ID` | CloudFront key pair ID for signed URLs |
|
||||
| `CLOUDFRONT_PRIVATE_KEY` | CloudFront private key (PEM format) |
|
||||
|
||||
@@ -5,7 +5,7 @@ description: Connect a GitHub App once, then PRs whose branch, title, or body re
|
||||
|
||||
import { Callout } from "fumadocs-ui/components/callout";
|
||||
|
||||
Connect a GitHub account or organization once in **Settings → GitHub**. After that, any pull request whose branch name, title, or body contains an issue identifier (for example `MUL-123`) is **auto-linked** to that [issue](/issues), appears under **Pull requests** in the issue sidebar, and — when the PR is merged — moves the issue to **Done**.
|
||||
Connect a GitHub account or organization once in **Settings → Integrations**. After that, any pull request whose branch name, title, or body contains an issue identifier (for example `MUL-123`) is **auto-linked** to that [issue](/issues), appears under **Pull requests** in the issue sidebar, and — when the PR is merged — moves the issue to **Done**.
|
||||
|
||||
There is no per-issue setup. The whole flow is identifier-driven.
|
||||
|
||||
@@ -13,7 +13,7 @@ There is no per-issue setup. The whole flow is identifier-driven.
|
||||
|
||||
| Surface | Behavior |
|
||||
|---|---|
|
||||
| **Settings → GitHub** | Workspace admins see the GitHub tab with a master toggle, **Connect GitHub** button, and feature switches (PR sidebar, Co-authored-by, auto-link). After install you bounce back to the GitHub tab. |
|
||||
| **Settings → Integrations** | Workspace admins see a GitHub card with a **Connect GitHub** button. Clicking it opens GitHub's App install page; after install you bounce back to Settings. |
|
||||
| **Issue sidebar → Pull requests** | Every PR auto-linked to this issue, with title, repo, state (`Open` / `Draft` / `Merged` / `Closed`), and author. Click a row to jump to the PR on GitHub. |
|
||||
| **Webhook (background)** | On every `pull_request` event, Multica upserts the PR row, scans the PR for issue identifiers, and (re)builds the link rows. Idempotent — replaying a delivery is a no-op. |
|
||||
| **Auto-status on merge** | When a PR transitions to `merged`, every linked issue not already `Done` or `Cancelled` is moved to `Done`. The status change is timeline-logged with source `github_pr_merged`. |
|
||||
@@ -56,10 +56,10 @@ The action is attributed to the `system` actor on the timeline. Subscribers of t
|
||||
|
||||
## Disconnecting
|
||||
|
||||
In **Settings → GitHub** there is no installation list — you manage existing installations from GitHub directly:
|
||||
In **Settings → Integrations** there is no installation list — you manage existing installations from GitHub directly:
|
||||
|
||||
- **From GitHub** — uninstall the Multica GitHub App at `https://github.com/settings/installations` (personal) or `https://github.com/organizations/<org>/settings/installations` (org). Multica receives the `installation.deleted` webhook and drops the row in real time; any open Settings tab updates without a refresh.
|
||||
- **Disconnect from inside Multica is admin-only** — the Disconnect control on the GitHub tab is hidden for non-admins. It stays available even when the master GitHub switch is off, so admins can still revoke a stale installation after one-click-disabling the feature.
|
||||
- **Disconnect from inside Multica is admin-only** — the Settings card is hidden for non-admins.
|
||||
|
||||
After disconnect, mirrored PR rows stay in the database so historical issue sidebars still show what was linked, but no new webhook events from that installation will be accepted.
|
||||
|
||||
@@ -121,7 +121,7 @@ Both variables are required. If either is missing:
|
||||
- `Connect GitHub` in Settings is **disabled** and shows a "not configured" hint.
|
||||
- The `/api/webhooks/github` endpoint returns **`503 github webhooks not configured`** — Multica refuses to process events with no secret, rather than silently treating every signature as valid.
|
||||
|
||||
`FRONTEND_ORIGIN` must also be set (it already is for any production self-host); the setup callback bounces the user back to `<FRONTEND_ORIGIN>/settings?tab=github` after install.
|
||||
`FRONTEND_ORIGIN` must also be set (it already is for any production self-host); the setup callback bounces the user back to `<FRONTEND_ORIGIN>/settings` after install.
|
||||
|
||||
Restart the API after setting the env vars.
|
||||
|
||||
@@ -139,10 +139,10 @@ Three tables get created: `github_installation`, `github_pull_request`, `issue_p
|
||||
|
||||
In Multica:
|
||||
|
||||
1. Open **Settings → GitHub** as an owner or admin.
|
||||
1. Open **Settings → Integrations** as an owner or admin.
|
||||
2. Click **Connect GitHub**. GitHub opens in a new tab.
|
||||
3. Pick the repositories to grant access to and **Install**.
|
||||
4. GitHub redirects back to `<api-host>/api/github/setup`, which records the installation and bounces you to `<FRONTEND_ORIGIN>/settings?tab=github&github_connected=1`.
|
||||
4. GitHub redirects back to `<api-host>/api/github/setup`, which records the installation and bounces you to `<FRONTEND_ORIGIN>/settings?github_connected=1`.
|
||||
|
||||
After that, open any PR whose branch / title / body contains an issue identifier — within a few seconds the Pull requests block appears on that issue's detail page.
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ description: 一次性连接 GitHub App,之后 PR 的分支名、标题或正
|
||||
|
||||
import { Callout } from "fumadocs-ui/components/callout";
|
||||
|
||||
在 **Settings → GitHub** 里一次性连一个 GitHub 账号或组织。之后任何 PR 只要分支名、标题或正文里出现 issue 编号(例如 `MUL-123`),就会**自动关联**到那个 [issue](/issues),出现在 issue 详情页右侧的 **Pull requests** 区块里——PR 合并时,issue 自动转 **Done**。
|
||||
在 **Settings → Integrations** 里一次性连一个 GitHub 账号或组织。之后任何 PR 只要分支名、标题或正文里出现 issue 编号(例如 `MUL-123`),就会**自动关联**到那个 [issue](/issues),出现在 issue 详情页右侧的 **Pull requests** 区块里——PR 合并时,issue 自动转 **Done**。
|
||||
|
||||
没有 per-issue 的配置,整个流程是「编号驱动」的。
|
||||
|
||||
@@ -13,7 +13,7 @@ import { Callout } from "fumadocs-ui/components/callout";
|
||||
|
||||
| 出现位置 | 行为 |
|
||||
|---|---|
|
||||
| **Settings → GitHub** | 工作区 owner / admin 看到 GitHub 这个 tab,里面有主开关、**Connect GitHub** 按钮,以及功能开关(PR 侧栏、Co-authored-by、auto-link)。点 Connect 会打开 GitHub 的 App 安装页;装好后跳回 GitHub tab。 |
|
||||
| **Settings → Integrations** | 工作区 owner / admin 看到一个 GitHub 卡片,里面有 **Connect GitHub** 按钮。点击会打开 GitHub 的 App 安装页;装好后跳回 Settings。 |
|
||||
| **Issue 详情侧栏 → Pull requests** | 列出所有自动关联到该 issue 的 PR,含标题、仓库、状态(`Open` / `Draft` / `Merged` / `Closed`)和作者。点一行跳到 GitHub。 |
|
||||
| **Webhook(后台)** | 每次 `pull_request` 事件触发:upsert PR 行 → 扫描里面的 issue 编号 →(重新)建立 link。幂等——重投 delivery 不会产生重复记录。 |
|
||||
| **Merge 自动改 status** | PR 转 `merged` 时,所有已关联且状态不是 `Done` / `Cancelled` 的 issue 会被推到 `Done`。时间线里以 source 为 `github_pr_merged` 记录。 |
|
||||
@@ -56,10 +56,10 @@ PR **关闭但没合并**——只更新 PR 卡片的状态为 `Closed`,issue
|
||||
|
||||
## 断开连接
|
||||
|
||||
**Settings → GitHub** 里没有 installation 列表——现有 installation 直接到 GitHub 上管理:
|
||||
**Settings → Integrations** 里没有 installation 列表——现有 installation 直接到 GitHub 上管理:
|
||||
|
||||
- **从 GitHub 卸载** —— 个人在 `https://github.com/settings/installations`、组织在 `https://github.com/organizations/<org>/settings/installations` 卸载 Multica App。Multica 收到 `installation.deleted` webhook 后立刻删行;任何已打开的 Settings tab 实时更新,不用刷新
|
||||
- **Multica 这边的断开是 admin only** —— GitHub tab 上的 Disconnect 控件对非 admin 不显示;主开关关掉时 Disconnect 仍然可用,方便 admin 一键关闭功能后再单独清理已连接的 installation
|
||||
- **Multica 这边的断开是 admin only** —— 卡片对非 admin 不显示连接操作
|
||||
|
||||
断开之后,已经镜像的 PR 行保留在数据库里——历史 issue 侧栏仍能显示当时关联的 PR,但来自这个 installation 的新 webhook 事件不再被接受。
|
||||
|
||||
@@ -121,7 +121,7 @@ GITHUB_WEBHOOK_SECRET=<你刚生成的 webhook secret>
|
||||
- Settings 里 `Connect GitHub` 按钮会被 **disable**,并显示「not configured」提示
|
||||
- `/api/webhooks/github` 直接返回 **`503 github webhooks not configured`**——Multica 在 secret 没配置时拒绝处理事件,不会出现「没 secret 也接受 webhook」的安全坑
|
||||
|
||||
`FRONTEND_ORIGIN` 也必须设置(任何生产 self-host 都已经设了)——setup 回调结束后用它把用户跳回 `<FRONTEND_ORIGIN>/settings?tab=github`。
|
||||
`FRONTEND_ORIGIN` 也必须设置(任何生产 self-host 都已经设了)——setup 回调结束后用它把用户跳回 `<FRONTEND_ORIGIN>/settings`。
|
||||
|
||||
设完 env 重启 API。
|
||||
|
||||
@@ -139,10 +139,10 @@ make migrate-up
|
||||
|
||||
到 Multica:
|
||||
|
||||
1. 以 owner 或 admin 身份打开 **Settings → GitHub**
|
||||
1. 以 owner 或 admin 身份打开 **Settings → Integrations**
|
||||
2. 点 **Connect GitHub**,GitHub 在新 tab 打开
|
||||
3. 选择要授权的仓库,点 **Install**
|
||||
4. GitHub 跳回 `<api-host>/api/github/setup`,落库后再跳到 `<FRONTEND_ORIGIN>/settings?tab=github&github_connected=1`
|
||||
4. GitHub 跳回 `<api-host>/api/github/setup`,落库后再跳到 `<FRONTEND_ORIGIN>/settings?github_connected=1`
|
||||
|
||||
之后在任意一个仓库开一个分支 / 标题 / 正文带本工作区 issue 编号的 PR——几秒内对应 issue 的详情页上就能看到 Pull requests 区块。
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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 Code(Anthropic)
|
||||
|
||||
集成最完整的一款。会话续接好用,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`。 |
|
||||
| 备注 | 新用户首选。 |
|
||||
|
||||
### Codex(OpenAI)
|
||||
|
||||
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`。 |
|
||||
|
||||
### Cursor(Anysphere)
|
||||
|
||||
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 订阅。 |
|
||||
|
||||
### Gemini(Google)
|
||||
|
||||
支持 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`。 |
|
||||
|
||||
### OpenCode(SST)
|
||||
|
||||
开源 CLI agent。会从自己的配置文件里动态发现可用模型 —— 适合想自己掌控模型清单的用户。
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| 守护进程扫描 | `opencode` |
|
||||
| 安装 | 看官方指引 [opencode.ai](https://opencode.ai/) 或仓库 [github.com/sst/opencode](https://github.com/sst/opencode)。一般是装脚本或 npm 包。 |
|
||||
| 认证 | 按 OpenCode 的文档配你自己的模型供应商(Anthropic、OpenAI 等)。 |
|
||||
|
||||
### Kiro CLI(Amazon)
|
||||
|
||||
ACP-over-stdio 传输。会话续接通过 ACP `session/load` 工作;skills 拷到 `.kiro/skills/`。
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| 守护进程扫描 | `kiro-cli` |
|
||||
| 安装 | 看 Kiro 的文档 [kiro.dev](https://kiro.dev/)。可执行文件叫 `kiro-cli`,不是 `kiro`。 |
|
||||
| 认证 | 基于 AWS 账号,按 Kiro 自己的引导走。 |
|
||||
|
||||
### Kimi(Moonshot)
|
||||
|
||||
ACP 协议 agent,主要面向中国市场。Skills 放在 `.kimi/skills/`(原生发现路径)。
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| 守护进程扫描 | `kimi` |
|
||||
| 安装 | 看官方指引 [github.com/MoonshotAI/kimi-cli](https://github.com/MoonshotAI/kimi-cli)。 |
|
||||
| 认证 | Moonshot API key,按厂商文档配置。 |
|
||||
|
||||
### Hermes(Nous 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 的文档配底层模型供应商。 |
|
||||
|
||||
### Pi(Inflection 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 挑一款工具,开始跑任务
|
||||
@@ -19,7 +19,6 @@
|
||||
"squads",
|
||||
"---How agents run---",
|
||||
"daemon-runtimes",
|
||||
"install-agent-runtime",
|
||||
"tasks",
|
||||
"providers",
|
||||
"---Collaborating with agents---",
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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` 拿。
|
||||
|
||||
## 下一步
|
||||
|
||||
|
||||
@@ -77,9 +77,8 @@ multica issue rerun <issue-id>
|
||||
|
||||
Behavior:
|
||||
|
||||
- By default, targets the issue's **current agent assignee** — useful when you want the rerun to follow the current assignment regardless of who ran the prior task.
|
||||
- The execution-log retry button on a specific row sends that row's task ID alongside, so the rerun targets **the agent that ran that exact task** — not the current assignee. This makes per-row retry meaningful for squad workers, parallel @-mention agents, or rows whose agent has since been displaced by a reassignment.
|
||||
- **Cancels** the target agent's queued or running task on this issue (if any). Tasks owned by other agents on the same issue (e.g. parallel @-mention runs) are left alone.
|
||||
- Targets the issue's **current agent assignee** — not whoever ran the most recent task. If the assignee changed since the last run, rerun follows the current assignment. To rerun a specific agent that is no longer the assignee, reassign the issue first, then rerun.
|
||||
- **Cancels** the assignee's queued or running task on this issue (if any). Tasks owned by other agents on the same issue (e.g. parallel @-mention runs) are left alone.
|
||||
- Creates a **brand-new** task — attempt count resets to 1, even if the original task hit the attempt ceiling.
|
||||
- Starts a **fresh agent session** — the prior session ID is **not** inherited. A manual rerun means you've judged the previous output bad, so resuming the same conversation would replay the same poisoned state. (Automatic retry, by contrast, does inherit the session — that path is for infrastructure failures, not bad output.)
|
||||
|
||||
@@ -90,7 +89,7 @@ Comparison:
|
||||
| Trigger | System, based on failure reason | You, manually |
|
||||
| Ceiling | 2 attempts | No limit |
|
||||
| Applicable sources | Issues, chat | Issues with an agent assignee |
|
||||
| Agent picked | Same agent as the failed task | Source task's agent (UI per-row retry) or issue's current assignee (CLI / no task_id) |
|
||||
| Agent picked | Same agent as the failed task | Issue's current assignee |
|
||||
| Session inheritance | Yes (resumes prior session) | No (fresh session) |
|
||||
|
||||
## How a failed task affects issue status
|
||||
|
||||
@@ -77,9 +77,8 @@ multica issue rerun <issue-id>
|
||||
|
||||
行为:
|
||||
|
||||
- 默认跑的是 issue **当前的智能体分配人**——适用于希望 rerun 跟随当前分配人的场景。
|
||||
- 执行日志里某一行的 retry 按钮会把这一行的 task ID 一并发出,rerun 会**针对那一行原本的 agent**,而不是当前分配人。这让 squad worker、并行的 @-mention agent、或者已经被新分配人替代的旧任务行的 retry 按钮都能符合直觉地工作。
|
||||
- **取消**目标 agent 在这条 issue 上 queued / running 的任务(如果有)。同 issue 上其它 agent 的任务(例如 @-mention 触发的并行任务)不会被一起取消。
|
||||
- 跑的是 issue **当前的智能体分配人**——不是上一次跑过的 agent。如果分配人在上次运行后改了,rerun 会跟着新的分配人走。要重跑一个已经不再是分配人的智能体,先把 issue 改派回它,再 rerun。
|
||||
- **取消**该分配人在这条 issue 上 queued / running 的任务(如果有)。同 issue 上其它 agent 的任务(例如 @-mention 触发的并行任务)不会被一起取消。
|
||||
- 创建一个**全新**的执行任务——尝试次数重置为 1,即使原任务已达最大尝试。
|
||||
- 启动**全新的智能体会话**——**不**继承之前的会话 ID。手动重跑意味着你已经判定上一次的产出不行,再继续之前的对话只会重放被污染的上下文。(自动重试则相反,会继承会话——那条路径处理的是基础设施层面的失败,不是产出不好。)
|
||||
|
||||
@@ -90,7 +89,7 @@ multica issue rerun <issue-id>
|
||||
| 触发 | 系统基于失败原因自动执行 | 你主动发起 |
|
||||
| 上限 | 2 次 | 无上限 |
|
||||
| 适用来源 | issue、聊天 | 有智能体分配人的 issue |
|
||||
| 跑哪个 agent | 失败任务原本的 agent | UI 单行 retry:那一行任务的 agent;CLI / 不带 task_id:issue 当前的分配人 |
|
||||
| 跑哪个 agent | 失败任务原本的 agent | issue 当前的分配人 |
|
||||
| 会话继承 | 是(接着上次会话) | 否(全新会话) |
|
||||
|
||||
## 失败的任务对 issue 状态有什么影响
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -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 前缀当成"创建后不改"的设计来对待。
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -4,6 +4,7 @@ import { DashboardLayout } from "@multica/views/layout";
|
||||
import { MulticaIcon } from "@multica/ui/components/common/multica-icon";
|
||||
import { SearchCommand, SearchTrigger } from "@multica/views/search";
|
||||
import { ChatFab, ChatWindow } from "@multica/views/chat";
|
||||
import { StarterContentPrompt } from "@multica/views/onboarding";
|
||||
|
||||
export default function Layout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
@@ -15,6 +16,7 @@ export default function Layout({ children }: { children: React.ReactNode }) {
|
||||
<SearchCommand />
|
||||
<ChatWindow />
|
||||
<ChatFab />
|
||||
<StarterContentPrompt />
|
||||
</>
|
||||
}
|
||||
>
|
||||
|
||||
@@ -1,8 +1 @@
|
||||
import { RuntimesPage } from "@multica/views/runtimes";
|
||||
|
||||
const cloudRuntimeEnabled =
|
||||
process.env.NEXT_PUBLIC_ENABLE_CLOUD_RUNTIME === "true";
|
||||
|
||||
export default function RuntimesRoute() {
|
||||
return <RuntimesPage cloudRuntimeEnabled={cloudRuntimeEnabled} />;
|
||||
}
|
||||
export { RuntimesPage as default } from "@multica/views/runtimes";
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -284,61 +284,6 @@ export function createEnDict(allowSignup: boolean): LandingDict {
|
||||
fixes: "Bug Fixes",
|
||||
},
|
||||
entries: [
|
||||
{
|
||||
version: "0.3.4",
|
||||
date: "2026-05-20",
|
||||
title: "Smarter Autopilots, Agent Controls & Desktop Reliability",
|
||||
changes: [],
|
||||
features: [
|
||||
"Autopilots can assign new work through squads and place created Issues directly into a selected Project",
|
||||
"Agent settings now include per-agent thinking controls for Claude and Codex, with an inspector picker that updates instantly",
|
||||
"Desktop tabs can be pinned so important workspace pages stay parked while new links open in fresh tabs",
|
||||
"User profiles can add requester context, giving coding agents better background for assigned Issues",
|
||||
"Workspace settings now have a dedicated GitHub page, and regular members can see connected GitHub installations without admin controls",
|
||||
],
|
||||
improvements: [
|
||||
"New users are guided to connect a runtime instead of receiving starter content that may not match their workspace",
|
||||
"Runtime pages are quieter, and desktop keeps the local machine visible after stopping the local service",
|
||||
"Issue breadcrumbs show the Project segment when an Issue belongs to a Project",
|
||||
"HTML previews and attachment previews have roomier, more predictable layouts",
|
||||
"Squad pages show fuller loading states and use a clearer archive confirmation dialog",
|
||||
"Agents now receive parent and sub-issue handoff guidance before running assigned work",
|
||||
],
|
||||
fixes: [
|
||||
"List editing exits cleanly from an empty top-level item when pressing Enter",
|
||||
"The installer falls back to release binaries when Homebrew setup fails and reports clearer diagnostics",
|
||||
"Retrying an execution log row now reruns the agent that handled that row",
|
||||
"Chat and task-message loading ignore temporary IDs instead of calling invalid task routes",
|
||||
"OpenCode-backed daemon runs no longer enter invisible interactive question prompts",
|
||||
"Gemini runtimes use the correct official icon",
|
||||
],
|
||||
},
|
||||
{
|
||||
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",
|
||||
@@ -354,6 +299,7 @@ export function createEnDict(allowSignup: boolean): LandingDict {
|
||||
],
|
||||
improvements: [
|
||||
"Failed issue actions now show clearer error messages so teams can understand what happened without digging through logs",
|
||||
"Agent runs recover more reliably from stuck commands, idle sessions, and long-running work",
|
||||
"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",
|
||||
|
||||
@@ -284,61 +284,6 @@ export function createZhDict(allowSignup: boolean): LandingDict {
|
||||
fixes: "问题修复",
|
||||
},
|
||||
entries: [
|
||||
{
|
||||
version: "0.3.4",
|
||||
date: "2026-05-20",
|
||||
title: "自动任务项目归属、智能体思考设置与更稳的桌面端",
|
||||
changes: [],
|
||||
features: [
|
||||
"自动任务现在可以通过小队分配工作,并把创建的 Issue 直接归入指定项目",
|
||||
"智能体设置新增 Claude 和 Codex 的思考强度控制,并可在详情面板里直接调整",
|
||||
"桌面端标签页可以固定,重要页面会留在左侧,打开新内容时不打断原页面",
|
||||
"用户资料可以补充请求者背景,让代码智能体在处理 Issue 时更理解上下文",
|
||||
"工作区设置新增 GitHub 专页,普通成员也能查看已连接的 GitHub 安装信息",
|
||||
],
|
||||
improvements: [
|
||||
"新用户引导会优先创建连接运行环境的下一步,不再生成不合适的示例内容",
|
||||
"运行环境页面减少重复信息,桌面端停止本机服务后仍能看到本机行并重新启动",
|
||||
"Issue 面包屑会显示所属项目,查看来源更清楚",
|
||||
"HTML 预览和附件预览拥有更合适的默认尺寸,查看内容更自然",
|
||||
"小队列表加载状态更完整,归档小队时会使用更清晰的确认弹窗",
|
||||
"智能体运行前会收到父 Issue / 子 Issue 协作规则,完成子任务后的回传更稳定",
|
||||
],
|
||||
fixes: [
|
||||
"在空的顶层列表项按 Enter 时,编辑器可以正常退出列表",
|
||||
"安装脚本在 Homebrew 失败时会自动改用发行版文件,并显示更清楚的诊断信息",
|
||||
"从执行记录重试时,会重新唤起当时处理该记录的智能体",
|
||||
"聊天和任务消息加载会跳过临时 ID,避免访问无效任务",
|
||||
"OpenCode 运行环境不再进入看不见的交互提问流程",
|
||||
"Gemini 运行环境使用正确的官方图标",
|
||||
],
|
||||
},
|
||||
{
|
||||
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",
|
||||
@@ -353,6 +298,7 @@ export function createZhDict(allowSignup: boolean): LandingDict {
|
||||
],
|
||||
improvements: [
|
||||
"Issue 操作失败时会显示更明确的错误原因,团队不用翻日志也能理解发生了什么",
|
||||
"Agent 运行在遇到卡住的命令、空闲会话和长时间任务时更容易恢复",
|
||||
"关联 GitHub 的 Pull Request 会在 Multica 内展示 CI 和合并冲突状态",
|
||||
"自托管部署获得更安全的默认配置,并补充反向代理、登录限制和本地服务的说明",
|
||||
"搜索结果排序更准确,也会展示更有帮助的摘要片段",
|
||||
|
||||
@@ -15,8 +15,6 @@ export const mockUser: User = {
|
||||
// field shipped — migration 054 backfills 'skipped_legacy'.
|
||||
starter_content_state: "skipped_legacy",
|
||||
language: null,
|
||||
timezone: null,
|
||||
profile_description: "",
|
||||
created_at: "2026-01-01T00:00:00Z",
|
||||
updated_at: "2026-01-01T00:00:00Z",
|
||||
};
|
||||
|
||||
@@ -90,7 +90,7 @@ Every event is assigned to one dashboard category:
|
||||
| Category | Events |
|
||||
|---|---|
|
||||
| `core_loop` | `workspace_created`, `runtime_registered`, `runtime_ready`, `runtime_failed`, `runtime_offline`, `agent_created`, `issue_created`, `chat_message_sent`, `agent_task_queued`, `agent_task_dispatched`, `agent_task_started`, `agent_task_completed`, `agent_task_failed`, `agent_task_cancelled`, `autopilot_run_started`, `autopilot_run_completed`, `autopilot_run_failed` |
|
||||
| `onboarding_support` | `onboarding_started`, `onboarding_questionnaire_submitted`, `onboarding_completed`, `onboarding_runtime_path_selected`, `onboarding_runtime_detected` |
|
||||
| `onboarding_support` | `onboarding_started`, `onboarding_questionnaire_submitted`, `onboarding_completed`, `onboarding_runtime_path_selected`, `onboarding_runtime_detected`, `starter_content_decided` |
|
||||
| `acquisition` | `signup`, `download_intent_expressed`, `download_page_viewed`, `download_initiated`, `cloud_waitlist_joined` |
|
||||
| `ops_feedback` | `feedback_opened`, `feedback_submitted` |
|
||||
| `system/noise` | `$pageview`, `$set`, `$identify`, `$autocapture`, `$rageclick` |
|
||||
@@ -470,6 +470,21 @@ in the DB and never broadcast.
|
||||
the modal's current-workspace context and may be empty when feedback is
|
||||
sent from a pre-workspace surface.
|
||||
|
||||
### `starter_content_decided`
|
||||
|
||||
Fires on the atomic NULL → terminal state transition in both
|
||||
ImportStarterContent and DismissStarterContent. The `branch` property
|
||||
mirrors what ImportStarterContent would emit for the same workspace,
|
||||
so import-vs-dismiss rates split cleanly by branch.
|
||||
|
||||
| Property | Type | Description |
|
||||
|---|---|---|
|
||||
| `decision` | string | `imported` or `dismissed`. |
|
||||
| `branch` | string | `agent_guided` (workspace had ≥1 agent at decision time) or `self_serve` (no agents). |
|
||||
|
||||
`distinct_id` is the user's id; `workspace_id` is attached from the
|
||||
request payload.
|
||||
|
||||
### Frontend-only events
|
||||
|
||||
- `$pageview` — fired by `apps/web/components/pageview-tracker.tsx` on
|
||||
|
||||
@@ -1,374 +0,0 @@
|
||||
# Timezone 架构重构 — Scheduling / Viewing 两层模型
|
||||
|
||||
> Status: Implemented
|
||||
> Last updated: 2026-05-20
|
||||
|
||||
## TL;DR
|
||||
|
||||
- **问题**:当前代码里 timezone 被三种语义混用,导致 workspace usage 页 picker 在 #2822 review 中被移除(前后端 tz 不一致会把跨 UTC 午夜的行算到错的 calendar week),同时 runtime detail 页的 timezone editor 又承担了"既是物理 tz 又是报表 tz"的双重职责。
|
||||
- **方案**:把 timezone 收敛成两个独立的 product 概念——**Scheduling**(trigger 规则里写的"9 点"是哪个 9 点,由 `autopilot_trigger.timezone` 承载)和 **Viewing**(用户报表 tz,由新字段 `user.timezone` 承载)。原先混在 `runtime.timezone` 上的"物理位置"语义(Operational)经盘查无真实消费者,整列移除。
|
||||
- **数据层**:把 `task_usage_daily` (per-runtime, 物化在 runtime tz) 和 `task_usage_dashboard_daily` (workspace 级, 物化在 UTC) **合并成一张 `task_usage_hourly` (UTC, hourly grain)**,所有报表查询按调用方 tz 在查询时切日界。
|
||||
- **新增字段**:`user.timezone`(默认 = browser detected,可在 Preferences 覆盖)。
|
||||
- **不引入** `workspace.timezone`——viewing tz 是查看者属性,不是 workspace 属性。
|
||||
- **性能**:hourly rollup 在密集工况(16 active hours/day)下单 ws 90d 窗口 ~15k 行、~15ms,和现有 daily rollup 同档。
|
||||
- **副产品**:Migration 082 的"改 runtime tz → 重灌整张 rollup"逻辑可以删除;跨 region 团队自动支持各看各的"今天";未来要做 hourly heatmap / 时段分析无需再动 schema。
|
||||
|
||||
---
|
||||
|
||||
## 1. 背景
|
||||
|
||||
### 1.1 现状盘点
|
||||
|
||||
代码里"timezone"出现在四个地方:
|
||||
|
||||
| # | 位置 | 字段 | 实际语义 |
|
||||
|---|---|---|---|
|
||||
| 1 | `agent_runtime.timezone` | TEXT, daemon 探测或 UI 覆盖 | 报表 + 物理位置(混淆) |
|
||||
| 2 | `autopilot_trigger.timezone` | TEXT, 用户写规则时选 | Scheduling(正确) |
|
||||
| 3 | Workspace Usage 页面 | 无字段,曾在前端用 `useState(browserTimezone())` | Viewing(被 #2822 删除) |
|
||||
| 4 | 各种 list / log 时间戳显示 | 浏览器 tz | Viewing(隐式) |
|
||||
|
||||
### 1.2 问题
|
||||
|
||||
**问题 A — Runtime tz 同时承担两个不同的角色:**
|
||||
|
||||
`runtime.timezone` 在 migration 082 之后决定了 `task_usage_daily.bucket_date` 的物化口径,等于"报表 tz";同时 daemon 启动时 `detectLocalTimezone()` 写入这个字段,又当成"机器物理 tz"用。结果:
|
||||
|
||||
- 改这个字段会触发整张 rollup 重新物化(migration 082 backfill 逻辑),代价不小。
|
||||
- 一个 SF 的 dev 把 daemon 跑在 PST 的机器上,但 PM 在上海希望按 CST 出报表——这一个字段没法同时满足两个需求。
|
||||
- daemon 自动探测的"客观真值"和用户手动想换的"我想看的报表 tz"被同一个 PATCH 接口覆盖,互相打架。
|
||||
|
||||
**问题 B — Workspace usage 页面没有正确的"报表 tz"概念:**
|
||||
|
||||
PR #2822 删除了 workspace usage 页的 TimezonePicker,原因是:
|
||||
|
||||
> 后端 dashboard rollup 把数据按 UTC `bucket_date` 聚合,但前端却驱动 Weekly 边界用用户在 picker 里选的 tz。靠近 UTC 午夜的行会被放进错的 calendar week。Lock workspace Weekly to UTC and remove the timezone picker。
|
||||
|
||||
这个修复是对的——前后端 tz 不一致就是 bug。但它**没解决根本问题**:用户确实需要按自己的 tz 看 workspace 报表,只是当前数据层没法支持。
|
||||
|
||||
**问题 C — Viewing tz 没有持久化:**
|
||||
|
||||
即使 picker 还在,它也只是 `useState(browserTimezone())`——刷新页面、换设备、跨 session 都会丢。用户每次都得手动切。
|
||||
|
||||
**问题 D — 没有"跨 region 团队"的支持位:**
|
||||
|
||||
把"报表 tz"放在 workspace 上是常见的诱惑,但 workspace 里两个成员一个在 SF 一个在 Beijing,他们想看到的"今天"本来就不同。任何"workspace 级 tz 设置"都强制其中一个人看错位的报表。
|
||||
|
||||
### 1.3 目标
|
||||
|
||||
1. **架构上清晰**:每个 timezone 字段只回答一个问题。
|
||||
2. **性能上不退步**:所有现有报表查询保持 <15ms 量级。
|
||||
3. **正确性优先**:前后端 tz 物化口径必须一致,没有"前端切了但后端没跟"的 UI 谎言。
|
||||
4. **跨 region 友好**:同一 workspace 不同成员可以各看各的"今天"。
|
||||
|
||||
---
|
||||
|
||||
## 2. 两个 timezone 概念
|
||||
|
||||
| 概念 | 在回答什么 | 谁是真值 | 承载字段 |
|
||||
|---|---|---|---|
|
||||
| **Scheduling** | "9 点跑"的 9 点是哪个 9 点 | 用户写规则那一刻的意图 | `autopilot_trigger.timezone` |
|
||||
| **Viewing** | 我想看的"今天"是哪个日历日 | 当前查看者的偏好 | `user.timezone`(新增) |
|
||||
|
||||
**关键论断**:之前代码把"物理位置"和"报表口径"混在 `runtime.timezone` 一个字段上。重构后:
|
||||
|
||||
- Scheduling 不动,`autopilot_trigger.timezone` 已经正确。
|
||||
- Viewing 由新字段 `user.timezone` 承载。
|
||||
- 数据层不再按任何固定 tz 物化 bucket,而是以 UTC 为唯一存储口径,所有报表查询在 read time 按调用方传入的 tz 切日界。
|
||||
- `runtime.timezone` 整列删除——见 §2.1。
|
||||
|
||||
### 2.1 为什么不要 Operational 层
|
||||
|
||||
最初设计有第三个概念 **Operational**(机器物理在哪)。落地盘查后砍掉,两条理由:
|
||||
|
||||
**理由一 —— 就算需要 operational tz,`runtime` 也是错的层级。** Operational tz 是**物理机器**的属性,不是 runtime 的属性。同一台机器可以跑多个 runtime,它们共用同一个 OS 时钟,operational tz 必然相同。把 tz 放在 `agent_runtime` 上,等于把一个 machine 级事实复制到同机每一行 runtime——天然的冗余与 drift 风险(同机两个 runtime 的 tz 被改得不一致是无意义的非法状态)。要建模 operational tz,正确归属是 machine 层;而当前 schema 里根本没有 machine 实体,强行放 runtime 层只是把错误固化。
|
||||
|
||||
**理由二 —— 它的消费者都不需要 operational 语义。** `runtime.timezone` 今天承担"既是物理 tz 又是报表 tz"的双重职责,但盘查后没有一个读取者真正要"机器物理 tz":
|
||||
|
||||
- runtime detail 页的 Daily / Weekly 趋势图、KPI 卡片,通过 `task_usage_daily` 的物化口径间接吃这个 tz——这是**报表口径**语义,不是 operational。而且这些成本/token 数字要和 workspace dashboard 跨页对账,dashboard 下挂多 runtime、多时区,根本不存在"workspace 的 operational tz",可对账量只能统一走 Viewing tz。
|
||||
- hour-of-day heatmap(`GetRuntimeUsageByHour` / `GetRuntimeTaskActivity`)看似要"机器作息"属性,但若只让它一个图表走 operational,用户在同一张卡里切 "Daily" ↔ "Heatmap" 会看到同一个"昨天"两个数。它也只能跟 Viewing tz。
|
||||
|
||||
autopilot 调度走 `trigger.timezone` 不碰它,daemon 要时钟直接读 OS clock,`TimezoneEditor` 只是编辑它自己。换句话说,凡是真读它的地方都应当是 Viewing tz——operational 语义在整个系统里没有一个真实需求点。
|
||||
|
||||
结论:Operational 作为服务端持久化、用户可编辑的字段没有立足点。机器有物理时钟这个**事实**永远存在,但那是 daemon 进程内部的事,不必上 server。`runtime.timezone` 整列由 migration 104 删除。
|
||||
|
||||
代价已知且接受:跨 region 团队看一台 SF runtime 的 hour-of-day heatmap 时,按查看者自己的 tz(如 Asia/Shanghai)显示活跃时段,而非机器本地的 9-to-5。对单 region 团队零影响。
|
||||
|
||||
---
|
||||
|
||||
## 3. 字段定义与 UI 文案
|
||||
|
||||
### 3.1 `runtime.timezone` — 已移除
|
||||
|
||||
由 migration `104_drop_runtime_timezone` 删除整列。daemon 注册不再上报 host tz(`detectLocalTimezone()` 删除),`PATCH /api/runtimes/:id` 不再接受 `timezone`(只剩 `visibility`),Runtime Detail 页的 timezone editor 删除。理由见 §2.1。
|
||||
|
||||
### 3.2 `autopilot_trigger.timezone` — 不动
|
||||
|
||||
已经正确。
|
||||
|
||||
### 3.3 `user.timezone` — 新增 Viewing 字段
|
||||
|
||||
实现见 migration `100_user_timezone`。表名是 `"user"`(单数、保留字需加引号):
|
||||
|
||||
```sql
|
||||
ALTER TABLE "user"
|
||||
ADD COLUMN timezone TEXT NULL;
|
||||
|
||||
COMMENT ON COLUMN "user".timezone IS
|
||||
'User-preferred IANA timezone for report rendering (Viewing tz). '
|
||||
'NULL means "use the browser-detected tz at render time". Affects '
|
||||
'dashboards, charts, and any "today" label shown to this user. Does '
|
||||
'not affect data materialisation — all rollups remain in UTC.';
|
||||
```
|
||||
|
||||
`NULL` 是默认值——前端在 NULL 时 fallback 到 `browserTimezone()`。这样新用户零配置就有合理行为。
|
||||
|
||||
UI:
|
||||
- **Settings → Preferences → Timezone**:dropdown,可选 `(browser)` 或具体 IANA name。
|
||||
- Hint:`"Used for dashboards, charts, and any 'today' label shown to you. Other users in your workspaces will see their own timezone."`
|
||||
|
||||
### 3.4 不引入 `workspace.timezone`
|
||||
|
||||
理由见 §1.2 问题 D。如果未来真有"workspace 默认报表 tz"的需求(例如新成员加入时给一个建议默认值),可以在那时再加,与本 RFC 兼容——`user.timezone` 可作为 `workspace.timezone` 的 override。
|
||||
|
||||
### 3.5 Viewing tz 如何到达后端
|
||||
|
||||
报表 handler 通过 `Handler.resolveViewingTZ(r)` 解析当前请求该用哪个 tz 渲染,优先级:
|
||||
|
||||
1. `?tz=` query param —— 浏览器端 `useViewingTimezone()` 解析后随每个报表请求显式带上。
|
||||
2. 已认证用户的 `user.timezone`(query param 缺失时的 cold fallback,会多查一次 `GetUser`)。
|
||||
3. `"UTC"` —— 兜底。
|
||||
|
||||
非法 IANA 名直接跳过该级、不报错(tz 是显示问题)。浏览器走 (1) 显式 query param 这条热路径,旧客户端 / API client 漏传时由 (2) 服务端读 `user.timezone` 兜底。Handler 拿到 tz 后用 `parseSinceParamInTZ` 把 `days=N` 折算成"查看者本地第 N 天零点"对应的 UTC 瞬间,再连同 `@tz` 一起传给 SQL。
|
||||
|
||||
---
|
||||
|
||||
## 4. 数据层设计
|
||||
|
||||
### 4.1 新表 `task_usage_hourly`
|
||||
|
||||
实现见 migration `101_task_usage_hourly_schema`(建表):
|
||||
|
||||
```sql
|
||||
CREATE TABLE task_usage_hourly (
|
||||
bucket_hour TIMESTAMPTZ NOT NULL, -- UTC, truncated to hour boundary
|
||||
workspace_id UUID NOT NULL,
|
||||
runtime_id UUID NOT NULL,
|
||||
agent_id UUID NOT NULL,
|
||||
project_id UUID, -- nullable
|
||||
provider TEXT NOT NULL,
|
||||
model TEXT NOT NULL,
|
||||
input_tokens BIGINT NOT NULL DEFAULT 0,
|
||||
output_tokens BIGINT NOT NULL DEFAULT 0,
|
||||
cache_read_tokens BIGINT NOT NULL DEFAULT 0,
|
||||
cache_write_tokens BIGINT NOT NULL DEFAULT 0,
|
||||
task_count BIGINT NOT NULL DEFAULT 0, -- COUNT(DISTINCT task_id)
|
||||
event_count BIGINT NOT NULL DEFAULT 0, -- COUNT(*) of task_usage rows
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
CONSTRAINT uq_task_usage_hourly_key
|
||||
UNIQUE NULLS NOT DISTINCT
|
||||
(bucket_hour, workspace_id, runtime_id, agent_id, project_id, provider, model)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_task_usage_hourly_workspace_time
|
||||
ON task_usage_hourly (workspace_id, bucket_hour DESC);
|
||||
CREATE INDEX idx_task_usage_hourly_runtime_time
|
||||
ON task_usage_hourly (runtime_id, bucket_hour DESC);
|
||||
CREATE INDEX idx_task_usage_hourly_workspace_agent_time
|
||||
ON task_usage_hourly (workspace_id, agent_id, bucket_hour DESC);
|
||||
CREATE INDEX idx_task_usage_hourly_workspace_project_time
|
||||
ON task_usage_hourly (workspace_id, project_id, bucket_hour DESC)
|
||||
WHERE project_id IS NOT NULL;
|
||||
```
|
||||
|
||||
**关于字段的几个落地决定**:
|
||||
|
||||
- **没有 `cost_micros` 列**。成本不在数据层物化——`task_usage_hourly` 只存 token 计数,PK 里带 `provider`+`model`,客户端按 per-model 定价表算成本。这样定价表更新无需重灌 rollup。
|
||||
- **`task_count` 与 `event_count` 两个计数**:`task_count` 是 `COUNT(DISTINCT task_id)`,`event_count` 是 `COUNT(*)`(同一 task 多次 usage 事件)。注意 task 跨多个 hour bucket 时 `task_count` 会按小时重复计——面向用户的"任务数"列优先用 `agent_task_queue` 派生的查询(见 §4.2),hourly 表的 `task_count` 仅作信息参考。
|
||||
- **`runtime_id` 为 `NOT NULL`**:`agent_task_queue.runtime_id` 本身带 `NOT NULL` 约束(migration 004),所有建队列的写入路径(含 quick-create)都会带上 runtime,所以 rollup 永远不会产生 no-runtime 的 bucket。`project_id` 可空是因为任务确实可以不挂 project。
|
||||
|
||||
migration 101 同时建了两张配套表:
|
||||
|
||||
- `task_usage_hourly_rollup_state` —— 单行 watermark 状态表(与 073/084 的 rollup_state 同形)。
|
||||
- `task_usage_hourly_dirty` —— 失效队列,承载 `updated_at` watermark 看不到的失效(`task_usage` 的 DELETE、级联 DELETE、`issue.project_id` / `agent_task_queue.runtime_id` 改动导致的重新归属)。**必须配 TTL**,见 §4.4。
|
||||
|
||||
**这一张表替换两张现有表**:
|
||||
- `task_usage_daily` (migration 073, 082) — 含 runtime_id,物化在 runtime tz
|
||||
- `task_usage_dashboard_daily` (migration 084) — 含 agent_id/project_id,物化在 UTC
|
||||
|
||||
合并后 PK 同时包含 runtime / agent / project 三个维度,可以从同一张表派生出所有现有视图。
|
||||
|
||||
### 4.2 查询模式
|
||||
|
||||
Token 类报表查询从 `task_usage_hourly` 派生,按调用方传入的 `@tz` 在查询时折算日界。**成本不在 SQL 里算**——查询只 `SUM` token 列并保留 `model` 维度,成本由客户端按 per-model 定价表折算(所以按日期分组的查询会保留 `model`,按 agent 分组的也是)。
|
||||
|
||||
```sql
|
||||
-- Workspace dashboard 趋势图 ListDashboardUsageDaily(按 viewer tz 切日,保留 model)
|
||||
SELECT DATE(bucket_hour AT TIME ZONE @tz::text) AS date,
|
||||
model,
|
||||
SUM(input_tokens)::bigint AS input_tokens,
|
||||
SUM(output_tokens)::bigint AS output_tokens,
|
||||
SUM(cache_read_tokens)::bigint AS cache_read_tokens,
|
||||
SUM(cache_write_tokens)::bigint AS cache_write_tokens,
|
||||
SUM(task_count)::int AS task_count
|
||||
FROM task_usage_hourly
|
||||
WHERE workspace_id = $1
|
||||
AND bucket_hour >= @since::timestamptz
|
||||
AND (@project_id::uuid IS NULL OR project_id = @project_id)
|
||||
GROUP BY DATE(bucket_hour AT TIME ZONE @tz::text), model
|
||||
ORDER BY DATE(bucket_hour AT TIME ZONE @tz::text) DESC, model;
|
||||
|
||||
-- Runtime detail 趋势图 ListRuntimeUsage(按 viewer tz 切日,tz 来自 user 不是 runtime)
|
||||
SELECT DATE(bucket_hour AT TIME ZONE @tz::text) AS date,
|
||||
provider, model,
|
||||
SUM(input_tokens)::bigint AS input_tokens,
|
||||
...
|
||||
FROM task_usage_hourly
|
||||
WHERE runtime_id = $1
|
||||
AND bucket_hour >= @since::timestamptz
|
||||
GROUP BY DATE(bucket_hour AT TIME ZONE @tz::text), provider, model
|
||||
ORDER BY DATE(bucket_hour AT TIME ZONE @tz::text) DESC, provider, model;
|
||||
|
||||
-- Per-agent 视图 ListDashboardUsageByAgent / ListRuntimeUsageByAgent
|
||||
-- 不按日期分组 → 不需要 @tz,只用 @since 截断(@since 已是 viewer tz 折算后的 UTC 瞬间)。
|
||||
SELECT agent_id, model,
|
||||
SUM(input_tokens)::bigint AS input_tokens,
|
||||
...
|
||||
FROM task_usage_hourly
|
||||
WHERE workspace_id = $1
|
||||
AND bucket_hour >= @since::timestamptz
|
||||
GROUP BY agent_id, model
|
||||
ORDER BY agent_id, model;
|
||||
```
|
||||
|
||||
**两类查询不走 `task_usage_hourly`**:
|
||||
|
||||
- **Time / Tasks 指标**(dashboard 的"时长 / 任务数"标签页)由独立查询 `ListDashboardRunTimeDaily` / `ListDashboardAgentRunTime` 直接打 `agent_task_queue`,按 `completed_at AT TIME ZONE @tz` 切日——任务时长来自队列的 `started_at`/`completed_at`,不是 token rollup 能表达的。它们同样吃 `@tz`,保证 Tokens/Cost/Time/Tasks 四个标签页的日界一致。
|
||||
- **Runtime hour-of-day Heatmap**(`GetRuntimeUsageByHour` / `GetRuntimeTaskActivity`)仍直接扫原始 `task_usage` / `agent_task_queue`,按 **viewer tz**(`resolveViewingTZ` 解析出的 `@tz`)做 `EXTRACT(HOUR FROM ... AT TIME ZONE @tz)`。Heatmap 窗口小(单 runtime、近 30/90d),raw 扫描足够快,没有必要从 hourly 表派生。
|
||||
|
||||
### 4.3 性能预估
|
||||
|
||||
单 workspace 90d 窗口的 `task_usage_hourly` 行数:
|
||||
|
||||
| 工况 | 行数估算 | 趋势图查询代价 |
|
||||
|---|---|---|
|
||||
| 小(5 agent × 2 model × 2 active hour × 90d) | ~1.8k | <5ms |
|
||||
| 中(5 agent × 2 model × 8 active hour × 90d) | ~7.2k | <10ms |
|
||||
| 大(5 agent × 2 model × 16 active hour × 90d) | ~14.4k | ~15ms |
|
||||
| 巨大(20 agent × 5 model × 16 active hour × 90d) | ~144k | ~50ms |
|
||||
|
||||
和现有 daily rollup 在同一档。Leaderboard / per-agent / per-project 视图同样指标。
|
||||
|
||||
### 4.4 Rollup worker 改造
|
||||
|
||||
现有两张 rollup 表的写入逻辑合并成一条管线,实现见 migration `102_task_usage_hourly_pipeline`(触发器 + 窗口函数 + 失效队列 TTL + pg_cron 调度):
|
||||
|
||||
- 源数据扫描不变(仍然扫 `task_usage` 增量 + 失效队列)。`bucket_hour` 用 `task_usage_hour_bucket(tu.created_at)`(UTC 整点截断)。
|
||||
- Upsert 目标从两张 daily 表改为一张 `task_usage_hourly`。
|
||||
- 失效队列维度由 `(bucket_date, …)` 改为 `(bucket_hour, …)`(`task_usage_hourly_dirty`),由 `task_usage` / `agent_task_queue` / `issue` 上的触发器写入。**必须配 TTL(保留 7 天)**,否则脏行在密集工况下无界增长——这是整个设计最容易漏的正确性要求(hourly 粒度把脏面比 daily 放大了 ~24×)。
|
||||
- 调度入口 `rollup_task_usage_hourly()` 由 pg_cron 周期触发:取 advisory lock → 从 `task_usage_hourly_rollup_state` 读 watermark → 调 `rollup_task_usage_hourly_window(from, to)` 重算脏 bucket → 推进 watermark → 释放锁后跑 `prune_task_usage_hourly_dirty()`。单 tick 窗口上限 1 天,watermark 落后时分多次 tick 追平,不会一条语句锁表重算多周。
|
||||
|
||||
源表扫描是 worker 的主要开销,目标表换粒度只让单 tick 多几十 ms upsert,不会成倍增长。
|
||||
|
||||
### 4.5 Migration 082 的副作用消除
|
||||
|
||||
当前 `runtime.timezone` 的 PATCH 处理(migration 082 + 现有 handler)会触发该 runtime 的整张 `task_usage_daily` 重新物化——因为 `bucket_date` 含了 tz。
|
||||
|
||||
新方案下 `bucket_hour` 永远是 UTC,**`runtime.timezone` 改变不再触发任何数据层操作**。改 tz 立即生效,零 backfill。这同时修掉了:
|
||||
|
||||
- 改 tz 期间的 race condition(旧 bucket 还没重灌完,新查询已经按新 tz 渲染)。
|
||||
- daemon 第一次注册时探测到非 UTC 的 tz 但历史 rollup 还是 UTC 的尴尬过渡期。
|
||||
|
||||
---
|
||||
|
||||
## 5. UI / UX 影响
|
||||
|
||||
### 5.1 Runtime Detail 页
|
||||
|
||||
| 组件 | 重构前 tz 来源 | 重构后 tz 来源 |
|
||||
|---|---|---|
|
||||
| Daily / Weekly 趋势图 | `runtime.timezone` | `user.timezone ?? browserTimezone()` |
|
||||
| KPI 卡片 | `runtime.timezone`(隐式) | `user.timezone ?? browserTimezone()` |
|
||||
| 日历活跃热力图 | `runtime.timezone` 锚点 + viewer-tz 数据(不一致 bug) | `user.timezone ?? browserTimezone()`(锚点与数据统一) |
|
||||
| Hour-of-day Heatmap | `runtime.timezone` | `user.timezone ?? browserTimezone()` |
|
||||
| Timezone editor | 写 `runtime.timezone` | **删除** |
|
||||
|
||||
**用户可感知的行为变化**:
|
||||
|
||||
- Runtime Detail 页所有图表统一跟随 viewer 自己的 tz;页面上不再有任何 runtime 级 tz 控件。
|
||||
- 想换报表 tz 的用户去 Settings → Preferences 改一次,所有 workspace / runtime 的报表立刻全跟着变。
|
||||
- 跨 region 团队:hour-of-day heatmap 按查看者 tz 显示活跃时段(已知且接受的取舍,见 §2.1)。
|
||||
|
||||
### 5.2 Workspace Usage 页
|
||||
|
||||
恢复"按 viewing tz 渲染"的能力,但**不放页面级 picker**。理由:
|
||||
|
||||
- Picker 当年被加上去就是因为没有持久化的 viewing tz 概念。现在有了 `user.timezone`,picker 的诉求被 Preferences 替代。
|
||||
- 页面级 picker 容易让用户误以为"这是一个 view-state",但 viewing tz 是全应用属性,不是单页设置。
|
||||
- 减少 UI 控件 = 减少认知负担。
|
||||
|
||||
`packages/views/dashboard/components/dashboard-page.tsx` 里的 `WEEK_TZ = "UTC"` 改成 `useViewingTimezone()`(hook 见 `packages/views/common/use-viewing-timezone.ts`),相应的解释性注释删除。
|
||||
|
||||
### 5.3 Preferences 页
|
||||
|
||||
新增一个 Timezone setting,和现有的语言 / 主题等并列。
|
||||
|
||||
---
|
||||
|
||||
## 6. 实施
|
||||
|
||||
> 产品尚未上线,无存量用户需保护,全部变更作为一组迁移一次性交付——旧的 daily 管线在同一分支里直接拆除,不保留共存期。
|
||||
|
||||
整套变更落在分支 `feat/timezone-architecture`,migration 100–104:
|
||||
|
||||
| Migration | 内容 |
|
||||
|---|---|
|
||||
| `100_user_timezone` | 加 `"user".timezone` 列(nullable) |
|
||||
| `101_task_usage_hourly_schema` | 建 `task_usage_hourly` + `task_usage_hourly_rollup_state` + `task_usage_hourly_dirty` + 索引 |
|
||||
| `102_task_usage_hourly_pipeline` | 失效触发器、`rollup_task_usage_hourly_window` 窗口函数、`prune_task_usage_hourly_dirty()` 失效队列 TTL、带单日 cap 与 prune 的 `rollup_task_usage_hourly()` cron 入口、pg_cron 调度 |
|
||||
| `103_drop_legacy_daily_rollups` | 拆掉 `task_usage_daily` / `task_usage_dashboard_daily` 两条旧管线(表、函数、触发器、pg_cron 任务) |
|
||||
| `104_drop_runtime_timezone` | 删除 `agent_runtime.timezone` 列(Operational 层移除,见 §2.1) |
|
||||
|
||||
配套的代码侧改动:
|
||||
|
||||
- **数据回填**:一次性命令 `cmd/backfill_task_usage_hourly`,按 workspace 切片把历史 `task_usage` 灌进新表。旧的 `cmd/backfill_task_usage_daily` / `cmd/backfill_task_usage_dashboard_daily` 已删除。
|
||||
- **查询切换**:后端所有报表查询迁到 `task_usage_hourly`(或 Time/Tasks 的 `agent_task_queue` 查询),统一接受 `@tz`;`UseDailyRollupForDashboard` / `UseDailyRollupForRuntimeUsage` 等 feature flag 与旧的 raw-scan / daily-rollup 双查询路径一并删除。
|
||||
- **前端打通**:`useViewingTimezone()` hook 解析 viewer tz,报表组件随请求带 `?tz=`;`dashboard-page.tsx` 的 `WEEK_TZ = "UTC"` 改为 `useViewingTimezone()`,原 UTC-lock 解释性注释删除。
|
||||
- **UI 文案**:Preferences 新增 Timezone setting。Runtime Detail 页的 timezone editor 整体删除。
|
||||
- **runtime tz 移除**:`PATCH /api/runtimes/:id` 的 `timezone` 字段删除,该端点只剩 `visibility`;daemon 注册不再上报 host tz;`agent_runtime.timezone` 列由 migration 104 删除。
|
||||
|
||||
---
|
||||
|
||||
## 7. Open questions / Risks
|
||||
|
||||
### 7.1 Risks
|
||||
|
||||
- **Invalidation queue TTL 是必做**。如果忘记加,密集工况下 queue 会无界增长。
|
||||
- **Hourly rollup backfill 期间的源表 read pressure**。按 workspace 切片、低峰期跑,预期 OK,但需要提前给 DB 团队打招呼。
|
||||
- **DST 当天的 23h/25h "日"**。`DATE(bucket_hour AT TIME ZONE @tz)` 会正确处理,但前端任何"一天 = 24 小时"的硬编码偏移逻辑要测一遍 DST 边界。
|
||||
- **现有 `runtime.timezone` 的 PATCH endpoint 行为变了**。改完不再触发 backfill——这是好事,但 API 文档和 changelog 要写清楚,避免下游集成误判。
|
||||
|
||||
### 7.2 Open question
|
||||
|
||||
- **Trigger 的 timezone 默认值**?目前用户必须手动选;可以默认 `user.timezone`,但用户写 trigger 时的 viewing tz 和 trigger 实际跑的 tz 是两件事,需要产品决策。
|
||||
|
||||
### 7.3 非目标
|
||||
|
||||
- **不做** workspace 级 tz 设置:跨 region 团队两个成员各自正确的"今天"不同,workspace 级 tz 必让其中一方看错位报表。
|
||||
- **不做** 预物化多 tz rollup:IANA tz 列表有 ~600 个无法穷举、DST 需逐 tz 维护,而 hourly rollup 已经够快。
|
||||
- **不做** issue / comment / inbox 等列表的 tz 切换——它们已经隐式用浏览器 tz,本 RFC 不动。后续如果要让这些也跟 `user.timezone`,是独立的 follow-up。
|
||||
|
||||
---
|
||||
|
||||
## 8. 决策汇总
|
||||
|
||||
| 决策点 | 选择 |
|
||||
|---|---|
|
||||
| Timezone 概念分层 | Scheduling / Viewing 两层(Operational 经盘查后移除) |
|
||||
| `runtime.timezone` 角色 | ❌ 整列删除(migration 104) |
|
||||
| `user.timezone` 是否新增 | ✅ 新增,nullable,默认 fallback 到 browser |
|
||||
| `workspace.timezone` 是否新增 | ❌ 不引入 |
|
||||
| 数据层物化口径 | 统一 UTC, hourly grain |
|
||||
| Rollup 表合并 | `task_usage_daily` + `task_usage_dashboard_daily` → `task_usage_hourly` |
|
||||
| 报表 tz 切换粒度 | 全局 per-user(Preferences),不做 per-view picker |
|
||||
| hour-of-day heatmap tz | viewer tz(不再用机器物理 tz) |
|
||||
@@ -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` });
|
||||
});
|
||||
@@ -50,6 +50,7 @@ function makeRuntime(overrides: Partial<AgentRuntime> = {}): AgentRuntime {
|
||||
metadata: {},
|
||||
owner_id: null,
|
||||
visibility: "private",
|
||||
timezone: "UTC",
|
||||
last_seen_at: "2026-04-27T11:59:50Z",
|
||||
created_at: "2026-04-01T00:00:00Z",
|
||||
updated_at: "2026-04-01T00:00:00Z",
|
||||
|
||||
@@ -3,7 +3,3 @@ export {
|
||||
type AgentsScope,
|
||||
type AgentsViewState,
|
||||
} from "./view-store";
|
||||
export {
|
||||
useTranscriptViewStore,
|
||||
type TranscriptSortDirection,
|
||||
} from "./transcript-view-store";
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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 }),
|
||||
},
|
||||
),
|
||||
);
|
||||
@@ -48,11 +48,10 @@ describe("ApiClient", () => {
|
||||
await client.getAutopilot("ap-1");
|
||||
await client.createAutopilot({
|
||||
title: "Daily triage",
|
||||
project_id: "project-1",
|
||||
assignee_id: "agent-1",
|
||||
execution_mode: "create_issue",
|
||||
});
|
||||
await client.updateAutopilot("ap-1", { status: "paused", project_id: null });
|
||||
await client.updateAutopilot("ap-1", { status: "paused" });
|
||||
await client.deleteAutopilot("ap-1");
|
||||
await client.triggerAutopilot("ap-1");
|
||||
await client.listAutopilotRuns("ap-1", { limit: 10, offset: 20 });
|
||||
@@ -79,7 +78,6 @@ describe("ApiClient", () => {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
title: "Daily triage",
|
||||
project_id: "project-1",
|
||||
assignee_id: "agent-1",
|
||||
execution_mode: "create_issue",
|
||||
}),
|
||||
@@ -87,7 +85,7 @@ describe("ApiClient", () => {
|
||||
{
|
||||
url: "https://api.example.test/api/autopilots/ap-1",
|
||||
method: "PATCH",
|
||||
body: JSON.stringify({ status: "paused", project_id: null }),
|
||||
body: JSON.stringify({ status: "paused" }),
|
||||
},
|
||||
{ url: "https://api.example.test/api/autopilots/ap-1", method: "DELETE" },
|
||||
{ url: "https://api.example.test/api/autopilots/ap-1/trigger", method: "POST" },
|
||||
@@ -152,91 +150,6 @@ describe("ApiClient", () => {
|
||||
expect(headers["X-Client-OS"]).toBeUndefined();
|
||||
});
|
||||
|
||||
it("uses the Cloud Runtime node API contract and forwards bootstrap PAT on create", async () => {
|
||||
const node = {
|
||||
id: "node-1",
|
||||
owner_id: "user-1",
|
||||
instance_id: "i-0123456789abcdef0",
|
||||
region: "us-west-2",
|
||||
instance_type: "g5.xlarge",
|
||||
image_id: "ami-1",
|
||||
subnet_id: "subnet-1",
|
||||
name: "gpu-dev-01",
|
||||
status: "launching",
|
||||
tags: {},
|
||||
metadata: {},
|
||||
created_at: "2026-05-21T08:30:00Z",
|
||||
updated_at: "2026-05-21T08:30:00Z",
|
||||
};
|
||||
const fetchMock = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(
|
||||
new Response(JSON.stringify([]), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}),
|
||||
)
|
||||
.mockResolvedValueOnce(
|
||||
new Response(JSON.stringify(node), {
|
||||
status: 201,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}),
|
||||
);
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const client = new ApiClient("https://api.example.test");
|
||||
await client.listCloudRuntimeNodes({ limit: 20, offset: 5 });
|
||||
await client.createCloudRuntimeNode(
|
||||
{ instance_type: "g5.xlarge", name: "gpu-dev-01" },
|
||||
{ userPAT: "mul_cloud_bootstrap_pat" },
|
||||
);
|
||||
|
||||
const listCall = fetchMock.mock.calls[0]!;
|
||||
const createCall = fetchMock.mock.calls[1]!;
|
||||
expect(listCall[0]).toBe(
|
||||
"https://api.example.test/api/cloud-runtime/nodes?limit=20&offset=5",
|
||||
);
|
||||
expect((listCall[1]!.headers as Record<string, string>)["X-User-PAT"]).toBeUndefined();
|
||||
expect(createCall[0]).toBe(
|
||||
"https://api.example.test/api/cloud-runtime/nodes",
|
||||
);
|
||||
expect(createCall[1]).toMatchObject({
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
instance_type: "g5.xlarge",
|
||||
name: "gpu-dev-01",
|
||||
}),
|
||||
});
|
||||
expect((createCall[1]!.headers as Record<string, string>)["X-User-PAT"]).toBe(
|
||||
"mul_cloud_bootstrap_pat",
|
||||
);
|
||||
});
|
||||
|
||||
it("falls back when Cloud Runtime node responses drift", async () => {
|
||||
const fetchMock = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(
|
||||
new Response(JSON.stringify([{ id: 123 }]), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}),
|
||||
)
|
||||
.mockResolvedValueOnce(
|
||||
new Response(JSON.stringify({ id: 123 }), {
|
||||
status: 201,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}),
|
||||
);
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const client = new ApiClient("https://api.example.test");
|
||||
|
||||
await expect(client.listCloudRuntimeNodes()).resolves.toEqual([]);
|
||||
await expect(
|
||||
client.createCloudRuntimeNode({ instance_type: "g5.xlarge" }),
|
||||
).resolves.toMatchObject({ id: "", status: "" });
|
||||
});
|
||||
|
||||
describe("getAttachment", () => {
|
||||
it("returns the parsed attachment for a well-formed response", async () => {
|
||||
vi.stubGlobal(
|
||||
|
||||
@@ -101,12 +101,6 @@ import type {
|
||||
SquadMemberStatusListResponse,
|
||||
} from "../types";
|
||||
import type { OnboardingCompletionPath } from "../onboarding/types";
|
||||
import type {
|
||||
CloudRuntimeNode,
|
||||
CreateCloudRuntimeNodeOptions,
|
||||
CreateCloudRuntimeNodeRequest,
|
||||
ListCloudRuntimeNodesParams,
|
||||
} from "../runtimes/cloud-runtime";
|
||||
import { type Logger, noopLogger } from "../logger";
|
||||
import { createRequestId } from "../utils";
|
||||
import { getCurrentSlug } from "../platform/workspace-storage";
|
||||
@@ -117,8 +111,6 @@ import {
|
||||
AttachmentResponseSchema,
|
||||
ChildIssuesResponseSchema,
|
||||
CommentsListSchema,
|
||||
CloudRuntimeNodeListSchema,
|
||||
CloudRuntimeNodeSchema,
|
||||
CreateAgentFromTemplateResponseSchema,
|
||||
DashboardAgentRunTimeListSchema,
|
||||
DashboardRunTimeDailyListSchema,
|
||||
@@ -127,29 +119,19 @@ import {
|
||||
EMPTY_AGENT_TEMPLATE_DETAIL,
|
||||
EMPTY_AGENT_TEMPLATE_SUMMARY_LIST,
|
||||
EMPTY_ATTACHMENT,
|
||||
EMPTY_CLOUD_RUNTIME_NODE,
|
||||
EMPTY_CLOUD_RUNTIME_NODE_LIST,
|
||||
EMPTY_CREATE_AGENT_FROM_TEMPLATE_RESPONSE,
|
||||
EMPTY_GROUPED_ISSUES_RESPONSE,
|
||||
EMPTY_LIST_ISSUES_RESPONSE,
|
||||
EMPTY_SQUAD_MEMBER_STATUS_LIST,
|
||||
EMPTY_TIMELINE_ENTRIES,
|
||||
EMPTY_USER,
|
||||
EMPTY_LIST_WEBHOOK_DELIVERIES_RESPONSE,
|
||||
EMPTY_WEBHOOK_DELIVERY,
|
||||
GroupedIssuesResponseSchema,
|
||||
ListIssuesResponseSchema,
|
||||
ListWebhookDeliveriesResponseSchema,
|
||||
OnboardingNoRuntimeBootstrapResponseSchema,
|
||||
OnboardingRuntimeBootstrapResponseSchema,
|
||||
RuntimeHourlyActivityListSchema,
|
||||
RuntimeUsageByAgentListSchema,
|
||||
RuntimeUsageByHourListSchema,
|
||||
RuntimeUsageListSchema,
|
||||
SquadMemberStatusListResponseSchema,
|
||||
SubscribersListSchema,
|
||||
TimelineEntriesSchema,
|
||||
UserSchema,
|
||||
WebhookDeliveryResponseSchema,
|
||||
} from "./schemas";
|
||||
|
||||
@@ -178,29 +160,51 @@ export interface LoginResponse {
|
||||
user: User;
|
||||
}
|
||||
|
||||
export interface OnboardingRuntimeBootstrapResponse {
|
||||
workspace_id: string;
|
||||
agent_id: string;
|
||||
issue_id: string;
|
||||
// --- Starter content (post-onboarding import) -----------------------------
|
||||
// Shape mirrors the Go request/response in handler/onboarding.go.
|
||||
//
|
||||
// The client sends both branches of sub-issues and an unbound welcome
|
||||
// issue template (title + description, no `agent_id`). The SERVER picks
|
||||
// the branch by inspecting the workspace's agent list inside the
|
||||
// import transaction. This removes the client as a trusted decider —
|
||||
// even if the client has a stale agent cache or lies, the server uses
|
||||
// the DB as source of truth.
|
||||
|
||||
export interface ImportStarterIssuePayload {
|
||||
title: string;
|
||||
description: string;
|
||||
status: string;
|
||||
priority: string;
|
||||
/** Server uses `user_id` (per app-wide AssigneePicker convention)
|
||||
* as assignee when true. No member_id is threaded through. */
|
||||
assign_to_self: boolean;
|
||||
}
|
||||
|
||||
const EMPTY_ONBOARDING_RUNTIME_BOOTSTRAP_RESPONSE:
|
||||
OnboardingRuntimeBootstrapResponse = {
|
||||
workspace_id: "",
|
||||
agent_id: "",
|
||||
issue_id: "",
|
||||
};
|
||||
|
||||
export interface OnboardingNoRuntimeBootstrapResponse {
|
||||
workspace_id: string;
|
||||
issue_id: string;
|
||||
export interface ImportStarterWelcomeIssueTemplate {
|
||||
title: string;
|
||||
description: string;
|
||||
/** Defaults to "high" on server when empty. */
|
||||
priority: string;
|
||||
}
|
||||
|
||||
const EMPTY_ONBOARDING_NO_RUNTIME_BOOTSTRAP_RESPONSE:
|
||||
OnboardingNoRuntimeBootstrapResponse = {
|
||||
workspace_id: "",
|
||||
issue_id: "",
|
||||
};
|
||||
export interface ImportStarterContentPayload {
|
||||
workspace_id: string;
|
||||
project: { title: string; description: string; icon: string };
|
||||
/** Always sent. Server creates it only when an agent exists in the
|
||||
* workspace; ignored otherwise. Agent id is picked by the server. */
|
||||
welcome_issue_template: ImportStarterWelcomeIssueTemplate;
|
||||
/** Used when the workspace has at least one agent. */
|
||||
agent_guided_sub_issues: ImportStarterIssuePayload[];
|
||||
/** Used when the workspace has zero agents. */
|
||||
self_serve_sub_issues: ImportStarterIssuePayload[];
|
||||
}
|
||||
|
||||
export interface ImportStarterContentResponse {
|
||||
user: User;
|
||||
project_id: string;
|
||||
/** Non-null when server took the agent-guided branch. */
|
||||
welcome_issue_id: string | null;
|
||||
}
|
||||
|
||||
export class ApiError extends Error {
|
||||
readonly status: number;
|
||||
@@ -397,95 +401,71 @@ export class ApiClient {
|
||||
}
|
||||
|
||||
async getMe(): Promise<User> {
|
||||
const raw = await this.fetch<unknown>("/api/me");
|
||||
return parseWithFallback(raw, UserSchema, EMPTY_USER, {
|
||||
endpoint: "GET /api/me",
|
||||
});
|
||||
return this.fetch("/api/me");
|
||||
}
|
||||
|
||||
async markOnboardingComplete(payload?: {
|
||||
completion_path?: OnboardingCompletionPath;
|
||||
workspace_id?: string;
|
||||
}): Promise<User> {
|
||||
const raw = await this.fetch<unknown>("/api/me/onboarding/complete", {
|
||||
return this.fetch("/api/me/onboarding/complete", {
|
||||
method: "POST",
|
||||
body: payload ? JSON.stringify(payload) : undefined,
|
||||
});
|
||||
return parseWithFallback(raw, UserSchema, EMPTY_USER, {
|
||||
endpoint: "POST /api/me/onboarding/complete",
|
||||
});
|
||||
}
|
||||
|
||||
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;
|
||||
}): Promise<User> {
|
||||
const raw = await this.fetch<unknown>("/api/me/onboarding/cloud-waitlist", {
|
||||
return this.fetch("/api/me/onboarding/cloud-waitlist", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
return parseWithFallback(raw, UserSchema, EMPTY_USER, {
|
||||
endpoint: "POST /api/me/onboarding/cloud-waitlist",
|
||||
});
|
||||
}
|
||||
|
||||
async patchOnboarding(payload: {
|
||||
questionnaire?: Record<string, unknown>;
|
||||
}): Promise<User> {
|
||||
const raw = await this.fetch<unknown>("/api/me/onboarding", {
|
||||
return this.fetch("/api/me/onboarding", {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
return parseWithFallback(raw, UserSchema, EMPTY_USER, {
|
||||
endpoint: "PATCH /api/me/onboarding",
|
||||
}
|
||||
|
||||
/**
|
||||
* Imports the Getting Started project + optional welcome issue + sub-issues
|
||||
* in a single server-side transaction. Gated by an atomic
|
||||
* starter_content_state: NULL → 'imported' claim — a second call returns
|
||||
* 409 (already decided) and creates nothing new.
|
||||
*
|
||||
* The content templates live in TypeScript (see
|
||||
* @multica/views/onboarding/utils/starter-content-templates) and are
|
||||
* rendered from the user's questionnaire answers before being sent.
|
||||
*/
|
||||
async importStarterContent(
|
||||
payload: ImportStarterContentPayload,
|
||||
): Promise<ImportStarterContentResponse> {
|
||||
return this.fetch("/api/me/starter-content/import", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
async dismissStarterContent(payload?: {
|
||||
workspace_id?: string;
|
||||
}): Promise<User> {
|
||||
return this.fetch("/api/me/starter-content/dismiss", {
|
||||
method: "POST",
|
||||
body: payload ? JSON.stringify(payload) : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
async updateMe(data: UpdateMeRequest): Promise<User> {
|
||||
const raw = await this.fetch<unknown>("/api/me", {
|
||||
return this.fetch("/api/me", {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
return parseWithFallback(raw, UserSchema, EMPTY_USER, {
|
||||
endpoint: "PATCH /api/me",
|
||||
});
|
||||
}
|
||||
|
||||
// Issues
|
||||
@@ -500,9 +480,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, {
|
||||
@@ -522,7 +500,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(","));
|
||||
}
|
||||
@@ -829,54 +806,13 @@ export class ApiClient {
|
||||
return this.fetch(`/api/runtimes?${search}`);
|
||||
}
|
||||
|
||||
async listCloudRuntimeNodes(
|
||||
params?: ListCloudRuntimeNodesParams,
|
||||
): Promise<CloudRuntimeNode[]> {
|
||||
const search = new URLSearchParams();
|
||||
if (params?.limit !== undefined) search.set("limit", String(params.limit));
|
||||
if (params?.offset !== undefined) search.set("offset", String(params.offset));
|
||||
const query = search.toString();
|
||||
const raw = await this.fetch<unknown>(
|
||||
`/api/cloud-runtime/nodes${query ? `?${query}` : ""}`,
|
||||
);
|
||||
return parseWithFallback(
|
||||
raw,
|
||||
CloudRuntimeNodeListSchema,
|
||||
EMPTY_CLOUD_RUNTIME_NODE_LIST,
|
||||
{ endpoint: "GET /api/cloud-runtime/nodes" },
|
||||
);
|
||||
}
|
||||
|
||||
async createCloudRuntimeNode(
|
||||
data: CreateCloudRuntimeNodeRequest,
|
||||
options?: CreateCloudRuntimeNodeOptions,
|
||||
): Promise<CloudRuntimeNode> {
|
||||
const extraHeaders: Record<string, string> = {
|
||||
"Content-Type": "application/json",
|
||||
};
|
||||
const userPAT = options?.userPAT?.trim();
|
||||
if (userPAT) extraHeaders["X-User-PAT"] = userPAT;
|
||||
const res = await this.fetchRaw("/api/cloud-runtime/nodes", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(data),
|
||||
extraHeaders,
|
||||
});
|
||||
const raw = await res.json() as unknown;
|
||||
return parseWithFallback(
|
||||
raw,
|
||||
CloudRuntimeNodeSchema,
|
||||
EMPTY_CLOUD_RUNTIME_NODE,
|
||||
{ endpoint: "POST /api/cloud-runtime/nodes" },
|
||||
);
|
||||
}
|
||||
|
||||
async deleteRuntime(runtimeId: string): Promise<void> {
|
||||
await this.fetch(`/api/runtimes/${runtimeId}`, { method: "DELETE" });
|
||||
}
|
||||
|
||||
async updateRuntime(
|
||||
runtimeId: string,
|
||||
patch: { visibility?: "private" | "public" },
|
||||
patch: { timezone?: string; visibility?: "private" | "public" },
|
||||
): Promise<AgentRuntime> {
|
||||
return this.fetch(`/api/runtimes/${runtimeId}`, {
|
||||
method: "PATCH",
|
||||
@@ -884,77 +820,32 @@ export class ApiClient {
|
||||
});
|
||||
}
|
||||
|
||||
async getRuntimeUsage(
|
||||
runtimeId: string,
|
||||
params?: { days?: number; tz?: string },
|
||||
): Promise<RuntimeUsage[]> {
|
||||
async getRuntimeUsage(runtimeId: string, params?: { days?: number }): Promise<RuntimeUsage[]> {
|
||||
const search = new URLSearchParams();
|
||||
if (params?.days) search.set("days", String(params.days));
|
||||
// `tz` drives the calendar-day boundary for the trend chart (Viewing
|
||||
// layer). Caller-supplied; the backend falls back to user.timezone /
|
||||
// UTC if omitted.
|
||||
if (params?.tz) search.set("tz", params.tz);
|
||||
const raw = await this.fetch<unknown>(
|
||||
`/api/runtimes/${runtimeId}/usage?${search}`,
|
||||
);
|
||||
return parseWithFallback<RuntimeUsage[]>(raw, RuntimeUsageListSchema, [], {
|
||||
endpoint: "GET /api/runtimes/:id/usage",
|
||||
});
|
||||
return this.fetch(`/api/runtimes/${runtimeId}/usage?${search}`);
|
||||
}
|
||||
|
||||
async getRuntimeTaskActivity(
|
||||
runtimeId: string,
|
||||
params?: { tz?: string },
|
||||
): Promise<RuntimeHourlyActivity[]> {
|
||||
// Hour-of-day heatmap follows the viewer's tz, like the other reports on
|
||||
// this page. Pass the viewer's IANA zone so the server buckets correctly.
|
||||
const search = new URLSearchParams();
|
||||
if (params?.tz) search.set("tz", params.tz);
|
||||
const raw = await this.fetch<unknown>(
|
||||
`/api/runtimes/${runtimeId}/activity?${search}`,
|
||||
);
|
||||
return parseWithFallback<RuntimeHourlyActivity[]>(
|
||||
raw,
|
||||
RuntimeHourlyActivityListSchema,
|
||||
[],
|
||||
{ endpoint: "GET /api/runtimes/:id/activity" },
|
||||
);
|
||||
async getRuntimeTaskActivity(runtimeId: string): Promise<RuntimeHourlyActivity[]> {
|
||||
return this.fetch(`/api/runtimes/${runtimeId}/activity`);
|
||||
}
|
||||
|
||||
async getRuntimeUsageByAgent(
|
||||
runtimeId: string,
|
||||
params?: { days?: number; tz?: string },
|
||||
params?: { days?: number },
|
||||
): Promise<RuntimeUsageByAgent[]> {
|
||||
const search = new URLSearchParams();
|
||||
if (params?.days) search.set("days", String(params.days));
|
||||
if (params?.tz) search.set("tz", params.tz);
|
||||
const raw = await this.fetch<unknown>(
|
||||
`/api/runtimes/${runtimeId}/usage/by-agent?${search}`,
|
||||
);
|
||||
return parseWithFallback<RuntimeUsageByAgent[]>(
|
||||
raw,
|
||||
RuntimeUsageByAgentListSchema,
|
||||
[],
|
||||
{ endpoint: "GET /api/runtimes/:id/usage/by-agent" },
|
||||
);
|
||||
return this.fetch(`/api/runtimes/${runtimeId}/usage/by-agent?${search}`);
|
||||
}
|
||||
|
||||
async getRuntimeUsageByHour(
|
||||
runtimeId: string,
|
||||
params?: { days?: number; tz?: string },
|
||||
params?: { days?: number },
|
||||
): Promise<RuntimeUsageByHour[]> {
|
||||
const search = new URLSearchParams();
|
||||
if (params?.days) search.set("days", String(params.days));
|
||||
if (params?.tz) search.set("tz", params.tz);
|
||||
const raw = await this.fetch<unknown>(
|
||||
`/api/runtimes/${runtimeId}/usage/by-hour?${search}`,
|
||||
);
|
||||
return parseWithFallback<RuntimeUsageByHour[]>(
|
||||
raw,
|
||||
RuntimeUsageByHourListSchema,
|
||||
[],
|
||||
{ endpoint: "GET /api/runtimes/:id/usage/by-hour" },
|
||||
);
|
||||
return this.fetch(`/api/runtimes/${runtimeId}/usage/by-hour?${search}`);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -965,12 +856,11 @@ export class ApiClient {
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async getDashboardUsageDaily(
|
||||
params: { days?: number; project_id?: string | null; tz?: string },
|
||||
params: { days?: number; project_id?: string | null },
|
||||
): Promise<DashboardUsageDaily[]> {
|
||||
const search = new URLSearchParams();
|
||||
if (params.days) search.set("days", String(params.days));
|
||||
if (params.project_id) search.set("project_id", params.project_id);
|
||||
if (params.tz) search.set("tz", params.tz);
|
||||
const raw = await this.fetch<unknown>(`/api/dashboard/usage/daily?${search}`);
|
||||
return parseWithFallback<DashboardUsageDaily[]>(
|
||||
raw,
|
||||
@@ -981,12 +871,11 @@ export class ApiClient {
|
||||
}
|
||||
|
||||
async getDashboardUsageByAgent(
|
||||
params: { days?: number; project_id?: string | null; tz?: string },
|
||||
params: { days?: number; project_id?: string | null },
|
||||
): Promise<DashboardUsageByAgent[]> {
|
||||
const search = new URLSearchParams();
|
||||
if (params.days) search.set("days", String(params.days));
|
||||
if (params.project_id) search.set("project_id", params.project_id);
|
||||
if (params.tz) search.set("tz", params.tz);
|
||||
const raw = await this.fetch<unknown>(`/api/dashboard/usage/by-agent?${search}`);
|
||||
return parseWithFallback<DashboardUsageByAgent[]>(
|
||||
raw,
|
||||
@@ -997,14 +886,11 @@ export class ApiClient {
|
||||
}
|
||||
|
||||
async getDashboardAgentRunTime(
|
||||
params: { days?: number; project_id?: string | null; tz?: string },
|
||||
params: { days?: number; project_id?: string | null },
|
||||
): Promise<DashboardAgentRunTime[]> {
|
||||
const search = new URLSearchParams();
|
||||
if (params.days) search.set("days", String(params.days));
|
||||
if (params.project_id) search.set("project_id", params.project_id);
|
||||
// `tz` aligns the "last N days" cutoff with the viewer's calendar,
|
||||
// matching the per-agent token card.
|
||||
if (params.tz) search.set("tz", params.tz);
|
||||
const raw = await this.fetch<unknown>(`/api/dashboard/agent-runtime?${search}`);
|
||||
return parseWithFallback<DashboardAgentRunTime[]>(
|
||||
raw,
|
||||
@@ -1015,14 +901,11 @@ export class ApiClient {
|
||||
}
|
||||
|
||||
async getDashboardRunTimeDaily(
|
||||
params: { days?: number; project_id?: string | null; tz?: string },
|
||||
params: { days?: number; project_id?: string | null },
|
||||
): Promise<DashboardRunTimeDaily[]> {
|
||||
const search = new URLSearchParams();
|
||||
if (params.days) search.set("days", String(params.days));
|
||||
if (params.project_id) search.set("project_id", params.project_id);
|
||||
// `tz` cuts the day buckets in the viewer's calendar so Time / Tasks
|
||||
// align with the Cost / Tokens charts.
|
||||
if (params.tz) search.set("tz", params.tz);
|
||||
const raw = await this.fetch<unknown>(`/api/dashboard/runtime/daily?${search}`);
|
||||
return parseWithFallback<DashboardRunTimeDaily[]>(
|
||||
raw,
|
||||
@@ -1140,10 +1023,9 @@ export class ApiClient {
|
||||
});
|
||||
}
|
||||
|
||||
async rerunIssue(issueId: string, taskId?: string): Promise<AgentTask> {
|
||||
async rerunIssue(issueId: string): Promise<AgentTask> {
|
||||
return this.fetch(`/api/issues/${issueId}/rerun`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(taskId ? { task_id: taskId } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1220,7 +1102,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),
|
||||
|
||||
@@ -4,7 +4,13 @@ export {
|
||||
PreviewTooLargeError,
|
||||
PreviewUnsupportedError,
|
||||
} from "./client";
|
||||
export type { ApiClientOptions } from "./client";
|
||||
export type {
|
||||
ApiClientOptions,
|
||||
ImportStarterContentPayload,
|
||||
ImportStarterContentResponse,
|
||||
ImportStarterIssuePayload,
|
||||
ImportStarterWelcomeIssueTemplate,
|
||||
} from "./client";
|
||||
export { parseWithFallback, setSchemaLogger } from "./schema";
|
||||
export type { ParseOptions } from "./schema";
|
||||
export { DuplicateIssueErrorBodySchema } from "./schemas";
|
||||
|
||||
@@ -1,17 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
DashboardAgentRunTimeListSchema,
|
||||
DashboardUsageByAgentListSchema,
|
||||
DashboardUsageDailyListSchema,
|
||||
DuplicateIssueErrorBodySchema,
|
||||
EMPTY_USER,
|
||||
RuntimeHourlyActivityListSchema,
|
||||
RuntimeUsageByAgentListSchema,
|
||||
RuntimeUsageByHourListSchema,
|
||||
RuntimeUsageListSchema,
|
||||
UserSchema,
|
||||
} from "./schemas";
|
||||
import { parseWithFallback } from "./schema";
|
||||
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
|
||||
@@ -61,106 +49,3 @@ describe("DuplicateIssueErrorBodySchema", () => {
|
||||
expect(DuplicateIssueErrorBodySchema.safeParse(without).success).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// `user.timezone` (Viewing tz) was added in the timezone-architecture RFC.
|
||||
// A desktop build older than the server — or a server predating the
|
||||
// `user.timezone` migration — will return a `/api/me` body with no
|
||||
// `timezone` key. The schema must not fail closed on that: the field
|
||||
// defaults to `null`, which the frontend resolves to the browser-detected
|
||||
// tz at render time.
|
||||
describe("UserSchema timezone drift", () => {
|
||||
const base = {
|
||||
id: "11111111-1111-1111-1111-111111111111",
|
||||
name: "Ada",
|
||||
email: "ada@example.com",
|
||||
};
|
||||
|
||||
it("defaults timezone to null when the field is absent", () => {
|
||||
const parsed = UserSchema.parse(base);
|
||||
expect(parsed.timezone).toBe(null);
|
||||
});
|
||||
|
||||
it("preserves an explicit IANA timezone", () => {
|
||||
const parsed = UserSchema.parse({ ...base, timezone: "Asia/Tokyo" });
|
||||
expect(parsed.timezone).toBe("Asia/Tokyo");
|
||||
});
|
||||
|
||||
it("accepts an explicit null timezone", () => {
|
||||
const parsed = UserSchema.parse({ ...base, timezone: null });
|
||||
expect(parsed.timezone).toBe(null);
|
||||
});
|
||||
|
||||
// Wrong-type drift: a future server bug sending `timezone` as a number
|
||||
// must not throw into the UI. parseWithFallback degrades the whole user
|
||||
// object to the explicit fallback (EMPTY_USER) so /api/me callers keep a
|
||||
// valid shape instead of white-screening.
|
||||
it("falls back to EMPTY_USER when timezone is the wrong type", () => {
|
||||
const parsed = parseWithFallback(
|
||||
{ ...base, timezone: 42 },
|
||||
UserSchema,
|
||||
EMPTY_USER,
|
||||
{ endpoint: "GET /api/me" },
|
||||
);
|
||||
expect(parsed).toBe(EMPTY_USER);
|
||||
});
|
||||
});
|
||||
|
||||
// The workspace dashboard and runtime-detail pages were re-pointed at the
|
||||
// unified `task_usage_hourly` rollup. Every numeric field drives chart /
|
||||
// KPI math, and string keys (date / agent_id / model) bucket the series.
|
||||
// The contract these schemas must hold: a row missing a field degrades
|
||||
// that field to a sane default rather than dropping the WHOLE array to
|
||||
// the `[]` fallback — one drifted row must not blank the entire chart.
|
||||
describe("dashboard + runtime usage schema drift", () => {
|
||||
it("coerces a missing numeric field to 0 instead of dropping the array", () => {
|
||||
const parsed = DashboardUsageDailyListSchema.parse([
|
||||
{ date: "2026-05-19", model: "claude-opus-4-7", input_tokens: 100 },
|
||||
]);
|
||||
expect(parsed).toHaveLength(1);
|
||||
expect(parsed[0]?.output_tokens).toBe(0);
|
||||
expect(parsed[0]?.cache_read_tokens).toBe(0);
|
||||
expect(parsed[0]?.cache_write_tokens).toBe(0);
|
||||
});
|
||||
|
||||
it("coerces a missing date key to \"\" so the rest of the series survives", () => {
|
||||
const parsed = DashboardUsageDailyListSchema.parse([
|
||||
{ model: "claude-opus-4-7", input_tokens: 5 },
|
||||
]);
|
||||
expect(parsed).toHaveLength(1);
|
||||
expect(parsed[0]?.date).toBe("");
|
||||
});
|
||||
|
||||
it("coerces a missing agent_id key to \"\" for the agent-runtime panel", () => {
|
||||
const parsed = DashboardAgentRunTimeListSchema.parse([
|
||||
{ total_seconds: 42, task_count: 3, failed_count: 0 },
|
||||
]);
|
||||
expect(parsed).toHaveLength(1);
|
||||
expect(parsed[0]?.agent_id).toBe("");
|
||||
});
|
||||
|
||||
it("coerces a missing agent_id key to \"\" for the usage-by-agent panel", () => {
|
||||
const parsed = DashboardUsageByAgentListSchema.parse([
|
||||
{ model: "claude-opus-4-7", input_tokens: 7 },
|
||||
]);
|
||||
expect(parsed[0]?.agent_id).toBe("");
|
||||
});
|
||||
|
||||
it("coerces missing fields on every runtime usage schema", () => {
|
||||
expect(RuntimeUsageListSchema.parse([{ date: "2026-05-19" }])[0]?.input_tokens).toBe(0);
|
||||
expect(RuntimeHourlyActivityListSchema.parse([{ hour: 9 }])[0]?.count).toBe(0);
|
||||
expect(RuntimeUsageByAgentListSchema.parse([{ model: "x" }])[0]?.agent_id).toBe("");
|
||||
expect(RuntimeUsageByHourListSchema.parse([{ hour: 9 }])[0]?.model).toBe("");
|
||||
});
|
||||
|
||||
it("rejects a non-array body so parseWithFallback can return its fallback", () => {
|
||||
expect(DashboardUsageDailyListSchema.safeParse(null).success).toBe(false);
|
||||
expect(RuntimeUsageListSchema.safeParse({ rows: [] }).success).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps unknown server-side fields via .loose()", () => {
|
||||
const parsed = RuntimeUsageListSchema.parse([
|
||||
{ date: "2026-05-19", region: "us-east" },
|
||||
]);
|
||||
expect((parsed[0] as Record<string, unknown>).region).toBe("us-east");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -9,10 +9,8 @@ import type {
|
||||
ListIssuesResponse,
|
||||
ListWebhookDeliveriesResponse,
|
||||
TimelineEntry,
|
||||
User,
|
||||
WebhookDelivery,
|
||||
} from "../types";
|
||||
import type { CloudRuntimeNode } from "../runtimes/cloud-runtime";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Schemas for the highest-risk API endpoints — those whose responses drive
|
||||
@@ -200,67 +198,19 @@ 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();
|
||||
|
||||
export const CloudRuntimeNodeSchema = z.object({
|
||||
id: z.string(),
|
||||
owner_id: z.string(),
|
||||
instance_id: z.string(),
|
||||
region: z.string(),
|
||||
instance_type: z.string(),
|
||||
image_id: z.string(),
|
||||
subnet_id: z.string(),
|
||||
name: z.string(),
|
||||
status: z.string(),
|
||||
tags: z.record(z.string(), z.string()).default({}),
|
||||
metadata: z.record(z.string(), z.unknown()).default({}),
|
||||
created_at: z.string(),
|
||||
updated_at: z.string(),
|
||||
}).loose();
|
||||
|
||||
export const CloudRuntimeNodeListSchema = z.array(CloudRuntimeNodeSchema);
|
||||
|
||||
export const EMPTY_CLOUD_RUNTIME_NODE_LIST: CloudRuntimeNode[] = [];
|
||||
|
||||
export const EMPTY_CLOUD_RUNTIME_NODE: CloudRuntimeNode = {
|
||||
id: "",
|
||||
owner_id: "",
|
||||
instance_id: "",
|
||||
region: "",
|
||||
instance_type: "",
|
||||
image_id: "",
|
||||
subnet_id: "",
|
||||
name: "",
|
||||
status: "",
|
||||
tags: {},
|
||||
metadata: {},
|
||||
created_at: "",
|
||||
updated_at: "",
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Workspace dashboard schemas
|
||||
//
|
||||
// The dashboard hits three independent rollup endpoints. Each returns a flat
|
||||
// array, and every field is consumed by chart / KPI math — a missing number
|
||||
// silently degrades to NaN downstream, so we coerce missing numbers to 0.
|
||||
// String fields default to "" (no enum narrowing) to survive future model /
|
||||
// agent ID drift, and so a single null from tz-aware SQL bucketing fails
|
||||
// only that row instead of dropping the whole array to the `[]` fallback.
|
||||
// String fields stay lenient (no enum narrowing) to survive future model /
|
||||
// agent ID drift.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const DashboardUsageDailySchema = z.object({
|
||||
date: z.string().default(""),
|
||||
model: z.string().default(""),
|
||||
date: z.string(),
|
||||
model: z.string(),
|
||||
input_tokens: z.number().default(0),
|
||||
output_tokens: z.number().default(0),
|
||||
cache_read_tokens: z.number().default(0),
|
||||
@@ -271,8 +221,8 @@ const DashboardUsageDailySchema = z.object({
|
||||
export const DashboardUsageDailyListSchema = z.array(DashboardUsageDailySchema);
|
||||
|
||||
const DashboardUsageByAgentSchema = z.object({
|
||||
agent_id: z.string().default(""),
|
||||
model: z.string().default(""),
|
||||
agent_id: z.string(),
|
||||
model: z.string(),
|
||||
input_tokens: z.number().default(0),
|
||||
output_tokens: z.number().default(0),
|
||||
cache_read_tokens: z.number().default(0),
|
||||
@@ -283,7 +233,7 @@ const DashboardUsageByAgentSchema = z.object({
|
||||
export const DashboardUsageByAgentListSchema = z.array(DashboardUsageByAgentSchema);
|
||||
|
||||
const DashboardAgentRunTimeSchema = z.object({
|
||||
agent_id: z.string().default(""),
|
||||
agent_id: z.string(),
|
||||
total_seconds: z.number().default(0),
|
||||
task_count: z.number().default(0),
|
||||
failed_count: z.number().default(0),
|
||||
@@ -292,7 +242,7 @@ const DashboardAgentRunTimeSchema = z.object({
|
||||
export const DashboardAgentRunTimeListSchema = z.array(DashboardAgentRunTimeSchema);
|
||||
|
||||
const DashboardRunTimeDailySchema = z.object({
|
||||
date: z.string().default(""),
|
||||
date: z.string(),
|
||||
total_seconds: z.number().default(0),
|
||||
task_count: z.number().default(0),
|
||||
failed_count: z.number().default(0),
|
||||
@@ -300,57 +250,6 @@ const DashboardRunTimeDailySchema = z.object({
|
||||
|
||||
export const DashboardRunTimeDailyListSchema = z.array(DashboardRunTimeDailySchema);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Runtime usage schemas — the runtime-detail page's four usage endpoints
|
||||
// (`/api/runtimes/:id/usage*`). Same leniency rules as the dashboard
|
||||
// schemas above: numbers default to 0, strings to "", `.loose()` passes
|
||||
// unknown fields.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const RuntimeUsageSchema = z.object({
|
||||
runtime_id: z.string().default(""),
|
||||
date: z.string().default(""),
|
||||
provider: z.string().default(""),
|
||||
model: z.string().default(""),
|
||||
input_tokens: z.number().default(0),
|
||||
output_tokens: z.number().default(0),
|
||||
cache_read_tokens: z.number().default(0),
|
||||
cache_write_tokens: z.number().default(0),
|
||||
}).loose();
|
||||
|
||||
export const RuntimeUsageListSchema = z.array(RuntimeUsageSchema);
|
||||
|
||||
const RuntimeHourlyActivitySchema = z.object({
|
||||
hour: z.number().default(0),
|
||||
count: z.number().default(0),
|
||||
}).loose();
|
||||
|
||||
export const RuntimeHourlyActivityListSchema = z.array(RuntimeHourlyActivitySchema);
|
||||
|
||||
const RuntimeUsageByAgentSchema = z.object({
|
||||
agent_id: z.string().default(""),
|
||||
model: z.string().default(""),
|
||||
input_tokens: z.number().default(0),
|
||||
output_tokens: z.number().default(0),
|
||||
cache_read_tokens: z.number().default(0),
|
||||
cache_write_tokens: z.number().default(0),
|
||||
task_count: z.number().default(0),
|
||||
}).loose();
|
||||
|
||||
export const RuntimeUsageByAgentListSchema = z.array(RuntimeUsageByAgentSchema);
|
||||
|
||||
const RuntimeUsageByHourSchema = z.object({
|
||||
hour: z.number().default(0),
|
||||
model: z.string().default(""),
|
||||
input_tokens: z.number().default(0),
|
||||
output_tokens: z.number().default(0),
|
||||
cache_read_tokens: z.number().default(0),
|
||||
cache_write_tokens: z.number().default(0),
|
||||
task_count: z.number().default(0),
|
||||
}).loose();
|
||||
|
||||
export const RuntimeUsageByHourListSchema = z.array(RuntimeUsageByHourSchema);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Agent template catalog — `/api/agent-templates*` and the
|
||||
// create-from-template response. The desktop app's create-agent picker
|
||||
@@ -573,44 +472,3 @@ export const EMPTY_WEBHOOK_DELIVERY: WebhookDelivery = {
|
||||
last_attempt_at: "",
|
||||
created_at: "",
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// User (`/api/me` GET + PATCH). The auth store and Settings → Account both
|
||||
// trust this shape — a drift here would knock both surfaces out. Kept
|
||||
// lenient by the same rules as IssueSchema: enums stay `z.string()`,
|
||||
// nullable fields are unioned with `null`, unknown server fields pass
|
||||
// through via `.loose()`. `profile_description` is the field added in
|
||||
// MUL-2406; the server emits `""` when unset (NOT NULL DEFAULT ''), so
|
||||
// the schema defaults to `""` too — keeps the type tight without
|
||||
// breaking older backends that don't return the column yet.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const UserSchema = z.object({
|
||||
id: z.string(),
|
||||
name: z.string().default(""),
|
||||
email: z.string().default(""),
|
||||
avatar_url: z.string().nullable().default(null),
|
||||
onboarded_at: z.string().nullable().default(null),
|
||||
onboarding_questionnaire: z.record(z.string(), z.unknown()).default({}),
|
||||
starter_content_state: z.string().nullable().default(null),
|
||||
language: z.string().nullable().default(null),
|
||||
profile_description: z.string().default(""),
|
||||
timezone: z.string().nullable().default(null),
|
||||
created_at: z.string().default(""),
|
||||
updated_at: z.string().default(""),
|
||||
}).loose();
|
||||
|
||||
export const EMPTY_USER: User = {
|
||||
id: "",
|
||||
name: "",
|
||||
email: "",
|
||||
avatar_url: null,
|
||||
onboarded_at: null,
|
||||
onboarding_questionnaire: {},
|
||||
starter_content_state: null,
|
||||
language: null,
|
||||
profile_description: "",
|
||||
timezone: null,
|
||||
created_at: "",
|
||||
updated_at: "",
|
||||
};
|
||||
|
||||
@@ -6,7 +6,6 @@ import { WSClient } from "./ws-client";
|
||||
// upgrade URL construction, which is what carries client identity.
|
||||
class FakeWebSocket {
|
||||
static lastUrl: string | null = null;
|
||||
static lastInstance: FakeWebSocket | null = null;
|
||||
// Fields read by WSClient.connect()/disconnect(), all no-op here.
|
||||
onopen: (() => void) | null = null;
|
||||
onmessage: ((ev: { data: string }) => void) | null = null;
|
||||
@@ -15,7 +14,6 @@ class FakeWebSocket {
|
||||
readyState = 0;
|
||||
constructor(url: string) {
|
||||
FakeWebSocket.lastUrl = url;
|
||||
FakeWebSocket.lastInstance = this;
|
||||
}
|
||||
close() {}
|
||||
send() {}
|
||||
@@ -24,7 +22,6 @@ class FakeWebSocket {
|
||||
describe("WSClient", () => {
|
||||
beforeEach(() => {
|
||||
FakeWebSocket.lastUrl = null;
|
||||
FakeWebSocket.lastInstance = null;
|
||||
vi.stubGlobal("WebSocket", FakeWebSocket as unknown as typeof WebSocket);
|
||||
});
|
||||
|
||||
@@ -72,59 +69,4 @@ describe("WSClient", () => {
|
||||
expect(url.searchParams.has("client_version")).toBe(false);
|
||||
expect(url.searchParams.has("client_os")).toBe(false);
|
||||
});
|
||||
|
||||
it("truncates the logged payload when an unparseable frame is large", () => {
|
||||
const logger = {
|
||||
debug: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
};
|
||||
const ws = new WSClient("ws://example.test/ws", { logger });
|
||||
ws.connect();
|
||||
|
||||
const huge = "x".repeat(5000);
|
||||
FakeWebSocket.lastInstance!.onmessage?.({ data: huge });
|
||||
|
||||
expect(logger.warn).toHaveBeenCalledTimes(1);
|
||||
const [, summary] = logger.warn.mock.calls[0] as [string, string];
|
||||
expect(summary.length).toBeLessThan(huge.length);
|
||||
expect(summary).toContain("truncated");
|
||||
expect(summary).toContain("5000");
|
||||
expect(summary.startsWith("x".repeat(200))).toBe(true);
|
||||
});
|
||||
|
||||
it("logs and skips malformed frames without breaking later messages", () => {
|
||||
const logger = {
|
||||
debug: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
};
|
||||
const ws = new WSClient("ws://example.test/ws", { logger });
|
||||
const handler = vi.fn();
|
||||
ws.on("issue:updated", handler);
|
||||
ws.connect();
|
||||
|
||||
expect(() => {
|
||||
FakeWebSocket.lastInstance!.onmessage?.({ data: `{"type":"issue` });
|
||||
}).not.toThrow();
|
||||
|
||||
FakeWebSocket.lastInstance!.onmessage?.({
|
||||
data: JSON.stringify({
|
||||
type: "issue:updated",
|
||||
payload: { id: "issue-1" },
|
||||
}),
|
||||
});
|
||||
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
"ws: received unparseable message",
|
||||
`{"type":"issue`,
|
||||
);
|
||||
expect(handler).toHaveBeenCalledWith(
|
||||
{ id: "issue-1" },
|
||||
undefined,
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,17 +3,6 @@ import { type Logger, noopLogger } from "../logger";
|
||||
|
||||
type EventHandler = (payload: unknown, actorId?: string, actorType?: string) => void;
|
||||
|
||||
// Cap how much of an unparseable frame we put into the log. A malformed or
|
||||
// rogue server can stream arbitrarily large garbage, and the warn handler may
|
||||
// be a console / IPC bridge whose buffers we don't want to blow.
|
||||
const UNPARSEABLE_LOG_MAX_CHARS = 200;
|
||||
|
||||
function summarizeUnparseable(data: unknown): string {
|
||||
const text = typeof data === "string" ? data : String(data);
|
||||
if (text.length <= UNPARSEABLE_LOG_MAX_CHARS) return text;
|
||||
return `${text.slice(0, UNPARSEABLE_LOG_MAX_CHARS)}… (truncated, ${text.length} chars total)`;
|
||||
}
|
||||
|
||||
/** Identifies the WS client to the server. Sent as `client_platform`,
|
||||
* `client_version`, and `client_os` query parameters on the upgrade URL —
|
||||
* browsers cannot set custom headers on WebSocket handshakes, so query
|
||||
@@ -86,16 +75,7 @@ export class WSClient {
|
||||
};
|
||||
|
||||
this.ws.onmessage = (event) => {
|
||||
let msg: WSMessage;
|
||||
try {
|
||||
msg = JSON.parse(event.data as string) as WSMessage;
|
||||
} catch {
|
||||
this.logger.warn(
|
||||
"ws: received unparseable message",
|
||||
summarizeUnparseable(event.data),
|
||||
);
|
||||
return;
|
||||
}
|
||||
const msg = JSON.parse(event.data as string) as WSMessage;
|
||||
if ((msg as any).type === "auth_ack") {
|
||||
this.onAuthenticated();
|
||||
return;
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { isTaskMessageTaskId, taskMessagesOptions } from "./queries";
|
||||
|
||||
describe("taskMessagesOptions", () => {
|
||||
it("fetches task messages for persisted UUID task ids", () => {
|
||||
const taskId = "4a2e8d1c-7f9b-4e2a-9c1d-123456789abc";
|
||||
|
||||
expect(isTaskMessageTaskId(taskId)).toBe(true);
|
||||
expect(taskMessagesOptions(taskId).enabled).toBe(true);
|
||||
});
|
||||
|
||||
it("does not fetch task messages for optimistic task ids", () => {
|
||||
const taskId = "optimistic-optimistic-1778739487737";
|
||||
|
||||
expect(isTaskMessageTaskId(taskId)).toBe(false);
|
||||
expect(taskMessagesOptions(taskId).enabled).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -21,12 +21,6 @@ export const chatKeys = {
|
||||
taskMessages: (taskId: string) => ["task-messages", taskId] as const,
|
||||
};
|
||||
|
||||
const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||
|
||||
export function isTaskMessageTaskId(taskId: string | null | undefined): taskId is string {
|
||||
return typeof taskId === "string" && UUID_PATTERN.test(taskId);
|
||||
}
|
||||
|
||||
export function chatSessionsOptions(wsId: string) {
|
||||
return queryOptions({
|
||||
queryKey: chatKeys.sessions(wsId),
|
||||
@@ -76,7 +70,7 @@ export function taskMessagesOptions(taskId: string) {
|
||||
return queryOptions({
|
||||
queryKey: chatKeys.taskMessages(taskId),
|
||||
queryFn: () => api.listTaskMessages(taskId),
|
||||
enabled: isTaskMessageTaskId(taskId),
|
||||
enabled: !!taskId,
|
||||
staleTime: Infinity,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,55 +1,45 @@
|
||||
import { queryOptions } from "@tanstack/react-query";
|
||||
import { api } from "../api";
|
||||
|
||||
// Workspace dashboard query options. All three endpoints share the same
|
||||
// (wsId, days, projectId) key shape so workspace switching, time-range
|
||||
// changes, and the project filter each invalidate the cache cleanly.
|
||||
//
|
||||
// The cache key includes `wsId` explicitly: TanStack Query already isolates
|
||||
// per workspace via the key, but threading wsId into the queryFn lets
|
||||
// callers fail fast (return [] on empty wsId) instead of issuing a request
|
||||
// the server would reject.
|
||||
//
|
||||
// `projectId` is normalised to `null` (not undefined / "all") so the
|
||||
// queryKey shape is stable across renders even when the dropdown sits on
|
||||
// "all projects".
|
||||
|
||||
export const dashboardKeys = {
|
||||
all: (wsId: string) => ["dashboard", wsId] as const,
|
||||
daily: (
|
||||
wsId: string,
|
||||
days: number,
|
||||
projectId: string | null,
|
||||
tz: string,
|
||||
) => [...dashboardKeys.all(wsId), "daily", days, projectId, tz] as const,
|
||||
byAgent: (
|
||||
wsId: string,
|
||||
days: number,
|
||||
projectId: string | null,
|
||||
tz: string,
|
||||
) => [...dashboardKeys.all(wsId), "by-agent", days, projectId, tz] as const,
|
||||
agentRuntime: (
|
||||
wsId: string,
|
||||
days: number,
|
||||
projectId: string | null,
|
||||
tz: string,
|
||||
) => [...dashboardKeys.all(wsId), "agent-runtime", days, projectId, tz] as const,
|
||||
runTimeDaily: (
|
||||
wsId: string,
|
||||
days: number,
|
||||
projectId: string | null,
|
||||
tz: string,
|
||||
) => [...dashboardKeys.all(wsId), "runtime-daily", days, projectId, tz] as const,
|
||||
daily: (wsId: string, days: number, projectId: string | null) =>
|
||||
[...dashboardKeys.all(wsId), "daily", days, projectId] as const,
|
||||
byAgent: (wsId: string, days: number, projectId: string | null) =>
|
||||
[...dashboardKeys.all(wsId), "by-agent", days, projectId] as const,
|
||||
agentRuntime: (wsId: string, days: number, projectId: string | null) =>
|
||||
[...dashboardKeys.all(wsId), "agent-runtime", days, projectId] as const,
|
||||
runTimeDaily: (wsId: string, days: number, projectId: string | null) =>
|
||||
[...dashboardKeys.all(wsId), "runtime-daily", days, projectId] as const,
|
||||
};
|
||||
|
||||
// 5-min rollup cadence on the server, 60s background refetch on the client.
|
||||
// 60s staleTime matches the per-runtime usage queries — the data is rollup-
|
||||
// driven on the server (5-min rollup cadence) and the dashboard isn't a
|
||||
// real-time view, so background refetches every minute are plenty.
|
||||
const STALE_TIME = 60 * 1000;
|
||||
|
||||
// `tz` participates in every dashboard key so a Preferences change
|
||||
// repoints the cache. All four series — token rollups and the
|
||||
// atq.completed_at-based run-time series — slice their day boundary in
|
||||
// the viewer's tz, so the four dashboard tabs always agree.
|
||||
export function dashboardUsageDailyOptions(
|
||||
wsId: string,
|
||||
days: number,
|
||||
projectId: string | null,
|
||||
tz: string,
|
||||
) {
|
||||
return queryOptions({
|
||||
queryKey: dashboardKeys.daily(wsId, days, projectId, tz),
|
||||
queryKey: dashboardKeys.daily(wsId, days, projectId),
|
||||
queryFn: () =>
|
||||
api.getDashboardUsageDaily({
|
||||
days,
|
||||
project_id: projectId ?? undefined,
|
||||
tz,
|
||||
}),
|
||||
api.getDashboardUsageDaily({ days, project_id: projectId ?? undefined }),
|
||||
enabled: !!wsId,
|
||||
staleTime: STALE_TIME,
|
||||
});
|
||||
@@ -59,16 +49,11 @@ export function dashboardUsageByAgentOptions(
|
||||
wsId: string,
|
||||
days: number,
|
||||
projectId: string | null,
|
||||
tz: string,
|
||||
) {
|
||||
return queryOptions({
|
||||
queryKey: dashboardKeys.byAgent(wsId, days, projectId, tz),
|
||||
queryKey: dashboardKeys.byAgent(wsId, days, projectId),
|
||||
queryFn: () =>
|
||||
api.getDashboardUsageByAgent({
|
||||
days,
|
||||
project_id: projectId ?? undefined,
|
||||
tz,
|
||||
}),
|
||||
api.getDashboardUsageByAgent({ days, project_id: projectId ?? undefined }),
|
||||
enabled: !!wsId,
|
||||
staleTime: STALE_TIME,
|
||||
});
|
||||
@@ -78,16 +63,11 @@ export function dashboardAgentRunTimeOptions(
|
||||
wsId: string,
|
||||
days: number,
|
||||
projectId: string | null,
|
||||
tz: string,
|
||||
) {
|
||||
return queryOptions({
|
||||
queryKey: dashboardKeys.agentRuntime(wsId, days, projectId, tz),
|
||||
queryKey: dashboardKeys.agentRuntime(wsId, days, projectId),
|
||||
queryFn: () =>
|
||||
api.getDashboardAgentRunTime({
|
||||
days,
|
||||
project_id: projectId ?? undefined,
|
||||
tz,
|
||||
}),
|
||||
api.getDashboardAgentRunTime({ days, project_id: projectId ?? undefined }),
|
||||
enabled: !!wsId,
|
||||
staleTime: STALE_TIME,
|
||||
});
|
||||
@@ -97,16 +77,11 @@ export function dashboardRunTimeDailyOptions(
|
||||
wsId: string,
|
||||
days: number,
|
||||
projectId: string | null,
|
||||
tz: string,
|
||||
) {
|
||||
return queryOptions({
|
||||
queryKey: dashboardKeys.runTimeDaily(wsId, days, projectId, tz),
|
||||
queryKey: dashboardKeys.runTimeDaily(wsId, days, projectId),
|
||||
queryFn: () =>
|
||||
api.getDashboardRunTimeDaily({
|
||||
days,
|
||||
project_id: projectId ?? undefined,
|
||||
tz,
|
||||
}),
|
||||
api.getDashboardRunTimeDaily({ days, project_id: projectId ?? undefined }),
|
||||
enabled: !!wsId,
|
||||
staleTime: STALE_TIME,
|
||||
});
|
||||
|
||||
@@ -1,4 +1,2 @@
|
||||
export * from "./queries";
|
||||
export * from "./pull-request-status";
|
||||
export * from "./settings";
|
||||
export * from "./use-github-settings";
|
||||
|
||||
@@ -1,66 +0,0 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { deriveGitHubSettings } from "./settings";
|
||||
import type { Workspace } from "../types";
|
||||
|
||||
function ws(settings: Record<string, unknown>): Pick<Workspace, "settings"> {
|
||||
return { settings };
|
||||
}
|
||||
|
||||
describe("deriveGitHubSettings", () => {
|
||||
it("defaults every flag to true when workspace is null", () => {
|
||||
expect(deriveGitHubSettings(null)).toEqual({
|
||||
enabled: true,
|
||||
prSidebar: true,
|
||||
coAuthor: true,
|
||||
autoLinkPRs: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("defaults every flag to true on empty settings", () => {
|
||||
expect(deriveGitHubSettings(ws({}))).toEqual({
|
||||
enabled: true,
|
||||
prSidebar: true,
|
||||
coAuthor: true,
|
||||
autoLinkPRs: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("master switch off forces every dependent flag off", () => {
|
||||
const got = deriveGitHubSettings(
|
||||
ws({
|
||||
github_enabled: false,
|
||||
github_pr_sidebar_enabled: true,
|
||||
co_authored_by_enabled: true,
|
||||
github_auto_link_prs_enabled: true,
|
||||
}),
|
||||
);
|
||||
expect(got).toEqual({
|
||||
enabled: false,
|
||||
prSidebar: false,
|
||||
coAuthor: false,
|
||||
autoLinkPRs: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("each sub-flag can be flipped independently when master is on", () => {
|
||||
expect(
|
||||
deriveGitHubSettings(ws({ github_pr_sidebar_enabled: false })),
|
||||
).toMatchObject({ enabled: true, prSidebar: false, coAuthor: true, autoLinkPRs: true });
|
||||
|
||||
expect(
|
||||
deriveGitHubSettings(ws({ co_authored_by_enabled: false })),
|
||||
).toMatchObject({ enabled: true, prSidebar: true, coAuthor: false, autoLinkPRs: true });
|
||||
|
||||
expect(
|
||||
deriveGitHubSettings(ws({ github_auto_link_prs_enabled: false })),
|
||||
).toMatchObject({ enabled: true, prSidebar: true, coAuthor: true, autoLinkPRs: false });
|
||||
});
|
||||
|
||||
it("treats non-false values (true, null, missing) as enabled", () => {
|
||||
expect(
|
||||
deriveGitHubSettings(
|
||||
ws({ github_enabled: true, github_pr_sidebar_enabled: null }),
|
||||
),
|
||||
).toMatchObject({ enabled: true, prSidebar: true });
|
||||
});
|
||||
});
|
||||
@@ -1,29 +0,0 @@
|
||||
import type { Workspace } from "../types";
|
||||
|
||||
export interface GitHubSettings {
|
||||
/** Master switch. When false, every UI affordance and side-effect is gated off. */
|
||||
enabled: boolean;
|
||||
/** Issue-detail PR sidebar visibility. Implies `enabled`. */
|
||||
prSidebar: boolean;
|
||||
/** Co-authored-by trailer in agent commits. Implies `enabled`. */
|
||||
coAuthor: boolean;
|
||||
/** Auto-link issues ↔ PRs from webhook payloads. Implies `enabled`. */
|
||||
autoLinkPRs: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pure derivation from a workspace's settings JSONB. Defaults every flag to
|
||||
* true so workspaces predating MUL-2414 keep the historical "all on" behavior.
|
||||
*/
|
||||
export function deriveGitHubSettings(
|
||||
workspace: Pick<Workspace, "settings"> | null | undefined,
|
||||
): GitHubSettings {
|
||||
const s = (workspace?.settings ?? {}) as Record<string, unknown>;
|
||||
const enabled = s.github_enabled !== false;
|
||||
return {
|
||||
enabled,
|
||||
prSidebar: enabled && s.github_pr_sidebar_enabled !== false,
|
||||
coAuthor: enabled && s.co_authored_by_enabled !== false,
|
||||
autoLinkPRs: enabled && s.github_auto_link_prs_enabled !== false,
|
||||
};
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
import { useCurrentWorkspace } from "../paths";
|
||||
import { deriveGitHubSettings, type GitHubSettings } from "./settings";
|
||||
|
||||
/**
|
||||
* Reads the GitHub feature flags off the current workspace's settings JSONB.
|
||||
* Components downstream should consult this hook rather than poking at
|
||||
* `workspace.settings` directly, so the per-flag fallback semantics
|
||||
* (see deriveGitHubSettings) stay consistent.
|
||||
*/
|
||||
export function useGitHubSettings(): GitHubSettings {
|
||||
const workspace = useCurrentWorkspace();
|
||||
return useMemo(() => deriveGitHubSettings(workspace), [workspace]);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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));
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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 }),
|
||||
}),
|
||||
|
||||
@@ -9,8 +9,7 @@ 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 SortDirection = "asc" | "desc";
|
||||
@@ -71,11 +70,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;
|
||||
@@ -118,13 +113,8 @@ export const viewStoreSlice = (set: StoreApi<IssueViewState>["setState"]): Issue
|
||||
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 +232,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
|
||||
|
||||
@@ -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 {
|
||||
@@ -156,25 +151,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 +392,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));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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) });
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,37 +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, then
|
||||
* marks onboarding complete.
|
||||
*/
|
||||
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
|
||||
* install-runtime onboarding issue and marks onboarding complete.
|
||||
*/
|
||||
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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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`,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -94,6 +94,7 @@ function makeRuntime(ownerId: string | null): RuntimeDevice {
|
||||
metadata: {},
|
||||
owner_id: ownerId,
|
||||
visibility: "private",
|
||||
timezone: "UTC",
|
||||
last_seen_at: null,
|
||||
created_at: "2026-04-01T00:00:00Z",
|
||||
updated_at: "2026-04-01T00:00:00Z",
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
export { projectKeys, projectListOptions, projectDetailOptions } from "./queries";
|
||||
export { useCreateProject, useUpdateProject, useDeleteProject } from "./mutations";
|
||||
export { useProjectDraftStore } from "./draft-store";
|
||||
export { useProjectViewStore } from "./stores/view-store";
|
||||
export {
|
||||
projectResourceKeys,
|
||||
projectResourcesOptions,
|
||||
|
||||
@@ -1,96 +0,0 @@
|
||||
// @vitest-environment jsdom
|
||||
import { afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest";
|
||||
import { useProjectViewStore } from "./view-store";
|
||||
import { setCurrentWorkspace } from "../../platform/workspace-storage";
|
||||
|
||||
const flush = () => new Promise((resolve) => queueMicrotask(() => resolve(null)));
|
||||
|
||||
// Node 25 ships a partial `localStorage` shim under jsdom that's missing
|
||||
// `clear`/`removeItem`; replace it with a real in-memory Storage so persist
|
||||
// can round-trip values.
|
||||
beforeAll(() => {
|
||||
if (typeof globalThis.localStorage?.clear !== "function") {
|
||||
const values = new Map<string, string>();
|
||||
const storage: Storage = {
|
||||
get length() { return values.size; },
|
||||
clear: () => values.clear(),
|
||||
getItem: (k) => values.get(k) ?? null,
|
||||
key: (i) => Array.from(values.keys())[i] ?? null,
|
||||
removeItem: (k) => { values.delete(k); },
|
||||
setItem: (k, v) => { values.set(k, v); },
|
||||
};
|
||||
Object.defineProperty(globalThis, "localStorage", { configurable: true, value: storage });
|
||||
Object.defineProperty(window, "localStorage", { configurable: true, value: storage });
|
||||
}
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
useProjectViewStore.setState({ viewMode: "compact" });
|
||||
setCurrentWorkspace(null, null);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
setCurrentWorkspace(null, null);
|
||||
});
|
||||
|
||||
describe("useProjectViewStore", () => {
|
||||
it("defaults to 'compact'", () => {
|
||||
expect(useProjectViewStore.getState().viewMode).toBe("compact");
|
||||
});
|
||||
|
||||
it("setViewMode mutates the store", () => {
|
||||
useProjectViewStore.getState().setViewMode("comfortable");
|
||||
expect(useProjectViewStore.getState().viewMode).toBe("comfortable");
|
||||
});
|
||||
|
||||
it("partialize persists only viewMode under the workspace-namespaced key", async () => {
|
||||
setCurrentWorkspace("acme", "ws_a");
|
||||
await flush();
|
||||
useProjectViewStore.getState().setViewMode("comfortable");
|
||||
|
||||
const raw = localStorage.getItem("multica_projects_view:acme");
|
||||
expect(raw).not.toBeNull();
|
||||
const parsed = JSON.parse(raw as string);
|
||||
expect(parsed.state).toEqual({ viewMode: "comfortable" });
|
||||
});
|
||||
|
||||
it("rehydrates a different saved viewMode on workspace switch", async () => {
|
||||
localStorage.setItem(
|
||||
"multica_projects_view:acme",
|
||||
JSON.stringify({ state: { viewMode: "comfortable" }, version: 0 }),
|
||||
);
|
||||
localStorage.setItem(
|
||||
"multica_projects_view:beta",
|
||||
JSON.stringify({ state: { viewMode: "compact" }, version: 0 }),
|
||||
);
|
||||
|
||||
setCurrentWorkspace("acme", "ws_a");
|
||||
await flush();
|
||||
await flush();
|
||||
expect(useProjectViewStore.getState().viewMode).toBe("comfortable");
|
||||
|
||||
setCurrentWorkspace("beta", "ws_b");
|
||||
await flush();
|
||||
await flush();
|
||||
expect(useProjectViewStore.getState().viewMode).toBe("compact");
|
||||
});
|
||||
|
||||
it("resets to 'compact' when switching to a workspace with no persisted value", async () => {
|
||||
localStorage.setItem(
|
||||
"multica_projects_view:acme",
|
||||
JSON.stringify({ state: { viewMode: "comfortable" }, version: 0 }),
|
||||
);
|
||||
|
||||
setCurrentWorkspace("acme", "ws_a");
|
||||
await flush();
|
||||
await flush();
|
||||
expect(useProjectViewStore.getState().viewMode).toBe("comfortable");
|
||||
|
||||
setCurrentWorkspace("beta", "ws_b");
|
||||
await flush();
|
||||
await flush();
|
||||
expect(useProjectViewStore.getState().viewMode).toBe("compact");
|
||||
expect(localStorage.getItem("multica_projects_view:acme")).not.toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,33 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { create } from "zustand";
|
||||
import { createJSONStorage, persist } from "zustand/middleware";
|
||||
import { createWorkspaceAwareStorage, registerForWorkspaceRehydration } from "../../platform/workspace-storage";
|
||||
import { defaultStorage } from "../../platform/storage";
|
||||
|
||||
export type ProjectViewMode = "compact" | "comfortable";
|
||||
|
||||
export interface ProjectViewState {
|
||||
viewMode: ProjectViewMode;
|
||||
setViewMode: (mode: ProjectViewMode) => void;
|
||||
}
|
||||
|
||||
export const useProjectViewStore = create<ProjectViewState>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
viewMode: "compact",
|
||||
setViewMode: (mode) => set({ viewMode: mode }),
|
||||
}),
|
||||
{
|
||||
name: "multica_projects_view",
|
||||
storage: createJSONStorage(() => createWorkspaceAwareStorage(defaultStorage)),
|
||||
partialize: (state) => ({ viewMode: state.viewMode }),
|
||||
merge: (persisted, current) => {
|
||||
if (!persisted) return { ...current, viewMode: "compact" };
|
||||
return { ...current, ...(persisted as Partial<ProjectViewState>) };
|
||||
},
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
registerForWorkspaceRehydration(() => useProjectViewStore.persist.rehydrate());
|
||||
@@ -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),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
@@ -223,11 +191,6 @@ export function useRealtimeSync(
|
||||
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() });
|
||||
},
|
||||
@@ -340,7 +303,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 +540,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 +849,6 @@ export function useRealtimeSync(
|
||||
unsubIssueReactionRemoved();
|
||||
unsubSubscriberAdded();
|
||||
unsubSubscriberRemoved();
|
||||
unsubWsUpdated();
|
||||
unsubWsDeleted();
|
||||
unsubMemberRemoved();
|
||||
unsubMemberAdded();
|
||||
|
||||
@@ -1,90 +0,0 @@
|
||||
import { queryOptions, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { api } from "../api";
|
||||
|
||||
export interface CloudRuntimeNode {
|
||||
id: string;
|
||||
owner_id: string;
|
||||
instance_id: string;
|
||||
region: string;
|
||||
instance_type: string;
|
||||
image_id: string;
|
||||
subnet_id: string;
|
||||
name: string;
|
||||
status: string;
|
||||
tags: Record<string, string>;
|
||||
metadata: Record<string, unknown>;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface ListCloudRuntimeNodesParams {
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}
|
||||
|
||||
export interface CreateCloudRuntimeNodeRequest {
|
||||
instance_type: string;
|
||||
name?: string;
|
||||
region?: string;
|
||||
image_id?: string;
|
||||
subnet_id?: string;
|
||||
key_name?: string;
|
||||
iam_instance_profile?: string;
|
||||
disk_size_gb?: number;
|
||||
tags?: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface CreateCloudRuntimeNodeOptions {
|
||||
userPAT?: string;
|
||||
}
|
||||
|
||||
export const cloudRuntimeKeys = {
|
||||
all: (wsId: string) => ["cloud-runtime", wsId] as const,
|
||||
nodes: (wsId: string) => [...cloudRuntimeKeys.all(wsId), "nodes"] as const,
|
||||
};
|
||||
|
||||
const PENDING_NODE_STATUSES = new Set([
|
||||
"launching",
|
||||
"pending",
|
||||
"starting",
|
||||
"stopping",
|
||||
"rebooting",
|
||||
"terminating",
|
||||
]);
|
||||
|
||||
export function isCloudRuntimeNodePending(status: string): boolean {
|
||||
return PENDING_NODE_STATUSES.has(status.toLowerCase());
|
||||
}
|
||||
|
||||
export function cloudRuntimeNodeListOptions(
|
||||
wsId: string,
|
||||
params?: ListCloudRuntimeNodesParams,
|
||||
) {
|
||||
const limit = params?.limit ?? 20;
|
||||
const offset = params?.offset ?? 0;
|
||||
return queryOptions({
|
||||
queryKey: [...cloudRuntimeKeys.nodes(wsId), { limit, offset }] as const,
|
||||
queryFn: () => api.listCloudRuntimeNodes({ limit, offset }),
|
||||
refetchInterval: (query) =>
|
||||
query.state.data?.some((node) => isCloudRuntimeNodePending(node.status))
|
||||
? 5000
|
||||
: false,
|
||||
staleTime: 15 * 1000,
|
||||
});
|
||||
}
|
||||
|
||||
export function useCreateCloudRuntimeNode(wsId: string) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({
|
||||
data,
|
||||
userPAT,
|
||||
}: {
|
||||
data: CreateCloudRuntimeNodeRequest;
|
||||
userPAT?: string;
|
||||
}) => api.createCloudRuntimeNode(data, { userPAT }),
|
||||
onSettled: () => {
|
||||
qc.invalidateQueries({ queryKey: cloudRuntimeKeys.all(wsId) });
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -18,6 +18,7 @@ function makeRuntime(overrides: Partial<AgentRuntime> = {}): AgentRuntime {
|
||||
metadata: {},
|
||||
owner_id: null,
|
||||
visibility: "private",
|
||||
timezone: "UTC",
|
||||
last_seen_at: new Date(FIXED_NOW - 10_000).toISOString(),
|
||||
created_at: "2026-04-01T00:00:00Z",
|
||||
updated_at: "2026-04-01T00:00:00Z",
|
||||
|
||||
@@ -8,4 +8,3 @@ export * from "./derive-health";
|
||||
export * from "./use-runtime-health";
|
||||
export * from "./cli-version";
|
||||
export * from "./custom-pricing-store";
|
||||
export * from "./cloud-runtime";
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user