import { useEffect, useRef } from 'react'; import { CircleCheck, FileText, Image, List, Mic, Video } from 'lucide-react'; import { isParticleDeleted, type Human, type Particle } from '@/api/types'; import { cn } from '@/lib/utils'; import { useNetwork } from '@/hooks/use-networks'; import { resolveHumanDisplay } from '@/lib/humans'; import { RelativeTimestamp } from '@/components/relative-timestamp'; import { HumanAvatar } from '@/components/human-avatar'; import { KeyHint } from '@/components/key-hint'; import { ScrollArea } from '@/components/ui/scroll-area'; interface StreamListSidebarProps { items: Particle[]; networkId: string; currentIndex: number; onSelect: (index: number) => void; onToggle: () => void; } /** * Browse-mode panel beside the stream: a chat-like timeline of every * particle. Selecting a message plays it in the immersive stream view; * nothing auto-advances. */ export function StreamListSidebar({ items, networkId, currentIndex, onSelect, onToggle, }: StreamListSidebarProps) { const network = useNetwork(networkId); const rowRefs = useRef>([]); useEffect(() => { if (currentIndex >= 0) { rowRefs.current[currentIndex]?.scrollIntoView({ block: 'nearest' }); } }, [currentIndex]); return ( ); } function ChatRow({ particle, humans, isSelected, onClick, }: { particle: Particle; humans: Human[] | undefined; isSelected: boolean; onClick: () => void; }) { const sender = resolveHumanDisplay(particle.created_by_human_id, humans); return (
{ if (e.key === 'Enter') onClick(); }} className={cn( 'flex cursor-pointer items-start gap-2.5 rounded-md px-2 py-2 text-left transition-colors', isSelected ? 'bg-accent' : 'hover:bg-accent/50', )} >
{sender.displayName}
); } function ChatRowContent({ particle }: { particle: Particle }) { if (isParticleDeleted(particle)) { return (

This particle was deleted

); } switch (particle.type) { case 'text': return (

{particle.properties.content}

); case 'media': { const mime = particle.properties.mime_type; const transcript = particle.properties.transcript?.transcript; const isVideo = mime.startsWith('video/'); const isAudio = mime.startsWith('audio/'); const isImage = mime.startsWith('image/'); const Icon = isVideo ? Video : isAudio ? Mic : isImage ? Image : Video; const label = isVideo ? 'Video clip' : isAudio ? 'Voice note' : isImage ? 'Photo' : 'Media'; const durationSec = Math.round(particle.properties.duration_ms / 1000); const duration = durationSec > 0 ? ` · ${Math.floor(durationSec / 60)}:${String(durationSec % 60).padStart(2, '0')}` : ''; return (
{label} {duration} {transcript && (

{transcript}

)}
); } case 'file': return ( {particle.properties.filename} ); case 'task': { const { title, done, checklist = [] } = particle.properties; const doneCount = checklist.filter((item) => item.done).length; return (
{title} {checklist.length > 0 && ( {doneCount} / {checklist.length} subtasks )}
); } case 'paper': return (

{particle.properties.title}

); default: return

{particle.type}

; } }