Compare commits

..

3 Commits

Author SHA1 Message Date
Lambda
117c7ba6ae fix(inbox): keep scope/availability caches fresh on issue reassign + relation changes
- issue:updated WS + useUpdateIssue / useBatchUpdateIssues now invalidate
  inboxKeys.list + scopeCounts so assignee_scope-derived chip filtering,
  badges, and bulk operations don't lag the actual scope.
- onInboxInvalidate / onInboxIssueDeleted also flush scopeCounts so
  single-row archived/read events and CASCADE-deletes refresh the chip
  badge alongside the list.
- agent / member / squad refresh handlers invalidate
  inboxKeys.resourceAvailability so chip enabled state reacts to the
  first owned-agent / squad-membership / squad creation event instead
  of waiting for reload.
- Inbox page header unread count derives from filtered items rather
  than the global useInboxUnreadCount so the badge matches the visible
  list; sidebar / desktop badge stay on the global count.

Co-authored-by: multica-agent <github@multica.ai>
2026-05-19 23:18:36 +08:00
Lambda
7ac797fcd8 refactor(inbox): rename batch-archived operation literal archive_all_read → archive_read
RFC v4 final naming (per Xeon directive on MUL-2426): the three places that
must agree on the operation literal are the server `inbox:batch-archived`
event payload, the bulk-endpoint handler switch, and the frontend
`InboxBatchArchiveOperation` discriminated union. UI menu / error i18n keys
are unrelated and stay as-is.

Co-authored-by: multica-agent <github@multica.ai>
2026-05-19 17:00:37 +08:00
Lambda
fd913a2596 feat(inbox): add assignment filter chips (assigned to me / my agent / my squad) (MUL-2426)
Implements RFC v3 + the two v4 deltas (operation field on inbox:batch-archived,
scoped.id/issue_id alias). Server-side first (SQL + handler + WS payload +
resource-availability), then frontend (chip UI + store + dynamic bulk labels).

Backend:
- Migration 095: SQL function squad_involves_user mirroring the
  ListIssues involves_user_id semantics so the inbox scope predicate
  can't drift from My Issues.
- ListInboxItems now tags each row with assignee_scope (me / my_agent /
  my_squad / other / none) and accepts an optional scopes filter.
- New endpoints: GET /api/inbox/scope-counts (post-dedup), GET
  /api/inbox/resource-availability (decoupled chip-disabled signal).
- mark-all-read + 3 archive endpoints accept ?scope=...; archive-* emit
  inbox:batch-archived with operation + scope so listeners can pick
  the right predicate when applying precise cache updates.

Frontend:
- New workspace-aware inbox-scope-store; default = all 3 chips selected.
- resolveInboxFilter implements the all / subset / empty algorithm.
- InboxFilterChips component with disabled-but-selected state machine
  (S1-S4) and tooltips, sourced from resource-availability rather than
  scope counts.
- Bulk actions disabled in empty mode, label swaps to "filtered" copy
  in subset mode.
- WS handlers for inbox:batch-read / inbox:batch-archived wired in to
  refresh other devices.

Co-authored-by: multica-agent <github@multica.ai>
2026-05-19 16:55:33 +08:00
333 changed files with 8314 additions and 21891 deletions

View File

@@ -161,7 +161,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>.

View File

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

View File

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

View File

@@ -285,11 +285,19 @@ If you need full isolation between organizations or accounts — separate tokens
```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.
The current default workspace is marked with `*`.
### Show Current Workspace
```bash
multica workspace current
multica workspace current --output json
```
Prints the workspace that commands without `--workspace-id` and `MULTICA_WORKSPACE_ID` would target.
### Switch Default Workspace
@@ -307,12 +315,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
@@ -328,14 +334,7 @@ multica issue list --full-id
multica issue list --limit 20 --output json
```
Table output shows a routable issue `KEY` such as `MUL-123`; copy that key into follow-up commands like `issue get`, `issue comment list`, `issue status`, or `--parent`. Add `--full-id` when you need canonical UUIDs. Available filters: `--status`, `--priority`, `--assignee` / `--assignee-id`, `--project`, `--metadata`, `--limit`. Use `--assignee-id <uuid>` for unambiguous filtering when names overlap.
Use `--metadata key=value` (repeatable; combined with AND) to filter by per-issue metadata. The value is JSON-parsed: `true`/`false` become bool, numbers become numbers, anything else is a string. Wrap as `'"42"'` to force a string when the value would otherwise sniff as a number:
```bash
multica issue list --metadata pipeline_status=waiting_review
multica issue list --metadata pr_number=482 --metadata is_blocked=true
```
Table output shows a routable issue `KEY` such as `MUL-123`; copy that key into follow-up commands like `issue get`, `issue comment list`, `issue status`, or `--parent`. Add `--full-id` when you need canonical UUIDs. Available filters: `--status`, `--priority`, `--assignee` / `--assignee-id`, `--project`, `--limit`. Use `--assignee-id <uuid>` for unambiguous filtering when names overlap.
### Get Issue
@@ -351,7 +350,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
@@ -380,44 +379,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"
@@ -428,56 +392,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.
### Metadata
Per-issue metadata is a small KV map agents use to track pipeline state (PR number, pipeline status, waiting_on, ...). Keys match `^[a-zA-Z_][a-zA-Z0-9_.-]{0,63}$`, values are primitives (string / number / bool), max 50 keys per issue, blob capped at 8KB.
The bar for writing is high: pin a value only when it is materially important to the issue AND likely to be re-read by future runs on this same issue (the PR URL, the deploy URL, what we're blocked on). Most runs write zero new keys — that's the expected case. Don't pin runtime bookkeeping like `attempts`, single-run investigation notes, large logs, secrets/tokens, or description/comment copies — see the agent runtime prompt for the full anti-pattern list.
```bash
# List every key on an issue
multica issue metadata list <issue-id>
# Read a single key
multica issue metadata get <issue-id> --key pipeline_status
# Write a single key — value auto-typed (true/false → bool, numbers → number, else string)
multica issue metadata set <issue-id> --key pipeline_status --value waiting_review
multica issue metadata set <issue-id> --key pr_number --value 482
multica issue metadata set <issue-id> --key is_blocked --value true
# Force a specific type when sniffing would pick the wrong one
multica issue metadata set <issue-id> --key code --value 42 --type string
# Remove a key
multica issue metadata delete <issue-id> --key pipeline_status
```
All writes are single-key atomic — concurrent agents writing different keys do not lose each other's updates. To query, use `multica issue list --metadata key=value` (see *List Issues* above).
### Subscribers
```bash

View File

@@ -18,7 +18,6 @@ ARG COMMIT=unknown
RUN cd server && CGO_ENABLED=0 go build -ldflags "-s -w -X main.version=${VERSION} -X main.commit=${COMMIT}" -o bin/server ./cmd/server
RUN cd server && CGO_ENABLED=0 go build -ldflags "-s -w -X main.version=${VERSION} -X main.commit=${COMMIT}" -o bin/multica ./cmd/multica
RUN cd server && CGO_ENABLED=0 go build -ldflags "-s -w" -o bin/migrate ./cmd/migrate
RUN cd server && CGO_ENABLED=0 go build -ldflags "-s -w" -o bin/backfill_task_usage_hourly ./cmd/backfill_task_usage_hourly
# --- Runtime stage ---
FROM alpine:3.21
@@ -30,7 +29,6 @@ WORKDIR /app
COPY --from=builder /src/server/bin/server .
COPY --from=builder /src/server/bin/multica .
COPY --from=builder /src/server/bin/migrate .
COPY --from=builder /src/server/bin/backfill_task_usage_hourly .
COPY server/migrations/ ./migrations/
COPY docker/entrypoint.sh .
RUN sed -i 's/\r$//' entrypoint.sh && chmod +x entrypoint.sh

View File

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

View File

@@ -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"]) {

View File

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

View File

@@ -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}
/>
);

View File

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

View File

@@ -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();
});
});

View File

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

View File

@@ -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");
});
});

View File

@@ -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}`,
}),

View File

@@ -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();
});
});

View File

@@ -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 ?? [];

View File

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

View File

@@ -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
从协作视图上看,智能体就是工作区里的一个成员;它和人的名字排在同一张成员列表里,只是前面通常有一个机器人图标。

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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-配置)。

View File

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

View File

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

View File

@@ -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 区块。

View File

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

View File

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

View File

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

View File

@@ -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那一行任务的 agentCLI / 不带 task_idissue 当前的分配人 |
| 跑哪个 agent | 失败任务原本的 agent | issue 当前的分配人 |
| 会话继承 | 是(接着上次会话) | 否(全新会话) |
## 失败的任务对 issue 状态有什么影响

View File

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

View File

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

View File

