Commit Graph

1556 Commits

Author SHA1 Message Date
Naiyuan Qing
21267745ee refactor(views): give a project mention the component an issue mention has
`IssueMentionCard` owns "chip inside a link" for issues; the project equivalent
lived inline in the readonly renderer, so nothing named the pairing and nothing
held the rules that come with being a link.

That cost was not hypothetical. Both gaps fixed a commit ago landed on project
mentions alone: `.project-mention` never got the CSS rule cancelling generic
link chrome, and the hover card never learned to skip it. Each was written for
`.issue-mention` at the component that owns it, and project had no such place
for the second half to be written. `ProjectMentionCard` is that place.

No behaviour change: same anchor, same href, same hover affordance, same
accessibility contract that project-mention-a11y.test.tsx pins. The "open in
new tab" preference stays out — it is scoped to issue links, and inheriting it
by symmetry would be inventing product.

Also drops `not-prose` from both cards. It has no definition anywhere in the
repo — Tailwind's typography plugin is not installed, and the class does not
appear in built CSS — so it read as protection that was not there.

The editor's `MentionView` keeps its hand-rolled anchors: it needs a
modifier-click intent hook `AppLink` does not expose, and it does the same for
issues, so the two stay symmetric there too.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 16:05:46 +08:00
Naiyuan Qing
ff5169084d fix(rich-content): decide in-app by resolving the URL, not by its prefix
`href.startsWith("/")` was standing in for "this deployment". It is not: a
browser reads `//other.example/x` and `/\other.example/x` as another host and
goes there, and both start with a slash. The parser skipped the origin check
for exactly the hrefs that most needed it.

Nothing shipped from this: an unfurled chip links to
`paths.projectDetail(uuid)`, so the href it was parsed from is discarded and a
misparse could not send anyone anywhere. The prefix test was still the wrong
instrument. Adding `&& !startsWith("//")` would have looked like a fix while
leaving the backslash spelling through — the gap is the technique, not the
case, so this resolves the href against the app origin with `URL` and compares
`origin`, which is one comparison for every spelling and for the schemes
(`javascript:`, `data:`) whose opaque origin can never match.

Relative and absolute now take the same path, so the slugless legacy form
parses identically whether or not it carries the origin — previously the
absolute spelling was rejected by a reserved-slug test meant for workspace
slugs, and the two disagreed.

`openLink` still tests the prefix, and its result IS navigated. That is a live
issue, older than this feature and wider than it; it needs its own change
rather than a quiet ride here.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 15:25:25 +08:00
Naiyuan Qing
29be640eff fix(editor): stop drawing link chrome over mention chips
A mention chip already carries its own affordance — border, icon, hover
background — so the generic `.rich-text-editor a` color and underline draw a
second, competing one straight through the card. `.issue-mention` reset it;
`.project-mention` never did, so project chips shipped with a brand-coloured
underline through them. The rule belongs to the chip shape rather than to one
entity, so both selectors now share it and a future chip is one line.

The hover card had the same gap: it skipped `.issue-mention` only, so hovering
a project chip opened a URL card offering to copy `/{slug}/projects/{uuid}` —
an in-app path, not the shareable link that wording implies.

Both are pre-existing, but a bare project URL now renders as a chip, so what
used to surface on hand-written mentions alone shows up on ordinary pasted
links.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 15:00:35 +08:00
Naiyuan Qing
70f8200427 fix(rich-content): unfurl issue URLs in identifier form
The unfurl required a UUID id, on the stated grounds that "every link the
app itself produces carries a UUID". That holds for a project but not for
an issue: `copyLink` and `openInNewTab` both build
`paths.issueDetail(issueIdentifier || issueId)`, and the issue route
rewrites a UUID URL back to the identifier — so `MUL-123` is the shape a
user actually copies, out of the app or out of the address bar. The issue
half of the feature could not fire on the links people paste, while bare
`MUL-123` prose did become a chip: the fuller reference lost to the
shorter one.

`parseWorkspaceEntityLink` now accepts an issue identifier as well as a
UUID. A project still requires a UUID — it has no shorthand, so an
identifier-shaped id under /projects/ addresses nothing.

An identifier needs a lookup, which means it can miss, and the miss has to
differ by entry point. `AutolinkedIssueMentionLink` degraded to plain text,
which is right for autolinked prose and wrong for a URL: the author wrote a
link, and an issue this workspace cannot see must not cost them the only
pointer to it. The fallback is now a prop — plain text for the autolink
path, the original anchor for a URL.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 15:00:22 +08:00
Lambda
d7231d34d7 feat(rich-content): render bare in-app project/issue URLs as chips (MUL-5499)
A project has no `MUL-123`-style identifier — only a UUID and a free-text
title — so there is nothing for the bare-identifier autolink preprocessor to
detect, and the link copied out of the app is how people actually reference
one. It rendered as a raw URL.

RichLink now unfurls a bare in-app entity URL into the same chip the
`mention://project/<uuid>` form already produces (issue URLs go through the
same path for symmetry). Render-only: stored markdown is untouched, and the
editable Tiptap path is deliberately unaffected.

Three guards, each load-bearing: the link must be bare (an authored label is
never discarded), same-workspace (a chip resolves its title in the current
workspace only), and address exactly one entity page by UUID with no query or
fragment.

Also:
- mobile: tapping a `mention://project/` link navigated nowhere despite the
  `project/[id]` route existing — it now pushes the project detail.
- agents had no documented way to emit a clickable project reference: add the
  link form to the runtime brief's Mentions section and to the projects skill,
  and record in the mentioning skill why `project` sits outside `MentionRe`
  (render-only, enqueues nothing).

