feat(chat): improve auto-scroll behavior during message generation

This commit is contained in:
cogwheel0
2025-12-14 20:19:16 +05:30
parent f43a4d82aa
commit 4976172d8b

View File

@@ -70,6 +70,7 @@ class _ChatPageState extends ConsumerState<ChatPage> {
bool _autoScrollCallbackScheduled = false; bool _autoScrollCallbackScheduled = false;
bool _pendingConversationScrollReset = false; bool _pendingConversationScrollReset = false;
bool _suppressKeepPinnedOnce = false; // skip keep-pinned bottom after reset bool _suppressKeepPinnedOnce = false; // skip keep-pinned bottom after reset
bool _userPausedAutoScroll = false; // user scrolled away during generation
String? _cachedGreetingName; String? _cachedGreetingName;
bool _greetingReady = false; bool _greetingReady = false;
@@ -132,6 +133,7 @@ class _ChatPageState extends ConsumerState<ChatPage> {
_shouldAutoScrollToBottom = true; _shouldAutoScrollToBottom = true;
_pendingConversationScrollReset = false; _pendingConversationScrollReset = false;
_userPausedAutoScroll = false;
_scheduleAutoScrollToBottom(); _scheduleAutoScrollToBottom();
} }
@@ -444,6 +446,9 @@ class _ChatPageState extends ConsumerState<ChatPage> {
// Clear attachments after successful send // Clear attachments after successful send
ref.read(attachedFilesProvider.notifier).clearAll(); ref.read(attachedFilesProvider.notifier).clearAll();
// Reset auto-scroll pause when user sends a new message
_userPausedAutoScroll = false;
// Scroll to bottom after enqueuing (only if user was near bottom) // Scroll to bottom after enqueuing (only if user was near bottom)
WidgetsBinding.instance.addPostFrameCallback((_) { WidgetsBinding.instance.addPostFrameCallback((_) {
// Only auto-scroll if user was already near the bottom (within 300 px) // Only auto-scroll if user was already near the bottom (within 300 px)
@@ -890,6 +895,12 @@ class _ChatPageState extends ConsumerState<ChatPage> {
void _scrollToBottom({bool smooth = true}) { void _scrollToBottom({bool smooth = true}) {
if (!_scrollController.hasClients) return; if (!_scrollController.hasClients) return;
// Reset user pause when explicitly scrolling to bottom
if (_userPausedAutoScroll) {
setState(() {
_userPausedAutoScroll = false;
});
}
final position = _scrollController.position; final position = _scrollController.position;
final maxScroll = position.maxScrollExtent; final maxScroll = position.maxScrollExtent;
final target = maxScroll.isFinite ? maxScroll : 0.0; final target = maxScroll.isFinite ? maxScroll : 0.0;
@@ -1145,7 +1156,8 @@ class _ChatPageState extends ConsumerState<ChatPage> {
if (_shouldAutoScrollToBottom) { if (_shouldAutoScrollToBottom) {
_scheduleAutoScrollToBottom(); _scheduleAutoScrollToBottom();
} else { } else if (!_userPausedAutoScroll) {
// Only keep-pinned to bottom if user hasn't paused auto-scroll
WidgetsBinding.instance.addPostFrameCallback((_) { WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return; if (!mounted) return;
if (_suppressKeepPinnedOnce) { if (_suppressKeepPinnedOnce) {
@@ -1154,6 +1166,8 @@ class _ChatPageState extends ConsumerState<ChatPage> {
_suppressKeepPinnedOnce = false; _suppressKeepPinnedOnce = false;
return; return;
} }
// Skip if user has paused auto-scroll (double-check in callback)
if (_userPausedAutoScroll) return;
const double keepPinnedThreshold = 60.0; const double keepPinnedThreshold = 60.0;
final distanceFromBottom = _distanceFromBottom(); final distanceFromBottom = _distanceFromBottom();
if (distanceFromBottom > 0 && if (distanceFromBottom > 0 &&
@@ -1167,7 +1181,34 @@ class _ChatPageState extends ConsumerState<ChatPage> {
final topPadding = final topPadding =
MediaQuery.of(context).padding.top + kToolbarHeight + Spacing.md; MediaQuery.of(context).padding.top + kToolbarHeight + Spacing.md;
final bottomPadding = Spacing.lg + _inputHeight; final bottomPadding = Spacing.lg + _inputHeight;
return CustomScrollView(
// Check if any message is currently streaming
final isStreaming = messages.any((msg) => msg.isStreaming);
return NotificationListener<ScrollNotification>(
onNotification: (notification) {
// Detect user-initiated scroll (drag gesture)
if (notification is ScrollStartNotification &&
notification.dragDetails != null) {
// User started dragging - pause auto-scroll during generation
if (isStreaming && !_userPausedAutoScroll) {
setState(() {
_userPausedAutoScroll = true;
});
}
}
// Re-enable auto-scroll when user scrolls to bottom
if (notification is ScrollEndNotification) {
final distanceFromBottom = _distanceFromBottom();
if (distanceFromBottom <= 5 && _userPausedAutoScroll) {
setState(() {
_userPausedAutoScroll = false;
});
}
}
return false; // Allow notification to continue bubbling
},
child: CustomScrollView(
key: const ValueKey('actual_messages'), key: const ValueKey('actual_messages'),
controller: _scrollController, controller: _scrollController,
keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag, keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag,
@@ -1296,6 +1337,7 @@ class _ChatPageState extends ConsumerState<ChatPage> {
), ),
), ),
], ],
),
); );
} }
@@ -1489,6 +1531,7 @@ class _ChatPageState extends ConsumerState<ChatPage> {
); );
if (conversationId != _lastConversationId) { if (conversationId != _lastConversationId) {
_lastConversationId = conversationId; _lastConversationId = conversationId;
_userPausedAutoScroll = false; // Reset pause on conversation change
if (conversationId == null) { if (conversationId == null) {
_shouldAutoScrollToBottom = true; _shouldAutoScrollToBottom = true;
_pendingConversationScrollReset = false; _pendingConversationScrollReset = false;
@@ -1531,6 +1574,10 @@ class _ChatPageState extends ConsumerState<ChatPage> {
final canScroll = final canScroll =
_scrollController.hasClients && _scrollController.hasClients &&
_scrollController.position.maxScrollExtent > 0; _scrollController.position.maxScrollExtent > 0;
// Check if any message is currently streaming (for scroll button indicator)
final isStreamingAnyMessage = ref
.watch(chatMessagesProvider)
.any((msg) => msg.isStreaming);
// On keyboard open, if already near bottom, auto-scroll to bottom to keep input visible // On keyboard open, if already near bottom, auto-scroll to bottom to keep input visible
if (keyboardVisible && !_lastKeyboardVisible) { if (keyboardVisible && !_lastKeyboardVisible) {
@@ -2209,10 +2256,25 @@ class _ChatPageState extends ConsumerState<ChatPage> {
child: IconButton( child: IconButton(
onPressed: _scrollToBottom, onPressed: _scrollToBottom,
splashRadius: 24, splashRadius: 24,
tooltip:
_userPausedAutoScroll &&
isStreamingAnyMessage
? 'Resume auto-scroll'
: 'Scroll to bottom',
icon: Icon( icon: Icon(
Platform.isIOS // Show play icon when auto-scroll
? CupertinoIcons.arrow_down // is paused during streaming
: Icons.keyboard_arrow_down, _userPausedAutoScroll &&
isStreamingAnyMessage
? (Platform.isIOS
? CupertinoIcons
.play_arrow_solid
: Icons.play_arrow)
: (Platform.isIOS
? CupertinoIcons
.arrow_down
: Icons
.keyboard_arrow_down),
size: IconSize.lg, size: IconSize.lg,
color: context color: context
.conduitTheme .conduitTheme