diff --git a/plugins/web-ui/src/chat.ts b/plugins/web-ui/src/chat.ts index c8bb4b744..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,14 +895,7 @@ export function createChatSurface( ...chatState.inheritedMessages, ], ); - 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; - }); + restoreAnchor(); } catch { btn.disabled = false; btn.textContent = "Show earlier messages"; @@ -990,23 +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; - const prev = scrollerNow.style.scrollBehavior; - scrollerNow.style.scrollBehavior = "auto"; - scrollerNow.scrollTop = priorTop + (scrollerNow.scrollHeight - priorHeight); - scrollerNow.style.scrollBehavior = prev; - }); + restoreAnchor(); } catch { void 0; } finally { @@ -2327,20 +2309,22 @@ 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; 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..f01c97875 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,13 @@ 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, - ); - assert.equal(anchors?.length, 2); +test("programmatic transcript scrolls never animate", () => { + assert.doesNotMatch(css, /scroll-behavior:\s*smooth/); + assert.doesNotMatch(chat, /scrollBehavior/); +}); + +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); });