feat(agent): add opt-in push notifications

This commit is contained in:
2026-10-14 22:03:21 -07:00
parent 80748f764f
commit a1c0264d36
51 changed files with 4815 additions and 121 deletions
+44 -32
View File
@@ -1,13 +1,14 @@
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,
AGENT_ADMISSION_STORAGE_KEY, AgentRoomClient, agentAuthFrame, agentDisplayMessages, agentUnreadCount,
applyAgentEvent, applyAgentReadState,
createAgentReadWriter, createAgentRoomState, parseAgentRoomEvent, syncAgentHistory,
createAgentReadSession, 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";
import { getAgentPresenceSessionId, syncAgentNotificationRead } from "@/lib/agent-push-browser";
import { AGENT_ATTACHMENT_DRAFT_KEY, createAgentUploadQueue, type AgentAttachment, type AgentAttachmentCapability } from "@/lib/agent-attachments";
@@ -47,7 +48,8 @@ export function useAgentRoom(enabled: boolean): AgentRoomHookResult {
},
});
useEffect(() => () => uploadQueue.current?.dispose(), []);
const mark = useRef<((cursor: string) => void) | null>(null);
const mark = useRef<((cursor: string, sourceId: string | null | undefined) => void) | null>(null);
const sourceMismatch = useRef(false);
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 }));
@@ -80,11 +82,13 @@ export function useAgentRoom(enabled: boolean): AgentRoomHookResult {
useEffect(() => {
if (!enabled) {
sourceMismatch.current = false;
client.current = null; mark.current = null; setAttachmentCapability(undefined);
update(() => createAgentRoomState()); setLoading(false); setConnection("offline"); setError(null);
return;
}
let active = true;
let sourceChanged = false;
let socket: WebSocket | undefined;
let reconnectTimer: ReturnType<typeof setTimeout> | undefined;
let startupTimer: ReturnType<typeof setTimeout> | undefined;
@@ -100,7 +104,7 @@ export function useAgentRoom(enabled: boolean): AgentRoomHookResult {
client.current = api;
const presenter = createAgentRoomPresenter({ initial: current.current, reduceEvent: applyAgentEvent,
publish: (next, isTyping) => {
if (!active) return;
if (!active || sourceChanged) return;
outbox.current?.reconcile(next);
current.current = next;
setView({ room: next, isTyping });
@@ -108,35 +112,36 @@ export function useAgentRoom(enabled: boolean): AgentRoomHookResult {
});
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),
const readSession = createAgentReadSession({
initial: current.current,
save: (cursor, sourceId) => api.markRead(cursor, controller.signal, sourceId),
invalidated: failure => {
sourceChanged = true; sourceMismatch.current = true; mark.current = null;
clearTimeout(reconnectTimer); clearTimeout(startupTimer); clearTimeout(socketTimeout);
presenter.dispose(); controller.abort(); socket?.close();
setError(failure.message); setConnection("offline"); setLoading(false);
},
apply: read => {
acknowledgedReadCursor = advanceAgentCursor(acknowledgedReadCursor, read.cursor);
if (active) presenter.update(state => applyAgentReadState(state, read));
void syncAgentNotificationRead(read);
if (active && !sourceChanged) presenter.update(state => applyAgentReadState(state, read));
},
});
mark.current = cursor => { void readWriter.request(cursor).catch(reportError); };
mark.current = (cursor, sourceId) => { void readSession.request(cursor, sourceId).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));
if (active && !sourceChanged) readSession.receive(read);
};
const catchUp = async (replay = false) => syncAgentHistory(api, current.current.cursor, page => {
if (active) presenter.push({ type: "messages", ...page }, { replay });
if (active && !sourceChanged) presenter.push({ type: "messages", ...page }, { replay });
}, controller.signal);
const refreshActivity = async () => {
const descriptor = await api.descriptor(controller.signal);
if (active) { setAttachmentCapability(descriptor.capabilities?.attachments?.version === 1 ? descriptor.capabilities.attachments : undefined); presenter.push({ type: "activity", conversationId: descriptor.id, activity: descriptor.activity }); }
if (active && !sourceChanged) { setAttachmentCapability(descriptor.capabilities?.attachments?.version === 1 ? descriptor.capabilities.attachments : undefined); presenter.push({ type: "activity", conversationId: descriptor.id, activity: descriptor.activity }); }
};
const connect = () => {
if (!active || authFailed) return;
if (!active || authFailed || sourceChanged) return;
clearTimeout(reconnectTimer); clearTimeout(socketTimeout);
setConnection(reconnectAttempt ? "reconnecting" : "connecting");
socketAuthenticated = false;
@@ -144,12 +149,12 @@ export function useAgentRoom(enabled: boolean): AgentRoomHookResult {
socket = next;
socketTimeout = setTimeout(() => { if (!socketAuthenticated) next.close(); }, 12_000);
next.onopen = () => {
if (!active || socket !== next) { next.close(); return; }
if (!active || sourceChanged || socket !== next) { next.close(); return; }
lastPacket = Date.now();
next.send(agentAuthFrame(getAuthToken(), current.current.cursor));
next.send(agentAuthFrame(getAuthToken(), current.current.cursor, getAgentPresenceSessionId()));
};
next.onmessage = ({ data }) => {
if (!active || socket !== next) return;
if (!active || sourceChanged || socket !== next) return;
lastPacket = Date.now();
try {
const event = parseAgentRoomEvent(String(data));
@@ -159,6 +164,9 @@ export function useAgentRoom(enabled: boolean): AgentRoomHookResult {
clearTimeout(socketTimeout); socketAuthenticated = true; reconnectAttempt = 0;
setConnection("connected"); setError(null); return;
}
if (event.type === "read_state") {
readSession.receive(event); return;
}
presenter.push(event);
} catch (failure) {
reportError(failure);
@@ -167,9 +175,9 @@ export function useAgentRoom(enabled: boolean): AgentRoomHookResult {
next.close();
}
};
next.onerror = () => { if (active) setConnection("reconnecting"); };
next.onerror = () => { if (active && !sourceChanged) setConnection("reconnecting"); };
next.onclose = event => {
if (!active || socket !== next) return;
if (!active || sourceChanged || 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;
@@ -186,21 +194,21 @@ export function useAgentRoom(enabled: boolean): AgentRoomHookResult {
// 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;
if (!active || sourceChanged) return;
presenter.flush();
started = true; setLoading(false); connect();
} catch (failure) {
if (!active) return;
if (!active || sourceChanged) return;
reportError(failure); setLoading(false); setConnection("offline");
startupTimer = setTimeout(() => { void start(); }, 10_000);
}
};
const maintain = async (foreground = false) => {
if (!active || !started || maintaining || authFailed) return;
if (!active || sourceChanged || !started || maintaining || authFailed) return;
maintaining = true;
try {
await refreshRead();
await readWriter.flush();
await readSession.flush();
if (!socketAuthenticated) {
await Promise.all([catchUp(), refreshActivity()]);
} else if (foreground) await catchUp();
@@ -217,7 +225,7 @@ export function useAgentRoom(enabled: boolean): AgentRoomHookResult {
document.addEventListener("visibilitychange", focus);
void start();
return () => {
active = false; controller.abort();
active = false; readSession.cancel(); controller.abort();
presenter.dispose();
clearInterval(interval); clearTimeout(reconnectTimer); clearTimeout(startupTimer); clearTimeout(socketTimeout);
window.removeEventListener("focus", focus); document.removeEventListener("visibilitychange", focus);
@@ -228,12 +236,13 @@ export function useAgentRoom(enabled: boolean): AgentRoomHookResult {
}, [enabled, revision, update]);
const renderedCursor = room.cursor;
const renderedSourceId = room.sourceId;
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]);
mark.current?.(renderedCursor, renderedSourceId);
}, [enabled, isLoading, renderedCursor, renderedSourceId]);
const sendMessage = useCallback(async (content: string, attachments?: AgentAttachment[], onPersisted?: () => void) => {
if (!enabled || !outbox.current) throw new Error("Agent connection is unavailable.");
await outbox.current.send(content, attachments, () => { uploadQueue.current!.clear(); onPersisted?.(); });
@@ -250,7 +259,10 @@ export function useAgentRoom(enabled: boolean): AgentRoomHookResult {
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 retry = useCallback(() => {
if (sourceMismatch.current) { window.location.reload(); return; }
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,
attachmentCapability, uploadQueue: uploadQueue.current!, uploadAttachment, downloadAttachment, isTyping: enabled && view.isTyping, sendMessage, retryMessage, markRead, retry };
+16 -1
View File
@@ -1,5 +1,6 @@
import { useEffect, useState } from "preact/hooks";
import { getConfiguredToken, setAuthToken, verifySession } from "@/lib/api";
import { revokeAgentBrowserPush, startAgentBrowserSession } from "@/lib/agent-push-browser";
const TOKEN_STORAGE_KEY = "sybil_admin_token";
@@ -47,6 +48,7 @@ export function useSessionAuth() {
};
const handleAuthFailure = (message: string | null) => {
void revokeAgentBrowserPush();
setIsAuthenticated(false);
setAuthMode(null);
setAuthError(message ? normalizeAuthError(message) : null);
@@ -69,7 +71,8 @@ export function useSessionAuth() {
}
};
const logout = () => {
const logout = async () => {
await revokeAgentBrowserPush();
setAuthToken(null);
persistToken(null);
setIsAuthenticated(false);
@@ -77,6 +80,18 @@ export function useSessionAuth() {
setAuthError(null);
};
useEffect(() => {
if (!isAuthenticated || authMode !== "token") return;
return startAgentBrowserSession();
}, [isAuthenticated, authMode]);
useEffect(() => {
// Signing out in one tab retires the others instead of using a stale token.
const signedOut = (event: StorageEvent) => { if (event.key === TOKEN_STORAGE_KEY && !event.newValue) handleAuthFailure(null); };
window.addEventListener("storage", signedOut);
return () => window.removeEventListener("storage", signedOut);
}, []);
useEffect(() => {
const token = readStoredToken() ?? getConfiguredToken();
void (async () => {