diff --git a/config/reliability-gates.jsonc b/config/reliability-gates.jsonc index 2785645d6..01db646c2 100644 --- a/config/reliability-gates.jsonc +++ b/config/reliability-gates.jsonc @@ -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", diff --git a/src/renderer/src/components/terminal-pane/terminal-visibility-resume.test.ts b/src/renderer/src/components/terminal-pane/terminal-visibility-resume.test.ts index 300a381c0..172711796 100644 --- a/src/renderer/src/components/terminal-pane/terminal-visibility-resume.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-visibility-resume.test.ts @@ -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' } diff --git a/src/renderer/src/components/terminal-pane/terminal-visibility-resume.ts b/src/renderer/src/components/terminal-pane/terminal-visibility-resume.ts index 641c99258..5c6b83145 100644 --- a/src/renderer/src/components/terminal-pane/terminal-visibility-resume.ts +++ b/src/renderer/src/components/terminal-pane/terminal-visibility-resume.ts @@ -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 diff --git a/tests/e2e/terminal-streaming-refocus-viewport.spec.ts b/tests/e2e/terminal-streaming-refocus-viewport.spec.ts new file mode 100644 index 000000000..8376a3188 --- /dev/null +++ b/tests/e2e/terminal-streaming-refocus-viewport.spec.ts @@ -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 { + 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 { + 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( + '.xterm-scrollbar.xterm-vertical' + ) + const thumb = scrollbar?.querySelector('.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 { + 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 { + return page.evaluate( + (targetTabId) => + new Promise((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('.xterm-scrollbar.xterm-vertical') ?? null + const thumb = scrollbar?.querySelector('.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('.xterm-slider') + return Boolean( + thumb && Math.abs(scrollbar.clientHeight - thumb.offsetHeight - thumb.offsetTop) <= 2 + ) + }) + ).toBe(true) + }) +})