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:
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user