Commit Graph

4143 Commits

Author SHA1 Message Date
Brooklyn Nicholson d91f08a39a fix(desktop): keep tool rows and notices out of the HUD band
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.
2026-08-08 19:32:54 -05:00
brooklyn! 37bb73d2cf Merge pull request #82101 from NousResearch/bb/hud-slash-popover
fix(desktop): make the slash list usable in HUD mode
2026-08-08 19:20:20 -05:00
Brooklyn Nicholson 05c5a337f6 fix(desktop): settle the HUD band's glanceable hold at 1.1s
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.
2026-08-08 19:12:27 -05:00
hermes-seaeye[bot] 99d1f18377 fmt(js): npm run fix on merge (#82099)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-08-09 00:11:46 +00:00
Brooklyn Nicholson 1040cfe286 fix(desktop): shorten the HUD band's glanceable hold to 1.75s
2.5s was long enough to feel like the band was waiting for something.
2026-08-08 19:10:35 -05:00
Brooklyn Nicholson 3d5f173440 feat(desktop): lift the HUD bar off what it lies over
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.
2026-08-08 19:10:35 -05:00
Brooklyn Nicholson 051a7fb1e2 fix(desktop): open the HUD completion list where there is room for it
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.
2026-08-08 19:08:46 -05:00
Brooklyn Nicholson 5ff506896f feat(desktop): sit the HUD band back when a completion list opens
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.
2026-08-08 19:08:45 -05:00
brooklyn! 34f3135879 Merge pull request #82077 from NousResearch/bb/hud-drag-and-chrome
fix(desktop): make the HUD grabbable, and stop it painting over dead space
2026-08-08 18:58:10 -05:00
Brooklyn Nicholson d2dc0ce707 fix(desktop): hold the HUD band when the window loses focus
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.
2026-08-08 18:49:19 -05:00
Brooklyn Nicholson bed3960e9d fix(desktop): give the HUD band a real glanceable stage before it goes
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.
2026-08-08 18:44:11 -05:00
Brooklyn Nicholson c5494ddddb chore(desktop): satisfy eslint on the HUD drag additions
Prop ordering on the composer root, and a disable directive for a rule the
effect no longer trips.
2026-08-08 18:44:11 -05:00
Brooklyn Nicholson eee38db824 fix(desktop): let the HUD band fade while a turn is still running
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.
2026-08-08 18:39:52 -05:00
Brooklyn Nicholson 7e3deea0a8 fix(desktop): make the HUD band a real box so it scrolls
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.
2026-08-08 18:36:56 -05:00
Brooklyn Nicholson 048907fb66 feat(desktop): show work and focus on the HUD bar itself
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.
2026-08-08 18:31:57 -05:00
Brooklyn Nicholson 4424a5a60f fix(desktop): stop the HUD painting sheet over empty space
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.
2026-08-08 18:31:52 -05:00
Brooklyn Nicholson dd11b8e8e8 fix(desktop): drag the HUD by holding the composer
`-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.
2026-08-08 18:31:37 -05:00
Brooklyn Nicholson 0db2788338 fix(desktop): keep the composer focused while reading the HUD band
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.
2026-08-08 18:31:29 -05:00
Brooklyn Nicholson 50e0640d1a fix(desktop): drop the "Edit message" tooltip from user bubbles
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.
2026-08-08 18:31:29 -05:00
Brooklyn Nicholson 67518a2adf fix(desktop): repaint peer windows when the appearance changes
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.
2026-08-08 18:31:25 -05:00
hermes-seaeye[bot] 3e6a081d60 fmt(js): npm run fix on merge (#82055)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-08-08 22:51:53 +00:00
brooklyn! 3898e646e5 Merge pull request #82044 from NousResearch/bb/agent-plugins
Surface agent plugins in the desktop app's Settings → Plugins
2026-08-08 17:41:38 -05:00
Brooklyn Nicholson 44790bc9c8 feat(desktop): plugin descriptions + open the agent plugins folder
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.
2026-08-08 17:34:20 -05:00
Brooklyn Nicholson c86da8397b feat(desktop): agent plugins in Settings → Plugins
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.
2026-08-08 17:24:59 -05:00
brooklyn! 3391836888 Merge pull request #82036 from NousResearch/bb/session-arc-source
perf(desktop): keep session status inside the row's own fiber
2026-08-08 16:58:34 -05:00
brooklyn! 4907e3d900 Merge pull request #81977 from helix4u/fix/desktop-main-window-ready-fallback
fix(desktop): reveal windows after a missed ready-to-show event
2026-08-08 16:58:00 -05:00
Brooklyn Nicholson 41ae3db426 fix(desktop): reveal every window after a missed ready event, not just the main one
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.
2026-08-08 16:51:44 -05:00
Brooklyn Nicholson 041cbff5e3 fix(desktop): keep the Playwright reveal path off the production fallback
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.
2026-08-08 16:51:44 -05:00
Brooklyn Nicholson 615a435b7a perf(desktop): stop a settling turn from repainting the whole sidebar
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.
2026-08-08 16:51:25 -05:00
Brooklyn Nicholson b07ee44f1b refactor(desktop): let the sidebar row read its own status
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.
2026-08-08 16:42:00 -05:00
brooklyn! 71326399d3 Merge pull request #81991 from NousResearch/bb/session-status-dot
fix(desktop): make the session status dot mean one thing
2026-08-08 16:33:39 -05:00
brooklyn! 51597c5e07 Merge pull request #82007 from NousResearch/bb/hud-surface-note
The agent knows when it's floating in HUD mode, and looks at the app underneath
2026-08-08 16:28:35 -05:00
Brooklyn Nicholson f04ad5a829 style(desktop): drop the pulsing glow behind the status dot
Nothing renders it now that the dot holds still, and the row markup no longer
has to opt out of overflow clipping to leave room for the halo.
2026-08-08 16:26:48 -05:00
Brooklyn Nicholson 302ee80b6f refactor(desktop): give the status dot one source of truth and a quieter look
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.
2026-08-08 16:26:48 -05:00
Brooklyn Nicholson b70c5cadb3 fix(desktop): stop the session status going idle while the turn is running
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.
2026-08-08 16:26:48 -05:00
Brooklyn Nicholson 0665cd4b5b style(hud): tighten the surface-note comments and test helper
Comment wording only, plus the desktop test's boolean parameter becomes
an 'app' | 'hud' union so the call site says which window it means.
2026-08-08 16:21:15 -05:00
brooklyn! 8e9ecc1f3e Merge pull request #82014 from NousResearch/bb/hud-band-hit-area
HUD: only take the mouse where the HUD actually is
2026-08-08 16:20:12 -05:00
Teknium 7537de9e74 fix(deps): patch 31 known CVEs across Python and npm lockfiles
OSV weekly scan reported 50 known vulnerabilities in pinned deps.
This bumps everything with a released, semver-compatible fix:

Python (uv.lock):
- aiohttp 3.14.1 -> 3.14.3 (GHSA-cq5v-8q36-5273, GHSA-mfx4-hv73-q22v,
  GHSA-mq44-7p77-q5h7)
- h2 4.3.0 -> 4.4.1 (CVE-2026-71554 request smuggling; exclude-newer
  exception documented in pyproject, remove after 2026-08-17)

npm (root workspace):
- brace-expansion 5.0.8 -> 5.0.9, undici 6.27->6.28 / 7.28->7.29,
  js-yaml 4.3.1, nanoid 3.3.17/3.3.18, ip-address 10.4.0,
  mermaid 11.16.1 + dompurify 3.4.13 (root overrides so the
  streamdown transitive copy is pinned too)
- electron 40.10.2 -> 40.10.6 (GHSA-r4w5-6pfg-jxp5; the 41.x major
  for GHSA-9f4c-93c8-jc8g is deferred to its own PR)

npm (website): mermaid, dompurify, js-yaml, nanoid, fast-uri 3.1.5,
postcss 8.5.23, undici 7.29.0
npm (photon sidecar): @opentelemetry/core 2.8.0 via override, undici
npm (whatsapp-bridge): body-parser 1.20.6

min-release-age excludes added to .npmrc/website/.npmrc for the
sub-2wk CVE-fix releases, each with a removal date.

Remaining findings are blocked upstream: cryptography <49 cap
(alibabacloud-tea-openapi), image-size (no fixed release), tar 6.x
transitive majors, electron 41.

Local rescan: 50 -> 19 known vulns, 0 introduced.
2026-08-08 14:06:48 -07:00
Brooklyn Nicholson 717b49c084 fix(desktop): read "is the cursor over the HUD" off the tree, not off a list
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.
2026-08-08 15:46:40 -05:00
Brooklyn Nicholson 48d672e493 fix(desktop): the HUD only takes the mouse where the HUD actually is
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.
2026-08-08 15:46:26 -05:00
Brooklyn Nicholson e24bac49fa feat(desktop): tell the agent when it is floating in HUD mode
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.
2026-08-08 15:37:41 -05:00
hermes-seaeye[bot] 3da72f1fd1 fmt(js): npm run fix on merge (#82000)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-08-08 20:29:52 +00:00
hermes-seaeye[bot] a726a4aee6 fmt(js): npm run fix on merge (#81997)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-08-08 20:20:18 +00:00
Brooklyn Nicholson c96b978d54 fix(desktop): drop a stale comment describing the removed inheritance step 2026-08-08 15:00:44 -05:00
Brooklyn Nicholson bd39673b8f refactor(desktop): one path-comparison helper instead of two
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.
2026-08-08 14:50:43 -05:00
Gille 48c05e0c6b fix(desktop): reveal main window after missed ready event
Co-authored-by: Thomas Repka <148156831+Gateton@users.noreply.github.com>
Co-authored-by: rshi0212 <61662344+rshi0212@users.noreply.github.com>
2026-08-08 13:41:04 -06:00
Brooklyn Nicholson 9050913e68 fix(desktop): remember the workspace you picked, not the one you looked at
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.
2026-08-08 14:40:26 -05:00
Dan Bennett 7ff9d7db91 fix(desktop): match a project to its cwd across Windows path spellings
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.
2026-08-08 14:40:25 -05:00
David Metcalfe 6dda0c91d9 fix(desktop): stop new chats inheriting the focused session's folder
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.
2026-08-08 14:40:24 -05:00
ypQQ1984 4c9e1e8223 fix(test): make desktop ui tests locale-agnostic
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.
2026-08-08 12:33:19 -07:00