11import { useLocation } from "@remix-run/react" ;
22import { type LoaderFunctionArgs } from "@remix-run/server-runtime" ;
33import { typedjson , useTypedLoaderData } from "remix-typedjson" ;
4- import { useEffect , useState , useRef , useCallback } from "react" ;
4+ import { useEffect , useLayoutEffect , useState , useRef , useCallback } from "react" ;
55import {
66 Clipboard ,
77 ClipboardCheck ,
@@ -13,6 +13,8 @@ import {
1313 ServerIcon ,
1414} from "lucide-react" ;
1515import { ExitIcon } from "~/assets/icons/ExitIcon" ;
16+ import { MoveToBottomIcon } from "~/assets/icons/MoveToBottomIcon" ;
17+ import { MoveToTopIcon } from "~/assets/icons/MoveToTopIcon" ;
1618import { GitMetadata } from "~/components/GitMetadata" ;
1719import { VercelLink } from "~/components/integrations/VercelLink" ;
1820import { RuntimeIcon } from "~/components/RuntimeIcon" ;
@@ -176,6 +178,7 @@ function getTriggeredViaDisplay(triggeredVia: string | null | undefined): {
176178}
177179
178180const EXTERNAL_ID_DISPLAY_LENGTH = 24 ;
181+ const AT_BOTTOM_TOLERANCE_PX = 16 ;
179182
180183function 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