From 5a8a06f92fe6228818df5d7e1ef92e7bfb9a3516 Mon Sep 17 00:00:00 2001 From: Sina Matian <89218912+time-attack@users.noreply.github.com> Date: Sat, 5 Sep 2026 20:08:24 -0400 Subject: [PATCH 1/2] fix(web-ui): stop animating programmatic transcript scrolls The transcript scroller had scroll-behavior: smooth, so every programmatic scroll-to-bottom animated. When a run ended with a pending approval the transcript redrew and re-stuck to the bottom, and because the scroller started at the top the browser animated the entire way down. Smooth scrolling only affects programmatic scrolls, never user input, so drop it and delete the three sites that toggled the style off and back on to work around it. --- plugins/web-ui/src/chat.ts | 15 --------------- plugins/web-ui/src/shell.css | 1 - plugins/web-ui/test/layout-thrash.test.ts | 9 +++++---- 3 files changed, 5 insertions(+), 20 deletions(-) diff --git a/plugins/web-ui/src/chat.ts b/plugins/web-ui/src/chat.ts index c8bb4b744..2751c37f3 100644 --- a/plugins/web-ui/src/chat.ts +++ b/plugins/web-ui/src/chat.ts @@ -900,10 +900,7 @@ export function createChatSurface( requestAnimationFrame(() => { const scrollerNow = container?.querySelector(".chat-scroll"); if (!scrollerNow) return; - const prev = scrollerNow.style.scrollBehavior; - scrollerNow.style.scrollBehavior = "auto"; scrollerNow.scrollTop = priorTop + (scrollerNow.scrollHeight - priorHeight); - scrollerNow.style.scrollBehavior = prev; }); } catch { btn.disabled = false; @@ -1002,10 +999,7 @@ export function createChatSurface( requestAnimationFrame(() => { const scrollerNow = chatState.host?.querySelector(".chat-scroll"); if (!scrollerNow) return; - const prev = scrollerNow.style.scrollBehavior; - scrollerNow.style.scrollBehavior = "auto"; scrollerNow.scrollTop = priorTop + (scrollerNow.scrollHeight - priorHeight); - scrollerNow.style.scrollBehavior = prev; }); } catch { void 0; @@ -2332,15 +2326,6 @@ export function createChatSurface( if (!scroller) return; if (!force && !stickToBottom) return; requestAnimationFrame(() => { - if (force) { - const prev = scroller.style.scrollBehavior; - scroller.style.scrollBehavior = "auto"; - scroller.scrollTop = scroller.scrollHeight; - requestAnimationFrame(() => { - scroller.style.scrollBehavior = prev; - }); - return; - } scroller.scrollTop = scroller.scrollHeight; }); } diff --git a/plugins/web-ui/src/shell.css b/plugins/web-ui/src/shell.css index 043d5cd23..9e3cd1d91 100644 --- a/plugins/web-ui/src/shell.css +++ b/plugins/web-ui/src/shell.css @@ -1260,7 +1260,6 @@ a.chat-row-open { overflow-y: auto; padding: 28px var(--chat-pad) 8px; - scroll-behavior: smooth; } .fork-origin-row { diff --git a/plugins/web-ui/test/layout-thrash.test.ts b/plugins/web-ui/test/layout-thrash.test.ts index a26fdc142..b49609b09 100644 --- a/plugins/web-ui/test/layout-thrash.test.ts +++ b/plugins/web-ui/test/layout-thrash.test.ts @@ -4,6 +4,7 @@ import test from "node:test"; const composer = readFileSync(new URL("../src/composer.ts", import.meta.url), "utf8"); const chat = readFileSync(new URL("../src/chat.ts", import.meta.url), "utf8"); +const css = readFileSync(new URL("../src/shell.css", import.meta.url), "utf8"); test("resizeComposer skips the forced-reflow measure pass when the draft value is unchanged", () => { const fn = composer.match(/function resizeComposer\(\): void \{[\s\S]*?\n {2}\}/)?.[0] ?? ""; @@ -41,9 +42,9 @@ test("revealing a transcript re-arms auto-follow; read-only mounts start at the assert.match(chat, /const scroller = ctx\.container\(\)\?\.querySelector/); }); -test("both pagination paths adjust their anchor without smooth scrolling", () => { - const anchors = chat.match( - /const prev = scrollerNow\.style\.scrollBehavior;\s*scrollerNow\.style\.scrollBehavior = "auto";\s*scrollerNow\.scrollTop = priorTop \+ \(scrollerNow\.scrollHeight - priorHeight\);\s*scrollerNow\.style\.scrollBehavior = prev;/g, - ); +test("programmatic transcript scrolls never animate", () => { + assert.doesNotMatch(css, /scroll-behavior:\s*smooth/); + assert.doesNotMatch(chat, /scrollBehavior/); + const anchors = chat.match(/scrollerNow\.scrollTop = priorTop \+ \(scrollerNow\.scrollHeight - priorHeight\);/g); assert.equal(anchors?.length, 2); }); From d8637dc3c18288142e18a7591f4a49794c7590ef Mon Sep 17 00:00:00 2001 From: Sina Matian <89218912+time-attack@users.noreply.github.com> Date: Sat, 5 Sep 2026 20:14:45 -0400 Subject: [PATCH 2/2] refactor(web-ui): share the pagination scroll-anchor restore Both earlier-messages loaders captured scrollTop and scrollHeight, rebuilt the transcript, then re-derived scrollTop from the height delta with the same inline block. Review flagged the pair as drift-prone once the smooth scroll workaround was gone. One helper now holds the anchor and returns the restore step; the layout test asserts both paths go through it instead of counting copies. --- plugins/web-ui/src/chat.ts | 31 +++++++++++------------ plugins/web-ui/test/layout-thrash.test.ts | 8 ++++-- 2 files changed, 21 insertions(+), 18 deletions(-) diff --git a/plugins/web-ui/src/chat.ts b/plugins/web-ui/src/chat.ts index 2751c37f3..652ec17e2 100644 --- a/plugins/web-ui/src/chat.ts +++ b/plugins/web-ui/src/chat.ts @@ -881,9 +881,7 @@ export function createChatSurface( anchorSeq !== null ? { beforeSeq: anchorSeq, tailTurns: TAIL_TURNS } : undefined, ); if (chatState.sessionId !== s.id) return; - const scroller = container?.querySelector(".chat-scroll"); - const priorHeight = scroller?.scrollHeight ?? 0; - const priorTop = scroller?.scrollTop ?? 0; + const restoreAnchor = holdScrollAnchor(container); const rawRemaining = page.earlierEntries ?? 0; const remaining = currentEarlierCount(s, rawRemaining); const split = inheritedTranscript(s, page.entries ?? []); @@ -897,11 +895,7 @@ export function createChatSurface( ...chatState.inheritedMessages, ], ); - requestAnimationFrame(() => { - const scrollerNow = container?.querySelector(".chat-scroll"); - if (!scrollerNow) return; - scrollerNow.scrollTop = priorTop + (scrollerNow.scrollHeight - priorHeight); - }); + restoreAnchor(); } catch { btn.disabled = false; btn.textContent = "Show earlier messages"; @@ -987,20 +981,14 @@ export function createChatSurface( ...entriesToMessages(split.inherited, transcriptModel()), ...chatState.inheritedMessages, ]; - const scroller = chatState.host?.querySelector(".chat-scroll"); - const priorHeight = scroller?.scrollHeight ?? 0; - const priorTop = scroller?.scrollTop ?? 0; + const restoreAnchor = holdScrollAnchor(chatState.host); agent.state.messages = [...earlierMessages, ...agent.state.messages]; const rawRemaining = page.earlierEntries ?? 0; chatState.transcriptAnchorSeq = rawRemaining > 0 ? (page.entries?.[0]?.seq ?? null) : null; chatState.earlierCount = currentEarlierCount(chatState.forkSession ?? {}, rawRemaining); chatState.loadingEarlier = false; drawActiveChat(agent); - requestAnimationFrame(() => { - const scrollerNow = chatState.host?.querySelector(".chat-scroll"); - if (!scrollerNow) return; - scrollerNow.scrollTop = priorTop + (scrollerNow.scrollHeight - priorHeight); - }); + restoreAnchor(); } catch { void 0; } finally { @@ -2321,6 +2309,17 @@ export function createChatSurface( scrollTranscript(true); } + function holdScrollAnchor(root: ParentNode | null | undefined): () => void { + const scroller = root?.querySelector(".chat-scroll"); + const priorHeight = scroller?.scrollHeight ?? 0; + const priorTop = scroller?.scrollTop ?? 0; + return () => + requestAnimationFrame(() => { + const now = root?.querySelector(".chat-scroll"); + if (now) now.scrollTop = priorTop + (now.scrollHeight - priorHeight); + }); + } + function scrollTranscript(force = false): void { const scroller = ctx.container()?.querySelector(".chat-scroll"); if (!scroller) return; diff --git a/plugins/web-ui/test/layout-thrash.test.ts b/plugins/web-ui/test/layout-thrash.test.ts index b49609b09..f01c97875 100644 --- a/plugins/web-ui/test/layout-thrash.test.ts +++ b/plugins/web-ui/test/layout-thrash.test.ts @@ -45,6 +45,10 @@ test("revealing a transcript re-arms auto-follow; read-only mounts start at the test("programmatic transcript scrolls never animate", () => { assert.doesNotMatch(css, /scroll-behavior:\s*smooth/); assert.doesNotMatch(chat, /scrollBehavior/); - const anchors = chat.match(/scrollerNow\.scrollTop = priorTop \+ \(scrollerNow\.scrollHeight - priorHeight\);/g); - assert.equal(anchors?.length, 2); +}); + +test("both pagination paths keep the viewport anchored through one helper", () => { + assert.match(chat, /function holdScrollAnchor\(root: ParentNode \| null \| undefined\): \(\) => void/); + assert.equal(chat.match(/const restoreAnchor = holdScrollAnchor\(/g)?.length, 2); + assert.equal(chat.match(/restoreAnchor\(\);/g)?.length, 2); });