'use client';
import React, { useEffect, useRef } from 'react';
import { Virtuoso, type VirtuosoHandle } from 'react-virtuoso';
import { DataInspector } from './ui/data-inspector';
import { Skeleton } from './ui/skeleton';
// ──────────────────────────────────────────────────────────────────────────
// Helpers
// ──────────────────────────────────────────────────────────────────────────
function deserializeChunkText(text: string): string {
try {
const parsed = JSON.parse(text);
if (typeof parsed === 'string') {
return parsed;
}
return JSON.stringify(parsed, null, 2);
} catch {
return text;
}
}
function parseChunkData(text: string): unknown {
try {
return JSON.parse(text);
} catch {
return text;
}
}
// ──────────────────────────────────────────────────────────────────────────
// Types
// ──────────────────────────────────────────────────────────────────────────
export interface StreamChunk {
id: number;
text: string;
}
type Chunk = StreamChunk;
interface StreamViewerProps {
streamId: string;
chunks: Chunk[];
isLive: boolean;
error?: string | null;
/** True while the initial stream connection is being established */
isLoading?: boolean;
/** Called when the user scrolls near the bottom, for triggering pagination */
onScrollEnd?: () => void;
}
// ──────────────────────────────────────────────────────────────────────────
// Chunk row — memoized to prevent remounts during polling
// ──────────────────────────────────────────────────────────────────────────
const ChunkRow = React.memo(function ChunkRow({
chunk,
index,
}: {
chunk: Chunk;
index: number;
}) {
const parsed = parseChunkData(chunk.text);
return (
[{index}]
{typeof parsed === 'string' ? (
{deserializeChunkText(parsed)}
) : (
)}
);
});
// ──────────────────────────────────────────────────────────────────────────
// Skeleton loading
// ──────────────────────────────────────────────────────────────────────────
function StreamSkeleton() {
return (
{[1, 2, 3, 4].map((i) => (
))}
);
}
// ──────────────────────────────────────────────────────────────────────────
// Main component
// ──────────────────────────────────────────────────────────────────────────
/**
* StreamViewer component that displays real-time stream data.
* Each chunk is rendered with DataInspector for proper display
* of complex types (Map, Set, Date, custom classes, etc.).
*/
export function StreamViewer({
streamId: _streamId,
chunks,
isLive,
error,
isLoading,
onScrollEnd,
}: StreamViewerProps) {
const virtuosoRef = useRef(null);
const prevChunkCountRef = useRef(0);
// Auto-scroll to bottom when new chunks arrive (live streaming)
useEffect(() => {
if (chunks.length > prevChunkCountRef.current && chunks.length > 0) {
virtuosoRef.current?.scrollToIndex({
index: chunks.length - 1,
align: 'end',
});
}
prevChunkCountRef.current = chunks.length;
}, [chunks.length]);
// Show skeleton when loading and no chunks have arrived yet
if (isLoading && chunks.length === 0) {
return (
);
}
return (
{/* Live indicator */}
{isLive && (
Live
)}
{/* Header */}
{chunks.length > 0 && (
Stream Chunks
({chunks.length})
)}
{/* Content */}
{error ? (
Error reading stream:
{error}
) : chunks.length === 0 ? (
{isLive ? 'Waiting for stream data...' : 'Stream is empty'}
) : (
onScrollEnd?.()}
itemContent={(index) => (
)}
style={{ flex: 1, minHeight: 0 }}
/>
)}
);
}