The layout tree persists the active tab, but every reload landed on main
anyway: boot's route resume sets $selectedStoredSessionId, and the
selection listener in store/session-states.ts treats every selection
change as a navigation — noteActiveTreeGroup(null) +
revealTreePane('workspace') — fronting the workspace tab over the
persisted active tile and then persisting that clobber.
A cold-start restore is a re-attachment, not a navigation, so
use-route-resume now arms a one-shot (markSelectionRestore) before
dispatching the window's FIRST resume; the selection listener consumes
it and skips homing exactly once. Every later resume — sidebar click,
route change, reconnect — homes as before, and starting on /new
consumes boot status too so a subsequent session open still homes.
The poll this page's pairing block rode was retired hours earlier by
f8e07a332, which moved Messaging onto platforms.changed. That signal is the
mtime of gateway_state.json — where the gateway persists connect/disconnect
health — and a new pairing request moves none of it. So on an event-capable
backend a pending row stayed invisible until something unrelated
reconnected, and the count badge with it.
Adds pairing.changed to the change watcher, signed off the pending/approved
ledgers across the global store and every profile's own. _rate_limits.json
is deliberately excluded: it moves on every unauthorized DM, including ones
that produce no new row, so signalling on it would refetch for nothing.
The page now refreshes platforms and pairing on their own signals rather
than one combined call, and the legacy visible-tab poll (older backends)
covers both.
newSessionOpensTab answers a question that isn't specific to the sidebar
"+" — is there a conversation on main that must not be discarded — and the
palette needs the same answer. Fold it into open-session as
mainChatOccupied so both callers share one definition.
The change watcher (#73673) missed one always-on-while-mounted timer: the
Messaging page polled /api/messaging/platforms every 6s for connection
status. The gateway already persists platform connect/disconnect/health to
gateway_state.json, so watch that file's mtime and broadcast
platforms.changed (floored to 5s — the gateway also rewrites the file for
in-flight-count bookkeeping), route it through live-sync like its
siblings, and refresh the page on the tick. Older backends keep the
legacy visible-tab poll verbatim.
Finishes the always-on poll sweep for #73618.
Long thinking/tool stretches in a voice conversation are dead air — the
user cannot tell whether the agent is alive. New: quiet, repeating soft
bubble blips while the agent works and no speech audio is flowing.
- tools/voice_mode.py: numpy-synthesized blips (no binary assets) — two
alternating low pitches (G4/E4) with pitch glide + smooth attack/decay
envelopes, ~0.8-1.2s randomized spacing, volume = voice.beep_volume * 0.5.
start_thinking_sound(should_play=...) / stop_thinking_sound() daemon-loop
lifecycle; macOS-TCC-safe (sounddevice output gated there → silent skip,
no per-second afplay churn). New mark_audio_output_active()/
is_audio_output_active() ref-count wraps play_audio_file and the
streaming OutputStream sentence writes so "audio is flowing" is accurate.
- Config: voice.thinking_sound (default true) off-switch.
- cli.py: starts when a voice-mode turn begins, per-blip gate skips while
TTS speaks / mic records / barge capture owns the mic; stopped in the
chat() finally on every exit path.
- tui_gateway/server.py: same lifecycle around _run_prompt_submit turns
(voice mode on), gated on is_audio_output_active + continuous capture.
- Desktop: renderer owns voice-conversation audio, so a matching WebAudio
implementation (src/lib/thinking-sound.ts, same envelope/pitches) runs
while conversation status === "thinking"; honors voice.thinking_sound
(via config store) and the shared sound-mute toggle; stops instantly on
speaking/listening/end.
One owner for the wording: voice_stop_hint() in tools/voice_mode.py —
sources the phrase from voice.stop_phrases (first entry) so a custom
phrase renders correctly, and returns "" when the feature is disabled
(stop_phrases: []) so no surface shows a hint.
- CLI: printed in /voice on output (style-matched dim notice).
- TUI: voice.toggle action=on now carries stop_hint; the Ink client
renders it in the "Voice mode enabled" block (older gateways omit
the field — no hint, no crash).
- Desktop: the renderer voice loop never touches tools/voice_mode.py,
so the phrase is read from config (voice.stop_phrases → $voiceStopPhrase
store, seeded in use-hermes-config) and shown as an info toast when a
voice conversation starts. i18n: en/ja/zh/zh-hant/ar.
Session rows served without ?profile= carried no profile field, so in
multi-profile desktops the default profile's sessions circulated unowned:
resolveStoredSession cached them profile-less, resolveSessionProfile returned
undefined, and session.resume targeted whichever gateway was active -- opening
a default-profile session from a non-default window failed with
"session can't be found" while the reverse direction worked (#67603 family).
Server: GET /api/sessions/{id} and GET /api/sessions now stamp profile/
is_default_profile unconditionally -- the serving profile is always known
(_cron_default_profile() when the request is unscoped).
Renderer: resolveStoredSession treats a profile-less $sessions cache hit as
unresolved when >1 profile exists (falls through to the stamped by-id ladder)
and back-fills the active profile on bare by-id hits from older backends, so
unowned rows are never re-cached.
Verified via CDP against a live 4-profile renderer: bare by-id GET returned
hasProfileField:false and the stale cache rows matched; with the fix both
lookups return the owning profile and the resume routes to the right backend.
The binding check landed unscoped, so it fired for every caller passing a
sessionId/storedSessionId pair — not just queue drains. A slash skill
dispatch into a fresh ⌘T tab passes exactly that shape (sessionId=tab
runtime, storedSessionId=tab stored) with no central binding recorded yet,
so the check nulled the target and the kickoff dropped instead of landing
in the tab.
Only a drain pairs identifiers from two different clocks; every other
explicit-target caller resolves both ids in the same tick and is
authoritative by construction. Gate on fromQueue and add the scoping
invariant as a test.
Also refresh the two drain tests for `queued: true`, which prompt.submit
started sending for queued drains in ab68c5efe after this work branched.
Co-authored-by: theone139344 <theone139344@users.noreply.github.com>
A queue drain pairs two identifiers from different clocks: the queue key
(flips with the route) and the explicit runtime id (lags a resume behind).
Mid-switch the composer can fire a drain with storedSessionId=B but
sessionId=A-runtime, and prompt.submit then lands B's queued prompt — and
its whole answer turn — inside session A.
Make the central runtime binding authoritative for queued sends: when the
explicit runtime id no longer matches the binding recorded for the target
stored session, adopt the binding (or drop to the stored-id resume path
when none exists yet). The identity pair (storedSessionId === sessionId)
is the fresh-chat fallback and stays untouched.
Tests: re-home-via-resume and rebind-to-central-runtime; existing
background-drain and sleep/wake cases declare central bindings explicitly.
Null-fallback guard already on main; this PR is the remaining half.
Keep Settings-configurable preview/image loads on readFileDataUrl, and
route remote non-image attach through a 256 MiB IPC so uploads are not
stuck on the 16 MiB default after #73221.
Co-authored-by: Börje <borje@dqsverige.se>
Recents were hard-sorted by started_at and only refreshed last_active after
turn complete — so reviving an old thread stayed buried until the assistant
finished. Stamp last_active on prompt seed, keep it monotonic across mid-turn
refreshes, and sort agent recents by activity.
One controller (store/live-sync.ts, the workspace-events twin for gateway
state): gateway-event.ts routes pet.changed / cron.changed /
sessions.changed into tick atoms, gated on the active profile like
skin.changed; gateway.ready's change_events flag arms them; a gateway wipe
resets the capability.
Consumers subscribe to ticks and demote their polls to slow backstops
(legacy cadence is kept verbatim against older backends):
- session.active_list 1.5s → sessions.changed + 30s backstop
- cron list 30s → cron.changed + 5min backstop
- messaging lists 10s → trailing sessions.changed refresh + legacy poll
only when events are unavailable
- open messaging transcript 5s → sessions.changed + 30s backstop
- pet.info 3s/15s → pet.changed, NO timer on event-capable backends
(users with no pet used to poll hardest); enabled=false broadcasts
clear the mascot with zero round-trips
- cron runs page/peek 8s → cron.changed + 60s backstop
Idle renderer traffic drops from ~89 req/min to the backstops (~5/min).
Part of #73618.
Hard 16 MB cap on readFileDataUrl blocked larger local attaches with no way to raise it. Settings -> Chat now has a free-form MB field. Main process owns the persisted value and clamps only absurd inputs.
A session is meant to be either the main thread or a tile, never both.
openSessionTile enforced this from the tile side (refusing to tile the
selected session), but the main side never dropped an existing tile — so
any path that routes main to a session that's already tiled (cold-start
remembered-session restore, a pasted/Cmd-K route, a notification jump)
painted the same transcript twice: the workspace pane from the route and
the tile pane in parallel, both fighting one runtime.
resumeSession is the single chokepoint every load-into-main path funnels
through, so close the redundant tile there once the session becomes the
selection. The warm cache/runtime binding survives for main to reuse.
The subpath entry existed only so the TUI could import the client-side
projection fallback. The TUI spawns its gateway from this same checkout and
cannot version-skew with it, so that fallback was dead weight — it reads
`display` directly now. With its last caller gone the dispatch helper folds
back into the desktop, where an older backend is genuinely reachable.
apps/shared/package.json is byte-identical to main again.
The bubble, the queue panel, and the queue editor all showed a queued or
sent /skill turn's expanded body. Thread the invocation through submit and
the queue entry, and chip a leading slash command the way a mid-prose one
already chips.
The composer re-asked the gateway for the command catalog on every open
and for complete.slash on every keystroke. Both scan the skills dir on
the backend, so opening the menu cost a round trip against data that
only moves when a skill is added, removed, or toggled.
Hold both behind a one-hour cache keyed per query, and invalidate it
where the skill set actually changes: hub install/uninstall/update, a
Capabilities toggle, bulk toggle, archive, the agent's own skill_manage
call, and a profile switch. A cached query also skips the debounce and
the spinner, so a warm menu opens in the same frame.
The bare-slash list showed no skills at all: the backend categorizes
registry commands but appends skills to the flat pairs list only, and
the popover prefers the categorized layout. Re-add the uncategorized
leftovers under a Skills header, so /clean and /work are visible on /
and not just after typing enough of the name to match.
The desktop's queue promises "run this AFTER the current turn", but the
promise broke on a race the user can't see: a drain that fired when the
client observed idle while the server was still unwinding the turn landed
in _handle_busy_submit, which applied busy_input_mode — redirecting or
interrupting the live turn with text the user explicitly queued. That's
why force-sending the queue felt like a dice roll: the same gesture
steered, interrupted, or queued depending on a millisecond settle race.
prompt.submit now carries queued:true on every fromQueue drain (composer
auto-drain, send-now, background drain), and the gateway's busy path
honors it by forcing queue semantics — never steer, never redirect,
never interrupt. Lose the race and the text simply waits its turn, which
is what queueing meant all along.
Covered on both sides: a gateway test proving a queued drain cannot touch
redirect/steer/interrupt on the live agent, and the desktop drain tests
now assert the flag rides every prompt.submit shape (direct, background,
resume-retry).
`tool.generating` names the tool whose arguments are streaming, and nothing
ever closed that claim: there is no stop-drafting event, and a draft can be
abandoned without reaching `tool.start` when a mid-stream retry drops a
partial call or a guardrail blocks the tool. Enumerating the ways a draft
ends left those holes open, so "Editing" sat under the transcript for the
rest of a multi-iteration turn.
Invert the rule — the claim only covers what the model is emitting right
now, so any other output from that session retires it. Stopping the turn
clears it too, and a `tool.generating` that arrives after the stop is
ignored on the same condition `mutateStream` already drops late tool rows.
A live run was the settled view under a CSS max-height: the real rows,
capped at ~6.75rem, with an escape hatch that let an open diff lift the cap
entirely. So it was neither a single line nor an honest expanded block, and a
run could balloon mid-turn.
Split the two presentations instead. Live, a run is its summary plus a
one-line reel that slides each finished action up and out, so a turn touching
thirty files reads as one line ticking over in place. Settled, the summary is
the whole of it until opened. Cards — file edits, clarify, image_generate —
leave the run entirely and stay on screen where they happened, since the diff
or the question IS the point of the turn.
Drops useToolWindow, the bounded-window threshold, the scroll container, the
fade mask and the :has([data-tool-open]) escape hatch, all of which existed to
make "the real rows, but shorter" work.
Also unifies transcript scaffolding on one colour: thinking headers painted
--ui-text-secondary and tool summaries --ui-text-tertiary under a shared
opacity, which is two greys for one kind of line. Both now render through
ScaffoldRow at a token pitched between them.
The right rail held two things at once: a list of file tabs, and a
privileged "live preview" slot with a hardcoded `preview` tab id backed by
a separate session-keyed registry. The two were written under different
session-id rules and reconciled against each other, so an `open_preview`
from a session whose stored id hadn't landed yet was set and then
immediately cleared — the pane flashed and vanished. Artifacts arrived as
a third list with their own pane and renderers.
Now everything the rail can show is a `PreviewTarget` in `$previewTabs`,
and `openPreview` is the only way in. `$previewTarget` is a computed read
of the active tab, the session registry and its reconciler are gone, and
artifacts render in the real preview pane through the shared mode switcher
and source view instead of a parallel one. Artifact tabs stay memory-only
since the registry rebuilds from the transcript.
Adds a regression test for #72602: navigating to a sidebar route now calls navigate() and fronts the workspace pane (noteActiveTreeGroup(null) + revealTreePane('workspace')).
Mocks @/components/pane-shell/tree/store to assert the calls without depending on real pane-tree DOM state.
Capabilities/Messaging/Artifacts (and other full-page workspace routes) rendered their content correctly on navigate(), but the workspace pane itself stayed behind an active session tile in the pane tree if one was focused. Clicking the same sidebar item again after switching to a session tile appeared to do nothing.
Session switches already call revealTreePane('workspace') + noteActiveTreeGroup(null) to front the pane (store/session-states.ts), but sidebar/keybind/command-palette navigation to full-page routes did not have an equivalent.
Add navigateToWorkspacePage() helper in routes.ts that wraps navigate() and fronts the workspace pane for non-overlay, non-chat views. Apply it at all 5 call sites that navigate to full-page workspace routes: selectSidebarItem, use-keybinds (nav.skills/messaging/artifacts), command palette 'go', Command Center's onNavigateRoute, and cold-start route restore.
Fixes#72602
* fix: Branch in new chat loses the question and the branched session (#issue)
- session.branch on the backend now accepts a count param to truncate
the parent history to the clicked message, instead of always forking
the entire transcript. Also returns stored_session_id/messages/info
so the frontend has parity with session.create's response shape.
- branchCurrentSession (open live chat) now slices history from 0
instead of from the clicked message index, so the question preceding
an assistant reply is no longer dropped when branching.
- forkBranch now calls session.branch (not session.create) when
branching an open live chat, since session.create only persists a DB
row lazily on first prompt - a branched chat that nobody types into
never got saved, and vanished as 'session not found' on the next
app restart. branchStoredSession (branching from the sidebar, no
live runtime) keeps using session.create as before.
* test: cover session.branch count truncation and open-chat branching
- backend: assert session.branch with a count param only persists the
first N messages of the live history to the new session.
- frontend: BranchHarness now exposes branchCurrentSession; assert
branching an open chat from a middle message calls session.branch
with the parent session id and the correct trimmed count, instead of
session.create.
Driving HER real instance (real profile, real transcripts, streams live)
via CDP instead of synthetic tiles finally exposed the remaining stall.
The timeline on a real 60-frame sash drag:
style recalc 2736ms | script 1027ms | layout 89ms
top callsite: pin @ fallback.tsx — 927ms
Two pin-to-bottom ResizeObservers (the bounded tool window's and the
reasoning preview's) pinned on EVERY resize delivery. A sash drag changes
every message's WIDTH once per frame, so each frame ran scrollTop write ->
scrollHeight read across every tool group: a forced write-read reflow
cascade that the render counters could never see (zero React involvement).
Both pins are now height-gated off the RO entry (reflow-free): only
content GROWTH pins. Width-only deliveries return immediately.
Measured on the live app, same drag, before -> after:
fps 11.5 -> 59-60
p95 101ms -> 18ms
slow>33 60/60 -> 1/60
Also in this batch (each was verified live before the next was attempted):
- thread/list: split messageSignature into STRUCTURAL (ids/roles — keys
boundaries + row identity) and WEIGHT (part counts — budget only), and
memoize groups + row JSX. A streamed part-append re-rendered every
turn's boundary via its resetKey prop; explain() measured 540-865
wasted Block renders per drag/stream sample, now {}.
- message-render-boundary: document the structural-only resetKey contract.
- tool/fallback: memoize ToolFallback's part object + ToolEntry/ToolTitle/
ToolGlyph (151 renders each, 100% wasted, on real transcripts).
- use-message-stream: ADAPTIVE flush floor — next flush waits 3x the
measured cost of the last one (33ms floor, 250ms cap), so multi-stream
load degrades text update rate instead of input latency.
- tree-split: preview sash drags with inline flex on the two seam
wrappers, committing the store ONCE on release (fixed-zone sides get
flexBasis only, so a hidden sidebar can't leave a phantom gap).
- debug/: perf-live LoAF long-frame attribution, explain() cascade walker
with changed-hook indices, diag-real-loop/key-latency/switch-trace
probes that drive the real app over CDP.
Typing during 2 live streams: keystroke->paint p50 3.3ms, p95 18.4ms,
zero frames over 33ms. Session switch p50 ~35ms settled; the remaining
~1.3s outlier tail is streaming-session switches (React work-loop, not
style/layout) — next target.
Per hermes-sweeper review on PR #67005:
- Remove `broadcastSessionsChanged` import (unused)
- Remove `setSessionTodos` import (unused)
- Keep `clearActiveSessionTodos` and other active imports
No behavioral change, just dead code removal.
Cherry-pick of b4d5ba3e onto current main. Original commit's target file
apps/desktop/src/app/session/hooks/use-message-stream.ts has since been
moved into apps/desktop/src/app/session/hooks/use-message-stream/gateway-event.ts.
The fix is unchanged: replace clearSessionSubagents() with
pruneFinishedSessionSubagents() at the message.start boundary so that
still-running subagent rows survive new turns.
Per @teknium1's review of #64038 on 2026-07-16: 'mergeable_state=dirty'
because the dispatcher moved. Resolved by retargeting the call.
Tests: 3 new cases in subagents.test.ts (unchanged from original PR).
Failing-first verified in original PR (3/3 fail on unfixed, 3/3 pass on fix).
The salvaged no-op-publish guard moved the compression-rotation signal
into ensureSessionState, where storedSessionId is string|null. A cleared
stored id is a detach, not a rotation — firing the event with a null
next id would send the route-follow effect chasing nothing (and tsc
rejects it). Guard on a real next id.