From ab0ed32543f0ad2f1bb6668aeadf263eb772f61b Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Sun, 12 Apr 2026 16:38:49 -0400 Subject: [PATCH] fix: preserve terminal scroll position when splitting panes (#543) --- .../src/lib/pane-manager/pane-manager.ts | 31 +++++++++++++++++-- 1 file changed, 28 insertions(+), 3 deletions(-) diff --git a/src/renderer/src/lib/pane-manager/pane-manager.ts b/src/renderer/src/lib/pane-manager/pane-manager.ts index 85ad0b7b086..1326dd5a721 100644 --- a/src/renderer/src/lib/pane-manager/pane-manager.ts +++ b/src/renderer/src/lib/pane-manager/pane-manager.ts @@ -97,8 +97,22 @@ export class PaneManager { const isVertical = direction === 'vertical' const divider = this.createDividerWrapped(isVertical) + // Why: wrapInSplit reparents the existing container via replaceChild + + // appendChild, which can cause the browser to reset scrollTop on xterm's + // viewport element to 0 during the next layout. Capture the scroll-at- + // bottom state now, before the DOM reparenting corrupts it. + const buf = existing.terminal.buffer.active + const wasAtBottom = buf.viewportY >= buf.baseY + wrapInSplit(existing.container, newPane.container, isVertical, divider, opts) + // Why: immediately restore the scroll position after DOM reparenting so + // that xterm's internal viewportY stays correct when the browser fires + // asynchronous scroll events during its layout phase. + if (wasAtBottom) { + existing.terminal.scrollToBottom() + } + // Open terminal for new pane openTerminal(newPane) @@ -111,14 +125,25 @@ export class PaneManager { newPane.terminal.focus() } - // Refit existing pane since it now shares space - safeFit(existing) - updateMultiPaneState(this.getDragCallbacks()) void this.options.onPaneCreated?.(this.toPublic(newPane)) this.options.onLayoutChanged?.() + // Why: belt-and-suspenders for the scroll position — the deferred + // fitPanes (from onLayoutChanged → queueResizeAll) reflows the buffer + // for the new column count, which changes baseY. If the browser's + // rendering pipeline fired a scroll event that reset viewportY between + // our synchronous scrollToBottom above and the rAF, safeFit's + // wasAtBottom check would read false and skip scrollToBottom. This + // final rAF runs after fitPanes (FIFO ordering) and unconditionally + // restores the scroll-to-bottom state. + if (wasAtBottom) { + requestAnimationFrame(() => { + existing.terminal.scrollToBottom() + }) + } + return this.toPublic(newPane) }