mirror of
https://github.com/stablyai/orca.git
synced 2026-10-21 08:03:33 +00:00
perf: bound repeated watcher, vault, and terminal work
This commit is contained in:
@@ -14,7 +14,7 @@ import {
|
||||
// Why: ONE module owns the scan cache so the desktop IPC handler AND the runtime
|
||||
// RPC method share a single cache instance — opening the desktop panel and the
|
||||
// mobile screen for the same scope must not double-scan hundreds of transcripts.
|
||||
const AI_VAULT_CACHE_TTL_MS = 15_000
|
||||
const AI_VAULT_CACHE_TTL_MS = 60_000
|
||||
|
||||
// Why: codex-home + WSL home dirs must be sourced from a serve-mode-reachable
|
||||
// seam (the OrcaRuntimeService deps), NOT the window-only registerCoreHandlers
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { WatcherProcessCrashFuse } from './parcel-watcher-crash-fuse'
|
||||
|
||||
describe('WatcherProcessCrashFuse', () => {
|
||||
it('opens after three crashes at the watcher restart cadence', () => {
|
||||
const fuse = new WatcherProcessCrashFuse()
|
||||
|
||||
fuse.recordCrash(0)
|
||||
fuse.recordCrash(40_000)
|
||||
fuse.recordCrash(80_000)
|
||||
|
||||
expect(fuse.isOpen(80_000)).toBe(true)
|
||||
})
|
||||
|
||||
it('stays open until explicitly reset', () => {
|
||||
const fuse = new WatcherProcessCrashFuse()
|
||||
|
||||
fuse.recordCrash(0)
|
||||
fuse.recordCrash(40_000)
|
||||
fuse.recordCrash(80_000)
|
||||
|
||||
expect(fuse.isOpen(10 * 60_000)).toBe(true)
|
||||
fuse.reset()
|
||||
expect(fuse.isOpen(10 * 60_000)).toBe(false)
|
||||
})
|
||||
|
||||
it('expires crashes outside the two-minute window', () => {
|
||||
const fuse = new WatcherProcessCrashFuse()
|
||||
|
||||
fuse.recordCrash(0)
|
||||
fuse.recordCrash(40_000)
|
||||
fuse.recordCrash(120_000)
|
||||
|
||||
expect(fuse.isOpen(120_000)).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -1,21 +1,30 @@
|
||||
const CRASH_WINDOW_MS = 30_000
|
||||
const CRASH_WINDOW_MS = 2 * 60_000
|
||||
const MAX_CRASHES_PER_WINDOW = 3
|
||||
|
||||
export class WatcherProcessCrashFuse {
|
||||
private crashTimes: number[] = []
|
||||
private open = false
|
||||
|
||||
recordCrash(now = Date.now()): void {
|
||||
if (this.open) {
|
||||
return
|
||||
}
|
||||
this.removeExpired(now)
|
||||
this.crashTimes.push(now)
|
||||
this.open = this.crashTimes.length >= MAX_CRASHES_PER_WINDOW
|
||||
}
|
||||
|
||||
isOpen(now = Date.now()): boolean {
|
||||
if (this.open) {
|
||||
return true
|
||||
}
|
||||
this.removeExpired(now)
|
||||
return this.crashTimes.length >= MAX_CRASHES_PER_WINDOW
|
||||
return false
|
||||
}
|
||||
|
||||
reset(): void {
|
||||
this.crashTimes = []
|
||||
this.open = false
|
||||
}
|
||||
|
||||
private removeExpired(now: number): void {
|
||||
|
||||
@@ -656,6 +656,37 @@ describe('subscribeViaWatcherProcess', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('does not respawn for later subscriptions after the crash fuse opens', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
vi.setSystemTime(0)
|
||||
const promise = subscribeViaWatcherProcess('/repo', vi.fn(), {})
|
||||
ackSubscribe(currentChild())
|
||||
await promise
|
||||
|
||||
for (const crashTime of [0, 40_000]) {
|
||||
vi.setSystemTime(crashTime)
|
||||
const child = currentChild()
|
||||
child.connected = false
|
||||
child.emit('exit', 3221226505, null)
|
||||
ackSubscribe(currentChild())
|
||||
}
|
||||
vi.setSystemTime(80_000)
|
||||
const last = currentChild()
|
||||
last.connected = false
|
||||
last.emit('exit', 3221226505, null)
|
||||
expect(forkMock).toHaveBeenCalledTimes(3)
|
||||
|
||||
vi.setSystemTime(10 * 60_000)
|
||||
await expect(subscribeViaWatcherProcess('/later', vi.fn(), {})).rejects.toMatchObject({
|
||||
code: 'process_unavailable'
|
||||
})
|
||||
expect(forkMock).toHaveBeenCalledTimes(3)
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('kills the idle child after the last unsubscribe and respawns on the next subscribe', async () => {
|
||||
const promise = subscribeViaWatcherProcess('/repo', vi.fn(), {})
|
||||
const first = currentChild()
|
||||
|
||||
@@ -16,6 +16,12 @@ export type WorktreeBaseCollectedChanges = {
|
||||
headIdentityRepoIds: string[]
|
||||
}
|
||||
|
||||
export function hasCollectedWorktreeBaseChanges(changes: WorktreeBaseCollectedChanges): boolean {
|
||||
return [changes.structureRepoIds, changes.gitStatusRepoIds, changes.headIdentityRepoIds].some(
|
||||
(ids) => ids.length > 0
|
||||
)
|
||||
}
|
||||
|
||||
type ChangeBuckets = {
|
||||
structureRepoIds: Set<string>
|
||||
gitStatusRepoIds: Set<string>
|
||||
|
||||
@@ -353,6 +353,44 @@ describe('worktree base directory watcher', () => {
|
||||
expect(notifyWorktreesChanged).toHaveBeenCalledWith(expect.anything(), 'repo-1')
|
||||
})
|
||||
|
||||
it('throttles repeated structural refreshes from watcher failures', async () => {
|
||||
await syncWorktreeBaseDirectoryWatchers(makeStore([makeRepo()]) as never, makeWindow() as never)
|
||||
const onWatchError = pollerOptions.get(PROJECT_GIT_COMMON_DIR)?.onWatchError
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
|
||||
onWatchError?.(new Error('watch interrupted'))
|
||||
await vi.advanceTimersByTimeAsync(300)
|
||||
onWatchError?.(new Error('watch interrupted'))
|
||||
await vi.advanceTimersByTimeAsync(300)
|
||||
|
||||
expect(notifyWorktreesChanged).toHaveBeenCalledTimes(1)
|
||||
|
||||
await vi.advanceTimersByTimeAsync(60_000)
|
||||
onWatchError?.(new Error('watch interrupted'))
|
||||
await vi.advanceTimersByTimeAsync(300)
|
||||
warn.mockRestore()
|
||||
|
||||
expect(notifyWorktreesChanged).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('lets a real event reset the watcher-failure refresh cooldown', async () => {
|
||||
await syncWorktreeBaseDirectoryWatchers(makeStore([makeRepo()]) as never, makeWindow() as never)
|
||||
const onWatchError = pollerOptions.get(PROJECT_GIT_COMMON_DIR)?.onWatchError
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
|
||||
onWatchError?.(new Error('watch interrupted'))
|
||||
await vi.advanceTimersByTimeAsync(300)
|
||||
emit(PROJECT_GIT_COMMON_DIR, [{ type: 'update', path: join(PROJECT_GIT_COMMON_DIR, 'config') }])
|
||||
await vi.advanceTimersByTimeAsync(300)
|
||||
vi.mocked(notifyWorktreesChanged).mockClear()
|
||||
|
||||
onWatchError?.(new Error('watch interrupted'))
|
||||
await vi.advanceTimersByTimeAsync(300)
|
||||
warn.mockRestore()
|
||||
|
||||
expect(notifyWorktreesChanged).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('keeps linked HEAD and lock metadata structural', async () => {
|
||||
await syncWorktreeBaseDirectoryWatchers(makeStore([makeRepo()]) as never, makeWindow() as never)
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
import {
|
||||
collectLocalWorktreeBaseChanges,
|
||||
collectRemoteWorktreeBaseChanges,
|
||||
type WorktreeBaseCollectedChanges
|
||||
hasCollectedWorktreeBaseChanges
|
||||
} from './worktree-base-directory-change-collector'
|
||||
import {
|
||||
clearPendingWorktreeBaseNotifications,
|
||||
@@ -33,6 +33,7 @@ import {
|
||||
updateActiveGitStatusRefBinding,
|
||||
type GitStatusRefBindingRequest
|
||||
} from './worktree-git-status-ref-watch'
|
||||
import { WorktreeWatcherFailureRefreshCooldown } from './worktree-watcher-failure-refresh-cooldown'
|
||||
|
||||
type ActiveWatch = WorktreeBaseWatchTarget & {
|
||||
mainWindow: BrowserWindow
|
||||
@@ -43,6 +44,7 @@ type ActiveWatch = WorktreeBaseWatchTarget & {
|
||||
pendingHeadIdentityRepoIds: Set<string>
|
||||
headIdentityRefresh: WorktreeHeadIdentityRefreshState
|
||||
gitStatusRefPaths: Set<string>
|
||||
watcherFailureRefresh: WorktreeWatcherFailureRefreshCooldown
|
||||
disposed: boolean
|
||||
}
|
||||
|
||||
@@ -57,12 +59,6 @@ export function setWorktreeGitStatusRefWatch(
|
||||
return updateActiveGitStatusRefBinding(args, () => activeWatches.values(), resolveUpstreamRef)
|
||||
}
|
||||
|
||||
function hasCollectedChanges(changes: WorktreeBaseCollectedChanges): boolean {
|
||||
return [changes.structureRepoIds, changes.gitStatusRepoIds, changes.headIdentityRepoIds].some(
|
||||
(ids) => ids.length > 0
|
||||
)
|
||||
}
|
||||
|
||||
function handleLocalWatchEvents(
|
||||
watch: ActiveWatch,
|
||||
error: Error | null,
|
||||
@@ -74,16 +70,19 @@ function handleLocalWatchEvents(
|
||||
if (error) {
|
||||
console.warn(`[worktree-base-watcher] watcher failed for ${watch.path}:`, error)
|
||||
invalidateActiveGitStatusRefResolution(watch, () => activeWatches.values())
|
||||
scheduleWorktreeBaseNotification(watch, { structureRepoIds: [...watch.repos.keys()] })
|
||||
if (watch.watcherFailureRefresh.consume()) {
|
||||
scheduleWorktreeBaseNotification(watch, { structureRepoIds: [...watch.repos.keys()] })
|
||||
}
|
||||
return
|
||||
}
|
||||
watch.watcherFailureRefresh.reset()
|
||||
invalidateGitStatusRefResolutionForPaths(
|
||||
watch,
|
||||
events.map((event) => event.path),
|
||||
() => activeWatches.values()
|
||||
)
|
||||
const changes = collectLocalWorktreeBaseChanges(watch, events)
|
||||
if (hasCollectedChanges(changes)) {
|
||||
if (hasCollectedWorktreeBaseChanges(changes)) {
|
||||
scheduleWorktreeBaseNotification(watch, changes)
|
||||
}
|
||||
}
|
||||
@@ -108,7 +107,7 @@ function handleRemoteWatchEvents(
|
||||
scheduleWorktreeBaseNotification(watch, { structureRepoIds: [...watch.repos.keys()] })
|
||||
return
|
||||
}
|
||||
if (hasCollectedChanges(changes)) {
|
||||
if (hasCollectedWorktreeBaseChanges(changes)) {
|
||||
scheduleWorktreeBaseNotification(watch, changes)
|
||||
}
|
||||
}
|
||||
@@ -129,6 +128,7 @@ function createActiveWatch(
|
||||
pendingHeadIdentityRepoIds: new Set(),
|
||||
headIdentityRefresh: createWorktreeHeadIdentityRefreshState(),
|
||||
gitStatusRefPaths,
|
||||
watcherFailureRefresh: new WorktreeWatcherFailureRefreshCooldown(),
|
||||
disposed: false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
const WATCHER_FAILURE_REFRESH_COOLDOWN_MS = 60_000
|
||||
|
||||
export class WorktreeWatcherFailureRefreshCooldown {
|
||||
private refreshedAt: number | null = null
|
||||
|
||||
consume(now = Date.now()): boolean {
|
||||
if (this.refreshedAt !== null && now - this.refreshedAt < WATCHER_FAILURE_REFRESH_COOLDOWN_MS) {
|
||||
return false
|
||||
}
|
||||
this.refreshedAt = now
|
||||
return true
|
||||
}
|
||||
|
||||
reset(): void {
|
||||
this.refreshedAt = null
|
||||
}
|
||||
}
|
||||
@@ -186,6 +186,22 @@ describe('aiVault.listSessions handler + shared cache', () => {
|
||||
expect(scanAiVaultSessions).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('keeps completed scans cached for one minute', async () => {
|
||||
vi.useFakeTimers({ now: new Date('2026-08-05T00:00:00.000Z') })
|
||||
try {
|
||||
await listAiVaultSessions({ limit: 500 })
|
||||
await vi.advanceTimersByTimeAsync(59_999)
|
||||
await listAiVaultSessions({ limit: 500 })
|
||||
expect(scanAiVaultSessions).toHaveBeenCalledTimes(1)
|
||||
|
||||
await vi.advanceTimersByTimeAsync(1)
|
||||
await listAiVaultSessions({ limit: 500 })
|
||||
expect(scanAiVaultSessions).toHaveBeenCalledTimes(2)
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('serves lower depths from a larger completed scan', async () => {
|
||||
await listAiVaultSessions({ limit: 1000 })
|
||||
await listAiVaultSessions({ limit: 250 })
|
||||
|
||||
@@ -20,7 +20,7 @@ const EMPTY_RESULT: AiVaultListResult = {
|
||||
scannedAt: '2026-07-01T00:00:00.000Z'
|
||||
}
|
||||
|
||||
const THROTTLE_MS = 5_000
|
||||
const THROTTLE_MS = 30_000
|
||||
|
||||
const listSessionsMock = vi.fn<(args: unknown) => Promise<AiVaultListResult>>()
|
||||
const cancelListSessionsMock = vi.fn<() => Promise<void>>()
|
||||
@@ -206,14 +206,14 @@ afterEach(() => {
|
||||
})
|
||||
|
||||
describe('useAiVaultSessionRefresh refocus behavior', () => {
|
||||
it('bypasses the scan cache on mount so panel entry shows new sessions', async () => {
|
||||
it('uses the shared scan cache on local panel entry', async () => {
|
||||
await renderHook()
|
||||
await flushMicrotasks()
|
||||
|
||||
expect(listSessionsMock).toHaveBeenCalledTimes(1)
|
||||
expect(listSessionsMock.mock.calls[0]?.[0]).toMatchObject({
|
||||
executionHostScope: 'local',
|
||||
force: true
|
||||
force: false
|
||||
})
|
||||
})
|
||||
|
||||
@@ -351,32 +351,25 @@ describe('useAiVaultSessionRefresh refocus behavior', () => {
|
||||
expect(latest?.scanResult?.scannedAt).toBe('2026-07-01T00:00:02.000Z')
|
||||
})
|
||||
|
||||
it('force re-scans on refocus once the throttle allows it', async () => {
|
||||
it('refreshes from the shared cache on refocus', async () => {
|
||||
await renderHook()
|
||||
await flushMicrotasks()
|
||||
expect(listSessionsMock).toHaveBeenCalledTimes(1)
|
||||
|
||||
await advance(THROTTLE_MS + 1)
|
||||
await fireWindowFocused()
|
||||
|
||||
expect(listSessionsMock).toHaveBeenCalledTimes(2)
|
||||
expect(lastCallArgs()).toMatchObject({ force: true })
|
||||
expect(lastCallArgs()).toMatchObject({ force: false })
|
||||
})
|
||||
|
||||
it('defers a refocus inside the throttle window to a trailing forced scan', async () => {
|
||||
it('does not force transcript scans for refocus events', async () => {
|
||||
await renderHook()
|
||||
await flushMicrotasks()
|
||||
expect(listSessionsMock).toHaveBeenCalledTimes(1)
|
||||
|
||||
// Within the throttle window nothing runs yet — the event must not be
|
||||
// dropped, so it lands as one trailing scan when the throttle frees up.
|
||||
await fireWindowFocused()
|
||||
await dispatch(document, 'visibilitychange')
|
||||
expect(listSessionsMock).toHaveBeenCalledTimes(1)
|
||||
|
||||
await advance(THROTTLE_MS + 1)
|
||||
expect(listSessionsMock).toHaveBeenCalledTimes(2)
|
||||
expect(lastCallArgs()).toMatchObject({ force: true })
|
||||
expect(lastCallArgs()).toMatchObject({ force: false })
|
||||
})
|
||||
|
||||
it('ignores focus/visibility events while the document is hidden', async () => {
|
||||
@@ -393,15 +386,12 @@ describe('useAiVaultSessionRefresh refocus behavior', () => {
|
||||
expect(listSessionsMock).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('stops listening and cancels trailing scans after unmount', async () => {
|
||||
it('stops listening after unmount', async () => {
|
||||
await renderHook()
|
||||
await flushMicrotasks()
|
||||
expect(listSessionsMock).toHaveBeenCalledTimes(1)
|
||||
|
||||
// Queue a trailing forced scan, then unmount before it fires.
|
||||
await fireWindowFocused()
|
||||
roots.splice(0).forEach((root) => act(() => root.unmount()))
|
||||
await advance(THROTTLE_MS + 1)
|
||||
await fireWindowFocused()
|
||||
await dispatch(document, 'visibilitychange')
|
||||
|
||||
@@ -479,7 +469,7 @@ describe('useAiVaultSessionRefresh refocus behavior', () => {
|
||||
expect(lastCallArgs()).toMatchObject({ force: true })
|
||||
})
|
||||
|
||||
it('counts a manual force refresh against the rescan throttle', async () => {
|
||||
it('keeps refocus cache-backed after a manual force refresh', async () => {
|
||||
await renderHook()
|
||||
await flushMicrotasks()
|
||||
|
||||
@@ -489,9 +479,9 @@ describe('useAiVaultSessionRefresh refocus behavior', () => {
|
||||
})
|
||||
expect(listSessionsMock).toHaveBeenCalledTimes(2)
|
||||
|
||||
// The button just scanned; an immediate refocus defers to trailing.
|
||||
await fireWindowFocused()
|
||||
expect(listSessionsMock).toHaveBeenCalledTimes(2)
|
||||
expect(listSessionsMock).toHaveBeenCalledTimes(3)
|
||||
expect(lastCallArgs()).toMatchObject({ force: false })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -514,10 +504,13 @@ describe('useAiVaultSessionRefresh in-app agent session behavior', () => {
|
||||
expect(listSessionsMock).toHaveBeenCalledTimes(1)
|
||||
|
||||
await setAgentStatuses({ 'pane-1': makeAgentEntry('sess-1') })
|
||||
expect(listSessionsMock).toHaveBeenCalledTimes(1)
|
||||
expect(listSessionsMock).toHaveBeenCalledTimes(2)
|
||||
|
||||
await setAgentStatuses({ 'pane-2': makeAgentEntry('sess-2') })
|
||||
expect(listSessionsMock).toHaveBeenCalledTimes(2)
|
||||
|
||||
await advance(THROTTLE_MS + 1)
|
||||
expect(listSessionsMock).toHaveBeenCalledTimes(2)
|
||||
expect(listSessionsMock).toHaveBeenCalledTimes(3)
|
||||
expect(lastCallArgs()).toMatchObject({ force: true })
|
||||
})
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ import {
|
||||
type AiVaultListResult,
|
||||
type AiVaultSession
|
||||
} from '../../../../shared/ai-vault-types'
|
||||
import { LOCAL_EXECUTION_HOST_ID, type ExecutionHostScope } from '../../../../shared/execution-host'
|
||||
import type { ExecutionHostScope } from '../../../../shared/execution-host'
|
||||
import { useAppStore } from '@/store'
|
||||
import type { AiVaultSessionLimit } from './ai-vault-session-limit'
|
||||
import {
|
||||
@@ -14,22 +14,11 @@ import {
|
||||
resetAiVaultSessionResultCacheForTest
|
||||
} from './ai-vault-session-result-cache'
|
||||
|
||||
// Panel entry and window refocus must show sessions started since the last
|
||||
// scan, so they bypass the main process's 15s cache — but a full scan parses
|
||||
// up to ~1000 transcripts, so bound forced scans to one per interval. Module
|
||||
// scope so the throttle survives panel remounts (the panel unmounts per tab).
|
||||
const FORCED_RESCAN_MIN_INTERVAL_MS = 5_000
|
||||
// In-app session creation bypasses the cache so the new session appears promptly.
|
||||
// Keep the budget at module scope so tab remounts cannot amplify full scans.
|
||||
const FORCED_RESCAN_MIN_INTERVAL_MS = 30_000
|
||||
let lastForcedRescanAt = 0
|
||||
|
||||
function consumeForcedRescanBudget(): boolean {
|
||||
const now = Date.now()
|
||||
if (now - lastForcedRescanAt < FORCED_RESCAN_MIN_INTERVAL_MS) {
|
||||
return false
|
||||
}
|
||||
lastForcedRescanAt = now
|
||||
return true
|
||||
}
|
||||
|
||||
export function resetAiVaultForcedRescanThrottleForTest(): void {
|
||||
lastForcedRescanAt = 0
|
||||
resetAiVaultSessionResultCacheForTest()
|
||||
@@ -201,7 +190,7 @@ export function useAiVaultSessionRefresh(
|
||||
[currentScanScopeKey]
|
||||
)
|
||||
|
||||
// Forced rescans triggered by events (refocus, agent-session starts) run
|
||||
// Forced rescans triggered by new agent sessions run
|
||||
// immediately when the throttle allows, otherwise once as soon as it frees
|
||||
// up — dropping the event would leave a just-started session invisible
|
||||
// until some unrelated later trigger.
|
||||
@@ -240,33 +229,23 @@ export function useAiVaultSessionRefresh(
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Remote scans can take long enough for normal tab navigation to feel stuck,
|
||||
// so re-entering a remote panel uses its host/scope cache. Explicit refresh,
|
||||
// app refocus and new in-app agent sessions still force a fresh scan.
|
||||
// Panel entry reuses the renderer result first, then the host scan cache.
|
||||
useEffect(() => {
|
||||
if (refreshInFlightRef.current) {
|
||||
void window.api.aiVault.cancelListSessions({
|
||||
requestToken: requestTokenRef.current
|
||||
})
|
||||
}
|
||||
const refreshOnEntry = executionHostScope === LOCAL_EXECUTION_HOST_ID
|
||||
const force = refreshOnEntry && consumeForcedRescanBudget()
|
||||
void refresh({ force, reuseLoadedDepth: true })
|
||||
if (refreshOnEntry && !force) {
|
||||
requestForcedRescan()
|
||||
}
|
||||
}, [executionHostScope, refresh, requestForcedRescan, scanScopeKey])
|
||||
void refresh({ force: false, reuseLoadedDepth: true })
|
||||
}, [executionHostScope, refresh, scanScopeKey])
|
||||
|
||||
// Sessions started while the app was backgrounded should appear when the
|
||||
// user returns, so refocus also bypasses the scan cache (throttled). OS
|
||||
// refocus arrives via the main process — renderer DOM focus events don't
|
||||
// fire on macOS app activation; visibilitychange covers minimize-restore.
|
||||
// Refocus checks the shared host cache without forcing another transcript scan.
|
||||
useEffect(() => {
|
||||
const onRefocus = (): void => {
|
||||
if (document.visibilityState !== 'visible') {
|
||||
return
|
||||
}
|
||||
requestForcedRescan()
|
||||
void refresh({ background: true, force: false })
|
||||
}
|
||||
const unsubscribeWindowFocus = window.api.aiVault.onWindowFocused?.(onRefocus)
|
||||
document.addEventListener('visibilitychange', onRefocus)
|
||||
@@ -274,7 +253,7 @@ export function useAiVaultSessionRefresh(
|
||||
unsubscribeWindowFocus?.()
|
||||
document.removeEventListener('visibilitychange', onRefocus)
|
||||
}
|
||||
}, [requestForcedRescan])
|
||||
}, [refresh])
|
||||
|
||||
// Sessions started inside Orca never blur the window, so refocus alone
|
||||
// can't surface them. Agent hooks already report provider sessions; re-scan
|
||||
|
||||
@@ -335,6 +335,7 @@ type MockTransport = {
|
||||
const scheduleRuntimeGraphSync = vi.fn()
|
||||
const shouldSeedCacheTimerOnInitialTitle = vi.fn(() => false)
|
||||
const scheduleTerminalWebglAtlasRecovery = vi.fn()
|
||||
const scheduleTerminalWebglPresent = vi.fn()
|
||||
|
||||
let mockStoreState: StoreState
|
||||
let transportFactoryQueue: MockTransport[] = []
|
||||
@@ -358,7 +359,8 @@ vi.mock('@/store', () => ({
|
||||
}))
|
||||
|
||||
vi.mock('./terminal-webgl-atlas-recovery', () => ({
|
||||
scheduleTerminalWebglAtlasRecovery
|
||||
scheduleTerminalWebglAtlasRecovery,
|
||||
scheduleTerminalWebglPresent
|
||||
}))
|
||||
|
||||
function notifyStoreSubscribers(): void {
|
||||
@@ -16884,7 +16886,7 @@ describe('connectPanePty', () => {
|
||||
expect(scheduleTerminalWebglAtlasRecovery).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('schedules WebGL atlas recovery for Vim-style foreground alternate-screen redraws', async () => {
|
||||
it('schedules a WebGL repaint for Vim-style foreground alternate-screen redraws', async () => {
|
||||
const restoreNavigator = temporarilySetNavigatorUserAgent('Mozilla/5.0 (Macintosh)')
|
||||
try {
|
||||
const { connectPanePty } = await import('./pty-connection')
|
||||
@@ -16917,16 +16919,17 @@ describe('connectPanePty', () => {
|
||||
'\x1b[2J\x1b[H{"name":"eepo"}\r\n\x1b[2;1H{"name":"expo"}\x1b[K'
|
||||
)
|
||||
|
||||
expect(scheduleTerminalWebglAtlasRecovery).not.toHaveBeenCalled()
|
||||
expect(scheduleTerminalWebglPresent).not.toHaveBeenCalled()
|
||||
parseCallback?.()
|
||||
expect(refresh).toHaveBeenCalledWith(0, 39, true)
|
||||
expect(scheduleTerminalWebglAtlasRecovery).toHaveBeenCalledTimes(1)
|
||||
expect(scheduleTerminalWebglPresent).toHaveBeenCalledTimes(1)
|
||||
expect(scheduleTerminalWebglAtlasRecovery).not.toHaveBeenCalled()
|
||||
} finally {
|
||||
restoreNavigator()
|
||||
}
|
||||
})
|
||||
|
||||
it('schedules WebGL atlas recovery when a foreground rewrite enters alternate screen', async () => {
|
||||
it('schedules a WebGL repaint when a foreground rewrite enters alternate screen', async () => {
|
||||
const restoreNavigator = temporarilySetNavigatorUserAgent('Mozilla/5.0 (Macintosh)')
|
||||
try {
|
||||
const { connectPanePty } = await import('./pty-connection')
|
||||
@@ -16956,18 +16959,19 @@ describe('connectPanePty', () => {
|
||||
|
||||
capturedDataCallback.current?.('\x1b[?1049h\x1b[2J\x1b[HVim package.json')
|
||||
|
||||
expect(scheduleTerminalWebglAtlasRecovery).not.toHaveBeenCalled()
|
||||
expect(scheduleTerminalWebglPresent).not.toHaveBeenCalled()
|
||||
// Why: xterm switches to the alternate buffer while parsing; the write callback observes the post-parse state.
|
||||
;(pane.terminal.buffer.active as { type: 'normal' | 'alternate' }).type = 'alternate'
|
||||
parseCallback?.()
|
||||
expect(refresh).toHaveBeenCalledWith(0, 39, true)
|
||||
expect(scheduleTerminalWebglAtlasRecovery).toHaveBeenCalledTimes(1)
|
||||
expect(scheduleTerminalWebglPresent).toHaveBeenCalledTimes(1)
|
||||
expect(scheduleTerminalWebglAtlasRecovery).not.toHaveBeenCalled()
|
||||
} finally {
|
||||
restoreNavigator()
|
||||
}
|
||||
})
|
||||
|
||||
it('schedules WebGL atlas recovery when the alternate-screen enter sequence splits across chunks', async () => {
|
||||
it('schedules a WebGL repaint when the alternate-screen enter sequence splits across chunks', async () => {
|
||||
const restoreNavigator = temporarilySetNavigatorUserAgent('Mozilla/5.0 (Macintosh)')
|
||||
try {
|
||||
const { connectPanePty } = await import('./pty-connection')
|
||||
@@ -16998,18 +17002,19 @@ describe('connectPanePty', () => {
|
||||
// Why: PTY reads split CSI sequences at arbitrary byte boundaries, so the enter sequence can straddle two onData chunks.
|
||||
capturedDataCallback.current?.('\x1b[?104')
|
||||
parseCallback?.()
|
||||
expect(scheduleTerminalWebglAtlasRecovery).not.toHaveBeenCalled()
|
||||
expect(scheduleTerminalWebglPresent).not.toHaveBeenCalled()
|
||||
|
||||
capturedDataCallback.current?.('9h\x1b[2J\x1b[H~\x1b[K')
|
||||
;(pane.terminal.buffer.active as { type: 'normal' | 'alternate' }).type = 'alternate'
|
||||
parseCallback?.()
|
||||
expect(scheduleTerminalWebglAtlasRecovery).toHaveBeenCalledTimes(1)
|
||||
expect(scheduleTerminalWebglPresent).toHaveBeenCalledTimes(1)
|
||||
expect(scheduleTerminalWebglAtlasRecovery).not.toHaveBeenCalled()
|
||||
} finally {
|
||||
restoreNavigator()
|
||||
}
|
||||
})
|
||||
|
||||
it('schedules WebGL atlas recovery when a foreground rewrite leaves alternate screen', async () => {
|
||||
it('schedules a WebGL repaint when a foreground rewrite leaves alternate screen', async () => {
|
||||
const restoreNavigator = temporarilySetNavigatorUserAgent('Mozilla/5.0 (Macintosh)')
|
||||
try {
|
||||
const { connectPanePty } = await import('./pty-connection')
|
||||
@@ -17042,13 +17047,14 @@ describe('connectPanePty', () => {
|
||||
capturedDataCallback.current?.('\x1b[34;1H\x1b[K\x1b[34;1H\x1b[?1049l\x1b[?25h')
|
||||
;(pane.terminal.buffer.active as { type: 'normal' | 'alternate' }).type = 'normal'
|
||||
parseCallback?.()
|
||||
expect(scheduleTerminalWebglAtlasRecovery).toHaveBeenCalledTimes(1)
|
||||
expect(scheduleTerminalWebglPresent).toHaveBeenCalledTimes(1)
|
||||
expect(scheduleTerminalWebglAtlasRecovery).not.toHaveBeenCalled()
|
||||
} finally {
|
||||
restoreNavigator()
|
||||
}
|
||||
})
|
||||
|
||||
it('schedules WebGL atlas recovery when one chunk enters and exits alternate screen', async () => {
|
||||
it('schedules a WebGL repaint when one chunk enters and exits alternate screen', async () => {
|
||||
const restoreNavigator = temporarilySetNavigatorUserAgent('Mozilla/5.0 (Macintosh)')
|
||||
try {
|
||||
const { connectPanePty } = await import('./pty-connection')
|
||||
@@ -17090,13 +17096,14 @@ describe('connectPanePty', () => {
|
||||
bufferChangeListener?.()
|
||||
bufferChangeListener?.()
|
||||
parseCallback?.()
|
||||
expect(scheduleTerminalWebglAtlasRecovery).toHaveBeenCalledTimes(1)
|
||||
expect(scheduleTerminalWebglPresent).toHaveBeenCalledTimes(1)
|
||||
expect(scheduleTerminalWebglAtlasRecovery).not.toHaveBeenCalled()
|
||||
} finally {
|
||||
restoreNavigator()
|
||||
}
|
||||
})
|
||||
|
||||
it('schedules WebGL atlas recovery for real captured Vim redraw chunks split mid-sequence', async () => {
|
||||
it('schedules WebGL repaints for real captured Vim redraw chunks split mid-sequence', async () => {
|
||||
const restoreNavigator = temporarilySetNavigatorUserAgent('Mozilla/5.0 (Macintosh)')
|
||||
try {
|
||||
const { connectPanePty } = await import('./pty-connection')
|
||||
@@ -17128,13 +17135,14 @@ describe('connectPanePty', () => {
|
||||
// Captured from a real vim session: a 1024-byte PTY read boundary cuts the cursor move \x1b[30;5H into "\x1b[30" + ";5H".
|
||||
capturedDataCallback.current?.('"rules": {\x1b[29;15H\x1b[K\x1b[30')
|
||||
parseCallback?.()
|
||||
expect(scheduleTerminalWebglAtlasRecovery).toHaveBeenCalledTimes(1)
|
||||
expect(scheduleTerminalWebglPresent).toHaveBeenCalledTimes(1)
|
||||
|
||||
capturedDataCallback.current?.(
|
||||
';5H "js-combine-iterations": "off"\r\n }\x1b[31;6H\x1b[K\x1b[33;1H\x1b[?25h'
|
||||
)
|
||||
parseCallback?.()
|
||||
expect(scheduleTerminalWebglAtlasRecovery).toHaveBeenCalledTimes(2)
|
||||
expect(scheduleTerminalWebglPresent).toHaveBeenCalledTimes(2)
|
||||
expect(scheduleTerminalWebglAtlasRecovery).not.toHaveBeenCalled()
|
||||
} finally {
|
||||
restoreNavigator()
|
||||
}
|
||||
|
||||
@@ -233,7 +233,10 @@ import { createCommandCodeOutputStatusDetector } from '../../../../shared/comman
|
||||
import type { PtyDataMeta } from './pty-dispatcher'
|
||||
import { getEagerPtyBufferHandle } from './pty-dispatcher'
|
||||
import { createTerminalGitHubPRLinkDetector } from '../../../../shared/terminal-github-pr-link-detector'
|
||||
import { scheduleTerminalWebglAtlasRecovery } from './terminal-webgl-atlas-recovery'
|
||||
import {
|
||||
scheduleTerminalWebglAtlasRecovery,
|
||||
scheduleTerminalWebglPresent
|
||||
} from './terminal-webgl-atlas-recovery'
|
||||
import {
|
||||
CONPTY_DA1_RESPONSE,
|
||||
DEFAULT_DA1_RESPONSE,
|
||||
@@ -6326,8 +6329,8 @@ export function connectPanePty(
|
||||
return decision.prefersRenderRefresh
|
||||
}
|
||||
|
||||
// Why: Vim-style rewrites leave stale WebGL glyphs until the atlas rebuilds; alt-screen membership is only authoritative post-parse (enter/exit can split chunks), so capture pre-parse and decide at parse completion.
|
||||
function alternateScreenRewriteAtlasRecoveryOnParsed(): () => void {
|
||||
// Why: alt-screen membership is only authoritative post-parse because enter/exit sequences can split across chunks.
|
||||
function alternateScreenRewritePresentOnParsed(): () => void {
|
||||
const wasAlternateScreenBuffer = pane.terminal.buffer.active.type === 'alternate'
|
||||
const switchesBeforeParse = alternateScreenBufferSwitches
|
||||
return () => {
|
||||
@@ -6336,7 +6339,7 @@ export function connectPanePty(
|
||||
alternateScreenBufferSwitches !== switchesBeforeParse ||
|
||||
pane.terminal.buffer.active.type === 'alternate'
|
||||
) {
|
||||
scheduleTerminalWebglAtlasRecovery()
|
||||
scheduleTerminalWebglPresent()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6464,7 +6467,7 @@ export function connectPanePty(
|
||||
? recoverWebglAtlasAfterParse
|
||||
? scheduleTerminalWebglAtlasRecovery
|
||||
: renderRefreshDecision.inPlaceRewrite
|
||||
? alternateScreenRewriteAtlasRecoveryOnParsed()
|
||||
? alternateScreenRewritePresentOnParsed()
|
||||
: undefined
|
||||
: undefined
|
||||
const foregroundRenderRefreshNeeded = renderRefreshDecision.refresh
|
||||
|
||||
@@ -13,12 +13,12 @@ import type { TerminalTab } from '../../../../shared/types'
|
||||
// was built for. Reveal cost is a flat ~170ms remount regardless of buffer
|
||||
// size, so cutting remount *frequency* beats shaving replay.
|
||||
export const TERMINAL_WORKTREE_COLD_PARK_DELAY_MS = 30_000
|
||||
export const TERMINAL_WORKTREE_HOT_RETAIN_MS = 15 * 60_000
|
||||
export const TERMINAL_WORKTREE_HOT_RETAIN_LIMIT = 8
|
||||
export const TERMINAL_WORKTREE_HOT_RETAIN_MS = 5 * 60_000
|
||||
export const TERMINAL_WORKTREE_HOT_RETAIN_LIMIT = 4
|
||||
export const TERMINAL_WORKTREE_PARK_DELAY_MS = TERMINAL_WORKTREE_COLD_PARK_DELAY_MS
|
||||
export const TERMINAL_TAB_COLD_PARK_DELAY_MS = 30_000
|
||||
export const TERMINAL_TAB_HOT_RETAIN_MS = 15 * 60_000
|
||||
export const TERMINAL_TAB_HOT_RETAIN_LIMIT = 12
|
||||
export const TERMINAL_TAB_HOT_RETAIN_MS = 5 * 60_000
|
||||
export const TERMINAL_TAB_HOT_RETAIN_LIMIT = 6
|
||||
|
||||
// Why: tests override these per call (instead of process.env reads inside the
|
||||
// module) to shrink the 30s hysteresis to test-friendly durations.
|
||||
|
||||
@@ -13,8 +13,8 @@ import type { TerminalTab } from '../../../../shared/types'
|
||||
// Why these sizes: a retained hidden pane costs a measured ~2.5MB of V8 heap
|
||||
// at the 5k-row default scrollback and ~19MB at 50k (plus per-pane queues),
|
||||
// not the ~4-5MB per WORKTREE the warm cap assumed — so un-parkable worktrees
|
||||
// (pty classes parking can't restore) get a retention budget: at most 12 stay
|
||||
// mounted while hidden and none past 45 minutes, evicted least-recently-hidden
|
||||
// (pty classes parking can't restore) get a retention budget: at most 4 stay
|
||||
// mounted while hidden and none past 15 minutes, evicted least-recently-hidden
|
||||
// first via force-park. The TTL is absolute: the last-active exemption bounds
|
||||
// the cap, never the clock.
|
||||
// NOT covered by this bound: eviction-exempt TABS (isEvictionExemptTerminalPty
|
||||
@@ -27,8 +27,8 @@ import type { TerminalTab } from '../../../../shared/types'
|
||||
// spared worktree (last-active, exempt tabs) can hold full 50k-row scrollback
|
||||
// indefinitely. Accepted tradeoff: high-scrollback users rely on unmount
|
||||
// eviction, not demotion.
|
||||
export const TERMINAL_HIDDEN_WORKTREE_RETENTION_LIMIT = 12
|
||||
export const TERMINAL_HIDDEN_WORKTREE_RETENTION_TTL_MS = 45 * 60_000
|
||||
export const TERMINAL_HIDDEN_WORKTREE_RETENTION_LIMIT = 4
|
||||
export const TERMINAL_HIDDEN_WORKTREE_RETENTION_TTL_MS = 15 * 60_000
|
||||
|
||||
export function hasPendingRetentionSpawnWork(
|
||||
tab: Pick<TerminalTab, 'id' | 'ptyId' | 'pendingActivationSpawn'>,
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
scheduleImagePasteWebglAtlasRecovery,
|
||||
scheduleTabRevealWebglAtlasRecovery,
|
||||
scheduleTerminalWebglAtlasRecovery,
|
||||
scheduleTerminalWebglPresent,
|
||||
TERMINAL_OUTPUT_RECOVERY_QUIET_MS
|
||||
} from './terminal-webgl-atlas-recovery'
|
||||
|
||||
@@ -204,6 +205,24 @@ describe('terminal WebGL atlas recovery', () => {
|
||||
expect(manager.refreshAllPanes).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('repaints settled rewrites without clearing the shared atlas', () => {
|
||||
vi.useFakeTimers()
|
||||
const manager = registerManager()
|
||||
|
||||
scheduleTerminalWebglPresent()
|
||||
scheduleTerminalWebglPresent()
|
||||
vi.advanceTimersByTime(TERMINAL_OUTPUT_RECOVERY_QUIET_MS - 1)
|
||||
|
||||
expect(manager.scheduleRevealPresent).not.toHaveBeenCalled()
|
||||
expect(manager.resetWebglTextureAtlases).not.toHaveBeenCalled()
|
||||
|
||||
vi.advanceTimersByTime(1)
|
||||
|
||||
expect(manager.scheduleRevealPresent).toHaveBeenCalledOnce()
|
||||
expect(manager.resetWebglTextureAtlases).not.toHaveBeenCalled()
|
||||
expect(manager.refreshAllPanes).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('recovers exactly once after terminal output settles, not a 3-stage burst', () => {
|
||||
// Brennan-approved single-recovery decision: the streaming settle fires one
|
||||
// clear+refresh, not the rAF+120+500 burst, so a clear can only ever land
|
||||
|
||||
@@ -18,6 +18,7 @@ const TERMINAL_OUTPUT_RECOVERY_REFILL_INTERVAL_MS = 6_000
|
||||
|
||||
let terminalOutputRecoveryDebounceTimer: ReturnType<typeof setTimeout> | null = null
|
||||
let terminalOutputRecoveryRetryTimer: ReturnType<typeof setTimeout> | null = null
|
||||
let terminalPresentDebounceTimer: ReturnType<typeof setTimeout> | null = null
|
||||
let terminalOutputRecoveryWipeTokens = TERMINAL_OUTPUT_RECOVERY_BURST_WIPES
|
||||
let terminalOutputRecoveryTokensRefilledAt: number | null = null
|
||||
let terminalOutputRecoveryLastWipeAt: number | null = null
|
||||
@@ -95,6 +96,16 @@ export function scheduleTerminalWebglAtlasRecovery(): void {
|
||||
}, TERMINAL_OUTPUT_RECOVERY_QUIET_MS)
|
||||
}
|
||||
|
||||
export function scheduleTerminalWebglPresent(): void {
|
||||
if (terminalPresentDebounceTimer != null) {
|
||||
globalThis.clearTimeout(terminalPresentDebounceTimer)
|
||||
}
|
||||
terminalPresentDebounceTimer = globalThis.setTimeout(() => {
|
||||
terminalPresentDebounceTimer = null
|
||||
presentPanesWithoutAtlasClear()
|
||||
}, TERMINAL_OUTPUT_RECOVERY_QUIET_MS)
|
||||
}
|
||||
|
||||
function scheduleTerminalOutputRecoveryRetry(delayMs: number): void {
|
||||
terminalOutputRecoveryRetryTimer = globalThis.setTimeout(() => {
|
||||
terminalOutputRecoveryRetryTimer = null
|
||||
@@ -128,6 +139,10 @@ export function resetTerminalWebglAtlasRecoveryBudgetForTesting(): void {
|
||||
globalThis.clearTimeout(terminalOutputRecoveryRetryTimer)
|
||||
}
|
||||
terminalOutputRecoveryRetryTimer = null
|
||||
if (terminalPresentDebounceTimer != null) {
|
||||
globalThis.clearTimeout(terminalPresentDebounceTimer)
|
||||
}
|
||||
terminalPresentDebounceTimer = null
|
||||
terminalOutputRecoveryWipeTokens = TERMINAL_OUTPUT_RECOVERY_BURST_WIPES
|
||||
terminalOutputRecoveryTokensRefilledAt = null
|
||||
terminalOutputRecoveryLastWipeAt = null
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import type { ManagedPane, ManagedPaneInternal } from './pane-manager-types'
|
||||
|
||||
export function isManagedPaneDisplayNone(pane: ManagedPane): boolean {
|
||||
const element = (pane as ManagedPaneInternal).xtermContainer ?? pane.container
|
||||
const view = element?.ownerDocument?.defaultView
|
||||
if (!element || !view) {
|
||||
return false
|
||||
}
|
||||
for (let current: HTMLElement | null = element; current; current = current.parentElement) {
|
||||
if (view.getComputedStyle(current).display === 'none') {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { recordRendererCrashBreadcrumb } from '@/lib/crash-breadcrumb-recorder'
|
||||
import type { ManagedPane, ScrollState } from './pane-manager-types'
|
||||
import type { ManagedPane, ManagedPaneInternal, ScrollState } from './pane-manager-types'
|
||||
import { safeFit, safeFitAndThen } from './pane-fit'
|
||||
import { paneFitClientSizeChanged } from './pane-reveal-fit'
|
||||
|
||||
@@ -214,6 +214,28 @@ describe('safeFitAndThen unmeasurable-pane retry', () => {
|
||||
expect(continuation).not.toHaveBeenCalled()
|
||||
await expect(handle.completion).resolves.toBe(false)
|
||||
})
|
||||
|
||||
it('does not retry a pane explicitly hidden with display none', async () => {
|
||||
vi.mocked(recordRendererCrashBreadcrumb).mockClear()
|
||||
const pane = createPane({ rect: { width: 0, height: 0 } })
|
||||
const container = (pane as unknown as ManagedPaneInternal).xtermContainer
|
||||
Object.assign(container, {
|
||||
ownerDocument: {
|
||||
defaultView: { getComputedStyle: () => ({ display: 'none' }) }
|
||||
},
|
||||
parentElement: null
|
||||
})
|
||||
const continuation = vi.fn()
|
||||
|
||||
const handle = safeFitAndThen(pane, 'reattach-pty-resize', continuation, {
|
||||
retryIfUnmeasurable: true
|
||||
})
|
||||
|
||||
expect(requestAnimationFrame).not.toHaveBeenCalled()
|
||||
expect(continuation).not.toHaveBeenCalled()
|
||||
expect(recordRendererCrashBreadcrumb).not.toHaveBeenCalled()
|
||||
await expect(handle.completion).resolves.toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('paneFitClientSizeChanged (reveal fit gate)', () => {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { ManagedPane, ManagedPaneInternal, ScrollState } from './pane-manager-types'
|
||||
import { isManagedPaneDisplayNone } from './pane-display-visibility'
|
||||
import { getFitOverrideForPty } from './mobile-fit-overrides'
|
||||
import {
|
||||
armPaneFitContinuationRetry,
|
||||
@@ -314,7 +315,11 @@ export function safeFitAndThen(
|
||||
() => {
|
||||
if (pendingSafeFitContinuations.get(pane)?.get(operationKey) === pending) {
|
||||
if (!safeFit(pane) && options.retryIfUnmeasurable) {
|
||||
armSafeFitContinuationRetry(pane)
|
||||
if (isManagedPaneDisplayNone(pane)) {
|
||||
cancel()
|
||||
} else {
|
||||
armSafeFitContinuationRetry(pane)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -323,7 +328,11 @@ export function safeFitAndThen(
|
||||
return { completion, cancel }
|
||||
}
|
||||
if (!safeFit(pane) && options.retryIfUnmeasurable) {
|
||||
armSafeFitContinuationRetry(pane)
|
||||
if (isManagedPaneDisplayNone(pane)) {
|
||||
cancel()
|
||||
} else {
|
||||
armSafeFitContinuationRetry(pane)
|
||||
}
|
||||
}
|
||||
return { completion, cancel }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user