Commit graph

69 commits

Author SHA1 Message Date
Brooklyn Nicholson
7ea37cd082 fix(desktop): stop validating provider keys in launch setup
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.
2026-06-03 18:39:00 -05:00
brooklyn!
1927ff217e
Merge pull request #38517 from NousResearch/bb/desktop-yolo-statusbar-toggle
feat(desktop): YOLO toggle in the status bar (per-session, TUI parity)
2026-06-03 23:33:09 +00:00
Teknium
5c0a1fec0c
fix(desktop): surface skill & quick-command slash commands in the palette (#38531)
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.
2026-06-03 16:24:06 -07:00
stremtec
0caa23788f
fix(desktop): prevent IME Enter from splitting messages and viewport resize from disarming scroll anchor (#38333)
* 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
2026-06-03 20:14:52 +00:00
Brooklyn Nicholson
93228d5299 fix(desktop): persist pins, reconnect after sleep, dedupe session search
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.
2026-06-03 12:39:31 -05:00
brooklyn!
84710995ef
Merge pull request #38312 from NousResearch/bb/installer-stderr-log-label
fix(installer): stop mislabeling stdout-style progress as stderr
2026-06-03 12:17:35 -05:00
brooklyn!
9632609447
Merge pull request #38296 from NousResearch/bb/fix-dmg-update-relaunch
fix(desktop): self-update rebuilds and relaunches cleanly on macOS
2026-06-03 12:06:30 -05:00
Brooklyn Nicholson
810e5864db fix(installer): stop mislabeling stdout-style progress as stderr
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.
2026-06-03 10:38:34 -05:00
Brooklyn Nicholson
c711146ad4 fix(desktop): dedupe clipboard image paste
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.
2026-06-03 10:27:47 -05:00
Brooklyn Nicholson
a1cda2410b fix(desktop): self-update rebuilds and relaunches cleanly on macOS
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.
2026-06-03 10:19:44 -05:00
Brooklyn Nicholson
1dca7c6207 fix(install): require Node >=20.19/22.12 for the desktop build
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).
2026-06-03 09:19:04 -05:00
Fermin Quant
c930a49ce9
fix(desktop): honor upward wheel scroll in long threads 2026-06-03 05:54:49 -07:00
luyao618
3aa24e2619
fix(desktop): stop chat scroll backward-jump from content-growth interim scrolls (#37997)
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.
2026-06-03 05:54:45 -07:00
Vladyslav Kovalchuk
f45d7dee7d fix(desktop): add @testing-library/dom as explicit dev dependency
@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").
2026-06-03 05:50:36 -07:00
teknium1
5fca754ee3 fix(desktop): pass live backend PID to in-app update so its own dashboard is spared
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.
2026-06-03 04:59:49 -07:00
kshitij
f019a9c491
Merge pull request #37975 from kshitijk4poor/fix/desktop-session-view-bleed
fix(desktop): stop background session messages bleeding into the active transcript
2026-06-03 01:03:50 -07:00
kshitijk4poor
49f1b9e4b4 fix(desktop): stop Esc reopening the slash/@ menu; harden keyup guard
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.
2026-06-03 13:15:08 +05:30
kshitijk4poor
c77c470d27 test(desktop): real-DOM regression for slash/@ menu keyboard nav
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.
2026-06-03 12:46:14 +05:30
kshitijk4poor
28f1590b7a fix(desktop): stop background session messages bleeding into the active transcript
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.
2026-06-03 12:09:18 +05:30
kshitijk4poor
a23728dfcc fix(desktop): make Stop button actually interrupt when a turn is queued
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.
2026-06-03 11:46:02 +05:30
kshitij
9b43ab8de5
Merge pull request #37937 from kshitijk4poor/fix/desktop-slash-menu-keyup-nav
fix(desktop): keep slash/@ completion menu navigable and Esc-dismissable
2026-06-02 22:54:05 -07:00
kshitijk4poor
188e52db91 fix(desktop): keep slash/@ completion menu navigable and Esc-dismissable
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.
2026-06-03 11:19:07 +05:30
Brooklyn Nicholson
d0ea4caf7f fix(desktop): don't treat WSLg as a remote display
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.
2026-06-03 00:42:05 -05:00
Brooklyn Nicholson
6a2909fe5a fix(desktop): disable GPU acceleration on remote displays to stop flicker
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.
2026-06-03 00:36:59 -05:00
Brooklyn Nicholson
e0a999aa8a fix(desktop): label in-flight new chats with the first message
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.
2026-06-03 00:25:19 -05:00
Brooklyn Nicholson
55a76ec669 fix(desktop): keep in-flight new chats from vanishing on refresh
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.
2026-06-03 00:21:05 -05:00
ethernet
e618cbee44 feat(desktop): custom zoom shortcuts at half default step
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.
2026-06-03 01:07:44 -04:00
Brooklyn Nicholson
cbc1d901ba chore: uptick 2026-06-02 23:44:51 -05:00
Brooklyn Nicholson
84eb5f1f89 fix(desktop): restore sticky human clamp transition at 0.75s 2026-06-02 23:44:06 -05:00
Brooklyn Nicholson
e5472da584 fix(desktop): drop sticky human clamp max-height transition 2026-06-02 23:43:52 -05:00
Brooklyn Nicholson
3ab783a7bb chore: uptick 2026-06-02 23:43:25 -05:00
Brooklyn Nicholson
06aa140fa1 fix(desktop): inset sticky human messages with --sticky-human-top
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.
2026-06-02 23:42:38 -05:00
Brooklyn Nicholson
9bdf01852a feat(desktop): clamp sticky human messages to ~2 lines until hover/focus
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.
2026-06-02 23:29:05 -05:00
Brooklyn Nicholson
e67ab2e042 fix(desktop): stop chat scroll jumping by disabling native scroll anchoring
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.
2026-06-02 23:08:01 -05:00
brooklyn!
7d0246ab57
Merge pull request #37745 from xxxigm/fix/macos-mic-entitlement-inherit
fix(desktop): inherit microphone entitlement for macOS helpers (#37718)
2026-06-02 20:43:05 -05:00
brooklyn!
54343bcade
Merge pull request #37738 from NousResearch/bb/statusbar-model-menu
feat(desktop): inline model picker in the status bar
2026-06-02 20:00:39 -05:00
Brooklyn Nicholson
b6945ce772 fix(desktop): switch model on keyboard activation of picker rows
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.
2026-06-02 19:50:55 -05:00
brooklyn!
591c329f15
Merge pull request #37739 from NousResearch/bb/desktop-macos-install-forward
fix(desktop): adopt existing macOS install + auto-place app
2026-06-02 19:49:05 -05:00
Brooklyn Nicholson
afec339e96 docs(desktop): sync marker schema comment + default dock note arg
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).
2026-06-02 19:42:59 -05:00
Brooklyn Nicholson
d704df2d6e fix(desktop): roll back optimistic model switch on failure
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.
2026-06-02 19:40:42 -05:00
xxxigm
21e172b94a fix(desktop): inherit microphone entitlement for macOS helpers
Add com.apple.security.device.audio-input to entitlements.mac.inherit.plist.
Under hardenedRuntime the Electron Helper/Setup processes inherit this file,
and the missing entitlement made macOS TCC deny the microphone with no prompt,
breaking voice chat.

Fixes #37718
2026-06-03 07:32:00 +07:00
Brooklyn Nicholson
1daecfa4b0 fix(desktop): write Dock tile as a file-reference URL
The Dock stores persistent-apps as type-15 file:// URLs; the type-0/raw-path
tile we wrote was silently dropped on the next Dock restart (so the pin never
took, yet we'd stamped the marker and never retried). Use pathToFileURL + type
15 and flush prefs through cfprefsd before `killall Dock`. Verified end-to-end
on a packaged build: move -> adopt -> Dock tile lands as
file:///Applications/Hermes.app/.
2026-06-02 19:30:06 -05:00
ethernet
a51a7b9b92 fix(node/nix): consolidate workspace lockfile + update all consumers
Consolidate per-package package-lock.json files into a single root-level
workspace lockfile.  Update all consumers:

- Nix: shared src/npmDeps/npmDepsHash in lib.nix; devshell hook stamps
  package.json paths then runs npm ci from root; individual .nix files
  use mkNpmPassthru attrs instead of per-package fetchNpmDeps.
- Python CLI: new _workspace_root() helper so _tui_need_npm_install,
  _make_tui_argv, _build_web_ui resolve lockfile/node_modules from the
  workspace root.
- Desktop: replace --force-build/mtime heuristic with content-hash build
  stamp (_compute_desktop_content_hash via pathspec).  Remove --force-build
  flag.
- Dockerfile: single root npm install; no per-directory lockfile copies.
- CI: nix-lockfile-fix and osv-scanner reference root package-lock.json;
  apps/dashboard → apps/desktop.
- Tests: new test_tui_npm_install.py; desktop stamp tests in
  test_gui_command.py; updated assertions in test_cmd_update.py,
  test_web_ui_build.py, test_dockerfile_pid1_reaping.py.
- Docs: remove --force-build from desktop flag table.

Deleted: apps/desktop/package-lock.json, ui-tui/package-lock.json,
ui-tui/packages/hermes-ink/package-lock.json, web/package-lock.json.
2026-06-02 20:28:18 -04:00
Brooklyn Nicholson
115671ae6b fix(desktop): address Copilot review on model picker
- selectModel reports success; edits bail (and roll back) instead of landing
  on the previously active model when a switch fails
- Fast toggle stays available to turn off a carried-over speed param even when
  the new model has no native fast mechanism
- active row's "Fast" label derives from the same fastControl as the submenu
  toggle, so it's consistent and handles standalone `-fast` model ids
2026-06-02 19:28:11 -05:00
Brooklyn Nicholson
7466182179 fix(desktop): adopt existing macOS install + auto-place app
First-launch "already installed?" hinged solely on a marker that only the
desktop's own bootstrap writes, so a runtime from `install.sh --include-desktop`
(or a DMG launch over a prior CLI install) was runnable yet markerless and got
the WHOLE installer re-run on top of it. Detect a runnable ACTIVE_HERMES_ROOT
(valid source + venv), adopt it (stamp the marker, recording HEAD), and forward
straight to the app. Repair keeps forcing a real re-bootstrap.

Also: on first packaged macOS launch relocate the bundle into /Applications
(Electron relaunches from there) and pin the canonical copy to the Dock once,
so users stop re-opening the installer from Downloads/the DMG.
2026-06-02 19:11:05 -05:00
Brooklyn Nicholson
ea4fe15631 feat(desktop): inline model picker in the status bar
Replace the status-bar model chip's modal with a Cursor-style dropdown:
- providers grouped by name in a stable order (no recency reshuffle on select)
- per-model hover-Edit submenu for reasoning effort + fast, gated by per-model
  capabilities now surfaced in the model.options payload
- unified Fast toggle: flips the speed=fast param where supported, else swaps
  to the model's `-fast` variant (base and variant collapse into one row)
- localStorage-backed "Edit Models" dialog to choose which models appear

Adds reusable dropdown primitives (DropdownMenuSearch, shared row/label
tokens, portaled + collision-aware submenus) and reads session state from
nanostores rather than prop-drilling, so editing options doesn't rebuild and
close the menu.
2026-06-02 19:09:41 -05:00
Brooklyn Nicholson
d963ad56c1 fix(desktop): address second Copilot pass on xAI loopback flow
- onboarding: openSignInUrl now falls back to window.open when the desktop
  bridge's openExternal throws/rejects (OS handler missing, user denied),
  not just when the bridge is absent
- web_server: cancelling a loopback session shuts down the 127.0.0.1
  callback server + joins its thread immediately, freeing the port instead
  of holding it until the wait times out (+ regression test)
- web_server: document the new "loopback" flow in the /api/providers/oauth
  enum, the poll-endpoint docstring, and the Phase 2 flow comment block
2026-06-02 18:14:00 -05:00
Brooklyn Nicholson
3be9fb7317 fix(desktop): address Copilot review on xAI loopback flow
- web_server: join the callback-server thread in the start error path so a
  failed discovery/URL build doesn't leave a daemon thread running
- web_server: loopback worker now bails if the session was cancelled while
  waiting for the callback or exchanging the code, instead of persisting
  tokens the user no longer wants (+ regression test)
- onboarding: fall back to window.open when the desktop bridge's
  openExternal is unavailable, so the flow never silently stalls
2026-06-02 17:55:22 -05:00
Brooklyn Nicholson
dd5e97bd7f feat(desktop): make xAI Grok a first-class OAuth provider in the launcher
xAI Grok was only reachable via the "I have an API key" form. xAI's
OAuth (SuperGrok / Premium+) flow already exists in the backend
(`hermes auth add xai-oauth`) but was never surfaced in the desktop
onboarding launcher.

Add a loopback PKCE flow: the local backend binds the 127.0.0.1
callback listener, the client opens the browser, and the redirect lands
back automatically — no code to copy/paste. Reuses the existing xAI
OAuth helpers (discovery, callback server, token exchange, persist)
rather than duplicating them.

- web_server: catalog entry (flow: loopback) + status dispatch +
  _start_xai_loopback_flow + background worker + route branch
- desktop: 'loopback' flow type, awaiting_browser status, xAI Grok card
  (PROVIDER_DISPLAY / FLOW_SUBTITLES / FlowPanel waiting render)
- tests: catalog listing, start authorize-url, worker persist, state
  mismatch rejection
2026-06-02 17:34:00 -05:00
Austin Pickett
ac76bbe21f
fix(desktop): triage batch of GUI quality-of-life fixes (#37536)
* fix(desktop): triage 24 GUI quality-of-life fixes across sidebar, composer, tool cards, messaging, and platform plumbing

A grab-bag of high-leverage UX fixes plus a few backend touches that the
GUI needs to behave correctly on Windows.

Sidebar / sessions
- Decrement $sessionsTotal on delete + archive so "Load N more" stops
  claiming removed rows are still on the server.
- Hide the "Group by workspace" toggle when no unpinned sessions exist.
- Accept Cmd/Ctrl+N as a "new session" accelerator (in addition to bare
  Shift+N), and render the kbd hint per-platform.
- Switch the statusbar to overflow-x-clip so untitled sessions don't
  paint a horizontal scrollbar at the bottom of the window.

Messaging + Cron
- Add [-webkit-app-region: no-drag] to the page-search input so clicks
  reach the field instead of routing to the OS window-drag handler.
- Replace single-letter PlatformAvatar with brand glyphs from
  @icons-pack/react-simple-icons (telegram, discord, matrix, signal,
  whatsapp, mattermost, wechat, qq, ...). Letter monogram fallback for
  Slack / Dingtalk / Feishu / WeCom (removed from Simple Icons at brand
  owner request).
- Drop the duplicate "Create first cron" button in the empty state.

Composer
- Dedupe pasted images by (name, size, lastModified, type) instead of
  Blob identity; Chromium hands us the same screenshot via both
  clipboard.items and clipboard.files with fresh File instances.
- Enable spellcheck on the contentEditable, configure Chromium's
  spellchecker with the system locale on whenReady, and add
  replaceMisspelling + "Add to dictionary" entries to the context menu.
- Render user messages through a minimal markdown pipeline (inline
  backtick code + fenced ``` blocks) while keeping @file:/@image:
  directive chips intact.
- max-h-[60vh] overflow-y-auto + collisionPadding on the prompt-snippet
  submenu.
- Bake cursor-pointer into the <Button> primitive (with
  disabled:cursor-default) and into titlebarButtonClass.

Dialogs + tabs + version
- Default DialogContent now has max-h-[85vh] overflow-y-auto so long
  bodies scroll instead of falling off-screen.
- Right-rail preview tabs close on middle-click (button === 1), with an
  onMouseDown swallow to suppress Chromium autoscroll.
- New refreshDesktopVersion() helper called from About mount, after
  every update check, and on throttled window focus so About reflects
  the just-installed binary.

Keys + Artifacts + Terminal
- Drop the global "Show advanced" toggle in KeysSettings. Provider
  groups now default-expand when they have any key set.
- Extend openExternalUrl to handle file:// via shell.openPath, with
  showItemInFolder fallback when the OS can't open the file.
- New lib/ansi.ts SGR parser + <AnsiText> component, applied to
  terminal/execute_code tool output.
- ToolView gained stdout / stderr / rendersAnsi; tool-fallback renders
  the two streams as separate labeled blocks with stderr in a neutral
  tone (not destructive — many CLIs log info on stderr).
- Drop 'stderr' from ERROR_MSG_KEYS in tool-result-summary.

Paths + platform
- resolveHermesCwd skips process.cwd() when packaged and prefers a
  user-configurable default project directory.
- New hermes:setting:defaultProjectDir:{get,set,pick} IPC handlers +
  preload bridge + global.d.ts typing + a "Default project directory"
  row in Sessions settings.
- FileOperations.delete_path(path, recursive=True) on the abstract
  base; ShellFileOperations.delete_file rewritten to run a cross-
  platform python3 -c snippet so deletes work on Windows shells (which
  have no rm/rm -rf). Fallback to `python` when `python3` isn't on PATH.
- README troubleshooting block split into macOS/Linux + Windows
  PowerShell recipes.
- Tightened renderer favicon links in index.html + added color-scheme
  and theme-color meta.

Backend lifecycle (renderer-side mitigation)
- New noteSessionActivity() heartbeat + session.ts watchdog: an
  8-minute silence on the stream auto-clears stuck $workingSessionIds
  entries so "Session Busy" never gets permanently wedged. Wired into
  useSessionStateCache so every state update refreshes the timer.

i18n spike
- docs/desktop-i18n-rfc.md scoping a future language-switcher PR
  (recommends react-intl, audits IME/RTL/CJK in the composer +
  chat bubbles, 4-PR rollout plan, ~3-4 eng-weeks for the first
  non-English locale).

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(desktop): replace native OS scrollbar in portaled dropdown menus

Radix's DropdownMenuPrimitive.Portal renders content under document.body,
outside the `.scrollbar-dt` scope on #root. Whenever a menu's max-height
clipped its content (even by a pixel — common for the composer "+" menu
that opens upward near the bottom of the window), the user saw the OS's
chunky native scrollbar painted across the whole menu.

Bake a thin, slot-styled scrollbar onto DropdownMenuContent and
DropdownMenuSubContent via [scrollbar-width:thin] + WebKit pseudo-element
arbitrary variants. The submenu also gets a max-h tied to
--radix-dropdown-menu-content-available-height so long snippet lists scroll
cleanly instead of running off the bottom of the viewport. Drop the now-
redundant max-h-[60vh] override on the prompt-snippet submenu.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(desktop): unbork dropdown menu — submenu opens, parent isn't a circle

Two regressions from the previous dropdown-scrollbar fix:

- The parent menu rendered as a rounded oval. Long Tailwind v4 arbitrary-
  variant strings like [&::-webkit-scrollbar-thumb]:rounded-full inside a
  cn() call were being mis-resolved so the `rounded-full` leaked onto the
  menu container itself. Replaced the whole tower of arbitrary variants
  with a real `.dt-portal-scrollbar` class in styles.css that mirrors what
  `.scrollbar-dt` already does for #root descendants. Plain CSS, no Tailwind
  parser ambiguity.
- The Prompt snippets submenu didn't open. Radix publishes
  --radix-dropdown-menu-content-available-height on Content but NOT on
  SubContent, so the `max-h` bound to that variable computed to 0 and the
  submenu collapsed to zero height. Switched SubContent to a fixed
  max-h-80 (≈20rem) which is plenty for a snippet list and never collapses.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(desktop): promote prompt snippets from Radix submenu to a real Dialog

The submenu refused to open when the parent dropdown was anchored at the
bottom of the window (composer "+" button) — Radix's collision detection +
SubContent positioning was fighting us. Rather than keep tuning side /
sideOffset / collisionPadding / max-h until something stuck, replace the
DropdownMenuSub with a clicked DropdownMenuItem that opens a proper
Dialog.

Side benefits over the submenu:
- Each snippet gets a description line, so a glance is enough to pick one.
- Focus management is handled by Dialog automatically.
- Easy to grow (search, custom user snippets, categories) without
  another round of Radix positioning bugs.

Also extract types/interfaces to the bottom of the file per workspace
convention.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(desktop): move cron 'New cron' button off the top bar into the body

Reverses the previous direction on cron empty-state dedup. The body
button is more discoverable for first-time users (it's anchored next to
the "No scheduled jobs yet" copy that explains the feature) and frees
the top bar from a global CTA that wasn't pulling its weight.

- Empty (zero jobs): EmptyState renders the "Create first cron" button
  again, like the original design.
- Empty (search filtered out all jobs): no button, just "Try a broader
  search query" copy.
- Has jobs: small inline header above the list shows `N/M active` plus
  a single "New cron" button (right-aligned). The rows themselves
  already cover edit/pause/trigger/delete, so this is the only "create"
  affordance.

Also drop the dead `<div className="hidden">…</div>` enabledCount line
the previous patch left behind; the count is now visible in the new
header instead of hidden.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(desktop): address Copilot review on PR 37536

- sessions-settings: guard the WHOLE bridge call rather than chaining
  `?.settings.foo().then(...)` — the latter throws when
  `window.hermesDesktop` is undefined (non-Electron / Vitest contexts)
  because the chain short-circuits to `undefined.then(...)`.
- file_operations: drop `Path.unlink(missing_ok=True)` (Py>=3.8) so the
  generated delete snippet still works on remote backends running
  Python 3.7. The existing FileNotFoundError handler covers the same
  case and works back to 3.4.
- ansi.test.ts: add focused Vitest coverage for the SGR parser
  (basic/bright colors, bold toggles, default-fg reset, coalescing,
  256-color / truecolor arg consumption, non-SGR CSI drop, empty SGR
  full-reset) so future refactors can't silently regress terminal
  rendering.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(desktop/updates): swallow refreshDesktopVersion bridge errors

`refreshDesktopVersion()` is called best-effort with `void` from
`checkUpdates()`, `startUpdatePoller()`, and the window focus handler.
If the IPC bridge rejects (main process shutting down during reload,
bridge not yet ready on first paint), the rejection surfaces as an
unhandled promise rejection in the renderer. Wrap the call in try/catch
and return null on failure so callers can keep the existing
fire-and-forget pattern safely.

Co-authored-by: Cursor <cursoragent@cursor.com>

* chore(desktop): drop work duplicated by other in-flight PRs

- composer/text-utils.ts: revert paste-image dedupe — PR #37596
  ships the same fix with a cleaner content-key approach and a
  Vitest file (text-utils.test.ts). Letting that PR own the change.
- docs/desktop-i18n-rfc.md: delete the i18n scoping RFC — PR #37568
  has already shipped a working i18n surface (homegrown nanostores
  `t()` helper over en/zh dictionaries), so the RFC's framework
  recommendation (`react-intl`) is now obsolete and would just
  contradict the implementation that's actually landing.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-02 16:33:22 -04:00