fix(terminal): preserve follow output through streaming refocus (#11915)

This commit is contained in:
Neil 2026-08-01 03:26:30 -07:00 committed by GitHub
parent 76a2317bc0
commit 1f307afa6d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 386 additions and 2 deletions

View File

@ -3048,6 +3048,105 @@
],
"demotionRule": "Demote or quarantine if the unit gate flakes without a product bug or harness bug filed to the owner."
},
{
"id": "terminal-scroll.streaming-refocus-intent",
"title": "Streaming refocus preserves follow-output viewport intent",
"maturity": "experimental",
"protection": "partial",
"owner": "terminal-rendering",
"layer": "renderer-unit-and-electron-e2e",
"surfaces": [
"terminal lifecycle",
"window focus recovery",
"hidden-to-visible resume",
"xterm write backlog",
"scrollback"
],
"platforms": ["macos", "linux", "windows"],
"providers": ["local", "daemon", "ssh", "wsl", "remote-runtime"],
"coveredPlatforms": ["macos"],
"coveredProviders": ["local", "daemon"],
"coverageNotes": "Unit coverage proves provider-independent ordering for any PaneManager. Live Electron coverage exercises a local PTY through the daemon on macOS; SSH, WSL, remote-runtime, Linux, and Windows remain unproved for this exact race.",
"motivatingLinks": [
"https://github.com/stablyai/orca/issues/11753",
"https://github.com/stablyai/orca/pull/11915"
],
"invariant": "When output is queued during a focus or visibility transition, Orca records the pre-flush viewport intent before xterm parses backlog writes, so a follow-output terminal stays at the bottom and a pinned terminal keeps its prior position.",
"oracle": "Unit tests require exactly one intent sync before each queued-output flush. The Electron test injects a transient top-of-buffer xterm wobble during refocus and requires every presented scrollbar frame, including the final rendered output, to remain at the bottom.",
"commands": [
"pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/terminal-visibility-resume.test.ts",
"pnpm exec electron-vite build --mode e2e",
"SKIP_BUILD=1 pnpm exec playwright test tests/e2e/terminal-streaming-refocus-viewport.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1 --repeat-each=5"
],
"testFiles": [
"src/renderer/src/components/terminal-pane/terminal-visibility-resume.test.ts",
"tests/e2e/terminal-streaming-refocus-viewport.spec.ts"
],
"assertionRefs": [
{
"file": "src/renderer/src/components/terminal-pane/terminal-visibility-resume.test.ts",
"assertions": [
"window-wake recovery synchronizes viewport intent exactly once before flushing queued output",
"heavy visibility resume synchronizes intent exactly once before flushing queued output"
]
},
{
"file": "tests/e2e/terminal-streaming-refocus-viewport.spec.ts",
"assertions": [
"phase-one scrollback is visibly ready at the bottom without a fixed sleep",
"no presented animation frame moves the scrollbar thumb away from the bottom during refocus",
"the final streamed marker renders with the visible scrollbar still at the bottom"
]
}
],
"evidenceRuns": [
{
"date": "2026-08-01",
"runner": "local",
"platform": "macos",
"command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/terminal-visibility-resume.test.ts",
"result": "passed",
"durationSeconds": 0.096,
"summary": "14 tests passed, including exact sync count and pre-flush ordering for wake and heavy visibility resume."
},
{
"date": "2026-08-01",
"runner": "local",
"platform": "macos",
"command": "SKIP_BUILD=1 pnpm exec playwright test tests/e2e/terminal-streaming-refocus-viewport.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1 --repeat-each=5",
"result": "passed",
"durationSeconds": 35.2,
"summary": "Five consecutive Electron iterations passed after replacing fixed-time readiness and stale tab capture with deterministic viewport and pane-identity oracles."
}
],
"runtimeBudget": {
"p95Seconds": 30,
"scope": "focused renderer unit test or one Electron E2E iteration"
},
"flakeHistory": {
"status": "soaking",
"evidence": "Five consecutive local Electron iterations passed; CI soak history is still required before promotion."
},
"redGreenEvidence": {
"status": "complete",
"evidence": "The controlled xterm viewport wobble reproduces the pinned-top failure with post-flush intent sampling and passes when intent is latched before the queued write flush."
},
"performanceBudget": {
"required": true,
"evidence": "Wake recovery keeps one O(panes) intent pass before the existing bounded 64 KiB-per-pane flush. Heavy resume removes its second intent pass and keeps the existing bounded 256 KiB-per-pane flush; no polling, timers, subprocesses, IPC, output parsing, fit, or repaint work is added."
},
"promotionCriteria": [
"Collect stable CI soak history for the Electron race gate.",
"Run the live oracle on Linux and Windows terminal backends.",
"Add live SSH or remote-runtime coverage for queued output during refocus."
],
"knownGaps": [
"The deterministic wobble uses xterm private buffer state and must be updated if that internal contract changes.",
"Live Linux, Windows, SSH, WSL, and remote-runtime execution is not covered for this exact race.",
"The Electron gate proves follow-output behavior; adjacent scroll-intent coverage protects pinned viewport behavior."
],
"demotionRule": "Demote or quarantine if the Electron oracle flakes without a product bug or harness bug filed to terminal-rendering."
},
{
"id": "startup-upgrade.persisted-session-corpus",
"title": "Current Orca preserves or recovers old production persisted sessions",

View File

@ -151,6 +151,50 @@ describe('resumeTerminalVisibility reveal repaint', () => {
expect(manager.fitAllPanes).not.toHaveBeenCalled()
})
it('latches viewport intent before refocus recovery flushes streaming output', async () => {
const terminal = { name: 'streaming-terminal' }
const manager = createManager()
manager.getPanes.mockReturnValue([{ terminal }])
const { syncTerminalScrollIntentFromViewport } = vi.mocked(
await import('@/lib/pane-manager/terminal-scroll-intent')
)
const { flushTerminalOutput } = vi.mocked(
await import('@/lib/pane-manager/pane-terminal-output-scheduler')
)
recoverVisibleTerminalWindowWake({
manager: manager as never as PaneManager,
isActive: true,
clearGlyphAtlases: false
})
expect(flushTerminalOutput).toHaveBeenCalledOnce()
expect(syncTerminalScrollIntentFromViewport).toHaveBeenCalledOnce()
expect(syncTerminalScrollIntentFromViewport.mock.invocationCallOrder[0]).toBeLessThan(
flushTerminalOutput.mock.invocationCallOrder[0] ?? Number.POSITIVE_INFINITY
)
})
it('does not overwrite pre-reveal intent after queuing hidden output', async () => {
const terminal = { name: 'hidden-streaming-terminal' }
const manager = createManager()
manager.getPanes.mockReturnValue([{ terminal }])
const { syncTerminalScrollIntentFromViewport } = vi.mocked(
await import('@/lib/pane-manager/terminal-scroll-intent')
)
const { flushTerminalOutput } = vi.mocked(
await import('@/lib/pane-manager/pane-terminal-output-scheduler')
)
resumeTerminalVisibility(resumeArgs(manager, false))
expect(flushTerminalOutput).toHaveBeenCalledOnce()
expect(syncTerminalScrollIntentFromViewport).toHaveBeenCalledOnce()
expect(syncTerminalScrollIntentFromViewport.mock.invocationCallOrder[0]).toBeLessThan(
flushTerminalOutput.mock.invocationCallOrder[0] ?? Number.POSITIVE_INFINITY
)
})
it('resets each pane linkifier hover cache on window wake recovery so links recover without a scroll', () => {
const first = { name: 'pane-a' }
const second = { name: 'pane-b' }

View File

@ -143,6 +143,8 @@ export function recoverVisibleTerminalWindowWake({
}: RecoverVisibleTerminalWindowWakeArgs): void {
// Why: macOS screensaver/display wake can leave xterm visible but with a
// stale renderer/input surface; Orca's own hidden-state resume never runs.
// Why: backlog writes can expose transient viewport geometry while parsing.
syncTerminalViewportIntents(manager)
for (const pane of manager.getPanes()) {
requestTerminalBacklogRecovery(pane.terminal)
flushTerminalOutput(pane.terminal, { maxChars: WINDOW_WAKE_FLUSH_CHARS })
@ -154,7 +156,6 @@ export function recoverVisibleTerminalWindowWake({
resetTerminalLinkifierHoverState(pane.terminal)
}
}
syncTerminalViewportIntents(manager)
manager.resumeRendering()
// Why: wake re-attaches WebGL — same transient cell-metric wobble guard as the heavy resume.
manager.fitAllRevealedPanes()
@ -192,7 +193,7 @@ function resumeTerminalVisibilityHeavy(manager: PaneManager, isActive: boolean):
requestTerminalBacklogRecovery(pane.terminal)
flushTerminalOutput(pane.terminal, { maxChars: VISIBLE_RESUME_FLUSH_CHARS })
}
syncTerminalViewportIntents(manager)
// Intent was latched by the caller before queued writes can expose transient geometry.
// Resume WebGL immediately so the terminal shows its last-known state
// on the first painted frame. macOS context creation is ~5 ms; on
// Windows (ANGLE -> D3D11) it can be 100-500 ms but a deferred resume

View File

@ -0,0 +1,240 @@
import path from 'node:path'
import type { Page } from '@stablyai/playwright-test'
import { expect, test } from './helpers/orca-app'
import { ensureTerminalVisible, waitForActiveWorktree, waitForSessionReady } from './helpers/store'
import {
execInTerminal,
getTerminalContent,
waitForActivePaneHookDescriptor,
waitForActivePanePtyId,
waitForActiveTerminalManager
} from './helpers/terminal'
import { waitForTerminalPtyDataInjector } from './helpers/terminal-pty-injection'
import { nodeTerminalCommand } from './terminal-node-command'
const STREAMING_FIXTURE_PATH = path.join(
process.cwd(),
'tests/e2e/fixtures/streaming-scrollback-fixture.cjs'
)
type RevealFrame = {
targetPresented: boolean
thumbTop: number | null
maxThumbTop: number | null
}
async function closeFeatureTips(page: Page): Promise<void> {
await page.evaluate(() => {
const store = window.__store
store?.getState().markFeatureTipsSeen(['orca-cli', 'cmd-j-palette', 'voice-dictation'])
if (store?.getState().activeModal === 'feature-tips') {
store.getState().closeModal()
}
})
}
async function waitForPhaseOneAtBottom(page: Page, tabId: string): Promise<void> {
await expect
.poll(
() =>
page.evaluate((targetTabId) => {
const pane = window.__paneManagers?.get(targetTabId)?.getPanes?.()[0]
const terminal = pane?.terminal
if (!terminal) {
return false
}
const buffer = terminal.buffer.active
let containsMarker = false
for (let line = buffer.baseY; line < buffer.baseY + terminal.rows; line += 1) {
if (buffer.getLine(line)?.translateToString(true).includes('STREAM_PHASE1_DONE')) {
containsMarker = true
break
}
}
const scrollbar = pane.container.querySelector<HTMLElement>(
'.xterm-scrollbar.xterm-vertical'
)
const thumb = scrollbar?.querySelector<HTMLElement>('.xterm-slider') ?? null
return Boolean(
buffer.baseY > 0 &&
buffer.viewportY === buffer.baseY &&
containsMarker &&
scrollbar &&
thumb &&
scrollbar.clientHeight - thumb.offsetHeight > 1 &&
Math.abs(scrollbar.clientHeight - thumb.offsetHeight - thumb.offsetTop) <= 2
)
}, tabId),
{
timeout: 30_000,
message: 'phase-one output did not render with the visible viewport at the bottom'
}
)
.toBe(true)
}
async function injectQueuedWriteAndRefocus(
page: Page,
tabId: string,
paneKey: string
): Promise<void> {
await page.evaluate(
({ targetTabId, paneKey }) => {
const pane = window.__paneManagers?.get(targetTabId)?.getPanes?.()[0]
if (!pane) {
throw new Error('Hidden terminal pane unavailable')
}
const terminal = pane.terminal
// Why: fail loudly if xterm moves the private buffer path that models this wobble.
const bufferService = (
terminal as typeof terminal & {
_core?: {
_bufferService?: { buffer?: { ydisp: number }; isUserScrolling: boolean }
}
}
)._core?._bufferService
const internalBuffer = bufferService?.buffer
if (!internalBuffer || !bufferService) {
throw new Error('xterm internal buffer unavailable')
}
const originalWrite = terminal.write
let wobbleApplied = false
terminal.write = ((data: string, callback?: () => void) => {
terminal.write = originalWrite
wobbleApplied = true
internalBuffer.ydisp = 0
bufferService.isUserScrolling = true
if (terminal.buffer.active.viewportY !== 0) {
throw new Error('xterm viewport wobble was not observable')
}
originalWrite.call(terminal, data, callback)
}) as typeof terminal.write
const injector = (
window as Window & {
__terminalPtyDataInjection?: {
inject: (paneKey: string, data: string) => boolean
}
}
).__terminalPtyDataInjection
const rows = Array.from(
{ length: 400 },
(_, index) => `REFOCUS_STREAM_ROW_${String(index).padStart(4, '0')}_${'x'.repeat(80)}\n`
).join('')
if (!injector?.inject(paneKey, `${rows}REFOCUS_STREAM_DONE\n`)) {
throw new Error('PTY data injector unavailable')
}
// Why: focus recovery must flush through terminal.write in this synchronous dispatch.
window.dispatchEvent(new Event('focus'))
if (!wobbleApplied) {
throw new Error('refocus did not flush the queued xterm write')
}
},
{ targetTabId: tabId, paneKey }
)
}
async function sampleRevealFrames(page: Page, targetTabId: string): Promise<RevealFrame[]> {
return page.evaluate(
(targetTabId) =>
new Promise<RevealFrame[]>((resolve) => {
const frames: RevealFrame[] = []
const startedAt = performance.now()
const isPresented = (element: Element | null): boolean => {
if (!(element instanceof HTMLElement)) {
return false
}
for (
let current: HTMLElement | null = element;
current;
current = current.parentElement
) {
const style = getComputedStyle(current)
if (
style.display === 'none' ||
style.visibility === 'hidden' ||
style.opacity === '0'
) {
return false
}
}
const rect = element.getBoundingClientRect()
return (
rect.width > 0 &&
rect.height > 0 &&
rect.right > 0 &&
rect.bottom > 0 &&
rect.left < window.innerWidth &&
rect.top < window.innerHeight
)
}
const sample = (): void => {
const pane = window.__paneManagers?.get(targetTabId)?.getPanes?.()[0]
const targetXterm = pane?.container.querySelector('.xterm') ?? null
const scrollbar =
targetXterm?.querySelector<HTMLElement>('.xterm-scrollbar.xterm-vertical') ?? null
const thumb = scrollbar?.querySelector<HTMLElement>('.xterm-slider') ?? null
frames.push({
targetPresented: isPresented(targetXterm),
thumbTop: thumb?.offsetTop ?? null,
maxThumbTop: scrollbar && thumb ? scrollbar.clientHeight - thumb.offsetHeight : null
})
if (performance.now() - startedAt >= 700) {
resolve(frames)
return
}
requestAnimationFrame(sample)
}
sample()
}),
targetTabId
)
}
test.describe('terminal streaming refocus viewport', () => {
test('keeps follow-output at the bottom through a queued-write refocus wobble', async ({
orcaPage
}) => {
await waitForSessionReady(orcaPage)
await closeFeatureTips(orcaPage)
await waitForActiveWorktree(orcaPage)
await ensureTerminalVisible(orcaPage)
await waitForActiveTerminalManager(orcaPage, 30_000)
const ptyId = await waitForActivePanePtyId(orcaPage)
const { paneKey } = await waitForActivePaneHookDescriptor(orcaPage)
const tabId = paneKey.slice(0, paneKey.indexOf(':'))
await waitForTerminalPtyDataInjector(orcaPage, paneKey)
await execInTerminal(orcaPage, ptyId, nodeTerminalCommand([STREAMING_FIXTURE_PATH]))
await waitForPhaseOneAtBottom(orcaPage, tabId)
const framesPromise = sampleRevealFrames(orcaPage, tabId)
await injectQueuedWriteAndRefocus(orcaPage, tabId, paneKey)
const frames = await framesPromise
expect(frames.filter((frame) => !frame.targetPresented)).toEqual([])
expect(
frames.filter(
(frame) =>
frame.thumbTop === null ||
frame.maxThumbTop === null ||
Math.abs(frame.maxThumbTop - frame.thumbTop) > 2
)
).toEqual([])
expect(frames.some((frame) => (frame.maxThumbTop ?? 0) > 1)).toBe(true)
expect(
frames.filter((frame) => (frame.maxThumbTop ?? 0) > 1 && (frame.thumbTop ?? 0) <= 1)
).toEqual([])
await expect
.poll(() => getTerminalContent(orcaPage), { timeout: 15_000 })
.toContain('REFOCUS_STREAM_DONE')
const visibleScrollbar = orcaPage.locator('.xterm-scrollbar.xterm-vertical:visible').first()
await expect(visibleScrollbar).toBeVisible()
expect(
await visibleScrollbar.evaluate((scrollbar) => {
const thumb = scrollbar.querySelector<HTMLElement>('.xterm-slider')
return Boolean(
thumb && Math.abs(scrollbar.clientHeight - thumb.offsetHeight - thumb.offsetTop) <= 2
)
})
).toBe(true)
})
})