gate conversation auto-scroll on the bottom pin (T-368)

New items arrive on every streamed token, and _onChanged jumped to
maxScrollExtent unconditionally — so a reader who scrolled up was
dragged back to the tail continuously for the whole reply. The
_atBottom pin already existed for viewport resizes (T-297); apply it
to the new-item path too, re-checking after layout since the user can
scroll during the frame. Twin tests added beside the T-297 pair:
pinned view keeps following, scrolled-up view stays put.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-06-12 00:26:32 +02:00
co-authored by Claude Fable 5
parent 75fc2719a0
commit 390ab2b64e
5 changed files with 63 additions and 4 deletions
@@ -1,6 +1,10 @@
/// T-297: when the bottom interaction zone resizes, the conversation re-anchors
/// to the tail (if pinned there) so content isn't left hidden behind the taller
/// box — and leaves a scrolled-up reader undisturbed.
///
/// T-368: the same gate applies to NEW ITEMS — they arrive on every streamed
/// token, and following the tail unconditionally yanked a scrolled-up reader
/// to the bottom for the whole reply.
library;
import 'dart:async';
@@ -24,7 +28,7 @@ void main() {
ScrollPosition scrollPos(WidgetTester tester) => tester.state<ScrollableState>(find.byType(Scrollable).first).position;
Future<ConversationController> pump(WidgetTester tester, ValueNotifier<double> bottomH) async {
Future<(ConversationController, StreamController<ConversationItem>)> pump(WidgetTester tester, ValueNotifier<double> bottomH) async {
tester.view.physicalSize = const Size(600, 600);
tester.view.devicePixelRatio = 1.0;
addTearDown(() {
@@ -58,7 +62,7 @@ void main() {
stream.add(_asst('conversation line number $i', i));
}
await tester.pumpAndSettle();
return c;
return (c, stream);
}
testWidgets('a growing bottom zone re-anchors the tail when pinned to bottom', (tester) async {
@@ -95,4 +99,42 @@ void main() {
expect(after.pixels, closeTo(before, 1), reason: 'offset preserved; not re-anchored to bottom');
expect(after.pixels, lessThan(after.maxScrollExtent - 8), reason: 'still not at the tail');
});
testWidgets('new streamed items keep following the tail when pinned', (tester) async {
final bottomH = ValueNotifier<double>(40);
addTearDown(bottomH.dispose);
final (_, stream) = await pump(tester, bottomH);
final p = scrollPos(tester);
expect(p.pixels, closeTo(p.maxScrollExtent, 1), reason: 'starts pinned to the tail');
for (var i = 40; i < 60; i++) {
stream.add(_asst('streamed delta number $i', i));
}
await tester.pumpAndSettle();
final p2 = scrollPos(tester);
expect(p2.pixels, closeTo(p2.maxScrollExtent, 1), reason: 'still pinned after new items streamed in');
});
testWidgets('new streamed items do not yank a scrolled-up reader (T-368)', (tester) async {
final bottomH = ValueNotifier<double>(40);
addTearDown(bottomH.dispose);
final (_, stream) = await pump(tester, bottomH);
// Scroll up, away from the tail.
scrollPos(tester).jumpTo(30);
await tester.pump();
final before = scrollPos(tester).pixels;
expect(before, closeTo(30, 1));
for (var i = 40; i < 60; i++) {
stream.add(_asst('streamed delta number $i', i));
}
await tester.pumpAndSettle();
final after = scrollPos(tester);
expect(after.pixels, closeTo(before, 1), reason: 'reading position preserved while the reply streams');
expect(after.pixels, lessThan(after.maxScrollExtent - 8), reason: 'still not at the tail');
});
}