diff --git a/src/renderer/src/components/dashboard/build-dashboard-bucket-counts.test.ts b/src/renderer/src/components/dashboard/build-dashboard-bucket-counts.test.ts new file mode 100644 index 00000000000..bedd2aa4269 --- /dev/null +++ b/src/renderer/src/components/dashboard/build-dashboard-bucket-counts.test.ts @@ -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() + return { + ...actual, + buildWorktreeAgentRows: (args: Parameters[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 { + 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 & { 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 + agentPaneKeys: string[] +} + +/** 10 repos / 423 worktrees / 193 with tabs / 382 tabs, agents in every bucket. */ +function buildScaleFixture(): ScaleFixture { + const repos: unknown[] = [] + const worktreesByRepo: Record = {} + const tabsByWorktree: Record = {} + const terminalLayoutsByTabId: Record = {} + const ptyIdsByTabId: Record = {} + const runtimePaneTitlesByTabId: Record> = {} + const agentStatusByPaneKey: Record = {} + const retainedAgentsByPaneKey: Record = {} + const acknowledgedAgentsByPaneKey: Record = {} + const tabIdsByWorktreeId = new Map() + 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 +): 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), + 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) + }) +}) diff --git a/src/renderer/src/components/dashboard/build-dashboard-bucket-counts.ts b/src/renderer/src/components/dashboard/build-dashboard-bucket-counts.ts index d077b447a3a..3e53ba5c1f9 100644 --- a/src/renderer/src/components/dashboard/build-dashboard-bucket-counts.ts +++ b/src/renderer/src/components/dashboard/build-dashboard-bucket-counts.ts @@ -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 = { attention: 0, @@ -25,6 +18,48 @@ const EMPTY_COUNTS: Record = { 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 - 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 diff --git a/src/renderer/src/components/dashboard/dashboard-worktree-bucket-counts.ts b/src/renderer/src/components/dashboard/dashboard-worktree-bucket-counts.ts new file mode 100644 index 00000000000..c42c5a0cc5b --- /dev/null +++ b/src/renderer/src/components/dashboard/dashboard-worktree-bucket-counts.ts @@ -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 + +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 = {} +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 + acknowledgedAgentsByPaneKey: unknown + terminalLayoutsByTabId: unknown + runtimePaneTitlesByTabId: unknown + ptyIdsByTabId: unknown + layoutByTab: (TerminalLayoutSnapshot | undefined)[] + paneTitlesByTab: (Record | 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() +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, + paneTitlesByTabId: Record | undefined>, + ptyIdsByTabId: Record +): 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 + 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, + 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 | 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 +} diff --git a/src/renderer/src/components/dashboard/useAgentBucketCounts.test.tsx b/src/renderer/src/components/dashboard/useAgentBucketCounts.test.tsx index f45dcd3b6e7..d68ae8cc462 100644 --- a/src/renderer/src/components/dashboard/useAgentBucketCounts.test.tsx +++ b/src/renderer/src/components/dashboard/useAgentBucketCounts.test.tsx @@ -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) + }) }) diff --git a/src/renderer/src/components/dashboard/useAgentBucketCounts.ts b/src/renderer/src/components/dashboard/useAgentBucketCounts.ts index 58b7a444e37..c83ebbeacce 100644 --- a/src/renderer/src/components/dashboard/useAgentBucketCounts.ts +++ b/src/renderer/src/components/dashboard/useAgentBucketCounts.ts @@ -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 -/** - * 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) }