diff --git a/src/renderer/src/components/status-bar/use-workspace-space-decision-projection.test.tsx b/src/renderer/src/components/status-bar/use-workspace-space-decision-projection.test.tsx new file mode 100644 index 00000000000..e26a5ad60dc --- /dev/null +++ b/src/renderer/src/components/status-bar/use-workspace-space-decision-projection.test.tsx @@ -0,0 +1,304 @@ +// @vitest-environment happy-dom +import { act, cleanup, renderHook } from '@testing-library/react' +import { useStore } from 'zustand' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { AppState } from '../../store/types' +import { + createTestStore, + makeOpenFile, + makeTab, + makeWorktree +} from '../../store/slices/store-test-helpers' +import type { AgentStatusEntry } from '../../../../shared/agent-status-types' +import type { WorkspaceSpaceWorktree } from '../../../../shared/workspace-space-types' +import { resetAgentStatusEpochClockForTests } from '@/lib/agent-status-epoch-clock' +import { useWorkspaceSpaceManagerBindings } from './use-workspace-space-manager-bindings' +import { useWorkspaceSpaceDecisionProjection } from './use-workspace-space-decision-projection' + +let store: ReturnType +vi.mock('../../store', () => ({ + useAppStore: Object.assign( + (selector: (state: AppState) => Result) => useStore(store, selector), + { getState: () => store.getState() } + ) +})) + +function row( + worktreeId: string, + executionHostId: 'local' | 'ssh:host' = 'local' +): WorkspaceSpaceWorktree { + return { + worktreeId, + executionHostId, + repoId: 'repo', + repoDisplayName: 'Repo', + repoPath: '/repo', + displayName: worktreeId, + path: `/repo/${worktreeId}`, + branch: 'main', + isMainWorktree: false, + isRemote: executionHostId !== 'local', + isSparse: false, + canDelete: true, + lastActivityAt: 0, + status: 'ok', + error: null, + scannedAt: 0, + sizeBytes: 0, + reclaimableBytes: 0, + skippedEntryCount: 0, + topLevelItems: [], + omittedTopLevelItemCount: 0, + omittedTopLevelSizeBytes: 0 + } +} + +function entry(paneKey: string, overrides: Partial = {}): AgentStatusEntry { + return { + paneKey, + state: 'working', + prompt: '', + updatedAt: Date.now(), + stateStartedAt: Date.now(), + stateHistory: [], + ...overrides + } +} + +function seed(rows: WorkspaceSpaceWorktree[]) { + store.setState({ + worktreesByRepo: { repo: rows.map((r) => makeWorktree({ id: r.worktreeId, repoId: 'repo' })) }, + tabsByWorktree: Object.fromEntries( + rows.map((r) => [ + r.worktreeId, + [makeTab({ id: `${r.worktreeId}-tab`, worktreeId: r.worktreeId })] + ]) + ), + workspaceSpaceAnalysis: { + scannedAt: 1, + worktrees: rows, + repos: [], + totalSizeBytes: 0, + reclaimableBytes: 0, + worktreeCount: rows.length, + scannedWorktreeCount: rows.length, + unavailableWorktreeCount: 0 + }, + refreshGitHubForWorktreeIfStale: vi.fn(async () => {}) + }) +} + +function mountProjection() { + return renderHook(() => useWorkspaceSpaceDecisionProjection(useWorkspaceSpaceManagerBindings())) +} + +beforeEach(() => { + vi.useFakeTimers() + vi.setSystemTime(1_000_000) + store = createTestStore() + resetAgentStatusEpochClockForTests() +}) +afterEach(() => { + cleanup() + vi.clearAllTimers() + vi.useRealTimers() + vi.restoreAllMocks() + resetAgentStatusEpochClockForTests() +}) + +describe('mounted Space decision projection', () => { + it('reads each owned agent instead of every global record on each title publication', () => { + seed(Array.from({ length: 20 }, (_, i) => row(`w${i}`))) + let liveProbes = 0, + retainedProbes = 0 + const live: AppState['agentStatusByPaneKey'] = {} + const retained: AppState['retainedAgentsByPaneKey'] = {} + for (let i = 0; i < 10; i += 1) { + const paneKey = `w${i}-tab:legacy` + live[paneKey] = { + ...entry(paneKey), + get state(): AgentStatusEntry['state'] { + liveProbes += 1 + return 'working' + } + } + } + for (let i = 0; i < 40; i += 1) { + const worktreeId = `w${i % 20}` + retained[`completed-${i}`] = { + entry: entry(`completed-${i}:legacy`, { state: 'done' }), + get worktreeId() { + retainedProbes += 1 + return worktreeId + }, + tab: makeTab({ id: `completed-${i}`, worktreeId }), + agentType: 'claude', + startedAt: 0 + } + } + store.setState({ agentStatusByPaneKey: live, retainedAgentsByPaneKey: retained }) + const view = mountProjection() + liveProbes = 0 + retainedProbes = 0 + for (let i = 0; i < 5; i += 1) { + act(() => store.getState().setRuntimePaneTitle('w0-tab', 0, `Ordinary command ${i}`)) + } + expect(view.result.current.decisionDetailsByWorktreeId.size).toBe(20) + expect({ liveProbes, retainedProbes }).toEqual({ liveProbes: 50, retainedProbes: 200 }) + }) + + it('updates from real status, retention, draft, tab and source-row changes', () => { + seed([row('w0'), row('w1')]) + const view = mountProjection() + const details = () => view.result.current.decisionDetailsByWorktreeId.get('local|w0') + act(() => + store.getState().setAgentStatus('w0-tab:legacy', { + agentType: 'custom-non-icon-agent', + state: 'working', + prompt: 'Generated task' + }) + ) + expect(details()?.activeAgentCount).toBe(1) + act(() => + store.getState().setAgentStatus('w0-tab:legacy', { + agentType: 'custom-non-icon-agent', + state: 'done', + prompt: 'Generated task' + }) + ) + expect(details()?.activeAgentCount).toBe(0) + act(() => + store.getState().retainAgents([ + { + entry: entry('completed:legacy', { state: 'done' }), + worktreeId: 'w0', + tab: makeTab({ id: 'completed', worktreeId: 'w0' }), + agentType: 'claude', + startedAt: 0 + } + ]) + ) + expect(details()?.completedAgentCount).toBe(1) + act(() => store.setState({ openFiles: [makeOpenFile({ id: 'file', worktreeId: 'w0' })] })) + act(() => store.getState().setEditorDraft('file', 'Changed content')) + expect(details()?.dirtyEditorBufferCount).toBe(1) + act(() => store.setState({ tabsByWorktree: { w0: [] } })) + expect(details()?.terminalTabCount).toBe(0) + act(() => store.setState({ workspaceSpaceAnalysis: null })) + expect(view.result.current.decisionDetailsByWorktreeId.size).toBe(0) + }) + + it('keeps special own keys and migration workspace-or-tab ownership across host collisions', () => { + seed([row('w0'), row('w0', 'ssh:host'), row('w1')]) + store.setState({ + tabsByWorktree: { + w0: [makeTab({ id: 'shared', worktreeId: 'w0' })], + w1: [makeTab({ id: 'shared', worktreeId: 'w0' })] + }, + agentStatusByPaneKey: Object.fromEntries([ + ['__proto__', entry('shared:legacy', { agentType: 'custom-non-icon-agent' })], + ['constructor', entry('shared:legacy')], + ['2', entry('wrong:legacy')], + ['shared:legacy', entry('other:legacy')] + ]), + migrationUnsupportedByPtyId: Object.fromEntries< + AppState['migrationUnsupportedByPtyId'][string] + >([ + [ + '__proto__', + { + ptyId: 'm1', + worktreeId: 'w0', + tabId: 'shared', + source: 'local', + reason: 'legacy-numeric-pane-key', + updatedAt: 1 + } + ], + [ + 'missing-owner', + { + ptyId: 'm3', + tabId: 'shared', + source: 'local', + reason: 'legacy-numeric-pane-key', + updatedAt: 1 + } + ], + [ + 'constructor', + { + ptyId: 'm2', + worktreeId: 'w0', + tabId: '', + paneKey: 'shared:legacy', + source: 'local', + reason: 'legacy-numeric-pane-key', + updatedAt: 1 + } + ] + ]), + retainedAgentsByPaneKey: Object.fromEntries([ + [ + '__proto__', + { + entry: entry('done:legacy', { state: 'done' }), + worktreeId: 'w0', + tab: makeTab({ id: 'done', worktreeId: 'w0' }), + agentType: 'claude', + startedAt: 0 + } + ], + [ + 'constructor', + { + entry: entry('working:legacy'), + worktreeId: 'w0', + tab: makeTab({ id: 'working', worktreeId: 'w0' }), + agentType: 'claude', + startedAt: 0 + } + ] + ]) + }) + const view = mountProjection() + const results = view.result.current.decisionDetailsByWorktreeId + expect(results.get('local|w0')).toMatchObject({ activeAgentCount: 5, completedAgentCount: 1 }) + expect(results.get('ssh:host|w0')).toMatchObject({ + activeAgentCount: 5, + completedAgentCount: 1 + }) + expect(results.get('local|w1')).toMatchObject({ activeAgentCount: 4, completedAgentCount: 0 }) + }) + + it('does not enumerate global records when there are no scan rows', () => { + seed([]) + const trap = { + ownKeys() { + throw new Error('empty analysis enumerated global agent records') + } + } + store.setState({ + agentStatusByPaneKey: new Proxy({}, trap), + migrationUnsupportedByPtyId: new Proxy({}, trap), + retainedAgentsByPaneKey: new Proxy({}, trap) + }) + expect(mountProjection().result.current.decisionDetailsByWorktreeId.size).toBe(0) + }) + + it('samples freshness on epoch changes without reusing a stale verdict', () => { + seed([row('w0')]) + store.setState({ agentStatusByPaneKey: { 'w0-tab:legacy': entry('w0-tab:legacy') } }) + const view = mountProjection() + expect(view.result.current.decisionDetailsByWorktreeId.get('local|w0')?.activeAgentCount).toBe( + 1 + ) + act(() => { + vi.setSystemTime(3_000_000) + store.setState({ agentStatusEpoch: store.getState().agentStatusEpoch + 1 }) + }) + expect(view.result.current.decisionDetailsByWorktreeId.get('local|w0')?.activeAgentCount).toBe( + 0 + ) + }) +}) diff --git a/src/renderer/src/components/status-bar/use-workspace-space-decision-projection.ts b/src/renderer/src/components/status-bar/use-workspace-space-decision-projection.ts index 78519edde52..9903076842e 100644 --- a/src/renderer/src/components/status-bar/use-workspace-space-decision-projection.ts +++ b/src/renderer/src/components/status-bar/use-workspace-space-decision-projection.ts @@ -1,4 +1,5 @@ import { useCallback, useMemo } from 'react' +import { groupWorkspaceSpaceDecisionInputs } from './workspace-space-decision-inputs' import { getAgentStatusEpochNow } from '@/lib/agent-status-epoch-clock' import { getWorkspaceDecisionDetails, @@ -47,6 +48,23 @@ export function useWorkspaceSpaceDecisionProjection(bindings: WorkspaceSpaceMana } return counts }, [sourceRows]) + const groupedInputs = useMemo( + () => + groupWorkspaceSpaceDecisionInputs(sourceRows, tabsByWorktree, { + agentStatusByPaneKey, + migrationUnsupportedByPtyId, + retainedAgentsByPaneKey, + openFiles + }), + [ + sourceRows, + tabsByWorktree, + agentStatusByPaneKey, + migrationUnsupportedByPtyId, + retainedAgentsByPaneKey, + openFiles + ] + ) const decisionDetailsByWorktreeId = useMemo(() => { // Why: the epoch bumps when fresh hook entries cross the stale boundary so // delete readiness recomputes with the same wall-clock sample as the store. @@ -77,12 +95,14 @@ export function useWorkspaceSpaceDecisionProjection(bindings: WorkspaceSpaceMana settings, activeWorktreeId, activeWorkspaceExecutionHostId, + ...groupedInputs.get(worktree.worktreeId), now: agentStatusNow }) ) } return details }, [ + groupedInputs, activeWorktreeId, activeWorkspaceExecutionHostId, agentStatusEpoch, diff --git a/src/renderer/src/components/status-bar/workspace-space-decision-inputs.ts b/src/renderer/src/components/status-bar/workspace-space-decision-inputs.ts new file mode 100644 index 00000000000..bcd31cf3fb7 --- /dev/null +++ b/src/renderer/src/components/status-bar/workspace-space-decision-inputs.ts @@ -0,0 +1,82 @@ +import type { TerminalTab } from '../../../../shared/terminal-tab-types' +import type { WorkspaceSpaceWorktree } from '../../../../shared/workspace-space-types' +import type { WorkspaceDecisionInputs } from './workspace-space-decision-details' +import { getPaneKeyTabId } from './workspace-space-presentation' + +type GroupedInputs = Pick< + WorkspaceDecisionInputs, + 'agentStatusByPaneKey' | 'migrationUnsupportedByPtyId' | 'retainedAgentsByPaneKey' | 'openFiles' +> + +type Bucket = { + agentStatuses: [string, WorkspaceDecisionInputs['agentStatusByPaneKey'][string]][] + migrations: [string, WorkspaceDecisionInputs['migrationUnsupportedByPtyId'][string]][] + retainedAgents: [string, WorkspaceDecisionInputs['retainedAgentsByPaneKey'][string]][] + openFiles: WorkspaceDecisionInputs['openFiles'][number][] +} + +export function groupWorkspaceSpaceDecisionInputs( + rows: readonly Pick[], + tabsByWorktree: Record, + inputs: GroupedInputs +): Map { + const result = new Map() + if (rows.length === 0) { + return result + } + const buckets = new Map() + const tabOwners = new Map>() + for (const row of rows) { + if (buckets.has(row.worktreeId)) { + continue + } + buckets.set(row.worktreeId, { + agentStatuses: [], + migrations: [], + retainedAgents: [], + openFiles: [] + }) + for (const tab of tabsByWorktree[row.worktreeId] ?? []) { + let owners = tabOwners.get(tab.id) + if (!owners) { + owners = new Set() + tabOwners.set(tab.id, owners) + } + owners.add(row.worktreeId) + } + } + for (const [key, entry] of Object.entries(inputs.agentStatusByPaneKey)) { + const tabId = getPaneKeyTabId(entry.paneKey || key) + if (!tabId) { + continue + } + for (const owner of tabOwners.get(tabId) ?? []) { + buckets.get(owner)?.agentStatuses.push([key, entry]) + } + } + for (const [key, entry] of Object.entries(inputs.migrationUnsupportedByPtyId)) { + const tabId = entry.tabId ?? (entry.paneKey ? getPaneKeyTabId(entry.paneKey) : null) + const owners = new Set(tabId ? tabOwners.get(tabId) : undefined) + if (entry.worktreeId !== undefined && buckets.has(entry.worktreeId)) { + owners.add(entry.worktreeId) + } + for (const owner of owners) { + buckets.get(owner)?.migrations.push([key, entry]) + } + } + for (const [key, entry] of Object.entries(inputs.retainedAgentsByPaneKey)) { + buckets.get(entry.worktreeId)?.retainedAgents.push([key, entry]) + } + for (const file of inputs.openFiles) { + buckets.get(file.worktreeId)?.openFiles.push(file) + } + for (const [id, bucket] of buckets) { + result.set(id, { + agentStatusByPaneKey: Object.fromEntries(bucket.agentStatuses), + migrationUnsupportedByPtyId: Object.fromEntries(bucket.migrations), + retainedAgentsByPaneKey: Object.fromEntries(bucket.retainedAgents), + openFiles: bucket.openFiles + }) + } + return result +} diff --git a/src/renderer/src/components/status-bar/workspace-space-presentation.ts b/src/renderer/src/components/status-bar/workspace-space-presentation.ts index 5edad9ec739..b88332e8a3f 100644 --- a/src/renderer/src/components/status-bar/workspace-space-presentation.ts +++ b/src/renderer/src/components/status-bar/workspace-space-presentation.ts @@ -47,7 +47,7 @@ export type WorkspaceSpaceAgentActivityInputs = { now: number } -function getPaneKeyTabId(paneKey: string): string | null { +export function getPaneKeyTabId(paneKey: string): string | null { const parsed = parsePaneKey(paneKey) if (parsed) { return parsed.tabId