diff --git a/config/reliability-gates.jsonc b/config/reliability-gates.jsonc index bd90a3370..bdabd2a7b 100644 --- a/config/reliability-gates.jsonc +++ b/config/reliability-gates.jsonc @@ -458,6 +458,97 @@ ], "demotionRule": "Cannot promote while it only checks visual rendering." }, + { + "id": "terminal-scroll.intent-preservation", + "title": "Scrollbar drag intent survives tab and visibility resume", + "maturity": "experimental", + "protection": "partial", + "owner": "terminal-rendering", + "layer": "renderer-unit", + "surfaces": [ + "terminal lifecycle", + "hidden-to-visible resume", + "tab switching", + "xterm scrollbar DOM", + "scrollback" + ], + "platforms": [ + "macos", + "linux", + "windows" + ], + "providers": [ + "local", + "daemon", + "ssh", + "wsl", + "remote-runtime" + ], + "coveredPlatforms": [ + "macos" + ], + "coveredProviders": [], + "coverageNotes": "Renderer-unit coverage proves the shared xterm DOM intent path. Live Electron evidence is PR validation evidence for local macOS only until the flow has stable automation; live SSH, WSL, Linux, and Windows paths remain unproved.", + "motivatingLinks": [ + "STA-1341" + ], + "invariant": "A user-driven xterm scrollbar thumb or track scroll updates the live terminal scroll intent before tab, visibility, or layout resume enforces intent, so resume preserves the latest dragged viewport instead of an older pinned line.", + "oracle": "Pointerdown on .xterm-scrollbar or .xterm-slider followed by xterm viewport movement records the new pinned viewport, and enforcing current intent restores that dragged line instead of stale top intent.", + "commands": [ + "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/lib/pane-manager/terminal-scroll-intent.test.ts" + ], + "testFiles": [ + "src/renderer/src/lib/pane-manager/terminal-scroll-intent.test.ts" + ], + "assertionRefs": [ + { + "file": "src/renderer/src/lib/pane-manager/terminal-scroll-intent.test.ts", + "assertions": [ + "pointer-driven .xterm-scrollbar and .xterm-slider scrolls update terminal scroll intent", + "a scrollbar-dragged viewport is restored instead of stale top intent", + "terminal body pointer activity is not treated as scrollbar intent" + ] + } + ], + "evidenceRuns": [ + { + "date": "2026-07-05", + "runner": "local", + "platform": "macos", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/lib/pane-manager/terminal-scroll-intent.test.ts", + "result": "passed", + "durationSeconds": 0.3, + "summary": "1 test file(s) passed, 17 tests passed in this worktree." + } + ], + "runtimeBudget": { + "p95Seconds": 10, + "scope": "focused renderer unit test" + }, + "flakeHistory": { + "status": "unknown", + "evidence": "New experimental gate; needs soak history before promotion." + }, + "redGreenEvidence": { + "status": "partial", + "evidence": "The new scrollbar DOM target assertions fail when only .xterm-viewport pointerdown is recognized. Needs saved CI/intentional-break artifact before blocking promotion." + }, + "performanceBudget": { + "required": true, + "evidence": "Runtime fix is limited to constant-time class/closest checks on pointerdown. It must not add polling, output parsing, PTY listing, hidden-pane wakeups, resize calls, or startup awaits." + }, + "promotionCriteria": [ + "Collect stable CI soak history for the renderer-unit gate.", + "Add stable live Electron automation for the scrollbar drag plus tab-switch repro.", + "Attach red/green evidence for stale intent restoring the wrong viewport." + ], + "knownGaps": [ + "Manifest command is deterministic renderer-unit coverage, not a live Electron scrollbar drag.", + "Live daemon, SSH, WSL, remote-runtime, Linux, and Windows validation is not covered by this gate.", + "Future xterm scrollbar DOM class changes are only caught if they break the currently modeled class contract." + ], + "demotionRule": "Demote or quarantine if the unit gate flakes without a product bug or harness bug filed to the owner." + }, { "id": "startup-upgrade.persisted-session-corpus", "title": "Current Orca preserves or recovers old production persisted sessions", diff --git a/src/renderer/src/components/settings/SourceControlActionRepoOverrideNote.tsx b/src/renderer/src/components/settings/SourceControlActionRepoOverrideNote.tsx index ec389927e..b3ca7aa3e 100644 --- a/src/renderer/src/components/settings/SourceControlActionRepoOverrideNote.tsx +++ b/src/renderer/src/components/settings/SourceControlActionRepoOverrideNote.tsx @@ -32,11 +32,6 @@ function getRecipeOverrideFieldLabel(field: SourceControlActionRecipeOverrideFie 'auto.components.settings.SourceControlActionRepoOverrideNote.commandTemplate', 'Command template' ) - default: { - // Fail at compile time if the override-field union grows a new variant. - const _exhaustive: never = field - return _exhaustive - } } } diff --git a/src/renderer/src/lib/pane-manager/terminal-scroll-intent.test.ts b/src/renderer/src/lib/pane-manager/terminal-scroll-intent.test.ts index f132e786d..bc728ce7d 100644 --- a/src/renderer/src/lib/pane-manager/terminal-scroll-intent.test.ts +++ b/src/renderer/src/lib/pane-manager/terminal-scroll-intent.test.ts @@ -53,12 +53,15 @@ class TestElement extends EventTarget { } closest(selector: string): TestElement | null { - if (!selector.startsWith('.')) { - return null - } - const className = selector.slice(1) - if (this.classList.contains(className)) { - return this + for (const candidate of selector.split(',')) { + const trimmed = candidate.trim() + if (!trimmed.startsWith('.')) { + continue + } + const className = trimmed.slice(1) + if (this.classList.contains(className)) { + return this + } } return this.parentElement?.closest(selector) ?? null } @@ -270,6 +273,56 @@ describe('terminal scroll intent', () => { disposable.dispose() }) + it.each(['xterm-scrollbar', 'xterm-slider'])( + 'tracks pointer-driven xterm %s scrolls as user intent', + (scrollbarClassName) => { + vi.stubGlobal('Element', TestElement) + const terminal = createTerminal({ viewportY: 100, baseY: 100 }) + const hostElement = new TestElement() + const scrollbarTarget = new TestElement(scrollbarClassName) + const scrollbarChild = new TestElement('xterm-scrollbar-child') + hostElement.append(scrollbarTarget) + scrollbarTarget.append(scrollbarChild) + const host = hostElement as unknown as HTMLElement + const disposable = attachTerminalScrollIntentTracking(terminal, host) + + terminal.buffer.active.viewportY = 50 + host.dispatchEvent(new Event('scroll')) + expect(getTerminalScrollIntentKind(terminal)).toBe('followOutput') + + scrollbarChild.dispatchEvent(new Event('pointerdown', { bubbles: true })) + scrollbarChild.dispatchEvent(new Event('scroll', { bubbles: true })) + expect(getTerminalScrollIntentKind(terminal)).toBe('pinnedViewport') + + disposable.dispose() + } + ) + + it('restores a scrollbar-dragged viewport instead of stale top intent', () => { + vi.stubGlobal('Element', TestElement) + const terminal = createTerminal({ viewportY: 0, baseY: 600 }) + const hostElement = new TestElement() + const scrollbar = new TestElement('xterm-scrollbar') + const slider = new TestElement('xterm-slider') + hostElement.append(scrollbar) + scrollbar.append(slider) + const host = hostElement as unknown as HTMLElement + const disposable = attachTerminalScrollIntentTracking(terminal, host, 'terminal-1') + + expect(getTerminalScrollIntentKind(terminal)).toBe('pinnedViewport') + + terminal.buffer.active.viewportY = 572 + slider.dispatchEvent(new Event('pointerdown', { bubbles: true })) + slider.dispatchEvent(new Event('scroll', { bubbles: true })) + + terminal.buffer.active.viewportY = 0 + enforceTerminalCurrentScrollIntent(terminal) + + expect(terminal.scrollToLine).toHaveBeenLastCalledWith(572) + expect(terminal.buffer.active.viewportY).toBe(572) + disposable.dispose() + }) + it('does not treat terminal body pointer activity as scrollbar intent', () => { vi.stubGlobal('Element', TestElement) const terminal = createTerminal({ viewportY: 100, baseY: 100 }) diff --git a/src/renderer/src/lib/pane-manager/terminal-scroll-intent.ts b/src/renderer/src/lib/pane-manager/terminal-scroll-intent.ts index 35537c11f..c0ab35f65 100644 --- a/src/renderer/src/lib/pane-manager/terminal-scroll-intent.ts +++ b/src/renderer/src/lib/pane-manager/terminal-scroll-intent.ts @@ -42,6 +42,14 @@ const terminalScrollIntentKeyByTerminal = new WeakMap< const terminalScrollIntentByKey = new Map() const BOTTOM_TOLERANCE_ROWS = 1 +const XTERM_SCROLL_INTENT_POINTER_TARGET_CLASSES = [ + 'xterm-viewport', + 'xterm-scrollbar', + 'xterm-slider' +] as const +const XTERM_SCROLL_INTENT_POINTER_TARGET_SELECTOR = XTERM_SCROLL_INTENT_POINTER_TARGET_CLASSES.map( + (className) => `.${className}` +).join(',') function readBufferSnapshot( terminal: TerminalScrollIntentTarget @@ -120,6 +128,14 @@ function safeScrollCall(fn: () => void): boolean { } } +function isTerminalScrollIntentPointerTarget(target: EventTarget | null): target is Element { + if (typeof Element === 'undefined' || !(target instanceof Element)) { + return false + } + // xterm's custom scrollbar uses separate thumb/track nodes from the viewport. + return target.closest(XTERM_SCROLL_INTENT_POINTER_TARGET_SELECTOR) !== null +} + export function markTerminalFollowOutput(terminal: TerminalScrollIntentTarget): void { writeIntent(terminal, 'followOutput') } @@ -255,11 +271,7 @@ export function attachTerminalScrollIntentTracking( } const onPointerDown = (event: PointerEvent): void => { - const target = event.target - pointerScrollActive = - typeof Element !== 'undefined' && - target instanceof Element && - (target.classList.contains('xterm-viewport') || target.closest('.xterm-viewport') !== null) + pointerScrollActive = isTerminalScrollIntentPointerTarget(event.target) } const onPointerDone = (): void => {