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
@@ -3941,3 +3941,5 @@ INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, chang
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBHBJ5T7HAQ9CA8XQMX43A2C', 'status', 'in_progress', 'done', NULL, '2026-06-11 22:18:25', '2026-06-11 22:18:25', '2026-06-11 22:18:25', NULL, '307a819cf82cf2ed49b3fd9e192992b6', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBHBV0465906BY3QFAY9F1YM', 'status', 'backlog', 'in_progress', NULL, '2026-06-11 22:18:56', '2026-06-11 22:18:56', '2026-06-11 22:18:56', NULL, '6b229d2b93c00f6197b026610c921273', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBHBV0465906BY3QFAY9F1YM', 'status', 'in_progress', 'done', NULL, '2026-06-11 22:24:07', '2026-06-11 22:24:07', '2026-06-11 22:24:07', NULL, '6e0986eb4b0c15223d7b373bbbc421cb', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBHBYTJ4E7ZBY6DWWNT1S16M', 'status', 'backlog', 'in_progress', NULL, '2026-06-11 22:24:34', '2026-06-11 22:24:34', '2026-06-11 22:24:34', NULL, '151c44c4c697f83900b1cc003c6a94f6', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBHBYTJ4E7ZBY6DWWNT1S16M', 'status', 'in_progress', 'done', NULL, '2026-06-11 22:26:15', '2026-06-11 22:26:15', '2026-06-11 22:26:15', NULL, '05386705854fd48ee75d7a3dfbfd5bc9', 2) ON CONFLICT(hash) DO NOTHING;
+6
View File
@@ -3985,3 +3985,9 @@ Fix: cache the kernel ref in didChangeDependencies, delete the catch-alls. Combi
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBHBV0465906BY3QFAY9F1YM', 'bug', '06FBHBGHNEQTAEPGNJKN42C1E8', 'Kernel lookup in dispose() leaks shell PTYs and settings listeners', 'lib/builtin/terminal/src/terminal_pane.dart:131-137 calls ClideKernel.of(context) from dispose() — illegal ancestor lookup, swallowed by catch (_) — so pane.close is never sent and the backend PTY + daemon pane leak on every closed terminal pane. The same idiom leaks the settings listener in every disposed ClaudePane (lib/builtin/claude/src/claude_pane.dart:460-466).
Fix: cache the kernel ref in didChangeDependencies, delete the catch-alls. Combined with the PTY natural-exit fd leak this is a two-stage leak pipeline. Tests: closing a terminal pane sends pane.close; disposing a ClaudePane removes its settings listener.', 'done', 'high', NULL, NULL, NULL, '2026-06-11 21:56:01', '2026-06-11 22:24:07', NULL, '167f8b01f1fcf2ef0bcc845585ca17aa', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBHBYTJ4E7ZBY6DWWNT1S16M', 'bug', '06FBHBGHNEQTAEPGNJKN42C1E8', 'Conversation auto-scroll yanks a scrolled-up reader to the bottom on every streamed token', 'lib/builtin/claude/src/conversation_view.dart:268-277 — the _atBottom pin exists but is only consulted on viewport resize, not on new items. Anyone reading earlier output during a long streaming reply is dragged to the bottom continuously.
Fix: gate the new-item auto-scroll on _atBottom (one-line) and add the missing twin test: scrolled-up viewport stays put when items stream in; at-bottom viewport follows.', 'in_progress', 'high', NULL, NULL, NULL, '2026-06-11 21:56:33', '2026-06-11 22:24:34', NULL, '2cf4e96fedd8de400239157f53ac0b94', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBHBYTJ4E7ZBY6DWWNT1S16M', 'bug', '06FBHBGHNEQTAEPGNJKN42C1E8', 'Conversation auto-scroll yanks a scrolled-up reader to the bottom on every streamed token', 'lib/builtin/claude/src/conversation_view.dart:268-277 — the _atBottom pin exists but is only consulted on viewport resize, not on new items. Anyone reading earlier output during a long streaming reply is dragged to the bottom continuously.
Fix: gate the new-item auto-scroll on _atBottom (one-line) and add the missing twin test: scrolled-up viewport stays put when items stream in; at-bottom viewport follows.', 'done', 'high', NULL, NULL, NULL, '2026-06-11 21:56:33', '2026-06-11 22:26:15', NULL, '1340fb7aac3cdf06476ce74248fda6c0', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
+5
View File
@@ -41,6 +41,11 @@ heading, and (b) bumping `pubspec.yaml` `version:` in the same commit.
sent — the backend PTY and daemon pane leaked on every closed terminal
pane, and Claude panes leaked a settings listener the same way. (T-366)
- **Scrolling up during a streaming reply no longer fights the auto-scroll.**
The conversation followed the tail on every streamed token regardless of
scroll position, dragging a reader back to the bottom; it now follows only
while already pinned there. (T-368)
- **Accepting ExitPlanMode now leaves plan mode in the conversation panel.**
Approving Claude's plan (the ExitPlanMode tool) transitioned the underlying
session out of plan mode, but clide's tracked permission mode didn't follow,
@@ -268,9 +268,13 @@ class _ConversationViewState extends State<ConversationView> {
void _onChanged() {
if (!mounted) return;
setState(() {});
// Follow the tail — jump to the bottom after the new item lays out.
// Follow the tail — but only when already pinned to it. New items arrive
// on every streamed token; jumping unconditionally yanks a reader who
// scrolled up back to the bottom for the whole reply (T-368, twin of the
// T-297 resize gate).
if (!_atBottom) return;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (_scroll.hasClients) {
if (_scroll.hasClients && _atBottom) {
_scroll.jumpTo(_scroll.position.maxScrollExtent);
}
});
@@ -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');
});
}