Fix terminal paste cancellation on transient blur (#6232)

Fix terminal paste cancellation on transient blur
This commit is contained in:
Jinwoo Hong 2026-06-23 21:24:13 -07:00 committed by GitHub
parent 7c9943959e
commit 1b6e65c4ef
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 187 additions and 6 deletions

View File

@ -17,6 +17,21 @@ function makePaneContainer(acceptedElement: Element): Element {
} as unknown as Element
}
function makePaneContainerFor(acceptedElements: readonly Element[]): Element {
return {
contains: vi.fn((element: Element | null) => acceptedElements.includes(element as Element))
} as unknown as Element
}
function makeElement(tagName: string, classNames: string[] = []): Element {
return {
tagName,
classList: {
contains: (className: string) => classNames.includes(className)
}
} as unknown as Element
}
describe('terminal paste target state', () => {
it('accepts the same mounted pane, transport, and PTY identity', () => {
const transport = makeTransport()
@ -128,6 +143,51 @@ describe('terminal paste target state', () => {
).toBe(false)
})
it('keeps keyboard-owned paste current across transient document blur', () => {
const terminalInput = {} as Element
const body = makeElement('body')
const paneContainer = makePaneContainer(terminalInput)
expect(
isTerminalPanePasteFocusCurrent({
requireSameFocusedElement: true,
activeElementAtDispatch: terminalInput,
paneContainer,
activeElement: body
})
).toBe(true)
})
it('keeps keyboard-owned paste current when xterm replaces its helper textarea', () => {
const originalTerminalInput = makeElement('textarea', ['xterm-helper-textarea'])
const replacementTerminalInput = makeElement('textarea', ['xterm-helper-textarea'])
const paneContainer = makePaneContainerFor([originalTerminalInput, replacementTerminalInput])
expect(
isTerminalPanePasteFocusCurrent({
requireSameFocusedElement: true,
activeElementAtDispatch: originalTerminalInput,
paneContainer,
activeElement: replacementTerminalInput
})
).toBe(true)
})
it('rejects keyboard-owned paste when focus moves to another control in the pane', () => {
const terminalInput = makeElement('textarea', ['xterm-helper-textarea'])
const searchInput = makeElement('input')
const paneContainer = makePaneContainerFor([terminalInput, searchInput])
expect(
isTerminalPanePasteFocusCurrent({
requireSameFocusedElement: true,
activeElementAtDispatch: terminalInput,
paneContainer,
activeElement: searchInput
})
).toBe(false)
})
it('does not require focus continuity for programmatic terminal paste', () => {
const terminalInput = {} as Element
const otherInput = {} as Element

View File

@ -54,9 +54,31 @@ export function isTerminalPanePasteFocusCurrent({
if (!requireSameFocusedElement || activeElementAtDispatch === null) {
return true
}
// Why: clipboard reads are async, so focus may leave the terminal before
// execution. In that case the stale terminal must not receive the payload.
return (
activeElement === activeElementAtDispatch && paneContainer.contains(activeElementAtDispatch)
)
if (!paneContainer.contains(activeElementAtDispatch)) {
return false
}
if (activeElement === activeElementAtDispatch) {
return true
}
// Why: macOS dictation and clipboard permission handoffs can transiently
// blur xterm to body, and xterm may replace its helper textarea mid-paste.
if (isInertDocumentFocus(activeElement)) {
return true
}
if (activeElement === paneContainer) {
return true
}
return paneContainer.contains(activeElement) && isXtermHelperTextarea(activeElement)
}
function isInertDocumentFocus(element: Element | null): boolean {
if (!element) {
return true
}
const tagName = element.tagName?.toUpperCase()
return tagName === 'BODY' || tagName === 'HTML'
}
function isXtermHelperTextarea(element: Element | null): boolean {
return element?.classList?.contains('xterm-helper-textarea') === true
}

View File

@ -1,7 +1,7 @@
import { randomUUID } from 'node:crypto'
import { rmSync, writeFileSync } from 'node:fs'
import path from 'node:path'
import type { Page } from '@stablyai/playwright-test'
import type { ElectronApplication, Page } from '@stablyai/playwright-test'
import { test, expect } from './helpers/orca-app'
import {
focusActiveTerminalInput,
@ -112,6 +112,62 @@ async function rightClickActiveTerminalSurface(page: Page): Promise<void> {
await page.mouse.click(point.x, point.y, { button: 'right' })
}
async function installClipboardReadTerminalBlurRepro(app: ElectronApplication): Promise<void> {
await app.evaluate(({ BrowserWindow, ipcMain }) => {
type ClipboardReadHandler = (event: unknown, ...args: unknown[]) => Promise<unknown> | unknown
const global = globalThis as unknown as {
__orcaOriginalClipboardReadTextHandler?: ClipboardReadHandler
}
const invokeHandlers = (
ipcMain as unknown as {
_invokeHandlers?: Map<string, ClipboardReadHandler>
}
)._invokeHandlers
const handler = invokeHandlers?.get('clipboard:readText')
if (!invokeHandlers || !handler || global.__orcaOriginalClipboardReadTextHandler) {
return
}
global.__orcaOriginalClipboardReadTextHandler = handler
invokeHandlers.set('clipboard:readText', async (event, ...args) => {
const windows = BrowserWindow.getAllWindows().filter((window) => !window.isDestroyed())
await Promise.all(
windows.map((window) =>
window.webContents
.executeJavaScript(
`
(document.activeElement && document.activeElement.blur && document.activeElement.blur());
document.body.tabIndex = -1;
document.body.focus();
`
)
.catch(() => undefined)
)
)
// Why: reproduce the focus churn window before the async clipboard read resolves.
await new Promise((resolve) => setTimeout(resolve, 0))
return global.__orcaOriginalClipboardReadTextHandler!(event, ...args)
})
})
}
async function restoreClipboardReadTerminalBlurRepro(app: ElectronApplication): Promise<void> {
await app.evaluate(({ ipcMain }) => {
type ClipboardReadHandler = (event: unknown, ...args: unknown[]) => Promise<unknown> | unknown
const global = globalThis as unknown as {
__orcaOriginalClipboardReadTextHandler?: ClipboardReadHandler
}
const invokeHandlers = (
ipcMain as unknown as {
_invokeHandlers?: Map<string, ClipboardReadHandler>
}
)._invokeHandlers
if (invokeHandlers && global.__orcaOriginalClipboardReadTextHandler) {
invokeHandlers.set('clipboard:readText', global.__orcaOriginalClipboardReadTextHandler)
delete global.__orcaOriginalClipboardReadTextHandler
}
})
}
async function openTerminalContextMenu(page: Page): Promise<void> {
const isWindows = await page.evaluate(() => navigator.userAgent.includes('Windows'))
const isMac = await page.evaluate(() => navigator.userAgent.includes('Mac'))
@ -171,6 +227,49 @@ test.describe('terminal paste ownership', () => {
}
})
test('keyboard paste survives transient terminal blur during clipboard read', async ({
electronApp,
orcaPage,
testRepoPath
}) => {
await waitForSessionReady(orcaPage)
await waitForActiveWorktree(orcaPage)
await ensureTerminalVisible(orcaPage)
await waitForActiveTerminalManager(orcaPage, 30_000)
await installTerminalPtyWriteSpy(electronApp)
const ptyId = await waitForActivePanePtyId(orcaPage)
const runId = randomUUID()
const scriptPath = path.join(testRepoPath, `.orca-paste-blur-${runId}.mjs`)
writeFileSync(scriptPath, pasteEchoScript(runId))
let scriptStarted = false
try {
await sendToTerminal(orcaPage, ptyId, `node ${JSON.stringify(scriptPath)}\r`)
scriptStarted = true
await waitForTerminalOutput(orcaPage, `PASTE_READY_${runId}`, 10_000)
const payload = `ORCA_E2E_TRANSIENT_BLUR_PASTE_${runId}`
const encodedPayload = Buffer.from(payload, 'utf8').toString('base64')
await clearTerminalPtyWriteLog(electronApp)
await orcaPage.evaluate((text) => window.api.ui.writeClipboardText(text), payload)
await focusActiveTerminalInput(orcaPage)
await installClipboardReadTerminalBlurRepro(electronApp)
await orcaPage.keyboard.press(keyboardPasteChords()[0])
await waitForTerminalOutput(orcaPage, encodedPayload, 10_000, 12_000)
const writes = (await readTerminalPtyWrites(electronApp)).join('')
expect(countOccurrences(writes, payload), 'transient blur PTY write count').toBe(1)
} finally {
await restoreClipboardReadTerminalBlurRepro(electronApp).catch(() => undefined)
if (scriptStarted) {
await sendToTerminal(orcaPage, ptyId, '\x03').catch(() => undefined)
}
rmSync(scriptPath, { force: true })
}
})
test('terminal context-menu Paste sends clipboard text exactly once', async ({
electronApp,
orcaPage,