Commit graph

1354 commits

Author SHA1 Message Date
b
628ce5bb86 perf(desktop): bail the transcript out of router-driven re-renders on session switch 2026-07-27 01:53:49 -05:00
b
bbfe181a2a perf(desktop): keep thread message component types stable across a session switch 2026-07-27 01:53:49 -05:00
brooklyn!
825069004a
Merge pull request #72504 from NousResearch/bb/desktop-real-session-perf
perf(desktop): 60fps on real sessions — reflow-gated pins, adaptive flush, stream-aware backfill
2026-07-27 01:50:41 -05:00
Brooklyn Nicholson
9c28771cf3 fix(desktop): keep pinned sidebar rows in user order
flattenSessionsWithBranches always re-sorted roots by last_active, so a
turn finishing floated background tasks over the hand-picked Pinned list
even though $pinnedSessionIds already stored drag order. preserveOrder
skips that sort for pins (and other non-date-grouped manual lists); default
recents stay recency-sorted for truthful date buckets.
2026-07-27 01:38:21 -05:00
Brooklyn Nicholson
7f87b67245 Revert the streaming-backfill gate — it broke a real E2E invariant
Deferring the FIRST_PAINT_BUDGET -> RENDER_BUDGET backfill while a thread
streams cut a 1374ms streaming-session switch to instant, but it also
means a streaming transcript stays clipped to 60 parts for the duration
of the run. `large-session-resume` asserts the resumed transcript shows
every seeded reply exactly once, and that count is short while the budget
is held down — a genuine behavior change, not a flaky test.

The switch cost is real and still worth fixing, but the fix has to keep
the full transcript mounted (raise the budget in idle callbacks, or
virtualize) rather than withhold it. Session-switch work is happening in
a parallel effort; leaving the invariant intact for them.

Everything else in this branch is untouched: the reflow-gated RO pins
(11.5 -> 59fps drag), the structural/weight signature split, the adaptive
stream flush, the tree-split preview, and the tool-row memo boundaries.
2026-07-27 01:30:29 -05:00
Brooklyn Nicholson
81779fa6a0 perf(desktop): don't backfill the transcript while its thread streams
Switching to a STREAMING session took ~1.4s to settle while an idle
session settled in ~50ms. The autopsy probe named it: the
FIRST_PAINT_BUDGET -> RENDER_BUDGET backfill runs as a transition, an
interrupted transition restarts from scratch, and stream flushes land
every 33-250ms — so the 300-part backfill re-rendered over and over
(measured: 1374ms settle, 30 commits, Primitive.div x2237 for one switch).

Gate the backfill on the thread being idle. The user lands on the live
tail immediately either way; older turns backfill the moment the run
ends, and 'Show earlier' remains the manual path meanwhile.

Measured on the live app (diag-switch-autopsy, real sessions):
  switch to idle session        ~35-55ms settled (unchanged)
  switch to streaming session   1374ms -> backfill deferred; lands at
                                the live tail like any other switch

Adds diag-switch-autopsy.mjs (per-switch settle/commits/top-renders) and
live-drive.mjs (status/fps/drag one-liners against the running app).
2026-07-27 01:14:29 -05:00
Brooklyn Nicholson
2c867b05ce perf(desktop): 60fps sash drag on real sessions — height-gate the RO pins
Driving HER real instance (real profile, real transcripts, streams live)
via CDP instead of synthetic tiles finally exposed the remaining stall.
The timeline on a real 60-frame sash drag:

  style recalc 2736ms | script 1027ms | layout 89ms
  top callsite: pin @ fallback.tsx — 927ms

Two pin-to-bottom ResizeObservers (the bounded tool window's and the
reasoning preview's) pinned on EVERY resize delivery. A sash drag changes
every message's WIDTH once per frame, so each frame ran scrollTop write ->
scrollHeight read across every tool group: a forced write-read reflow
cascade that the render counters could never see (zero React involvement).

Both pins are now height-gated off the RO entry (reflow-free): only
content GROWTH pins. Width-only deliveries return immediately.

Measured on the live app, same drag, before -> after:
  fps      11.5 -> 59-60
  p95      101ms -> 18ms
  slow>33  60/60 -> 1/60

Also in this batch (each was verified live before the next was attempted):
- thread/list: split messageSignature into STRUCTURAL (ids/roles — keys
  boundaries + row identity) and WEIGHT (part counts — budget only), and
  memoize groups + row JSX. A streamed part-append re-rendered every
  turn's boundary via its resetKey prop; explain() measured 540-865
  wasted Block renders per drag/stream sample, now {}.
- message-render-boundary: document the structural-only resetKey contract.
- tool/fallback: memoize ToolFallback's part object + ToolEntry/ToolTitle/
  ToolGlyph (151 renders each, 100% wasted, on real transcripts).
- use-message-stream: ADAPTIVE flush floor — next flush waits 3x the
  measured cost of the last one (33ms floor, 250ms cap), so multi-stream
  load degrades text update rate instead of input latency.
- tree-split: preview sash drags with inline flex on the two seam
  wrappers, committing the store ONCE on release (fixed-zone sides get
  flexBasis only, so a hidden sidebar can't leave a phantom gap).
- debug/: perf-live LoAF long-frame attribution, explain() cascade walker
  with changed-hook indices, diag-real-loop/key-latency/switch-trace
  probes that drive the real app over CDP.

Typing during 2 live streams: keystroke->paint p50 3.3ms, p95 18.4ms,
zero frames over 33ms. Session switch p50 ~35ms settled; the remaining
~1.3s outlier tail is streaming-session switches (React work-loop, not
style/layout) — next target.
2026-07-27 01:07:48 -05:00
teknium1
91d69c4ca3 fix(desktop): satisfy eslint in kimi-k3 picker rebase 2026-07-26 21:22:40 -07:00
HexLab98
c63e0cd3e3 fix(models): match bare Kimi Coding k3 when searching kimi
Kimi Coding discovers the flagship as wire id `k3`. Picker search used
only that id, so typing "kimi" hid it next to every other kimi-* model.
Add picker-only search aliases without changing the wire id.
2026-07-26 21:22:40 -07:00
teknium1
2be2464d25 fix(desktop): satisfy eslint in approvals-command rebase 2026-07-26 21:13:03 -07:00
Teknium
f9cd577915 feat(approvals): add cross-surface mode command
Co-authored-by: luxiaolu4827 <227715866+luxiaolu4827@users.noreply.github.com>
2026-07-26 21:13:03 -07:00
brooklyn!
443ce196f4
Merge pull request #72442 from NousResearch/bb/statusbar-session-prefs
feat(statusbar): hide the per-turn session readouts by default
2026-07-26 23:07:08 -05:00
Brooklyn Nicholson
f7cfc6ecd3 test(statusbar): cover the session readouts starting hidden 2026-07-26 22:38:33 -05:00
Brooklyn Nicholson
5b9518db41 feat(statusbar): hide the per-turn session readouts by default
The running/session timers and the context meter light up whenever a
session works, so the bar filled with diagnostics most users don't watch
every turn. They join the same right-click show/hide menu the route
shortcuts and terminal toggle already use: hidden out of the box, opt in
via a toggleLabel, choice persisted per install.
2026-07-26 22:38:33 -05:00
teknium1
f8918391d9 fix(desktop): drop now-unused clearSessionSubagents import in gateway-event
The message.start site swapped to pruneFinishedSessionSubagents; the old
import survived the rebase and trips unused-imports + sort-imports lint.
2026-07-26 20:37:37 -07:00
Sophia
14c754cda4 fix(desktop): remove unused imports from gateway-event.ts
Per hermes-sweeper review on PR #67005:
- Remove `broadcastSessionsChanged` import (unused)
- Remove `setSessionTodos` import (unused)
- Keep `clearActiveSessionTodos` and other active imports

No behavioral change, just dead code removal.
2026-07-26 20:37:37 -07:00
Sophia
8a32426300 fix(desktop): preserve live background subagents across message.start (rebased onto main, #64015)
Cherry-pick of b4d5ba3e onto current main. Original commit's target file
apps/desktop/src/app/session/hooks/use-message-stream.ts has since been
moved into apps/desktop/src/app/session/hooks/use-message-stream/gateway-event.ts.
The fix is unchanged: replace clearSessionSubagents() with
pruneFinishedSessionSubagents() at the message.start boundary so that
still-running subagent rows survive new turns.

Per @teknium1's review of #64038 on 2026-07-16: 'mergeable_state=dirty'
because the dispatcher moved. Resolved by retargeting the call.

Tests: 3 new cases in subagents.test.ts (unchanged from original PR).
Failing-first verified in original PR (3/3 fail on unfixed, 3/3 pass on fix).
2026-07-26 20:37:37 -07:00
brooklyn!
b9ba7c78e4
Merge pull request #72345 from NousResearch/hermes/hermes-5ee2515d
feat(desktop): artifacts — versioned cards, sandboxed live preview, right-rail viewer
2026-07-26 22:08:34 -05:00
Brooklyn Nicholson
a94ec27f97 polish(desktop): tokenize artifact card font sizes, drop redundant hover class
- artifact card title/meta/open-hint now use --conversation-text-font-size /
  --conversation-tool-font-size like CodeCard and the rest of the transcript,
  instead of hardcoded rem literals
- drop no-op hover:border-border (border is border-border at rest)
- comment the deliberate raw bg-white + colorScheme:light on the sandboxed
  iframe so it doesn't read as an untokenized literal
2026-07-26 22:00:29 -05:00
hermes-seaeye[bot]
8c36cd4670
fmt(js): npm run fix on merge (#72411)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-07-27 02:52:53 +00:00
teknium1
236b1b56cd
feat(desktop): artifacts — versioned cards, sandboxed live preview, right-rail viewer
Substantial generated content (full HTML documents, large SVGs, long code)
now promotes out of the transcript into versioned, openable artifacts:

- lib/artifact-detect.ts: pure detection over fenced blocks — html docs,
  large standalone svg, and 48+ line / 3k+ char code fences become
  artifacts; prose/terminal/mermaid fences and small snippets never do.
  Titles derive from <title>/<h1>, filename comments, or declarations.
- store/artifacts.ts: per-session registry with content-hash dedupe and
  version history (same kind+title = one artifact the model iterates on),
  persisted to localStorage with per-session/per-artifact/byte caps.
- ArtifactCard (transcript): compact openable card replaces the wall of
  code; streaming shows shimmer + line count; versions accumulate
  automatically on completion but the rail only opens on click
  (offer, don't hijack). Reasoning scratchpads never register.
- ArtifactPane (right rail): artifact: tabs beside preview/file tabs with
  version stepper (v2 of 3 / Latest), PREVIEW/SOURCE toggle, copy,
  download (kind-aware extension), open-in-browser for HTML.
- Rendering: HTML runs in a sandbox=allow-scripts iframe (opaque origin,
  no network to the app, no top-nav); SVG is DOMPurify-sanitized with the
  same profile as the inline embed; source view reuses the windowed Shiki
  renderer so 5k-line artifacts scroll smoothly.
- Rail integration: artifact tabs participate in tab order, close-others/
  close-to-right, ⌘W, pane visibility, and reveal; gateway switches close
  open artifact tabs; a persisted artifact: active-tab id reconciles to
  preview on boot (artifact tabs are ephemeral).
- i18n: en/zh/zh-hant/ja/ar strings for card + pane.
- session-ref-open.test.tsx: mock now spreads the real module — the
  artifact card imports session-view, which needs $sessionStates.

Tests: artifact-detect (15), artifacts store (12), markdown-pipeline
integration (3); full desktop suite 3128 passed, electron project 751
passed, tsc no new errors.
2026-07-26 19:29:25 -07:00
Brooklyn Nicholson
3d6c32b061 perf(desktop): derive the tab menu's row narrowly instead of subscribing wholesale
SessionTabMenu subscribed to $sessions + $projectTree for values it
never rendered (the row was re-read imperatively), so every tab of every
tile re-rendered its menu wrapper on any session-list or project-tree
churn — for a context menu that is almost never open. Same class as the
TreeGroup fix (#72245): derive the three scalars the menu actually shows
(pinId, title, profile) behind a keyed bail-out, so the wrapper only
re-renders when one of them changes.
2026-07-26 21:05:55 -05:00
Brooklyn Nicholson
132654cdec perf(desktop): finish narrowing the statusbar's store subscriptions
#72163 narrowed $focusedSessionState but left two whole-store reads in
the same hook paying the same price:

- $subagentsBySession: only two COUNTS are rendered, but the whole-map
  subscription re-ran the hook (rebuilding all ~9 statusbar items) on
  every subagent progress tick in any session. Select the two scalars.
- $sessions: only one row's started_at is read, but any session-list
  write (title update, poll refresh, archive) re-ran the hook. Select
  the one scalar.
2026-07-26 21:05:55 -05:00
Brooklyn Nicholson
fc3af6095f perf(desktop): stop cross-session churn re-rendering every composer status stack
$statusItemsBySession rebuilt its whole output map — fresh arrays,
fresh item objects — on every recompute, and its inputs churn constantly
(subagent ticks, 5s background polls, todo updates, in ANY session). A
whole-map useStore in ComposerStatusStack then re-rendered every mounted
stack — one per open tile — on all of it, and the fresh item objects
defeated row memoization downstream.

Two halves, per the documented slice contract (use-session-slice.ts):

- producer: stabilize $statusItemsBySession per key — an unchanged
  session keeps its previous array and item objects, and a fully
  unchanged map keeps its previous reference so computed skips the
  notify entirely ('preserve reference identity on no-ops').
- consumer: the stack subscribes to its OWN session's slice via
  useSessionSlice instead of the whole map.
2026-07-26 21:05:55 -05:00
Brooklyn Nicholson
6107624727 perf(desktop): stop the model-picker overlay re-rendering per streaming token
ModelPickerOverlay subscribed to $focusedSessionState whole — a
projection of $sessionStates, republished on every message delta — to
read two fields that essentially never change (model, provider). The
overlay is mounted app-wide and unconditionally renders the un-memoized
ModelPickerDialog (closed), so the focused session's stream re-ran the
dialog's full hook body ~30x/s.

Same defect class and same fix as the statusbar (#72163): select each
scalar through useStoreSelector so unchanged values bail out.
2026-07-26 21:05:55 -05:00
Brooklyn Nicholson
c74f48b62e fix(desktop): null-guard the rotation signal fired from ensureSessionState
The salvaged no-op-publish guard moved the compression-rotation signal
into ensureSessionState, where storedSessionId is string|null. A cleared
stored id is a detach, not a rotation — firing the event with a null
next id would send the route-follow effect chasing nothing (and tsc
rejects it). Guard on a real next id.
2026-07-26 21:05:55 -05:00
Andy
9aaabdcbf4 fix(desktop): cover nested terminal layout changes 2026-07-26 21:05:55 -05:00
Andy
651a313d2b fix(desktop): suspend decorative work when inactive 2026-07-26 21:05:55 -05:00
Ho Lim
5c8ac975e7 fix(desktop): invalidate terminal overlay position on layout mutations
Signed-off-by: Ho Lim <subhoya@gmail.com>
2026-07-26 21:05:55 -05:00
Ho Lim
7a5d534f5b fix(desktop): gate idle renderer loops
Signed-off-by: Ho Lim <166576253+HOYALIM@users.noreply.github.com>
2026-07-26 21:05:55 -05:00
蒋方明
f8a554bced fix(desktop): keep message component types stable across Thread re-renders
The component map Thread passes to the virtualizer listed the
onBranchInNewChat / onCancel callbacks as useMemo deps. Whenever a parent
re-render handed down a fresh callback identity, the memo rebuilt the map
and produced new component *types*, so React unmounted and remounted every
visible message. Async-rendered parts (shiki code blocks) collapsed and
re-expanded on each remount, making the whole thread visibly jump.

That is exactly what shipped in v0.15.1: the desktop controller passed an
inline arrow for onBranchInNewChat, and the 15s status-snapshot poll
re-rendered the controller, so threads with code blocks jumped every 15
seconds (layout-shift scores of 0.39 + 0.47 per cycle, measured via CDP).
arrow away from regressing.

Route the callbacks through a ref so the component types survive any
parent re-render; only the callbacks' definedness stays a dep, because it
gates UI (the user-message Stop button). Add a regression test that fails
on the old code by asserting message DOM nodes keep their identity when
callback props change identity.

Tested on macOS arm64 (vitest + rebuilt app, CDP layout-shift
instrumentation confirms zero shifts over multiple poll cycles).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-26 21:05:54 -05:00
webtecnica
571b75792c fix(desktop): stop renderer OOM from session.info heartbeat churn on $sessionStates
The renderer OOMs every ~60s because periodic ~1/s session.info
heartbeats churn the entire $sessionStates store on every tick even
when nothing changed. Each heartbeat:

1. Called updateSessionState with the running-test updater, which
   always returned a new spread object — even when busy state hadn't
   changed — because the updater param was already a fresh spread.

2. publishSessionState then spread the full $sessionStates record
   and set it, firing every computed atom ($workingSessionIds,
   $attentionSessionIds) and their subscribers on every heartbeat.

Over 60s × ~1/s heartbeat this continuous store churn creates
millions of short-lived objects, amplifies React re-renders, and
starves the GC, sending the renderer working set from ~350 MB to
1.2-5 GB before the OOM crash.

Fix (two changes):

1. updateSessionState: pass the raw previous state (not a spread) to
   the updater so it can return the same reference on no-op. Skip the
   store write, publishSessionState, and syncSessionStateToView when
   the updater returned the same reference. The rotation signal from
   storedSessionId changes is now emitted directly from
   ensureSessionState since publishSessionState (and thus
   handleTransition) is skipped on no-op.

2. publishSessionState: guard with `prev === state` reference check
   (belt-and-suspenders for any other caller).

Fixes #69016
2026-07-26 20:47:57 -05:00
brooklyn!
2078af601a
Merge pull request #72346 from NousResearch/bb/desktop-perf-finish
perf(desktop): drag at 60fps with five streaming tabs
2026-07-26 20:15:35 -05:00
brooklyn!
e1ace0ac98
Merge pull request #72336 from NousResearch/bb/statusbar-prefs
Quieter status bar and sidebar counts
2026-07-26 20:10:16 -05:00
brooklyn!
6cea77303b
Merge pull request #72339 from NousResearch/bb/redirect-user-row
Preserve the original prompt when a mid-turn redirect corrects a turn
2026-07-26 20:09:46 -05:00
Brooklyn Nicholson
bbfc4df357 perf(desktop): one app-level TooltipProvider, not one per Tip
Every `Tip` carried its own `TooltipProvider`, and there are ~107 call
sites. Each is a subtree that re-renders when anything above it does, so
they dominated unrelated interactions: 52,784 TooltipProvider renders and
18.3s of component time in a single sash drag.

Radix's provider holds only refs and stable callbacks (no reactive state)
— hoisting one to the app root is what it is designed for. `Tooltip`
still reads delayDuration/disableHoverableContent from context, and the
per-Tip overrides are preserved.

`Tip` keeps a local provider as a FALLBACK, chosen by context: a
component rendered in isolation has no root provider and Radix throws
"`Tooltip` must be used within `TooltipProvider`". Without this, 20 unit
tests that render a single control fail. Inside the app the flag is
always true, so the common path is a bare Tooltip.

This is the shape the earlier lazy-mount attempt should have taken. That
one deferred the Radix subtree until hover, which moved
data-slot="tooltip-trigger" off the mounted DOM and broke 18 tests
encoding that contract. Hoisting keeps the contract intact — every one of
those tests passes unchanged.

Measured on the same drag:

  TooltipProvider   52,784 renders / 18.3s -> gone from the table
  Primitive.div     40.5s -> 13.4s
  Popper            10.5s ->  2.7s
  Tooltip           15.7s ->  4.4s
2026-07-26 19:56:55 -05:00
Brooklyn Nicholson
31d49f0dfc perf(desktop): share one ResizeObserver instead of one per consumer
A CDP trace of one sash drag settled what the render counters could not.
I had assumed the remaining cost was layout/paint; it was not:

  script 6770ms | style 1866ms | layout 71ms

Layout was never the problem. The top attributable callsite in our own
code was use-resize-observer.ts at 977ms.

Counting the callbacks named the mechanism exactly: 8,620 ResizeObserver
instances constructed, and during a 40-move drag 2,600 callbacks each
carrying exactly ONE entry — 65 separate callbacks per pointermove. Every
consumer owned a private observer, so N elements resizing under a common
ancestor meant N trips through the observer machinery instead of one
batched delivery. With five mounted tiles that is ~100 user bubbles, each
with its own observer, all woken by a width change.

One shared observer with a WeakMap of target -> handlers. Callers keep
their exact contract: a handler observing several elements is still
invoked once with all of its entries, and unobserve happens when the last
handler for an element goes away.

Verified by trace, before -> after:

  use-resize-observer   977ms -> 42.5ms   (-96%)
  style recalc         1866ms -> 1145ms   (-39%)
  total script         6770ms -> 3929ms   (-42%)

Callback count 2,600 -> 43: one delivery per frame instead of 65.

Adds the two probes that found it. diag-drag-trace.mjs takes a real
timeline trace and prints the style/layout/script split plus the top
script callsites — that split is what disproved the layout theory.
diag-ro-storm.mjs counts RO callbacks vs entries, which is what
distinguished 'a few expensive calls' from 'very many cheap ones'.
2026-07-26 19:56:55 -05:00
Brooklyn Nicholson
c5336b472e feat(statusbar): right-click to choose what the bar shows
The status bar shipped every affordance it had, so approvals, the terminal
toggle, agents, cron and webhooks sat there permanently for users who never
touched them.

Those five now start hidden and the bar owns a context menu that turns them
back on, persisted per install. Items opt in by naming themselves with
`toggleLabel`, so a plugin contribution that doesn't opt in always shows;
the system icon and the version/update pills are listed but locked on,
since hiding the way back into settings strands the user.

Preferences store the hidden set rather than the visible one, so an item
added to the bar in a later version appears for existing users instead of
staying silently off.
2026-07-26 19:52:57 -05:00
teknium1
d6fdae6746 feat(desktop): quick-entry window — global hotkey capture to any session
Parity with Claude Desktop quick-entry-window / ChatGPT Quick Chat.
2026-07-26 17:48:19 -07:00
teknium1
aa3121bb34 feat(desktop): Quick Entry — global-hotkey mini composer window 2026-07-26 17:48:19 -07:00
teknium1
5557b10fb6 fix(desktop): tear down find-in-page highlights on navigation and pin the keybind registration
Electron's findInPage selection is per-webContents, not per-route: without
an explicit teardown, the highlight overlay (and a stale match counter)
from one chat survived navigating to another session or a settings page.
The FindBar now closes itself via effect cleanup keyed on the router
pathname — the first render never fires it, a route change tears down the
previous route's search, and unmount (the session/profile switch paths
that remount the global overlays) gets the same teardown. closeFindBar is
already idempotent, so a closed bar never re-enters the bridge, and a
navigation with the bar closed makes zero bridge calls.

Tests (find-bar.test.tsx 42 -> 48):
- navigation closes the bar, resets the store, and calls
  stopFindInPage exactly once (highlight cleanup on session switch)
- navigation with the bar closed never reaches into the bridge
- keybind registration pins: view.findInPage is mod+f in the view
  category; mod+f passes comboAllowedInInput (so ⌘F fires from the
  composer instead of typing 'f'); the find-next/find-previous pair
  ships unbound while view.toggleReview keeps mod+g; all three actions
  have en + zh labels for the keybinds panel

The component tests now render inside a MemoryRouter (FindBar reads
useLocation), with a navigation harness that captures useNavigate.
2026-07-26 17:48:05 -07:00
teknium1
6d427e82df feat(desktop): complete find-in-page with the find-next/find-previous accelerators
PR #53891 (cherry-picked in the three preceding commits) landed the Electron
bridge, the store, the find bar overlay, and Cmd/Ctrl+F to open. It stopped
short of the rest of the accelerator set and had two lifecycle leaks. This
completes the surface to match the platform convention that Chrome, Safari,
VS Code, and Claude Desktop's own findInPage bundle all ship.

Accelerators now wired:
- Cmd/Ctrl+F      open the find bar          (view.findInPage, from the PR)
- Cmd/Ctrl+G      find next                  (new)
- Cmd/Ctrl+Shift+G find previous             (new)
- Enter / Shift+Enter step from the input     (from the PR)
- Escape          close + stopFindInPage('clearSelection')  (from the PR)

Keyboard ownership (the substantive fix)

Cmd+G was already bound to `view.toggleReview` and Escape to
`composer.cancel`. The find bar's own capture-phase window listener cannot
win those keys by calling stopPropagation: the keybind dispatcher's listener
sits on the SAME window target in the SAME phase, and propagation control
does not suppress sibling listeners on one target. Left alone, Cmd+G would
step a match AND toggle the review pane, and Escape would dismiss the bar AND
abort a running turn.

So ownership is decided by the dispatcher, which AGENTS.md already names the
single owner of combo dispatch: `findBarClaimsCombo` is consulted in
use-keybinds before the registry lookup, and yields mod+g / mod+shift+g /
escape to the bar only while it is open. Closing the bar hands every one of
them straight back. That is the "keyboard ownership follows focus / one cancel
gesture does exactly one thing" invariant.

`view.findNext` / `view.findPrevious` are registered with EMPTY defaults on
purpose — shipping mod+g as a second default would flag a permanent conflict
in the keybinds panel against view.toggleReview. The entries document the pair
and let a user bind a dedicated chord; stepping is a no-op unless the bar is
open with a query, so a bound key can never search invisibly.

Listener-leak fixes

- The found-in-page bridge subscription is now refcounted in the store, so a
  remount (the connection re-home path remounts the global overlays) cannot
  stack duplicate subscribers that each re-dispatch the same result and
  outlive their component. The subscription is deliberately mount-scoped, not
  active-scoped: results for an in-flight search must still land if the bar
  just closed.
- `setFindQuery` now refuses to search a closed bar. The component clears its
  debounce on close, but a 200ms timer that already fired would re-issue a
  find and re-highlight the page after the user pressed Escape. Caught by the
  test, fixed in the store rather than papered over in the component.
- `closeFindBar` is idempotent — Escape is a shared gesture, so a second close
  must not reach into Electron again.

Pure logic extracted for testing (no source regexing)

- `src/lib/find-in-page.ts`: `formatMatchLabel` (three distinct counter
  states: hidden with no query, explicit 0/0, ordinal/count; clamps the
  ordinal and never emits NaN — Electron legitimately reports ordinal 0 on a
  non-final update), `findBarKeyAction` (the keybinding matcher, DOM-free),
  and `findBarClaimsCombo` (the ownership predicate above).

Also: match counter and buttons get accessible names and the counter is
aria-live, the hardcoded English "Previous"/"Next"/"Close" tooltips move to
i18n (en + zh) alongside the new keybind labels, and the input gets an
aria-label so the bar is reachable by role.

Tests: apps/desktop/src/components/find-bar.test.tsx — 42 cases over the
pure helpers, the store (open/close, next/prev dispatch shape, escape clears,
refcount, double-release), and the component (focus on open, debounce
coalescing, Cmd+G from outside the input, unmount releases both the bridge
subscription and the window listener).

  cd apps/desktop && npx vitest run src/components/find-bar.test.tsx \
    electron/find-in-page.test.ts
  -> 62 passed (42 new + 20 from the PR)

Adjacent suites (src/lib/keybinds, src/i18n, src/store): 487 passed.
`tsc -p tsconfig.electron.json --noEmit` clean; `tsc -p .` has 114 pre-existing
errors vs 120 on the merge base (all @assistant-ui / bippy / composer), none in
the touched files. eslint clean on every touched file.

Co-authored-by: David Metcalfe <DavidMetcalfe@users.noreply.github.com>
2026-07-26 17:48:05 -07:00
David Metcalfe
43e86ea50e feat(desktop): wire find-in-page in the renderer
Brings forward the renderer-side changes from PR #53891,
adapted to the current main branch (where app-shell.tsx
has been replaced by apps/desktop/src/app/contrib/wiring.tsx
and keybinds/actions.ts has gained new view.* entries):

- apps/desktop/src/store/find-in-page.ts (new): nanostores
  atom + actions for the find bar (openFindBar, closeFindBar,
  setFindQuery, findNext, findPrevious, updateFindResults,
  initFindInPageListener). openFindBar is dispatched by the
  view.findInPage keybind handler in use-keybinds.

- apps/desktop/src/components/find-bar.tsx (new): the find
  bar overlay (top-right, below the titlebar). Debounces
  input 200ms before issuing findInPage, focuses on open,
  supports Enter (next) / Shift+Enter (previous) / Escape
  (close), shows a "3/12" match counter from the
  'hermes:found-in-page' stream. Global capture-phase
  Escape listener so the bar closes regardless of focus.

- apps/desktop/src/lib/keybinds/actions.ts: adds
  view.findInPage with default combo 'mod+f'. The keybinds
  runtime already routes any mod+ / ctrl+ combo through
  editable-focus contexts (see comboAllowedInInput in
  lib/keybinds/combo.ts:193), so ⌘F focuses the find bar
  instead of typing 'f' into a textarea — matches browser
  behavior.

- apps/desktop/src/app/hooks/use-keybinds.ts: wires
  view.findInPage → openFindBar in the global handler map.

- apps/desktop/src/app/contrib/wiring.tsx: mounts <FindBar />
  alongside the other global overlays (CommandPalette,
  SessionSwitcher, etc.).

- apps/desktop/src/i18n/{en,zh}.ts: labels
  'view.findInPage' for the keybinds panel.

Closes #46169
2026-07-26 17:48:05 -07:00
David Metcalfe
7bbb063c71 feat(desktop): port find-in-page bridge to TypeScript Electron
The original PR targeted the CJS Electron files
(apps/desktop/electron/main.cjs and preload.cjs), but commit
39d09453f "feat(desktop): ts-ify everything" renamed them to
main.ts and preload.ts on current main. The PR's diff therefore
targeted files that no longer exist on main.

Brings the bridge forward to the current TypeScript Electron
files and extracts the IPC bridge helpers into a focused
pure-helpers module:

- apps/desktop/electron/find-in-page.ts (new):
  - performFind(webContents, query, options) — wraps
    webContents.findInPage with default-coercing options.
  - stopFind(webContents, action) — clears highlights.
  - formatFoundInPage(result) — pure projection of
    Electron's FoundInPageResult onto the wire payload shape
    ({ activeMatchOrdinal, count }).
  - installFoundInPageForwarder(webContents) — wires a
    sender-scoped 'found-in-page' forwarder; returns an
    uninstall function. Returns a no-op uninstall for null
    or destroyed webContents so callers don't need guards.

- apps/desktop/electron/main.ts:
  - ipcMain.handle('hermes:find-in-page', event => ...)
    resolves the requesting window via
    BrowserWindow.fromWebContents(event.sender) and routes
    the search to THAT window, not the global primary. This
    fixes a multi-window bug where Cmd+F pressed in a
    secondary session window (one per chat, spawned via
    hermes🪟openSession) searched the primary window
    instead of the focused surface.
  - ipcMain.handle('hermes:stop-find-in-page', event => ...)
    routes stopFind through the requesting window for
    multi-window correctness.
  - A per-sender lazy forwarder registry
    (foundInPageForwarders: Map<webContentsId, () => void>)
    installs installFoundInPageForwarder on first
    findInPage call, scoped to the sender's webContents.
    Cleans up automatically via webContents.once('destroyed',
    ...). The forwarder sends results back to the SAME
    renderer that initiated the search, never the global
    primary — so a secondary session window's Cmd+F shows
    matches from THAT window and the match counter reports
    matches from THAT window's DOM.

- apps/desktop/electron/preload.ts:
  - hermesDesktop.findInPage(query, options) — invokes
    the IPC handler.
  - hermesDesktop.stopFindInPage() — invokes the IPC
    handler.
  - hermesDesktop.onFoundInPage(callback) — subscribes to
    'hermes:found-in-page' results from the sender;
    returns an unsubscribe function so the FindBar can
    clean up on unmount.

- apps/desktop/src/global.d.ts:
  - Three new hermesDesktop method declarations:
    findInPage, stopFindInPage, onFoundInPage. The new
    forwarder install registers a 'found-in-page' listener
    bound to the sender's webContents and emits
    'hermes:found-in-page' results back to the sender.

The multi-window fix is part of the same port — the old
PR's behavior (Cmd+F in a secondary session window searched
the global primary) was a bug present in the CJS files,
not a design constraint we wanted to preserve. The new
helper module uses event.sender by design, so the
multi-window correctness lands with the TS port.

Fixes #46169
2026-07-26 17:48:05 -07:00
teknium1
e5d21e87cb fix(desktop): hydrate goal indicator on /goal set and controls, full locale copy
Follow-ups on the salvaged goal-status display (#63527):

- Seed the goal store from the /goal dispatch notice ("⊙ Goal set …") and
  from /goal status|pause|resume|clear exec output in slash.ts. The backend
  only emits status.update kind:"goal" after the first turn's post-turn
  judge, so without this the indicator stayed empty while the kickoff turn
  ran (sweeper review finding on #63527).
- Add the missing ja / zh-hant statusStack goal copy — desktop ships four
  locales, not two (sweeper review finding on #55651).
- Add a component-level vitest for the composer goal indicator rendering
  from store states: none / active / paused / detail line / other-session.

Co-authored-by: HaisamAbbas <95044189+HaisamAbbas@users.noreply.github.com>

Assisted-by: Claude Fable 5 via Hermes Agent
2026-07-26 17:47:38 -07:00
wjj1872744570-source
bb98595694 Add desktop goal status display 2026-07-26 17:47:38 -07:00
Brooklyn Nicholson
2109a1875e fix(desktop): stop dropping the prompt a mid-turn redirect corrected
redirectPrompt inserts its correction as a second user row just before the
live reply, so one turn can own a contiguous run of user rows. Three recovery
paths each assumed a turn has exactly one, and all three kept the correction
and discarded the prompt that started the turn:

- recoverableTail walked back to the nearest user row, so the crash journal
  never stored the original.
- preserveLocalPendingTurnMessages kept only the newest optimistic user row.
  Widened to the contiguous run — rows separated by an assistant reply are
  still dropped, which is the stale-post-compression case that rule exists for.
- appendLiveSessionProjection had no way to render corrections; it now projects
  them after the prompt, deduped against the transcript's latest user run.

Losing a row also shifted every later role:ordinal pairing in the reconcile,
which is why the thread looked like it compacted rather than just missing one
bubble. Reproducible on a reconnect and on a dev hot update, which remounts the
session cache while the gateway socket survives.
2026-07-26 19:44:42 -05:00
Brooklyn Nicholson
0f7492f43a refactor(sidebar): drop session counts and the COUNT(*) that fed them
The sidebar labelled sections and workspace lanes `loaded/total`, which
read as a progress bar people expected to fill up rather than a count of
loaded rows. Pricing that label cost a COUNT(*) per profile database on
every sidebar refresh, purely so the numerator and denominator could
differ.

Pagination only needs to know whether another page exists, and that comes
free from the rows the query already returned: a window that comes back
full means more remain on disk. Sections now show the loaded count alone,
and the backend reports per-profile `profiles_truncated` flags in place of
`total` / `profile_totals`.
2026-07-26 19:36:08 -05:00
brooklyn!
e2fbd0dcd7
Merge pull request #72303 from NousResearch/bb/desktop-session-status
fix(desktop): stop sidebar sessions from lying about whether they're running
2026-07-26 19:19:22 -05:00
brooklyn!
2b38d5ad59
Merge pull request #72308 from NousResearch/bb/tool-group-bounding
fix(desktop): stop reads and edits vetoing tool-call grouping
2026-07-26 19:13:22 -05:00