mirror of
https://github.com/stablyai/orca.git
synced 2026-10-31 00:57:45 +00:00
fix(terminal): keep a brand-new remote pane's typing for the terminal it creates (#33859)
* fix(terminal): keep a brand-new remote pane's typing for the terminal it creates * fix(terminal): new-pane typing goes only to a genuinely fresh create, never an adopted or restored terminal * fix(terminal): decide a new pane looked blank at connect; at bind re-check only what the create can't cause * test(terminal): replayed history from the real restore path drops a new remote pane's typing * fix(terminal): type the replayed-history marks by xterm Terminal
This commit is contained in:
@@ -3,6 +3,7 @@ import type {
|
||||
TerminalPaneLayoutNode,
|
||||
TerminalPaneSplitDirection
|
||||
} from '../../../../shared/terminal-tab-types'
|
||||
import { markTerminalShowsReplayedHistory } from './replayed-history-terminals'
|
||||
import { isTerminalLeafId } from '../../../../shared/stable-pane-id'
|
||||
import {
|
||||
POST_REPLAY_MODE_RESET,
|
||||
@@ -126,6 +127,7 @@ export function restoreScrollbackBuffers(
|
||||
})
|
||||
continue
|
||||
}
|
||||
markTerminalShowsReplayedHistory(pane.terminal)
|
||||
try {
|
||||
const renderOptions = {
|
||||
shouldRefreshViewportSynchronously: () => !manager.hasWebglRenderer(pane.id)
|
||||
|
||||
@@ -247,6 +247,44 @@ describe('connectPanePty pre-connect input', () => {
|
||||
binding.dispose()
|
||||
})
|
||||
|
||||
it('keeps a brand-new pane’s typing for the terminal it creates, but not a restored pane’s', async () => {
|
||||
transportFactoryQueue.push(createMockTransport(), createMockTransport())
|
||||
mockStoreState.terminalLayoutsByTabId = {}
|
||||
|
||||
const fresh = connectPlainPane()
|
||||
const restored = connectPlainPane({
|
||||
restoredLeafId: leafIdForPane(1),
|
||||
restoredPtyIdByLeafId: { [leafIdForPane(1)]: 'remote:env-1@@terminal-1' }
|
||||
})
|
||||
|
||||
const keepFresh = createdTransportOptions[0]?.keepTypeAheadForNewTerminal
|
||||
const keepRestored = createdTransportOptions[1]?.keepTypeAheadForNewTerminal
|
||||
expect(typeof keepFresh === 'function' && keepFresh()).toBe(true)
|
||||
expect(typeof keepRestored === 'function' && keepRestored()).toBe(false)
|
||||
fresh.dispose()
|
||||
restored.dispose()
|
||||
})
|
||||
|
||||
// Why: a cleared binding can still replay saved history into the pane.
|
||||
it('stops keeping a new pane’s typing once replayed history shows in it', async () => {
|
||||
transportFactoryQueue.push(createMockTransport())
|
||||
mockStoreState.terminalLayoutsByTabId = {}
|
||||
const pane = createPane(1)
|
||||
const connectArgs = [pane, createManager(1), buildPaneConnectionDeps(() => mockStoreState)]
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the shared pane fixtures implement the members connectPanePty touches.
|
||||
const binding = connectPanePty(...(connectArgs as unknown as Parameters<typeof connectPanePty>))
|
||||
const keep = createdTransportOptions[0]?.keepTypeAheadForNewTerminal
|
||||
expect(typeof keep === 'function' && keep()).toBe(true)
|
||||
|
||||
// The fresh create's own prompt and leaf binding count only before it starts, never at bind.
|
||||
pane.terminal.buffer.active.baseY = 12
|
||||
expect(typeof keep === 'function' && keep('connect')).toBe(false)
|
||||
expect(typeof keep === 'function' && keep('bind')).toBe(true)
|
||||
// Replayed history (checked at bind too) is covered through the real restore path in
|
||||
// pty-connection-remote-new-terminal-typeahead.test.ts.
|
||||
binding.dispose()
|
||||
})
|
||||
|
||||
it('drops keys typed into a deliberately slept pane instead of replaying them on wake', async () => {
|
||||
markWorktreeSleepIntent('wt-1')
|
||||
const transport = Object.assign(createMockTransport(), { abandonPreconnectInput: vi.fn() })
|
||||
|
||||
+238
@@ -0,0 +1,238 @@
|
||||
import type * as React from 'react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { connectPanePty } from './pty-connection'
|
||||
import { restoreScrollbackBuffers } from './layout-serialization'
|
||||
import { createDeferred } from './pty-connection-test-async'
|
||||
import { createManager, createPane, leafIdForPane } from './pty-connection-test-pane-fixtures'
|
||||
import { buildPaneConnectionDeps } from './pty-connection-test-deps'
|
||||
import {
|
||||
installTerminalTestGlobals,
|
||||
restoreTerminalTestGlobals
|
||||
} from './pty-connection-test-environment'
|
||||
import {
|
||||
buildActiveRuntimeEnvironmentState,
|
||||
createInitialStoreState
|
||||
} from './pty-connection-test-store-fixtures'
|
||||
import type { StoreState } from './pty-connection-test-store-state'
|
||||
import {
|
||||
createRemoteRuntimeTransportMocks,
|
||||
type MultiplexSubscriptionCallbacks
|
||||
} from './remote-runtime-pty-transport-test-harness'
|
||||
|
||||
// Real options builder and real remote transport: the fresh create's own layout binding must not
|
||||
// read as an old terminal when the held typing settles.
|
||||
|
||||
const {
|
||||
notifyCodexPaneBoundForStaleSweep,
|
||||
scheduleRuntimeGraphSync,
|
||||
shouldSeedCacheTimerOnInitialTitle,
|
||||
toastInfo
|
||||
} = vi.hoisted(() => ({
|
||||
notifyCodexPaneBoundForStaleSweep: vi.fn(),
|
||||
scheduleRuntimeGraphSync: vi.fn(),
|
||||
shouldSeedCacheTimerOnInitialTitle: vi.fn(() => false),
|
||||
toastInfo: vi.fn()
|
||||
}))
|
||||
|
||||
let mockStoreState: StoreState
|
||||
let storeSubscribers: ((state: StoreState) => void)[] = []
|
||||
let callbacks: MultiplexSubscriptionCallbacks = null
|
||||
let resolvedHandle = 'terminal-1'
|
||||
|
||||
const layoutOwner = vi.hoisted(() => ({ isMain: false }))
|
||||
|
||||
vi.mock('../../../../shared/layout-owner-switch', () => ({
|
||||
get ORCA_LAYOUT_OWNER_IS_MAIN() {
|
||||
return layoutOwner.isMain
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/runtime/sync-runtime-graph', () => ({ scheduleRuntimeGraphSync }))
|
||||
|
||||
vi.mock('@/store', () => ({
|
||||
useAppStore: {
|
||||
getState: () => mockStoreState,
|
||||
subscribe: (listener: (state: StoreState) => void) => {
|
||||
storeSubscribers.push(listener)
|
||||
return () => {
|
||||
storeSubscribers = storeSubscribers.filter((candidate) => candidate !== listener)
|
||||
}
|
||||
}
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/agent-status', async (importOriginal) => {
|
||||
const { buildAgentStatusModuleMock } = await import('./pty-connection-test-environment')
|
||||
return buildAgentStatusModuleMock(await importOriginal<Record<string, unknown>>())
|
||||
})
|
||||
|
||||
vi.mock('./cache-timer-seeding', () => ({ shouldSeedCacheTimerOnInitialTitle }))
|
||||
|
||||
vi.mock('sonner', () => ({ toast: { info: toastInfo } }))
|
||||
|
||||
vi.mock('@/lib/codex-stale-pane-sweep', () => ({ notifyCodexPaneBoundForStaleSweep }))
|
||||
|
||||
vi.mock('react', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof React>()
|
||||
return {
|
||||
...actual,
|
||||
useCallback: <T extends (...args: unknown[]) => unknown>(fn: T): T => fn
|
||||
}
|
||||
})
|
||||
|
||||
const {
|
||||
runtimeCall,
|
||||
runtimeSubscribe,
|
||||
latestSubscribePayload,
|
||||
emitSnapshot,
|
||||
inputFrameTexts,
|
||||
resetRemoteRuntimeTransport
|
||||
} = createRemoteRuntimeTransportMocks({
|
||||
getCallbacks: () => callbacks,
|
||||
setCallbacks: (value) => {
|
||||
callbacks = value
|
||||
},
|
||||
getResolvedPaneHandle: () => resolvedHandle,
|
||||
setResolvedPaneHandle: (value) => {
|
||||
resolvedHandle = value
|
||||
}
|
||||
})
|
||||
|
||||
type CreatedTerminal = {
|
||||
handle: string
|
||||
isReattach?: true
|
||||
coldRestore?: { scrollback: string; cwd: string }
|
||||
}
|
||||
|
||||
async function connectNewRemotePane() {
|
||||
const created = createDeferred<CreatedTerminal | Error>()
|
||||
const call = runtimeCall.getMockImplementation()
|
||||
runtimeCall.mockImplementation(async (request: { method: string }) => {
|
||||
if (request.method !== 'terminal.create') {
|
||||
return call?.(request)
|
||||
}
|
||||
const outcome = await created.promise
|
||||
if (outcome instanceof Error) {
|
||||
throw outcome
|
||||
}
|
||||
return { ok: true, result: { terminal: outcome } }
|
||||
})
|
||||
const pane = createPane(1)
|
||||
const userInputListeners: (() => void)[] = []
|
||||
Object.assign(pane.terminal, {
|
||||
_core: {
|
||||
coreService: {
|
||||
onUserInput: (listener: () => void) => {
|
||||
userInputListeners.push(listener)
|
||||
return { dispose: vi.fn() }
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
const connectArgs = [pane, createManager(1), buildPaneConnectionDeps(() => mockStoreState)]
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the shared pane fixtures implement the members connectPanePty touches.
|
||||
const binding = connectPanePty(...(connectArgs as unknown as Parameters<typeof connectPanePty>))
|
||||
await vi.waitFor(() =>
|
||||
expect(runtimeCall).toHaveBeenCalledWith(expect.objectContaining({ method: 'terminal.create' }))
|
||||
)
|
||||
return {
|
||||
pane,
|
||||
created,
|
||||
binding,
|
||||
type: (data: string) => {
|
||||
for (const listener of userInputListeners) {
|
||||
listener()
|
||||
}
|
||||
for (const [listener] of pane.terminal.onData.mock.calls) {
|
||||
listener(data)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function completeAttach(): Promise<void> {
|
||||
await vi.waitFor(() => expect(latestSubscribePayload()).toBeDefined())
|
||||
const { streamId } = latestSubscribePayload()
|
||||
callbacks?.onResponse({ ok: true, result: { type: 'subscribed', streamId } })
|
||||
emitSnapshot(streamId, 'prompt$ ')
|
||||
}
|
||||
|
||||
describe('a brand-new remote pane through connectPanePty', () => {
|
||||
beforeEach(async () => {
|
||||
resetRemoteRuntimeTransport()
|
||||
storeSubscribers = []
|
||||
mockStoreState = buildActiveRuntimeEnvironmentState(
|
||||
createInitialStoreState(() => mockStoreState),
|
||||
'env-1'
|
||||
)
|
||||
mockStoreState.tabsByWorktree = { 'wt-1': [{ id: 'tab-1', ptyId: null }] }
|
||||
mockStoreState.ptyIdsByTabId = {}
|
||||
const layout = mockStoreState.terminalLayoutsByTabId?.['tab-1']
|
||||
if (layout) {
|
||||
layout.ptyIdsByLeafId = {}
|
||||
}
|
||||
await installTerminalTestGlobals()
|
||||
Object.assign(window.api, {
|
||||
runtimeEnvironments: { call: runtimeCall, subscribe: runtimeSubscribe }
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await restoreTerminalTestGlobals()
|
||||
})
|
||||
|
||||
it('sends typing from before the create to the terminal the create makes', async () => {
|
||||
const pane = await connectNewRemotePane()
|
||||
pane.type('echo hi')
|
||||
pane.type('\r')
|
||||
pane.created.resolve({ handle: 'terminal-new' })
|
||||
await completeAttach()
|
||||
|
||||
await vi.waitFor(() => expect(inputFrameTexts().join('')).toBe('echo hi\r'))
|
||||
// The create bound the leaf itself; that binding is not an old terminal.
|
||||
expect(
|
||||
mockStoreState.terminalLayoutsByTabId?.['tab-1']?.ptyIdsByLeafId?.[leafIdForPane(1)]
|
||||
).toBe('remote:env-1@@terminal-new')
|
||||
pane.binding.dispose()
|
||||
})
|
||||
|
||||
it.each([
|
||||
['an adopted terminal', { handle: 'terminal-live', isReattach: true as const }],
|
||||
[
|
||||
'a cold-restored terminal',
|
||||
{ handle: 'terminal-restored', coldRestore: { scrollback: 'old$ ', cwd: '/w' } }
|
||||
]
|
||||
])('drops the typing when the create returns %s', async (_label, terminal) => {
|
||||
const pane = await connectNewRemotePane()
|
||||
pane.type('rm -rf build\r')
|
||||
pane.created.resolve(terminal)
|
||||
await completeAttach()
|
||||
pane.type('live')
|
||||
await vi.waitFor(() => expect(inputFrameTexts().join('')).toMatch(/live$/))
|
||||
|
||||
expect(inputFrameTexts().join('')).toBe('live')
|
||||
pane.binding.dispose()
|
||||
})
|
||||
|
||||
it('drops the typing when saved history is replayed into the pane before it binds', async () => {
|
||||
const pane = await connectNewRemotePane()
|
||||
pane.type('typed on old history\r')
|
||||
// The real restore path: saved scrollback for this leaf lands in the new pane.
|
||||
const manager = { getPanes: () => [pane.pane], hasWebglRenderer: () => false }
|
||||
restoreScrollbackBuffers(
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: restoreScrollbackBuffers reads only getPanes and hasWebglRenderer.
|
||||
manager as unknown as Parameters<typeof restoreScrollbackBuffers>[0],
|
||||
{ [leafIdForPane(1)]: 'old$ make\r\n' },
|
||||
new Map([[leafIdForPane(1), pane.pane.id]]),
|
||||
{ current: new Map<number, number>() }
|
||||
)
|
||||
pane.created.resolve({ handle: 'terminal-new' })
|
||||
await completeAttach()
|
||||
// A key typed after the bind proves anything held ahead of it has settled.
|
||||
pane.type('live')
|
||||
await vi.waitFor(() => expect(inputFrameTexts().join('')).toMatch(/live$/))
|
||||
|
||||
expect(inputFrameTexts().join('')).toBe('live')
|
||||
pane.binding.dispose()
|
||||
})
|
||||
})
|
||||
@@ -1,4 +1,6 @@
|
||||
import { useAppStore } from '@/store'
|
||||
import { terminalShowsReplayedHistory } from '../replayed-history-terminals'
|
||||
import { isWebTerminalSurfaceTabId } from '../../../../../shared/terminal-surface-id'
|
||||
import { shouldRetainDisposedPaneSpawn } from './disposed-spawn-retention'
|
||||
import { findColdRestoreResumeSource } from './cold-restore-resume-startup'
|
||||
import { mainStartsPane } from './main-starts-pane'
|
||||
@@ -35,6 +37,45 @@ function holdsInputUntilConnect(session: ConnectPanePtySession): boolean {
|
||||
return !paneHasStartupWork(session)
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the pane may show an old terminal: it mirrors a host's tab, or saved history was replayed
|
||||
* into it (a cleared binding can still replay). Before its first connect, also: its leaf names a
|
||||
* terminal or it shows output. Those are not re-read at bind, where the fresh create causes both.
|
||||
*/
|
||||
function paneMayShowOldTerminal(session: ConnectPanePtySession, atBind: boolean): boolean {
|
||||
if (
|
||||
isWebTerminalSurfaceTabId(session.deps.tabId) ||
|
||||
terminalShowsReplayedHistory(session.pane.terminal)
|
||||
) {
|
||||
return true
|
||||
}
|
||||
if (atBind) {
|
||||
return false
|
||||
}
|
||||
const state = useAppStore.getState()
|
||||
return Boolean(
|
||||
session.restoredPtyIdForTransport ||
|
||||
state.terminalLayoutsByTabId[session.deps.tabId]?.ptyIdsByLeafId?.[session.pane.leafId] ||
|
||||
terminalShowsOutput(session.pane.terminal)
|
||||
)
|
||||
}
|
||||
|
||||
function terminalShowsOutput(terminal: ConnectPanePtySession['pane']['terminal']): boolean {
|
||||
const buffer = terminal.buffer?.active
|
||||
if (!buffer) {
|
||||
return false
|
||||
}
|
||||
if (buffer.baseY > 0) {
|
||||
return true
|
||||
}
|
||||
for (let row = 0; row < buffer.length; row += 1) {
|
||||
if (buffer.getLine(row)?.translateToString(true)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
export function buildPaneTransportOptions(session: ConnectPanePtySession): IpcPtyTransportOptions {
|
||||
const holdsInput = holdsInputUntilConnect(session)
|
||||
return {
|
||||
@@ -48,6 +89,13 @@ export function buildPaneTransportOptions(session: ConnectPanePtySession): IpcPt
|
||||
releasePreconnectInput: () => holdsInputUntilConnect(session)
|
||||
}
|
||||
: {}),
|
||||
// Why: typing on old pixels must never reach a fresh shell (#10065).
|
||||
...(holdsInput
|
||||
? {
|
||||
keepTypeAheadForNewTerminal: (phase: 'connect' | 'bind') =>
|
||||
!paneMayShowOldTerminal(session, phase === 'bind')
|
||||
}
|
||||
: {}),
|
||||
// Why gated: a seed alone arms the transport hold, which would flush ahead of startup work.
|
||||
...(holdsInput && session.deps.preconnectInput?.length
|
||||
? { preconnectInput: session.deps.preconnectInput }
|
||||
|
||||
@@ -269,6 +269,9 @@ export type IpcPtyTransportOptions = {
|
||||
cwd?: string
|
||||
/** Retain bounded user input until the pane's first PTY connection binds. */
|
||||
bufferInputUntilConnect?: boolean
|
||||
/** A new pane with no old screen: typing before its first remote terminal exists goes to it. */
|
||||
/** 'connect' decides once that the pane looked blank; 'bind' re-checks only what a create can't cause. */
|
||||
keepTypeAheadForNewTerminal?: (phase: 'connect' | 'bind') => boolean
|
||||
/** Re-checked when held input would flush; false drops it (startup work appeared after the hold began). */
|
||||
releasePreconnectInput?: () => boolean
|
||||
/** Seed a fresh transport with input handed off from a remounted deferred split. */
|
||||
|
||||
+127
-4
@@ -55,8 +55,14 @@ async function completeAttach(): Promise<void> {
|
||||
}
|
||||
|
||||
/** Holds `terminal.create` open until the spec settles it with the handle the host returns. */
|
||||
async function createGatedTransport() {
|
||||
const created = createDeferred<string | Error>()
|
||||
type CreatedTerminal = {
|
||||
handle: string
|
||||
isReattach?: true
|
||||
coldRestore?: { scrollback: string; cwd: string }
|
||||
}
|
||||
|
||||
async function createGatedTransport(options: Record<string, unknown> = {}) {
|
||||
const created = createDeferred<string | Error | CreatedTerminal>()
|
||||
// Acknowledged writes to a host without input acks go out as `terminal.send` calls.
|
||||
const acceptedSends: string[] = []
|
||||
const call = runtimeCall.getMockImplementation()
|
||||
@@ -73,11 +79,13 @@ async function createGatedTransport() {
|
||||
if (outcome instanceof Error) {
|
||||
throw outcome
|
||||
}
|
||||
return { ok: true, result: { terminal: { handle: outcome, isReattach: true } } }
|
||||
// A bare handle stands for the host adopting this pane's existing terminal.
|
||||
const terminal = typeof outcome === 'string' ? { handle: outcome, isReattach: true } : outcome
|
||||
return { ok: true, result: { terminal } }
|
||||
}
|
||||
)
|
||||
const { createRemoteRuntimePtyTransport } = await import('./remote-runtime-pty-transport')
|
||||
const transport = createRemoteRuntimePtyTransport('env-1', { worktreeId: 'wt-1' })
|
||||
const transport = createRemoteRuntimePtyTransport('env-1', { worktreeId: 'wt-1', ...options })
|
||||
const waitForCreate = () =>
|
||||
vi.waitFor(() => {
|
||||
expect(runtimeCall).toHaveBeenCalledWith(
|
||||
@@ -235,4 +243,119 @@ describe('remote reattach type-ahead', () => {
|
||||
transport.destroy?.()
|
||||
}
|
||||
)
|
||||
|
||||
describe('a brand-new pane', () => {
|
||||
it('delivers typing from before and during its first create to the terminal it makes', async () => {
|
||||
const { transport, created, waitForCreate } = await createGatedTransport({
|
||||
keepTypeAheadForNewTerminal: () => true
|
||||
})
|
||||
expect(transport.sendInput('ec', 'driving')).toBe(true)
|
||||
const connecting = transport.connect({ url: '', callbacks: {} })
|
||||
await waitForCreate()
|
||||
expect(transport.sendInput('ho hi\r', 'driving')).toBe(true)
|
||||
expect(inputFrameTexts()).toEqual([])
|
||||
created.resolve({ handle: 'terminal-new' })
|
||||
await connecting
|
||||
await completeAttach()
|
||||
await vi.waitFor(() => expect(inputFrameTexts().join('')).toBe('echo hi\r'))
|
||||
transport.destroy?.()
|
||||
})
|
||||
|
||||
it('drops its typing when startup work appeared before the terminal bound', async () => {
|
||||
const { transport, created, waitForCreate } = await createGatedTransport({
|
||||
keepTypeAheadForNewTerminal: () => true,
|
||||
releasePreconnectInput: () => false
|
||||
})
|
||||
expect(transport.sendInput('make the tests pass\r', 'driving')).toBe(true)
|
||||
const connecting = transport.connect({ url: '', callbacks: {} })
|
||||
await waitForCreate()
|
||||
created.resolve({ handle: 'terminal-new' })
|
||||
await connecting
|
||||
await completeAttach()
|
||||
expect(transport.sendInput('live', 'driving')).toBe(true)
|
||||
await vi.waitFor(() => expect(inputFrameTexts().join('')).toMatch(/live$/))
|
||||
expect(inputFrameTexts().join('')).toBe('live')
|
||||
transport.destroy?.()
|
||||
})
|
||||
|
||||
it('drops its typing when the first create binds no terminal', async () => {
|
||||
const { transport, created, waitForCreate } = await createGatedTransport({
|
||||
keepTypeAheadForNewTerminal: () => true
|
||||
})
|
||||
const accepted = transport.sendInputAccepted?.('lost\r', 'driving')
|
||||
const connecting = transport.connect({ url: '', callbacks: {} })
|
||||
await waitForCreate()
|
||||
created.resolve(new Error('offline'))
|
||||
await connecting
|
||||
await expect(accepted).resolves.toBe(false)
|
||||
await flushAsyncTicks()
|
||||
expect(inputFrameTexts()).toEqual([])
|
||||
transport.destroy?.()
|
||||
})
|
||||
// Why: the host adopts a live terminal for this tab and leaf, or restores history into a new
|
||||
// one; typing made for a blank pane must not run in either (#10065).
|
||||
it.each([
|
||||
['an adopted terminal', { handle: 'terminal-live', isReattach: true as const }],
|
||||
[
|
||||
'a cold-restored terminal',
|
||||
{ handle: 'terminal-restored', coldRestore: { scrollback: 'old$ ', cwd: '/w' } }
|
||||
]
|
||||
])('drops its typing when the first create returns %s', async (_label, terminal) => {
|
||||
const { transport, created, waitForCreate } = await createGatedTransport({
|
||||
keepTypeAheadForNewTerminal: () => true
|
||||
})
|
||||
expect(transport.sendInput('rm -rf build\r', 'driving')).toBe(true)
|
||||
const connecting = transport.connect({ url: '', callbacks: {} })
|
||||
await waitForCreate()
|
||||
created.resolve(terminal)
|
||||
await connecting
|
||||
await completeAttach()
|
||||
expect(transport.sendInput('live', 'driving')).toBe(true)
|
||||
await vi.waitFor(() => expect(inputFrameTexts().join('')).toMatch(/live$/))
|
||||
expect(inputFrameTexts().join('')).toBe('live')
|
||||
transport.destroy?.()
|
||||
})
|
||||
|
||||
it('drops typing held across a failed first create when a later connect creates', async () => {
|
||||
const { transport, created, waitForCreate } = await createGatedTransport({
|
||||
keepTypeAheadForNewTerminal: () => true
|
||||
})
|
||||
expect(transport.sendInput('first try\r', 'driving')).toBe(true)
|
||||
const first = transport.connect({ url: '', callbacks: {} })
|
||||
await waitForCreate()
|
||||
created.resolve(new Error('offline'))
|
||||
await first
|
||||
const call = runtimeCall.getMockImplementation()
|
||||
runtimeCall.mockImplementation(async (request: { method: string }) =>
|
||||
request.method === 'terminal.create'
|
||||
? { ok: true, result: { terminal: { handle: 'terminal-second' } } }
|
||||
: call?.(request)
|
||||
)
|
||||
transport.sendInput('between\r', 'driving')
|
||||
await transport.connect({ url: '', callbacks: {} })
|
||||
await completeAttach()
|
||||
expect(transport.sendInput('live', 'driving')).toBe(true)
|
||||
await vi.waitFor(() => expect(inputFrameTexts().join('')).toMatch(/live$/))
|
||||
expect(inputFrameTexts().join('')).toBe('live')
|
||||
transport.destroy?.()
|
||||
})
|
||||
|
||||
it('drops its typing when replayed history appeared before the terminal bound', async () => {
|
||||
let blank = true
|
||||
const { transport, created, waitForCreate } = await createGatedTransport({
|
||||
keepTypeAheadForNewTerminal: () => blank
|
||||
})
|
||||
const connecting = transport.connect({ url: '', callbacks: {} })
|
||||
await waitForCreate()
|
||||
expect(transport.sendInput('typed on old pixels\r', 'driving')).toBe(true)
|
||||
blank = false
|
||||
created.resolve({ handle: 'terminal-new' })
|
||||
await connecting
|
||||
await completeAttach()
|
||||
expect(transport.sendInput('live', 'driving')).toBe(true)
|
||||
await vi.waitFor(() => expect(inputFrameTexts().join('')).toMatch(/live$/))
|
||||
expect(inputFrameTexts().join('')).toBe('live')
|
||||
transport.destroy?.()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -335,6 +335,7 @@ export function createRemoteRuntimePtyTransport(
|
||||
}
|
||||
|
||||
const recoveryInputHold = createRemoteRuntimeRecoveryInputHold({ holdBeforeFirstConnect: true })
|
||||
let isFirstConnect = true
|
||||
let heldInputRelease: {
|
||||
stream: RemoteRuntimeMultiplexedTerminal
|
||||
waitingForValidation: boolean
|
||||
@@ -3368,12 +3369,31 @@ export function createRemoteRuntimePtyTransport(
|
||||
if (!replay) {
|
||||
settleSignInConnectWait()
|
||||
}
|
||||
const expected = recoveryInputHold.expect(restoredInputEndpoint(options.sessionId))
|
||||
// Why: a brand-new pane shows no old screen, so its typing belongs to the terminal it creates.
|
||||
const newTerminal =
|
||||
!options.sessionId && isFirstConnect && opts.keepTypeAheadForNewTerminal?.('connect') === true
|
||||
isFirstConnect = false
|
||||
const expected = newTerminal
|
||||
? recoveryInputHold.expectNewTerminal()
|
||||
: recoveryInputHold.expect(restoredInputEndpoint(options.sessionId))
|
||||
let result: PtyConnectResult | undefined
|
||||
try {
|
||||
result = await connectPane(options)
|
||||
} finally {
|
||||
recoveryInputHold.settleExpectation(expected, handle !== null && !destroyed)
|
||||
const boundHandle = handle !== null && !destroyed ? handle : null
|
||||
// Why a genuine create only: an adopted or cold-restored terminal is not the one typed into.
|
||||
const createdFresh = Boolean(result && !result.isReattach && !result.coldRestore)
|
||||
recoveryInputHold.settleExpectation(
|
||||
expected,
|
||||
boundHandle !== null,
|
||||
// Startup work, or replayed history, that appeared since mount must not run behind typing.
|
||||
boundHandle &&
|
||||
createdFresh &&
|
||||
opts.releasePreconnectInput?.() !== false &&
|
||||
opts.keepTypeAheadForNewTerminal?.('bind') === true
|
||||
? heldInputEndpoint(boundHandle)
|
||||
: null
|
||||
)
|
||||
if (handle && recoveryInputHold.isHolding()) {
|
||||
releaseHeldInput()
|
||||
}
|
||||
|
||||
@@ -27,7 +27,11 @@ type HeldInput = {
|
||||
}
|
||||
|
||||
/** One connect's claim on pre-bind typing; identity decides which connect may settle it. */
|
||||
export type RestoredInputExpectation = { readonly endpoint: HeldEndpoint }
|
||||
export type RestoredInputExpectation = {
|
||||
readonly endpoint: HeldEndpoint
|
||||
/** A brand-new pane's first create: its pre-bind typing belongs to whatever terminal it makes. */
|
||||
readonly newTerminal?: true
|
||||
}
|
||||
|
||||
export type RemoteRuntimeRecoveryInputHold = {
|
||||
isHolding: () => boolean
|
||||
@@ -42,8 +46,17 @@ export type RemoteRuntimeRecoveryInputHold = {
|
||||
) => Promise<boolean>
|
||||
/** Binds pre-bind typing to the terminal a connect restores; null (a fresh create) drops it. */
|
||||
expect: (endpoint: HeldEndpoint) => RestoredInputExpectation
|
||||
/** Ends the latest connect's expectation: without a bound terminal its typing is dropped. */
|
||||
settleExpectation: (expectation: RestoredInputExpectation, bound: boolean) => void
|
||||
/** Keeps a brand-new pane's pre-bind typing for the terminal its first create makes. */
|
||||
expectNewTerminal: () => RestoredInputExpectation
|
||||
/**
|
||||
* Ends the latest connect's expectation: without a bound terminal its typing is dropped. A
|
||||
* new-terminal expectation keeps it only when `createdEndpoint` names the terminal it made.
|
||||
*/
|
||||
settleExpectation: (
|
||||
expectation: RestoredInputExpectation,
|
||||
bound: boolean,
|
||||
createdEndpoint?: RemoteRuntimeInputEndpoint | null
|
||||
) => void
|
||||
/** Delivers held input to the endpoint it was typed into, or drops it if the pane rebound elsewhere. */
|
||||
release: (
|
||||
bound: RemoteRuntimeInputEndpoint,
|
||||
@@ -128,7 +141,16 @@ export function createRemoteRuntimeRecoveryInputHold(
|
||||
expected.beforeBind = true
|
||||
return expectation
|
||||
},
|
||||
settleExpectation(expectation, bound) {
|
||||
expectNewTerminal() {
|
||||
const expectation: RestoredInputExpectation = { endpoint: null, newTerminal: true }
|
||||
latestExpectation = expectation
|
||||
if (!held?.buffer.isBuffering() || held.endpoint) {
|
||||
discard()
|
||||
held = newHold(null, true)
|
||||
}
|
||||
return expectation
|
||||
},
|
||||
settleExpectation(expectation, bound, createdEndpoint) {
|
||||
if (expectation !== latestExpectation) {
|
||||
return
|
||||
}
|
||||
@@ -136,11 +158,14 @@ export function createRemoteRuntimeRecoveryInputHold(
|
||||
if (!held?.beforeBind) {
|
||||
return
|
||||
}
|
||||
if (bound) {
|
||||
held.beforeBind = false
|
||||
} else {
|
||||
if (!bound || (expectation.newTerminal && !createdEndpoint)) {
|
||||
discard()
|
||||
return
|
||||
}
|
||||
if (expectation.newTerminal) {
|
||||
held.endpoint = createdEndpoint ?? null
|
||||
}
|
||||
held.beforeBind = false
|
||||
},
|
||||
release(bound, writer) {
|
||||
const current = held
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import type { Terminal } from '@xterm/xterm'
|
||||
|
||||
// Terminals saved history was replayed into: what the user sees there is an old session, not a blank pane.
|
||||
const terminalsWithReplayedHistory = new WeakSet<Terminal>()
|
||||
|
||||
export function markTerminalShowsReplayedHistory(terminal: Terminal): void {
|
||||
terminalsWithReplayedHistory.add(terminal)
|
||||
}
|
||||
|
||||
export function terminalShowsReplayedHistory(terminal: Terminal): boolean {
|
||||
return terminalsWithReplayedHistory.has(terminal)
|
||||
}
|
||||
Reference in New Issue
Block a user