import { BoltIcon, BoltSlashIcon } from "@heroicons/react/20/solid"; import { type LoaderFunctionArgs } from "@remix-run/server-runtime"; import { type SSEStreamPart, SSEStreamSubscription } from "@trigger.dev/core/v3"; import { useVirtualizer } from "@tanstack/react-virtual"; import { Clipboard, ClipboardCheck } from "lucide-react"; import { useCallback, useEffect, useRef, useState } from "react"; import simplur from "simplur"; import { ListBulletIcon } from "~/assets/icons/ListBulletIcon"; import { MoveToBottomIcon } from "~/assets/icons/MoveToBottomIcon"; import { MoveToTopIcon } from "~/assets/icons/MoveToTopIcon"; import { SnakedArrowIcon } from "~/assets/icons/SnakedArrowIcon"; import { Paragraph } from "~/components/primitives/Paragraph"; import { Spinner } from "~/components/primitives/Spinner"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger, } from "~/components/primitives/Tooltip"; import { $replica } from "~/db.server"; import { useEnvironment } from "~/hooks/useEnvironment"; import { useOrganization } from "~/hooks/useOrganizations"; import { useProject } from "~/hooks/useProject"; import { getRequestAbortSignal } from "~/services/httpAsyncStorage.server"; import { getRealtimeStreamInstance } from "~/services/realtime/v1StreamsGlobal.server"; import { requireUserId } from "~/services/session.server"; import { cn } from "~/utils/cn"; import { v3RunStreamParamsSchema } from "~/utils/pathBuilder"; import { runStore } from "~/v3/runStore.server"; import { controlPlaneResolver } from "~/v3/runOpsMigration/controlPlaneResolver.server"; type ViewMode = "list" | "compact"; export type StreamChunk = { id: string; data: unknown; timestamp: number; }; export const loader = async ({ request, params }: LoaderFunctionArgs) => { const userId = await requireUserId(request); const { projectParam, organizationSlug, envParam, runParam, streamKey } = v3RunStreamParamsSchema.parse(params); const project = await $replica.project.findFirst({ where: { slug: projectParam, organization: { slug: organizationSlug, members: { some: { userId, }, }, }, }, }); if (!project) { throw new Response("Not Found", { status: 404 }); } const run = await runStore.findRun( { friendlyId: runParam, projectId: project.id }, { select: { id: true, friendlyId: true, realtimeStreamsVersion: true, streamBasinName: true, runtimeEnvironmentId: true, }, } ); if (!run) { throw new Response("Not Found", { status: 404 }); } const environment = await controlPlaneResolver.resolveAuthenticatedEnv(run.runtimeEnvironmentId); if (!environment || environment.slug !== envParam) { throw new Response("Not Found", { status: 404 }); } // Get Last-Event-ID header for resuming from a specific position const lastEventId = request.headers.get("Last-Event-ID") || undefined; const realtimeStream = getRealtimeStreamInstance(environment, run.realtimeStreamsVersion, { run: { streamBasinName: run.streamBasinName }, }); return realtimeStream.streamResponse( request, run.friendlyId, streamKey, getRequestAbortSignal(), { lastEventId, } ); }; export function RealtimeStreamViewer({ runId, streamKey, metadata, displayName, resourcePath: resourcePathOverride, headerLabel, headerLeft, headerRight, hideViewModeToggle = false, headerClassName, }: { runId?: string; streamKey?: string; metadata?: Record | undefined; displayName?: string; /** Pre-built resource path. When provided, `runId`/`streamKey` are unused. */ resourcePath?: string; /** Override the "Stream:" / "Input stream:" prefix in the header. */ headerLabel?: string; /** * Replaces the default "Stream: " content next to the connection * icon. Use to inline tabs or other navigation in place of a static * label. */ headerLeft?: React.ReactNode; /** * Extra content appended after the built-in chunks/view-mode/copy * controls on the right side of the header. Use to inline a mode * toggle or other actions alongside the stream controls. */ headerRight?: React.ReactNode; /** Hide the "Flow as text" / "View as list" view-mode toggle button. */ hideViewModeToggle?: boolean; /** Extra classes applied to the header bar (overrides default styling via tailwind-merge). */ headerClassName?: string; }) { const organization = useOrganization(); const project = useProject(); const environment = useEnvironment(); const resourcePath = resourcePathOverride ?? `/resources/orgs/${organization.slug}/projects/${project.slug}/env/${environment.slug}/runs/${runId}/streams/${streamKey}`; const startIndex = typeof metadata?.startIndex === "number" ? metadata.startIndex : undefined; const { chunks, error, isConnected } = useRealtimeStream(resourcePath, startIndex); const scrollRef = useRef(null); const bottomRef = useRef(null); const [isAtBottom, setIsAtBottom] = useState(true); const [viewMode, setViewMode] = useState("list"); const [mouseOver, setMouseOver] = useState(false); const [copied, setCopied] = useState(false); const getCompactText = useCallback(() => { return chunks .map((chunk) => { if (typeof chunk.data === "string") { return chunk.data; } return JSON.stringify(chunk.data); }) .join(""); }, [chunks]); const onCopied = useCallback( (event: React.MouseEvent) => { event.preventDefault(); event.stopPropagation(); navigator.clipboard.writeText(getCompactText()); setCopied(true); setTimeout(() => { setCopied(false); }, 1500); }, [getCompactText] ); // Use IntersectionObserver to detect when the bottom element is visible useEffect(() => { const bottomElement = bottomRef.current; const scrollElement = scrollRef.current; if (!bottomElement || !scrollElement) return; const observer = new IntersectionObserver( (entries) => { const entry = entries[0]; if (entry) { setIsAtBottom(entry.isIntersecting); } }, { root: scrollElement, threshold: 0.1, rootMargin: "0px", } ); observer.observe(bottomElement); // Also add a scroll listener as a backup to ensure state updates let scrollTimeout: ReturnType | null = null; const handleScroll = () => { if (!scrollElement || !bottomElement) return; if (scrollTimeout) { clearTimeout(scrollTimeout); } // Debounce the state update to avoid interrupting smooth scroll scrollTimeout = setTimeout(() => { const scrollBottom = scrollElement.scrollTop + scrollElement.clientHeight; const isNearBottom = scrollElement.scrollHeight - scrollBottom < 50; setIsAtBottom(isNearBottom); }, 100); }; scrollElement.addEventListener("scroll", handleScroll); // Check initial state const scrollBottom = scrollElement.scrollTop + scrollElement.clientHeight; const isNearBottom = scrollElement.scrollHeight - scrollBottom < 50; setIsAtBottom(isNearBottom); return () => { observer.disconnect(); scrollElement.removeEventListener("scroll", handleScroll); if (scrollTimeout) { clearTimeout(scrollTimeout); } }; }, [chunks.length, viewMode]); // Auto-scroll to bottom when new chunks arrive, if we're at the bottom useEffect(() => { if (isAtBottom && scrollRef.current) { // Preserve horizontal scroll position while scrolling to bottom vertically const currentScrollLeft = scrollRef.current.scrollLeft; scrollRef.current.scrollTop = scrollRef.current.scrollHeight; scrollRef.current.scrollLeft = currentScrollLeft; } }, [chunks, isAtBottom]); const firstLineNumber = startIndex ?? 0; const lastLineNumber = firstLineNumber + chunks.length - 1; const maxLineNumberWidth = (chunks.length > 0 ? lastLineNumber : firstLineNumber).toString() .length; // Virtual rendering for list view const rowVirtualizer = useVirtualizer({ count: chunks.length, getScrollElement: () => scrollRef.current, estimateSize: () => 28, overscan: 5, }); return (
{/* Header */}
{isConnected ? ( ) : ( )} {isConnected ? "Connected" : "Disconnected"} {headerLeft ?? ( {headerLabel ?? (displayName ? "Input stream:" : "Stream:")} {displayName ?? streamKey ?? ""} )}
{simplur`${chunks.length} chunk[|s]`}
{!hideViewModeToggle && ( setViewMode(viewMode === "list" ? "compact" : "list")} className={cn( "text-text-dimmed transition-colors focus-custom", chunks.length === 0 ? "cursor-not-allowed opacity-50" : "hover:cursor-pointer hover:text-text-bright" )} > {viewMode === "list" ? ( ) : ( )} {viewMode === "list" ? "Flow as text" : "View as list"} )} setMouseOver(true)} onMouseLeave={() => setMouseOver(false)} className={cn( "transition-colors duration-100 focus-custom", chunks.length === 0 ? "cursor-not-allowed opacity-50" : copied ? "text-success hover:cursor-pointer" : "text-text-dimmed hover:cursor-pointer hover:text-text-bright" )} > {copied ? ( ) : ( )} {copied ? "Copied" : "Copy"} { if (isAtBottom) { scrollRef.current?.scrollTo({ top: 0, behavior: "smooth" }); } else { bottomRef.current?.scrollIntoView({ behavior: "smooth", block: "end" }); } }} className={cn( "text-text-dimmed transition-colors focus-custom", chunks.length === 0 ? "cursor-not-allowed opacity-50" : "hover:cursor-pointer hover:text-text-bright" )} > {isAtBottom ? ( ) : ( )} {isAtBottom ? "Scroll to top" : "Scroll to bottom"}
{headerRight}
{/* Content */}
{error && (
Error: {error.message}
)} {chunks.length === 0 && !error && (
{isConnected ? (
Waiting for data…
) : ( No data received )}
)} {chunks.length > 0 && viewMode === "list" && (
{rowVirtualizer.getVirtualItems().map((virtualItem) => ( ))} {/* Sentinel element for IntersectionObserver */}
)} {chunks.length > 0 && viewMode === "compact" && (
{/* Sentinel element for IntersectionObserver */}
)}
); } function CompactStreamView({ chunks }: { chunks: StreamChunk[] }) { const compactText = chunks .map((chunk) => { if (typeof chunk.data === "string") { return chunk.data; } return JSON.stringify(chunk.data); }) .join(""); return
{compactText}
; } function StreamChunkLine({ chunk, lineNumber, maxLineNumberWidth, size, start, }: { chunk: StreamChunk; lineNumber: number; maxLineNumberWidth: number; size: number; start: number; }) { const formattedData = typeof chunk.data === "string" ? chunk.data : JSON.stringify(chunk.data, null, 2); const date = new Date(chunk.timestamp); const timeString = date.toLocaleTimeString("en-US", { hour12: false, hour: "2-digit", minute: "2-digit", second: "2-digit", }); const milliseconds = date.getMilliseconds().toString().padStart(3, "0"); const timestamp = `${timeString}.${milliseconds}`; return (
{/* Line number */}
{lineNumber}
{/* Timestamp */}
{timestamp}
{/* Content */}
{formattedData}
); } export function useRealtimeStream(resourcePath: string, startIndex?: number) { const [chunks, setChunks] = useState([]); const [error, setError] = useState(null); const [isConnected, setIsConnected] = useState(false); useEffect(() => { setChunks([]); setError(null); const abortController = new AbortController(); let reader: ReadableStreamDefaultReader> | null = null; async function connectAndConsume() { try { const sseSubscription = new SSEStreamSubscription(resourcePath, { signal: abortController.signal, lastEventId: startIndex ? (startIndex - 1).toString() : undefined, timeoutInSeconds: 30, }); const stream = await sseSubscription.subscribe(); setIsConnected(true); reader = stream.getReader(); while (true) { const { done, value } = await reader.read(); if (done) { break; } if (value !== undefined) { setChunks((prev) => [ ...prev, { id: value.id, data: value.chunk, timestamp: value.timestamp, }, ]); } } } catch (err) { // Only set error if not aborted if (!abortController.signal.aborted) { setError(err instanceof Error ? err : new Error(String(err))); } } finally { setIsConnected(false); } } connectAndConsume(); return () => { abortController.abort(); reader?.cancel(); }; }, [resourcePath, startIndex]); return { chunks, error, isConnected }; }