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:
2026-06-09 18:22:14 +02:00
co-authored by Claude Opus 4.8
parent 3414db148a
commit 6efb3b5d2b
3 changed files with 138 additions and 1 deletions
+37 -1
View File
@@ -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,
);
}
}