import { assertEquals } from "@std/assert"; import type { LiveState } from "@earendil-works/pi-durable"; import { withDom } from "@berry/util/dom.ts"; import { scroller } from "./scroller.tsx"; import { Scope } from "./ui.ts"; Deno.test("only new bottom content follows a reader near the previous bottom", async () => { let resize = () => {}; await withDom(async (window) => { const scope = new Scope(); try { const content = document.createElement("div"); const older = document.createElement("div"); const newest = document.createElement("div"); content.append(older, newest); const element = scroller(scope, content); const height = 400; let total = 1000; let top = 0; Object.defineProperties(element, { clientHeight: { get: () => height }, scrollHeight: { get: () => total }, scrollTop: { get: () => top, set: (value: number) => top = Math.max(0, Math.min(value, total - height)), }, }); resize(); assertEquals(top, 600); const flush = () => window.happyDOM.waitUntilComplete(); total += 200; newest.textContent = "streaming"; await flush(); assertEquals(top, 800); // scrolling can precede the browser's scroll event element.dispatchEvent(new Event("pointerdown")); top = 700; total += 200; newest.firstChild!.textContent = "still streaming"; await flush(); assertEquals(top, 700); element.dispatchEvent(new Event("scroll")); element.dispatchEvent(new Event("pointerup")); top = total - height - 65; element.dispatchEvent(new Event("scroll")); total += 200; newest.append(" more"); await flush(); assertEquals(top, 935); top = total - height - 64; element.dispatchEvent(new Event("scroll")); total += 200; newest.append(" more"); await flush(); assertEquals(top, 1400); // a layout change is not new output, but it moves the bottom a pinned reader follows total += 100; resize(); newest.append(" more"); await flush(); assertEquals(top, 1500); top = total - height; total += 200; older.textContent = "an older tool updated"; await flush(); assertEquals(top, 1500); top = total - height; total += 200; content.prepend(document.createElement("div")); await flush(); assertEquals(top, 1700); top = total - height; total += 200; content.append(document.createElement("div")); await flush(); assertEquals(top, 2100); scope.dispose(); total += 200; content.append(document.createElement("div")); await flush(); assertEquals(top, 2100); } finally { scope.dispose(); } }, { globals: { ResizeObserver: class { constructor(callback: () => void) { resize = callback; } observe() {} disconnect() {} }, }, }); }); Deno.test("a resize keeps a reader at the bottom there, and leaves a reader above in place", async () => { let resize = () => {}; await withDom(async (window) => { const scope = new Scope(); try { const content = document.createElement("div"); content.append(document.createElement("div")); const element = scroller(scope, content); let width = 800; let height = 400; let total = 1000; let top = 0; const clamp = (value: number) => Math.max(0, Math.min(value, total - height)); Object.defineProperties(element, { clientWidth: { get: () => width }, clientHeight: { get: () => height }, scrollHeight: { get: () => total }, scrollTop: { get: () => top, set: (value: number) => top = clamp(value) }, }); const output = async () => { total += 200; content.lastElementChild!.append(" more"); await window.happyDOM.waitUntilComplete(); }; resize(); assertEquals(top, 600); // the composer grows height = 300; resize(); assertEquals(top, 700); // narrowing rewraps the transcript taller width = 600; total = 1300; resize(); assertEquals(top, 1000); await output(); assertEquals(top, 1200); // widening clamps the reader, whose scroll event can arrive first width = 800; total = 1100; top = clamp(top); element.dispatchEvent(new Event("scroll")); resize(); await output(); assertEquals(top, 1000); top = 500; element.dispatchEvent(new Event("scroll")); height = 200; resize(); assertEquals(top, 500); await output(); assertEquals(top, 500); } finally { scope.dispose(); } }, { globals: { ResizeObserver: class { constructor(callback: () => void) { resize = callback; } observe() {} disconnect() {} }, }, }); }); Deno.test("a pinned reader stays at the bottom through layout changes larger than the follow margin", async () => { let resize = () => {}; await withDom(async (window) => { const scope = new Scope(); try { const content = document.createElement("div"); content.append(document.createElement("div")); const element = scroller(scope, content); let height = 400; let total = 1000; let top = 0; Object.defineProperties(element, { clientHeight: { get: () => height }, scrollHeight: { get: () => total }, scrollTop: { get: () => top, set: (value: number) => top = Math.max(0, Math.min(value, total - height)), }, }); resize(); assertEquals(top, 600); // the existing content grows, as an image or font loading makes it total += 300; content.prepend(document.createElement("div")); await window.happyDOM.waitUntilComplete(); element.dispatchEvent(new Event("scroll")); resize(); assertEquals(top, 900); // a scroll event from the pane shrinking arrives before the resize settles it height = 300; element.dispatchEvent(new Event("scroll")); resize(); assertEquals(top, 1000); // the composer grows in successive steps, as its textarea gains a row and then its controls stack height = 260; resize(); assertEquals(top, 1040); height = 220; resize(); assertEquals(top, 1080); top = 500; element.dispatchEvent(new Event("scroll")); height = 180; resize(); assertEquals(top, 500); } finally { scope.dispose(); } }, { globals: { ResizeObserver: class { constructor(callback: () => void) { resize = callback; } observe() {} disconnect() {} }, }, }); }); Deno.test("a reader scrolled up can jump back to the newest output, and follows it again", async () => { let resize = () => {}; await withDom(async (window) => { const scope = new Scope(); try { const content = document.createElement("div"); content.append(document.createElement("div")); const element = scroller(scope, content); let total = 1000; let top = 0; Object.defineProperties(element, { clientHeight: { value: 400 }, scrollHeight: { get: () => total }, scrollTop: { get: () => top, set: (value: number) => top = Math.max(0, Math.min(value, total - 400)), }, }); const jump = element.querySelector( 'button[aria-label="Jump to the newest output"]', )!; const shown = () => jump.closest("[hidden]") === null; resize(); assertEquals(shown(), false); top = 100; element.dispatchEvent(new Event("scroll")); assertEquals(shown(), true); jump.click(); assertEquals(top, 600); assertEquals(shown(), false); total += 200; content.lastElementChild!.append("more"); await window.happyDOM.waitUntilComplete(); assertEquals(top, 800); } finally { scope.dispose(); } }, { globals: { ResizeObserver: class { constructor(callback: () => void) { resize = callback; } observe() {} disconnect() {} }, }, }); }); Deno.test("a transcript waits for visible, scrollable content before its initial scroll", async () => { let resize = () => {}; await withDom(async (window) => { const scope = new Scope(); try { const content = document.createElement("div"); const element = scroller(scope, content); let visible = true; let total = 400; let top = 0; Object.defineProperties(element, { clientHeight: { get: () => visible ? 400 : 0 }, scrollHeight: { get: () => visible ? total : 0 }, scrollTop: { get: () => top, set: (value: number) => top = visible ? Math.max(0, Math.min(value, total - 400)) : 0, }, }); resize(); content.append(document.createElement("div")); await window.happyDOM.waitUntilComplete(); assertEquals(top, 0); visible = false; resize(); content.append(document.createElement("div")); total = 1000; await window.happyDOM.waitUntilComplete(); resize(); assertEquals(top, 0); visible = true; resize(); assertEquals(top, 600); top = 200; element.dispatchEvent(new Event("scroll")); total += 200; resize(); assertEquals(top, 200); visible = false; resize(); visible = true; resize(); assertEquals(top, 200); } finally { scope.dispose(); } }, { globals: { ResizeObserver: class { constructor(callback: () => void) { resize = callback; } observe() {} disconnect() {} }, }, }); }); Deno.test("a scroller has its content draw more as the reader scrolls, and as it resizes", async () => { let resize = () => {}; await withDom(() => { const scope = new Scope(); try { const content = document.createElement("div"); content.append(document.createElement("div")); const drawn: number[] = []; const element = scroller(scope, content, undefined, (pane) => drawn.push(pane.clientHeight)); Object.defineProperties(element, { clientHeight: { value: 400 }, scrollHeight: { value: 1000 }, scrollTop: { value: 600, writable: true }, }); resize(); assertEquals(drawn, [400]); element.dispatchEvent(new Event("scroll")); assertEquals(drawn, [400, 400]); resize(); assertEquals(drawn, [400, 400, 400]); } finally { scope.dispose(); } }, { globals: { ResizeObserver: class { constructor(callback: () => void) { resize = callback; } observe() {} disconnect() {} }, }, }); }); Deno.test("a transcript follows its last block, not updates to earlier blocks in the same response", async () => { await withDom(async (window) => { const { transcript } = await import("./transcript.tsx"); const { fauxAssistantMessage, fauxText } = await import( "@earendil-works/pi-ai/providers/faux" ); const scope = new Scope(); const history = transcript(); scope.add(history.dispose); const element = scroller(scope, history.element, history.last); let total = 1000; let top = 0; Object.defineProperties(element, { clientHeight: { value: 400 }, scrollHeight: { get: () => total }, scrollTop: { get: () => top, set: (value: number) => top = Math.max(0, Math.min(value, total - 400)), }, }); const entries: never[] = []; const update = async (first: string, last: string) => { history.update(entries, { generation: { attempt: 0, message: fauxAssistantMessage([fauxText(first), fauxText(last)]), }, } as unknown as LiveState); await window.happyDOM.waitUntilComplete(); }; try { await update("first", "last"); assertEquals(top, 600); total += 200; await update("first updated", "last"); assertEquals(top, 600); top = 800; total += 200; await update("first updated", "last updated"); assertEquals(top, 1000); } finally { scope.dispose(); } }); });