Skip to content

Commit 8cd881a

Browse files
committed
Added compact view for streams and sticky copy button
1 parent 718ab6c commit 8cd881a

File tree

1 file changed

+128
-18
lines changed
  • apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.$runParam.streams.$streamKey

1 file changed

+128
-18
lines changed

apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.$runParam.streams.$streamKey/route.tsx

Lines changed: 128 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,20 @@
11
import { 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";
39
import { type LoaderFunctionArgs } from "@remix-run/server-runtime";
4-
import { useEffect, useRef, useState } from "react";
10+
import { useCallback, useEffect, useRef, useState } from "react";
511
import { Paragraph } from "~/components/primitives/Paragraph";
12+
import {
13+
Tooltip,
14+
TooltipContent,
15+
TooltipProvider,
16+
TooltipTrigger,
17+
} from "~/components/primitives/Tooltip";
618
import { $replica } from "~/db.server";
719
import { useEnvironment } from "~/hooks/useEnvironment";
820
import { useOrganization } from "~/hooks/useOrganizations";
@@ -12,6 +24,8 @@ import { requireUserId } from "~/services/session.server";
1224
import { cn } from "~/utils/cn";
1325
import { v3RunStreamParamsSchema } from "~/utils/pathBuilder";
1426

27+
type ViewMode = "list" | "compact";
28+
1529
type 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+
218328
function StreamChunkLine({
219329
chunk,
220330
lineNumber,

0 commit comments

Comments
 (0)