Skip to content

perf(desktop): kill sidebar + overlay render churn from hot store subscriptions - #73698

Merged
OutThisLife merged 6 commits into
mainfrom
bb/desktop-overlay-perf
Jul 29, 2026
Merged

perf(desktop): kill sidebar + overlay render churn from hot store subscriptions#73698
OutThisLife merged 6 commits into
mainfrom
bb/desktop-overlay-perf

Conversation

@OutThisLife

@OutThisLife OutThisLife commented Jul 29, 2026

Copy link
Copy Markdown
Collaborator

Summary

Overlays and workspace pages showed heavy render churn while a session streamed. The measured cause splits into three independent mechanisms, not one:

  1. Sidebar bleed (dominant, cross-cutting). The sidebar stays mounted in the shell beneath/beside every overlay and page. It subscribes to hot stores that republish on every streaming delta, and its rows were unmemoized — so one token re-rendered the whole session list, and the global render counter attributes that churn to whatever surface is open at the time. This is why Cron, Profiles, Agents, Starmap, Webhooks all showed the same Codicon/SidebarRowLabel/SessionStatusDot churn despite sharing no page code.
  2. Per-surface hot-store subscriptions. Command Center, Skills, and Settings each subscribed to a hot store ($sessions, $gateway) unconditionally, re-rendering the overlay itself on every tick even when the subscribed data wasn't on screen.
  3. Per-surface local loops. Artifacts has a self-sustaining tooltip/link-title cycle that churns at idle with nothing streaming — unrelated to the stores above.

Each is addressed (or scoped out) separately below.


Hot stores involved and what ticks them

store republishes on consumers hit
$sessions every session title update / new session / stream-driven mutation (gateway-event.ts writes on title deltas) sidebar list, Command Center
$workingSessionIds a session starting/finishing a turn sidebar rows, every SessionStatusDot
$attentionSessionIds, $stalledSessionIds, $unreadFinishedSessionIds, $backgroundRunningSessionIds per-session status transitions during streaming every SessionStatusDot
$gateway connect / disconnect / reconnect Skills view (only needed by MCP tab)

Each is a plain nanostore that publishes a new array/object reference per write, so useStore(store) re-renders every consumer on every write regardless of whether the slice they read changed.


Per-surface breakdown (measured, 2s window, CDP render counter)

Origin column: sidebar = churn from the shell's session list captured by the global counter while this surface is open; own = components rendered by this surface itself.

surface dominant churning components origin before (wasted) after
Cron Codicon 218, SidebarRowLabel 90, DisclosureCaret 9 100% sidebar 407 ~30 (idle-own: 0)
Starmap Codicon 78, SidebarRowLabel 30 100% sidebar 188 56 (idle-own: 0)
Profiles Codicon 370, SidebarRowLabel 150 (sidebar) + TooltipContent 7 (own) ~95% sidebar 732 ~80
Agents Codicon 73, SidebarRowLabel 30 100% sidebar 132 9
Webhooks Codicon 76, SidebarRowLabel 30 100% sidebar 154 22
Command Center › System Codicon 169, SidebarRowLabel 60, Kbd 28, GitBranch 18 sidebar + own $sessions sub 425 0–10
Messaging Codicon 80, PlatformAvatar 65, StatusDot 64, PlatformRow 31 sidebar + own platform rows 312 ~170 (own residual ~17)
Skills (Skills tab) Kbd 12, Codicon 11, GitBranch 9, KbdCombo 5 own $gateway sub (keybind bar) 88 8
Settings › Model SelectItem 123, Primitive.span 408, SelectItemProvider 123 own model-<Select> re-rendering under parent churn 1,477* 0
Settings (Appearance/Notifications/Providers/Keybinds/API Keys/Plugins/Archived/About) 0 0
Artifacts TooltipContent/Presence/Tooltip/Popper (mount cascade) own (mount) 5,528 (mount) → 12 settled 12
CmdK root (typing "setting") Block 833, Ct 833, inlineCode 807 (transcript behind modal) + PaletteRow/CommandItem 51 (own) own + transcript 5,519 3,201
CmdK theme / color-mode submenus PaletteRow/CommandItem 51 own 209 ≈neutral

