Commit Graph
65 Commits
Author SHA1 Message Date
Brooklyn Nicholson 2b762c5364 style(desktop): de-box appearance options into flat rows + bare theme swatches
Color Mode and Tool Call Display become flat radio-style rows (no tile
border/fill, no inner icon box, no filled check badge — just a subtle active
bg and a check). Theme drops its outer card wrapper so only the preview
swatch shows, with a primary ring marking the active palette.
2026-06-03 22:06:23 -05:00
Brooklyn Nicholson 75adf7d603 style(desktop): flatten appearance settings — drop card-in-card sections
Remove the outer card chrome (border/bg/shadow/rounded) wrapping each
appearance section so they're flat headings + option grids instead of
boxes nested inside boxes, matching the other settings pages.
2026-06-03 22:05:06 -05:00
Brooklyn Nicholson 0776d1b19c style(desktop): unify Input/Textarea/SelectTrigger on shared controlVariants
Mirror the buttonVariants exercise for non-composer form controls: add a
single controlVariants source of truth (2.5px radius, 12px text,
padding-driven sizing, chrome via desktop-input-chrome) and consume it from
Input, Textarea, and SelectTrigger. Drop per-call radius/height/font
overrides that fought the shared look.
2026-06-03 22:03:46 -05:00
Brooklyn Nicholson d6e2c940e9 style(desktop): nudge button scale up + 2.5px radius on non-icon buttons
Bump default/sm vertical padding a step (the 12px pass read too small) and give
non-icon buttons a subtle 2.5px radius instead of square corners. Icon buttons
keep their 4px.
2026-06-03 22:00:39 -05:00
Brooklyn Nicholson fb0250ef63 feat(desktop): add boxless text button variant; use for aux-model actions
New reusable `text` variant renders a button as inline label text (no
bg/border, muted -> foreground, underline-on-hover affordance). Emphasize the
actionable word by adding `font-semibold`/`underline` at the call site. Applied
to the auxiliary-model "Set to main" (plain), "Change" and "Reset all to main"
(bold + underlined) actions, replacing the boxed ghost/outline buttons.
2026-06-03 21:59:44 -05:00
Brooklyn Nicholson 1e1ab31ad6 style(desktop): 12px button text, drop sparkle decoration + redundant settings titles
- Button base font down to 12px (text-xs) for the dense desktop scale.
- Remove the decorative Sparkles glyph from the model "Apply" button (keep the
  spinner while applying).
- Drop the page-level section titles that just restate the left nav ("Main
  model", "Appearance", "MCP servers") — the sidebar already labels the pane.
  Sub-section headings (Auxiliary models, LLM providers, etc.) stay.
2026-06-03 21:58:47 -05:00
Brooklyn Nicholson 8c0f15478d style(desktop): shrink button scale, flush overlay sidebar, variant-ize stray buttons
- Buttons: smaller default font (14px -> 13px) and tighter padding-driven sizes
  across every variant; the chunky shadcn scale read as oversized in a dense
  desktop UI.
- Overlay split layout (settings / command center): the shared OverlayView top
  padding left the card surface showing as a gap above the sidebar. Move the
  titlebar clearance into each column so the sidebar background runs flush to
  the card's top edge.
- Consolidate buttons that hardcoded size/radius/font onto the proper size
  variants (tooltip-icon-button, overlay close, cron IconAction, SidebarTrigger,
  gateway system button, session-row actions radius, title chip radius, release
  notes link) so styling flows from variant props, not per-call overrides.
  Composer and the inline approval strip are intentionally left as-is.
2026-06-03 21:56:35 -05:00
Brooklyn Nicholson 712bf4d8e4 style(desktop): padding-driven, square non-icon buttons
Default button sizing was vanilla-shadcn chunky (fixed h-9, 16px padding) and
inconsistent with the icon-button radius pass. Size text variants by
padding + line-height instead of fixed heights so they stay snug and scale
with content, and drop the radius on non-icon buttons (icon buttons keep the
shared 4px). Move the update-overlay CTAs off a hardcoded h-10 onto the
padding-based lg variant. Composer and the inline approval strip are untouched.
2026-06-03 21:50:03 -05:00
Brooklyn Nicholson 35a750eedd feat(desktop): persistent needs-input indicator + icon button consolidation
Replace the background-clarify toast (expired on alt-tab, easy to miss) with a
persistent, glowing amber "needs input" dot on the session's sidebar row,
driven off a new ClientSessionState.needsInput flag mirrored into a
$attentionSessionIds store. The flag is set on clarify.request and cleared the
moment the turn resumes (tool.complete) or ends.

Also: redesign the clarify tool UI (borderless choices, pseudo-radio dots,
right-aligned checkmark, arc border, tighter padding), make Button the single
source of icon-button styling (4px radius, new icon-titlebar variant, titlebar
buttons rendered polymorphically via asChild, Codicons throughout), put the
file-tree refresh action first, and .trim() pasted composer text.
2026-06-03 21:44:30 -05:00
Brooklyn Nicholson 72f556dfc4 Merge remote-tracking branch 'origin/main' into bb/desktop-background-clarify 2026-06-03 21:07:35 -05:00
Brooklyn Nicholson 58eb473baa fix(desktop): surface background-session clarify prompts instead of hanging
clarify.request is a one-shot blocking event: the gateway turn blocks on
clarify.respond. The desktop handler dropped it for any non-focused session
(`if (!isActiveEvent) return`) and stored at most one request in a single
global atom, so a background session that asked a clarifying question hung
forever and re-focusing it could never recover (the event was already gone).

- store/clarify.ts: key pending requests by runtime session id; expose the
  active session's request via a focus-scoped computed view (ClarifyTool is
  unchanged). clearClarifyRequest takes an optional session id for targeted
  clears, with a request-id fallback.
