perf(sidebar): stop recounting every workspace's agents for four sidebar numbers

The always-mounted agent bucket counts re-walked every active worktree, tab
and pane on any of 14 store slices moving — and several of those move on
ordinary agent traffic. Memoize each worktree's contribution on its own input
identities, cache the active-workspace list, and replace the 14-key useShallow
gate (which allocates two Object.entries arrays and two Maps per store write
inside zustand's shallow) with an allocation-free identity check.

Row buckets read the clock only through isExplicitAgentStatusFresh, so each
cached contribution carries validUntil = min(evidenceObservedAt +
AGENT_STATUS_STALE_AFTER_MS) over its entries — the same boundary the store's
freshness scheduler bumps agentStatusEpoch on — and is reused only inside that
interval. Counts and idle-decay timing are unchanged.
This commit is contained in:
Neil
2026-09-02 03:27:21 -07:00
parent 34999e328e
commit fec0c8e1c0
5 changed files with 929 additions and 120 deletions
@@ -0,0 +1,478 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type * as WorktreeAgentRowsModule from '../sidebar/worktree-agent-rows'
const rowBuilds = vi.hoisted(() => ({ count: 0 }))
// Why: counts worktrees actually walked per recompute, and works against both
// the memoized and the pre-memo implementation, so the scaling assertions below
// fail loudly if the per-worktree gate regresses.
vi.mock('../sidebar/worktree-agent-rows', async (importOriginal) => {
const actual = await importOriginal<typeof WorktreeAgentRowsModule>()
return {
...actual,
buildWorktreeAgentRows: (args: Parameters<typeof actual.buildWorktreeAgentRows>[0]) => {
rowBuilds.count += 1
return actual.buildWorktreeAgentRows(args)
}
}
})
import {
AGENT_STATUS_STALE_AFTER_MS,
type AgentStatusEntry
} from '../../../../shared/agent-status-types'
import type { DashboardBucket } from '../../../../shared/dashboard-snapshot'
import { makePaneKey } from '../../../../shared/stable-pane-id'
import type { TerminalTab } from '../../../../shared/terminal-tab-types'
import { migrationUnsupportedToAgentStatusEntry } from '@/lib/migration-unsupported-agent-entry'
import type { RetainedAgentEntry } from '@/store/slices/agent-status'
import {
buildDashboardBucketCounts,
resetDashboardBucketCountCachesForTests
} from './build-dashboard-bucket-counts'
import type { DashboardSnapshotState } from './build-dashboard-snapshot'
import {
getWorktreeBucketCountRebuildCountForTests,
resetWorktreeBucketCountCacheForTests
} from './dashboard-worktree-bucket-counts'
import { applyAgentRowLineage } from './agent-row-lineage'
import { collectActiveDashboardWorkspaces } from './dashboard-snapshot-workspaces'
import { selectDashboardOrchestration } from './dashboard-orchestration-selection'
import { dashboardRowBucketProjection } from './dashboard-row-bucket'
import { buildWorktreeAgentRows } from '../sidebar/worktree-agent-rows'
import {
selectLiveAgentStatusEntriesForWorktree,
selectMigrationUnsupportedEntriesForWorktree,
selectRetainedAgentEntriesForWorktree,
selectTerminalLayoutsForWorktree
} from '../sidebar/worktree-agent-row-selectors'
import { EMPTY_WORKTREE_AGENT_ORCHESTRATION } from '../sidebar/worktree-agent-orchestration-batch'
import {
selectLivePtyIdsForWorktree,
selectRuntimePaneTitlesForWorktree
} from '../sidebar/worktree-card-status-inputs'
const NOW = 1_700_000_000_000
const REPO_COUNT = 10
const WORKTREE_COUNT = 423
const WORKTREES_WITH_TABS = 193
const TWO_TAB_WORKTREES = 189
/**
* Byte-for-byte port of the pre-memo implementation, kept as the oracle so a
* refactor of the cached path cannot silently change a single count.
*/
function referenceBucketCounts(
state: DashboardSnapshotState,
now: number
): Record<DashboardBucket, number> {
const counts = { attention: 0, working: 0, done: 0, idle: 0 }
const activeWorktrees = collectActiveDashboardWorkspaces(state, false)
const { singletonOrchestration, orchestrationByWorktree } = selectDashboardOrchestration(
state,
activeWorktrees
)
for (const { worktree } of activeWorktrees) {
const worktreeId = worktree.id
const liveEntries = selectLiveAgentStatusEntriesForWorktree(state, worktreeId)
const migrationUnsupported = selectMigrationUnsupportedEntriesForWorktree(state, worktreeId)
const entries =
migrationUnsupported.length > 0
? [
...liveEntries,
...migrationUnsupported.flatMap((unsupported) => {
const entry = migrationUnsupportedToAgentStatusEntry(unsupported)
return entry ? [entry] : []
})
]
: liveEntries
const rows = applyAgentRowLineage(
buildWorktreeAgentRows({
tabs: state.tabsByWorktree[worktreeId] ?? [],
entries,
retained: selectRetainedAgentEntriesForWorktree(state, worktreeId),
runtimePaneTitlesByTabId: selectRuntimePaneTitlesForWorktree(state, worktreeId),
ptyIdsByTabId: selectLivePtyIdsForWorktree(state, worktreeId),
terminalLayoutsByTabId: selectTerminalLayoutsForWorktree(state, worktreeId),
runtimeAgentOrchestrationByPaneKey:
singletonOrchestration ??
orchestrationByWorktree?.get(worktreeId) ??
EMPTY_WORKTREE_AGENT_ORCHESTRATION,
now
})
)
for (const row of rows) {
if (row.rowSource === 'subagent') {
continue
}
counts[dashboardRowBucketProjection(row, state.acknowledgedAgentsByPaneKey).bucket] += 1
}
}
return counts
}
function leafId(index: number): string {
return `${index.toString(16).padStart(8, '0')}-1111-4111-8111-111111111111`
}
function tab(id: string, worktreeId: string, title = 'zsh'): TerminalTab {
return {
id,
ptyId: `pty-${id}`,
worktreeId,
title,
customTitle: null,
color: null,
sortOrder: 0,
createdAt: NOW
}
}
function entry(overrides: Partial<AgentStatusEntry> & { paneKey: string }): AgentStatusEntry {
return {
state: 'working',
prompt: 'do the thing',
updatedAt: NOW,
stateStartedAt: NOW - 5_000,
stateHistory: [],
agentType: 'claude',
...overrides
}
}
type ScaleFixture = {
state: DashboardSnapshotState
tabIdsByWorktreeId: Map<string, string[]>
agentPaneKeys: string[]
}
/** 10 repos / 423 worktrees / 193 with tabs / 382 tabs, agents in every bucket. */
function buildScaleFixture(): ScaleFixture {
const repos: unknown[] = []
const worktreesByRepo: Record<string, unknown[]> = {}
const tabsByWorktree: Record<string, TerminalTab[]> = {}
const terminalLayoutsByTabId: Record<string, unknown> = {}
const ptyIdsByTabId: Record<string, string[]> = {}
const runtimePaneTitlesByTabId: Record<string, Record<number, string>> = {}
const agentStatusByPaneKey: Record<string, AgentStatusEntry> = {}
const retainedAgentsByPaneKey: Record<string, RetainedAgentEntry> = {}
const acknowledgedAgentsByPaneKey: Record<string, number> = {}
const tabIdsByWorktreeId = new Map<string, string[]>()
const agentPaneKeys: string[] = []
for (let repoIndex = 0; repoIndex < REPO_COUNT; repoIndex += 1) {
repos.push({
id: `r${repoIndex}`,
path: `/r${repoIndex}`,
displayName: `Repo ${repoIndex}`,
badgeColor: '#000'
})
worktreesByRepo[`r${repoIndex}`] = []
}
let tabCounter = 0
for (let index = 0; index < WORKTREE_COUNT; index += 1) {
const worktreeId = `w${index}`
const repoId = `r${index % REPO_COUNT}`
worktreesByRepo[repoId].push({
id: worktreeId,
repoId,
path: `/${repoId}/${worktreeId}`,
head: 'abc123',
branch: 'main',
isBare: false,
isMainWorktree: false,
displayName: worktreeId,
comment: '',
linkedIssue: null,
linkedPR: null,
linkedLinearIssue: null,
isArchived: false,
isUnread: false,
isPinned: false,
sortOrder: 0,
lastActivityAt: NOW
})
if (index >= WORKTREES_WITH_TABS) {
continue
}
const tabCount = index < TWO_TAB_WORKTREES ? 2 : 1
const tabs: TerminalTab[] = []
for (let slot = 0; slot < tabCount; slot += 1) {
const tabId = `t${tabCounter}`
const paneLeafId = leafId(tabCounter)
tabCounter += 1
tabs.push(tab(tabId, worktreeId))
terminalLayoutsByTabId[tabId] = {
root: { type: 'leaf', leafId: paneLeafId },
activeLeafId: paneLeafId,
ptyIdsByLeafId: { [paneLeafId]: `pty-${tabId}` }
}
ptyIdsByTabId[tabId] = [`pty-${tabId}`]
// Six repeating shapes so every bucket and both decay paths are covered.
const paneKey = makePaneKey(tabId, paneLeafId)
switch (tabCounter % 6) {
case 0:
agentStatusByPaneKey[paneKey] = entry({ paneKey, state: 'working', worktreeId })
agentPaneKeys.push(paneKey)
break
case 1:
agentStatusByPaneKey[paneKey] = entry({ paneKey, state: 'blocked', worktreeId })
agentPaneKeys.push(paneKey)
break
case 2:
agentStatusByPaneKey[paneKey] = entry({ paneKey, state: 'done', worktreeId })
agentPaneKeys.push(paneKey)
break
case 3:
agentStatusByPaneKey[paneKey] = entry({ paneKey, state: 'done', worktreeId })
acknowledgedAgentsByPaneKey[paneKey] = NOW
agentPaneKeys.push(paneKey)
break
case 4:
// Stale non-done entry over a live pty: decays through the row builder.
agentStatusByPaneKey[paneKey] = entry({
paneKey,
state: 'working',
worktreeId,
updatedAt: NOW - AGENT_STATUS_STALE_AFTER_MS - 1_000
})
agentPaneKeys.push(paneKey)
break
default:
// No live entry: exercises the title-derived row path.
runtimePaneTitlesByTabId[tabId] = { 0: '✳ Cooking… (12s · claude)' }
break
}
}
tabsByWorktree[worktreeId] = tabs
tabIdsByWorktreeId.set(worktreeId, tabs.map((t) => t.id))
}
// A retained (hibernated) completion on a worktree that has no live entry.
const retainedTabId = tabsByWorktree.w5[0].id
const retainedPaneKey = makePaneKey(retainedTabId, leafId(9_000))
retainedAgentsByPaneKey[retainedPaneKey] = {
entry: entry({ paneKey: retainedPaneKey, state: 'done', worktreeId: 'w5' }),
worktreeId: 'w5',
tab: tab(retainedTabId, 'w5'),
agentType: 'claude',
startedAt: NOW - 60_000
}
const state = {
repos,
worktreesByRepo,
tabsByWorktree,
unifiedTabsByWorktree: {},
agentStatusByPaneKey,
retainedAgentsByPaneKey,
migrationUnsupportedByPtyId: {},
runtimeAgentOrchestrationByPaneKey: {},
terminalLayoutsByTabId,
ptyIdsByTabId,
runtimePaneTitlesByTabId,
folderWorkspaces: [],
acknowledgedAgentsByPaneKey,
settings: null
} as unknown as DashboardSnapshotState
return { state, tabIdsByWorktreeId, agentPaneKeys }
}
function withReplacedStatus(
state: DashboardSnapshotState,
paneKey: string,
overrides: Partial<AgentStatusEntry>
): DashboardSnapshotState {
return {
...state,
agentStatusByPaneKey: {
...state.agentStatusByPaneKey,
[paneKey]: { ...state.agentStatusByPaneKey[paneKey], ...overrides }
}
}
}
function resetCaches(): void {
resetDashboardBucketCountCachesForTests()
resetWorktreeBucketCountCacheForTests()
rowBuilds.count = 0
}
beforeEach(() => {
resetCaches()
})
describe('buildDashboardBucketCounts scaling', () => {
it('walks one worktree when one worktree receives an agent status ping', () => {
const { state, agentPaneKeys } = buildScaleFixture()
const warm = buildDashboardBucketCounts(state, NOW)
expect(warm).toEqual(referenceBucketCounts(state, NOW))
expect(rowBuilds.count).toBeGreaterThan(WORKTREES_WITH_TABS - 1)
// A same-state prompt update on one pane: exactly the traffic an idle app
// produces continuously while one agent is running.
const pinged = withReplacedStatus(state, agentPaneKeys[0], {
prompt: 'next tool call',
updatedAt: NOW + 1
})
rowBuilds.count = 0
const after = buildDashboardBucketCounts(pinged, NOW)
expect(rowBuilds.count).toBe(1)
expect(after).toEqual(referenceBucketCounts(pinged, NOW))
})
it('walks one worktree when one tab publishes a new runtime pane title', () => {
const { state, tabIdsByWorktreeId } = buildScaleFixture()
buildDashboardBucketCounts(state, NOW)
const titledTabId = tabIdsByWorktreeId.get('w2')![0]
const retitled = {
...state,
runtimePaneTitlesByTabId: {
...state.runtimePaneTitlesByTabId,
[titledTabId]: { 0: '✳ Reticulating… (3s · claude)' }
}
} as DashboardSnapshotState
rowBuilds.count = 0
const after = buildDashboardBucketCounts(retitled, NOW)
expect(rowBuilds.count).toBe(1)
expect(after).toEqual(referenceBucketCounts(retitled, NOW))
})
it('walks nothing when the same store snapshot is re-derived', () => {
const { state } = buildScaleFixture()
const first = buildDashboardBucketCounts(state, NOW)
rowBuilds.count = 0
const rebuildsBefore = getWorktreeBucketCountRebuildCountForTests()
const second = buildDashboardBucketCounts(state, NOW)
expect(rowBuilds.count).toBe(0)
expect(getWorktreeBucketCountRebuildCountForTests()).toBe(rebuildsBefore)
expect(second).toEqual(first)
})
it('re-walks only the worktrees whose rows can cross the stale boundary', () => {
const { state } = buildScaleFixture()
buildDashboardBucketCounts(state, NOW)
rowBuilds.count = 0
// Every live entry's freshness deadline has passed, so every worktree that
// holds one must be re-derived — but the ~230 workspaces with no tabs and
// the tabbed worktrees with no live entry must not be.
const later = NOW + AGENT_STATUS_STALE_AFTER_MS + 1
const decayed = buildDashboardBucketCounts(state, later)
const walked = rowBuilds.count
expect(decayed).toEqual(referenceBucketCounts(state, later))
expect(walked).toBeGreaterThan(0)
expect(walked).toBeLessThanOrEqual(WORKTREES_WITH_TABS)
})
})
describe('buildDashboardBucketCounts equivalence', () => {
it('matches the pre-memo implementation across a matrix of states', () => {
const { state, agentPaneKeys, tabIdsByWorktreeId } = buildScaleFixture()
const paneKey = agentPaneKeys[0]
const otherPaneKey = agentPaneKeys[7]
const tabId = tabIdsByWorktreeId.get('w3')![0]
const matrix: { name: string; state: DashboardSnapshotState; now: number }[] = [
{ name: 'baseline', state, now: NOW },
{
name: 'blocked -> working',
state: withReplacedStatus(state, paneKey, { state: 'working' }),
now: NOW
},
{
name: 'working -> done unseen',
state: withReplacedStatus(state, paneKey, { state: 'done', stateStartedAt: NOW }),
now: NOW
},
{
name: 'acknowledged completion',
state: {
...state,
acknowledgedAgentsByPaneKey: {
...state.acknowledgedAgentsByPaneKey,
[otherPaneKey]: NOW + 1
}
} as DashboardSnapshotState,
now: NOW
},
{
name: 'subagent children are excluded',
state: withReplacedStatus(state, paneKey, {
subagents: [
{ id: 's1', state: 'working', startedAt: NOW - 1_000, agentType: 'claude' },
{ id: 's2', state: 'done', startedAt: NOW - 2_000, agentType: 'claude' }
]
} as Partial<AgentStatusEntry>),
now: NOW
},
{
name: 'pty closed under a titled pane',
state: {
...state,
ptyIdsByTabId: { ...state.ptyIdsByTabId, [tabId]: [] }
} as DashboardSnapshotState,
now: NOW
},
{
name: 'tab removed from a worktree',
state: {
...state,
tabsByWorktree: { ...state.tabsByWorktree, w4: [] }
} as DashboardSnapshotState,
now: NOW
},
{ name: 'past the stale boundary', state, now: NOW + AGENT_STATUS_STALE_AFTER_MS + 1 },
{ name: 'far past the stale boundary', state, now: NOW + AGENT_STATUS_STALE_AFTER_MS * 4 }
]
// Run twice: once cold, once against the warm cache left by the previous case.
for (const pass of [1, 2]) {
for (const { name, state: caseState, now } of matrix) {
expect(
{ name, pass, counts: buildDashboardBucketCounts(caseState, now) },
`${name} (pass ${pass})`
).toEqual({ name, pass, counts: referenceBucketCounts(caseState, now) })
}
}
})
it('returns the shared empty-counts constant when every bucket is zero', () => {
const emptyState = {
repos: [],
worktreesByRepo: {},
tabsByWorktree: {},
unifiedTabsByWorktree: {},
agentStatusByPaneKey: {},
retainedAgentsByPaneKey: {},
migrationUnsupportedByPtyId: {},
runtimeAgentOrchestrationByPaneKey: {},
terminalLayoutsByTabId: {},
ptyIdsByTabId: {},
runtimePaneTitlesByTabId: {},
folderWorkspaces: [],
acknowledgedAgentsByPaneKey: {},
settings: null
} as unknown as DashboardSnapshotState
const otherEmptyState = { ...emptyState, repos: [] } as DashboardSnapshotState
const first = buildDashboardBucketCounts(emptyState, NOW)
const second = buildDashboardBucketCounts(otherEmptyState, NOW)
expect(first).toEqual({ attention: 0, working: 0, done: 0, idle: 0 })
expect(second).toBe(first)
})
})
@@ -1,22 +1,15 @@
import type { DashboardBucket } from '../../../../shared/dashboard-snapshot'
import { migrationUnsupportedToAgentStatusEntry } from '@/lib/migration-unsupported-agent-entry'
import { applyAgentRowLineage } from './agent-row-lineage'
import type { DashboardSnapshotState } from './build-dashboard-snapshot'
import { collectActiveDashboardWorkspaces } from './dashboard-snapshot-workspaces'
import {
collectActiveDashboardWorkspaces,
type ActiveDashboardWorkspace
} from './dashboard-snapshot-workspaces'
import { selectDashboardOrchestration } from './dashboard-orchestration-selection'
import { dashboardRowBucketProjection } from './dashboard-row-bucket'
import { buildWorktreeAgentRows } from '../sidebar/worktree-agent-rows'
import {
selectLiveAgentStatusEntriesForWorktree,
selectMigrationUnsupportedEntriesForWorktree,
selectRetainedAgentEntriesForWorktree,
selectTerminalLayoutsForWorktree
} from '../sidebar/worktree-agent-row-selectors'
endWorktreeBucketCountPass,
selectWorktreeBucketCounts
} from './dashboard-worktree-bucket-counts'
import { EMPTY_WORKTREE_AGENT_ORCHESTRATION } from '../sidebar/worktree-agent-orchestration-batch'
import {
selectLivePtyIdsForWorktree,
selectRuntimePaneTitlesForWorktree
} from '../sidebar/worktree-card-status-inputs'
const EMPTY_COUNTS: Record<DashboardBucket, number> = {
attention: 0,
@@ -25,6 +18,48 @@ const EMPTY_COUNTS: Record<DashboardBucket, number> = {
idle: 0
}
type ActiveWorkspacesCache = {
repos: unknown
worktreesByRepo: unknown
folderWorkspaces: unknown
projectGroups: unknown
workspaces: ActiveDashboardWorkspace[]
}
let activeWorkspacesCache: ActiveWorkspacesCache | null = null
/**
* `collectActiveDashboardWorkspaces` with `includeMapMetadata: false` reads only
* these four slices, so the 400+ workspace descriptors it allocates can be
* reused until one of them changes identity.
*/
function selectActiveDashboardWorkspaces(
state: DashboardSnapshotState
): ActiveDashboardWorkspace[] {
if (
activeWorkspacesCache &&
activeWorkspacesCache.repos === state.repos &&
activeWorkspacesCache.worktreesByRepo === state.worktreesByRepo &&
activeWorkspacesCache.folderWorkspaces === state.folderWorkspaces &&
activeWorkspacesCache.projectGroups === state.projectGroups
) {
return activeWorkspacesCache.workspaces
}
const workspaces = collectActiveDashboardWorkspaces(state, false)
activeWorkspacesCache = {
repos: state.repos,
worktreesByRepo: state.worktreesByRepo,
folderWorkspaces: state.folderWorkspaces,
projectGroups: state.projectGroups,
workspaces
}
return workspaces
}
export function resetDashboardBucketCountCachesForTests(): void {
activeWorkspacesCache = null
}
/** Derive sidebar counts without allocating dashboard cards or metadata. */
export function buildDashboardBucketCounts(
state: DashboardSnapshotState,
@@ -36,51 +71,27 @@ export function buildDashboardBucketCounts(
done: 0,
idle: 0
} satisfies Record<DashboardBucket, number>
const activeWorktrees = collectActiveDashboardWorkspaces(state, false)
const activeWorktrees = selectActiveDashboardWorkspaces(state)
const { singletonOrchestration, orchestrationByWorktree } = selectDashboardOrchestration(
state,
activeWorktrees
)
for (const { worktree } of activeWorktrees) {
const worktreeId = worktree.id
const liveEntries = selectLiveAgentStatusEntriesForWorktree(state, worktreeId)
const migrationUnsupported = selectMigrationUnsupportedEntriesForWorktree(state, worktreeId)
const entries =
migrationUnsupported.length > 0
? [
...liveEntries,
...migrationUnsupported.flatMap((unsupported) => {
const entry = migrationUnsupportedToAgentStatusEntry(unsupported)
return entry ? [entry] : []
})
]
: liveEntries
const terminalLayoutsByTabId = selectTerminalLayoutsForWorktree(state, worktreeId)
const paneTitlesByTabId = selectRuntimePaneTitlesForWorktree(state, worktreeId)
const rows = applyAgentRowLineage(
buildWorktreeAgentRows({
tabs: state.tabsByWorktree[worktreeId] ?? [],
entries,
retained: selectRetainedAgentEntriesForWorktree(state, worktreeId),
runtimePaneTitlesByTabId: paneTitlesByTabId,
ptyIdsByTabId: selectLivePtyIdsForWorktree(state, worktreeId),
terminalLayoutsByTabId,
runtimeAgentOrchestrationByPaneKey:
singletonOrchestration ??
orchestrationByWorktree?.get(worktreeId) ??
EMPTY_WORKTREE_AGENT_ORCHESTRATION,
now
})
const contribution = selectWorktreeBucketCounts(
state,
worktree.id,
singletonOrchestration ??
orchestrationByWorktree?.get(worktree.id) ??
EMPTY_WORKTREE_AGENT_ORCHESTRATION,
now
)
for (const row of rows) {
if (row.rowSource === 'subagent') {
continue
}
counts[dashboardRowBucketProjection(row, state.acknowledgedAgentsByPaneKey).bucket] += 1
}
counts.attention += contribution.attention
counts.working += contribution.working
counts.done += contribution.done
counts.idle += contribution.idle
}
endWorktreeBucketCountPass()
return counts.attention === 0 && counts.working === 0 && counts.done === 0 && counts.idle === 0
? EMPTY_COUNTS
@@ -0,0 +1,267 @@
import type { RetainedAgentEntry } from '@/store/slices/agent-status'
import { migrationUnsupportedToAgentStatusEntry } from '@/lib/migration-unsupported-agent-entry'
import {
AGENT_STATUS_STALE_AFTER_MS,
agentStatusEvidenceObservedAt,
type AgentStatusEntry,
type AgentStatusOrchestrationContext,
type MigrationUnsupportedPtyEntry
} from '../../../../shared/agent-status-types'
import type { DashboardBucket } from '../../../../shared/dashboard-snapshot'
import type { TerminalLayoutSnapshot, TerminalTab } from '../../../../shared/terminal-tab-types'
import { applyAgentRowLineage } from './agent-row-lineage'
import type { DashboardSnapshotState } from './build-dashboard-snapshot'
import { dashboardRowBucketProjection } from './dashboard-row-bucket'
import { buildWorktreeAgentRows } from '../sidebar/worktree-agent-rows'
import {
selectLiveAgentStatusEntriesForWorktree,
selectMigrationUnsupportedEntriesForWorktree,
selectRetainedAgentEntriesForWorktree,
selectTerminalLayoutsForWorktree
} from '../sidebar/worktree-agent-row-selectors'
import {
selectLivePtyIdsForWorktree,
selectRuntimePaneTitlesForWorktree
} from '../sidebar/worktree-card-status-inputs'
export type WorktreeBucketCounts = Record<DashboardBucket, number>
const EMPTY_TABS: TerminalTab[] = []
// Why: unit tests pass partial store mocks; a missing map must behave like an
// empty slice while keeping one stable identity for the cache keys below.
const EMPTY_RECORD: Record<string, never> = {}
const ZERO_COUNTS: WorktreeBucketCounts = Object.freeze({
attention: 0,
working: 0,
done: 0,
idle: 0
})
type WorktreeBucketCountsCacheEntry = {
tabs: TerminalTab[]
liveEntries: AgentStatusEntry[]
migrationUnsupported: MigrationUnsupportedPtyEntry[]
retained: RetainedAgentEntry[]
orchestration: Record<string, AgentStatusOrchestrationContext>
acknowledgedAgentsByPaneKey: unknown
terminalLayoutsByTabId: unknown
runtimePaneTitlesByTabId: unknown
ptyIdsByTabId: unknown
layoutByTab: (TerminalLayoutSnapshot | undefined)[]
paneTitlesByTab: (Record<number, string> | undefined)[]
ptyIdsByTab: (string[] | undefined)[]
computedAt: number
/**
* Latest `now` this contribution still describes. Row buckets read the clock
* only through `isExplicitAgentStatusFresh`, so the answer can only change
* when an entry crosses `evidenceObservedAt + AGENT_STATUS_STALE_AFTER_MS` —
* the same boundary the store's freshness scheduler bumps `agentStatusEpoch` on.
*/
validUntil: number
counts: WorktreeBucketCounts
}
let cacheByWorktreeId = new Map<string, WorktreeBucketCountsCacheEntry>()
let liveEntriesThisPass = 0
let rebuildCount = 0
/** Test-only: proves one worktree's status change rebuilds one worktree. */
export function getWorktreeBucketCountRebuildCountForTests(): number {
return rebuildCount
}
export function resetWorktreeBucketCountCacheForTests(): void {
cacheByWorktreeId = new Map()
liveEntriesThisPass = 0
rebuildCount = 0
}
/**
* Close a full pass. Any cached contribution the pass did not touch belongs to
* a worktree that is gone, so the map is dropped rather than kept forever.
*/
export function endWorktreeBucketCountPass(): void {
if (cacheByWorktreeId.size > liveEntriesThisPass) {
cacheByWorktreeId = new Map()
}
liveEntriesThisPass = 0
}
function nextFreshnessBoundary(entries: AgentStatusEntry[], now: number): number {
let validUntil = Number.POSITIVE_INFINITY
for (const entry of entries) {
// Why skipped: a restored-unconfirmed entry is never fresh at any clock
// reading, so it has no future transition to invalidate this contribution.
if (entry.restoredUnconfirmed === true) {
continue
}
const expiryAt = agentStatusEvidenceObservedAt(entry) + AGENT_STATUS_STALE_AFTER_MS
if (expiryAt >= now && expiryAt < validUntil) {
validUntil = expiryAt
}
}
return validUntil
}
function perTabInputsUnchanged(
cached: WorktreeBucketCountsCacheEntry,
tabs: TerminalTab[],
layoutsByTabId: Record<string, TerminalLayoutSnapshot | undefined>,
paneTitlesByTabId: Record<string, Record<number, string> | undefined>,
ptyIdsByTabId: Record<string, string[] | undefined>
): boolean {
if (
cached.terminalLayoutsByTabId === layoutsByTabId &&
cached.runtimePaneTitlesByTabId === paneTitlesByTabId &&
cached.ptyIdsByTabId === ptyIdsByTabId
) {
return true
}
// Why per tab: one tab's title, layout, or pty write mints a new global map,
// but only the worktree owning that tab can change bucket because of it.
for (let index = 0; index < tabs.length; index += 1) {
const tabId = tabs[index].id
if (
cached.layoutByTab[index] !== layoutsByTabId[tabId] ||
cached.paneTitlesByTab[index] !== paneTitlesByTabId[tabId] ||
cached.ptyIdsByTab[index] !== ptyIdsByTabId[tabId]
) {
return false
}
}
return true
}
function countRowBuckets(args: {
state: DashboardSnapshotState
worktreeId: string
tabs: TerminalTab[]
entries: AgentStatusEntry[]
retained: RetainedAgentEntry[]
orchestration: Record<string, AgentStatusOrchestrationContext>
now: number
}): WorktreeBucketCounts {
const counts: WorktreeBucketCounts = { attention: 0, working: 0, done: 0, idle: 0 }
const rows = applyAgentRowLineage(
buildWorktreeAgentRows({
tabs: args.tabs,
entries: args.entries,
retained: args.retained,
runtimePaneTitlesByTabId: selectRuntimePaneTitlesForWorktree(args.state, args.worktreeId),
ptyIdsByTabId: selectLivePtyIdsForWorktree(args.state, args.worktreeId),
terminalLayoutsByTabId: selectTerminalLayoutsForWorktree(args.state, args.worktreeId),
runtimeAgentOrchestrationByPaneKey: args.orchestration,
now: args.now
})
)
for (const row of rows) {
if (row.rowSource === 'subagent') {
continue
}
counts[dashboardRowBucketProjection(row, args.state.acknowledgedAgentsByPaneKey).bucket] += 1
}
return counts
}
/**
* One worktree's contribution to the sidebar bucket counts, memoized on that
* worktree's own input identities plus the clock boundary above.
*
* Why: an agent ping mints new global slice maps, but the indexed selectors in
* worktree-agent-row-selectors keep every unaffected worktree's derived arrays
* reference-equal. Without this gate the counts re-walked every worktree x tab
* x pane on traffic that could only move one worktree.
*/
export function selectWorktreeBucketCounts(
state: DashboardSnapshotState,
worktreeId: string,
orchestration: Record<string, AgentStatusOrchestrationContext>,
now: number
): WorktreeBucketCounts {
const tabs = state.tabsByWorktree?.[worktreeId] ?? EMPTY_TABS
const liveEntries = selectLiveAgentStatusEntriesForWorktree(state, worktreeId)
const migrationUnsupported = selectMigrationUnsupportedEntriesForWorktree(state, worktreeId)
const retained = selectRetainedAgentEntriesForWorktree(state, worktreeId)
const cached = cacheByWorktreeId.get(worktreeId)
if (
tabs.length === 0 &&
liveEntries.length === 0 &&
migrationUnsupported.length === 0 &&
retained.length === 0
) {
// Why: with no tabs and no entries the row builder provably yields no rows,
// so a workspace that was never opened costs four length checks.
if (cached) {
cacheByWorktreeId.delete(worktreeId)
}
return ZERO_COUNTS
}
const layoutsByTabId = state.terminalLayoutsByTabId ?? EMPTY_RECORD
const paneTitlesByTabId = state.runtimePaneTitlesByTabId ?? EMPTY_RECORD
const ptyIdsByTabId = state.ptyIdsByTabId ?? EMPTY_RECORD
if (
cached &&
cached.tabs === tabs &&
cached.liveEntries === liveEntries &&
cached.migrationUnsupported === migrationUnsupported &&
cached.retained === retained &&
cached.orchestration === orchestration &&
cached.acknowledgedAgentsByPaneKey === state.acknowledgedAgentsByPaneKey &&
now >= cached.computedAt &&
now <= cached.validUntil &&
perTabInputsUnchanged(cached, tabs, layoutsByTabId, paneTitlesByTabId, ptyIdsByTabId)
) {
liveEntriesThisPass += 1
return cached.counts
}
rebuildCount += 1
const entries =
migrationUnsupported.length > 0
? [
...liveEntries,
...migrationUnsupported.flatMap((unsupported) => {
const entry = migrationUnsupportedToAgentStatusEntry(unsupported)
return entry ? [entry] : []
})
]
: liveEntries
const counts = countRowBuckets({
state,
worktreeId,
tabs,
entries,
retained,
orchestration,
now
})
const layoutByTab: (TerminalLayoutSnapshot | undefined)[] = []
const paneTitlesByTab: (Record<number, string> | undefined)[] = []
const ptyIdsByTab: (string[] | undefined)[] = []
for (const tab of tabs) {
layoutByTab.push(layoutsByTabId[tab.id])
paneTitlesByTab.push(paneTitlesByTabId[tab.id])
ptyIdsByTab.push(ptyIdsByTabId[tab.id])
}
cacheByWorktreeId.set(worktreeId, {
tabs,
liveEntries,
migrationUnsupported,
retained,
orchestration,
acknowledgedAgentsByPaneKey: state.acknowledgedAgentsByPaneKey,
terminalLayoutsByTabId: layoutsByTabId,
runtimePaneTitlesByTabId: paneTitlesByTabId,
ptyIdsByTabId,
layoutByTab,
paneTitlesByTab,
ptyIdsByTab,
computedAt: now,
validUntil: nextFreshnessBoundary(entries, now),
counts
})
liveEntriesThisPass += 1
return counts
}
@@ -32,11 +32,12 @@ vi.mock('./build-dashboard-bucket-counts', () => ({
buildDashboardBucketCounts: mocks.buildDashboardBucketCounts
}))
import { useAgentBucketCounts } from './useAgentBucketCounts'
import { resetAgentBucketCountGateForTests, useAgentBucketCounts } from './useAgentBucketCounts'
afterEach(() => {
cleanup()
vi.clearAllMocks()
resetAgentBucketCountGateForTests()
mocks.state.acknowledgedAgentsByPaneKey = {}
mocks.state.unrelatedEpoch = 0
})
@@ -87,4 +88,21 @@ describe('useAgentBucketCounts', () => {
expect(result.current).toEqual({ attention: 0, working: 0, done: 0, idle: 1 })
expect(mocks.buildDashboardBucketCounts).toHaveBeenCalledTimes(2)
})
it('keeps one counts object across agent traffic that leaves the totals alone', () => {
mocks.buildDashboardBucketCounts.mockImplementation(() => ({
attention: 0,
working: 1,
done: 0,
idle: 0
}))
const { result, rerender } = renderHook(() => useAgentBucketCounts())
const first = result.current
mocks.state.agentStatusEpoch += 1
rerender()
expect(mocks.buildDashboardBucketCounts).toHaveBeenCalledTimes(2)
expect(result.current).toBe(first)
})
})
@@ -1,34 +1,75 @@
import { useMemo } from 'react'
import { useAppStore } from '@/store'
import { useShallow } from 'zustand/react/shallow'
import type { AppState } from '@/store/types'
import type { DashboardBucket } from '../../../../shared/dashboard-snapshot'
import { buildDashboardBucketCounts } from './build-dashboard-bucket-counts'
export type AgentBucketCounts = Record<DashboardBucket, number>
/**
* Per-state agent counts for the sidebar dashboard entry, using the same row
* and bucket derivation as the pop-out board without allocating its cards.
* Recomputes only when an input slice changes.
*/
export function useAgentBucketCounts(): AgentBucketCounts {
const {
repos,
worktreesByRepo,
tabsByWorktree,
unifiedTabsByWorktree,
agentStatusByPaneKey,
retainedAgentsByPaneKey,
migrationUnsupportedByPtyId,
runtimeAgentOrchestrationByPaneKey,
terminalLayoutsByTabId,
ptyIdsByTabId,
runtimePaneTitlesByTabId,
folderWorkspaces,
acknowledgedAgentsByPaneKey,
agentStatusEpoch
} = useAppStore(
useShallow((s) => ({
type BucketCountInputs = Pick<
AppState,
| 'repos'
| 'worktreesByRepo'
| 'tabsByWorktree'
| 'unifiedTabsByWorktree'
| 'agentStatusByPaneKey'
| 'retainedAgentsByPaneKey'
| 'migrationUnsupportedByPtyId'
| 'runtimeAgentOrchestrationByPaneKey'
| 'terminalLayoutsByTabId'
| 'ptyIdsByTabId'
| 'runtimePaneTitlesByTabId'
| 'folderWorkspaces'
| 'acknowledgedAgentsByPaneKey'
| 'agentStatusEpoch'
>
type BucketCountGate = BucketCountInputs & { counts: AgentBucketCounts }
// Why module scope rather than useShallow: Zustand re-runs this selector on
// every store write, and `shallow()` allocated a 14-key object plus two key
// arrays each time just to prove nothing moved. Comparing the same 14 slice
// identities in place allocates nothing on the unchanged path.
let gate: BucketCountGate | null = null
export function resetAgentBucketCountGateForTests(): void {
gate = null
}
function bucketCountInputsUnchanged(s: AppState): boolean {
return (
gate !== null &&
gate.repos === s.repos &&
gate.worktreesByRepo === s.worktreesByRepo &&
gate.tabsByWorktree === s.tabsByWorktree &&
gate.unifiedTabsByWorktree === s.unifiedTabsByWorktree &&
gate.agentStatusByPaneKey === s.agentStatusByPaneKey &&
gate.retainedAgentsByPaneKey === s.retainedAgentsByPaneKey &&
gate.migrationUnsupportedByPtyId === s.migrationUnsupportedByPtyId &&
gate.runtimeAgentOrchestrationByPaneKey === s.runtimeAgentOrchestrationByPaneKey &&
gate.terminalLayoutsByTabId === s.terminalLayoutsByTabId &&
gate.ptyIdsByTabId === s.ptyIdsByTabId &&
gate.runtimePaneTitlesByTabId === s.runtimePaneTitlesByTabId &&
gate.folderWorkspaces === s.folderWorkspaces &&
gate.acknowledgedAgentsByPaneKey === s.acknowledgedAgentsByPaneKey &&
gate.agentStatusEpoch === s.agentStatusEpoch
)
}
function countsEqual(previous: AgentBucketCounts, next: AgentBucketCounts): boolean {
return (
previous.attention === next.attention &&
previous.working === next.working &&
previous.done === next.done &&
previous.idle === next.idle
)
}
function selectAgentBucketCounts(s: AppState): AgentBucketCounts {
if (gate !== null && bucketCountInputsUnchanged(s)) {
return gate.counts
}
const next = buildDashboardBucketCounts(
{
repos: s.repos,
worktreesByRepo: s.worktreesByRepo,
tabsByWorktree: s.tabsByWorktree,
@@ -42,49 +83,43 @@ export function useAgentBucketCounts(): AgentBucketCounts {
runtimePaneTitlesByTabId: s.runtimePaneTitlesByTabId,
folderWorkspaces: s.folderWorkspaces,
acknowledgedAgentsByPaneKey: s.acknowledgedAgentsByPaneKey,
agentStatusEpoch: s.agentStatusEpoch
}))
// Same: counts never render a card's conversation name, so the
// generated-title gate is moot and the sidebar stays off settings.
settings: null
},
// Why: Date.now() is read only when an input slice moved, so idle-decay
// tracks agentStatusEpoch ticks, matching useDashboardData.
Date.now()
)
// Why: an agent ping that leaves every bucket total where it was must not
// re-render the sidebar entry.
const counts = gate !== null && countsEqual(gate.counts, next) ? gate.counts : next
gate = {
repos: s.repos,
worktreesByRepo: s.worktreesByRepo,
tabsByWorktree: s.tabsByWorktree,
unifiedTabsByWorktree: s.unifiedTabsByWorktree,
agentStatusByPaneKey: s.agentStatusByPaneKey,
retainedAgentsByPaneKey: s.retainedAgentsByPaneKey,
migrationUnsupportedByPtyId: s.migrationUnsupportedByPtyId,
runtimeAgentOrchestrationByPaneKey: s.runtimeAgentOrchestrationByPaneKey,
terminalLayoutsByTabId: s.terminalLayoutsByTabId,
ptyIdsByTabId: s.ptyIdsByTabId,
runtimePaneTitlesByTabId: s.runtimePaneTitlesByTabId,
folderWorkspaces: s.folderWorkspaces,
acknowledgedAgentsByPaneKey: s.acknowledgedAgentsByPaneKey,
agentStatusEpoch: s.agentStatusEpoch,
counts
}
return counts
}
return useMemo(() => {
return buildDashboardBucketCounts(
{
repos,
worktreesByRepo,
tabsByWorktree,
unifiedTabsByWorktree,
agentStatusByPaneKey,
retainedAgentsByPaneKey,
migrationUnsupportedByPtyId,
runtimeAgentOrchestrationByPaneKey,
terminalLayoutsByTabId,
ptyIdsByTabId,
runtimePaneTitlesByTabId,
folderWorkspaces,
acknowledgedAgentsByPaneKey,
// Same: counts never render a card's conversation name, so the
// generated-title gate is moot and the sidebar stays off settings.
settings: null
},
Date.now()
)
// Why: Date.now() is read inside the memo (not a dep) so idle-decay tracks
// agentStatusEpoch ticks, matching useDashboardData.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [
repos,
worktreesByRepo,
tabsByWorktree,
unifiedTabsByWorktree,
agentStatusByPaneKey,
retainedAgentsByPaneKey,
migrationUnsupportedByPtyId,
runtimeAgentOrchestrationByPaneKey,
terminalLayoutsByTabId,
ptyIdsByTabId,
runtimePaneTitlesByTabId,
folderWorkspaces,
acknowledgedAgentsByPaneKey,
agentStatusEpoch
])
/**
* Per-state agent counts for the sidebar dashboard entry, using the same row
* and bucket derivation as the pop-out board without allocating its cards.
* Recomputes only when an input slice changes, and then only re-walks the
* worktrees whose own inputs moved.
*/
export function useAgentBucketCounts(): AgentBucketCounts {
return useAppStore(selectAgentBucketCounts)
}