mirror of
https://github.com/stablyai/orca.git
synced 2026-10-01 08:01:56 +00:00
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:
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user