Commit graph

63 commits

Author SHA1 Message Date
Brooklyn Nicholson
891c9a6823 fix(desktop): close eager-upload races flagged in review
Two races in the drop-time eager upload:

- Resurrected chip: the success path used addComposerAttachment, which
  re-appends when the id is gone, so a file removed mid-upload reappeared once
  the upload resolved. Add updateComposerAttachment (update-only; no-op when the
  chip was removed) and use it on both the eager success path and submit-time
  sync.
- Duplicate upload: submit-time sync didn't join an eager upload still in
  flight, so drop-then-Enter could fire file.attach twice and leave a duplicate
  under .hermes/desktop-attachments/. Track in-flight eager uploads by id and
  await the pending one before deciding to re-upload, reusing its gateway ref.

Tests: composer-store no-resurrect unit tests + a join-on-submit integration
test asserting a single file.attach.

Addresses @helix4u review on #43109.
2026-06-09 18:21:10 -05:00
Brooklyn Nicholson
4906dcfc25 fix(desktop): stage dropped files into the remote session workspace
Finder/OS drops became `@file:/Users/...` refs that only resolve when the
gateway shares the local disk, so on a remote gateway non-image files
(PDF/CSV/Markdown/...) never reached the agent. Route OS drops through the
file.attach / image.attach_bytes upload pipeline — in-app project-tree and
gutter drags stay inline workspace-relative refs — across every drop surface:
the conversation area, the composer form, the contenteditable input, and the
message-edit composer (which still reproduced the bug).

Also:
- upload dropped files eagerly when a session exists, so the card shows a
  spinner instead of stalling the send (images stay submit-time to avoid
  racing their thumbnail write);
- round the attachment card and drop the monospace detail;
- render image previews from the bytes we already hold, so a pasted/dropped
  screenshot shows its thumbnail and previews even when its only on-disk copy
  is a transient path (the data URL is not persisted to localStorage).

Supersedes #38615, #41203.

