mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 00:02:31 +00:00
perf(dashboard): build sidebar bucket counts without cards (#17497)
* perf(dashboard): build sidebar bucket counts without cards * fix dashboard bucket parity for unified agent tabs * docs(dashboard): clarify count projection comment * refactor(dashboard): share bucket projection derivation
This commit is contained in:
@@ -0,0 +1,88 @@
|
||||
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 { 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 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)
|
||||
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
|
||||
})
|
||||
)
|
||||
|
||||
for (const row of rows) {
|
||||
if (row.rowSource === 'subagent') {
|
||||
continue
|
||||
}
|
||||
counts[dashboardRowBucketProjection(row, state.acknowledgedAgentsByPaneKey).bucket] += 1
|
||||
}
|
||||
}
|
||||
|
||||
return counts.attention === 0 && counts.working === 0 && counts.done === 0 && counts.idle === 0
|
||||
? EMPTY_COUNTS
|
||||
: counts
|
||||
}
|
||||
+145
@@ -3,9 +3,11 @@ import { makePaneKey } from '../../../../shared/stable-pane-id'
|
||||
import { folderWorkspaceKey } from '../../../../shared/workspace-scope'
|
||||
import type { FolderWorkspace } from '../../../../shared/folder-workspace-types'
|
||||
import type { ProjectGroup } from '../../../../shared/project-group-types'
|
||||
import type { Tab } from '../../../../shared/tab-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 +92,7 @@ function state(): DashboardSnapshotState {
|
||||
[TAB_ID]: {
|
||||
root: { type: 'leaf', leafId: LEAF_ID },
|
||||
activeLeafId: LEAF_ID,
|
||||
expandedLeafId: null,
|
||||
ptyIdsByLeafId: { [LEAF_ID]: 'pty-folder' }
|
||||
}
|
||||
},
|
||||
@@ -101,6 +104,148 @@ 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('keeps done structured sessions visible when their tab exists only in unified tabs', () => {
|
||||
const structuredState = state()
|
||||
structuredState.tabsByWorktree = { [WORKSPACE_ID]: [] }
|
||||
structuredState.unifiedTabsByWorktree = {
|
||||
[WORKSPACE_ID]: [
|
||||
{
|
||||
id: TAB_ID,
|
||||
entityId: 'session-1',
|
||||
groupId: 'group-1',
|
||||
worktreeId: WORKSPACE_ID,
|
||||
contentType: 'agent-session',
|
||||
label: 'Codex Chat',
|
||||
customLabel: null,
|
||||
color: null,
|
||||
sortOrder: 0,
|
||||
createdAt: NOW,
|
||||
isPinned: false,
|
||||
agentSessionAgent: 'codex'
|
||||
} satisfies Tab
|
||||
]
|
||||
}
|
||||
structuredState.agentStatusByPaneKey = {
|
||||
[PANE_KEY]: {
|
||||
...entry(),
|
||||
state: 'done',
|
||||
sessionBoundary: true
|
||||
}
|
||||
}
|
||||
|
||||
const snapshot = buildDashboardSnapshot(structuredState, NOW)
|
||||
const expected = { attention: 0, working: 0, done: 0, idle: 0 }
|
||||
for (const card of snapshot.cards) {
|
||||
expected[card.bucket] += 1
|
||||
}
|
||||
|
||||
expect(snapshot.cards).toHaveLength(1)
|
||||
expect(snapshot.cards[0]).toMatchObject({ paneKey: PANE_KEY, bucket: 'done' })
|
||||
expect(buildDashboardBucketCounts(structuredState, 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']])
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
import type { AppState } from '@/store/types'
|
||||
import {
|
||||
DASHBOARD_MAX_MAP_WORKSPACES,
|
||||
dashboardCardDisplayState,
|
||||
type DashboardCard,
|
||||
type DashboardCardDotState,
|
||||
type DashboardSnapshot,
|
||||
type DashboardWorkspace
|
||||
} from '../../../../shared/dashboard-snapshot'
|
||||
@@ -22,14 +20,9 @@ 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 { EMPTY_WORKTREE_AGENT_ORCHESTRATION } from '../sidebar/worktree-agent-orchestration-batch'
|
||||
import {
|
||||
selectLivePtyIdsForWorktree,
|
||||
selectRuntimePaneTitlesForWorktree
|
||||
@@ -54,8 +47,9 @@ import {
|
||||
type DashboardLaunchDetectionState
|
||||
} from './dashboard-worktree-launch-options'
|
||||
import { buildDashboardSnapshotFilterOptions } from './dashboard-snapshot-filter-options'
|
||||
import { dashboardBucketForDotState } from './dashboard-card-bucket'
|
||||
import { groupSubagentsByParentPaneKey } from './dashboard-subagent-cards'
|
||||
import { selectDashboardOrchestration } from './dashboard-orchestration-selection'
|
||||
import { dashboardRowBucketProjection } from './dashboard-row-bucket'
|
||||
|
||||
/** The store slices the snapshot builder reads. Kept as a Pick so unit tests
|
||||
* can pass a partial store without constructing the whole AppState. */
|
||||
@@ -78,7 +72,7 @@ export type DashboardSnapshotState = Pick<
|
||||
Partial<
|
||||
DashboardCardTerminalInputState &
|
||||
DashboardLaunchDetectionState &
|
||||
Pick<AppState, 'runtimeEnvironments' | 'sshTargetLabels'>
|
||||
Pick<AppState, 'runtimeEnvironments' | 'sshTargetLabels' | 'unifiedTabsByWorktree'>
|
||||
>
|
||||
|
||||
/**
|
||||
@@ -106,23 +100,10 @@ export function buildDashboardSnapshot(
|
||||
options.includeFilterOptions === false
|
||||
? undefined
|
||||
: buildDashboardSnapshotFilterOptions(state, activeWorktrees)
|
||||
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
|
||||
)
|
||||
}
|
||||
}
|
||||
const { singletonOrchestration, orchestrationByWorktree } = selectDashboardOrchestration(
|
||||
state,
|
||||
activeWorktrees
|
||||
)
|
||||
|
||||
for (const workspace of activeWorktrees) {
|
||||
const { repo, worktree } = workspace
|
||||
@@ -195,7 +176,8 @@ export function buildDashboardSnapshot(
|
||||
// agent-hook status) carry synthetic prompt/lastAssistantMessage — the
|
||||
// agent LABEL and a status word like "Idle". They're marked by
|
||||
// startedAt === 0, and must NOT be shown as real conversation.
|
||||
const isTitleDerived = row.startedAt === 0
|
||||
const { isTitleDerived, dotState, workingMode, unseen, bucket } =
|
||||
dashboardRowBucketProjection(row, state.acknowledgedAgentsByPaneKey)
|
||||
const routingPaneKey = row.activationPaneKey ?? row.paneKey
|
||||
const parsed = parsePaneKey(routingPaneKey)
|
||||
const tabId = parsed?.tabId ?? row.tab.id
|
||||
@@ -209,17 +191,6 @@ export function buildDashboardSnapshot(
|
||||
layoutPtyId && (state.ptyIdsByTabId?.[tabId] ?? []).includes(layoutPtyId)
|
||||
? layoutPtyId
|
||||
: null
|
||||
const dotState = row.state as DashboardCardDotState
|
||||
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, workingMode, unseen })
|
||||
)
|
||||
// Why: only a live pty can open a preview terminal, and only a
|
||||
// card-rendering caller can open one — the sidebar's bucket counts must
|
||||
// not pay host resolution on every agent-status tick.
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import type { ActiveDashboardWorkspace } from './dashboard-snapshot-workspaces'
|
||||
import type { DashboardSnapshotState } from './build-dashboard-snapshot'
|
||||
import {
|
||||
releaseRuntimeAgentOrchestrationBatchCache,
|
||||
selectRuntimeAgentOrchestrationBatch
|
||||
} from '../sidebar/worktree-agent-orchestration-batch'
|
||||
import { selectRuntimeAgentOrchestrationForWorktree } from '../sidebar/worktree-agent-row-selectors'
|
||||
|
||||
/** Select the singleton or batched orchestration view for active workspaces. */
|
||||
export function selectDashboardOrchestration(
|
||||
state: DashboardSnapshotState,
|
||||
activeWorkspaces: readonly Pick<ActiveDashboardWorkspace, 'worktree'>[]
|
||||
): {
|
||||
singletonOrchestration: ReturnType<typeof selectRuntimeAgentOrchestrationForWorktree> | null
|
||||
orchestrationByWorktree: ReturnType<typeof selectRuntimeAgentOrchestrationBatch> | null
|
||||
} {
|
||||
let singletonOrchestration: ReturnType<typeof selectRuntimeAgentOrchestrationForWorktree> | null =
|
||||
null
|
||||
let orchestrationByWorktree: ReturnType<typeof selectRuntimeAgentOrchestrationBatch> | null = null
|
||||
|
||||
if (activeWorkspaces.length >= 2) {
|
||||
orchestrationByWorktree = selectRuntimeAgentOrchestrationBatch(
|
||||
state,
|
||||
activeWorkspaces.map(({ worktree }) => worktree.id)
|
||||
)
|
||||
} else {
|
||||
releaseRuntimeAgentOrchestrationBatchCache()
|
||||
if (activeWorkspaces.length === 1) {
|
||||
singletonOrchestration = selectRuntimeAgentOrchestrationForWorktree(
|
||||
state,
|
||||
activeWorkspaces[0].worktree.id
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
singletonOrchestration,
|
||||
orchestrationByWorktree
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import type { DashboardAgentRow } from './useDashboardData'
|
||||
import {
|
||||
dashboardCardDisplayState,
|
||||
type DashboardBucket,
|
||||
type DashboardCardDotState
|
||||
} from '../../../../shared/dashboard-snapshot'
|
||||
import { dashboardBucketForDotState } from './dashboard-card-bucket'
|
||||
|
||||
export type DashboardRowBucketProjection = {
|
||||
isTitleDerived: boolean
|
||||
dotState: DashboardCardDotState
|
||||
workingMode: DashboardAgentRow['entry']['workingMode']
|
||||
unseen: boolean
|
||||
bucket: DashboardBucket
|
||||
}
|
||||
|
||||
/** Derive the shared dashboard presentation state for one agent row. */
|
||||
export function dashboardRowBucketProjection(
|
||||
row: Pick<DashboardAgentRow, 'paneKey' | 'entry' | 'state' | 'startedAt'>,
|
||||
acknowledgedAgentsByPaneKey?: Record<string, number>
|
||||
): DashboardRowBucketProjection {
|
||||
const isTitleDerived = row.startedAt === 0
|
||||
const dotState = row.state as DashboardCardDotState
|
||||
const workingMode =
|
||||
row.state === 'working' && row.entry.workingMode === 'monitoring'
|
||||
? row.entry.workingMode
|
||||
: undefined
|
||||
const unseen =
|
||||
!isTitleDerived && (acknowledgedAgentsByPaneKey?.[row.paneKey] ?? 0) < row.entry.stateStartedAt
|
||||
const bucket = dashboardBucketForDotState(
|
||||
dashboardCardDisplayState({ dotState, workingMode, unseen })
|
||||
)
|
||||
|
||||
return { isTitleDerived, dotState, workingMode, unseen, bucket }
|
||||
}
|
||||
@@ -8,6 +8,7 @@ const mocks = vi.hoisted(() => ({
|
||||
repos: [],
|
||||
worktreesByRepo: {},
|
||||
tabsByWorktree: {},
|
||||
unifiedTabsByWorktree: {},
|
||||
agentStatusByPaneKey: {},
|
||||
retainedAgentsByPaneKey: {},
|
||||
migrationUnsupportedByPtyId: {},
|
||||
@@ -20,15 +21,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 +43,48 @@ 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.objectContaining({ folderWorkspaces: mocks.state.folderWorkspaces }),
|
||||
expect.any(Number),
|
||||
{ includeCardDetails: false, includeFilterOptions: false }
|
||||
expect(mocks.buildDashboardBucketCounts).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
folderWorkspaces: mocks.state.folderWorkspaces,
|
||||
unifiedTabsByWorktree: mocks.state.unifiedTabsByWorktree
|
||||
}),
|
||||
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,22 +2,21 @@ 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
|
||||
* only when an input slice changes (mirrors useDashboardData's cost profile).
|
||||
* 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,
|
||||
@@ -33,6 +32,7 @@ export function useAgentBucketCounts(): AgentBucketCounts {
|
||||
repos: s.repos,
|
||||
worktreesByRepo: s.worktreesByRepo,
|
||||
tabsByWorktree: s.tabsByWorktree,
|
||||
unifiedTabsByWorktree: s.unifiedTabsByWorktree,
|
||||
agentStatusByPaneKey: s.agentStatusByPaneKey,
|
||||
retainedAgentsByPaneKey: s.retainedAgentsByPaneKey,
|
||||
migrationUnsupportedByPtyId: s.migrationUnsupportedByPtyId,
|
||||
@@ -47,11 +47,12 @@ export function useAgentBucketCounts(): AgentBucketCounts {
|
||||
)
|
||||
|
||||
return useMemo(() => {
|
||||
const snapshot = buildDashboardSnapshot(
|
||||
return buildDashboardBucketCounts(
|
||||
{
|
||||
repos,
|
||||
worktreesByRepo,
|
||||
tabsByWorktree,
|
||||
unifiedTabsByWorktree,
|
||||
agentStatusByPaneKey,
|
||||
retainedAgentsByPaneKey,
|
||||
migrationUnsupportedByPtyId,
|
||||
@@ -65,17 +66,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
|
||||
@@ -83,6 +75,7 @@ export function useAgentBucketCounts(): AgentBucketCounts {
|
||||
repos,
|
||||
worktreesByRepo,
|
||||
tabsByWorktree,
|
||||
unifiedTabsByWorktree,
|
||||
agentStatusByPaneKey,
|
||||
retainedAgentsByPaneKey,
|
||||
migrationUnsupportedByPtyId,
|
||||
|
||||
@@ -59,6 +59,7 @@ function makeSnapshotWatchState(): DashboardSnapshotWatchState {
|
||||
repos: [],
|
||||
worktreesByRepo: {},
|
||||
tabsByWorktree: {},
|
||||
unifiedTabsByWorktree: {},
|
||||
agentStatusByPaneKey: {},
|
||||
retainedAgentsByPaneKey: {},
|
||||
migrationUnsupportedByPtyId: {},
|
||||
@@ -182,6 +183,7 @@ describe('useDashboardPopoutBridge', () => {
|
||||
'repos',
|
||||
'worktreesByRepo',
|
||||
'tabsByWorktree',
|
||||
'unifiedTabsByWorktree',
|
||||
'retainedAgentsByPaneKey',
|
||||
'migrationUnsupportedByPtyId',
|
||||
'runtimeAgentOrchestrationByPaneKey',
|
||||
@@ -241,6 +243,19 @@ describe('useDashboardPopoutBridge', () => {
|
||||
expect(republished).toEqual(profileInputs.map((next) => Object.keys(next)[0]))
|
||||
})
|
||||
|
||||
it('republishes when the unified agent-session tab projection changes', () => {
|
||||
const previousState = makeSnapshotWatchState()
|
||||
expect(
|
||||
dashboardSnapshotInputsChanged(
|
||||
{
|
||||
...previousState,
|
||||
unifiedTabsByWorktree: { 'worktree-1': [] }
|
||||
},
|
||||
previousState
|
||||
)
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('releases every dashboard listener when the experiment is disabled', async () => {
|
||||
await act(async () => root.render(<Harness enabled />))
|
||||
|
||||
|
||||
@@ -46,6 +46,7 @@ export function dashboardSnapshotInputsChanged(
|
||||
state.repos !== previousState.repos ||
|
||||
state.worktreesByRepo !== previousState.worktreesByRepo ||
|
||||
state.tabsByWorktree !== previousState.tabsByWorktree ||
|
||||
state.unifiedTabsByWorktree !== previousState.unifiedTabsByWorktree ||
|
||||
state.retainedAgentsByPaneKey !== previousState.retainedAgentsByPaneKey ||
|
||||
state.migrationUnsupportedByPtyId !== previousState.migrationUnsupportedByPtyId ||
|
||||
state.runtimeAgentOrchestrationByPaneKey !== previousState.runtimeAgentOrchestrationByPaneKey ||
|
||||
|
||||
@@ -13,6 +13,7 @@ export function useLiveDashboardSnapshot(): DashboardSnapshot {
|
||||
const repos = useAppStore((s) => s.repos)
|
||||
const worktreesByRepo = useAppStore((s) => s.worktreesByRepo)
|
||||
const tabsByWorktree = useAppStore((s) => s.tabsByWorktree)
|
||||
const unifiedTabsByWorktree = useAppStore((s) => s.unifiedTabsByWorktree)
|
||||
const agentStatusByPaneKey = useAppStore((s) => s.agentStatusByPaneKey)
|
||||
const retainedAgentsByPaneKey = useAppStore((s) => s.retainedAgentsByPaneKey)
|
||||
const migrationUnsupportedByPtyId = useAppStore((s) => s.migrationUnsupportedByPtyId)
|
||||
@@ -68,6 +69,7 @@ export function useLiveDashboardSnapshot(): DashboardSnapshot {
|
||||
repos,
|
||||
worktreesByRepo,
|
||||
tabsByWorktree,
|
||||
unifiedTabsByWorktree,
|
||||
agentStatusByPaneKey,
|
||||
retainedAgentsByPaneKey,
|
||||
migrationUnsupportedByPtyId,
|
||||
@@ -107,6 +109,7 @@ export function useLiveDashboardSnapshot(): DashboardSnapshot {
|
||||
repos,
|
||||
worktreesByRepo,
|
||||
tabsByWorktree,
|
||||
unifiedTabsByWorktree,
|
||||
agentStatusByPaneKey,
|
||||
retainedAgentsByPaneKey,
|
||||
migrationUnsupportedByPtyId,
|
||||
|
||||
Reference in New Issue
Block a user