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,120 @@
|
||||
import type { AgentRoomEvent, AgentRoomState } from './agent-room';
|
||||
import type { Message } from './api';
|
||||
|
||||
type Scheduler = {
|
||||
now(): number;
|
||||
schedule(callback: () => void, delay: number): unknown;
|
||||
cancel(handle: unknown): void;
|
||||
};
|
||||
export type AgentRoomPresenterOptions = {
|
||||
initial: AgentRoomState;
|
||||
reduceEvent(state: AgentRoomState, event: AgentRoomEvent): AgentRoomState;
|
||||
/** Publish room and transcript typing together in one UI state update. */
|
||||
publish(state: AgentRoomState, isTyping: boolean): void;
|
||||
batchMs?: number;
|
||||
typingDelayMs?: number;
|
||||
clock?: Scheduler;
|
||||
};
|
||||
const quietTools = new Set(['user_message', 'notify_parent', 'clock_wait', 'event_decision']);
|
||||
const data = (message: Message): Record<string, unknown> => message.metadata && typeof message.metadata === 'object'
|
||||
&& !Array.isArray(message.metadata) ? message.metadata as Record<string, unknown> : {};
|
||||
const entryId = (message: Message): bigint | undefined => {
|
||||
const value = data(message).entryId;
|
||||
return (typeof value === 'string' && /^\d+$/.test(value)) || (typeof value === 'number' && Number.isSafeInteger(value) && value >= 0)
|
||||
? BigInt(value) : undefined;
|
||||
};
|
||||
const toolId = (message: Message) => typeof data(message).toolCallId === 'string' ? String(data(message).toolCallId) : message.id;
|
||||
|
||||
/**
|
||||
* Pi sends messages and activity as separate frames. This adapter publishes them
|
||||
* as one room snapshot and latches transcript typing off when a new public reply
|
||||
* arrives. An unchanged busy frame cannot restore the old turn's indicator.
|
||||
* Actual activity remains untouched for the sidebar, workers, and queue display.
|
||||
*/
|
||||
export function createAgentRoomPresenter(options: AgentRoomPresenterOptions) {
|
||||
const clock: Scheduler = options.clock || {
|
||||
now: () => Date.now(),
|
||||
schedule: (callback, delay) => setTimeout(callback, delay),
|
||||
cancel: handle => clearTimeout(handle as ReturnType<typeof setTimeout>),
|
||||
};
|
||||
const batchMs = options.batchMs ?? 100;
|
||||
const typingDelayMs = options.typingDelayMs ?? 250;
|
||||
let state = options.initial;
|
||||
let stopped = false;
|
||||
let batchTimer: unknown;
|
||||
let typingTimer: unknown;
|
||||
let suppressTyping = false;
|
||||
let candidateSince: number | undefined;
|
||||
const seenTools = new Set(state.messages.filter(message => message.role === 'tool').map(toolId));
|
||||
for (const tool of state.activity?.tools || []) seenTools.add(tool.id);
|
||||
|
||||
const reconcile = () => {
|
||||
const work = Boolean(state.activity?.isThinking || (state.activity?.backgroundWorkers || 0) > 0);
|
||||
if (!work || suppressTyping) candidateSince = undefined;
|
||||
else if (candidateSince === undefined) candidateSince = clock.now();
|
||||
};
|
||||
const clearTypingTimer = () => { if (typingTimer !== undefined) clock.cancel(typingTimer); typingTimer = undefined; };
|
||||
const flush = () => {
|
||||
if (stopped) return;
|
||||
if (batchTimer !== undefined) clock.cancel(batchTimer);
|
||||
batchTimer = undefined; clearTypingTimer();
|
||||
reconcile();
|
||||
const isTyping = candidateSince !== undefined && clock.now() - candidateSince >= typingDelayMs;
|
||||
options.publish(state, isTyping);
|
||||
if (candidateSince !== undefined && !isTyping) typingTimer = clock.schedule(flush, Math.max(0, typingDelayMs - (clock.now() - candidateSince)));
|
||||
};
|
||||
const schedule = () => {
|
||||
reconcile();
|
||||
// A fixed maximum batch window avoids starving publication during bursts.
|
||||
if (batchTimer === undefined) batchTimer = clock.schedule(flush, batchMs);
|
||||
};
|
||||
const release = () => { suppressTyping = false; };
|
||||
const latch = () => {
|
||||
suppressTyping = true;
|
||||
candidateSince = undefined;
|
||||
clearTypingTimer();
|
||||
};
|
||||
|
||||
return {
|
||||
/** replay:true is for initial historical loading, not ongoing catch-up. */
|
||||
push(event: AgentRoomEvent, hints: { replay?: boolean } = {}) {
|
||||
if (stopped) return;
|
||||
const before = state;
|
||||
const next = options.reduceEvent(before, event); // Validate/reduce before touching presentation state.
|
||||
state = next;
|
||||
if (event.type === 'messages') {
|
||||
const existing = new Set(before.messages.map(message => message.id));
|
||||
const fresh = event.messages.filter(message => !existing.has(message.id) && (entryId(message) ?? -1n) > BigInt(before.cursor))
|
||||
.sort((left, right) => (entryId(left) || 0n) < (entryId(right) || 0n) ? -1 : 1);
|
||||
if (!hints.replay) for (const message of fresh) {
|
||||
const info = data(message);
|
||||
if (message.role === 'assistant' && info.kind === 'message' && message.content.trim()) latch();
|
||||
else if (suppressTyping && message.role === 'user') release();
|
||||
else if (suppressTyping && message.role === 'tool' && info.status === 'initiated'
|
||||
&& !quietTools.has(String(info.toolName || message.name)) && !seenTools.has(toolId(message))) release();
|
||||
if (message.role === 'tool') seenTools.add(toolId(message));
|
||||
}
|
||||
for (const message of event.messages) if (message.role === 'tool') seenTools.add(toolId(message));
|
||||
} else if (event.type === 'activity') {
|
||||
const activity = event.activity;
|
||||
if (suppressTyping && (!activity.isThinking
|
||||
|| activity.tools.some(tool => !seenTools.has(tool.id) && !quietTools.has(tool.name)))) release();
|
||||
for (const tool of activity.tools) seenTools.add(tool.id);
|
||||
}
|
||||
schedule();
|
||||
},
|
||||
/** Read-position updates share the pending snapshot; they never lose queued frames. */
|
||||
update(change: (state: AgentRoomState) => AgentRoomState) {
|
||||
if (stopped) return;
|
||||
state = change(state); schedule();
|
||||
},
|
||||
flush,
|
||||
dispose() {
|
||||
stopped = true;
|
||||
if (batchTimer !== undefined) clock.cancel(batchTimer);
|
||||
batchTimer = undefined; clearTypingTimer();
|
||||
// Unpublished input is deliberately discarded; reconnect replays from the
|
||||
// last published cursor, so neither delivery nor read tracking gets ahead.
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user