* Settings › Model's 1,477 was captured during an active stream tick landing while the model <Select> was mounted; at idle it is 0. It re-rendered because its parent (SettingsView) re-rendered — fixed indirectly by memoizing navGroups so the overlay stops rebuilding, plus the sidebar no longer forcing shell-wide work. Not a targeted <Select> change.


Fixes (mechanism per file)

chat/sidebar/session-row.tsx — the cross-cutting fix.
SidebarSessionRow is now memo() with a custom comparator. The row's callback props (onArchive, onResume, onPin, …) are fresh closures every render by design — they close over the row's session id — so a default memo never bails. They are pure id-forwarders (identical behavior for a given row), so the comparator ignores them and compares only paint-affecting data: session, isPinned, isSelected, isWorking, branchStem, reorderable, dragging, showProfile, dragHandleProps, className, style. A row whose data is unchanged now bails while a sibling session streams. Verified: after clicking through 6 sessions, SidebarSessionRow no longer appears in the render report at all.

chat/session-status-dot.tsx — per-session selector.
The five $…SessionIds arrays were read as useStore(array).includes(id) — re-rendering every dot on every array tick. Now each reads via useStoreSelector(array, ids => ids.includes(storedSessionId)), which returns a boolean and bails on Object.is, so a dot repaints only when its own membership flips. This is what stops the SessionStatusDot and downstream Codicon churn that dominated every overlay's numbers.

command-center/index.tsx — gate + memo.
$sessions/$pinnedSessionIds are read through useStoreSelector returning a stable empty array when section !== 'sessions', so the System/Usage/Maintenance tabs never re-render from session churn. navGroups moved into useMemo.

skills/index.tsx — gate.
$gateway (needed only by the MCP tab) read through useStoreSelector returning null unless mode === 'mcp', so the Skills/Toolsets/Hub tabs stop re-rendering on connect/disconnect. Dropped the now-unused HermesGateway import.

settings/index.tsx — memo + stable callbacks.
navGroups (previously rebuilt inline with fresh onSelect closures for every nav item on every render) moved into useMemo; openSubView/openProviderView/openKeysView wrapped in useCallback so the memo deps are honest and stable.

overlays/overlay-split-layout.tsx — leaf memo.
OverlayNavItem wrapped in memo() so a nav rail item doesn't re-render when a sibling's active state flips.

command-palette/index.tsx — leaf memo.
Extracted PaletteRow as a memo() component so palette items don't re-render on unrelated parent state (open/close, theme). Cut CmdK-root wasted renders 5,519 → 3,201 (42%).

artifacts/index.tsx — stable ctx + cell memo.
cellCtx moved into useMemo with a useCallback'd onOpenChat (was a fresh object + closure every render, re-rendering every cell). PrimaryCell/LocationCell/SessionCell wrapped in memo() so one row's async link-title fetch doesn't re-render the whole table during the mount cascade. Correction to an earlier draft of this description: the ~5,500 Artifacts number was the one-time mount/load cascade (fetching artifacts, resolving link titles, mounting per-row tooltips), NOT a steady-state loop — measured settled (4s after mount) Artifacts is 12 wasted renders / 3s. The cell memo lowers the mount-cascade cost; there is no idle loop to fix.


What is NOT fixed (scoped to follow-up PRs)

  • Messaging platform-section churn (settled). PlatformAvatar/StatusDot re-render ~32×/3s (prop-driven, state:0) from the sidebar messaging section on stream ticks — a real steady-state loop, separate follow-up PR.
  • Artifacts mount cascade. Not an idle loop (settled = 12 wasted/3s); the mount cost is inherent to loading + link-title resolution + tooltip mount. Cell memo already trims it. No further action needed.

Metrics methodology

Measured on a live desktop instance via the in-app CDP render counter (__RENDER_COUNTS__), medians over 3 runs where noted. Sidebar-bleed numbers were captured with a session actively streaming (the only condition under which those stores tick); at idle both before and after read ~0 for those surfaces, because the churn is stream-driven — which is exactly when the app felt laggy. Artifacts was initially reported as an idle loop; on re-measurement it is a one-time mount cascade (settled = 12 wasted/3s), corrected above.

