mirror of
https://github.com/multica-ai/multica.git
synced 2026-06-21 14:44:30 +02:00
Compare commits
69 Commits
agent/lamb
...
pr-3009
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cabdc3c2cf | ||
|
|
710406d2ab | ||
|
|
cb90249eac | ||
|
|
af13d7ad3a | ||
|
|
cbd42dfcc4 | ||
|
|
adec90c621 | ||
|
|
ae530ef057 | ||
|
|
ab0228c2a1 | ||
|
|
e288eff2c5 | ||
|
|
29c2a5d18f | ||
|
|
81e8aa5812 | ||
|
|
0c767c0052 | ||
|
|
66c0464140 | ||
|
|
9a5d8a52f3 | ||
|
|
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 |
@@ -161,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
|
||||
|
||||
@@ -285,19 +285,11 @@ If you need full isolation between organizations or accounts — separate tokens
|
||||
|
||||
```bash
|
||||
multica workspace list
|
||||
multica workspace list --full-id
|
||||
multica workspace list --output json
|
||||
```
|
||||
|
||||
The current default workspace is marked with `*`.
|
||||
|
||||
### Show Current Workspace
|
||||
|
||||
```bash
|
||||
multica workspace current
|
||||
multica workspace current --output json
|
||||
```
|
||||
|
||||
Prints the workspace that commands without `--workspace-id` and `MULTICA_WORKSPACE_ID` would target.
|
||||
The current default workspace is marked with `*`. Table output shows short UUID prefixes — pass `--full-id` when you need the canonical UUIDs.
|
||||
|
||||
### Switch Default Workspace
|
||||
|
||||
@@ -315,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
|
||||
@@ -334,7 +328,14 @@ multica issue list --full-id
|
||||
multica issue list --limit 20 --output json
|
||||
```
|
||||
|
||||
Table output shows a routable issue `KEY` such as `MUL-123`; copy that key into follow-up commands like `issue get`, `issue comment list`, `issue status`, or `--parent`. Add `--full-id` when you need canonical UUIDs. Available filters: `--status`, `--priority`, `--assignee` / `--assignee-id`, `--project`, `--limit`. Use `--assignee-id <uuid>` for unambiguous filtering when names overlap.
|
||||
Table output shows a routable issue `KEY` such as `MUL-123`; copy that key into follow-up commands like `issue get`, `issue comment list`, `issue status`, or `--parent`. Add `--full-id` when you need canonical UUIDs. Available filters: `--status`, `--priority`, `--assignee` / `--assignee-id`, `--project`, `--metadata`, `--limit`. Use `--assignee-id <uuid>` for unambiguous filtering when names overlap.
|
||||
|
||||
Use `--metadata key=value` (repeatable; combined with AND) to filter by per-issue metadata. The value is JSON-parsed: `true`/`false` become bool, numbers become numbers, anything else is a string. Wrap as `'"42"'` to force a string when the value would otherwise sniff as a number:
|
||||
|
||||
```bash
|
||||
multica issue list --metadata pipeline_status=waiting_review
|
||||
multica issue list --metadata pr_number=482 --metadata is_blocked=true
|
||||
```
|
||||
|
||||
### Get Issue
|
||||
|
||||
@@ -350,7 +351,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
|
||||
|
||||
@@ -379,9 +380,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"
|
||||
|
||||
@@ -392,6 +428,56 @@ multica issue comment add <issue-id> --parent <comment-id> --content "Thanks!"
|
||||
multica issue comment delete <comment-id>
|
||||
```
|
||||
|
||||
**`--before` / `--before-id` semantics depend on the paging mode**, by
|
||||
design — same flag, different scope:
|
||||
|
||||
| Mode | What the cursor walks | stderr label |
|
||||
| --- | --- | --- |
|
||||
| `--recent N` | Older *threads* (last_activity_at, root_id) | `Next thread cursor` |
|
||||
| `--thread <id> --tail N` | Older *replies* inside that thread (created_at, id) | `Next reply cursor` |
|
||||
|
||||
Outside those two modes (`--thread` without `--tail`, or no `--thread`
|
||||
and no `--recent`) the cursor flags are rejected so they cannot silently
|
||||
no-op. The server emits the cursor headers (`X-Multica-Next-Before` /
|
||||
`X-Multica-Next-Before-Id`) only when an older page actually exists —
|
||||
exact-boundary pages (e.g. `--tail 3` on a thread with exactly 3
|
||||
replies) intentionally return no cursor so callers stop paginating.
|
||||
|
||||
When `--since` is combined with `--recent` or `--thread --tail`, the
|
||||
server additionally suppresses the cursor once the cursor target itself
|
||||
is older than `since`. Older pages walk strictly older rows, so they
|
||||
cannot satisfy `> since` either — emitting a cursor there would just
|
||||
hand back root-only pages until the caller reaches the start of the
|
||||
thread / issue. Incremental polling stops at the first page whose
|
||||
cursor target falls before the watermark.
|
||||
|
||||
### Metadata
|
||||
|
||||
Per-issue metadata is a small KV map agents use to track pipeline state (PR number, pipeline status, waiting_on, ...). Keys match `^[a-zA-Z_][a-zA-Z0-9_.-]{0,63}$`, values are primitives (string / number / bool), max 50 keys per issue, blob capped at 8KB.
|
||||
|
||||
The bar for writing is high: pin a value only when it is materially important to the issue AND likely to be re-read by future runs on this same issue (the PR URL, the deploy URL, what we're blocked on). Most runs write zero new keys — that's the expected case. Don't pin runtime bookkeeping like `attempts`, single-run investigation notes, large logs, secrets/tokens, or description/comment copies — see the agent runtime prompt for the full anti-pattern list.
|
||||
|
||||
```bash
|
||||
# List every key on an issue
|
||||
multica issue metadata list <issue-id>
|
||||
|
||||
# Read a single key
|
||||
multica issue metadata get <issue-id> --key pipeline_status
|
||||
|
||||
# Write a single key — value auto-typed (true/false → bool, numbers → number, else string)
|
||||
multica issue metadata set <issue-id> --key pipeline_status --value waiting_review
|
||||
multica issue metadata set <issue-id> --key pr_number --value 482
|
||||
multica issue metadata set <issue-id> --key is_blocked --value true
|
||||
|
||||
# Force a specific type when sniffing would pick the wrong one
|
||||
multica issue metadata set <issue-id> --key code --value 42 --type string
|
||||
|
||||
# Remove a key
|
||||
multica issue metadata delete <issue-id> --key pipeline_status
|
||||
```
|
||||
|
||||
All writes are single-key atomic — concurrent agents writing different keys do not lose each other's updates. To query, use `multica issue list --metadata key=value` (see *List Issues* above).
|
||||
|
||||
### Subscribers
|
||||
|
||||
```bash
|
||||
|
||||
@@ -18,6 +18,7 @@ ARG COMMIT=unknown
|
||||
RUN cd server && CGO_ENABLED=0 go build -ldflags "-s -w -X main.version=${VERSION} -X main.commit=${COMMIT}" -o bin/server ./cmd/server
|
||||
RUN cd server && CGO_ENABLED=0 go build -ldflags "-s -w -X main.version=${VERSION} -X main.commit=${COMMIT}" -o bin/multica ./cmd/multica
|
||||
RUN cd server && CGO_ENABLED=0 go build -ldflags "-s -w" -o bin/migrate ./cmd/migrate
|
||||
RUN cd server && CGO_ENABLED=0 go build -ldflags "-s -w" -o bin/backfill_task_usage_hourly ./cmd/backfill_task_usage_hourly
|
||||
|
||||
# --- Runtime stage ---
|
||||
FROM alpine:3.21
|
||||
@@ -29,6 +30,7 @@ WORKDIR /app
|
||||
COPY --from=builder /src/server/bin/server .
|
||||
COPY --from=builder /src/server/bin/multica .
|
||||
COPY --from=builder /src/server/bin/migrate .
|
||||
COPY --from=builder /src/server/bin/backfill_task_usage_hourly .
|
||||
COPY server/migrations/ ./migrations/
|
||||
COPY docker/entrypoint.sh .
|
||||
RUN sed -i 's/\r$//' entrypoint.sh && chmod +x entrypoint.sh
|
||||
|
||||
@@ -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>
|
||||
|
||||
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`。
|
||||
|
||||
**接下来守护进程会**:
|
||||
|
||||
|
||||
@@ -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 状态有什么影响
|
||||
|
||||
@@ -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,87 @@ export function createEnDict(allowSignup: boolean): LandingDict {
|
||||
fixes: "Bug Fixes",
|
||||
},
|
||||
entries: [
|
||||
{
|
||||
version: "0.3.5",
|
||||
date: "2026-05-21",
|
||||
title: "Local-Time Usage & Issue Custom KV",
|
||||
changes: [],
|
||||
features: [
|
||||
"Usage now uses each user's saved viewing timezone, so workspace and runtime usage match the day boundaries people expect",
|
||||
"Issues can carry compact agent-facing state that keeps automation progress tied to the work item without cluttering the sidebar",
|
||||
"Long Issue discussion threads can be read from the newest replies first while keeping the original thread context",
|
||||
"Project lists now offer compact and comfortable layouts that work better on small screens",
|
||||
],
|
||||
improvements: [
|
||||
"Workspace commands in the CLI accept slugs and short IDs from the list view",
|
||||
"The agent model picker no longer marks one model as a competing default",
|
||||
"Self-hosting and agent docs have corrected links and cleaner setup copy",
|
||||
"Reserved workspace paths have stronger coverage so special URLs are not accidentally reused as workspace addresses",
|
||||
"Agent runtime guidance keeps Issue metadata cleaner by reserving it for state future runs will actually need",
|
||||
],
|
||||
fixes: [
|
||||
"Automatic retries for stuck Codex runs now start from a fresh session instead of resuming the broken one",
|
||||
"Claude Code usage totals are recorded correctly when usage arrives at the end of a run",
|
||||
"Live updates now skip broken messages safely and keep logs small when a bad message is unusually large",
|
||||
"The Create Issue button explains when a missing title is blocking creation",
|
||||
"Runtimes can recover a task whose claim response was lost before the task actually started",
|
||||
],
|
||||
},
|
||||
{
|
||||
version: "0.3.4",
|
||||
date: "2026-05-20",
|
||||
title: "Smarter Autopilots, Agent Controls & Desktop Reliability",
|
||||
changes: [],
|
||||
features: [
|
||||
"Autopilots can assign new work through squads and place created Issues directly into a selected Project",
|
||||
"Agent settings now include per-agent thinking controls for Claude and Codex, with an inspector picker that updates instantly",
|
||||
"Desktop tabs can be pinned so important workspace pages stay parked while new links open in fresh tabs",
|
||||
"User profiles can add requester context, giving coding agents better background for assigned Issues",
|
||||
"Workspace settings now have a dedicated GitHub page, and regular members can see connected GitHub installations without admin controls",
|
||||
],
|
||||
improvements: [
|
||||
"New users are guided to connect a runtime instead of receiving starter content that may not match their workspace",
|
||||
"Runtime pages are quieter, and desktop keeps the local machine visible after stopping the local service",
|
||||
"Issue breadcrumbs show the Project segment when an Issue belongs to a Project",
|
||||
"HTML previews and attachment previews have roomier, more predictable layouts",
|
||||
"Squad pages show fuller loading states and use a clearer archive confirmation dialog",
|
||||
"Agents now receive parent and sub-issue handoff guidance before running assigned work",
|
||||
],
|
||||
fixes: [
|
||||
"List editing exits cleanly from an empty top-level item when pressing Enter",
|
||||
"The installer falls back to release binaries when Homebrew setup fails and reports clearer diagnostics",
|
||||
"Retrying an execution log row now reruns the agent that handled that row",
|
||||
"Chat and task-message loading ignore temporary IDs instead of calling invalid task routes",
|
||||
"OpenCode-backed daemon runs no longer enter invisible interactive question prompts",
|
||||
"Gemini runtimes use the correct official icon",
|
||||
],
|
||||
},
|
||||
{
|
||||
version: "0.3.3",
|
||||
date: "2026-05-19",
|
||||
title: "Project Timelines, Runtime Setup & Clearer Issue Work",
|
||||
changes: [],
|
||||
features: [
|
||||
"Projects now have a Gantt view for scheduled work, with updates that stay in sync as plans change",
|
||||
"Workspace admins can change the issue key prefix from settings",
|
||||
"The CLI can switch between workspaces and show the current workspace",
|
||||
"Agents can read issue threads from the most recent discussion first, making follow-up work easier to route and review",
|
||||
"Usage now includes a one-day view plus weekly trends that respect the selected timezone",
|
||||
"Agent detail pages now work as an issue board for that specific agent",
|
||||
],
|
||||
improvements: [
|
||||
"The onboarding flow now asks one focused question at a time and can guide runtime setup with fewer manual steps",
|
||||
"My Issues now includes squad-assigned work and labels the team-related tab more clearly",
|
||||
"Agent execution logs can be sorted in either direction when reviewing a run",
|
||||
],
|
||||
fixes: [
|
||||
"HTML previews open more predictably from desktop, close the full-screen modal when needed, and support in-page links",
|
||||
"HTML source view and attachment previews are easier to inspect, including opening content in a new tab",
|
||||
"Create-issue prompts no longer keep stale manual draft text when switching modes",
|
||||
"Runtime tasks now find the right workspace instructions and skills from the task folder",
|
||||
"Self-hosted teams can set how long auth sessions last",
|
||||
],
|
||||
},
|
||||
{
|
||||
version: "0.3.2",
|
||||
date: "2026-05-18",
|
||||
|
||||
@@ -284,6 +284,87 @@ export function createZhDict(allowSignup: boolean): LandingDict {
|
||||
fixes: "问题修复",
|
||||
},
|
||||
entries: [
|
||||
{
|
||||
version: "0.3.5",
|
||||
date: "2026-05-21",
|
||||
title: "按查看时区呈现用量,并支持 Issue Custom KV",
|
||||
changes: [],
|
||||
features: [
|
||||
"用量会使用用户保存的查看时区,工作区和运行环境用量会按每个人预期的自然日展示",
|
||||
"Issue 支持保存面向智能体的轻量状态,自动化进度可以跟随具体工作项,同时不打扰侧边栏阅读",
|
||||
"较长的 Issue 讨论线程可以从最新回复开始读取,并保留原始讨论上下文",
|
||||
"项目列表新增紧凑和舒适两种视图,小屏幕上也更容易浏览",
|
||||
],
|
||||
improvements: [
|
||||
"命令行里的工作区命令可以直接使用列表中的 slug 或短 ID",
|
||||
"智能体模型选择器移除了容易混淆的默认标签",
|
||||
"自托管和智能体文档修正了跳转链接,并补全了配置说明",
|
||||
"保留路径的保护更完整,避免特殊地址被误用为工作区地址",
|
||||
"智能体运行指引收紧了 Issue 状态写入规则,只把后续运行确实需要的信息留下",
|
||||
],
|
||||
fixes: [
|
||||
"Codex 运行卡住后自动重试时,会从全新会话开始,不再继续卡住的现场",
|
||||
"Claude Code 的用量统计在运行结束时返回时也能被正确记录",
|
||||
"实时更新遇到异常消息时会安全跳过,异常内容过长也不会撑大日志",
|
||||
"创建 Issue 时,如果标题为空,按钮会说明为什么暂时不能创建",
|
||||
"运行环境在任务领取响应丢失时,可以重新找回尚未开始的任务",
|
||||
],
|
||||
},
|
||||
{
|
||||
version: "0.3.4",
|
||||
date: "2026-05-20",
|
||||
title: "自动任务项目归属、智能体思考设置与更稳的桌面端",
|
||||
changes: [],
|
||||
features: [
|
||||
"自动任务现在可以通过小队分配工作,并把创建的 Issue 直接归入指定项目",
|
||||
"智能体设置新增 Claude 和 Codex 的思考强度控制,并可在详情面板里直接调整",
|
||||
"桌面端标签页可以固定,重要页面会留在左侧,打开新内容时不打断原页面",
|
||||
"用户资料可以补充请求者背景,让代码智能体在处理 Issue 时更理解上下文",
|
||||
"工作区设置新增 GitHub 专页,普通成员也能查看已连接的 GitHub 安装信息",
|
||||
],
|
||||
improvements: [
|
||||
"新用户引导会优先创建连接运行环境的下一步,不再生成不合适的示例内容",
|
||||
"运行环境页面减少重复信息,桌面端停止本机服务后仍能看到本机行并重新启动",
|
||||
"Issue 面包屑会显示所属项目,查看来源更清楚",
|
||||
"HTML 预览和附件预览拥有更合适的默认尺寸,查看内容更自然",
|
||||
"小队列表加载状态更完整,归档小队时会使用更清晰的确认弹窗",
|
||||
"智能体运行前会收到父 Issue / 子 Issue 协作规则,完成子任务后的回传更稳定",
|
||||
],
|
||||
fixes: [
|
||||
"在空的顶层列表项按 Enter 时,编辑器可以正常退出列表",
|
||||
"安装脚本在 Homebrew 失败时会自动改用发行版文件,并显示更清楚的诊断信息",
|
||||
"从执行记录重试时,会重新唤起当时处理该记录的智能体",
|
||||
"聊天和任务消息加载会跳过临时 ID,避免访问无效任务",
|
||||
"OpenCode 运行环境不再进入看不见的交互提问流程",
|
||||
"Gemini 运行环境使用正确的官方图标",
|
||||
],
|
||||
},
|
||||
{
|
||||
version: "0.3.3",
|
||||
date: "2026-05-19",
|
||||
title: "项目时间线、运行环境设置与更清晰的任务协作",
|
||||
changes: [],
|
||||
features: [
|
||||
"项目现在提供甘特图视图,用于查看有排期的工作,并会在计划变化时实时同步",
|
||||
"Workspace 管理员可以在设置中调整 Issue 编号前缀",
|
||||
"命令行可以切换 workspace 并查看当前 workspace",
|
||||
"Agent 现在可以优先读取最新的 Issue 讨论线程,后续跟进和审查更贴近当前上下文",
|
||||
"Usage 新增 1 天视图和按周趋势,并会遵循所选时区",
|
||||
"Agent 详情页现在是对应智能体的 Issue 看板",
|
||||
],
|
||||
improvements: [
|
||||
"Onboarding 改为一次回答一个问题,并能用更少步骤引导 runtime 设置",
|
||||
"My Issues 会包含分配给小队的工作,相关标签也更容易理解",
|
||||
"查看智能体执行日志时可以切换排序方向,回看运行过程更方便",
|
||||
],
|
||||
fixes: [
|
||||
"桌面端打开 HTML 预览更稳定,必要时会关闭全屏窗口,并支持页面内链接跳转",
|
||||
"HTML 源码视图和附件预览更容易检查,也可以把内容打开到新标签页",
|
||||
"切换创建 Issue 模式时,提示词里不再残留旧的手写草稿",
|
||||
"Runtime 任务会从任务目录读取正确的 workspace 指令和 skills",
|
||||
"自托管团队可以设置登录会话有效期",
|
||||
],
|
||||
},
|
||||
{
|
||||
version: "0.3.2",
|
||||
date: "2026-05-18",
|
||||
|
||||
@@ -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) |
|
||||
@@ -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",
|
||||
|
||||
@@ -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,109 @@ describe("ApiClient", () => {
|
||||
expect(headers["X-Client-OS"]).toBeUndefined();
|
||||
});
|
||||
|
||||
it("uses the Cloud Runtime node API contract and forwards bootstrap PAT on create", async () => {
|
||||
const node = {
|
||||
id: "node-1",
|
||||
owner_id: "user-1",
|
||||
instance_id: "i-0123456789abcdef0",
|
||||
region: "us-west-2",
|
||||
instance_type: "g5.xlarge",
|
||||
image_id: "ami-1",
|
||||
subnet_id: "subnet-1",
|
||||
name: "gpu-dev-01",
|
||||
status: "launching",
|
||||
tags: {},
|
||||
metadata: {},
|
||||
created_at: "2026-05-21T08:30:00Z",
|
||||
updated_at: "2026-05-21T08:30:00Z",
|
||||
};
|
||||
const fetchMock = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(
|
||||
new Response(JSON.stringify([]), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}),
|
||||
)
|
||||
.mockResolvedValueOnce(
|
||||
new Response(JSON.stringify(node), {
|
||||
status: 201,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}),
|
||||
);
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const client = new ApiClient("https://api.example.test");
|
||||
await client.listCloudRuntimeNodes({ limit: 20, offset: 5 });
|
||||
await client.createCloudRuntimeNode(
|
||||
{ instance_type: "g5.xlarge", name: "gpu-dev-01" },
|
||||
);
|
||||
|
||||
const listCall = fetchMock.mock.calls[0]!;
|
||||
const createCall = fetchMock.mock.calls[1]!;
|
||||
expect(listCall[0]).toBe(
|
||||
"https://api.example.test/api/cloud-runtime/nodes?limit=20&offset=5",
|
||||
);
|
||||
expect((listCall[1]!.headers as Record<string, string>)["X-User-PAT"]).toBeUndefined();
|
||||
expect(createCall[0]).toBe(
|
||||
"https://api.example.test/api/cloud-runtime/nodes",
|
||||
);
|
||||
expect(createCall[1]).toMatchObject({
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
instance_type: "g5.xlarge",
|
||||
name: "gpu-dev-01",
|
||||
}),
|
||||
});
|
||||
expect((createCall[1]!.headers as Record<string, string>)["X-User-PAT"]).toBeUndefined();
|
||||
});
|
||||
|
||||
it("falls back when Cloud Runtime node responses drift", async () => {
|
||||
const fetchMock = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(
|
||||
new Response(JSON.stringify([{ id: 123 }]), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}),
|
||||
)
|
||||
.mockResolvedValueOnce(
|
||||
new Response(JSON.stringify({ id: 123 }), {
|
||||
status: 201,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}),
|
||||
);
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const client = new ApiClient("https://api.example.test");
|
||||
|
||||
await expect(client.listCloudRuntimeNodes()).resolves.toEqual([]);
|
||||
await expect(
|
||||
client.createCloudRuntimeNode({ instance_type: "g5.xlarge" }),
|
||||
).resolves.toMatchObject({ id: "", status: "" });
|
||||
});
|
||||
|
||||
it("deleteCloudRuntimeNode sends DELETE with JSON body containing instance id", async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValueOnce(
|
||||
new Response(null, { status: 204 }),
|
||||
);
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const client = new ApiClient("https://api.example.test");
|
||||
await client.deleteCloudRuntimeNode("i-0123456789abcdef0");
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
const [url, opts] = fetchMock.mock.calls[0]!;
|
||||
expect(url).toBe("https://api.example.test/api/cloud-runtime/nodes");
|
||||
expect(opts).toMatchObject({
|
||||
method: "DELETE",
|
||||
body: JSON.stringify({ instance_id: "i-0123456789abcdef0" }),
|
||||
});
|
||||
expect((opts.headers as Record<string, string>)["Content-Type"]).toBe(
|
||||
"application/json",
|
||||
);
|
||||
});
|
||||
|
||||
describe("getAttachment", () => {
|
||||
it("returns the parsed attachment for a well-formed response", async () => {
|
||||
vi.stubGlobal(
|
||||
|
||||
@@ -101,6 +101,11 @@ import type {
|
||||
SquadMemberStatusListResponse,
|
||||
} from "../types";
|
||||
import type { OnboardingCompletionPath } from "../onboarding/types";
|
||||
import type {
|
||||
CloudRuntimeNode,
|
||||
CreateCloudRuntimeNodeRequest,
|
||||
ListCloudRuntimeNodesParams,
|
||||
} from "../runtimes/cloud-runtime";
|
||||
import { type Logger, noopLogger } from "../logger";
|
||||
import { createRequestId } from "../utils";
|
||||
import { getCurrentSlug } from "../platform/workspace-storage";
|
||||
@@ -111,6 +116,8 @@ import {
|
||||
AttachmentResponseSchema,
|
||||
ChildIssuesResponseSchema,
|
||||
CommentsListSchema,
|
||||
CloudRuntimeNodeListSchema,
|
||||
CloudRuntimeNodeSchema,
|
||||
CreateAgentFromTemplateResponseSchema,
|
||||
DashboardAgentRunTimeListSchema,
|
||||
DashboardRunTimeDailyListSchema,
|
||||
@@ -119,11 +126,14 @@ 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,
|
||||
@@ -131,9 +141,14 @@ import {
|
||||
ListWebhookDeliveriesResponseSchema,
|
||||
OnboardingNoRuntimeBootstrapResponseSchema,
|
||||
OnboardingRuntimeBootstrapResponseSchema,
|
||||
RuntimeHourlyActivityListSchema,
|
||||
RuntimeUsageByAgentListSchema,
|
||||
RuntimeUsageByHourListSchema,
|
||||
RuntimeUsageListSchema,
|
||||
SquadMemberStatusListResponseSchema,
|
||||
SubscribersListSchema,
|
||||
TimelineEntriesSchema,
|
||||
UserSchema,
|
||||
WebhookDeliveryResponseSchema,
|
||||
} from "./schemas";
|
||||
|
||||
@@ -186,52 +201,6 @@ const EMPTY_ONBOARDING_NO_RUNTIME_BOOTSTRAP_RESPONSE:
|
||||
issue_id: "",
|
||||
};
|
||||
|
||||
// --- Starter content (post-onboarding import) -----------------------------
|
||||
// Shape mirrors the Go request/response in handler/onboarding.go.
|
||||
//
|
||||
// The client sends both branches of sub-issues and an unbound welcome
|
||||
// issue template (title + description, no `agent_id`). The SERVER picks
|
||||
// the branch by inspecting the workspace's agent list inside the
|
||||
// import transaction. This removes the client as a trusted decider —
|
||||
// even if the client has a stale agent cache or lies, the server uses
|
||||
// the DB as source of truth.
|
||||
|
||||
export interface ImportStarterIssuePayload {
|
||||
title: string;
|
||||
description: string;
|
||||
status: string;
|
||||
priority: string;
|
||||
/** Server uses `user_id` (per app-wide AssigneePicker convention)
|
||||
* as assignee when true. No member_id is threaded through. */
|
||||
assign_to_self: boolean;
|
||||
}
|
||||
|
||||
export interface ImportStarterWelcomeIssueTemplate {
|
||||
title: string;
|
||||
description: string;
|
||||
/** Defaults to "high" on server when empty. */
|
||||
priority: string;
|
||||
}
|
||||
|
||||
export interface ImportStarterContentPayload {
|
||||
workspace_id: string;
|
||||
project: { title: string; description: string; icon: string };
|
||||
/** Always sent. Server creates it only when an agent exists in the
|
||||
* workspace; ignored otherwise. Agent id is picked by the server. */
|
||||
welcome_issue_template: ImportStarterWelcomeIssueTemplate;
|
||||
/** Used when the workspace has at least one agent. */
|
||||
agent_guided_sub_issues: ImportStarterIssuePayload[];
|
||||
/** Used when the workspace has zero agents. */
|
||||
self_serve_sub_issues: ImportStarterIssuePayload[];
|
||||
}
|
||||
|
||||
export interface ImportStarterContentResponse {
|
||||
user: User;
|
||||
project_id: string;
|
||||
/** Non-null when server took the agent-guided branch. */
|
||||
welcome_issue_id: string | null;
|
||||
}
|
||||
|
||||
export class ApiError extends Error {
|
||||
readonly status: number;
|
||||
readonly statusText: string;
|
||||
@@ -427,17 +396,23 @@ 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: {
|
||||
@@ -481,54 +456,35 @@ export class ApiClient {
|
||||
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
|
||||
@@ -544,7 +500,11 @@ export class ApiClient {
|
||||
if (params?.creator_id) search.set("creator_id", params.creator_id);
|
||||
if (params?.project_id) search.set("project_id", params.project_id);
|
||||
if (params?.involves_user_id) search.set("involves_user_id", params.involves_user_id);
|
||||
if (params?.metadata && Object.keys(params.metadata).length > 0) {
|
||||
search.set("metadata", JSON.stringify(params.metadata));
|
||||
}
|
||||
if (params?.open_only) search.set("open_only", "true");
|
||||
if (params?.scheduled) search.set("scheduled", "true");
|
||||
const path = `/api/issues?${search}`;
|
||||
const raw = await this.fetch<unknown>(path);
|
||||
return parseWithFallback(raw, ListIssuesResponseSchema, EMPTY_LIST_ISSUES_RESPONSE, {
|
||||
@@ -565,6 +525,9 @@ export class ApiClient {
|
||||
if (params.creator_id) search.set("creator_id", params.creator_id);
|
||||
if (params.project_id) search.set("project_id", params.project_id);
|
||||
if (params.involves_user_id) search.set("involves_user_id", params.involves_user_id);
|
||||
if (params.metadata && Object.keys(params.metadata).length > 0) {
|
||||
search.set("metadata", JSON.stringify(params.metadata));
|
||||
}
|
||||
if (params.assignee_filters?.length) {
|
||||
search.set("assignee_filters", params.assignee_filters.map((f) => `${f.type}:${f.id}`).join(","));
|
||||
}
|
||||
@@ -871,13 +834,56 @@ export class ApiClient {
|
||||
return this.fetch(`/api/runtimes?${search}`);
|
||||
}
|
||||
|
||||
async listCloudRuntimeNodes(
|
||||
params?: ListCloudRuntimeNodesParams,
|
||||
): Promise<CloudRuntimeNode[]> {
|
||||
const search = new URLSearchParams();
|
||||
if (params?.limit !== undefined) search.set("limit", String(params.limit));
|
||||
if (params?.offset !== undefined) search.set("offset", String(params.offset));
|
||||
const query = search.toString();
|
||||
const raw = await this.fetch<unknown>(
|
||||
`/api/cloud-runtime/nodes${query ? `?${query}` : ""}`,
|
||||
);
|
||||
return parseWithFallback(
|
||||
raw,
|
||||
CloudRuntimeNodeListSchema,
|
||||
EMPTY_CLOUD_RUNTIME_NODE_LIST,
|
||||
{ endpoint: "GET /api/cloud-runtime/nodes" },
|
||||
);
|
||||
}
|
||||
|
||||
async createCloudRuntimeNode(
|
||||
data: CreateCloudRuntimeNodeRequest,
|
||||
): Promise<CloudRuntimeNode> {
|
||||
const res = await this.fetchRaw("/api/cloud-runtime/nodes", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(data),
|
||||
extraHeaders: { "Content-Type": "application/json" },
|
||||
});
|
||||
const raw = await res.json() as unknown;
|
||||
return parseWithFallback(
|
||||
raw,
|
||||
CloudRuntimeNodeSchema,
|
||||
EMPTY_CLOUD_RUNTIME_NODE,
|
||||
{ endpoint: "POST /api/cloud-runtime/nodes" },
|
||||
);
|
||||
}
|
||||
|
||||
async deleteCloudRuntimeNode(instanceId: string): Promise<void> {
|
||||
await this.fetchRaw("/api/cloud-runtime/nodes", {
|
||||
method: "DELETE",
|
||||
body: JSON.stringify({ instance_id: instanceId }),
|
||||
extraHeaders: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
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",
|
||||
@@ -885,32 +891,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" },
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -921,11 +972,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,
|
||||
@@ -936,11 +988,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,
|
||||
@@ -951,11 +1004,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,
|
||||
@@ -966,11 +1022,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,
|
||||
@@ -1088,9 +1147,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 } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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,76 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { DuplicateIssueErrorBodySchema } from "./schemas";
|
||||
import {
|
||||
DashboardAgentRunTimeListSchema,
|
||||
DashboardUsageByAgentListSchema,
|
||||
DashboardUsageDailyListSchema,
|
||||
DuplicateIssueErrorBodySchema,
|
||||
EMPTY_USER,
|
||||
ListIssuesResponseSchema,
|
||||
RuntimeHourlyActivityListSchema,
|
||||
RuntimeUsageByAgentListSchema,
|
||||
RuntimeUsageByHourListSchema,
|
||||
RuntimeUsageListSchema,
|
||||
UserSchema,
|
||||
} from "./schemas";
|
||||
import { parseWithFallback } from "./schema";
|
||||
|
||||
const baseIssue = {
|
||||
id: "11111111-1111-1111-1111-111111111111",
|
||||
workspace_id: "ws-1",
|
||||
number: 1,
|
||||
identifier: "MUL-1",
|
||||
title: "Test",
|
||||
description: null,
|
||||
status: "todo",
|
||||
priority: "medium",
|
||||
assignee_type: null,
|
||||
assignee_id: null,
|
||||
creator_type: "member",
|
||||
creator_id: "user-1",
|
||||
parent_issue_id: null,
|
||||
project_id: null,
|
||||
position: 0,
|
||||
start_date: null,
|
||||
due_date: null,
|
||||
metadata: {},
|
||||
created_at: "2026-01-01T00:00:00Z",
|
||||
updated_at: "2026-01-01T00:00:00Z",
|
||||
};
|
||||
|
||||
describe("IssueSchema (via ListIssuesResponseSchema)", () => {
|
||||
it("accepts a primitive metadata KV map", () => {
|
||||
const payload = {
|
||||
issues: [
|
||||
{
|
||||
...baseIssue,
|
||||
metadata: { pipeline_status: "waiting", pr_number: 3, is_blocked: true },
|
||||
},
|
||||
],
|
||||
total: 1,
|
||||
};
|
||||
const parsed = ListIssuesResponseSchema.parse(payload);
|
||||
expect(parsed.issues[0]?.metadata).toEqual({
|
||||
pipeline_status: "waiting",
|
||||
pr_number: 3,
|
||||
is_blocked: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("defaults metadata to {} when the server omits it (older backend)", () => {
|
||||
const { metadata: _omit, ...issueWithoutMetadata } = baseIssue;
|
||||
const payload = { issues: [issueWithoutMetadata], total: 1 };
|
||||
const parsed = ListIssuesResponseSchema.parse(payload);
|
||||
expect(parsed.issues[0]?.metadata).toEqual({});
|
||||
});
|
||||
|
||||
it("rejects metadata with non-primitive values (nested object)", () => {
|
||||
const payload = {
|
||||
issues: [{ ...baseIssue, metadata: { nested: { x: 1 } } }],
|
||||
total: 1,
|
||||
};
|
||||
expect(ListIssuesResponseSchema.safeParse(payload).success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// The duplicate-issue branch in create-issue.tsx feeds ApiError.body
|
||||
// (typed as `unknown`) through this schema. Any future server drift that
|
||||
@@ -49,3 +120,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
|
||||
@@ -134,6 +136,11 @@ export const CommentSchema = z.object({
|
||||
|
||||
export const CommentsListSchema = z.array(CommentSchema);
|
||||
|
||||
// Metadata is primitive-only by API/DB contract. Stay lenient on shape:
|
||||
// unknown keys land as `unknown` to a caller, but the field itself defaults
|
||||
// to {} so consumers never need to nil-guard `issue.metadata`.
|
||||
const IssueMetadataSchema = z.record(z.string(), z.union([z.string(), z.number(), z.boolean()])).default({});
|
||||
|
||||
const IssueSchema = z.object({
|
||||
id: z.string(),
|
||||
workspace_id: z.string(),
|
||||
@@ -152,6 +159,7 @@ const IssueSchema = z.object({
|
||||
position: z.number(),
|
||||
start_date: z.string().nullable(),
|
||||
due_date: z.string().nullable(),
|
||||
metadata: IssueMetadataSchema,
|
||||
reactions: z.array(z.unknown()).optional(),
|
||||
labels: z.array(z.unknown()).optional(),
|
||||
created_at: z.string(),
|
||||
@@ -209,19 +217,56 @@ export const OnboardingNoRuntimeBootstrapResponseSchema = z.object({
|
||||
issue_id: z.string(),
|
||||
}).loose();
|
||||
|
||||
export const CloudRuntimeNodeSchema = z.object({
|
||||
id: z.string(),
|
||||
owner_id: z.string(),
|
||||
instance_id: z.string(),
|
||||
region: z.string(),
|
||||
instance_type: z.string(),
|
||||
image_id: z.string(),
|
||||
subnet_id: z.string(),
|
||||
name: z.string(),
|
||||
status: z.string(),
|
||||
tags: z.record(z.string(), z.string()).default({}),
|
||||
metadata: z.record(z.string(), z.unknown()).default({}),
|
||||
created_at: z.string(),
|
||||
updated_at: z.string(),
|
||||
}).loose();
|
||||
|
||||
export const CloudRuntimeNodeListSchema = z.array(CloudRuntimeNodeSchema);
|
||||
|
||||
export const EMPTY_CLOUD_RUNTIME_NODE_LIST: CloudRuntimeNode[] = [];
|
||||
|
||||
export const EMPTY_CLOUD_RUNTIME_NODE: CloudRuntimeNode = {
|
||||
id: "",
|
||||
owner_id: "",
|
||||
instance_id: "",
|
||||
region: "",
|
||||
instance_type: "",
|
||||
image_id: "",
|
||||
subnet_id: "",
|
||||
name: "",
|
||||
status: "",
|
||||
tags: {},
|
||||
metadata: {},
|
||||
created_at: "",
|
||||
updated_at: "",
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Workspace dashboard schemas
|
||||
//
|
||||
// The dashboard hits three independent rollup endpoints. Each returns a flat
|
||||
// array, and every field is consumed by chart / KPI math — a missing number
|
||||
// silently degrades to NaN downstream, so we coerce missing numbers to 0.
|
||||
// String fields 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),
|
||||
@@ -232,8 +277,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),
|
||||
@@ -244,7 +289,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),
|
||||
@@ -253,7 +298,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),
|
||||
@@ -261,6 +306,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
|
||||
@@ -483,3 +579,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);
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@ import { api } from "../api";
|
||||
import {
|
||||
issueKeys,
|
||||
ISSUE_PAGE_SIZE,
|
||||
PAGINATED_STATUSES,
|
||||
type AssigneeGroupedIssuesFilter,
|
||||
type MyIssuesFilter,
|
||||
} from "./queries";
|
||||
@@ -104,75 +103,6 @@ export function useLoadMoreByStatus(
|
||||
return { loadMore, hasMore, isLoading, total };
|
||||
}
|
||||
|
||||
/**
|
||||
* Drain every remaining paginated page across all statuses into the cache.
|
||||
* Used by surfaces that can't paginate per-column (e.g. the Project Gantt
|
||||
* view) and need the full project issue set up-front. Each iteration appends
|
||||
* one ISSUE_PAGE_SIZE page per status that still has unfetched rows; loops
|
||||
* until the cache totals match the server.
|
||||
*/
|
||||
export function useLoadAllRemaining(
|
||||
myIssues?: { scope: string; filter: MyIssuesFilter },
|
||||
) {
|
||||
const qc = useQueryClient();
|
||||
const wsId = useWorkspaceId();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const queryKey = myIssues
|
||||
? issueKeys.myList(wsId, myIssues.scope, myIssues.filter)
|
||||
: issueKeys.list(wsId);
|
||||
|
||||
const loadAll = useCallback(async () => {
|
||||
if (isLoading) return;
|
||||
setIsLoading(true);
|
||||
try {
|
||||
// Round-trip the cache rather than caching `loaded` locally so a
|
||||
// concurrent WS-driven update or another loadMore can't make us
|
||||
// re-fetch an already-loaded page.
|
||||
for (;;) {
|
||||
const cache = qc.getQueryData<ListIssuesCache>(queryKey);
|
||||
if (!cache) return;
|
||||
const pending = PAGINATED_STATUSES.filter((status) => {
|
||||
const bucket = cache.byStatus[status];
|
||||
if (!bucket) return false;
|
||||
return bucket.issues.length < bucket.total;
|
||||
});
|
||||
if (pending.length === 0) return;
|
||||
const results = await Promise.all(
|
||||
pending.map((status) =>
|
||||
api
|
||||
.listIssues({
|
||||
status,
|
||||
limit: ISSUE_PAGE_SIZE,
|
||||
offset: cache.byStatus[status]!.issues.length,
|
||||
...myIssues?.filter,
|
||||
})
|
||||
.then((res) => ({ status, res })),
|
||||
),
|
||||
);
|
||||
qc.setQueryData<ListIssuesCache>(queryKey, (old) => {
|
||||
if (!old) return old;
|
||||
let next = old;
|
||||
for (const { status, res } of results) {
|
||||
const prev = getBucket(next, status);
|
||||
const existingIds = new Set(prev.issues.map((i) => i.id));
|
||||
const appended = res.issues.filter((i) => !existingIds.has(i.id));
|
||||
next = setBucket(next, status, {
|
||||
issues: [...prev.issues, ...appended],
|
||||
total: res.total,
|
||||
});
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [isLoading, qc, queryKey, myIssues?.filter]);
|
||||
|
||||
return { loadAll, isLoading };
|
||||
}
|
||||
|
||||
export function useLoadMoreByAssigneeGroup(
|
||||
group: Pick<IssueAssigneeGroup, "id" | "assignee_type" | "assignee_id">,
|
||||
queryKey: QueryKey,
|
||||
@@ -251,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) });
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -327,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
|
||||
@@ -410,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);
|
||||
},
|
||||
});
|
||||
@@ -469,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({
|
||||
@@ -571,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),
|
||||
|
||||
132
packages/core/issues/queries.test.ts
Normal file
132
packages/core/issues/queries.test.ts
Normal file
@@ -0,0 +1,132 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { QueryClient } from "@tanstack/react-query";
|
||||
|
||||
import { setApiInstance } from "../api";
|
||||
import type { ApiClient } from "../api/client";
|
||||
import type { Issue, ListIssuesParams, ListIssuesResponse } from "../types";
|
||||
import {
|
||||
PROJECT_GANTT_MAX_ISSUES,
|
||||
PROJECT_GANTT_PAGE_LIMIT,
|
||||
issueKeys,
|
||||
projectGanttIssuesOptions,
|
||||
} from "./queries";
|
||||
|
||||
const WS_ID = "ws-1";
|
||||
const PROJECT_ID = "project-1";
|
||||
|
||||
function makeIssue(idx: number): Issue {
|
||||
return {
|
||||
id: `issue-${idx}`,
|
||||
workspace_id: WS_ID,
|
||||
number: idx,
|
||||
identifier: `MUL-${idx}`,
|
||||
title: `Issue ${idx}`,
|
||||
description: null,
|
||||
status: "todo",
|
||||
priority: "none",
|
||||
assignee_type: null,
|
||||
assignee_id: null,
|
||||
creator_type: "member",
|
||||
creator_id: "user-1",
|
||||
parent_issue_id: null,
|
||||
project_id: PROJECT_ID,
|
||||
position: idx,
|
||||
start_date: "2026-05-01T00:00:00Z",
|
||||
due_date: null,
|
||||
labels: [],
|
||||
metadata: {},
|
||||
created_at: "2025-01-01T00:00:00Z",
|
||||
updated_at: "2025-01-01T00:00:00Z",
|
||||
};
|
||||
}
|
||||
|
||||
// Type-only shim — only the methods the queries.ts code path under test calls.
|
||||
function installFakeApi(listIssues: (params?: ListIssuesParams) => Promise<ListIssuesResponse>) {
|
||||
setApiInstance({ listIssues } as unknown as ApiClient);
|
||||
}
|
||||
|
||||
describe("projectGanttIssuesOptions", () => {
|
||||
let qc: QueryClient;
|
||||
|
||||
beforeEach(() => {
|
||||
qc = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
qc.clear();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("returns the first page directly when it fits under PROJECT_GANTT_PAGE_LIMIT", async () => {
|
||||
const listIssues = vi
|
||||
.fn<(params?: ListIssuesParams) => Promise<ListIssuesResponse>>()
|
||||
.mockResolvedValue({
|
||||
issues: [makeIssue(1), makeIssue(2)],
|
||||
total: 2,
|
||||
});
|
||||
installFakeApi(listIssues);
|
||||
|
||||
const data = await qc.fetchQuery(projectGanttIssuesOptions(WS_ID, PROJECT_ID));
|
||||
|
||||
expect(listIssues).toHaveBeenCalledTimes(1);
|
||||
expect(listIssues).toHaveBeenCalledWith({
|
||||
project_id: PROJECT_ID,
|
||||
scheduled: true,
|
||||
limit: PROJECT_GANTT_PAGE_LIMIT,
|
||||
offset: 0,
|
||||
});
|
||||
expect(data).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("loops through pages until total is satisfied (no silent truncation)", async () => {
|
||||
const total = PROJECT_GANTT_PAGE_LIMIT + 7;
|
||||
const firstPage = Array.from({ length: PROJECT_GANTT_PAGE_LIMIT }, (_, i) =>
|
||||
makeIssue(i),
|
||||
);
|
||||
const secondPage = Array.from({ length: 7 }, (_, i) =>
|
||||
makeIssue(PROJECT_GANTT_PAGE_LIMIT + i),
|
||||
);
|
||||
|
||||
const listIssues = vi
|
||||
.fn<(params?: ListIssuesParams) => Promise<ListIssuesResponse>>()
|
||||
.mockImplementation(async (params) => {
|
||||
if (!params) throw new Error("expected params");
|
||||
const offset = params.offset ?? 0;
|
||||
if (offset === 0)
|
||||
return { issues: firstPage, total };
|
||||
if (offset === PROJECT_GANTT_PAGE_LIMIT)
|
||||
return { issues: secondPage, total };
|
||||
throw new Error(`unexpected offset ${offset}`);
|
||||
});
|
||||
installFakeApi(listIssues);
|
||||
|
||||
const data = await qc.fetchQuery(projectGanttIssuesOptions(WS_ID, PROJECT_ID));
|
||||
|
||||
expect(listIssues).toHaveBeenCalledTimes(2);
|
||||
expect(data).toHaveLength(total);
|
||||
});
|
||||
|
||||
it("stops looping when the server reports a smaller-than-limit page (safety net for total drift)", async () => {
|
||||
// Server says `total` is huge but only ever returns short pages — the
|
||||
// loop must terminate on the first short page to avoid an infinite fetch.
|
||||
const listIssues = vi
|
||||
.fn<(params?: ListIssuesParams) => Promise<ListIssuesResponse>>()
|
||||
.mockResolvedValue({
|
||||
issues: [makeIssue(1)],
|
||||
total: PROJECT_GANTT_MAX_ISSUES,
|
||||
});
|
||||
installFakeApi(listIssues);
|
||||
|
||||
const data = await qc.fetchQuery(projectGanttIssuesOptions(WS_ID, PROJECT_ID));
|
||||
|
||||
expect(listIssues).toHaveBeenCalledTimes(1);
|
||||
expect(data).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("uses the project-scoped Gantt cache key", () => {
|
||||
const options = projectGanttIssuesOptions(WS_ID, PROJECT_ID);
|
||||
expect(options.queryKey).toEqual(issueKeys.projectGantt(WS_ID, PROJECT_ID));
|
||||
});
|
||||
});
|
||||
@@ -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) =>
|
||||
@@ -79,34 +90,6 @@ export function flattenIssueBuckets(data: ListIssuesCache) {
|
||||
return out;
|
||||
}
|
||||
|
||||
export interface IssueListPagination {
|
||||
loaded: number;
|
||||
total: number;
|
||||
hasMore: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Aggregate the bucketed cache totals so non-paginated consumers (e.g. the
|
||||
* Gantt view, which doesn't have a per-status load-more affordance) can tell
|
||||
* whether the cache is missing pages and warn the user instead of silently
|
||||
* rendering an incomplete schedule.
|
||||
*/
|
||||
export function summarizeIssueListPagination(
|
||||
data: ListIssuesCache | undefined,
|
||||
): IssueListPagination {
|
||||
if (!data) return { loaded: 0, total: 0, hasMore: false };
|
||||
let loaded = 0;
|
||||
let total = 0;
|
||||
for (const status of PAGINATED_STATUSES) {
|
||||
const bucket = data.byStatus[status];
|
||||
if (bucket) {
|
||||
loaded += bucket.issues.length;
|
||||
total += bucket.total;
|
||||
}
|
||||
}
|
||||
return { loaded, total, hasMore: loaded < total };
|
||||
}
|
||||
|
||||
async function fetchFirstPages(filter: MyIssuesFilter = {}): Promise<ListIssuesCache> {
|
||||
const responses = await Promise.all(
|
||||
PAGINATED_STATUSES.map((status) =>
|
||||
@@ -171,20 +154,55 @@ export function myIssueListOptions(
|
||||
}
|
||||
|
||||
/**
|
||||
* Same cache entry as {@link myIssueListOptions} (shared queryKey + queryFn —
|
||||
* TanStack Query dedupes), but `select` derives a pagination summary instead
|
||||
* of the flat issue list. Use this alongside the list query when a consumer
|
||||
* needs to know how many issues live behind unfetched pages.
|
||||
* 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 function myIssueListPaginationOptions(
|
||||
wsId: string,
|
||||
scope: string,
|
||||
filter: MyIssuesFilter,
|
||||
) {
|
||||
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.myList(wsId, scope, filter),
|
||||
queryFn: () => fetchFirstPages(filter),
|
||||
select: summarizeIssueListPagination,
|
||||
queryKey: issueKeys.projectGantt(wsId, projectId),
|
||||
queryFn: () => fetchProjectGanttIssues(projectId),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -6,7 +6,13 @@ import {
|
||||
agentTaskSnapshotKeys,
|
||||
agentTasksKeys,
|
||||
} from "../agents/queries";
|
||||
import { onIssueDeleted, onIssueLabelsChanged } from "./ws-updaters";
|
||||
import {
|
||||
onIssueCreated,
|
||||
onIssueDeleted,
|
||||
onIssueLabelsChanged,
|
||||
onIssueMetadataChanged,
|
||||
onIssueUpdated,
|
||||
} from "./ws-updaters";
|
||||
import { issueKeys } from "./queries";
|
||||
import { labelKeys } from "../labels/queries";
|
||||
import type {
|
||||
@@ -66,6 +72,7 @@ const baseIssue: Issue = {
|
||||
position: 0,
|
||||
start_date: null,
|
||||
due_date: null,
|
||||
metadata: {},
|
||||
labels: [labelA],
|
||||
created_at: "2025-01-01T00:00:00Z",
|
||||
updated_at: "2025-01-01T00:00:00Z",
|
||||
@@ -151,6 +158,62 @@ describe("onIssueLabelsChanged", () => {
|
||||
const detail = qc.getQueryData<Issue>(issueKeys.detail(WS_ID, ISSUE_ID));
|
||||
expect(detail?.labels).toEqual([labelB]);
|
||||
});
|
||||
|
||||
it("patches the Project Gantt cache so label filters react in place", () => {
|
||||
const PROJECT_ID = "project-1";
|
||||
qc.setQueryData<Issue[]>(issueKeys.projectGantt(WS_ID, PROJECT_ID), [
|
||||
baseIssue,
|
||||
otherIssue,
|
||||
]);
|
||||
|
||||
onIssueLabelsChanged(qc, WS_ID, ISSUE_ID, [labelB]);
|
||||
|
||||
const gantt = qc.getQueryData<Issue[]>(
|
||||
issueKeys.projectGantt(WS_ID, PROJECT_ID),
|
||||
);
|
||||
expect(gantt?.find((i) => i.id === ISSUE_ID)?.labels).toEqual([labelB]);
|
||||
// Other issues in the same cache must not have their labels mutated.
|
||||
expect(gantt?.find((i) => i.id === OTHER_ISSUE_ID)?.labels).toEqual([
|
||||
labelA,
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("onIssueMetadataChanged", () => {
|
||||
let qc: QueryClient;
|
||||
|
||||
beforeEach(() => {
|
||||
qc = new QueryClient();
|
||||
});
|
||||
|
||||
it("replaces metadata in both detail and list caches (no merge)", () => {
|
||||
qc.setQueryData<Issue>(issueKeys.detail(WS_ID, ISSUE_ID), {
|
||||
...baseIssue,
|
||||
metadata: { pr_number: 1, stale: "yes" },
|
||||
});
|
||||
qc.setQueryData<ListIssuesCache>(issueKeys.list(WS_ID), {
|
||||
byStatus: {
|
||||
todo: {
|
||||
issues: [{ ...baseIssue, metadata: { pr_number: 1 } }],
|
||||
total: 1,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
onIssueMetadataChanged(qc, WS_ID, ISSUE_ID, { pr_number: 2 });
|
||||
|
||||
const detail = qc.getQueryData<Issue>(issueKeys.detail(WS_ID, ISSUE_ID));
|
||||
expect(detail?.metadata).toEqual({ pr_number: 2 });
|
||||
const list = qc.getQueryData<ListIssuesCache>(issueKeys.list(WS_ID));
|
||||
expect(list?.byStatus.todo?.issues[0]?.metadata).toEqual({ pr_number: 2 });
|
||||
});
|
||||
|
||||
it("leaves untouched caches as undefined (no spurious writes)", () => {
|
||||
onIssueMetadataChanged(qc, WS_ID, ISSUE_ID, { foo: "bar" });
|
||||
|
||||
expect(qc.getQueryData(issueKeys.detail(WS_ID, ISSUE_ID))).toBeUndefined();
|
||||
expect(qc.getQueryData(issueKeys.list(WS_ID))).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("onIssueDeleted", () => {
|
||||
@@ -392,3 +455,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));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
patchIssueInBuckets,
|
||||
} from "./cache-helpers";
|
||||
import { cleanupDeletedIssueCaches } from "./delete-cache";
|
||||
import type { Issue, IssueLabelsResponse, Label } from "../types";
|
||||
import type { Issue, IssueLabelsResponse, IssueMetadata, Label } from "../types";
|
||||
import type { ListIssuesCache } from "../types";
|
||||
|
||||
export function onIssueCreated(
|
||||
@@ -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,11 +114,49 @@ export function onIssueLabelsChanged(
|
||||
qc.setQueryData<IssueLabelsResponse>(labelKeys.byIssue(wsId, issueId), (old) =>
|
||||
old ? { ...old, labels } : old,
|
||||
);
|
||||
// Patch the Project Gantt caches in-place: the Gantt view applies
|
||||
// `labelFilters` to the row data, so a stale `labels` array would silently
|
||||
// hide or surface bars after another tab/agent attached or detached a
|
||||
// label. Mutating in place (instead of invalidating) avoids a refetch of
|
||||
// the entire scheduled set on every label toggle.
|
||||
for (const [key, data] of qc.getQueriesData<Issue[]>({
|
||||
queryKey: issueKeys.projectGanttAll(wsId),
|
||||
})) {
|
||||
if (!data) continue;
|
||||
const next = data.map((issue) =>
|
||||
issue.id === issueId ? { ...issue, labels } : issue,
|
||||
);
|
||||
qc.setQueryData<Issue[]>(key, next);
|
||||
}
|
||||
qc.invalidateQueries({ queryKey: issueKeys.myAll(wsId) });
|
||||
qc.invalidateQueries({ queryKey: issueKeys.assigneeGroupsAll(wsId) });
|
||||
qc.invalidateQueries({ queryKey: issueKeys.myAssigneeGroupsAll(wsId) });
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply a metadata snapshot to the issue detail + list + my-issues caches.
|
||||
* The server emits this whenever a single key is set or deleted, so the
|
||||
* payload is always the FULL post-mutation map — we replace, not merge.
|
||||
*
|
||||
* Used for the read-only metadata strip in issue detail. Updates that arrive
|
||||
* while no view is mounted still keep the caches accurate so the next render
|
||||
* shows the latest state without a refetch.
|
||||
*/
|
||||
export function onIssueMetadataChanged(
|
||||
qc: QueryClient,
|
||||
wsId: string,
|
||||
issueId: string,
|
||||
metadata: IssueMetadata,
|
||||
) {
|
||||
qc.setQueryData<ListIssuesCache>(issueKeys.list(wsId), (old) =>
|
||||
old ? patchIssueInBuckets(old, issueId, { metadata }) : old,
|
||||
);
|
||||
qc.setQueryData<Issue>(issueKeys.detail(wsId, issueId), (old) =>
|
||||
old ? { ...old, metadata } : old,
|
||||
);
|
||||
qc.invalidateQueries({ queryKey: issueKeys.myAll(wsId) });
|
||||
}
|
||||
|
||||
export function onIssueDeleted(
|
||||
qc: QueryClient,
|
||||
wsId: string,
|
||||
|
||||
@@ -55,8 +55,8 @@ export async function completeOnboarding(
|
||||
|
||||
/**
|
||||
* Runtime-connected onboarding path. The server creates or reuses the
|
||||
* default Multica Helper agent and the single onboarding issue, marks
|
||||
* onboarding complete, and suppresses the older starter-content prompt.
|
||||
* default Multica Helper agent and the single onboarding issue, then
|
||||
* marks onboarding complete.
|
||||
*/
|
||||
export async function bootstrapRuntimeOnboarding(
|
||||
workspaceId: string,
|
||||
@@ -72,8 +72,7 @@ export async function bootstrapRuntimeOnboarding(
|
||||
|
||||
/**
|
||||
* Runtime-skipped onboarding path. The server creates or reuses one
|
||||
* self-serve onboarding issue, marks onboarding complete, and suppresses
|
||||
* the older starter-content prompt so the user is not flooded with tasks.
|
||||
* install-runtime onboarding issue and marks onboarding complete.
|
||||
*/
|
||||
export async function bootstrapNoRuntimeOnboarding(
|
||||
workspaceId: string,
|
||||
|
||||
30
packages/core/paths/reserved-slugs.test.ts
Normal file
30
packages/core/paths/reserved-slugs.test.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { RESERVED_SLUGS, isReservedSlug } from "./reserved-slugs";
|
||||
|
||||
describe("reserved slugs", () => {
|
||||
it("returns true for a known reserved slug", () => {
|
||||
expect(isReservedSlug("login")).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false for an unreserved slug", () => {
|
||||
expect(isReservedSlug("my-cool-workspace")).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false for an empty slug", () => {
|
||||
expect(isReservedSlug("")).toBe(false);
|
||||
});
|
||||
|
||||
it("exposes a non-empty reserved slug set", () => {
|
||||
expect(RESERVED_SLUGS.size).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("keeps the set and predicate consistent", () => {
|
||||
for (const slug of RESERVED_SLUGS) {
|
||||
expect(isReservedSlug(slug)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("matches slugs case-sensitively", () => {
|
||||
expect(isReservedSlug("Login")).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -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());
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
onIssueUpdated,
|
||||
onIssueDeleted,
|
||||
onIssueLabelsChanged,
|
||||
onIssueMetadataChanged,
|
||||
} from "../issues/ws-updaters";
|
||||
import { onInboxNew, onInboxInvalidate, onInboxIssueStatusChanged, onInboxIssueDeleted } from "../inbox/ws-updaters";
|
||||
import { inboxKeys } from "../inbox/queries";
|
||||
@@ -44,6 +45,7 @@ import type {
|
||||
IssueCreatedPayload,
|
||||
IssueDeletedPayload,
|
||||
IssueLabelsChangedPayload,
|
||||
IssueMetadataChangedPayload,
|
||||
InboxNewPayload,
|
||||
CommentCreatedPayload,
|
||||
CommentUpdatedPayload,
|
||||
@@ -341,7 +343,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",
|
||||
"issue:updated", "issue:created", "issue:deleted", "issue_labels:changed", "issue_metadata:changed", "inbox:new",
|
||||
"comment:created", "comment:updated", "comment:deleted",
|
||||
"comment:resolved", "comment:unresolved",
|
||||
"activity:created",
|
||||
@@ -412,6 +414,13 @@ export function useRealtimeSync(
|
||||
if (wsId) onIssueLabelsChanged(qc, wsId, issue_id, labels ?? []);
|
||||
});
|
||||
|
||||
const unsubIssueMetadataChanged = ws.on("issue_metadata:changed", (p) => {
|
||||
const { issue_id, metadata } = p as IssueMetadataChangedPayload;
|
||||
if (!issue_id) return;
|
||||
const wsId = getCurrentWsId();
|
||||
if (wsId) onIssueMetadataChanged(qc, wsId, issue_id, metadata ?? {});
|
||||
});
|
||||
|
||||
const unsubInboxNew = ws.on("inbox:new", async (p) => {
|
||||
const { item } = p as InboxNewPayload;
|
||||
if (!item) return;
|
||||
@@ -878,6 +887,7 @@ export function useRealtimeSync(
|
||||
unsubIssueCreated();
|
||||
unsubIssueDeleted();
|
||||
unsubIssueLabelsChanged();
|
||||
unsubIssueMetadataChanged();
|
||||
unsubInboxNew();
|
||||
unsubCommentCreated();
|
||||
unsubCommentUpdated();
|
||||
|
||||
91
packages/core/runtimes/cloud-runtime.ts
Normal file
91
packages/core/runtimes/cloud-runtime.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
import { queryOptions, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { api } from "../api";
|
||||
|
||||
export interface CloudRuntimeNode {
|
||||
id: string;
|
||||
owner_id: string;
|
||||
instance_id: string;
|
||||
region: string;
|
||||
instance_type: string;
|
||||
image_id: string;
|
||||
subnet_id: string;
|
||||
name: string;
|
||||
status: string;
|
||||
tags: Record<string, string>;
|
||||
metadata: Record<string, unknown>;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface ListCloudRuntimeNodesParams {
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}
|
||||
|
||||
export interface CreateCloudRuntimeNodeRequest {
|
||||
instance_type: string;
|
||||
name?: string;
|
||||
region?: string;
|
||||
image_id?: string;
|
||||
subnet_id?: string;
|
||||
key_name?: string;
|
||||
iam_instance_profile?: string;
|
||||
disk_size_gb?: number;
|
||||
tags?: Record<string, string>;
|
||||
}
|
||||
|
||||
export const cloudRuntimeKeys = {
|
||||
all: (wsId: string) => ["cloud-runtime", wsId] as const,
|
||||
nodes: (wsId: string) => [...cloudRuntimeKeys.all(wsId), "nodes"] as const,
|
||||
};
|
||||
|
||||
const PENDING_NODE_STATUSES = new Set([
|
||||
"launching",
|
||||
"pending",
|
||||
"starting",
|
||||
"stopping",
|
||||
"rebooting",
|
||||
"terminating",
|
||||
]);
|
||||
|
||||
export function isCloudRuntimeNodePending(status: string): boolean {
|
||||
return PENDING_NODE_STATUSES.has(status.toLowerCase());
|
||||
}
|
||||
|
||||
export function cloudRuntimeNodeListOptions(
|
||||
wsId: string,
|
||||
params?: ListCloudRuntimeNodesParams,
|
||||
) {
|
||||
const limit = params?.limit ?? 20;
|
||||
const offset = params?.offset ?? 0;
|
||||
return queryOptions({
|
||||
queryKey: [...cloudRuntimeKeys.nodes(wsId), { limit, offset }] as const,
|
||||
queryFn: () => api.listCloudRuntimeNodes({ limit, offset }),
|
||||
refetchInterval: (query) =>
|
||||
query.state.data?.some((node) => isCloudRuntimeNodePending(node.status))
|
||||
? 5000
|
||||
: false,
|
||||
staleTime: 15 * 1000,
|
||||
});
|
||||
}
|
||||
|
||||
export function useCreateCloudRuntimeNode(wsId: string) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (data: CreateCloudRuntimeNodeRequest) =>
|
||||
api.createCloudRuntimeNode(data),
|
||||
onSettled: () => {
|
||||
qc.invalidateQueries({ queryKey: cloudRuntimeKeys.all(wsId) });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteCloudRuntimeNode(wsId: string) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (instanceId: string) => api.deleteCloudRuntimeNode(instanceId),
|
||||
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 =
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Issue, IssueStatus, IssuePriority, IssueAssigneeType } from "./issue";
|
||||
import type { Issue, IssueMetadata, IssueStatus, IssuePriority, IssueAssigneeType } from "./issue";
|
||||
import type { MemberRole } from "./workspace";
|
||||
import type { Project } from "./project";
|
||||
|
||||
@@ -54,7 +54,16 @@ export interface ListIssuesParams {
|
||||
* disjoint result sets by construction.
|
||||
*/
|
||||
involves_user_id?: string;
|
||||
/** JSONB containment filter on `issue.metadata`. AND across keys. */
|
||||
metadata?: IssueMetadata;
|
||||
open_only?: boolean;
|
||||
/**
|
||||
* Restrict the result to issues with at least one of `start_date` /
|
||||
* `due_date` set. Used by the Project Gantt view so it doesn't have to
|
||||
* page through every issue on the project just to discard the unscheduled
|
||||
* majority on the client.
|
||||
*/
|
||||
scheduled?: boolean;
|
||||
}
|
||||
|
||||
export interface IssueActorRef {
|
||||
@@ -76,6 +85,8 @@ export interface ListGroupedIssuesParams {
|
||||
project_id?: string;
|
||||
/** See `ListIssuesParams.involves_user_id` — same semantics. */
|
||||
involves_user_id?: string;
|
||||
/** JSONB containment filter on `issue.metadata`. AND across keys. */
|
||||
metadata?: IssueMetadata;
|
||||
assignee_filters?: IssueActorRef[];
|
||||
include_no_assignee?: boolean;
|
||||
creator_filters?: IssueActorRef[];
|
||||
@@ -146,6 +157,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;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Issue, IssueReaction } from "./issue";
|
||||
import type { Issue, IssueMetadata, IssueReaction } from "./issue";
|
||||
import type { Agent } from "./agent";
|
||||
import type { InboxItem } from "./inbox";
|
||||
import type { Comment, Reaction } from "./comment";
|
||||
@@ -65,6 +65,7 @@ export type WSEventType =
|
||||
| "label:updated"
|
||||
| "label:deleted"
|
||||
| "issue_labels:changed"
|
||||
| "issue_metadata:changed"
|
||||
| "pin:created"
|
||||
| "pin:deleted"
|
||||
| "pin:reordered"
|
||||
@@ -102,6 +103,11 @@ export interface IssueLabelsChangedPayload {
|
||||
labels: Label[];
|
||||
}
|
||||
|
||||
export interface IssueMetadataChangedPayload {
|
||||
issue_id: string;
|
||||
metadata: IssueMetadata;
|
||||
}
|
||||
|
||||
export interface AgentStatusPayload {
|
||||
agent: Agent;
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export type { Issue, IssueStatus, IssuePriority, IssueAssigneeType, IssueReaction } from "./issue";
|
||||
export type { Issue, IssueStatus, IssuePriority, IssueAssigneeType, IssueMetadata, IssueMetadataValue, IssueReaction } from "./issue";
|
||||
export type {
|
||||
Agent,
|
||||
AgentStatus,
|
||||
@@ -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,
|
||||
|
||||
@@ -22,6 +22,16 @@ export interface IssueReaction {
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-issue metadata is a flat KV map agents use to record pipeline state
|
||||
* (PR number, pipeline_status, waiting_on, ...). Values are primitives only —
|
||||
* string / number / bool — enforced by both the API and the DB. Always
|
||||
* present in responses (empty object when unset) so reads don't need a
|
||||
* nil guard on the parent field.
|
||||
*/
|
||||
export type IssueMetadataValue = string | number | boolean;
|
||||
export type IssueMetadata = Record<string, IssueMetadataValue>;
|
||||
|
||||
export interface Issue {
|
||||
id: string;
|
||||
workspace_id: string;
|
||||
@@ -40,6 +50,7 @@ export interface Issue {
|
||||
position: number;
|
||||
start_date: string | null;
|
||||
due_date: string | null;
|
||||
metadata: IssueMetadata;
|
||||
reactions?: IssueReaction[];
|
||||
labels?: Label[];
|
||||
created_at: string;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -87,7 +87,6 @@ function makeRuntime(overrides: Partial<RuntimeDevice>): RuntimeDevice {
|
||||
metadata: {},
|
||||
owner_id: ME,
|
||||
visibility: "private",
|
||||
timezone: "UTC",
|
||||
last_seen_at: "2026-04-27T11:59:50Z",
|
||||
created_at: "2026-04-01T00:00:00Z",
|
||||
updated_at: "2026-04-01T00:00:00Z",
|
||||
|
||||
@@ -145,21 +145,21 @@ export function ModelPicker({
|
||||
// string actually ships to the agent.
|
||||
tooltip={m.label !== m.id ? `${m.label} · ${m.id}` : m.id}
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="truncate font-medium">{m.label}</span>
|
||||
{m.default && (
|
||||
<span className="shrink-0 rounded bg-primary/10 px-1 text-[10px] font-medium text-primary">
|
||||
{t(($) => $.pickers.model_default_badge)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{/* PickerItem wraps children in a flex `<span>`. Putting a
|
||||
`<div>` inside that <span> is block-in-inline (invalid
|
||||
HTML5) and triggers the browser-default centering quirk
|
||||
that pushes descendants off-axis (model IDs floated to the
|
||||
center instead of left-aligning under their labels). Use
|
||||
`<span block text-left>` to keep layout deterministic —
|
||||
matches the fix already applied in thinking-picker.tsx. */}
|
||||
<span className="block min-w-0 flex-1 text-left">
|
||||
<span className="block truncate text-[13px] font-medium">{m.label}</span>
|
||||
{m.label !== m.id && (
|
||||
<div className="truncate font-mono text-[10px] text-muted-foreground">
|
||||
<span className="mt-0.5 block truncate font-mono text-[10px] leading-snug text-muted-foreground">
|
||||
{m.id}
|
||||
</div>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</span>
|
||||
</PickerItem>
|
||||
))}
|
||||
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { render, screen, fireEvent, cleanup } from "@testing-library/react";
|
||||
import type { RuntimeModelThinkingLevel } from "@multica/core/types";
|
||||
import { I18nProvider } from "@multica/core/i18n/react";
|
||||
import enCommon from "../../../locales/en/common.json";
|
||||
import enAgents from "../../../locales/en/agents.json";
|
||||
import enIssues from "../../../locales/en/issues.json";
|
||||
|
||||
import { ThinkingPicker } from "./thinking-picker";
|
||||
|
||||
const TEST_RESOURCES = {
|
||||
en: { common: enCommon, agents: enAgents, issues: enIssues },
|
||||
};
|
||||
|
||||
const CODEX_LEVELS: RuntimeModelThinkingLevel[] = [
|
||||
{ value: "minimal", label: "Minimal", description: "Fast, light reasoning" },
|
||||
{ value: "low", label: "Low" },
|
||||
{ value: "medium", label: "Medium" },
|
||||
{ value: "high", label: "High" },
|
||||
];
|
||||
|
||||
function renderPicker(props: Partial<React.ComponentProps<typeof ThinkingPicker>> = {}) {
|
||||
const onChange = vi.fn();
|
||||
const utils = render(
|
||||
<I18nProvider locale="en" resources={TEST_RESOURCES}>
|
||||
<ThinkingPicker
|
||||
value=""
|
||||
levels={CODEX_LEVELS}
|
||||
canEdit
|
||||
onChange={onChange}
|
||||
{...props}
|
||||
/>
|
||||
</I18nProvider>,
|
||||
);
|
||||
return { ...utils, onChange };
|
||||
}
|
||||
|
||||
describe("ThinkingPicker", () => {
|
||||
beforeEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it('renders "Follow CLI config" when value is empty', () => {
|
||||
renderPicker({ value: "" });
|
||||
// The trigger and the tooltip both carry the label. Empty value means
|
||||
// Multica omits --effort, so the local CLI's config decides the
|
||||
// reasoning level — see thinking-prop-row.tsx for the contract.
|
||||
expect(screen.getAllByText("Follow CLI config").length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("renders the matching level label when value is set", () => {
|
||||
renderPicker({ value: "high" });
|
||||
expect(screen.getAllByText("High").length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("renders the raw token when the saved value is no longer in the catalog", () => {
|
||||
// Simulates a model swap that dropped the option the user previously
|
||||
// picked — we still surface what's persisted so the user can clear it,
|
||||
// rather than silently showing "Follow CLI config".
|
||||
renderPicker({ value: "xhigh", levels: CODEX_LEVELS });
|
||||
expect(screen.getAllByText("xhigh").length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("renders a static read-only display when canEdit=false and exposes no popover trigger", () => {
|
||||
renderPicker({ value: "low", canEdit: false });
|
||||
expect(screen.getByText("Low")).toBeInTheDocument();
|
||||
expect(screen.queryByRole("button")).toBeNull();
|
||||
});
|
||||
|
||||
it("calls onChange with the picked value and skips when the user re-picks the current value", () => {
|
||||
const { onChange } = renderPicker({ value: "low" });
|
||||
fireEvent.click(screen.getByRole("button"));
|
||||
|
||||
// Picking a new level fires onChange with the runtime-native value.
|
||||
fireEvent.click(screen.getByText("High"));
|
||||
expect(onChange).toHaveBeenCalledWith("high");
|
||||
|
||||
// Re-opening and clicking the already-selected value is a no-op so we
|
||||
// don't enqueue a redundant PATCH. The trigger also reads "Low", so
|
||||
// there are two matches in the DOM — target the listbox item by
|
||||
// selecting the option button explicitly.
|
||||
onChange.mockClear();
|
||||
fireEvent.click(screen.getByRole("button"));
|
||||
const lowOption = screen
|
||||
.getAllByRole("button")
|
||||
.find((b) => b.getAttribute("data-picker-item") !== null && b.textContent?.includes("Low"));
|
||||
expect(lowOption).toBeDefined();
|
||||
fireEvent.click(lowOption!);
|
||||
expect(onChange).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("clears to empty string via the footer button when a value is set", () => {
|
||||
const { onChange } = renderPicker({ value: "high" });
|
||||
fireEvent.click(screen.getByRole("button"));
|
||||
// Footer copy resolves through i18n — match a substring so we don't
|
||||
// pin to the exact translated wording.
|
||||
const clearButton = screen.getByTitle(/Clear the override/i);
|
||||
fireEvent.click(clearButton);
|
||||
expect(onChange).toHaveBeenCalledWith("");
|
||||
});
|
||||
|
||||
it("does not render the clear button when value is already empty", () => {
|
||||
renderPicker({ value: "" });
|
||||
fireEvent.click(screen.getByRole("button"));
|
||||
expect(screen.queryByTitle(/Clear and fall back/i)).toBeNull();
|
||||
});
|
||||
});
|
||||
134
packages/views/agents/components/inspector/thinking-picker.tsx
Normal file
134
packages/views/agents/components/inspector/thinking-picker.tsx
Normal file
@@ -0,0 +1,134 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import type { RuntimeModelThinkingLevel } from "@multica/core/types";
|
||||
import {
|
||||
PickerItem,
|
||||
PropertyPicker,
|
||||
} from "../../../issues/components/pickers";
|
||||
import { CHIP_CLASS } from "./chip";
|
||||
import { useT } from "../../../i18n";
|
||||
|
||||
/**
|
||||
* Per-agent reasoning/effort picker (MUL-2339). Renders only when the
|
||||
* current model exposes a non-empty `supported_levels` set — Claude and
|
||||
* Codex today; every other provider gets nothing. The catalog is daemon-
|
||||
* discovered, so the value/label pairs match each CLI's own UI (`Low`,
|
||||
* `Extra high`, …) verbatim; never normalised across providers.
|
||||
*
|
||||
* Empty string is the "no override" sentinel: the backend omits the
|
||||
* effort flag entirely and the upstream CLI's own config / built-in
|
||||
* default decides what the model runs at. We render that state as
|
||||
* "Follow CLI config" rather than singling out one level as the
|
||||
* factory default, because the actual default at runtime is owned by
|
||||
* the user's local CLI install, not by Multica's catalog.
|
||||
*/
|
||||
export function ThinkingPicker({
|
||||
value,
|
||||
levels,
|
||||
canEdit = true,
|
||||
onChange,
|
||||
}: {
|
||||
/** Persisted thinking_level — "" means "follow local CLI config". */
|
||||
value: string;
|
||||
/** Supported levels for the current (runtime, model) pair. Usually
|
||||
* non-empty when the row is shown, but the stale-orphan clear path
|
||||
* in ThinkingPropRow mounts the picker with an empty list plus a
|
||||
* persisted value so the user can see and clear the dangling token. */
|
||||
levels: RuntimeModelThinkingLevel[];
|
||||
/** When false, render a static read-only display and skip the popover. */
|
||||
canEdit?: boolean;
|
||||
onChange: (next: string) => Promise<void> | void;
|
||||
}) {
|
||||
const { t } = useT("agents");
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const selected = value ? levels.find((l) => l.value === value) : undefined;
|
||||
// Unknown-but-set value (model swap that dropped the option, CLI upgrade
|
||||
// that trimmed the catalog): show the raw token so the user can see what
|
||||
// is actually persisted and clear it, rather than silently labelling it
|
||||
// "Default" when the backend would still send the stale value.
|
||||
const triggerLabel = selected
|
||||
? selected.label
|
||||
: value || t(($) => $.pickers.thinking_default);
|
||||
const triggerTitle = t(($) => $.pickers.thinking_tooltip, {
|
||||
value: triggerLabel,
|
||||
});
|
||||
|
||||
const select = async (next: string) => {
|
||||
setOpen(false);
|
||||
if (next !== value) await onChange(next);
|
||||
};
|
||||
|
||||
if (!canEdit) {
|
||||
return (
|
||||
<span
|
||||
className="min-w-0 truncate px-1.5 py-0.5 font-mono text-[11px] text-muted-foreground"
|
||||
title={triggerTitle}
|
||||
>
|
||||
{triggerLabel}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<PropertyPicker
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
width="w-auto min-w-[14rem] max-w-md"
|
||||
align="start"
|
||||
tooltip={triggerTitle}
|
||||
triggerRender={
|
||||
<button
|
||||
type="button"
|
||||
className={CHIP_CLASS}
|
||||
aria-label={triggerTitle}
|
||||
/>
|
||||
}
|
||||
trigger={
|
||||
<span className="min-w-0 truncate font-mono text-[11px]">
|
||||
{triggerLabel}
|
||||
</span>
|
||||
}
|
||||
>
|
||||
{levels.map((l) => (
|
||||
<PickerItem
|
||||
key={l.value}
|
||||
selected={l.value === value}
|
||||
onClick={() => void select(l.value)}
|
||||
>
|
||||
{/* PickerItem wraps children in a flex `<span>`. Putting a
|
||||
`<div>` inside that <span> is block-in-inline (invalid HTML5)
|
||||
and triggers browser quirks that shift descendant x-position.
|
||||
Use a `<span>` with explicit `block` + `text-left` so layout
|
||||
is deterministic across rows regardless of whether the label
|
||||
row has the `default` badge sibling. */}
|
||||
{/* No model-factory-default badge here on purpose: when the
|
||||
picker is "Follow CLI config" (value === ""), Multica omits
|
||||
`--effort` and the local CLI config decides — the model's
|
||||
factory default is irrelevant to what actually fires, so
|
||||
flagging one option as "default" was misleading. */}
|
||||
<span className="block min-w-0 flex-1 text-left">
|
||||
<span className="truncate text-[13px] font-medium">{l.label}</span>
|
||||
{l.description && (
|
||||
<span className="mt-0.5 block text-[11px] leading-snug text-muted-foreground">
|
||||
{l.description}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</PickerItem>
|
||||
))}
|
||||
|
||||
{value && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void select("")}
|
||||
className="mt-1 flex w-full items-center border-t px-3 py-2 text-left text-xs text-muted-foreground transition-colors hover:bg-accent/50"
|
||||
title={t(($) => $.pickers.thinking_clear_title)}
|
||||
>
|
||||
{t(($) => $.pickers.thinking_clear)}
|
||||
</button>
|
||||
)}
|
||||
</PropertyPicker>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import {
|
||||
cleanup,
|
||||
fireEvent,
|
||||
render,
|
||||
screen,
|
||||
waitFor,
|
||||
} from "@testing-library/react";
|
||||
import type {
|
||||
RuntimeModel,
|
||||
RuntimeModelListRequest,
|
||||
} from "@multica/core/types";
|
||||
import { I18nProvider } from "@multica/core/i18n/react";
|
||||
import enCommon from "../../../locales/en/common.json";
|
||||
import enAgents from "../../../locales/en/agents.json";
|
||||
import enIssues from "../../../locales/en/issues.json";
|
||||
|
||||
const TEST_RESOURCES = {
|
||||
en: { common: enCommon, agents: enAgents, issues: enIssues },
|
||||
};
|
||||
|
||||
const mockInitiateListModels = vi.hoisted(() => vi.fn());
|
||||
const mockGetListModelsResult = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("@multica/core/api", () => ({
|
||||
api: {
|
||||
initiateListModels: (...args: unknown[]) =>
|
||||
mockInitiateListModels(...args),
|
||||
getListModelsResult: (...args: unknown[]) =>
|
||||
mockGetListModelsResult(...args),
|
||||
},
|
||||
}));
|
||||
|
||||
import { ThinkingPropRow } from "./thinking-prop-row";
|
||||
|
||||
const CLAUDE_MODEL: RuntimeModel = {
|
||||
id: "claude-sonnet-4-6",
|
||||
label: "Claude Sonnet 4.6",
|
||||
default: true,
|
||||
thinking: {
|
||||
supported_levels: [
|
||||
{ value: "none", label: "None" },
|
||||
{ value: "low", label: "Low" },
|
||||
{ value: "medium", label: "Medium" },
|
||||
{ value: "high", label: "High" },
|
||||
],
|
||||
default_level: "medium",
|
||||
},
|
||||
};
|
||||
|
||||
// Model without thinking metadata — what the row sees when the agent's
|
||||
// model swap landed on a non-thinking runtime, or when the daemon catalog
|
||||
// shrank and stopped emitting `thinking` for this id.
|
||||
const NO_THINKING_MODEL: RuntimeModel = {
|
||||
id: "gemini-2.5-pro",
|
||||
label: "Gemini 2.5 Pro",
|
||||
default: true,
|
||||
};
|
||||
|
||||
function listResult(models: RuntimeModel[]): RuntimeModelListRequest {
|
||||
return {
|
||||
id: "req-1",
|
||||
runtime_id: "runtime-1",
|
||||
status: "completed",
|
||||
models,
|
||||
supported: true,
|
||||
created_at: "2026-05-20T00:00:00Z",
|
||||
updated_at: "2026-05-20T00:00:00Z",
|
||||
};
|
||||
}
|
||||
|
||||
function renderRow(
|
||||
props: Partial<React.ComponentProps<typeof ThinkingPropRow>> = {},
|
||||
) {
|
||||
const onChange = vi.fn();
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
});
|
||||
const utils = render(
|
||||
// PropRow uses CSS subgrid, so wrap with the same column tracks the
|
||||
// inspector parent declares — otherwise the row mounts without a
|
||||
// grid context and the column layout warns. Behaviour we care about
|
||||
// (visibility + clear flow) is independent of layout.
|
||||
<I18nProvider locale="en" resources={TEST_RESOURCES}>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<div className="grid grid-cols-[auto_1fr] gap-x-2 gap-y-0.5">
|
||||
<ThinkingPropRow
|
||||
runtimeId="runtime-1"
|
||||
runtimeOnline
|
||||
model="claude-sonnet-4-6"
|
||||
value=""
|
||||
canEdit
|
||||
onChange={onChange}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
</QueryClientProvider>
|
||||
</I18nProvider>,
|
||||
);
|
||||
return { ...utils, onChange, queryClient };
|
||||
}
|
||||
|
||||
describe("ThinkingPropRow", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockInitiateListModels.mockResolvedValue(listResult([CLAUDE_MODEL]));
|
||||
mockGetListModelsResult.mockResolvedValue(listResult([CLAUDE_MODEL]));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it("hides the row when the active model has no thinking levels and nothing is persisted", async () => {
|
||||
mockInitiateListModels.mockResolvedValue(listResult([NO_THINKING_MODEL]));
|
||||
renderRow({ model: "gemini-2.5-pro", value: "" });
|
||||
|
||||
// ThinkingPropRow returns null when levels are empty and value is
|
||||
// empty — both initially (data undefined) and after discovery
|
||||
// (NO_THINKING_MODEL has no `thinking` block). The `useQuery` hook
|
||||
// runs before the early null return on first render, so the
|
||||
// subscription is established and discovery still fires. In
|
||||
// production this is also covered by the sibling ModelPicker
|
||||
// mounted next to the row in agent-detail-inspector.
|
||||
await waitFor(() => {
|
||||
expect(mockInitiateListModels).toHaveBeenCalled();
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText("Thinking")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it("hides the row while the runtime is offline (no query fires)", () => {
|
||||
renderRow({ runtimeOnline: false, value: "" });
|
||||
|
||||
// Query disabled when runtimeOnline=false, so no models, levels stay
|
||||
// empty, value is empty → row stays hidden.
|
||||
expect(screen.queryByText("Thinking")).toBeNull();
|
||||
expect(mockInitiateListModels).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("renders the row with the persisted raw token when levels are empty but value is set (stale orphan)", async () => {
|
||||
// The agent persisted `thinking_level=xhigh` while it was on a
|
||||
// thinking-capable model, then was swapped to gemini (or the CLI
|
||||
// catalog shrank). PR1's behavior is daemon-side warn/drop, not a
|
||||
// synchronous DB clear, so the frontend must surface the orphan
|
||||
// token and let the user clear it explicitly.
|
||||
mockInitiateListModels.mockResolvedValue(listResult([NO_THINKING_MODEL]));
|
||||
renderRow({ model: "gemini-2.5-pro", value: "xhigh" });
|
||||
|
||||
await screen.findByText("Thinking");
|
||||
// The picker chip carries the raw value when it's not in the catalog.
|
||||
expect(await screen.findByText("xhigh")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("clears the orphan value via the picker footer, emitting onChange(\"\")", async () => {
|
||||
mockInitiateListModels.mockResolvedValue(listResult([NO_THINKING_MODEL]));
|
||||
const { onChange } = renderRow({
|
||||
model: "gemini-2.5-pro",
|
||||
value: "xhigh",
|
||||
});
|
||||
|
||||
// Wait until the row mounts with the orphan value, then open the
|
||||
// popover and fire the clear footer. The footer is the only target
|
||||
// matching the i18n `thinking_clear_title` copy.
|
||||
await screen.findByText("xhigh");
|
||||
fireEvent.click(screen.getByRole("button"));
|
||||
const clearButton = await screen.findByTitle(/Clear the override/i);
|
||||
fireEvent.click(clearButton);
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith("");
|
||||
});
|
||||
|
||||
it("renders the row with the matched label when the model still advertises the value", async () => {
|
||||
renderRow({ value: "high" });
|
||||
|
||||
await screen.findByText("Thinking");
|
||||
// Both the chip and the tooltip carry "High".
|
||||
expect((await screen.findAllByText("High")).length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("renders the row with \"Follow CLI config\" when value is empty and the model exposes levels", async () => {
|
||||
renderRow({ value: "" });
|
||||
|
||||
await screen.findByText("Thinking");
|
||||
// Empty value means Multica omits --effort, so the local CLI's
|
||||
// config decides — chip + tooltip both read "Follow CLI config".
|
||||
expect((await screen.findAllByText("Follow CLI config")).length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,71 @@
|
||||
"use client";
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import type { RuntimeModel } from "@multica/core/types";
|
||||
import { runtimeModelsOptions } from "@multica/core/runtimes";
|
||||
import { PropRow } from "../../../common/prop-row";
|
||||
import { useT } from "../../../i18n";
|
||||
import { ThinkingPicker } from "./thinking-picker";
|
||||
|
||||
/**
|
||||
* Thinking row for the agent inspector. Hidden when the active model has
|
||||
* no `supported_levels` advertised AND nothing is persisted, so providers
|
||||
* that don't expose reasoning never surface an empty row. If the agent
|
||||
* already has a `thinking_level` saved (model swap into a non-thinking
|
||||
* runtime, or the daemon / CLI catalog shrank and dropped the entry),
|
||||
* we still render the row so the user can see the orphan token the
|
||||
* backend is still sending and explicit-clear it via the picker footer.
|
||||
* PR1's per-model invalid behavior is daemon-side warn/drop, not a
|
||||
* synchronous DB clear, so the frontend has to surface the persisted
|
||||
* state honestly.
|
||||
*
|
||||
* Reuses the shared runtime-models query so it hits the same 60s cache
|
||||
* as the model picker; no extra round-trip on the inspector's hot path.
|
||||
* The sibling ModelPicker mounts unconditionally next to this row, so
|
||||
* the shared query subscription is established by the inspector mount
|
||||
* itself — returning null here does NOT cancel discovery.
|
||||
*/
|
||||
export function ThinkingPropRow({
|
||||
runtimeId,
|
||||
runtimeOnline,
|
||||
model,
|
||||
value,
|
||||
canEdit,
|
||||
onChange,
|
||||
}: {
|
||||
runtimeId: string | null;
|
||||
runtimeOnline: boolean;
|
||||
model: string;
|
||||
value: string;
|
||||
canEdit: boolean;
|
||||
onChange: (next: string) => Promise<void> | void;
|
||||
}) {
|
||||
const { t } = useT("agents");
|
||||
const modelsQuery = useQuery(
|
||||
runtimeModelsOptions(runtimeOnline ? runtimeId : null),
|
||||
);
|
||||
|
||||
const models = modelsQuery.data?.models ?? [];
|
||||
const entry = pickModelEntry(models, model);
|
||||
const levels = entry?.thinking?.supported_levels ?? [];
|
||||
if (levels.length === 0 && !value) return null;
|
||||
|
||||
return (
|
||||
<PropRow label={t(($) => $.inspector.prop_thinking)} interactive={false}>
|
||||
<ThinkingPicker
|
||||
value={value}
|
||||
levels={levels}
|
||||
canEdit={canEdit}
|
||||
onChange={onChange}
|
||||
/>
|
||||
</PropRow>
|
||||
);
|
||||
}
|
||||
|
||||
function pickModelEntry(
|
||||
models: RuntimeModel[],
|
||||
model: string,
|
||||
): RuntimeModel | undefined {
|
||||
if (model) return models.find((m) => m.id === model);
|
||||
return models.find((m) => m.default) ?? models[0];
|
||||
}
|
||||
@@ -183,14 +183,7 @@ export function ModelDropdown({
|
||||
}`}
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="truncate font-medium">{m.label}</span>
|
||||
{m.default && (
|
||||
<span className="shrink-0 rounded bg-primary/10 px-1.5 py-0.5 text-xs font-medium text-primary">
|
||||
{t(($) => $.pickers.model_default_badge)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="truncate font-medium">{m.label}</div>
|
||||
{m.label !== m.id && (
|
||||
<div className="truncate text-xs text-muted-foreground">
|
||||
{m.id}
|
||||
|
||||
@@ -10,6 +10,7 @@ import type { TaskFailureReason } from "@multica/core/types";
|
||||
export const failureReasonLabel: Record<TaskFailureReason, string> = {
|
||||
agent_error: "Agent execution error",
|
||||
timeout: "Task timed out",
|
||||
codex_semantic_inactivity: "Codex semantic inactivity timeout",
|
||||
runtime_offline: "Daemon offline",
|
||||
runtime_recovery: "Daemon restarted",
|
||||
manual: "Cancelled by user",
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
import { useEffect } from "react";
|
||||
import { useT } from "../i18n";
|
||||
import { useAttachmentHtmlText } from "../editor/hooks/use-attachment-html-text";
|
||||
import { withFragmentNavShim } from "../editor/utils/iframe-fragment-nav";
|
||||
|
||||
interface AttachmentPreviewPageProps {
|
||||
attachmentId: string;
|
||||
@@ -61,7 +62,7 @@ export function AttachmentPreviewPage({
|
||||
</div>
|
||||
) : (
|
||||
<iframe
|
||||
srcDoc={text}
|
||||
srcDoc={withFragmentNavShim(text)}
|
||||
sandbox="allow-scripts"
|
||||
title={filename ?? "HTML attachment"}
|
||||
className="flex-1 w-full border-0 bg-background"
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
} from "lucide-react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { autopilotDetailOptions, autopilotRunsOptions, autopilotRunOptions } from "@multica/core/autopilots/queries";
|
||||
import { projectDetailOptions } from "@multica/core/projects/queries";
|
||||
import {
|
||||
useUpdateAutopilot,
|
||||
useDeleteAutopilot,
|
||||
@@ -57,6 +58,7 @@ import { TranscriptButton } from "../../common/task-transcript";
|
||||
import { AutopilotDialog } from "./autopilot-dialog";
|
||||
import { WebhookPayloadPreview } from "./webhook-payload-preview";
|
||||
import { WebhookDeliveriesSection } from "./webhook-deliveries-section";
|
||||
import { ProjectIcon } from "../../projects/components/project-icon";
|
||||
import { useT } from "../../i18n";
|
||||
|
||||
function formatDate(date: string): string {
|
||||
@@ -583,6 +585,11 @@ export function AutopilotDetailPage({ autopilotId }: { autopilotId: string }) {
|
||||
const updateAutopilot = useUpdateAutopilot();
|
||||
const deleteAutopilot = useDeleteAutopilot();
|
||||
const triggerAutopilot = useTriggerAutopilot();
|
||||
const projectId = data?.autopilot.project_id ?? null;
|
||||
const { data: project, isLoading: projectLoading } = useQuery({
|
||||
...projectDetailOptions(wsId, projectId ?? ""),
|
||||
enabled: Boolean(projectId),
|
||||
});
|
||||
|
||||
const [triggerDialogOpen, setTriggerDialogOpen] = useState(false);
|
||||
const [editDialogOpen, setEditDialogOpen] = useState(false);
|
||||
@@ -724,8 +731,16 @@ export function AutopilotDetailPage({ autopilotId }: { autopilotId: string }) {
|
||||
<div>
|
||||
<label className="text-xs text-muted-foreground">{t(($) => $.detail.field_agent)}</label>
|
||||
<div className="mt-1 flex items-center gap-2">
|
||||
<ActorAvatar actorType="agent" actorId={autopilot.assignee_id} size={20} enableHoverCard showStatusDot />
|
||||
<span className="cursor-pointer">{getActorName("agent", autopilot.assignee_id)}</span>
|
||||
<ActorAvatar
|
||||
actorType={autopilot.assignee_type}
|
||||
actorId={autopilot.assignee_id}
|
||||
size={20}
|
||||
enableHoverCard={autopilot.assignee_type === "agent"}
|
||||
showStatusDot={autopilot.assignee_type === "agent"}
|
||||
/>
|
||||
<span className="cursor-pointer">
|
||||
{getActorName(autopilot.assignee_type, autopilot.assignee_id)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
@@ -734,6 +749,28 @@ export function AutopilotDetailPage({ autopilotId }: { autopilotId: string }) {
|
||||
{t(($) => $.execution_mode[autopilot.execution_mode as AutopilotExecutionMode])}
|
||||
</div>
|
||||
</div>
|
||||
{autopilot.execution_mode === "create_issue" && (
|
||||
<div>
|
||||
<label className="text-xs text-muted-foreground">{t(($) => $.detail.field_project)}</label>
|
||||
<div className="mt-1 min-w-0">
|
||||
{!autopilot.project_id ? (
|
||||
<span className="text-muted-foreground">{t(($) => $.detail.no_project)}</span>
|
||||
) : projectLoading ? (
|
||||
<Skeleton className="h-5 w-32" />
|
||||
) : project ? (
|
||||
<AppLink
|
||||
href={wsPaths.projectDetail(project.id)}
|
||||
className="inline-flex max-w-full items-center gap-1.5 text-foreground hover:underline"
|
||||
>
|
||||
<ProjectIcon project={project} size="md" />
|
||||
<span className="truncate">{project.title}</span>
|
||||
</AppLink>
|
||||
) : (
|
||||
<span className="text-muted-foreground">{t(($) => $.detail.project_unavailable)}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{autopilot.description && (
|
||||
<div className="col-span-2">
|
||||
<label className="text-xs text-muted-foreground">{t(($) => $.detail.field_prompt)}</label>
|
||||
@@ -796,7 +833,7 @@ export function AutopilotDetailPage({ autopilotId }: { autopilotId: string }) {
|
||||
<RunHistoryList
|
||||
runs={runs}
|
||||
agentId={autopilot.assignee_id}
|
||||
agentName={getActorName("agent", autopilot.assignee_id)}
|
||||
agentName={getActorName(autopilot.assignee_type, autopilot.assignee_id)}
|
||||
/>
|
||||
)}
|
||||
</section>
|
||||
@@ -828,6 +865,8 @@ export function AutopilotDetailPage({ autopilotId }: { autopilotId: string }) {
|
||||
initial={{
|
||||
title: autopilot.title,
|
||||
description: autopilot.description ?? "",
|
||||
project_id: autopilot.project_id ?? null,
|
||||
assignee_type: autopilot.assignee_type,
|
||||
assignee_id: autopilot.assignee_id,
|
||||
execution_mode: autopilot.execution_mode as AutopilotExecutionMode,
|
||||
}}
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
Clock,
|
||||
Copy,
|
||||
FilePlus2,
|
||||
FolderKanban,
|
||||
Maximize2,
|
||||
Minimize2,
|
||||
Play,
|
||||
@@ -37,7 +38,8 @@ import { TimeInput } from "@multica/ui/components/ui/time-input";
|
||||
import { TimezonePicker } from "./pickers/timezone-picker";
|
||||
import { useCurrentWorkspace } from "@multica/core/paths";
|
||||
import { useWorkspaceId } from "@multica/core/hooks";
|
||||
import { agentListOptions } from "@multica/core/workspace/queries";
|
||||
import { agentListOptions, squadListOptions } from "@multica/core/workspace/queries";
|
||||
import { projectListOptions } from "@multica/core/projects/queries";
|
||||
import {
|
||||
useCreateAutopilot,
|
||||
useCreateAutopilotTrigger,
|
||||
@@ -47,12 +49,15 @@ import {
|
||||
import { buildAutopilotWebhookUrl } from "@multica/core/autopilots";
|
||||
import { api } from "@multica/core/api";
|
||||
import type {
|
||||
AutopilotAssigneeType,
|
||||
AutopilotExecutionMode,
|
||||
AutopilotTrigger,
|
||||
} from "@multica/core/types";
|
||||
import { TitleEditor, ContentEditor } from "../../editor";
|
||||
import { ActorAvatar } from "../../common/actor-avatar";
|
||||
import { AgentPicker } from "./pickers/agent-picker";
|
||||
import { ProjectPicker } from "../../projects/components/project-picker";
|
||||
import { ProjectIcon } from "../../projects/components/project-icon";
|
||||
import { AgentPicker, type AssigneeSelection } from "./pickers/agent-picker";
|
||||
import {
|
||||
getDefaultTriggerConfig,
|
||||
getLocalTimezone,
|
||||
@@ -71,6 +76,8 @@ import { formatSchedulePartialFailureToast } from "./autopilot-dialog-toast";
|
||||
export interface AutopilotInitial {
|
||||
title: string;
|
||||
description: string;
|
||||
project_id: string | null;
|
||||
assignee_type: AutopilotAssigneeType;
|
||||
assignee_id: string;
|
||||
execution_mode: AutopilotExecutionMode;
|
||||
}
|
||||
@@ -242,6 +249,8 @@ export function AutopilotDialog(props: AutopilotDialogProps) {
|
||||
const workspaceName = useCurrentWorkspace()?.name;
|
||||
const wsId = useWorkspaceId();
|
||||
const { data: agents = [] } = useQuery(agentListOptions(wsId));
|
||||
const { data: squads = [] } = useQuery(squadListOptions(wsId));
|
||||
const { data: projects = [] } = useQuery(projectListOptions(wsId));
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
|
||||
const isCreate = props.mode === "create";
|
||||
@@ -251,6 +260,10 @@ export function AutopilotDialog(props: AutopilotDialogProps) {
|
||||
|
||||
const [title, setTitle] = useState(initial.title ?? "");
|
||||
const [description, setDescription] = useState(initial.description ?? "");
|
||||
const [projectId, setProjectId] = useState<string | null>(initial.project_id ?? null);
|
||||
const [assigneeType, setAssigneeType] = useState<AutopilotAssigneeType>(
|
||||
initial.assignee_type ?? "agent",
|
||||
);
|
||||
const [assigneeId, setAssigneeId] = useState<string>(initial.assignee_id ?? "");
|
||||
const [executionMode, setExecutionMode] = useState<AutopilotExecutionMode>(
|
||||
initial.execution_mode ?? "create_issue",
|
||||
@@ -296,11 +309,25 @@ export function AutopilotDialog(props: AutopilotDialogProps) {
|
||||
const triggerCount = isCreate ? 0 : props.triggers.length;
|
||||
const schedulePillDisabled = !isCreate && triggerCount >= 2;
|
||||
|
||||
const selectedAgent = useMemo(
|
||||
() => agents.find((a) => a.id === assigneeId) ?? null,
|
||||
[agents, assigneeId],
|
||||
const selectedAssignee = useMemo(() => {
|
||||
if (!assigneeId) return null;
|
||||
if (assigneeType === "squad") {
|
||||
const squad = squads.find((s) => s.id === assigneeId);
|
||||
return squad ? { name: squad.name, description: squad.description } : null;
|
||||
}
|
||||
const agent = agents.find((a) => a.id === assigneeId);
|
||||
return agent ? { name: agent.name, description: agent.description } : null;
|
||||
}, [agents, squads, assigneeId, assigneeType]);
|
||||
const selectedProject = useMemo(
|
||||
() => projects.find((project) => project.id === projectId) ?? null,
|
||||
[projects, projectId],
|
||||
);
|
||||
|
||||
const handleAssigneeChange = (next: AssigneeSelection) => {
|
||||
setAssigneeType(next.type);
|
||||
setAssigneeId(next.id);
|
||||
};
|
||||
|
||||
const createAutopilot = useCreateAutopilot();
|
||||
const createTrigger = useCreateAutopilotTrigger();
|
||||
const updateAutopilot = useUpdateAutopilot();
|
||||
@@ -324,6 +351,8 @@ export function AutopilotDialog(props: AutopilotDialogProps) {
|
||||
const autopilot = await createAutopilot.mutateAsync({
|
||||
title: title.trim(),
|
||||
description: description.trim() || undefined,
|
||||
project_id: executionMode === "create_issue" ? projectId : null,
|
||||
assignee_type: assigneeType,
|
||||
assignee_id: assigneeId,
|
||||
execution_mode: executionMode,
|
||||
});
|
||||
@@ -370,6 +399,8 @@ export function AutopilotDialog(props: AutopilotDialogProps) {
|
||||
id: props.autopilotId,
|
||||
title: title.trim(),
|
||||
description: description.trim() || null,
|
||||
project_id: executionMode === "create_issue" ? projectId : null,
|
||||
assignee_type: assigneeType,
|
||||
assignee_id: assigneeId,
|
||||
execution_mode: executionMode,
|
||||
});
|
||||
@@ -548,14 +579,23 @@ export function AutopilotDialog(props: AutopilotDialogProps) {
|
||||
{/* Right: Configuration */}
|
||||
<aside className="w-full lg:w-[340px] shrink-0 overflow-y-auto px-5 py-5 space-y-5 bg-muted/30">
|
||||
<AgentSection
|
||||
selectedType={assigneeType}
|
||||
selectedId={assigneeId}
|
||||
onChange={setAssigneeId}
|
||||
selectedName={selectedAgent?.name}
|
||||
selectedDescription={selectedAgent?.description}
|
||||
onChange={handleAssigneeChange}
|
||||
selectedName={selectedAssignee?.name}
|
||||
selectedDescription={selectedAssignee?.description}
|
||||
/>
|
||||
|
||||
<OutputModeSection mode={executionMode} onChange={setExecutionMode} />
|
||||
|
||||
{executionMode === "create_issue" && (
|
||||
<ProjectSection
|
||||
projectId={projectId}
|
||||
selectedProject={selectedProject}
|
||||
onChange={setProjectId}
|
||||
/>
|
||||
)}
|
||||
|
||||
{isCreate && (
|
||||
<TriggerKindSection kind={triggerKind} onChange={setTriggerKind} />
|
||||
)}
|
||||
@@ -618,22 +658,25 @@ function SectionLabel({ children }: { children: React.ReactNode }) {
|
||||
}
|
||||
|
||||
function AgentSection({
|
||||
selectedType,
|
||||
selectedId,
|
||||
onChange,
|
||||
selectedName,
|
||||
selectedDescription,
|
||||
}: {
|
||||
selectedType: AutopilotAssigneeType;
|
||||
selectedId: string;
|
||||
onChange: (id: string) => void;
|
||||
onChange: (next: AssigneeSelection) => void;
|
||||
selectedName?: string;
|
||||
selectedDescription?: string;
|
||||
}) {
|
||||
const { t } = useT("autopilots");
|
||||
const hasSelection = selectedId.length > 0;
|
||||
return (
|
||||
<div>
|
||||
<SectionLabel>{t(($) => $.dialog.section_agent)}</SectionLabel>
|
||||
<SectionLabel>{t(($) => $.dialog.section_assignee)}</SectionLabel>
|
||||
<AgentPicker
|
||||
agentId={selectedId || null}
|
||||
assignee={hasSelection ? { type: selectedType, id: selectedId } : null}
|
||||
onChange={onChange}
|
||||
align="start"
|
||||
triggerRender={
|
||||
@@ -644,12 +687,12 @@ function AgentSection({
|
||||
"hover:bg-accent/40 transition-colors cursor-pointer",
|
||||
)}
|
||||
>
|
||||
{selectedId ? (
|
||||
{hasSelection ? (
|
||||
<ActorAvatar
|
||||
actorType="agent"
|
||||
actorType={selectedType}
|
||||
actorId={selectedId}
|
||||
size={28}
|
||||
showStatusDot
|
||||
showStatusDot={selectedType === "agent"}
|
||||
/>
|
||||
) : (
|
||||
<span className="inline-flex size-7 items-center justify-center rounded-full bg-muted text-muted-foreground">
|
||||
@@ -658,7 +701,7 @@ function AgentSection({
|
||||
)}
|
||||
<span className="flex-1 min-w-0">
|
||||
<span className="block text-sm font-medium truncate">
|
||||
{selectedName ?? t(($) => $.dialog.select_agent)}
|
||||
{selectedName ?? t(($) => $.dialog.select_assignee)}
|
||||
</span>
|
||||
{selectedDescription && (
|
||||
<span className="block text-xs text-muted-foreground truncate">
|
||||
@@ -731,6 +774,49 @@ function OutputModeSection({
|
||||
);
|
||||
}
|
||||
|
||||
function ProjectSection({
|
||||
projectId,
|
||||
selectedProject,
|
||||
onChange,
|
||||
}: {
|
||||
projectId: string | null;
|
||||
selectedProject: { title: string; icon: string | null } | null;
|
||||
onChange: (projectId: string | null) => void;
|
||||
}) {
|
||||
const { t } = useT("autopilots");
|
||||
return (
|
||||
<div>
|
||||
<SectionLabel>{t(($) => $.dialog.section_project)}</SectionLabel>
|
||||
<ProjectPicker
|
||||
projectId={projectId}
|
||||
onUpdate={(updates) => onChange(updates.project_id ?? null)}
|
||||
align="start"
|
||||
triggerRender={
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"w-full flex items-center gap-2.5 rounded-md border bg-background px-3 py-2 text-left",
|
||||
"hover:bg-accent/40 transition-colors cursor-pointer",
|
||||
)}
|
||||
>
|
||||
{selectedProject ? (
|
||||
<ProjectIcon project={selectedProject} size="md" />
|
||||
) : (
|
||||
<span className="inline-flex size-5 items-center justify-center rounded-md bg-muted text-muted-foreground">
|
||||
<FolderKanban className="size-3.5" />
|
||||
</span>
|
||||
)}
|
||||
<span className="flex-1 min-w-0 truncate text-sm font-medium">
|
||||
{selectedProject?.title ?? t(($) => $.dialog.no_project)}
|
||||
</span>
|
||||
<ChevronDown className="size-3.5 text-muted-foreground shrink-0" />
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ScheduleSection({
|
||||
config,
|
||||
onChange,
|
||||
|
||||
@@ -146,11 +146,17 @@ function AutopilotRow({ autopilot }: { autopilot: Autopilot }) {
|
||||
</AppLink>
|
||||
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-x-3 gap-y-1 pl-6 text-xs sm:contents sm:pl-0">
|
||||
{/* Agent */}
|
||||
{/* Assignee — agent or squad */}
|
||||
<span className="flex min-w-0 items-center gap-1.5 text-muted-foreground sm:w-32 sm:shrink-0">
|
||||
<ActorAvatar actorType="agent" actorId={autopilot.assignee_id} size={18} enableHoverCard showStatusDot />
|
||||
<ActorAvatar
|
||||
actorType={autopilot.assignee_type}
|
||||
actorId={autopilot.assignee_id}
|
||||
size={18}
|
||||
enableHoverCard={autopilot.assignee_type === "agent"}
|
||||
showStatusDot={autopilot.assignee_type === "agent"}
|
||||
/>
|
||||
<span className="truncate">
|
||||
{getActorName("agent", autopilot.assignee_id)}
|
||||
{getActorName(autopilot.assignee_type, autopilot.assignee_id)}
|
||||
</span>
|
||||
</span>
|
||||
|
||||
|
||||
@@ -1,28 +1,35 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Bot } from "lucide-react";
|
||||
import { useWorkspaceId } from "@multica/core/hooks";
|
||||
import { agentListOptions } from "@multica/core/workspace/queries";
|
||||
import { agentListOptions, squadListOptions } from "@multica/core/workspace/queries";
|
||||
import type { AutopilotAssigneeType } from "@multica/core/types";
|
||||
import { ActorAvatar } from "../../../common/actor-avatar";
|
||||
import {
|
||||
PropertyPicker,
|
||||
PickerItem,
|
||||
PickerSection,
|
||||
PickerEmpty,
|
||||
} from "../../../issues/components/pickers/property-picker";
|
||||
import { useT } from "../../../i18n";
|
||||
import { matchesPinyin } from "../../../editor/extensions/pinyin-match";
|
||||
|
||||
export interface AssigneeSelection {
|
||||
type: AutopilotAssigneeType;
|
||||
id: string;
|
||||
}
|
||||
|
||||
export function AgentPicker({
|
||||
agentId,
|
||||
assignee,
|
||||
onChange,
|
||||
trigger: customTrigger,
|
||||
triggerRender,
|
||||
align = "start",
|
||||
}: {
|
||||
agentId: string | null;
|
||||
onChange: (id: string) => void;
|
||||
assignee: AssigneeSelection | null;
|
||||
onChange: (next: AssigneeSelection) => void;
|
||||
trigger?: React.ReactNode;
|
||||
triggerRender?: React.ReactElement;
|
||||
align?: "start" | "center" | "end";
|
||||
@@ -32,13 +39,30 @@ export function AgentPicker({
|
||||
const [open, setOpen] = useState(false);
|
||||
const [filter, setFilter] = useState("");
|
||||
const { data: agents = [] } = useQuery(agentListOptions(wsId));
|
||||
const active = agents.filter((a) => !a.archived_at);
|
||||
const selected = active.find((a) => a.id === agentId);
|
||||
const { data: squads = [] } = useQuery(squadListOptions(wsId));
|
||||
|
||||
const activeAgents = useMemo(() => agents.filter((a) => !a.archived_at), [agents]);
|
||||
const activeSquads = useMemo(() => squads.filter((s) => !s.archived_at), [squads]);
|
||||
|
||||
const selectedAgent =
|
||||
assignee?.type === "agent" ? activeAgents.find((a) => a.id === assignee.id) : undefined;
|
||||
const selectedSquad =
|
||||
assignee?.type === "squad" ? activeSquads.find((s) => s.id === assignee.id) : undefined;
|
||||
const selectedName = selectedAgent?.name ?? selectedSquad?.name;
|
||||
|
||||
const query = filter.trim().toLowerCase();
|
||||
const filteredAgents = query
|
||||
? active.filter((a) => a.name.toLowerCase().includes(query) || matchesPinyin(a.name, query))
|
||||
: active;
|
||||
const matches = (name: string) =>
|
||||
!query || name.toLowerCase().includes(query) || matchesPinyin(name, query);
|
||||
const filteredAgents = activeAgents.filter((a) => matches(a.name));
|
||||
const filteredSquads = activeSquads.filter((s) => matches(s.name));
|
||||
|
||||
const isSelected = (type: AutopilotAssigneeType, id: string) =>
|
||||
assignee?.type === type && assignee?.id === id;
|
||||
|
||||
const handlePick = (type: AutopilotAssigneeType, id: string) => {
|
||||
onChange({ type, id });
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<PropertyPicker
|
||||
@@ -53,37 +77,59 @@ export function AgentPicker({
|
||||
trigger={
|
||||
customTrigger ?? (
|
||||
<>
|
||||
{selected ? (
|
||||
{assignee && (selectedAgent || selectedSquad) ? (
|
||||
<>
|
||||
<ActorAvatar actorType="agent" actorId={selected.id} size={16} showStatusDot />
|
||||
<span className="truncate">{selected.name}</span>
|
||||
<ActorAvatar
|
||||
actorType={assignee.type}
|
||||
actorId={assignee.id}
|
||||
size={16}
|
||||
showStatusDot={assignee.type === "agent"}
|
||||
/>
|
||||
<span className="truncate">{selectedName}</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Bot className="size-3" />
|
||||
<span>{t(($) => $.agent_picker.select_agent)}</span>
|
||||
<span>{t(($) => $.agent_picker.select_assignee)}</span>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
>
|
||||
{filteredAgents.length === 0 ? (
|
||||
{filteredAgents.length === 0 && filteredSquads.length === 0 ? (
|
||||
<PickerEmpty />
|
||||
) : (
|
||||
filteredAgents.map((a) => (
|
||||
<PickerItem
|
||||
key={a.id}
|
||||
selected={a.id === agentId}
|
||||
onClick={() => {
|
||||
onChange(a.id);
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
<ActorAvatar actorType="agent" actorId={a.id} size={16} showStatusDot />
|
||||
<span className="truncate">{a.name}</span>
|
||||
</PickerItem>
|
||||
))
|
||||
<>
|
||||
{filteredAgents.length > 0 && (
|
||||
<PickerSection label={t(($) => $.agent_picker.agents_group)}>
|
||||
{filteredAgents.map((a) => (
|
||||
<PickerItem
|
||||
key={a.id}
|
||||
selected={isSelected("agent", a.id)}
|
||||
onClick={() => handlePick("agent", a.id)}
|
||||
>
|
||||
<ActorAvatar actorType="agent" actorId={a.id} size={16} showStatusDot />
|
||||
<span className="truncate">{a.name}</span>
|
||||
</PickerItem>
|
||||
))}
|
||||
</PickerSection>
|
||||
)}
|
||||
{filteredSquads.length > 0 && (
|
||||
<PickerSection label={t(($) => $.agent_picker.squads_group)}>
|
||||
{filteredSquads.map((s) => (
|
||||
<PickerItem
|
||||
key={s.id}
|
||||
selected={isSelected("squad", s.id)}
|
||||
onClick={() => handlePick("squad", s.id)}
|
||||
>
|
||||
<ActorAvatar actorType="squad" actorId={s.id} size={16} />
|
||||
<span className="truncate">{s.name}</span>
|
||||
</PickerItem>
|
||||
))}
|
||||
</PickerSection>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</PropertyPicker>
|
||||
);
|
||||
|
||||
@@ -19,7 +19,7 @@ import {
|
||||
import { ChevronRight, ChevronDown, Brain, AlertCircle, AlertTriangle, Copy } from "lucide-react";
|
||||
import { useScrollFade } from "@multica/ui/hooks/use-scroll-fade";
|
||||
import { useAutoScroll } from "@multica/ui/hooks/use-auto-scroll";
|
||||
import { taskMessagesOptions } from "@multica/core/chat/queries";
|
||||
import { isTaskMessageTaskId, taskMessagesOptions } from "@multica/core/chat/queries";
|
||||
import { Markdown } from "@multica/views/common/markdown";
|
||||
import { copyMarkdown } from "../../editor";
|
||||
import { AttachmentList } from "../../issues/components/comment-card";
|
||||
@@ -67,9 +67,10 @@ export function ChatMessageList({
|
||||
// Live timeline for the in-flight task. useRealtimeSync keeps this cache
|
||||
// current via setQueryData on task:message events.
|
||||
const showLiveTimeline = !!pendingTaskId && !pendingAlreadyPersisted;
|
||||
const canFetchLiveTimeline = isTaskMessageTaskId(pendingTaskId) && !pendingAlreadyPersisted;
|
||||
const { data: liveTaskMessages } = useQuery({
|
||||
...taskMessagesOptions(pendingTaskId ?? ""),
|
||||
enabled: showLiveTimeline,
|
||||
enabled: canFetchLiveTimeline,
|
||||
});
|
||||
const liveTimeline: ChatTimelineItem[] = (liveTaskMessages ?? []).map(toTimelineItem);
|
||||
const hasLive = showLiveTimeline && liveTimeline.length > 0;
|
||||
@@ -179,13 +180,14 @@ function AssistantMessage({
|
||||
isPending: boolean;
|
||||
}) {
|
||||
const taskId = message.task_id;
|
||||
const canFetchTaskMessages = isTaskMessageTaskId(taskId);
|
||||
|
||||
// Use the shared taskMessagesOptions so this cache entry is the same one
|
||||
// seeded by useRealtimeSync during task execution — zero refetch when the
|
||||
// task finishes, since WS already populated it.
|
||||
const { data: taskMessages } = useQuery({
|
||||
...taskMessagesOptions(taskId ?? ""),
|
||||
enabled: !!taskId,
|
||||
enabled: canFetchTaskMessages,
|
||||
});
|
||||
|
||||
const timeline: ChatTimelineItem[] = (taskMessages ?? []).map(toTimelineItem);
|
||||
@@ -621,4 +623,3 @@ function ErrorRow({ item }: { item: ChatTimelineItem }) {
|
||||
}
|
||||
|
||||
// ─── Shared ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -8,10 +8,10 @@ import {
|
||||
SelectValue,
|
||||
} from "@multica/ui/components/ui/select";
|
||||
|
||||
// Common IANA zones surfaced as quick picks. Used as the fallback option set
|
||||
// when Intl.supportedValuesOf is not available, and promoted to the top of
|
||||
// the list when it is.
|
||||
const COMMON_TIMEZONES = [
|
||||
// Curated fallback list used when the runtime lacks `Intl.supportedValuesOf`.
|
||||
// Exported so every timezone picker draws from one source instead of
|
||||
// drifting copies.
|
||||
export const COMMON_TIMEZONES = [
|
||||
"UTC",
|
||||
"America/Los_Angeles",
|
||||
"America/Denver",
|
||||
@@ -33,13 +33,25 @@ const COMMON_TIMEZONES = [
|
||||
"Pacific/Auckland",
|
||||
];
|
||||
|
||||
let cachedBrowserTZ: string | null = null;
|
||||
export function browserTimezone(): string {
|
||||
if (cachedBrowserTZ !== null) return cachedBrowserTZ;
|
||||
try {
|
||||
const tz = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||
return tz || "UTC";
|
||||
cachedBrowserTZ = Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC";
|
||||
} catch {
|
||||
return "UTC";
|
||||
cachedBrowserTZ = "UTC";
|
||||
}
|
||||
return cachedBrowserTZ;
|
||||
}
|
||||
|
||||
// Clears the module-level browserTimezone() cache. Browser code never
|
||||
// needs this — the tz is stable for a session — but the cache survives
|
||||
// across Vitest files in the same worker, so any test that stubs
|
||||
// `Intl.DateTimeFormat` (directly or via a fake timezone) MUST call this
|
||||
// in `beforeEach`, otherwise a value cached by an earlier suite leaks in.
|
||||
// Tests that mock the whole `./timezone-select` module are unaffected.
|
||||
export function resetBrowserTimezoneCache(): void {
|
||||
cachedBrowserTZ = null;
|
||||
}
|
||||
|
||||
type IntlWithSupportedValues = typeof Intl & {
|
||||
@@ -64,10 +76,6 @@ export function timezoneOptions(current: string): string[] {
|
||||
).filter(Boolean);
|
||||
}
|
||||
|
||||
// Shared single-select timezone picker. Surfaces the browser-resolved zone
|
||||
// with a translated suffix (passed in by the caller — the picker itself stays
|
||||
// i18n-namespace agnostic), followed by a curated set of common IANA zones
|
||||
// and everything Intl.supportedValuesOf exposes.
|
||||
export function TimezoneSelect({
|
||||
value,
|
||||
onValueChange,
|
||||
|
||||
67
packages/views/common/use-viewing-timezone.test.ts
Normal file
67
packages/views/common/use-viewing-timezone.test.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { renderHook } from "@testing-library/react";
|
||||
|
||||
const userRef = vi.hoisted(
|
||||
() => ({ current: null as { timezone?: string | null } | null }),
|
||||
);
|
||||
|
||||
vi.mock("@multica/core/auth", () => {
|
||||
type AuthState = { user: typeof userRef.current };
|
||||
const useAuthStore = Object.assign(
|
||||
(sel: (s: AuthState) => unknown) => sel({ user: userRef.current }),
|
||||
{ getState: () => ({ user: userRef.current }) },
|
||||
);
|
||||
return { useAuthStore };
|
||||
});
|
||||
|
||||
vi.mock("./timezone-select", () => ({
|
||||
browserTimezone: () => "America/Chicago",
|
||||
}));
|
||||
|
||||
import { useViewingTimezone } from "./use-viewing-timezone";
|
||||
|
||||
describe("useViewingTimezone", () => {
|
||||
beforeEach(() => {
|
||||
userRef.current = null;
|
||||
});
|
||||
|
||||
it("returns the stored preference when the user pinned one", () => {
|
||||
userRef.current = { timezone: "Asia/Tokyo" };
|
||||
const { result } = renderHook(() => useViewingTimezone());
|
||||
expect(result.current).toBe("Asia/Tokyo");
|
||||
});
|
||||
|
||||
it("falls back to the browser tz when there is no user", () => {
|
||||
userRef.current = null;
|
||||
const { result } = renderHook(() => useViewingTimezone());
|
||||
expect(result.current).toBe("America/Chicago");
|
||||
});
|
||||
|
||||
it("falls back to the browser tz when timezone is null", () => {
|
||||
userRef.current = { timezone: null };
|
||||
const { result } = renderHook(() => useViewingTimezone());
|
||||
expect(result.current).toBe("America/Chicago");
|
||||
});
|
||||
|
||||
it("falls back to the browser tz when timezone is blank", () => {
|
||||
userRef.current = { timezone: " " };
|
||||
const { result } = renderHook(() => useViewingTimezone());
|
||||
expect(result.current).toBe("America/Chicago");
|
||||
});
|
||||
|
||||
// The preferences clear-flow PATCHes timezone: "" and the server may echo
|
||||
// the empty string back before normalising it to null. The hook must
|
||||
// treat "" as "no preference" and fall back to the browser tz.
|
||||
it("falls back to the browser tz when timezone is an empty string", () => {
|
||||
userRef.current = { timezone: "" };
|
||||
const { result } = renderHook(() => useViewingTimezone());
|
||||
expect(result.current).toBe("America/Chicago");
|
||||
});
|
||||
|
||||
// Auth store still initialising: user is undefined, not null.
|
||||
it("falls back to the browser tz when the user is undefined", () => {
|
||||
userRef.current = undefined as never;
|
||||
const { result } = renderHook(() => useViewingTimezone());
|
||||
expect(result.current).toBe("America/Chicago");
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user