Compare commits

..

3 Commits

Author SHA1 Message Date
b55de8d75e Fix Jump to Current button not appearing on scroll-up
All checks were successful
Build App / compute-version (push) Successful in 3s
Build App / build-macos (push) Successful in 2m23s
Build App / build-windows (push) Successful in 2m35s
Build App / build-linux (push) Successful in 5m9s
Build App / create-tag (push) Successful in 7s
Build App / sync-to-github (push) Successful in 12s
The onScroll RAF optimization (only fire when atBottom changes) prevented
the button from showing because xterm's onScroll may not fire from wheel
events. Fix by setting isAtBottom(false) directly in the wheel handler
and removing the RAF guard to always schedule state updates.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 10:28:28 -07:00
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
ebae39026f Fix terminal auto-scroll and jump-to-bottom button coexistence
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 2m33s
Build App / build-linux (push) Successful in 6m3s
Build App / create-tag (push) Successful in 3s
Build App / sync-to-github (push) Successful in 10s
The previous fix checked isAtBottomRef inside the write callback, but
xterm's own scroll events during write processing could set the ref to
false (viewport desync), breaking auto-follow entirely.

Introduce a separate autoFollowRef that tracks user intent:
- Set to false only by explicit mouse wheel scroll-up (capture phase)
- Set to true when viewport reaches bottom or user clicks the button
- Write callback uses autoFollowRef so desync doesn't kill auto-follow
  but user scroll-up correctly pauses it

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-14 22:00:16 -07:00

View File

@@ -35,7 +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
// actions (mouse wheel up), not by xterm scroll events during writes.
const autoFollowRef = useRef(true);
const lastUserScrollTimeRef = useRef(0);
useEffect(() => { useEffect(() => {
if (!containerRef.current) return; if (!containerRef.current) return;
@@ -132,6 +137,19 @@ export default function TerminalView({ sessionId, active }: Props) {
sendInput(sessionId, data); sendInput(sessionId, data);
}); });
// 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);
isAtBottomRef.current = false;
setIsAtBottom(false);
}
};
containerRef.current.addEventListener("wheel", handleWheel, { capture: true, passive: true });
// 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. // Debounce state updates via rAF to avoid excessive re-renders during rapid output.
let scrollStateRafId: number | null = null; let scrollStateRafId: number | null = null;
@@ -139,6 +157,14 @@ export default function TerminalView({ sessionId, active }: Props) {
const buf = term.buffer.active; const buf = term.buffer.active;
const atBottom = buf.viewportY >= buf.baseY; const atBottom = buf.viewportY >= buf.baseY;
isAtBottomRef.current = atBottom; isAtBottomRef.current = 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) { if (scrollStateRafId === null) {
scrollStateRafId = requestAnimationFrame(() => { scrollStateRafId = requestAnimationFrame(() => {
scrollStateRafId = null; scrollStateRafId = null;
@@ -198,11 +224,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. if (autoFollowRef.current) {
// Check ref at callback time (not capture time) so that a user
// scroll-up between the write() call and callback is respected.
if (isAtBottomRef.current) {
term.scrollToBottom(); term.scrollToBottom();
if (!isAtBottomRef.current) {
isAtBottomRef.current = true;
setIsAtBottom(true);
}
} }
}); });
detector.feed(data); detector.feed(data);
@@ -246,11 +273,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();
} }
}); });
@@ -268,6 +293,7 @@ export default function TerminalView({ sessionId, active }: Props) {
scrollDisposable.dispose(); scrollDisposable.dispose();
selectionDisposable.dispose(); selectionDisposable.dispose();
setTerminalHasSelection(false); setTerminalHasSelection(false);
containerRef.current?.removeEventListener("wheel", handleWheel, { capture: true });
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?.());
@@ -303,6 +329,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
@@ -339,7 +368,8 @@ export default function TerminalView({ sessionId, active }: Props) {
const handleScrollToBottom = useCallback(() => { const handleScrollToBottom = useCallback(() => {
const term = termRef.current; const term = termRef.current;
if (term) { if (term) {
// Re-fit first to fix viewport desync (same thing a resize does) autoFollowRef.current = true;
setIsAutoFollow(true);
fitRef.current?.fit(); fitRef.current?.fit();
term.scrollToBottom(); term.scrollToBottom();
isAtBottomRef.current = true; isAtBottomRef.current = true;
@@ -347,6 +377,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}
@@ -367,6 +412,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}