diff --git a/src/renderer/src/components/Terminal.tsx b/src/renderer/src/components/Terminal.tsx index 82116fb6392..49bc620914a 100644 --- a/src/renderer/src/components/Terminal.tsx +++ b/src/renderer/src/components/Terminal.tsx @@ -119,6 +119,7 @@ import { TERMINAL_HIDDEN_WORKTREE_RETENTION_TTL_MS, countEvictionExemptTabRoutes, formatEvictionExemptRouteCounts, + getTerminalWorktreeParkingInputsKey, hasPendingRetentionSpawnWork, selectForceParkEvictableTabIds, selectRetentionForceParkedTerminalWorktrees, @@ -325,6 +326,10 @@ function Terminal(): React.JSX.Element | null { ) const activeView = useAppStore((s) => s.activeView) const tabsByWorktree = useAppStore((s) => s.tabsByWorktree) + const terminalWorktreeParkingInputsKey = useMemo( + () => getTerminalWorktreeParkingInputsKey(tabsByWorktree), + [tabsByWorktree] + ) const pendingStartupByTabId = useAppStore((s) => s.pendingStartupByTabId) const terminalParkingEnabled = useAppStore((s) => s.settings?.terminalHiddenViewParking !== false) const terminalSshParkingEnabled = useAppStore((s) => s.settings?.terminalSshViewParking !== false) @@ -913,6 +918,7 @@ function Terminal(): React.JSX.Element | null { // Worktree cold-park policy: hiddenSince bookkeeping, parked-set selection, and one recheck timer per deadline so React re-renders when hysteresis elapses instead of polling. useEffect(() => { const parkingTimers = terminalWorktreeParkingTimersRef.current + const parkingTabsByWorktree = useAppStore.getState().tabsByWorktree for (const timer of parkingTimers.values()) { window.clearTimeout(timer) } @@ -975,7 +981,7 @@ function Terminal(): React.JSX.Element | null { retentionCandidates.push({ worktreeId, - terminalTabs: tabsByWorktree[worktreeId] ?? [], + terminalTabs: parkingTabsByWorktree[worktreeId] ?? [], isVisible, shouldMeasureHiddenWorktree, hasActivityTerminalPortal, @@ -1012,7 +1018,7 @@ function Terminal(): React.JSX.Element | null { }) // Why: a worktree with any watcher-uncoverable tab must never park, or it goes silent for bells/titles/completions (sank the first parking attempt). for (const worktreeId of Array.from(nextParkedTerminalWorktreeIds)) { - if (!worktreeTabsAreWatcherCovered(worktreeId, tabsByWorktree[worktreeId] ?? [])) { + if (!worktreeTabsAreWatcherCovered(worktreeId, parkingTabsByWorktree[worktreeId] ?? [])) { nextParkedTerminalWorktreeIds.delete(worktreeId) } } @@ -1022,7 +1028,7 @@ function Terminal(): React.JSX.Element | null { // is the accepted cost of bounding retention. const retentionBudgetCandidates: TerminalWorktreeRetentionCandidate[] = retentionCandidates.map( (candidate) => { - const tabs = tabsByWorktree[candidate.worktreeId] ?? [] + const tabs = parkingTabsByWorktree[candidate.worktreeId] ?? [] const parkEligible = canParkTerminalWorktreeRenderers({ ...candidate, // Why nulled: the post-measure cool-down is a timing gate, not a @@ -1079,7 +1085,7 @@ function Terminal(): React.JSX.Element | null { const repos = useAppStore.getState().repos const nextEvictionExemptTabIds = new Set() for (const worktreeId of forceParkedWorktreeIds) { - const forceParkedTabs = tabsByWorktree[worktreeId] ?? [] + const forceParkedTabs = parkingTabsByWorktree[worktreeId] ?? [] const exemptTabIds = selectEvictionExemptTerminalTabIds(worktreeId, forceParkedTabs) for (const tabId of exemptTabIds) { nextEvictionExemptTabIds.add(tabId) @@ -1178,7 +1184,7 @@ function Terminal(): React.JSX.Element | null { pendingStartupByTabId, pairedRuntimeParkingEnvironmentIds, renderedActiveWorktreeId, - tabsByWorktree, + terminalWorktreeParkingInputsKey, terminalParkingEnabled, terminalParkingRevision, terminalProviderSnapshotCapabilityRevision, diff --git a/src/renderer/src/components/settings/Settings.tsx b/src/renderer/src/components/settings/Settings.tsx index 9a0d4d11b1d..e116dedc2b0 100644 --- a/src/renderer/src/components/settings/Settings.tsx +++ b/src/renderer/src/components/settings/Settings.tsx @@ -310,7 +310,6 @@ function Settings(): React.JSX.Element { const settingsProjectHostSelection = useAppStore((s) => s.settingsProjectHostSelection) const settingsProjectSetupSelection = useAppStore((s) => s.settingsProjectSetupSelection) const setSettingsProjectHostSelection = useAppStore((s) => s.setSettingsProjectHostSelection) - const settingsSearchInputQuery = useAppStore((s) => s.settingsSearchInputQuery) const settingsSearchQuery = useAppStore((s) => s.settingsSearchQuery) const setSettingsSearchQuery = useAppStore((s) => s.setSettingsSearchQuery) const modelStates = useAppStore((s) => s.modelStates) @@ -1206,12 +1205,10 @@ function Settings(): React.JSX.Element { generalGroups={generalNavGroups} repoSections={repoNavSections} hasRepos={repos.length > 0} - searchQuery={settingsSearchInputQuery} searchInputRef={searchInputRef} // Why: deep-links open panes/modals that own focus; plain entry lands in search. searchAutoFocus={settingsNavigationTarget == null} onBack={closeSettingsPageWithPromptGuard} - onSearchChange={setSettingsSearchQuery} onSelectSection={scrollToSection} /> diff --git a/src/renderer/src/components/settings/SettingsSidebar.test.tsx b/src/renderer/src/components/settings/SettingsSidebar.test.tsx index c7125b1109e..b852fe6cfe3 100644 --- a/src/renderer/src/components/settings/SettingsSidebar.test.tsx +++ b/src/renderer/src/components/settings/SettingsSidebar.test.tsx @@ -111,9 +111,7 @@ function renderSidebar( ]} repoSections={[]} hasRepos={false} - searchQuery="" onBack={vi.fn()} - onSearchChange={vi.fn()} onSelectSection={vi.fn()} /> diff --git a/src/renderer/src/components/settings/SettingsSidebar.tsx b/src/renderer/src/components/settings/SettingsSidebar.tsx index 11f73b3016d..680b2f1ec61 100644 --- a/src/renderer/src/components/settings/SettingsSidebar.tsx +++ b/src/renderer/src/components/settings/SettingsSidebar.tsx @@ -18,6 +18,7 @@ import type { SettingsSetupGuideProgress } from './settings-setup-guide-progress import { translate } from '@/i18n/i18n' import { resolveLeftSidebarStyleVariables } from '@/lib/left-sidebar-appearance' import { useSystemPrefersDark } from '../terminal-pane/use-system-prefers-dark' +import { useAppStore } from '@/store' type NavSection = { id: string @@ -46,11 +47,9 @@ type SettingsSidebarProps = { generalGroups: NavGroup[] repoSections: RepoNavSection[] hasRepos: boolean - searchQuery: string searchInputRef?: RefObject searchAutoFocus?: boolean onBack: () => void - onSearchChange: (query: string) => void onSelectSection: ( sectionId: string, modifiers: { @@ -62,6 +61,47 @@ type SettingsSidebarProps = { ) => void } +function SettingsSearchField({ + searchInputRef, + searchAutoFocus = false +}: Pick): React.JSX.Element { + const searchQuery = useAppStore((state) => state.settingsSearchInputQuery) + const onSearchChange = useAppStore((state) => state.setSettingsSearchQuery) + const searchShortcutCombos = useShortcutKeyComboDetails('settings.search') + + return ( +
+
+ + onSearchChange(event.target.value)} + placeholder={translate( + 'auto.components.settings.SettingsSidebar.dbceaa8840', + 'Search settings' + )} + className="bg-background/60 pl-9 pr-14 text-[13px]" + /> + {searchQuery === '' ? ( + + {searchShortcutCombos.map((combo) => ( + + ))} + + ) : null} +
+
+ ) +} + type VisibleInstallStatus = Extract< SettingsNavInstallStatus, 'update-available' | 'needs-attention' @@ -134,11 +174,9 @@ export function SettingsSidebar({ generalGroups, repoSections, hasRepos, - searchQuery, searchInputRef, searchAutoFocus = false, onBack, - onSearchChange, onSelectSection }: SettingsSidebarProps): React.JSX.Element { const setupGuideProgress = useSettingsSetupGuideProgress(true) @@ -152,7 +190,6 @@ export function SettingsSidebar({ // Settings should remain a stable place to reopen the checklist. const showSetupGuideTopRow = setupGuideProgress.ready && setupGuideProgress.doneCount < setupGuideProgress.total - const searchShortcutCombos = useShortcutKeyComboDetails('settings.search') const navItemClassName = (isActive: boolean): string => cn( 'flex w-full items-center gap-2 rounded-lg px-3 py-1.5 text-left text-[13px] outline-none transition-colors duration-150 focus-visible:ring-[3px] focus-visible:ring-worktree-sidebar-ring/50', @@ -194,35 +231,7 @@ export function SettingsSidebar({ -
-
- - onSearchChange(event.target.value)} - placeholder={translate( - 'auto.components.settings.SettingsSidebar.dbceaa8840', - 'Search settings' - )} - className="bg-background/60 pl-9 pr-14 text-[13px]" - /> - {searchQuery === '' ? ( - - {searchShortcutCombos.map((combo) => ( - - ))} - - ) : null} -
-
+ {showSetupGuideTopRow ? (
diff --git a/src/renderer/src/components/sidebar/sleep-worktree-activation-race.test.ts b/src/renderer/src/components/sidebar/sleep-worktree-activation-race.test.ts index fbed90765b0..bf7eb3c758e 100644 --- a/src/renderer/src/components/sidebar/sleep-worktree-activation-race.test.ts +++ b/src/renderer/src/components/sidebar/sleep-worktree-activation-race.test.ts @@ -96,12 +96,14 @@ describe('sleep flow vs slept-workspace activation', () => { expect(mocks.activateAndRevealWorktree).toHaveBeenCalledTimes(1) }) - it('does not activate a slept worktree when VM resume fails', async () => { + it('keeps the slept worktree selected when VM resume fails', async () => { mocks.resumeWorkspace.mockRejectedValueOnce(new Error('provider unavailable')) await activateWorktreeFromSidebar('wt-parent') - expect(mocks.activateAndRevealWorktree).not.toHaveBeenCalled() + expect(mocks.activateAndRevealWorktree).toHaveBeenCalledWith('wt-parent', { + revealInSidebar: false + }) expect(mocks.toastError).toHaveBeenCalledWith( 'Failed to wake ephemeral VM workspace', expect.objectContaining({ description: 'provider unavailable' }) diff --git a/src/renderer/src/components/terminal-pane/terminal-hidden-worktree-retention.test.ts b/src/renderer/src/components/terminal-pane/terminal-hidden-worktree-retention.test.ts index f288521ae8a..d45be73c63e 100644 --- a/src/renderer/src/components/terminal-pane/terminal-hidden-worktree-retention.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-hidden-worktree-retention.test.ts @@ -6,6 +6,7 @@ import { } from '../terminal/terminal-provider-snapshot-capability' import { TERMINAL_HIDDEN_WORKTREE_RETENTION_TTL_MS, + getTerminalWorktreeParkingInputsKey, hasPendingRetentionSpawnWork, isEvictionExemptTerminalPty, selectForceParkEvictableTabIds, @@ -13,6 +14,47 @@ import { type TerminalWorktreeRetentionCandidate } from './terminal-hidden-worktree-retention' +describe('getTerminalWorktreeParkingInputsKey', () => { + const tabs = { + 'repo::/worktree': [ + { + id: 'tab-1', + ptyId: 'repo::/worktree@@pty-1', + pendingActivationSpawn: undefined, + title: 'Original title' + } + ] + } + + it('ignores display-only tab changes', () => { + const retitledTabs = { + 'repo::/worktree': [{ ...tabs['repo::/worktree'][0], title: 'Updated title' }] + } + expect(getTerminalWorktreeParkingInputsKey(retitledTabs)).toBe( + getTerminalWorktreeParkingInputsKey(tabs) + ) + }) + + it('changes for inputs consumed by the parking policy', () => { + const original = getTerminalWorktreeParkingInputsKey(tabs) + expect( + getTerminalWorktreeParkingInputsKey({ + 'repo::/worktree': [{ ...tabs['repo::/worktree'][0], ptyId: null }] + }) + ).not.toBe(original) + expect( + getTerminalWorktreeParkingInputsKey({ + 'repo::/worktree': [{ ...tabs['repo::/worktree'][0], pendingActivationSpawn: true }] + }) + ).not.toBe(original) + expect( + getTerminalWorktreeParkingInputsKey({ + 'repo::/worktree': [{ ...tabs['repo::/worktree'][0], id: 'tab-2' }] + }) + ).not.toBe(original) + }) +}) + describe('hasPendingRetentionSpawnWork', () => { const remoteTab = { id: 'tab-remote', diff --git a/src/renderer/src/components/terminal-pane/terminal-hidden-worktree-retention.ts b/src/renderer/src/components/terminal-pane/terminal-hidden-worktree-retention.ts index 8473d07ffee..2024002bfa4 100644 --- a/src/renderer/src/components/terminal-pane/terminal-hidden-worktree-retention.ts +++ b/src/renderer/src/components/terminal-pane/terminal-hidden-worktree-retention.ts @@ -30,6 +30,19 @@ import type { TerminalTab } from '../../../../shared/terminal-tab-types' export const TERMINAL_HIDDEN_WORKTREE_RETENTION_LIMIT = 4 export const TERMINAL_HIDDEN_WORKTREE_RETENTION_TTL_MS = 15 * 60_000 +type TerminalWorktreeParkingTab = Pick + +export function getTerminalWorktreeParkingInputsKey( + tabsByWorktree: Readonly> +): string { + return JSON.stringify( + Object.entries(tabsByWorktree).map(([worktreeId, tabs]) => [ + worktreeId, + tabs.map((tab) => [tab.id, tab.ptyId, tab.pendingActivationSpawn]) + ]) + ) +} + export function hasPendingRetentionSpawnWork( tab: Pick, pendingStartupByTabId: Readonly> diff --git a/src/renderer/src/lib/sidebar-worktree-activation.test.ts b/src/renderer/src/lib/sidebar-worktree-activation.test.ts index 4d0b34da2e0..a33f91565a2 100644 --- a/src/renderer/src/lib/sidebar-worktree-activation.test.ts +++ b/src/renderer/src/lib/sidebar-worktree-activation.test.ts @@ -1,4 +1,4 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' const mocks = vi.hoisted(() => ({ activateAndRevealFolderWorkspace: vi.fn(), @@ -18,6 +18,10 @@ describe('sidebar worktree activation', () => { mocks.activateAndRevealFolderWorkspace.mockClear() }) + afterEach(() => { + vi.unstubAllGlobals() + }) + it('activates a clicked worktree without sidebar reveal', async () => { await activateWorktreeFromSidebar('wt-live') @@ -38,6 +42,29 @@ describe('sidebar worktree activation', () => { }) }) + it('switches immediately while an ephemeral runtime wake is pending', async () => { + let resolveResume: ((value: null) => void) | undefined + const resumeWorkspace = vi.fn( + () => + new Promise((resolve) => { + resolveResume = resolve + }) + ) + vi.stubGlobal('window', { + api: { ephemeralVm: { resumeWorkspace } } + }) + + const activation = activateWorktreeFromSidebar('wt-vm') + + expect(mocks.activateAndRevealWorktree).toHaveBeenCalledWith('wt-vm', { + revealInSidebar: false + }) + expect(resumeWorkspace).toHaveBeenCalledWith({ workspaceId: 'wt-vm' }) + + resolveResume?.(null) + await activation + }) + it('routes folder workspace activation through the guarded folder path', async () => { await activateWorktreeFromSidebar('folder:folder-workspace-1') diff --git a/src/renderer/src/lib/sidebar-worktree-activation.ts b/src/renderer/src/lib/sidebar-worktree-activation.ts index 664895013b3..dd4eec3f0df 100644 --- a/src/renderer/src/lib/sidebar-worktree-activation.ts +++ b/src/renderer/src/lib/sidebar-worktree-activation.ts @@ -22,6 +22,11 @@ export async function activateWorktreeFromSidebar( } return } + // Keep navigation independent from an optional runtime wake IPC. + activateAndRevealWorktree(worktreeId, { + revealInSidebar: false, + ...(executionHostId ? { executionHostId } : {}) + }) if (typeof window !== 'undefined' && window.api?.ephemeralVm?.resumeWorkspace) { try { @@ -41,14 +46,6 @@ export async function activateWorktreeFromSidebar( description: error instanceof Error ? error.message : String(error) } ) - return } } - - // Why: sidebar clicks already happen on a visible row; revealing again can - // jump duplicate pinned/canonical entries back to the first mounted copy. - activateAndRevealWorktree(worktreeId, { - revealInSidebar: false, - ...(executionHostId ? { executionHostId } : {}) - }) }