From aa04cb0422001da1f676ab186b0864a955609617 Mon Sep 17 00:00:00 2001 From: Juan Carranza Date: Sun, 1 Nov 2026 10:39:10 -0600 Subject: [PATCH 1/2] fix(chat): land a long chat's first open at its newest message The list's own move down to the end can stop short: the browser holds it at the transcript's old height, and no row changes after the transcript grows, so nothing follows. A reader still following is now put at the end before the next paint, as a move up the layout made already was. A glide is left to ChatTimeline's settle where it ends. The chat-virtual QA opens a long chat (a 400-line answer, an 80-row table) in a split pane's width and checks every frame until it settles. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01HabHntUPvy5yA9iU3G1m5y --- changelog.d/chat-long-chat-lands-short.md | 4 ++ scripts/chat-virtual-browser-qa.ts | 67 +++++++++++++++++++++++ scripts/chat-virtual-fixture.tsx | 14 +++-- src/components/ChatView.tsx | 8 ++- 4 files changed, 88 insertions(+), 5 deletions(-) create mode 100644 changelog.d/chat-long-chat-lands-short.md diff --git a/changelog.d/chat-long-chat-lands-short.md b/changelog.d/chat-long-chat-lands-short.md new file mode 100644 index 0000000000..50cc9d26e2 --- /dev/null +++ b/changelog.d/chat-long-chat-lands-short.md @@ -0,0 +1,4 @@ +### Fixed +- A long chat opened for the first time now lands at its newest message. Before, the view could + stop several hundred pixels short of it when a long answer or table near the end grew while the + chat went down to it, and it stayed there. diff --git a/scripts/chat-virtual-browser-qa.ts b/scripts/chat-virtual-browser-qa.ts index 838a26836d..cdf92311cf 100644 --- a/scripts/chat-virtual-browser-qa.ts +++ b/scripts/chat-virtual-browser-qa.ts @@ -21,6 +21,8 @@ let wholeReads = 0; let newestStart = 0; let beginsAtOrigin = false; let slideReads = 0; +/** The long chat's read: the test answers it once the chat has drawn a frame without it, as a PC's read comes. */ +let longRead = { asked: Promise.withResolvers(), answer: Promise.withResolvers() }; const WHOLE_OUTPUT = `FULL TOOL OUTPUT\n${"detail ".repeat(250)}\nFULL OUTPUT END`; const olderEvents = new EventTarget(); const olderReleases: Array<() => void> = []; @@ -34,6 +36,20 @@ function turn(index: number, machine: string): ConversationTurn { parts, }; } +/** A long chat: a 400-line answer near its start, an 80-row table and a 24-paragraph answer at its end. */ +function longChat(): ConversationTurn[] { + const say = (role: ConversationTurn["role"], text: string): ConversationTurn => ({ role, ts: "2026-10-12T00:00:00.000Z", parts: [{ kind: "text", text }] }); + const table = `| n | squared | cubed |\n|---|---|---|\n${Array.from({ length: 80 }, (_, n) => `| ${n + 1} | ${(n + 1) ** 2} | ${(n + 1) ** 3} |`).join("\n")}`; + return [ + say("user", "Print the numbers from 1 to 400."), + say("assistant", Array.from({ length: 400 }, (_, n) => String(n + 1)).join("\n")), + ...Array.from({ length: 11 }, (_, n) => say(n % 2 ? "assistant" : "user", `Turn ${n + 2}. ${"A short line of text. ".repeat(n % 2 ? 3 : 1)}`)), + say("assistant", `The table has 80 rows.\n\n${table}`), + say("user", "Print the 24 Greek letter names."), + say("user", "Now end with the marker."), + say("assistant", `${Array.from({ length: 24 }, (_, n) => `Letter ${n + 1}`).join("\n\n")}\n\nLONG-CHAT-END`), + ]; +} async function metrics(page: Page) { return page.locator(".chat-view").evaluate((element) => { const root = element.getBoundingClientRect(); @@ -126,6 +142,11 @@ try { if (url.pathname.endsWith("/pane/conversation/tool-output")) { wholeReads++; return new Response(WHOLE_OUTPUT); } // the chat asks for the pane's slash commands to mark skill names in an answer: this pane has none if (url.pathname.endsWith("/pane/commands")) return Response.json({ commands: [] }); + if (url.pathname.endsWith("/pane/conversation") && url.pathname.includes("/machines/long/")) { + longRead.asked.resolve(); + await longRead.answer.promise; + return Response.json({ source: "claude-transcript", history_id: "long:1", cursor: null, turns: longChat() }); + } if (url.pathname.endsWith("/pane/conversation")) { const machine = url.pathname.includes("/machines/remote/") ? "remote" : "local"; const older = url.searchParams.has("before"); @@ -155,6 +176,52 @@ try { return new Response(Bun.file(join(root, url.pathname.slice(1)))); } }); browser = await chromium.launch({ headless: true, args: ["--no-sandbox"] }); + // A long chat whose agent is idle (no glide) opens at its newest message, in a desktop pane as + // narrow as a split one and on a phone: every frame from the first that shows its rows until the + // list settles shows it. Rows far taller than their estimate grow while the list goes to the end: + // its last move there can stop short at the old height, and nothing grows after it to be followed. + for (const width of [1440, 390]) { + longRead = { asked: Promise.withResolvers(), answer: Promise.withResolvers() }; + const context = await browser.newContext({ viewport: { width, height: width === 390 ? 844 : 900 }, locale: "en-US" }); + await context.addInitScript(() => localStorage.setItem("ovis:settings", JSON.stringify({ language: "en", theme: "dark" }))); + const page = await context.newPage(); + activePage = page; + page.setDefaultTimeout(15_000); + await page.goto(`http://127.0.0.1:${server.port}/`); + await page.getByText("REVISION-0", { exact: true }).waitFor(); + await page.evaluate((narrow) => window.chatQa.resize(543, narrow ? 478 : undefined), width > 478); + await page.evaluate(() => { window.chatQa.status("idle"); window.chatQa.machine("long"); }); + await longRead.asked.promise; + await page.evaluate(async () => { + const frames: { gap: number; newestInView: boolean }[] = []; + Object.assign(window, { longLanding: new Promise((resolve) => { + // settled: the view and the transcript's height unchanged for 20 frames once the rows show + let last = "", same = 0; + const sample = () => { + const node = document.querySelector(".chat-view"); + const end = [...node?.querySelectorAll("p") ?? []].find((p) => p.textContent === "LONG-CHAT-END"); + if (node && node.querySelector("[data-chat-row]")) { + const view = node.getBoundingClientRect(), box = end?.getBoundingClientRect(); + frames.push({ gap: Math.round(node.scrollHeight - node.clientHeight - node.scrollTop), newestInView: box !== undefined && box.top >= view.top - 1 && box.bottom <= view.bottom + 1 }); + const now = `${node.scrollTop}:${node.scrollHeight}`; + same = now === last ? same + 1 : 0; + last = now; + } + if (same >= 20 || frames.length >= 600) resolve(frames); + else requestAnimationFrame(sample); + }; + requestAnimationFrame(sample); + }) }); + // the chat draws its frames without the answer, as it does while a PC reads it + for (let frame = 0; frame < 3; frame++) await new Promise((resolve) => requestAnimationFrame(resolve)); + }); + longRead.answer.resolve(); + const landing = await page.evaluate(() => (window as unknown as { longLanding: Promise<{ gap: number; newestInView: boolean }[]> }).longLanding); + const short = landing.filter((frame) => frame.gap > 1 || !frame.newestInView); + assert.ok(landing.length > 0 && short.length === 0, `a long chat's first open shows its newest message in every frame until it settles: ${short.length} of ${landing.length} frames short, last ${JSON.stringify(landing.at(-1))}`); + console.log(`PASS ${width}: a long chat's first open shows its newest message in all ${landing.length} frames until it settles`); + await context.close(); + } for (const [width, theme] of [[1440, "dark"], [1440, "light"], [390, "dark"], [390, "light"]] as const) { revision = 0; epoch = 1; holdOlder = false; olderReads = 0; wholeReads = 0; newestStart = 0; beginsAtOrigin = false; slideReads = 0; diff --git a/scripts/chat-virtual-fixture.tsx b/scripts/chat-virtual-fixture.tsx index de2473c277..f604661a6f 100644 --- a/scripts/chat-virtual-fixture.tsx +++ b/scripts/chat-virtual-fixture.tsx @@ -11,8 +11,11 @@ declare global { chatQa: { refresh: () => void; page: (direction: -1 | 1) => void; - resize: (height: number) => void; + /** the chat's height, and its width (a split pane's) when given */ + resize: (height: number, width?: number) => void; machine: (machine: string) => void; + /** the agent's status: a working one's follow glides */ + status: (status: "working" | "idle") => void; }; } } @@ -20,20 +23,23 @@ declare global { function Fixture() { const [refresh, setRefresh] = useState(0); const [height, setHeight] = useState(window.innerHeight); + const [width, setWidth] = useState("100%"); const [machine, setMachine] = useState("local"); + const [status, setStatus] = useState<"working" | "idle">("working"); const pageScroll = useRef<((direction: -1 | 1) => void) | null>(null); window.chatQa = { refresh: () => setRefresh((value) => value + 1), page: (direction) => pageScroll.current?.(direction), - resize: setHeight, + resize: (height, width) => { setHeight(height); if (width !== undefined) setWidth(width); }, machine: setMachine, + status: setStatus, }; - return
+ return
window.dispatchEvent(new Event("chat-qa-commit"))}> {}}> {/* the metadata of each answer the chat shows: a script can tell an applied answer from one only fetched */} - window.dispatchEvent(new CustomEvent("chat-qa-answer", { detail: metadata }))} /> diff --git a/src/components/ChatView.tsx b/src/components/ChatView.tsx index c900450a15..34f9544692 100644 --- a/src/components/ChatView.tsx +++ b/src/components/ChatView.tsx @@ -2021,9 +2021,15 @@ export const ChatView = memo(function ChatView({ paneId, presentationKey, finish pagingAway.current = true; dropPendingListScroll(timeline.current); } + // The list's own move down can stop short too: the browser holds it at the transcript's height + // before the list has grown it, and once grown no row changes to follow (a long chat's first + // open). A glide's stops are not that: where it ends, ChatTimeline settles it. + const stoppedShort = scrollingDown && !byReader && !smoothFollow && timeline.current !== null; // the layout's move left a reader still following short of the end: they go on there, unless // what they just clicked is laid out where they are (ChatTimeline manualLayout) - if (heldInPlace && stickToBottom.current && !pagingAway.current && !manualLayout.current && distanceFromBottom > LAYOUT_ALLOWANCE_PX) { + if ((heldInPlace || stoppedShort) && stickToBottom.current && !pagingAway.current && !manualLayout.current && distanceFromBottom > LAYOUT_ALLOWANCE_PX) { + // the list's scroll lands a frame later: the frame painted next is at the end already + if (stoppedShort) node.scrollTop = node.scrollHeight; if (timeline.current) timeline.current.scrollToEnd({ animated: false }); else node.scrollTo({ top: node.scrollHeight, behavior: "instant" }); } From f339696b880466fa885fc1301dd9d476d2f3f921 Mon Sep 17 00:00:00 2001 From: Juan Carranza Date: Sun, 1 Nov 2026 12:10:35 -0600 Subject: [PATCH 2/2] test(chat): bound the long-chat landing check's waits The wait for the long chat's read now fails after 15 s, and the frame sampler stops after 600 frames even when no row draws, so a broken first open fails the check instead of hanging it. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01HabHntUPvy5yA9iU3G1m5y --- scripts/chat-virtual-browser-qa.ts | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/scripts/chat-virtual-browser-qa.ts b/scripts/chat-virtual-browser-qa.ts index cdf92311cf..12491de567 100644 --- a/scripts/chat-virtual-browser-qa.ts +++ b/scripts/chat-virtual-browser-qa.ts @@ -191,13 +191,17 @@ try { await page.getByText("REVISION-0", { exact: true }).waitFor(); await page.evaluate((narrow) => window.chatQa.resize(543, narrow ? 478 : undefined), width > 478); await page.evaluate(() => { window.chatQa.status("idle"); window.chatQa.machine("long"); }); + const unasked = setTimeout(() => longRead.asked.reject(new Error("the long chat never asked for its conversation")), 15_000); await longRead.asked.promise; + clearTimeout(unasked); await page.evaluate(async () => { const frames: { gap: number; newestInView: boolean }[] = []; Object.assign(window, { longLanding: new Promise((resolve) => { - // settled: the view and the transcript's height unchanged for 20 frames once the rows show - let last = "", same = 0; + // settled: the view and the transcript's height unchanged for 20 frames once the rows show; + // 600 frames at most, rows or not + let last = "", same = 0, ticks = 0; const sample = () => { + ticks++; const node = document.querySelector(".chat-view"); const end = [...node?.querySelectorAll("p") ?? []].find((p) => p.textContent === "LONG-CHAT-END"); if (node && node.querySelector("[data-chat-row]")) { @@ -207,7 +211,7 @@ try { same = now === last ? same + 1 : 0; last = now; } - if (same >= 20 || frames.length >= 600) resolve(frames); + if (same >= 20 || ticks >= 600) resolve(frames); else requestAnimationFrame(sample); }; requestAnimationFrame(sample);