2026-10-14 12:20:11 -07:00
import { useEffect , useLayoutEffect , useMemo , useRef , useState } from "preact/hooks" ;
2026-10-16 16:42:58 -07:00
import { Clock3 , LoaderCircle , Menu , Paperclip , X , RefreshCw , SendHorizontal , WifiOff } from "lucide-preact" ;
2026-10-14 12:20:11 -07:00
import type { Message } from "@/lib/api" ;
import type { AgentRoomHookResult } from "@/hooks/use-agent-room" ;
2026-10-14 15:29:50 -07:00
import { agentTranscriptMessages } from "@/lib/agent-room-presentation" ;
2026-10-14 12:20:11 -07:00
import { MarkdownContent } from "@/components/markdown/markdown-content" ;
import { Button } from "@/components/ui/button" ;
2026-10-14 17:25:52 -07:00
import { agentAttachments , attachmentSize } from "@/lib/agent-attachments" ;
import { AgentAttachmentCard } from "./agent-attachment" ;
2026-10-14 12:20:11 -07:00
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 < string , unknown > {
return message . metadata && typeof message . metadata === "object" && ! Array . isArray ( message . metadata )
? message . metadata as Record < string , unknown > : {};
}
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" ;
}
2026-10-14 17:25:52 -07:00
function AgentBubble ({ message , animate , onRetry , download } : { message : Message ; animate : boolean ; onRetry : ( id : string ) => void ; download : AgentRoomHookResult [ "downloadAttachment" ] }) {
2026-10-14 12:20:11 -07:00
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 (
< article className = { `agent-room__message ${ own ? "is-own" : "is-agent" } ` } data-agent-message-id = { message . id } data-message-status = { status } data-animation = { animate ? "enter" : undefined } aria-label = { own ? "Your message" : "Sybil's message" }>
{ ! own ? < span className = "agent-room__message-avatar" aria-hidden = "true" >< img src = "/character-still.png" alt = "" draggable = { false } /></ span > : null }
< div className = "agent-room__message-content" >
< div className = { `message-bubble ${ own ? " message-bubble--user" : "" } ` }>
{ own ? < p className = "agent-room__plain-message" >{ message . content }</ p > : < MarkdownContent markdown = { message . content } openLinksInNewTab />}
2026-10-14 17:25:52 -07:00
{ agentAttachments ( message . metadata ). map ( file => < AgentAttachmentCard key = { file . id } file = { file } download = { download } />)}
2026-10-14 12:20:11 -07:00
</ div >
2026-10-14 17:25:52 -07:00
{ own && failed && typeof info . error === "string" ? < div className = "agent-attachment__error" role = "alert" >{ info . error }</ div > : null }
2026-10-14 12:20:11 -07:00
< div className = "agent-room__message-meta" >
< span >{ own ? "You" : "Sybil" }</ span >
{ date ? < time dateTime = { message . createdAt } title = { date . toLocaleString ()}>{ timeFormat . format ( date )}</ time > : null }
{ own && status === "sending" ? < span className = "agent-room__delivery" role = "status" > Sending … </ span > : null }
{ own && status === "queued" ? < span className = "agent-room__delivery" role = "status" >< Clock3 size = { 11 } aria-hidden = "true" /> Queued </ span > : null }
{ own && failed ? < span className = "agent-room__delivery is-error" title = { typeof info . error === "string" ? info.error : undefined } role = "status" >
{ status === "unconfirmed" ? "Unconfirmed" : "Not sent" }
< button type = "button" onClick = {() => onRetry ( message . id )} aria-label = "Retry message" > Retry </ button >
</ span > : null }
</ div >
</ div >
</ article >
);
}
export function AgentConversation ({ room , onOpenSidebar , isVisible = true } : Props ) {
const [ draft , setDraft ] = useState ( savedDraft );
const draftRef = useRef ( draft );
2026-10-14 17:25:52 -07:00
const [ uploadError , setUploadError ] = useState < string >();
const pickerRef = useRef < HTMLInputElement >( null );
const reselectRef = useRef < HTMLInputElement >( null );
const reselectId = useRef < string >();
const uploads = room . uploadQueue . snapshot ();
const pendingUploads = uploads . some ( file => file . status !== "ready" );
const addFiles = ( files : Iterable < File >) => {
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 )); } };
2026-10-14 12:20:11 -07:00
const seenMessageIdsRef = useRef ( new Set ( room . messages . map ( message => message . id )));
const enteringMessageIdsRef = useRef ( new Set < string >());
const animatedEntryIdsRef = useRef ( new Set < string >());
const entryAnimationsRef = useRef ( new Set < Animation >());
const typingHeightRef = useRef ( 0 );
const historyReadyRef = useRef ( ! room . isLoading );
const scrollerRef = useRef < HTMLDivElement >( null );
const timelineRef = useRef < HTMLDivElement >( null );
const textareaRef = useRef < HTMLTextAreaElement >( null );
const pinnedRef = useRef ( true );
const restoredScrollTopRef = useRef < number | null >( null );
const anchorRef = useRef < ReadingAnchor | null >( null );
const markReadRef = useRef ( room . markRead );
const visibleRef = useRef ( isVisible );
const loadingRef = useRef ( room . isLoading );
const readFrameRef = useRef < number | null >( 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 < HTMLElement >( "[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 < HTMLElement >( "[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 ;
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 < HTMLElement >( ".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 < HTMLElement >( "[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 ();
2026-10-14 17:25:52 -07:00
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 )); });
2026-10-14 12:20:11 -07:00
textareaRef . current ? . focus ();
};
const messages = useMemo (() => {
2026-10-14 15:29:50 -07:00
const transcript = agentTranscriptMessages ( room . messages );
2026-10-14 12:20:11 -07:00
let previousDay = "" ;
2026-10-14 15:29:50 -07:00
for ( const message of transcript ) {
if ( ! seenMessageIdsRef . current . has ( message . id ) && historyReadyRef . current && ! room . isLoading ) {
2026-10-14 12:20:11 -07:00
enteringMessageIdsRef . current . add ( message . id );
}
seenMessageIdsRef . current . add ( message . id );
}
if ( ! room . isLoading ) historyReadyRef . current = true ;
2026-10-14 15:29:50 -07:00
return transcript . map ( message => {
2026-10-14 12:20:11 -07:00
const date = dateOf ( message . createdAt ), day = dayKey ( date ), separator = previousDay !== day ;
previousDay = day ;
return (
< div className = "agent-room__entry" key = { message . id } data-agent-entry-id = { message . id }>
{ separator ? < div className = "agent-room__day" >< span >{ dayLabel ( date )}</ span ></ div > : null }
2026-10-14 17:25:52 -07:00
< AgentBubble message = { message } animate = { enteringMessageIdsRef . current . has ( message . id )} onRetry = { retryMessage } download = { room . downloadAttachment } />
2026-10-14 12:20:11 -07:00
</ div >
);
});
2026-10-14 17:25:52 -07:00
}, [ room . messages , room . isLoading , room . retryMessage , room . downloadAttachment ]);
2026-10-14 12:20:11 -07:00
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 (
< section className = "agent-room" aria-label = "Sybil Agent conversation" >
< header className = "agent-room__header flex shrink-0 items-center justify-between gap-2 border-b border-violet-300/12 bg-[linear-gradient(180deg,hsl(243_48%_10%_/_0.86),hsl(236_48%_6%_/_0.66))] px-4 py-3 md:gap-3 md:px-7" >
< div className = "flex min-w-0 items-center gap-2" >
< Button type = "button" size = "icon" variant = "ghost" className = "workspace-sidebar-trigger -ml-1 h-8 w-8 md:hidden" onClick = { onOpenSidebar } aria-label = "Open sidebar" >
< Menu className = "h-4 w-4" />
</ Button >
< h1 className = "truncate text-sm font-semibold text-violet-50 md:text-base" > Sybil Agent </ h1 >
</ div >
< div className = "flex h-10 shrink-0 items-center justify-end gap-2" >
< span className = "sr-only" role = "status" >{ connectionLabel }</ span >
{ ! connected ? < Button type = "button" size = "icon" variant = "ghost" onClick = { room . retry } aria-label = "Reconnect to Sybil Agent" title = { connectionLabel }>< RefreshCw className = "h-4 w-4" /></ Button > : null }
</ div >
</ header >
< div className = "agent-room__body" >
< div
ref = { scrollerRef }
className = "agent-room__scroll"
tabIndex = { 0 }
aria-label = "Conversation messages"
onScroll = {() => {
// 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 ();
}}
>
< div ref = { timelineRef } className = "agent-room__timeline" >
{ room . isLoading && ! messages . length ? < div className = "agent-room__empty" role = "status" >< LoaderCircle className = "h-5 w-5 animate-spin" aria-hidden = "true" />< span className = "sr-only" > Loading conversation </ span ></ div > : null }
{ messages }
{ running ? < div className = "agent-room__typing" role = "status" aria-live = "polite" >< span className = "agent-room__typing-dots" aria-hidden = "true" >< i />< i />< i /></ span >< span >{ workingLabel }</ span >{( activity ? . backgroundWorkers || 0 ) > 1 ? < span className = "agent-room__worker-count" >{ activity ! . backgroundWorkers } workers </ span > : null }</ div > : null }
{ ! running && waiting && activity ? . wait ? < div className = "agent-room__waiting" role = "status" >< Clock3 size = { 13 } />< span > Checking back at { timeFormat . format ( new Date ( activity . wait . due ))}</ span ></ div > : null }
</ div >
</ div >
</ div >
2026-10-14 17:25:52 -07:00
< div className = "agent-room__composer-area"
onDragOver = { event => { 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 ); } }}>
2026-10-14 12:20:11 -07:00
< div className = "agent-room__composer-inner" >
{ room . error ? < div className = "agent-room__error" role = "alert" >< WifiOff size = { 15 } />< span >{ room . error }</ span >< button onClick = { room . retry }> Retry </ button ></ div > : null }
2026-10-14 17:25:52 -07:00
{ uploadError ? < div className = "agent-room__error" role = "alert" >{ uploadError }</ div > : null }
< input ref = { reselectRef } type = "file" hidden aria-label = "Reselect attachment" onChange = { event => {
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 ? < div className = "agent-room__uploads" aria-label = "Selected attachments" >{ uploads . map ( row => < div className = "agent-room__upload" key = { row . uploadId }>
< div >< strong >{ row . filename }</ strong >< small >{ attachmentSize ( row . sizeBytes )} · < span role = "status" >{ row . status === "ready" ? "Uploaded" : row . status === "uploading" ? "Uploading…" : row . status === "waiting" ? "Waiting…" : row . status === "reselect" ? "Reselect file" : "Upload failed" }</ span ></ small ></ div >
{ row . status === "reselect" ? < button type = "button" onClick = {() => { reselectId . current = row . uploadId ; reselectRef . current ? . click (); }}> Reselect file </ button > : null }
{ row . status === "failed" ? < button type = "button" onClick = {() => room . uploadQueue . retry ( row . uploadId )}> Retry upload </ button > : null }
< button type = "button" aria-label = { `Remove ${ row . filename } ` } onClick = {() => removeFile ( row . uploadId )}>< X size = { 15 } /></ button >
{ row . error ? < span className = "agent-attachment__error" role = "alert" >{ row . error }</ span > : null }
{ row . attachment ? . workspacePath ? < code className = "agent-room__upload-path" title = { row . attachment . workspacePath }>{ row . attachment . workspacePath }</ code > : null }
</ div >)}</ div > : null }
2026-10-14 12:20:11 -07:00
< form className = "agent-room__composer" onSubmit = { event => { event . preventDefault (); submit (); }}>
2026-10-14 17:25:52 -07:00
{ room . attachmentCapability ? <>
< input ref = { pickerRef } type = "file" multiple hidden aria-label = "Choose attachments" onChange = { event => { if ( event . currentTarget . files ) addFiles ( event . currentTarget . files ); event . currentTarget . value = "" ; }} />
< button type = "button" className = "agent-room__attach" aria-label = "Attach files" title = "Attach files (8 files, 32 MiB total)" onClick = {() => pickerRef . current ? . click ()}>< Paperclip size = { 19 } /></ button >
</> : null }
2026-10-14 12:20:11 -07:00
< textarea
ref = { textareaRef }
value = { draft }
rows = { 1 }
2026-10-14 17:25:52 -07:00
maxLength = { 40000 }
onPaste = { event => { if ( room . attachmentCapability && event . clipboardData ? . files . length ) { event . preventDefault (); addFiles ( event . clipboardData . files ); } }}
2026-10-14 12:20:11 -07:00
onInput = { event => updateDraft ( event . currentTarget . value )}
onKeyDown = { event => { if ( event . key === "Enter" && ! event . shiftKey && ! event . isComposing ) { event . preventDefault (); submit (); } }}
placeholder = "Message Sybil Agent…"
aria-label = "Message Sybil Agent"
enterkeyhint = "send"
/>
2026-10-14 17:25:52 -07:00
< button type = "submit" className = "agent-room__send" disabled = {( ! draft . trim () && ! uploads . length ) || pendingUploads || Boolean ( uploads . length && ! room . attachmentCapability )} aria-label = "Send message" >< SendHorizontal size = { 19 } /></ button >
2026-10-14 12:20:11 -07:00
</ form >
</ div >
</ div >
</ section >
);
}