perf(dashboard): build sidebar bucket counts without cards

This commit is contained in:
Neil
2026-08-30 16:43:33 -07:00
parent cb8e08834c
commit adcecd7af1
4 changed files with 247 additions and 34 deletions
@@ -0,0 +1,119 @@
import {
dashboardCardDisplayState,
type DashboardBucket
} from '../../../../shared/dashboard-snapshot'
import { migrationUnsupportedToAgentStatusEntry } from '@/lib/migration-unsupported-agent-entry'
import { applyAgentRowLineage } from './agent-row-lineage'
import { dashboardBucketForDotState } from './dashboard-card-bucket'
import type { DashboardSnapshotState } from './build-dashboard-snapshot'
import { collectActiveDashboardWorkspaces } from './dashboard-snapshot-workspaces'
import { buildWorktreeAgentRows } from '../sidebar/worktree-agent-rows'
import {
selectLiveAgentStatusEntriesForWorktree,
selectMigrationUnsupportedEntriesForWorktree,
selectRetainedAgentEntriesForWorktree,
selectRuntimeAgentOrchestrationForWorktree,
selectTerminalLayoutsForWorktree
} from '../sidebar/worktree-agent-row-selectors'
import {
EMPTY_WORKTREE_AGENT_ORCHESTRATION,
releaseRuntimeAgentOrchestrationBatchCache,
selectRuntimeAgentOrchestrationBatch
} from '../sidebar/worktree-agent-orchestration-batch'
import {
selectLivePtyIdsForWorktree,
selectRuntimePaneTitlesForWorktree
} from '../sidebar/worktree-card-status-inputs'
const EMPTY_COUNTS: Record<DashboardBucket, number> = {
attention: 0,
working: 0,
done: 0,
idle: 0
}
/** Derive sidebar counts without allocating dashboard cards or metadata. */
export function buildDashboardBucketCounts(
state: DashboardSnapshotState,
now: number
): Record<DashboardBucket, number> {
const counts = {
attention: 0,
working: 0,
done: 0,
idle: 0
} satisfies Record<DashboardBucket, number>
const activeWorktrees = collectActiveDashboardWorkspaces(state, false)
let singletonOrchestration: ReturnType<typeof selectRuntimeAgentOrchestrationForWorktree> | null =
null
let orchestrationByWorktree: ReturnType<typeof selectRuntimeAgentOrchestrationBatch> | null = null
if (activeWorktrees.length >= 2) {
orchestrationByWorktree = selectRuntimeAgentOrchestrationBatch(
state,
activeWorktrees.map(({ worktree }) => worktree.id)
)
} else {
releaseRuntimeAgentOrchestrationBatchCache()
if (activeWorktrees.length === 1) {
singletonOrchestration = selectRuntimeAgentOrchestrationForWorktree(
state,
activeWorktrees[0].worktree.id
)
}
}
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
})
)
for (const row of rows) {
if (row.rowSource === 'subagent') {
continue
}
const isTitleDerived = row.startedAt === 0
const workingMode =
row.state === 'working' && row.entry.workingMode === 'monitoring'
? row.entry.workingMode
: undefined
const unseen =
!isTitleDerived &&
(state.acknowledgedAgentsByPaneKey?.[row.paneKey] ?? 0) < row.entry.stateStartedAt
const bucket = dashboardBucketForDotState(
dashboardCardDisplayState({ dotState: row.state, workingMode, unseen })
)
counts[bucket] += 1
}
}
return counts.attention === 0 && counts.working === 0 && counts.done === 0 && counts.idle === 0
? EMPTY_COUNTS
: counts
}
@@ -6,6 +6,7 @@ import type { ProjectGroup } from '../../../../shared/project-group-types'
import type { TerminalTab } from '../../../../shared/terminal-tab-types'
import type { AgentStatusEntry } from '../../../../shared/agent-status-types'
import { buildDashboardSnapshot, type DashboardSnapshotState } from './build-dashboard-snapshot'
import { buildDashboardBucketCounts } from './build-dashboard-bucket-counts'
const NOW = 2_000_000_000
const WORKSPACE_ID = folderWorkspaceKey('folder-1')
@@ -90,6 +91,7 @@ function state(): DashboardSnapshotState {
[TAB_ID]: {
root: { type: 'leaf', leafId: LEAF_ID },
activeLeafId: LEAF_ID,
expandedLeafId: null,
ptyIdsByLeafId: { [LEAF_ID]: 'pty-folder' }
}
},
@@ -101,6 +103,108 @@ function state(): DashboardSnapshotState {
}
describe('buildDashboardSnapshot folder workspaces', () => {
it('keeps count-only projection aligned across local and remote workspaces', () => {
const mixedState = state()
const localTabId = 'local-tab'
const localLeafId = '22222222-2222-4222-8222-222222222222'
const localPaneKey = makePaneKey(localTabId, localLeafId)
mixedState.repos = [
{
id: 'repo-1',
path: '/repo-1',
displayName: 'Local repo',
badgeColor: '#000'
}
] as unknown as DashboardSnapshotState['repos']
mixedState.worktreesByRepo = {
'repo-1': [
{
id: 'local-worktree',
repoId: 'repo-1',
path: '/repo-1/worktree',
head: 'abc123',
branch: 'main',
isBare: false,
isMainWorktree: false,
displayName: 'Local worktree',
comment: '',
linkedIssue: null,
linkedPR: null,
linkedLinearIssue: null,
isArchived: false,
isUnread: false,
isPinned: false,
sortOrder: 0,
lastActivityAt: NOW
}
]
} as unknown as DashboardSnapshotState['worktreesByRepo']
mixedState.tabsByWorktree['local-worktree'] = [
{
id: localTabId,
ptyId: 'pty-local',
worktreeId: 'local-worktree',
title: 'claude',
customTitle: null,
color: null,
sortOrder: 0,
createdAt: NOW
},
{
id: 'title-tab',
ptyId: 'pty-title',
worktreeId: 'local-worktree',
title: '✦ Claude Code',
customTitle: null,
color: null,
sortOrder: 1,
createdAt: NOW
}
]
mixedState.terminalLayoutsByTabId[localTabId] = {
root: { type: 'leaf', leafId: localLeafId },
activeLeafId: localLeafId,
expandedLeafId: null,
ptyIdsByLeafId: { [localLeafId]: 'pty-local' }
}
mixedState.terminalLayoutsByTabId['title-tab'] = {
root: { type: 'leaf', leafId: LEAF_ID },
activeLeafId: LEAF_ID,
expandedLeafId: null,
ptyIdsByLeafId: { [LEAF_ID]: 'pty-title' }
}
mixedState.ptyIdsByTabId[localTabId] = ['pty-local']
mixedState.ptyIdsByTabId['title-tab'] = ['pty-title']
mixedState.runtimePaneTitlesByTabId['title-tab'] = { 1: '✦ Claude Code' }
mixedState.agentStatusByPaneKey[localPaneKey] = {
paneKey: localPaneKey,
state: 'done',
prompt: 'Review complete',
updatedAt: NOW,
stateStartedAt: NOW - 60_000,
stateHistory: [],
agentType: 'claude',
tabId: localTabId,
worktreeId: 'local-worktree'
}
mixedState.acknowledgedAgentsByPaneKey[localPaneKey] = NOW
const snapshot = buildDashboardSnapshot(mixedState, NOW)
const expected = { attention: 0, working: 0, done: 0, idle: 0 }
for (const card of snapshot.cards) {
expected[card.bucket] += 1
}
expect(
snapshot.cards.find((card) => card.paneKey === makePaneKey('title-tab', LEAF_ID))
).toMatchObject({
bucket: 'working',
unseen: false,
startedAt: 0
})
expect(buildDashboardBucketCounts(mixedState, NOW)).toEqual(expected)
})
it('places folder-workspace agents in their real project group without git assumptions', () => {
const sshState = state()
sshState.sshTargetLabels = new Map([['ssh-1', 'openclaw']])
@@ -20,15 +20,15 @@ const mocks = vi.hoisted(() => ({
unrelatedEpoch: 0,
agentStatusEpoch: 0
},
buildDashboardSnapshot: vi.fn()
buildDashboardBucketCounts: vi.fn()
}))
vi.mock('@/store', () => ({
useAppStore: (selector: (state: typeof mocks.state) => unknown) => selector(mocks.state)
}))
vi.mock('./build-dashboard-snapshot', () => ({
buildDashboardSnapshot: mocks.buildDashboardSnapshot
vi.mock('./build-dashboard-bucket-counts', () => ({
buildDashboardBucketCounts: mocks.buildDashboardBucketCounts
}))
import { useAgentBucketCounts } from './useAgentBucketCounts'
@@ -42,44 +42,45 @@ afterEach(() => {
describe('useAgentBucketCounts', () => {
it('includes folder workspaces in the count snapshot inputs', () => {
mocks.buildDashboardSnapshot.mockImplementation((state: { folderWorkspaces?: unknown[] }) => ({
generatedAt: 1,
cards: state.folderWorkspaces?.length ? [{ bucket: 'working' }] : []
}))
mocks.buildDashboardBucketCounts.mockImplementation(
(state: { folderWorkspaces?: unknown[] }) => ({
attention: 0,
working: state.folderWorkspaces?.length ? 1 : 0,
done: 0,
idle: 0
})
)
const { result } = renderHook(() => useAgentBucketCounts())
expect(result.current).toEqual({ attention: 0, working: 1, done: 0, idle: 0 })
expect(mocks.buildDashboardSnapshot).toHaveBeenCalledWith(
expect(mocks.buildDashboardBucketCounts).toHaveBeenCalledWith(
expect.objectContaining({ folderWorkspaces: mocks.state.folderWorkspaces }),
expect.any(Number),
{ includeCardDetails: false, includeFilterOptions: false }
expect.any(Number)
)
})
it('moves acknowledged completions to idle without recomputing for unrelated writes', () => {
mocks.buildDashboardSnapshot.mockImplementation(
mocks.buildDashboardBucketCounts.mockImplementation(
(state: { acknowledgedAgentsByPaneKey?: Record<string, number> }) => ({
generatedAt: 1,
cards: [
{
bucket: state.acknowledgedAgentsByPaneKey?.['pane-done'] ? 'idle' : 'done'
}
]
attention: 0,
working: 0,
done: state.acknowledgedAgentsByPaneKey?.['pane-done'] ? 0 : 1,
idle: state.acknowledgedAgentsByPaneKey?.['pane-done'] ? 1 : 0
})
)
const { result, rerender } = renderHook(() => useAgentBucketCounts())
expect(result.current).toEqual({ attention: 0, working: 0, done: 1, idle: 0 })
expect(mocks.buildDashboardSnapshot).toHaveBeenCalledTimes(1)
expect(mocks.buildDashboardBucketCounts).toHaveBeenCalledTimes(1)
mocks.state.unrelatedEpoch += 1
rerender()
expect(mocks.buildDashboardSnapshot).toHaveBeenCalledTimes(1)
expect(mocks.buildDashboardBucketCounts).toHaveBeenCalledTimes(1)
mocks.state.acknowledgedAgentsByPaneKey = { 'pane-done': 1 }
rerender()
expect(result.current).toEqual({ attention: 0, working: 0, done: 0, idle: 1 })
expect(mocks.buildDashboardSnapshot).toHaveBeenCalledTimes(2)
expect(mocks.buildDashboardBucketCounts).toHaveBeenCalledTimes(2)
})
})
@@ -2,12 +2,10 @@ import { useMemo } from 'react'
import { useAppStore } from '@/store'
import { useShallow } from 'zustand/react/shallow'
import type { DashboardBucket } from '../../../../shared/dashboard-snapshot'
import { buildDashboardSnapshot } from './build-dashboard-snapshot'
import { buildDashboardBucketCounts } from './build-dashboard-bucket-counts'
export type AgentBucketCounts = Record<DashboardBucket, number>
const EMPTY_COUNTS: AgentBucketCounts = { attention: 0, working: 0, done: 0, idle: 0 }
/**
* Per-state agent counts for the sidebar dashboard entry, derived from the same
* builder that feeds the pop-out board so the numbers always agree. Recomputes
@@ -47,7 +45,7 @@ export function useAgentBucketCounts(): AgentBucketCounts {
)
return useMemo(() => {
const snapshot = buildDashboardSnapshot(
return buildDashboardBucketCounts(
{
repos,
worktreesByRepo,
@@ -65,17 +63,8 @@ export function useAgentBucketCounts(): AgentBucketCounts {
// generated-title gate is moot and the sidebar stays off settings.
settings: null
},
Date.now(),
{ includeCardDetails: false, includeFilterOptions: false }
Date.now()
)
if (snapshot.cards.length === 0) {
return EMPTY_COUNTS
}
const counts: AgentBucketCounts = { attention: 0, working: 0, done: 0, idle: 0 }
for (const card of snapshot.cards) {
counts[card.bucket] += 1
}
return counts
// 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