Clicking the edit composer's send arrow did nothing — the edit only went
through via revert. On macOS a <button> takes no focus on mousedown, so the
arrow-click blurred the contenteditable, the blur's 80ms timer cancelled the
edit and tore down the assistant-ui composer core, and the click's send() then
threw "Composer is not available" against the dead core. submitEdit had already
set submitting=true, so the arrow wedged and only revert worked.
Guard focus on the send button with onPointerDown preventDefault, the same way
the restore button does, so the click never blurs the editor. Also wrap the
send() and the blur-timer cancel() in the #49903 unguarded-core swallow so a
raced teardown can never wedge the arrow or leak an uncaught renderer error.
Promote the session row's inline MenuKit device into components/ui/
actions-menu.tsx: one ActionsMenu (kebab) + ActionsContextMenu (right-click)
pair driven by a single items(kit) render function, so a row's dropdown and
its context menu can't drift. Refactor the session menu onto it and let
StatusRow forward ref/onContextMenu so any row can host a context menu.
The strips painted with `--theme-card-seed` — the raw, unmixed seed rather
than a surface token — so they read as their own band beside the sidebar and
titlebar, which share `--ui-bg-sidebar`.
Fixed at the default in `PaneTab` rather than per strip. The right-rail
preview strip never set the vars at all, so its inactive tabs fell through
to the seed even though its container was already on the sidebar surface;
correcting the fallback fixes that one for free and keeps the next strip
from regressing. With the default right, the two zone strips no longer need
to redeclare the var and paint the token directly.
The preview pane shares a collapsible column with the file tree, and
`revealTreePane` un-collapses a column through that column's bound store —
which on the right is `$fileBrowserOpen`, the tree's own ⌘J toggle. So
every preview open literally called `setFileBrowserOpen(true)` and the tree
came with it.
`revealPreview` now un-collapses the column directly and leaves the toggle
alone. The tree pane's visibility binding gains `$fileBrowserOpen` to match,
since its presence was tracking only the column's collapse — without that it
would still render the moment anything opened the column.
`$activeSessionId` only ever holds the primary chat's session, but surfaces
that render once per transcript were reading it as if it meant "the session
on screen." A preview produced inside a session tile was recorded under the
main chat's key and surfaced in the main chat's composer, which is what
prompted this.
The tool row now records under its own `SessionView`, and the same fix
applies to the other readers of that atom that render per surface:
attachment pills and inline preview links resolve relative paths against
their session's cwd, composer voice and auto-speak read and subscribe to
their own transcript, and the thread's compaction label, prompt-wait gate
and turn timer follow the session that mounted them. `ComposerScope` now
carries a `$messages` atom rather than a read closure so both the
imperative read and the subscription come from one place.
The right rail held two things at once: a list of file tabs, and a
privileged "live preview" slot with a hardcoded `preview` tab id backed by
a separate session-keyed registry. The two were written under different
session-id rules and reconciled against each other, so an `open_preview`
from a session whose stored id hadn't landed yet was set and then
immediately cleared — the pane flashed and vanished. Artifacts arrived as
a third list with their own pane and renderers.
Now everything the rail can show is a `PreviewTarget` in `$previewTabs`,
and `openPreview` is the only way in. `$previewTarget` is a computed read
of the active tab, the session registry and its reconciler are gone, and
artifacts render in the real preview pane through the shared mode switcher
and source view instead of a parallel one. Artifact tabs stay memory-only
since the registry rebuilds from the transcript.
The seam hairline sat at full strength on every split, so an empty
workspace read as a wireframe. Hold it at 0.1 and bring it up with the
grab band already on hover.
The active tab was defined by absence: the strip painted a rule and the
tab covered it, so inactive tabs stopped a pixel short to let it show.
Draw the state instead. The tab carries its own 2px --theme-primary
underline and the strip's rule goes away, which lets tabs run full height
and removes PANE_TAB_STRIP_LINE along with it.
DESIGN.md already said app-wide surfaces must not compete through ad-hoc
z-index literals, and the code disagreed: three overlapping numbering
schemes, and comments narrating the fight ("defaults to z-130, renders
UNDER the onboarding overlay (z-1300) ... bump it above with z-[1310]").
Picking a number meant reading someone else's near miss.
Name the rungs — modal, over-modal, switcher, and the boot chain — and
point the call sites at them. Every rung keeps the exact value it had, so
nothing moves; what changes is that the next overlay has a name to reach
for instead of a number to guess. Local stacking within a component stays
on plain z-10/z-20.
The tail "Hermes is thinking" indicator resets on every flush, but its
timer never did: with no timer key, useElapsedSeconds anchors to mount,
and the indicator mounts with the assistant message. A stall two minutes
into a turn therefore claimed two minutes of silence instead of the two
seconds that had actually passed.
Give the hook an explicit epoch and hand it the timestamp of the activity
the quiet spell followed. Compaction still counts from the turn's start,
which is the span it owns.
Diff add/remove lines were hardcoded to Tailwind's emerald/rose while the
overview ruler beside them — and the rest of the app — used --ui-green /
--ui-red, so every diff sat slightly off-brand and stayed put when the
semantic palette moved. Derive the tint, gutter, and text from those two
colors instead. One renderer feeds the tool card, the file preview, and
the review pane, so all three follow.
`align-middle` centers a pill on the x-height midpoint, which sits above
the center of the surrounding text box, so chips rode visibly low against
the words they're nestled between. Measured against the rendered surface,
`-0.12em` lands the chip's own baseline within 0.08px of the line's
(vs 0.79px off before) without growing the line box.
Applies to both the directive and slash chip classes — they share a line,
so fixing one and not the other just moves the mismatch.
* fix: Branch button is a dead no-op inside a branched chat tile
session-tile.tsx wired onBranchInNewChat to () => undefined for
tiled/branched sessions (nested branching isn't supported there), but
the button in AssistantMessage's action bar rendered unconditionally
regardless of whether a real handler was supplied. The button looked
clickable but silently did nothing, with no visual feedback.
- AssistantMessage now only renders the Branch button when
onBranchInNewChat is actually provided, matching the existing
pattern used for onDismissError/onRestoreToMessage.
- session-tile.tsx no longer passes a no-op handler; the prop is
simply omitted so the button doesn't render in tiles.
- onBranchInNewChat is now optional on ChatViewProps, and the
latestChatActions passthrough wrapper uses the existing
latestOptional helper instead of an unconditional call.
* test: assert Branch button visibility matches handler presence
Adds coverage for the bug #2 fix: renders Thread with and without an
onBranchInNewChat handler and asserts the Branch in new chat button
is shown only when a real handler is supplied, hidden otherwise -
covering both the normal open-chat case and the session-tile
(branched chat) case that used to leave a dead, clickable button.
Deferring the FIRST_PAINT_BUDGET -> RENDER_BUDGET backfill while a thread
streams cut a 1374ms streaming-session switch to instant, but it also
means a streaming transcript stays clipped to 60 parts for the duration
of the run. `large-session-resume` asserts the resumed transcript shows
every seeded reply exactly once, and that count is short while the budget
is held down — a genuine behavior change, not a flaky test.
The switch cost is real and still worth fixing, but the fix has to keep
the full transcript mounted (raise the budget in idle callbacks, or
virtualize) rather than withhold it. Session-switch work is happening in
a parallel effort; leaving the invariant intact for them.
Everything else in this branch is untouched: the reflow-gated RO pins
(11.5 -> 59fps drag), the structural/weight signature split, the adaptive
stream flush, the tree-split preview, and the tool-row memo boundaries.
Switching to a STREAMING session took ~1.4s to settle while an idle
session settled in ~50ms. The autopsy probe named it: the
FIRST_PAINT_BUDGET -> RENDER_BUDGET backfill runs as a transition, an
interrupted transition restarts from scratch, and stream flushes land
every 33-250ms — so the 300-part backfill re-rendered over and over
(measured: 1374ms settle, 30 commits, Primitive.div x2237 for one switch).
Gate the backfill on the thread being idle. The user lands on the live
tail immediately either way; older turns backfill the moment the run
ends, and 'Show earlier' remains the manual path meanwhile.
Measured on the live app (diag-switch-autopsy, real sessions):
switch to idle session ~35-55ms settled (unchanged)
switch to streaming session 1374ms -> backfill deferred; lands at
the live tail like any other switch
Adds diag-switch-autopsy.mjs (per-switch settle/commits/top-renders) and
live-drive.mjs (status/fps/drag one-liners against the running app).
Driving HER real instance (real profile, real transcripts, streams live)
via CDP instead of synthetic tiles finally exposed the remaining stall.
The timeline on a real 60-frame sash drag:
style recalc 2736ms | script 1027ms | layout 89ms
top callsite: pin @ fallback.tsx — 927ms
Two pin-to-bottom ResizeObservers (the bounded tool window's and the
reasoning preview's) pinned on EVERY resize delivery. A sash drag changes
every message's WIDTH once per frame, so each frame ran scrollTop write ->
scrollHeight read across every tool group: a forced write-read reflow
cascade that the render counters could never see (zero React involvement).
Both pins are now height-gated off the RO entry (reflow-free): only
content GROWTH pins. Width-only deliveries return immediately.
Measured on the live app, same drag, before -> after:
fps 11.5 -> 59-60
p95 101ms -> 18ms
slow>33 60/60 -> 1/60
Also in this batch (each was verified live before the next was attempted):
- thread/list: split messageSignature into STRUCTURAL (ids/roles — keys
boundaries + row identity) and WEIGHT (part counts — budget only), and
memoize groups + row JSX. A streamed part-append re-rendered every
turn's boundary via its resetKey prop; explain() measured 540-865
wasted Block renders per drag/stream sample, now {}.
- message-render-boundary: document the structural-only resetKey contract.
- tool/fallback: memoize ToolFallback's part object + ToolEntry/ToolTitle/
ToolGlyph (151 renders each, 100% wasted, on real transcripts).
- use-message-stream: ADAPTIVE flush floor — next flush waits 3x the
measured cost of the last one (33ms floor, 250ms cap), so multi-stream
load degrades text update rate instead of input latency.
- tree-split: preview sash drags with inline flex on the two seam
wrappers, committing the store ONCE on release (fixed-zone sides get
flexBasis only, so a hidden sidebar can't leave a phantom gap).
- debug/: perf-live LoAF long-frame attribution, explain() cascade walker
with changed-hook indices, diag-real-loop/key-latency/switch-trace
probes that drive the real app over CDP.
Typing during 2 live streams: keystroke->paint p50 3.3ms, p95 18.4ms,
zero frames over 33ms. Session switch p50 ~35ms settled; the remaining
~1.3s outlier tail is streaming-session switches (React work-loop, not
style/layout) — next target.
Kimi Coding discovers the flagship as wire id `k3`. Picker search used
only that id, so typing "kimi" hid it next to every other kimi-* model.
Add picker-only search aliases without changing the wire id.
- artifact card title/meta/open-hint now use --conversation-text-font-size /
--conversation-tool-font-size like CodeCard and the rest of the transcript,
instead of hardcoded rem literals
- drop no-op hover:border-border (border is border-border at rest)
- comment the deliberate raw bg-white + colorScheme:light on the sandboxed
iframe so it doesn't read as an untokenized literal
Substantial generated content (full HTML documents, large SVGs, long code)
now promotes out of the transcript into versioned, openable artifacts:
- lib/artifact-detect.ts: pure detection over fenced blocks — html docs,
large standalone svg, and 48+ line / 3k+ char code fences become
artifacts; prose/terminal/mermaid fences and small snippets never do.
Titles derive from <title>/<h1>, filename comments, or declarations.
- store/artifacts.ts: per-session registry with content-hash dedupe and
version history (same kind+title = one artifact the model iterates on),
persisted to localStorage with per-session/per-artifact/byte caps.
- ArtifactCard (transcript): compact openable card replaces the wall of
code; streaming shows shimmer + line count; versions accumulate
automatically on completion but the rail only opens on click
(offer, don't hijack). Reasoning scratchpads never register.
- ArtifactPane (right rail): artifact: tabs beside preview/file tabs with
version stepper (v2 of 3 / Latest), PREVIEW/SOURCE toggle, copy,
download (kind-aware extension), open-in-browser for HTML.
- Rendering: HTML runs in a sandbox=allow-scripts iframe (opaque origin,
no network to the app, no top-nav); SVG is DOMPurify-sanitized with the
same profile as the inline embed; source view reuses the windowed Shiki
renderer so 5k-line artifacts scroll smoothly.
- Rail integration: artifact tabs participate in tab order, close-others/
close-to-right, ⌘W, pane visibility, and reveal; gateway switches close
open artifact tabs; a persisted artifact: active-tab id reconciles to
preview on boot (artifact tabs are ephemeral).
- i18n: en/zh/zh-hant/ja/ar strings for card + pane.
- session-ref-open.test.tsx: mock now spreads the real module — the
artifact card imports session-view, which needs $sessionStates.
Tests: artifact-detect (15), artifacts store (12), markdown-pipeline
integration (3); full desktop suite 3128 passed, electron project 751
passed, tsc no new errors.
The component map Thread passes to the virtualizer listed the
onBranchInNewChat / onCancel callbacks as useMemo deps. Whenever a parent
re-render handed down a fresh callback identity, the memo rebuilt the map
and produced new component *types*, so React unmounted and remounted every
visible message. Async-rendered parts (shiki code blocks) collapsed and
re-expanded on each remount, making the whole thread visibly jump.
That is exactly what shipped in v0.15.1: the desktop controller passed an
inline arrow for onBranchInNewChat, and the 15s status-snapshot poll
re-rendered the controller, so threads with code blocks jumped every 15
seconds (layout-shift scores of 0.39 + 0.47 per cycle, measured via CDP).
arrow away from regressing.
Route the callbacks through a ref so the component types survive any
parent re-render; only the callbacks' definedness stays a dep, because it
gates UI (the user-message Stop button). Add a regression test that fails
on the old code by asserting message DOM nodes keep their identity when
callback props change identity.
Tested on macOS arm64 (vitest + rebuilt app, CDP layout-shift
instrumentation confirms zero shifts over multiple poll cycles).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Every `Tip` carried its own `TooltipProvider`, and there are ~107 call
sites. Each is a subtree that re-renders when anything above it does, so
they dominated unrelated interactions: 52,784 TooltipProvider renders and
18.3s of component time in a single sash drag.
Radix's provider holds only refs and stable callbacks (no reactive state)
— hoisting one to the app root is what it is designed for. `Tooltip`
still reads delayDuration/disableHoverableContent from context, and the
per-Tip overrides are preserved.
`Tip` keeps a local provider as a FALLBACK, chosen by context: a
component rendered in isolation has no root provider and Radix throws
"`Tooltip` must be used within `TooltipProvider`". Without this, 20 unit
tests that render a single control fail. Inside the app the flag is
always true, so the common path is a bare Tooltip.
This is the shape the earlier lazy-mount attempt should have taken. That
one deferred the Radix subtree until hover, which moved
data-slot="tooltip-trigger" off the mounted DOM and broke 18 tests
encoding that contract. Hoisting keeps the contract intact — every one of
those tests passes unchanged.
Measured on the same drag:
TooltipProvider 52,784 renders / 18.3s -> gone from the table
Primitive.div 40.5s -> 13.4s
Popper 10.5s -> 2.7s
Tooltip 15.7s -> 4.4s
The status bar shipped every affordance it had, so approvals, the terminal
toggle, agents, cron and webhooks sat there permanently for users who never
touched them.
Those five now start hidden and the bar owns a context menu that turns them
back on, persisted per install. Items opt in by naming themselves with
`toggleLabel`, so a plugin contribution that doesn't opt in always shows;
the system icon and the version/update pills are listed but locked on,
since hiding the way back into settings strands the user.
Preferences store the hidden set rather than the visible one, so an item
added to the bar in a later version appears for existing users instead of
staying silently off.
Electron's findInPage selection is per-webContents, not per-route: without
an explicit teardown, the highlight overlay (and a stale match counter)
from one chat survived navigating to another session or a settings page.
The FindBar now closes itself via effect cleanup keyed on the router
pathname — the first render never fires it, a route change tears down the
previous route's search, and unmount (the session/profile switch paths
that remount the global overlays) gets the same teardown. closeFindBar is
already idempotent, so a closed bar never re-enters the bridge, and a
navigation with the bar closed makes zero bridge calls.
Tests (find-bar.test.tsx 42 -> 48):
- navigation closes the bar, resets the store, and calls
stopFindInPage exactly once (highlight cleanup on session switch)
- navigation with the bar closed never reaches into the bridge
- keybind registration pins: view.findInPage is mod+f in the view
category; mod+f passes comboAllowedInInput (so ⌘F fires from the
composer instead of typing 'f'); the find-next/find-previous pair
ships unbound while view.toggleReview keeps mod+g; all three actions
have en + zh labels for the keybinds panel
The component tests now render inside a MemoryRouter (FindBar reads
useLocation), with a navigation harness that captures useNavigate.
PR #53891 (cherry-picked in the three preceding commits) landed the Electron
bridge, the store, the find bar overlay, and Cmd/Ctrl+F to open. It stopped
short of the rest of the accelerator set and had two lifecycle leaks. This
completes the surface to match the platform convention that Chrome, Safari,
VS Code, and Claude Desktop's own findInPage bundle all ship.
Accelerators now wired:
- Cmd/Ctrl+F open the find bar (view.findInPage, from the PR)
- Cmd/Ctrl+G find next (new)
- Cmd/Ctrl+Shift+G find previous (new)
- Enter / Shift+Enter step from the input (from the PR)
- Escape close + stopFindInPage('clearSelection') (from the PR)
Keyboard ownership (the substantive fix)
Cmd+G was already bound to `view.toggleReview` and Escape to
`composer.cancel`. The find bar's own capture-phase window listener cannot
win those keys by calling stopPropagation: the keybind dispatcher's listener
sits on the SAME window target in the SAME phase, and propagation control
does not suppress sibling listeners on one target. Left alone, Cmd+G would
step a match AND toggle the review pane, and Escape would dismiss the bar AND
abort a running turn.
So ownership is decided by the dispatcher, which AGENTS.md already names the
single owner of combo dispatch: `findBarClaimsCombo` is consulted in
use-keybinds before the registry lookup, and yields mod+g / mod+shift+g /
escape to the bar only while it is open. Closing the bar hands every one of
them straight back. That is the "keyboard ownership follows focus / one cancel
gesture does exactly one thing" invariant.
`view.findNext` / `view.findPrevious` are registered with EMPTY defaults on
purpose — shipping mod+g as a second default would flag a permanent conflict
in the keybinds panel against view.toggleReview. The entries document the pair
and let a user bind a dedicated chord; stepping is a no-op unless the bar is
open with a query, so a bound key can never search invisibly.
Listener-leak fixes
- The found-in-page bridge subscription is now refcounted in the store, so a
remount (the connection re-home path remounts the global overlays) cannot
stack duplicate subscribers that each re-dispatch the same result and
outlive their component. The subscription is deliberately mount-scoped, not
active-scoped: results for an in-flight search must still land if the bar
just closed.
- `setFindQuery` now refuses to search a closed bar. The component clears its
debounce on close, but a 200ms timer that already fired would re-issue a
find and re-highlight the page after the user pressed Escape. Caught by the
test, fixed in the store rather than papered over in the component.
- `closeFindBar` is idempotent — Escape is a shared gesture, so a second close
must not reach into Electron again.
Pure logic extracted for testing (no source regexing)
- `src/lib/find-in-page.ts`: `formatMatchLabel` (three distinct counter
states: hidden with no query, explicit 0/0, ordinal/count; clamps the
ordinal and never emits NaN — Electron legitimately reports ordinal 0 on a
non-final update), `findBarKeyAction` (the keybinding matcher, DOM-free),
and `findBarClaimsCombo` (the ownership predicate above).
Also: match counter and buttons get accessible names and the counter is
aria-live, the hardcoded English "Previous"/"Next"/"Close" tooltips move to
i18n (en + zh) alongside the new keybind labels, and the input gets an
aria-label so the bar is reachable by role.
Tests: apps/desktop/src/components/find-bar.test.tsx — 42 cases over the
pure helpers, the store (open/close, next/prev dispatch shape, escape clears,
refcount, double-release), and the component (focus on open, debounce
coalescing, Cmd+G from outside the input, unmount releases both the bridge
subscription and the window listener).
cd apps/desktop && npx vitest run src/components/find-bar.test.tsx \
electron/find-in-page.test.ts
-> 62 passed (42 new + 20 from the PR)
Adjacent suites (src/lib/keybinds, src/i18n, src/store): 487 passed.
`tsc -p tsconfig.electron.json --noEmit` clean; `tsc -p .` has 114 pre-existing
errors vs 120 on the merge base (all @assistant-ui / bippy / composer), none in
the touched files. eslint clean on every touched file.
Co-authored-by: David Metcalfe <DavidMetcalfe@users.noreply.github.com>
Brings forward the renderer-side changes from PR #53891,
adapted to the current main branch (where app-shell.tsx
has been replaced by apps/desktop/src/app/contrib/wiring.tsx
and keybinds/actions.ts has gained new view.* entries):
- apps/desktop/src/store/find-in-page.ts (new): nanostores
atom + actions for the find bar (openFindBar, closeFindBar,
setFindQuery, findNext, findPrevious, updateFindResults,
initFindInPageListener). openFindBar is dispatched by the
view.findInPage keybind handler in use-keybinds.
- apps/desktop/src/components/find-bar.tsx (new): the find
bar overlay (top-right, below the titlebar). Debounces
input 200ms before issuing findInPage, focuses on open,
supports Enter (next) / Shift+Enter (previous) / Escape
(close), shows a "3/12" match counter from the
'hermes:found-in-page' stream. Global capture-phase
Escape listener so the bar closes regardless of focus.
- apps/desktop/src/lib/keybinds/actions.ts: adds
view.findInPage with default combo 'mod+f'. The keybinds
runtime already routes any mod+ / ctrl+ combo through
editable-focus contexts (see comboAllowedInInput in
lib/keybinds/combo.ts:193), so ⌘F focuses the find bar
instead of typing 'f' into a textarea — matches browser
behavior.
- apps/desktop/src/app/hooks/use-keybinds.ts: wires
view.findInPage → openFindBar in the global handler map.
- apps/desktop/src/app/contrib/wiring.tsx: mounts <FindBar />
alongside the other global overlays (CommandPalette,
SessionSwitcher, etc.).
- apps/desktop/src/i18n/{en,zh}.ts: labels
'view.findInPage' for the keybinds panel.
Closes#46169
A run of 3+ adjacent tool calls collapses into the `.tool-group-scroll`
window, but `shouldBoundToolGroup` took `hasUnboundable` as a run-level
veto: a single exempt row anywhere in the range disabled collapsing for
the entire run. The exempt set was clarify, image_generate, execute_code,
read_file and every file-edit tool — which is most of what a coding
session does, so in practice runs never collapsed. Replaying a real
session's transcript: 84 consecutive calls, 30 of them veto-triggering,
zero windows.
The code-body entries were never needed. Everything ToolEntry renders
carries `data-tool-row`, and the `:has([data-tool-row][data-tool-open])`
rule already lifts the cap and the mask. A diff row mounts open, so it
frees the group the moment it appears; a collapsed row is a one-line
status whose body is not in the DOM at all, so there is nothing to clip.
Collapsing the row drops the group back to a compact window, which the
JS veto could not do.
Narrow the opt-out to the two components that bypass ToolEntry and so
can never emit `data-tool-row`: clarify and image_generate.
The edit composer pastes through the same `insertComposerContentsAtCaret` the
main composer uses, so it had the identical bug: the Range-based insert never
reaches Chromium's undo stack and Cmd+Z skipped past the paste to destroy the
edit before it. Its inline-ref and trigger-chip inserts mutate the DOM directly
too, with the same result.
Both surfaces now share `useComposerUndo`. The hook already keys its
document-level `beforeinput` claim off `document.activeElement`, so the two
mounted instances stay independent — only the focused editor's stack responds.
Undo/redo is handled ahead of Escape here, since a stray Cmd+Z falling through
would cancel the whole edit rather than step back one change.
`insertRefStrings` banks through `withUndoPoint` rather than recording after the
insert, which would have snapshotted the state it was meant to restore.