revert: remove terminal retention changes (#4562)
This commit is contained in:
parent
3079e4690a
commit
8eee59c084
|
|
@ -57,11 +57,6 @@ import {
|
|||
getEffectiveLayoutForWorktree as getEffectiveLayout,
|
||||
anyMountedWorktreeHasLayout as computeAnyMountedWorktreeHasLayout
|
||||
} from './terminal/split-group-mount'
|
||||
import {
|
||||
getTerminalWorktreeColdParkRecheckDelayMs,
|
||||
selectColdParkedTerminalWorktrees,
|
||||
type TerminalWorktreeColdParkCandidate
|
||||
} from './terminal/terminal-worktree-parking'
|
||||
import { focusTerminalTabSurface } from '@/lib/focus-terminal-tab-surface'
|
||||
import { appendUniqueOpenFileIds } from './terminal/unsaved-close-queue'
|
||||
import CodexRestartChip from './CodexRestartChip'
|
||||
|
|
@ -108,18 +103,6 @@ const EDITOR_TAB_CONTENT_TYPES = new Set<TabContentType>(['editor', 'diff', 'con
|
|||
|
||||
type TerminalStoreSnapshot = ReturnType<typeof useAppStore.getState>
|
||||
|
||||
function haveSameWorktreeIds(left: Set<string>, right: Set<string>): boolean {
|
||||
if (left.size !== right.size) {
|
||||
return false
|
||||
}
|
||||
for (const id of left) {
|
||||
if (!right.has(id)) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
function findUnifiedTabByVisibleId(
|
||||
state: TerminalStoreSnapshot,
|
||||
worktreeId: string,
|
||||
|
|
@ -198,15 +181,11 @@ function getKeybindingContext(target: EventTarget | null): KeybindingContext {
|
|||
function Terminal(): React.JSX.Element | null {
|
||||
const mountedWorktreeIdsRef = useRef(new Set<string>())
|
||||
const measurableBackgroundWorktreeIdsRef = useRef(new Set<string>())
|
||||
const parkedTerminalWorktreeIdsRef = useRef(new Set<string>())
|
||||
const terminalWorktreeHiddenSinceRef = useRef(new Map<string, number>())
|
||||
const terminalWorktreeParkingTimersRef = useRef(new Map<string, number>())
|
||||
const allWorktrees = useAllWorktrees()
|
||||
const activeWorktreeId = useAppStore((s) => s.activeWorktreeId)
|
||||
const renderedActiveWorktreeId = activeWorktreeId
|
||||
const activeView = useAppStore((s) => s.activeView)
|
||||
const tabsByWorktree = useAppStore((s) => s.tabsByWorktree)
|
||||
const pendingStartupByTabId = useAppStore((s) => s.pendingStartupByTabId)
|
||||
const activeTabId = useAppStore((s) => s.activeTabId)
|
||||
const createTab = useAppStore((s) => s.createTab)
|
||||
const closeTab = useAppStore((s) => s.closeTab)
|
||||
|
|
@ -672,8 +651,7 @@ function Terminal(): React.JSX.Element | null {
|
|||
// Only mount TerminalPanes for visited worktrees to prevent mass PTY
|
||||
// spawning when restoring a session with many saved worktree tabs.
|
||||
const measurableBackgroundWorktreeTimersRef = useRef(new Map<string, number>())
|
||||
const [backgroundMountRevision, setBackgroundMountRevision] = useState(0)
|
||||
const [terminalParkingRevision, setTerminalParkingRevision] = useState(0)
|
||||
const [, setBackgroundMountRevision] = useState(0)
|
||||
useEffect(() => {
|
||||
const timers = measurableBackgroundWorktreeTimersRef.current
|
||||
const closeDialogDebounceTimers = closeDialogDebounceTimersRef.current
|
||||
|
|
@ -723,108 +701,6 @@ function Terminal(): React.JSX.Element | null {
|
|||
closeDialogDebounceTimers.clear()
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const timers = terminalWorktreeParkingTimersRef.current
|
||||
return () => {
|
||||
for (const timer of timers.values()) {
|
||||
window.clearTimeout(timer)
|
||||
}
|
||||
timers.clear()
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const parkingTimers = terminalWorktreeParkingTimersRef.current
|
||||
for (const timer of parkingTimers.values()) {
|
||||
window.clearTimeout(timer)
|
||||
}
|
||||
parkingTimers.clear()
|
||||
|
||||
const nowMs = Date.now()
|
||||
let changed = false
|
||||
const portalWorktreeIds = new Set(activityTerminalPortals.map((portal) => portal.worktreeId))
|
||||
const currentWorktreeIds = new Set(allWorktrees.map((worktree) => worktree.id))
|
||||
for (const worktreeId of Array.from(terminalWorktreeHiddenSinceRef.current.keys())) {
|
||||
if (!currentWorktreeIds.has(worktreeId) || !mountedWorktreeIdsRef.current.has(worktreeId)) {
|
||||
terminalWorktreeHiddenSinceRef.current.delete(worktreeId)
|
||||
}
|
||||
}
|
||||
|
||||
const retentionCandidates: TerminalWorktreeColdParkCandidate[] = []
|
||||
for (const worktree of allWorktrees) {
|
||||
const worktreeId = worktree.id
|
||||
if (!mountedWorktreeIdsRef.current.has(worktreeId)) {
|
||||
terminalWorktreeHiddenSinceRef.current.delete(worktreeId)
|
||||
continue
|
||||
}
|
||||
const isVisible = activeView === 'terminal' && renderedActiveWorktreeId === worktreeId
|
||||
const shouldMeasureHiddenWorktree =
|
||||
!isVisible && measurableBackgroundWorktreeIdsRef.current.has(worktreeId)
|
||||
const hasActivityTerminalPortal = portalWorktreeIds.has(worktreeId)
|
||||
if (isVisible || shouldMeasureHiddenWorktree || hasActivityTerminalPortal) {
|
||||
terminalWorktreeHiddenSinceRef.current.delete(worktreeId)
|
||||
} else if (!terminalWorktreeHiddenSinceRef.current.has(worktreeId)) {
|
||||
terminalWorktreeHiddenSinceRef.current.set(worktreeId, nowMs)
|
||||
}
|
||||
|
||||
retentionCandidates.push({
|
||||
worktreeId,
|
||||
terminalTabs: tabsByWorktree[worktreeId] ?? [],
|
||||
isVisible,
|
||||
shouldMeasureHiddenWorktree,
|
||||
hasActivityTerminalPortal,
|
||||
hiddenSinceMs: terminalWorktreeHiddenSinceRef.current.get(worktreeId) ?? null
|
||||
})
|
||||
}
|
||||
|
||||
const nextParkedTerminalWorktreeIds = selectColdParkedTerminalWorktrees({
|
||||
worktrees: retentionCandidates,
|
||||
pendingStartupByTabId,
|
||||
nowMs
|
||||
})
|
||||
|
||||
if (!haveSameWorktreeIds(parkedTerminalWorktreeIdsRef.current, nextParkedTerminalWorktreeIds)) {
|
||||
parkedTerminalWorktreeIdsRef.current = nextParkedTerminalWorktreeIds
|
||||
changed = true
|
||||
}
|
||||
|
||||
for (const candidate of retentionCandidates) {
|
||||
if (
|
||||
candidate.isVisible ||
|
||||
candidate.shouldMeasureHiddenWorktree ||
|
||||
candidate.hasActivityTerminalPortal ||
|
||||
nextParkedTerminalWorktreeIds.has(candidate.worktreeId)
|
||||
) {
|
||||
continue
|
||||
}
|
||||
const delayMs = getTerminalWorktreeColdParkRecheckDelayMs({
|
||||
hiddenSinceMs: candidate.hiddenSinceMs,
|
||||
nowMs
|
||||
})
|
||||
if (delayMs !== null && delayMs > 0) {
|
||||
const worktreeId = candidate.worktreeId
|
||||
const timer = window.setTimeout(() => {
|
||||
parkingTimers.delete(worktreeId)
|
||||
setTerminalParkingRevision((revision) => revision + 1)
|
||||
}, delayMs)
|
||||
parkingTimers.set(worktreeId, timer)
|
||||
}
|
||||
}
|
||||
|
||||
if (changed) {
|
||||
setTerminalParkingRevision((revision) => revision + 1)
|
||||
}
|
||||
}, [
|
||||
activeView,
|
||||
activityTerminalPortals,
|
||||
allWorktrees,
|
||||
backgroundMountRevision,
|
||||
pendingStartupByTabId,
|
||||
renderedActiveWorktreeId,
|
||||
tabsByWorktree,
|
||||
terminalParkingRevision
|
||||
])
|
||||
// Why: gated on workspaceSessionReady to prevent TerminalPane from mounting
|
||||
// before reconnectPersistedTerminals() has finished eagerly spawning PTYs.
|
||||
// Without this gate, Phase 1 (hydrateWorkspaceSession) sets activeWorktreeId
|
||||
|
|
@ -1789,10 +1665,6 @@ function Terminal(): React.JSX.Element | null {
|
|||
activeView === 'terminal' && worktree.id === renderedActiveWorktreeId
|
||||
const shouldMeasureHiddenWorktree =
|
||||
!isVisible && measurableBackgroundWorktreeIdsRef.current.has(worktree.id)
|
||||
const shouldColdParkTerminalPanes =
|
||||
!isVisible &&
|
||||
!shouldMeasureHiddenWorktree &&
|
||||
parkedTerminalWorktreeIdsRef.current.has(worktree.id)
|
||||
return (
|
||||
<WorktreeSplitSurface
|
||||
key={`tab-groups-${worktree.id}`}
|
||||
|
|
@ -1802,7 +1674,6 @@ function Terminal(): React.JSX.Element | null {
|
|||
focusedGroupId={activeGroupIdByWorktree[worktree.id]}
|
||||
isVisible={isVisible}
|
||||
shouldMeasureHiddenWorktree={shouldMeasureHiddenWorktree}
|
||||
shouldColdParkTerminalPanes={shouldColdParkTerminalPanes}
|
||||
activityTerminalPortals={activityTerminalPortals}
|
||||
/>
|
||||
)
|
||||
|
|
@ -1853,10 +1724,6 @@ function Terminal(): React.JSX.Element | null {
|
|||
activeView === 'terminal' && worktree.id === renderedActiveWorktreeId
|
||||
const shouldMeasureHiddenWorktree =
|
||||
!isVisible && measurableBackgroundWorktreeIdsRef.current.has(worktree.id)
|
||||
const shouldColdParkTerminalPanes =
|
||||
!isVisible &&
|
||||
!shouldMeasureHiddenWorktree &&
|
||||
parkedTerminalWorktreeIdsRef.current.has(worktree.id)
|
||||
return (
|
||||
<div
|
||||
key={worktree.id}
|
||||
|
|
@ -1878,9 +1745,6 @@ function Terminal(): React.JSX.Element | null {
|
|||
const isActivityPortalTab = activityTerminalPortal !== null
|
||||
const isActiveTerminalTab =
|
||||
isVisible && tab.id === activeTabId && activeTabType === 'terminal'
|
||||
if (shouldColdParkTerminalPanes && !isActivityPortalTab) {
|
||||
return null
|
||||
}
|
||||
const terminalPane = (
|
||||
<TerminalPane
|
||||
key={`${tab.id}-${tab.generation ?? 0}`}
|
||||
|
|
@ -2069,7 +1933,6 @@ const WorktreeSplitSurface = React.memo(function WorktreeSplitSurface({
|
|||
focusedGroupId,
|
||||
isVisible,
|
||||
shouldMeasureHiddenWorktree,
|
||||
shouldColdParkTerminalPanes,
|
||||
activityTerminalPortals
|
||||
}: {
|
||||
worktreeId: string
|
||||
|
|
@ -2078,7 +1941,6 @@ const WorktreeSplitSurface = React.memo(function WorktreeSplitSurface({
|
|||
focusedGroupId?: string
|
||||
isVisible: boolean
|
||||
shouldMeasureHiddenWorktree: boolean
|
||||
shouldColdParkTerminalPanes: boolean
|
||||
activityTerminalPortals: ActivityTerminalPortalTarget[]
|
||||
}): React.JSX.Element {
|
||||
const browserPageIds = useAppStore(
|
||||
|
|
@ -2116,7 +1978,6 @@ const WorktreeSplitSurface = React.memo(function WorktreeSplitSurface({
|
|||
worktreeId={worktreeId}
|
||||
worktreePath={worktreePath}
|
||||
isWorktreeActive={isVisible}
|
||||
coldParkTerminalPanes={shouldColdParkTerminalPanes}
|
||||
activityTerminalPortals={activityTerminalPortals}
|
||||
/>
|
||||
<BrowserPaneOverlayLayer worktreeId={worktreeId} isWorktreeActive={isVisible} />
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { memo, useCallback, useMemo, useState } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { useShallow } from 'zustand/react/shallow'
|
||||
import type { Tab, TabGroup, TerminalTab } from '../../../../shared/types'
|
||||
|
|
@ -9,11 +9,6 @@ import {
|
|||
type ActivityTerminalPortalTarget
|
||||
} from '../activity/activity-terminal-portal'
|
||||
import TerminalPane from './TerminalPane'
|
||||
import {
|
||||
getTerminalTabColdParkRecheckDelayMs,
|
||||
selectColdParkedTerminalTabs,
|
||||
type TerminalTabColdParkCandidate
|
||||
} from '../terminal/terminal-worktree-parking'
|
||||
|
||||
type TerminalOverlayAssignment = {
|
||||
groupId: string
|
||||
|
|
@ -40,18 +35,6 @@ type TerminalOverlaySlotProps = {
|
|||
leaveWorktreeIfEmpty: () => void
|
||||
}
|
||||
|
||||
function haveSameTerminalTabIds(left: ReadonlySet<string>, right: ReadonlySet<string>): boolean {
|
||||
if (left.size !== right.size) {
|
||||
return false
|
||||
}
|
||||
for (const id of left) {
|
||||
if (!right.has(id)) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
const TerminalOverlaySlot = memo(function TerminalOverlaySlot({
|
||||
terminalTabId,
|
||||
terminalGeneration,
|
||||
|
|
@ -151,13 +134,11 @@ const TerminalPaneOverlayLayer = memo(function TerminalPaneOverlayLayer({
|
|||
worktreeId,
|
||||
worktreePath,
|
||||
isWorktreeActive,
|
||||
coldParkTerminalPanes = false,
|
||||
activityTerminalPortals = EMPTY_ACTIVITY_PORTALS
|
||||
}: {
|
||||
worktreeId: string
|
||||
worktreePath: string
|
||||
isWorktreeActive: boolean
|
||||
coldParkTerminalPanes?: boolean
|
||||
activityTerminalPortals?: ActivityTerminalPortalTarget[]
|
||||
}): React.JSX.Element | null {
|
||||
const { terminalTabs, unifiedTabs, groups, activeGroupId } = useAppStore(
|
||||
|
|
@ -173,13 +154,6 @@ const TerminalPaneOverlayLayer = memo(function TerminalPaneOverlayLayer({
|
|||
const closeTab = useAppStore((state) => state.closeTab)
|
||||
const setActiveWorktree = useAppStore((state) => state.setActiveWorktree)
|
||||
const reconcileWorktreeTabModel = useAppStore((state) => state.reconcileWorktreeTabModel)
|
||||
const pendingStartupByTabId = useAppStore((state) => state.pendingStartupByTabId)
|
||||
const terminalTabHiddenSinceRef = useRef(new Map<string, number>())
|
||||
const terminalTabParkingTimersRef = useRef(new Map<string, number>())
|
||||
const [terminalTabParkingRevision, setTerminalTabParkingRevision] = useState(0)
|
||||
const [coldParkedTerminalTabIds, setColdParkedTerminalTabIds] = useState<ReadonlySet<string>>(
|
||||
() => new Set()
|
||||
)
|
||||
|
||||
// Why: legacy TabGroupPanel routed terminal closes through
|
||||
// commands.closeItem → leaveWorktreeIfEmpty, which deselected the worktree
|
||||
|
|
@ -225,98 +199,6 @@ const TerminalPaneOverlayLayer = memo(function TerminalPaneOverlayLayer({
|
|||
return entries
|
||||
}, [groupActiveTabById, unifiedTabs])
|
||||
|
||||
useEffect(() => {
|
||||
const timers = terminalTabParkingTimersRef.current
|
||||
return () => {
|
||||
for (const timer of timers.values()) {
|
||||
window.clearTimeout(timer)
|
||||
}
|
||||
timers.clear()
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const timers = terminalTabParkingTimersRef.current
|
||||
for (const timer of timers.values()) {
|
||||
window.clearTimeout(timer)
|
||||
}
|
||||
timers.clear()
|
||||
|
||||
const nowMs = Date.now()
|
||||
const currentTerminalTabIds = new Set(terminalTabs.map((tab) => tab.id))
|
||||
const portalTabIds = new Set(
|
||||
activityTerminalPortals
|
||||
.filter((portal) => portal.worktreeId === worktreeId)
|
||||
.map((portal) => portal.tabId)
|
||||
)
|
||||
for (const tabId of Array.from(terminalTabHiddenSinceRef.current.keys())) {
|
||||
if (!currentTerminalTabIds.has(tabId)) {
|
||||
terminalTabHiddenSinceRef.current.delete(tabId)
|
||||
}
|
||||
}
|
||||
|
||||
const candidates: TerminalTabColdParkCandidate[] = terminalTabs.map((terminalTab) => {
|
||||
const assignment = assignments.get(terminalTab.id)
|
||||
const isVisible = Boolean(isWorktreeActive && assignment && assignment.isActiveInGroup)
|
||||
const hasActivityTerminalPortal = portalTabIds.has(terminalTab.id)
|
||||
if (isVisible || hasActivityTerminalPortal) {
|
||||
terminalTabHiddenSinceRef.current.delete(terminalTab.id)
|
||||
} else if (!terminalTabHiddenSinceRef.current.has(terminalTab.id)) {
|
||||
terminalTabHiddenSinceRef.current.set(terminalTab.id, nowMs)
|
||||
}
|
||||
return {
|
||||
id: terminalTab.id,
|
||||
ptyId: terminalTab.ptyId,
|
||||
pendingActivationSpawn: terminalTab.pendingActivationSpawn,
|
||||
isVisible,
|
||||
hasActivityTerminalPortal,
|
||||
hiddenSinceMs: terminalTabHiddenSinceRef.current.get(terminalTab.id) ?? null
|
||||
}
|
||||
})
|
||||
|
||||
const nextColdParkedTerminalTabIds = selectColdParkedTerminalTabs({
|
||||
worktreeId,
|
||||
terminalTabs: candidates,
|
||||
pendingStartupByTabId,
|
||||
nowMs
|
||||
})
|
||||
setColdParkedTerminalTabIds((current) =>
|
||||
haveSameTerminalTabIds(current, nextColdParkedTerminalTabIds)
|
||||
? current
|
||||
: nextColdParkedTerminalTabIds
|
||||
)
|
||||
|
||||
for (const candidate of candidates) {
|
||||
if (
|
||||
candidate.isVisible ||
|
||||
candidate.hasActivityTerminalPortal ||
|
||||
nextColdParkedTerminalTabIds.has(candidate.id)
|
||||
) {
|
||||
continue
|
||||
}
|
||||
const delayMs = getTerminalTabColdParkRecheckDelayMs({
|
||||
hiddenSinceMs: candidate.hiddenSinceMs,
|
||||
nowMs
|
||||
})
|
||||
if (delayMs !== null && delayMs > 0) {
|
||||
const tabId = candidate.id
|
||||
const timer = window.setTimeout(() => {
|
||||
timers.delete(tabId)
|
||||
setTerminalTabParkingRevision((revision) => revision + 1)
|
||||
}, delayMs)
|
||||
timers.set(tabId, timer)
|
||||
}
|
||||
}
|
||||
}, [
|
||||
activityTerminalPortals,
|
||||
assignments,
|
||||
isWorktreeActive,
|
||||
pendingStartupByTabId,
|
||||
terminalTabParkingRevision,
|
||||
terminalTabs,
|
||||
worktreeId
|
||||
])
|
||||
|
||||
if (!worktreePath) {
|
||||
return null
|
||||
}
|
||||
|
|
@ -331,12 +213,6 @@ const TerminalPaneOverlayLayer = memo(function TerminalPaneOverlayLayer({
|
|||
worktreeId,
|
||||
tabId: terminalTab.id
|
||||
})
|
||||
if (
|
||||
(coldParkTerminalPanes || (!isVisible && coldParkedTerminalTabIds.has(terminalTab.id))) &&
|
||||
!activityTerminalPortal
|
||||
) {
|
||||
return null
|
||||
}
|
||||
return (
|
||||
<TerminalOverlaySlot
|
||||
key={terminalTab.id}
|
||||
|
|
|
|||
|
|
@ -3654,264 +3654,6 @@ describe('connectPanePty', () => {
|
|||
expect(deps.syncPanePtyLayoutBinding).toHaveBeenCalledWith(2, 'pty-local-detached')
|
||||
})
|
||||
|
||||
it('restores a detached local PTY from the reattach snapshot when remounted', async () => {
|
||||
const { connectPanePty } = await import('./pty-connection')
|
||||
const transport = createMockTransport()
|
||||
transport.connect.mockImplementation(async ({ sessionId }: { sessionId?: string }) => {
|
||||
if (sessionId) {
|
||||
return { id: sessionId, snapshot: 'snapshot-after-parking' }
|
||||
}
|
||||
return null
|
||||
})
|
||||
transportFactoryQueue.push(transport)
|
||||
|
||||
mockStoreState = {
|
||||
...mockStoreState,
|
||||
tabsByWorktree: {
|
||||
'wt-1': [{ id: 'tab-1', ptyId: 'wt-1@@detached-local' }]
|
||||
},
|
||||
settings: {
|
||||
...mockStoreState.settings
|
||||
}
|
||||
} as StoreState
|
||||
|
||||
const pane = createPane(1)
|
||||
const manager = createManager(1)
|
||||
const deps = createDeps()
|
||||
|
||||
connectPanePty(pane as never, manager as never, deps as never)
|
||||
await flushAsyncTicks(20)
|
||||
|
||||
expect(transport.connect).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ sessionId: 'wt-1@@detached-local' })
|
||||
)
|
||||
expect(transport.attach).not.toHaveBeenCalled()
|
||||
expect(pane.terminal.write).toHaveBeenCalledWith('\x1b[2J\x1b[3J\x1b[H', expect.any(Function))
|
||||
expect(pane.terminal.write).toHaveBeenCalledWith('snapshot-after-parking', expect.any(Function))
|
||||
expect(deps.syncPanePtyLayoutBinding).toHaveBeenCalledWith(1, 'wt-1@@detached-local')
|
||||
})
|
||||
|
||||
it('prefers the bounded main snapshot when a parked local PTY remounts', async () => {
|
||||
const { connectPanePty } = await import('./pty-connection')
|
||||
const transport = createMockTransport('wt-1@@detached-local')
|
||||
transport.connect.mockImplementation(async ({ sessionId }: { sessionId?: string }) => {
|
||||
if (sessionId) {
|
||||
return {
|
||||
id: sessionId,
|
||||
isReattach: true,
|
||||
snapshot: 'provider-stale-snapshot'
|
||||
}
|
||||
}
|
||||
return null
|
||||
})
|
||||
transportFactoryQueue.push(transport)
|
||||
const getMainBufferSnapshot = window.api.pty.getMainBufferSnapshot as unknown as ReturnType<
|
||||
typeof vi.fn
|
||||
>
|
||||
getMainBufferSnapshot.mockResolvedValue({
|
||||
data: 'main-headless-current\r\n',
|
||||
cols: 100,
|
||||
rows: 30,
|
||||
seq: 42
|
||||
})
|
||||
|
||||
mockStoreState = {
|
||||
...mockStoreState,
|
||||
tabsByWorktree: {
|
||||
'wt-1': [{ id: 'tab-1', ptyId: 'wt-1@@detached-local' }]
|
||||
},
|
||||
settings: {
|
||||
...mockStoreState.settings
|
||||
}
|
||||
} as StoreState
|
||||
|
||||
const pane = createPane(1)
|
||||
const manager = createManager(1)
|
||||
const deps = createDeps()
|
||||
|
||||
connectPanePty(pane as never, manager as never, deps as never)
|
||||
await flushAsyncTicks(30)
|
||||
|
||||
expect(getMainBufferSnapshot).toHaveBeenCalledWith('wt-1@@detached-local', {
|
||||
scrollbackRows: 5000
|
||||
})
|
||||
expect(pane.terminal.resize).toHaveBeenCalledWith(100, 30)
|
||||
expect(pane.terminal.write).toHaveBeenCalledWith(
|
||||
'main-headless-current\r\n',
|
||||
expect.any(Function)
|
||||
)
|
||||
expect(pane.terminal.write).not.toHaveBeenCalledWith(
|
||||
'provider-stale-snapshot',
|
||||
expect.any(Function)
|
||||
)
|
||||
})
|
||||
|
||||
it('replays foreground bytes that arrive while a parked reattach snapshot is in flight', async () => {
|
||||
const { connectPanePty } = await import('./pty-connection')
|
||||
const transport = createMockTransport('wt-1@@detached-local')
|
||||
const capturedDataCallback: {
|
||||
current: ((data: string, meta?: { seq?: number; rawLength?: number }) => void) | null
|
||||
} = { current: null }
|
||||
transport.connect.mockImplementation(
|
||||
async ({ sessionId, callbacks }: { sessionId?: string; callbacks: ConnectCallbacks }) => {
|
||||
capturedDataCallback.current = callbacks.onData ?? null
|
||||
if (sessionId) {
|
||||
return {
|
||||
id: sessionId,
|
||||
isReattach: true,
|
||||
snapshot: 'provider-stale-snapshot'
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
)
|
||||
transportFactoryQueue.push(transport)
|
||||
const getMainBufferSnapshot = window.api.pty.getMainBufferSnapshot as unknown as ReturnType<
|
||||
typeof vi.fn
|
||||
>
|
||||
const snapshot = createDeferred<{ data: string; cols: number; rows: number; seq: number }>()
|
||||
getMainBufferSnapshot.mockReturnValue(snapshot.promise)
|
||||
|
||||
mockStoreState = {
|
||||
...mockStoreState,
|
||||
tabsByWorktree: {
|
||||
'wt-1': [{ id: 'tab-1', ptyId: 'wt-1@@detached-local' }]
|
||||
},
|
||||
settings: {
|
||||
...mockStoreState.settings
|
||||
}
|
||||
} as StoreState
|
||||
|
||||
const pane = createPane(1)
|
||||
const manager = createManager(1)
|
||||
const deps = createDeps()
|
||||
|
||||
connectPanePty(pane as never, manager as never, deps as never)
|
||||
await flushAsyncTicks(8)
|
||||
expect(capturedDataCallback.current).not.toBeNull()
|
||||
expect(getMainBufferSnapshot).toHaveBeenCalledTimes(1)
|
||||
|
||||
const live = 'live-after-snapshot-start\r\n'
|
||||
capturedDataCallback.current?.(live, {
|
||||
seq: 100 + live.length,
|
||||
rawLength: live.length
|
||||
})
|
||||
snapshot.resolve({
|
||||
data: 'main-before-live\r\n',
|
||||
cols: 120,
|
||||
rows: 40,
|
||||
seq: 100
|
||||
})
|
||||
await flushAsyncTicks(30)
|
||||
|
||||
expect(pane.terminal.write).toHaveBeenCalledWith('main-before-live\r\n', expect.any(Function))
|
||||
expect(pane.terminal.write).toHaveBeenCalledWith(live, expect.any(Function))
|
||||
expect(pane.terminal.write).not.toHaveBeenCalledWith(
|
||||
'provider-stale-snapshot',
|
||||
expect.any(Function)
|
||||
)
|
||||
})
|
||||
|
||||
it('does not paint stale fallback data when disposed during parked snapshot restore', async () => {
|
||||
const { connectPanePty } = await import('./pty-connection')
|
||||
const transport = createMockTransport('wt-1@@detached-local')
|
||||
transport.connect.mockImplementation(async ({ sessionId }: { sessionId?: string }) => {
|
||||
if (sessionId) {
|
||||
return {
|
||||
id: sessionId,
|
||||
isReattach: true,
|
||||
snapshot: 'provider-stale-snapshot'
|
||||
}
|
||||
}
|
||||
return null
|
||||
})
|
||||
transportFactoryQueue.push(transport)
|
||||
const getMainBufferSnapshot = window.api.pty.getMainBufferSnapshot as unknown as ReturnType<
|
||||
typeof vi.fn
|
||||
>
|
||||
const snapshot = createDeferred<{ data: string; cols: number; rows: number; seq: number }>()
|
||||
getMainBufferSnapshot.mockReturnValue(snapshot.promise)
|
||||
|
||||
mockStoreState = {
|
||||
...mockStoreState,
|
||||
tabsByWorktree: {
|
||||
'wt-1': [{ id: 'tab-1', ptyId: 'wt-1@@detached-local' }]
|
||||
},
|
||||
settings: {
|
||||
...mockStoreState.settings
|
||||
}
|
||||
} as StoreState
|
||||
|
||||
const pane = createPane(1)
|
||||
const manager = createManager(1)
|
||||
const deps = createDeps()
|
||||
|
||||
const binding = connectPanePty(pane as never, manager as never, deps as never)
|
||||
await flushAsyncTicks(8)
|
||||
expect(getMainBufferSnapshot).toHaveBeenCalledTimes(1)
|
||||
|
||||
binding.dispose()
|
||||
snapshot.resolve({
|
||||
data: 'main-after-dispose\r\n',
|
||||
cols: 120,
|
||||
rows: 40,
|
||||
seq: 100
|
||||
})
|
||||
await flushAsyncTicks(30)
|
||||
|
||||
expect(pane.terminal.write).not.toHaveBeenCalledWith(
|
||||
'main-after-dispose\r\n',
|
||||
expect.any(Function)
|
||||
)
|
||||
expect(pane.terminal.write).not.toHaveBeenCalledWith(
|
||||
'provider-stale-snapshot',
|
||||
expect.any(Function)
|
||||
)
|
||||
expect(window.api.pty.settlePaneSerializer).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps fresh cold-restore content when main has no parked reattach snapshot', async () => {
|
||||
const { connectPanePty } = await import('./pty-connection')
|
||||
const transport = createMockTransport()
|
||||
transport.connect.mockImplementation(async ({ sessionId }: { sessionId?: string }) => {
|
||||
if (sessionId) {
|
||||
return {
|
||||
id: sessionId,
|
||||
coldRestore: { scrollback: 'cold-restore-scrollback', cwd: '/tmp/wt-1' }
|
||||
}
|
||||
}
|
||||
return null
|
||||
})
|
||||
transportFactoryQueue.push(transport)
|
||||
const getMainBufferSnapshot = window.api.pty.getMainBufferSnapshot as unknown as ReturnType<
|
||||
typeof vi.fn
|
||||
>
|
||||
|
||||
mockStoreState = {
|
||||
...mockStoreState,
|
||||
tabsByWorktree: {
|
||||
'wt-1': [{ id: 'tab-1', ptyId: 'wt-1@@detached-local' }]
|
||||
},
|
||||
settings: {
|
||||
...mockStoreState.settings
|
||||
}
|
||||
} as StoreState
|
||||
|
||||
const pane = createPane(1)
|
||||
const manager = createManager(1)
|
||||
const deps = createDeps()
|
||||
|
||||
connectPanePty(pane as never, manager as never, deps as never)
|
||||
await flushAsyncTicks(20)
|
||||
|
||||
expect(getMainBufferSnapshot).not.toHaveBeenCalled()
|
||||
expect(pane.terminal.write).toHaveBeenCalledWith(
|
||||
'cold-restore-scrollback',
|
||||
expect.any(Function)
|
||||
)
|
||||
expect(window.api.pty.ackColdRestore).toHaveBeenCalledWith('wt-1@@detached-local')
|
||||
})
|
||||
|
||||
it('attaches remote runtime PTY handles instead of creating a replacement terminal', async () => {
|
||||
const { connectPanePty } = await import('./pty-connection')
|
||||
const transport = createMockTransport()
|
||||
|
|
|
|||
|
|
@ -354,29 +354,6 @@ function isSessionOwnedByWorktree(sessionId: string, worktreeId: string): boolea
|
|||
return sessionId.slice(0, separatorIdx) === worktreeId
|
||||
}
|
||||
|
||||
function shouldPreferMainBufferSnapshotForReattach(args: {
|
||||
ptyId: string
|
||||
staleSessionId?: string | null
|
||||
connectResult: PtyConnectResult | null
|
||||
}): boolean {
|
||||
if (isRemoteRuntimePtyId(args.ptyId)) {
|
||||
return false
|
||||
}
|
||||
if (
|
||||
args.connectResult?.coldRestore &&
|
||||
!args.connectResult.snapshot &&
|
||||
!args.connectResult.replay &&
|
||||
args.connectResult.isReattach !== true
|
||||
) {
|
||||
return false
|
||||
}
|
||||
return (
|
||||
args.connectResult?.isReattach === true ||
|
||||
Boolean(args.connectResult?.snapshot) ||
|
||||
args.ptyId === args.staleSessionId
|
||||
)
|
||||
}
|
||||
|
||||
function shouldWritePtyOutputForeground(isPaneVisible: boolean): boolean {
|
||||
if (!isPaneVisible) {
|
||||
return false
|
||||
|
|
@ -1641,7 +1618,6 @@ export function connectPanePty(
|
|||
let hiddenOutputRestorePendingChars = 0
|
||||
let hiddenOutputRestorePendingOverflow = false
|
||||
let hiddenOutputRestoreFreshSnapshotNeeded = false
|
||||
let mainBufferSnapshotApplyCount = 0
|
||||
// Why: hidden recovery state belongs to one PTY stream. Reattach/restart
|
||||
// can reuse the pane object for a different session before visibility.
|
||||
let hiddenOutputRestorePtyId: string | null = null
|
||||
|
|
@ -1956,7 +1932,6 @@ export function connectPanePty(
|
|||
writeReplayData('\x1b[2J\x1b[3J\x1b[H')
|
||||
writeReplayData(snapshot.data)
|
||||
writeReplayData(POST_REPLAY_LIVE_SNAPSHOT_RESET)
|
||||
mainBufferSnapshotApplyCount += 1
|
||||
recordTerminalOutput(pane.terminal)
|
||||
const currentPtyId = transport.getPtyId()
|
||||
if (currentPtyId && !getFitOverrideForPty(currentPtyId)) {
|
||||
|
|
@ -1970,10 +1945,7 @@ export function connectPanePty(
|
|||
restoreScrollStateAfterSnapshotReplay(scrollState)
|
||||
}
|
||||
|
||||
function requestHiddenOutputRestoreIfNeeded(
|
||||
opts: { showUnavailableWarning?: boolean } = {}
|
||||
): boolean {
|
||||
const showUnavailableWarning = opts.showUnavailableWarning !== false
|
||||
function requestHiddenOutputRestoreIfNeeded(): boolean {
|
||||
resetHiddenOutputRestoreIfPtyChanged()
|
||||
const ptyId = hiddenOutputRestorePtyId ?? transport.getPtyId()
|
||||
if (!hiddenOutputRestoreNeeded && hiddenOutputRestorePendingChunks.length === 0) {
|
||||
|
|
@ -1998,9 +1970,7 @@ export function connectPanePty(
|
|||
if (hiddenOutputRestorePtyId === currentPtyId) {
|
||||
clearHiddenOutputRestoreState()
|
||||
}
|
||||
if (showUnavailableWarning) {
|
||||
writeRestoreUnavailableWarning()
|
||||
}
|
||||
writeRestoreUnavailableWarning()
|
||||
return
|
||||
}
|
||||
if (transport.getPtyId() !== currentPtyId) {
|
||||
|
|
@ -2036,9 +2006,7 @@ export function connectPanePty(
|
|||
}
|
||||
if (!snapshot) {
|
||||
clearHiddenOutputRestoreState()
|
||||
if (showUnavailableWarning) {
|
||||
writeRestoreUnavailableWarning()
|
||||
}
|
||||
writeRestoreUnavailableWarning()
|
||||
return
|
||||
}
|
||||
applyMainBufferSnapshot(snapshot)
|
||||
|
|
@ -2067,34 +2035,12 @@ export function connectPanePty(
|
|||
hiddenOutputRestoreNeeded &&
|
||||
shouldWritePtyOutputForeground(deps.isVisibleRef.current)
|
||||
) {
|
||||
requestHiddenOutputRestoreIfNeeded({ showUnavailableWarning })
|
||||
requestHiddenOutputRestoreIfNeeded()
|
||||
}
|
||||
})
|
||||
return true
|
||||
}
|
||||
|
||||
async function restoreMainBufferSnapshotForReattach(ptyId: string): Promise<boolean> {
|
||||
if (!canUseMainBufferSnapshot(ptyId)) {
|
||||
return false
|
||||
}
|
||||
if (hiddenOutputRestorePtyId !== null && hiddenOutputRestorePtyId !== ptyId) {
|
||||
clearHiddenOutputRestoreState()
|
||||
}
|
||||
const applyCountBefore = mainBufferSnapshotApplyCount
|
||||
hiddenOutputRestorePtyId = ptyId
|
||||
hiddenOutputRestoreNeeded = true
|
||||
requestHiddenOutputRestoreIfNeeded({ showUnavailableWarning: false })
|
||||
const inFlight = hiddenOutputRestoreInFlight
|
||||
if (inFlight) {
|
||||
await inFlight
|
||||
}
|
||||
return (
|
||||
!disposed &&
|
||||
transport.getPtyId() === ptyId &&
|
||||
mainBufferSnapshotApplyCount > applyCountBefore
|
||||
)
|
||||
}
|
||||
|
||||
unregisterBacklogRecovery = registerTerminalBacklogRecovery(
|
||||
pane.terminal,
|
||||
requestHiddenOutputRestoreIfNeeded
|
||||
|
|
@ -2174,10 +2120,10 @@ export function connectPanePty(
|
|||
}
|
||||
}
|
||||
|
||||
const handleReattachResult = async (
|
||||
const handleReattachResult = (
|
||||
result: PtyConnectResult | string | void,
|
||||
staleSessionId?: string | null
|
||||
): Promise<void> => {
|
||||
): void => {
|
||||
if (disposed) {
|
||||
return
|
||||
}
|
||||
|
|
@ -2225,85 +2171,62 @@ export function connectPanePty(
|
|||
// main-process hydration path has full status parity.
|
||||
registerPaneSerializerFor(ptyId)
|
||||
|
||||
// Strict precedence: main snapshot > provider snapshot > replay > coldRestore.
|
||||
// Paint exactly one source per reattach. Painting snapshot AND replay
|
||||
// produced the duplicated TUI output users saw on worktree switch (the
|
||||
// relay replay buffer's tail typically overlaps with the daemon snapshot's
|
||||
// tail, so both writing into xterm doubles the same lines). Snapshot wins
|
||||
// because the main/provider authoritative buffer is freshest when present;
|
||||
// Strict precedence: snapshot > replay > coldRestore. Paint exactly
|
||||
// one source per reattach. Painting snapshot AND replay produced the
|
||||
// duplicated TUI output users saw on worktree switch (the relay replay
|
||||
// buffer's tail typically overlaps with the daemon snapshot's tail, so
|
||||
// both writing into xterm doubles the same lines). Snapshot wins
|
||||
// because the daemon's authoritative buffer is freshest when present;
|
||||
// replay wins over coldRestore because the relay's last 100 KB is
|
||||
// newer than disk-recorded scrollback. If we ever return all three,
|
||||
// the daemon and relay are by definition tracking the same session
|
||||
// and only the freshest source belongs on screen.
|
||||
const restoredMainBufferSnapshot = shouldPreferMainBufferSnapshotForReattach({
|
||||
ptyId,
|
||||
staleSessionId,
|
||||
connectResult
|
||||
})
|
||||
? await restoreMainBufferSnapshotForReattach(ptyId)
|
||||
: false
|
||||
const currentPtyIdAfterMainSnapshot = transport.getPtyId()
|
||||
if (
|
||||
disposed ||
|
||||
(currentPtyIdAfterMainSnapshot !== null && currentPtyIdAfterMainSnapshot !== ptyId)
|
||||
) {
|
||||
return
|
||||
}
|
||||
if (
|
||||
restoredMainBufferSnapshot &&
|
||||
connectResult?.coldRestore &&
|
||||
!isRemoteRuntimePtyId(ptyId)
|
||||
) {
|
||||
window.api.pty.ackColdRestore(ptyId)
|
||||
}
|
||||
if (!restoredMainBufferSnapshot) {
|
||||
if (connectResult?.snapshot) {
|
||||
writeReplayData('\x1b[2J\x1b[3J\x1b[H')
|
||||
writeReplayData(connectResult.snapshot)
|
||||
// Snapshot reattach keeps a live session, so avoid the broader mode
|
||||
// reset. We only drop stale cursor/focus state that should not leak
|
||||
// from replay bytes into the restored renderer terminal.
|
||||
writeReplayData(POST_REPLAY_REATTACH_RESET)
|
||||
if (connectResult.coldRestore) {
|
||||
// Snapshot superseded the cold-restore payload — ack it so the
|
||||
// daemon does not redeliver it on the next reattach.
|
||||
if (!isRemoteRuntimePtyId(ptyId)) {
|
||||
window.api.pty.ackColdRestore(ptyId)
|
||||
}
|
||||
}
|
||||
} else if (connectResult?.replay) {
|
||||
// Relay replay holds the last 100 KB of raw output. The xterm may
|
||||
// already hold pre-disconnect content; clear first to avoid
|
||||
// duplication. The reattach reset prevents stale cursor/focus mode
|
||||
// bits in the replayed data from leaking into the restored terminal.
|
||||
writeReplayData('\x1b[2J\x1b[3J\x1b[H')
|
||||
writeReplayData(connectResult.replay)
|
||||
writeReplayData(POST_REPLAY_REATTACH_RESET)
|
||||
if (connectResult.coldRestore) {
|
||||
if (!isRemoteRuntimePtyId(ptyId)) {
|
||||
window.api.pty.ackColdRestore(ptyId)
|
||||
}
|
||||
}
|
||||
} else if (connectResult?.coldRestore) {
|
||||
// restoreScrollbackBuffers() already wrote the saved xterm buffer
|
||||
// before this rAF ran. The cold-restore scrollback overlaps with
|
||||
// that content; clear first.
|
||||
// replayIntoTerminal: the recorded scrollback is raw PTY output that
|
||||
// may contain query sequences the previous agent CLI emitted;
|
||||
// writing them through xterm.write would trigger auto-replies that
|
||||
// land in the new shell's stdin. See replay-guard.ts.
|
||||
writeReplayData('\x1b[2J\x1b[3J\x1b[H')
|
||||
writeReplayData(connectResult.coldRestore.scrollback)
|
||||
writeReplayData('\r\n\x1b[2m--- session restored ---\x1b[0m\r\n\r\n')
|
||||
// Cold-restore means the daemon lost the session and spawned a
|
||||
// fresh shell — no TUI is consuming the mode-setting bytes that a
|
||||
// crashed TUI (e.g. Claude's \e[?1004h) left in the scrollback, so
|
||||
// reset them to match the fresh shell's expectations.
|
||||
writeReplayData(POST_REPLAY_MODE_RESET)
|
||||
if (connectResult?.snapshot) {
|
||||
writeReplayData('\x1b[2J\x1b[3J\x1b[H')
|
||||
writeReplayData(connectResult.snapshot)
|
||||
// Snapshot reattach keeps a live session, so avoid the broader mode
|
||||
// reset. We only drop stale cursor/focus state that should not leak
|
||||
// from replay bytes into the restored renderer terminal.
|
||||
writeReplayData(POST_REPLAY_REATTACH_RESET)
|
||||
if (connectResult.coldRestore) {
|
||||
// Snapshot superseded the cold-restore payload — ack it so the
|
||||
// daemon does not redeliver it on the next reattach.
|
||||
if (!isRemoteRuntimePtyId(ptyId)) {
|
||||
window.api.pty.ackColdRestore(ptyId)
|
||||
}
|
||||
}
|
||||
} else if (connectResult?.replay) {
|
||||
// Relay replay holds the last 100 KB of raw output. The xterm may
|
||||
// already hold pre-disconnect content; clear first to avoid
|
||||
// duplication. The reattach reset prevents stale cursor/focus mode
|
||||
// bits in the replayed data from leaking into the restored terminal.
|
||||
writeReplayData('\x1b[2J\x1b[3J\x1b[H')
|
||||
writeReplayData(connectResult.replay)
|
||||
writeReplayData(POST_REPLAY_REATTACH_RESET)
|
||||
if (connectResult.coldRestore) {
|
||||
if (!isRemoteRuntimePtyId(ptyId)) {
|
||||
window.api.pty.ackColdRestore(ptyId)
|
||||
}
|
||||
}
|
||||
} else if (connectResult?.coldRestore) {
|
||||
// restoreScrollbackBuffers() already wrote the saved xterm buffer
|
||||
// before this rAF ran. The cold-restore scrollback overlaps with
|
||||
// that content; clear first.
|
||||
// replayIntoTerminal: the recorded scrollback is raw PTY output that
|
||||
// may contain query sequences the previous agent CLI emitted;
|
||||
// writing them through xterm.write would trigger auto-replies that
|
||||
// land in the new shell's stdin. See replay-guard.ts.
|
||||
writeReplayData('\x1b[2J\x1b[3J\x1b[H')
|
||||
writeReplayData(connectResult.coldRestore.scrollback)
|
||||
writeReplayData('\r\n\x1b[2m--- session restored ---\x1b[0m\r\n\r\n')
|
||||
// Cold-restore means the daemon lost the session and spawned a
|
||||
// fresh shell — no TUI is consuming the mode-setting bytes that a
|
||||
// crashed TUI (e.g. Claude's \e[?1004h) left in the scrollback, so
|
||||
// reset them to match the fresh shell's expectations.
|
||||
writeReplayData(POST_REPLAY_MODE_RESET)
|
||||
if (!isRemoteRuntimePtyId(ptyId)) {
|
||||
window.api.pty.ackColdRestore(ptyId)
|
||||
}
|
||||
}
|
||||
// Why: when a mobile-fit override is active, skip sending desktop dims
|
||||
// to the PTY — the PTY is already at phone dimensions and must stay there.
|
||||
|
|
@ -2516,7 +2439,7 @@ export function connectPanePty(
|
|||
)
|
||||
if (!result && expiredReattachError) {
|
||||
const gen = await preSignalPromise
|
||||
if (typeof gen === 'number' && typeof window !== 'undefined') {
|
||||
if (typeof gen === 'number') {
|
||||
void window.api.pty.clearPendingPaneSerializer(cacheKey, gen).catch(() => {})
|
||||
}
|
||||
if (disposed) {
|
||||
|
|
@ -2527,11 +2450,8 @@ export function connectPanePty(
|
|||
startFreshSpawn()
|
||||
return
|
||||
}
|
||||
await handleReattachResult(result, pendingSessionId)
|
||||
handleReattachResult(result, pendingSessionId)
|
||||
const gen = await preSignalPromise
|
||||
if (disposed || typeof window === 'undefined') {
|
||||
return
|
||||
}
|
||||
if (typeof gen === 'number') {
|
||||
if (!isRemoteRuntimePtyId(pendingSessionId)) {
|
||||
void window.api.pty.settlePaneSerializer(cacheKey, gen).catch(() => {})
|
||||
|
|
@ -2540,7 +2460,7 @@ export function connectPanePty(
|
|||
})
|
||||
.catch(async (err) => {
|
||||
const gen = await preSignalPromise
|
||||
if (typeof gen === 'number' && typeof window !== 'undefined') {
|
||||
if (typeof gen === 'number') {
|
||||
void window.api.pty.clearPendingPaneSerializer(cacheKey, gen).catch(() => {})
|
||||
}
|
||||
console.warn(`[pty-connection] Reattach FAILED for tab=${deps.tabId}:`, err)
|
||||
|
|
@ -2645,7 +2565,7 @@ export function connectPanePty(
|
|||
.then(async (result) => {
|
||||
if (!result && expiredReattachError) {
|
||||
const gen = await preSignalPromise
|
||||
if (typeof gen === 'number' && typeof window !== 'undefined') {
|
||||
if (typeof gen === 'number') {
|
||||
void window.api.pty.clearPendingPaneSerializer(cacheKey, gen).catch(() => {})
|
||||
}
|
||||
if (disposed) {
|
||||
|
|
@ -2656,11 +2576,8 @@ export function connectPanePty(
|
|||
startFreshSpawn()
|
||||
return
|
||||
}
|
||||
await handleReattachResult(result, deferredReattachSessionId)
|
||||
handleReattachResult(result, deferredReattachSessionId)
|
||||
const gen = await preSignalPromise
|
||||
if (disposed || typeof window === 'undefined') {
|
||||
return
|
||||
}
|
||||
if (typeof gen === 'number') {
|
||||
if (!isRemoteRuntimePtyId(deferredReattachSessionId)) {
|
||||
void window.api.pty.settlePaneSerializer(cacheKey, gen).catch(() => {})
|
||||
|
|
@ -2669,7 +2586,7 @@ export function connectPanePty(
|
|||
})
|
||||
.catch(async (err) => {
|
||||
const gen = await preSignalPromise
|
||||
if (typeof gen === 'number' && typeof window !== 'undefined') {
|
||||
if (typeof gen === 'number') {
|
||||
void window.api.pty.clearPendingPaneSerializer(cacheKey, gen).catch(() => {})
|
||||
}
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
|
|
|
|||
|
|
@ -265,7 +265,6 @@ export type PtyConnectResult = {
|
|||
snapshotCols?: number
|
||||
snapshotRows?: number
|
||||
isAlternateScreen?: boolean
|
||||
isReattach?: boolean
|
||||
sessionExpired?: boolean
|
||||
coldRestore?: { scrollback: string; cwd: string }
|
||||
replay?: string
|
||||
|
|
|
|||
|
|
@ -651,7 +651,6 @@ describe('createIpcPtyTransport', () => {
|
|||
snapshotCols: 132,
|
||||
snapshotRows: 43,
|
||||
isAlternateScreen: undefined,
|
||||
isReattach: true,
|
||||
coldRestore: undefined,
|
||||
replay: undefined,
|
||||
sessionExpired: undefined
|
||||
|
|
|
|||
|
|
@ -577,7 +577,6 @@ export function createIpcPtyTransport(opts: IpcPtyTransportOptions = {}): PtyTra
|
|||
snapshotCols: spawnResult.snapshotCols,
|
||||
snapshotRows: spawnResult.snapshotRows,
|
||||
isAlternateScreen: spawnResult.isAlternateScreen,
|
||||
isReattach: spawnResult.isReattach,
|
||||
sessionExpired: spawnResult.sessionExpired,
|
||||
coldRestore: spawnResult.coldRestore,
|
||||
replay: spawnResult.replay
|
||||
|
|
|
|||
|
|
@ -1,147 +0,0 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
TERMINAL_TAB_HOT_RETAIN_MS,
|
||||
TERMINAL_WORKTREE_PARK_DELAY_MS,
|
||||
getTerminalTabColdParkRecheckDelayMs,
|
||||
selectColdParkedTerminalTabs
|
||||
} from './terminal-worktree-parking'
|
||||
|
||||
describe('selectColdParkedTerminalTabs', () => {
|
||||
const nowMs = 500_000
|
||||
|
||||
function localTab(id: string, hiddenSinceMs: number) {
|
||||
return {
|
||||
id,
|
||||
ptyId: `wt-1@@session-${id}`,
|
||||
pendingActivationSpawn: false,
|
||||
isVisible: false,
|
||||
hasActivityTerminalPortal: false,
|
||||
hiddenSinceMs
|
||||
}
|
||||
}
|
||||
|
||||
it('keeps visible and recent inactive terminal tabs mounted', () => {
|
||||
const selected = selectColdParkedTerminalTabs({
|
||||
worktreeId: 'wt-1',
|
||||
terminalTabs: [
|
||||
{ ...localTab('tab-visible', nowMs - TERMINAL_WORKTREE_PARK_DELAY_MS), isVisible: true },
|
||||
localTab('tab-recent-1', nowMs - TERMINAL_WORKTREE_PARK_DELAY_MS),
|
||||
localTab('tab-recent-2', nowMs - TERMINAL_WORKTREE_PARK_DELAY_MS - 1)
|
||||
],
|
||||
pendingStartupByTabId: {},
|
||||
nowMs,
|
||||
hotRetainLimit: 2
|
||||
})
|
||||
|
||||
expect(selected).toEqual(new Set())
|
||||
})
|
||||
|
||||
it('cold-parks the oldest inactive local tabs beyond the retain limit', () => {
|
||||
const selected = selectColdParkedTerminalTabs({
|
||||
worktreeId: 'wt-1',
|
||||
terminalTabs: [
|
||||
localTab('tab-1', nowMs - TERMINAL_WORKTREE_PARK_DELAY_MS),
|
||||
localTab('tab-2', nowMs - TERMINAL_WORKTREE_PARK_DELAY_MS - 1),
|
||||
localTab('tab-3', nowMs - TERMINAL_WORKTREE_PARK_DELAY_MS - 2)
|
||||
],
|
||||
pendingStartupByTabId: {},
|
||||
nowMs,
|
||||
hotRetainLimit: 2
|
||||
})
|
||||
|
||||
expect(selected).toEqual(new Set(['tab-3']))
|
||||
})
|
||||
|
||||
it('cold-parks aged inactive local tabs even when under the retain limit', () => {
|
||||
const selected = selectColdParkedTerminalTabs({
|
||||
worktreeId: 'wt-1',
|
||||
terminalTabs: [localTab('tab-1', nowMs - TERMINAL_TAB_HOT_RETAIN_MS)],
|
||||
pendingStartupByTabId: {},
|
||||
nowMs,
|
||||
hotRetainLimit: 12
|
||||
})
|
||||
|
||||
expect(selected).toEqual(new Set(['tab-1']))
|
||||
})
|
||||
|
||||
it('does not cold-park inactive terminal tabs without local snapshot recovery', () => {
|
||||
const selected = selectColdParkedTerminalTabs({
|
||||
worktreeId: 'wt-1',
|
||||
terminalTabs: [
|
||||
localTab('tab-local', nowMs - TERMINAL_TAB_HOT_RETAIN_MS),
|
||||
{
|
||||
...localTab('tab-ssh', nowMs - TERMINAL_TAB_HOT_RETAIN_MS),
|
||||
ptyId: 'ssh:ssh-1@@pty-1'
|
||||
},
|
||||
{
|
||||
...localTab('tab-remote', nowMs - TERMINAL_TAB_HOT_RETAIN_MS),
|
||||
ptyId: 'remote:env-1@@terminal-1'
|
||||
}
|
||||
],
|
||||
pendingStartupByTabId: {},
|
||||
nowMs,
|
||||
hotRetainLimit: 0
|
||||
})
|
||||
|
||||
expect(selected).toEqual(new Set(['tab-local']))
|
||||
})
|
||||
|
||||
it('keeps portaled, pending-startup, and pending-activation terminal tabs mounted', () => {
|
||||
const selected = selectColdParkedTerminalTabs({
|
||||
worktreeId: 'wt-1',
|
||||
terminalTabs: [
|
||||
{
|
||||
...localTab('tab-portal', nowMs - TERMINAL_TAB_HOT_RETAIN_MS),
|
||||
hasActivityTerminalPortal: true
|
||||
},
|
||||
localTab('tab-startup', nowMs - TERMINAL_TAB_HOT_RETAIN_MS),
|
||||
{
|
||||
...localTab('tab-activation', nowMs - TERMINAL_TAB_HOT_RETAIN_MS),
|
||||
pendingActivationSpawn: true
|
||||
}
|
||||
],
|
||||
pendingStartupByTabId: { 'tab-startup': { command: 'echo pending' } },
|
||||
nowMs,
|
||||
hotRetainLimit: 0
|
||||
})
|
||||
|
||||
expect(selected).toEqual(new Set())
|
||||
})
|
||||
})
|
||||
|
||||
describe('getTerminalTabColdParkRecheckDelayMs', () => {
|
||||
it('returns the next terminal-tab cold-park policy deadline', () => {
|
||||
expect(
|
||||
getTerminalTabColdParkRecheckDelayMs({
|
||||
hiddenSinceMs: null,
|
||||
nowMs: 1_000,
|
||||
coldParkDelayMs: 100,
|
||||
hotRetainMs: 1_000
|
||||
})
|
||||
).toBeNull()
|
||||
expect(
|
||||
getTerminalTabColdParkRecheckDelayMs({
|
||||
hiddenSinceMs: 1_000,
|
||||
nowMs: 1_050,
|
||||
coldParkDelayMs: 100,
|
||||
hotRetainMs: 1_000
|
||||
})
|
||||
).toBe(50)
|
||||
expect(
|
||||
getTerminalTabColdParkRecheckDelayMs({
|
||||
hiddenSinceMs: 1_000,
|
||||
nowMs: 1_100,
|
||||
coldParkDelayMs: 100,
|
||||
hotRetainMs: 1_000
|
||||
})
|
||||
).toBe(900)
|
||||
expect(
|
||||
getTerminalTabColdParkRecheckDelayMs({
|
||||
hiddenSinceMs: 1_000,
|
||||
nowMs: 2_000,
|
||||
coldParkDelayMs: 100,
|
||||
hotRetainMs: 1_000
|
||||
})
|
||||
).toBeNull()
|
||||
})
|
||||
})
|
||||
|
|
@ -1,283 +0,0 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
TERMINAL_WORKTREE_HOT_RETAIN_MS,
|
||||
TERMINAL_WORKTREE_PARK_DELAY_MS,
|
||||
canParkTerminalWorktreeRenderers,
|
||||
getTerminalWorktreeColdParkRecheckDelayMs,
|
||||
selectColdParkedTerminalWorktrees,
|
||||
isSnapshotBackedTerminalPty
|
||||
} from './terminal-worktree-parking'
|
||||
|
||||
describe('isSnapshotBackedTerminalPty', () => {
|
||||
it('allows local daemon sessions owned by the worktree', () => {
|
||||
expect(isSnapshotBackedTerminalPty('repo::/worktree@@session-1', 'repo::/worktree')).toBe(true)
|
||||
expect(isSnapshotBackedTerminalPty('wt-1@@session-1', 'wt-1')).toBe(true)
|
||||
})
|
||||
|
||||
it('allows legacy local PTY IDs that reattach through the local session path', () => {
|
||||
expect(isSnapshotBackedTerminalPty('pty-local-detached', 'repo::/worktree')).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects tabs that do not have a PTY yet', () => {
|
||||
expect(isSnapshotBackedTerminalPty(null, 'repo::/worktree')).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects daemon sessions owned by another worktree', () => {
|
||||
expect(isSnapshotBackedTerminalPty('repo::/other@@session-1', 'repo::/worktree')).toBe(false)
|
||||
expect(isSnapshotBackedTerminalPty('wt-2@@session-1', 'wt-1')).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects SSH and remote runtime PTY handles', () => {
|
||||
expect(isSnapshotBackedTerminalPty('ssh:ssh-1@@pty-1', 'repo::/worktree')).toBe(false)
|
||||
expect(isSnapshotBackedTerminalPty('remote:env-1@@terminal-1', 'repo::/worktree')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('canParkTerminalWorktreeRenderers', () => {
|
||||
const hiddenSinceMs = 1_000
|
||||
const nowMs = hiddenSinceMs + TERMINAL_WORKTREE_PARK_DELAY_MS
|
||||
|
||||
it('parks hidden local terminal renderers after the idle delay', () => {
|
||||
expect(
|
||||
canParkTerminalWorktreeRenderers({
|
||||
worktreeId: 'repo::/worktree',
|
||||
terminalTabs: [{ id: 'tab-1', ptyId: 'repo::/worktree@@session-1' }],
|
||||
pendingStartupByTabId: {},
|
||||
isVisible: false,
|
||||
shouldMeasureHiddenWorktree: false,
|
||||
hasActivityTerminalPortal: false,
|
||||
hiddenSinceMs,
|
||||
nowMs
|
||||
})
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('keeps renderers mounted while visible, measuring, portaled, or before the delay', () => {
|
||||
const base = {
|
||||
worktreeId: 'repo::/worktree',
|
||||
terminalTabs: [{ id: 'tab-1', ptyId: 'repo::/worktree@@session-1' }],
|
||||
pendingStartupByTabId: {},
|
||||
isVisible: false,
|
||||
shouldMeasureHiddenWorktree: false,
|
||||
hasActivityTerminalPortal: false,
|
||||
hiddenSinceMs,
|
||||
nowMs
|
||||
}
|
||||
|
||||
expect(canParkTerminalWorktreeRenderers({ ...base, isVisible: true })).toBe(false)
|
||||
expect(canParkTerminalWorktreeRenderers({ ...base, shouldMeasureHiddenWorktree: true })).toBe(
|
||||
false
|
||||
)
|
||||
expect(canParkTerminalWorktreeRenderers({ ...base, hasActivityTerminalPortal: true })).toBe(
|
||||
false
|
||||
)
|
||||
expect(
|
||||
canParkTerminalWorktreeRenderers({
|
||||
...base,
|
||||
nowMs: hiddenSinceMs + TERMINAL_WORKTREE_PARK_DELAY_MS - 1
|
||||
})
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('keeps the renderer mounted when any terminal lacks snapshot-backed restore', () => {
|
||||
expect(
|
||||
canParkTerminalWorktreeRenderers({
|
||||
worktreeId: 'repo::/worktree',
|
||||
terminalTabs: [
|
||||
{ id: 'tab-1', ptyId: 'repo::/worktree@@session-1' },
|
||||
{ id: 'tab-2', ptyId: 'ssh:ssh-1@@pty-1' }
|
||||
],
|
||||
pendingStartupByTabId: {},
|
||||
isVisible: false,
|
||||
shouldMeasureHiddenWorktree: false,
|
||||
hasActivityTerminalPortal: false,
|
||||
hiddenSinceMs,
|
||||
nowMs
|
||||
})
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('keeps renderers mounted while a tab has startup or activation work pending', () => {
|
||||
const base = {
|
||||
worktreeId: 'repo::/worktree',
|
||||
terminalTabs: [{ id: 'tab-1', ptyId: 'repo::/worktree@@session-1' }],
|
||||
pendingStartupByTabId: {},
|
||||
isVisible: false,
|
||||
shouldMeasureHiddenWorktree: false,
|
||||
hasActivityTerminalPortal: false,
|
||||
hiddenSinceMs,
|
||||
nowMs
|
||||
}
|
||||
|
||||
expect(
|
||||
canParkTerminalWorktreeRenderers({
|
||||
...base,
|
||||
pendingStartupByTabId: { 'tab-1': { command: 'echo pending' } }
|
||||
})
|
||||
).toBe(false)
|
||||
expect(
|
||||
canParkTerminalWorktreeRenderers({
|
||||
...base,
|
||||
terminalTabs: [
|
||||
{ id: 'tab-1', ptyId: 'repo::/worktree@@session-1', pendingActivationSpawn: true }
|
||||
]
|
||||
})
|
||||
).toBe(false)
|
||||
expect(
|
||||
canParkTerminalWorktreeRenderers({
|
||||
...base,
|
||||
terminalTabs: [
|
||||
{ id: 'tab-1', ptyId: 'repo::/worktree@@session-1', pendingActivationSpawn: 2 }
|
||||
]
|
||||
})
|
||||
).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('selectColdParkedTerminalWorktrees', () => {
|
||||
const nowMs = 500_000
|
||||
|
||||
function localCandidate(worktreeId: string, hiddenSinceMs: number) {
|
||||
return {
|
||||
worktreeId,
|
||||
terminalTabs: [{ id: `tab-${worktreeId}`, ptyId: `${worktreeId}@@session-1` }],
|
||||
isVisible: false,
|
||||
shouldMeasureHiddenWorktree: false,
|
||||
hasActivityTerminalPortal: false,
|
||||
hiddenSinceMs
|
||||
}
|
||||
}
|
||||
|
||||
it('keeps recent hidden local worktrees hot up to the retain limit', () => {
|
||||
const selected = selectColdParkedTerminalWorktrees({
|
||||
worktrees: [
|
||||
localCandidate('wt-1', nowMs - TERMINAL_WORKTREE_PARK_DELAY_MS),
|
||||
localCandidate('wt-2', nowMs - TERMINAL_WORKTREE_PARK_DELAY_MS - 1)
|
||||
],
|
||||
pendingStartupByTabId: {},
|
||||
nowMs,
|
||||
hotRetainLimit: 2
|
||||
})
|
||||
|
||||
expect(selected).toEqual(new Set())
|
||||
})
|
||||
|
||||
it('cold-parks the oldest hidden local worktrees beyond the retain limit', () => {
|
||||
const selected = selectColdParkedTerminalWorktrees({
|
||||
worktrees: [
|
||||
localCandidate('wt-1', nowMs - TERMINAL_WORKTREE_PARK_DELAY_MS),
|
||||
localCandidate('wt-2', nowMs - TERMINAL_WORKTREE_PARK_DELAY_MS - 1),
|
||||
localCandidate('wt-3', nowMs - TERMINAL_WORKTREE_PARK_DELAY_MS - 2)
|
||||
],
|
||||
pendingStartupByTabId: {},
|
||||
nowMs,
|
||||
hotRetainLimit: 2
|
||||
})
|
||||
|
||||
expect(selected).toEqual(new Set(['wt-3']))
|
||||
})
|
||||
|
||||
it('cold-parks aged local worktrees even when under the retain limit', () => {
|
||||
const selected = selectColdParkedTerminalWorktrees({
|
||||
worktrees: [localCandidate('wt-1', nowMs - TERMINAL_WORKTREE_HOT_RETAIN_MS)],
|
||||
pendingStartupByTabId: {},
|
||||
nowMs,
|
||||
hotRetainLimit: 4
|
||||
})
|
||||
|
||||
expect(selected).toEqual(new Set(['wt-1']))
|
||||
})
|
||||
|
||||
it('does not cold-park terminals without local snapshot recovery', () => {
|
||||
const selected = selectColdParkedTerminalWorktrees({
|
||||
worktrees: [
|
||||
localCandidate('wt-local', nowMs - TERMINAL_WORKTREE_HOT_RETAIN_MS),
|
||||
{
|
||||
...localCandidate('wt-ssh', nowMs - TERMINAL_WORKTREE_HOT_RETAIN_MS),
|
||||
terminalTabs: [{ id: 'tab-ssh', ptyId: 'ssh:ssh-1@@pty-1' }]
|
||||
},
|
||||
{
|
||||
...localCandidate('wt-remote', nowMs - TERMINAL_WORKTREE_HOT_RETAIN_MS),
|
||||
terminalTabs: [{ id: 'tab-remote', ptyId: 'remote:env-1@@terminal-1' }]
|
||||
}
|
||||
],
|
||||
pendingStartupByTabId: {},
|
||||
nowMs,
|
||||
hotRetainLimit: 0
|
||||
})
|
||||
|
||||
expect(selected).toEqual(new Set(['wt-local']))
|
||||
})
|
||||
|
||||
it('keeps visible, measuring, portaled, and pending terminals mounted', () => {
|
||||
const selected = selectColdParkedTerminalWorktrees({
|
||||
worktrees: [
|
||||
{
|
||||
...localCandidate('wt-visible', nowMs - TERMINAL_WORKTREE_HOT_RETAIN_MS),
|
||||
isVisible: true
|
||||
},
|
||||
{
|
||||
...localCandidate('wt-measuring', nowMs - TERMINAL_WORKTREE_HOT_RETAIN_MS),
|
||||
shouldMeasureHiddenWorktree: true
|
||||
},
|
||||
{
|
||||
...localCandidate('wt-portal', nowMs - TERMINAL_WORKTREE_HOT_RETAIN_MS),
|
||||
hasActivityTerminalPortal: true
|
||||
},
|
||||
{
|
||||
...localCandidate('wt-activation', nowMs - TERMINAL_WORKTREE_HOT_RETAIN_MS),
|
||||
terminalTabs: [
|
||||
{
|
||||
id: 'tab-activation',
|
||||
ptyId: 'wt-activation@@session-1',
|
||||
pendingActivationSpawn: true
|
||||
}
|
||||
]
|
||||
},
|
||||
localCandidate('wt-startup', nowMs - TERMINAL_WORKTREE_HOT_RETAIN_MS)
|
||||
],
|
||||
pendingStartupByTabId: { 'tab-wt-startup': { command: 'echo pending' } },
|
||||
nowMs,
|
||||
hotRetainLimit: 0
|
||||
})
|
||||
|
||||
expect(selected).toEqual(new Set())
|
||||
})
|
||||
})
|
||||
|
||||
describe('getTerminalWorktreeColdParkRecheckDelayMs', () => {
|
||||
it('returns the next cold-park policy deadline', () => {
|
||||
expect(
|
||||
getTerminalWorktreeColdParkRecheckDelayMs({
|
||||
hiddenSinceMs: null,
|
||||
nowMs: 1_000,
|
||||
coldParkDelayMs: 100,
|
||||
hotRetainMs: 1_000
|
||||
})
|
||||
).toBeNull()
|
||||
expect(
|
||||
getTerminalWorktreeColdParkRecheckDelayMs({
|
||||
hiddenSinceMs: 1_000,
|
||||
nowMs: 1_050,
|
||||
coldParkDelayMs: 100,
|
||||
hotRetainMs: 1_000
|
||||
})
|
||||
).toBe(50)
|
||||
expect(
|
||||
getTerminalWorktreeColdParkRecheckDelayMs({
|
||||
hiddenSinceMs: 1_000,
|
||||
nowMs: 1_100,
|
||||
coldParkDelayMs: 100,
|
||||
hotRetainMs: 1_000
|
||||
})
|
||||
).toBe(900)
|
||||
expect(
|
||||
getTerminalWorktreeColdParkRecheckDelayMs({
|
||||
hiddenSinceMs: 1_000,
|
||||
nowMs: 2_000,
|
||||
coldParkDelayMs: 100,
|
||||
hotRetainMs: 1_000
|
||||
})
|
||||
).toBeNull()
|
||||
})
|
||||
})
|
||||
|
|
@ -1,244 +0,0 @@
|
|||
import { PTY_SESSION_ID_SEPARATOR } from '../../../../shared/pty-session-id-format'
|
||||
import { parseAppSshPtyId } from '../../../../shared/ssh-pty-id'
|
||||
import type { TerminalTab } from '../../../../shared/types'
|
||||
import { isRemoteRuntimePtyId } from '../../runtime/runtime-terminal-inspection'
|
||||
|
||||
export const TERMINAL_WORKTREE_COLD_PARK_DELAY_MS = 30_000
|
||||
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 = 5 * 60_000
|
||||
export const TERMINAL_TAB_HOT_RETAIN_LIMIT = 12
|
||||
|
||||
export type ColdParkableTerminalTab = Pick<TerminalTab, 'id' | 'ptyId' | 'pendingActivationSpawn'>
|
||||
|
||||
export type TerminalWorktreeColdParkCandidate = {
|
||||
worktreeId: string
|
||||
terminalTabs: readonly ColdParkableTerminalTab[]
|
||||
isVisible: boolean
|
||||
shouldMeasureHiddenWorktree: boolean
|
||||
hasActivityTerminalPortal: boolean
|
||||
hiddenSinceMs: number | null
|
||||
}
|
||||
|
||||
export type TerminalTabColdParkCandidate = ColdParkableTerminalTab & {
|
||||
isVisible: boolean
|
||||
hasActivityTerminalPortal: boolean
|
||||
hiddenSinceMs: number | null
|
||||
}
|
||||
|
||||
function getPendingActivationSpawnCount(value: boolean | number | undefined): number {
|
||||
if (value === true) {
|
||||
return 1
|
||||
}
|
||||
return typeof value === 'number' && value > 0 ? value : 0
|
||||
}
|
||||
|
||||
export function isSnapshotBackedTerminalPty(ptyId: string | null, worktreeId: string): boolean {
|
||||
if (!ptyId) {
|
||||
return false
|
||||
}
|
||||
if (isRemoteRuntimePtyId(ptyId) || parseAppSshPtyId(ptyId)) {
|
||||
return false
|
||||
}
|
||||
const separatorIdx = ptyId.lastIndexOf(PTY_SESSION_ID_SEPARATOR)
|
||||
return separatorIdx === -1 || ptyId.slice(0, separatorIdx) === worktreeId
|
||||
}
|
||||
|
||||
export function canParkTerminalWorktreeRenderers(args: {
|
||||
worktreeId: string
|
||||
terminalTabs: readonly ColdParkableTerminalTab[]
|
||||
pendingStartupByTabId: Readonly<Record<string, unknown>>
|
||||
isVisible: boolean
|
||||
shouldMeasureHiddenWorktree: boolean
|
||||
hasActivityTerminalPortal: boolean
|
||||
hiddenSinceMs: number | null
|
||||
nowMs: number
|
||||
coldParkDelayMs?: number
|
||||
}): boolean {
|
||||
if (
|
||||
args.isVisible ||
|
||||
args.shouldMeasureHiddenWorktree ||
|
||||
args.hasActivityTerminalPortal ||
|
||||
args.hiddenSinceMs === null
|
||||
) {
|
||||
return false
|
||||
}
|
||||
if (
|
||||
args.nowMs - args.hiddenSinceMs <
|
||||
(args.coldParkDelayMs ?? TERMINAL_WORKTREE_COLD_PARK_DELAY_MS)
|
||||
) {
|
||||
return false
|
||||
}
|
||||
return args.terminalTabs.every((tab) => {
|
||||
if (args.pendingStartupByTabId[tab.id] !== undefined) {
|
||||
return false
|
||||
}
|
||||
if (getPendingActivationSpawnCount(tab.pendingActivationSpawn) > 0) {
|
||||
return false
|
||||
}
|
||||
return isSnapshotBackedTerminalPty(tab.ptyId, args.worktreeId)
|
||||
})
|
||||
}
|
||||
|
||||
export function canParkTerminalTabRenderer(args: {
|
||||
worktreeId: string
|
||||
terminalTab: TerminalTabColdParkCandidate
|
||||
pendingStartupByTabId: Readonly<Record<string, unknown>>
|
||||
nowMs: number
|
||||
coldParkDelayMs?: number
|
||||
}): boolean {
|
||||
const tab = args.terminalTab
|
||||
if (tab.isVisible || tab.hasActivityTerminalPortal || tab.hiddenSinceMs === null) {
|
||||
return false
|
||||
}
|
||||
if (args.nowMs - tab.hiddenSinceMs < (args.coldParkDelayMs ?? TERMINAL_TAB_COLD_PARK_DELAY_MS)) {
|
||||
return false
|
||||
}
|
||||
if (args.pendingStartupByTabId[tab.id] !== undefined) {
|
||||
return false
|
||||
}
|
||||
if (getPendingActivationSpawnCount(tab.pendingActivationSpawn) > 0) {
|
||||
return false
|
||||
}
|
||||
return isSnapshotBackedTerminalPty(tab.ptyId, args.worktreeId)
|
||||
}
|
||||
|
||||
export function selectColdParkedTerminalWorktrees(args: {
|
||||
worktrees: readonly TerminalWorktreeColdParkCandidate[]
|
||||
pendingStartupByTabId: Readonly<Record<string, unknown>>
|
||||
nowMs: number
|
||||
coldParkDelayMs?: number
|
||||
hotRetainMs?: number
|
||||
hotRetainLimit?: number
|
||||
}): Set<string> {
|
||||
const coldParkDelayMs = args.coldParkDelayMs ?? TERMINAL_WORKTREE_COLD_PARK_DELAY_MS
|
||||
const hotRetainMs = args.hotRetainMs ?? TERMINAL_WORKTREE_HOT_RETAIN_MS
|
||||
const hotRetainLimit = Math.max(0, args.hotRetainLimit ?? TERMINAL_WORKTREE_HOT_RETAIN_LIMIT)
|
||||
const coldParkedWorktreeIds = new Set<string>()
|
||||
const retainedCandidates: { worktreeId: string; hiddenSinceMs: number }[] = []
|
||||
|
||||
for (const worktree of args.worktrees) {
|
||||
if (
|
||||
!canParkTerminalWorktreeRenderers({
|
||||
...worktree,
|
||||
pendingStartupByTabId: args.pendingStartupByTabId,
|
||||
nowMs: args.nowMs,
|
||||
coldParkDelayMs
|
||||
})
|
||||
) {
|
||||
continue
|
||||
}
|
||||
const hiddenSinceMs = worktree.hiddenSinceMs
|
||||
if (hiddenSinceMs === null) {
|
||||
continue
|
||||
}
|
||||
if (args.nowMs - hiddenSinceMs >= hotRetainMs) {
|
||||
coldParkedWorktreeIds.add(worktree.worktreeId)
|
||||
continue
|
||||
}
|
||||
retainedCandidates.push({
|
||||
worktreeId: worktree.worktreeId,
|
||||
hiddenSinceMs
|
||||
})
|
||||
}
|
||||
|
||||
retainedCandidates.sort((a, b) => {
|
||||
const recencyDelta = b.hiddenSinceMs - a.hiddenSinceMs
|
||||
return recencyDelta === 0 ? a.worktreeId.localeCompare(b.worktreeId) : recencyDelta
|
||||
})
|
||||
|
||||
for (const candidate of retainedCandidates.slice(hotRetainLimit)) {
|
||||
coldParkedWorktreeIds.add(candidate.worktreeId)
|
||||
}
|
||||
|
||||
return coldParkedWorktreeIds
|
||||
}
|
||||
|
||||
export function selectColdParkedTerminalTabs(args: {
|
||||
worktreeId: string
|
||||
terminalTabs: readonly TerminalTabColdParkCandidate[]
|
||||
pendingStartupByTabId: Readonly<Record<string, unknown>>
|
||||
nowMs: number
|
||||
coldParkDelayMs?: number
|
||||
hotRetainMs?: number
|
||||
hotRetainLimit?: number
|
||||
}): Set<string> {
|
||||
const coldParkDelayMs = args.coldParkDelayMs ?? TERMINAL_TAB_COLD_PARK_DELAY_MS
|
||||
const hotRetainMs = args.hotRetainMs ?? TERMINAL_TAB_HOT_RETAIN_MS
|
||||
const hotRetainLimit = Math.max(0, args.hotRetainLimit ?? TERMINAL_TAB_HOT_RETAIN_LIMIT)
|
||||
const coldParkedTabIds = new Set<string>()
|
||||
const retainedCandidates: { tabId: string; hiddenSinceMs: number }[] = []
|
||||
|
||||
for (const tab of args.terminalTabs) {
|
||||
if (
|
||||
!canParkTerminalTabRenderer({
|
||||
worktreeId: args.worktreeId,
|
||||
terminalTab: tab,
|
||||
pendingStartupByTabId: args.pendingStartupByTabId,
|
||||
nowMs: args.nowMs,
|
||||
coldParkDelayMs
|
||||
})
|
||||
) {
|
||||
continue
|
||||
}
|
||||
const hiddenSinceMs = tab.hiddenSinceMs
|
||||
if (hiddenSinceMs === null) {
|
||||
continue
|
||||
}
|
||||
if (args.nowMs - hiddenSinceMs >= hotRetainMs) {
|
||||
coldParkedTabIds.add(tab.id)
|
||||
continue
|
||||
}
|
||||
retainedCandidates.push({
|
||||
tabId: tab.id,
|
||||
hiddenSinceMs
|
||||
})
|
||||
}
|
||||
|
||||
retainedCandidates.sort((a, b) => {
|
||||
const recencyDelta = b.hiddenSinceMs - a.hiddenSinceMs
|
||||
return recencyDelta === 0 ? a.tabId.localeCompare(b.tabId) : recencyDelta
|
||||
})
|
||||
|
||||
for (const candidate of retainedCandidates.slice(hotRetainLimit)) {
|
||||
coldParkedTabIds.add(candidate.tabId)
|
||||
}
|
||||
|
||||
return coldParkedTabIds
|
||||
}
|
||||
|
||||
export function getTerminalWorktreeColdParkRecheckDelayMs(args: {
|
||||
hiddenSinceMs: number | null
|
||||
nowMs: number
|
||||
coldParkDelayMs?: number
|
||||
hotRetainMs?: number
|
||||
}): number | null {
|
||||
if (args.hiddenSinceMs === null) {
|
||||
return null
|
||||
}
|
||||
const coldParkDelayMs = args.coldParkDelayMs ?? TERMINAL_WORKTREE_COLD_PARK_DELAY_MS
|
||||
const hotRetainMs = args.hotRetainMs ?? TERMINAL_WORKTREE_HOT_RETAIN_MS
|
||||
const nextRecheckAtMs = [args.hiddenSinceMs + coldParkDelayMs, args.hiddenSinceMs + hotRetainMs]
|
||||
.filter((deadlineMs) => deadlineMs > args.nowMs)
|
||||
.sort((a, b) => a - b)[0]
|
||||
return nextRecheckAtMs === undefined ? null : nextRecheckAtMs - args.nowMs
|
||||
}
|
||||
|
||||
export function getTerminalTabColdParkRecheckDelayMs(args: {
|
||||
hiddenSinceMs: number | null
|
||||
nowMs: number
|
||||
coldParkDelayMs?: number
|
||||
hotRetainMs?: number
|
||||
}): number | null {
|
||||
if (args.hiddenSinceMs === null) {
|
||||
return null
|
||||
}
|
||||
const coldParkDelayMs = args.coldParkDelayMs ?? TERMINAL_TAB_COLD_PARK_DELAY_MS
|
||||
const hotRetainMs = args.hotRetainMs ?? TERMINAL_TAB_HOT_RETAIN_MS
|
||||
const nextRecheckAtMs = [args.hiddenSinceMs + coldParkDelayMs, args.hiddenSinceMs + hotRetainMs]
|
||||
.filter((deadlineMs) => deadlineMs > args.nowMs)
|
||||
.sort((a, b) => a - b)[0]
|
||||
return nextRecheckAtMs === undefined ? null : nextRecheckAtMs - args.nowMs
|
||||
}
|
||||
Loading…
Reference in New Issue