Commit graph

1558 commits

Author SHA1 Message Date
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
Brooklyn Nicholson
8d243c8afa fix(desktop): put the technical tool payload behind a chevron
Technical mode rendered the raw payload two different ways — a bare
block for most rows, a native `<details>` for file edits, whose
browser-drawn marker matches nothing else in the app. Both are now one
collapsed chevron disclosure at a smaller type size, with even padding
against the row body.
2026-07-26 04:25:32 -05:00
Brooklyn Nicholson
f2ac03196f fix(desktop): restore the pane tab strip's bottom rule
The strip's rule is an inset shadow painted in the container's last pixel
row, and full-height tabs covered it — so each tab read as overhanging
the bar by 1px. Inactive tabs compensated with their own border, stacking
a second translucent line that darkened the seam.

Inactive tabs now stop 1px short and draw no bottom border, leaving the
container as the sole owner of one continuous rule; the active tab keeps
full height so it alone cuts through. Hover also darkens rather than
lightens, since lightening moved a hovered tab toward the active
surface's look.
2026-07-26 04:25:29 -05:00
Brooklyn Nicholson
6b816ad8c3 fix(desktop): make the running-session arc legible in the sidebar
The arc reduced to a few faint dots on session rows. Two causes: the ring
was outset by 2px into a scroller that clips horizontally, losing its
left and right runs; and its tail color defaults to the chrome
background, which is invisible against the sidebar, leaving only the
bright stop of each gradient pass.

