11import { type SSEStreamPart , SSEStreamSubscription } from "@trigger.dev/core/v3" ;
2- import { BoltIcon , BoltSlashIcon } from "@heroicons/react/20/solid" ;
2+ import {
3+ BoltIcon ,
4+ BoltSlashIcon ,
5+ ListBulletIcon ,
6+ Bars3BottomLeftIcon ,
7+ } from "@heroicons/react/20/solid" ;
8+ import { Clipboard , ClipboardCheck } from "lucide-react" ;
39import { type LoaderFunctionArgs } from "@remix-run/server-runtime" ;
4- import { useEffect , useRef , useState } from "react" ;
10+ import { useCallback , useEffect , useRef , useState } from "react" ;
511import { Paragraph } from "~/components/primitives/Paragraph" ;
12+ import {
13+ Tooltip ,
14+ TooltipContent ,
15+ TooltipProvider ,
16+ TooltipTrigger ,
17+ } from "~/components/primitives/Tooltip" ;
618import { $replica } from "~/db.server" ;
719import { useEnvironment } from "~/hooks/useEnvironment" ;
820import { useOrganization } from "~/hooks/useOrganizations" ;
@@ -12,6 +24,8 @@ import { requireUserId } from "~/services/session.server";
1224import { cn } from "~/utils/cn" ;
1325import { v3RunStreamParamsSchema } from "~/utils/pathBuilder" ;
1426
27+ type ViewMode = "list" | "compact" ;
28+
1529type StreamChunk = {
1630 id : string ;
1731 data : unknown ;
@@ -98,6 +112,33 @@ export function RealtimeStreamViewer({
98112 const scrollRef = useRef < HTMLDivElement > ( null ) ;
99113 const bottomRef = useRef < HTMLDivElement > ( null ) ;
100114 const [ isAtBottom , setIsAtBottom ] = useState ( true ) ;
115+ const [ viewMode , setViewMode ] = useState < ViewMode > ( "list" ) ;
116+ const [ mouseOver , setMouseOver ] = useState ( false ) ;
117+ const [ copied , setCopied ] = useState ( false ) ;
118+
119+ const getCompactText = useCallback ( ( ) => {
120+ return chunks
121+ . map ( ( chunk ) => {
122+ if ( typeof chunk . data === "string" ) {
123+ return chunk . data ;
124+ }
125+ return JSON . stringify ( chunk . data ) ;
126+ } )
127+ . join ( "" ) ;
128+ } , [ chunks ] ) ;
129+
130+ const onCopied = useCallback (
131+ ( event : React . MouseEvent < HTMLButtonElement > ) => {
132+ event . preventDefault ( ) ;
133+ event . stopPropagation ( ) ;
134+ navigator . clipboard . writeText ( getCompactText ( ) ) ;
135+ setCopied ( true ) ;
136+ setTimeout ( ( ) => {
137+ setCopied ( false ) ;
138+ } , 1500 ) ;
139+ } ,
140+ [ getCompactText ]
141+ ) ;
101142
102143 // Use IntersectionObserver to detect when the bottom element is visible
103144 useEffect ( ( ) => {
@@ -143,29 +184,77 @@ export function RealtimeStreamViewer({
143184 < Paragraph variant = "small/bright" className = "mb-0" >
144185 Stream: < span className = "font-mono text-text-dimmed" > { streamKey } </ span >
145186 </ Paragraph >
146- < div className = "flex items-center gap-1.5" >
147- < div className = "flex items-center gap-1" >
148- { isConnected ? (
149- < BoltIcon className = { cn ( "size-3.5 animate-pulse text-success" ) } />
150- ) : (
151- < BoltSlashIcon className = { cn ( "size-3.5 text-text-dimmed" ) } />
152- ) }
153- < Paragraph variant = "small" className = "mb-0" >
154- { isConnected ? "Connected" : "Disconnected" }
155- </ Paragraph >
187+ < div className = "flex items-center gap-3" >
188+ < div className = "flex items-center gap-1.5" >
189+ < div className = "flex items-center gap-1" >
190+ { isConnected ? (
191+ < BoltIcon className = { cn ( "size-3.5 animate-pulse text-success" ) } />
192+ ) : (
193+ < BoltSlashIcon className = { cn ( "size-3.5 text-text-dimmed" ) } />
194+ ) }
195+ < Paragraph variant = "small" className = "mb-0" >
196+ { isConnected ? "Connected" : "Disconnected" }
197+ </ Paragraph >
198+ </ div >
199+ < div className = "size-1 rounded-full bg-text-dimmed/50" />
200+ < Paragraph variant = "small" className = "mb-0" >
201+ { chunks . length } { chunks . length === 1 ? "chunk" : "chunks" }
202+ </ Paragraph >
156203 </ div >
157- < div className = "size-1 rounded-full bg-text-dimmed/50" />
158- < Paragraph variant = "small" className = "mb-0" >
159- { chunks . length } { chunks . length === 1 ? "chunk" : "chunks" }
160- </ Paragraph >
204+ < TooltipProvider >
205+ < Tooltip disableHoverableContent >
206+ < TooltipTrigger
207+ onClick = { ( ) => setViewMode ( viewMode === "list" ? "compact" : "list" ) }
208+ className = "text-text-dimmed transition-colors focus-custom hover:cursor-pointer hover:text-text-bright"
209+ >
210+ { viewMode === "list" ? (
211+ < Bars3BottomLeftIcon className = "size-4" />
212+ ) : (
213+ < ListBulletIcon className = "size-4" />
214+ ) }
215+ </ TooltipTrigger >
216+ < TooltipContent side = "left" className = "text-xs" >
217+ { viewMode === "list" ? "Compact view" : "List view" }
218+ </ TooltipContent >
219+ </ Tooltip >
220+ </ TooltipProvider >
161221 </ div >
162222 </ div >
163223
164224 { /* Content */ }
165225 < div
166226 ref = { scrollRef }
167- className = "flex-1 overflow-y-auto bg-charcoal-900 scrollbar-thin scrollbar-track-transparent scrollbar-thumb-charcoal-600"
227+ className = "relative flex-1 overflow-y-auto bg-charcoal-900 scrollbar-thin scrollbar-track-transparent scrollbar-thumb-charcoal-600"
168228 >
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+
169258 { error && (
170259 < div className = "border-b border-error/20 bg-error/10 p-3" >
171260 < Paragraph variant = "small" className = "mb-0 text-error" >
@@ -182,7 +271,7 @@ export function RealtimeStreamViewer({
182271 </ div >
183272 ) }
184273
185- { chunks . length > 0 && (
274+ { chunks . length > 0 && viewMode === "list" && (
186275 < div className = "p-3 font-mono text-xs leading-relaxed" >
187276 { chunks . map ( ( chunk , index ) => (
188277 < StreamChunkLine
@@ -196,6 +285,14 @@ export function RealtimeStreamViewer({
196285 < div ref = { bottomRef } className = "h-px" />
197286 </ div >
198287 ) }
288+
289+ { chunks . length > 0 && viewMode === "compact" && (
290+ < div className = "p-3 font-mono text-xs leading-relaxed" >
291+ < CompactStreamView chunks = { chunks } />
292+ { /* Sentinel element for IntersectionObserver */ }
293+ < div ref = { bottomRef } className = "h-px" />
294+ </ div >
295+ ) }
199296 </ div >
200297
201298 { /* Footer with auto-scroll indicator */ }
@@ -215,6 +312,19 @@ export function RealtimeStreamViewer({
215312 ) ;
216313}
217314
315+ function CompactStreamView ( { chunks } : { chunks : StreamChunk [ ] } ) {
316+ const compactText = chunks
317+ . map ( ( chunk ) => {
318+ if ( typeof chunk . data === "string" ) {
319+ return chunk . data ;
320+ }
321+ return JSON . stringify ( chunk . data ) ;
322+ } )
323+ . join ( "" ) ;
324+
325+ return < div className = "whitespace-pre-wrap break-all text-text-bright" > { compactText } </ div > ;
326+ }
327+
218328function StreamChunkLine ( {
219329 chunk,
220330 lineNumber,
0 commit comments