From 8c7747a069825fd68df8c54c693d1fe8ea05354c Mon Sep 17 00:00:00 2001 From: Den <2119348+dzianisv@users.noreply.github.com> Date: Fri, 13 Nov 2026 14:44:31 +0000 Subject: [PATCH] fix(chat): auto-scroll transcript to newest message (#155) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Port the auto-scroll policy from PR #182 (commit c60ebaf) in scoped form: src/lib/auto-scroll.ts + its 10 tests, byte-identical to PR head 1181372f, wired into app/session/[id].tsx. Root cause: the transcript is an inverted FlatList and nothing observed content growth, so an arriving or streaming reply never followed; the only scroll-to-newest path was the manual button. maintainVisibleContentPosition stays (it stops jumps when older pages load), so the fix scrolls explicitly via the existing scrollToBottom when new content arrives — but only while the view is within 200px of the newest message, so a user scrolled up reading history is never yanked. The scroll-to-bottom button now uses the same predicate, so the two cannot disagree about where 'bottom' is. Signature kept at #182's 2-field form (count:length): the only case the 4-field form would catch is a same-length swap of the newest message, negligible for a streaming transcript. Gates: npm run check:versions clean (0.4.17/44), tsc --noEmit clean, full suite 405 pass / 0 fail (395 baseline + 10 ported auto-scroll tests). Device proof still owed before merge. --- app/session/[id].tsx | 39 +++++++++++++++++- src/lib/auto-scroll.test.ts | 79 +++++++++++++++++++++++++++++++++++++ src/lib/auto-scroll.ts | 65 ++++++++++++++++++++++++++++++ 3 files changed, 181 insertions(+), 2 deletions(-) create mode 100644 src/lib/auto-scroll.test.ts create mode 100644 src/lib/auto-scroll.ts diff --git a/app/session/[id].tsx b/app/session/[id].tsx index db6c5e8a..a94029d0 100644 --- a/app/session/[id].tsx +++ b/app/session/[id].tsx @@ -43,6 +43,7 @@ import { useAuth } from "../../src/stores/auth" import { useCatalog } from "../../src/stores/catalog" import { useSpeech } from "../../src/lib/speech" import { messagePartsToSelectableText } from "../../src/lib/message-text" +import { shouldAutoScroll, shouldShowScrollButton, transcriptSignature } from "../../src/lib/auto-scroll" import { jsKeyboardPaddingApplies, keyboardPadding, @@ -320,6 +321,35 @@ export default function SessionScreen() { flatListRef.current?.scrollToOffset({ offset: 0, animated }) }, []) + // Follow new content (issue #155: "Message cannot be scrolled automatically"). + // + // scrollToBottom() was previously only wired to the manual scroll button, so + // nothing followed an arriving or streaming message. Compounding that, + // maintainVisibleContentPosition (below) deliberately holds visible items + // still when the data changes — and since new messages are inserted at index + // 0 of this inverted list, that setting parks new content just outside the + // viewport. That prop is worth keeping (it stops the jump when older pages + // load), so instead scroll explicitly. + // + // Only when the user is already at the bottom: someone who scrolled up to + // read history must not be yanked back down mid-sentence. See + // src/lib/auto-scroll.ts. + const newest = messageData[0] + const contentSignature = transcriptSignature( + messageData.length, + newest ? (newest.parts || []).reduce((n, part) => n + (part.text?.length ?? 0), 0) : 0, + ) + const prevSignatureRef = useRef(null) + useEffect(() => { + const auto = shouldAutoScroll({ + offsetY: scrollOffsetRef.current, + previousSignature: prevSignatureRef.current, + currentSignature: contentSignature, + }) + prevSignatureRef.current = contentSignature + if (auto) scrollToBottom(true) + }, [contentSignature, scrollToBottom]) + // Re-select on every focus, not just mount. currentSession/messages/ // permissions are a single global store, and the native stack keeps screens // underneath a pushed one mounted. Without re-selecting on focus, navigating @@ -515,10 +545,15 @@ export default function SessionScreen() { } } - // In inverted mode, offset 0 = bottom. Show scroll button when scrolled away from bottom. + // In inverted mode, offset 0 = bottom (newest message). Track the live + // offset in a ref as well as state: the auto-scroll effect above needs the + // current position without taking `offsetY` as a dependency, which would + // re-run it on every scroll frame. + const scrollOffsetRef = useRef(0) const handleScroll = useCallback((event: any) => { const { contentOffset } = event.nativeEvent - setShowScrollButton(contentOffset.y > 200) + scrollOffsetRef.current = contentOffset.y + setShowScrollButton(shouldShowScrollButton(contentOffset.y)) }, []) // Debounce: onEndReached can fire multiple times during a single scroll gesture diff --git a/src/lib/auto-scroll.test.ts b/src/lib/auto-scroll.test.ts new file mode 100644 index 00000000..e81d3d07 --- /dev/null +++ b/src/lib/auto-scroll.test.ts @@ -0,0 +1,79 @@ +import { test } from "node:test" +import assert from "node:assert/strict" +import { + AT_BOTTOM_THRESHOLD_PX, + isAtBottom, + shouldAutoScroll, + shouldShowScrollButton, + transcriptSignature, +} from "./auto-scroll.ts" + +test("isAtBottom treats offset 0 (newest message, inverted list) as the bottom", () => { + assert.equal(isAtBottom(0), true) + assert.equal(isAtBottom(AT_BOTTOM_THRESHOLD_PX), true) + assert.equal(isAtBottom(AT_BOTTOM_THRESHOLD_PX + 1), false) +}) + +test("isAtBottom tolerates negative overscroll from bounce", () => { + assert.equal(isAtBottom(-40), true) +}) + +test("the scroll button appears exactly when auto-follow stops", () => { + for (const offset of [0, 50, 200, 201, 900]) { + assert.equal(shouldShowScrollButton(offset), !isAtBottom(offset), `offset ${offset}`) + } +}) + +// The reported bug: new content arrived and the view did not follow it. +test("scrolls when new content arrives and the user is at the bottom", () => { + assert.equal( + shouldAutoScroll({ offsetY: 0, previousSignature: "3:100", currentSignature: "4:0" }), + true, + ) +}) + +test("follows a streaming reply as it grows, not just on completion", () => { + assert.equal( + shouldAutoScroll({ offsetY: 10, previousSignature: "4:120", currentSignature: "4:260" }), + true, + ) +}) + +// The usual bug in naive fixes: yanking a reader back down. +test("does NOT scroll when the user has scrolled up to read history", () => { + assert.equal( + shouldAutoScroll({ offsetY: 5000, previousSignature: "3:100", currentSignature: "4:0" }), + false, + ) +}) + +test("does not scroll when content is unchanged, so it can't fight a gesture", () => { + assert.equal( + shouldAutoScroll({ offsetY: 0, previousSignature: "4:260", currentSignature: "4:260" }), + false, + ) +}) + +test("scrolls on the very first content (no previous signature)", () => { + assert.equal( + shouldAutoScroll({ offsetY: 0, previousSignature: null, currentSignature: "1:0" }), + true, + ) +}) + +test("a custom threshold is honoured", () => { + assert.equal( + shouldAutoScroll({ offsetY: 300, previousSignature: "1:0", currentSignature: "2:0", threshold: 500 }), + true, + ) + assert.equal( + shouldAutoScroll({ offsetY: 300, previousSignature: "1:0", currentSignature: "2:0", threshold: 100 }), + false, + ) +}) + +test("transcriptSignature changes on new messages and on streaming growth", () => { + assert.notEqual(transcriptSignature(3, 100), transcriptSignature(4, 100)) + assert.notEqual(transcriptSignature(4, 100), transcriptSignature(4, 250)) + assert.equal(transcriptSignature(4, 100), transcriptSignature(4, 100)) +}) diff --git a/src/lib/auto-scroll.ts b/src/lib/auto-scroll.ts new file mode 100644 index 00000000..f892bada --- /dev/null +++ b/src/lib/auto-scroll.ts @@ -0,0 +1,65 @@ +// Auto-scroll policy for the session transcript. +// +// The transcript is an *inverted* FlatList, so contentOffset.y === 0 is the +// newest message ("the bottom" visually) and larger offsets mean the user has +// scrolled back through history. +// +// Two things conspired to make new content unreachable (issue #155, +// "Message cannot be scrolled automatically"): +// +// 1. scrollToBottom() was only ever wired to the manual scroll-to-bottom +// button. Nothing scrolled when a message arrived or while one streamed. +// 2. maintainVisibleContentPosition={{ minIndexForVisible: 0 }} asks the list +// to hold currently-visible items still when the data changes. New +// messages are inserted at index 0 of the inverted list, so that setting +// actively compensates the offset to keep the older items stationary — +// parking the new message just outside the viewport. +// +// (2) is worth keeping: it's what stops the view jumping when older pages load +// via onEndReached. So the fix is to scroll explicitly when new content +// arrives — but only when the user is already at the bottom. Someone who has +// scrolled up to read history must not be yanked back down mid-sentence, +// which is the usual bug in naive "always scroll on new message" fixes. +// +// Dependency-free so it's testable under plain `node --test`. + +// How far from the newest message still counts as "following along". Also the +// threshold at which the scroll-to-bottom button appears, so the button shows +// exactly when auto-follow stops — one number, no drift between the two. +export const AT_BOTTOM_THRESHOLD_PX = 200 + +export function isAtBottom(offsetY: number, threshold: number = AT_BOTTOM_THRESHOLD_PX): boolean { + // Guard against overscroll/bounce producing small negative offsets. + return offsetY <= threshold +} + +export function shouldShowScrollButton(offsetY: number, threshold: number = AT_BOTTOM_THRESHOLD_PX): boolean { + return !isAtBottom(offsetY, threshold) +} + +/** + * Should the transcript scroll itself to the newest message? + * + * Only when the content actually changed *and* the user was already following + * along at the bottom. `contentSignature` is any value that changes as content + * does — message count plus the streaming message's length — so a re-render + * with unchanged content doesn't re-scroll and fight a user's own gesture. + */ +export function shouldAutoScroll(input: { + offsetY: number + previousSignature: string | null + currentSignature: string + threshold?: number +}): boolean { + if (input.previousSignature === input.currentSignature) return false + return isAtBottom(input.offsetY, input.threshold ?? AT_BOTTOM_THRESHOLD_PX) +} + +/** + * A value that changes whenever the transcript gains content: the number of + * messages, plus the size of the newest one so a streaming reply keeps the + * view pinned as it grows rather than only when it completes. + */ +export function transcriptSignature(messageCount: number, newestMessageLength: number): string { + return `${messageCount}:${newestMessageLength}` +}