Pattern

Same invariant as #72504: hot-path state must reach leaves through stable identities. A parent subscribing to a store that ticks, then rendering a .map() of children, converts one store tick into N subtree renders. The fix is useStoreSelector (subscribe to a scalar, bail on Object.is) + memo on the leaf — with a comparator that ignores callbacks whose behavior is stable even when their identity is not.

Verification

  • apps/desktop typecheck: clean
  • apps/desktop lint: 0 errors
  • apps/desktop tests: 104 passed

…alette rows

Command Center, Settings, Skills, and Command Palette all subscribed to
hot stores unconditionally —  ticks on every streaming token (title
updates),  on every connect/disconnect. Components that only need
that data on one tab were re-rendering on every stream delta while sitting
on an unrelated tab.

Fixes:
- CommandCenterView: gate / to the Sessions tab
  via useStoreSelector returning a stable empty array on other tabs
- SkillsView: gate  to the MCP tab only
- SettingsView: memoize navGroups (was rebuilt inline on every render with
  fresh onSelect closures for every nav item)
- OverlayNavItem: memo() so nav items don't re-render when a sibling's
  active state changes
- CommandPalette: extract memoized PaletteRow so items don't re-render on
  unrelated parent state changes (open/close, theme, etc)
- CommandCenterView: memoize navGroups array (was inline JSX)
@github-actions

github-actions Bot commented Jul 29, 2026

Copy link
Copy Markdown
Contributor

૮ >ﻌ< ა ci review

ran on 50d0de0

all good!

@alt-glitch alt-glitch added type/perf Performance improvement or optimization P3 Low — cosmetic, nice to have comp/desktop Electron desktop app (apps/desktop/*) labels Jul 29, 2026
The sidebar stays mounted beneath every overlay/page, and it subscribes to
$sessions + $workingSessionIds — both tick on every streaming token. An
unmemoized SidebarSessionRow re-rendered the whole list (Codicon, labels,
status dots) on each delta, and that churn bled into every overlay opened on
top: Cron, Profiles, Agents, Starmap, Webhooks, Command Center, Settings.

- SidebarSessionRow: memo() with a custom comparator that ignores the pure
  id-forwarding callbacks (fresh closures by design) and compares only the
  data that changes what the row paints. Rows bail out while siblings stream.
- SessionStatusDot: the 5 $...SessionIds arrays now read via useStoreSelector
  returning this session's boolean, so a dot repaints only when ITS OWN
  membership flips, not on every array tick.
- Artifacts: stable cellCtx (useMemo) + memoized Primary/Location/Session
  cells so a link-title fetch on one row stops re-rendering the whole table.

Measured before/after (2s idle, sessions streaming), sidebar-fed overlays:
  Cron 407->~30 wasted, Profiles 732->~80, Agents 132->9, Starmap 188->56,
  Webhooks 154->22.
@OutThisLife OutThisLife changed the title perf(desktop): gate hot store subscriptions + memoize overlay nav + palette rows perf(desktop): kill sidebar + overlay render churn from hot store subscriptions Jul 29, 2026
…deps

- command-center/command-palette/skills: import ordering + drop unused
  HermesGateway type
- settings: wrap openSubView/openProviderView/openKeysView in useCallback so
  the navGroups memo deps are honest and stable
- command-center: add setSection to navGroups memo deps
@OutThisLife
OutThisLife merged commit 40a53ca into main Jul 29, 2026
34 checks passed
@OutThisLife
OutThisLife deleted the bb/desktop-overlay-perf branch July 29, 2026 02:01
randlee pushed a commit to randlee/hermes-agent that referenced this pull request Aug 11, 2026
…erlay-perf

perf(desktop): kill sidebar + overlay render churn from hot store subscriptions
33hodl pushed a commit to 33hodl/hermes-agent that referenced this pull request Aug 12, 2026
…erlay-perf

perf(desktop): kill sidebar + overlay render churn from hot store subscriptions
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

comp/desktop Electron desktop app (apps/desktop/*) P3 Low — cosmetic, nice to have type/perf Performance improvement or optimization

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants