feat(sidebar): add a filter to hide detached-HEAD workspaces (#10786)

Adds "Hide detached HEAD" alongside the existing sidebar filters, wired
through the same pipeline as Hide CLI-created: sidebar list, Cmd+J
empty-query list, workspace board, active-filter badges, Clear/Reset
Filters, and persisted UI state.

The predicate reuses getWorktreeGitIdentityDisplay so the filter targets
exactly what the card renders a Detached HEAD badge for. Requiring a real
head (not just an empty branch) keeps folder workspaces and
SSH-synthesized rows — which carry both empty — out of the filter.

Activating a hidden detached workspace clears the filter, matching the
existing reveal escape hatch for automation- and CLI-created workspaces.

Splits the filter-state describes out of visible-worktrees.test.ts into
sidebar-filter-state.test.ts to stay under the max-lines budget.

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Neil
2026-07-26 14:42:05 -07:00
committed by GitHub
co-authored by Orca
parent 5d17bd8f33
commit af708d3471
22 changed files with 376 additions and 182 deletions
@@ -198,6 +198,7 @@ export const UiUpdate = z
hideDefaultBranchWorkspace: z.boolean().optional(),
hideAutomationGeneratedWorkspaces: z.boolean().optional(),
hideCliCreatedWorkspaces: z.boolean().optional(),
hideDetachedHeadWorkspaces: z.boolean().optional(),
filterRepoIds: StringArray.optional(),
collapsedGroups: StringArray.optional(),
uiZoomLevel: z.number().finite().optional(),
+3
View File
@@ -639,6 +639,7 @@ function App(): React.JSX.Element {
const hideDefaultBranchWorkspace = useAppStore((s) => s.hideDefaultBranchWorkspace)
const hideAutomationGeneratedWorkspaces = useAppStore((s) => s.hideAutomationGeneratedWorkspaces)
const hideCliCreatedWorkspaces = useAppStore((s) => s.hideCliCreatedWorkspaces)
const hideDetachedHeadWorkspaces = useAppStore((s) => s.hideDetachedHeadWorkspaces)
const showDotfilesByWorktree = useAppStore((s) => s.showDotfilesByWorktree)
const filterRepoIds = useAppStore((s) => s.filterRepoIds)
const acknowledgedAgentsByPaneKey = useAppStore((s) => s.acknowledgedAgentsByPaneKey)
@@ -1339,6 +1340,7 @@ function App(): React.JSX.Element {
hideDefaultBranchWorkspace,
hideAutomationGeneratedWorkspaces,
hideCliCreatedWorkspaces,
hideDetachedHeadWorkspaces,
showDotfilesByWorktree,
filterRepoIds,
// Why (#9002): activeView is deliberately NOT included here. It used to
@@ -1370,6 +1372,7 @@ function App(): React.JSX.Element {
hideDefaultBranchWorkspace,
hideAutomationGeneratedWorkspaces,
hideCliCreatedWorkspaces,
hideDetachedHeadWorkspaces,
showDotfilesByWorktree,
filterRepoIds,
acknowledgedAgentsByPaneKey
@@ -31,6 +31,7 @@ import { sortWorktreesSmart } from '@/components/sidebar/smart-sort'
import {
isAutomationGeneratedWorkspace,
isCliCreatedWorkspace,
isDetachedHeadWorkspace,
isDefaultBranchWorkspace
} from '@/components/sidebar/visible-worktrees'
import { getLiveAgentStatusByWorktreeId, isInactiveWorkspace } from '@/lib/worktree-activity-state'
@@ -394,6 +395,7 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null {
const hideDefaultBranchWorkspace = useAppStore((s) => s.hideDefaultBranchWorkspace)
const hideAutomationGeneratedWorkspaces = useAppStore((s) => s.hideAutomationGeneratedWorkspaces)
const hideCliCreatedWorkspaces = useAppStore((s) => s.hideCliCreatedWorkspaces)
const hideDetachedHeadWorkspaces = useAppStore((s) => s.hideDetachedHeadWorkspaces)
const showSleepingWorkspaces = useAppStore((s) => s.showSleepingWorkspaces)
const lastVisitedAtByWorktreeId = useAppStore((s) => s.lastVisitedAtByWorktreeId)
const workspacePortScan = useAppStore((s) => s.workspacePortScan?.result ?? null)
@@ -487,6 +489,9 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null {
if (hideCliCreatedWorkspaces && isCliCreatedWorkspace(worktree)) {
return false
}
if (hideDetachedHeadWorkspaces && isDetachedHeadWorkspace(worktree)) {
return false
}
if (
!showSleepingWorkspaces &&
isInactiveWorkspace(
@@ -507,6 +512,7 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null {
hideAutomationGeneratedWorkspaces,
hideCliCreatedWorkspaces,
hideDefaultBranchWorkspace,
hideDetachedHeadWorkspaces,
ptyIdsByTabId,
showSleepingWorkspaces,
tabsByWorktree,
@@ -4,6 +4,7 @@ import {
Check,
FolderPlus,
GitBranch,
GitCommitHorizontal,
ListFilter,
Moon,
Server,
@@ -59,6 +60,8 @@ const SidebarFilter = React.memo(function SidebarFilter({
)
const hideCliCreatedWorkspaces = useAppStore((s) => s.hideCliCreatedWorkspaces)
const setHideCliCreatedWorkspaces = useAppStore((s) => s.setHideCliCreatedWorkspaces)
const hideDetachedHeadWorkspaces = useAppStore((s) => s.hideDetachedHeadWorkspaces)
const setHideDetachedHeadWorkspaces = useAppStore((s) => s.setHideDetachedHeadWorkspaces)
const filterRepoIds = useAppStore((s) => s.filterRepoIds)
const setFilterRepoIds = useAppStore((s) => s.setFilterRepoIds)
const repos = useAppStore((s) => s.repos)
@@ -110,12 +113,14 @@ const SidebarFilter = React.memo(function SidebarFilter({
hideDefaultBranchWorkspace ||
hideAutomationGeneratedWorkspaces ||
hideCliCreatedWorkspaces ||
hideDetachedHeadWorkspaces ||
hasRepoFilter
const activeFilterCount =
(hasSleepingFilter ? 1 : 0) +
(hideDefaultBranchWorkspace ? 1 : 0) +
(hideAutomationGeneratedWorkspaces ? 1 : 0) +
(hideCliCreatedWorkspaces ? 1 : 0) +
(hideDetachedHeadWorkspaces ? 1 : 0) +
selectedCount
const filteredRepos = useMemo(() => searchRepos(repos, query), [repos, query])
@@ -130,12 +135,14 @@ const SidebarFilter = React.memo(function SidebarFilter({
setHideDefaultBranchWorkspace(false)
setHideAutomationGeneratedWorkspaces(false)
setHideCliCreatedWorkspaces(false)
setHideDetachedHeadWorkspaces(false)
setFilterRepoIds([])
}, [
setShowSleepingWorkspaces,
setHideDefaultBranchWorkspace,
setHideAutomationGeneratedWorkspaces,
setHideCliCreatedWorkspaces,
setHideDetachedHeadWorkspaces,
setFilterRepoIds
])
@@ -229,6 +236,15 @@ const SidebarFilter = React.memo(function SidebarFilter({
checked={hideCliCreatedWorkspaces}
onChange={setHideCliCreatedWorkspaces}
/>
<FilterToggleRow
icon={<GitCommitHorizontal className="size-3.5" />}
label={translate(
'auto.components.sidebar.SidebarFilter.detachedHead',
'Hide detached HEAD'
)}
checked={hideDetachedHeadWorkspaces}
onChange={setHideDetachedHeadWorkspaces}
/>
{canFilterRepos && (
<>
@@ -1,5 +1,5 @@
import React from 'react'
import { CalendarClock, GitBranch, Moon, SquareTerminal } from 'lucide-react'
import { CalendarClock, GitBranch, GitCommitHorizontal, Moon, SquareTerminal } from 'lucide-react'
import { useAppStore } from '@/store'
import { cn } from '@/lib/utils'
import { translate } from '@/i18n/i18n'
@@ -15,6 +15,8 @@ const SidebarWorkspaceFilterSection = React.memo(function SidebarWorkspaceFilter
)
const hideCliCreatedWorkspaces = useAppStore((s) => s.hideCliCreatedWorkspaces)
const setHideCliCreatedWorkspaces = useAppStore((s) => s.setHideCliCreatedWorkspaces)
const hideDetachedHeadWorkspaces = useAppStore((s) => s.hideDetachedHeadWorkspaces)
const setHideDetachedHeadWorkspaces = useAppStore((s) => s.setHideDetachedHeadWorkspaces)
return (
<>
@@ -59,6 +61,15 @@ const SidebarWorkspaceFilterSection = React.memo(function SidebarWorkspaceFilter
checked={hideCliCreatedWorkspaces}
onChange={setHideCliCreatedWorkspaces}
/>
<FilterToggleRow
icon={<GitCommitHorizontal className="size-3.5" />}
label={translate(
'auto.components.sidebar.SidebarWorkspaceFilterSection.detachedHead',
'Hide detached HEAD'
)}
checked={hideDetachedHeadWorkspaces}
onChange={setHideDetachedHeadWorkspaces}
/>
</>
)
})
@@ -39,6 +39,7 @@ const SidebarWorkspaceOptionsMenu = React.memo(function SidebarWorkspaceOptionsM
const hideDefaultBranchWorkspace = useAppStore((s) => s.hideDefaultBranchWorkspace)
const hideAutomationGeneratedWorkspaces = useAppStore((s) => s.hideAutomationGeneratedWorkspaces)
const hideCliCreatedWorkspaces = useAppStore((s) => s.hideCliCreatedWorkspaces)
const hideDetachedHeadWorkspaces = useAppStore((s) => s.hideDetachedHeadWorkspaces)
const filterRepoIds = useAppStore((s) => s.filterRepoIds)
const repos = useAppStore((s) => s.repos)
const setWorkspaceHostScope = useAppStore((s) => s.setWorkspaceHostScope)
@@ -82,6 +83,7 @@ const SidebarWorkspaceOptionsMenu = React.memo(function SidebarWorkspaceOptionsM
hideDefaultBranchWorkspace ||
hideAutomationGeneratedWorkspaces ||
hideCliCreatedWorkspaces ||
hideDetachedHeadWorkspaces ||
hasRepoFilter ||
hasHostVisibilityFilter
const activeFilterCount =
@@ -89,6 +91,7 @@ const SidebarWorkspaceOptionsMenu = React.memo(function SidebarWorkspaceOptionsM
(hideDefaultBranchWorkspace ? 1 : 0) +
(hideAutomationGeneratedWorkspaces ? 1 : 0) +
(hideCliCreatedWorkspaces ? 1 : 0) +
(hideDetachedHeadWorkspaces ? 1 : 0) +
(hasHostVisibilityFilter ? 1 : 0) +
selectedCount
const activeFilterLabel = `${activeFilterCount} ${activeFilterCount === 1 ? 'filter' : 'filters'}`
@@ -5232,6 +5232,7 @@ const WorktreeList = React.memo(function WorktreeList({
const hideDefaultBranchWorkspace = useAppStore((s) => s.hideDefaultBranchWorkspace)
const hideAutomationGeneratedWorkspaces = useAppStore((s) => s.hideAutomationGeneratedWorkspaces)
const hideCliCreatedWorkspaces = useAppStore((s) => s.hideCliCreatedWorkspaces)
const hideDetachedHeadWorkspaces = useAppStore((s) => s.hideDetachedHeadWorkspaces)
const filterRepoIds = useAppStore((s) => s.filterRepoIds)
const openModal = useAppStore((s) => s.openModal)
const openSettingsPage = useAppStore((s) => s.openSettingsPage)
@@ -5518,6 +5519,7 @@ const WorktreeList = React.memo(function WorktreeList({
hideDefaultBranchWorkspace,
hideAutomationGeneratedWorkspaces,
hideCliCreatedWorkspaces,
hideDetachedHeadWorkspaces,
repoMap,
workspaceHostScope,
visibleWorkspaceHostIds,
@@ -5534,6 +5536,7 @@ const WorktreeList = React.memo(function WorktreeList({
hideDefaultBranchWorkspace,
hideAutomationGeneratedWorkspaces,
hideCliCreatedWorkspaces,
hideDetachedHeadWorkspaces,
workspaceHostScope,
visibleWorkspaceHostIds,
settings,
@@ -6494,6 +6497,7 @@ const WorktreeList = React.memo(function WorktreeList({
hideDefaultBranchWorkspace,
hideAutomationGeneratedWorkspaces,
hideCliCreatedWorkspaces,
hideDetachedHeadWorkspaces,
visibleWorkspaceHostIds,
workspaceHostScope
}),
@@ -6503,6 +6507,7 @@ const WorktreeList = React.memo(function WorktreeList({
hideDefaultBranchWorkspace,
hideAutomationGeneratedWorkspaces,
hideCliCreatedWorkspaces,
hideDetachedHeadWorkspaces,
visibleWorkspaceHostIds,
workspaceHostScope
]
@@ -6514,6 +6519,7 @@ const WorktreeList = React.memo(function WorktreeList({
(s) => s.setHideAutomationGeneratedWorkspaces
)
const setHideCliCreatedWorkspaces = useAppStore((s) => s.setHideCliCreatedWorkspaces)
const setHideDetachedHeadWorkspaces = useAppStore((s) => s.setHideDetachedHeadWorkspaces)
const setFilterRepoIds = useAppStore((s) => s.setFilterRepoIds)
const setVisibleWorkspaceHostIds = useAppStore((s) => s.setVisibleWorkspaceHostIds)
@@ -6534,6 +6540,9 @@ const WorktreeList = React.memo(function WorktreeList({
if (actions.resetHideCliCreatedWorkspaces) {
setHideCliCreatedWorkspaces(false)
}
if (actions.resetHideDetachedHeadWorkspaces) {
setHideDetachedHeadWorkspaces(false)
}
if (actions.resetVisibleWorkspaceHostIds) {
setVisibleWorkspaceHostIds(null)
}
@@ -6543,6 +6552,7 @@ const WorktreeList = React.memo(function WorktreeList({
setHideDefaultBranchWorkspace,
setHideAutomationGeneratedWorkspaces,
setHideCliCreatedWorkspaces,
setHideDetachedHeadWorkspaces,
setVisibleWorkspaceHostIds,
filterState
])
@@ -0,0 +1,217 @@
import { describe, expect, it } from 'vitest'
import {
computeClearFilterActions,
isDefaultBranchWorkspace,
sidebarHasActiveFilters
} from './visible-worktrees'
import type { Worktree } from '../../../../shared/types'
function makeWorktree(id: string, repoId = 'repo1'): Worktree {
return {
id,
repoId,
path: `/tmp/${id}`,
head: 'abc123',
branch: 'refs/heads/main',
isBare: false,
isMainWorktree: false,
displayName: id,
comment: '',
linkedIssue: null,
linkedPR: null,
linkedLinearIssue: null,
isArchived: false,
isUnread: false,
isPinned: false,
sortOrder: 0,
lastActivityAt: 0
}
}
type FilterState = Parameters<typeof sidebarHasActiveFilters>[0]
function filterState(overrides: Partial<FilterState> = {}): FilterState {
return {
showSleepingWorkspaces: true,
filterRepoIds: [],
hideDefaultBranchWorkspace: false,
hideAutomationGeneratedWorkspaces: false,
hideCliCreatedWorkspaces: false,
hideDetachedHeadWorkspaces: false,
workspaceHostScope: 'all',
...overrides
}
}
describe('isDefaultBranchWorkspace', () => {
it('returns true for a branch-backed main worktree', () => {
const main = makeWorktree('main')
main.isMainWorktree = true
expect(isDefaultBranchWorkspace(main)).toBe(true)
})
it('returns false for folder-mode main worktrees (empty branch)', () => {
const folder = makeWorktree('folder')
folder.isMainWorktree = true
folder.branch = ''
expect(isDefaultBranchWorkspace(folder)).toBe(false)
})
it('returns false for non-main worktrees even on the default branch', () => {
const feature = makeWorktree('feature')
expect(isDefaultBranchWorkspace(feature)).toBe(false)
})
})
describe('sidebarHasActiveFilters', () => {
it('returns false when no filters are active', () => {
expect(sidebarHasActiveFilters(filterState())).toBe(false)
})
it('returns true when only hideDefaultBranchWorkspace is active', () => {
// Why: regression guard for the empty-sidebar escape hatch. If hide is
// omitted from the filter union, a user whose only worktree is the
// default-branch row sees "No workspaces found" with no way back.
expect(sidebarHasActiveFilters(filterState({ hideDefaultBranchWorkspace: true }))).toBe(true)
})
it('returns true when only automation-created workspaces are hidden', () => {
expect(sidebarHasActiveFilters(filterState({ hideAutomationGeneratedWorkspaces: true }))).toBe(
true
)
})
it('returns true when only CLI-created workspaces are hidden', () => {
expect(sidebarHasActiveFilters(filterState({ hideCliCreatedWorkspaces: true }))).toBe(true)
})
it('returns true when only detached-HEAD workspaces are hidden', () => {
expect(sidebarHasActiveFilters(filterState({ hideDetachedHeadWorkspaces: true }))).toBe(true)
})
it('returns true when sleeping workspaces are hidden', () => {
expect(sidebarHasActiveFilters(filterState({ showSleepingWorkspaces: false }))).toBe(true)
})
it('returns true when only filterRepoIds is non-empty', () => {
expect(sidebarHasActiveFilters(filterState({ filterRepoIds: ['repo1'] }))).toBe(true)
})
it('returns true when only host visibility is narrowed', () => {
expect(sidebarHasActiveFilters(filterState({ visibleWorkspaceHostIds: ['local'] }))).toBe(true)
})
})
describe('computeClearFilterActions', () => {
it('returns no-op actions when nothing is set', () => {
expect(computeClearFilterActions(filterState())).toEqual({
resetShowSleepingWorkspaces: false,
resetFilterRepoIds: false,
resetHideDefaultBranchWorkspace: false,
resetHideAutomationGeneratedWorkspaces: false,
resetHideCliCreatedWorkspaces: false,
resetHideDetachedHeadWorkspaces: false,
resetVisibleWorkspaceHostIds: false
})
})
it('flags only hideDefaultBranchWorkspace for reset when it is the sole filter', () => {
// Why: verifies the empty-sidebar escape hatch actually clears the hide
// flag. A regression here would leave users stuck on "No workspaces found"
// because the only active filter would never clear.
expect(computeClearFilterActions(filterState({ hideDefaultBranchWorkspace: true }))).toEqual({
resetShowSleepingWorkspaces: false,
resetFilterRepoIds: false,
resetHideDefaultBranchWorkspace: true,
resetHideAutomationGeneratedWorkspaces: false,
resetHideCliCreatedWorkspaces: false,
resetHideDetachedHeadWorkspaces: false,
resetVisibleWorkspaceHostIds: false
})
})
it('flags only hideAutomationGeneratedWorkspaces for reset when it is the sole filter', () => {
expect(
computeClearFilterActions(filterState({ hideAutomationGeneratedWorkspaces: true }))
).toEqual({
resetShowSleepingWorkspaces: false,
resetFilterRepoIds: false,
resetHideDefaultBranchWorkspace: false,
resetHideAutomationGeneratedWorkspaces: true,
resetHideCliCreatedWorkspaces: false,
resetHideDetachedHeadWorkspaces: false,
resetVisibleWorkspaceHostIds: false
})
})
it('flags only hideCliCreatedWorkspaces for reset when it is the sole filter', () => {
expect(computeClearFilterActions(filterState({ hideCliCreatedWorkspaces: true }))).toEqual({
resetShowSleepingWorkspaces: false,
resetFilterRepoIds: false,
resetHideDefaultBranchWorkspace: false,
resetHideAutomationGeneratedWorkspaces: false,
resetHideCliCreatedWorkspaces: true,
resetHideDetachedHeadWorkspaces: false,
resetVisibleWorkspaceHostIds: false
})
})
it('flags only hideDetachedHeadWorkspaces for reset when it is the sole filter', () => {
expect(computeClearFilterActions(filterState({ hideDetachedHeadWorkspaces: true }))).toEqual({
resetShowSleepingWorkspaces: false,
resetFilterRepoIds: false,
resetHideDefaultBranchWorkspace: false,
resetHideAutomationGeneratedWorkspaces: false,
resetHideCliCreatedWorkspaces: false,
resetHideDetachedHeadWorkspaces: true,
resetVisibleWorkspaceHostIds: false
})
})
it('does not flag hideDefaultBranchWorkspace when it is already off', () => {
// Why: avoids issuing a pointless IPC write on every Clear Filters click
// in the common case where hide was never on.
const actions = computeClearFilterActions(
filterState({
filterRepoIds: ['repo1']
})
)
expect(actions.resetHideDefaultBranchWorkspace).toBe(false)
expect(actions.resetShowSleepingWorkspaces).toBe(false)
expect(actions.resetFilterRepoIds).toBe(true)
})
it('flags legacy single-host scope for reset even without visible host ids', () => {
expect(computeClearFilterActions(filterState({ workspaceHostScope: 'ssh:host-1' }))).toEqual({
resetShowSleepingWorkspaces: false,
resetFilterRepoIds: false,
resetHideDefaultBranchWorkspace: false,
resetHideAutomationGeneratedWorkspaces: false,
resetHideCliCreatedWorkspaces: false,
resetHideDetachedHeadWorkspaces: false,
resetVisibleWorkspaceHostIds: true
})
})
it('flags every active filter simultaneously', () => {
expect(
computeClearFilterActions(
filterState({
showSleepingWorkspaces: false,
filterRepoIds: ['repo1', 'repo2'],
hideDefaultBranchWorkspace: true,
hideAutomationGeneratedWorkspaces: true,
visibleWorkspaceHostIds: ['local']
})
)
).toEqual({
resetShowSleepingWorkspaces: true,
resetFilterRepoIds: true,
resetHideDefaultBranchWorkspace: true,
resetHideAutomationGeneratedWorkspaces: true,
resetHideCliCreatedWorkspaces: false,
resetHideDetachedHeadWorkspaces: false,
resetVisibleWorkspaceHostIds: true
})
})
})
@@ -21,6 +21,7 @@ export function useVisibleWorkspaceKanbanWorktreeIds({
const hideDefaultBranchWorkspace = useAppStore((s) => s.hideDefaultBranchWorkspace)
const hideAutomationGeneratedWorkspaces = useAppStore((s) => s.hideAutomationGeneratedWorkspaces)
const hideCliCreatedWorkspaces = useAppStore((s) => s.hideCliCreatedWorkspaces)
const hideDetachedHeadWorkspaces = useAppStore((s) => s.hideDetachedHeadWorkspaces)
const workspaceHostScope = useAppStore((s) => s.workspaceHostScope)
const visibleWorkspaceHostIds = useAppStore((s) => s.visibleWorkspaceHostIds)
const settings = useAppStore((s) => s.settings)
@@ -59,6 +60,7 @@ export function useVisibleWorkspaceKanbanWorktreeIds({
hideDefaultBranchWorkspace,
hideAutomationGeneratedWorkspaces,
hideCliCreatedWorkspaces,
hideDetachedHeadWorkspaces,
repoMap,
workspaceHostScope,
visibleWorkspaceHostIds,
@@ -76,6 +78,7 @@ export function useVisibleWorkspaceKanbanWorktreeIds({
hideDefaultBranchWorkspace,
hideAutomationGeneratedWorkspaces,
hideCliCreatedWorkspaces,
hideDetachedHeadWorkspaces,
workspaceHostScope,
visibleWorkspaceHostIds,
settings,
@@ -1,10 +1,5 @@
import { describe, expect, it } from 'vitest'
import {
computeClearFilterActions,
computeVisibleWorktreeIds,
isDefaultBranchWorkspace,
sidebarHasActiveFilters
} from './visible-worktrees'
import { computeVisibleWorktreeIds } from './visible-worktrees'
import type { Repo, TerminalTab, Worktree, WorktreeLineage } from '../../../../shared/types'
import { LOCAL_EXECUTION_HOST_ID } from '../../../../shared/execution-host'
@@ -83,6 +78,7 @@ function visibleOptions(overrides: Partial<VisibleOptions> = {}): VisibleOptions
hideDefaultBranchWorkspace: false,
hideAutomationGeneratedWorkspaces: false,
hideCliCreatedWorkspaces: false,
hideDetachedHeadWorkspaces: false,
repoMap,
workspaceHostScope: 'all',
defaultHostId: LOCAL_EXECUTION_HOST_ID,
@@ -91,20 +87,6 @@ function visibleOptions(overrides: Partial<VisibleOptions> = {}): VisibleOptions
}
}
type FilterState = Parameters<typeof sidebarHasActiveFilters>[0]
function filterState(overrides: Partial<FilterState> = {}): FilterState {
return {
showSleepingWorkspaces: true,
filterRepoIds: [],
hideDefaultBranchWorkspace: false,
hideAutomationGeneratedWorkspaces: false,
hideCliCreatedWorkspaces: false,
workspaceHostScope: 'all',
...overrides
}
}
describe('computeVisibleWorktreeIds', () => {
it('keeps browser-tab worktrees visible when sleeping workspaces are hidden', () => {
const wt = makeWorktree('wt-browser')
@@ -216,6 +198,46 @@ describe('computeVisibleWorktreeIds', () => {
expect(result).toEqual([legacy.id])
})
it('hides detached-HEAD workspaces when the detached filter is enabled', () => {
const onBranch = makeWorktree('on-branch')
const detached = { ...makeWorktree('detached'), branch: '', head: 'deadbeefcafe' }
const result = computeVisibleWorktreeIds(
{ repo1: [onBranch, detached] },
[onBranch.id, detached.id],
visibleOptions({ hideDetachedHeadWorkspaces: true })
)
expect(result).toEqual([onBranch.id])
})
it('keeps detached-HEAD workspaces visible while the detached filter is off', () => {
const onBranch = makeWorktree('on-branch')
const detached = { ...makeWorktree('detached'), branch: '', head: 'deadbeefcafe' }
const result = computeVisibleWorktreeIds(
{ repo1: [onBranch, detached] },
[onBranch.id, detached.id],
visibleOptions()
)
expect(result).toEqual([onBranch.id, detached.id])
})
it('keeps headless workspaces visible when the detached filter is enabled', () => {
// Why: folder workspaces and SSH-synthesized rows carry an empty branch AND
// an empty head. Only a real head means a genuine detached checkout.
const folder = { ...makeWorktree('folder'), branch: '', head: '', isMainWorktree: true }
const result = computeVisibleWorktreeIds(
{ repo1: [folder] },
[folder.id],
visibleOptions({ hideDetachedHeadWorkspaces: true })
)
expect(result).toEqual([folder.id])
})
it('does not treat slept wake-hint tabs as live surfaces', () => {
const wt = makeWorktree('wt-slept')
@@ -596,6 +618,11 @@ describe('computeVisibleWorktreeIds', () => {
const cliParent = makeWorktree('cli-parent')
cliParent.cliProvenance = { kind: 'created-by-cli', createdAt: 1 }
expect(run(cliParent, { hideCliCreatedWorkspaces: true })).toEqual([child.id])
const detachedParent = makeWorktree('detached-parent')
detachedParent.branch = ''
detachedParent.head = 'deadbeefcafe'
expect(run(detachedParent, { hideDetachedHeadWorkspaces: true })).toEqual([child.id])
})
it('includes inline lineage ancestors when send-target mode forces a filtered child visible', () => {
@@ -751,154 +778,3 @@ describe('computeVisibleWorktreeIds', () => {
expect(result).toEqual([child.id])
})
})
describe('isDefaultBranchWorkspace', () => {
it('returns true for a branch-backed main worktree', () => {
const main = makeWorktree('main')
main.isMainWorktree = true
expect(isDefaultBranchWorkspace(main)).toBe(true)
})
it('returns false for folder-mode main worktrees (empty branch)', () => {
const folder = makeWorktree('folder')
folder.isMainWorktree = true
folder.branch = ''
expect(isDefaultBranchWorkspace(folder)).toBe(false)
})
it('returns false for non-main worktrees even on the default branch', () => {
const feature = makeWorktree('feature')
expect(isDefaultBranchWorkspace(feature)).toBe(false)
})
})
describe('sidebarHasActiveFilters', () => {
it('returns false when no filters are active', () => {
expect(sidebarHasActiveFilters(filterState())).toBe(false)
})
it('returns true when only hideDefaultBranchWorkspace is active', () => {
// Why: regression guard for the empty-sidebar escape hatch. If hide is
// omitted from the filter union, a user whose only worktree is the
// default-branch row sees "No workspaces found" with no way back.
expect(sidebarHasActiveFilters(filterState({ hideDefaultBranchWorkspace: true }))).toBe(true)
})
it('returns true when only automation-created workspaces are hidden', () => {
expect(sidebarHasActiveFilters(filterState({ hideAutomationGeneratedWorkspaces: true }))).toBe(
true
)
})
it('returns true when only CLI-created workspaces are hidden', () => {
expect(sidebarHasActiveFilters(filterState({ hideCliCreatedWorkspaces: true }))).toBe(true)
})
it('returns true when sleeping workspaces are hidden', () => {
expect(sidebarHasActiveFilters(filterState({ showSleepingWorkspaces: false }))).toBe(true)
})
it('returns true when only filterRepoIds is non-empty', () => {
expect(sidebarHasActiveFilters(filterState({ filterRepoIds: ['repo1'] }))).toBe(true)
})
it('returns true when only host visibility is narrowed', () => {
expect(sidebarHasActiveFilters(filterState({ visibleWorkspaceHostIds: ['local'] }))).toBe(true)
})
})
describe('computeClearFilterActions', () => {
it('returns no-op actions when nothing is set', () => {
expect(computeClearFilterActions(filterState())).toEqual({
resetShowSleepingWorkspaces: false,
resetFilterRepoIds: false,
resetHideDefaultBranchWorkspace: false,
resetHideAutomationGeneratedWorkspaces: false,
resetHideCliCreatedWorkspaces: false,
resetVisibleWorkspaceHostIds: false
})
})
it('flags only hideDefaultBranchWorkspace for reset when it is the sole filter', () => {
// Why: verifies the empty-sidebar escape hatch actually clears the hide
// flag. A regression here would leave users stuck on "No workspaces found"
// because the only active filter would never clear.
expect(computeClearFilterActions(filterState({ hideDefaultBranchWorkspace: true }))).toEqual({
resetShowSleepingWorkspaces: false,
resetFilterRepoIds: false,
resetHideDefaultBranchWorkspace: true,
resetHideAutomationGeneratedWorkspaces: false,
resetHideCliCreatedWorkspaces: false,
resetVisibleWorkspaceHostIds: false
})
})
it('flags only hideAutomationGeneratedWorkspaces for reset when it is the sole filter', () => {
expect(
computeClearFilterActions(filterState({ hideAutomationGeneratedWorkspaces: true }))
).toEqual({
resetShowSleepingWorkspaces: false,
resetFilterRepoIds: false,
resetHideDefaultBranchWorkspace: false,
resetHideAutomationGeneratedWorkspaces: true,
resetHideCliCreatedWorkspaces: false,
resetVisibleWorkspaceHostIds: false
})
})
it('flags only hideCliCreatedWorkspaces for reset when it is the sole filter', () => {
expect(computeClearFilterActions(filterState({ hideCliCreatedWorkspaces: true }))).toEqual({
resetShowSleepingWorkspaces: false,
resetFilterRepoIds: false,
resetHideDefaultBranchWorkspace: false,
resetHideAutomationGeneratedWorkspaces: false,
resetHideCliCreatedWorkspaces: true,
resetVisibleWorkspaceHostIds: false
})
})
it('does not flag hideDefaultBranchWorkspace when it is already off', () => {
// Why: avoids issuing a pointless IPC write on every Clear Filters click
// in the common case where hide was never on.
const actions = computeClearFilterActions(
filterState({
filterRepoIds: ['repo1']
})
)
expect(actions.resetHideDefaultBranchWorkspace).toBe(false)
expect(actions.resetShowSleepingWorkspaces).toBe(false)
expect(actions.resetFilterRepoIds).toBe(true)
})
it('flags legacy single-host scope for reset even without visible host ids', () => {
expect(computeClearFilterActions(filterState({ workspaceHostScope: 'ssh:host-1' }))).toEqual({
resetShowSleepingWorkspaces: false,
resetFilterRepoIds: false,
resetHideDefaultBranchWorkspace: false,
resetHideAutomationGeneratedWorkspaces: false,
resetHideCliCreatedWorkspaces: false,
resetVisibleWorkspaceHostIds: true
})
})
it('flags every active filter simultaneously', () => {
expect(
computeClearFilterActions(
filterState({
showSleepingWorkspaces: false,
filterRepoIds: ['repo1', 'repo2'],
hideDefaultBranchWorkspace: true,
hideAutomationGeneratedWorkspaces: true,
visibleWorkspaceHostIds: ['local']
})
)
).toEqual({
resetShowSleepingWorkspaces: true,
resetFilterRepoIds: true,
resetHideDefaultBranchWorkspace: true,
resetHideAutomationGeneratedWorkspaces: true,
resetHideCliCreatedWorkspaces: false,
resetVisibleWorkspaceHostIds: true
})
})
})
@@ -20,6 +20,7 @@ import {
getLineageRenderInfo
} from './worktree-lineage-projection'
import { computeRenderedSidebarWorktreeOrder } from './rendered-sidebar-worktree-order'
import { getWorktreeGitIdentityDisplay } from '@/lib/worktree-git-identity-display'
/**
* Whether a worktree represents the repo's default-branch row that the
@@ -42,6 +43,18 @@ export function isCliCreatedWorkspace(worktree: Worktree): boolean {
return worktree.cliProvenance?.kind === 'created-by-cli'
}
/**
* Whether a worktree sits on a detached HEAD (a commit, not a branch).
*
* Why the head check: folder workspaces and SSH-synthesized rows carry both an
* empty branch and an empty head, so branch-emptiness alone would sweep them
* into this filter. Requiring a real head keeps the predicate to genuine
* detached-HEAD checkouts, matching what DetachedHeadBadge renders on the card.
*/
export function isDetachedHeadWorkspace(worktree: Worktree): boolean {
return getWorktreeGitIdentityDisplay(worktree)?.kind === 'detached'
}
/** Inputs describing sidebar filter settings that the Clear Filters path owns. */
export type SidebarFilterState = {
showSleepingWorkspaces: boolean
@@ -49,6 +62,7 @@ export type SidebarFilterState = {
hideDefaultBranchWorkspace: boolean
hideAutomationGeneratedWorkspaces: boolean
hideCliCreatedWorkspaces: boolean
hideDetachedHeadWorkspaces: boolean
visibleWorkspaceHostIds?: readonly ExecutionHostId[] | null
workspaceHostScope?: ExecutionHostScope
}
@@ -69,6 +83,7 @@ export function sidebarHasActiveFilters(state: SidebarFilterState): boolean {
state.hideDefaultBranchWorkspace ||
state.hideAutomationGeneratedWorkspaces ||
state.hideCliCreatedWorkspaces ||
state.hideDetachedHeadWorkspaces ||
state.visibleWorkspaceHostIds != null ||
(state.workspaceHostScope != null && state.workspaceHostScope !== ALL_EXECUTION_HOSTS_SCOPE)
)
@@ -82,6 +97,7 @@ export type ClearFilterActions = {
resetHideDefaultBranchWorkspace: boolean
resetHideAutomationGeneratedWorkspaces: boolean
resetHideCliCreatedWorkspaces: boolean
resetHideDetachedHeadWorkspaces: boolean
resetVisibleWorkspaceHostIds: boolean
}
@@ -102,6 +118,7 @@ export function computeClearFilterActions(state: SidebarFilterState): ClearFilte
resetHideDefaultBranchWorkspace: state.hideDefaultBranchWorkspace,
resetHideAutomationGeneratedWorkspaces: state.hideAutomationGeneratedWorkspaces,
resetHideCliCreatedWorkspaces: state.hideCliCreatedWorkspaces,
resetHideDetachedHeadWorkspaces: state.hideDetachedHeadWorkspaces,
resetVisibleWorkspaceHostIds:
state.visibleWorkspaceHostIds != null ||
(state.workspaceHostScope != null && state.workspaceHostScope !== ALL_EXECUTION_HOSTS_SCOPE)
@@ -137,6 +154,7 @@ export function computeVisibleWorktreeIds(
hideDefaultBranchWorkspace: boolean
hideAutomationGeneratedWorkspaces: boolean
hideCliCreatedWorkspaces: boolean
hideDetachedHeadWorkspaces: boolean
repoMap: Map<string, Repo>
workspaceHostScope: ExecutionHostScope
visibleWorkspaceHostIds?: readonly ExecutionHostId[] | null
@@ -167,6 +185,10 @@ export function computeVisibleWorktreeIds(
all = all.filter((w) => !isCliCreatedWorkspace(w))
}
if (opts.hideDetachedHeadWorkspaces) {
all = all.filter((w) => !isDetachedHeadWorkspace(w))
}
const visibleHostIds =
opts.visibleWorkspaceHostIds ??
(opts.workspaceHostScope === ALL_EXECUTION_HOSTS_SCOPE ? null : [opts.workspaceHostScope])
@@ -347,6 +369,7 @@ export function getVisibleWorktreeIds(): string[] {
hideDefaultBranchWorkspace: state.hideDefaultBranchWorkspace,
hideAutomationGeneratedWorkspaces: state.hideAutomationGeneratedWorkspaces,
hideCliCreatedWorkspaces: state.hideCliCreatedWorkspaces,
hideDetachedHeadWorkspaces: state.hideDetachedHeadWorkspaces,
repoMap,
workspaceHostScope: state.workspaceHostScope,
visibleWorkspaceHostIds: state.visibleWorkspaceHostIds,
+4 -2
View File
@@ -4125,7 +4125,8 @@
"489d1c8c9f": "Search projects...",
"ee240a39eb": "Edit filters",
"automationCreated": "Hide automation-created",
"cliCreated": "Hide CLI-created"
"cliCreated": "Hide CLI-created",
"detachedHead": "Hide detached HEAD"
},
"SidebarHeader": {
"25a95899c9": "Add Project",
@@ -4189,7 +4190,8 @@
"ed1611b65b": "Hide sleeping",
"82594419ba": "Filters",
"automationCreated": "Hide automation-created",
"cliCreated": "Hide CLI-created"
"cliCreated": "Hide CLI-created",
"detachedHead": "Hide detached HEAD"
},
"sidebarHostOptions": {
"3e102f111c": "All hosts",
+4 -2
View File
@@ -4087,7 +4087,8 @@
"489d1c8c9f": "Buscar proyectos...",
"ee240a39eb": "Editar filtros",
"automationCreated": "Ocultar creados por automatizaciones",
"cliCreated": "Hide CLI-created"
"cliCreated": "Hide CLI-created",
"detachedHead": "Ocultar HEAD desacoplado"
},
"SidebarHeader": {
"92154beb7e": "Nuevo espacio de trabajo",
@@ -4151,7 +4152,8 @@
"ed1611b65b": "Ocultar en reposo",
"82594419ba": "Filtros",
"automationCreated": "Ocultar creados por automatizaciones",
"cliCreated": "Hide CLI-created"
"cliCreated": "Hide CLI-created",
"detachedHead": "Ocultar HEAD desacoplado"
},
"SidebarWorkspaceOptionsMenu": {
"95c9754653": "Diseño de actividad del Agent",
+4 -2
View File
@@ -4068,7 +4068,8 @@
"489d1c8c9f": "プロジェクトを検索...",
"ee240a39eb": "フィルターの編集",
"automationCreated": "自動化で作成されたワークスペースを非表示",
"cliCreated": "Hide CLI-created"
"cliCreated": "Hide CLI-created",
"detachedHead": "分離HEADを非表示"
},
"SidebarHeader": {
"92154beb7e": "新規ワークスペース",
@@ -4132,7 +4133,8 @@
"ed1611b65b": "スリープ中を非表示",
"82594419ba": "フィルター",
"automationCreated": "自動化で作成されたワークスペースを非表示",
"cliCreated": "Hide CLI-created"
"cliCreated": "Hide CLI-created",
"detachedHead": "分離HEADを非表示"
},
"SidebarWorkspaceOptionsMenu": {
"95c9754653": "Agent アクティビティのレイアウト",
+4 -2
View File
@@ -4068,7 +4068,8 @@
"489d1c8c9f": "프로젝트 검색...",
"ee240a39eb": "필터 편집",
"automationCreated": "자동화로 생성된 워크스페이스 숨기기",
"cliCreated": "Hide CLI-created"
"cliCreated": "Hide CLI-created",
"detachedHead": "분리된 HEAD 숨기기"
},
"SidebarHeader": {
"92154beb7e": "새로운 워크스페이스",
@@ -4132,7 +4133,8 @@
"ed1611b65b": "슬립 중인 항목 숨기기",
"82594419ba": "필터",
"automationCreated": "자동화로 생성된 워크스페이스 숨기기",
"cliCreated": "Hide CLI-created"
"cliCreated": "Hide CLI-created",
"detachedHead": "분리된 HEAD 숨기기"
},
"SidebarWorkspaceOptionsMenu": {
"95c9754653": "Agent 활동 레이아웃",
+4 -2
View File
@@ -4068,7 +4068,8 @@
"489d1c8c9f": "搜索项目...",
"ee240a39eb": "编辑筛选条件",
"automationCreated": "隐藏自动化创建的工作区",
"cliCreated": "Hide CLI-created"
"cliCreated": "Hide CLI-created",
"detachedHead": "隐藏分离 HEAD"
},
"SidebarHeader": {
"92154beb7e": "新工作区",
@@ -4132,7 +4133,8 @@
"ed1611b65b": "隐藏休眠项",
"82594419ba": "筛选条件",
"automationCreated": "隐藏自动化创建的工作区",
"cliCreated": "Hide CLI-created"
"cliCreated": "Hide CLI-created",
"detachedHead": "隐藏分离 HEAD"
},
"SidebarWorkspaceOptionsMenu": {
"95c9754653": "智能体活动布局",
@@ -49,6 +49,7 @@ export function getStartupErrorFallbackUI(uiHydrated: boolean): PersistedUIState
showSleepingWorkspaces: DEFAULT_SHOW_SLEEPING_WORKSPACES,
hideDefaultBranchWorkspace: false,
hideCliCreatedWorkspaces: false,
hideDetachedHeadWorkspaces: false,
hideAutomationGeneratedWorkspaces: false,
filterRepoIds: [],
collapsedGroups: [],
@@ -64,6 +64,7 @@ import {
import { toast } from 'sonner'
import { initialAgentTabViewModeProps } from './native-chat-initial-view-mode'
import { getConnectionId } from '@/lib/connection-context'
import { isDetachedHeadWorkspace } from '@/components/sidebar/visible-worktrees'
import { isNativeChatTranscriptLocalReadable } from '@/lib/native-chat-transcript-readability'
import { seedNativeChatAppliedSessionOptions } from '@/components/native-chat/native-chat-session-option-cache'
import { resolveNativeChatSessionOptionDefaults } from '../../../shared/native-chat-session-option-defaults'
@@ -367,6 +368,9 @@ export function activateAndRevealWorktree(
if (state.hideCliCreatedWorkspaces && wt.cliProvenance?.kind === 'created-by-cli') {
state.setHideCliCreatedWorkspaces(false)
}
if (state.hideDetachedHeadWorkspaces && isDetachedHeadWorkspace(wt)) {
state.setHideDetachedHeadWorkspaces(false)
}
// 6. Reveal in sidebar
if (opts?.revealInSidebar !== false) {
@@ -1651,6 +1651,7 @@ describe('reconnectPersistedTerminals', () => {
hideDefaultBranchWorkspace: false,
hideAutomationGeneratedWorkspaces: false,
hideCliCreatedWorkspaces: false,
hideDetachedHeadWorkspaces: false,
repoMap: new Map(s.repos.map((repo) => [repo.id, repo])),
workspaceHostScope: 'all',
defaultHostId: LOCAL_EXECUTION_HOST_ID,
@@ -1997,6 +1998,7 @@ describe('reconnectPersistedTerminals', () => {
hideDefaultBranchWorkspace: false,
hideAutomationGeneratedWorkspaces: false,
hideCliCreatedWorkspaces: false,
hideDetachedHeadWorkspaces: false,
repoMap: new Map(s.repos.map((repo) => [repo.id, repo])),
workspaceHostScope: 'all',
defaultHostId: LOCAL_EXECUTION_HOST_ID,
+5
View File
@@ -868,6 +868,8 @@ export type UISlice = {
setHideAutomationGeneratedWorkspaces: (v: boolean) => void
hideCliCreatedWorkspaces: boolean
setHideCliCreatedWorkspaces: (v: boolean) => void
hideDetachedHeadWorkspaces: boolean
setHideDetachedHeadWorkspaces: (v: boolean) => void
showDotfilesByWorktree: Record<string, boolean>
setShowDotfilesForWorktree: (worktreeId: string, showDotfiles: boolean) => void
toggleShowDotfilesForWorktree: (worktreeId: string) => void
@@ -2033,6 +2035,8 @@ export const createUISlice: StateCreator<AppState, [], [], UISlice> = (set, get)
setHideAutomationGeneratedWorkspaces: (v) => set({ hideAutomationGeneratedWorkspaces: v }),
hideCliCreatedWorkspaces: false,
setHideCliCreatedWorkspaces: (v) => set({ hideCliCreatedWorkspaces: v }),
hideDetachedHeadWorkspaces: false,
setHideDetachedHeadWorkspaces: (v) => set({ hideDetachedHeadWorkspaces: v }),
showDotfilesByWorktree: {},
setShowDotfilesForWorktree: (worktreeId, showDotfiles) =>
@@ -2432,6 +2436,7 @@ export const createUISlice: StateCreator<AppState, [], [], UISlice> = (set, get)
hideDefaultBranchWorkspace: ui.hideDefaultBranchWorkspace ?? false,
hideAutomationGeneratedWorkspaces: ui.hideAutomationGeneratedWorkspaces === true,
hideCliCreatedWorkspaces: ui.hideCliCreatedWorkspaces === true,
hideDetachedHeadWorkspaces: ui.hideDetachedHeadWorkspaces === true,
showDotfilesByWorktree: sanitizeShowDotfilesByWorktree(ui.showDotfilesByWorktree),
// Why: startup hydrates UI before repo catalogs, so defer repo-filter validation to the all-host refresh.
filterRepoIds:
+1
View File
@@ -460,6 +460,7 @@ export function getDefaultUIState(): PersistedUIState {
hideDefaultBranchWorkspace: false,
hideAutomationGeneratedWorkspaces: false,
hideCliCreatedWorkspaces: false,
hideDetachedHeadWorkspaces: false,
showDotfilesByWorktree: {},
filterRepoIds: [],
collapsedGroups: [],
+2
View File
@@ -3265,6 +3265,8 @@ export type PersistedUIState = {
hideAutomationGeneratedWorkspaces?: boolean
/** Hide workspaces created through `orca worktree create`. */
hideCliCreatedWorkspaces?: boolean
/** Hide workspaces sitting on a detached HEAD; folder workspaces (no head at all) are unaffected. */
hideDetachedHeadWorkspaces?: boolean
/** Per-worktree Explorer dotfile visibility. Missing entries inherit the default: show. */
showDotfilesByWorktree?: Record<string, boolean>
filterRepoIds: string[]