Revert "fix(terminal): add proportional scroll fallback for sidebar resize (#901)" (#930)

This reverts commit 2ab2db9132.
This commit is contained in:
Jinwoo Hong 2026-04-22 00:44:37 -04:00 committed by GitHub
parent 2ab2db9132
commit 7a64ee3a11
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
16 changed files with 92 additions and 1358 deletions

View File

@ -3,11 +3,7 @@ import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
import { DEFAULT_STATUS_BAR_ITEMS, DEFAULT_WORKTREE_CARD_PROPERTIES } from '../../shared/constants'
import { Minimize2, PanelLeft, PanelRight } from 'lucide-react'
import {
FOCUS_TERMINAL_PANE_EVENT,
LAYOUT_WILL_CHANGE_EVENT,
TOGGLE_TERMINAL_PANE_EXPAND_EVENT
} from '@/constants/terminal'
import { FOCUS_TERMINAL_PANE_EVENT, TOGGLE_TERMINAL_PANE_EXPAND_EVENT } from '@/constants/terminal'
import { syncZoomCSSVar } from '@/lib/ui-zoom'
import { toast } from 'sonner'
import { Toaster } from '@/components/ui/sonner'
@ -503,7 +499,6 @@ function App(): React.JSX.Element {
if (!e.altKey && !e.shiftKey && e.key.toLowerCase() === 'b') {
dispatchClearModifierHints()
e.preventDefault()
window.dispatchEvent(new Event(LAYOUT_WILL_CHANGE_EVENT))
actions.toggleSidebar()
return
}
@ -524,7 +519,6 @@ function App(): React.JSX.Element {
if (!e.altKey && !e.shiftKey && e.key.toLowerCase() === 'l') {
dispatchClearModifierHints()
e.preventDefault()
window.dispatchEvent(new Event(LAYOUT_WILL_CHANGE_EVENT))
actions.toggleRightSidebar()
return
}
@ -533,7 +527,6 @@ function App(): React.JSX.Element {
if (e.shiftKey && !e.altKey && e.key.toLowerCase() === 'e') {
dispatchClearModifierHints()
e.preventDefault()
window.dispatchEvent(new Event(LAYOUT_WILL_CHANGE_EVENT))
actions.setRightSidebarTab('explorer')
actions.setRightSidebarOpen(true)
return
@ -543,7 +536,6 @@ function App(): React.JSX.Element {
if (e.shiftKey && !e.altKey && e.key.toLowerCase() === 'f') {
dispatchClearModifierHints()
e.preventDefault()
window.dispatchEvent(new Event(LAYOUT_WILL_CHANGE_EVENT))
actions.setRightSidebarTab('search')
actions.setRightSidebarOpen(true)
return
@ -560,7 +552,6 @@ function App(): React.JSX.Element {
}
dispatchClearModifierHints()
e.preventDefault()
window.dispatchEvent(new Event(LAYOUT_WILL_CHANGE_EVENT))
actions.setRightSidebarTab('source-control')
actions.setRightSidebarOpen(true)
}
@ -606,10 +597,7 @@ function App(): React.JSX.Element {
<TooltipTrigger asChild>
<button
className="sidebar-toggle"
onClick={() => {
window.dispatchEvent(new Event(LAYOUT_WILL_CHANGE_EVENT))
actions.toggleSidebar()
}}
onClick={actions.toggleSidebar}
aria-label="Toggle sidebar"
>
<PanelLeft size={16} />
@ -738,10 +726,7 @@ function App(): React.JSX.Element {
<TooltipTrigger asChild>
<button
className="sidebar-toggle mr-2"
onClick={() => {
window.dispatchEvent(new Event(LAYOUT_WILL_CHANGE_EVENT))
actions.toggleRightSidebar()
}}
onClick={actions.toggleRightSidebar}
aria-label="Toggle right sidebar"
>
<PanelRight size={16} />
@ -757,7 +742,6 @@ function App(): React.JSX.Element {
if (activeView === 'tasks' && rightSidebarOpen) {
// Why: hide the right sidebar immediately when entering the tasks page
// so a previous open state can't bleed into that distraction-free view.
window.dispatchEvent(new Event(LAYOUT_WILL_CHANGE_EVENT))
actions.setRightSidebarOpen(false)
}
}, [activeView, rightSidebarOpen, actions])

View File

@ -1,8 +1,7 @@
import type { PaneManager } from '@/lib/pane-manager/pane-manager'
import type { ScrollState } from '@/lib/pane-manager/pane-manager-types'
export function fitPanes(manager: PaneManager, preCapturedStates?: Map<number, ScrollState>): void {
manager.fitAllPanes(preCapturedStates)
export function fitPanes(manager: PaneManager): void {
manager.fitAllPanes()
}
/**
@ -34,11 +33,8 @@ export function focusActivePane(manager: PaneManager): void {
activePane?.terminal.focus()
}
export function fitAndFocusPanes(
manager: PaneManager,
preCapturedStates?: Map<number, ScrollState>
): void {
fitPanes(manager, preCapturedStates)
export function fitAndFocusPanes(manager: PaneManager): void {
fitPanes(manager)
focusActivePane(manager)
}

View File

@ -103,10 +103,6 @@ function createPane(paneId: number) {
},
fitAddon: {
fit: vi.fn()
},
container: {
addEventListener: vi.fn(),
removeEventListener: vi.fn()
}
}
}

View File

@ -192,35 +192,10 @@ export function connectPanePty(
transport.sendInput(data)
})
// Why: during divider drag, each fit() fires terminal.onResize which sends
// SIGWINCH to the PTY. Interactive programs (Claude Code) redraw on SIGWINCH,
// writing new content that corrupts scroll position between capture and
// restore. We fully suppress PTY resize during drag and store the latest
// pending dimensions. When the drag ends, `pane-drag-end` fires on the pane
// container and we flush the final dimensions in one shot — no debounce
// timer that could fire mid-drag if the user pauses.
let pendingDragResize: { cols: number; rows: number } | null = null
const onResizeDisposable = pane.terminal.onResize(({ cols, rows }) => {
if (manager.isPaneDragResizing(pane.id)) {
pendingDragResize = { cols, rows }
return
}
transport.resize(cols, rows)
})
// Why: `unlockDragScroll` dispatches this event after clearing the drag
// scroll lock and restoring scroll state. At this point the terminal has
// its final dimensions from the last fit() but the PTY hasn't been told
// yet. Flush the pending resize so the PTY (and Claude Code) redraws at
// the correct size exactly once.
const onDragEnd = (): void => {
if (pendingDragResize) {
transport.resize(pendingDragResize.cols, pendingDragResize.rows)
pendingDragResize = null
}
}
pane.container.addEventListener('pane-drag-end', onDragEnd)
// Defer PTY spawn/attach to next frame so FitAddon has time to calculate
// the correct terminal dimensions from the laid-out container.
deps.pendingWritesRef.current.set(pane.id, '')
@ -518,8 +493,6 @@ export function connectPanePty(
clearTimeout(startupInjectTimer)
startupInjectTimer = null
}
pane.container.removeEventListener('pane-drag-end', onDragEnd)
pendingDragResize = null
if (connectFrame !== null) {
// Why: StrictMode and split-group remounts can dispose a pane binding
// before its deferred PTY attach/spawn work runs. Cancel that queued

View File

@ -1,12 +1,10 @@
import { useEffect, useRef } from 'react'
import {
FOCUS_TERMINAL_PANE_EVENT,
LAYOUT_WILL_CHANGE_EVENT,
TOGGLE_TERMINAL_PANE_EXPAND_EVENT,
type FocusTerminalPaneDetail
} from '@/constants/terminal'
import type { PaneManager } from '@/lib/pane-manager/pane-manager'
import type { ScrollState } from '@/lib/pane-manager/pane-manager-types'
import { shellEscapePath } from './pane-helpers'
import { fitAndFocusPanes, fitPanes, hasDimensionsChanged } from './pane-helpers'
import type { PtyTransport } from './pty-transport'
@ -71,14 +69,13 @@ export function useTerminalPaneGlobalEffects({
return
}
if (isVisible) {
// Why: capture scroll states BEFORE resumeRendering() because WebGL
// context recreation and refresh() can fire xterm.js internal scroll
// events that corrupt viewportY. This is the same pre-corruption
// capture strategy used for sidebar toggles and divider drags.
const preResumeScrollStates = manager.captureAllScrollStates()
// Why: resume WebGL immediately so the terminal shows its last-known
// state on the first painted frame.
// state on the first painted frame. Without this, the browser paints
// 1+ frames of stale xterm DOM-fallback content at wrong dimensions
// (the "stretched" flash). On macOS, WebGL context creation is ~5 ms
// — fast enough to feel instant. On Windows (ANGLE → D3D11) it can
// take 100500 ms, but the alternative (deferring to a rAF) leaves
// the terminal visibly distorted, which is a worse UX tradeoff.
manager.resumeRendering()
fitEpochRef.current++
@ -126,10 +123,10 @@ export function useTerminalPaneGlobalEffects({
}
fitRanForEpochRef.current = epoch
if (isActive) {
fitAndFocusPanes(mgr, preResumeScrollStates)
fitAndFocusPanes(mgr)
return
}
fitPanes(mgr, preResumeScrollStates)
fitPanes(mgr)
}
if (entries.length === 0) {
@ -256,36 +253,28 @@ export function useTerminalPaneGlobalEffects({
// terminal running at a stale column count.
const RESIZE_DEBOUNCE_MS = 150
let timerId: ReturnType<typeof setTimeout> | null = null
// Why: scroll states must be captured at the instant the ResizeObserver
// fires, not 150ms later when the debounced fit runs. During the debounce
// window, async events (WebGL context loss, xterm.js Viewport._sync)
// can corrupt the scroll position, so a later capture would record the
// wrong viewportY. Capturing eagerly preserves the true pre-resize state.
let pendingScrollStates: Map<number, ScrollState> | null = null
const resizeObserver = new ResizeObserver(() => {
const manager = managerRef.current
if (manager) {
pendingScrollStates = manager.captureAllScrollStates()
}
if (timerId !== null) {
clearTimeout(timerId)
}
timerId = setTimeout(() => {
timerId = null
const mgr = managerRef.current
if (!mgr) {
const manager = managerRef.current
if (!manager) {
return
}
// Why: apply the same epoch-based deduplication as the isActive
// effect's rAF path. Read the current epoch at fire time (not a
// captured value) because the ResizeObserver persists across the
// activation. Dimension changes (e.g. window resize) bypass the
// dedup so legitimate refits are never suppressed.
const currentEpoch = fitEpochRef.current
const dimensionsChanged = hasDimensionsChanged(mgr)
const dimensionsChanged = hasDimensionsChanged(manager)
if (!dimensionsChanged && fitRanForEpochRef.current >= currentEpoch) {
pendingScrollStates = null
return
}
fitRanForEpochRef.current = currentEpoch
const states = pendingScrollStates
pendingScrollStates = null
fitPanes(mgr, states ?? undefined)
fitPanes(manager)
}, RESIZE_DEBOUNCE_MS)
})
resizeObserver.observe(container)
@ -326,42 +315,4 @@ export function useTerminalPaneGlobalEffects({
}
})
}, [isActiveRef, managerRef, paneTransportsRef])
// Why: instant layout changes (sidebar toggle via Cmd+L / Cmd+B) resize the
// terminal container synchronously. xterm.js fires internal scroll events on
// the viewport div during this synchronous reflow, corrupting scrollTop before
// any ResizeObserver callback can capture state. By locking scroll states in
// response to a synchronous event dispatched BEFORE the state change, we
// guarantee capture happens while the viewport is still at its original
// position — the same pattern that keeps divider drag scroll stable.
useEffect(() => {
let unlockTimer: ReturnType<typeof setTimeout> | null = null
const onLayoutWillChange = (): void => {
const manager = managerRef.current
if (!manager || !isVisibleRef.current) {
return
}
manager.lockAllScrollStates()
// Why: the sidebar CSS transition (200ms) + ResizeObserver debounce (150ms)
// means the final fitAllPanesInternal runs at ~350ms. The unlock must fire
// AFTER that fit so the locked state is used for the definitive restore.
// 500ms provides margin for CSS transition variance and frame scheduling.
// unlockAllScrollStates starts a 500ms settling rAF loop, so the lock
// actually persists until ~1000ms total, absorbing any SIGWINCH redraws.
if (unlockTimer !== null) {
clearTimeout(unlockTimer)
}
unlockTimer = setTimeout(() => {
unlockTimer = null
managerRef.current?.unlockAllScrollStates()
}, 500)
}
window.addEventListener(LAYOUT_WILL_CHANGE_EVENT, onLayoutWillChange)
return () => {
window.removeEventListener(LAYOUT_WILL_CHANGE_EVENT, onLayoutWillChange)
if (unlockTimer !== null) {
clearTimeout(unlockTimer)
}
}
}, [managerRef, isVisibleRef])
}

View File

@ -1,6 +1,5 @@
export const TOGGLE_TERMINAL_PANE_EXPAND_EVENT = 'orca-toggle-terminal-pane-expand'
export const FOCUS_TERMINAL_PANE_EVENT = 'orca-focus-terminal-pane'
export const LAYOUT_WILL_CHANGE_EVENT = 'orca-layout-will-change'
export type ToggleTerminalPaneExpandDetail = {
tabId: string

View File

@ -1053,7 +1053,7 @@ describe('useIpcEvents shortcut hint clearing', () => {
expect(dispatchEvent).toHaveBeenCalledWith(
expect.objectContaining({ type: 'orca:clear-modifier-hints' })
)
expect(dispatchEvent).toHaveBeenCalledTimes(3)
expect(dispatchEvent).toHaveBeenCalledTimes(2)
expect(toggleSidebar).toHaveBeenCalledTimes(1)
expect(activateAndRevealWorktree).toHaveBeenCalledWith('wt-2')
})

View File

@ -16,7 +16,6 @@ import { dispatchZoomLevelChanged } from '@/lib/zoom-events'
import { resolveZoomTarget } from './resolve-zoom-target'
import { handleSwitchTab } from './ipc-tab-switch'
import { dispatchClearModifierHints } from './useModifierHint'
import { LAYOUT_WILL_CHANGE_EVENT } from '@/constants/terminal'
import { isGitRepoKind } from '../../../shared/repo-kind'
export { resolveZoomTarget } from './resolve-zoom-target'
@ -48,7 +47,6 @@ export function useIpcEvents(): void {
unsubs.push(
window.api.ui.onToggleLeftSidebar(() => {
dispatchClearModifierHints()
window.dispatchEvent(new Event(LAYOUT_WILL_CHANGE_EVENT))
useAppStore.getState().toggleSidebar()
})
)
@ -56,7 +54,6 @@ export function useIpcEvents(): void {
unsubs.push(
window.api.ui.onToggleRightSidebar(() => {
dispatchClearModifierHints()
window.dispatchEvent(new Event(LAYOUT_WILL_CHANGE_EVENT))
useAppStore.getState().toggleRightSidebar()
})
)

View File

@ -1,4 +1,4 @@
import type { ManagedPaneInternal, ScrollState } from './pane-manager-types'
import type { ManagedPaneInternal } from './pane-manager-types'
import { captureScrollState, restoreScrollState } from './pane-scroll'
// ---------------------------------------------------------------------------
@ -14,159 +14,17 @@ export function lockDragScroll(el: HTMLElement, panes: Map<number, ManagedPaneIn
}
}
// Why: SIGWINCH settling delay. After the drag ends we flush the suppressed
// PTY resize, which sends SIGWINCH to the shell. Interactive TUIs (Claude
// Code / Ink) redraw on SIGWINCH, writing cursor-positioned content that
// moves xterm's viewportY. If we clear pendingDragScrollState immediately,
// the ResizeObserver's captureAllPaneScrollStates runs against the corrupted
// viewportY and cements the wrong position. Keeping the lock active for
// this period makes captureAllPaneScrollStates skip the pane and makes
// safeFit / fitAllPanesInternal restore from the original captured state.
const SIGWINCH_SETTLE_MS = 500
export function unlockDragScroll(el: HTMLElement, panes: Map<number, ManagedPaneInternal>): void {
for (const pane of findManagedPanesUnder(el, panes)) {
if (pane.pendingDragScrollState) {
const originalState = pane.pendingDragScrollState
try {
restoreScrollState(pane.terminal, originalState)
} catch {
/* ignore */
}
// Why: during divider drag, PTY resize is suppressed to prevent
// SIGWINCH-driven redraws from corrupting scroll state. Dispatch
// on the pane container so pty-connection.ts can flush the final
// dimensions to the PTY when the drag ends.
pane.container.dispatchEvent(new CustomEvent('pane-drag-end'))
// Why: the PTY resize we just flushed causes SIGWINCH → Claude
// Code / Ink redraws → viewportY corruption. We keep the lock
// and actively restore scroll every frame during the settling
// period so the user never sees the viewport jump. Without this,
// there is a visible flash where the viewport jumps to line 0
// before the settling timeout fires.
startSettlingLoop(pane, originalState)
}
}
}
function startSettlingLoop(pane: ManagedPaneInternal, originalState: ScrollState): void {
let rafId: number | null = null
const restoreFrame = (): void => {
if (pane.pendingDragScrollState !== originalState) {
return
}
try {
restoreScrollState(pane.terminal, originalState)
} catch {
/* ignore */
}
rafId = requestAnimationFrame(restoreFrame)
}
rafId = requestAnimationFrame(restoreFrame)
setTimeout(() => {
if (rafId !== null) {
cancelAnimationFrame(rafId)
}
if (pane.pendingDragScrollState === originalState) {
try {
restoreScrollState(pane.terminal, originalState)
restoreScrollState(pane.terminal, pane.pendingDragScrollState)
} catch {
/* ignore */
}
pane.pendingDragScrollState = null
// Why: during the settling period isPaneDragResizing was true,
// so any terminal.onResize events were suppressed and stored as
// pending. Dispatch pane-drag-end again so pty-connection.ts
// flushes any accumulated resize to the PTY.
pane.container.dispatchEvent(new CustomEvent('pane-drag-end'))
}
}, SIGWINCH_SETTLE_MS)
}
export function captureAllPaneScrollStates(
panes: Map<number, ManagedPaneInternal>
): Map<number, ScrollState> {
const states = new Map<number, ScrollState>()
for (const pane of panes.values()) {
if (
!pane.pendingSplitScrollState &&
!pane.pendingDragScrollState &&
!pane.pendingLayoutScrollState
) {
states.set(pane.id, captureScrollState(pane.terminal))
}
}
return states
}
export function lockAllPaneScrollStates(panes: Map<number, ManagedPaneInternal>): void {
for (const pane of panes.values()) {
if (
!pane.pendingSplitScrollState &&
!pane.pendingDragScrollState &&
!pane.pendingLayoutScrollState
) {
pane.pendingLayoutScrollState = captureScrollState(pane.terminal)
}
}
}
export function unlockAllPaneScrollStates(panes: Map<number, ManagedPaneInternal>): void {
for (const pane of panes.values()) {
if (pane.pendingLayoutScrollState) {
const originalState = pane.pendingLayoutScrollState
try {
restoreScrollState(pane.terminal, originalState)
} catch {
/* ignore */
}
// Why: PTY resize is NOT suppressed during layout changes (unlike
// drag), so SIGWINCH fires immediately when fit() runs. Interactive
// TUIs (Claude Code / Ink) redraw on SIGWINCH, corrupting viewportY.
// The settling loop restores scroll every frame for 500ms, absorbing
// these redraws. The lock stays active so fitAllPanesInternal
// continues using the original captured state during settling.
startLayoutSettlingLoop(pane, originalState)
}
}
}
function startLayoutSettlingLoop(pane: ManagedPaneInternal, originalState: ScrollState): void {
let rafId: number | null = null
const restoreFrame = (): void => {
if (pane.pendingLayoutScrollState !== originalState) {
return
}
try {
restoreScrollState(pane.terminal, originalState)
} catch {
/* ignore */
}
rafId = requestAnimationFrame(restoreFrame)
}
rafId = requestAnimationFrame(restoreFrame)
setTimeout(() => {
if (rafId !== null) {
cancelAnimationFrame(rafId)
}
if (pane.pendingLayoutScrollState === originalState) {
try {
restoreScrollState(pane.terminal, originalState)
} catch {
/* ignore */
}
pane.pendingLayoutScrollState = null
}
}, SIGWINCH_SETTLE_MS)
}
function findManagedPanesUnder(

View File

@ -135,8 +135,7 @@ export function createPaneDOM(
webglAddon: null,
compositionHandler: null,
pendingSplitScrollState: null,
pendingDragScrollState: null,
pendingLayoutScrollState: null
pendingDragScrollState: null
}
// Focus handler: clicking a pane makes it active and explicitly focuses
@ -278,9 +277,6 @@ export function attachWebgl(pane: ManagedPaneInternal): void {
} else if (pane.pendingDragScrollState) {
pane.fitAddon.fit()
restoreScrollState(pane.terminal, pane.pendingDragScrollState)
} else if (pane.pendingLayoutScrollState) {
pane.fitAddon.fit()
restoreScrollState(pane.terminal, pane.pendingLayoutScrollState)
} else {
const scrollState = captureScrollState(pane.terminal)
pane.fitAddon.fit()
@ -346,24 +342,3 @@ export function disposePane(
}
panes.delete(pane.id)
}
export function suspendAllRendering(panes: Map<number, ManagedPaneInternal>): void {
for (const pane of panes.values()) {
disposeWebgl(pane)
}
}
export function resumeAllRendering(panes: Map<number, ManagedPaneInternal>): void {
for (const pane of panes.values()) {
if (pane.gpuRenderingEnabled && !pane.webglAddon) {
attachWebgl(pane)
// Why: fresh WebGL canvas from attachWebgl() has no painted content;
// without refresh the terminal appears frozen when dims are unchanged.
try {
pane.terminal.refresh(0, pane.terminal.rows - 1)
} catch {
/* ignore */
}
}
}
}

View File

@ -53,12 +53,6 @@ export type ScrollState = {
firstVisibleLineContent: string
viewportY: number
totalLines: number
cols: number
// Why: number of wrapped rows between the logical line start and the
// viewport row. When the anchor is the logical line start (not the
// viewport row itself), this offset lets restoreScrollState approximate
// the correct viewport position after reflow changes wrap points.
logicalLineOffset: number
}
export type ManagedPaneInternal = {
@ -83,11 +77,6 @@ export type ManagedPaneInternal = {
// terminal to a completely wrong position. Capturing once at drag start
// and reusing that state for every restore eliminates accumulation.
pendingDragScrollState: ScrollState | null
// Why: sidebar toggles and worktree switches resize the terminal container
// synchronously, corrupting scroll before ResizeObserver fires. Separate
// from pendingDragScrollState so a keyboard sidebar toggle during an active
// divider drag doesn't steal the drag's lock.
pendingLayoutScrollState: ScrollState | null
} & ManagedPane
export type DropZone = 'top' | 'bottom' | 'left' | 'right'

View File

@ -1,11 +1,9 @@
/* oxlint-disable max-lines */
import type {
PaneManagerOptions,
PaneStyleOptions,
ManagedPane,
ManagedPaneInternal,
DropZone,
ScrollState
DropZone
} from './pane-manager-types'
import {
createDivider,
@ -24,9 +22,7 @@ import {
openTerminal,
attachWebgl,
disposeWebgl,
disposePane,
suspendAllRendering,
resumeAllRendering
disposePane
} from './pane-lifecycle'
import { shouldFollowMouseFocus } from './focus-follows-mouse'
import {
@ -39,13 +35,7 @@ import {
captureScrollState,
refitPanesUnder
} from './pane-tree-ops'
import {
lockDragScroll,
unlockDragScroll,
captureAllPaneScrollStates,
lockAllPaneScrollStates,
unlockAllPaneScrollStates
} from './pane-drag-scroll'
import { lockDragScroll, unlockDragScroll } from './pane-drag-scroll'
import { scheduleSplitScrollRestore } from './pane-split-scroll'
export type { PaneManagerOptions, PaneStyleOptions, ManagedPane, DropZone }
@ -192,25 +182,8 @@ export class PaneManager {
return Array.from(this.panes.values()).map((p) => this.toPublic(p))
}
fitAllPanes(preCapturedStates?: Map<number, ScrollState>): void {
fitAllPanesInternal(this.panes, preCapturedStates)
}
captureAllScrollStates(): Map<number, ScrollState> {
return captureAllPaneScrollStates(this.panes)
}
lockAllScrollStates(): void {
lockAllPaneScrollStates(this.panes)
}
unlockAllScrollStates(): void {
unlockAllPaneScrollStates(this.panes)
}
isPaneDragResizing(paneId: number): boolean {
const pane = this.panes.get(paneId)
return pane?.pendingDragScrollState != null
fitAllPanes(): void {
fitAllPanesInternal(this.panes)
}
getActivePane(): ManagedPane | null {
@ -263,13 +236,32 @@ export class PaneManager {
}
suspendRendering(): void {
suspendAllRendering(this.panes)
for (const pane of this.panes.values()) {
disposeWebgl(pane)
}
}
resumeRendering(): void {
resumeAllRendering(this.panes)
for (const pane of this.panes.values()) {
if (pane.gpuRenderingEnabled && !pane.webglAddon) {
attachWebgl(pane)
// Why: the fitPanes() optimization skips panes whose dimensions are
// unchanged (common when a worktree goes hidden→visible at the same
// window size). But the fresh WebGL canvas created by attachWebgl()
// has no painted content — without an explicit refresh the terminal
// appears frozen until something forces a dimension change (e.g. a
// split). This mirrors the onContextLoss handler in attachWebgl which
// calls the same refresh after falling back to the DOM renderer.
try {
pane.terminal.refresh(0, pane.terminal.rows - 1)
} catch {
/* ignore — pane may not be fully initialised yet */
}
}
}
}
/** Move a pane from its current position to a new position relative to a target pane. */
movePane(sourcePaneId: number, targetPaneId: number, zone: DropZone): void {
handlePaneDrop(sourcePaneId, targetPaneId, zone, this.dragState, this.getDragCallbacks())
}
@ -284,6 +276,10 @@ export class PaneManager {
this.activePaneId = null
}
// -----------------------------------------------------------------------
// Internal helpers
// -----------------------------------------------------------------------
private createPaneInternal(): ManagedPaneInternal {
const id = this.nextPaneId++
const pane = createPaneDOM(
@ -293,8 +289,12 @@ export class PaneManager {
this.getDragCallbacks(),
(paneId) => {
if (!this.destroyed) {
// Why: always re-focus on click — activePaneId can be stale after
// splits, sending input to the wrong pane without an explicit focus.
// Why: split-pane layout/focus callbacks can leave the manager's
// activePaneId temporarily in sync while the browser's real focused
// xterm textarea is still on a different pane. Clicking a pane must
// always re-focus its terminal, even if the manager already thinks
// that pane is active; otherwise input can keep going to the wrong
// split after vertical/horizontal splits.
this.setActivePane(paneId, { focus: true })
}
},
@ -306,8 +306,16 @@ export class PaneManager {
return pane
}
// Why: modal overlays must be rendered OUTSIDE .pane elements, otherwise
// mouseenter fires on the pane underneath and incorrectly switches focus.
/**
* Focus-follows-mouse entry point. Collects gate inputs from the manager
* and delegates to the pure gate helper.
*
* Invariant for future contributors: modal overlays (context menus, close
* dialogs, command palette) must be rendered as portals/siblings OUTSIDE
* the pane container. If a future overlay is ever rendered inside a .pane
* element, mouseenter will still fire on the pane underneath and this
* handler will incorrectly switch focus. Keep overlays out of the pane.
*/
private handlePaneMouseEnter(paneId: number, event: MouseEvent): void {
if (
shouldFollowMouseFocus({
@ -348,6 +356,7 @@ export class PaneManager {
}
}
/** Build the callbacks object for drag-reorder functions. */
private getDragCallbacks() {
return {
getPanes: () => this.panes,

View File

@ -53,128 +53,32 @@ export function captureScrollState(terminal: Terminal): ScrollState {
const buf = terminal.buffer.active
const viewportY = buf.viewportY
const wasAtBottom = viewportY >= buf.baseY
const firstVisibleLineContent = buf.getLine(viewportY)?.translateToString(true)?.trimEnd() ?? ''
const totalLines = buf.baseY + terminal.rows
// Why: if the viewport starts at a wrapped continuation row, its content
// won't appear as a line start after reflow (column count change shifts
// wrap points). Walk backward to the logical line start — that content
// always remains a line start regardless of column width, making content
// matching reliable for long-line terminals like Claude Code.
let anchorY = viewportY
while (anchorY > 0 && buf.getLine(anchorY)?.isWrapped) {
anchorY--
}
const firstVisibleLineContent = buf.getLine(anchorY)?.translateToString(true)?.trimEnd() ?? ''
const logicalLineOffset = viewportY - anchorY
return {
wasAtBottom,
firstVisibleLineContent,
viewportY,
totalLines,
cols: terminal.cols,
logicalLineOffset
}
return { wasAtBottom, firstVisibleLineContent, viewportY, totalLines }
}
export function restoreScrollState(terminal: Terminal, state: ScrollState): void {
if (state.wasAtBottom) {
scrollToLineSync(terminal, terminal.buffer.active.baseY)
terminal.scrollToBottom()
forceViewportScrollbarSync(terminal)
return
}
const hintRatio = state.totalLines > 0 ? state.viewportY / state.totalLines : undefined
const target = findLineByContent(terminal, state.firstVisibleLineContent, hintRatio)
if (target >= 0) {
let scrollTarget = target
if (state.logicalLineOffset > 0) {
const newCols = terminal.cols
const scaledOffset =
state.cols > 0 && newCols > 0
? Math.round(state.logicalLineOffset * (state.cols / newCols))
: state.logicalLineOffset
scrollTarget = Math.min(target + scaledOffset, terminal.buffer.active.baseY)
}
scrollToLineSync(terminal, scrollTarget)
return
}
if (hintRatio !== undefined) {
const newTotalLines = terminal.buffer.active.baseY + terminal.rows
const fallbackLine = Math.round(hintRatio * newTotalLines)
const clampedLine = Math.min(fallbackLine, terminal.buffer.active.baseY)
scrollToLineSync(terminal, clampedLine)
terminal.scrollToLine(target)
forceViewportScrollbarSync(terminal)
}
}
// Why: the public terminal.scrollToLine(line) goes through
// CoreBrowserTerminal.scrollLines → viewport.scrollLines, which calls
// SmoothScrollableElement.setScrollPosition WITHOUT updating Viewport's
// _latestYDisp. A subsequent queued _sync (from resize) then sees
// ydisp === _latestYDisp (stale) and skips setScrollPosition, leaving the
// SmoothScrollableElement's internal scrollTop out of sync with the buffer.
// When the user later scrolls (mouse wheel), the SmoothScrollableElement
// adjusts from its stale scrollTop, causing the terminal to jump.
//
// Viewport.scrollToLine(line, disableSmoothScroll=true) directly sets
// _latestYDisp = line before calling setScrollPosition, keeping the
// scrollbar state in sync. We access this through _core._viewport which
// is an internal API (tested against @xterm/xterm 6.0.0). Falls back to
// the public API + scroll jiggle if internals are unavailable.
function scrollToLineSync(terminal: Terminal, line: number): void {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const viewport = (terminal as any)._core?._viewport
if (viewport && typeof viewport._sync === 'function') {
try {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const cellH = (terminal as any)._core?._renderService?.dimensions?.css?.cell?.height
const scrollableEl = viewport._scrollableElement
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const bufLines = (terminal as any)._core?._bufferService?.buffer?.lines?.length
// Why: _sync(line) calls setScrollDimensions (possibly with stale
// renderer values) then setScrollPosition. If the stale dimensions
// give a maxScrollTop smaller than our target, the position is
// silently clamped. We call _sync first to let it do its thing,
// then OVERRIDE with correct dimensions computed from the actual
// buffer state, then re-set the scroll position. The second
// setScrollDimensions + setScrollPosition pair operates against
// accurate maxScrollTop so the target isn't clamped.
viewport._latestYDisp = undefined
viewport._sync(line)
if (
cellH &&
bufLines &&
scrollableEl?.setScrollDimensions &&
scrollableEl?.setScrollPosition
) {
// Why: suppress _handleScroll during our dimension fix-up to
// prevent stale scroll events from overwriting buffer.ydisp.
viewport._suppressOnScrollHandler = true
scrollableEl.setScrollDimensions({
height: cellH * terminal.rows,
scrollHeight: cellH * bufLines
})
viewport._suppressOnScrollHandler = false
scrollableEl.setScrollPosition({ scrollTop: line * cellH })
}
viewport._latestYDisp = line
} catch {
terminal.scrollToLine(line)
forceViewportScrollbarSync(terminal)
}
return
}
terminal.scrollToLine(line)
forceViewportScrollbarSync(terminal)
}
// Why: fallback for when the internal viewport API is unavailable. The
// scroll jiggle (-1/+1) triggers _handleScroll with diff≠0, which updates
// _latestYDisp. Less reliable than the direct viewport.scrollToLine path
// because it reads from the SmoothScrollableElement's potentially-stale
// scrollTop, but better than nothing.
// Why: xterm 6's Viewport._sync() updates scrollDimensions after resize but
// skips the scrollPosition update when ydisp matches _latestYDisp (a stale
// internal value). This leaves the scrollbar thumb at a wrong position even
// though the rendered content is correct. A scroll jiggle (-1/+1) in the
// same JS turn forces _sync() to fire with a differing ydisp, which triggers
// setScrollPosition and syncs the scrollbar. No paint occurs between the two
// synchronous calls so the intermediate state is never visible.
function forceViewportScrollbarSync(terminal: Terminal): void {
const buf = terminal.buffer.active
if (buf.viewportY > 0) {

View File

@ -1,9 +1,4 @@
import type {
DropZone,
ManagedPaneInternal,
PaneStyleOptions,
ScrollState
} from './pane-manager-types'
import type { DropZone, ManagedPaneInternal, PaneStyleOptions } from './pane-manager-types'
import { createDivider } from './pane-divider'
import { captureScrollState, restoreScrollState } from './pane-scroll'
@ -31,21 +26,7 @@ export function safeFit(pane: ManagedPaneInternal): void {
}
if (pane.pendingDragScrollState) {
pane.fitAddon.fit()
// Why: fit() → resize() → queueSync() schedules a deferred _sync via
// addRefreshCallback that updates the Scrollable's dimensions in the next
// animation frame. Any scroll position we set synchronously here can be
// silently clamped when that deferred _sync calls setScrollDimensions
// (with _suppressOnScrollHandler=true, so clamping updates neither
// _latestYDisp nor buffer.ydisp). By scheduling our restore via the same
// addRefreshCallback mechanism, we run AFTER the deferred _sync has
// corrected the Scrollable dimensions, so our setScrollPosition operates
// against accurate maxScrollTop and isn't clamped.
deferredDragScrollRestore(pane)
return
}
if (pane.pendingLayoutScrollState) {
pane.fitAddon.fit()
restoreScrollState(pane.terminal, pane.pendingLayoutScrollState)
restoreScrollState(pane.terminal, pane.pendingDragScrollState)
return
}
const state = captureScrollState(pane.terminal)
@ -56,32 +37,7 @@ export function safeFit(pane: ManagedPaneInternal): void {
}
}
function deferredDragScrollRestore(pane: ManagedPaneInternal): void {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const renderService = (pane.terminal as any)._core?._renderService
if (!renderService?.addRefreshCallback) {
if (pane.pendingDragScrollState) {
restoreScrollState(pane.terminal, pane.pendingDragScrollState)
}
return
}
renderService.addRefreshCallback(() => {
try {
const state = pane.pendingDragScrollState
if (!state) {
return
}
restoreScrollState(pane.terminal, state)
} catch {
/* pane may have been disposed */
}
})
}
export function fitAllPanesInternal(
panes: Map<number, ManagedPaneInternal>,
preCapturedStates?: Map<number, ScrollState>
): void {
export function fitAllPanesInternal(panes: Map<number, ManagedPaneInternal>): void {
for (const pane of panes.values()) {
try {
const dims = pane.fitAddon.proposeDimensions()
@ -97,17 +53,7 @@ export function fitAllPanesInternal(
restoreScrollState(pane.terminal, pane.pendingDragScrollState)
continue
}
if (pane.pendingLayoutScrollState) {
pane.fitAddon.fit()
restoreScrollState(pane.terminal, pane.pendingLayoutScrollState)
continue
}
// Why: use pre-captured state when available because the ResizeObserver
// debounce (150ms) gives async events (WebGL context loss, viewport
// _sync) time to corrupt the scroll position before we run. Capturing
// at the instant the ResizeObserver fires preserves the true pre-resize
// viewport position.
const state = preCapturedStates?.get(pane.id) ?? captureScrollState(pane.terminal)
const state = captureScrollState(pane.terminal)
pane.fitAddon.fit()
restoreScrollState(pane.terminal, state)
} catch {

View File

@ -1,72 +0,0 @@
/**
* Simulates Ink's (React for CLI) inline viewport rendering behavior.
*
* Ink renders inline in the normal terminal buffer (not the alt screen).
* On SIGWINCH it re-renders by:
* 1. Moving cursor up to the start of the previous render
* 2. Clearing from cursor to end of display (\033[J)
* 3. Writing the new rendered output
*
* This is the exact pattern that causes scroll position corruption during
* divider drag resize, because the clear + rewrite sequence moves xterm.js's
* viewportY when the terminal has scrollback content above the render area.
*
* Usage: node tests/e2e/fixtures/ink-tui-sim.mjs
* Exits cleanly on SIGTERM or SIGINT.
*/
const TUI_HEIGHT = 12
let lastLineCount = 0
function render() {
const { columns: cols, rows } = process.stdout
// Step 1: if we previously rendered, move cursor back to start of that render
if (lastLineCount > 0) {
process.stdout.write(`\x1b[${lastLineCount}A\r`)
}
// Step 2: clear from cursor to end of display (exactly what Ink does)
process.stdout.write('\x1b[J')
// Step 3: write the TUI content
const lines = []
const hr = '━'.repeat(Math.min(cols, 80))
lines.push(`\x1b[1m${hr}\x1b[0m`)
lines.push(` \x1b[36m◆\x1b[0m Claude Code \x1b[2mv2.1.10\x1b[0m`)
lines.push(` \x1b[2mModel: claude-sonnet-4-20250514\x1b[0m`)
lines.push('')
lines.push(` \x1b[33m⠋\x1b[0m Thinking...`)
lines.push('')
// Fill remaining TUI height with status lines
const used = lines.length
for (let i = used; i < TUI_HEIGHT - 2; i++) {
const pad = ' '.repeat(Math.max(0, Math.min(cols, 80) - 30))
lines.push(` \x1b[2mcontext: ${cols}×${rows} │ tokens: ${1234 + i}${pad}\x1b[0m`)
}
lines.push(`\x1b[1m${hr}\x1b[0m`)
lines.push(
`\x1b[7m ${' '.repeat(Math.max(0, Math.min(cols, 80) - 22))}` +
`${cols}×${rows} | Ctrl+C to exit \x1b[0m`
)
const output = `${lines.join('\n')}\n`
process.stdout.write(output)
lastLineCount = lines.length
}
// Initial render
render()
// Re-render on SIGWINCH (terminal resize) — this is the critical behavior
process.on('SIGWINCH', render)
// Clean exit
process.on('SIGTERM', () => process.exit(0))
process.on('SIGINT', () => process.exit(0))
// Keep alive
setInterval(() => {}, 60_000)

View File

@ -1,4 +1,3 @@
/* oxlint-disable max-lines, curly */
/**
* E2E tests for terminal pane splitting, state retention, resizing, and closing.
*
@ -34,7 +33,6 @@ import {
ensureTerminalVisible
} from './helpers/store'
import { pressShortcut } from './helpers/shortcuts'
import path from 'path'
// Why: only the pointer-drag resize test needs a visible window (pointer
// capture requires a real pointer id). Every other pane operation here is
@ -282,775 +280,6 @@ test.describe('Terminal Panes', () => {
.toBe(true)
})
/**
* Regression test: dragging a vertical divider to resize split panes must
* preserve scroll position in partially-scrolled terminals. The bug manifests
* as terminals jumping to the top (or near-top) during/after drag.
*
* Why headful: pointer capture requires a real pointer ID from a visible window.
*/
test('@headful scroll position preserved during divider drag resize', async ({ orcaPage }) => {
await splitActiveTerminalPane(orcaPage, 'vertical')
await waitForPaneCount(orcaPage, 2)
const ptyId = await discoverActivePtyId(orcaPage)
// Why: long lines that wrap are essential to reproduce the bug. Short
// lines (like `seq 1 5000`) don't wrap, so baseY stays constant during
// reflow and scroll preservation is trivially correct. Claude Code output
// contains long formatted lines that reflow dramatically on column changes.
// Simulate Claude Code-like output: ANSI-formatted text with unicode
// box-drawing characters and long wrapped lines, similar to what Claude
// Code renders in a real session.
await execInTerminal(
orcaPage,
ptyId,
`python3 -c "
import sys
# Banner like Claude Code
print('\\033[1;36m ▐▛███▜▌ Claude Code v2.1.10\\033[0m')
print('\\033[90m' + '─' * 200 + '\\033[0m')
# Simulated conversation with long wrapped lines
for i in range(500):
if i % 10 == 0:
print(f'\\033[1;32m User message {i//10}:\\033[0m')
print(' ' + 'Here is a long user prompt that wraps across multiple lines. ' * 4)
else:
# Code block with indentation
print(f'\\033[90m {i:4d} │\\033[0m ' + 'const result = await fetch(url).then(r => r.json()).catch(err => console.error(err)); // ' + 'x' * 80)
print('\\033[90m' + '─' * 200 + '\\033[0m')
print('DONE_MARKER')
"`
)
await waitForTerminalOutput(orcaPage, 'DONE_MARKER', 15_000)
await orcaPage.waitForTimeout(500)
// Scroll to ~middle of scrollback
const scrollBefore = await orcaPage.evaluate(() => {
const tabId = (() => {
const store = window.__store
if (!store) return null
const state = store.getState()
const wId = state.activeWorktreeId
if (!wId) return null
return (state.tabsByWorktree[wId] ?? [])[0]?.id ?? null
})()
if (!tabId) return null
const manager = window.__paneManagers?.get(tabId)
if (!manager) return null
const pane = manager.getActivePane()
if (!pane) return null
const buf = pane.terminal.buffer.active
const targetLine = Math.floor(buf.baseY / 2)
pane.terminal.scrollToLine(targetLine)
return {
viewportY: pane.terminal.buffer.active.viewportY,
baseY: buf.baseY,
paneId: pane.id,
cols: pane.terminal.cols
}
})
expect(scrollBefore).not.toBeNull()
expect(scrollBefore!.viewportY).toBeGreaterThan(0)
// Capture renderer console logs for diagnostics
const consoleLogs: string[] = []
orcaPage.on('console', (msg) => {
const text = msg.text()
if (text.includes('[restoreScrollState]') || text.includes('[scrollToLineSync]')) {
consoleLogs.push(text)
}
})
// Drag the divider LEFT then RIGHT then LEFT — simulating the user's
// "adjust right and left" gesture. Each direction change causes reflow.
const divider = orcaPage.locator('.pane-divider.is-vertical').first()
await expect(divider).toBeVisible({ timeout: 3_000 })
const box = await divider.boundingBox()
expect(box).not.toBeNull()
const startX = box!.x + box!.width / 2
const startY = box!.y + box!.height / 2
await orcaPage.mouse.move(startX, startY)
await orcaPage.mouse.down()
// Drag left (narrowing), then right (widening), then left again
await orcaPage.mouse.move(startX - 250, startY, { steps: 20 })
await orcaPage.mouse.move(startX + 150, startY, { steps: 20 })
await orcaPage.mouse.move(startX - 200, startY, { steps: 20 })
await orcaPage.mouse.up()
await orcaPage.waitForTimeout(500)
const scrollAfter = await orcaPage.evaluate(
({ paneId }) => {
const tabId = (() => {
const store = window.__store
if (!store) return null
const state = store.getState()
const wId = state.activeWorktreeId
if (!wId) return null
return (state.tabsByWorktree[wId] ?? [])[0]?.id ?? null
})()
if (!tabId) return null
const manager = window.__paneManagers?.get(tabId)
if (!manager) return null
const panes = manager.getPanes() ?? []
const pane = panes.find((p: { id: number }) => p.id === paneId)
if (!pane) return null
return {
viewportY: pane.terminal.buffer.active.viewportY,
baseY: pane.terminal.buffer.active.baseY,
cols: pane.terminal.cols
}
},
{ paneId: scrollBefore!.paneId }
)
expect(scrollAfter).not.toBeNull()
const drift = Math.abs(scrollAfter!.viewportY - scrollBefore!.viewportY)
// Use proportional position (viewportY / baseY) since absolute line
// numbers change legitimately during reflow (more/less wrapping).
const ratioBefore = scrollBefore!.viewportY / scrollBefore!.baseY
const ratioAfter = scrollAfter!.baseY > 0 ? scrollAfter!.viewportY / scrollAfter!.baseY : 0
const proportionalDrift = Math.abs(ratioAfter - ratioBefore)
console.log(
'[scroll-test] wrapping-content:',
JSON.stringify({
before: scrollBefore!.viewportY,
after: scrollAfter!.viewportY,
absoluteDrift: drift,
ratioBefore: ratioBefore.toFixed(4),
ratioAfter: ratioAfter.toFixed(4),
proportionalDrift: proportionalDrift.toFixed(4),
baseYBefore: scrollBefore!.baseY,
baseYAfter: scrollAfter!.baseY,
colsBefore: scrollBefore!.cols,
colsAfter: scrollAfter!.cols
})
)
// Proportional drift should be <5% — the same relative content should
// be visible regardless of how wrapping changed the absolute line count.
expect(proportionalDrift).toBeLessThanOrEqual(0.05)
// Must not be at the very top
expect(scrollAfter!.viewportY).toBeGreaterThan(scrollAfter!.baseY * 0.1)
// Check for Scrollable desync: do a small mouse wheel scroll and verify
// the viewport doesn't jump to a completely different position.
const paneEl = orcaPage.locator(`.pane[data-pane-id="${scrollBefore!.paneId}"]`).first()
const paneBox = await paneEl.boundingBox()
if (paneBox) {
await orcaPage.mouse.move(paneBox.x + paneBox.width / 2, paneBox.y + paneBox.height / 2)
await orcaPage.mouse.wheel(0, 50) // scroll down slightly
await orcaPage.waitForTimeout(200)
const scrollAfterWheel = await orcaPage.evaluate(
({ paneId }) => {
const tabId = (() => {
const store = window.__store
if (!store) return null
const state = store.getState()
const wId = state.activeWorktreeId
if (!wId) return null
return (state.tabsByWorktree[wId] ?? [])[0]?.id ?? null
})()
if (!tabId) return null
const manager = window.__paneManagers?.get(tabId)
if (!manager) return null
const panes = manager.getPanes() ?? []
const pane = panes.find((p: { id: number }) => p.id === paneId)
if (!pane) return null
return {
viewportY: pane.terminal.buffer.active.viewportY,
baseY: pane.terminal.buffer.active.baseY
}
},
{ paneId: scrollBefore!.paneId }
)
if (scrollAfterWheel) {
const wheelJump = Math.abs(scrollAfterWheel.viewportY - scrollAfter!.viewportY)
const ratioAfterWheel =
scrollAfterWheel.baseY > 0 ? scrollAfterWheel.viewportY / scrollAfterWheel.baseY : 0
console.log(
'[scroll-test] post-wheel:',
JSON.stringify({
viewportYAfterDrag: scrollAfter!.viewportY,
viewportYAfterWheel: scrollAfterWheel.viewportY,
wheelJump,
ratioAfterWheel: ratioAfterWheel.toFixed(4)
})
)
// A small wheel scroll should move by a few lines, not jump hundreds
expect(wheelJump).toBeLessThan(scrollAfterWheel.baseY * 0.05)
}
}
})
test('@headful scroll position preserved in triple split layout', async ({ orcaPage }) => {
// Reproduce user's exact layout: triple split (top-left has content,
// bottom-left and right are empty)
await splitActiveTerminalPane(orcaPage, 'vertical') // left | right
await waitForPaneCount(orcaPage, 2)
await splitActiveTerminalPane(orcaPage, 'horizontal') // top-left / bottom-left | right
await waitForPaneCount(orcaPage, 3)
// Focus the first pane (top-left) and fill with wrapping content
const ptyId = await discoverActivePtyId(orcaPage)
await execInTerminal(
orcaPage,
ptyId,
'python3 -c "import string; s=string.ascii_letters+string.digits; [print(f\'LINE_{i:04d} \' + (s*8)[:500]) for i in range(1000)]"'
)
await waitForTerminalOutput(orcaPage, 'LINE_0999', 15_000)
await orcaPage.waitForTimeout(500)
const scrollBefore = await orcaPage.evaluate(() => {
const tabId = (() => {
const store = window.__store
if (!store) return null
const state = store.getState()
const wId = state.activeWorktreeId
if (!wId) return null
return (state.tabsByWorktree[wId] ?? [])[0]?.id ?? null
})()
if (!tabId) return null
const manager = window.__paneManagers?.get(tabId)
if (!manager) return null
const pane = manager.getActivePane()
if (!pane) return null
const buf = pane.terminal.buffer.active
const targetLine = Math.floor(buf.baseY / 2)
pane.terminal.scrollToLine(targetLine)
return {
viewportY: pane.terminal.buffer.active.viewportY,
baseY: buf.baseY,
paneId: pane.id,
cols: pane.terminal.cols
}
})
expect(scrollBefore).not.toBeNull()
expect(scrollBefore!.viewportY).toBeGreaterThan(0)
const consoleLogs: string[] = []
orcaPage.on('console', (msg) => {
const text = msg.text()
if (text.includes('[restoreScrollState]') || text.includes('[scrollToLineSync]')) {
consoleLogs.push(text)
}
})
// Drag the HORIZONTAL divider (between top-left and bottom-left) UP
// to change the height of the content pane — this changes rows and
// triggers reflow in a triple-split layout.
// Also try the vertical divider to change cols.
const divider = orcaPage.locator('.pane-divider.is-vertical').first()
await expect(divider).toBeVisible({ timeout: 3_000 })
const box = await divider.boundingBox()
expect(box).not.toBeNull()
const startX = box!.x + box!.width / 2
const startY = box!.y + box!.height / 2
await orcaPage.mouse.move(startX, startY)
await orcaPage.mouse.down()
// Drag RIGHT to widen left panes (more cols, less wrapping)
await orcaPage.mouse.move(startX + 200, startY, { steps: 30 })
await orcaPage.mouse.up()
await orcaPage.waitForTimeout(500)
const scrollAfter = await orcaPage.evaluate(
({ paneId }) => {
const tabId = (() => {
const store = window.__store
if (!store) return null
const state = store.getState()
const wId = state.activeWorktreeId
if (!wId) return null
return (state.tabsByWorktree[wId] ?? [])[0]?.id ?? null
})()
if (!tabId) return null
const manager = window.__paneManagers?.get(tabId)
if (!manager) return null
const panes = manager.getPanes() ?? []
const pane = panes.find((p: { id: number }) => p.id === paneId)
if (!pane) return null
return {
viewportY: pane.terminal.buffer.active.viewportY,
baseY: pane.terminal.buffer.active.baseY,
cols: pane.terminal.cols
}
},
{ paneId: scrollBefore!.paneId }
)
expect(scrollAfter).not.toBeNull()
const ratioBefore = scrollBefore!.viewportY / scrollBefore!.baseY
const ratioAfter = scrollAfter!.baseY > 0 ? scrollAfter!.viewportY / scrollAfter!.baseY : 0
const proportionalDrift = Math.abs(ratioAfter - ratioBefore)
console.log(
'[scroll-test] triple-split:',
JSON.stringify({
before: scrollBefore!.viewportY,
after: scrollAfter!.viewportY,
ratioBefore: ratioBefore.toFixed(4),
ratioAfter: ratioAfter.toFixed(4),
proportionalDrift: proportionalDrift.toFixed(4),
baseYBefore: scrollBefore!.baseY,
baseYAfter: scrollAfter!.baseY,
colsBefore: scrollBefore!.cols,
colsAfter: scrollAfter!.cols
})
)
// Check for Scrollable desync via mouse wheel
const paneEl = orcaPage.locator(`.pane[data-pane-id="${scrollBefore!.paneId}"]`).first()
const paneBox = await paneEl.boundingBox()
if (paneBox) {
await orcaPage.mouse.move(paneBox.x + paneBox.width / 2, paneBox.y + paneBox.height / 2)
await orcaPage.mouse.wheel(0, 50)
await orcaPage.waitForTimeout(200)
const scrollAfterWheel = await orcaPage.evaluate(
({ paneId }) => {
const tabId = (() => {
const store = window.__store
if (!store) return null
const state = store.getState()
const wId = state.activeWorktreeId
if (!wId) return null
return (state.tabsByWorktree[wId] ?? [])[0]?.id ?? null
})()
if (!tabId) return null
const manager = window.__paneManagers?.get(tabId)
if (!manager) return null
const panes = manager.getPanes() ?? []
const pane = panes.find((p: { id: number }) => p.id === paneId)
if (!pane) return null
return {
viewportY: pane.terminal.buffer.active.viewportY,
baseY: pane.terminal.buffer.active.baseY
}
},
{ paneId: scrollBefore!.paneId }
)
if (scrollAfterWheel) {
const wheelJump = Math.abs(scrollAfterWheel.viewportY - scrollAfter!.viewportY)
console.log(
'[scroll-test] triple-split-post-wheel:',
JSON.stringify({
viewportYAfterDrag: scrollAfter!.viewportY,
viewportYAfterWheel: scrollAfterWheel.viewportY,
wheelJump
})
)
expect(wheelJump).toBeLessThan(scrollAfterWheel.baseY * 0.05)
}
}
expect(proportionalDrift).toBeLessThanOrEqual(0.05)
expect(scrollAfter!.viewportY).toBeGreaterThan(scrollAfter!.baseY * 0.1)
})
test('@headful scroll position preserved during drag with active terminal writes', async ({
orcaPage
}) => {
await splitActiveTerminalPane(orcaPage, 'vertical')
await waitForPaneCount(orcaPage, 2)
const ptyId = await discoverActivePtyId(orcaPage)
await execInTerminal(orcaPage, ptyId, 'seq 1 5000')
await waitForTerminalOutput(orcaPage, '5000')
await orcaPage.waitForTimeout(300)
const scrollBefore = await orcaPage.evaluate(() => {
const tabId = (() => {
const store = window.__store
if (!store) return null
const state = store.getState()
const wId = state.activeWorktreeId
if (!wId) return null
return (state.tabsByWorktree[wId] ?? [])[0]?.id ?? null
})()
if (!tabId) return null
const manager = window.__paneManagers?.get(tabId)
if (!manager) return null
const pane = manager.getActivePane()
if (!pane) return null
const buf = pane.terminal.buffer.active
const targetLine = Math.floor(buf.baseY / 2)
pane.terminal.scrollToLine(targetLine)
return {
viewportY: pane.terminal.buffer.active.viewportY,
baseY: buf.baseY,
paneId: pane.id,
cols: pane.terminal.cols
}
})
expect(scrollBefore).not.toBeNull()
expect(scrollBefore!.viewportY).toBeGreaterThan(0)
// Start a background loop that writes to the terminal during drag,
// simulating what Claude Code does when it redraws on SIGWINCH
await execInTerminal(
orcaPage,
ptyId,
'while true; do echo "REDRAW $(date +%s%N)"; sleep 0.05; done &'
)
await orcaPage.waitForTimeout(200)
// Re-scroll after the writes started (they push to bottom)
await orcaPage.evaluate(
({ paneId, targetViewportY }) => {
const tabId = (() => {
const store = window.__store
if (!store) return null
const state = store.getState()
const wId = state.activeWorktreeId
if (!wId) return null
return (state.tabsByWorktree[wId] ?? [])[0]?.id ?? null
})()
if (!tabId) return
const manager = window.__paneManagers?.get(tabId)
if (!manager) return
const panes = manager.getPanes() ?? []
const pane = panes.find((p: { id: number }) => p.id === paneId)
if (!pane) return
pane.terminal.scrollToLine(targetViewportY)
},
{ paneId: scrollBefore!.paneId, targetViewportY: scrollBefore!.viewportY }
)
await orcaPage.waitForTimeout(100)
const divider = orcaPage.locator('.pane-divider.is-vertical').first()
await expect(divider).toBeVisible({ timeout: 3_000 })
const box = await divider.boundingBox()
expect(box).not.toBeNull()
const startX = box!.x + box!.width / 2
const startY = box!.y + box!.height / 2
await orcaPage.mouse.move(startX, startY)
await orcaPage.mouse.down()
await orcaPage.mouse.move(startX + 100, startY, { steps: 15 })
await orcaPage.mouse.up()
// Kill the background writer
await execInTerminal(orcaPage, ptyId, 'kill %1 2>/dev/null; true')
await orcaPage.waitForTimeout(500)
const scrollAfter = await orcaPage.evaluate(
({ paneId }) => {
const tabId = (() => {
const store = window.__store
if (!store) return null
const state = store.getState()
const wId = state.activeWorktreeId
if (!wId) return null
return (state.tabsByWorktree[wId] ?? [])[0]?.id ?? null
})()
if (!tabId) return null
const manager = window.__paneManagers?.get(tabId)
if (!manager) return null
const panes = manager.getPanes() ?? []
const pane = panes.find((p: { id: number }) => p.id === paneId)
if (!pane) return null
return {
viewportY: pane.terminal.buffer.active.viewportY,
baseY: pane.terminal.buffer.active.baseY,
cols: pane.terminal.cols
}
},
{ paneId: scrollBefore!.paneId }
)
expect(scrollAfter).not.toBeNull()
const drift = Math.abs(scrollAfter!.viewportY - scrollBefore!.viewportY)
console.log('[scroll-test] active-writes:', {
before: scrollBefore!.viewportY,
after: scrollAfter!.viewportY,
drift,
baseYBefore: scrollBefore!.baseY,
baseYAfter: scrollAfter!.baseY,
colsBefore: scrollBefore!.cols,
colsAfter: scrollAfter!.cols
})
// With active writes, allow more tolerance but must not jump to top
const maxDrift = scrollBefore!.baseY * 0.1
expect(drift).toBeLessThanOrEqual(maxDrift)
expect(scrollAfter!.viewportY).toBeGreaterThan(scrollBefore!.baseY * 0.1)
})
test('@headful scroll position preserved during drag with TUI-style redraws', async ({
orcaPage
}) => {
await splitActiveTerminalPane(orcaPage, 'vertical')
await waitForPaneCount(orcaPage, 2)
const ptyId = await discoverActivePtyId(orcaPage)
await execInTerminal(orcaPage, ptyId, 'seq 1 5000')
await waitForTerminalOutput(orcaPage, '5000')
await orcaPage.waitForTimeout(300)
const scrollBefore = await orcaPage.evaluate(() => {
const tabId = (() => {
const store = window.__store
if (!store) return null
const state = store.getState()
const wId = state.activeWorktreeId
if (!wId) return null
return (state.tabsByWorktree[wId] ?? [])[0]?.id ?? null
})()
if (!tabId) return null
const manager = window.__paneManagers?.get(tabId)
if (!manager) return null
const pane = manager.getActivePane()
if (!pane) return null
const buf = pane.terminal.buffer.active
const targetLine = Math.floor(buf.baseY / 2)
pane.terminal.scrollToLine(targetLine)
return {
viewportY: pane.terminal.buffer.active.viewportY,
baseY: buf.baseY,
paneId: pane.id,
cols: pane.terminal.cols
}
})
expect(scrollBefore).not.toBeNull()
expect(scrollBefore!.viewportY).toBeGreaterThan(0)
// Simulate TUI redraw: a SIGWINCH-responsive script that clears the
// last few lines and rewrites them — like Claude Code's status bar.
// This uses cursor movement + clear-to-end escape sequences.
await execInTerminal(
orcaPage,
ptyId,
String.raw`trap 'printf "\033[s\033[999;1H\033[2K--- RESIZE %dx%d ---\033[u" $COLUMNS $LINES' WINCH; while true; do sleep 0.1; done &`
)
await orcaPage.waitForTimeout(200)
// Re-scroll after trap setup
await orcaPage.evaluate(
({ paneId, targetViewportY }) => {
const tabId = (() => {
const store = window.__store
if (!store) return null
const state = store.getState()
const wId = state.activeWorktreeId
if (!wId) return null
return (state.tabsByWorktree[wId] ?? [])[0]?.id ?? null
})()
if (!tabId) return
const manager = window.__paneManagers?.get(tabId)
if (!manager) return
const panes = manager.getPanes() ?? []
const pane = panes.find((p: { id: number }) => p.id === paneId)
if (!pane) return
pane.terminal.scrollToLine(targetViewportY)
},
{ paneId: scrollBefore!.paneId, targetViewportY: scrollBefore!.viewportY }
)
await orcaPage.waitForTimeout(100)
const divider = orcaPage.locator('.pane-divider.is-vertical').first()
await expect(divider).toBeVisible({ timeout: 3_000 })
const box = await divider.boundingBox()
expect(box).not.toBeNull()
const startX = box!.x + box!.width / 2
const startY = box!.y + box!.height / 2
await orcaPage.mouse.move(startX, startY)
await orcaPage.mouse.down()
// Slower drag with more steps to trigger multiple SIGWINCHes
await orcaPage.mouse.move(startX + 150, startY, { steps: 30 })
await orcaPage.mouse.up()
await execInTerminal(orcaPage, ptyId, 'kill %1 2>/dev/null; true')
await orcaPage.waitForTimeout(500)
const scrollAfter = await orcaPage.evaluate(
({ paneId }) => {
const tabId = (() => {
const store = window.__store
if (!store) return null
const state = store.getState()
const wId = state.activeWorktreeId
if (!wId) return null
return (state.tabsByWorktree[wId] ?? [])[0]?.id ?? null
})()
if (!tabId) return null
const manager = window.__paneManagers?.get(tabId)
if (!manager) return null
const panes = manager.getPanes() ?? []
const pane = panes.find((p: { id: number }) => p.id === paneId)
if (!pane) return null
return {
viewportY: pane.terminal.buffer.active.viewportY,
baseY: pane.terminal.buffer.active.baseY,
cols: pane.terminal.cols
}
},
{ paneId: scrollBefore!.paneId }
)
expect(scrollAfter).not.toBeNull()
const drift = Math.abs(scrollAfter!.viewportY - scrollBefore!.viewportY)
console.log('[scroll-test] tui-redraw:', {
before: scrollBefore!.viewportY,
after: scrollAfter!.viewportY,
drift,
baseYBefore: scrollBefore!.baseY,
baseYAfter: scrollAfter!.baseY,
colsBefore: scrollBefore!.cols,
colsAfter: scrollAfter!.cols
})
const maxDrift = scrollBefore!.baseY * 0.1
expect(drift).toBeLessThanOrEqual(maxDrift)
expect(scrollAfter!.viewportY).toBeGreaterThan(scrollBefore!.baseY * 0.1)
})
/**
* Regression test for the SIGWINCH scroll corruption bug.
*
* Uses an Ink-like TUI simulator that reproduces the exact rendering
* pattern that caused Claude Code sessions to lose scroll position during
* divider drag resize: cursor-up + clear-to-end-of-display + rewrite.
*
* The bug: drag resize PTY resize SIGWINCH TUI redraws viewportY
* corrupted ResizeObserver captures corrupted state scroll jumps to 0.
*
* Why headful: pointer capture requires a real pointer ID from a visible window.
*/
test('@headful scroll position preserved during drag with Ink-style TUI redraws', async ({
orcaPage
}) => {
await splitActiveTerminalPane(orcaPage, 'vertical')
await waitForPaneCount(orcaPage, 2)
const ptyId = await discoverActivePtyId(orcaPage)
// Generate scrollback content with long wrapping lines
await execInTerminal(
orcaPage,
ptyId,
[
'python3 -c "',
'import string, sys',
'chars = string.ascii_letters + string.digits',
'for i in range(300):',
" line = f\\'LINE{i:04d} \\' + (chars * 3)[:200]",
' print(line)',
'sys.stdout.flush()',
'"'
].join('\n')
)
await waitForTerminalOutput(orcaPage, 'LINE0299', 15_000)
await orcaPage.waitForTimeout(500)
// Start the Ink TUI simulator (redraws on SIGWINCH like Claude Code)
const fixturePath = path.resolve('tests/e2e/fixtures/ink-tui-sim.mjs')
await execInTerminal(orcaPage, ptyId, `node ${fixturePath} &`)
await orcaPage.waitForTimeout(1000)
// Scroll to ~middle of scrollback
const scrollBefore = await orcaPage.evaluate(() => {
const store = window.__store
if (!store) {
return null
}
const state = store.getState()
const wId = state.activeWorktreeId
if (!wId) {
return null
}
const tabId = (state.tabsByWorktree[wId] ?? [])[0]?.id ?? null
if (!tabId) {
return null
}
const manager = window.__paneManagers?.get(tabId)
if (!manager) {
return null
}
const pane = manager.getActivePane()
if (!pane) {
return null
}
const buf = pane.terminal.buffer.active
const targetLine = Math.floor(buf.baseY / 2)
pane.terminal.scrollToLine(targetLine)
return {
viewportY: pane.terminal.buffer.active.viewportY,
baseY: buf.baseY,
paneId: pane.id,
cols: pane.terminal.cols
}
})
expect(scrollBefore).not.toBeNull()
expect(scrollBefore!.viewportY).toBeGreaterThan(0)
await orcaPage.waitForTimeout(200)
// Drag the divider — each step triggers fit() → onResize. The PTY resize
// is suppressed during drag but flushed at drag end, sending SIGWINCH to
// the Ink TUI simulator which does cursor-up + clear + rewrite.
const divider = orcaPage.locator('.pane-divider.is-vertical').first()
await expect(divider).toBeVisible({ timeout: 3_000 })
const box = await divider.boundingBox()
expect(box).not.toBeNull()
const startX = box!.x + box!.width / 2
const startY = box!.y + box!.height / 2
await orcaPage.mouse.move(startX, startY)
await orcaPage.mouse.down()
await orcaPage.mouse.move(startX + 150, startY, { steps: 20 })
await orcaPage.mouse.up()
// Wait for the SIGWINCH settling period (500ms) + buffer
await orcaPage.waitForTimeout(1000)
const scrollAfter = await orcaPage.evaluate(
({ paneId }) => {
const store = window.__store
if (!store) {
return null
}
const state = store.getState()
const wId = state.activeWorktreeId
if (!wId) {
return null
}
const tabId = (state.tabsByWorktree[wId] ?? [])[0]?.id ?? null
if (!tabId) {
return null
}
const manager = window.__paneManagers?.get(tabId)
if (!manager) {
return null
}
const panes = manager.getPanes() ?? []
const pane = panes.find((p: { id: number }) => p.id === paneId)
if (!pane) {
return null
}
return {
viewportY: pane.terminal.buffer.active.viewportY,
baseY: pane.terminal.buffer.active.baseY,
cols: pane.terminal.cols
}
},
{ paneId: scrollBefore!.paneId }
)
expect(scrollAfter).not.toBeNull()
// Kill the background TUI simulator
await execInTerminal(orcaPage, ptyId, 'kill %1 2>/dev/null; true')
await orcaPage.waitForTimeout(300)
const ratioBefore = scrollBefore!.viewportY / scrollBefore!.baseY
const ratioAfter = scrollAfter!.baseY > 0 ? scrollAfter!.viewportY / scrollAfter!.baseY : 0
const proportionalDrift = Math.abs(ratioAfter - ratioBefore)
console.log(
'[scroll-test] ink-tui-sim:',
JSON.stringify({
before: scrollBefore!.viewportY,
after: scrollAfter!.viewportY,
ratioBefore: ratioBefore.toFixed(4),
ratioAfter: ratioAfter.toFixed(4),
proportionalDrift: proportionalDrift.toFixed(4),
baseYBefore: scrollBefore!.baseY,
baseYAfter: scrollAfter!.baseY,
colsBefore: scrollBefore!.cols,
colsAfter: scrollAfter!.cols
})
)
expect(proportionalDrift).toBeLessThanOrEqual(0.05)
expect(scrollAfter!.viewportY).toBeGreaterThan(scrollAfter!.baseY * 0.1)
})
/**
* User Prompt:
* - closing panes works