* fix(desktop): render approval/sudo/secret prompts so tools stop silently timing out
The desktop app's gateway event handler (use-message-stream.ts) handled
clarify.request but had no case for approval.request, sudo.request, or
secret.request. When a tool needed approval, the gateway emitted
approval.request and blocked the agent thread in _await_gateway_decision()
for up to 5 min (approvals.gateway_timeout); the desktop dropped the unknown
event, never showed a dialog, then the agent returned BLOCKED. No prompt,
just a stall then a block.
The Ink TUI already handles all three (createGatewayEventHandler.ts); this
brings the Electron app to parity.
- store/prompts.ts: approval/sudo/secret atoms (+ request-id-guarded clears)
- components/prompt-overlays.tsx: Radix dialogs; close/Esc maps to refusal so
silence is never mistaken for consent (parity with TUI Esc->deny)
- use-message-stream.ts: wire the three *.request cases; clearAllPrompts on
message.complete so an overlay can't outlive its turn
- chat-messages.ts: GatewayEventPayload gains command/description/env_var/prompt
- mount PromptOverlays in the chat shell
* feat(desktop): inline tool-call approval bar (Cursor-style "Run")
Render dangerous-command / execute_code approval inline on the pending
tool row instead of as a modal. Binding is positional: the desktop
tool.start payload carries no structured args, but approval.request only
fires from the terminal/execute_code guards and the agent blocks on one
approval at a time, so the single pending row of those tools is the one
that raised it. Command/description text comes from $approvalRequest.
Drops ApprovalDialog from PromptOverlays (sudo/secret stay modal).
* style(desktop): make inline approval bar match Cursor's command card
Drop the amber alert styling for a neutral elevated card: command on a
terminal-prefixed row up top, a divided footer with the muted description
on the left and right-aligned controls — a ghost "Reject" (Esc) plus a
split primary "Run" (⌘⏎) whose chevron opens "Allow this session" /
"Always allow" / "Reject". Wire ⌘/Ctrl+Enter → Run and Esc → Reject to
match Cursor's accept/skip bindings, guarded against double-send via the
$approvalRequest atom.
* style(desktop): shrink inline approval to a tiny Cursor-style button strip
The running tool row already shows the command, so drop the whole card +
command echo + description band. What's left is a compact strip under the
row: a small split "Run ⌘⏎" button (chevron → Allow this session / Always
allow / Reject) and a ghost "Reject Esc", indented to sit under the row's
title text.
* style(desktop): drop the loud blue Run button for a quiet outlined control
Swap the primary (blue) Run for a subtle outlined split control — neutral
border, transparent fill, hover-accent — so the approval strip reads as
quiet inline affordance rather than a big CTA. Reject stays ghost.
* style(desktop): make Run a soft primary badge
Tint the Run split control with the primary color as a badge (bg-primary/10,
primary text, primary/25 border, rounded-md, hover primary/15) instead of a
solid CTA or a neutral outline.
* style(desktop): slim the approval chevron and space out Reject
The chevron button had ballooned because dropping the size prop fell back
to the big default size (h-9 + has-svg px-3). Pin size=xs everywhere and
give the chevron a tight w-5/px-0. Bump the gap between the Run badge and
Reject (gap-2.5) and loosen Reject's internal spacing.
* feat(desktop): confirm before "Always allow" persists an approval
"Always allow" writes the matched pattern to ~/.hermes/config.yaml and
suppresses the prompt in every future session — too consequential to fire
straight from a menu click. Route it through a confirm dialog that names
the pattern + command and the file it touches. The dialog owns the
keyboard while open so Esc closes it instead of denying the approval.
* fix(gateway): make sudo + secret prompts actually fire in the desktop
Tek's PR added the sudo/secret overlays and callback wiring, but neither
reached the live path:
- Sudo: the sudo password callback is thread-local (terminal_tool
_callback_tls), and _wire_callbacks runs on the agent-build thread, not
the turn thread that executes tools. At command time the callback was
missing, so terminal sudo fell through to /dev/tty and hung the headless
gateway. Re-wire callbacks at the top of the prompt-submit turn thread.
- Secret: skills_tool short-circuited to the "secret entry unsupported"
hint for any gateway surface, before invoking the callback. Interactive
surfaces (desktop/TUI) register a secret-capture callback that routes to
the secret.request overlay; only short-circuit when no callback exists,
so messaging still gets the hint but the desktop prompts.
* docs(desktop): drop Cursor references from approval comments
* docs(desktop): drop Cursor reference from prompt-overlays comment
* fix(skills): gate in-band secret capture on HERMES_INTERACTIVE, not callback presence
The desktop/sudo PR switched the gateway secret-capture short-circuit from
"any gateway surface" to "gateway surface with no callback registered". That
made a messaging gateway (telegram/discord/...) attempt interactive in-band
secret capture whenever any callback happened to be registered, instead of
returning the safe "setup unsupported" hint — and broke
test_gateway_still_loads_skill_but_returns_setup_guidance.
Discriminate on HERMES_INTERACTIVE instead: the desktop app / TUI set it in
_enable_gateway_prompts (alongside registering the secret.request callback),
while messaging platforms never do. This is the same flag tools/approval.py
uses to tell an interactive surface from a messaging one, so messaging keeps
the hint and desktop/TUI still prompt.
---------
Co-authored-by: Brooklyn Nicholson <brooklyn.bb.nicholson@gmail.com>
Onboarding's "Local / custom endpoint" only wrote the OPENAI_BASE_URL env
var, which runtime resolution ignores — so a self-hosted endpoint was never
wired in and setup failed with "No usable credentials found for custom" even
though local servers need no key.
Route the local option through saveOnboardingLocalEndpoint: probe the
endpoint, auto-discover a model from /v1/models, persist provider=custom +
base_url + model via /api/model/set, then verify the runtime directly
(not via completeWithModelConfirm, which would re-assign the model without
base_url and wipe it). No onboarding form/UI changes — the existing single
URL field is enough.
The setup-flow provider list showed two Anthropic/Claude entries with
ambiguous labels ('Anthropic (Claude API)' and 'Claude Code (subscription)')
in no deliberate order. Relabel and reorder so the distinction and the
subscription caveat are explicit:
- 'Anthropic API Key' (PKCE, API path)
- 'Anthropic OAuth: Required Extra Usage Credits to Use Subscription' (external)
- Both Anthropic entries moved to the bottom of the list.
- 'OpenAI Codex (ChatGPT)' -> 'OpenAI OAuth (ChatGPT)', now first after Nous.
Applied consistently to the backend OAuth catalog (web_server.py) and the
desktop onboarding overlay's PROVIDER_DISPLAY title/order map; test
assertions updated to the new titles.
The launch provider setup screen rejected too many legitimate users:
a live credential probe ("key rejected"), a post-save runtime check
("still cannot reach X"), and an 8-char minimum all gated progression.
Corporate proxies, regional blocks, rate-limited/flaky probes, and
self-hosted endpoints all tripped these. Now we just require a
non-empty value and save it; a genuinely bad key surfaces later at
chat time instead of blocking onboarding.
The desktop chat app's slash curation (desktop-slash-commands.ts) only
suggested the ~19 curated built-ins. isDesktopSlashSuggestion required
membership in DESKTOP_COMMANDS, so every skill-derived command and user
quick_command was silently dropped from both completion paths
(commands.catalog empty-query + complete.slash typed-query) and from
filterDesktopCommandsCatalog — even though isDesktopSlashCommand let them
EXECUTE when typed in full. The tui_gateway backend already includes skills
in both RPCs; the gap was purely renderer-side.
Add isDesktopSlashExtensionCommand() (= not-a-known-Hermes-built-in, the
same predicate that already gates execution) and let extensions through the
suggestion path. The catalog filter routes through isDesktopSlashSuggestion,
so skill/quick-command categories and pairs are kept automatically.
* fix(desktop): prevent IME Enter from splitting messages and viewport resize from disarming scroll anchor
Two fixes for the Hermes Desktop composer:
1. IME composition Enter was treated as message submission. When a Korean/
Japanese/Chinese IME is composing text and the user presses Enter to
finalise the preedit, handleEditorKeyDown fired submitDraft() because it
did not check event.nativeEvent.isComposing. The assistant-ui hidden
textarea already guards this correctly; the custom contentEditable
handler was missing it. Added an early return when isComposing is true.
2. Viewport resize (composer expand/collapse, window resize) was disarming
the scroll sticky-bottom anchor. When the composer grows, the thread
viewport shrinks, the browser adjusts scrollTop down to keep content
visible, and the onScroll handler misread this as a user scroll-up.
Added lastClientHeightRef tracking so the disarm condition now requires
BOTH stable scrollHeight AND stable clientHeight before treating a
scrollTop decrease as user intent.
Fixes: random mid-message sends during IME typing; scroll jumps when the
composer resizes or the window changes size.
* fix(desktop): prevent virtualizer measurement adjustments from fighting scroll anchoring
The virtualizer's measureElement callbacks trigger scroll adjustments when
item sizes differ from estimates. These fight our ResizeObserver +
pinToBottom loop, creating visible rubber-banding (view snaps to composer
then jumps back up), even during idle.
Three changes:
1. React.memo on VirtualizedThread to stop parent re-renders cascading
2. Shared stickyBottomRef so scrollToFn can check bottom state
3. scrollToFn override: skip adjustments when user is at bottom
* fix(desktop): use stable useCallback ref instead of inline arrow for onBranchInNewChat
The inline arrow `messageId => void branchInNewChat(messageId)` created a
new function reference on every render. This cascaded through:
desktop-controller → ChatView → Thread → useMemo([...onBranchInNewChat])
→ new messageComponents object → VirtualizedThread receives new prop
→ React.memo overridden → virtualizer recalculates → measurement
adjustments trigger scroll jumps at the 15-second useStatusSnapshot
interval.
Pass the already-useCallback'd branchInNewChat directly.
* fix(desktop): use ctrlEnter submitMode on hidden textarea + gate ResizeObserver on isRunning
Two root-cause fixes:
1. IME message splitting: The hidden ComposerPrimitive.Input textarea had
submitMode='enter' (default), so any Enter keydown it received — even
during IME composition — triggered form.requestSubmit(). Changed to
submitMode='ctrlEnter' so only the contentEditable div (which correctly
checks isComposing) handles plain-Enter submission.
2. Scroll jumps during idle: The ResizeObserver auto-follow loop was
active even when the thread wasn't running, causing spurious
pinToBottom calls whenever any layout shift occurred (browser reflow,
font load, GPU cache eviction). Gated the ResizeObserver on
thread.isRunning so auto-scroll only follows during active streaming.
User messages still pin via useLayoutEffect, and thread.runStart still
calls jumpToBottom.
* fix(desktop): keep chat bottom anchor stable through idle layout shifts
* fix(desktop): prevent code block shrink scroll bounce
* fix(desktop): release bottom height lock on run completion
* fix(desktop): keep streaming code blocks rendered
* fix(desktop): keep bottom anchored through final render
* fix(desktop): render streaming reasoning code blocks
* feat(desktop): add subtle streaming block animations
The macOS self-update relaunches and installs over the app it derives via
resolve_hermes_desktop_app (.../Hermes.app/Contents/MacOS/Hermes ->
.../Hermes.app). That derivation is load-bearing for both the ditto
install target and the auto-relaunch (open <app>), but had no test.
Add unit coverage:
- resolve_hermes_desktop_app_finds_built_bundle: a fake built release tree
resolves to the .app bundle on macOS (and the exe elsewhere).
- resolve_hermes_desktop_app_is_none_without_a_build: no build => None.
Verified the positive test FAILS if the .app parent-walk is wrong (e.g.
one too few .parent() hops), so it's a real guard against a regression
that would break the post-update relaunch target.
cargo test -> 17 passed.
The macOS self-update bundle swap (install_macos_app_update, added in
#38296) could leave the user with NO app installed. If moving the
existing /Applications/Hermes.app aside failed, the code deleted the
running app outright and set moved_old=false; if the subsequent move of
the freshly built bundle into place then also failed, the rollback was
gated on moved_old (now false) and skipped — leaving the target deleted
with no replacement.
Extract the swap into swap_in_new_bundle() with a strict invariant: on
ANY failure path the target is left pointing at a working bundle (either
the original, rolled back, or untouched) and is never deleted with no
replacement. Also clean up the staged .hermes-update-new copy on the
failure paths instead of orphaning it.
Add unit tests covering the happy path, the rollback-on-install-failure
path, and the catastrophic both-moves-fail path. The catastrophic-path
test was verified to FAIL against the old code ("original app must NOT
be deleted on failure") and pass against the fix.
Avoid stale WebSocket events from an old reconnect attempt flipping the gateway state after a newer socket opens. Also limit session-search dedupe to compression edges so branch-specific hits still open the branch instead of collapsing to the parent.
Four related desktop session-management bugs:
- Pins lost until refresh: pinned sessions are joined against the
paginated in-memory session list, so a pinned chat that aged off the
most-recent page got evicted on the next refresh (every message.complete
triggers one) and the Pinned section went empty. mergeWorkingSessions ->
mergeSessionPage now also preserves pinned rows (matched by live id or
lineage root). Pin id checks in the chat header, command center, and
delete/archive are normalized to the durable sessionPinId so pins survive
auto-compression.
- Stuck on "Starting Hermes" after sleep: macOS sleep drops the renderer
WebSocket; nothing reconnected on wake so the composer stayed disabled.
The gateway boot hook now auto-reconnects with backoff on close/error and
on wake signals (powerMonitor resume/unlock-screen IPC, window online,
visibilitychange). connect() gains an open timeout so a hung reconnect
can't deadlock in 'connecting'. Composer placeholder distinguishes
"Reconnecting to Hermes" from a cold start.
- Loses chats from itself: the same hard-replace that dropped pins also
dropped loaded sessions; mergeSessionPage keeps them.
- Multiple copies/branches in search: /api/sessions/search deduped only by
raw session_id, so compression segments and branches surfaced as separate
hits. It now dedupes by lineage root and returns the live compression tip,
matching the session_search tool's behavior.
PR #38296 added four emit_log() calls using the old 3-arg signature, but
main had already changed emit_log to take a `stream: LogStream` argument
(#38312, "stop mislabeling stdout-style progress as stderr"). The two PRs
touched different lines, so the merge auto-resolved with no conflict and
left main unable to compile the bootstrap installer (E0061: 4 args expected,
3 supplied).
Supply the missing stream: Stdout for the update/install progress lines and
Stderr for the "could not auto-launch desktop" failure, matching the
convention from #38312. cargo check passes.
Co-authored-by: Cursor <cursoragent@cursor.com>
Both installers (Electron bootstrap-runner + Tauri) hardcoded a literal
`stderr: ` prefix onto every line that arrived on fd 2. Tools like
uv/pip/git/npm write normal progress to stderr by design, so routine
install output showed up tagged as "stderr" (and rendered red in the
Tauri progress UI), making a healthy install look like it was erroring.
Carry the stream as structured metadata (`stream: 'stdout' | 'stderr'`)
on the log event instead of mangling the line text. The UI now styles
stderr subtly (dimmed) rather than alarmingly, and the persistent
forensic logs keep their stdout/stderr distinction.
Chromium exposes the same pasted image on both DataTransfer.items and
.files as distinct Blob objects, which attached twice. Prefer items and
skip the files mirror when items already yielded images.
The macOS DMG / in-app update could leave Hermes unable to relaunch: the
staged updater rebuilt the desktop without managed Node on PATH ("npm not
found"), never installed the rebuilt bundle over the running app, and could
race itself on `git stash`. Child install scripts also inherited a deleted
cwd from the .app bundle replaced during self-update.
- update.rs: prepend $HERMES_HOME/node/bin + venv bin to the rebuild PATH;
read --branch / --target-app from args; add a macOS "install" stage that
dittos the rebuilt bundle over the target app, clears quarantine, and
relaunches via `open` (rolling back on a failed swap); guard start_update
with an AtomicBool so concurrent startUpdate() calls can't race git stash.
- main.cjs: pass --branch <configured> and --target-app <running bundle> to
the staged updater, and spawn it with HERMES_HOME + managed Node/venv on
PATH and cwd=HERMES_HOME.
- bootstrap.rs: launch the desktop via `open <App>.app` on macOS instead of
exec'ing Contents/MacOS/Hermes, avoiding cwd/quarantine issues post-rebuild.
- powershell.rs: pin child install scripts to a stable cwd so they don't emit
getcwd errors when the launching .app is replaced mid-install.
- failure.tsx: in update mode show "Update didn't finish" / "Retry update"
and retry via startUpdate() instead of re-running the installer bootstrap.
The "Build desktop app" install step failed with an opaque "exit code 1"
on machines with an old Node, and nothing in the logs explained it.
Reproduced: on Node 20.5.1, `npm run pack`'s `vite build` crashes with
You are using Node.js 20.5.1. Vite requires Node.js version 20.19+ or 22.12+.
SyntaxError: The requested module 'node:util' does not provide an
export named 'styleText'
Vite 8 (rolldown) imports node:util.styleText, which doesn't exist before
Node 20.12, so the build dies before producing the app. The installer's
check_node / Test-Node accepted ANY pre-existing Node with no version
floor, so a too-old system Node was used for the build instead of the
bundled Node 22.
Add a version floor (^20.19 || >=22.12) to check_node (install.sh) and
Test-Node (install.ps1): a too-old system Node is replaced with the
Hermes-managed Node 22 LTS, and the desktop stage re-resolves Node so the
build always runs on a satisfying version. Declare the same range in
apps/desktop/package.json engines.
Verified: build succeeds on Node 22, fails on 20.5.1 with the error above;
the floor logic matches Vite's range across boundary versions (20.18/20.19,
21.x, 22.11/22.12).
The thread scroll-anchor hook in apps/desktop/src/components/assistant-ui/
thread-virtualizer.tsx was disarming sticky-bottom whenever scrollTop
decreased by >1px between scroll events. That check was too eager: when
content height grows mid-frame (virtualizer measurement of a newly visible
turn, streaming token, Streamdown/Shiki re-tokenization, composer chip
toggle), the browser emits an interim 'scroll' event whose scrollTop is
smaller than the previous frame's because scrollHeight just jumped. The
rAF-scheduled pinToBottom hasn't run yet, so programmaticScrollPendingRef
is 0 and the disarm fired. With sticky-bottom disarmed the scroller stuck
~50px above bottom — the visible at-rest backward jump that #37997
describes (and the same root cause as the wheel-up variant in #37527).
Fix:
- Track scrollHeight per frame (lastHeightRef). Disarm on scrollTop
decrease ONLY when scrollHeight did not grow this frame. Real upward
user intent (scrollbar drag, keyboard PgUp, programmatic scrollIntoView)
still disarms because it moves scrollTop without growing the content.
Wheel-up and touchmove continue to disarm via their own listeners.
- Stop observing the scroller element itself in the ResizeObserver; only
observe its content child. Viewport-only resizes (window resize,
devtools panel toggle) no longer trigger spurious pins, matching the
intent of the auto-stick-to-bottom behavior.
Verified:
- apps/desktop `tsc -b` clean.
- apps/desktop `vitest run src/components/assistant-ui/streaming.test.tsx`
passes (9/9), including the existing wheel-up disarm regression test
that asserts scrollTop stays at 420 after a wheel-up + content growth.
@testing-library/react@16 declares @testing-library/dom as a peerDependency
and re-exports waitFor/fireEvent/screen/within from it. Without dom installed
as a direct dependency, tsc -b fails with TS2305 in every test file that
imports those names — which breaks the apps/desktop build during installer
bootstrap (Hermes Setup → "INSTALL DIDN'T FINISH").
The Python half (#37538) reads HERMES_DESKTOP_CHILD_PID to exclude the
desktop-managed backend from _kill_stale_dashboard_processes, but nothing
set it. applyUpdatesPosixInApp now passes the live backend PID in the
`hermes update` env, completing the #37532 fix end-to-end.
Follow-up to #37937. That fix guarded the composer's keyup with
`shouldSkipTriggerRefreshOnKeyUp(key, trigger !== null)`. The `trigger !== null`
check is timing-fragile for Escape: Escape's *keydown* sets `trigger = null`
and closes the menu, but in a real browser the *keyup* fires after a re-render,
so the handler closure sees `trigger === null`, the guard returns false,
`refreshTrigger` runs, re-detects the still-present `/` in the input, and
instantly reopens the menu. (jsdom batches state synchronously so a unit test
could not observe this -- only the running app does.)
Replace the value-based guard with a `triggerKeyConsumedRef` set synchronously
in keydown whenever the open popover consumes a nav/control key
(Arrow/Enter/Tab/Escape). keyup consults and clears that ref, so it is immune
to the keydown->re-render->keyup timing. Applied to both the main composer
(chat/composer/index.tsx) and the message-edit composer
(assistant-ui/thread.tsx).
Removes the now-unused `shouldSkipTriggerRefreshOnKeyUp` helper and its unit
test. The real-DOM regression test now fires keydown+keyup pairs through the
ref-based handlers and asserts Esc closes and stays closed.
Verified by running a production renderer build (Vite v8) under Electron
against a local backend: ArrowDown/ArrowUp cycle the full list and Esc
dismisses the menu without reopening.
The existing slash-menu fix (PR #37937) shipped a unit test that drove the
keydown reducer directly. It did not exercise the actual DOM event path —
specifically the keyup-driven `refreshTrigger` that was the root cause — so
it would not have caught a regression in that path.
This adds a faithful @testing-library reproduction that mounts the real
`useLiveCompletionAdapter` plus the index.tsx trigger wiring and fires real
`keyDown` + `keyUp` event pairs on a contentEditable. It asserts:
- ArrowDown cycles through ALL items (0,1,2,3,4,0,1), not just the first two
- Escape closes the menu and keyup does not reopen it
Reverting the fix (always-refresh keyup + unconditional setTriggerActive(0))
makes this test fail with the highlight stuck at the top — confirming it
guards the real bug.
A still-busy background session (one the user toggled away from) keeps
emitting updateSessionState() heartbeats — stream deltas, and especially
the 'session busy' prompt-rejection errors from auto-drained queued turns.
Each call invoked syncSessionStateToView() unconditionally, staging that
session's messages into the shared $messages view.
flushPendingViewState() guarded against the wrong session reaching the
view, but only one requestAnimationFrame is scheduled per frame and
pendingViewStateRef holds just the latest writer. So within a single
frame a background write could overwrite an already-pending foreground
write, and the stale background transcript (e.g. the red 'session busy'
rows) would render on top of whatever session the user switched to —
appearing to 'bleed' into every session.
Guard at the staging site: a session may only stage into the view when
it is the currently-active session. Background sessions still update
their own cache entry; they just never touch $messages. Pure render
fix, no behavior change to queuing, interrupt, or drain.
When a follow-up message is queued during a busy turn, the composer
clears and the primary button switches back to the Stop affordance. But
clicking Stop ran interruptAndSendNextQueued(), which cancelled the turn
and *immediately* re-sent the head of the queue. The auto-drain effect
(busy true to false) compounded this: any explicit cancel flipped busy
false and re-fired the queue. The net effect was that Stop appeared to
never interrupt -- the agent kept running on the queued prompt.
Fix:
- Stop button (busy + empty composer) now always performs a pure
interrupt via onCancel(); it no longer hijacks the queue.
- An explicit interrupt latches userInterruptedRef so the busy to false
auto-drain skips exactly one drain. Queued turns are preserved and the
user resumes them deliberately (Cmd/Ctrl+K, Enter, or the per-row
send-now arrow), matching the documented Esc=cancel / Cmd+K=send-next
affordances.
- Extracted the settle decision into shouldAutoDrainOnSettle() with unit
tests covering natural completion vs. explicit interrupt.
The desktop composer's `onKeyUp` handler unconditionally re-ran
`refreshTrigger` on every keyup, including the Arrow/Enter/Tab/Escape keys
the open-trigger `onKeyDown` branch had already fully handled. Because
`refreshTrigger` re-detects the trigger and resets the active index to 0,
this produced two bugs in the `/` (and `@`) completion popover:
- ArrowDown/ArrowUp moved the highlight on keydown, then keyup snapped it
straight back to the top — so the user could never cycle past the first
couple of items.
- Escape closed the menu on keydown, then keyup re-detected the still-present
`/` and immediately reopened it — so Esc appeared to do nothing.
Fix: skip the keyup-driven refresh for the navigation/control keys while a
trigger menu is open (they never edit text, so refreshing is pointless), and
only reset the highlight in `refreshTrigger` when the detected trigger query
actually changed. Applied to both the main composer (chat/composer/index.tsx)
and the message-edit composer (assistant-ui/thread.tsx), which shared the
same bug. New `shouldSkipTriggerRefreshOnKeyUp` helper is unit-tested.
WSLg renders Linux GUIs locally through a vGPU surface rather than
shipping frames over the wire, so it doesn't show the remote-compositor
flicker — confirmed by a WSL user seeing zero flickering. Drop the WSL
branch from detectRemoteDisplay so WSLg keeps hardware acceleration;
detection now covers only genuinely-remote displays (SSH X11 forwarding,
VNC, RDP). The HERMES_DESKTOP_DISABLE_GPU override still works for anyone
who does hit it.
Users on remote/forwarded displays (SSH X11 forwarding, VNC, RDP, WSLg)
reported the window flickering during scroll/streaming; nobody on native
Windows/macOS ever saw it.
Root cause: the app shipped with Chromium's default GPU hardware
acceleration and no remote-display handling. Over a remote connection the
GPU compositor can't present accelerated layers cleanly across the wire,
so the surface flashes on repaint. Local sessions composite on the GPU
and never hit it.
Detect a remote display before app `ready` (detectRemoteDisplay in
bootstrap-platform.cjs) and fall back to software rendering via
app.disableHardwareAcceleration() + --disable-gpu-compositing. Software
compositing is rock-steady over the wire and the CPU cost is negligible
next to the connection's latency. HERMES_DESKTOP_DISABLE_GPU overrides
detection both ways for VNC/screen-sharing setups we can't sniff or
remote hosts that do have working acceleration.
The send path created the optimistic sidebar row with a null preview, so
a new chat read "Untitled session" until its turn persisted and auto-title
ran. With concurrent new chats now preserved across refreshes, several
"Untitled session" rows could show at once.
Seed the optimistic preview with the user's first message (the branch path
already does this) so each in-flight row is labeled immediately. The
server's own preview/title supersedes it once the turn persists.
Creating several sessions in a row (Ctrl-N, type, send, repeat) and
waiting for one to finish made the other still-running chats disappear
from the sidebar.
Root cause: a new session's first user message isn't flushed to the
SessionDB until its turn is persisted, so the row's message_count stays
0 mid-response. `refreshSessions()` lists with min_messages=1 and then
hard-replaces $sessions. Because every message.complete triggers a
refresh, the moment one session finished, the others (still at
message_count 0) were filtered out of the server page and dropped from
the list.
Fix: merge instead of replace. `mergeWorkingSessions()` preserves any
session that is still in $workingSessionIds but absent from the server
page, so concurrent new chats stay visible until their own turn persists.
Optimistic deletes/archives already remove the row from the previous
list, so a removed session can't be resurrected by the merge.
Replace Electron's built-in zoomIn/zoomOut/resetZoom menu roles with
custom implementations that use a 0.1 zoom-level step instead of
Chromium's default 0.2. This makes Ctrl/Cmd + +/-0 zoom feel more
granular and less jumpy.
Also adds installZoomShortcuts() which intercepts the keyboard shortcuts
via before-input-event. This is necessary on Linux/Windows where the
application menu is set to null, so Chromium's default handler would
otherwise apply the full 0.2 step.
Pin user bubbles 0.75rem below the scroll top via a single token instead of
flush top-0, so the sticky header doesn't sit hard against the thread edge.
Long user prompts stick to the top of the thread while the response streams
beneath them, so a multi-line prompt could eat most of the viewport. Clamp the
read-only human bubble's text to ~2 lines with a soft bottom fade; the clamp
lifts on hover or keyboard focus, and clicking the bubble still opens the edit
composer (which shows the full text). Short messages are untouched — no clamp,
no fade.
Overflow is measured on an unclamped inner wrapper so the ResizeObserver only
fires on real content/width changes, not every frame while the outer
max-height animates open; the measured height feeds --human-msg-full so
expand/collapse animate to the true height instead of overshooting the cap.
The thread renders virtualized turns in natural document flow with padding
spacers, and @tanstack/react-virtual already adjusts scrollTop itself when an
off-screen turn is measured and its real height differs from the 220px
estimate. With the browser default `overflow-anchor: auto`, native scroll
anchoring corrects that SAME size delta too, so the two double-correct and the
view lurches — most visibly with Windows mouse wheels, whose coarse notches
mount/measure several under-estimated turns per tick (Mac trackpads scroll
~1-3px/frame, keeping it sub-perceptual).
Set `overflow-anchor: none` on the thread viewport so only the virtualizer
compensates. Also adds `diag-scroll-reset.mjs`, a CDP wheel-up repro that A/B
tests the anchor behavior at runtime to confirm the fix.
The model row is a Radix sub-trigger (no onSelect), so switching was
pointer-only. Wire Enter/Space alongside onClick so keyboard users can switch
models too.
Address Copilot review: document the `adopted` flag and nullable `pinnedCommit`
in the marker schema comment, and default `done(note = {})` so the dock-pinned
marker write is unambiguous (object spread of undefined was already a no-op, but
explicit is clearer).
selectModel snapshots the prior model/provider and restores the store +
query cache when the backend switch fails, so the UI never shows a model the
backend didn't actually select.