Keep the transcript pinned to the bottom as its content changes

This commit is contained in:
Ian Gulliver
2026-10-15 13:49:10 -07:00
parent cd0664e1e0
commit 8da51ea9ba
5 changed files with 49 additions and 29 deletions
+1 -1
View File
File diff suppressed because one or more lines are too long
+22 -13
View File
@@ -112,6 +112,20 @@ export function mountChat(root, src, makeHead) {
const head = h("header", { class: "task-header" }); const head = h("header", { class: "task-header" });
const transcript = h("div", { class: "transcript" }); const transcript = h("div", { class: "transcript" });
const composer = h("div", { class: "composer" }); const composer = h("div", { class: "composer" });
let stuck = true;
const pin = () => {
if (stuck) {
transcript.scrollTop = transcript.scrollHeight;
}
};
transcript.addEventListener("scroll", () => {
stuck = transcript.scrollHeight - transcript.scrollTop - transcript.clientHeight < 80;
});
transcript.addEventListener("load", pin, true);
const resizes = new ResizeObserver(pin);
resizes.observe(transcript);
const mutations = new MutationObserver(pin);
mutations.observe(transcript, { childList: true, subtree: true, characterData: true });
const visibleReports = new Map(); const visibleReports = new Map();
const acked = new Set(); const acked = new Set();
const ackVisible = () => { const ackVisible = () => {
@@ -171,11 +185,8 @@ export function mountChat(root, src, makeHead) {
if (running === !spinner.hidden) { if (running === !spinner.hidden) {
return; return;
} }
const pinned = transcript.scrollHeight - transcript.scrollTop - transcript.clientHeight < 80;
spinner.hidden = !running; spinner.hidden = !running;
if (pinned) { pin();
transcript.scrollTop = transcript.scrollHeight;
}
}; };
const putBefore = (el, anchor) => { const putBefore = (el, anchor) => {
if (anchor?.parentNode === transcript) { if (anchor?.parentNode === transcript) {
@@ -235,7 +246,8 @@ export function mountChat(root, src, makeHead) {
pending.push(entry); pending.push(entry);
ctx.run = undefined; ctx.run = undefined;
putBefore(el, spinner); putBefore(el, spinner);
transcript.scrollTop = transcript.scrollHeight; stuck = true;
pin();
return () => { return () => {
el.remove(); el.remove();
pending = pending.filter((p) => p !== entry); pending = pending.filter((p) => p !== entry);
@@ -299,12 +311,9 @@ export function mountChat(root, src, makeHead) {
return; return;
} }
stream.timer = 0; stream.timer = 0;
const pinned = transcript.scrollHeight - transcript.scrollTop - transcript.clientHeight < 80;
stream.el.hidden = quietReply.startsWith(stream.text.trim()); stream.el.hidden = quietReply.startsWith(stream.text.trim());
replace(stream.el, markdown(stream.text, "text md", { taskId: ctx.taskId })); replace(stream.el, markdown(stream.text, "text md", { taskId: ctx.taskId }));
if (pinned) { pin();
transcript.scrollTop = transcript.scrollHeight;
}
}, 100); }, 100);
}; };
const openHandoff = () => { const openHandoff = () => {
@@ -328,7 +337,6 @@ export function mountChat(root, src, makeHead) {
return handoff.summary; return handoff.summary;
}; };
const appendEvents = (events) => { const appendEvents = (events) => {
const pinned = transcript.scrollHeight - transcript.scrollTop - transcript.clientHeight < 80;
let contextChanged = false; let contextChanged = false;
headImpl.onEvents?.(events); headImpl.onEvents?.(events);
for (const ev of events) { for (const ev of events) {
@@ -407,11 +415,10 @@ export function mountChat(root, src, makeHead) {
renderComposer(); renderComposer();
} }
} }
if (pinned) { pin();
transcript.scrollTop = transcript.scrollHeight;
}
}; };
const renderAll = () => { const renderAll = () => {
stuck = true;
observer.disconnect(); observer.disconnect();
visibleReports.clear(); visibleReports.clear();
dropStream(); dropStream();
@@ -450,6 +457,8 @@ export function mountChat(root, src, makeHead) {
document.removeEventListener("visibilitychange", ackVisible); document.removeEventListener("visibilitychange", ackVisible);
headImpl.dispose?.(); headImpl.dispose?.();
observer.disconnect(); observer.disconnect();
resizes.disconnect();
mutations.disconnect();
dropStream(); dropStream();
}; };
} }
+1 -1
View File
File diff suppressed because one or more lines are too long
+2 -1
View File
@@ -8,7 +8,8 @@ import { openSwitcher } from "./navbar.js";
import { findTask, focusedProject, focusTab, on, onEach, state, tabs } from "./state.js"; import { findTask, focusedProject, focusTab, on, onEach, state, tabs } from "./state.js";
import { mountPreview } from "./preview.js"; import { mountPreview } from "./preview.js";
import { mountProject } from "./project.js"; import { mountProject } from "./project.js";
import { mountTranscript } from "./task.js";import { mountTerminal } from "./terminal.js"; import { mountTranscript } from "./task.js";
import { mountTerminal } from "./terminal.js";
const views: Record<TaskTab, { label: string; mount: (root: HTMLElement) => () => void }> = { const views: Record<TaskTab, { label: string; mount: (root: HTMLElement) => () => void }> = {
transcript: { label: "Transcript", mount: mountTranscript }, transcript: { label: "Transcript", mount: mountTranscript },
+23 -13
View File
@@ -176,6 +176,21 @@ export function mountChat(root: HTMLElement, src: ChatSource, makeHead: (rerende
const transcript = h("div", { class: "transcript" }); const transcript = h("div", { class: "transcript" });
const composer = h("div", { class: "composer" }); const composer = h("div", { class: "composer" });
let stuck = true;
const pin = () => {
if (stuck) {
transcript.scrollTop = transcript.scrollHeight;
}
};
transcript.addEventListener("scroll", () => {
stuck = transcript.scrollHeight - transcript.scrollTop - transcript.clientHeight < 80;
});
transcript.addEventListener("load", pin, true);
const resizes = new ResizeObserver(pin);
resizes.observe(transcript);
const mutations = new MutationObserver(pin);
mutations.observe(transcript, { childList: true, subtree: true, characterData: true });
const visibleReports = new Map<Element, string>(); const visibleReports = new Map<Element, string>();
const acked = new Set<string>(); const acked = new Set<string>();
const ackVisible = () => { const ackVisible = () => {
@@ -239,11 +254,8 @@ export function mountChat(root: HTMLElement, src: ChatSource, makeHead: (rerende
if (running === !spinner.hidden) { if (running === !spinner.hidden) {
return; return;
} }
const pinned = transcript.scrollHeight - transcript.scrollTop - transcript.clientHeight < 80;
spinner.hidden = !running; spinner.hidden = !running;
if (pinned) { pin();
transcript.scrollTop = transcript.scrollHeight;
}
}; };
const putBefore = (el: HTMLElement, anchor: HTMLElement | undefined) => { const putBefore = (el: HTMLElement, anchor: HTMLElement | undefined) => {
if (anchor?.parentNode === transcript) { if (anchor?.parentNode === transcript) {
@@ -309,7 +321,8 @@ export function mountChat(root: HTMLElement, src: ChatSource, makeHead: (rerende
pending.push(entry); pending.push(entry);
ctx.run = undefined; ctx.run = undefined;
putBefore(el, spinner); putBefore(el, spinner);
transcript.scrollTop = transcript.scrollHeight; stuck = true;
pin();
return () => { return () => {
el.remove(); el.remove();
pending = pending.filter((p) => p !== entry); pending = pending.filter((p) => p !== entry);
@@ -376,12 +389,9 @@ export function mountChat(root: HTMLElement, src: ChatSource, makeHead: (rerende
return; return;
} }
stream.timer = 0; stream.timer = 0;
const pinned = transcript.scrollHeight - transcript.scrollTop - transcript.clientHeight < 80;
stream.el.hidden = quietReply.startsWith(stream.text.trim()); stream.el.hidden = quietReply.startsWith(stream.text.trim());
replace(stream.el, markdown(stream.text, "text md", { taskId: ctx.taskId })); replace(stream.el, markdown(stream.text, "text md", { taskId: ctx.taskId }));
if (pinned) { pin();
transcript.scrollTop = transcript.scrollHeight;
}
}, 100); }, 100);
}; };
@@ -406,7 +416,6 @@ export function mountChat(root: HTMLElement, src: ChatSource, makeHead: (rerende
}; };
const appendEvents = (events: ByreEvent[]) => { const appendEvents = (events: ByreEvent[]) => {
const pinned = transcript.scrollHeight - transcript.scrollTop - transcript.clientHeight < 80;
let contextChanged = false; let contextChanged = false;
headImpl.onEvents?.(events); headImpl.onEvents?.(events);
for (const ev of events) { for (const ev of events) {
@@ -485,12 +494,11 @@ export function mountChat(root: HTMLElement, src: ChatSource, makeHead: (rerende
renderComposer(); renderComposer();
} }
} }
if (pinned) { pin();
transcript.scrollTop = transcript.scrollHeight;
}
}; };
const renderAll = () => { const renderAll = () => {
stuck = true;
observer.disconnect(); observer.disconnect();
visibleReports.clear(); visibleReports.clear();
dropStream(); dropStream();
@@ -531,6 +539,8 @@ export function mountChat(root: HTMLElement, src: ChatSource, makeHead: (rerende
document.removeEventListener("visibilitychange", ackVisible); document.removeEventListener("visibilitychange", ackVisible);
headImpl.dispose?.(); headImpl.dispose?.();
observer.disconnect(); observer.disconnect();
resizes.disconnect();
mutations.disconnect();
dropStream(); dropStream();
}; };
} }