Enter-commits-first was still filter-only past the first row: arrows
handed focus to Radix's own item focus, hover fought the keyboard for
which row Enter meant, and unfiltered MoA presets sat below zero model
matches as phantom commits.
The search input now owns the whole keyboard interaction over one flat
row list that mirrors exactly what's rendered (collapse, filter,
presets included — presets filter by query now too):
- no query → selection sits on the current model, Enter just closes
- typing → first match auto-selected, ↑/↓ step with wraparound (reset
on every keystroke), Enter commits the highlighted row
- selection scrolls into view; the highlight yields while the pointer
is in the list so hover and keyboard never disagree about Enter
composer.modelPicker shipped unbound and opened the full-screen picker
dialog. It now defaults to ⌘⇧M — the chord LibreChat, Open WebUI, and
Cherry Studio independently converged on — and toggles the composer
pill's live dropdown instead, search field ready: ⌘⇧M → 'gr' → Enter
switches model in two keypresses.
Routing follows the tab-verb convention (#74447): the request lands on
the chat surface in the hovered zone first, then the active composer,
skipping hidden keep-alive tabs. With no chat surface on screen
(settings, profiles) the keybind falls back to the full dialog; with
the gateway closed the pill opens the dialog like a click would.
Two fixes to the pill dropdown's filter, following the consensus across
VS Code, Zed, Open WebUI, and Cherry Studio:
- The pinned current model no longer rides along on a query it doesn't
match. It sat above the real matches looking like the top result, so
typing 'grok' and committing landed you back on the current model.
- Enter in the search field commits the first visible match (VS Code's
'so Enter works without pressing DownArrow first'). Radix highlights
nothing until an arrow key, so Enter used to dead-end; now
open → type → Enter is the whole switch.
Matched letters also render through HighlightMatches like the other
searchable pickers.
Typing in the model picker dialog, edit-models dialog, or command
palette now marks WHY each row matched: every occurrence of the query
(per-term for the palette's AND matcher) renders as an accent-colored
semantic <mark>. cmdk's scorer exposes no match ranges, so the shared
HighlightMatches primitive mirrors each surface's own filter semantics
instead: literal substring for the pickers, split-on-whitespace terms
with merged overlapping ranges for the palette.
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.
Backspace path-ascend keeping the leading command pill, folder picks
alongside a command pill, commits spanning Chromium-split text nodes,
slash-pill hydration boundaries (committed vs half-typed vs arg-taking),
and replaceBeforeCaret refusing across chip boundaries.
textBeforeCaret serialized chip labels into the string the trigger
regexes scan, so a leading /work pill made the anchored command regex
swallow the rest of the line as its argument — silencing the @ popover
for the whole message. Chips now contribute an object-replacement
placeholder and <br> a newline, so committed pills can't poison
detection.
Two halves of one bug class: the composer treated 'rebuild the editor
from serialized text' as routine, and serialized text couldn't express a
slash pill.
In-place commits: Chromium fragments text nodes around
contenteditable=false chips, so the old commit path — whole token in ONE
text node with the caret at its end — failed almost every keyboard pick
and fell into the rebuild fallback. rangeBeforeCaret now walks the token
backwards across sibling text nodes and refuses only at real boundaries
(chip, <br>, block), making in-place replacement the default for picks,
folder descends, Backspace path-ascends, and action items in both the
main composer and the user-edit composer.
Slash-pill hydration: renderComposerContents re-chipped @kind:value refs
but never /command, so any surviving rebuild (draft restore, undo, the
fallback above) demoted a committed command pill. A leading no-arg
/command now hydrates back to its pill; arg-taking commands stay text
since their tail may be uncommitted prose.
Also: popover picks bank an undo point in the main composer, and Tab is
swallowed while completions are in flight instead of moving focus out of
the composer.
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.
Trust explicit success over a stale isError envelope so real saves stop
painting amber. Over-budget refusals keep a soft warning with "Memory
write noted" instead of crowing "Saved", and entry_count labels as
entries.
The gateway keeps one PairingStore per served profile, but every
`/api/pairing` endpoint built the global one. An operator managing a named
profile saw the wrong pending list, and approving wrote a grant into a
whitelist their running gateway never consults — the user stays locked out
while the UI shows them as approved.
`_pairing_store(profile)` now resolves per profile and validates the name
(400/404 on an unknown one). No `_profile_scope` needed: PairingStore
resolves the profile's home itself, so nothing process-global is swapped
across an await.
Both GUIs had to change to match. The listing rides the query param — for
the dashboard that meant deleting `pairing` from the "machine-global, must
NOT be rewritten" exclusion list, a comment this change makes false. The
mutating endpoints read the profile off the BODY, which no query-param
rewrite reaches, so approve/revoke send it explicitly on both surfaces.
Desktop had no pairing surface at all. Someone DMs the bot, gets a code,
and lands in pending — where only the dashboard or `hermes pairing approve`
could let them in. That gap is why the dashboard's broken approve button
went unnoticed for so long: desktop users never saw the flow.
This puts it where the decision already lives. The messaging page is
already master-detail by platform, already polls every 6s, and already owns
"who can talk to this bot" — the allowlist env vars in the same detail pane
are what an approval writes into. A pending block sits above the credential
fields (approving is the hot path); a count badge on the platform row is the
discovery mechanism, so you see "Telegram 2" whenever you open Messaging for
any reason. Neither renders when nobody is waiting: approvals are rare, and
a permanent empty state would be chrome on a page that is otherwise about
credentials.
Approval sends the row's request_id and never a code — the code is the
requester's proof that the channel is theirs and is never returned by the
API. Rows paint optimistically from a snapshot and roll back visibly on
failure, and a 429 gets its own message since the code path's lockout is a
condition the operator can only wait out.
Pairing rides the existing platform refresh via allSettled, so a backend
without the endpoint yields no rows instead of blanking the page.
⌘1…⌘9, ⌃Tab, and the ⌘W / ⌘T family all resolved the last-interacted
zone, so switching tabs in a second pane meant clicking into it first.
They now resolve the HOVERED zone when the pointer is in one, falling
back to the focused zone otherwise — hover pane 1, ⌘2; hover pane 2, ⌘2,
and each lands in its own strip.
tabTargetGroupId() is the single resolver, keeping the number keys and
the tab verbs from disagreeing about which zone is "the" zone the way
they already couldn't. pointerover fires per boundary crossing rather
than per mouse move, and leaving the document clears the override so a
parked pointer never strands the keys on a stale zone.
Both surfaces passed the sidebar's in-place intent, which means "load it
into main when it isn't already on screen" — right for a row you clicked
in a list you were looking at, wrong for a chat opened from outside the
workspace. Neither had a surface of its own, so they took the one you
were using.
Add a stack intent for that case. It focuses the session when it's
already open, spends an unused draft tab when there is one, and only
falls back to main while main is itself a blank draft. Modifiers still
force a tab or window.
An unused draft tab is the one a user would have typed into, so give the
tile store a way to name it (blankDraftTile) and hand it to another
session in place (reuseBlankDraftTile). A blank-but-busy tab has its
first turn in flight and an unbound tile is unknown rather than empty, so
neither is a candidate.
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 thread's bottom clearance is composer + status-stack + 2rem, and both
inputs are measured by JS onto the owning [data-chat-surface]. The surface-var
helpers fell back to document.documentElement when they couldn't resolve one —
but :root is where every surface's DEFAULTS live, so a single stale write there
becomes a global floor under every thread's clearance until reload.
An unowned publisher has nowhere to publish to, so it now publishes nowhere.
The updater keeps a Tauri/Cocoa event loop alive while it relaunches the
desktop, and that loop can outlive app.exit(0). Relying on Drop alone
left a *successful* update looking active -- a live pid holding a fresh
marker -- which blocked desktop startup and, now that the marker is also
the cross-process update lock, every subsequent updater until the age
ceiling expired.
Release explicitly once all install-tree mutations are done, before the
relaunch. complete() is idempotent so Drop still covers the failure and
panic paths. Arms a process-exit fallback so a wedged event loop cannot
leave a finished updater lingering as a live pid.
Co-authored-by: nateEc <nateEc@users.noreply.github.com>
UpdateMarkerGuard::acquire overwrote the in-progress marker
unconditionally, so a Tauri update launched while a dashboard-spawned
"hermes update" was mid-flight simply took the marker and ran a second
updater over the same checkout. That is the race behind the reported
Windows failure: install-mode bootstrap rewound the tree while the
dashboard's updater was still running npm install against it.
acquire now returns Result and refuses when a live foreign owner holds
the marker, and Drop no longer deletes a marker this process does not
own. Liveness matches the Python and Electron readers of the same file:
dead pid or past the shared age ceiling means stale and reclaimable, so
a crashed updater cannot wedge future updates.
Adds a cfg(unix) libc dependency for the signal-0 liveness probe; the
Windows path uses OpenProcess/GetExitCodeProcess.
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.
The voice-interruption fix (5081551f0) covered the Python surfaces (CLI +
TUI gateway) but the desktop app has its own mic path in voice-barge-in.ts,
which still had both bugs on Windows:
- HALF-DUPLEX GAP: the barge monitor only opened when TTS playback started;
during LLM generation no mic was listening at all.
- PLAYBACK DEAFNESS: the monitor calibrated its noise floor while the
speakers were already playing TTS, baking bleed into the floor. On
Windows, Chromium echoCancellation does not reliably cancel same-app
output (measured live: quiet floor ~35-50 RMS vs playback bleed
600-1700 RMS), making the trigger unreachable.
Changes mirror tools/voice_mode.full_duplex_listen:
- voice-barge-in.ts: phase-aware full-duplex monitor — quiet-only
calibration (floor held through playback, never recalibrated against
bleed), playback min-trigger clamp + ceiling, 500ms grace on playback
onset only, windowed-majority detection.
- use-voice-conversation.ts: monitor arms at turn submit and spans
generation + playback (ensureBargeMonitor, idempotent). Mid-generation
speech fires the new onInterrupt callback; spoken stop-word during a
barge ends the conversation; submit waits for the interrupt to settle.
- use-composer-voice.ts + composer/index.tsx: plumb onInterrupt: haltRun
(same seam as the Stop button).
Tests: use-voice-conversation.test.tsx (6 tests) covering monitor lifecycle
across generation + playback, mid-generation interrupt, stop-word handling.
npm run typecheck + eslint clean; remaining desktop vitest failures
reproduce on a clean tree (pre-existing Windows env failures).
A live tool run renders its rows inside a ticker — a window exactly one
line tall that offsets to the newest row. Expanding a row inside it left
the row's output clipped to that line, and the next call ticked it away
entirely.
Opening a row is a request to read it, so the run gives up the window
until it settles: the ticker unmounts and the rows render at full
height, the same escape an approval already had.
`hermes.desktop.lastSessionId` is keyed by owning profile (143942d49), but
`hermes.desktop.lastRoute` stayed global -- and cold-start restore prefers the
route over the id. A session route embeds a session id in its path, so
relaunching under profile B navigated straight into a session owned by profile
A, bypassing the id scoping entirely (#67603 family).
Key the remembered route by the same owner the id already uses
(rememberedSessionProfile), read it back for the active profile on restore, and
keep the default profile on the original unsuffixed key so existing installs'
remembered route survives the upgrade.
Salvaged from the profile-hint work in #49619, which threaded an explicit
profile through every route/IPC/window surface to reach the same end. The server
now stamps session ownership unconditionally (#74033), so the resolver already
gets the right answer and only this persisted key was left crossing profiles.
Co-authored-by: d31tcjg <d31tcjg@users.noreply.github.com>
`submit` measures the scroll jump when a turn is appended; nothing measured
the jump when a session is opened, which is the prepend/settle path. Clicks
sidebar rows and tracks how far the bottom turn moves after first paint.
Opening a session painted a small first budget, then prepended the rest of
the turns above the viewport with nothing holding the scroll position, so the
view was stranded near the top of the transcript until use-stick-to-bottom's
ResizeObserver caught up a frame or two later. The settle loop couldn't cover
for it either: keyed on sessionKey alone it measured an EMPTY viewport on a
cold load, saw a stable height, and declared the load settled hundreds of ms
before the messages arrived.
Anchor before the backfill prepends (the mechanism "Show earlier" already
uses) and re-arm the settle loop when the transcript actually lands.
Measured over 12 real sidebar-click loads: 12,179 -> 544 px of post-paint
movement per load, worst single jump 13,369 -> 2,296 px. Render churn is
unchanged (fewer commits and total renders, same wasted count).
Pebrd's fix is right but the `useMemo` it edits didn't list
`messagingSessions` as a dependency, so the index would go stale as the
messaging slice refreshed. Rather than regex around logic buried in a
1500-line component, lift it into `buildSessionByAnyId` where the
contract can be tested directly — the extraction LeonSGP43 proposed in
sibling PR #49896.
The test asserts the invariant that actually matters: a pin resolves
from every slice the sidebar fetches. Reverting the function to its
two-slice form fails it on the messaging and lineage-root cases.
Co-authored-by: LeonSGP43 <LeonSGP43@users.noreply.github.com>
The sessionByAnyId map only indexed cronSessions and visibleSessions
(local CLI chats), so a pinned gateway session (Telegram, etc.) stored
its pinId in localStorage but could never be resolved back to a
SessionInfo object — the Pinned section rendered empty for those.
Include messagingSessions in the lookup so pinned gateway chats appear
correctly at the top of the sidebar.
The pin bridge only ever pushed: localStorage to the backend, never
back. Two apps on the same gateway each kept their own localStorage, so
a pin made on the Mac never appeared on the Windows app.
Now that a pinned row is guaranteed to be in the page, its absence says
nothing about its pin state — which makes the server row authoritative.
`pullRemotePins` adopts pins this app hasn't seen and drops local pins
the server says are gone, keying on the durable lineage root so a pin
survives compression tip rotation. Adopted pins are recorded as already
mirrored rather than echoed back as a redundant write.
A list request in flight when we PATCH still carries the old value, so
honouring it would silently undo the pin the user just made. Writes are
guarded for the lifetime of their own request — no timers, no wall-clock
windows. A runtime predating the flag sends no `pinned` at all; that's
treated as no opinion and leaves the local set alone.
Co-authored-by: webtecnica <75556242+webtecnica@users.noreply.github.com>
Co-authored-by: Kyzcreig <9063726+Kyzcreig@users.noreply.github.com>
Co-authored-by: aman-merchant <274313970+aman-merchant@users.noreply.github.com>
Co-authored-by: rerdi92 <76791321+rerdi92@users.noreply.github.com>
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.
Two residual gaps from the #72707/#72632 probe-hardening series:
- backendSupportsServe never forwarded backend.shell to the serve
--help probe. A .cmd/.bat shim backend (which carries shell: true in
its step-4 descriptor) makes execFileSync throw EINVAL on modern
Node; the bare catch then caches supported=false for the process
lifetime, permanently routing that backend through the legacy
dashboard form. Forward the flag.
- The Windows python-discovery probes ran with no timeout at all:
reg query (registry read) and py.exe -c 'import sys;...' (bare
interpreter startup) are both synchronous execs on the boot path;
a wedged reg.exe or python.exe would hang the resolver forever.
Bound reg query at 5s and the py.exe probe at the shared
PROBE_TIMEOUT_MS budget.