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.
Keep-alive keeps every ever-active tab mounted, but each hidden tab's
ChatRuntimeBoundary still subscribed to its view's $messages — so every
streaming delta flush (~30x/s) re-rendered every busy tab's whole thread,
and five concurrent sessions dropped the app to a crawl.
Flow the pane layer's visibility down as PaneVisibleContext and gate the
$messages subscription on it: a hidden tab freezes its transcript (status
dots stay live through the separate status atoms) and catches up in one
commit on reveal, since subscribe fires immediately with the current value.
Warm the shared title cache before rendering so a resolved title is
available synchronously — the previous TUI assertion only proved a label
survived when no title had resolved, which passed on the buggy ordering.
Asserts the bug class on both surfaces: an authored label outranks a
resolved title and suppresses the fetch, an unlabeled link still resolves
one, and a "Page not found" title is discarded rather than rendered.
Co-authored-by: Kinkoolino-Hermes <297364961+Kinkoolino-Hermes@users.noreply.github.com>
Self-hosted forges answer a missing or private page with a 200 and a
"Page not found" title, which then rendered as the link's text. Both
surfaces keep their own copy of the error-title list, so extend both.
Co-authored-by: Kinkoolino-Hermes <297364961+Kinkoolino-Hermes@users.noreply.github.com>
Both chat renderers resolve link titles over the network and render them
in place of the link's text, unconditionally — so they also replaced text
the agent deliberately wrote. `[#71706](url)` rendered as the whole GitHub
page title, and prose labels were swapped out mid-sentence.
The TUI ordered `fetched || label` and always passed the URL to
useLinkTitle. Desktop looked guarded but wasn't: chat markdown hands
authored text to PrettyLink as `fallbackLabel`, not `label`, so
`useLinkTitle(label ? null : target)` never skipped the fetch and
`fetched || label || fallbackLabel` still let the title win.
Treat an authored label as the intent: it wins, and it skips the fetch. A
label that is just the URL still resolves, since `[url](url)` and `<url>`
are bare links wearing markdown syntax — desktop already applied that same
URL-equality rule before handing the label over.
Co-authored-by: Kinkoolino-Hermes <297364961+Kinkoolino-Hermes@users.noreply.github.com>
Connects the preceding seams to the boot path:
- buildReadinessHealthProbe picks the probe credentials from the connection's
authMode via resolveReadinessProbeAuth, and reports whether the probe is
credentialed so waitForHermesReady can read a 401 correctly. The bearer
goes through fetchJson's `bearer` option — a raw `headers` object is
ignored there, which would have silently probed uncredentialed.
- authMode is threaded into all three waitForHermes call sites: the primary
remote connect, the profile pool backend, and the SSH tunnel (loopback
forward, token auth), so an old backend behind a tunnel gets the same
compatibility path.
- The confirmed reauth failure latches in remoteReauthFailure and
short-circuits startHermes, and is cleared on every recovery path —
resetHermesConnection (soft/hard apply and reconnect), bootstrap reset,
repair, and a CONFIRMED oauth sign-in. A cancelled or closed login window
leaves the latch set so the overlay stays actionable.
- gatewayAuthProviders reads the public /api/auth/providers (cached per base
URL, failures return []) so the oauth pre-flight guard can skip its hard
failure for an all-password gateway while keeping the strict guard
everywhere else.
shouldLatchBackendStartFailure deliberately never latches a remote failure:
remote faults are usually transient and must stay retryable without an app
restart. A confirmed reauth rejection is the exception — it cannot self-heal,
because nothing changes until the user signs in again.
Worse, not latching actively prevents the recovery it was protecting. A
non-latching remote boot failure re-runs startHermes on every
getConnection/api call, re-emits running: true, and the boot-failure overlay
(visible = Boolean(boot.error) && !boot.running) hides itself — so the
"Sign in" button flickers out from under the user before it can be clicked.
Add shouldLatchRemoteReauthFailure as a separate predicate rather than
changing the existing one, so transient remote failures keep self-healing.
The two latches are complementary and never fire for the same failure.
Two pure seams in native-auth-decisions.ts, following that module's existing
pattern — the value is the test that pins the contract so the god-file call
sites can't drift back to the buggy shape.
resolveReadinessProbeAuth decides how the boot readiness probe
authenticates, delegating to resolveOauthRestAuth for the oauth case rather
than duplicating the bearer-vs-cookie rule.
oauthGuardMayHardFail fixes a second way a gated gateway is misread.
authModeFromStatus maps the gateway's `auth_required: true` onto 'oauth',
but that flag only means the dashboard is GATED — it says nothing about how
you authenticate. A gateway whose providers are all username/password can
satisfy neither of the pre-flight guard's checks by construction:
start_login raises NotImplementedError, /auth/native/authorize rejects
password providers, and its cookies come from a plain password-login POST
rather than the /auth/callback redirect the OAuth partition is primed for.
The guard therefore threw "uses OAuth, but you are not signed in" one line
before a mintGatewayWsTicket that would have succeeded against that very
partition.
The helper returns false only when EVERY advertised provider is
password-based; an unknown or empty list keeps the strict guard, so backends
predating /api/auth/providers are unaffected.
The boot readiness probe calls the credential-free /api/health, and only a
404 flips it to the /api/status fallback. Both halves are wrong against a
gated gateway.
/api/health landed in ccab46ca4 (2026-07-24), after the v2026.7.20 tag, so
every container pinned to a release lacks the route. On those backends the
dashboard auth gate runs ahead of the SPA catch-all, so an unknown /api/*
path is rejected as unauthenticated rather than 404 — a credential-free
probe can never observe the 404 that the fallback keys on, and boot loops
until the 45s deadline reporting a healthy backend as "did not become
ready". Upgrading the backend is not a workaround for release-pinned
deployments.
Simulating a 0.19.0 backend (both the route and its PUBLIC_API_PATHS entry
removed, since ccab46ca4 added the two together) shows the probe's 401 means
two different things depending on whether credentials were sent:
credential-free: /api/health -> 401 no_cookie, /api/status -> 200
credentialed: /api/health -> 404, /api/sessions -> 200
So the fix is not "fall back on any 401". Uncredentialed, a gate-shaped 401
identifies a missing route and must fall back. Credentialed, a 401/403 is a
rejected session and must fail fast — falling back would hit the public
/api/status, get a 200, and report a dead session as ready, deferring the
no_cookie to the first real API call.
Split the two cases and tag the credentialed rejection as a terminal reauth
error. A generic 401 without the gate shape, plus 429 and 5xx, keep polling
as before.
Clicking "Install Hermes locally" surfaced no error when the bridge was
missing, if the click landed before React drained the passive effect that
reacted to the first bootstrap snapshot. The effect cleared the transient
button state on every change of setupChoice.activeRoot, and the very first
snapshot counts as a change: it moves the root from null to its initial
value. The choice paints as soon as that snapshot commits, so a fast click
produced an error that the effect then cleared before it ever rendered.
The state now records the root it was produced under and is read back only
under that same root, so leaving a phase still discards it but arriving in
one cannot. Deriving it removes the effect rather than adding a guard to it.
Reproduced by observing the DOM with a MutationObserver instead of findBy*,
which only settles on a timer tick — by then the effect has already run and
the window is closed. Reverting the component change fails the new test.