- use-message-stream.ts: park every session's clarify (drop the isActiveEvent
  early return); toast when one lands for a background session since the row
  otherwise just keeps spinning like normal work.
- clarify-tool.tsx: clear by session id so answering one chat can't wipe
  another's pending request.
- store/clarify.test.ts: concurrent independence, focus-scoped view,
  targeted/stale/fallback clears.
2026-06-03 21:07:33 -05:00
TekniumandBrooklyn Nicholson f66a929a6b fix(desktop): render approval/sudo/secret prompts so tools stop silently timing out (#38578)
* 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>
2026-06-04 01:53:51 +00:00
xxxigm 5a22cd427d fix(desktop): configure local/custom endpoint without an API key or UI changes
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.
2026-06-03 17:48:55 -07:00
Teknium d50741af90 fix(onboarding): clarify Anthropic API vs OAuth provider entries and reorder (#38577)
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.
2026-06-03 17:46:04 -07:00
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 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
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
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
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 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
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! 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 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
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 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 PickettandCursor 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
brooklyn! 31c40c72c0 fix(desktop): stabilize project folder sessions (#37586)
* fix(desktop): stabilize project folder sessions

Keep desktop folder selection aligned with new sessions and scope TUI gateway cwd through session context so prompts and tools resolve against the selected workspace.

* fix(desktop): address review feedback on folder sessions

Snapshot sessions before iterating to avoid concurrent-mutation crashes,
optional-chain the revealLogs catch, and read console-message args from
the correct Electron event/messageDetails positions.

* fix(desktop): address second review pass on folder sessions

Sync the remembered workspace key with the cwd atom (clear on empty),
only load tree children for real directory nodes, and throttle renderer
auto-reloads so a deterministic startup crash can't loop forever.

* fix(desktop): inherit parent workspace for ephemeral agent tasks

Background and preview tasks use ephemeral ids absent from the session
map, so pass the parent session cwd into the session context explicitly
instead of clearing it back to the gateway launch dir. Also correct the
set_session_vars docstring about clear_session_vars semantics.

* fix(desktop): validate preview cwd before pinning session context

A non-empty but non-existent client cwd would pin an unusable override
and silently fall back to the launch dir. Validate once, reuse for both
the session context and the terminal override, and fall back to the
parent session workspace when invalid.

* fix(desktop): harden preview cwd normalization and adopt normalized cwd

Guard preview cwd normalization against malformed client paths so a bad
input can't fail the whole restart, and adopt the backend's normalized
config.get cwd in the no-active-session path so the persisted workspace
stays consistent with what the agent uses.
2026-06-02 20:23:09 +00:00
Brooklyn Nicholson 0c29cfd1a6 Clarify desktop install retry guidance 2026-06-02 12:08:39 -05:00
brooklyn! 23c0578bd7 Merge pull request #37462 from NousResearch/bb/desktop-update-throttle
fix(desktop): throttle the update-available toast
2026-06-02 10:26:52 -05:00