Compare commits

...

1 Commits

Author SHA1 Message Date
8512ca615d Fix terminal scroll glitch and add auto-follow toggle button
All checks were successful
Build App / compute-version (push) Successful in 4s
Build App / build-macos (push) Successful in 2m24s
Build App / build-windows (push) Successful in 3m4s
Build App / build-linux (push) Successful in 4m53s
Build App / create-tag (push) Successful in 9s
Build App / sync-to-github (push) Successful in 11s
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) <noreply@anthropic.com>
2026-03-15 07:00:09 -07:00

View File

@@ -35,10 +35,12 @@ 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 [isAutoFollow, setIsAutoFollow] = useState(true);
const isAtBottomRef = useRef(true); const isAtBottomRef = useRef(true);
// Tracks user intent to follow output — only set to false by explicit user // Tracks user intent to follow output — only set to false by explicit user
// actions (mouse wheel up), not by xterm scroll events during writes. // actions (mouse wheel up), not by xterm scroll events during writes.
const autoFollowRef = useRef(true); const autoFollowRef = useRef(true);
const lastUserScrollTimeRef = useRef(0);
useEffect(() => { useEffect(() => {
if (!containerRef.current) return; 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. // Detect user-initiated scroll-up (mouse wheel) to pause auto-follow.
// Captured during capture phase so it fires before xterm's own handler. // Captured during capture phase so it fires before xterm's own handler.
const handleWheel = (e: WheelEvent) => { const handleWheel = (e: WheelEvent) => {
lastUserScrollTimeRef.current = Date.now();
if (e.deltaY < 0) { if (e.deltaY < 0) {
autoFollowRef.current = false; autoFollowRef.current = false;
setIsAutoFollow(false);
} }
}; };
containerRef.current.addEventListener("wheel", handleWheel, { capture: true, passive: true }); 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 scrollDisposable = term.onScroll(() => {
const buf = term.buffer.active; const buf = term.buffer.active;
const atBottom = buf.viewportY >= buf.baseY; const atBottom = buf.viewportY >= buf.baseY;
const prevAtBottom = isAtBottomRef.current;
isAtBottomRef.current = atBottom; 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; autoFollowRef.current = true;
setIsAutoFollow(true);
} }
if (scrollStateRafId === null) {
scrollStateRafId = requestAnimationFrame(() => { // Only update React state when value changes
scrollStateRafId = null; if (atBottom !== prevAtBottom) {
setIsAtBottom(isAtBottomRef.current); 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) => { const outputPromise = onOutput(sessionId, (data) => {
if (aborted) return; if (aborted) return;
term.write(data, () => { 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) { if (autoFollowRef.current) {
term.scrollToBottom(); term.scrollToBottom();
isAtBottomRef.current = true; if (!isAtBottomRef.current) {
setIsAtBottom(true); isAtBottomRef.current = true;
setIsAtBottom(true);
}
} }
}); });
detector.feed(data); detector.feed(data);
@@ -265,11 +275,9 @@ 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 (autoFollowRef.current) {
if (wasAtBottom) {
term.scrollToBottom(); term.scrollToBottom();
} }
}); });
@@ -323,6 +331,9 @@ export default function TerminalView({ sessionId, active }: Props) {
} }
} }
fitRef.current?.fit(); fitRef.current?.fit();
if (autoFollowRef.current) {
term.scrollToBottom();
}
term.focus(); term.focus();
} else { } else {
// Release WebGL context for inactive terminals // Release WebGL context for inactive terminals
@@ -360,7 +371,7 @@ export default function TerminalView({ sessionId, active }: Props) {
const term = termRef.current; const term = termRef.current;
if (term) { if (term) {
autoFollowRef.current = true; autoFollowRef.current = true;
// Re-fit first to fix viewport desync (same thing a resize does) setIsAutoFollow(true);
fitRef.current?.fit(); fitRef.current?.fit();
term.scrollToBottom(); term.scrollToBottom();
isAtBottomRef.current = true; 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 ( return (
<div <div
ref={terminalContainerRef} ref={terminalContainerRef}
@@ -388,6 +414,19 @@ export default function TerminalView({ sessionId, active }: Props) {
{imagePasteMsg} {imagePasteMsg}
</div> </div>
)} )}
{/* Auto-follow toggle - top right */}
<button
onClick={handleToggleAutoFollow}
className={`absolute top-2 right-4 z-50 px-2 py-1 rounded text-[10px] font-medium border shadow-sm transition-colors cursor-pointer ${
isAutoFollow
? "bg-[#1a2332] text-[#3fb950] border-[#238636] hover:bg-[#1f2d3d]"
: "bg-[#1f2937] text-[#8b949e] border-[#30363d] hover:bg-[#2d3748]"
}`}
title={isAutoFollow ? "Auto-scrolling to latest output (click to pause)" : "Auto-scroll paused (click to resume)"}
>
{isAutoFollow ? "▼ Following" : "▽ Paused"}
</button>
{/* Jump to Current - bottom right, when scrolled up */}
{!isAtBottom && ( {!isAtBottom && (
<button <button
onClick={handleScrollToBottom} onClick={handleScrollToBottom}