From 6efb3b5d2b7fc7b2fc5aad09f233af641656a813 Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Tue, 9 Jun 2026 18:22:14 +0200 Subject: [PATCH] re-anchor the conversation tail when the bottom input zone resizes (T-297) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the interaction zone grows/shrinks (composer ↔ permission prompt / AskUserQuestion, D-78) the conversation viewport changed height but the scroll offset didn't follow, leaving the last card hidden behind the taller box. Track whether the view is pinned to the tail; a LayoutBuilder around the list detects the viewport-height change and re-jumps to the bottom only when pinned, so a scrolled-up reader is undisturbed. Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 5 + lib/builtin/claude/src/conversation_view.dart | 38 +++++++- .../claude/conversation_scroll_test.dart | 96 +++++++++++++++++++ 3 files changed, 138 insertions(+), 1 deletion(-) create mode 100644 test/builtin/claude/conversation_scroll_test.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index b58b3c08..b3d7e604 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -224,6 +224,11 @@ heading, and (b) bumping `pubspec.yaml` `version:` in the same commit. ### Fixed +- **The conversation re-anchors when the input area resizes.** Opening a + permission prompt or AskUserQuestion (which grows the bottom zone, D-78) no + longer hides the last message behind it — when pinned to the tail, the view + re-scrolls to keep it visible; a scrolled-up reader is left undisturbed. + (T-297) - **The chosen theme now persists across restarts**, per repo. Picking a theme (status-bar switcher or Settings) writes it to the repo's `.clide/settings.yaml` (and a global default), and reopening the repo restores diff --git a/lib/builtin/claude/src/conversation_view.dart b/lib/builtin/claude/src/conversation_view.dart index ceb9222f..a22c8f63 100644 --- a/lib/builtin/claude/src/conversation_view.dart +++ b/lib/builtin/claude/src/conversation_view.dart @@ -69,10 +69,28 @@ class ConversationView extends StatefulWidget { class _ConversationViewState extends State { final ScrollController _scroll = ScrollController(); + /// Whether the view is pinned to the tail — only then do we re-anchor on a + /// viewport resize, so a user who scrolled up isn't yanked back down (T-297). + bool _atBottom = true; + + /// Last laid-out viewport height; a change means the bottom interaction zone + /// grew/shrank (a permission prompt / AskUserQuestion opened, D-78) and the + /// tail needs re-anchoring above the newly-sized box. + double? _lastViewportHeight; + + static const double _bottomEpsilon = 8; + @override void initState() { super.initState(); widget.controller.addListener(_onChanged); + _scroll.addListener(_trackBottom); + } + + void _trackBottom() { + if (!_scroll.hasClients) return; + final p = _scroll.position; + _atBottom = (p.maxScrollExtent - p.pixels) <= _bottomEpsilon; } @override @@ -87,6 +105,7 @@ class _ConversationViewState extends State { @override void dispose() { widget.controller.removeListener(_onChanged); + _scroll.removeListener(_trackBottom); _scroll.dispose(); super.dispose(); } @@ -289,9 +308,26 @@ class _ConversationViewState extends State { }, ), ); + // Re-anchor the tail when the viewport height changes — the bottom + // interaction zone (composer ↔ permission prompt / AskUserQuestion, D-78) + // resizing would otherwise leave the last card hidden behind the taller box + // (T-297). Only when already pinned to the bottom, so scrolled-up reading + // is undisturbed. + final sized = LayoutBuilder( + builder: (ctx, constraints) { + final h = constraints.maxHeight; + if (_lastViewportHeight != null && h != _lastViewportHeight && _atBottom) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (_scroll.hasClients) _scroll.jumpTo(_scroll.position.maxScrollExtent); + }); + } + _lastViewportHeight = h; + return list; + }, + ); return ColoredBox( color: tokens.panelBackground, - child: widget.wrapInSelectionArea ? ClideSelectionArea(child: list) : list, + child: widget.wrapInSelectionArea ? ClideSelectionArea(child: sized) : sized, ); } } diff --git a/test/builtin/claude/conversation_scroll_test.dart b/test/builtin/claude/conversation_scroll_test.dart new file mode 100644 index 00000000..bcdd33fb --- /dev/null +++ b/test/builtin/claude/conversation_scroll_test.dart @@ -0,0 +1,96 @@ +/// 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. +library; + +import 'dart:async'; + +import 'package:clide/builtin/claude/src/conversation_controller.dart'; +import 'package:clide/builtin/claude/src/conversation_view.dart'; +import 'package:clide/builtin/claude/src/transcript_reader.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../../helpers/kernel_fixture.dart'; +import '../../helpers/widget_harness.dart'; + +final _t = DateTime.utc(2026, 1, 1); +AssistantTextMessage _asst(String text, int i) => AssistantTextMessage(uuid: 'a$i', timestamp: _t, isSidechain: false, text: text); + +void main() { + late KernelFixture f; + setUp(() async => f = await KernelFixture.create()); + tearDown(() => f.dispose()); + + ScrollPosition scrollPos(WidgetTester tester) => tester.state(find.byType(Scrollable).first).position; + + Future pump(WidgetTester tester, ValueNotifier bottomH) async { + tester.view.physicalSize = const Size(600, 600); + tester.view.devicePixelRatio = 1.0; + addTearDown(() { + tester.view.resetPhysicalSize(); + tester.view.resetDevicePixelRatio(); + }); + final stream = StreamController.broadcast(); + final c = ConversationController(stream: stream.stream); + addTearDown(c.dispose); + await tester.pumpWidget(harness( + f, + SizedBox( + width: 600, + height: 600, + child: Column( + children: [ + Expanded(child: ConversationView(controller: c)), + // Stand-in for the interaction zone (composer / prompt) whose height + // changes; ValueListenableBuilder rebuilds just the box. + ValueListenableBuilder( + valueListenable: bottomH, + builder: (_, h, __) => SizedBox(height: h, width: 600), + ), + ], + ), + ), + )); + for (var i = 0; i < 40; i++) { + stream.add(_asst('conversation line number $i', i)); + } + await tester.pumpAndSettle(); + return c; + } + + testWidgets('a growing bottom zone re-anchors the tail when pinned to bottom', (tester) async { + final bottomH = ValueNotifier(40); + addTearDown(bottomH.dispose); + await pump(tester, bottomH); + + final p = scrollPos(tester); + expect(p.pixels, closeTo(p.maxScrollExtent, 1), reason: 'starts pinned to the tail'); + + // The interaction zone grows (a permission prompt opened). + bottomH.value = 220; + await tester.pumpAndSettle(); + + final p2 = scrollPos(tester); + expect(p2.pixels, closeTo(p2.maxScrollExtent, 1), reason: 'still pinned after the zone grew'); + }); + + testWidgets('a scrolled-up reader is not yanked when the zone resizes', (tester) async { + final bottomH = ValueNotifier(40); + addTearDown(bottomH.dispose); + 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)); + + bottomH.value = 220; + await tester.pumpAndSettle(); + + final after = scrollPos(tester); + 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'); + }); +}