Skip to content
6 changes: 6 additions & 0 deletions .server-changes/deployment-logs-follow-scroll.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
area: webapp
type: feature
---

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.
100 changes: 100 additions & 0 deletions apps/webapp/app/hooks/useFollowScroll.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
import { useEffect, useLayoutEffect, useRef, useState, type RefObject } from "react";

const AT_BOTTOM_TOLERANCE_PX = 4;

type FollowState = {
follow: boolean;
pinnedScrollTop: number | null;
lastScrollTop: number;
lastClientHeight: number;
};

export function useFollowScroll(containerRef: RefObject<HTMLElement | null>, content: unknown) {
const [isAtBottom, setIsAtBottom] = useState(true);
const stateRef = useRef<FollowState>({
follow: true,
pinnedScrollTop: null,
lastScrollTop: 0,
lastClientHeight: 0,
});

useEffect(() => {
const container = containerRef.current;
if (!container) return;

const state = stateRef.current;
state.lastScrollTop = container.scrollTop;
state.lastClientHeight = container.clientHeight;

const onScroll = () => {
const { scrollTop, scrollHeight, clientHeight } = container;
const isEcho = scrollTop === state.pinnedScrollTop;
const movedUp = scrollTop < state.lastScrollTop && clientHeight <= state.lastClientHeight;
state.pinnedScrollTop = null;
state.lastScrollTop = scrollTop;
state.lastClientHeight = clientHeight;
if (isEcho) return;

state.follow = !movedUp && scrollHeight - scrollTop - clientHeight <= AT_BOTTOM_TOLERANCE_PX;
setIsAtBottom(state.follow);
};

const onWheel = (event: WheelEvent) => {
if (event.deltaY >= 0 || !canScroll(container)) return;
state.follow = false;
setIsAtBottom(false);
};

container.addEventListener("scroll", onScroll, { passive: true });
container.addEventListener("wheel", onWheel, { passive: true });
return () => {
container.removeEventListener("scroll", onScroll);
container.removeEventListener("wheel", onWheel);
};
}, [containerRef]);

useLayoutEffect(() => {
const container = containerRef.current;
if (container && stateRef.current.follow) pinToBottom(container, stateRef.current);
}, [containerRef, isAtBottom, content]);

useEffect(() => {
const container = containerRef.current;
if (!container) return;

const observer = new ResizeObserver(() => {
if (stateRef.current.follow) pinToBottom(container, stateRef.current);
});
observer.observe(container);
return () => observer.disconnect();
}, [containerRef]);

const scrollToBottom = () => {
const container = containerRef.current;
if (!container) return;
stateRef.current.follow = true;
setIsAtBottom(true);
pinToBottom(container, stateRef.current);
};

const scrollToTop = () => {
const container = containerRef.current;
if (!container || !canScroll(container)) return;
stateRef.current.follow = false;
setIsAtBottom(false);
container.scrollTop = 0;
};

return { isAtBottom, scrollToBottom, scrollToTop };
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

function pinToBottom(container: HTMLElement, state: FollowState) {
container.scrollTop = container.scrollHeight;
state.pinnedScrollTop = container.scrollTop;
state.lastScrollTop = container.scrollTop;
state.lastClientHeight = container.clientHeight;
}

function canScroll(container: HTMLElement) {
return container.scrollHeight - container.clientHeight > AT_BOTTOM_TOLERANCE_PX;
}
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ import {
ServerIcon,
} from "lucide-react";
import { ExitIcon } from "~/assets/icons/ExitIcon";
import { MoveToBottomIcon } from "~/assets/icons/MoveToBottomIcon";
import { MoveToTopIcon } from "~/assets/icons/MoveToTopIcon";
import { GitMetadata } from "~/components/GitMetadata";
import { VercelLink } from "~/components/integrations/VercelLink";
import { RuntimeIcon } from "~/components/RuntimeIcon";
Expand Down Expand Up @@ -51,6 +53,7 @@ import { v3DeploymentParams, v3DeploymentsPath, v3RunsPath } from "~/utils/pathB
import { capitalizeWord } from "~/utils/string";
import { UserTag } from "../_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.deployments/route";
import { useDeploymentLogs } from "~/hooks/useDeploymentLogs";
import { useFollowScroll } from "~/hooks/useFollowScroll";
import { type DeploymentLogEntry } from "~/components/runs/v3/deploymentLogsCache";
import { deploymentAgentPageContext } from "~/components/dashboard-agent/suggested-prompts";
import type { Handle } from "~/utils/handle";
Expand Down Expand Up @@ -307,6 +310,7 @@ export default function Page() {
<Property.Item>
<Property.Label>Logs</Property.Label>
<LogsDisplay
key={deployment.id}
logs={logs}
isStreaming={isStreaming}
streamError={streamError}
Expand Down Expand Up @@ -525,19 +529,13 @@ function LogsDisplay({
const [mouseOver, setMouseOver] = useState(false);
const [collapsed, setCollapsed] = useState(initialCollapsed);
const logsContainerRef = useRef<HTMLDivElement>(null);
const { isAtBottom, scrollToBottom, scrollToTop } = useFollowScroll(logsContainerRef, logs);

useEffect(() => {
// 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.
setCollapsed(initialCollapsed);
}, [initialCollapsed]);

// auto-scroll log container to bottom when new logs arrive
useEffect(() => {
if (logsContainerRef.current) {
logsContainerRef.current.scrollTop = logsContainerRef.current.scrollHeight;
}
}, [logs]);

const onCopyLogs = useCallback(
(event: React.MouseEvent<HTMLButtonElement>) => {
event.preventDefault();
Expand Down Expand Up @@ -584,6 +582,27 @@ function LogsDisplay({
</div>
{logs.length > 0 && (
<div className="flex items-center gap-3">
<TooltipProvider>
<Tooltip disableHoverableContent>
<TooltipTrigger
onClick={isAtBottom ? scrollToTop : scrollToBottom}
className={cn(
"transition-colors duration-100 focus-custom hover:cursor-pointer",
"text-text-dimmed hover:text-text-bright"
)}
>
{isAtBottom ? (
<MoveToTopIcon className="size-4" />
) : (
<MoveToBottomIcon className="size-4" />
)}
</TooltipTrigger>
<TooltipContent side="left" className="text-xs">
{isAtBottom ? "Scroll to top" : "Scroll to bottom"}
</TooltipContent>
</Tooltip>
</TooltipProvider>

<TooltipProvider>
<Tooltip open={copied || mouseOver} disableHoverableContent>
<TooltipTrigger
Expand Down