re-anchor the conversation tail when the bottom input zone resizes (T-297)
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) <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -69,10 +69,28 @@ class ConversationView extends StatefulWidget {
|
||||
class _ConversationViewState extends State<ConversationView> {
|
||||
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<ConversationView> {
|
||||
@override
|
||||
void dispose() {
|
||||
widget.controller.removeListener(_onChanged);
|
||||
_scroll.removeListener(_trackBottom);
|
||||
_scroll.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
@@ -289,9 +308,26 @@ class _ConversationViewState extends State<ConversationView> {
|
||||
},
|
||||
),
|
||||
);
|
||||
// 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<ScrollableState>(find.byType(Scrollable).first).position;
|
||||
|
||||
Future<ConversationController> pump(WidgetTester tester, ValueNotifier<double> bottomH) async {
|
||||
tester.view.physicalSize = const Size(600, 600);
|
||||
tester.view.devicePixelRatio = 1.0;
|
||||
addTearDown(() {
|
||||
tester.view.resetPhysicalSize();
|
||||
tester.view.resetDevicePixelRatio();
|
||||
});
|
||||
final stream = StreamController<ConversationItem>.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<double>(
|
||||
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<double>(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<double>(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');
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user