Commit graph

1512 commits

Author SHA1 Message Date
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!
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
Brooklyn Nicholson
47f0cdf3a2 perf(desktop): freeze hidden tab transcripts during streaming
Keep-alive keeps every ever-active tab mounted, but each hidden tab's
ChatRuntimeBoundary still subscribed to its view's $messages — so every
streaming delta flush (~30x/s) re-rendered every busy tab's whole thread,
and five concurrent sessions dropped the app to a crawl.

Flow the pane layer's visibility down as PaneVisibleContext and gate the
$messages subscription on it: a hidden tab freezes its transcript (status
dots stay live through the separate status atoms) and catches up in one
commit on reveal, since subscribe fires immediately with the current value.
2026-07-26 00:20:40 -05:00
hermes-seaeye[bot]
0ce9022e03
fmt(js): npm run fix on merge (#71740)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-07-26 04:11:26 +00:00
brooklyn!
559d0849a9
Merge pull request #71716 from NousResearch/bb/tui-link-label
fix(tui,desktop): stop link-title resolution from overwriting authored link text
2026-07-25 22:59:40 -05:00
Brooklyn Nicholson
5c5f11d23a test(tui,desktop): cover authored link labels and not-found titles
Warm the shared title cache before rendering so a resolved title is
available synchronously — the previous TUI assertion only proved a label
survived when no title had resolved, which passed on the buggy ordering.

Asserts the bug class on both surfaces: an authored label outranks a
resolved title and suppresses the fetch, an unlabeled link still resolves
one, and a "Page not found" title is discarded rather than rendered.

Co-authored-by: Kinkoolino-Hermes <297364961+Kinkoolino-Hermes@users.noreply.github.com>
2026-07-25 22:52:38 -05:00
Brooklyn Nicholson
17b8b3f657 fix(tui,desktop): treat "not found" page titles as unusable
Self-hosted forges answer a missing or private page with a 200 and a
"Page not found" title, which then rendered as the link's text. Both
surfaces keep their own copy of the error-title list, so extend both.

Co-authored-by: Kinkoolino-Hermes <297364961+Kinkoolino-Hermes@users.noreply.github.com>
2026-07-25 22:52:38 -05:00
Brooklyn Nicholson
f244deffee fix(tui,desktop): let authored link text outrank the fetched page title
Both chat renderers resolve link titles over the network and render them
in place of the link's text, unconditionally — so they also replaced text
the agent deliberately wrote. `[#71706](url)` rendered as the whole GitHub
page title, and prose labels were swapped out mid-sentence.

The TUI ordered `fetched || label` and always passed the URL to
useLinkTitle. Desktop looked guarded but wasn't: chat markdown hands
authored text to PrettyLink as `fallbackLabel`, not `label`, so
`useLinkTitle(label ? null : target)` never skipped the fetch and
`fetched || label || fallbackLabel` still let the title win.

Treat an authored label as the intent: it wins, and it skips the fetch. A
label that is just the URL still resolves, since `[url](url)` and `<url>`
are bare links wearing markdown syntax — desktop already applied that same
URL-equality rule before handing the label over.

Co-authored-by: Kinkoolino-Hermes <297364961+Kinkoolino-Hermes@users.noreply.github.com>
2026-07-25 22:52:34 -05:00
brooklyn!
8a71feb84c
Merge pull request #71709 from NousResearch/bb/first-run-local-start-error
fix(desktop): keep the first-run local-start error from being wiped on mount
2026-07-25 22:05:41 -05:00
brooklyn!
8b6ab1fba9
Merge pull request #71714 from NousResearch/bb/gated-health-probe
fix(desktop): connect to gated remote gateways that 401 the readiness probe
2026-07-25 22:03:05 -05:00
Brooklyn Nicholson
75456183b6 fix(desktop): wire the credentialed readiness probe and reauth latch into boot
Connects the preceding seams to the boot path:

- buildReadinessHealthProbe picks the probe credentials from the connection's
  authMode via resolveReadinessProbeAuth, and reports whether the probe is
  credentialed so waitForHermesReady can read a 401 correctly. The bearer
  goes through fetchJson's `bearer` option — a raw `headers` object is
  ignored there, which would have silently probed uncredentialed.
- authMode is threaded into all three waitForHermes call sites: the primary
  remote connect, the profile pool backend, and the SSH tunnel (loopback
  forward, token auth), so an old backend behind a tunnel gets the same
  compatibility path.
- The confirmed reauth failure latches in remoteReauthFailure and
  short-circuits startHermes, and is cleared on every recovery path —
  resetHermesConnection (soft/hard apply and reconnect), bootstrap reset,
  repair, and a CONFIRMED oauth sign-in. A cancelled or closed login window
  leaves the latch set so the overlay stays actionable.
- gatewayAuthProviders reads the public /api/auth/providers (cached per base
  URL, failures return []) so the oauth pre-flight guard can skip its hard
  failure for an all-password gateway while keeping the strict guard
  everywhere else.
2026-07-25 21:52:51 -05:00
Brooklyn Nicholson
36e2228a4c fix(desktop): latch a confirmed remote reauth failure so the overlay stays clickable
shouldLatchBackendStartFailure deliberately never latches a remote failure:
remote faults are usually transient and must stay retryable without an app
restart. A confirmed reauth rejection is the exception — it cannot self-heal,
because nothing changes until the user signs in again.

Worse, not latching actively prevents the recovery it was protecting. A
non-latching remote boot failure re-runs startHermes on every
getConnection/api call, re-emits running: true, and the boot-failure overlay
(visible = Boolean(boot.error) && !boot.running) hides itself — so the
"Sign in" button flickers out from under the user before it can be clicked.

Add shouldLatchRemoteReauthFailure as a separate predicate rather than
changing the existing one, so transient remote failures keep self-healing.
The two latches are complementary and never fire for the same failure.
2026-07-25 21:52:45 -05:00
Brooklyn Nicholson
cf4fb8993f fix(desktop): name the readiness-probe auth and password-gateway guard decisions
Two pure seams in native-auth-decisions.ts, following that module's existing
pattern — the value is the test that pins the contract so the god-file call
sites can't drift back to the buggy shape.

resolveReadinessProbeAuth decides how the boot readiness probe
authenticates, delegating to resolveOauthRestAuth for the oauth case rather
than duplicating the bearer-vs-cookie rule.

oauthGuardMayHardFail fixes a second way a gated gateway is misread.
authModeFromStatus maps the gateway's `auth_required: true` onto 'oauth',
but that flag only means the dashboard is GATED — it says nothing about how
you authenticate. A gateway whose providers are all username/password can
satisfy neither of the pre-flight guard's checks by construction:
start_login raises NotImplementedError, /auth/native/authorize rejects
password providers, and its cookies come from a plain password-login POST
rather than the /auth/callback redirect the OAuth partition is primed for.
The guard therefore threw "uses OAuth, but you are not signed in" one line
before a mintGatewayWsTicket that would have succeeded against that very
partition.

The helper returns false only when EVERY advertised provider is
password-based; an unknown or empty list keeps the strict guard, so backends
predating /api/auth/providers are unaffected.
2026-07-25 21:52:40 -05:00
Brooklyn Nicholson
8d025489cf fix(desktop): read a readiness-probe 401 by whether it was credentialed
The boot readiness probe calls the credential-free /api/health, and only a
404 flips it to the /api/status fallback. Both halves are wrong against a
gated gateway.

/api/health landed in ccab46ca4 (2026-07-24), after the v2026.7.20 tag, so
every container pinned to a release lacks the route. On those backends the
dashboard auth gate runs ahead of the SPA catch-all, so an unknown /api/*
path is rejected as unauthenticated rather than 404 — a credential-free
probe can never observe the 404 that the fallback keys on, and boot loops
until the 45s deadline reporting a healthy backend as "did not become
ready". Upgrading the backend is not a workaround for release-pinned
deployments.

Simulating a 0.19.0 backend (both the route and its PUBLIC_API_PATHS entry
removed, since ccab46ca4 added the two together) shows the probe's 401 means
two different things depending on whether credentials were sent:

  credential-free: /api/health -> 401 no_cookie, /api/status   -> 200
  credentialed:    /api/health -> 404,           /api/sessions -> 200

So the fix is not "fall back on any 401". Uncredentialed, a gate-shaped 401
identifies a missing route and must fall back. Credentialed, a 401/403 is a
rejected session and must fail fast — falling back would hit the public
/api/status, get a 200, and report a dead session as ready, deferring the
no_cookie to the first real API call.

Split the two cases and tag the credentialed rejection as a terminal reauth
error. A generic 401 without the gate shape, plus 429 and 5xx, keep polling
as before.
2026-07-25 21:52:26 -05:00
hermes-seaeye[bot]
6c9718151c
fmt(js): npm run fix on merge (#71706)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-07-26 02:46:01 +00:00
Brooklyn Nicholson
f97e7086c7 fix(desktop): keep the first-run local-start error from being wiped on mount
Clicking "Install Hermes locally" surfaced no error when the bridge was
missing, if the click landed before React drained the passive effect that
reacted to the first bootstrap snapshot. The effect cleared the transient
button state on every change of setupChoice.activeRoot, and the very first
snapshot counts as a change: it moves the root from null to its initial
value. The choice paints as soon as that snapshot commits, so a fast click
produced an error that the effect then cleared before it ever rendered.

The state now records the root it was produced under and is read back only
under that same root, so leaving a phase still discards it but arriving in
one cannot. Deriving it removes the effect rather than adding a guard to it.

Reproduced by observing the DOM with a MutationObserver instead of findBy*,
which only settles on a timer tick — by then the effect has already run and
the window is closed. Reverting the component change fails the new test.
2026-07-25 21:34:35 -05:00
brooklyn!
6ab5d2df2a
Merge pull request #71664 from NousResearch/bb/composer-slash-trigger
fix(desktop): make skills referenceable anywhere in the composer
2026-07-25 21:26:14 -05:00
brooklyn!
3a06908433
Merge pull request #71688 from NousResearch/bb/profile-gateway-inherit-label
fix(desktop): clarify inherited profile gateway
2026-07-25 21:22:41 -05:00
brooklyn!
a135b278dd
Merge pull request #71601 from helix4u/fix/desktop-model-picker-authority
fix(desktop): keep model picker aligned with session state
2026-07-25 21:20:54 -05:00
Brooklyn Nicholson
28c12c8c94 fix(desktop): localize the inherited-gateway copy for Arabic
The ar locale landed after the inheritance-label fix was written, so it
still told users to set a named profile to Local to inherit the default
and fell back to English for the renamed card.
2026-07-25 21:10:04 -05:00
KaliShodan
4656a1a009 fix(desktop): clarify inherited profile gateway 2026-07-25 21:06:40 -05:00
brooklyn!
a606d24cf2
Merge pull request #71679 from NousResearch/bb/default-effort
fix(desktop): honor the configured reasoning effort instead of assuming medium
2026-07-25 20:58:00 -05:00
Brooklyn Nicholson
43d1088ba2 refactor(desktop): give reasoning effort one owner
The seven effort levels were enumerated four times in four shapes: a
labels map, a radio-option list, a settings value array, and an enum
list for the config field. Each carried its own hardcoded medium
fallback, which is how the picker drifted from the configured default in
the first place.

Collapse them into lib/reasoning-effort, which owns the scale, the
labels, and the resolve/fallback rules. Net -80/+26 in source. Dropping
the effortLabelKey cast also means the i18n keys are now type-checked
against the canonical list instead of asserted into place.
2026-07-25 20:51:18 -05:00
brooklyn!
10160a1808
Merge pull request #71672 from NousResearch/bb/tab-session-titles
fix(desktop): name a Cmd+T session from its first message
2026-07-25 20:51:01 -05:00
Brooklyn Nicholson
412a535433 fix(desktop): scope mid-message references to skills and keep them as chips
Two corrections to the inline slash reference.

Only skills are offered mid-message now. A built-in like `/model` or `/new`
acts on the app, so it reads as nothing useful in the middle of a sentence —
whereas a skill is exactly the thing you want to point at while describing
work. A leading `/` is unchanged and still offers the full command set.

A picked skill also stays a pill in the sent message. The composer already
inserts one, but the message renderer knew nothing about slash references and
flattened it back to raw text on send. It now parses a mid-prose `/skill` into
a chip segment and renders it with the same styling the composer uses. The
submitted text is untouched — the chip still round-trips to the literal
`/clean` the backend expects — so this is presentation only. Path-like tokens
(`/usr/local/bin`) are excluded: unlike the caret-anchored composer trigger,
this scans finished text, so it also has to reject a token that runs on into
a path.
2026-07-25 20:45:11 -05:00
Brooklyn Nicholson
fab0e60570 fix(desktop): name a Cmd+T session from its first message
A Cmd+T tab's session is created unlisted (openNewSessionTile passes
`listed: false`), so it has no $sessions row until its first turn
persists and a refresh surfaces it. For that whole first exchange the
tab strip and the sidebar read "New session".

Cmd+N has no such gap: its session is created per-send, and
createBackendSessionForSend seeds the optimistic row with the user's
text as the preview. A tile never reaches that path — its runtime id is
already bound, so its createBackendSessionForSend seam is a no-op that
returns the existing id.

Seed the row the same way on a tile's first send. The tab strip already
re-syncs on $sessions (watchSessionTiles lists it in `also`), so the tab
and the sidebar both name the session within the first message; the
server's auto-title supersedes the preview when the turn completes.

Guarded so it only ever fires once per session: no-op on empty text and
on an already-listed row, matching across compression by lineage root so
a re-send can't clobber a real title with a raw message preview.
2026-07-25 20:44:19 -05:00
brooklyn!
a288fc341c
Merge pull request #71678 from NousResearch/bb/code-block-overflow
fix(desktop): keep code and diffs out of the tool overflow window
2026-07-25 20:42:22 -05:00
Brooklyn Nicholson
be1edef5ce fix(desktop): show the real effort on the composer pill
The pill hardcoded 'Med' whenever the surface had no explicit effort, so
a profile configured for high advertised a level the agent would not
use. Take the profile default as a fallback and drop the now-unused
shell.modelMenu.medium key across the locales.
2026-07-25 20:37:13 -05:00
Brooklyn Nicholson
e2122f22b8 fix(desktop): stop the model picker overriding the configured effort
Selecting a model applied 'preset.effort ?? medium', so a user running
agent.reasoning_effort: high was silently downgraded to medium on every
model switch and every new chat that inherited the pick. The Thinking
toggle and the effort radio group defaulted to the same literal.

Resolve all of them from the profile default instead, falling back to
DEFAULT_REASONING_EFFORT only when config has not loaded.
2026-07-25 20:37:09 -05:00
Brooklyn Nicholson
58f5a05655 fix(desktop): publish the profile default reasoning effort to a store
The composer only seeded effort from agent.reasoning_effort when it was
about to reseed the whole selection, which a sticky manual model pick
skips. The configured value was read and discarded, leaving every other
surface with no way to resolve the profile default.

Mirror it into $defaultReasoningEffort on every config load, independent
of the composer reseed, so the manual pick stays sticky without hiding
the default from the surfaces that need it.
2026-07-25 20:37:06 -05:00
Brooklyn Nicholson
7f2971039a fix(desktop): keep code and diffs out of the tool overflow window
A run of 3+ adjacent tool calls collapses into the 6.75rem
`.tool-group-scroll` window. That is right for status rows, but a patch
or execute_code row's body is a code block the user reads, and the
window squeezed it to a ~2-row viewport behind a fade mask.

The CSS break-out (`:has([data-tool-row][data-tool-open])`) already
lifts the cap once a row is expanded, which is why most tools are safe
to bound. It can't help here: the user has to notice the clipped block
and expand it before the code is legible.

Extend the existing UNBOUNDABLE_TOOLS opt-out to the code-bearing tools
behind an `isUnboundableTool` predicate, deriving the file-edit names
from `isFileEditTool` so a newly supported edit tool can't be exempt in
one place and clipped in the other. `terminal` stays boundable: console
output is a log tail whose last lines are the ones that matter, which
is exactly what the window pins.

Verified in Chrome against the app's compiled CSS: a 3-call run
carrying a 520px diff rendered at 108px before, full height after.
2026-07-25 20:35:01 -05:00
Brooklyn Nicholson
bfc8e3b00d fix(desktop): give ⌘T session tabs a live gateway so slash completions load
`useGatewayRequest` only exposed the gateway through a ref that its own
subscription effect fills in, so a component reading it during render saw
null on mount. Session tiles — what ⌘T and the tab-strip "+" open — did
exactly that and passed `gateway={null}` down to ChatBar. The slash
adapter is disabled without a gateway, so a new tab had no completions at
all while ⌘N, which resolves its gateway through a state-driven memo,
worked fine. Nothing re-rendered the tile afterwards unless the connection
state happened to flip, so it never recovered.

Return the `$gateway` atom as a reactive value alongside the ref and use
it wherever the gateway is needed as a render-time value. The ref stays
for `requestGateway`, which wants a stable identity. The model picker,
model visibility, and settings overlays read the same ref during render,
so they're moved over too.
2026-07-25 20:30:55 -05:00
Brooklyn Nicholson
58d805302a fix(desktop): let a slash command be typed anywhere in the prompt
The `/` trigger was anchored strictly at position 0, so the completion
popover only opened when the slash was the first character. Typing
`please run /clean` mid-message produced nothing to pick, which put every
skill out of reach unless the prompt started with it.

Position 0 and mid-message are genuinely two different things, so detect
them separately. At position 0 a slash is a command invocation and keeps
its arg completion (`/personality alic`). After whitespace it's an inline
reference inside prose, so it completes as a single token and never
expands into an arg step. Both stay trailing-anchored, which keeps file
paths (`src/foo/bar`, `look at /usr/local/bin`) from matching.
2026-07-25 20:30:55 -05:00
hermes-seaeye[bot]
ba159d6fa9
fmt(js): npm run fix on merge (#71667)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-07-26 01:21:38 +00:00
teknium1
751c15f9eb lint(desktop): exempt benign ref writes flagged by the new atom-mirror guard
The guard matches any `.current` write inside a `useEffect`, so it also
flags refs that are not mirroring a reactive value. Seven such sites
landed on main after the rule was written:

  - wiring.tsx: one-shot request-seen sentinel
  - billing/plans-view.tsx: previous-confirming tracker for focus return
  - config-settings.tsx: autosave bookkeeping
  - gateway-settings.tsx: monotonic request-sequence counters
  - toolset-config-panel.tsx: mount flag + one-shot provider-choice claim
  - voice-provider-fields.tsx: one-shot config seed flag

None mirrors an atom/prop/state value, so none can produce the
one-render-lag stale read the rule exists to prevent. Each gets an
eslint-disable with the specific reason rather than widening the rule,
which would reopen the hole for real mirrors.

Verified the guard still catches the real antipattern: a probe file
mirroring `useStore($activeSessionId)` into a ref via useEffect is
reported. eslint over apps/desktop/src is 0 errors, and the 24 remaining
warnings match the pre-change baseline exactly.
2026-07-25 18:12:48 -07:00
ethernet
e026fd61d8 lint(desktop): ban atom-mirrored refs via no-restricted-syntax eslint rule
A ref synced from a nanostores atom via useEffect lags the atom by one
render. Callbacks that read the ref instead of the atom get stale values —
cancelRun sent session.interrupt to the wrong session (#66485), and
steerPrompt/restoreToMessage/editMessage had closure-priority stale reads.

The rule catches the three shapes of this antipattern:
  - useEffect(() => { ref.current = value }, [value])
  - useEffect(() => { ref.current = value; ... }, [value])
  - useEffect(() => { setMutableRef(ref, value) }, [value])

All 79 existing hits get eslint-disable-next-line with a comment. The
legitimate ref writes (DOM instances, mount flags, request tokens,
prev-value tracking, prop mirrors) stay suppressed; the 11 real bug
sites will be fixed in the next commit so their suppressions can be
removed.

Rule is scoped to apps/desktop only — ui-tui and tests-js don't use
nanostores and don't have this pattern.
2026-07-25 18:12:48 -07:00
hermes-seaeye[bot]
44480617ff
fmt(js): npm run fix on merge (#71638)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-07-25 23:26:29 +00:00
hermes-seaeye[bot]
2ba1e028e4
fmt(js): npm run fix on merge (#71634)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-07-25 23:18:02 +00:00
SHL0MS
6b26b409cf fix(desktop): /goal arg stays editable, kickoff queues when busy, slash header stops echoing long args
Four symptoms from the same /goal flow on desktop:

- Typing '/goal <text>' sealed the command into a directive chip on
  Space because /goal was registered without args:true, so the goal
  prose rendered awkwardly after a pill. The registry row now matches
  /personality and /tools: the arg stays editable text.

- The slash status header echoed the ENTIRE invocation ('slash:/goal
  <whole goal prose>') in mono, immediately above the backend notice
  that repeats the goal text again, and the kickoff user bubble that
  repeats it a third time. The header now carries just the command
  token (slash:/goal).

- When the session was busy, handleDispatch rendered 'session busy'
  and dropped the dispatch message. For /goal that message is the
  kickoff prompt, and the backend has ALREADY set the goal by then —
  the goal existed but the agent never heard about it, and later turns
  looked goal-unaware (#63352). The busy path now queues the kickoff
  on the composer queue: it sends on settle and is visible/editable in
  the queue panel meanwhile. Falls back to the old message if the
  queue rejects the entry.

- A slash command issued on a fresh draft created the backend session
  with no preview, so the sidebar row sat as 'Untitled session' —
  and when the kickoff was dropped, auto-title never fired either
  (it needs a completed user->assistant exchange). ensureSessionId now
  seeds the preview with the typed command.

Tests: registry row contract, busy-path queueing (kickoff neither
sends mid-turn nor vanishes), and the header-token assertion.
2026-07-25 16:09:38 -07:00
hermes-seaeye[bot]
ec734b6658
fmt(js): npm run fix on merge (#71626)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-07-25 23:00:16 +00:00
teknium1
584b274495 fix(desktop): slash commands target the user's chat, not a new session
`/goal status` reported "No active goal" for a goal that was live: the
desktop's slash pipeline resolved its target session differently than the
submit pipeline, so the command ran against a different session than the
chat on screen.

`/goal` state is persisted per-session in SessionDB (`state_meta` key
`goal:<session_id>`). slash.ts resolved with a bare
`hint || activeRef || createBackendSessionForSend()`, so whenever the
runtime binding was momentarily absent — profile swap, reconnect,
orphan-reap, request timeout — it silently MINTED A NEW SESSION and ran
there. submit.ts already handles this case by resuming the routed stored
session on its owning profile (#55578, #67603).

Extract that ladder into one shared resolver both pipelines use, per the
"one resolver owns each policy" rule in apps/desktop/AGENTS.md. This fixes
the whole class, not just `/goal`: every exec/rpc slash command
(`/usage`, `/status`, `/tools`, …) had the same hole.

A targeted durable conversation whose runtime cannot be rebound now
returns null instead of forking the chat — reporting that a command could
not run beats running it against the wrong session.
2026-07-25 15:44:39 -07:00
Gille
c943787bf6 fix(desktop): keep model picker aligned with session state 2026-07-25 16:01:43 -06:00
teknium1
78c06525e8 fix(desktop): keep optional action handlers optional through the latest-actions adapters
The adapters wrapped every field in an arrow function, including the
optional ones. That makes an absent handler unconditionally truthy, and
several children gate on a handler's PRESENCE rather than just calling
it:

  - onDismissError    -> assistant-message.tsx renders the dismiss button
                         only when defined
  - onRestoreToMessage -> thread/index.tsx gates the restore-confirm flow
  - onTranscribeAudio  -> use-voice-recorder / use-voice-conversation gate
                         recording on it
  - onLoadMoreMessaging / onLoadMoreProfileSessions -> sidebar paging

So the adapter would paint a dead dismiss button and let voice recording
proceed into a no-op transcription path even when the controller had
deliberately left those handlers off.

Wrap an optional field only when it is currently present, and re-read the
latest value inside the wrapper so the stale-closure fix still applies.
Presence is stable for a given actions object (the controller mutates
fields in place rather than toggling a handler between defined and
undefined), while the closure is what churns — which is exactly what the
indirection re-reads.

Adds two regression tests: absent optional handlers stay undefined, and a
present optional handler still late-binds to the latest closure. The
first was verified to fail against the unconditional-wrapper form.
2026-07-25 13:01:14 -07:00
eagle-nyp
344976773a test(desktop): cover stale steer action binding 2026-07-25 13:01:14 -07:00
eagle
8f53429651 fix(desktop): avoid stale actions in memoized surfaces 2026-07-25 13:01:14 -07:00
hermes-seaeye[bot]
5baf174781
fmt(js): npm run fix on merge (#71549)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-07-25 19:53:26 +00:00
teknium1
771f1b7f21 fix(desktop): route live-turn and history actions by the current session, not a stale closure
Redirect/steer, regenerate, restore-checkpoint, edit-message, and
change-cwd read `activeSessionId || activeSessionIdRef.current`, which
prefers the closure-captured prop whenever it is non-null and only falls
back to the ref once the prop is null.

That precedence is backwards. The actions bag is a stable ref that
wiring.tsx mutates in place (Object.assign), and the pane surfaces are
memoized on that stable ref, so a surface does not re-render when the
active session changes and keeps whichever closure was current when it
last rendered. `activeSessionIdRef` is the authority: it is mirrored
during render in use-session-state-cache, and submit.ts /
use-session-actions pin it imperatively mid-flight without touching the
source prop. The prop is stale by design. `cancelRun` in the same file
already reads the ref exclusively and documents exactly this hazard.

User-visible effect after switching chats: a typed correction was
delivered into the previously focused conversation's live turn (the
"session suddenly working on another chat's task" report), and rewinds
truncated the wrong session's transcript — real data loss, since a
truncating resubmit deletes history after the target ordinal. Nothing
crosses over in stored state, which is why a DB/transcript audit of the
affected session comes back clean.

Also fixes the same defect in `changeSessionCwd`, where a stale target
re-anchored another conversation's workspace, pointing that agent's
terminal/file tools at the wrong project. The now-unused
`activeSessionId` option is dropped from useCwdActions rather than left
as a footgun for the next caller.

Not changed, verified not affected: model-edit-submenu reads the runtime
id via `useStore` (live subscription, re-renders on change), and
use-composer-actions guards on `attachedSessionId === activeSessionId`,
so a stale value there only skips a detach instead of writing
cross-session.

Tests: 4 regression cases pin each action to the current session when
the prop and the ref disagree. Verified to fail against the pre-fix code
(all four reported the stale `rt-abc123` instead of the current
session), including a scripted revert of all four sites.

Co-authored-by: Drew Donaldson <49219012+Automata-intelligentsia@users.noreply.github.com>
2026-07-25 12:45:46 -07:00
Perseus Computing
ac327dfa38
fix(desktop): resolve unpacked spawn-helper before chmod (#71171) 2026-07-25 15:19:29 -04:00