Co-authored-by: multica-agent <github@multica.ai>
2026-07-29 21:53:37 +08:00
Jiayuan Zhang
9e3b661d44 fix(views): open a background tab on middle-click of AppLink (MUL-5457) (#6126)
AppLink only handled onClick, and a middle click never produces a click
event, so it fell through to Chromium's native window-open. The desktop
shell denies every window-open and forwards the URL to openExternalSafely,
which only allows http/https. In a packaged build the renderer is file://,
so an in-app href resolves to file:///<path> and gets dropped — middle
clicking a sub-issue row, list row, board card, gantt bar, parent
breadcrumb or content mention did nothing at all. In dev the renderer is
http://localhost:<port>, which clears the allowlist and throws the click
out into the system browser instead.

Add onAuxClick with the same semantics as useRowLink: middle button only,
background tab through the adapter on desktop. Web keeps the native path
untouched — AppLink renders a real anchor, so the browser's own background
tab is already the correct outcome and preventing it would break it.

Co-authored-by: Lambda <agent@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-29 19:00:50 +08:00
Jiayuan Zhang
2889269c16 fix(views): open a real browser tab on modifier-click for non-anchor surfaces (MUL-5456) (#6125)
Web has no `NavigationAdapter.openInNewTab`. Real anchors are fine — the
browser's native modifier-click handles them — but three surfaces are not
anchors and had no fallback, so cmd/ctrl/middle click silently did the
wrong thing:

- List rows (`useRowLink`) navigated in place. Affects projects / agents /
  skills / squads / runtimes / autopilots.
- Avatar profile links navigated in place.
- Editor project mentions did nothing at all: `handleClick` called
  `preventDefault()` up front, then returned when no adapter was found.

Fixed per surface rather than by defining `openInNewTab` on the web
adapter — that would regress `AppLink`, which relies on the adapter being
undefined to let native anchor semantics through, collapsing cmd+click
(background tab), shift+click (new window) and cmd+shift+click
(foreground tab) into one `window.open` outcome.

- Non-anchors (rows, avatars) get the `window.open(getShareableUrl(...))`
  fallback already used by `table-view` and `html-attachment-preview`.
- The project mention is a real anchor, so `preventDefault()` moves into
  the branches that actually handle the click, matching `IssueMention` in
  the same file. Native behaviour is preserved.

Desktop is unchanged: the adapter still wins on every path.

Co-authored-by: Lambda <lambda@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-29 19:00:14 +08:00
Jiayuan Zhang
f3d88b1d47 feat(issues): add "Open in new tab" to the issue actions menu (MUL-5455) (#6124)
The issue right-click / kebab menu had status, priority, assignee, dates,
pin, copy link, copy workdir path, sub-issue relations and delete — but no
way to open the issue itself. Users who don't know modifier-click had no
affordance at all for opening an issue somewhere else.

Adds `openInNewTab` to `useIssueActions` (reusing the canonical pattern from
table-view's `openIssue`) and surfaces it at the top of the "act on this
issue" group, above the copy actions. Foreground tab (`activate: true`):
this is an explicit CTA, so focus follows the user into the new context —
unlike modifier-click, which stashes a background tab.

Desktop routes through the tab adapter (`openTab` dedupes by pathname, so a
second invocation focuses the existing tab); web has no adapter and falls
back to a browser tab via the shareable URL.

Copy reuses the established "新标签页" / "Open in new tab" wording from the
preferences toggle and the attachment preview, in all four locales.

Co-authored-by: Lambda <lambda@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-29 18:57:30 +08:00
Jiayuan Zhang
545afea827 Revert "feat(ui): establish a role-named type scale and migrate ad-hoc font s…" (#6116)
This reverts commit d68d636c91.
2026-07-29 17:10:33 +08:00
Jiayuan Zhang
d68d636c91 feat(ui): establish a role-named type scale and migrate ad-hoc font sizes (MUL-5451) (#6108)
tokens.css defined colours, radii and font families but not a single
--text-* step, so font sizes had no baseline to align to and grew wherever
they were needed: 51 distinct sizes across web + desktop, 370 of them written
as arbitrary text-[Npx] values, six at half a pixel (10.5 / 11.5 / 12.5 /
13.5 / 14.5 / 15.5px). text-xs and text-sm carried nearly all UI text while
the range between them — 11, 13, 15px — could only be reached with arbitrary
values. Hierarchy does not come from having more sizes; past a handful, each
extra size makes the hierarchy blurrier.

Add ten role-named steps, each with its own line-height so leading cannot
fragment the way size did, and move every product-UI call site onto them.
Steps are named for what the text is for, not for a t-shirt size, because
that is what keeps the scale from drifting again.

Six steps deliberately keep the exact size/line-height pairs of the Tailwind
defaults they replace, so the 1,900-call-site rename (text-sm -> text-body
and friends) moves nothing on screen. The visible changes are confined to
former arbitrary values snapping to a step:

- 8 / 9 / 10px -> micro (11px): 102 sites, mostly badges and overline
  labels. Deliberate — under 12px is a counter role, not a text role.
- 17px -> title (18px), 22px -> display-sm (24px), 30px (text-3xl) ->
  display (36px): 21 sites, all headings or stat numbers in flexible
  containers.
- Half-pixel steps are gone entirely.

Arbitrary sizes also inherited whatever line-height was above them; the
tokens now pin one, which removes latent overflow risk in the fixed-height
h-4/h-5 badges those sizes were used in.

apps/mobile (own NativeWind config) and apps/docs (fumadocs' own type system)
keep Tailwind's default scale and are untouched. Landing-page display type
(rem/clamp, 2.2-6.4rem) is marketing typography on a separate ramp and stays
out of the scale, as do four decorative emoji / serif-hero sizes.

A guard test fails the build on any font size written outside the scale,
reporting file:line — without it nothing in a Tailwind build makes an
off-scale value look wrong, which is how the drift happened.

Verified against a local stack: typecheck, lint and the full TS suite show no
new failures, and before/after screenshots of issues, issue detail, runtimes
(populated), usage, agents, inbox, my-issues and settings differ only in the
intended 10px -> 11px labels, with no row-height, truncation or layout shift.

Co-authored-by: Lambda <lambda@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-29 16:30:13 +08:00
Jiayuan Zhang
4f70480039 fix(typography): real Inter italic + variable Geist Mono on desktop (MUL-5449) (#6105)
* fix(typography): load real Inter italic on web and desktop (MUL-5449)

Neither platform loaded an Inter italic face: next/font defaults to
`style: ["normal"]`, and desktop imported `@fontsource-variable/inter`
without `wght-italic.css`. Every `italic` in the product was therefore
browser-synthesized oblique — the ~20 semantic UI labels (chat empty
states, model-picker's "Managed by runtime", dashboard/squad
placeholders) plus all markdown <em> and blockquotes, which are user
content.

Load the real face on both, mirroring how Source Serif 4 is already
wired.

Also set `font-synthesis: style` on html, which forbids weight synthesis
while leaving style synthesis on. Weight synthesis is pure loss now that
every loaded face has real weights, and it is actively harmful on the CJK
tail of --font-sans: `font-bold` against PingFang SC (which stops at
Semibold) makes Chromium smear a fake 700 that closes up the counters of
dense Han glyphs.

Style synthesis stays on deliberately. `auto` only synthesizes when no
real italic matches, so the newly loaded Inter Italic already wins for
Latin. The only stacks still synthesizing are the two that ship no italic
at all — CJK fallbacks and Geist Mono — where `font-style: italic` has no
other visual carrier; a blanket `font-synthesis: none` would silently
flatten every Chinese <em>, every editor italic mark and every hljs
comment to upright.

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

* fix(typography): give desktop the variable Geist Mono (MUL-5449)

Web gets a variable Geist Mono from next/font (`font-weight: 100 900`,
verified in the emitted CSS), but desktop loaded only the discrete 400
and 700 cuts. Any weight in between silently snapped to the nearest one
desktop had, so the same shared component rendered at two different
weights on the two platforms:

- `font-mono font-medium` (500) fell back to 400 in chart.tsx,
  webhook-event-filter-section.tsx and keyboard-shortcuts-tab.tsx.
- Inline <code> in rendered markdown has no explicit weight, so inside a
  <strong>, heading or <th> — all `font-semibold` — it inherits 600 and
  resolved up to 700.

Loading `@fontsource/geist-mono/500.css` would have fixed only the first
of those. Switching to `@fontsource-variable/geist-mono` covers 100-900
in one file and closes the whole class, matching how Inter and Source
Serif 4 are already wired on desktop.

The latin subset is also smaller than the two static cuts it replaces:
22.6 KB vs 14.4 + 14.9 KB.

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

* docs(typography): correct stale Geist Mono note in font-synthesis comment

The comment was written when desktop still loaded discrete 400/700 cuts.
The following commit swapped desktop to @fontsource-variable/geist-mono,
so Geist Mono is now variable on both platforms and the "ships discrete
cuts" clause was false. Also name landing's 400-only Instrument Serif so
the "every face we load has real weights" claim is complete.

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-29 16:29:05 +08:00
Multica Eve
c25a82eee0 perf(agents): fast model discovery on runtime switch (MUL-5444) (#6098)
* perf(agents): make runtime model discovery fast on runtime switch (MUL-5444)

Switching runtime in the agent creation form left the model picker
spinning for ~8-20s. Two costs stacked up:

- the list-models request sat in the store until the daemon's next
  scheduled heartbeat (0-15s, avg 7.5s of pure dead wait), and
- the daemon then enumerated the catalog locally (static for claude,
  but a CLI/ACP round trip up to ~15s for everyone else).

Both are addressed with the two standard techniques for a slow,
low-frequency, read-only operation: push instead of poll, and
stale-while-revalidate.

Push (removes the heartbeat wait):
- new additive `daemon:pending_work` hint, runtime-scoped, delivered
  through the existing daemon WS hub and the Redis relay so the API node
  holding the socket does the delivery.
- the daemon answers a hint with ONE immediate heartbeat and dispatches
  what it claimed. The hint deliberately carries no work, so nothing has
  to be un-claimed when delivery fails and a duplicate hint cannot
  duplicate work - PopPending stays the atomic claim.
- per-runtime coalescing plus a 1s floor keeps a caller-triggered hint
  from becoming a heartbeat amplifier.

Cache (removes the discovery wait on repeat opens):
- server-side per-runtime catalog cache (in-memory single-node, Redis
  multi-node) written on every successful report.
- a snapshot younger than 15min answers the POST immediately as an
  already-completed request; older than 60s it also enqueues a
  background refresh that only warms the cache.
- only supported, non-empty catalogs are cached; a completed-but-empty
  report invalidates instead, while a failed report keeps serving the
  last known good list.

Frontend: staleTime 60s -> 5min and gcTime 30min, so a runtime revisited
in the same session renders from cache and revalidates in the background
instead of showing the spinner again.

Compatibility: every wire change is additive. Old daemons ignore the
unknown hint type and keep using the scheduled heartbeat; new daemons
against an old server simply never receive one. The cached response is
shaped exactly like a completed live discovery apart from the optional
`cached` / `cached_at` markers.

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

* fix(agents): address review on model discovery SWR (MUL-5444)

Sol-Boy's review on #6098 found the client cache could outlive the
server's own staleness promise, and that the two changed endpoints were
still cast rather than validated.

Must-fix 1 — client freshness now derives from the served answer.
`staleTime` was a flat 5min, so a 14-minute-old snapshot (which the
server returns while queueing its own refresh) was held as fresh for
another 5min: observable staleness became server window + client window,
and the refreshed catalog never reached the tab that triggered the
refresh. `staleTime` is now a function of the query data: a `cached`
answer is stale on arrival (bound stays the server's window alone, and
the next mount/focus picks up the refreshed snapshot), while a live
discovery — which just measured the truth — is trusted for the full 5min
so a cold runtime is never re-enumerated inside one form session.
`gcTime` stays 30min, so a revisited runtime still renders from cache and
revalidates in the background; the pickers gate their spinner on
`isLoading`, which stays false throughout.

Must-fix 2 — both model-discovery responses go through a zod schema.
`POST /api/runtimes/{id}/models` and its poll companion were casting
network JSON to `RuntimeModelListRequest`, which the root CLAUDE.md
API-compatibility rules forbid. Added a lenient schema (`status` stays
`z.string()`, `supported` defaults to true, `.loose()` keeps unknown
fields) plus a fallback record whose `status` is `failed`: a malformed
body now surfaces "discovery failed" with manual entry still usable
instead of a fabricated empty catalog or an endless spinner.
`resolveRuntimeModels` was tightened to match — only an explicit
`completed` is a catalog, so an unrecognised status is an error rather
than a silent empty list, and `supported` can no longer be `undefined`.

Nit — the in-memory catalog cache now deep-copies each entry's
`Thinking` (and its level slice) and `ServiceTiers`, so it delivers the
independent value its comment promises and matches the Redis backend's
JSON round-trip semantics.

Tests: staleTime policy for cached/live/no-data; a QueryObserver test
proving the refreshed catalog reaches the same client with no blank
loading state; unknown-status and omitted-`supported` handling; schema
tests for live, cached, old-backend and nine malformed shapes; client
tests that both endpoints degrade to an explicit failure; nested-field
mutation isolation for the cache.

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-29 16:03:26 +08:00
Bohan Jiang
06a100d612 fix(editor): render proxy-mode inline images from an authenticated byte fetch (MUL-5445) (#6091)
Inline media re-sign is a two-step repair: detect that a URL is auth-gated
(fixed in #6029), then swap in a URL a native <img> can load. The second step
only worked where the server had a signed URL to give — CloudFront signing, or
presign mode with a DownloadPresigner. In proxy mode GetAttachmentByID returns
`/api/attachments/<id>/download` again, the renderer rejected it and kept the
URL it already knew 401s, so the image stayed broken and the metadata request
was pure overhead.

Proxy is the default for self-hosted storage on an internal host: `auto` mode
forces it for a dotless hostname (docker-compose MinIO at `http://minio:9000`),
localhost, .local/.internal/.lan/.docker suffixes, and private/loopback IPs.
Combined with a client that cannot ride the session cookie on a native resource
fetch — Desktop's file:// renderer, the mobile webview, split-origin web
(cookies are SameSite=Strict) — every inline image in such a deployment fails.

When the refreshed metadata confirms there is no signed URL, pull the bytes
through the authenticated API client and paint them from an object URL. The
metadata request stops being wasted: it is the per-attachment probe that
decides signed-URL vs bytes, so presign/CloudFront clients never double-fetch.

- getAttachmentBlob goes through fetchRaw, inheriting auth headers, 401
  recovery and the ApiError shape, mirroring getAttachmentTextContent.
- The byte fetch is gated on the image branch; a file card only needs a link
  and must not pull a large archive into renderer memory.
- The object URL is revoked on unmount, and the blob query is capped with a
  5 minute gcTime so an image-heavy thread does not pin every screenshot.
- Copy Link keeps handing out the durable URL — a blob: URL resolves only
  inside this renderer session.

Co-authored-by: J <agent-j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-29 15:45:11 +08:00
Jiayuan Zhang
9ff766d700 fix(ui): tabular figures on live task timers (MUL-5448) (#6094)
The 1Hz elapsed counters rendered with proportional figures, so the text
width changed on 9s -> 10s and 1m 9s -> 1m 10s, nudging the neighbouring
elements once a second for the whole run.

Web/desktop take the `tabular-nums` utility. Mobile cannot: Tailwind
compiles that class to `font-variant-numeric`, and react-native-css-interop's
property allow-list only carries `font-variant-caps`, so the declaration is
dropped and the class is a silent no-op on RN. Use RN's `fontVariant` style
prop there instead.

Co-authored-by: Lambda <lambda@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-29 15:38:12 +08:00
Jiayuan Zhang
e54296eabb fix(ui): raise light muted-foreground to clear WCAG AA (MUL-5447) (#6095)
In light mode `--muted-foreground` at oklch(0.552 0.016 285.938) missed the
4.5:1 AA floor on every light surface except pure white. The worst pair was
3.98:1 — nav labels on --sidebar-accent, i.e. the primary navigation in its
hover state. One token backs ~2k `text-muted-foreground` call sites, so the
blast radius is most of the product's secondary text.

Drop lightness to 0.505 and leave hue and chroma alone so the neutral ramp
keeps its cast. Worst case is now 4.88:1. Dark mode already passed at
5.2-7.2:1 and is untouched.

The `.landing-light` block in apps/web re-declares the light palette so
token-driven components stay light under next-themes' `.dark` class; it moves
with the source or it silently drifts.

The new test asserts on the token file rather than on components: the token is
the contract every consumer inherits, and a component test could only prove a
class name is present, not that the pixels are legible.

Co-authored-by: Lambda <lambda@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-29 15:37:20 +08:00
Bohan Jiang
bdae0d2a03 fix(attachments): serve proxy-mode downloads with a scoped capability URL (MUL-5292) (#6092)
Desktop users on self-hosted deployments saw a save dialog but never got a
file. Electron's native download is a browser-level request: it carries
neither the desktop client's Authorization header nor a session cookie, so
GET /api/attachments/{id}/download answered 401.

The authenticated GET /api/attachments/{id} already exists to hand native
loaders a URL they can fetch without our credentials, and it already does so
in two of three modes -- a CloudFront-signed URL, or an S3 presigned URL.
Proxy mode (local disk, private object host) had no equivalent and kept
returning the auth-gated API path, which is the whole of the bug: one
unfinished branch of an otherwise correct design.

Finish that branch. In proxy mode the already-authenticated metadata endpoint
now mints a capability -- an HMAC-SHA256 signature over (version, attachment
id, expiry) with a key domain-separated from the JWT secret, valid for 60
seconds and scoped to exactly one attachment -- and a separate public route
redeems it. Membership is checked when the capability is minted, never at
redemption; the signature is the proof that check happened.

Nothing moves out of middleware.Auth: the existing authenticated download
route is untouched, so clients that predate this keep working and there is no
second copy of the header/cookie/PAT/task-token resolution. The capability
route always proxy-streams, so it emits no cross-origin redirect and the
signed query cannot leak to a CDN in a Referer.

The capability is site-relative and minted only by GetAttachmentByID. Both
matter: an absolute URL would be picked up by the inline-media re-sign path
and pinned into an <img> far longer than the TTL, and a capability in a list
response would expire before anything used it.

Verification: go test ./internal/handler/ ./cmd/server/ and the
@multica/views editor tests pass; gofmt, go vet, tsc --noEmit clean.

Co-authored-by: Bohan-J <bohan@devv.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-29 15:20:13 +08:00
Naiyuan Qing
6c9a59cc14 refactor(uploads): widen handleUpload contract, fix interrupted doc drift (#6035)
Follow-up to #6025 (MUL-5391), addressing the two non-blocking cleanups
raised in review.

1. `CoordinatedUploads.handleUpload` was still typed as a one-arg
   function while the real `ContentEditor` contract is `(file, uploadId)`.
   Runtime was already safe (the implementation accepts `uploadId?`), but
   the exported interface erased the second parameter at the boundary, so
   a mock or hand-rolled caller could silently drop the editor-minted id
   and mint a second one — breaking the one-id link between the document
   node and the draft record. Widened the type and documented why the id
   must be threaded through.

2. #6025 changed `normalizeStoredUploads` to DROP persisted `uploading`
   records instead of coercing them to `interrupted`, but eleven comments
   across core and views still described the old coercion. Corrected them
   to state what the code does. `interrupted` is now produced by no code
   path at all; it stays in the union and is still accepted, rendered and
   dismissable because builds before this change persisted such records.
   Marked it LEGACY at the type and in the test that pins the behaviour.

No runtime behaviour change: comments, one type widening, one test comment.

Verified: pnpm typecheck (6/6); packages/core Vitest 1133 tests;
packages/views Vitest 3178 tests; git diff --check.

Co-authored-by: multica-agent <github@multica.ai>
2026-07-29 15:17:40 +08:00
Naiyuan Qing
e70e71fea1 fix(issues): repair the issue Table's column interactions, loading states and scroll rendering (#6036)
* fix(ui): make table column resizing land where the pointer is (MUL-5166)

Pressing the resize handle committed a width before any drag: it wrote the
rendered width, which fixed table-layout had stretched past the configured
one, so a stray click on a column edge silently rewrote and persisted that
column and stopped it adapting to the window. Dragging then ran ahead of the
pointer, because committing one width changes how much leftover space the
layout has to share out and rescales every other column mid-gesture. And the
gesture never ended if the pointer came up outside the window or the user
switched apps — the column kept tracking the pointer on return, with the page
stuck unselectable under a col-resize cursor.

- Require 4px of travel before anything is committed, matching the
  column-reorder sensor's activation distance on the same header.
- Pin every column to its rendered width on the frame the drag starts, so
  there is no leftover left to redistribute and the drag maps 1:1.
- Capture the pointer and end on blur / pointercancel / lostpointercapture,
  the same four-part contract the sidebar rail already uses.
- Own the cursor from a portaled full-viewport layer for the duration of the
  drag. document.body.style.cursor loses to any descendant that declares its
  own, which is why the cursor flickered over rows and text.

Resizing also re-rendered every cell each frame, and each cell carries a
popover, so a frame cost ~143ms. Widths now travel as custom properties
published once on the <table>, and the body is swapped for a memoized copy
while a drag is live — the browser applies each new width with no React work.

Visually the table had no column rules at all, which left the pinned columns'
trailing shadow as the only vertical line and made it read as a stray border
on one column. Every column now carries a rule, the pinned shadow appears
only once the surface is actually scrolled sideways, and the resize handle
brightens its rule to brand rather than matching the faint resting colour.

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

* feat(issues): drag a table column by its header, not by a hidden grip

Reordering columns was advertised by a grip that only appeared once the
pointer was already on the header, and dragging it moved a wrapper the height
of its own line of text while the <th> around it clipped anything that
travelled outside the cell — so the column being moved looked like it had
vanished rather than slid.

The header is now the handle. There is no grip: the cursor carries the
affordance (grab, then grabbing), the whole cell responds, and only the
sort/hide button opts out, since a press there is always the menu. The
wrapper spans the cell's box at all times — the negative margins undo the
<th>'s padding and put it back inside — so what travels is a header-sized
block and going translucent is the entire drag state, matching how a desktop
tab behaves. Overflow is lifted for the length of a reorder and the column in
hand is raised over its neighbours.

Columns are restricted to the horizontal axis, the same constraint the
desktop tab bar puts on tab reordering, which is why packages/views now
declares @dnd-kit/modifiers directly.

Transforming the <th> itself would carry the header's height along for free,
but `transform` on a table cell is a corner of the spec browsers take
liberties with — Chromium lifts the cell out of the table's box model and its
geometry stops matching the row.

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

* fix(issues): stop a dragged table column stretching into its neighbour's width

Dragging a column header applied dnd-kit's full transform, which carries
scaleX/scaleY alongside the translation. Those come from its layout animation:
old rect over new rect, tweening an item into the shape of the slot it lands
in. Between two tabs of equal width the ratio is 1 and never shows. Between
two columns it is not — swapping a 174px column with a 96px one stretched the
header to 1.8x on the way across. Only the horizontal translation is applied
now; reordering changes no column's width, so there is no shape for a tween to
describe. The travel and the settle stay animated through `transition`.

The travelling wrapper was also fixed at h-8 while the header strip measures
39px once row borders are counted, leaving the block short at both edges and
misaligned by 3px — which read as the same flattening. Its height is derived
from the cell now.

The reorder grip returns as the drag handle, appearing on hover as before and
staying visible for the length of a drag.

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

* fix(ui): mark the frozen column boundary while the table is scrolled sideways

The edge where the frozen columns end had a shadow drawn inside the pinned
cell with `--border`. That token is tuned for hairlines between adjacent
surfaces — oklch .945 in light mode — and all but disappears once spread into
a shadow, so the boundary read as unmarked while content slid underneath it.
Darkening it in place only made the frozen column look outlined: an inset
shadow can shade that cell's own edge and nothing else, while the depth being
described belongs to the content passing beneath.

Split into the two things MUI X's data grid separates, a permanent border for
where the boundary is and a shadow for something crossing it right now:

- the rule between the frozen columns and the rest stays put, as before;
- a 12px gradient is cast past the frozen block, mounted outside the scroll
  container and shown only while scrolled sideways.

Its position is measured off the DOM — the trailing frozen header carries a
`data-pinned-edge` marker — rather than summed from column sizes. Fixed
table-layout stretches columns past their configured widths whenever the
container is wider than the table's min-width, so a sum lands the marker in
the middle of visible content.

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

* feat(ui): size a table column to its content on double-click

Double-clicking a column's resize handle called column.resetSize, which
cleared the stored width and let TanStack fall back to its generic default of
150 — a number unrelated to any width this table was designed with. "Reset"
therefore widened priority from 130, collapsed labels from 220, and clamped
title to its 260 minimum. It restored nothing.

It now sizes the column to its widest rendered cell, the convention Excel,
Sheets, AG Grid and Notion all share for that gesture.

Fixed table-layout ignores content and the cells truncate their own text, so
nothing on screen reports the width the content wants. The measurement lifts
both constraints across the column's cells, reads them, and restores
everything within the same task, so the browser paints once — after the
restore — and the intermediate layout is never seen. Only the rows inside the
virtual window are measured.

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

* fix(ui): stop the table header flickering black during scroll

The sticky header carried backdrop-blur over a translucent fill. A
backdrop-filter on a sticky element with content scrolling beneath it is a
known Chromium compositing fault — the blur layer recomputes its backdrop
every frame, and virtualisation is adding and removing the very rows it reads
from, so the strip flickers black under fast scrolling. Chromium's fix for the
same symptom was reverted, so upgrading does not carry it (electron#12906,
electron#45854, chromium#339841685).

The fill is now the opaque colour that bg-muted/30 was compositing to, which
is the mix the pinned header cells already use. A header the content scrolls
behind has no reason to show it through.

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

* fix(issues): give the table's title column back the width its hover actions held

The sub-issue and rename buttons sat inline in the title cell at opacity 0.
Hidden is not absent: their boxes reserved around 40px of the column at all
times, in the one column with the least room to spare, for controls that only
appear on hover. They are positioned over the cell's trailing edge now, the
way SidebarMenuAction is, with a gradient fading the title running underneath
rather than icons sitting on top of it — the sidebar needs no gradient because
its labels are short, a title runs to the cell's edge. focus-within keeps them
reachable from the keyboard, where hover never fires.

Reordering a column also scrolled the table vertically. Modifiers constrain a
drag's movement but not its auto-scrolling, which reads raw pointer
coordinates, so a few pixels of vertical drift sent the rows moving under a
gesture that cannot act on them. Auto-scroll's y threshold is zero now; x
keeps it, since a table wider than its viewport needs it to reach a distant
slot, but at 0.05 rather than the default 0.2, which arms it a fifth of the
way in from either edge — most of a wide header.

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

* feat(issues): show the table's own grid while its first page loads, and end its columns like every other surface

Two loading-state gaps, both in how the table's non-issue rows are modelled.

A cold load rendered a single "Loading…" line under a full header, which reads
as an empty table rather than a loading one. The surface-level skeleton meant
for it was unreachable: use-issue-surface-data hardcodes isLoading to false for
table, so the `mode === "table"` branch never ran — and it would not have
fitted, drawing rounded bars at p-2 where the table draws an edge-to-edge grid,
so switching it on would have traded one wrong state for a layout jump.

Placeholders are rows now, rendered through the ordinary cell renderer so they
inherit the real column widths, pinning and borders. Everything the table knows
before its data — header, toolbar, column layout — is up immediately, and the
rows swap in without moving anything. The unreachable surface branch is gone.

The end of a column was hand-rolled too, leaving the table the one surface
where a failed page read as muted body text rather than an error, where the
prompt sat left-aligned against three centred ones, and where reaching the end
of a paginated branch said nothing at all. The row carries its state rather
than a finished label and renders through ListLoadMoreFooter, the footer Board,
List and Swimlane already share — which exists, per its own comment, so those
states and their wording stay consistent.

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

* fix(ui): measure table rows instead of assuming they are all one height

Virtualisation sized every row at the same 41px estimate, but the table does
not have one kind of row: group headers are 37px, an end-of-column footer
shorter still, and placeholders different again. Each one put the estimate a
few pixels out, and the error accumulates — with grouping on, the rendered
window drifts off the rows it should be showing and the scrollbar overshoots
the bottom.

Rows report their own height through the virtualizer's measureElement now, and
the estimate only covers rows that have never been mounted. Rows built by
renderRow are cloned to carry data-index and the measuring ref, so callers keep
returning a plain <tr> and still take part.

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

* fix(issues): scope the title cell's actions to the title cell

The sub-issue and rename buttons keyed off the row's hover state, so pointing
anywhere along a row — a status chip, a date, empty space — put controls that
act on the title under a pointer that was somewhere else. They follow the
title cell's own hover now. The gradient behind them still tracks the row
colour, since that is what the cell is painted with while they are showing.

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

* fix(ui): let group rows report their height, and re-measure the frozen edge on resize

Self-review of the branch turned up two places where a change did not reach as
far as it was supposed to.

Group rows never took part in the row measurement. DataTable clones the
virtualizer's ref and data-index onto whatever renderRow returns, but
IssueTableGroupRow declared only its own three props and absorbed them — and a
group header, being shorter than a data row, is exactly the row the
measurement was added for.

The frozen-column shadow measured its boundary from the scroll handler alone.
Resizing a frozen column while the surface is scrolled sideways moves that
boundary with no scroll event to follow, leaving the shadow behind at the old
position.

Also drops a dependency left behind when the load-more rows stopped building
their own labels.

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 15:13:20 +08:00
Multica Eve
07a78d910d fix(daemon): discover agent CLIs installed after startup (MUL-5439) (#6086)
* fix(daemon): discover agent CLIs installed after startup (MUL-5439)

The built-in agent availability set was built exactly once, in LoadConfig, and
every later consumer read that static map. A CLI installed while the daemon was
running was therefore invisible until the daemon process restarted.

On Desktop that is worse than it sounds: the app defaults to autoStop=false and
auto-start only compares CLI versions, so quitting and reopening the app does
not restart the daemon. A user who installed a CLI, verified it in their shell,
and relaunched the app was left with a runtime that never appeared — which is
GH #6077, reported against Antigravity but not specific to it.

- Extract discovery into probeAgentCLIs (pure availability, no version gate).
- Add agentDiscoveryLoop: re-probe every 2 minutes and register providers that
  appeared, reusing applyRegisterResponseInPlace so nothing restarts and no
  in-flight task is interrupted. RecoverOrphans is deliberately not called here
  (MUL-3332): surviving runtimes may be executing tasks.
- Additive only. A provider that stops resolving is kept, because a narrower
  PATH or a version manager mid-upgrade would otherwise tear down a working
  runtime. Removal stays with an explicit restart.
- Hold the set in an atomic.Pointer copy-on-write: cfg.Agents was read unlocked
  from task-execution paths, so a mutable map would be a data race.
- Cache the login-shell PATH fallback process-wide with a 30m TTL keyed on
  PATH/SHELL/HOME, so the 2-minute loop stays a pure LookPath sweep instead of
  forking the user's rc files every round.
- Report skipped_agents on /health with the reason a discovered provider was
  dropped at registration, so "not installed" and "installed but rejected" stop
  looking identical (they were only distinguishable in the daemon log).
- Fix the onboarding template in all four locales: it told users that
  restarting the desktop app was enough, which is exactly the false lead the
  reporter followed.

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

* fix(daemon): retry discovery until registered, and never evict live runtimes

Addresses both P1s from review.

P1-1: a failed first attempt was never retried. refreshAgentAvailability
published a newly discovered provider into the availability set and only acted
on providers "gained" in that same round, so a version probe that timed out or a
register call that failed left the provider permanently unregistered — the user
was back to restarting the daemon, with skipped_agents able to explain the
problem but not fix it.

Registration is now driven by live state instead of by the round that discovered
the provider. providersMissingRuntimes derives, from runtimeIndex, which
discovered providers lack a built-in runtime in each tracked workspace;
convergeRuntimeRegistrations registers only those, for only the workspaces that
need them. Nothing records "already handled", so a version-probe failure, a
register failure, or a partial failure across workspaces all retry on the next
tick, and a provider rejected for being below the minimum version recovers on
its own after an in-place upgrade. A permanently stuck provider is bounded by
exponential backoff (one discovery interval up to 30m) on the expensive half
only; discovery itself stays on its 2-minute cadence, and the steady state
issues no version probes and no register calls at all.

P1-2: the "additive only" refresh could evict existing custom runtimes.
applyRegisterResponseInPlace treats the response as authoritative and drops
prior runtime IDs it does not mention — correct for the convergence paths that
re-derive a whole runtime set, wrong here. appendProfileRuntimes is best-effort,
so one failed GetRuntimeProfiles call yields a builtins-only response, which
would evict the workspace's custom profile runtimes from runtimeIndex and stop
their heartbeats, possibly mid-task.

Added mergeRegisterResponseInPlace: indexes and appends returned runtimes, never
deletes an unmentioned one, and keeps profileSetSig when the fetch failed. Safe
because the server's register endpoint is a pure per-entry upsert that prunes
nothing, so omitted runtimes still exist server-side. ID rotation is still
handled destructively for that one runtime, so a re-issued ID replaces its
predecessor instead of leaving two heartbeat goroutines.

New regression tests: first version probe fails then recovers; first register
call fails then recovers; partial failure across two workspaces retries only the
one that needs it and makes exactly one call; a failed profile fetch leaves the
custom runtime indexed, watched, and its signature intact; below-minimum
provider registers after an upgrade; steady state re-probes nothing; rotated
runtime ID is swapped not duplicated; stuck provider is retried but bounded.

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

* fix(daemon): keep CLI discovery out of custom-profile convergence (MUL-5439)

Third-round review P1: discovery could mask a concurrent profile disable.

The discovery path registered through registerRuntimesForWorkspaceBatch, which
fetches the workspace's custom runtime profiles and returns their content
signature, and the additive merge cached that signature. So if a user disabled a
custom profile at the same moment a newly installed CLI was discovered, the merge
correctly kept the disabled profile's runtime ID (it must not delete unmentioned
runtimes) while recording the POST-disable signature. refreshWorkspaceRuntimeProfiles
short-circuits on a matching signature, so the drift path then saw "already
converged" and the disabled runtime stayed tracked and heartbeating forever.

Discovery is now strictly built-ins only:

- registerBuiltinRuntimesForWorkspace posts a builtins-only register request. It
  never calls appendProfileRuntimes, so discovery cannot observe the profile set
  at all and there is no signature to cache.
- mergeRegisterResponseInPlace becomes mergeBuiltinRegisterResponse: it ignores
  any entry carrying a ProfileID (invariant guard — only the drift path may
  introduce a custom runtime), and neither reads nor writes profileSetSig.
- Custom profile add/edit/disable remains owned exclusively by
  refreshWorkspaceRuntimeProfiles.

Regression tests: a profile disabled concurrently with a new CLI install is
still converged away by the drift path (this test reproduces the reviewer's
exact failure — "disabled custom runtime rt-2 remained tracked" — when the old
signature write is replayed); the merge ignores profile-bearing entries; the
merge leaves profileSetSig untouched. The profile-fetch-failure test now also
asserts the signature is unchanged rather than merely non-empty.

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-29 15:08:56 +08:00
Multica Eve
d4dac0e77c perf(agents): index-backed latest-terminal lookup in task snapshot (MUL-5436) (#6085)
ListWorkspaceAgentTaskSnapshot took each agent's latest completed/failed
task with a workspace-wide DISTINCT ON, so every presence load read and
sorted the workspace's whole terminal history. Neither existing index
matches that shape: (agent_id, status) has no completed_at, and migration
231's (completed_at) partial index is completed_at-first for the Usage
rollups.

Replace the outcome half with a per-agent JOIN LATERAL Top-1 and add a
partial index on (agent_id, completed_at DESC NULLS LAST, created_at DESC,
id DESC) WHERE status IN ('completed','failed'). On a 40-agent workspace
with 200k terminal rows this goes from 6631 shared buffers / 48.3 ms to
162 buffers / 0.1 ms, with an identical row set.

The (created_at, id) tie-break also makes the pick deterministic when
completed_at ties or is NULL — completed_at DESC alone left the winner up
to the plan.

Report #6075 asked to delete the outcome half as dead code, but PR #2608
made the Squad hover card (AgentLivePeekCard) read those rows for its
"last activity" line, so removing them would be a product regression for
shipped desktop builds. The response contract is unchanged here; splitting
the outcome into a lazy endpoint stays follow-up work.

Also tighten pickLatestTerminal to completed/failed only, matching the
snapshot's filter — it accepted cancelled, which the endpoint never
returns and which would have masked an agent's last real outcome.

Co-authored-by: Eve <eve@multica-ai.local>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-29 14:28:44 +08:00
苗大
6d98b5eeb3 fix(editor): re-sign cross-origin attachment URLs (#6029)
Treat absolute cross-origin attachment API URLs as requiring an authenticated metadata refresh, so draft image previews load on split-origin self-hosted deployments backed by presign-capable or CloudFront-signed storage.

Same-origin URLs (relative or absolute) and external image URLs keep their existing no-refetch path.

Fixes #6049
2026-07-29 14:19:25 +08:00
Bohan Jiang
cc5ee169f0 fix(agents): allow agent owners to manage their own agent's env (MUL-5438) (#6080)
* fix(agents): let agent owners manage their own agent's env (MUL-5438)

GET/PUT /api/agents/{id}/env admitted only workspace owner/admin, which
made env the one endpoint in the agent permission model that ignored
agent ownership: canManageAgent already lets the owner update/archive,
and canViewAgentSecrets already lets the owner read mcp_config. The
asymmetry was worst on create — POST /api/agents accepts custom_env from
any member and stores that member as owner_id — so a member could write
secrets into their own agent and then never read or rotate them, with
UpdateAgent rejecting custom_env outright and no workaround left.

authorizeAgentEnv now admits a workspace owner/admin OR the agent's own
human owner. The agent-actor rejection still runs first and unchanged:
an agent process is denied even when its backing human owns the target
agent. The owner comparison uses member.UserID rather than the raw
X-User-ID header because agent.owner_id is nullable and uuidToString
renders NULL as "", and canManageAgentEnv rejects an empty owner from
the other side too.

The web Environment tab is gated on the same rule via the existing
canEdit decision, so it stops offering a "Reveal & edit" action that is
a guaranteed 403. The server remains the boundary.

Closes #6076

Fixes: https://github.com/multica-ai/multica/issues/6076
Co-authored-by: multica-agent <github@multica.ai>

* docs(agents): correct stale "owner/admin only" env permission wording

Follow-up to the MUL-5438 permission change: several comments and the
published docs still described the env endpoints as workspace
owner/admin only, which now contradicts the code.

- router.go / agent.go / types/agent.ts: the three sites flagged in
  review.
- agents-create.mdx (en/zh/ja/ko): the user-facing callout said reading
  values requires a workspace owner or admin. It now names the agent's
  own owner first, and spells out that the agent-actor denial holds even
  for an agent the same human owns.
- daemon.go / middleware/auth.go: these called the env endpoints
  "owner-only" as shorthand for "reject agent actors". That property is
  unchanged, but "human-only" is what they actually mean now.

Comments and docs only — no behavior change.

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-29 14:01:09 +08:00
Bohan Jiang
2e9a3d0119 fix(dashboard): stop leaking private agents from the per-agent rollups (MUL-5409) (#6051)
* fix(dashboard): stop leaking private agents from the per-agent rollups (MUL-5409)

Three per-agent dashboard endpoints authorized on workspace membership alone
and returned a bare agent_id for every agent in the workspace:

  GET /api/dashboard/usage/by-agent
  GET /api/dashboard/agent-runtime
  GET /api/dashboard/failures/by-agent

That told a plain member which private agents exist, how much they spend, how
long they run and what they fail on. The client already collapsed those rows,
but client-side filtering is decoration — one curl bypasses it.

Server: rows for agents the caller may not view are now folded onto a
`__restricted_agents__` sentinel before serialization, via one shared helper.
Folded, not dropped: each of these responses is the per-agent half of a pair
whose other half (usage/daily, runtime/daily, failures/daily) is workspace-
scoped and unfiltered, so dropping rows would make the per-agent breakdown stop
adding up to the KPIs rendered beside it. The bucket keeps its provider/model
and failure_reason dimensions — both are derivable by subtraction from the
workspace-level series anyway, and the client needs them to price the bucket and
compute its failure rate.

Owner/admin and agent actors short-circuit before any extra query, so the
governance view is unchanged. Hard-deleted agents are deliberately excluded from
the fold — they have no visibility left to protect and keep their own bucket.

Client: fixes the mislabelling that shipped with this. A live private agent was
folded into a row labelled "Deleted agents" with a bin icon, and counted into
the card's "· N deleted" caption — telling the user N agents were deleted when
they are alive and still running. The restricted bucket is now its own row with
neutral copy, keeps its real Time / Tasks values, and counts as neither an agent
nor a deletion in the caption.

Tests: handler regression coverage proving a plain member's response contains no
private agent UUID while every aggregate still sums to the privileged view's
total, plus view coverage for the label and caption.

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

* fix(dashboard): fold hidden system agent carriers into the restricted bucket (MUL-5409)

Review follow-up. The first pass built the restricted set from ListAllAgents,
which filters `kind = 'user'` — so it missed the hidden `kind = 'system'`
execution carriers behind agent-builder sessions.

Those carriers run real tasks and book real usage, and all three rollups
aggregate over agent_task_queue / task_usage with no kind filter of their own.
No list endpoint returns them either (ListAgents / ListAllAgents both filter on
kind), so no client can resolve one to a name. Net effect: the exact two bugs
this PR exists to fix, still live — a bare UUID exposing one member's builder
session (with its spend and failure profile) to every other member, and, once
the agent list loads, a running agent folded into the client's "Deleted agents"
row and counted as a deletion.

restrictedAgentIDs now reads a new ListAllAgentsAnyKind and restricts every
non-user-kind agent for EVERYONE, workspace owner included — nobody can name
one, so a bare UUID row is wrong for every viewer, not just plain members. User
agents keep the per-viewer visibility rule. The invocation-target lookup is
skipped for actors that rule can never restrict (agent actors, owner/admin), so
the added cost is one indexed list query.

Because the bucket now also carries carriers that are nobody's "restricted"
agents, its copy drops to the neutral "Other agents" — the same wording the
Errors card already uses for its equivalent row, in all four locales.

Adds a regression test seeding a kind=system private carrier with tasks and
usage: no endpoint may return its UUID to either the plain member OR the
workspace owner who owns it, a bucket must be present to carry its rows, and
every metric delta (tokens, seconds, tasks, failures, runs) must equal its exact
contribution. Verified to fail on all three endpoints for both viewers with the
kind-filtered query restored.

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-29 13:08:48 +08:00
Multica Eve
cf4114cd5d MUL-5396: validate agent concurrency limits (#6034)
* fix(agent): validate concurrency limits

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

* fix(agent): harden concurrency duplication

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-29 12:47:14 +08:00
Naiyuan Qing
ba17fcf46a fix(editor): open mention and slash pickers only for typed triggers (MUL-5429) (#6072)
* fix(editor): stop pasted @ text from hijacking Enter and Escape (MUL-5429)

Pasting a line containing `@` into the create-issue composer left an empty
mention picker open that swallowed Enter, and Escape then closed the whole
dialog instead of just the picker.

Two independent causes:

1. The Tiptap suggestion plugin re-runs findSuggestionMatch on EVERY
   transaction, including the one that commits a paste, so pasted text
   containing `@` opened the picker. With `allowSpaces` the match runs from
   the `@` to the end of the line, so the entire pasted tail became one query
   that matched nothing — and the empty popup deliberately captures Enter.
   Gate the mention picker with `shouldShow`: skip paste/drop transactions,
   and skip queries longer than any real mention target (60 chars). The
   empty-list Enter capture is intentional and is left alone; the fix stops
   the picker opening instead.

2. ProseMirror calls preventDefault() for a handled key but never stops
   propagation, and Base UI's dismiss layer listens for Escape on `document`
   in the bubble phase without consulting defaultPrevented. So the Escape
   that closed the picker also closed the host dialog. Stop propagation in
   the shared suggestion popup handler — the only layer that knows a picker
   is open — which fixes every host at once.

The slash pickers get the same paste guard so a pasted path no longer opens
the command menu; they were never affected by the Enter capture.

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

* fix(editor): open mention and slash pickers only for typed triggers (MUL-5429)

Pasting a line containing `@` opened an empty mention picker that swallowed
Enter. The first attempt at this fix aimed at the wrong target and did not
work in the product; this replaces it.

The picker also opened with no paste involved at all: placing the caret at the
end of a line that already contained `@` was enough. Tiptap's Suggestion plugin
is state-derived, not event-driven — it re-runs findSuggestionMatch on EVERY
transaction and asks whether the text before the cursor looks like a trigger,
never whether the user just typed one. Pasted, dropped, undone and
server-loaded text produce an identical document, so it cannot tell them apart.
With allowSpaces the match then runs from the `@` to the end of the line, so
the whole pasted tail became one query that matched nothing, and the empty
popup deliberately captures Enter.

That is upstream's known, unfixed design: ueberdosis/tiptap#4183 (open since
2023, labelled `complexity: hard`, reopened in January after `shouldShow`
proved insufficient) and #7371. Upstream's position is that applications supply
the missing provenance themselves. `shouldShow` alone cannot: it receives the
transaction without `prev.active`, while `allow` receives `prev.active` without
the transaction, so no transaction-inspecting predicate can express "only on
the transaction that opened the picker".

Add a trigger-arming plugin instead. `handleTextInput` is the one ProseMirror
hook that fires for real keyboard and IME input only — paste goes through
handlePaste/doPaste and drop through handleDrop, neither of which reaches it.
Typing a trigger character arms its document position; the pickers' shouldShow
opens only for a match anchored there; a deliberate caret move, or losing the
trigger character, disarms it. This is the same signal Tiptap's own InputRules
run on, which is why typing `- ` makes a list but pasting it does not.
Suggestion is the one Tiptap feature that does not use it.

Also stamp the paste metas markdownPaste was dropping. ProseMirror's doPaste
returns early once a handlePaste prop claims the event, and markdownPaste is a
catch-all that claims nearly every paste, so the transaction that commits a
paste carried no `paste` / `uiEvent` mark. That is why the previous fix never
fired in the product, and it independently broke issueIdentifierAutolink, which
reads exactly that mark: pasting `See MUL-2 now` autolinked nothing, because
the unmarked transaction sent it down the typing path that only inspects the
token before the caret.

Both features' paste tests passed throughout because they hand-built
transactions carrying a meta the real paste path never produces. Every paste
case now fires a real paste event, and typing is routed through
handleTextInput the way readDOMChange does, so a test cannot be green while the
product is broken.

The Escape containment from the previous attempt is unrelated to all of this
and is kept as is.

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

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-29 10:43:21 +08:00
Naiyuan Qing
831ef926e1 fix(editor): limit paste-as-file to chat (#6043)
#6019 opted four composers into converting an over-threshold plain-text
paste into a `pasted-text.txt` attachment. Only chat should: a wall of
text there is context handed to an agent for one turn, and the body is
not what anyone reads. In an issue comment the paste IS prose a human
reader is expected to see in the thread, so hiding it behind an
attachment chip makes the thread worse, not better.

So the three comment surfaces — new comment, reply, comment edit — stop
passing `pasteAsFileThreshold`. Nothing else changes: the extension, the
threshold constant and the failed-paste recovery all stay, because the
prop was always opt-in per editor and chat still uses every part of it.

The recovery test moves from comment-composers to
use-coordinated-uploads. Comments can no longer produce a paste-as-file
upload at all, so hosting the test there would pin a path that cannot
happen; the hook is where the contract actually lives, since the upload
outlives its mount and no editor can own the failure.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-28 18:03:55 +08:00
Naiyuan Qing
18adb20c14 MUL-5391: unify upload placeholder UI (#6025)
* fix(editor): an in-flight upload placeholder is never content, and is drawn once

Two defects with one cause: a placeholder for an upload in progress was both
serialised into the draft body and drawn a second time as a chip.

The document IS the persisted draft (getMarkdown -> setDraft), so serialising
an in-flight node turns it into text that outlives the upload:

  - fileCard emitted `!file[x.pdf]()`. Its own tokenizer cannot parse an empty
    href back, so the line survived reopen as dead literal text, sat next to
    the real link the write-back appended, and shipped with the comment.
  - image emitted its process-local `blob:` URL, which ContentEditor then
    scrubbed back out with a regex on every serialise.

Both renderMarkdown implementations now emit nothing while `attrs.uploading`
is set (or no URL exists). A node becomes content the moment it holds a real
URL and never before, which is strictly stronger than scrubbing after the
fact — so BLOB_IMAGE_RE / stripBlobUrls are deleted rather than extended.

Separately, ComposerUploadChips rendered every non-`uploaded` entry, including
ones whose placeholder node is right there in the editor. Every upload started
from a live mount inserts a node first (uploadAndInsertFile is the uploader's
only caller), so those chips were the same upload drawn twice, in two visual
languages, shifting layout as they appeared and vanished. useCoordinatedUploads
now exposes `orphanUploads` — the entries inherited from the persisted draft,
whose originating mount is gone and whose node died with it. That is the case
the chip strip was introduced for, and now the only one it covers.

`getMarkdown()` deliberately stays untrimmed (see its safety-net test); only
its stripBlobUrls wrapper is gone.

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

* fix(editor): keep a failed upload visible after the chip/node split

Self-review catch on the previous commit: suppressing the chip for every
upload this mount started also suppressed it for FAILED ones. The document
cannot stand in for those — uploadAndInsertFile removes the placeholder node
on failure — so the outcome was left to a toast that has already gone.

The rule is not "started here" but "the document is showing it", and the
document only ever shows a live placeholder: still `uploading` AND started by
this mount. `failed` / `interrupted` always get a chip, `uploaded` never does
(the editor and AttachmentList render those), which also makes an
`orphanUploads.length` gate mean what the call sites assume.

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

* fix(editor): keep the chip when the user deletes a running upload's placeholder

Code-review catch: "started by this mount" is necessary but not sufficient for
"the document is showing it". Cmd+Z right after a paste removes the placeholder
node while the upload keeps running — and gate.isBlocked keeps blocking send on
the store entry regardless of the node — so the previous filter left a dead send
button with nothing on screen explaining it.

The filter now also consults editorGate.uploading, which is the document's own
answer to "am I showing a placeholder right now" (sourced from the uploading-node
scan via onUploadingChange). Started-here AND still shown is what suppresses a
chip; either half failing brings it back.

Also drops a stale stripBlobUrls reference from the use-upload-gate docstring —
that helper no longer exists.

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

* fix(editor): a failed upload leaves nothing behind

The failure chip carried no information the toast had not already given at the
moment it happened, and it could not act on it: the bytes were never persisted,
so there is nothing to retry, and the file is still on disk to re-attach. Its
only affordance was a dismiss ✕.

It cost more than that. The entry lives in the persisted draft, so it survived
reload and reopen until dismissed by hand — and `isMeaningful` counts uploads,
so a single flaky request kept an otherwise-empty draft alive for the full
30-day TTL. Uploading again did not clear it either: a new upload is a new
clientUploadId.

Failures now remove their placeholder outright instead of marking it. Both
failure paths (size check, coordinator settle) collapse into that one rule,
which also folds the paste-as-file recovery into the shared branch rather than
duplicating it.

`interrupted` keeps its chip: it is discovered a session later, when the user
no longer remembers attaching anything. `orphanUploads` still handles `failed`
because an older client may have persisted one.

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

* refactor(editor): one upload, one node, from start to finish

The chip strip existed because the document could not answer for an upload it
was not showing. Give it that ability and the strip has no reason to exist.

Three changes make one model:

- ONE IDENTITY. The node's `uploadId` and the draft's `clientUploadId` were
  two independently minted random values, because the node is inserted before
  the handler that created the draft record runs. `uploadAndInsertFile` now
  mints the id up front and hands it to the uploader, which adopts it. Asking
  "is this upload in the document" becomes a lookup instead of an inference.

- REBUILD ON MOUNT. A placeholder is never serialised (it is not content), so
  it dies with the document that drew it and a reopened composer showed no
  trace of an upload still running. The draft record is enough to draw it
  again. Once per id per mount: a placeholder the user deleted mid-upload
  stays deleted (MUL-5181), and the guard is what stops the next store write
  from undoing that. Skipped entirely while chat pins its document to another
  draft — `uploads` follows the selected key, the document does not.

- SETTLE IN PLACE. The write-back replaces the placeholder where the user last
  saw it instead of appending the link at the end. A card promotes to an image
  when that is what arrived; the rebuild path only ever has a filename, so it
  cannot know in advance.

With that, the chips are deleted outright, along with `orphanUploads` and the
three-condition rule that approximated all of the above. `interrupted` goes
too: nothing could act on it, no surface rendered it after this change, and
`isMeaningful` counted it — one dead record kept an empty draft alive for the
full TTL. The attachment's absence from the body is the signal to re-attach.

SubmitButton's `busy` now spins rather than only greying out, so an upload
with no other on-screen trace (a composer still rebuilding, a placeholder the
user deleted) does not read as a dead control.

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

* fix(editor): whoever draws a placeholder registers it, not whoever finds it

Review catch on the rebuild effect. An upload started by the current mount had
its node drawn synchronously by uploadAndInsertFile, but its id only entered
`rebuiltUploadIdsRef` once the effect ran and happened to find that node. In
between, a delete (Cmd+Z right after a paste) left the effect looking at an
unmarked `uploading` record with no node — so it drew a second one, undoing a
removal MUL-5181 says must stick, and letting the settle land an attachment
the user had taken out.

The id is now registered where it is minted: an id handed into handleUpload
means the editor already drew the node. The window is sub-frame and needs a
keystroke inside one render pass, but "whoever draws it registers it" is a
rule, where "the effect will notice in time" was a race.

The composer mocks called `onUploadFile(file)` with no id, so they were not
exercising the one-id contract at all — every mount-started upload looked
inherited to the hook. They now mint and pass one like the real handle does,
which is what lets the new regression test see the difference.

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-28 17:08:09 +08:00
Multica Eve
81797b5a03 feat(agents): thinking level + Codex speed in the create flow (MUL-5390) (#6027)
* feat(agents): expose thinking level and Codex speed in the create flow (MUL-5390)

Creating an agent could only set runtime + model, so a Codex agent that
needed Fast had to be created and then fixed in settings — and duplicating
one silently dropped both overrides. The create API and the TS contract
already carried `thinking_level` / `service_tier`; only the creation
surface did not.

- AgentDraft carries thinkingLevel / serviceTier, rendered in the
  Execution section through the same ThinkingSettingField /
  ServiceTierSettingField the settings page uses. They stay fail-closed:
  a field appears only when the exact selected model's live catalog on an
  online runtime advertises the capability, so no value can be sent that
  the daemon would refuse.
- Payload and duplicate-draft assembly move into pure functions
  (buildCreateAgentRequest / buildDuplicateDraft); empty overrides are
  omitted instead of sent as "".
- Dependency cleanup: a runtime change clears model + thinking + speed, a
  model change clears the two per-model overrides. Applies to the plain
  form, the AI-builder setup screen, a committed builder runtime rebind,
  and a builder draft that moves the model.
- Duplicate now follows the rule `multica agent copy` already enforces:
  same runtime copies model / thinking / speed, a forced fallback to
  another runtime clears all three (it previously kept a model the target
  runtime may not serve) and says so. custom_args and concurrency are
  runtime-independent and still copied.
- Settings page: changing the model no longer leaves an orphan override.
  Only what the authoritative catalog says the new model does not support
  is cleared, and it travels with the model in one request. An unknown
  catalog (offline runtime, discovery in flight or failed), an empty
  "runtime default" model or a model missing from the catalog leaves the
  stored values untouched instead of deleting a value the daemon would
  have honoured.
- Restores the 255-rune description limit plus counter that the studio
  lost relative to the old dialog, and gates create on it since a
  duplicate or builder draft can seed a longer value programmatically.
- zh-Hans / en / ja / ko strings, including a fuller duplicate notice
  (MCP servers and machine-local runtime config are not copied either).

Out of scope, tracked separately: max_concurrent_tasks bounds on the API,
from-template parity (its UI is unreachable on main), and copying skill
enabled state / runtime-skill overrides.

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

* fix(agents): do not seed a duplicate draft before runtimes resolve (MUL-5390)

On a cold start — a direct ?duplicate=<id> link or a refresh — the agent list
can resolve while the runtime list is still pending. The one-shot seeding
effect ran anyway, so buildDuplicateDraft judged the source runtime
unavailable against an empty list and permanently cleared the model /
thinking_level / service_tier a same-runtime copy must keep, plus showed the
fallback notice. Re-running on every runtime change would instead overwrite
edits the user already made, so the gate is on the query being decidable.

Seeding now lives in useDuplicateDraftSeed and waits for the runtime query to
resolve or fail; a failure still seeds, since an unconfirmable runtime makes
the fallback correct. Verified the new test fails without the gate.

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-28 17:03:41 +08:00
Multica Eve
aa110aac9c MUL-5088: import repositories from GitHub App (#5896)
* feat(github): import repositories from app installations

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

* fix(github): address repository import review nits

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

---------

Co-authored-by: Eve <eve@multica-ai.local>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-28 16:19:35 +08:00
Naiyuan Qing
420ffe7dbc feat(diagnostics): capture the JS stack of a hung renderer (MUL-5345) (#6026)
* feat(diagnostics): capture the JS stack of a hung renderer (MUL-5345)

Route attribution shipped in 0.4.12 and did its job: hard hangs are no longer
scattered, they cluster on one page (10 of 12 hangs, 8 distinct users, spread
over 16 hours). It also hit its ceiling there. That page hosts three modes on a
single route and the mode lives in component state, so the route field cannot
say which of them froze — and a page name was never going to name the function
either. Two rounds of code-reading produced two hypotheses and both were wrong,
and one manual repro attempt covered a path the telemetry never pointed at.
Naming the code requires reading it off the stuck thread.

When the renderer hangs, the main process attaches the DevTools protocol and
asks for the stack. The channel is warmed while the renderer is healthy because
a command sent after the thread is stuck is never dispatched — measured on the
pinned Electron 39.8.7, where a post-hang attach returned nothing in 5s while a
pause on a warm channel 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; the ordering drift between cold phases exceeded the effect).

That channel is the reason this ships behind a fail-closed server flag rather
than on by default. `desktop_hang_stack_capture` rides the existing
`/api/config` feature flags; main starts off, only an explicit `true` enables
it, and revoking it detaches the channels rather than merely skipping the next
capture. Main cannot read config itself, so the renderer forwards the one bit —
which means a config that never arrives also lands on off.

Privacy is unchanged in kind from `$exception`: four scalar fields per frame,
`scopeChain` and `this` dropped so no handle can be dereferenced into user
data, script URLs reduced to their bundle-relative tail, and a four-verb CDP
allowlist that a source-level test pins to a single callsite. Resume is
unconditional — a capture must never turn a recoverable hang into a permanent
one.

Delivery is fixed alongside, because a stack that cannot be sent is not worth
capturing: `freeze:get-last` no longer deletes on read, the report goes out
with `send_instantly`, and the breadcrumb is retired only after a grace window,
so a second hang inside that window leaves the file for the next boot instead
of taking the report with it (the MUL-4115 failure mode).

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

* fix(diagnostics): close the kill switch, egress and multi-window gaps (MUL-5345)

Three review findings on #6026, all in failure paths the tests didn't reach.

The kill switch didn't reliably revoke. `coolDebuggerChannel` only detached
after `Debugger.disable` resolved, so a failed disable left the channel
attached — the exact state the switch exists to exit. Disable is a courtesy to
the renderer; detach is the contract, so it now runs in `finally`. Warming had
the mirror bug: an attach we made and could not enable returned false while
leaving the channel open, stranding a debugger on a renderer nothing tracks.
That attach is rolled back now, and only that one — a channel someone else owns
(DevTools) is left alone.

Stack frames were sanitized at capture and then forwarded verbatim at egress.
Between those two points they cross an on-disk breadcrumb that `readFreezeBreadcrumb`
barely validates, by design: it only has to survive version skew. So "sanitized
once" was not a property the flush side could rely on — an older build, a
corrupt file or a future writer could put a `scopeChain` handle or an absolute
install path in there and it would ship. Both ends now rebuild frames through
one shared whitelist, which also makes them impossible to drift apart. The url
reduction is idempotent so re-running it costs nothing.

The control flag was global, and that does not survive multiple windows. Every
renderer publishes `false` before its own config lands, so a window opened
while capture was on either never warmed (the global value never changed, so
nothing warmed the new webContents) or cooled every other window on its way up.
State is per renderer now; they converge on the same value because they read
the same config, but each on its own schedule.

Regression tests for each: detach after a throwing disable and rollback after a
throwing enable, a frame carrying `scopeChain` / `this` / an absolute path
reaching the flush side, and a second window warming while the first is already
on without revoking it.

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-28 16:05:32 +08:00
Bohan Jiang
ce45b8d353 feat(usage): rank the Errors offender list by failures or rate (MUL-5389) (#6023)
* feat(usage): rank the Errors offender list by failures or rate (MUL-5389)

The Top offenders list ranked agents by absolute failure count and sized
its bars the same way, while the loudest number on each row was the
failure rate. The three never agreed: the worst-rate agent could sit near
the bottom of the list with one of the shortest bars, and the busiest
agent was pinned to the top with a full bar regardless of how healthy it
was.

Adopt the leaderboard's contract — sort metric, bar length and the
emphasised column move together:

- A Failures / Rate segmented control above the list. Bars are scaled to
  whichever metric is active, so a bar can no longer imply a number it is
  not measuring.
- Under Rate, agents with fewer than MIN_RATE_SAMPLE terminal runs sort
  after every agent with enough runs. One run that failed is a 100% rate
  and would otherwise win outright. Those rows still render, and say why
  their rate is muted on hover — dropping them would stop the list
  reconciling with the workspace failure count above it.
- `4 / 10 · 40%` becomes labelled Failed / Runs / Rate columns.
- The bar is stacked by failure class and the dominant-class badge is
  gone. Colour now means the whole composition rather than repeating the
  badge: an agent failing one way is a solid block, one failing five ways
  is visibly striped. The bar carries that split as its accessible name.
- `By class` becomes `Failure mix · N`, so the section states its own
  denominator instead of sitting under a rate over a different one.

The failure rollups are unchanged; per-agent per-class counts were
already on the wire and were being discarded after picking a top class.

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

* fix(usage): announce the segmented control's active option, refresh a stale comment

Review follow-ups on #6023.

`Segmented` expressed the active option only as a colour swap, so a screen
reader could not tell which metric the leaderboard or the offender list was
ranked by. Each option now carries `aria-pressed`, and the group carries a
required `label` — a naked group of toggle buttons announces "Rate, pressed"
without ever saying what it ranks. Toggle buttons rather than a radiogroup:
a radiogroup owes the user arrow-key roving focus, and these are ordinary
tab stops everywhere they appear.

`anonymizeUnresolvedAgentRows`' comment still argued that merging aggregated
rows would lose the class breakdown, which stopped being true once
AgentFailureRow started carrying the full per-class split. The reason to
rewrite raw rows is now that the bucket reaches `aggregateAgentFailures` as
an ordinary agent_id, so its totals, rate, class split and rank all come from
one code path instead of a hand-rolled second copy at the merge site.

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 16:03:35 +08:00
Bohan Jiang
a874dc8066 feat(usage): cap the leaderboard at the top 10 agents behind a Show all toggle (MUL-5388) (#6020)
The leaderboard flattened every agent in the workspace, so a workspace
with dozens of them pushed the Errors card a full screen or more below
the fold. Rank the top 10 by the selected metric and collapse the tail
behind the same toggle the Errors card's top-offenders list already
uses; bar widths still scale to the global max so nothing re-scales on
expand. Rows become a real list so the truncated ranking exposes its
item boundaries to screen readers.

Co-authored-by: Bohan-J <bohan@devv.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-28 14:39:07 +08:00
Naiyuan Qing
995ec8fcf2 feat(editor): 粘贴超长文本自动转为文本附件 (#6019)
* feat(editor): convert over-threshold pastes into a text attachment

Pasting more than PASTE_AS_FILE_THRESHOLD (4000) characters into a
turn-based composer now uploads a `pasted-text.txt` attachment instead of
writing thousands of characters into the body. Opt-in per editor: chat,
new comment, reply and comment edit pass the threshold; issue and project
descriptions deliberately do not, because there a long paste IS the
content.

The synthesised File goes through the existing upload pipeline unchanged
(nothing in it inspects a File's origin), so draft persistence, status
chips and .txt preview all come for free.

Recovery is the part that needed real design. A dropped file survives a
failed upload on disk; this text exists nowhere else — it was never
written into the document and its source tab may be closed. Since uploads
outlive their mount (MUL-5181), the editor cannot own that recovery, so
useCoordinatedUploads does: deliverPastedTextBack mirrors
deliverFinishedUpload (live editor, else the persisted body) and is the
single responder, so the live and dead cases can neither both fire nor
both be skipped. The text is restored as markdown because markdown-paste
is what would have handled it had it never been converted.

A paste inside a code block stays inline — opening a fence is the request
to show the thing.

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

* test(editor): pin the paste handler order against the real extension array

Both markdownPaste and fileUpload register catch-all handlePaste handlers,
so which one ProseMirror consults first decides whether paste-as-file
works at all. That ordering is not visible in either file: Tiptap reverses
the extension array before collecting plugins (@tiptap/core `get
plugins()`), and neither extension sets a priority, so the array position
in createEditorExtensions is the whole contract.

The existing tests mounted the fileUpload extension alone and could not
see it. This one builds the production array via createEditorExtensions;
swapping the two entries makes it fail.

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-28 14:31:44 +08:00
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
b5f19adbab feat(composer): post-send caret policy per surface (afterAccepted) (#6014)
* feat(composer): post-send caret policy per surface (afterAccepted)

Where the caret goes after a send was hand-rolled per composer: chat blurred,
quick-create hand-wrote its own requestAnimationFrame focus, and comment/reply
did nothing at all. The mechanics are identical everywhere and easy to get
wrong (must run after the clear, must survive a dialog focus trap, must not
steal focus the user moved elsewhere mid-flight), so they now live once in the
shared send contract.

`useComposerSubmit` gains `afterAccepted` ("refocus" | "blur" | "none",
default "none") plus an optional `containerRef` that bounds focus reclaim to
the composer that sent. The mode may be a function so a surface can decide at
accept time — chat only reclaims focus when the commit actually scrubbed the
shared editor, never when a fire-and-forget send left another session's draft
on screen.

Per-surface policy:
- Chat refocuses (one file, three mount points: chat page, floating window,
  agent creation studio). Replaces the deliberate blur.
- Thread replies refocus — the user is mid-conversation.
- A top-level comment blurs, and IssueDetail reveals what was posted instead:
  submitComment now returns the created id, and the page scrolls to that row
  and flashes it with the same jumpToThread the inbox deep-link uses.
- Quick create's keep-open mode drops its hand-written rAF for the option.
- Inline comment edit and Create Issue keep "none": both close on save.

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

* refactor(composer): drop the scroll-to-posted-comment reveal

The top-level composer is sticky at the bottom of the timeline, so a comment
posted from it lands directly above the box and is almost always already on
screen — the scroll was a no-op and the flash re-announced something the user
had just deliberately done. The flash earns its keep for inbox deep-links,
where the user did not choose the landing spot.

`submitComment` goes back to returning a boolean; it only carried the created
id to feed the reveal. The composer still blurs after posting: the turn is
over, so the caret is dropped rather than kept.

The shared contract is untouched — `afterAccepted` never knew about comments,
which is why removing this costs nothing outside IssueDetail.

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

* docs(composer): drop stale references to the removed comment reveal

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

* fix(composer): bind the post-send caret policy to an actually-cleared editor

Review blocker: CommentInput passed a literal `afterAccepted: "blur"`, but its
stale-submit guard declines to clear when the user typed during the request.
Posting comment A on a slow connection while typing comment B therefore
dropped the caret out of B mid-sentence — the guard kept the text, and the
blur fired anyway.

Both issue composers now resolve the mode from a ref set only on the branch
that really wipes the editor, matching what ChatInput and quick-create already
do. ReplyInput gets the same treatment: refocusing a box the user is typing in
happens to be harmless, but leaving one surface on the unsafe shape invites the
next one to copy it.

The rule is now stated on the option itself: a surface whose `onAccepted` can
decline to clear must pass a function and resolve to "none" on those paths.

Both regressions are mutation-verified — reverting either binding fails the new
assertions.

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-28 13:15:43 +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
f1cb726d7c fix(usage): move the Errors card to the page bottom and rebalance its layout (#6003)
Two problems with the card as shipped, both visible on real data (283
failures across 28 agents):

1. It sat between the trend chart and the leaderboard. Spend is the headline
   of this page; failures are the follow-up question you ask after seeing who
   is spending. Moved below the leaderboard.

2. The two-column split put a 7-row list next to an unbounded one. In
   practice that was 7 rows of classes beside 28 rows of agents — roughly
   400px of content next to 1500px, so the left half was mostly whitespace
   and the card was taller than the rest of the page combined.

Rebalanced by stacking two full-width sections instead:

- Class breakdown is now a single 100%-stacked bar plus a legend, replacing
  seven individual progress bars. The question is "what is the mix", and one
  bar answers it directly instead of making the reader compare seven lengths
  and total them mentally. ~340px becomes ~70px.
- Offender rows fold onto one line, borrowing the leaderboard's grid shape
  (identity, bar, numbers) instead of stacking a full-width bar under each
  name. Halves the per-row height and lines the numbers into a scannable
  column.
- The list caps at 8 with a "Show all N" toggle. It is ranked by absolute
  failure count, so the tail is agents that failed once or twice — real, but
  not what anyone opens this card to see. The toggle label carries the full
  count, so the cap is never silent.
- The raw error-code list gets two columns on wide viewports now that the
  section has the full page width.

Card height on the screenshot's data drops from ~1500px to ~420px.

Co-authored-by: Bohan-J <bohan@devv.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-27 19:18:47 +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
Bohan Jiang
ceba14a227 fix(issues): MUL-5362 return to the source list after deleting an issue (#5997)
* fix(issues): return to the source list after deleting an issue

Deleting an issue from its detail page always pushed the workspace
Issues list, so opening an issue from My Issues (or a project list,
search, a pin, an agent panel) and deleting it dropped the user's
navigation context — GH #5995.

Go back instead. `useBackOrReplace` steps back when the platform
reports in-app history and replaces with a fallback path when there
is none, so a shared link opened cold or a new tab never steps off
the app. Web answers via the Navigation API, falling back to counting
its own pushes; desktop reads the active tab's virtual history.

`replace`, not `push`: the deleted issue's URL must not stay in
history for the back button to land on a 404.

The not-found "Back to Issues" button loses the same context, so it
moves to the same helper and its label becomes a plain "Back".

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

* fix(web): track history position, not push count, for canGoBack

The Navigation API fallback only ever counted pushes, so `pushes > 0`
did not mean the current entry still had an in-app page behind it.
Cold-open an issue, click Issues, press the browser's Back button, then
delete: the tracker still claimed history and `back()` would step off
Multica — the exact case the fallback exists to prevent.

Count depth instead: a push adds one, any traversal takes one away
(clamped at zero). Reaching the document's first entry requires
traversing back at least as many times as we pushed, so a positive
depth can never be claimed while sitting on it. A browser Forward is
now conservative — it reports no history where a step back would have
been fine — which costs a fallback navigation rather than an exit.

Also corrects the useBackOrReplace contract comment: stepping back
leaves the dead URL in forward history, so the guarantee is that it
never lands on the back stack, not that it leaves history entirely.

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

* fix(web): answer canGoBack from the browser alone, never from push counts

Review found the counter still lied, one level deeper: it counted
`router.push` calls, and a call is not a committed history entry. Next
drops a push to `replaceState` when the canonical URL is unchanged
(app-router.js, the `pendingPush && href !== canonicalUrl` branch), so
clicking a self-link — the breadcrumb on an issue detail page, a pin to
the issue you are on — incremented depth with no entry behind it, and a
delete from there could still step off the app.

Fixing the count needs per-entry markers, which means depending on both
React effect ordering (our provider's effect runs before app-router's
history commit) and Next's own preserveCustomHistoryState behaviour.
Two rounds of review have now found holes in hand-rolled history
tracking; a third layer of it is not the way to buy this guarantee.

So stop deriving. `canGoBack` is the Navigation API's answer or `false`.
Browsers without it take the fallback path, which is exactly what they
did before any of this existed — nobody regresses, and the "wrong true
walks the user out of the app" failure is now unreachable by
construction.

Drops the tracker, the popstate wiring and the push wrapper: the
`multica:navigate` bridge returns to its original shape.

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:39:25 +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
Naiyuan Qing
c7fe10e549 fix(issues): move the thread quick-jump rail to the right edge (MUL-4522) (#5990)
The rail looks and behaves like a scroll affordance, but sat on the far
left: on a wide screen it was hundreds of pixels from both the body text
and the scrollbar the pointer was already on, so every jump cost a full
sweep across the page.

Move it to the right edge, just inside the scrollbar. right-3 plus a
20px strip is the inset that holds in both scrollbar modes: it clears a
classic scrollbar's ~11px gutter, and lands exactly on the content
column's 32px padding when the gutter is 0 (overlay scrollbars), so it
covers neither the scrollbar nor body text. Ticks flush right and the
hover wave grows them inward; the preview card opens leftward. The find
bar steps inside the rail on desktop so it can't cover the top ticks.

No left/right preference setting: the evidence is one-sided, and a
toggle would mean maintaining and testing two layouts for a preference
nobody has argued for yet.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-27 15:50:36 +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
Multica Eve
a1bd3ca9b2 fix(editor): allow spaces in mention search (#5980)
Co-authored-by: Eve <eve@multica-ai.local>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-27 13:54:00 +08:00
Bohan Jiang
ef69c3d4a3 feat(projects): add search and max-height to the project picker (MUL-5344) (#5979)
* feat(projects): add search and max-height to the project picker (MUL-5344)

Migrate ProjectPicker off the bare DropdownMenu onto the shared
PropertyPicker (the same primitive assignee/label/status pickers use), so
the project dropdown now caps its height with a scrollable list and gains a
client-side search box. Search matches on title substring and pinyin, so
Chinese project names are reachable by latin input.

Preserves the full existing contract: controlled/uncontrolled open with the
Base UI open-latch normalization, the disabled read-only lock, the inline
hover/keyboard clear button, and every caller's custom trigger.

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

* fix(pickers): reset picker search state on programmatic close (MUL-5344)

PropertyPicker cleared its search query inside the popover's open-change
handler. Every picker closes itself after a selection by calling its own
setOpen(false), which flips the `open` prop directly and never routes
through that handler — so the stale query survived into the next open and
kept the rest of the list filtered out.

Move the reset onto the open -> closed transition so it covers programmatic
closes too. This also fixes the same latent staleness in the assignee and
label pickers, which close on selection the same way.

Adds a regression test: search -> select -> reopen must show an empty input
and the full list.

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:52:22 +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
Naiyuan Qing
a964c5229d feat(editor): pan/zoom the image attachment preview (MUL-5316) (#5971)
* feat(editor): pan/zoom the image attachment preview (MUL-5316)

Image preview could only fit-to-window, so a screenshot of text opened
unreadably small with no way in. It now runs on the same pan/zoom canvas
the Mermaid viewer uses: fit on open, then wheel (cursor-anchored), drag,
pinch, double-click fit<->100%, +/-/0 and arrow keys, plus a toolbar with
zoom out / % / zoom in / fit / actual size / reset.

The canvas is generalised out of Mermaid rather than duplicated:
diagram-transform -> zoom-transform, use-diagram-canvas -> use-zoom-canvas,
the canvas CSS out of mermaid.css into zoom-canvas.css, and a shared
ZoomCanvas + ZoomControls that MermaidViewer now composes too (dropping its
hand-rolled toolbar and canvas markup). The five zoom labels move from
editor.mermaid.* to a shared editor.canvas.* in all four locales.

Two fixes the generalisation forced, both of which also improve Mermaid:

- MIN_SCALE floored computeFitScale at 25%, so content more than 4x the
  canvas could not be fitted at all. A 1600x8000 full-page screenshot needs
  ~10% and would have opened cropped — worse than the fit-only behaviour it
  replaces. The lower bound is now min(0.25, fitScale), threaded through
  clampTransform / zoomToAt / canZoomOut.
- The canvas measured its viewport with getBoundingClientRect while its
  container was mid scale-in animation, fitting against a viewport a few
  percent too small; ResizeObserver reports the untransformed layout box so
  it never fired to correct it. Measures offsetWidth/offsetHeight now.

Image-specific handling: natural size is read from the ref as well as
onLoad (a cached image is already complete before onLoad attaches), and an
image with no intrinsic size — an SVG with only a viewBox — keeps the old
letterboxed render with the zoom controls hidden instead of a blank canvas.
The canvas is focused on open so the keyboard controls work without a click
first, native image drag is disabled so it can't hijack the pan, and the
backdrop only closes on a click that actually lands on it.

Verified: pnpm typecheck, pnpm lint, pnpm test (3003 views tests, 50 in
attachment-preview-modal). The flex chain and the long-screenshot fit were
also checked in headless Chromium, which jsdom cannot measure.

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

* fix(ui): keep kbd keycaps readable inside tooltips

Upstream shadcn inverts its tooltip surface, so Kbd forced near-white text
inside tooltip-content. Our TooltipContent keeps the popover surface, which
made keycaps render white-on-white. Drop the inverted-surface overrides so
keycaps keep their regular muted colors, which read correctly on popover in
both themes.

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

* refactor(editor): drop the redundant reset zoom control, add shortcut tooltips

The reset toolbar button was a literal alias of fit (reset: fit) — it could
never do anything fit doesn't, so remove it along with the reset API, the
isFitted state that only served its disabled look, and the reset_view copy
in all four locales.

Replace the native title attribute on the remaining zoom controls with the
shared Base UI Tooltip + ShortcutKeycaps pattern (same as the editor bubble
menu), showing the matching keyboard hints: zoom out (-), zoom in (+), fit
to view (0).

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

---------

Co-authored-by: Walt <walt@multica.ai>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-27 13:27:58 +08:00
Rusty Raven
67d3952ee2 fix(transcript): auto-follow live task output in transcript dialog (#5932)
* fix(transcript): auto-follow live task output in transcript dialog (#5921)

Since the transcript event list moved to Virtuoso (#5733), a live task's
new events required manual scrolling to see — the dialog opened at the
top and never followed appended output.

Wire live-follow through Virtuoso's own primitives, per sort direction:

- Chronological (default): `followOutput` returns "smooth" while the
  task is live and the reader is at the bottom (Virtuoso's own atBottom
  tracking, with a forgiving 120px threshold). Scrolling up suspends the
  follow until the reader returns.
- Newest-first: growth is PREPENDS, which the firstItemIndex anchoring
  deliberately holds in place — so a reader parked at the top would
  silently stop seeing new rows. Track the top edge via
  `atTopStateChange` into a ref and snap back to index 0 when new events
  arrive while at the top. Readers who scrolled away are left in place.
- Opening a live chronological transcript now lands on the newest event
  (`initialTopMostItemIndex: LAST/end`, same pattern as the chat list);
  the per-listEpoch remount re-applies it after task/sort/filter
  changes. Completed tasks keep opening at the top.

Fixes #5921.

* fix(transcript): rework newest-first live follow as a user-intent latch (#5921)

The previous approach gated the newest-first follow on "is the viewport at
the top right now" — but firstItemIndex prepend anchoring moves the viewport
away from the top on every flush, so the signal broke itself: after the
first prepend the follow silently disengaged and never recovered.

Replace it with a pure latch controller (transcript-follow.ts):

- Disengage only on accumulated USER displacement (wheel/touch/key deltas,
  scrollbar drag) beyond the 120px edge zone; system displacement never
  counts, no matter how far it pushes the viewport.
- While following, non-user displacement is pinned back to the live end on
  the scroll event itself (Virtuoso's prepend compensation lands after React
  effects, so an effect-timed snap alone stays one flush behind).
- Enforcement is suppressed while the mouse is held (text-selection
  autoscroll) or mid-gesture; returning within the edge zone re-engages.
- Timeline segment clicks explicitly unlatch so navigation isn't pinned back.

Chronological followOutput switches "smooth" -> "auto": a still-animating
smooth scroll reads as "not at bottom" on the next flush and drops the
follow under rapid appends.

The latch decision table is unit-tested (transcript-follow.test.ts), and the
mechanism was validated end-to-end against react-virtuoso 4.18.7 in a
browser harness: follow-at-top, in-zone nudge + flush (no false disengage),
scroll-away with stable anchor, return-to-top re-engage, rapid flushes.

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

---------

Co-authored-by: Naiyuan Qing <145280634+NevilleQingNY@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-27 13:10:44 +08:00
Bohan Jiang
1869b1b5e0 test(issues): give table-view editor test 60s to survive CI CPU starvation (MUL-5326) (#5964)
Co-authored-by: Bohan-J <bohan@devv.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-27 03:12:50 +08:00