Commit Graph

662 Commits

Author SHA1 Message Date
Jiayuan Zhang
2274f521dc feat(issues): agents-working chip on the sub-issues header (#5825) (#5834)
* feat(issues): aggregate agents-working chip on the sub-issues header (#5825)

Add a live "N agents working" chip next to the sub-issues progress ring
in issue detail. The per-row IssueAgentActivityIndicator shows which
sub-issue is being worked; this chip shows how many agents are on the
parent's children at a glance — and keeps that signal visible while the
list is collapsed.

Derives from the shared workspace agent-task snapshot narrowed by a new
selectIssuesTasks select (structural sharing keeps unrelated snapshot
churn from re-rendering the header). Counts unique agents to match the
workspace chip, whose chip_agents_working / hover_header_queued strings
it reuses — already translated in every locale. Hover opens the shared
AgentActivityHoverContent task list.

Fixes #5825

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

* refactor(issues): read the sub-issues chip from the working-agents projection (#5825)

The chip landed deriving its own count from the workspace agent-task
snapshot, which put a second definition of "an agent is working" in the
client. It showed up immediately: the number came from the running tasks
only while the hover body listed running plus queued, so a parent with 2
running and 3 queued agents read "2 agents working" over a five-row card.

A header count is a claim about a scope, so let the server own both the
scope and the arithmetic, exactly as the Issues list header already does.
ListWorkspaceWorkingAgents grows an optional parent_issue_id narrowing and
the chip reads /api/working-agents?type=issue&parent=<id>. The number, the
avatars and the hover body are now one list rather than three derivations,
so they cannot disagree.

Row indicators keep reading the snapshot. One shared query sliced per row
is the right shape for a per-row cue and a stale row decoration costs
nothing; a header number is the opposite, it has to be authoritative.

The new parameter is additive: omitted, the query and the response are
byte-for-byte what they were, so an installed client that never sends it
keeps the workspace-wide behaviour. A regression test pins that.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Lambda <lambda@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
Co-authored-by: Naiyuan Qing <145280634+NevilleQingNY@users.noreply.github.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-28 14:30:19 +08:00
Bohan Jiang
50c89c7094 feat(autopilots): hide webhook URL token by default (MUL-5374) (#6015)
* feat(autopilots): hide webhook URL token by default (MUL-5374)

The webhook URL is a bearer credential — anyone who reads it off a screen
share or screenshot can fire the autopilot. GH #6004 reports exactly that
leak during a live demo.

The trigger row and the post-create panel now render the URL through a
shared WebhookUrlField that masks the token segment by default. Clicking
the value (or the eye toggle) reveals it; Copy keeps working while hidden,
so the common case never needs a reveal. The plaintext token is not in the
DOM until the user asks for it — this is a real display boundary, not a
CSS blur.

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

* fix(autopilots): scope webhook URL reveal to the URL it was granted for (MUL-5374)

The reveal was a bare boolean, so a token rotation under a mounted trigger
row swapped in the new URL while `revealed` was still true — exposing the
new credential in the clear at the exact moment the user rotated to contain
a leak.

Track which URL the reveal was granted for and derive `revealed` during
render. Deriving it (rather than resetting in an effect) means the new
token never reaches the DOM, not even for the pre-effect frame.

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

---------

Co-authored-by: Bohan-J <bohan@devv.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-28 14:10:42 +08:00
Bohan Jiang
c271f80999 MUL-5370 fix: label stalled skill-bundle downloads, align failure-reason copy with the backend taxonomy (#6001)
* fix(daemon): label stalled skill-bundle downloads and make them retryable

A skill bundle that could not be downloaded during task preparation surfaced
as the bare string "resolve skill bundles: context deadline exceeded".
taskfailure.Classify has no rule for a Go context deadline, so it landed in
agent_error.unknown — a bucket that is NOT on the server's retry allowlist.
A transient stall therefore became a terminal chat failure carrying a label
nobody could act on, and the failure was invisible on the Usage page's Errors
breakdown. (MUL-5370)

- Add the platform-side reason skill_bundle_unavailable and put it on
  retryableReasons. Retrying is cheap and safe: the agent process never
  started, and bundles that did arrive are already cached on disk, so
  successive attempts converge.
- Carry a sentinel error from the resolve loop so the reason is derived
  structurally rather than by matching the wrapped transport error's text,
  and name the skill, its declared size and the elapsed wait in the wrap —
  enough to tell "this bundle is too big for the link" from "the link is
  dead" without reading daemon logs.
- Normalise the wire shape an OLD daemon produces (a non-empty catchall plus
  the previous "resolve skill bundles:" wrapper) on the server side. Installed
  daemons upgrade on their own cadence, and FailTask only classifies when the
  caller supplied nothing, so without this the fix would reach only hosts that
  happened to update — while the un-upgraded hosts most likely to be hitting
  the bug kept failing terminally.
- Teach Classify about "deadline exceeded" and net/http's "Client.Timeout
  exceeded while awaiting" so any other Go-side deadline that reaches it as
  text stops falling into the unknown bucket too.
- Backfill historical rows in both agent_task_queue and chat_message. Scoped
  to agent_error.unknown alone — the old wrapper string postdates the
  in-flight classifier by three weeks, so no row carrying it can hold the
  legacy coarse value — which keeps the down migration an exact inverse.

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

* fix(chat): give chat its own failure copy for the refined reasons

#5991 rebuilt the operator-facing failure labels around an open wire string
with a raw-value fallback, but the chat bubble kept its own exact-key lookup
against the six coarse values from migration 055. So all 14 agent_error.*
values still missed and rendered the generic "Something went wrong and the
agent couldn't finish replying" — the classification the backend had already
computed was discarded at the last step, and that is the message the MUL-5370
reporter saw.

- Add resolveFailureReasonKey in packages/core: exact match, else degrade an
  `agent_error.*` value to its family, else undefined. A reason newer than the
  shipped client now lands on the family line instead of the fallback.
- Rekey the chat copy map by wire value and route it through the helper.
  Chat deliberately degrades to friendly copy rather than adopting the
  operator surfaces' raw-value fallback: it is read by the person who just
  sent a message, and the raw error is one click away under the collapsible.
- Add refined chat copy (en / zh-Hans / ja / ko) only where it can say
  something the family line can't — a different next step: network, auth,
  quota, rate limit, context overflow, missing/outdated CLI, skill download.
- Give skill_bundle_unavailable a label on the web and mobile surfaces and a
  class on the Usage page's Errors breakdown (runtime — the operator response
  is "check the daemon's link to Multica", the provider is not involved).
- Mobile's two label maps were still coarse-only for the same reason; rekey
  them by wire value and fill in the refined taxonomy.

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

---------

Co-authored-by: Bohan-J <bohan@devv.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-28 13:31:29 +08:00
Naiyuan Qing
77b309a5ac feat(drafts): unified draft lifecycle + upload ownership inversion (MUL-5181) (#5900)
* feat(drafts): unified draft lifecycle + upload ownership inversion (MUL-5181)

Unify how every composer preserves unsent work, sends, and handles uploads.

L1 foundation (packages/core/drafts):
- createDraftStore factory + self-registering cleanup-registry replacing the
  hand-maintained WORKSPACE_SCOPED_KEYS list; register-all-drafts guarantees
  registration completeness. Fixes the confirmed cross-user draft leak
  (persistence + in-memory) on logout / workspace delete.

L3 send paradigm:
- useComposerSubmit: one await-then-render contract (lock/spin, keep-on-fail,
  clear-on-success, single-flight, submit-time upload-gate), adopted by
  comment/reply/edit, create-issue, quick-create, and chat.

Per-surface:
- Comment/Reply/Edit: attachments moved into the persisted draft.
- Create Issue: draft split into shared/manual/agent/activeMode with
  non-destructive mode switching + migration for old flat drafts.
- Chat: optimistic send converted to await-then-render (kept server-driven
  cancel restore_to_input); chat draft keys registered for cleanup.

L2 upload coordinator (ownership inversion, Linear-validated shape):
- upload-coordinator + DraftUpload placeholder: uploads owned by a module
  coordinator that outlives the component, state persisted in the draft;
  AbortController + abort-on-logout; interrupted-on-reload. Comment surface
  fully wired. Create-issue/chat upload wiring is a documented residual.

Verified: core + views typecheck clean; core 1064 + views 2928 tests pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(drafts): close three review gaps in the unified draft lifecycle (MUL-5181)

1. Logout resurrection: reset in-memory draft stores BEFORE removing their
   persisted keys — each reset is a setState and persist writes it straight
   back under the still-active slug, so the old order re-created the deleted
   keys. The issue draft store's reset is now a full reset including
   lastAssignee, which clearDraft deliberately re-seeds and would otherwise
   hand the previous user's last-picked assignee to the next login.

2. Submit gate blind spot: the composer gate now also reads the draft's
   coordinator-owned upload placeholders (hasUploadingDraft). A composer
   reopened over a still-in-flight upload could previously send past the
   editor-only gate, clearing the draft out from under the settling upload.

3. Attachment binding returns to reference-filtering: a submit binds only
   uploads the body references, so deleting an inline image really unbinds
   it. An upload that settles after its mount died gets its markdown link
   written back into the body instead — via the reopened composer's live
   editor (new ContentEditorRef.insertMarkdownAtEnd) or appended to the
   persisted draft (new appendToDraftContent) — so close-surviving files
   stay visible, deletable, and honestly bound.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(drafts): harden upload write-back delivery after independent review

Review of the previous commit (fresh-context reviewer + probe against real
@tiptap/react) found the write-back could still lose a file:

- insertMarkdownAtEnd now returns a boolean: the imperative handle exists
  from first commit but the Tiptap instance arrives in a passive effect, so
  an insert in that window (or after destroy) no-ops. Callers previously
  assumed it landed.
- Write-back is now confirmed delivery (deliverFinishedUpload): insert into
  the live editor and, on success, persist the same body as insurance
  against the debounced emit being dropped by a quick unmount; append to
  the store only when NO composer is mounted (a mounted editor's first emit
  would erase a store-only append); retry while a mounted composer's
  instance is still warming up. Every attempt re-checks the generation
  guard and the body reference.
- mountedRef flips in a layout effect: React nulls the child editor ref in
  the unmount commit, and a settle in the gap before passive cleanup saw
  "mounted" with no editor left to swap.
- uploadAndInsertFile guards editor.isDestroyed after the await: now that
  uploads outlive mounts, the swap/remove paths could dispatch against a
  destroyed EditorView and escape as an unhandled rejection.
- Tests: the reopened-composer test now asserts the editor actually
  received the insert (it previously passed with liveEditors disabled),
  plus a warming-up retry case; the mock editor mirrors isDestroyed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(drafts): roll coordinated uploads out to issue-create and chat (MUL-5181 L2)

Completes the upload-ownership layer for every composer surface. The generic
engine is extracted from the comment implementation into
editor/use-coordinated-uploads (UploadDraftBinding adapter: store-backed
accessors + registry key + body append), and use-comment-uploads becomes a
thin binding over it — behavior unchanged, all comment tests green.

Issue-create (manual + agent panels):
- shared.attachments migrates Attachment[] -> DraftUpload[]; load normalizes
  legacy bare rows to `uploaded` and coerces stale `uploading` to
  `interrupted`.
- Uploads are coordinator-owned: placeholder at pick time, survives dialog
  close, aborts on logout, chips for uploading/failed/interrupted, combined
  gate on Create and both mode-switch actions.
- Write-back targets the body of the MODE that started the upload (manual
  description vs agent prompt); mount-time prune keeps placeholders and drops
  only unreferenced `uploaded` entries.

Chat (tab + floating window):
- inputDraftAttachments migrates to DraftUpload[] with load-time
  normalization; new store ops (add/settle/fail/remove upload, append-to-
  draft) mirror the comment store.
- ChatInput adopts the engine; the upload target is snapshotted at pick time
  via resolveUploadTarget so a file dropped while the editor is pinned to a
  previous session's document files under THAT draft.
- uploadMapRef is gone — the draft's uploads are the single binding source,
  reference-filtered at send. Hosts no longer own transport: onUploadFile
  prop becomes uploadEnabled, and the controller/window drop uploadWithToast.
- commitDraft prunes only `uploaded` entries the body no longer references;
  placeholders survive keystrokes (chips are their only UI).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(drafts): harden L2 rollout after independent review

- attachmentToDraftUpload now strips the response-scoped signed download_url
  before the row is persisted (draft uploads survive restarts; a stale
  signature 403s the preview on reopen). Covers comments, issue-create, and
  chat in one place; issue-create's settle reuses the helper, and the
  Signature assertion the rollout had dropped is restored.
- chat's live-editor registry follows the LOADED draft key (reactive mirror
  of editorDraftKeyRef): a settle for draft B must not insert into an editor
  still pinned to draft A's document.
- removeUpload aborts an in-flight request before dropping its placeholder.
- issue-create hasDraft counts only uploaded/uploading entries so a failed
  remnant can't pin the sidebar draft dot forever.
- Tests: mutation-proof coverage for the two placeholder-preservation rules
  (create-issue mount prune, chat commitDraft prune) — both previously
  survived rule inversion; direct core tests for the five new chat store
  upload ops incl. persistence and signed-URL stripping; quick-create test
  gets the editor i18n namespace; dead uploadWithToast scaffolding removed
  from both modal tests; chat-input mock aligned with the real append
  semantics.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(drafts): close third-round review gaps in the upload engine

- The live-editor registry registers in a layout effect: chat's adopt swaps
  the editor's document and loaded key synchronously during commit, and a
  passive re-registration one task later left a settle window where the old
  key mapped to an editor already holding another draft's document. The
  registry key is also built only when a binding exists.
- removeUpload aborts only a request THIS surface tracks as `uploading`
  (guarded before the abort), with the comment now honest about the path
  being defensive — no current chip exposes ✕ mid-upload.
- Mutation-proof test for the loaded-key registry rule: a dead mount's
  settle for a pinned draft must insert into the editor HOLDING it, not the
  selected one (verified to fail with the registry keyed by selection).
- hasDraft upload semantics pinned by tests (uploaded/uploading count;
  failed/interrupted remnants don't pin the sidebar dot).
- Dead scaffolding dropped: identity use-file-upload mocks and a redundant
  assertion in the modal tests.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(drafts): stale-submit draft guard + registry layout timing (review BLOCKED items)

Blocker 1 — a submit that outlives its composer may only consume the draft
it submitted (MUL-5181 P0). Every accepted-submit clear is now guarded:
- create-issue / quick-create snapshot the singleton draft's object identity
  at submit; a dead panel clears (and records last-assignee/mode) only if
  the draft is untouched, and never runs close/reset effects. A replaced
  draft B typed after close survives a late success of draft A.
- comment / reply / edit snapshot the per-key draft entry; a dead composer
  clears only the exact entry it submitted.
- chat snapshots the sent slot's value; a dead mount's commitInput clears
  only an unreplaced draft.
Mutation-verified tests for the create panels and comments (guard inverted
=> tests fail), plus untouched-draft control cases.

Blocker 2 — the live-editor registry is now genuinely registered in a
layout effect. The prior commit claimed this fix but a test-time
`git checkout --` discarded the unstaged engine edits before committing;
re-applied: layout registration, binding-gated registry key, and the
tracked-only abort in removeUpload. New registry timing test captures the
registry from a parent layout effect across a key switch — verified to
fail with passive registration.

Also: `multica:chat:selectedProjectId` joins the workspace-scoped cleanup
list (was leaking across logout; flagged as a pre-existing risk).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(drafts): mounted submits also clear only the draft they submitted

The stale-submit snapshot guard previously protected only dead composers;
a mounted one cleared unconditionally on success. But the editor stays
interactive during a request (Tiptap cannot toggle editable post-mount), so
text typed while draft A was in flight was wiped by A's success. The guard
is now unconditional across every surface: success consumes exactly the
submitted snapshot, and any later edit survives.

- create-issue / quick-create: the editor's pending debounce is flushed into
  the store BEFORE snapshotting (a late flush of pre-submit typing must not
  read as a mid-flight edit); a touched draft skips clear AND close/reset —
  the dialog stays open on the newer work. Untouched behavior unchanged.
- comment / reply / edit: same flush + snapshot; a touched entry keeps both
  the store draft and the editor content (edit mode stays open on it).
- chat: commitInput's value compare now applies while mounted too, and the
  editor is scrubbed only for an untouched draft.
- use-composer-submit docs no longer claim "editor locked": they state the
  real contract — send affordance locks, edits after submit survive.

Regression tests: mounted mid-flight-edit cases for manual create (incl.
"dialog must not close over draft B"), quick create, comment, and chat,
plus mounted-untouched controls.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(drafts): idempotent draft writes so a tab switch cannot resurrect a posted comment

Final-review blocker: the comment/reply visibilitychange/pagehide flush
re-writes IDENTICAL content on every tab switch, and writeDraft minted a
new entry object each call — the stale-submit guard's identity compare then
read a mid-flight tab switch as "edited during the request", kept the
posted comment's draft alive, and left Send enabled for a duplicate.

- writeDraft is now a no-op when content and uploads are unchanged (also
  kills a spurious persist write per tab switch). Regression tests: entry
  identity preserved on identical setDraft (core), and the reproduced
  tab-switch-mid-send scenario clears the posted draft (views) — verified
  to fail with the idempotence removed.
- onAccepted now flushes the editor's pending debounce before judging
  `untouched` on every surface, so typing still inside the debounce window
  counts as a mid-flight edit instead of being scrubbed.
- create-issue records last-assignee/mode from the SUBMITTED values,
  outside the untouched gate — a created issue updates the preference even
  when the dialog stays open on newer edits.
- Stale guard comments corrected in both create panels; the
  use-composer-submit docstring no longer claims project/feedback were
  migrated (they still hand-roll await-then-clear; registered debt).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-28 10:10:00 +08:00
Bohan Jiang
4d0475ce89 feat(usage): error/failure charts on the Usage page (MUL-5352) (#5991)
* feat(usage): add error/failure visibility to the Usage dashboard

The Usage page could only answer "how much did we spend"; nothing on it
showed how often agents fail, what kind of failure it was, or which agent
is responsible. Operators had to open failed tasks one at a time to spot a
pattern.

`agent_task_queue.failure_reason` already carries the refined 21-value
taxonomy from server/pkg/taskfailure, so this is a read path over data that
already exists.

Backend — two rollups, both scoped by workspace/project/window like the
existing dashboard endpoints:

  GET /api/dashboard/failures/daily     per-(date, failure_reason)
  GET /api/dashboard/failures/by-agent  per-(agent, failure_reason)

They return every terminal task, not just failures: the `failure_reason: ""`
row carries the succeeded count. That is what makes the error rate's
denominator share filters with its numerator. The run-time rollups can't
serve as that denominator — they require `started_at IS NOT NULL`, so a task
that expired in the queue (the signature of a runtime outage) contributes
nothing to their failed_count. A failed row with an empty reason column
lands in an `unclassified` bucket rather than being mistaken for a success.

Frontend:
- "Errors" joins the trend toggle, daily and weekly, stacked by failure
  class with the bucket's error rate in the tooltip.
- An Errors card breaks the window down by class and by agent, with the raw
  failure_reason strings behind a disclosure (unlocalised — an operator
  pastes them into a log search). Each agent row links to its Work tab,
  which lists the actual failed runs.
- The 21 backend reasons fold into 7 display classes in
  @multica/core/dashboard. Unknown reasons — including ones from a backend
  newer than the client — land in "other" instead of being dropped, so the
  class totals always reconcile with the failure count.

The Tasks KPI tile is deliberately left alone: its value counts started
tasks only, so quoting the failure rollup's larger count there would put two
denominators in one tile. The Errors card states its rate with the
denominator spelled out instead.

Migration 225 adds a partial index on agent_task_queue(completed_at) for
terminal statuses. The table had no completed_at index at all, so the two
pre-existing run-time rollups were already scanning it; these two new
queries would have doubled that.

Closes #4429 (MUL-5352)

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

* fix(usage): correct the Errors drill-down, window and agent exposure

Review findings on PR #5991.

1. The drill-down pointed at the wrong page. `?view=work` renders
   ActorIssuesPanel — the issues assigned to the agent — while its runs live
   in the Overview pane's ActivityTab. Link to Overview.

   That page also could not show why a run failed: `failureReasonLabel` was a
   `Record<TaskFailureReason, string>` indexed with a cast to the old 6-value
   coarse enum, so every refined reason the backend has written since
   MUL-1949 resolved to `undefined`. It is now a function over the full
   21-value taxonomy plus the legacy coarse values, falling back to the raw
   wire string for anything newer than the client. Fixes the issue execution
   log too, which had the same cast.

2. The Errors card covered one more calendar day than the chart above it.
   `parseSinceParamInTZ` returns N+1 days of headroom on purpose and the
   dashboard trims the surplus client-side — but only a series carrying a
   date can be trimmed that way. Totals / classes / reasons now derive from
   the date-bucketed rollup after that trim, and the per-agent rollup (which
   has no date to trim on) closes its window server-side via a new
   `parseExactSinceParamInTZ`. At days=1 the card previously reported
   yesterday's failures beside a chart showing none.

3. The top-offenders list leaked agents the viewer cannot see. The failure
   rollups are workspace-scoped and deliberately skip per-agent visibility,
   but the agent list they are joined against does not — members only see a
   private agent when they own it or are owner/admin. `name ?? row.agentId`
   therefore rendered a bare UUID along with that agent's failure count,
   rate and dominant error class. Unresolvable agents now fold into one
   anonymous row, and the renderer never falls back to an id. Stricter than
   `bucketUnknownAgentRows` while the agent list loads: a transient flash of
   UUIDs is the leak, not a cosmetic glitch.

Also from the review: the Errors tooltip echoed the raw Recharts dataKey
("rate_limit") instead of the translated label the legend already carries.

Not changed — the schema's `failure_reason` default stays `""`. Defaulting a
missing field to a failure bucket guards against a deflated rate, but the
realistic drift is `omitempty` on the Go struct tag, which would strip the
field from exactly the SUCCESS rows and read as a 100% error rate. Added
TestDashboardFailureWireContractKeepsEmptyReason to pin that the server
always emits the field, which is the assumption the default rests on.

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

* fix(usage): renumber migration and fix the anonymous bucket's failure class

Review findings on PR #5991, round 2.

1. Migration prefix 225 collided with `225_chat_message_channel_media_pending`,
   which landed on main while this branch was open — backend CI failed on
   TestMigrationNumericPrefixesStayUniqueAfterLegacySet. Merged main and
   renumbered to 231; main now carries 225 through 230, so 226 is taken too.

2. The anonymous "Other agents" bucket could announce the wrong failure class.
   It merged rows that had ALREADY collapsed to one dominant class per agent,
   then credited each agent's entire failure count to that class. An agent
   failing auth 6 / timeout 5 contributed 11 to auth and 0 to timeout, so a
   bucket whose real composition was timeout 15 / auth 6 rendered as Auth.

   Fixed by anonymizing the raw per-(agent, reason) rows instead: the sentinel
   becomes just another agent_id and `aggregateAgentFailures` computes its
   classes from real counts. That also deletes the parallel bucketing pass —
   one identity rewrite replaces it. `knownAgentIds` moves up to where both
   consumers can see it.

Also from the review:
- The wire-contract test decoded both payloads into one map. json.Unmarshal
  merges into a non-nil map rather than resetting it, so a residual
  failure_reason from the first case could have masked an omitempty
  regression in the second — exactly what the test is meant to catch. Now
  table-driven with a fresh map per case.
- A test comment still described the drill-down as pointing at the Work tab.

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

---------

Co-authored-by: Bohan-J <bohan@devv.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-27 18:40:48 +08:00
Naiyuan Qing
38b08acf00 feat(diagnostics): report which page a desktop hang happened on (MUL-5345) (#5989)
* feat(diagnostics): attribute desktop hangs to route, function and stack (MUL-5345)

A desktop hang currently reports "froze for 8s" and nothing else, so MUL-5345
could not be diagnosed at all. Three gaps, all fixed here.

Route attribution was silently dead. The main window's route reporting lived in
the PostHog pageview tracker and was deleted with it (MUL-4127), leaving
`getDiagnosticContext` in main reading a WeakMap nothing ever wrote — every
field report carried only the asar index.html URL. `DiagnosticRouteReporter`
restores the push, and now feeds the in-renderer watchdog too: the renderer runs
a memory router, so `location.pathname` could never identify the page either.
Paths are bucketed to templates (`/:slug/issues/:id`) before publishing.

Function attribution did not exist. The watchdog now prefers
`long-animation-frame` over `longtask` where supported, which carries per-script
`sourceFunctionName` / `sourceURL` / `sourceCharPosition`. That covers hangs the
thread survives. For hangs it does not, main captures the JS call stack over CDP
— which requires the Debugger channel to be warmed at window creation, because a
command sent after the thread is stuck never gets dispatched. Commands go
through a four-verb allowlist, only scalar code locations are copied out of the
paused frames (never `scopeChain`, whose handles dereference into user data),
and resume is unconditional so a capture can never turn a recoverable hang into
a permanent one.

Reports could also be lost before delivery. `freeze:get-last` no longer deletes;
the renderer sends with `send_instantly` and acks by exact timestamp, so a
report killed by a second hang is retried next boot instead of vanishing with
the file (the MUL-4115 failure mode: three deterministic hangs, zero events). A
7-day TTL keeps an undeliverable breadcrumb from becoming permanent boot noise.

Operations name themselves: `parseMarkdownChunked` marks the diagnostic context
before it runs, and the mark travels to main over the async IPC channel that
still lands after the main thread stops responding. The event carries how stale
the mark is, so it reads as context rather than as a cause.

Verified on Electron 39.8.7 / Chromium 142 (throwaway spike, not committed):
Debugger.pause during a 12s synchronous block returned the stack in 2ms with the
blocking function on top; holding the channel open all session showed no cost
beyond run-to-run noise (A/B/A, ordering drift larger than the effect); DevTools
and the channel coexist in both open orders.

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

* fix(diagnostics): close the freeze report ack race and stop shipping raw ids (MUL-5345)

Two review findings on #5989.

Acking on hand-off was not acking on delivery. `onCaptured` fires when
posthog.capture() returns; the request is still in flight, and posthog-js
exposes no delivery callback to wait on (`CaptureOptions` has `send_instantly`
and `transport`, nothing else). Deleting the breadcrumb there loses the report
whenever the app freezes again or is killed in that gap — the same MUL-4115
failure the ack protocol was added to prevent. The flush now waits out a grace
window before acking: if the process dies inside it the timer never fires, the
file survives, and the next boot retries. Duplicates are the accepted trade and
are trivially deduped on `breadcrumb_ts`; a lost report is not.

Raw identifiers were reaching telemetry. The breadcrumb context was spread
wholesale into the event props, so `workspaceSlug`, `tabId` and the absolute
`windowUrl` shipped with every report despite the stated "bucketed path only"
constraint. Fixed at both ends: the slug and tab id are no longer put into the
route context at all (nothing else read them), the sanitizer constructs its
result explicitly so a stale renderer's payload can't reintroduce them,
`windowUrl` is dropped since it is an install path that can carry the OS
username and the bucketed route already says which page it was, and the event
props are now assembled field by field so a future context key cannot ship
itself.

The flush moved into `freeze-flush.ts` to make both behaviours testable:
`onCaptured` does not ack, the grace window does, a cancelled window keeps the
breadcrumb, and props built from a context still carrying slug/tabId/windowUrl
contain none of them.

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

* refactor(diagnostics): reduce MUL-5345 to route attribution only

Scope call from product review: the next hang should answer "which page", and
nothing more. Removes the CDP stack capture, the long-animation-frame observer,
the operation breadcrumb, and the read/ack delivery protocol added earlier on
this branch, along with the spike-derived build note. What stays is the smallest
change that gives the two existing hang events a real route.

The route reporting had been dead since MUL-4127 (#4996) deleted it along with
the PostHog pageview tracker: `getDiagnosticContext` in main kept reading a
WeakMap nothing wrote, so a hang report carried only the asar index.html URL.
`DiagnosticRouteReporter` restores the push to main — the only party alive
during a true hang, which cannot ask a blocked renderer anything — and also
publishes to the in-renderer watchdog, whose `location.pathname` is that same
useless packaged path because the shell runs a memory router.

Paths are bucketed to templates (`/:slug/issues/:id`) before publishing, and
the workspace slug and tab id are not sent at all; nothing outside diagnostics
read them. The sanitizer constructs its result explicitly so a renderer older
than this build cannot reintroduce them, `windowUrl` is dropped because it is an
install path that can carry the OS username, and the breadcrumb event props are
assembled by whitelist rather than by spreading the context.

Both hang events now report `path` under the same name, so they group in one
query.

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

* fix(diagnostics): bucket hang routes by known structure, not id shape (MUL-5345)

The bucketer guessed which segments were ids by looking at them — UUID, issue
key, or all digits. Every id that does not look like one therefore travelled to
telemetry intact, and most of ours do not: project, autopilot, agent, member,
squad, runtime, skill and attachment ids are arbitrary strings from
`paths.ts`. `/acme/projects/p1` bucketed to `/:slug/projects/p1`, and
`/acme/runtimes/machine%2Fruntime/runtime/runtime%20one` came through
completely unchanged.

It now matches structurally against the known route shapes, so a `:param` slot
is whatever occupies that position regardless of how it is spelled or encoded.
Where several patterns fit, the most literal one wins, which keeps `agents/new`
the create page rather than an agent whose id happens to be "new".

An unmatched path is masked (`/:slug/issues/*`, `/:slug/*`) rather than passed
through: a route we do not know is exactly the case where an id cannot be told
from a page name, so nothing from it travels. That makes a route added to
paths.ts without being added here a loss of detail instead of a leak.

To stop the list falling behind quietly, a parity test walks the real path
builders — not a copy of them — and asserts that no builder leaks its slug or
ids and that none falls back to the mask. Removing a single route from the
table fails it with the builder named.

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

---------

Co-authored-by: multica-agent <github@multica.ai>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 16:20:23 +08:00
Bohan Jiang
4581e9ee76 fix(server): surface real reason for failed quick-create (MUL-5268) (#5898)
* fix(server): surface real reason for failed quick-create (MUL-5268, #5885)

When an agent's quick-create run finishes without producing an issue, the
completion path wrote a fixed "agent finished without creating an issue"
inbox, discarding the real reason — most often the active-duplicate guard
rejecting the create. Users saw no actionable detail.

notifyQuickCreateCompleted now:
- distinguishes pgx.ErrNoRows (a confirmed no-issue → real failure) from a
  genuine lookup fault (DB/timeout), so a transient error no longer mislabels
  a run that may actually have created the issue;
- on the real-failure branch, surfaces the agent's final output as the
  failure reason. The quick-create prompt already requires the agent to exit
  with the CLI error as its only output, so this carries the concrete cause
  (e.g. the existing issue's identifier + status), unescaped, bounded, and
  redacted. Empty output falls back to the existing generic message.

No API/CLI contract or migration change.

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

* fix(server): never end quick-create with no notification on lookup fault

Review follow-up. The previous commit returned silently when the completion
lookup failed with a non-ErrNoRows error, to avoid misreporting a failure that
was never observed. But the task is already completed and nothing retries this
reconciliation, so a single transient DB fault permanently stranded the
requester with no inbox result at all.

The indeterminate branch now writes a neutral, terminal notification: it does
not assert failure (the agent may have created the issue), does not reuse the
agent output as if it were the confirmed reason, and points at the one safe
next step — check recent issues before retrying, so a retry cannot silently
produce the duplicate the guard exists to prevent.

notifyQuickCreateFailed / notifyQuickCreateUnconfirmed are now thin wrappers
over a shared writer so both outcomes keep the identical row shape and the
frontend's 'Edit as advanced form' recovery affordance.

Tests:
- TestQuickCreateLookupFault_WritesUnconfirmedInbox: fails against the previous
  commit with 'no rows in result set' (the exact silent-drop), passes now. Uses
  a DBTX wrapper that faults only GetIssueByOrigin so the inbox write still
  reaches the real DB.
- TestQuickCreateFailure_RedactsAgentOutput: locks in that the newly-surfaced
  agent output is scrubbed before storage.

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

* fix(inbox): render unverified quick-create outcome as neutral, not failed

Review round 2. Three fixes.

1. Rebased onto main and updated the three CompleteTask call sites for the
   new sessionRolloutMissing parameter; the branch no longer compiles-fails
   on the merge ref.

2. The unverified outcome reused the quick_create_failed inbox type, so every
   client framed it as a failure regardless of the neutral title/body: web
   list rendered 'Failed: {detail}', web detail showed 'Create with agent
   failed', mobile rendered 'Failed: ...', and getInboxDisplayTitle replaced
   the neutral title with the original prompt. Users saw 'Failed: Couldn't
   confirm...' — asserting a failure never observed. Added a distinct
   quick_create_unconfirmed type end to end: core type union, web list label
   (no failure framing), web detail pane, the original-prompt box and 'Edit as
   advanced form' recovery affordance, mobile label + display title, and en /
   zh-Hans / ja / ko strings. Older clients hit their existing default branch
   and render the already-neutral title.

3. The terminal notification reused the caller's context, so a lookup that
   failed with context.Canceled / DeadlineExceeded failed the write for the
   same reason and still dropped the notification. The write is now detached
   via context.WithoutCancel with a bounded timeout.

Tests (each verified to fail without its fix):
- TestQuickCreateLookupCancelled_StillWritesUnconfirmedInbox: cancels the ctx
  at the lookup; without the detach, 'no rows in result set'.
- inbox-detail-label.test.tsx: resolves accessors against the real en locale;
  pointing the unconfirmed case back at failed_with_detail reproduces
  'Failed: Couldn't confirm...'.
- inbox-display.test.ts: both outcomes stay recoverable rows.

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

---------

Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-27 15:35:09 +08:00
Bohan Jiang
6da0407b03 fix(issues): keep issue row pages in sync on observer reattach (MUL-5341) (#5978)
* fix(issues): refetch invalidated row pages on observer reattach (MUL-5341)

issueTableRowPageOptions used `refetchOnMount: false`, which blocks the
mount refetch of a *successful-but-invalidated* row page as well as an
errored one. When a row page is WS-invalidated while its dynamic
useQueries observer is detached and the observer later reattaches, the
page stays `invalidated: true / fetchStatus: idle` under the global
`staleTime: Infinity` default — the status count (facet query, always
active) updates but the list keeps a stale snapshot missing the moved
issue, until a full page refresh.

Switch to `retryOnMount: false`, which expresses the intended
"don't auto-retry an errored page" behavior without blocking stale
(invalidated) successful pages from refetching. Fresh cached pages still
don't refetch (staleTime: Infinity), so re-expanding a collapsed section
reuses settled cursor pages.

Add core regression tests for both the invalidated-refetch and the
errored-stays-errored paths, and align the status-branches test fixture
with the production client's `staleTime: Infinity`.

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

* fix(issues): keep errored row pages stable on reattach (MUL-5341 review)

Address Elon's review: `retryOnMount: false` alone only guards a no-data
first-load error. When a page has loaded, then an invalidation-triggered
background refetch fails, TanStack's "error" reducer flags the page
`isInvalidated: true` while retaining its data. Under the default
`refetchOnMount: true` that page is both stale and errored, so it re-fires
the failing request on every dynamic-observer reattach — bypassing the
page's explicit Retry.

Add `refetchOnMount: (query) => query.state.status !== "error"` alongside
`retryOnMount: false`: a successful-but-invalidated page still refetches on
reattach (the original bug), a fresh page stays put under
`staleTime: Infinity`, and both first-load and background-refetch errors now
wait for an explicit Retry.

Add a regression test covering the has-data background-refetch-error path
(load → invalidate → refetch fails → detach/reattach asserts no extra
request until an explicit refetch).

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

---------

Co-authored-by: Bohan-J <bohan@devv.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-27 13:30:48 +08:00
Bohan Jiang
ecce589867 MUL-5265: GitHub API-snapshot PR cards — CI status + mergeability (#5889)
* feat(github): API-snapshot PR cards — CI status + mergeability (MUL-5265)

Fetch each linked PR's CI checks and mergeability from the GitHub GraphQL
API as the single source of truth (Plan C). Webhooks, page visits and a
bounded TTL sweep are refresh triggers only; nothing is inferred from
webhook payloads anymore.

Backend (server/internal/integrations/ghsnapshot):
- installation-token cache + GraphQL client (private key / tokens never logged)
- one paginated pullRequest query -> normalized per-check snapshot
- outbound queue: (installation,repo,PR) dedup + single in-flight per PR,
  bounded worker pool, Retry-After / rate-limit backoff, jitter
- head-SHA-guarded atomic batch replace (a slow response for an old head
  can never overwrite a newer head's snapshot)
- bounded chase window (30s->5m, stops on terminal/closed) + page-visit +
  TTL refresh; clean degradation when no App private key is configured

Removes the old suite-level webhook aggregation display path (query +
handlers + tests). check_suite / check_run / status are now pure triggers.

Frontend: PR card shows two independent tri-state elements (CI status +
mergeability). "Ready to merge" only when merge state is clean; no-checks
and unknown-mergeable never assert a positive verdict; progress strip
removed; four locales; stale marker.

Docs: github-integration + environment-variables (four languages) — now
required App private key, read-only Checks/Commit-statuses permissions,
new event subscriptions, capability boundaries and troubleshooting.

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

* fix(github): address PR snapshot review blockers

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

* fix(github): bound snapshot refresh scheduling

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

* fix(github): concurrent check-run index migration + singleflight token mint

Address Elon's third-round review on the MUL-5265 PR snapshot pipeline.

Must-fix — migration built a non-concurrent index. The
github_pull_request_check_run table declared PRIMARY KEY (pr_id, ordinal)
inside CREATE TABLE, which builds a unique index synchronously and violates
the repo rule that every migration-created index (including on a new table)
use CREATE UNIQUE INDEX CONCURRENTLY in its own single-statement file. Split:
222 now creates the table without a primary key; new 223 adds the
(pr_id, ordinal) unique index CONCURRENTLY. The atomic delete-all/insert
write path already guarantees ordinal uniqueness, so a plain unique index is
sufficient; the index also serves the pr_id-prefix list aggregation and the
workspace/PR cleanup deletes.

Nit — token mint now singleflights per installation. installationToken
released the lock before minting, so the N workers of one installation could
mint N tokens on a cold cache or a simultaneous renew. Concurrent callers for
the same installation are now collapsed via singleflight into one HTTP mint;
added a -race concurrent-mint test asserting a single mint under 16 callers.

Verified: fresh DB migrates through 223 (table has no PK, concurrent unique
index present); ghsnapshot suite + new test pass under -race; migration lint
and handler github/workspace-delete tests pass; sqlc produced no diff;
go build / vet / gofmt / git diff --check clean.

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

---------

Co-authored-by: Bohan-J <bohan@devv.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-24 18:30:20 +08:00
Naiyuan Qing
00e658401b feat(views): revamp execution log — virtualized, readable, two-tier header (#5890)
Combines the transcript work (previously split across #5860 virtual
scroll and #5871 reading hierarchy) into one branch on current main,
folding in the runtime-alias display from #5881.

- Virtualize the event list (react-virtuoso) so a multi-thousand-event
  run mounts a bounded number of DOM rows (#5733), with firstItemIndex
  anchoring for newest-first live prepends.
- Reading hierarchy via a pure trace-event-presenter: agent text and
  errors render expanded in place through RichContent (compact,
  log-scale markdown); thinking/tool rows fold to one line; tool detail
  expands into a quiet surface with "show all".
- Persisted 3-way expand density (smart/expanded/collapsed) with per-row
  overrides; legacy defaultExpanded boolean migrated. Filters always
  persist (dropped the preserve-filters toggle).
- Two-tier header: identity row (status, agent, trigger source,
  triggered-by) + list toolbar (created/duration/events facts left,
  shared Button controls right); runtime/provider/workdir/timestamps
  move to an ⓘ popover, runtime shown via runtimeDisplayName (#5881).
- Copy-all exports full event bodies (redacted) with RFC 3339 timestamps
  (#5873), not the truncated summary.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-24 16:44:43 +08:00
Bohan Jiang
73b0015475 feat(vcs): make self-hosted Git providers self-host-only (MUL-3772, MUL-5138) (#5888)
* feat(vcs): gate self-hosted Git providers to self-host deployments only (MUL-3772)

The Forgejo/Gitea/GitLab integration is intended for self-hosted Multica, where
Multica can reach a Git instance on the operator's own network. On the managed
multi-tenant cloud it adds an SSRF surface (connect validates a user-supplied
instance URL from the server) and would store third-party Git tokens for all
tenants under one key, while only serving the small subset of users whose
instance is publicly reachable. Product decision: offer it on self-host only.

- Add an explicit deployment switch MULTICA_VCS_INTEGRATION_ENABLED (default
  off). Connect, rotate, and webhook now require BOTH the switch on AND a valid
  MULTICA_VCS_SECRET_KEY — the switch is the product boundary, not key presence
  alone. When off, connect/rotate return 404 and the webhook returns a bare 404
  (no config leak), independent of the frontend.
- /api/config exposes vcs_integration_available (mirrors the switch, omitted
  when false) so the Settings UI hides the whole "Git providers" section on
  cloud instead of surfacing an operator-only "missing key" hint.
- docker-compose.selfhost.yml defaults the switch on; .env.example documents it.
- Docs (en/zh) lead with a callout: available on self-hosted Multica only, not
  Multica Cloud, and clarify "self-hosted" means Multica itself, not just Git.

#5006 / #5883 stay in place — the schema and backend capability are retained;
this only gates availability. No cloud VCS connection can exist (connect always
required the key, which the cloud never set), so nothing needs migrating.

Verified: go build/vet + VCS/config handler tests on a fresh migrated DB
(incl. a new disabled-deployment 404 test); pnpm typecheck (core + views) and
the integrations-tab + core schema/config vitest suites pass.

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

* fix(vcs): complete self-host integration gating (MUL-5138)

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

---------

Co-authored-by: Bohan-J <bohan@devv.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-24 16:39:22 +08:00
dixonl90
581d9527ba feat(vcs): self-hosted Git providers (Forgejo, Gitea, GitLab) alongside GitHub (MUL-3772) (#5006)
Adds self-hosted Git provider support (Forgejo, Gitea, GitLab) alongside GitHub:
per-workspace token connection, a provider-dispatched webhook, PR/MR and CI
mirroring, and the shared issue auto-link / auto-close machinery. Off until
MULTICA_VCS_SECRET_KEY is set, so existing deployments are unaffected.

Co-authored-by: Bohan <bohan@devv.ai>
2026-07-24 15:01:27 +08:00
Naiyuan Qing
7d2f20f2ee feat(chat): warn when agent daemon is too old for project context (#5867)
Chat project context (MUL-5150, #5765) is rendered into the run brief by the
daemon, so daemons older than the release that ships it silently drop the
project description while still honoring the server-extracted repos. Mirror
the handoff-note soft gate: resolve the active agent's runtime cli_version
from the warm runtime cache and, when it is a stale release build, surface a
warning next to the composer chip and inside the project submenu. Selection
is never blocked — dev-describe builds and unknown runtimes stay silent.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-24 13:32:44 +08:00
Jiayuan Zhang
a3fe6d91dd MUL-5150: add project context to Chat (#5765)
* feat(chat): add project context

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

* fix(chat): resolve MUL-5150 review blockers

- Renumber project-context migrations to unique prefixes after current main:
  206_chat_session_project -> 212 (column), 207_chat_session_project_index ->
  213 (concurrent index). 206/207 collided with 206_agent_disabled_runtime_skills
  and main's 207-211 client_usage_daily set.
- Add the 4 missing chat input.project_context keys to ja/ko locales so the
  locale parity test passes (en/zh-Hans already had them).
- Lock the project-context control while a send is in flight (isSubmitting),
  not just while the agent is running. A brand-new chat creates its session
  lazily during send bound to the project at click time; switching project
  mid-send would create the session against the stale project and clear the
  editor as if the send landed on the new selection. Add a regression test.

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

* fix(chat): complete project context handling

* fix(chat): pin fresh chat to open session's agent on project switch

Switching an existing session to a different project opens a fresh chat but
only cleared the active session, dropping selection back to the stored
`selectedAgentId`. When that preference was stale (open session belongs to
agent B while the persisted pick is still agent A), the lazily-created session
and its first send bound to the wrong agent (agent A).

Extract the project-switch decision into a shared `planProjectContextChange`
pure helper in use-chat-controller.ts and route both chat surfaces (the chat
tab controller and the floating ChatWindow) through it, so the fresh chat is
pinned to the open session's agent and the rule cannot drift between the two
copies. Add a dual-entry regression test (pure-fn guard + controller
integration) covering the stale selectedAgentId case.

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

* chore(ci): re-trigger required checks on latest head

The prior push updated the branch ref but GitHub did not emit a pull_request
synchronize for it (PR head-sync lag), so CI/Mobile Verify never ran on the
commit carrying the stale-agent project-switch fix. Empty commit to force a
fresh synchronize on a head that includes it.

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

* fix(chat): renumber project migrations to 213/214 after main added 212

Current main added 212_agent_service_tier; the PR's 212/213 chat migrations
collided with it on the merge ref, failing TestMigrationNumericPrefixesStay
UniqueAfterLegacySet. Merge current main and move the chat column migration to
213 and the concurrent index migration to 214 (column before index preserved).

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

* fix(chat): lock ProjectPicker clear control during send (keyboard path)

The send-pending lock only put pointer-events-none on the wrapper, which
blocks the mouse but leaves ProjectPicker's inline clear button in the tab
order — a keyboard user could Tab to "Remove from project" and press Enter
mid-send, detaching the project after the lazily-created session already went
out with the old one (reopens the mid-send retarget path via keyboard).

Add an explicit `disabled` capability to the shared ProjectPicker that locks
the trigger, the menu (forced closed), and the inline clear button (disabled +
out of the tab order). Defaults to false, so issue/create/autopilot callers
keep their hover/keyboard clear. ChatInput passes disabled while the project
selection is locked.

Tests: real-ProjectPicker regression (keyboard activation of the clear control
is inert when disabled; still works when enabled) + ChatInput wiring assertion
that the picker is disabled mid-send.

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

---------

Co-authored-by: Lambda <lambda@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
Co-authored-by: Walt <walt@multica.ai>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: Naiyuan Qing <145280634+NevilleQingNY@users.noreply.github.com>
Co-authored-by: NevilleQingNY <nevilleqing@gmail.com>
2026-07-24 11:30:27 +08:00
Bohan Jiang
8d18d3a9ec Revert "MUL-5180: fix(github): surface CI status on PR cards (#5811)" (#5855)
This reverts commit 139cc89200.

Co-authored-by: Bohan-J <bohan@devv.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-24 02:52:22 +08:00
Bohan Jiang
ffa8e16369 MUL-5228 fix(usage): bill Grok at xAI's reported cost, fix $0 resumed sessions (#5841)
* fix(agent): attribute Grok usage from the turn's own model id

A resumed Grok session with no configured model recorded its entire spend
under the model id "unknown", which matches no pricing row — so the task
reported $0 cost instead of its real spend.

grok.go only learned the model from the session handshake, and ACP's
`session/load` carries no model id (only `session/new` does). When neither
the agent nor MULTICA_GROK_MODEL pins a model, `daemon.go` legitimately
passes an empty model, leaving nothing to attribute the usage to.

Every Grok turn stamps `result._meta.modelId` with what it actually billed
against. Parse it in the shared ACP result parser and use it as the fallback
in grok.go. Other ACP backends are untouched — they keep whatever the
handshake gave them.

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

* fix(metrics): price the Grok catalog in server-side cost metrics

server/internal/metrics/pricing.go carried no Grok rows at all, so
RecordLLMUsage took the unpriced branch for every Grok turn: llm_cost_usd
reported zero Grok spend while the tokens accumulated in
llm_unpriced_tokens. Internal cost monitoring simply could not see Grok.

Add the six SKUs xAI publishes rates for, mirroring the frontend table in
packages/views/runtimes/utils.ts. Aliases are anchored exact matches like
the gpt-5.6 rows, so `grok-composer-*` (in the catalog, absent from the
price sheet) stays unmapped instead of inheriting a guessed rate.

Short-context tier on purpose: xAI bills a request at 2x once its prompt
reaches 200K tokens, but a usage record aggregates every model call in a
turn and cannot say which tier an individual request hit.

A regression test re-derives the cost of a real grok 0.2.106 turn from the
table and checks it against the costUsdTicks xAI returned for that turn.

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

* docs(changelog): scope the Grok cost claim to what was actually fixed

The v0.4.9 entry promised "accurate cost" in all four languages, but the
fix corrected catalog pricing and cached-input double-counting — it did not
implement xAI's 2x long-context tier, so a turn whose requests reach 200K
prompt tokens still under-reports by up to 50%. Say what was fixed instead.

Also correct two stale claims in the pricing comment: the daemon tags usage
rows with the runtime provider `grok`, not `xai` (the bare `grok-*` keys are
what make them resolve), and record why thresholding the long-context tier
on an aggregated row would be worse than not pricing it at all.

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

* feat(usage): carry the provider's own cost through to the usage record

Cost has always been derived client-side as tokens x a static rate, which
cannot express request-level pricing rules. xAI bills a Grok request at 2x
once its prompt reaches 200K tokens, and a task_usage row aggregates every
model call in a turn — so the stored token counts genuinely cannot say which
tier any individual request hit. Thresholding on the aggregate would be worse
than the status quo: it turns a bounded 50% under-estimate into an unbounded
over-estimate for turns made of many short requests.

Grok already reports what it charged, per turn, in `_meta.usage.costUsdTicks`.
Parse it, carry it through agent -> daemon -> API, and store it on task_usage
as a nullable BIGINT of 1e-10 USD ticks (integer, so sub-cent turns stay exact
end to end). NULL means the provider reported no cost — every pre-existing row
and every provider that doesn't return one. No backfill: there is no
authoritative figure to recover for those, and inventing one is the guess this
removes.

A single hourly bucket can mix rows that carry a cost with rows that don't, so
task_usage_hourly gains both halves: `cost_usd_ticks` sums the authoritative
side, and `uncosted_*_tokens` carry exactly the tokens that still need a
rate-table estimate. Consumers report authoritative + estimate(uncosted),
which degrades to today's behaviour when nothing in the bucket is
authoritative. The existing token columns keep covering every row, so token
displays are untouched. The new columns are additive with defaults, so the
unique key, the dirty-queue shape, and migration 102's triggers are unaffected.

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

* feat(usage): prefer the provider's own cost over the rate table

With the authoritative figure now stored, both cost consumers use it: the
usage dashboard (estimateCost / estimateCostBreakdown) and the server-side
llm_cost_usd metric. Each reports `authoritative + estimate(uncosted tokens)`,
so a row or bucket that mixes priced and unpriced sources stays whole.

The static rate tables remain, but for Grok they are now a fallback — they
still price usage recorded by a daemon too old to report cost, and every
provider that reports none. Custom pricing overrides likewise apply only to
the estimated half: they are a user's guess at a rate, and the authoritative
half is not a guess. A model with no rate-table row but a provider-reported
cost now also drops out of the "unmapped models" banner, since asking the user
to supply a rate for it would invite overriding a real bill.

llm_cost_usd is labelled by token_type and the provider reports one number per
turn, so the charge is distributed across the buckets in the rate table's own
proportions. Only the total is authoritative; the split stays an estimate,
which is why this scales the existing buckets rather than inventing a label.
estimateCostBreakdown does the same, keeping the stacked chart summing to the
headline figure instead of silently under-drawing every Grok row.

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

* docs(changelog): say Grok cost now follows xAI's actual charge

The earlier wording scoped the claim down to catalog pricing and cached input
because the long-context tier was still unhandled. It is handled now — the
cost comes from what xAI charged for the turn — so the entry can say so.

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

* fix(usage): keep the provider's cost when the model has no rate row

Both cost consumers bailed out before reading the authoritative figure when
the rate table had no row for the model. A `grok-composer-*` turn — in the
Grok Build catalog, absent from xAI's price sheet — was therefore reported as
$0 spend even though xAI told us exactly what it charged.

Worse on the client: estimateCost returned the real cost while
estimateCostBreakdown returned zeros, so the headline and the stacked chart
disagreed on precisely the rows whose cost is exact — and the unmapped-models
banner was (correctly) hidden, so nothing explained the discrepancy.

Handle the charge before the rate lookup in both places. Without rates there
is nothing to split a total by, so it lands whole in the `input` bucket, the
same fallback distributeAuthoritativeCost already uses when it has no shape to
scale. Tokens with no rate keep going to llm_unpriced_tokens: "unpriced"
describes the rate table, not the money.

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

* perf(usage): drop the historical rewrite from the cost-split migration

Migration 213 rewrote every existing task_usage_hourly row to seed the
uncosted counters. That is a full-table UPDATE inside a schema migration —
lock time, WAL and bloat all scaling with table size — for rows this issue
explicitly does not care about.

Deleting the UPDATE alone would have zeroed historical cost: with
`NOT NULL DEFAULT 0`, an untouched row asserts "nothing here needs
estimating", so every pre-split bucket would report $0 until the rollup
happened to touch it. Make the uncosted columns nullable with no default
instead. NULL means "never recomputed since the split existed", readers
COALESCE it to the row's own token total ("estimate all of it"), and the
pre-split behaviour is preserved exactly — with nothing to seed, so no
rewrite. A bare ADD COLUMN is metadata-only, so this is now fast DDL.

Rows heal into the split naturally as the rollup recomputes their buckets.

Verified on a fresh database: a legacy-shaped row reads back as its full
tokens to estimate, and a group mixing legacy and post-split buckets sums to
the authoritative cost plus both rows' estimable tokens.

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

---------

Co-authored-by: Bohan-J <bohan@devv.ai>
Co-authored-by: J <agent@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-24 01:42:08 +08:00
Bohan Jiang
139cc89200 MUL-5180: fix(github): surface CI status on PR cards (#5811)
* fix(github): surface CI status on PR cards (MUL-5180)

The CI mirroring pipeline (MUL-2228, MUL-2392) has never received a single
event in production. The GitHub App setup docs only ever asked operators to
grant `pull_requests: read` and subscribe to `pull_request`, so GitHub never
delivered `check_suite` — `handleCheckSuiteEvent` sat dead behind a
subscription nobody was told to enable. Every linked PR reports
checks_passed/failed/pending = 0 and the sidebar row falls through to
"Checks haven't reported yet" forever.

Docs (the root cause), all four locales:
- add `Checks: Read-only` permission + `Check suite` event to the App setup
  table
- drop the stale "CI check states are not modeled" claim, which predates
  MUL-2228 and is what let the setup table stay incomplete
- add a "PR rows show no CI status" troubleshooting entry with the public
  `/apps/<slug>` probe to confirm what an App is actually subscribed to, and
  a warning that existing installations must accept the new permission
  before any `check_suite` is delivered

UI:
- give the actionable status kinds (checks failed/pending/passed, conflicts,
  ready) their own icon + color. CI outcome previously rendered as plain
  muted 11px text, visually identical to the diff stats beside it — a failing
  build read the same as "+437 −6 · 6 files". Terminal and unknown kinds stay
  muted; the row's state icon already carries that meaning.

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

* fix(github): unbreak docs build, stop overclaiming CI completeness (MUL-5180)

Both must-fixes from review.

1. docs production build failed. `<your App>` in prose was parsed as a JSX
   tag, so `pnpm --filter @multica/docs build` died with `Expected a closing
   tag for <your>`. Dropped the angle brackets. Repo CI never caught this
   because no workflow runs the docs production build — only Vercel does,
   which is why the PR's GitHub checks were green while the deployment
   errored.

2. `Checks: Read-only` cannot support the pending status the docs promised.
   GitHub's webhook contract delivers `check_suite.requested` /
   `.rerequested` only to Apps holding Checks *write*; read-level access
   receives `completed` only. Verified against GitHub's published docs.

   Direction chosen: keep read-only, degrade honestly to final-results-only.
   Checks *write* is a repo-write capability (create/update check runs), not
   a wider read — escalating every installation to it just to render an
   in-flight spinner is not a trade to make on the operator's behalf, and it
   contradicts the integration's read-only posture.

   The concrete bug this leaves is premature green: with two reporting apps,
   the first to complete makes total=1/passed=1 and the row claimed "All
   checks passed" while the second was still running and might fail. Copy is
   now "Checks passed" in all four locales — it reports what reported and
   never asserts completeness. `derivePullRequestStatusKind` documents why.

   Docs gain a "what CI status can and cannot tell you" section (all four
   locales) with the read-vs-write delivery table, both consequences stated
   plainly, and the opt-in path for teams that do want in-flight status: set
   Checks to Read and write on their own App and the existing pending code
   lights up with no code change. The pending promise is removed from the
   read-only setup path.

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

* fix(github): ignore non-completed check_suite actions (MUL-5180)

Review was right: the `Read and write` opt-in the previous commit documented
does not produce reliable pending, and following it would break the card.

`check_suite.requested` / `.rerequested` are not observations that some CI
provider started. GitHub sends them only to Apps holding Checks write, and
per the CI-checks App docs they mean "GitHub has created a check suite for
YOUR app on this commit; now add your check runs to it".

Multica observes other apps' results and never creates check runs. Recording
such a suite parks a `queued` row nothing can ever complete, and since
`checks_pending` outranks `checks_passed` in derivePullRequestStatusKind, one
stuck row freezes every PR on that installation at "checks running" and hides
the real pass/fail result. Any self-hoster who already grants Checks write
hits this on every push, so the gate is on the action, not the permission.

- handleCheckSuiteEvent drops every action except `completed`, with the
  reasoning and the "don't resurrect requested as a running signal" warning
  recorded at the gate.
- TestWebhook_CheckSuite_QueuedCountsAsPending encoded the wrong delivery
  semantics (two external apps sending `requested`, which GitHub never does).
  Replaced by TestWebhook_CheckSuite_NonCompletedActionsIgnored, which pins
  the drop and checks a later `completed` suite still lands.
- The two out-of-order stash tests used `requested` payloads to exercise
  paths that are really about completed suites; both now use `completed` and
  assert the same guarantees.
- Docs (four locales): the write opt-in is gone. In-flight CI is documented
  as unsupported at any permission level, with the actual reason and the note
  that real running status needs polling or a check_run model instead.

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

* fix(github): make legacy non-completed check suites inert (MUL-5180)

Review was right again: the previous commit gated the webhook entry point but
left the pre-upgrade state — and the people it was meant to protect (self-
hosters who already granted Checks write) are exactly the ones holding it.

Two leftovers, both now closed:

1. Rows already in github_pull_request_check_suite. The old handler stored
   GitHub's `requested` suites as `queued`; nothing will ever complete them.
   ListPullRequestsByIssue still counted them, so `checks_pending` kept
   outranking `checks_passed` and the PR stayed pinned to "checks running"
   for as long as its head SHA stood. The aggregation now selects only
   `completed` suites.

   Filtering beats deleting here: recovery is automatic on deploy, needs no
   migration over a table that can be large, and holds for any writer that
   misses a gate — not just for today's legacy rows. DISTINCT ON runs after
   the filter, so an app whose newest suite is a stuck `queued` still reports
   its most recent completed verdict instead of disappearing.

2. Rows already in github_pending_check_suite. replayPendingCheckSuitesForPR
   is a second write path into the live table that never passes through
   handleCheckSuiteEvent, so the next `pull_request` event would re-inject a
   permanently-queued suite after the fix shipped. It now skips non-completed
   rows; the drain is DELETE ... RETURNING, so skipping discards them.

Both are covered by regression tests that seed the legacy row directly — the
fixed handler can no longer produce one — and both were confirmed to fail
with their respective fix reverted. The stash test additionally asserts its
fixture landed under the repo address the drain keys on; the first draft used
the wrong owner and passed vacuously.

Also corrects the aggregateChecksConclusion doc comment, which still
described "pending" as a not-yet-completed suite. It is now reachable only
for a completed suite carrying a null conclusion, and is explicitly not a
"CI is running" signal.

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

* test(github): assert the legacy stash row is consumed by the drain (MUL-5180)

Review nits.

The stash test proved its fixture existed before the webhook but never that
the drain consumed it, so a future change to firePullRequestWebhookWithHead's
repo address would make the assertions pass for the wrong reason again — the
same way the first draft of this test did. Asserting the stash is empty
afterwards closes that gap from the other side.

Also fixes two comment typos: `an "CI is running"` -> `a`, and drops the
"merged-but-open PR" state, which cannot exist.

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

---------

Co-authored-by: Bohan-J <bohan@devv.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-23 21:23:25 +08:00
Multica Eve
98072e2e56 fix(issues): filter working agents by active task issues (#5839)
Co-authored-by: Eve <eve@multica-ai.local>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-23 17:54:52 +08:00
Multica Eve
423a5c59cb MUL-5200: unify working-agent filters across issue views (#5819)
* fix(issues): query workspace working agents independently

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

* feat(agents): filter working agents by source type

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

* feat(issues): scope working agents to My Issues

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

* test(agents): cover My Issues squad relations

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

* fix(issues): unify working-agent filters across views

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

* fix(issues): preserve empty working-agent filters

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-07-23 16:54:41 +08:00
Multica Eve
40f9ecdd56 MUL-5202: unify Issue Query across List, Board, and Swimlane (#5820)
* MUL-5202: migrate status issue surfaces to table query

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

* MUL-5202: unify grouped issue surfaces

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

* MUL-5202: cover move safety boundaries

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

* MUL-5202: preserve server swimlane semantics

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

* MUL-5202: keep grouped surface facets exact

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-07-23 15:58:25 +08:00
Multica Eve
6992c58de3 MUL-5185: add Codex Fast mode (#5821)
* feat(agents): add Codex fast mode (MUL-5185)

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

* fix(agents): make Codex Fast override authoritative

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

* fix(agents): remove Codex Fast config conflicts

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

* chore: refresh checks after conflict resolution

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-07-23 15:40:18 +08:00
Multica Eve
8065cead85 MUL-5198: Restore server-backed issue table grouping (MUL-5100) (#5817)
* revert(issues): restore server-backed table grouping

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

* test(skills): stabilize import completion coverage

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-07-23 12:03:27 +08:00
Bohan Jiang
168620fc15 MUL-5163 fix(agents): rebind the Agent Builder carrier when the runtime is switched (#5780)
Switching the runtime mid-conversation in Build with AI only updated local React state, so the picker could show runtime B while every subsequent message still executed on the runtime frozen at session create time.

- Add PATCH /api/agent-builder/sessions/{id}/runtime to rebind the hidden builder carrier (runtime_id/runtime_mode, model cleared since model ids are per-runtime). Creator-only, builder carriers only, target must be in-workspace, usable by the member, and online; a reply in flight returns 409.
- Serialise rebind against send: both take LockChatSessionForRuntimeBind on the chat_session row and SendDirectChatMessage re-reads the agent inside that transaction, so a send blocked behind a rebind cannot resume and stamp its task with the runtime the switch moved away from.
- Leave chat_session.runtime_id stale on purpose so the daemon starts a fresh provider session on the new runtime while Multica-side history and the draft survive.
- Frontend updates the draft only after the server reports the bound runtime, blocks sending during a rebind, disables the Mine/All filter alongside the trigger, and explains why the picker is locked during a pending reply.

Closes #5773
2026-07-23 10:56:53 +08:00
Jiayuan Zhang
a61a8ecfed feat(onboarding): merge About-you step, collect source after agents deliver value (#5786)
Flow drops from five steps to three: role + use_case merge into a
single About-you screen (one Skip covers both; Continue stamps skip
markers on whichever group was left unanswered), and the source
question leaves onboarding entirely.

Source is now collected only by the workspace source-backfill prompt,
which additionally waits until agents/squads have completed at least
SOURCE_BACKFILL_MIN_AGENT_DONE_ISSUES (3) issues in the workspace —
attribution is asked after Multica has visibly delivered value, not
before. The count rides a limit:1 issues query keyed under
issueKeys.all so realtime invalidations keep it fresh, enabled only
for users who still owe an answer.

Server: questionnaire complete() narrows to role + use_case so the
funnel step doesn't stall on the now-deferred source; a new
metrics-only onboarding_source_submitted event (+ Prometheus counter)
tracks the backfill prompt's answer/decline transition once per user.

Co-authored-by: Lambda <lambda@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-23 01:57:44 +08:00
Jiayuan Zhang
d4391fda4d fix: preserve new issue draft options (#5770)
Co-authored-by: Lambda <lambda@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-23 01:31:25 +08:00
Jiayuan Zhang
4d74db89cb feat(issues): richer sub-issue rows in issue detail (MUL-5098) (#5721)
* feat(issues): richer sub-issue rows in issue detail (MUL-5098)

The sub-issues panel showed only status, identifier, title and assignee —
priority, due dates, labels, live agent activity and nested breakdowns
were invisible without opening each child.

- SubIssueRow now shows priority (checkbox-slot swap like list rows),
  the agent-activity indicator, label chips (+n overflow), the child's
  own done/total progress ring, and an inline-editable due date with
  overdue emphasis (muted when the child is done/cancelled)
- Right-click opens the shared issue actions menu via a section-level
  IssueContextMenuProvider — parity with list/board surfaces
- ListChildIssues + ListChildrenByParents now bulk-load labels
  (same labelsByIssue pattern as the other list endpoints)
- patchIssueLabels patches per-parent children caches;
  invalidateIssueLabelDerivatives refetches the Map-shaped batched
  children caches so label changes stay live everywhere

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

* feat(issues): customizable property display for sub-issue rows (MUL-5098)

The enriched sub-issue rows were a fixed field set — no way to trim
them or surface workspace custom properties.

- New user-level persisted preference (useSubIssueDisplayStore):
  built-in field toggles (priority / labels / sub-issue progress /
  due date / assignee) + opted-in custom property ids. Defaults match
  the previous fixed layout, so existing users see no change.
- SubIssueDisplayPopover on the section header — same switch-row
  interaction as the main views' Display panel, reusing its card_*
  locale keys (no new translations needed).
- Rows render opted-in custom property chips (PropertyIcon +
  CustomPropertyValueDisplay, list-row parity) only when the child
  carries a value; ids resolve against live non-archived definitions,
  so foreign-workspace or archived ids are inert.

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

* fix(issues): reconcile sub-issue cache updates

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

---------

Co-authored-by: Lambda <lambda@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-22 22:43:34 +08:00
Jiayuan Zhang
f6902a5f5b chore(onboarding): remove legacy agent & first-issue steps (#5774)
The "Create your first agent" and "first issue" onboarding steps were
dropped from the in-flow sequence (helper-agent creation moved to the
post-onboarding workspace shell), but their code was left behind. Remove
the now-dead residue:

- Delete unused step components `step-agent.tsx` and `step-first-issue.tsx`
  (not referenced or exported anywhere).
- Delete `recommend-template.ts` (+ test) and drop its core export — it
  was consumed only by `step-agent.tsx`.
- Drop the dead `agent` / `first_issue` members from the `OnboardingStep`
  union.
- Remove the orphaned `step_agent` and `first_issue` i18n sections across
  all four locales (en / zh-Hans / ja / ko); parity test stays green.
- Fix a stale doc path in `step-order.ts` (welcome-after-onboarding.tsx).

No behavior change: the live flow is welcome → source → role → use_case
→ workspace → runtime. Typecheck (core/views/web/desktop) and onboarding
+ locale-parity tests pass.

Co-authored-by: Lambda <lambda@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-22 18:16:41 +08:00
LinYushen
4dc47ef113 Revert "MUL-5100: Move issue table grouping to the server" (#5777)
This reverts commit d43e500ff6.
2026-07-22 18:06:37 +08:00
Multica Eve
216aee5629 [MUL-5125] Add daily Desktop/Web usage and runtime reporting (#5763)
* feat(analytics): add daily client usage reporting (MUL-5125)

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

* fix(analytics): clarify daily usage semantics (MUL-5125)

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

* fix(analytics): resolve usage review blockers (MUL-5125)

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-07-22 16:52:34 +08:00
Jiayuan Zhang
5d9295ac65 feat(agents): add per-agent runtime skill controls (#5686)
* feat(agents): add per-agent runtime skill controls

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

* fix(agents): renumber runtime-skill migration and broadcast agent:status on toggle

Address the MUL-5101 review blockers on PR #5686:

- Rebase onto main and renumber the runtime-skill-disable migration
  202 -> 203. main added 202_runtime_profile_add_qwen, so the pair
  collided on prefix 202 and migrations_lint_test would reject the
  duplicate. 203 is the next free prefix.
- Publish an "agent:status" event after persisting a
  disabled_runtime_skills override, mirroring the workspace-skill toggle
  in writeUpdatedAgentSkills. The realtime layer keys off this event to
  invalidate workspaceKeys.agents, so other open web/desktop/mobile
  clients now drop their stale toggle state instead of only the
  initiating tab refreshing. Reload junction-table skills before the
  broadcast so it doesn't signal cleared skills (#3459).
- Add a handler regression test proving the broadcast fires on both
  disable and enable.

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

---------

Co-authored-by: multica-agent <github@multica.ai>
Co-authored-by: Walt <walt@multica.ai>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-22 15:38:38 +08:00
Jiayuan Zhang
d43e500ff6 MUL-5100: Move issue table grouping to the server
Merge approved after review; CI checks are green.
2026-07-22 14:36:51 +08:00
Multica Eve
2795cef41b fix(issues): refresh sub-issues on detail mount (#5750)
Co-authored-by: Eve <eve@multica-ai.local>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-22 12:21:57 +08:00
Naiyuan Qing
2f111037d2 feat(desktop): tab presentation by object identity (MUL-4370) (#5661)
* fix(nav): derive route icons from the URL across all nav surfaces (MUL-4370)

The same route rendered different icons in the sidebar and the desktop tab
bar because the mapping was maintained in three places. Projects had no tab
icon at all; autopilots/chat/squads/usage fell back to ListTodo.

Establish one contract instead: `@multica/core/paths` maps a route segment to
a stable icon *name* (React-free), and `@multica/views/layout` maps that name
to a Lucide component. Every nav surface — sidebar, desktop tab bar, and the
search palette — resolves through `routeIconForPath(path)`, so a route cannot
render two different icons.

Crucially the icon is now derived, not stored. `TabSession.icon` is removed,
so persisted tab state can no longer hold a stale icon name: a user who had
an /autopilots tab from an older build gets the correct icon after upgrade
rather than the one that was persisted. Legacy `icon` values in v4 payloads
are ignored on rehydration and dropped on the next write.

Builds on the design in #5204 by LiangliangSui.

Tests: stale/unknown/absent persisted icon on rehydration, derived icon
rendering per route in the tab bar, name→component registry totality, and
nav-route icon coverage.

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

* feat(desktop): tab presentation by object identity, not route segment (MUL-4370)

Replace the "route segment → icon" tab mapping with a semantic Tab
Presentation Contract: a tab's leading visual and title are derived live
from its URL + the query cache, so a tab shows *what it points at*, not the
module it lives under.

- core `parseTabSubject(url)` classifies a URL as page / resource / actor /
  container (inbox, chat) / flow / unknown, purely (no React, no Lucide).
- core `resolveTabPresentation(subject, data)` maps that + cached entity data
  to a leading visual (issue StatusIcon, ProjectIcon, ActorAvatar, or a type
  icon) and a title spec. Exhaustive: a new route forces an explicit choice.
- views `useTabPresentation` reads the cache (enabled:false, no fetch from the
  tab bar) and `ResourceLeadingVisual` renders it in a fixed 16×16 slot.
- Containers keep their icon; only the title tracks the selection
  (`/inbox?issue=`, `/chat?session=`), so an inbox-opened issue reads
  differently from a direct issue tab.
- Titles are plain text; the project 📁 and autopilot  glyphs are dropped
  from document.title.
- Pin no longer replaces the resource visual. Persisted `tab.icon` cleanup
  from the prior revision is kept; the active tab persists its resolved title
  as a first-frame fallback (the document.title→observer path is removed).

Supersedes the route-segment approach in #5204 per the agreed PRD. No schema,
API, or migration changes; reuses existing queries/caches.

Tests: table-driven parseTabSubject over every desktop route; the
URL/data → visual+title matrix incl. pending/loading, containers, unknown,
runtime custom-name; views cache-integration; tab-bar pin + active-tab
title persistence.

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

* fix(desktop): archived inbox title sync + attachment filename in tab (MUL-4370)

Addresses two PRD gaps from review:

1. Archived Inbox selection now syncs the tab title. `parseTabSubject` captures
   `?view=archived` on the inbox subject, and the presentation hook resolves the
   selection against `archivedInboxListOptions` (its own cache, the one the
   InboxPage populates) instead of only the active list. An
   `/inbox?view=archived&issue=<id>` tab now shows the archived item's title —
   issue (`identifier: title`) or non-issue (display title) — and, being purely
   URL+cache derived, restores correctly on refresh. Previously it fell back to
   "Inbox" and persisted that wrong title.

2. Attachment tabs use the filename. `parseTabSubject` captures the `?name=`
   the preview route already carries; the resolver shows the filename as the
   title and picks a file-type icon from its extension (image/video/audio/
   archive/code/text), falling back to the generic File glyph + "Attachment"
   label only when the name is missing.

Tests: parseTabSubject archived/name cases; the presentation matrix for
attachment filename/extension + missing fallback and iconForAttachment edge
cases; views cache-integration for archived issue/non-issue selection (must
resolve against the archived list, not the active one) and attachment filename.

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

---------

Co-authored-by: multica-agent <github@multica.ai>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-22 09:36:53 +08:00
Bowser
f8bf6cd8b9 feat(runtime): add Qwen Code runtime (MUL-5015)
Merge approved PR #5666.
2026-07-21 14:55:08 +08:00
Bohan Jiang
d3fac023c9 feat(issues): live-resort board/list on updated_at drift (MUL-5016) (#5671)
* feat(issues): live-resort board/list on updated_at drift (MUL-5016)

An open Kanban board / list sorted by "Updated date" did not re-sort in
real time when a card's updated_at advanced — it only picked up the new
order on the next fetch (navigation, invalidation, etc.).

Two triggers now re-sort live:

- comment:created: a new comment bumps the parent issue's updated_at
  server-side (MUL-5009), but the WS handler only invalidated the
  per-issue timeline cache. It now also invalidates loaded updated_at-
  sorted issue-list/board/flat keys via invalidateUpdatedAtSortedIssue
  Lists, so the commented card re-sorts (and an off-window card can
  surface). Only updated_at-sorted keys are touched.

- same-status field edit: applyIssueChange already reconciled flat
  windows on updated_at-sort drift but the bucketed board only marked
  stale on membership/status change. It now marks an updated_at-sorted
  bucketed key stale whenever the patch advances updated_at, mirroring
  the flat-window rule. Deferred to onSettle for mutations (WS flushes
  immediately), respecting the existing timing contract.

Extracted patchChangesAnyIssueField shared helper. Tests cover the
coordinator drift/targeting and the comment:created wiring.

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

* fix(issues): cover assignee-grouped boards and property/metadata events in updated_at re-sort (MUL-5016)

Addresses Elon's review of #5671.

Must-fix 1: invalidateUpdatedAtSortedIssueLists missed assignee-grouped
boards. They fold the sort into their filter bag (issueAssigneeGroupsOptions
does `{ ...filter, ...sort }`) rather than a standalone sort key, so the old
bucketed+flat enumeration skipped them. Replace it with a single predicate
invalidateQueries over issueKeys.all(wsId) that matches any query key with an
object part carrying sort_by: "updated_at" — covering status boards, flat
tables, and assignee-grouped boards (workspace + My Issues) with one rule.

Must-fix 2: custom-property and metadata edits also bump issue.updated_at
server-side (SetIssuePropertyValue / DeleteIssuePropertyValue /
SetIssueMetadataKey / DeleteIssueMetadataKey) but flow through
issue_properties:changed / issue_metadata:changed, not applyIssueChange.
Their handlers patched cards in place and only invalidated property/My-Issues/
grouped windows, so an updated_at-sorted workspace board or flat table stayed
in the old order. Call invalidateUpdatedAtSortedIssueLists from both
onIssuePropertiesChanged and onIssueMetadataChanged. Both are committed-only
paths (WS event + mutation onSuccess); the optimistic leg still uses
patchIssueProperties, so no premature refetch.

Tests: grouped-board targeting in cache-coordinator; updated_at vs position
re-sort for onIssueMetadataChanged / onIssuePropertiesChanged (incl. the
optimistic leg not refetching) in ws-updaters.

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

---------

Co-authored-by: Bohan-J <bohan@devv.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-20 16:03:01 +08:00
Multica Eve
1483ce0825 fix(agents): always enable AI creation (MUL-4998) (#5660)
Co-authored-by: Eve <eve@multica-ai.local>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-20 12:46:39 +08:00
Naiyuan Qing
fcb4798f12 fix(issues): stop cold-load render loop on the Issues route (MUL-4985) (#5643)
Board and Swimlane crashed with "Maximum update depth exceeded" on first
paint of the Issues route. During cold load the member/agent/squad
directory queries are unresolved, so useActorName's `= []` defaults
allocated a fresh array every render and its `getActorName` memo changed
identity each render. BoardView's `groups` (and SwimLaneView's
`laneGroups`) list `getActorName` as a dep, so they churned every render
and re-fired the column-resync effect without end; react-virtuoso
escalated the loop into the reported crash. This predates MUL-4797 — it
was exposed when board/swimlane columns were virtualized with a real
<Virtuoso>.

- Share stable empty references for the loading directory snapshot so
  getActorName is referentially stable across cold-load renders (root
  cause).
- Equality-guard the shared column setter in useDragSettle so a
  content-equal rebuild returns the previous reference and cannot spin
  the resync effect (defense-in-depth).

Regression coverage: useActorName stability during cold load; the
column-setter equality guard; and Board (40 cards) + Swimlane rendered
with the REAL react-virtuoso under pending directories, which reproduce
"Maximum update depth exceeded" without the fix and pass with it.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-20 08:52:02 +08:00
Jiayuan Zhang
002ea0d879 MUL-4797: add configurable issue table view (#5454)
* feat(issues): add configurable table view

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

* test(issues): cover table columns in page fixture

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

* fix(issues): make table column picker interactive

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

* fix(issues): repair quick create and virtualize table rows

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

* fix(issues): keep pinned table cells opaque

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

* fix(issues): anchor full-width table rows

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

* fix(issues): consolidate table controls

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

* fix(issues): harden table pagination and export

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

* feat(issues): add table quick search

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

* fix(issues): make table window filters, selection, and export authoritative

Round-2 review fixes for the issues table (MUL-4797):

- Send the agents-working filter as a server ids facet so matches on
  unfetched pages surface and total/pagination/export agree; a present-
  but-empty id list yields an empty window instead of an unfiltered one.
- Reset surface selection when the membership window changes and act on
  selection ∩ visible rows in the batch toolbar, so batch actions,
  Export selected, and the count all share one authoritative set.
- Materialize the full flat window while table grouping is active, and
  suspend hierarchy nesting / parent-based grouping until the window is
  complete so structure cannot reshuffle as pages arrive; suppress
  header facet-count badges while the table window is partial.
- Resolve actor directories and the property catalog at export time and
  fail the export instead of writing Unknown* actors or dropping
  configured property columns on cold/errored lookups.
- Append a unique id tie-break to the list/grouped ORDER BY and mirror
  it in compareIssuesForSort so offset pages are stable across
  same-timestamp ties.

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

* fix(issues): bound table structure window and align chip/transport/selection

Round-3 review fixes for the issues table (MUL-4797):

- Cap whole-window materialization at TABLE_STRUCTURE_MAX_WINDOW (1000):
  below it the remaining pages load automatically — hierarchy applies
  without scrolling to the last page — and above it grouping/hierarchy
  suspend with an explicit toolbar notice instead of triggering an
  unbounded workspace download from a persisted view option.
- Give the agents-working chip the authoritative in-window running set
  (the ids-facet window query, shared key with the filter-on state) so
  its badge can no longer say 0 while the filter would find matches on
  unfetched pages; falls back to loaded-row scoping elsewhere.
- Route ids-facet windows through a new POST /api/issues/query twin —
  hundreds of running-issue UUIDs overflow the ~8 KB GET request-line
  budget of common proxies. The body carries the same key/value pairs;
  the handler rebuilds the query string and delegates to ListIssues.
- Reset surface selection during render (key-change pattern) instead of
  a post-commit effect, so no frame ever pairs new membership with the
  old selection.

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

* fix(issues): harden table auto-pagination against errors and stale totals

Round-4 review fixes for the issues table (MUL-4797):

- Stop the structure materialization loop (and the scroll sentinel) when
  the window query is in error state — a persistently failing page left
  hasNextPage true and isFetchingNextPage false after every attempt, so
  the ungated effect refired forever. Resuming is an explicit toolbar
  Retry. The advancement decision now lives in a pure, tested
  shouldAutoLoadNextStructurePage helper.
- Make the structure ceiling a hard stop: the ceiling check reads the
  LATEST page's total (pagination already advances on it, so a stale
  small page-1 total could re-open unbounded materialization), and the
  loop additionally halts on loaded count >= ceiling regardless of any
  reported total.
- Drive the working (ids-facet) window to completion — it is inherently
  bounded by the running set — and treat it as the chip's authoritative
  scope only when complete, so >100 running issues no longer under-count
  as a single page.

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

* fix(issues): make working-window pagination capped and unknown-aware

Round-5 (final) review fixes for the issues table (MUL-4797):

- The working (ids-facet) window now advances through the same
  shouldAutoLoadNextWindowPage gates as the structure loop — it shares
  the main table's cache key while the agents-working filter is on, so
  an uncapped chip-driven loop re-opened the very ceiling the table just
  enforced. An over-ceiling window stops after page one.
- A cold-load failure of the flat window is an ERROR state, not an empty
  workspace: isEmpty only claims empty on a successful zero-result
  fetch, and the surface renders a dedicated failed-to-load state with a
  reachable Retry (the in-table Retry never mounted without data).
- The chip scope is now tri-state honest: a COMPLETE window (or an empty
  running set) yields a precise count, keepPreviousData carries the
  last-known-complete set across re-keys, and everything else — cold
  resolving, failed, over the ceiling — presents as an explicit unknown
  ('Agents working: —') instead of a number derived from whichever
  incomplete window happened to be loaded.

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

* fix(issues): single pagination owner and placeholder-honest chip scope

Round-6 review fixes for the issues table (MUL-4797):

- Exclude placeholder data from the working-window completeness gate:
  on a re-key (running set or facet change) keepPreviousData leaves the
  OLD key's rows visible, and pairing them with the new task snapshot
  published a precise-looking number for a scope nobody fetched. The
  scope now reads unknown until the new key resolves.
- Make the shared table query single-owner while the agents-working
  filter is on: the chip's background loop no longer answers the same
  render snapshot as TableView's structure loop, and every auto caller
  (structure loop, working loop, scroll sentinel, retry) now uses
  fetchNextPage({cancelRefetch: false}) so a concurrent responder
  no-ops instead of cancel/restarting a fetch whose HTTP request is not
  abortable — which had been duplicating every offset.

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

---------

Co-authored-by: Lambda <lambda@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-17 19:21:27 +08:00
YYClaw
71141559c2 fix(shortcuts): ignore synthetic key events without a key
Guard shortcut parsing against synthetic key events that do not expose a string key.
2026-07-17 16:51:21 +08:00
YYClaw
465546b83b feat(autopilots): redesign the autopilot schedule editor (#5457)
Replace the free-form trigger-config form with a structured schedule
editor built on an orthogonal cron model: separate frequency, time, and
day-of-week/day-of-month dimensions map to and from cron expressions via
a dedicated grammar and mapping layer, with validation and a
human-readable describe() summary. The grammar suite drives the editor
against a combinatorially generated corpus of 51,755 distinct cron
expressions - every token form of every field, crossed - each judged
against a reference robfig/cron v3 parser.

Add a server-side /autopilot/cron-preview endpoint (plus schema and
React Query hook) so the editor shows upcoming run times, and echo
wildcard-carrying cron lists correctly instead of collapsing them.

Supporting pieces: timezone-aware formatting helper, segmented-toggle
and debounced-value utilities, a reworked time-input, and refreshed
en/ja/ko/zh-Hans locale strings.
2026-07-17 16:44:31 +08:00
Jiayuan Zhang
82906bd9e9 feat(settings): move create-issue field config into a Settings Issue tab (#5556)
The quick-create field toggles used to live inside the quick create
popover; per feedback on MUL-4873 they belong in the Settings page.
Adds a My Account 'Issue' tab with one group per create mode backed by
a new per-workspace issue-create-settings store, extends field
visibility to the manual dialog (status/priority/assignee/labels/
project/due date/start date with value-reveal and overflow re-entry),
and routes both dialogs' 'Customize fields' items to the tab.

Co-authored-by: Lambda <lambda@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-17 13:36:13 +08:00
Jiayuan Zhang
ed9adc2bbe feat: improve create issue field controls (#5532)
Co-authored-by: Lambda <lambda@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-17 11:11:52 +08:00
Naiyuan Qing
c52ebeaa0f MUL-4864 fix(chat): use a single New Chat draft, keyed by session not agent (#5519)
* fix(chat): use a single New Chat draft, keyed by session not agent (MUL-4864)

An uncreated chat kept one draft per agent (`__new__:<agentId>`), so
switching the agent picker mid-compose swapped the draft out from under
the user and left invisible per-agent drafts behind. That slot shape was
never a product decision: it fell out of 34e452776, which keyed the
editor by agent to remount it and refresh the Tiptap placeholder. The
placeholder now syncs live, so the motive is gone — and mobile already
implements the target contract.

An uncreated chat now has ONE draft slot per workspace. `selectedAgentId`
is the send target, not draft ownership. Created sessions keep their own
per-session slots, so conversations stay isolated.

Three connected changes this needs to actually hold:

- Editor identity no longer tracks the agent. Keying it by agent kept the
  draft slot correct but still remounted on switch, dropping whatever the
  100ms draft debounce had not yet persisted — the last thing typed.
- The post-send "scrub the composer?" rule reads `activeSessionId` alone.
  Moving the picker is no longer navigation, so counting it as such left
  a completed send's text in the composer, primed to be sent a second
  time to the agent just picked. Both send chains (the chat tab's
  controller and the floating ChatWindow) now share one exported rule
  rather than two copies of the predicate.
- Legacy `__new__:<agentId>` slots are folded on load. They carry no
  timestamp, so when several exist none can be shown to be newest: adopt
  the persisted `selectedAgentId`'s slot (the draft the workspace would
  have opened with) and drop the rest — those extras are the invisible
  multi-draft state this removes. Text and attachments migrate together
  so a draft can't end up with another agent's files.

Tests cover the acceptance scenarios: agent switch preserves text,
attachments and the live editor instance; sessions stay isolated; the
first send clears the draft and routes to the agent selected at send
time; a failed create keeps the input; and the migration adopts, prunes,
persists, and is idempotent.

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

* fix(chat): file unflushed keystrokes under the draft they were typed in (MUL-4864)

Review blocker: one editor instance serves every chat draft and its
onUpdate is debounced 100ms. Typing in session A and switching to B
inside that window fires the timer with B's draftKey in scope — because
onUpdate always resolves to the latest render's closure — writing A's
document into B's draft. Worse, ContentEditor's dirty guard suppresses
B's incoming sync while those unflushed bytes are live, so B's own draft
never loads and A's context could be sent to B's agent.

This predates the per-agent draft change: on main `editorKey` is
`selectedAgentId`, so any two sessions of the SAME agent already shared
one editor instance and already cross-wrote. Verified by running the new
regression against main's editor identity with the flush removed — it
fails there too. Unifying the New Chat draft widened the same hazard to
cross-agent switches, so it is fixed here rather than left latent.

Fix at the root: a debounced write must land on the key it was typed
under, not wherever the composer now points.

- ContentEditor gains `flushPendingUpdate()`: cancels the armed debounce
  and hands its markdown back rather than firing it, and advances the
  emit watermark so the editor reads clean and the dirty guard stops
  blocking the incoming sync. Distinct from flushPendingOnUnmount — the
  instance is alive, so it reads the live document.
- ChatInput flushes on draftKey change and commits the result to the
  PREVIOUS key. useLayoutEffect, not useEffect: passive effects run
  child-first, so a passive flush would land after ContentEditor's sync
  had already skipped on a dirty editor; a layout effect is part of the
  commit, so no pending timer can fire ahead of it.
- onUpdate and the flush now share one `commitDraft`, so text and
  attachment pruning cannot diverge between the two paths.

Tests exercise the REAL debounce and REAL dirty guard, not an instant
mock onUpdate:

- chat-input-draft-isolation.test.tsx (new): mounts the real
  ContentEditor with only Tiptap's primitive mocked. Pins A/B isolation
  across a mid-debounce switch, that B's draft actually loads, that a
  lazy session create keeps its bytes in the New Chat slot, and that an
  agent switch still preserves the New Chat draft. Three of four fail
  without the flush.
- content-editor.test.tsx: flushPendingUpdate hands back the pending
  markdown, kills the timer, unblocks the next sync, and leaves the
  guard intact when NOT flushed.

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

* fix(chat): keep an in-flight upload bound to the draft it started in (MUL-4864)

Second review blocker. An upload PINS the editor to its source document:
Guard 0 refuses to setContent over an `uploading` node, because wiping it
strands the upload's finalize and the file silently vanishes. So while an
upload runs, the instance still holds A's document even though the user
has navigated to B — and the upload's own completion dispatch fires
onUpdate on that instance, which resolves to the latest render's closure.
Result: A's body and its attachment URL were written into B's draft, B's
own draft was destroyed, and A lost the upload it was waiting for.

Nothing stopped this: useUploadGate gates submit, not session/agent
navigation. Reproduced as a test before fixing — B's "B's own words" was
replaced by A's body + A's CDN URL.

Root cause is the same shape as the debounce bug, one level up: the
editor's writes were keyed off what is SELECTED, when they belong to what
is LOADED. Those are the same key except while an upload pins the
document.

- chat-input models that explicitly with `editorDraftKeyRef` — the draft
  whose document the instance holds. Every editor-driven write (onUpdate,
  the upload's attachment binding) uses it, not `draftKey`.
- The draft switch defers while `hasActiveUploads()`, leaving both the
  document and its writes on the source key, then completes when the gate
  clears. Blocking navigation instead would make the user wait on a
  network round-trip to change tabs.
- The deferred case must force the adopt: ContentEditor's sync effect
  CONSUMED the defaultValue change while Guard 0 was up
  (lastDefaultValueRef advances before the guard), so it never re-runs and
  B's draft would never load. New `adoptContent()` ref method lands it;
  its body is the sync effect's own apply path, extracted so both agree.
- handleSend refuses while loaded !== selected. The upload gate covers
  most of that window but not the sliver between the upload's final
  dispatch and the adopt re-render — React flushes that on a scheduler
  task, so Mod+Enter can land first.

Tests (real ContentEditor, real Guard 0, real debounce, real upload
lifecycle incl. the transaction that publishes the queue flip):
completing upload's markdown stays in the source draft and never reaches
the target; an attachment dropped while pinned binds to the source; the
target draft loads once the guard clears; send is refused mid-divergence.
Each verified to fail without its fix — the send guard initially passed
vacuously (the disabled SubmitButton swallowed the click) and now drives
the real Mod+Enter path.

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

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-16 15:56:55 +08:00
Naiyuan Qing
6b2097ccbb feat(inbox): archived notifications sub-view (MUL-3736) (#5518)
Adds an "Archived" sub-view to the Inbox, reachable from an entry at the
bottom of the main list, with per-row unarchive. Mirrors chat's archived
sub-view so the two surfaces share one mental model.

Backend:
- GET /api/inbox/archived and POST /api/inbox/{id}/unarchive. Kept off the
  existing GET /api/inbox so installed clients keep their contract and the
  unbounded archive never rides along with the main list.
- The archived query excludes any issue that still has an active row. Archiving
  is issue-level, so a new notification on an archived issue leaves old archived
  rows beside a fresh active one — without the guard the issue renders in BOTH
  lists. The exclusion lives in SQL so neither list depends on the other's cache.
- Unarchive is issue-level (mirroring archive) and leaves `read` untouched, so a
  restored unread item raises the unread badge again.
- v1 ships no pagination: LIMIT 200, newest-first, so truncation drops the
  oldest rows and never hides a group's newest one.
- inbox:unarchived event, fanned out to the recipient like the other personal
  inbox events.
- Two CONCURRENTLY-built indexes; inbox_item previously had none covering
  workspace/archived/created_at.

Frontend:
- Separate TanStack cache per list; every inbox event invalidates the workspace
  prefix, since any of them can move an item across the boundary.
- View persisted as ?view=archived, so refresh, back/forward, and the mobile
  detail-back all return to the list the user was in.
- Batch actions stay main-view only — they archive from the MAIN inbox, so
  offering them over the archived list would do the opposite of what it reads.
- Mobile subscribes to inbox:unarchived (its list gains the restored row); its
  own archived view remains follow-up.

Known debt: no pagination, so an archive past ~200 rows is truncated silently
in the UI; the entry's count is the deduplicated count of the rows returned.

Verified: pnpm typecheck/test/lint (0 errors), go build/vet, Go inbox suite
against a real Postgres, migrations up+down, and EXPLAIN confirming both new
indexes serve the query.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-16 14:58:42 +08:00
Bohan Jiang
4fcb27a729 fix(issues): attach labels in the create transaction (MUL-4832) (#5510)
* fix(issues): attach labels in the create transaction (MUL-4832)

Labels chosen at issue creation were attached in a second, non-atomic
round-trip after the issue was already committed (web modal looped
attachLabelToIssue per label; the create endpoint took no labels). A
partial failure of that follow-up left the issue committed but
mis-categorized, surfaced only as a toast.

Carry label_ids through the create request instead: the service
validates them (workspace + resource_type='issue') and attaches them
inside the same transaction as the issue insert, so the issue and its
labels commit together or not at all. An unknown or wrong-scope label
id now fails the whole create with 400 rather than being silently
dropped. Duplicate ids are idempotent (dedupe + ON CONFLICT DO NOTHING).

- server: IssueCreateParams.LabelIDs + ErrIssueLabelNotFound; validate
  and attach in IssueService.Create; handler parses label_ids and maps
  the error to 400.
- web: create-issue modal forwards label_ids and drops the post-create
  attach loop and its dead toast key.
- tests: handler coverage for atomic attach, stale-id 400 (no issue
  left behind), duplicate-id idempotency, wrong-scope rejection; web
  test asserts label_ids is forwarded.

Scope is deliberately labels-only. The contributor PR #5475 also
auto-parsed an acceptance_criteria field from description text; that
introduces a new user-facing data contract with no defined edit/display
rules and is left out for a separate product decision.

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

* fix(issues): echo created labels + guard old-backend compat (MUL-4832)

Addresses review of #5510.

1. Create response + issue:created event now carry the labels attached in
   the create transaction. IssueService.Create returns the authoritative
   label snapshot; the handler sets it on both the HTTP response and the
   issue:created broadcast payload. Without this, online members other than
   the creator saw the new issue unlabeled indefinitely (staleTime: Infinity,
   no invalidation) because this PR removed the old post-create
   issue_labels:changed broadcast.

2. New web + old backend compatibility. During the rolling deploy window the
   web app can run ahead of the backend (web auto-deploys on merge, backend
   deploys manually). An older backend ignores label_ids and returns an issue
   with no labels field. The create modal now falls back to the legacy
   per-label attach only when the response omits labels, so labels aren't
   silently dropped; when labels is present the atomic path already ran and
   no fallback fires. The backend always returns an explicit labels array
   (empty when none) as the detection signal.

Tests: handler issue:created-carries-labels + response-carries-labels +
response-always-includes-labels; web modal no-fallback (new backend) and
fallback (old backend); ws-updaters keeps the label snapshot in list cache.

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

* fix(issues): validate create response through a schema (MUL-4832)

Addresses review of #5510 (must-fix 3).

The create modal keys its label-attach compatibility fallback off
`issue.labels` being absent (older backend) vs a validated Label[]
(current backend). But api.createIssue cast the raw JSON straight to
Issue, so a malformed labels value (null, an object, a garbage array)
would be !== undefined and wrongly suppress the fallback, caching a bad
shape — violating the repo's API Compatibility rule.

Parse the create response through CreateIssueResponseSchema:
- labels absent  -> undefined (older-backend signal; fallback runs)
- labels valid   -> Label[] (fully validated elements, not z.unknown())
- labels malformed -> undefined via .catch (safe: never masquerades as
  handled; worst case a redundant re-attach, never a silent drop)
A whole-body parse failure degrades to EMPTY_ISSUE (never throws into
React), matching the existing parseWithFallback contract.

Tests: schema.test.ts covers absent / valid / null / wrong-element-shape
labels and the empty-issue degrade.

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

* fix(issues): reject a malformed create response instead of faking success (MUL-4832)

Follow-up to review of #5510. The prior fix degraded a schema-failed
create response to EMPTY_ISSUE, which React Query treats as a successful
mutation: the empty issue is written into the list cache, a blank
"created" toast shows, the "View issue" link points at an empty id, and
with labels the fallback attach runs against an empty issue id.

A create that returns an unusable body is a failed mutation, not a
safe-empty read. Fall back to null and reject: mutateAsync is already
inside the create modal's try/catch, so a controlled rejection preserves
the draft and shows the failure toast, and onSettled still refreshes the
list so a genuinely-created issue can still surface.

- CreateIssueResponseSchema tightens id to non-empty; an id-less body
  routes to the same reject path.
- createIssue throws on parse failure (empty-message Error so the modal
  renders its localized "failed to create" toast; parseWithFallback
  already logged the schema issues + raw body).
- Dropped the EMPTY_ISSUE fallback constant.
- Tests: whole-body malformed and empty-id now assert createIssue
  rejects; only-labels-malformed still returns the real issue with
  labels undefined.

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

---------

Co-authored-by: Bohan-J <bohan@devv.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-16 13:31:06 +08:00
Naiyuan Qing
3b08456264 feat(views): block draft-fixing actions while attachments upload (#5465)
Submitting a composer mid-upload silently lost the file: the editor holds a
`blob:` placeholder that gets stripped during serialization, and the
attachment id does not exist yet, so the send succeeded without the file.
Chat and Quick Create gated this; manual issue create, Feedback's button,
comments, replies and comment edit did not.

Gate every action that FIXES a draft — Send/Create/Save, Cmd/Ctrl+Enter,
Enter on the title, and the manual/agent mode switches — behind one source
of truth: the editor document, which IS the upload queue. ContentEditor
publishes queue transitions via `onUploadingChange` (a transaction listener,
not `onUpdate` — that path is debounced and skips no-change emissions, and a
failed upload leaves byte-identical markdown, so the un-gate would never
fire). `useUploadGate` pairs that render state with an `isBlocked()` re-read
at submit time, since the shortcut paths never consult the button.

The publisher emits its current answer on subscribe rather than only on
flips: hosts outlive editor instances (comment edit remounts on cancel,
chat swaps by key on agent switch), and an editor torn down mid-upload would
otherwise leave submit wedged shut with no pending node left to reopen it.

Also fixes the failure toast that never fired: `uploadWithToast` only
reported errors if a caller passed `onError`, and none did, so failed uploads
removed their placeholder and vanished unexplained. `useEditorUpload` now
supplies it once for every composer.

Per MUL-4808: drops Quick Create's upload-time lock on the attach button
(files can queue), and leaves description autosave ungated by design.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-16 09:56:24 +08:00
Jiayuan Zhang
ea8511340e MUL-4820: support custom property icons (#5468)
* feat(properties): add custom icons

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

* fix(migrations): use unique property icon prefix

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

* fix(properties): replace emoji icons with Lucide picker

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

---------

Co-authored-by: Lambda <lambda@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-15 22:37:57 +08:00
Jiayuan Zhang
19e52e007c MUL-4798: make Inbox notification preference updates atomic (#5451)
* fix(notifications): make preference updates atomic

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

* fix(notifications): serialize preference mutations

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

---------

Co-authored-by: Lambda <lambda@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-15 17:52:10 +08:00
chouti
b31132edf3 feat(agents): add access-scope column, filter, and bulk edit to agents list (#5393)
Adds a three-state access column/filter and a bulk "Set access scope" action to the agents list, plus a fix for a state-update loop in the bulk access dialog (parent/child callback + effect cleanup were re-triggering each other).

Co-authored-by: chouti <chouti@users.noreply.github.com>
2026-07-15 15:53:24 +08:00
Jiayuan Zhang
50e28d539c feat(settings): open issue links in new tab by default, configurable in preferences (#5445)
Issue mention chips in descriptions (tiptap), comments (readonly
markdown), and chat now open the linked issue in a new tab on plain
click — a browser tab on web, a foreground app tab on desktop. A new
Settings → Preferences switch (default on) restores in-place navigation
when turned off; the preference persists via a zustand store.

AppLink now honors target="_blank": desktop delegates to the
navigation adapter's openInNewTab with activate, web leaves the native
anchor behavior intact. This also fixes dead cmd/ctrl-click on web for
comment and editor mentions, which previously preventDefault'd without
an adapter fallback.

MUL-4793

Co-authored-by: Lambda <lambda@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-15 14:33:00 +08:00