perf(sidebar): stop rebuilding per-card selector records on every store write

Zustand re-runs every mounted subscriber's selector on every store write. The
per-worktree sidebar selectors built a fresh Record per call, so 15 visible
cards x 6 reads x every write allocated a record each time even when nothing
they read had changed.

- Add createWorktreeRecordSelector: gates the build on the source slice
  identities, memoizes per worktree id, and carries the previous generation
  forward so a rebuild with equal contents keeps its reference.
- Route the pane-title, live-PTY, layout-root and terminal-layout selectors
  through it, and return a shared frozen empty when a worktree has no tabs.
- Swap useWorktreeAgentRows' inactive-branch `[]`/`{}` literals for the shared
  frozen constants so the `active` gate actually short-circuits on identity.
- Identity-cache the sidebar pending-worktree-creation key list, which ran
  Object.values(...).map(...) from an always-mounted subscriber.
- Drop `key={text}` from TruncatedSidebarLabel so a label change remeasures in
  place instead of remounting the span and rebuilding its ResizeObserver.
- Remove the non-compositable `width` from the board drop indicator's
  will-change hint.
This commit is contained in:
Neil
2026-09-02 03:13:10 -07:00
parent 34999e328e
commit e798864bf7
12 changed files with 507 additions and 76 deletions
+3 -1
View File
@@ -1956,7 +1956,9 @@ html.native-shell .app-layout {
transform 120ms cubic-bezier(0.2, 0.8, 0.2, 1),
width 120ms cubic-bezier(0.2, 0.8, 0.2, 1),
opacity 80ms ease-out;
will-change: transform, width, opacity;
/* Why no `width`: it is not compositable, so hinting it only pins a layer that
has to be re-rastered every frame of the transition anyway. */
will-change: transform, opacity;
}
[data-workspace-board-card-drop-indicator='true']::before,
@@ -105,4 +105,40 @@ describe('TruncatedSidebarLabel', () => {
expect(container.textContent).toContain('feature/really-long-branch-name')
expect(container.querySelector('[data-tooltip-content]')).toBeNull()
})
// Why: worktree titles change on the hot store-write path. Remounting the
// span per text change tore down and rebuilt its ResizeObserver every time.
it('keeps one ResizeObserver across a label text change', async () => {
const originalResizeObserver = globalThis.ResizeObserver
let constructed = 0
let disconnected = 0
class CountingResizeObserver {
constructor(_callback: ResizeObserverCallback) {
constructed += 1
}
observe(): void {}
unobserve(): void {}
disconnect(): void {
disconnected += 1
}
}
globalThis.ResizeObserver = CountingResizeObserver as unknown as typeof ResizeObserver
try {
await act(async () => {
root.render(<TruncatedSidebarLabel text="feature/short" />)
})
expect(constructed).toBe(1)
await act(async () => {
root.render(<TruncatedSidebarLabel text="fix/short" />)
})
expect(container.textContent).toBe('fix/short')
expect(constructed).toBe(1)
expect(disconnected).toBe(0)
} finally {
globalThis.ResizeObserver = originalResizeObserver
}
})
})
@@ -1,4 +1,4 @@
import React, { useCallback, useState } from 'react'
import React, { useCallback, useLayoutEffect, useState } from 'react'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { cn } from '@/lib/utils'
@@ -23,6 +23,7 @@ export function TruncatedSidebarLabel({
tooltipSide = 'right',
tooltipSideOffset = 8
}: TruncatedSidebarLabelProps): React.JSX.Element {
const nodeRef = React.useRef<HTMLSpanElement | null>(null)
const resizeObserverRef = React.useRef<ResizeObserver | null>(null)
const removeResizeListenerRef = React.useRef<(() => void) | null>(null)
const [truncated, setTruncated] = useState(false)
@@ -39,6 +40,7 @@ export function TruncatedSidebarLabel({
removeResizeListenerRef.current?.()
removeResizeListenerRef.current = null
nodeRef.current = node
if (!node) {
measureTruncated(null)
return
@@ -60,14 +62,15 @@ export function TruncatedSidebarLabel({
[measureTruncated]
)
// Why: ResizeObserver does not fire when only the rendered text changes, but
// scrollWidth can. Remeasure in place rather than remounting the span, which
// would tear down and rebuild the observer on every title update.
useLayoutEffect(() => {
measureTruncated(nodeRef.current)
}, [measureTruncated, text])
const label = (
<span
// Why: ResizeObserver does not fire when only the rendered text changes,
// but scrollWidth can; remount so branch reuse remeasures immediately.
key={text}
ref={handleRef}
className={cn('block min-w-0 truncate', className)}
>
<span ref={handleRef} className={cn('block min-w-0 truncate', className)}>
{text}
</span>
)
@@ -5,22 +5,33 @@ import { applyAgentRowLineage } from '@/components/dashboard/agent-row-lineage'
import { migrationUnsupportedToAgentStatusEntry } from '@/lib/migration-unsupported-agent-entry'
import { useAppStore } from '@/store'
import {
EMPTY_LIVE_PTY_IDS,
EMPTY_RUNTIME_PANE_TITLES,
selectLivePtyIdsForWorktree,
selectRuntimePaneTitlesForWorktree
} from './worktree-card-status-inputs'
import { buildWorktreeAgentRows } from './worktree-agent-rows'
import {
EMPTY_LIVE_ENTRIES,
EMPTY_MIGRATION_UNSUPPORTED_ENTRIES,
EMPTY_RETAINED,
EMPTY_TERMINAL_LAYOUTS,
selectLiveAgentStatusEntriesForWorktree,
selectMigrationUnsupportedEntriesForWorktree,
selectRuntimeAgentOrchestrationForWorktree,
selectRetainedAgentEntriesForWorktree,
selectTerminalLayoutsForWorktree
} from './worktree-agent-row-selectors'
import { EMPTY_WORKTREE_AGENT_ORCHESTRATION } from './worktree-agent-orchestration-index'
import { EMPTY_TABS } from './WorktreeCardHelpers'
import {
createWorktreeAgentFreshnessSelector,
EMPTY_WORKTREE_AGENT_FRESHNESS_SIGNATURE
} from './worktree-agent-freshness-selector'
// Why frozen: shared by every inactive card, and callers only read these rows.
const EMPTY_AGENT_ROWS = Object.freeze([]) as unknown as DashboardAgentRow[]
export { buildWorktreeAgentRows } from './worktree-agent-rows'
export {
selectLiveAgentStatusEntriesForWorktree,
@@ -44,35 +55,51 @@ export function useWorktreeAgentRows(worktreeId: string, active = true): Dashboa
() => createWorktreeAgentFreshnessSelector(worktreeId),
[worktreeId]
)
const tabs = useAppStore((s) => (active ? s.tabsByWorktree[worktreeId] : undefined))
const tabs = useAppStore((s) => (active ? s.tabsByWorktree[worktreeId] : EMPTY_TABS))
// Why: narrow the subscriptions to only THIS worktree's entries via
// useShallow. Subscribing to the whole agentStatusByPaneKey map would make
// every on-screen card re-render on any agent-status update anywhere —
// O(worktrees²) render amplification. Pre-filtering here means the card
// only re-renders when something relevant to THIS worktree changes.
const liveEntries = useAppStore(
useShallow((s) => (active ? selectLiveAgentStatusEntriesForWorktree(s, worktreeId) : []))
useShallow((s) =>
active ? selectLiveAgentStatusEntriesForWorktree(s, worktreeId) : EMPTY_LIVE_ENTRIES
)
)
// Why: keep the store selector limited to stable raw records. Converting
// migration entries creates fresh objects with Date.now(), which breaks
// useSyncExternalStore's cached-snapshot contract and can blank Electron.
const migrationUnsupported = useAppStore(
useShallow((s) => (active ? selectMigrationUnsupportedEntriesForWorktree(s, worktreeId) : []))
useShallow((s) =>
active
? selectMigrationUnsupportedEntriesForWorktree(s, worktreeId)
: EMPTY_MIGRATION_UNSUPPORTED_ENTRIES
)
)
const retained = useAppStore(
useShallow((s) => (active ? selectRetainedAgentEntriesForWorktree(s, worktreeId) : []))
useShallow((s) =>
active ? selectRetainedAgentEntriesForWorktree(s, worktreeId) : EMPTY_RETAINED
)
)
const runtimePaneTitlesByTabId = useAppStore(
useShallow((s) => (active ? selectRuntimePaneTitlesForWorktree(s, worktreeId) : {}))
useShallow((s) =>
active ? selectRuntimePaneTitlesForWorktree(s, worktreeId) : EMPTY_RUNTIME_PANE_TITLES
)
)
const ptyIdsByTabId = useAppStore(
useShallow((s) => (active ? selectLivePtyIdsForWorktree(s, worktreeId) : {}))
useShallow((s) => (active ? selectLivePtyIdsForWorktree(s, worktreeId) : EMPTY_LIVE_PTY_IDS))
)
const terminalLayoutsByTabId = useAppStore(
useShallow((s) => (active ? selectTerminalLayoutsForWorktree(s, worktreeId) : {}))
useShallow((s) =>
active ? selectTerminalLayoutsForWorktree(s, worktreeId) : EMPTY_TERMINAL_LAYOUTS
)
)
const runtimeAgentOrchestrationByPaneKey = useAppStore(
useShallow((s) => (active ? selectRuntimeAgentOrchestrationForWorktree(s, worktreeId) : {}))
useShallow((s) =>
active
? selectRuntimeAgentOrchestrationForWorktree(s, worktreeId)
: EMPTY_WORKTREE_AGENT_ORCHESTRATION
)
)
const agentFreshnessSignature = useAppStore((s) =>
active ? selectAgentFreshness(s) : EMPTY_WORKTREE_AGENT_FRESHNESS_SIGNATURE
@@ -80,7 +107,7 @@ export function useWorktreeAgentRows(worktreeId: string, active = true): Dashboa
return useMemo<DashboardAgentRow[]>(() => {
if (!active) {
return []
return EMPTY_AGENT_ROWS
}
// Why: Date.now() is read inside the memo so stale-decay recalculates when
// this worktree's freshness signature changes, even without new PTY data.
@@ -97,7 +124,7 @@ export function useWorktreeAgentRows(worktreeId: string, active = true): Dashboa
: liveEntries
return applyAgentRowLineage(
buildWorktreeAgentRows({
tabs: tabs ?? [],
tabs: tabs ?? EMPTY_TABS,
entries,
retained,
runtimePaneTitlesByTabId,
@@ -9,10 +9,15 @@ import type { RetainedAgentEntry } from '@/store/slices/agent-status'
import { makePaneKey } from '../../../../shared/stable-pane-id'
import { getLiveEntriesFullRebuildCountForTests } from './worktree-agent-live-index-patch'
import {
EMPTY_LIVE_ENTRIES,
EMPTY_MIGRATION_UNSUPPORTED_ENTRIES,
EMPTY_RETAINED,
EMPTY_TERMINAL_LAYOUTS,
selectLiveAgentStatusEntriesForWorktree,
selectMigrationUnsupportedEntriesForWorktree,
selectRuntimeAgentOrchestrationForWorktree,
selectRetainedAgentEntriesForWorktree
selectRetainedAgentEntriesForWorktree,
selectTerminalLayoutsForWorktree
} from './worktree-agent-row-selectors'
const PANE_KEY_1 = makePaneKey('tab-1', '22222222-2222-4222-8222-222222222222')
@@ -94,8 +99,14 @@ describe('selectMigrationUnsupportedEntriesForWorktree', () => {
describe('selectLiveAgentStatusEntriesForWorktree', () => {
it('reuses unaffected worktree arrays when another worktree receives a same-state ping', () => {
const wt1Entry = makeEntry(PANE_KEY_1, 1000, { state: 'working', prompt: 'first' })
const wt2Entry = makeEntry(PANE_KEY_2, 1000, { state: 'working', prompt: 'first' })
const wt1Entry = makeEntry(PANE_KEY_1, 1000, {
state: 'working',
prompt: 'first'
})
const wt2Entry = makeEntry(PANE_KEY_2, 1000, {
state: 'working',
prompt: 'first'
})
const state = {
tabsByWorktree: {
'wt-1': [makeTab('tab-1')],
@@ -192,8 +203,14 @@ describe('selectLiveAgentStatusEntriesForWorktree', () => {
})
it('patches instead of full-rebuilding across within-state pings, and stays correct on transitions', () => {
const wt1Entry = makeEntry(PANE_KEY_1, 1000, { state: 'working', prompt: 'wt1 prompt' })
const wt2Entry = makeEntry(PANE_KEY_2, 1000, { state: 'working', prompt: 'wt2 prompt' })
const wt1Entry = makeEntry(PANE_KEY_1, 1000, {
state: 'working',
prompt: 'wt1 prompt'
})
const wt2Entry = makeEntry(PANE_KEY_2, 1000, {
state: 'working',
prompt: 'wt2 prompt'
})
const baseState = {
tabsByWorktree: {
'wt-1': [makeTab('tab-1')],
@@ -256,7 +273,10 @@ describe('selectLiveAgentStatusEntriesForWorktree', () => {
})
it('falls back to a full rebuild when a within-map update changes worktree attribution', () => {
const entry = makeEntry(PANE_KEY_1, 1000, { state: 'working', worktreeId: 'wt-1' })
const entry = makeEntry(PANE_KEY_1, 1000, {
state: 'working',
worktreeId: 'wt-1'
})
const state = {
// No tab membership: bucketing comes from entry.worktreeId attribution.
tabsByWorktree: { 'wt-1': [], 'wt-2': [] },
@@ -276,7 +296,10 @@ describe('selectLiveAgentStatusEntriesForWorktree', () => {
})
it('falls back to a full rebuild when a live entry completes with its tab gone', () => {
const entry = makeEntry(PANE_KEY_1, 1000, { state: 'working', worktreeId: 'wt-1' })
const entry = makeEntry(PANE_KEY_1, 1000, {
state: 'working',
worktreeId: 'wt-1'
})
const state = {
tabsByWorktree: { 'wt-1': [] },
agentStatusByPaneKey: { [PANE_KEY_1]: entry },
@@ -427,3 +450,79 @@ describe('selectRetainedAgentEntriesForWorktree', () => {
expect(secondWt2[0]?.startedAt).toBe(1100)
})
})
describe('selectTerminalLayoutsForWorktree', () => {
const layout = {
root: { type: 'leaf', leafId: '44444444-4444-4444-8444-444444444444' },
activeLeafId: '44444444-4444-4444-8444-444444444444',
expandedLeafId: null,
ptyIdsByLeafId: {}
} as const
// Why: every visible card re-runs this selector on every store write; a fresh
// record per call is an allocation per card per write.
it('returns one identity per store generation', () => {
const state = {
tabsByWorktree: { 'wt-1': [makeTab('tab-1')] },
terminalLayoutsByTabId: { 'tab-1': layout }
}
expect(selectTerminalLayoutsForWorktree(state, 'wt-1')).toBe(
selectTerminalLayoutsForWorktree(state, 'wt-1')
)
})
it('returns the shared frozen empty for a worktree with no tabs', () => {
const state = { tabsByWorktree: {}, terminalLayoutsByTabId: {} }
expect(selectTerminalLayoutsForWorktree(state, 'missing')).toBe(EMPTY_TERMINAL_LAYOUTS)
})
})
// Why: the inline-agents hook short-circuits on `active`; the off branch has to
// hand back a shared identity or the gate allocates on every store write.
describe('inactive-card empty constants', () => {
it('are frozen and shared', () => {
for (const empty of [
EMPTY_LIVE_ENTRIES,
EMPTY_MIGRATION_UNSUPPORTED_ENTRIES,
EMPTY_RETAINED,
EMPTY_TERMINAL_LAYOUTS
]) {
expect(Object.isFrozen(empty)).toBe(true)
}
expect(
selectLiveAgentStatusEntriesForWorktree(
{
agentStatusByPaneKey: {},
migrationUnsupportedByPtyId: {},
retainedAgentsByPaneKey: {},
tabsByWorktree: {}
},
'missing'
)
).toBe(EMPTY_LIVE_ENTRIES)
expect(
selectMigrationUnsupportedEntriesForWorktree(
{
agentStatusByPaneKey: {},
migrationUnsupportedByPtyId: {},
retainedAgentsByPaneKey: {},
tabsByWorktree: {}
},
'missing'
)
).toBe(EMPTY_MIGRATION_UNSUPPORTED_ENTRIES)
expect(
selectRetainedAgentEntriesForWorktree(
{
agentStatusByPaneKey: {},
migrationUnsupportedByPtyId: {},
retainedAgentsByPaneKey: {},
tabsByWorktree: {}
},
'missing'
)
).toBe(EMPTY_RETAINED)
})
})
@@ -13,11 +13,18 @@ import {
recordLiveEntriesFullRebuild
} from './worktree-agent-live-index-patch'
import { selectWorktreeAgentOrchestration } from './worktree-agent-orchestration-index'
import { createWorktreeRecordSelector } from './worktree-record-selector-cache'
import type { TerminalLayoutSnapshot } from '../../../../shared/terminal-tab-types'
const EMPTY_LIVE_ENTRIES: AgentStatusEntry[] = []
const EMPTY_MIGRATION_UNSUPPORTED_ENTRIES: MigrationUnsupportedPtyEntry[] = []
const EMPTY_RETAINED: RetainedAgentEntry[] = []
// Why frozen and exported: card hooks return these from their inactive branch,
// so the identity has to be shared app-wide and safe from stray writes.
export const EMPTY_LIVE_ENTRIES = Object.freeze([]) as unknown as AgentStatusEntry[]
export const EMPTY_MIGRATION_UNSUPPORTED_ENTRIES = Object.freeze(
[]
) as unknown as MigrationUnsupportedPtyEntry[]
export const EMPTY_RETAINED = Object.freeze([]) as unknown as RetainedAgentEntry[]
export const EMPTY_TERMINAL_LAYOUTS: Record<string, TerminalLayoutSnapshot | undefined> =
Object.freeze({})
// Why: selector unit tests often pass partial store mocks; production state
// owns these maps, but missing mock maps should behave like empty slices.
const EMPTY_RECORD = {}
@@ -268,13 +275,20 @@ export function selectRuntimeAgentOrchestrationForWorktree(
return selectWorktreeAgentOrchestration(state, worktreeId)
}
export function selectTerminalLayoutsForWorktree(
state: Pick<AppState, 'tabsByWorktree' | 'terminalLayoutsByTabId'>,
worktreeId: string
): Record<string, TerminalLayoutSnapshot | undefined> {
const out: Record<string, TerminalLayoutSnapshot | undefined> = {}
for (const tab of (state.tabsByWorktree ?? EMPTY_RECORD)[worktreeId] ?? []) {
out[tab.id] = (state.terminalLayoutsByTabId ?? EMPTY_RECORD)[tab.id]
export const selectTerminalLayoutsForWorktree = createWorktreeRecordSelector<
Pick<AppState, 'tabsByWorktree' | 'terminalLayoutsByTabId'>,
Record<string, TerminalLayoutSnapshot | undefined>
>({
readSources: (state) => [
state.tabsByWorktree ?? EMPTY_RECORD,
state.terminalLayoutsByTabId ?? EMPTY_RECORD
],
empty: EMPTY_TERMINAL_LAYOUTS,
build: (state, worktreeId) => {
const out: Record<string, TerminalLayoutSnapshot | undefined> = {}
for (const tab of (state.tabsByWorktree ?? EMPTY_RECORD)[worktreeId] ?? []) {
out[tab.id] = (state.terminalLayoutsByTabId ?? EMPTY_RECORD)[tab.id]
}
return out
}
return out
}
})
@@ -6,6 +6,9 @@ import type {
TerminalTab
} from '../../../../shared/terminal-tab-types'
import {
EMPTY_LIVE_PTY_IDS,
EMPTY_RUNTIME_PANE_TITLES,
EMPTY_TERMINAL_LAYOUT_ROOTS,
selectLivePtyIdsForWorktree,
selectTerminalLayoutRootsForWorktree,
selectTerminalLayoutRootsForWorktrees,
@@ -145,4 +148,75 @@ describe('worktree card status input selectors', () => {
)
).toBe(true)
})
// Why: zustand re-runs every mounted card's selector on every store write, so
// a fresh record per call multiplies by (visible cards x writes/sec).
it('returns one identity per store generation instead of rebuilding per call', () => {
const worktreeId = 'repo1::/path/wt1'
const state: SelectorState & LayoutRootSelectorState = {
tabsByWorktree: {
[worktreeId]: [makeTab('tab-1', worktreeId)]
},
runtimePaneTitlesByTabId: { 'tab-1': { 0: 'codex [working]' } },
ptyIdsByTabId: { 'tab-1': ['pty-1'] },
terminalLayoutsByTabId: {
'tab-1': makeLayout(
{ type: 'leaf', leafId: '11111111-1111-4111-8111-111111111111' },
'pty-1'
)
}
}
expect(selectRuntimePaneTitlesForWorktree(state, worktreeId)).toBe(
selectRuntimePaneTitlesForWorktree(state, worktreeId)
)
expect(selectLivePtyIdsForWorktree(state, worktreeId)).toBe(
selectLivePtyIdsForWorktree(state, worktreeId)
)
expect(selectTerminalLayoutRootsForWorktree(state, worktreeId)).toBe(
selectTerminalLayoutRootsForWorktree(state, worktreeId)
)
})
it('carries the same identity across unrelated pane-title and PTY churn', () => {
const worktreeId = 'repo1::/path/wt1'
const state: SelectorState = {
tabsByWorktree: {
[worktreeId]: [makeTab('tab-1', worktreeId)]
},
runtimePaneTitlesByTabId: { 'tab-1': { 0: 'codex [working]' } },
ptyIdsByTabId: { 'tab-1': ['pty-1'] }
}
const unrelatedUpdate: SelectorState = {
...state,
runtimePaneTitlesByTabId: {
...state.runtimePaneTitlesByTabId,
'other-tab': { 0: 'claude [permission]' }
},
ptyIdsByTabId: { ...state.ptyIdsByTabId, 'other-tab': ['pty-other'] }
}
expect(selectRuntimePaneTitlesForWorktree(state, worktreeId)).toBe(
selectRuntimePaneTitlesForWorktree(unrelatedUpdate, worktreeId)
)
expect(selectLivePtyIdsForWorktree(state, worktreeId)).toBe(
selectLivePtyIdsForWorktree(unrelatedUpdate, worktreeId)
)
})
it('returns the shared frozen empty for a worktree with no tabs', () => {
const state: SelectorState & LayoutRootSelectorState = {
tabsByWorktree: {},
runtimePaneTitlesByTabId: {},
ptyIdsByTabId: {},
terminalLayoutsByTabId: {}
}
expect(selectRuntimePaneTitlesForWorktree(state, 'missing')).toBe(EMPTY_RUNTIME_PANE_TITLES)
expect(selectLivePtyIdsForWorktree(state, 'missing')).toBe(EMPTY_LIVE_PTY_IDS)
expect(selectTerminalLayoutRootsForWorktree(state, 'missing')).toBe(EMPTY_TERMINAL_LAYOUT_ROOTS)
expect(Object.isFrozen(EMPTY_RUNTIME_PANE_TITLES)).toBe(true)
expect(Object.isFrozen(EMPTY_LIVE_PTY_IDS)).toBe(true)
expect(Object.isFrozen(EMPTY_TERMINAL_LAYOUT_ROOTS)).toBe(true)
})
})
@@ -1,9 +1,19 @@
import type { AppState } from '@/store/types'
import type { TerminalPaneLayoutNode } from '../../../../shared/terminal-tab-types'
import { createWorktreeRecordSelector } from './worktree-record-selector-cache'
// Why: these selectors return fresh maps whose top-level values preserve
// underlying per-tab references, so callers must compare them shallowly.
// Why frozen: one instance is shared by every card, so a stray write would leak
// across worktrees instead of failing locally.
export const EMPTY_RUNTIME_PANE_TITLES: Record<string, Record<number, string>> = Object.freeze({})
export const EMPTY_LIVE_PTY_IDS: Record<string, string[]> = Object.freeze({})
export const EMPTY_TERMINAL_LAYOUT_ROOTS: Record<
string,
TerminalPaneLayoutNode | null | undefined
> = Object.freeze({})
type WorktreeCardStatusInputState = Pick<AppState, 'runtimePaneTitlesByTabId' | 'ptyIdsByTabId'> & {
tabsByWorktree: Record<string, readonly { id: string }[]>
}
@@ -12,44 +22,56 @@ type WorktreeCardLayoutRootInputState = Pick<AppState, 'terminalLayoutsByTabId'>
tabsByWorktree: Record<string, readonly { id: string }[]>
}
export function selectRuntimePaneTitlesForWorktree(
state: WorktreeCardStatusInputState,
worktreeId: string
): Record<string, Record<number, string>> {
const out: Record<string, Record<number, string>> = {}
for (const tab of state.tabsByWorktree[worktreeId] ?? []) {
const paneTitles = state.runtimePaneTitlesByTabId[tab.id]
if (paneTitles) {
out[tab.id] = paneTitles
export const selectRuntimePaneTitlesForWorktree = createWorktreeRecordSelector<
WorktreeCardStatusInputState,
Record<string, Record<number, string>>
>({
readSources: (state) => [state.tabsByWorktree, state.runtimePaneTitlesByTabId],
empty: EMPTY_RUNTIME_PANE_TITLES,
build: (state, worktreeId) => {
const out: Record<string, Record<number, string>> = {}
for (const tab of state.tabsByWorktree[worktreeId] ?? []) {
const paneTitles = state.runtimePaneTitlesByTabId[tab.id]
if (paneTitles) {
out[tab.id] = paneTitles
}
}
return out
}
return out
}
})
export function selectLivePtyIdsForWorktree(
state: WorktreeCardStatusInputState,
worktreeId: string
): Record<string, string[]> {
const out: Record<string, string[]> = {}
for (const tab of state.tabsByWorktree[worktreeId] ?? []) {
const ids = state.ptyIdsByTabId[tab.id]
if (ids && ids.length > 0) {
out[tab.id] = ids
export const selectLivePtyIdsForWorktree = createWorktreeRecordSelector<
WorktreeCardStatusInputState,
Record<string, string[]>
>({
readSources: (state) => [state.tabsByWorktree, state.ptyIdsByTabId],
empty: EMPTY_LIVE_PTY_IDS,
build: (state, worktreeId) => {
const out: Record<string, string[]> = {}
for (const tab of state.tabsByWorktree[worktreeId] ?? []) {
const ids = state.ptyIdsByTabId[tab.id]
if (ids && ids.length > 0) {
out[tab.id] = ids
}
}
return out
}
return out
}
})
export function selectTerminalLayoutRootsForWorktree(
state: WorktreeCardLayoutRootInputState,
worktreeId: string
): Record<string, TerminalPaneLayoutNode | null | undefined> {
const out: Record<string, TerminalPaneLayoutNode | null | undefined> = {}
for (const tab of state.tabsByWorktree[worktreeId] ?? []) {
out[tab.id] = state.terminalLayoutsByTabId[tab.id]?.root
export const selectTerminalLayoutRootsForWorktree = createWorktreeRecordSelector<
WorktreeCardLayoutRootInputState,
Record<string, TerminalPaneLayoutNode | null | undefined>
>({
readSources: (state) => [state.tabsByWorktree, state.terminalLayoutsByTabId],
empty: EMPTY_TERMINAL_LAYOUT_ROOTS,
build: (state, worktreeId) => {
const out: Record<string, TerminalPaneLayoutNode | null | undefined> = {}
for (const tab of state.tabsByWorktree[worktreeId] ?? []) {
out[tab.id] = state.terminalLayoutsByTabId[tab.id]?.root
}
return out
}
return out
}
})
export function selectTerminalLayoutRootsForWorktrees(
state: WorktreeCardLayoutRootInputState,
@@ -0,0 +1,53 @@
import { describe, expect, it } from 'vitest'
import type { AppState } from '@/store/types'
import {
EMPTY_PENDING_WORKTREE_CREATION_KEYS,
selectPendingWorktreeCreationKeys
} from './pending-worktree-creation-keys'
type PendingCreations = AppState['pendingWorktreeCreations']
function makePending(creationId: string, repoId: string): PendingCreations[string] {
return {
creationId,
request: { repoId }
} as unknown as PendingCreations[string]
}
describe('selectPendingWorktreeCreationKeys', () => {
// Why: this runs inside an always-mounted sidebar subscriber, so zustand
// re-evaluates it on every store write in the app.
it('returns the shared frozen empty when nothing is pending', () => {
const empty: PendingCreations = {}
expect(selectPendingWorktreeCreationKeys(empty)).toBe(EMPTY_PENDING_WORKTREE_CREATION_KEYS)
expect(selectPendingWorktreeCreationKeys({})).toBe(EMPTY_PENDING_WORKTREE_CREATION_KEYS)
expect(selectPendingWorktreeCreationKeys(undefined)).toBe(EMPTY_PENDING_WORKTREE_CREATION_KEYS)
expect(Object.isFrozen(EMPTY_PENDING_WORKTREE_CREATION_KEYS)).toBe(true)
})
it('builds the key list once per slice identity', () => {
const pending: PendingCreations = {
'creation-1': makePending('creation-1', 'repo with space')
}
const first = selectPendingWorktreeCreationKeys(pending)
expect(first).toEqual(['creation-1 repo with space'])
expect(selectPendingWorktreeCreationKeys(pending)).toBe(first)
})
it('rebuilds when the slice is replaced', () => {
const before: PendingCreations = {
'creation-1': makePending('creation-1', 'repo-1')
}
const after: PendingCreations = {
...before,
'creation-2': makePending('creation-2', 'repo-2')
}
expect(selectPendingWorktreeCreationKeys(after)).toEqual([
'creation-1 repo-1',
'creation-2 repo-2'
])
})
})
@@ -0,0 +1,39 @@
import type { AppState } from '@/store/types'
// Why frozen: the sidebar row model is always mounted and this list is empty
// almost always, so one shared identity serves every read.
export const EMPTY_PENDING_WORKTREE_CREATION_KEYS: string[] = Object.freeze(
[]
) as unknown as string[]
const keysBySource = new WeakMap<object, string[]>()
/**
* Flat `"<creationId> <repoId>"` keys for the pending-creation sidebar rows.
*
* Why identity-cached: this runs inside an always-mounted subscriber, so an
* unmemoized `Object.values(...).map(...)` allocated an array plus one template
* string per pending creation on every store write in the app. Keyed on the
* slice reference, so it only rebuilds when the slice itself is replaced.
*
* Split on the first space — creationId is a UUID (no space) so a
* space-containing repoId stays intact.
*/
export function selectPendingWorktreeCreationKeys(
pendingWorktreeCreations: AppState['pendingWorktreeCreations'] | undefined
): string[] {
if (!pendingWorktreeCreations) {
return EMPTY_PENDING_WORKTREE_CREATION_KEYS
}
const cached = keysBySource.get(pendingWorktreeCreations)
if (cached) {
return cached
}
const creations = Object.values(pendingWorktreeCreations)
const keys =
creations.length === 0
? EMPTY_PENDING_WORKTREE_CREATION_KEYS
: creations.map((creation) => `${creation.creationId} ${creation.request.repoId}`)
keysBySource.set(pendingWorktreeCreations, keys)
return keys
}
@@ -19,6 +19,7 @@ import { getEmptyProjectPlaceholderRepoIds } from '../../empty-project-placehold
import { addHostSectionRows } from '../../host-section-rows'
import { orderHostSectionOptions } from '../../host-section-order'
import { buildSidebarHostOptions } from '../../sidebar-host-options'
import { selectPendingWorktreeCreationKeys } from './pending-worktree-creation-keys'
type SectionRowsArgs = {
groupBy: WorktreeGroupBy
@@ -96,19 +97,17 @@ export function useSidebarSectionRows(args: SectionRowsArgs) {
)
// Why: subscribe on a flat key array (useShallow) so progress ticks don't rebuild the whole row model.
// Split on first space — creationId is a UUID (no space) so a space-containing repoId stays intact.
const pendingCreationKeys = useAppStore(
useShallow((s) =>
Object.values(s.pendingWorktreeCreations ?? {}).map(
(creation) => `${creation.creationId} ${creation.request.repoId}`
)
)
useShallow((s) => selectPendingWorktreeCreationKeys(s.pendingWorktreeCreations))
)
const pendingCreations = useMemo(
() =>
pendingCreationKeys.map((key) => {
const separator = key.indexOf(' ')
return { creationId: key.slice(0, separator), repoId: key.slice(separator + 1) }
return {
creationId: key.slice(0, separator),
repoId: key.slice(separator + 1)
}
}),
[pendingCreationKeys]
)
@@ -0,0 +1,63 @@
import { shallow } from 'zustand/shallow'
type WorktreeRecordGeneration<TValue> = {
sources: readonly unknown[]
carried: ReadonlyMap<string, TValue> | null
byWorktreeId: Map<string, TValue>
}
function sameSources(previous: readonly unknown[], next: readonly unknown[]): boolean {
if (previous.length !== next.length) {
return false
}
for (let index = 0; index < next.length; index += 1) {
if (previous[index] !== next[index]) {
return false
}
}
return true
}
/**
* Wraps a per-worktree record selector in a store-identity-keyed cache.
*
* Zustand re-runs every mounted subscriber's selector on every store write, so
* an unmemoized build allocates one record per visible card per write even when
* nothing it reads changed. Gating on the source slice identities collapses that
* to one build per worktree per generation; carrying the previous generation
* forward keeps the reference stable when a rebuild produces equal contents, so
* downstream `useShallow`/`useMemo` gates short-circuit on identity.
*
* The returned records are shared by every caller and must never be mutated.
*/
export function createWorktreeRecordSelector<TState, TValue extends object>(options: {
readSources: (state: TState) => readonly unknown[]
build: (state: TState, worktreeId: string) => TValue
empty: TValue
}): (state: TState, worktreeId: string) => TValue {
let generation: WorktreeRecordGeneration<TValue> | null = null
return (state, worktreeId) => {
const sources = options.readSources(state)
if (!generation || !sameSources(generation.sources, sources)) {
generation = {
sources,
carried: generation?.byWorktreeId ?? null,
byWorktreeId: new Map()
}
}
const cached = generation.byWorktreeId.get(worktreeId)
if (cached !== undefined) {
return cached
}
const built = options.build(state, worktreeId)
const carried = generation.carried?.get(worktreeId)
let value = built
if (Object.keys(built).length === 0) {
value = options.empty
} else if (carried && shallow(carried, built)) {
value = carried
}
generation.byWorktreeId.set(worktreeId, value)
return value
}
}