mirror of
https://github.com/stablyai/orca.git
synced 2026-11-03 02:27:43 +00:00
fix(terminal): keep keys typed before a pane's terminal exists (#33281)
* fix(terminal): keep keys typed before a pane's PTY exists * fix(terminal): hold pre-spawn keys only in panes with no startup work * fix(terminal): no pre-spawn hold for live-entry resumes or main-started panes * fix(terminal): keep the deferred-split hold exactly as before * fix(terminal): drop held keys if a resume or startup work appears before the bind
This commit is contained in:
@@ -0,0 +1,262 @@
|
||||
import type * as React from 'react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { connectPanePty } from './pty-connection'
|
||||
import { makePaneKey } from '../../../../shared/stable-pane-id'
|
||||
import { flushAsyncTicks } from './pty-connection-test-async'
|
||||
import {
|
||||
createManager,
|
||||
createMockTransport,
|
||||
createPane,
|
||||
leafIdForPane,
|
||||
type MockTransport
|
||||
} from './pty-connection-test-pane-fixtures'
|
||||
import { buildPaneConnectionDeps } from './pty-connection-test-deps'
|
||||
import {
|
||||
installTerminalTestGlobals,
|
||||
restoreTerminalTestGlobals
|
||||
} from './pty-connection-test-environment'
|
||||
import { createInitialStoreState } from './pty-connection-test-store-fixtures'
|
||||
import type { StoreState } from './pty-connection-test-store-state'
|
||||
import { clearWorktreeSleepIntent, markWorktreeSleepIntent } from '@/lib/worktree-sleep-intent'
|
||||
|
||||
const {
|
||||
notifyCodexPaneBoundForStaleSweep,
|
||||
scheduleRuntimeGraphSync,
|
||||
shouldSeedCacheTimerOnInitialTitle,
|
||||
toastInfo
|
||||
} = vi.hoisted(() => ({
|
||||
notifyCodexPaneBoundForStaleSweep: vi.fn(),
|
||||
scheduleRuntimeGraphSync: vi.fn(),
|
||||
shouldSeedCacheTimerOnInitialTitle: vi.fn(() => false),
|
||||
toastInfo: vi.fn()
|
||||
}))
|
||||
|
||||
let mockStoreState: StoreState
|
||||
let transportFactoryQueue: MockTransport[] = []
|
||||
let createdTransportOptions: Record<string, unknown>[] = []
|
||||
let storeSubscribers: ((state: StoreState) => void)[] = []
|
||||
|
||||
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
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('./pty-transport', () => ({
|
||||
createIpcPtyTransport: vi.fn((options: Record<string, unknown>) => {
|
||||
createdTransportOptions.push(options)
|
||||
const nextTransport = transportFactoryQueue.shift()
|
||||
if (!nextTransport) {
|
||||
throw new Error('No mock transport queued')
|
||||
}
|
||||
return nextTransport
|
||||
})
|
||||
}))
|
||||
|
||||
function connectPlainPane(
|
||||
overrides: Record<string, unknown> = {}
|
||||
): ReturnType<typeof connectPanePty> {
|
||||
const connectArgs = [
|
||||
createPane(1),
|
||||
createManager(1),
|
||||
buildPaneConnectionDeps(() => mockStoreState, overrides)
|
||||
]
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the shared pane fixtures implement the members connectPanePty touches.
|
||||
return connectPanePty(...(connectArgs as unknown as Parameters<typeof connectPanePty>))
|
||||
}
|
||||
|
||||
describe('connectPanePty pre-connect input', () => {
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks()
|
||||
transportFactoryQueue = []
|
||||
createdTransportOptions = []
|
||||
storeSubscribers = []
|
||||
mockStoreState = createInitialStoreState(() => mockStoreState)
|
||||
await installTerminalTestGlobals()
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
layoutOwner.isMain = false
|
||||
clearWorktreeSleepIntent('wt-1')
|
||||
await restoreTerminalTestGlobals()
|
||||
})
|
||||
|
||||
it('holds keys typed before an ordinary pane has a PTY', async () => {
|
||||
const transport = createMockTransport('pty-1')
|
||||
transportFactoryQueue.push(transport)
|
||||
|
||||
const binding = connectPlainPane()
|
||||
await flushAsyncTicks(20)
|
||||
|
||||
expect(createdTransportOptions[0]?.bufferInputUntilConnect).toBe(true)
|
||||
binding.dispose()
|
||||
})
|
||||
|
||||
// Why: held keys would flush ahead of the startup command, so a typed prompt would run in the shell.
|
||||
it('keeps today’s drop for a pane with a startup command', async () => {
|
||||
transportFactoryQueue.push(createMockTransport('pty-1'))
|
||||
|
||||
const binding = connectPlainPane({ startup: { command: 'claude' } })
|
||||
await flushAsyncTicks(20)
|
||||
|
||||
expect(createdTransportOptions[0]?.bufferInputUntilConnect).toBeUndefined()
|
||||
binding.dispose()
|
||||
})
|
||||
|
||||
it('keeps holding a deferred split’s keys even when it has startup work, as before', async () => {
|
||||
transportFactoryQueue.push(createMockTransport('pty-1'))
|
||||
|
||||
const binding = connectPlainPane({
|
||||
startup: { command: 'claude' },
|
||||
cwdPromise: new Promise<string>(() => {})
|
||||
})
|
||||
|
||||
expect(createdTransportOptions[0]?.bufferInputUntilConnect).toBe(true)
|
||||
binding.dispose()
|
||||
})
|
||||
|
||||
it('keeps today’s drop for a pane that resumes a sleeping agent', async () => {
|
||||
const paneKey = makePaneKey('tab-1', leafIdForPane(1))
|
||||
mockStoreState.sleepingAgentSessionsByPaneKey[paneKey] = {
|
||||
paneKey,
|
||||
tabId: 'tab-1',
|
||||
worktreeId: 'wt-1'
|
||||
}
|
||||
transportFactoryQueue.push(createMockTransport())
|
||||
|
||||
const binding = connectPlainPane()
|
||||
|
||||
expect(createdTransportOptions[0]?.bufferInputUntilConnect).toBeUndefined()
|
||||
binding.dispose()
|
||||
})
|
||||
|
||||
it('keeps today’s drop for a pane that resumes its live agent status entry', async () => {
|
||||
const paneKey = makePaneKey('tab-1', leafIdForPane(1))
|
||||
mockStoreState.agentStatusByPaneKey[paneKey] = {
|
||||
state: 'working',
|
||||
prompt: '',
|
||||
agentType: 'claude',
|
||||
paneKey,
|
||||
updatedAt: 1_000,
|
||||
stateStartedAt: 1_000,
|
||||
stateHistory: [],
|
||||
providerSession: { key: 'session_id', id: 'session-1' }
|
||||
}
|
||||
transportFactoryQueue.push(createMockTransport())
|
||||
|
||||
const binding = connectPlainPane()
|
||||
|
||||
expect(createdTransportOptions[0]?.bufferInputUntilConnect).toBeUndefined()
|
||||
binding.dispose()
|
||||
})
|
||||
|
||||
it('keeps today’s drop for a pane main starts, whose launch this window cannot see', async () => {
|
||||
layoutOwner.isMain = true
|
||||
transportFactoryQueue.push(createMockTransport())
|
||||
|
||||
const binding = connectPlainPane()
|
||||
|
||||
expect(createdTransportOptions[0]?.bufferInputUntilConnect).toBeUndefined()
|
||||
binding.dispose()
|
||||
})
|
||||
|
||||
it('stops releasing held keys once a resume source lands before the first bind', async () => {
|
||||
transportFactoryQueue.push(createMockTransport())
|
||||
|
||||
const binding = connectPlainPane()
|
||||
const release = createdTransportOptions[0]?.releasePreconnectInput
|
||||
expect(typeof release).toBe('function')
|
||||
expect(typeof release === 'function' && release()).toBe(true)
|
||||
|
||||
const paneKey = makePaneKey('tab-1', leafIdForPane(1))
|
||||
mockStoreState.agentStatusByPaneKey[paneKey] = {
|
||||
state: 'working',
|
||||
prompt: '',
|
||||
agentType: 'claude',
|
||||
paneKey,
|
||||
updatedAt: 1_000,
|
||||
stateStartedAt: 1_000,
|
||||
stateHistory: [],
|
||||
providerSession: { key: 'session_id', id: 'session-1' }
|
||||
}
|
||||
|
||||
expect(typeof release === 'function' && release()).toBe(false)
|
||||
binding.dispose()
|
||||
})
|
||||
|
||||
it('keeps releasing a deferred split’s held keys as before when startup work appears', async () => {
|
||||
transportFactoryQueue.push(createMockTransport())
|
||||
|
||||
const binding = connectPlainPane({ cwdPromise: new Promise<string>(() => {}) })
|
||||
const release = createdTransportOptions[0]?.releasePreconnectInput
|
||||
const paneKey = makePaneKey('tab-1', leafIdForPane(1))
|
||||
mockStoreState.sleepingAgentSessionsByPaneKey[paneKey] = {
|
||||
paneKey,
|
||||
tabId: 'tab-1',
|
||||
worktreeId: 'wt-1'
|
||||
}
|
||||
|
||||
expect(typeof release === 'function' && release()).toBe(true)
|
||||
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() })
|
||||
transportFactoryQueue.push(transport)
|
||||
|
||||
const binding = connectPlainPane()
|
||||
await flushAsyncTicks(20)
|
||||
|
||||
expect(transport.connect).not.toHaveBeenCalled()
|
||||
expect(transport.abandonPreconnectInput).toHaveBeenCalledOnce()
|
||||
binding.dispose()
|
||||
})
|
||||
|
||||
it('keeps a deferred split’s held keys through a deliberate sleep, as before', async () => {
|
||||
markWorktreeSleepIntent('wt-1')
|
||||
const transport = Object.assign(createMockTransport(), { abandonPreconnectInput: vi.fn() })
|
||||
transportFactoryQueue.push(transport)
|
||||
|
||||
const binding = connectPlainPane({ cwdPromise: Promise.resolve('/split') })
|
||||
await flushAsyncTicks(20)
|
||||
|
||||
expect(transport.abandonPreconnectInput).not.toHaveBeenCalled()
|
||||
binding.dispose()
|
||||
})
|
||||
})
|
||||
+25
-14
@@ -20,27 +20,38 @@ import type { ColdRestoreAgentResumeStartup } from './fresh-spawn-types'
|
||||
|
||||
import type { ConnectPanePtySession } from './connect-pane-pty-session'
|
||||
|
||||
type AppState = ReturnType<typeof useAppStore.getState>
|
||||
|
||||
/** The agent session a cold restore of this pane would resume; read-only, so pre-spawn checks can share it. */
|
||||
export function findColdRestoreResumeSource(session: ConnectPanePtySession, state: AppState) {
|
||||
const entry = state.agentStatusByPaneKey[session.cacheKey]
|
||||
const sleepingRecordEntry = session.getSleepingRecordForPane(state)
|
||||
const sleepingRecord = sleepingRecordEntry?.record
|
||||
const liveEntry = entry && entry.state !== 'done' ? entry : null
|
||||
const agent = liveEntry ? liveEntry.agentType : sleepingRecord?.agent
|
||||
if (!agent || !isResumableTuiAgent(agent)) {
|
||||
return null
|
||||
}
|
||||
const providerSession = normalizeAgentProviderSession(
|
||||
liveEntry ? liveEntry.providerSession : sleepingRecord?.providerSession
|
||||
)
|
||||
return providerSession ? { agent, providerSession, liveEntry, sleepingRecordEntry } : null
|
||||
}
|
||||
|
||||
export function bindBuildColdRestoreAgentResumeStartup(session: ConnectPanePtySession): void {
|
||||
session.buildColdRestoreAgentResumeStartup = (): ColdRestoreAgentResumeStartup | null => {
|
||||
if (session.pendingStartupCommand) {
|
||||
return null
|
||||
}
|
||||
const state = useAppStore.getState()
|
||||
const entry = state.agentStatusByPaneKey[session.cacheKey]
|
||||
const sleepingRecordEntry = session.getSleepingRecordForPane(state)
|
||||
const source = findColdRestoreResumeSource(session, state)
|
||||
if (!source) {
|
||||
return null
|
||||
}
|
||||
const { agent, providerSession, sleepingRecordEntry } = source
|
||||
const entry = source.liveEntry
|
||||
const sleepingRecord = sleepingRecordEntry?.record
|
||||
|
||||
const useLiveEntry = entry && entry.state !== 'done'
|
||||
const agent = useLiveEntry ? entry.agentType : sleepingRecord?.agent
|
||||
if (!agent || !isResumableTuiAgent(agent)) {
|
||||
return null
|
||||
}
|
||||
const providerSession = normalizeAgentProviderSession(
|
||||
useLiveEntry ? entry.providerSession : sleepingRecord?.providerSession
|
||||
)
|
||||
if (!providerSession) {
|
||||
return null
|
||||
}
|
||||
const useLiveEntry = entry !== null
|
||||
// Why: this is the second issuer of `--resume`, and the one that handles a quit/live record
|
||||
// whose pane still exists — the sweep hands those here rather than launching them. A session id
|
||||
// names a transcript on the machine that captured it, so replaying one over a pane now attached
|
||||
|
||||
@@ -1,17 +1,48 @@
|
||||
import { useAppStore } from '@/store'
|
||||
import { shouldRetainDisposedPaneSpawn } from './disposed-spawn-retention'
|
||||
import { findColdRestoreResumeSource } from './cold-restore-resume-startup'
|
||||
import { mainStartsPane } from './main-starts-pane'
|
||||
import { requestTerminalPaneRecovery } from '../terminal-pane-recovery'
|
||||
import type { IpcPtyTransportOptions } from '../pty-transport-types'
|
||||
import type { ConnectPanePtySession } from './connect-pane-pty-session'
|
||||
|
||||
/** A split waiting on its inherited cwd, or remounted with handed-off keys; it holds keys as before. */
|
||||
export function isDeferredSplitInputHold(session: ConnectPanePtySession): boolean {
|
||||
return Boolean(session.deps.cwdPromise || session.deps.preconnectInput?.length)
|
||||
}
|
||||
|
||||
/**
|
||||
* Deferred splits and plain shells hold keys typed before their PTY binds (a first spawn can wait
|
||||
* seconds on the host).
|
||||
* Why not panes with startup work: held keys would flush ahead of the startup command or an agent
|
||||
* resume (sleeping record or live status entry), so a typed prompt would run as a shell command or
|
||||
* corrupt the launch line. A pane main starts is excluded too: main picks its launch, unseen here.
|
||||
*/
|
||||
function holdsInputUntilConnect(session: ConnectPanePtySession): boolean {
|
||||
if (isDeferredSplitInputHold(session)) {
|
||||
return true
|
||||
}
|
||||
const state = useAppStore.getState()
|
||||
return (
|
||||
!session.paneStartup &&
|
||||
!session.getSleepingRecordForPane(state) &&
|
||||
!findColdRestoreResumeSource(session, state) &&
|
||||
!mainStartsPane(session)
|
||||
)
|
||||
}
|
||||
|
||||
/** The spawn/attach options a pane hands its PTY transport. */
|
||||
export function buildPaneTransportOptions(session: ConnectPanePtySession): IpcPtyTransportOptions {
|
||||
return {
|
||||
terminalKittyKeyboardProtocol:
|
||||
session.pane.terminal.options.vtExtensions?.kittyKeyboard === true,
|
||||
cwd: session.deps.cwd,
|
||||
...(session.deps.cwdPromise || session.deps.preconnectInput?.length
|
||||
? { bufferInputUntilConnect: true }
|
||||
...(holdsInputUntilConnect(session)
|
||||
? {
|
||||
bufferInputUntilConnect: true,
|
||||
// Why: a resume source can land between mount and bind; keys must not flush ahead of it.
|
||||
releasePreconnectInput: () => holdsInputUntilConnect(session)
|
||||
}
|
||||
: {}),
|
||||
...(session.deps.preconnectInput?.length
|
||||
? { preconnectInput: session.deps.preconnectInput }
|
||||
|
||||
@@ -10,6 +10,7 @@ import { hasWorktreeSleepIntent, onWorktreeSleepIntentCleared } from '@/lib/work
|
||||
import { recordPtyConnectDiagnostic } from './pty-connect-limits'
|
||||
import { defersToMainPaneStart } from './main-pane-start-request'
|
||||
import { findTerminalTabForPane } from './terminal-tab-id'
|
||||
import { isDeferredSplitInputHold } from './pane-transport-options'
|
||||
|
||||
import type { ConnectPanePtySession } from './connect-pane-pty-session'
|
||||
import { bindBuildColdRestoreAgentResumeStartup } from './cold-restore-resume-startup'
|
||||
@@ -52,6 +53,10 @@ export function installRunDeferredConnect(session: ConnectPanePtySession): void
|
||||
}
|
||||
if (!wakeWaitStarted) {
|
||||
wakeWaitStarted = true
|
||||
// Why: keys typed into a slept shell must not run in the shell a later wake reattaches.
|
||||
if (!isDeferredSplitInputHold(session)) {
|
||||
session.transport.abandonPreconnectInput?.()
|
||||
}
|
||||
recordPtyConnectDiagnostic(
|
||||
`pane=${session.pane.id} tab=${session.deps.tabId} -> WAIT FOR WAKE (deliberate sleep)`
|
||||
)
|
||||
|
||||
@@ -117,6 +117,60 @@ describe('createIpcPtyTransport', () => {
|
||||
expect(delivered).toEqual(['first-', 'second-', 'third'])
|
||||
})
|
||||
|
||||
it('delivers keys typed during a slow SSH spawn in order once the PTY binds', async () => {
|
||||
const spawn = createDeferred<{ id: string }>()
|
||||
vi.mocked(window.api.pty.spawn).mockReturnValue(spawn.promise as never)
|
||||
const { createIpcPtyTransport } = await import('./pty-transport')
|
||||
const transport = createIpcPtyTransport({
|
||||
connectionId: 'ssh-1',
|
||||
bufferInputUntilConnect: true
|
||||
})
|
||||
|
||||
expect(transport.sendInput('ec', 'driving')).toBe(true)
|
||||
const connecting = transport.connect({ url: '', callbacks: {} })
|
||||
expect(transport.sendInput('ho hi\r', 'driving')).toBe(true)
|
||||
expect(window.api.pty.write).not.toHaveBeenCalled()
|
||||
|
||||
spawn.resolve({ id: 'pty-1' })
|
||||
await connecting
|
||||
await flushAsyncTicks()
|
||||
expect(vi.mocked(window.api.pty.write).mock.calls).toEqual([
|
||||
['pty-1', 'ec', 'driving'],
|
||||
['pty-1', 'ho hi\r', 'driving']
|
||||
])
|
||||
|
||||
// Live input still writes straight through.
|
||||
expect(transport.sendInput('next', 'driving')).toBe(true)
|
||||
await flushAsyncTicks()
|
||||
expect(window.api.pty.write).toHaveBeenLastCalledWith('pty-1', 'next', 'driving')
|
||||
})
|
||||
|
||||
it('drops held keys instead of flushing them when startup work appeared before the bind', async () => {
|
||||
const spawn = createDeferred<{ id: string }>()
|
||||
vi.mocked(window.api.pty.spawn).mockReturnValue(spawn.promise as never)
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
|
||||
let release = true
|
||||
const { createIpcPtyTransport } = await import('./pty-transport')
|
||||
const transport = createIpcPtyTransport({
|
||||
bufferInputUntilConnect: true,
|
||||
releasePreconnectInput: () => release
|
||||
})
|
||||
|
||||
const connecting = transport.connect({ url: '', callbacks: {} })
|
||||
expect(transport.sendInput('make the tests pass\r', 'driving')).toBe(true)
|
||||
release = false
|
||||
spawn.resolve({ id: 'pty-1' })
|
||||
await connecting
|
||||
await flushAsyncTicks()
|
||||
|
||||
expect(window.api.pty.write).not.toHaveBeenCalled()
|
||||
// Live input after the bind is unchanged.
|
||||
expect(transport.sendInput('next', 'driving')).toBe(true)
|
||||
await flushAsyncTicks()
|
||||
expect(window.api.pty.write).toHaveBeenCalledWith('pty-1', 'next', 'driving')
|
||||
warn.mockRestore()
|
||||
})
|
||||
|
||||
it('flushes remount-handoff input before newly typed input without recapturing the seed', async () => {
|
||||
const spawn = createDeferred<{ id: string }>()
|
||||
vi.mocked(window.api.pty.spawn).mockReturnValue(spawn.promise as never)
|
||||
|
||||
@@ -288,8 +288,10 @@ export type PtyTransport = {
|
||||
|
||||
export type IpcPtyTransportOptions = {
|
||||
cwd?: string
|
||||
/** Retain bounded user input while a visible split waits to start its PTY. */
|
||||
/** Retain bounded user input until the pane's first PTY connection binds. */
|
||||
bufferInputUntilConnect?: 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. */
|
||||
preconnectInput?: readonly PtyPreconnectInputEntry[]
|
||||
/** Records newly retained input against a remount-safe deferred split handoff. */
|
||||
|
||||
@@ -105,6 +105,10 @@ export function createIpcPtyTransport(opts: IpcPtyTransportOptions = {}): PtyTra
|
||||
}
|
||||
return
|
||||
}
|
||||
if (opts.releasePreconnectInput && !opts.releasePreconnectInput()) {
|
||||
dropPreconnectInput('startup work appeared before it attached')
|
||||
return
|
||||
}
|
||||
await preconnectInputBuffer.flush({
|
||||
isCurrent: () => !destroyed && connected && ptyId === id,
|
||||
sendInput: (data, inputKind) => inputWriteQueue.enqueue(id, data, inputKind),
|
||||
|
||||
Reference in New Issue
Block a user