import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "preact/hooks"; import { memo } from "preact/compat"; import { Clock3, LoaderCircle, Menu, Paperclip, X, 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 { agentAttachments, attachmentSize } from "@/lib/agent-attachments"; import { AgentAttachmentCard } from "./agent-attachment"; 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"; } const AgentBubble = memo(function AgentBubble({ message, animate, onRetry, download }: { message: Message; animate: boolean; onRetry: (id: string) => void; download: AgentRoomHookResult["downloadAttachment"] }) { 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}

: } {agentAttachments(message.metadata).map(file => )}
{own && failed && typeof info.error === "string" ?
{info.error}
: null}
{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 [uploadError, setUploadError] = useState(); const pickerRef = useRef(null); const reselectRef = useRef(null); const reselectId = useRef(); const uploads = room.uploadQueue.snapshot(); const pendingUploads = uploads.some(file => file.status !== "ready"); const addFiles = (files: Iterable) => { try { room.uploadQueue.add(files); setUploadError(undefined); } catch (error) { setUploadError(error instanceof Error ? error.message : String(error)); } }; const removeFile = (id: string) => { try { room.uploadQueue.remove(id); setUploadError(undefined); } catch (error) { setUploadError(String(error)); } }; 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 historyBeforeRef = useRef(room.historyBeforeCursor); 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; // Find the first visible row without laying out every offscreen message. const entries = element.querySelectorAll("[data-agent-entry-id]"); // Probe row boxes without measuring every skipped subtree in a long history. let low = 0, high = entries.length; while (low < high) { const mid = (low + high) >>> 1; if (entries[mid].getBoundingClientRect().bottom <= top + 8) low = mid + 1; else high = mid; } const node = entries[low]; // The first row can lose its day separator when older rows arrive. Anchor // the message itself so that changing that separator cannot shift its text. const message = node?.querySelector("[data-agent-message-id]"); anchorRef.current = node && message ? { id: node.dataset.agentEntryId!, offset: message.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 = element.querySelector(`[data-agent-entry-id="${CSS.escape(anchor.id)}"]`); const message = node?.querySelector("[data-agent-message-id]"); if (message) element.scrollTop += message.getBoundingClientRect().top - element.getBoundingClientRect().top - anchor.offset; } restoredScrollTopRef.current = element.scrollTop; // Keep the user's key/offset through successive intrinsic-size corrections. // Recapturing here can anchor a newly revealed older row while its neighbors // are still being measured, causing the original reading position to drift. scheduleRead(); }; const jumpToLatest = () => { pinnedRef.current = true; anchorRef.current = null; 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, room.isLoadingOlder, room.olderError, room.hasOlder, 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 = useCallback((id: string) => { // Admission errors remain attached to their own bubble. void room.retryMessage(id).catch(() => {}); }, [room.retryMessage]); const submit = () => { const content = draftRef.current.trim(); if ((!content && !uploads.length) || pendingUploads || (uploads.length && !room.attachmentCapability)) return; let attachments; try { attachments = room.uploadQueue.ready(); } catch (error) { setUploadError(String(error)); return; } const delivery = room.sendMessage(content, attachments, () => { updateDraft(""); setUploadError(undefined); jumpToLatest(); }); void delivery.catch(error => { if (draftRef.current === content) setUploadError(error instanceof Error ? error.message : String(error)); }); textareaRef.current?.focus(); }; const messages = useMemo(() => { const transcript = agentTranscriptMessages(room.messages); const historyChanged = historyBeforeRef.current !== room.historyBeforeCursor; historyBeforeRef.current = room.historyBeforeCursor; let previousDay = ""; let lastSeen = -1; transcript.forEach((message, index) => { if (seenMessageIdsRef.current.has(message.id)) lastSeen = index; }); for (const [index, message] of transcript.entries()) { // Historical prepends keep their measured space; only new tail rows enter. if (index > lastSeen && !(historyChanged && lastSeen < 0) && !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.historyBeforeCursor, room.retryMessage, room.downloadAttachment]); 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; rememberAnchor(); if (bottom) scheduleRead(); }} >
{room.isLoading && !messages.length ?
: null} {room.hasOlder ?
{room.olderError ? {room.olderError} Try loading again. : null}
: 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}
{ if (room.attachmentCapability && event.dataTransfer?.types.includes("Files")) event.preventDefault(); }} onDrop={event => { if (room.attachmentCapability && event.dataTransfer?.files.length) { event.preventDefault(); addFiles(event.dataTransfer.files); } }}>
{room.error ?
{room.error}
: null} {uploadError ?
{uploadError}
: null} { const file = event.currentTarget.files?.[0], id = reselectId.current; event.currentTarget.value = ""; if (file && id) void room.uploadQueue.reselect(id, file).then(() => setUploadError(undefined)).catch(error => setUploadError(error instanceof Error ? error.message : String(error))); }} /> {uploads.length ?
{uploads.map(row =>
{row.filename}{attachmentSize(row.sizeBytes)} · {row.status === "ready" ? "Uploaded" : row.status === "uploading" ? "Uploading…" : row.status === "waiting" ? "Waiting…" : row.status === "reselect" ? "Reselect file" : "Upload failed"}
{row.status === "reselect" ? : null} {row.status === "failed" ? : null} {row.error ? {row.error} : null} {row.attachment?.workspacePath ? {row.attachment.workspacePath} : null}
)}
: null}
{ event.preventDefault(); submit(); }}> {room.attachmentCapability ? <> { if (event.currentTarget.files) addFiles(event.currentTarget.files); event.currentTarget.value = ""; }} /> : null}