mirror of
https://github.com/stablyai/orca.git
synced 2026-10-22 00:03:47 +00:00
fix(terminal): retain alt frames through park reveal (#13882)
* fix(terminal): repaint an alt frame the fit never lands for
#13014 skips a too-wide alt frame on snapshot replay and relies on a post-fit
repaint. For a pane visible but permanently below the 48x24px fit floor,
readProposedTerminalCols is forever undefined, so skipIfTargetUnknown drops the
frame — and the only compensating repaint lives in a safe-fit continuation that
is failed rather than run for such a pane. The frame is lost until the user
widens the pane.
Adds a deferred repaint gated on display:none rather than on target-unknown,
preserving #13014's no-clip guarantee for a pane that later fits narrower.
Does NOT fix artificial-opencode-terminal-load.spec.ts:761. Two open concerns:
the repaint waits out the full 40-frame safe-fit retry budget (~1.3s) against
that spec's own 2s restore budget, and the cold-park reveal path at
pty-connection.ts:8222/8289 still has no deferral net.
Co-authored-by: Orca <help@stably.ai>
* fix(terminal): restore the deferred PTY input hop
Inlining forwardPtyInput into the onData handler dropped the
deps.deferPtyInput dispatch, leaving the callback and its type declared
and supplied but never called. Link-click mouse suppression silently
stopped withholding mouse-report frames, so a click in a mouse-tracking
TUI reached the PTY and the link-open fallback never fired.
Covers both branches, which had no test at all.
* fix(terminal): retain alt frames until final fit
* fix(terminal): preserve park intent across effect replay
* test(terminal): satisfy native static analysis
* fix(terminal): consume park intent after layout replay
* fix(terminal): re-read park intent instead of caching it per pane
useTerminalParkMountIntent cached park intent in a ref for the lifetime of
the component. The ref was never load-bearing: React finishes the render
phase before any effect runs, so StrictMode's passive-effect replay already
observes the same render-phase value. What makes the reveal work is reading
at render time rather than at connect time, which a plain call already does.
The ref did go stale. The pane lifecycle effect re-runs on [tabId, cwd] for
the same component instance, so a worktree path or startupCwd change
resupplied mountFollowsTerminalPark: true long after the park reveal, since
the post-replay consume clears the per-run ptyDeps copy and not the ref.
Each spurious run re-armed the pre-paint snapshot prefetch.
Also scope the post-replay consume behind a tested one-shot helper so splits
created after the restored-layout replay keep ordinary reconnect semantics,
and correct two comments that described the removed ref.
---------
Co-authored-by: Orca <help@stably.ai>
Co-authored-by: Brennan Benson <79079362+brennanb2025@users.noreply.github.com>
(cherry picked from commit ef8eea51af)
This commit is contained in:
@@ -1562,6 +1562,7 @@ function TerminalPane(
|
||||
worktreeId,
|
||||
cwd,
|
||||
startup: CODEX_ACCOUNT_RESTART_STARTUP,
|
||||
mountFollowsTerminalPark: false,
|
||||
paneTransportsRef,
|
||||
paneMode2031Ref,
|
||||
paneKittyKeyboardModesRef,
|
||||
|
||||
@@ -48,6 +48,9 @@ export type PtyConnectionDeps = {
|
||||
} | null
|
||||
restoredLeafId?: string | null
|
||||
restoredPtyIdByLeafId?: Record<string, string>
|
||||
/** Park intent sampled at render time, before the host disposes the tab's
|
||||
* watchers; consumed once the restored layout has been replayed. */
|
||||
mountFollowsTerminalPark: boolean
|
||||
paneTransportsRef: React.RefObject<Map<number, PtyTransport>>
|
||||
paneMode2031Ref: React.RefObject<Map<number, boolean>>
|
||||
/** Per-pane mirror of the kitty keyboard flags the pane's application
|
||||
|
||||
@@ -62,18 +62,6 @@ async function drainFakeTimerWork(limit = 20): Promise<void> {
|
||||
vi.clearAllTimers()
|
||||
}
|
||||
|
||||
// Why: a reveal remount reads the still-live park watcher entry at connect time to
|
||||
// tell itself apart from an in-place reattach; the host disposes it a beat later.
|
||||
async function parkTabForReveal(tabId: string, ptyId: string, worktreeId = 'wt-1'): Promise<void> {
|
||||
const { parkedWatchersByTabId } = await import('./terminal-parked-watcher-registry')
|
||||
parkedWatchersByTabId.set(tabId, {
|
||||
worktreeId,
|
||||
tabPtyId: ptyId,
|
||||
paneIdByPtyId: new Map([[ptyId, 1]]),
|
||||
disposersByPtyId: new Map([[ptyId, () => {}]])
|
||||
})
|
||||
}
|
||||
|
||||
async function drainPendingTimeouts(pendingTimeouts: (() => void)[], limit = 100): Promise<void> {
|
||||
let iterations = 0
|
||||
while (pendingTimeouts.length > 0) {
|
||||
@@ -624,6 +612,7 @@ function createDeps(overrides: Record<string, unknown> = {}) {
|
||||
startup: null,
|
||||
restoredLeafId: null,
|
||||
restoredPtyIdByLeafId: {},
|
||||
mountFollowsTerminalPark: false,
|
||||
paneTransportsRef: { current: new Map() },
|
||||
paneMode2031Ref: { current: new Map() },
|
||||
paneKittyKeyboardModesRef: { current: new Map() },
|
||||
@@ -3148,6 +3137,39 @@ describe('connectPanePty', () => {
|
||||
expect(pane.terminal.write).toHaveBeenCalledWith(redraw, expect.any(Function))
|
||||
})
|
||||
|
||||
it('routes terminal input through deferPtyInput so the host can withhold it', async () => {
|
||||
// Regression: inlining forwardPtyInput into onData dropped this hop, silently disabling link-click mouse suppression.
|
||||
const { connectPanePty } = await import('./pty-connection')
|
||||
const pane = createPane(1)
|
||||
const transport = createMockTransport('pty-1')
|
||||
transportFactoryQueue.push(transport)
|
||||
const deferPtyInput = vi.fn()
|
||||
|
||||
connectPanePty(pane as never, createManager(1) as never, createDeps({ deferPtyInput }) as never)
|
||||
await flushAsyncTicks()
|
||||
sendTerminalInputThroughPane(pane, 'a')
|
||||
|
||||
expect(deferPtyInput).toHaveBeenCalledWith(1, 'a', expect.any(Function))
|
||||
expect(transport.sendInput).not.toHaveBeenCalled()
|
||||
|
||||
const forward = deferPtyInput.mock.calls[0]?.[2] as (data: string) => void
|
||||
forward('a')
|
||||
expect(transport.sendInput).toHaveBeenCalledWith('a')
|
||||
})
|
||||
|
||||
it('forwards terminal input directly when the host supplies no deferPtyInput', async () => {
|
||||
const { connectPanePty } = await import('./pty-connection')
|
||||
const pane = createPane(1)
|
||||
const transport = createMockTransport('pty-1')
|
||||
transportFactoryQueue.push(transport)
|
||||
|
||||
connectPanePty(pane as never, createManager(1) as never, createDeps() as never)
|
||||
await flushAsyncTicks()
|
||||
sendTerminalInputThroughPane(pane, 'a')
|
||||
|
||||
expect(transport.sendInput).toHaveBeenCalledWith('a')
|
||||
})
|
||||
|
||||
it('does not let OpenTUI-style small ANSI redraw bursts monopolize foreground writes', async () => {
|
||||
const { connectPanePty } = await import('./pty-connection')
|
||||
const pane = createPane(1)
|
||||
@@ -8758,7 +8780,7 @@ describe('connectPanePty', () => {
|
||||
expect(writes.join('')).not.toContain('ALT-FRAME-BODY')
|
||||
})
|
||||
|
||||
it('drops a live daemon alt frame until a hidden pane has a final grid', async () => {
|
||||
it('keeps a daemon alt frame at its capture grid while the target grid is unknown', async () => {
|
||||
const { connectPanePty } = await import('./pty-connection')
|
||||
const transport = createMockTransport('tab-pty')
|
||||
transport.connect.mockImplementation(async ({ sessionId }: { sessionId?: string }) =>
|
||||
@@ -8796,7 +8818,8 @@ describe('connectPanePty', () => {
|
||||
|
||||
const writes = (pane.terminal.write as ReturnType<typeof vi.fn>).mock.calls.map((c) => c[0])
|
||||
expect(writes.join('')).toContain('PREFIX-SCROLLBACK')
|
||||
expect(writes.join('')).not.toContain('ALT-FRAME-BODY')
|
||||
expect(writes.join('')).toContain('ALT-FRAME-BODY')
|
||||
expect(writes.filter((write) => write.includes('ALT-FRAME-BODY'))).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('falls back to the merged daemon snapshot when split metadata is incomplete', async () => {
|
||||
@@ -15446,9 +15469,8 @@ describe('connectPanePty', () => {
|
||||
disposable.dispose()
|
||||
})
|
||||
|
||||
it('pulses a skipped hidden alt frame when reveal keeps the snapshot grid', async () => {
|
||||
it('keeps a hidden-output alt frame at the capture grid below the fit floor', async () => {
|
||||
const { connectPanePty } = await import('./pty-connection')
|
||||
const { safeFit } = await import('@/lib/pane-manager/pane-tree-ops')
|
||||
const transport = createMockTransport('pty-id')
|
||||
let onData: ConnectCallbacks['onData']
|
||||
transport.connect.mockImplementation(async ({ callbacks }: { callbacks: ConnectCallbacks }) => {
|
||||
@@ -15470,41 +15492,36 @@ describe('connectPanePty', () => {
|
||||
scrollbackAnsi: 'preserved history'
|
||||
})
|
||||
const pane = createPane(1)
|
||||
let paneRect = createRect(0, 0)
|
||||
pane.container.getBoundingClientRect = vi.fn(() => paneRect)
|
||||
let xtermContainerDisplay = 'none'
|
||||
const xtermContainer = new EventTarget() as HTMLElement
|
||||
Object.defineProperty(xtermContainer, 'parentElement', { value: null })
|
||||
Object.defineProperty(xtermContainer, 'ownerDocument', {
|
||||
value: { defaultView: { getComputedStyle: () => ({ display: xtermContainerDisplay }) } }
|
||||
pane.terminal.cols = 80
|
||||
pane.terminal.rows = 24
|
||||
const writes: string[] = []
|
||||
pane.terminal.write = vi.fn((data: string, callback?: () => void) => {
|
||||
writes.push(data)
|
||||
callback?.()
|
||||
})
|
||||
;(pane as { xtermContainer?: HTMLElement }).xtermContainer = xtermContainer
|
||||
pane.container.getBoundingClientRect = vi.fn(() => createRect(4, 3))
|
||||
const deps = createDeps({ isVisibleRef: { current: false } })
|
||||
const disposable = connectPanePty(pane as never, createManager(1) as never, deps as never)
|
||||
const binding = connectPanePty(pane as never, createManager(1) as never, deps as never)
|
||||
await flushAsyncTicks(6)
|
||||
onData?.(hidden, { seq: hidden.length, rawLength: hidden.length })
|
||||
const { _dispatchPtyModelRestoreNeededForTest } = await import('./pty-model-restore-channel')
|
||||
_dispatchPtyModelRestoreNeededForTest({
|
||||
id: 'pty-id',
|
||||
reason: 'hidden-drop',
|
||||
markerSeq: hidden.length + 1
|
||||
})
|
||||
;(deps.isVisibleRef as { current: boolean }).current = true
|
||||
onData?.('x', { seq: hidden.length + 1, rawLength: 1 })
|
||||
const { requestTerminalBacklogRecovery } =
|
||||
await import('@/lib/pane-manager/pane-terminal-output-scheduler')
|
||||
requestTerminalBacklogRecovery(pane.terminal as never)
|
||||
await flushAsyncTicks(20)
|
||||
|
||||
const writes = (pane.terminal.write as ReturnType<typeof vi.fn>).mock.calls.map(
|
||||
(call) => call[0]
|
||||
)
|
||||
expect(writes.join('')).not.toContain('TOO-WIDE-ALT-FRAME')
|
||||
expect(writes.join('')).toContain('\x1b[?1004h\x1b[?25l')
|
||||
expect(writes.join('')).toContain('TOO-WIDE-ALT-FRAME')
|
||||
expect(writes.join('')).toContain('preserved history')
|
||||
expect(writes.filter((write) => write.includes('TOO-WIDE-ALT-FRAME'))).toHaveLength(1)
|
||||
expect(pane.terminal.resize).toHaveBeenCalledWith(120, 40)
|
||||
expect(transport.resize).not.toHaveBeenCalled()
|
||||
|
||||
xtermContainerDisplay = 'block'
|
||||
paneRect = createRect(800, 600)
|
||||
safeFit(pane as never)
|
||||
await flushAsyncTicks(12)
|
||||
|
||||
const pulseStart = transport.resize.mock.calls.findIndex(
|
||||
([cols, rows]) => cols === 119 && rows === 40
|
||||
)
|
||||
expect(pulseStart).toBeGreaterThanOrEqual(0)
|
||||
expect(transport.resize.mock.calls[pulseStart + 1]).toEqual([120, 40])
|
||||
disposable.dispose()
|
||||
binding.dispose()
|
||||
})
|
||||
|
||||
it('drains foreground output after a renderer-sourced hidden-backlog snapshot without seq', async () => {
|
||||
@@ -21401,7 +21418,6 @@ describe('connectPanePty', () => {
|
||||
alternateScreen: true,
|
||||
scrollbackAnsi: 'PARKED-SSH-SCROLLBACK\r\n'
|
||||
})
|
||||
await parkTabForReveal('tab-1', sshPtyId)
|
||||
mockStoreState = {
|
||||
...mockStoreState,
|
||||
tabsByWorktree: { 'wt-1': [{ id: 'tab-1', ptyId: sshPtyId }] },
|
||||
@@ -21419,6 +21435,7 @@ describe('connectPanePty', () => {
|
||||
pane as never,
|
||||
createManager(1) as never,
|
||||
createDeps({
|
||||
mountFollowsTerminalPark: true,
|
||||
restoredLeafId: LEAF_1,
|
||||
restoredPtyIdByLeafId: { [LEAF_1]: sshPtyId }
|
||||
}) as never
|
||||
@@ -21464,7 +21481,6 @@ describe('connectPanePty', () => {
|
||||
transportFactoryQueue.push(createMockTransport())
|
||||
vi.mocked(window.api.pty.getMainBufferSnapshot).mockReturnValue(snapshot.promise)
|
||||
vi.mocked(window.api.ssh.connect).mockReturnValue(sshConnect.promise)
|
||||
await parkTabForReveal('tab-1', sshPtyId)
|
||||
mockStoreState = {
|
||||
...mockStoreState,
|
||||
tabsByWorktree: { 'wt-1': [{ id: 'tab-1', ptyId: sshPtyId, generation: 7 }] },
|
||||
@@ -21491,6 +21507,7 @@ describe('connectPanePty', () => {
|
||||
pane as never,
|
||||
createManager(1) as never,
|
||||
createDeps({
|
||||
mountFollowsTerminalPark: true,
|
||||
restoredLeafId: LEAF_1,
|
||||
restoredPtyIdByLeafId: { [LEAF_1]: sshPtyId }
|
||||
}) as never
|
||||
@@ -21543,7 +21560,6 @@ describe('connectPanePty', () => {
|
||||
seq: 125,
|
||||
source: 'headless'
|
||||
})
|
||||
await parkTabForReveal('tab-1', foreignPtyId)
|
||||
mockStoreState = {
|
||||
...mockStoreState,
|
||||
tabsByWorktree: { 'wt-1': [{ id: 'tab-1', ptyId: foreignPtyId }] },
|
||||
@@ -21560,6 +21576,7 @@ describe('connectPanePty', () => {
|
||||
pane as never,
|
||||
createManager(1) as never,
|
||||
createDeps({
|
||||
mountFollowsTerminalPark: true,
|
||||
restoredLeafId: LEAF_1,
|
||||
restoredPtyIdByLeafId: { [LEAF_1]: foreignPtyId }
|
||||
}) as never
|
||||
@@ -21595,7 +21612,6 @@ describe('connectPanePty', () => {
|
||||
})
|
||||
transportFactoryQueue.push(transport)
|
||||
vi.mocked(window.api.pty.getMainBufferSnapshot).mockReturnValue(snapshot.promise)
|
||||
await parkTabForReveal('tab-1', sshPtyId)
|
||||
mockStoreState = {
|
||||
...mockStoreState,
|
||||
tabsByWorktree: { 'wt-1': [{ id: 'tab-1', ptyId: sshPtyId }] },
|
||||
@@ -21612,6 +21628,7 @@ describe('connectPanePty', () => {
|
||||
pane as never,
|
||||
createManager(1) as never,
|
||||
createDeps({
|
||||
mountFollowsTerminalPark: true,
|
||||
restoredLeafId: LEAF_1,
|
||||
restoredPtyIdByLeafId: { [LEAF_1]: sshPtyId }
|
||||
}) as never
|
||||
@@ -21649,7 +21666,6 @@ describe('connectPanePty', () => {
|
||||
source: 'headless'
|
||||
})
|
||||
transportFactoryQueue.push(transport)
|
||||
await parkTabForReveal('tab-1', remotePtyId)
|
||||
mockStoreState = {
|
||||
...mockStoreState,
|
||||
runtimeStatusByEnvironmentId: new Map([
|
||||
@@ -21665,7 +21681,7 @@ describe('connectPanePty', () => {
|
||||
|
||||
const pane = createPane(1)
|
||||
const { parseCallbacks, writes } = captureCallbackTerminalWrites(pane)
|
||||
const deps = createDeps()
|
||||
const deps = createDeps({ mountFollowsTerminalPark: true })
|
||||
|
||||
connectPanePty(pane as never, createManager(1) as never, deps as never)
|
||||
for (let step = 0; step < 30; step += 1) {
|
||||
@@ -21680,7 +21696,7 @@ describe('connectPanePty', () => {
|
||||
expect(transport.getPtyId).toHaveReturnedWith(remotePtyId)
|
||||
})
|
||||
|
||||
it('restores a local main-model snapshot after a contentless park reattach', async () => {
|
||||
it('keeps a parked main-model alt frame at its capture grid while hidden', async () => {
|
||||
const { connectPanePty } = await import('./pty-connection')
|
||||
const localPtyId = 'global-floating-terminal@@terminal-1'
|
||||
const transport = createMockTransport(localPtyId)
|
||||
@@ -21691,13 +21707,15 @@ describe('connectPanePty', () => {
|
||||
transportFactoryQueue.push(transport)
|
||||
const getMainBufferSnapshot = vi.mocked(window.api.pty.getMainBufferSnapshot)
|
||||
getMainBufferSnapshot.mockResolvedValue({
|
||||
data: 'FLOATING-PARK-RESTORE-OK\r\n',
|
||||
data: 'FLOATING-PARK-ALT-FRAME',
|
||||
frameRestoreAnsi: '\x1b[?25l',
|
||||
cols: 113,
|
||||
rows: 32,
|
||||
seq: 558,
|
||||
source: 'headless'
|
||||
source: 'headless',
|
||||
alternateScreen: true,
|
||||
scrollbackAnsi: 'FLOATING-PARK-HISTORY\r\n'
|
||||
})
|
||||
await parkTabForReveal('tab-1', localPtyId, 'global-floating-terminal')
|
||||
mockStoreState = {
|
||||
...mockStoreState,
|
||||
tabsByWorktree: {
|
||||
@@ -21715,8 +21733,10 @@ describe('connectPanePty', () => {
|
||||
}
|
||||
|
||||
const pane = createPane(1)
|
||||
pane.container.getBoundingClientRect = vi.fn(() => createRect(0, 0))
|
||||
const { parseCallbacks, writes } = captureCallbackTerminalWrites(pane)
|
||||
const deps = createDeps({
|
||||
mountFollowsTerminalPark: true,
|
||||
worktreeId: 'global-floating-terminal',
|
||||
restoredLeafId: LEAF_1,
|
||||
restoredPtyIdByLeafId: { [LEAF_1]: localPtyId }
|
||||
@@ -21733,7 +21753,55 @@ describe('connectPanePty', () => {
|
||||
expect.objectContaining({ sessionId: localPtyId })
|
||||
)
|
||||
expect(deps.syncPanePtyLayoutBinding).toHaveBeenCalledWith(1, localPtyId)
|
||||
expect(writes.join('')).toContain('FLOATING-PARK-RESTORE-OK')
|
||||
expect(writes.join('')).toContain('FLOATING-PARK-HISTORY')
|
||||
expect(writes.join('')).toContain('FLOATING-PARK-ALT-FRAME')
|
||||
expect(writes.filter((write) => write.includes('FLOATING-PARK-ALT-FRAME'))).toHaveLength(1)
|
||||
expect(pane.terminal.resize).toHaveBeenCalledWith(113, 32)
|
||||
})
|
||||
|
||||
it('restores a parked SSH alt frame after StrictMode watcher disposal', async () => {
|
||||
const { connectPanePty } = await import('./pty-connection')
|
||||
const sshPtyId = toAppSshPtyId('conn-1', 'relay-pty-static-alt')
|
||||
const transport = createMockTransport(sshPtyId)
|
||||
transport.connect.mockImplementation(async () => {
|
||||
transport.getPtyId.mockReturnValue(sshPtyId)
|
||||
return { id: sshPtyId, isReattach: true, replay: '' }
|
||||
})
|
||||
transportFactoryQueue.push(transport)
|
||||
vi.mocked(window.api.pty.getMainBufferSnapshot).mockResolvedValue({
|
||||
data: '\x1b[0m\x1b[?1049h\x1b[HSTATIC-SSH-ALT-FRAME',
|
||||
frameRestoreAnsi: '\x1b[0m\x1b[?1049h\x1b[?6l\x1b[1;21H',
|
||||
cols: 100,
|
||||
rows: 30,
|
||||
seq: 512,
|
||||
source: 'headless',
|
||||
alternateScreen: true,
|
||||
scrollbackAnsi: 'PRESERVED-SSH-HISTORY\r\n'
|
||||
})
|
||||
mockStoreState = {
|
||||
...mockStoreState,
|
||||
tabsByWorktree: { 'wt-1': [{ id: 'tab-1', ptyId: null }] },
|
||||
repos: [{ id: 'repo1', connectionId: 'conn-1' }],
|
||||
deferredSshReconnectTargets: ['conn-1'],
|
||||
deferredSshSessionIdsByTabId: { 'tab-1': sshPtyId }
|
||||
}
|
||||
|
||||
const pane = createPane(1)
|
||||
const { parseCallbacks, writes } = captureCallbackTerminalWrites(pane)
|
||||
const deps = createDeps({
|
||||
mountFollowsTerminalPark: true,
|
||||
restoredLeafId: LEAF_1,
|
||||
restoredPtyIdByLeafId: { [LEAF_1]: sshPtyId }
|
||||
})
|
||||
connectPanePty(pane as never, createManager(1) as never, deps as never)
|
||||
for (let step = 0; step < 30; step += 1) {
|
||||
parseCallbacks.shift()?.()
|
||||
await flushAsyncTicks(2)
|
||||
}
|
||||
|
||||
expect(window.api.pty.getMainBufferSnapshot).toHaveBeenCalledOnce()
|
||||
expect(writes.join('')).toContain('PRESERVED-SSH-HISTORY')
|
||||
expect(writes.filter((write) => write.includes('STATIC-SSH-ALT-FRAME'))).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('falls back to relay replay when the SSH model snapshot stalls', async () => {
|
||||
@@ -21747,9 +21815,7 @@ describe('connectPanePty', () => {
|
||||
})
|
||||
transportFactoryQueue.push(transport)
|
||||
vi.mocked(window.api.pty.getMainBufferSnapshot).mockReturnValue(new Promise(() => {}))
|
||||
// Why parked: only a reveal remount probes main's model — the pane reads the
|
||||
// still-live park watcher entry at connect time to tell the two apart.
|
||||
await parkTabForReveal('tab-1', sshPtyId)
|
||||
// Why parked: only a reveal remount probes main's model.
|
||||
|
||||
mockStoreState = {
|
||||
...mockStoreState,
|
||||
@@ -21762,6 +21828,7 @@ describe('connectPanePty', () => {
|
||||
const pane = createPane(1)
|
||||
const { writes } = captureCallbackTerminalWrites(pane)
|
||||
const deps = createDeps({
|
||||
mountFollowsTerminalPark: true,
|
||||
restoredLeafId: LEAF_1,
|
||||
restoredPtyIdByLeafId: { [LEAF_1]: sshPtyId }
|
||||
})
|
||||
|
||||
@@ -280,7 +280,6 @@ import {
|
||||
setCommandCodeDoneSettleExecutor
|
||||
} from './command-code-done-settle'
|
||||
import { canCommandCodeOutputOwnPane } from './command-code-output-ownership'
|
||||
import { isTerminalTabParked } from './terminal-parked-watcher-registry'
|
||||
import {
|
||||
getExecutionHostIdForWorktree,
|
||||
getSettingsForWorktreeRuntimeOwner
|
||||
@@ -1096,10 +1095,7 @@ export function connectPanePty(
|
||||
// settles after remount must not remount its already-replaced successor.
|
||||
const terminalRecoveryGeneration = captureTerminalPaneRecoveryGeneration(deps.tabId)
|
||||
const terminalRecoveryInstance = registerTerminalPaneRecoveryInstance(deps.tabId)
|
||||
// Why sampled here: the host disposes this tab's park watcher in the effect
|
||||
// that follows this mount, so connect time is the only moment a pane can tell
|
||||
// a reveal remount from an in-place reattach.
|
||||
let mountFollowsTerminalPark = isTerminalTabParked(deps.tabId)
|
||||
let mountFollowsTerminalPark = deps.mountFollowsTerminalPark
|
||||
let authoritativeReattachGeneration = 0
|
||||
exposeE2eTerminalPtyOutputDebug()
|
||||
let disposed = false
|
||||
@@ -7303,9 +7299,7 @@ export function connectPanePty(
|
||||
skippedAltFrame =
|
||||
snapshot.alternateScreen === true &&
|
||||
snapshot.frameRestoreAnsi !== undefined &&
|
||||
shouldSkipAltFrameForWidthMismatch(snapshot.cols, readProposedTerminalCols(pane), {
|
||||
skipIfTargetUnknown: true
|
||||
})
|
||||
shouldSkipAltFrameForWidthMismatch(snapshot.cols, readProposedTerminalCols(pane))
|
||||
for (const replayChunk of buildMainModelSnapshotReplayWrites(snapshot, {
|
||||
skipAltFrame: skippedAltFrame
|
||||
})) {
|
||||
@@ -8256,8 +8250,7 @@ export function connectPanePty(
|
||||
typeof snapshotFrameRestoreAnsi === 'string' &&
|
||||
shouldSkipAltFrameForWidthMismatch(
|
||||
connectResult.snapshotCols,
|
||||
readProposedTerminalCols(pane),
|
||||
{ skipIfTargetUnknown: true }
|
||||
readProposedTerminalCols(pane)
|
||||
)
|
||||
writeReplayData(
|
||||
daemonAltFrameSkippable
|
||||
@@ -8326,10 +8319,7 @@ export function connectPanePty(
|
||||
for (const replayChunk of buildMainModelSnapshotReplayWrites(modelSnapshot, {
|
||||
skipAltFrame: shouldSkipAltFrameForWidthMismatch(
|
||||
modelCols,
|
||||
readProposedTerminalCols(pane),
|
||||
{
|
||||
skipIfTargetUnknown: true
|
||||
}
|
||||
readProposedTerminalCols(pane)
|
||||
)
|
||||
})) {
|
||||
writeReplayData(replayChunk)
|
||||
|
||||
@@ -58,10 +58,10 @@ export function terminalWatcherLiveWorkspaceIds(workspaceIds: Iterable<string>):
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether this tab is parked right now — the reveal remount's own mount effect
|
||||
* runs before the host effect that disposes the watcher (child effects first),
|
||||
* so a pane reading this at connect time can tell a park-reveal from an
|
||||
* in-place reattach. Empty entries are pinned-close tombstones, not live parks.
|
||||
* Whether this tab is parked right now — the reveal remount renders before the
|
||||
* host effect that disposes the watcher, so a pane reading this at render time
|
||||
* can tell a park-reveal from an in-place reattach. Empty entries are
|
||||
* pinned-close tombstones, not live parks.
|
||||
*/
|
||||
export function isTerminalTabParked(tabId: string): boolean {
|
||||
return (parkedWatchersByTabId.get(tabId)?.disposersByPtyId.size ?? 0) > 0
|
||||
|
||||
@@ -89,18 +89,35 @@ describe('shouldSkipAltFrameForWidthMismatch', () => {
|
||||
expect(shouldSkipAltFrameForWidthMismatch(Number.NaN, 128)).toBe(false)
|
||||
expect(shouldSkipAltFrameForWidthMismatch(Number.POSITIVE_INFINITY, 128)).toBe(false)
|
||||
})
|
||||
|
||||
it('can conservatively skip a live frame until a hidden pane has a final grid', () => {
|
||||
expect(shouldSkipAltFrameForWidthMismatch(135, undefined, { skipIfTargetUnknown: true })).toBe(
|
||||
true
|
||||
)
|
||||
expect(
|
||||
shouldSkipAltFrameForWidthMismatch(undefined, undefined, { skipIfTargetUnknown: true })
|
||||
).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildMainModelSnapshotReplayWrites alt-frame skip', () => {
|
||||
it('restores the exact capture-grid alt frame while the target grid is unknown', async () => {
|
||||
const terminal = new Terminal({ cols: 12, rows: 5, scrollback: 20 })
|
||||
const snapshot = {
|
||||
data: '\x1b[1;1HTOP---------\x1b[2;1HMIDDLE------\x1b[3;1HBOTTOM------',
|
||||
frameRestoreAnsi: '\x1b[?25l',
|
||||
alternateScreen: true,
|
||||
scrollbackAnsi: 'history'
|
||||
}
|
||||
|
||||
try {
|
||||
const skipAltFrame = shouldSkipAltFrameForWidthMismatch(12, undefined)
|
||||
for (const chunk of buildMainModelSnapshotReplayWrites(snapshot, { skipAltFrame })) {
|
||||
await writeTerminal(terminal, chunk)
|
||||
}
|
||||
|
||||
expect(
|
||||
Array.from({ length: 3 }, (_, row) =>
|
||||
terminal.buffer.active.getLine(row)?.translateToString(true)
|
||||
)
|
||||
).toEqual(['TOP---------', 'MIDDLE------', 'BOTTOM------'])
|
||||
expect(terminal.buffer.normal.getLine(0)?.translateToString(true)).toBe('history')
|
||||
} finally {
|
||||
terminal.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps normal history and a clean alt grid through the real resize path', async () => {
|
||||
const terminal = new Terminal({ cols: 12, rows: 5, scrollback: 20 })
|
||||
const snapshot = {
|
||||
|
||||
@@ -35,8 +35,8 @@ export function resolvePositiveTerminalDimensions(
|
||||
* The column count the post-replay fit will land on. Why not terminal.cols: a
|
||||
* pane that has not been fitted yet still reads xterm's 80-column default, so
|
||||
* comparing against it would drop frames whose width actually matches the
|
||||
* container. Returns undefined when the pane cannot be measured; callers with
|
||||
* a repaint owner can clear and defer, while offline preconnect paint cannot.
|
||||
* container. Returns undefined when the pane cannot be measured, so replay can
|
||||
* retain the frame at its capture grid until a final fit exists.
|
||||
*/
|
||||
export function readProposedTerminalCols(pane: ManagedPane): number | undefined {
|
||||
return readProposedPaneFitDimensions(pane)?.cols
|
||||
@@ -44,15 +44,14 @@ export function readProposedTerminalCols(pane: ManagedPane): number | undefined
|
||||
|
||||
export function shouldSkipAltFrameForWidthMismatch(
|
||||
snapshotCols: number | undefined,
|
||||
targetCols: number | undefined,
|
||||
options: { skipIfTargetUnknown?: boolean } = {}
|
||||
targetCols: number | undefined
|
||||
): boolean {
|
||||
if (typeof snapshotCols !== 'number' || !Number.isFinite(snapshotCols) || snapshotCols <= 0) {
|
||||
return false
|
||||
}
|
||||
if (typeof targetCols !== 'number' || !Number.isFinite(targetCols) || targetCols <= 0) {
|
||||
// A hidden pane has no final grid; its live app can repaint after the deferred fit.
|
||||
return options.skipIfTargetUnknown === true
|
||||
// Keep the frame at its capture grid until a real fit can replace that grid.
|
||||
return false
|
||||
}
|
||||
// Fixed-grid alt rows clip at narrower columns; normal history remains reflowable.
|
||||
return snapshotCols > targetCols
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
resolvePaneLinkCwd,
|
||||
resolvePaneSeedCwd,
|
||||
resolveQueuedInitialCwd,
|
||||
replayLayoutWithOneShotParkIntent,
|
||||
resetTerminalKeyboardProtocolAfterInterrupt,
|
||||
retireMountedTerminalPaneSurface,
|
||||
shouldDetachPaneTransportOnUnmount,
|
||||
@@ -248,6 +249,35 @@ describe('splitPaneWithOneShotStartup', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('replayLayoutWithOneShotParkIntent', () => {
|
||||
it('exposes park intent to replayed panes and clears it before later splits', () => {
|
||||
const deps = { mountFollowsTerminalPark: true }
|
||||
const observedByReplayedPane: boolean[] = []
|
||||
|
||||
const restored = replayLayoutWithOneShotParkIntent(deps, () => {
|
||||
observedByReplayedPane.push(deps.mountFollowsTerminalPark)
|
||||
return 'restored-panes'
|
||||
})
|
||||
|
||||
expect(restored).toBe('restored-panes')
|
||||
expect(observedByReplayedPane).toEqual([true])
|
||||
// A split after replay reads the same deps object, so it must see ordinary reconnect semantics.
|
||||
expect(deps.mountFollowsTerminalPark).toBe(false)
|
||||
})
|
||||
|
||||
it('clears park intent even when layout replay throws', () => {
|
||||
const deps = { mountFollowsTerminalPark: true }
|
||||
|
||||
expect(() =>
|
||||
replayLayoutWithOneShotParkIntent(deps, () => {
|
||||
throw new Error('replay failed')
|
||||
})
|
||||
).toThrow('replay failed')
|
||||
|
||||
expect(deps.mountFollowsTerminalPark).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('applyTerminalScrollbackRowsToMountedPanes', () => {
|
||||
it('updates mounted pane xterm scrollback options only when needed', () => {
|
||||
const firstOptions = { scrollback: 1_000 }
|
||||
|
||||
@@ -130,6 +130,7 @@ import { markTerminalPinnedViewport } from '@/lib/pane-manager/terminal-scroll-i
|
||||
import { syncTerminalScrollIntentSoon } from '@/lib/pane-manager/terminal-scroll-intent-settle'
|
||||
import { registerRuntimeTerminalTab, scheduleRuntimeGraphSync } from '@/runtime/sync-runtime-graph'
|
||||
import { captureParkedTerminalPaneCandidates } from './terminal-parked-tab-watchers'
|
||||
import { useTerminalParkMountIntent } from './use-terminal-park-mount-intent'
|
||||
import { e2eConfig } from '@/lib/e2e-config'
|
||||
import {
|
||||
PRIMARY_SELECTION_MAX_LENGTH,
|
||||
@@ -483,6 +484,19 @@ export function splitPaneWithOneShotStartup<TPane>(
|
||||
}
|
||||
}
|
||||
|
||||
/** Scopes `deps.mountFollowsTerminalPark` to the restored-layout replay. */
|
||||
export function replayLayoutWithOneShotParkIntent<TRestored>(
|
||||
deps: { mountFollowsTerminalPark: boolean },
|
||||
replayLayout: () => TRestored
|
||||
): TRestored {
|
||||
// Why: only panes reconstructed by this replay belong to the park reveal; later splits must use ordinary reconnect semantics.
|
||||
try {
|
||||
return replayLayout()
|
||||
} finally {
|
||||
deps.mountFollowsTerminalPark = false
|
||||
}
|
||||
}
|
||||
|
||||
export function shouldDetachPaneTransportOnUnmount(args: {
|
||||
tabStillExists: boolean
|
||||
tabId: string
|
||||
@@ -665,6 +679,7 @@ export function useTerminalPaneLifecycle({
|
||||
const systemPrefersDarkRef = useRef(systemPrefersDark)
|
||||
systemPrefersDarkRef.current = systemPrefersDark
|
||||
const previousVisibleForReconcileRef = useRef<TerminalPaneVisibilitySnapshot | null>(null)
|
||||
const mountFollowsTerminalPark = useTerminalParkMountIntent(tabId)
|
||||
const linkProviderDisposablesRef = useRef(new Map<number, IDisposable>())
|
||||
const terminalHandleLinkDisposablesRef = useRef(new Map<number, IDisposable>())
|
||||
const linkifierClickPrimingDisposablesRef = useRef(new Map<number, IDisposable>())
|
||||
@@ -854,6 +869,7 @@ export function useTerminalPaneLifecycle({
|
||||
worktreeId,
|
||||
cwd: startupCwd,
|
||||
startup: startupWithSetupSplitWait,
|
||||
mountFollowsTerminalPark,
|
||||
paneTransportsRef,
|
||||
paneMode2031Ref,
|
||||
paneKittyKeyboardModesRef,
|
||||
@@ -1575,7 +1591,9 @@ export function useTerminalPaneLifecycle({
|
||||
window.__paneManagers = window.__paneManagers ?? new Map()
|
||||
window.__paneManagers.set(tabId, manager)
|
||||
}
|
||||
const restoredPaneByLeafId = replayTerminalLayout(manager, initialLayoutRef.current, isActive)
|
||||
const restoredPaneByLeafId = replayLayoutWithOneShotParkIntent(ptyDeps, () =>
|
||||
replayTerminalLayout(manager, initialLayoutRef.current, isActive)
|
||||
)
|
||||
|
||||
const restoredBuffers = initialLayoutRef.current.buffersByLeafId
|
||||
restoreScrollbackBuffers(
|
||||
|
||||
+76
@@ -0,0 +1,76 @@
|
||||
/** @vitest-environment happy-dom */
|
||||
import { StrictMode, act, useEffect } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { disposeParkedTabWatchers, parkedWatchersByTabId } from './terminal-parked-watcher-registry'
|
||||
import { useTerminalParkMountIntent } from './use-terminal-park-mount-intent'
|
||||
|
||||
;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true
|
||||
|
||||
const TAB_ID = 'strict-park-reveal'
|
||||
const observations: boolean[] = []
|
||||
|
||||
function Child(): null {
|
||||
const mountFollowsTerminalPark = useTerminalParkMountIntent(TAB_ID)
|
||||
useEffect(() => {
|
||||
observations.push(mountFollowsTerminalPark)
|
||||
}, [mountFollowsTerminalPark])
|
||||
return null
|
||||
}
|
||||
|
||||
function Parent() {
|
||||
useEffect(() => {
|
||||
disposeParkedTabWatchers(TAB_ID)
|
||||
}, [])
|
||||
return <Child />
|
||||
}
|
||||
|
||||
function parkTab(): void {
|
||||
parkedWatchersByTabId.set(TAB_ID, {
|
||||
worktreeId: 'wt-1',
|
||||
tabPtyId: 'pty-1',
|
||||
paneIdByPtyId: new Map([['pty-1', 1]]),
|
||||
disposersByPtyId: new Map([['pty-1', () => {}]])
|
||||
})
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
observations.length = 0
|
||||
disposeParkedTabWatchers(TAB_ID)
|
||||
})
|
||||
|
||||
describe('useTerminalParkMountIntent', () => {
|
||||
it('survives StrictMode effect replay after the parent disposes the watcher', () => {
|
||||
parkTab()
|
||||
const container = document.createElement('div')
|
||||
const root = createRoot(container)
|
||||
|
||||
act(() => {
|
||||
root.render(
|
||||
<StrictMode>
|
||||
<Parent />
|
||||
</StrictMode>
|
||||
)
|
||||
})
|
||||
|
||||
expect(observations).toEqual([true, true])
|
||||
act(() => root.unmount())
|
||||
})
|
||||
|
||||
// Why: the lifecycle effect re-runs on cwd changes for the same component
|
||||
// instance, so intent cached per instance would resupply a stale park reveal.
|
||||
it('re-reads park intent when the same instance re-renders after disposal', () => {
|
||||
parkTab()
|
||||
const container = document.createElement('div')
|
||||
const root = createRoot(container)
|
||||
|
||||
act(() => root.render(<Child />))
|
||||
expect(observations).toEqual([true])
|
||||
|
||||
disposeParkedTabWatchers(TAB_ID)
|
||||
act(() => root.render(<Child />))
|
||||
|
||||
expect(observations).toEqual([true, false])
|
||||
act(() => root.unmount())
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,9 @@
|
||||
import { isTerminalTabParked } from './terminal-parked-watcher-registry'
|
||||
|
||||
// Why render time: the host effect that disposes the watcher runs after every
|
||||
// child render, so reading here — not at connect time — is what lets a reveal
|
||||
// remount still see the park. Caching it per instance would instead resupply a
|
||||
// stale reveal on later same-instance effect re-runs (cwd change).
|
||||
export function useTerminalParkMountIntent(tabId: string): boolean {
|
||||
return isTerminalTabParked(tabId)
|
||||
}
|
||||
Reference in New Issue
Block a user