An `arc-row` variant sits flush and ties the tail back to the ring
color. The ring's radius is now derived from its standoff (r_host + gap)
rather than inherited, which keeps any outset host concentric instead of
pinched.
2026-07-26 04:25:25 -05:00
Brooklyn Nicholson
6893faf472 feat(desktop): style inline links as tinted chips
Content links read as a small primary-tinted chip instead of an
underline, dropping the trailing external-link arrow. The tint is
currentColor-relative, so one class carries text and fill in the same
hue across every theme, and `box-decoration-break: clone` gives a
wrapped link a chip per line fragment.
2026-07-26 04:25:16 -05:00
hermes-seaeye[bot]
953707103f
fmt(js): npm run fix on merge (#71863)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-07-26 09:04:27 +00:00
Brooklyn Nicholson
fc39c7ac31 test(desktop): scope e2e transcript helpers to the active chat surface
The sidebar "+" now stacks a tab instead of replacing the surface, so the
prior session stays mounted and several chat surfaces can be on the page at
once. Helpers that waited for the old transcript to disappear from the page
timed out, and `.first()` locators / bare `document.querySelector` calls
started resolving against the wrong session (CI's "resolved to 2 elements"
strict-mode violation).

Target the most recently mounted `[data-composer-target]` surface instead,
and assert the NEW surface is empty rather than waiting for the old text to
vanish.
2026-07-26 03:57:49 -05:00
brooklyn!
4dae897265
Merge pull request #71835 from NousResearch/bb/stream-history-cost
perf(desktop): make streaming cost independent of transcript length
2026-07-26 03:56:05 -05:00
brooklyn!
de9196ed0c
Merge pull request #71836 from NousResearch/bb/stream-foreground-leak
Render the workspace pane from its own session slice
2026-07-26 03:55:52 -05:00
brooklyn!
d9f1043c33
Merge pull request #71840 from NousResearch/bb/status-stack-scope
Scope measured-height vars to each chat surface
2026-07-26 03:26:39 -05:00
Brooklyn Nicholson
bd86ce9938 feat(desktop): open a tab from the sidebar "+" when a chat is loaded
The sidebar "+" ran startFreshSessionDraft, so it replaced whatever was on
screen — ⌘N behavior. With a conversation already open (possibly mid-turn)
that discards it to make room for a blank draft, which is not what a create
affordance should do. The tab-strip "+" and ⌘T already stack a new tab.

Route the sidebar button through the same path once a session is loaded,
falling back to the fresh-draft path when the surface is empty and there is
no tab worth preserving. openNewSessionTile now takes an optional cwd so the
new tab stays anchored to the clicked project/worktree lane.
2026-07-26 03:22:09 -05:00
hermes-seaeye[bot]
2686cfa5b4
fmt(js): npm run fix on merge (#71845)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-07-26 08:20:35 +00:00
Brooklyn Nicholson
e7f4b95b47 refactor(desktop): tighten the surface-var helper
Fold the null fallback into chatSurfaceRoot so both callers share one
target resolution, hoist the var names next to it, and drop the
duplicated rationale from the status-stack comment.
2026-07-26 03:20:12 -05:00
Brooklyn Nicholson
20fcef5207 perf(desktop): add a stream-history scenario to the perf harness
The existing `stream` scenario measures streaming into an empty thread, which
is exactly the case where per-delta transcript work does not show up. This adds
`--historyTurns`, which mounts a settled transcript and lets it drain BEFORE
the recorders start, so the measurement window contains streaming work only —
and `stream-history`, a report-only preset that turns it on.

Report-only (tier: manual) rather than gated: how much history a host can mount
varies, so the absolute number is not comparable across machines. It is meant
for same-machine before/after runs.

Co-authored-by: Jakub Wolniewicz <frizikk@users.noreply.github.com>
2026-07-26 03:15:48 -05:00
Brooklyn Nicholson
ad09bf3872 fix(desktop): flush stream deltas from a timer, not an animation frame
Once the coalescing floor had already elapsed, the next delta was scheduled
with requestAnimationFrame. Chromium pauses rAF for a renderer it considers
hidden, and that is not something this code can verify: backgroundThrottling:
false and the process-level switches in electron/main.ts cover the blurred and
occluded cases, but not a minimized window, a fully off-screen one, or a
renderer the compositor has otherwise parked. In those states the callback is
accepted and never runs, so a finished answer sits in the queue until some
later focus or input event happens to wake a frame — the reply looks stalled,
then lands all at once on refocus.

Always use a timer. The coalescing cadence is unchanged (the floor above is
what enforces it), timers are clamped rather than suspended in background
renderers, and disable-background-timer-throttling already opts out of that
clamp. The teardown path loses its cancelAnimationFrame branch with it.

The regression test parks rAF the way an occluded renderer does and asserts
the delta still arrives. It has to send a delta, let it flush, then idle past
the floor before the delta under test, because the frame-gated branch was only
reachable on that second scheduling — a single-delta version of this test
passes on main and proves nothing.

Co-authored-by: NetRunner2037 <rerdi92@users.noreply.github.com>
2026-07-26 03:15:44 -05:00
brooklyn!
1aed1f7bf4
Merge pull request #71812 from NousResearch/bb/memory-tool-card
fix(desktop): stop painting healthy tool rows as errors
2026-07-26 03:12:25 -05:00
Brooklyn Nicholson
c52bc5ec95 fix(desktop): scope measured-height vars to each chat surface
The composer and the out-of-flow status stack published their measured
heights onto document.documentElement, but both components mount once per
chat surface. Session tiles render a full ChatView beside the workspace
pane, so N surfaces raced for one value: a background tab with a tall
status stack inflated the foreground thread's bottom clearance and pushed
its jump-to-bottom button into mid-screen. Whichever surface unmounted
last also cleared the var for everyone still showing.

Publish onto the surface's own root instead, and re-declare the clearance
calc there — `:root` substitutes the root measurements once, so scoping
only the inputs would leave every thread reading the same value.
2026-07-26 03:05:30 -05:00
Brooklyn Nicholson
c489480cbb fix(desktop): render the workspace pane from its own session slice
The workspace pane read the global $messages/$busy atoms — a mirror of
whichever session was active — while every ⌘T tile rendered from its own
$sessionStates slice. With two turns in flight, navigating away from a
still-streaming session left it painting into the surface now showing a
different conversation: the wrong transcript under the right route.

Point the primary view at the active session's own slice, keeping the
global atoms as the draft surface for a chat that has no runtime id yet.
2026-07-26 02:58:52 -05:00
Brooklyn Nicholson
adf47ca832 perf(desktop): reconcile only the messages that actually moved
syncRepositoryIncrementally rewrote the entire transcript on every adapter
snapshot: one addOrUpdateMessage per message, a second full export to find
deletions, and an unconditional resetHead. During streaming that fires ~30x a
second, so the per-delta cost scaled with how long the conversation already
was — the reported symptom.

Now that settled messages keep reference identity, an identity check is a
sound "did this change?" test. Write only the items whose message or parentId
moved, and skip resetHead when the head did not move (it prunes the head's
descendants, so calling it needlessly is not free). Anything the fast path
cannot prove safe — a disjoint session swap, a changed message count, an id
with no repository entry — falls through to the original full rebuild.

Tests cover the perf contract (one write for one delta, zero for a no-op) and
the correctness cases the fast path must not break: appends, authoritative
deletion, disjoint session replacement, branch re-parenting, and an explicit
headId rewind. The two perf assertions fail on main.

Co-authored-by: Jakub Wolniewicz <frizikk@users.noreply.github.com>
2026-07-26 02:56:08 -05:00
Brooklyn Nicholson
beb3f566ba perf(desktop): stop re-normalizing the transcript on every stream delta
useRuntimeMessageRepository keeps a WeakMap of converted messages so a
settled turn converts once. Building the export with
ExportedMessageRepository.fromBranchableArray threw that away again: it maps
the whole array through fromThreadMessageLike on every call, so each streamed
delta re-normalized the entire settled history and handed the runtime a fresh
object for every message.

Normalize on the cache miss instead, using the same fallback status
fromBranchableArray applies, and build the export literal directly. Settled
turns now keep reference identity across deltas, which is what lets the
reconcile below tell that only the tail moved.

Co-authored-by: Jakub Wolniewicz <frizikk@users.noreply.github.com>
2026-07-26 02:56:04 -05:00
Brooklyn Nicholson
87ee6a52fa fix(desktop): count output_preview as command output
The nonzero-exit guard already knew a failing exit code with real output
usually isn't a failure worth painting red. It only looked at output /
stdout / stderr, but background-process polls report their text under
output_preview — so a poll of any process that exited nonzero rendered
destructive-red with no error to show, even when the output was routine
npm chatter. Half the red process rows in a real session history were
this case.
2026-07-26 02:23:25 -05:00
hermes-seaeye[bot]
a97b6ff8f6
fmt(js): npm run fix on merge (#71818)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-07-26 07:20:08 +00:00
Hermes Agent
21a2185f86 fix(desktop-e2e): poll for the finished-unread dot instead of sampling once
Sentinel-released processes exposed a second bare-sample assertion in the
same file. The unread-dot check ran a synchronous .count() 140ms after the
running dot cleared:

  15.91s  poll "dot should disappear" -> 2
  16.05s  ... -> 0  (running dot gone)
  16.05s  bare .count() for unread dot -> 0  FAIL

"Finished — unread" is an event-driven transition that lands just after the
running dot clears. The old fixed `sleep 5` happened to leave enough slack
between the two that a single sample usually caught it; releasing the
process deterministically removed that incidental slack and made the latent
race deterministic instead.

Poll for it, matching how sidebar-states.spec.ts already asserts this exact
dot. The split-tile assertion at line 234 stays a bare sample on purpose —
it asserts an absence (toBe(0)), where polling would only wait for something
that must never appear.
2026-07-26 00:12:36 -07:00
Hermes Agent
3a3bc41c7e fix(desktop-e2e): end the sidebar background-dot wall-clock race
The cross-session sidebar specs asserted a state that could expire before
they looked at it, making them the flakiest tests in the suite — two reds
on unrelated PRs within three minutes on 2026-07-26.

Root cause, from the failing run's trace: the tests need a background
process that is still RUNNING after the agent turn finishes, but the
process was a fixed `sleep 5` racing two other clocks — the turn itself
(two model round trips plus a real subagent delegation) and the 4s
success linger before a finished task auto-dismisses. On a loaded runner
the "dot should appear" poll took 7.5s to see the dot; by then `sleep 5`
had already exited, `waitForFunction(finalText)` returned in 0.08s
because the turn was long done, and the next line — a bare synchronous
`.count()`, not a wait — sampled 0.

The process lifetime is now test-controlled: `createBackgroundReleaseHandle()`
mints a sentinel path, the scripted command blocks until that file
appears, and the test releases it exactly when it wants the dot to clear.
One clock instead of three, and the "turn done, process still running"
state is stable rather than a window to catch. The wait is bounded (60s)
so a forgotten release can't hang a worker, and `sleep 5` stays as the
default for callers that pass no handle.

No product code touched — E2E harness only.
2026-07-26 00:12:36 -07:00
brooklyn!
6deb92df52
Merge pull request #71800 from NousResearch/bb/project-sort
fix(desktop): sort projects by real activity and record terminal session cwd
2026-07-26 02:06:00 -05:00
Brooklyn Nicholson
717746ecf8 feat(desktop): give memory tool rows a title and glyph
Memory rows fell back to the auto-derived name, so they read as a bare
"Memory" / "Running memory" next to a generic icon. Add the tool to
TOOL_META with proper copy across all five locales, plus a solid brain
glyph in the existing Phosphor fill set.
2026-07-26 02:00:01 -05:00
Brooklyn Nicholson
6192d3bb57 fix(desktop): stop painting memory writes as errors
A memory tool call rendered destructive-red with an alert glyph and, when
expanded, a raw dump of the whole args + result payload.

Two causes. The red came from toolStatus treating any error text as a
failure: the memory store rejects an over-budget batch so the agent can
retry a smaller one, which is routine bookkeeping, not something the user
acts on. Those now land on the existing amber warning tier.

The payload dump came from memory having no case in toolSubtitle or
toolDetailText, so both fell through to the generic stringify-everything
fallback. Both now surface just the human-readable line.
2026-07-26 01:59:54 -05:00
brooklyn!
6ffd7302bf
Merge pull request #71789 from NousResearch/bb/timeline-idle
perf(desktop): stop the thread timeline working when nothing can see it
2026-07-26 01:58:26 -05:00
brooklyn!
d471fc9560
Merge pull request #71799 from NousResearch/bb/model-picker-perf
perf(desktop): faster model picker open + idle CPU on stacked tabs
2026-07-26 01:51:20 -05:00
Brooklyn Nicholson
428c909d28 fix(desktop): bind a tab's slash command to its own session
A slash command in a ⌘T tab or split pane routes through the primary
chat's dispatcher, which read the FOREGROUND view's identity for two
decisions it had no business asking the foreground about.

Busy: the gate read `busyRef`, a mirror of whatever chat is on screen.
A brand-new tab with zero turns was told "session busy — message queued"
because an unrelated chat was mid-stream, and the converse let a
background send fire into a live turn.

Identity: the output writer bound to the foreground's stored session, so
a tab's transcript writes re-keyed its cache entry onto the primary's
stored id and its queued payload landed on the primary's queue — the
kickoff would then drain into the wrong conversation. `submitText` also
dropped an explicit target when it routed to a slash command, running a
queue drain's command against whatever was in front.

Read the target session's own published state for both. One shared
resolver so submit and slash cannot drift apart again.
2026-07-26 01:44:03 -05:00
Brooklyn Nicholson
7ef3f1407b fix(desktop): gate the slash/submit busy queue on the target session
A slash command runs against the session `resolveTargetSessionId` picks,
which is routinely not the session on screen — a tile, a route rebind, or
a session created by the call itself. Both prompt pipelines gated on
`busyRef`, the FOREGROUND view's busy flag, so one session's send was
gated on another session's turn: a stale foreground `true` (a warm resume
of a still-running chat leaves one behind) parked an idle session's
command on the composer queue and reported "session busy" about a session
doing nothing. The converse also leaked — a background send could fire
mid-turn while the foreground happened to be idle.

Read the published per-session state instead, falling back to the
foreground flag only when the target has no state yet (a just-minted
session whose first publish hasn't landed). One shared resolver so submit
and slash cannot drift apart again.
2026-07-26 01:39:56 -05:00
Brooklyn Nicholson
bf726a7ab8 fix(desktop): drop the fade mask on the live thinking preview
The preview window masked its top 28% to transparent while reasoning
streamed, so a gradient appeared over the thinking text and vanished the
moment the block finished. Remove the mask; the max-height window and
bottom-pinned scroll still keep the preview compact and following the
newest tokens.
2026-07-26 01:21:47 -05:00
Brooklyn Nicholson
19025eb7ce fix(desktop): rank projects by real activity, not disk-scan time
The sidebar's project overview put git checkouts with zero Hermes sessions
above the repos the user actually works in, and dragging a project into place
did not stick.

Two causes. The repo discovery payload folded `discovered_repos.last_seen`
into `last_active`, but `last_seen` is when the disk scan last saw the
directory, so every scanned checkout was stamped "just now" and outranked
real work. Activity is now session-derived only; a repo with no sessions
reports no activity.

The overview also applied the manual drag-order through `orderByIds`, which
floats every id missing from the saved order to the top. That is right for
sessions, where a new chat should not sink, but the overview keeps receiving
newly-scanned repos — so once the user dragged anything, each new discovery
jumped above their hand-picked list. Projects the user has not ordered now
keep their deterministic position: ones with real activity still surface on
top, zero-session discoveries sort below the ordered list.
2026-07-26 01:20:23 -05:00
Brooklyn Nicholson
7dec2c9640 perf(desktop): add model-picker open-latency probes
probe-model-picker.mjs times pill-click → menu painted over N rounds;
profile-model-picker.mjs wraps one open in a CPU profile with a
top-self-time table. Both attach to the perf:serve instance.
2026-07-26 01:10:22 -05:00
Brooklyn Nicholson
0b745bc993 perf(desktop): pause glyph spinners on hidden tabs
Each kept-alive tab whose model hasn't resolved ticks a GlyphSpinner —
setInterval + setState + a React commit every ~80ms, per mounted tab,
for pixels behind the active one. Gate the interval on the pane's
visibility context; the visible tab keeps its spinner, hidden tabs
resume from frame 0 on reveal.
2026-07-26 01:10:22 -05:00
Brooklyn Nicholson
667758ac24 perf(desktop): defer model-row submenu bodies until hover
ModelMenuPanel mounts a ModelEditSubmenu per model row, and each body ran
its hooks and built its JSX eagerly on menu open — ~90 rows of switches,
radio groups, and preset lookups nobody hovered yet, the largest app-code
slice in the open-latency profile. Wrap the body in a child component under
SubContent so Radix's presence gate leaves it unrendered until the submenu
actually opens; open latency drops roughly in half on a 91-model catalog.
2026-07-26 01:10:22 -05:00
Brooklyn Nicholson
91a8fe4a3e perf(desktop): stop the thread timeline working when nothing can see it
The prompt rail mounts in every chat surface, and a tab group keeps
inactive tabs mounted, so a background timeline was stringifying every
user prompt's full text on each store update — including on every
streamed assistant token, since the selector walked all messages — plus
running a scroll listener and a getBoundingClientRect per prompt against
a viewport nobody was looking at.

It now defers each piece until it can be seen. An inactive pane returns
before a single hook is declared (usePaneVisible, the same context the
hidden-tab transcript freeze uses), so no subscription is opened at all.
An active rail subscribes to prompt IDS rather than prompt text and
reads the transcript imperatively only when that signal changes, which
takes streaming off the derivation path entirely; an identical
derivation hands back the previous array so a filtered-out prompt
doesn't restart the measure effect. The offset pass bails below the
render threshold instead of measuring a rail that renders null, ahead of
the existing following-the-bottom fast path, and the hover popover keeps
its shell for the fade but builds its rows on first open.
2026-07-26 00:45:34 -05:00
brooklyn!
be00a7176c
Merge pull request #71780 from NousResearch/bb/multitab-perf
perf(desktop): make multitab streaming sessions fast
2026-07-26 00:41:07 -05:00
Brooklyn Nicholson
9173f589c6 perf(desktop): add a multitab scenario to the perf harness
N session tiles stacked as tabs, all mounted (keep-alive) and all
streaming concurrently through the real publishSessionState path — the
"several PR reviews at once" workload. Frame pacing + longtask metrics,
no backend or credits needed; the workload that exposed both fixes above
and the regression gate that keeps them fixed.
2026-07-26 00:34:00 -05:00
Brooklyn Nicholson
982a425162 perf(desktop): skip the timeline rect walk while following the bottom
ThreadTimeline's scroll compute read getBoundingClientRect for every user
message per scroll frame; interleaved with React's streaming style writes
each read forced a full reflow — the hottest self-time frame in the
multitab profile (620ms over one 5-tab run). While the viewport is pinned
to the bottom the active prompt is simply the last entry, so answer from
data and save the layout reads for actual scrollback.
2026-07-26 00:20:49 -05:00