Compare commits

...

3 Commits

Author SHA1 Message Date
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
d34e8e2c6d Fix jump-to-bottom button broken during active terminal output
All checks were successful
Build App / compute-version (push) Successful in 6s
Build App / build-macos (push) Successful in 2m21s
Build App / build-windows (push) Successful in 3m4s
Build App / build-linux (push) Successful in 5m31s
Build App / create-tag (push) Successful in 3s
Build App / sync-to-github (push) Successful in 11s
The shouldFollow flag was captured before term.write() but the callback
ran asynchronously — if the user scrolled up in between, the stale flag
forced the viewport back to bottom, preventing the button from appearing.

Check isAtBottomRef at callback time instead so user scroll-up is respected.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-14 21:35:58 -07:00
3935104cb5 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>
2026-03-13 12:59:19 -07:00

View File

@@ -35,6 +35,10 @@ export default function TerminalView({ sessionId, active }: Props) {
const [detectedUrl, setDetectedUrl] = useState<string | null>(null);
const [imagePasteMsg, setImagePasteMsg] = useState<string | null>(null);
const [isAtBottom, setIsAtBottom] = 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);
useEffect(() => {
if (!containerRef.current) return;
@@ -131,10 +135,32 @@ export default function TerminalView({ sessionId, active }: Props) {
sendInput(sessionId, data);
});
// Track scroll position to show "Jump to Current" button
// 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) => {
if (e.deltaY < 0) {
autoFollowRef.current = false;
}
};
containerRef.current.addEventListener("wheel", handleWheel, { capture: true, passive: true });
// 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 buf = term.buffer.active;
setIsAtBottom(buf.viewportY >= buf.baseY);
const atBottom = buf.viewportY >= buf.baseY;
isAtBottomRef.current = atBottom;
// Re-enable auto-follow when viewport reaches the bottom
if (atBottom) {
autoFollowRef.current = true;
}
if (scrollStateRafId === null) {
scrollStateRafId = requestAnimationFrame(() => {
scrollStateRafId = null;
setIsAtBottom(isAtBottomRef.current);
});
}
});
// Track text selection to show copy hint in status bar
@@ -187,7 +213,17 @@ export default function TerminalView({ sessionId, active }: Props) {
const outputPromise = onOutput(sessionId, (data) => {
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) {
term.scrollToBottom();
isAtBottomRef.current = true;
setIsAtBottom(true);
}
});
detector.feed(data);
// Scan for SSO refresh marker in terminal output
@@ -229,8 +265,13 @@ 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) {
term.scrollToBottom();
}
});
});
resizeObserver.observe(containerRef.current);
@@ -246,9 +287,11 @@ export default function TerminalView({ sessionId, active }: Props) {
scrollDisposable.dispose();
selectionDisposable.dispose();
setTerminalHasSelection(false);
containerRef.current?.removeEventListener("wheel", handleWheel, { capture: true });
containerRef.current?.removeEventListener("paste", handlePaste, { capture: true });
outputPromise.then((fn) => fn?.());
exitPromise.then((fn) => fn?.());
if (scrollStateRafId !== null) cancelAnimationFrame(scrollStateRafId);
if (resizeRafId !== null) cancelAnimationFrame(resizeRafId);
resizeObserver.disconnect();
try { webglRef.current?.dispose(); } catch { /* may already be disposed */ }
@@ -314,8 +357,15 @@ export default function TerminalView({ sessionId, active }: Props) {
}, [detectedUrl]);
const handleScrollToBottom = useCallback(() => {
termRef.current?.scrollToBottom();
setIsAtBottom(true);
const term = termRef.current;
if (term) {
autoFollowRef.current = true;
// Re-fit first to fix viewport desync (same thing a resize does)
fitRef.current?.fit();
term.scrollToBottom();
isAtBottomRef.current = true;
setIsAtBottom(true);
}
}, []);
return (