From 58b4ecc65fbf27c50fd1daa5133bbfb9dc194a69 Mon Sep 17 00:00:00 2001 From: Saadi Myftija Date: Tue, 25 Aug 2026 16:05:48 +0200 Subject: [PATCH 1/7] feat(webapp): pause deployment log auto-scroll when scrolled up The deployment log viewer jumped to the bottom on every new line, which made it impossible to read earlier output while a build was streaming. Auto-scroll now only follows while the view is at the bottom; scrolling up pauses it, and scrolling back down (or using the new scroll-to-bottom button in the log header) resumes it. Switching deployments resets the viewer to follow the new deployment's logs. --- .../route.tsx | 70 +++++++++++++++++-- 1 file changed, 65 insertions(+), 5 deletions(-) diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.deployments.$deploymentParam/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.deployments.$deploymentParam/route.tsx index bfa7e5918fb..cb9c0810d6a 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.deployments.$deploymentParam/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.deployments.$deploymentParam/route.tsx @@ -1,7 +1,7 @@ import { useLocation } from "@remix-run/react"; import { type LoaderFunctionArgs } from "@remix-run/server-runtime"; import { typedjson, useTypedLoaderData } from "remix-typedjson"; -import { useEffect, useState, useRef, useCallback } from "react"; +import { useEffect, useLayoutEffect, useState, useRef, useCallback } from "react"; import { Clipboard, ClipboardCheck, @@ -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"; @@ -176,6 +178,7 @@ function getTriggeredViaDisplay(triggeredVia: string | null | undefined): { } const EXTERNAL_ID_DISPLAY_LENGTH = 24; +const AT_BOTTOM_TOLERANCE_PX = 16; function ExternalIdValue({ externalId }: { externalId: string | null }) { if (!externalId) { @@ -307,6 +310,7 @@ export default function Page() { Logs (null); useEffect(() => { @@ -531,12 +536,46 @@ function LogsDisplay({ setCollapsed(initialCollapsed); }, [initialCollapsed]); - // auto-scroll log container to bottom when new logs arrive useEffect(() => { - if (logsContainerRef.current) { - logsContainerRef.current.scrollTop = logsContainerRef.current.scrollHeight; + const container = logsContainerRef.current; + if (!container) return; + + const updateIsAtBottom = () => { + const distance = container.scrollHeight - container.scrollTop - container.clientHeight; + setIsAtBottom(distance <= AT_BOTTOM_TOLERANCE_PX); + }; + + container.addEventListener("scroll", updateIsAtBottom, { passive: true }); + return () => container.removeEventListener("scroll", updateIsAtBottom); + }, []); + + useLayoutEffect(() => { + const container = logsContainerRef.current; + if (!isAtBottom || !container) return; + container.scrollTop = container.scrollHeight; + }, [logs, isAtBottom]); + + useEffect(() => { + const container = logsContainerRef.current; + if (!isAtBottom || !container) return; + + const observer = new ResizeObserver(() => { + container.scrollTop = container.scrollHeight; + }); + observer.observe(container); + return () => observer.disconnect(); + }, [isAtBottom]); + + const onToggleScroll = useCallback(() => { + const container = logsContainerRef.current; + if (!container) return; + if (isAtBottom) { + setIsAtBottom(false); + container.scrollTo({ top: 0, behavior: "smooth" }); + } else { + container.scrollTop = container.scrollHeight; } - }, [logs]); + }, [isAtBottom]); const onCopyLogs = useCallback( (event: React.MouseEvent) => { @@ -584,6 +623,27 @@ function LogsDisplay({ {logs.length > 0 && (
+ + + + {isAtBottom ? ( + + ) : ( + + )} + + + {isAtBottom ? "Scroll to top" : "Scroll to bottom"} + + + + Date: Tue, 25 Aug 2026 16:10:53 +0200 Subject: [PATCH 2/7] chore(webapp): add server-changes note for deployment log follow scrolling --- .server-changes/deployment-logs-follow-scroll.md | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 .server-changes/deployment-logs-follow-scroll.md diff --git a/.server-changes/deployment-logs-follow-scroll.md b/.server-changes/deployment-logs-follow-scroll.md new file mode 100644 index 00000000000..d5fcf23ce8d --- /dev/null +++ b/.server-changes/deployment-logs-follow-scroll.md @@ -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. From 9078452303a2e4c4619032c104a9a787206b7ff8 Mon Sep 17 00:00:00 2001 From: Saadi Myftija Date: Tue, 25 Aug 2026 16:15:13 +0200 Subject: [PATCH 3/7] fix(webapp): keep deployment log follow state in sync when the log cannot scroll Scroll to top is a no-op when the log fits its container and emitted no scroll event, leaving auto-scroll switched off. The toggle now only scrolls up when there is overflow and sets the follow state explicitly when jumping to the bottom. --- .../route.tsx | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.deployments.$deploymentParam/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.deployments.$deploymentParam/route.tsx index cb9c0810d6a..75aedd94026 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.deployments.$deploymentParam/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.deployments.$deploymentParam/route.tsx @@ -566,16 +566,18 @@ function LogsDisplay({ return () => observer.disconnect(); }, [isAtBottom]); - const onToggleScroll = useCallback(() => { + const onToggleScroll = () => { const container = logsContainerRef.current; if (!container) return; - if (isAtBottom) { - setIsAtBottom(false); - container.scrollTo({ top: 0, behavior: "smooth" }); - } else { + if (!isAtBottom) { + setIsAtBottom(true); container.scrollTop = container.scrollHeight; + return; } - }, [isAtBottom]); + if (container.scrollHeight - container.clientHeight <= AT_BOTTOM_TOLERANCE_PX) return; + setIsAtBottom(false); + container.scrollTo({ top: 0, behavior: "smooth" }); + }; const onCopyLogs = useCallback( (event: React.MouseEvent) => { From e4822ad173184fc2b3ada7d86fd0bf16cfe74d60 Mon Sep 17 00:00:00 2001 From: Saadi Myftija Date: Tue, 25 Aug 2026 16:23:30 +0200 Subject: [PATCH 4/7] fix(webapp): scroll deployment logs to top instantly so follow mode stays paused A smooth scroll eases in, so its first scroll event can still read as at-bottom and re-enable auto-scroll, which snaps the view back down. --- .../route.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.deployments.$deploymentParam/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.deployments.$deploymentParam/route.tsx index 75aedd94026..8e1c6f8163f 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.deployments.$deploymentParam/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.deployments.$deploymentParam/route.tsx @@ -576,7 +576,7 @@ function LogsDisplay({ } if (container.scrollHeight - container.clientHeight <= AT_BOTTOM_TOLERANCE_PX) return; setIsAtBottom(false); - container.scrollTo({ top: 0, behavior: "smooth" }); + container.scrollTop = 0; }; const onCopyLogs = useCallback( From 4f97cb6c8772b84e64befa19de90db73886df89e Mon Sep 17 00:00:00 2001 From: Saadi Myftija Date: Tue, 25 Aug 2026 16:27:04 +0200 Subject: [PATCH 5/7] fix(webapp): pause deployment log follow on any upward scroll intent Scrolling up by a few pixels stayed inside the at-bottom band, so a log batch arriving mid-gesture snapped the view back to the bottom and cancelled the scroll. Any upward wheel or scroll movement now pauses following immediately, and following resumes only once the view is back at the bottom. The scroll tracking moves into a useFollowScroll hook. --- apps/webapp/app/hooks/useFollowScroll.ts | 70 +++++++++++++++++++ .../route.tsx | 51 ++------------ 2 files changed, 74 insertions(+), 47 deletions(-) create mode 100644 apps/webapp/app/hooks/useFollowScroll.ts diff --git a/apps/webapp/app/hooks/useFollowScroll.ts b/apps/webapp/app/hooks/useFollowScroll.ts new file mode 100644 index 00000000000..a8f8b176229 --- /dev/null +++ b/apps/webapp/app/hooks/useFollowScroll.ts @@ -0,0 +1,70 @@ +import { useEffect, useLayoutEffect, useState, type RefObject } from "react"; + +const AT_BOTTOM_TOLERANCE_PX = 4; + +export function useFollowScroll(containerRef: RefObject, content: unknown) { + const [isAtBottom, setIsAtBottom] = useState(true); + + useEffect(() => { + const container = containerRef.current; + if (!container) return; + + let lastScrollTop = container.scrollTop; + + const onScroll = () => { + const { scrollTop, scrollHeight, clientHeight } = container; + const distance = scrollHeight - scrollTop - clientHeight; + const movedUp = scrollTop < lastScrollTop && distance > 1; + lastScrollTop = scrollTop; + setIsAtBottom(!movedUp && distance <= AT_BOTTOM_TOLERANCE_PX); + }; + + const onWheel = (event: WheelEvent) => { + if (event.deltaY < 0 && canScroll(container)) 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 (!isAtBottom || !container) return; + container.scrollTop = container.scrollHeight; + }, [containerRef, isAtBottom, content]); + + useEffect(() => { + const container = containerRef.current; + if (!isAtBottom || !container) return; + + const observer = new ResizeObserver(() => { + container.scrollTop = container.scrollHeight; + }); + observer.observe(container); + return () => observer.disconnect(); + }, [containerRef, isAtBottom]); + + const scrollToBottom = () => { + const container = containerRef.current; + if (!container) return; + setIsAtBottom(true); + container.scrollTop = container.scrollHeight; + }; + + const scrollToTop = () => { + const container = containerRef.current; + if (!container || !canScroll(container)) return; + setIsAtBottom(false); + container.scrollTop = 0; + }; + + return { isAtBottom, scrollToBottom, scrollToTop }; +} + +function canScroll(container: HTMLElement) { + return container.scrollHeight - container.clientHeight > AT_BOTTOM_TOLERANCE_PX; +} diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.deployments.$deploymentParam/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.deployments.$deploymentParam/route.tsx index 8e1c6f8163f..a89752eba13 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.deployments.$deploymentParam/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.deployments.$deploymentParam/route.tsx @@ -1,7 +1,7 @@ import { useLocation } from "@remix-run/react"; import { type LoaderFunctionArgs } from "@remix-run/server-runtime"; import { typedjson, useTypedLoaderData } from "remix-typedjson"; -import { useEffect, useLayoutEffect, useState, useRef, useCallback } from "react"; +import { useEffect, useState, useRef, useCallback } from "react"; import { Clipboard, ClipboardCheck, @@ -53,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"; @@ -178,7 +179,6 @@ function getTriggeredViaDisplay(triggeredVia: string | null | undefined): { } const EXTERNAL_ID_DISPLAY_LENGTH = 24; -const AT_BOTTOM_TOLERANCE_PX = 16; function ExternalIdValue({ externalId }: { externalId: string | null }) { if (!externalId) { @@ -528,57 +528,14 @@ function LogsDisplay({ const [copied, setCopied] = useState(false); const [mouseOver, setMouseOver] = useState(false); const [collapsed, setCollapsed] = useState(initialCollapsed); - const [isAtBottom, setIsAtBottom] = useState(true); const logsContainerRef = useRef(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]); - useEffect(() => { - const container = logsContainerRef.current; - if (!container) return; - - const updateIsAtBottom = () => { - const distance = container.scrollHeight - container.scrollTop - container.clientHeight; - setIsAtBottom(distance <= AT_BOTTOM_TOLERANCE_PX); - }; - - container.addEventListener("scroll", updateIsAtBottom, { passive: true }); - return () => container.removeEventListener("scroll", updateIsAtBottom); - }, []); - - useLayoutEffect(() => { - const container = logsContainerRef.current; - if (!isAtBottom || !container) return; - container.scrollTop = container.scrollHeight; - }, [logs, isAtBottom]); - - useEffect(() => { - const container = logsContainerRef.current; - if (!isAtBottom || !container) return; - - const observer = new ResizeObserver(() => { - container.scrollTop = container.scrollHeight; - }); - observer.observe(container); - return () => observer.disconnect(); - }, [isAtBottom]); - - const onToggleScroll = () => { - const container = logsContainerRef.current; - if (!container) return; - if (!isAtBottom) { - setIsAtBottom(true); - container.scrollTop = container.scrollHeight; - return; - } - if (container.scrollHeight - container.clientHeight <= AT_BOTTOM_TOLERANCE_PX) return; - setIsAtBottom(false); - container.scrollTop = 0; - }; - const onCopyLogs = useCallback( (event: React.MouseEvent) => { event.preventDefault(); @@ -628,7 +585,7 @@ function LogsDisplay({ Date: Tue, 25 Aug 2026 16:35:06 +0200 Subject: [PATCH 6/7] fix(webapp): treat a one pixel upward log scroll as leaving the bottom --- apps/webapp/app/hooks/useFollowScroll.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/webapp/app/hooks/useFollowScroll.ts b/apps/webapp/app/hooks/useFollowScroll.ts index a8f8b176229..c35bde42d85 100644 --- a/apps/webapp/app/hooks/useFollowScroll.ts +++ b/apps/webapp/app/hooks/useFollowScroll.ts @@ -14,7 +14,7 @@ export function useFollowScroll(containerRef: RefObject, con const onScroll = () => { const { scrollTop, scrollHeight, clientHeight } = container; const distance = scrollHeight - scrollTop - clientHeight; - const movedUp = scrollTop < lastScrollTop && distance > 1; + const movedUp = scrollTop < lastScrollTop && distance >= 1; lastScrollTop = scrollTop; setIsAtBottom(!movedUp && distance <= AT_BOTTOM_TOLERANCE_PX); }; From d71e5a438989d5dd32d102251e19bd7c54f84e6f Mon Sep 17 00:00:00 2001 From: Saadi Myftija Date: Tue, 25 Aug 2026 16:39:41 +0200 Subject: [PATCH 7/7] fix(webapp): ignore the scroll event echoed by pinning deployment logs to the bottom Pinning the log view fires a scroll event a frame later. If the user scrolled up in that frame the echo read as a downward move and re-enabled following, snapping the view back. The hook now records the position it set and skips the matching event, seeds the last position on every pin, detects the browser clamping scrollTop on expand by the height change instead of a distance guard, and keeps the follow intent in a ref so pins act on the latest input rather than committed state. --- apps/webapp/app/hooks/useFollowScroll.ts | 56 ++++++++++++++++++------ 1 file changed, 43 insertions(+), 13 deletions(-) diff --git a/apps/webapp/app/hooks/useFollowScroll.ts b/apps/webapp/app/hooks/useFollowScroll.ts index c35bde42d85..4189f0377a7 100644 --- a/apps/webapp/app/hooks/useFollowScroll.ts +++ b/apps/webapp/app/hooks/useFollowScroll.ts @@ -1,26 +1,48 @@ -import { useEffect, useLayoutEffect, useState, type RefObject } from "react"; +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, content: unknown) { const [isAtBottom, setIsAtBottom] = useState(true); + const stateRef = useRef({ + follow: true, + pinnedScrollTop: null, + lastScrollTop: 0, + lastClientHeight: 0, + }); useEffect(() => { const container = containerRef.current; if (!container) return; - let lastScrollTop = container.scrollTop; + const state = stateRef.current; + state.lastScrollTop = container.scrollTop; + state.lastClientHeight = container.clientHeight; const onScroll = () => { const { scrollTop, scrollHeight, clientHeight } = container; - const distance = scrollHeight - scrollTop - clientHeight; - const movedUp = scrollTop < lastScrollTop && distance >= 1; - lastScrollTop = scrollTop; - setIsAtBottom(!movedUp && distance <= AT_BOTTOM_TOLERANCE_PX); + 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)) setIsAtBottom(false); + if (event.deltaY >= 0 || !canScroll(container)) return; + state.follow = false; + setIsAtBottom(false); }; container.addEventListener("scroll", onScroll, { passive: true }); @@ -33,31 +55,32 @@ export function useFollowScroll(containerRef: RefObject, con useLayoutEffect(() => { const container = containerRef.current; - if (!isAtBottom || !container) return; - container.scrollTop = container.scrollHeight; + if (container && stateRef.current.follow) pinToBottom(container, stateRef.current); }, [containerRef, isAtBottom, content]); useEffect(() => { const container = containerRef.current; - if (!isAtBottom || !container) return; + if (!container) return; const observer = new ResizeObserver(() => { - container.scrollTop = container.scrollHeight; + if (stateRef.current.follow) pinToBottom(container, stateRef.current); }); observer.observe(container); return () => observer.disconnect(); - }, [containerRef, isAtBottom]); + }, [containerRef]); const scrollToBottom = () => { const container = containerRef.current; if (!container) return; + stateRef.current.follow = true; setIsAtBottom(true); - container.scrollTop = container.scrollHeight; + pinToBottom(container, stateRef.current); }; const scrollToTop = () => { const container = containerRef.current; if (!container || !canScroll(container)) return; + stateRef.current.follow = false; setIsAtBottom(false); container.scrollTop = 0; }; @@ -65,6 +88,13 @@ export function useFollowScroll(containerRef: RefObject, con return { isAtBottom, scrollToBottom, scrollToTop }; } +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; }