From 083d595347fcaf4f8bd19078f808f6078a0dcef2 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Fri, 29 May 2026 07:21:12 -0700 Subject: [PATCH] Derive workspace space row state during render --- .../status-bar/WorkspaceSpaceManagerPanel.tsx | 71 +++++++++---------- .../workspace-space-presentation.test.ts | 29 ++++++++ .../workspace-space-presentation.ts | 36 ++++++++++ 3 files changed, 100 insertions(+), 36 deletions(-) diff --git a/src/renderer/src/components/status-bar/WorkspaceSpaceManagerPanel.tsx b/src/renderer/src/components/status-bar/WorkspaceSpaceManagerPanel.tsx index adf5a28f2e8..306312caed2 100644 --- a/src/renderer/src/components/status-bar/WorkspaceSpaceManagerPanel.tsx +++ b/src/renderer/src/components/status-bar/WorkspaceSpaceManagerPanel.tsx @@ -66,10 +66,13 @@ import { buildTreemapLayout, type TreemapRect } from './workspace-space-layout' import { filterWorkspaceSpaceRows, countWorkspaceSpaceActiveAgents, + getWorkspaceSpaceInspectedWorktreeId, getSelectedDeletableWorkspaceIds, getVisibleDeletableWorkspaceIds, getWorkspaceSpaceGitStatusRefreshCandidates, + getWorkspaceSpaceZoomWorktreeId, isWorkspaceSpaceRowReadyToDelete, + pruneWorkspaceSpaceSelectedIds, sortWorkspaceSpaceRows, type WorkspaceSpaceSortDirection, type WorkspaceSpaceSortKey @@ -1105,6 +1108,28 @@ export function WorkspaceSpaceManagerPanel(): React.JSX.Element { }, [cancelWorkspaceSpaceScan]) const sourceRows = useMemo(() => analysis?.worktrees ?? [], [analysis?.worktrees]) + const currentInspectedWorktreeId = getWorkspaceSpaceInspectedWorktreeId( + sourceRows, + inspectedWorktreeId + ) + const currentSelectedIds = pruneWorkspaceSpaceSelectedIds(sourceRows, selectedIds) + const currentTreemapZoomWorktreeId = getWorkspaceSpaceZoomWorktreeId( + sourceRows, + treemapZoomWorktreeId + ) + + // Why: this panel can keep rendering while a scan/delete removes rows. + // Repair local row ids before delete actions, treemap, and details render. + if (inspectedWorktreeId !== currentInspectedWorktreeId) { + setInspectedWorktreeId(currentInspectedWorktreeId) + } + if (currentSelectedIds !== selectedIds) { + setSelectedIds(currentSelectedIds) + } + if (treemapZoomWorktreeId !== currentTreemapZoomWorktreeId) { + setTreemapZoomWorktreeId(currentTreemapZoomWorktreeId) + } + const decisionDetailsByWorktreeId = useMemo(() => { // Why: active-agent freshness is time-based. The epoch bumps when fresh // hook entries cross the stale boundary so delete readiness recomputes. @@ -1270,16 +1295,18 @@ export function WorkspaceSpaceManagerPanel(): React.JSX.Element { }, [refreshWorkspaceGitStatus, sourceRows]) const inspectedWorktree = - rows.find((row) => row.worktreeId === inspectedWorktreeId) ?? + rows.find((row) => row.worktreeId === currentInspectedWorktreeId) ?? rows.find((row) => row.status === 'ok') ?? null const zoomedWorktree = - sourceRows.find((row) => row.worktreeId === treemapZoomWorktreeId && row.status === 'ok') ?? - null + sourceRows.find( + (row) => row.worktreeId === currentTreemapZoomWorktreeId && row.status === 'ok' + ) ?? null const maxSize = Math.max(...rows.map((row) => row.sizeBytes), 0) const selectedDeletableIds = useMemo( - () => getSelectedDeletableWorkspaceIds(rows, selectedIds, isWorktreeUnavailableForDelete), - [isWorktreeUnavailableForDelete, rows, selectedIds] + () => + getSelectedDeletableWorkspaceIds(rows, currentSelectedIds, isWorktreeUnavailableForDelete), + [currentSelectedIds, isWorktreeUnavailableForDelete, rows] ) const selectedDeletableIdSet = useMemo( () => new Set(selectedDeletableIds), @@ -1290,8 +1317,8 @@ export function WorkspaceSpaceManagerPanel(): React.JSX.Element { [isWorktreeUnavailableForDelete, rows] ) const allVisibleSelected = - visibleDeletableIds.length > 0 && visibleDeletableIds.every((id) => selectedIds.has(id)) - const someVisibleSelected = visibleDeletableIds.some((id) => selectedIds.has(id)) + visibleDeletableIds.length > 0 && visibleDeletableIds.every((id) => currentSelectedIds.has(id)) + const someVisibleSelected = visibleDeletableIds.some((id) => currentSelectedIds.has(id)) const visibleSelectionState = allVisibleSelected ? true : someVisibleSelected ? 'mixed' : false const isInitialScan = isScanning && !analysis const hasRows = sourceRows.length > 0 @@ -1305,34 +1332,6 @@ export function WorkspaceSpaceManagerPanel(): React.JSX.Element { [rows, selectedDeletableIdSet] ) - useEffect(() => { - if (!analysis) { - setInspectedWorktreeId(null) - return - } - setInspectedWorktreeId((current) => - current && analysis.worktrees.some((worktree) => worktree.worktreeId === current) - ? current - : (analysis.worktrees.find((worktree) => worktree.status === 'ok')?.worktreeId ?? null) - ) - }, [analysis]) - - useEffect(() => { - setSelectedIds((current) => { - const valid = new Set(sourceRows.map((row) => row.worktreeId)) - const next = new Set([...current].filter((id) => valid.has(id))) - return next.size === current.size ? current : next - }) - }, [sourceRows]) - - useEffect(() => { - setTreemapZoomWorktreeId((current) => - current && sourceRows.some((row) => row.worktreeId === current && row.status === 'ok') - ? current - : null - ) - }, [sourceRows]) - const toggleSort = (key: WorkspaceSpaceSortKey): void => { if (sortKey === key) { setSortDirection((current) => (current === 'asc' ? 'desc' : 'asc')) @@ -1690,7 +1689,7 @@ export function WorkspaceSpaceManagerPanel(): React.JSX.Element { key={worktree.worktreeId} worktree={worktree} maxSize={maxSize} - selected={selectedIds.has(worktree.worktreeId)} + selected={currentSelectedIds.has(worktree.worktreeId)} inspected={inspectedWorktree?.worktreeId === worktree.worktreeId} decisionDetails={ decisionDetailsByWorktreeId.get(worktree.worktreeId) ?? diff --git a/src/renderer/src/components/status-bar/workspace-space-presentation.test.ts b/src/renderer/src/components/status-bar/workspace-space-presentation.test.ts index 8d52d451f00..98038589eca 100644 --- a/src/renderer/src/components/status-bar/workspace-space-presentation.test.ts +++ b/src/renderer/src/components/status-bar/workspace-space-presentation.test.ts @@ -5,8 +5,11 @@ import { filterWorkspaceSpaceRows, getSelectedDeletableWorkspaceIds, getVisibleDeletableWorkspaceIds, + getWorkspaceSpaceInspectedWorktreeId, getWorkspaceSpaceGitStatusRefreshCandidates, + getWorkspaceSpaceZoomWorktreeId, isWorkspaceSpaceRowReadyToDelete, + pruneWorkspaceSpaceSelectedIds, sortWorkspaceSpaceRows } from './workspace-space-presentation' import type { AgentStatusEntry } from '../../../../shared/agent-status-types' @@ -124,6 +127,32 @@ describe('workspace space presentation helpers', () => { ).toEqual(['idle']) }) + it('keeps the inspected workspace when it is still present', () => { + const rows = [row({ worktreeId: 'failed', status: 'error' }), row({ worktreeId: 'ok' })] + + expect(getWorkspaceSpaceInspectedWorktreeId(rows, 'failed')).toBe('failed') + }) + + it('falls back inspected workspace to the first ok row', () => { + const rows = [row({ worktreeId: 'missing-source', status: 'error' }), row({ worktreeId: 'ok' })] + + expect(getWorkspaceSpaceInspectedWorktreeId(rows, 'gone')).toBe('ok') + expect(getWorkspaceSpaceInspectedWorktreeId([], 'gone')).toBeNull() + }) + + it('prunes selected workspace ids to rows still in the scan', () => { + const rows = [row({ worktreeId: 'keep' }), row({ worktreeId: 'also-keep' })] + + expect([...pruneWorkspaceSpaceSelectedIds(rows, new Set(['keep', 'gone']))]).toEqual(['keep']) + }) + + it('keeps treemap zoom only for an ok workspace row', () => { + const rows = [row({ worktreeId: 'ok' }), row({ worktreeId: 'failed', status: 'error' })] + + expect(getWorkspaceSpaceZoomWorktreeId(rows, 'ok')).toBe('ok') + expect(getWorkspaceSpaceZoomWorktreeId(rows, 'failed')).toBeNull() + }) + it('treats open browser tabs as active workspace usage for deletion readiness', () => { const workspace = row({ worktreeId: 'with-browser', canDelete: true, status: 'ok' }) 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 5a8edcd35f9..c7c8ee0c5e8 100644 --- a/src/renderer/src/components/status-bar/workspace-space-presentation.ts +++ b/src/renderer/src/components/status-bar/workspace-space-presentation.ts @@ -238,6 +238,42 @@ export function getSelectedDeletableWorkspaceIds( .map((row) => row.worktreeId) } +export function getWorkspaceSpaceInspectedWorktreeId( + rows: readonly WorkspaceSpaceWorktree[], + currentId: string | null +): string | null { + if (currentId && rows.some((row) => row.worktreeId === currentId)) { + return currentId + } + return rows.find((row) => row.status === 'ok')?.worktreeId ?? null +} + +export function getWorkspaceSpaceZoomWorktreeId( + rows: readonly WorkspaceSpaceWorktree[], + currentId: string | null +): string | null { + return currentId && rows.some((row) => row.worktreeId === currentId && row.status === 'ok') + ? currentId + : null +} + +export function pruneWorkspaceSpaceSelectedIds( + rows: readonly WorkspaceSpaceWorktree[], + selectedIds: ReadonlySet +): Set { + const validIds = new Set(rows.map((row) => row.worktreeId)) + let changed = false + const next = new Set() + for (const id of selectedIds) { + if (validIds.has(id)) { + next.add(id) + } else { + changed = true + } + } + return changed ? next : (selectedIds as Set) +} + export function getVisibleDeletableWorkspaceIds( rows: readonly WorkspaceSpaceWorktree[], isWorktreeDeleting: (worktreeId: string) => boolean = () => false