@@ -284,87 +284,6 @@ export function createEnDict(allowSignup: boolean): LandingDict {
fixes: "Bug Fixes",
},
entries: [
{
version: "0.3.5",
date: "2026-05-21",
title: "Local-Time Usage & Issue Custom KV",
changes: [],
features: [
"Usage now uses each user's saved viewing timezone, so workspace and runtime usage match the day boundaries people expect",
"Issues can carry compact agent-facing state that keeps automation progress tied to the work item without cluttering the sidebar",
"Long Issue discussion threads can be read from the newest replies first while keeping the original thread context",
"Project lists now offer compact and comfortable layouts that work better on small screens",
],
improvements: [
"Workspace commands in the CLI accept slugs and short IDs from the list view",
"The agent model picker no longer marks one model as a competing default",
"Self-hosting and agent docs have corrected links and cleaner setup copy",
"Reserved workspace paths have stronger coverage so special URLs are not accidentally reused as workspace addresses",
"Agent runtime guidance keeps Issue metadata cleaner by reserving it for state future runs will actually need",
],
fixes: [
"Automatic retries for stuck Codex runs now start from a fresh session instead of resuming the broken one",
"Claude Code usage totals are recorded correctly when usage arrives at the end of a run",
"Live updates now skip broken messages safely and keep logs small when a bad message is unusually large",
"The Create Issue button explains when a missing title is blocking creation",
"Runtimes can recover a task whose claim response was lost before the task actually started",
],
},
{
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",

View File

@@ -284,87 +284,6 @@ export function createZhDict(allowSignup: boolean): LandingDict {
fixes: "问题修复",
},
entries: [
{
version: "0.3.5",
date: "2026-05-21",
title: "按查看时区呈现用量,并支持 Issue Custom KV",
changes: [],
features: [
"用量会使用用户保存的查看时区,工作区和运行环境用量会按每个人预期的自然日展示",
"Issue 支持保存面向智能体的轻量状态,自动化进度可以跟随具体工作项,同时不打扰侧边栏阅读",
"较长的 Issue 讨论线程可以从最新回复开始读取,并保留原始讨论上下文",
"项目列表新增紧凑和舒适两种视图,小屏幕上也更容易浏览",
],
improvements: [
"命令行里的工作区命令可以直接使用列表中的 slug 或短 ID",
"智能体模型选择器移除了容易混淆的默认标签",
"自托管和智能体文档修正了跳转链接,并补全了配置说明",
"保留路径的保护更完整,避免特殊地址被误用为工作区地址",
"智能体运行指引收紧了 Issue 状态写入规则,只把后续运行确实需要的信息留下",
],
fixes: [
"Codex 运行卡住后自动重试时,会从全新会话开始,不再继续卡住的现场",
"Claude Code 的用量统计在运行结束时返回时也能被正确记录",
"实时更新遇到异常消息时会安全跳过,异常内容过长也不会撑大日志",
"创建 Issue 时,如果标题为空,按钮会说明为什么暂时不能创建",
"运行环境在任务领取响应丢失时,可以重新找回尚未开始的任务",
],
},
{
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",

View File

@@ -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",
};

View File

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

View File

@@ -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.2hourly 表的 `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/90draw 扫描足够快,没有必要从 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 100104
| 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 rollupIANA 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-userPreferences不做 per-view picker |
| hour-of-day heatmap tz | viewer tz不再用机器物理 tz |

View File

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

View File

@@ -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,109 +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" },
);
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"]).toBeUndefined();
});
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: "" });
});
it("deleteCloudRuntimeNode sends DELETE with JSON body containing node id", async () => {
const fetchMock = vi.fn().mockResolvedValueOnce(
new Response(null, { status: 204 }),
);
vi.stubGlobal("fetch", fetchMock);
const client = new ApiClient("https://api.example.test");
await client.deleteCloudRuntimeNode("node-abc-123");
expect(fetchMock).toHaveBeenCalledTimes(1);
const [url, opts] = fetchMock.mock.calls[0]!;
expect(url).toBe("https://api.example.test/api/cloud-runtime/nodes");
expect(opts).toMatchObject({
method: "DELETE",
body: JSON.stringify({ id: "node-abc-123" }),
});
expect((opts.headers as Record<string, string>)["Content-Type"]).toBe(
"application/json",
);
});
describe("getAttachment", () => {
it("returns the parsed attachment for a well-formed response", async () => {
vi.stubGlobal(

View File

@@ -23,6 +23,9 @@ import type {
AgentRunCount,
AgentRuntime,
InboxItem,
InboxFilterScope,
InboxScopeCounts,
InboxResourceAvailability,
IssueSubscriber,
Comment,
Reaction,
@@ -101,11 +104,6 @@ import type {
SquadMemberStatusListResponse,
} from "../types";
import type { OnboardingCompletionPath } from "../onboarding/types";
import type {
CloudRuntimeNode,
CreateCloudRuntimeNodeRequest,
ListCloudRuntimeNodesParams,
} from "../runtimes/cloud-runtime";
import { type Logger, noopLogger } from "../logger";
import { createRequestId } from "../utils";
import { getCurrentSlug } from "../platform/workspace-storage";
@@ -116,8 +114,6 @@ import {
AttachmentResponseSchema,
ChildIssuesResponseSchema,
CommentsListSchema,
CloudRuntimeNodeListSchema,
CloudRuntimeNodeSchema,
CreateAgentFromTemplateResponseSchema,
DashboardAgentRunTimeListSchema,
DashboardRunTimeDailyListSchema,
@@ -126,14 +122,11 @@ 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,
@@ -141,14 +134,9 @@ import {
ListWebhookDeliveriesResponseSchema,
OnboardingNoRuntimeBootstrapResponseSchema,
OnboardingRuntimeBootstrapResponseSchema,
RuntimeHourlyActivityListSchema,
RuntimeUsageByAgentListSchema,
RuntimeUsageByHourListSchema,
RuntimeUsageListSchema,
SquadMemberStatusListResponseSchema,
SubscribersListSchema,
TimelineEntriesSchema,
UserSchema,
WebhookDeliveryResponseSchema,
} from "./schemas";
@@ -201,6 +189,60 @@ const EMPTY_ONBOARDING_NO_RUNTIME_BOOTSTRAP_RESPONSE:
issue_id: "",
};
// Serialize the inbox `scope` filter into a `?scope=me,my_agent` query
// fragment. The server rejects empty arrays, so callers must skip the bulk
// request entirely when no chip is selected (RFC v3 §E.1, mode=empty).
function inboxScopeQuery(scope?: InboxFilterScope[] | null): string {
if (!scope || scope.length === 0) return "";
return `?scope=${encodeURIComponent(scope.join(","))}`;
}
// --- 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;
}
export interface ImportStarterWelcomeIssueTemplate {
title: string;
description: string;
/** Defaults to "high" on server when empty. */
priority: string;
}
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;
readonly statusText: string;
@@ -396,23 +438,17 @@ 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: {
@@ -456,35 +492,54 @@ export class ApiClient {
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,11 +555,7 @@ export class ApiClient {
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?.metadata && Object.keys(params.metadata).length > 0) {
search.set("metadata", JSON.stringify(params.metadata));
}
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, {
@@ -525,9 +576,6 @@ export class ApiClient {
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.metadata && Object.keys(params.metadata).length > 0) {
search.set("metadata", JSON.stringify(params.metadata));
}
if (params.assignee_filters?.length) {
search.set("assignee_filters", params.assignee_filters.map((f) => `${f.type}:${f.id}`).join(","));
}
@@ -834,56 +882,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,
): Promise<CloudRuntimeNode> {
const res = await this.fetchRaw("/api/cloud-runtime/nodes", {
method: "POST",
body: JSON.stringify(data),
extraHeaders: { "Content-Type": "application/json" },
});
const raw = await res.json() as unknown;
return parseWithFallback(
raw,
CloudRuntimeNodeSchema,
EMPTY_CLOUD_RUNTIME_NODE,
{ endpoint: "POST /api/cloud-runtime/nodes" },
);
}
async deleteCloudRuntimeNode(nodeId: string): Promise<void> {
await this.fetchRaw("/api/cloud-runtime/nodes", {
method: "DELETE",
body: JSON.stringify({ id: nodeId }),
extraHeaders: { "Content-Type": "application/json" },
});
}
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",
@@ -891,77 +896,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}`);
}
// ---------------------------------------------------------------------------
@@ -972,12 +932,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,
@@ -988,12 +947,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,
@@ -1004,14 +962,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,
@@ -1022,14 +977,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,
@@ -1147,16 +1099,15 @@ 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 } : {}),
});
}
// Inbox
async listInbox(): Promise<InboxItem[]> {
return this.fetch("/api/inbox");
async listInbox(scope?: InboxFilterScope[]): Promise<InboxItem[]> {
return this.fetch(`/api/inbox${inboxScopeQuery(scope)}`);
}
async markInboxRead(id: string): Promise<InboxItem> {
@@ -1171,20 +1122,28 @@ export class ApiClient {
return this.fetch("/api/inbox/unread-count");
}
async markAllInboxRead(): Promise<{ count: number }> {
return this.fetch("/api/inbox/mark-all-read", { method: "POST" });
async getInboxScopeCounts(): Promise<InboxScopeCounts> {
return this.fetch("/api/inbox/scope-counts");
}
async archiveAllInbox(): Promise<{ count: number }> {
return this.fetch("/api/inbox/archive-all", { method: "POST" });
async getInboxResourceAvailability(): Promise<InboxResourceAvailability> {
return this.fetch("/api/inbox/resource-availability");
}
async archiveAllReadInbox(): Promise<{ count: number }> {
return this.fetch("/api/inbox/archive-all-read", { method: "POST" });
async markAllInboxRead(scope?: InboxFilterScope[]): Promise<{ count: number }> {
return this.fetch(`/api/inbox/mark-all-read${inboxScopeQuery(scope)}`, { method: "POST" });
}
async archiveCompletedInbox(): Promise<{ count: number }> {
return this.fetch("/api/inbox/archive-completed", { method: "POST" });
async archiveAllInbox(scope?: InboxFilterScope[]): Promise<{ count: number }> {
return this.fetch(`/api/inbox/archive-all${inboxScopeQuery(scope)}`, { method: "POST" });
}
async archiveAllReadInbox(scope?: InboxFilterScope[]): Promise<{ count: number }> {
return this.fetch(`/api/inbox/archive-all-read${inboxScopeQuery(scope)}`, { method: "POST" });
}
async archiveCompletedInbox(scope?: InboxFilterScope[]): Promise<{ count: number }> {
return this.fetch(`/api/inbox/archive-completed${inboxScopeQuery(scope)}`, { method: "POST" });
}
// Notification preferences

View File

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

View File

@@ -1,76 +1,5 @@
import { describe, expect, it } from "vitest";
import {
DashboardAgentRunTimeListSchema,
DashboardUsageByAgentListSchema,
DashboardUsageDailyListSchema,
DuplicateIssueErrorBodySchema,
EMPTY_USER,
ListIssuesResponseSchema,
RuntimeHourlyActivityListSchema,
RuntimeUsageByAgentListSchema,
RuntimeUsageByHourListSchema,
RuntimeUsageListSchema,
UserSchema,
} from "./schemas";
import { parseWithFallback } from "./schema";
const baseIssue = {
id: "11111111-1111-1111-1111-111111111111",
workspace_id: "ws-1",
number: 1,
identifier: "MUL-1",
title: "Test",
description: null,
status: "todo",
priority: "medium",
assignee_type: null,
assignee_id: null,
creator_type: "member",
creator_id: "user-1",
parent_issue_id: null,
project_id: null,
position: 0,
start_date: null,
due_date: null,
metadata: {},
created_at: "2026-01-01T00:00:00Z",
updated_at: "2026-01-01T00:00:00Z",
};
describe("IssueSchema (via ListIssuesResponseSchema)", () => {
it("accepts a primitive metadata KV map", () => {
const payload = {
issues: [
{
...baseIssue,
metadata: { pipeline_status: "waiting", pr_number: 3, is_blocked: true },
},
],
total: 1,
};
const parsed = ListIssuesResponseSchema.parse(payload);
expect(parsed.issues[0]?.metadata).toEqual({
pipeline_status: "waiting",
pr_number: 3,
is_blocked: true,
});
});
it("defaults metadata to {} when the server omits it (older backend)", () => {
const { metadata: _omit, ...issueWithoutMetadata } = baseIssue;
const payload = { issues: [issueWithoutMetadata], total: 1 };
const parsed = ListIssuesResponseSchema.parse(payload);
expect(parsed.issues[0]?.metadata).toEqual({});
});
it("rejects metadata with non-primitive values (nested object)", () => {
const payload = {
issues: [{ ...baseIssue, metadata: { nested: { x: 1 } } }],
total: 1,
};
expect(ListIssuesResponseSchema.safeParse(payload).success).toBe(false);
});
});
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
@@ -120,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");
});
});

View File

@@ -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
@@ -136,11 +134,6 @@ export const CommentSchema = z.object({
export const CommentsListSchema = z.array(CommentSchema);
// Metadata is primitive-only by API/DB contract. Stay lenient on shape:
// unknown keys land as `unknown` to a caller, but the field itself defaults
// to {} so consumers never need to nil-guard `issue.metadata`.
const IssueMetadataSchema = z.record(z.string(), z.union([z.string(), z.number(), z.boolean()])).default({});
const IssueSchema = z.object({
id: z.string(),
workspace_id: z.string(),
@@ -159,7 +152,6 @@ const IssueSchema = z.object({
position: z.number(),
start_date: z.string().nullable(),
due_date: z.string().nullable(),
metadata: IssueMetadataSchema,
reactions: z.array(z.unknown()).optional(),
labels: z.array(z.unknown()).optional(),
created_at: z.string(),
@@ -217,56 +209,19 @@ export const OnboardingNoRuntimeBootstrapResponseSchema = z.object({
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),
@@ -277,8 +232,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),
@@ -289,7 +244,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),
@@ -298,7 +253,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),
@@ -306,57 +261,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
@@ -579,44 +483,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: "",
};

View File

@@ -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,
);
});
});

View File

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

View File

@@ -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);
});
});

View File

@@ -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,
});
}

View File

@@ -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,
});

View File

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

View File

@@ -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 });
});
});

View File

@@ -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,
};
}

View File

@@ -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]);
}

View File

@@ -1,3 +1,4 @@
export * from "./queries";
export * from "./mutations";
export * from "./ws-updaters";
export * from "./stores";

View File

