The band is a few lines of conversation floating over another app, not a
transcript. Tool blocks, file-diff panels, and background notices ("Self-
improvement review: patched ...") pushed the actual answer out of the
capped band and read as junk pinned over the window below.
Anything longer reads as the band waiting for something. Also drops the note
claiming a sub-second hold disappears into the fade — that was written when
losing window focus never started the hold at all, so what looked like too
short a stage was no stage.
A hairline shadow under the bar's bottom edge. Without it the HUD reads as
pasted onto the other app rather than floating above it; kept tight and faint
so it never becomes a glow around a card.
The composer's completion list — `/`, `@`, `:`, and the help hint — hangs off
the top of the bar. That is right everywhere the composer has a window above
it, and wrong in the one place the bar is parked against the screen's top edge:
the list rendered off screen, so `/` looked like it did nothing at all.
Flip it below the bar in that orientation and cap it to the room it actually
has, since the app's own cap assumes a full window.
The list hangs over the band, and both were half-lit over a third thing — the
app underneath. No pair of opacities reads well in that stack.
So the list goes fully opaque and the band falls back behind it: dimmer,
fractionally smaller, slightly out of focus, scaled from the bar's edge so it
reads as depth rather than as the panel shrinking.
Clicking away to another app is the commonest way the HUD gets let go of, and
it fires no focusout — the composer stays document.activeElement while the
window is inactive. Chrome stops matching `:focus` on an unfocused window all
the same, so the band lost its focus state with no hold running and snapped
shut instead of stepping down to the glanceable stage.
The sheet also goes heavier than the text in front of it. There is no blur to
separate the band from what it lies over, so it is the only thing keeping
half-opacity text off someone else's UI.
The middle state — half-opacity text over the tinted sheet, after a turn lands
or after you click away from the composer — held for 700ms, which reads as part
of the fade rather than as a state you can still finish reading in. 2.5s.
Clicking away always buys the full window, streaming output still does not, so
an unfocused HUD fades on the same clock whether or not the agent is working.
Two things pinned the transcript open for the length of a reply, so
unfocusing the HUD left it sitting open across the screen for as long as the
agent worked — the state the fade exists to avoid.
A busy session no longer counts as held. Held is for a question the agent
cannot continue without (clarify, approval, sudo, secret), where fading hands
you a prompt that is neither readable nor clickable; watching a turn write
itself is what focus is for, and the answer landing flashes the band anyway.
And activity only re-arms the hold while the composer has focus. $messages
republishes ~30×/s mid-stream, so an unfocused HUD was being held open by its
own streaming output.
The band ran the full window and was clipped down for paint and hit-testing,
which was fine while its height was whatever the transcript measured. Capping
it broke that: the scroll container was still window-tall, so content shorter
than the window never overflowed and never scrolled, while the clip hid
everything past the cap. Half the transcript was unreachable.
Give the band the geometry it was only pretending to have — anchored to the
bar, as tall as --hud-band-height, inset at the sides — and drop the clip-path
along with the bar-height clearance that only existed to hold text out from
under a composer the box no longer runs beneath.
In HUD mode the bar is often the only thing on screen, so the states that
matter have to live on it.
Working gets the travelling arc the sidebar's active session already uses, at
2px and pill-rounded to match. Focus recolours the border the bar already
draws: the bar sits flush against the window, so a ring or a shadow is sawn off
by the window before any CSS can shape it, and buying the clearance moves the
bar.
The exit control moves off the band and into the open space above the composer,
right-aligned, appearing on focus only — anchored to the band it drifted to
wherever the transcript happened to end, and a turn landing is not a reason to
offer the window controls. Placed from --hud-bar-height rather than CSS
anchor(), because Lightning CSS drops an entire rule containing an `anchor()`
on the vertical axis: the flipped-edge override never reached the browser and
the control rendered off screen. In that orientation the bar hugs the window's
top edge, so the strip it sits in is reserved as dock padding.
Four separate ways the band claimed room it had nothing to put in:
The height was measured to the viewport's edge, and the scroll container is
`min-height: 100%`, so the whole window counted as transcript. Measure the
message rows instead, and treat zero-height rows as no transcript at all — a
fresh thread still renders scaffolding in the content box, which was enough to
buy the 12px overhang and leave a sliver of sheet hanging under the bar.
The band was uncapped, so a long thread turned a glance-over-your-work strip
into a second window. It now tops out at the smaller of 9.5rem and 42% of the
HUD.
The frost is native vibrancy — the OS content view, which fills the window
rectangle and cannot be clipped to the sheet from the page — so it is only ever
right when the sheet covers the window. With the band capped that is now
essentially never, and anything looser paints a grey slab across the whole HUD.
The composer's drop target is a full-window dashed sheet sized for the app's
chat column; in a bar there is nowhere to drop anything anyway.
Also insets the band 0.5rem each side so the bar's corner controls sit clear of
the sheet's edge rather than on top of it.
`-webkit-app-region: drag` and `useHudClickThrough` cannot share a window, and
every previous attempt at a HUD grab handle had them fighting. The window
manager takes a draggable region's mouse input whole, so the page never sees
the cursor arrive on the handle — and click-through, which decides whether the
window is solid from exactly those moves, has already handed the window to the
desktop by the time you press. The handle was unusable (the press fell through
to the app behind) and, having eaten the moves on the way out, it also left the
HUD solid over its own dead space so clicks meant for the app behind died in
empty window.
So no HUD surface declares a drag region any more, and dragging is a press and
hold on the bar: 140ms to arm, then the renderer moves the window through a new
`hud.moveBy`. Deltas are read in screen coordinates, because client coordinates
are relative to the window being moved and report zero once it keeps up with
the cursor. The pointer is captured and the window pinned solid for the
duration, so a fast drag cannot outrun the bar.
Removes the 2rem invisible drag strip along with it — dead window that only
ever swallowed clicks aimed at whatever was behind the HUD.
The band is there to be read over another app, so clicking a line in it is not
leaving the composer. Mousedown on the scrollback blurred the input, which
faded the band and dropped the focus treatment mid-read.
The bubble IS the edit button, so the native title popped up on hovering any
user message anywhere in the app. The aria-label stays, so the control keeps
its accessible name.
Skin and mode are per-profile localStorage, and every desktop window is a
separate renderer on the same origin that reads them once at boot. Changing
the theme in the HUD therefore repainted the HUD alone; the app window still
held its startup value and reverted the moment you looked at it.
Listen for `storage`, which fires in the OTHER windows of an origin — exactly
the set that needs to catch up.
HermesPlugin/PluginRecord gain a description one-liner (kanban gets
one) shown in the inventory instead of the raw file path, and the
agent plugins section can open the backend's plugins dir — path from
config.get profile so it's profile-aware, local backends only since
openDir mkdir-creates.
Backend plugins — native Hermes plugins and portable Agent Plugins v1
packages — were invisible in the desktop app. Settings → Plugins now
lists them under the desktop (renderer) plugins with source/portable
pills, enable/disable switches keyed by canonical registry key, and a
live-filter search box, backed by a nanostore over plugins.manage.
Categories other surfaces own (dashboard_auth/*, model-providers/*,
platforms/*) are curated out renderer-side.
Session, instance, HUD, quick-entry and pet-overlay windows all open with
show: false and are revealed only by ready-to-show, so the Electron 40 bug
strands them exactly the way it stranded the primary window — and none of
them have the second-launch workaround that made the main-window case
recoverable.
Generalize the controller to any window and wire all six through one
wireWindowReveal helper. Callers pass their own reveal action (showInactive
for the pet overlay, show + focus for the HUD and quick entry) and their own
post-visible work, so whichever path wins runs them exactly once.
Quick entry now reveals the window the call created rather than whatever
`quickEntryWindow` points at when the event lands.
Desktop E2E is hard-disabled in ci.yml (#76627) because the mock-backend
window never reaches a usable state, so nothing can validate dropping the
TEST_WORKER_INDEX force-show right now — and the suite's lead symptom is
already a window-readiness failure. Restore it, routed through the reveal
controller so the bookkeeping in onRevealed still runs exactly once, and
leave the removal to whoever re-enables the suite.
ChatSidebar read $workingSessionIds with useStore purely to notice that a turn
had finished and re-probe worktree lanes. Nothing in its markup used the value,
so every status edge re-rendered the entire sidebar — each section, each row —
to run an effect that touches no DOM.
Listen to the store instead. The rows own their status subscription, so a
session changing color repaints that row's fiber and nothing above it, which a
test now holds in place by counting row renders.
The dot resolved its state through $sessionDotStateById while the arc on the
same row was decided from an isWorking prop, drilled from the sidebar through
two list components and asserted in five test setups. Two paths to the same
question is how the row's arc and its dot end up disagreeing, and it is why the
arc has broken independently of the dot before.
The row now reads the resolved state directly, and the arc rule moves next to
the states it talks about as `showsRunningArc`. `hasLiveTurn` keeps the row's
other treatment — brighter title, age yielding to the actions menu — on the
wider meaning it always had, where a turn waiting on an answer still counts as
this session's turn.
The list chain drops the prop, its types and the id set built to feed it.
`$workingSessionIds` stays where the sidebar genuinely needs it, for noticing
that a turn settled.
Priority between the overlapping signals — a session can be working and unread
and running a background job at once — was resolved at the call site from five
separate membership lookups, which is how surfaces drift apart. `$sessionDotStateById`
does it once and hands each surface a single answer.
The dot's visual language collapses to three colors on one fill/hollow axis
with nothing moving. Motion on a six-pixel circle can only say "something is
happening", which the row's arc already says better, and it cost a repaint per
frame on every row at once; filled now means producing and hollow means open
but quiet. Working and stalled had differed by 30% opacity and were in practice
the same dot. A settled session paints its project color or nothing, rather
than a grey mark of the same weight as a real status next to every resting row.
The switcher had grown its own dot with its own three states, so it disagreed
with the sidebar on the same session. It renders the shared one now.
The status sets are published under a session's current stored id, but the
sidebar row, a persisted tile and the route can each be holding a different
tip of the same lineage after a compression, and every consumer tested
membership with a plain equality check. When the tips disagreed the session
fell out of the working set mid-turn and the dot dropped to idle with the
model still going. Publish each state under every id the conversation answers
to instead, via a shared `lineageAliases` helper.
A conversation that has not been persisted yet has no stored id at all, and
the projection dropped those rows outright, so the first turn of a new chat
showed no dot and no row arc until the backend handed an id back. Fall back to
the runtime id, which until persistence is the same value the surfaces key on.
Background polls could also clear a live busy state before the backend had
caught up with a just-submitted turn, flicking the dot idle for a beat; the
stream path already guards against that, so the poll path now does too.
The stalled watchdog fired at eight minutes, well past the point of being
useful as a hint. Five is past the app's own long-but-healthy silences, like
a typecheck or a full test run, without outlasting the user's patience.
The hit test excluded <body> and <html> and missed `#root`, which is
full-window and hit-testable, so every point in the window came back as
something and the window never went mouse-transparent at all. Ask it
structurally instead: anything that CONTAINS the shell is scaffolding around
the HUD rather than part of it, which covers the mount, the body and the
document in one predicate and cannot be out of date again.
Focus gets the same treatment. #81552 pinned the window solid whenever
anything in it held focus, to stop the HUD going click-through under its own
dialogs — but the composer holds focus as the HUD's resting state, so an
engaged HUD claimed its whole rectangle. What that fix needed was focus
BESIDE the shell: a portalled dialog, popover or menu owns the next click,
including the one outside it that dismisses it, and the hit test cannot see
that one coming. Focus inside the shell is the composer, and the hit test
already covers everything the composer can reach.
The decision is a pure function now, so it can be tested against a real DOM
instead of inferred from the effect.
Follow-up to #81920, which bounded the frost to the sheet and left the surface
underneath it unbounded. Nothing paints in the empty space above a short
transcript now, and clicks still die there.
Two reasons, both in this stylesheet. The shell's scaffolding — the shell
itself, the chat surface, the wrapper between them — is full-window,
invisible and hit-testable, so the click-through hit test found something at
every point in the window. And the band's box is the whole window by design
(it is the scroll container), so engaging the HUD turned that entire rectangle
into a click target, which on a fresh thread is a window-sized hole over
whatever you were working in.
So: default the shell to `pointer-events: none` and let surfaces opt in, and
clip the band's box to the sheet, which hit-testing honours. Opting in rather
than listing the scaffolding to exclude, because the scaffolding is not a list
anyone maintains — one more wrapper and the dead rectangle is back, whereas a
control that forgets to opt in is visibly dead.
In HUD mode Hermes is a strip over the app the user is actually working
in, so "what's under you?" or "look up the weather" is almost always
about that app — but the agent had no way to know it was floating, and
answered from its own browser and panes instead.
The desktop tags a HUD submit with `surface: 'hud'` and the gateway turns
that into a per-turn note pointing at read_window_below, and at carrying
the work out in the app underneath. It rides the model-bound message
beside the reaction and speech-interrupted notes rather than the system
prompt: one session can be driven from the app window on one turn and the
HUD on the next, and the system prompt has to stay byte-stable.
Every tool the note names is checked against the agent's own schema
first, so a session without computer_use or read_window_below is never
pointed at a tool it cannot call.
The Windows-aware path matching added for project ownership was a second
copy of what the file tree's IPC layer already had — same Windows test, same
containment check, one of them carrying a trailing-slash branch its own
normalisation made unreachable. Both now share lib/path-compare.
On a remote backend a new chat starts in the remembered workspace, and
setCurrentCwd persisted that key on every call — including the six paths
that merely follow a conversation (resume settling, warm switch, stored-row
preview, agent relocation, boot seed, resolved new-chat default). So opening
a chat inside a project quietly made that project the destination for the
next "New session", which is the half of the report the resolver fix does
not reach: a Windows desktop driving a WSL gateway is a remote connection.
setCurrentCwdTransient already meant "move the path, claim nothing" — the
following paths now use it, and setCurrentCwd is reserved for a workspace
the user actually named.
Project ownership compared paths literally, so a nested cwd failed to match
its project whenever the separator or drive-letter case differed — which on
Windows is routine. Normalise both sides for comparison only, folding case
for drive and UNC paths.
resolveNewSessionCwd() inherited the focused chat's workspace, so every
"New session" landed in whatever project you were last looking at — and
after a restart the focused session's stored cwd is often a home-dir
fallback, which shadowed the configured default project dir entirely.
The boot seed had a second failure mode: ensureDefaultWorkspaceCwd() only
seeds while no session is active, and it ran after gateway.connect() — the
same event that un-gates route-resume. On a slow start the resume won and
the seed silently skipped. Seed before connect instead, where no session
can be active yet, and keep both seeds non-fatal.
Three desktop UI tests froze en-US-formatted strings while the
implementation formatters deliberately use the runtime locale
(new Intl.DateTimeFormat(undefined, ...) / Intl.NumberFormat(undefined,
...)) — runtime-locale output is the intended behavior for a localized
UI. On any non-en-US dev machine the tests fail even though the code is
correct:
# zh-CN host:
time.test.ts -> expected '三月' to be 'March'
billing -> Unable to find text 'Threshold: minimum is $10.'
(zh-CN renders USD as 'US$10')
billing -> Unable to find text '$25 added. Balance is refreshing.'
Assert the behavior contract instead of the frozen snapshot, per the
repo's testing guidance (behavior contracts over snapshots):
- time.test.ts: same-year month buckets render via fmtMonth, prior-year
via fmtMonthYear — assert sessionBucketLabel(bucket) equals the shared
formatter's output for bucket.at, with bucket-kind narrowing.
- billing/index.test.tsx: interpolate formatMoney(10) / formatMoney(25)
into the expected strings.
No production code changes.
Verified: zh-CN host 40/40, LANG=C.UTF-8 40/40, tsc clean, eslint clean.