Load the lead's scrollback 50 turns at a time

This commit is contained in:
Ian Gulliver
2026-10-16 18:11:00 -07:00
parent 5dddc15ab9
commit 859aa2dd8b
20 changed files with 625 additions and 238 deletions
+79 -6
View File
@@ -24,6 +24,7 @@ export type ChatSource = {
transcriptTopic: Topic;
streamTopic: Topic;
readyTopic: Topic;
older?: { has: () => boolean; load: () => Promise<ByreEvent[]> };
empty: string;
canSend: (sessionState: string) => boolean;
placeholder: string;
@@ -287,11 +288,17 @@ export function mountChat(root: HTMLElement, src: ChatSource, makeHead: (rerende
const scanner = h("div", { class: "scanner", hidden: true });
const loading = h("div", { class: "empty" }, "loading…");
const earlier = h("div");
let sink: DocumentFragment | undefined;
const setRunning = (running: boolean) => {
transcript.classList.toggle("running", running);
scanner.hidden = !running;
};
const putBefore = (el: HTMLElement, anchor: HTMLElement | undefined) => {
if (sink) {
sink.append(el);
return;
}
if (anchor?.parentNode === transcript) {
transcript.insertBefore(el, anchor);
} else {
@@ -483,9 +490,8 @@ export function mountChat(root: HTMLElement, src: ChatSource, makeHead: (rerende
return handoff.summary;
};
const appendEvents = (events: ByreEvent[]) => {
const renderEvents = (events: ByreEvent[]): boolean => {
let contextChanged = false;
headImpl.onEvents?.(events);
for (const ev of events) {
const p = ev.payload as Record<string, unknown>;
const parent = parentOf(p);
@@ -500,12 +506,15 @@ export function mountChat(root: HTMLElement, src: ChatSource, makeHead: (rerende
claudeTaskStatus(p, ctx);
continue;
}
if ((ev.kind === "agent.message" && !parent) || ev.kind === "turn.ended" || ev.kind === "user.message") {
if (!sink && ((ev.kind === "agent.message" && !parent) || ev.kind === "turn.ended" || ev.kind === "user.message")) {
dropStream();
}
if (ev.kind === "user.message" && ev.actor !== "byre") {
if (!sink && ev.kind === "user.message" && ev.actor !== "byre") {
confirmPending(String(p["text"] ?? ""));
}
if (ev.kind === "session.context" && sink) {
continue;
}
if (ev.kind === "session.context" && ev.agent_session_id) {
const prev = context.get(ev.agent_session_id);
const window = Number(p["window"]) || prev?.window || 0;
@@ -590,6 +599,12 @@ export function mountChat(root: HTMLElement, src: ChatSource, makeHead: (rerende
}
put(el);
}
return contextChanged;
};
const appendEvents = (events: ByreEvent[]) => {
headImpl.onEvents?.(events);
const contextChanged = renderEvents(events);
syncRunning();
if (contextChanged) {
renderHead();
@@ -603,6 +618,58 @@ export function mountChat(root: HTMLElement, src: ChatSource, makeHead: (rerende
jump();
};
const prependEvents = (events: ByreEvent[]) => {
const live = ctx;
const fromBottom = transcript.scrollHeight - transcript.scrollTop;
const box = document.createDocumentFragment();
ctx = newCtx();
sink = box;
renderEvents(events);
sink = undefined;
ctx = live;
syncRunning();
fillTodoLinks(box, models[0]!);
earlier.after(box);
transcript.scrollTop = transcript.scrollHeight - fromBottom;
};
let disposed = false;
let fetching = false;
const loadOlder = async () => {
const older = src.older;
if (!older || fetching || !older.has()) {
return;
}
fetching = true;
replace(earlier, h("div", { class: "empty" }, "loading earlier…"));
try {
const events = await older.load();
replace(earlier);
if (!disposed) {
prependEvents(events);
}
} finally {
fetching = false;
replace(earlier);
}
watchTop();
};
const top = new IntersectionObserver(
(entries) => {
if (entries.some((e) => e.isIntersecting)) {
loadOlder().catch((err: unknown) => console.error("load earlier events", err));
}
},
{ root: transcript, rootMargin: "400px 0px 0px 0px" },
);
const watchTop = () => {
if (disposed) {
return;
}
top.unobserve(earlier);
top.observe(earlier);
};
const renderAll = () => {
stuck = true;
dropStream();
@@ -619,12 +686,13 @@ export function mountChat(root: HTMLElement, src: ChatSource, makeHead: (rerende
replace(transcript, h("div", { class: "empty" }, src.empty), scanner);
return;
}
replace(transcript, scanner);
replace(transcript, earlier, scanner);
if (src.loading()) {
transcript.prepend(loading);
}
appendEvents(src.events());
renderComposer();
watchTop();
};
const offs = [
@@ -635,7 +703,10 @@ export function mountChat(root: HTMLElement, src: ChatSource, makeHead: (rerende
}),
onEach([src.transcriptTopic], (fresh) => appendEvents(fresh as ByreEvent[])),
onEach([src.streamTopic], (ev) => appendDelta(ev as ByreEvent)),
onEach([src.readyTopic], () => loading.remove()),
onEach([src.readyTopic], () => {
loading.remove();
watchTop();
}),
onEach(["tasks", "sessions"], () => fillTodoLinks(transcript, models[0]!)),
on("jump", jump),
];
@@ -646,6 +717,8 @@ export function mountChat(root: HTMLElement, src: ChatSource, makeHead: (rerende
for (const off of offs) {
off();
}
disposed = true;
top.disconnect();
headImpl.dispose?.();
resizes.disconnect();
mutations.disconnect();