While the workspace pane shows a full page (Artifacts, Skills, Messaging, a
plugin route), a sidebar click on the ACTIVE session did nothing: onResumeSession
took focusOpenSession's `true` for the main-session branch as "already on
screen" and skipped the navigate, but fronting the workspace tab doesn't put the
chat back — the page is still routed. The user had to click some other session
and then the active one to get back.
focusOpenSession now reports WHICH surface it fronted ('main' | 'tile' | null),
and focusedSessionNeedsRoute decides: a tile never needs a route (its pane
renders the chat regardless), a main hit does while a page covers the workspace.
`/work` typed into a fresh Cmd+T tab loaded the skill in that tab and
printed "⚡ loading skill: work" there, then fired the skill's kickoff
prompt as a user message into whatever conversation was on screen.
The dispatcher resolves its target once, through resolveTargetSessionId,
and every other consumer of that answer already honors it: the output
writer binds to the target's stored id, and the busy gate reads the
target's own state. The send did not — `submitPromptText(message)` passed
no target at all, so submit fell back to `activeSessionIdRef`, which
names the foreground chat. #71805 fixed the two sibling leaks in this
same function; this is the third and the one that actually moved the
user's prompt.
Forward the resolved pair instead. Every target the dispatcher serves —
a tile, a background queue drain, a session this very call created —
was hitting the same fallback, so the fix covers the class rather than
the tab case that surfaced it.
Technical mode rendered the raw payload two different ways — a bare
block for most rows, a native `<details>` for file edits, whose
browser-drawn marker matches nothing else in the app. Both are now one
collapsed chevron disclosure at a smaller type size, with even padding
against the row body.
The strip's rule is an inset shadow painted in the container's last pixel
row, and full-height tabs covered it — so each tab read as overhanging
the bar by 1px. Inactive tabs compensated with their own border, stacking
a second translucent line that darkened the seam.
Inactive tabs now stop 1px short and draw no bottom border, leaving the
container as the sole owner of one continuous rule; the active tab keeps
full height so it alone cuts through. Hover also darkens rather than
lightens, since lightening moved a hovered tab toward the active
surface's look.
The arc reduced to a few faint dots on session rows. Two causes: the ring
was outset by 2px into a scroller that clips horizontally, losing its
left and right runs; and its tail color defaults to the chrome
background, which is invisible against the sidebar, leaving only the
bright stop of each gradient pass.
An `arc-row` variant sits flush and ties the tail back to the ring
color. The ring's radius is now derived from its standoff (r_host + gap)
rather than inherited, which keeps any outset host concentric instead of
pinched.
Content links read as a small primary-tinted chip instead of an
underline, dropping the trailing external-link arrow. The tint is
currentColor-relative, so one class carries text and fill in the same
hue across every theme, and `box-decoration-break: clone` gives a
wrapped link a chip per line fragment.
The sidebar "+" now stacks a tab instead of replacing the surface, so the
prior session stays mounted and several chat surfaces can be on the page at
once. Helpers that waited for the old transcript to disappear from the page
timed out, and `.first()` locators / bare `document.querySelector` calls
started resolving against the wrong session (CI's "resolved to 2 elements"
strict-mode violation).
Target the most recently mounted `[data-composer-target]` surface instead,
and assert the NEW surface is empty rather than waiting for the old text to
vanish.
The sidebar "+" ran startFreshSessionDraft, so it replaced whatever was on
screen — ⌘N behavior. With a conversation already open (possibly mid-turn)
that discards it to make room for a blank draft, which is not what a create
affordance should do. The tab-strip "+" and ⌘T already stack a new tab.
Route the sidebar button through the same path once a session is loaded,
falling back to the fresh-draft path when the surface is empty and there is
no tab worth preserving. openNewSessionTile now takes an optional cwd so the
new tab stays anchored to the clicked project/worktree lane.
Fold the null fallback into chatSurfaceRoot so both callers share one
target resolution, hoist the var names next to it, and drop the
duplicated rationale from the status-stack comment.
The existing `stream` scenario measures streaming into an empty thread, which
is exactly the case where per-delta transcript work does not show up. This adds
`--historyTurns`, which mounts a settled transcript and lets it drain BEFORE
the recorders start, so the measurement window contains streaming work only —
and `stream-history`, a report-only preset that turns it on.
Report-only (tier: manual) rather than gated: how much history a host can mount
varies, so the absolute number is not comparable across machines. It is meant
for same-machine before/after runs.
Co-authored-by: Jakub Wolniewicz <frizikk@users.noreply.github.com>
Once the coalescing floor had already elapsed, the next delta was scheduled
with requestAnimationFrame. Chromium pauses rAF for a renderer it considers
hidden, and that is not something this code can verify: backgroundThrottling:
false and the process-level switches in electron/main.ts cover the blurred and
occluded cases, but not a minimized window, a fully off-screen one, or a
renderer the compositor has otherwise parked. In those states the callback is
accepted and never runs, so a finished answer sits in the queue until some
later focus or input event happens to wake a frame — the reply looks stalled,
then lands all at once on refocus.
Always use a timer. The coalescing cadence is unchanged (the floor above is
what enforces it), timers are clamped rather than suspended in background
renderers, and disable-background-timer-throttling already opts out of that
clamp. The teardown path loses its cancelAnimationFrame branch with it.
The regression test parks rAF the way an occluded renderer does and asserts
the delta still arrives. It has to send a delta, let it flush, then idle past
the floor before the delta under test, because the frame-gated branch was only
reachable on that second scheduling — a single-delta version of this test
passes on main and proves nothing.
Co-authored-by: NetRunner2037 <rerdi92@users.noreply.github.com>
The composer and the out-of-flow status stack published their measured
heights onto document.documentElement, but both components mount once per
chat surface. Session tiles render a full ChatView beside the workspace
pane, so N surfaces raced for one value: a background tab with a tall
status stack inflated the foreground thread's bottom clearance and pushed
its jump-to-bottom button into mid-screen. Whichever surface unmounted
last also cleared the var for everyone still showing.
Publish onto the surface's own root instead, and re-declare the clearance
calc there — `:root` substitutes the root measurements once, so scoping
only the inputs would leave every thread reading the same value.
The workspace pane read the global $messages/$busy atoms — a mirror of
whichever session was active — while every ⌘T tile rendered from its own
$sessionStates slice. With two turns in flight, navigating away from a
still-streaming session left it painting into the surface now showing a
different conversation: the wrong transcript under the right route.
Point the primary view at the active session's own slice, keeping the
global atoms as the draft surface for a chat that has no runtime id yet.
syncRepositoryIncrementally rewrote the entire transcript on every adapter
snapshot: one addOrUpdateMessage per message, a second full export to find
deletions, and an unconditional resetHead. During streaming that fires ~30x a
second, so the per-delta cost scaled with how long the conversation already
was — the reported symptom.
Now that settled messages keep reference identity, an identity check is a
sound "did this change?" test. Write only the items whose message or parentId
moved, and skip resetHead when the head did not move (it prunes the head's
descendants, so calling it needlessly is not free). Anything the fast path
cannot prove safe — a disjoint session swap, a changed message count, an id
with no repository entry — falls through to the original full rebuild.
Tests cover the perf contract (one write for one delta, zero for a no-op) and
the correctness cases the fast path must not break: appends, authoritative
deletion, disjoint session replacement, branch re-parenting, and an explicit
headId rewind. The two perf assertions fail on main.
Co-authored-by: Jakub Wolniewicz <frizikk@users.noreply.github.com>
useRuntimeMessageRepository keeps a WeakMap of converted messages so a
settled turn converts once. Building the export with
ExportedMessageRepository.fromBranchableArray threw that away again: it maps
the whole array through fromThreadMessageLike on every call, so each streamed
delta re-normalized the entire settled history and handed the runtime a fresh
object for every message.
Normalize on the cache miss instead, using the same fallback status
fromBranchableArray applies, and build the export literal directly. Settled
turns now keep reference identity across deltas, which is what lets the
reconcile below tell that only the tail moved.
Co-authored-by: Jakub Wolniewicz <frizikk@users.noreply.github.com>
The nonzero-exit guard already knew a failing exit code with real output
usually isn't a failure worth painting red. It only looked at output /
stdout / stderr, but background-process polls report their text under
output_preview — so a poll of any process that exited nonzero rendered
destructive-red with no error to show, even when the output was routine
npm chatter. Half the red process rows in a real session history were
this case.
Sentinel-released processes exposed a second bare-sample assertion in the
same file. The unread-dot check ran a synchronous .count() 140ms after the
running dot cleared:
15.91s poll "dot should disappear" -> 2
16.05s ... -> 0 (running dot gone)
16.05s bare .count() for unread dot -> 0 FAIL
"Finished — unread" is an event-driven transition that lands just after the
running dot clears. The old fixed `sleep 5` happened to leave enough slack
between the two that a single sample usually caught it; releasing the
process deterministically removed that incidental slack and made the latent
race deterministic instead.
Poll for it, matching how sidebar-states.spec.ts already asserts this exact
dot. The split-tile assertion at line 234 stays a bare sample on purpose —
it asserts an absence (toBe(0)), where polling would only wait for something
that must never appear.
The cross-session sidebar specs asserted a state that could expire before
they looked at it, making them the flakiest tests in the suite — two reds
on unrelated PRs within three minutes on 2026-07-26.
Root cause, from the failing run's trace: the tests need a background
process that is still RUNNING after the agent turn finishes, but the
process was a fixed `sleep 5` racing two other clocks — the turn itself
(two model round trips plus a real subagent delegation) and the 4s
success linger before a finished task auto-dismisses. On a loaded runner
the "dot should appear" poll took 7.5s to see the dot; by then `sleep 5`
had already exited, `waitForFunction(finalText)` returned in 0.08s
because the turn was long done, and the next line — a bare synchronous
`.count()`, not a wait — sampled 0.
The process lifetime is now test-controlled: `createBackgroundReleaseHandle()`
mints a sentinel path, the scripted command blocks until that file
appears, and the test releases it exactly when it wants the dot to clear.
One clock instead of three, and the "turn done, process still running"
state is stable rather than a window to catch. The wait is bounded (60s)
so a forgotten release can't hang a worker, and `sleep 5` stays as the
default for callers that pass no handle.
No product code touched — E2E harness only.
Memory rows fell back to the auto-derived name, so they read as a bare
"Memory" / "Running memory" next to a generic icon. Add the tool to
TOOL_META with proper copy across all five locales, plus a solid brain
glyph in the existing Phosphor fill set.
A memory tool call rendered destructive-red with an alert glyph and, when
expanded, a raw dump of the whole args + result payload.
Two causes. The red came from toolStatus treating any error text as a
failure: the memory store rejects an over-budget batch so the agent can
retry a smaller one, which is routine bookkeeping, not something the user
acts on. Those now land on the existing amber warning tier.
The payload dump came from memory having no case in toolSubtitle or
toolDetailText, so both fell through to the generic stringify-everything
fallback. Both now surface just the human-readable line.
A slash command in a ⌘T tab or split pane routes through the primary
chat's dispatcher, which read the FOREGROUND view's identity for two
decisions it had no business asking the foreground about.
Busy: the gate read `busyRef`, a mirror of whatever chat is on screen.
A brand-new tab with zero turns was told "session busy — message queued"
because an unrelated chat was mid-stream, and the converse let a
background send fire into a live turn.
Identity: the output writer bound to the foreground's stored session, so
a tab's transcript writes re-keyed its cache entry onto the primary's
stored id and its queued payload landed on the primary's queue — the
kickoff would then drain into the wrong conversation. `submitText` also
dropped an explicit target when it routed to a slash command, running a
queue drain's command against whatever was in front.
Read the target session's own published state for both. One shared
resolver so submit and slash cannot drift apart again.
A slash command runs against the session `resolveTargetSessionId` picks,
which is routinely not the session on screen — a tile, a route rebind, or
a session created by the call itself. Both prompt pipelines gated on
`busyRef`, the FOREGROUND view's busy flag, so one session's send was
gated on another session's turn: a stale foreground `true` (a warm resume
of a still-running chat leaves one behind) parked an idle session's
command on the composer queue and reported "session busy" about a session
doing nothing. The converse also leaked — a background send could fire
mid-turn while the foreground happened to be idle.
Read the published per-session state instead, falling back to the
foreground flag only when the target has no state yet (a just-minted
session whose first publish hasn't landed). One shared resolver so submit
and slash cannot drift apart again.
The preview window masked its top 28% to transparent while reasoning
streamed, so a gradient appeared over the thinking text and vanished the
moment the block finished. Remove the mask; the max-height window and
bottom-pinned scroll still keep the preview compact and following the
newest tokens.
The sidebar's project overview put git checkouts with zero Hermes sessions
above the repos the user actually works in, and dragging a project into place
did not stick.
Two causes. The repo discovery payload folded `discovered_repos.last_seen`
into `last_active`, but `last_seen` is when the disk scan last saw the
directory, so every scanned checkout was stamped "just now" and outranked
real work. Activity is now session-derived only; a repo with no sessions
reports no activity.
The overview also applied the manual drag-order through `orderByIds`, which
floats every id missing from the saved order to the top. That is right for
sessions, where a new chat should not sink, but the overview keeps receiving
newly-scanned repos — so once the user dragged anything, each new discovery
jumped above their hand-picked list. Projects the user has not ordered now
keep their deterministic position: ones with real activity still surface on
top, zero-session discoveries sort below the ordered list.
probe-model-picker.mjs times pill-click → menu painted over N rounds;
profile-model-picker.mjs wraps one open in a CPU profile with a
top-self-time table. Both attach to the perf:serve instance.
Each kept-alive tab whose model hasn't resolved ticks a GlyphSpinner —
setInterval + setState + a React commit every ~80ms, per mounted tab,
for pixels behind the active one. Gate the interval on the pane's
visibility context; the visible tab keeps its spinner, hidden tabs
resume from frame 0 on reveal.
ModelMenuPanel mounts a ModelEditSubmenu per model row, and each body ran
its hooks and built its JSX eagerly on menu open — ~90 rows of switches,
radio groups, and preset lookups nobody hovered yet, the largest app-code
slice in the open-latency profile. Wrap the body in a child component under
SubContent so Radix's presence gate leaves it unrendered until the submenu
actually opens; open latency drops roughly in half on a 91-model catalog.
The prompt rail mounts in every chat surface, and a tab group keeps
inactive tabs mounted, so a background timeline was stringifying every
user prompt's full text on each store update — including on every
streamed assistant token, since the selector walked all messages — plus
running a scroll listener and a getBoundingClientRect per prompt against
a viewport nobody was looking at.
It now defers each piece until it can be seen. An inactive pane returns
before a single hook is declared (usePaneVisible, the same context the
hidden-tab transcript freeze uses), so no subscription is opened at all.
An active rail subscribes to prompt IDS rather than prompt text and
reads the transcript imperatively only when that signal changes, which
takes streaming off the derivation path entirely; an identical
derivation hands back the previous array so a filtered-out prompt
doesn't restart the measure effect. The offset pass bails below the
render threshold instead of measuring a rail that renders null, ahead of
the existing following-the-bottom fast path, and the hover popover keeps
its shell for the fade but builds its rows on first open.
N session tiles stacked as tabs, all mounted (keep-alive) and all
streaming concurrently through the real publishSessionState path — the
"several PR reviews at once" workload. Frame pacing + longtask metrics,
no backend or credits needed; the workload that exposed both fixes above
and the regression gate that keeps them fixed.
ThreadTimeline's scroll compute read getBoundingClientRect for every user
message per scroll frame; interleaved with React's streaming style writes
each read forced a full reflow — the hottest self-time frame in the
multitab profile (620ms over one 5-tab run). While the viewport is pinned
to the bottom the active prompt is simply the last entry, so answer from
data and save the layout reads for actual scrollback.