mirror of
https://github.com/multica-ai/multica.git
synced 2026-08-02 18:13:27 +02:00
agent/lambda/0b13282a
1565 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
a6a22a259b |
feat(issues): badge resolved threads on the comment rail's preview card (MUL-5543)
The quick-jump rail is the one place where a resolved thread looked exactly like an open one: the tick is the same, and the preview card showed only the title and body excerpt. Scanning the rail gave no way to tell "settled" from "still open" without jumping into the thread. Add a leading "Resolved" badge to the card, in the same `text-success` CheckCircle2 treatment CommentCard uses for its Resolution badge. The state leads so it is read before the content. The flag is derived with `deriveThreadResolution`, not taken from the `resolved-bar` item kind: that kind only covers root resolutions that are currently folded, so it would miss "Resolve thread with comment" (reply) resolutions and would flip off the moment a user expanded a folded thread. The card is invisible to screen readers, so the tick's accessible name carries the state too — "<title> (resolved)". Co-authored-by: multica-agent <github@multica.ai> |
||
|
|
b06c489413 |
fix(issues): stabilize the Table query identity so a workspace switch cannot churn its branch queries (MUL-5477) (#6178)
Switching workspaces onto a persisted Table + hierarchy surface pinned the renderer at ~150% CPU. Three reference-stability defects on that path, all in the window where the surface's own queries have not settled yet — which is exactly the window a workspace switch opens: - `tableQuerySpec` is built from 17 dependencies and two of them defaulted their un-settled data to a fresh `[]`, so every render produced a new-but-identical spec. Every consumer keys off that identity: the facet request, the status and group branch hooks, and the Table's `useQueries` branch list, which was rebuilt once per render for a query that had not changed. - Each rebuilt branch query carried `placeholderData: () => placeholder`. QueryObserver reuses a placeholder result only while that option compares equal by reference, so a fresh arrow per render forced the placeholder to be recomputed and the result re-derived every time. The fixes are the smallest ones that remove those edges rather than damp them: the two empty defaults become one module-level constant (the same pattern `useActorName` already uses for its own lists), the spec's identity is pinned to its content with TanStack's own `hashKey` so it agrees with how the same spec is hashed into a queryKey, and the placeholder is passed as the value it always was. `useMemo` rather than a render-phase ref write, so nothing mutates during render. This removes feedback edges on the reported path. It is NOT a confirmed root cause for the production hang: the loop has not been reproduced against the affected client, and acceptance is still a live check on that machine. Regression coverage closes a real gap. Production mounts the Table with `virtualizeRows` and hierarchy on, and no test covered that combination — every existing one replaces the virtualizer, because jsdom reports a zero-height viewport. The new test supplies the layout instead, so the real measuring virtualizer sits in the circuit, and asserts the table stops committing once the tree settles. That circuit is load-bearing: while writing the test, an unstable mock closure alone reproduced a sustained ~35 commits/s storm with no fetching and no DOM measurement, which is why the mocks there return hoisted references and why the convergence assertion needs the real virtualizer to mean anything. Co-authored-by: Claude Opus 5 <noreply@anthropic.com> Co-authored-by: multica-agent <github@multica.ai> |
||
|
|
a8c775e94b |
feat(skills): floating save pill with change summary + one skill icon everywhere (#6177)
* feat(skills): floating change-summary save pill on skill detail Replace the always-mounted docked save bar with a dirty-only floating pill matching the skills list batch toolbar: page-root anchored, with a summary of what changed (name, description, N files — renames counted once by matching files by id), discard/save actions, and a fade+slide-in entrance. Editor surfaces gain bottom padding so the last lines stay readable under the pill. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * refactor(skills): one icon for the skill entity everywhere (MUL-5443) Skills were drawn with four different icons: BookOpenText in the sidebar and desktop tab bar, BookOpen on the list page header and empty state, FileText for skill rows in an agent's Skills tab and the skill picker, and Sparkles on the new detail identity block — where it also already meant "imported origin" two lines below. `WORKSPACE_PAGES.skills.icon` already declares the icon name, and ROUTE_ICON_COMPONENTS already turns it into a component for the sidebar and tab bar. Derive a `SkillIcon` export from that pair instead of re-declaring the icon per call site, so the nav and every in-page surface cannot drift apart again. A test asserts SkillIcon is the same component the tab bar resolves for a /skills path. File-type icons inside a skill's file tree are untouched — a file in a skill is not a skill. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Co-authored-by: multica-agent <github@multica.ai> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> Co-authored-by: multica-agent <github@multica.ai> |
||
|
|
999e9f93c7 |
fix(codex): record file edit payload for patch_apply events (#6158)
* fix(redact): scrub secrets nested inside tool input maps and slices
InputMap only passed top-level string values through Text and documented
non-string values as "preserved as-is". Any secret one level down reached
the database and the WebSocket broadcast untouched:
flat -> [REDACTED ...] (scrubbed)
nested -> [map[diff:token=ghp_... path:a.go]] (leaked verbatim)
This is a prerequisite for recording structured file-edit payloads. Codex
reports an edit as changes[]{path, diff, content}, and the legacy protocol
reports a deletion as the whole outgoing file — so without this, deleting a
.env would persist its full contents in cleartext.
redactValue now walks the composite shapes json.Unmarshal produces, plus
[]string and map[string]string for argv-style inputs. Composites are copied
rather than scrubbed in place, because the caller keeps using the map it
passed in.
Nesting depth comes from daemon-supplied JSON, so the walk is bounded at 32
levels; a pathologically nested payload would otherwise recurse until the
stack blows. Hitting the bound yields a placeholder rather than the raw
value, keeping the fail-safe direction.
Verified: the five new tests each fail against the previous top-level-only
implementation and pass now; full ./pkg/redact suite green.
Co-authored-by: multica-agent <github@multica.ai>
* fix(codex): record file edit payload for patch_apply events
Both Codex protocol paths recorded a file edit as a bare call ID and set no
payload, so a run that edited six files left six blank, unexpandable rows in
the transcript. The same task on Claude or Grok showed a readable diff, and
the branch Codex pushed was the only surviving record of what it changed
(GH #6157).
The omission was specific to this one tool, not to the adapter: the
exec_command handlers directly above already captured command and output.
Both paths are fixed, since the protocol is sniffed at runtime. Their wire
shapes differ more than they appear, and the normalizer reconciles that:
- legacy patch_apply_begin/end carry map[path]FileChange, internally tagged
on `type`, where add/delete hold whole-file `content` and only update
holds a `unified_diff` plus `move_path`. There is no diff for every case,
so the normalized form keeps diff and content as alternatives.
- v2 fileChange items carry an ordered array of {path, kind, diff} where
`kind` is an object, not a string — reading it as a string silently
yields "" and loses the add/delete/update distinction.
- status spellings differ too: legacy is snake_case, v2 is camelCase and
adds inProgress. Both normalize onto one vocabulary, and a legacy event
predating `status` falls back to its `success` bool.
Legacy map iteration is sorted by path so a replayed event does not reshuffle
the file list.
Completion events now also produce a non-empty output (status, file count,
and any apply_patch stdout/stderr), because an empty output renders as an
unexpandable blank row just like a missing input.
Anything unrecognised — absent, wrongly typed, or malformed changes — returns
no payload, preserving exactly the previous degradation rather than risking
the transcript.
Total diff/content bytes are bounded at 64 KiB with UTF-8-safe truncation,
recording `truncated` and `original_bytes`; paths and kinds always survive,
since they are what a reviewer needs when the body is gone. The bound is
deliberately scoped to this new payload: other providers stream tool inputs
through unbounded, and clamping them here would silently truncate
transcripts that render correctly today. Unifying the limit at the
persistence boundary is left as a follow-up.
Verified: the new tests reproduce the reported symptom (Input:map[],
Output:"") against the previous call sites and pass now; ./pkg/agent and
./pkg/redact green, go vet and gofmt clean.
Co-authored-by: multica-agent <github@multica.ai>
* feat(transcript): render Codex multi-file patch payloads as diffs
The presenter identified an edit by input shape — a top-level file_path plus
old_string/new_string or content — which is Claude's and Grok's shape. Codex
records one patch_apply covering several files as changes[], so even with the
payload now populated it fell through to pretty JSON instead of a diff.
A new `patch` detail kind carries one entry per file, since collapsing them
into a single body would lose which change belongs where. Each file reuses the
existing single-file surfaces, so all bodies behave alike inside the
virtualized list.
Codex hands over a ready-made unified diff, so parseUnifiedDiff maps it onto
diff rows rather than recomputing one — there is no before/after pair to
compare, and reconstructing both sides from the diff just to diff them again
would be circular. Hunk headers become `gap` rows, which is what they denote:
skipped unchanged content.
A deletion renders as all-removals rather than as a whole-file write, because
the legacy protocol reports it as the outgoing file's content and a green
"+N" gutter would state the opposite of what happened.
The collapsed row needed its own fix: with no single path field, the summary
fell through the preference chain and came back empty. It now reads as the
first path plus "+N more".
Anything that is not this shape still falls back to pretty JSON, so a payload
this presenter does not understand stays readable.
Verified: 17 new tests (43 in the presenter suite) pass; repo typecheck and
lint clean. The one failing views test, layout/sidebar-resize, fails
identically on an untouched checkout.
Co-authored-by: multica-agent <github@multica.ai>
* fix(codex): route v2 add/delete payloads as content, not diff
Addresses review on #6158.
Upstream's format_file_change_diff only produces a unified diff for `update`.
For `add` and `delete` it returns the whole file's contents under the same
`diff` field, and for a moved `update` it appends a trailing
"\n\nMoved to: <path>" line:
FileChange::Add { content } => content.clone(),
FileChange::Delete { content } => content.clone(),
FileChange::Update { unified_diff, move_path } => ...
(codex-rs/app-server-protocol/src/protocol/item_builders.rs, rust-v0.145.0)
Recording that as a diff mislabels every line of an added or deleted file as
context, and actively inverts any line whose content begins with '+' or '-' —
so an added file containing "-minus lead" rendered as a deletion. The payload
is now routed by `kind` rather than by field name, and the "Moved to:"
sentence is stripped since move_path already carries the destination.
The previous v2 tests hid this by using a fixture the real protocol never
emits (an `add` carrying "@@ ... +package main"). They now use upstream's
shape, plus cases for delete, an empty add, and an add whose contents look
like diff headers.
Empty bodies are also kept on both paths: presence of the field, not its
non-emptiness, decides whether a body was reported, so an empty added file
renders as an empty body instead of "no content reported".
Verified: the new assertions fail against the previous normalizer — where an
`add` came through as {"diff": "package main\n"} — and pass now.
Co-authored-by: multica-agent <github@multica.ai>
* fix(transcript): stop treating header-like content lines as file headers
Addresses review on #6158.
parseUnifiedDiff matched "---" / "+++" / "diff --git" / "index " at any
position, so a changed line whose *content* starts with a dash or plus was
silently discarded:
parseUnifiedDiff("@@ -1 +1 @@\n--- old markdown\n+++ new markdown\n")
// before: [{ kind: "gap", ... }] — both changed lines gone
A removal of "-- old markdown" is spelled "--- old markdown" on the wire, so
this hit Markdown rules, embedded patches, and comment banners.
File headers only exist ahead of the first hunk, so they are only recognised
there; once inside a hunk every line is parsed strictly by its first
character.
Also localizes the multi-file summary count, which was hardcoded English and
so leaked into the zh-Hans / ja / ko transcript rows. The presenter owns no
React and no i18n by design, so the phrasing is injected by the caller rather
than imported here, keeping the module unit-testable in isolation; the English
form remains the fallback. The three Chinese/Japanese/Korean truncation
strings now use "..." to match the English source they translate.
Verified: both new parser assertions fail against the previous
strip-anywhere behaviour and pass now; 47 presenter tests green, repo
typecheck and lint clean.
Co-authored-by: multica-agent <github@multica.ai>
* fix(daemon): redact nested tool input before it leaves the daemon
Addresses review on #6158.
Recursive redaction ran only in the server's ingest handler. The daemon built
the new nested edit payload and sent msg.Input verbatim, so a daemon that
self-updated ahead of the server — or one talking to a server mid-rollout —
would ship whole-file edit contents to a peer that does not scrub nested
values yet. The legacy protocol reports a deletion as the whole outgoing file,
so that window covered a deleted .env in cleartext.
Ordering three commits inside one PR is not a deployment barrier, and daemon
and server upgrade independently. Deployment order is not a control we have,
so the sending side is now safe on its own; the server keeps redacting on
ingest as the second line of defence.
Scoped to Input, which is the field this PR newly fills with file contents.
Content and Output are plain strings already redacted server-side, and
changing their daemon-side handling would be unrelated to this fix.
Verified: the new daemon test asserts the nested token is masked in the
reported batch while the change metadata survives. It fails without this
change, reporting the full GITHUB_TOKEN= line on the wire, and passes with
it; ./internal/daemon green.
Co-authored-by: multica-agent <github@multica.ai>
* fix(transcript): correct the Chinese multi-file patch count semantics
Addresses review on #6158.
The summary is handed the number of files *beyond* the named one, but the
Chinese phrasing stated a total: "a.go 等 2 个文件" reads as two files including
a.go, so a three-file patch under-reported by one. English hides the
distinction ("+2 more"), which is why it survived the first pass.
Rewords zh-Hans to "另有 N 个文件". Japanese (他) and Korean (외) already read
as "besides", so their wording is unchanged.
Also renames the interpolation variable from `count` to `extra`, for two
reasons. i18next treats `count` as the plural selector — this very namespace
relies on that for events_one/events_other — so a plain number had no business
borrowing it. And the name is what a translator reads: `extra` cannot be
mistaken for a total the way `count` was.
Guards the whole bug class rather than just this string: a locale test asserts
every locale interpolates {{path}} and {{extra}} and never the reserved
{{count}}, and a presenter test pins that the injected number is the count of
additional files, not the total.
Verified: both new locale assertions fail against the reverted string and pass
now; rendering the real locale strings for a three-file patch yields "+2 more",
"另有 2 个文件", "他 2 件", "외 2개". 53 target tests pass, repo typecheck clean,
views lint back to its pre-existing 16 warnings and 0 errors.
Co-authored-by: multica-agent <github@multica.ai>
* fix(transcript): put the patch surface on the type scale
Addresses review on #6158.
The patch surface wrote text-[10px] / text-[11px] / text-[10px], copied from
the sibling transcript surfaces as they looked when this branch started. Since
then MUL-5451 (#6136) introduced a role-named type scale and migrated those
same siblings to text-micro, so these three call sites were the only remaining
arbitrary sizes — and the type-scale guard reports them precisely.
All three become text-micro. That matches the analogues they were copied from
now that those have moved: the FileWriteSurface line-count row, the
DiffDetailSurface header row, and the ToolDetailSurface body. It is also the
only correct target, since micro (11px) is the smallest step the scale defines
— there is nothing at 10px to map to.
Merges origin/main so the guard runs here rather than only in CI.
Verified: apps/web app/type-scale.test.ts 13/13 (it listed exactly these three
lines before), no `text-[` left in the file, repo typecheck clean, views lint
unchanged at 16 pre-existing warnings and 0 errors.
Co-authored-by: multica-agent <github@multica.ai>
* fix(codex): redact patch bodies before applying the size budget
Addresses review on #6158.
The adapter sized and truncated the normalized changes, and redaction only ran
later — in the daemon before sending, then again in the server on ingest. That
order loses secrets that straddle the budget.
The PEM rule needs both markers to match:
-----BEGIN[A-Z\s]*PRIVATE KEY-----.*?-----END[A-Z\s]*PRIVATE KEY-----
So a 70 KB private key whose BEGIN sits inside the first 64 KiB and whose END
falls past the cut stops matching once truncated. Neither later pass can
recognise what truncation already broke, so the marker and 64 KiB of key
material reach the database and the WebSocket broadcast. Measured on the
previous code:
stored bytes 65536 | BEGIN marker present | key body present | placeholder absent
Redaction now runs first, and the budget measures the redacted bodies — which
is also the honest measurement, since those are what actually gets stored and
redaction usually shrinks them (that key collapses to 23 bytes, so no trimming
is needed at all). `original_bytes` still reports the pre-redaction size so the
reader sees how large the real patch was. The daemon and server passes stay as
defence in depth; redaction is idempotent, so running three times is safe and
that is now asserted.
Note for callers: codexPatchInput no longer trims its argument in place, because
redaction copies first. Two existing tests were asserting on the caller's
original slice and had silently become vacuous; they now read the returned
payload, and one pins the no-mutation contract. The delete fixture in the
diff-vs-content routing test was also a credential-shaped string, which now
redacts — it is plain text so that test keeps testing routing.
Verified: the new boundary test fails on the previous order, reporting the
surviving BEGIN marker and key material, and passes now. go test ./pkg/agent
./pkg/redact ./internal/daemon green; execenv ByteIdentical green; go vet and
gofmt clean.
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: Eve <eve@multica-ai.local>
Co-authored-by: multica-agent <github@multica.ai>
|
||
|
|
9072cef12c |
Revert "MUL-5493: feat(chat): add a visible follow-up queue (#6133)" (#6171)
This reverts commit
|
||
|
|
b13657be71 |
MUL-5493: feat(chat): add a visible follow-up queue (#6133)
* feat(chat): add a visible follow-up queue Add a visible, manageable FIFO follow-up queue for Web and Desktop chat while preserving the existing per-session scheduler and backward-compatible pending-task response. * fix(chat): preserve queue after deferred cancellation --------- Co-authored-by: TeAmo <liu.junhui3@iwhalecloud.com> |
||
|
|
9c732c47c5 |
perf(issues): make the URL rewrite to the identifier cost no extra request (#6169)
Opening an issue from an in-app link fetched it twice. In-app links still carry the UUID, so the route lands on the UUID URL, loads the issue, then rewrites the address bar to the identifier. That rewrite is a navigation: the route re-renders with the identifier as its segment, `useCanonicalIssue` sees a non-UUID, and its resolution query misses on an identifier-keyed cache entry nothing has filled — so it re-fetches an issue already in hand. Measured across the rewrite: 2 requests for one issue open. Mirror the loaded row into its identifier-keyed entry, the reverse of the `initialData` hop that already covers the identifier-first direction. Both directions now hold at one request. The `??` keeps a realtime-patched entry intact, and only `.id` is ever read back out of that entry, which never changes. Co-authored-by: Bohan-J <bohan@devv.ai> Co-authored-by: multica-agent <github@multica.ai> |
||
|
|
6be7adcb6b |
feat(chat): toggle the floating chat window from the keyboard (MUL-5522) (#6162)
Adds a `toggleChat` shortcut action (default Mod+J, rebindable in Settings -> Shortcuts) so the pop-up chat window can be opened and dismissed without a mouse, and focuses the composer whenever the window opens so you can start typing immediately. The shortcut deliberately does not claim the chord where the overlay cannot exist -- on the Chat tab, or when the Settings -> Chat preference is off -- since flipping a hidden `isOpen` would read as a dead keypress and then surprise the user on the next navigation. That route rule now lives in one predicate shared with the overlay itself. Focus is requested only on a real closed -> open transition: ChatWindow stays mounted while closed and `isOpen` is restored from storage, so treating mount as an open event would steal focus on page load. Co-authored-by: Bohan-J <bohan@devv.ai> Co-authored-by: multica-agent <github@multica.ai> |
||
|
|
577018649e |
feat(issues): support human-readable issue URLs using issue keys (MUL-5354) (#6117)
* feat(issues): support human-readable issue URLs using issue keys (MUL-5354) Closes #5987. `/{ws}/issues/MUL-123` now opens the issue, the copy-link action shares that form, and a UUID URL rewrites itself to it. Existing UUID links keep working. Backend already resolved identifiers on `GET /api/issues/{id}`, but it compared the number only — every prefix with the right number opened the same issue, so no identifier URL could be canonical. Resolution now validates the prefix against the workspace's own (case-insensitively, matching `lookupIssueByIdentifier`), and the number parser bails on int32 overflow instead of truncating a digits-only UUID group into a plausible issue number. On the client the identifier stays a presentation concern: the route resolves it to the UUID before rendering, because the realtime updaters patch `issueKeys.detail(wsId, issue.id)` with the UUID from the websocket payload. A view keyed on the identifier would sit on a cache entry no realtime event can reach and silently stop updating. Resolution reuses the request the detail view would have made anyway and seeds the UUID-keyed entry, so an identifier URL costs no extra round trip. The desktop tab title/status glyph hops through the same resolution for the same reason. The URL rewrite lives in the new route wrapper rather than IssueDetail: the inbox renders IssueDetail in a side panel, where replacing the URL would navigate the user out of the inbox. No migration — `issue (workspace_id, number)` is already unique/indexed. Co-authored-by: multica-agent <github@multica.ai> * refactor(issues): make the single-request guarantee for identifier URLs explicit Review flagged that opening `/{ws}/issues/MUL-123` fires two detail requests. It does not, under the app's own QueryClient — but the guarantee was resting on something implicit, so make it structural. The old shape seeded the UUID-keyed entry from a `useEffect` after resolution, while the route enabled the UUID query in the same render. That held only because the seed effect happened to be declared before the UUID query's own effect, and because `createQueryClient` sets `staleTime: Infinity` so a seeded entry is never refetched. Neither is obvious from the code, and a diagnostic run under a bare `new QueryClient()` (staleTime 0) does show two calls — the second being a staleness refetch of an already-seeded entry, i.e. a harness artifact. `useCanonicalIssueId` becomes `useCanonicalIssue`, which owns both the resolution query and the canonical detail query and hands the resolution response to the latter as `initialData`. That is applied while the observer is created, so the canonical query never observes an empty cache and never starts a fetch of its own — no dependency on effect ordering, and no cache write that could race a realtime patch (`initialData` is ignored once the entry holds data). Callers collapse to one hook each: the route no longer runs its own detail query, and the desktop page drops its duplicate. Tests now build the client with `createQueryClient()` rather than a bare `new QueryClient()`, so request-count assertions measure production behavior instead of the harness, plus a direct assertion that an identifier URL costs exactly one request. Co-authored-by: multica-agent <github@multica.ai> * fix(issues): stop the request loop when an identifier names no issue Opening `/{ws}/issues/ZZZ-134` never reached "not found". It spun an unbounded request loop and left the UI on the loading skeleton forever. The route treated a failed resolution as "nothing resolved" and handed the raw identifier down to IssueDetail. IssueDetail mounted a second observer on the query that had just failed; `retryOnMount` refetched it, which flipped the resolve hook back to pending, which unmounted IssueDetail, which remounted it when the refetch failed — and around again. Measured with retry disabled to isolate it: 8,192 requests at 300ms, 32,768 at 600ms. Under the app's `retry: 1` the backoff only paces the loop, it still never converges. `useCanonicalIssue` now reports a terminal `notFound` read from the resolution query's own error state, rather than leaving callers to infer failure from "not resolving and no id" — an inference that cannot distinguish failed from in-flight. `IssueDetailRoute` renders the not-found UI itself and never hands an unresolved segment to a view that would query it again, so no second observer exists to restart the cycle. Same measurement after the fix: 1 request, settled, "not found" on screen. The not-found UI moves out of IssueDetail into a shared `IssueNotFound` so both render the identical state. Regression tests at both levels, with retry off so any count above 1 can only be a remount refetch: the hook settles a failed resolution without looping, and the real IssueDetailRoute holds at one request across waits and rerenders. Both fail against the previous code. Co-authored-by: multica-agent <github@multica.ai> --------- Co-authored-by: Bohan-J <bohan@devv.ai> Co-authored-by: multica-agent <github@multica.ai> |
||
|
|
ab6da2e73b |
fix(views): navigate mention/slash pickers with Ctrl+N/J/P/K (MUL-5495) (#6135)
The command bar is built on cmdk, whose `vimBindings` option (on by default) navigates the result list on Ctrl+N/Ctrl+J (down) and Ctrl+P/Ctrl+K (up). The editor pickers accepted arrow keys only, so the same muscle memory silently did nothing in the @mention and / menus. Add `pickerNavigationDirection` next to the existing `isPickerAcceptKey` policy in suggestion-popup.tsx and route both list components through it, so every picker built on `createSuggestionPopupRender` navigates identically instead of each one re-deciding what "move down" means. The letter aliases require Ctrl alone: with another modifier the chord belongs to the browser or OS (Ctrl+Shift+N opens an incognito window). Cmd-based aliases are deliberately not added — cmdk never bound them either, and Cmd+P/Cmd+N are browser accelerators the app does not own on web, matching `isReservedShortcut` in @multica/core/shortcuts. Co-authored-by: Lambda <lambda@multica.ai> Co-authored-by: multica-agent <github@multica.ai> |
||
|
|
7803a5b9ea |
feat(ui): establish a role-named type scale and migrate ad-hoc font sizes (MUL-5451) (#6136)
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 written as arbitrary 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 moves nothing on screen. The visible changes are confined to former arbitrary values snapping to a step: 8/9/10px -> micro (11px) on badges and overlines; 17 -> 18; 22 -> 24; 30 (text-3xl) -> 36 on headings and stat numbers; 12.8px -> label (13px) on small buttons and toggles. Half-pixel sizes are gone. This supersedes #6108, which was reverted by #6116 because the sidebar group labels rendered at the inherited 16px. The cause was not the scale but cn(): `text-<x>` is ambiguous in Tailwind, and tailwind-merge resolves it against a table listing only the default sizes, so it filed every role step under text-colour and dropped whichever of `text-caption` / `text-sidebar-foreground/70` came first. Registering the steps as a font-size class group restores the real conflict groups — size beats size, colour beats colour, the two coexist — and a test pins the list against the scale, since the failure is silent in source. Hand-written CSS is covered too. The transcript kept a 12.5px body long after every Tailwind call site was on the scale, so the "no half-pixel sizes" claim was true of the classes and false of the product; the editor's prose, code and mermaid ramps had the same blind spot, and seven of their eight values already equalled a step exactly. All now reference var(--text-*). The guard test reads raw `font-size:` declarations as well as class names, exempting only the 16px iOS input-zoom workaround in base.css and the landing pages' marketing ramp. apps/mobile (own NativeWind config) and apps/docs (fumadocs' own type system) keep Tailwind's default scale and are untouched. Landing display type (rem/clamp, 2.2-6.4rem) stays on its separate ramp, as do four decorative emoji / serif-hero sizes. Verified on a running local stack: pinned sidebar rows and group labels measure 12px/16px, nav items 14px/20px — identical to pre-migration. An audit of every rendered font size across the product surfaces finds nothing off the scale; the only exceptions are avatar initials and emoji, which actor-avatar.tsx sizes proportionally to the avatar diameter by design. Co-authored-by: Lambda <lambda@multica.ai> Co-authored-by: multica-agent <github@multica.ai> |
||
|
|
37d66260e8 |
feat(skills): rebuild skill detail page around Overview/Files tabs (MUL-5443) (#6100)
* feat(skills): rebuild skill detail page around Overview/Files tabs (MUL-5443) The skill detail page was the only detail surface that skipped the shared detail-page shape: no identity header, no tabs, and a hard three-column split (w-56 tree / editor / w-72 sidebar). Name and description appeared three times — the editor header, the metadata sidebar, and the SKILL.md frontmatter card — and the 900-character trigger descriptions agents match on were edited through a two-row textarea. Rebuild it on the agent detail page's structure: identity block, underline tabs synced to `?view=`, and the agent second-level nav rail reused for the file list (main file / supporting files), so nothing new is invented. - Overview owns the properties (name, description, labels) plus who uses the skill and the permission note. Description gets a field sized for the data and a character count. - Files pairs the rail with the editor and a Preview / Plain text control. That mode now lives on the page: it used to sit in FileViewer, which the per-path `key` remounted, so every file switch snapped back to preview. - Frontmatter is stripped from the preview — the properties above are the same two fields. - The save bar is page-level and always mounted while editable, so it covers edits from either tab and committing one never shifts the layout. - No Settings tab: UpdateSkillRequest carries only name / description / content / config / files, and edit rights are derived with no writable counterpart, so it would hold a delete button and a read-only sentence. Delete stays in the header, matching Archive on the agent page. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Co-authored-by: multica-agent <github@multica.ai> * fix(ui): keep page-level action bars out from under the chat launcher The chat launcher is mounted by the dashboard layout, so it overlays the bottom-right corner of every workspace page. Nothing had accounted for that: the skill detail page's Save button and the agent creation studio's footer both run to that corner, and both sat underneath it. The launcher's geometry moves to tokens and the button reads its size and inset from them, so the space it claims is derived rather than measured off a screenshot. A `pe-chat-launcher` utility applies that reserve; page-level bars that reach the corner add the class. Naming it keeps the intent legible at each site, makes every yielding surface findable by one search, and means a launcher that moves or resizes carries its clearance along. Only these two bars need it. The batch-action toolbars float centred, dialog footers are centred and above the launcher, and the composer bars sit inside their editors — none of them reach that corner. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * refactor(skills): stop the skill header restating what the Overview tab edits The header carried a 48px mark, the name at text-xl, the description over two lines, and a meta row — around 150px above a page whose only verbs are edit, add and delete. Name and description then appeared a second time on the Overview tab, as fields you can actually change. Every visit paid for a read-only restatement of the next screenful. It is one line now: mark, name, and the counts that say what the skill is made of — origin, files, agents using it, last update. All four are absent from the Overview tab, so none of them is a repeat. The description is dropped; the list this page is reached from already carries it for anyone deciding whether to open it. Adds ExpandableDescription for the descriptions that stay in a header, since neither detail page clamped and a long one pushed the meta row and tab strip down the page. The agent header uses it; its taller form is left alone, as bringing it across is a separate change to a page this branch does not otherwise touch. Also hides the Labels row while agent- and skill-scoped labels are behind their release flag. ResourceLabelPicker renders nothing with the flag off — the server gates the routes on the same flag — so the row was a label above an empty field, reading as broken rather than absent. The flag check is exported as a hook so callers can decide whether to lay out a row at all. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(skills): put the skill detail page's remaining sizes on the type scale #6108's guard flags five font sizes this page still sets outside the scale: three Tailwind defaults left by the merge and two `text-[10px]` picker headings that predate the scale. Mapped to their role-named steps — the identity strip's title to text-title, section headings to text-title-sm, and the arbitrary 10px to text-micro, the step the scale provides for overline labels. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * feat(skills): rename and delete files from the tree itself Deleting a file meant selecting it, then finding a Trash button in the editor's top-right toolbar — nowhere near the row being deleted, and invisible until something was open. Renaming did not exist at all: the only way to change a path was to delete the file and retype its contents. Both live on the row now, reachable by right-click or by a trailing button that appears on hover, the two entry points opening one menu rather than a context-menu root beside a dropdown root. Renaming happens in the row, where the name is read and the neighbouring paths stay visible to compare against. Validation stays with the caller, so the tree carries no second opinion on what a legal path is. Delete takes the path it acted on, so it no longer removes whichever file happens to be open; the toolbar button keeps working unchanged. Rows offer nothing to read-only viewers, and nothing on SKILL.md, which maps to skill.Content and which the server drops from the files list. The path validator gains two rules the rename path made reachable. Directories here are inferred from slashes rather than stored, so naming a file after an existing folder — or nesting one under an existing file — makes buildTree merge it into that node: the file saves and then cannot be seen. Both directions are refused now, for adds as well as renames. 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> |
||
|
|
da7451843b |
MUL-5494: feat(transcript): render tool events as diffs, content and terminal output (#6134)
* feat(transcript): make tool events readable — diffs, content, terminal output The expanded transcript row printed a tool call's input as raw JSON, so an edit showed `old_string`/`new_string` as escaped one-line literals — the one event type where seeing *what changed* matters most. Tool results kept their JSON string encoding, so every shell result read as a quoted blob with literal `\n`, in the collapsed summary and in Copy all as well as in the body. What each kind of event now renders as: - A replacement reads as a diff. Unchanged runs fold to `⋯` with three lines of context either side, so a one-line change inside a large `old_string` is not buried in text that never moved. - A whole-file write reads as plain content with a line count. There is no before side to compare against, so a `+` on all of it carries no information. - A result is unwrapped once, everywhere it appears. File mutations are identified by the *shape* of the input (`file_path` plus `old_string`/`new_string`, or `content`), never by tool name: the presenter's contract is to keep provider-native names verbatim, and those differ per provider. The write mode keys on `content` rather than on "the before side is empty", because an edit with an empty old_string is an insertion into a file that already exists and still reads as a diff. Highlighting reuses the rich-content engine (`lowlight` and the `.hljs-*` class contract), so a file looks the same in a transcript as it does in a comment, with no new dependency. Each side is highlighted as ONE block and then split at newlines, re-opening the enclosing spans per line — highlighting line by line would break every multi-line string, comment and template literal. Grammar comes from the file extension; an unknown extension stays plain rather than guessing. The hljs palette was scoped to `.rich-text-editor`; it now also covers `.transcript-code`, with no colour definition duplicated. Diffing is a small LCS over lines, degrading to a plain replacement block past 250k cells. Line numbers are deliberately absent: the transcript stores only the tool input, so a snippet's position inside its file is not knowable here, and relative numbers would read as file lines and mislead. * fix(transcript): keep the show-all label off the line it covers The fade overlay does not fully clear the clipped line, so the transparent "Show all" label rendered on top of whatever text sat behind it — the two interleaved character by character and neither was readable. Giving the button an opaque surface separates them. Pre-existing: any tool output long enough to clip hit it. It became routine once whole-file writes started rendering their content. |
||
|
|
beb3e9be65 |
fix(chat): widen the conversation gutter and align its edges (MUL-5497) (#6140)
The chat body hugged its pane: a flat px-5 (20px) on every layer, which reads as cramped once the conversation pane is wider than the floating window it was tuned for. The gutter now scales with the CONTAINER — 20px base, 32px past @2xl (672px), 48px past @4xl (896px) — so the chat tab's resizable pane and the agent builder column breathe while the 360px floating window keeps exactly its current spacing. Viewport variants would have been wrong here: these three surfaces are independent widths inside one window. Layout also drifted between layers. The message list capped `max-w-4xl` with its padding INSIDE the cap while the banners and composer put theirs outside, so past ~936px the text sat 20px narrower than the box below it. Both now come from one CHAT_GUTTER / CHAT_COLUMN pair — gutter outside the cap — which is what keeps the edges locked together. Co-authored-by: Lambda <lambda@multica.ai> Co-authored-by: multica-agent <github@multica.ai> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
545afea827 |
Revert "feat(ui): establish a role-named type scale and migrate ad-hoc font s…" (#6116)
This reverts commit
|
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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>
|
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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>
|
||
|
|
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 |
||
|
|
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>
|
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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>
|
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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>
|
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |