Commit graph

1569 commits

Author SHA1 Message Date
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
31385a0102 test(desktop): cover find-in-page helpers and multi-window routing
Vitest coverage for apps/desktop/electron/find-in-page.ts.
The helpers and the IPC handlers in main.ts are the only
consumers, so the tests pin the wire shape (match counter
shape, options defaults, no-throw-on-destroyed) and the
multi-window correctness that the original CJS PR missed:

- formatFoundInPage:
  - Maps { activeMatchOrdinal, matches } → wire payload.
  - Coerces missing fields to zero (the renderer never
    sees NaN).
  - Tolerates null / undefined inputs.

- performFind:
  - Forwards query + options to webContents.findInPage.
  - Defaults forward=true and findNext=false when omitted.
  - Treats null / non-object options as "all defaults".
  - Coerces a non-string query to string (defensive against
    a misbehaving renderer).
  - Is a no-op on null webContents.
  - Is a no-op on destroyed webContents (does not throw
    across the IPC boundary).

- stopFind:
  - Calls stopFindInPage with the default action
    ('clearSelection').
  - Honors an explicit action argument.
  - Is a no-op on null or destroyed webContents.

- installFoundInPageForwarder:
  - Forwards 'found-in-page' to the sender as a formatted
    payload.
  - Handles missing fields without throwing.
  - Skips send when webContents is destroyed at fire time.
  - Returned uninstall removes the listener.
  - Returned uninstall on null/destroyed webContents is a
    safe no-op.
  - Regression: two forwarders installed on distinct
    webContents do not cross-fire. This is the bug the
    original PR shipped — the global mainWindow listener
    routed results to the primary regardless of which
    renderer invoked findInPage. Pinning this here keeps
    the per-sender routing from regressing.

Run with:
  cd apps/desktop && npx vitest run electron/find-in-page.test.ts --project electron
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!
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
Brooklyn Nicholson
19d84d1071 test(desktop): pin the sidebar spinner's liveness contract
Investigating the missing-spinner report turned up no defect in the seeding
path: the active_list poll already lights a row for a turn the renderer never
saw start, holds it across polls, and follows a recycled runtime id onto its
new stored session. Pin all three so the reap change can't silently regress
turn-start while fixing turn-end.

Two boundaries worth naming rather than rediscovering:

- `starting` is deliberately NOT working. It means agent_build_started without
  agent_ready, and _start_agent_build runs on any incidental RPC that needs the
  agent — not just a prompt — so treating it as a turn would spin the row on
  merely opening a session.
- $workingSessionIds is keyed by STORED id and drops entries whose
  storedSessionId is null, while message.start flips busy without carrying one.
  A runtime that was never seeded with a stored id therefore goes busy
  invisibly. That is the remaining path by which a spinner can go missing.
2026-07-26 19:02:46 -05:00
Brooklyn Nicholson
058aa34d8a fix(desktop): stop reads and edits vetoing tool-call grouping
A run of 3+ adjacent tool calls collapses into the `.tool-group-scroll`
window, but `shouldBoundToolGroup` took `hasUnboundable` as a run-level
veto: a single exempt row anywhere in the range disabled collapsing for
the entire run. The exempt set was clarify, image_generate, execute_code,
read_file and every file-edit tool — which is most of what a coding
session does, so in practice runs never collapsed. Replaying a real
session's transcript: 84 consecutive calls, 30 of them veto-triggering,
zero windows.

The code-body entries were never needed. Everything ToolEntry renders
carries `data-tool-row`, and the `:has([data-tool-row][data-tool-open])`
rule already lifts the cap and the mask. A diff row mounts open, so it
frees the group the moment it appears; a collapsed row is a one-line
status whose body is not in the DOM at all, so there is nothing to clip.
Collapsing the row drops the group back to a compact window, which the
JS veto could not do.

Narrow the opt-out to the two components that bypass ToolEntry and so
can never emit `data-tool-row`: clarify and image_generate.
2026-07-26 18:49:05 -05:00
Brooklyn Nicholson
6b273f419a fix(desktop): keep thinking traces and tool calls across a mid-turn switch
preserveReasoningParts was gated on exact text equality with the cached row.
Mid-turn the authoritative text has advanced by a delta or two, so the guard
fails and the row is rebuilt from the gateway's inflight projection — which
is text-only. The renderer's cache is the sole carrier of a running turn's
structure, so switching away and back stripped the reasoning and every tool
call, leaving the turn looking inert.

Carry tool calls alongside reasoning, dedupe them on toolCallId, and match on
same-turn (identical text, or authoritative text extending the cached text)
rather than strict equality. Attachment refs and image re-appending stay on
the strict path: those reconcile a settled row, and a growing row is by
definition not settled.
2026-07-26 18:39:11 -05:00
Brooklyn Nicholson
42a30d13db fix(desktop): settle sessions that vanish from the live snapshot
session.active_list is authoritative about absence, but the renderer only
read the rows it returned. A turn that ends while the websocket is degraded
— a remote gateway on a flaky link, a reconnect, a profile swap — drops out
of the gateway's _sessions without Desktop ever seeing the running=false
edge, so the row spins forever and the busy->idle transition that paints the
green unread dot never fires.

Track live runtime ids per gateway profile and settle anything that
disappears between polls through publishSessionState so the real transition
fires. Profile scoping is load-bearing: background profiles are served by
other gateways and never appear in this profile's snapshot, so an unscoped
reap would dark out every other profile's running rows.
2026-07-26 18:39:03 -05:00
brooklyn!
bd6437d605
Merge pull request #72288 from NousResearch/bb/composer-undo
Give the composer its own undo stack
2026-07-26 18:38:11 -05:00
brooklyn!
2ec84c5d25
Merge pull request #72245 from NousResearch/bb/desktop-idle-churn
perf(desktop): stop the whole transcript re-rendering on sash drag
2026-07-26 18:36:00 -05:00
Brooklyn Nicholson
92f62bedd7 fix(desktop): extend the composer undo stack to the message edit composer
The edit composer pastes through the same `insertComposerContentsAtCaret` the
main composer uses, so it had the identical bug: the Range-based insert never
reaches Chromium's undo stack and Cmd+Z skipped past the paste to destroy the
edit before it. Its inline-ref and trigger-chip inserts mutate the DOM directly
too, with the same result.

Both surfaces now share `useComposerUndo`. The hook already keys its
document-level `beforeinput` claim off `document.activeElement`, so the two
mounted instances stay independent — only the focused editor's stack responds.
Undo/redo is handled ahead of Escape here, since a stray Cmd+Z falling through
would cancel the whole edit rather than step back one change.

`insertRefStrings` banks through `withUndoPoint` rather than recording after the
insert, which would have snapshotted the state it was meant to restore.
2026-07-26 18:21:50 -05:00
Brooklyn Nicholson
bc2ddf5bab fix(desktop): give the composer its own undo stack so Cmd+Z sees a paste
The rich editor mutates its DOM through `Range` rather than the browser's
editing commands, because `execCommand('insertText')` is ~O(n²) on large
multiline blobs and froze the composer for seconds on a big paste (#45812).
Those mutations never reach Chromium's undo stack, so a paste was invisible to
it — Cmd+Z skipped straight past the pasted text and undid whatever edit came
before it, leaving the paste stranded and the earlier edit destroyed.

Owning the stack outright is the only coherent fix; a half-owned one interleaves
our snapshots with Chromium's own typing entries and undoes them out of order.
Every edit path now banks its pre-edit state, and the editor claims Cmd+Z /
Cmd+Shift+Z (plus Ctrl+Y) instead of letting the native command run. Snapshots
are plain text + a caret offset rather than DOM, since the editor already
round-trips losslessly through composerPlainText/renderComposerContents.

Consecutive keystrokes coalesce inside a 600ms window, so undo steps back by a
typing burst the way a native editor does rather than one character at a time.
Electron's Edit menu `{ role: 'undo' }` fires the native command without a
keystroke the renderer can see, so a capture-phase `beforeinput` listener claims
historyUndo/historyRedo too and keeps the menu item and the shortcut in
agreement. Switching drafts resets the history — undoing into another
conversation's text is worse than having none.

Co-authored-by: David Metcalfe <80915+DavidMetcalfe@users.noreply.github.com>
2026-07-26 18:21:35 -05:00
Brooklyn Nicholson
0226d1162e Revert "perf(desktop): mount tooltips lazily"
Reverts the tooltip half of 4798994dc; keeps the idle-cost scenario.

Lazily mounting Radix on first hover measured well (105k -> 26k
TooltipProvider renders per drag) but broke 18 tests across 12 files.
Those tests are not incidental: the repo has an established convention of
asserting `[data-slot="tooltip-trigger"]` at mount to prove a control
carries a tooltip, and deferring the mount invalidates all of them at
once. There is also a real behavior risk the convention was protecting —
`asChild` puts the slot on the button element itself, so arming REPLACES
the node, which is exactly the kind of identity change that breaks focus
restoration and ref-holding call sites.

A 4x cut in tooltip churn is not worth reworking every tooltip assertion
in the app plus taking that risk, on a component with ~107 call sites.
If it's worth revisiting, the right shape is probably making
TooltipProvider itself cheap (one app-level provider) rather than
deferring the mount per call site — that preserves the DOM contract these
tests encode.

The genuine win in this branch stands on its own: the $layoutTree
subscription fix (commits 83 -> 12 on a sash drag) is unaffected.
2026-07-26 18:00:02 -05:00
Brooklyn Nicholson
8896fc7500 fix(desktop): stop a chip inserted after a word from swallowing its space
`plainTextInRange` serialized the caret's preceding content through a bare
<div>, but `composerPlainText` appends "\n" to any block element that isn't the
editor slot. So `beforeText` always looked like it ended in whitespace and the
separating space was never inserted — dragging a file in after a word produced
`review@file:...` glued together.

Marking the scratch container with RICH_INPUT_SLOT makes it serialize in the
same coordinates as the editor. Same fix lands in the new `caretOffsetInEditor`,
which measures caret offsets the same way.
2026-07-26 17:50:18 -05:00
brooklyn!
48bdde1deb
Merge pull request #72230 from NousResearch/bb/bootstrap-marker-triage
fix: make the bootstrap-complete marker consistent across every install path
2026-07-26 17:48:57 -05:00
Brooklyn Nicholson
f64b719321 perf(desktop): cache short markdown parses too
parseMarkdownIntoBlocksCached bypassed its cache for text under 1024
chars, on the theory that re-lexing a short message is cheap. The lex is
cheap; what it returns is not free. `parseMarkdownIntoBlocks` builds a
fresh array every call (verified in streamdown's dist: `let r=[]` ...
`return r`), and Streamdown mirrors the block list into useState — so a
new array identity for UNCHANGED text re-renders Streamdown and every
Block beneath it.

Most messages are short, so most of the transcript was on the uncached
path. Caching every length cuts the idle cost of five mounted tiles:

  Streamdown   5.2ms -> 2.6ms
  Block        128ms -> 85ms
  Ct           122ms -> 81ms

Cache bumped 64 -> 256 entries to cover the now-larger key space.

This does NOT reduce Streamdown's 105 idle self-renders — array identity
turned out not to be what drives those, and I verified the cache returns
a stable identity, so that root is still open. This is a cost win, not
the churn fix.
2026-07-26 17:02:59 -05:00
Brooklyn Nicholson
edadfcae96 perf(desktop): stop every zone subscribing to the whole layout tree
TreeGroup called useStore($layoutTree) to build its right-click menu's
move/split directions. That subscribes every zone — and therefore every
mounted pane and its entire transcript — to the whole layout tree. A sash
drag rewrites the tree once per frame, so dragging the sidebar re-rendered
all five tiles' message lists on every pointermove, for a context menu
nobody had open.

The directions are only read when the menu renders, so read the tree there
with .get() instead. Same lazy shape the neighbouring `closable` prop
already uses.

Measured over one 60px sash drag with five busy tiles:

  commits          83 -> 12
  ChatView        150 -> 10   (4465ms -> 353ms)
  AuiProvider    9450 -> 630  (9868ms -> 774ms)
  TreeGroup       180 -> 12
  TreeSplit        90 ->  6

Also fixes an observer effect in the harness: idle-cost recorded render
attribution *during* the timed gesture, and the counter walks the fiber
tree on every commit. That was large enough to hide this 15x reduction
behind an unchanged fps, so timing and attribution are separate passes now
and `record` defaults off.

Adds scripts/diag-drag-churn.mjs — the probe that found this. It reports
the transcript chain (who above the messages re-rendered) plus every atom
that notified, which is what named TreeGroup instead of leaving it to be
guessed at. Notably the atom list came back EMPTY: this was never store
churn, so the render-attribution path was the only thing that could have
found it.
2026-07-26 16:53:20 -05:00
Brooklyn Nicholson
f18d9b2843 fix(desktop): make idle-cost's drag actually drag
The synthetic gesture oscillated +/-3px, which nets to zero displacement
and can clamp to a no-op — so it reported a confident fps number for a
drag that never moved the sash. Sweeps monotonically now, dispatches
pointer events React's synthetic system accepts (isPrimary/button/buttons),
and records dragTarget + dragMoved so a drag that silently did nothing is
visible in the output rather than passing as a measurement.

Verified: dragMoved now reports 60px where it previously reported 0.
2026-07-26 16:36:42 -05:00
Brooklyn Nicholson
4798994dce perf(desktop): mount tooltips lazily, and measure the idle cost
Adds an `idle-cost` scenario for the symptom Brooklyn reported: with a
thread spinning, resizing the sidebar feels slow. It holds N tiles busy,
pushes NO tokens, and measures the renderer's self-inflicted commit rate
plus fps while dragging the splitter and while typing.

It reproduces immediately. Five busy tiles, nothing streaming:

  idle commits   17.7/sec   (should be 0 — nothing is happening)
  drag           1.4 fps    p95 812ms, worst frame 1.9s
  typing         61 fps     (fine — this is specific to resize)

Attributing the drag window showed 105,385 TooltipProvider renders and
~15s of component time across a 60-frame gesture. Cause: `Tip` mounts a
full Radix provider + Tooltip per call site, and there are ~107 of them.
Radix's Tooltip holds real state and Popper subscribes to layout, so an
unrelated interaction re-rendered all of them.

Mounts the machinery lazily instead, on first hover/focus. Tooltip churn
drops ~4x (105k -> 26k) and drag doubles to 3fps.

Note `defaultOpen` on the armed Tooltip is load-bearing: the pointerenter
that armed it has already fired, so Radix never sees it and the tip mounts
silently closed. A test caught exactly that, and now guards it.

3fps is still bad — the remaining cost is the whole transcript
re-rendering per resize frame (MessagePrimitive.Parts 12,600 renders /
10.5s, Block/Ct 24,300 each, all 100% wasted). Separate fix.
2026-07-26 16:36:42 -05:00
brooklyn!
79b4a40568
Merge pull request #72220 from NousResearch/bb/tool-row-dedupe
fix(tools): stop the inline tool row stuttering its verb and repeating the command
2026-07-26 16:25:50 -05:00
Brooklyn Nicholson
53707e405c fix(desktop): stop repair from deleting a healthy install's marker
Repair signalled "reinstall me" by deleting the bootstrap marker. That was
already destructive -- repair is reachable from a transient backend error on a
fully working install -- and it stranded users in first-run setup with no way
back short of hand-writing the marker file.

Carry the intent in an explicit flag instead. Repair forces the next resolve
through the installer and clears itself once the reinstall starts, so a forced
reinstall still works without destroying provenance about how the install was
created.

Closes #72166
2026-07-26 16:18:01 -05:00
Brooklyn Nicholson
048e9b2150 fix(desktop): launch a usable active runtime without a bootstrap marker
Marker presence was the launch gate, but the marker is provenance about who
ran the install -- not proof the runtime works. A CLI-installed repo+venv, or
a healthy install whose marker a repair deleted, both read as "never
installed" and dropped the user into first-run bootstrap on every launch.

Split the two questions: classifyActiveRuntime() reports marker validity and
runtime usability separately, and the resolver launches whenever the runtime
is usable, logging when it proceeds without a marker. An unusable runtime
still falls through to bootstrap even with a valid marker, so an interrupted
install can't spawn a dead backend.

Drops isBootstrapComplete(), which had no callers left once the gate moved.

Co-authored-by: iveywest <iveywest@users.noreply.github.com>
Co-authored-by: lihengming <lihengming@users.noreply.github.com>
2026-07-26 16:12:14 -05:00
Brooklyn Nicholson
ec6719b04e fix(desktop): stop the tool row repeating its command three times
The expanded terminal row printed the same string as the title, as the
`$` transcript, and again as detail. shellCommand preferred the
backend's display preview over the real `command` arg, so the transcript
showed a summary of what ran rather than what ran; and a terminal call
with no output fell through to the generic fallback, which echoes
args.context under a transcript already showing it.
2026-07-26 16:03:49 -05:00
brooklyn!
f695ce3461
Merge pull request #72212 from NousResearch/bb/desktop-transcript-renders
fix(desktop): make render-churn measure streaming, not boot churn
2026-07-26 16:00:17 -05:00
Brooklyn Nicholson
f7aee9dc8c fix(desktop): make render-churn measure streaming, not boot churn
Two problems, both found by distrusting the harness's own numbers.

1. The scenario slept a fixed 1s after mounting tabs, then recorded. Boot
   and session hydration are not reliably done by then, so a variable
   amount of unrelated work landed inside the measurement window. Three
   back-to-back runs on identical code spread 2.2x on total_renders and
   3.8x on wasted_renders — wide enough that a single-run before/after
   delta could be mostly noise. Replaced with a quiesce gate that waits
   for commits to hold still before recording, and reports 'quiet:N' or
   'timeout:...' so a contaminated run is visible instead of silent.

2. The counter attributed a context-driven re-render as 'wasted', which
   pointed at memo() as the fix when memo cannot block context at all.
   Adds contextChanged via the fiber's context dependency list, and
   excludes it from wasted.

The gate also turned up a finding worth more than the fix: with five busy
tiles and NO driver running, the renderer still commits ~18x/sec. The
report now names the cascade roots (own state changed, props did not)
rather than leaving them to be guessed at — Streamdown re-renders itself
105 times while idle, which is what drives Block/Ct.
2026-07-26 15:54:18 -05:00
Brooklyn Nicholson
adfaa95e3b feat(desktop): chip a link pasted or typed into the composer
A pasted link went in as raw URL text, wrapping across the composer and
staying inert. It now becomes the same `@url:` reference the "+ → Add URL"
dialog inserts — parsed in place, so a link mid-sentence keeps its position
and the punctuation that ended the sentence stays outside the chip. Typing
one and pressing space commits it the same way.

Both rich-editor surfaces get it: the composer and the message-edit box,
whose paste went through `execCommand` and could not produce a chip at all.
2026-07-26 15:27:06 -05:00
Brooklyn Nicholson
456b2f9c7d refactor(desktop): give the rich editor a chip-aware caret insert
`insertPlainTextAtCaret` dropped its text in verbatim, so a caller with
directives in hand had no way to land them as chips. It becomes
`insertComposerContentsAtCaret`, sharing the parse `renderComposerContents`
already uses, and gains a sibling `replaceBeforeCaret` for swapping a
just-typed token for a chip.
2026-07-26 15:23:51 -05:00
Brooklyn Nicholson
ee09162e45 fix(desktop): label a url chip with its host and path
The composer labeled a `@url:` chip with `refLabel`, which takes the last
path segment — three PR links all read as their number. The transcript had
its own better labeler that stopped at the hostname, so the same reference
read differently before and after send.

One `refChipLabel` now serves both: host without `www.`, path riding along
for the chip's existing truncate to cut, and the full value on the chip's
title so a cut-off link is still readable on hover.
2026-07-26 15:23:51 -05:00
Brooklyn Nicholson
41dd895e12 perf(desktop): stop the statusbar re-rendering per streaming token
The statusbar subscribed to `$focusedSessionState` — a projection of
`$sessionStates`, which is republished on every message delta — but reads
only three fields off it. Every token therefore re-ran useStatusbarItems
and rebuilt all ~9 item objects, and since StatusbarItemView was not
memoized, one changed item (the running timer) re-rendered the whole bar.

Adds `useStoreSelector` beside the existing `useSessionSlice`, the same
narrowing idea for a scalar instead of a keyed array: subscribe to the
store, but bail out unless the selected value changes. Applies it to the
three fields the statusbar actually reads, and memoizes StatusbarItemView.

Measured over five concurrent streaming tabs (`render-churn`):

  total renders   78,385 -> 21,701   (-72%)
  wasted renders  10,432 ->  6,112   (-41%)
  TooltipContent   2,304 ->    143   (-94%)
  StatusbarItemView 2,174 ->      0
2026-07-26 14:21:34 -05:00
brooklyn!
41f2196c53
Merge pull request #71925 from NousResearch/bb/desktop-state-diagnostics
feat(desktop): state diagnostics — render + store churn counters
2026-07-26 14:12:16 -05:00
Brooklyn Nicholson
37ac8ae76a feat(desktop): render-churn perf scenario
Drives the same synthetic multi-tab streaming workload as `multitab`
(publishSessionState per session per flush, no backend, no credits) but
reports render attribution instead of frame pacing — sidebar_renders,
wasted_renders, and wasted_notifies.

Answers 'does the sidebar re-render while an agent is typing' directly.
2026-07-26 06:11:00 -05:00
Brooklyn Nicholson
44211f3608 feat(desktop): dev-only render + store churn counters
Adds two dev-only counters that attribute re-renders and store
notifications during an interaction, so a perf claim can be answered with
a number instead of a hunch:

  window.__RENDER_COUNTS__  what re-rendered, and why (props/state/parent)
  window.__ATOM_CHURN__     which store published it, and whether it mattered

The `wasted` column in each is the fix list — components that re-rendered
with no changed input, and stores that published a value equal to the
last one. Both are inert until start(), so idle cost is one branch per
commit and per notify.

React 19.2 removed injectProfilingHooks from react-dom, so the mark*
profiling family is unavailable and onCommitFiberRoot is the only channel
left. <Profiler> can't answer the question either: React invokes onRender
for every Profiler in a committed tree including subtrees that bailed
out, and a bailed-out subtree still reports nonzero actualDuration. This
uses bippy's didFiberRender instead. bippy over react-scan because
react-scan/lite is a thin wrapper over it while the package pulls ~217
transitive deps and floats two on latest.

main.tsx imports the entry statically above react-dom because react-dom
captures the devtools hook at module init — a late install reports
renderers=0 and observes zero commits. Production exclusion is handled by
a build-time alias to a no-op module rather than tree-shaking, since a
static side-effect import can't be eliminated.
2026-07-26 06:10:54 -05:00
brooklyn!
eb52760564
Merge pull request #71901 from NousResearch/bb/session-click-active
fix(desktop): clicking the active session from a full page returns to the chat
2026-07-26 05:32:43 -05:00
hermes-seaeye[bot]
529ae164ae
fmt(js): npm run fix on merge (#71897)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-07-26 10:27:29 +00:00
Brooklyn Nicholson
ecf8ef970e fix(desktop): clicking the active session from a full page returns to the chat
While the workspace pane shows a full page (Artifacts, Skills, Messaging, a
plugin route), a sidebar click on the ACTIVE session did nothing: onResumeSession
took focusOpenSession's `true` for the main-session branch as "already on
screen" and skipped the navigate, but fronting the workspace tab doesn't put the
chat back — the page is still routed. The user had to click some other session
and then the active one to get back.

focusOpenSession now reports WHICH surface it fronted ('main' | 'tile' | null),
and focusedSessionNeedsRoute decides: a tile never needs a route (its pane
renders the chat regardless), a main hit does while a page covers the workspace.
2026-07-26 05:26:26 -05:00
brooklyn!
3b9bd0de6d
Merge pull request #71848 from NousResearch/bb/sidebar-plus-tab
Open a tab from the sidebar "+" when a chat is already loaded
2026-07-26 05:19:51 -05:00
hermes-seaeye[bot]
af217e444b
fmt(js): npm run fix on merge (#71892)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-07-26 10:16:26 +00:00
brooklyn!
080ee077a8
Merge pull request #71891 from NousResearch/bb/slash-tab-target
fix(desktop): send a skill's kickoff into the tab that invoked it
2026-07-26 05:15:16 -05:00
Brooklyn Nicholson
f2f5b32531 fix(desktop): expect keep-alive tabs not to repaint on reactivation 2026-07-26 05:11:46 -05:00
Brooklyn Nicholson
2a6368f041 fix(desktop): send a skill's kickoff into the tab that invoked it
`/work` typed into a fresh Cmd+T tab loaded the skill in that tab and
printed " loading skill: work" there, then fired the skill's kickoff
prompt as a user message into whatever conversation was on screen.

The dispatcher resolves its target once, through resolveTargetSessionId,
and every other consumer of that answer already honors it: the output
writer binds to the target's stored id, and the busy gate reads the
target's own state. The send did not — `submitPromptText(message)` passed
no target at all, so submit fell back to `activeSessionIdRef`, which
names the foreground chat. #71805 fixed the two sibling leaks in this
same function; this is the third and the one that actually moved the
user's prompt.

Forward the resolved pair instead. Every target the dispatcher serves —
a tile, a background queue drain, a session this very call created —
was hitting the same fallback, so the fix covers the class rather than
the tab case that surfaced it.
2026-07-26 05:08:37 -05:00
brooklyn!
02721cc1c0
Merge pull request #71872 from NousResearch/bb/desktop-ui-polish
Desktop UI polish: link chips, sidebar arc, tab strip rule
2026-07-26 05:08:08 -05:00
Brooklyn Nicholson
9947a6065b fix(desktop): align external-link icon tests with default-off 2026-07-26 05:00:53 -05:00
Brooklyn Nicholson
76e9ac3915 fix(desktop): preserve correction order in session tabs 2026-07-26 05:00:18 -05:00