diff --git a/mobile/app/h/[hostId]/index.tsx b/mobile/app/h/[hostId]/index.tsx index 24da552ea..b71f171ce 100644 --- a/mobile/app/h/[hostId]/index.tsx +++ b/mobile/app/h/[hostId]/index.tsx @@ -164,7 +164,8 @@ export function HostScreen({ const [filters, setFilters] = useState({ filterRepoIds: new Set(), hideSleeping: false, - hideDefaultBranch: false + hideDefaultBranch: false, + alwaysShowDefaultBranch: true }) const [groupMode, setGroupMode] = useState('repo') const [workspaceStatuses, setWorkspaceStatuses] = useState( @@ -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, diff --git a/mobile/src/worktree/workspace-list-sections.test.ts b/mobile/src/worktree/workspace-list-sections.test.ts index d6d04d43d..d1fc267c0 100644 --- a/mobile/src/worktree/workspace-list-sections.test.ts +++ b/mobile/src/worktree/workspace-list-sections.test.ts @@ -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', diff --git a/mobile/src/worktree/workspace-list-sections.ts b/mobile/src/worktree/workspace-list-sections.ts index bdb9e4783..d798a7fe5 100644 --- a/mobile/src/worktree/workspace-list-sections.ts +++ b/mobile/src/worktree/workspace-list-sections.ts @@ -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)) diff --git a/mobile/src/worktree/workspace-list-types.ts b/mobile/src/worktree/workspace-list-types.ts index 4dfbc74fa..4ddb135af 100644 --- a/mobile/src/worktree/workspace-list-types.ts +++ b/mobile/src/worktree/workspace-list-types.ts @@ -52,6 +52,8 @@ export type FilterState = { filterRepoIds: Set 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[] } diff --git a/mobile/src/worktree/workspace-view-settings.ts b/mobile/src/worktree/workspace-view-settings.ts index 098791564..32a4ac73a 100644 --- a/mobile/src/worktree/workspace-view-settings.ts +++ b/mobile/src/worktree/workspace-view-settings.ts @@ -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 diff --git a/src/main/runtime/rpc/methods/client-ui-schemas.ts b/src/main/runtime/rpc/methods/client-ui-schemas.ts index af254ca02..0e07476b2 100644 --- a/src/main/runtime/rpc/methods/client-ui-schemas.ts +++ b/src/main/runtime/rpc/methods/client-ui-schemas.ts @@ -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(), diff --git a/src/main/runtime/rpc/methods/client-ui.test.ts b/src/main/runtime/rpc/methods/client-ui.test.ts index 3e3bbd9ba..2a4b17b45 100644 --- a/src/main/runtime/rpc/methods/client-ui.test.ts +++ b/src/main/runtime/rpc/methods/client-ui.test.ts @@ -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 } diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 81f72be49..9874acecd 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -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 diff --git a/src/renderer/src/components/WorktreeJumpPalette.test.tsx b/src/renderer/src/components/WorktreeJumpPalette.test.tsx new file mode 100644 index 000000000..7d84be81b --- /dev/null +++ b/src/renderer/src/components/WorktreeJumpPalette.test.tsx @@ -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() + 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: () => +})) + +vi.mock('@/components/repo/RepoBadgeLabel', () => ({ + RepoBadgeMark: () => +})) + +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 ?
{children}
: null, + CommandInput: ({ + value, + onValueChange, + placeholder + }: { + value?: string + onValueChange?: (next: string) => void + placeholder?: string + }) => { + setCommandQuery = onValueChange ?? null + return ( + onValueChange?.(event.currentTarget.value)} + /> + ) + }, + CommandList: React.forwardRef(function CommandList( + { children }: { children: React.ReactNode }, + ref: React.ForwardedRef + ) { + return ( +
+ {children} +
+ ) + }), + CommandEmpty: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), + CommandItem: ({ + children, + onSelect, + value + }: { + children: React.ReactNode + onSelect?: (value: string) => void + value?: string + }) => ( + + ) + } +}) + +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 { + 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 { + await act(async () => { + await Promise.resolve() + await Promise.resolve() + }) +} + +async function renderPalette(overrides: Partial): Promise { + 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) + + await act(async () => { + testRoot.render() + }) + await flushEffects() +} + +function getWorktreeRows(): string[] { + return [...testContainer.querySelectorAll('[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') + }) +}) diff --git a/src/renderer/src/components/WorktreeJumpPalette.tsx b/src/renderer/src/components/WorktreeJumpPalette.tsx index c85a2d700..19e352f76 100644 --- a/src/renderer/src/components/WorktreeJumpPalette.tsx +++ b/src/renderer/src/components/WorktreeJumpPalette.tsx @@ -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, diff --git a/src/renderer/src/components/sidebar/FilterToggleRow.tsx b/src/renderer/src/components/sidebar/FilterToggleRow.tsx new file mode 100644 index 000000000..60930a19c --- /dev/null +++ b/src/renderer/src/components/sidebar/FilterToggleRow.tsx @@ -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 ( + + ) +} + +export default FilterToggleRow diff --git a/src/renderer/src/components/sidebar/SidebarFilter.tsx b/src/renderer/src/components/sidebar/SidebarFilter.tsx index 06c445564..1efbf6d00 100644 --- a/src/renderer/src/components/sidebar/SidebarFilter.tsx +++ b/src/renderer/src/components/sidebar/SidebarFilter.tsx @@ -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} /> + } + 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} + /> } 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 ( - - ) -} - export default SidebarFilter diff --git a/src/renderer/src/components/sidebar/SidebarWorkspaceFilterSection.tsx b/src/renderer/src/components/sidebar/SidebarWorkspaceFilterSection.tsx index 20cddcfae..e06f81420 100644 --- a/src/renderer/src/components/sidebar/SidebarWorkspaceFilterSection.tsx +++ b/src/renderer/src/components/sidebar/SidebarWorkspaceFilterSection.tsx @@ -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)} /> + } + 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} + /> } 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 ( - - ) -} - export default SidebarWorkspaceFilterSection diff --git a/src/renderer/src/components/sidebar/SidebarWorkspaceOptionsMenu.tsx b/src/renderer/src/components/sidebar/SidebarWorkspaceOptionsMenu.tsx index b087a3fb8..5f3f65dce 100644 --- a/src/renderer/src/components/sidebar/SidebarWorkspaceOptionsMenu.tsx +++ b/src/renderer/src/components/sidebar/SidebarWorkspaceOptionsMenu.tsx @@ -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'}` diff --git a/src/renderer/src/components/sidebar/WorktreeList.tsx b/src/renderer/src/components/sidebar/WorktreeList.tsx index e21ccdd01..422b765f0 100644 --- a/src/renderer/src/components/sidebar/WorktreeList.tsx +++ b/src/renderer/src/components/sidebar/WorktreeList.tsx @@ -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 ]) diff --git a/src/renderer/src/components/sidebar/add-repo-skip-finalization.test.ts b/src/renderer/src/components/sidebar/add-repo-skip-finalization.test.ts index 6a42c4619..de9c6f430 100644 --- a/src/renderer/src/components/sidebar/add-repo-skip-finalization.test.ts +++ b/src/renderer/src/components/sidebar/add-repo-skip-finalization.test.ts @@ -34,11 +34,14 @@ function makeState(overrides: Partial): 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', diff --git a/src/renderer/src/components/sidebar/add-repo-skip-finalization.ts b/src/renderer/src/components/sidebar/add-repo-skip-finalization.ts index 4c5e28d54..4161f3bb6 100644 --- a/src/renderer/src/components/sidebar/add-repo-skip-finalization.ts +++ b/src/renderer/src/components/sidebar/add-repo-skip-finalization.ts @@ -6,11 +6,14 @@ export type AddRepoSkipFinalizationState = { filterRepoIds: string[] showActiveOnly: boolean hideDefaultBranchWorkspace: boolean + showSleepingWorkspaces: boolean + alwaysShowDefaultBranchWorkspace: boolean worktreesByRepo: Record 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) + } } diff --git a/src/renderer/src/components/sidebar/default-branch-visible-under-hide-sleeping.test.ts b/src/renderer/src/components/sidebar/default-branch-visible-under-hide-sleeping.test.ts new file mode 100644 index 000000000..9187dd02e --- /dev/null +++ b/src/renderer/src/components/sidebar/default-branch-visible-under-hide-sleeping.test.ts @@ -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([['repo1', makeRepo('repo1')]]) + +type VisibleOptions = Parameters[2] + +function visibleOptions(overrides: Partial = {}): 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): 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 project’s 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]) + }) +}) diff --git a/src/renderer/src/components/sidebar/sidebar-filter-state.test.ts b/src/renderer/src/components/sidebar/sidebar-filter-state.test.ts index 0d27355c4..317f0e620 100644 --- a/src/renderer/src/components/sidebar/sidebar-filter-state.test.ts +++ b/src/renderer/src/components/sidebar/sidebar-filter-state.test.ts @@ -38,6 +38,7 @@ function filterState(overrides: Partial = {}): 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 }) }) 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 15e3ec6de..0e5650186 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 @@ -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, diff --git a/src/renderer/src/components/sidebar/visible-worktrees.test.ts b/src/renderer/src/components/sidebar/visible-worktrees.test.ts index f54dcef60..ee212e44e 100644 --- a/src/renderer/src/components/sidebar/visible-worktrees.test.ts +++ b/src/renderer/src/components/sidebar/visible-worktrees.test.ts @@ -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') diff --git a/src/renderer/src/components/sidebar/visible-worktrees.ts b/src/renderer/src/components/sidebar/visible-worktrees.ts index 9c44d8587..10aa72bcf 100644 --- a/src/renderer/src/components/sidebar/visible-worktrees.ts +++ b/src/renderer/src/components/sidebar/visible-worktrees.ts @@ -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 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, diff --git a/src/renderer/src/components/worktree-jump-palette-sleeping-filter.test.ts b/src/renderer/src/components/worktree-jump-palette-sleeping-filter.test.ts new file mode 100644 index 000000000..05ac5130e --- /dev/null +++ b/src/renderer/src/components/worktree-jump-palette-sleeping-filter.test.ts @@ -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 { + 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) + }) +}) diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index d0511b2ca..7b55bf133 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -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", diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 9797580ad..904beaa9d 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -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", diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index eab737f25..3d5f8588d 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -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 アクティビティのレイアウト", diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 6e49c5376..aa4bc49ce 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -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 활동 레이아웃", diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 10f601cfe..05a13abd2 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -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": "智能体活动布局", diff --git a/src/renderer/src/lib/startup-ui-hydration.ts b/src/renderer/src/lib/startup-ui-hydration.ts index 6daa375c8..aeb14f9c7 100644 --- a/src/renderer/src/lib/startup-ui-hydration.ts +++ b/src/renderer/src/lib/startup-ui-hydration.ts @@ -51,6 +51,7 @@ export function getStartupErrorFallbackUI(uiHydrated: boolean): PersistedUIState hideDefaultBranchWorkspace: false, hideCliCreatedWorkspaces: false, hideDetachedHeadWorkspaces: false, + alwaysShowDefaultBranchWorkspace: true, hideAutomationGeneratedWorkspaces: false, filterRepoIds: [], collapsedGroups: [], diff --git a/src/renderer/src/store/slices/ui.test.ts b/src/renderer/src/store/slices/ui.test.ts index 2e0e48546..dbd81acee 100644 --- a/src/renderer/src/store/slices/ui.test.ts +++ b/src/renderer/src/store/slices/ui.test.ts @@ -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).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') diff --git a/src/renderer/src/store/slices/ui.ts b/src/renderer/src/store/slices/ui.ts index 625a8e9ab..d926a100f 100644 --- a/src/renderer/src/store/slices/ui.ts +++ b/src/renderer/src/store/slices/ui.ts @@ -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 setShowDotfilesForWorktree: (worktreeId: string, showDotfiles: boolean) => void toggleShowDotfilesForWorktree: (worktreeId: string) => void @@ -2053,6 +2055,8 @@ export const createUISlice: StateCreator = (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 = (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: diff --git a/src/shared/constants.ts b/src/shared/constants.ts index 9645d3ba3..80fca73c8 100644 --- a/src/shared/constants.ts +++ b/src/shared/constants.ts @@ -479,6 +479,7 @@ export function getDefaultUIState(): PersistedUIState { hideAutomationGeneratedWorkspaces: false, hideCliCreatedWorkspaces: false, hideDetachedHeadWorkspaces: false, + alwaysShowDefaultBranchWorkspace: true, showDotfilesByWorktree: {}, filterRepoIds: [], collapsedGroups: [], diff --git a/src/shared/types.ts b/src/shared/types.ts index b4e1a2432..1ba5aeb79 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -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 filterRepoIds: string[] diff --git a/tests/e2e/default-branch-visibility.spec.ts b/tests/e2e/default-branch-visibility.spec.ts new file mode 100644 index 000000000..166dc4194 --- /dev/null +++ b/tests/e2e/default-branch-visibility.spec.ts @@ -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 { + 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) + }) +})