From 3e0ff551d707c0a4e3b4ffe49a0f0ed5b7cf4a5c Mon Sep 17 00:00:00 2001 From: brennanb2025 Date: Fri, 26 Jun 2026 11:05:08 -0700 Subject: [PATCH] Stream workspace cleanup scan progress Co-authored-by: Orca --- src/main/ipc/workspace-cleanup.test.ts | 43 ++++++ src/main/ipc/workspace-cleanup.ts | 125 ++++++++++++++---- src/preload/api-types.ts | 6 +- src/preload/index.ts | 20 ++- .../WorkspaceCleanupDialog.tsx | 54 ++++---- .../store/slices/workspace-cleanup.test.ts | 59 ++++++++- .../src/store/slices/workspace-cleanup.ts | 49 ++++++- src/shared/workspace-cleanup.ts | 7 + 8 files changed, 305 insertions(+), 58 deletions(-) diff --git a/src/main/ipc/workspace-cleanup.test.ts b/src/main/ipc/workspace-cleanup.test.ts index 9af63a135f2..7b3a15828eb 100644 --- a/src/main/ipc/workspace-cleanup.test.ts +++ b/src/main/ipc/workspace-cleanup.test.ts @@ -171,6 +171,49 @@ describe('workspace cleanup scan', () => { }) }) + it('reports cleanup scan progress as inactive workspaces are checked', async () => { + listRepoWorktreesMock.mockResolvedValue([ + { + path: '/repo-feature-a', + head: 'abc123', + branch: 'refs/heads/feature-a', + isBare: false, + isMainWorktree: false + }, + { + path: '/repo-feature-b', + head: 'def456', + branch: 'refs/heads/feature-b', + isBare: false, + isMainWorktree: false + } + ]) + const progress: unknown[] = [] + + const result = await scanWorkspaceCleanup( + makeStore(), + { scanId: 'scan-1' }, + { onProgress: (event) => progress.push(event) } + ) + + expect(result.candidates).toHaveLength(2) + expect(progress[0]).toMatchObject({ + scanId: 'scan-1', + scannedWorktreeCount: 0, + totalWorktreeCount: 2, + candidates: [] + }) + expect(progress.at(-1)).toMatchObject({ + scanId: 'scan-1', + scannedWorktreeCount: 2, + totalWorktreeCount: 2, + candidates: expect.arrayContaining([ + expect.objectContaining({ worktreeId: 'repo-1::/repo-feature-a' }), + expect.objectContaining({ worktreeId: 'repo-1::/repo-feature-b' }) + ]) + }) + }) + it('keeps raw scan errors out of renderer-facing results', async () => { listRepoWorktreesMock.mockRejectedValue(new Error('fatal: path /Users/alice/private failed')) diff --git a/src/main/ipc/workspace-cleanup.ts b/src/main/ipc/workspace-cleanup.ts index 0e3d6c1b7fa..5e44d395863 100644 --- a/src/main/ipc/workspace-cleanup.ts +++ b/src/main/ipc/workspace-cleanup.ts @@ -36,6 +36,7 @@ import { type WorkspaceCleanupReason, type WorkspaceCleanupScanError, type WorkspaceCleanupScanArgs, + type WorkspaceCleanupScanProgress, type WorkspaceCleanupScanResult } from '../../shared/workspace-cleanup' @@ -55,6 +56,16 @@ type WorkspaceCleanupHandlerDeps = { getLocalPtyProvider?: () => IPtyProvider } +type WorkspaceCleanupScanOptions = { + onProgress?: (progress: WorkspaceCleanupScanProgress) => void +} + +type WorkspaceCleanupScanRepoProgress = { + onWorktreesDiscovered?: (count: number) => void + onCandidateScanned?: (candidate: WorkspaceCleanupCandidate) => void + onErrors?: (errors: WorkspaceCleanupScanError[]) => void +} + export function registerWorkspaceCleanupHandlers( store: Store, deps: WorkspaceCleanupHandlerDeps = {} @@ -66,8 +77,12 @@ export function registerWorkspaceCleanupHandlers( ipcMain.handle( 'workspaceCleanup:scan', - (_event, args?: WorkspaceCleanupScanArgs): Promise => - scanWorkspaceCleanup(store, args ?? {}) + (event, args?: WorkspaceCleanupScanArgs): Promise => + scanWorkspaceCleanup(store, args ?? {}, { + onProgress: args?.scanId + ? (progress) => event.sender.send('workspaceCleanup:scanProgress', progress) + : undefined + }) ) ipcMain.handle('workspaceCleanup:dismiss', (_event, args: WorkspaceCleanupDismissArgs) => { @@ -192,7 +207,8 @@ function isPathWithin(candidatePath: string, parentPath: string): boolean { export async function scanWorkspaceCleanup( store: Store, - args: WorkspaceCleanupScanArgs = {} + args: WorkspaceCleanupScanArgs = {}, + options: WorkspaceCleanupScanOptions = {} ): Promise { const scannedAt = Date.now() const parsedTarget = args.worktreeId ? splitWorktreeId(args.worktreeId) : null @@ -204,6 +220,23 @@ export async function scanWorkspaceCleanup( : store.getRepos() const errors: WorkspaceCleanupScanResult['errors'] = [] const candidates: WorkspaceCleanupCandidate[] = [] + const progressCandidates: WorkspaceCleanupCandidate[] = [] + const progressErrors: WorkspaceCleanupScanError[] = [] + let totalWorktreeCount = 0 + let scannedWorktreeCount = 0 + const emitProgress = (): void => { + if (!args.scanId) { + return + } + options.onProgress?.({ + scanId: args.scanId, + scannedAt, + totalWorktreeCount, + scannedWorktreeCount, + candidates: [...progressCandidates], + errors: [...progressErrors] + }) + } for (const repo of repos) { const result = await scanRepoWorkspaces({ @@ -211,7 +244,20 @@ export async function scanWorkspaceCleanup( repo, scannedAt, targetWorktreeId: args.worktreeId, - skipGitWorktreeIds: new Set(args.skipGitWorktreeIds ?? []) + skipGitWorktreeIds: new Set(args.skipGitWorktreeIds ?? []), + onWorktreesDiscovered: (count) => { + totalWorktreeCount += count + emitProgress() + }, + onCandidateScanned: (candidate) => { + progressCandidates.push(candidate) + scannedWorktreeCount += 1 + emitProgress() + }, + onErrors: (repoErrors) => { + appendListItems(progressErrors, repoErrors) + emitProgress() + } }) appendListItems(candidates, result.candidates) appendListItems(errors, result.errors) @@ -220,14 +266,25 @@ export async function scanWorkspaceCleanup( return { scannedAt, candidates, errors } } -async function scanRepoWorkspaces(args: { - store: Store - repo: Repo - scannedAt: number - targetWorktreeId?: string - skipGitWorktreeIds: Set -}): Promise { - const { store, repo, scannedAt, targetWorktreeId, skipGitWorktreeIds } = args +async function scanRepoWorkspaces( + args: { + store: Store + repo: Repo + scannedAt: number + targetWorktreeId?: string + skipGitWorktreeIds: Set + } & WorkspaceCleanupScanRepoProgress +): Promise { + const { + store, + repo, + scannedAt, + targetWorktreeId, + skipGitWorktreeIds, + onWorktreesDiscovered, + onCandidateScanned, + onErrors + } = args const errors: WorkspaceCleanupScanResult['errors'] = [] let provider: IGitProvider | null = null let gitWorktrees: GitWorktreeInfo[] = [] @@ -241,11 +298,16 @@ async function scanRepoWorkspaces(args: { if (!provider) { // Why: cleanup should reflect only workspaces Orca can currently // inspect. Disconnected SSH repos are skipped in broad scans. + const candidates = targetWorktreeId + ? synthesizeDisconnectedSshCandidates(store, repo, scannedAt, targetWorktreeId) + : [] + onWorktreesDiscovered?.(candidates.length) + for (const candidate of candidates) { + onCandidateScanned?.(candidate) + } return { scannedAt, - candidates: targetWorktreeId - ? synthesizeDisconnectedSshCandidates(store, repo, scannedAt, targetWorktreeId) - : [], + candidates, errors: [] } } @@ -269,6 +331,7 @@ async function scanRepoWorkspaces(args: { return { scannedAt, candidates: [], errors: [] } } errors.push(createScanError(repo, toSafeRepoScanError(error))) + onErrors?.(errors) return { scannedAt, candidates: [], errors } } @@ -289,18 +352,26 @@ async function scanRepoWorkspaces(args: { ) }) - const candidates = await mapWithConcurrency(worktrees, WORKTREE_SCAN_CONCURRENCY, (worktree) => - buildCandidate({ - repo, - worktree, - scannedAt, - provider, - skipGit: skipGitWorktreeIds.has(worktree.id), - forceGitCheck: Boolean(targetWorktreeId) - }).catch((error) => { - console.error('Workspace cleanup candidate scan failed', error) - return buildCandidateFromError(repo, worktree, scannedAt, toErrorMessage(error)) - }) + onWorktreesDiscovered?.(worktrees.length) + + const candidates = await mapWithConcurrency( + worktrees, + WORKTREE_SCAN_CONCURRENCY, + async (worktree) => { + const candidate = await buildCandidate({ + repo, + worktree, + scannedAt, + provider, + skipGit: skipGitWorktreeIds.has(worktree.id), + forceGitCheck: Boolean(targetWorktreeId) + }).catch((error) => { + console.error('Workspace cleanup candidate scan failed', error) + return buildCandidateFromError(repo, worktree, scannedAt, toErrorMessage(error)) + }) + onCandidateScanned?.(candidate) + return candidate + } ) return { scannedAt, candidates, errors } diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index 79d80adb8f2..939711661b1 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -391,6 +391,7 @@ import type { WorkspaceCleanupLocalProcessArgs, WorkspaceCleanupLocalProcessResult, WorkspaceCleanupScanArgs, + WorkspaceCleanupScanProgress, WorkspaceCleanupScanResult } from '../shared/workspace-cleanup' import type { KeybindingActionId, KeybindingFileSnapshot } from '../shared/keybindings' @@ -989,7 +990,10 @@ export type PreloadApi = { ) => () => void } workspaceCleanup: { - scan: (args?: WorkspaceCleanupScanArgs) => Promise + scan: ( + args?: WorkspaceCleanupScanArgs, + onProgress?: (progress: WorkspaceCleanupScanProgress) => void + ) => Promise dismiss: (args: WorkspaceCleanupDismissArgs) => Promise clearDismissals: () => Promise hasKillableLocalProcesses: ( diff --git a/src/preload/index.ts b/src/preload/index.ts index 08e31cab006..bccb8c16ab3 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -79,6 +79,7 @@ import type { RateLimitState } from '../shared/rate-limit-types' import type { WorkspaceSpaceScanProgress } from '../shared/workspace-space-types' +import type { WorkspaceCleanupScanProgress } from '../shared/workspace-cleanup' import type { WorkspacePortAdvertisedUrlChangedEvent } from '../shared/workspace-ports' import type { GhAuthDiagnostic } from '../shared/github-auth-types' import type { TaskSourceContext } from '../shared/task-source-context' @@ -662,7 +663,24 @@ const api = { } satisfies PreloadApi['worktrees'], workspaceCleanup: { - scan: (args) => ipcRenderer.invoke('workspaceCleanup:scan', args), + scan: (args, onProgress) => { + if (!onProgress) { + return ipcRenderer.invoke('workspaceCleanup:scan', args) + } + const scanId = args?.scanId ?? crypto.randomUUID() + const listener = ( + _event: Electron.IpcRendererEvent, + progress: WorkspaceCleanupScanProgress + ): void => { + if (progress.scanId === scanId) { + onProgress(progress) + } + } + ipcRenderer.on('workspaceCleanup:scanProgress', listener) + return ipcRenderer + .invoke('workspaceCleanup:scan', { ...args, scanId }) + .finally(() => ipcRenderer.removeListener('workspaceCleanup:scanProgress', listener)) + }, dismiss: (args) => ipcRenderer.invoke('workspaceCleanup:dismiss', args), clearDismissals: () => ipcRenderer.invoke('workspaceCleanup:clearDismissals'), hasKillableLocalProcesses: (args) => diff --git a/src/renderer/src/components/workspace-cleanup/WorkspaceCleanupDialog.tsx b/src/renderer/src/components/workspace-cleanup/WorkspaceCleanupDialog.tsx index e8ddb9dfd4d..b8cb35f1837 100644 --- a/src/renderer/src/components/workspace-cleanup/WorkspaceCleanupDialog.tsx +++ b/src/renderer/src/components/workspace-cleanup/WorkspaceCleanupDialog.tsx @@ -27,7 +27,6 @@ import { } from '@/components/ui/dialog' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' -import { Progress } from '@/components/ui/progress' import RepoMultiCombobox from '@/components/ui/repo-multi-combobox' import { ScrollArea } from '@/components/ui/scroll-area' import { @@ -48,6 +47,7 @@ import { type WorkspaceCleanupBlocker, type WorkspaceCleanupCandidate, type WorkspaceCleanupScanError, + type WorkspaceCleanupScanProgress, type WorkspaceCleanupTier } from '../../../../shared/workspace-cleanup' import { @@ -192,6 +192,7 @@ export default function WorkspaceCleanupDialog(): React.JSX.Element { const openModal = useAppStore((s) => s.openModal) const closeModal = useAppStore((s) => s.closeModal) const scan = useAppStore((s) => s.workspaceCleanupScan) + const scanProgress = useAppStore((s) => s.workspaceCleanupProgress) const loading = useAppStore((s) => s.workspaceCleanupLoading) const error = useAppStore((s) => s.workspaceCleanupError) const repos = useAppStore((s) => s.repos) @@ -407,7 +408,7 @@ export default function WorkspaceCleanupDialog(): React.JSX.Element { const protectedCount = groups.protected.length const inactiveCount = filteredCandidates.length const hasAnyCandidates = candidates.length > 0 - const initialLoading = loading && !scan + const initialLoading = loading && !hasAnyCandidates const activeBaseRows = resolvedActiveView === 'hidden' ? hiddenCandidates : groups[resolvedActiveView] const activeRows = useMemo( @@ -670,7 +671,9 @@ export default function WorkspaceCleanupDialog(): React.JSX.Element { 'Scanning worktrees and git state, then combining open tab, terminal, live agent, and remote availability signals before suggesting deletions.' )} - +
+ {formatWorkspaceCleanupProgress(scanProgress)} +
) : hasAnyCandidates ? ( @@ -721,7 +724,7 @@ export default function WorkspaceCleanupDialog(): React.JSX.Element { {translate( 'auto.components.workspace.cleanup.WorkspaceCleanupDialog.946e8b0eb1', - 'Refreshing' + 'Scanning' )} ) : null} @@ -754,18 +757,20 @@ export default function WorkspaceCleanupDialog(): React.JSX.Element { ) : null} - {loading && scan ? ( + {loading && scan && hasAnyCandidates ? (
{translate( 'auto.components.workspace.cleanup.WorkspaceCleanupDialog.c4dc32d56c', - 'Refreshing inactive workspaces. You can leave this modal; the last result stays visible.' + 'Scanning inactive workspaces. New rows appear here as they finish.' )} + + {formatWorkspaceCleanupProgress(scanProgress)} +
-
) : null} @@ -1002,22 +1007,6 @@ function StatusPill({ ) } -function ScanProgress({ - value, - className -}: { - value: number - className?: string -}): React.JSX.Element { - return ( - - ) -} - function WorkspaceCleanupFilterToolbar({ filters, sortKey, @@ -1763,6 +1752,25 @@ function formatWorkspaceCleanupReadyToastDescription( return `${inactiveCount} inactive ${inactiveNoun} found, with ${suggestedCount} cleanup ${suggestedNoun}.` } +function formatWorkspaceCleanupProgress(progress: WorkspaceCleanupScanProgress | null): string { + if (!progress || progress.totalWorktreeCount === 0) { + return translate( + 'auto.components.workspace.cleanup.WorkspaceCleanupDialog.4cc5b73efe', + 'Finding inactive workspaces...' + ) + } + const noun = progress.totalWorktreeCount === 1 ? 'workspace' : 'workspaces' + return translate( + 'auto.components.workspace.cleanup.WorkspaceCleanupDialog.5bf2e88480', + '{{value0}}/{{value1}} {{value2}} scanned', + { + value0: progress.scannedWorktreeCount, + value1: progress.totalWorktreeCount, + value2: noun + } + ) +} + function SkeletonRows(): React.JSX.Element { return (
diff --git a/src/renderer/src/store/slices/workspace-cleanup.test.ts b/src/renderer/src/store/slices/workspace-cleanup.test.ts index a6c4ddf3c57..ebbd303d99b 100644 --- a/src/renderer/src/store/slices/workspace-cleanup.test.ts +++ b/src/renderer/src/store/slices/workspace-cleanup.test.ts @@ -5,6 +5,7 @@ import { describe, expect, it, vi } from 'vitest' import type { AppState } from '../types' import type { WorkspaceCleanupCandidate, + WorkspaceCleanupScanProgress, WorkspaceCleanupScanResult } from '../../../../shared/workspace-cleanup' import { createWorkspaceCleanupSlice, enrichWorkspaceCleanupCandidates } from './workspace-cleanup' @@ -135,6 +136,55 @@ describe('workspace cleanup viewed rows', () => { expect(store.getState().workspaceCleanupLoading).toBe(false) }) + it('shows scanned cleanup candidates before the final broad scan resolves', async () => { + const pending = deferred() + let onProgress: ((progress: WorkspaceCleanupScanProgress) => void) | undefined + const partialCandidate = makeCandidate({ worktreeId: 'repo1::/tmp/partial' }) + const finalCandidate = makeCandidate({ worktreeId: 'repo1::/tmp/final' }) + const scan = vi.fn((_args, progressCallback) => { + onProgress = progressCallback + return pending.promise + }) + installWorkspaceCleanupApi(scan) + const store = createCleanupTestStore() + + const scanPromise = store.getState().scanWorkspaceCleanup() + onProgress?.({ + scanId: 'scan-1', + scannedAt: NOW, + scannedWorktreeCount: 1, + totalWorktreeCount: 2, + candidates: [partialCandidate], + errors: [] + }) + + expect(store.getState().workspaceCleanupLoading).toBe(true) + await vi.waitFor(() => { + expect(store.getState().workspaceCleanupProgress).toMatchObject({ + scannedWorktreeCount: 1, + totalWorktreeCount: 2 + }) + }) + expect(store.getState().workspaceCleanupScan?.candidates).toEqual([partialCandidate]) + + pending.resolve({ + scannedAt: NOW, + candidates: [partialCandidate, finalCandidate], + errors: [] + }) + + await expect(scanPromise).resolves.toEqual({ + scannedAt: NOW, + candidates: [partialCandidate, finalCandidate], + errors: [] + }) + expect(store.getState().workspaceCleanupLoading).toBe(false) + expect(store.getState().workspaceCleanupProgress).toMatchObject({ + scannedWorktreeCount: 2, + totalWorktreeCount: 2 + }) + }) + it('does not join broad cleanup scans with different explicit args', async () => { const firstPending = deferred() const secondPending = deferred() @@ -436,9 +486,12 @@ describe('workspace cleanup viewed rows', () => { await store.getState().scanWorkspaceCleanup() - expect(scan).toHaveBeenCalledWith({ - skipGitWorktreeIds: expect.arrayContaining([WORKTREE_ID, 'repo1::/tmp/terminal-workspace']) - }) + expect(scan).toHaveBeenCalledWith( + { + skipGitWorktreeIds: expect.arrayContaining([WORKTREE_ID, 'repo1::/tmp/terminal-workspace']) + }, + expect.any(Function) + ) }) it('does not defer git checks for focused remove preflights', async () => { diff --git a/src/renderer/src/store/slices/workspace-cleanup.ts b/src/renderer/src/store/slices/workspace-cleanup.ts index 23e529f60f1..86c6cdba37e 100644 --- a/src/renderer/src/store/slices/workspace-cleanup.ts +++ b/src/renderer/src/store/slices/workspace-cleanup.ts @@ -18,6 +18,7 @@ import { type WorkspaceCleanupCandidate, type WorkspaceCleanupDismissal, type WorkspaceCleanupScanArgs, + type WorkspaceCleanupScanProgress, type WorkspaceCleanupScanResult } from '../../../../shared/workspace-cleanup' import { detectAgentStatusFromTitle, isExplicitAgentStatusFresh } from '@/lib/agent-status' @@ -42,6 +43,7 @@ type WorkspaceCleanupViewedCandidate = { export type WorkspaceCleanupSlice = { workspaceCleanupScan: WorkspaceCleanupScanResult | null + workspaceCleanupProgress: WorkspaceCleanupScanProgress | null workspaceCleanupLoading: boolean workspaceCleanupError: string | null workspaceCleanupDismissals: Record @@ -105,6 +107,7 @@ export const createWorkspaceCleanupSlice: StateCreator ({ workspaceCleanupScan: null, + workspaceCleanupProgress: null, workspaceCleanupLoading: false, workspaceCleanupError: null, workspaceCleanupDismissals: {}, @@ -135,14 +138,31 @@ export const createWorkspaceCleanupSlice: StateCreator { - const scan = await window.api.workspaceCleanup.scan(scanArgs) + const scan = await window.api.workspaceCleanup.scan(scanArgs, (progress) => { + void applyWorkspaceCleanupProgress(progress, scanToken, get, set) + }) const enriched = await enrichWorkspaceCleanupCandidates(scan.candidates, get()) const result = { ...scan, candidates: enriched } if (scanToken === latestWorkspaceCleanupScanToken) { - set({ workspaceCleanupScan: result, workspaceCleanupLoading: false }) + set({ + workspaceCleanupScan: result, + workspaceCleanupProgress: { + scanId: get().workspaceCleanupProgress?.scanId ?? '', + scannedAt: result.scannedAt, + scannedWorktreeCount: result.candidates.length, + totalWorktreeCount: result.candidates.length, + candidates: result.candidates, + errors: result.errors + }, + workspaceCleanupLoading: false + }) } return result })() @@ -286,6 +306,29 @@ function getWorkspaceCleanupScanKey(args: WorkspaceCleanupScanArgs): string { }) } +async function applyWorkspaceCleanupProgress( + progress: WorkspaceCleanupScanProgress, + scanToken: number, + getState: () => AppState, + setState: ( + partial: Partial | ((state: AppState) => Partial), + replace?: false + ) => void +): Promise { + const enriched = await enrichWorkspaceCleanupCandidates(progress.candidates, getState()) + if (scanToken !== latestWorkspaceCleanupScanToken) { + return + } + setState({ + workspaceCleanupScan: { + scannedAt: progress.scannedAt, + candidates: enriched, + errors: progress.errors + }, + workspaceCleanupProgress: { ...progress, candidates: enriched } + }) +} + async function mapWithConcurrency( items: readonly T[], limit: number, diff --git a/src/shared/workspace-cleanup.ts b/src/shared/workspace-cleanup.ts index f751b7738a5..0837f57cda8 100644 --- a/src/shared/workspace-cleanup.ts +++ b/src/shared/workspace-cleanup.ts @@ -74,6 +74,7 @@ export type WorkspaceCleanupCandidate = { export type WorkspaceCleanupScanArgs = { worktreeId?: string skipGitWorktreeIds?: string[] + scanId?: string } export type WorkspaceCleanupLocalProcessArgs = { @@ -94,6 +95,12 @@ export type WorkspaceCleanupScanResult = { errors: WorkspaceCleanupScanError[] } +export type WorkspaceCleanupScanProgress = WorkspaceCleanupScanResult & { + scanId: string + scannedWorktreeCount: number + totalWorktreeCount: number +} + export type WorkspaceCleanupLocalProcessResult = { hasKillableProcesses: boolean | null }