fix(renderer): keep navigation responsive under terminal load (#16138)

This commit is contained in:
Neil
2026-08-23 15:07:36 -07:00
committed by GitHub
parent f0c718ecd5
commit b89b58bf77
9 changed files with 146 additions and 55 deletions
+11 -5
View File
@@ -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<string>()
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,
@@ -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}
/>
@@ -111,9 +111,7 @@ function renderSidebar(
]}
repoSections={[]}
hasRepos={false}
searchQuery=""
onBack={vi.fn()}
onSearchChange={vi.fn()}
onSelectSection={vi.fn()}
/>
</TooltipProvider>
@@ -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<HTMLInputElement | null>
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<SettingsSidebarProps, 'searchInputRef' | 'searchAutoFocus'>): React.JSX.Element {
const searchQuery = useAppStore((state) => state.settingsSearchInputQuery)
const onSearchChange = useAppStore((state) => state.setSettingsSearchQuery)
const searchShortcutCombos = useShortcutKeyComboDetails('settings.search')
return (
<div className="border-b border-worktree-sidebar-border px-3 py-3">
<div className="relative">
<Search className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
<Input
ref={searchInputRef}
autoFocus={searchAutoFocus}
value={searchQuery}
onChange={(event) => onSearchChange(event.target.value)}
placeholder={translate(
'auto.components.settings.SettingsSidebar.dbceaa8840',
'Search settings'
)}
className="bg-background/60 pl-9 pr-14 text-[13px]"
/>
{searchQuery === '' ? (
<span className="pointer-events-none absolute right-2 top-1/2 flex -translate-y-1/2 items-center">
{searchShortcutCombos.map((combo) => (
<ShortcutKeyCombo
key={combo.keys.join('-')}
keys={combo.keys}
doubleTap={combo.doubleTap}
className="inline-flex gap-0.5"
separatorClassName="text-[10px] text-muted-foreground"
/>
))}
</span>
) : null}
</div>
</div>
)
}
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({
</Button>
</div>
<div className="border-b border-worktree-sidebar-border px-3 py-3">
<div className="relative">
<Search className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
<Input
ref={searchInputRef}
autoFocus={searchAutoFocus}
value={searchQuery}
onChange={(event) => onSearchChange(event.target.value)}
placeholder={translate(
'auto.components.settings.SettingsSidebar.dbceaa8840',
'Search settings'
)}
className="bg-background/60 pl-9 pr-14 text-[13px]"
/>
{searchQuery === '' ? (
<span className="pointer-events-none absolute right-2 top-1/2 flex -translate-y-1/2 items-center">
{searchShortcutCombos.map((combo) => (
<ShortcutKeyCombo
key={combo.keys.join('-')}
keys={combo.keys}
doubleTap={combo.doubleTap}
className="inline-flex gap-0.5"
separatorClassName="text-[10px] text-muted-foreground"
/>
))}
</span>
) : null}
</div>
</div>
<SettingsSearchField searchInputRef={searchInputRef} searchAutoFocus={searchAutoFocus} />
{showSetupGuideTopRow ? (
<div className="border-b border-worktree-sidebar-border px-3 py-3">
@@ -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' })
@@ -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',
@@ -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<TerminalTab, 'id' | 'ptyId' | 'pendingActivationSpawn'>
export function getTerminalWorktreeParkingInputsKey(
tabsByWorktree: Readonly<Record<string, readonly TerminalWorktreeParkingTab[]>>
): 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<TerminalTab, 'id' | 'ptyId' | 'pendingActivationSpawn'>,
pendingStartupByTabId: Readonly<Record<string, unknown>>
@@ -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<null>((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')
@@ -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 } : {})
})
}