fix(sidebar): keep each project's entry-point workspace visible under "Hide sleeping" (#12257)

"Hide sleeping" swept each project's main workspace out of the sidebar as soon as
it had no live PTY, browser tab or agent — even with "Hide default branch" off.
For a project whose only row is that workspace (a folder workspace, a fresh
clone, a detached-HEAD main), the entire project vanished with no in-place way
back.

Adds a shared `isSleepingSweepExemptWorkspace` predicate keyed on
`isMainWorktree` rather than the branch name, so folder workspaces (no branch),
detached-HEAD mains, and SSH rows whose head/branch are blanked while a provider
is disconnected all stay put. Wired into `computeVisibleWorktreeIds` (sidebar,
Cmd+1-9, workspace board), the jump palette's duplicate inline pass, and mobile's
`filterWorktrees`.

Ships default-on with an escape hatch: a persisted
`alwaysShowDefaultBranchWorkspace` setting surfaced as "Except default branch"
under "Hide sleeping". Explicit "Hide default branch" still wins, since it
filters before the sleeping sweep.

Mobile reads the setting but never writes it back, so a desktop opt-out can't be
clobbered by a filter tap before the ui.get roundtrip lands.

Combines the two PRs open against #8873. #8966's exempt set is a strict subset of
this one, so its production diff was subsumed rather than ported; its jump-palette
render harness and e2e spec were carried over, and are the only such coverage here.

Fixes #8873
Closes #8966

Co-authored-by: Rod Boev <rod.boev@gmail.com>
Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Neil 2026-08-03 22:55:17 -07:00 committed by GitHub
parent 0927b9c156
commit e59a319ffe
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
34 changed files with 1315 additions and 105 deletions

View File

