From af708d3471fe744270d12f4cf1a41e1aa9ffcc2e Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sun, 26 Jul 2026 14:42:05 -0700 Subject: [PATCH] feat(sidebar): add a filter to hide detached-HEAD workspaces (#10786) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../runtime/rpc/methods/client-ui-schemas.ts | 1 + src/renderer/src/App.tsx | 3 + .../src/components/WorktreeJumpPalette.tsx | 6 + .../src/components/sidebar/SidebarFilter.tsx | 16 ++ .../sidebar/SidebarWorkspaceFilterSection.tsx | 13 +- .../sidebar/SidebarWorkspaceOptionsMenu.tsx | 3 + .../src/components/sidebar/WorktreeList.tsx | 10 + .../sidebar/sidebar-filter-state.test.ts | 217 +++++++++++++++++ ...e-visible-workspace-kanban-worktree-ids.ts | 3 + .../sidebar/visible-worktrees.test.ts | 218 ++++-------------- .../components/sidebar/visible-worktrees.ts | 23 ++ src/renderer/src/i18n/locales/en.json | 6 +- src/renderer/src/i18n/locales/es.json | 6 +- src/renderer/src/i18n/locales/ja.json | 6 +- src/renderer/src/i18n/locales/ko.json | 6 +- src/renderer/src/i18n/locales/zh.json | 6 +- src/renderer/src/lib/startup-ui-hydration.ts | 1 + src/renderer/src/lib/worktree-activation.ts | 4 + .../slices/store-session-cascades.test.ts | 2 + src/renderer/src/store/slices/ui.ts | 5 + src/shared/constants.ts | 1 + src/shared/types.ts | 2 + 22 files changed, 376 insertions(+), 182 deletions(-) create mode 100644 src/renderer/src/components/sidebar/sidebar-filter-state.test.ts diff --git a/src/main/runtime/rpc/methods/client-ui-schemas.ts b/src/main/runtime/rpc/methods/client-ui-schemas.ts index f7236c61df3..7265a1ee806 100644 --- a/src/main/runtime/rpc/methods/client-ui-schemas.ts +++ b/src/main/runtime/rpc/methods/client-ui-schemas.ts @@ -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(), diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 842a7a9e213..a58f1c5c474 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -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 diff --git a/src/renderer/src/components/WorktreeJumpPalette.tsx b/src/renderer/src/components/WorktreeJumpPalette.tsx index 538615f1824..242ce097d7b 100644 --- a/src/renderer/src/components/WorktreeJumpPalette.tsx +++ b/src/renderer/src/components/WorktreeJumpPalette.tsx @@ -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, diff --git a/src/renderer/src/components/sidebar/SidebarFilter.tsx b/src/renderer/src/components/sidebar/SidebarFilter.tsx index a594144cb34..06c445564a7 100644 --- a/src/renderer/src/components/sidebar/SidebarFilter.tsx +++ b/src/renderer/src/components/sidebar/SidebarFilter.tsx @@ -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} /> + } + label={translate( + 'auto.components.sidebar.SidebarFilter.detachedHead', + 'Hide detached HEAD' + )} + checked={hideDetachedHeadWorkspaces} + onChange={setHideDetachedHeadWorkspaces} + /> {canFilterRepos && ( <> diff --git a/src/renderer/src/components/sidebar/SidebarWorkspaceFilterSection.tsx b/src/renderer/src/components/sidebar/SidebarWorkspaceFilterSection.tsx index 3469d2d24df..20cddcfae49 100644 --- a/src/renderer/src/components/sidebar/SidebarWorkspaceFilterSection.tsx +++ b/src/renderer/src/components/sidebar/SidebarWorkspaceFilterSection.tsx @@ -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} /> + } + label={translate( + 'auto.components.sidebar.SidebarWorkspaceFilterSection.detachedHead', + 'Hide detached HEAD' + )} + checked={hideDetachedHeadWorkspaces} + onChange={setHideDetachedHeadWorkspaces} + /> ) }) diff --git a/src/renderer/src/components/sidebar/SidebarWorkspaceOptionsMenu.tsx b/src/renderer/src/components/sidebar/SidebarWorkspaceOptionsMenu.tsx index 139ad4ae933..b087a3fb8f5 100644 --- a/src/renderer/src/components/sidebar/SidebarWorkspaceOptionsMenu.tsx +++ b/src/renderer/src/components/sidebar/SidebarWorkspaceOptionsMenu.tsx @@ -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'}` diff --git a/src/renderer/src/components/sidebar/WorktreeList.tsx b/src/renderer/src/components/sidebar/WorktreeList.tsx index 7b96520e706..9cbb151f09c 100644 --- a/src/renderer/src/components/sidebar/WorktreeList.tsx +++ b/src/renderer/src/components/sidebar/WorktreeList.tsx @@ -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 ]) diff --git a/src/renderer/src/components/sidebar/sidebar-filter-state.test.ts b/src/renderer/src/components/sidebar/sidebar-filter-state.test.ts new file mode 100644 index 00000000000..0d27355c45a --- /dev/null +++ b/src/renderer/src/components/sidebar/sidebar-filter-state.test.ts @@ -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[0] + +function filterState(overrides: Partial = {}): 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 + }) + }) +}) diff --git a/src/renderer/src/components/sidebar/use-visible-workspace-kanban-worktree-ids.ts b/src/renderer/src/components/sidebar/use-visible-workspace-kanban-worktree-ids.ts index 7edcf8a45f9..15e3ec6de43 100644 --- a/src/renderer/src/components/sidebar/use-visible-workspace-kanban-worktree-ids.ts +++ b/src/renderer/src/components/sidebar/use-visible-workspace-kanban-worktree-ids.ts @@ -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, diff --git a/src/renderer/src/components/sidebar/visible-worktrees.test.ts b/src/renderer/src/components/sidebar/visible-worktrees.test.ts index 7c3ff699334..f54dcef60e7 100644 --- a/src/renderer/src/components/sidebar/visible-worktrees.test.ts +++ b/src/renderer/src/components/sidebar/visible-worktrees.test.ts @@ -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 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 } } -type FilterState = Parameters[0] - -function filterState(overrides: Partial = {}): 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 - }) - }) -}) diff --git a/src/renderer/src/components/sidebar/visible-worktrees.ts b/src/renderer/src/components/sidebar/visible-worktrees.ts index 2ee9f41a0b2..9c44d8587e3 100644 --- a/src/renderer/src/components/sidebar/visible-worktrees.ts +++ b/src/renderer/src/components/sidebar/visible-worktrees.ts @@ -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 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, diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 117de2db5ec..d617a59168c 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -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", diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 37222a3a307..a92e2d06a08 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -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", diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 398c5c9611d..e519062d530 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -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 アクティビティのレイアウト", diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index a5150b01853..4e0d9aabee9 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -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 활동 레이아웃", diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 709f1a053bb..66d232343a0 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -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": "智能体活动布局", diff --git a/src/renderer/src/lib/startup-ui-hydration.ts b/src/renderer/src/lib/startup-ui-hydration.ts index bee87b3052e..1fb14546ce8 100644 --- a/src/renderer/src/lib/startup-ui-hydration.ts +++ b/src/renderer/src/lib/startup-ui-hydration.ts @@ -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: [], diff --git a/src/renderer/src/lib/worktree-activation.ts b/src/renderer/src/lib/worktree-activation.ts index 5fef7f80e98..19f79bf175a 100644 --- a/src/renderer/src/lib/worktree-activation.ts +++ b/src/renderer/src/lib/worktree-activation.ts @@ -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) { diff --git a/src/renderer/src/store/slices/store-session-cascades.test.ts b/src/renderer/src/store/slices/store-session-cascades.test.ts index 98761ca5cac..aef50546144 100644 --- a/src/renderer/src/store/slices/store-session-cascades.test.ts +++ b/src/renderer/src/store/slices/store-session-cascades.test.ts @@ -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, diff --git a/src/renderer/src/store/slices/ui.ts b/src/renderer/src/store/slices/ui.ts index 6988e5cc474..a39631edc79 100644 --- a/src/renderer/src/store/slices/ui.ts +++ b/src/renderer/src/store/slices/ui.ts @@ -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 setShowDotfilesForWorktree: (worktreeId: string, showDotfiles: boolean) => void toggleShowDotfilesForWorktree: (worktreeId: string) => void @@ -2033,6 +2035,8 @@ export const createUISlice: StateCreator = (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 = (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: diff --git a/src/shared/constants.ts b/src/shared/constants.ts index 3b1ff27ed7d..dfafdd6a923 100644 --- a/src/shared/constants.ts +++ b/src/shared/constants.ts @@ -460,6 +460,7 @@ export function getDefaultUIState(): PersistedUIState { hideDefaultBranchWorkspace: false, hideAutomationGeneratedWorkspaces: false, hideCliCreatedWorkspaces: false, + hideDetachedHeadWorkspaces: false, showDotfilesByWorktree: {}, filterRepoIds: [], collapsedGroups: [], diff --git a/src/shared/types.ts b/src/shared/types.ts index d94617f7dc1..5838526733d 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -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 filterRepoIds: string[]