Captures medians of 5 runs for multitab and render-churn so tonight's
wins can't silently regress.
idle-cost is deliberately NOT gated. Its render attribution and idle
commit rate are trustworthy and are what the scenario exists for, but the
drag fps it reports (~0.6fps, p95 814ms) contradicts a direct
single-clock probe of the same gesture on the same build (57fps). I ruled
out sash selection, tile setup, render-counter residue, and a 20s soak,
and could not explain the gap — so the metric ships as a report, not a
gate. Gating CI on a number I can't defend would either fire on a phantom
or mask a real stall.
tier: 'report' is outside GATED ('ci','cold'), so the scenario still runs
and prints but neither compares nor writes a baseline.
withFrames ran its own requestAnimationFrame ticker while the gesture body
independently awaited rAF per step. Two rAF consumers, so the observer's
deltas counted the driver's frames as well as the app's — it reported
~3fps for a drag that a single-clock probe measures at ~23fps, and it
never moved no matter what got fixed underneath.
Timing now comes from the same callbacks the body drives (__MARK__).
This also fixes a silent false-negative on the typing pass: it paced on
setTimeout, so the independent ticker was mostly sampling idle waits
between keystrokes and reported a flat 61fps. On the driving clock the
same interaction reports ~30fps with 27 of 40 frames over 33ms — which
matches the 'typing feels slow' symptom I previously could not reproduce.
TYPE now records __TYPE_TARGET__ and the runner throws when no composer is
found, so a pass that measures nothing fails loudly instead of scoring a
perfect 0 deficit — same guard DRAG already had.
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
A CDP trace of one sash drag settled what the render counters could not.
I had assumed the remaining cost was layout/paint; it was not:
script 6770ms | style 1866ms | layout 71ms
Layout was never the problem. The top attributable callsite in our own
code was use-resize-observer.ts at 977ms.
Counting the callbacks named the mechanism exactly: 8,620 ResizeObserver
instances constructed, and during a 40-move drag 2,600 callbacks each
carrying exactly ONE entry — 65 separate callbacks per pointermove. Every
consumer owned a private observer, so N elements resizing under a common
ancestor meant N trips through the observer machinery instead of one
batched delivery. With five mounted tiles that is ~100 user bubbles, each
with its own observer, all woken by a width change.
One shared observer with a WeakMap of target -> handlers. Callers keep
their exact contract: a handler observing several elements is still
invoked once with all of its entries, and unobserve happens when the last
handler for an element goes away.
Verified by trace, before -> after:
use-resize-observer 977ms -> 42.5ms (-96%)
style recalc 1866ms -> 1145ms (-39%)
total script 6770ms -> 3929ms (-42%)
Callback count 2,600 -> 43: one delivery per frame instead of 65.
Adds the two probes that found it. diag-drag-trace.mjs takes a real
timeline trace and prints the style/layout/script split plus the top
script callsites — that split is what disproved the layout theory.
diag-ro-storm.mjs counts RO callbacks vs entries, which is what
distinguished 'a few expensive calls' from 'very many cheap ones'.
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>
Vitest coverage for apps/desktop/electron/find-in-page.ts.
The helpers and the IPC handlers in main.ts are the only
consumers, so the tests pin the wire shape (match counter
shape, options defaults, no-throw-on-destroyed) and the
multi-window correctness that the original CJS PR missed:
- formatFoundInPage:
- Maps { activeMatchOrdinal, matches } → wire payload.
- Coerces missing fields to zero (the renderer never
sees NaN).
- Tolerates null / undefined inputs.
- performFind:
- Forwards query + options to webContents.findInPage.
- Defaults forward=true and findNext=false when omitted.
- Treats null / non-object options as "all defaults".
- Coerces a non-string query to string (defensive against
a misbehaving renderer).
- Is a no-op on null webContents.
- Is a no-op on destroyed webContents (does not throw
across the IPC boundary).
- stopFind:
- Calls stopFindInPage with the default action
('clearSelection').
- Honors an explicit action argument.
- Is a no-op on null or destroyed webContents.
- installFoundInPageForwarder:
- Forwards 'found-in-page' to the sender as a formatted
payload.
- Handles missing fields without throwing.
- Skips send when webContents is destroyed at fire time.
- Returned uninstall removes the listener.
- Returned uninstall on null/destroyed webContents is a
safe no-op.
- Regression: two forwarders installed on distinct
webContents do not cross-fire. This is the bug the
original PR shipped — the global mainWindow listener
routed results to the primary regardless of which
renderer invoked findInPage. Pinning this here keeps
the per-sender routing from regressing.
Run with:
cd apps/desktop && npx vitest run electron/find-in-page.test.ts --project electron
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
The original PR targeted the CJS Electron files
(apps/desktop/electron/main.cjs and preload.cjs), but commit
39d09453f "feat(desktop): ts-ify everything" renamed them to
main.ts and preload.ts on current main. The PR's diff therefore
targeted files that no longer exist on main.
Brings the bridge forward to the current TypeScript Electron
files and extracts the IPC bridge helpers into a focused
pure-helpers module:
- apps/desktop/electron/find-in-page.ts (new):
- performFind(webContents, query, options) — wraps
webContents.findInPage with default-coercing options.
- stopFind(webContents, action) — clears highlights.
- formatFoundInPage(result) — pure projection of
Electron's FoundInPageResult onto the wire payload shape
({ activeMatchOrdinal, count }).
- installFoundInPageForwarder(webContents) — wires a
sender-scoped 'found-in-page' forwarder; returns an
uninstall function. Returns a no-op uninstall for null
or destroyed webContents so callers don't need guards.
- apps/desktop/electron/main.ts:
- ipcMain.handle('hermes:find-in-page', event => ...)
resolves the requesting window via
BrowserWindow.fromWebContents(event.sender) and routes
the search to THAT window, not the global primary. This
fixes a multi-window bug where Cmd+F pressed in a
secondary session window (one per chat, spawned via
hermes🪟openSession) searched the primary window
instead of the focused surface.
- ipcMain.handle('hermes:stop-find-in-page', event => ...)
routes stopFind through the requesting window for
multi-window correctness.
- A per-sender lazy forwarder registry
(foundInPageForwarders: Map<webContentsId, () => void>)
installs installFoundInPageForwarder on first
findInPage call, scoped to the sender's webContents.
Cleans up automatically via webContents.once('destroyed',
...). The forwarder sends results back to the SAME
renderer that initiated the search, never the global
primary — so a secondary session window's Cmd+F shows
matches from THAT window and the match counter reports
matches from THAT window's DOM.
- apps/desktop/electron/preload.ts:
- hermesDesktop.findInPage(query, options) — invokes
the IPC handler.
- hermesDesktop.stopFindInPage() — invokes the IPC
handler.
- hermesDesktop.onFoundInPage(callback) — subscribes to
'hermes:found-in-page' results from the sender;
returns an unsubscribe function so the FindBar can
clean up on unmount.
- apps/desktop/src/global.d.ts:
- Three new hermesDesktop method declarations:
findInPage, stopFindInPage, onFoundInPage. The new
forwarder install registers a 'found-in-page' listener
bound to the sender's webContents and emits
'hermes:found-in-page' results back to the sender.
The multi-window fix is part of the same port — the old
PR's behavior (Cmd+F in a secondary session window searched
the global primary) was a bug present in the CJS files,
not a design constraint we wanted to preserve. The new
helper module uses event.sender by design, so the
multi-window correctness lands with the TS port.
Fixes#46169
Follow-ups on the salvaged goal-status display (#63527):
- Seed the goal store from the /goal dispatch notice ("⊙ Goal set …") and
from /goal status|pause|resume|clear exec output in slash.ts. The backend
only emits status.update kind:"goal" after the first turn's post-turn
judge, so without this the indicator stayed empty while the kickoff turn
ran (sweeper review finding on #63527).
- Add the missing ja / zh-hant statusStack goal copy — desktop ships four
locales, not two (sweeper review finding on #55651).
- Add a component-level vitest for the composer goal indicator rendering
from store states: none / active / paused / detail line / other-session.
Co-authored-by: HaisamAbbas <95044189+HaisamAbbas@users.noreply.github.com>
Assisted-by: Claude Fable 5 via Hermes Agent
redirectPrompt inserts its correction as a second user row just before the
live reply, so one turn can own a contiguous run of user rows. Three recovery
paths each assumed a turn has exactly one, and all three kept the correction
and discarded the prompt that started the turn:
- recoverableTail walked back to the nearest user row, so the crash journal
never stored the original.
- preserveLocalPendingTurnMessages kept only the newest optimistic user row.
Widened to the contiguous run — rows separated by an assistant reply are
still dropped, which is the stale-post-compression case that rule exists for.
- appendLiveSessionProjection had no way to render corrections; it now projects
them after the prompt, deduped against the transcript's latest user run.
Losing a row also shifted every later role:ordinal pairing in the reconcile,
which is why the thread looked like it compacted rather than just missing one
bubble. Reproducible on a reconnect and on a dev hot update, which remounts the
session cache while the gateway socket survives.
The sidebar labelled sections and workspace lanes `loaded/total`, which
read as a progress bar people expected to fill up rather than a count of
loaded rows. Pricing that label cost a COUNT(*) per profile database on
every sidebar refresh, purely so the numerator and denominator could
differ.
Pagination only needs to know whether another page exists, and that comes
free from the rows the query already returned: a window that comes back
full means more remain on disk. Sections now show the loaded count alone,
and the backend reports per-profile `profiles_truncated` flags in place of
`total` / `profile_totals`.
Investigating the missing-spinner report turned up no defect in the seeding
path: the active_list poll already lights a row for a turn the renderer never
saw start, holds it across polls, and follows a recycled runtime id onto its
new stored session. Pin all three so the reap change can't silently regress
turn-start while fixing turn-end.
Two boundaries worth naming rather than rediscovering:
- `starting` is deliberately NOT working. It means agent_build_started without
agent_ready, and _start_agent_build runs on any incidental RPC that needs the
agent — not just a prompt — so treating it as a turn would spin the row on
merely opening a session.
- $workingSessionIds is keyed by STORED id and drops entries whose
storedSessionId is null, while message.start flips busy without carrying one.
A runtime that was never seeded with a stored id therefore goes busy
invisibly. That is the remaining path by which a spinner can go missing.
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.
preserveReasoningParts was gated on exact text equality with the cached row.
Mid-turn the authoritative text has advanced by a delta or two, so the guard
fails and the row is rebuilt from the gateway's inflight projection — which
is text-only. The renderer's cache is the sole carrier of a running turn's
structure, so switching away and back stripped the reasoning and every tool
call, leaving the turn looking inert.
Carry tool calls alongside reasoning, dedupe them on toolCallId, and match on
same-turn (identical text, or authoritative text extending the cached text)
rather than strict equality. Attachment refs and image re-appending stay on
the strict path: those reconcile a settled row, and a growing row is by
definition not settled.
session.active_list is authoritative about absence, but the renderer only
read the rows it returned. A turn that ends while the websocket is degraded
— a remote gateway on a flaky link, a reconnect, a profile swap — drops out
of the gateway's _sessions without Desktop ever seeing the running=false
edge, so the row spins forever and the busy->idle transition that paints the
green unread dot never fires.
Track live runtime ids per gateway profile and settle anything that
disappears between polls through publishSessionState so the real transition
fires. Profile scoping is load-bearing: background profiles are served by
other gateways and never appear in this profile's snapshot, so an unscoped
reap would dark out every other profile's running rows.
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.
The rich editor mutates its DOM through `Range` rather than the browser's
editing commands, because `execCommand('insertText')` is ~O(n²) on large
multiline blobs and froze the composer for seconds on a big paste (#45812).
Those mutations never reach Chromium's undo stack, so a paste was invisible to
it — Cmd+Z skipped straight past the pasted text and undid whatever edit came
before it, leaving the paste stranded and the earlier edit destroyed.
Owning the stack outright is the only coherent fix; a half-owned one interleaves
our snapshots with Chromium's own typing entries and undoes them out of order.
Every edit path now banks its pre-edit state, and the editor claims Cmd+Z /
Cmd+Shift+Z (plus Ctrl+Y) instead of letting the native command run. Snapshots
are plain text + a caret offset rather than DOM, since the editor already
round-trips losslessly through composerPlainText/renderComposerContents.
Consecutive keystrokes coalesce inside a 600ms window, so undo steps back by a
typing burst the way a native editor does rather than one character at a time.
Electron's Edit menu `{ role: 'undo' }` fires the native command without a
keystroke the renderer can see, so a capture-phase `beforeinput` listener claims
historyUndo/historyRedo too and keeps the menu item and the shortcut in
agreement. Switching drafts resets the history — undoing into another
conversation's text is worse than having none.
Co-authored-by: David Metcalfe <80915+DavidMetcalfe@users.noreply.github.com>
Reverts the tooltip half of 4798994dc; keeps the idle-cost scenario.
Lazily mounting Radix on first hover measured well (105k -> 26k
TooltipProvider renders per drag) but broke 18 tests across 12 files.
Those tests are not incidental: the repo has an established convention of
asserting `[data-slot="tooltip-trigger"]` at mount to prove a control
carries a tooltip, and deferring the mount invalidates all of them at
once. There is also a real behavior risk the convention was protecting —
`asChild` puts the slot on the button element itself, so arming REPLACES
the node, which is exactly the kind of identity change that breaks focus
restoration and ref-holding call sites.
A 4x cut in tooltip churn is not worth reworking every tooltip assertion
in the app plus taking that risk, on a component with ~107 call sites.
If it's worth revisiting, the right shape is probably making
TooltipProvider itself cheap (one app-level provider) rather than
deferring the mount per call site — that preserves the DOM contract these
tests encode.
The genuine win in this branch stands on its own: the $layoutTree
subscription fix (commits 83 -> 12 on a sash drag) is unaffected.
`plainTextInRange` serialized the caret's preceding content through a bare
<div>, but `composerPlainText` appends "\n" to any block element that isn't the
editor slot. So `beforeText` always looked like it ended in whitespace and the
separating space was never inserted — dragging a file in after a word produced
`review@file:...` glued together.
Marking the scratch container with RICH_INPUT_SLOT makes it serialize in the
same coordinates as the editor. Same fix lands in the new `caretOffsetInEditor`,
which measures caret offsets the same way.
parseMarkdownIntoBlocksCached bypassed its cache for text under 1024
chars, on the theory that re-lexing a short message is cheap. The lex is
cheap; what it returns is not free. `parseMarkdownIntoBlocks` builds a
fresh array every call (verified in streamdown's dist: `let r=[]` ...
`return r`), and Streamdown mirrors the block list into useState — so a
new array identity for UNCHANGED text re-renders Streamdown and every
Block beneath it.
Most messages are short, so most of the transcript was on the uncached
path. Caching every length cuts the idle cost of five mounted tiles:
Streamdown 5.2ms -> 2.6ms
Block 128ms -> 85ms
Ct 122ms -> 81ms
Cache bumped 64 -> 256 entries to cover the now-larger key space.
This does NOT reduce Streamdown's 105 idle self-renders — array identity
turned out not to be what drives those, and I verified the cache returns
a stable identity, so that root is still open. This is a cost win, not
the churn fix.
TreeGroup called useStore($layoutTree) to build its right-click menu's
move/split directions. That subscribes every zone — and therefore every
mounted pane and its entire transcript — to the whole layout tree. A sash
drag rewrites the tree once per frame, so dragging the sidebar re-rendered
all five tiles' message lists on every pointermove, for a context menu
nobody had open.
The directions are only read when the menu renders, so read the tree there
with .get() instead. Same lazy shape the neighbouring `closable` prop
already uses.
Measured over one 60px sash drag with five busy tiles:
commits 83 -> 12
ChatView 150 -> 10 (4465ms -> 353ms)
AuiProvider 9450 -> 630 (9868ms -> 774ms)
TreeGroup 180 -> 12
TreeSplit 90 -> 6
Also fixes an observer effect in the harness: idle-cost recorded render
attribution *during* the timed gesture, and the counter walks the fiber
tree on every commit. That was large enough to hide this 15x reduction
behind an unchanged fps, so timing and attribution are separate passes now
and `record` defaults off.
Adds scripts/diag-drag-churn.mjs — the probe that found this. It reports
the transcript chain (who above the messages re-rendered) plus every atom
that notified, which is what named TreeGroup instead of leaving it to be
guessed at. Notably the atom list came back EMPTY: this was never store
churn, so the render-attribution path was the only thing that could have
found it.
The synthetic gesture oscillated +/-3px, which nets to zero displacement
and can clamp to a no-op — so it reported a confident fps number for a
drag that never moved the sash. Sweeps monotonically now, dispatches
pointer events React's synthetic system accepts (isPrimary/button/buttons),
and records dragTarget + dragMoved so a drag that silently did nothing is
visible in the output rather than passing as a measurement.
Verified: dragMoved now reports 60px where it previously reported 0.
Adds an `idle-cost` scenario for the symptom Brooklyn reported: with a
thread spinning, resizing the sidebar feels slow. It holds N tiles busy,
pushes NO tokens, and measures the renderer's self-inflicted commit rate
plus fps while dragging the splitter and while typing.
It reproduces immediately. Five busy tiles, nothing streaming:
idle commits 17.7/sec (should be 0 — nothing is happening)
drag 1.4 fps p95 812ms, worst frame 1.9s
typing 61 fps (fine — this is specific to resize)
Attributing the drag window showed 105,385 TooltipProvider renders and
~15s of component time across a 60-frame gesture. Cause: `Tip` mounts a
full Radix provider + Tooltip per call site, and there are ~107 of them.
Radix's Tooltip holds real state and Popper subscribes to layout, so an
unrelated interaction re-rendered all of them.
Mounts the machinery lazily instead, on first hover/focus. Tooltip churn
drops ~4x (105k -> 26k) and drag doubles to 3fps.
Note `defaultOpen` on the armed Tooltip is load-bearing: the pointerenter
that armed it has already fired, so Radix never sees it and the tip mounts
silently closed. A test caught exactly that, and now guards it.
3fps is still bad — the remaining cost is the whole transcript
re-rendering per resize frame (MessagePrimitive.Parts 12,600 renders /
10.5s, Block/Ct 24,300 each, all 100% wasted). Separate fix.
Repair signalled "reinstall me" by deleting the bootstrap marker. That was
already destructive -- repair is reachable from a transient backend error on a
fully working install -- and it stranded users in first-run setup with no way
back short of hand-writing the marker file.
Carry the intent in an explicit flag instead. Repair forces the next resolve
through the installer and clears itself once the reinstall starts, so a forced
reinstall still works without destroying provenance about how the install was
created.
Closes#72166
Marker presence was the launch gate, but the marker is provenance about who
ran the install -- not proof the runtime works. A CLI-installed repo+venv, or
a healthy install whose marker a repair deleted, both read as "never
installed" and dropped the user into first-run bootstrap on every launch.
Split the two questions: classifyActiveRuntime() reports marker validity and
runtime usability separately, and the resolver launches whenever the runtime
is usable, logging when it proceeds without a marker. An unusable runtime
still falls through to bootstrap even with a valid marker, so an interrupted
install can't spawn a dead backend.
Drops isBootstrapComplete(), which had no callers left once the gate moved.
Co-authored-by: iveywest <iveywest@users.noreply.github.com>
Co-authored-by: lihengming <lihengming@users.noreply.github.com>
The expanded terminal row printed the same string as the title, as the
`$` transcript, and again as detail. shellCommand preferred the
backend's display preview over the real `command` arg, so the transcript
showed a summary of what ran rather than what ran; and a terminal call
with no output fell through to the generic fallback, which echoes
args.context under a transcript already showing it.
The macOS launcher fast path gates on hermes_is_installed(), which needs
.hermes-bootstrap-complete next to a built desktop app. Nothing in the Rust
bootstrap pipeline ever wrote that marker -- only install.ps1 did -- so every
reopen of /Applications/Hermes.app re-ran setup instead of launching.
Publish the marker atomically (temp sibling + fsync + rename) because
hermes_is_installed() only checks existence: a torn direct write would arm
the fast path against a half-installed tree. A marker write failure emits
BootstrapEvent::Failed so the installer UI leaves the progress state.
Co-authored-by: giggling-ginger <giggling-ginger@users.noreply.github.com>
Two problems, both found by distrusting the harness's own numbers.
1. The scenario slept a fixed 1s after mounting tabs, then recorded. Boot
and session hydration are not reliably done by then, so a variable
amount of unrelated work landed inside the measurement window. Three
back-to-back runs on identical code spread 2.2x on total_renders and
3.8x on wasted_renders — wide enough that a single-run before/after
delta could be mostly noise. Replaced with a quiesce gate that waits
for commits to hold still before recording, and reports 'quiet:N' or
'timeout:...' so a contaminated run is visible instead of silent.
2. The counter attributed a context-driven re-render as 'wasted', which
pointed at memo() as the fix when memo cannot block context at all.
Adds contextChanged via the fiber's context dependency list, and
excludes it from wasted.
The gate also turned up a finding worth more than the fix: with five busy
tiles and NO driver running, the renderer still commits ~18x/sec. The
report now names the cascade roots (own state changed, props did not)
rather than leaving them to be guessed at — Streamdown re-renders itself
105 times while idle, which is what drives Block/Ct.
A pasted link went in as raw URL text, wrapping across the composer and
staying inert. It now becomes the same `@url:` reference the "+ → Add URL"
dialog inserts — parsed in place, so a link mid-sentence keeps its position
and the punctuation that ended the sentence stays outside the chip. Typing
one and pressing space commits it the same way.
Both rich-editor surfaces get it: the composer and the message-edit box,
whose paste went through `execCommand` and could not produce a chip at all.
`insertPlainTextAtCaret` dropped its text in verbatim, so a caller with
directives in hand had no way to land them as chips. It becomes
`insertComposerContentsAtCaret`, sharing the parse `renderComposerContents`
already uses, and gains a sibling `replaceBeforeCaret` for swapping a
just-typed token for a chip.
The composer labeled a `@url:` chip with `refLabel`, which takes the last
path segment — three PR links all read as their number. The transcript had
its own better labeler that stopped at the hostname, so the same reference
read differently before and after send.
One `refChipLabel` now serves both: host without `www.`, path riding along
for the chip's existing truncate to cut, and the full value on the chip's
title so a cut-off link is still readable on hover.
* feat(billing): add payment_method union to the billing-state wire type
* feat(billing): carry the payment-method union through the gateway
NAS now sends a typed `paymentMethod` union on /api/billing/state alongside
the legacy `card` field. The gateway parses payloads field-by-field, so the
new field was dropped on the floor before reaching TUI/Desktop.
Parse it into PaymentMethodInfo and re-emit it as snake_case `payment_method`,
matching the translation the rest of this payload already does. The payment
method id is deliberately not carried through — clients have no use for it.
No client rendering changes: `card` stays populated for cards, so every
existing consumer behaves exactly as before and the new field is inert until
a surface opts into reading it.
* fix(billing): send only the fields each payment-method kind declares
The serializer emitted every key for every kind, so a Link method went out
carrying brand, last4 and wallet set to null. That contradicts the shared
type, where each kind declares its own fields: a client testing `'brand' in
pm` would read every Link method as a card, and one trusting the declared
non-null `brand` could crash on it.
Send each kind's own fields, and forward an unrecognized kind by name alone
so a client that predates it can still say something honest. The shared type
gains the matching fallback arm its own comment already promised.
Tests now follow a payload from the server response through to the client
wire for each kind, rather than checking parsing and serializing separately —
which is why the old expectation locked in the wrong shape without noticing.
* fix(billing): keep the payment-method kind narrowable
Typing the fallback arm's kind as `string & {}` borrowed a trick that only
works on unions of plain strings. On a union of objects it makes the
discriminant non-literal, so TypeScript stops narrowing on every arm — even
`if (pm.kind === 'card')` no longer gives you `brand`. The first client to
use this would have hit a compile error and reached for a cast.
An unrecognised kind now arrives as `unknown`, carrying the real name
alongside it, so every arm has a literal discriminant. A type-level test
pins this: it fails to compile if the discriminant stops narrowing.
The parser settles which kind it is, the way the card parser already does,
so the record cannot hold fields that do not belong to its kind and the
serializer no longer re-checks. The type comment also stops claiming `card`
is a safe signal — it is null for Link, so `!card` does not mean "nothing on
file".
The statusbar subscribed to `$focusedSessionState` — a projection of
`$sessionStates`, which is republished on every message delta — but reads
only three fields off it. Every token therefore re-ran useStatusbarItems
and rebuilt all ~9 item objects, and since StatusbarItemView was not
memoized, one changed item (the running timer) re-rendered the whole bar.
Adds `useStoreSelector` beside the existing `useSessionSlice`, the same
narrowing idea for a scalar instead of a keyed array: subscribe to the
store, but bail out unless the selected value changes. Applies it to the
three fields the statusbar actually reads, and memoizes StatusbarItemView.
Measured over five concurrent streaming tabs (`render-churn`):
total renders 78,385 -> 21,701 (-72%)
wasted renders 10,432 -> 6,112 (-41%)
TooltipContent 2,304 -> 143 (-94%)
StatusbarItemView 2,174 -> 0
Drives the same synthetic multi-tab streaming workload as `multitab`
(publishSessionState per session per flush, no backend, no credits) but
reports render attribution instead of frame pacing — sidebar_renders,
wasted_renders, and wasted_notifies.
Answers 'does the sidebar re-render while an agent is typing' directly.