@@ -2,7 +2,7 @@ import { useMutation, useQueryClient } from "@tanstack/react-query";
import { api } from "../api";
import { inboxKeys } from "./queries";
import { useWorkspaceId } from "../hooks";
import type { InboxItem } from "../types";
import type { InboxItem, InboxFilterScope } from "../types";
export function useMarkInboxRead() {
const qc = useQueryClient();
@@ -22,6 +22,7 @@ export function useMarkInboxRead() {
},
onSettled: () => {
qc.invalidateQueries({ queryKey: inboxKeys.list(wsId) });
qc.invalidateQueries({ queryKey: inboxKeys.scopeCounts(wsId) });
},
});
}
@@ -51,21 +52,27 @@ export function useArchiveInbox() {
},
onSettled: () => {
qc.invalidateQueries({ queryKey: inboxKeys.list(wsId) });
qc.invalidateQueries({ queryKey: inboxKeys.scopeCounts(wsId) });
},
});
}
// All bulk mutations accept an optional `scope` parameter. When the caller
// is in mode=all (RFC v3 §E.1) it should pass undefined; when in mode=subset
// it should pass the resolved chip subset; in mode=empty the button is
// disabled and these mutations should not fire.
export function useMarkAllInboxRead() {
const qc = useQueryClient();
const wsId = useWorkspaceId();
return useMutation({
mutationFn: () => api.markAllInboxRead(),
onMutate: async () => {
mutationFn: (scope?: InboxFilterScope[]) => api.markAllInboxRead(scope),
onMutate: async (scope) => {
await qc.cancelQueries({ queryKey: inboxKeys.list(wsId) });
const prev = qc.getQueryData<InboxItem[]>(inboxKeys.list(wsId));
const inScope = scopeMatcher(scope);
qc.setQueryData<InboxItem[]>(inboxKeys.list(wsId), (old) =>
old?.map((item) =>
!item.archived ? { ...item, read: true } : item,
!item.archived && inScope(item) ? { ...item, read: true } : item,
),
);
return { prev };
@@ -75,6 +82,7 @@ export function useMarkAllInboxRead() {
},
onSettled: () => {
qc.invalidateQueries({ queryKey: inboxKeys.list(wsId) });
qc.invalidateQueries({ queryKey: inboxKeys.scopeCounts(wsId) });
},
});
}
@@ -83,9 +91,10 @@ export function useArchiveAllInbox() {
const qc = useQueryClient();
const wsId = useWorkspaceId();
return useMutation({
mutationFn: () => api.archiveAllInbox(),
mutationFn: (scope?: InboxFilterScope[]) => api.archiveAllInbox(scope),
onSettled: () => {
qc.invalidateQueries({ queryKey: inboxKeys.list(wsId) });
qc.invalidateQueries({ queryKey: inboxKeys.scopeCounts(wsId) });
},
});
}
@@ -94,9 +103,10 @@ export function useArchiveAllReadInbox() {
const qc = useQueryClient();
const wsId = useWorkspaceId();
return useMutation({
mutationFn: () => api.archiveAllReadInbox(),
mutationFn: (scope?: InboxFilterScope[]) => api.archiveAllReadInbox(scope),
onSettled: () => {
qc.invalidateQueries({ queryKey: inboxKeys.list(wsId) });
qc.invalidateQueries({ queryKey: inboxKeys.scopeCounts(wsId) });
},
});
}
@@ -105,9 +115,21 @@ export function useArchiveCompletedInbox() {
const qc = useQueryClient();
const wsId = useWorkspaceId();
return useMutation({
mutationFn: () => api.archiveCompletedInbox(),
mutationFn: (scope?: InboxFilterScope[]) => api.archiveCompletedInbox(scope),
onSettled: () => {
qc.invalidateQueries({ queryKey: inboxKeys.list(wsId) });
qc.invalidateQueries({ queryKey: inboxKeys.scopeCounts(wsId) });
},
});
}
// True when the inbox item belongs to the user-selected scope subset, or
// when no scope was passed (= mark/archive everything).
function scopeMatcher(scope?: InboxFilterScope[]) {
if (!scope || scope.length === 0) return (_item: InboxItem) => true;
const set = new Set(scope);
return (item: InboxItem) => {
const s = item.assignee_scope;
return s != null && (set as Set<string>).has(s);
};
}

View File

@@ -1,19 +1,49 @@
import { queryOptions, useQuery } from "@tanstack/react-query";
import { api } from "../api";
import type { InboxItem } from "../types";
import type {
InboxItem,
InboxFilterScope,
InboxScopeCounts,
InboxResourceAvailability,
} from "../types";
export const inboxKeys = {
all: (wsId: string) => ["inbox", wsId] as const,
// The list key is intentionally a single key per workspace — the scope
// filter is applied client-side on top of the full cached list (RFC v3
// §E selector), so we don't fragment the cache by scope. When the user
// changes chips we just re-derive from the same query.
list: (wsId: string) => [...inboxKeys.all(wsId), "list"] as const,
scopeCounts: (wsId: string) =>
[...inboxKeys.all(wsId), "scope-counts"] as const,
resourceAvailability: (wsId: string) =>
[...inboxKeys.all(wsId), "resource-availability"] as const,
};
export function inboxListOptions(wsId: string) {
return queryOptions({
queryKey: inboxKeys.list(wsId),
// Always fetch the full list (no scope param). The chip filter runs in
// the selector — that way the badge counts and the dedupe logic always
// operate on the complete picture, and toggling a chip is instant.
queryFn: () => api.listInbox(),
});
}
export function inboxScopeCountsOptions(wsId: string) {
return queryOptions({
queryKey: inboxKeys.scopeCounts(wsId),
queryFn: () => api.getInboxScopeCounts(),
});
}
export function inboxResourceAvailabilityOptions(wsId: string) {
return queryOptions({
queryKey: inboxKeys.resourceAvailability(wsId),
queryFn: () => api.getInboxResourceAvailability(),
});
}
/**
* Unread inbox count for the given workspace, aligned with what the inbox
* list UI renders: archived items excluded, then deduplicated by issue so a
@@ -57,3 +87,29 @@ export function deduplicateInboxItems(items: InboxItem[]): InboxItem[] {
new Date(b.created_at).getTime() - new Date(a.created_at).getTime(),
);
}
/**
* Narrow a deduplicated inbox list to the user-selected chips. Applies the
* RFC v3 §E selector rules: a strict subset of {me, my_agent, my_squad}
* keeps only items tagged with one of those scopes (other/none are dropped);
* a null filter (= "all" mode) passes everything through unchanged.
*
* `null` is the no-op signal. Pass `null` whenever you don't want to filter,
* including the empty-mode case where the caller is also expected to render
* an empty state instead of calling this.
*/
export function filterInboxByScope(
items: InboxItem[],
scopes: InboxFilterScope[] | null,
): InboxItem[] {
if (!scopes) return items;
const set = new Set(scopes);
return items.filter((i) => {
const s = i.assignee_scope;
return s != null && (set as Set<string>).has(s);
});
}
// Re-exports — kept for backwards compatibility with code importing the
// inbox scope-count / availability response shapes from this module.
export type { InboxScopeCounts, InboxResourceAvailability };

View File

@@ -0,0 +1,83 @@
"use client";
import { create } from "zustand";
import { createJSONStorage, persist } from "zustand/middleware";
import { createWorkspaceAwareStorage, registerForWorkspaceRehydration } from "../../platform/workspace-storage";
import { defaultStorage } from "../../platform/storage";
import type { InboxFilterScope } from "../../types";
// All three assignment chips, in stable display order. Used both for the
// "default = all selected" initial state and for callers that need to render
// chips deterministically.
export const INBOX_FILTER_SCOPES: readonly InboxFilterScope[] = [
"me",
"my_agent",
"my_squad",
] as const;
interface InboxScopeState {
// Persisted selection. The default is the full set so a freshly installed
// app shows every notification — see RFC v3 §E.1 mode=all.
selected: InboxFilterScope[];
toggle: (scope: InboxFilterScope) => void;
set: (scopes: InboxFilterScope[]) => void;
selectAll: () => void;
clear: () => void;
}
export const useInboxScopeStore = create<InboxScopeState>()(
persist(
(set) => ({
selected: [...INBOX_FILTER_SCOPES],
toggle: (scope) =>
set((state) => ({
selected: state.selected.includes(scope)
? state.selected.filter((s) => s !== scope)
: [...state.selected, scope],
})),
set: (scopes) => set({ selected: scopes }),
selectAll: () => set({ selected: [...INBOX_FILTER_SCOPES] }),
clear: () => set({ selected: [] }),
}),
{
name: "multica_inbox_scope",
storage: createJSONStorage(() => createWorkspaceAwareStorage(defaultStorage)),
},
),
);
registerForWorkspaceRehydration(() => useInboxScopeStore.persist.rehydrate());
// Resolved filter mode. Matches the three-state algorithm in RFC v3 §E.1:
// - all: 3 selected → no `scope` is sent; selector keeps me/my_agent/my_squad/other/none
// - subset: 1-2 selected → `scope=...` is sent; selector filters to the subset
// - empty: 0 selected → don't request; show empty state, bulk disabled
export type InboxFilterMode = "all" | "subset" | "empty";
export interface InboxFilterResolution {
mode: InboxFilterMode;
// Scopes to send on the wire. `null` for mode="all" (omit param entirely),
// a string[] for mode="subset", `[]` for mode="empty".
scopes: InboxFilterScope[] | null;
}
export function resolveInboxFilter(
selected: InboxFilterScope[],
): InboxFilterResolution {
// Dedupe + restrict to the three valid chip values. "other" / "none" are
// server-internal buckets and must never appear on the wire.
const unique = new Set<InboxFilterScope>();
for (const s of selected) {
if (s === "me" || s === "my_agent" || s === "my_squad") unique.add(s);
}
if (unique.size === INBOX_FILTER_SCOPES.length) {
return { mode: "all", scopes: null };
}
if (unique.size === 0) {
return { mode: "empty", scopes: [] };
}
return {
mode: "subset",
scopes: INBOX_FILTER_SCOPES.filter((s) => unique.has(s)),
};
}

View File

@@ -0,0 +1,7 @@
export {
useInboxScopeStore,
resolveInboxFilter,
INBOX_FILTER_SCOPES,
type InboxFilterMode,
type InboxFilterResolution,
} from "./inbox-scope-store";

View File

@@ -10,6 +10,19 @@ export function onInboxNew(
// Use invalidateQueries instead of setQueryData — triggers a refetch that
// reliably notifies all observers. The inbox list is small so this is cheap.
qc.invalidateQueries({ queryKey: inboxKeys.list(wsId) });
qc.invalidateQueries({ queryKey: inboxKeys.scopeCounts(wsId) });
}
// `inbox:batch-read` and `inbox:batch-archived` are emitted when the user
// runs a bulk endpoint (mark-all-read / archive-*). They can carry a `scope`
// filter (RFC v3 §C.5) and `inbox:batch-archived` additionally carries an
// `operation` (RFC v4 §1). We currently fall back to a generic invalidate
// for both — precise cache updates per operation+scope are a documented
// follow-up: the payload contract is already in place, so the optimization
// is a frontend-only change later.
export function onInboxBatch(qc: QueryClient, wsId: string) {
qc.invalidateQueries({ queryKey: inboxKeys.list(wsId) });
qc.invalidateQueries({ queryKey: inboxKeys.scopeCounts(wsId) });
}
export function onInboxIssueStatusChanged(
@@ -27,7 +40,9 @@ export function onInboxIssueStatusChanged(
// Mirrors the DB-level ON DELETE CASCADE on inbox_item.issue_id: when an issue
// is deleted, all inbox items that referenced it are gone server-side, so drop
// them from the cache too.
// them from the cache too. Scope counts shift in lockstep with the pruned
// rows, so invalidate them here as well — otherwise the chip badge keeps
// counting an issue that no longer exists.
export function onInboxIssueDeleted(
qc: QueryClient,
wsId: string,
@@ -36,8 +51,14 @@ export function onInboxIssueDeleted(
qc.setQueryData<InboxItem[]>(inboxKeys.list(wsId), (old) =>
old?.filter((i) => i.issue_id !== issueId),
);
qc.invalidateQueries({ queryKey: inboxKeys.scopeCounts(wsId) });
}
// Generic single-item inbox invalidation (e.g. `inbox:archived`,
// `inbox:read`). The chip badge is derived from the same rows that just
// changed, so it has to be re-fetched alongside the list — otherwise the
// badge stays at the pre-change value until a hard refresh.
export function onInboxInvalidate(qc: QueryClient, wsId: string) {
qc.invalidateQueries({ queryKey: inboxKeys.list(wsId) });
qc.invalidateQueries({ queryKey: inboxKeys.scopeCounts(wsId) });
}

View File

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

View File

@@ -4,6 +4,7 @@ import { api } from "../api";
import {
issueKeys,
ISSUE_PAGE_SIZE,
PAGINATED_STATUSES,
type AssigneeGroupedIssuesFilter,
type MyIssuesFilter,
} from "./queries";
@@ -24,6 +25,7 @@ import {
pruneDeletedIssueFromParentChildrenCaches,
} from "./delete-cache";
import { useWorkspaceId } from "../hooks";
import { inboxKeys } from "../inbox/queries";
import { useRecentIssuesStore } from "./stores";
import type { GroupedIssuesResponse, Issue, IssueAssigneeGroup, IssueReaction, IssueStatus } from "../types";
import type {
@@ -103,6 +105,75 @@ export function useLoadMoreByStatus(
return { loadMore, hasMore, isLoading, total };
}
/**
* Drain every remaining paginated page across all statuses into the cache.
* Used by surfaces that can't paginate per-column (e.g. the Project Gantt
* view) and need the full project issue set up-front. Each iteration appends
* one ISSUE_PAGE_SIZE page per status that still has unfetched rows; loops
* until the cache totals match the server.
*/
export function useLoadAllRemaining(
myIssues?: { scope: string; filter: MyIssuesFilter },
) {
const qc = useQueryClient();
const wsId = useWorkspaceId();
const [isLoading, setIsLoading] = useState(false);
const queryKey = myIssues
? issueKeys.myList(wsId, myIssues.scope, myIssues.filter)
: issueKeys.list(wsId);
const loadAll = useCallback(async () => {
if (isLoading) return;
setIsLoading(true);
try {
// Round-trip the cache rather than caching `loaded` locally so a
// concurrent WS-driven update or another loadMore can't make us
// re-fetch an already-loaded page.
for (;;) {
const cache = qc.getQueryData<ListIssuesCache>(queryKey);
if (!cache) return;
const pending = PAGINATED_STATUSES.filter((status) => {
const bucket = cache.byStatus[status];
if (!bucket) return false;
return bucket.issues.length < bucket.total;
});
if (pending.length === 0) return;
const results = await Promise.all(
pending.map((status) =>
api
.listIssues({
status,
limit: ISSUE_PAGE_SIZE,
offset: cache.byStatus[status]!.issues.length,
...myIssues?.filter,
})
.then((res) => ({ status, res })),
),
);
qc.setQueryData<ListIssuesCache>(queryKey, (old) => {
if (!old) return old;
let next = old;
for (const { status, res } of results) {
const prev = getBucket(next, status);
const existingIds = new Set(prev.issues.map((i) => i.id));
const appended = res.issues.filter((i) => !existingIds.has(i.id));
next = setBucket(next, status, {
issues: [...prev.issues, ...appended],
total: res.total,
});
}
return next;
});
}
} finally {
setIsLoading(false);
}
}, [isLoading, qc, queryKey, myIssues?.filter]);
return { loadAll, isLoading };
}
export function useLoadMoreByAssigneeGroup(
group: Pick<IssueAssigneeGroup, "id" | "assignee_type" | "assignee_id">,
queryKey: QueryKey,
@@ -181,7 +252,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 +328,20 @@ 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) });
// Inbox rows carry a server-computed `assignee_scope` derived from
// the issue's assignee. Re-assigning the issue (member ↔ agent ↔
// squad ↔ none) shifts the row's chip bucket and the scope-count
// badge, so flush both whenever this mutation touched assignment.
// The WS handler also invalidates on the broadcast issue:updated;
// doing it here too lets the originating tab refresh without
// round-tripping through the server.
if (
Object.prototype.hasOwnProperty.call(vars, "assignee_id") ||
Object.prototype.hasOwnProperty.call(vars, "assignee_type")
) {
qc.invalidateQueries({ queryKey: inboxKeys.list(wsId) });
qc.invalidateQueries({ queryKey: inboxKeys.scopeCounts(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 +425,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);
},
});
@@ -398,11 +480,19 @@ export function useBatchUpdateIssues() {
}
}
},
onSettled: (_data, _err, _vars, ctx) => {
onSettled: (_data, _err, vars, ctx) => {
qc.invalidateQueries({ queryKey: issueKeys.list(wsId) });
qc.invalidateQueries({ queryKey: issueKeys.assigneeGroupsAll(wsId) });
qc.invalidateQueries({ queryKey: issueKeys.myAssigneeGroupsAll(wsId) });
qc.invalidateQueries({ queryKey: issueKeys.projectGanttAll(wsId) });
// Bulk reassignments shift `assignee_scope` across N rows — same
// reasoning as useUpdateIssue.
if (
Object.prototype.hasOwnProperty.call(vars.updates, "assignee_id") ||
Object.prototype.hasOwnProperty.call(vars.updates, "assignee_type")
) {
qc.invalidateQueries({ queryKey: inboxKeys.list(wsId) });
qc.invalidateQueries({ queryKey: inboxKeys.scopeCounts(wsId) });
}
if (ctx?.affectedParentIds && ctx.affectedParentIds.size > 0) {
for (const parentId of ctx.affectedParentIds) {
qc.invalidateQueries({
@@ -505,7 +595,6 @@ export function useBatchDeleteIssues() {
qc.invalidateQueries({ queryKey: issueKeys.list(wsId) });
qc.invalidateQueries({ queryKey: issueKeys.assigneeGroupsAll(wsId) });
qc.invalidateQueries({ queryKey: issueKeys.myAssigneeGroupsAll(wsId) });
qc.invalidateQueries({ queryKey: issueKeys.projectGanttAll(wsId) });
if (ctx?.parentIssueIds && ctx.parentIssueIds.size > 0) {
invalidateDeletedIssueParentCaches(qc, wsId, {
parentIssueIds: Array.from(ctx.parentIssueIds),

View File

@@ -1,132 +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: [],
metadata: {},
created_at: "2025-01-01T00:00:00Z",
updated_at: "2025-01-01T00:00:00Z",
};
}
// Type-only shim — only the methods the queries.ts code path under test calls.
function installFakeApi(listIssues: (params?: ListIssuesParams) => Promise<ListIssuesResponse>) {
setApiInstance({ listIssues } as unknown as ApiClient);
}
describe("projectGanttIssuesOptions", () => {
let qc: QueryClient;
beforeEach(() => {
qc = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
});
afterEach(() => {
qc.clear();
vi.restoreAllMocks();
});
it("returns the first page directly when it fits under PROJECT_GANTT_PAGE_LIMIT", async () => {
const listIssues = vi
.fn<(params?: ListIssuesParams) => Promise<ListIssuesResponse>>()
.mockResolvedValue({
issues: [makeIssue(1), makeIssue(2)],
total: 2,
});
installFakeApi(listIssues);
const data = await qc.fetchQuery(projectGanttIssuesOptions(WS_ID, PROJECT_ID));
expect(listIssues).toHaveBeenCalledTimes(1);
expect(listIssues).toHaveBeenCalledWith({
project_id: PROJECT_ID,
scheduled: true,
limit: PROJECT_GANTT_PAGE_LIMIT,
offset: 0,
});
expect(data).toHaveLength(2);
});
it("loops through pages until total is satisfied (no silent truncation)", async () => {
const total = PROJECT_GANTT_PAGE_LIMIT + 7;
const firstPage = Array.from({ length: PROJECT_GANTT_PAGE_LIMIT }, (_, i) =>
makeIssue(i),
);
const secondPage = Array.from({ length: 7 }, (_, i) =>
makeIssue(PROJECT_GANTT_PAGE_LIMIT + i),
);
const listIssues = vi
.fn<(params?: ListIssuesParams) => Promise<ListIssuesResponse>>()
.mockImplementation(async (params) => {
if (!params) throw new Error("expected params");
const offset = params.offset ?? 0;
if (offset === 0)
return { issues: firstPage, total };
if (offset === PROJECT_GANTT_PAGE_LIMIT)
return { issues: secondPage, total };
throw new Error(`unexpected offset ${offset}`);
});
installFakeApi(listIssues);
const data = await qc.fetchQuery(projectGanttIssuesOptions(WS_ID, PROJECT_ID));
expect(listIssues).toHaveBeenCalledTimes(2);
expect(data).toHaveLength(total);
});
it("stops looping when the server reports a smaller-than-limit page (safety net for total drift)", async () => {
// Server says `total` is huge but only ever returns short pages — the
// loop must terminate on the first short page to avoid an infinite fetch.
const listIssues = vi
.fn<(params?: ListIssuesParams) => Promise<ListIssuesResponse>>()
.mockResolvedValue({
issues: [makeIssue(1)],
total: PROJECT_GANTT_MAX_ISSUES,
});
installFakeApi(listIssues);
const data = await qc.fetchQuery(projectGanttIssuesOptions(WS_ID, PROJECT_ID));
expect(listIssues).toHaveBeenCalledTimes(1);
expect(data).toHaveLength(1);
});
it("uses the project-scoped Gantt cache key", () => {
const options = projectGanttIssuesOptions(WS_ID, PROJECT_ID);
expect(options.queryKey).toEqual(issueKeys.projectGantt(WS_ID, PROJECT_ID));
});
});

View File

@@ -28,17 +28,6 @@ export const issueKeys = {
scope: string,
filter: AssigneeGroupedIssuesFilter,
) => [...issueKeys.myAssigneeGroupsAll(wsId), scope, filter] as const,
/** All Project Gantt queries — prefix-match key for cross-project invalidation. */
projectGanttAll: (wsId: string) =>
[...issueKeys.all(wsId), "project-gantt"] as const,
/**
* Per-project Gantt issue list (scheduled-only). Uses its own cache key
* rather than reusing the bucketed `myList` cache so WS handlers and
* cache helpers don't have to special-case a non-bucketed shape under
* the `my` prefix.
*/
projectGantt: (wsId: string, projectId: string) =>
[...issueKeys.projectGanttAll(wsId), projectId] as const,
detail: (wsId: string, id: string) =>
[...issueKeys.all(wsId), "detail", id] as const,
children: (wsId: string, id: string) =>
@@ -90,6 +79,34 @@ export function flattenIssueBuckets(data: ListIssuesCache) {
return out;
}
export interface IssueListPagination {
loaded: number;
total: number;
hasMore: boolean;
}
/**
* Aggregate the bucketed cache totals so non-paginated consumers (e.g. the
* Gantt view, which doesn't have a per-status load-more affordance) can tell
* whether the cache is missing pages and warn the user instead of silently
* rendering an incomplete schedule.
*/
export function summarizeIssueListPagination(
data: ListIssuesCache | undefined,
): IssueListPagination {
if (!data) return { loaded: 0, total: 0, hasMore: false };
let loaded = 0;
let total = 0;
for (const status of PAGINATED_STATUSES) {
const bucket = data.byStatus[status];
if (bucket) {
loaded += bucket.issues.length;
total += bucket.total;
}
}
return { loaded, total, hasMore: loaded < total };
}
async function fetchFirstPages(filter: MyIssuesFilter = {}): Promise<ListIssuesCache> {
const responses = await Promise.all(
PAGINATED_STATUSES.map((status) =>
@@ -154,55 +171,20 @@ 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`.
* Same cache entry as {@link myIssueListOptions} (shared queryKey + queryFn —
* TanStack Query dedupes), but `select` derives a pagination summary instead
* of the flat issue list. Use this alongside the list query when a consumer
* needs to know how many issues live behind unfetched pages.
*/
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) {
export function myIssueListPaginationOptions(
wsId: string,
scope: string,
filter: MyIssuesFilter,
) {
return queryOptions({
queryKey: issueKeys.projectGantt(wsId, projectId),
queryFn: () => fetchProjectGanttIssues(projectId),
queryKey: issueKeys.myList(wsId, scope, filter),
queryFn: () => fetchFirstPages(filter),
select: summarizeIssueListPagination,
});
}

View File

@@ -6,13 +6,7 @@ import {
agentTaskSnapshotKeys,
agentTasksKeys,
} from "../agents/queries";
import {
onIssueCreated,
onIssueDeleted,
onIssueLabelsChanged,
onIssueMetadataChanged,
onIssueUpdated,
} from "./ws-updaters";
import { onIssueDeleted, onIssueLabelsChanged } from "./ws-updaters";
import { issueKeys } from "./queries";
import { labelKeys } from "../labels/queries";
import type {
@@ -72,7 +66,6 @@ const baseIssue: Issue = {
position: 0,
start_date: null,
due_date: null,
metadata: {},
labels: [labelA],
created_at: "2025-01-01T00:00:00Z",
updated_at: "2025-01-01T00:00:00Z",
@@ -158,62 +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("onIssueMetadataChanged", () => {
let qc: QueryClient;
beforeEach(() => {
qc = new QueryClient();
});
it("replaces metadata in both detail and list caches (no merge)", () => {
qc.setQueryData<Issue>(issueKeys.detail(WS_ID, ISSUE_ID), {
...baseIssue,
metadata: { pr_number: 1, stale: "yes" },
});
qc.setQueryData<ListIssuesCache>(issueKeys.list(WS_ID), {
byStatus: {
todo: {
issues: [{ ...baseIssue, metadata: { pr_number: 1 } }],
total: 1,
},
},
});
onIssueMetadataChanged(qc, WS_ID, ISSUE_ID, { pr_number: 2 });
const detail = qc.getQueryData<Issue>(issueKeys.detail(WS_ID, ISSUE_ID));
expect(detail?.metadata).toEqual({ pr_number: 2 });
const list = qc.getQueryData<ListIssuesCache>(issueKeys.list(WS_ID));
expect(list?.byStatus.todo?.issues[0]?.metadata).toEqual({ pr_number: 2 });
});
it("leaves untouched caches as undefined (no spurious writes)", () => {
onIssueMetadataChanged(qc, WS_ID, ISSUE_ID, { foo: "bar" });
expect(qc.getQueryData(issueKeys.detail(WS_ID, ISSUE_ID))).toBeUndefined();
expect(qc.getQueryData(issueKeys.list(WS_ID))).toBeUndefined();
});
});
describe("onIssueDeleted", () => {
@@ -455,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));
});
});

View File

@@ -7,7 +7,7 @@ import {
patchIssueInBuckets,
} from "./cache-helpers";
import { cleanupDeletedIssueCaches } from "./delete-cache";
import type { Issue, IssueLabelsResponse, IssueMetadata, Label } from "../types";
import type { Issue, IssueLabelsResponse, Label } from "../types";
import type { ListIssuesCache } from "../types";
export function onIssueCreated(
@@ -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,49 +103,11 @@ 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) });
}
/**
* Apply a metadata snapshot to the issue detail + list + my-issues caches.
* The server emits this whenever a single key is set or deleted, so the
* payload is always the FULL post-mutation map — we replace, not merge.
*
* Used for the read-only metadata strip in issue detail. Updates that arrive
* while no view is mounted still keep the caches accurate so the next render
* shows the latest state without a refetch.
*/
export function onIssueMetadataChanged(
qc: QueryClient,
wsId: string,
issueId: string,
metadata: IssueMetadata,
) {
qc.setQueryData<ListIssuesCache>(issueKeys.list(wsId), (old) =>
old ? patchIssueInBuckets(old, issueId, { metadata }) : old,
);
qc.setQueryData<Issue>(issueKeys.detail(wsId, issueId), (old) =>
old ? { ...old, metadata } : old,
);
qc.invalidateQueries({ queryKey: issueKeys.myAll(wsId) });
}
export function onIssueDeleted(
qc: QueryClient,
wsId: string,

View File

@@ -55,8 +55,8 @@ export async function completeOnboarding(
/**
* Runtime-connected onboarding path. The server creates or reuses the
* default Multica Helper agent and the single onboarding issue, then
* marks onboarding complete.
* default Multica Helper agent and the single onboarding issue, marks
* onboarding complete, and suppresses the older starter-content prompt.
*/
export async function bootstrapRuntimeOnboarding(
workspaceId: string,
@@ -72,7 +72,8 @@ export async function bootstrapRuntimeOnboarding(
/**
* Runtime-skipped onboarding path. The server creates or reuses one
* install-runtime onboarding issue and marks onboarding complete.
* self-serve onboarding issue, marks onboarding complete, and suppresses
* the older starter-content prompt so the user is not flooded with tasks.
*/
export async function bootstrapNoRuntimeOnboarding(
workspaceId: string,

View File

@@ -37,6 +37,8 @@
"./inbox/queries": "./inbox/queries.ts",
"./inbox/mutations": "./inbox/mutations.ts",
"./inbox/ws-updaters": "./inbox/ws-updaters.ts",
"./inbox/stores": "./inbox/stores/index.ts",
"./inbox/stores/*": "./inbox/stores/*.ts",
"./notification-preferences": "./notification-preferences/index.ts",
"./notification-preferences/queries": "./notification-preferences/queries.ts",
"./notification-preferences/mutations": "./notification-preferences/mutations.ts",

View File

@@ -1,30 +0,0 @@
import { describe, expect, it } from "vitest";
import { RESERVED_SLUGS, isReservedSlug } from "./reserved-slugs";
describe("reserved slugs", () => {
it("returns true for a known reserved slug", () => {
expect(isReservedSlug("login")).toBe(true);
});
it("returns false for an unreserved slug", () => {
expect(isReservedSlug("my-cool-workspace")).toBe(false);
});
it("returns false for an empty slug", () => {
expect(isReservedSlug("")).toBe(false);
});
it("exposes a non-empty reserved slug set", () => {
expect(RESERVED_SLUGS.size).toBeGreaterThan(0);
});
it("keeps the set and predicate consistent", () => {
for (const slug of RESERVED_SLUGS) {
expect(isReservedSlug(slug)).toBe(true);
}
});
it("matches slugs case-sensitively", () => {
expect(isReservedSlug("Login")).toBe(false);
});
});

View File

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

View File

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

View File

@@ -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();
});
});

View File

@@ -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());

View File

@@ -26,9 +26,8 @@ import {
onIssueUpdated,
onIssueDeleted,
onIssueLabelsChanged,
onIssueMetadataChanged,
} from "../issues/ws-updaters";
import { onInboxNew, onInboxInvalidate, onInboxIssueStatusChanged, onInboxIssueDeleted } from "../inbox/ws-updaters";
import { onInboxNew, onInboxInvalidate, onInboxIssueStatusChanged, onInboxIssueDeleted, onInboxBatch } from "../inbox/ws-updaters";
import { inboxKeys } from "../inbox/queries";
import { notificationPreferenceOptions } from "../notification-preferences/queries";
import { workspaceKeys, workspaceListOptions } from "../workspace/queries";
@@ -45,7 +44,6 @@ import type {
IssueCreatedPayload,
IssueDeletedPayload,
IssueLabelsChangedPayload,
IssueMetadataChangedPayload,
InboxNewPayload,
CommentCreatedPayload,
CommentUpdatedPayload,
@@ -219,11 +217,22 @@ export function useRealtimeSync(
// the per-squad members-status cache. Prefix-matches both the
// squad list and every squadMemberStatus query.
qc.invalidateQueries({ queryKey: workspaceKeys.squads(wsId) });
// Creating/deleting the user's first owned agent flips
// `has_my_agent`, which gates the "my agent" chip's
// disabled-but-selected state. Refresh the resource-availability
// probe so the chip un-greys (or greys) on the first relationship
// change instead of waiting for reload.
qc.invalidateQueries({ queryKey: inboxKeys.resourceAvailability(wsId) });
}
},
member: () => {
const wsId = getCurrentWsId();
if (wsId) qc.invalidateQueries({ queryKey: workspaceKeys.members(wsId) });
if (wsId) {
qc.invalidateQueries({ queryKey: workspaceKeys.members(wsId) });
// Member adds/removes can flip `has_my_squad` (user joining or
// leaving a squad as a human member). Mirror the agent handler.
qc.invalidateQueries({ queryKey: inboxKeys.resourceAvailability(wsId) });
}
},
// workspace:updated is handled by the specific handler below
// (compares prefixes to decide whether to also invalidate issues).
@@ -247,6 +256,10 @@ export function useRealtimeSync(
qc.invalidateQueries({ queryKey: workspaceKeys.squads(wsId) });
// squad:deleted triggers assignee transfer — refresh issues too.
qc.invalidateQueries({ queryKey: issueKeys.all(wsId) });
// Creating/deleting a squad the user is involved in flips
// `has_my_squad`. Refresh resource-availability so the
// "my squad" chip's disabled state reacts in realtime.
qc.invalidateQueries({ queryKey: inboxKeys.resourceAvailability(wsId) });
}
},
label: () => {
@@ -343,7 +356,8 @@ 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", "issue_metadata:changed", "inbox:new",
"issue:updated", "issue:created", "issue:deleted", "issue_labels:changed", "inbox:new",
"inbox:batch-read", "inbox:batch-archived",
"comment:created", "comment:updated", "comment:deleted",
"comment:resolved", "comment:unresolved",
"activity:created",
@@ -387,6 +401,13 @@ export function useRealtimeSync(
if (issue.status) {
onInboxIssueStatusChanged(qc, wsId, issue.id, issue.status);
}
// The inbox row's `assignee_scope` is derived from the issue's
// assignee, so any issue:updated event may have shifted it (the
// payload doesn't tell us which fields changed). Invalidate the
// inbox list and scope counts so chip filtering, chip badges, and
// scope-targeted bulk actions reflect the new scope without
// requiring a full reload.
onInboxInvalidate(qc, wsId);
}
});
@@ -414,13 +435,6 @@ export function useRealtimeSync(
if (wsId) onIssueLabelsChanged(qc, wsId, issue_id, labels ?? []);
});
const unsubIssueMetadataChanged = ws.on("issue_metadata:changed", (p) => {
const { issue_id, metadata } = p as IssueMetadataChangedPayload;
if (!issue_id) return;
const wsId = getCurrentWsId();
if (wsId) onIssueMetadataChanged(qc, wsId, issue_id, metadata ?? {});
});
const unsubInboxNew = ws.on("inbox:new", async (p) => {
const { item } = p as InboxNewPayload;
if (!item) return;
@@ -479,6 +493,21 @@ export function useRealtimeSync(
});
});
// Bulk mark-all-read / archive-* on another device — refresh this device's
// inbox so the change appears. The payload carries `scope` (and for
// archived, `operation`) per RFC v3 §C.5 / v4 §1; precise cache updates
// off those fields are a documented follow-up — invalidate is the safe
// baseline today.
const unsubInboxBatchRead = ws.on("inbox:batch-read", () => {
const wsId = getCurrentWsId();
if (wsId) onInboxBatch(qc, wsId);
});
const unsubInboxBatchArchived = ws.on("inbox:batch-archived", () => {
const wsId = getCurrentWsId();
if (wsId) onInboxBatch(qc, wsId);
});
// --- Timeline event handlers (global fallback) ---
// These events are also handled granularly by useIssueTimeline when
// IssueDetail is mounted. This global handler exists to mark the
@@ -887,8 +916,9 @@ export function useRealtimeSync(
unsubIssueCreated();
unsubIssueDeleted();
unsubIssueLabelsChanged();
unsubIssueMetadataChanged();
unsubInboxNew();
unsubInboxBatchRead();
unsubInboxBatchArchived();
unsubCommentCreated();
unsubCommentUpdated();
unsubCommentDeleted();

View File

@@ -1,91 +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 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: CreateCloudRuntimeNodeRequest) =>
api.createCloudRuntimeNode(data),
onSettled: () => {
qc.invalidateQueries({ queryKey: cloudRuntimeKeys.all(wsId) });
},
});
}
export function useDeleteCloudRuntimeNode(wsId: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: (nodeId: string) => api.deleteCloudRuntimeNode(nodeId),
onSettled: () => {
qc.invalidateQueries({ queryKey: cloudRuntimeKeys.all(wsId) });
},
});
}

View File

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

View File

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

View File

@@ -12,8 +12,12 @@ export function useDeleteRuntime(wsId: string) {
});
}
// useUpdateRuntime patches editable fields on a runtime (visibility).
// Invalidates the runtime list so the picker disabled-state recomputes.
// useUpdateRuntime patches editable fields on a runtime (timezone, visibility).
// Invalidates the runtime list AND any keys downstream of the updated runtime
// — usage queries are bucketed by tz on the server, so a tz change must blow
// away cached usage rows or the chart would lie for one polling cycle. A
// visibility change only needs the runtime list to refetch so the picker
// disabled-state recomputes.
export function useUpdateRuntime(wsId: string) {
const qc = useQueryClient();
return useMutation({
@@ -22,10 +26,23 @@ export function useUpdateRuntime(wsId: string) {
patch,
}: {
runtimeId: string;
patch: { visibility?: "private" | "public" };
patch: { timezone?: string; visibility?: "private" | "public" };
}) => api.updateRuntime(runtimeId, patch),
onSettled: () => {
onSettled: (_data, _err, vars) => {
qc.invalidateQueries({ queryKey: runtimeKeys.all(wsId) });
if (vars && vars.patch.timezone !== undefined) {
// Usage query keys are not workspace-scoped; invalidate only this
// runtime's daily/by-agent/by-hour usage rows under the new tz buckets.
qc.invalidateQueries({
queryKey: ["runtimes", "usage", vars.runtimeId],
});
qc.invalidateQueries({
queryKey: ["runtimes", "usage", "by-agent", vars.runtimeId],
});
qc.invalidateQueries({
queryKey: ["runtimes", "usage", "by-hour", vars.runtimeId],
});
}
},
});
}

View File

@@ -5,45 +5,43 @@ export const runtimeKeys = {
all: (wsId: string) => ["runtimes", wsId] as const,
list: (wsId: string) => [...runtimeKeys.all(wsId), "list"] as const,
listMine: (wsId: string) => [...runtimeKeys.all(wsId), "list", "mine"] as const,
usage: (rid: string, days: number, tz: string) =>
["runtimes", "usage", rid, days, tz] as const,
usageByAgent: (rid: string, days: number, tz: string) =>
["runtimes", "usage", "by-agent", rid, days, tz] as const,
// by-hour now follows the viewer's tz, like the other reports.
usageByHour: (rid: string, days: number, tz: string) =>
["runtimes", "usage", "by-hour", rid, days, tz] as const,
usage: (rid: string, days: number) =>
["runtimes", "usage", rid, days] as const,
usageByAgent: (rid: string, days: number) =>
["runtimes", "usage", "by-agent", rid, days] as const,
usageByHour: (rid: string, days: number) =>
["runtimes", "usage", "by-hour", rid, days] as const,
latestVersion: () => ["runtimes", "latestVersion"] as const,
};
// `tz` is the viewer's IANA name — all reports follow the viewer's tz.
export function runtimeUsageOptions(
runtimeId: string,
days: number,
tz: string,
) {
// Per-runtime usage. Used by the list view (each row pulls its own activity
// sparkline + 30d cost) and by the detail page. TanStack Query naturally
// deduplicates concurrent calls for the same runtime, so multiple components
// observing the same runtimeId share one network request.
export function runtimeUsageOptions(runtimeId: string, days: number) {
return queryOptions({
queryKey: runtimeKeys.usage(runtimeId, days, tz),
queryFn: () => api.getRuntimeUsage(runtimeId, { days, tz }),
queryKey: runtimeKeys.usage(runtimeId, days),
queryFn: () => api.getRuntimeUsage(runtimeId, { days }),
staleTime: 60 * 1000,
});
}
export function runtimeUsageByAgentOptions(
runtimeId: string,
days: number,
tz: string,
) {
// Per-agent token totals for one runtime — drives the "Cost by agent" tab
// on the runtime detail page. Server-side aggregation keeps the response
// small (one row per agent) regardless of task volume.
export function runtimeUsageByAgentOptions(runtimeId: string, days: number) {
return queryOptions({
queryKey: runtimeKeys.usageByAgent(runtimeId, days, tz),
queryFn: () => api.getRuntimeUsageByAgent(runtimeId, { days, tz }),
queryKey: runtimeKeys.usageByAgent(runtimeId, days),
queryFn: () => api.getRuntimeUsageByAgent(runtimeId, { days }),
staleTime: 60 * 1000,
});
}
export function runtimeUsageByHourOptions(runtimeId: string, days: number, tz: string) {
// Hourly (0..23) token totals for one runtime — drives the "By hour" tab.
export function runtimeUsageByHourOptions(runtimeId: string, days: number) {
return queryOptions({
queryKey: runtimeKeys.usageByHour(runtimeId, days, tz),
queryFn: () => api.getRuntimeUsageByHour(runtimeId, { days, tz }),
queryKey: runtimeKeys.usageByHour(runtimeId, days),
queryFn: () => api.getRuntimeUsageByHour(runtimeId, { days }),
staleTime: 60 * 1000,
});
}

View File

@@ -26,6 +26,7 @@ export interface RuntimeDevice {
owner_id: string | null;
/** Defaults to "private" when the backend predates the visibility flag. */
visibility: RuntimeVisibility;
timezone: string;
last_seen_at: string | null;
created_at: string;
updated_at: string;
@@ -39,7 +40,6 @@ export type AgentRuntime = RuntimeDevice;
export type TaskFailureReason =
| "agent_error"
| "timeout"
| "codex_semantic_inactivity"
| "runtime_offline"
| "runtime_recovery"
| "manual";
@@ -130,17 +130,6 @@ export interface Agent {
status: AgentStatus;
max_concurrent_tasks: number;
model: string;
/**
* Runtime-native reasoning/effort token (e.g. Claude's
* `low|medium|high|xhigh|max`, Codex's
* `none|minimal|low|medium|high|xhigh`). Empty string means "no
* override": the backend omits the effort flag and the upstream CLI
* config / built-in default decides at run time. The picker is
* per-runtime per-model — the API never normalises across providers.
* Older backends omit this field entirely; treat undefined as ""
* (MUL-2339).
*/
thinking_level?: string;
owner_id: string | null;
skills: AgentSkillSummary[];
created_at: string;
@@ -174,8 +163,6 @@ export interface CreateAgentRequest {
visibility?: AgentVisibility;
max_concurrent_tasks?: number;
model?: string;
/** Optional runtime-native reasoning/effort token. See `Agent.thinking_level`. */
thinking_level?: string;
/** Optional template slug used by the onboarding agent picker. Surfaced
* as the `template` property on the `agent_created` PostHog event. */
template?: string;
@@ -264,15 +251,6 @@ export interface UpdateAgentRequest {
status?: AgentStatus;
max_concurrent_tasks?: number;
model?: string;
/**
* Runtime-native reasoning/effort token. Tri-state semantics (MUL-2339):
* - field omitted → no change
* - "" → clear the override; backend omits the effort flag and the
* local CLI config / built-in default decides what the model runs at
* - non-empty → set; validated server-side against the target
* runtime's provider enum, rejected with 400 if not recognised
*/
thinking_level?: string;
}
// Skills
@@ -453,34 +431,6 @@ export interface RuntimeModel {
label: string;
provider?: string;
default?: boolean;
/**
* Per-model reasoning/effort catalog discovered by the daemon. Currently
* populated for claude and codex runtimes only; omitted (or undefined)
* for every other provider, which the UI treats as "no thinking-level
* picker for this model". See MUL-2339.
*/
thinking?: RuntimeModelThinking;
}
export interface RuntimeModelThinking {
/** Levels the user is allowed to pick for this model. */
supported_levels: RuntimeModelThinkingLevel[];
/** Informational: the level the upstream CLI documents as its built-in
* default when no `--effort` flag is passed. Surfaced by the daemon
* but not actively rendered today — Multica's empty `thinking_level`
* means "no override; let the local CLI config decide", which may
* itself differ from this value. */
default_level?: string;
}
export interface RuntimeModelThinkingLevel {
/** Runtime-native token passed to the CLI; never normalised. */
value: string;
/** Display label matching each CLI's own UI (`Low`, `Extra high`, …). */
label: string;
/** Optional helper copy lifted from upstream catalog
* (`codex debug models` emits one per level). */
description?: string;
}
export type RuntimeModelListStatus =

View File

@@ -1,4 +1,4 @@
import type { Issue, IssueMetadata, IssueStatus, IssuePriority, IssueAssigneeType } from "./issue";
import type { Issue, IssueStatus, IssuePriority, IssueAssigneeType } from "./issue";
import type { MemberRole } from "./workspace";
import type { Project } from "./project";
@@ -54,16 +54,7 @@ export interface ListIssuesParams {
* disjoint result sets by construction.
*/
involves_user_id?: string;
/** JSONB containment filter on `issue.metadata`. AND across keys. */
metadata?: IssueMetadata;
open_only?: boolean;
/**
* Restrict the result to issues with at least one of `start_date` /
* `due_date` set. Used by the Project Gantt view so it doesn't have to
* page through every issue on the project just to discard the unscheduled
* majority on the client.
*/
scheduled?: boolean;
}
export interface IssueActorRef {
@@ -85,8 +76,6 @@ export interface ListGroupedIssuesParams {
project_id?: string;
/** See `ListIssuesParams.involves_user_id` — same semantics. */
involves_user_id?: string;
/** JSONB containment filter on `issue.metadata`. AND across keys. */
metadata?: IssueMetadata;
assignee_filters?: IssueActorRef[];
include_no_assignee?: boolean;
creator_filters?: IssueActorRef[];
@@ -157,10 +146,6 @@ export interface UpdateMeRequest {
name?: string;
avatar_url?: string;
language?: string;
/** Free-form self-description (max 2000 chars). Pass "" to clear. */
profile_description?: string;
/** IANA tz to pin; "" clears back to browser-tz; undefined leaves untouched. */
timezone?: string;
}
export interface CreateMemberRequest {

View File

@@ -2,12 +2,6 @@ export type AutopilotStatus = "active" | "paused" | "archived";
export type AutopilotExecutionMode = "create_issue" | "run_only";
// `assignee_type` selects which polymorphic actor backs the autopilot:
// "agent" → assignee_id references agent(id); "squad" → assignee_id references
// squad(id) and dispatch resolves to squad.leader_id at run time (MUL-2429,
// Path A). Older servers omit this field — callers should default to "agent".
export type AutopilotAssigneeType = "agent" | "squad";
export type AutopilotTriggerKind = "schedule" | "webhook" | "api";
// `skipped` is emitted by the backend pre-flight admission check
@@ -28,8 +22,6 @@ export interface Autopilot {
workspace_id: string;
title: string;
description: string | null;
project_id?: string | null;
assignee_type: AutopilotAssigneeType;
assignee_id: string;
status: AutopilotStatus;
execution_mode: AutopilotExecutionMode;
@@ -83,10 +75,6 @@ export interface AutopilotRun {
export interface CreateAutopilotRequest {
title: string;
description?: string;
project_id?: string | null;
// Optional on the wire — when omitted the server defaults to "agent" so
// older clients keep working.
assignee_type?: AutopilotAssigneeType;
assignee_id: string;
execution_mode: AutopilotExecutionMode;
issue_title_template?: string;
@@ -95,10 +83,6 @@ export interface CreateAutopilotRequest {
export interface UpdateAutopilotRequest {
title?: string;
description?: string | null;
project_id?: string | null;
// Send `assignee_type` together with `assignee_id` whenever you change the
// assignee — the server requires both for a type swap.
assignee_type?: AutopilotAssigneeType;
assignee_id?: string;
status?: AutopilotStatus;
execution_mode?: AutopilotExecutionMode;

View File

@@ -1,4 +1,4 @@
import type { Issue, IssueMetadata, IssueReaction } from "./issue";
import type { Issue, IssueReaction } from "./issue";
import type { Agent } from "./agent";
import type { InboxItem } from "./inbox";
import type { Comment, Reaction } from "./comment";
@@ -65,7 +65,6 @@ export type WSEventType =
| "label:updated"
| "label:deleted"
| "issue_labels:changed"
| "issue_metadata:changed"
| "pin:created"
| "pin:deleted"
| "pin:reordered"
@@ -103,11 +102,6 @@ export interface IssueLabelsChangedPayload {
labels: Label[];
}
export interface IssueMetadataChangedPayload {
issue_id: string;
metadata: IssueMetadata;
}
export interface AgentStatusPayload {
agent: Agent;
}
@@ -141,11 +135,20 @@ export interface InboxArchivedPayload {
export interface InboxBatchReadPayload {
recipient_id: string;
count: number;
// Optional assignment-scope filter the originating mark-all-read was
// narrowed to (RFC v3 §C.5). When present, listeners may apply a precise
// cache update; when absent, the safe default is a full inbox invalidate.
scope?: import("./inbox").InboxFilterScope[] | null;
}
export interface InboxBatchArchivedPayload {
recipient_id: string;
count: number;
// Identifies the bulk archive variant so listeners can pick the right
// predicate for a precise cache update (RFC v4 §1). Optional for backward
// compatibility with older servers.
operation?: import("./inbox").InboxBatchArchiveOperation | null;
scope?: import("./inbox").InboxFilterScope[] | null;
}
export interface CommentCreatedPayload {

View File

@@ -14,18 +14,11 @@ export type GitHubMergeableState = string;
export interface GitHubInstallation {
id: string;
workspace_id: string;
/** GitHub's numeric installation id — the management handle used by the
* connect / disconnect flows. Omitted when the caller cannot manage
* integrations (see `ListGitHubInstallationsResponse.can_manage`). */
installation_id?: number;
installation_id: number;
account_login: string;
account_type: "User" | "Organization";
account_avatar_url: string | null;
created_at: string;
/** Display name of the workspace member who connected this installation.
* Optional because older backends and minimum-visibility deployments may
* omit it; the UI renders the "connected by" line only when present. */
connected_by?: string;
}
export interface GitHubPullRequest {
@@ -64,11 +57,6 @@ export interface ListGitHubInstallationsResponse {
installations: GitHubInstallation[];
/** Whether the deployment has GitHub App credentials configured. When false, the Connect button is hidden / disabled. */
configured: boolean;
/** Whether the caller can connect / disconnect installations. Non-admin
* members get `false` along with installations that omit `installation_id`.
* Older backends predating MUL-2413 omit the field; treat absence as
* `false` for read-only safety. */
can_manage?: boolean;
}
export interface GitHubConnectResponse {

View File

@@ -21,6 +21,22 @@ export type InboxItemType =
| "quick_create_done"
| "quick_create_failed";
/**
* Inbox assignment scope buckets (RFC v3 §B). The three "my_*" values map to
* the user-selectable chips; "other" and "none" are server-internal fallback
* buckets that fill the default-no-filter view but cannot be explicitly
* filtered to.
*/
export type InboxAssigneeScope =
| "me"
| "my_agent"
| "my_squad"
| "other"
| "none";
/** User-selectable subset of InboxAssigneeScope (chips). */
export type InboxFilterScope = "me" | "my_agent" | "my_squad";
export interface InboxItem {
id: string;
workspace_id: string;
@@ -38,4 +54,26 @@ export interface InboxItem {
archived: boolean;
created_at: string;
details: Record<string, string> | null;
// Server-tagged scope of the issue this inbox item references (RFC v3 §A).
// Optional because older servers may not emit it.
issue_assignee_type?: "member" | "agent" | "squad" | null;
issue_assignee_id?: string | null;
assignee_scope?: InboxAssigneeScope | null;
}
export type InboxScopeCounts = Record<InboxAssigneeScope, number>;
export interface InboxResourceAvailability {
has_my_agent: boolean;
has_my_squad: boolean;
}
/**
* Identifies which bulk-archive endpoint produced an `inbox:batch-archived`
* WS event. Frontends use this to choose the right predicate when applying a
* precise cache update (RFC v4 §1).
*/
export type InboxBatchArchiveOperation =
| "archive_all"
| "archive_read"
| "archive_completed";

View File

@@ -1,4 +1,4 @@
export type { Issue, IssueStatus, IssuePriority, IssueAssigneeType, IssueMetadata, IssueMetadataValue, IssueReaction } from "./issue";
export type { Issue, IssueStatus, IssuePriority, IssueAssigneeType, IssueReaction } from "./issue";
export type {
Agent,
AgentStatus,
@@ -36,8 +36,6 @@ export type {
RuntimeUpdate,
RuntimeUpdateStatus,
RuntimeModel,
RuntimeModelThinking,
RuntimeModelThinkingLevel,
RuntimeModelListRequest,
RuntimeModelListStatus,
RuntimeModelsResult,
@@ -51,7 +49,16 @@ export type {
IssueUsageSummary,
} from "./agent";
export type { Workspace, WorkspaceRepo, Member, MemberRole, User, MemberWithUser, Invitation } from "./workspace";
export type { InboxItem, InboxSeverity, InboxItemType } from "./inbox";
export type {
InboxItem,
InboxSeverity,
InboxItemType,
InboxAssigneeScope,
InboxFilterScope,
InboxScopeCounts,
InboxResourceAvailability,
InboxBatchArchiveOperation,
} from "./inbox";
export type { NotificationGroupKey, NotificationGroupValue, NotificationPreferences, NotificationPreferenceResponse } from "./notification-preference";
export type { Comment, CommentType, CommentAuthorType, Reaction } from "./comment";
export type { Label, CreateLabelRequest, UpdateLabelRequest, ListLabelsResponse, IssueLabelsResponse } from "./label";
@@ -92,7 +99,6 @@ export type {
Autopilot,
AutopilotStatus,
AutopilotExecutionMode,
AutopilotAssigneeType,
AutopilotTrigger,
AutopilotTriggerKind,
AutopilotRun,

View File

@@ -22,16 +22,6 @@ export interface IssueReaction {
created_at: string;
}
/**
* Per-issue metadata is a flat KV map agents use to record pipeline state
* (PR number, pipeline_status, waiting_on, ...). Values are primitives only —
* string / number / bool — enforced by both the API and the DB. Always
* present in responses (empty object when unset) so reads don't need a
* nil guard on the parent field.
*/
export type IssueMetadataValue = string | number | boolean;
export type IssueMetadata = Record<string, IssueMetadataValue>;
export interface Issue {
id: string;
workspace_id: string;
@@ -50,7 +40,6 @@ export interface Issue {
position: number;
start_date: string | null;
due_date: string | null;
metadata: IssueMetadata;
reactions?: IssueReaction[];
labels?: Label[];
created_at: string;

View File

@@ -39,24 +39,18 @@ export interface User {
*/
onboarding_questionnaire: Record<string, unknown>;
/**
* Legacy column from the removed starter-content dialog. The column is
* still written to (always 'imported' for new accounts after the
* mark-onboarded paths run) so older desktop builds — which still render
* the dialog on NULL — don't show it to anyone created on a newer server.
* Kept as `string | null` for forward compatibility.
* Terminal state for the post-onboarding "import starter content" prompt.
* null → new user, dialog will show on issues-list landing
* 'imported' → accepted, starter project + issues were seeded
* 'dismissed' → declined, never ask again
* 'skipped_legacy' → backfilled for users who finished onboarding
* before this feature shipped
* Kept as a generic `string | null` here so future states (e.g.
* 'retry_after_error') can be added without churning this type.
*/
starter_content_state: string | null;
/** Preferred UI language. null means "follow client/system". */
language: string | null;
/**
* Free-form self-description (role, stack, preferences). Injected into
* the agent brief so coding agents have cheap, durable context about
* who is requesting the work. Server always returns a string —
* NOT NULL DEFAULT '' at the column level, empty when unset.
*/
profile_description: string;
/** Pinned IANA tz; null means "use browser-detected tz at render time". */
timezone: string | null;
created_at: string;
updated_at: string;
}

View File

@@ -43,7 +43,6 @@ import { ConcurrencyPicker } from "./inspector/concurrency-picker";
import { ModelPicker } from "./inspector/model-picker";
import { RuntimePicker } from "./inspector/runtime-picker";
import { SkillAttach } from "./inspector/skill-attach";
import { ThinkingPropRow } from "./inspector/thinking-prop-row";
import { VisibilityPicker } from "./inspector/visibility-picker";
interface InspectorProps {
@@ -131,14 +130,6 @@ export function AgentDetailInspector({
onChange={(m) => update({ model: m })}
/>
</PropRow>
<ThinkingPropRow
runtimeId={agent.runtime_id}
runtimeOnline={!!isOnline}
model={agent.model ?? ""}
value={agent.thinking_level ?? ""}
canEdit={canEdit}
onChange={(v) => update({ thinking_level: v })}
/>
<PropRow label={t(($) => $.inspector.prop_visibility)} interactive={false}>
<VisibilityPicker
value={agent.visibility}

View File

@@ -101,44 +101,11 @@ export function AgentDetailPage({ agentId }: AgentDetailPageProps) {
const [confirmArchive, setConfirmArchive] = useState(false);
const handleUpdate = async (id: string, data: Record<string, unknown>) => {
// Optimistic update: patch the matching agent in the cached list
// BEFORE the network round-trip so the inspector picker chips flip to
// the new value immediately on click. Without this, every inspector
// picker (thinking / visibility / concurrency / model / runtime) waits
// 0.5-2s for the API response + invalidate + refetch before the trigger
// updates — readable as obvious lag in the UI.
//
// On error we rollback only the fields THIS call wrote, leaving any
// other concurrently-mutated fields untouched, then invalidate so the
// cache converges with the server. A whole-list snapshot rollback
// would clobber a concurrent successful mutation if the failing call
// resolves last (e.g. flipping visibility then runtime simultaneously
// and only the visibility PATCH fails).
const queryKey = workspaceKeys.agents(wsId);
const prevAgents = qc.getQueryData<Agent[]>(queryKey);
const prevAgent = prevAgents?.find((a) => a.id === id);
const prevFields: Record<string, unknown> = {};
if (prevAgent) {
for (const key of Object.keys(data)) {
prevFields[key] = (prevAgent as unknown as Record<string, unknown>)[key];
}
}
qc.setQueryData<Agent[]>(queryKey, (old) =>
old?.map((a) => (a.id === id ? ({ ...a, ...data } as Agent) : a)),
);
try {
await api.updateAgent(id, data as UpdateAgentRequest);
qc.invalidateQueries({ queryKey });
qc.invalidateQueries({ queryKey: workspaceKeys.agents(wsId) });
toast.success(t(($) => $.detail.agent_updated_toast));
} catch (e) {
if (prevAgent) {
qc.setQueryData<Agent[]>(queryKey, (old) =>
old?.map((a) =>
a.id === id ? ({ ...a, ...prevFields } as Agent) : a,
),
);
}
qc.invalidateQueries({ queryKey });
toast.error(e instanceof Error ? e.message : t(($) => $.detail.update_failed_toast));
throw e;
}

View File

@@ -87,6 +87,7 @@ function makeRuntime(overrides: Partial<RuntimeDevice>): RuntimeDevice {
metadata: {},
owner_id: ME,
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",

View File

@@ -145,21 +145,21 @@ export function ModelPicker({
// string actually ships to the agent.
tooltip={m.label !== m.id ? `${m.label} · ${m.id}` : m.id}
>
{/* PickerItem wraps children in a flex `<span>`. Putting a
`<div>` inside that <span> is block-in-inline (invalid
HTML5) and triggers the browser-default centering quirk
that pushes descendants off-axis (model IDs floated to the
center instead of left-aligning under their labels). Use
`<span block text-left>` to keep layout deterministic —
matches the fix already applied in thinking-picker.tsx. */}
<span className="block min-w-0 flex-1 text-left">
<span className="block truncate text-[13px] font-medium">{m.label}</span>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-1.5">
<span className="truncate font-medium">{m.label}</span>
{m.default && (
<span className="shrink-0 rounded bg-primary/10 px-1 text-[10px] font-medium text-primary">
{t(($) => $.pickers.model_default_badge)}
</span>
)}
</div>
{m.label !== m.id && (
<span className="mt-0.5 block truncate font-mono text-[10px] leading-snug text-muted-foreground">
<div className="truncate font-mono text-[10px] text-muted-foreground">
{m.id}
</span>
</div>
)}
</span>
</div>
</PickerItem>
))}

View File

@@ -1,112 +0,0 @@
// @vitest-environment jsdom
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, screen, fireEvent, cleanup } from "@testing-library/react";
import type { RuntimeModelThinkingLevel } from "@multica/core/types";
import { I18nProvider } from "@multica/core/i18n/react";
import enCommon from "../../../locales/en/common.json";
import enAgents from "../../../locales/en/agents.json";
import enIssues from "../../../locales/en/issues.json";
import { ThinkingPicker } from "./thinking-picker";
const TEST_RESOURCES = {
en: { common: enCommon, agents: enAgents, issues: enIssues },
};
const CODEX_LEVELS: RuntimeModelThinkingLevel[] = [
{ value: "minimal", label: "Minimal", description: "Fast, light reasoning" },
{ value: "low", label: "Low" },
{ value: "medium", label: "Medium" },
{ value: "high", label: "High" },
];
function renderPicker(props: Partial<React.ComponentProps<typeof ThinkingPicker>> = {}) {
const onChange = vi.fn();
const utils = render(
<I18nProvider locale="en" resources={TEST_RESOURCES}>
<ThinkingPicker
value=""
levels={CODEX_LEVELS}
canEdit
onChange={onChange}
{...props}
/>
</I18nProvider>,
);
return { ...utils, onChange };
}
describe("ThinkingPicker", () => {
beforeEach(() => {
cleanup();
});
afterEach(() => {
cleanup();
});
it('renders "Follow CLI config" when value is empty', () => {
renderPicker({ value: "" });
// The trigger and the tooltip both carry the label. Empty value means
// Multica omits --effort, so the local CLI's config decides the
// reasoning level — see thinking-prop-row.tsx for the contract.
expect(screen.getAllByText("Follow CLI config").length).toBeGreaterThan(0);
});
it("renders the matching level label when value is set", () => {
renderPicker({ value: "high" });
expect(screen.getAllByText("High").length).toBeGreaterThan(0);
});
it("renders the raw token when the saved value is no longer in the catalog", () => {
// Simulates a model swap that dropped the option the user previously
// picked — we still surface what's persisted so the user can clear it,
// rather than silently showing "Follow CLI config".
renderPicker({ value: "xhigh", levels: CODEX_LEVELS });
expect(screen.getAllByText("xhigh").length).toBeGreaterThan(0);
});
it("renders a static read-only display when canEdit=false and exposes no popover trigger", () => {
renderPicker({ value: "low", canEdit: false });
expect(screen.getByText("Low")).toBeInTheDocument();
expect(screen.queryByRole("button")).toBeNull();
});
it("calls onChange with the picked value and skips when the user re-picks the current value", () => {
const { onChange } = renderPicker({ value: "low" });
fireEvent.click(screen.getByRole("button"));
// Picking a new level fires onChange with the runtime-native value.
fireEvent.click(screen.getByText("High"));
expect(onChange).toHaveBeenCalledWith("high");
// Re-opening and clicking the already-selected value is a no-op so we
// don't enqueue a redundant PATCH. The trigger also reads "Low", so
// there are two matches in the DOM — target the listbox item by
// selecting the option button explicitly.
onChange.mockClear();
fireEvent.click(screen.getByRole("button"));
const lowOption = screen
.getAllByRole("button")
.find((b) => b.getAttribute("data-picker-item") !== null && b.textContent?.includes("Low"));
expect(lowOption).toBeDefined();
fireEvent.click(lowOption!);
expect(onChange).not.toHaveBeenCalled();
});
it("clears to empty string via the footer button when a value is set", () => {
const { onChange } = renderPicker({ value: "high" });
fireEvent.click(screen.getByRole("button"));
// Footer copy resolves through i18n — match a substring so we don't
// pin to the exact translated wording.
const clearButton = screen.getByTitle(/Clear the override/i);
fireEvent.click(clearButton);
expect(onChange).toHaveBeenCalledWith("");
});
it("does not render the clear button when value is already empty", () => {
renderPicker({ value: "" });
fireEvent.click(screen.getByRole("button"));
expect(screen.queryByTitle(/Clear and fall back/i)).toBeNull();
});
});

View File

@@ -1,134 +0,0 @@
"use client";
import { useState } from "react";
import type { RuntimeModelThinkingLevel } from "@multica/core/types";
import {
PickerItem,
PropertyPicker,
} from "../../../issues/components/pickers";
import { CHIP_CLASS } from "./chip";
import { useT } from "../../../i18n";
/**
* Per-agent reasoning/effort picker (MUL-2339). Renders only when the
* current model exposes a non-empty `supported_levels` set — Claude and
* Codex today; every other provider gets nothing. The catalog is daemon-
* discovered, so the value/label pairs match each CLI's own UI (`Low`,
* `Extra high`, …) verbatim; never normalised across providers.
*
* Empty string is the "no override" sentinel: the backend omits the
* effort flag entirely and the upstream CLI's own config / built-in
* default decides what the model runs at. We render that state as
* "Follow CLI config" rather than singling out one level as the
* factory default, because the actual default at runtime is owned by
* the user's local CLI install, not by Multica's catalog.
*/
export function ThinkingPicker({
value,
levels,
canEdit = true,
onChange,
}: {
/** Persisted thinking_level — "" means "follow local CLI config". */
value: string;
/** Supported levels for the current (runtime, model) pair. Usually
* non-empty when the row is shown, but the stale-orphan clear path
* in ThinkingPropRow mounts the picker with an empty list plus a
* persisted value so the user can see and clear the dangling token. */
levels: RuntimeModelThinkingLevel[];
/** When false, render a static read-only display and skip the popover. */
canEdit?: boolean;
onChange: (next: string) => Promise<void> | void;
}) {
const { t } = useT("agents");
const [open, setOpen] = useState(false);
const selected = value ? levels.find((l) => l.value === value) : undefined;
// Unknown-but-set value (model swap that dropped the option, CLI upgrade
// that trimmed the catalog): show the raw token so the user can see what
// is actually persisted and clear it, rather than silently labelling it
// "Default" when the backend would still send the stale value.
const triggerLabel = selected
? selected.label
: value || t(($) => $.pickers.thinking_default);
const triggerTitle = t(($) => $.pickers.thinking_tooltip, {
value: triggerLabel,
});
const select = async (next: string) => {
setOpen(false);
if (next !== value) await onChange(next);
};
if (!canEdit) {
return (
<span
className="min-w-0 truncate px-1.5 py-0.5 font-mono text-[11px] text-muted-foreground"
title={triggerTitle}
>
{triggerLabel}
</span>
);
}
return (
<PropertyPicker
open={open}
onOpenChange={setOpen}
width="w-auto min-w-[14rem] max-w-md"
align="start"
tooltip={triggerTitle}
triggerRender={
<button
type="button"
className={CHIP_CLASS}
aria-label={triggerTitle}
/>
}
trigger={
<span className="min-w-0 truncate font-mono text-[11px]">
{triggerLabel}
</span>
}
>
{levels.map((l) => (
<PickerItem
key={l.value}
selected={l.value === value}
onClick={() => void select(l.value)}
>
{/* PickerItem wraps children in a flex `<span>`. Putting a
`<div>` inside that <span> is block-in-inline (invalid HTML5)
and triggers browser quirks that shift descendant x-position.
Use a `<span>` with explicit `block` + `text-left` so layout
is deterministic across rows regardless of whether the label
row has the `default` badge sibling. */}
{/* No model-factory-default badge here on purpose: when the
picker is "Follow CLI config" (value === ""), Multica omits
`--effort` and the local CLI config decides — the model's
factory default is irrelevant to what actually fires, so
flagging one option as "default" was misleading. */}
<span className="block min-w-0 flex-1 text-left">
<span className="truncate text-[13px] font-medium">{l.label}</span>
{l.description && (
<span className="mt-0.5 block text-[11px] leading-snug text-muted-foreground">
{l.description}
</span>
)}
</span>
</PickerItem>
))}
{value && (
<button
type="button"
onClick={() => void select("")}
className="mt-1 flex w-full items-center border-t px-3 py-2 text-left text-xs text-muted-foreground transition-colors hover:bg-accent/50"
title={t(($) => $.pickers.thinking_clear_title)}
>
{t(($) => $.pickers.thinking_clear)}
</button>
)}
</PropertyPicker>
);
}

View File

@@ -1,193 +0,0 @@
// @vitest-environment jsdom
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import {
cleanup,
fireEvent,
render,
screen,
waitFor,
} from "@testing-library/react";
import type {
RuntimeModel,
RuntimeModelListRequest,
} from "@multica/core/types";
import { I18nProvider } from "@multica/core/i18n/react";
import enCommon from "../../../locales/en/common.json";
import enAgents from "../../../locales/en/agents.json";
import enIssues from "../../../locales/en/issues.json";
const TEST_RESOURCES = {
en: { common: enCommon, agents: enAgents, issues: enIssues },
};
const mockInitiateListModels = vi.hoisted(() => vi.fn());
const mockGetListModelsResult = vi.hoisted(() => vi.fn());
vi.mock("@multica/core/api", () => ({
api: {
initiateListModels: (...args: unknown[]) =>
mockInitiateListModels(...args),
getListModelsResult: (...args: unknown[]) =>
mockGetListModelsResult(...args),
},
}));
import { ThinkingPropRow } from "./thinking-prop-row";
const CLAUDE_MODEL: RuntimeModel = {
id: "claude-sonnet-4-6",
label: "Claude Sonnet 4.6",
default: true,
thinking: {
supported_levels: [
{ value: "none", label: "None" },
{ value: "low", label: "Low" },
{ value: "medium", label: "Medium" },
{ value: "high", label: "High" },
],
default_level: "medium",
},
};
// Model without thinking metadata — what the row sees when the agent's
// model swap landed on a non-thinking runtime, or when the daemon catalog
// shrank and stopped emitting `thinking` for this id.
const NO_THINKING_MODEL: RuntimeModel = {
id: "gemini-2.5-pro",
label: "Gemini 2.5 Pro",
default: true,
};
function listResult(models: RuntimeModel[]): RuntimeModelListRequest {
return {
id: "req-1",
runtime_id: "runtime-1",
status: "completed",
models,
supported: true,
created_at: "2026-05-20T00:00:00Z",
updated_at: "2026-05-20T00:00:00Z",
};
}
function renderRow(
props: Partial<React.ComponentProps<typeof ThinkingPropRow>> = {},
) {
const onChange = vi.fn();
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
const utils = render(
// PropRow uses CSS subgrid, so wrap with the same column tracks the
// inspector parent declares — otherwise the row mounts without a
// grid context and the column layout warns. Behaviour we care about
// (visibility + clear flow) is independent of layout.
<I18nProvider locale="en" resources={TEST_RESOURCES}>
<QueryClientProvider client={queryClient}>
<div className="grid grid-cols-[auto_1fr] gap-x-2 gap-y-0.5">
<ThinkingPropRow
runtimeId="runtime-1"
runtimeOnline
model="claude-sonnet-4-6"
value=""
canEdit
onChange={onChange}
{...props}
/>
</div>
</QueryClientProvider>
</I18nProvider>,
);
return { ...utils, onChange, queryClient };
}
describe("ThinkingPropRow", () => {
beforeEach(() => {
vi.clearAllMocks();
mockInitiateListModels.mockResolvedValue(listResult([CLAUDE_MODEL]));
mockGetListModelsResult.mockResolvedValue(listResult([CLAUDE_MODEL]));
});
afterEach(() => {
cleanup();
});
it("hides the row when the active model has no thinking levels and nothing is persisted", async () => {
mockInitiateListModels.mockResolvedValue(listResult([NO_THINKING_MODEL]));
renderRow({ model: "gemini-2.5-pro", value: "" });
// ThinkingPropRow returns null when levels are empty and value is
// empty — both initially (data undefined) and after discovery
// (NO_THINKING_MODEL has no `thinking` block). The `useQuery` hook
// runs before the early null return on first render, so the
// subscription is established and discovery still fires. In
// production this is also covered by the sibling ModelPicker
// mounted next to the row in agent-detail-inspector.
await waitFor(() => {
expect(mockInitiateListModels).toHaveBeenCalled();
});
await waitFor(() => {
expect(screen.queryByText("Thinking")).toBeNull();
});
});
it("hides the row while the runtime is offline (no query fires)", () => {
renderRow({ runtimeOnline: false, value: "" });
// Query disabled when runtimeOnline=false, so no models, levels stay
// empty, value is empty → row stays hidden.
expect(screen.queryByText("Thinking")).toBeNull();
expect(mockInitiateListModels).not.toHaveBeenCalled();
});
it("renders the row with the persisted raw token when levels are empty but value is set (stale orphan)", async () => {
// The agent persisted `thinking_level=xhigh` while it was on a
// thinking-capable model, then was swapped to gemini (or the CLI
// catalog shrank). PR1's behavior is daemon-side warn/drop, not a
// synchronous DB clear, so the frontend must surface the orphan
// token and let the user clear it explicitly.
mockInitiateListModels.mockResolvedValue(listResult([NO_THINKING_MODEL]));
renderRow({ model: "gemini-2.5-pro", value: "xhigh" });
await screen.findByText("Thinking");
// The picker chip carries the raw value when it's not in the catalog.
expect(await screen.findByText("xhigh")).toBeInTheDocument();
});
it("clears the orphan value via the picker footer, emitting onChange(\"\")", async () => {
mockInitiateListModels.mockResolvedValue(listResult([NO_THINKING_MODEL]));
const { onChange } = renderRow({
model: "gemini-2.5-pro",
value: "xhigh",
});
// Wait until the row mounts with the orphan value, then open the
// popover and fire the clear footer. The footer is the only target
// matching the i18n `thinking_clear_title` copy.
await screen.findByText("xhigh");
fireEvent.click(screen.getByRole("button"));
const clearButton = await screen.findByTitle(/Clear the override/i);
fireEvent.click(clearButton);
expect(onChange).toHaveBeenCalledWith("");
});
it("renders the row with the matched label when the model still advertises the value", async () => {
renderRow({ value: "high" });
await screen.findByText("Thinking");
// Both the chip and the tooltip carry "High".
expect((await screen.findAllByText("High")).length).toBeGreaterThan(0);
});
it("renders the row with \"Follow CLI config\" when value is empty and the model exposes levels", async () => {
renderRow({ value: "" });
await screen.findByText("Thinking");
// Empty value means Multica omits --effort, so the local CLI's
// config decides — chip + tooltip both read "Follow CLI config".
expect((await screen.findAllByText("Follow CLI config")).length).toBeGreaterThan(0);
});
});

View File

@@ -1,71 +0,0 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import type { RuntimeModel } from "@multica/core/types";
import { runtimeModelsOptions } from "@multica/core/runtimes";
import { PropRow } from "../../../common/prop-row";
import { useT } from "../../../i18n";
import { ThinkingPicker } from "./thinking-picker";
/**
* Thinking row for the agent inspector. Hidden when the active model has
* no `supported_levels` advertised AND nothing is persisted, so providers
* that don't expose reasoning never surface an empty row. If the agent
* already has a `thinking_level` saved (model swap into a non-thinking
* runtime, or the daemon / CLI catalog shrank and dropped the entry),
* we still render the row so the user can see the orphan token the
* backend is still sending and explicit-clear it via the picker footer.
* PR1's per-model invalid behavior is daemon-side warn/drop, not a
* synchronous DB clear, so the frontend has to surface the persisted
* state honestly.
*
* Reuses the shared runtime-models query so it hits the same 60s cache
* as the model picker; no extra round-trip on the inspector's hot path.
* The sibling ModelPicker mounts unconditionally next to this row, so
* the shared query subscription is established by the inspector mount
* itself — returning null here does NOT cancel discovery.
*/
export function ThinkingPropRow({
runtimeId,
runtimeOnline,
model,
value,
canEdit,
onChange,
}: {
runtimeId: string | null;
runtimeOnline: boolean;
model: string;
value: string;
canEdit: boolean;
onChange: (next: string) => Promise<void> | void;
}) {
const { t } = useT("agents");
const modelsQuery = useQuery(
runtimeModelsOptions(runtimeOnline ? runtimeId : null),
);
const models = modelsQuery.data?.models ?? [];
const entry = pickModelEntry(models, model);
const levels = entry?.thinking?.supported_levels ?? [];
if (levels.length === 0 && !value) return null;
return (
<PropRow label={t(($) => $.inspector.prop_thinking)} interactive={false}>
<ThinkingPicker
value={value}
levels={levels}
canEdit={canEdit}
onChange={onChange}
/>
</PropRow>
);
}
function pickModelEntry(
models: RuntimeModel[],
model: string,
): RuntimeModel | undefined {
if (model) return models.find((m) => m.id === model);
return models.find((m) => m.default) ?? models[0];
}

View File

@@ -183,7 +183,14 @@ export function ModelDropdown({
}`}
>
<div className="min-w-0 flex-1">
<div className="truncate font-medium">{m.label}</div>
<div className="flex items-center gap-1.5">
<span className="truncate font-medium">{m.label}</span>
{m.default && (
<span className="shrink-0 rounded bg-primary/10 px-1.5 py-0.5 text-xs font-medium text-primary">
{t(($) => $.pickers.model_default_badge)}
</span>
)}
</div>
{m.label !== m.id && (
<div className="truncate text-xs text-muted-foreground">
{m.id}

View File

@@ -10,7 +10,6 @@ import type { TaskFailureReason } from "@multica/core/types";
export const failureReasonLabel: Record<TaskFailureReason, string> = {
agent_error: "Agent execution error",
timeout: "Task timed out",
codex_semantic_inactivity: "Codex semantic inactivity timeout",
runtime_offline: "Daemon offline",
runtime_recovery: "Daemon restarted",
manual: "Cancelled by user",

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