fix(terminal): suppress xterm auto-replies during session restore (#960)

This commit is contained in:
Brennan Benson
2026-04-22 22:14:36 -07:00
committed by GitHub
parent 206efc4900
commit f5ada40afd
11 changed files with 394 additions and 11 deletions
@@ -67,6 +67,11 @@ export default function TerminalPane({
const paneLastThemeModeRef = useRef<Map<number, 'dark' | 'light'>>(new Map())
const panePtyBindingsRef = useRef<Map<number, IDisposable>>(new Map())
const pendingWritesRef = useRef<Map<number, string>>(new Map())
// Why: tracks panes currently replaying recorded PTY bytes into xterm
// (cold-restore, daemon snapshot, scrollback restore, eager-buffer flush).
// While non-zero, pty-connection.ts drops xterm onData so auto-replies to
// embedded query sequences don't leak to the shell. See replay-guard.ts.
const replayingPanesRef = useRef<Map<number, number>>(new Map())
const isActiveRef = useRef(isActive)
isActiveRef.current = isActive
const isVisibleRef = useRef(isVisible)
@@ -347,6 +352,7 @@ export default function TerminalPane({
paneLastThemeModeRef,
panePtyBindingsRef,
pendingWritesRef,
replayingPanesRef,
isActiveRef,
isVisibleRef,
onPtyExitRef,
@@ -408,6 +414,7 @@ export default function TerminalPane({
startup: { command: 'codex' },
paneTransportsRef,
pendingWritesRef,
replayingPanesRef,
isActiveRef,
isVisibleRef,
onPtyExitRef,
@@ -4,6 +4,7 @@ import type {
TerminalPaneSplitDirection
} from '../../../../shared/types'
import type { PaneManager } from '@/lib/pane-manager/pane-manager'
import { replayIntoTerminal, type ReplayingPanesRef } from './replay-guard'
export const EMPTY_LAYOUT: TerminalLayoutSnapshot = {
root: null,
@@ -198,7 +199,8 @@ function collectLeafIds(
export function restoreScrollbackBuffers(
manager: PaneManager,
savedBuffers: Record<string, string> | undefined,
restoredPaneByLeafId: Map<string, number>
restoredPaneByLeafId: Map<string, number>,
replayingPanesRef: ReplayingPanesRef
): void {
if (!savedBuffers) {
return
@@ -224,10 +226,15 @@ export function restoreScrollbackBuffers(
buf = buf.slice(0, lastOn)
}
if (buf.length > 0) {
pane.terminal.write(buf)
// Why replayIntoTerminal: the serialized buffer can contain query
// sequences that leaked in via the pendingWritesRef flush before
// serialization (see TerminalPane capture hook). Writing those
// through xterm would trigger auto-replies that land in the new
// shell's stdin. See replay-guard.ts.
replayIntoTerminal(pane, replayingPanesRef, buf)
// Ensure cursor is on a new line so the new shell prompt
// doesn't trigger zsh's PROMPT_EOL_MARK (%) indicator.
pane.terminal.write('\r\n')
replayIntoTerminal(pane, replayingPanesRef, '\r\n')
}
} catch {
// If restore fails, continue with blank terminal.
@@ -1,4 +1,5 @@
import type { PtyTransport } from './pty-transport'
import type { ReplayingPanesRef } from './replay-guard'
export type PtyConnectionDeps = {
tabId: string
@@ -9,6 +10,7 @@ export type PtyConnectionDeps = {
restoredPtyIdByLeafId?: Record<string, string>
paneTransportsRef: React.RefObject<Map<number, PtyTransport>>
pendingWritesRef: React.RefObject<Map<number, string>>
replayingPanesRef: ReplayingPanesRef
isActiveRef: React.RefObject<boolean>
isVisibleRef: React.RefObject<boolean>
onPtyExitRef: React.RefObject<(ptyId: string) => void>
@@ -125,6 +125,7 @@ function createDeps(overrides: Record<string, unknown> = {}) {
restoredPtyIdByLeafId: {},
paneTransportsRef: { current: new Map() },
pendingWritesRef: { current: new Map() },
replayingPanesRef: { current: new Map() },
isActiveRef: { current: true },
isVisibleRef: { current: true },
onPtyExitRef: { current: vi.fn() },
@@ -229,6 +230,47 @@ describe('connectPanePty', () => {
)
})
it('drops xterm onData while pane is replaying restored bytes', async () => {
// Regression: during cold-restore / snapshot replay, xterm auto-replies
// to embedded query sequences (DA1, DECRQM, OSC 10/11, focus, CPR) via
// onData. Those replies must not pipe through to transport.sendInput, or
// they land as stray characters ("?1;2c", "2026;2$y", ...) on the new
// shell's prompt. See replay-guard.ts.
const { connectPanePty } = await import('./pty-connection')
const transport = createMockTransport('pty-live')
transportFactoryQueue.push(transport)
mockStoreState = {
...mockStoreState,
tabsByWorktree: { 'wt-1': [{ id: 'tab-1', ptyId: null }] }
}
const pane = createPane(1)
let onDataHandler: ((data: string) => void) | null = null
pane.terminal.onData = vi.fn(((handler: (data: string) => void) => {
onDataHandler = handler
return { dispose: vi.fn() }
}) as typeof pane.terminal.onData)
const manager = createManager(1)
const replayingPanesRef = { current: new Map<number, number>([[1, 1]]) }
const deps = createDeps({ replayingPanesRef })
connectPanePty(pane as never, manager as never, deps as never)
expect(onDataHandler).toBeDefined()
if (!onDataHandler) {
throw new Error('expected onData handler to be registered')
}
// Simulate xterm emitting a DA1 auto-reply during replay parse.
;(onDataHandler as (data: string) => void)('\x1b[?1;2c')
expect(transport.sendInput).not.toHaveBeenCalled()
// Once replay completes (guard cleared), real keystrokes flow through.
replayingPanesRef.current.delete(1)
;(onDataHandler as (data: string) => void)('a')
expect(transport.sendInput).toHaveBeenCalledWith('a')
})
it('blocks input to stale Codex panes until they restart', async () => {
const { connectPanePty } = await import('./pty-connection')
@@ -8,6 +8,7 @@ import type { PtyConnectResult } from './pty-transport'
import { createIpcPtyTransport } from './pty-transport'
import { shouldSeedCacheTimerOnInitialTitle } from './cache-timer-seeding'
import type { PtyConnectionDeps } from './pty-connection-types'
import { isPaneReplaying, replayIntoTerminal } from './replay-guard'
const pendingSpawnByTabId = new Map<string, Promise<string | null>>()
@@ -190,6 +191,16 @@ export function connectPanePty(
deps.paneTransportsRef.current.set(pane.id, transport)
const onDataDisposable = pane.terminal.onData((data) => {
// Why: xterm auto-replies to embedded query sequences (DA1, DECRQM,
// OSC 10/11, focus, CPR) via onData. When we replay recorded PTY bytes
// into xterm for scrollback/cold-restore/snapshot, those queries would
// otherwise pipe replies into the freshly spawned shell as stray input
// ("?1;2c", "2026;2$y", OSC color fragments, ...). The replay sites
// engage the guard via replayIntoTerminal; here we drop everything
// xterm emits while the guard is active. See replay-guard.ts.
if (isPaneReplaying(deps.replayingPanesRef, pane.id)) {
return
}
const currentPtyId = transport.getPtyId()
// Why: after a Codex account switch, the runtime auth has already moved to
// the newly selected account. Stale panes must not keep sending input until
@@ -259,6 +270,7 @@ export function connectPanePty(
rows,
callbacks: {
onData: dataCallback,
onReplayData: replayDataCallback,
onError: reportError
}
})
@@ -275,6 +287,21 @@ export function connectPanePty(
pendingSpawnByTabId.set(deps.tabId, spawnPromise)
}
// Why: replay bytes (eager-buffer flush, attach-time screen clear) must
// always go through the replay guard so xterm's auto-replies to embedded
// query sequences don't leak to the shell. Unlike dataCallback we do not
// honor isVisibleRef — the visibility branch is a perf batching strategy
// for live output that defers parsing until the worktree is foregrounded,
// but deferring replay parsing drops the bytes into pendingWritesRef,
// which later flushes through plain pane.terminal.write (in
// use-terminal-pane-global-effects) with no guard engaged. xterm's
// write() buffers internally regardless of DOM visibility, and the guard
// stays engaged via the write-completion callback until xterm finishes
// parsing — so writing directly here is both correct and safe.
const replayDataCallback = (data: string): void => {
replayIntoTerminal(pane, deps.replayingPanesRef, data)
}
const dataCallback = (data: string): void => {
if (deps.isVisibleRef.current) {
pane.terminal.write(data)
@@ -371,6 +398,7 @@ export function connectPanePty(
sessionId: deferredReattachSessionId,
callbacks: {
onData: dataCallback,
onReplayData: replayDataCallback,
onError: reportError
}
})
@@ -397,9 +425,17 @@ export function connectPanePty(
// buffer before this rAF ran. The cold-restore scrollback from
// disk history overlaps with that content. Without clearing first,
// the terminal shows duplicated output.
pane.terminal.write('\x1b[2J\x1b[3J\x1b[H')
pane.terminal.write(connectResult.coldRestore.scrollback)
pane.terminal.write('\r\n\x1b[2m--- session restored ---\x1b[0m\r\n\r\n')
// Why 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.
replayIntoTerminal(pane, deps.replayingPanesRef, '\x1b[2J\x1b[3J\x1b[H')
replayIntoTerminal(pane, deps.replayingPanesRef, connectResult.coldRestore.scrollback)
replayIntoTerminal(
pane,
deps.replayingPanesRef,
'\r\n\x1b[2m--- session restored ---\x1b[0m\r\n\r\n'
)
window.api.pty.ackColdRestore(ptyId!)
} else if (connectResult?.snapshot) {
// Why: always clear before writing the daemon snapshot to prevent
@@ -407,8 +443,9 @@ export function connectPanePty(
// wrote earlier. The alt-screen case previously skipped this,
// leaving stale scrollback in the normal buffer that reappeared
// when the user exited the TUI (e.g. Claude Code).
pane.terminal.write('\x1b[2J\x1b[3J\x1b[H')
pane.terminal.write(connectResult.snapshot)
// Why replayIntoTerminal: same rationale as the cold-restore path.
replayIntoTerminal(pane, deps.replayingPanesRef, '\x1b[2J\x1b[3J\x1b[H')
replayIntoTerminal(pane, deps.replayingPanesRef, connectResult.snapshot)
}
if (ptyId) {
@@ -446,6 +483,7 @@ export function connectPanePty(
rows,
callbacks: {
onData: dataCallback,
onReplayData: replayDataCallback,
onError: reportError
}
})
@@ -493,6 +531,7 @@ export function connectPanePty(
rows,
callbacks: {
onData: dataCallback,
onReplayData: replayDataCallback,
onError: reportError
}
})
@@ -150,6 +150,11 @@ export type PtyTransport = {
onConnect?: () => void
onDisconnect?: () => void
onData?: (data: string) => void
/** Replay bytes from a prior session (eager buffers, attach-time screen
* clears). Routed separately from onData so the renderer can engage
* the replay guard — otherwise xterm auto-replies to embedded query
* sequences leak into the shell. See replay-guard.ts. */
onReplayData?: (data: string) => void
onStatus?: (shell: string) => void
onError?: (message: string, errors?: string[]) => void
onExit?: (code: number) => void
@@ -169,6 +174,8 @@ export type PtyTransport = {
onConnect?: () => void
onDisconnect?: () => void
onData?: (data: string) => void
/** See note on connect.callbacks.onReplayData. */
onReplayData?: (data: string) => void
onStatus?: (shell: string) => void
onError?: (message: string, errors?: string[]) => void
onExit?: (code: number) => void
@@ -118,6 +118,83 @@ describe('createIpcPtyTransport', () => {
expect(onBell).not.toHaveBeenCalled()
})
it('routes eager-buffered bytes through onReplayData so the renderer can engage the replay guard', async () => {
const { createIpcPtyTransport, registerEagerPtyBuffer } = await import('./pty-transport')
// Why: eager-buffered bytes often contain query sequences (e.g. DA1 `\x1b[c`)
// left over from a previous session. Routing them through onData instead of
// onReplayData would bypass pty-connection's replay guard and xterm would
// auto-reply to those queries, leaking stray input into the shell.
const bufferedPayload = 'helloworld'
const handle = registerEagerPtyBuffer('pty-restored', vi.fn())
onData?.({
id: 'pty-restored',
data: bufferedPayload
})
const transport = createIpcPtyTransport()
const onDataCallback = vi.fn()
const onReplayData = vi.fn()
transport.attach({
existingPtyId: 'pty-restored',
callbacks: {
onData: onDataCallback,
onReplayData
}
})
expect(handle.flush()).toBe('')
expect(onReplayData).toHaveBeenCalledWith(bufferedPayload)
expect(onDataCallback).not.toHaveBeenCalledWith(bufferedPayload)
})
it('routes the attach-time clear sequence through onReplayData for non-alternate-screen sessions', async () => {
const { createIpcPtyTransport } = await import('./pty-transport')
const transport = createIpcPtyTransport()
const onDataCallback = vi.fn()
const onReplayData = vi.fn()
transport.attach({
existingPtyId: 'pty-attached',
callbacks: {
onData: onDataCallback,
onReplayData
}
})
// Why: the clear preamble must travel the replay path so any subsequent
// snapshot bytes sit under the same replay guard in pty-connection.ts.
const clear = '\x1b[2J\x1b[3J\x1b[H'
expect(onReplayData).toHaveBeenCalledWith(clear)
expect(onDataCallback).not.toHaveBeenCalledWith(clear)
})
it('skips the attach-time clear sequence for alternate-screen sessions', async () => {
const { createIpcPtyTransport } = await import('./pty-transport')
const transport = createIpcPtyTransport()
const onDataCallback = vi.fn()
const onReplayData = vi.fn()
transport.attach({
existingPtyId: 'pty-alt-screen',
isAlternateScreen: true,
callbacks: {
onData: onDataCallback,
onReplayData
}
})
// Why: alternate-screen snapshots already fill the viewport; emitting the
// clear would erase the restored content. Neither path should see it.
const clear = '\x1b[2J\x1b[3J\x1b[H'
expect(onReplayData).not.toHaveBeenCalledWith(clear)
expect(onDataCallback).not.toHaveBeenCalledWith(clear)
})
it('passes startup commands through PTY spawn instead of writing them after connect', async () => {
const { createIpcPtyTransport } = await import('./pty-transport')
const spawnMock = vi.fn().mockResolvedValue({ id: 'pty-1' })
@@ -128,10 +128,20 @@ export function createIpcPtyTransport(opts: IpcPtyTransportOptions = {}): PtyTra
agentTracker?.handleTitle(title)
}
// Why: true while we're replaying buffered/attach-time bytes into the
// terminal. Routes those bytes through onReplayData so the renderer can
// engage the replay guard — otherwise xterm auto-replies to embedded
// query sequences leak into the shell as stray input.
let replayingBufferedData = false
// Why: shared by connect() and attach() to avoid duplicating title/bell/exit logic.
function registerPtyDataHandler(id: string): void {
ptyDataHandlers.set(id, (data) => {
storedCallbacks.onData?.(data)
if (replayingBufferedData && storedCallbacks.onReplayData) {
storedCallbacks.onReplayData(data)
} else {
storedCallbacks.onData?.(data)
}
if (onTitleChange) {
const title = extractLastOscTitle(data)
if (title !== null) {
@@ -283,10 +293,16 @@ export function createIpcPtyTransport(opts: IpcPtyTransportOptions = {}): PtyTra
// data so titles and scrollback restore correctly, but it must not
// generate fresh unread badges or notifications for unrelated
// worktrees just because Orca is reconnecting background terminals.
// Why replayingBufferedData: those buffered bytes are raw PTY output
// that may contain query sequences from the previous session; route
// them through onReplayData so the renderer engages the replay guard
// and xterm's auto-replies do not leak into the shell.
suppressAttentionEvents = true
replayingBufferedData = true
try {
ptyDataHandlers.get(id)?.(buffered)
} finally {
replayingBufferedData = false
suppressAttentionEvents = false
}
}
@@ -297,8 +313,15 @@ export function createIpcPtyTransport(opts: IpcPtyTransportOptions = {}): PtyTra
// content doesn't layer on top of stale output. Skip the clear for
// alternate-screen sessions — the snapshot already fills the screen
// and clearing would erase it.
// Why onReplayData: treat this clear as replay-path too so any data
// that immediately follows from the renderer sits under the same guard.
if (!options.isAlternateScreen) {
storedCallbacks.onData?.('\x1b[2J\x1b[3J\x1b[H')
const clear = '\x1b[2J\x1b[3J\x1b[H'
if (storedCallbacks.onReplayData) {
storedCallbacks.onReplayData(clear)
} else {
storedCallbacks.onData?.(clear)
}
}
if (options.cols && options.rows) {
@@ -0,0 +1,118 @@
import { describe, expect, it } from 'vitest'
import type { ManagedPane } from '@/lib/pane-manager/pane-manager'
import { isPaneReplaying, replayIntoTerminal, type ReplayingPanesRef } from './replay-guard'
function makeRef(): ReplayingPanesRef {
return { current: new Map() } as ReplayingPanesRef
}
type FakeTerminal = {
write: (data: string, cb?: () => void) => void
lastData: string[]
pendingCallbacks: (() => void)[]
/** Flush all pending xterm write callbacks, simulating parse completion. */
flush: () => void
}
function makeFakePane(paneId: number): { pane: ManagedPane; terminal: FakeTerminal } {
const pendingCallbacks: (() => void)[] = []
const terminal: FakeTerminal = {
lastData: [],
pendingCallbacks,
write(data: string, cb?: () => void) {
terminal.lastData.push(data)
if (cb) {
pendingCallbacks.push(cb)
}
},
flush() {
while (pendingCallbacks.length > 0) {
pendingCallbacks.shift()!()
}
}
}
// Only `id` and `terminal` are exercised by replayIntoTerminal.
const pane = { id: paneId, terminal } as unknown as ManagedPane
return { pane, terminal }
}
describe('replay-guard', () => {
it('reports no replay for untouched pane', () => {
const ref = makeRef()
expect(isPaneReplaying(ref, 1)).toBe(false)
})
it('is replaying between write dispatch and xterm parse completion', () => {
const ref = makeRef()
const { pane, terminal } = makeFakePane(1)
replayIntoTerminal(pane, ref, 'hello')
// Before xterm fires its write-completion callback, the guard is engaged —
// this is the window during which xterm could emit auto-replies for any
// query sequences embedded in the replayed data.
expect(isPaneReplaying(ref, 1)).toBe(true)
terminal.flush()
expect(isPaneReplaying(ref, 1)).toBe(false)
})
it('composes nested replays via a counter', () => {
const ref = makeRef()
const { pane, terminal } = makeFakePane(1)
// Simulates the cold-restore path: clear preamble + scrollback + banner
// dispatched back-to-back before xterm completes any of them.
replayIntoTerminal(pane, ref, '\x1b[2J\x1b[3J\x1b[H')
replayIntoTerminal(pane, ref, 'scrollback bytes')
replayIntoTerminal(pane, ref, '--- session restored ---')
expect(isPaneReplaying(ref, 1)).toBe(true)
// Completion of the first write must not clear the guard — the later
// writes are still in xterm's queue and may still auto-reply.
terminal.pendingCallbacks.shift()!()
expect(isPaneReplaying(ref, 1)).toBe(true)
terminal.pendingCallbacks.shift()!()
expect(isPaneReplaying(ref, 1)).toBe(true)
terminal.pendingCallbacks.shift()!()
expect(isPaneReplaying(ref, 1)).toBe(false)
})
it('keeps each pane independent', () => {
const ref = makeRef()
const a = makeFakePane(1)
const b = makeFakePane(2)
replayIntoTerminal(a.pane, ref, 'a')
expect(isPaneReplaying(ref, 1)).toBe(true)
expect(isPaneReplaying(ref, 2)).toBe(false)
replayIntoTerminal(b.pane, ref, 'b')
expect(isPaneReplaying(ref, 2)).toBe(true)
a.terminal.flush()
expect(isPaneReplaying(ref, 1)).toBe(false)
expect(isPaneReplaying(ref, 2)).toBe(true)
b.terminal.flush()
expect(isPaneReplaying(ref, 2)).toBe(false)
})
it('skips empty data without touching the guard or xterm', () => {
const ref = makeRef()
const { pane, terminal } = makeFakePane(1)
replayIntoTerminal(pane, ref, '')
expect(terminal.lastData).toEqual([])
expect(isPaneReplaying(ref, 1)).toBe(false)
})
it('removes the counter entry when the last replay completes', () => {
const ref = makeRef()
const { pane, terminal } = makeFakePane(1)
replayIntoTerminal(pane, ref, 'x')
terminal.flush()
expect(ref.current.has(1)).toBe(false)
})
})
@@ -0,0 +1,55 @@
import type { ManagedPane } from '@/lib/pane-manager/pane-manager'
// Why: xterm.js auto-responds to terminal query sequences (DA1 `CSI c`,
// DECRQM `CSI ? Ps $ p`, OSC 10/11 color queries, focus events, CPR) by
// emitting the reply through its onData callback. In pty-connection.ts that
// callback is wired directly to `transport.sendInput`, which pipes the reply
// to the shell's stdin. When we restore terminal state at startup or on
// reattach we write recorded PTY bytes back into xterm — including any
// queries the previous agent CLI emitted — and the auto-replies end up as
// stray characters on the new shell's prompt (e.g. `?1;2c`, `2026;2$y`,
// OSC 10/11 color fragments).
//
// xterm does not expose a `wasUserInput` flag on its public onData, so we
// cannot distinguish replay-induced replies from real keystrokes after the
// fact. Instead, we track an in-flight replay counter per pane: callers
// replay into xterm via `replayIntoTerminal`, which increments the counter,
// writes, and decrements in xterm's write-completion callback. The onData
// handler in pty-connection.ts drops data while the counter is non-zero.
//
// The guard window is bounded by xterm's own parse completion, not a
// wall-clock timer, so only replies generated while parsing the replayed
// bytes are suppressed. User keystrokes typed after the replay completes
// are unaffected. In practice replay finishes within milliseconds — before
// the user could meaningfully type — so the few-ms window where real input
// would also be dropped is acceptable relative to correctness.
export type ReplayingPanesRef = React.RefObject<Map<number, number>>
export function isPaneReplaying(ref: ReplayingPanesRef, paneId: number): boolean {
return (ref.current.get(paneId) ?? 0) > 0
}
/** Writes `data` into the pane's terminal with the replay guard engaged,
* so xterm's auto-replies to embedded query sequences do not leak to the
* shell as input. The counter increments/decrements so nested replays
* (e.g. clear-screen preamble + snapshot body) compose correctly. */
export function replayIntoTerminal(
pane: ManagedPane,
replayingPanesRef: ReplayingPanesRef,
data: string
): void {
if (!data) {
return
}
const map = replayingPanesRef.current
map.set(pane.id, (map.get(pane.id) ?? 0) + 1)
pane.terminal.write(data, () => {
const remaining = (map.get(pane.id) ?? 1) - 1
if (remaining <= 0) {
map.delete(pane.id)
} else {
map.set(pane.id, remaining)
}
})
}
@@ -29,6 +29,7 @@ import type { EffectiveMacOptionAsAlt } from '@/lib/keyboard-layout/detect-optio
import { resolveEffectiveTerminalAppearance } from '@/lib/terminal-theme'
import { connectPanePty } from './pty-connection'
import type { PtyTransport } from './pty-transport'
import type { ReplayingPanesRef } from './replay-guard'
import { fitAndFocusPanes, fitPanes } from './pane-helpers'
import { registerRuntimeTerminalTab, scheduleRuntimeGraphSync } from '@/runtime/sync-runtime-graph'
import { e2eConfig } from '@/lib/e2e-config'
@@ -76,6 +77,7 @@ type UseTerminalPaneLifecycleDeps = {
paneLastThemeModeRef: React.RefObject<Map<number, 'dark' | 'light'>>
panePtyBindingsRef: React.RefObject<Map<number, IDisposable>>
pendingWritesRef: React.RefObject<Map<number, string>>
replayingPanesRef: ReplayingPanesRef
isActiveRef: React.RefObject<boolean>
isVisibleRef: React.RefObject<boolean>
onPtyExitRef: React.RefObject<(ptyId: string) => void>
@@ -154,6 +156,7 @@ export function useTerminalPaneLifecycle({
paneLastThemeModeRef,
panePtyBindingsRef,
pendingWritesRef,
replayingPanesRef,
isActiveRef,
isVisibleRef,
onPtyExitRef,
@@ -286,6 +289,7 @@ export function useTerminalPaneLifecycle({
startup,
paneTransportsRef,
pendingWritesRef,
replayingPanesRef,
isActiveRef,
isVisibleRef,
onPtyExitRef,
@@ -467,6 +471,7 @@ export function useTerminalPaneLifecycle({
clearRuntimePaneTitle(tabId, paneId)
paneFontSizesRef.current.delete(paneId)
pendingWritesRef.current.delete(paneId)
replayingPanesRef.current.delete(paneId)
// Clean up pane title state so closed panes don't leave stale entries.
setPaneTitles((prev) => {
if (!(paneId in prev)) {
@@ -567,7 +572,8 @@ export function useTerminalPaneLifecycle({
restoreScrollbackBuffers(
manager,
initialLayoutRef.current.buffersByLeafId,
restoredPaneByLeafId
restoredPaneByLeafId,
replayingPanesRef
)
// Seed pane titles from the persisted snapshot using the same