web: open Agent at recent history and load older pages on demand

This commit is contained in:
2026-10-16 21:18:15 -07:00
parent 671ee98ab1
commit 7b48201e28
9 changed files with 490 additions and 28 deletions
+42 -15
View File
@@ -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}