330 lines
17 KiB
TypeScript
330 lines
17 KiB
TypeScript
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<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";
|
|
}
|
|
|
|
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 (
|
|
<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 />}
|
|
</div>
|
|
<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 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 [atBottom, setAtBottom] = useState(true);
|
|
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;
|
|
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<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();
|
|
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 (
|
|
<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} />
|
|
</div>
|
|
);
|
|
});
|
|
}, [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 (
|
|
<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;
|
|
setAtBottom(current => current === bottom ? 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>
|
|
{!atBottom ? <button className="agent-room__jump" onClick={jumpToLatest}><ArrowDown size={15} /><span>{room.unreadCount ? `${room.unreadCount} new message${room.unreadCount === 1 ? "" : "s"}` : "Jump to latest"}</span></button> : null}
|
|
</div>
|
|
|
|
<div className="agent-room__composer-area">
|
|
<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}
|
|
<form className="agent-room__composer" onSubmit={event => { event.preventDefault(); submit(); }}>
|
|
<textarea
|
|
ref={textareaRef}
|
|
value={draft}
|
|
rows={1}
|
|
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()} aria-label="Send message"><SendHorizontal size={19} /></button>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
);
|
|
}
|