diff --git a/.pql/changelog/ticket_history/2026-06.sql b/.pql/changelog/ticket_history/2026-06.sql index 9b53d57f..431003bb 100644 --- a/.pql/changelog/ticket_history/2026-06.sql +++ b/.pql/changelog/ticket_history/2026-06.sql @@ -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; diff --git a/.pql/changelog/tickets/2026-06.sql b/.pql/changelog/tickets/2026-06.sql index 5af6defe..da2d2933 100644 --- a/.pql/changelog/tickets/2026-06.sql +++ b/.pql/changelog/tickets/2026-06.sql @@ -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); diff --git a/CHANGELOG.md b/CHANGELOG.md index d42b73af..1f1b5244 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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, diff --git a/lib/builtin/claude/src/conversation_view.dart b/lib/builtin/claude/src/conversation_view.dart index 28c4a08a..39d95892 100644 --- a/lib/builtin/claude/src/conversation_view.dart +++ b/lib/builtin/claude/src/conversation_view.dart @@ -268,9 +268,13 @@ class _ConversationViewState extends State { 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); } }); diff --git a/test/builtin/claude/conversation_scroll_test.dart b/test/builtin/claude/conversation_scroll_test.dart index 5e817789..f207a7eb 100644 --- a/test/builtin/claude/conversation_scroll_test.dart +++ b/test/builtin/claude/conversation_scroll_test.dart @@ -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(find.byType(Scrollable).first).position; - Future pump(WidgetTester tester, ValueNotifier bottomH) async { + Future<(ConversationController, StreamController)> pump(WidgetTester tester, ValueNotifier 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(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(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'); + }); }