From 701a38908546f13a93d68ae266c8ef4fbc18aac3 Mon Sep 17 00:00:00 2001 From: Lu <42706009+luinbytes@users.noreply.github.com> Date: Thu, 27 Aug 2026 16:34:50 +0100 Subject: [PATCH 1/2] fix(web): keep queued transcript snaps reader-safe --- apps/web/src/lib/transcript-scroll.test.ts | 21 ++++++++++- apps/web/src/lib/transcript-scroll.ts | 11 ++++++ apps/web/src/pages/Shell.tsx | 41 +++++++++++++++------- 3 files changed, 59 insertions(+), 14 deletions(-) diff --git a/apps/web/src/lib/transcript-scroll.test.ts b/apps/web/src/lib/transcript-scroll.test.ts index 4f11a1ac5..a1876b0f4 100644 --- a/apps/web/src/lib/transcript-scroll.test.ts +++ b/apps/web/src/lib/transcript-scroll.test.ts @@ -1,5 +1,9 @@ import { describe, expect, it } from "vitest"; -import { transcriptIsNearEnd } from "./transcript-scroll.js"; +import { + transcriptCanSnapAfterFrame, + transcriptIsNearEnd, + transcriptMovedDown, +} from "./transcript-scroll.js"; describe("transcriptIsNearEnd", () => { it("follows only while the viewport is within 80px of the latest message", () => { @@ -11,3 +15,18 @@ describe("transcriptIsNearEnd", () => { ); }); }); + +describe("transcriptCanSnapAfterFrame", () => { + it("does not snap after the reader moves while the frame is queued", () => { + expect(transcriptCanSnapAfterFrame({ scrollTop: 920 }, 950)).toBe(false); + expect(transcriptCanSnapAfterFrame({ scrollTop: 950 }, 950)).toBe(true); + }); +}); + +describe("transcriptMovedDown", () => { + it("does not treat an uninitialized baseline as downward movement", () => { + expect(transcriptMovedDown(null, 920)).toBe(false); + expect(transcriptMovedDown(950, 920)).toBe(false); + expect(transcriptMovedDown(920, 950)).toBe(true); + }); +}); diff --git a/apps/web/src/lib/transcript-scroll.ts b/apps/web/src/lib/transcript-scroll.ts index 325357b6d..c6d72a419 100644 --- a/apps/web/src/lib/transcript-scroll.ts +++ b/apps/web/src/lib/transcript-scroll.ts @@ -3,3 +3,14 @@ export function transcriptIsNearEnd( ): boolean { return element.scrollHeight - element.scrollTop - element.clientHeight < 80; } + +export function transcriptCanSnapAfterFrame( + element: Pick, + queuedScrollTop: number, +): boolean { + return element.scrollTop === queuedScrollTop; +} + +export function transcriptMovedDown(previousScrollTop: number | null, scrollTop: number): boolean { + return previousScrollTop !== null && scrollTop >= previousScrollTop; +} diff --git a/apps/web/src/pages/Shell.tsx b/apps/web/src/pages/Shell.tsx index 3d7041bc9..7c965ab44 100644 --- a/apps/web/src/pages/Shell.tsx +++ b/apps/web/src/pages/Shell.tsx @@ -140,7 +140,11 @@ import { reduceThreadSnapshot, userHoldsComputerControl, } from "../lib/thread-events"; -import { transcriptIsNearEnd } from "../lib/transcript-scroll"; +import { + transcriptCanSnapAfterFrame, + transcriptIsNearEnd, + transcriptMovedDown, +} from "../lib/transcript-scroll"; import { speaker } from "../lib/tts"; import { ActivityList } from "./ActivityList"; import type { ContextMenuPosition } from "./BotContextMenu"; @@ -474,6 +478,17 @@ export function ShellPage() { [navigate], ); + function snapTranscriptToEndAfterFrame() { + const queuedScrollTop = messageScroll.current?.scrollTop; + if (queuedScrollTop === undefined) return; + window.requestAnimationFrame(() => { + const element = messageScroll.current; + if (element && transcriptCanSnapAfterFrame(element, queuedScrollTop)) { + element.scrollTop = element.scrollHeight; + } + }); + } + async function refreshGroupThread(id: string) { const scrollElement = messageScroll.current; const stickToEnd = !scrollElement || transcriptIsNearEnd(scrollElement); @@ -498,10 +513,7 @@ export function ShellPage() { (!scrollElement || transcriptIsNearEnd(scrollElement)) && expandedHistoryThread.current !== snap.threadId ) { - window.requestAnimationFrame(() => { - const element = messageScroll.current; - if (element) element.scrollTop = element.scrollHeight; - }); + snapTranscriptToEndAfterFrame(); } return snap; } @@ -537,10 +549,7 @@ export function ShellPage() { (!scrollElement || transcriptIsNearEnd(scrollElement)) && expandedHistoryThread.current !== snap.threadId ) { - window.requestAnimationFrame(() => { - const element = messageScroll.current; - if (element) element.scrollTop = element.scrollHeight; - }); + snapTranscriptToEndAfterFrame(); } const [routines, skills] = await Promise.all([ rpc.routines.list({ botId: id }), @@ -3103,7 +3112,7 @@ const Transcript = memo(function Transcript({ const [atEnd, setAtEnd] = useState(true); const following = useRef(true); const autoScrolling = useRef(false); - const lastScrollTop = useRef(0); + const lastScrollTop = useRef(null); const autoScrollTimer = useRef(undefined); const jumpButtonRef = useRef(null); const messageById = useMemo( @@ -3185,22 +3194,28 @@ const Transcript = memo(function Transcript({
{ + onPointerDown={(event) => { + lastScrollTop.current = event.currentTarget.scrollTop; autoScrolling.current = false; following.current = false; }} - onTouchStart={() => { + onTouchStart={(event) => { + lastScrollTop.current = event.currentTarget.scrollTop; autoScrolling.current = false; following.current = false; }} onWheel={(event) => { if (event.deltaY < 0) { + lastScrollTop.current = event.currentTarget.scrollTop; autoScrolling.current = false; following.current = false; } }} onScroll={(event) => { - const scrolledDown = event.currentTarget.scrollTop >= lastScrollTop.current; + const scrolledDown = transcriptMovedDown( + lastScrollTop.current, + event.currentTarget.scrollTop, + ); lastScrollTop.current = event.currentTarget.scrollTop; const nearEnd = transcriptIsNearEnd(event.currentTarget); setAtEnd(nearEnd); From 9406ba0ef069086cccc05d743d1a7a505f9fc4f7 Mon Sep 17 00:00:00 2001 From: Lu <42706009+luinbytes@users.noreply.github.com> Date: Thu, 27 Aug 2026 16:38:50 +0100 Subject: [PATCH 2/2] fix(web): bind queued snap to transcript --- apps/web/src/lib/transcript-scroll.test.ts | 12 ++++++++++-- apps/web/src/lib/transcript-scroll.ts | 5 +++-- apps/web/src/pages/Shell.tsx | 9 +++++---- 3 files changed, 18 insertions(+), 8 deletions(-) diff --git a/apps/web/src/lib/transcript-scroll.test.ts b/apps/web/src/lib/transcript-scroll.test.ts index a1876b0f4..2b1cb12ff 100644 --- a/apps/web/src/lib/transcript-scroll.test.ts +++ b/apps/web/src/lib/transcript-scroll.test.ts @@ -18,8 +18,16 @@ describe("transcriptIsNearEnd", () => { describe("transcriptCanSnapAfterFrame", () => { it("does not snap after the reader moves while the frame is queued", () => { - expect(transcriptCanSnapAfterFrame({ scrollTop: 920 }, 950)).toBe(false); - expect(transcriptCanSnapAfterFrame({ scrollTop: 950 }, 950)).toBe(true); + const transcript = { scrollTop: 950 }; + expect(transcriptCanSnapAfterFrame(transcript, transcript, 950)).toBe(true); + transcript.scrollTop = 920; + expect(transcriptCanSnapAfterFrame(transcript, transcript, 950)).toBe(false); + }); + + it("does not snap a replacement transcript", () => { + const queuedTranscript = { scrollTop: 950 }; + const replacementTranscript = { scrollTop: 950 }; + expect(transcriptCanSnapAfterFrame(replacementTranscript, queuedTranscript, 950)).toBe(false); }); }); diff --git a/apps/web/src/lib/transcript-scroll.ts b/apps/web/src/lib/transcript-scroll.ts index c6d72a419..8f5d9ea8b 100644 --- a/apps/web/src/lib/transcript-scroll.ts +++ b/apps/web/src/lib/transcript-scroll.ts @@ -5,10 +5,11 @@ export function transcriptIsNearEnd( } export function transcriptCanSnapAfterFrame( - element: Pick, + element: Pick | null, + queuedElement: Pick, queuedScrollTop: number, ): boolean { - return element.scrollTop === queuedScrollTop; + return element === queuedElement && queuedElement.scrollTop === queuedScrollTop; } export function transcriptMovedDown(previousScrollTop: number | null, scrollTop: number): boolean { diff --git a/apps/web/src/pages/Shell.tsx b/apps/web/src/pages/Shell.tsx index 7c965ab44..70613e85f 100644 --- a/apps/web/src/pages/Shell.tsx +++ b/apps/web/src/pages/Shell.tsx @@ -479,12 +479,13 @@ export function ShellPage() { ); function snapTranscriptToEndAfterFrame() { - const queuedScrollTop = messageScroll.current?.scrollTop; - if (queuedScrollTop === undefined) return; + const queuedElement = messageScroll.current; + if (!queuedElement) return; + const queuedScrollTop = queuedElement.scrollTop; window.requestAnimationFrame(() => { const element = messageScroll.current; - if (element && transcriptCanSnapAfterFrame(element, queuedScrollTop)) { - element.scrollTop = element.scrollHeight; + if (transcriptCanSnapAfterFrame(element, queuedElement, queuedScrollTop)) { + queuedElement.scrollTop = queuedElement.scrollHeight; } }); }