Derive workspace space row state during render

This commit is contained in:
Neil
2026-05-29 07:21:12 -07:00
parent 8f37c7d4ad
commit 083d595347
3 changed files with 100 additions and 36 deletions
@@ -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) ??
@@ -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' })
@@ -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<string>
): Set<string> {
const validIds = new Set(rows.map((row) => row.worktreeId))
let changed = false
const next = new Set<string>()
for (const id of selectedIds) {
if (validIds.has(id)) {
next.add(id)
} else {
changed = true
}
}
return changed ? next : (selectedIds as Set<string>)
}
export function getVisibleDeletableWorkspaceIds(
rows: readonly WorkspaceSpaceWorktree[],
isWorktreeDeleting: (worktreeId: string) => boolean = () => false