mirror of
https://github.com/multica-ai/multica.git
synced 2026-08-03 11:10:23 +02:00
main
1593 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
aa349fed02 |
MUL-5637 fix(mcp): agent mcp_config is an authoritative allowlist (#6292)
* fix(mcp): treat agent mcp_config as an authoritative allowlist
An agent's saved mcp_config was silently widened with the runtime host's
own user-level MCP servers, so an explicitly empty `{"mcpServers":{}}`
resolved to the COMPLETE host set instead of no servers at all — the
opposite of what the operator configured (GitHub #6283).
`--strict-mcp-config` was being passed correctly; the merge happened
before it, in the daemon, so strict mode constrained an already-widened
set. Introduced by #5277 and present in v0.4.16 through main.
Restore the three-state contract in resolveEffectiveMcpConfig:
null / unset -> inherit the provider's native MCP configuration
{"mcpServers":{}} -> strict empty, no host servers
non-empty object -> strict allowlist, exactly those servers
Two explicit inherit paths keep the additive behaviour reachable without
weakening the default:
- runtime_config.mcp.inherit_runtime = true opts an agent back in.
- The claim response now carries mcp_config_overlay_only so the daemon
can tell an agent-authored config from a per-task Composio overlay.
Without it, enabling an integration on an agent that never configured
MCP would have stripped the host servers it was already inheriting.
Both decode paths fail closed: malformed runtime_config never enables
inheritance, and a failed runtime merge falls back to the agent's own
config.
The web MCP tab and the `agent create/update --mcp-config` help text
described the old additive behaviour, which is how a tightened config
could look correct while exposing every host server; both now state
which mode is in effect.
Note for rollout: the fix lives in the daemon, so self-hosted users must
upgrade the daemon — a server/UI upgrade alone does not apply it.
Co-authored-by: multica-agent <github@multica.ai>
* fix(mcp): close review gaps in the authoritative mcp_config change
Addresses the four must-fix findings from review of #6292.
1. Deleting the last managed server no longer widens access.
removeManagedMcpServer cleared the config to null, which now means
"inherit the host's MCP servers" — so a delete took the agent from one
allowed server to every server on the host. It now leaves an explicit
`{"mcpServers":{}}`. Restoring inheritance moved to a separate
clearManagedMcpConfig action behind its own confirmation that states the
widening. The delete dialog no longer claims "Runtime servers are not
affected", which was the opposite of the truth.
2. The UI no longer promises a boundary an old daemon does not enforce.
The strict semantics live in the daemon, so a config saved against an
older daemon is not yet in effect. Adds the authoritative-mcp-v1 daemon
capability:
- The daemon advertises it and reports authoritative_mcp on the
runtime-capabilities response.
- The claim path fails closed: a managed, non-inheriting mcp_config
claimed by a daemon without the capability cancels the task and
returns 412 with an actionable message, instead of letting that daemon
merge the host's servers in. runtime_config.mcp.inherit_runtime is the
documented escape hatch, and it is honest — it declares that the
operator accepts the host's servers.
- The MCP tab shows "needs upgrade" rather than "Not exposed" while the
bound runtime lacks the capability.
3. Saving OpenClaw settings no longer drops the inherit opt-in.
parseOpenclawRuntimeConfig discarded unknown keys and the tab persisted
the result as the whole runtime_config, so one unrelated routing save
silently deleted mcp.inherit_runtime. Unknown keys now round-trip
through OpenclawRuntimeConfig.passthrough, excluded from the dirty check
so they cannot make the form look edited.
4. Documents the new semantics in the built-in creating-agents skill and
its source map: the three states, the persisted
runtime_config.mcp.inherit_runtime field, and the claim-time capability
gate.
Also corrects the PR's rollout claim: there is no database migration, but
this does add a persisted JSON field and change the meaning of an existing
one.
Co-authored-by: multica-agent <github@multica.ai>
* fix(mcp): stop the authoritative-daemon gate from blocking valid claims
CI's backend job failed three handler claim tests with the new 412. Two
distinct problems, both real:
1. The gate fired for a non-object mcp_config. 66 handler fixtures seed
`[]`, which is not a valid MCP config and cannot carry `mcpServers`, so
it expresses no boundary to protect. An old daemon does not widen it
either: mergeRuntimeAndAgentMcpConfig fails to unmarshal a non-object
and falls back to the agent config alone (verified directly). Gating
these blocked tasks with no security benefit, so the gate now requires a
JSON object.
2. The shared daemon test-request helper advertised no capabilities, so
every claim test was accidentally simulating a pre-#6283 daemon. It now
defaults authoritative-mcp-v1 on, matching what every current daemon
sends. Only that capability — skill-bundles / coalesced-comments / rpc
are feature negotiations whose absence tests real legacy behaviour, so
they stay opt-in per test.
Adds claim-level coverage for the gate itself, which is what the unit tests
alone could not catch: an outdated daemon gets 412 with an actionable
message and the task is cancelled; a capability-advertising daemon gets
200; the inherit_runtime opt-in lets an outdated daemon through; and an
unmanaged or non-object config is never gated.
Verified against a real migrated schema this time (throwaway Postgres),
which is how the three failures were reproduced locally and confirmed
fixed: `go test ./internal/handler ./internal/daemon` both ok.
Co-authored-by: multica-agent <github@multica.ai>
* fix(mcp): surface the daemon-upgrade refusal and stop gating safe providers
Addresses the second review round on #6292.
1. The refusal is now visible wherever the operator looks. The default
claim path is the machine-level BATCH endpoint, which skips build
failures and still answers 200 {"tasks":[]}, so the previous bare
CancelTask showed a task that vanished with no stated reason — turning an
explicit upgrade requirement into an unexplained failure. The claim path
now fails the task with a new classified reason,
mcp_config_daemon_outdated, plus the actionable message. That reaches the
user on all three claim paths and on any daemon version, which a new
response field could not: the audience is by definition a daemon too old
to read one. The per-runtime path keeps its 412.
The reason is deliberately not auto-retryable — the same outdated daemon
would claim the retry and fail it again.
2. The gate no longer cancels safe tasks. It applied to every provider, but
only claude / codebuddy / codex / cursor / opencode / openclaw were ever
merged with host MCP by an old daemon (loadRuntimeMcpServerConfigs).
Qwen was never merged and already had strict semantics, so its tasks were
being failed for a risk that does not exist. Scoped via
providersOldDaemonsMergedRuntimeMcp; an unknown provider does not gate,
because the gate should only fire where the old behaviour is concrete.
3. The new authoritative_mcp flag now goes through the API schema layer.
Both local-skills responses were returning raw network JSON, so the flag
that decides whether the UI may assert an MCP boundary rested on an
unchecked type assertion. Adds RuntimeLocalSkillListRequestSchema with
authoritative_mcp and mcp_supported defaulting to FALSE — the fail-closed
direction — and a MALFORMED_ fallback that cannot express a guarantee.
Claim-level tests now cover all three paths, which is what the previous
helper-only tests missed: per-runtime 412, batch recording the refusal on
the task while still delivering the healthy tasks in the same batch, WS RPC
refusing and accepting, the qwen negative case, the inherit_runtime escape
hatch, and unmanaged / non-object configs.
Verified against a real migrated schema (throwaway Postgres):
go test ./internal/handler ./internal/daemon ./pkg/agent ./pkg/taskfailure
./internal/service all ok.
Co-authored-by: multica-agent <github@multica.ai>
* fix(mcp): register the new failure reason and wire its copy into the UI
Addresses the third review round on #6292.
1. mcp_config_daemon_outdated was declared but never registered in
taskfailure.allReasons, so metrics.NormalizeFailureReason missed the
known-value map and fell through to free-text Classify() — relabelling a
platform-side refusal as `agent_error.unknown` (verified directly) and
leaving the Prometheus series un-pre-warmed. Registered it, canonical
count 22 → 23 (platform 8 → 9), with the wire value and IsAgentError split
pinned. New test pins the WHOLE canonical set through
NormalizeFailureReason so forgetting the next reason fails a test instead
of quietly mislabelling a metric; NormalizeFailureReason had no coverage
at all before.
2. The upgrade copy was dead. The locale strings landed last round but
neither consumer mapped the reason: chatFailureCopy fell back to generic
failure text with the actionable detail buried in the collapsed raw
error, and task-failure.ts rendered the bare wire value
`mcp_config_daemon_outdated` in the agent activity list and issue
execution log. Both are mapped now, with regression tests, plus the
runtime class pinned in failure-class.test.ts. This directly contradicted
the claim in the claim-path comment that every path reaches the user, so
that is now actually true.
3. providersOldDaemonsMergedRuntimeMcp is documented as what it is: a FROZEN
record of what pre-capability daemons merged, not a mirror of the daemon's
current provider switch. The old "keep the two lists in lockstep" note was
actively harmful advice — runtime MCP discovery for a new provider can only
ship in a daemon that already advertises the capability (never gated), so
adding it here would fail tasks on old daemons that never merged for it,
re-creating the qwen false-positive. Pinned with a test.
Also corrects a stale count in task-failure.ts (7 → 9 platform reasons).
Verified against a real migrated schema (throwaway Postgres): full backend
suite green apart from the pre-existing environmental cmd/multica guard; all
9 TestMcpGate_* integration tests pass.
Co-authored-by: multica-agent <github@multica.ai>
* docs(taskfailure): correct taxonomy counts and finish the reason registration
Non-blocking nits from the fourth review round on #6292.
- Taxonomy counts now say 23 reasons / 9 platform-side. Registering
mcp_config_daemon_outdated last round updated the assertions but not the
prose. Swept the whole repo rather than only the flagged lines, which
turned up four more that were already stale at 21 and drifted further:
handler/dashboard.go, daemon/poisoned.go, core/types/agent.ts, and the
db/queries/task_usage.sql comment sqlc copies into the generated file.
The generated file's comment was updated by hand to match its source.
Running `sqlc generate` churned 58 lines across 47 unrelated files — the
local sqlc version differs from the one that produced the checked-in
output — so that churn was reverted and only the one intended line kept.
- failure_test.go's `required` list now includes
ReasonMcpConfigDaemonOutdated. Length and label assertions already covered
the reason, but the list is documented as the complete canonical set, so
the omission contradicted its own comment.
- Restored the line break in chat-message-list.test.tsx that a previous edit
of mine collapsed.
Comment, test-fixture and formatting only; no behaviour change.
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: Eve <eve@multica-ai.local>
Co-authored-by: multica-agent <github@multica.ai>
|
||
|
|
f1fc33f458 |
fix(autopilots): save the first schedule added from the edit dialog (MUL-5649) (#6303)
Editing a manual-only autopilot showed the schedule panel seeded with the editor's default — 09:00 every day, next runs and all — as though that were the autopilot's schedule. It was not: the autopilot had no trigger. Saving then compared that default against itself, found no change, wrote nothing, and toasted "Autopilot updated" while the detail page went on reading "No triggers configured". The dirty check is right when a schedule exists (it stops a re-picked, unchanged cron from being rewritten) and meaningless when none does, because the panel is showing a proposal rather than stored state. So the panel now says what is true — a dashed card matching the detail page's empty state, "No schedule — this autopilot only runs when triggered manually" — and asks for the schedule explicitly. Adding one writes it on save whether or not the user touched the default; leaving it alone keeps the autopilot manual, so a title-only edit can no longer put it on a daily cron by accident. The footer's auto-run promise drops out while that empty state is up. The schedule write also targets the first `schedule` trigger rather than `triggers[0]`, which on an api-triggered autopilot was a row a cron could have been patched into. Co-authored-by: Bohan-J <bohan@devv.ai> Co-authored-by: multica-agent <github@multica.ai> |
||
|
|
76fbd48849 |
fix(skills): measure draft dirtiness against a seeded baseline (MUL-5645) (#6294)
* fix(skills): measure draft dirtiness against a seeded baseline (MUL-5645) The detail page inferred "the user edited something" from "the draft differs from the latest server skill". That difference has two independent causes — the user typed, or the server moved — and the page could not tell them apart, so it read both as a local edit. Two user-visible failures came out of it: - A description ending in whitespace (what `description: |` frontmatter yields, so every imported skill) compared unequal to itself, because the dirty check trimmed the draft side and not the server side. The page opened permanently dirty and Discard reseeded the same value, so the save bar could not be dismissed at all — only Save cleared it, by rewriting the stored description. - Any remote update to an open skill was taken for a local edit, so the page raised the conflict banner and refused to reseed. The editor stayed frozen on pre-update text with no way to see what had changed, and saving from there pushed the stale draft back over the newer version. Record the seeded snapshot in `baselineRef` and compare against that instead. With a baseline the two causes separate: `draft !== baseline` is a local edit, and a new `updated_at` with no local edits is just a remote update, which now reseeds silently. The conflict banner is left for the case it was written for — a remote update landing on real unsaved work. `toDraft` also trims name and description at the single seam where server data becomes a draft, matching what Save persists, so later comparisons are plain equality rather than a trim both sides have to remember. Content and file bodies are not normalized: whitespace in a SKILL.md body is content. File sets are compared through a path-sorted signature, since GET sorts files by path while PUT echoes request order and that difference is not a content change. Four of the five regression tests fail against the previous implementation; the fifth covers the true-conflict path, which was already correct and must stay that way. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Co-authored-by: multica-agent <github@multica.ai> * fix(skills): trim frontmatter name and description at the parse seam (MUL-5645) Both fields are single-line labels everywhere they are consumed, but YAML clip chomping gives `description: |` and `description: >` a trailing newline, so imports stored a description that differed from its own trimmed form. The detail page no longer depends on this — it normalizes when it seeds a draft — but leaving it means every new import keeps writing the padded value, and any future consumer that compares a stored description to a trimmed one inherits the same trap. Trimming here covers all four import paths (GitHub, skills.sh, archive, runtime-local) in one place rather than asking each to remember. Callers that need the raw SKILL.md still have it: `content` is stored untouched. Defensive only. Reverting this commit alone does not reintroduce the bug. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Co-authored-by: multica-agent <github@multica.ai> * fix(skills): release the conflict when the user reverts their own edits (MUL-5645) Review catch on the previous commit. Once a conflict was raised, the seed effect only reconsidered when a new server version arrived, so a user who resolved the conflict by hand — retyping the field back to what it was — got stuck: the draft was clean again, and because the save bar renders only while dirty, its Discard button unmounted. That left the banner sitting above stale text with no control left to dismiss it, and no way forward short of a reload. This state is a regression from measuring dirtiness locally. Previously the draft was compared against the moved server value, so reverting still counted as dirty and Discard stayed on screen. Re-run the decision on draft changes too. When the local edits go away there is nothing left to protect, so the page adopts the server version and clears the banner — the same outcome as the never-edited case, reached a moment later. The true-conflict path is unchanged, and its regression test still passes, which is what keeps this from over-correcting into "always release". Reseeding also grew a third caller, so the four pieces that have to move together — draft, baseline, seeded key, conflict flag — are now assigned in exactly one place, `adoptServerVersion`, used by first load, silent refresh, Save and Discard alike. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Co-authored-by: multica-agent <github@multica.ai> --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com> Co-authored-by: multica-agent <github@multica.ai> |
||
|
|
b06af2ae17 |
feat(runtime): unbind agents on runtime delete instead of destroying them (#6220)
* feat(runtime): unbind agents on runtime delete instead of destroying them Deleting a runtime archived its agents and then hard-deleted the rows, so the agents and every conversation with them disappeared — while the confirmation dialog said "archive", which a user reasonably reads as recoverable. Retiring a laptop is an ordinary action; losing the agents configured on it is not an ordinary consequence. An agent is now a persistent business object and a runtime is replaceable execution capacity: deleting a runtime unbinds its agents. `runtime_id IS NULL` means unbound — orthogonal to archived — and the agent keeps its instructions, skills, chats, labels, channel installations, autopilots and task history. service.AgentReadiness already refused an agent with no runtime, so the scheduling safety gate needed no change. Two columns become nullable, not one. Without `agent_task_queue.runtime_id`, deleting the runtime still cascades the task history away (and task_message / task_usage / task_token with it), so the agents would survive with no record of anything they did — the same class of loss. A NOT VALID CHECK keeps NULL confined to history: an active task must always have a runtime, so claim / dispatch / delivery-CAS paths can never observe one without. It is written against completed_at rather than a status list so a future non-terminal status fails closed instead of slipping through. Two prerequisites this depends on: - 'deferred' (migration 128) was missing from CancelAgentTasksByRuntimeOrAgent. It went unnoticed because the delete used to cascade those rows away; with the new CHECK it would abort the delete and make the runtime undeletable. - The channel-installation / label / chat-pin / invocation-target / draft-restore cleanups were scoped to "archived agents on this runtime". Archived user agents now survive, so that scope is narrowed to kind='system' — otherwise the fix would produce a subtler loss: agent alive, configuration wiped. Also removes the squad guard that refused (409) when an active squad's leader was an archived agent on the runtime, plus the archived-squad delete that existed only to get past squad.leader_id's RESTRICT FK. The leader is no longer deleted, so nothing needs to be given up to retire a machine. Autopilots are no longer paused either: their assignee survives, and a rebind restores them without the owner having to remember to re-enable. Reason codes: an unbound agent reports agent_runtime_required, not runtime_offline. The copy for runtime_offline tells users to reconnect a machine; an unbound agent has no machine to reconnect, and the fix is to bind a runtime. Chat's bare 409 string gains the same code so the composer can offer that action. API: agents gain runtime_bound. runtime_id stays a string (empty when unbound) so installed clients keep parsing and no gated two-release rollout is needed. The confirmed-delete endpoint is /unbind-agents-and-delete; /archive-agents-and-delete still routes to it, and the compared expected_active_agent_ids set is unchanged — widening it would 409 every older client forever. Co-authored-by: multica-agent <github@multica.ai> * fix: make runtime unbinding recoverable Co-authored-by: multica-agent <github@multica.ai> * fix: address runtime unbind review nits Co-authored-by: multica-agent <github@multica.ai> * fix: resolve runtime unbind review blockers Co-authored-by: multica-agent <github@multica.ai> * fix(migrations): renumber runtime unbind after main merge Co-authored-by: multica-agent <github@multica.ai> * test(daemon): avoid late-request lease flake Co-authored-by: multica-agent <github@multica.ai> * test(autopilots): bind validation fixture runtime Co-authored-by: multica-agent <github@multica.ai> --------- Co-authored-by: Eve <eve@multica-ai.local> Co-authored-by: multica-agent <github@multica.ai> |
||
|
|
9b013e34e8 |
feat(inbox): move the selection with the arrow keys (MUL-5622) (#6269)
Up/Down inside the inbox list scrolled the container instead of walking the selection, so a notification could only be opened with the mouse. The list's scroll container now owns the arrow keys: it moves the selection by one row, scrolls the new row into view through Virtuoso (never the DOM's scrollIntoView, which also scrolls ancestors), and claims the keypress so the native scroll cannot pull the viewport off the selected row. Keyboard focus is parked on the container rather than a row, because virtualization unmounts rows as they scroll out. Scoping the handler to the container keeps Down from swapping the row out while the user is reading the issue detail. Co-authored-by: Bohan-J <bohan@devv.ai> Co-authored-by: multica-agent <github@multica.ai> |
||
|
|
37f3bb7dd9 |
MUL-5587: fix(autopilots): tell the user which required field blocks Create (#6231) (#6237)
* fix(autopilots): tell the user which required field blocks Create (MUL-5587) The create dialog gated its submit button with native `disabled` on an empty title, an unpicked assignee, or a server-rejected schedule. A natively disabled button is neither hoverable nor focusable, so a user who had not picked an assignee got a dead grey control and no hint about what was missing (GitHub #6231) — the assignee picker looks exactly like the optional Project and Subscribers pickers beside it, and nothing said only that one was required. An unmet requirement now uses `aria-disabled` instead: the button still takes the hover that shows a tooltip naming the missing field, and still takes the click, which reveals an inline error under the field at fault and focuses it. `handleSubmit` is the real gate either way, so nothing can be submitted that could not be before. The assignee section also carries a required marker up front, so the dead end is avoided rather than only explained. The three states come from one `submitBlock` value shared by the button, the tooltip, the inline errors and `handleSubmit`, so a rendered affordance cannot disagree with what submitting actually does. Co-authored-by: multica-agent <github@multica.ai> * refactor(autopilots): stop dimming Create at all — the click is the feedback (MUL-5587) Follow-up on review. The previous commit kept the button greyed via `aria-disabled` and explained the grey with a tooltip. Since a blocked click now intercepts and points at the offending field, the greying earns nothing: drop `aria-disabled`, the dimming classes and the tooltip, and let the button be an ordinary live button whenever a save isn't already in flight. The schedule case no longer scrolls to the editor's inline error and returns. It falls through to `scheduleGate.ensureAccepted`, which re-asks the server and toasts its actual reason — visible feedback where the scroll could be a no-op, and it self-heals when a stored expression the server once rejected is accepted again. That leaves nothing in this dialog reading the gate's `scheduleValid`, so its `onValidityChange` / `clearRejection` wiring goes too rather than sitting inert. The shared hook keeps them for the detail page's add-trigger dialog, which still gates on it. Co-authored-by: multica-agent <github@multica.ai> --------- Co-authored-by: Bohan-J <bohan@devv.ai> Co-authored-by: multica-agent <github@multica.ai> |
||
|
|
8df7549d84 |
refactor(issues): move sidebar Details section below the execution log (MUL-5599) (#6244)
Co-authored-by: Bohan-J <bohan@devv.ai> Co-authored-by: multica-agent <github@multica.ai> |
||
|
|
3cab03cc36 |
feat(labels): remove agent labels from the product (MUL-5600) (#6245)
Label Settings no longer exposes an Agents catalog, so agent labels can no longer be created, renamed, recolored, or deleted from the UI. Removing only that catalog would have left the agent detail settings form with an attach-only label picker pointing at a catalog the user can no longer populate, so the Labels row there goes too. That picker was the only other agent-label surface in the product — the CLI never managed them, and no list, filter, or chip renders them anywhere else. The backend still models the `agent` resource type and keeps its endpoints; this change removes the product surface, not the API. Co-authored-by: Lambda <lambda@multica.ai> Co-authored-by: multica-agent <github@multica.ai> |
||
|
|
28b6105edc |
fix(subscribers): notify the human an agent files sub-issues for (MUL-5483) (#6209)
When an agent created a sub-issue while working on a human's behalf, that human received no notifications for it at all. issue_subscriber modelled ACTOR identity, so an agent-created, agent-assigned issue had a full subscriber list and zero members to deliver to. The platform already knew who the work was for (agent_task_queue.originator_user_id, MUL-4302); notification never asked. - attribution.DelegatedSubscriber: one shared rule over the same origin waterfall ClassifyDirect uses. agent_create subscribes the originator as 'delegated'; quick_create keeps the direct 'creator' tier; autopilot and degraded attribution subscribe nobody. - Delegated is a reduced delivery tier: in_review/done/cancelled/blocked plus failures and mentions. Routine churn is suppressed, and the parent bubble cannot re-deliver what the tier dropped. - Unsubscribe becomes stateful: an unsubscribed_at tombstone survives later rule passes, and opt_out_scope distinguishes "this issue" from "this subtree" so a narrow opt-out no longer silently suppresses future children. - Subtree unsubscribe is its own endpoint. A body flag cannot fail loudly against an older backend (Go drops unknown fields); an unknown route 404s, which the UI now surfaces with a distinct message. - Eligibility and the write share one statement under a (workspace, user) advisory lock that subtree unsubscribe and member revoke also take, closing the check-then-insert races. Revoke additionally clears the departing member's subscriptions in the same tx. - UI explains a delegated subscription and offers both unsubscribe scopes. Migrations 249/250 add the delegated reason, the opt-out tombstone, and the opt-out scope, using NOT VALID + VALIDATE CONSTRAINT so the widened CHECK does not scan issue_subscriber under an exclusive lock. Reviewed across eight rounds; an earlier write-time subtree roll-up was built and then removed in full once it proved unfixable without serializing every topology mutation. The parent's own status transition already carries that signal. Closes MUL-5483. |
||
|
|
d4ae220cc1 |
feat(rich-content): render bare in-app project/issue URLs as chips (MUL-5499) (#6141)
* feat(rich-content): render bare in-app project/issue URLs as chips (MUL-5499) A project has no `MUL-123`-style identifier — only a UUID and a free-text title — so there is nothing for the bare-identifier autolink preprocessor to detect, and the link copied out of the app is how people actually reference one. It rendered as a raw URL. RichLink now unfurls a bare in-app entity URL into the same chip the `mention://project/<uuid>` form already produces (issue URLs go through the same path for symmetry). Render-only: stored markdown is untouched, and the editable Tiptap path is deliberately unaffected. Three guards, each load-bearing: the link must be bare (an authored label is never discarded), same-workspace (a chip resolves its title in the current workspace only), and address exactly one entity page by UUID with no query or fragment. Also: - mobile: tapping a `mention://project/` link navigated nowhere despite the `project/[id]` route existing — it now pushes the project detail. - agents had no documented way to emit a clickable project reference: add the link form to the runtime brief's Mentions section and to the projects skill, and record in the mentioning skill why `project` sits outside `MentionRe` (render-only, enqueues nothing). Co-authored-by: multica-agent <github@multica.ai> * fix(rich-content): unfurl issue URLs in identifier form The unfurl required a UUID id, on the stated grounds that "every link the app itself produces carries a UUID". That holds for a project but not for an issue: `copyLink` and `openInNewTab` both build `paths.issueDetail(issueIdentifier || issueId)`, and the issue route rewrites a UUID URL back to the identifier — so `MUL-123` is the shape a user actually copies, out of the app or out of the address bar. The issue half of the feature could not fire on the links people paste, while bare `MUL-123` prose did become a chip: the fuller reference lost to the shorter one. `parseWorkspaceEntityLink` now accepts an issue identifier as well as a UUID. A project still requires a UUID — it has no shorthand, so an identifier-shaped id under /projects/ addresses nothing. An identifier needs a lookup, which means it can miss, and the miss has to differ by entry point. `AutolinkedIssueMentionLink` degraded to plain text, which is right for autolinked prose and wrong for a URL: the author wrote a link, and an issue this workspace cannot see must not cost them the only pointer to it. The fallback is now a prop — plain text for the autolink path, the original anchor for a URL. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(editor): stop drawing link chrome over mention chips A mention chip already carries its own affordance — border, icon, hover background — so the generic `.rich-text-editor a` color and underline draw a second, competing one straight through the card. `.issue-mention` reset it; `.project-mention` never did, so project chips shipped with a brand-coloured underline through them. The rule belongs to the chip shape rather than to one entity, so both selectors now share it and a future chip is one line. The hover card had the same gap: it skipped `.issue-mention` only, so hovering a project chip opened a URL card offering to copy `/{slug}/projects/{uuid}` — an in-app path, not the shareable link that wording implies. Both are pre-existing, but a bare project URL now renders as a chip, so what used to surface on hand-written mentions alone shows up on ordinary pasted links. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(rich-content): decide in-app by resolving the URL, not by its prefix `href.startsWith("/")` was standing in for "this deployment". It is not: a browser reads `//other.example/x` and `/\other.example/x` as another host and goes there, and both start with a slash. The parser skipped the origin check for exactly the hrefs that most needed it. Nothing shipped from this: an unfurled chip links to `paths.projectDetail(uuid)`, so the href it was parsed from is discarded and a misparse could not send anyone anywhere. The prefix test was still the wrong instrument. Adding `&& !startsWith("//")` would have looked like a fix while leaving the backslash spelling through — the gap is the technique, not the case, so this resolves the href against the app origin with `URL` and compares `origin`, which is one comparison for every spelling and for the schemes (`javascript:`, `data:`) whose opaque origin can never match. Relative and absolute now take the same path, so the slugless legacy form parses identically whether or not it carries the origin — previously the absolute spelling was rejected by a reserved-slug test meant for workspace slugs, and the two disagreed. `openLink` still tests the prefix, and its result IS navigated. That is a live issue, older than this feature and wider than it; it needs its own change rather than a quiet ride here. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * docs(skills): state what mobile actually does with a project mention The projects skill told agents a `mention://project/<uuid>` link "renders as a navigable project chip on web, desktop, and mobile", and that a pasted project URL is unfurled into that same chip by "the reader's client". Neither holds on mobile: `apps/mobile/lib/markdown/markdown.tsx` renders the default enriched link and only routes the tap, and a bare URL still goes to `Linking.openURL`, which leaves the app. These files enter agent context and read as product contract, so an agent choosing between a mention link and a pasted URL was choosing on false information — and the URL is the option that strands a mobile reader in a browser. Both skills and both source maps now say chip on web/desktop, ordinary link that opens the project on tap on mobile, and unfurling as web/desktop only. The projects skill also now states the preference outright rather than presenting the two forms as equivalent. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * refactor(views): give a project mention the component an issue mention has `IssueMentionCard` owns "chip inside a link" for issues; the project equivalent lived inline in the readonly renderer, so nothing named the pairing and nothing held the rules that come with being a link. That cost was not hypothetical. Both gaps fixed a commit ago landed on project mentions alone: `.project-mention` never got the CSS rule cancelling generic link chrome, and the hover card never learned to skip it. Each was written for `.issue-mention` at the component that owns it, and project had no such place for the second half to be written. `ProjectMentionCard` is that place. No behaviour change: same anchor, same href, same hover affordance, same accessibility contract that project-mention-a11y.test.tsx pins. The "open in new tab" preference stays out — it is scoped to issue links, and inheriting it by symmetry would be inventing product. Also drops `not-prose` from both cards. It has no definition anywhere in the repo — Tailwind's typography plugin is not installed, and the class does not appear in built CSS — so it read as protection that was not there. The editor's `MentionView` keeps its hand-rolled anchors: it needs a modifier-click intent hook `AppLink` does not expose, and it does the same for issues, so the two stay symmetric there too. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- 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> |
||
|
|
e4b6f7a31b |
MUL-5581: add Qoder CN CLI runtime (#6232)
* feat(agent): add Qoder CN CLI runtime Co-authored-by: multica-agent <github@multica.ai> * fix(agent): address Qoder CN review nits Co-authored-by: multica-agent <github@multica.ai> * fix(agent): defer Qoder CN version 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> |
||
|
|
f13969b996 |
refactor(chat): generate quick actions server-side via the LLM layer (MUL-5573) (#6214)
* refactor(chat): generate quick actions server-side via the LLM layer (MUL-5573)
Follow-up suggestions were produced by a second, full provider CLI invocation
per chat turn: the daemon resumed the just-finished session and ran a
suggestion-only pass. That pass inherited the main turn's exec options, so its
20s budget had to cover process spawn, every MCP handshake, session replay, and
model reasoning at the agent's own thinking level — typically 8-15s of visible
skeleton, and every turn paid two provider cold starts.
Generate them here instead, through the same pkg/llm layer that backs chat
auto-titling. Suggestions need no tools, workdir, or agent identity — only the
tail of the conversation — so a bounded 8s call on the deployment's small model
replaces the whole resumed turn.
Quality changes that came with the move:
- The prompt now states the frame explicitly ("you write FOR THE USER"). The
old pass ran inside the agent's session and inherited the runtime brief's
identity, which drifted suggestions toward agent-operations actions.
- Previously-offered labels are replayed as ALREADY SUGGESTED. The old
architecture had the opposite effect: on providers that append on resume,
each pass saw its predecessor's JSON and anchored on it.
- A failed generation broadcasts failed=true. Before, a timeout delivered an
empty array — indistinguishable from "nothing worth suggesting", so every
slow pass read as a quality problem.
- The in-band footer is still stripped from replies but its actions are now
discarded, so a pre-upgrade session is not pinned to the retired
suggestions with the replacing pass suppressed.
The refresh path no longer enqueues an agent task: it validates the target and
calls the same generator, which also drops the not-resumable refusal — a session
whose runtime was rebound can now be refreshed. Client contract is unchanged
(chat:done pending flag, chat:quick_actions supplement); the only frontend
change is the pending window, resized from 30s to 12s to match the new budget.
Also removes the daemon's TMPDIR-after-cleanup hazard by construction: the old
pass started after runTask's defers had already deleted the temp dir it was
still pointed at.
Co-authored-by: multica-agent <github@multica.ai>
* refactor(chat): drop the quick-actions opt-out setting (MUL-5573)
Suggestions are always on. The Settings → Chat toggle is removed along with
the whole per-turn opt-out path it fed: the persisted client preference, the
quick_actions_enabled send field, the quick_actions_disabled task stamp, and
the eligibility gate that read it.
The toggle predates server-side generation, when it could only hide chips a
provider pass had already paid for. Now that generation is a bounded call the
server decides on, an off switch buys nothing a user would miss, and it was
the last piece of UI implying the feature might be unavailable.
agent_task_queue.quick_actions_disabled is no longer written (dropped from
CreateChatTask's INSERT; the column keeps its false default). Left in place
alongside regenerate_quick_actions_for for a later drop migration — removing
columns an already-running binary still inserts would break mid-deploy.
Co-authored-by: multica-agent <github@multica.ai>
* fix(chat): address quick-actions review findings (MUL-5573)
Four defects from review of the server-side generation change.
1. Automatic failures were reported as refresh failures. The generator
broadcast failed=true on any LLM error, but the client turns every
failed=true into a "couldn't refresh" toast — so an automatic timeout
popped a toast for an action the user never took. This also contradicted
ChatQuickActionsPayload.Failed, which documents false for the automatic
pass. The caller now passes its origin; only an explicit refresh reports.
2. Generation context was not bound to the target turn. The pass re-read the
session's newest messages while always writing to the task it was handed,
so a turn landing between the completion callback and the detached read
supplied the context for a reply it did not belong to. Worse, a user
typing a follow-up in the second after a reply left the window ending on
a user row, which the old code treated as "nothing to build on" — that
turn silently never got pills. The window is now anchored on the target
assistant message and queried strictly before it.
3. No concurrency or idempotency bound on generation. Refresh stopped
creating a task, so the busy check could not see a pass already running:
two refreshes both returned 202, spent two upstream calls, and raced to
write one row. Nothing bounded generation process-wide either. Adds a
per-session in-flight guard (refresh now 409s on a duplicate) and a
process-wide ceiling; a shed pass still resolves the client placeholder
so no skeleton hangs on work that never started.
4. A new daemon could not safely talk to an older server. The refresh task
discriminator was deleted, so a regenerate task from such a server fell
through to the ordinary chat path: no user message, but the agent would
answer anyway and the server would persist it as a real reply. The field
is restored as a refusal marker only — the task completes empty, which is
the shape the retired pass produced and which that server writes no row
for. Not a restored execution path.
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: Lambda <lambda@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
|
||
|
|
51f44873cc |
fix(labels): always enable resource labels (MUL-5563) (#6225)
* fix(labels): always enable resource labels (MUL-5563) Co-authored-by: multica-agent <github@multica.ai> * docs(labels): clarify resource label rollback safety (MUL-5563) Co-authored-by: multica-agent <github@multica.ai> * docs(labels): correct compat client range (MUL-5563) Co-authored-by: multica-agent <github@multica.ai> --------- Co-authored-by: Eve <eve@multica-ai.local> Co-authored-by: multica-agent <github@multica.ai> |
||
|
|
b530bbad5b |
fix(issues): use a solid tone for the filtered empty-state icon (MUL-5580) (#6223)
The FilteredEmptyState added in #6191 draws its FilterX glyph with `text-muted-foreground/40`, the transparency-as-hierarchy pattern that #6152 removed from the codebase and then guarded with a test. That PR was cut before the guard landed, so the merge reintroduced the one shape the test rejects and main's frontend-test job has failed on every commit since. Switch to `text-faint-foreground`, the solid token the rule names for icons and the one every other empty-state glyph already uses. Co-authored-by: Bohan-J <bohan@devv.ai> Co-authored-by: multica-agent <github@multica.ai> |
||
|
|
13b06f038e |
fix(issues): count agents working in the surface, not the workspace (MUL-5525) (#6191)
* fix(issues): count agents working in the surface, not the workspace (MUL-5525) The "N agents working" chip ran its own workspace-wide `/api/working-agents` read while the list it filters came from the surface's own compiled query. Two definitions of the same question, so on a project page the chip could advertise agents working nowhere near that project and then open an empty list. Every other narrowing the list knows about — status, priority, assignee, creator, label, custom property, date, sub-issue display, the /issues Members/Agents tabs — was invisible to the count for the same reason. Only /my-issues (relation) and the issue-detail sub-issue chip (parent) were narrowed, because those were the two cases the endpoint had grown parameters for. Rather than add a `project_id` parameter and leave the next dimension to be discovered the same way, the count now comes from a `working_agents` facet on the existing issue-table facets endpoint: same scope, same filters, same compiled WHERE clause the rows come from, joined to running issue tasks and grouped by agent. Correct-by-construction instead of correct-by-keeping-two-lists-in-sync. - Facet is disjunctive like every other one: it drops `working_issue_ids` / `working_only`, so the answer is identical whether the filter is on or off and the number does not move when you click the chip. - Facet keys are agent ids, so they pass the same visibility gate as the other workspace-wide agent aggregations — a private or non-allow-listed agent is not disclosed by id, count, or presence. - Gantt keeps a client-side count: its canvas projection (scheduled + dated + showCompleted) cannot be expressed in the Table query spec, so it counts the agents holding canvas rows instead. - The chip is now presentational; `undefined` renders the existing indeterminate label rather than a zero it cannot stand behind. - Removes the MUL-4884 `workingScopeIssues` plumbing, dead since the count moved to the endpoint in MUL-5200, keeping only the Gantt branch that still has a real consumer. Also fixes the empty state that bug dropped you into: a filtered-empty surface claimed "No issues linked — create one" while 41 issues sat behind the filter. Shared filtered-empty state now precedes each surface's own copy and offers to clear exactly the filters it blames. Verified: pnpm typecheck, pnpm test (469 files), pnpm lint (0 errors), go test ./internal/handler (new facet tests cover project scope, status and sub-issue narrowing, filter-independence, and the access gate). Co-authored-by: multica-agent <github@multica.ai> Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(issues): keep the working-agents unknown state unknown (MUL-5525) The chip correctly refused to print a number for an unresolved projection, then handed the hover card `agents ?? []` — so hovering an indeterminate chip read "No agents working right now". That is the same unearned claim this issue is about, made by the one surface with room to spell it out: the label said "—" while the body next to it asserted zero. - `WorkingAgentsHoverContent` takes `readonly WorkingAgentSummary[] | undefined` and distinguishes all three states: `undefined` renders new `agent_activity.unknown_hover` copy, `[]` keeps the empty sentence, a non-empty list keeps the roster. The chip passes its projection through untouched. - The colour tier had the same collapse: unknown wore the neutral tier WITH muted text, which is exactly the "nothing is happening here" tier a known zero wears. `chipAppearance` now takes a `ChipActivity` ("unknown" | "none" | "some") instead of a boolean, so the three cases cannot be written as two, and unknown stays neutral but undimmed. - The sub-issues chip is unaffected: it passes a resolved array and renders nothing at zero, so it never claimed anything either way. Regression tests cover the hover path specifically — reverting either downgrade fails "does not let the hover body downgrade an unresolved projection to zero", "does not dim the chip while the projection is unresolved", and the chipAppearance unknown case (verified by reverting). `WorkingAgentsHoverContent` also gets direct unknown / empty / roster tests, and `chipActivity` one for the three-way split. Verified: pnpm typecheck, pnpm test (469 files), pnpm lint (0 errors). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Co-authored-by: multica-agent <github@multica.ai> --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com> Co-authored-by: multica-agent <github@multica.ai> |
||
|
|
e6610c0831 |
fix(usage): close the per-agent rollup windows so the leaderboard cannot exceed the totals (MUL-5551) (#6194)
The Usage page showed a single agent with 1021.0M tokens under a workspace Tokens KPI of 805.9M for the same 1D window. Both halves read the same rows and disagreed only on the window. parseSinceParamInTZ deliberately returns N+1 calendar days of headroom, and the date-bucketed series (usage/daily, runtime/daily) get trimmed back to -(days-1) client-side before the KPIs and the chart are computed. The two per-agent rollups behind the leaderboard carry no date column, so nothing trimmed them and they kept the full N+1 span: at days=1 that is today PLUS yesterday. One busy agent's two-day total then trivially exceeded the workspace's one-day total. Same defect and same fix already applied to failures/by-agent: switch usage/by-agent and agent-runtime to parseExactSinceParamInTZ. This also realigns the Run time / Tasks KPI tiles, which are sourced from agent-runtime and were therefore a day wider than the Cost / Tokens tiles beside them. Co-authored-by: Eve <eve@multica-ai.local> |
||
|
|
c3cc777acb |
fix(onboarding): add logout escape (#6179)
Closes #3960 |
||
|
|
32ab1e77dc |
fix(agents): drop the runtime badge from agent avatars (MUL-5567) (#6208)
The provider mark overlaid on the avatar's top-right read as clutter: half of it overhangs the disc, so on the agent page, the profile card, and the chat header it looked like something stuck to the avatar rather than part of it — and every one of those surfaces already names the runtime in text a line away. Removes the overlay and everything that existed only to feed it: the `AgentRuntimeBadge` module, the `showRuntimeBadge` prop on `ActorAvatar`, and `useAgentRuntimeProvider`. The presence dot keeps the wrapper, now back to a single overlay. The provider mark on the agents list Runtime column stays — it sits beside its label instead of on an avatar, which is where scanning "which of these run on Codex" is a shape match rather than a read. Co-authored-by: Lambda <lambda@multica.ai> Co-authored-by: multica-agent <github@multica.ai> |
||
|
|
0fdc38704e |
MUL-5149: add agent-generated Chat quick actions (#5766)
* feat(chat): add agent-generated quick actions
Co-authored-by: multica-agent <github@multica.ai>
* fix(chat): preserve mid-response quick-action fences
Co-authored-by: multica-agent <github@multica.ai>
* fix(chat): drop quick actions on empty reply to keep no_response fallback
An actions-only completion — a quick-actions footer with no visible text —
wrote an empty-content assistant message (message_kind=message). Older
Desktop/mobile clients ignore the quick_actions field and render that as an
empty bubble, breaking the MUL-4351 contract that an empty turn always gives
old clients a visible no_response fallback.
Drop the quick actions when the visible body is empty so an actions-only turn
falls through to the visible no_response outcome, and revert the completion
switch to gate the message row on visible text only. Update the completion
test to pin the corrected behavior.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
* feat(chat): generate quick actions via daemon suggestion pass
Replace the in-band runtime-brief instruction with a dedicated post-completion
provider turn: after a direct chat reply finishes, the daemon resumes the same
session with a JSON-only suggest prompt and forwards the raw output on the
complete callback. The server parses it leniently and reuses the existing
sanitize/redact/store/broadcast pipeline; the stripped in-band footer stays as
a fallback for older daemons and pre-upgrade sessions. The footer strip now
covers every chat completion, fixing the intro-turn protocol leak. Adds a
Settings → Chat toggle (client-persisted, default on) that hides the chips.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(chat): deliver quick actions async with skeleton placeholders
Decouple suggestion generation from the turn: the daemon reports completion
immediately (chat:done carries quick_actions_pending as a per-turn capability
signal) and runs the suggestion pass in the background, delivering results
through a new supplement endpoint + chat:quick_actions broadcast. A new turn
on the same session cancels the stale pass. Clients render pill skeletons
under the finished reply until the supplement resolves them (entrance
animation on arrival, 30s safety timeout); older daemons never raise the flag
so no skeleton dangles. Suggest usage re-reports merged totals because
task_usage upserts replace per (task, provider, model). Prompt now asks for
exactly 3 actions.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(chat): make the quick-actions toggle stop generation, not hide pills
The Settings → Chat toggle previously only hid rendered pills while the
daemon kept burning a suggestion call every turn. It now travels with each
send (quick_actions_enabled, absent = enabled for older clients), is stamped
on the chat task (migration 213), forwarded on the claim, and gates the
daemon's suggestion pass at the source — no call, no pending flag, no
skeleton. Existing suggestions stay visible; settings copy now says
'generate' instead of 'show'.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(migrations): renumber quick-action migrations onto current main
Merging current origin/main brought the vcs migrations to their canonical
216-221 prefixes, which collided with the quick-action migrations that were
sitting at 219/220 (backend CI red in
TestMigrationNumericPrefixesStayUniqueAfterLegacySet). Renumber them to the
next unused prefixes:
- 219_chat_message_quick_actions -> 222_chat_message_quick_actions
- 220_agent_task_quick_actions_disabled -> 223_agent_task_quick_actions_disabled
Contents are unchanged; sqlc regeneration produces no drift since the added
columns are independent of the vcs tables.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
* fix(mobile): render async chat quick actions via chat:quick_actions
The daemon generates quick actions in a background pass after the turn
finishes, delivering them on a separate chat:quick_actions event. Mobile
only handled chat:done (which invalidates + refetches an actions-less
message list) and keeps the messages query at staleTime: Infinity, so an
active mobile session never rendered async-generated quick actions until a
manual pull-to-refresh or refocus.
Add applyChatQuickActionsToCache — mirroring web's patcher — which patches
the supplement onto the targeted assistant message in the flat messages
cache, and subscribe to chat:quick_actions in use-chat-session-realtime.
Patch-only (no invalidate), matching web and mobile's cellular
patch-over-invalidate rule; an empty supplement is a terminal no-op. Covered
by chat-ws-updaters.test.ts.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
* fix(chat): cancel in-flight messages refetch before quick-actions patch
The chat:done invalidate can leave a messages refetch in flight that read the
assistant row before the daemon persisted the quick actions. If that refetch
resolves after the chat:quick_actions setQueryData patch, it overwrites the
freshly-patched actions with an actions-less row. Both message caches are
staleTime: Infinity, so the overwrite never self-heals and the actions vanish
permanently (MUL-5149, Howard review).
applyChatQuickActionsToCache now awaits cancelQueries for the affected caches
(web: flat messages + messagesPage, mobile: flat messages) before patching, so
a stale in-flight refetch is cancelled and cannot land after the patch. Cancel
must precede setQueryData because cancelQueries reverts to the pre-fetch state.
WS handlers call it via `void` (fire-and-forget).
Adds an active-query race regression test on both web and mobile that holds a
refetch open across the supplement and asserts the patched actions survive;
verified to fail without the cancel.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
* feat(chat): quick-actions refresh/regenerate + review hardening (MUL-5149)
Co-authored-by: multica-agent <github@multica.ai>
* fix(chat): address quick-actions re-review (MUL-5149)
- Ack alignment: refresh request carries the target message_id; server
atomically confirms it is still the session's latest turn (409 stale
otherwise), so the client marker always matches the resolving
chat:quick_actions — no response reconciliation. Adds a regression test.
- Converge the pending marker on every terminal path: HandleFailedTasks
(sweeper/orphan) now resolves it, and the daemon reports a failed supplement
so FailTask resolves it instead of leaving a completed-but-unresolved task.
- Timeout fallback now clears the real query state (useQuickActionsPendingTimeout)
instead of a component-local flag that only masked the UI; drop the skeleton's
and pill row's local timers.
- frontend-test type-scale: text-xs -> text-caption. Strip EOF blank line.
Co-authored-by: multica-agent <github@multica.ai>
* fix(chat): close quick-actions refresh races and failure feedback (MUL-5149)
Third-round review of the refresh button surfaced three issues; all three
are addressed here.
§1/§2 Session-busy race + concurrent-refresh double-spend: a newer reply
that is queued/running but whose assistant row hasn't landed leaves the old
turn as latest-persisted, so the stale check passes and the regen resumes
the newer provider state — attaching suggestions to the wrong turn. And two
concurrent refreshes each enqueue a quota-spending pass. Add
HasActiveChatTaskForSession and refuse a refresh (ErrChatQuickActionsBusy →
409) whenever the session has any task in flight, checked under the same
session lock as the enqueue so no sibling insert slips past.
§3a Timeout re-arm on surface switch: the pending marker now carries an
absolute expires_at deadline instead of a per-mount timer, so switching
between the floating window and the chat tab resumes the same deadline
rather than restarting a fresh 30s window each remount.
§3b Generation failure masked as success: runChatSuggestPass now returns ok
so an explicit refresh distinguishes a failed pass (didn't start / didn't
complete / timed out) from a completed-but-empty one. On failure the regen
task reports failure, resolveFailedRegenerateQuickActions broadcasts a
FAILED chat:quick_actions, and the client resolves the spinner AND toasts
"couldn't refresh" instead of silently stopping on unchanged pills.
Co-authored-by: multica-agent <github@multica.ai>
* fix(chat): count deferred tasks in refresh busy check; solid refresh icon tone (MUL-5149)
Two re-review blockers on
|
||
|
|
5e3b7a8c37 |
feat(issues): Issue Quick Actions — preset agent + prompt, one-click from the sidebar (MUL-5465) (#6132)
* feat(issues): Issue Quick Actions — preset agent + prompt, one-click from the sidebar (MUL-5465)
Preset "who to call and what to say" once in Settings, then trigger it from
any issue's sidebar with a single click.
Running one is NOT a new dispatch path. The server renders the prompt, posts
a `quick_action` comment carrying the target's mention markup, and hands off
to the existing comment -> mention -> task trigger. Permission
(canInvokeAgent), attribution, squad-leader routing, the execution log, and
pending-task coalescing are inherited rather than reimplemented — the
MUL-3375 lesson about four drifting copies of one trigger decision.
Three things the UI has to be honest about, because the backend already
decided them:
- One pending task per (issue, agent) is a DB invariant
(idx_one_pending_task_per_issue_agent). A second click against a busy agent
starts no new run; the comment merges into the pending task. The toast says
"Added to Lambda's current run", not "Lambda started working".
- An offline target defers rather than fails; the run reuses the existing
dispatch.ReasonCode vocabulary instead of inventing one.
- Private agents are deny-by-default with no admin bypass. The sidebar filters
by the caller's own invoke verdict, so a dead button is never rendered, and
a direct API call still 403s with `invocation_not_allowed`.
Visibility is DERIVED from the bound agent's permission_mode on every request,
never stored — so it cannot drift after someone flips an agent between private
and public_to. Binding a workspace action to a private agent is allowed (the
alternative pressures people into making agents public just to satisfy a
config constraint) but the settings form says so at bind time, and the
catalog badges it. The target's name is withheld from callers who cannot see
it, so the response never discloses a private agent's existence.
Prompt templating is flat substitution over a closed whitelist. No
conditionals, loops, or filters — the agent already reads the whole issue, so
natural language is the control flow. One optional runtime input ({{input}})
keeps a single action from splitting into five near-identical variants; both
directions of the input/{{input}} agreement are rejected at write time so a
typo can never land silently.
Surfaces: sidebar (top 5, rest behind More), the `/` menu in the comment
composer (inserts the server-rendered body to edit before sending), and
Alt-click for the same hand-off from the sidebar.
Migrations 234-236: quick_action table, its listing index (CONCURRENTLY, own
file), and comment.type + comment.quick_action_id.
Co-authored-by: multica-agent <github@multica.ai>
* refactor(issues): simplify quick action permissions to a stored public/private intent (MUL-5465)
Replaces the derived four-value visibility model with a two-value choice made
at creation, and collapses permission handling to a single check.
The old model computed visibility per request from the bound agent's
permission_mode and used it to filter the sidebar. That filtering was the
problem: two people on one issue saw different sidebars with nothing to
explain the difference, which is harder to debug than a button that tells you
why it refused. It also required the list endpoint to run an invocation-target
query per action per request.
Now:
- `visibility` is stored INTENT — 'public' or 'private' — chosen up front.
- A public action must bind a target every workspace member can invoke
(public_to carrying a workspace target), enforced at write time. So a
public action is runnable by construction and dead buttons are eliminated
at the source rather than filtered out later.
- A private action allows any target and is returned only to its creator.
That scoping is what the field MEANS, not a permission check.
- Permission is checked in exactly one place: RunQuickAction. A refusal is a
structured 403 the client renders as one dialog. The dialog does not
distinguish "no permission" from "the binding drifted" — the person
reading it takes the same next step either way, and the person who can fix
it looks at settings.
Removed: can_run, position + manual ordering (settings sorted by usage while
the sidebar sorted by position — one list, two orders), the derived
visibility_broken flag, the runnable_only projection and its second cache
entry, target_name redaction, the alt-click composer hand-off (the `/` menu
covers insert-then-edit and is discoverable), and the sidebar_limit response
field (now a shared constant).
Ordering is use_count DESC everywhere. Settings shows the target's current
reachability as plain metadata ("Nova · private"), so a public action pointing
at a now-private agent reads as visibly wrong without a bespoke error state.
The tradeoff — no active signal when that drift happens — was accepted
deliberately: drift is rare and the failure is loud at click time.
Migration 234 is edited in place rather than layered, since the PR is
unmerged and the table has never been deployed.
Co-authored-by: multica-agent <github@multica.ai>
* refactor(issues): drop quick action variables and runtime input (MUL-5465)
V1 ships a preset prompt sent verbatim, triggered from the sidebar or the `/`
slash command. Two features are removed and one guard is kept.
Runtime input goes because `/` already covers it. Typing `/code review` drops
the rendered body into the composer, where any part of it can be edited before
sending — strictly more flexible than one fixed field, and the field was
specified before `/` was in V1. Two UIs for one need.
Variables go because none of them passed their own test. The rule was that a
variable earns its place only if it changes what the agent ATTENDS TO, not what
it KNOWS. Checked one by one — {{issue.title}}, {{issue.identifier}},
{{issue.url}}, {{user.name}}, {{date}} — the agent already has every one from
the issue context and from the fact that the comment is authored by the person
who triggered it. They were inherited from autopilot's title template rather
than justified.
The REJECTION survives the feature: any `{{...}}` is refused at write time,
naming the offending token. Someone carrying the habit over would otherwise
have `{{issue.title}}` rendered literally into an agent's instructions and
never notice — the exact silent-typo failure the whitelist existed to prevent.
The check is a fraction of the interpolation engine it replaces and keeps the
door open to enabling variables later without touching stored data.
Removed: 4 columns (input_enabled/label/placeholder/required),
renderQuickActionPrompt + the variable whitelist + quickActionIssueURL, the
two-way {{input}} agreement logic, the run/render `input` parameter, the
variable insert chips, the entire "Ask for input on click" block, and the
sidebar's Popover branch — every row is now a plain button. The settings
dialog drops from six field groups to four.
Migration 234 is edited in place rather than layered, since the PR is unmerged
and the table has never been deployed.
Co-authored-by: multica-agent <github@multica.ai>
* refactor(settings): align Quick Actions with the Labels/Properties list, then fix what the UI review found (MUL-5465)
The tab used a bespoke card list while its two siblings — Labels and
Properties — share one table layout. These three are the workspace's catalog
of small named things and should read as one surface, so Quick Actions now
uses the same structure: search + primary action row, bordered card, responsive
column grid that collapses to stacked rows under `md`, and an overflow menu
instead of a row of icon buttons. Columns are Name / Runs as / Who / Used /
Updated. The tab joins the max-w-5xl group for the same reason.
A UI review pass over the result found five things, four of which are fixed
here:
- The visibility chooser communicated selection through border and background
only, so a screen reader announced both options identically. Added
aria-pressed.
- The editor dialog was max-w-xl while both siblings use sm:max-w-lg, and the
unprefixed cap applied at every breakpoint.
- The empty-state hint diverged from the Properties tab it was copied from
(text-sm and no max width vs mx-auto max-w-sm text-xs).
- Two hardcoded `text-amber-600 dark:text-amber-400` usages replaced with the
`text-warning` semantic token, per the repo's design-token rule.
Also fixed a signal-quality bug the review surfaced: the usage column
highlighted anything with use_count 0, so an action was flagged the instant it
was created. Staleness now means "has had time to be used and wasn't" — 90
days since last use, or 90 days since creation for one never used.
Not fixed here: the overflow trigger is size-7 (28px), under the 44px touch
floor. Labels and Properties use the identical size, so changing only this tab
would break the consistency this commit exists to create; it needs one pass
across all three.
Co-authored-by: multica-agent <github@multica.ai>
* fix(issues): drop the quick_action comment type, widen the mention guard, harden the slash race (MUL-5465)
Second review round on PR #6132. All four remaining findings.
**Comment type removed entirely (#2 blocker + #3).** Adding a `quick_action`
type meant dropping and re-adding comment_type_check, and re-adding a CHECK
holds ACCESS EXCLUSIVE on `comment` for a full table scan — a read/write stall
on one of the hottest tables in the product, every deploy. It was also
forgeable: `type` is client-supplied on POST /comments, so any member could
post type='quick_action' and have an ordinary comment render as an action
audit record with its body collapsed out of view.
Both go away by not having the type. A quick action now posts an ORDINARY
comment marked with `quick_action_id`, and the collapsed card keys off that id.
There is no request field for it, so the marker cannot be forged, and the
migration is a bare nullable ADD COLUMN — metadata-only and instant. Verified
against a fresh database: comment_type_check is untouched.
The generic comment endpoint now also validates `type` instead of letting the
DB CHECK reject it. An unknown type surfaced as a 500 on a constraint
violation, which reads as a server fault for plainly bad input; it is a 400
now. `status_change` and `system` are excluded from what a client may author —
claiming those would be forging system narration.
**Member mentions rejected too (#1).** The first pass allowed
`mention://member/...` in prompts on the reasoning that it "only renders a
link". That was wrong: notification_listeners.go adds member mentions to the
recipient set and creates an inbox item, so a saved prompt pinged that person
on every single click. Only `mention://issue/...` reaches nobody and stays
allowed.
**Slash race, properly this time (#4).** The previous fix checked only that the
range still started with "/". Rewriting `/review` into `/fix` while the request
was open passed that check, and the stale response overwrote the new command.
The exact original text is now captured and compared; if the command was
edited, moved, or removed, the pick is abandoned rather than inserted
somewhere wrong. Adds the three regression tests the review asked for:
delayed resolve, rejection, and edit-during-flight.
Co-authored-by: multica-agent <github@multica.ai>
* fix(issues): stop the quick action card repeating its own prompt, and insert the `/` body as markdown (MUL-5465)
Two fixes, one reported and one found while verifying it.
**The card printed the prompt twice.** The collapsed header previewed the
prompt's first line, and expanding showed the mention line plus that same
prompt again. The header now identifies WHICH action ran — "Code Review via
Lambda" — which is both non-redundant and something the body never told you:
the prompt text alone does not say which action produced it. This is what the
original design called for; previewing the prompt was the implementation
drifting from it.
When the action cannot be resolved — deleted, or another member's private one
and so absent from this viewer's catalog — the header falls back to the
prompt's opening line, which is the previous behaviour.
**The `/` menu inserted its body as literal text.** insertContentAt was called
with a plain string, so Tiptap treated the server-rendered markdown as text
rather than parsing it. The mention never became a node; it serialised back out
with escaped brackets (`\[@Lambda\](mention://agent/…)`) and rendered as raw
markup in the thread. Passing `contentType: "markdown"` — the same option the
description editor already uses — parses it properly. Found by reading the
comment rows while checking the first fix: one had escaped brackets and no
quick_action_id, which is what a slash-inserted comment looked like.
The existing async test now asserts the contentType, so the option cannot be
dropped again without failing.
Co-authored-by: multica-agent <github@multica.ai>
* docs(issues): correct the stale quick actions sidebar comment (MUL-5465)
The comment still claimed the section renders nothing when no action is
runnable by the member. Permission filtering was removed several rounds
ago -- the list is deliberately unfiltered and a refusal is explained at
run time -- so the comment described behavior that no longer exists.
Co-authored-by: multica-agent <github@multica.ai>
* refactor(settings): cut the quick action dialog's helper copy in half (MUL-5465)
The dialog had five blocks of explanatory prose around four fields, and
three of them wrapped to two lines, so the form read as a paragraph with
inputs in it.
Each helper now earns its line or loses it:
- The header explained the implementation ("keeps the same history,
permissions, and execution log as an @mention") -- an architecture note
the person creating an action does not need. Reduced to the one fact
they do: it posts a comment.
- "Who can use it" is a question, so the hints answer it as noun phrases
("Everyone in the workspace" / "Only you") instead of restating the
verb. Both now fit one line, which also makes the two cards the same
height -- the shorter one used to sit in dead space.
- The target and prompt hints front-load the constraint rather than
burying it mid-sentence.
70 words to 32 across the dialog, with no fact dropped. Field spacing
goes 4 -> 5 so the gap between groups beats the gap inside one.
Co-authored-by: multica-agent <github@multica.ai>
* refactor(issues): render a quick action comment as an ordinary comment (MUL-5465)
The card had a collapsed one-line header that expanded to reveal the
prompt, on the theory that repeated runs of the same action would bury
the discussion. That was solving a problem the feature does not have:
prompts are a sentence or two, the header restated what the body already
said, and the disclosure only put a click between the reader and the
text.
A quick action posts a real comment through the real mention path, so
the honest rendering is the one every other comment gets. Drops
QuickActionCommentBody, its query for the action catalog, and the
now-orphaned quick_action_ran_via string in all four locales.
quick_action_id stays on the comment: it is provenance, and it was never
the reason the card looked different -- keying the special rendering off
it is what is going away, not the record itself.
Co-authored-by: multica-agent <github@multica.ai>
* fix(settings): use the faint tone token for the empty-state icon (MUL-5465)
main added apps/web/app/text-contrast.test.ts, a guard that rejects
transparency standing in for a text tone. The empty-state Zap used
text-muted-foreground/60, which is exactly the pattern it forbids: an
alpha-dimmed tone lands at a different contrast on every surface it is
composited over, so it cannot be reasoned about the way a token can.
text-faint-foreground is the token the guard names for icons and glyphs.
The rule arrived on main after this branch's last merge, so local runs
never saw it -- CI tests the merge commit, which is why only CI caught
it. Merged main first so the branch is checked against the same rules.
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: Lambda <lambda@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
|
||
|
|
f0110da555 |
feat(inbox): mark a notification unread from the row context menu (MUL-5496) (#6137)
The inbox auto-marks a notification read the moment it is selected, so
"opened" and "handled" were the same signal — a row you glanced at and
meant to come back to was gone from the unread count with no way back.
Right-click any inbox row for a shared context menu: Mark as read /
Mark as unread, plus Archive (Unarchive in the archived view).
- POST /api/inbox/{id}/unread + MarkInboxUnread query, publishing
inbox:unread. Item-scoped, mirroring mark-read: the list renders one
row per issue carrying that group's newest item, so flipping the whole
group would resurrect siblings the user already dealt with.
- useMarkInboxUnread patches both lists optimistically and re-pulls the
cross-workspace unread summary on settle.
- One shared menu per list rather than a Base UI root per row (the same
shape IssueContextMenuProvider uses): only one is ever open, and a
per-row root would unmount with its menu when the row scrolls out of
the virtualized viewport.
- The read toggle is main-view only — archived rows deliberately render
as read and the unread count excludes them, so a toggle there would
report success and change nothing on screen.
- Parking the row that is currently open holds the auto-read effect off
that one item while it stays selected; re-opening it later marks it
read again.
- Mobile subscribes to inbox:unread so the unread dots agree across
clients.
Co-authored-by: Lambda <lambda@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
|
||
|
|
f73250654f |
fix(issues): stop blaming permission for an unresolved mention target (MUL-5548) (#6190)
* fix(issues): stop blaming permission for an unresolved mention target (MUL-5548) A well-formed but wrong agent mention UUID comes back as `invocation_not_allowed`, and the UI rendered that as "You don't have permission to use this target". The server never claimed a permission cause: `invocation_not_allowed` is deliberately ambiguous so a blocked reason cannot confirm that a private agent in another workspace exists (dispatch/reason.go). The copy turned a typo into an access-control investigation — GH #6181 hit exactly this on a squad handoff. - Reword the blocked-trigger labels in all four locales to name both possibilities instead of asserting permission, and record in blocked-trigger-copy.ts why a label must not narrow the wire code. - Report a mention id that is not a valid UUID at all (`mention://agent/-`) as `target_unavailable`, matching the squad branch beside it and the autopilot admission path. A non-UUID names no entity in any workspace, so it conceals nothing and must not be blamed on invoke permission. The well-formed-but-unresolved case is unchanged and still shares `invocation_not_allowed` with a private agent — the enumeration boundary this issue asked us to move stays exactly where it is. Also refresh the multica-mentioning skill: the mention path gates on `canInvokeAgent`, not `canAccessPrivateAgent` (split in MUL-3963), and the skill now tells agents to check a mention UUID against the roster before touching any visibility setting. Co-authored-by: multica-agent <github@multica.ai> * docs(skills): correct the mentioning skill's "silent no-op" framing (MUL-5548) Review nit on #6190: the source map still titled its table "Guards that make a valid mention a silent no-op", but a parsed mention is never silently dropped — it is either blocked with a reason_code or folded into a running task. Several rows in the same table also pointed at comment.go:14xx line numbers that had drifted. - Retitle to "Guards and outcomes for a parsed mention" and split the outcome into its own column, so each row states the reason_code it produces. - Replace the drifted line numbers with stable search anchors, matching the convention the newer rows in this file already use. - Correct two rows that were wrong, not just stale: archived / no-runtime targets are blocked (target_unavailable, runtime_offline), and an already-pending target is a coalesce/defer fold, not a skip. - Apply the same correction to SKILL.md, where the frontmatter and the "What does NOT happen" section told agents an already-pending mention was dropped. It is folded into the running task and still gets read — worth being exact about, since believing otherwise invites a re-post. Co-authored-by: multica-agent <github@multica.ai> --------- Co-authored-by: Bohan-J <bohan@devv.ai> Co-authored-by: multica-agent <github@multica.ai> |
||
|
|
9c90327ce4 |
fix(ui): replace the text-transparency ladder with solid tones (MUL-5452) (#6152)
* fix(ui): replace the text-transparency ladder with solid tones (MUL-5452) Hierarchy was being expressed with transparency: 152 call sites of text-muted-foreground/30..80, 26 of text-foreground/60..90, plus a handful on destructive and current, and a few written as a standalone opacity-* utility instead of a slash alpha. On light surfaces every muted variant failed WCAG AA - /80 reached only 3.78:1 and /40 sat at 1.80:1, below even the 3:1 floor for non-text - because the palette had no step below --muted-foreground, so transparency was the only tool for 'quieter than muted'. The palette now has that step, and it is deliberately non-text: --faint-foreground clears 3:1 (WCAG 1.4.11) on every surface for icons, chevrons, separator glyphs and empty-cell em dashes. There is no room for a third readable text tone - AA caps a lighter text tone 0.018 L away from muted - so text keeps exactly one floor, --muted-foreground. Also fixes text-destructive/70 on a cron error message, which was 3.61:1. This branch changes zero font sizes. The sub-12px half of the issue is MUL-5451's (#6136); keeping the two apart is what makes this one reviewable on its own after #6108 was reverted. apps/web/app/text-contrast.test.ts replaces muted-foreground-contrast.test.ts rather than sitting beside it. It recomputes the floors from tokens.css instead of hard-coding ratios, and fails the build on all four ways to spell the defect: /70, /[0.5], /[50%], and a detached opacity-* in the same class string. Transparency behind hover/focus/disabled stays allowed - the resting state carries the contrast obligation and it is solid. Co-authored-by: multica-agent <github@multica.ai> * fix(ui): correlate transparency across a whole class expression Review found two ways past the guard, both real. A per-literal check cannot see cn("… text-muted-foreground", suppressed && "opacity-60") - one element wearing a colour in one argument and a dim in the next. That split shape is the common one, and it was hiding live violations: the comment trigger chips dimmed aria-pressed label text to 2.55:1 while the sweep reported clean. The second was my own exemption. Accepting any state word within 80 characters let "text-muted-foreground hover:text-foreground opacity-50" through, because the hover: belongs to the colour, not to the opacity. The detector now correlates across a whole cn() call or template literal, splits it into segments that each carry their own condition, and exempts only on the variant prefix the opacity utility itself carries or on the condition governing its segment. Segment splitting is what keeps ${disabled ? "opacity-60" : ""} exempt while flagging its neighbours. Fixed what that surfaced: three trigger-chip controls (the suppressed state is already carried by the avatar's own grayscale, the sentence wording and a solid muted step), a disabled-skill icon chip, and the diff gutter marker. tab-bar's isDragging is exempt - a drag ghost is an in-flight gesture, the same category as :active. The detector now has its own table of thirteen cases. Every hole so far has been silent, so the shapes it must and must not catch are pinned next to the reason each one exists. Co-authored-by: multica-agent <github@multica.ai> * test(ui): cover the faint tone in the cn() merge regression test text-faint-foreground is a new text-<x> class, which is the exact shape that silently broke the sidebar labels in #6108: tailwind-merge cannot tell a size from a colour and drops one of them. The token does resolve correctly today - verified both orders against a size step and against another colour - but the test that exists to catch this was not covering it, so the guarantee rested on nothing. Co-authored-by: multica-agent <github@multica.ai> --------- Co-authored-by: Lambda <agent@multica.ai> Co-authored-by: multica-agent <github@multica.ai> |
||
|
|
ba43b11aee |
feat(agents): show which runtime backs an agent (#6185)
* feat(agents): show which runtime backs an agent An agent's provider was only discoverable by reading text: a Runtime column on the agents list, a meta line on its page. `ProviderLogo` has existed for every provider we support, but only runtimes surfaces ever used it. Two additions, split by what each surface is for: - The agents list Runtime column gets the provider mark before its label, so scanning "which of these run on Codex" is a shape match rather than a read. - Identity surfaces get `showRuntimeBadge` on the avatar — the agent's own page, its profile card, the chat session header. The badge is derived from `runtime_id` on every render rather than stored, so moving an agent between runtimes moves its mark, and it renders nothing when the provider can't be resolved: a mark naming the wrong runtime is worse than no mark. It sits at the avatar's top-right, opposite the presence dot, and the two are designed to coexist — they answer different questions (can it take work right now, vs what backs it). Below 32px it no-ops: a badge gets ~40% of the diameter, and provider marks are detailed artwork that turns to mush at the ~8px a 20px picker row would give it. That is exactly where the status dot lives, so in practice the two rarely want the same avatar anyway. Co-authored-by: multica-agent <github@multica.ai> * fix(agents): sit the runtime badge on the avatar's rim, not over its face Flush to the bounding box corner reads correctly on a square and wrong on a circle: at 45° the edge has already curved ~29% of the diameter away from that corner, so the badge landed well inside the disc and masked the avatar. Offset it outward by (badge radius − rim inset) so its centre lands on the rim and about half of it overhangs. Co-authored-by: multica-agent <github@multica.ai> --------- Co-authored-by: Lambda <lambda@multica.ai> Co-authored-by: multica-agent <github@multica.ai> |
||
|
|
d359ce7b90 |
feat(agents): pick an emoji as the agent avatar (MUL-5534) (#6173)
* feat(agents): pick an emoji as the agent avatar (MUL-5534) The server has always seeded a new agent with a random `emoji:<char>` avatar and every renderer already parsed the marker, but the only way a user could change one was to upload an image. Clicking an agent avatar now opens a picker offering both: the image upload it always had, plus the emoji set the product hands out, with the full searchable picker one click behind it. Emoji stays opt-in per call site (`onEmojiSelected`), so user, workspace, and squad avatars keep their click-straight-to-upload behavior. Co-authored-by: multica-agent <github@multica.ai> * fix(agents): lock the avatar while a pick saves, single-owner failures Two problems from review of the emoji picker: An edit caller PATCHes on every pick, and the emoji path never entered `busy`, so a second pick could be started while the first was still in flight. The two writes are last-one-to-arrive-wins on the server, which means the user's newer choice can lose to the older one and stick — the invalidate that follows only converges on whatever the server kept. The callback now runs inside `busy`, so the trigger is disabled until the save settles. Persistence failures were reported twice on the agent detail page: `handleUpdate` toasts and then rethrows so autosave can render a failed state, and the control toasted the same error again. `persistedByCaller` makes the ownership explicit — a caller's rejection is theirs to report, and the upload this control runs itself stays the one failure it owns. That double toast predated the emoji path on the image flow too, and is fixed for both. Regression tests cover the pending lock (deferred promise), and that neither a rejected emoji save nor a rejected image save toasts here while an upload failure still does. Co-authored-by: multica-agent <github@multica.ai> --------- Co-authored-by: Lambda <lambda@multica.ai> Co-authored-by: multica-agent <github@multica.ai> |
||
|
|
55926c072d |
refactor(views): make the sidebar Discord promo a footer row (MUL-5454) (#6138)
* refactor(views): restyle sidebar Discord promo as a nav row The dismissible Discord promo was drawn heavier than the navigation above it: border + fill + saturated brand icon + a permanently visible close button. Light mode rendered the fill at 1.04:1 against the sidebar (border 1.10:1), so both structural layers read as a smudge; dark mode inverted it into a bright ring around a dim box. The 11px description failed WCAG AA at 4.17:1 and was the only hardcoded 11px in the package. Reshape it to match SidebarMenuButton metrics (h-8 / rounded-md / gap-2 / text-sm) with no resting border or fill, drop the description line, render the mark in currentColor, and add the ArrowUpRight the Help menu already uses for outbound links. The dismiss button gains a 24px hit area, a focus ring, and hover/focus reveal with a coarse-pointer fallback. Co-authored-by: multica-agent <github@multica.ai> * refactor(views): merge the Discord link into the help footer strip The footer stacked a full-width Discord row above a right-aligned help trigger, leaving the trigger's leading two-thirds empty and the promo isolated in its own band. Put both on one strip: the link takes the free leading space and `justify-end` keeps the trigger right-aligned once the link is dismissed. Footer height drops 68px -> 48px. Sharing a 224px strip leaves 128px for the label, and the external-link arrow plus the dismiss button together overflow it (label is 109px in en and 125px in zh), so the two cannot coexist at the default sidebar width. Drop the arrow rather than the dismiss button: dismissal is a user-facing capability while the arrow is only a hint the Discord mark already carries. Measured in the running app -- no locale clips at the default width. Co-authored-by: multica-agent <github@multica.ai> --------- Co-authored-by: Lambda <lambda@multica.ai> Co-authored-by: multica-agent <github@multica.ai> |
||
|
|
a9a4a3d638 |
feat(issues): badge resolved threads on the comment rail's preview card (MUL-5543) (#6184)
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: Lambda <lambda@multica.ai> Co-authored-by: multica-agent <github@multica.ai> |
||
|
|
67744d413d |
refactor(skills): imported-origin marker uses Download, not Sparkles (#6187)
* refactor(skills): imported-origin marker uses Download, not Sparkles Follow-up to #6177: with the identity mark unified on SkillIcon, the only Sparkles left on the skill detail page was the imported-origin marker. Swap it for Download — the glyph the import affordances (list-toolbar import button, URL import dialog) already use — so origin and the action that created it share one visual language, and Sparkles disappears from skill surfaces entirely. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(skills): detail origin marker mirrors the list's three-way source split The Download swap put an import glyph on manually created skills. Align the detail identity strip with the list's Source column: manual origin gets Pencil and the "Created manually" wording (was "Workspace"), runtime keeps HardDrive, imported sources keep Download. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
d58dab0757 |
fix(issues): stop the sub-issues shimmer chip clipping descenders (#6182)
* fix(issues): stop the shimmer chip clipping descenders The sub-issues "N agents working" chip paired animate-chat-text-shimmer with leading-none. The shimmer paints glyphs via background-clip: text, and the background only covers the line box — with the line box squeezed to 1em, descenders (g, y, p) fell outside it and rendered transparent. Drop leading-none; the pill height is governed by the avatar stack, so the visual size is unchanged. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(issues): same descender clip in the per-row activity indicator Same leading-none + background-clip:text pairing as the sub-issues chip; "Working" lost its g descender in inbox and issue rows. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
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> |