mirror of
https://github.com/multica-ai/multica.git
synced 2026-08-03 11:10:23 +02:00
agent/j/cfbae4bd
4457 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
f920f81bd3 |
fix(daemon): address review on the Linux full-access default (MUL-5578)
Docs, both must-fix: - The Chinese page rendered the product concept as `Agent`; the repo glossary in developers/conventions.zh.mdx mandates 智能体. Fixed all nine occurrences. ja/ko already used エージェント / 에이전트 and needed no change. - The security page asserted without qualification that every task runs with the daemon user's full permissions and that the Codex filesystem sandbox is always off. codexSandboxPolicyForWindows still keeps workspace-write when a user explicitly opts into a native windows.sandbox, so an authoritative security page contradicted the code. It now states that Multica makes no filesystem-sandbox guarantee, names the Windows opt-in as the one current exception, and says which combinations sandbox anything is a compatibility detail that moves with tool versions. All four locales updated, plus the historical callout which now says Linux matches the macOS/Windows *default*. Both stale comments from the non-blocking notes: - prepareCodexHome no longer claims to assume workspace-write + network_access; it pins GOOS=linux, which now resolves to danger-full-access. - ensureCodexSandboxConfig's warn-level logging is no longer described as a macOS-only fallback; it fires for every danger-full-access resolution. Adds TestCodexTaskShellEnvInheritsRealHome at the daemon env-assembly layer: HOME and the XDG base dirs must reach a Codex task's shell tools from the inherited daemon environment. Verified it fails when that pass-through breaks. It guards the pass-through, not runTask's decision not to inject a HOME of its own — that decision is inline in runTask and has no unit seam. Co-authored-by: multica-agent <github@multica.ai> |
||
|
|
52e3a2cb45 |
docs: document the agent execution security model (MUL-5578)
The docs had no page describing what a task can reach on the machine that runs it — the sandbox posture was only discoverable from source. Adds one, stating plainly that tasks run with the full permissions of the daemon user and that isolation must come from a dedicated Unix user, container, or VM. Also separates what Multica genuinely isolates (per-task workdir, task-scoped CODEX_HOME, agent+task-bound API tokens) from what is not a boundary (the coding tool's own sandbox and approval settings), and links it from step 5 of the self-host quickstart, where the daemon is first installed. Co-authored-by: multica-agent <github@multica.ai> |
||
|
|
8143c18491 |
feat(daemon): default Linux Codex to danger-full-access on the real HOME (MUL-5578)
Linux Codex tasks ran under the `workspace-write` Landlock sandbox with a generated per-task HOME: the daemon rewrote HOME/XDG_*/npm_config_cache into `<envRoot>/home`, symlinked a hand-maintained allowlist of seven credential paths back into it, and granted that directory as a `writable_roots` entry. That mechanism could not converge. Any host CLI outside the allowlist (aws, kubectl, gcloud, glab, rclone, cargo, …) started every task as if unconfigured even though it works in the daemon user's shell, and three open issues asked to extend it in three incompatible directions (#5636, #5573, #3867). The containment it bought was also narrower than it looked: workspace-write restricts writes only — reads and network were already unrestricted, and `.ssh` was seeded into the task home — so credentials under the real HOME were readable and exfiltratable regardless. Linux now runs `danger-full-access` on the daemon user's real HOME and inherited XDG environment, matching macOS and Windows. The task filesystem boundary is the boundary the daemon itself runs inside (VM, container, or dedicated Unix user), which is what the other providers already assumed — Claude Code runs with `--permission-mode bypassPermissions` today. This also removes the split-brain the mechanism could enter: the task-HOME decision read the platform default only, so a `-c sandbox_mode=danger-full-access` override (which passes arg filtering and wins over config.toml) left a task running unsandboxed while the daemon still redirected HOME and emitted writable_roots for a sandbox that was not in effect. With no HOME rewrite there is only one environment contract left to disagree about. Removed: prepareTaskHome, prepareCodexSandboxHome, TaskHomeEnv, Env.TaskHome, both seed allowlists, and the now-unfed WritableRoots plumbing through codexSandboxPolicy / CodexHomeOptions. Env roots created by older daemons keep working; their leftover `home/` directory is simply ignored and reclaimed with the env root. Task-scoped CODEX_HOME is untouched — it is managed Codex state, not the Unix HOME. macOS, Windows, and non-Codex providers are unchanged. Co-authored-by: multica-agent <github@multica.ai> |
||
|
|
f4bf8e2c36 |
fix(realtime): bound inbound WebSocket message size (MUL-5569) (#6222)
The client-facing realtime hub upgraded a connection and read from it without ever calling SetReadLimit, so gorilla buffered a whole inbound message in memory before any application-level check ran. A fragmented message with interleaved pong frames keeps refreshing the read deadline, so a single connection could grow that buffer without bound and OOM the process, taking every workspace on the instance down with it. Set a 64 KiB limit — matching the daemon hub, three orders of magnitude above the largest legitimate frame — immediately after the upgrade rather than in readPump: the token auth path reads its first frame before the caller is authenticated, so a limit installed any later leaves that read unbounded. Over-limit closes get their own counter on both paths so the breach stays visible instead of blending into ordinary churn. Closes #6210 Co-authored-by: Bohan-J <bohan@devv.ai> 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> |
||
|
|
abdfd3e28c |
refactor(skills): make the brief's skill list a names-only index (MUL-5529) (#6207)
* refactor(skills): make the brief's skill list a names-only index (MUL-5529)
Step 3 of MUL-5529. Every runtime CLI discovers the SKILL.md files the daemon
writes and builds its own listing from their frontmatter — verified against 11
locally installed CLIs plus official docs for 5 more. The brief's copy of those
descriptions was therefore the same routing signal paid for twice: measured on
a real task, `## Skills` was 13,295 chars, 40% of the entire brief, against a
16,304-char CLI listing of the same 28 skills.
Now 850 chars for that same set — roughly 3,100 tokens back per brief.
The index itself stays. It is the one skill listing Multica controls; each
CLI's own listing is theirs, and its format — or its existence — can change
with any release.
Three changes:
- Descriptions dropped from the `## Skills` entries.
- The per-provider branch is gone. Its fallback told providers outside a
hardcoded list to read `.agent_context/skills/`, but the only providers
that ever reached it were grok and traecli, whose files are written to
`.grok/skills` and `.traecli/skills` and which discover natively. The
pointer was wrong for everyone it addressed, so removing the branch
deletes the bug rather than relocating it. This closes MUL-5537.
- issue_context.md and its quick-create / autopilot variants no longer render
`## Agent Skills`. That copy duplicated the brief once both were
names-only, and nothing ever read it: no prompt references the path, and
grepping the server finds only the writer. `.agent_context/skills/` had the
same fate for hermes (issue #5242). Quick-create, previously skipped in the
brief and served only by that unread copy, now gets the brief section like
every other kind — one index, one place.
Not included: skills carrying `disable-model-invocation` are still written to
disk for every provider. The plan assumed that key needed provider-specific
handling for everything except claude; probing the installed CLIs shows 9 of 11
honor it, and only opencode and hermes do not. The remaining question is
narrow and a genuine product tradeoff — withholding the file honors the
author's intent but also removes explicit invocation — so it is left to a
separate decision rather than folded in here.
Co-authored-by: multica-agent <github@multica.ai>
* docs(skills): align stale comments with the names-only brief contract (MUL-5529)
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: Bohan-J <bohan@devv.ai>
Co-authored-by: multica-agent <github@multica.ai>
Co-authored-by: Steve Jobs (Multica Agent) <agent-steve-jobs@multica.ai>
|
||
|
|
00e2060978 |
docs(license): name it the Multica License and restore the verbatim Apache 2.0 text (MUL-5558) (#6206)
* docs(license): name the license Multica License and restore the verbatim Apache 2.0 text (MUL-5558) Co-authored-by: multica-agent <github@multica.ai> * docs(license): define the combined license and fix the NOTICE 4(d) claim (MUL-5558) Addresses the two must-fixes from review on #6206. Must-fix 1 — the file held Multica conditions 1-2 followed by Apache sections 1-9, and Apache section 1 defines "License" as sections 1-9 of the document. So "a copy of this License" in section 4(a) and the inbound terms in section 5 could both be read as reaching only the Apache half, leaving downstream recipients and future contributors unsure which set of terms actually binds them. Split the file into `Part I — Additional Conditions` and `Part II — Incorporated Apache License 2.0 Text` so the two "1 / 2" numberings no longer collide, and add condition 3 as the operative bridge: both parts together are the Multica License, "this License" in Part II means the whole file (naming 4(a) and 5 explicitly), Part I controls on conflict, and redistribution must deliver the complete file. Condition 2 becomes "By submitting a contribution ... you agree" instead of the non-binding "you should agree", gains 2c pinning contributions to the whole license, and drops the stale "open-source agreement" wording. The same inbound terms now also appear in CONTRIBUTING.md. Must-fix 2 — NOTICE claimed section 4(d) requires reproducing its entire contents. It does not: 4(d) covers the relevant attribution notices, and a NOTICE cannot create obligations of its own. Replaced with a statement that the file is informational and does not modify the license. Conditions 1a-1d are untouched. The Apache 2.0 text is untouched and still hashes to cfc7749b96f63bd31c3c42b5c471bf756814053e847c10f3eb003417bc523d30. Co-authored-by: multica-agent <github@multica.ai> --------- 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> |
||
|
|
0a54485ab6 | chore(llm): use gpt-5.6-luna by default | ||
|
|
c3cc777acb |
fix(onboarding): add logout escape (#6179)
Closes #3960 |
||
|
|
2e0c599edd |
fix(agent): avoid H1 headings in issue bodies (#6199)
Co-authored-by: Lambda <lambda@multica.ai> Co-authored-by: multica-agent <github@multica.ai> |
||
|
|
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> |
||
|
|
8d4cf83a47 |
chore(release): drop the inaccurate Apache-2.0 SPDX declaration from the Homebrew formula (MUL-5558) (#6205)
Co-authored-by: Bohan-J <bohan@devv.ai> Co-authored-by: multica-agent <github@multica.ai> |
||
|
|
75c11db048 |
MUL-5549: feat(agent): discover codebuddy models over ACP instead of scraping --help (#6203)
* feat(agent): discover codebuddy models over ACP instead of scraping --help (MUL-5549) CodeBuddy speaks ACP, and `session/new` answers with a structured catalog under models.availableModels plus a currentModelId — exactly the shape the shared parseACPSessionNewModels already reads for Copilot / Kimi / Kiro / Qoder / Grok / TRAE. Scraping the `--model` line out of `codebuddy --help` was never necessary. The help text carried IDs and nothing else, which cost us three things: - Labels were guessed from the ID and were simply wrong. `kimi-k3-1` rendered as "Kimi K3 1" where the CLI says Kimi-K3; `deepseek-v3-2-volc` as "Deepseek V3 2 Volc" where the CLI says DeepSeek-V3.2. - The default model was a "first entry wins" guess rather than the advertised currentModelId. - The effort catalog needed a second regex over the same output. All three come from the handshake now. The effort catalog rides along in the same session/new response as the `thought_level` config option, so it costs no extra process — which also retires the "at most one --help per request" constraint added in #6196, because --help is no longer run at all. One trap worth naming: thought_level advertises `enabled` ("On (default)") alongside the six real levels, but `--effort enabled` is not a valid command line — the daemon passes the selected level straight to the flag. Advertised levels are filtered against the flag's accepted set, and a currentValue outside that set (the default `enabled`) becomes an empty DefaultLevel, which the UI renders as a generic "Default" instead of a value we cannot pass through. Two adjacent inaccuracies surfaced while confirming the real level set against CodeBuddy 2.130.0, both fixed here: the static effort fallback omitted `minimal` and `max`, and so did the server-side IsKnownThinkingValue gate — so the server rejected two levels the CLI genuinely accepts. Discovery keeps its fallback, still marked Fallback so it can never be cached as authoritative (#6196). That covers the not-logged-in case, which is deliberately NOT special-cased with an auth step: the catalog came back without calling authenticate on a logged-in CLI, and inventing an auth branch we cannot exercise would be speculation. Removes codebuddyModelRe, parseCodebuddyModels, codebuddyModelLabel, codebuddyModelProvider, codebuddyEffortRe, parseCodebuddyEffortHelp, codebuddyEffortSuperset, codebuddyHelpOutput and its 60s help cache. Co-authored-by: multica-agent <github@multica.ai> * fix(agent): keep codebuddy's vendor grouping after the ACP migration (MUL-5549) Review nit, and a real regression in the previous commit. Dropping codebuddyModelProvider looked like removing dead code, but it was the only thing populating Model.Provider for CodeBuddy — and the picker groups on that field. acpModelEntry can only recover a vendor from a `vendor:model` id. CodeBuddy's are bare (`glm-5.2`, `kimi-k3-1`), so every model came back with an empty Provider, and model-dropdown renders the empty group with no header at all: all 16 models would have collapsed into one unlabelled list where main shows Zhipu / Kimi / MiniMax / DeepSeek / Hunyuan sections. Restores the prefix inference as a post-pass over the ACP catalog, exactly the shape discoverCopilotModels already uses for the same reason. Verified against the real CLI: all 16 models land in five vendor groups with none ungrouped. Tests assert the vendor for every id CodeBuddy 2.130.0 advertises plus the static fallback ids, and that the fallback entries' hardcoded providers agree with the inference. Removing the post-pass fails them. Co-authored-by: multica-agent <github@multica.ai> --------- Co-authored-by: Bohan-J <bohan@devv.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
|
||
|
|
5199278780 |
fix(skills): give every skill one name across brief, directory, and frontmatter (MUL-5529) (#6189)
* fix(skills): give every skill one name across brief, directory, and frontmatter (MUL-5529)
A skill could answer to three different names at once. The runtime brief listed
`AgentSkillData.Name` verbatim — a workspace skill's human display name ("PR
review") — while the invocable identity on disk is the sanitized slug
(`pr-review`). Separately, ensureSkillFrontmatter returned valid upstream
frontmatter untouched, so a SKILL.md could declare `name: multica-dev-workflow`
inside a directory called `multica-git-workflow`.
That last divergence is the sharp one: runtimes disagree on which field
identifies a skill. Claude routes on the directory name, OpenCode on the
frontmatter `name`. So the same skill is invocable under different names
depending on where it runs, and the brief's instruction to use "only names from
the listing" pointed at names that resolve nowhere.
The slug is authoritative: it is what lands on disk, it derives from the name
users see in the product, and it is the only value with a uniqueness guarantee
(allocateCollisionFreeSkillDir). A frontmatter `name` is author-supplied and two
imported skills may both claim the same one.
- modelVisibleSkills normalizes Name to the slug. All four model-visible
listings (runtime brief + the three issue_context renderers) already route
through it, so they cannot drift apart.
- ensureSkillFrontmatter rewrites `name` to the allocated slug and keeps every
other key byte-identical, so deliberately shaped upstream frontmatter still
survives. The rewrite follows the collision fallback slug too.
- Name matching is now top-level only. An indented `name:` belongs to a nested
mapping; treating it as the skill's identity both missed that the block had no
top-level name and would have spliced a top-level key into the nested one.
Known gap, tracked separately: the listings render the natural slug, so a
collision fallback to `<slug>-multica` still leaves the brief naming the user's
skill. Closing it needs the allocated slug threaded back from Prepare, which the
renderers cannot reach without giving up the byte-identical-brief guarantee.
Co-authored-by: multica-agent <github@multica.ai>
* fix(skills): cover multi-line name values and in-batch slug collisions (MUL-5529)
Two holes in the name-unification change, both found in review, both
reproduced before fixing.
1. setFrontmatterName replaced only the `name:` line, not the rest of the YAML
value. A value may continue onto indented lines — `name: >-\n upstream`,
multi-line plain scalars, wrapped quoted scalars — so the continuation
survived and YAML folded it into the new value: the block parsed as
"my-slug upstream-name", not "my-slug". The directory == frontmatter-name
invariant this change set exists to establish was still broken, just less
visibly. frontmatterNameSpan now covers the whole value.
As a side effect this also fixes `name:` with the value entirely on the
following line, which previously read as "no name" and got a second
top-level `name` injected above it — two `name` keys, which strict loaders
reject outright.
2. The listings derived slugs with sanitizeSkillName alone, which is not
injective: "A B" and "A-B" both reduce to "a-b". writeSkillFiles resolved
that at write time, so the second skill landed in `a-b-multica` while both
were listed as `a-b` — the second skill had no invocable name and the model
was pointed at the first. This needed no user-installed skill and no
local_directory; two such skills bound to one agent reproduce it in a clean
workdir. resolveSkillSlugs now deduplicates the batch up front and both the
listings and the writer derive from it, with skillSlugCandidate shared so
the in-memory and filesystem allocators cannot disagree on the suffix
sequence.
Slugs are allocated over the unfiltered batch: hidden
(disable-model-invocation) skills are still written to disk and still
consume a slug, so filtering first would shift every later suffix.
Filesystem-dependent collisions against user-installed directories remain out
of scope and are still tracked in MUL-5550.
Regression tests assert the parsed YAML value rather than the output text —
the first line looked correct in every one of these cases — and set-equality
between listed names and the directories actually written. Both were confirmed
to fail against the previous implementation.
Co-authored-by: multica-agent <github@multica.ai>
* fix(skills): bound the frontmatter name value with the YAML parser (MUL-5529)
Review round two found a valid multi-line name the indentation rule still
mangled. A quoted scalar may wrap onto a line at the *same* indentation as its
key:
name: "upstream
continued"
The rule stopped at the key's line, stranding `continued"` and producing
invalid YAML. Probing the parser showed the same holds for flow collections
(`name: [a,\nb]`, `name: {a: 1,\nb: 2}`), so this was not a quoting special
case: indentation simply does not bound a YAML value, and no amount of
patching the heuristic would have made it one.
Value extent now comes from yaml.v3's own line numbers. frontmatterNameValueSpan
locates the `name` key node and ends the span where the next top-level key
begins, stepping back over blank lines and unindented comments so they survive
the rewrite. Unindented is the operative word: block scalar content is always
indented, so an unindented `#` can only be a comment, while ` # text` inside a
block scalar is value and stays in the span.
Detection stays lexical, in lexicalFrontmatterNameSpan. It runs on malformed
blocks too, where there is no parse to consult, and only decides which branch
to take.
setFrontmatterName now re-parses its own output and returns verified=false
unless `name` really is the slug; ensureSkillFrontmatter then routes to the
existing re-synthesis path rather than emitting a block it cannot vouch for.
This adds no new fallback — it feeds an already-present one. The invariant is
the whole point of the change, so an unprovable rewrite is worth less than a
reformatted block: with the span deliberately broken, output stays valid YAML
carrying the right name and loses only upstream formatting.
Tests extend the table to same-indent single/double quoted scalars, flow
sequences and mappings, and name-as-last-key, and now assert the *input* parses
so a case cannot pass by silently taking the re-synthesis path. Two more cover
comment survival and a `#` line inside a block scalar name. All four
same-indent cases fail against the previous implementation.
Co-authored-by: multica-agent <github@multica.ai>
* fix(skills): preserve policy keys when the surgical name rewrite fails (MUL-5529)
Review round three: the post-condition check added last round was routing valid
YAML into bare re-synthesis, and re-synthesis emits only name and description.
An anchor on the name value is one way to get there. Replacing the line drops
`&skill_name`, so `description: *skill_name` no longer resolves, the check
correctly rejects the rewrite — and the block was then rebuilt as just:
name: my-slug
`disable-model-invocation: true` went with it. That key is the author's
instruction that a runtime must not surface the skill on its own, and the
SKILL.md we write is what native discovery reads, so losing it advertises a
skill that was deliberately hidden. Confirmed on the written output:
skillDisablesModelInvocation went from true to false. That is a semantic
regression, not the formatting loss the fallback was justified by.
The two failure modes are now separate:
- invalid YAML → re-synthesize, unchanged; there is nothing to preserve.
- valid YAML, rewrite unprovable → renameFrontmatterNameViaNode rebuilds the
block from the parsed node with `name` set to the slug, keeping every other
key. Formatting normalizes; semantics survive.
The anchor is deliberately kept on the name node. Dropping it is what
invalidates a document that aliases it; keeping it means the alias resolves to
the slug — that value changes, but the document still loads and every policy
key is intact. The rebuilt block is re-parsed and checked like the surgical
path, so an unprovable result still falls through to re-synthesis.
Regression test asserts name, disable-model-invocation, and a custom key all
survive, and that the written file still reads as hidden to
skillDisablesModelInvocation. It fails without the new path.
Co-authored-by: multica-agent <github@multica.ai>
* fix(skills): materialize aliases before renaming an anchored name (MUL-5529)
Round three kept the anchor on the name node so aliases would not dangle. That
avoided an invalid document but created a worse one: every alias resolves
through the anchor, so renaming the anchored value silently rewrote whatever
those fields meant.
name: &shared "true"
disable-model-invocation: *shared
skillDisablesModelInvocation went true -> false across the rewrite. Same
skill-exposing regression as round three, reached by keeping the key instead of
dropping it — which is the lesson: preserving a key is not the invariant,
preserving each key's resolved value is.
Aliases pointing at the name node are now materialized to the value they
resolved to *before* the rename, and the anchor is dropped afterwards as
unreferenced. Ordering matters: the clones are taken first, so they capture the
original value rather than the slug. Copies are per-alias, since sharing one
node would make the encoder re-emit an anchor/alias pair.
Nested aliases are covered by walking the whole document, not just the top
mapping.
Tests: three shapes (alias carrying the policy value, alias nested in another
mapping, two aliases of one anchor) each assert the fixture starts hidden and
stays hidden, that no anchor or alias survives, and that name is the slug. The
round-three test now also pins its aliased `description` to the pre-rename
value instead of merely tolerating the slug. All four fail without the change.
Co-authored-by: multica-agent <github@multica.ai>
* fix(skills): let the parser decide whether a name key exists (MUL-5529)
The branch that chooses between "rewrite the name" and "inject a name" was
still gated on a lexical scan, which only recognizes a bare `name:` carrying a
value on the same line. Two valid spellings therefore read as nameless:
"name": upstream # quoting is syntax, not identity
name: # a key with no value is still the key
Both got a second `name` injected above the existing one, and a duplicate
mapping key is rejected outright — `mapping key "name" already defined`. The
skill does not end up misnamed, it fails to load. Single-quoted keys have the
same problem.
For valid YAML the parsed top-level mapping now answers the question, so any
spelling of the key routes to the rewrite. The lexical scan is confined to the
invalid-YAML branch, where there is no parse to consult and it is only choosing
between re-synthesis and injection. Nesting still reads as absent: a `name`
under another mapping is not the skill's name, so one is added.
Once past the gate the existing paths handle both shapes unchanged, since
frontmatterNameValueSpan already bounds the entry by node line numbers rather
than by how the key is written.
Also tightens the alias tests per review: the nested and two-alias cases now
assert `meta.inner` and `other` still resolve to the anchor's original value,
not merely that visibility survived. The invariant is every key's resolved
value, so every alias should be pinned, not just the one that gates hiding.
Co-authored-by: multica-agent <github@multica.ai>
* fix(skills): detect quoted and valueless name keys in malformed frontmatter (MUL-5529)
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: Bohan-J <bohan@devv.ai>
Co-authored-by: multica-agent <github@multica.ai>
Co-authored-by: Steve Jobs (Multica Agent) <agent-steve-jobs@multica.ai>
|
||
|
|
0314df3b8e |
docs(license): widen the branding condition to all UI code and add NOTICE (MUL-5558) (#6197)
* docs(license): widen the branding condition to all UI code and add NOTICE (MUL-5558) The branding condition in 1b defined Multica's "frontend" as `apps/web/` only, which left the code that actually renders the console brand outside its own scope: the sidebar, invite and new-workspace brand surfaces live in `packages/views/`, and the Electron and iOS clients mount the whole console from `@multica/views` without touching `apps/web/`. A rebranded desktop build could therefore satisfy the condition literally while removing every Multica mark. Replace the directory-enumerated "frontend" with a derivation-based "Multica user interface" covering `apps/web/`, `apps/desktop/`, `apps/mobile/`, `packages/views/` and `packages/ui/` across source, the Docker "web" image, and compiled desktop/mobile binaries. Keep the non-interface exemption so backend-only use stays governed by 1a rather than by branding, and add 1c so that path still carries attribution. Add a NOTICE file to give the attribution obligation somewhere to land: the repository had none, and no per-file copyright headers, so Apache 2.0 section 4(c)/(d) had nothing to reproduce. Move the "commercial license must be obtained" sentence into 1a, since it describes only that condition and 1b/1c are cured by written authorization and attribution respectively, not by purchase. Co-authored-by: multica-agent <github@multica.ai> * docs(license): separate the commercial license from the branding waiver (MUL-5558) Conditions (a) and (b) were both cured by "explicitly authorized by Multica in writing", so one authorization letter could be read as releasing both — handing over commercial hosting rights when only a rebranding permission was intended. Name the two grants distinctly: (a) is cured only by a commercial license obtained from the producer, (b) only by a written branding waiver. Add (d) stating that neither implies the other and that neither can be inferred from the producer's silence or from acceptance of contributions. Co-authored-by: multica-agent <github@multica.ai> * docs(license): name the published frontend image and cover relocated UI code (MUL-5558) Co-authored-by: multica-agent <github@multica.ai> * chore(docker): ship LICENSE and NOTICE in the published images (MUL-5558) Co-authored-by: multica-agent <github@multica.ai> * docs(readme): add bilingual License sections matching the updated conditions (MUL-5558) Co-authored-by: multica-agent <github@multica.ai> * docs(license): reposition as the self-contained, source-available Multica License (MUL-5558) Co-authored-by: multica-agent <github@multica.ai> * chore(release): align license metadata and ship NOTICE in release artifacts (MUL-5558) Co-authored-by: multica-agent <github@multica.ai> * docs(readme): describe Multica as source-available and add contribution terms (MUL-5558) Co-authored-by: multica-agent <github@multica.ai> * fix(landing): describe Multica as source-available instead of open source (MUL-5558) Co-authored-by: multica-agent <github@multica.ai> * revert(license): keep the Dify-style open-source framing, scope widening only (MUL-5558) Co-authored-by: multica-agent <github@multica.ai> --------- Co-authored-by: Bohan-J <bohan@devv.ai> Co-authored-by: multica-agent <github@multica.ai> |
||
|
|
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>
|
||
|
|
44ce16d9b8 |
MUL-5549: fix(agent): stop reporting a failed model discovery as a real catalog (#6196)
* fix(agent): stop reporting a failed model discovery as a real catalog (MUL-5549) Selecting the CodeBuddy runtime showed a model list that shares no IDs with what the CLI actually supports, so every pick was an ID codebuddy rejects (GH #6180). The list in the report is codebuddyStaticModels() verbatim: the daemon had fallen back, but nothing downstream could tell. discoverCodebuddyModels returned (staticModels, nil) on all three failure paths, and copilot/cursor/grok do the same. A failed discovery therefore arrived as a successful one, which defeated every guard built to catch it: the daemon reported status "completed", the picker's discovery_failed hint only renders on isError, and cacheableModelCatalog — whose own comment says an empty list means transient failure — waves through a non-empty stand-in and stores it as last-known-good for the full 24h serve window. One blip got pinned as the answer for a day. Discovery now returns a Catalog carrying a Fallback marker, which the daemon forwards as an additive `fallback` field (older servers ignore it; an older daemon omitting it keeps the previous behaviour). A fallback catalog is still rendered — the picker stays populated and manual entry still works — but it is kept out of both the daemon's 60s discovery cache and the server's catalog cache. On the server it maps to Keep rather than Drop: a stand-in is no grounds to evict a real catalog, matching how a `failed` report is treated. Also stop codebuddyHelpOutput swallowing the exec error. CombinedOutput folds in stderr, so a codebuddy whose `#!/usr/bin/env node` interpreter is missing from a GUI-launched daemon's PATH had `env: node: No such file or directory` parsed as help text — and cached as such for 60s. Verified against CodeBuddy CLI v2.130.0: the parser itself is fine (16 models from real --help), so this fixes the reporting of the failure, not the parse. Co-authored-by: multica-agent <github@multica.ai> * fix(agent): run codebuddy --help at most once per model-list request (MUL-5549) Review catch on the previous commit. Model discovery and effort discovery both read `codebuddy --help`, and the effort pass called it independently. That was free while a failed --help was (wrongly) memoised, but once failures correctly stopped being cached, the failure path ran the 35s command twice in a single request — past the server's 60s running timeout, so the request timed out and the late report was then discarded as stale. The user got nothing, not even the fallback list the previous commit exists to preserve. discoverCodebuddyModels now owns the thinking annotation, so the one help capture feeds both catalogs, and the failure path uses codebuddyFallbackCatalog to apply the static effort levels without exec'ing at all: whatever broke --help for the model catalog breaks it for the effort catalog too. Also strengthen the handler tests. They decoded into a struct declared in the test rather than calling ReportModelListResult, so a wrong JSON tag or a mis-wired cache branch would have passed. They now drive the real endpoint with daemon auth and chi params, covering: a fallback report leaving a previously discovered catalog intact, an older daemon omitting the field still warming the cache, and an authoritative empty catalog still dropping the snapshot. Both fixes are mutation-tested — reverting either makes the new tests fail. Co-authored-by: multica-agent <github@multica.ai> * docs(agent): correct codebuddy --help comments after the single-capture refactor (MUL-5549) Review nit. The comments still described the pre-refactor call graph, where both discoverCodebuddyModels and codebuddyEffortSuperset called codebuddyHelpOutput and the cache was what stopped the duplicate run. The effort parser now takes an already-captured string, and the single-invocation guarantee is structural rather than cache-dependent — which matters, because a failed --help is deliberately not cached, so a second caller would re-run the full 35s timeout. Also note on codebuddyHelpOutput that it has exactly one caller and why a new one would reintroduce the bug. Co-authored-by: multica-agent <github@multica.ai> --------- Co-authored-by: Bohan-J <bohan@devv.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> |
||
|
|
a4bca74c6e |
refactor(skills): trim 8 built-in skill descriptions to trigger + boundary (MUL-5546) (#6188)
Every runtime CLI scans SKILL.md frontmatter and renders each description into its own always-loaded skill listing, so those 3,465 characters are paid on every run. Most of them did no work: each description carried a "Covers A, B, C..." content inventory that contributes nothing to routing, since the agent reads the body anyway once it opens the skill. Keep the trigger sentence and the reverse boundaries, drop the inventory: 3,465 -> 1,353 chars (-61%), roughly 866 -> 338 tokens. Also tighten maxDescriptionChars from 1024 to 300. The 927-char description grew because the old cap never pushed back; trimming the text without moving the gate fixes the symptom, not the system. Co-authored-by: Bohan-J <bohan@devv.ai> Co-authored-by: multica-agent <github@multica.ai> |
||
|
|
99d8f29bde |
fix(codex): raise the first-turn no-progress ceiling to 60s (MUL-5542) (#6192)
The first-turn watchdog killed healthy turns. Two independent field reports measured the first progress event landing just past 30s on gpt-5.5, and ~39s for a WSL app-server, all inside the window the watchdog treats as "stuck". Raise the ceiling to 60s. The window's only job is to fail fast instead of waiting out the 10 minute semantic inactivity backstop, so 60s keeps that value while clearing the observed evidence with margin. Add a regression test for the clamp, which had none: the configured semantic inactivity timeout can only shrink this window, never raise it. Co-authored-by: Bohan-J <bohan@devv.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>v0.4.15 |
||
|
|
cbb23598fd |
docs(changelog): add v0.4.15 release entry (2026-07-30) (MUL-5545) (#6186)
Co-authored-by: Eve <eve@multica-ai.local> 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> |
||
|
|
1d2a3499c9 |
test(agent): stop the cursor fixtures racing their own prompt write (MUL-5536) (#6174)
TestCursorExecuteFailsOnCleanEOFWithoutResult failed on main with "cursor-agent prompt write failed: write |1: broken pipe" where it expects "stream ended without terminal result". The fake cursor-agent exits without reading stdin, so the prompt write races the child's exit: win and the pipe buffer swallows it, lose and the read end is gone and the write returns EPIPE. writeErr outranks both exitErr and the generic no-terminal-result error in cursor.go, so a lost race replaces the asserted failure with the EPIPE one. A real cursor-agent reads stdin to EOF, so the fixtures now do too. That removes the race rather than reordering production error precedence, which is deliberate. Only the two fixtures whose expected error ranks below writeErr need the drain. Co-authored-by: Bohan-J <bohan@devv.ai> 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
|
||
|
|
d6bd4cf7d5 |
fix(agent): recover from Hermes resumed sessions it refuses without running (MUL-5509) (#6164)
* fix(agent): recover from Hermes resumed sessions it refuses without running Hermes never reports an unknown ACP session as a JSON-RPC error. Its adapter answers session/prompt for a session it cannot load with an ordinary success frame carrying stopReason=refusal, and session/resume echoes nothing back -- ACP's ResumeSessionResponse has no sessionId field at all, unlike NewSessionResponse -- so resolveResumedSessionID keeps the id we asked for. Nothing in the exchange is an error, so the isACPSessionNotFound branches at set_model and prompt time never fire for this backend. The result was a Result carrying the dead session id with ResumeRejected=false, which shouldRetryWithFreshSession reads as "not a rejection". GetLastTaskSession then handed the same dead id to every later dispatch on that (agent, issue) pair, so a Hermes agent was usable exactly once per issue: the first task worked, and every following comment, approval or @mention failed the same way until a manual rerun bought one more turn. When no provider error reached stderr the turn was worse than a failure -- it reported completed with empty output, an agent that silently did nothing. Treat a refusal on a resumed session with zero agent activity as the runtime telling us the session is gone: clear the session id, set ResumeRejected so the existing fresh-session retry and session-retirement path take over, and fail the turn instead of reporting an empty success. Both conditions are required -- stopReason=refusal alone is a legitimate model refusal, and a refusal after real work is not a lost session. The reason is applied after promoteACPResultOnProviderError so a captured provider error stays the user-visible message; the generic fallback only fills in when nothing more specific was seen. Verified against the real hermes CLI (upstream main ba7d214b6) on an isolated HERMES_HOME with a local mock endpoint: a fresh task completes, and resuming it now yields status=failed, SessionID="", ResumeRejected=true with the provider error preserved -- previously the dead id came back with ResumeRejected=false. Refs GH #6150, MUL-5509 Co-authored-by: multica-agent <github@multica.ai> * fix(agent): decide Hermes resume loss after the pipe drain, not at quiescence The notification quiet window closing does not end a turn — stdin EOF and the pipe drain do, and Hermes legitimately delivers a turn's final chunk in that gap (TestHermesBackendDrainsLateFinalNotificationAfterPromptResponse exists because of it). Freezing the resume-loss decision at the quiescence boundary therefore read turnActivity == 0 while a real answer was still in flight: a runtime that merely answered slowly after stopReason=refusal had its healthy session id cleared and ResumeRejected set, discarding a live conversation pointer and, with no tool use recorded, triggering an unnecessary fresh-session retry that re-ran the turn. Move the evaluation to the point where the turn has settled — after waitForHermesPipeDrain and streamingCurrentTurn.Store(false), where every accepted update has been counted. This also drops the resumeLost variable and the ordering hazard that came with it. The new regression sends the late chunk 600ms after the refusal, past the 250ms quiet window and inside the 2s drain grace, and asserts the session id survives with ResumeRejected=false. Confirmed to fail against the previous ordering with exactly the reported symptoms (session id emptied, ResumeRejected=true). Refs GH #6150, MUL-5509 Co-authored-by: multica-agent <github@multica.ai> --------- Co-authored-by: J <agent@multica.ai> Co-authored-by: multica-agent <github@multica.ai> |
||
|
|
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> |
||
|
|
ee7ba83f53 |
fix(self-host): apply setup config to daemon (MUL-5269) (#5880)
Fixes two connected self-host setup failures in local worktree development. Generated worktree environments now expose the backend HTTP origin through MULTICA_PUBLIC_URL, and existing generated worktrees derive the missing value at startup through both scripts/local-env.sh and the Makefile. Explicit values, including an intentionally empty same-origin setting, are preserved. setup and setup self-host now reconcile an existing same-profile daemon after authentication so it loads the newly written server URL and token. An idle daemon is restarted behind the existing restart preflight; when active tasks exist, setup leaves the daemon running to avoid cancelling work and prints an actionable profile-aware restart command instead. Closes #5879 |
||
|
|
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> |
||
|
|
74d5fc41d8 |
fix(daemon): discover qodercli via the login shell (MUL-5524) (#6163)
Qoder is a fully supported provider, but a GUI-launched daemon could never detect it. Two gaps, both in agent discovery: - probeAgentCLIs called resolveAgentExecutablePath directly for qoder instead of going through the shared probe() helper, so qoder was the only provider with no login-shell fallback. A daemon started from Finder/Launchpad (the apple.dmg desktop build) does not inherit the interactive shell PATH, so a qodercli in an npm global prefix or any ~/.zshrc-added dir stayed invisible no matter how often the daemon restarted. - "qodercli" was missing from defaultAgentCommandNames, which is the only list cachedShellResolvedAgents asks the login shell about. Even with the fallback wired up, the resolver would not have looked for it. TestDefaultAgentCommandNamesCoversAllProbes was supposed to catch exactly this, but it parsed config.go for probe() calls and silently became a no-op when probeAgentCLIs moved to agents_probe.go. It now parses agents_probe.go and asserts it found at least one probe() per default command, so a future move fails loudly instead of passing vacuously. Pinned-path semantics are unchanged: an absolute/relative MULTICA_QODER_PATH that does not exist stays a hard miss rather than silently resolving a different binary. Co-authored-by: Eve <eve@multica-ai.local> 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> |
||
|
|
e395fb8744 |
docs: four-language documentation overhaul with verified facts and product screenshots (MUL-5057) (#5714)
Complete rework of the docs site, in three passes squashed into one change: 1. P0 fact corrections across the core-loop pages (agent access model, comment triggers and coalescing, issue lifecycle, offline/queue semantics, task states and retries, dispatch via realtime push). 2. Chinese rewrite as the authoring language: final 12-group IA, new "Core concepts" and "Put agents to work" pages, curated welcome page, permanent-UI quickstart (no onboarding-wizard dependency), operator reference restored (CLI command reference, webhook response codes, task state/timeout tables, config set keys, upgrade notes). 3. Verification and sync: every load-bearing claim re-checked against current main with file:line evidence (migration 103 auto-backfill, CLI-only trigger toggling, duplication semantics, COOKIE_DOMAIN, GitHub App variables, Qwen Code as the 17th provider); register standards applied to all 39 pages; en/ja/ko synced from zh with same-language heading anchors (verified, zero dead links); 17 product screenshots plus 2 diagrams wired in. Supporting fixes: i18n middleware no longer swallows /images assets, sidebar folder indent survives custom link padding, unused editorial components removed. Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
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> |
||
|
|
423d86da04 |
fix(daemon): prevent runtime update starvation (#6123)
Co-authored-by: Mia <mia@firtal.com> 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. |