Compare commits

...

69 Commits

Author SHA1 Message Date
yushen
cabdc3c2cf refactor: rename nodeId param to instanceId and update test values
- client.ts: deleteCloudRuntimeNode(nodeId) → deleteCloudRuntimeNode(instanceId)
- cloud-runtime.ts: hook mutationFn param nodeId → instanceId
- client.test.ts: description says 'instance id', test value is i-0123456789abcdef0

Co-authored-by: multica-agent <github@multica.ai>
2026-05-21 18:52:24 +08:00
yushen
710406d2ab fix(api): use instance_id in deleteCloudRuntimeNode body
Fleet API requires instance_id, not id. Fixes 'instance_id is required' error.

MUL-2510

Co-authored-by: multica-agent <github@multica.ai>
2026-05-21 18:52:24 +08:00
Bohan Jiang
cb90249eac test(issues): match new metadata trigger label with count suffix (#3011)
The sidebar metadata trigger now reads "Metadata · N" (#3010), so the
exact-name button query stopped matching and 2 tests went red on main.
Relax the assertion to `/^Metadata\b/` — still anchors on the label but
tolerates the count suffix.
2026-05-21 18:25:36 +08:00
Multica Eve
af13d7ad3a docs: add v0.3.5 changelog (#3006)
* docs: add v0.3.5 changelog

Co-authored-by: multica-agent <github@multica.ai>

* docs: revise v0.3.5 changelog

Co-authored-by: multica-agent <github@multica.ai>

* docs: refine v0.3.5 changelog title

Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: Eve <eve@multica-ai.local>
Co-authored-by: multica-agent <github@multica.ai>
2026-05-21 18:09:29 +08:00
Bohan Jiang
cbd42dfcc4 feat(issues): align metadata trigger with sibling sections (#3010)
Reshape the sidebar metadata trigger so it visually matches the Pull
requests / Details / Parent issue headers (muted "Metadata · N" row
instead of an icon+label button). Clicking still opens the existing
JSON dialog — folding the bag inline pushed the rest of the sidebar
down too much when the payload was large.
2026-05-21 17:57:27 +08:00
LinYushen
adec90c621 MUL-2510 feat: add delete button to fleet nodes list (#3007)
* feat: add delete button to fleet nodes list

- Add deleteCloudRuntimeNode method to API client (DELETE /api/cloud-runtime/nodes/:nodeId)
- Add useDeleteCloudRuntimeNode mutation hook in cloud-runtime.ts
- Add delete button with Trash2 icon to CloudRuntimeNodeRow component
- Include confirmation dialog, loading state, and toast notifications
- Add i18n keys for en and zh-Hans locales

Co-authored-by: multica-agent <github@multica.ai>

* fix(api): correct deleteCloudRuntimeNode contract to match server

- Change from DELETE /api/cloud-runtime/nodes/:nodeId (no body) to
  DELETE /api/cloud-runtime/nodes with JSON body { id: nodeId }
- Use fetchRaw + Content-Type header to match server's withBody proxy
- Add contract test verifying URL, method, body, and Content-Type

Fixes review feedback on MUL-2510

Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: multica-agent <github@multica.ai>
2026-05-21 17:46:26 +08:00
Bohan Jiang
ae530ef057 docs(runtime): tighten issue-metadata write bar (MUL-2507) (#3004)
The previous wording invited agents to pin too much: any opened PR,
external link, or "fact future agents will want one-glance access to"
was framed as worth writing, with no explicit upper bound. In practice
this caused metadata bags to accumulate single-run details and
description-summary noise instead of the small set of repeatedly-read
values the feature was designed for.

Rework the agent runtime brief and the CLI docs to lead with the bar:
write a key only when it is materially important AND likely to be
re-read by future runs on the same issue. "Most runs write zero new
keys" is now stated as the expected case, and the workflow exit step
is rewritten to mirror the same gate. Recommended-key list, safety
boundaries, and stale-key cleanup are preserved so the locked-in test
anchors still pass.

Co-authored-by: multica-agent <github@multica.ai>
2026-05-21 17:20:43 +08:00
Bohan Jiang
ab0228c2a1 feat(issues): collapse long metadata bags in sidebar MUL-2503 (#3003)
* feat(issues): collapse long metadata bags in sidebar (MUL-2503)

The metadata KV strip rendered every key inline, so issues with many
pinned keys pushed the rest of the sidebar far down. Keep the first
four rows visible and tuck the remainder behind a Show N more / Show
less toggle once the bag reaches five keys, mirroring the PR list
collapse rule.

Co-authored-by: multica-agent <github@multica.ai>

* refactor(issues): hide metadata behind a JSON dialog (MUL-2503)

Metadata is an agent-facing free-form KV bag — the values almost never
mean anything to a human reader, and every property humans actually care
about already has a dedicated sidebar field (status, priority, assignee,
etc.). Rendering the first four keys inline still pushed real signal
down and added visual noise for no benefit, so drop the inline strip
entirely.

Replace the section with a small `{ }` Metadata button at the bottom of
the sidebar that opens a Dialog showing the formatted JSON. The button
hides itself when the bag is empty, so the common case stays completely
quiet. Removes the prior collapse threshold (and its `Show N more` /
`Show less` strings) since there is nothing to collapse anymore.

Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: multica-agent <github@multica.ai>
2026-05-21 17:18:57 +08:00
LinYushen
e288eff2c5 feat: server auto-generates PAT for cloud runtime bootstrap (#3002)
When bootstrap is enabled and no PAT is available from the request
header or Authorization bearer token, the server now generates a new
PAT automatically and forwards it to the cloud service.

This removes the need for the frontend to pass X-User-PAT — the
server handles it entirely.
2026-05-21 17:07:44 +08:00
YOMXXX
29c2a5d18f fix(daemon): reclaim stale dispatched claims (MUL-2485) (#2872)
* fix(daemon): reclaim stale dispatched claims

* fix(daemon): widen stale claim reclaim window
2026-05-21 17:06:55 +08:00
Tom Qiao
81e8aa5812 test(core): add unit tests for reserved-slugs (#2985)
Co-authored-by: Tom Qiao <tomqiaozc@users.noreply.github.com>
Co-authored-by: Claude Opus 4 <noreply@anthropic.com>
2026-05-21 16:54:45 +08:00
Bohan Jiang
0c767c0052 feat(issues): per-issue metadata KV (MUL-2017) (#2845)
* feat(issues): per-issue metadata KV (MUL-2017)

Adds a small JSONB KV map to every issue for agent pipeline state (attempts,
PR number, pipeline status, ...). Keys match a narrow regex, values are
primitives (string / number / bool), capped at 50 keys per issue and 8KB
per blob. Defense-in-depth via two CHECK constraints (object shape + size).

All mutations are single-key atomic (jsonb_set / `- key`). `UpdateIssue`
intentionally does NOT touch metadata: a whole-blob overwrite would race
with concurrent agent writes.

  GET    /api/issues/:id/metadata
  PUT    /api/issues/:id/metadata/:key   body: { "value": <primitive> }
  DELETE /api/issues/:id/metadata/:key

Containment filter on list: GET /api/issues?metadata=<json-object> uses
PG `@>` against a `jsonb_path_ops` GIN index. Mirrored across ListIssues,
CountIssues, ListOpenIssues, and the hand-rolled ListGroupedIssues SQL so
CLI/API and UI grouped views stay consistent.

CLI: multica issue metadata {list,get,set,delete}
  multica issue list --metadata key=value (repeatable, AND)
  set has --type to override the default value-sniffing
Co-authored-by: multica-agent <github@multica.ai>

* fix(issues): metadata test bugs + wire realtime + read-only display (MUL-2017)

- Fix two failing handler tests blocking backend CI:
  - reset decode target after delete so map merge does not mask removal
  - url.PathEscape the key segment so spaces no longer panic NewRequest
- Wire issue_metadata:changed end to end so the detail / list / my-issues
  caches stay in sync with set/delete events (other tabs, CLI writes).
- Add a read-only Metadata strip to the issue detail sidebar; hidden when
  the issue has no keys so it stays quiet in the common case.

Co-authored-by: multica-agent <github@multica.ai>

* feat(runtime): teach agents to read/write issue metadata (MUL-2017)

Add an `## Issue Metadata` section to the runtime brief plus a
`metadata list` step on entry and a `metadata set`/`delete` step on
exit. Section only emits when the task carries an issue id (comment- or
assignment-triggered); chat / quick-create / run-only autopilot stay
clean so they don't fire failing CLI calls.

Co-authored-by: multica-agent <github@multica.ai>

* fix(issues): bump metadata migration to 105 and drop attempts as example (MUL-2017)

main is now at 104_drop_runtime_timezone; the migrator picks
LatestVersion() by sorted filename, so a slot before the tail would
let DBs that have already run 099–104 think they're up-to-date while
the issue.metadata column is missing — runtime would then fail with
column does not exist. Renumbering to 105 puts the migration at the
tail and forces it to run.

Also drop attempts as a positive example across docs/code comments and
test fixtures — the runtime instruction prompt already lists it under
"What NOT to pin" (runtime bookkeeping). Replace with pr_number, which
is in the recommended-keys set, so docs/tests speak the same language
as the prompt.

Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: multica-agent <github@multica.ai>
2026-05-21 16:35:45 +08:00
Multica Eve
66c0464140 fix: simplify cloud runtime create form (#3000)
Co-authored-by: Eve <eve@multica-ai.local>
Co-authored-by: multica-agent <github@multica.ai>
2026-05-21 16:34:11 +08:00
Bohan Jiang
9a5d8a52f3 fix(timezone): harden hourly-rollup rollout against straight-through migrate MUL-2488 (#2998)
* fix(timezone): harden hourly-rollup rollout against straight-through migrate

MUL-2488

PR #2968 introduced the new task_usage_hourly rollup but assumed operators
would stop migrate between 102 and 103 to run the one-shot
cmd/backfill_task_usage_hourly. Two pieces made that unsafe in practice:

1. The Dockerfile only shipped server / multica / migrate, so a deployed
   container has no backfill binary to run between phases.
2. cmd/migrate has no per-version stop, and entrypoint.sh runs `migrate up`
   to the latest version, so 103 silently drops the legacy daily rollups
   even when nobody ran the backfill — leaving usage dashboards at zero
   despite source data being intact in task_usage.

Changes:

- Build cmd/backfill_task_usage_hourly into the runtime image alongside
  the other binaries so operators can `docker exec` the backfill instead
  of needing a source checkout.
- Add a fail-closed plpgsql guard at the top of migration 103 that
  aborts the migration when task_usage has rows but task_usage_hourly is
  empty. Fresh databases (no task_usage rows) are exempt because the new
  triggers from 102 will populate the hourly table on the first event.

Already-applied databases are unaffected — schema_migrations tracks by
version only, so 103 is not re-run.

Co-authored-by: multica-agent <github@multica.ai>

* fix(timezone): use watermark coverage for hourly-rollup guard

The previous check only required `task_usage_hourly` to be non-empty,
which an interrupted backfill or a manual `rollup_task_usage_hourly_window`
call both satisfy. The completion signal we actually trust is
`task_usage_hourly_rollup_state.watermark_at` — backfill only stamps it
to `now() - 5 min` after every monthly slice succeeded, and the cron
worker only advances it on a real tick. Default after migration 101 is
`1970-01-01`, so an unrun or partial backfill is trivially detected.

Also corrects the comment about fresh-install behavior: the triggers in
102 only enqueue dirty keys for agent_task_queue / issue / task_usage
DELETE — they do not write hourly rows. INSERT/UPDATE flows through the
`updated_at` watermark window of `rollup_task_usage_hourly()`, which
only runs once the operator registers it as a pg_cron job.

Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: multica-agent <github@multica.ai>
2026-05-21 16:26:42 +08:00
Multica Eve
51b3c5291f feat: add env-gated cloud runtime launcher (MUL-2453) (#2995)
* feat: add env-gated cloud runtime launcher

Co-authored-by: multica-agent <github@multica.ai>

* fix: address cloud runtime frontend nits

Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: Eve <eve@multica-ai.local>
Co-authored-by: multica-agent <github@multica.ai>
2026-05-21 15:41:31 +08:00
Bohan Jiang
51c6e90363 docs: finish /projects link fix + tidy AWS_ENDPOINT_URL description (#2996)
Followup to #2979. One missed /issues → /projects link in agents.mdx
plus two AWS_ENDPOINT_URL row nits (URL/URLs repetition and trailing
period) in SELF_HOSTING_ADVANCED.md and the Chinese self-hosting page.

MUL-2498

Co-authored-by: multica-agent <github@multica.ai>
2026-05-21 15:35:39 +08:00
YYClaw
614dfae884 MUL-2488 feat(timezone): Scheduling / Viewing two-layer timezone architecture (#2968)
* docs(timezone): add scheduling/viewing timezone architecture RFC

* feat(db): replace daily rollups with task_usage_hourly, add user.timezone

Migrations 100-104: add "user".timezone (Viewing tz), build the UTC
hourly task_usage_hourly rollup with its pipeline, drop the legacy
task_usage_daily / task_usage_dashboard_daily pipelines, and drop the
agent_runtime.timezone column. Report queries now slice day boundaries
at read time by the caller-supplied @tz instead of materialising in a
fixed tz. Regenerate sqlc.

* feat(server): add task_usage_hourly backfill command

Replace the two legacy backfill commands (daily / dashboard_daily) with
a single backfill_task_usage_hourly that loads historical task_usage
into the new UTC hourly rollup, sliced per workspace.

* refactor(server): resolve viewing timezone in report handlers

Report handlers resolve the Viewing tz per request (?tz query param,
then user.timezone, then UTC) and pass it to the hourly-rollup queries.
Drop the UseDailyRollup feature flags and the old raw-scan/daily-rollup
dual paths, remove the /api/usage endpoints, and stop the daemon from
reporting and the runtime handler from accepting host timezone.

* refactor(core): switch report queries to viewing timezone

API client and dashboard/runtime queries send ?tz with each report
request, the user schema/types carry the new timezone field, and the
runtime timezone field/mutation is removed.

* feat(views): add viewing timezone preference and UI

Add the useViewingTimezone hook and a Timezone setting in Preferences;
report charts and the dashboard week boundary follow the viewer tz.
Remove the runtime detail timezone editor and its locale strings.

* fix(test): update fixtures and stabilize tests for timezone refactor

The timezone architecture refactor changed several types without
updating dependent test code:

- RuntimeDevice no longer has a timezone field — drop it from the
  create-agent-dialog runtime fixture.
- User now requires a timezone field — add it to the apps/web mockUser
  fixture.
- The PreferencesTab timezone tests asserted on the async save handler
  (PATCH then store update) with a bare expect, racing the mutation's
  settle callback, and timed out querying the Select's ~600-option IANA
  list on a loaded CI runner. Wrap the assertions in waitFor and extend
  the timeout for those three tests.

* docs(timezone): document self-host migration order and trigger invariant

Add a SELF-HOST UPGRADE ORDER runbook to the backfill command's package
comment: applying migrations 100-104 in a single migrate-up drops the
legacy daily rollups before the hourly backfill runs, leaving dashboards
empty until cron catches up.

Add an INVARIANT comment on trg_atq_dirty_hourly noting that agent_id
must be added to the trigger's OF list if it ever becomes mutable,
otherwise dirty buckets for the old agent_id are silently missed.

* style(runtimes): drop trailing blank line in runtime-detail
2026-05-21 15:33:47 +08:00
Tom Qiao
d0666138ec docs: fix broken anchor links and truncated env-var description (#2979)
Three docs issues spotted while reading:
- agents.mdx and agents.zh.mdx: [project](/issues) -> [project](/projects)
- cloud-quickstart.mdx: troubleshooting anchor #daemon-cant-reach-the-server
  did not exist; the heading is "Daemon can't connect to the server"
- SELF_HOSTING_ADVANCED.md and getting-started/self-hosting.zh.mdx:
  AWS_ENDPOINT_URL row description was truncated; append " URLs."

Co-authored-by: Tom Qiao <tomqiaozc@users.noreply.github.com>
2026-05-21 15:32:58 +08:00
Multica Eve
41cb91abd9 feat: add cloud runtime fleet proxy API (MUL-2453) (#2986)
* feat: add cloud runtime fleet proxy API

Co-authored-by: multica-agent <github@multica.ai>

* test: cover cloud runtime handler nits

Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: Eve <eve@multica-ai.local>
Co-authored-by: multica-agent <github@multica.ai>
2026-05-21 15:06:10 +08:00
Bohan Jiang
1c892aa3f9 fix(projects): default project view to compact (#2975)
The compact view was the original list layout and is what users expect
on this page; the post-#2840 default of comfortable changed long-standing
behavior. Reset the unpersisted default (and the cross-workspace fallback
in `merge`) back to compact. Updates the view-store tests accordingly.

MUL-2464

Co-authored-by: multica-agent <github@multica.ai>
2026-05-21 14:07:40 +08:00
Anderson Shindy Oki
65feb890b8 feat: Add project list responsive compact and comfortable views (MUL-2464) (#2840)
* feat: Add project screen compact and comfortable views

* wip

* i18n

* refactor and add search

* refactor
2026-05-21 13:56:11 +08:00
兰之
7e55813460 fix(ui): show tooltip when create-issue button is disabled due to empty title (#2943)
Co-Authored-By: Xiaomi MiMo V2.5 Pro
2026-05-21 13:43:31 +08:00
Bohan Jiang
7f9e4e829d feat(comments): thread-internal --tail pagination + reply cursor (MUL-2421) (#2846)
* feat(comments): thread-internal pagination via --tail + reply cursor (MUL-2421)

Long threads inside a single issue still forced agents to read every reply
once they used --thread, even after MUL-2387 fixed cross-thread noise. This
adds reply-level paging so a 200-reply thread can be navigated tail-first
without dragging the whole conversation into prompt context.

- New SQL query ListThreadCommentsForIssuePaged: same recursive root walk
  as the legacy thread query, but caps reply count and supports an
  (created_at, id) composite cursor. Root is unconditional — even tail=0
  emits it so the reader keeps the "what is this thread about" context.
- Handler ListComments: parses `tail` (non-negative, ThreadTailSet flag
  preserves the tail=0 intent), threads it through to the paged query,
  and re-uses X-Multica-Next-Before / X-Multica-Next-Before-Id for the
  reply cursor. Cursor's meaning is now context-dependent: thread cursor
  under --recent, reply cursor under --thread + --tail.
- CLI: new --tail flag (only valid with --thread; mutually exclusive
  with --recent), reply-cursor semantics for --before / --before-id when
  paired with --thread + --tail, stderr label flips to "Next reply cursor"
  so an operator copy-pasting the cursor knows which scope it scrolls.
- Tests cover the new contract: tail=N keeps newest N + root, tail=0 is
  root-only, anchor on a nested reply still walks up, reply cursor
  scrolls older replies page-by-page, since combined with tail filters
  after the cut, and the negative-flag-combination matrix.

Out of scope: prompt template update to hint at `--thread <id> --tail 30`
on long threads — separate follow-up per the issue.

Co-authored-by: multica-agent <github@multica.ai>

* fix(comments): only emit reply cursor when older reply exists (MUL-2421)

The thread-tail path emitted `X-Multica-Next-Before` whenever the page
filled to exactly the requested reply count, even when there was nothing
older to scroll to. So `--thread <root> --tail 3` on a thread with
exactly 3 replies sent a cursor that, when followed, returned just the
root — a wasted round-trip that surfaced as a phantom "older replies"
affordance in the agent prompt.

Switch to a `reply_limit + 1` probe: ask the SQL for one extra row, trim
the oldest overflow before responding, and only emit the cursor when an
older reply actually existed. The exact-boundary case (replyCount ==
tail with no overflow) now returns no cursor.

Also documents `--thread/--tail/--recent/--before` and the cursor
semantics in CLI_AND_DAEMON.md, which was the second must-fix in the
MUL-2421 review.

Co-authored-by: multica-agent <github@multica.ai>

* fix(comments): suppress reply cursor when --since covers older replies (MUL-2421)

In the thread + tail + since path the server still emitted a reply cursor
whenever there was an older reply on disk, regardless of `since`. If the
oldest retained reply on the page was already `<= since`, every older
reply was guaranteed to be filtered out too, so the next page only ever
returned the root — wasting round-trips until the agent walked the whole
pre-`since` history. Mirror the recent + since suppression: when
`replies[0].CreatedAt <= since`, drop the cursor.

Test covers the exact case from Elon's review: tail=2 overflow, body
keeps a fresher reply, but the cursor target (oldest retained reply) is
already past `since` — header must be empty.

Co-authored-by: multica-agent <github@multica.ai>

* feat(prompt): default comment-trigger reads to --thread --tail 30 (MUL-2421)

Comment-triggered agents previously defaulted the trigger-thread read to
the unbounded `--thread <id> --output json`, which dumps the full thread
into the prompt — exactly the kind of context bloat MUL-2387 fixed at the
cross-thread layer but never bounded inside a single thread.

Use the new `--tail` flag landed earlier in this PR (server + CLI) as the
default for both the per-turn prompt and the runtime-config Workflow:

- `--thread <trigger-id> --tail 30 --output json` is the new default.
  Root is always included so "what is this about" context survives.
- If 30 replies aren't enough, the prompt now spells out the reply
  cursor: re-feed the stderr `Next reply cursor: --before <ts>
  --before-id <reply-id>` pair back to walk older replies.
- `--recent 20` stays as the cross-thread background fallback, with an
  explicit callout that the same `--before` / `--before-id` flags walk
  *threads* (not replies) in that mode.
- Available Commands core line now surfaces `--tail N` and both stderr
  cursor labels so non-workflow callers also discover the flag.
- `--since` callouts reflect the post-MUL-2421 combinable mode names
  (`--thread --tail` / `--recent`).

Tests (`prompt_test.go`, `execenv_test.go`) pin the new defaults and add
a regression guard against the unbounded `--thread` recipe sneaking back
in.

Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: multica-agent <github@multica.ai>
2026-05-21 13:43:15 +08:00
Bohan Jiang
8a135d2982 fix(ws): truncate unparseable frame payload in client warn log (#2974)
The post-#2946 onmessage guard logs the raw event.data alongside the
warning. A malformed or rogue server can stream arbitrarily large
garbage and bloat the renderer / desktop main-process log buffers, so
cap the logged payload to the first 200 chars and append a
"(truncated, N chars total)" suffix when truncation occurs.

MUL-2490

Co-authored-by: multica-agent <github@multica.ai>
2026-05-21 13:37:42 +08:00
YOMXXX
83e90c9530 fix(ws): log auth frame write failures (#2946) 2026-05-21 13:33:12 +08:00
Bohan Jiang
ef6a944063 fix(cli): accept slug + short UUID prefix in workspace get/update/member (#2972)
* fix(cli): accept slug + short UUID prefix in workspace get/update/member (MUL-2385)

`workspace list` shows the 8-char short UUID prefix, name, and slug by
default; `workspace get`/`update`/`member list` only accepted full UUIDs.
That broke the natural list -> get flow: every value the user could copy
from list output was rejected. They had to either rerun list with
`--full-id` or parse the JSON output -- both implementation-detail level
operations.

Extend `resolveWorkspaceByIDOrSlug` with a short UUID prefix fallback
(>=4 hex chars, ambiguous matches return all candidates), introduce
`resolveWorkspaceRef`/`resolveWorkspaceArg` helpers that fetch the
caller's accessible workspaces and resolve UUID/slug/prefix in one call,
and wire them into get/update/member list (switch already used the same
list-then-resolve pattern). Full UUIDs short-circuit the extra
`/api/workspaces` round trip; access control remains on the downstream
endpoint.

Also add a one-line tip after `workspace list` table output pointing
users at get/update/switch with the same identifier columns, and
broaden the command Use strings to `<id|slug|prefix>` so help reflects
the new behavior.

Refs https://github.com/multica-ai/multica/issues/2750

Co-authored-by: multica-agent <github@multica.ai>

* chore(cli): include prefix hint in workspace list footer

Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: multica-agent <github@multica.ai>
2026-05-21 13:08:44 +08:00
YOMXXX
ed2957ddf8 fix(claude): record result model usage (#2899) 2026-05-21 13:00:12 +08:00
iYuan
2f1f90c11a fix(agent): retry codex semantic inactivity fresh (#2593) 2026-05-20 20:03:39 +08:00
Bohan Jiang
688dcb017c fix(agents): drop confusing "default" badge from model picker (MUL-2477) (#2938)
The model dropdown already exposes a "Default (provider)" option meaning
"follow the CLI's current selection". Tagging the runtime's preferred
model with a small "default" chip created two competing notions of
"default" in the same UI and confused users. Remove the chip from both
the create-agent ModelDropdown and the inspector ModelPicker; keep the
underlying RuntimeModel.default flag intact since thinking-prop-row
still uses it as a fallback heuristic.

Co-authored-by: multica-agent <github@multica.ai>
2026-05-20 18:07:57 +08:00
Multica Eve
cf000d1e93 docs(changelog): add 2026-05-20 release notes (#2932)
Co-authored-by: Eve <eve@multica-ai.local>
Co-authored-by: multica-agent <github@multica.ai>
2026-05-20 17:28:08 +08:00
Naiyuan Qing
317bca40c1 feat(squads): show skeleton on squad detail initial load (#2930)
Replaces the plain "Loading..." text fallback in SquadDetailPage with a
skeleton that mirrors the loaded page's two-column layout (left inspector
+ right tabs panel), matching the SquadsListSkeleton work shipped in #2890.

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-05-20 17:21:52 +08:00
Bohan Jiang
8d4f4caf4a MUL-2338 fix(comments): allow agent self-mention to enqueue cross-issue handoff (#2928)
* fix(comments): allow agent self-mention to enqueue cross-issue handoff

The @mention path in CreateComment unconditionally skipped any
self-mention. That dropped the child→parent handoff between issues
assigned to the same agent: the child run posted `@J` on the parent
issue, the guard tripped, and the parent's J was never woken — the chain
silently broke.

Drop the self-trigger `continue` in the agent mention branch. Runtime
ready / private-agent gate / HasPendingTaskForIssueAndAgent dedup all
remain, so a same-issue self-mention while a queued or dispatched task
exists is still deduped; a running task no longer pre-empts a new
follow-up (the existing queue coalescing handles that).

Three regression tests:
  - cross-issue self-mention enqueues a task on the target issue
  - same-issue self-mention while running queues a follow-up
  - same-issue self-mention with a pre-existing queued/dispatched task
    is deduped

MUL-2338

Co-authored-by: multica-agent <github@multica.ai>

* test(handler): assign per-workspace issue number in self-mention fixture

The fixture inserts two issues in the same test workspace; without an
explicit number both default to 0 and the second insert violates
uq_issue_workspace_number, taking the backend CI job down on PR #2928.

Mirror the workspace-counter advancement pattern from
issue_scheduled_test.go so each fixture issue gets a unique number.

Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: multica-agent <github@multica.ai>
2026-05-20 17:18:41 +08:00
YOMXXX
34f16e2c7a fix(opencode): deny interactive questions in daemon mode (#2878)
* fix(opencode): deny interactive questions in daemon mode

* fix(opencode): avoid permission env ordering bypass
2026-05-20 17:17:31 +08:00
Naiyuan Qing
85e363370e Revert "feat(issues): Working filter + agent-working badge on board (MUL-2452…" (#2927)
This reverts commit dee5c7cf50.
2026-05-20 16:47:41 +08:00
Naiyuan Qing
b040165f4e feat(squads): skeleton loader + AlertDialog archive confirm (MUL-2437) (#2890)
* feat(squads): skeleton loader + AlertDialog archive confirm (MUL-2437)

- Replace `Loading...` text on the squads list with a Skeleton placeholder
  matching the SquadCard shape (avatar + title + subtitle), aligning with
  the Agents / Dashboard pattern.
- Replace the native `confirm()` on the squad detail Archive button with
  the project's AlertDialog (destructive variant, pending-disabled, i18n
  copy interpolating the squad name).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>

* fix(squads): drop misleading restore copy from archive confirm (MUL-2437)

Archive is irreversible — there is no unarchive command (see
apps/docs/content/docs/squads.mdx:113). Aligns dialog copy with
docs: tells the user the action can't be undone and to create a
new squad if they need the routing back.

Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-05-20 16:43:58 +08:00
Naiyuan Qing
dee5c7cf50 feat(issues): Working filter + agent-working badge on board (MUL-2452) (#2924)
* feat(issues): surface "agent working" on board + add Working filter (MUL-2452)

Adds a brand-color "agent working" badge to board cards / list rows so
users can see at a glance which issues have an active agent task, plus a
new "Working" toggle on the `/issues` and `/my-issues` headers (next to
the existing scope segmented control) that filters to those issues. The
toggle shows an avatar stack of the agents currently active on the
current surface + scope. Pure frontend: re-shapes the existing
workspace-wide `agentTaskSnapshot` cache via two new selectors
(`activeTasksByIssueOptions` / `workingIssueIdsOptions`), no new SQL,
endpoint, or DB field; WS `task:*` events already invalidate the
snapshot so the badge / filter update in realtime.

Project detail page keeps the per-card badge but intentionally omits the
header toggle (`showWorkingToggle={false}`) to leave the project
surface's filter dimensions unchanged.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>

* fix(issues): working filter column header reflects filtered count (MUL-2452)

Assignee-grouped board column headers kept showing the unfiltered cache
total when Working was on, because `PaginatedAssigneeBoardColumn` passed
`useLoadMoreByAssigneeGroup`'s cache-derived `total` straight to
`BoardColumn`. The hook still needs the cache total for hasMore, but the
displayed count must follow the visible-after-filter set.

Split the two: when Working is active the column header now uses
`group.totalCount` (set by applyWorkingFilterToGroups) for the assignee
path, and `issueIds.length` for the status path. Load-more keeps reading
from cache so paginated columns still see the full server total.

Regression tests cover applyWorkingFilterToGroups (total rewrite +
empty-group preservation), filterIssues workingOnly combinations, and an
end-to-end assertion via IssuesPage that proves the column header equals
the filtered count, not the cached value.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-05-20 16:35:58 +08:00
Bohan Jiang
aeb284cbeb feat(runtime): teach agents the parent/sub-issue protocol (MUL-2338) (#2918)
* feat(runtime): teach agents the parent/sub-issue protocol (MUL-2338)

Adds a Parent / Sub-issue Protocol section to the runtime brief built by
`buildMetaSkillContent`, emitted whenever the agent is running on a real
Multica issue (assignment- or comment-triggered). Two behaviors are now
documented for every issue-bound agent:

- A. When wrapping up a child issue, post the final result and switch to
  `in_review` on this issue first, then post a single top-level comment
  on the parent. Mention the parent assignee only when it is another
  agent on a still-open parent — never self-mention, never @ member /
  squad, never re-trigger a `done` / `cancelled` parent.
- B. When creating sub-issues, choose `--status backlog` for sub-issues
  that must wait and `--status todo` for the one to start immediately;
  promote with `multica issue status <id> todo` when its turn comes.

The signal is explicitly framed as best-effort — no server-side state
sync, no claim of a guaranteed handshake. The section is skipped for
chat, quick-create, and run-only autopilot runs, which have no
parent/child semantics.

Tests in runtime_config_test.go assert that the section is present in
both issue workflows, absent in the three non-issue modes, and that the
wording does not introduce a non-existent `multica issue list --parent`
command or promise a reliable handshake.

Co-authored-by: multica-agent <github@multica.ai>

* fix(runtime): split Step A of parent/sub-issue protocol by trigger type (MUL-2338)

Comment-triggered runs were inheriting an unconditional
`multica issue status <this-issue-id> in_review` from Step A, which
conflicts with the comment-triggered workflow rule "Do NOT change the
issue status unless the comment explicitly asks for it" (Elon's blocking
review on PR #2918). Step A now branches on trigger type:

- Assignment-triggered: keep "post final results + flip in_review".
- Comment-triggered: complete the reply per the existing workflow rule,
  only flip status when the triggering comment asked for it, and gate
  the parent-notification steps on actually closing out child work.

Tests lock the boundary: comment-triggered briefs must not contain the
unconditional in_review command, must echo the existing status
guardrail inside Step A, and must spell out the "closing out" gate.
Assignment-triggered briefs still carry the unconditional flip.

Co-authored-by: multica-agent <github@multica.ai>

* fix(runtime): simplify parent/sub-issue mention rule to always @ parent assignee (MUL-2338)

Per Bohan's directive on PR #2918: the per-case mention table (same agent /
member / squad / closed parent) is overkill prompt complexity. Replace it
with a single rule: always @mention the parent's assignee using the URL
that matches assignee_type. The platform's existing run dedup handles
re-triggers, and a single rule is easier for agents to follow predictably.

Preserves the existing comment-triggered boundary (Step A still does NOT
add an unconditional in_review flip on comment-triggered runs).

Co-authored-by: multica-agent <github@multica.ai>

* refactor(runtime): compress parent/sub-issue protocol to 3-rule convention (MUL-2338)

Drop the spec-flavored A/B sub-headings and per-case mention table; keep
three numbered rules (close out child, notify parent, pick backlog vs
todo) plus a one-line best-effort preamble. The comment-triggered
branch still re-asserts the "do not change status unless asked"
guardrail and gates parent notification on actually closing out child
work; the assignment-triggered branch still flips to `in_review`.

Section is now 7 lines instead of 29. A new TestParentSubIssueProtocolIsCompact
guards the ≤10-line ceiling so this stays a convention, not a spec.

Co-authored-by: multica-agent <github@multica.ai>

* fix(runtime): make sub-issue creation rule unconditional in parent/sub-issue protocol (MUL-2338)

Elon's review on PR #2918: the preamble previously gated all three
rules on the current issue having `parent_issue_id`, but rule 3
(creating sub-issues) needs to reach top-level parents that have no
parent themselves — that is exactly where the `todo` vs `backlog`
decision matters most. Move the gate from the preamble onto rules 1
and 2 per-rule; rule 3 now applies to any issue-bound run. Section
stays at 7 newlines (≤10).

Co-authored-by: multica-agent <github@multica.ai>

* refactor(runtime): unify parent/sub-issue protocol as mechanism description (MUL-2338)

Drop the if/else split between assignment- and comment-triggered runs in
the Parent / Sub-issue Protocol section: both runs now read the same
two-rule description of how the parent/child mechanism works. The
comment-triggered workflow rule "Do NOT change the issue status unless
the comment explicitly asks for it" naturally short-circuits the parent
notification (no status flip → not closing out the child → skip), so the
protocol no longer needs to branch on TriggerCommentID.

Tests collapse the two trigger-specific cases into one parameterized
test, and the assignment vs comment status-flip invariants are now
anchored on the real workflow command (with substituted issue id)
instead of the protocol's removed `<this-issue-id>` placeholder.

Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: multica-agent <github@multica.ai>
2026-05-20 16:20:33 +08:00
Angular
1f978bf1ec feat(autopilot): link created issues to projects (#2908)
* feat(autopilot): link created issues to projects

* test(autopilot): cover project flag
2026-05-20 15:37:23 +08:00
Bohan Jiang
ffc0c5ab2e docs(agent-inspector): sync thinking_level comments with no-override semantics (MUL-2339) (#2923)
Follow-up to #2919 review nits — comments still described the empty
thinking_level as "use runtime default" and claimed ThinkingPicker callers
guaranteed non-empty levels. Both were stale after the semantics changed:

- packages/core/types/agent.ts: clarify that "" clears the override and
  the local CLI config / built-in default decides at runtime.
- thinking-picker.tsx: document that the stale-orphan clear path in
  ThinkingPropRow mounts the picker with an empty levels list plus a
  persisted value, so callers do not guarantee non-empty levels.

Co-authored-by: multica-agent <github@multica.ai>
2026-05-20 15:34:27 +08:00
Bohan Jiang
b7082a01f1 fix(issues): retry button targets the row's agent (MUL-2457) (#2921)
* fix(issues): retry button targets the row's agent, not the assignee (MUL-2457)

The execution log retry button used to re-fire the issue's current
assignee instead of the agent that actually ran the clicked row. After
a reassignment, or for squad workers / @-mention agents, the rerun
landed on the wrong agent.

POST /api/issues/{id}/rerun now accepts an optional task_id: when set,
the rerun targets that task's agent (and reuses its leader/worker
role). An empty body keeps the assignee-driven CLI/API contract.

The execution-log retry button passes task.id, so per-row retry always
fires the correct agent. enqueueMentionTask gained a forceFreshSession
parameter so the new mention-path rerun keeps the same fresh-session
contract as the assignee path.

Co-authored-by: multica-agent <github@multica.ai>

* fix(issues): inherit trigger provenance + fix cross-issue test (MUL-2457)

Address review feedback on PR #2921:

1. RerunIssue now inherits TriggerCommentID from the source task when
   sourceTaskID is valid. Without this, a per-row rerun of a comment-
   or mention-triggered task degrades into a generic issue run because
   the daemon's buildCommentPrompt path keys on TriggerCommentID. The
   inherited summary is rebuilt naturally inside the enqueue helpers
   (buildCommentTriggerSummary derives it from the comment ID).
2. The new cross-issue rejection test inserted a second issue without
   `number`, hitting uq_issue_workspace_number on a same-workspace
   collision with the fixture's issue. Both inserts now claim the next
   available per-workspace number (MAX(number)+1) — matching the
   pattern used by notification_listeners_test.

Added TestRerunIssueInheritsTriggerCommentFromSourceTask to lock the
trigger provenance contract.

Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: multica-agent <github@multica.ai>
2026-05-20 15:30:03 +08:00
Angular
314e91fa6d fix(chat): guard optimistic task message ids (#2901) 2026-05-20 15:18:42 +08:00
Bohan Jiang
68270e238e MUL-2339: polish(agent-inspector): optimistic updates + picker layout + thinking-default semantics (#2919)
* polish(agent-inspector): optimistic updates + picker layout + thinking-default semantics

Round of cleanup on the agent inspector pickers after using them end-to-end:

1. **Optimistic updates** (`agent-detail-page.tsx`)
   The `handleUpdate` callback that backs every inspector picker
   (thinking / model / visibility / concurrency / runtime / name /
   description / avatar) was strictly sequential:
   `await api.updateAgent → invalidateQueries → toast.success`. Each pick
   waited 0.5-2s for the network round trip before the trigger chip
   updated, which read as visible UI lag.
   Snapshot the cached agent list, patch the matching agent
   synchronously via `setQueryData`, then run the network request in
   the background. On error roll back to the snapshot before the toast
   surfaces the cause. All inspector pickers now respond instantly.

2. **Block-in-inline fix in Model + Thinking pickers**
   `PickerItem` wraps its children in a flex `<span>`. The picker
   bodies had `<div>` children, which is block-in-inline (invalid
   HTML5) and triggers a browser layout quirk that off-aligns
   descendants — model IDs floated to the center under their labels
   in ModelPicker, descriptions indented unevenly under levels in
   ThinkingPicker. Replace the inner `<div>`s with `<span block
   text-left>` so the layout is deterministic across rows.

3. **Visual polish in Thinking picker**
   Label was `font-medium` at the parent's default `text-sm` (14px),
   chunky next to the 10px description. Drop to `text-[13px]`, bump
   description to `text-[11px] leading-snug` with `mt-0.5` so the
   contrast between rows feels less jarring.

4. **Match Model picker's row typography to Thinking's**
   Same `text-[13px]` for label + `text-[10px] mt-0.5` for the model
   ID. Both pickers now read as the same component family.

5. **"Default" semantics: follow CLI config, not model factory default**
   The chip displayed "Default" / "default" badge when no
   `thinking_level` was set, alongside a `[default]` chip on the
   model's factory-advertised default option in the menu. That was
   misleading: when Multica omits `--effort` (because picker is
   unset), it's the user's *local CLI config* (claude/codex) that
   decides the reasoning level — not the model's factory default.
   Showing "medium [default]" while the user has xhigh in their CLI
   config lies about what actually fires at the API.
   - Trigger label: "Default" → "Follow CLI config" (zh: "跟随 CLI 配置")
   - Footer clear button: "Use model default" → "Follow CLI config"
   - Footer tooltip: explicitly mentions claude/codex CLI config
   - Inline `[default]` badge on the factory-default option: removed
   - `defaultLevel` prop chain (picker + prop-row + test): cleaned up
     as now-dead code

6. **Stop hiding the Thinking row while discovery loads**
   `if (levels.length === 0 && !value) return null` hid the row
   while the runtime-models query was still in flight, which
   subscribed-then-unsubscribed from useQuery in such a way that
   the discovery only fired when the user manually opened the Model
   picker. Gate the early return on `!isLoading && !isFetching` so
   ThinkingPropRow stays mounted (and thus its useQuery keeps
   subscribed) until discovery returns; row appears as soon as
   data arrives, no Model-picker tap required.

7. **Drop the inline tooltip on Thinking picker items**
   The same description was rendered both inline under the label
   (always visible) and as a hover tooltip (overlapping the next
   row). The hover bubble was redundant — removed.

Tests
- `pnpm --filter @multica/views test thinking-picker` → 7/7 pass after
  renaming the "Default" assertion + clearing the unused defaultLevel
  test prop.
- `pnpm --filter @multica/views typecheck` clean.

* fix(test): align thinking-prop-row tests with renamed copy + loading-aware row gate

CI surfaced 3 broken assertions in `thinking-prop-row.test.tsx` —
all consequences of the polish PR's behaviour changes that the test
file hadn't tracked:

- "hides the row when ... no thinking levels and nothing is persisted"
  The row now stays mounted while runtime-models discovery is in
  flight (so the useQuery subscription actually survives long enough
  to issue the request — fixes the bug where Thinking only appeared
  after manually opening the Model picker). The assertion asserted
  absence only after `initiate` was called, but loading is still in
  progress at that point. Wrap the absence assertion in `waitFor`
  so it waits for the row to disappear after the query settles.

- "clears the orphan value via the picker footer"
  Tooltip copy changed from "Clear and fall back to this model's
  default reasoning level" → "Clear the override and let the local
  CLI config decide the reasoning level". Update the regex.

- "renders the row with \"Default\" when value is empty"
  Trigger label changed from "Default" → "Follow CLI config" to
  reflect that Multica omits --effort and the local CLI config
  decides. Update the assertion + test name.

`pnpm --filter @multica/views test` → 701/701 pass.

* fix(agent-inspector): drop loading-row gate + per-field optimistic rollback (MUL-2339)

Addressing review feedback on #2919:

- ThinkingPropRow no longer keeps the row visible during discovery.
  The previous explanation ("early return null aborts the useQuery
  subscription") was wrong — React doesn't unmount a component that
  returns null, so hooks (and their subscriptions) stay live. The
  loading-aware gate only succeeded in showing an empty "Follow CLI
  config" row that opened to an empty menu before discovery settled.
  Restore the simple `levels empty && !value -> null` behavior; the
  sibling ModelPicker mounts unconditionally and keeps the shared
  runtime-models query active regardless.

- AgentDetailPage.handleUpdate now rolls back only the fields the
  failing PATCH wrote, instead of restoring a whole-list snapshot.
  A whole-list snapshot rollback discards any concurrent successful
  inspector mutation that landed between snapshot and rollback. Per-
  field rollback + a final invalidate converges the cache on server
  truth without clobbering unrelated optimistic writes.

- Sync the now-stale "use model/runtime default" wording in the
  thinking-related JSDoc and type comments: empty thinking_level is a
  "no override" sentinel — the backend omits --effort and the upstream
  CLI config decides — not a Multica-known default level.

Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: multica-agent <github@multica.ai>
2026-05-20 15:18:34 +08:00
Bohan Jiang
eaf8b14866 fix(installer): post-merge nits from #2881 (MUL-2458) (#2922)
- Capture `brew tap` output and print the same diagnostic tail on
  failure that `brew install` already prints, so #2867-style "no
  signal" reports are gone from both Homebrew failure paths.
- Add a `brew tap` failure regression case to `scripts/install.test.sh`
  and refactor the test runner to share sandbox/curl-stub setup; both
  cases now also assert the diagnostic tail is emitted.
- Move the shell installer test out of the heavy backend job into a
  dedicated `installer` matrix job that runs on `ubuntu-latest` and
  `macos-latest`, since the installer targets macOS/Homebrew and BSD vs
  GNU `tar` / `sed` / `mktemp` differences are the next likely break.
- Surface `MULTICA_INSTALL_DIR`, `MULTICA_BIN_DIR`, and
  `MULTICA_SELFHOST_REF` in `install.sh --help` so `MULTICA_BIN_DIR`
  stops looking like a test-only knob.

Co-authored-by: multica-agent <github@multica.ai>
2026-05-20 15:18:17 +08:00
Jiayuan Zhang
41753d17a2 feat(desktop): pin tab (MUL-2449) (#2914)
* feat(desktop): pin tab — keep parked tabs anchored across navigations (MUL-2449)

Adds tab pinning to the desktop tab bar. Pinned tabs render as icon-only at
the left, suppress the X close button, and intercept any `navigation.push()`
that would change their pathname — those are redirected into a new tab so
the pinned tab stays parked on its original route. Search/hash/back/forward
stay in-tab so pinned filter and drawer state still work.

Implements the FINAL combo from the MUL-2449 RFC §4: right-click menu +
⌘⇧P shortcut (D1 a+c), icon-only visual (D1v i), pathname-change → new tab
with same-path-allowed (D2a/b A), back / refresh allowed (D2c/d A), pinned
auto-cluster left and persist (D3a/b A), pinned can't be X-closed (D3c A),
dedupe respected (D4a A), default Issues tab pinnable (D4b A), drag clamped
to its zone (D4c A), deep link prefers pinned (D4e A).

Store changes:
  - Tab.pinned added; togglePin maintains the "pinned first" invariant by
    inserting at the zone boundary.
  - moveTab clamps cross-zone drags so dnd-kit can't violate the ordering.
  - Persistence bumped v2 → v3 with a defaulting migration (pinned=false).
    Rehydrate sorts pinned-first as a defensive net.

Navigation:
  - tryRouteToPinnedNewTab compares the active tab router's live pathname
    to the target. Same-pathname push (query / hash / sub-router) falls
    through to the router; different pathname → openTab + setActiveTab
    (foreground; respects dedupe).

UI:
  - Tab bar wraps each tab in a shadcn ContextMenu with Pin/Unpin + Close
    (Close disabled for pinned or last-remaining tab).
  - Pinned tabs use a narrower icon-only layout with an accent left border
    and a divider between the pinned and unpinned groups.
  - Global keydown listener registers ⌘⇧P / Ctrl+Shift+P to toggle pin on
    the active tab.

Tests: - tab-store: togglePin ordering, moveTab boundary clamping, v2→v3
    migration.
  - navigation: pinned push → new foreground tab; same-pathname push stays
    in tab; cross-workspace still wins over pin.
Co-authored-by: multica-agent <github@multica.ai>

* test(desktop): cover TabNavigationProvider.push pin interception (MUL-2449)

Add pathname-diff / same-pathname cases for the per-tab navigation
adapter. Existing tests only exercised the root-level
DesktopNavigationProvider, but in-tab AppLink / page clicks flow
through TabNavigationProvider — so a future refactor that drops the
pin check from that provider would silently regress.

Co-authored-by: multica-agent <github@multica.ai>

* refactor(desktop): pin tab — hover button, full title, drop ⌘⇧P (MUL-2449)

Jiayuan's interactive review of PR #2914 surfaced three changes to the
RFC's D1 (entry / visual) decisions:

  1. Drop the ⌘⇧P global shortcut — it added a keybinding for a
     low-frequency action and crowded the shortcut namespace.
  2. Reveal a Pin / Unpin button on tab hover instead of relying on the
     right-click menu as the primary entry; right-click remains as a
     fallback (and for Close).
  3. Pinned tabs keep their full title and width. The only weak visual
     differences vs. unpinned tabs are the accent left border and the
     suppressed X close button.

Removes the global keydown listener (no other doc / handler referenced
it). Adds a hover-only Pin / Unpin span next to the existing close
affordance, both gated by group-hover. Drops the icon-only width /
hidden-title styling for pinned tabs.

Tests: new tab-bar.test.tsx covers Pin / Unpin button rendering, click
handlers (togglePin), the hidden-X invariant on pinned tabs, and the
full-title rendering. 146 passed, typecheck clean.

Co-authored-by: multica-agent <github@multica.ai>

* refactor(desktop): pin tab — drop accent left border, swap leading icon to Pin (MUL-2449)

Jiayuan reported that the accent left border on pinned tabs reads as a
heavy black edge in light mode and looks unrefined. Replace it with a
quieter identifier: pinned tabs swap their route icon for a Pin glyph
in the leading slot (same size, no extra horizontal space). The hidden
X close button stays as the secondary cue. RFC §3 D1v moves from
iii FINAL to iv FINAL; iii is demoted to v2 FINAL → v3 REMOVED.

Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: Lambda <lambda@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-05-20 09:14:43 +02:00
Angular
edded77691 fix(installer): fall back when brew install fails (#2881) 2026-05-20 15:14:18 +08:00
Bohan Jiang
9d3b6e2241 feat(agent): inspector picker for thinking_level (MUL-2339) (#2912)
* feat(agent): inspector picker for thinking_level (MUL-2339)

PR1 (#2865) shipped the backend — column, daemon-side discovery,
Claude/Codex injection, API validation — but the agent detail inspector
had no UI to set the value. Users could only configure thinking_level
via custom_env / API. This wires up the picker so it lives next to
Runtime and Model where everything else editable already lives.

Picker is per-(runtime, model): it reuses the same `runtimeModelsOptions`
query the Model picker already runs (60s cache, no extra round-trip)
and reads the active model's `thinking.supported_levels`. When the list
is empty — every provider except Claude/Codex today, or a Claude model
that doesn't expose `--effort` — the entire PropRow is hidden, not just
rendered inert. The picker never gets to invent value/label pairs
itself; they come verbatim from each CLI's own catalog (`Low`,
`Extra high`, …) so the user sees exactly what `claude --effort` /
`/effort` and Codex's TUI show.

The `default_level` from the catalog is badged inside the popover so
the user knows which value `""` (the persisted "use model default"
sentinel) maps to. The clear footer sends `""` explicitly, which the
backend already understands as the tri-state "explicit clear" branch
of UpdateAgent. Invalid combinations (e.g. picking a value not in the
target provider's enum after a runtime swap in the same PATCH) hit
the existing 400 path on the server and surface as a toast via the
inspector's standard `onUpdate` error handler — no extra client-side
guard needed.

Exports `RuntimeModelThinking` and `RuntimeModelThinkingLevel` from
`@multica/core/types` so views consumers can refer to them by name.
i18n keys added in EN and zh-Hans (parity test green).

Co-authored-by: multica-agent <github@multica.ai>

* fix(agent): preserve unknown thinking_level in picker label

Stale persisted values (model swap, CLI catalog shrink) used to render
as 'Default' even though the backend would still ship the orphaned
token. Fall back to the raw value when no entry matches so the user
sees what's actually saved and can clear it.

Co-authored-by: multica-agent <github@multica.ai>

* test(agent): unit tests for thinking-picker label + clear flow

Covers the default-vs-set trigger label, the unknown-token preservation
path added in 3452fae3f, the read-only display, picking and re-picking
into onChange, and the clear footer's empty-string emission.

Co-authored-by: multica-agent <github@multica.ai>

* fix(agent): keep Thinking row visible when value is stale (MUL-2339)

Inspector was hiding the row whenever the active model had no
supported_levels, which also hid persisted orphan tokens (model swap
into a non-thinking runtime, or a CLI catalog that shrank). PR1's
per-model invalid behavior is daemon-side warn/drop, not a synchronous
DB clear, so the frontend has to surface the raw value and let the
user explicit-clear it via the picker footer.

Render the row when levels are empty AND value is empty; otherwise
keep it. Extract ThinkingPropRow into its own file so the row-level
logic is unit-testable.

Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: multica-agent <github@multica.ai>
2026-05-20 13:47:19 +08:00
Bohan Jiang
2bec2221d2 feat(agent): per-agent thinking_level for claude + codex (MUL-2339) (#2865)
* feat(agent): persist thinking_level per agent (MUL-2339)

Adds a nullable `thinking_level` column to the `agent` table so the
backend can route a runtime-native reasoning/effort token (e.g. Claude's
`xhigh`, Codex's `minimal`) through to the agent CLI on every dispatch.

The column is intentionally TEXT rather than an enum — Claude and Codex
publish overlapping but distinct vocabularies and we want the persisted
value to round-trip exactly through whichever CLI receives it. NULL is
the "use runtime default" sentinel that every downstream consumer reads
as "do not inject --effort / reasoning_effort".

This commit is just the storage layer (migration + sqlc); subsequent
commits wire it through the API, daemon, and agent backends.

Co-authored-by: multica-agent <github@multica.ai>

* feat(agent-backend): inject reasoning effort for claude + codex (MUL-2339)

Extends ExecOptions with a runtime-native ThinkingLevel string and wires
it into the Claude and Codex backends. Discovery is driven by the local
CLI so the daemon advertises whatever the host install supports rather
than a hand-maintained list that goes stale.

Per Elon's PR1 review:
- Claude: parses `claude --help` to learn the `--effort` superset and
  projects through a per-model allow-list (xhigh is Opus-only; max is
  session-only on the smaller models). Falls back to a conservative
  static list when the binary is missing or help drift hides the line.
- Codex: drives `codex debug models --output json` so per-model
  reasoning subsets and the documented default come directly from the
  CLI. The older config-error probe trick is gone — the JSON path is
  stable and doesn't pollute stderr with an intentional misconfig.
- Cache key includes (provider, executablePath, cliVersion) so a CLI
  upgrade invalidates entries that referenced the older help / catalog.

Per Trump's PR1 constraint, all three Codex injection points
(thread/start.config, thread/resume.config, turn/start.effort) flow
through one helper (`applyCodexReasoningEffort`) so they cannot drift
independently. The shared `codexReasoningCases` fixture in
`thinking_test.go` asserts the same value→{shape, key} contract at
each site for every level the runtimes know about.

Claude's `--effort` is also added to `claudeBlockedArgs` so a user
custom_args entry can't silently outvote the daemon-injected value.

Co-authored-by: multica-agent <github@multica.ai>

* feat(api): wire thinking_level through API + daemon contract (MUL-2339)

End-to-end plumbing for the per-agent reasoning/effort setting:

- AgentResponse / TaskAgentData now carry `thinking_level`; the daemon's
  claim response includes it and the daemon's executor passes it through
  to agent.ExecOptions, where the Claude and Codex backends already know
  what to do with it.
- ModelEntry on the runtime-models wire format gains a `thinking` block
  carrying `supported_levels` + `default_level` per model so the UI can
  render a runtime-aware picker without the server having to know about
  the local CLI install. `handleModelList` projects the agent-package
  catalog (including the new Thinking field) into the wire shape.
- CreateAgent / UpdateAgent gate the field with a synchronous provider
  enum check (claude / codex only today). UpdateAgent is tri-state:
  field omitted = no change, "" = explicit clear (new
  `ClearAgentThinkingLevel` query, mirrors the existing mcp_config null
  pattern), non-empty = validate then set.

Per Trump's PR1 review, the API NEVER auto-clears on a runtime/model
swap and ALWAYS returns 400 on an unknown literal value — same shape
across CreateAgent, UpdateAgent, and combined patches that move
runtime + level in one request. Per-model combination failures (e.g.
`xhigh` against a model that only supports up to `high`) surface as a
daemon-side task error, not a silent server-side rewrite.

TS types follow the same shape: `Agent.thinking_level`,
`CreateAgentRequest`/`UpdateAgentRequest` add the field, `RuntimeModel`
grows a `thinking` block. Older backends omit the field, which the
front-end treats as "no picker for this model" — installed desktop
builds keep working.

Co-authored-by: multica-agent <github@multica.ai>

* fix(agent): correct codex debug models argv + pin via runner test (MUL-2339)

`codex debug models --output json` is rejected by codex-cli 0.131.0 —
the subcommand emits JSON on stdout by default and has no `--output`
flag. Drop the flag and add `--bundled` to skip the network refresh
discovery doesn't need. Move the argv to a package-level var and add
a test that runs a fake `codex` to assert the binary actually
receives exactly `debug models --bundled`, so the contract can't
silently drift on the next refactor.

Also teach ValidateThinkingLevel to resolve an empty model to the
provider's default model entry. Without this, every default-model
task with a persisted thinking_level would be misjudged "unknown
model" by the daemon guard.

Co-authored-by: multica-agent <github@multica.ai>

* fix(api): reject runtime switch that would leave invalid thinking_level (MUL-2339)

A PATCH that changed `runtime_id` without touching `thinking_level`
used to silently keep the existing value, so a Claude agent storing
`max` could land on a Codex runtime where `max` is not a recognised
token at all, and the daemon would receive a literal-invalid level.

Hold the same "always 400 on literal-invalid, never silent coerce"
rule on this implicit path. When runtime_id changes and the existing
value is not in the new provider's enum, return 400 with the
recovery options (clear via `thinking_level=""` or re-set in the
same PATCH).

Add coverage for both the kept-when-still-valid and the rejected
cases, plus the two recovery paths (clear and replace).

Co-authored-by: multica-agent <github@multica.ai>

* fix(daemon): guard runTask with per-model thinking_level validator (MUL-2339)

ValidateThinkingLevel existed but had no call site — `task.Agent.
ThinkingLevel` flowed straight into ExecOptions, so `xhigh` configured
on a non-Opus Claude model, or API-side stale values that escaped the
provider enum gate, would be injected anyway.

Run the validator before building ExecOptions. Invalid combinations
log a warning and drop the level instead of failing the task: the
agent still runs, just at the runtime's default reasoning effort.
Discovery errors fail open (keep the level, let the CLI surface any
objection) so a transient `claude --help` failure can't strand work.

Empty model is forwarded as-is; the validator resolves it to the
provider's default model internally per the cross-package contract.

Co-authored-by: multica-agent <github@multica.ai>

* chore(agent): drop stale `--output json` comments + unused scanner (MUL-2339)

Codex CLI's `debug models` subcommand emits JSON without an `--output`
flag, and `parseCodexDebugModels` never read from the bufio.Scanner.
Sync the comments with the actual invocation and remove the dead init.

Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: multica-agent <github@multica.ai>
2026-05-20 12:30:10 +08:00
Jiayuan Zhang
292226f632 fix(runtimes): use official Gemini spark icon (MUL-2447) (#2904)
* fix(runtimes): use official Gemini spark icon (MUL-2447)

Gemini provider was falling through to the default Monitor icon in the
runtime list. Add the official 4-point spark mark with Google's
blue → purple → pink gradient, matching the SVG style/sizing of the
other provider icons.

Co-authored-by: multica-agent <github@multica.ai>

* fix(runtimes): use current Gemini multicolor spark gradient (MUL-2447)

Per review on PR #2904: the previous 3-stop blue/purple/pink gradient
was the legacy Bard-era Gemini spark. Update to the 5-stop cyan → blue
→ purple → pink → orange gradient used by the current Gemini app/web
multicolor mark.

Co-authored-by: multica-agent <github@multica.ai>

* fix(runtimes): switch Gemini icon to aurora multicolor treatment (MUL-2447)

Co-authored-by: multica-agent <github@multica.ai>

* fix(runtimes): align Gemini aurora color positions and smooth spark path

Swap yellow/green radial gradient anchors so colors land at the official
positions: top red / right blue / left yellow / bottom green, matching
gemini.google.com's current aurora spark. Replace the arc-based 4-point
spark outline with a cubic-bezier version normalized to the 24-viewBox
so the inset between tips is smoother and closer to the gstatic source.

Co-authored-by: multica-agent <github@multica.ai>

* fix(runtimes): use Simple Icons Google Gemini mark (MUL-2447)

Drop the hand-crafted aurora gradient approximation and inline the
canonical "Google Gemini" path from Simple Icons (CC0 1.0), rendered
in the Simple Icons brand color (#8E75B2). This matches the pattern
used by the other provider marks in this file (Claude/Codex from
Bootstrap Icons, etc.) instead of trying to manually approximate the
official multicolor wash from gemini.google.com (which paints via a
clipPath over an embedded raster).

Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: Lambda <lambda@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-05-20 12:27:53 +08:00
Jiayuan Zhang
72339f347b fix(desktop): keep local machine row visible after stopping daemon (#2906)
The Start button lives in `DaemonRuntimeActions`, which is rendered in
the per-machine detail pane and only when the selected machine is
flagged `isCurrent`. After the user manually stopped the daemon,
`status.daemonId` went back to undefined, so no machine could be
matched as `isCurrent` — the local row either disappeared (when the
server-side runtime had been GC'd) or moved into the "remote" section
(when it was still present but unmatched). Either way the Start button
was unreachable until the app was restarted.

Two-part fix:

- `DesktopRuntimesPage` now caches the last-known daemonId/deviceName
  so the local match keeps working while the runtime is still on the
  server (recently_lost / offline window).
- `buildRuntimeMachines` accepts an `ensureLocalMachine` flag; when no
  real runtime matches, a placeholder local row is synthesized so the
  Start button still has a home. Desktop opts in via a new
  `hasLocalMachine` prop on `RuntimesPage`. The empty state is also
  suppressed when this prop is set so the placeholder row isn't hidden
  behind the "register a runtime" hint on first launch.

Co-authored-by: Lambda <lambda@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-05-20 06:16:20 +02:00
Jiayuan Zhang
fc8528d64d feat(autopilot): support assigning to a squad (MUL-2429) (#2888)
* feat(autopilot): support assigning autopilot to a squad (MUL-2429)

Path A (Squad-as-Leader) from the RFC: when an autopilot's assignee is a
squad, dispatch resolves to squad.leader_id and executes against the
leader's runtime — semantics match a human manually assigning the issue
to that squad, no fan-out.

Backend scope only; frontend picker change is a follow-up PR.

Changes:
- 096_autopilot_squad_assignee migration: drop agent FK on
  autopilot.assignee_id, add assignee_type column (default 'agent'),
  add autopilot_run.squad_id attribution column.
- service.AgentReadiness: single source of truth for archived /
  runtime-bound / runtime-online checks. Shared by autopilot
  admission gate, run_only dispatch, and isSquadLeaderReady.
- service.resolveAutopilotLeader: translates assignee_type/id to the
  agent that actually runs the work.
- dispatchCreateIssue: stamps issue with assignee_type='squad' for
  squad autopilots and enqueues via EnqueueTaskForSquadLeader.
- dispatchRunOnly: belt-and-braces readiness re-check after resolving
  squad → leader so a leader that went offline between admission and
  dispatch produces a clean failure instead of a doomed task.
- handler.CreateAutopilot / UpdateAutopilot: accept assignee_type with
  squad/agent existence + leader-archived validation. Backward-compatible
  default of "agent" preserves the contract for older clients.
- Analytics: AutopilotRunStarted/Completed/Failed events carry
  assignee_type and squad_id; PostHog can now group autopilot runs by
  squad without joining back to the autopilot row.

Co-authored-by: multica-agent <github@multica.ai>

* fix(autopilot): reject archived squads, route post-admission skips, cleanup dangling-agent autopilots (MUL-2429)

Addresses three review findings on PR #2888:

1. Archived squad handling: validateAutopilotAssignee now rejects squads
   with archived_at set; resolveAutopilotLeader returns errSquadArchived
   so the admission gate fails closed; DeleteSquad now mirrors the issue
   transfer for autopilot rows (TransferSquadAutopilotsToLeader) so
   surviving autopilots flip to assignee_type='agent' (leader) instead
   of dangling at the archived squad.

2. dispatchRunOnly post-admission readiness: introduces errDispatchSkipped
   sentinel, recognised by DispatchAutopilot via handleDispatchSkip so
   the run is recorded as `skipped` (not `failed`). Manual triggers no
   longer 500 when the leader's runtime goes offline between admission
   and task creation. New TestManualTriggerDoesNotErrorOnPostAdmissionSkip
   locks the behaviour in.

3. Dangling agent assignee after migration 096 dropped the FK:
   shouldSkipDispatch now distinguishes pgx.ErrNoRows / errSquadArchived
   (hard skip — retrying won't help) from transient DB errors
   (fail-open). DeleteAgentRuntime pauses autopilots that target agents
   about to be hard-deleted (ListArchivedAgentIDsByRuntime +
   PauseAutopilotsByAgentAssignees) so the breakage surfaces as a paused
   row in the UI instead of a quiet skip-burning loop.

Unit tests cover the sentinel unwrap contract and errSquadArchived
errors.Is behaviour. Integration test
TestAutopilotDispatchSkipsWhenRuntimeOffline re-verified against a fresh
DB with migration 096 applied.

Co-authored-by: multica-agent <github@multica.ai>

* fix(autopilot): bump last_run_at on post-admission skip (MUL-2429)

Match recordSkippedRun (pre-flight skip) and the success path so the
scheduler / "last seen" UI both reflect that this tick evaluated the
trigger, even when the post-admission readiness gate caught a late
regression.

Addresses Emacs review caveat #1 on PR #2888.

Co-authored-by: multica-agent <github@multica.ai>

* feat(autopilot): mixed agent/squad assignee picker in dialog (MUL-2429)

End-to-end UI for assigning an autopilot to a squad. Closes the PR #2888
backend gap: the squad-as-assignee feature was already wired in Go (Path A,
RFC §4) but the desktop dialog never offered the choice.

- core/types/autopilot: add `AutopilotAssigneeType`, surface
  `assignee_type` on `Autopilot` + Create/Update request payloads.
- views/autopilots/pickers/agent-picker: switch to a polymorphic
  AssigneeSelection (`{type, id}`); render agents and squads as two
  grouped sections with shared pinyin search.
- views/autopilots/autopilot-dialog: maintain `assigneeType` state, send
  it on create/update, render the trigger avatar / hover dot with
  `assignee.type`.
- views/autopilots/autopilots-page + autopilot-detail-page: render the
  assignee row using `autopilot.assignee_type` so squad-typed autopilots
  show the squad avatar + name, not a broken agent lookup.
- locales: add `agents_group` / `squads_group` / `select_assignee` keys
  (en + zh-Hans), keep legacy `select_agent` for callers that still
  reference it.

Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: Lambda <lambda@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-05-20 05:30:13 +02:00
Jiayuan Zhang
4a487adfeb feat(github): split canView / canManage in settings tab for read-only members (MUL-2413) (#2898)
Wires the frontend half of the read-only RFC. The Settings → GitHub tab
now always issues the installation list query for any workspace member
(the backend gates it via `RequireWorkspaceMember` after PR #2886) and
gets `can_manage` straight from the API response. The render matrix
covers the six cases the RFC calls out:

- configured + connected + admin   → Disconnect + (optional) Connected by
- configured + connected + member  → read-only "Connected to" + read_only_hint
- configured + not connected + admin   → Connect button + dev description
- configured + not connected + member  → contact_admin_to_connect hint
- not configured + admin               → operator banner + disabled Connect
- not configured + member              → contact_admin_to_connect hint

New i18n keys (en + zh-Hans): read_only_hint, connected_by, contact_admin_to_connect.
The unused github.manage_hint string is removed (its non-admin branch
now resolves to one of the two new hints depending on connection state).

GitHubInstallation gains an optional `connected_by` display name so the
UI can render the "Connected by {name}" line without further changes
once the backend exposes the field.

Co-authored-by: Lambda <lambda@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-05-20 04:19:28 +02:00
Jiayuan Zhang
e48f6a84d6 feat(github): expose read-only installation list to workspace members (MUL-2413) (#2886)
* feat(github): expose read-only installation list to workspace members (MUL-2413)

Relax `GET /api/workspaces/{id}/github/installations` from owner/admin-only
to any workspace member so the Settings → Integrations tab no longer renders
blank for non-admins (the original symptom of MUL-2413).

The handler now reads the caller's role from the workspace middleware:
- owner / admin keep the full row including the numeric `installation_id`
  (the connect / disconnect handle) and receive `can_manage: true`.
- every other role (member / guest) receives rows with `installation_id`
  omitted and `can_manage: false`, giving them visibility into "is GitHub
  wired up?" without the management handle.

`GET /github/connect` and `DELETE /github/installations/{id}` stay under
the admin/owner middleware group — this PR only relaxes the read path.

Tests: `TestListGitHubInstallations_RoleGating` exercises admin, owner,
member, and guest paths against the real DB-backed handler fixture and
asserts the field stripping + `can_manage` contract.

Refs: MUL-2413
Co-authored-by: multica-agent <github@multica.ai>

* fix(github): redact installation_id from realtime broadcasts (MUL-2413)

GET /github/installations strips the numeric installation_id for non-admin
members, but the github_installation:created / uninstall / suspend WS
events were still publishing it, so the same handle was reachable from
any workspace client subscribed to the workspace scope. Broadcast both
payload variants without it — the frontend uses these events only to
invalidate the installations query, so admins re-query the list endpoint
to recover the management handle.

Also adds a router-level test that mounts the production middleware split
(member-visible list vs. owner/admin connect+delete) so a future routing
change can't silently widen the write surface.

Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: Lambda <lambda@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-05-20 04:17:45 +02:00
Naiyuan Qing
5b8303b83c fix(editor): fill modal viewport in attachment preview (MUL-2431) (#2891)
In the attachment preview modal, image and video previews used
`max-h-full max-w-full`, which let small assets render at their
natural size and leave the modal mostly empty. Switch to
`h-full w-full` so the preview always occupies the modal viewport,
relying on `object-contain` to preserve aspect ratio without
upscaling beyond the intrinsic bounds.

Only touches `packages/views/editor/attachment-preview-modal.tsx`
for the image (line 355) and video (line 373) branches; pdf, audio,
markdown, html, and text branches keep their existing layout.

Co-authored-by: multica-agent <github@multica.ai>
2026-05-20 09:16:08 +08:00
Naiyuan Qing
071ffca034 fix(editor): exit list when Enter pressed on empty top-level item (MUL-2430) (#2861)
Tiptap's stock ListItem keymap binds Enter only to splitListItem. When the
cursor sits in an empty top-level list item, splitListItem returns false
(without dispatching) with a code comment saying "let next command handle
lifting" — but no next command is chained. Enter then falls through to
ProseMirror's baseKeymap which inserts another empty paragraph inside the
list item, trapping the user.

Replace StarterKit's ListItem with PatchedListItem whose Enter binding
chains splitListItem → liftListItem via commands.first. The lift fallback
only runs when splitListItem returns false (top-level empty case),
restoring the standard "double-Enter exits the list" behaviour seen in
every other rich-text editor. Non-empty and nested-empty items are
unaffected because splitListItem already handles them correctly.

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-05-20 08:20:50 +08:00
Jiayuan Zhang
2ad1cd8ff8 feat(profile): user profile description injected into agent brief (MUL-2406)
## Summary

Adds per-user `profile_description` so coding agents have cheap, durable context about who is asking. v1 per the brief Xeon locked in on [MUL-2406](mention://issue/63a7247c-4f6a-42cf-90d1-7c746e77158a):

- **DB** — `user.profile_description TEXT NOT NULL DEFAULT ''` (migration 096). 2000-rune cap enforced server-side. No nullable / privacy state to manage.
- **API** — `PATCH /api/me` accepts the field; `UserResponse` always emits it. Client wraps `updateMe` in a lenient `UserSchema` + `EMPTY_USER` fallback per CLAUDE.md API Response Compatibility.
- **UI** — Settings → Account gains an "About you" textarea with live `n/2000` counter, `maxLength` guard, and a localized too-long error (EN + zh-Hans).
- **CLI** — `multica user profile get` / `multica user profile update` with `--description / --description-stdin / --description-file / --clear`, mirroring the existing `issue comment add` input-mode menu.
- **Daemon injection** — claim handler resolves the runtime owner and stamps `requesting_user_name` + `requesting_user_profile_description` on the task. `buildMetaSkillContent` emits `## Requesting User` between `## Agent Identity` and `## Available Commands`, blockquoted and framed as background context. The block is omitted entirely when the description is empty (no token cost when unused).

Brief is written **once per task** via `CLAUDE.md` / `AGENTS.md`, not the per-turn prompt — same path the agent already reads for identity, so no extra per-turn cost.

## Test plan

- [x] `go build ./...`, `go vet ./...`, `go test ./internal/cli/ ./internal/daemon/ ./internal/daemon/execenv/ ./cmd/multica/`
- [x] New brief tests: `TestBuildMetaSkillContentEmitsRequestingUser`, `TestBuildMetaSkillContentOmitsRequestingUserWhenEmpty`
- [x] `pnpm typecheck`, `pnpm lint`, `pnpm test` (74 files, 644 tests pass)
- [ ] Handler DB tests (`TestUpdateMe*`) require a migrated test DB — not runnable in this sandbox
- [ ] Manual: open Settings → Account, set a description, confirm the next daemon-run agent's `CLAUDE.md` shows `## Requesting User`
2026-05-19 19:51:28 +02:00
Jiayuan Zhang
34988216ed feat(issues): show project segment in issue breadcrumb (MUL-2422)
* feat(issues): show project segment in issue breadcrumb (MUL-2422)

Render the issue's project (when present) between the workspace and any
parent-issue segment. Segment reflects the issue's own `project_id` so
the same URL produces the same breadcrumb from every entry point.

Failed/missing project queries fall back to an "Unknown project"
placeholder; loading shows a skeleton to avoid layout shift.

Co-authored-by: multica-agent <github@multica.ai>

* fix(issues): cap project breadcrumb width to preserve title precedence

Constrain Project crumb to max-w-72 (matching ProjectChip) and add
min-w-0 to the title span so the flex compression order matches RFC
§5/§9: Project/Parent shrink before the current Issue title.

Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: Lambda <lambda@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-05-19 19:46:20 +02:00
Jiayuan Zhang
80cc7b23f8 refactor(runtimes): declutter the runtimes page (MUL-2407) (#2833)
* refactor(runtimes): declutter the runtimes page (MUL-2407)

Cuts visual noise on the Runtimes detail view without removing real
information:

- MachineDetail: drop the 4-card metric grid (RUNTIMES / HEALTH /
  WORKLOAD / CLI) and replace it with a single inline meta strip. The
  cards repeated what the title chip and runtime rows already show.
- PageHeaderBar: remove the inline tagline + "Learn more" link. The
  header is now icon + title + count + connect button.
- VisibilityBadge: only render the Public chip. Private is the default,
  so a row of `🔒 Private` badges was pure noise.
- CliCell: drop the per-row "Desktop" managed badge — the same string on
  every desktop row carried near-zero information.
- MachineSidebar row: hide the truncated daemon-id subtitle. The id is
  still available on hover via `title` and remains visible in the
  detail header.

Co-authored-by: multica-agent <github@multica.ai>

* fix(runtimes): address review feedback on inline meta and hover title

- Inline meta now reads "6 runtimes · 5 online" instead of "6 6 online"
  by using runtime_count for the total label.
- Sidebar machine title hover now shows full daemon id (with subtitle
  fallback) so the daemon id is recoverable after the sub-row was hidden.

Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: multica-agent <github@multica.ai>
2026-05-19 19:15:46 +02:00
Jiayuan Zhang
044f7f0cc6 feat(editor): bump HTML iframe preview default height to 480px (MUL-2419) (#2842)
320px was too cramped for typical rendered HTML (charts, dashboards,
formatted documents). Matches the existing HTML attachment preview
height for visual consistency across both iframe surfaces.

Co-authored-by: Lambda <lambda@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-05-19 19:15:03 +02:00
Jiayuan Zhang
591e47842d refactor(onboarding): remove starter-content kit; unify install-runtime issue across mark-onboarded paths (MUL-2438) (#2884)
* refactor(onboarding): remove starter-content kit, unify install-runtime issue across mark-onboarded paths (MUL-2438)

Drops the post-onboarding ImportStarterContent / DismissStarterContent
flow (handler + routes + StarterContentPrompt + templates + locale
strings + analytics event). The bug — web onboarding seeding 6+ starter
issues without a runtime — only existed through that path; with it gone
the source disappears.

The "install a runtime" issue from BootstrapOnboardingNoRuntime is now
the canonical no-runtime onboarding seed. The title/description and a
LockAndFindActiveDuplicate-deduped seeder move to
handler/no_runtime_issue.go, and CompleteOnboarding / CreateWorkspace /
AcceptInvitation seed it whenever the workspace has no runtime yet, so
every mark-onboarded entry point lands the user on a concrete next
step.

starter_content_state column is kept and continues to be claimed as
'imported' in all five entry points so older desktop builds (which
still render the legacy dialog on NULL) don't surface it to accounts
created after this change.

Co-authored-by: multica-agent <github@multica.ai>

* fix(onboarding): backfill starter_content_state for in-window NULL users (MUL-2438)

054 only covered pre-feature users. Anyone onboarded between then and the
starter-content kit removal could still sit at NULL, and old desktop
clients gate the legacy StarterContentPrompt on `starter_content_state
IS NULL`. The import/dismiss routes are gone, so leaving these rows NULL
would surface a dialog whose buttons 404. Mark them 'imported' to match
the new helper's claim semantics.

Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: Lambda <lambda@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-05-19 18:37:48 +02:00
Jiayuan Zhang
cd37b4e3d6 feat(settings): consolidate GitHub options under a dedicated Settings tab (MUL-2414) 2026-05-19 17:23:30 +02:00
Naiyuan Qing
f92deaf939 feat(desktop): foreground new tab for explicit Open-in-new-tab CTAs (MUL-2434) (#2869)
Add optional `opts.activate` to NavigationAdapter.openInNewTab. Default
stays `false` so cmd/ctrl+click on links/mentions keeps browser-style
background semantics. The two explicit toolbar entry points
(attachment-preview-modal, html-attachment-preview) opt in with
`{ activate: true }` so the new tab gains focus after the modal closes.

Both desktop providers (root + per-tab) now use the tab id returned by
`store.openTab` to call `setActiveTab` only when `activate` is true.

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-05-19 18:11:37 +08:00
Bohan Jiang
f120e0ef43 refactor(cli): tidy workspace subtree (MUL-2386) (#2866)
- Drop `workspace current`; `workspace get` (no args) already prints the
  current default workspace, so the two were doing the same thing.
- Rename `workspace members` to `workspace member list` to free up the
  `member` namespace for future `add` / `remove` subcommands and align
  with the rest of the CLI's `<resource> <verb>` shape.
- Add `--full-id` to `workspace list`, matching `project list`,
  `autopilot list`, and friends.

Docs and the daemon prompt are updated to match.

Co-authored-by: multica-agent <github@multica.ai>
2026-05-19 17:54:21 +08:00
Multica Eve
240792d5e0 docs: add 2026-05-19 changelog entry (#2863)
* docs: add 2026-05-19 changelog entry

Co-authored-by: multica-agent <github@multica.ai>

* docs: refine 2026-05-19 changelog copy

Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: Eve <eve@multica-ai.local>
Co-authored-by: multica-agent <github@multica.ai>
2026-05-19 17:45:04 +08:00
Joey Frasier (Boothe)
76cd8275ff fix(openclaw): parse whole buffer instead of line-by-line scanner (MUL-1908) (#2292)
* fix(openclaw): parse whole buffer instead of line-by-line scanner

Follow-up to c87d7676 (WOR-10). The stdout/stderr swap fixed the dominant
case but `processOutput` still scanned line-by-line and only attempted a
whole-buffer parse from a fragile fallback path. Pretty-printed JSON
(openclaw 2026.5.x emits the result blob indented across many lines) made
every individual line unparseable on its own — `{`, `  "payloads": [`,
`    {`, etc. — so the success path hinged entirely on the fallback
joining `rawLines` and re-trying.

Under load (daemon restarts racing the close-on-cancel goroutine, partial
chunked reads when stdout closes mid-flight) the line scanner could see
truncated input that never reassembled into valid JSON, surfacing
"openclaw returned no parseable output" against runs where the agent had
in fact completed the work and posted comments. Roughly 30–40% of recent
runs in v0.2.27 logs hit this path; multica still wrote a `task_failed`
inbox row for each one even though the underlying issue had moved to
`in_review` or `done`.

The fix:

- processOutput now reads the full stdout buffer with `io.ReadAll` first.
- A new `parseWholeBufferOpenclawResult` helper attempts a single
  `json.Unmarshal` against the entire buffer (after trimming, and after
  optionally stripping leading non-JSON log lines). When it matches, we
  build the result and return — the line scanner never runs.
- If the whole-buffer parse fails, we fall through to the existing NDJSON
  line-by-line scanner. This preserves streaming-event support (kept for
  forward compatibility and other backends) without leaving openclaw's
  dominant pretty-printed shape at the mercy of timing.
- The failure path now emits a `(got N bytes; preview: ...)` suffix on
  the canonical "no parseable output" error so future debugging isn't
  blind. The exact canonical phrase is preserved for empty buffers so
  existing dashboards / log-grep tooling keep matching.

Tests:

- TestOpenclawProcessOutputWholeBufferPrettyJSON: feeds a hand-crafted
  multi-line indented blob (multiple payloads, nested agentMeta, usage
  map) and asserts every field round-trips through the whole-buffer fast
  path.
- TestOpenclawProcessOutputDeeplyIndentedFixture: re-runs the recorded
  openclaw 2026.5.5 stdout fixture (1070 lines) directly through
  parseWholeBufferOpenclawResult, asserting the bug-shape parses cleanly
  on the first attempt without falling through to NDJSON scanning.
- TestOpenclawProcessOutputEmptyBufferErrorIncludesByteCount: tightens
  the empty-buffer failure path, asserts the canonical phrase survives so
  observability tooling keeps working.

All existing tests in the openclaw + buildOpenclawArgs suites stay green
(streaming NDJSON event tests, lifecycle tests, structured-error tests,
usage-field-variant tests). The two pre-existing flaky timeout-tight
codex tests (TestCodexExecuteSemanticInactivityAllowsContinuous*) fail on
both this branch and on c87d7676 baseline; they are unrelated and out of
scope here.

Co-authored-by: multica-agent <github@multica.ai>

* fix(openclaw): drop dead preview branch, document streaming regression

Rebase + review-fix follow-up on top of f27df2d9b.

processOutput's preview branch was unreachable: openclawNoParseableOutputError
was only called from the `!gotEvents && trimmed == ""` path, which by
construction means the entire scanned buffer collapsed to whitespace, so the
`(got N bytes; preview: ...)` formatter could never fire on a non-empty buffer.
Replace the helper with a single canonical-string constant (callsite is now
inline) and update the test name to match what it actually asserts (the
canonical empty-buffer error string is preserved for external log-grep /
dashboard consumers).

Also document on processOutput that the line-scanner path is no longer
truly streaming after the io.ReadAll switch: events accumulate until
stdout closes. OpenClaw 2026.5.x does not emit streaming events so this
regression is invisible today, but flag it for the next backend that
might.

Misc: switch the scanner's input source from
`strings.NewReader(string(buf))` to `bytes.NewReader(buf)` to drop one
unnecessary byte/string round-trip.

MUL-1908

Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: multica-agent <github@multica.ai>
Co-authored-by: J (Multica agent) <j@multica.local>
2026-05-19 17:42:41 +08:00
Bohan Jiang
54368fd826 feat(projects): scheduled-only Gantt data source + WS reactivity (MUL-1881) (#2856)
* feat(projects): scheduled-only Gantt data source + WS reactivity (MUL-1881)

Project Gantt now fetches its own scheduled-only data instead of riding the
Board/List pagination cache. The Unscheduled drawer and pagination warning
banner are gone, and any WS-driven issue change (create / update / delete)
invalidates the new cache so the timeline stays live.

- Backend: `GET /api/issues?scheduled=true` adds an
  `(i.start_date IS NOT NULL OR i.due_date IS NOT NULL)` predicate on both
  ListIssues and CountIssues. New SQL filter is plumbed through sqlc + handler.
- Frontend: new `projectGanttIssuesOptions(wsId, projectId)` issues a single
  fetch and lives under its own cache key. WS handlers and mutations
  invalidate the prefix on create/update/delete so the bar reacts to
  start_date / due_date changes from other tabs and from this tab without
  waiting on the WS round-trip.
- GanttView: drops the Unscheduled section, the pagination warning banner,
  and the load-all button; renders only scheduled rows.
- Removes now-dead `useLoadAllRemaining`, `myIssueListPaginationOptions`,
  `summarizeIssueListPagination`, and the gantt locale strings that
  supported the old plumbing.

Co-authored-by: multica-agent <github@multica.ai>

* fix(projects): page through Gantt fetch and isolate per-view data sources

- Walk paginated `scheduled=true` issues until total is reached so projects
  with more than 500 scheduled bars no longer silently truncate.
- Gantt mode disables the bucketed Board/List query and reads its own
  scheduled cache for the project empty-state check, so the page never
  short-circuits Gantt with a Board-derived "no issues" CTA.
- `onIssueLabelsChanged` patches matching rows in the Project Gantt cache
  in-place, keeping label filters consistent after attach/detach from
  other tabs or agents.

MUL-1881

Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: multica-agent <github@multica.ai>
2026-05-19 17:04:16 +08:00
Naiyuan Qing
d46e90ee0a refactor(editor): keep <Attachment> image rendering as a pure port of the original ImageView (#2857)
Earlier the unification commit dragged in a Tailwind override stack
(ring, rounded-md, transition-shadow, bg-background/95, button hover
classes) "to make standalone surfaces work without .rich-text-editor
scope". Because the legacy CSS rules were not removed, both layers
applied in the editor, producing a visible double-stroke selection
ring and a light-theme hover on top of the dark-glass toolbar.

This commit reverts the styling churn:

- ImageAttachmentView now emits the same span-only DOM as the original
  ReadonlyImage: <span.image-node> > <span.image-figure> > <img.image-content>
  + <span.image-toolbar> with naked <button> children. No Tailwind tax.
- The `.image-*` rules in content-editor.css are de-scoped from
  `.rich-text-editor` so the single set of styles also drives chat /
  AttachmentList renders. Editor-only behavior (640px cap, NodeView
  centering) stays under the `.rich-text-editor` scope.
- A `data-clickable` attribute carries the "this image is clickable
  to preview" hint that the readonly cursor rule used to key off the
  `.rich-text-editor.readonly` scope.
- ImageView NodeViewWrapper no longer adds its own `image-node` class
  because `<Attachment>` already emits one; the duplicate was harmless
  but redundant.

Visual: editor + readonly comments render identical to before. Chat /
AttachmentList previously rendered a gray file card for images (the
P0 fix in the parent commit) and now match the editor visual without
the heavy-handed Tailwind detour.

Tests: 98 attachment-related tests pass; full `pnpm typecheck` + `pnpm
test` (652 tests) green.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 16:58:45 +08:00
Naiyuan Qing
6901325761 fix(desktop): open HTML preview in background tab and close modal (MUL-2418) (#2854)
Two independent root causes made "Open in new tab" on a desktop
attachment-preview modal feel like "the popup is still there and the
current tab got replaced":

1. `AttachmentPreviewModal.handleOpenInNewTab` never called `onClose()`,
   so the modal stayed mounted over the new tab.
2. Both `DesktopNavigationProvider.openInNewTab` and
   `TabNavigationProvider.openInNewTab` called
   `store.setActiveTab(tabId)` after `store.openTab(...)`, which stole
   focus to the new tab — violating the type contract
   ("Desktop only: open a path in a new background tab") and matching
   neither Chrome's cmd+click default nor the user's expectation.

Fixes:
- Modal: always call `onClose()` after dispatching the navigation
  (desktop adapter path and web `window.open` fallback path).
- Desktop navigation: drop the post-`openTab` `setActiveTab` call in both
  providers. `openTab` already preserves `activeTabId` for new paths and
  switches to the existing tab when the path is already open, which is
  exactly the background-tab semantics the type contract advertises.

Tests:
- `attachment-preview-modal.test.tsx`: assert `onClose` is invoked on
  both the desktop and web fallback branches.
- `pageview-tracker.test.tsx`: rename the "openInNewTab / addTab" case
  so the comment no longer claims `openInNewTab` activates the new tab.
- New `apps/desktop/.../platform/navigation.test.tsx`: assert that
  `openInNewTab` on both providers calls `openTab` and never
  `setActiveTab` for same-workspace paths, and routes cross-workspace
  paths through `switchWorkspace`.

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-05-19 16:45:18 +08:00
Naiyuan Qing
c49c78b780 fix(editor): make in-iframe #fragment links scroll in HTML attachment preview (MUL-2417) (#2855)
HTML attachment previews mount the document inside a sandboxed
`<iframe srcdoc>` deliberately WITHOUT `allow-same-origin` — uploads are
untrusted user content. Chromium treats fragment-link clicks inside such an
opaque-origin srcdoc iframe as cross-origin frame navigation and silently
rejects them, so clicking a TOC entry never scrolls.

Append a tiny shim script to the srcdoc that intercepts `<a href="#...">`
clicks inside the iframe and calls `scrollIntoView` directly. The shim runs
in the iframe's own opaque origin under `allow-scripts` — no new
capabilities, no sandbox token changes; it cannot reach parent / cookies /
localStorage.

All three HTML attachment surfaces share the same helper:
  - inline 480px card  (html-attachment-preview.tsx)
  - full-screen modal  (attachment-preview-modal.tsx)
  - full-page route    (attachment-preview-page.tsx)

References: whatwg/html#3537, crbug 40191760.

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-05-19 16:44:55 +08:00
Bohan Jiang
fd6ccbe371 feat(desktop): forward renderer console + crash events to main stderr in dev (#2853)
When the renderer crashes hard enough to leave a white window (React
boundary unrecoverable, syntax error during initial mount, preload
script throw), DevTools can't be opened and the only signal in the
`make dev` terminal is the daemon-manager 5s polling complaint
("Render frame was disposed before WebFrameMain could be accessed").
That's a downstream symptom — the actual JS error is unreachable, so
the user has no path to diagnose without restarting the renderer
(which loses the failure mode entirely).

Add four webContents listeners on the main BrowserWindow, gated by
`is.dev` so packaged builds keep their stderr clean:

- `console-message`: forwards every renderer `console.*` to main's
  stderr with file:line. React error boundaries, `window.onerror`, and
  unhandled-rejection handlers all surface here.
- `render-process-gone`: serialises the GoneDetails (`crashed` / `oom`
  / `killed` / `launch-failed`) so the user sees *why* the renderer
  died, not just that it did.
- `did-fail-load`: catches loadURL/loadFile failures. Skip
  `errorCode === -3 (ABORTED)` because that's the normal HMR-induced
  navigation abort.
- `preload-error`: the one error class DevTools can never show, because
  preload runs before the window owns a console. Without this listener
  preload throws are invisible.

All output is prefixed with `[renderer <tag>]` so it's easy to grep
distinct from main's own logs.

No behavioural change in production: the entire block is inside an
`is.dev` guard. Packaged builds keep their existing stderr.
2026-05-19 16:42:12 +08:00
316 changed files with 21680 additions and 6995 deletions

View File

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

View File

@@ -40,7 +40,7 @@ Closes #
- [ ] I have added or updated tests where applicable
- [ ] If this change affects the UI, I have included before/after screenshots
- [ ] I have updated relevant documentation to reflect my changes
- [ ] If I added a new runtime / coding tool / UI tab, I synced the change to **landing copy** (`apps/web/features/landing/i18n/`), **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

View File

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

View File

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

View File

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

View File

@@ -79,7 +79,7 @@ For file uploads and attachments, configure S3 and (optionally) CloudFront:
| `S3_BUCKET` | Bucket name only (e.g. `my-bucket`). Do **not** include the `.s3.<region>.amazonaws.com` suffix — the server constructs the public URL from `S3_BUCKET` + `S3_REGION` |
| `S3_REGION` | AWS region (default: `us-west-2`). Must match the bucket's actual region — used for both SDK signing and public URLs |
| `AWS_ACCESS_KEY_ID` / `AWS_SECRET_ACCESS_KEY` | Static credentials. When both are unset, the AWS SDK default credential chain is used |
| `AWS_ENDPOINT_URL` | Custom S3-compatible endpoint (e.g. MinIO, R2, B2). Setting this switches 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) |

View File

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

View File

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

View File

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

View File

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

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

View File

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

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

View File

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

View File

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

View File

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

View File

@@ -5,7 +5,7 @@ description: "An agent is a first-class member of a Multica workspace — it can
import { Callout } from "fumadocs-ui/components/callout";
An agent is a **first-class member** of a Multica [workspace](/workspaces) — like a human, it can be [assigned issues](/assigning-issues), speak up in [comments](/comments), be [`@`-mentioned](/mentioning-agents), and lead a [project](/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.

View File

@@ -5,7 +5,7 @@ description: 智能体agent是 Multica 工作区里的一等公民成员
import { Callout } from "fumadocs-ui/components/callout";
智能体agent是 Multica [工作区](/workspaces) 里的**一等公民成员**——和人一样能被 [分配 issue](/assigning-issues)、在 [评论](/comments) 里发言、被 [`@` 点名](/mentioning-agents)、作为 [project](/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
从协作视图上看,智能体就是工作区里的一个成员;它和人的名字排在同一张成员列表里,只是前面通常有一个机器人图标。

View File

@@ -35,7 +35,7 @@ multica issue assign MUL-42 --to alice
multica issue assign MUL-42 --to-id 5fb87ac7-23b5-4a7a-81fa-ed295a54545d
```
`--to` takes a member username or an agent name (fuzzy match). When names overlap — e.g. an agent `J` alongside `Cursor - J` — pass `--to-id <uuid>` instead, using the `user_id` (member) or `id` (agent) from `multica workspace 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:

View File

@@ -35,7 +35,7 @@ multica issue assign MUL-42 --to alice
multica issue assign MUL-42 --to-id 5fb87ac7-23b5-4a7a-81fa-ed295a54545d
```
`--to` 后跟成员用户名或智能体名字(模糊匹配)。如果工作区里有同名 / 互相含子串的成员或智能体(例如 agent `J` 旁边还有 `Cursor - J`),改用 `--to-id <uuid>`UUID 来自 `multica workspace 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` 互斥。
取消分配:

View File

@@ -39,7 +39,7 @@ For the difference between token types, see [Authentication and tokens](/auth-to
|---|---|
| `multica workspace list` | List every workspace you can access |
| `multica workspace get <slug>` | Show details for one workspace |
| `multica workspace 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

View File

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

View File

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

View File

@@ -72,7 +72,7 @@ multica daemon status
In the web UI, go to **Settings → Runtimes**. The daemon you just started should appear as one or more active runtimes — one per AI coding tool installed locally.
If it shows as offline, don't panic — see [Troubleshooting → Daemon can't 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**:

View File

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

View File

@@ -180,12 +180,12 @@ The [GitHub PR ↔ issue integration](/github-integration) needs two variables.
| Variable | Default | Description |
|---|---|---|
| `GITHUB_APP_SLUG` | empty | The slug of your GitHub App (the tail of `https://github.com/apps/<slug>`). Drives the Settings → 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.

View File

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

View File

@@ -219,7 +219,7 @@ For file uploads and attachments, configure S3 and (optionally) CloudFront:
| `S3_BUCKET` | Bucket name only (e.g. `my-bucket`). Do **not** include the `.s3.<region>.amazonaws.com` suffix — the server constructs the public URL from `S3_BUCKET` + `S3_REGION` |
| `S3_REGION` | AWS region (default: `us-west-2`). Must match the bucket's actual region — used for both SDK signing and public URLs |
| `AWS_ACCESS_KEY_ID` / `AWS_SECRET_ACCESS_KEY` | Static credentials. When both are unset, the AWS SDK default credential chain is used |
| `AWS_ENDPOINT_URL` | Custom S3-compatible endpoint (e.g. MinIO, R2, B2). Setting this switches 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) |

View File

@@ -5,7 +5,7 @@ description: Connect a GitHub App once, then PRs whose branch, title, or body re
import { Callout } from "fumadocs-ui/components/callout";
Connect a GitHub account or organization once in **Settings → 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.

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -90,7 +90,7 @@ Every event is assigned to one dashboard category:
| Category | Events |
|---|---|
| `core_loop` | `workspace_created`, `runtime_registered`, `runtime_ready`, `runtime_failed`, `runtime_offline`, `agent_created`, `issue_created`, `chat_message_sent`, `agent_task_queued`, `agent_task_dispatched`, `agent_task_started`, `agent_task_completed`, `agent_task_failed`, `agent_task_cancelled`, `autopilot_run_started`, `autopilot_run_completed`, `autopilot_run_failed` |
| `onboarding_support` | `onboarding_started`, `onboarding_questionnaire_submitted`, `onboarding_completed`, `onboarding_runtime_path_selected`, `onboarding_runtime_detected`, `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

View 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.2hourly 表的 `task_count` 仅作信息参考。
- **`runtime_id``NOT NULL`**`agent_task_queue.runtime_id` 本身带 `NOT NULL` 约束migration 004所有建队列的写入路径含 quick-create都会带上 runtime所以 rollup 永远不会产生 no-runtime 的 bucket。`project_id` 可空是因为任务确实可以不挂 project。
migration 101 同时建了两张配套表:
- `task_usage_hourly_rollup_state` —— 单行 watermark 状态表(与 073/084 的 rollup_state 同形)。
- `task_usage_hourly_dirty` —— 失效队列,承载 `updated_at` watermark 看不到的失效(`task_usage` 的 DELETE、级联 DELETE、`issue.project_id` / `agent_task_queue.runtime_id` 改动导致的重新归属)。**必须配 TTL**,见 §4.4。
**这一张表替换两张现有表**
- `task_usage_daily` (migration 073, 082) — 含 runtime_id物化在 runtime tz
- `task_usage_dashboard_daily` (migration 084) — 含 agent_id/project_id物化在 UTC
合并后 PK 同时包含 runtime / agent / project 三个维度,可以从同一张表派生出所有现有视图。
### 4.2 查询模式
Token 类报表查询从 `task_usage_hourly` 派生,按调用方传入的 `@tz` 在查询时折算日界。**成本不在 SQL 里算**——查询只 `SUM` token 列并保留 `model` 维度,成本由客户端按 per-model 定价表折算(所以按日期分组的查询会保留 `model`,按 agent 分组的也是)。
```sql
-- Workspace dashboard 趋势图 ListDashboardUsageDaily按 viewer tz 切日,保留 model
SELECT DATE(bucket_hour AT TIME ZONE @tz::text) AS date,
model,
SUM(input_tokens)::bigint AS input_tokens,
SUM(output_tokens)::bigint AS output_tokens,
SUM(cache_read_tokens)::bigint AS cache_read_tokens,
SUM(cache_write_tokens)::bigint AS cache_write_tokens,
SUM(task_count)::int AS task_count
FROM task_usage_hourly
WHERE workspace_id = $1
AND bucket_hour >= @since::timestamptz
AND (@project_id::uuid IS NULL OR project_id = @project_id)
GROUP BY DATE(bucket_hour AT TIME ZONE @tz::text), model
ORDER BY DATE(bucket_hour AT TIME ZONE @tz::text) DESC, model;
-- Runtime detail 趋势图 ListRuntimeUsage按 viewer tz 切日tz 来自 user 不是 runtime
SELECT DATE(bucket_hour AT TIME ZONE @tz::text) AS date,
provider, model,
SUM(input_tokens)::bigint AS input_tokens,
...
FROM task_usage_hourly
WHERE runtime_id = $1
AND bucket_hour >= @since::timestamptz
GROUP BY DATE(bucket_hour AT TIME ZONE @tz::text), provider, model
ORDER BY DATE(bucket_hour AT TIME ZONE @tz::text) DESC, provider, model;
-- Per-agent 视图 ListDashboardUsageByAgent / ListRuntimeUsageByAgent
-- 不按日期分组 → 不需要 @tz只用 @since 截断(@since 已是 viewer tz 折算后的 UTC 瞬间)。
SELECT agent_id, model,
SUM(input_tokens)::bigint AS input_tokens,
...
FROM task_usage_hourly
WHERE workspace_id = $1
AND bucket_hour >= @since::timestamptz
GROUP BY agent_id, model
ORDER BY agent_id, model;
```
**两类查询不走 `task_usage_hourly`**
- **Time / Tasks 指标**dashboard 的"时长 / 任务数"标签页)由独立查询 `ListDashboardRunTimeDaily` / `ListDashboardAgentRunTime` 直接打 `agent_task_queue`,按 `completed_at AT TIME ZONE @tz` 切日——任务时长来自队列的 `started_at`/`completed_at`,不是 token rollup 能表达的。它们同样吃 `@tz`,保证 Tokens/Cost/Time/Tasks 四个标签页的日界一致。
- **Runtime hour-of-day Heatmap**`GetRuntimeUsageByHour` / `GetRuntimeTaskActivity`)仍直接扫原始 `task_usage` / `agent_task_queue`,按 **viewer tz**`resolveViewingTZ` 解析出的 `@tz`)做 `EXTRACT(HOUR FROM ... AT TIME ZONE @tz)`。Heatmap 窗口小(单 runtime、近 30/90draw 扫描足够快,没有必要从 hourly 表派生。
### 4.3 性能预估
单 workspace 90d 窗口的 `task_usage_hourly` 行数:
| 工况 | 行数估算 | 趋势图查询代价 |
|---|---|---|
| 小5 agent × 2 model × 2 active hour × 90d | ~1.8k | <5ms |
| 中5 agent × 2 model × 8 active hour × 90d | ~7.2k | <10ms |
| 大5 agent × 2 model × 16 active hour × 90d | ~14.4k | ~15ms |
| 巨大20 agent × 5 model × 16 active hour × 90d | ~144k | ~50ms |
和现有 daily rollup 在同一档。Leaderboard / per-agent / per-project 视图同样指标。
### 4.4 Rollup worker 改造
现有两张 rollup 表的写入逻辑合并成一条管线,实现见 migration `102_task_usage_hourly_pipeline`(触发器 + 窗口函数 + 失效队列 TTL + pg_cron 调度):
- 源数据扫描不变(仍然扫 `task_usage` 增量 + 失效队列)。`bucket_hour``task_usage_hour_bucket(tu.created_at)`UTC 整点截断)。
- Upsert 目标从两张 daily 表改为一张 `task_usage_hourly`
- 失效队列维度由 `(bucket_date, …)` 改为 `(bucket_hour, …)``task_usage_hourly_dirty`),由 `task_usage` / `agent_task_queue` / `issue` 上的触发器写入。**必须配 TTL保留 7 天)**否则脏行在密集工况下无界增长——这是整个设计最容易漏的正确性要求hourly 粒度把脏面比 daily 放大了 ~24×
- 调度入口 `rollup_task_usage_hourly()` 由 pg_cron 周期触发:取 advisory lock → 从 `task_usage_hourly_rollup_state` 读 watermark → 调 `rollup_task_usage_hourly_window(from, to)` 重算脏 bucket → 推进 watermark → 释放锁后跑 `prune_task_usage_hourly_dirty()`。单 tick 窗口上限 1 天watermark 落后时分多次 tick 追平,不会一条语句锁表重算多周。
源表扫描是 worker 的主要开销,目标表换粒度只让单 tick 多几十 ms upsert不会成倍增长。
### 4.5 Migration 082 的副作用消除
当前 `runtime.timezone` 的 PATCH 处理migration 082 + 现有 handler会触发该 runtime 的整张 `task_usage_daily` 重新物化——因为 `bucket_date` 含了 tz。
新方案下 `bucket_hour` 永远是 UTC**`runtime.timezone` 改变不再触发任何数据层操作**。改 tz 立即生效,零 backfill。这同时修掉了
- 改 tz 期间的 race condition旧 bucket 还没重灌完,新查询已经按新 tz 渲染)。
- daemon 第一次注册时探测到非 UTC 的 tz 但历史 rollup 还是 UTC 的尴尬过渡期。
---
## 5. UI / UX 影响
### 5.1 Runtime Detail 页
| 组件 | 重构前 tz 来源 | 重构后 tz 来源 |
|---|---|---|
| Daily / Weekly 趋势图 | `runtime.timezone` | `user.timezone ?? browserTimezone()` |
| KPI 卡片 | `runtime.timezone`(隐式) | `user.timezone ?? browserTimezone()` |
| 日历活跃热力图 | `runtime.timezone` 锚点 + viewer-tz 数据(不一致 bug | `user.timezone ?? browserTimezone()`(锚点与数据统一) |
| Hour-of-day Heatmap | `runtime.timezone` | `user.timezone ?? browserTimezone()` |
| Timezone editor | 写 `runtime.timezone` | **删除** |
**用户可感知的行为变化**
- Runtime Detail 页所有图表统一跟随 viewer 自己的 tz页面上不再有任何 runtime 级 tz 控件。
- 想换报表 tz 的用户去 Settings → Preferences 改一次,所有 workspace / runtime 的报表立刻全跟着变。
- 跨 region 团队hour-of-day heatmap 按查看者 tz 显示活跃时段(已知且接受的取舍,见 §2.1)。
### 5.2 Workspace Usage 页
恢复"按 viewing tz 渲染"的能力,但**不放页面级 picker**。理由:
- Picker 当年被加上去就是因为没有持久化的 viewing tz 概念。现在有了 `user.timezone`picker 的诉求被 Preferences 替代。
- 页面级 picker 容易让用户误以为"这是一个 view-state",但 viewing tz 是全应用属性,不是单页设置。
- 减少 UI 控件 = 减少认知负担。
`packages/views/dashboard/components/dashboard-page.tsx` 里的 `WEEK_TZ = "UTC"` 改成 `useViewingTimezone()`hook 见 `packages/views/common/use-viewing-timezone.ts`),相应的解释性注释删除。
### 5.3 Preferences 页
新增一个 Timezone setting和现有的语言 / 主题等并列。
---
## 6. 实施
> 产品尚未上线,无存量用户需保护,全部变更作为一组迁移一次性交付——旧的 daily 管线在同一分支里直接拆除,不保留共存期。
整套变更落在分支 `feat/timezone-architecture`migration 100104
| Migration | 内容 |
|---|---|
| `100_user_timezone` | 加 `"user".timezone`nullable |
| `101_task_usage_hourly_schema` | 建 `task_usage_hourly` + `task_usage_hourly_rollup_state` + `task_usage_hourly_dirty` + 索引 |
| `102_task_usage_hourly_pipeline` | 失效触发器、`rollup_task_usage_hourly_window` 窗口函数、`prune_task_usage_hourly_dirty()` 失效队列 TTL、带单日 cap 与 prune 的 `rollup_task_usage_hourly()` cron 入口、pg_cron 调度 |
| `103_drop_legacy_daily_rollups` | 拆掉 `task_usage_daily` / `task_usage_dashboard_daily` 两条旧管线表、函数、触发器、pg_cron 任务) |
| `104_drop_runtime_timezone` | 删除 `agent_runtime.timezone`Operational 层移除,见 §2.1 |
配套的代码侧改动:
- **数据回填**:一次性命令 `cmd/backfill_task_usage_hourly`,按 workspace 切片把历史 `task_usage` 灌进新表。旧的 `cmd/backfill_task_usage_daily` / `cmd/backfill_task_usage_dashboard_daily` 已删除。
- **查询切换**:后端所有报表查询迁到 `task_usage_hourly`(或 Time/Tasks 的 `agent_task_queue` 查询),统一接受 `@tz``UseDailyRollupForDashboard` / `UseDailyRollupForRuntimeUsage` 等 feature flag 与旧的 raw-scan / daily-rollup 双查询路径一并删除。
- **前端打通**`useViewingTimezone()` hook 解析 viewer tz报表组件随请求带 `?tz=``dashboard-page.tsx``WEEK_TZ = "UTC"` 改为 `useViewingTimezone()`,原 UTC-lock 解释性注释删除。
- **UI 文案**Preferences 新增 Timezone setting。Runtime Detail 页的 timezone editor 整体删除。
- **runtime tz 移除**`PATCH /api/runtimes/:id``timezone` 字段删除,该端点只剩 `visibility`daemon 注册不再上报 host tz`agent_runtime.timezone` 列由 migration 104 删除。
---
## 7. Open questions / Risks
### 7.1 Risks
- **Invalidation queue TTL 是必做**。如果忘记加,密集工况下 queue 会无界增长。
- **Hourly rollup backfill 期间的源表 read pressure**。按 workspace 切片、低峰期跑,预期 OK但需要提前给 DB 团队打招呼。
- **DST 当天的 23h/25h "日"**。`DATE(bucket_hour AT TIME ZONE @tz)` 会正确处理,但前端任何"一天 = 24 小时"的硬编码偏移逻辑要测一遍 DST 边界。
- **现有 `runtime.timezone` 的 PATCH endpoint 行为变了**。改完不再触发 backfill——这是好事但 API 文档和 changelog 要写清楚,避免下游集成误判。
### 7.2 Open question
- **Trigger 的 timezone 默认值**?目前用户必须手动选;可以默认 `user.timezone`,但用户写 trigger 时的 viewing tz 和 trigger 实际跑的 tz 是两件事,需要产品决策。
### 7.3 非目标
- **不做** workspace 级 tz 设置:跨 region 团队两个成员各自正确的"今天"不同workspace 级 tz 必让其中一方看错位报表。
- **不做** 预物化多 tz rollupIANA tz 列表有 ~600 个无法穷举、DST 需逐 tz 维护,而 hourly rollup 已经够快。
- **不做** issue / comment / inbox 等列表的 tz 切换——它们已经隐式用浏览器 tz本 RFC 不动。后续如果要让这些也跟 `user.timezone`,是独立的 follow-up。
---
## 8. 决策汇总
| 决策点 | 选择 |
|---|---|
| Timezone 概念分层 | Scheduling / Viewing 两层Operational 经盘查后移除) |
| `runtime.timezone` 角色 | ❌ 整列删除migration 104 |
| `user.timezone` 是否新增 | ✅ 新增nullable默认 fallback 到 browser |
| `workspace.timezone` 是否新增 | ❌ 不引入 |
| 数据层物化口径 | 统一 UTC, hourly grain |
| Rollup 表合并 | `task_usage_daily` + `task_usage_dashboard_daily``task_usage_hourly` |
| 报表 tz 切换粒度 | 全局 per-userPreferences不做 per-view picker |
| hour-of-day heatmap tz | viewer tz不再用机器物理 tz |

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

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

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

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

@@ -55,8 +55,8 @@ export async function completeOnboarding(
/**
* Runtime-connected onboarding path. The server creates or reuses the
* default Multica Helper agent and the single onboarding issue, 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,

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

View File

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

View File

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

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

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -145,21 +145,21 @@ export function ModelPicker({
// string actually ships to the agent.
tooltip={m.label !== m.id ? `${m.label} · ${m.id}` : m.id}
>
<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>
))}

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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