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:
2026-10-14 12:20:11 -07:00
parent dd6cd1ea7b
commit ef3d79c428
29 changed files with 2752 additions and 25 deletions
+229
View File
@@ -0,0 +1,229 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "preact/hooks";
import { getApiBaseUrl, getAuthToken, type Message } from "@/lib/api";
import {
AGENT_ADMISSION_STORAGE_KEY, AgentRoomClient, advanceAgentCursor, agentAuthFrame, agentDisplayMessages, agentUnreadCount,
applyAgentEvent, applyAgentReadState,
createAgentReadWriter, createAgentRoomState, parseAgentRoomEvent, syncAgentHistory,
type AgentActivity, type AgentRoomState,
} from "@/lib/agent-room";
import { AGENT_OUTBOX_STORAGE_KEY, createAgentOptimisticOutbox } from "@/lib/agent-optimistic-outbox";
import { createAgentRoomPresenter } from "@/lib/agent-room-presentation";
export type AgentRoomConnection = "connecting" | "connected" | "reconnecting" | "offline";
export type AgentRoomHookResult = {
messages: Message[]; activity: AgentActivity | null; unreadCount: number;
connection: AgentRoomConnection; error: string | null; isLoading: boolean; isTyping: boolean;
sendMessage(content: string): Promise<void>; retryMessage(messageId: string): Promise<void>; markRead(): void; retry(): void;
};
const errorText = (error: unknown) => error instanceof Error ? error.message : String(error);
/** Mount once in App; selection controls reading, never the lifetime of this subscription. */
export function useAgentRoom(enabled: boolean): AgentRoomHookResult {
const [view, setView] = useState(() => ({ room: createAgentRoomState(), isTyping: false }));
const room = view.room;
const current = useRef(room);
const [connection, setConnection] = useState<AgentRoomConnection>("offline");
const [error, setError] = useState<string | null>(null);
const [isLoading, setLoading] = useState(enabled);
const [revision, setRevision] = useState(0);
const client = useRef<AgentRoomClient | null>(null);
const mark = useRef<((cursor: string) => void) | null>(null);
const [outboxRevision, setOutboxRevision] = useState(0);
const update = useCallback((change: (state: AgentRoomState) => AgentRoomState) => {
const next = change(current.current); current.current = next; setView(previous => ({ ...previous, room: next }));
}, []);
const outbox = useRef<ReturnType<typeof createAgentOptimisticOutbox> | null>(null);
if (!outbox.current) {
const stored = (key: string) => { try { return JSON.parse(sessionStorage.getItem(key) || "null"); } catch { return null; } };
outbox.current = createAgentOptimisticOutbox({
submit: (content, requestId) => {
if (!client.current) throw new Error("Agent connection is unavailable.");
return client.current.submit(content, requestId);
},
changed: () => setOutboxRevision(value => value + 1),
storage: {
read: () => stored(AGENT_OUTBOX_STORAGE_KEY),
legacyRows: () => stored("sybil.pi-root.outbox"),
legacyIds: () => stored(AGENT_ADMISSION_STORAGE_KEY),
write: rows => {
try {
// Keep [] as a migration marker; legacy keys are retired only once
// their replacement has been saved successfully.
sessionStorage.setItem(AGENT_OUTBOX_STORAGE_KEY, JSON.stringify(rows));
sessionStorage.removeItem("sybil.pi-root.outbox");
sessionStorage.removeItem(AGENT_ADMISSION_STORAGE_KEY);
} catch { throw new Error("Could not save this message for retry. Allow session storage or free browser storage, then retry."); }
},
},
});
}
useEffect(() => {
if (!enabled) {
client.current = null; mark.current = null;
update(() => createAgentRoomState()); setLoading(false); setConnection("offline"); setError(null);
return;
}
let active = true;
let socket: WebSocket | undefined;
let reconnectTimer: ReturnType<typeof setTimeout> | undefined;
let startupTimer: ReturnType<typeof setTimeout> | undefined;
let socketTimeout: ReturnType<typeof setTimeout> | undefined;
let reconnectAttempt = 0;
let started = false;
let maintaining = false;
let lastPacket = Date.now();
let socketAuthenticated = false;
let authFailed = false;
const controller = new AbortController();
const api = new AgentRoomClient({ baseUrl: getApiBaseUrl(), token: getAuthToken });
client.current = api;
const presenter = createAgentRoomPresenter({ initial: current.current, reduceEvent: applyAgentEvent,
publish: (next, isTyping) => {
if (!active) return;
outbox.current?.reconcile(next);
current.current = next;
setView({ room: next, isTyping });
},
});
setLoading(current.current.conversationId === null); setConnection("connecting"); setError(null);
const reportError = (failure: unknown) => { if (active && !controller.signal.aborted) setError(errorText(failure)); };
// Read acknowledgments may be ahead of the batched UI for ~100ms. The
// writer must observe that acknowledged cursor immediately to avoid a
// repeated PUT loop while the presenter waits to publish its batch.
let acknowledgedReadCursor = current.current.readCursor;
const readWriter = createAgentReadWriter({
current: () => acknowledgedReadCursor,
save: cursor => api.markRead(cursor, controller.signal),
apply: read => {
acknowledgedReadCursor = advanceAgentCursor(acknowledgedReadCursor, read.cursor);
if (active) presenter.update(state => applyAgentReadState(state, read));
},
});
mark.current = cursor => { void readWriter.request(cursor).catch(reportError); };
const refreshRead = async () => {
const read = await api.readState(controller.signal);
acknowledgedReadCursor = advanceAgentCursor(acknowledgedReadCursor, read.cursor);
if (active) presenter.update(state => applyAgentReadState(state, read));
};
const catchUp = async (replay = false) => syncAgentHistory(api, current.current.cursor, page => {
if (active) presenter.push({ type: "messages", ...page }, { replay });
}, controller.signal);
const refreshActivity = async () => {
const descriptor = await api.descriptor(controller.signal);
if (active) presenter.push({ type: "activity", conversationId: descriptor.id, activity: descriptor.activity });
};
const connect = () => {
if (!active || authFailed) return;
clearTimeout(reconnectTimer); clearTimeout(socketTimeout);
setConnection(reconnectAttempt ? "reconnecting" : "connecting");
socketAuthenticated = false;
const next = new WebSocket(api.websocketUrl(window.location.href));
socket = next;
socketTimeout = setTimeout(() => { if (!socketAuthenticated) next.close(); }, 12_000);
next.onopen = () => {
if (!active || socket !== next) { next.close(); return; }
lastPacket = Date.now();
next.send(agentAuthFrame(getAuthToken(), current.current.cursor));
};
next.onmessage = ({ data }) => {
if (!active || socket !== next) return;
lastPacket = Date.now();
try {
const event = parseAgentRoomEvent(String(data));
if (!event) return;
if (event.type === "error") { reportError(new Error(event.error || event.message || "Agent connection error.")); return; }
if (event.type === "ready") {
clearTimeout(socketTimeout); socketAuthenticated = true; reconnectAttempt = 0;
setConnection("connected"); setError(null); return;
}
presenter.push(event);
} catch (failure) {
reportError(failure);
// A malformed message must not advance the replay cursor. Reconnect
// from the last applied page, rather than silently skipping that input.
next.close();
}
};
next.onerror = () => { if (active) setConnection("reconnecting"); };
next.onclose = event => {
if (!active || socket !== next) return;
clearTimeout(socketTimeout); socketAuthenticated = false;
if (event.code === 4401) {
authFailed = true; setConnection("offline"); setError("Agent authentication failed. Sign in again or retry."); return;
}
setConnection("reconnecting");
const wait = Math.min(30_000, 1000 * 2 ** Math.min(reconnectAttempt++, 5));
reconnectTimer = setTimeout(connect, wait);
};
};
const start = async () => {
try {
await Promise.all([refreshRead(), refreshActivity()]);
// The first start begins at zero, not the latest page. Unread counts
// therefore include older public replies even after a long absence.
await catchUp(!started);
if (!active) return;
presenter.flush();
started = true; setLoading(false); connect();
} catch (failure) {
if (!active) return;
reportError(failure); setLoading(false); setConnection("offline");
startupTimer = setTimeout(() => { void start(); }, 10_000);
}
};
const maintain = async (foreground = false) => {
if (!active || !started || maintaining || authFailed) return;
maintaining = true;
try {
await refreshRead();
await readWriter.flush();
if (!socketAuthenticated) {
await Promise.all([catchUp(), refreshActivity()]);
} else if (foreground) await catchUp();
if (socket?.readyState === WebSocket.OPEN) {
if (Date.now() - lastPacket > 60_000) socket.close();
else socket.send(JSON.stringify({ type: "ping" }));
}
} catch (failure) { reportError(failure); }
finally { maintaining = false; }
};
const focus = () => { if (document.visibilityState === "visible") void maintain(true); };
const interval = setInterval(() => { void maintain(); }, 15_000);
window.addEventListener("focus", focus);
document.addEventListener("visibilitychange", focus);
void start();
return () => {
active = false; controller.abort();
presenter.dispose();
clearInterval(interval); clearTimeout(reconnectTimer); clearTimeout(startupTimer); clearTimeout(socketTimeout);
window.removeEventListener("focus", focus); document.removeEventListener("visibilitychange", focus);
socket?.close();
if (client.current === api) client.current = null;
mark.current = null;
};
}, [enabled, revision, update]);
const renderedCursor = room.cursor;
const markRead = useCallback(() => {
if (!enabled || isLoading) return;
// This callback belongs to this rendered snapshot. Reading current.current
// here would incorrectly mark a socket message that has not rendered yet.
mark.current?.(renderedCursor);
}, [enabled, isLoading, renderedCursor]);
const sendMessage = useCallback(async (content: string) => {
if (!enabled || !outbox.current) throw new Error("Agent connection is unavailable.");
await outbox.current.send(content);
}, [enabled]);
const retryMessage = useCallback(async (messageId: string) => {
if (!enabled || !outbox.current) throw new Error("Agent connection is unavailable.");
await outbox.current.retry(messageId);
}, [enabled]);
const retry = useCallback(() => { setRevision(value => value + 1); }, []);
const messages = useMemo(() => outbox.current?.display(agentDisplayMessages(room)) || agentDisplayMessages(room), [room.messages, room.activity, outboxRevision]);
return { messages: enabled ? messages : [], activity: room.activity, unreadCount: agentUnreadCount(room), connection, error, isLoading,
isTyping: enabled && view.isTyping, sendMessage, retryMessage, markRead, retry };
}