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
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>
This commit is contained in:
@@ -36,6 +36,9 @@ export default function TerminalView({ sessionId, active }: Props) {
|
||||
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;
|
||||
@@ -132,6 +135,15 @@ export default function TerminalView({ sessionId, active }: Props) {
|
||||
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) => {
|
||||
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;
|
||||
@@ -139,6 +151,10 @@ export default function TerminalView({ sessionId, active }: Props) {
|
||||
const buf = term.buffer.active;
|
||||
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;
|
||||
@@ -199,10 +215,13 @@ export default function TerminalView({ sessionId, active }: Props) {
|
||||
if (aborted) return;
|
||||
term.write(data, () => {
|
||||
// Keep viewport pinned to bottom when user hasn't scrolled up.
|
||||
// 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) {
|
||||
// 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);
|
||||
@@ -268,6 +287,7 @@ 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?.());
|
||||
@@ -339,6 +359,7 @@ export default function TerminalView({ sessionId, active }: Props) {
|
||||
const handleScrollToBottom = useCallback(() => {
|
||||
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();
|
||||
|
||||
Reference in New Issue
Block a user