@ -164,7 +164,8 @@ export function HostScreen({
const [filters, setFilters] = useState<FilterState>({
filterRepoIds: new Set(),
hideSleeping: false,
hideDefaultBranch: false
hideDefaultBranch: false,
alwaysShowDefaultBranch: true
})
const [groupMode, setGroupMode] = useState<MobileGroupMode>('repo')
const [workspaceStatuses, setWorkspaceStatuses] = useState<readonly WorkspaceStatusDefinition[]>(
@ -195,6 +196,7 @@ export function HostScreen({
sortMode: 'recent',
hideSleeping: false,
hideDefaultBranch: false,
alwaysShowDefaultBranch: true,
filterRepoIds: [],
collapsedGroups: [],
workspaceStatuses: DEFAULT_MOBILE_WORKSPACE_STATUSES
@ -206,6 +208,7 @@ export function HostScreen({
sortMode,
hideSleeping: filters.hideSleeping,
hideDefaultBranch: filters.hideDefaultBranch,
alwaysShowDefaultBranch: filters.alwaysShowDefaultBranch !== false,
filterRepoIds: [...filters.filterRepoIds],
collapsedGroups: [...collapsedGroups],
workspaceStatuses
@ -222,7 +225,8 @@ export function HostScreen({
setFilters({
filterRepoIds: new Set(next.filterRepoIds),
hideSleeping: next.hideSleeping,
hideDefaultBranch: next.hideDefaultBranch
hideDefaultBranch: next.hideDefaultBranch,
alwaysShowDefaultBranch: next.alwaysShowDefaultBranch
})
}, [])
@ -234,6 +238,9 @@ export function HostScreen({
if (!client) {
return
}
// alwaysShowDefaultBranchWorkspace is deliberately absent: mobile reads it
// but has no toggle, so echoing its local default would silently revert a
// desktop opt-out on the first filter tap before ui.get lands (#8873).
const payload: WorkspaceViewSettings = {
groupBy: groupModeToDesktop(next.groupMode),
sortBy: next.sortMode,

View File

@ -106,6 +106,95 @@ describe('filterWorktrees', () => {
).toEqual([featureNamedMain])
})
it('keeps a sleeping main worktree visible under hide-sleeping by default (#8873)', () => {
const main = worktree({ worktreeId: 'main', branch: 'main', isMainWorktree: true })
const feature = worktree({ worktreeId: 'feature', isMainWorktree: false })
expect(
filterWorktrees(
[main, feature],
{ filterRepoIds: new Set(), hideSleeping: true, hideDefaultBranch: false },
''
)
).toEqual([main])
})
it('keeps a sleeping folder workspace visible under hide-sleeping', () => {
const folder = worktree({
workspaceKind: 'folder-workspace',
worktreeId: 'folder:workspace-1',
branch: '',
isMainWorktree: true
})
expect(
filterWorktrees(
[folder],
{ filterRepoIds: new Set(), hideSleeping: true, hideDefaultBranch: false },
''
)
).toEqual([folder])
})
it('re-hides the sleeping main worktree when the desktop setting is off', () => {
const main = worktree({ worktreeId: 'main', branch: 'main', isMainWorktree: true })
expect(
filterWorktrees(
[main],
{
filterRepoIds: new Set(),
hideSleeping: true,
hideDefaultBranch: false,
alwaysShowDefaultBranch: false
},
''
)
).toEqual([])
})
it('falls back to the branch heuristic for hosts that omit isMainWorktree', () => {
const legacyMain = worktree({ worktreeId: 'legacy-main', branch: 'refs/heads/master' })
expect(
filterWorktrees(
[legacyMain],
{ filterRepoIds: new Set(), hideSleeping: true, hideDefaultBranch: false },
''
)
).toEqual([legacyMain])
})
it('keeps a sleeping folder workspace on hosts that omit isMainWorktree', () => {
// A folder workspace has no branch, so the legacy branch heuristic can never
// recognise it — without its own arm, #8873 still reproduces on old desktops.
const legacyFolder = worktree({
workspaceKind: 'folder-workspace',
worktreeId: 'folder:legacy-1',
branch: ''
})
expect(
filterWorktrees(
[legacyFolder],
{ filterRepoIds: new Set(), hideSleeping: true, hideDefaultBranch: false },
''
)
).toEqual([legacyFolder])
})
it('lets hide-default-branch still win over the sleeping exemption', () => {
const main = worktree({ worktreeId: 'main', branch: 'main', isMainWorktree: true })
expect(
filterWorktrees(
[main],
{ filterRepoIds: new Set(), hideSleeping: true, hideDefaultBranch: true },
''
)
).toEqual([])
})
it('keeps folder workspaces when default branch hiding is enabled', () => {
const folder = worktree({
workspaceKind: 'folder-workspace',

View File

@ -65,6 +65,19 @@ function isDefaultBranchWorkspace(w: Worktree): boolean {
return branch === 'main' || branch === 'master'
}
/**
* Whether "Hide sleeping" must keep this row the project's entry point (#8873).
* Falls back to the branch heuristic for hosts that predate isMainWorktree; a
* folder workspace is always its project's entry point, and isDefaultBranchWorkspace
* rejects it by design, so it needs its own legacy arm or #8873 still reproduces.
*/
function isSleepingSweepExempt(w: Worktree, alwaysShowDefaultBranch: boolean | undefined): boolean {
if (alwaysShowDefaultBranch === false) {
return false
}
return w.isMainWorktree ?? (w.workspaceKind === 'folder-workspace' || isDefaultBranchWorkspace(w))
}
function orderMainWorktreeFirst(worktrees: Worktree[]): Worktree[] {
const mainWorktrees = worktrees.filter((worktree) => worktree.isMainWorktree)
if (mainWorktrees.length === 0) {
@ -80,7 +93,9 @@ export function filterWorktrees(
): Worktree[] {
let result = worktrees.filter((w) => !w.isArchived)
if (filters.hideSleeping) {
result = result.filter(isWorktreeActive)
result = result.filter(
(w) => isSleepingSweepExempt(w, filters.alwaysShowDefaultBranch) || isWorktreeActive(w)
)
}
if (filters.hideDefaultBranch) {
result = result.filter((w) => !isDefaultBranchWorkspace(w))

View File

@ -52,6 +52,8 @@ export type FilterState = {
filterRepoIds: Set<string>
hideSleeping: boolean
hideDefaultBranch: boolean
/** Absent means on: #8873's exemption must fail open on older host payloads. */
alwaysShowDefaultBranch?: boolean
}
export type Section = { key: string; title: string; icon?: 'pin'; data: Worktree[] }

View File

@ -16,6 +16,7 @@ export type WorkspaceViewSettings = {
sortBy?: 'name' | 'smart' | 'recent' | 'repo' | 'manual'
hideSleepingWorkspaces?: boolean
hideDefaultBranchWorkspace?: boolean
alwaysShowDefaultBranchWorkspace?: boolean
filterRepoIds?: string[]
collapsedGroups?: string[]
workspaceStatuses?: WorkspaceStatusDefinition[]
@ -60,6 +61,7 @@ export type MobileViewState = {
sortMode: MobileSortMode
hideSleeping: boolean
hideDefaultBranch: boolean
alwaysShowDefaultBranch: boolean
filterRepoIds: string[]
collapsedGroups: string[]
workspaceStatuses: readonly WorkspaceStatusDefinition[]
@ -83,6 +85,8 @@ export function applyDesktopViewSettings(
sortMode: sortMode ?? current.sortMode,
hideSleeping: settings.hideSleepingWorkspaces ?? current.hideSleeping,
hideDefaultBranch: settings.hideDefaultBranchWorkspace ?? current.hideDefaultBranch,
alwaysShowDefaultBranch:
settings.alwaysShowDefaultBranchWorkspace ?? current.alwaysShowDefaultBranch,
filterRepoIds: settings.filterRepoIds ?? current.filterRepoIds,
collapsedGroups: settings.collapsedGroups ?? current.collapsedGroups,
workspaceStatuses

View File

@ -223,6 +223,7 @@ const UiUpdateFields = z
showDotfilesByWorktree: z.record(z.string(), z.boolean()).optional(),
hideCliCreatedWorkspaces: z.boolean().optional(),
hideDetachedHeadWorkspaces: z.boolean().optional(),
alwaysShowDefaultBranchWorkspace: z.boolean().optional(),
filterRepoIds: StringArray.optional(),
collapsedGroups: StringArray.optional(),
uiZoomLevel: z.number().finite().optional(),

View File

@ -555,7 +555,8 @@ describe('client UI RPC methods', () => {
],
['browserImportHintHidden', { browserImportHintHidden: true }],
['mobileEmulatorTabIntroDismissed', { mobileEmulatorTabIntroDismissed: true }],
['mobileEmulatorAgentSetupDismissed', { mobileEmulatorAgentSetupDismissed: true }]
['mobileEmulatorAgentSetupDismissed', { mobileEmulatorAgentSetupDismissed: true }],
['alwaysShowDefaultBranchWorkspace', { alwaysShowDefaultBranchWorkspace: false }]
])('accepts %s, which the renderer persists through ui.set', async (_label, payload) => {
const runtime = {
getRuntimeId: () => 'test-runtime',
@ -592,6 +593,7 @@ describe('client UI RPC methods', () => {
showSleepingWorkspaces: true,
hideDefaultBranchWorkspace: false,
hideAutomationGeneratedWorkspaces: false,
alwaysShowDefaultBranchWorkspace: true,
showDotfilesByWorktree: { 'repo::/worktree': true },
filterRepoIds: ['repo-1'],
acknowledgedAgentsByPaneKey: { 'pane-1': 123 }

View File

@ -661,6 +661,7 @@ function App(): React.JSX.Element {
const hideAutomationGeneratedWorkspaces = useAppStore((s) => s.hideAutomationGeneratedWorkspaces)
const hideCliCreatedWorkspaces = useAppStore((s) => s.hideCliCreatedWorkspaces)
const hideDetachedHeadWorkspaces = useAppStore((s) => s.hideDetachedHeadWorkspaces)
const alwaysShowDefaultBranchWorkspace = useAppStore((s) => s.alwaysShowDefaultBranchWorkspace)
const showDotfilesByWorktree = useAppStore((s) => s.showDotfilesByWorktree)
const filterRepoIds = useAppStore((s) => s.filterRepoIds)
const acknowledgedAgentsByPaneKey = useAppStore((s) => s.acknowledgedAgentsByPaneKey)
@ -1391,6 +1392,7 @@ function App(): React.JSX.Element {
hideAutomationGeneratedWorkspaces,
hideCliCreatedWorkspaces,
hideDetachedHeadWorkspaces,
alwaysShowDefaultBranchWorkspace,
showDotfilesByWorktree,
filterRepoIds,
// Why (#9002): activeView is deliberately NOT included here. It used to
@ -1424,6 +1426,7 @@ function App(): React.JSX.Element {
hideAutomationGeneratedWorkspaces,
hideCliCreatedWorkspaces,
hideDetachedHeadWorkspaces,
alwaysShowDefaultBranchWorkspace,
showDotfilesByWorktree,
filterRepoIds,
acknowledgedAgentsByPaneKey

View File

@ -0,0 +1,351 @@
// @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 * as ReactI18Next from 'react-i18next'
import type { Repo, Worktree } from '../../../shared/types'
import { useAppStore } from '@/store'
import type { AppState } from '@/store/types'
import WorktreeJumpPalette from './WorktreeJumpPalette'
vi.mock('react-i18next', async (importOriginal) => {
const actual = await importOriginal<typeof ReactI18Next>()
return {
...actual,
useTranslation: () => ({ t: (_key: string, fallback?: string) => fallback ?? _key })
}
})
vi.mock('sonner', () => ({
toast: {
success: vi.fn(),
error: vi.fn(),
info: vi.fn(),
warning: vi.fn(),
message: vi.fn()
}
}))
vi.mock('@/hooks/useSettingsNavigationMetadata', () => ({
useSettingsNavigationMetadata: () => []
}))
vi.mock('@/components/sidebar/StatusIndicator', () => ({
default: () => <span data-status-indicator="true" />
}))
vi.mock('@/components/repo/RepoBadgeLabel', () => ({
RepoBadgeMark: () => <span data-repo-badge-mark="true" />
}))
vi.mock('@/components/cmd-j/palette-host-badge', () => ({
getPaletteHostBadge: () => null
}))
vi.mock('@/components/ui/command', async () => {
const React = await import('react')
return {
CommandDialog: ({ children, open }: { children: React.ReactNode; open?: boolean }) =>
open ? <div data-command-dialog="true">{children}</div> : null,
CommandInput: ({
value,
onValueChange,
placeholder
}: {
value?: string
onValueChange?: (next: string) => void
placeholder?: string
}) => {
setCommandQuery = onValueChange ?? null
return (
<input
data-command-input="true"
placeholder={placeholder}
value={value}
onChange={(event) => onValueChange?.(event.currentTarget.value)}
/>
)
},
CommandList: React.forwardRef(function CommandList(
{ children }: { children: React.ReactNode },
ref: React.ForwardedRef<HTMLDivElement>
) {
return (
<div ref={ref} data-command-list="true">
{children}
</div>
)
}),
CommandEmpty: ({ children }: { children: React.ReactNode }) => (
<div data-command-empty="true">{children}</div>
),
CommandItem: ({
children,
onSelect,
value
}: {
children: React.ReactNode
onSelect?: (value: string) => void
value?: string
}) => (
<button data-command-item={value ?? ''} onClick={() => onSelect?.(value ?? '')} type="button">
{children}
</button>
)
}
})
const initialAppState = useAppStore.getInitialState()
let testRoot: Root
let testContainer: HTMLDivElement
let setCommandQuery: ((next: string) => void) | null = null
function makeRepo(): Repo {
return {
id: 'repo-1',
path: '/repos/repo-1',
displayName: 'Repo 1',
badgeColor: '#000000',
addedAt: 0
}
}
function makeWorktree(
id: string,
displayName: string,
overrides: Partial<Worktree> = {}
): Worktree {
return {
id,
repoId: 'repo-1',
path: `/tmp/${id}`,
head: 'abc123',
branch: 'refs/heads/main',
isBare: false,
isMainWorktree: false,
displayName,
comment: '',
linkedIssue: null,
linkedPR: null,
linkedLinearIssue: null,
isArchived: false,
isUnread: false,
isPinned: false,
sortOrder: 0,
lastActivityAt: 0,
...overrides
}
}
async function flushEffects(): Promise<void> {
await act(async () => {
await Promise.resolve()
await Promise.resolve()
})
}
async function renderPalette(overrides: Partial<AppState>): Promise<void> {
useAppStore.setState({
activeModal: 'worktree-palette',
activeWorktreeId: null,
repos: [makeRepo()],
tabsByWorktree: {},
browserTabsByWorktree: {},
browserPagesByWorkspace: {},
unifiedTabsByWorktree: {},
hideDefaultBranchWorkspace: false,
hideAutomationGeneratedWorkspaces: false,
// Why explicit: the sweep exemption is what these cases probe, so it must
// not ride on whatever the store default happens to be.
alwaysShowDefaultBranchWorkspace: true,
lastVisitedAtByWorktreeId: {},
...overrides
} as Partial<AppState>)
await act(async () => {
testRoot.render(<WorktreeJumpPalette />)
})
await flushEffects()
}
function getWorktreeRows(): string[] {
return [...testContainer.querySelectorAll<HTMLElement>('[data-command-item^="worktree:"]')].map(
(node) => node.textContent ?? ''
)
}
describe('WorktreeJumpPalette', () => {
beforeEach(() => {
globalThis.IS_REACT_ACT_ENVIRONMENT = true
setCommandQuery = null
useAppStore.setState(initialAppState, true)
testContainer = document.createElement('div')
document.body.appendChild(testContainer)
testRoot = createRoot(testContainer)
})
afterEach(async () => {
await act(async () => {
testRoot.unmount()
})
document.body.replaceChildren()
useAppStore.setState(initialAppState, true)
})
it('keeps every inactive main workspace visible when sleeping workspaces are hidden', async () => {
const defaultBranch = makeWorktree('default-branch', 'Default branch workspace', {
isMainWorktree: true,
branch: 'refs/heads/main'
})
const feature = makeWorktree('feature', 'Feature workspace', {
branch: 'refs/heads/feature'
})
const folderMain = makeWorktree('folder-main', 'Folder workspace', {
isMainWorktree: true,
branch: ''
})
await renderPalette({
worktreesByRepo: { 'repo-1': [defaultBranch, feature, folderMain] },
showSleepingWorkspaces: false
})
expect(testContainer.textContent).toContain('Default branch workspace')
expect(testContainer.textContent).not.toContain('Feature workspace')
// Why kept: the exemption keys on isMainWorktree, not the branch name, so a
// branchless folder workspace is the project's entry point too.
expect(testContainer.textContent).toContain('Folder workspace')
})
it('keeps the explicit default-branch filter authoritative', async () => {
const defaultBranch = makeWorktree('default-branch', 'Default branch workspace', {
isMainWorktree: true,
branch: 'refs/heads/main'
})
await renderPalette({
worktreesByRepo: { 'repo-1': [defaultBranch] },
showSleepingWorkspaces: false,
hideDefaultBranchWorkspace: true
})
expect(testContainer.textContent).not.toContain('Default branch workspace')
})
it('keeps an active non-default workspace visible when sleeping workspaces are hidden', async () => {
const defaultBranch = makeWorktree('default-branch', 'Default branch workspace', {
isMainWorktree: true,
branch: 'refs/heads/main'
})
const feature = makeWorktree('feature', 'Feature workspace', {
branch: 'refs/heads/feature'
})
const folderMain = makeWorktree('folder-main', 'Folder workspace', {
isMainWorktree: true,
branch: ''
})
await renderPalette({
worktreesByRepo: { 'repo-1': [defaultBranch, feature, folderMain] },
showSleepingWorkspaces: false,
browserTabsByWorktree: {
feature: [
{
id: 'browser-tab-1',
worktreeId: 'feature',
url: 'https://example.com',
title: 'example.com',
loading: false,
faviconUrl: null,
canGoBack: false,
canGoForward: false,
loadError: null,
createdAt: 0
}
]
}
})
expect(testContainer.textContent).toContain('Feature workspace')
expect(testContainer.textContent).toContain('Default branch workspace')
// Why kept: same isMainWorktree exemption — folder workspaces are covered.
expect(testContainer.textContent).toContain('Folder workspace')
})
it('sweeps the sleeping main workspace once the exemption is opted out', async () => {
const defaultBranch = makeWorktree('default-branch', 'Default branch workspace', {
isMainWorktree: true,
branch: 'refs/heads/main'
})
const feature = makeWorktree('feature', 'Feature workspace', {
branch: 'refs/heads/feature'
})
await renderPalette({
worktreesByRepo: { 'repo-1': [defaultBranch, feature] },
showSleepingWorkspaces: false,
alwaysShowDefaultBranchWorkspace: false
})
expect(getWorktreeRows()).toEqual([])
expect(testContainer.textContent).not.toContain('Default branch workspace')
expect(testContainer.textContent).not.toContain('Feature workspace')
})
it('keeps the show-sleeping baseline and empty-query ordering intact', async () => {
const defaultBranch = makeWorktree('default-branch', 'Default branch workspace', {
isMainWorktree: true,
branch: 'refs/heads/main'
})
const feature = makeWorktree('feature', 'Feature workspace', {
branch: 'refs/heads/feature'
})
const folderMain = makeWorktree('folder-main', 'Folder workspace', {
isMainWorktree: true,
branch: ''
})
await renderPalette({
worktreesByRepo: { 'repo-1': [defaultBranch, feature, folderMain] },
showSleepingWorkspaces: true,
lastVisitedAtByWorktreeId: {
feature: 300,
'default-branch': 200,
'folder-main': 100
}
})
expect(getWorktreeRows()).toEqual([
expect.stringContaining('Feature workspace'),
expect.stringContaining('Default branch workspace'),
expect.stringContaining('Folder workspace')
])
})
it('keeps typed-query results on the full non-archived scope', async () => {
const defaultBranch = makeWorktree('default-branch', 'Default branch workspace', {
isMainWorktree: true,
branch: 'refs/heads/main'
})
const feature = makeWorktree('feature', 'Feature workspace', {
branch: 'refs/heads/feature'
})
await renderPalette({
worktreesByRepo: { 'repo-1': [defaultBranch, feature] },
showSleepingWorkspaces: false
})
expect(testContainer.textContent).not.toContain('Feature workspace')
expect(setCommandQuery).not.toBeNull()
await act(async () => {
setCommandQuery?.('Feature')
})
await flushEffects()
expect(testContainer.textContent).toContain('Feature workspace')
})
})

View File

@ -31,7 +31,8 @@ import {
isAutomationGeneratedWorkspace,
isCliCreatedWorkspace,
isDetachedHeadWorkspace,
isDefaultBranchWorkspace
isDefaultBranchWorkspace,
isSleepingSweepExemptWorkspace
} from '@/components/sidebar/visible-worktrees'
import { getLiveAgentStatusByWorktreeId, isInactiveWorkspace } from '@/lib/worktree-activity-state'
import { orderEmptyQueryWorktrees } from '@/lib/order-empty-query-worktrees'
@ -404,6 +405,7 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null {
const hideCliCreatedWorkspaces = useAppStore((s) => s.hideCliCreatedWorkspaces)
const hideDetachedHeadWorkspaces = useAppStore((s) => s.hideDetachedHeadWorkspaces)
const showSleepingWorkspaces = useAppStore((s) => s.showSleepingWorkspaces)
const alwaysShowDefaultBranchWorkspace = useAppStore((s) => s.alwaysShowDefaultBranchWorkspace)
const lastVisitedAtByWorktreeId = useAppStore((s) => s.lastVisitedAtByWorktreeId)
const workspacePortScan = useAppStore((s) => s.workspacePortScan?.result ?? null)
const openNewBrowserTabInActiveWorkspace = useAppStore(
@ -501,6 +503,9 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null {
}
if (
!showSleepingWorkspaces &&
// Why the exemption here too: Cmd+J re-implements the sidebar's
// filter pass, so the shared predicate is what keeps them in step.
!isSleepingSweepExemptWorkspace(worktree, alwaysShowDefaultBranchWorkspace) &&
isInactiveWorkspace(
worktree.id,
tabsByWorktree,
@ -515,6 +520,7 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null {
}),
[
allWorktrees,
alwaysShowDefaultBranchWorkspace,
browserTabsByWorktree,
hideAutomationGeneratedWorkspaces,
hideCliCreatedWorkspaces,

View File

@ -0,0 +1,71 @@
import React from 'react'
import { DropdownMenuShortcut } from '@/components/ui/dropdown-menu'
import { cn } from '@/lib/utils'
/**
* Switch row shared by the two workspace-filter surfaces (the sidebar filter
* dropdown and the workspace options menu). Extracted so the two copies cannot
* drift as rows are added.
*/
export function FilterToggleRow({
icon,
label,
ariaLabel,
checked,
onChange,
shortcutLabel,
indented = false
}: {
icon: React.ReactNode
label: string
/** Full sentence for assistive tech when `label` only reads in visual context. */
ariaLabel?: string
checked: boolean
onChange: (next: boolean) => void
shortcutLabel?: string
/** Renders the row as a sub-option of the row above it. */
indented?: boolean
}) {
return (
<button
type="button"
role="switch"
aria-checked={checked}
aria-label={ariaLabel}
onClick={() => onChange(!checked)}
className={cn(
'flex w-full items-center justify-between gap-2 rounded-[5px] px-2 py-1.5 text-[12px] font-medium hover:bg-muted focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring',
indented && 'pl-6'
)}
>
<span
className={cn(
'inline-flex items-center gap-2',
indented ? 'text-muted-foreground' : 'text-foreground'
)}
>
<span className="text-muted-foreground">{icon}</span>
{label}
</span>
<span className="inline-flex items-center gap-2">
{shortcutLabel ? <DropdownMenuShortcut>{shortcutLabel}</DropdownMenuShortcut> : null}
<span
aria-hidden
className={cn(
'relative h-3.5 w-6 shrink-0 rounded-full transition-colors',
checked ? 'bg-primary' : 'bg-muted-foreground/30'
)}
>
<span
className={cn(
'absolute top-0.5 left-0.5 size-2.5 rounded-full bg-background shadow-sm transition-transform',
checked && 'translate-x-2.5'
)}
/>
</span>
</span>
</button>
)
}
export default FilterToggleRow

View File

@ -23,14 +23,13 @@ import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu'
import { Tooltip, TooltipTrigger, TooltipContent } from '@/components/ui/tooltip'
import RepoBadgeLabel from '@/components/repo/RepoBadgeLabel'
import { FilterToggleRow } from './FilterToggleRow'
import { useShortcutLabel } from '@/hooks/useShortcutLabel'
import { searchRepos } from '@/lib/repo-search'
import { cn } from '@/lib/utils'
import { DEFAULT_SHOW_SLEEPING_WORKSPACES } from '../../../../shared/constants'
import { translate } from '@/i18n/i18n'
@ -62,6 +61,10 @@ const SidebarFilter = React.memo(function SidebarFilter({
const setHideCliCreatedWorkspaces = useAppStore((s) => s.setHideCliCreatedWorkspaces)
const hideDetachedHeadWorkspaces = useAppStore((s) => s.hideDetachedHeadWorkspaces)
const setHideDetachedHeadWorkspaces = useAppStore((s) => s.setHideDetachedHeadWorkspaces)
const alwaysShowDefaultBranchWorkspace = useAppStore((s) => s.alwaysShowDefaultBranchWorkspace)
const setAlwaysShowDefaultBranchWorkspace = useAppStore(
(s) => s.setAlwaysShowDefaultBranchWorkspace
)
const filterRepoIds = useAppStore((s) => s.filterRepoIds)
const setFilterRepoIds = useAppStore((s) => s.setFilterRepoIds)
const repos = useAppStore((s) => s.repos)
@ -114,6 +117,9 @@ const SidebarFilter = React.memo(function SidebarFilter({
hideAutomationGeneratedWorkspaces ||
hideCliCreatedWorkspaces ||
hideDetachedHeadWorkspaces ||
// Why counted: turning the exemption off is the only way this row narrows
// the list, so the badge and Reset filters must both notice it.
!alwaysShowDefaultBranchWorkspace ||
hasRepoFilter
const activeFilterCount =
(hasSleepingFilter ? 1 : 0) +
@ -121,6 +127,7 @@ const SidebarFilter = React.memo(function SidebarFilter({
(hideAutomationGeneratedWorkspaces ? 1 : 0) +
(hideCliCreatedWorkspaces ? 1 : 0) +
(hideDetachedHeadWorkspaces ? 1 : 0) +
(alwaysShowDefaultBranchWorkspace ? 0 : 1) +
selectedCount
const filteredRepos = useMemo(() => searchRepos(repos, query), [repos, query])
@ -136,6 +143,7 @@ const SidebarFilter = React.memo(function SidebarFilter({
setHideAutomationGeneratedWorkspaces(false)
setHideCliCreatedWorkspaces(false)
setHideDetachedHeadWorkspaces(false)
setAlwaysShowDefaultBranchWorkspace(true)
setFilterRepoIds([])
}, [
setShowSleepingWorkspaces,
@ -143,6 +151,7 @@ const SidebarFilter = React.memo(function SidebarFilter({
setHideAutomationGeneratedWorkspaces,
setHideCliCreatedWorkspaces,
setHideDetachedHeadWorkspaces,
setAlwaysShowDefaultBranchWorkspace,
setFilterRepoIds
])
@ -212,6 +221,20 @@ const SidebarFilter = React.memo(function SidebarFilter({
onChange={(hideSleeping) => setShowSleepingWorkspaces(!hideSleeping)}
shortcutLabel={sleepingShortcut === 'Unassigned' ? undefined : sleepingShortcut}
/>
<FilterToggleRow
indented
icon={<GitBranch className="size-3.5" />}
label={translate(
'auto.components.sidebar.SidebarFilter.keepDefaultBranch',
'Except default branch'
)}
ariaLabel={translate(
'auto.components.sidebar.SidebarFilter.keepDefaultBranchAria',
'Keep the default branch visible while hiding sleeping workspaces'
)}
checked={alwaysShowDefaultBranchWorkspace}
onChange={setAlwaysShowDefaultBranchWorkspace}
/>
<FilterToggleRow
icon={<GitBranch className="size-3.5" />}
label={translate(
@ -372,50 +395,4 @@ const SidebarFilter = React.memo(function SidebarFilter({
)
})
function FilterToggleRow({
icon,
label,
checked,
onChange,
shortcutLabel
}: {
icon: React.ReactNode
label: string
checked: boolean
onChange: (next: boolean) => void
shortcutLabel?: string
}) {
return (
<button
type="button"
role="switch"
aria-checked={checked}
onClick={() => onChange(!checked)}
className="flex w-full items-center justify-between gap-2 rounded-[5px] px-2 py-1.5 text-[12px] font-medium hover:bg-muted focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
>
<span className="inline-flex items-center gap-2 text-foreground">
<span className="text-muted-foreground">{icon}</span>
{label}
</span>
<span className="inline-flex items-center gap-2">
{shortcutLabel ? <DropdownMenuShortcut>{shortcutLabel}</DropdownMenuShortcut> : null}
<span
aria-hidden
className={cn(
'relative h-3.5 w-6 shrink-0 rounded-full transition-colors',
checked ? 'bg-primary' : 'bg-muted-foreground/30'
)}
>
<span
className={cn(
'absolute top-0.5 left-0.5 size-2.5 rounded-full bg-background shadow-sm transition-transform',
checked && 'translate-x-2.5'
)}
/>
</span>
</span>
</button>
)
}
export default SidebarFilter

View File

@ -1,8 +1,8 @@
import React from 'react'
import { CalendarClock, GitBranch, GitCommitHorizontal, Moon, SquareTerminal } from 'lucide-react'
import { useAppStore } from '@/store'
import { cn } from '@/lib/utils'
import { translate } from '@/i18n/i18n'
import { FilterToggleRow } from './FilterToggleRow'
const SidebarWorkspaceFilterSection = React.memo(function SidebarWorkspaceFilterSection() {
const showSleepingWorkspaces = useAppStore((s) => s.showSleepingWorkspaces)
@ -17,6 +17,10 @@ const SidebarWorkspaceFilterSection = React.memo(function SidebarWorkspaceFilter
const setHideCliCreatedWorkspaces = useAppStore((s) => s.setHideCliCreatedWorkspaces)
const hideDetachedHeadWorkspaces = useAppStore((s) => s.hideDetachedHeadWorkspaces)
const setHideDetachedHeadWorkspaces = useAppStore((s) => s.setHideDetachedHeadWorkspaces)
const alwaysShowDefaultBranchWorkspace = useAppStore((s) => s.alwaysShowDefaultBranchWorkspace)
const setAlwaysShowDefaultBranchWorkspace = useAppStore(
(s) => s.setAlwaysShowDefaultBranchWorkspace
)
return (
<>
@ -34,6 +38,20 @@ const SidebarWorkspaceFilterSection = React.memo(function SidebarWorkspaceFilter
checked={!showSleepingWorkspaces}
onChange={(hideSleeping) => setShowSleepingWorkspaces(!hideSleeping)}
/>
<FilterToggleRow
indented
icon={<GitBranch className="size-3.5" />}
label={translate(
'auto.components.sidebar.SidebarWorkspaceFilterSection.keepDefaultBranch',
'Except default branch'
)}
ariaLabel={translate(
'auto.components.sidebar.SidebarWorkspaceFilterSection.keepDefaultBranchAria',
'Keep the default branch visible while hiding sleeping workspaces'
)}
checked={alwaysShowDefaultBranchWorkspace}
onChange={setAlwaysShowDefaultBranchWorkspace}
/>
<FilterToggleRow
icon={<GitBranch className="size-3.5" />}
label={translate(
@ -74,45 +92,4 @@ const SidebarWorkspaceFilterSection = React.memo(function SidebarWorkspaceFilter
)
})
function FilterToggleRow({
icon,
label,
checked,
onChange
}: {
icon: React.ReactNode
label: string
checked: boolean
onChange: (next: boolean) => void
}) {
return (
<button
type="button"
role="switch"
aria-checked={checked}
onClick={() => onChange(!checked)}
className="flex w-full items-center justify-between gap-2 rounded-[5px] px-2 py-1.5 text-[12px] font-medium hover:bg-muted focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
>
<span className="inline-flex items-center gap-2 text-foreground">
<span className="text-muted-foreground">{icon}</span>
{label}
</span>
<span
aria-hidden
className={cn(
'relative h-3.5 w-6 shrink-0 rounded-full transition-colors',
checked ? 'bg-primary' : 'bg-muted-foreground/30'
)}
>
<span
className={cn(
'absolute top-0.5 left-0.5 size-2.5 rounded-full bg-background shadow-sm transition-transform',
checked && 'translate-x-2.5'
)}
/>
</span>
</button>
)
}
export default SidebarWorkspaceFilterSection

View File

@ -40,6 +40,7 @@ const SidebarWorkspaceOptionsMenu = React.memo(function SidebarWorkspaceOptionsM
const hideAutomationGeneratedWorkspaces = useAppStore((s) => s.hideAutomationGeneratedWorkspaces)
const hideCliCreatedWorkspaces = useAppStore((s) => s.hideCliCreatedWorkspaces)
const hideDetachedHeadWorkspaces = useAppStore((s) => s.hideDetachedHeadWorkspaces)
const alwaysShowDefaultBranchWorkspace = useAppStore((s) => s.alwaysShowDefaultBranchWorkspace)
const filterRepoIds = useAppStore((s) => s.filterRepoIds)
const repos = useAppStore((s) => s.repos)
const setWorkspaceHostScope = useAppStore((s) => s.setWorkspaceHostScope)
@ -84,6 +85,7 @@ const SidebarWorkspaceOptionsMenu = React.memo(function SidebarWorkspaceOptionsM
hideAutomationGeneratedWorkspaces ||
hideCliCreatedWorkspaces ||
hideDetachedHeadWorkspaces ||
!alwaysShowDefaultBranchWorkspace ||
hasRepoFilter ||
hasHostVisibilityFilter
const activeFilterCount =
@ -92,6 +94,7 @@ const SidebarWorkspaceOptionsMenu = React.memo(function SidebarWorkspaceOptionsM
(hideAutomationGeneratedWorkspaces ? 1 : 0) +
(hideCliCreatedWorkspaces ? 1 : 0) +
(hideDetachedHeadWorkspaces ? 1 : 0) +
(alwaysShowDefaultBranchWorkspace ? 0 : 1) +
(hasHostVisibilityFilter ? 1 : 0) +
selectedCount
const activeFilterLabel = `${activeFilterCount} ${activeFilterCount === 1 ? 'filter' : 'filters'}`

View File

@ -5222,6 +5222,7 @@ const WorktreeList = React.memo(function WorktreeList({
const hideAutomationGeneratedWorkspaces = useAppStore((s) => s.hideAutomationGeneratedWorkspaces)
const hideCliCreatedWorkspaces = useAppStore((s) => s.hideCliCreatedWorkspaces)
const hideDetachedHeadWorkspaces = useAppStore((s) => s.hideDetachedHeadWorkspaces)
const alwaysShowDefaultBranchWorkspace = useAppStore((s) => s.alwaysShowDefaultBranchWorkspace)
const filterRepoIds = useAppStore((s) => s.filterRepoIds)
const openModal = useAppStore((s) => s.openModal)
const openSettingsPage = useAppStore((s) => s.openSettingsPage)
@ -5509,6 +5510,7 @@ const WorktreeList = React.memo(function WorktreeList({
hideAutomationGeneratedWorkspaces,
hideCliCreatedWorkspaces,
hideDetachedHeadWorkspaces,
alwaysShowDefaultBranchWorkspace,
repoMap,
workspaceHostScope,
visibleWorkspaceHostIds,
@ -5526,6 +5528,7 @@ const WorktreeList = React.memo(function WorktreeList({
hideAutomationGeneratedWorkspaces,
hideCliCreatedWorkspaces,
hideDetachedHeadWorkspaces,
alwaysShowDefaultBranchWorkspace,
workspaceHostScope,
visibleWorkspaceHostIds,
settings,
@ -6487,6 +6490,7 @@ const WorktreeList = React.memo(function WorktreeList({
hideAutomationGeneratedWorkspaces,
hideCliCreatedWorkspaces,
hideDetachedHeadWorkspaces,
alwaysShowDefaultBranchWorkspace,
visibleWorkspaceHostIds,
workspaceHostScope
}),
@ -6497,6 +6501,7 @@ const WorktreeList = React.memo(function WorktreeList({
hideAutomationGeneratedWorkspaces,
hideCliCreatedWorkspaces,
hideDetachedHeadWorkspaces,
alwaysShowDefaultBranchWorkspace,
visibleWorkspaceHostIds,
workspaceHostScope
]
@ -6509,6 +6514,9 @@ const WorktreeList = React.memo(function WorktreeList({
)
const setHideCliCreatedWorkspaces = useAppStore((s) => s.setHideCliCreatedWorkspaces)
const setHideDetachedHeadWorkspaces = useAppStore((s) => s.setHideDetachedHeadWorkspaces)
const setAlwaysShowDefaultBranchWorkspace = useAppStore(
(s) => s.setAlwaysShowDefaultBranchWorkspace
)
const setFilterRepoIds = useAppStore((s) => s.setFilterRepoIds)
const setVisibleWorkspaceHostIds = useAppStore((s) => s.setVisibleWorkspaceHostIds)
@ -6532,6 +6540,9 @@ const WorktreeList = React.memo(function WorktreeList({
if (actions.resetHideDetachedHeadWorkspaces) {
setHideDetachedHeadWorkspaces(false)
}
if (actions.resetAlwaysShowDefaultBranchWorkspace) {
setAlwaysShowDefaultBranchWorkspace(true)
}
if (actions.resetVisibleWorkspaceHostIds) {
setVisibleWorkspaceHostIds(null)
}
@ -6542,6 +6553,7 @@ const WorktreeList = React.memo(function WorktreeList({
setHideAutomationGeneratedWorkspaces,
setHideCliCreatedWorkspaces,
setHideDetachedHeadWorkspaces,
setAlwaysShowDefaultBranchWorkspace,
setVisibleWorkspaceHostIds,
filterState
])

View File

@ -34,11 +34,14 @@ function makeState(overrides: Partial<AddRepoSkipFinalizationState>): AddRepoSki
filterRepoIds: [],
showActiveOnly: false,
hideDefaultBranchWorkspace: false,
showSleepingWorkspaces: true,
alwaysShowDefaultBranchWorkspace: true,
worktreesByRepo: {},
setActiveRepo: vi.fn(),
setFilterRepoIds: vi.fn(),
setShowActiveOnly: vi.fn(),
setHideDefaultBranchWorkspace: vi.fn(),
setAlwaysShowDefaultBranchWorkspace: vi.fn(),
...overrides
}
}
@ -83,6 +86,48 @@ describe('finalizeImportedRepoAfterSkip', () => {
expect(state.setHideDefaultBranchWorkspace).toHaveBeenCalledWith(false)
})
it('re-enables the default-branch exemption when the import would land asleep and hidden', () => {
const state = makeState({
showSleepingWorkspaces: false,
alwaysShowDefaultBranchWorkspace: false,
worktreesByRepo: {
'repo-new': [
makeWorktree({
id: 'repo-new::/repo/main',
repoId: 'repo-new',
isMainWorktree: true,
branch: 'refs/heads/main'
})
]
}
})
finalizeImportedRepoAfterSkip(state, 'repo-new')
expect(state.setAlwaysShowDefaultBranchWorkspace).toHaveBeenCalledWith(true)
})
it('leaves the default-branch exemption alone when sleeping workspaces are shown', () => {
const state = makeState({
showSleepingWorkspaces: true,
alwaysShowDefaultBranchWorkspace: false,
worktreesByRepo: {
'repo-new': [
makeWorktree({
id: 'repo-new::/repo/main',
repoId: 'repo-new',
isMainWorktree: true,
branch: 'refs/heads/main'
})
]
}
})
finalizeImportedRepoAfterSkip(state, 'repo-new')
expect(state.setAlwaysShowDefaultBranchWorkspace).not.toHaveBeenCalled()
})
it('still reveals the imported repo when it has no discovered worktrees yet', () => {
const state = makeState({
activeRepoId: 'repo-old',

View File

@ -6,11 +6,14 @@ export type AddRepoSkipFinalizationState = {
filterRepoIds: string[]
showActiveOnly: boolean
hideDefaultBranchWorkspace: boolean
showSleepingWorkspaces: boolean
alwaysShowDefaultBranchWorkspace: boolean
worktreesByRepo: Record<string, Worktree[]>
setActiveRepo: (repoId: string | null) => void
setFilterRepoIds: (repoIds: string[]) => void
setShowActiveOnly: (value: boolean) => void
setHideDefaultBranchWorkspace: (value: boolean) => void
setAlwaysShowDefaultBranchWorkspace: (value: boolean) => void
}
export function finalizeImportedRepoAfterSkip(
@ -37,4 +40,14 @@ export function finalizeImportedRepoAfterSkip(
) {
state.setHideDefaultBranchWorkspace(false)
}
// Why: with "Hide sleeping" on, a freshly imported project has no live PTY
// yet, so the opted-out exemption would leave it invisible on arrival.
if (
importedWorktrees.length > 0 &&
state.alwaysShowDefaultBranchWorkspace === false &&
!state.showSleepingWorkspaces &&
importedWorktrees.every((worktree) => worktree.isMainWorktree)
) {
state.setAlwaysShowDefaultBranchWorkspace(true)
}
}

View File

@ -0,0 +1,205 @@
import { describe, expect, it } from 'vitest'
import {
computeVisibleWorktreeIds,
isDefaultBranchWorkspace,
type SidebarFilterState
} from './visible-worktrees'
import type { Repo, Worktree } from '../../../../shared/types'
import { LOCAL_EXECUTION_HOST_ID } from '../../../../shared/execution-host'
/**
* Repro for #8873 "[Feature]: Display default branch".
*
* Reporter: turning "Hide default branch" OFF does not keep the repo's
* default-branch workspace in the sidebar; once it has no live PTY/browser/agent
* it counts as sleeping and "Hide sleeping" sweeps it away. There is no
* "Always display default branch" escape hatch.
*/
function makeRepo(id: string): Repo {
return { id, path: `/${id}`, displayName: id, badgeColor: '#000', addedAt: 0 }
}
function makeDefaultBranchWorktree(): Worktree {
return {
id: 'wt-main',
repoId: 'repo1',
path: '/tmp/repo1',
head: 'abc123',
branch: 'refs/heads/main',
isBare: false,
isMainWorktree: true,
displayName: 'main',
comment: '',
linkedIssue: null,
linkedPR: null,
linkedLinearIssue: null,
isArchived: false,
isUnread: false,
isPinned: false,
sortOrder: 0,
lastActivityAt: 0
}
}
const repoMap = new Map<string, Repo>([['repo1', makeRepo('repo1')]])
type VisibleOptions = Parameters<typeof computeVisibleWorktreeIds>[2]
function visibleOptions(overrides: Partial<VisibleOptions> = {}): VisibleOptions {
return {
filterRepoIds: [],
showSleepingWorkspaces: true,
tabsByWorktree: {},
ptyIdsByTabId: {},
browserTabsByWorktree: {},
worktreeIdsWithLiveAgent: new Set(),
hideDefaultBranchWorkspace: false,
hideAutomationGeneratedWorkspaces: false,
hideCliCreatedWorkspaces: false,
hideDetachedHeadWorkspaces: false,
repoMap,
workspaceHostScope: 'all',
defaultHostId: LOCAL_EXECUTION_HOST_ID,
worktreeLineageById: {},
...overrides
}
}
describe('#8873 default-branch workspace under "Hide sleeping"', () => {
it('is genuinely the default-branch row the "Hide default branch" toggle targets', () => {
expect(isDefaultBranchWorkspace(makeDefaultBranchWorktree())).toBe(true)
})
it('stays in the sidebar when it is sleeping and "Hide sleeping" is on', () => {
const worktree = makeDefaultBranchWorktree()
const visible = computeVisibleWorktreeIds({ repo1: [worktree] }, [worktree.id], {
...visibleOptions({
// "Hide sleeping" ON, "Hide default branch" OFF — exactly the reporter's setup.
showSleepingWorkspaces: false,
hideDefaultBranchWorkspace: false
})
})
expect(visible).toEqual([worktree.id])
})
it('exposes an opt-in that keeps the default branch visible under "Hide sleeping"', () => {
// The issue asks for an "Always display default branch" flag. Prove the
// sidebar filter contract has no such knob today.
const filterKeys: readonly (keyof SidebarFilterState)[] = [
'showSleepingWorkspaces',
'filterRepoIds',
'hideDefaultBranchWorkspace',
'hideAutomationGeneratedWorkspaces',
'hideCliCreatedWorkspaces',
'hideDetachedHeadWorkspaces',
'alwaysShowDefaultBranchWorkspace',
'visibleWorkspaceHostIds',
'workspaceHostScope'
]
const optionKeys = Object.keys(visibleOptions())
const alwaysShowKnob = [...filterKeys, ...optionKeys].find((key) =>
/always.*default|default.*always|pinDefaultBranch/i.test(key)
)
expect(alwaysShowKnob).toBeDefined()
})
})
describe('the "Hide sleeping" exemption for project entry-point rows', () => {
function visible(worktrees: Worktree[], overrides: Partial<VisibleOptions>): string[] {
return computeVisibleWorktreeIds(
{ repo1: worktrees },
worktrees.map((w) => w.id),
visibleOptions({ showSleepingWorkspaces: false, ...overrides })
)
}
it('re-hides the sleeping default branch when the user opts out', () => {
const worktree = makeDefaultBranchWorktree()
expect(visible([worktree], { alwaysShowDefaultBranchWorkspace: false })).toEqual([])
})
it('keeps the sleeping default branch when the option is explicitly on', () => {
const worktree = makeDefaultBranchWorktree()
expect(visible([worktree], { alwaysShowDefaultBranchWorkspace: true })).toEqual([worktree.id])
})
it('lets an explicit "Hide default branch" still win over the exemption', () => {
const worktree = makeDefaultBranchWorktree()
expect(
visible([worktree], {
hideDefaultBranchWorkspace: true,
alwaysShowDefaultBranchWorkspace: true
})
).toEqual([])
})
it('still sweeps sleeping non-main workspaces', () => {
const feature: Worktree = {
...makeDefaultBranchWorktree(),
id: 'wt-feature',
branch: 'refs/heads/feature',
isMainWorktree: false
}
expect(visible([feature], { alwaysShowDefaultBranchWorkspace: true })).toEqual([])
})
it('keeps a sleeping folder workspace, which has no sibling row to fall back to', () => {
// Folder-mode projects are main worktrees with an empty branch/head, so the
// default-branch predicate rejects them; sweeping them drops the whole project.
const folder: Worktree = {
...makeDefaultBranchWorktree(),
id: 'wt-folder',
branch: '',
head: ''
}
expect(isDefaultBranchWorkspace(folder)).toBe(false)
expect(visible([folder], { alwaysShowDefaultBranchWorkspace: true })).toEqual([folder.id])
})
it('keeps a sleeping detached-HEAD main worktree', () => {
const detachedMain: Worktree = { ...makeDefaultBranchWorktree(), id: 'wt-detached', branch: '' }
expect(visible([detachedMain], { alwaysShowDefaultBranchWorkspace: true })).toEqual([
detachedMain.id
])
})
it('lets "Hide detached HEAD" still win over the exemption', () => {
const detachedMain: Worktree = { ...makeDefaultBranchWorktree(), id: 'wt-detached', branch: '' }
expect(
visible([detachedMain], {
hideDetachedHeadWorkspaces: true,
alwaysShowDefaultBranchWorkspace: true
})
).toEqual([])
})
it('keeps every projects entry point, not just the first', () => {
const mainA = makeDefaultBranchWorktree()
const mainB: Worktree = { ...makeDefaultBranchWorktree(), id: 'wt-main-b' }
expect(visible([mainA, mainB], {})).toEqual([mainA.id, mainB.id])
})
it('does not flicker out while an SSH host is offline', () => {
// A disconnected SSH provider re-synthesizes persisted worktrees with empty
// head/branch (orca-runtime.ts) while isMainWorktree stays a path compare —
// and the dead PTYs make the row read as sleeping at exactly that moment.
const awake = makeDefaultBranchWorktree()
const offline: Worktree = { ...awake, head: '', branch: '' }
expect(visible([offline], {})).toEqual([offline.id])
expect(visible([awake], {})).toEqual([awake.id])
})
})

View File

@ -38,6 +38,7 @@ function filterState(overrides: Partial<FilterState> = {}): FilterState {
hideAutomationGeneratedWorkspaces: false,
hideCliCreatedWorkspaces: false,
hideDetachedHeadWorkspaces: false,
alwaysShowDefaultBranchWorkspace: true,
workspaceHostScope: 'all',
...overrides
}
@ -97,6 +98,17 @@ describe('sidebarHasActiveFilters', () => {
expect(sidebarHasActiveFilters(filterState({ filterRepoIds: ['repo1'] }))).toBe(true)
})
it('counts an opted-out default-branch exemption as an active filter', () => {
expect(sidebarHasActiveFilters(filterState({ alwaysShowDefaultBranchWorkspace: false }))).toBe(
true
)
})
it('treats a missing default-branch exemption as the default, not a filter', () => {
const { alwaysShowDefaultBranchWorkspace: _omitted, ...withoutFlag } = filterState()
expect(sidebarHasActiveFilters(withoutFlag)).toBe(false)
})
it('returns true when only host visibility is narrowed', () => {
expect(sidebarHasActiveFilters(filterState({ visibleWorkspaceHostIds: ['local'] }))).toBe(true)
})
@ -111,6 +123,7 @@ describe('computeClearFilterActions', () => {
resetHideAutomationGeneratedWorkspaces: false,
resetHideCliCreatedWorkspaces: false,
resetHideDetachedHeadWorkspaces: false,
resetAlwaysShowDefaultBranchWorkspace: false,
resetVisibleWorkspaceHostIds: false
})
})
@ -126,6 +139,7 @@ describe('computeClearFilterActions', () => {
resetHideAutomationGeneratedWorkspaces: false,
resetHideCliCreatedWorkspaces: false,
resetHideDetachedHeadWorkspaces: false,
resetAlwaysShowDefaultBranchWorkspace: false,
resetVisibleWorkspaceHostIds: false
})
})
@ -140,6 +154,7 @@ describe('computeClearFilterActions', () => {
resetHideAutomationGeneratedWorkspaces: true,
resetHideCliCreatedWorkspaces: false,
resetHideDetachedHeadWorkspaces: false,
resetAlwaysShowDefaultBranchWorkspace: false,
resetVisibleWorkspaceHostIds: false
})
})
@ -152,6 +167,7 @@ describe('computeClearFilterActions', () => {
resetHideAutomationGeneratedWorkspaces: false,
resetHideCliCreatedWorkspaces: true,
resetHideDetachedHeadWorkspaces: false,
resetAlwaysShowDefaultBranchWorkspace: false,
resetVisibleWorkspaceHostIds: false
})
})
@ -164,6 +180,7 @@ describe('computeClearFilterActions', () => {
resetHideAutomationGeneratedWorkspaces: false,
resetHideCliCreatedWorkspaces: false,
resetHideDetachedHeadWorkspaces: true,
resetAlwaysShowDefaultBranchWorkspace: false,
resetVisibleWorkspaceHostIds: false
})
})
@ -189,10 +206,26 @@ describe('computeClearFilterActions', () => {
resetHideAutomationGeneratedWorkspaces: false,
resetHideCliCreatedWorkspaces: false,
resetHideDetachedHeadWorkspaces: false,
resetAlwaysShowDefaultBranchWorkspace: false,
resetVisibleWorkspaceHostIds: true
})
})
it('flags only the default-branch exemption for reset when it is the sole filter', () => {
expect(
computeClearFilterActions(filterState({ alwaysShowDefaultBranchWorkspace: false }))
).toEqual({
resetShowSleepingWorkspaces: false,
resetFilterRepoIds: false,
resetHideDefaultBranchWorkspace: false,
resetHideAutomationGeneratedWorkspaces: false,
resetHideCliCreatedWorkspaces: false,
resetHideDetachedHeadWorkspaces: false,
resetAlwaysShowDefaultBranchWorkspace: true,
resetVisibleWorkspaceHostIds: false
})
})
it('flags every active filter simultaneously', () => {
expect(
computeClearFilterActions(
@ -211,6 +244,7 @@ describe('computeClearFilterActions', () => {
resetHideAutomationGeneratedWorkspaces: true,
resetHideCliCreatedWorkspaces: false,
resetHideDetachedHeadWorkspaces: false,
resetAlwaysShowDefaultBranchWorkspace: false,
resetVisibleWorkspaceHostIds: true
})
})

View File

@ -22,6 +22,7 @@ export function useVisibleWorkspaceKanbanWorktreeIds({
const hideAutomationGeneratedWorkspaces = useAppStore((s) => s.hideAutomationGeneratedWorkspaces)
const hideCliCreatedWorkspaces = useAppStore((s) => s.hideCliCreatedWorkspaces)
const hideDetachedHeadWorkspaces = useAppStore((s) => s.hideDetachedHeadWorkspaces)
const alwaysShowDefaultBranchWorkspace = useAppStore((s) => s.alwaysShowDefaultBranchWorkspace)
const workspaceHostScope = useAppStore((s) => s.workspaceHostScope)
const visibleWorkspaceHostIds = useAppStore((s) => s.visibleWorkspaceHostIds)
const settings = useAppStore((s) => s.settings)
@ -61,6 +62,7 @@ export function useVisibleWorkspaceKanbanWorktreeIds({
hideAutomationGeneratedWorkspaces,
hideCliCreatedWorkspaces,
hideDetachedHeadWorkspaces,
alwaysShowDefaultBranchWorkspace,
repoMap,
workspaceHostScope,
visibleWorkspaceHostIds,
@ -79,6 +81,7 @@ export function useVisibleWorkspaceKanbanWorktreeIds({
hideAutomationGeneratedWorkspaces,
hideCliCreatedWorkspaces,
hideDetachedHeadWorkspaces,
alwaysShowDefaultBranchWorkspace,
workspaceHostScope,
visibleWorkspaceHostIds,
settings,

View File

@ -553,6 +553,69 @@ describe('computeVisibleWorktreeIds', () => {
expect(result).toEqual([parent.id, child.id])
})
it('gives a sleeping-exempt main its cached-sort slot, not a position at the end', () => {
// #8873: the exempted main enters `all` and is sorted with everyone else,
// so Cmd+1-9 numbers it where the sidebar actually renders it.
const awakeA = makeWorktree('awake-a')
const main = { ...makeWorktree('main'), isMainWorktree: true }
const awakeB = makeWorktree('awake-b')
const options = {
showSleepingWorkspaces: false,
tabsByWorktree: {
[awakeA.id]: [makeTab('t-a', awakeA.id, 'p-a')],
[awakeB.id]: [makeTab('t-b', awakeB.id, 'p-b')]
},
ptyIdsByTabId: { 't-a': ['p-a'], 't-b': ['p-b'] }
}
const byRepo = { repo1: [awakeA, main, awakeB] }
const sortedIds = [awakeA.id, main.id, awakeB.id]
expect(
computeVisibleWorktreeIds(
byRepo,
sortedIds,
visibleOptions({ ...options, alwaysShowDefaultBranchWorkspace: true })
)
).toEqual([awakeA.id, main.id, awakeB.id])
expect(
computeVisibleWorktreeIds(
byRepo,
sortedIds,
visibleOptions({ ...options, alwaysShowDefaultBranchWorkspace: false })
)
).toEqual([awakeA.id, awakeB.id])
})
it('leaves lineage ordering untouched when the exempted main is also a parent', () => {
// The parent used to arrive via addVisibleLineageAncestors and now arrives
// on its own; either way it must render immediately above its child.
const parent = { ...makeWorktree('parent'), isMainWorktree: true }
const child = makeWorktree('child')
const sibling = makeWorktree('sibling')
const lineage = makeWorktreeLineage(child, parent)
const run = (alwaysShowDefaultBranchWorkspace: boolean): string[] =>
computeVisibleWorktreeIds(
{ repo1: [parent, child, sibling] },
[sibling.id, child.id, parent.id],
visibleOptions({
showSleepingWorkspaces: false,
alwaysShowDefaultBranchWorkspace,
tabsByWorktree: {
[child.id]: [makeTab('t-child', child.id, 'p-child')],
[sibling.id]: [makeTab('t-sib', sibling.id, 'p-sib')]
},
ptyIdsByTabId: { 't-child': ['p-child'], 't-sib': ['p-sib'] },
worktreeLineageById: { [child.id]: lineage }
})
)
expect(run(true)).toEqual([sibling.id, parent.id, child.id])
expect(run(false)).toEqual(run(true))
})
it('includes a filtered parent from resolved inline lineage when hydration has no side-map entry', () => {
const parent = makeWorktree('parent')
const child = makeWorktree('child')

View File

@ -35,6 +35,25 @@ export function isDefaultBranchWorkspace(worktree: Worktree): boolean {
return worktree.isMainWorktree && worktree.branch.trim() !== ''
}
/**
* Whether the "Hide sleeping" sweep must keep this row (#8873).
*
* Why isMainWorktree and not isDefaultBranchWorkspace: the project's primary
* checkout is the repo's only guaranteed entry point. Folder workspaces and
* detached-HEAD mains fail the default-branch predicate yet often have no
* sibling row at all, so sweeping them drops the entire project out of the
* sidebar, Cmd+J and the board with no way back except changing a filter.
*
* Why shared: the sidebar pipeline and the jump palette both apply this, and a
* second copy is how the two surfaces drift.
*/
export function isSleepingSweepExemptWorkspace(
worktree: Worktree,
alwaysShowDefaultBranchWorkspace: boolean | undefined
): boolean {
return alwaysShowDefaultBranchWorkspace !== false && worktree.isMainWorktree
}
export function isAutomationGeneratedWorkspace(worktree: Worktree): boolean {
return worktree.automationProvenance?.kind === 'created-by-automation'
}
@ -63,6 +82,8 @@ export type SidebarFilterState = {
hideAutomationGeneratedWorkspaces: boolean
hideCliCreatedWorkspaces: boolean
hideDetachedHeadWorkspaces: boolean
/** Keeps each project's main workspace out of the "Hide sleeping" sweep; absent means on. */
alwaysShowDefaultBranchWorkspace?: boolean
visibleWorkspaceHostIds?: readonly ExecutionHostId[] | null
workspaceHostScope?: ExecutionHostScope
}
@ -84,6 +105,9 @@ export function sidebarHasActiveFilters(state: SidebarFilterState): boolean {
state.hideAutomationGeneratedWorkspaces ||
state.hideCliCreatedWorkspaces ||
state.hideDetachedHeadWorkspaces ||
// Why: turning this off is the only way to narrow the list below the
// default, so Clear Filters must be able to undo it like any other filter.
state.alwaysShowDefaultBranchWorkspace === false ||
state.visibleWorkspaceHostIds != null ||
(state.workspaceHostScope != null && state.workspaceHostScope !== ALL_EXECUTION_HOSTS_SCOPE)
)
@ -98,6 +122,7 @@ export type ClearFilterActions = {
resetHideAutomationGeneratedWorkspaces: boolean
resetHideCliCreatedWorkspaces: boolean
resetHideDetachedHeadWorkspaces: boolean
resetAlwaysShowDefaultBranchWorkspace: boolean
resetVisibleWorkspaceHostIds: boolean
}
@ -119,6 +144,7 @@ export function computeClearFilterActions(state: SidebarFilterState): ClearFilte
resetHideAutomationGeneratedWorkspaces: state.hideAutomationGeneratedWorkspaces,
resetHideCliCreatedWorkspaces: state.hideCliCreatedWorkspaces,
resetHideDetachedHeadWorkspaces: state.hideDetachedHeadWorkspaces,
resetAlwaysShowDefaultBranchWorkspace: state.alwaysShowDefaultBranchWorkspace === false,
resetVisibleWorkspaceHostIds:
state.visibleWorkspaceHostIds != null ||
(state.workspaceHostScope != null && state.workspaceHostScope !== ALL_EXECUTION_HOSTS_SCOPE)
@ -155,6 +181,11 @@ export function computeVisibleWorktreeIds(
hideAutomationGeneratedWorkspaces: boolean
hideCliCreatedWorkspaces: boolean
hideDetachedHeadWorkspaces: boolean
// Why optional here, against the "why required" rule above: omitting it
// must fail *open*. A caller that forgets the flag then shows an extra row
// instead of silently re-hiding the project's entry point, which is the
// exact regression #8873 reports.
alwaysShowDefaultBranchWorkspace?: boolean
repoMap: Map<string, Repo>
workspaceHostScope: ExecutionHostScope
visibleWorkspaceHostIds?: readonly ExecutionHostId[] | null
@ -211,8 +242,11 @@ export function computeVisibleWorktreeIds(
}
if (!opts.showSleepingWorkspaces) {
// Why no !hideDefaultBranchWorkspace term: that filter already ran above, so
// an explicit hide still wins over the exemption.
all = all.filter(
(w) =>
isSleepingSweepExemptWorkspace(w, opts.alwaysShowDefaultBranchWorkspace) ||
!isInactiveWorkspace(
w.id,
opts.tabsByWorktree,
@ -370,6 +404,7 @@ export function getVisibleWorktreeIds(): string[] {
hideAutomationGeneratedWorkspaces: state.hideAutomationGeneratedWorkspaces,
hideCliCreatedWorkspaces: state.hideCliCreatedWorkspaces,
hideDetachedHeadWorkspaces: state.hideDetachedHeadWorkspaces,
alwaysShowDefaultBranchWorkspace: state.alwaysShowDefaultBranchWorkspace,
repoMap,
workspaceHostScope: state.workspaceHostScope,
visibleWorkspaceHostIds: state.visibleWorkspaceHostIds,

View File

@ -0,0 +1,69 @@
import { readFileSync } from 'node:fs'
import { join } from 'node:path'
import { describe, expect, it } from 'vitest'
import { isSleepingSweepExemptWorkspace } from './sidebar/visible-worktrees'
import type { Worktree } from '../../../shared/types'
const source = readFileSync(join(__dirname, 'WorktreeJumpPalette.tsx'), 'utf8')
function makeWorktree(overrides: Partial<Worktree> = {}): Worktree {
return {
id: 'wt-main',
repoId: 'repo1',
path: '/tmp/repo1',
head: 'abc123',
branch: 'refs/heads/main',
isBare: false,
isMainWorktree: true,
displayName: 'main',
comment: '',
linkedIssue: null,
linkedPR: null,
linkedLinearIssue: null,
isArchived: false,
isUnread: false,
isPinned: false,
sortOrder: 0,
lastActivityAt: 0,
...overrides
}
}
describe('Cmd+J empty-query "Hide sleeping" pass (#8873)', () => {
// Why source-level: the palette re-implements the sidebar's filter pass
// inline, so the only structural guarantee that the two agree is that both
// call the shared predicate. A behavioral copy here would not catch a
// hand-rolled duplicate creeping back in.
it('routes the sleeping sweep through the shared exemption predicate', () => {
const start = source.indexOf('const emptyQueryVisibleWorktrees = useMemo(')
expect(start).toBeGreaterThanOrEqual(0)
const end = source.indexOf('const { visibleWorktreesForState', start)
const filterPass = source.slice(start, end)
expect(filterPass).toContain(
'!isSleepingSweepExemptWorkspace(worktree, alwaysShowDefaultBranchWorkspace)'
)
expect(filterPass).toContain('alwaysShowDefaultBranchWorkspace,')
})
it('reads the flag from the same store field the sidebar uses', () => {
expect(source).toContain(
'const alwaysShowDefaultBranchWorkspace = useAppStore((s) => s.alwaysShowDefaultBranchWorkspace)'
)
})
it('exempts a project entry point by default and honours an explicit opt-out', () => {
const main = makeWorktree()
expect(isSleepingSweepExemptWorkspace(main, undefined)).toBe(true)
expect(isSleepingSweepExemptWorkspace(main, true)).toBe(true)
expect(isSleepingSweepExemptWorkspace(main, false)).toBe(false)
})
it('never exempts a non-main workspace', () => {
const feature = makeWorktree({ id: 'wt-feature', isMainWorktree: false })
expect(isSleepingSweepExemptWorkspace(feature, true)).toBe(false)
expect(isSleepingSweepExemptWorkspace(feature, undefined)).toBe(false)
})
})

View File

@ -4309,7 +4309,9 @@
"ee240a39eb": "Edit filters",
"automationCreated": "Hide automation-created",
"cliCreated": "Hide CLI-created",
"detachedHead": "Hide detached HEAD"
"detachedHead": "Hide detached HEAD",
"keepDefaultBranch": "Except default branch",
"keepDefaultBranchAria": "Keep the default branch visible while hiding sleeping workspaces"
},
"SidebarHeader": {
"25a95899c9": "Add Project",
@ -4374,7 +4376,9 @@
"82594419ba": "Filters",
"automationCreated": "Hide automation-created",
"cliCreated": "Hide CLI-created",
"detachedHead": "Hide detached HEAD"
"detachedHead": "Hide detached HEAD",
"keepDefaultBranch": "Except default branch",
"keepDefaultBranchAria": "Keep the default branch visible while hiding sleeping workspaces"
},
"sidebarHostOptions": {
"3e102f111c": "All hosts",

View File

@ -4227,7 +4227,9 @@
"ee240a39eb": "Editar filtros",
"automationCreated": "Ocultar creados por automatizaciones",
"cliCreated": "Hide CLI-created",
"detachedHead": "Ocultar HEAD desacoplado"
"detachedHead": "Ocultar HEAD desacoplado",
"keepDefaultBranch": "Excepto la rama predeterminada",
"keepDefaultBranchAria": "Mantener visible la rama predeterminada al ocultar los espacios de trabajo en reposo"
},
"SidebarHeader": {
"92154beb7e": "Nuevo espacio de trabajo",
@ -4292,7 +4294,9 @@
"82594419ba": "Filtros",
"automationCreated": "Ocultar creados por automatizaciones",
"cliCreated": "Hide CLI-created",
"detachedHead": "Ocultar HEAD desacoplado"
"detachedHead": "Ocultar HEAD desacoplado",
"keepDefaultBranch": "Excepto la rama predeterminada",
"keepDefaultBranchAria": "Mantener visible la rama predeterminada al ocultar los espacios de trabajo en reposo"
},
"SidebarWorkspaceOptionsMenu": {
"95c9754653": "Diseño de actividad del Agent",

View File

@ -4208,7 +4208,9 @@
"ee240a39eb": "フィルターの編集",
"automationCreated": "自動化で作成されたワークスペースを非表示",
"cliCreated": "Hide CLI-created",
"detachedHead": "分離HEADを非表示"
"detachedHead": "分離HEADを非表示",
"keepDefaultBranch": "デフォルトのブランチを除く",
"keepDefaultBranchAria": "スリープ中のワークスペースを非表示にしてもデフォルトのブランチは表示したままにする"
},
"SidebarHeader": {
"92154beb7e": "新規ワークスペース",
@ -4273,7 +4275,9 @@
"82594419ba": "フィルター",
"automationCreated": "自動化で作成されたワークスペースを非表示",
"cliCreated": "Hide CLI-created",
"detachedHead": "分離HEADを非表示"
"detachedHead": "分離HEADを非表示",
"keepDefaultBranch": "デフォルトのブランチを除く",
"keepDefaultBranchAria": "スリープ中のワークスペースを非表示にしてもデフォルトのブランチは表示したままにする"
},
"SidebarWorkspaceOptionsMenu": {
"95c9754653": "Agent アクティビティのレイアウト",

View File

@ -4208,7 +4208,9 @@
"ee240a39eb": "필터 편집",
"automationCreated": "자동화로 생성된 워크스페이스 숨기기",
"cliCreated": "Hide CLI-created",
"detachedHead": "분리된 HEAD 숨기기"
"detachedHead": "분리된 HEAD 숨기기",
"keepDefaultBranch": "기본 브랜치는 제외",
"keepDefaultBranchAria": "슬립 중인 워크스페이스를 숨겨도 기본 브랜치는 계속 표시"
},
"SidebarHeader": {
"92154beb7e": "새로운 워크스페이스",
@ -4273,7 +4275,9 @@
"82594419ba": "필터",
"automationCreated": "자동화로 생성된 워크스페이스 숨기기",
"cliCreated": "Hide CLI-created",
"detachedHead": "분리된 HEAD 숨기기"
"detachedHead": "분리된 HEAD 숨기기",
"keepDefaultBranch": "기본 브랜치는 제외",
"keepDefaultBranchAria": "슬립 중인 워크스페이스를 숨겨도 기본 브랜치는 계속 표시"
},
"SidebarWorkspaceOptionsMenu": {
"95c9754653": "Agent 활동 레이아웃",

View File

@ -4220,7 +4220,9 @@
"ee240a39eb": "编辑筛选条件",
"automationCreated": "隐藏自动化创建的工作区",
"cliCreated": "Hide CLI-created",
"detachedHead": "隐藏分离 HEAD"
"detachedHead": "隐藏分离 HEAD",
"keepDefaultBranch": "默认分支除外",
"keepDefaultBranchAria": "隐藏休眠工作区时仍显示默认分支"
},
"SidebarHeader": {
"92154beb7e": "新工作区",
@ -4285,7 +4287,9 @@
"82594419ba": "筛选条件",
"automationCreated": "隐藏自动化创建的工作区",
"cliCreated": "Hide CLI-created",
"detachedHead": "隐藏分离 HEAD"
"detachedHead": "隐藏分离 HEAD",
"keepDefaultBranch": "默认分支除外",
"keepDefaultBranchAria": "隐藏休眠工作区时仍显示默认分支"
},
"SidebarWorkspaceOptionsMenu": {
"95c9754653": "智能体活动布局",

View File

@ -51,6 +51,7 @@ export function getStartupErrorFallbackUI(uiHydrated: boolean): PersistedUIState
hideDefaultBranchWorkspace: false,
hideCliCreatedWorkspaces: false,
hideDetachedHeadWorkspaces: false,
alwaysShowDefaultBranchWorkspace: true,
hideAutomationGeneratedWorkspaces: false,
filterRepoIds: [],
collapsedGroups: [],

View File

@ -721,6 +721,33 @@ describe('createUISlice hydratePersistedUI', () => {
expect(store.getState().showSleepingWorkspaces).toBe(true)
})
it('defaults the default-branch sleeping exemption to on', () => {
expect(getDefaultUIState().alwaysShowDefaultBranchWorkspace).toBe(true)
expect(createUIStore().getState().alwaysShowDefaultBranchWorkspace).toBe(true)
})
it('treats a legacy profile with no default-branch exemption key as opted in', () => {
// Why: profiles written before #8873 are exactly the ones showing the bug,
// so an absent key must hydrate to on rather than silently re-hiding main.
const store = createUIStore()
const legacy = makePersistedUI()
delete (legacy as Partial<PersistedUIState>).alwaysShowDefaultBranchWorkspace
store.getState().hydratePersistedUI(legacy, 'startup')
expect(store.getState().alwaysShowDefaultBranchWorkspace).toBe(true)
})
it('preserves an explicit default-branch exemption opt-out on hydration', () => {
const store = createUIStore()
store
.getState()
.hydratePersistedUI(makePersistedUI({ alwaysShowDefaultBranchWorkspace: false }), 'startup')
expect(store.getState().alwaysShowDefaultBranchWorkspace).toBe(false)
})
it('defaults workspace host scope to all hosts', () => {
expect(getDefaultUIState().workspaceHostScope).toBe('all')
expect(createUIStore().getState().workspaceHostScope).toBe('all')

View File

@ -875,6 +875,8 @@ export type UISlice = {
setHideCliCreatedWorkspaces: (v: boolean) => void
hideDetachedHeadWorkspaces: boolean
setHideDetachedHeadWorkspaces: (v: boolean) => void
alwaysShowDefaultBranchWorkspace: boolean
setAlwaysShowDefaultBranchWorkspace: (v: boolean) => void
showDotfilesByWorktree: Record<string, boolean>
setShowDotfilesForWorktree: (worktreeId: string, showDotfiles: boolean) => void
toggleShowDotfilesForWorktree: (worktreeId: string) => void
@ -2053,6 +2055,8 @@ export const createUISlice: StateCreator<AppState, [], [], UISlice> = (set, get)
setHideCliCreatedWorkspaces: (v) => set({ hideCliCreatedWorkspaces: v }),
hideDetachedHeadWorkspaces: false,
setHideDetachedHeadWorkspaces: (v) => set({ hideDetachedHeadWorkspaces: v }),
alwaysShowDefaultBranchWorkspace: true,
setAlwaysShowDefaultBranchWorkspace: (v) => set({ alwaysShowDefaultBranchWorkspace: v }),
showDotfilesByWorktree: {},
setShowDotfilesForWorktree: (worktreeId, showDotfiles) =>
@ -2459,6 +2463,9 @@ export const createUISlice: StateCreator<AppState, [], [], UISlice> = (set, get)
hideAutomationGeneratedWorkspaces: ui.hideAutomationGeneratedWorkspaces === true,
hideCliCreatedWorkspaces: ui.hideCliCreatedWorkspaces === true,
hideDetachedHeadWorkspaces: ui.hideDetachedHeadWorkspaces === true,
// Why !== false: profiles written before #8873 have no key, and they are
// precisely the ones showing the bug, so absence must mean "exempt".
alwaysShowDefaultBranchWorkspace: ui.alwaysShowDefaultBranchWorkspace !== false,
showDotfilesByWorktree: sanitizeShowDotfilesByWorktree(ui.showDotfilesByWorktree),
// Why: startup hydrates UI before repo catalogs, so defer repo-filter validation to the all-host refresh.
filterRepoIds:

View File

@ -479,6 +479,7 @@ export function getDefaultUIState(): PersistedUIState {
hideAutomationGeneratedWorkspaces: false,
hideCliCreatedWorkspaces: false,
hideDetachedHeadWorkspaces: false,
alwaysShowDefaultBranchWorkspace: true,
showDotfilesByWorktree: {},
filterRepoIds: [],
collapsedGroups: [],

View File

@ -3396,6 +3396,8 @@ export type PersistedUIState = {
hideCliCreatedWorkspaces?: boolean
/** Hide workspaces sitting on a detached HEAD; folder workspaces (no head at all) are unaffected. */
hideDetachedHeadWorkspaces?: boolean
/** Keep each project's main workspace out of the "Hide sleeping" sweep. Absent means on (#8873). */
alwaysShowDefaultBranchWorkspace?: boolean
/** Per-worktree Explorer dotfile visibility. Missing entries inherit the default: show. */
showDotfilesByWorktree?: Record<string, boolean>
filterRepoIds: string[]

View File

@ -0,0 +1,165 @@
/**
* Regression #8873: with "Hide sleeping" on, the project's main workspace must
* stay in the sidebar it is the only guaranteed way back into the project
* while a sleeping feature workspace is still swept.
*/
import type { Page } from '@stablyai/playwright-test'
import { test, expect } from './helpers/orca-app'
import { waitForActiveWorktree, waitForSessionReady } from './helpers/store'
import { worktreeRow } from './worktree-row-locators'
type SidebarVisibilityScenario = {
defaultBranchId: string
featureId: string
}
async function seedSidebarVisibilityScenario(page: Page): Promise<SidebarVisibilityScenario> {
return page.evaluate(() => {
const store = window.__store
if (!store) {
throw new Error('window.__store is not available')
}
const state = store.getState()
const repo = state.repos[0]
if (!repo) {
throw new Error('Sidebar visibility E2E needs a seeded repo')
}
const currentWorktree = (state.worktreesByRepo[repo.id] ?? [])[0]
if (!currentWorktree) {
throw new Error('Sidebar visibility E2E needs a seeded worktree')
}
const defaultBranchId = 'e2e-default-branch-visibility-main'
const featureId = 'e2e-default-branch-visibility-feature'
const currentId = currentWorktree.id
store.setState((current) => ({
worktreesByRepo: {
...current.worktreesByRepo,
[repo.id]: [
{
...currentWorktree,
id: currentId,
displayName: 'Current workspace',
isMainWorktree: false,
branch: 'refs/heads/current',
lastActivityAt: 3
},
{
...currentWorktree,
id: defaultBranchId,
displayName: 'Default branch workspace',
isMainWorktree: true,
branch: 'refs/heads/main',
lastActivityAt: 2
},
{
...currentWorktree,
id: featureId,
displayName: 'Feature workspace',
isMainWorktree: false,
branch: 'refs/heads/feature',
lastActivityAt: 1
}
]
},
tabsByWorktree: {
...current.tabsByWorktree,
[defaultBranchId]: [],
[featureId]: []
},
browserTabsByWorktree: {
...current.browserTabsByWorktree,
[defaultBranchId]: [],
[featureId]: []
}
}))
const nextState = store.getState()
nextState.setActiveView('terminal')
nextState.setSidebarOpen(true)
nextState.setGroupBy('none')
nextState.setSortBy('recent')
nextState.setShowActiveOnly(false)
nextState.setFilterRepoIds([])
return { defaultBranchId, featureId }
})
}
test.describe('Default branch visibility', () => {
test.beforeEach(async ({ orcaPage }) => {
await waitForSessionReady(orcaPage)
await waitForActiveWorktree(orcaPage)
})
test('keeps the default branch visible when sleeping workspaces are hidden', async ({
orcaPage
}) => {
const { defaultBranchId, featureId } = await seedSidebarVisibilityScenario(orcaPage)
const defaultBranchRow = worktreeRow(orcaPage, defaultBranchId)
const featureRow = worktreeRow(orcaPage, featureId)
// Poll rather than set once: hydration can land after the seed and reset the filters.
await expect
.poll(() =>
orcaPage.evaluate(
({ defaultBranchId, featureId }) => {
const state = window.__store?.getState()
state?.setShowSleepingWorkspaces(false)
state?.setHideDefaultBranchWorkspace(false)
state?.setAlwaysShowDefaultBranchWorkspace(true)
const featureTabs = state?.tabsByWorktree[featureId] ?? []
return {
alwaysShowDefaultBranchWorkspace: state?.alwaysShowDefaultBranchWorkspace ?? null,
defaultBranchTabs: state?.tabsByWorktree[defaultBranchId]?.length ?? 0,
featureBrowserTabs: state?.browserTabsByWorktree[featureId]?.length ?? 0,
featureHasLivePty: featureTabs.some(
(tab) => (state?.ptyIdsByTabId[tab.id] ?? []).length > 0
),
featureTabs: featureTabs.length,
hideDefaultBranchWorkspace: state?.hideDefaultBranchWorkspace ?? null,
showSleepingWorkspaces: state?.showSleepingWorkspaces ?? null
}
},
{ defaultBranchId, featureId }
)
)
.toEqual({
alwaysShowDefaultBranchWorkspace: true,
defaultBranchTabs: 0,
featureBrowserTabs: 0,
featureHasLivePty: false,
featureTabs: 0,
hideDefaultBranchWorkspace: false,
showSleepingWorkspaces: false
})
await expect(defaultBranchRow).toBeVisible()
await expect(defaultBranchRow).toContainText('Default branch workspace')
await expect(featureRow).toHaveCount(0)
// Opting out of the exemption is the only way back to the pre-#8873 sweep.
await orcaPage.evaluate(() => {
window.__store?.getState().setAlwaysShowDefaultBranchWorkspace(false)
})
await expect(defaultBranchRow).toHaveCount(0)
await orcaPage.evaluate(() => {
window.__store?.getState().setAlwaysShowDefaultBranchWorkspace(true)
})
await expect(defaultBranchRow).toBeVisible()
// The explicit hide filter still outranks the exemption.
await orcaPage.evaluate(() => {
window.__store?.getState().setHideDefaultBranchWorkspace(true)
})
await expect(defaultBranchRow).toHaveCount(0)
})
})