fix(terminal): preserve follow output through streaming refocus (#11915)
This commit is contained in:
parent
76a2317bc0
commit
1f307afa6d
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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' }
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
Loading…
Reference in New Issue