feat(workspaces): rework cleanup dialog

This commit is contained in:
Brennan Benson
2026-08-09 23:58:32 -07:00
parent 3ac51e9333
commit f2ada12bfc
54 changed files with 6313 additions and 1702 deletions
-1
View File
@@ -264,7 +264,6 @@ inline src/renderer/src/components/terminal-pane/terminal-link-handlers.test.ts
inline src/renderer/src/components/terminal-pane/use-terminal-pane-context-menu.ts
inline src/renderer/src/components/terminal-pane/use-terminal-pane-global-effects.test.ts
inline src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts
inline src/renderer/src/components/workspace-cleanup/WorkspaceCleanupDialog.tsx
inline src/renderer/src/hooks/useAutomationDispatchEvents.ts
inline src/renderer/src/hooks/useComposerState.ts
inline src/renderer/src/hooks/useEditorExternalWatch.ts
@@ -0,0 +1,372 @@
// @vitest-environment happy-dom
import type { ReactNode, Ref } from 'react'
import { act } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type {
WorkspaceCleanupScanProgress,
WorkspaceCleanupScanResult
} from '../../../../shared/workspace-cleanup'
import { createTestStore, seedStore } from '../../store/slices/store-test-helpers'
import { resetWorkspaceCleanupBrowsePersistTimer } from '../../store/slices/workspace-cleanup-browse'
import { NOW, makeCandidate } from '../../store/slices/workspace-cleanup-slice-test-harness'
import WorkspaceCleanupDialog from './WorkspaceCleanupDialog'
const holders = vi.hoisted(() => ({
store: null as unknown as { (selector: (s: unknown) => unknown): unknown } & {
getState: () => Record<string, unknown>
setState: (partial: unknown) => void
subscribe: (listener: (state: unknown, previous: unknown) => void) => () => void
},
infoToasts: [] as string[],
rig: null as null | {
resolvers: ((result: { scannedAt: number; candidates: never[]; errors: never[] }) => void)[]
}
}))
vi.mock('@/store', () => {
const useAppStore = (selector: (s: unknown) => unknown): unknown => holders.store(selector)
useAppStore.getState = () => holders.store.getState()
useAppStore.setState = (partial: unknown) => holders.store.setState(partial)
useAppStore.subscribe = (listener: (state: unknown, previous: unknown) => void) =>
holders.store.subscribe(listener)
return { useAppStore }
})
vi.mock('sonner', () => ({
toast: Object.assign(vi.fn(), {
success: vi.fn(),
error: vi.fn(),
warning: vi.fn(),
info: (message: unknown) => holders.infoToasts.push(String(message))
})
}))
vi.mock('@/lib/worktree-activation', () => ({ activateAndRevealWorktree: vi.fn() }))
vi.mock('@/components/ui/dialog', () => ({
Dialog: ({ open, children }: { open: boolean; children: ReactNode }) =>
open ? <div>{children}</div> : null,
DialogContent: ({ children }: { children: ReactNode }) => <div>{children}</div>,
DialogHeader: ({ children }: { children: ReactNode }) => <div>{children}</div>,
DialogTitle: ({ children }: { children: ReactNode }) => <div>{children}</div>,
DialogDescription: ({ children }: { children: ReactNode }) => <div>{children}</div>,
DialogFooter: ({ children }: { children: ReactNode }) => <div>{children}</div>
}))
vi.mock('@/components/ui/scroll-area', () => ({
ScrollArea: ({ children, viewportRef }: { children: ReactNode; viewportRef?: Ref<unknown> }) => (
<div ref={viewportRef as Ref<HTMLDivElement>}>{children}</div>
),
ScrollBar: () => null
}))
vi.mock('@/components/ui/tooltip', () => ({
Tooltip: ({ children }: { children: ReactNode }) => <>{children}</>,
TooltipTrigger: ({ children }: { children: ReactNode }) => <>{children}</>,
TooltipContent: () => null
}))
vi.mock('@/components/ui/popover', () => ({
Popover: ({ children }: { children: ReactNode }) => <>{children}</>,
PopoverTrigger: ({ children }: { children: ReactNode }) => <>{children}</>,
PopoverContent: () => null
}))
vi.mock('@/components/ui/dropdown-menu', () => ({
DropdownMenu: ({ children }: { children: ReactNode }) => <>{children}</>,
DropdownMenuTrigger: ({ children }: { children: ReactNode }) => <>{children}</>,
DropdownMenuContent: () => null,
DropdownMenuItem: ({ children }: { children: ReactNode }) => <div>{children}</div>,
DropdownMenuLabel: ({ children }: { children: ReactNode }) => <div>{children}</div>
}))
const CACHED_AT = NOW - 2 * 60 * 60 * 1000
type ScanRig = {
scan: ReturnType<typeof vi.fn>
progressCallbacks: ((progress: WorkspaceCleanupScanProgress) => void)[]
resolvers: ((result: WorkspaceCleanupScanResult) => void)[]
}
function installApi(cachedScan: WorkspaceCleanupScanResult | null): ScanRig {
const rig: ScanRig = { scan: vi.fn(), progressCallbacks: [], resolvers: [] }
rig.scan.mockImplementation(
(
args?: { worktreeId?: string },
onProgress?: (progress: WorkspaceCleanupScanProgress) => void
) => {
if (args?.worktreeId) {
return Promise.resolve({
scannedAt: NOW,
candidates: [makeCandidate({ worktreeId: args.worktreeId })],
errors: []
})
}
if (onProgress) {
rig.progressCallbacks.push(onProgress)
}
return new Promise<WorkspaceCleanupScanResult>((resolve) => {
rig.resolvers.push(resolve)
})
}
)
;(window as unknown as { api: unknown }).api = {
workspaceCleanup: {
scan: rig.scan,
getCachedScan: vi.fn().mockResolvedValue(cachedScan),
dismiss: vi.fn().mockResolvedValue(undefined),
clearDismissals: vi.fn().mockResolvedValue(undefined),
hasKillableLocalProcesses: vi.fn().mockResolvedValue({ hasKillableProcesses: false })
},
workspaceSpace: {
getCachedAnalysis: vi.fn().mockResolvedValue(null),
analyze: vi.fn(),
cancel: vi.fn().mockResolvedValue(false),
onProgress: vi.fn().mockReturnValue(() => undefined)
},
ui: { set: vi.fn().mockResolvedValue(undefined) }
}
holders.rig = rig as unknown as typeof holders.rig
return rig
}
let root: Root | null = null
let container: HTMLDivElement | null = null
async function flush(): Promise<void> {
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 0))
await new Promise((resolve) => setTimeout(resolve, 0))
})
}
async function renderDialog(): Promise<void> {
await act(async () => {
root?.render(<WorkspaceCleanupDialog />)
})
}
async function openDialog(): Promise<void> {
await act(async () => {
holders.store.setState({ activeModal: 'workspace-cleanup' })
})
await flush()
}
function rowNames(): string[] {
return [...(container?.querySelectorAll('span.truncate.text-sm.font-medium') ?? [])].map(
(node) => node.textContent ?? ''
)
}
function rowCheckbox(name: string): HTMLElement | null {
return container?.querySelector<HTMLElement>(`[aria-label="Select ${name}"]`) ?? null
}
function cachedFleet(): WorkspaceCleanupScanResult {
return {
scannedAt: CACHED_AT,
candidates: [
makeCandidate({ worktreeId: 'repo1::/tmp/alpha', displayName: 'alpha' }),
makeCandidate({ worktreeId: 'repo1::/tmp/beta', displayName: 'beta' })
],
errors: []
}
}
function progressTick(
rig: ScanRig,
progress: Partial<WorkspaceCleanupScanProgress> & Pick<WorkspaceCleanupScanProgress, 'candidates'>
): void {
rig.progressCallbacks.at(-1)?.({
scanId: 'scan-1',
scannedAt: NOW,
scannedWorktreeCount: progress.candidates.length,
totalWorktreeCount: 3,
errors: [],
candidateMode: 'append',
...progress
})
}
describe('WorkspaceCleanupDialog stale-while-revalidate', () => {
beforeEach(() => {
globalThis.IS_REACT_ACT_ENVIRONMENT = true
// Why Date-only fake: relative "Updated Xh ago" labels need a pinned clock,
// but flush() depends on real setTimeout.
vi.useFakeTimers({ toFake: ['Date'], now: NOW })
const store = createTestStore()
seedStore(store, {})
holders.store = store as unknown as typeof holders.store
holders.infoToasts = []
container = document.createElement('div')
document.body.appendChild(container)
root = createRoot(container)
})
afterEach(async () => {
if (root) {
act(() => root?.unmount())
}
// Why: broad-scan dedupe lives in slice module state; an unresolved scan
// left by one case would silently join into the next case's scan call.
for (const resolve of holders.rig?.resolvers ?? []) {
resolve({ scannedAt: NOW, candidates: [], errors: [] })
}
holders.rig = null
await new Promise((resolve) => setTimeout(resolve, 0))
container?.remove()
root = null
container = null
resetWorkspaceCleanupBrowsePersistTimer()
vi.useRealTimers()
})
it('renders the cached snapshot immediately and kicks one background rescan', async () => {
const rig = installApi(cachedFleet())
await renderDialog()
await openDialog()
// Full list from cache while the rescan is still streaming (unresolved).
expect(rowNames()).toEqual(expect.arrayContaining(['alpha', 'beta']))
expect(rig.scan).toHaveBeenCalledTimes(1)
expect(container?.textContent).toContain('Updated 2h ago')
expect(container?.textContent).toContain('Refreshing')
})
it('reconciles streamed rows into the cached list without clearing it', async () => {
const rig = installApi(cachedFleet())
await renderDialog()
await openDialog()
progressTick(rig, {
candidates: [
makeCandidate({
worktreeId: 'repo1::/tmp/alpha',
displayName: 'alpha',
fingerprint: 'fingerprint-alpha-2'
}),
makeCandidate({ worktreeId: 'repo1::/tmp/gamma', displayName: 'gamma' })
],
scannedWorktreeCount: 2
})
await flush()
// beta was not re-reported yet; it must still be on screen mid-refresh.
expect(rowNames()).toEqual(expect.arrayContaining(['alpha', 'beta', 'gamma']))
})
it('keeps search and selection interactive while rows stream in', async () => {
const rig = installApi(cachedFleet())
await renderDialog()
await openDialog()
const search = container?.querySelector<HTMLInputElement>('[aria-label="Search workspaces"]')
expect(search?.disabled).toBe(false)
const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set
await act(async () => {
setter?.call(search, 'beta')
search?.dispatchEvent(new Event('input', { bubbles: true }))
})
progressTick(rig, {
candidates: [makeCandidate({ worktreeId: 'repo1::/tmp/gamma', displayName: 'gamma' })],
scannedWorktreeCount: 1
})
await flush()
// Filter applied mid-stream: only beta visible even as new rows arrive.
expect(rowNames()).toEqual(['beta'])
await act(async () => {
setter?.call(search, '')
search?.dispatchEvent(new Event('input', { bubbles: true }))
})
await flush()
expect(rowNames()).toEqual(expect.arrayContaining(['alpha', 'beta', 'gamma']))
// Rows stay selectable during the stream.
const checkbox = rowCheckbox('alpha')
expect(checkbox).not.toBeNull()
await act(async () => {
checkbox?.click()
})
expect(rowCheckbox('alpha')?.getAttribute('aria-checked')).toBe('true')
})
it('adopts the in-flight scan on reopen instead of starting a duplicate', async () => {
const rig = installApi(cachedFleet())
await renderDialog()
await openDialog()
expect(rig.scan).toHaveBeenCalledTimes(1)
progressTick(rig, {
candidates: [makeCandidate({ worktreeId: 'repo1::/tmp/gamma', displayName: 'gamma' })],
scannedWorktreeCount: 1
})
await flush()
// Close at 40% and reopen: the same scan keeps streaming into the store.
await act(async () => {
holders.store.setState({ activeModal: null })
})
await openDialog()
expect(rig.scan).toHaveBeenCalledTimes(1)
progressTick(rig, {
candidates: [makeCandidate({ worktreeId: 'repo1::/tmp/delta', displayName: 'delta' })],
scannedWorktreeCount: 2
})
await flush()
await act(async () => {
rig.resolvers.at(-1)?.({
scannedAt: NOW,
candidates: [
...cachedFleet().candidates,
makeCandidate({ worktreeId: 'repo1::/tmp/gamma', displayName: 'gamma' }),
makeCandidate({ worktreeId: 'repo1::/tmp/delta', displayName: 'delta' })
],
errors: []
})
})
await flush()
expect(rig.scan).toHaveBeenCalledTimes(1)
expect(rowNames()).toEqual(expect.arrayContaining(['alpha', 'beta', 'gamma', 'delta']))
})
it('keeps selections across a refresh and surfaces vanished selections', async () => {
const rig = installApi(cachedFleet())
await renderDialog()
await openDialog()
await act(async () => {
rig.resolvers.at(-1)?.(cachedFleet())
})
await flush()
// Both rows are default-selected suggestions after the first settle.
expect(rowCheckbox('alpha')?.getAttribute('aria-checked')).toBe('true')
expect(rowCheckbox('beta')?.getAttribute('aria-checked')).toBe('true')
await act(async () => {
container?.querySelector<HTMLElement>('[aria-label="Refresh"]')?.click()
})
await flush()
expect(rig.scan).toHaveBeenCalledTimes(2)
// Selection survives while the refresh streams.
expect(rowCheckbox('alpha')?.getAttribute('aria-checked')).toBe('true')
await act(async () => {
rig.resolvers.at(-1)?.({
scannedAt: NOW + 1,
candidates: [makeCandidate({ worktreeId: 'repo1::/tmp/alpha', displayName: 'alpha' })],
errors: []
})
})
await flush()
expect(rowCheckbox('alpha')?.getAttribute('aria-checked')).toBe('true')
expect(rowNames()).not.toContain('beta')
expect(holders.infoToasts).toContain('1 selected workspace no longer exists.')
})
})
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,127 @@
import { useCallback, useMemo } from 'react'
import { useAppStore } from '@/store'
import type { WorkspaceCleanupBrowseState } from '../../../../shared/workspace-cleanup-browse-state'
import {
createDefaultWorkspaceCleanupFilterState,
DEFAULT_WORKSPACE_CLEANUP_SORT,
type WorkspaceCleanupFilterState,
type WorkspaceCleanupSortField,
type WorkspaceCleanupSortState
} from '../../../../shared/workspace-cleanup-filter-model'
import {
applyWorkspaceCleanupPreset,
listWorkspaceCleanupPresets,
matchWorkspaceCleanupPresetId
} from '../../../../shared/workspace-cleanup-preset-state'
import type { WorkspaceCleanupPreset } from '../../../../shared/workspace-cleanup-presets'
export type WorkspaceCleanupBrowseController = {
browse: WorkspaceCleanupBrowseState
filters: WorkspaceCleanupFilterState
sort: WorkspaceCleanupSortState
presets: WorkspaceCleanupPreset[]
/** Preset the current state still matches; null once the user edits away from one. */
matchedPresetId: string | null
setFilters: (next: WorkspaceCleanupFilterState) => void
patchFilters: <K extends keyof WorkspaceCleanupFilterState>(
key: K,
value: Partial<WorkspaceCleanupFilterState[K]> | WorkspaceCleanupFilterState[K]
) => void
setSort: (next: WorkspaceCleanupSortState) => void
toggleSortField: (field: WorkspaceCleanupSortField) => void
applyPreset: (preset: WorkspaceCleanupPreset) => void
clearFilters: () => void
}
/**
* Single seam over the persisted browse slice so the dialog never owns filter,
* sort, or preset state — reopening the dialog must not discard the user's view.
*/
export function useWorkspaceCleanupBrowseState(): WorkspaceCleanupBrowseController {
const browse = useAppStore((s) => s.workspaceCleanupBrowse)
const updateBrowse = useAppStore((s) => s.updateWorkspaceCleanupBrowseState)
const presets = useMemo(
() => listWorkspaceCleanupPresets(browse.customPresets),
[browse.customPresets]
)
const matchedPresetId = useMemo(
() =>
matchWorkspaceCleanupPresetId(
{ filters: browse.filters, sort: browse.sort },
browse.customPresets
),
[browse.customPresets, browse.filters, browse.sort]
)
const setFilters = useCallback(
(next: WorkspaceCleanupFilterState) => {
updateBrowse({ ...browse, filters: next })
},
[browse, updateBrowse]
)
const patchFilters = useCallback<WorkspaceCleanupBrowseController['patchFilters']>(
(key, value) => {
const current = browse.filters[key]
const next =
typeof current === 'object' && current !== null
? { ...current, ...(value as object) }
: value
// Cast: a computed key over a union widens the spread result past
// WorkspaceCleanupFilterState even though `key` is constrained to it.
const filters = { ...browse.filters, [key]: next } as WorkspaceCleanupFilterState
updateBrowse({ ...browse, filters })
},
[browse, updateBrowse]
)
const setSort = useCallback(
(next: WorkspaceCleanupSortState) => {
updateBrowse({ ...browse, sort: next })
},
[browse, updateBrowse]
)
// Why: re-picking the active column flips direction, which is the table
// convention users expect from a sortable header.
const toggleSortField = useCallback(
(field: WorkspaceCleanupSortField) => {
const direction =
browse.sort.field === field && browse.sort.direction === 'asc' ? 'desc' : 'asc'
updateBrowse({ ...browse, sort: { field, direction } })
},
[browse, updateBrowse]
)
const applyPreset = useCallback(
(preset: WorkspaceCleanupPreset) => {
const applied = applyWorkspaceCleanupPreset(preset)
updateBrowse({ ...browse, activePresetId: preset.id, ...applied })
},
[browse, updateBrowse]
)
const clearFilters = useCallback(() => {
updateBrowse({
...browse,
activePresetId: null,
filters: createDefaultWorkspaceCleanupFilterState(),
sort: { ...DEFAULT_WORKSPACE_CLEANUP_SORT }
})
}, [browse, updateBrowse])
return {
browse,
filters: browse.filters,
sort: browse.sort,
presets,
matchedPresetId,
setFilters,
patchFilters,
setSort,
toggleSortField,
applyPreset,
clearFilters
}
}
@@ -0,0 +1,147 @@
import { useMemo } from 'react'
import { useShallow } from 'zustand/react/shallow'
import { useAppStore } from '@/store'
import { getLiveAgentStatusByWorktreeId } from '@/lib/worktree-activity-state'
import { isGitRepoKind } from '../../../../shared/repo-kind'
import type { HostedReviewProvider } from '../../../../shared/hosted-review'
import type { WorkspaceCleanupCandidate } from '../../../../shared/workspace-cleanup'
import type {
WorkspaceCleanupFilterState,
WorkspaceCleanupSortState
} from '../../../../shared/workspace-cleanup-filter-model'
import {
buildWorkspaceCleanupFacetList,
buildWorkspaceCleanupSizeIndex,
buildWorkspaceCleanupWorktreeIndex,
type WorkspaceCleanupFacets
} from './workspace-cleanup-facets'
import {
countWorkspaceCleanupFacetMatches,
runWorkspaceCleanupQuery
} from './workspace-cleanup-query'
import {
getWorkspaceCleanupReviewInfo,
type WorkspaceCleanupReviewInfo
} from './workspace-cleanup-presentation'
import type {
WorkspaceCleanupFacetCounts,
WorkspaceCleanupFacetOptions
} from './workspace-cleanup-facet-panel-model'
export type WorkspaceCleanupFacetRows = {
rows: WorkspaceCleanupFacets[]
selectableWorktreeIds: string[]
matchedCount: number
totalCount: number
facetCounts: WorkspaceCleanupFacetCounts
options: WorkspaceCleanupFacetOptions
reviewInfoByWorktreeId: ReadonlyMap<string, WorkspaceCleanupReviewInfo>
sizeByWorktreeId: ReadonlyMap<string, number>
/** 0 means the workspace-space scan has never produced a usable size. */
measuredSizeCount: number
}
/**
* Joins the scan rows with everything the flat list filters and sorts on:
* renderer-only signals (visits, live agents, review cache, dismissals) plus
* sizes from the EXISTING workspace-space scan — no second scanner.
*/
export function useWorkspaceCleanupFacetRows({
candidates,
filters,
sort,
now
}: {
candidates: readonly WorkspaceCleanupCandidate[]
filters: WorkspaceCleanupFilterState
sort: WorkspaceCleanupSortState
now: number
}): WorkspaceCleanupFacetRows {
const sources = useAppStore(
useShallow((s) => ({
worktreesByRepo: s.worktreesByRepo,
hostedReviewCache: s.hostedReviewCache,
repos: s.repos,
settings: s.settings,
// Statuses are a top-level UI-slice field, not part of GlobalSettings.
workspaceStatuses: s.workspaceStatuses,
lastVisitedAtByWorktreeId: s.lastVisitedAtByWorktreeId,
agentStatusByPaneKey: s.agentStatusByPaneKey,
tabsByWorktree: s.tabsByWorktree,
dismissals: s.workspaceCleanupDismissals,
spaceWorktrees: s.workspaceSpaceAnalysis?.worktrees ?? null
}))
)
const reviewInfoByWorktreeId = useMemo(() => {
const infos = new Map<string, WorkspaceCleanupReviewInfo>()
for (const candidate of candidates) {
infos.set(candidate.worktreeId, getWorkspaceCleanupReviewInfo(candidate, sources))
}
return infos
}, [candidates, sources])
const sizeByWorktreeId = useMemo(
() => buildWorkspaceCleanupSizeIndex(sources.spaceWorktrees),
[sources.spaceWorktrees]
)
const facets = useMemo(
() =>
buildWorkspaceCleanupFacetList(candidates, {
worktreeById: buildWorkspaceCleanupWorktreeIndex(sources.worktreesByRepo),
sizeBytesByWorktreeId: sizeByWorktreeId,
lastVisitedAtByWorktreeId: sources.lastVisitedAtByWorktreeId,
liveAgentStatusByWorktreeId: getLiveAgentStatusByWorktreeId(
sources.agentStatusByPaneKey,
sources.tabsByWorktree,
now
),
reviewInfoByWorktreeId,
dismissedWorktreeIds: new Set(Object.keys(sources.dismissals))
}),
[candidates, now, reviewInfoByWorktreeId, sizeByWorktreeId, sources]
)
const result = useMemo(
() => runWorkspaceCleanupQuery(facets, { filters, sort }, now),
[facets, filters, now, sort]
)
const facetCounts = useMemo(
() => countWorkspaceCleanupFacetMatches(facets, filters, now),
[facets, filters, now]
)
const options = useMemo<WorkspaceCleanupFacetOptions>(
() => ({
workspaceStatuses: sources.workspaceStatuses.map((status) => ({
id: status.id,
label: status.label
})),
hostIds: [...new Set(facets.map((row) => row.hostId))].sort(),
repos: sources.repos
.filter((repo) => isGitRepoKind(repo))
.map((repo) => ({ id: repo.id, label: repo.displayName || repo.path })),
reviewProviders: [
...new Set(
facets
.map((row) => row.review.provider)
.filter((provider): provider is HostedReviewProvider => provider !== null)
)
].sort()
}),
[facets, sources.repos, sources.workspaceStatuses]
)
return {
rows: result.rows,
selectableWorktreeIds: result.selectableWorktreeIds,
matchedCount: result.matchedCount,
totalCount: result.totalCount,
facetCounts,
options,
reviewInfoByWorktreeId,
sizeByWorktreeId,
measuredSizeCount: sizeByWorktreeId.size
}
}
@@ -0,0 +1,119 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { useAppStore } from '@/store'
import { useMountedRef } from '@/hooks/useMountedRef'
import type { WorkspaceCleanupCandidate } from '../../../../shared/workspace-cleanup'
import {
selectWorkspaceCleanupGitEvidenceTargets,
WORKSPACE_CLEANUP_GIT_EVIDENCE_CONCURRENCY
} from './workspace-cleanup-git-evidence'
export type WorkspaceCleanupGitEvidenceState = {
/** Focused re-scan results, keyed by worktree id. */
evidenceByWorktreeId: ReadonlyMap<string, WorkspaceCleanupCandidate>
pendingWorktreeIds: ReadonlySet<string>
checkedCount: number
totalCount: number
}
const EMPTY_EVIDENCE: WorkspaceCleanupGitEvidenceState = {
evidenceByWorktreeId: new Map(),
pendingWorktreeIds: new Set(),
checkedCount: 0,
totalCount: 0
}
/**
* Fills in git evidence the broad scan deferred, one focused re-scan per row.
* Only runs while a git-dependent filter or sort is active — the initial list
* render never waits on it, so an unfiltered browse stays instant.
*/
export function useWorkspaceCleanupGitEvidence({
enabled,
candidates
}: {
enabled: boolean
candidates: readonly WorkspaceCleanupCandidate[]
}): WorkspaceCleanupGitEvidenceState {
const scanWorkspaceCleanup = useAppStore((s) => s.scanWorkspaceCleanup)
const mountedRef = useMountedRef()
const [state, setState] = useState<WorkspaceCleanupGitEvidenceState>(EMPTY_EVIDENCE)
const evidenceRef = useRef(new Map<string, WorkspaceCleanupCandidate>())
const attemptedRef = useRef(new Set<string>())
const queueRef = useRef<string[]>([])
const inFlightRef = useRef(new Set<string>())
const totalRef = useRef(0)
// Why: results that land after the user cleared the git filter belong to a
// stale pass and must not resurrect its pending banner.
const generationRef = useRef(0)
const publish = useCallback(() => {
if (!mountedRef.current) {
return
}
setState({
evidenceByWorktreeId: new Map(evidenceRef.current),
pendingWorktreeIds: new Set(inFlightRef.current),
checkedCount: totalRef.current - queueRef.current.length - inFlightRef.current.size,
totalCount: totalRef.current
})
}, [mountedRef])
const pump = useCallback(() => {
const generation = generationRef.current
while (
inFlightRef.current.size < WORKSPACE_CLEANUP_GIT_EVIDENCE_CONCURRENCY &&
queueRef.current.length > 0
) {
const worktreeId = queueRef.current.shift()
if (worktreeId === undefined) {
break
}
inFlightRef.current.add(worktreeId)
attemptedRef.current.add(worktreeId)
void scanWorkspaceCleanup({ worktreeId })
.then((result) => {
const refreshed = result.candidates.find(
(candidate) => candidate.worktreeId === worktreeId
)
if (refreshed && generation === generationRef.current) {
evidenceRef.current.set(worktreeId, refreshed)
}
})
.catch(() => {
// Why: a failed focused scan leaves the row honestly "Not checked";
// attemptedRef keeps it from retrying in a loop.
})
.finally(() => {
inFlightRef.current.delete(worktreeId)
if (generation !== generationRef.current) {
return
}
publish()
pump()
})
}
publish()
}, [publish, scanWorkspaceCleanup])
useEffect(() => {
if (!enabled) {
generationRef.current += 1
queueRef.current = []
inFlightRef.current.clear()
totalRef.current = 0
publish()
return
}
const targets = selectWorkspaceCleanupGitEvidenceTargets(candidates, {
resolvedWorktreeIds: attemptedRef.current
}).filter((id) => !queueRef.current.includes(id))
if (targets.length === 0) {
return
}
queueRef.current.push(...targets)
totalRef.current += targets.length
pump()
}, [candidates, enabled, pump, publish])
return state
}
@@ -0,0 +1,219 @@
import { useCallback, useRef, useState } from 'react'
import { useAppStore } from '@/store'
import { useMountedRef } from '@/hooks/useMountedRef'
import type { WorkspaceCleanupCandidate } from '../../../../shared/workspace-cleanup'
import {
startWorkspaceCleanupBackgroundRemoval,
type WorkspaceCleanupRemovalProgress
} from './workspace-cleanup-background-removal'
import { filterWorkspaceCleanupRemovalCandidates } from './workspace-cleanup-removal-candidates'
export type WorkspaceCleanupRemovalController = {
confirming: boolean
confirmCandidates: WorkspaceCleanupCandidate[]
removalProgress: WorkspaceCleanupRemovalProgress | null
removalInFlight: boolean
/** Synchronous guard; state alone lags a second confirm click. */
removalInFlightRef: { current: boolean }
rowFailures: Record<string, string>
resetRowFailures: () => void
resetForReopen: () => void
openConfirmRemove: (candidates: readonly WorkspaceCleanupCandidate[]) => void
confirmRemove: () => void
cancelConfirmRemove: () => void
backToList: () => void
}
/**
* Owns the destructive path: confirm gate, background batch, late settlement,
* and the queued-delete overlay each row shows in the sidebar.
*/
export function useWorkspaceCleanupRemoval({
onDeselect,
closeModal
}: {
onDeselect: (removedIds: readonly string[]) => void
closeModal: () => void
}): WorkspaceCleanupRemovalController {
const removeCandidates = useAppStore((s) => s.removeWorkspaceCleanupCandidates)
const markWorktreesQueuedForDeletion = useAppStore((s) => s.markWorktreesQueuedForDeletion)
const clearWorktreeDeleteState = useAppStore((s) => s.clearWorktreeDeleteState)
const mountedRef = useMountedRef()
const [confirming, setConfirming] = useState(false)
const [confirmCandidates, setConfirmCandidates] = useState<WorkspaceCleanupCandidate[]>([])
const [removalProgress, setRemovalProgress] = useState<WorkspaceCleanupRemovalProgress | null>(
null
)
// Why: `removalProgress` only arrives once the batch reports, so rendering
// needs its own in-flight flag; removalInFlightRef stays the synchronous guard.
const [removalInFlight, setRemovalInFlight] = useState(false)
const [rowFailures, setRowFailures] = useState<Record<string, string>>({})
const removalInFlightRef = useRef(false)
// Why: the dialog stays mounted across cleanup runs, so late settlements from
// an earlier batch must not mutate a newer batch's row state.
const removalBatchIdRef = useRef(0)
const resetRowFailures = useCallback(() => setRowFailures({}), [])
const resetForReopen = useCallback(() => {
if (removalInFlightRef.current) {
return
}
setConfirming(false)
setRowFailures({})
}, [])
const clearQueuedDeleteState = useCallback(
(worktreeId: string) => {
const deleteState = useAppStore.getState().deleteStateByWorktreeId[worktreeId]
// Why: candidates that fail before removal starts would otherwise stay
// marked "Queued for deletion" in the sidebar; rows already in the
// 'deleting' phase or failed with an error keep their own state.
if (deleteState?.isDeleting && deleteState.error === null && deleteState.phase === 'queued') {
clearWorktreeDeleteState(worktreeId)
}
},
[clearWorktreeDeleteState]
)
const openConfirmRemove = useCallback((candidates: readonly WorkspaceCleanupCandidate[]) => {
const nextCandidates = filterWorkspaceCleanupRemovalCandidates(
candidates,
useAppStore.getState().deleteStateByWorktreeId
)
if (nextCandidates.length === 0) {
return
}
setConfirmCandidates(nextCandidates)
setConfirming(true)
}, [])
const cancelConfirmRemove = useCallback(() => {
if (removalProgress) {
closeModal()
return
}
setConfirming(false)
setConfirmCandidates([])
}, [closeModal, removalProgress])
// Why: the header X reads as "leave this screen", not "abandon the dialog". The
// batch keeps running either way and the list shows each row's progress.
// Diverges from cancelConfirmRemove, which closes the dialog mid-batch: here
// removalProgress stays set until the batch settles, so re-entry stays blocked.
const backToList = useCallback(() => {
setConfirming(false)
setConfirmCandidates([])
}, [])
const settle = useCallback(() => {
setRemovalProgress(null)
setRemovalInFlight(false)
setConfirming(false)
setConfirmCandidates([])
}, [])
const confirmRemove = useCallback(() => {
if (confirmCandidates.length === 0 || removalInFlightRef.current) {
return
}
const removableCandidates = filterWorkspaceCleanupRemovalCandidates(
confirmCandidates,
useAppStore.getState().deleteStateByWorktreeId
)
if (removableCandidates.length === 0) {
setConfirming(false)
setConfirmCandidates([])
return
}
removalInFlightRef.current = true
setRemovalInFlight(true)
removalBatchIdRef.current += 1
const removalBatchId = removalBatchIdRef.current
// Why: a hung late settlement retains these callbacks for the renderer's
// lifetime; capture only ids so it cannot pin the candidate objects.
const removableWorktreeIds = removableCandidates.map((candidate) => candidate.worktreeId)
setRowFailures({})
markWorktreesQueuedForDeletion(removableWorktreeIds)
startWorkspaceCleanupBackgroundRemoval({
candidates: removableCandidates,
removeCandidates,
onProgress: (progress) => {
if (mountedRef.current) {
setRemovalProgress(progress)
}
},
onRowFailed: (failure) => {
clearQueuedDeleteState(failure.worktreeId)
},
onResult: (result) => {
const nextFailures: Record<string, string> = {}
for (const failure of result.failures) {
nextFailures[failure.worktreeId] = failure.message
clearQueuedDeleteState(failure.worktreeId)
}
if (mountedRef.current) {
setRowFailures(nextFailures)
onDeselect(result.removedIds)
settle()
}
removalInFlightRef.current = false
},
onLateResult: (result) => {
for (const failure of result.failures) {
// Why: a late failure can come from a hung preflight whose row never
// reached 'deleting'; clear its queued overlay like every other path.
clearQueuedDeleteState(failure.worktreeId)
}
if (!mountedRef.current || removalBatchIdRef.current !== removalBatchId) {
return
}
setRowFailures((current) => {
const next = { ...current }
for (const id of result.removedIds) {
delete next[id]
}
for (const failure of result.failures) {
next[failure.worktreeId] = failure.message
}
return next
})
onDeselect(result.removedIds)
},
onError: () => {
for (const worktreeId of removableWorktreeIds) {
clearWorktreeDeleteState(worktreeId)
}
if (mountedRef.current) {
settle()
}
removalInFlightRef.current = false
}
})
}, [
clearQueuedDeleteState,
clearWorktreeDeleteState,
confirmCandidates,
markWorktreesQueuedForDeletion,
mountedRef,
onDeselect,
removeCandidates,
settle
])
return {
confirming,
confirmCandidates,
removalProgress,
removalInFlight,
removalInFlightRef,
rowFailures,
resetRowFailures,
resetForReopen,
openConfirmRemove,
confirmRemove,
cancelConfirmRemove,
backToList
}
}
@@ -0,0 +1,64 @@
import { describe, expect, it } from 'vitest'
import {
arrangeWorkspaceCleanupRowsByFrozenOrder,
createWorkspaceCleanupFrozenRowOrder
} from './use-workspace-cleanup-row-order'
type Row = { worktreeId: string }
const row = (worktreeId: string): Row => ({ worktreeId })
const ids = (rows: readonly Row[]): string[] => rows.map((entry) => entry.worktreeId)
describe('workspace cleanup streaming row order', () => {
it('keeps existing rows in their frozen slots when streamed values would resort them', () => {
const order = createWorkspaceCleanupFrozenRowOrder([row('a'), row('b'), row('c')], 'name:asc')
// The live sort now says c < a < b (values changed mid-stream).
const arranged = arrangeWorkspaceCleanupRowsByFrozenOrder([row('c'), row('a'), row('b')], order)
expect(ids(arranged)).toEqual(['a', 'b', 'c'])
})
it('appends unseen rows after the frozen ones and keeps them stable on later ticks', () => {
const order = createWorkspaceCleanupFrozenRowOrder([row('a'), row('b')], 'name:asc')
const firstTick = arrangeWorkspaceCleanupRowsByFrozenOrder(
[row('a'), row('d'), row('b'), row('c')],
order
)
expect(ids(firstTick)).toEqual(['a', 'b', 'd', 'c'])
// A later tick sorting d and c differently must not swap them anymore.
const secondTick = arrangeWorkspaceCleanupRowsByFrozenOrder(
[row('c'), row('d'), row('b'), row('a')],
order
)
expect(ids(secondTick)).toEqual(['a', 'b', 'd', 'c'])
})
it('holds slots for rows a filter temporarily hides', () => {
const order = createWorkspaceCleanupFrozenRowOrder([row('a'), row('b'), row('c')], 'name:asc')
expect(ids(arrangeWorkspaceCleanupRowsByFrozenOrder([row('c'), row('a')], order))).toEqual([
'a',
'c'
])
// Un-hiding b restores its original slot between a and c.
expect(
ids(arrangeWorkspaceCleanupRowsByFrozenOrder([row('c'), row('b'), row('a')], order))
).toEqual(['a', 'b', 'c'])
})
it('grows the frozen order from empty during a cold-start stream', () => {
const order = createWorkspaceCleanupFrozenRowOrder([], 'name:asc')
expect(ids(arrangeWorkspaceCleanupRowsByFrozenOrder([row('b'), row('d')], order))).toEqual([
'b',
'd'
])
// New rows slot after the already-presented ones even when they sort earlier.
expect(
ids(arrangeWorkspaceCleanupRowsByFrozenOrder([row('a'), row('b'), row('c'), row('d')], order))
).toEqual(['b', 'd', 'a', 'c'])
})
})
@@ -0,0 +1,78 @@
import { useMemo, useRef } from 'react'
import type { WorkspaceCleanupSortState } from '../../../../shared/workspace-cleanup-filter-model'
type OrderedRow = { worktreeId: string }
export type WorkspaceCleanupFrozenRowOrder = {
sortSignature: string
positions: Map<string, number>
}
export function createWorkspaceCleanupFrozenRowOrder(
rows: readonly OrderedRow[],
sortSignature: string
): WorkspaceCleanupFrozenRowOrder {
return {
sortSignature,
positions: new Map(rows.map((row, index) => [row.worktreeId, index]))
}
}
/**
* Rows already on screen keep their slot while a refresh streams updated
* values in; rows the order has not seen yet append after them (in the live
* sort among themselves) and claim the next slots, so later ticks cannot
* reshuffle them either.
*/
export function arrangeWorkspaceCleanupRowsByFrozenOrder<Row extends OrderedRow>(
sortedRows: readonly Row[],
order: WorkspaceCleanupFrozenRowOrder
): Row[] {
const known: Row[] = []
const fresh: Row[] = []
for (const row of sortedRows) {
if (order.positions.has(row.worktreeId)) {
known.push(row)
} else {
fresh.push(row)
}
}
known.sort(
(left, right) =>
(order.positions.get(left.worktreeId) ?? 0) - (order.positions.get(right.worktreeId) ?? 0)
)
for (const row of fresh) {
order.positions.set(row.worktreeId, order.positions.size)
}
return [...known, ...fresh]
}
/**
* Streaming-order stability for the flat list: while a scan streams, existing
* rows must not reshuffle on every progress tick. The user's sort applies to
* the snapshot immediately; streamed value changes land in place; one calm
* re-sort happens when the scan settles — or at once if the user explicitly
* changes the sort mid-scan.
*/
export function useWorkspaceCleanupRowOrder<Row extends OrderedRow>({
rows,
streaming,
sort
}: {
rows: readonly Row[]
streaming: boolean
sort: WorkspaceCleanupSortState
}): readonly Row[] {
const frozenOrderRef = useRef<WorkspaceCleanupFrozenRowOrder | null>(null)
return useMemo(() => {
const sortSignature = `${sort.field}:${sort.direction}`
const frozen = frozenOrderRef.current
if (!streaming || frozen === null || frozen.sortSignature !== sortSignature) {
// Why: settled renders (and an explicit mid-scan sort change) present the
// live sort and freeze it as the order the next stream must hold steady.
frozenOrderRef.current = createWorkspaceCleanupFrozenRowOrder(rows, sortSignature)
return rows
}
return arrangeWorkspaceCleanupRowsByFrozenOrder(rows, frozen)
}, [rows, sort.direction, sort.field, streaming])
}
@@ -0,0 +1,129 @@
import { useCallback, useEffect, useRef } from 'react'
import { toast } from 'sonner'
import { useAppStore } from '@/store'
import { useMountedRef } from '@/hooks/useMountedRef'
import { translate } from '@/i18n/i18n'
import { formatWorkspaceCleanupReadyToast } from './workspace-cleanup-scan-notice'
export type WorkspaceCleanupScanLifecycle = {
startWorkspaceCleanupScan: (options?: { notifyWhenReady?: boolean }) => void
}
/**
* Owns the dialog's scan cadence: seed the list from the persisted snapshot,
* kick one background rescan per open, and adopt — never duplicate — a scan
* already streaming into the store when the dialog reopens mid-flight.
*/
export function useWorkspaceCleanupScanLifecycle({
open,
loading,
removalInFlightRef,
resetRowFailures,
onFreshOpen
}: {
open: boolean
loading: boolean
removalInFlightRef: { current: boolean }
resetRowFailures: () => void
onFreshOpen: () => void
}): WorkspaceCleanupScanLifecycle {
const scanWorkspaceCleanup = useAppStore((s) => s.scanWorkspaceCleanup)
const hydrateCleanupFromCache = useAppStore((s) => s.hydrateWorkspaceCleanupFromCache)
const hydrateSpaceFromCache = useAppStore((s) => s.hydrateWorkspaceSpaceFromCache)
const openModal = useAppStore((s) => s.openModal)
const mountedRef = useMountedRef()
const openRef = useRef(open)
const wasOpenRef = useRef(false)
const autoScanAttemptedForOpenRef = useRef(false)
const latestReadyToastScanAtRef = useRef<number | null>(null)
useEffect(() => {
openRef.current = open
}, [open])
const startWorkspaceCleanupScan = useCallback(
(options: { notifyWhenReady?: boolean } = {}) => {
resetRowFailures()
void scanWorkspaceCleanup()
.then((result) => {
if (!mountedRef.current || !options.notifyWhenReady || openRef.current) {
return
}
if (latestReadyToastScanAtRef.current === result.scannedAt) {
return
}
latestReadyToastScanAtRef.current = result.scannedAt
const suggestedCount = result.candidates.filter(
(candidate) => candidate.selectedByDefault
).length
toast.success(
translate(
'auto.components.workspace.cleanup.WorkspaceCleanupDialog.0e2d235c63',
'Inactive workspace scan ready'
),
{
description: formatWorkspaceCleanupReadyToast(
result.candidates.length,
suggestedCount
),
action: {
label: translate(
'auto.components.workspace.cleanup.WorkspaceCleanupDialog.4a35c08764',
'Review'
),
onClick: () => openModal('workspace-cleanup')
}
}
)
})
.catch((err: unknown) => {
if (mountedRef.current) {
toast.error(
translate(
'auto.components.workspace.cleanup.WorkspaceCleanupDialog.662b8ec3f8',
'Workspace cleanup scan failed'
),
{ description: err instanceof Error ? err.message : String(err) }
)
}
})
},
[mountedRef, openModal, resetRowFailures, scanWorkspaceCleanup]
)
const hydrateThenScan = useCallback(() => {
// Why: the cached snapshot must seed the list before the rescan starts so
// progress reconciles into full rows instead of a cold rebuild.
void Promise.allSettled([hydrateCleanupFromCache(), hydrateSpaceFromCache()]).then(() => {
startWorkspaceCleanupScan({ notifyWhenReady: true })
})
}, [hydrateCleanupFromCache, hydrateSpaceFromCache, startWorkspaceCleanupScan])
useEffect(() => {
if (!open) {
wasOpenRef.current = false
autoScanAttemptedForOpenRef.current = false
return
}
if (!wasOpenRef.current) {
wasOpenRef.current = true
autoScanAttemptedForOpenRef.current = false
onFreshOpen()
}
// Why: reopening mid-batch keeps the deletion progress view; a broad scan
// started here would be discarded by the removal's scan invalidation, so
// skip it while a removal batch is running.
if (autoScanAttemptedForOpenRef.current || removalInFlightRef.current) {
return
}
autoScanAttemptedForOpenRef.current = true
if (loading) {
// Why: reopening mid-scan adopts the stream already feeding the store;
// starting another broad scan here would duplicate it once it settles.
return
}
hydrateThenScan()
}, [hydrateThenScan, loading, onFreshOpen, open, removalInFlightRef])
return { startWorkspaceCleanupScan }
}
@@ -0,0 +1,38 @@
import { describe, expect, it } from 'vitest'
import { createDefaultWorkspaceCleanupFilterState } from '../../../../shared/workspace-cleanup-filter-model'
import {
hasActiveWorkspaceCleanupFilters,
listActiveWorkspaceCleanupFacetGroups
} from './workspace-cleanup-active-facets'
describe('listActiveWorkspaceCleanupFacetGroups', () => {
it('reports nothing for the default state', () => {
const filters = createDefaultWorkspaceCleanupFilterState()
expect(listActiveWorkspaceCleanupFacetGroups(filters)).toEqual([])
expect(hasActiveWorkspaceCleanupFilters(filters)).toBe(false)
})
it('names each edited group', () => {
const filters = createDefaultWorkspaceCleanupFilterState()
filters.activity.idleMinDays = 45
filters.git.states = ['dirty']
expect(listActiveWorkspaceCleanupFacetGroups(filters)).toEqual(['activity', 'git'])
})
it('treats multi-select order as meaningless', () => {
const filters = createDefaultWorkspaceCleanupFilterState()
filters.review.states = ['merged', 'closed']
const reordered = createDefaultWorkspaceCleanupFilterState()
reordered.review.states = ['closed', 'merged']
expect(listActiveWorkspaceCleanupFacetGroups(filters)).toEqual(
listActiveWorkspaceCleanupFacetGroups(reordered)
)
})
it('counts a text query as an active filter without naming a group', () => {
const filters = createDefaultWorkspaceCleanupFilterState()
filters.query = ' alpha '
expect(listActiveWorkspaceCleanupFacetGroups(filters)).toEqual([])
expect(hasActiveWorkspaceCleanupFilters(filters)).toBe(true)
})
})
@@ -0,0 +1,58 @@
import {
createDefaultWorkspaceCleanupFilterState,
type WorkspaceCleanupFilterState
} from '../../../../shared/workspace-cleanup-filter-model'
export type WorkspaceCleanupFacetGroupKey = keyof Omit<WorkspaceCleanupFilterState, 'query'>
const FACET_GROUP_KEYS: readonly WorkspaceCleanupFacetGroupKey[] = [
'activity',
'size',
'status',
'agent',
'git',
'review',
'ticket',
'context',
'location',
'safety'
]
/**
* Facet groups the user has moved off the default state. Drives the "N filters
* on" badge, so it compares against defaults rather than against the active
* preset — a preset is itself just a named filter state.
*/
export function listActiveWorkspaceCleanupFacetGroups(
filters: WorkspaceCleanupFilterState
): WorkspaceCleanupFacetGroupKey[] {
const defaults = createDefaultWorkspaceCleanupFilterState()
return FACET_GROUP_KEYS.filter((key) => !isGroupEqual(filters[key], defaults[key]))
}
export function hasActiveWorkspaceCleanupFilters(filters: WorkspaceCleanupFilterState): boolean {
return (
filters.query.trim().length > 0 || listActiveWorkspaceCleanupFacetGroups(filters).length > 0
)
}
/** Selection order is not meaning, so array members compare as sets. */
function isGroupEqual(left: unknown, right: unknown): boolean {
return JSON.stringify(normalize(left)) === JSON.stringify(normalize(right))
}
function normalize(value: unknown): unknown {
if (Array.isArray(value)) {
return [...value].map(String).sort()
}
if (value !== null && typeof value === 'object') {
const entries = Object.entries(value as Record<string, unknown>).sort(([a], [b]) =>
a.localeCompare(b)
)
return entries.map(([key, entry]) => [
key,
typeof entry === 'string' ? entry.trim() : normalize(entry)
])
}
return value
}
@@ -0,0 +1,91 @@
import React from 'react'
import { translate } from '@/i18n/i18n'
import type { WorkspaceCleanupBrowseController } from './use-workspace-cleanup-browse-state'
import type { WorkspaceCleanupFacetRows } from './use-workspace-cleanup-facet-rows'
import {
hasActiveWorkspaceCleanupFilters,
listActiveWorkspaceCleanupFacetGroups
} from './workspace-cleanup-active-facets'
import { WorkspaceCleanupEmptyState } from './workspace-cleanup-dialog-notices'
import { WorkspaceCleanupFilterBar } from './workspace-cleanup-filter-bar'
import { WorkspaceCleanupPresetChips } from './workspace-cleanup-preset-chips'
import { WorkspaceCleanupSortHeader } from './workspace-cleanup-sort-header'
export function WorkspaceCleanupBrowseToolbar({
browse,
facetRows,
selectedCount,
spaceScanning,
gitPendingCount,
gitCheckedTotal,
onRunSpaceScan,
onToggleSelectAll
}: {
browse: WorkspaceCleanupBrowseController
facetRows: WorkspaceCleanupFacetRows
selectedCount: number
spaceScanning: boolean
gitPendingCount: number
gitCheckedTotal: number
onRunSpaceScan: () => void
onToggleSelectAll: (selectAll: boolean) => void
}): React.JSX.Element {
const activeFilters = hasActiveWorkspaceCleanupFilters(browse.filters)
return (
<>
<WorkspaceCleanupPresetChips
presets={browse.presets}
matchedPresetId={browse.matchedPresetId}
requestedPresetId={browse.browse.activePresetId}
hasActiveFilters={activeFilters}
onApplyPreset={browse.applyPreset}
onClearFilters={browse.clearFilters}
/>
<WorkspaceCleanupFilterBar
facetProps={{
filters: browse.filters,
counts: facetRows.facetCounts,
totalCount: facetRows.totalCount,
options: facetRows.options,
onPatch: browse.patchFilters
}}
activeFacetGroupCount={listActiveWorkspaceCleanupFacetGroups(browse.filters).length}
matchedCount={facetRows.matchedCount}
hasActiveFilters={activeFilters}
sizeScan={{
measuredCount: facetRows.measuredSizeCount,
scanning: spaceScanning,
onRun: onRunSpaceScan
}}
gitEvidence={{ pendingCount: gitPendingCount, totalCount: gitCheckedTotal }}
onQueryChange={(query) => browse.patchFilters('query', query)}
onClearFilters={browse.clearFilters}
/>
<WorkspaceCleanupSortHeader
sort={browse.sort}
selectableCount={facetRows.selectableWorktreeIds.length}
selectedCount={selectedCount}
onToggleSortField={browse.toggleSortField}
onSetSort={browse.setSort}
onToggleSelectAll={onToggleSelectAll}
/>
{browse.sort.field === 'size' && facetRows.measuredSizeCount === 0 ? (
<WorkspaceCleanupEmptyState
title={translate(
'components.workspace.cleanup.browse.noSizes',
'No workspace sizes measured yet.'
)}
description={translate(
'components.workspace.cleanup.browse.noSizesDescription',
'Sizes come from the disk-space scan. Run it to sort and filter by size.'
)}
actionLabel={translate(
'components.workspace.cleanup.browse.measureSizes',
'Measure sizes'
)}
onAction={onRunSpaceScan}
/>
) : null}
</>
)
}
@@ -1,6 +1,8 @@
import React from 'react'
import { useVirtualizer } from '@tanstack/react-virtual'
import type { WorkspaceCleanupCandidate } from '../../../../shared/workspace-cleanup'
/** Any row shape the flat list renders — raw candidates or enriched facet rows. */
type WorkspaceCleanupListRow = { worktreeId: string }
// Why: below this count plain rows keep the pre-virtualization DOM (natural
// flow, no absolute positioning), so the common few-worktrees case is
@@ -19,7 +21,7 @@ const WORKSPACE_CLEANUP_ROW_OVERSCAN = 8
* no scroll-margin bookkeeping is needed. Lists shorter than
* WORKSPACE_CLEANUP_VIRTUALIZE_MIN_ROWS render plainly.
*/
export function WorkspaceCleanupCandidateList({
export function WorkspaceCleanupCandidateList<Row extends WorkspaceCleanupListRow>({
rows,
renderRow,
// Why: a state-held element, not a ref — the ScrollArea viewport is not
@@ -27,8 +29,8 @@ export function WorkspaceCleanupCandidateList({
// virtualizer unobserved until some unrelated re-render.
scrollElement
}: {
rows: readonly WorkspaceCleanupCandidate[]
renderRow: (candidate: WorkspaceCleanupCandidate, index: number) => React.ReactNode
rows: readonly Row[]
renderRow: (row: Row, index: number) => React.ReactNode
scrollElement: HTMLDivElement | null
}): React.JSX.Element {
const virtualize = rows.length >= WORKSPACE_CLEANUP_VIRTUALIZE_MIN_ROWS
@@ -8,6 +8,7 @@ import {
FileWarning,
GitBranch,
GitPullRequest,
HardDrive,
Loader2,
Search,
SquareTerminal,
@@ -48,9 +49,13 @@ type CandidateRowProps = {
deletionPhase?: WorkspaceCleanupDeletionPhase
expanded: boolean
failure?: string
/** A focused git re-scan is in flight, so "Not checked" is provisional. */
gitEvidencePending?: boolean
last: boolean
lastActivityLabel: string
removing?: boolean
/** Joined from the workspace-space scan; null when that scan has not run. */
sizeLabel?: string | null
reviewInfo: WorkspaceCleanupReviewInfo
selected: boolean
onIgnore: (candidate: WorkspaceCleanupCandidate) => void
@@ -106,9 +111,11 @@ export const CandidateRow = React.memo(function CandidateRow({
deletionPhase,
expanded,
failure,
gitEvidencePending = false,
last,
lastActivityLabel,
removing = false,
sizeLabel = null,
reviewInfo,
selected,
onIgnore,
@@ -193,7 +200,28 @@ export const CandidateRow = React.memo(function CandidateRow({
)} ${lastActivityLabel}`}
value={formatCompactActivityLabel(lastActivityLabel)}
/>
{dirtyLabel && showGitMetadataChip ? (
{sizeLabel ? (
<MetadataIconChip
icon={HardDrive}
label={translate(
'components.workspace.cleanup.browse.sizeOnDisk',
'Size on disk: {{value0}}',
{ value0: sizeLabel }
)}
value={sizeLabel}
/>
) : null}
{gitEvidencePending ? (
<span
className="inline-flex h-5 shrink-0 items-center gap-1 rounded-full border border-border bg-background px-1.5 text-[11px] font-medium text-muted-foreground"
aria-label={translate(
'components.workspace.cleanup.browse.checkingGitRow',
'Checking git status'
)}
>
<Loader2 className="size-3 animate-spin" aria-hidden="true" />
</span>
) : dirtyLabel && showGitMetadataChip ? (
<MetadataIconChip icon={FileWarning} label={dirtyLabel} tone="destructive" />
) : showGitMetadataChip ? (
<MetadataIconChip
@@ -0,0 +1,225 @@
import React from 'react'
import { AlertTriangle, Loader2, Trash2, X } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { Progress } from '@/components/ui/progress'
import { ScrollArea } from '@/components/ui/scroll-area'
import { cn } from '@/lib/utils'
import { translate } from '@/i18n/i18n'
import type { WorkspaceCleanupCandidate } from '../../../../shared/workspace-cleanup'
import type { WorkspaceCleanupRemovalProgress } from './workspace-cleanup-background-removal'
import {
getCandidateStatus,
getContextPillLabel,
getDirtyGitLabel,
getReviewPillTone,
shouldShowGitMetadataChip
} from './workspace-cleanup-candidate-row-data'
import type { WorkspaceCleanupReviewInfo } from './workspace-cleanup-presentation'
import { formatWorkspaceCleanupRelativeTime } from './workspace-cleanup-relative-time'
import { StatusPill } from './workspace-cleanup-status-pill'
const EMPTY_REVIEW_INFO: WorkspaceCleanupReviewInfo = {
hasReview: false,
label: null,
state: null,
provider: null,
title: null
}
export function WorkspaceCleanupConfirmRemove({
candidates,
reviewInfoByWorktreeId,
progress,
onBack,
onCancel,
onConfirm
}: {
candidates: WorkspaceCleanupCandidate[]
reviewInfoByWorktreeId: ReadonlyMap<string, WorkspaceCleanupReviewInfo>
progress: WorkspaceCleanupRemovalProgress | null
onBack: () => void
onCancel: () => void
onConfirm: () => void
}): React.JSX.Element {
const count = candidates.length
const deleting = progress !== null
const progressValue = progress
? Math.min(100, Math.max(0, (progress.processedCount / progress.totalCount) * 100))
: 0
return (
<>
<DialogHeader className="border-b border-border px-5 py-4">
<div className="flex items-start justify-between gap-4">
<div className="flex min-w-0 items-start gap-3">
<div className="mt-0.5 flex size-8 shrink-0 items-center justify-center rounded-md border border-destructive/25 bg-destructive/10 text-destructive">
{deleting ? (
<Loader2 className="size-4 animate-spin" />
) : (
<AlertTriangle className="size-4" />
)}
</div>
<div className="min-w-0">
<DialogTitle className="text-base">
{deleting
? translate(
'auto.components.workspace.cleanup.WorkspaceCleanupDialog.deletingCount',
'Deleting workspaces: {{value0}}',
{ value0: count }
)
: translate(
'auto.components.workspace.cleanup.WorkspaceCleanupDialog.deleteCount',
'Delete workspaces: {{value0}}?',
{ value0: count }
)}
</DialogTitle>
<DialogDescription className="mt-1.5 text-xs leading-5">
{deleting
? translate(
'auto.components.workspace.cleanup.WorkspaceCleanupDialog.1d3503357d',
'You can close this and come back while deletion continues.'
)
: translate(
'auto.components.workspace.cleanup.WorkspaceCleanupDialog.38ca0b1400',
"This permanently deletes their local files. You can't undo this."
)}
</DialogDescription>
</div>
</div>
<Button
variant="ghost"
size="icon-sm"
aria-label={translate(
'auto.components.workspace.cleanup.WorkspaceCleanupDialog.74f6c16279',
'Back'
)}
onClick={onBack}
>
<X className="size-4" />
</Button>
</div>
</DialogHeader>
<div className="flex min-h-0 flex-1 flex-col">
{progress ? (
<div className="border-b border-border bg-muted/25 px-5 py-3">
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<Loader2 className="size-3.5 shrink-0 animate-spin" />
<span className="font-medium text-foreground">
{formatWorkspaceCleanupRemovalProgress(progress)}
</span>
</div>
<Progress value={progressValue} className="mt-2 h-1.5" />
</div>
) : null}
<div className="flex items-center justify-between border-b border-border px-5 py-2.5">
<div className="text-[11px] font-semibold uppercase tracking-[0.05em] text-muted-foreground">
{translate(
'auto.components.workspace.cleanup.WorkspaceCleanupDialog.selectedForDeletionCount',
'Selected for deletion: {{value0}}',
{ value0: count }
)}
</div>
</div>
<ScrollArea className="min-h-0 flex-1">
{candidates.map((candidate, index) => (
<ConfirmRemoveRow
key={candidate.worktreeId}
candidate={candidate}
reviewInfo={reviewInfoByWorktreeId.get(candidate.worktreeId) ?? EMPTY_REVIEW_INFO}
last={index === candidates.length - 1}
/>
))}
</ScrollArea>
</div>
<DialogFooter className="border-t border-border px-5 py-3">
<Button variant="outline" onClick={onCancel}>
{deleting
? translate(
'auto.components.workspace.cleanup.WorkspaceCleanupDialog.191f0bc98e',
'Close'
)
: translate(
'auto.components.workspace.cleanup.WorkspaceCleanupDialog.b6bae1eed1',
'Cancel'
)}
</Button>
{!deleting ? (
<Button variant="destructive" onClick={onConfirm} disabled={count === 0}>
<Trash2 className="size-4" />
{translate(
'auto.components.workspace.cleanup.WorkspaceCleanupDialog.deleteButtonCount',
'Delete {{value0}}',
{ value0: count }
)}
</Button>
) : null}
</DialogFooter>
</>
)
}
function ConfirmRemoveRow({
candidate,
reviewInfo,
last
}: {
candidate: WorkspaceCleanupCandidate
reviewInfo: WorkspaceCleanupReviewInfo
last: boolean
}): React.JSX.Element {
const dirtyLabel = getDirtyGitLabel(candidate)
const branchDiffersFromName = candidate.branch !== candidate.displayName
const contextPillLabel = getContextPillLabel(candidate)
const showGitMetadataChip = shouldShowGitMetadataChip(candidate)
const status = getCandidateStatus(candidate)
return (
<div className={cn('border-b border-border/60 px-5 py-2.5', last && 'border-b-0')}>
<div className="flex min-w-0 flex-wrap items-baseline gap-x-2 gap-y-0.5">
<span className="min-w-0 truncate text-sm font-medium">{candidate.displayName}</span>
<span className="text-xs text-muted-foreground">
{translate(
'auto.components.workspace.cleanup.WorkspaceCleanupDialog.352f15d6fc',
'Last active'
)}{' '}
{formatWorkspaceCleanupRelativeTime(candidate.lastActivityAt)}
</span>
<StatusPill tone={status.tone}>{status.label}</StatusPill>
{reviewInfo.label ? (
<StatusPill tone={getReviewPillTone(reviewInfo)}>{reviewInfo.label}</StatusPill>
) : null}
{contextPillLabel ? <StatusPill>{contextPillLabel}</StatusPill> : null}
{dirtyLabel && showGitMetadataChip ? (
<StatusPill tone="destructive">{dirtyLabel}</StatusPill>
) : null}
</div>
<div className="mt-0.5 flex min-w-0 flex-wrap items-baseline gap-x-2 text-xs text-muted-foreground">
<span className="min-w-0 truncate">{candidate.repoName}</span>
{branchDiffersFromName ? (
<>
<span aria-hidden="true">·</span>
<span className="min-w-0 truncate font-mono">{candidate.branch}</span>
</>
) : null}
</div>
<div className="mt-0.5 min-w-0 truncate font-mono text-[11px] text-muted-foreground/80">
{candidate.path}
</div>
</div>
)
}
function formatWorkspaceCleanupRemovalProgress(progress: WorkspaceCleanupRemovalProgress): string {
const deletedText = translate(
'auto.components.workspace.cleanup.WorkspaceCleanupDialog.4c2990886e',
'{{value0}}/{{value1}} deleted',
{ value0: progress.removedCount, value1: progress.totalCount }
)
if (progress.failedCount === 0) {
return deletedText
}
return translate(
'auto.components.workspace.cleanup.WorkspaceCleanupDialog.86ba852118',
'{{value0}}, {{value1}} failed',
{ value0: deletedText, value1: progress.failedCount }
)
}
@@ -0,0 +1,99 @@
import React from 'react'
import { RefreshCcw, Trash2, X } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { cn } from '@/lib/utils'
import { translate } from '@/i18n/i18n'
import type { WorkspaceCleanupScanProgress } from '../../../../shared/workspace-cleanup'
import { WorkspaceCleanupFreshness } from './workspace-cleanup-freshness'
export function WorkspaceCleanupDialogHeader({
selectedCount,
deleteDisabled,
loading,
scannedAt,
scanProgress,
onDeleteSelected,
onRefresh,
onClose
}: {
selectedCount: number
deleteDisabled: boolean
loading: boolean
scannedAt: number | null
scanProgress: WorkspaceCleanupScanProgress | null
onDeleteSelected: () => void
onRefresh: () => void
onClose: () => void
}): React.JSX.Element {
const refreshLabel = translate(
'auto.components.workspace.cleanup.WorkspaceCleanupDialog.7ae2ad30f4',
'Refresh'
)
return (
<DialogHeader className="border-b border-border px-5 py-4">
<div className="flex items-center justify-between gap-4">
<DialogTitle className="min-w-0 text-base">
{translate(
'auto.components.workspace.cleanup.WorkspaceCleanupDialog.b2c1331844',
'Delete Inactive Workspaces'
)}
</DialogTitle>
<div className="flex shrink-0 items-center gap-2">
<WorkspaceCleanupFreshness
scannedAt={scannedAt}
refreshing={loading}
progress={scanProgress}
/>
<span className="text-sm font-medium text-foreground">
{selectedCount}{' '}
{translate(
'auto.components.workspace.cleanup.WorkspaceCleanupDialog.ac5ba84cc1',
'selected'
)}
</span>
<Button
variant="destructive"
size="sm"
onClick={onDeleteSelected}
disabled={deleteDisabled}
>
<Trash2 className="size-3.5" />
{translate(
'auto.components.workspace.cleanup.WorkspaceCleanupDialog.b771c92598',
'Delete selected'
)}
</Button>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="outline"
size="icon-sm"
aria-label={refreshLabel}
onClick={onRefresh}
disabled={loading}
>
<RefreshCcw className={cn('size-3.5', loading && 'animate-spin')} />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={4}>
{refreshLabel}
</TooltipContent>
</Tooltip>
<Button
variant="ghost"
size="icon-sm"
aria-label={translate(
'auto.components.workspace.cleanup.WorkspaceCleanupDialog.191f0bc98e',
'Close'
)}
onClick={onClose}
>
<X className="size-4" />
</Button>
</div>
</div>
</DialogHeader>
)
}
@@ -0,0 +1,94 @@
import React from 'react'
import { AlertTriangle, Loader2 } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { translate } from '@/i18n/i18n'
import type { WorkspaceCleanupScanProgress } from '../../../../shared/workspace-cleanup'
import { formatWorkspaceCleanupScanProgress } from './workspace-cleanup-scan-notice'
export function WorkspaceCleanupInitialScanBanner({
progress
}: {
progress: WorkspaceCleanupScanProgress | null
}): React.JSX.Element {
return (
<div className="flex items-start gap-2 border-b border-border bg-muted/25 px-5 py-3">
<Loader2 className="mt-0.5 size-3.5 shrink-0 animate-spin text-muted-foreground" />
<div className="min-w-0">
<div className="text-xs font-medium text-foreground">
{translate(
'auto.components.workspace.cleanup.WorkspaceCleanupDialog.7eee951968',
'Checking inactive workspaces'
)}
</div>
<div className="mt-0.5 text-xs text-muted-foreground">
{translate(
'auto.components.workspace.cleanup.WorkspaceCleanupDialog.47123d0108',
'Scanning inactive workspaces. You can close this and come back.'
)}
</div>
<div className="mt-1 text-xs font-medium text-muted-foreground">
{formatWorkspaceCleanupScanProgress(progress)}
</div>
</div>
</div>
)
}
export function WorkspaceCleanupNotice({
tone = 'muted',
message
}: {
tone?: 'muted' | 'destructive'
message: string
}): React.JSX.Element {
if (tone === 'destructive') {
return (
<div className="border-b border-destructive/30 bg-destructive/10 px-5 py-2 text-xs text-destructive">
{message}
</div>
)
}
return (
<div className="flex items-start gap-2 border-b border-border bg-muted/25 px-5 py-2 text-xs text-muted-foreground">
<AlertTriangle className="mt-0.5 size-3.5 shrink-0" />
<span>{message}</span>
</div>
)
}
export function WorkspaceCleanupEmptyState({
title,
description,
actionLabel,
onAction
}: {
title: string
description?: string
actionLabel?: string
onAction?: () => void
}): React.JSX.Element {
return (
<div className="flex min-h-48 flex-col items-center justify-center gap-2 rounded-lg border border-border bg-muted/20 px-6 text-center text-sm text-muted-foreground">
<span className="font-medium text-foreground">{title}</span>
{description ? <span className="text-xs">{description}</span> : null}
{actionLabel && onAction ? (
<Button variant="outline" size="sm" className="mt-1" onClick={onAction}>
{actionLabel}
</Button>
) : null}
</div>
)
}
export function WorkspaceCleanupSkeletonRows(): React.JSX.Element {
return (
<div className="space-y-2">
{[0, 1, 2].map((index) => (
<div
key={index}
className="h-24 animate-pulse rounded-lg border border-border bg-muted/35"
/>
))}
</div>
)
}
@@ -0,0 +1,152 @@
// @vitest-environment happy-dom
import type { ReactNode } from 'react'
import { act } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import { afterEach, beforeEach, describe, expect, it, vi, type MockInstance } from 'vitest'
import {
createDefaultWorkspaceCleanupFilterState,
type WorkspaceCleanupFilterState
} from '../../../../shared/workspace-cleanup-filter-model'
import { WorkspaceCleanupGitReviewFacets } from './workspace-cleanup-git-review-facets'
import { WorkspaceCleanupLifecycleFacets } from './workspace-cleanup-lifecycle-facets'
import type {
WorkspaceCleanupFacetCounts,
WorkspaceCleanupFacetGroupProps,
WorkspaceCleanupFacetOptions
} from './workspace-cleanup-facet-panel-model'
/** `Mock<T>` erases T's type parameter, so keep the generic call signature and add the spy API. */
type PatchMock = WorkspaceCleanupFacetGroupProps['onPatch'] & MockInstance
function createPatchMock(): PatchMock {
return vi.fn()
}
let root: Root | null = null
let container: HTMLDivElement | null = null
const COUNTS: WorkspaceCleanupFacetCounts = {
activity: 3,
size: 4,
status: 8,
agent: 8,
git: 2,
review: 5,
ticket: 8,
context: 6,
location: 8,
safety: 7
}
const OPTIONS: WorkspaceCleanupFacetOptions = {
workspaceStatuses: [{ id: 'in-progress', label: 'In progress' }],
hostIds: ['local', 'ssh:box'],
repos: [
{ id: 'repo-1', label: 'Alpha' },
{ id: 'repo-2', label: 'Beta' }
],
reviewProviders: ['github', 'gitlab']
}
function render(node: ReactNode): void {
act(() => root?.render(node))
}
function control(label: string): HTMLElement | null {
return container?.querySelector<HTMLElement>(`[aria-label="${label}"]`) ?? null
}
/** React tracks the controlled value, so a bare `input.value =` is swallowed. */
function typeInto(input: HTMLInputElement | null, value: string): void {
if (!input) {
return
}
const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set
setter?.call(input, value)
input.dispatchEvent(new Event('input', { bubbles: true }))
}
function renderFacets(filters: WorkspaceCleanupFilterState, onPatch: PatchMock): void {
const props = { filters, counts: COUNTS, totalCount: 8, options: OPTIONS, onPatch }
render(
<>
<WorkspaceCleanupLifecycleFacets {...props} />
<WorkspaceCleanupGitReviewFacets {...props} />
</>
)
}
describe('workspace cleanup facet controls', () => {
beforeEach(() => {
container = document.createElement('div')
document.body.appendChild(container)
root = createRoot(container)
})
afterEach(() => {
if (root) {
act(() => root?.unmount())
}
container?.remove()
root = null
container = null
})
it('shows a per-facet match count for every group', () => {
renderFacets(createDefaultWorkspaceCleanupFilterState(), createPatchMock())
const counts = [...(container?.querySelectorAll('[data-facet-count]') ?? [])].map(
(node) => node.textContent
)
expect(counts).toHaveLength(10)
expect(counts).toContain('2/8')
expect(counts).toContain('3/8')
})
it('takes a user-chosen idle threshold in days rather than a fixed enum', () => {
const onPatch = createPatchMock()
renderFacets(createDefaultWorkspaceCleanupFilterState(), onPatch)
const input = control('Idle for at least') as HTMLInputElement | null
expect(input?.type).toBe('number')
act(() => typeInto(input, '17'))
expect(onPatch).toHaveBeenCalledWith('activity', { idleMinDays: 17 })
})
it('multi-selects git states', () => {
const onPatch = createPatchMock()
const filters = createDefaultWorkspaceCleanupFilterState()
filters.git.states = ['dirty']
renderFacets(filters, onPatch)
expect(control('Uncommitted changes')?.getAttribute('aria-checked')).toBe('true')
act(() => control('Unpushed commits')?.click())
expect(onPatch).toHaveBeenCalledWith('git', { states: ['dirty', 'unpushed'] })
})
it('offers the hosts and providers discovered in the fleet, not a hardcoded list', () => {
renderFacets(createDefaultWorkspaceCleanupFilterState(), createPatchMock())
expect(control('GitLab')).not.toBeNull()
expect(container?.textContent).toContain('Alpha')
expect(container?.textContent).toContain('In progress')
})
it('cycles a tri-state safety facet', () => {
const onPatch = createPatchMock()
renderFacets(createDefaultWorkspaceCleanupFilterState(), onPatch)
act(() => container?.querySelector<HTMLElement>('[aria-label="Ignored: Only"]')?.click())
expect(onPatch).toHaveBeenCalledWith('safety', { dismissed: 'only' })
})
it('toggles the unsized escape hatch for the opt-in space scan', () => {
const onPatch = createPatchMock()
renderFacets(createDefaultWorkspaceCleanupFilterState(), onPatch)
const checkbox = control('Include unmeasured workspaces')
expect(checkbox?.getAttribute('data-state')).toBe('checked')
act(() => checkbox?.click())
expect(onPatch).toHaveBeenCalledWith('size', { includeUnsized: false })
})
})
@@ -0,0 +1,230 @@
import React from 'react'
import { Checkbox } from '@/components/ui/checkbox'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { cn } from '@/lib/utils'
/** One facet group inside the filter panel, with its own match count. */
export function FacetSection({
title,
matchCount,
totalCount,
children
}: {
title: string
matchCount: number
totalCount: number
children: React.ReactNode
}): React.JSX.Element {
return (
<section className="border-b border-border px-3 py-2.5 last:border-b-0">
<div className="flex items-baseline justify-between gap-2">
<h4 className="text-[11px] font-semibold uppercase tracking-[0.05em] text-muted-foreground">
{title}
</h4>
<span
className="shrink-0 tabular-nums text-[11px] text-muted-foreground"
data-facet-count={title}
>
{matchCount}/{totalCount}
</span>
</div>
<div className="mt-2 space-y-2">{children}</div>
</section>
)
}
/** Multi-select: empty selection leaves the facet unconstrained. */
export function FacetToggleList<T extends string>({
label,
values,
selected,
getLabel,
onChange
}: {
label?: string
values: readonly T[]
selected: readonly T[]
getLabel: (value: T) => string
onChange: (next: T[]) => void
}): React.JSX.Element {
return (
<FacetField label={label}>
<div className="flex flex-wrap gap-1">
{values.map((value) => {
const active = selected.includes(value)
return (
<button
key={value}
type="button"
role="checkbox"
aria-checked={active}
aria-label={getLabel(value)}
onClick={() =>
onChange(
active ? selected.filter((entry) => entry !== value) : [...selected, value]
)
}
className={cn(
'inline-flex h-6 items-center rounded-full border border-border px-2 text-[11px] font-medium text-muted-foreground transition-colors',
'hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring',
active && 'bg-accent text-accent-foreground'
)}
>
{getLabel(value)}
</button>
)
})}
</div>
</FacetField>
)
}
/** Single-choice segmented control for tri-state / presence / enum facets. */
export function FacetChoice<T extends string>({
label,
value,
options,
getLabel,
onChange
}: {
label: string
value: T
options: readonly T[]
getLabel?: (value: T) => string
onChange: (next: T) => void
}): React.JSX.Element {
return (
<FacetField label={label}>
<div className="flex gap-1" role="radiogroup" aria-label={label}>
{options.map((option) => (
<button
key={option}
type="button"
role="radio"
data-facet-value={option}
aria-checked={value === option}
aria-label={`${label}: ${getLabel ? getLabel(option) : option}`}
onClick={() => onChange(option)}
className={cn(
'inline-flex h-6 items-center rounded-md border border-border px-2 text-[11px] font-medium text-muted-foreground transition-colors',
'hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring',
value === option && 'bg-accent text-accent-foreground'
)}
>
{getLabel ? getLabel(option) : option}
</button>
))}
</div>
</FacetField>
)
}
/** Free-form numeric threshold — deliberately not a fixed 30/90 enum. */
export function FacetNumberField({
label,
value,
placeholder,
suffix,
onChange
}: {
label: string
value: number | null
placeholder?: string
suffix?: string
onChange: (next: number | null) => void
}): React.JSX.Element {
return (
<FacetField label={label}>
<div className="flex items-center gap-1.5">
<Input
type="number"
inputMode="numeric"
min={0}
aria-label={label}
value={value === null ? '' : String(value)}
placeholder={placeholder}
onChange={(event) => onChange(parseFacetNumber(event.target.value))}
className="h-7 w-24 text-xs"
/>
{suffix ? <span className="text-[11px] text-muted-foreground">{suffix}</span> : null}
</div>
</FacetField>
)
}
export function FacetTextField({
label,
value,
placeholder,
onChange
}: {
label: string
value: string
placeholder?: string
onChange: (next: string) => void
}): React.JSX.Element {
return (
<FacetField label={label}>
<Input
aria-label={label}
value={value}
placeholder={placeholder}
onChange={(event) => onChange(event.target.value)}
className="h-7 text-xs"
/>
</FacetField>
)
}
export function FacetCheckbox({
label,
checked,
onChange
}: {
label: string
checked: boolean
onChange: (next: boolean) => void
}): React.JSX.Element {
const id = `workspace-cleanup-facet-${label.replace(/\W+/g, '-').toLowerCase()}`
return (
<div className="flex items-center gap-2">
<Checkbox
id={id}
aria-label={label}
checked={checked}
onCheckedChange={(next) => onChange(next === true)}
/>
<Label htmlFor={id} className="cursor-pointer text-xs font-normal text-foreground">
{label}
</Label>
</div>
)
}
function FacetField({
label,
children
}: {
label?: string
children: React.ReactNode
}): React.JSX.Element {
if (!label) {
return <>{children}</>
}
return (
<div className="space-y-1">
<div className="text-[11px] text-muted-foreground">{label}</div>
{children}
</div>
)
}
export function parseFacetNumber(raw: string): number | null {
const trimmed = raw.trim()
if (trimmed === '') {
return null
}
const parsed = Number(trimmed)
return Number.isFinite(parsed) && parsed >= 0 ? parsed : null
}
@@ -0,0 +1,400 @@
import { describe, expect, it } from 'vitest'
import {
buildWorkspaceCleanupFacetList,
buildWorkspaceCleanupSizeIndex,
buildWorkspaceCleanupWorktreeIndex
} from './workspace-cleanup-facets'
import {
DAY,
FACET_NOW,
makeFacetCandidate,
makeFacets,
makeNamedFacets
} from './workspace-cleanup-facet.test.fixture'
import {
createDefaultWorkspaceCleanupFilterState,
type WorkspaceCleanupFilterState
} from '../../../../shared/workspace-cleanup-filter-model'
import {
countWorkspaceCleanupFacetMatches,
filterWorkspaceCleanupFacets,
matchesWorkspaceCleanupFilters,
queryWorkspaceCleanupCandidates
} from './workspace-cleanup-query'
import type { WorkspaceSpaceWorktree } from '../../../../shared/workspace-space-types'
function filters(patch: (state: WorkspaceCleanupFilterState) => void): WorkspaceCleanupFilterState {
const state = createDefaultWorkspaceCleanupFilterState()
patch(state)
return state
}
const ANY = filters((state) => {
state.safety.dismissed = 'any'
})
describe('facet building', () => {
it('strips the ref prefix and falls back to local host', () => {
const facets = makeFacets({ worktree: { branch: 'refs/heads/feature/x' } })
expect(facets.branch).toBe('feature/x')
expect(facets.hostId).toBe('local')
})
it('treats a missing size entry as unsized rather than zero', () => {
expect(makeFacets().sizeBytes).toBeNull()
expect(makeFacets({ sizeBytes: 0 }).sizeBytes).toBe(0)
})
it('treats a missing visit stamp as never opened', () => {
expect(makeFacets().lastVisitedAt).toBeNull()
expect(makeFacets({ lastVisitedAt: FACET_NOW }).lastVisitedAt).toBe(FACET_NOW)
})
it('only trusts sizes from an ok space scan', () => {
const scanned = [
{ worktreeId: 'a', status: 'ok', sizeBytes: 10 },
{ worktreeId: 'b', status: 'permission-denied', sizeBytes: 99 }
] as WorkspaceSpaceWorktree[]
const index = buildWorkspaceCleanupSizeIndex(scanned)
expect(index.get('a')).toBe(10)
expect(index.has('b')).toBe(false)
})
it('indexes worktrees across repos by id', () => {
const index = buildWorkspaceCleanupWorktreeIndex({
'repo-1': [{ id: 'w1' }],
'repo-2': [{ id: 'w2' }]
})
expect([...index.keys()]).toEqual(['w1', 'w2'])
})
it('counts a workspace with nothing attached as completely empty', () => {
expect(makeFacets().isCompletelyEmpty).toBe(true)
expect(makeFacets({ worktree: { comment: 'keep' } }).isCompletelyEmpty).toBe(false)
expect(makeFacets({ review: {} }).isCompletelyEmpty).toBe(false)
expect(makeFacets({ worktree: { linkedIssue: 12 } }).isCompletelyEmpty).toBe(false)
})
})
describe('query search', () => {
it('matches branch, host, status, provider, and blockers', () => {
const facets = makeFacets({
candidate: { blockers: ['live-agent'] },
worktree: {
branch: 'refs/heads/fix/login',
hostId: 'ssh:builder',
workspaceStatus: 'in-review'
},
review: { provider: 'gitlab', label: 'MR #7' }
})
for (const term of ['fix/login', 'ssh:builder', 'in-review', 'gitlab', 'live-agent']) {
expect(matchesWorkspaceCleanupFilters(facets, { ...ANY, query: term }, FACET_NOW)).toBe(true)
}
expect(matchesWorkspaceCleanupFilters(facets, { ...ANY, query: 'nope' }, FACET_NOW)).toBe(false)
})
})
describe('activity filter', () => {
it('uses the user-chosen day threshold against the chosen signal', () => {
const recentlyVisited = makeFacets({ lastVisitedAt: FACET_NOW - 5 * DAY })
const staleVisit = makeFacets({ lastVisitedAt: FACET_NOW - 45 * DAY })
const state = filters((s) => {
s.safety.dismissed = 'any'
s.activity.idleSignal = 'last-visited'
s.activity.idleMinDays = 30
})
expect(matchesWorkspaceCleanupFilters(recentlyVisited, state, FACET_NOW)).toBe(false)
expect(matchesWorkspaceCleanupFilters(staleVisit, state, FACET_NOW)).toBe(true)
})
it('reads a missing signal as maximally idle', () => {
const state = filters((s) => {
s.safety.dismissed = 'any'
s.activity.idleMinDays = 365
})
expect(matchesWorkspaceCleanupFilters(makeFacets(), state, FACET_NOW)).toBe(true)
})
it('separates background activity from a real visit', () => {
const busyButUnopened = makeFacets({ candidate: { lastActivityAt: FACET_NOW } })
const state = filters((s) => {
s.safety.dismissed = 'any'
s.activity.neverVisited = true
})
expect(matchesWorkspaceCleanupFilters(busyButUnopened, state, FACET_NOW)).toBe(true)
expect(
matchesWorkspaceCleanupFilters(makeFacets({ lastVisitedAt: FACET_NOW }), state, FACET_NOW)
).toBe(false)
})
})
describe('size filter', () => {
it('keeps unsized rows unless the user opts out', () => {
const unsized = makeFacets()
expect(matchesWorkspaceCleanupFilters(unsized, ANY, FACET_NOW)).toBe(true)
const state = filters((s) => {
s.safety.dismissed = 'any'
s.size.includeUnsized = false
})
expect(matchesWorkspaceCleanupFilters(unsized, state, FACET_NOW)).toBe(false)
})
it('applies min and max byte bounds', () => {
const state = filters((s) => {
s.safety.dismissed = 'any'
s.size.minBytes = 100
s.size.maxBytes = 200
})
expect(matchesWorkspaceCleanupFilters(makeFacets({ sizeBytes: 150 }), state, FACET_NOW)).toBe(
true
)
expect(matchesWorkspaceCleanupFilters(makeFacets({ sizeBytes: 99 }), state, FACET_NOW)).toBe(
false
)
expect(matchesWorkspaceCleanupFilters(makeFacets({ sizeBytes: 201 }), state, FACET_NOW)).toBe(
false
)
})
})
describe('status filter', () => {
it('matches open workspace-status strings and statusless rows separately', () => {
const inReview = makeFacets({ worktree: { workspaceStatus: 'in-review' } })
const statusless = makeFacets()
const state = filters((s) => {
s.safety.dismissed = 'any'
s.status.workspaceStatuses = ['in-review']
s.status.matchStatusless = false
})
expect(matchesWorkspaceCleanupFilters(inReview, state, FACET_NOW)).toBe(true)
expect(matchesWorkspaceCleanupFilters(statusless, state, FACET_NOW)).toBe(false)
state.status.matchStatusless = true
expect(matchesWorkspaceCleanupFilters(statusless, state, FACET_NOW)).toBe(true)
})
it('applies tri-state flags', () => {
const archived = makeFacets({ worktree: { isArchived: true } })
const only = filters((s) => {
s.safety.dismissed = 'any'
s.status.archived = 'only'
})
const exclude = filters((s) => {
s.safety.dismissed = 'any'
s.status.archived = 'exclude'
})
expect(matchesWorkspaceCleanupFilters(archived, only, FACET_NOW)).toBe(true)
expect(matchesWorkspaceCleanupFilters(archived, exclude, FACET_NOW)).toBe(false)
expect(matchesWorkspaceCleanupFilters(makeFacets(), exclude, FACET_NOW)).toBe(true)
})
})
describe('agent, git, review, ticket, context, and location filters', () => {
it('filters by live agent rollup and retained done agents', () => {
const state = filters((s) => {
s.safety.dismissed = 'any'
s.agent.states = ['permission']
})
expect(
matchesWorkspaceCleanupFilters(makeFacets({ agentStatus: 'permission' }), state, FACET_NOW)
).toBe(true)
expect(
matchesWorkspaceCleanupFilters(makeFacets({ agentStatus: 'working' }), state, FACET_NOW)
).toBe(false)
expect(makeFacets().agentState).toBe('idle')
const retained = filters((s) => {
s.safety.dismissed = 'any'
s.agent.retainedDoneAgents = 'only'
})
const withRetained = makeFacets({
candidate: {
localContext: {
...makeFacetCandidate().localContext,
retainedDoneAgentCount: 2
}
}
})
expect(matchesWorkspaceCleanupFilters(withRetained, retained, FACET_NOW)).toBe(true)
expect(matchesWorkspaceCleanupFilters(makeFacets(), retained, FACET_NOW)).toBe(false)
})
it('filters by git state, ahead counts, branch text, prunable and locked', () => {
const unpushed = makeFacets({
candidate: { git: { clean: true, upstreamAhead: 3, upstreamBehind: 1, checkedAt: 1 } }
})
expect(unpushed.gitState).toBe('unpushed')
const state = filters((s) => {
s.safety.dismissed = 'any'
s.git.states = ['unpushed']
s.git.minAhead = 2
})
expect(matchesWorkspaceCleanupFilters(unpushed, state, FACET_NOW)).toBe(true)
state.git.minAhead = 4
expect(matchesWorkspaceCleanupFilters(unpushed, state, FACET_NOW)).toBe(false)
const branchState = filters((s) => {
s.safety.dismissed = 'any'
s.git.branchQuery = 'ALPH'
})
expect(matchesWorkspaceCleanupFilters(makeFacets(), branchState, FACET_NOW)).toBe(true)
const prunable = filters((s) => {
s.safety.dismissed = 'any'
s.git.prunable = 'only'
})
expect(
matchesWorkspaceCleanupFilters(
makeFacets({ worktree: { prunable: true } }),
prunable,
FACET_NOW
)
).toBe(true)
expect(matchesWorkspaceCleanupFilters(makeFacets(), prunable, FACET_NOW)).toBe(false)
})
it('keeps review filtering provider-general and treats draft as a state', () => {
const draftMr = makeFacets({ review: { provider: 'gitlab', state: 'draft', label: 'MR #7' } })
const state = filters((s) => {
s.safety.dismissed = 'any'
s.review.presence = 'some'
s.review.states = ['draft']
s.review.providers = ['gitlab', 'bitbucket']
})
expect(matchesWorkspaceCleanupFilters(draftMr, state, FACET_NOW)).toBe(true)
state.review.providers = ['github']
expect(matchesWorkspaceCleanupFilters(draftMr, state, FACET_NOW)).toBe(false)
const none = filters((s) => {
s.safety.dismissed = 'any'
s.review.presence = 'none'
})
expect(matchesWorkspaceCleanupFilters(makeFacets(), none, FACET_NOW)).toBe(true)
expect(matchesWorkspaceCleanupFilters(draftMr, none, FACET_NOW)).toBe(false)
})
it('filters by ticket source', () => {
const linear = makeFacets({ worktree: { linkedLinearIssue: 'STA-1' } })
expect(linear.ticketSources).toEqual(['linear'])
const state = filters((s) => {
s.safety.dismissed = 'any'
s.ticket.presence = 'some'
s.ticket.sources = ['linear']
})
expect(matchesWorkspaceCleanupFilters(linear, state, FACET_NOW)).toBe(true)
expect(
matchesWorkspaceCleanupFilters(makeFacets({ worktree: { linkedIssue: 9 } }), state, FACET_NOW)
).toBe(false)
})
it('filters by local context and completely-empty', () => {
const busy = makeFacets({
candidate: {
localContext: { ...makeFacetCandidate().localContext, terminalTabCount: 2 }
}
})
const hasContext = filters((s) => {
s.safety.dismissed = 'any'
s.context.presence = 'some'
})
expect(matchesWorkspaceCleanupFilters(busy, hasContext, FACET_NOW)).toBe(true)
const empty = filters((s) => {
s.safety.dismissed = 'any'
s.context.completelyEmpty = true
})
expect(matchesWorkspaceCleanupFilters(busy, empty, FACET_NOW)).toBe(false)
expect(matchesWorkspaceCleanupFilters(makeFacets(), empty, FACET_NOW)).toBe(true)
})
it('filters by execution host, repo, and path prefix', () => {
const remote = makeFacets({ worktree: { hostId: 'ssh:builder' } })
const state = filters((s) => {
s.safety.dismissed = 'any'
s.location.hostIds = ['ssh:builder']
s.location.repoIds = ['repo-1']
s.location.pathPrefix = '/repo/'
})
expect(matchesWorkspaceCleanupFilters(remote, state, FACET_NOW)).toBe(true)
state.location.pathPrefix = '/other/'
expect(matchesWorkspaceCleanupFilters(remote, state, FACET_NOW)).toBe(false)
expect(matchesWorkspaceCleanupFilters(makeFacets(), state, FACET_NOW)).toBe(false)
})
})
describe('safety filter', () => {
it('supports any-of and none-of blocker selection', () => {
const live = makeFacets({
candidate: { blockers: ['live-agent', 'pinned'], tier: 'protected' }
})
const anyOf = filters((s) => {
s.safety.dismissed = 'any'
s.safety.blockers = ['live-agent']
s.safety.blockerMode = 'any-of'
})
const noneOf = filters((s) => {
s.safety.dismissed = 'any'
s.safety.blockers = ['live-agent']
s.safety.blockerMode = 'none-of'
})
expect(matchesWorkspaceCleanupFilters(live, anyOf, FACET_NOW)).toBe(true)
expect(matchesWorkspaceCleanupFilters(live, noneOf, FACET_NOW)).toBe(false)
expect(matchesWorkspaceCleanupFilters(makeFacets(), noneOf, FACET_NOW)).toBe(true)
})
it('excludes dismissed rows by default and surfaces them on demand', () => {
const dismissed = makeFacets({ dismissed: true })
expect(
matchesWorkspaceCleanupFilters(
dismissed,
createDefaultWorkspaceCleanupFilterState(),
FACET_NOW
)
).toBe(false)
const only = filters((s) => {
s.safety.dismissed = 'only'
})
expect(matchesWorkspaceCleanupFilters(dismissed, only, FACET_NOW)).toBe(true)
expect(matchesWorkspaceCleanupFilters(makeFacets(), only, FACET_NOW)).toBe(false)
})
it('restricts to rows the policy would actually let the user delete', () => {
const blocked = makeFacets({ candidate: { blockers: ['dirty-files'], tier: 'review' } })
const state = filters((s) => {
s.safety.dismissed = 'any'
s.safety.selectableOnly = true
})
expect(matchesWorkspaceCleanupFilters(blocked, state, FACET_NOW)).toBe(false)
expect(matchesWorkspaceCleanupFilters(makeFacets(), state, FACET_NOW)).toBe(true)
})
})
describe('query pipeline', () => {
it('reports counts and selectable ids', () => {
const rows = [
makeNamedFacets('alpha'),
makeNamedFacets('beta', { candidate: { blockers: ['dirty-files'], tier: 'review' } })
]
expect(filterWorkspaceCleanupFacets(rows, ANY, FACET_NOW)).toHaveLength(2)
const result = queryWorkspaceCleanupCandidates(
buildWorkspaceCleanupFacetList([makeFacetCandidate()]).map((f) => f.candidate),
{ filters: ANY, sort: { field: 'name', direction: 'asc' } },
{},
FACET_NOW
)
expect(result.totalCount).toBe(1)
expect(result.matchedCount).toBe(1)
expect(result.selectableWorktreeIds).toEqual(['repo-1::/repo/alpha'])
})
it('counts per-facet matches independently of the other groups', () => {
const rows = [makeNamedFacets('alpha', { sizeBytes: 10 }), makeNamedFacets('beta')]
const state = filters((s) => {
s.safety.dismissed = 'any'
s.size.includeUnsized = false
s.git.states = ['dirty']
})
const counts = countWorkspaceCleanupFacetMatches(rows, state, FACET_NOW)
expect(counts.size).toBe(1)
expect(counts.git).toBe(0)
expect(counts.safety).toBe(2)
})
})
@@ -0,0 +1,168 @@
import { translate } from '@/i18n/i18n'
import type { HostedReviewProvider } from '../../../../shared/hosted-review'
import type { WorkspaceCleanupTier } from '../../../../shared/workspace-cleanup'
import type {
WorkspaceCleanupAgentState,
WorkspaceCleanupGitState,
WorkspaceCleanupIdleSignal,
WorkspaceCleanupPresence,
WorkspaceCleanupReviewState,
WorkspaceCleanupSortField,
WorkspaceCleanupTicketSource,
WorkspaceCleanupTriState
} from '../../../../shared/workspace-cleanup-filter-model'
export function getWorkspaceCleanupSortFieldLabel(field: WorkspaceCleanupSortField): string {
switch (field) {
case 'last-activity':
return translate('components.workspace.cleanup.browse.sort.lastActivity', 'Last activity')
case 'last-visited':
return translate('components.workspace.cleanup.browse.sort.lastVisited', 'Last opened')
case 'created':
return translate('components.workspace.cleanup.browse.sort.created', 'Created')
case 'size':
return translate('components.workspace.cleanup.browse.sort.size', 'Size')
case 'name':
return translate('components.workspace.cleanup.browse.sort.name', 'Workspace')
case 'repo':
return translate('components.workspace.cleanup.browse.sort.repo', 'Repository')
case 'path':
return translate('components.workspace.cleanup.browse.sort.path', 'Path')
case 'host':
return translate('components.workspace.cleanup.browse.sort.host', 'Host')
case 'workspace-status':
return translate('components.workspace.cleanup.browse.sort.workspaceStatus', 'Status')
case 'agent':
return translate('components.workspace.cleanup.browse.sort.agent', 'Agent')
case 'git':
return translate('components.workspace.cleanup.browse.sort.git', 'Git')
case 'ahead':
return translate('components.workspace.cleanup.browse.sort.ahead', 'Ahead')
case 'behind':
return translate('components.workspace.cleanup.browse.sort.behind', 'Behind')
case 'branch':
return translate('components.workspace.cleanup.browse.sort.branch', 'Branch')
case 'review':
return translate('components.workspace.cleanup.browse.sort.review', 'Review')
case 'ticket':
return translate('components.workspace.cleanup.browse.sort.ticket', 'Ticket')
case 'local-context':
return translate('components.workspace.cleanup.browse.sort.localContext', 'Open context')
case 'tier':
return translate('components.workspace.cleanup.browse.sort.tier', 'Safety')
case 'blocker-count':
return translate('components.workspace.cleanup.browse.sort.blockerCount', 'Blockers')
}
}
export function getWorkspaceCleanupGitStateLabel(state: WorkspaceCleanupGitState): string {
switch (state) {
case 'clean':
return translate('components.workspace.cleanup.browse.git.clean', 'Clean')
case 'dirty':
return translate('components.workspace.cleanup.browse.git.dirty', 'Uncommitted changes')
case 'unpushed':
return translate('components.workspace.cleanup.browse.git.unpushed', 'Unpushed commits')
case 'unknown':
return translate('components.workspace.cleanup.browse.git.unknown', 'Not checked')
}
}
export function getWorkspaceCleanupAgentStateLabel(state: WorkspaceCleanupAgentState): string {
switch (state) {
case 'working':
return translate('components.workspace.cleanup.browse.agent.working', 'Working')
case 'permission':
return translate('components.workspace.cleanup.browse.agent.permission', 'Waiting on you')
case 'idle':
return translate('components.workspace.cleanup.browse.agent.idle', 'Idle')
}
}
export function getWorkspaceCleanupReviewStateLabel(state: WorkspaceCleanupReviewState): string {
switch (state) {
case 'open':
return translate('components.workspace.cleanup.browse.review.open', 'Open')
case 'draft':
return translate('components.workspace.cleanup.browse.review.draft', 'Draft')
case 'merged':
return translate('components.workspace.cleanup.browse.review.merged', 'Merged')
case 'closed':
return translate('components.workspace.cleanup.browse.review.closed', 'Closed')
case 'unknown':
return translate('components.workspace.cleanup.browse.review.unknown', 'Unknown')
}
}
/** Provider-general: GitLab MRs and Azure DevOps PRs are first-class here. */
export function getWorkspaceCleanupReviewProviderLabel(provider: HostedReviewProvider): string {
switch (provider) {
case 'github':
return 'GitHub'
case 'gitlab':
return 'GitLab'
case 'bitbucket':
return 'Bitbucket'
case 'azure-devops':
return 'Azure DevOps'
case 'gitea':
return 'Gitea'
case 'unsupported':
return translate('components.workspace.cleanup.browse.review.otherProvider', 'Other')
}
}
export function getWorkspaceCleanupTicketSourceLabel(source: WorkspaceCleanupTicketSource): string {
switch (source) {
case 'work-item':
return translate('components.workspace.cleanup.browse.ticket.workItem', 'Work item')
case 'linear':
return translate('components.workspace.cleanup.browse.ticket.linear', 'Linear')
case 'issue':
return translate('components.workspace.cleanup.browse.ticket.issue', 'Issue')
}
}
export function getWorkspaceCleanupTierLabel(tier: WorkspaceCleanupTier): string {
switch (tier) {
case 'ready':
return translate('components.workspace.cleanup.browse.tier.ready', 'Ready')
case 'review':
return translate('components.workspace.cleanup.browse.tier.review', 'Needs review')
case 'protected':
return translate('components.workspace.cleanup.browse.tier.protected', 'Protected')
}
}
export function getWorkspaceCleanupIdleSignalLabel(signal: WorkspaceCleanupIdleSignal): string {
switch (signal) {
case 'last-visited':
return translate('components.workspace.cleanup.browse.idleSignal.lastVisited', 'Not opened')
case 'last-activity':
return translate('components.workspace.cleanup.browse.idleSignal.lastActivity', 'No activity')
case 'created':
return translate('components.workspace.cleanup.browse.idleSignal.created', 'Created before')
}
}
export function getWorkspaceCleanupTriStateLabel(value: WorkspaceCleanupTriState): string {
switch (value) {
case 'any':
return translate('components.workspace.cleanup.browse.triState.any', 'Any')
case 'only':
return translate('components.workspace.cleanup.browse.triState.only', 'Only')
case 'exclude':
return translate('components.workspace.cleanup.browse.triState.exclude', 'Exclude')
}
}
export function getWorkspaceCleanupPresenceLabel(value: WorkspaceCleanupPresence): string {
switch (value) {
case 'any':
return translate('components.workspace.cleanup.browse.presence.any', 'Any')
case 'some':
return translate('components.workspace.cleanup.browse.presence.some', 'Has one')
case 'none':
return translate('components.workspace.cleanup.browse.presence.none', 'Has none')
}
}
@@ -0,0 +1,211 @@
import type { WorkspaceCleanupFacets } from './workspace-cleanup-facets'
import type {
WorkspaceCleanupActivityFilter,
WorkspaceCleanupAgentFilter,
WorkspaceCleanupContextFilterState,
WorkspaceCleanupGitFilterState,
WorkspaceCleanupLocationFilter,
WorkspaceCleanupPresence,
WorkspaceCleanupReviewFilterState,
WorkspaceCleanupSafetyFilter,
WorkspaceCleanupSizeFilter,
WorkspaceCleanupStatusFilter,
WorkspaceCleanupTicketFilter,
WorkspaceCleanupTriState
} from '../../../../shared/workspace-cleanup-filter-model'
export const WORKSPACE_CLEANUP_DAY_MS = 24 * 60 * 60 * 1000
export function matchesWorkspaceCleanupTriState(
filter: WorkspaceCleanupTriState,
value: boolean
): boolean {
return filter === 'any' || (filter === 'only' ? value : !value)
}
export function matchesWorkspaceCleanupPresence(
filter: WorkspaceCleanupPresence,
value: boolean
): boolean {
return filter === 'any' || (filter === 'some' ? value : !value)
}
export function matchesWorkspaceCleanupActivity(
facets: WorkspaceCleanupFacets,
filter: WorkspaceCleanupActivityFilter,
now: number
): boolean {
if (filter.neverVisited && facets.lastVisitedAt !== null) {
return false
}
if (filter.idleMinDays === null) {
return true
}
const signalAt = getIdleSignalAt(facets, filter)
// Why: a missing signal means Orca has no evidence of recent use, so it
// reads as maximally idle rather than being filtered out.
return signalAt === null || now - signalAt >= filter.idleMinDays * WORKSPACE_CLEANUP_DAY_MS
}
export function matchesWorkspaceCleanupSize(
facets: WorkspaceCleanupFacets,
filter: WorkspaceCleanupSizeFilter
): boolean {
if (facets.sizeBytes === null) {
return filter.includeUnsized
}
if (filter.minBytes !== null && facets.sizeBytes < filter.minBytes) {
return false
}
return filter.maxBytes === null || facets.sizeBytes <= filter.maxBytes
}
export function matchesWorkspaceCleanupStatus(
facets: WorkspaceCleanupFacets,
filter: WorkspaceCleanupStatusFilter
): boolean {
if (filter.workspaceStatuses.length > 0 && !matchesWorkspaceStatusList(facets, filter)) {
return false
}
return (
matchesWorkspaceCleanupTriState(filter.archived, facets.isArchived) &&
matchesWorkspaceCleanupTriState(filter.pinned, facets.isPinned) &&
matchesWorkspaceCleanupTriState(filter.unread, facets.isUnread) &&
matchesWorkspaceCleanupTriState(filter.comment, facets.hasComment)
)
}
export function matchesWorkspaceCleanupAgent(
facets: WorkspaceCleanupFacets,
filter: WorkspaceCleanupAgentFilter
): boolean {
if (filter.states.length > 0 && !filter.states.includes(facets.agentState)) {
return false
}
return matchesWorkspaceCleanupTriState(
filter.retainedDoneAgents,
facets.retainedDoneAgentCount > 0
)
}
export function matchesWorkspaceCleanupGit(
facets: WorkspaceCleanupFacets,
filter: WorkspaceCleanupGitFilterState
): boolean {
if (filter.states.length > 0 && !filter.states.includes(facets.gitState)) {
return false
}
if (filter.minAhead !== null && (facets.upstreamAhead ?? 0) < filter.minAhead) {
return false
}
if (filter.minBehind !== null && (facets.upstreamBehind ?? 0) < filter.minBehind) {
return false
}
const branchQuery = filter.branchQuery.trim().toLowerCase()
if (branchQuery && !facets.branch.toLowerCase().includes(branchQuery)) {
return false
}
return (
matchesWorkspaceCleanupTriState(filter.prunable, facets.isPrunable) &&
matchesWorkspaceCleanupTriState(filter.locked, facets.isLocked)
)
}
export function matchesWorkspaceCleanupReview(
facets: WorkspaceCleanupFacets,
filter: WorkspaceCleanupReviewFilterState
): boolean {
if (!matchesWorkspaceCleanupPresence(filter.presence, facets.review.hasReview)) {
return false
}
if (filter.states.length > 0) {
if (facets.reviewState === null || !filter.states.includes(facets.reviewState)) {
return false
}
}
if (filter.providers.length === 0) {
return true
}
return facets.review.provider !== null && filter.providers.includes(facets.review.provider)
}
export function matchesWorkspaceCleanupTicket(
facets: WorkspaceCleanupFacets,
filter: WorkspaceCleanupTicketFilter
): boolean {
if (!matchesWorkspaceCleanupPresence(filter.presence, facets.ticketSources.length > 0)) {
return false
}
return (
filter.sources.length === 0 ||
filter.sources.some((source) => facets.ticketSources.includes(source))
)
}
export function matchesWorkspaceCleanupContext(
facets: WorkspaceCleanupFacets,
filter: WorkspaceCleanupContextFilterState
): boolean {
if (!matchesWorkspaceCleanupPresence(filter.presence, facets.hasLocalContext)) {
return false
}
return !filter.completelyEmpty || facets.isCompletelyEmpty
}
export function matchesWorkspaceCleanupLocation(
facets: WorkspaceCleanupFacets,
filter: WorkspaceCleanupLocationFilter
): boolean {
if (filter.hostIds.length > 0 && !filter.hostIds.includes(facets.hostId)) {
return false
}
if (filter.repoIds.length > 0 && !filter.repoIds.includes(facets.repoId)) {
return false
}
const prefix = filter.pathPrefix.trim()
return prefix.length === 0 || facets.path.startsWith(prefix)
}
export function matchesWorkspaceCleanupSafety(
facets: WorkspaceCleanupFacets,
filter: WorkspaceCleanupSafetyFilter
): boolean {
if (filter.tiers.length > 0 && !filter.tiers.includes(facets.tier)) {
return false
}
if (!matchesWorkspaceCleanupTriState(filter.dismissed, facets.isDismissed)) {
return false
}
if (filter.selectableOnly && !facets.isSelectable) {
return false
}
if (filter.blockers.length === 0) {
return true
}
const hit = filter.blockers.some((blocker) => facets.blockers.includes(blocker))
return filter.blockerMode === 'any-of' ? hit : !hit
}
function matchesWorkspaceStatusList(
facets: WorkspaceCleanupFacets,
filter: WorkspaceCleanupStatusFilter
): boolean {
if (facets.workspaceStatus === null) {
return filter.matchStatusless
}
return filter.workspaceStatuses.includes(facets.workspaceStatus)
}
function getIdleSignalAt(
facets: WorkspaceCleanupFacets,
filter: WorkspaceCleanupActivityFilter
): number | null {
switch (filter.idleSignal) {
case 'last-visited':
return facets.lastVisitedAt
case 'last-activity':
return facets.lastActivityAt
case 'created':
return facets.createdAt
}
}
@@ -0,0 +1,37 @@
import type { ExecutionHostId } from '../../../../shared/execution-host'
import type { HostedReviewProvider } from '../../../../shared/hosted-review'
import type { WorkspaceCleanupFilterState } from '../../../../shared/workspace-cleanup-filter-model'
export type WorkspaceCleanupFacetCounts = Record<
keyof Omit<WorkspaceCleanupFilterState, 'query'>,
number
>
/** Facet values discovered from the loaded fleet, not a hardcoded enum. */
export type WorkspaceCleanupFacetOptions = {
workspaceStatuses: readonly { id: string; label: string }[]
hostIds: readonly ExecutionHostId[]
repos: readonly { id: string; label: string }[]
reviewProviders: readonly HostedReviewProvider[]
}
export type WorkspaceCleanupFacetGroupProps = {
filters: WorkspaceCleanupFilterState
counts: WorkspaceCleanupFacetCounts
totalCount: number
options: WorkspaceCleanupFacetOptions
onPatch: <K extends keyof WorkspaceCleanupFilterState>(
key: K,
value: Partial<WorkspaceCleanupFilterState[K]> | WorkspaceCleanupFilterState[K]
) => void
}
export const WORKSPACE_CLEANUP_MEGABYTE = 1024 * 1024
export function toMegabytes(bytes: number | null): number | null {
return bytes === null ? null : Math.round(bytes / WORKSPACE_CLEANUP_MEGABYTE)
}
export function fromMegabytes(megabytes: number | null): number | null {
return megabytes === null ? null : Math.round(megabytes * WORKSPACE_CLEANUP_MEGABYTE)
}
@@ -0,0 +1,140 @@
import { describe, expect, it } from 'vitest'
import { DAY, FACET_NOW, makeNamedFacets } from './workspace-cleanup-facet.test.fixture'
import {
isWorkspaceCleanupAbsentLastSortField,
sortWorkspaceCleanupFacets
} from './workspace-cleanup-facet-sort'
import type { WorkspaceCleanupSortField } from '../../../../shared/workspace-cleanup-filter-model'
function names(
rows: ReturnType<typeof makeNamedFacets>[],
field: WorkspaceCleanupSortField,
direction: 'asc' | 'desc'
): string[] {
return sortWorkspaceCleanupFacets(rows, { field, direction }).map((row) => row.displayName)
}
describe('absent values', () => {
it('sinks unsized workspaces to the bottom in both directions', () => {
const rows = [
makeNamedFacets('unsized'),
makeNamedFacets('small', { sizeBytes: 10 }),
makeNamedFacets('big', { sizeBytes: 900 })
]
expect(names(rows, 'size', 'desc')).toEqual(['big', 'small', 'unsized'])
expect(names(rows, 'size', 'asc')).toEqual(['small', 'big', 'unsized'])
})
it('sinks never-visited workspaces to the bottom in both directions', () => {
const rows = [
makeNamedFacets('never'),
makeNamedFacets('old', { lastVisitedAt: FACET_NOW - 50 * DAY }),
makeNamedFacets('recent', { lastVisitedAt: FACET_NOW })
]
expect(names(rows, 'last-visited', 'asc')).toEqual(['old', 'recent', 'never'])
expect(names(rows, 'last-visited', 'desc')).toEqual(['recent', 'old', 'never'])
})
it('sinks review-less and ticket-less workspaces to the bottom', () => {
const rows = [
makeNamedFacets('none'),
makeNamedFacets('merged', { review: { state: 'merged' } }),
makeNamedFacets('open', { review: { state: 'open' } })
]
expect(names(rows, 'review', 'desc')).toEqual(['open', 'merged', 'none'])
const ticketRows = [
makeNamedFacets('untracked'),
makeNamedFacets('linear', { worktree: { linkedLinearIssue: 'STA-1' } })
]
expect(names(ticketRows, 'ticket', 'asc')).toEqual(['linear', 'untracked'])
})
it('marks exactly the optional-value fields as absent-last', () => {
expect(isWorkspaceCleanupAbsentLastSortField('size')).toBe(true)
expect(isWorkspaceCleanupAbsentLastSortField('name')).toBe(false)
})
})
describe('ranked fields', () => {
it('orders git states from safest to riskiest', () => {
const rows = [
makeNamedFacets('unpushed', {
candidate: { git: { clean: true, upstreamAhead: 2, upstreamBehind: 0, checkedAt: 1 } }
}),
makeNamedFacets('clean'),
makeNamedFacets('dirty', {
candidate: { git: { clean: false, upstreamAhead: 0, upstreamBehind: 0, checkedAt: 1 } }
}),
makeNamedFacets('unknown', {
candidate: { git: { clean: null, upstreamAhead: 0, upstreamBehind: 0, checkedAt: null } }
})
]
expect(names(rows, 'git', 'asc')).toEqual(['clean', 'unknown', 'dirty', 'unpushed'])
})
it('orders tiers ready to protected and agents idle to permission', () => {
const tierRows = [
makeNamedFacets('protectedRow', { candidate: { tier: 'protected' } }),
makeNamedFacets('readyRow', { candidate: { tier: 'ready' } }),
makeNamedFacets('reviewRow', { candidate: { tier: 'review' } })
]
expect(names(tierRows, 'tier', 'asc')).toEqual(['readyRow', 'reviewRow', 'protectedRow'])
const agentRows = [
makeNamedFacets('permission', { agentStatus: 'permission' }),
makeNamedFacets('idle'),
makeNamedFacets('working', { agentStatus: 'working' })
]
expect(names(agentRows, 'agent', 'desc')).toEqual(['permission', 'working', 'idle'])
})
it('breaks equal blocker counts by the worst blocker', () => {
const rows = [
makeNamedFacets('softest', { candidate: { blockers: ['git-status-error'] } }),
makeNamedFacets('hardest', { candidate: { blockers: ['main-worktree'] } })
]
expect(names(rows, 'blocker-count', 'desc')).toEqual(['hardest', 'softest'])
})
})
describe('tie-breaks', () => {
it('falls back to activity, repo, name, then worktree id', () => {
const rows = [
makeNamedFacets('zeta', { candidate: { lastActivityAt: FACET_NOW - DAY } }),
makeNamedFacets('alpha', { candidate: { lastActivityAt: FACET_NOW - 2 * DAY } })
]
// Same size on both sides, so only the tie-break chain can order them.
expect(names(rows, 'size', 'desc')).toEqual(['alpha', 'zeta'])
})
it('keeps tie-broken rows in the same order when direction flips', () => {
const rows = [
makeNamedFacets('b', { candidate: { lastActivityAt: FACET_NOW - DAY } }),
makeNamedFacets('a', { candidate: { lastActivityAt: FACET_NOW - 2 * DAY } })
]
expect(names(rows, 'git', 'asc')).toEqual(names(rows, 'git', 'desc'))
})
it('does not mutate the input array', () => {
const rows = [makeNamedFacets('b'), makeNamedFacets('a')]
const before = rows.map((row) => row.displayName)
sortWorkspaceCleanupFacets(rows, { field: 'name', direction: 'asc' })
expect(rows.map((row) => row.displayName)).toEqual(before)
})
})
describe('text fields', () => {
it('sorts by name, repo, path, branch, and host', () => {
const rows = [makeNamedFacets('charlie'), makeNamedFacets('alpha'), makeNamedFacets('bravo')]
expect(names(rows, 'name', 'asc')).toEqual(['alpha', 'bravo', 'charlie'])
expect(names(rows, 'path', 'desc')).toEqual(['charlie', 'bravo', 'alpha'])
expect(names(rows, 'branch', 'asc')).toEqual(['alpha', 'bravo', 'charlie'])
const hostRows = [
makeNamedFacets('remote', { worktree: { hostId: 'ssh:builder' } }),
makeNamedFacets('here', { worktree: { hostId: 'local' } })
]
expect(names(hostRows, 'host', 'asc')).toEqual(['here', 'remote'])
})
})
@@ -0,0 +1,190 @@
import type { WorkspaceCleanupFacets } from './workspace-cleanup-facets'
import {
getWorkspaceCleanupAgentRank,
getWorkspaceCleanupBlockerSeverity,
getWorkspaceCleanupGitRank,
getWorkspaceCleanupReviewRank,
getWorkspaceCleanupTierRank
} from '../../../../shared/workspace-cleanup-facet-rankings'
import type {
WorkspaceCleanupSortDirectionState,
WorkspaceCleanupSortField,
WorkspaceCleanupSortState
} from '../../../../shared/workspace-cleanup-filter-model'
/**
* Fields whose value can be absent. Absent rows always sink to the bottom in
* BOTH directions — flipping direction should reorder the rows that carry the
* signal, not promote the ones that carry nothing.
*/
const ABSENT_LAST_FIELDS: ReadonlySet<WorkspaceCleanupSortField> = new Set([
'size',
'last-visited',
'created',
'ahead',
'behind',
'workspace-status',
'review',
'ticket'
])
export function isWorkspaceCleanupAbsentLastSortField(field: WorkspaceCleanupSortField): boolean {
return ABSENT_LAST_FIELDS.has(field)
}
export function sortWorkspaceCleanupFacets(
facets: readonly WorkspaceCleanupFacets[],
sort: WorkspaceCleanupSortState
): WorkspaceCleanupFacets[] {
const multiplier = sort.direction === 'asc' ? 1 : -1
return [...facets].sort(
(left, right) =>
comparePrimary(left, right, sort.field, multiplier) || compareTieBreaks(left, right)
)
}
export function compareWorkspaceCleanupFacets(
left: WorkspaceCleanupFacets,
right: WorkspaceCleanupFacets,
field: WorkspaceCleanupSortField,
direction: WorkspaceCleanupSortDirectionState
): number {
const multiplier = direction === 'asc' ? 1 : -1
return comparePrimary(left, right, field, multiplier) || compareTieBreaks(left, right)
}
function comparePrimary(
left: WorkspaceCleanupFacets,
right: WorkspaceCleanupFacets,
field: WorkspaceCleanupSortField,
multiplier: number
): number {
const absence = compareAbsence(left, right, field)
if (absence !== 0) {
return absence
}
return compareNatural(left, right, field) * multiplier
}
/** Non-zero only when exactly one side is absent for an absent-last field. */
function compareAbsence(
left: WorkspaceCleanupFacets,
right: WorkspaceCleanupFacets,
field: WorkspaceCleanupSortField
): number {
if (!ABSENT_LAST_FIELDS.has(field)) {
return 0
}
const leftAbsent = isAbsent(left, field)
const rightAbsent = isAbsent(right, field)
if (leftAbsent === rightAbsent) {
return 0
}
return leftAbsent ? 1 : -1
}
function isAbsent(facets: WorkspaceCleanupFacets, field: WorkspaceCleanupSortField): boolean {
if (field === 'size') {
return facets.sizeBytes === null
}
if (field === 'last-visited') {
return facets.lastVisitedAt === null
}
if (field === 'created') {
return facets.createdAt === null
}
if (field === 'ahead') {
return facets.upstreamAhead === null
}
if (field === 'behind') {
return facets.upstreamBehind === null
}
if (field === 'workspace-status') {
return facets.workspaceStatus === null
}
if (field === 'review') {
return !facets.review.hasReview
}
if (field === 'ticket') {
return facets.ticketSources.length === 0
}
return false
}
function compareNatural(
left: WorkspaceCleanupFacets,
right: WorkspaceCleanupFacets,
field: WorkspaceCleanupSortField
): number {
switch (field) {
case 'last-activity':
return left.lastActivityAt - right.lastActivityAt
case 'last-visited':
return (left.lastVisitedAt ?? 0) - (right.lastVisitedAt ?? 0)
case 'created':
return (left.createdAt ?? 0) - (right.createdAt ?? 0)
case 'size':
return (left.sizeBytes ?? 0) - (right.sizeBytes ?? 0)
case 'name':
return left.displayName.localeCompare(right.displayName)
case 'repo':
return left.repoName.localeCompare(right.repoName)
case 'path':
return left.path.localeCompare(right.path)
case 'host':
return left.hostId.localeCompare(right.hostId)
case 'workspace-status':
return (left.workspaceStatus ?? '').localeCompare(right.workspaceStatus ?? '')
case 'agent':
return (
getWorkspaceCleanupAgentRank(left.agentState) -
getWorkspaceCleanupAgentRank(right.agentState)
)
case 'git':
return getWorkspaceCleanupGitRank(left.gitState) - getWorkspaceCleanupGitRank(right.gitState)
case 'ahead':
return (left.upstreamAhead ?? 0) - (right.upstreamAhead ?? 0)
case 'behind':
return (left.upstreamBehind ?? 0) - (right.upstreamBehind ?? 0)
case 'branch':
return left.branch.localeCompare(right.branch)
case 'review':
return (
getWorkspaceCleanupReviewRank(left.reviewState) -
getWorkspaceCleanupReviewRank(right.reviewState) ||
(left.review.label ?? '').localeCompare(right.review.label ?? '')
)
case 'ticket':
return (
left.ticketSources.length - right.ticketSources.length ||
(left.ticketSources[0] ?? '').localeCompare(right.ticketSources[0] ?? '')
)
case 'local-context':
return left.localContextCount - right.localContextCount
case 'tier':
return getWorkspaceCleanupTierRank(left.tier) - getWorkspaceCleanupTierRank(right.tier)
case 'blocker-count':
return (
left.blockerCount - right.blockerCount ||
getMaxBlockerSeverity(left) - getMaxBlockerSeverity(right)
)
}
}
/** Direction-independent so a flipped sort never reshuffles equal rows. */
function compareTieBreaks(left: WorkspaceCleanupFacets, right: WorkspaceCleanupFacets): number {
return (
left.lastActivityAt - right.lastActivityAt ||
left.repoName.localeCompare(right.repoName) ||
left.displayName.localeCompare(right.displayName) ||
left.worktreeId.localeCompare(right.worktreeId)
)
}
function getMaxBlockerSeverity(facets: WorkspaceCleanupFacets): number {
let max = 0
for (const blocker of facets.blockers) {
max = Math.max(max, getWorkspaceCleanupBlockerSeverity(blocker))
}
return max
}
@@ -0,0 +1,124 @@
import type { LiveAgentWorktreeStatus } from '@/lib/worktree-activity-state'
import type { WorkspaceCleanupCandidate } from '../../../../shared/workspace-cleanup'
import {
buildWorkspaceCleanupFacets,
type WorkspaceCleanupFacetSources,
type WorkspaceCleanupFacets,
type WorkspaceCleanupWorktreeFacts
} from './workspace-cleanup-facets'
import type { WorkspaceCleanupReviewInfo } from './workspace-cleanup-presentation'
export const FACET_NOW = 1_700_000_000_000
export const DAY = 24 * 60 * 60 * 1000
export function makeFacetCandidate(
overrides: Partial<WorkspaceCleanupCandidate> = {}
): WorkspaceCleanupCandidate {
return {
worktreeId: 'repo-1::/repo/alpha',
repoId: 'repo-1',
repoName: 'Repo',
connectionId: null,
displayName: 'alpha',
branch: 'refs/heads/alpha',
path: '/repo/alpha',
tier: 'ready',
selectedByDefault: true,
reasons: ['idle-clean'],
blockers: [],
lastActivityAt: FACET_NOW - 40 * DAY,
localContext: {
terminalTabCount: 0,
cleanEditorTabCount: 0,
browserTabCount: 0,
diffCommentCount: 0,
newestDiffCommentAt: null,
retainedDoneAgentCount: 0
},
git: { clean: true, upstreamAhead: 0, upstreamBehind: 0, checkedAt: FACET_NOW },
fingerprint: 'fingerprint',
...overrides
}
}
export function makeWorktreeFacts(
overrides: Partial<WorkspaceCleanupWorktreeFacts> = {}
): WorkspaceCleanupWorktreeFacts {
return {
id: 'repo-1::/repo/alpha',
branch: 'refs/heads/alpha',
comment: '',
isArchived: false,
isPinned: false,
isUnread: false,
linkedIssue: null,
linkedLinearIssue: null,
...overrides
}
}
export function makeReviewInfo(
overrides: Partial<WorkspaceCleanupReviewInfo> = {}
): WorkspaceCleanupReviewInfo {
return {
hasReview: true,
label: 'PR #42',
state: 'open',
provider: 'github',
title: 'Alpha cleanup',
...overrides
}
}
export type FacetFixtureInput = {
candidate?: Partial<WorkspaceCleanupCandidate>
worktree?: Partial<WorkspaceCleanupWorktreeFacts> | null
sizeBytes?: number
lastVisitedAt?: number
agentStatus?: LiveAgentWorktreeStatus
review?: Partial<WorkspaceCleanupReviewInfo>
dismissed?: boolean
}
/** Builds through the real facet builder so tests exercise the production join. */
export function makeFacets(input: FacetFixtureInput = {}): WorkspaceCleanupFacets {
const candidate = makeFacetCandidate(input.candidate)
const id = candidate.worktreeId
const sources: WorkspaceCleanupFacetSources = {
worktreeById:
input.worktree === null
? new Map()
: new Map([[id, makeWorktreeFacts({ id, ...input.worktree })]]),
sizeBytesByWorktreeId:
input.sizeBytes === undefined ? new Map() : new Map([[id, input.sizeBytes]]),
lastVisitedAtByWorktreeId:
input.lastVisitedAt === undefined ? {} : { [id]: input.lastVisitedAt },
liveAgentStatusByWorktreeId:
input.agentStatus === undefined ? new Map() : new Map([[id, input.agentStatus]]),
reviewInfoByWorktreeId:
input.review === undefined ? new Map() : new Map([[id, makeReviewInfo(input.review)]]),
dismissedWorktreeIds: new Set(input.dismissed ? [id] : [])
}
return buildWorkspaceCleanupFacets(candidate, sources)
}
/** Distinct worktree/display identity so tie-break ordering is observable. */
export function makeNamedFacets(
name: string,
input: FacetFixtureInput = {}
): WorkspaceCleanupFacets {
return makeFacets({
...input,
candidate: {
worktreeId: `repo-1::/repo/${name}`,
displayName: name,
path: `/repo/${name}`,
branch: `refs/heads/${name}`,
...input.candidate
},
worktree:
input.worktree === null
? null
: { id: `repo-1::/repo/${name}`, branch: `refs/heads/${name}`, ...input.worktree }
})
}
@@ -0,0 +1,247 @@
import type { LiveAgentWorktreeStatus } from '@/lib/worktree-activity-state'
import { LOCAL_EXECUTION_HOST_ID, type ExecutionHostId } from '../../../../shared/execution-host'
import type { Worktree } from '../../../../shared/types'
import type { WorkspaceSpaceWorktree } from '../../../../shared/workspace-space-types'
import {
canSelectWorkspaceCleanupCandidate,
type WorkspaceCleanupBlocker,
type WorkspaceCleanupCandidate,
type WorkspaceCleanupTier
} from '../../../../shared/workspace-cleanup'
import { getWorkspaceCleanupGitState } from './workspace-cleanup-filter-sort'
import type {
WorkspaceCleanupAgentState,
WorkspaceCleanupGitState,
WorkspaceCleanupReviewState,
WorkspaceCleanupTicketSource
} from '../../../../shared/workspace-cleanup-filter-model'
import type { WorkspaceCleanupReviewInfo } from './workspace-cleanup-presentation'
/** Structural subset of `Worktree` — Pick keeps it in sync when the source type moves. */
export type WorkspaceCleanupWorktreeFacts = Pick<Worktree, 'id'> &
Partial<
Pick<
Worktree,
| 'workspaceStatus'
| 'isArchived'
| 'isPinned'
| 'isUnread'
| 'comment'
| 'hostId'
| 'branch'
| 'createdAt'
| 'linkedWorkItem'
| 'linkedLinearIssue'
| 'linkedIssue'
| 'locked'
| 'prunable'
>
>
export type WorkspaceCleanupFacetSources = {
worktreeById?: ReadonlyMap<string, WorkspaceCleanupWorktreeFacts>
/** Absent entry = the space scan never ran for that worktree. */
sizeBytesByWorktreeId?: ReadonlyMap<string, number>
lastVisitedAtByWorktreeId?: Readonly<Record<string, number>>
liveAgentStatusByWorktreeId?: ReadonlyMap<string, LiveAgentWorktreeStatus>
reviewInfoByWorktreeId?: ReadonlyMap<string, WorkspaceCleanupReviewInfo>
dismissedWorktreeIds?: ReadonlySet<string>
}
export type WorkspaceCleanupFacets = {
candidate: WorkspaceCleanupCandidate
worktreeId: string
repoId: string
repoName: string
displayName: string
path: string
branch: string
hostId: ExecutionHostId
tier: WorkspaceCleanupTier
blockers: readonly WorkspaceCleanupBlocker[]
blockerCount: number
isDismissed: boolean
isSelectable: boolean
/** Background signal: ambient PTY/agent churn bumps this without a human. */
lastActivityAt: number
createdAt: number | null
/** Honest "user opened it" signal; null when Orca never recorded a visit. */
lastVisitedAt: number | null
sizeBytes: number | null
workspaceStatus: string | null
isArchived: boolean
isPinned: boolean
isUnread: boolean
hasComment: boolean
agentState: WorkspaceCleanupAgentState
retainedDoneAgentCount: number
gitState: WorkspaceCleanupGitState
upstreamAhead: number | null
upstreamBehind: number | null
isPrunable: boolean
isLocked: boolean
review: WorkspaceCleanupReviewInfo
reviewState: WorkspaceCleanupReviewState | null
ticketSources: readonly WorkspaceCleanupTicketSource[]
localContextCount: number
hasLocalContext: boolean
isCompletelyEmpty: boolean
searchText: string
}
const EMPTY_REVIEW_INFO: WorkspaceCleanupReviewInfo = {
hasReview: false,
label: null,
state: null,
provider: null,
title: null
}
export function buildWorkspaceCleanupFacets(
candidate: WorkspaceCleanupCandidate,
sources: WorkspaceCleanupFacetSources = {}
): WorkspaceCleanupFacets {
const worktree = sources.worktreeById?.get(candidate.worktreeId) ?? null
const review = sources.reviewInfoByWorktreeId?.get(candidate.worktreeId) ?? EMPTY_REVIEW_INFO
const ticketSources = getTicketSources(worktree)
const localContextCount = getLocalContextCount(candidate)
const hasComment = (worktree?.comment ?? '').trim().length > 0
const branch = getBranchDisplayName(worktree?.branch ?? candidate.branch)
const facets: Omit<WorkspaceCleanupFacets, 'searchText'> = {
candidate,
worktreeId: candidate.worktreeId,
repoId: candidate.repoId,
repoName: candidate.repoName,
displayName: candidate.displayName,
path: candidate.path,
branch,
hostId: worktree?.hostId ?? LOCAL_EXECUTION_HOST_ID,
tier: candidate.tier,
blockers: candidate.blockers,
blockerCount: candidate.blockers.length,
isDismissed:
(sources.dismissedWorktreeIds?.has(candidate.worktreeId) ?? false) ||
candidate.blockers.includes('dismissed'),
isSelectable: canSelectWorkspaceCleanupCandidate(candidate),
lastActivityAt: candidate.lastActivityAt,
createdAt: toFiniteOrNull(worktree?.createdAt ?? candidate.createdAt),
lastVisitedAt: toFiniteOrNull(sources.lastVisitedAtByWorktreeId?.[candidate.worktreeId]),
sizeBytes: toFiniteOrNull(sources.sizeBytesByWorktreeId?.get(candidate.worktreeId)),
workspaceStatus: normalizeStatus(worktree?.workspaceStatus),
isArchived: worktree?.isArchived ?? candidate.reasons.includes('archived'),
isPinned: worktree?.isPinned ?? candidate.blockers.includes('pinned'),
isUnread: worktree?.isUnread ?? false,
hasComment,
agentState: sources.liveAgentStatusByWorktreeId?.get(candidate.worktreeId) ?? 'idle',
retainedDoneAgentCount: candidate.localContext.retainedDoneAgentCount,
gitState: getWorkspaceCleanupGitState(candidate),
upstreamAhead: toFiniteOrNull(candidate.git.upstreamAhead),
upstreamBehind: toFiniteOrNull(candidate.git.upstreamBehind),
isPrunable: worktree?.prunable ?? false,
isLocked: worktree?.locked ?? false,
review,
reviewState: review.hasReview ? (review.state ?? 'unknown') : null,
ticketSources,
localContextCount,
hasLocalContext: localContextCount > 0,
isCompletelyEmpty:
localContextCount === 0 && !review.hasReview && ticketSources.length === 0 && !hasComment
}
return { ...facets, searchText: buildSearchText(facets) }
}
export function buildWorkspaceCleanupFacetList(
candidates: readonly WorkspaceCleanupCandidate[],
sources: WorkspaceCleanupFacetSources = {}
): WorkspaceCleanupFacets[] {
return candidates.map((candidate) => buildWorkspaceCleanupFacets(candidate, sources))
}
/** Only `ok` scans carry a trustworthy byte count; everything else stays unsized. */
export function buildWorkspaceCleanupSizeIndex(
worktrees: readonly WorkspaceSpaceWorktree[] | null | undefined
): Map<string, number> {
const index = new Map<string, number>()
for (const entry of worktrees ?? []) {
if (entry.status === 'ok' && Number.isFinite(entry.sizeBytes)) {
index.set(entry.worktreeId, entry.sizeBytes)
}
}
return index
}
export function buildWorkspaceCleanupWorktreeIndex(
worktreesByRepo: Readonly<Record<string, readonly WorkspaceCleanupWorktreeFacts[]>>
): Map<string, WorkspaceCleanupWorktreeFacts> {
const index = new Map<string, WorkspaceCleanupWorktreeFacts>()
for (const worktrees of Object.values(worktreesByRepo)) {
for (const worktree of worktrees) {
index.set(worktree.id, worktree)
}
}
return index
}
function getLocalContextCount(candidate: WorkspaceCleanupCandidate): number {
const context = candidate.localContext
return (
context.terminalTabCount +
context.cleanEditorTabCount +
context.browserTabCount +
context.diffCommentCount +
context.retainedDoneAgentCount
)
}
function getTicketSources(
worktree: WorkspaceCleanupWorktreeFacts | null
): WorkspaceCleanupTicketSource[] {
if (!worktree) {
return []
}
const sources: WorkspaceCleanupTicketSource[] = []
if (worktree.linkedWorkItem != null) {
sources.push('work-item')
}
if ((worktree.linkedLinearIssue ?? '').length > 0) {
sources.push('linear')
}
if (worktree.linkedIssue != null) {
sources.push('issue')
}
return sources
}
function buildSearchText(facets: Omit<WorkspaceCleanupFacets, 'searchText'>): string {
return [
facets.displayName,
facets.repoName,
facets.branch,
facets.path,
facets.hostId,
facets.workspaceStatus,
facets.review.label,
facets.review.title,
facets.review.provider,
facets.gitState,
facets.tier,
...facets.ticketSources,
...facets.blockers
]
.filter((value): value is string => Boolean(value))
.join(' ')
.toLowerCase()
}
function normalizeStatus(status: string | undefined): string | null {
const trimmed = (status ?? '').trim()
return trimmed.length > 0 ? trimmed : null
}
function toFiniteOrNull(value: number | null | undefined): number | null {
return typeof value === 'number' && Number.isFinite(value) ? value : null
}
function getBranchDisplayName(branch: string): string {
return branch.replace(/^refs\/heads\//, '') || 'HEAD'
}
@@ -0,0 +1,132 @@
import React from 'react'
import { HardDrive, Loader2, RotateCcw, Search, SlidersHorizontal } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { ScrollArea } from '@/components/ui/scroll-area'
import { translate } from '@/i18n/i18n'
import { WorkspaceCleanupGitReviewFacets } from './workspace-cleanup-git-review-facets'
import { WorkspaceCleanupLifecycleFacets } from './workspace-cleanup-lifecycle-facets'
import type { WorkspaceCleanupFacetGroupProps } from './workspace-cleanup-facet-panel-model'
export type WorkspaceCleanupSizeScanState = {
measuredCount: number
scanning: boolean
onRun: () => void
}
export type WorkspaceCleanupGitEvidenceProgress = {
pendingCount: number
totalCount: number
}
export function WorkspaceCleanupFilterBar({
facetProps,
activeFacetGroupCount,
matchedCount,
hasActiveFilters,
sizeScan,
gitEvidence,
onQueryChange,
onClearFilters
}: {
facetProps: WorkspaceCleanupFacetGroupProps
activeFacetGroupCount: number
matchedCount: number
hasActiveFilters: boolean
sizeScan: WorkspaceCleanupSizeScanState
gitEvidence: WorkspaceCleanupGitEvidenceProgress
onQueryChange: (query: string) => void
onClearFilters: () => void
}): React.JSX.Element {
const { filters, totalCount } = facetProps
return (
<div className="flex flex-wrap items-center gap-2 border-b border-border bg-muted/15 px-3 py-2">
<div className="relative min-w-[180px] flex-1">
<Search className="pointer-events-none absolute left-2.5 top-1/2 size-3.5 -translate-y-1/2 text-muted-foreground" />
<Input
value={filters.query}
onChange={(event) => onQueryChange(event.target.value)}
placeholder={translate(
'components.workspace.cleanup.browse.searchPlaceholder',
'Search name, repo, branch, path, host'
)}
aria-label={translate(
'components.workspace.cleanup.browse.searchLabel',
'Search workspaces'
)}
className="h-8 pl-8 text-xs"
/>
</div>
<Popover modal={false}>
<PopoverTrigger asChild>
<Button variant="outline" size="sm" className="shrink-0">
<SlidersHorizontal className="size-3.5" />
{translate('components.workspace.cleanup.browse.filters', 'Filters')}
{activeFacetGroupCount > 0 ? (
<span className="ml-1 rounded-full bg-accent px-1.5 text-[11px] tabular-nums text-accent-foreground">
{activeFacetGroupCount}
</span>
) : null}
</Button>
</PopoverTrigger>
<PopoverContent align="end" sideOffset={6} className="w-[320px] p-0">
<ScrollArea className="max-h-[420px]">
<WorkspaceCleanupLifecycleFacets {...facetProps} />
<WorkspaceCleanupGitReviewFacets {...facetProps} />
</ScrollArea>
<div className="flex items-center justify-between gap-2 border-t border-border px-3 py-2">
<span className="text-[11px] text-muted-foreground">
{translate(
'components.workspace.cleanup.browse.showingCount',
'Showing {{value0}} of {{value1}}',
{ value0: matchedCount, value1: totalCount }
)}
</span>
<Button variant="ghost" size="sm" disabled={!hasActiveFilters} onClick={onClearFilters}>
<RotateCcw className="size-3.5" />
{translate('components.workspace.cleanup.browse.clearFilters', 'Clear filters')}
</Button>
</div>
</PopoverContent>
</Popover>
<span className="shrink-0 text-xs tabular-nums text-muted-foreground">
{translate(
'components.workspace.cleanup.browse.showingCount',
'Showing {{value0}} of {{value1}}',
{ value0: matchedCount, value1: totalCount }
)}
</span>
{gitEvidence.pendingCount > 0 ? (
<span className="flex shrink-0 items-center gap-1.5 text-xs text-muted-foreground">
<Loader2 className="size-3.5 animate-spin" />
{translate(
'components.workspace.cleanup.browse.checkingGit',
'Checking git status: {{value0}} left',
{ value0: gitEvidence.pendingCount }
)}
</span>
) : null}
{sizeScan.measuredCount === 0 ? (
<Button
variant="outline"
size="sm"
className="shrink-0"
disabled={sizeScan.scanning}
onClick={sizeScan.onRun}
>
{sizeScan.scanning ? (
<Loader2 className="size-3.5 animate-spin" />
) : (
<HardDrive className="size-3.5" />
)}
{translate('components.workspace.cleanup.browse.measureSizes', 'Measure sizes')}
</Button>
) : null}
</div>
)
}
@@ -1,4 +1,5 @@
import type { WorkspaceCleanupCandidate } from '../../../../shared/workspace-cleanup'
import type { WorkspaceCleanupGitState } from '../../../../shared/workspace-cleanup-filter-model'
import type {
WorkspaceCleanupContextFilter,
WorkspaceCleanupFilters,
@@ -94,20 +95,35 @@ export function sortWorkspaceCleanupCandidates(
})
}
const GIT_STATE_LABELS: Record<WorkspaceCleanupGitState, string> = {
unpushed: 'Unpushed',
unknown: 'Unknown',
clean: 'Clean',
dirty: 'Dirty'
}
export function getWorkspaceCleanupGitLabel(candidate: WorkspaceCleanupCandidate): string {
return GIT_STATE_LABELS[getWorkspaceCleanupGitState(candidate)]
}
/** Unpushed outranks unknown: a lost commit is worse than an unreadable status. */
export function getWorkspaceCleanupGitState(
candidate: WorkspaceCleanupCandidate
): WorkspaceCleanupGitState {
if (hasUnpushedCommits(candidate)) {
return 'Unpushed'
return 'unpushed'
}
if (isGitStatusUnknown(candidate)) {
return 'Unknown'
return 'unknown'
}
if (candidate.git.clean === true) {
return 'Clean'
return 'clean'
}
if (candidate.git.clean === false) {
return 'Dirty'
}
return 'Unknown'
return candidate.git.clean === false ? 'dirty' : 'unknown'
}
export function hasWorkspaceCleanupUnpushedCommits(candidate: WorkspaceCleanupCandidate): boolean {
return hasUnpushedCommits(candidate)
}
function compareWorkspaceCleanupCandidates(
@@ -0,0 +1,129 @@
// @vitest-environment happy-dom
import type { ReactNode } from 'react'
import { act } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { applyWorkspaceCleanupPreset } from '../../../../shared/workspace-cleanup-preset-state'
import { WORKSPACE_CLEANUP_BUILT_IN_PRESETS } from '../../../../shared/workspace-cleanup-presets'
import { CandidateRow } from './workspace-cleanup-candidate-row'
import { WorkspaceCleanupCandidateList } from './workspace-cleanup-candidate-list'
import { FACET_NOW, makeNamedFacets } from './workspace-cleanup-facet.test.fixture'
import { runWorkspaceCleanupQuery } from './workspace-cleanup-query'
import type { WorkspaceCleanupFacets } from './workspace-cleanup-facets'
vi.mock('@/components/ui/tooltip', () => ({
Tooltip: ({ children }: { children: ReactNode }) => <>{children}</>,
TooltipContent: () => null,
TooltipTrigger: ({ children }: { children: ReactNode }) => <>{children}</>
}))
let root: Root | null = null
let container: HTMLDivElement | null = null
// One row per bucket the retired tab partition used to hide behind a tab.
const FLEET: WorkspaceCleanupFacets[] = [
makeNamedFacets('ready-one', { candidate: { tier: 'ready' }, sizeBytes: 4 * 1024 * 1024 }),
makeNamedFacets('needs-review', { candidate: { tier: 'review' } }),
makeNamedFacets('protected-one', {
candidate: { tier: 'protected', blockers: ['pinned'], selectedByDefault: false }
}),
makeNamedFacets('ignored-one', { dismissed: true })
]
function renderRows(rows: readonly WorkspaceCleanupFacets[]): void {
act(() =>
root?.render(
<WorkspaceCleanupCandidateList
rows={rows}
scrollElement={null}
renderRow={(row, index) => (
<CandidateRow
key={row.worktreeId}
candidate={row.candidate}
reviewInfo={row.review}
expanded={false}
last={index === rows.length - 1}
lastActivityLabel="40d ago"
sizeLabel={row.sizeBytes === null ? null : '4.00 MB'}
selected={false}
onIgnore={vi.fn()}
onRemove={vi.fn()}
onToggleExpanded={vi.fn()}
onToggleSelected={vi.fn()}
onView={vi.fn()}
/>
)}
/>
)
)
}
function renderedNames(): string[] {
// Buttons share `text-sm font-medium`, so scope to the row's name span.
return [...(container?.querySelectorAll('span.truncate.text-sm.font-medium') ?? [])].map(
(node) => node.textContent ?? ''
)
}
function query(presetId: string) {
const preset = WORKSPACE_CLEANUP_BUILT_IN_PRESETS.find((entry) => entry.id === presetId)
if (!preset) {
throw new Error(`missing preset ${presetId}`)
}
return runWorkspaceCleanupQuery(FLEET, applyWorkspaceCleanupPreset(preset), FACET_NOW)
}
describe('workspace cleanup flat list', () => {
beforeEach(() => {
container = document.createElement('div')
document.body.appendChild(container)
root = createRoot(container)
})
afterEach(() => {
if (root) {
act(() => root?.unmount())
}
container?.remove()
root = null
container = null
})
it('renders every tier in one list rather than per-tab groups', () => {
const result = query('all')
renderRows(result.rows)
expect(result.totalCount).toBe(FLEET.length)
expect(result.matchedCount).toBe(FLEET.length)
expect(renderedNames()).toEqual(
expect.arrayContaining(['ready-one', 'needs-review', 'protected-one', 'ignored-one'])
)
})
it('switches the visible rows when a preset is applied', () => {
const suggested = query('suggested')
renderRows(suggested.rows)
expect(renderedNames()).toEqual(['ready-one'])
const ignored = query('ignored')
renderRows(ignored.rows)
expect(renderedNames()).toEqual(['ignored-one'])
const protectedRows = query('protected')
renderRows(protectedRows.rows)
expect(renderedNames()).toEqual(['protected-one'])
})
it('reports only the rows the user could actually queue for deletion', () => {
expect(query('all').selectableWorktreeIds).not.toContain('repo-1::/repo/protected-one')
})
it('shows a measured size on the row and nothing when the space scan never ran', () => {
renderRows(query('all').rows)
expect(container?.textContent).toContain('4.00 MB')
const sizedRows = [...(container?.querySelectorAll('[aria-label^="Size on disk"]') ?? [])]
expect(sizedRows).toHaveLength(1)
})
})
@@ -0,0 +1,55 @@
import React from 'react'
import { Loader2 } from 'lucide-react'
import { useNow } from '@/components/dashboard/useNow'
import { translate } from '@/i18n/i18n'
import type { WorkspaceCleanupScanProgress } from '../../../../shared/workspace-cleanup'
import { formatWorkspaceCleanupRelativeTime } from './workspace-cleanup-relative-time'
/**
* Fixed-slot freshness line: "Updated 2h ago" for the data on screen plus a
* non-blocking "Refreshing" affordance while a background rescan streams.
*/
export function WorkspaceCleanupFreshness({
scannedAt,
refreshing,
progress
}: {
scannedAt: number | null
refreshing: boolean
progress: WorkspaceCleanupScanProgress | null
}): React.JSX.Element | null {
const now = useNow(30_000)
if (scannedAt === null && !refreshing) {
return null
}
return (
<span className="flex min-w-0 items-center gap-2 text-xs text-muted-foreground">
{scannedAt !== null ? (
<span className="truncate">
{translate('components.workspace.cleanup.browse.updatedAgo', 'Updated {{value0}}', {
value0: formatWorkspaceCleanupRelativeTime(scannedAt, now).toLowerCase()
})}
</span>
) : null}
{refreshing ? (
<span className="flex shrink-0 items-center gap-1">
<Loader2 className="size-3 animate-spin" aria-hidden="true" />
{formatWorkspaceCleanupRefreshingLabel(progress)}
</span>
) : null}
</span>
)
}
function formatWorkspaceCleanupRefreshingLabel(
progress: WorkspaceCleanupScanProgress | null
): string {
if (!progress || progress.totalWorktreeCount === 0) {
return translate('components.workspace.cleanup.browse.refreshing', 'Refreshing…')
}
return translate(
'components.workspace.cleanup.browse.refreshingProgress',
'Refreshing {{value0}}/{{value1}}',
{ value0: progress.scannedWorktreeCount, value1: progress.totalWorktreeCount }
)
}
@@ -0,0 +1,114 @@
import { describe, expect, it } from 'vitest'
import { createDefaultWorkspaceCleanupFilterState } from '../../../../shared/workspace-cleanup-filter-model'
import { makeFacetCandidate } from './workspace-cleanup-facet.test.fixture'
import {
applyWorkspaceCleanupGitEvidence,
hasWorkspaceCleanupGitEvidence,
needsWorkspaceCleanupGitEvidence,
selectWorkspaceCleanupGitEvidenceTargets
} from './workspace-cleanup-git-evidence'
const CLEAN_SORT = { field: 'name', direction: 'asc' } as const
function deferredCandidate(worktreeId: string) {
return makeFacetCandidate({
worktreeId,
git: { clean: null, upstreamAhead: null, upstreamBehind: null, checkedAt: null }
})
}
describe('needsWorkspaceCleanupGitEvidence', () => {
it('stays off for a filter and sort that never read git', () => {
expect(
needsWorkspaceCleanupGitEvidence(createDefaultWorkspaceCleanupFilterState(), CLEAN_SORT)
).toBe(false)
})
it.each([
[
'git state',
(f: ReturnType<typeof createDefaultWorkspaceCleanupFilterState>) => {
f.git.states = ['dirty']
}
],
[
'ahead threshold',
(f: ReturnType<typeof createDefaultWorkspaceCleanupFilterState>) => {
f.git.minAhead = 1
}
],
[
'behind threshold',
(f: ReturnType<typeof createDefaultWorkspaceCleanupFilterState>) => {
f.git.minBehind = 1
}
],
[
'git-derived blocker',
(f: ReturnType<typeof createDefaultWorkspaceCleanupFilterState>) => {
f.safety.blockers = ['unpushed-commits']
}
]
])('turns on for a %s filter', (_label, mutate) => {
const filters = createDefaultWorkspaceCleanupFilterState()
mutate(filters)
expect(needsWorkspaceCleanupGitEvidence(filters, CLEAN_SORT)).toBe(true)
})
it.each(['git', 'ahead', 'behind'] as const)('turns on for the %s sort', (field) => {
expect(
needsWorkspaceCleanupGitEvidence(createDefaultWorkspaceCleanupFilterState(), {
field,
direction: 'asc'
})
).toBe(true)
})
it('ignores a blocker filter that does not depend on git', () => {
const filters = createDefaultWorkspaceCleanupFilterState()
filters.safety.blockers = ['pinned']
expect(needsWorkspaceCleanupGitEvidence(filters, CLEAN_SORT)).toBe(false)
})
})
describe('selectWorkspaceCleanupGitEvidenceTargets', () => {
it('only targets rows the host deferred', () => {
const targets = selectWorkspaceCleanupGitEvidenceTargets([
deferredCandidate('a'),
makeFacetCandidate({ worktreeId: 'b' })
])
expect(targets).toEqual(['a'])
expect(hasWorkspaceCleanupGitEvidence(makeFacetCandidate({ worktreeId: 'b' }))).toBe(true)
})
it('skips rows already attempted and caps the batch', () => {
const candidates = ['a', 'b', 'c'].map(deferredCandidate)
expect(
selectWorkspaceCleanupGitEvidenceTargets(candidates, {
resolvedWorktreeIds: new Set(['a'])
})
).toEqual(['b', 'c'])
expect(selectWorkspaceCleanupGitEvidenceTargets(candidates, { maxTargets: 2 })).toEqual([
'a',
'b'
])
})
})
describe('applyWorkspaceCleanupGitEvidence', () => {
it('replaces the deferred row with the focused re-scan result', () => {
const deferred = deferredCandidate('a')
const refreshed = makeFacetCandidate({
worktreeId: 'a',
git: { clean: false, upstreamAhead: 2, upstreamBehind: 0, checkedAt: 5 }
})
const [row] = applyWorkspaceCleanupGitEvidence([deferred], new Map([['a', refreshed]]))
expect(row.git.clean).toBe(false)
expect(row.git.checkedAt).toBe(5)
})
it('returns the input untouched when nothing was re-scanned', () => {
const candidates = [deferredCandidate('a')]
expect(applyWorkspaceCleanupGitEvidence(candidates, new Map())).toBe(candidates)
})
})
@@ -0,0 +1,81 @@
import type {
WorkspaceCleanupBlocker,
WorkspaceCleanupCandidate
} from '../../../../shared/workspace-cleanup'
import type {
WorkspaceCleanupFilterState,
WorkspaceCleanupSortField,
WorkspaceCleanupSortState
} from '../../../../shared/workspace-cleanup-filter-model'
/**
* The host defers git evidence for rows with no inactivity reason, so their
* `git.checkedAt` is null and the facet layer reads them as `unknown`. A
* git-dependent filter or sort would then quietly mislabel most of the list,
* so the dialog re-scans those rows on demand before trusting the answer.
*/
export function hasWorkspaceCleanupGitEvidence(candidate: WorkspaceCleanupCandidate): boolean {
return candidate.git.checkedAt !== null
}
const GIT_SORT_FIELDS: ReadonlySet<WorkspaceCleanupSortField> = new Set(['git', 'ahead', 'behind'])
const GIT_DERIVED_BLOCKERS: ReadonlySet<WorkspaceCleanupBlocker> = new Set([
'dirty-files',
'unpushed-commits',
'git-status-error',
'unknown-base'
])
/** Bounds how many git processes one filter toggle can spawn across a huge fleet. */
export const WORKSPACE_CLEANUP_GIT_EVIDENCE_MAX_TARGETS = 500
export const WORKSPACE_CLEANUP_GIT_EVIDENCE_CONCURRENCY = 4
export function needsWorkspaceCleanupGitEvidence(
filters: WorkspaceCleanupFilterState,
sort: WorkspaceCleanupSortState
): boolean {
return (
filters.git.states.length > 0 ||
filters.git.minAhead !== null ||
filters.git.minBehind !== null ||
filters.safety.blockers.some((blocker) => GIT_DERIVED_BLOCKERS.has(blocker)) ||
GIT_SORT_FIELDS.has(sort.field)
)
}
/**
* Ids still missing git evidence, capped and in list order so the rows the user
* is looking at resolve first.
*/
export function selectWorkspaceCleanupGitEvidenceTargets(
candidates: readonly WorkspaceCleanupCandidate[],
options: {
resolvedWorktreeIds?: ReadonlySet<string>
maxTargets?: number
} = {}
): string[] {
const resolved = options.resolvedWorktreeIds ?? new Set<string>()
const max = options.maxTargets ?? WORKSPACE_CLEANUP_GIT_EVIDENCE_MAX_TARGETS
const targets: string[] = []
for (const candidate of candidates) {
if (targets.length >= max) {
break
}
if (!hasWorkspaceCleanupGitEvidence(candidate) && !resolved.has(candidate.worktreeId)) {
targets.push(candidate.worktreeId)
}
}
return targets
}
/** Focused re-scan results win over the deferred broad-scan row. */
export function applyWorkspaceCleanupGitEvidence(
candidates: readonly WorkspaceCleanupCandidate[],
evidenceByWorktreeId: ReadonlyMap<string, WorkspaceCleanupCandidate>
): readonly WorkspaceCleanupCandidate[] {
if (evidenceByWorktreeId.size === 0) {
return candidates
}
return candidates.map((candidate) => evidenceByWorktreeId.get(candidate.worktreeId) ?? candidate)
}
@@ -0,0 +1,206 @@
import React from 'react'
import { translate } from '@/i18n/i18n'
import { getExecutionHostLabel } from '../../../../shared/execution-host'
import {
WORKSPACE_CLEANUP_BLOCKER_MODE_VALUES,
WORKSPACE_CLEANUP_BLOCKER_VALUES,
WORKSPACE_CLEANUP_GIT_STATE_VALUES,
WORKSPACE_CLEANUP_PRESENCE_VALUES,
WORKSPACE_CLEANUP_REVIEW_STATE_VALUES,
WORKSPACE_CLEANUP_TICKET_SOURCE_VALUES,
WORKSPACE_CLEANUP_TIER_VALUES,
WORKSPACE_CLEANUP_TRI_STATE_VALUES
} from '../../../../shared/workspace-cleanup-facet-rankings'
import { getWorkspaceCleanupBlockerLabel } from './workspace-cleanup-candidate-labels'
import {
FacetCheckbox,
FacetChoice,
FacetNumberField,
FacetSection,
FacetTextField,
FacetToggleList
} from './workspace-cleanup-facet-controls'
import {
getWorkspaceCleanupGitStateLabel,
getWorkspaceCleanupPresenceLabel,
getWorkspaceCleanupReviewProviderLabel,
getWorkspaceCleanupReviewStateLabel,
getWorkspaceCleanupTicketSourceLabel,
getWorkspaceCleanupTierLabel,
getWorkspaceCleanupTriStateLabel
} from './workspace-cleanup-facet-labels'
import type { WorkspaceCleanupFacetGroupProps } from './workspace-cleanup-facet-panel-model'
/** Git, review/PR, tickets, location/host, and safety blockers. */
export function WorkspaceCleanupGitReviewFacets({
filters,
counts,
totalCount,
options,
onPatch
}: WorkspaceCleanupFacetGroupProps): React.JSX.Element {
return (
<>
<FacetSection
title={translate('components.workspace.cleanup.browse.facet.git', 'Git')}
matchCount={counts.git}
totalCount={totalCount}
>
<FacetToggleList
values={WORKSPACE_CLEANUP_GIT_STATE_VALUES}
selected={filters.git.states}
getLabel={getWorkspaceCleanupGitStateLabel}
onChange={(states) => onPatch('git', { states })}
/>
<FacetNumberField
label={translate('components.workspace.cleanup.browse.minAhead', 'Commits ahead ≥')}
value={filters.git.minAhead}
onChange={(minAhead) => onPatch('git', { minAhead })}
/>
<FacetNumberField
label={translate('components.workspace.cleanup.browse.minBehind', 'Commits behind ≥')}
value={filters.git.minBehind}
onChange={(minBehind) => onPatch('git', { minBehind })}
/>
<FacetTextField
label={translate('components.workspace.cleanup.browse.branchQuery', 'Branch contains')}
value={filters.git.branchQuery}
onChange={(branchQuery) => onPatch('git', { branchQuery })}
/>
<FacetChoice
label={translate('components.workspace.cleanup.browse.prunable', 'Prunable')}
value={filters.git.prunable}
options={WORKSPACE_CLEANUP_TRI_STATE_VALUES}
getLabel={getWorkspaceCleanupTriStateLabel}
onChange={(prunable) => onPatch('git', { prunable })}
/>
<FacetChoice
label={translate('components.workspace.cleanup.browse.locked', 'Locked')}
value={filters.git.locked}
options={WORKSPACE_CLEANUP_TRI_STATE_VALUES}
getLabel={getWorkspaceCleanupTriStateLabel}
onChange={(locked) => onPatch('git', { locked })}
/>
</FacetSection>
<FacetSection
title={translate('components.workspace.cleanup.browse.facet.review', 'Review')}
matchCount={counts.review}
totalCount={totalCount}
>
<FacetChoice
label={translate('components.workspace.cleanup.browse.reviewPresence', 'PR / MR')}
value={filters.review.presence}
options={WORKSPACE_CLEANUP_PRESENCE_VALUES}
getLabel={getWorkspaceCleanupPresenceLabel}
onChange={(presence) => onPatch('review', { presence })}
/>
<FacetToggleList
values={WORKSPACE_CLEANUP_REVIEW_STATE_VALUES}
selected={filters.review.states}
getLabel={getWorkspaceCleanupReviewStateLabel}
onChange={(states) => onPatch('review', { states })}
/>
{options.reviewProviders.length > 0 ? (
<FacetToggleList
label={translate('components.workspace.cleanup.browse.reviewProvider', 'Provider')}
values={options.reviewProviders}
selected={filters.review.providers}
getLabel={getWorkspaceCleanupReviewProviderLabel}
onChange={(providers) => onPatch('review', { providers })}
/>
) : null}
</FacetSection>
<FacetSection
title={translate('components.workspace.cleanup.browse.facet.ticket', 'Tickets')}
matchCount={counts.ticket}
totalCount={totalCount}
>
<FacetChoice
label={translate('components.workspace.cleanup.browse.ticketPresence', 'Linked ticket')}
value={filters.ticket.presence}
options={WORKSPACE_CLEANUP_PRESENCE_VALUES}
getLabel={getWorkspaceCleanupPresenceLabel}
onChange={(presence) => onPatch('ticket', { presence })}
/>
<FacetToggleList
values={WORKSPACE_CLEANUP_TICKET_SOURCE_VALUES}
selected={filters.ticket.sources}
getLabel={getWorkspaceCleanupTicketSourceLabel}
onChange={(sources) => onPatch('ticket', { sources })}
/>
</FacetSection>
<FacetSection
title={translate('components.workspace.cleanup.browse.facet.location', 'Location')}
matchCount={counts.location}
totalCount={totalCount}
>
<FacetToggleList
label={translate('components.workspace.cleanup.browse.host', 'Host')}
values={options.hostIds}
selected={filters.location.hostIds}
getLabel={getExecutionHostLabel}
onChange={(hostIds) => onPatch('location', { hostIds })}
/>
{options.repos.length > 1 ? (
<FacetToggleList
label={translate('components.workspace.cleanup.browse.repo', 'Repository')}
values={options.repos.map((repo) => repo.id)}
selected={filters.location.repoIds}
getLabel={(id) => options.repos.find((repo) => repo.id === id)?.label ?? id}
onChange={(repoIds) => onPatch('location', { repoIds })}
/>
) : null}
<FacetTextField
label={translate('components.workspace.cleanup.browse.pathPrefix', 'Path starts with')}
value={filters.location.pathPrefix}
onChange={(pathPrefix) => onPatch('location', { pathPrefix })}
/>
</FacetSection>
<FacetSection
title={translate('components.workspace.cleanup.browse.facet.safety', 'Safety')}
matchCount={counts.safety}
totalCount={totalCount}
>
<FacetToggleList
label={translate('components.workspace.cleanup.browse.tierField', 'Cleanup tier')}
values={WORKSPACE_CLEANUP_TIER_VALUES}
selected={filters.safety.tiers}
getLabel={getWorkspaceCleanupTierLabel}
onChange={(tiers) => onPatch('safety', { tiers })}
/>
<FacetChoice
label={translate('components.workspace.cleanup.browse.blockerMode', 'Blocker match')}
value={filters.safety.blockerMode}
options={WORKSPACE_CLEANUP_BLOCKER_MODE_VALUES}
onChange={(blockerMode) => onPatch('safety', { blockerMode })}
/>
<FacetToggleList
label={translate('components.workspace.cleanup.browse.blockers', 'Blockers')}
values={WORKSPACE_CLEANUP_BLOCKER_VALUES}
selected={filters.safety.blockers}
getLabel={getWorkspaceCleanupBlockerLabel}
onChange={(blockers) => onPatch('safety', { blockers })}
/>
<FacetChoice
label={translate('components.workspace.cleanup.browse.dismissed', 'Ignored')}
value={filters.safety.dismissed}
options={WORKSPACE_CLEANUP_TRI_STATE_VALUES}
getLabel={getWorkspaceCleanupTriStateLabel}
onChange={(dismissed) => onPatch('safety', { dismissed })}
/>
<FacetCheckbox
label={translate(
'components.workspace.cleanup.browse.selectableOnly',
'Only workspaces I can delete now'
)}
checked={filters.safety.selectableOnly}
onChange={(selectableOnly) => onPatch('safety', { selectableOnly })}
/>
</FacetSection>
</>
)
}
@@ -0,0 +1,194 @@
import React from 'react'
import { translate } from '@/i18n/i18n'
import {
WORKSPACE_CLEANUP_AGENT_STATE_VALUES,
WORKSPACE_CLEANUP_IDLE_SIGNAL_VALUES,
WORKSPACE_CLEANUP_PRESENCE_VALUES,
WORKSPACE_CLEANUP_TRI_STATE_VALUES
} from '../../../../shared/workspace-cleanup-facet-rankings'
import {
FacetCheckbox,
FacetChoice,
FacetNumberField,
FacetSection,
FacetToggleList
} from './workspace-cleanup-facet-controls'
import {
getWorkspaceCleanupAgentStateLabel,
getWorkspaceCleanupIdleSignalLabel,
getWorkspaceCleanupPresenceLabel,
getWorkspaceCleanupTriStateLabel
} from './workspace-cleanup-facet-labels'
import {
fromMegabytes,
toMegabytes,
type WorkspaceCleanupFacetGroupProps
} from './workspace-cleanup-facet-panel-model'
/** Activity, size, workspace status, agent state, and local context. */
export function WorkspaceCleanupLifecycleFacets({
filters,
counts,
totalCount,
options,
onPatch
}: WorkspaceCleanupFacetGroupProps): React.JSX.Element {
return (
<>
<FacetSection
title={translate('components.workspace.cleanup.browse.facet.activity', 'Activity')}
matchCount={counts.activity}
totalCount={totalCount}
>
<FacetChoice
label={translate('components.workspace.cleanup.browse.idleSignalField', 'Idle signal')}
value={filters.activity.idleSignal}
options={WORKSPACE_CLEANUP_IDLE_SIGNAL_VALUES}
getLabel={getWorkspaceCleanupIdleSignalLabel}
onChange={(idleSignal) => onPatch('activity', { idleSignal })}
/>
<FacetNumberField
label={translate('components.workspace.cleanup.browse.idleMinDays', 'Idle for at least')}
value={filters.activity.idleMinDays}
placeholder="30"
suffix={translate('components.workspace.cleanup.browse.days', 'days')}
onChange={(idleMinDays) => onPatch('activity', { idleMinDays })}
/>
<FacetCheckbox
label={translate('components.workspace.cleanup.browse.neverVisited', 'Never opened')}
checked={filters.activity.neverVisited}
onChange={(neverVisited) => onPatch('activity', { neverVisited })}
/>
</FacetSection>
<FacetSection
title={translate('components.workspace.cleanup.browse.facet.size', 'Size on disk')}
matchCount={counts.size}
totalCount={totalCount}
>
<FacetNumberField
label={translate('components.workspace.cleanup.browse.minSize', 'At least')}
value={toMegabytes(filters.size.minBytes)}
placeholder="0"
suffix="MB"
onChange={(megabytes) => onPatch('size', { minBytes: fromMegabytes(megabytes) })}
/>
<FacetNumberField
label={translate('components.workspace.cleanup.browse.maxSize', 'At most')}
value={toMegabytes(filters.size.maxBytes)}
suffix="MB"
onChange={(megabytes) => onPatch('size', { maxBytes: fromMegabytes(megabytes) })}
/>
<FacetCheckbox
label={translate(
'components.workspace.cleanup.browse.includeUnsized',
'Include unmeasured workspaces'
)}
checked={filters.size.includeUnsized}
onChange={(includeUnsized) => onPatch('size', { includeUnsized })}
/>
</FacetSection>
<FacetSection
title={translate('components.workspace.cleanup.browse.facet.status', 'Workspace status')}
matchCount={counts.status}
totalCount={totalCount}
>
{options.workspaceStatuses.length > 0 ? (
<FacetToggleList
values={options.workspaceStatuses.map((status) => status.id)}
selected={filters.status.workspaceStatuses}
getLabel={(id) =>
options.workspaceStatuses.find((status) => status.id === id)?.label ?? id
}
onChange={(workspaceStatuses) => onPatch('status', { workspaceStatuses })}
/>
) : null}
<FacetCheckbox
label={translate(
'components.workspace.cleanup.browse.matchStatusless',
'Include workspaces with no status'
)}
checked={filters.status.matchStatusless}
onChange={(matchStatusless) => onPatch('status', { matchStatusless })}
/>
<FacetChoice
label={translate('components.workspace.cleanup.browse.archived', 'Archived')}
value={filters.status.archived}
options={WORKSPACE_CLEANUP_TRI_STATE_VALUES}
getLabel={getWorkspaceCleanupTriStateLabel}
onChange={(archived) => onPatch('status', { archived })}
/>
<FacetChoice
label={translate('components.workspace.cleanup.browse.pinned', 'Pinned')}
value={filters.status.pinned}
options={WORKSPACE_CLEANUP_TRI_STATE_VALUES}
getLabel={getWorkspaceCleanupTriStateLabel}
onChange={(pinned) => onPatch('status', { pinned })}
/>
<FacetChoice
label={translate('components.workspace.cleanup.browse.unread', 'Unread')}
value={filters.status.unread}
options={WORKSPACE_CLEANUP_TRI_STATE_VALUES}
getLabel={getWorkspaceCleanupTriStateLabel}
onChange={(unread) => onPatch('status', { unread })}
/>
<FacetChoice
label={translate('components.workspace.cleanup.browse.comment', 'Has comment')}
value={filters.status.comment}
options={WORKSPACE_CLEANUP_TRI_STATE_VALUES}
getLabel={getWorkspaceCleanupTriStateLabel}
onChange={(comment) => onPatch('status', { comment })}
/>
</FacetSection>
<FacetSection
title={translate('components.workspace.cleanup.browse.facet.agent', 'Agent')}
matchCount={counts.agent}
totalCount={totalCount}
>
<FacetToggleList
values={WORKSPACE_CLEANUP_AGENT_STATE_VALUES}
selected={filters.agent.states}
getLabel={getWorkspaceCleanupAgentStateLabel}
onChange={(states) => onPatch('agent', { states })}
/>
<FacetChoice
label={translate(
'components.workspace.cleanup.browse.retainedDoneAgents',
'Finished agent transcripts'
)}
value={filters.agent.retainedDoneAgents}
options={WORKSPACE_CLEANUP_TRI_STATE_VALUES}
getLabel={getWorkspaceCleanupTriStateLabel}
onChange={(retainedDoneAgents) => onPatch('agent', { retainedDoneAgents })}
/>
</FacetSection>
<FacetSection
title={translate('components.workspace.cleanup.browse.facet.context', 'Local context')}
matchCount={counts.context}
totalCount={totalCount}
>
<FacetChoice
label={translate(
'components.workspace.cleanup.browse.contextPresence',
'Open tabs, terminals, comments'
)}
value={filters.context.presence}
options={WORKSPACE_CLEANUP_PRESENCE_VALUES}
getLabel={getWorkspaceCleanupPresenceLabel}
onChange={(presence) => onPatch('context', { presence })}
/>
<FacetCheckbox
label={translate(
'components.workspace.cleanup.browse.completelyEmpty',
'Nothing left to lose'
)}
checked={filters.context.completelyEmpty}
onChange={(completelyEmpty) => onPatch('context', { completelyEmpty })}
/>
</FacetSection>
</>
)
}
@@ -0,0 +1,128 @@
// @vitest-environment happy-dom
import type { ReactNode } from 'react'
import { act } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { WORKSPACE_CLEANUP_BUILT_IN_PRESETS } from '../../../../shared/workspace-cleanup-presets'
import { WorkspaceCleanupPresetChips } from './workspace-cleanup-preset-chips'
vi.mock('@/components/ui/tooltip', () => ({
Tooltip: ({ children }: { children: ReactNode }) => <>{children}</>,
TooltipContent: () => null,
TooltipTrigger: ({ children }: { children: ReactNode }) => <>{children}</>
}))
let root: Root | null = null
let container: HTMLDivElement | null = null
function render(node: ReactNode): void {
act(() => root?.render(node))
}
function chip(presetId: string): HTMLButtonElement | null {
return container?.querySelector<HTMLButtonElement>(`[data-preset-id="${presetId}"]`) ?? null
}
describe('WorkspaceCleanupPresetChips', () => {
beforeEach(() => {
container = document.createElement('div')
document.body.appendChild(container)
root = createRoot(container)
})
afterEach(() => {
if (root) {
act(() => root?.unmount())
}
container?.remove()
root = null
container = null
})
it('renders every preset as a chip instead of a fixed tab set', () => {
render(
<WorkspaceCleanupPresetChips
presets={WORKSPACE_CLEANUP_BUILT_IN_PRESETS}
matchedPresetId="suggested"
requestedPresetId="suggested"
hasActiveFilters={false}
onApplyPreset={vi.fn()}
onClearFilters={vi.fn()}
/>
)
expect(container?.querySelectorAll('[data-preset-id]')).toHaveLength(
WORKSPACE_CLEANUP_BUILT_IN_PRESETS.length
)
expect(chip('ignored')).not.toBeNull()
expect(chip('all')).not.toBeNull()
})
it('marks the matched preset active and the edited one modified', () => {
render(
<WorkspaceCleanupPresetChips
presets={WORKSPACE_CLEANUP_BUILT_IN_PRESETS}
matchedPresetId={null}
requestedPresetId="needs-review"
hasActiveFilters
onApplyPreset={vi.fn()}
onClearFilters={vi.fn()}
/>
)
expect(chip('needs-review')?.getAttribute('data-preset-state')).toBe('modified')
expect(chip('needs-review')?.getAttribute('aria-pressed')).toBe('false')
expect(chip('suggested')?.getAttribute('data-preset-state')).toBe('idle')
})
it('applies the clicked preset', () => {
const onApplyPreset = vi.fn()
render(
<WorkspaceCleanupPresetChips
presets={WORKSPACE_CLEANUP_BUILT_IN_PRESETS}
matchedPresetId="suggested"
requestedPresetId="suggested"
hasActiveFilters={false}
onApplyPreset={onApplyPreset}
onClearFilters={vi.fn()}
/>
)
act(() => chip('largest')?.click())
expect(onApplyPreset).toHaveBeenCalledWith(
WORKSPACE_CLEANUP_BUILT_IN_PRESETS.find((preset) => preset.id === 'largest')
)
})
it('only offers clear filters once something is filtered', () => {
const onClearFilters = vi.fn()
render(
<WorkspaceCleanupPresetChips
presets={WORKSPACE_CLEANUP_BUILT_IN_PRESETS}
matchedPresetId={null}
requestedPresetId={null}
hasActiveFilters={false}
onApplyPreset={vi.fn()}
onClearFilters={onClearFilters}
/>
)
expect(container?.textContent).not.toContain('Clear filters')
render(
<WorkspaceCleanupPresetChips
presets={WORKSPACE_CLEANUP_BUILT_IN_PRESETS}
matchedPresetId={null}
requestedPresetId={null}
hasActiveFilters
onApplyPreset={vi.fn()}
onClearFilters={onClearFilters}
/>
)
const clearButton = [...(container?.querySelectorAll('button') ?? [])].find((button) =>
button.textContent?.includes('Clear filters')
)
act(() => clearButton?.click())
expect(onClearFilters).toHaveBeenCalledTimes(1)
})
})
@@ -0,0 +1,79 @@
import React from 'react'
import { RotateCcw } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { ScrollArea, ScrollBar } from '@/components/ui/scroll-area'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { cn } from '@/lib/utils'
import { translate } from '@/i18n/i18n'
import type { WorkspaceCleanupPreset } from '../../../../shared/workspace-cleanup-presets'
export function WorkspaceCleanupPresetChips({
presets,
matchedPresetId,
requestedPresetId,
hasActiveFilters,
onApplyPreset,
onClearFilters
}: {
presets: readonly WorkspaceCleanupPreset[]
/** Preset the live filter state still equals, or null once it was edited. */
matchedPresetId: string | null
/** Preset the user last picked, even after edits, so the chip can read "modified". */
requestedPresetId: string | null
hasActiveFilters: boolean
onApplyPreset: (preset: WorkspaceCleanupPreset) => void
onClearFilters: () => void
}): React.JSX.Element {
return (
<div className="flex items-center gap-2 border-b border-border px-3 py-2">
<ScrollArea className="min-w-0 flex-1">
<div className="flex items-center gap-1.5 pb-1.5">
{presets.map((preset) => {
const active = matchedPresetId === preset.id
const modified = !active && requestedPresetId === preset.id
return (
<Tooltip key={preset.id}>
<TooltipTrigger asChild>
<button
type="button"
aria-pressed={active}
data-preset-id={preset.id}
data-preset-state={active ? 'active' : modified ? 'modified' : 'idle'}
onClick={() => onApplyPreset(preset)}
className={cn(
'inline-flex h-7 shrink-0 items-center gap-1.5 rounded-full border border-border px-2.5 text-xs font-medium text-muted-foreground transition-colors',
'hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring',
active && 'bg-accent text-accent-foreground',
modified && 'border-dashed text-foreground'
)}
>
<span className="truncate">
{preset.labelKey ? translate(preset.labelKey, preset.label) : preset.label}
</span>
{modified ? (
<span className="text-[11px] font-normal text-muted-foreground">
{translate('components.workspace.cleanup.browse.presetModified', 'edited')}
</span>
) : null}
</button>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={4}>
{preset.descriptionKey
? translate(preset.descriptionKey, preset.description)
: preset.description || preset.label}
</TooltipContent>
</Tooltip>
)
})}
</div>
<ScrollBar orientation="horizontal" />
</ScrollArea>
{hasActiveFilters ? (
<Button variant="ghost" size="sm" className="shrink-0" onClick={onClearFilters}>
<RotateCcw className="size-3.5" />
{translate('components.workspace.cleanup.browse.clearFilters', 'Clear filters')}
</Button>
) : null}
</div>
)
}
@@ -0,0 +1,215 @@
import { describe, expect, it } from 'vitest'
import {
createDefaultWorkspaceCleanupBrowseState,
normalizeWorkspaceCleanupBrowseState,
WORKSPACE_CLEANUP_BROWSE_STATE_VERSION
} from '../../../../shared/workspace-cleanup-browse-state'
import { DAY, FACET_NOW, makeNamedFacets } from './workspace-cleanup-facet.test.fixture'
import { createDefaultWorkspaceCleanupFilterState } from '../../../../shared/workspace-cleanup-filter-model'
import {
applyWorkspaceCleanupPreset,
createWorkspaceCleanupCustomPreset,
findWorkspaceCleanupPreset,
isWorkspaceCleanupFilterStateEqual,
listWorkspaceCleanupPresets,
matchWorkspaceCleanupPresetId
} from '../../../../shared/workspace-cleanup-preset-state'
import { WORKSPACE_CLEANUP_BUILT_IN_PRESETS } from '../../../../shared/workspace-cleanup-presets'
import { runWorkspaceCleanupQuery } from './workspace-cleanup-query'
function applyPreset(presetId: string, rows: ReturnType<typeof makeNamedFacets>[]): string[] {
const preset = findWorkspaceCleanupPreset(presetId)
expect(preset).not.toBeNull()
return runWorkspaceCleanupQuery(rows, applyWorkspaceCleanupPreset(preset!), FACET_NOW).rows.map(
(row) => row.displayName
)
}
const CORPUS = [
makeNamedFacets('ready'),
makeNamedFacets('needsReview', {
candidate: {
tier: 'review',
blockers: ['dirty-files'],
git: { clean: false, upstreamAhead: 0, upstreamBehind: 0, checkedAt: 1 }
}
}),
makeNamedFacets('locked', { candidate: { tier: 'protected', blockers: ['live-agent'] } }),
makeNamedFacets('ignored', { dismissed: true }),
makeNamedFacets('mergedClean', { review: { state: 'merged', provider: 'gitlab' } }),
makeNamedFacets('visited', { lastVisitedAt: FACET_NOW - 5 * DAY }),
makeNamedFacets('huge', { sizeBytes: 5_000, lastVisitedAt: FACET_NOW - 200 * DAY })
]
describe('built-in presets', () => {
it('exposes unique ids and i18n keys', () => {
const ids = WORKSPACE_CLEANUP_BUILT_IN_PRESETS.map((preset) => preset.id)
expect(new Set(ids).size).toBe(ids.length)
expect(ids).toEqual([
'suggested',
'needs-review',
'protected',
'ignored',
'merged-review-clean',
'never-opened',
'largest',
'stale-visits',
'all'
])
for (const preset of WORKSPACE_CLEANUP_BUILT_IN_PRESETS) {
expect(preset.source).toBe('built-in')
expect(preset.labelKey).toBe(`components.workspace.cleanup.presets.${preset.id}.label`)
}
})
it('suggested keeps only selectable ready rows', () => {
// Equal lastActivityAt across the corpus, so the name tie-break decides order.
expect(applyPreset('suggested', CORPUS)).toEqual(['huge', 'mergedClean', 'ready', 'visited'])
})
it('needs review and protected split the non-selectable rows', () => {
expect(applyPreset('needs-review', CORPUS)).toEqual(['needsReview'])
expect(applyPreset('protected', CORPUS)).toEqual(['locked'])
})
it('ignored shows only dismissed rows', () => {
expect(applyPreset('ignored', CORPUS)).toEqual(['ignored'])
})
it('merged review & clean requires both a landed review and a clean tree', () => {
expect(applyPreset('merged-review-clean', CORPUS)).toEqual(['mergedClean'])
})
it('never opened ignores background activity', () => {
expect(applyPreset('never-opened', CORPUS)).not.toContain('visited')
expect(applyPreset('never-opened', CORPUS)).toContain('ready')
})
it('largest drops unsized rows and sorts descending', () => {
expect(applyPreset('largest', CORPUS)).toEqual(['huge'])
const sized = [
makeNamedFacets('small', { sizeBytes: 1 }),
makeNamedFacets('big', { sizeBytes: 100 })
]
expect(applyPreset('largest', sized)).toEqual(['big', 'small'])
})
it('stale visits uses the visit signal, not background activity', () => {
expect(applyPreset('stale-visits', CORPUS)).toContain('huge')
expect(applyPreset('stale-visits', CORPUS)).not.toContain('visited')
})
it('all includes dismissed rows', () => {
expect(applyPreset('all', CORPUS)).toContain('ignored')
expect(applyPreset('all', CORPUS)).toHaveLength(CORPUS.length)
})
})
describe('preset application', () => {
it('deep clones so editing applied state never mutates the preset', () => {
const preset = findWorkspaceCleanupPreset('suggested')!
const applied = applyWorkspaceCleanupPreset(preset)
applied.filters.safety.tiers.push('protected')
applied.sort.direction = 'desc'
expect(preset.filters.safety.tiers).toEqual(['ready'])
expect(preset.sort.direction).toBe('asc')
})
it('round-trips through preset matching', () => {
for (const preset of WORKSPACE_CLEANUP_BUILT_IN_PRESETS) {
expect(matchWorkspaceCleanupPresetId(applyWorkspaceCleanupPreset(preset))).toBe(preset.id)
}
})
it('reports no preset once the user edits away from one', () => {
const applied = applyWorkspaceCleanupPreset(findWorkspaceCleanupPreset('suggested')!)
applied.filters.git.branchQuery = 'feature'
expect(matchWorkspaceCleanupPresetId(applied)).toBeNull()
})
it('compares multi-selects as sets, not ordered lists', () => {
const left = createDefaultWorkspaceCleanupFilterState()
const right = createDefaultWorkspaceCleanupFilterState()
left.safety.blockers = ['pinned', 'live-agent']
right.safety.blockers = ['live-agent', 'pinned']
expect(isWorkspaceCleanupFilterStateEqual(left, right)).toBe(true)
right.safety.blockers = ['pinned']
expect(isWorkspaceCleanupFilterStateEqual(left, right)).toBe(false)
})
})
describe('custom presets', () => {
it('appends customs after built-ins and lets a custom id shadow a built-in', () => {
const custom = createWorkspaceCleanupCustomPreset({
id: 'mine',
label: 'Mine',
state: applyWorkspaceCleanupPreset(findWorkspaceCleanupPreset('all')!),
createdAt: FACET_NOW
})
const listed = listWorkspaceCleanupPresets([custom])
expect(listed.at(-1)?.id).toBe('mine')
expect(listed.at(-1)?.source).toBe('custom')
const shadow = { ...custom, id: 'suggested', label: 'My suggested' }
const shadowed = listWorkspaceCleanupPresets([shadow])
expect(shadowed.filter((preset) => preset.id === 'suggested')).toHaveLength(1)
expect(findWorkspaceCleanupPreset('suggested', [shadow])?.label).toBe('My suggested')
})
})
describe('persisted browse state', () => {
it('defaults to the suggested preset', () => {
const state = createDefaultWorkspaceCleanupBrowseState()
expect(state.version).toBe(WORKSPACE_CLEANUP_BROWSE_STATE_VERSION)
expect(state.activePresetId).toBe('suggested')
expect(state.customPresets).toEqual([])
})
it('degrades unknown, missing, and corrupt values instead of throwing', () => {
expect(normalizeWorkspaceCleanupBrowseState(null)).toEqual(
createDefaultWorkspaceCleanupBrowseState()
)
const normalized = normalizeWorkspaceCleanupBrowseState({
activePresetId: 42,
sort: { field: 'from-the-future', direction: 'sideways' },
filters: {
query: 7,
activity: { idleSignal: 'nope', idleMinDays: -5 },
safety: { blockers: ['live-agent', 'live-agent', 'not-a-blocker'], dismissed: 'maybe' },
unknownGroup: { anything: true }
},
customPresets: 'nope'
})
expect(normalized.activePresetId).toBeNull()
expect(normalized.sort).toEqual({ field: 'last-activity', direction: 'asc' })
expect(normalized.filters.query).toBe('')
expect(normalized.filters.activity.idleSignal).toBe('last-visited')
expect(normalized.filters.activity.idleMinDays).toBe(0)
expect(normalized.filters.safety.blockers).toEqual(['live-agent'])
expect(normalized.filters.safety.dismissed).toBe('exclude')
expect(normalized.customPresets).toEqual([])
expect(normalized).not.toHaveProperty('unknownGroup')
})
it('round-trips a saved state through JSON', () => {
const state = createDefaultWorkspaceCleanupBrowseState()
state.filters.activity.idleMinDays = 45
state.filters.status.workspaceStatuses = ['in-review']
state.customPresets = [
createWorkspaceCleanupCustomPreset({
id: 'mine',
label: 'Mine',
state: { filters: state.filters, sort: state.sort },
createdAt: FACET_NOW
})
]
expect(normalizeWorkspaceCleanupBrowseState(JSON.parse(JSON.stringify(state)))).toEqual(state)
})
it('drops duplicate custom preset ids and entries without an id', () => {
const normalized = normalizeWorkspaceCleanupBrowseState({
customPresets: [{ id: 'a', label: 'A' }, { id: 'a', label: 'B' }, { label: 'no id' }]
})
expect(normalized.customPresets.map((preset) => preset.label)).toEqual(['A'])
})
})
@@ -0,0 +1,130 @@
import type { WorkspaceCleanupCandidate } from '../../../../shared/workspace-cleanup'
import {
buildWorkspaceCleanupFacetList,
type WorkspaceCleanupFacetSources,
type WorkspaceCleanupFacets
} from './workspace-cleanup-facets'
import {
matchesWorkspaceCleanupActivity,
matchesWorkspaceCleanupAgent,
matchesWorkspaceCleanupContext,
matchesWorkspaceCleanupGit,
matchesWorkspaceCleanupLocation,
matchesWorkspaceCleanupReview,
matchesWorkspaceCleanupSafety,
matchesWorkspaceCleanupSize,
matchesWorkspaceCleanupStatus,
matchesWorkspaceCleanupTicket
} from './workspace-cleanup-facet-matchers'
import { sortWorkspaceCleanupFacets } from './workspace-cleanup-facet-sort'
import type {
WorkspaceCleanupFilterState,
WorkspaceCleanupSortState
} from '../../../../shared/workspace-cleanup-filter-model'
export type WorkspaceCleanupQuery = {
filters: WorkspaceCleanupFilterState
sort: WorkspaceCleanupSortState
}
export type WorkspaceCleanupQueryResult = {
/** Sorted rows that passed every facet. */
rows: WorkspaceCleanupFacets[]
/** Rows the user could actually queue for deletion, for the select-all affordance. */
selectableWorktreeIds: string[]
totalCount: number
matchedCount: number
}
export function matchesWorkspaceCleanupFilters(
facets: WorkspaceCleanupFacets,
filters: WorkspaceCleanupFilterState,
now: number = Date.now()
): boolean {
const query = filters.query.trim().toLowerCase()
if (query && !facets.searchText.includes(query)) {
return false
}
return (
matchesWorkspaceCleanupSafety(facets, filters.safety) &&
matchesWorkspaceCleanupActivity(facets, filters.activity, now) &&
matchesWorkspaceCleanupSize(facets, filters.size) &&
matchesWorkspaceCleanupStatus(facets, filters.status) &&
matchesWorkspaceCleanupAgent(facets, filters.agent) &&
matchesWorkspaceCleanupGit(facets, filters.git) &&
matchesWorkspaceCleanupReview(facets, filters.review) &&
matchesWorkspaceCleanupTicket(facets, filters.ticket) &&
matchesWorkspaceCleanupContext(facets, filters.context) &&
matchesWorkspaceCleanupLocation(facets, filters.location)
)
}
export function filterWorkspaceCleanupFacets(
facets: readonly WorkspaceCleanupFacets[],
filters: WorkspaceCleanupFilterState,
now: number = Date.now()
): WorkspaceCleanupFacets[] {
return facets.filter((entry) => matchesWorkspaceCleanupFilters(entry, filters, now))
}
export function runWorkspaceCleanupQuery(
facets: readonly WorkspaceCleanupFacets[],
query: WorkspaceCleanupQuery,
now: number = Date.now()
): WorkspaceCleanupQueryResult {
const matched = filterWorkspaceCleanupFacets(facets, query.filters, now)
const rows = sortWorkspaceCleanupFacets(matched, query.sort)
return {
rows,
selectableWorktreeIds: rows.filter((row) => row.isSelectable).map((row) => row.worktreeId),
totalCount: facets.length,
matchedCount: rows.length
}
}
/** One-shot convenience for callers holding raw scan candidates. */
export function queryWorkspaceCleanupCandidates(
candidates: readonly WorkspaceCleanupCandidate[],
query: WorkspaceCleanupQuery,
sources: WorkspaceCleanupFacetSources = {},
now: number = Date.now()
): WorkspaceCleanupQueryResult {
return runWorkspaceCleanupQuery(buildWorkspaceCleanupFacetList(candidates, sources), query, now)
}
/**
* Per-facet-group match counts for badge/count rendering. Each entry counts the
* rows that would survive if only that group were applied, so a UI can show how
* much each facet is costing without re-running the whole pipeline per group.
*/
export function countWorkspaceCleanupFacetMatches(
facets: readonly WorkspaceCleanupFacets[],
filters: WorkspaceCleanupFilterState,
now: number = Date.now()
): Record<keyof Omit<WorkspaceCleanupFilterState, 'query'>, number> {
return {
activity: count(facets, (row) => matchesWorkspaceCleanupActivity(row, filters.activity, now)),
size: count(facets, (row) => matchesWorkspaceCleanupSize(row, filters.size)),
status: count(facets, (row) => matchesWorkspaceCleanupStatus(row, filters.status)),
agent: count(facets, (row) => matchesWorkspaceCleanupAgent(row, filters.agent)),
git: count(facets, (row) => matchesWorkspaceCleanupGit(row, filters.git)),
review: count(facets, (row) => matchesWorkspaceCleanupReview(row, filters.review)),
ticket: count(facets, (row) => matchesWorkspaceCleanupTicket(row, filters.ticket)),
context: count(facets, (row) => matchesWorkspaceCleanupContext(row, filters.context)),
location: count(facets, (row) => matchesWorkspaceCleanupLocation(row, filters.location)),
safety: count(facets, (row) => matchesWorkspaceCleanupSafety(row, filters.safety))
}
}
function count(
facets: readonly WorkspaceCleanupFacets[],
predicate: (facets: WorkspaceCleanupFacets) => boolean
): number {
let total = 0
for (const entry of facets) {
if (predicate(entry)) {
total += 1
}
}
return total
}
@@ -0,0 +1,19 @@
/** Compact "how long ago" label for cleanup rows; 0 means Orca never recorded it. */
export function formatWorkspaceCleanupRelativeTime(timestamp: number, now = Date.now()): string {
if (!timestamp) {
return 'Never'
}
const deltaMs = now - timestamp
if (deltaMs < 60_000) {
return 'Just now'
}
const minutes = Math.floor(deltaMs / 60_000)
if (minutes < 60) {
return `${minutes}m ago`
}
const hours = Math.floor(minutes / 60)
if (hours < 48) {
return `${hours}h ago`
}
return `${Math.floor(hours / 24)}d ago`
}
@@ -0,0 +1,95 @@
import React from 'react'
import { translate } from '@/i18n/i18n'
import type { WorkspaceCleanupCandidate } from '../../../../shared/workspace-cleanup'
import { formatBytes } from '../status-bar/workspace-space-format'
import { CandidateRow, type WorkspaceCleanupDeletionPhase } from './workspace-cleanup-candidate-row'
import { WorkspaceCleanupCandidateList } from './workspace-cleanup-candidate-list'
import { WorkspaceCleanupEmptyState } from './workspace-cleanup-dialog-notices'
import type { WorkspaceCleanupFacets } from './workspace-cleanup-facets'
import { formatWorkspaceCleanupRelativeTime } from './workspace-cleanup-relative-time'
export type WorkspaceCleanupRowListState = {
rows: readonly WorkspaceCleanupFacets[]
/** Rows the scan produced before filtering; separates "empty fleet" from "no matches". */
scannedCount: number
hasScanned: boolean
loading: boolean
deletionPhaseByWorktreeId: Record<string, WorkspaceCleanupDeletionPhase>
deletingWorktreeIds: ReadonlySet<string>
expandedRowIds: ReadonlySet<string>
selectedIds: ReadonlySet<string>
gitPendingWorktreeIds: ReadonlySet<string>
rowFailures: Record<string, string>
removalInFlight: boolean
scrollElement: HTMLDivElement | null
onClearFilters: () => void
onToggleExpanded: (worktreeId: string) => void
onToggleSelected: (worktreeId: string) => void
onView: (candidate: WorkspaceCleanupCandidate) => void
onIgnore: (candidate: WorkspaceCleanupCandidate) => void
onRemove: (candidate: WorkspaceCleanupCandidate) => void
}
export function WorkspaceCleanupRowList(props: WorkspaceCleanupRowListState): React.JSX.Element {
const { rows, loading, hasScanned, scannedCount } = props
const settled = !loading && hasScanned
return (
<>
{settled && scannedCount === 0 ? (
<WorkspaceCleanupEmptyState
title={translate(
'components.workspace.cleanup.browse.noWorkspaces',
'No workspaces found.'
)}
/>
) : null}
{hasScanned && scannedCount > 0 && rows.length === 0 ? (
<WorkspaceCleanupEmptyState
title={translate(
'components.workspace.cleanup.browse.noMatches',
'No workspaces match these filters.'
)}
description={translate(
'components.workspace.cleanup.browse.noMatchesDescription',
'Every workspace is in one list — widen a facet or clear the filters.'
)}
actionLabel={translate(
'components.workspace.cleanup.browse.clearFilters',
'Clear filters'
)}
onAction={props.onClearFilters}
/>
) : null}
<WorkspaceCleanupCandidateList
rows={rows}
scrollElement={props.scrollElement}
renderRow={(row, index) => (
<CandidateRow
key={row.worktreeId}
candidate={row.candidate}
reviewInfo={row.review}
last={rows.length > 1 && index === rows.length - 1}
expanded={props.expandedRowIds.has(row.worktreeId)}
lastActivityLabel={formatWorkspaceCleanupRelativeTime(row.lastActivityAt)}
sizeLabel={row.sizeBytes === null ? null : formatBytes(row.sizeBytes)}
gitEvidencePending={props.gitPendingWorktreeIds.has(row.worktreeId)}
deletionPhase={props.deletionPhaseByWorktreeId[row.worktreeId]}
// Why: a background rescan must not lock rows; only an actual
// removal batch or this row's own deletion disables it.
removing={props.removalInFlight || props.deletingWorktreeIds.has(row.worktreeId)}
selected={
props.selectedIds.has(row.worktreeId) &&
!props.deletingWorktreeIds.has(row.worktreeId)
}
failure={props.rowFailures[row.worktreeId]}
onToggleExpanded={props.onToggleExpanded}
onToggleSelected={props.onToggleSelected}
onView={props.onView}
onIgnore={props.onIgnore}
onRemove={props.onRemove}
/>
)}
/>
</>
)
}
@@ -0,0 +1,83 @@
import { translate } from '@/i18n/i18n'
import type {
WorkspaceCleanupScanError,
WorkspaceCleanupScanProgress
} from '../../../../shared/workspace-cleanup'
function isDisconnectedRemoteScanError(message: string): boolean {
return (
message === 'SSH provider is unavailable.' ||
message === 'Remote workspaces are not connected. Reconnect and refresh to check them.'
)
}
export function formatWorkspaceCleanupScanNotice(
errors: readonly WorkspaceCleanupScanError[],
repoNameById: ReadonlyMap<string, string>
): string | null {
const visibleErrors = errors.filter(
(error) => !isDisconnectedRemoteScanError(error.message ?? '')
)
if (visibleErrors.length === 0) {
return null
}
if (visibleErrors.length === 1) {
const error = visibleErrors[0]
const repoName = formatScanErrorRepoName(error, repoNameById)
return `Could not check ${repoName}: ${formatScanErrorReason(error.message)}. Some workspaces may be missing. Refresh to try again.`
}
const repoNames = visibleErrors
.slice(0, 3)
.map((error) => formatScanErrorRepoName(error, repoNameById))
.join(', ')
const moreCount = visibleErrors.length - 3
const suffix = moreCount > 0 ? `, +${moreCount} more` : ''
return `Could not check ${visibleErrors.length} repositories (${repoNames}${suffix}). Some workspaces may be missing. Refresh to try again.`
}
export function formatWorkspaceCleanupScanProgress(
progress: WorkspaceCleanupScanProgress | null
): string {
if (!progress || progress.scannedWorktreeCount === 0) {
return translate(
'auto.components.workspace.cleanup.WorkspaceCleanupDialog.4cc5b73efe',
'Finding inactive workspaces...'
)
}
return translate(
'auto.components.workspace.cleanup.WorkspaceCleanupDialog.7b7bde5181',
'Checked workspaces so far: {{value0}}',
{ value0: progress.scannedWorktreeCount }
)
}
export function formatWorkspaceCleanupReadyToast(
inactiveCount: number,
suggestedCount: number
): string {
if (inactiveCount === 0) {
return 'No workspaces found.'
}
const workspaceNoun = inactiveCount === 1 ? 'workspace' : 'workspaces'
const suggestedNoun = suggestedCount === 1 ? 'suggestion' : 'suggestions'
return `${inactiveCount} ${workspaceNoun} found, with ${suggestedCount} cleanup ${suggestedNoun}.`
}
function formatScanErrorRepoName(
error: Partial<WorkspaceCleanupScanError>,
repoNameById: ReadonlyMap<string, string>
): string {
const repoName = error.repoName?.trim()
if (repoName) {
return repoName
}
const fallback = error.repoId ? repoNameById.get(error.repoId)?.trim() : ''
return fallback || 'a repository'
}
function formatScanErrorReason(message: string | undefined): string {
if (!message || message === 'Could not scan workspace cleanup for this repository.') {
return 'Git could not list worktrees'
}
return message.replace(/\.$/, '')
}
@@ -0,0 +1,73 @@
// @vitest-environment happy-dom
import { act } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { WorkspaceCleanupSortState } from '../../../../shared/workspace-cleanup-filter-model'
import { WorkspaceCleanupSortHeader } from './workspace-cleanup-sort-header'
let root: Root | null = null
let container: HTMLDivElement | null = null
function render(sort: WorkspaceCleanupSortState, handlers: Record<string, unknown> = {}): void {
act(() =>
root?.render(
<WorkspaceCleanupSortHeader
sort={sort}
selectableCount={3}
selectedCount={0}
onToggleSortField={vi.fn()}
onSetSort={vi.fn()}
onToggleSelectAll={vi.fn()}
{...handlers}
/>
)
)
}
function header(field: string): HTMLButtonElement | null {
return container?.querySelector<HTMLButtonElement>(`[data-sort-field="${field}"]`) ?? null
}
describe('WorkspaceCleanupSortHeader', () => {
beforeEach(() => {
container = document.createElement('div')
document.body.appendChild(container)
root = createRoot(container)
})
afterEach(() => {
if (root) {
act(() => root?.unmount())
}
container?.remove()
root = null
container = null
})
it('marks the active sort column', () => {
render({ field: 'size', direction: 'desc' })
expect(header('size')?.getAttribute('aria-pressed')).toBe('true')
expect(header('name')?.getAttribute('aria-pressed')).toBe('false')
})
it('routes a column click through the sort toggle', () => {
const onToggleSortField = vi.fn()
render({ field: 'last-activity', direction: 'asc' }, { onToggleSortField })
act(() => header('repo')?.click())
expect(onToggleSortField).toHaveBeenCalledWith('repo')
})
it('select-all reads the query result rather than the rendered page', () => {
const onToggleSelectAll = vi.fn()
render({ field: 'name', direction: 'asc' }, { onToggleSelectAll })
const checkbox = container?.querySelector<HTMLButtonElement>('[role="checkbox"]')
expect(checkbox?.getAttribute('aria-checked')).toBe('false')
act(() => checkbox?.click())
expect(onToggleSelectAll).toHaveBeenCalledWith(true)
})
})
@@ -0,0 +1,151 @@
import React from 'react'
import { ArrowDown, ArrowUp, Check, ChevronsUpDown } from 'lucide-react'
import { Button } from '@/components/ui/button'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu'
import { cn } from '@/lib/utils'
import { translate } from '@/i18n/i18n'
import { WORKSPACE_CLEANUP_SORT_FIELD_VALUES } from '../../../../shared/workspace-cleanup-facet-rankings'
import type {
WorkspaceCleanupSortField,
WorkspaceCleanupSortState
} from '../../../../shared/workspace-cleanup-filter-model'
import { getWorkspaceCleanupSortFieldLabel } from './workspace-cleanup-facet-labels'
/** Columns the flat list actually renders; the rest stay in the overflow menu. */
const PRIMARY_SORT_FIELDS: readonly WorkspaceCleanupSortField[] = [
'name',
'repo',
'git',
'review',
'size',
'last-activity'
]
export function WorkspaceCleanupSortHeader({
sort,
selectableCount,
selectedCount,
onToggleSortField,
onSetSort,
onToggleSelectAll
}: {
sort: WorkspaceCleanupSortState
selectableCount: number
selectedCount: number
onToggleSortField: (field: WorkspaceCleanupSortField) => void
onSetSort: (next: WorkspaceCleanupSortState) => void
onToggleSelectAll: (selectAll: boolean) => void
}): React.JSX.Element {
const allSelected = selectableCount > 0 && selectedCount >= selectableCount
return (
<div className="flex items-center gap-1 border-b border-border bg-muted/25 px-3 py-1.5">
<button
type="button"
role="checkbox"
aria-checked={allSelected}
disabled={selectableCount === 0}
aria-label={translate(
'components.workspace.cleanup.browse.selectAll',
'Select all matching workspaces'
)}
onClick={() => onToggleSelectAll(!allSelected)}
className="flex size-4 shrink-0 items-center justify-center rounded border border-border bg-background text-primary hover:bg-accent disabled:opacity-40 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
>
{allSelected ? <Check className="size-3" strokeWidth={3} /> : null}
</button>
<div className="flex min-w-0 flex-1 flex-wrap items-center gap-0.5">
{PRIMARY_SORT_FIELDS.map((field) => (
<SortHeaderButton
key={field}
field={field}
sort={sort}
onToggleSortField={onToggleSortField}
/>
))}
</div>
<DropdownMenu modal={false}>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="xs"
className="shrink-0 text-[11px] text-muted-foreground"
aria-label={translate('components.workspace.cleanup.browse.moreSorts', 'More sorts')}
>
<ChevronsUpDown className="size-3" />
{translate('components.workspace.cleanup.browse.moreSorts', 'More sorts')}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-56">
<DropdownMenuLabel>
{translate('components.workspace.cleanup.browse.sortBy', 'Sort by')}
</DropdownMenuLabel>
{WORKSPACE_CLEANUP_SORT_FIELD_VALUES.map((field) => (
<DropdownMenuItem
key={field}
onSelect={() =>
onSetSort({
field,
direction:
sort.field === field && sort.direction === 'asc' ? 'desc' : sort.direction
})
}
>
<span className="flex-1">{getWorkspaceCleanupSortFieldLabel(field)}</span>
{sort.field === field ? <SortDirectionIcon direction={sort.direction} /> : null}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
</div>
)
}
function SortHeaderButton({
field,
sort,
onToggleSortField
}: {
field: WorkspaceCleanupSortField
sort: WorkspaceCleanupSortState
onToggleSortField: (field: WorkspaceCleanupSortField) => void
}): React.JSX.Element {
const active = sort.field === field
const label = getWorkspaceCleanupSortFieldLabel(field)
return (
<button
type="button"
data-sort-field={field}
aria-pressed={active}
aria-label={translate(
'components.workspace.cleanup.browse.sortByField',
'Sort by {{value0}}',
{
value0: label
}
)}
onClick={() => onToggleSortField(field)}
className={cn(
'inline-flex h-6 items-center gap-1 rounded-md px-1.5 text-[11px] font-semibold uppercase tracking-[0.05em] text-muted-foreground transition-colors',
'hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring',
active && 'bg-accent text-accent-foreground'
)}
>
{label}
{active ? <SortDirectionIcon direction={sort.direction} /> : null}
</button>
)
}
function SortDirectionIcon({ direction }: { direction: 'asc' | 'desc' }): React.JSX.Element {
return direction === 'asc' ? (
<ArrowUp className="size-3" aria-hidden="true" />
) : (
<ArrowDown className="size-3" aria-hidden="true" />
)
}
@@ -1,94 +0,0 @@
import { describe, expect, it } from 'vitest'
import {
resolveWorkspaceCleanupActiveView,
type WorkspaceCleanupViewCounts
} from './workspace-cleanup-view-selection'
const emptyCounts: WorkspaceCleanupViewCounts = {
ready: 0,
review: 0,
protected: 0,
hidden: 0
}
describe('resolveWorkspaceCleanupActiveView', () => {
it('keeps the requested view while it still has rows', () => {
expect(
resolveWorkspaceCleanupActiveView({
requestedView: 'review',
counts: { ...emptyCounts, ready: 2, review: 1 },
open: true,
loading: false,
hasScan: true
})
).toBe('review')
})
it('falls back to the first populated cleanup view when the requested view is empty', () => {
expect(
resolveWorkspaceCleanupActiveView({
requestedView: 'protected',
counts: { ...emptyCounts, review: 3, hidden: 1 },
open: true,
loading: false,
hasScan: true
})
).toBe('review')
})
it('uses hidden suggestions when no visible cleanup views have rows', () => {
expect(
resolveWorkspaceCleanupActiveView({
requestedView: 'ready',
counts: { ...emptyCounts, hidden: 2 },
open: true,
loading: false,
hasScan: true
})
).toBe('hidden')
})
it('leaves the requested view alone before an open completed scan', () => {
expect(
resolveWorkspaceCleanupActiveView({
requestedView: 'protected',
counts: { ...emptyCounts, ready: 4 },
open: false,
loading: false,
hasScan: true
})
).toBe('protected')
expect(
resolveWorkspaceCleanupActiveView({
requestedView: 'protected',
counts: { ...emptyCounts, ready: 4 },
open: true,
loading: true,
hasScan: true
})
).toBe('protected')
expect(
resolveWorkspaceCleanupActiveView({
requestedView: 'protected',
counts: { ...emptyCounts, ready: 4 },
open: true,
loading: false,
hasScan: false
})
).toBe('protected')
})
it('keeps an empty requested view when every view is empty', () => {
expect(
resolveWorkspaceCleanupActiveView({
requestedView: 'hidden',
counts: emptyCounts,
open: true,
loading: false,
hasScan: true
})
).toBe('hidden')
})
})
@@ -1,34 +0,0 @@
export type WorkspaceCleanupView = 'ready' | 'review' | 'protected' | 'hidden'
export type WorkspaceCleanupViewCounts = Record<WorkspaceCleanupView, number>
export function resolveWorkspaceCleanupActiveView({
requestedView,
counts,
open,
loading,
hasScan
}: {
requestedView: WorkspaceCleanupView
counts: WorkspaceCleanupViewCounts
open: boolean
loading: boolean
hasScan: boolean
}): WorkspaceCleanupView {
if (!open || loading || !hasScan || counts[requestedView] > 0) {
return requestedView
}
if (counts.ready > 0) {
return 'ready'
}
if (counts.review > 0) {
return 'review'
}
if (counts.protected > 0) {
return 'protected'
}
if (counts.hidden > 0) {
return 'hidden'
}
return requestedView
}
+6 -1
View File
@@ -15118,7 +15118,12 @@
"none": "Has none"
},
"tierField": "Cleanup tier",
"idleSignalField": "Idle signal"
"idleSignalField": "Idle signal",
"selectionVanishedOne": "1 selected workspace no longer exists.",
"selectionVanished": "{{value0}} selected workspaces no longer exist.",
"updatedAgo": "Updated {{value0}}",
"refreshing": "Refreshing…",
"refreshingProgress": "Refreshing {{value0}}/{{value1}}"
}
}
},
@@ -0,0 +1,196 @@
import { describe, expect, it, vi } from 'vitest'
import type { AppState } from '../types'
import type {
WorkspaceCleanupScanProgress,
WorkspaceCleanupScanResult
} from '../../../../shared/workspace-cleanup'
import {
NOW,
createCleanupTestStore,
deferred,
installWorkspaceCleanupApi,
makeCandidate
} from './workspace-cleanup-slice-test-harness'
const CACHED_AT = NOW - 60 * 60 * 1000
function makeCachedScan(): WorkspaceCleanupScanResult {
return {
scannedAt: CACHED_AT,
candidates: [
makeCandidate({ worktreeId: 'repo1::/tmp/alpha', displayName: 'alpha' }),
makeCandidate({ worktreeId: 'repo1::/tmp/beta', displayName: 'beta' })
],
errors: []
}
}
describe('workspace cleanup cache hydration', () => {
it('hydrates an empty slice from the persisted snapshot', async () => {
const scan = vi.fn()
const getCachedScan = vi.fn().mockResolvedValue(makeCachedScan())
installWorkspaceCleanupApi(scan, getCachedScan)
const store = createCleanupTestStore()
await expect(store.getState().hydrateWorkspaceCleanupFromCache()).resolves.toBe(true)
expect(scan).not.toHaveBeenCalled()
expect(store.getState().workspaceCleanupScan?.scannedAt).toBe(CACHED_AT)
expect(
store.getState().workspaceCleanupScan?.candidates.map((candidate) => candidate.worktreeId)
).toEqual(['repo1::/tmp/alpha', 'repo1::/tmp/beta'])
})
it('never clobbers live scan data with the snapshot', async () => {
const getCachedScan = vi.fn().mockResolvedValue(makeCachedScan())
installWorkspaceCleanupApi(vi.fn(), getCachedScan)
const store = createCleanupTestStore()
const live = { scannedAt: NOW, candidates: [makeCandidate()], errors: [] }
store.setState({ workspaceCleanupScan: live } as Partial<AppState>)
await expect(store.getState().hydrateWorkspaceCleanupFromCache()).resolves.toBe(false)
expect(getCachedScan).not.toHaveBeenCalled()
expect(store.getState().workspaceCleanupScan).toBe(live)
})
it('bails when a broad scan starts while the cache read is in flight', async () => {
const cacheRead = deferred<WorkspaceCleanupScanResult>()
const broadScan = deferred<WorkspaceCleanupScanResult>()
const scan = vi.fn().mockReturnValue(broadScan.promise)
installWorkspaceCleanupApi(scan, vi.fn().mockReturnValue(cacheRead.promise))
const store = createCleanupTestStore()
const hydration = store.getState().hydrateWorkspaceCleanupFromCache()
const scanPromise = store.getState().scanWorkspaceCleanup()
cacheRead.resolve(makeCachedScan())
await expect(hydration).resolves.toBe(false)
expect(store.getState().workspaceCleanupScan).toBeNull()
broadScan.resolve({ scannedAt: NOW, candidates: [], errors: [] })
await scanPromise
expect(store.getState().workspaceCleanupScan?.scannedAt).toBe(NOW)
})
it('reconciles a refresh into the hydrated snapshot instead of clearing it', async () => {
const pending = deferred<WorkspaceCleanupScanResult>()
let onProgress: ((progress: WorkspaceCleanupScanProgress) => void) | undefined
const scan = vi.fn((_args, progressCallback) => {
onProgress = progressCallback
return pending.promise
})
installWorkspaceCleanupApi(scan, vi.fn().mockResolvedValue(makeCachedScan()))
const store = createCleanupTestStore()
await store.getState().hydrateWorkspaceCleanupFromCache()
const scanPromise = store.getState().scanWorkspaceCleanup()
const refreshedAlpha = makeCandidate({
worktreeId: 'repo1::/tmp/alpha',
displayName: 'alpha',
lastActivityAt: NOW - 1000,
fingerprint: 'fingerprint-alpha-2'
})
const gamma = makeCandidate({ worktreeId: 'repo1::/tmp/gamma', displayName: 'gamma' })
onProgress?.({
scanId: 'scan-1',
scannedAt: NOW,
scannedWorktreeCount: 2,
totalWorktreeCount: 3,
candidates: [refreshedAlpha, gamma],
errors: [],
candidateMode: 'append'
})
await vi.waitFor(() => {
expect(store.getState().workspaceCleanupProgress?.scannedWorktreeCount).toBe(2)
})
const streaming = store.getState().workspaceCleanupScan
// Update in place, keep unseen snapshot rows, append new — never clear.
expect(streaming?.candidates.map((candidate) => candidate.worktreeId)).toEqual([
'repo1::/tmp/alpha',
'repo1::/tmp/beta',
'repo1::/tmp/gamma'
])
expect(streaming?.candidates[0]?.fingerprint).toBe('fingerprint-alpha-2')
// The presented "as of" time stays the snapshot's until the scan settles.
expect(streaming?.scannedAt).toBe(CACHED_AT)
pending.resolve({ scannedAt: NOW, candidates: [refreshedAlpha, gamma], errors: [] })
await scanPromise
const settled = store.getState().workspaceCleanupScan
expect(settled?.scannedAt).toBe(NOW)
// beta vanished from the final result, so only the settle removes it.
expect(settled?.candidates.map((candidate) => candidate.worktreeId)).toEqual([
'repo1::/tmp/alpha',
'repo1::/tmp/gamma'
])
})
it('keeps one scan streaming across a dialog close and reopen', async () => {
const pending = deferred<WorkspaceCleanupScanResult>()
let onProgress: ((progress: WorkspaceCleanupScanProgress) => void) | undefined
const scan = vi.fn((_args, progressCallback) => {
onProgress = progressCallback
return pending.promise
})
installWorkspaceCleanupApi(scan, vi.fn().mockResolvedValue(null))
const store = createCleanupTestStore()
const first = store.getState().scanWorkspaceCleanup()
const early = [
makeCandidate({ worktreeId: 'repo1::/tmp/one' }),
makeCandidate({ worktreeId: 'repo1::/tmp/two' })
]
onProgress?.({
scanId: 'scan-1',
scannedAt: NOW,
scannedWorktreeCount: 2,
totalWorktreeCount: 5,
candidates: early,
errors: [],
candidateMode: 'append'
})
await vi.waitFor(() => {
expect(store.getState().workspaceCleanupProgress?.scannedWorktreeCount).toBe(2)
})
// Dialog closes at 40% and reopens: reopening adopts the in-flight scan.
// The dialog joins via another scan call, which must not restart anything.
const rejoined = store.getState().scanWorkspaceCleanup()
expect(scan).toHaveBeenCalledTimes(1)
const late = [
makeCandidate({ worktreeId: 'repo1::/tmp/three' }),
makeCandidate({ worktreeId: 'repo1::/tmp/four' }),
makeCandidate({ worktreeId: 'repo1::/tmp/five' })
]
onProgress?.({
scanId: 'scan-1',
scannedAt: NOW,
scannedWorktreeCount: 5,
totalWorktreeCount: 5,
candidates: late,
errors: [],
candidateMode: 'append'
})
await vi.waitFor(() => {
expect(store.getState().workspaceCleanupProgress?.scannedWorktreeCount).toBe(5)
})
pending.resolve({ scannedAt: NOW, candidates: [...early, ...late], errors: [] })
await Promise.all([first, rejoined])
expect(store.getState().workspaceCleanupLoading).toBe(false)
expect(
store.getState().workspaceCleanupScan?.candidates.map((candidate) => candidate.worktreeId)
).toEqual([
'repo1::/tmp/one',
'repo1::/tmp/two',
'repo1::/tmp/three',
'repo1::/tmp/four',
'repo1::/tmp/five'
])
})
})
@@ -0,0 +1,41 @@
import type {
WorkspaceCleanupCandidate,
WorkspaceCleanupScanResult
} from '../../../../shared/workspace-cleanup'
/**
* Stale-while-revalidate seed: fills an empty cleanup slice from the snapshot
* main persisted, so the dialog renders a full list immediately and the
* background rescan reconciles into it instead of rebuilding from nothing.
*/
export async function hydrateWorkspaceCleanupScanFromCache({
hasLiveScanState,
enrich,
apply
}: {
/** True once live scan data exists or a broad scan runs — cache never clobbers it. */
hasLiveScanState: () => boolean
enrich: (candidates: readonly WorkspaceCleanupCandidate[]) => Promise<WorkspaceCleanupCandidate[]>
apply: (scan: WorkspaceCleanupScanResult) => void
}): Promise<boolean> {
if (hasLiveScanState()) {
return false
}
let cached: WorkspaceCleanupScanResult | null
try {
cached = await window.api.workspaceCleanup.getCachedScan()
} catch {
// Why: hydration is best-effort; the rescan that follows is the recovery.
return false
}
if (cached === null || hasLiveScanState()) {
return false
}
const candidates = await enrich(cached.candidates)
// Why: enrichment awaits terminal probes; a scan may have started meanwhile.
if (hasLiveScanState()) {
return false
}
apply({ ...cached, candidates })
return true
}
@@ -237,6 +237,7 @@ describe('workspace cleanup removal and protection', () => {
expect(scan).toHaveBeenCalledWith(
{
includeAllWorkspaces: true,
skipGitWorktreeIds: expect.arrayContaining([WORKTREE_ID, 'repo1::/tmp/terminal-workspace'])
},
expect.any(Function)
@@ -80,11 +80,15 @@ export function createCleanupTestStore(removeWorktree: ReturnType<typeof vi.fn>
)
}
export function installWorkspaceCleanupApi(scan: ReturnType<typeof vi.fn>) {
export function installWorkspaceCleanupApi(
scan: ReturnType<typeof vi.fn>,
getCachedScan: ReturnType<typeof vi.fn> = vi.fn().mockResolvedValue(null)
) {
;(globalThis as { window: unknown }).window = {
api: {
workspaceCleanup: {
scan,
getCachedScan,
dismiss: vi.fn().mockResolvedValue(undefined),
clearDismissals: vi.fn().mockResolvedValue(undefined),
hasKillableLocalProcesses: vi.fn().mockResolvedValue({
@@ -22,6 +22,7 @@ import {
type WorkspaceCleanupScanResult
} from '../../../../shared/workspace-cleanup'
import { mapWithConcurrency } from '../../../../shared/map-with-concurrency'
import { hydrateWorkspaceCleanupScanFromCache } from './workspace-cleanup-cache-hydration'
import { classifyTitleActivity, isExplicitAgentStatusFresh } from '@/lib/pane-agent-evidence'
import { translate } from '@/i18n/i18n'
@@ -56,6 +57,8 @@ export type WorkspaceCleanupSlice = {
workspaceCleanupDismissals: Record<string, WorkspaceCleanupDismissal>
workspaceCleanupViewedCandidates: Record<string, WorkspaceCleanupViewedCandidate>
scanWorkspaceCleanup: (args?: WorkspaceCleanupScanArgs) => Promise<WorkspaceCleanupScanResult>
/** Stale-while-revalidate seed; true when the persisted snapshot filled an empty slice. */
hydrateWorkspaceCleanupFromCache: () => Promise<boolean>
markWorkspaceCleanupCandidateViewed: (candidate: WorkspaceCleanupCandidate) => void
dismissWorkspaceCleanupCandidates: (
candidates: readonly WorkspaceCleanupCandidate[]
@@ -158,6 +161,9 @@ export const createWorkspaceCleanupSlice: StateCreator<AppState, [], [], Workspa
}
const scanArgs = {
// Why: the dialog renders one flat list of every workspace, so a broad
// scan must not stop at the 30-day-idle suggestions.
includeAllWorkspaces: true,
...args,
skipGitWorktreeIds: [
...new Set([
@@ -235,6 +241,15 @@ export const createWorkspaceCleanupSlice: StateCreator<AppState, [], [], Workspa
}
},
hydrateWorkspaceCleanupFromCache: () =>
hydrateWorkspaceCleanupScanFromCache({
// Why: loading covers an in-flight broad scan whose progress may not have
// reached the store yet; the cache must never clobber either.
hasLiveScanState: () => get().workspaceCleanupScan !== null || get().workspaceCleanupLoading,
enrich: (candidates) => enrichWorkspaceCleanupCandidates(candidates, get()),
apply: (scan) => set({ workspaceCleanupScan: scan })
}),
markWorkspaceCleanupCandidateViewed: (candidate) => {
set((state) => ({
workspaceCleanupViewedCandidates: {
@@ -367,6 +382,7 @@ export const createWorkspaceCleanupSlice: StateCreator<AppState, [], [], Workspa
function getWorkspaceCleanupScanKey(args: WorkspaceCleanupScanArgs): string {
return JSON.stringify({
includeAllWorkspaces: args.includeAllWorkspaces === true,
skipGitWorktreeIds: [...new Set(args.skipGitWorktreeIds ?? [])].sort()
})
}
@@ -424,11 +440,13 @@ async function applyWorkspaceCleanupProgress(
return
}
const state = getState()
// Why: a cached snapshot (or the previous settled scan) may already fill the
// list; streamed rows reconcile into it by worktreeId so a refresh never
// clears and rebuilds what the user is reading.
const previousCandidates =
progress.candidateMode === 'append' &&
state.workspaceCleanupProgress?.scanId === progress.scanId
? state.workspaceCleanupProgress.candidates
: []
: (state.workspaceCleanupScan?.candidates ?? [])
const enrichedProgressCandidates = await enrichWorkspaceCleanupCandidatesForScan(
progress.candidates,
state,
@@ -462,7 +480,10 @@ async function applyWorkspaceCleanupProgress(
}
return {
workspaceCleanupScan: {
scannedAt: progress.scannedAt,
// Why: mid-refresh the list still mixes in rows from the previous
// snapshot; the honest "as of" time stays the snapshot's until the new
// scan settles and removes vanished rows.
scannedAt: state.workspaceCleanupScan?.scannedAt ?? progress.scannedAt,
candidates,
errors: progress.errors
},
@@ -497,11 +518,8 @@ function mergeWorkspaceCleanupProgressCandidates({
progress: WorkspaceCleanupScanProgress
scanToken: number
}): WorkspaceCleanupCandidate[] {
if (progress.candidateMode !== 'append') {
workspaceCleanupProgressCandidateIndex = null
return [...nextCandidates]
}
// Why: snapshot-mode ticks also merge — replacing would drop the
// stale-while-revalidate rows this scan has not re-reported yet.
if (nextCandidates.length === 0) {
return previousCandidates as WorkspaceCleanupCandidate[]
}
@@ -14,6 +14,8 @@ export type WorkspaceSpaceSlice = {
workspaceSpaceScanning: boolean
applyWorkspaceSpaceProgress: (progress: WorkspaceSpaceScanProgress) => void
cancelWorkspaceSpaceScan: () => Promise<boolean>
/** Stale-while-revalidate seed; true when the persisted analysis filled an empty slice. */
hydrateWorkspaceSpaceFromCache: () => Promise<boolean>
refreshWorkspaceSpace: () => Promise<WorkspaceSpaceAnalysis>
removeWorkspaceSpaceWorktrees: (worktreeIds: readonly string[]) => void
}
@@ -105,6 +107,25 @@ export const createWorkspaceSpaceSlice: StateCreator<AppState, [], [], Workspace
}
return cancelled
},
hydrateWorkspaceSpaceFromCache: async () => {
const hasLiveAnalysis = (): boolean =>
get().workspaceSpaceAnalysis !== null || get().workspaceSpaceScanning || inFlightScan !== null
if (hasLiveAnalysis()) {
return false
}
let cached: Awaited<ReturnType<typeof window.api.workspaceSpace.getCachedAnalysis>>
try {
cached = await window.api.workspaceSpace.getCachedAnalysis()
} catch {
// Why: hydration is best-effort; a manual scan remains the recovery path.
return false
}
if (cached === null || hasLiveAnalysis()) {
return false
}
set({ workspaceSpaceAnalysis: cached })
return true
},
refreshWorkspaceSpace: async () => {
if (inFlightScan) {
return inFlightScan