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,135 +1181,163 @@ 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(
key: const ValueKey('actual_messages'),
controller: _scrollController,
keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag,
physics: const AlwaysScrollableScrollPhysics(),
cacheExtent: 600,
slivers: [
SliverPadding(
padding: EdgeInsets.fromLTRB(
Spacing.lg,
topPadding,
Spacing.lg,
bottomPadding,
),
sliver: OptimizedSliverList<ChatMessage>(
items: messages,
itemBuilder: (context, message, index) {
final isUser = message.role == 'user';
final isStreaming = message.isStreaming;
final isSelected = _selectedMessageIds.contains(message.id); // Check if any message is currently streaming
final isStreaming = messages.any((msg) => msg.isStreaming);
// Resolve a friendly model display name for message headers return NotificationListener<ScrollNotification>(
String? displayModelName; onNotification: (notification) {
Model? matchedModel; // Detect user-initiated scroll (drag gesture)
final rawModel = message.model; if (notification is ScrollStartNotification &&
if (rawModel != null && rawModel.isNotEmpty) { notification.dragDetails != null) {
final modelsAsync = ref.watch(modelsProvider); // User started dragging - pause auto-scroll during generation
if (modelsAsync.hasValue) { if (isStreaming && !_userPausedAutoScroll) {
final models = modelsAsync.value!; setState(() {
try { _userPausedAutoScroll = true;
// Prefer exact ID match; fall back to exact name match });
final match = models.firstWhere( }
(m) => m.id == rawModel || m.name == rawModel, }
); // Re-enable auto-scroll when user scrolls to bottom
matchedModel = match; if (notification is ScrollEndNotification) {
displayModelName = _formatModelDisplayName(match.name); final distanceFromBottom = _distanceFromBottom();
} catch (_) { if (distanceFromBottom <= 5 && _userPausedAutoScroll) {
// As a fallback, format the raw value to be more readable setState(() {
_userPausedAutoScroll = false;
});
}
}
return false; // Allow notification to continue bubbling
},
child: CustomScrollView(
key: const ValueKey('actual_messages'),
controller: _scrollController,
keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag,
physics: const AlwaysScrollableScrollPhysics(),
cacheExtent: 600,
slivers: [
SliverPadding(
padding: EdgeInsets.fromLTRB(
Spacing.lg,
topPadding,
Spacing.lg,
bottomPadding,
),
sliver: OptimizedSliverList<ChatMessage>(
items: messages,
itemBuilder: (context, message, index) {
final isUser = message.role == 'user';
final isStreaming = message.isStreaming;
final isSelected = _selectedMessageIds.contains(message.id);
// Resolve a friendly model display name for message headers
String? displayModelName;
Model? matchedModel;
final rawModel = message.model;
if (rawModel != null && rawModel.isNotEmpty) {
final modelsAsync = ref.watch(modelsProvider);
if (modelsAsync.hasValue) {
final models = modelsAsync.value!;
try {
// Prefer exact ID match; fall back to exact name match
final match = models.firstWhere(
(m) => m.id == rawModel || m.name == rawModel,
);
matchedModel = match;
displayModelName = _formatModelDisplayName(match.name);
} catch (_) {
// As a fallback, format the raw value to be more readable
displayModelName = _formatModelDisplayName(rawModel);
}
} else {
// Models not loaded yet; format raw value for readability
displayModelName = _formatModelDisplayName(rawModel); displayModelName = _formatModelDisplayName(rawModel);
} }
}
final modelIconUrl = resolveModelIconUrlForModel(
apiService,
matchedModel,
);
var hasUserBubbleBelow = false;
var hasAssistantBubbleBelow = false;
for (var i = index + 1; i < messages.length; i++) {
final role = messages[i].role;
if (role == 'user') {
hasUserBubbleBelow = true;
break;
}
if (role == 'assistant') {
hasAssistantBubbleBelow = true;
break;
}
}
// Hide archived assistant variants in the linear view
final isArchivedVariant =
!isUser && (message.metadata?['archivedVariant'] == true);
if (isArchivedVariant) {
return const SizedBox.shrink();
}
final showFollowUps =
!isUser && !hasUserBubbleBelow && !hasAssistantBubbleBelow;
// Wrap message in selection container if in selection mode
Widget messageWidget;
// Use documentation style for assistant messages, bubble for user messages
if (isUser) {
messageWidget = UserMessageBubble(
key: ValueKey('user-${message.id}'),
message: message,
isUser: isUser,
isStreaming: isStreaming,
modelName: displayModelName,
onCopy: () => _copyMessage(message.content),
onRegenerate: () => _regenerateMessage(message),
);
} else { } else {
// Models not loaded yet; format raw value for readability messageWidget = assistant.AssistantMessageWidget(
displayModelName = _formatModelDisplayName(rawModel); key: ValueKey('assistant-${message.id}'),
message: message,
isStreaming: isStreaming,
showFollowUps: showFollowUps,
modelName: displayModelName,
modelIconUrl: modelIconUrl,
onCopy: () => _copyMessage(message.content),
onRegenerate: () => _regenerateMessage(message),
);
} }
}
final modelIconUrl = resolveModelIconUrlForModel( // Add selection functionality if in selection mode
apiService, if (_isSelectionMode) {
matchedModel, return _SelectableMessageWrapper(
); isSelected: isSelected,
onTap: () => _toggleMessageSelection(message.id),
var hasUserBubbleBelow = false; onLongPress: () {
var hasAssistantBubbleBelow = false; if (!_isSelectionMode) {
for (var i = index + 1; i < messages.length; i++) { _toggleSelectionMode();
final role = messages[i].role; _toggleMessageSelection(message.id);
if (role == 'user') { }
hasUserBubbleBelow = true; },
break; child: messageWidget,
} );
if (role == 'assistant') { } else {
hasAssistantBubbleBelow = true; return GestureDetector(
break; onLongPress: () {
}
}
// Hide archived assistant variants in the linear view
final isArchivedVariant =
!isUser && (message.metadata?['archivedVariant'] == true);
if (isArchivedVariant) {
return const SizedBox.shrink();
}
final showFollowUps =
!isUser && !hasUserBubbleBelow && !hasAssistantBubbleBelow;
// Wrap message in selection container if in selection mode
Widget messageWidget;
// Use documentation style for assistant messages, bubble for user messages
if (isUser) {
messageWidget = UserMessageBubble(
key: ValueKey('user-${message.id}'),
message: message,
isUser: isUser,
isStreaming: isStreaming,
modelName: displayModelName,
onCopy: () => _copyMessage(message.content),
onRegenerate: () => _regenerateMessage(message),
);
} else {
messageWidget = assistant.AssistantMessageWidget(
key: ValueKey('assistant-${message.id}'),
message: message,
isStreaming: isStreaming,
showFollowUps: showFollowUps,
modelName: displayModelName,
modelIconUrl: modelIconUrl,
onCopy: () => _copyMessage(message.content),
onRegenerate: () => _regenerateMessage(message),
);
}
// Add selection functionality if in selection mode
if (_isSelectionMode) {
return _SelectableMessageWrapper(
isSelected: isSelected,
onTap: () => _toggleMessageSelection(message.id),
onLongPress: () {
if (!_isSelectionMode) {
_toggleSelectionMode(); _toggleSelectionMode();
_toggleMessageSelection(message.id); _toggleMessageSelection(message.id);
} },
}, child: messageWidget,
child: messageWidget, );
); }
} else { },
return GestureDetector( ),
onLongPress: () {
_toggleSelectionMode();
_toggleMessageSelection(message.id);
},
child: messageWidget,
);
}
},
), ),
), ],
], ),
); );
} }
@@ -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