Skip to content

Commit 58b4ecc

Browse files
committed
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.
1 parent ee29393 commit 58b4ecc

1 file changed

Lines changed: 65 additions & 5 deletions

File tree

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

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

Lines changed: 65 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { useLocation } from "@remix-run/react";
22
import { type LoaderFunctionArgs } from "@remix-run/server-runtime";
33
import { typedjson, useTypedLoaderData } from "remix-typedjson";
4-
import { useEffect, useState, useRef, useCallback } from "react";
4+
import { useEffect, useLayoutEffect, useState, useRef, useCallback } from "react";
55
import {
66
Clipboard,
77
ClipboardCheck,
@@ -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";
@@ -176,6 +178,7 @@ function getTriggeredViaDisplay(triggeredVia: string | null | undefined): {
176178
}
177179

178180
const EXTERNAL_ID_DISPLAY_LENGTH = 24;
181+
const AT_BOTTOM_TOLERANCE_PX = 16;
179182

180183
function ExternalIdValue({ externalId }: { externalId: string | null }) {
181184
if (!externalId) {
@@ -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}
@@ -524,19 +528,54 @@ function LogsDisplay({
524528
const [copied, setCopied] = useState(false);
525529
const [mouseOver, setMouseOver] = useState(false);
526530
const [collapsed, setCollapsed] = useState(initialCollapsed);
531+
const [isAtBottom, setIsAtBottom] = useState(true);
527532
const logsContainerRef = useRef<HTMLDivElement>(null);
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
535539
useEffect(() => {
536-
if (logsContainerRef.current) {
537-
logsContainerRef.current.scrollTop = logsContainerRef.current.scrollHeight;
540+
const container = logsContainerRef.current;
541+
if (!container) return;
542+
543+
const updateIsAtBottom = () => {
544+
const distance = container.scrollHeight - container.scrollTop - container.clientHeight;
545+
setIsAtBottom(distance <= AT_BOTTOM_TOLERANCE_PX);
546+
};
547+
548+
container.addEventListener("scroll", updateIsAtBottom, { passive: true });
549+
return () => container.removeEventListener("scroll", updateIsAtBottom);
550+
}, []);
551+
552+
useLayoutEffect(() => {
553+
const container = logsContainerRef.current;
554+
if (!isAtBottom || !container) return;
555+
container.scrollTop = container.scrollHeight;
556+
}, [logs, isAtBottom]);
557+
558+
useEffect(() => {
559+
const container = logsContainerRef.current;
560+
if (!isAtBottom || !container) return;
561+
562+
const observer = new ResizeObserver(() => {
563+
container.scrollTop = container.scrollHeight;
564+
});
565+
observer.observe(container);
566+
return () => observer.disconnect();
567+
}, [isAtBottom]);
568+
569+
const onToggleScroll = useCallback(() => {
570+
const container = logsContainerRef.current;
571+
if (!container) return;
572+
if (isAtBottom) {
573+
setIsAtBottom(false);
574+
container.scrollTo({ top: 0, behavior: "smooth" });
575+
} else {
576+
container.scrollTop = container.scrollHeight;
538577
}
539-
}, [logs]);
578+
}, [isAtBottom]);
540579

541580
const onCopyLogs = useCallback(
542581
(event: React.MouseEvent<HTMLButtonElement>) => {
@@ -584,6 +623,27 @@ function LogsDisplay({
584623
</div>
585624
{logs.length > 0 && (
586625
<div className="flex items-center gap-3">
626+
<TooltipProvider>
627+
<Tooltip disableHoverableContent>
628+
<TooltipTrigger
629+
onClick={onToggleScroll}
630+
className={cn(
631+
"transition-colors duration-100 focus-custom hover:cursor-pointer",
632+
"text-text-dimmed hover:text-text-bright"
633+
)}
634+
>
635+
{isAtBottom ? (
636+
<MoveToTopIcon className="size-4" />
637+
) : (
638+
<MoveToBottomIcon className="size-4" />
639+
)}
640+
</TooltipTrigger>
641+
<TooltipContent side="left" className="text-xs">
642+
{isAtBottom ? "Scroll to top" : "Scroll to bottom"}
643+
</TooltipContent>
644+
</Tooltip>
645+
</TooltipProvider>
646+
587647
<TooltipProvider>
588648
<Tooltip open={copied || mouseOver} disableHoverableContent>
589649
<TooltipTrigger

0 commit comments

Comments
 (0)