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,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
|
||||||
|
|||||||
Reference in New Issue
Block a user