import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "preact/hooks"; import { ArrowDown, Clock3, LoaderCircle, Menu, RefreshCw, SendHorizontal, WifiOff } from "lucide-preact"; import type { Message } from "@/lib/api"; import type { AgentRoomHookResult } from "@/hooks/use-agent-room"; import { agentTranscriptMessages } from "@/lib/agent-room-presentation"; import { MarkdownContent } from "@/components/markdown/markdown-content"; import { Button } from "@/components/ui/button"; import "./agent-conversation.css"; type Props = { room: AgentRoomHookResult; onOpenSidebar: () => void; /** False while a mobile navigation overlay covers this pane. */ isVisible?: boolean; }; type ReadingAnchor = { id: string; offset: number }; const DRAFT_KEY = "sybil.pi-root.draft"; const timeFormat = new Intl.DateTimeFormat(undefined, { hour: "numeric", minute: "2-digit" }); const dayFormat = new Intl.DateTimeFormat(undefined, { weekday: "short", month: "short", day: "numeric" }); function savedDraft() { try { return sessionStorage.getItem(DRAFT_KEY) || ""; } catch { return ""; } } function saveDraft(value: string) { try { if (value) sessionStorage.setItem(DRAFT_KEY, value); else sessionStorage.removeItem(DRAFT_KEY); } catch { /* A blocked browser store does not prevent chatting. */ } } function metadata(message: Message): Record { return message.metadata && typeof message.metadata === "object" && !Array.isArray(message.metadata) ? message.metadata as Record : {}; } function dateOf(value: string) { const date = new Date(value); return Number.isNaN(date.getTime()) || date.getFullYear() < 2000 ? null : date; } function dayKey(date: Date | null) { return date ? `${date.getFullYear()}-${date.getMonth()}-${date.getDate()}` : "older"; } function dayLabel(date: Date | null) { if (!date) return "Earlier"; const today = new Date(); if (dayKey(date) === dayKey(today)) return "Today"; today.setDate(today.getDate() - 1); return dayKey(date) === dayKey(today) ? "Yesterday" : dayFormat.format(date); } function toolName(value: unknown) { return typeof value === "string" ? value.replace(/_/g, " ") : "Tool"; } function AgentBubble({ message, animate, onRetry }: { message: Message; animate: boolean; onRetry: (id: string) => void }) { const own = message.role === "user"; const info = metadata(message); const status = typeof info.status === "string" ? info.status : "complete"; const failed = status === "failed" || status === "unconfirmed"; const date = dateOf(message.createdAt); return (
{!own ? : null}
{own ?

{message.content}

: }
{own ? "You" : "Sybil"} {date ? : null} {own && status === "sending" ? Sending… : null} {own && status === "queued" ? : null} {own && failed ? {status === "unconfirmed" ? "Unconfirmed" : "Not sent"} : null}
); } export function AgentConversation({ room, onOpenSidebar, isVisible = true }: Props) { const [draft, setDraft] = useState(savedDraft); const draftRef = useRef(draft); const seenMessageIdsRef = useRef(new Set(room.messages.map(message => message.id))); const enteringMessageIdsRef = useRef(new Set()); const animatedEntryIdsRef = useRef(new Set()); const entryAnimationsRef = useRef(new Set()); const typingHeightRef = useRef(0); const historyReadyRef = useRef(!room.isLoading); const [atBottom, setAtBottom] = useState(true); const scrollerRef = useRef(null); const timelineRef = useRef(null); const textareaRef = useRef(null); const pinnedRef = useRef(true); const restoredScrollTopRef = useRef(null); const anchorRef = useRef(null); const markReadRef = useRef(room.markRead); const visibleRef = useRef(isVisible); const loadingRef = useRef(room.isLoading); const readFrameRef = useRef(null); const mountedRef = useRef(true); markReadRef.current = room.markRead; visibleRef.current = isVisible; loadingRef.current = room.isLoading; const distanceFromBottom = () => { const element = scrollerRef.current; return element ? element.scrollHeight - element.scrollTop - element.clientHeight : Infinity; }; const scheduleRead = () => { if (readFrameRef.current !== null) cancelAnimationFrame(readFrameRef.current); readFrameRef.current = requestAnimationFrame(() => { readFrameRef.current = null; if (mountedRef.current && visibleRef.current && !loadingRef.current && entryAnimationsRef.current.size === 0 && document.visibilityState === "visible" && document.hasFocus() && distanceFromBottom() <= 24) { markReadRef.current(); } }); }; const rememberAnchor = () => { const element = scrollerRef.current; if (!element || pinnedRef.current) { anchorRef.current = null; return; } const top = element.getBoundingClientRect().top; // Anchor to layout, independently of the bubble's entrance transform. const node = Array.from(element.querySelectorAll("[data-agent-entry-id]")).find(item => item.getBoundingClientRect().bottom > top + 8); anchorRef.current = node ? { id: node.dataset.agentEntryId!, offset: node.getBoundingClientRect().top - top } : null; }; const restoreViewport = () => { const element = scrollerRef.current; if (!element) return; if (pinnedRef.current) element.scrollTop = element.scrollHeight; else if (anchorRef.current) { const anchor = anchorRef.current; const node = Array.from(element.querySelectorAll("[data-agent-entry-id]")).find(item => item.dataset.agentEntryId === anchor.id); if (node) element.scrollTop += node.getBoundingClientRect().top - element.getBoundingClientRect().top - anchor.offset; } restoredScrollTopRef.current = element.scrollTop; rememberAnchor(); scheduleRead(); }; const jumpToLatest = () => { pinnedRef.current = true; anchorRef.current = null; setAtBottom(true); if (scrollerRef.current) { scrollerRef.current.scrollTop = scrollerRef.current.scrollHeight; restoredScrollTopRef.current = scrollerRef.current.scrollTop; } scheduleRead(); }; useLayoutEffect(() => { const timeline = timelineRef.current; if (!timeline) return; const gap = parseFloat(getComputedStyle(timeline).rowGap) || 0; const typing = timeline.querySelector(".agent-room__typing"); // Transfer the departing indicator's space to the reply before it grows. // Removing that space first would briefly move the transcript downward. let replacedTypingHeight = typing ? 0 : typingHeightRef.current; typingHeightRef.current = typing ? typing.offsetHeight + (typing.previousElementSibling ? gap : 0) : 0; const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches; for (const entry of timeline.querySelectorAll("[data-agent-entry-id]")) { const id = entry.dataset.agentEntryId!; if (animatedEntryIdsRef.current.has(id) || !enteringMessageIdsRef.current.has(id)) continue; animatedEntryIdsRef.current.add(id); if (reduceMotion) continue; const height = entry.getBoundingClientRect().height; const spacing = entry.previousElementSibling ? gap : 0; const isReply = Boolean(entry.querySelector(".agent-room__message.is-agent")); const initialHeight = isReply ? Math.min(replacedTypingHeight, height + spacing) : 0; if (isReply) replacedTypingHeight = 0; // Include the inter-message gap in the reveal. Clipping keeps the bubble's // positive-Y transform from prematurely extending the scrollable area. entry.style.overflow = "clip"; const animation = entry.animate([ { height: `${initialHeight}px`, marginTop: `${-spacing}px` }, { height: `${height}px`, marginTop: "0px" }, ], { duration: 240, easing: "cubic-bezier(.2,.7,.25,1)" }); entryAnimationsRef.current.add(animation); animation.onfinish = () => { entry.style.overflow = ""; entryAnimationsRef.current.delete(animation); restoreViewport(); }; } }, [room.messages, room.isTyping]); useLayoutEffect(() => { restoreViewport(); }, [room.messages, room.isTyping, room.activity?.state, room.isLoading, isVisible]); useEffect(() => { mountedRef.current = true; const observer = new ResizeObserver(restoreViewport); if (scrollerRef.current) observer.observe(scrollerRef.current); if (timelineRef.current) observer.observe(timelineRef.current); const onVisibility = () => scheduleRead(); window.addEventListener("focus", onVisibility); document.addEventListener("visibilitychange", onVisibility); return () => { mountedRef.current = false; observer.disconnect(); for (const animation of entryAnimationsRef.current) animation.cancel(); entryAnimationsRef.current.clear(); window.removeEventListener("focus", onVisibility); document.removeEventListener("visibilitychange", onVisibility); if (readFrameRef.current !== null) cancelAnimationFrame(readFrameRef.current); }; }, []); useLayoutEffect(() => { const textarea = textareaRef.current; if (!textarea) return; textarea.style.height = "0px"; textarea.style.height = `${Math.min(156, Math.max(46, textarea.scrollHeight))}px`; }, [draft]); const updateDraft = (value: string) => { draftRef.current = value; saveDraft(value); setDraft(value); }; const retryMessage = (id: string) => { // Admission errors remain attached to their own bubble. void room.retryMessage(id).catch(() => {}); }; const submit = () => { const content = draftRef.current.trim(); if (!content) return; const delivery = room.sendMessage(content); updateDraft(""); jumpToLatest(); void delivery.catch(() => {}); textareaRef.current?.focus(); }; const messages = useMemo(() => { const transcript = agentTranscriptMessages(room.messages); let previousDay = ""; for (const message of transcript) { if (!seenMessageIdsRef.current.has(message.id) && historyReadyRef.current && !room.isLoading) { enteringMessageIdsRef.current.add(message.id); } seenMessageIdsRef.current.add(message.id); } if (!room.isLoading) historyReadyRef.current = true; return transcript.map(message => { const date = dateOf(message.createdAt), day = dayKey(date), separator = previousDay !== day; previousDay = day; return (
{separator ?
{dayLabel(date)}
: null}
); }); }, [room.messages, room.isLoading, room.retryMessage]); const activity = room.activity; const running = room.isTyping; const waiting = activity?.state === "waiting"; const connected = room.connection === "connected"; const connectionLabel = connected ? "Connected" : room.connection === "offline" ? "Offline" : room.connection === "reconnecting" ? "Reconnecting" : "Connecting"; const activeTool = activity?.tools?.find(tool => tool.status !== "done"); const workingLabel = activeTool ? `Using ${toolName(activeTool.name)}` : activity?.isThinking ? "Sybil is thinking" : (activity?.backgroundWorkers || 0) > 0 ? "Working in the background" : ""; return (

Sybil Agent

{connectionLabel} {!connected ? : null}
{ // A scroll event from our last resize correction can arrive after // the next animation frame has already grown a tall reply. That // temporary bottom distance does not mean the reader scrolled up. if (restoredScrollTopRef.current !== null && scrollerRef.current && Math.abs(scrollerRef.current.scrollTop - restoredScrollTopRef.current) < 1) return; const bottom = distanceFromBottom() <= 48; pinnedRef.current = bottom; setAtBottom(current => current === bottom ? current : bottom); rememberAnchor(); if (bottom) scheduleRead(); }} >
{room.isLoading && !messages.length ?
: null} {messages} {running ?
{workingLabel}{(activity?.backgroundWorkers || 0) > 1 ? {activity!.backgroundWorkers} workers : null}
: null} {!running && waiting && activity?.wait ?
Checking back at {timeFormat.format(new Date(activity.wait.due))}
: null}
{!atBottom ? : null}
{room.error ?
{room.error}
: null}
{ event.preventDefault(); submit(); }}>