Files
Sybil-2/web/src/components/agent/agent-conversation.tsx
T

388 lines
23 KiB
TypeScript

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<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";
}
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 (
<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 />}
{agentAttachments(message.metadata).map(file => <AgentAttachmentCard key={file.id} file={file} download={download} />)}
</div>
{own && failed && typeof info.error === "string" ? <div className="agent-attachment__error" role="alert">{info.error}</div> : null}
<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);
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)); } };
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 historyBeforeRef = useRef(room.historyBeforeCursor);
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;
// Find the first visible row without laying out every offscreen message.
const entries = element.querySelectorAll<HTMLElement>("[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<HTMLElement>("[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<HTMLElement>(`[data-agent-entry-id="${CSS.escape(anchor.id)}"]`);
const message = node?.querySelector<HTMLElement>("[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<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, 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 (
<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}
<AgentBubble message={message} animate={enteringMessageIdsRef.current.has(message.id)} onRetry={retryMessage} download={room.downloadAttachment} />
</div>
);
});
}, [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 (
<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}
{room.hasOlder ? <div className="agent-room__history">
<button type="button" disabled={room.isLoadingOlder} onClick={() => {
pinnedRef.current = false; rememberAnchor(); room.loadOlder();
}}>{room.isLoadingOlder ? "Loading older messages…" : "Load older messages"}</button>
{room.olderError ? <span role="alert">{room.olderError} Try loading again.</span> : null}
</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>
<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); } }}>
<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}
{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}
<form className="agent-room__composer" onSubmit={event => { event.preventDefault(); submit(); }}>
{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}
<textarea
ref={textareaRef}
value={draft}
rows={1}
maxLength={40000}
onPaste={event => { if (room.attachmentCapability && event.clipboardData?.files.length) { event.preventDefault(); addFiles(event.clipboardData.files); } }}
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"
/>
<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>
</form>
</div>
</div>
</section>
);
}