diff --git a/src/renderer/src/components/dashboard/build-dashboard-bucket-counts.ts b/src/renderer/src/components/dashboard/build-dashboard-bucket-counts.ts new file mode 100644 index 00000000000..d077b447a3a --- /dev/null +++ b/src/renderer/src/components/dashboard/build-dashboard-bucket-counts.ts @@ -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 = { + 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 { + const counts = { + attention: 0, + working: 0, + done: 0, + idle: 0 + } satisfies Record + 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 +} diff --git a/src/renderer/src/components/dashboard/build-dashboard-snapshot-folder-workspace.test.ts b/src/renderer/src/components/dashboard/build-dashboard-snapshot-folder-workspace.test.ts index 51a3aada421..6c670934cb5 100644 --- a/src/renderer/src/components/dashboard/build-dashboard-snapshot-folder-workspace.test.ts +++ b/src/renderer/src/components/dashboard/build-dashboard-snapshot-folder-workspace.test.ts @@ -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']]) diff --git a/src/renderer/src/components/dashboard/build-dashboard-snapshot.ts b/src/renderer/src/components/dashboard/build-dashboard-snapshot.ts index e0091066972..4b336a56a88 100644 --- a/src/renderer/src/components/dashboard/build-dashboard-snapshot.ts +++ b/src/renderer/src/components/dashboard/build-dashboard-snapshot.ts @@ -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 + Pick > /** @@ -106,23 +100,10 @@ export function buildDashboardSnapshot( options.includeFilterOptions === false ? undefined : buildDashboardSnapshotFilterOptions(state, activeWorktrees) - let singletonOrchestration: ReturnType | null = - null - let orchestrationByWorktree: ReturnType | 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. diff --git a/src/renderer/src/components/dashboard/dashboard-orchestration-selection.ts b/src/renderer/src/components/dashboard/dashboard-orchestration-selection.ts new file mode 100644 index 00000000000..d3e823fa6f0 --- /dev/null +++ b/src/renderer/src/components/dashboard/dashboard-orchestration-selection.ts @@ -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[] +): { + singletonOrchestration: ReturnType | null + orchestrationByWorktree: ReturnType | null +} { + let singletonOrchestration: ReturnType | null = + null + let orchestrationByWorktree: ReturnType | 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 + } +} diff --git a/src/renderer/src/components/dashboard/dashboard-row-bucket.ts b/src/renderer/src/components/dashboard/dashboard-row-bucket.ts new file mode 100644 index 00000000000..c1c15e293ec --- /dev/null +++ b/src/renderer/src/components/dashboard/dashboard-row-bucket.ts @@ -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, + acknowledgedAgentsByPaneKey?: Record +): 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 } +} diff --git a/src/renderer/src/components/dashboard/useAgentBucketCounts.test.tsx b/src/renderer/src/components/dashboard/useAgentBucketCounts.test.tsx index 02f9b5a5386..f45dcd3b6e7 100644 --- a/src/renderer/src/components/dashboard/useAgentBucketCounts.test.tsx +++ b/src/renderer/src/components/dashboard/useAgentBucketCounts.test.tsx @@ -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 }) => ({ - 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) }) }) diff --git a/src/renderer/src/components/dashboard/useAgentBucketCounts.ts b/src/renderer/src/components/dashboard/useAgentBucketCounts.ts index fe55b6e405c..58b7a444e37 100644 --- a/src/renderer/src/components/dashboard/useAgentBucketCounts.ts +++ b/src/renderer/src/components/dashboard/useAgentBucketCounts.ts @@ -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 -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, diff --git a/src/renderer/src/components/dashboard/useDashboardPopoutBridge.test.tsx b/src/renderer/src/components/dashboard/useDashboardPopoutBridge.test.tsx index 84ea917f372..aa427ed55c5 100644 --- a/src/renderer/src/components/dashboard/useDashboardPopoutBridge.test.tsx +++ b/src/renderer/src/components/dashboard/useDashboardPopoutBridge.test.tsx @@ -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()) diff --git a/src/renderer/src/components/dashboard/useDashboardPopoutBridge.ts b/src/renderer/src/components/dashboard/useDashboardPopoutBridge.ts index 3d58b9100c8..c2446f4e11f 100644 --- a/src/renderer/src/components/dashboard/useDashboardPopoutBridge.ts +++ b/src/renderer/src/components/dashboard/useDashboardPopoutBridge.ts @@ -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 || diff --git a/src/renderer/src/components/dashboard/useLiveDashboardSnapshot.ts b/src/renderer/src/components/dashboard/useLiveDashboardSnapshot.ts index d3848178509..07317083d7e 100644 --- a/src/renderer/src/components/dashboard/useLiveDashboardSnapshot.ts +++ b/src/renderer/src/components/dashboard/useLiveDashboardSnapshot.ts @@ -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,