Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 37 additions & 2 deletions app/session/[id].tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<string | null>(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
Expand Down Expand Up @@ -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
Expand Down
79 changes: 79 additions & 0 deletions src/lib/auto-scroll.test.ts
Original file line number Diff line number Diff line change
@@ -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))
})
65 changes: 65 additions & 0 deletions src/lib/auto-scroll.ts
Original file line number Diff line number Diff line change
@@ -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}`
}
Loading