Skip to content

Commit 6a6f0a4

Browse files
authored
feat(webapp): pause deployment log auto-scroll on scroll-up (#4776)
Auto-scroll now only follows while you are at the bottom. Scrolling up pauses it; scrolling back to the bottom, or clicking the new scroll-to-bottom button in the log header, resumes it. When you are at the bottom the same button scrolls to the top. Switching to another deployment starts at the bottom again.
1 parent 97d70b8 commit 6a6f0a4

3 files changed

Lines changed: 132 additions & 7 deletions

File tree

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
area: webapp
3+
type: feature
4+
---
5+
6+
Deployment logs no longer jump to the bottom while you are reading earlier output. Scroll up to pause auto-scroll, and scroll back down or use the new scroll-to-bottom button in the log header to resume following.
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
import { useEffect, useLayoutEffect, useRef, useState, type RefObject } from "react";
2+
3+
const AT_BOTTOM_TOLERANCE_PX = 4;
4+
5+
type FollowState = {
6+
follow: boolean;
7+
pinnedScrollTop: number | null;
8+
lastScrollTop: number;
9+
lastClientHeight: number;
10+
};
11+
12+
export function useFollowScroll(containerRef: RefObject<HTMLElement | null>, content: unknown) {
13+
const [isAtBottom, setIsAtBottom] = useState(true);
14+
const stateRef = useRef<FollowState>({
15+
follow: true,
16+
pinnedScrollTop: null,
17+
lastScrollTop: 0,
18+
lastClientHeight: 0,
19+
});
20+
21+
useEffect(() => {
22+
const container = containerRef.current;
23+
if (!container) return;
24+
25+
const state = stateRef.current;
26+
state.lastScrollTop = container.scrollTop;
27+
state.lastClientHeight = container.clientHeight;
28+
29+
const onScroll = () => {
30+
const { scrollTop, scrollHeight, clientHeight } = container;
31+
const isEcho = scrollTop === state.pinnedScrollTop;
32+
const movedUp = scrollTop < state.lastScrollTop && clientHeight <= state.lastClientHeight;
33+
state.pinnedScrollTop = null;
34+
state.lastScrollTop = scrollTop;
35+
state.lastClientHeight = clientHeight;
36+
if (isEcho) return;
37+
38+
state.follow = !movedUp && scrollHeight - scrollTop - clientHeight <= AT_BOTTOM_TOLERANCE_PX;
39+
setIsAtBottom(state.follow);
40+
};
41+
42+
const onWheel = (event: WheelEvent) => {
43+
if (event.deltaY >= 0 || !canScroll(container)) return;
44+
state.follow = false;
45+
setIsAtBottom(false);
46+
};
47+
48+
container.addEventListener("scroll", onScroll, { passive: true });
49+
container.addEventListener("wheel", onWheel, { passive: true });
50+
return () => {
51+
container.removeEventListener("scroll", onScroll);
52+
container.removeEventListener("wheel", onWheel);
53+
};
54+
}, [containerRef]);
55+
56+
useLayoutEffect(() => {
57+
const container = containerRef.current;
58+
if (container && stateRef.current.follow) pinToBottom(container, stateRef.current);
59+
}, [containerRef, isAtBottom, content]);
60+
61+
useEffect(() => {
62+
const container = containerRef.current;
63+
if (!container) return;
64+
65+
const observer = new ResizeObserver(() => {
66+
if (stateRef.current.follow) pinToBottom(container, stateRef.current);
67+
});
68+
observer.observe(container);
69+
return () => observer.disconnect();
70+
}, [containerRef]);
71+
72+
const scrollToBottom = () => {
73+
const container = containerRef.current;
74+
if (!container) return;
75+
stateRef.current.follow = true;
76+
setIsAtBottom(true);
77+
pinToBottom(container, stateRef.current);
78+
};
79+
80+
const scrollToTop = () => {
81+
const container = containerRef.current;
82+
if (!container || !canScroll(container)) return;
83+
stateRef.current.follow = false;
84+
setIsAtBottom(false);
85+
container.scrollTop = 0;
86+
};
87+
88+
return { isAtBottom, scrollToBottom, scrollToTop };
89+
}
90+
91+
function pinToBottom(container: HTMLElement, state: FollowState) {
92+
container.scrollTop = container.scrollHeight;
93+
state.pinnedScrollTop = container.scrollTop;
94+
state.lastScrollTop = container.scrollTop;
95+
state.lastClientHeight = container.clientHeight;
96+
}
97+
98+
function canScroll(container: HTMLElement) {
99+
return container.scrollHeight - container.clientHeight > AT_BOTTOM_TOLERANCE_PX;
100+
}

apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.deployments.$deploymentParam/route.tsx

Lines changed: 26 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@ import {
1313
ServerIcon,
1414
} from "lucide-react";
1515
import { ExitIcon } from "~/assets/icons/ExitIcon";
16+
import { MoveToBottomIcon } from "~/assets/icons/MoveToBottomIcon";
17+
import { MoveToTopIcon } from "~/assets/icons/MoveToTopIcon";
1618
import { GitMetadata } from "~/components/GitMetadata";
1719
import { VercelLink } from "~/components/integrations/VercelLink";
1820
import { RuntimeIcon } from "~/components/RuntimeIcon";
@@ -51,6 +53,7 @@ import { v3DeploymentParams, v3DeploymentsPath, v3RunsPath } from "~/utils/pathB
5153
import { capitalizeWord } from "~/utils/string";
5254
import { UserTag } from "../_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.deployments/route";
5355
import { useDeploymentLogs } from "~/hooks/useDeploymentLogs";
56+
import { useFollowScroll } from "~/hooks/useFollowScroll";
5457
import { type DeploymentLogEntry } from "~/components/runs/v3/deploymentLogsCache";
5558
import { deploymentAgentPageContext } from "~/components/dashboard-agent/suggested-prompts";
5659
import type { Handle } from "~/utils/handle";
@@ -307,6 +310,7 @@ export default function Page() {
307310
<Property.Item>
308311
<Property.Label>Logs</Property.Label>
309312
<LogsDisplay
313+
key={deployment.id}
310314
logs={logs}
311315
isStreaming={isStreaming}
312316
streamError={streamError}
@@ -525,19 +529,13 @@ function LogsDisplay({
525529
const [mouseOver, setMouseOver] = useState(false);
526530
const [collapsed, setCollapsed] = useState(initialCollapsed);
527531
const logsContainerRef = useRef<HTMLDivElement>(null);
532+
const { isAtBottom, scrollToBottom, scrollToTop } = useFollowScroll(logsContainerRef, logs);
528533

529534
useEffect(() => {
530535
// oxlint-disable-next-line react/set-state-in-effect, react/no-deriving-state-in-effects -- Deployment status changes intentionally reset the user-controlled collapse state.
531536
setCollapsed(initialCollapsed);
532537
}, [initialCollapsed]);
533538

534-
// auto-scroll log container to bottom when new logs arrive
535-
useEffect(() => {
536-
if (logsContainerRef.current) {
537-
logsContainerRef.current.scrollTop = logsContainerRef.current.scrollHeight;
538-
}
539-
}, [logs]);
540-
541539
const onCopyLogs = useCallback(
542540
(event: React.MouseEvent<HTMLButtonElement>) => {
543541
event.preventDefault();
@@ -584,6 +582,27 @@ function LogsDisplay({
584582
</div>
585583
{logs.length > 0 && (
586584
<div className="flex items-center gap-3">
585+
<TooltipProvider>
586+
<Tooltip disableHoverableContent>
587+
<TooltipTrigger
588+
onClick={isAtBottom ? scrollToTop : scrollToBottom}
589+
className={cn(
590+
"transition-colors duration-100 focus-custom hover:cursor-pointer",
591+
"text-text-dimmed hover:text-text-bright"
592+
)}
593+
>
594+
{isAtBottom ? (
595+
<MoveToTopIcon className="size-4" />
596+
) : (
597+
<MoveToBottomIcon className="size-4" />
598+
)}
599+
</TooltipTrigger>
600+
<TooltipContent side="left" className="text-xs">
601+
{isAtBottom ? "Scroll to top" : "Scroll to bottom"}
602+
</TooltipContent>
603+
</Tooltip>
604+
</TooltipProvider>
605+
587606
<TooltipProvider>
588607
<Tooltip open={copied || mouseOver} disableHoverableContent>
589608
<TooltipTrigger

0 commit comments

Comments
 (0)