1+ import { BoltIcon , BoltSlashIcon } from "@heroicons/react/20/solid" ;
2+ import { type LoaderFunctionArgs } from "@remix-run/server-runtime" ;
13import { type SSEStreamPart , SSEStreamSubscription } from "@trigger.dev/core/v3" ;
2- import {
3- BoltIcon ,
4- BoltSlashIcon ,
5- ListBulletIcon ,
6- Bars3BottomLeftIcon ,
7- } from "@heroicons/react/20/solid" ;
84import { Clipboard , ClipboardCheck } from "lucide-react" ;
9- import { type LoaderFunctionArgs } from "@remix-run/server-runtime" ;
105import { useCallback , useEffect , useRef , useState } from "react" ;
116import simplur from "simplur" ;
7+ import { ListBulletIcon } from "~/assets/icons/ListBulletIcon" ;
8+ import { MoveToBottomIcon } from "~/assets/icons/MoveToBottomIcon" ;
9+ import { MoveToTopIcon } from "~/assets/icons/MoveToTopIcon" ;
10+ import { SnakedArrowIcon } from "~/assets/icons/SnakedArrowIcon" ;
1211import { Paragraph } from "~/components/primitives/Paragraph" ;
1312import {
1413 Tooltip ,
@@ -144,7 +143,8 @@ export function RealtimeStreamViewer({
144143 // Use IntersectionObserver to detect when the bottom element is visible
145144 useEffect ( ( ) => {
146145 const bottomElement = bottomRef . current ;
147- if ( ! bottomElement ) return ;
146+ const scrollElement = scrollRef . current ;
147+ if ( ! bottomElement || ! scrollElement ) return ;
148148
149149 const observer = new IntersectionObserver (
150150 ( entries ) => {
@@ -154,17 +154,46 @@ export function RealtimeStreamViewer({
154154 }
155155 } ,
156156 {
157- root : scrollRef . current ,
157+ root : scrollElement ,
158158 threshold : 0.1 ,
159+ rootMargin : "0px" ,
159160 }
160161 ) ;
161162
162163 observer . observe ( bottomElement ) ;
163164
165+ // Also add a scroll listener as a backup to ensure state updates
166+ let scrollTimeout : ReturnType < typeof setTimeout > | null = null ;
167+ const handleScroll = ( ) => {
168+ if ( ! scrollElement || ! bottomElement ) return ;
169+
170+ // Clear any existing timeout
171+ if ( scrollTimeout ) {
172+ clearTimeout ( scrollTimeout ) ;
173+ }
174+
175+ // Debounce the state update to avoid interrupting smooth scroll
176+ scrollTimeout = setTimeout ( ( ) => {
177+ const scrollBottom = scrollElement . scrollTop + scrollElement . clientHeight ;
178+ const isNearBottom = scrollElement . scrollHeight - scrollBottom < 50 ;
179+ setIsAtBottom ( isNearBottom ) ;
180+ } , 100 ) ;
181+ } ;
182+
183+ scrollElement . addEventListener ( "scroll" , handleScroll ) ;
184+ // Check initial state
185+ const scrollBottom = scrollElement . scrollTop + scrollElement . clientHeight ;
186+ const isNearBottom = scrollElement . scrollHeight - scrollBottom < 50 ;
187+ setIsAtBottom ( isNearBottom ) ;
188+
164189 return ( ) => {
165190 observer . disconnect ( ) ;
191+ scrollElement . removeEventListener ( "scroll" , handleScroll ) ;
192+ if ( scrollTimeout ) {
193+ clearTimeout ( scrollTimeout ) ;
194+ }
166195 } ;
167- } , [ ] ) ;
196+ } , [ chunks . length ] ) ;
168197
169198 // Auto-scroll to bottom when new chunks arrive, if we're at the bottom
170199 useEffect ( ( ) => {
@@ -182,79 +211,103 @@ export function RealtimeStreamViewer({
182211 < div className = "flex h-full flex-col overflow-hidden border-t border-grid-bright" >
183212 { /* Header */ }
184213 < div className = "flex flex-wrap items-center justify-between gap-2 border-b border-grid-bright bg-background-bright px-3 py-3" >
185- < Paragraph variant = "small/bright" className = "mb-0" >
186- Stream: < span className = "font-mono text-text-dimmed" > { streamKey } </ span >
187- </ Paragraph >
188- < div className = "flex flex-wrap items-center gap-3" >
189- < div className = "flex flex-wrap items-center gap-2.5" >
190- < div className = "flex items-center gap-1" >
191- { isConnected ? (
192- < BoltIcon className = { cn ( "size-3.5 animate-pulse text-success" ) } />
193- ) : (
194- < BoltSlashIcon className = { cn ( "size-3.5 text-text-dimmed" ) } />
195- ) }
196- < Paragraph variant = "small" className = "mb-0" >
214+ < div className = "flex items-center gap-1.5" >
215+ < TooltipProvider >
216+ < Tooltip >
217+ < TooltipTrigger >
218+ { isConnected ? (
219+ < BoltIcon className = { cn ( "size-3.5 animate-pulse text-success" ) } />
220+ ) : (
221+ < BoltSlashIcon className = { cn ( "size-3.5 text-text-dimmed" ) } />
222+ ) }
223+ </ TooltipTrigger >
224+ < TooltipContent side = "top" className = "text-xs" >
197225 { isConnected ? "Connected" : "Disconnected" }
198- </ Paragraph >
199- </ div >
200- < Paragraph variant = "small" className = "mb-0" >
201- { simplur `${ chunks . length } chunk[|s]` }
202- </ Paragraph >
203- </ div >
226+ </ TooltipContent >
227+ </ Tooltip >
228+ </ TooltipProvider >
229+ < Paragraph variant = "small/bright" className = "mb-0" >
230+ Stream: < span className = "font-mono text-text-dimmed" > { streamKey } </ span >
231+ </ Paragraph >
232+ </ div >
233+ < div className = "flex flex-wrap items-center gap-3" >
234+ < Paragraph variant = "small" className = "mb-0" >
235+ { simplur `${ chunks . length } chunk[|s]` }
236+ </ Paragraph >
204237 < TooltipProvider >
205238 < Tooltip disableHoverableContent >
206239 < TooltipTrigger
207240 onClick = { ( ) => setViewMode ( viewMode === "list" ? "compact" : "list" ) }
208241 className = "text-text-dimmed transition-colors focus-custom hover:cursor-pointer hover:text-text-bright"
209242 >
210243 { viewMode === "list" ? (
211- < Bars3BottomLeftIcon className = "size-4" />
244+ < SnakedArrowIcon className = "size-4" />
212245 ) : (
213246 < ListBulletIcon className = "size-4" />
214247 ) }
215248 </ TooltipTrigger >
216249 < TooltipContent side = "left" className = "text-xs" >
217- { viewMode === "list" ? "Compact view " : "List view " }
250+ { viewMode === "list" ? "Flow as text " : "View as list " }
218251 </ TooltipContent >
219252 </ Tooltip >
220253 </ TooltipProvider >
254+ { chunks . length > 0 && (
255+ < TooltipProvider >
256+ < Tooltip open = { copied || mouseOver } disableHoverableContent >
257+ < TooltipTrigger
258+ onClick = { onCopied }
259+ onMouseEnter = { ( ) => setMouseOver ( true ) }
260+ onMouseLeave = { ( ) => setMouseOver ( false ) }
261+ className = { cn (
262+ "transition-colors duration-100 focus-custom hover:cursor-pointer" ,
263+ copied ? "text-success" : "text-text-dimmed hover:text-text-bright"
264+ ) }
265+ >
266+ { copied ? (
267+ < ClipboardCheck className = "size-4" />
268+ ) : (
269+ < Clipboard className = "size-4" />
270+ ) }
271+ </ TooltipTrigger >
272+ < TooltipContent side = "left" className = "text-xs" >
273+ { copied ? "Copied" : "Copy" }
274+ </ TooltipContent >
275+ </ Tooltip >
276+ </ TooltipProvider >
277+ ) }
278+ { chunks . length > 0 && (
279+ < TooltipProvider >
280+ < Tooltip disableHoverableContent >
281+ < TooltipTrigger
282+ onClick = { ( ) => {
283+ if ( isAtBottom ) {
284+ scrollRef . current ?. scrollTo ( { top : 0 , behavior : "smooth" } ) ;
285+ } else {
286+ bottomRef . current ?. scrollIntoView ( { behavior : "smooth" , block : "end" } ) ;
287+ }
288+ } }
289+ className = "text-text-dimmed transition-colors focus-custom hover:cursor-pointer hover:text-text-bright"
290+ >
291+ { isAtBottom ? (
292+ < MoveToTopIcon className = "size-4" />
293+ ) : (
294+ < MoveToBottomIcon className = "size-4" />
295+ ) }
296+ </ TooltipTrigger >
297+ < TooltipContent side = "left" className = "text-xs" >
298+ { isAtBottom ? "Scroll to top" : "Scroll to bottom" }
299+ </ TooltipContent >
300+ </ Tooltip >
301+ </ TooltipProvider >
302+ ) }
221303 </ div >
222304 </ div >
223305
224306 { /* Content */ }
225307 < div
226308 ref = { scrollRef }
227- className = "relative flex-1 overflow-y-auto bg-charcoal-900 scrollbar-thin scrollbar-track-transparent scrollbar-thumb-charcoal-600"
309+ className = "flex-1 overflow-y-auto bg-charcoal-900 scrollbar-thin scrollbar-track-transparent scrollbar-thumb-charcoal-600"
228310 >
229- { chunks . length > 0 && (
230- < div className = "pointer-events-none sticky top-2.5 z-50 h-0" >
231- < div className = "pointer-events-auto absolute right-3 top-0" >
232- < TooltipProvider >
233- < Tooltip open = { copied || mouseOver } disableHoverableContent >
234- < TooltipTrigger
235- onClick = { onCopied }
236- onMouseEnter = { ( ) => setMouseOver ( true ) }
237- onMouseLeave = { ( ) => setMouseOver ( false ) }
238- className = { cn (
239- "transition-colors duration-100 focus-custom hover:cursor-pointer" ,
240- copied ? "text-success" : "text-text-dimmed hover:text-text-bright"
241- ) }
242- >
243- { copied ? (
244- < ClipboardCheck className = "size-4" />
245- ) : (
246- < Clipboard className = "size-4" />
247- ) }
248- </ TooltipTrigger >
249- < TooltipContent side = "left" className = "text-xs" >
250- { copied ? "Copied" : "Copy" }
251- </ TooltipContent >
252- </ Tooltip >
253- </ TooltipProvider >
254- </ div >
255- </ div >
256- ) }
257-
258311 { error && (
259312 < div className = "border-b border-error/20 bg-error/10 p-3" >
260313 < Paragraph variant = "small" className = "mb-0 text-error" >
@@ -272,7 +325,7 @@ export function RealtimeStreamViewer({
272325 ) }
273326
274327 { chunks . length > 0 && viewMode === "list" && (
275- < div className = "p-3 font-mono text-xs leading-relaxed " >
328+ < div className = "font-mono text-xs leading-tight " >
276329 { chunks . map ( ( chunk , index ) => (
277330 < StreamChunkLine
278331 key = { index }
@@ -287,27 +340,13 @@ export function RealtimeStreamViewer({
287340 ) }
288341
289342 { chunks . length > 0 && viewMode === "compact" && (
290- < div className = "p-3 font-mono text-xs leading-relaxed" >
343+ < div className = "p-3 pt-0 font-mono text-xs leading-relaxed" >
291344 < CompactStreamView chunks = { chunks } />
292345 { /* Sentinel element for IntersectionObserver */ }
293346 < div ref = { bottomRef } className = "h-px" />
294347 </ div >
295348 ) }
296349 </ div >
297-
298- { /* Footer with auto-scroll indicator */ }
299- { ! isAtBottom && chunks . length > 0 && (
300- < div className = "border-t border-grid-bright bg-charcoal-850 px-3 py-2" >
301- < button
302- onClick = { ( ) => {
303- bottomRef . current ?. scrollIntoView ( { behavior : "smooth" , block : "end" } ) ;
304- } }
305- className = "text-xs text-blue-500 hover:text-blue-400"
306- >
307- ↓ Scroll to bottom
308- </ button >
309- </ div >
310- ) }
311350 </ div >
312351 ) ;
313352}
@@ -351,7 +390,7 @@ function StreamChunkLine({
351390 < div className = "group flex w-full gap-3 py-1 hover:bg-charcoal-800" >
352391 { /* Line number */ }
353392 < div
354- className = "flex-none select-none text-right text-charcoal-500"
393+ className = "flex-none select-none pl-2 text-right text-charcoal-500"
355394 style = { { width : `${ Math . max ( maxLineNumberWidth , 3 ) } ch` } }
356395 >
357396 { lineNumber }
0 commit comments