A cold profile switch pays the full pool-backend spawn — Python boot,
port announcement, readiness probe, token adoption — before the
profile's gateway can even open. Measured with the new CDP harness
(scripts/measure-profile-switch.mjs, same family as
profile-session-switch.mjs): click → WS open is ~2.5-2.9s on a cold
profile, ~3-3.6s to a settled sidebar; a warm profile settles in
~0.5-0.8s. The pointer entering a profile square telegraphs the switch
hundreds of ms before the click lands, so start the spawn then.
- store/profile: prewarmProfileBackend(name) — fires the existing
hermesDesktop.getConnection IPC, which is idempotent (ensureBackend
returns the pooled connectionPromise), so the real switch joins the
in-flight spawn instead of starting it. Skips the active gateway
profile, throttles per profile (60s) so drive-by hovers can't spam
spawn attempts, and swallows failures — error UX belongs to the real
switch. No new IPC surface; the pool's existing LRU cap + idle reaper
still bound resource use, and the LRU guard never evicts a
keepalive-fresh backend for a hover spawn.
- sidebar/use-profile-prewarm: pointerenter/pointerleave handlers with
a 120ms dwell so sweeping the pointer across the rail or a
mixed-profile session list doesn't spawn a backend per element
crossed.
- Wired at the three switch surfaces: rail ProfileSquare, the condensed
ProfileDropdown items (extracted ProfileDropdownItem so each row owns
its dwell timer), and SidebarSessionRow (covers cross-profile resumes
from the all-profiles view; same-profile rows no-op inside the guard).
Measured E2E over CDP: synthetic hover on a cold profile square spawns
its backend in the background; the subsequent click settles in ~519ms
vs ~3.0-3.6s unhovered — and any hover shorter than the spawn still
shaves its dwell off the click's wait.
Verification: apps/desktop `npx tsc --noEmit` clean; full
`npx vitest run` 212 files / 1777 passed (new prewarm guard/throttle
tests in store/profile.test.ts); eslint + prettier clean.
Follow-up to #65890 (router transitions off) and #65898 (structural
compare + first-paint budget): profiling the switch path on real 1000+-
message sessions with a new CDP harness showed the remaining freeze is
NOT markdown rendering — it's a forced-reflow cascade from mount-time
layout reads interleaved with style writes across the transcript's
layout effects, plus the first-paint budget cut landing too late to
stop the full-budget commit.
Measured on the two largest local sessions (996 and 1363 messages),
main-thread longtask totals per switch: warm 2450ms -> 557ms and
1158ms -> 194ms; first paint 1690ms -> 444ms. Harness:
scripts/profile-session-switch.mjs (same CDP family as
profile-real-stream.mjs).
- use-resize-observer: drop the synchronous initial callback and ride
the observer's spec-guaranteed first delivery instead (same frame,
after layout, before paint). The sync call ran while the commit's
layout was dirty, so every size read in a callback forced a full
reflow — with one instance per user bubble (measureClamp read
scrollHeight, then WROTE --human-msg-full, re-dirtying layout for the
next bubble), the switch commit thrashed for over a second. Inside RO
timing the same reads are free. Composer metrics (2x
getBoundingClientRect + documentElement style writes) rides the same
fix.
- Same class, same fix at the remaining call sites profiling surfaced:
ExpandableBlock and TerminalOutput (dozens per tool-heavy transcript)
now measure/pin via RO initial delivery; the tool-window and
thinking-preview pins drop their sync pin() call; the thread
timeline's initial active-tick compute joins its existing
scroll-time rAF batching so back-to-back transcript updates coalesce.
- thread/list: cut the render budget in the RENDER phase (state-from-
props adjustment) instead of the post-commit layout effect. The
effect-time cut was too late — on a warm switch React first built and
committed the full 300-part tree, then re-rendered at 60, then bumped
back to 300, so the expensive commit still happened (and on a cold
switch the bump rAF usually fired while the transcript was still
empty, so the prefetched messages rendered at full budget anyway).
The render-phase cut restarts the component before any child renders;
a second trigger handles the cold path where messages land later
under the same sessionKey.
- thread/list: backfill 60 -> 300 inside startTransition so the older
turns' markdown+shiki render is interruptible background work instead
of a synchronous freeze one frame after the switch paints. Functional
Math.max so an urgent "Show earlier" click can't be rebased back down.
- composer focus: skip the rAF/timeout focus retries when the element
is already focused — focus() runs the full focusing steps (forcing
layout) even on the active element, ~585ms per switch on a large
dirty DOM.
- Replace the tautological render-budget test (it re-declared the
constants locally and asserted 60 < 300) with behavior tests of the
now-exported buildGroups + firstVisibleGroupIndex.
Verification: apps/desktop `npx tsc --noEmit` clean; full
`npx vitest run` 210 files / 1763 passed; manual CDP check confirms the
deferred backfill commits the full transcript, stays pinned to bottom,
and "Show earlier" still pages.
Follow-up to #65890 (router transitions off) and #65898 (structural
compare + first-paint budget): profiling the switch path on real 1000+-
message sessions with a new CDP harness showed the remaining freeze is
NOT markdown rendering — it's a forced-reflow cascade from mount-time
layout reads interleaved with style writes across the transcript's
layout effects, plus the first-paint budget cut landing too late to
stop the full-budget commit.
Measured on the two largest local sessions (996 and 1363 messages),
main-thread longtask totals per switch: warm 2450ms -> 557ms and
1158ms -> 194ms; first paint 1690ms -> 444ms. Harness:
scripts/profile-session-switch.mjs (same CDP family as
profile-real-stream.mjs).
- use-resize-observer: drop the synchronous initial callback and ride
the observer's spec-guaranteed first delivery instead (same frame,
after layout, before paint). The sync call ran while the commit's
layout was dirty, so every size read in a callback forced a full
reflow — with one instance per user bubble (measureClamp read
scrollHeight, then WROTE --human-msg-full, re-dirtying layout for the
next bubble), the switch commit thrashed for over a second. Inside RO
timing the same reads are free. Composer metrics (2x
getBoundingClientRect + documentElement style writes) rides the same
fix.
- Same class, same fix at the remaining call sites profiling surfaced:
ExpandableBlock and TerminalOutput (dozens per tool-heavy transcript)
now measure/pin via RO initial delivery; the tool-window and
thinking-preview pins drop their sync pin() call; the thread
timeline's initial active-tick compute joins its existing
scroll-time rAF batching so back-to-back transcript updates coalesce.
- thread/list: cut the render budget in the RENDER phase (state-from-
props adjustment) instead of the post-commit layout effect. The
effect-time cut was too late — on a warm switch React first built and
committed the full 300-part tree, then re-rendered at 60, then bumped
back to 300, so the expensive commit still happened (and on a cold
switch the bump rAF usually fired while the transcript was still
empty, so the prefetched messages rendered at full budget anyway).
The render-phase cut restarts the component before any child renders;
a second trigger handles the cold path where messages land later
under the same sessionKey.
- thread/list: backfill 60 -> 300 inside startTransition so the older
turns' markdown+shiki render is interruptible background work instead
of a synchronous freeze one frame after the switch paints. Functional
Math.max so an urgent "Show earlier" click can't be rebased back down.
- composer focus: skip the rAF/timeout focus retries when the element
is already focused — focus() runs the full focusing steps (forcing
layout) even on the active element, ~585ms per switch on a large
dirty DOM.
- Replace the tautological render-budget test (it re-declared the
constants locally and asserted 60 < 300) with behavior tests of the
now-exported buildGroups + firstVisibleGroupIndex.
Verification: apps/desktop `npx tsc --noEmit` clean; full
`npx vitest run` 210 files / 1763 passed; manual CDP check confirms the
deferred backfill commits the full transcript, stays pinned to bottom,
and "Show earlier" still pages.
Auto-compression ends the SessionDB session and forks a continuation,
rotating the stored session id. The gateway emits `session.info` with
the new `stored_session_id`, and the desktop's cache entry was updated
via `ensureSessionState` — but the URL route and `$selectedStoredSessionId`
never followed the rotation.
On the next send, `getRuntimeIdForStoredSession(oldStoredId)` returned
null (the cache entry's `storedSessionId` no longer matched the old id),
so `routedSessionNeedsResume` evaluated true, triggering a full
`session.resume` + REST transcript prefetch — the whole thread reloaded.
Fix: a new `$activeSessionStoredId` atom is set in `ensureSessionState`
when the active session's stored id changes. A `useEffect` in
`use-session-actions` subscribes to it and re-anchors the route +
selection (`setSelectedStoredSessionId` + `navigate(replace: true)`),
and cleans up the stale stored→runtime mapping.
`replace: true` because it's the same conversation — compression is
transparent to the user, so back-button stays correct.
* feat(desktop): add useKeybindHint hook and TipKeybindLabel
Add a shared hook that reads the current keybind combo for an action
id from the $bindings store (rebindable) or KEYBIND_READONLY (fixed),
returning a formatted string or null when unbound.
Add TipKeybindLabel — a convenience component that auto-reads both
its label (from i18n) and keybind combo from the action registry.
Pass only actionId for the common case; pass text to override when
the tooltip is context-dependent.
* fix(desktop): replace native title= on buttons with themed Tip
Migrate all <button>/<Button> elements using the native HTML title=
attribute to the instant, themed <Tip> component. Native tooltips are
unstyled, delayed (~500ms OS default), and visually inconsistent with
the app's instant themed tooltips.
Also adds <Tip> wrappers to icon-only buttons that were missing
tooltips entirely (dialog close, search clear, overlay close,
master-detail pane controls, keybind panel rebind/reset buttons).
Adds an enforcement test (no-native-title.test.ts) that scans all
.tsx files for <button>/<Button> with title= and fails if any are
found. Updates DESIGN.md with the icon-only button tooltip rule and
keybind hint guidance.
* feat(desktop): wire keybind hints into button tooltips
Add actionId to TitlebarTool, StatusbarItem, and SidebarNavItem so
their tooltips show the current keybind combo via TipKeybindLabel.
Fix the hardcoded NEW_SESSION_KBD in the sidebar to read from
$bindings so it stays live on rebind.
Wired surfaces:
- Titlebar: sidebar toggle, flip panes, keybinds, settings
- Statusbar: terminal toggle (view.showTerminal)
- Status stack: open agents button (nav.agents)
- Sidebar nav: new session, skills, messaging, artifacts
* feat(desktop): move keybind panel to settings tab with search filter
Move the keyboard shortcuts panel from a Radix Dialog into a proper
Settings tab (/settings?tab=keybinds). The ⌘/ shortcut and titlebar
keyboard button now navigate to this settings tab instead of toggling
a dialog. Adds a search filter to filter shortcuts by label.
- New: src/app/settings/keybind-settings.tsx (extracted from keybind-panel.tsx)
- Delete: src/app/shell/keybind-panel.tsx (dialog wrapper removed)
- Remove: $keybindPanelOpen atom and toggle/open/close functions
- Add: IconKeyboard to lib/icons.ts
- i18n: keybinds.search + settings.nav.keybinds (en, zh, zh-hant, ja)
* refactor(desktop): unify worktree dialog into shared WorktreeDialog
Extract the worktree creation dialog from StartWorkButton (sidebar) into a
shared WorktreeDialog component. Both the sidebar's StartWorkButton and the
composer's CodingStatusRow now use the same dialog, eliminating the duplicated
UI.
The shared dialog keeps the sidebar version's full feature set:
- BaseBranchPicker (filterable base branch combobox)
- Convert mode (check out an existing branch into a worktree)
- Sanitized branch name input
The coding row passes repoPath (from cwd) and onOpenWorktree (which carries
the composer draft to the new session) so the unified dialog works everywhere
there's a repo, not just inside an entered project.
Switching between chat sessions in the desktop app froze for up to ~1–2s on
large transcripts. Profiling the switch path surfaced three main-thread
blockers, fixed here minimally and without changing behavior.
1. JSON.stringify deep-compare (worst case). chatMessagesEquivalent compared
message parts with JSON.stringify(a) === JSON.stringify(b) on every switch.
On image-/large-blob-bearing transcripts this serialized every part twice
and cost well over a second. Replaced with a structural compare that never
stringifies: array-level identity fast-path, per-part reference fast-path,
then type-aware field comparison. The compare's only consumer asks "did the
transcript change, should I setMessages?", so it is deliberately
conservative — a false-negative just causes one extra idempotent
setMessages, while a false-positive (the unsafe direction) is avoided.
2. Scroll-settle loop. thread-list ran a requestAnimationFrame settle loop up
to 90 frames (or 5 stable frames) on every sessionKey change, each frame
forcing a synchronous layout read + write — racing the markdown paint for
up to ~1.5s. A normal synchronous switch stabilizes within a couple
frames, so the ceiling is now 2 stable frames / 15 max.
3. Synchronous first paint of up to 300 parts. On switch, thread-list reset
the render budget to the full RENDER_BUDGET=300, so up to 300 parts went
through markdown + shiki syntax-highlighting synchronously on the switch
commit. It now paints a small FIRST_PAINT_BUDGET=60 first, then bumps to
the full 300 in a requestAnimationFrame after the first commit.
Salvaged from PR #49807 by professorpalmer — re-applied to the restructured
file layout (use-session-actions/utils.ts, thread/list.tsx) and tests merged
into the existing utils.test.ts.
Co-authored-by: Cary Palmer <professorpalmer@users.noreply.github.com>
The sidebar working dot didn't update for background sessions until the
user opened them. Two coupled causes:
1. The gateway's session.info event payload omitted stored_session_id,
so the desktop app had no way to map a background session's runtime
id to its stored id. Without the stored id, setSessionWorking(null,
...) was a no-op — the $workingSessionIds atom never updated.
2. The running→busy transition in the session.info handler was gated on
`apply` (active session only). The gate correctly scopes view-only
side effects (setCurrentModel, setCurrentCwd, etc.) to the focused
chat, but the per-session busy state drives the sidebar indicator and
must reach every session. updateSessionState only mutates the
per-runtime cache entry, and syncSessionStateToView already guards
the view publish to the active session, so ungating is safe.
Fix: add stored_session_id to _session_info() in tui_gateway/server.py,
add the field to GatewayEventPayload, pass it to updateSessionState in
the session.info handler, and ungate the running→busy transition.
react-router v7's HashRouter wraps every route state update in
React.startTransition() by default. In React 19's concurrent renderer,
transitions are non-urgent — React can yield mid-render and come back
later. When the app is under load (streaming token deltas, gateway
events, store updates from active sessions), those higher-priority
updates keep interrupting the transition, starving the route change commit.
This matches every symptom of the session-switch lag:
- Main thread is free (animations run, clicks work) — startTransition
defers, it does not block
- navigate() does not take effect for seconds — the transition keeps
getting interrupted by higher-priority updates
- Worse under load — more concurrent re-renders = more interruptions
- The whole UI (sidebar + main pane) does not update — the entire route
change is one transition, so nothing commits until it finishes
Pass useTransitions={false} to HashRouter so route state updates are
synchronous at default React priority instead of deferred transition
priority. navigate() now commits and paints immediately.
See: react-router v7 chunk-BIP66BKV.js HashRouter implementation.
Reassert the persisted webContents zoom after BrowserWindow moved, covering Windows monitor transitions where Chromium recalculates display scaling and drops the user-selected zoom.
Rebased onto current main, where the electron test harness migrated from
node:test to vitest (test:desktop:platforms = `vitest run --project electron`,
auto-discovering electron/*.test.ts). Swap the node:test import for vitest and
drop the .ts-extension import hack; the obsolete package.json node --test list
edit is dropped in the cherry-pick resolution since vitest auto-discovers.
Co-authored-by: Gille <4317663+helix4u@users.noreply.github.com>
selectModel in use-model-controls captured activeSessionId as a closure
prop, but the actions bag in wiring.tsx mutates in place to keep a stable
identity for memoized surfaces. The modelMenuContent useMemo captures
selectModel once when the gateway first opens (before any session is
active) and never re-evaluates, so clicking a model in an existing thread
goes through a stale closure with activeSessionId=null — the pick is
treated as UI-only, config.set is never sent, and the next session.info
event clobbers the optimistic update back to the session's real model.
Drop the activeSessionId prop entirely. All three callbacks now read
$activeSessionId.get() live from the store, matching the pattern
refreshCurrentModel already followed. This is the correct contract for
the actions bag's in-place mutation: callbacks read live state from the
store, not from captured props.
Route the provider config endpoints on the surface query param exactly
as main does: surface=declared serves the curated schema (now sourced
from the plugins' config_schema.py instead of the deleted
hermes_cli/memory_providers.py), while the default surface keeps
serving the raw plugin schema that the web dashboard parses. Both
surfaces honor the profile query param.
The declared PUT returns {ok: true} to match main's contract; only the
raw-surface PUT reports the activated provider. The desktop client
requests surface=declared again, and the undeclared-provider tests use
builtin now that honcho has a declared schema.
Skip argv ownership verification after the remote PID is already proven dead, then remove only the validated lock/log metadata and continue with a fresh spawn.
Add SSH as a separate saved connection shape while preserving Cloud URL/OAuth semantics, inactive SSH drafts, strict host/port normalization, and profile-specific precedence.
Guard staged node-pty ASAR path rewrites so already-unpacked paths are
not rewritten twice. Normalize spawn-helper to mode 0755 in both the
prebuild and locally compiled build/Release staging paths.
Add behavioral coverage for both unpacked path forms and both helper
layouts.
Co-authored-by: zhouwei <zwcf5200@163.com>
Co-authored-by: liuhao1024 <sunsky.lau@gmail.com>
hermes update is a Python CLI writing to a pipe when the Tauri updater or
the desktop's in-app POSIX path spawns it, so CPython block-buffers stdout.
Long quiet steps stream nothing to the progress UI. Worst case is the
pre-update backup (updates.pre_update_backup: true): it can zip multi-GB
archives for minutes while the updater still shows the previous line
('waiting for Hermes to exit...'). Users read that as a hang, cancel a
healthy update, and the orphaned child keeps mutating the install.
Set PYTHONUNBUFFERED=1 in both spawn sites (update_child_env in the Tauri
updater, applyUpdatesPosixInApp in the desktop) so output streams line by
line.
Also make the lock-probe unit test pass on macOS: the packaged payload
lives under Contents/Resources there, and Path::ends_with is
case-sensitive, so the lowercase resources/app.asar assertion only ever
matched the Windows/Linux layouts.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Conflict resolutions:
- web_server.py: keep the branch's unified provider-config handlers
(declared schema served by default, profile-scoped) over main's
surface=declared query param. Main's declared-surface helpers and the
hermes_cli.memory_providers import are dropped — the branch deleted
that module when provider schemas moved into their plugins, so main's
code path could no longer import.
- hermes.ts: keep profileScoped() calls without ?surface=declared to
match the unified backend.
- constants.ts: take main's new reasoning effort values (max, ultra),
keep the branch's memory provider ordering.
- config-settings.tsx: take main's FallbackModelsField import, drop the
now-unused SECTIONS import (branch replaced it with
sectionFieldEntries).
- provider-config-panel.test.tsx: file moved to settings/memory/ on the
branch; main's act() fixes targeted the old tests, and the rewritten
suite produces no act() warnings, so the old path stays deleted.
- test_web_server.py: keep both sides' new tests (main's MoA endpoint
tests plus the branch's Honcho provider tests).
The CI change classifier routes package.json / package-lock.json /
.ts/.tsx changes to the frontend lane, not the Python lane. Four
Python tests asserted about these JS-side artifacts, so a PR touching
only those files would skip the Python suite — the regression goes
green on the PR and red on main (where the classifier fails open).
Ported all four to vitest so they run in the correct CI lane:
tests/test_package_json_lazy_deps.py
→ apps/desktop/electron/package-json-lazy-deps.test.ts
(camofox is lazy, agent-browser is eager, lockfile clean)
tests/test_desktop_electron_pin.py
→ apps/desktop/electron/desktop-electron-pin.test.ts
(electron dep is exact, matches build.electronVersion, lockfile agrees)
tests/test_assistant_ui_tap_compat.py
→ apps/desktop/electron/assistant-ui-tap-compat.test.ts
(@assistant-ui cluster shares one tap version + semver helper)
tests/test_dashboard_sidecar_close_on_disconnect.py
→ web/src/lib/chat-sidebar-session-params.test.ts
(sidecar session.create opts into close_on_disconnect + profile)
The ChatSidebar test was regex-matching .tsx source text (the
source-reading anti-pattern). Extracted sidecarSessionCreateParams()
from the component's effect into an exported pure function so the
test calls real code instead of pattern-matching a string.
Verified: 8 electron tests + 76 web tests pass; both typecheck clean.
The hoisted shared eslint config catches pre-existing no-useless-escape
and no-control-regex errors in apps/desktop that were only fixed in web/
in the previous commit.
- no-useless-escape: remove unnecessary \) escapes inside character classes
- no-control-regex: add eslint-disable-next-line comments for intentional
\x1b terminal escape byte matching (same pattern as web/src/lib/pty-mobile-input.ts)
* feat(desktop): add background-task indicator to sidebar session rows
A session with a live terminal(background=true) process but no active LLM
turn now shows a pulsing gray dot in the sidebar — distinct from the accent
pulse of an active turn and the steady amber/green of needs-input/unread.
New $backgroundRunningSessionIds computed atom joins
$backgroundStatusBySession (runtime-keyed) with $sessionStates
(runtime→stored) to produce stored session ids the sidebar row can match
against — same pattern as $attentionSessionIds and $unreadFinishedSessionIds.
Also refactors SidebarRowDot from a 3-branch if-else chain into a
table-driven priority array of DotState entries. Each state declares its
active flag, className, ariaLabel, and title in one place — adding a new
indicator state is now one array entry instead of another conditional.
* fix(desktop): keep pulse-dot before:bg-* static so Tailwind emits it
The PING() helper interpolated the color into `before:bg-${color}`, but
Tailwind v4 only generates utilities it finds as complete static strings — a
template-composed `before:bg-(--ui-accent)` / `before:bg-muted-foreground/50`
is never emitted, so both pulse rings (working + new background) lost their
halo color. Make PING a static scaffold and write the before:bg color inline
per variant.
---------
Co-authored-by: Brooklyn Nicholson <brooklyn.bb.nicholson@gmail.com>
SessionInfo already carries git_branch from the backend, but neither the
ctrl-k command palette nor the sidebar search function included it in
their matching fields. Typing a branch name matched nothing.
- session-search.ts: add session.git_branch to sessionMatchesSearch
- command-palette/index.tsx: thread git_branch through SessionEntry and
into the keywords array for both sessions and archived sessions groups
- session-search.test.ts: cover full, partial, and "main" branch matches
* fix(desktop): un-minimize zone when revealing a collapsed tool panel
`revealTreePane()` fronted the pane's tab but never un-minimized its zone
when the pane lived in a shared zone (terminal + logs, or a tool panel
stacked with the workspace). The shared-zone branch of `setPaneCollapsed`
routes opens through `revealTreePane` instead of `toggleTreeGroupMinimized`,
so the zone stayed collapsed and the terminal appeared to "close but not
open" on Ctrl+` — and tab clicks needed a double-click because the first
click's `restoreTreePane` → opener → listener → `revealTreePane` chain
didn't un-minimize either.
The fix: `revealTreePane` now restores a minimized zone before fronting
the pane, chaining the un-minimize + activate into a single `commit` so
the second op sees the updated tree.
* fix(desktop): side collapse in column-root layouts + restore after zone-menu minimize
Two follow-up fixes for the panel layout:
1. Ctrl+B / Ctrl+J sidebar toggles didn't work in the Terminal deck (and
Quad) layout. The side-collapse system (treeSideOfPane, paneRootSide,
layoutHasRootSide, and the renderer's semanticSides) only operated on the
ROOT split when it was a row — but Terminal deck's root is a column, so the
side columns (sessions/files) nested inside a child row were invisible to
the collapse system. Extracted a rootRow() helper that finds the row
containing main (the root itself for row layouts, or the row child of a
column root), and a rootRow prop propagated through TreeNode → TreeSplit so
semanticSides fires on the right split regardless of root orientation.
2. Clicking the "logs" tab in a minimized terminal/logs zone needed a
double-click when the zone was minimized via the zone menu (right-click →
minimize), not the toggle. restoreTreePane called the opener
($logsOpen.set(true)) and returned early — but when the store was already
true, nanostores don't fire the listener, so setPaneCollapsed/revealTreePane
never ran and the zone stayed minimized. Now restoreTreePane also
un-minimizes directly + calls revealTreePane when the zone is still
minimized after the opener runs.