perf(desktop): kill sidebar + overlay render churn from hot store subscriptions - #73698
Merged
Conversation
…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)
Contributor
૮ >ﻌ< ა ci reviewran on 50d0de0 all good! |
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.
…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
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Overlays and workspace pages showed heavy render churn while a session streamed. The measured cause splits into three independent mechanisms, not one:
Codicon/SidebarRowLabel/SessionStatusDotchurn despite sharing no page code.$sessions,$gateway) unconditionally, re-rendering the overlay itself on every tick even when the subscribed data wasn't on screen.Each is addressed (or scoped out) separately below.
Hot stores involved and what ticks them
$sessionsgateway-event.tswrites on title deltas)$workingSessionIdsSessionStatusDot$attentionSessionIds,$stalledSessionIds,$unreadFinishedSessionIds,$backgroundRunningSessionIdsSessionStatusDot$gatewayEach 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.
Codicon218,SidebarRowLabel90,DisclosureCaret9Codicon78,SidebarRowLabel30Codicon370,SidebarRowLabel150 (sidebar) +TooltipContent7 (own)Codicon73,SidebarRowLabel30Codicon76,SidebarRowLabel30Codicon169,SidebarRowLabel60,Kbd28,GitBranch18$sessionssubCodicon80,PlatformAvatar65,StatusDot64,PlatformRow31Kbd12,Codicon11,GitBranch9,KbdCombo5$gatewaysub (keybind bar)SelectItem123,Primitive.span408,SelectItemProvider123<Select>re-rendering under parent churnTooltipContent/Presence/Tooltip/Popper(mount cascade)Block833,Ct833,inlineCode807 (transcript behind modal) +PaletteRow/CommandItem51 (own)PaletteRow/CommandItem51* 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 memoizingnavGroupsso 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.SidebarSessionRowis nowmemo()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 defaultmemonever 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,SidebarSessionRowno longer appears in the render report at all.chat/session-status-dot.tsx— per-session selector.The five
$…SessionIdsarrays were read asuseStore(array).includes(id)— re-rendering every dot on every array tick. Now each reads viauseStoreSelector(array, ids => ids.includes(storedSessionId)), which returns a boolean and bails onObject.is, so a dot repaints only when its own membership flips. This is what stops theSessionStatusDotand downstreamCodiconchurn that dominated every overlay's numbers.command-center/index.tsx— gate + memo.$sessions/$pinnedSessionIdsare read throughuseStoreSelectorreturning a stable empty array whensection !== 'sessions', so the System/Usage/Maintenance tabs never re-render from session churn.navGroupsmoved intouseMemo.skills/index.tsx— gate.$gateway(needed only by the MCP tab) read throughuseStoreSelectorreturningnullunlessmode === 'mcp', so the Skills/Toolsets/Hub tabs stop re-rendering on connect/disconnect. Dropped the now-unusedHermesGatewayimport.settings/index.tsx— memo + stable callbacks.navGroups(previously rebuilt inline with freshonSelectclosures for every nav item on every render) moved intouseMemo;openSubView/openProviderView/openKeysViewwrapped inuseCallbackso the memo deps are honest and stable.overlays/overlay-split-layout.tsx— leaf memo.OverlayNavItemwrapped inmemo()so a nav rail item doesn't re-render when a sibling's active state flips.command-palette/index.tsx— leaf memo.Extracted
PaletteRowas amemo()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.cellCtxmoved intouseMemowith auseCallback'donOpenChat(was a fresh object + closure every render, re-rendering every cell).PrimaryCell/LocationCell/SessionCellwrapped inmemo()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)
PlatformAvatar/StatusDotre-render ~32×/3s (prop-driven,state:0) from the sidebar messaging section on stream ticks — a real steady-state loop, separate follow-up PR.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 isuseStoreSelector(subscribe to a scalar, bail onObject.is) +memoon the leaf — with a comparator that ignores callbacks whose behavior is stable even when their identity is not.Verification
apps/desktoptypecheck: cleanapps/desktoplint: 0 errorsapps/desktoptests: 104 passed