Add persistent agent messaging and shared chat bubbles
Connect Sybil to the Pi root conversation with authenticated REST and WebSocket APIs, persistent read state, and a dedicated agent tab. Keep optimistic messages stable through delivery and retries, and animate arrivals without losing the reader's scroll position. Share bubble styling with regular chats and remove the obsolete admission sender. Reject malformed upstream receipts and WebSocket upgrade requests, with regression coverage and updated API documentation. Validated with 51 web tests, 44 server tests, both production builds, desktop/mobile bubble comparisons, and 11 browser animation and scrolling checks.
This commit is contained in:
@@ -0,0 +1,350 @@
|
||||
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "preact/hooks";
|
||||
import { ArrowDown, ChevronDown, Clock3, LoaderCircle, Menu, RefreshCw, SendHorizontal, WifiOff, Wrench } from "lucide-preact";
|
||||
import type { Message } from "@/lib/api";
|
||||
import type { AgentRoomHookResult } from "@/hooks/use-agent-room";
|
||||
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 AgentToolCard({ message }: { message: Message }) {
|
||||
const info = metadata(message);
|
||||
const active = info.status === "initiated";
|
||||
const failed = info.status === "failed";
|
||||
const title = toolName(info.toolName || message.name);
|
||||
const preview = typeof info.resultPreview === "string" ? info.resultPreview : typeof info.error === "string" ? info.error : "";
|
||||
return (
|
||||
<details className={`agent-room__tool${active ? " is-running" : ""}${failed ? " is-failed" : ""}`} data-agent-message-id={message.id}>
|
||||
<summary>
|
||||
<span className="agent-room__tool-icon"><Wrench size={14} aria-hidden="true" /></span>
|
||||
<span className="agent-room__tool-title">{title}</span>
|
||||
<span className="agent-room__tool-state">{active ? "Running" : failed ? "Failed" : "Done"}</span>
|
||||
<ChevronDown className="agent-room__tool-chevron" size={14} aria-hidden="true" />
|
||||
</summary>
|
||||
<div className="agent-room__tool-body">
|
||||
{info.args && typeof info.args === "object" ? <pre aria-label="Tool input">{JSON.stringify(info.args, null, 2)}</pre> : null}
|
||||
{preview ? <pre aria-label={failed ? "Tool error" : "Tool result"}>{preview}</pre> : null}
|
||||
{!info.args && !preview ? <p>{active ? "This tool is still running." : "No additional details."}</p> : null}
|
||||
</div>
|
||||
</details>
|
||||
);
|
||||
}
|
||||
|
||||
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(() => {
|
||||
let previousDay = "";
|
||||
for (const message of room.messages) {
|
||||
if (!seenMessageIdsRef.current.has(message.id) && historyReadyRef.current && !room.isLoading && message.role !== "tool") {
|
||||
enteringMessageIdsRef.current.add(message.id);
|
||||
}
|
||||
seenMessageIdsRef.current.add(message.id);
|
||||
}
|
||||
if (!room.isLoading) historyReadyRef.current = true;
|
||||
return room.messages.filter(message => message.role !== "system").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}
|
||||
{message.role === "tool" ? <AgentToolCard message={message} /> : <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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user