mirror of
https://github.com/stablyai/orca.git
synced 2026-09-30 08:03:12 +00:00
fix(renderer): keep navigation responsive under terminal load (#16138)
This commit is contained in:
@@ -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 } : {})
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user