Co-authored-by: LeonSGP <154585401+LeonSGP43@users.noreply.github.com>
Co-authored-by: Teknium <127238744+teknium1@users.noreply.github.com>
2026-06-09 16:50:08 -05:00
brooklyn!
d046169646
fix(desktop): local-only recents, per-platform sidebar sections, and Ctrl+N regressions (#42537)
* fix(desktop): keep chat recents focused and reset hotkey target

Exclude messaging platform threads from chat recents pagination so Load More returns chat sessions, and clear stale quick-create profile state before Ctrl+N starts a new session.

* fix(desktop): surface new sessions in sidebar + unstick new-chat Thinking

Two renderer regressions in the desktop chat app:

- Sidebar ordering: orderByIds/reconcileOrderIds appended ids missing from
  the persisted order to the BOTTOM. Callers pass recency-sorted lists
  (newest first), so a brand-new Ctrl+N session sank below the saved order
  and read as "my latest session never showed up". Prepend fresh ids so new
  activity surfaces at the top.

- New-chat stuck on "Thinking": terminal/attention state transitions
  (turn finished, error, or agent now waiting on user) were RAF-batched.
  Electron throttles requestAnimationFrame to ~0 while the window is
  backgrounded, occluded, or unfocused, stranding the deferred flush. Flush
  critical transitions (!busy || needsInput) synchronously; keep the busy
  heartbeat RAF-batched to avoid scroll churn.

Does not touch the messaging-source exclusion in chat recents queries.

* fix(desktop): stop excluding messaging platforms from chat recents

The "keep chat recents focused" change excluded every messaging-platform
source (telegram, discord, slack, …) from the recents query. That silently
undid the messaging-source-folder feature already on main (ede4f5a4a): the
sidebar builds those folders purely from the loaded recents page, so once the
sources were filtered out the folders never rendered — telegram and friends
vanished from the left sidebar.

Only cron stays excluded (it has its own dedicated section). Messaging
sessions belong in the sidebar and render with their platform folder/icon.
Removes the now-unused MESSAGING_SESSION_SOURCE_IDS export.

* fix(desktop): give each messaging platform its own self-managed sidebar section

Recents are local-only again: cron and every messaging platform are excluded
from the chat-recents query, so "Load more" pages through interactive local
chats instead of interleaving gateway threads that bury them.

Each messaging platform (telegram, discord, ...) is now fetched as its own
slice (refreshMessagingSessions) and rendered as a self-managed sidebar
section with its platform icon, count, and per-platform "load more" — no
source-grouping magic inside recents.

Handed-off sessions (live source becomes local after a handoff) keep their
origin-platform badge on the row via handoff_platform, so a Telegram thread
continued in the desktop still reads as Telegram.

* fix(desktop): self-heal a stranded routed session in route-resume

An intermittent create/stream race can leave selected/active session ids
null while the route stays on /:sid — the transcript then sticks empty
even though the turn completed and persisted (the "second Ctrl+N shows no
response" symptom). The pathname didn't change, so route-resume's normal
gate skipped and the view stayed stuck.

Resume whenever the routed session isn't the loaded one, gated on
freshDraftReady so the /:sid -> /new transition (which also momentarily
nulls selected/active a render before the pathname flips) is NOT treated
as stranded. selectedStoredSessionIdRef is set synchronously at resume
entry, so this can't loop, and the resume cached fast-path restores the
already-streamed messages without a refetch.

* fix(desktop): bypass smooth reveal on primary markdown stream

Render main assistant text through deferred markdown directly instead of the smooth-reveal wrapper. This isolates the wrapper to reasoning surfaces and avoids the intermittent blank-response regression after consecutive new-session flows.
2026-06-09 14:24:25 +00:00
teknium1
dbbd1d4d05 feat(desktop+gateway): remote-gateway file attachments via file.attach
@file: attachments now work when the desktop is connected to a remote
gateway. Previously a referenced file resolved to a client-disk path the
gateway couldn't see, so context_references rejected it with "path is
outside the allowed workspace" and the agent never saw the file.

Adds a file.attach RPC (sibling to the existing image.attach_bytes /
pdf.attach byte-upload pipeline): the desktop uploads the file bytes, the
gateway stages them into <workspace>/.hermes/desktop-attachments/ and
returns a workspace-relative @file: ref that resolves cleanly. Local mode
passes the path directly; a gateway-visible file outside the workspace is
copied in; an in-workspace file is referenced as-is with no copy.

Consolidates the file-sync design from #38615 (LeonSGP43) and the
host-file-staging idea from #33455 (Carry00), rebased onto the
image/PDF remote-media helpers already on main.

Co-authored-by: LeonSGP43 <cine.dreamer.one@gmail.com>
2026-06-09 00:03:49 -07:00
brooklyn!
395ed91891
fix(desktop): keep a just-finished session visible after switching away (#42285)
A brand-new session's first turn persists to the SessionDB a beat after
the gateway emits message.complete, so a refresh fired in that window gets
a listSessions(min_messages=1) page that omits the new row. sessionsToKeep()
already shields the *active* chat from this race, but a session you started
and then navigated away from is — at the next refresh — neither working,
pinned, nor active, so mergeSessionPage() evicts it. Nothing re-fetches
afterward, so it stays gone until the app restarts.

Track sessions whose turn just settled (a real working->idle transition) in
a short, auto-expiring grace window and add them to the merge keep-set. This
bridges the persist race for non-active chats without resurrecting deleted
rows (mergeSessionPage only revives rows still in the in-memory list, which
optimistic delete/archive already drop).

Repro: start a new chat, send a message, then click another session before
the reply lands — the new session vanishes from the sidebar.
2026-06-08 12:32:27 -05:00
yoniebans
74239b4942 i18n(desktop): translate backend update apply status messages
Two independent reviewers flagged that applyBackendUpdate's in-progress and
error messages were inline English while the rest of the update overlay is
i18n'd. Move them into updates.applyStatus (preparing/pulling/restarting/
notAvailable/failed/noReturn) across en, ja, zh, zh-hant + types.
2026-06-08 08:58:26 -07:00
yoniebans
b000e05b11 fix(desktop): don't claim the backend update succeeded when it never returns
The no-return error said 'Backend updated but did not come back online' — but
once the connection drops the client can't know the update's exit code, only
that it was started and the backend is unreachable. Reword to not overclaim:
the update may not have completed.
2026-06-08 08:58:26 -07:00
yoniebans
cd030f5f40 fix(desktop): close the backend update overlay on success; error on no-return
Three rough edges in the remote backend apply flow:
- On success the overlay dropped to IDLE, briefly re-rendering the pre-install
  'update available' view and then the generic 'you're all set' before settling.
  Close the overlay outright once the backend is confirmed back instead of
  bouncing through the idle view.
- If the backend never came back (a failed restart), the flow still reported
  success. waitForBackendReturn now returns whether the backend answered;
  finishBackendApply surfaces an error when it didn't.
- The up-to-date copy said 'you're running the latest version', conflating
  client and backend. Backend target now reads 'the backend is running the
  latest version' — the client's own version is a separate pill.
2026-06-08 08:58:26 -07:00
yoniebans
81647458c7 fix(desktop): recover the backend update overlay after the remote restarts
The backend Install path set stage:'restart' and stopped — in remote mode no
boot-progress events arrive to carry the overlay to done, so it sat on the
restarting spinner until a manual reload while the backend had already come
back. Poll the backend until it answers again, then clear the overlay and
refresh the backend status. Target-aware applying copy explains the remote
restart + auto-reconnect instead of the local-updater-window wording.

Also switch the apply poll sleeps from window.setTimeout to globalThis.setTimeout
so the flow is exercisable off the renderer.
2026-06-08 08:58:26 -07:00
yoniebans
47518bc913 fix(desktop): check backend updates when the connection becomes remote
The poller starts at mount, before the gateway connects, so its initial
checkBackendUpdates() ran while mode was still unset and no-op'd via the
remote-mode guard — leaving the backend button empty until the user clicked it.
Subscribe to $connection and re-check the backend when mode resolves to remote.
2026-06-08 08:58:26 -07:00
yoniebans
cfaa46fcae fix(desktop): pre-check backend updates in poller; client button first
Two follow-ups from testing the two-button bar:

- The background poller and focus handler only checked the client, so the
  backend behind-count and changelog stayed empty until the user opened the
  overlay — and the overlay's first render then hit the empty-commits fallback
  ('Improvements and fixes') instead of the real changelog. Check the backend
  alongside the client on poller start, interval, and focus so its state is
  ready before the button is clicked.
- Order the status bar client-first, backend-second.
2026-06-08 08:58:26 -07:00
yoniebans
56be1a63a3 fix(desktop): split client and backend into two distinct update buttons
The status bar merged both versions into one pill with a single click target,
so there was no way to tell which artifact an update acted on — and the apply
path was overloaded by connection mode. Separate them:

- store: independent client (checkUpdates/applyUpdates) and backend
  (checkBackendUpdates/applyBackendUpdate) flows with their own status/apply
  atoms; openUpdateOverlayFor(target) drives the overlay.
- status bar: two buttons — client vX (always) and backend vY (+N) (remote
  only), each with its own behind-count, opening the overlay for its target.
- overlay: reads the active target's atoms; install/check route per target.

Removes the version-bar merge helper (no longer merging the two versions).
2026-06-08 08:58:26 -07:00
yoniebans
64da518db4 feat(desktop): remote update overlay sourced from backend
In remote mode, checkUpdates()/applyUpdates() branch on connection.mode and
drive the existing updates overlay from the connected backend instead of the
local Electron git bridge:

- checkUpdates -> GET /api/hermes/update/check, mapped onto DesktopUpdateStatus
  (behind, commits, supported=can_apply, message). The overlay renders the
  commit list as 'what's changed' and shows guidance (not Install) when the
  backend install can't self-apply (docker/nix).
- applyUpdates -> POST /api/hermes/update (the proven command-center path),
  polling the action to completion and handling the expected mid-update
  connection drop as the restart phase.

Local mode is unchanged. Adds checkHermesUpdate() to hermes.ts and a
BackendUpdateCheckResponse type.
2026-06-08 08:58:26 -07:00
Gille
039fbb41fc
fix(desktop): show newly configured model providers (#41545) 2026-06-08 01:39:37 -07:00
D'Angelo Rodriguez
3fc67b7333 Persist desktop sidebar drag order 2026-06-07 23:44:04 -07:00
brooklyn!
d65b513f23
feat(desktop): hover-reveal collapsed sidebars as fixed overlays (#41670)
* feat(desktop): hover-reveal collapsed chat sidebar as a fixed overlay

When the sessions sidebar is collapsed, hovering the left edge now floats
it back in as a fixed overlay over the main content instead of just being
hidden. The collapsed grid track stays at 0px so the panel never reserves
space — it slides over whatever's underneath and retracts on pointer-leave.

- PaneShell: new hoverReveal prop. When a pane is collapsed + hoverReveal,
  render an edge hot-zone + a side-anchored floating panel (absolute, full
  height, honors any persisted resize width) that slides in on hover/focus.
- ChatSidebar: force the (otherwise opacity-0 when collapsed) sidebar fully
  visible + interactive while the overlay is revealed, via an
  in-data-[pane-hover-reveal=open] variant.
- desktop-controller: opt the chat-sidebar pane into hoverReveal.

* feat(desktop): lower window minWidth 900→400

Lets the window shrink to a narrow rail (e.g. for the collapsed
hover-reveal sidebar) instead of being floored at 900px.

* fix(desktop): render full sidebar content in hover-reveal overlay

The hover-reveal overlay showed only the nav rail — session rows, search,
pinned/recents were gated behind `sidebarOpen` (false while collapsed), so
they never mounted in the floated panel.

Add a $sidebarRevealed store the PaneShell overlay drives via a new
onHoverRevealChange callback, and gate ChatSidebar's content on
`sidebarOpen || sidebarRevealed` (contentVisible) instead of raw open
state. The overlay now shows the complete sidebar.

* fix(desktop): drop shadow on hover-reveal sidebar overlay

* feat(desktop): hover-reveal the file-browser sidebar too

The reveal mechanism already lives in the shared Pane primitive — the
right rail just opts in with hoverReveal. Its content renders
unconditionally, so (unlike the chat sidebar) it needs no extra
content-visibility gating.

* clean(desktop): tighten hover-reveal pane code

KISS pass — flatten the translate ternary, derive a single `revealed`,
inline the edge style, drop the redundant set-guard, and trim comments to
the house one-liner style. No behavior change.

* fix(desktop): stop hiding sidebar nav labels on narrow windows

The nav labels (New session, Skills, …) and the ⌘N hint were gated on a
viewport breakpoint (max-[46.25rem]:hidden), so shrinking the window hid
them even when the sidebar itself was wide — including in the hover-reveal
overlay. Drop the gate; the label already truncates (min-w-0 flex-1) so it
ellipsizes gracefully in a narrow rail, and contentVisible already hides it
when collapsed to the icon rail.

* feat(desktop): auto-collapse both sidebars below 600px into hover-reveal

Add a Pane `forceCollapsed` prop — collapses the track without writing to
the store (so the saved open state restores when the window widens) while
keeping hoverReveal alive (unlike `disabled`, which suppresses it).

desktop-controller watches (max-width: 600px) and force-collapses the chat
sidebar + file browser, so on a narrow window both rails get out of the way
and the hover-reveal overlay becomes the way in.

* feat(desktop): hover-intent + refined easing for sidebar reveal

- Gate the reveal on pointer velocity: the full-height edge hot-zone now
  only arms on a slow, deliberate pass (<=0.55 px/ms). Fast sweeps toward
  the titlebar/statusbar — or off the window — blow past the threshold and
  never trigger, so the wide hit area stops being a nuisance.
- Swap the slide easing to cubic-bezier(0.32,0.72,0,1) at 260ms (snappy-out,
  soft-land) for a more serious-app feel.

* fix(desktop): don't reveal sidebar during window resize

Resizing the window parks the cursor on the screen edge and fires slow
pointermoves over the hot-zone, reading as deliberate intent. Guard the
reveal on (a) e.buttons !== 0 — any button-held drag, incl. edge-resize —
and (b) a 250ms cooldown after any window resize event.

* feat(desktop): hoverIntent-style poll gate + inert contents during slide

Replace the single-sample velocity check (too eager — fired on any one slow
move, incl. resize drift) with a port of Brian Cherne's hoverIntent: poll
the pointer every 90ms and only arm once it has *settled* (moved <5px between
two consecutive polls inside the edge zone). Fly-bys, pass-throughs, and
resize drift never produce two close samples in a row, so they don't trigger.

Also keep the revealed panel's CONTENTS pointer-events-none until the slide-in
transition finishes (onTransitionEnd → settled), so you can't misclick a
session row mid-animation. Resets on retract.

* fix(desktop): no cursor/hit-test leak before reveal settles

The edge hot-zone showed cursor:pointer the instant the pointer touched it —
before the panel was armed or in view. And contents were inert but the panel
itself still hit-tested, so the cursor could flip mid-slide. Fix: hot-zone is
cursor-default (it's invisible), and the whole panel is pointer-events-none
until revealed && settled, so the cursor never changes or lands on a row
before the slide-in finishes.

* fix(desktop): geometry-driven close so revealed panel always retracts

The revealed panel relied on its own onPointerLeave to close — but a panel
that slid in under a still cursor (or whose contents were inert during the
slide) never fires enter/leave, so it got stuck open (esp. the file browser).
onTransitionEnd also bubbled from the file-tree's own row transitions,
tripping the settled flag wrongly.

Replace with a document-level pointermove watcher that closes once the cursor
leaves the panel's bounding rect + a 24px grace — independent of pointer-events
state or what the contents do. Gate interactivity on a simple slide-duration
timer (interactive) instead of the fragile transitionEnd, so the cursor still
can't flip or land on a row before the panel is in view.

* feat(desktop): make sidebar toggle shortcuts reveal when force-collapsed

mod+b / mod+j were no-ops on a narrow (force-collapsed) window — they
flipped the store but the pane ignores it. Now the toggle handlers also
dispatch PANE_TOGGLE_REVEAL_EVENT; a force-collapsed Pane listens (only while
overlayActive) and flips its hover-reveal, so the shortcut floats the rail in
(and back out) at this responsive breakpoint.

* refactor(desktop): name the 600px sidebar collapse breakpoint

Hoist the inline '(max-width: 600px)' literal into
SIDEBAR_COLLAPSE_BREAKPOINT_PX + SIDEBAR_COLLAPSE_MEDIA_QUERY in
layout-constants, so the responsive collapse point is a single named source
of truth instead of a magic string in the controller.

* tweak(desktop): sidebar auto-collapse breakpoint 600px -> 768px

768 is the standard md breakpoint and a more honest 'no room to dock' point.

* tweak(desktop): halve sidebar reveal slide duration 260ms -> 130ms

* Revert "tweak(desktop): halve sidebar reveal slide duration 260ms -> 130ms"

This reverts commit 6009a13200.

* perf(desktop): pre-mount hover-reveal contents to kill slide-in stall

The reveal mounted the (heavy, virtualized) sidebar contents in the same
frame the slide started, so the browser stalled painting the transform until
the mount finished — a ~100-200ms beat before the panel moved, very visible
on the instant keyboard toggle (hover masked it via the 90ms intent poll).

Report overlayActive (collapsed-overlay mode) rather than the live reveal
state to the mount consumer, so contents stay mounted off-screen while
collapsed and reveal is a pure transform. Visibility is still driven
separately by the data-pane-hover-reveal attr + the slide transform.

* fix(desktop): make reveal hotkey spammable

Two throttles on the reveal toggle:
- The handler fired both the reveal event AND toggleSidebarOpen() per press;
  the store write hits localStorage synchronously every keystroke + recomputes
  the grid, janking rapid presses. When collapsed, only dispatch the reveal
  event (the store toggle was a no-op anyway).
- The geometry close-watcher slammed a keyboard-opened panel shut on the first
  stray pointermove (trackpad jitter), fighting hotkey spam. Keyboard reveals
  now ignore geometry until the cursor actually enters the panel, then the
  mouse takes over.

* fix(desktop): inset reveal hot-zone past the OS window-resize gutter

The hot-zone sat flush at the window edge (left-0/right-0), overlapping the
OS resize grab strip — reaching to drag-resize naturally slows the cursor
there, which hoverIntent reads as settled and reveals before the resize drag
even starts. Inset the hot-zone 8px so the outermost edge stays a pure
resize/drag region and only an intentful move just inside it arms a reveal.

* fix(desktop): keep reveal hot-zone at edge, gate arming past resize gutter

Insetting the hot-zone made it unreachable when moving fast. Instead, anchor
the zone flush at the edge (w-4, always captures the pointer) but only ARM the
reveal when the cursor settles >=8px in from the edge — so a resize-reach that
parks on the outermost OS grab strip never triggers, while a deliberate move
into the zone still does. Keeps polling while in the gutter so moving inward
still arms.

* refactor(desktop): rebuild hover-reveal as pure CSS, delete the JS state machine

The hand-rolled pointer state machine (hoverIntent poll, refs, timers, document
pointermove geometry-close, interactive gate, resize cooldowns, keyboard-held
suppression) was fragile and side/instance-specific — hover broke on the right
rail, keyboard toggles triggered phantom animations, resize popped it open.

Replace all of it with the native primitive: CSS group-hover drives the slide
transform; a transition-delay on enter (instant on leave) is the hover-intent
gate (a fast pass-by doesn't dwell long enough to open); a thin edge trigger
inset past the OS resize grab strip arms it; and a single `forced` bool
(data-forced, toggled by the keyboard event) pins it open. Side-agnostic by
construction — group-hover doesn't care which edge or which pane.

Net: ~200 lines of imperative pointer logic → ~40 lines of declarative CSS.

* fix(desktop): don't animate hover-reveal panel across viewport on side flip

Flipping panes changed the off-screen transform from -translateX (off the
left) to +translateX (off the right). transition-transform interpolated
between them, passing through translate-x-0 (fully on-screen) mid-way — so the
hidden panel visibly slid across the window to reach its new hiding spot.
Key the panel on side so it remounts off-screen on the new edge with no
transition to play.

* clean(desktop): tighten hover-reveal markup

KISS pass on the CSS-driven reveal: reuse the existing `side` instead of a
local `left`, move the static duration/ease to inline style (drop two
single-use CSS vars + their arbitrary-value classes, keep only the
state-dependent enter-delay var), and trim comments to the house one-liner
density. No behavior change.

* fix(desktop): inset titlebar past traffic lights when sidebar is force-collapsed

The titlebar content inset (clearing the macOS traffic lights) keyed off the
stored sidebarOpen/fileBrowserOpen, but below the collapse breakpoint both
rails are force-collapsed so the left edge is uncovered while the store still
says open — content (the intro wordmark) overflowed under the lights. Gate
leftEdgePaneOpen on !narrowViewport using the shared SIDEBAR_COLLAPSE_MEDIA_QUERY.

Also rename the now-misleading reveal plumbing to match what it actually does:
onHoverRevealChange -> onOverlayActiveChange, $sidebarRevealed ->
$sidebarOverlayMounted (+ setter/consumer). It reports/stores collapsed-overlay
mode (mount gate), not live reveal state.

* feat(desktop): small --nous-shadow lift on revealed hover-reveal panels

Add a --nous-shadow token (white-based on light, black-based on dark) and apply
it to the floating sidebar panel only while revealed (group-hover / data-forced)
so it reads as lifted off the surface. No shadow on the off-screen panel.

* feat(desktop): shadow-reveal lift on revealed hover-reveal panels

Mirror the --shadow-nous layered falloff into a new --shadow-reveal token whose
drop color flips per mode (white on light, black on dark) via --shadow-reveal-raw
set in :root / :root.dark. Apply the generated shadow-reveal utility to the
floated panel only while revealed (group-hover / data-forced). Leaves the shared
--shadow-nous untouched.

* feat(desktop): use tuned reveal shadow, drop per-mode token

Replace the --shadow-reveal token machinery with Brooklyn's tuned literal
(0 -18px 18px -5px #0000003b) inline per-panel via --reveal-shadow, y-offset
sign flipped for the right side. Same color both modes. Reverts styles.css to
pristine (token removed).

* fix(desktop): use the reveal shadow verbatim, don't invert it per side

Flipping the y-offset sign for the right side inverted the shadow's direction
(cast-up -> cast-down), making it read heavier — not a mirror. The mirror axis
for a left/right panel is offset-x, which is 0 here, so both sides take the
tuned value as-is: 0 -18px 18px -5px #0000003b.

* clean(desktop): hoist reveal shadow to a named const

Move the inline reveal-shadow literal to HOVER_REVEAL_SHADOW alongside the
other HOVER_REVEAL_* tuning consts; drop the now-stale per-side comment.

* fix(desktop): truncate titlebar title before the right tool cluster

The session title used a hardcoded max-w-[52vw] that's blind to where the
right-side tools start, so it ran under them at narrow widths / with pane
tools present. Bound the title container by the same vars the titlebar drag
region uses (--titlebar-content-inset + --titlebar-tools-right +
--titlebar-tools-width) so it truncates exactly at the cluster's left edge.

* fix(desktop): responsive markdown tables — floor width + nowrap headers

The wrapper had overflow-x-auto but the table was w-full with auto layout, so
instead of scrolling it crushed columns until even header words broke mid-word
(Tim/e, Nig/ht). Add a min-w-[18rem] floor so it scrolls horizontally when the
column is narrower than readable, and whitespace-nowrap on th so headers never
break mid-word. Above the floor it still wraps cells naturally.

* fix intro
2026-06-07 22:41:21 -05:00
Brooklyn Nicholson
1238d08e0c fix(desktop): cron overlay mutations sync the sidebar instantly
The manage overlay held its own local jobs list, so deleting/creating a
job there left the sidebar's $cronJobs atom stale until the 30s poll
(delete all → section lingered). Make the overlay read and mutate the
shared atom directly (updateCronJobs), so sidebar + overlay are one
source of truth and changes show immediately.
2026-06-06 16:47:46 -05:00
Brooklyn Nicholson
f491260365 Merge remote-tracking branch 'origin/main' into bb/cron-sessions-sidebar
# Conflicts:
#	apps/desktop/src/app/cron/index.tsx
2026-06-06 16:34:23 -05:00
brooklyn!
f033b7dbfb
feat(desktop): unified overlay design system, BrandMark & onboarding redesign (#40708)
* fix(desktop): unify dialog/overlay buttons on shared Button component

Replace raw <button> action/text controls across the modal layer (boot
failure, install, update, onboarding, clarify, model-visibility,
notifications, gateway menu) with the shared Button + its variants
(text / ghost / icon-xs). Drops the bespoke square-cornered styling so
every dialog matches the app's slightly-rounded button system, and
swaps clarify-tool's hardcoded "Skip" for the existing i18n string.

* feat(desktop): add dev-only dialog gallery for auditing overlays

A code-split, DEV-gated harness (toggle ⌘/Ctrl+Alt+Shift+D) that triggers
every dialog/overlay so their buttons can be eyeballed in one place:
store-driven overlays (boot failure, updates, notifications, sudo/secret)
plus in-place dialogs (confirm, profile create/rename, attach-url, model
picker/visibility, clarify, tool approval). Never ships to production.

* fix(desktop): use Ctrl+Shift+D for dialog gallery (mac-friendly)

The Cmd/Ctrl+Alt+Shift+D chord is impractical on macOS (Option mangles
the keypress). Ctrl+Shift+D is the same chord on every platform and uses
neither Cmd nor Option.

* fix(desktop): stop overriding button icon size to size-4

Action buttons hardcoded size-4 icons, overriding the Button component's
built-in size-3.5. That extra 2px is why boot-failure / onboarding / gateway
buttons looked chunkier than the settings "Apply" (size-3.5 spinner) despite
being the same component+size. Drop the overrides so icons inherit 3.5.

* feat(desktop): add BrandMark, use it in the updates overlay hero

New BrandMark renders the white logo.png on a hardcoded brand-blue tile
(#0000F2 light / #222 dark), replacing the generic Sparkles hero glyph in
the "update available" overlay. Trying it here first to iterate on the look.

NOTE: apps/desktop/public/logo.png is currently a 1x1 placeholder — the tile
renders now; the glyph appears once the real white logo art is dropped in.

* feat(desktop): add real logo.png asset, render it white in BrandMark

logo.png is blue line-art on transparent, so force it white via filter to
read on both the brand-blue (#0000F2) and near-black (#222) tiles. Bump the
glyph to 62% of the tile for the portrait aspect.

* fix(desktop): BrandMark renders logo as-is, no light bg/radius/padding

Drop the white filter, the hardcoded light-mode blue tile, the radius, and
the inner padding. Logo now fills the tile over a transparent surface in
light mode; dark keeps the #222 tile.

* fix(desktop): bump updates-overlay BrandMark to size-16

* feat(desktop): use downscaled karb.webp in BrandMark

Swap the BrandMark glyph to karb.webp, downscaled from 1129x1418/888KB to
254x320/81KB for the hero badge.

* feat(desktop): use nous-girl mark in BrandMark, invert in dark

Key the white background to transparent so only the black line-art remains
(384px/20KB webp). Light mode shows black art; dark mode flips it white via
dark:invert on the #222 tile. Drop the now-unused karb.webp and logo.png.

* fix(desktop): BrandMark uses nous-girl as-is (no transparent/invert)

The dark-mode invert read as a creepy negative. Use the opaque black-on-white
mark unchanged in both themes; drop the white-key, dark:invert, and #222 tile.

* fix(desktop): give BrandMark an explicit white bg tile

* fix(desktop): use nous-girl.jpg directly in BrandMark

* perf(desktop): downscale nous-girl.jpg to 256x256 (466KB -> 19KB)

* style(desktop): bump nous light --theme-secondary to 14% blue

* fix(desktop): outline button is transparent, not chrome-filled

The outline variant used bg-background (the chrome color), so on cards/overlays
with a different surface it rendered as an odd gray-blue fill (visible on the
boot overlay's Repair install / Use local gateway). Make it bg-transparent so
it inherits the surface like a real outline. Reverts the unrelated
--theme-secondary tweak.

* fix(desktop): clean outline button — thin border, no shadow/fill

Drop shadow-xs and the resting fills (light chrome bg, dark bg-input/30) so
outline is just a thin clean border with a subtle hover, in both themes.

* fix(desktop): stop forcing tertiary bg on outline buttons

A global [data-variant='outline'] rule set background: var(--ui-bg-tertiary),
which (attribute-selector specificity) overrode the cva bg-transparent — so
outline buttons always showed the pale tertiary fill on cards/overlays
regardless of the variant classes. Scope that fill to secondary only; outline
is now a true transparent border.

* style(desktop): unified overlay design system + restore #38631 flat-UI

Overlays/dialogs/toasts share a custom shadow-nous (downward-weighted) and
--stroke-nous hairline instead of hard borders: boot-failure, install,
notifications, model-picker, onboarding, prompt-overlays, updates, Dialog.

- button: outline is a 1px inset ring (no fill/shadow); chrome lives in Button
- BrandMark: 256px nous-girl mark replaces sparkle glyphs (updates/onboarding/about)
- onboarding: conditional header, lemniscate-bloom loaders, OTP device-code boxes,
  NOUS CONNECTED hero (ascii decode) + cuneiform easter egg, "Begin" matrix exit
- shared LogView + ErrorState; math/ascii loaders over "Loading..." text
- appearance-settings flattened to SegmentedControl/ListRow; keybind-panel on
  shadow-nous + text-variant reset
- restore flat-UI clobbered by #38631's stale-squash (4a1907bd1): command-center,
  profiles, skills, messaging, cron de-boxed; shared SearchField + PAGE_INSET_X;
  profiles back on OverlaySplitLayout; skills tabs+search one row, no row dividers

* refactor(desktop): clean pass — drop dead code, dedupe, fix stale docs

- log-view: drop unused `bare` prop + forwardRef (no caller uses ref)
- install-overlay: drop `stateOverride` (only the removed dev gallery used it)
- profiles: ProfilesViewProps down to { onClose } (drop vestigial section/titlebar)
- onboarding: hoist shared PROVIDER_ROW_CLASS (was duplicated 2x)
- brand-mark / error-state: tighten comments, fix stale AlertCircle reference
2026-06-06 16:32:47 -05:00
Brooklyn Nicholson
471a5fc5c9 feat(desktop): make cron jobs the first-class sidebar entity
Redesign the cron surface around jobs (not run sessions), following
power-user patterns (GitHub Actions / Airflow / Dagu): master → detail → output.

Sidebar "Cron jobs" section:
- jobs with a state pip + live next-run countdown
- click toggles an inline run-history peek; a run opens its chat (active run highlighted)
- hover: trigger-now + manage (open the Cron page)
- capped at 50 with a "50+" badge

Cron page: de-nested from a collapse-in-row accordion to master/detail —
job list + the selected job's schedule, actions, and run history.

Backend: GET /api/cron/jobs/{id}/runs lists a job's run sessions.

Share STATE_DOT/jobState across both surfaces; drop dead code/keys.
2026-06-06 14:04:11 -05:00
Brooklyn Nicholson
628f9040df feat(desktop): split cron sessions into their own sidebar section
Scheduler sessions (source=cron) were listed in recents, where their
`[IMPORTANT: …]` first-message previews spammed the list — and because
cron runs are always newest, a burst of them consumed the whole recents
page budget and starved real conversations (sidebar showed 0 sessions).

Recents and cron jobs are now two independent lists:
- Backend: /api/sessions + /api/profiles/sessions accept source /
  exclude_sources; session_count gains exclude_sources. Recents query
  excludes cron; the cron section queries source=cron.
- Desktop: separate $cronSessions store + refreshCronSessions fetch, a
  collapsed (persisted) "Cron jobs" section below Sessions that only
  renders when cron sessions exist, with its own bounded scroller.
2026-06-06 12:30:39 -05:00
Brooklyn Nicholson
74c8f51e95 fix(desktop): match file-browser default width to sessions sidebar
Both rails now open at SIDEBAR_DEFAULT_WIDTH so a fresh window has
equal-width sidebars instead of the old 237px vs 17rem mismatch.
2026-06-06 11:51:45 -05:00
Brooklyn Nicholson
258984fcb9 feat(desktop): broaden hotkey coverage + fold in stray shortcuts
Add rebindable actions for the high-frequency gaps: focus composer, open
model picker, next/prev session, search sessions (⌘⇧F), show files/
terminal tab, and nav→artifacts. Reconcile the duplicate Shift+N new-
session listener into session.new's defaults, and surface the remaining
context-local shortcuts (⌘↵ steer, ⌘L terminal selection, ⌘W close
preview) as read-only rows so the panel is the honest source of truth.
2026-06-06 11:47:33 -05:00
Brooklyn Nicholson
5e2b83a8ad feat(desktop): rebindable keyboard shortcuts panel
Add a central keybind registry + nanostore so desktop hotkeys are
discoverable and user-rebindable. A titlebar ⌨ button (and ⌘/) opens a
collapsible map grouped by Composer (read-only) / Profiles / Session /
Navigation / View; click any chip to capture a new combo. Overrides
persist to localStorage as a delta against shipped defaults, so future
default changes aren't shadowed by a stored snapshot.

Migrates the previously scattered inline listeners (palette, command
center, new session, sidebar, theme) into the registry, and adds profile
switch/cycle/create + default-profile hotkeys.
2026-06-06 11:41:57 -05:00
Brooklyn Nicholson
ce50030634 feat(desktop): integrate arrow history with the message queue
Builds on @naqerl's arrow up/down history (previous commit), making
ArrowUp do the right thing when a queue exists.

ArrowUp/ArrowDown priority:
1. Editing a queued turn → walk older/newer through queued entries,
   saving each edit; ArrowDown past the newest exits and restores the
   pre-edit draft.
2. Empty composer + queued turns → ArrowUp opens the newest queued entry
   for editing (the row's pencil), so Enter saves it back to the queue
   instead of firing a new message — the gap the history nav had alone.
3. Otherwise → sent-message history recall (unchanged).

Also: Esc cancels an in-progress queue edit (else interrupts).

Cleanups on the integrated code: fold the browse-state reset into the
existing session-change effect (drop the duplicate ref+effect); reuse
loadIntoComposer for history recall; sort imports; add curly braces +
the runDrain sessionId dep (lint).
2026-06-05 20:33:53 -05:00
naqerl
f94363d1f0 feat(desktop): arrow up/down to navigate previous user messages 2026-06-05 20:32:29 -05:00
brooklyn!
0cbcc75935
fix(desktop): reliable composer message queue (#40221)
* fix(desktop): make composer message queue reliable

The queue felt 'dumb' because of three real bugs:

1. Drained-after-interrupt sends went silent. cancelRun sets
   interrupted:true and nothing reset it; submitPromptText's optimistic
   seed preserved it, and the message stream drops every delta while
   interrupted. So Send-now-while-busy and any interrupt+drain submitted
   the next turn into a muted session. Fix: a fresh submit is a new turn —
   seed interrupted:false.

2. Back-to-back queue drains stalled. The drain fires on the busy->false
   settle edge, but busyRef (synced from the busy store by a separate
   effect) can still read true on that same edge, so the drained send hit
   the busy guard, returned false, and the entry was never removed. Fix:
   fromQueue sends bypass the busyRef guard (the queue drain lock
   serializes them); the user path keeps the guard.

3. Double-enter-to-interrupt killed single non-queue turns. The hidden
   450ms timer meant a natural double-tap after sending stopped the agent.
   Fix: empty Enter while busy is a no-op; interrupting is explicit —
   Stop button or Esc.

Also: clean stop (no [interrupted] marker), Send-now works while busy
(promote + interrupt + auto-drain), settle on the interrupted completion
path. Adds regression tests and unblocks the prompt-actions suite by
completing its stale @/hermes mock.

* fix(desktop): float the queue panel as an overlay so the chat doesn't resize

The queue list rendered in-flow inside the composer root, so its height
fed --composer-measured-height (the composer rect drives the thread's
bottom padding + last-message clearance). Queuing a message grew that
rect and the whole chat visibly resized.

Anchor the panel out of flow above the composer (absolute bottom-full,
capped at 40vh with internal scroll). It no longer contributes to the
measured height, so the thread layout stays put and the list overlays the
(already faded) chat. Still collapsible via the panel's own
disclosure header.

* fix(desktop): queue panel collapsed by default + shared border with composer

- Default the queue disclosure to collapsed (compact 'N queued' pill)
  instead of expanded.
- Drop the gap and merge the panel into the composer: square bottom
  corners, no bottom border/radius, and overlap down by the Root's pt-2
  (-mb-2) so the panel's borderless bottom lands on the composer surface's
  top border — one continuous bordered shape.

* style(desktop): tighten queue panel padding

* style(desktop): trim queue-ux comments to house style

* style(desktop): drop 'Cursor' references from comments
2026-06-05 20:21:41 -05:00
Jim Liu 宝玉
1d9c3ebae0 feat(desktop): persist i18n language in config 2026-06-05 10:32:26 -07:00
ethernet
7f016f5f33 change(desktop): show up to 50 models in list per provider by default 2026-06-05 00:00:19 -04:00
shannonsands
ab706a3346
Clear stale desktop onboarding errors (#38844) 2026-06-04 22:59:55 -05:00
Teknium
9cc47b20cb
feat(desktop): add 'choose provider later' skip to first-run onboarding (#39483)
The first-run provider picker was a hard gate — the only way out was
connecting a provider. Add an 'I'll choose a provider later' link that
dismisses the overlay and persists the skip to localStorage so it never
re-nags on subsequent launches. Users connect a provider any time from
Settings -> Providers (manual onboarding already bypasses the skip gate).

- onboarding.ts: firstRunSkipped state seeded from localStorage
  (hermes-onboarding-skipped-v1) + dismissFirstRunOnboarding() action;
  completeDesktopOnboarding clears the flag once a provider connects.
- overlay: skip gate (firstRunSkipped && !manual returns null); ChooseLaterLink
  rendered in both the OAuth picker footer and the API-key fallback, first-run only.
- tests: skip persists + hidden in manual mode; full-state fixtures updated.
2026-06-04 19:40:54 -07:00
Brooklyn Nicholson
4891f9ae78 feat(desktop): concurrent multi-profile gateway sockets
Keep one persistent socket per profile with live work instead of closing
the single socket on every profile swap, so background sessions across
profiles keep streaming at once. A gateway registry owns the primary
(window) socket plus lazy secondaries (own backoff/reconnect); all feed
the same session-keyed event handler. Secondaries are pruned to profiles
with a working/needs-input session, the keepalive pings every open
backend, and LRU eviction spares freshly-touched backends so the soft cap
can't abort a running agent. Approval/sudo/secret prompts are parked
per-session (surfaced via the needs-input badge) so a background turn can
block without hijacking the foreground. Single-profile users only ever
have the primary, so their path is unchanged.
2026-06-04 20:44:19 -05:00
Brooklyn Nicholson
1b01fa3acf feat(desktop): long-press a rail profile to pick its color
Hold (~450ms) a profile square — or right-click → Color… — to open a
shadcn Popover of swatches and override its rail color, with Auto to fall
back to the deterministic hue. The hold timer rides alongside the dnd
pointer listener (a real drag cancels it, the trailing click is
suppressed), so reorder/select/recolor stay distinct gestures.

Overrides persist in localStorage ($profileColors), resolved via
resolveProfileColor (override wins, else the name-hashed hue). Cosmetic
and gated on the multi-profile rail, so single-profile users are
unaffected. Adds a reusable ui/popover.tsx (radix-ui umbrella).
2026-06-04 20:12:37 -05:00
Brooklyn Nicholson
9dbd3c57d7 feat(desktop): drag sessions into chat as @session links + spawn loader
Drag a sidebar session into the composer to drop an @session:<profile>/<id>
chip the agent resolves via session_search. New READ shape dumps a whole
session by id (head+tail when large); a `profile` param reads another
profile's DB read-only, and a cross-profile locate scan resolves bare ids
when the model drops the owning profile from the link.

Also: ASCII "waking up <profile>" overlay during lazy gateway swaps,
global haptic rate-limit to kill the reconnect-storm "clickity" buzz, and
reauth toasts surfaced once per disconnect instead of every backoff tick.
2026-06-04 19:41:51 -05:00
Brooklyn Nicholson
a40e20e136 feat(desktop): profile rail rename/delete + context-switch polish
- right-click a profile square to rename or delete it, via shared
  self-contained dialogs (also reused by the profiles page)
- switching or creating a profile now resets to a fresh new-session
  draft so the prior session doesn't stay sticky across contexts
- deleting the profile you're currently in falls back to default
  instead of stranding the gateway on a dead profile
- shared ConfirmDialog: Enter/Space confirm from anywhere in the dialog;
  profile-delete and cron-delete both route through it
2026-06-04 18:24:39 -05:00
Brooklyn Nicholson
e0121c59d3 feat(desktop): drag-sort profiles in the rail
Make the named-profile squares reorderable via dnd-kit (horizontal sort,
4px activation so a tap still selects). Order persists in localStorage
($profileOrder); unordered/new profiles alphabetize at the tail.
2026-06-04 16:59:39 -05:00
Brooklyn Nicholson
b94b3622b5 feat(desktop): per-session profile switching + cross-profile sessions
Add first-class profile support to the desktop app without app reloads.

- Swap the single live gateway onto a session's profile lazily (spawned on
  demand by the Electron backend pool), so one backend serves the active
  profile and others stay cold — no OOM with many profiles.
- Aggregate sessions across profiles by reading each profile's state.db
  read-only; unified "All profiles" view groups sessions per profile with
  per-profile pagination, while the default view stays scoped to one profile.
- Add an Arc-style profile rail at the sidebar foot: a default<->all toggle
  pinned left, colored named-profile squares scrolling between, Manage pinned
  right. Profile identity is a deterministic per-name color.
- Route profile-scoped REST (config/env/skills/tools/model) to the active
  gateway profile and invalidate React Query caches on swap. Single-profile
  users never trigger a swap, so their path is unchanged.

Backend:
- web_server: profile-aware active/list endpoints + per-profile session
  totals; hermes_state: session_count(exclude_children); main.py: honor
  --profile over HERMES_HOME env for pooled backends.

UI primitives:
- Add a position-aware Tip tooltip (instant, themed) as a drop-in for native
  title=, and strip redundant tooltips from self-descriptive chrome.
2026-06-04 16:35:34 -05:00
brooklyn!
e003c53b06
chore(desktop): zero eslint/typecheck debt + prettier pass (#39100)
- eslint --fix across src/ and electron/ (unused imports, import/prop sort, padding)
- flatten empty catch blocks in electron CJS; drop unused applyUpdatesPosixInApp arg
- add setMutableRef helper for imperative ref writes (react-compiler clean)
- move sidebar cookie persistence into an effect; extract scrollElementToBottom helper
2026-06-04 14:10:38 +00:00
Austin Pickett
9cbc37e25b
feat(desktop): dedicated Providers settings + polished Accounts/API-keys UX (#38551)
* feat(desktop): dedicated Providers settings with Accounts/API-keys subnav

Rework provider configuration in the desktop app into its own Providers
page that mirrors the first-run onboarding picker, instead of burying
provider keys in the generic Tools & Keys list.

- Add a Providers settings page (providers-settings.tsx) reusing the
  onboarding picker cards/ApiKeyForm so the two surfaces stay identical
- Add a sidebar subnav (Accounts vs API keys) backed by a deep-linkable
  `pview` URL param; nested OverlayNavItem variant for a lighter active
  state so children don't compete with the parent item
- Scope provider search to the active sub-view in its native card format
  (no more accordion fallback); collapse the API-key grid to the top
  providers behind a "Show all" toggle to cut scrolling
- Launch real in-app OAuth from settings via startManualProviderOAuth;
  fix the misleading red "reason" banner that showed during an active
  connect (neutral style, hidden during a flow, omitted for direct
  per-provider launches)
- Expand PROVIDER_GROUPS and add longest-prefix matching so providers
  like xAI/Ollama group correctly instead of landing under "Other"
- Drop redundant messaging API keys from Tools & Keys (channel_managed)

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

* feat(desktop): Cursor-style provider key list with inline inputs

Replace the card-grid API-key form on the Providers page with a
per-provider list (mirrors Cursor's API keys section):

- One row per vendor with its primary key input inline; rows with extra
  vars (base URL, region, alt tokens) expand to reveal those on focus
- Set keys show their redacted value as the placeholder; Save appears on
  edit, Remove on a set key
- Hide redundant alias key fields (e.g. ANTHROPIC_TOKEN vs
  ANTHROPIC_API_KEY) unless already set, and label set aliases by env var
  name so they're unambiguous
- Smaller mono input text + compact height

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

* style(desktop): flatten providers settings UI chrome

Tighten the providers settings surface to match the newer desktop style:
remove extra card rails/borders in API-key rows, reduce visual noise in the
providers subnav, replace bespoke link-like controls with shared text-button
variants, and improve key input readability.

* feat(desktop): rework providers settings UI

- Flatten the shared OAuth picker rows (accounts + onboarding): drop the
  rounded-2xl/border cards for flat hover-bg rows; Nous hero keeps a subtle
  tint plus an animated blue→purple arc border.
- Key fields collapse to a single input: a set key reads read-only (redacted)
  and edits in place on focus/click — no Replace/Cancel chrome. Save on type,
  Esc cancels (without closing the overlay), "Remove or esc to cancel" hint.
- Non-key overrides render boxless, content-sized (field-sizing) and
  right-anchored; advanced fields align under the primary key column.
- Add `xs` control size; size fields via padding (no fixed heights).
- Cards expand on key-input focus; chevron shows on hover/expanded; expanded
  state uses a ring + softer bg tier so hover ≠ focus.
- Relocate "Get a key" to the bottom-right of the expanded panel; drop the
  redundant provider description.
- Cmd+K: add Providers (accounts) and Provider API keys deep-links.

* fix(desktop): flatten provider fields, drop input shadows, fix Cmd+K provider rank

- KeyField: collapse to one stacked label-above-input form field (drop the
  bespoke `naked`/inline/column branches); empty advanced overrides fade until
  hover/focus/set
- styles: kill the resting + focus drop shadow on shared input chrome so form
  inputs sit flat (composer keeps its own shadow)
- Cmd+K: drop stray `providers` keyword from Skills & Tools so the Providers
  settings entry ranks first for "provider"

* fix(desktop): nous portal arc blue → orange

* fix(desktop): rank appearance above settings in Cmd+K

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Brooklyn Nicholson <brooklyn.bb.nicholson@gmail.com>
2026-06-04 03:03:42 -05:00
Brooklyn Nicholson
38acced687 style(desktop): satisfy lint across PR-touched files 2026-06-04 00:22:17 -05:00
Brooklyn Nicholson
ac9de2e80c feat(desktop): global Cmd+K palette + UI consistency overhaul
Builds on the clarify/needs-input work with a cross-cutting pass to make
the desktop surfaces feel like one app.

- Global Cmd+K command palette (cmdk): nav, settings deep-links, async
  API-key / MCP-server / archived-session groups, reusable theme sub-page
  (light/dark groups, stays open on pick), loop nav, fuzzy match. Replaces
  per-page settings search.
- Shared SearchField: borderless, underline-on-focus, `field-sizing`
  auto-width. Unifies sessions sidebar, pages, overlays, command center,
  cron; drops bespoke OverlaySearchInput.
- Cron & Profiles converted to OverlayView; flat token-driven panels
  (no card-in-card / divider borders) matching command center.
- `r` refresh hotkey via useRefreshHotkey; drop the visible refresh buttons.
- Button text/textStrong link variants applied across settings & views;
  shared PAGE_INSET_X content gutters.
- Math/ascii loaders replace "Loading…" text placeholders; x-icon close
  over text "Close"; cursor-pointer at the dropdown/select primitive level.
2026-06-03 23:45:45 -05:00
Brooklyn Nicholson
e68fc4def2 feat(desktop): titlebar toggle to flip sidebar sides
Adds a top-left swap button (replacing the search icon) that mirrors the
layout: sessions sidebar ↔ file browser + preview rail. Persisted via
$panesFlipped. The left/right sidebar toggles, content inset, and pane
borders all follow the active side so the buttons stay accurate after a flip.
2026-06-03 22:30:47 -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
Teknium
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
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
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