From 8512ca615d947bdaafa0b4d7bf6ffa048cb0ba52 Mon Sep 17 00:00:00 2001 From: Josh Knapp Date: Sun, 15 Mar 2026 07:00:09 -0700 Subject: [PATCH] Fix terminal scroll glitch and add auto-follow toggle button Prevent viewport jumping during Claude output by only re-enabling auto-follow on user-initiated scrolls (wheel events within 300ms), not on write-triggered xterm scroll events. Add a "Following/Paused" toggle button in the top-right corner of the terminal. Co-Authored-By: Claude Opus 4.6 (1M context) --- app/src/components/terminal/TerminalView.tsx | 73 +++++++++++++++----- 1 file changed, 56 insertions(+), 17 deletions(-) diff --git a/app/src/components/terminal/TerminalView.tsx b/app/src/components/terminal/TerminalView.tsx index c106737..c7c6650 100644 --- a/app/src/components/terminal/TerminalView.tsx +++ b/app/src/components/terminal/TerminalView.tsx @@ -35,10 +35,12 @@ export default function TerminalView({ sessionId, active }: Props) { const [detectedUrl, setDetectedUrl] = useState(null); const [imagePasteMsg, setImagePasteMsg] = useState(null); const [isAtBottom, setIsAtBottom] = useState(true); + const [isAutoFollow, setIsAutoFollow] = useState(true); const isAtBottomRef = useRef(true); // Tracks user intent to follow output — only set to false by explicit user // actions (mouse wheel up), not by xterm scroll events during writes. const autoFollowRef = useRef(true); + const lastUserScrollTimeRef = useRef(0); useEffect(() => { if (!containerRef.current) return; @@ -138,8 +140,10 @@ export default function TerminalView({ sessionId, active }: Props) { // Detect user-initiated scroll-up (mouse wheel) to pause auto-follow. // Captured during capture phase so it fires before xterm's own handler. const handleWheel = (e: WheelEvent) => { + lastUserScrollTimeRef.current = Date.now(); if (e.deltaY < 0) { autoFollowRef.current = false; + setIsAutoFollow(false); } }; containerRef.current.addEventListener("wheel", handleWheel, { capture: true, passive: true }); @@ -150,16 +154,24 @@ export default function TerminalView({ sessionId, active }: Props) { const scrollDisposable = term.onScroll(() => { const buf = term.buffer.active; const atBottom = buf.viewportY >= buf.baseY; + const prevAtBottom = isAtBottomRef.current; isAtBottomRef.current = atBottom; - // Re-enable auto-follow when viewport reaches the bottom - if (atBottom) { + + // Re-enable auto-follow only when USER scrolls to bottom (not write-triggered) + const isUserScroll = (Date.now() - lastUserScrollTimeRef.current) < 300; + if (atBottom && isUserScroll && !autoFollowRef.current) { autoFollowRef.current = true; + setIsAutoFollow(true); } - if (scrollStateRafId === null) { - scrollStateRafId = requestAnimationFrame(() => { - scrollStateRafId = null; - setIsAtBottom(isAtBottomRef.current); - }); + + // Only update React state when value changes + if (atBottom !== prevAtBottom) { + if (scrollStateRafId === null) { + scrollStateRafId = requestAnimationFrame(() => { + scrollStateRafId = null; + setIsAtBottom(isAtBottomRef.current); + }); + } } }); @@ -214,14 +226,12 @@ export default function TerminalView({ sessionId, active }: Props) { const outputPromise = onOutput(sessionId, (data) => { if (aborted) return; term.write(data, () => { - // Keep viewport pinned to bottom when user hasn't scrolled up. - // Uses autoFollowRef (user intent) rather than isAtBottomRef (viewport - // position) so that xterm desync during writes doesn't kill auto-follow, - // but an explicit user scroll-up does pause it. if (autoFollowRef.current) { term.scrollToBottom(); - isAtBottomRef.current = true; - setIsAtBottom(true); + if (!isAtBottomRef.current) { + isAtBottomRef.current = true; + setIsAtBottom(true); + } } }); detector.feed(data); @@ -265,11 +275,9 @@ export default function TerminalView({ sessionId, active }: Props) { resizeRafId = requestAnimationFrame(() => { resizeRafId = null; if (!containerRef.current || containerRef.current.offsetWidth === 0) return; - const wasAtBottom = isAtBottomRef.current; fitAddon.fit(); resize(sessionId, term.cols, term.rows); - // Maintain scroll position after resize reflow - if (wasAtBottom) { + if (autoFollowRef.current) { term.scrollToBottom(); } }); @@ -323,6 +331,9 @@ export default function TerminalView({ sessionId, active }: Props) { } } fitRef.current?.fit(); + if (autoFollowRef.current) { + term.scrollToBottom(); + } term.focus(); } else { // Release WebGL context for inactive terminals @@ -360,7 +371,7 @@ export default function TerminalView({ sessionId, active }: Props) { const term = termRef.current; if (term) { autoFollowRef.current = true; - // Re-fit first to fix viewport desync (same thing a resize does) + setIsAutoFollow(true); fitRef.current?.fit(); term.scrollToBottom(); isAtBottomRef.current = true; @@ -368,6 +379,21 @@ export default function TerminalView({ sessionId, active }: Props) { } }, []); + const handleToggleAutoFollow = useCallback(() => { + const next = !autoFollowRef.current; + autoFollowRef.current = next; + setIsAutoFollow(next); + if (next) { + const term = termRef.current; + if (term) { + fitRef.current?.fit(); + term.scrollToBottom(); + isAtBottomRef.current = true; + setIsAtBottom(true); + } + } + }, []); + return (
)} + {/* Auto-follow toggle - top right */} + + {/* Jump to Current - bottom right, when scrolled up */} {!isAtBottom && (