mirror of
https://github.com/multica-ai/multica.git
synced 2026-06-22 23:19:17 +02:00
Compare commits
78 Commits
agent/lamb
...
fix/cloud-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
94f4003410 | ||
|
|
51b3c5291f | ||
|
|
51c6e90363 | ||
|
|
614dfae884 | ||
|
|
d0666138ec | ||
|
|
41cb91abd9 | ||
|
|
1c892aa3f9 | ||
|
|
65feb890b8 | ||
|
|
7e55813460 | ||
|
|
7f9e4e829d | ||
|
|
8a135d2982 | ||
|
|
83e90c9530 | ||
|
|
ef6a944063 | ||
|
|
ed2957ddf8 | ||
|
|
2f1f90c11a | ||
|
|
688dcb017c | ||
|
|
cf000d1e93 | ||
|
|
317bca40c1 | ||
|
|
8d4f4caf4a | ||
|
|
34f16e2c7a | ||
|
|
85e363370e | ||
|
|
b040165f4e | ||
|
|
dee5c7cf50 | ||
|
|
aeb284cbeb | ||
|
|
1f978bf1ec | ||
|
|
ffc0c5ab2e | ||
|
|
b7082a01f1 | ||
|
|
314e91fa6d | ||
|
|
68270e238e | ||
|
|
eaf8b14866 | ||
|
|
41753d17a2 | ||
|
|
edded77691 | ||
|
|
9d3b6e2241 | ||
|
|
2bec2221d2 | ||
|
|
292226f632 | ||
|
|
72339f347b | ||
|
|
fc8528d64d | ||
|
|
4a487adfeb | ||
|
|
e48f6a84d6 | ||
|
|
5b8303b83c | ||
|
|
071ffca034 | ||
|
|
2ad1cd8ff8 | ||
|
|
34988216ed | ||
|
|
80cc7b23f8 | ||
|
|
044f7f0cc6 | ||
|
|
591e47842d | ||
|
|
cd37b4e3d6 | ||
|
|
f92deaf939 | ||
|
|
f120e0ef43 | ||
|
|
240792d5e0 | ||
|
|
76cd8275ff | ||
|
|
54368fd826 | ||
|
|
d46e90ee0a | ||
|
|
6901325761 | ||
|
|
c49c78b780 | ||
|
|
fd6ccbe371 | ||
|
|
39f43a9a98 | ||
|
|
59617f376e | ||
|
|
9a577f3e11 | ||
|
|
7be3838ada | ||
|
|
98ef021d1d | ||
|
|
6f21cb8f3e | ||
|
|
d7e58760f3 | ||
|
|
6e0f7b0f36 | ||
|
|
b5102eb3d2 | ||
|
|
e19f7967b9 | ||
|
|
ccd9e6cdfb | ||
|
|
8d30d76300 | ||
|
|
0339de54e7 | ||
|
|
c577a29c10 | ||
|
|
434003d129 | ||
|
|
93153d08b7 | ||
|
|
35fc318d68 | ||
|
|
5476e7678d | ||
|
|
e65c0889b9 | ||
|
|
8db354f721 | ||
|
|
3c510c31ed | ||
|
|
54f884ebc8 |
39
.agents/skills/web-design-guidelines/SKILL.md
Normal file
39
.agents/skills/web-design-guidelines/SKILL.md
Normal file
@@ -0,0 +1,39 @@
|
||||
---
|
||||
name: web-design-guidelines
|
||||
description: Review UI code for Web Interface Guidelines compliance. Use when asked to "review my UI", "check accessibility", "audit design", "review UX", or "check my site against best practices".
|
||||
metadata:
|
||||
author: vercel
|
||||
version: "1.0.0"
|
||||
argument-hint: <file-or-pattern>
|
||||
---
|
||||
|
||||
# Web Interface Guidelines
|
||||
|
||||
Review files for compliance with Web Interface Guidelines.
|
||||
|
||||
## How It Works
|
||||
|
||||
1. Fetch the latest guidelines from the source URL below
|
||||
2. Read the specified files (or prompt user for files/pattern)
|
||||
3. Check against all rules in the fetched guidelines
|
||||
4. Output findings in the terse `file:line` format
|
||||
|
||||
## Guidelines Source
|
||||
|
||||
Fetch fresh guidelines before each review:
|
||||
|
||||
```
|
||||
https://raw.githubusercontent.com/vercel-labs/web-interface-guidelines/main/command.md
|
||||
```
|
||||
|
||||
Use WebFetch to retrieve the latest rules. The fetched content contains all the rules and output format instructions.
|
||||
|
||||
## Usage
|
||||
|
||||
When a user provides a file or pattern argument:
|
||||
1. Fetch guidelines from the source URL above
|
||||
2. Read the specified files
|
||||
3. Apply all rules from the fetched guidelines
|
||||
4. Output findings using the format specified in the guidelines
|
||||
|
||||
If no files specified, ask the user which files to review.
|
||||
@@ -112,6 +112,13 @@ CLOUDFRONT_DOMAIN=
|
||||
# attribute and browsers silently drop such cookies.
|
||||
COOKIE_DOMAIN=
|
||||
|
||||
# AUTH_TOKEN_TTL — auth token lifetime. Accepts Go duration strings (e.g.
|
||||
# "8760h", "720h30m") or plain integer seconds.
|
||||
# Default: 2592000 (30 days). Self-hosted deployments on trusted networks can
|
||||
# set a longer value to reduce re-authentication frequency.
|
||||
# Note: longer TTL = longer exposure window if a cookie is leaked.
|
||||
# AUTH_TOKEN_TTL=2592000
|
||||
|
||||
# Local file storage (fallback when S3_BUCKET is not set)
|
||||
LOCAL_UPLOAD_DIR=./data/uploads
|
||||
LOCAL_UPLOAD_BASE_URL=http://localhost:8080
|
||||
@@ -154,7 +161,7 @@ CORS_ALLOWED_ORIGINS=
|
||||
# `Authorization: Bearer <token>`.
|
||||
# REALTIME_METRICS_TOKEN=
|
||||
|
||||
# GitHub App integration (Settings → Integrations "Connect GitHub")
|
||||
# GitHub App integration (Settings → GitHub "Connect GitHub")
|
||||
# Both must be set for the Connect button to enable and for webhooks to be
|
||||
# accepted; leave empty to disable the integration. See docs/github-integration.
|
||||
# GITHUB_APP_SLUG is the tail of https://github.com/apps/<slug>.
|
||||
|
||||
2
.github/PULL_REQUEST_TEMPLATE.md
vendored
2
.github/PULL_REQUEST_TEMPLATE.md
vendored
@@ -40,7 +40,7 @@ Closes #
|
||||
- [ ] I have added or updated tests where applicable
|
||||
- [ ] If this change affects the UI, I have included before/after screenshots
|
||||
- [ ] I have updated relevant documentation to reflect my changes
|
||||
- [ ] If I added a new runtime / coding tool / UI tab, I synced the change to **landing copy** (`apps/web/features/landing/i18n/`), **starter-content** (`packages/views/onboarding/utils/starter-content-content-*.ts`), 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/`) and **relevant docs** (`apps/docs/content/docs/`)
|
||||
- [ ] If this PR touches Chinese product copy, I checked it against `apps/docs/content/docs/developers/conventions.zh.mdx` (terminology, mixed-rule for `task` / `issue` / `skill`)
|
||||
- [ ] I have considered and documented any risks above
|
||||
- [ ] I will address all reviewer comments before requesting merge
|
||||
|
||||
17
.github/workflows/ci.yml
vendored
17
.github/workflows/ci.yml
vendored
@@ -91,3 +91,20 @@ jobs:
|
||||
|
||||
- name: Test
|
||||
run: cd server && go test ./...
|
||||
|
||||
installer:
|
||||
# Stub-driven shell tests for scripts/install.sh. Kept off the heavy
|
||||
# backend job so installer regressions surface independently, and
|
||||
# exercised on macOS too because the installer targets macOS/Homebrew
|
||||
# and `tar` / `sed` / `mktemp` differ between BSD and GNU userlands.
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: [ubuntu-latest, macos-latest]
|
||||
runs-on: ${{ matrix.os }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Test shell installers
|
||||
run: bash scripts/install.test.sh
|
||||
|
||||
@@ -269,21 +269,37 @@ Each profile gets its own config directory (`~/.multica/profiles/<name>/`), daem
|
||||
|
||||
## Workspaces
|
||||
|
||||
### Working with multiple workspaces
|
||||
|
||||
Every command runs against a single workspace. The CLI resolves which one in this order (highest priority first):
|
||||
|
||||
1. `--workspace-id <id>` flag on the command
|
||||
2. `MULTICA_WORKSPACE_ID` environment variable
|
||||
3. The default workspace stored in your current profile (set by `multica workspace switch` or `multica login`)
|
||||
|
||||
`multica workspace switch <id|slug>` is the day-to-day way to change the default workspace. For scripting and headless setups where you don't want any stored state, prefer the `--workspace-id` flag or the env variable. `multica config set workspace_id <id>` is the low-level equivalent of `switch` (it writes the same setting but skips the access check).
|
||||
|
||||
If you need full isolation between organizations or accounts — separate tokens, separate daemons, separate config dirs — use `--profile <name>` instead. Each profile keeps its own default workspace.
|
||||
|
||||
### List Workspaces
|
||||
|
||||
```bash
|
||||
multica workspace list
|
||||
multica workspace list --full-id
|
||||
multica workspace list --output json
|
||||
```
|
||||
|
||||
Watched workspaces are marked with `*`. The daemon only processes tasks for watched workspaces.
|
||||
The current default workspace is marked with `*`. Table output shows short UUID prefixes — pass `--full-id` when you need the canonical UUIDs.
|
||||
|
||||
### Watch / Unwatch
|
||||
### Switch Default Workspace
|
||||
|
||||
```bash
|
||||
multica workspace watch <workspace-id>
|
||||
multica workspace unwatch <workspace-id>
|
||||
multica workspace switch <workspace-id>
|
||||
multica workspace switch <slug>
|
||||
```
|
||||
|
||||
Verifies you have access to the workspace, then sets it as the default for the current profile. Subsequent commands without `--workspace-id` and `MULTICA_WORKSPACE_ID` target this workspace. Pair `--profile` if you want to change a non-default profile's workspace.
|
||||
|
||||
### Get Details
|
||||
|
||||
```bash
|
||||
@@ -291,10 +307,12 @@ 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 members <workspace-id>
|
||||
multica workspace member list <workspace-id>
|
||||
```
|
||||
|
||||
## Issues
|
||||
@@ -326,7 +344,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 members --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 member list --output json` / `multica agent list --output json`.
|
||||
|
||||
### Update Issue
|
||||
|
||||
@@ -355,9 +373,44 @@ Valid statuses: `backlog`, `todo`, `in_progress`, `in_review`, `done`, `blocked`
|
||||
### Comments
|
||||
|
||||
```bash
|
||||
# List comments
|
||||
# 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.
|
||||
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"
|
||||
|
||||
@@ -368,6 +421,29 @@ multica issue comment add <issue-id> --parent <comment-id> --content "Thanks!"
|
||||
multica issue comment delete <comment-id>
|
||||
```
|
||||
|
||||
**`--before` / `--before-id` semantics depend on the paging mode**, by
|
||||
design — same flag, different scope:
|
||||
|
||||
| Mode | What the cursor walks | stderr label |
|
||||
| --- | --- | --- |
|
||||
| `--recent N` | Older *threads* (last_activity_at, root_id) | `Next thread cursor` |
|
||||
| `--thread <id> --tail N` | Older *replies* inside that thread (created_at, id) | `Next reply cursor` |
|
||||
|
||||
Outside those two modes (`--thread` without `--tail`, or no `--thread`
|
||||
and no `--recent`) the cursor flags are rejected so they cannot silently
|
||||
no-op. The server emits the cursor headers (`X-Multica-Next-Before` /
|
||||
`X-Multica-Next-Before-Id`) only when an older page actually exists —
|
||||
exact-boundary pages (e.g. `--tail 3` on a thread with exactly 3
|
||||
replies) intentionally return no cursor so callers stop paginating.
|
||||
|
||||
When `--since` is combined with `--recent` or `--thread --tail`, the
|
||||
server additionally suppresses the cursor once the cursor target itself
|
||||
is older than `since`. Older pages walk strictly older rows, so they
|
||||
cannot satisfy `> since` either — emitting a cursor there would just
|
||||
hand back root-only pages until the caller reaches the start of the
|
||||
thread / issue. Incremental polling stops at the first page whose
|
||||
cursor target falls before the watermark.
|
||||
|
||||
### Subscribers
|
||||
|
||||
```bash
|
||||
@@ -508,6 +584,8 @@ multica config set app_url https://app.example.com
|
||||
multica config set workspace_id <workspace-id>
|
||||
```
|
||||
|
||||
`config set workspace_id <id>` is the low-level interface — it writes the value verbatim without checking that the workspace exists or that you have access. Prefer `multica workspace switch <id|slug>` for day-to-day workspace changes; it does both checks before saving.
|
||||
|
||||
## Autopilot Commands
|
||||
|
||||
Autopilots are scheduled/triggered automations that dispatch agent tasks (either by creating an issue or by running an agent directly).
|
||||
|
||||
@@ -142,6 +142,8 @@ The `multica` CLI connects your local machine to Multica — authenticate, manag
|
||||
| `multica daemon status` | Check daemon status |
|
||||
| `multica setup` | One-command setup for Multica Cloud (configure + login + start daemon) |
|
||||
| `multica setup self-host` | Same, but for self-hosted deployments |
|
||||
| `multica workspace list` | List your workspaces (current is marked with `*`) |
|
||||
| `multica workspace switch <id\|slug>` | Switch the default workspace for this profile |
|
||||
| `multica issue list` | List issues in your workspace |
|
||||
| `multica issue create` | Create a new issue |
|
||||
| `multica update` | Update to the latest version |
|
||||
|
||||
@@ -79,7 +79,7 @@ For file uploads and attachments, configure S3 and (optionally) CloudFront:
|
||||
| `S3_BUCKET` | Bucket name only (e.g. `my-bucket`). Do **not** include the `.s3.<region>.amazonaws.com` suffix — the server constructs the public URL from `S3_BUCKET` + `S3_REGION` |
|
||||
| `S3_REGION` | AWS region (default: `us-west-2`). Must match the bucket's actual region — used for both SDK signing and public URLs |
|
||||
| `AWS_ACCESS_KEY_ID` / `AWS_SECRET_ACCESS_KEY` | Static credentials. When both are unset, the AWS SDK default credential chain is used |
|
||||
| `AWS_ENDPOINT_URL` | Custom S3-compatible endpoint (e.g. MinIO, R2, B2). Setting this switches the public URL to path-style |
|
||||
| `AWS_ENDPOINT_URL` | Custom S3-compatible endpoint (e.g. MinIO, R2, B2). Setting this switches to path-style URLs |
|
||||
| `CLOUDFRONT_DOMAIN` | CloudFront distribution domain — when set, public URLs use this host instead of the S3 host |
|
||||
| `CLOUDFRONT_KEY_PAIR_ID` | CloudFront key pair ID for signed URLs |
|
||||
| `CLOUDFRONT_PRIVATE_KEY` | CloudFront private key (PEM format) |
|
||||
|
||||
@@ -200,6 +200,57 @@ function createWindow(): void {
|
||||
}
|
||||
});
|
||||
|
||||
// Dev-mode renderer diagnostics. When the renderer crashes hard enough
|
||||
// that DevTools can't be opened (white screen with no clickable surface),
|
||||
// the only way to recover the actual JS error is to forward it from the
|
||||
// main process to the terminal running `make dev`. Without these, the
|
||||
// user sees only the daemon-manager polling noise (`Render frame was
|
||||
// disposed before WebFrameMain could be accessed`) which is a downstream
|
||||
// symptom, not the cause.
|
||||
//
|
||||
// Gated by `is.dev` to keep production stderr clean — packaged builds
|
||||
// don't have a terminal anyway, and we ship to crash-reporting separately.
|
||||
if (is.dev) {
|
||||
const log = (tag: string, ...args: unknown[]) =>
|
||||
process.stderr.write(`[renderer ${tag}] ${args.map(String).join(" ")}\n`);
|
||||
|
||||
// Forward every renderer-side console.* call. The detail object also
|
||||
// carries source URL + line — included so a thrown stack trace from
|
||||
// window.onerror is traceable back to a file.
|
||||
mainWindow.webContents.on("console-message", (details) => {
|
||||
const { level, message, sourceId, lineNumber } = details;
|
||||
log(level, `${message} (${sourceId}:${lineNumber})`);
|
||||
});
|
||||
|
||||
// Fires when the renderer process dies for any reason (OOM, crash,
|
||||
// killed). `details.reason` is the discriminator: "crashed", "oom",
|
||||
// "killed", "abnormal-exit", "launch-failed", etc.
|
||||
mainWindow.webContents.on("render-process-gone", (_event, details) => {
|
||||
log("process-gone", JSON.stringify(details));
|
||||
});
|
||||
|
||||
// Fires when loadURL / loadFile can't reach its target (dev server
|
||||
// not up yet, network blip, file missing). errorCode is a Chromium
|
||||
// net error number; -3 = ABORTED is normal during HMR and skipped.
|
||||
mainWindow.webContents.on(
|
||||
"did-fail-load",
|
||||
(_event, errorCode, errorDescription, validatedURL, isMainFrame) => {
|
||||
if (errorCode === -3) return;
|
||||
log(
|
||||
"did-fail-load",
|
||||
`code=${errorCode} desc=${errorDescription} url=${validatedURL} mainFrame=${isMainFrame}`,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
// Fires when the preload script throws before the renderer can boot.
|
||||
// This is the one error class that NEVER reaches DevTools (preload
|
||||
// runs before any window) — without this listener it's invisible.
|
||||
mainWindow.webContents.on("preload-error", (_event, preloadPath, error) => {
|
||||
log("preload-error", `path=${preloadPath} err=${error?.stack ?? error}`);
|
||||
});
|
||||
}
|
||||
|
||||
installContextMenu(mainWindow.webContents);
|
||||
|
||||
if (is.dev && process.env["ELECTRON_RENDERER_URL"]) {
|
||||
|
||||
@@ -13,7 +13,6 @@ 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";
|
||||
@@ -169,7 +168,6 @@ export function DesktopShell() {
|
||||
</div>
|
||||
{slug && <ModalRegistry />}
|
||||
{slug && <SearchCommand />}
|
||||
{slug && <StarterContentPrompt />}
|
||||
<WindowOverlay />
|
||||
</WorkspaceSlugProvider>
|
||||
</DesktopNavigationProvider>
|
||||
|
||||
@@ -19,10 +19,28 @@ 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(() => {
|
||||
window.daemonAPI.getStatus().then(setStatus);
|
||||
return window.daemonAPI.onStatusChange(setStatus);
|
||||
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);
|
||||
}, []);
|
||||
|
||||
const bootstrapping =
|
||||
@@ -32,9 +50,14 @@ export function DesktopRuntimesPage() {
|
||||
|
||||
return (
|
||||
<RuntimesPage
|
||||
localDaemonId={status.daemonId ?? null}
|
||||
localMachineName={status.deviceName ?? null}
|
||||
localDaemonId={status.daemonId ?? lastIdentity.daemonId}
|
||||
localMachineName={status.deviceName ?? lastIdentity.deviceName}
|
||||
localMachineActions={<DaemonRuntimeActions />}
|
||||
// Desktop owns a local machine for the lifetime of the app, even
|
||||
// while the daemon is stopped or hasn't registered yet. The shared
|
||||
// page synthesizes a placeholder local row when no real runtime
|
||||
// matches, so the Start button is always reachable.
|
||||
hasLocalMachine
|
||||
bootstrapping={bootstrapping}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -116,7 +116,7 @@ describe("PageviewTracker", () => {
|
||||
expect(state.capturePageview).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("fires pageview when a new tab is opened (openInNewTab / addTab)", () => {
|
||||
it("fires pageview when a foreground tab is added (addTab path)", () => {
|
||||
state.byWorkspace = {
|
||||
acme: {
|
||||
activeTabId: "tA",
|
||||
@@ -128,7 +128,11 @@ describe("PageviewTracker", () => {
|
||||
const { rerender } = render(<PageviewTracker />);
|
||||
state.capturePageview.mockClear();
|
||||
|
||||
// Simulate openInNewTab("/acme/agents") → new tab tC added and activated.
|
||||
// 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.
|
||||
state.byWorkspace = {
|
||||
acme: {
|
||||
activeTabId: "tC",
|
||||
|
||||
151
apps/desktop/src/renderer/src/components/tab-bar.test.tsx
Normal file
151
apps/desktop/src/renderer/src/components/tab-bar.test.tsx
Normal file
@@ -0,0 +1,151 @@
|
||||
import { describe, expect, it, vi, beforeEach } from "vitest";
|
||||
import { render, fireEvent, within } from "@testing-library/react";
|
||||
|
||||
type MockTab = {
|
||||
id: string;
|
||||
path: string;
|
||||
title: string;
|
||||
icon: string;
|
||||
pinned: boolean;
|
||||
};
|
||||
|
||||
const state = vi.hoisted(() => ({
|
||||
activeWorkspaceSlug: "acme" as string | null,
|
||||
byWorkspace: {
|
||||
acme: {
|
||||
activeTabId: "tA",
|
||||
tabs: [
|
||||
{ id: "tA", path: "/acme/issues", title: "Issues", icon: "ListTodo", pinned: false },
|
||||
{ id: "tB", path: "/acme/projects", title: "Projects", icon: "ListTodo", pinned: false },
|
||||
] as MockTab[],
|
||||
},
|
||||
} as Record<string, { activeTabId: string; tabs: MockTab[] }>,
|
||||
togglePin: vi.fn<(tabId: string) => void>(),
|
||||
closeTab: vi.fn<(tabId: string) => void>(),
|
||||
setActiveTab: vi.fn<(tabId: string) => void>(),
|
||||
moveTab: vi.fn<(from: number, to: number) => void>(),
|
||||
addTab: vi.fn<(path: string, title: string, icon: string) => string>(),
|
||||
}));
|
||||
|
||||
vi.mock("@/stores/tab-store", () => {
|
||||
const store = {
|
||||
get activeWorkspaceSlug() {
|
||||
return state.activeWorkspaceSlug;
|
||||
},
|
||||
get byWorkspace() {
|
||||
return state.byWorkspace;
|
||||
},
|
||||
togglePin: state.togglePin,
|
||||
closeTab: state.closeTab,
|
||||
setActiveTab: state.setActiveTab,
|
||||
moveTab: state.moveTab,
|
||||
addTab: state.addTab,
|
||||
};
|
||||
const useTabStore = Object.assign(
|
||||
(selector?: (s: typeof store) => unknown) =>
|
||||
selector ? selector(store) : store,
|
||||
{ getState: () => store },
|
||||
);
|
||||
const useActiveGroup = () =>
|
||||
state.activeWorkspaceSlug
|
||||
? (state.byWorkspace[state.activeWorkspaceSlug] ?? null)
|
||||
: null;
|
||||
const resolveRouteIcon = () => "ListTodo";
|
||||
return { useTabStore, useActiveGroup, resolveRouteIcon };
|
||||
});
|
||||
|
||||
vi.mock("@multica/core/paths", () => ({
|
||||
paths: {
|
||||
workspace: (slug: string) => ({
|
||||
issues: () => `/${slug}/issues`,
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
import { TabBar } from "./tab-bar";
|
||||
|
||||
function reset() {
|
||||
state.activeWorkspaceSlug = "acme";
|
||||
state.byWorkspace = {
|
||||
acme: {
|
||||
activeTabId: "tA",
|
||||
tabs: [
|
||||
{ id: "tA", path: "/acme/issues", title: "Issues", icon: "ListTodo", pinned: false },
|
||||
{ id: "tB", path: "/acme/projects", title: "Projects", icon: "ListTodo", pinned: false },
|
||||
],
|
||||
},
|
||||
};
|
||||
state.togglePin.mockReset();
|
||||
state.closeTab.mockReset();
|
||||
state.setActiveTab.mockReset();
|
||||
state.moveTab.mockReset();
|
||||
state.addTab.mockReset();
|
||||
}
|
||||
|
||||
beforeEach(reset);
|
||||
|
||||
describe("TabBar hover action buttons", () => {
|
||||
it("renders a Pin button on every unpinned tab and an Unpin button on every pinned tab", () => {
|
||||
state.byWorkspace.acme.tabs = [
|
||||
{ id: "tA", path: "/acme/issues", title: "Issues", icon: "ListTodo", pinned: true },
|
||||
{ id: "tB", path: "/acme/projects", title: "Projects", icon: "ListTodo", pinned: false },
|
||||
];
|
||||
const { getAllByLabelText } = render(<TabBar />);
|
||||
expect(getAllByLabelText("Unpin tab")).toHaveLength(1);
|
||||
expect(getAllByLabelText("Pin tab")).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("clicking the Pin button calls togglePin for the tab", () => {
|
||||
const { getAllByLabelText } = render(<TabBar />);
|
||||
const pinButtons = getAllByLabelText("Pin tab");
|
||||
fireEvent.click(pinButtons[1]); // click Pin on tB (Projects)
|
||||
expect(state.togglePin).toHaveBeenCalledWith("tB");
|
||||
});
|
||||
|
||||
it("clicking the Unpin button on a pinned tab calls togglePin", () => {
|
||||
state.byWorkspace.acme.tabs = [
|
||||
{ id: "tA", path: "/acme/issues", title: "Issues", icon: "ListTodo", pinned: true },
|
||||
{ id: "tB", path: "/acme/projects", title: "Projects", icon: "ListTodo", pinned: false },
|
||||
];
|
||||
const { getByLabelText } = render(<TabBar />);
|
||||
fireEvent.click(getByLabelText("Unpin tab"));
|
||||
expect(state.togglePin).toHaveBeenCalledWith("tA");
|
||||
});
|
||||
|
||||
it("hides the X close button on a pinned tab but keeps it on an unpinned tab", () => {
|
||||
state.byWorkspace.acme.tabs = [
|
||||
{ id: "tA", path: "/acme/issues", title: "Issues", icon: "ListTodo", pinned: true },
|
||||
{ id: "tB", path: "/acme/projects", title: "Projects", icon: "ListTodo", pinned: false },
|
||||
];
|
||||
const { queryAllByLabelText } = render(<TabBar />);
|
||||
// Only the unpinned tab exposes a Close affordance — pinned tab requires
|
||||
// explicit Unpin first (RFC §3 D3c FINAL).
|
||||
expect(queryAllByLabelText("Close tab")).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("keeps the full title visible on a pinned tab (no icon-only collapse)", () => {
|
||||
state.byWorkspace.acme.tabs = [
|
||||
{ id: "tA", path: "/acme/issues", title: "Issues", icon: "ListTodo", pinned: true },
|
||||
];
|
||||
const { getByLabelText } = render(<TabBar />);
|
||||
const pinnedTab = getByLabelText("Issues (pinned)");
|
||||
expect(within(pinnedTab).getByText("Issues")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("renders the Pin glyph as the leading icon on a pinned tab and the route icon on an unpinned tab", () => {
|
||||
state.byWorkspace.acme.tabs = [
|
||||
{ id: "tA", path: "/acme/issues", title: "Issues", icon: "ListTodo", pinned: true },
|
||||
{ id: "tB", path: "/acme/projects", title: "Projects", icon: "ListTodo", pinned: false },
|
||||
];
|
||||
const { getByLabelText } = render(<TabBar />);
|
||||
const pinnedTab = getByLabelText("Issues (pinned)");
|
||||
const unpinnedTab = getByLabelText("Projects");
|
||||
// lucide-react renders the icon name into the class list. The leading
|
||||
// slot icon is size-3.5; the hover Pin/Unpin action button is size-2.5,
|
||||
// so we qualify on size to avoid matching the action glyph.
|
||||
expect(pinnedTab.querySelector(".lucide-pin.size-3\\.5")).toBeTruthy();
|
||||
expect(pinnedTab.querySelector(".lucide-list-todo")).toBeNull();
|
||||
expect(unpinnedTab.querySelector(".lucide-list-todo.size-3\\.5")).toBeTruthy();
|
||||
expect(unpinnedTab.querySelector(".lucide-pin.size-3\\.5")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,3 +1,4 @@
|
||||
import { Fragment } from "react";
|
||||
import {
|
||||
Inbox,
|
||||
CircleUser,
|
||||
@@ -8,6 +9,8 @@ import {
|
||||
Settings,
|
||||
X,
|
||||
Plus,
|
||||
Pin,
|
||||
PinOff,
|
||||
type LucideIcon,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
@@ -28,8 +31,20 @@ 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> = {
|
||||
@@ -42,9 +57,23 @@ const TAB_ICONS: Record<string, LucideIcon> = {
|
||||
Settings,
|
||||
};
|
||||
|
||||
function SortableTabItem({ tab, isActive, isOnly }: { tab: Tab; isActive: boolean; isOnly: boolean }) {
|
||||
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;
|
||||
}) {
|
||||
const setActiveTab = useTabStore((s) => s.setActiveTab);
|
||||
const closeTab = useTabStore((s) => s.closeTab);
|
||||
const togglePin = useTabStore((s) => s.togglePin);
|
||||
|
||||
const {
|
||||
attributes,
|
||||
@@ -55,7 +84,11 @@ function SortableTabItem({ tab, isActive, isOnly }: { tab: Tab; isActive: boolea
|
||||
isDragging,
|
||||
} = useSortable({ id: tab.id });
|
||||
|
||||
const Icon = TAB_ICONS[tab.icon];
|
||||
// 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 style = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
@@ -74,17 +107,30 @@ function SortableTabItem({ tab, isActive, isOnly }: { tab: Tab; isActive: boolea
|
||||
closeTab(tab.id);
|
||||
};
|
||||
|
||||
const stopDragOnClose = (e: React.PointerEvent) => {
|
||||
const handleTogglePin = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
togglePin(tab.id);
|
||||
};
|
||||
|
||||
const stopDragOnAction = (e: React.PointerEvent) => {
|
||||
e.stopPropagation();
|
||||
};
|
||||
|
||||
return (
|
||||
// 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 = (
|
||||
<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",
|
||||
@@ -94,7 +140,7 @@ function SortableTabItem({ tab, isActive, isOnly }: { tab: Tab; isActive: boolea
|
||||
isDragging && "opacity-60",
|
||||
)}
|
||||
>
|
||||
{Icon && <Icon className="size-3.5 shrink-0" />}
|
||||
{LeadingIcon && <LeadingIcon className="size-3.5 shrink-0" />}
|
||||
<span
|
||||
className="min-w-0 flex-1 overflow-hidden whitespace-nowrap text-left"
|
||||
style={{
|
||||
@@ -104,10 +150,22 @@ function SortableTabItem({ tab, isActive, isOnly }: { tab: Tab; isActive: boolea
|
||||
>
|
||||
{tab.title}
|
||||
</span>
|
||||
{!isOnly && (
|
||||
<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 && (
|
||||
<span
|
||||
onClick={handleClose}
|
||||
onPointerDown={stopDragOnClose}
|
||||
onPointerDown={stopDragOnAction}
|
||||
role="button"
|
||||
aria-label="Close 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"
|
||||
>
|
||||
<X className="size-2.5" />
|
||||
@@ -115,6 +173,36 @@ function SortableTabItem({ tab, isActive, isOnly }: { tab: Tab; isActive: boolea
|
||||
)}
|
||||
</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() {
|
||||
@@ -155,12 +243,17 @@ 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);
|
||||
};
|
||||
|
||||
@@ -173,13 +266,22 @@ export function TabBar() {
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
<SortableContext items={tabIds} strategy={horizontalListSortingStrategy}>
|
||||
{tabs.map((tab) => (
|
||||
<SortableTabItem
|
||||
key={tab.id}
|
||||
tab={tab}
|
||||
isActive={tab.id === activeTabId}
|
||||
isOnly={tabs.length === 1}
|
||||
/>
|
||||
{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>
|
||||
))}
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
|
||||
@@ -62,18 +62,25 @@ function WindowOverlayInner() {
|
||||
{overlay.type === "invitations" && <InvitationsPage />}
|
||||
{overlay.type === "onboarding" && (
|
||||
<OnboardingFlow
|
||||
onComplete={(ws) => {
|
||||
onComplete={(ws, issueId) => {
|
||||
close();
|
||||
// Post-onboarding landing is always the workspace issues
|
||||
// list. The welcome-issue flow moved into a dialog that
|
||||
// renders on that page (StarterContentPrompt), so the
|
||||
// flow doesn't need to thread a target issue id back here.
|
||||
if (ws) {
|
||||
// Runtime-connected onboarding lands on its single guide
|
||||
// issue. Runtime-less exits still land on the issues list.
|
||||
if (ws && issueId) {
|
||||
push(paths.workspace(ws.slug).issueDetail(issueId));
|
||||
} else if (ws) {
|
||||
push(paths.workspace(ws.slug).issues());
|
||||
} else {
|
||||
push(paths.root());
|
||||
}
|
||||
}}
|
||||
// Restart the bundled daemon when the user hits Refresh on
|
||||
// Step 3. The daemon's PATH probe runs once at boot, so a
|
||||
// newly-installed CLI (Claude / Codex / Cursor) doesn't show
|
||||
// up until the daemon is bounced.
|
||||
onRuntimeRefresh={async () => {
|
||||
await window.daemonAPI?.restart?.();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
355
apps/desktop/src/renderer/src/platform/navigation.test.tsx
Normal file
355
apps/desktop/src/renderer/src/platform/navigation.test.tsx
Normal file
@@ -0,0 +1,355 @@
|
||||
import { describe, expect, it, vi, beforeEach } from "vitest";
|
||||
import { render } from "@testing-library/react";
|
||||
import { useEffect } from "react";
|
||||
|
||||
// Shared in-memory state that the mocked tab store reads / mutates. The test
|
||||
// records every method call so we can assert openInNewTab does NOT activate
|
||||
// the new tab (i.e. setActiveTab is never invoked on the same-workspace path).
|
||||
type MockRouter = {
|
||||
state: { location: { pathname: string } };
|
||||
navigate: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
|
||||
type MockTab = {
|
||||
id: string;
|
||||
path: string;
|
||||
pinned: boolean;
|
||||
router: MockRouter;
|
||||
};
|
||||
|
||||
function makeMockRouter(pathname: string): MockRouter {
|
||||
return {
|
||||
state: { location: { pathname } },
|
||||
navigate: vi.fn(),
|
||||
};
|
||||
}
|
||||
|
||||
const state = vi.hoisted(() => ({
|
||||
activeWorkspaceSlug: "acme" as string | null,
|
||||
byWorkspace: {
|
||||
acme: {
|
||||
activeTabId: "tA",
|
||||
tabs: [
|
||||
{
|
||||
id: "tA",
|
||||
path: "/acme/issues",
|
||||
pinned: false,
|
||||
router: makeMockRouter("/acme/issues"),
|
||||
},
|
||||
] as MockTab[],
|
||||
},
|
||||
} as Record<string, { activeTabId: string; tabs: MockTab[] }>,
|
||||
openTab: vi.fn<(path: string, title?: string, icon?: string) => string>(),
|
||||
setActiveTab: vi.fn<(tabId: string) => void>(),
|
||||
switchWorkspace: vi.fn<(slug: string, openPath?: string) => void>(),
|
||||
}));
|
||||
|
||||
vi.mock("@/stores/tab-store", () => {
|
||||
const store = {
|
||||
get activeWorkspaceSlug() {
|
||||
return state.activeWorkspaceSlug;
|
||||
},
|
||||
get byWorkspace() {
|
||||
return state.byWorkspace;
|
||||
},
|
||||
openTab: state.openTab,
|
||||
setActiveTab: state.setActiveTab,
|
||||
switchWorkspace: state.switchWorkspace,
|
||||
};
|
||||
const useTabStore = Object.assign(
|
||||
(selector?: (s: typeof store) => unknown) =>
|
||||
selector ? selector(store) : store,
|
||||
{ getState: () => store },
|
||||
);
|
||||
const getActiveTab = () => {
|
||||
const slug = state.activeWorkspaceSlug;
|
||||
if (!slug) return null;
|
||||
const group = state.byWorkspace[slug];
|
||||
if (!group) return null;
|
||||
return group.tabs.find((t) => t.id === group.activeTabId) ?? null;
|
||||
};
|
||||
const useActiveTabIdentity = () => ({
|
||||
slug: state.activeWorkspaceSlug,
|
||||
tabId: state.activeWorkspaceSlug
|
||||
? (state.byWorkspace[state.activeWorkspaceSlug]?.activeTabId ?? null)
|
||||
: null,
|
||||
});
|
||||
const useActiveTabRouter = () => null;
|
||||
const resolveRouteIcon = () => "File";
|
||||
return {
|
||||
useTabStore,
|
||||
getActiveTab,
|
||||
useActiveTabIdentity,
|
||||
useActiveTabRouter,
|
||||
resolveRouteIcon,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/stores/window-overlay-store", () => ({
|
||||
useWindowOverlayStore: Object.assign(
|
||||
() => null,
|
||||
{ getState: () => ({ overlay: null, open: vi.fn(), close: vi.fn() }) },
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("@multica/core/auth", () => ({
|
||||
useAuthStore: Object.assign(
|
||||
() => null,
|
||||
{ getState: () => ({ logout: vi.fn() }) },
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("@multica/core/paths", () => ({
|
||||
isReservedSlug: (s: string) =>
|
||||
["login", "workspaces", "invite", "onboarding", "invitations"].includes(s),
|
||||
}));
|
||||
|
||||
// DesktopNavigationProvider reads window.desktopAPI.runtimeConfig synchronously.
|
||||
beforeEach(() => {
|
||||
state.openTab.mockReset();
|
||||
state.setActiveTab.mockReset();
|
||||
state.switchWorkspace.mockReset();
|
||||
state.openTab.mockImplementation(() => "tNew");
|
||||
state.activeWorkspaceSlug = "acme";
|
||||
state.byWorkspace = {
|
||||
acme: {
|
||||
activeTabId: "tA",
|
||||
tabs: [
|
||||
{
|
||||
id: "tA",
|
||||
path: "/acme/issues",
|
||||
pinned: false,
|
||||
router: makeMockRouter("/acme/issues"),
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
Object.defineProperty(window, "desktopAPI", {
|
||||
configurable: true,
|
||||
value: {
|
||||
runtimeConfig: { ok: true, config: { appUrl: "https://app.example" } },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
import {
|
||||
DesktopNavigationProvider,
|
||||
TabNavigationProvider,
|
||||
} from "./navigation";
|
||||
import { useNavigation } from "@multica/views/navigation";
|
||||
|
||||
function captureAdapter(onAdapter: (adapter: ReturnType<typeof useNavigation>) => void) {
|
||||
function Probe() {
|
||||
const nav = useNavigation();
|
||||
useEffect(() => {
|
||||
onAdapter(nav);
|
||||
}, [nav]);
|
||||
return null;
|
||||
}
|
||||
return Probe;
|
||||
}
|
||||
|
||||
describe("DesktopNavigationProvider.openInNewTab", () => {
|
||||
it("opens a background tab (no setActiveTab) for a same-workspace path", () => {
|
||||
let adapter: ReturnType<typeof useNavigation> | null = null;
|
||||
const Probe = captureAdapter((a) => {
|
||||
adapter = a;
|
||||
});
|
||||
render(
|
||||
<DesktopNavigationProvider>
|
||||
<Probe />
|
||||
</DesktopNavigationProvider>,
|
||||
);
|
||||
expect(adapter).not.toBeNull();
|
||||
adapter!.openInNewTab!("/acme/agents", "Agents");
|
||||
expect(state.openTab).toHaveBeenCalledWith("/acme/agents", "Agents", "File");
|
||||
expect(state.setActiveTab).not.toHaveBeenCalled();
|
||||
expect(state.switchWorkspace).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("activates the new tab when opts.activate is true (foreground)", () => {
|
||||
let adapter: ReturnType<typeof useNavigation> | null = null;
|
||||
const Probe = captureAdapter((a) => {
|
||||
adapter = a;
|
||||
});
|
||||
render(
|
||||
<DesktopNavigationProvider>
|
||||
<Probe />
|
||||
</DesktopNavigationProvider>,
|
||||
);
|
||||
adapter!.openInNewTab!("/acme/agents", "Agents", { activate: true });
|
||||
expect(state.openTab).toHaveBeenCalledWith("/acme/agents", "Agents", "File");
|
||||
expect(state.setActiveTab).toHaveBeenCalledWith("tNew");
|
||||
expect(state.switchWorkspace).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("delegates to switchWorkspace for a cross-workspace path", () => {
|
||||
let adapter: ReturnType<typeof useNavigation> | null = null;
|
||||
const Probe = captureAdapter((a) => {
|
||||
adapter = a;
|
||||
});
|
||||
render(
|
||||
<DesktopNavigationProvider>
|
||||
<Probe />
|
||||
</DesktopNavigationProvider>,
|
||||
);
|
||||
adapter!.openInNewTab!("/butter/inbox");
|
||||
expect(state.switchWorkspace).toHaveBeenCalledWith("butter", "/butter/inbox");
|
||||
expect(state.openTab).not.toHaveBeenCalled();
|
||||
expect(state.setActiveTab).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("DesktopNavigationProvider.push with pinned active tab", () => {
|
||||
function pinActive(pathname: string) {
|
||||
state.byWorkspace.acme.tabs[0] = {
|
||||
id: "tA",
|
||||
path: pathname,
|
||||
pinned: true,
|
||||
router: makeMockRouter(pathname),
|
||||
};
|
||||
}
|
||||
|
||||
it("redirects push to a new foreground tab when pathname differs", () => {
|
||||
pinActive("/acme/issues");
|
||||
let adapter: ReturnType<typeof useNavigation> | null = null;
|
||||
const Probe = captureAdapter((a) => {
|
||||
adapter = a;
|
||||
});
|
||||
render(
|
||||
<DesktopNavigationProvider>
|
||||
<Probe />
|
||||
</DesktopNavigationProvider>,
|
||||
);
|
||||
adapter!.push("/acme/projects");
|
||||
expect(state.openTab).toHaveBeenCalledWith("/acme/projects", "/acme/projects", "File");
|
||||
expect(state.setActiveTab).toHaveBeenCalledWith("tNew");
|
||||
});
|
||||
|
||||
it("allows in-tab navigation when only search/hash changes", () => {
|
||||
pinActive("/acme/issues");
|
||||
let adapter: ReturnType<typeof useNavigation> | null = null;
|
||||
const Probe = captureAdapter((a) => {
|
||||
adapter = a;
|
||||
});
|
||||
render(
|
||||
<DesktopNavigationProvider>
|
||||
<Probe />
|
||||
</DesktopNavigationProvider>,
|
||||
);
|
||||
adapter!.push("/acme/issues?filter=open");
|
||||
// Pathname unchanged → pinned interception declines and falls through to
|
||||
// the router's own navigate — openTab / setActiveTab must not fire.
|
||||
expect(state.openTab).not.toHaveBeenCalled();
|
||||
expect(state.setActiveTab).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("leaves cross-workspace push to the workspace switcher (not pin)", () => {
|
||||
pinActive("/acme/issues");
|
||||
let adapter: ReturnType<typeof useNavigation> | null = null;
|
||||
const Probe = captureAdapter((a) => {
|
||||
adapter = a;
|
||||
});
|
||||
render(
|
||||
<DesktopNavigationProvider>
|
||||
<Probe />
|
||||
</DesktopNavigationProvider>,
|
||||
);
|
||||
adapter!.push("/butter/inbox");
|
||||
// Cross-workspace push runs through tryRouteToOtherWorkspace before
|
||||
// tryRouteToPinnedNewTab, so switchWorkspace wins.
|
||||
expect(state.switchWorkspace).toHaveBeenCalledWith("butter", "/butter/inbox");
|
||||
expect(state.openTab).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("TabNavigationProvider.openInNewTab", () => {
|
||||
function renderTabProvider() {
|
||||
let adapter: ReturnType<typeof useNavigation> | null = null;
|
||||
const Probe = captureAdapter((a) => {
|
||||
adapter = a;
|
||||
});
|
||||
const fakeRouter = {
|
||||
state: { location: { pathname: "/acme/issues", search: "" } },
|
||||
subscribe: () => () => {},
|
||||
navigate: vi.fn(),
|
||||
} as unknown as Parameters<typeof TabNavigationProvider>[0]["router"];
|
||||
render(
|
||||
<TabNavigationProvider router={fakeRouter}>
|
||||
<Probe />
|
||||
</TabNavigationProvider>,
|
||||
);
|
||||
return () => adapter!;
|
||||
}
|
||||
|
||||
it("opens a background tab (no setActiveTab) for a same-workspace path", () => {
|
||||
const getAdapter = renderTabProvider();
|
||||
getAdapter().openInNewTab!("/acme/agents", "Agents");
|
||||
expect(state.openTab).toHaveBeenCalledWith("/acme/agents", "Agents", "File");
|
||||
expect(state.setActiveTab).not.toHaveBeenCalled();
|
||||
expect(state.switchWorkspace).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("activates the new tab when opts.activate is true (foreground)", () => {
|
||||
const getAdapter = renderTabProvider();
|
||||
getAdapter().openInNewTab!("/acme/agents", "Agents", { activate: true });
|
||||
expect(state.openTab).toHaveBeenCalledWith("/acme/agents", "Agents", "File");
|
||||
expect(state.setActiveTab).toHaveBeenCalledWith("tNew");
|
||||
expect(state.switchWorkspace).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("TabNavigationProvider.push with pinned active tab", () => {
|
||||
type ProviderRouter = Parameters<typeof TabNavigationProvider>[0]["router"];
|
||||
|
||||
function renderPinnedTabProvider(pathname: string) {
|
||||
// The active tab and the per-tab router must share the same pathname:
|
||||
// tryRouteToPinnedNewTab reads the *active tab's* router for the current
|
||||
// pathname (so query-only pushes routed via React Router still compare
|
||||
// correctly), while the TabNavigationProvider falls back to *its own*
|
||||
// router.navigate when no interception fires. In real desktop usage they
|
||||
// are the same router instance; this helper mirrors that invariant.
|
||||
const fakeRouter = {
|
||||
state: { location: { pathname, search: "" } },
|
||||
subscribe: () => () => {},
|
||||
navigate: vi.fn(),
|
||||
} as unknown as ProviderRouter;
|
||||
state.byWorkspace.acme.tabs[0] = {
|
||||
id: "tA",
|
||||
path: pathname,
|
||||
pinned: true,
|
||||
router: fakeRouter as unknown as MockRouter,
|
||||
};
|
||||
|
||||
let adapter: ReturnType<typeof useNavigation> | null = null;
|
||||
const Probe = captureAdapter((a) => {
|
||||
adapter = a;
|
||||
});
|
||||
render(
|
||||
<TabNavigationProvider router={fakeRouter}>
|
||||
<Probe />
|
||||
</TabNavigationProvider>,
|
||||
);
|
||||
return { getAdapter: () => adapter!, fakeRouter };
|
||||
}
|
||||
|
||||
it("redirects push to a new foreground tab when pathname differs", () => {
|
||||
const { getAdapter, fakeRouter } = renderPinnedTabProvider("/acme/issues");
|
||||
getAdapter().push("/acme/projects");
|
||||
expect(state.openTab).toHaveBeenCalledWith("/acme/projects", "/acme/projects", "File");
|
||||
expect(state.setActiveTab).toHaveBeenCalledWith("tNew");
|
||||
// Pinned interception short-circuits — the per-tab router must NOT
|
||||
// navigate, otherwise the pinned tab itself would move off its path.
|
||||
expect(fakeRouter.navigate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("allows in-tab navigation when only search/hash changes", () => {
|
||||
const { getAdapter, fakeRouter } = renderPinnedTabProvider("/acme/issues");
|
||||
getAdapter().push("/acme/issues?filter=open");
|
||||
// Same pathname → pinned interception declines, push falls through to
|
||||
// the tab's own router.navigate, and no new tab is opened.
|
||||
expect(state.openTab).not.toHaveBeenCalled();
|
||||
expect(state.setActiveTab).not.toHaveBeenCalled();
|
||||
expect(fakeRouter.navigate).toHaveBeenCalledWith("/acme/issues?filter=open");
|
||||
});
|
||||
});
|
||||
@@ -108,6 +108,37 @@ 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).
|
||||
@@ -165,6 +196,7 @@ 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) => {
|
||||
@@ -178,9 +210,16 @@ export function DesktopNavigationProvider({
|
||||
},
|
||||
pathname: location.pathname,
|
||||
searchParams: new URLSearchParams(location.search),
|
||||
openInNewTab: (path: string, title?: string) => {
|
||||
openInNewTab: (
|
||||
path: string,
|
||||
title?: string,
|
||||
opts?: { activate?: boolean },
|
||||
) => {
|
||||
// Cross-workspace "open in new tab" switches workspace and opens
|
||||
// the path there; same-workspace just adds a tab in the current group.
|
||||
// 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.
|
||||
const slug = extractWorkspaceSlug(path);
|
||||
const store = useTabStore.getState();
|
||||
if (slug && slug !== store.activeWorkspaceSlug) {
|
||||
@@ -188,8 +227,10 @@ export function DesktopNavigationProvider({
|
||||
return;
|
||||
}
|
||||
const icon = resolveRouteIcon(path);
|
||||
const tabId = store.openTab(path, title ?? path, icon);
|
||||
if (tabId) store.setActiveTab(tabId);
|
||||
const newId = store.openTab(path, title ?? path, icon);
|
||||
if (opts?.activate && newId) {
|
||||
store.setActiveTab(newId);
|
||||
}
|
||||
},
|
||||
getShareableUrl: (path: string) => `${appUrl}${path}`,
|
||||
}),
|
||||
@@ -231,6 +272,7 @@ 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) => {
|
||||
@@ -241,7 +283,11 @@ export function TabNavigationProvider({
|
||||
back: () => router.navigate(-1),
|
||||
pathname: location.pathname,
|
||||
searchParams: new URLSearchParams(location.search),
|
||||
openInNewTab: (path: string, title?: string) => {
|
||||
openInNewTab: (
|
||||
path: string,
|
||||
title?: string,
|
||||
opts?: { activate?: boolean },
|
||||
) => {
|
||||
const slug = extractWorkspaceSlug(path);
|
||||
const store = useTabStore.getState();
|
||||
if (slug && slug !== store.activeWorkspaceSlug) {
|
||||
@@ -249,8 +295,10 @@ export function TabNavigationProvider({
|
||||
return;
|
||||
}
|
||||
const icon = resolveRouteIcon(path);
|
||||
const tabId = store.openTab(path, title ?? path, icon);
|
||||
if (tabId) store.setActiveTab(tabId);
|
||||
const newId = store.openTab(path, title ?? path, icon);
|
||||
if (opts?.activate && newId) {
|
||||
store.setActiveTab(newId);
|
||||
}
|
||||
},
|
||||
getShareableUrl: (path: string) => `${appUrl}${path}`,
|
||||
}),
|
||||
|
||||
@@ -17,6 +17,7 @@ vi.mock("../routes", () => ({
|
||||
import {
|
||||
sanitizeTabPath,
|
||||
migrateV1ToV2,
|
||||
migrateV2ToV3,
|
||||
useTabStore,
|
||||
} from "./tab-store";
|
||||
|
||||
@@ -277,3 +278,155 @@ describe("useTabStore actions", () => {
|
||||
expect(useTabStore.getState().activeWorkspaceSlug).toBe("acme");
|
||||
});
|
||||
});
|
||||
|
||||
describe("togglePin", () => {
|
||||
it("flips a tab's pinned state", () => {
|
||||
const store = useTabStore.getState();
|
||||
store.switchWorkspace("acme");
|
||||
const tabId = useTabStore.getState().byWorkspace.acme.tabs[0].id;
|
||||
expect(useTabStore.getState().byWorkspace.acme.tabs[0].pinned).toBe(false);
|
||||
|
||||
store.togglePin(tabId);
|
||||
expect(useTabStore.getState().byWorkspace.acme.tabs[0].pinned).toBe(true);
|
||||
|
||||
store.togglePin(tabId);
|
||||
expect(useTabStore.getState().byWorkspace.acme.tabs[0].pinned).toBe(false);
|
||||
});
|
||||
|
||||
it("moves a newly-pinned tab to the start of the pinned zone", () => {
|
||||
const store = useTabStore.getState();
|
||||
store.switchWorkspace("acme"); // creates default unpinned tab at index 0
|
||||
store.addTab("/acme/projects", "Projects", "FolderKanban");
|
||||
store.addTab("/acme/agents", "Agents", "Bot");
|
||||
const agentsId = useTabStore.getState().byWorkspace.acme.tabs[2].id;
|
||||
|
||||
store.togglePin(agentsId);
|
||||
const tabs = useTabStore.getState().byWorkspace.acme.tabs;
|
||||
expect(tabs[0].id).toBe(agentsId);
|
||||
expect(tabs[0].pinned).toBe(true);
|
||||
expect(tabs[1].pinned).toBe(false);
|
||||
expect(tabs[2].pinned).toBe(false);
|
||||
});
|
||||
|
||||
it("appends a second pinned tab after the first pinned tab", () => {
|
||||
const store = useTabStore.getState();
|
||||
store.switchWorkspace("acme");
|
||||
store.addTab("/acme/projects", "Projects", "FolderKanban");
|
||||
store.addTab("/acme/agents", "Agents", "Bot");
|
||||
const projectsId = useTabStore.getState().byWorkspace.acme.tabs[1].id;
|
||||
const agentsId = useTabStore.getState().byWorkspace.acme.tabs[2].id;
|
||||
|
||||
store.togglePin(agentsId);
|
||||
store.togglePin(projectsId);
|
||||
|
||||
// Both pinned, in the order they were pinned (agents first, projects
|
||||
// second), then the unpinned default tab.
|
||||
const tabs = useTabStore.getState().byWorkspace.acme.tabs;
|
||||
expect(tabs.map((t) => t.id)).toEqual([
|
||||
agentsId,
|
||||
projectsId,
|
||||
tabs[2].id,
|
||||
]);
|
||||
expect(tabs.map((t) => t.pinned)).toEqual([true, true, false]);
|
||||
});
|
||||
|
||||
it("returns an unpinned tab to the start of the unpinned zone", () => {
|
||||
const store = useTabStore.getState();
|
||||
store.switchWorkspace("acme");
|
||||
store.addTab("/acme/projects", "Projects", "FolderKanban");
|
||||
const issuesId = useTabStore.getState().byWorkspace.acme.tabs[0].id;
|
||||
const projectsId = useTabStore.getState().byWorkspace.acme.tabs[1].id;
|
||||
|
||||
// Pin both, then unpin one.
|
||||
store.togglePin(issuesId);
|
||||
store.togglePin(projectsId);
|
||||
store.togglePin(issuesId);
|
||||
|
||||
const tabs = useTabStore.getState().byWorkspace.acme.tabs;
|
||||
expect(tabs.map((t) => t.id)).toEqual([projectsId, issuesId]);
|
||||
expect(tabs.map((t) => t.pinned)).toEqual([true, false]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("moveTab boundary clamp", () => {
|
||||
it("clamps a pinned-tab move so it never crosses into the unpinned zone", () => {
|
||||
const store = useTabStore.getState();
|
||||
store.switchWorkspace("acme");
|
||||
store.addTab("/acme/projects", "Projects", "FolderKanban");
|
||||
store.addTab("/acme/agents", "Agents", "Bot");
|
||||
const issuesId = useTabStore.getState().byWorkspace.acme.tabs[0].id;
|
||||
|
||||
store.togglePin(issuesId); // [issues(pinned), projects, agents]
|
||||
|
||||
// User tries to drag the pinned tab to index 2 (unpinned zone end).
|
||||
store.moveTab(0, 2);
|
||||
const tabs = useTabStore.getState().byWorkspace.acme.tabs;
|
||||
// It should be clamped to index 0 — the only pinned slot — i.e. unchanged.
|
||||
expect(tabs[0].id).toBe(issuesId);
|
||||
expect(tabs.map((t) => t.pinned)).toEqual([true, false, false]);
|
||||
});
|
||||
|
||||
it("clamps an unpinned-tab move so it never crosses into the pinned zone", () => {
|
||||
const store = useTabStore.getState();
|
||||
store.switchWorkspace("acme");
|
||||
store.addTab("/acme/projects", "Projects", "FolderKanban");
|
||||
store.addTab("/acme/agents", "Agents", "Bot");
|
||||
const issuesId = useTabStore.getState().byWorkspace.acme.tabs[0].id;
|
||||
const agentsId = useTabStore.getState().byWorkspace.acme.tabs[2].id;
|
||||
|
||||
store.togglePin(issuesId); // [issues(pinned), projects, agents]
|
||||
|
||||
// User tries to drag agents (index 2) to index 0 (pinned zone).
|
||||
store.moveTab(2, 0);
|
||||
const tabs = useTabStore.getState().byWorkspace.acme.tabs;
|
||||
// Clamped to index 1 — start of the unpinned zone.
|
||||
expect(tabs[0].id).toBe(issuesId);
|
||||
expect(tabs[1].id).toBe(agentsId);
|
||||
expect(tabs.map((t) => t.pinned)).toEqual([true, false, false]);
|
||||
});
|
||||
|
||||
it("reorders freely within the same zone", () => {
|
||||
const store = useTabStore.getState();
|
||||
store.switchWorkspace("acme");
|
||||
store.addTab("/acme/projects", "Projects", "FolderKanban");
|
||||
store.addTab("/acme/agents", "Agents", "Bot");
|
||||
|
||||
// All unpinned; move agents (2) to position 0.
|
||||
store.moveTab(2, 0);
|
||||
const tabs = useTabStore.getState().byWorkspace.acme.tabs;
|
||||
expect(tabs.map((t) => t.path)).toEqual([
|
||||
"/acme/agents",
|
||||
"/acme/issues",
|
||||
"/acme/projects",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("migrateV2ToV3", () => {
|
||||
it("adds pinned=false to every persisted tab", () => {
|
||||
const v2 = {
|
||||
activeWorkspaceSlug: "acme",
|
||||
byWorkspace: {
|
||||
acme: {
|
||||
activeTabId: "t1",
|
||||
tabs: [
|
||||
{ id: "t1", path: "/acme/issues", title: "Issues", icon: "ListTodo" },
|
||||
{ id: "t2", path: "/acme/projects", title: "Projects", icon: "FolderKanban" },
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
const v3 = migrateV2ToV3(v2);
|
||||
expect(v3.activeWorkspaceSlug).toBe("acme");
|
||||
expect(v3.byWorkspace.acme.tabs).toEqual([
|
||||
{ id: "t1", path: "/acme/issues", title: "Issues", icon: "ListTodo", pinned: false },
|
||||
{ id: "t2", path: "/acme/projects", title: "Projects", icon: "FolderKanban", pinned: false },
|
||||
]);
|
||||
});
|
||||
|
||||
it("handles missing byWorkspace gracefully", () => {
|
||||
const v3 = migrateV2ToV3({ activeWorkspaceSlug: null } as Parameters<typeof migrateV2ToV3>[0]);
|
||||
expect(v3.byWorkspace).toEqual({});
|
||||
expect(v3.activeWorkspaceSlug).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -20,6 +20,14 @@ 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 {
|
||||
@@ -78,8 +86,20 @@ 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. */
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
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
|
||||
@@ -190,9 +210,17 @@ 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`;
|
||||
@@ -453,17 +481,63 @@ 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, toIndex),
|
||||
tabs: arrayMove(group.tabs, fromIndex, clampedTo),
|
||||
},
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
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;
|
||||
@@ -497,17 +571,23 @@ export const useTabStore = create<TabStore>()(
|
||||
}),
|
||||
{
|
||||
name: "multica_tabs",
|
||||
version: 2,
|
||||
version: 3,
|
||||
storage: createJSONStorage(() => createPersistStorage(defaultStorage)),
|
||||
migrate: (persistedState, version) => {
|
||||
// v1 → v2: flat `tabs` array → per-workspace grouping.
|
||||
// Tabs whose path isn't workspace-scoped (root `/`, login, etc.)
|
||||
// are dropped — they have no workspace to belong to, and the new
|
||||
// model's invariant is "every tab lives in a workspace group".
|
||||
if (version < 2 && persistedState && typeof persistedState === "object") {
|
||||
return migrateV1ToV2(persistedState as Partial<V1Persisted>);
|
||||
let state = persistedState;
|
||||
if (version < 2 && state && typeof state === "object") {
|
||||
state = migrateV1ToV2(state as Partial<V1Persisted>);
|
||||
}
|
||||
return persistedState as V2Persisted;
|
||||
// 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;
|
||||
},
|
||||
partialize: (state) => ({
|
||||
activeWorkspaceSlug: state.activeWorkspaceSlug,
|
||||
@@ -517,15 +597,19 @@ 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<V2Persisted> | undefined;
|
||||
const persisted = persistedState as Partial<V3Persisted> | undefined;
|
||||
if (!persisted?.byWorkspace) return currentState;
|
||||
|
||||
const byWorkspace: Record<string, WorkspaceTabGroup> = {};
|
||||
@@ -552,9 +636,14 @@ 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;
|
||||
@@ -605,6 +694,38 @@ interface V2Persisted {
|
||||
byWorkspace: Record<string, V2PersistedGroup>;
|
||||
}
|
||||
|
||||
interface V3PersistedTab {
|
||||
id: string;
|
||||
path: string;
|
||||
title: string;
|
||||
icon: string;
|
||||
pinned: boolean;
|
||||
}
|
||||
|
||||
interface V3PersistedGroup {
|
||||
tabs: V3PersistedTab[];
|
||||
activeTabId: string;
|
||||
}
|
||||
|
||||
interface V3Persisted {
|
||||
activeWorkspaceSlug: string | null;
|
||||
byWorkspace: Record<string, V3PersistedGroup>;
|
||||
}
|
||||
|
||||
export function migrateV2ToV3(v2: V2Persisted): V3Persisted {
|
||||
const byWorkspace: Record<string, V3PersistedGroup> = {};
|
||||
for (const [slug, group] of Object.entries(v2.byWorkspace ?? {})) {
|
||||
byWorkspace[slug] = {
|
||||
activeTabId: group.activeTabId,
|
||||
tabs: group.tabs.map((t) => ({ ...t, pinned: false })),
|
||||
};
|
||||
}
|
||||
return {
|
||||
activeWorkspaceSlug: v2.activeWorkspaceSlug ?? null,
|
||||
byWorkspace,
|
||||
};
|
||||
}
|
||||
|
||||
export function migrateV1ToV2(v1: Partial<V1Persisted>): V2Persisted {
|
||||
const byWorkspace: Record<string, V2PersistedGroup> = {};
|
||||
const oldTabs = v1.tabs ?? [];
|
||||
|
||||
@@ -5,7 +5,7 @@ description: "An agent is a first-class member of a Multica workspace — it can
|
||||
|
||||
import { Callout } from "fumadocs-ui/components/callout";
|
||||
|
||||
An agent is a **first-class member** of a Multica [workspace](/workspaces) — like a human, it can be [assigned issues](/assigning-issues), speak up in [comments](/comments), be [`@`-mentioned](/mentioning-agents), and lead a [project](/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.
|
||||
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.
|
||||
|
||||
## 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](/issues)** — it can be set as project lead, same as a human
|
||||
- **Lead a [project](/projects)** — it can be set as project lead, same as a human
|
||||
- **Open [issues](/issues) itself** — while running a task, if it spots a related problem, it can create a new issue directly
|
||||
|
||||
From the collaboration view, an agent is just a member of the workspace — its name sits in the same member list as humans, usually with a small robot icon in front.
|
||||
|
||||
@@ -5,7 +5,7 @@ description: 智能体(agent)是 Multica 工作区里的一等公民成员
|
||||
|
||||
import { Callout } from "fumadocs-ui/components/callout";
|
||||
|
||||
智能体(agent)是 Multica [工作区](/workspaces) 里的**一等公民成员**——和人一样能被 [分配 issue](/assigning-issues)、在 [评论](/comments) 里发言、被 [`@` 点名](/mentioning-agents)、作为 [project](/issues) 的负责人。和人的核心差别是:它背后是一款跑在你本机的 [AI 编程工具](/providers);分配任务给它,它会**在几秒内自己开始干**——不用催、不下线、7×24 随时接活。
|
||||
智能体(agent)是 Multica [工作区](/workspaces) 里的**一等公民成员**——和人一样能被 [分配 issue](/assigning-issues)、在 [评论](/comments) 里发言、被 [`@` 点名](/mentioning-agents)、作为 [project](/projects) 的负责人。和人的核心差别是:它背后是一款跑在你本机的 [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](/issues) 的负责人** —— 和人一样能被设为 project lead
|
||||
- **作为 [project](/projects) 的负责人** —— 和人一样能被设为 project lead
|
||||
- **自己开 [issue](/issues)** —— 跑任务时如果发现了关联问题,它能直接创建新的 issue
|
||||
|
||||
从协作视图上看,智能体就是工作区里的一个成员;它和人的名字排在同一张成员列表里,只是前面通常有一个机器人图标。
|
||||
|
||||
@@ -35,7 +35,7 @@ multica issue assign MUL-42 --to alice
|
||||
multica issue assign MUL-42 --to-id 5fb87ac7-23b5-4a7a-81fa-ed295a54545d
|
||||
```
|
||||
|
||||
`--to` takes a member username or an agent name (fuzzy match). When names overlap — e.g. an agent `J` alongside `Cursor - J` — pass `--to-id <uuid>` instead, using the `user_id` (member) or `id` (agent) from `multica workspace 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.
|
||||
`--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.
|
||||
|
||||
Unassign:
|
||||
|
||||
|
||||
@@ -35,7 +35,7 @@ multica issue assign MUL-42 --to alice
|
||||
multica issue assign MUL-42 --to-id 5fb87ac7-23b5-4a7a-81fa-ed295a54545d
|
||||
```
|
||||
|
||||
`--to` 后跟成员用户名或智能体名字(模糊匹配)。如果工作区里有同名 / 互相含子串的成员或智能体(例如 agent `J` 旁边还有 `Cursor - J`),改用 `--to-id <uuid>`:UUID 来自 `multica workspace members --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 member list --output json` 的 `user_id` 或 `multica agent list --output json` 的 `id`,是唯一精确的方式,特别适合脚本和驱动 CLI 的智能体。`--to` 和 `--to-id` 互斥。
|
||||
|
||||
取消分配:
|
||||
|
||||
|
||||
@@ -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 members` | List members of the current workspace |
|
||||
| `multica workspace member list` | List members of the current workspace |
|
||||
| `multica workspace update <id> --name "..." [--description "..."] [--context "..."] [--issue-prefix "..."]` | Update workspace metadata (admin/owner). Long fields accept `--description-stdin` / `--context-stdin`. |
|
||||
|
||||
## Issues and projects
|
||||
|
||||
@@ -39,7 +39,7 @@ Token 类型的详细区分见 [认证与令牌](/auth-tokens)。
|
||||
|---|---|
|
||||
| `multica workspace list` | 列出你有权访问的所有工作区 |
|
||||
| `multica workspace get <slug>` | 查看一个工作区的详情 |
|
||||
| `multica workspace members` | 列出当前工作区的成员 |
|
||||
| `multica workspace member list` | 列出当前工作区的成员 |
|
||||
| `multica workspace update <id> --name "..." [--description "..."] [--context "..."] [--issue-prefix "..."]` | 修改 workspace 元数据(admin/owner 权限)。长文本可用 `--description-stdin` / `--context-stdin`。 |
|
||||
|
||||
## Issue 和 Project
|
||||
|
||||
@@ -210,7 +210,7 @@ multica workspace get <workspace-id> --output json
|
||||
### List Members
|
||||
|
||||
```bash
|
||||
multica workspace members <workspace-id>
|
||||
multica workspace member list <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 members --output json`),传 `--assignee-id <uuid>`(与 `--assignee` 互斥)以精确锁定。
|
||||
Flags: `--title` (required), `--description`, `--status`, `--priority`, `--assignee` / `--assignee-id`, `--parent`, `--project`, `--due-date`. 脚本里如果已经拿到了 UUID(例如来自 `multica workspace member list --output json`),传 `--assignee-id <uuid>`(与 `--assignee` 互斥)以精确锁定。
|
||||
|
||||
### Update Issue
|
||||
|
||||
|
||||
@@ -72,7 +72,7 @@ multica daemon status
|
||||
|
||||
In the web UI, go to **Settings → Runtimes**. The daemon you just started should appear as one or more active runtimes — one per AI coding tool installed locally.
|
||||
|
||||
If it shows as offline, don't panic — see [Troubleshooting → Daemon can't reach the server](/troubleshooting#daemon-cant-reach-the-server).
|
||||
If it shows as offline, don't panic — see [Troubleshooting → Daemon can't connect to the server](/troubleshooting#daemon-cant-connect-to-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 members --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 member list --output json`.
|
||||
|
||||
**What happens next from the daemon**:
|
||||
|
||||
|
||||
@@ -99,7 +99,7 @@ multica issue create --title "给 README 加一段 ASCII 架构图"
|
||||
multica issue assign MUL-1 --to my-agent-name
|
||||
```
|
||||
|
||||
`--to` 后面填智能体或成员的**名字**,子串就行——如果智能体叫 `my-code-reviewer`,填 `reviewer` 也能命中。如果工作区里名字相互重叠或冲突,改用 `--to-id <uuid>`(与 `--to` 互斥);UUID 来自 `multica agent list --output json` 或 `multica workspace members --output json`。
|
||||
`--to` 后面填智能体或成员的**名字**,子串就行——如果智能体叫 `my-code-reviewer`,填 `reviewer` 也能命中。如果工作区里名字相互重叠或冲突,改用 `--to-id <uuid>`(与 `--to` 互斥);UUID 来自 `multica agent list --output json` 或 `multica workspace member list --output json`。
|
||||
|
||||
**接下来守护进程会**:
|
||||
|
||||
|
||||
@@ -70,7 +70,7 @@ If logic appears in both apps, it MUST be extracted to a shared package. There a
|
||||
|
||||
### Issue keys
|
||||
|
||||
Every issue has a human-readable key like `MUL-123`: workspace `issue_prefix` (3 letters, uppercase) + sequence number. The prefix is set at workspace creation and is never changed afterward.
|
||||
Every issue has a human-readable key like `MUL-123`: workspace `issue_prefix` (uppercase letters and digits, typically 3 chars, max 10) + sequence number. Workspace admins can change the prefix in Settings → General; changing it renumbers every existing issue, so external references that embed the old prefix (PR titles, branch names, links in docs and chat) stop resolving.
|
||||
|
||||
### Comments in code
|
||||
|
||||
|
||||
@@ -70,7 +70,7 @@ monorepo 的包边界是硬约束:
|
||||
|
||||
### Issue 编号
|
||||
|
||||
每个 issue 有人类可读的编号,比如 `MUL-123`:工作区 `issue_prefix`(3 个大写字母)+ 流水号。前缀在工作区创建时定,之后不可改。
|
||||
每个 issue 有人类可读的编号,比如 `MUL-123`:工作区 `issue_prefix`(大写字母和数字,通常 3 个字符,最长 10 个)+ 流水号。工作区管理员可以在 Settings → General 中修改前缀;修改会让所有现有 issue 重新编号,外部引用——PR 标题、分支名、文档与聊天里的链接——里的旧前缀会失效。
|
||||
|
||||
### 代码注释
|
||||
|
||||
|
||||
@@ -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 → Integrations install button URL |
|
||||
| `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_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 → Integrations is **disabled** and shows a "not configured" hint to admins.
|
||||
- `Connect GitHub` in Settings → GitHub is **disabled** and shows a "not configured" hint to admins.
|
||||
- The `/api/webhooks/github` endpoint returns **`503 github webhooks not configured`** — Multica refuses to process events with no secret rather than treating every signature as valid.
|
||||
|
||||
**Note:** `GITHUB_WEBHOOK_SECRET` is reused as the signing key for the install-flow state token, so operators only need to manage one secret. It is **not** the GitHub App's *Client* secret — Client secrets are OAuth-related and not used by this integration. See [GitHub integration → Self-host setup](/github-integration#self-host-setup) for the full walkthrough.
|
||||
|
||||
@@ -180,12 +180,12 @@ Multica 存储用户上传的附件(评论里的图片、文件等)。**优
|
||||
|
||||
| 环境变量 | 默认值 | 说明 |
|
||||
|---|---|---|
|
||||
| `GITHUB_APP_SLUG` | 空 | 你的 GitHub App slug(`https://github.com/apps/<slug>` 的尾部)。Settings → Integrations 里安装按钮的跳转 URL 用它拼 |
|
||||
| `GITHUB_APP_SLUG` | 空 | 你的 GitHub App slug(`https://github.com/apps/<slug>` 的尾部)。Settings → GitHub 里安装按钮的跳转 URL 用它拼 |
|
||||
| `GITHUB_WEBHOOK_SECRET` | 空 | 你在 GitHub App 上设置的 Webhook secret。每条 `pull_request` / `installation` delivery 都用它做 HMAC-SHA256 校验;同一个值也用作 setup 回调里 state token 的签名密钥 |
|
||||
|
||||
**任一变量未设时:**
|
||||
|
||||
- Settings → Integrations 里 `Connect GitHub` 按钮 **disable**,对 admin 显示「not configured」提示
|
||||
- Settings → GitHub 里 `Connect GitHub` 按钮 **disable**,对 admin 显示「not configured」提示
|
||||
- `/api/webhooks/github` 直接返回 **`503 github webhooks not configured`**——secret 没配置时 Multica 拒绝处理任何 webhook 事件,而不是把所有签名当 valid
|
||||
|
||||
**注意:** `GITHUB_WEBHOOK_SECRET` 同时被复用为 install 流程里 state token 的签名密钥,所以运维只需要维护一个 secret。它**不是** GitHub App 的 *Client* secret——Client secret 是 OAuth 用的,和本集成无关。完整配置流程见 [GitHub 集成 → Self-Host 配置](/github-integration#self-host-配置)。
|
||||
|
||||
@@ -219,7 +219,7 @@ For file uploads and attachments, configure S3 and (optionally) CloudFront:
|
||||
| `S3_BUCKET` | Bucket name only (e.g. `my-bucket`). Do **not** include the `.s3.<region>.amazonaws.com` suffix — the server constructs the public URL from `S3_BUCKET` + `S3_REGION` |
|
||||
| `S3_REGION` | AWS region (default: `us-west-2`). Must match the bucket's actual region — used for both SDK signing and public URLs |
|
||||
| `AWS_ACCESS_KEY_ID` / `AWS_SECRET_ACCESS_KEY` | Static credentials. When both are unset, the AWS SDK default credential chain is used |
|
||||
| `AWS_ENDPOINT_URL` | Custom S3-compatible endpoint (e.g. MinIO, R2, B2). Setting this switches the public URL to path-style |
|
||||
| `AWS_ENDPOINT_URL` | Custom S3-compatible endpoint (e.g. MinIO, R2, B2). Setting this switches to path-style URLs |
|
||||
| `CLOUDFRONT_DOMAIN` | CloudFront distribution domain — when set, public URLs use this host instead of the S3 host |
|
||||
| `CLOUDFRONT_KEY_PAIR_ID` | CloudFront key pair ID for signed URLs |
|
||||
| `CLOUDFRONT_PRIVATE_KEY` | CloudFront private key (PEM format) |
|
||||
|
||||
@@ -5,7 +5,7 @@ description: Connect a GitHub App once, then PRs whose branch, title, or body re
|
||||
|
||||
import { Callout } from "fumadocs-ui/components/callout";
|
||||
|
||||
Connect a GitHub account or organization once in **Settings → 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**.
|
||||
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**.
|
||||
|
||||
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 → 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. |
|
||||
| **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. |
|
||||
| **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 → Integrations** there is no installation list — you manage existing installations from GitHub directly:
|
||||
In **Settings → GitHub** 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 Settings card is hidden for non-admins.
|
||||
- **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.
|
||||
|
||||
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` 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?tab=github` 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 → Integrations** as an owner or admin.
|
||||
1. Open **Settings → GitHub** 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?github_connected=1`.
|
||||
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`.
|
||||
|
||||
After that, open any PR whose branch / title / body contains an issue identifier — within a few seconds the Pull requests block appears on that issue's detail page.
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ description: 一次性连接 GitHub App,之后 PR 的分支名、标题或正
|
||||
|
||||
import { Callout } from "fumadocs-ui/components/callout";
|
||||
|
||||
在 **Settings → Integrations** 里一次性连一个 GitHub 账号或组织。之后任何 PR 只要分支名、标题或正文里出现 issue 编号(例如 `MUL-123`),就会**自动关联**到那个 [issue](/issues),出现在 issue 详情页右侧的 **Pull requests** 区块里——PR 合并时,issue 自动转 **Done**。
|
||||
在 **Settings → GitHub** 里一次性连一个 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 → Integrations** | 工作区 owner / admin 看到一个 GitHub 卡片,里面有 **Connect GitHub** 按钮。点击会打开 GitHub 的 App 安装页;装好后跳回 Settings。 |
|
||||
| **Settings → GitHub** | 工作区 owner / admin 看到 GitHub 这个 tab,里面有主开关、**Connect GitHub** 按钮,以及功能开关(PR 侧栏、Co-authored-by、auto-link)。点 Connect 会打开 GitHub 的 App 安装页;装好后跳回 GitHub tab。 |
|
||||
| **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 → Integrations** 里没有 installation 列表——现有 installation 直接到 GitHub 上管理:
|
||||
**Settings → GitHub** 里没有 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** —— 卡片对非 admin 不显示连接操作
|
||||
- **Multica 这边的断开是 admin only** —— GitHub tab 上的 Disconnect 控件对非 admin 不显示;主开关关掉时 Disconnect 仍然可用,方便 admin 一键关闭功能后再单独清理已连接的 installation
|
||||
|
||||
断开之后,已经镜像的 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`。
|
||||
`FRONTEND_ORIGIN` 也必须设置(任何生产 self-host 都已经设了)——setup 回调结束后用它把用户跳回 `<FRONTEND_ORIGIN>/settings?tab=github`。
|
||||
|
||||
设完 env 重启 API。
|
||||
|
||||
@@ -139,10 +139,10 @@ make migrate-up
|
||||
|
||||
到 Multica:
|
||||
|
||||
1. 以 owner 或 admin 身份打开 **Settings → Integrations**
|
||||
1. 以 owner 或 admin 身份打开 **Settings → GitHub**
|
||||
2. 点 **Connect GitHub**,GitHub 在新 tab 打开
|
||||
3. 选择要授权的仓库,点 **Install**
|
||||
4. GitHub 跳回 `<api-host>/api/github/setup`,落库后再跳到 `<FRONTEND_ORIGIN>/settings?github_connected=1`
|
||||
4. GitHub 跳回 `<api-host>/api/github/setup`,落库后再跳到 `<FRONTEND_ORIGIN>/settings?tab=github&github_connected=1`
|
||||
|
||||
之后在任意一个仓库开一个分支 / 标题 / 正文带本工作区 issue 编号的 PR——几秒内对应 issue 的详情页上就能看到 Pull requests 区块。
|
||||
|
||||
|
||||
@@ -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 members --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 member list --output json`, and `multica squad list --output json`.
|
||||
|
||||
## Next
|
||||
|
||||
|
||||
@@ -126,7 +126,7 @@ multica squad member add <squad-id> --member-id <agent-or-user-uuid> --type agen
|
||||
| `multica squad member remove <id> --member-id <uuid> --type agent\|member` | 移除成员(**不能移除队长**——先换队长)|
|
||||
| `multica squad activity <issue-id> <action\|no_action\|failed> --reason "..."` | 队长每次结束前由它自己调用 |
|
||||
|
||||
`--leader` 接受智能体名字或 UUID;其它 ID 从 `multica agent list --output json`、`multica workspace members --output json`、`multica squad list --output json` 拿。
|
||||
`--leader` 接受智能体名字或 UUID;其它 ID 从 `multica agent list --output json`、`multica workspace member list --output json`、`multica squad list --output json` 拿。
|
||||
|
||||
## 下一步
|
||||
|
||||
|
||||
@@ -77,8 +77,9 @@ multica issue rerun <issue-id>
|
||||
|
||||
Behavior:
|
||||
|
||||
- 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.
|
||||
- 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.
|
||||
- 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.)
|
||||
|
||||
@@ -89,7 +90,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 | Issue's current 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) |
|
||||
| Session inheritance | Yes (resumes prior session) | No (fresh session) |
|
||||
|
||||
## How a failed task affects issue status
|
||||
|
||||
@@ -77,8 +77,9 @@ multica issue rerun <issue-id>
|
||||
|
||||
行为:
|
||||
|
||||
- 跑的是 issue **当前的智能体分配人**——不是上一次跑过的 agent。如果分配人在上次运行后改了,rerun 会跟着新的分配人走。要重跑一个已经不再是分配人的智能体,先把 issue 改派回它,再 rerun。
|
||||
- **取消**该分配人在这条 issue 上 queued / running 的任务(如果有)。同 issue 上其它 agent 的任务(例如 @-mention 触发的并行任务)不会被一起取消。
|
||||
- 默认跑的是 issue **当前的智能体分配人**——适用于希望 rerun 跟随当前分配人的场景。
|
||||
- 执行日志里某一行的 retry 按钮会把这一行的 task ID 一并发出,rerun 会**针对那一行原本的 agent**,而不是当前分配人。这让 squad worker、并行的 @-mention agent、或者已经被新分配人替代的旧任务行的 retry 按钮都能符合直觉地工作。
|
||||
- **取消**目标 agent 在这条 issue 上 queued / running 的任务(如果有)。同 issue 上其它 agent 的任务(例如 @-mention 触发的并行任务)不会被一起取消。
|
||||
- 创建一个**全新**的执行任务——尝试次数重置为 1,即使原任务已达最大尝试。
|
||||
- 启动**全新的智能体会话**——**不**继承之前的会话 ID。手动重跑意味着你已经判定上一次的产出不行,再继续之前的对话只会重放被污染的上下文。(自动重试则相反,会继承会话——那条路径处理的是基础设施层面的失败,不是产出不好。)
|
||||
|
||||
@@ -89,7 +90,7 @@ multica issue rerun <issue-id>
|
||||
| 触发 | 系统基于失败原因自动执行 | 你主动发起 |
|
||||
| 上限 | 2 次 | 无上限 |
|
||||
| 适用来源 | issue、聊天 | 有智能体分配人的 issue |
|
||||
| 跑哪个 agent | 失败任务原本的 agent | issue 当前的分配人 |
|
||||
| 跑哪个 agent | 失败任务原本的 agent | UI 单行 retry:那一行任务的 agent;CLI / 不带 task_id:issue 当前的分配人 |
|
||||
| 会话继承 | 是(接着上次会话) | 否(全新会话) |
|
||||
|
||||
## 失败的任务对 issue 状态有什么影响
|
||||
|
||||
@@ -13,7 +13,7 @@ Three things get decided when you create a workspace:
|
||||
|
||||
- **Workspace name** — the display name members see. Spaces and non-ASCII characters are allowed. You can change it later.
|
||||
- **Slug** — the string used in the workspace URL. Lowercase letters and digits only (joined with `-`). **It cannot be changed after creation**, so pick carefully. If the slug is taken or hits a system-reserved word, the create screen will ask you to choose another.
|
||||
- **Issue prefix** — the prefix for every issue number in the workspace (the `MUL` in `MUL-123`). Use uppercase letters.
|
||||
- **Issue prefix** — the prefix for every issue number in the workspace (the `MUL` in `MUL-123`). Uppercase letters and digits, up to 10 characters.
|
||||
|
||||
<Callout type="warning">
|
||||
**Avoid changing the issue prefix.** Issue numbers are rendered with the current prefix — change it and `MUL-5` instantly becomes `NEW-5`. Every external link, Slack mention, and historical reference in comments breaks against the old number. Treat the issue prefix as "set at creation, never touched."
|
||||
|
||||
@@ -13,7 +13,7 @@ import { Callout } from "fumadocs-ui/components/callout";
|
||||
|
||||
- **工作区名字** — 给成员看的显示名称,可以包含空格和中文。后续随时能改。
|
||||
- **Slug(短链标识符)** — 工作区 URL 中使用的字符串,只能是小写字母和数字(用 `-` 连接)。**创建后不能改**,提前想好。如果 slug 已被占用或命中系统保留词,创建界面会让你换一个。
|
||||
- **Issue 前缀** — 工作区里所有 issue 编号的前缀(比如 `MUL-123` 里的 `MUL`)。使用大写字母。
|
||||
- **Issue 前缀** — 工作区里所有 issue 编号的前缀(比如 `MUL-123` 里的 `MUL`)。只能是大写字母和数字,最长 10 个字符。
|
||||
|
||||
<Callout type="warning">
|
||||
**尽量不要修改 issue 前缀。** 系统在展示 issue 编号时会用当前的前缀——改了之后,`MUL-5` 会立刻变成 `NEW-5`。所有外部链接、Slack 提及、评论里的历史引用都会对不上旧编号。把 issue 前缀当成"创建后不改"的设计来对待。
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useAuthStore } from "@multica/core/auth";
|
||||
@@ -17,9 +17,9 @@ import { CliInstallInstructions, OnboardingFlow } from "@multica/views/onboardin
|
||||
* web (matching `WindowOverlay` on desktop); content is the shared
|
||||
* `<OnboardingFlow />`. Kept minimal — guard on auth, render, exit.
|
||||
*
|
||||
* On complete: if a workspace was just created, navigate into it;
|
||||
* otherwise fall back to root (proxy / landing picks the user's first ws
|
||||
* or bounces to onboarding if still zero).
|
||||
* On complete: runtime-connected onboarding may provide a guide issue id;
|
||||
* navigate there. Otherwise land on the workspace issues list, or root if
|
||||
* the flow never produced a workspace.
|
||||
*
|
||||
* `CliInstallInstructions` is passed in as the `runtimeInstructions`
|
||||
* slot so the flow can render it inside the CLI dialog. The commands it
|
||||
@@ -34,6 +34,14 @@ export default function OnboardingPage() {
|
||||
...workspaceListOptions(),
|
||||
enabled: !!user,
|
||||
});
|
||||
// The bootstrap path calls refreshMe() before returning, which flips
|
||||
// hasOnboarded to true while the page is still mounted. Without this
|
||||
// flag the guard below races onComplete: the guard's router.replace
|
||||
// (issues list) can overtake onComplete's router.push (guide issue),
|
||||
// dropping the user on the wrong destination. Marking the page as
|
||||
// "completing" right before onComplete navigates keeps the guard
|
||||
// silent for the in-flight transition.
|
||||
const completingRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (isLoading || !user) {
|
||||
@@ -41,6 +49,7 @@ export default function OnboardingPage() {
|
||||
return;
|
||||
}
|
||||
if (!workspacesFetched) return;
|
||||
if (completingRef.current) return;
|
||||
// Bounce out only when onboarding genuinely doesn't apply: the user is
|
||||
// already onboarded. We deliberately don't bounce on `workspaces.length`
|
||||
// here — Step 3 of the flow creates a workspace mid-onboarding, and a
|
||||
@@ -62,12 +71,14 @@ export default function OnboardingPage() {
|
||||
return (
|
||||
<div className="h-full overflow-y-auto bg-background">
|
||||
<OnboardingFlow
|
||||
onComplete={(ws) => {
|
||||
// No more firstIssueId handoff — the welcome issue is created
|
||||
// inside the workspace via StarterContentPrompt, not during
|
||||
// onboarding. Always land on the workspace issues list (or
|
||||
// root if the flow never produced a workspace).
|
||||
if (ws) {
|
||||
onComplete={(ws, issueId) => {
|
||||
// Runtime-connected onboarding now creates one focused
|
||||
// onboarding issue. Skip/runtime-less exits still land on the
|
||||
// workspace issues list.
|
||||
completingRef.current = true;
|
||||
if (ws && issueId) {
|
||||
router.push(paths.workspace(ws.slug).issueDetail(issueId));
|
||||
} else if (ws) {
|
||||
router.push(paths.workspace(ws.slug).issues());
|
||||
} else {
|
||||
router.push(paths.root());
|
||||
|
||||
@@ -4,7 +4,6 @@ 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 (
|
||||
@@ -16,7 +15,6 @@ export default function Layout({ children }: { children: React.ReactNode }) {
|
||||
<SearchCommand />
|
||||
<ChatWindow />
|
||||
<ChatFab />
|
||||
<StarterContentPrompt />
|
||||
</>
|
||||
}
|
||||
>
|
||||
|
||||
@@ -1 +1,8 @@
|
||||
export { RuntimesPage as default } from "@multica/views/runtimes";
|
||||
import { RuntimesPage } from "@multica/views/runtimes";
|
||||
|
||||
const cloudRuntimeEnabled =
|
||||
process.env.NEXT_PUBLIC_ENABLE_CLOUD_RUNTIME === "true";
|
||||
|
||||
export default function RuntimesRoute() {
|
||||
return <RuntimesPage cloudRuntimeEnabled={cloudRuntimeEnabled} />;
|
||||
}
|
||||
|
||||
@@ -284,6 +284,61 @@ export function createEnDict(allowSignup: boolean): LandingDict {
|
||||
fixes: "Bug Fixes",
|
||||
},
|
||||
entries: [
|
||||
{
|
||||
version: "0.3.4",
|
||||
date: "2026-05-20",
|
||||
title: "Smarter Autopilots, Agent Controls & Desktop Reliability",
|
||||
changes: [],
|
||||
features: [
|
||||
"Autopilots can assign new work through squads and place created Issues directly into a selected Project",
|
||||
"Agent settings now include per-agent thinking controls for Claude and Codex, with an inspector picker that updates instantly",
|
||||
"Desktop tabs can be pinned so important workspace pages stay parked while new links open in fresh tabs",
|
||||
"User profiles can add requester context, giving coding agents better background for assigned Issues",
|
||||
"Workspace settings now have a dedicated GitHub page, and regular members can see connected GitHub installations without admin controls",
|
||||
],
|
||||
improvements: [
|
||||
"New users are guided to connect a runtime instead of receiving starter content that may not match their workspace",
|
||||
"Runtime pages are quieter, and desktop keeps the local machine visible after stopping the local service",
|
||||
"Issue breadcrumbs show the Project segment when an Issue belongs to a Project",
|
||||
"HTML previews and attachment previews have roomier, more predictable layouts",
|
||||
"Squad pages show fuller loading states and use a clearer archive confirmation dialog",
|
||||
"Agents now receive parent and sub-issue handoff guidance before running assigned work",
|
||||
],
|
||||
fixes: [
|
||||
"List editing exits cleanly from an empty top-level item when pressing Enter",
|
||||
"The installer falls back to release binaries when Homebrew setup fails and reports clearer diagnostics",
|
||||
"Retrying an execution log row now reruns the agent that handled that row",
|
||||
"Chat and task-message loading ignore temporary IDs instead of calling invalid task routes",
|
||||
"OpenCode-backed daemon runs no longer enter invisible interactive question prompts",
|
||||
"Gemini runtimes use the correct official icon",
|
||||
],
|
||||
},
|
||||
{
|
||||
version: "0.3.3",
|
||||
date: "2026-05-19",
|
||||
title: "Project Timelines, Runtime Setup & Clearer Issue Work",
|
||||
changes: [],
|
||||
features: [
|
||||
"Projects now have a Gantt view for scheduled work, with updates that stay in sync as plans change",
|
||||
"Workspace admins can change the issue key prefix from settings",
|
||||
"The CLI can switch between workspaces and show the current workspace",
|
||||
"Agents can read issue threads from the most recent discussion first, making follow-up work easier to route and review",
|
||||
"Usage now includes a one-day view plus weekly trends that respect the selected timezone",
|
||||
"Agent detail pages now work as an issue board for that specific agent",
|
||||
],
|
||||
improvements: [
|
||||
"The onboarding flow now asks one focused question at a time and can guide runtime setup with fewer manual steps",
|
||||
"My Issues now includes squad-assigned work and labels the team-related tab more clearly",
|
||||
"Agent execution logs can be sorted in either direction when reviewing a run",
|
||||
],
|
||||
fixes: [
|
||||
"HTML previews open more predictably from desktop, close the full-screen modal when needed, and support in-page links",
|
||||
"HTML source view and attachment previews are easier to inspect, including opening content in a new tab",
|
||||
"Create-issue prompts no longer keep stale manual draft text when switching modes",
|
||||
"Runtime tasks now find the right workspace instructions and skills from the task folder",
|
||||
"Self-hosted teams can set how long auth sessions last",
|
||||
],
|
||||
},
|
||||
{
|
||||
version: "0.3.2",
|
||||
date: "2026-05-18",
|
||||
|
||||
@@ -284,6 +284,61 @@ export function createZhDict(allowSignup: boolean): LandingDict {
|
||||
fixes: "问题修复",
|
||||
},
|
||||
entries: [
|
||||
{
|
||||
version: "0.3.4",
|
||||
date: "2026-05-20",
|
||||
title: "自动任务项目归属、智能体思考设置与更稳的桌面端",
|
||||
changes: [],
|
||||
features: [
|
||||
"自动任务现在可以通过小队分配工作,并把创建的 Issue 直接归入指定项目",
|
||||
"智能体设置新增 Claude 和 Codex 的思考强度控制,并可在详情面板里直接调整",
|
||||
"桌面端标签页可以固定,重要页面会留在左侧,打开新内容时不打断原页面",
|
||||
"用户资料可以补充请求者背景,让代码智能体在处理 Issue 时更理解上下文",
|
||||
"工作区设置新增 GitHub 专页,普通成员也能查看已连接的 GitHub 安装信息",
|
||||
],
|
||||
improvements: [
|
||||
"新用户引导会优先创建连接运行环境的下一步,不再生成不合适的示例内容",
|
||||
"运行环境页面减少重复信息,桌面端停止本机服务后仍能看到本机行并重新启动",
|
||||
"Issue 面包屑会显示所属项目,查看来源更清楚",
|
||||
"HTML 预览和附件预览拥有更合适的默认尺寸,查看内容更自然",
|
||||
"小队列表加载状态更完整,归档小队时会使用更清晰的确认弹窗",
|
||||
"智能体运行前会收到父 Issue / 子 Issue 协作规则,完成子任务后的回传更稳定",
|
||||
],
|
||||
fixes: [
|
||||
"在空的顶层列表项按 Enter 时,编辑器可以正常退出列表",
|
||||
"安装脚本在 Homebrew 失败时会自动改用发行版文件,并显示更清楚的诊断信息",
|
||||
"从执行记录重试时,会重新唤起当时处理该记录的智能体",
|
||||
"聊天和任务消息加载会跳过临时 ID,避免访问无效任务",
|
||||
"OpenCode 运行环境不再进入看不见的交互提问流程",
|
||||
"Gemini 运行环境使用正确的官方图标",
|
||||
],
|
||||
},
|
||||
{
|
||||
version: "0.3.3",
|
||||
date: "2026-05-19",
|
||||
title: "项目时间线、运行环境设置与更清晰的任务协作",
|
||||
changes: [],
|
||||
features: [
|
||||
"项目现在提供甘特图视图,用于查看有排期的工作,并会在计划变化时实时同步",
|
||||
"Workspace 管理员可以在设置中调整 Issue 编号前缀",
|
||||
"命令行可以切换 workspace 并查看当前 workspace",
|
||||
"Agent 现在可以优先读取最新的 Issue 讨论线程,后续跟进和审查更贴近当前上下文",
|
||||
"Usage 新增 1 天视图和按周趋势,并会遵循所选时区",
|
||||
"Agent 详情页现在是对应智能体的 Issue 看板",
|
||||
],
|
||||
improvements: [
|
||||
"Onboarding 改为一次回答一个问题,并能用更少步骤引导 runtime 设置",
|
||||
"My Issues 会包含分配给小队的工作,相关标签也更容易理解",
|
||||
"查看智能体执行日志时可以切换排序方向,回看运行过程更方便",
|
||||
],
|
||||
fixes: [
|
||||
"桌面端打开 HTML 预览更稳定,必要时会关闭全屏窗口,并支持页面内链接跳转",
|
||||
"HTML 源码视图和附件预览更容易检查,也可以把内容打开到新标签页",
|
||||
"切换创建 Issue 模式时,提示词里不再残留旧的手写草稿",
|
||||
"Runtime 任务会从任务目录读取正确的 workspace 指令和 skills",
|
||||
"自托管团队可以设置登录会话有效期",
|
||||
],
|
||||
},
|
||||
{
|
||||
version: "0.3.2",
|
||||
date: "2026-05-18",
|
||||
|
||||
@@ -15,6 +15,8 @@ export const mockUser: User = {
|
||||
// field shipped — migration 054 backfills 'skipped_legacy'.
|
||||
starter_content_state: "skipped_legacy",
|
||||
language: null,
|
||||
timezone: null,
|
||||
profile_description: "",
|
||||
created_at: "2026-01-01T00:00:00Z",
|
||||
updated_at: "2026-01-01T00:00:00Z",
|
||||
};
|
||||
|
||||
@@ -90,7 +90,7 @@ Every event is assigned to one dashboard category:
|
||||
| Category | Events |
|
||||
|---|---|
|
||||
| `core_loop` | `workspace_created`, `runtime_registered`, `runtime_ready`, `runtime_failed`, `runtime_offline`, `agent_created`, `issue_created`, `chat_message_sent`, `agent_task_queued`, `agent_task_dispatched`, `agent_task_started`, `agent_task_completed`, `agent_task_failed`, `agent_task_cancelled`, `autopilot_run_started`, `autopilot_run_completed`, `autopilot_run_failed` |
|
||||
| `onboarding_support` | `onboarding_started`, `onboarding_questionnaire_submitted`, `onboarding_completed`, `onboarding_runtime_path_selected`, `onboarding_runtime_detected`, `starter_content_decided` |
|
||||
| `onboarding_support` | `onboarding_started`, `onboarding_questionnaire_submitted`, `onboarding_completed`, `onboarding_runtime_path_selected`, `onboarding_runtime_detected` |
|
||||
| `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,21 +470,6 @@ 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
|
||||
|
||||
374
docs/timezone-architecture-rfc.md
Normal file
374
docs/timezone-architecture-rfc.md
Normal file
@@ -0,0 +1,374 @@
|
||||
# Timezone 架构重构 — Scheduling / Viewing 两层模型
|
||||
|
||||
> Status: Implemented
|
||||
> Last updated: 2026-05-20
|
||||
|
||||
## TL;DR
|
||||
|
||||
- **问题**:当前代码里 timezone 被三种语义混用,导致 workspace usage 页 picker 在 #2822 review 中被移除(前后端 tz 不一致会把跨 UTC 午夜的行算到错的 calendar week),同时 runtime detail 页的 timezone editor 又承担了"既是物理 tz 又是报表 tz"的双重职责。
|
||||
- **方案**:把 timezone 收敛成两个独立的 product 概念——**Scheduling**(trigger 规则里写的"9 点"是哪个 9 点,由 `autopilot_trigger.timezone` 承载)和 **Viewing**(用户报表 tz,由新字段 `user.timezone` 承载)。原先混在 `runtime.timezone` 上的"物理位置"语义(Operational)经盘查无真实消费者,整列移除。
|
||||
- **数据层**:把 `task_usage_daily` (per-runtime, 物化在 runtime tz) 和 `task_usage_dashboard_daily` (workspace 级, 物化在 UTC) **合并成一张 `task_usage_hourly` (UTC, hourly grain)**,所有报表查询按调用方 tz 在查询时切日界。
|
||||
- **新增字段**:`user.timezone`(默认 = browser detected,可在 Preferences 覆盖)。
|
||||
- **不引入** `workspace.timezone`——viewing tz 是查看者属性,不是 workspace 属性。
|
||||
- **性能**:hourly rollup 在密集工况(16 active hours/day)下单 ws 90d 窗口 ~15k 行、~15ms,和现有 daily rollup 同档。
|
||||
- **副产品**:Migration 082 的"改 runtime tz → 重灌整张 rollup"逻辑可以删除;跨 region 团队自动支持各看各的"今天";未来要做 hourly heatmap / 时段分析无需再动 schema。
|
||||
|
||||
---
|
||||
|
||||
## 1. 背景
|
||||
|
||||
### 1.1 现状盘点
|
||||
|
||||
代码里"timezone"出现在四个地方:
|
||||
|
||||
| # | 位置 | 字段 | 实际语义 |
|
||||
|---|---|---|---|
|
||||
| 1 | `agent_runtime.timezone` | TEXT, daemon 探测或 UI 覆盖 | 报表 + 物理位置(混淆) |
|
||||
| 2 | `autopilot_trigger.timezone` | TEXT, 用户写规则时选 | Scheduling(正确) |
|
||||
| 3 | Workspace Usage 页面 | 无字段,曾在前端用 `useState(browserTimezone())` | Viewing(被 #2822 删除) |
|
||||
| 4 | 各种 list / log 时间戳显示 | 浏览器 tz | Viewing(隐式) |
|
||||
|
||||
### 1.2 问题
|
||||
|
||||
**问题 A — Runtime tz 同时承担两个不同的角色:**
|
||||
|
||||
`runtime.timezone` 在 migration 082 之后决定了 `task_usage_daily.bucket_date` 的物化口径,等于"报表 tz";同时 daemon 启动时 `detectLocalTimezone()` 写入这个字段,又当成"机器物理 tz"用。结果:
|
||||
|
||||
- 改这个字段会触发整张 rollup 重新物化(migration 082 backfill 逻辑),代价不小。
|
||||
- 一个 SF 的 dev 把 daemon 跑在 PST 的机器上,但 PM 在上海希望按 CST 出报表——这一个字段没法同时满足两个需求。
|
||||
- daemon 自动探测的"客观真值"和用户手动想换的"我想看的报表 tz"被同一个 PATCH 接口覆盖,互相打架。
|
||||
|
||||
**问题 B — Workspace usage 页面没有正确的"报表 tz"概念:**
|
||||
|
||||
PR #2822 删除了 workspace usage 页的 TimezonePicker,原因是:
|
||||
|
||||
> 后端 dashboard rollup 把数据按 UTC `bucket_date` 聚合,但前端却驱动 Weekly 边界用用户在 picker 里选的 tz。靠近 UTC 午夜的行会被放进错的 calendar week。Lock workspace Weekly to UTC and remove the timezone picker。
|
||||
|
||||
这个修复是对的——前后端 tz 不一致就是 bug。但它**没解决根本问题**:用户确实需要按自己的 tz 看 workspace 报表,只是当前数据层没法支持。
|
||||
|
||||
**问题 C — Viewing tz 没有持久化:**
|
||||
|
||||
即使 picker 还在,它也只是 `useState(browserTimezone())`——刷新页面、换设备、跨 session 都会丢。用户每次都得手动切。
|
||||
|
||||
**问题 D — 没有"跨 region 团队"的支持位:**
|
||||
|
||||
把"报表 tz"放在 workspace 上是常见的诱惑,但 workspace 里两个成员一个在 SF 一个在 Beijing,他们想看到的"今天"本来就不同。任何"workspace 级 tz 设置"都强制其中一个人看错位的报表。
|
||||
|
||||
### 1.3 目标
|
||||
|
||||
1. **架构上清晰**:每个 timezone 字段只回答一个问题。
|
||||
2. **性能上不退步**:所有现有报表查询保持 <15ms 量级。
|
||||
3. **正确性优先**:前后端 tz 物化口径必须一致,没有"前端切了但后端没跟"的 UI 谎言。
|
||||
4. **跨 region 友好**:同一 workspace 不同成员可以各看各的"今天"。
|
||||
|
||||
---
|
||||
|
||||
## 2. 两个 timezone 概念
|
||||
|
||||
| 概念 | 在回答什么 | 谁是真值 | 承载字段 |
|
||||
|---|---|---|---|
|
||||
| **Scheduling** | "9 点跑"的 9 点是哪个 9 点 | 用户写规则那一刻的意图 | `autopilot_trigger.timezone` |
|
||||
| **Viewing** | 我想看的"今天"是哪个日历日 | 当前查看者的偏好 | `user.timezone`(新增) |
|
||||
|
||||
**关键论断**:之前代码把"物理位置"和"报表口径"混在 `runtime.timezone` 一个字段上。重构后:
|
||||
|
||||
- Scheduling 不动,`autopilot_trigger.timezone` 已经正确。
|
||||
- Viewing 由新字段 `user.timezone` 承载。
|
||||
- 数据层不再按任何固定 tz 物化 bucket,而是以 UTC 为唯一存储口径,所有报表查询在 read time 按调用方传入的 tz 切日界。
|
||||
- `runtime.timezone` 整列删除——见 §2.1。
|
||||
|
||||
### 2.1 为什么不要 Operational 层
|
||||
|
||||
最初设计有第三个概念 **Operational**(机器物理在哪)。落地盘查后砍掉,两条理由:
|
||||
|
||||
**理由一 —— 就算需要 operational tz,`runtime` 也是错的层级。** Operational tz 是**物理机器**的属性,不是 runtime 的属性。同一台机器可以跑多个 runtime,它们共用同一个 OS 时钟,operational tz 必然相同。把 tz 放在 `agent_runtime` 上,等于把一个 machine 级事实复制到同机每一行 runtime——天然的冗余与 drift 风险(同机两个 runtime 的 tz 被改得不一致是无意义的非法状态)。要建模 operational tz,正确归属是 machine 层;而当前 schema 里根本没有 machine 实体,强行放 runtime 层只是把错误固化。
|
||||
|
||||
**理由二 —— 它的消费者都不需要 operational 语义。** `runtime.timezone` 今天承担"既是物理 tz 又是报表 tz"的双重职责,但盘查后没有一个读取者真正要"机器物理 tz":
|
||||
|
||||
- runtime detail 页的 Daily / Weekly 趋势图、KPI 卡片,通过 `task_usage_daily` 的物化口径间接吃这个 tz——这是**报表口径**语义,不是 operational。而且这些成本/token 数字要和 workspace dashboard 跨页对账,dashboard 下挂多 runtime、多时区,根本不存在"workspace 的 operational tz",可对账量只能统一走 Viewing tz。
|
||||
- hour-of-day heatmap(`GetRuntimeUsageByHour` / `GetRuntimeTaskActivity`)看似要"机器作息"属性,但若只让它一个图表走 operational,用户在同一张卡里切 "Daily" ↔ "Heatmap" 会看到同一个"昨天"两个数。它也只能跟 Viewing tz。
|
||||
|
||||
autopilot 调度走 `trigger.timezone` 不碰它,daemon 要时钟直接读 OS clock,`TimezoneEditor` 只是编辑它自己。换句话说,凡是真读它的地方都应当是 Viewing tz——operational 语义在整个系统里没有一个真实需求点。
|
||||
|
||||
结论:Operational 作为服务端持久化、用户可编辑的字段没有立足点。机器有物理时钟这个**事实**永远存在,但那是 daemon 进程内部的事,不必上 server。`runtime.timezone` 整列由 migration 104 删除。
|
||||
|
||||
代价已知且接受:跨 region 团队看一台 SF runtime 的 hour-of-day heatmap 时,按查看者自己的 tz(如 Asia/Shanghai)显示活跃时段,而非机器本地的 9-to-5。对单 region 团队零影响。
|
||||
|
||||
---
|
||||
|
||||
## 3. 字段定义与 UI 文案
|
||||
|
||||
### 3.1 `runtime.timezone` — 已移除
|
||||
|
||||
由 migration `104_drop_runtime_timezone` 删除整列。daemon 注册不再上报 host tz(`detectLocalTimezone()` 删除),`PATCH /api/runtimes/:id` 不再接受 `timezone`(只剩 `visibility`),Runtime Detail 页的 timezone editor 删除。理由见 §2.1。
|
||||
|
||||
### 3.2 `autopilot_trigger.timezone` — 不动
|
||||
|
||||
已经正确。
|
||||
|
||||
### 3.3 `user.timezone` — 新增 Viewing 字段
|
||||
|
||||
实现见 migration `100_user_timezone`。表名是 `"user"`(单数、保留字需加引号):
|
||||
|
||||
```sql
|
||||
ALTER TABLE "user"
|
||||
ADD COLUMN timezone TEXT NULL;
|
||||
|
||||
COMMENT ON COLUMN "user".timezone IS
|
||||
'User-preferred IANA timezone for report rendering (Viewing tz). '
|
||||
'NULL means "use the browser-detected tz at render time". Affects '
|
||||
'dashboards, charts, and any "today" label shown to this user. Does '
|
||||
'not affect data materialisation — all rollups remain in UTC.';
|
||||
```
|
||||
|
||||
`NULL` 是默认值——前端在 NULL 时 fallback 到 `browserTimezone()`。这样新用户零配置就有合理行为。
|
||||
|
||||
UI:
|
||||
- **Settings → Preferences → Timezone**:dropdown,可选 `(browser)` 或具体 IANA name。
|
||||
- Hint:`"Used for dashboards, charts, and any 'today' label shown to you. Other users in your workspaces will see their own timezone."`
|
||||
|
||||
### 3.4 不引入 `workspace.timezone`
|
||||
|
||||
理由见 §1.2 问题 D。如果未来真有"workspace 默认报表 tz"的需求(例如新成员加入时给一个建议默认值),可以在那时再加,与本 RFC 兼容——`user.timezone` 可作为 `workspace.timezone` 的 override。
|
||||
|
||||
### 3.5 Viewing tz 如何到达后端
|
||||
|
||||
报表 handler 通过 `Handler.resolveViewingTZ(r)` 解析当前请求该用哪个 tz 渲染,优先级:
|
||||
|
||||
1. `?tz=` query param —— 浏览器端 `useViewingTimezone()` 解析后随每个报表请求显式带上。
|
||||
2. 已认证用户的 `user.timezone`(query param 缺失时的 cold fallback,会多查一次 `GetUser`)。
|
||||
3. `"UTC"` —— 兜底。
|
||||
|
||||
非法 IANA 名直接跳过该级、不报错(tz 是显示问题)。浏览器走 (1) 显式 query param 这条热路径,旧客户端 / API client 漏传时由 (2) 服务端读 `user.timezone` 兜底。Handler 拿到 tz 后用 `parseSinceParamInTZ` 把 `days=N` 折算成"查看者本地第 N 天零点"对应的 UTC 瞬间,再连同 `@tz` 一起传给 SQL。
|
||||
|
||||
---
|
||||
|
||||
## 4. 数据层设计
|
||||
|
||||
### 4.1 新表 `task_usage_hourly`
|
||||
|
||||
实现见 migration `101_task_usage_hourly_schema`(建表):
|
||||
|
||||
```sql
|
||||
CREATE TABLE task_usage_hourly (
|
||||
bucket_hour TIMESTAMPTZ NOT NULL, -- UTC, truncated to hour boundary
|
||||
workspace_id UUID NOT NULL,
|
||||
runtime_id UUID NOT NULL,
|
||||
agent_id UUID NOT NULL,
|
||||
project_id UUID, -- nullable
|
||||
provider TEXT NOT NULL,
|
||||
model TEXT NOT NULL,
|
||||
input_tokens BIGINT NOT NULL DEFAULT 0,
|
||||
output_tokens BIGINT NOT NULL DEFAULT 0,
|
||||
cache_read_tokens BIGINT NOT NULL DEFAULT 0,
|
||||
cache_write_tokens BIGINT NOT NULL DEFAULT 0,
|
||||
task_count BIGINT NOT NULL DEFAULT 0, -- COUNT(DISTINCT task_id)
|
||||
event_count BIGINT NOT NULL DEFAULT 0, -- COUNT(*) of task_usage rows
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
CONSTRAINT uq_task_usage_hourly_key
|
||||
UNIQUE NULLS NOT DISTINCT
|
||||
(bucket_hour, workspace_id, runtime_id, agent_id, project_id, provider, model)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_task_usage_hourly_workspace_time
|
||||
ON task_usage_hourly (workspace_id, bucket_hour DESC);
|
||||
CREATE INDEX idx_task_usage_hourly_runtime_time
|
||||
ON task_usage_hourly (runtime_id, bucket_hour DESC);
|
||||
CREATE INDEX idx_task_usage_hourly_workspace_agent_time
|
||||
ON task_usage_hourly (workspace_id, agent_id, bucket_hour DESC);
|
||||
CREATE INDEX idx_task_usage_hourly_workspace_project_time
|
||||
ON task_usage_hourly (workspace_id, project_id, bucket_hour DESC)
|
||||
WHERE project_id IS NOT NULL;
|
||||
```
|
||||
|
||||
**关于字段的几个落地决定**:
|
||||
|
||||
- **没有 `cost_micros` 列**。成本不在数据层物化——`task_usage_hourly` 只存 token 计数,PK 里带 `provider`+`model`,客户端按 per-model 定价表算成本。这样定价表更新无需重灌 rollup。
|
||||
- **`task_count` 与 `event_count` 两个计数**:`task_count` 是 `COUNT(DISTINCT task_id)`,`event_count` 是 `COUNT(*)`(同一 task 多次 usage 事件)。注意 task 跨多个 hour bucket 时 `task_count` 会按小时重复计——面向用户的"任务数"列优先用 `agent_task_queue` 派生的查询(见 §4.2),hourly 表的 `task_count` 仅作信息参考。
|
||||
- **`runtime_id` 为 `NOT NULL`**:`agent_task_queue.runtime_id` 本身带 `NOT NULL` 约束(migration 004),所有建队列的写入路径(含 quick-create)都会带上 runtime,所以 rollup 永远不会产生 no-runtime 的 bucket。`project_id` 可空是因为任务确实可以不挂 project。
|
||||
|
||||
migration 101 同时建了两张配套表:
|
||||
|
||||
- `task_usage_hourly_rollup_state` —— 单行 watermark 状态表(与 073/084 的 rollup_state 同形)。
|
||||
- `task_usage_hourly_dirty` —— 失效队列,承载 `updated_at` watermark 看不到的失效(`task_usage` 的 DELETE、级联 DELETE、`issue.project_id` / `agent_task_queue.runtime_id` 改动导致的重新归属)。**必须配 TTL**,见 §4.4。
|
||||
|
||||
**这一张表替换两张现有表**:
|
||||
- `task_usage_daily` (migration 073, 082) — 含 runtime_id,物化在 runtime tz
|
||||
- `task_usage_dashboard_daily` (migration 084) — 含 agent_id/project_id,物化在 UTC
|
||||
|
||||
合并后 PK 同时包含 runtime / agent / project 三个维度,可以从同一张表派生出所有现有视图。
|
||||
|
||||
### 4.2 查询模式
|
||||
|
||||
Token 类报表查询从 `task_usage_hourly` 派生,按调用方传入的 `@tz` 在查询时折算日界。**成本不在 SQL 里算**——查询只 `SUM` token 列并保留 `model` 维度,成本由客户端按 per-model 定价表折算(所以按日期分组的查询会保留 `model`,按 agent 分组的也是)。
|
||||
|
||||
```sql
|
||||
-- Workspace dashboard 趋势图 ListDashboardUsageDaily(按 viewer tz 切日,保留 model)
|
||||
SELECT DATE(bucket_hour AT TIME ZONE @tz::text) AS date,
|
||||
model,
|
||||
SUM(input_tokens)::bigint AS input_tokens,
|
||||
SUM(output_tokens)::bigint AS output_tokens,
|
||||
SUM(cache_read_tokens)::bigint AS cache_read_tokens,
|
||||
SUM(cache_write_tokens)::bigint AS cache_write_tokens,
|
||||
SUM(task_count)::int AS task_count
|
||||
FROM task_usage_hourly
|
||||
WHERE workspace_id = $1
|
||||
AND bucket_hour >= @since::timestamptz
|
||||
AND (@project_id::uuid IS NULL OR project_id = @project_id)
|
||||
GROUP BY DATE(bucket_hour AT TIME ZONE @tz::text), model
|
||||
ORDER BY DATE(bucket_hour AT TIME ZONE @tz::text) DESC, model;
|
||||
|
||||
-- Runtime detail 趋势图 ListRuntimeUsage(按 viewer tz 切日,tz 来自 user 不是 runtime)
|
||||
SELECT DATE(bucket_hour AT TIME ZONE @tz::text) AS date,
|
||||
provider, model,
|
||||
SUM(input_tokens)::bigint AS input_tokens,
|
||||
...
|
||||
FROM task_usage_hourly
|
||||
WHERE runtime_id = $1
|
||||
AND bucket_hour >= @since::timestamptz
|
||||
GROUP BY DATE(bucket_hour AT TIME ZONE @tz::text), provider, model
|
||||
ORDER BY DATE(bucket_hour AT TIME ZONE @tz::text) DESC, provider, model;
|
||||
|
||||
-- Per-agent 视图 ListDashboardUsageByAgent / ListRuntimeUsageByAgent
|
||||
-- 不按日期分组 → 不需要 @tz,只用 @since 截断(@since 已是 viewer tz 折算后的 UTC 瞬间)。
|
||||
SELECT agent_id, model,
|
||||
SUM(input_tokens)::bigint AS input_tokens,
|
||||
...
|
||||
FROM task_usage_hourly
|
||||
WHERE workspace_id = $1
|
||||
AND bucket_hour >= @since::timestamptz
|
||||
GROUP BY agent_id, model
|
||||
ORDER BY agent_id, model;
|
||||
```
|
||||
|
||||
**两类查询不走 `task_usage_hourly`**:
|
||||
|
||||
- **Time / Tasks 指标**(dashboard 的"时长 / 任务数"标签页)由独立查询 `ListDashboardRunTimeDaily` / `ListDashboardAgentRunTime` 直接打 `agent_task_queue`,按 `completed_at AT TIME ZONE @tz` 切日——任务时长来自队列的 `started_at`/`completed_at`,不是 token rollup 能表达的。它们同样吃 `@tz`,保证 Tokens/Cost/Time/Tasks 四个标签页的日界一致。
|
||||
- **Runtime hour-of-day Heatmap**(`GetRuntimeUsageByHour` / `GetRuntimeTaskActivity`)仍直接扫原始 `task_usage` / `agent_task_queue`,按 **viewer tz**(`resolveViewingTZ` 解析出的 `@tz`)做 `EXTRACT(HOUR FROM ... AT TIME ZONE @tz)`。Heatmap 窗口小(单 runtime、近 30/90d),raw 扫描足够快,没有必要从 hourly 表派生。
|
||||
|
||||
### 4.3 性能预估
|
||||
|
||||
单 workspace 90d 窗口的 `task_usage_hourly` 行数:
|
||||
|
||||
| 工况 | 行数估算 | 趋势图查询代价 |
|
||||
|---|---|---|
|
||||
| 小(5 agent × 2 model × 2 active hour × 90d) | ~1.8k | <5ms |
|
||||
| 中(5 agent × 2 model × 8 active hour × 90d) | ~7.2k | <10ms |
|
||||
| 大(5 agent × 2 model × 16 active hour × 90d) | ~14.4k | ~15ms |
|
||||
| 巨大(20 agent × 5 model × 16 active hour × 90d) | ~144k | ~50ms |
|
||||
|
||||
和现有 daily rollup 在同一档。Leaderboard / per-agent / per-project 视图同样指标。
|
||||
|
||||
### 4.4 Rollup worker 改造
|
||||
|
||||
现有两张 rollup 表的写入逻辑合并成一条管线,实现见 migration `102_task_usage_hourly_pipeline`(触发器 + 窗口函数 + 失效队列 TTL + pg_cron 调度):
|
||||
|
||||
- 源数据扫描不变(仍然扫 `task_usage` 增量 + 失效队列)。`bucket_hour` 用 `task_usage_hour_bucket(tu.created_at)`(UTC 整点截断)。
|
||||
- Upsert 目标从两张 daily 表改为一张 `task_usage_hourly`。
|
||||
- 失效队列维度由 `(bucket_date, …)` 改为 `(bucket_hour, …)`(`task_usage_hourly_dirty`),由 `task_usage` / `agent_task_queue` / `issue` 上的触发器写入。**必须配 TTL(保留 7 天)**,否则脏行在密集工况下无界增长——这是整个设计最容易漏的正确性要求(hourly 粒度把脏面比 daily 放大了 ~24×)。
|
||||
- 调度入口 `rollup_task_usage_hourly()` 由 pg_cron 周期触发:取 advisory lock → 从 `task_usage_hourly_rollup_state` 读 watermark → 调 `rollup_task_usage_hourly_window(from, to)` 重算脏 bucket → 推进 watermark → 释放锁后跑 `prune_task_usage_hourly_dirty()`。单 tick 窗口上限 1 天,watermark 落后时分多次 tick 追平,不会一条语句锁表重算多周。
|
||||
|
||||
源表扫描是 worker 的主要开销,目标表换粒度只让单 tick 多几十 ms upsert,不会成倍增长。
|
||||
|
||||
### 4.5 Migration 082 的副作用消除
|
||||
|
||||
当前 `runtime.timezone` 的 PATCH 处理(migration 082 + 现有 handler)会触发该 runtime 的整张 `task_usage_daily` 重新物化——因为 `bucket_date` 含了 tz。
|
||||
|
||||
新方案下 `bucket_hour` 永远是 UTC,**`runtime.timezone` 改变不再触发任何数据层操作**。改 tz 立即生效,零 backfill。这同时修掉了:
|
||||
|
||||
- 改 tz 期间的 race condition(旧 bucket 还没重灌完,新查询已经按新 tz 渲染)。
|
||||
- daemon 第一次注册时探测到非 UTC 的 tz 但历史 rollup 还是 UTC 的尴尬过渡期。
|
||||
|
||||
---
|
||||
|
||||
## 5. UI / UX 影响
|
||||
|
||||
### 5.1 Runtime Detail 页
|
||||
|
||||
| 组件 | 重构前 tz 来源 | 重构后 tz 来源 |
|
||||
|---|---|---|
|
||||
| Daily / Weekly 趋势图 | `runtime.timezone` | `user.timezone ?? browserTimezone()` |
|
||||
| KPI 卡片 | `runtime.timezone`(隐式) | `user.timezone ?? browserTimezone()` |
|
||||
| 日历活跃热力图 | `runtime.timezone` 锚点 + viewer-tz 数据(不一致 bug) | `user.timezone ?? browserTimezone()`(锚点与数据统一) |
|
||||
| Hour-of-day Heatmap | `runtime.timezone` | `user.timezone ?? browserTimezone()` |
|
||||
| Timezone editor | 写 `runtime.timezone` | **删除** |
|
||||
|
||||
**用户可感知的行为变化**:
|
||||
|
||||
- Runtime Detail 页所有图表统一跟随 viewer 自己的 tz;页面上不再有任何 runtime 级 tz 控件。
|
||||
- 想换报表 tz 的用户去 Settings → Preferences 改一次,所有 workspace / runtime 的报表立刻全跟着变。
|
||||
- 跨 region 团队:hour-of-day heatmap 按查看者 tz 显示活跃时段(已知且接受的取舍,见 §2.1)。
|
||||
|
||||
### 5.2 Workspace Usage 页
|
||||
|
||||
恢复"按 viewing tz 渲染"的能力,但**不放页面级 picker**。理由:
|
||||
|
||||
- Picker 当年被加上去就是因为没有持久化的 viewing tz 概念。现在有了 `user.timezone`,picker 的诉求被 Preferences 替代。
|
||||
- 页面级 picker 容易让用户误以为"这是一个 view-state",但 viewing tz 是全应用属性,不是单页设置。
|
||||
- 减少 UI 控件 = 减少认知负担。
|
||||
|
||||
`packages/views/dashboard/components/dashboard-page.tsx` 里的 `WEEK_TZ = "UTC"` 改成 `useViewingTimezone()`(hook 见 `packages/views/common/use-viewing-timezone.ts`),相应的解释性注释删除。
|
||||
|
||||
### 5.3 Preferences 页
|
||||
|
||||
新增一个 Timezone setting,和现有的语言 / 主题等并列。
|
||||
|
||||
---
|
||||
|
||||
## 6. 实施
|
||||
|
||||
> 产品尚未上线,无存量用户需保护,全部变更作为一组迁移一次性交付——旧的 daily 管线在同一分支里直接拆除,不保留共存期。
|
||||
|
||||
整套变更落在分支 `feat/timezone-architecture`,migration 100–104:
|
||||
|
||||
| Migration | 内容 |
|
||||
|---|---|
|
||||
| `100_user_timezone` | 加 `"user".timezone` 列(nullable) |
|
||||
| `101_task_usage_hourly_schema` | 建 `task_usage_hourly` + `task_usage_hourly_rollup_state` + `task_usage_hourly_dirty` + 索引 |
|
||||
| `102_task_usage_hourly_pipeline` | 失效触发器、`rollup_task_usage_hourly_window` 窗口函数、`prune_task_usage_hourly_dirty()` 失效队列 TTL、带单日 cap 与 prune 的 `rollup_task_usage_hourly()` cron 入口、pg_cron 调度 |
|
||||
| `103_drop_legacy_daily_rollups` | 拆掉 `task_usage_daily` / `task_usage_dashboard_daily` 两条旧管线(表、函数、触发器、pg_cron 任务) |
|
||||
| `104_drop_runtime_timezone` | 删除 `agent_runtime.timezone` 列(Operational 层移除,见 §2.1) |
|
||||
|
||||
配套的代码侧改动:
|
||||
|
||||
- **数据回填**:一次性命令 `cmd/backfill_task_usage_hourly`,按 workspace 切片把历史 `task_usage` 灌进新表。旧的 `cmd/backfill_task_usage_daily` / `cmd/backfill_task_usage_dashboard_daily` 已删除。
|
||||
- **查询切换**:后端所有报表查询迁到 `task_usage_hourly`(或 Time/Tasks 的 `agent_task_queue` 查询),统一接受 `@tz`;`UseDailyRollupForDashboard` / `UseDailyRollupForRuntimeUsage` 等 feature flag 与旧的 raw-scan / daily-rollup 双查询路径一并删除。
|
||||
- **前端打通**:`useViewingTimezone()` hook 解析 viewer tz,报表组件随请求带 `?tz=`;`dashboard-page.tsx` 的 `WEEK_TZ = "UTC"` 改为 `useViewingTimezone()`,原 UTC-lock 解释性注释删除。
|
||||
- **UI 文案**:Preferences 新增 Timezone setting。Runtime Detail 页的 timezone editor 整体删除。
|
||||
- **runtime tz 移除**:`PATCH /api/runtimes/:id` 的 `timezone` 字段删除,该端点只剩 `visibility`;daemon 注册不再上报 host tz;`agent_runtime.timezone` 列由 migration 104 删除。
|
||||
|
||||
---
|
||||
|
||||
## 7. Open questions / Risks
|
||||
|
||||
### 7.1 Risks
|
||||
|
||||
- **Invalidation queue TTL 是必做**。如果忘记加,密集工况下 queue 会无界增长。
|
||||
- **Hourly rollup backfill 期间的源表 read pressure**。按 workspace 切片、低峰期跑,预期 OK,但需要提前给 DB 团队打招呼。
|
||||
- **DST 当天的 23h/25h "日"**。`DATE(bucket_hour AT TIME ZONE @tz)` 会正确处理,但前端任何"一天 = 24 小时"的硬编码偏移逻辑要测一遍 DST 边界。
|
||||
- **现有 `runtime.timezone` 的 PATCH endpoint 行为变了**。改完不再触发 backfill——这是好事,但 API 文档和 changelog 要写清楚,避免下游集成误判。
|
||||
|
||||
### 7.2 Open question
|
||||
|
||||
- **Trigger 的 timezone 默认值**?目前用户必须手动选;可以默认 `user.timezone`,但用户写 trigger 时的 viewing tz 和 trigger 实际跑的 tz 是两件事,需要产品决策。
|
||||
|
||||
### 7.3 非目标
|
||||
|
||||
- **不做** workspace 级 tz 设置:跨 region 团队两个成员各自正确的"今天"不同,workspace 级 tz 必让其中一方看错位报表。
|
||||
- **不做** 预物化多 tz rollup:IANA tz 列表有 ~600 个无法穷举、DST 需逐 tz 维护,而 hourly rollup 已经够快。
|
||||
- **不做** issue / comment / inbox 等列表的 tz 切换——它们已经隐式用浏览器 tz,本 RFC 不动。后续如果要让这些也跟 `user.timezone`,是独立的 follow-up。
|
||||
|
||||
---
|
||||
|
||||
## 8. 决策汇总
|
||||
|
||||
| 决策点 | 选择 |
|
||||
|---|---|
|
||||
| Timezone 概念分层 | Scheduling / Viewing 两层(Operational 经盘查后移除) |
|
||||
| `runtime.timezone` 角色 | ❌ 整列删除(migration 104) |
|
||||
| `user.timezone` 是否新增 | ✅ 新增,nullable,默认 fallback 到 browser |
|
||||
| `workspace.timezone` 是否新增 | ❌ 不引入 |
|
||||
| 数据层物化口径 | 统一 UTC, hourly grain |
|
||||
| Rollup 表合并 | `task_usage_daily` + `task_usage_dashboard_daily` → `task_usage_hourly` |
|
||||
| 报表 tz 切换粒度 | 全局 per-user(Preferences),不做 per-view picker |
|
||||
| hour-of-day heatmap tz | viewer tz(不再用机器物理 tz) |
|
||||
111
e2e/onboarding-v2-smoke.spec.ts
Normal file
111
e2e/onboarding-v2-smoke.spec.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
import { TestApiClient } from "./fixtures";
|
||||
|
||||
// Smoke test for Onboarding V2: verifies the new per-question flow
|
||||
// renders and captures screenshots for review. Uses a unique email
|
||||
// per run so the user is always a fresh, un-onboarded user landing
|
||||
// on /onboarding.
|
||||
|
||||
const EMAIL = `onboarding-v2-${Date.now()}@localhost`;
|
||||
const SHOTS_DIR = "/tmp/onboarding-v2-shots";
|
||||
|
||||
test.use({ viewport: { width: 1440, height: 900 } });
|
||||
|
||||
test("onboarding v2 — welcome → source → role → use_case (skip path)", async ({ page }) => {
|
||||
const api = new TestApiClient();
|
||||
await api.login(EMAIL, "OBv2 Tester");
|
||||
const token = api.getToken();
|
||||
|
||||
await page.goto("/login");
|
||||
await page.evaluate((t) => {
|
||||
localStorage.setItem("multica_token", t);
|
||||
}, token);
|
||||
await page.goto("/onboarding");
|
||||
await page.waitForLoadState("networkidle");
|
||||
|
||||
// 1. Welcome screen
|
||||
await expect(page.getByRole("button", { name: "Continue on web" })).toBeVisible({ timeout: 15000 });
|
||||
await page.screenshot({ path: `${SHOTS_DIR}/01-welcome.png`, fullPage: false });
|
||||
|
||||
// Click Start exploring to advance to Source
|
||||
await page.getByRole("button", { name: "Continue on web" }).click();
|
||||
|
||||
// 2. Source step
|
||||
await expect(page.getByText("How did you hear about Multica?")).toBeVisible({ timeout: 10000 });
|
||||
await expect(page.getByText(`Step 1 of 6`)).toBeVisible();
|
||||
await page.waitForTimeout(500);
|
||||
await page.screenshot({ path: `${SHOTS_DIR}/02-source.png` });
|
||||
|
||||
// Pick Friends/colleagues then click Continue to advance.
|
||||
await page.getByRole("radio", { name: /Friends or colleagues/i }).click();
|
||||
await page.getByRole("button", { name: "Continue" }).click();
|
||||
|
||||
// 3. Role step
|
||||
await expect(page.getByText("Which best describes you?")).toBeVisible({ timeout: 10000 });
|
||||
await expect(page.getByText(`Step 2 of 6`)).toBeVisible();
|
||||
await page.waitForTimeout(500);
|
||||
await page.screenshot({ path: `${SHOTS_DIR}/03-role.png` });
|
||||
|
||||
// Skip role
|
||||
await page.getByRole("button", { name: "Skip" }).click();
|
||||
|
||||
// 4. Use case step
|
||||
await expect(page.getByText("What do you want to use Multica for?")).toBeVisible({ timeout: 10000 });
|
||||
await expect(page.getByText(`Step 3 of 6`)).toBeVisible();
|
||||
await page.waitForTimeout(500);
|
||||
await page.screenshot({ path: `${SHOTS_DIR}/04-use-case.png` });
|
||||
|
||||
// Pick ship_code then Continue → workspace step.
|
||||
await page.getByRole("radio", { name: /Ship code with AI agents/i }).click();
|
||||
await page.getByRole("button", { name: "Continue" }).click();
|
||||
|
||||
// 5. Workspace step (legacy)
|
||||
await expect(page.getByRole("heading", { name: /Name your workspace/i })).toBeVisible({ timeout: 10000 });
|
||||
await page.screenshot({ path: `${SHOTS_DIR}/05-workspace.png` });
|
||||
});
|
||||
|
||||
test("onboarding v2 — rage-skip all 3 questions", async ({ page }) => {
|
||||
const api = new TestApiClient();
|
||||
await api.login(`rage-skip-${Date.now()}@localhost`, "Rage Skipper");
|
||||
const token = api.getToken();
|
||||
|
||||
await page.goto("/login");
|
||||
await page.evaluate((t) => localStorage.setItem("multica_token", t), token);
|
||||
await page.goto("/onboarding");
|
||||
await page.waitForLoadState("networkidle");
|
||||
|
||||
await page.getByRole("button", { name: "Continue on web" }).click();
|
||||
await expect(page.getByText("How did you hear about Multica?")).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// Skip × 3
|
||||
await page.getByRole("button", { name: "Skip" }).click();
|
||||
await expect(page.getByText("Which best describes you?")).toBeVisible({ timeout: 10000 });
|
||||
await page.getByRole("button", { name: "Skip" }).click();
|
||||
await expect(page.getByText("What do you want to use Multica for?")).toBeVisible({ timeout: 10000 });
|
||||
await page.getByRole("button", { name: "Skip" }).click();
|
||||
|
||||
// Lands on workspace step
|
||||
await expect(page.getByRole("heading", { name: /Name your workspace/i })).toBeVisible({ timeout: 10000 });
|
||||
await page.screenshot({ path: `${SHOTS_DIR}/06-after-rage-skip.png` });
|
||||
});
|
||||
|
||||
test("onboarding v2 — zh-Hans renders Chinese labels", async ({ page, context }) => {
|
||||
await context.addCookies([
|
||||
{ name: "multica-locale", value: "zh-Hans", url: "http://localhost:13442" },
|
||||
]);
|
||||
const api = new TestApiClient();
|
||||
await api.login(`zh-${Date.now()}@localhost`, "中文用户");
|
||||
const token = api.getToken();
|
||||
|
||||
await page.goto("/login");
|
||||
await page.evaluate((t) => localStorage.setItem("multica_token", t), token);
|
||||
await page.goto("/onboarding");
|
||||
await page.waitForLoadState("networkidle");
|
||||
|
||||
await page.getByRole("button").first().click().catch(() => {});
|
||||
|
||||
// Source screen — Chinese question
|
||||
await expect(page.getByText("你是从哪里了解到 Multica 的?")).toBeVisible({ timeout: 10000 });
|
||||
await page.waitForTimeout(500);
|
||||
await page.screenshot({ path: `${SHOTS_DIR}/07-source-zh.png` });
|
||||
});
|
||||
@@ -50,7 +50,6 @@ function makeRuntime(overrides: Partial<AgentRuntime> = {}): AgentRuntime {
|
||||
metadata: {},
|
||||
owner_id: null,
|
||||
visibility: "private",
|
||||
timezone: "UTC",
|
||||
last_seen_at: "2026-04-27T11:59:50Z",
|
||||
created_at: "2026-04-01T00:00:00Z",
|
||||
updated_at: "2026-04-01T00:00:00Z",
|
||||
|
||||
@@ -3,3 +3,7 @@ export {
|
||||
type AgentsScope,
|
||||
type AgentsViewState,
|
||||
} from "./view-store";
|
||||
export {
|
||||
useTranscriptViewStore,
|
||||
type TranscriptSortDirection,
|
||||
} from "./transcript-view-store";
|
||||
|
||||
22
packages/core/agents/stores/transcript-view-store.test.ts
Normal file
22
packages/core/agents/stores/transcript-view-store.test.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import { useTranscriptViewStore } from "./transcript-view-store";
|
||||
|
||||
beforeEach(() => {
|
||||
useTranscriptViewStore.setState({ sortDirection: "chronological" });
|
||||
});
|
||||
|
||||
describe("useTranscriptViewStore", () => {
|
||||
it("defaults to chronological so existing readers see no behavior change", () => {
|
||||
expect(useTranscriptViewStore.getState().sortDirection).toBe("chronological");
|
||||
});
|
||||
|
||||
it("setSortDirection switches between the two known directions", () => {
|
||||
const { setSortDirection } = useTranscriptViewStore.getState();
|
||||
|
||||
setSortDirection("newest_first");
|
||||
expect(useTranscriptViewStore.getState().sortDirection).toBe("newest_first");
|
||||
|
||||
setSortDirection("chronological");
|
||||
expect(useTranscriptViewStore.getState().sortDirection).toBe("chronological");
|
||||
});
|
||||
});
|
||||
26
packages/core/agents/stores/transcript-view-store.ts
Normal file
26
packages/core/agents/stores/transcript-view-store.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
"use client";
|
||||
|
||||
import { create } from "zustand";
|
||||
import { createJSONStorage, persist } from "zustand/middleware";
|
||||
import { defaultStorage } from "../../platform/storage";
|
||||
|
||||
export type TranscriptSortDirection = "chronological" | "newest_first";
|
||||
|
||||
interface TranscriptViewState {
|
||||
sortDirection: TranscriptSortDirection;
|
||||
setSortDirection: (dir: TranscriptSortDirection) => void;
|
||||
}
|
||||
|
||||
export const useTranscriptViewStore = create<TranscriptViewState>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
sortDirection: "chronological",
|
||||
setSortDirection: (sortDirection) => set({ sortDirection }),
|
||||
}),
|
||||
{
|
||||
name: "multica_transcript_view",
|
||||
storage: createJSONStorage(() => defaultStorage),
|
||||
partialize: (state) => ({ sortDirection: state.sortDirection }),
|
||||
},
|
||||
),
|
||||
);
|
||||
@@ -48,10 +48,11 @@ 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" });
|
||||
await client.updateAutopilot("ap-1", { status: "paused", project_id: null });
|
||||
await client.deleteAutopilot("ap-1");
|
||||
await client.triggerAutopilot("ap-1");
|
||||
await client.listAutopilotRuns("ap-1", { limit: 10, offset: 20 });
|
||||
@@ -78,6 +79,7 @@ describe("ApiClient", () => {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
title: "Daily triage",
|
||||
project_id: "project-1",
|
||||
assignee_id: "agent-1",
|
||||
execution_mode: "create_issue",
|
||||
}),
|
||||
@@ -85,7 +87,7 @@ describe("ApiClient", () => {
|
||||
{
|
||||
url: "https://api.example.test/api/autopilots/ap-1",
|
||||
method: "PATCH",
|
||||
body: JSON.stringify({ status: "paused" }),
|
||||
body: JSON.stringify({ status: "paused", project_id: null }),
|
||||
},
|
||||
{ url: "https://api.example.test/api/autopilots/ap-1", method: "DELETE" },
|
||||
{ url: "https://api.example.test/api/autopilots/ap-1/trigger", method: "POST" },
|
||||
@@ -150,6 +152,91 @@ describe("ApiClient", () => {
|
||||
expect(headers["X-Client-OS"]).toBeUndefined();
|
||||
});
|
||||
|
||||
it("uses the Cloud Runtime node API contract and forwards bootstrap PAT on create", async () => {
|
||||
const node = {
|
||||
id: "node-1",
|
||||
owner_id: "user-1",
|
||||
instance_id: "i-0123456789abcdef0",
|
||||
region: "us-west-2",
|
||||
instance_type: "g5.xlarge",
|
||||
image_id: "ami-1",
|
||||
subnet_id: "subnet-1",
|
||||
name: "gpu-dev-01",
|
||||
status: "launching",
|
||||
tags: {},
|
||||
metadata: {},
|
||||
created_at: "2026-05-21T08:30:00Z",
|
||||
updated_at: "2026-05-21T08:30:00Z",
|
||||
};
|
||||
const fetchMock = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(
|
||||
new Response(JSON.stringify([]), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}),
|
||||
)
|
||||
.mockResolvedValueOnce(
|
||||
new Response(JSON.stringify(node), {
|
||||
status: 201,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}),
|
||||
);
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const client = new ApiClient("https://api.example.test");
|
||||
await client.listCloudRuntimeNodes({ limit: 20, offset: 5 });
|
||||
await client.createCloudRuntimeNode(
|
||||
{ instance_type: "g5.xlarge", name: "gpu-dev-01" },
|
||||
{ userPAT: "mul_cloud_bootstrap_pat" },
|
||||
);
|
||||
|
||||
const listCall = fetchMock.mock.calls[0]!;
|
||||
const createCall = fetchMock.mock.calls[1]!;
|
||||
expect(listCall[0]).toBe(
|
||||
"https://api.example.test/api/cloud-runtime/nodes?limit=20&offset=5",
|
||||
);
|
||||
expect((listCall[1]!.headers as Record<string, string>)["X-User-PAT"]).toBeUndefined();
|
||||
expect(createCall[0]).toBe(
|
||||
"https://api.example.test/api/cloud-runtime/nodes",
|
||||
);
|
||||
expect(createCall[1]).toMatchObject({
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
instance_type: "g5.xlarge",
|
||||
name: "gpu-dev-01",
|
||||
}),
|
||||
});
|
||||
expect((createCall[1]!.headers as Record<string, string>)["X-User-PAT"]).toBe(
|
||||
"mul_cloud_bootstrap_pat",
|
||||
);
|
||||
});
|
||||
|
||||
it("falls back when Cloud Runtime node responses drift", async () => {
|
||||
const fetchMock = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(
|
||||
new Response(JSON.stringify([{ id: 123 }]), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}),
|
||||
)
|
||||
.mockResolvedValueOnce(
|
||||
new Response(JSON.stringify({ id: 123 }), {
|
||||
status: 201,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}),
|
||||
);
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const client = new ApiClient("https://api.example.test");
|
||||
|
||||
await expect(client.listCloudRuntimeNodes()).resolves.toEqual([]);
|
||||
await expect(
|
||||
client.createCloudRuntimeNode({ instance_type: "g5.xlarge" }),
|
||||
).resolves.toMatchObject({ id: "", status: "" });
|
||||
});
|
||||
|
||||
describe("getAttachment", () => {
|
||||
it("returns the parsed attachment for a well-formed response", async () => {
|
||||
vi.stubGlobal(
|
||||
|
||||
@@ -101,6 +101,12 @@ import type {
|
||||
SquadMemberStatusListResponse,
|
||||
} from "../types";
|
||||
import type { OnboardingCompletionPath } from "../onboarding/types";
|
||||
import type {
|
||||
CloudRuntimeNode,
|
||||
CreateCloudRuntimeNodeOptions,
|
||||
CreateCloudRuntimeNodeRequest,
|
||||
ListCloudRuntimeNodesParams,
|
||||
} from "../runtimes/cloud-runtime";
|
||||
import { type Logger, noopLogger } from "../logger";
|
||||
import { createRequestId } from "../utils";
|
||||
import { getCurrentSlug } from "../platform/workspace-storage";
|
||||
@@ -111,6 +117,8 @@ import {
|
||||
AttachmentResponseSchema,
|
||||
ChildIssuesResponseSchema,
|
||||
CommentsListSchema,
|
||||
CloudRuntimeNodeListSchema,
|
||||
CloudRuntimeNodeSchema,
|
||||
CreateAgentFromTemplateResponseSchema,
|
||||
DashboardAgentRunTimeListSchema,
|
||||
DashboardRunTimeDailyListSchema,
|
||||
@@ -119,19 +127,29 @@ import {
|
||||
EMPTY_AGENT_TEMPLATE_DETAIL,
|
||||
EMPTY_AGENT_TEMPLATE_SUMMARY_LIST,
|
||||
EMPTY_ATTACHMENT,
|
||||
EMPTY_CLOUD_RUNTIME_NODE,
|
||||
EMPTY_CLOUD_RUNTIME_NODE_LIST,
|
||||
EMPTY_CREATE_AGENT_FROM_TEMPLATE_RESPONSE,
|
||||
EMPTY_GROUPED_ISSUES_RESPONSE,
|
||||
EMPTY_LIST_ISSUES_RESPONSE,
|
||||
EMPTY_SQUAD_MEMBER_STATUS_LIST,
|
||||
EMPTY_TIMELINE_ENTRIES,
|
||||
EMPTY_USER,
|
||||
EMPTY_LIST_WEBHOOK_DELIVERIES_RESPONSE,
|
||||
EMPTY_WEBHOOK_DELIVERY,
|
||||
GroupedIssuesResponseSchema,
|
||||
ListIssuesResponseSchema,
|
||||
ListWebhookDeliveriesResponseSchema,
|
||||
OnboardingNoRuntimeBootstrapResponseSchema,
|
||||
OnboardingRuntimeBootstrapResponseSchema,
|
||||
RuntimeHourlyActivityListSchema,
|
||||
RuntimeUsageByAgentListSchema,
|
||||
RuntimeUsageByHourListSchema,
|
||||
RuntimeUsageListSchema,
|
||||
SquadMemberStatusListResponseSchema,
|
||||
SubscribersListSchema,
|
||||
TimelineEntriesSchema,
|
||||
UserSchema,
|
||||
WebhookDeliveryResponseSchema,
|
||||
} from "./schemas";
|
||||
|
||||
@@ -160,52 +178,30 @@ export interface LoginResponse {
|
||||
user: User;
|
||||
}
|
||||
|
||||
// --- 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 {
|
||||
export interface OnboardingRuntimeBootstrapResponse {
|
||||
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[];
|
||||
agent_id: string;
|
||||
issue_id: string;
|
||||
}
|
||||
|
||||
export interface ImportStarterContentResponse {
|
||||
user: User;
|
||||
project_id: string;
|
||||
/** Non-null when server took the agent-guided branch. */
|
||||
welcome_issue_id: string | null;
|
||||
const EMPTY_ONBOARDING_RUNTIME_BOOTSTRAP_RESPONSE:
|
||||
OnboardingRuntimeBootstrapResponse = {
|
||||
workspace_id: "",
|
||||
agent_id: "",
|
||||
issue_id: "",
|
||||
};
|
||||
|
||||
export interface OnboardingNoRuntimeBootstrapResponse {
|
||||
workspace_id: string;
|
||||
issue_id: string;
|
||||
}
|
||||
|
||||
const EMPTY_ONBOARDING_NO_RUNTIME_BOOTSTRAP_RESPONSE:
|
||||
OnboardingNoRuntimeBootstrapResponse = {
|
||||
workspace_id: "",
|
||||
issue_id: "",
|
||||
};
|
||||
|
||||
export class ApiError extends Error {
|
||||
readonly status: number;
|
||||
readonly statusText: string;
|
||||
@@ -401,71 +397,95 @@ export class ApiClient {
|
||||
}
|
||||
|
||||
async getMe(): Promise<User> {
|
||||
return this.fetch("/api/me");
|
||||
const raw = await this.fetch<unknown>("/api/me");
|
||||
return parseWithFallback(raw, UserSchema, EMPTY_USER, {
|
||||
endpoint: "GET /api/me",
|
||||
});
|
||||
}
|
||||
|
||||
async markOnboardingComplete(payload?: {
|
||||
completion_path?: OnboardingCompletionPath;
|
||||
workspace_id?: string;
|
||||
}): Promise<User> {
|
||||
return this.fetch("/api/me/onboarding/complete", {
|
||||
const raw = await this.fetch<unknown>("/api/me/onboarding/complete", {
|
||||
method: "POST",
|
||||
body: payload ? JSON.stringify(payload) : undefined,
|
||||
});
|
||||
return parseWithFallback(raw, UserSchema, EMPTY_USER, {
|
||||
endpoint: "POST /api/me/onboarding/complete",
|
||||
});
|
||||
}
|
||||
|
||||
async bootstrapOnboardingRuntime(payload: {
|
||||
workspace_id: string;
|
||||
runtime_id: string;
|
||||
}): Promise<OnboardingRuntimeBootstrapResponse> {
|
||||
const raw = await this.fetch<unknown>(
|
||||
"/api/me/onboarding/runtime-bootstrap",
|
||||
{
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
},
|
||||
);
|
||||
return parseWithFallback(
|
||||
raw,
|
||||
OnboardingRuntimeBootstrapResponseSchema,
|
||||
EMPTY_ONBOARDING_RUNTIME_BOOTSTRAP_RESPONSE,
|
||||
{ endpoint: "POST /api/me/onboarding/runtime-bootstrap" },
|
||||
);
|
||||
}
|
||||
|
||||
async bootstrapOnboardingNoRuntime(payload: {
|
||||
workspace_id: string;
|
||||
}): Promise<OnboardingNoRuntimeBootstrapResponse> {
|
||||
const raw = await this.fetch<unknown>(
|
||||
"/api/me/onboarding/no-runtime-bootstrap",
|
||||
{
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
},
|
||||
);
|
||||
return parseWithFallback(
|
||||
raw,
|
||||
OnboardingNoRuntimeBootstrapResponseSchema,
|
||||
EMPTY_ONBOARDING_NO_RUNTIME_BOOTSTRAP_RESPONSE,
|
||||
{ endpoint: "POST /api/me/onboarding/no-runtime-bootstrap" },
|
||||
);
|
||||
}
|
||||
|
||||
async joinCloudWaitlist(payload: {
|
||||
email: string;
|
||||
reason?: string;
|
||||
}): Promise<User> {
|
||||
return this.fetch("/api/me/onboarding/cloud-waitlist", {
|
||||
const raw = await this.fetch<unknown>("/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> {
|
||||
return this.fetch("/api/me/onboarding", {
|
||||
const raw = await this.fetch<unknown>("/api/me/onboarding", {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 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,
|
||||
return parseWithFallback(raw, UserSchema, EMPTY_USER, {
|
||||
endpoint: "PATCH /api/me/onboarding",
|
||||
});
|
||||
}
|
||||
|
||||
async updateMe(data: UpdateMeRequest): Promise<User> {
|
||||
return this.fetch("/api/me", {
|
||||
const raw = await this.fetch<unknown>("/api/me", {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
return parseWithFallback(raw, UserSchema, EMPTY_USER, {
|
||||
endpoint: "PATCH /api/me",
|
||||
});
|
||||
}
|
||||
|
||||
// Issues
|
||||
@@ -480,7 +500,9 @@ export class ApiClient {
|
||||
if (params?.assignee_ids?.length) search.set("assignee_ids", params.assignee_ids.join(","));
|
||||
if (params?.creator_id) search.set("creator_id", params.creator_id);
|
||||
if (params?.project_id) search.set("project_id", params.project_id);
|
||||
if (params?.involves_user_id) search.set("involves_user_id", params.involves_user_id);
|
||||
if (params?.open_only) search.set("open_only", "true");
|
||||
if (params?.scheduled) search.set("scheduled", "true");
|
||||
const path = `/api/issues?${search}`;
|
||||
const raw = await this.fetch<unknown>(path);
|
||||
return parseWithFallback(raw, ListIssuesResponseSchema, EMPTY_LIST_ISSUES_RESPONSE, {
|
||||
@@ -500,6 +522,7 @@ export class ApiClient {
|
||||
if (params.assignee_ids?.length) search.set("assignee_ids", params.assignee_ids.join(","));
|
||||
if (params.creator_id) search.set("creator_id", params.creator_id);
|
||||
if (params.project_id) search.set("project_id", params.project_id);
|
||||
if (params.involves_user_id) search.set("involves_user_id", params.involves_user_id);
|
||||
if (params.assignee_filters?.length) {
|
||||
search.set("assignee_filters", params.assignee_filters.map((f) => `${f.type}:${f.id}`).join(","));
|
||||
}
|
||||
@@ -806,13 +829,54 @@ export class ApiClient {
|
||||
return this.fetch(`/api/runtimes?${search}`);
|
||||
}
|
||||
|
||||
async listCloudRuntimeNodes(
|
||||
params?: ListCloudRuntimeNodesParams,
|
||||
): Promise<CloudRuntimeNode[]> {
|
||||
const search = new URLSearchParams();
|
||||
if (params?.limit !== undefined) search.set("limit", String(params.limit));
|
||||
if (params?.offset !== undefined) search.set("offset", String(params.offset));
|
||||
const query = search.toString();
|
||||
const raw = await this.fetch<unknown>(
|
||||
`/api/cloud-runtime/nodes${query ? `?${query}` : ""}`,
|
||||
);
|
||||
return parseWithFallback(
|
||||
raw,
|
||||
CloudRuntimeNodeListSchema,
|
||||
EMPTY_CLOUD_RUNTIME_NODE_LIST,
|
||||
{ endpoint: "GET /api/cloud-runtime/nodes" },
|
||||
);
|
||||
}
|
||||
|
||||
async createCloudRuntimeNode(
|
||||
data: CreateCloudRuntimeNodeRequest,
|
||||
options?: CreateCloudRuntimeNodeOptions,
|
||||
): Promise<CloudRuntimeNode> {
|
||||
const extraHeaders: Record<string, string> = {
|
||||
"Content-Type": "application/json",
|
||||
};
|
||||
const userPAT = options?.userPAT?.trim();
|
||||
if (userPAT) extraHeaders["X-User-PAT"] = userPAT;
|
||||
const res = await this.fetchRaw("/api/cloud-runtime/nodes", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(data),
|
||||
extraHeaders,
|
||||
});
|
||||
const raw = await res.json() as unknown;
|
||||
return parseWithFallback(
|
||||
raw,
|
||||
CloudRuntimeNodeSchema,
|
||||
EMPTY_CLOUD_RUNTIME_NODE,
|
||||
{ endpoint: "POST /api/cloud-runtime/nodes" },
|
||||
);
|
||||
}
|
||||
|
||||
async deleteRuntime(runtimeId: string): Promise<void> {
|
||||
await this.fetch(`/api/runtimes/${runtimeId}`, { method: "DELETE" });
|
||||
}
|
||||
|
||||
async updateRuntime(
|
||||
runtimeId: string,
|
||||
patch: { timezone?: string; visibility?: "private" | "public" },
|
||||
patch: { visibility?: "private" | "public" },
|
||||
): Promise<AgentRuntime> {
|
||||
return this.fetch(`/api/runtimes/${runtimeId}`, {
|
||||
method: "PATCH",
|
||||
@@ -820,32 +884,77 @@ export class ApiClient {
|
||||
});
|
||||
}
|
||||
|
||||
async getRuntimeUsage(runtimeId: string, params?: { days?: number }): Promise<RuntimeUsage[]> {
|
||||
async getRuntimeUsage(
|
||||
runtimeId: string,
|
||||
params?: { days?: number; tz?: string },
|
||||
): Promise<RuntimeUsage[]> {
|
||||
const search = new URLSearchParams();
|
||||
if (params?.days) search.set("days", String(params.days));
|
||||
return this.fetch(`/api/runtimes/${runtimeId}/usage?${search}`);
|
||||
// `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",
|
||||
});
|
||||
}
|
||||
|
||||
async getRuntimeTaskActivity(runtimeId: string): Promise<RuntimeHourlyActivity[]> {
|
||||
return this.fetch(`/api/runtimes/${runtimeId}/activity`);
|
||||
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 getRuntimeUsageByAgent(
|
||||
runtimeId: string,
|
||||
params?: { days?: number },
|
||||
params?: { days?: number; tz?: string },
|
||||
): Promise<RuntimeUsageByAgent[]> {
|
||||
const search = new URLSearchParams();
|
||||
if (params?.days) search.set("days", String(params.days));
|
||||
return this.fetch(`/api/runtimes/${runtimeId}/usage/by-agent?${search}`);
|
||||
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" },
|
||||
);
|
||||
}
|
||||
|
||||
async getRuntimeUsageByHour(
|
||||
runtimeId: string,
|
||||
params?: { days?: number },
|
||||
params?: { days?: number; tz?: string },
|
||||
): Promise<RuntimeUsageByHour[]> {
|
||||
const search = new URLSearchParams();
|
||||
if (params?.days) search.set("days", String(params.days));
|
||||
return this.fetch(`/api/runtimes/${runtimeId}/usage/by-hour?${search}`);
|
||||
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" },
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -856,11 +965,12 @@ export class ApiClient {
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async getDashboardUsageDaily(
|
||||
params: { days?: number; project_id?: string | null },
|
||||
params: { days?: number; project_id?: string | null; tz?: string },
|
||||
): 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,
|
||||
@@ -871,11 +981,12 @@ export class ApiClient {
|
||||
}
|
||||
|
||||
async getDashboardUsageByAgent(
|
||||
params: { days?: number; project_id?: string | null },
|
||||
params: { days?: number; project_id?: string | null; tz?: string },
|
||||
): 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,
|
||||
@@ -886,11 +997,14 @@ export class ApiClient {
|
||||
}
|
||||
|
||||
async getDashboardAgentRunTime(
|
||||
params: { days?: number; project_id?: string | null },
|
||||
params: { days?: number; project_id?: string | null; tz?: string },
|
||||
): 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,
|
||||
@@ -901,11 +1015,14 @@ export class ApiClient {
|
||||
}
|
||||
|
||||
async getDashboardRunTimeDaily(
|
||||
params: { days?: number; project_id?: string | null },
|
||||
params: { days?: number; project_id?: string | null; tz?: string },
|
||||
): 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,
|
||||
@@ -1023,9 +1140,10 @@ export class ApiClient {
|
||||
});
|
||||
}
|
||||
|
||||
async rerunIssue(issueId: string): Promise<AgentTask> {
|
||||
async rerunIssue(issueId: string, taskId?: string): Promise<AgentTask> {
|
||||
return this.fetch(`/api/issues/${issueId}/rerun`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(taskId ? { task_id: taskId } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1102,7 +1220,7 @@ export class ApiClient {
|
||||
});
|
||||
}
|
||||
|
||||
async updateWorkspace(id: string, data: { name?: string; description?: string; context?: string; settings?: Record<string, unknown>; repos?: WorkspaceRepo[] }): Promise<Workspace> {
|
||||
async updateWorkspace(id: string, data: { name?: string; description?: string; context?: string; settings?: Record<string, unknown>; repos?: WorkspaceRepo[]; issue_prefix?: string }): Promise<Workspace> {
|
||||
return this.fetch(`/api/workspaces/${id}`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify(data),
|
||||
|
||||
@@ -4,13 +4,7 @@ export {
|
||||
PreviewTooLargeError,
|
||||
PreviewUnsupportedError,
|
||||
} from "./client";
|
||||
export type {
|
||||
ApiClientOptions,
|
||||
ImportStarterContentPayload,
|
||||
ImportStarterContentResponse,
|
||||
ImportStarterIssuePayload,
|
||||
ImportStarterWelcomeIssueTemplate,
|
||||
} from "./client";
|
||||
export type { ApiClientOptions } from "./client";
|
||||
export { parseWithFallback, setSchemaLogger } from "./schema";
|
||||
export type { ParseOptions } from "./schema";
|
||||
export { DuplicateIssueErrorBodySchema } from "./schemas";
|
||||
|
||||
@@ -1,5 +1,17 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { DuplicateIssueErrorBodySchema } from "./schemas";
|
||||
import {
|
||||
DashboardAgentRunTimeListSchema,
|
||||
DashboardUsageByAgentListSchema,
|
||||
DashboardUsageDailyListSchema,
|
||||
DuplicateIssueErrorBodySchema,
|
||||
EMPTY_USER,
|
||||
RuntimeHourlyActivityListSchema,
|
||||
RuntimeUsageByAgentListSchema,
|
||||
RuntimeUsageByHourListSchema,
|
||||
RuntimeUsageListSchema,
|
||||
UserSchema,
|
||||
} from "./schemas";
|
||||
import { parseWithFallback } from "./schema";
|
||||
|
||||
// The duplicate-issue branch in create-issue.tsx feeds ApiError.body
|
||||
// (typed as `unknown`) through this schema. Any future server drift that
|
||||
@@ -49,3 +61,106 @@ describe("DuplicateIssueErrorBodySchema", () => {
|
||||
expect(DuplicateIssueErrorBodySchema.safeParse(without).success).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// `user.timezone` (Viewing tz) was added in the timezone-architecture RFC.
|
||||
// A desktop build older than the server — or a server predating the
|
||||
// `user.timezone` migration — will return a `/api/me` body with no
|
||||
// `timezone` key. The schema must not fail closed on that: the field
|
||||
// defaults to `null`, which the frontend resolves to the browser-detected
|
||||
// tz at render time.
|
||||
describe("UserSchema timezone drift", () => {
|
||||
const base = {
|
||||
id: "11111111-1111-1111-1111-111111111111",
|
||||
name: "Ada",
|
||||
email: "ada@example.com",
|
||||
};
|
||||
|
||||
it("defaults timezone to null when the field is absent", () => {
|
||||
const parsed = UserSchema.parse(base);
|
||||
expect(parsed.timezone).toBe(null);
|
||||
});
|
||||
|
||||
it("preserves an explicit IANA timezone", () => {
|
||||
const parsed = UserSchema.parse({ ...base, timezone: "Asia/Tokyo" });
|
||||
expect(parsed.timezone).toBe("Asia/Tokyo");
|
||||
});
|
||||
|
||||
it("accepts an explicit null timezone", () => {
|
||||
const parsed = UserSchema.parse({ ...base, timezone: null });
|
||||
expect(parsed.timezone).toBe(null);
|
||||
});
|
||||
|
||||
// Wrong-type drift: a future server bug sending `timezone` as a number
|
||||
// must not throw into the UI. parseWithFallback degrades the whole user
|
||||
// object to the explicit fallback (EMPTY_USER) so /api/me callers keep a
|
||||
// valid shape instead of white-screening.
|
||||
it("falls back to EMPTY_USER when timezone is the wrong type", () => {
|
||||
const parsed = parseWithFallback(
|
||||
{ ...base, timezone: 42 },
|
||||
UserSchema,
|
||||
EMPTY_USER,
|
||||
{ endpoint: "GET /api/me" },
|
||||
);
|
||||
expect(parsed).toBe(EMPTY_USER);
|
||||
});
|
||||
});
|
||||
|
||||
// The workspace dashboard and runtime-detail pages were re-pointed at the
|
||||
// unified `task_usage_hourly` rollup. Every numeric field drives chart /
|
||||
// KPI math, and string keys (date / agent_id / model) bucket the series.
|
||||
// The contract these schemas must hold: a row missing a field degrades
|
||||
// that field to a sane default rather than dropping the WHOLE array to
|
||||
// the `[]` fallback — one drifted row must not blank the entire chart.
|
||||
describe("dashboard + runtime usage schema drift", () => {
|
||||
it("coerces a missing numeric field to 0 instead of dropping the array", () => {
|
||||
const parsed = DashboardUsageDailyListSchema.parse([
|
||||
{ date: "2026-05-19", model: "claude-opus-4-7", input_tokens: 100 },
|
||||
]);
|
||||
expect(parsed).toHaveLength(1);
|
||||
expect(parsed[0]?.output_tokens).toBe(0);
|
||||
expect(parsed[0]?.cache_read_tokens).toBe(0);
|
||||
expect(parsed[0]?.cache_write_tokens).toBe(0);
|
||||
});
|
||||
|
||||
it("coerces a missing date key to \"\" so the rest of the series survives", () => {
|
||||
const parsed = DashboardUsageDailyListSchema.parse([
|
||||
{ model: "claude-opus-4-7", input_tokens: 5 },
|
||||
]);
|
||||
expect(parsed).toHaveLength(1);
|
||||
expect(parsed[0]?.date).toBe("");
|
||||
});
|
||||
|
||||
it("coerces a missing agent_id key to \"\" for the agent-runtime panel", () => {
|
||||
const parsed = DashboardAgentRunTimeListSchema.parse([
|
||||
{ total_seconds: 42, task_count: 3, failed_count: 0 },
|
||||
]);
|
||||
expect(parsed).toHaveLength(1);
|
||||
expect(parsed[0]?.agent_id).toBe("");
|
||||
});
|
||||
|
||||
it("coerces a missing agent_id key to \"\" for the usage-by-agent panel", () => {
|
||||
const parsed = DashboardUsageByAgentListSchema.parse([
|
||||
{ model: "claude-opus-4-7", input_tokens: 7 },
|
||||
]);
|
||||
expect(parsed[0]?.agent_id).toBe("");
|
||||
});
|
||||
|
||||
it("coerces missing fields on every runtime usage schema", () => {
|
||||
expect(RuntimeUsageListSchema.parse([{ date: "2026-05-19" }])[0]?.input_tokens).toBe(0);
|
||||
expect(RuntimeHourlyActivityListSchema.parse([{ hour: 9 }])[0]?.count).toBe(0);
|
||||
expect(RuntimeUsageByAgentListSchema.parse([{ model: "x" }])[0]?.agent_id).toBe("");
|
||||
expect(RuntimeUsageByHourListSchema.parse([{ hour: 9 }])[0]?.model).toBe("");
|
||||
});
|
||||
|
||||
it("rejects a non-array body so parseWithFallback can return its fallback", () => {
|
||||
expect(DashboardUsageDailyListSchema.safeParse(null).success).toBe(false);
|
||||
expect(RuntimeUsageListSchema.safeParse({ rows: [] }).success).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps unknown server-side fields via .loose()", () => {
|
||||
const parsed = RuntimeUsageListSchema.parse([
|
||||
{ date: "2026-05-19", region: "us-east" },
|
||||
]);
|
||||
expect((parsed[0] as Record<string, unknown>).region).toBe("us-east");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -9,8 +9,10 @@ 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
|
||||
@@ -198,19 +200,67 @@ export const ChildIssuesResponseSchema = z.object({
|
||||
issues: z.array(IssueSchema).default([]),
|
||||
}).loose();
|
||||
|
||||
export const OnboardingRuntimeBootstrapResponseSchema = z.object({
|
||||
workspace_id: z.string(),
|
||||
agent_id: z.string(),
|
||||
issue_id: z.string(),
|
||||
}).loose();
|
||||
|
||||
export const OnboardingNoRuntimeBootstrapResponseSchema = z.object({
|
||||
workspace_id: z.string(),
|
||||
issue_id: z.string(),
|
||||
}).loose();
|
||||
|
||||
export const CloudRuntimeNodeSchema = z.object({
|
||||
id: z.string(),
|
||||
owner_id: z.string(),
|
||||
instance_id: z.string(),
|
||||
region: z.string(),
|
||||
instance_type: z.string(),
|
||||
image_id: z.string(),
|
||||
subnet_id: z.string(),
|
||||
name: z.string(),
|
||||
status: z.string(),
|
||||
tags: z.record(z.string(), z.string()).default({}),
|
||||
metadata: z.record(z.string(), z.unknown()).default({}),
|
||||
created_at: z.string(),
|
||||
updated_at: z.string(),
|
||||
}).loose();
|
||||
|
||||
export const CloudRuntimeNodeListSchema = z.array(CloudRuntimeNodeSchema);
|
||||
|
||||
export const EMPTY_CLOUD_RUNTIME_NODE_LIST: CloudRuntimeNode[] = [];
|
||||
|
||||
export const EMPTY_CLOUD_RUNTIME_NODE: CloudRuntimeNode = {
|
||||
id: "",
|
||||
owner_id: "",
|
||||
instance_id: "",
|
||||
region: "",
|
||||
instance_type: "",
|
||||
image_id: "",
|
||||
subnet_id: "",
|
||||
name: "",
|
||||
status: "",
|
||||
tags: {},
|
||||
metadata: {},
|
||||
created_at: "",
|
||||
updated_at: "",
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Workspace dashboard schemas
|
||||
//
|
||||
// The dashboard hits three independent rollup endpoints. Each returns a flat
|
||||
// array, and every field is consumed by chart / KPI math — a missing number
|
||||
// silently degrades to NaN downstream, so we coerce missing numbers to 0.
|
||||
// String fields stay lenient (no enum narrowing) to survive future model /
|
||||
// agent ID drift.
|
||||
// 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.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const DashboardUsageDailySchema = z.object({
|
||||
date: z.string(),
|
||||
model: z.string(),
|
||||
date: 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),
|
||||
@@ -221,8 +271,8 @@ const DashboardUsageDailySchema = z.object({
|
||||
export const DashboardUsageDailyListSchema = z.array(DashboardUsageDailySchema);
|
||||
|
||||
const DashboardUsageByAgentSchema = z.object({
|
||||
agent_id: z.string(),
|
||||
model: z.string(),
|
||||
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),
|
||||
@@ -233,7 +283,7 @@ const DashboardUsageByAgentSchema = z.object({
|
||||
export const DashboardUsageByAgentListSchema = z.array(DashboardUsageByAgentSchema);
|
||||
|
||||
const DashboardAgentRunTimeSchema = z.object({
|
||||
agent_id: z.string(),
|
||||
agent_id: z.string().default(""),
|
||||
total_seconds: z.number().default(0),
|
||||
task_count: z.number().default(0),
|
||||
failed_count: z.number().default(0),
|
||||
@@ -242,7 +292,7 @@ const DashboardAgentRunTimeSchema = z.object({
|
||||
export const DashboardAgentRunTimeListSchema = z.array(DashboardAgentRunTimeSchema);
|
||||
|
||||
const DashboardRunTimeDailySchema = z.object({
|
||||
date: z.string(),
|
||||
date: z.string().default(""),
|
||||
total_seconds: z.number().default(0),
|
||||
task_count: z.number().default(0),
|
||||
failed_count: z.number().default(0),
|
||||
@@ -250,6 +300,57 @@ 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
|
||||
@@ -472,3 +573,44 @@ export const EMPTY_WEBHOOK_DELIVERY: WebhookDelivery = {
|
||||
last_attempt_at: "",
|
||||
created_at: "",
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// User (`/api/me` GET + PATCH). The auth store and Settings → Account both
|
||||
// trust this shape — a drift here would knock both surfaces out. Kept
|
||||
// lenient by the same rules as IssueSchema: enums stay `z.string()`,
|
||||
// nullable fields are unioned with `null`, unknown server fields pass
|
||||
// through via `.loose()`. `profile_description` is the field added in
|
||||
// MUL-2406; the server emits `""` when unset (NOT NULL DEFAULT ''), so
|
||||
// the schema defaults to `""` too — keeps the type tight without
|
||||
// breaking older backends that don't return the column yet.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const UserSchema = z.object({
|
||||
id: z.string(),
|
||||
name: z.string().default(""),
|
||||
email: z.string().default(""),
|
||||
avatar_url: z.string().nullable().default(null),
|
||||
onboarded_at: z.string().nullable().default(null),
|
||||
onboarding_questionnaire: z.record(z.string(), z.unknown()).default({}),
|
||||
starter_content_state: z.string().nullable().default(null),
|
||||
language: z.string().nullable().default(null),
|
||||
profile_description: z.string().default(""),
|
||||
timezone: z.string().nullable().default(null),
|
||||
created_at: z.string().default(""),
|
||||
updated_at: z.string().default(""),
|
||||
}).loose();
|
||||
|
||||
export const EMPTY_USER: User = {
|
||||
id: "",
|
||||
name: "",
|
||||
email: "",
|
||||
avatar_url: null,
|
||||
onboarded_at: null,
|
||||
onboarding_questionnaire: {},
|
||||
starter_content_state: null,
|
||||
language: null,
|
||||
profile_description: "",
|
||||
timezone: null,
|
||||
created_at: "",
|
||||
updated_at: "",
|
||||
};
|
||||
|
||||
@@ -6,6 +6,7 @@ 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;
|
||||
@@ -14,6 +15,7 @@ class FakeWebSocket {
|
||||
readyState = 0;
|
||||
constructor(url: string) {
|
||||
FakeWebSocket.lastUrl = url;
|
||||
FakeWebSocket.lastInstance = this;
|
||||
}
|
||||
close() {}
|
||||
send() {}
|
||||
@@ -22,6 +24,7 @@ class FakeWebSocket {
|
||||
describe("WSClient", () => {
|
||||
beforeEach(() => {
|
||||
FakeWebSocket.lastUrl = null;
|
||||
FakeWebSocket.lastInstance = null;
|
||||
vi.stubGlobal("WebSocket", FakeWebSocket as unknown as typeof WebSocket);
|
||||
});
|
||||
|
||||
@@ -69,4 +72,59 @@ describe("WSClient", () => {
|
||||
expect(url.searchParams.has("client_version")).toBe(false);
|
||||
expect(url.searchParams.has("client_os")).toBe(false);
|
||||
});
|
||||
|
||||
it("truncates the logged payload when an unparseable frame is large", () => {
|
||||
const logger = {
|
||||
debug: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
};
|
||||
const ws = new WSClient("ws://example.test/ws", { logger });
|
||||
ws.connect();
|
||||
|
||||
const huge = "x".repeat(5000);
|
||||
FakeWebSocket.lastInstance!.onmessage?.({ data: huge });
|
||||
|
||||
expect(logger.warn).toHaveBeenCalledTimes(1);
|
||||
const [, summary] = logger.warn.mock.calls[0] as [string, string];
|
||||
expect(summary.length).toBeLessThan(huge.length);
|
||||
expect(summary).toContain("truncated");
|
||||
expect(summary).toContain("5000");
|
||||
expect(summary.startsWith("x".repeat(200))).toBe(true);
|
||||
});
|
||||
|
||||
it("logs and skips malformed frames without breaking later messages", () => {
|
||||
const logger = {
|
||||
debug: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
};
|
||||
const ws = new WSClient("ws://example.test/ws", { logger });
|
||||
const handler = vi.fn();
|
||||
ws.on("issue:updated", handler);
|
||||
ws.connect();
|
||||
|
||||
expect(() => {
|
||||
FakeWebSocket.lastInstance!.onmessage?.({ data: `{"type":"issue` });
|
||||
}).not.toThrow();
|
||||
|
||||
FakeWebSocket.lastInstance!.onmessage?.({
|
||||
data: JSON.stringify({
|
||||
type: "issue:updated",
|
||||
payload: { id: "issue-1" },
|
||||
}),
|
||||
});
|
||||
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
"ws: received unparseable message",
|
||||
`{"type":"issue`,
|
||||
);
|
||||
expect(handler).toHaveBeenCalledWith(
|
||||
{ id: "issue-1" },
|
||||
undefined,
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,6 +3,17 @@ 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
|
||||
@@ -75,7 +86,16 @@ export class WSClient {
|
||||
};
|
||||
|
||||
this.ws.onmessage = (event) => {
|
||||
const msg = JSON.parse(event.data as string) as WSMessage;
|
||||
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;
|
||||
}
|
||||
if ((msg as any).type === "auth_ack") {
|
||||
this.onAuthenticated();
|
||||
return;
|
||||
|
||||
19
packages/core/chat/queries.test.ts
Normal file
19
packages/core/chat/queries.test.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { isTaskMessageTaskId, taskMessagesOptions } from "./queries";
|
||||
|
||||
describe("taskMessagesOptions", () => {
|
||||
it("fetches task messages for persisted UUID task ids", () => {
|
||||
const taskId = "4a2e8d1c-7f9b-4e2a-9c1d-123456789abc";
|
||||
|
||||
expect(isTaskMessageTaskId(taskId)).toBe(true);
|
||||
expect(taskMessagesOptions(taskId).enabled).toBe(true);
|
||||
});
|
||||
|
||||
it("does not fetch task messages for optimistic task ids", () => {
|
||||
const taskId = "optimistic-optimistic-1778739487737";
|
||||
|
||||
expect(isTaskMessageTaskId(taskId)).toBe(false);
|
||||
expect(taskMessagesOptions(taskId).enabled).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -21,6 +21,12 @@ 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),
|
||||
@@ -70,7 +76,7 @@ export function taskMessagesOptions(taskId: string) {
|
||||
return queryOptions({
|
||||
queryKey: chatKeys.taskMessages(taskId),
|
||||
queryFn: () => api.listTaskMessages(taskId),
|
||||
enabled: !!taskId,
|
||||
enabled: isTaskMessageTaskId(taskId),
|
||||
staleTime: Infinity,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,45 +1,55 @@
|
||||
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) =>
|
||||
[...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,
|
||||
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,
|
||||
};
|
||||
|
||||
// 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.
|
||||
// 5-min rollup cadence on the server, 60s background refetch on the client.
|
||||
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),
|
||||
queryKey: dashboardKeys.daily(wsId, days, projectId, tz),
|
||||
queryFn: () =>
|
||||
api.getDashboardUsageDaily({ days, project_id: projectId ?? undefined }),
|
||||
api.getDashboardUsageDaily({
|
||||
days,
|
||||
project_id: projectId ?? undefined,
|
||||
tz,
|
||||
}),
|
||||
enabled: !!wsId,
|
||||
staleTime: STALE_TIME,
|
||||
});
|
||||
@@ -49,11 +59,16 @@ export function dashboardUsageByAgentOptions(
|
||||
wsId: string,
|
||||
days: number,
|
||||
projectId: string | null,
|
||||
tz: string,
|
||||
) {
|
||||
return queryOptions({
|
||||
queryKey: dashboardKeys.byAgent(wsId, days, projectId),
|
||||
queryKey: dashboardKeys.byAgent(wsId, days, projectId, tz),
|
||||
queryFn: () =>
|
||||
api.getDashboardUsageByAgent({ days, project_id: projectId ?? undefined }),
|
||||
api.getDashboardUsageByAgent({
|
||||
days,
|
||||
project_id: projectId ?? undefined,
|
||||
tz,
|
||||
}),
|
||||
enabled: !!wsId,
|
||||
staleTime: STALE_TIME,
|
||||
});
|
||||
@@ -63,11 +78,16 @@ export function dashboardAgentRunTimeOptions(
|
||||
wsId: string,
|
||||
days: number,
|
||||
projectId: string | null,
|
||||
tz: string,
|
||||
) {
|
||||
return queryOptions({
|
||||
queryKey: dashboardKeys.agentRuntime(wsId, days, projectId),
|
||||
queryKey: dashboardKeys.agentRuntime(wsId, days, projectId, tz),
|
||||
queryFn: () =>
|
||||
api.getDashboardAgentRunTime({ days, project_id: projectId ?? undefined }),
|
||||
api.getDashboardAgentRunTime({
|
||||
days,
|
||||
project_id: projectId ?? undefined,
|
||||
tz,
|
||||
}),
|
||||
enabled: !!wsId,
|
||||
staleTime: STALE_TIME,
|
||||
});
|
||||
@@ -77,11 +97,16 @@ export function dashboardRunTimeDailyOptions(
|
||||
wsId: string,
|
||||
days: number,
|
||||
projectId: string | null,
|
||||
tz: string,
|
||||
) {
|
||||
return queryOptions({
|
||||
queryKey: dashboardKeys.runTimeDaily(wsId, days, projectId),
|
||||
queryKey: dashboardKeys.runTimeDaily(wsId, days, projectId, tz),
|
||||
queryFn: () =>
|
||||
api.getDashboardRunTimeDaily({ days, project_id: projectId ?? undefined }),
|
||||
api.getDashboardRunTimeDaily({
|
||||
days,
|
||||
project_id: projectId ?? undefined,
|
||||
tz,
|
||||
}),
|
||||
enabled: !!wsId,
|
||||
staleTime: STALE_TIME,
|
||||
});
|
||||
|
||||
@@ -1,2 +1,4 @@
|
||||
export * from "./queries";
|
||||
export * from "./pull-request-status";
|
||||
export * from "./settings";
|
||||
export * from "./use-github-settings";
|
||||
|
||||
66
packages/core/github/settings.test.ts
Normal file
66
packages/core/github/settings.test.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
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 });
|
||||
});
|
||||
});
|
||||
29
packages/core/github/settings.ts
Normal file
29
packages/core/github/settings.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
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,
|
||||
};
|
||||
}
|
||||
16
packages/core/github/use-github-settings.ts
Normal file
16
packages/core/github/use-github-settings.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
import { useCurrentWorkspace } from "../paths";
|
||||
import { deriveGitHubSettings, type GitHubSettings } from "./settings";
|
||||
|
||||
/**
|
||||
* Reads the GitHub feature flags off the current workspace's settings JSONB.
|
||||
* Components downstream should consult this hook rather than poking at
|
||||
* `workspace.settings` directly, so the per-flag fallback semantics
|
||||
* (see deriveGitHubSettings) stay consistent.
|
||||
*/
|
||||
export function useGitHubSettings(): GitHubSettings {
|
||||
const workspace = useCurrentWorkspace();
|
||||
return useMemo(() => deriveGitHubSettings(workspace), [workspace]);
|
||||
}
|
||||
@@ -162,5 +162,9 @@ export function cleanupDeletedIssueCaches(
|
||||
qc.invalidateQueries({ queryKey: issueKeys.childProgress(wsId) });
|
||||
qc.invalidateQueries({ queryKey: issueKeys.list(wsId) });
|
||||
qc.invalidateQueries({ queryKey: issueKeys.myAll(wsId) });
|
||||
// Project Gantt cache lives outside `myAll`, so it needs an explicit
|
||||
// refresh when an issue is removed — the deleted row may have been a
|
||||
// scheduled bar visible right now.
|
||||
qc.invalidateQueries({ queryKey: issueKeys.projectGanttAll(wsId) });
|
||||
invalidateDeletedIssueDependentCaches(qc, wsId);
|
||||
}
|
||||
|
||||
@@ -181,6 +181,7 @@ 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) });
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -257,6 +258,7 @@ export function useUpdateIssue() {
|
||||
qc.invalidateQueries({ queryKey: issueKeys.list(wsId) });
|
||||
qc.invalidateQueries({ queryKey: issueKeys.assigneeGroupsAll(wsId) });
|
||||
qc.invalidateQueries({ queryKey: issueKeys.myAssigneeGroupsAll(wsId) });
|
||||
qc.invalidateQueries({ queryKey: issueKeys.projectGanttAll(wsId) });
|
||||
// Refresh the issue's attachments cache when the description editor
|
||||
// bound new uploads — the description editor reads `issueAttachments`
|
||||
// to resolve text-preview Eye gates, and unlike other mutations this
|
||||
@@ -340,6 +342,7 @@ 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);
|
||||
},
|
||||
});
|
||||
@@ -399,6 +402,7 @@ export function useBatchUpdateIssues() {
|
||||
qc.invalidateQueries({ queryKey: issueKeys.list(wsId) });
|
||||
qc.invalidateQueries({ queryKey: issueKeys.assigneeGroupsAll(wsId) });
|
||||
qc.invalidateQueries({ queryKey: issueKeys.myAssigneeGroupsAll(wsId) });
|
||||
qc.invalidateQueries({ queryKey: issueKeys.projectGanttAll(wsId) });
|
||||
if (ctx?.affectedParentIds && ctx.affectedParentIds.size > 0) {
|
||||
for (const parentId of ctx.affectedParentIds) {
|
||||
qc.invalidateQueries({
|
||||
@@ -501,6 +505,7 @@ 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),
|
||||
|
||||
131
packages/core/issues/queries.test.ts
Normal file
131
packages/core/issues/queries.test.ts
Normal file
@@ -0,0 +1,131 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { QueryClient } from "@tanstack/react-query";
|
||||
|
||||
import { setApiInstance } from "../api";
|
||||
import type { ApiClient } from "../api/client";
|
||||
import type { Issue, ListIssuesParams, ListIssuesResponse } from "../types";
|
||||
import {
|
||||
PROJECT_GANTT_MAX_ISSUES,
|
||||
PROJECT_GANTT_PAGE_LIMIT,
|
||||
issueKeys,
|
||||
projectGanttIssuesOptions,
|
||||
} from "./queries";
|
||||
|
||||
const WS_ID = "ws-1";
|
||||
const PROJECT_ID = "project-1";
|
||||
|
||||
function makeIssue(idx: number): Issue {
|
||||
return {
|
||||
id: `issue-${idx}`,
|
||||
workspace_id: WS_ID,
|
||||
number: idx,
|
||||
identifier: `MUL-${idx}`,
|
||||
title: `Issue ${idx}`,
|
||||
description: null,
|
||||
status: "todo",
|
||||
priority: "none",
|
||||
assignee_type: null,
|
||||
assignee_id: null,
|
||||
creator_type: "member",
|
||||
creator_id: "user-1",
|
||||
parent_issue_id: null,
|
||||
project_id: PROJECT_ID,
|
||||
position: idx,
|
||||
start_date: "2026-05-01T00:00:00Z",
|
||||
due_date: null,
|
||||
labels: [],
|
||||
created_at: "2025-01-01T00:00:00Z",
|
||||
updated_at: "2025-01-01T00:00:00Z",
|
||||
};
|
||||
}
|
||||
|
||||
// Type-only shim — only the methods the queries.ts code path under test calls.
|
||||
function installFakeApi(listIssues: (params?: ListIssuesParams) => Promise<ListIssuesResponse>) {
|
||||
setApiInstance({ listIssues } as unknown as ApiClient);
|
||||
}
|
||||
|
||||
describe("projectGanttIssuesOptions", () => {
|
||||
let qc: QueryClient;
|
||||
|
||||
beforeEach(() => {
|
||||
qc = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
qc.clear();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("returns the first page directly when it fits under PROJECT_GANTT_PAGE_LIMIT", async () => {
|
||||
const listIssues = vi
|
||||
.fn<(params?: ListIssuesParams) => Promise<ListIssuesResponse>>()
|
||||
.mockResolvedValue({
|
||||
issues: [makeIssue(1), makeIssue(2)],
|
||||
total: 2,
|
||||
});
|
||||
installFakeApi(listIssues);
|
||||
|
||||
const data = await qc.fetchQuery(projectGanttIssuesOptions(WS_ID, PROJECT_ID));
|
||||
|
||||
expect(listIssues).toHaveBeenCalledTimes(1);
|
||||
expect(listIssues).toHaveBeenCalledWith({
|
||||
project_id: PROJECT_ID,
|
||||
scheduled: true,
|
||||
limit: PROJECT_GANTT_PAGE_LIMIT,
|
||||
offset: 0,
|
||||
});
|
||||
expect(data).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("loops through pages until total is satisfied (no silent truncation)", async () => {
|
||||
const total = PROJECT_GANTT_PAGE_LIMIT + 7;
|
||||
const firstPage = Array.from({ length: PROJECT_GANTT_PAGE_LIMIT }, (_, i) =>
|
||||
makeIssue(i),
|
||||
);
|
||||
const secondPage = Array.from({ length: 7 }, (_, i) =>
|
||||
makeIssue(PROJECT_GANTT_PAGE_LIMIT + i),
|
||||
);
|
||||
|
||||
const listIssues = vi
|
||||
.fn<(params?: ListIssuesParams) => Promise<ListIssuesResponse>>()
|
||||
.mockImplementation(async (params) => {
|
||||
if (!params) throw new Error("expected params");
|
||||
const offset = params.offset ?? 0;
|
||||
if (offset === 0)
|
||||
return { issues: firstPage, total };
|
||||
if (offset === PROJECT_GANTT_PAGE_LIMIT)
|
||||
return { issues: secondPage, total };
|
||||
throw new Error(`unexpected offset ${offset}`);
|
||||
});
|
||||
installFakeApi(listIssues);
|
||||
|
||||
const data = await qc.fetchQuery(projectGanttIssuesOptions(WS_ID, PROJECT_ID));
|
||||
|
||||
expect(listIssues).toHaveBeenCalledTimes(2);
|
||||
expect(data).toHaveLength(total);
|
||||
});
|
||||
|
||||
it("stops looping when the server reports a smaller-than-limit page (safety net for total drift)", async () => {
|
||||
// Server says `total` is huge but only ever returns short pages — the
|
||||
// loop must terminate on the first short page to avoid an infinite fetch.
|
||||
const listIssues = vi
|
||||
.fn<(params?: ListIssuesParams) => Promise<ListIssuesResponse>>()
|
||||
.mockResolvedValue({
|
||||
issues: [makeIssue(1)],
|
||||
total: PROJECT_GANTT_MAX_ISSUES,
|
||||
});
|
||||
installFakeApi(listIssues);
|
||||
|
||||
const data = await qc.fetchQuery(projectGanttIssuesOptions(WS_ID, PROJECT_ID));
|
||||
|
||||
expect(listIssues).toHaveBeenCalledTimes(1);
|
||||
expect(data).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("uses the project-scoped Gantt cache key", () => {
|
||||
const options = projectGanttIssuesOptions(WS_ID, PROJECT_ID);
|
||||
expect(options.queryKey).toEqual(issueKeys.projectGantt(WS_ID, PROJECT_ID));
|
||||
});
|
||||
});
|
||||
@@ -28,6 +28,17 @@ 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) =>
|
||||
@@ -55,7 +66,7 @@ export const issueKeys = {
|
||||
|
||||
export type MyIssuesFilter = Pick<
|
||||
ListIssuesParams,
|
||||
"assignee_id" | "assignee_ids" | "creator_id" | "project_id"
|
||||
"assignee_id" | "assignee_ids" | "creator_id" | "project_id" | "involves_user_id"
|
||||
>;
|
||||
|
||||
export type AssigneeGroupedIssuesFilter = Omit<
|
||||
@@ -142,6 +153,59 @@ export function myIssueListOptions(
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Page size for the scheduled-issue fetch. The Gantt view always pulls every
|
||||
* scheduled issue (no client pagination), so this is just the chunk size we
|
||||
* use to walk the server's `(limit, offset)` window until we hit `total`.
|
||||
*/
|
||||
export const PROJECT_GANTT_PAGE_LIMIT = 500;
|
||||
|
||||
/**
|
||||
* Paranoia cap on the loop in {@link fetchProjectGanttIssues}. Real projects
|
||||
* shouldn't come close to this — a single project carrying 50k scheduled
|
||||
* issues is already a product problem, not a Gantt-rendering one — but the
|
||||
* guard prevents a buggy server `total` from spinning the loop forever.
|
||||
*/
|
||||
export const PROJECT_GANTT_MAX_ISSUES = 10_000;
|
||||
|
||||
async function fetchProjectGanttIssues(projectId: string) {
|
||||
const issues = [];
|
||||
let offset = 0;
|
||||
while (offset < PROJECT_GANTT_MAX_ISSUES) {
|
||||
const res = await api.listIssues({
|
||||
project_id: projectId,
|
||||
scheduled: true,
|
||||
limit: PROJECT_GANTT_PAGE_LIMIT,
|
||||
offset,
|
||||
});
|
||||
issues.push(...res.issues);
|
||||
if (res.issues.length < PROJECT_GANTT_PAGE_LIMIT) break;
|
||||
if (issues.length >= res.total) break;
|
||||
offset += PROJECT_GANTT_PAGE_LIMIT;
|
||||
}
|
||||
return issues;
|
||||
}
|
||||
|
||||
/**
|
||||
* One-shot fetch of every scheduled issue (`start_date` or `due_date` set)
|
||||
* for a project. The Project Gantt view consumes this directly — no status
|
||||
* bucketing, no client-side pagination, no Load-all affordance — because
|
||||
* the scheduled subset is bounded enough to come back in a small handful of
|
||||
* requests.
|
||||
*
|
||||
* Backed by `GET /api/issues?scheduled=true&project_id=…`; the SQL filter
|
||||
* mirrors the same `(start_date IS NOT NULL OR due_date IS NOT NULL)`
|
||||
* predicate the Gantt view applies on the client. Pages are walked until
|
||||
* `total` is reached so an oversized project can't silently lose bars past
|
||||
* the first page.
|
||||
*/
|
||||
export function projectGanttIssuesOptions(wsId: string, projectId: string) {
|
||||
return queryOptions({
|
||||
queryKey: issueKeys.projectGantt(wsId, projectId),
|
||||
queryFn: () => fetchProjectGanttIssues(projectId),
|
||||
});
|
||||
}
|
||||
|
||||
export function myIssueAssigneeGroupsOptions(
|
||||
wsId: string,
|
||||
scope: string,
|
||||
|
||||
@@ -9,7 +9,8 @@ import { ALL_STATUSES } from "../config";
|
||||
import { createWorkspaceAwareStorage, registerForWorkspaceRehydration } from "../../platform/workspace-storage";
|
||||
import { defaultStorage } from "../../platform/storage";
|
||||
|
||||
export type ViewMode = "board" | "list";
|
||||
export type ViewMode = "board" | "list" | "gantt";
|
||||
export type GanttZoom = "day" | "week" | "month";
|
||||
export type IssueGrouping = "status" | "assignee";
|
||||
export type SortField = "position" | "priority" | "start_date" | "due_date" | "created_at" | "title";
|
||||
export type SortDirection = "asc" | "desc";
|
||||
@@ -70,7 +71,11 @@ export interface IssueViewState {
|
||||
sortDirection: SortDirection;
|
||||
cardProperties: CardProperties;
|
||||
listCollapsedStatuses: IssueStatus[];
|
||||
ganttZoom: GanttZoom;
|
||||
ganttShowCompleted: boolean;
|
||||
setViewMode: (mode: ViewMode) => void;
|
||||
setGanttZoom: (zoom: GanttZoom) => void;
|
||||
toggleGanttShowCompleted: () => void;
|
||||
setGrouping: (grouping: IssueGrouping) => void;
|
||||
toggleStatusFilter: (status: IssueStatus) => void;
|
||||
togglePriorityFilter: (priority: IssuePriority) => void;
|
||||
@@ -113,8 +118,13 @@ export const viewStoreSlice = (set: StoreApi<IssueViewState>["setState"]): Issue
|
||||
labels: true,
|
||||
},
|
||||
listCollapsedStatuses: [],
|
||||
ganttZoom: "week",
|
||||
ganttShowCompleted: false,
|
||||
|
||||
setViewMode: (mode) => set({ viewMode: mode }),
|
||||
setGanttZoom: (zoom) => set({ ganttZoom: zoom }),
|
||||
toggleGanttShowCompleted: () =>
|
||||
set((state) => ({ ganttShowCompleted: !state.ganttShowCompleted })),
|
||||
setGrouping: (grouping) => set({ grouping }),
|
||||
toggleStatusFilter: (status) =>
|
||||
set((state) => ({
|
||||
@@ -232,6 +242,8 @@ export const viewStorePersistOptions = (name: string) => ({
|
||||
sortDirection: state.sortDirection,
|
||||
cardProperties: state.cardProperties,
|
||||
listCollapsedStatuses: state.listCollapsedStatuses,
|
||||
ganttZoom: state.ganttZoom,
|
||||
ganttShowCompleted: state.ganttShowCompleted,
|
||||
}),
|
||||
// Default Zustand merge is shallow, so a persisted `cardProperties` snapshot
|
||||
// saved before a new toggle was introduced wins entirely and the new key is
|
||||
|
||||
@@ -6,7 +6,12 @@ import {
|
||||
agentTaskSnapshotKeys,
|
||||
agentTasksKeys,
|
||||
} from "../agents/queries";
|
||||
import { onIssueDeleted, onIssueLabelsChanged } from "./ws-updaters";
|
||||
import {
|
||||
onIssueCreated,
|
||||
onIssueDeleted,
|
||||
onIssueLabelsChanged,
|
||||
onIssueUpdated,
|
||||
} from "./ws-updaters";
|
||||
import { issueKeys } from "./queries";
|
||||
import { labelKeys } from "../labels/queries";
|
||||
import type {
|
||||
@@ -151,6 +156,25 @@ describe("onIssueLabelsChanged", () => {
|
||||
const detail = qc.getQueryData<Issue>(issueKeys.detail(WS_ID, ISSUE_ID));
|
||||
expect(detail?.labels).toEqual([labelB]);
|
||||
});
|
||||
|
||||
it("patches the Project Gantt cache so label filters react in place", () => {
|
||||
const PROJECT_ID = "project-1";
|
||||
qc.setQueryData<Issue[]>(issueKeys.projectGantt(WS_ID, PROJECT_ID), [
|
||||
baseIssue,
|
||||
otherIssue,
|
||||
]);
|
||||
|
||||
onIssueLabelsChanged(qc, WS_ID, ISSUE_ID, [labelB]);
|
||||
|
||||
const gantt = qc.getQueryData<Issue[]>(
|
||||
issueKeys.projectGantt(WS_ID, PROJECT_ID),
|
||||
);
|
||||
expect(gantt?.find((i) => i.id === ISSUE_ID)?.labels).toEqual([labelB]);
|
||||
// Other issues in the same cache must not have their labels mutated.
|
||||
expect(gantt?.find((i) => i.id === OTHER_ISSUE_ID)?.labels).toEqual([
|
||||
labelA,
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("onIssueDeleted", () => {
|
||||
@@ -392,3 +416,38 @@ describe("onIssueDeleted", () => {
|
||||
expect(qc.getQueryData(issueKeys.tasks(ISSUE_ID))).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
// Regression coverage for the Project Gantt cache. The Gantt view rides its
|
||||
// own dedicated cache (server-filtered to `scheduled=true`); every WS-driven
|
||||
// path that can shift Gantt membership has to invalidate the prefix or the
|
||||
// timeline goes stale.
|
||||
describe("project gantt cache invalidation", () => {
|
||||
const PROJECT_ID = "project-1";
|
||||
let qc: QueryClient;
|
||||
|
||||
beforeEach(() => {
|
||||
qc = new QueryClient();
|
||||
qc.setQueryData<Issue[]>(
|
||||
issueKeys.projectGantt(WS_ID, PROJECT_ID),
|
||||
[baseIssue],
|
||||
);
|
||||
});
|
||||
|
||||
it("invalidates the project Gantt cache on issue:created", () => {
|
||||
onIssueCreated(qc, WS_ID, otherIssue);
|
||||
expectInvalidated(qc, issueKeys.projectGantt(WS_ID, PROJECT_ID));
|
||||
});
|
||||
|
||||
it("invalidates the project Gantt cache on issue:updated", () => {
|
||||
onIssueUpdated(qc, WS_ID, {
|
||||
id: ISSUE_ID,
|
||||
start_date: "2026-01-01T00:00:00Z",
|
||||
});
|
||||
expectInvalidated(qc, issueKeys.projectGantt(WS_ID, PROJECT_ID));
|
||||
});
|
||||
|
||||
it("invalidates the project Gantt cache on issue:deleted", () => {
|
||||
onIssueDeleted(qc, WS_ID, ISSUE_ID);
|
||||
expectInvalidated(qc, issueKeys.projectGantt(WS_ID, PROJECT_ID));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -21,6 +21,11 @@ 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) });
|
||||
@@ -52,6 +57,12 @@ 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,
|
||||
);
|
||||
@@ -103,6 +114,20 @@ export function onIssueLabelsChanged(
|
||||
qc.setQueryData<IssueLabelsResponse>(labelKeys.byIssue(wsId, issueId), (old) =>
|
||||
old ? { ...old, labels } : old,
|
||||
);
|
||||
// Patch the Project Gantt caches in-place: the Gantt view applies
|
||||
// `labelFilters` to the row data, so a stale `labels` array would silently
|
||||
// hide or surface bars after another tab/agent attached or detached a
|
||||
// label. Mutating in place (instead of invalidating) avoids a refetch of
|
||||
// the entire scheduled set on every label toggle.
|
||||
for (const [key, data] of qc.getQueriesData<Issue[]>({
|
||||
queryKey: issueKeys.projectGanttAll(wsId),
|
||||
})) {
|
||||
if (!data) continue;
|
||||
const next = data.map((issue) =>
|
||||
issue.id === issueId ? { ...issue, labels } : issue,
|
||||
);
|
||||
qc.setQueryData<Issue[]>(key, next);
|
||||
}
|
||||
qc.invalidateQueries({ queryKey: issueKeys.myAll(wsId) });
|
||||
qc.invalidateQueries({ queryKey: issueKeys.assigneeGroupsAll(wsId) });
|
||||
qc.invalidateQueries({ queryKey: issueKeys.myAssigneeGroupsAll(wsId) });
|
||||
|
||||
@@ -2,13 +2,15 @@ export type {
|
||||
OnboardingStep,
|
||||
OnboardingCompletionPath,
|
||||
QuestionnaireAnswers,
|
||||
TeamSize,
|
||||
Source,
|
||||
Role,
|
||||
UseCase,
|
||||
} from "./types";
|
||||
export {
|
||||
saveQuestionnaire,
|
||||
completeOnboarding,
|
||||
bootstrapRuntimeOnboarding,
|
||||
bootstrapNoRuntimeOnboarding,
|
||||
joinCloudWaitlist,
|
||||
} from "./store";
|
||||
export { ONBOARDING_STEP_ORDER } from "./step-order";
|
||||
|
||||
@@ -3,113 +3,145 @@ import { recommendTemplate } from "./recommend-template";
|
||||
import type { Role, UseCase } from "./types";
|
||||
|
||||
const ALL_USE_CASES: UseCase[] = [
|
||||
"coding",
|
||||
"planning",
|
||||
"writing_research",
|
||||
"explore",
|
||||
"ship_code",
|
||||
"manage_team",
|
||||
"personal_tasks",
|
||||
"plan_research",
|
||||
"write_publish",
|
||||
"automate_ops",
|
||||
"evaluate",
|
||||
"other",
|
||||
];
|
||||
|
||||
describe("recommendTemplate", () => {
|
||||
describe("identity fallbacks — role alone decides", () => {
|
||||
it.each(ALL_USE_CASES)(
|
||||
"role=other (use_case=%s) → assistant",
|
||||
const ALL_ROLES: Role[] = [
|
||||
"engineer",
|
||||
"product",
|
||||
"designer",
|
||||
"founder",
|
||||
"marketing",
|
||||
"writer",
|
||||
"research",
|
||||
"ops",
|
||||
"student",
|
||||
"other",
|
||||
];
|
||||
|
||||
describe("recommendTemplate (v2)", () => {
|
||||
describe("engineer × use_case tiebreaker", () => {
|
||||
it.each<UseCase>(["manage_team", "plan_research"])(
|
||||
"engineer × %s → planning",
|
||||
(use_case) => {
|
||||
expect(recommendTemplate({ role: "other", use_case })).toBe(
|
||||
"assistant",
|
||||
expect(recommendTemplate({ role: "engineer", use_case })).toBe(
|
||||
"planning",
|
||||
);
|
||||
},
|
||||
);
|
||||
it("engineer × write_publish → writing", () => {
|
||||
expect(
|
||||
recommendTemplate({ role: "engineer", use_case: "write_publish" }),
|
||||
).toBe("writing");
|
||||
});
|
||||
it.each<UseCase>([
|
||||
"ship_code",
|
||||
"personal_tasks",
|
||||
"automate_ops",
|
||||
"evaluate",
|
||||
"other",
|
||||
])("engineer × %s → coding", (use_case) => {
|
||||
expect(recommendTemplate({ role: "engineer", use_case })).toBe("coding");
|
||||
});
|
||||
it("engineer × null → coding", () => {
|
||||
expect(recommendTemplate({ role: "engineer", use_case: null })).toBe(
|
||||
"coding",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it.each(ALL_USE_CASES)(
|
||||
"role=founder (use_case=%s) → assistant",
|
||||
describe("product × use_case", () => {
|
||||
it("product × ship_code → coding", () => {
|
||||
expect(
|
||||
recommendTemplate({ role: "product", use_case: "ship_code" }),
|
||||
).toBe("coding");
|
||||
});
|
||||
it.each<UseCase>(["manage_team", "plan_research", "evaluate", "other"])(
|
||||
"product × %s → planning",
|
||||
(use_case) => {
|
||||
expect(recommendTemplate({ role: "founder", use_case })).toBe(
|
||||
"assistant",
|
||||
expect(recommendTemplate({ role: "product", use_case })).toBe(
|
||||
"planning",
|
||||
);
|
||||
},
|
||||
);
|
||||
it("product × null → planning", () => {
|
||||
expect(recommendTemplate({ role: "product", use_case: null })).toBe(
|
||||
"planning",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it.each(ALL_USE_CASES)(
|
||||
"role=writer (use_case=%s) → writing",
|
||||
describe("marketing × use_case", () => {
|
||||
it.each<UseCase>(["write_publish", "plan_research"])(
|
||||
"marketing × %s → writing",
|
||||
(use_case) => {
|
||||
expect(recommendTemplate({ role: "writer", use_case })).toBe(
|
||||
expect(recommendTemplate({ role: "marketing", use_case })).toBe(
|
||||
"writing",
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("developer × use_case tiebreaker", () => {
|
||||
it("developer × planning → planning", () => {
|
||||
it("marketing × manage_team → planning", () => {
|
||||
expect(
|
||||
recommendTemplate({ role: "developer", use_case: "planning" }),
|
||||
recommendTemplate({ role: "marketing", use_case: "manage_team" }),
|
||||
).toBe("planning");
|
||||
});
|
||||
|
||||
it.each<UseCase>([
|
||||
"coding",
|
||||
"writing_research",
|
||||
"explore",
|
||||
"other",
|
||||
])("developer × %s → coding", (use_case) => {
|
||||
expect(recommendTemplate({ role: "developer", use_case })).toBe(
|
||||
"coding",
|
||||
);
|
||||
});
|
||||
|
||||
it("developer × null use_case → coding (default)", () => {
|
||||
expect(
|
||||
recommendTemplate({ role: "developer", use_case: null }),
|
||||
).toBe("coding");
|
||||
});
|
||||
});
|
||||
|
||||
describe("product_lead × use_case tiebreaker", () => {
|
||||
it("product_lead × coding → coding", () => {
|
||||
expect(
|
||||
recommendTemplate({ role: "product_lead", use_case: "coding" }),
|
||||
).toBe("coding");
|
||||
describe("single-template roles", () => {
|
||||
it.each(ALL_USE_CASES)("writer × %s → writing", (use_case) => {
|
||||
expect(recommendTemplate({ role: "writer", use_case })).toBe("writing");
|
||||
});
|
||||
|
||||
it.each<UseCase>([
|
||||
"planning",
|
||||
"writing_research",
|
||||
"explore",
|
||||
"other",
|
||||
])("product_lead × %s → planning", (use_case) => {
|
||||
expect(recommendTemplate({ role: "product_lead", use_case })).toBe(
|
||||
it.each(ALL_USE_CASES)("designer × %s → assistant", (use_case) => {
|
||||
expect(recommendTemplate({ role: "designer", use_case })).toBe(
|
||||
"assistant",
|
||||
);
|
||||
});
|
||||
it.each(ALL_USE_CASES)("research × %s → planning", (use_case) => {
|
||||
expect(recommendTemplate({ role: "research", use_case })).toBe(
|
||||
"planning",
|
||||
);
|
||||
});
|
||||
|
||||
it("product_lead × null use_case → planning (default)", () => {
|
||||
expect(
|
||||
recommendTemplate({ role: "product_lead", use_case: null }),
|
||||
).toBe("planning");
|
||||
});
|
||||
it.each<Role>(["founder", "ops", "student", "other"])(
|
||||
"%s → assistant",
|
||||
(role) => {
|
||||
expect(recommendTemplate({ role, use_case: null })).toBe("assistant");
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("unanswered questionnaire", () => {
|
||||
it("null role → assistant regardless of use_case", () => {
|
||||
expect(recommendTemplate({ role: null, use_case: null })).toBe(
|
||||
"assistant",
|
||||
describe("role skipped — use_case fallback", () => {
|
||||
it("null × ship_code → coding", () => {
|
||||
expect(recommendTemplate({ role: null, use_case: "ship_code" })).toBe(
|
||||
"coding",
|
||||
);
|
||||
expect(recommendTemplate({ role: null, use_case: "coding" })).toBe(
|
||||
});
|
||||
it("null × write_publish → writing", () => {
|
||||
expect(
|
||||
recommendTemplate({ role: null, use_case: "write_publish" }),
|
||||
).toBe("writing");
|
||||
});
|
||||
it.each<UseCase>(["manage_team", "plan_research"])(
|
||||
"null × %s → planning",
|
||||
(use_case) => {
|
||||
expect(recommendTemplate({ role: null, use_case })).toBe("planning");
|
||||
},
|
||||
);
|
||||
it("both null → assistant", () => {
|
||||
expect(recommendTemplate({ role: null, use_case: null })).toBe(
|
||||
"assistant",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("exhaustive role coverage", () => {
|
||||
const roles: Role[] = [
|
||||
"developer",
|
||||
"product_lead",
|
||||
"writer",
|
||||
"founder",
|
||||
"other",
|
||||
];
|
||||
it.each(roles)("role=%s returns a valid template id", (role) => {
|
||||
it.each(ALL_ROLES)("role=%s returns a valid template id", (role) => {
|
||||
const result = recommendTemplate({ role, use_case: null });
|
||||
expect(["coding", "planning", "writing", "assistant"]).toContain(result);
|
||||
});
|
||||
|
||||
@@ -1,41 +1,69 @@
|
||||
import type { QuestionnaireAnswers } from "./types";
|
||||
import type { QuestionnaireAnswers, Role, UseCase } from "./types";
|
||||
|
||||
/**
|
||||
* Identifier for the four agent templates offered during onboarding Step 4.
|
||||
* Keep in sync with the template registry inside StepAgent in
|
||||
* Identifier for the four legacy onboarding agent templates. Keep in
|
||||
* sync with the template registry inside StepAgent in
|
||||
* `packages/views/onboarding/steps/step-agent.tsx`.
|
||||
*/
|
||||
export type AgentTemplateId = "coding" | "planning" | "writing" | "assistant";
|
||||
|
||||
/**
|
||||
* Pick a recommended agent template for a user based on their
|
||||
* questionnaire answers. Role is treated as the primary signal (who the
|
||||
* user is); use_case is only a tiebreaker for roles that legitimately
|
||||
* split between templates (developer / product_lead).
|
||||
* Pick a recommended agent template based on the v2 questionnaire
|
||||
* (role × use_case). Role is the primary signal; use_case is a
|
||||
* tiebreaker for roles that legitimately split between templates
|
||||
* (engineer / product / marketing).
|
||||
*
|
||||
* `role = other` and `role = founder` both fall back to the generic
|
||||
* Assistant: "other" means the user declined to claim a role, and
|
||||
* "founder" means they wear every hat, so a single specialized agent is
|
||||
* a poor default.
|
||||
* Fallback chain when role is skipped or null:
|
||||
* 1. Derive from use_case alone.
|
||||
* 2. Both unknown → `assistant` (the generic default).
|
||||
*
|
||||
* Pure / deterministic — safe to call on every render.
|
||||
*/
|
||||
export function recommendTemplate(
|
||||
answers: Pick<QuestionnaireAnswers, "role" | "use_case">,
|
||||
): AgentTemplateId {
|
||||
const { role, use_case } = answers;
|
||||
const role: Role | null = answers.role;
|
||||
const useCase: UseCase | null = answers.use_case;
|
||||
|
||||
if (role === "other" || role === "founder") return "assistant";
|
||||
if (role === "writer") return "writing";
|
||||
if (role === null) return fallbackFromUseCase(useCase);
|
||||
|
||||
if (role === "developer") {
|
||||
return use_case === "planning" ? "planning" : "coding";
|
||||
switch (role) {
|
||||
case "engineer":
|
||||
if (useCase === "manage_team" || useCase === "plan_research")
|
||||
return "planning";
|
||||
if (useCase === "write_publish") return "writing";
|
||||
return "coding";
|
||||
case "product":
|
||||
if (useCase === "ship_code") return "coding";
|
||||
return "planning";
|
||||
case "designer":
|
||||
return "assistant";
|
||||
case "writer":
|
||||
return "writing";
|
||||
case "marketing":
|
||||
if (useCase === "write_publish" || useCase === "plan_research")
|
||||
return "writing";
|
||||
return "planning";
|
||||
case "research":
|
||||
return "planning";
|
||||
case "founder":
|
||||
case "ops":
|
||||
case "student":
|
||||
case "other":
|
||||
return "assistant";
|
||||
}
|
||||
}
|
||||
|
||||
function fallbackFromUseCase(useCase: UseCase | null): AgentTemplateId {
|
||||
switch (useCase) {
|
||||
case "ship_code":
|
||||
return "coding";
|
||||
case "write_publish":
|
||||
return "writing";
|
||||
case "manage_team":
|
||||
case "plan_research":
|
||||
return "planning";
|
||||
default:
|
||||
return "assistant";
|
||||
}
|
||||
|
||||
if (role === "product_lead") {
|
||||
return use_case === "coding" ? "coding" : "planning";
|
||||
}
|
||||
|
||||
// Unknown / null role — user hasn't answered Q2 yet.
|
||||
return "assistant";
|
||||
}
|
||||
|
||||
@@ -15,9 +15,10 @@ import type { OnboardingStep } from "./types";
|
||||
* as progress toward completing setup.
|
||||
*/
|
||||
export const ONBOARDING_STEP_ORDER: readonly OnboardingStep[] = [
|
||||
"questionnaire",
|
||||
"source",
|
||||
"role",
|
||||
"use_case",
|
||||
"workspace",
|
||||
"runtime",
|
||||
"agent",
|
||||
"first_issue",
|
||||
"teammate",
|
||||
] as const;
|
||||
|
||||
@@ -4,14 +4,16 @@ import { setPersonProperties } from "../analytics";
|
||||
import type { OnboardingCompletionPath, QuestionnaireAnswers } from "./types";
|
||||
|
||||
/**
|
||||
* Persist Q1/Q2/Q3 answers and sync the refreshed user into the auth
|
||||
* store. Source of truth is `user.onboarding_questionnaire` (JSONB on
|
||||
* the server). No client-side cache here.
|
||||
* Persist questionnaire answers (one or more slots at a time — each
|
||||
* onboarding step PATCHes its own slot) and sync the refreshed user
|
||||
* into the auth store. Source of truth is
|
||||
* `user.onboarding_questionnaire` (JSONB on the server). No
|
||||
* client-side cache here.
|
||||
*
|
||||
* Resume-by-step is intentionally not persisted: every onboarding
|
||||
* entry starts at Welcome. The questionnaire is the only piece of
|
||||
* progress that survives a re-entry — it pre-fills Step 1 so the
|
||||
* user doesn't re-answer.
|
||||
* entry starts at Welcome. Answered slots are pre-filled on
|
||||
* re-entry; skipped slots are treated as fresh (the user can answer
|
||||
* this time).
|
||||
*/
|
||||
export async function saveQuestionnaire(
|
||||
answers: Partial<QuestionnaireAnswers>,
|
||||
@@ -19,12 +21,11 @@ export async function saveQuestionnaire(
|
||||
const user = await api.patchOnboarding({ questionnaire: answers });
|
||||
useAuthStore.getState().setUser(user);
|
||||
// Mirror the three cohort signals into person properties so every
|
||||
// PostHog event on this user can be broken down by role / use_case /
|
||||
// team_size without re-joining the DB. Matches the $set block the
|
||||
// server writes alongside `onboarding_questionnaire_submitted`.
|
||||
if (answers.team_size || answers.role || answers.use_case) {
|
||||
// PostHog event on this user can be broken down by source / role /
|
||||
// use_case without re-joining the DB.
|
||||
if (answers.source || answers.role || answers.use_case) {
|
||||
setPersonProperties({
|
||||
...(answers.team_size ? { team_size: answers.team_size } : {}),
|
||||
...(answers.source ? { source: answers.source } : {}),
|
||||
...(answers.role ? { role: answers.role } : {}),
|
||||
...(answers.use_case ? { use_case: answers.use_case } : {}),
|
||||
});
|
||||
@@ -52,6 +53,37 @@ export async function completeOnboarding(
|
||||
await useAuthStore.getState().refreshMe();
|
||||
}
|
||||
|
||||
/**
|
||||
* Runtime-connected onboarding path. The server creates or reuses the
|
||||
* default Multica Helper agent and the single onboarding issue, then
|
||||
* marks onboarding complete.
|
||||
*/
|
||||
export async function bootstrapRuntimeOnboarding(
|
||||
workspaceId: string,
|
||||
runtimeId: string,
|
||||
): Promise<{ workspace_id: string; agent_id: string; issue_id: string }> {
|
||||
const result = await api.bootstrapOnboardingRuntime({
|
||||
workspace_id: workspaceId,
|
||||
runtime_id: runtimeId,
|
||||
});
|
||||
await useAuthStore.getState().refreshMe();
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Runtime-skipped onboarding path. The server creates or reuses one
|
||||
* install-runtime onboarding issue and marks onboarding complete.
|
||||
*/
|
||||
export async function bootstrapNoRuntimeOnboarding(
|
||||
workspaceId: string,
|
||||
): Promise<{ workspace_id: string; issue_id: string }> {
|
||||
const result = await api.bootstrapOnboardingNoRuntime({
|
||||
workspace_id: workspaceId,
|
||||
});
|
||||
await useAuthStore.getState().refreshMe();
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Records interest in cloud runtimes. Pure side effect — does NOT
|
||||
* complete onboarding; the user still has to pick a real Step 3
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
export type OnboardingStep =
|
||||
| "welcome"
|
||||
| "questionnaire"
|
||||
| "source"
|
||||
| "role"
|
||||
| "use_case"
|
||||
| "workspace"
|
||||
| "runtime"
|
||||
| "teammate"
|
||||
| "agent"
|
||||
| "first_issue";
|
||||
|
||||
@@ -13,33 +16,64 @@ export type OnboardingStep =
|
||||
* `OnboardingPath*` constants in `server/internal/analytics/events.go`.
|
||||
*/
|
||||
export type OnboardingCompletionPath =
|
||||
| "full" // Reached Step 5 (first_issue) with a runtime connected
|
||||
| "runtime_skipped" // Step 3 skipped (no runtime) but still completed
|
||||
| "cloud_waitlist" // Submitted the cloud waitlist form and skipped Step 3
|
||||
| "skip_existing" // "I've done this before" from Welcome
|
||||
| "invite_accept"; // Accepted at least one invite from /invitations
|
||||
| "full"
|
||||
| "runtime_skipped"
|
||||
| "cloud_waitlist"
|
||||
| "skip_existing"
|
||||
| "invite_accept";
|
||||
|
||||
export type TeamSize = "solo" | "team" | "other";
|
||||
export type Source =
|
||||
| "friends_colleagues"
|
||||
| "search"
|
||||
| "social_x"
|
||||
| "social_linkedin"
|
||||
| "social_youtube"
|
||||
| "social_other"
|
||||
| "blog_newsletter"
|
||||
| "ai_assistant"
|
||||
| "from_work"
|
||||
| "event_conference"
|
||||
| "dont_remember"
|
||||
| "other";
|
||||
|
||||
export type Role =
|
||||
| "developer"
|
||||
| "product_lead"
|
||||
| "writer"
|
||||
| "engineer"
|
||||
| "product"
|
||||
| "designer"
|
||||
| "founder"
|
||||
| "marketing"
|
||||
| "writer"
|
||||
| "research"
|
||||
| "ops"
|
||||
| "student"
|
||||
| "other";
|
||||
|
||||
export type UseCase =
|
||||
| "coding"
|
||||
| "planning"
|
||||
| "writing_research"
|
||||
| "explore"
|
||||
| "ship_code"
|
||||
| "manage_team"
|
||||
| "personal_tasks"
|
||||
| "plan_research"
|
||||
| "write_publish"
|
||||
| "automate_ops"
|
||||
| "evaluate"
|
||||
| "other";
|
||||
|
||||
/**
|
||||
* v2 questionnaire shape. `*_skipped: true` distinguishes an explicit
|
||||
* Skip click from a slot the user never reached. Both states are
|
||||
* "unknown" for recommendation purposes; the skip marker exists for
|
||||
* analytics and so future re-prompts can avoid nagging users who
|
||||
* already declined.
|
||||
*/
|
||||
export interface QuestionnaireAnswers {
|
||||
team_size: TeamSize | null;
|
||||
team_size_other: string | null;
|
||||
source: Source | null;
|
||||
source_other: string | null;
|
||||
source_skipped: boolean;
|
||||
role: Role | null;
|
||||
role_other: string | null;
|
||||
role_skipped: boolean;
|
||||
use_case: UseCase | null;
|
||||
use_case_other: string | null;
|
||||
use_case_skipped: boolean;
|
||||
version: 2;
|
||||
}
|
||||
|
||||
@@ -94,7 +94,6 @@ function makeRuntime(ownerId: string | null): RuntimeDevice {
|
||||
metadata: {},
|
||||
owner_id: ownerId,
|
||||
visibility: "private",
|
||||
timezone: "UTC",
|
||||
last_seen_at: null,
|
||||
created_at: "2026-04-01T00:00:00Z",
|
||||
updated_at: "2026-04-01T00:00:00Z",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
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,
|
||||
|
||||
96
packages/core/projects/stores/view-store.test.ts
Normal file
96
packages/core/projects/stores/view-store.test.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
// @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();
|
||||
});
|
||||
});
|
||||
33
packages/core/projects/stores/view-store.ts
Normal file
33
packages/core/projects/stores/view-store.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
"use client";
|
||||
|
||||
import { create } from "zustand";
|
||||
import { createJSONStorage, persist } from "zustand/middleware";
|
||||
import { createWorkspaceAwareStorage, registerForWorkspaceRehydration } from "../../platform/workspace-storage";
|
||||
import { defaultStorage } from "../../platform/storage";
|
||||
|
||||
export type ProjectViewMode = "compact" | "comfortable";
|
||||
|
||||
export interface ProjectViewState {
|
||||
viewMode: ProjectViewMode;
|
||||
setViewMode: (mode: ProjectViewMode) => void;
|
||||
}
|
||||
|
||||
export const useProjectViewStore = create<ProjectViewState>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
viewMode: "compact",
|
||||
setViewMode: (mode) => set({ viewMode: mode }),
|
||||
}),
|
||||
{
|
||||
name: "multica_projects_view",
|
||||
storage: createJSONStorage(() => createWorkspaceAwareStorage(defaultStorage)),
|
||||
partialize: (state) => ({ viewMode: state.viewMode }),
|
||||
merge: (persisted, current) => {
|
||||
if (!persisted) return { ...current, viewMode: "compact" };
|
||||
return { ...current, ...(persisted as Partial<ProjectViewState>) };
|
||||
},
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
registerForWorkspaceRehydration(() => useProjectViewStore.persist.rehydrate());
|
||||
@@ -1,8 +1,18 @@
|
||||
import { QueryClient } from "@tanstack/react-query";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { chatKeys } from "../chat/queries";
|
||||
import type { ChatDonePayload, ChatMessage, ChatPendingTask } from "../types";
|
||||
import { applyChatDoneToCache } from "./use-realtime-sync";
|
||||
import { issueKeys } from "../issues/queries";
|
||||
import { workspaceKeys } from "../workspace/queries";
|
||||
import type {
|
||||
ChatDonePayload,
|
||||
ChatMessage,
|
||||
ChatPendingTask,
|
||||
Workspace,
|
||||
} from "../types";
|
||||
import {
|
||||
applyChatDoneToCache,
|
||||
applyWorkspaceUpdatedToCache,
|
||||
} from "./use-realtime-sync";
|
||||
|
||||
const sessionId = "session-1";
|
||||
const taskId = "task-1";
|
||||
@@ -115,3 +125,78 @@ describe("applyChatDoneToCache", () => {
|
||||
expect(qc.getQueryData<ChatPendingTask>(pendingKey)).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
describe("applyWorkspaceUpdatedToCache", () => {
|
||||
const wsId = "ws-1";
|
||||
|
||||
function workspace(overrides: Partial<Workspace> = {}): Workspace {
|
||||
return {
|
||||
id: wsId,
|
||||
name: "Test",
|
||||
slug: "test",
|
||||
description: null,
|
||||
context: null,
|
||||
settings: {},
|
||||
repos: [],
|
||||
issue_prefix: "TES",
|
||||
created_at: "2026-05-18T00:00:00Z",
|
||||
updated_at: "2026-05-18T00:00:00Z",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
it("invalidates issue cache when issue_prefix changes", () => {
|
||||
const qc = createQueryClient();
|
||||
qc.setQueryData<Workspace[]>(workspaceKeys.list(), [
|
||||
workspace({ issue_prefix: "TES" }),
|
||||
]);
|
||||
const invalidate = vi.spyOn(qc, "invalidateQueries");
|
||||
|
||||
applyWorkspaceUpdatedToCache(qc, {
|
||||
workspace: workspace({ issue_prefix: "NEW" }),
|
||||
});
|
||||
|
||||
expect(invalidate).toHaveBeenCalledWith({
|
||||
queryKey: issueKeys.all(wsId),
|
||||
});
|
||||
expect(invalidate).toHaveBeenCalledWith({
|
||||
queryKey: workspaceKeys.list(),
|
||||
});
|
||||
});
|
||||
|
||||
it("does not invalidate issue cache when only non-prefix fields change", () => {
|
||||
const qc = createQueryClient();
|
||||
qc.setQueryData<Workspace[]>(workspaceKeys.list(), [
|
||||
workspace({ issue_prefix: "TES", name: "Old name" }),
|
||||
]);
|
||||
const invalidate = vi.spyOn(qc, "invalidateQueries");
|
||||
|
||||
applyWorkspaceUpdatedToCache(qc, {
|
||||
workspace: workspace({ issue_prefix: "TES", name: "New name" }),
|
||||
});
|
||||
|
||||
expect(invalidate).not.toHaveBeenCalledWith({
|
||||
queryKey: issueKeys.all(wsId),
|
||||
});
|
||||
expect(invalidate).toHaveBeenCalledWith({
|
||||
queryKey: workspaceKeys.list(),
|
||||
});
|
||||
});
|
||||
|
||||
it("invalidates issue cache when the workspace isn't in the cached list yet", () => {
|
||||
// Conservative: a workspace appearing for the first time may correspond
|
||||
// to issue queries that were primed without ever seeing the (possibly
|
||||
// changing) prefix. Erring on the side of refresh keeps identifiers
|
||||
// accurate at minimal cost.
|
||||
const qc = createQueryClient();
|
||||
const invalidate = vi.spyOn(qc, "invalidateQueries");
|
||||
|
||||
applyWorkspaceUpdatedToCache(qc, {
|
||||
workspace: workspace({ issue_prefix: "NEW" }),
|
||||
});
|
||||
|
||||
expect(invalidate).toHaveBeenCalledWith({
|
||||
queryKey: issueKeys.all(wsId),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -31,12 +31,14 @@ import { onInboxNew, onInboxInvalidate, onInboxIssueStatusChanged, onInboxIssueD
|
||||
import { inboxKeys } from "../inbox/queries";
|
||||
import { notificationPreferenceOptions } from "../notification-preferences/queries";
|
||||
import { workspaceKeys, workspaceListOptions } from "../workspace/queries";
|
||||
import type { Workspace } from "../types/workspace";
|
||||
import { chatKeys } from "../chat/queries";
|
||||
import { useChatStore } from "../chat";
|
||||
import { resolvePostAuthDestination, useHasOnboarded } from "../paths";
|
||||
import type {
|
||||
MemberAddedPayload,
|
||||
WorkspaceDeletedPayload,
|
||||
WorkspaceUpdatedPayload,
|
||||
MemberRemovedPayload,
|
||||
IssueUpdatedPayload,
|
||||
IssueCreatedPayload,
|
||||
@@ -107,6 +109,36 @@ export function applyChatDoneToCache(
|
||||
qc.invalidateQueries({ queryKey: chatKeys.pendingTask(sessionId) });
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply a workspace:updated event to the cache. Always refreshes the
|
||||
* workspace list. If the incoming `issue_prefix` differs from what's
|
||||
* currently cached, also invalidates issueKeys.all for that workspace,
|
||||
* since every issue's rendered identifier (`MUL-123`) is recomputed from
|
||||
* the workspace prefix at read time. Without this, the UI keeps showing
|
||||
* the old `OLD-N` keys until the next hard refresh.
|
||||
*
|
||||
* If the workspace isn't in the cached list (first observation), we
|
||||
* conservatively invalidate — the prefix is effectively "new" relative to
|
||||
* what's cached, so any issues already loaded under the old prefix would
|
||||
* be stale anyway.
|
||||
*/
|
||||
export function applyWorkspaceUpdatedToCache(
|
||||
qc: QueryClient,
|
||||
payload: WorkspaceUpdatedPayload,
|
||||
): void {
|
||||
const next = payload.workspace;
|
||||
if (next?.id) {
|
||||
const cached =
|
||||
qc
|
||||
.getQueryData<Workspace[]>(workspaceKeys.list())
|
||||
?.find((w) => w.id === next.id) ?? null;
|
||||
if (!cached || cached.issue_prefix !== next.issue_prefix) {
|
||||
qc.invalidateQueries({ queryKey: issueKeys.all(next.id) });
|
||||
}
|
||||
}
|
||||
qc.invalidateQueries({ queryKey: workspaceKeys.list() });
|
||||
}
|
||||
|
||||
/**
|
||||
* Invalidates all workspace-scoped queries. Used after reconnect and when a
|
||||
* new WSClient instance is detected (workspace switch) to recover events
|
||||
@@ -191,6 +223,11 @@ export function useRealtimeSync(
|
||||
const wsId = getCurrentWsId();
|
||||
if (wsId) qc.invalidateQueries({ queryKey: workspaceKeys.members(wsId) });
|
||||
},
|
||||
// workspace:updated is handled by the specific handler below
|
||||
// (compares prefixes to decide whether to also invalidate issues).
|
||||
// This generic fallback still fires for workspace:deleted (paired
|
||||
// with the specific navigation handler) and any future workspace:*
|
||||
// events without dedicated handlers.
|
||||
workspace: () => {
|
||||
qc.invalidateQueries({ queryKey: workspaceKeys.list() });
|
||||
},
|
||||
@@ -303,6 +340,7 @@ export function useRealtimeSync(
|
||||
|
||||
// Event types handled by specific handlers below -- skip generic refresh
|
||||
const specificEvents = new Set([
|
||||
"workspace:updated",
|
||||
"issue:updated", "issue:created", "issue:deleted", "issue_labels:changed", "inbox:new",
|
||||
"comment:created", "comment:updated", "comment:deleted",
|
||||
"comment:resolved", "comment:unresolved",
|
||||
@@ -540,6 +578,10 @@ export function useRealtimeSync(
|
||||
}
|
||||
};
|
||||
|
||||
const unsubWsUpdated = ws.on("workspace:updated", (p) => {
|
||||
applyWorkspaceUpdatedToCache(qc, p as WorkspaceUpdatedPayload);
|
||||
});
|
||||
|
||||
const unsubWsDeleted = ws.on("workspace:deleted", (p) => {
|
||||
const { workspace_id } = p as WorkspaceDeletedPayload;
|
||||
// Event payload has UUID; look up slug from cached workspace list
|
||||
@@ -849,6 +891,7 @@ export function useRealtimeSync(
|
||||
unsubIssueReactionRemoved();
|
||||
unsubSubscriberAdded();
|
||||
unsubSubscriberRemoved();
|
||||
unsubWsUpdated();
|
||||
unsubWsDeleted();
|
||||
unsubMemberRemoved();
|
||||
unsubMemberAdded();
|
||||
|
||||
90
packages/core/runtimes/cloud-runtime.ts
Normal file
90
packages/core/runtimes/cloud-runtime.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
import { queryOptions, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { api } from "../api";
|
||||
|
||||
export interface CloudRuntimeNode {
|
||||
id: string;
|
||||
owner_id: string;
|
||||
instance_id: string;
|
||||
region: string;
|
||||
instance_type: string;
|
||||
image_id: string;
|
||||
subnet_id: string;
|
||||
name: string;
|
||||
status: string;
|
||||
tags: Record<string, string>;
|
||||
metadata: Record<string, unknown>;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface ListCloudRuntimeNodesParams {
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}
|
||||
|
||||
export interface CreateCloudRuntimeNodeRequest {
|
||||
instance_type: string;
|
||||
name?: string;
|
||||
region?: string;
|
||||
image_id?: string;
|
||||
subnet_id?: string;
|
||||
key_name?: string;
|
||||
iam_instance_profile?: string;
|
||||
disk_size_gb?: number;
|
||||
tags?: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface CreateCloudRuntimeNodeOptions {
|
||||
userPAT?: string;
|
||||
}
|
||||
|
||||
export const cloudRuntimeKeys = {
|
||||
all: (wsId: string) => ["cloud-runtime", wsId] as const,
|
||||
nodes: (wsId: string) => [...cloudRuntimeKeys.all(wsId), "nodes"] as const,
|
||||
};
|
||||
|
||||
const PENDING_NODE_STATUSES = new Set([
|
||||
"launching",
|
||||
"pending",
|
||||
"starting",
|
||||
"stopping",
|
||||
"rebooting",
|
||||
"terminating",
|
||||
]);
|
||||
|
||||
export function isCloudRuntimeNodePending(status: string): boolean {
|
||||
return PENDING_NODE_STATUSES.has(status.toLowerCase());
|
||||
}
|
||||
|
||||
export function cloudRuntimeNodeListOptions(
|
||||
wsId: string,
|
||||
params?: ListCloudRuntimeNodesParams,
|
||||
) {
|
||||
const limit = params?.limit ?? 20;
|
||||
const offset = params?.offset ?? 0;
|
||||
return queryOptions({
|
||||
queryKey: [...cloudRuntimeKeys.nodes(wsId), { limit, offset }] as const,
|
||||
queryFn: () => api.listCloudRuntimeNodes({ limit, offset }),
|
||||
refetchInterval: (query) =>
|
||||
query.state.data?.some((node) => isCloudRuntimeNodePending(node.status))
|
||||
? 5000
|
||||
: false,
|
||||
staleTime: 15 * 1000,
|
||||
});
|
||||
}
|
||||
|
||||
export function useCreateCloudRuntimeNode(wsId: string) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({
|
||||
data,
|
||||
userPAT,
|
||||
}: {
|
||||
data: CreateCloudRuntimeNodeRequest;
|
||||
userPAT?: string;
|
||||
}) => api.createCloudRuntimeNode(data, { userPAT }),
|
||||
onSettled: () => {
|
||||
qc.invalidateQueries({ queryKey: cloudRuntimeKeys.all(wsId) });
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -18,7 +18,6 @@ function makeRuntime(overrides: Partial<AgentRuntime> = {}): AgentRuntime {
|
||||
metadata: {},
|
||||
owner_id: null,
|
||||
visibility: "private",
|
||||
timezone: "UTC",
|
||||
last_seen_at: new Date(FIXED_NOW - 10_000).toISOString(),
|
||||
created_at: "2026-04-01T00:00:00Z",
|
||||
updated_at: "2026-04-01T00:00:00Z",
|
||||
|
||||
@@ -8,3 +8,4 @@ export * from "./derive-health";
|
||||
export * from "./use-runtime-health";
|
||||
export * from "./cli-version";
|
||||
export * from "./custom-pricing-store";
|
||||
export * from "./cloud-runtime";
|
||||
|
||||
@@ -12,12 +12,8 @@ export function useDeleteRuntime(wsId: string) {
|
||||
});
|
||||
}
|
||||
|
||||
// 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.
|
||||
// useUpdateRuntime patches editable fields on a runtime (visibility).
|
||||
// Invalidates the runtime list so the picker disabled-state recomputes.
|
||||
export function useUpdateRuntime(wsId: string) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
@@ -26,23 +22,10 @@ export function useUpdateRuntime(wsId: string) {
|
||||
patch,
|
||||
}: {
|
||||
runtimeId: string;
|
||||
patch: { timezone?: string; visibility?: "private" | "public" };
|
||||
patch: { visibility?: "private" | "public" };
|
||||
}) => api.updateRuntime(runtimeId, patch),
|
||||
onSettled: (_data, _err, vars) => {
|
||||
onSettled: () => {
|
||||
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],
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -5,43 +5,45 @@ 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) =>
|
||||
["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,
|
||||
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,
|
||||
latestVersion: () => ["runtimes", "latestVersion"] as const,
|
||||
};
|
||||
|
||||
// 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) {
|
||||
// `tz` is the viewer's IANA name — all reports follow the viewer's tz.
|
||||
export function runtimeUsageOptions(
|
||||
runtimeId: string,
|
||||
days: number,
|
||||
tz: string,
|
||||
) {
|
||||
return queryOptions({
|
||||
queryKey: runtimeKeys.usage(runtimeId, days),
|
||||
queryFn: () => api.getRuntimeUsage(runtimeId, { days }),
|
||||
queryKey: runtimeKeys.usage(runtimeId, days, tz),
|
||||
queryFn: () => api.getRuntimeUsage(runtimeId, { days, tz }),
|
||||
staleTime: 60 * 1000,
|
||||
});
|
||||
}
|
||||
|
||||
// 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) {
|
||||
export function runtimeUsageByAgentOptions(
|
||||
runtimeId: string,
|
||||
days: number,
|
||||
tz: string,
|
||||
) {
|
||||
return queryOptions({
|
||||
queryKey: runtimeKeys.usageByAgent(runtimeId, days),
|
||||
queryFn: () => api.getRuntimeUsageByAgent(runtimeId, { days }),
|
||||
queryKey: runtimeKeys.usageByAgent(runtimeId, days, tz),
|
||||
queryFn: () => api.getRuntimeUsageByAgent(runtimeId, { days, tz }),
|
||||
staleTime: 60 * 1000,
|
||||
});
|
||||
}
|
||||
|
||||
// Hourly (0..23) token totals for one runtime — drives the "By hour" tab.
|
||||
export function runtimeUsageByHourOptions(runtimeId: string, days: number) {
|
||||
export function runtimeUsageByHourOptions(runtimeId: string, days: number, tz: string) {
|
||||
return queryOptions({
|
||||
queryKey: runtimeKeys.usageByHour(runtimeId, days),
|
||||
queryFn: () => api.getRuntimeUsageByHour(runtimeId, { days }),
|
||||
queryKey: runtimeKeys.usageByHour(runtimeId, days, tz),
|
||||
queryFn: () => api.getRuntimeUsageByHour(runtimeId, { days, tz }),
|
||||
staleTime: 60 * 1000,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -26,7 +26,6 @@ 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;
|
||||
@@ -40,6 +39,7 @@ export type AgentRuntime = RuntimeDevice;
|
||||
export type TaskFailureReason =
|
||||
| "agent_error"
|
||||
| "timeout"
|
||||
| "codex_semantic_inactivity"
|
||||
| "runtime_offline"
|
||||
| "runtime_recovery"
|
||||
| "manual";
|
||||
@@ -130,6 +130,17 @@ 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;
|
||||
@@ -163,6 +174,8 @@ 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;
|
||||
@@ -251,6 +264,15 @@ 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
|
||||
@@ -431,6 +453,34 @@ 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 =
|
||||
|
||||
@@ -45,7 +45,23 @@ export interface ListIssuesParams {
|
||||
assignee_ids?: string[];
|
||||
creator_id?: string;
|
||||
project_id?: string;
|
||||
/**
|
||||
* Widen the assignee filter to issues where the user is the *indirect*
|
||||
* assignee — assignee is one of the user's owned agents, or a squad that
|
||||
* involves the user (human member / leader-via-owned-agent / agent member
|
||||
* owned by the user). Direct member assignment is intentionally excluded:
|
||||
* `involves_user_id` and `assignee_id=<user>` (tab "Assigned to me") produce
|
||||
* disjoint result sets by construction.
|
||||
*/
|
||||
involves_user_id?: string;
|
||||
open_only?: boolean;
|
||||
/**
|
||||
* Restrict the result to issues with at least one of `start_date` /
|
||||
* `due_date` set. Used by the Project Gantt view so it doesn't have to
|
||||
* page through every issue on the project just to discard the unscheduled
|
||||
* majority on the client.
|
||||
*/
|
||||
scheduled?: boolean;
|
||||
}
|
||||
|
||||
export interface IssueActorRef {
|
||||
@@ -65,6 +81,8 @@ export interface ListGroupedIssuesParams {
|
||||
assignee_ids?: string[];
|
||||
creator_id?: string;
|
||||
project_id?: string;
|
||||
/** See `ListIssuesParams.involves_user_id` — same semantics. */
|
||||
involves_user_id?: string;
|
||||
assignee_filters?: IssueActorRef[];
|
||||
include_no_assignee?: boolean;
|
||||
creator_filters?: IssueActorRef[];
|
||||
@@ -135,6 +153,10 @@ 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 {
|
||||
|
||||
@@ -2,6 +2,12 @@ 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
|
||||
@@ -22,6 +28,8 @@ 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;
|
||||
@@ -75,6 +83,10 @@ 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;
|
||||
@@ -83,6 +95,10 @@ 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;
|
||||
|
||||
@@ -14,11 +14,18 @@ export type GitHubMergeableState = string;
|
||||
export interface GitHubInstallation {
|
||||
id: string;
|
||||
workspace_id: string;
|
||||
installation_id: number;
|
||||
/** 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;
|
||||
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 {
|
||||
@@ -57,6 +64,11 @@ 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 {
|
||||
|
||||
@@ -36,6 +36,8 @@ export type {
|
||||
RuntimeUpdate,
|
||||
RuntimeUpdateStatus,
|
||||
RuntimeModel,
|
||||
RuntimeModelThinking,
|
||||
RuntimeModelThinkingLevel,
|
||||
RuntimeModelListRequest,
|
||||
RuntimeModelListStatus,
|
||||
RuntimeModelsResult,
|
||||
@@ -90,6 +92,7 @@ export type {
|
||||
Autopilot,
|
||||
AutopilotStatus,
|
||||
AutopilotExecutionMode,
|
||||
AutopilotAssigneeType,
|
||||
AutopilotTrigger,
|
||||
AutopilotTriggerKind,
|
||||
AutopilotRun,
|
||||
|
||||
@@ -39,18 +39,24 @@ export interface User {
|
||||
*/
|
||||
onboarding_questionnaire: Record<string, unknown>;
|
||||
/**
|
||||
* 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.
|
||||
* 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.
|
||||
*/
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -60,6 +60,20 @@ export interface MarkdownProps {
|
||||
* When provided, enables file card preprocessing and rendering.
|
||||
*/
|
||||
cdnDomain?: string
|
||||
/**
|
||||
* Optional override for the image renderer. When provided, replaces the
|
||||
* default `<img>` with constrained sizing. The views-package wrapper uses
|
||||
* this to inject the unified `<Attachment>` component so chat messages get
|
||||
* the same hover toolbar / lightbox / preview-modal treatment as comments.
|
||||
*/
|
||||
renderImage?: (props: { src: string; alt: string }) => React.ReactNode
|
||||
/**
|
||||
* Optional override for the file-card renderer. When provided, replaces
|
||||
* the simplified card chrome (filename + download button) with whatever
|
||||
* the caller supplies. Used the same way as `renderImage` to bridge into
|
||||
* the views-package `<Attachment>` component.
|
||||
*/
|
||||
renderFileCard?: (props: { href: string; filename: string }) => React.ReactNode
|
||||
}
|
||||
|
||||
// Sanitization schema — extends GitHub defaults to allow code highlighting classes
|
||||
@@ -113,6 +127,8 @@ function createComponents(
|
||||
onUrlClick?: (url: string) => void,
|
||||
onFileClick?: (path: string) => void,
|
||||
renderMention?: (props: { type: string; id: string }) => React.ReactNode,
|
||||
renderImage?: (props: { src: string; alt: string }) => React.ReactNode,
|
||||
renderFileCard?: (props: { href: string; filename: string }) => React.ReactNode,
|
||||
): Partial<Components> {
|
||||
const baseComponents: Partial<Components> = {
|
||||
// FileCard: intercept <div data-type="fileCard"> from preprocessFileCards
|
||||
@@ -122,6 +138,9 @@ function createComponents(
|
||||
const rawHref = (node?.properties?.dataHref as string) || ''
|
||||
const href = isAllowedFileCardHref(rawHref) ? rawHref : ''
|
||||
const filename = (node?.properties?.dataFilename as string) || ''
|
||||
if (renderFileCard) {
|
||||
return <>{renderFileCard({ href, filename })}</>
|
||||
}
|
||||
return (
|
||||
<div className="my-1 flex items-center gap-2 rounded-md border border-border bg-muted/50 px-2.5 py-1 transition-colors hover:bg-muted">
|
||||
<FileText className="size-4 shrink-0 text-muted-foreground" />
|
||||
@@ -143,14 +162,19 @@ function createComponents(
|
||||
return <div {...props}>{children}</div>
|
||||
},
|
||||
// Images: render uploaded images with constrained sizing
|
||||
img: ({ src, alt }) => (
|
||||
<img
|
||||
src={src}
|
||||
alt={alt ?? ""}
|
||||
className="max-w-full h-auto rounded-md my-2"
|
||||
loading="lazy"
|
||||
/>
|
||||
),
|
||||
img: ({ src, alt }) => {
|
||||
if (renderImage) {
|
||||
return <>{renderImage({ src: typeof src === 'string' ? src : '', alt: alt ?? '' })}</>
|
||||
}
|
||||
return (
|
||||
<img
|
||||
src={src}
|
||||
alt={alt ?? ""}
|
||||
className="max-w-full h-auto rounded-md my-2"
|
||||
loading="lazy"
|
||||
/>
|
||||
)
|
||||
},
|
||||
// Links: Make clickable with callbacks, or render as mention
|
||||
a: ({ href, children }) => {
|
||||
// Mention links: mention://member/id, mention://agent/id, mention://issue/id, mention://all/all
|
||||
@@ -384,11 +408,13 @@ export function Markdown({
|
||||
onUrlClick,
|
||||
onFileClick,
|
||||
renderMention,
|
||||
renderImage,
|
||||
renderFileCard,
|
||||
cdnDomain
|
||||
}: MarkdownProps): React.JSX.Element {
|
||||
const components = React.useMemo(
|
||||
() => createComponents(mode, onUrlClick, onFileClick, renderMention),
|
||||
[mode, onUrlClick, onFileClick, renderMention]
|
||||
() => createComponents(mode, onUrlClick, onFileClick, renderMention, renderImage, renderFileCard),
|
||||
[mode, onUrlClick, onFileClick, renderMention, renderImage, renderFileCard]
|
||||
)
|
||||
|
||||
// Preprocess: convert mention shortcodes, raw URLs, and file cards to renderable content
|
||||
|
||||
@@ -43,6 +43,7 @@ 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 {
|
||||
@@ -130,6 +131,14 @@ 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}
|
||||
|
||||
@@ -101,11 +101,44 @@ 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: workspaceKeys.agents(wsId) });
|
||||
qc.invalidateQueries({ queryKey });
|
||||
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;
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user