* feat(chat): add project context
Co-authored-by: multica-agent <github@multica.ai>
* fix(chat): resolve MUL-5150 review blockers
- Renumber project-context migrations to unique prefixes after current main:
206_chat_session_project -> 212 (column), 207_chat_session_project_index ->
213 (concurrent index). 206/207 collided with 206_agent_disabled_runtime_skills
and main's 207-211 client_usage_daily set.
- Add the 4 missing chat input.project_context keys to ja/ko locales so the
locale parity test passes (en/zh-Hans already had them).
- Lock the project-context control while a send is in flight (isSubmitting),
not just while the agent is running. A brand-new chat creates its session
lazily during send bound to the project at click time; switching project
mid-send would create the session against the stale project and clear the
editor as if the send landed on the new selection. Add a regression test.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
* fix(chat): complete project context handling
* fix(chat): pin fresh chat to open session's agent on project switch
Switching an existing session to a different project opens a fresh chat but
only cleared the active session, dropping selection back to the stored
`selectedAgentId`. When that preference was stale (open session belongs to
agent B while the persisted pick is still agent A), the lazily-created session
and its first send bound to the wrong agent (agent A).
Extract the project-switch decision into a shared `planProjectContextChange`
pure helper in use-chat-controller.ts and route both chat surfaces (the chat
tab controller and the floating ChatWindow) through it, so the fresh chat is
pinned to the open session's agent and the rule cannot drift between the two
copies. Add a dual-entry regression test (pure-fn guard + controller
integration) covering the stale selectedAgentId case.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
* chore(ci): re-trigger required checks on latest head
The prior push updated the branch ref but GitHub did not emit a pull_request
synchronize for it (PR head-sync lag), so CI/Mobile Verify never ran on the
commit carrying the stale-agent project-switch fix. Empty commit to force a
fresh synchronize on a head that includes it.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
* fix(chat): renumber project migrations to 213/214 after main added 212
Current main added 212_agent_service_tier; the PR's 212/213 chat migrations
collided with it on the merge ref, failing TestMigrationNumericPrefixesStay
UniqueAfterLegacySet. Merge current main and move the chat column migration to
213 and the concurrent index migration to 214 (column before index preserved).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
* fix(chat): lock ProjectPicker clear control during send (keyboard path)
The send-pending lock only put pointer-events-none on the wrapper, which
blocks the mouse but leaves ProjectPicker's inline clear button in the tab
order — a keyboard user could Tab to "Remove from project" and press Enter
mid-send, detaching the project after the lazily-created session already went
out with the old one (reopens the mid-send retarget path via keyboard).
Add an explicit `disabled` capability to the shared ProjectPicker that locks
the trigger, the menu (forced closed), and the inline clear button (disabled +
out of the tab order). Defaults to false, so issue/create/autopilot callers
keep their hover/keyboard clear. ChatInput passes disabled while the project
selection is locked.
Tests: real-ProjectPicker regression (keyboard activation of the clear control
is inert when disabled; still works when enabled) + ChatInput wiring assertion
that the picker is disabled mid-send.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: Lambda <lambda@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
Co-authored-by: Walt <walt@multica.ai>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: Naiyuan Qing <145280634+NevilleQingNY@users.noreply.github.com>
Co-authored-by: NevilleQingNY <nevilleqing@gmail.com>
* fix(agent): attribute Grok usage from the turn's own model id
A resumed Grok session with no configured model recorded its entire spend
under the model id "unknown", which matches no pricing row — so the task
reported $0 cost instead of its real spend.
grok.go only learned the model from the session handshake, and ACP's
`session/load` carries no model id (only `session/new` does). When neither
the agent nor MULTICA_GROK_MODEL pins a model, `daemon.go` legitimately
passes an empty model, leaving nothing to attribute the usage to.
Every Grok turn stamps `result._meta.modelId` with what it actually billed
against. Parse it in the shared ACP result parser and use it as the fallback
in grok.go. Other ACP backends are untouched — they keep whatever the
handshake gave them.
Co-authored-by: J <agent@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
* fix(metrics): price the Grok catalog in server-side cost metrics
server/internal/metrics/pricing.go carried no Grok rows at all, so
RecordLLMUsage took the unpriced branch for every Grok turn: llm_cost_usd
reported zero Grok spend while the tokens accumulated in
llm_unpriced_tokens. Internal cost monitoring simply could not see Grok.
Add the six SKUs xAI publishes rates for, mirroring the frontend table in
packages/views/runtimes/utils.ts. Aliases are anchored exact matches like
the gpt-5.6 rows, so `grok-composer-*` (in the catalog, absent from the
price sheet) stays unmapped instead of inheriting a guessed rate.
Short-context tier on purpose: xAI bills a request at 2x once its prompt
reaches 200K tokens, but a usage record aggregates every model call in a
turn and cannot say which tier an individual request hit.
A regression test re-derives the cost of a real grok 0.2.106 turn from the
table and checks it against the costUsdTicks xAI returned for that turn.
Co-authored-by: J <agent@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
* docs(changelog): scope the Grok cost claim to what was actually fixed
The v0.4.9 entry promised "accurate cost" in all four languages, but the
fix corrected catalog pricing and cached-input double-counting — it did not
implement xAI's 2x long-context tier, so a turn whose requests reach 200K
prompt tokens still under-reports by up to 50%. Say what was fixed instead.
Also correct two stale claims in the pricing comment: the daemon tags usage
rows with the runtime provider `grok`, not `xai` (the bare `grok-*` keys are
what make them resolve), and record why thresholding the long-context tier
on an aggregated row would be worse than not pricing it at all.
Co-authored-by: J <agent@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
* feat(usage): carry the provider's own cost through to the usage record
Cost has always been derived client-side as tokens x a static rate, which
cannot express request-level pricing rules. xAI bills a Grok request at 2x
once its prompt reaches 200K tokens, and a task_usage row aggregates every
model call in a turn — so the stored token counts genuinely cannot say which
tier any individual request hit. Thresholding on the aggregate would be worse
than the status quo: it turns a bounded 50% under-estimate into an unbounded
over-estimate for turns made of many short requests.
Grok already reports what it charged, per turn, in `_meta.usage.costUsdTicks`.
Parse it, carry it through agent -> daemon -> API, and store it on task_usage
as a nullable BIGINT of 1e-10 USD ticks (integer, so sub-cent turns stay exact
end to end). NULL means the provider reported no cost — every pre-existing row
and every provider that doesn't return one. No backfill: there is no
authoritative figure to recover for those, and inventing one is the guess this
removes.
A single hourly bucket can mix rows that carry a cost with rows that don't, so
task_usage_hourly gains both halves: `cost_usd_ticks` sums the authoritative
side, and `uncosted_*_tokens` carry exactly the tokens that still need a
rate-table estimate. Consumers report authoritative + estimate(uncosted),
which degrades to today's behaviour when nothing in the bucket is
authoritative. The existing token columns keep covering every row, so token
displays are untouched. The new columns are additive with defaults, so the
unique key, the dirty-queue shape, and migration 102's triggers are unaffected.
Co-authored-by: J <agent@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
* feat(usage): prefer the provider's own cost over the rate table
With the authoritative figure now stored, both cost consumers use it: the
usage dashboard (estimateCost / estimateCostBreakdown) and the server-side
llm_cost_usd metric. Each reports `authoritative + estimate(uncosted tokens)`,
so a row or bucket that mixes priced and unpriced sources stays whole.
The static rate tables remain, but for Grok they are now a fallback — they
still price usage recorded by a daemon too old to report cost, and every
provider that reports none. Custom pricing overrides likewise apply only to
the estimated half: they are a user's guess at a rate, and the authoritative
half is not a guess. A model with no rate-table row but a provider-reported
cost now also drops out of the "unmapped models" banner, since asking the user
to supply a rate for it would invite overriding a real bill.
llm_cost_usd is labelled by token_type and the provider reports one number per
turn, so the charge is distributed across the buckets in the rate table's own
proportions. Only the total is authoritative; the split stays an estimate,
which is why this scales the existing buckets rather than inventing a label.
estimateCostBreakdown does the same, keeping the stacked chart summing to the
headline figure instead of silently under-drawing every Grok row.
Co-authored-by: J <agent@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
* docs(changelog): say Grok cost now follows xAI's actual charge
The earlier wording scoped the claim down to catalog pricing and cached input
because the long-context tier was still unhandled. It is handled now — the
cost comes from what xAI charged for the turn — so the entry can say so.
Co-authored-by: J <agent@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
* fix(usage): keep the provider's cost when the model has no rate row
Both cost consumers bailed out before reading the authoritative figure when
the rate table had no row for the model. A `grok-composer-*` turn — in the
Grok Build catalog, absent from xAI's price sheet — was therefore reported as
$0 spend even though xAI told us exactly what it charged.
Worse on the client: estimateCost returned the real cost while
estimateCostBreakdown returned zeros, so the headline and the stacked chart
disagreed on precisely the rows whose cost is exact — and the unmapped-models
banner was (correctly) hidden, so nothing explained the discrepancy.
Handle the charge before the rate lookup in both places. Without rates there
is nothing to split a total by, so it lands whole in the `input` bucket, the
same fallback distributeAuthoritativeCost already uses when it has no shape to
scale. Tokens with no rate keep going to llm_unpriced_tokens: "unpriced"
describes the rate table, not the money.
Co-authored-by: J <agent@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
* perf(usage): drop the historical rewrite from the cost-split migration
Migration 213 rewrote every existing task_usage_hourly row to seed the
uncosted counters. That is a full-table UPDATE inside a schema migration —
lock time, WAL and bloat all scaling with table size — for rows this issue
explicitly does not care about.
Deleting the UPDATE alone would have zeroed historical cost: with
`NOT NULL DEFAULT 0`, an untouched row asserts "nothing here needs
estimating", so every pre-split bucket would report $0 until the rollup
happened to touch it. Make the uncosted columns nullable with no default
instead. NULL means "never recomputed since the split existed", readers
COALESCE it to the row's own token total ("estimate all of it"), and the
pre-split behaviour is preserved exactly — with nothing to seed, so no
rewrite. A bare ADD COLUMN is metadata-only, so this is now fast DDL.
Rows heal into the split naturally as the rollup recomputes their buckets.
Verified on a fresh database: a legacy-shaped row reads back as its full
tokens to estimate, and a group mixing legacy and post-split buckets sums to
the authoritative cost plus both rows' estimable tokens.
Co-authored-by: J <agent@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: Bohan-J <bohan@devv.ai>
Co-authored-by: J <agent@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
* fix(github): surface CI status on PR cards (MUL-5180)
The CI mirroring pipeline (MUL-2228, MUL-2392) has never received a single
event in production. The GitHub App setup docs only ever asked operators to
grant `pull_requests: read` and subscribe to `pull_request`, so GitHub never
delivered `check_suite` — `handleCheckSuiteEvent` sat dead behind a
subscription nobody was told to enable. Every linked PR reports
checks_passed/failed/pending = 0 and the sidebar row falls through to
"Checks haven't reported yet" forever.
Docs (the root cause), all four locales:
- add `Checks: Read-only` permission + `Check suite` event to the App setup
table
- drop the stale "CI check states are not modeled" claim, which predates
MUL-2228 and is what let the setup table stay incomplete
- add a "PR rows show no CI status" troubleshooting entry with the public
`/apps/<slug>` probe to confirm what an App is actually subscribed to, and
a warning that existing installations must accept the new permission
before any `check_suite` is delivered
UI:
- give the actionable status kinds (checks failed/pending/passed, conflicts,
ready) their own icon + color. CI outcome previously rendered as plain
muted 11px text, visually identical to the diff stats beside it — a failing
build read the same as "+437 −6 · 6 files". Terminal and unknown kinds stay
muted; the row's state icon already carries that meaning.
Co-authored-by: multica-agent <github@multica.ai>
* fix(github): unbreak docs build, stop overclaiming CI completeness (MUL-5180)
Both must-fixes from review.
1. docs production build failed. `<your App>` in prose was parsed as a JSX
tag, so `pnpm --filter @multica/docs build` died with `Expected a closing
tag for <your>`. Dropped the angle brackets. Repo CI never caught this
because no workflow runs the docs production build — only Vercel does,
which is why the PR's GitHub checks were green while the deployment
errored.
2. `Checks: Read-only` cannot support the pending status the docs promised.
GitHub's webhook contract delivers `check_suite.requested` /
`.rerequested` only to Apps holding Checks *write*; read-level access
receives `completed` only. Verified against GitHub's published docs.
Direction chosen: keep read-only, degrade honestly to final-results-only.
Checks *write* is a repo-write capability (create/update check runs), not
a wider read — escalating every installation to it just to render an
in-flight spinner is not a trade to make on the operator's behalf, and it
contradicts the integration's read-only posture.
The concrete bug this leaves is premature green: with two reporting apps,
the first to complete makes total=1/passed=1 and the row claimed "All
checks passed" while the second was still running and might fail. Copy is
now "Checks passed" in all four locales — it reports what reported and
never asserts completeness. `derivePullRequestStatusKind` documents why.
Docs gain a "what CI status can and cannot tell you" section (all four
locales) with the read-vs-write delivery table, both consequences stated
plainly, and the opt-in path for teams that do want in-flight status: set
Checks to Read and write on their own App and the existing pending code
lights up with no code change. The pending promise is removed from the
read-only setup path.
Co-authored-by: multica-agent <github@multica.ai>
* fix(github): ignore non-completed check_suite actions (MUL-5180)
Review was right: the `Read and write` opt-in the previous commit documented
does not produce reliable pending, and following it would break the card.
`check_suite.requested` / `.rerequested` are not observations that some CI
provider started. GitHub sends them only to Apps holding Checks write, and
per the CI-checks App docs they mean "GitHub has created a check suite for
YOUR app on this commit; now add your check runs to it".
Multica observes other apps' results and never creates check runs. Recording
such a suite parks a `queued` row nothing can ever complete, and since
`checks_pending` outranks `checks_passed` in derivePullRequestStatusKind, one
stuck row freezes every PR on that installation at "checks running" and hides
the real pass/fail result. Any self-hoster who already grants Checks write
hits this on every push, so the gate is on the action, not the permission.
- handleCheckSuiteEvent drops every action except `completed`, with the
reasoning and the "don't resurrect requested as a running signal" warning
recorded at the gate.
- TestWebhook_CheckSuite_QueuedCountsAsPending encoded the wrong delivery
semantics (two external apps sending `requested`, which GitHub never does).
Replaced by TestWebhook_CheckSuite_NonCompletedActionsIgnored, which pins
the drop and checks a later `completed` suite still lands.
- The two out-of-order stash tests used `requested` payloads to exercise
paths that are really about completed suites; both now use `completed` and
assert the same guarantees.
- Docs (four locales): the write opt-in is gone. In-flight CI is documented
as unsupported at any permission level, with the actual reason and the note
that real running status needs polling or a check_run model instead.
Co-authored-by: multica-agent <github@multica.ai>
* fix(github): make legacy non-completed check suites inert (MUL-5180)
Review was right again: the previous commit gated the webhook entry point but
left the pre-upgrade state — and the people it was meant to protect (self-
hosters who already granted Checks write) are exactly the ones holding it.
Two leftovers, both now closed:
1. Rows already in github_pull_request_check_suite. The old handler stored
GitHub's `requested` suites as `queued`; nothing will ever complete them.
ListPullRequestsByIssue still counted them, so `checks_pending` kept
outranking `checks_passed` and the PR stayed pinned to "checks running"
for as long as its head SHA stood. The aggregation now selects only
`completed` suites.
Filtering beats deleting here: recovery is automatic on deploy, needs no
migration over a table that can be large, and holds for any writer that
misses a gate — not just for today's legacy rows. DISTINCT ON runs after
the filter, so an app whose newest suite is a stuck `queued` still reports
its most recent completed verdict instead of disappearing.
2. Rows already in github_pending_check_suite. replayPendingCheckSuitesForPR
is a second write path into the live table that never passes through
handleCheckSuiteEvent, so the next `pull_request` event would re-inject a
permanently-queued suite after the fix shipped. It now skips non-completed
rows; the drain is DELETE ... RETURNING, so skipping discards them.
Both are covered by regression tests that seed the legacy row directly — the
fixed handler can no longer produce one — and both were confirmed to fail
with their respective fix reverted. The stash test additionally asserts its
fixture landed under the repo address the drain keys on; the first draft used
the wrong owner and passed vacuously.
Also corrects the aggregateChecksConclusion doc comment, which still
described "pending" as a not-yet-completed suite. It is now reachable only
for a completed suite carrying a null conclusion, and is explicitly not a
"CI is running" signal.
Co-authored-by: multica-agent <github@multica.ai>
* test(github): assert the legacy stash row is consumed by the drain (MUL-5180)
Review nits.
The stash test proved its fixture existed before the webhook but never that
the drain consumed it, so a future change to firePullRequestWebhookWithHead's
repo address would make the assertions pass for the wrong reason again — the
same way the first draft of this test did. Asserting the stash is empty
afterwards closes that gap from the other side.
Also fixes two comment typos: `an "CI is running"` -> `a`, and drops the
"merged-but-open PR" state, which cannot exist.
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: Bohan-J <bohan@devv.ai>
Co-authored-by: multica-agent <github@multica.ai>
* feat(usage): price the published xAI Grok catalog
Grok usage resolved to no pricing row, so every Grok token contributed
$0.00 to cost totals and the models sat permanently in the unmapped
diagnostic. Add the six SKUs xAI publishes rates for.
Short-context rates on purpose: xAI bills a request at 2x once its
prompt reaches 200K tokens, but aggregated usage rows carry no
per-request prompt sizes — the same trade-off the Anthropic `[1m]`
context tag already takes. `grok-composer-*` ships in the Grok Build
catalog but is absent from the price sheet, so it stays unmapped rather
than inheriting a guessed rate.
Source: https://docs.x.ai/developers/pricing
Co-authored-by: multica-agent <github@multica.ai>
* fix(usage): keep custom pricing editable after it is saved
The custom-pricing dialog had exactly one entry point: the button inside
the unmapped-models banner. That banner only rendered while something in
the window was unpriced, and `resolvePricing` falls back to the custom
store — so saving a rate for the last unmapped model made it resolve,
the banner disappeared, and the saved rate could no longer be edited or
removed. Clearing localStorage was the only way out, which also wiped
every other override.
Keep the bar mounted whenever there is something to manage: warning
state while models are unpriced, a quiet row with an edit button once
they all resolve but overrides exist. Hidden entirely otherwise.
The existing store mock hard-coded `getCustomPricing` to undefined, so
no test could ever observe a saved override; make it stateful and add
the regression case.
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: Bohan-J <bohan@devv.ai>
Co-authored-by: multica-agent <github@multica.ai>
* fix(desktop): open in-app links in a tab instead of the browser (MUL-5208)
A link written as an absolute URL on this deployment's own origin
(`https://<app-host>/acme/issues/1` — what "copy link" produces, and what
agents paste into chat) fell through openLink's external branch to
window.open, which Electron routes to shell.openExternal. Clicking an issue
link in Desktop chat therefore opened a browser window instead of a tab.
openLink now resolves such a URL back to its in-app path and takes the same
route a relative path does. Backend-served prefixes (/api/, /_next/) stay
external so attachment downloads keep working.
Two supporting fixes the change depends on:
- The `multica:navigate` event had no listener on web, so in-app paths in
content were dead links there; normalizing app URLs would have extended
that to every pasted app URL. The web platform layer now answers the event
with a router push.
- The desktop handler opened every path inside the active workspace's tab
group. A cross-workspace link now goes through switchWorkspace, matching
what the navigation adapter already does for pushes.
Co-authored-by: multica-agent <github@multica.ai>
* fix(desktop): scope in-app link conversion to workspace pages, answer it in issue windows
Review follow-ups on MUL-5208.
1. The non-page exclusion list only covered /api/ and /_next/, so a
same-origin /uploads/* link — local-storage attachments, served by the
backend and proxied by web — was routed as an app page, opening a dead tab
instead of the file. Replaced the deny-list with the app's own routing
model: an absolute URL converts to an in-app path only when its first
segment is a slug a workspace could own, which the existing reserved-slug
list (shared with the backend) already answers. /api, /uploads, /_next,
/favicon.ico and the pre-workspace routes all stay external without a
second list to keep in sync.
2. A dedicated issue window derives the same app origin from its adapter but
had no multica:navigate listener, so a same-origin link there became a
silent no-op (it used to reach the browser). The window now answers the
event: another issue opens in place — matching what its adapter push and
mention chips already do — and any other app page, which this single-route
window cannot host, goes to the browser.
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: J <agent@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
* fix(resize): keep the drag cursor consistent for table and chat resize
Table column resize and the chat floating-window resize locked the active
cursor via document.body.style.cursor, which loses the CSS cascade to any
descendant that declares its own cursor (clickable rows, inputs, buttons).
So the resize cursor showed on hover but flipped back to pointer/text/default
once the drag pointer swept over those elements — inconsistent with the
sidebar rail and react-resizable-panels, which force it globally.
Switch both to the same robust contract used by the sidebar/panels: set a
data attribute on <html> during the drag and let a global
`html[...], html[...] * { cursor: ...; user-select: none } !important` rule in
base.css win over every descendant. Chat keys the attribute by direction
(left/top/corner) so col/row/nw-resize stay correct.
MUL-5166
Co-authored-by: multica-agent <github@multica.ai>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix(chat): clean up chat resize cursor lock on every drag-end path
The chat window resize started the drag with setPointerCapture but only
removed data-chat-resizing on pointerup. A pointer-capture drag can end via
pointercancel, lostpointercapture, or window blur without ever firing
pointerup; since the attribute now drives a global
`html[...] * { cursor; user-select } !important` rule, a missed cleanup would
strand it and freeze the whole page in the resize cursor with text selection
disabled.
Mirror the sidebar rail: one idempotent finishDrag() wired to pointerup,
pointercancel, lostpointercapture and blur, releasing pointer capture and
removing every listener plus the attribute from a single path. Add a
regression test covering each termination path.
MUL-5166
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
* fix(ui): release table resize cursor lock on window blur too
Table column resize wires stopResize to pointerup/pointercancel but not to
window blur. With the resize cursor now held by a global
`html[data-table-resizing] * { cursor; user-select } !important` rule, an
alt-tab mid-drag (button released while the window is blurred, so no pointerup
arrives) would strand the attribute and freeze the page. stopResize is
idempotent, so also wire it to blur — matching the sidebar rail and chat.
MUL-5166
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
The chat composer was capped at a fixed max-h-40 (160px), so a long draft
was previewed through a five-line porthole no matter how large the chat
window was. Cap the composer at 50% of the surface it sits on instead,
with a 384px absolute ceiling, and let the card shrink into that cap via
a flex-column wrapper.
Verified in Chromium across all four surfaces that mount ChatInput
(floating window at min and expanded size, chat tab, agent builder): a
long draft caps at min(50% of the surface, 384px) and scrolls internally,
a short draft still hugs its content, and the message list keeps space.
Co-authored-by: Bohan-J <bohan@devv.ai>
Co-authored-by: multica-agent <github@multica.ai>
* fix(views): raise hover delay on the agent activity badge
The per-issue agent activity badge sits on the right edge of dense
scrolling lists (inbox rows, issue rows, board cards) and appears on
every issue an agent currently touches. Base UI's 600ms default opened
the 288px activity card on pointer travel rather than on intent.
Raise the open delay to 900ms and drop the close delay to 150ms. The
card body is read-only, so there is no hover bridge to protect.
MUL-5189
Co-authored-by: multica-agent <github@multica.ai>
* fix(inbox): drop the agent activity hover card from inbox rows
The badge already shows who is running and whether they are working or
queued. On a triage surface the card's only incremental fact is elapsed
time, which never changes the one decision an inbox row supports — do I
open this? The row also carries the ActorAvatar hover card on the left,
so a second popup was mostly noise.
Add an opt-out `hoverCard` prop to IssueAgentActivityIndicator and pass
false from the inbox row. Issue lists and board cards keep the card:
monitoring work in flight is what those views are for, and they keep the
900ms dwell delay from the previous commit.
MUL-5189
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: Bohan-J <bohan@devv.ai>
Co-authored-by: multica-agent <github@multica.ai>
Switching the runtime mid-conversation in Build with AI only updated local React state, so the picker could show runtime B while every subsequent message still executed on the runtime frozen at session create time.
- Add PATCH /api/agent-builder/sessions/{id}/runtime to rebind the hidden builder carrier (runtime_id/runtime_mode, model cleared since model ids are per-runtime). Creator-only, builder carriers only, target must be in-workspace, usable by the member, and online; a reply in flight returns 409.
- Serialise rebind against send: both take LockChatSessionForRuntimeBind on the chat_session row and SendDirectChatMessage re-reads the agent inside that transaction, so a send blocked behind a rebind cannot resume and stamp its task with the runtime the switch moved away from.
- Leave chat_session.runtime_id stale on purpose so the daemon starts a fresh provider session on the new runtime while Multica-side history and the draft survive.
- Frontend updates the draft only after the server reports the bound runtime, blocks sending during a rebind, disables the Mine/All filter alongside the trigger, and explains why the picker is locked during a pending reply.
Closes#5773
Flow drops from five steps to three: role + use_case merge into a
single About-you screen (one Skip covers both; Continue stamps skip
markers on whichever group was left unanswered), and the source
question leaves onboarding entirely.
Source is now collected only by the workspace source-backfill prompt,
which additionally waits until agents/squads have completed at least
SOURCE_BACKFILL_MIN_AGENT_DONE_ISSUES (3) issues in the workspace —
attribution is asked after Multica has visibly delivered value, not
before. The count rides a limit:1 issues query keyed under
issueKeys.all so realtime invalidations keep it fresh, enabled only
for users who still owe an answer.
Server: questionnaire complete() narrows to role + use_case so the
funnel step doesn't stall on the now-deferred source; a new
metrics-only onboarding_source_submitted event (+ Prometheus counter)
tracks the backfill prompt's answer/decline transition once per user.
Co-authored-by: Lambda <lambda@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
* perf(daemon): parallelize runtime version detection during registration (MUL-5119)
Registration probed each agent CLI's `--version` serially, so total latency
was the sum of every probe. On an onboarding host with several coding tools
installed that stacked into many seconds before runtimes registered — long
enough that the desktop runtime step timed out into its empty 'no runtime
found' state while the daemon was still working.
Fan the probes out with a bounded errgroup so total latency tracks the slowest
single probe instead of their sum. Each probe still self-heals a vanished
pinned path and re-detects the live version (no cross-registration caching, so
an in-place upgrade is still reported correctly); failures are logged and
skipped as before. Results are sorted by provider for a deterministic payload.
Co-authored-by: multica-agent <github@multica.ai>
* fix(onboarding): stop the runtime step flashing 'no runtime found' while the daemon probes (MUL-5119)
The runtime step flipped from scanning to the empty 'no runtime found' state on
a fixed 5s wall-clock, so a machine that does have coding tools installed saw a
false-negative flash whenever registration outlasted the timeout (cold start,
slow/wedged CLI, many CLIs).
Gate the empty flip on a desktop-only `runtimesPending` signal derived from the
local daemon's live status (booting, or running with agent CLIs detected on the
host): while pending, keep the scanning skeleton past the soft timeout. An
absolute hard-timeout ceiling still guarantees a fallback so a wedged probe
can't pin the step on the skeleton forever. Web omits the signal and keeps the
plain wall-clock timeout.
Also drop the two dead/duplicated affordances on the step: the permanently
disabled 'Start exploring' button now renders only in the found phase, and the
empty state's duplicate footer 'Skip for now' is removed in favour of its own
Skip card.
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: Lambda <lambda@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
* feat(issues): richer sub-issue rows in issue detail (MUL-5098)
The sub-issues panel showed only status, identifier, title and assignee —
priority, due dates, labels, live agent activity and nested breakdowns
were invisible without opening each child.
- SubIssueRow now shows priority (checkbox-slot swap like list rows),
the agent-activity indicator, label chips (+n overflow), the child's
own done/total progress ring, and an inline-editable due date with
overdue emphasis (muted when the child is done/cancelled)
- Right-click opens the shared issue actions menu via a section-level
IssueContextMenuProvider — parity with list/board surfaces
- ListChildIssues + ListChildrenByParents now bulk-load labels
(same labelsByIssue pattern as the other list endpoints)
- patchIssueLabels patches per-parent children caches;
invalidateIssueLabelDerivatives refetches the Map-shaped batched
children caches so label changes stay live everywhere
Co-authored-by: multica-agent <github@multica.ai>
* feat(issues): customizable property display for sub-issue rows (MUL-5098)
The enriched sub-issue rows were a fixed field set — no way to trim
them or surface workspace custom properties.
- New user-level persisted preference (useSubIssueDisplayStore):
built-in field toggles (priority / labels / sub-issue progress /
due date / assignee) + opted-in custom property ids. Defaults match
the previous fixed layout, so existing users see no change.
- SubIssueDisplayPopover on the section header — same switch-row
interaction as the main views' Display panel, reusing its card_*
locale keys (no new translations needed).
- Rows render opted-in custom property chips (PropertyIcon +
CustomPropertyValueDisplay, list-row parity) only when the child
carries a value; ids resolve against live non-archived definitions,
so foreign-workspace or archived ids are inert.
Co-authored-by: multica-agent <github@multica.ai>
* fix(issues): reconcile sub-issue cache updates
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: Lambda <lambda@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
* test(issues): repair table-view editing tests orphaned by the grouping revert
Reverting "Move issue table grouping to the server" (#5777) removed the
server-driven `serverIssues` fixture, but two tests still assigned to it,
so packages/views typecheck and vitest have been failing on main. Pass the
issue through the Harness `issues` prop like the sibling tests do.
Co-authored-by: multica-agent <github@multica.ai>
* refactor(issues): drop the table toolbar loaded-count and structure-paused copy
The table toolbar rendered a permanently visible "Loaded X of N" counter
plus a long "Grouping and hierarchy are paused — ..." notice. Neither is
worth the horizontal space it took between search and Export, so remove
both strings and their locale keys.
The load-failure Retry button stays: it only renders on a failed window
fetch and is the explicit resume path for the infinite-scroll sentinel.
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: J <agent@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
The "Create your first agent" and "first issue" onboarding steps were
dropped from the in-flow sequence (helper-agent creation moved to the
post-onboarding workspace shell), but their code was left behind. Remove
the now-dead residue:
- Delete unused step components `step-agent.tsx` and `step-first-issue.tsx`
(not referenced or exported anywhere).
- Delete `recommend-template.ts` (+ test) and drop its core export — it
was consumed only by `step-agent.tsx`.
- Drop the dead `agent` / `first_issue` members from the `OnboardingStep`
union.
- Remove the orphaned `step_agent` and `first_issue` i18n sections across
all four locales (en / zh-Hans / ja / ko); parity test stays green.
- Fix a stale doc path in `step-order.ts` (welcome-after-onboarding.tsx).
No behavior change: the live flow is welcome → source → role → use_case
→ workspace → runtime. Typecheck (core/views/web/desktop) and onboarding
+ locale-parity tests pass.
Co-authored-by: Lambda <lambda@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
* feat(agents): add per-agent runtime skill controls
Co-authored-by: multica-agent <github@multica.ai>
* fix(agents): renumber runtime-skill migration and broadcast agent:status on toggle
Address the MUL-5101 review blockers on PR #5686:
- Rebase onto main and renumber the runtime-skill-disable migration
202 -> 203. main added 202_runtime_profile_add_qwen, so the pair
collided on prefix 202 and migrations_lint_test would reject the
duplicate. 203 is the next free prefix.
- Publish an "agent:status" event after persisting a
disabled_runtime_skills override, mirroring the workspace-skill toggle
in writeUpdatedAgentSkills. The realtime layer keys off this event to
invalidate workspaceKeys.agents, so other open web/desktop/mobile
clients now drop their stale toggle state instead of only the
initiating tab refreshing. Reload junction-table skills before the
broadcast so it doesn't signal cleared skills (#3459).
- Add a handler regression test proving the broadcast fires on both
disable and enable.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: multica-agent <github@multica.ai>
Co-authored-by: Walt <walt@multica.ai>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
* fix(nav): derive route icons from the URL across all nav surfaces (MUL-4370)
The same route rendered different icons in the sidebar and the desktop tab
bar because the mapping was maintained in three places. Projects had no tab
icon at all; autopilots/chat/squads/usage fell back to ListTodo.
Establish one contract instead: `@multica/core/paths` maps a route segment to
a stable icon *name* (React-free), and `@multica/views/layout` maps that name
to a Lucide component. Every nav surface — sidebar, desktop tab bar, and the
search palette — resolves through `routeIconForPath(path)`, so a route cannot
render two different icons.
Crucially the icon is now derived, not stored. `TabSession.icon` is removed,
so persisted tab state can no longer hold a stale icon name: a user who had
an /autopilots tab from an older build gets the correct icon after upgrade
rather than the one that was persisted. Legacy `icon` values in v4 payloads
are ignored on rehydration and dropped on the next write.
Builds on the design in #5204 by LiangliangSui.
Tests: stale/unknown/absent persisted icon on rehydration, derived icon
rendering per route in the tab bar, name→component registry totality, and
nav-route icon coverage.
Co-authored-by: multica-agent <github@multica.ai>
* feat(desktop): tab presentation by object identity, not route segment (MUL-4370)
Replace the "route segment → icon" tab mapping with a semantic Tab
Presentation Contract: a tab's leading visual and title are derived live
from its URL + the query cache, so a tab shows *what it points at*, not the
module it lives under.
- core `parseTabSubject(url)` classifies a URL as page / resource / actor /
container (inbox, chat) / flow / unknown, purely (no React, no Lucide).
- core `resolveTabPresentation(subject, data)` maps that + cached entity data
to a leading visual (issue StatusIcon, ProjectIcon, ActorAvatar, or a type
icon) and a title spec. Exhaustive: a new route forces an explicit choice.
- views `useTabPresentation` reads the cache (enabled:false, no fetch from the
tab bar) and `ResourceLeadingVisual` renders it in a fixed 16×16 slot.
- Containers keep their icon; only the title tracks the selection
(`/inbox?issue=`, `/chat?session=`), so an inbox-opened issue reads
differently from a direct issue tab.
- Titles are plain text; the project 📁 and autopilot ⚡ glyphs are dropped
from document.title.
- Pin no longer replaces the resource visual. Persisted `tab.icon` cleanup
from the prior revision is kept; the active tab persists its resolved title
as a first-frame fallback (the document.title→observer path is removed).
Supersedes the route-segment approach in #5204 per the agreed PRD. No schema,
API, or migration changes; reuses existing queries/caches.
Tests: table-driven parseTabSubject over every desktop route; the
URL/data → visual+title matrix incl. pending/loading, containers, unknown,
runtime custom-name; views cache-integration; tab-bar pin + active-tab
title persistence.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
* fix(desktop): archived inbox title sync + attachment filename in tab (MUL-4370)
Addresses two PRD gaps from review:
1. Archived Inbox selection now syncs the tab title. `parseTabSubject` captures
`?view=archived` on the inbox subject, and the presentation hook resolves the
selection against `archivedInboxListOptions` (its own cache, the one the
InboxPage populates) instead of only the active list. An
`/inbox?view=archived&issue=<id>` tab now shows the archived item's title —
issue (`identifier: title`) or non-issue (display title) — and, being purely
URL+cache derived, restores correctly on refresh. Previously it fell back to
"Inbox" and persisted that wrong title.
2. Attachment tabs use the filename. `parseTabSubject` captures the `?name=`
the preview route already carries; the resolver shows the filename as the
title and picks a file-type icon from its extension (image/video/audio/
archive/code/text), falling back to the generic File glyph + "Attachment"
label only when the name is missing.
Tests: parseTabSubject archived/name cases; the presentation matrix for
attachment filename/extension + missing fallback and iconForAttachment edge
cases; views cache-integration for archived issue/non-issue selection (must
resolve against the archived list, not the active one) and attachment filename.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: multica-agent <github@multica.ai>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
* fix(issues): keep table cell editors open across refreshes; title click opens the issue (MUL-5108)
Table view interaction fixes:
- Cell/header renderers are now stable module-level components reading
data via table.options.meta. flexRender mounts function cells as
component types, so the per-render closures rebuilt on every data
refresh (new childProgressMap / property / actor identities) remounted
every cell and closed any open picker popup.
- One hoisted editingCellKey drives all cell editors (controlled open),
and while an editor is open the row structure renders from a frozen
snapshot (live issue values, frozen order) so window materialization,
hierarchy assembly, and realtime reorders cannot move or unmount the
popup's anchor row mid-interaction. Structure catches up on close.
- Clicking an issue title now opens the issue (it previously started
inline rename despite the link-style hover). Renaming moved to a
hover pencil affordance; dead space in the title cell navigates too.
Co-authored-by: multica-agent <github@multica.ai>
* fix(issues): address table editor review — blur-click nav, virtual-unmount key release, deterministic test (MUL-5108)
Follow-up to the MUL-5108 table fixes, per code review on PR #5730:
- R1#2 (P1): committing a rename by clicking away also navigated into the
issue. onBlur flips `editing` off synchronously before the commit-click
lands, stripping the click-time guard so the click bubbled to row
navigation. Record whether the gesture began while editing (mousedown,
before blur) and swallow that click in the capture phase, before it can
reach the row or the title's open handler. Dead-space clicks while not
editing still open the issue. New blur-click regression test.
- R1#3 (P2): the hoisted editingCellKey (and the frozen row structure keyed
off it) never cleared when row virtualization unmounted the anchor cell —
Base UI does not fire onOpenChange(false) on unmount, so the table stayed
frozen and the picker silently reopened / dropped the rename draft on
scroll-back. Add useReleaseEditingCellOnUnmount: an unmount responder that
releases the key iff the unmounting cell still owns it. Focused tests.
- R1#1 (P1): the new integration test exceeded the 5s default under
concurrent CI worker load (real-timer gaps between userEvent steps). Drive
userEvent with delay:null and give the heavy full-mount test explicit
headroom so it is deterministic.
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: Lambda <lambda@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
* feat(issues): confirm assignment without a pre-flight run preview (MUL-5010)
RunConfirmModal called POST /api/issues/preview-trigger on open and blocked
the entire dialog behind a "检查中…" spinner until it landed. That query is
keyed per issue id with staleTime 0, so every new issue was a guaranteed
cache miss — the wait was structural, not incidental. The dialog also
promised "会立即开始工作" while still checking whether anything would start.
Redefine it as "dialog = confirm the assignment", not "confirm N runs":
- Open fires no request. Note box and both buttons are usable on frame one.
- Title/primary action become 确认指派; copy states the assignment as certain
and the run as conditional, with no predicted count.
- The write reports what actually happened: UpdateIssue returns runs_started
(0/1) and batch-update returns the batch total, surfaced as one short
toast — "已指派给 X,已启动 N 个 run", or just "已指派给 X".
runs_started counts successful enqueues rather than predicate hits, since an
insert can still no-op on the pending unique index. It is pointer+omitempty
like Labels, so only the PUT reply carries it, and it is stripped in
useUpdateIssue's onSuccess so a write-scoped fact never lands in the cache.
The handoff-note version gate stays local and synchronous, now covering
squads too by resolving the leader's runtime from the warm squad list —
removing the last reason the dialog needed the server round-trip.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
* test(issues): assert runs_started on the write responses (MUL-5010)
The assign-confirm dialog now reports the enqueue outcome from the write
reply instead of predicting it, so the number itself is the contract. Pin
it directly on the responses:
- single assign that enqueues → runs_started 1
- single assign that starts nothing → 0, via both routes (suppress_run,
and assigning into backlog), so a client can tell "no run" apart from
"old backend omitting the field"
- batch → the real aggregate, asserted against a mixed selection where
updated=3 but runs_started=2; the divergence from `updated` is the point
- absent on GET, guarding the pointer+omitempty contract that keeps a
write-scoped fact off every read path
Each count is cross-checked against agent_task_queue so the assertions
track real enqueues rather than the predicate.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
* refactor(issues): drop runs_started + result toast, keep silent completion (MUL-5010)
Final scope: the dialog only removes the preview wait; it does not add a
post-submit result toast. Per the confirmed decision, revert everything that
existed solely to report "N runs started" and keep completion silent, exactly
as it was before this PR — the assignee and any run already surface through
the issue's normal updates.
Removed:
- the success toast on submit and its two i18n keys (all four locales)
- the `runs_started` response field on IssueResponse, the single/batch write
counting, and dispatchIssueRun's bool return (back to void)
- the `runs_started` type/schema/api-client additions and the onSuccess strip
- server/internal/handler/issue_runs_started_test.go (only served the field)
Kept: preview-trigger removal and the instant-usable dialog — note box and
buttons live on the first frame, agent/squad handoff-note version gate resolved
locally, and "确认指派" copy. Net diff vs base is now frontend-only; the backend
and packages/core files are byte-identical to base again.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
* docs(issues): fix stale headline comment referencing removed result toast (MUL-5010)
The result toast was removed with runs_started, but the headline comment
still said "the real one arrives in the result toast". Reword to state only
what remains true: the copy names no run count because the run is conditional.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
* refactor(markdown): single canonical sanitize schema for both renderers (MUL-4922)
Phase 1 of the RichContent convergence: collapse the duplicated security
base shared by the two product-level Markdown chains.
Chat (packages/ui/markdown/Markdown.tsx) and Issue/Comment
(packages/views/editor/readonly-content.tsx) each carried a verbatim fork
of the rehype-sanitize schema and urlTransform, and the forks had already
drifted: readonly whitelisted <mark> for `==highlight==`, chat did not.
A security-relevant allow-list maintained in two places means every future
XSS fix has to land twice, and missing one is a hole — this is the hardest
reason for the sweep, ahead of the user-visible feature drift.
- Extract markdownSanitizeSchema + markdownUrlTransform into
packages/ui/markdown/sanitize.ts and export from the package index.
Both chains now import the single copy; no local forks remain.
- The canonical schema is the union, so chat gains the <mark> tag name.
This is the one intentional behavior delta: <mark> is inert and admits
no attributes, and chat needs it anyway once ==highlight== converges.
- Annotate the schema as rehype-sanitize's Options: exporting it makes the
previously-inferred hast-util-sanitize type unnameable across packages.
Adds a cross-surface contract test that runs one set of security fixtures
(script, event handlers, javascript: href, data:image vs data:text/html,
mark, slash://) through BOTH surfaces and asserts identical outcomes —
the mechanism that stops a third fork from growing back.
Code-block rendering is deliberately not asserted cross-surface yet: chat
highlights with Shiki, readonly with lowlight, so emitted class tokens
still differ. Converging them is the RichCodeBlock phase and needs the
highlight-engine decision first; only the schema-level allow-list is
shared here.
Verified: pnpm typecheck (6 workspaces), pnpm lint (0 errors),
pnpm vitest run in packages/views (228 files, 2665 tests, all passing).
Co-authored-by: multica-agent <github@multica.ai>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* feat(rich-content): one RichContent renderer for Chat and Issue/Comment (MUL-4922)
Phase 2+3 of the convergence. Chat now renders agent output through the same
product renderer as Issue descriptions and Comments, so a ```mermaid fence an
agent emits is a diagram in Chat — not a dead code block.
Before this there were two product Markdown chains. Issue/Comment had Mermaid,
HTML preview, lowlight code, product Mention/Attachment/LinkHover; Chat went
through a generic renderer with no Mermaid/HTML dispatcher at all. Chat is
where agents emit the most diagrams, so the gap was most visible exactly where
it mattered.
Architecture
- packages/views/rich-content/ holds the ONE product renderer: a single
ReactMarkdown pipeline, one sanitize config, one components map, one fenced
-code dispatcher. Public API is content/attachments/density/phase — no
`surface` prop, no renderMention override, no custom code-renderer hook,
because each is a door a per-surface fork walks back through.
- `density` is CSS only; `phase` is lifecycle only. Neither switches parser,
plugins or the semantic DOM, so all surfaces emit the same blocks.
- ReadonlyContent is now a ~40-line compatibility wrapper (was 501 lines);
its consumers are untouched. views/common/markdown.tsx is deleted.
- Leaf components (Mermaid, HTML preview, lowlight code) stay in editor/ and
are imported by direct path, so Tiptap keeps reusing them and Chat does not
pull in the editor's Tiptap graph. Moving them is a later mechanical commit.
Streaming fence gate
Rich blocks upgrade only when the fence is CLOSED, judged by a real CommonMark
parse (mdast) rather than a `startsWith("```")` scan. A half-streamed fence
renders as source, so Mermaid never parses a partial diagram and no iframe is
created for HTML still arriving. Closedness — not `phase` — is the gate, so a
settled-but-malformed fence stays source too. The gate returns offsets only; it
never renders, because a gate that rendered would be the second renderer this
change exists to delete. Verified that source offsets survive rehype-raw +
sanitize + katex before relying on them.
Live -> persisted row identity
The live timeline moved out of Virtuoso's Footer into a real row keyed
`task:<taskId>`, and persisted assistant rows key on the same task instead of
`message.id`. Completion is now an in-place data swap through one
AssistantMessage component, so an already-rendered diagram or iframe stays
mounted and is not re-run. The process fold previously relied on that remount
to re-collapse, so the collapse is now expressed directly.
Notes
- Chat code blocks move from Shiki to lowlight (Howard's ratified choice),
matching Tiptap/Readonly and the existing hljs CSS. Ligature suppression
carries over via code.css instead of utility classes.
- Chat message tests now stub react-virtuoso: real Virtuoso renders its Footer
but no data rows under jsdom's zero-height viewport, and the live timeline is
a row now.
Tests: five-surface Mermaid parity (Chat user / live / persisted, Issue
description, Comment), streaming open->closed->settled with mount counting,
live->persisted no-remount, htmlbars/mermaidx not misdispatched, sandbox and
data-URI security regressions, CommonMark fence edge cases (shorter closer,
tilde, indented, nested, in-list), and source-level import boundary guards.
Verified: pnpm typecheck (6 workspaces), pnpm lint (0 errors),
packages/views vitest 240 files / 2743 tests all passing.
Co-authored-by: multica-agent <github@multica.ai>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix(rich-content): drop dangling export, add export guard + lazy rich blocks (MUL-4922)
Two review follow-ups.
1. Dangling package export
`packages/views` still exported "./common/markdown" after the file was
deleted in the RichContent convergence. TypeScript resolves against the
source tree so typecheck and unit tests both passed; the subpath would only
fail when a consuming app bundled it. No consumer imports it, so this was
latent rather than broken in practice.
Removes the entry and adds packages/views/rich-content/package-exports.test.ts,
which walks every workspace package.json and asserts each export target
exists (wildcards checked by directory prefix). Scoped repo-wide because the
failure mode belongs to package.json, not to this package. Verified the guard
actually fails by re-adding the dangling entry before committing.
2. Near-viewport lazy shell for Mermaid / HTML
Implements the deferred performance contract rather than requesting an
exemption. LazyRichBlock defers each rich leaf until it is within 800px of
the viewport, then latches: once mounted it is never unmounted, so scrolling
past does not re-run Mermaid, rebuild the sandboxed iframe, or discard the
viewer's pan/zoom state.
The stable-size requirement is handled by reserving the block's expected
height before AND after mount, so a block never measures 0px off-screen and
then jumps — the churn that makes a virtualized list mis-estimate item sizes.
The reservation is not a local guess: reservedMermaidHeightPx() reuses the
existing session layout cache (real height on a cache hit, else the documented
280px skeleton) and HTML_BLOCK_PREVIEW_HEIGHT_PX is the pixel twin of the
preview iframe's fixed h-[480px]. Both are exported from the leaves so there
is one source of truth per block type.
Environments without IntersectionObserver (jsdom, SSR) mount eagerly, which is
today's behaviour; rendering nothing would not be.
Verified in real Chromium (jsdom has no IntersectionObserver, so unit tests
only exercise the eager fallback):
- Non-virtualized Issue/Comment with 25 diagrams: 3 mounted, 22 deferred on
load; after scrolling, mounted grows 3 -> 6 and never drops, confirming the
latch. This is where the win is real.
- Chat gains less: Virtuoso already caps the DOM to ~4 rows, so only 1 of 4
shells was deferred. Stated rather than overclaimed.
- Live -> persisted handoff re-checked with the shell in place: scrollTop
220 -> 220 (delta 0), the tagged diagram DOM node survived, and the run
reached the persisted state. Contract 1 is not regressed.
Tests: 6 lazy-shell cases (defer, mount-on-approach, latch/no-unmount, equal
reserved height before and after mount, root margin exceeding the chat list's
own overscan, no-IntersectionObserver fallback) plus 3 export-guard cases.
Verified: pnpm typecheck (6 workspaces), pnpm lint (0 errors),
packages/views vitest 242 files / 2752 tests all passing.
Co-authored-by: multica-agent <github@multica.ai>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix(rich-content): SSR-deterministic lazy state, mention a11y, drop type casts (MUL-4922)
Three review blockers.
1. Hydration mismatch in the lazy shell
LazyRichBlock derived its initial `mounted` state from feature detection
inside useState. On the server (no window) that resolved true and rendered
the whole Mermaid/HTML subtree; in the browser (IntersectionObserver present)
the hydration pass resolved false and rendered a placeholder — different
markup for the same component, and an SSR path that silently bypassed the
lazy gate. `"use client"` does not opt a component out of Next's server
render, so this was reachable.
Initial state is now unconditionally false on both sides. The eager fallback
for environments without IntersectionObserver moved into the effect, which
never runs on the server, so the first committed frame is identical
everywhere and the latch is unchanged.
The first version of the SSR test passed against the buggy component: jsdom
always provides `window`, so server and client took the same detection branch
and the mismatch was unobservable. The suite now removes IntersectionObserver
for the duration of renderToString to reproduce the real asymmetry. Verified
by reinstating the bug: 2 of the 3 SSR tests fail and React raises its own
"Hydration failed" error.
2. Project mention lost keyboard access
The unified renderer inherited the readonly surface's `<span onClick>` around
ProjectChip, while Chat had previously used AppLink — so converging the
surfaces regressed Chat from a focusable anchor to a mouse-only span. A span
and an anchor are visually identical and behave the same under a mouse, which
is why only an assertion on the emitted element catches it.
Now rendered through AppLink, which also owns plain-click, modifier-click and
the desktop new-tab adapter, so none of that is reimplemented. The wrapper
keeps only stopPropagation, matching IssueMentionCard.
Adds project-mention-a11y.test.tsx using the REAL AppLink and
NavigationProvider — mocking AppLink to emit an anchor would assert the mock.
Covers anchor + href, chip's nearest interactive ancestor, Tab focus, Enter
activation, click, and modifier-click labelling. All 6 fail on the old span.
3. Type suppressions in the production renderer
`as never` on the plugin lists and `as NonNullable<Components[...]>` on the
code/pre overrides silenced real type errors, against the repo's strict-TS
rule. Replaced with react-markdown's own types: RichCode/RichPre now derive
their props from `ComponentPropsWithoutRef<tag> & ExtraProps`, and the plugin
lists use `satisfies NonNullable<Options["remarkPlugins" | "rehypePlugins"]>`
so a bad plugin tuple still fails to compile.
Also removed the remaining casts this exposed: hast property reads went
through `as string` even though hast property values are a union, so a
non-string `data-*` attribute would have been typed as a lie — replaced with
a runtime-narrowing helper. `toHtml()` already returns string; its cast was
redundant. Node position reads are narrowed in one helper. No cast or
ts-ignore remains in production rich-content.
Verified: pnpm typecheck (6 workspaces), pnpm lint (0 errors),
packages/views vitest 243 files / 2780 tests all passing.
Co-authored-by: multica-agent <github@multica.ai>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix(rich-content): cached-height hydration, scroll-root + recycle latch, CDN reactivity (MUL-4922)
Three review blockers.
1. Cached reserved height still reached the first frame
The previous fix made `mounted` deterministic but left the *height* reading
sessionStorage during render: RichFenceBlock called reservedMermaidHeightPx()
inline. Server has no sessionStorage so it emitted 280px, while a browser with
a warm cache emitted the real height — a differing style="min-height:…" on the
frame React hydrates, which React reports as an attribute mismatch and does
not repair. Same bug class as the one already fixed, one layer up.
RichFenceBlock now splits into Mermaid/HTML components (so the height hook is
never conditional) and reserves the skeleton default on the first frame,
adopting the cached height in an effect. Zero-shift on a warm cache is kept;
only the read moves after hydration.
The earlier SSR suite passed a fixed reservedHeightPx straight to the lazy
shell, bypassing this path — it could not have caught this. New tests drive the
real RichFenceBlock with a real prefilled sessionStorage entry, and simulate
the server by removing sessionStorage for the renderToString call (jsdom
provides one, so without that the "server" takes the browser branch and the
mismatch is invisible).
2. Wrong observer root, and a latch that died with the row
The IntersectionObserver set only rootMargin, so it clipped against the
viewport — but Chat scrolls inside its own element (Virtuoso
customScrollParent). Expanding the viewport box says nothing about a nested
scroller, so Chat blocks only loaded once already visible and the preload was
effectively dead there. Surfaces now publish their scroll container through
RichContentScrollRootProvider and the observer uses it as `root`; page-scrolled
surfaces keep the viewport root.
Separately, the mount latch was component state, so Virtuoso recycling a row
discarded it and scrolling back re-ran Mermaid, rebuilt the iframe and dropped
viewer pan/zoom — the per-pass cost the shell exists to prevent. The latch moved
to a module-level registry keyed by a hash of the content, bounded at 500
entries with oldest-first eviction. Restore happens in a layout effect (before
paint, so no placeholder flash) and never in initial state, keeping the
server/client first frame identical. Scope was not narrowed.
3. CDN config arriving late never reprocessed content
preprocessMarkdown read configStore.getState().cdnDomain imperatively and
RichContent's memo depended only on `content`, so content rendered before the
async config landed kept legacy CDN links as plain anchors permanently.
cdnDomain is now an explicit parameter: RichContent subscribes via
useConfigStore and puts it in the memo deps, while the Tiptap editor — which
genuinely preprocesses once at load — reads the store at its own call sites. No
fallback branch.
Each fix was verified by reinstating its bug and confirming the new tests fail
(2/5, 2/14 and 1/3 respectively) rather than trusting a green run.
Also fixes a false positive in the boundary guard: the Tiptap check read raw
file text instead of using the suite's stripComments helper, so a doc comment
mentioning RichContent counted as a violation.
Verified: pnpm typecheck (6 workspaces), pnpm lint (0 errors),
packages/views vitest 245 files / 2793 tests all passing.
Co-authored-by: multica-agent <github@multica.ai>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---------
Co-authored-by: multica-agent <github@multica.ai>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
The create-issue dialogs wire pickers with open={cond ? true : undefined}. Base UI latches a controlled open={true} and does not treat a later undefined as closed, so the project dropdown remains visible after selection. Normalize the picker to an always-boolean open state, matching the other field pickers.
Co-authored-by: 余剑剑 <clydeyu@lilith.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Align the project list view control with the issue list: instead of a
button that flips table <-> cards on click, the trigger now opens a
dropdown menu with a 'View' header and a radio group of view modes
(Table / Cards). Using DropdownMenuRadioGroup keeps it trivial to add
future view modes as new menu items.
Co-authored-by: Bohan-J <bohan@devv.ai>
Co-authored-by: multica-agent <github@multica.ai>
* feat(issues): live-resort board/list on updated_at drift (MUL-5016)
An open Kanban board / list sorted by "Updated date" did not re-sort in
real time when a card's updated_at advanced — it only picked up the new
order on the next fetch (navigation, invalidation, etc.).
Two triggers now re-sort live:
- comment:created: a new comment bumps the parent issue's updated_at
server-side (MUL-5009), but the WS handler only invalidated the
per-issue timeline cache. It now also invalidates loaded updated_at-
sorted issue-list/board/flat keys via invalidateUpdatedAtSortedIssue
Lists, so the commented card re-sorts (and an off-window card can
surface). Only updated_at-sorted keys are touched.
- same-status field edit: applyIssueChange already reconciled flat
windows on updated_at-sort drift but the bucketed board only marked
stale on membership/status change. It now marks an updated_at-sorted
bucketed key stale whenever the patch advances updated_at, mirroring
the flat-window rule. Deferred to onSettle for mutations (WS flushes
immediately), respecting the existing timing contract.
Extracted patchChangesAnyIssueField shared helper. Tests cover the
coordinator drift/targeting and the comment:created wiring.
Co-authored-by: multica-agent <github@multica.ai>
* fix(issues): cover assignee-grouped boards and property/metadata events in updated_at re-sort (MUL-5016)
Addresses Elon's review of #5671.
Must-fix 1: invalidateUpdatedAtSortedIssueLists missed assignee-grouped
boards. They fold the sort into their filter bag (issueAssigneeGroupsOptions
does `{ ...filter, ...sort }`) rather than a standalone sort key, so the old
bucketed+flat enumeration skipped them. Replace it with a single predicate
invalidateQueries over issueKeys.all(wsId) that matches any query key with an
object part carrying sort_by: "updated_at" — covering status boards, flat
tables, and assignee-grouped boards (workspace + My Issues) with one rule.
Must-fix 2: custom-property and metadata edits also bump issue.updated_at
server-side (SetIssuePropertyValue / DeleteIssuePropertyValue /
SetIssueMetadataKey / DeleteIssueMetadataKey) but flow through
issue_properties:changed / issue_metadata:changed, not applyIssueChange.
Their handlers patched cards in place and only invalidated property/My-Issues/
grouped windows, so an updated_at-sorted workspace board or flat table stayed
in the old order. Call invalidateUpdatedAtSortedIssueLists from both
onIssuePropertiesChanged and onIssueMetadataChanged. Both are committed-only
paths (WS event + mutation onSuccess); the optimistic leg still uses
patchIssueProperties, so no premature refetch.
Tests: grouped-board targeting in cache-coordinator; updated_at vs position
re-sort for onIssueMetadataChanged / onIssuePropertiesChanged (incl. the
optimistic leg not refetching) in ws-updaters.
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: Bohan-J <bohan@devv.ai>
Co-authored-by: multica-agent <github@multica.ai>