web: open Agent at recent history and load older pages on demand
This commit is contained in:
+3
-3
@@ -3554,7 +3554,7 @@ export default function App() {
|
||||
type="button"
|
||||
data-testid="agent-sidebar-item"
|
||||
aria-current={isAgentMode ? "page" : undefined}
|
||||
aria-label={`Sybil Agent${agentRoom.unreadCount ? `, ${agentRoom.unreadCount} unread messages` : ""}${isAgentBusy ? ", working" : ""}`}
|
||||
aria-label={`Sybil Agent${agentRoom.unreadCount ? `, ${agentRoom.unreadCount}${agentRoom.unreadCountIsPartial ? " or more" : ""} unread messages` : agentRoom.unreadCountIsPartial ? ", earlier messages may be unread" : ""}${isAgentBusy ? ", working" : ""}`}
|
||||
onClick={() => {
|
||||
setContextMenu(null);
|
||||
setError(null);
|
||||
@@ -3580,9 +3580,9 @@ export default function App() {
|
||||
<span className="block truncate text-sm font-semibold">Sybil Agent</span>
|
||||
</span>
|
||||
{isAgentBusy ? <LoaderCircle className="h-4 w-4 shrink-0 animate-spin text-cyan-200" aria-label="Agent working" /> : null}
|
||||
{agentRoom.unreadCount > 0 ? (
|
||||
{agentRoom.unreadCount > 0 || agentRoom.unreadCountIsPartial ? (
|
||||
<span data-testid="agent-unread-badge" className="flex h-5 min-w-5 shrink-0 items-center justify-center rounded-full bg-cyan-200 px-1.5 text-[11px] font-bold tabular-nums text-slate-950">
|
||||
{agentRoom.unreadCount}
|
||||
{agentRoom.unreadCount ? `${agentRoom.unreadCount}${agentRoom.unreadCountIsPartial ? "+" : ""}` : "•"}
|
||||
</span>
|
||||
) : null}
|
||||
</button>
|
||||
|
||||
@@ -45,6 +45,32 @@
|
||||
.agent-room__entry {
|
||||
flex-shrink:0;
|
||||
min-width:0;
|
||||
/* Retain chronological DOM/selection/a11y; skip offscreen layout and paint.
|
||||
This is not DOM virtualization. Remember measured heights when skipped. */
|
||||
content-visibility:auto;
|
||||
contain-intrinsic-size:auto 160px;
|
||||
}
|
||||
|
||||
.agent-room__history {
|
||||
display:grid;
|
||||
justify-items:center;
|
||||
gap:8px;
|
||||
flex-shrink:0;
|
||||
color:hsl(255 19% 70%);
|
||||
font-size:12px;
|
||||
}
|
||||
|
||||
.agent-room__history button {
|
||||
border:1px solid hsl(267 75% 73%/.2);
|
||||
border-radius:8px;
|
||||
padding:8px 14px;
|
||||
background:hsl(261 52% 20%/.56);
|
||||
cursor:pointer;
|
||||
}
|
||||
|
||||
.agent-room__history button:disabled {
|
||||
cursor:wait;
|
||||
opacity:.65;
|
||||
}
|
||||
|
||||
.agent-room__day {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "preact/hooks";
|
||||
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "preact/hooks";
|
||||
import { memo } from "preact/compat";
|
||||
import { Clock3, LoaderCircle, Menu, Paperclip, X, RefreshCw, SendHorizontal, WifiOff } from "lucide-preact";
|
||||
import type { Message } from "@/lib/api";
|
||||
import type { AgentRoomHookResult } from "@/hooks/use-agent-room";
|
||||
@@ -49,7 +50,7 @@ function toolName(value: unknown) {
|
||||
return typeof value === "string" ? value.replace(/_/g, " ") : "Tool";
|
||||
}
|
||||
|
||||
function AgentBubble({ message, animate, onRetry, download }: { message: Message; animate: boolean; onRetry: (id: string) => void; download: AgentRoomHookResult["downloadAttachment"] }) {
|
||||
const AgentBubble = memo(function AgentBubble({ message, animate, onRetry, download }: { message: Message; animate: boolean; onRetry: (id: string) => void; download: AgentRoomHookResult["downloadAttachment"] }) {
|
||||
const own = message.role === "user";
|
||||
const info = metadata(message);
|
||||
const status = typeof info.status === "string" ? info.status : "complete";
|
||||
@@ -77,7 +78,7 @@ function AgentBubble({ message, animate, onRetry, download }: { message: Message
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
export function AgentConversation({ room, onOpenSidebar, isVisible = true }: Props) {
|
||||
const [draft, setDraft] = useState(savedDraft);
|
||||
@@ -98,6 +99,7 @@ export function AgentConversation({ room, onOpenSidebar, isVisible = true }: Pro
|
||||
const entryAnimationsRef = useRef(new Set<Animation>());
|
||||
const typingHeightRef = useRef(0);
|
||||
const historyReadyRef = useRef(!room.isLoading);
|
||||
const historyBeforeRef = useRef(room.historyBeforeCursor);
|
||||
const scrollerRef = useRef<HTMLDivElement>(null);
|
||||
const timelineRef = useRef<HTMLDivElement>(null);
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
@@ -130,9 +132,20 @@ export function AgentConversation({ room, onOpenSidebar, isVisible = true }: Pro
|
||||
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;
|
||||
// Find the first visible row without laying out every offscreen message.
|
||||
const entries = element.querySelectorAll<HTMLElement>("[data-agent-entry-id]");
|
||||
// Probe row boxes without measuring every skipped subtree in a long history.
|
||||
let low = 0, high = entries.length;
|
||||
while (low < high) {
|
||||
const mid = (low + high) >>> 1;
|
||||
if (entries[mid].getBoundingClientRect().bottom <= top + 8) low = mid + 1;
|
||||
else high = mid;
|
||||
}
|
||||
const node = entries[low];
|
||||
// The first row can lose its day separator when older rows arrive. Anchor
|
||||
// the message itself so that changing that separator cannot shift its text.
|
||||
const message = node?.querySelector<HTMLElement>("[data-agent-message-id]");
|
||||
anchorRef.current = node && message ? { id: node.dataset.agentEntryId!, offset: message.getBoundingClientRect().top - top } : null;
|
||||
};
|
||||
const restoreViewport = () => {
|
||||
const element = scrollerRef.current;
|
||||
@@ -140,11 +153,14 @@ export function AgentConversation({ room, onOpenSidebar, isVisible = true }: Pro
|
||||
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;
|
||||
const node = element.querySelector<HTMLElement>(`[data-agent-entry-id="${CSS.escape(anchor.id)}"]`);
|
||||
const message = node?.querySelector<HTMLElement>("[data-agent-message-id]");
|
||||
if (message) element.scrollTop += message.getBoundingClientRect().top - element.getBoundingClientRect().top - anchor.offset;
|
||||
}
|
||||
restoredScrollTopRef.current = element.scrollTop;
|
||||
rememberAnchor();
|
||||
// Keep the user's key/offset through successive intrinsic-size corrections.
|
||||
// Recapturing here can anchor a newly revealed older row while its neighbors
|
||||
// are still being measured, causing the original reading position to drift.
|
||||
scheduleRead();
|
||||
};
|
||||
const jumpToLatest = () => {
|
||||
@@ -195,7 +211,7 @@ export function AgentConversation({ room, onOpenSidebar, isVisible = true }: Pro
|
||||
|
||||
useLayoutEffect(() => {
|
||||
restoreViewport();
|
||||
}, [room.messages, room.isTyping, room.activity?.state, room.isLoading, isVisible]);
|
||||
}, [room.messages, room.isTyping, room.activity?.state, room.isLoading, room.isLoadingOlder, room.olderError, room.hasOlder, isVisible]);
|
||||
|
||||
useEffect(() => {
|
||||
mountedRef.current = true;
|
||||
@@ -229,10 +245,10 @@ export function AgentConversation({ room, onOpenSidebar, isVisible = true }: Pro
|
||||
setDraft(value);
|
||||
};
|
||||
|
||||
const retryMessage = (id: string) => {
|
||||
const retryMessage = useCallback((id: string) => {
|
||||
// Admission errors remain attached to their own bubble.
|
||||
void room.retryMessage(id).catch(() => {});
|
||||
};
|
||||
}, [room.retryMessage]);
|
||||
const submit = () => {
|
||||
const content = draftRef.current.trim();
|
||||
if ((!content && !uploads.length) || pendingUploads || (uploads.length && !room.attachmentCapability)) return;
|
||||
@@ -245,9 +261,14 @@ export function AgentConversation({ room, onOpenSidebar, isVisible = true }: Pro
|
||||
|
||||
const messages = useMemo(() => {
|
||||
const transcript = agentTranscriptMessages(room.messages);
|
||||
const historyChanged = historyBeforeRef.current !== room.historyBeforeCursor;
|
||||
historyBeforeRef.current = room.historyBeforeCursor;
|
||||
let previousDay = "";
|
||||
for (const message of transcript) {
|
||||
if (!seenMessageIdsRef.current.has(message.id) && historyReadyRef.current && !room.isLoading) {
|
||||
let lastSeen = -1;
|
||||
transcript.forEach((message, index) => { if (seenMessageIdsRef.current.has(message.id)) lastSeen = index; });
|
||||
for (const [index, message] of transcript.entries()) {
|
||||
// Historical prepends keep their measured space; only new tail rows enter.
|
||||
if (index > lastSeen && !(historyChanged && lastSeen < 0) && !seenMessageIdsRef.current.has(message.id) && historyReadyRef.current && !room.isLoading) {
|
||||
enteringMessageIdsRef.current.add(message.id);
|
||||
}
|
||||
seenMessageIdsRef.current.add(message.id);
|
||||
@@ -263,7 +284,7 @@ export function AgentConversation({ room, onOpenSidebar, isVisible = true }: Pro
|
||||
</div>
|
||||
);
|
||||
});
|
||||
}, [room.messages, room.isLoading, room.retryMessage, room.downloadAttachment]);
|
||||
}, [room.messages, room.isLoading, room.historyBeforeCursor, room.retryMessage, room.downloadAttachment]);
|
||||
|
||||
const activity = room.activity;
|
||||
const running = room.isTyping;
|
||||
@@ -309,6 +330,12 @@ export function AgentConversation({ room, onOpenSidebar, isVisible = true }: Pro
|
||||
>
|
||||
<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}
|
||||
{room.hasOlder ? <div className="agent-room__history">
|
||||
<button type="button" disabled={room.isLoadingOlder} onClick={() => {
|
||||
pinnedRef.current = false; rememberAnchor(); room.loadOlder();
|
||||
}}>{room.isLoadingOlder ? "Loading older messages…" : "Load older messages"}</button>
|
||||
{room.olderError ? <span role="alert">{room.olderError} Try loading again.</span> : null}
|
||||
</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}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "preact/hooks"
|
||||
import { getApiBaseUrl, getAuthToken, type Message } from "@/lib/api";
|
||||
import {
|
||||
AGENT_ADMISSION_STORAGE_KEY, AgentRoomClient, agentAuthFrame, agentDisplayMessages, agentUnreadCount,
|
||||
applyAgentEvent, applyAgentReadState,
|
||||
applyAgentEvent, applyAgentReadState, applyAgentHistoryPage, agentUnreadCountIsPartial,
|
||||
createAgentReadSession, createAgentRoomState, parseAgentRoomEvent, syncAgentHistory,
|
||||
type AgentActivity, type AgentRoomState,
|
||||
} from "@/lib/agent-room";
|
||||
@@ -16,6 +16,7 @@ export type AgentRoomConnection = "connecting" | "connected" | "reconnecting" |
|
||||
export type AgentRoomHookResult = {
|
||||
messages: Message[]; activity: AgentActivity | null; unreadCount: number;
|
||||
connection: AgentRoomConnection; error: string | null; isLoading: boolean; isTyping: boolean;
|
||||
hasOlder: boolean; historyBeforeCursor: string | null; isLoadingOlder: boolean; olderError: string | null; loadOlder(): void; unreadCountIsPartial: boolean;
|
||||
attachmentCapability?: AgentAttachmentCapability;
|
||||
uploadAttachment(file: File, uploadId: string, signal: AbortSignal): Promise<AgentAttachment>;
|
||||
downloadAttachment(file: AgentAttachment, signal?: AbortSignal): Promise<Blob>;
|
||||
@@ -33,6 +34,9 @@ export function useAgentRoom(enabled: boolean): AgentRoomHookResult {
|
||||
const [attachmentCapability, setAttachmentCapability] = useState<AgentAttachmentCapability>();
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isLoading, setLoading] = useState(enabled);
|
||||
const [isLoadingOlder, setLoadingOlder] = useState(false);
|
||||
const [olderError, setOlderError] = useState<string | null>(null);
|
||||
const older = useRef<(() => Promise<void>) | null>(null);
|
||||
const [revision, setRevision] = useState(0);
|
||||
const client = useRef<AgentRoomClient | null>(null);
|
||||
const [, renderUploads] = useState(0);
|
||||
@@ -81,9 +85,10 @@ export function useAgentRoom(enabled: boolean): AgentRoomHookResult {
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
setLoadingOlder(false); setOlderError(null);
|
||||
if (!enabled) {
|
||||
sourceMismatch.current = false;
|
||||
client.current = null; mark.current = null; setAttachmentCapability(undefined);
|
||||
client.current = null; mark.current = null; older.current = null; setAttachmentCapability(undefined);
|
||||
update(() => createAgentRoomState()); setLoading(false); setConnection("offline"); setError(null);
|
||||
return;
|
||||
}
|
||||
@@ -96,6 +101,7 @@ export function useAgentRoom(enabled: boolean): AgentRoomHookResult {
|
||||
let reconnectAttempt = 0;
|
||||
let started = false;
|
||||
let maintaining = false;
|
||||
let loadingOlder = false;
|
||||
let lastPacket = Date.now();
|
||||
let socketAuthenticated = false;
|
||||
let authFailed = false;
|
||||
@@ -110,7 +116,7 @@ export function useAgentRoom(enabled: boolean): AgentRoomHookResult {
|
||||
setView({ room: next, isTyping });
|
||||
},
|
||||
});
|
||||
setLoading(current.current.conversationId === null); setConnection("connecting"); setError(null);
|
||||
setLoading(!current.current.historyLoaded); setConnection("connecting"); setError(null);
|
||||
const reportError = (failure: unknown) => { if (active && !controller.signal.aborted) setError(errorText(failure)); };
|
||||
const readSession = createAgentReadSession({
|
||||
initial: current.current,
|
||||
@@ -119,6 +125,7 @@ export function useAgentRoom(enabled: boolean): AgentRoomHookResult {
|
||||
sourceChanged = true; sourceMismatch.current = true; mark.current = null;
|
||||
clearTimeout(reconnectTimer); clearTimeout(startupTimer); clearTimeout(socketTimeout);
|
||||
presenter.dispose(); controller.abort(); socket?.close();
|
||||
older.current = null; setLoadingOlder(false);
|
||||
setError(failure.message); setConnection("offline"); setLoading(false);
|
||||
},
|
||||
apply: read => {
|
||||
@@ -139,6 +146,22 @@ export function useAgentRoom(enabled: boolean): AgentRoomHookResult {
|
||||
const descriptor = await api.descriptor(controller.signal);
|
||||
if (active && !sourceChanged) { setAttachmentCapability(descriptor.capabilities?.attachments?.version === 1 ? descriptor.capabilities.attachments : undefined); presenter.push({ type: "activity", conversationId: descriptor.id, activity: descriptor.activity }); }
|
||||
};
|
||||
older.current = async () => {
|
||||
const before = current.current.beforeCursor;
|
||||
if (!active || sourceChanged || !started || loadingOlder || !current.current.hasOlder || before === null) return;
|
||||
loadingOlder = true; setLoadingOlder(true); setOlderError(null);
|
||||
try {
|
||||
const page = await api.history(before, controller.signal);
|
||||
if (!active || sourceChanged || controller.signal.aborted) return;
|
||||
presenter.update(state => applyAgentHistoryPage(state, page, before));
|
||||
presenter.flush();
|
||||
} catch (failure) {
|
||||
if (active && !sourceChanged && !controller.signal.aborted) setOlderError(errorText(failure));
|
||||
} finally {
|
||||
loadingOlder = false;
|
||||
if (active && !sourceChanged) setLoadingOlder(false);
|
||||
}
|
||||
};
|
||||
|
||||
const connect = () => {
|
||||
if (!active || authFailed || sourceChanged) return;
|
||||
@@ -191,9 +214,11 @@ export function useAgentRoom(enabled: boolean): AgentRoomHookResult {
|
||||
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 (!current.current.historyLoaded) {
|
||||
const page = await api.history(undefined, controller.signal);
|
||||
if (!active || sourceChanged || controller.signal.aborted) return;
|
||||
presenter.update(state => applyAgentHistoryPage(state, page));
|
||||
} else await catchUp(!started);
|
||||
if (!active || sourceChanged) return;
|
||||
presenter.flush();
|
||||
started = true; setLoading(false); connect();
|
||||
@@ -231,18 +256,19 @@ export function useAgentRoom(enabled: boolean): AgentRoomHookResult {
|
||||
window.removeEventListener("focus", focus); document.removeEventListener("visibilitychange", focus);
|
||||
socket?.close();
|
||||
if (client.current === api) client.current = null;
|
||||
mark.current = null;
|
||||
mark.current = null; older.current = null;
|
||||
};
|
||||
}, [enabled, revision, update]);
|
||||
|
||||
const renderedCursor = room.cursor;
|
||||
const renderedSourceId = room.sourceId;
|
||||
const hasRenderedHistory = room.messages.some(message => message.role === "user" || message.role === "assistant");
|
||||
const markRead = useCallback(() => {
|
||||
if (!enabled || isLoading) return;
|
||||
if (!enabled || isLoading || !hasRenderedHistory) 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, renderedSourceId);
|
||||
}, [enabled, isLoading, renderedCursor, renderedSourceId]);
|
||||
}, [enabled, isLoading, hasRenderedHistory, 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?.(); });
|
||||
@@ -263,7 +289,9 @@ export function useAgentRoom(enabled: boolean): AgentRoomHookResult {
|
||||
if (sourceMismatch.current) { window.location.reload(); return; }
|
||||
setRevision(value => value + 1);
|
||||
}, []);
|
||||
const loadOlder = useCallback(() => { void older.current?.(); }, []);
|
||||
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,
|
||||
hasOlder: room.hasOlder, historyBeforeCursor: room.beforeCursor, isLoadingOlder, olderError, loadOlder, unreadCountIsPartial: agentUnreadCountIsPartial(room),
|
||||
attachmentCapability, uploadQueue: uploadQueue.current!, uploadAttachment, downloadAttachment, isTyping: enabled && view.isTyping, sendMessage, retryMessage, markRead, retry };
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@ export type AgentReadState = { cursor: string; updatedAt: string | null; sourceI
|
||||
export type AgentRoomState = {
|
||||
conversationId: string | null; messages: Message[]; cursor: string;
|
||||
readCursor: string; activity: AgentActivity | null;
|
||||
historyLoaded: boolean; beforeCursor: string | null; hasOlder: boolean;
|
||||
/** undefined before the first read snapshot; null pins a legacy source-less session. */
|
||||
sourceId?: string | null;
|
||||
};
|
||||
@@ -84,6 +85,7 @@ export function mergeAgentMessages(previous: readonly Message[], incoming: reado
|
||||
}
|
||||
export const createAgentRoomState = (): AgentRoomState => ({
|
||||
conversationId: null, messages: [], cursor: "0", readCursor: "0", activity: null,
|
||||
historyLoaded: false, beforeCursor: null, hasOlder: false,
|
||||
});
|
||||
export class AgentReadSourceChangedError extends Error {
|
||||
constructor() { super("Agent history changed. Reload the page before marking messages read."); this.name = "AgentReadSourceChangedError"; }
|
||||
@@ -102,6 +104,25 @@ export function applyAgentPage(state: AgentRoomState, page: AgentMessagesPage):
|
||||
const cursor = page.cursor === null ? state.cursor : advanceAgentCursor(state.cursor, page.cursor);
|
||||
return { ...state, conversationId: page.conversationId, messages: mergeAgentMessages(state.messages, page.messages), cursor };
|
||||
}
|
||||
/** Recent/older windows have their own boundary; live replay never moves it. */
|
||||
export function applyAgentHistoryPage(state: AgentRoomState, page: AgentMessagesPage, before?: string): AgentRoomState {
|
||||
// A retired or repeated older request cannot overwrite a newer window.
|
||||
if (before !== undefined && before !== state.beforeCursor) return state;
|
||||
const boundary = page.beforeCursor === null ? null : normalizeAgentCursor(page.beforeCursor);
|
||||
const cursor = page.cursor === null ? null : normalizeAgentCursor(page.cursor);
|
||||
if (page.hasMore && (boundary === null || boundary === "0"
|
||||
|| (before !== undefined && BigInt(boundary) >= BigInt(before)))) throw new Error("Agent older history did not advance its cursor.");
|
||||
if (before !== undefined && ((cursor !== null && BigInt(cursor) >= BigInt(before))
|
||||
|| (boundary !== null && BigInt(boundary) > BigInt(before)))) throw new Error("Agent older history returned an invalid boundary.");
|
||||
const next = applyAgentPage(state, page);
|
||||
return { ...next, historyLoaded: true, beforeCursor: boundary, hasOlder: page.hasMore,
|
||||
// Older snapshots must not replace newer socket edits or advance delivery/read state.
|
||||
...(before === undefined ? {} : { cursor: state.cursor, messages: mergeAgentMessages(page.messages, state.messages) }),
|
||||
};
|
||||
}
|
||||
export function agentUnreadCountIsPartial(state: AgentRoomState): boolean {
|
||||
return state.hasOlder && state.beforeCursor !== null && BigInt(state.readCursor) + 1n < BigInt(state.beforeCursor);
|
||||
}
|
||||
export function applyAgentReadState(state: AgentRoomState, read: AgentReadState): AgentRoomState {
|
||||
const sourceId = readSource(state, read);
|
||||
return { ...state, sourceId, readCursor: advanceAgentCursor(state.readCursor, read.cursor) };
|
||||
@@ -178,6 +199,9 @@ export class AgentRoomClient {
|
||||
messages(after: string, signal?: AbortSignal): Promise<AgentMessagesPage> {
|
||||
return this.request(`/messages?${new URLSearchParams({ after: normalizeAgentCursor(after), limit: "200" })}`, { signal });
|
||||
}
|
||||
history(before?: string, signal?: AbortSignal): Promise<AgentMessagesPage> {
|
||||
return this.request(`/messages?${new URLSearchParams({ ...(before === undefined ? {} : { before: normalizeAgentCursor(before) }), limit: "100" })}`, { signal });
|
||||
}
|
||||
readState(signal?: AbortSignal): Promise<AgentReadState> { return this.request("/read-state", { signal }); }
|
||||
markRead(cursor: string, signal?: AbortSignal, sourceId?: string | null): Promise<AgentReadState> {
|
||||
return this.request("/read-state", { method: "PUT", body: JSON.stringify({ cursor: normalizeAgentCursor(cursor), ...(agentPushSourceId(sourceId) ? { sourceId } : {}) }), signal });
|
||||
|
||||
Reference in New Issue
Block a user