feat(chat): improve auto-scroll behavior during message generation
This commit is contained in:
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user