Fix xterm scroll jump and viewport desync during long output
All checks were successful
Build App / compute-version (push) Successful in 3s
Build App / build-macos (push) Successful in 2m24s
Build App / build-windows (push) Successful in 3m30s
Build App / build-linux (push) Successful in 4m47s
Build App / create-tag (push) Successful in 3s
Build App / sync-to-github (push) Successful in 10s

Auto-scroll viewport on new output when user is at bottom, debounce
scroll state updates to reduce re-renders, preserve scroll position
across resize reflows, and fix "Jump to Current" button by re-fitting
the terminal to clear viewport desync.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-13 12:59:19 -07:00
parent b17c759bd6
commit 3935104cb5

View File

@@ -35,6 +35,7 @@ export default function TerminalView({ sessionId, active }: Props) {
const [detectedUrl, setDetectedUrl] = useState<string | null>(null); const [detectedUrl, setDetectedUrl] = useState<string | null>(null);
const [imagePasteMsg, setImagePasteMsg] = useState<string | null>(null); const [imagePasteMsg, setImagePasteMsg] = useState<string | null>(null);
const [isAtBottom, setIsAtBottom] = useState(true); const [isAtBottom, setIsAtBottom] = useState(true);
const isAtBottomRef = useRef(true);
useEffect(() => { useEffect(() => {
if (!containerRef.current) return; if (!containerRef.current) return;
@@ -131,10 +132,19 @@ export default function TerminalView({ sessionId, active }: Props) {
sendInput(sessionId, data); sendInput(sessionId, data);
}); });
// Track scroll position to show "Jump to Current" button // Track scroll position to show "Jump to Current" button.
// Debounce state updates via rAF to avoid excessive re-renders during rapid output.
let scrollStateRafId: number | null = null;
const scrollDisposable = term.onScroll(() => { const scrollDisposable = term.onScroll(() => {
const buf = term.buffer.active; const buf = term.buffer.active;
setIsAtBottom(buf.viewportY >= buf.baseY); const atBottom = buf.viewportY >= buf.baseY;
isAtBottomRef.current = atBottom;
if (scrollStateRafId === null) {
scrollStateRafId = requestAnimationFrame(() => {
scrollStateRafId = null;
setIsAtBottom(isAtBottomRef.current);
});
}
}); });
// Track text selection to show copy hint in status bar // Track text selection to show copy hint in status bar
@@ -187,7 +197,15 @@ export default function TerminalView({ sessionId, active }: Props) {
const outputPromise = onOutput(sessionId, (data) => { const outputPromise = onOutput(sessionId, (data) => {
if (aborted) return; if (aborted) return;
term.write(data); const shouldFollow = isAtBottomRef.current;
term.write(data, () => {
// Keep viewport pinned to bottom when user hasn't scrolled up
if (shouldFollow) {
term.scrollToBottom();
isAtBottomRef.current = true;
setIsAtBottom(true);
}
});
detector.feed(data); detector.feed(data);
// Scan for SSO refresh marker in terminal output // Scan for SSO refresh marker in terminal output
@@ -229,8 +247,13 @@ export default function TerminalView({ sessionId, active }: Props) {
resizeRafId = requestAnimationFrame(() => { resizeRafId = requestAnimationFrame(() => {
resizeRafId = null; resizeRafId = null;
if (!containerRef.current || containerRef.current.offsetWidth === 0) return; if (!containerRef.current || containerRef.current.offsetWidth === 0) return;
const wasAtBottom = isAtBottomRef.current;
fitAddon.fit(); fitAddon.fit();
resize(sessionId, term.cols, term.rows); resize(sessionId, term.cols, term.rows);
// Maintain scroll position after resize reflow
if (wasAtBottom) {
term.scrollToBottom();
}
}); });
}); });
resizeObserver.observe(containerRef.current); resizeObserver.observe(containerRef.current);
@@ -249,6 +272,7 @@ export default function TerminalView({ sessionId, active }: Props) {
containerRef.current?.removeEventListener("paste", handlePaste, { capture: true }); containerRef.current?.removeEventListener("paste", handlePaste, { capture: true });
outputPromise.then((fn) => fn?.()); outputPromise.then((fn) => fn?.());
exitPromise.then((fn) => fn?.()); exitPromise.then((fn) => fn?.());
if (scrollStateRafId !== null) cancelAnimationFrame(scrollStateRafId);
if (resizeRafId !== null) cancelAnimationFrame(resizeRafId); if (resizeRafId !== null) cancelAnimationFrame(resizeRafId);
resizeObserver.disconnect(); resizeObserver.disconnect();
try { webglRef.current?.dispose(); } catch { /* may already be disposed */ } try { webglRef.current?.dispose(); } catch { /* may already be disposed */ }
@@ -314,8 +338,14 @@ export default function TerminalView({ sessionId, active }: Props) {
}, [detectedUrl]); }, [detectedUrl]);
const handleScrollToBottom = useCallback(() => { const handleScrollToBottom = useCallback(() => {
termRef.current?.scrollToBottom(); const term = termRef.current;
setIsAtBottom(true); if (term) {
// Re-fit first to fix viewport desync (same thing a resize does)
fitRef.current?.fit();
term.scrollToBottom();
isAtBottomRef.current = true;
setIsAtBottom(true);
}
}, []); }, []);
return ( return (