refactor: enhance chat message handling and scrolling behavior

- Added `sendMessageWithContainer` function to facilitate message sending with a ProviderContainer.
- Updated `_ChatPageState` to improve scroll behavior, ensuring smoother auto-scrolling when near the bottom of the chat.
- Refactored scroll logic to simplify conditions for showing and hiding the scroll-to-bottom button.
- Adjusted the `OptimizedList` widget to correctly handle item indexing based on the reverse property, enhancing list performance and usability.
This commit is contained in:
cogwheel0
2025-09-30 21:17:11 +05:30
parent 7debb7a055
commit 46bd057089
4 changed files with 43 additions and 36 deletions

View File

@@ -1447,6 +1447,15 @@ Future<void> sendMessageFromService(
await _sendMessageInternal(ref, message, attachments, toolIds); await _sendMessageInternal(ref, message, attachments, toolIds);
} }
Future<void> sendMessageWithContainer(
ProviderContainer container,
String message,
List<String>? attachments, [
List<String>? toolIds,
]) async {
await _sendMessageInternal(container, message, attachments, toolIds);
}
// Internal send message implementation // Internal send message implementation
Future<void> _sendMessageInternal( Future<void> _sendMessageInternal(
dynamic ref, dynamic ref,

View File

@@ -372,10 +372,9 @@ class _ChatPageState extends ConsumerState<ChatPage> {
// 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((_) {
if (_scrollController.hasClients) { if (_scrollController.hasClients) {
final maxScroll = _scrollController.position.maxScrollExtent;
final currentScroll = _scrollController.position.pixels; final currentScroll = _scrollController.position.pixels;
// Only auto-scroll if user was already near the bottom (within 300px) // Only auto-scroll if user was already near the bottom (within 300px)
if (maxScroll - currentScroll < 300) { if (currentScroll <= 300) {
_scrollToBottom(); _scrollToBottom();
} }
} }
@@ -543,26 +542,18 @@ class _ChatPageState extends ConsumerState<ChatPage> {
_scrollDebounceTimer = Timer(const Duration(milliseconds: 80), () { _scrollDebounceTimer = Timer(const Duration(milliseconds: 80), () {
if (!mounted || _isDeactivated || !_scrollController.hasClients) return; if (!mounted || _isDeactivated || !_scrollController.hasClients) return;
final maxScroll = _scrollController.position.maxScrollExtent;
final currentScroll = _scrollController.position.pixels; final currentScroll = _scrollController.position.pixels;
final maxScroll = _scrollController.position.maxScrollExtent;
// Hysteresis thresholds to avoid flicker const double showThreshold = 300.0;
const double showThreshold = const double hideThreshold = 150.0;
300.0; // show when farther than this from bottom
const double hideThreshold =
150.0; // hide when within this distance of bottom
final bool farFromBottom = currentScroll < (maxScroll - showThreshold); final bool farFromBottom = currentScroll > showThreshold;
final bool nearBottom = currentScroll >= (maxScroll - hideThreshold); final bool nearBottom = currentScroll <= hideThreshold;
bool showButton; final bool showButton = _showScrollToBottom
if (_showScrollToBottom) { ? !nearBottom && maxScroll > showThreshold
// Currently shown: keep it until we are near the bottom : farFromBottom && maxScroll > showThreshold;
showButton = !nearBottom && maxScroll > showThreshold;
} else {
// Currently hidden: only show when far from bottom
showButton = farFromBottom && maxScroll > showThreshold;
}
if (showButton != _showScrollToBottom && mounted && !_isDeactivated) { if (showButton != _showScrollToBottom && mounted && !_isDeactivated) {
setState(() { setState(() {
@@ -575,17 +566,15 @@ class _ChatPageState extends ConsumerState<ChatPage> {
void _scrollToBottom({bool smooth = true}) { void _scrollToBottom({bool smooth = true}) {
if (!_scrollController.hasClients) return; if (!_scrollController.hasClients) return;
final maxScroll = _scrollController.position.maxScrollExtent; final target = 0.0;
if (maxScroll <= 0) return;
if (smooth) { if (smooth) {
_scrollController.animateTo( _scrollController.animateTo(
maxScroll, target,
duration: const Duration(milliseconds: 200), duration: const Duration(milliseconds: 200),
curve: Curves.easeOutCubic, curve: Curves.easeOutCubic,
); );
} else { } else {
_scrollController.jumpTo(maxScroll); _scrollController.jumpTo(target);
} }
} }
@@ -742,6 +731,7 @@ class _ChatPageState extends ConsumerState<ChatPage> {
scrollController: _scrollController, scrollController: _scrollController,
physics: const AlwaysScrollableScrollPhysics(), physics: const AlwaysScrollableScrollPhysics(),
items: messages, items: messages,
reverse: true,
padding: const EdgeInsets.fromLTRB( padding: const EdgeInsets.fromLTRB(
Spacing.lg, Spacing.lg,
Spacing.md, Spacing.md,
@@ -1029,9 +1019,8 @@ class _ChatPageState extends ConsumerState<ChatPage> {
WidgetsBinding.instance.addPostFrameCallback((_) { WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return; if (!mounted) return;
if (_scrollController.hasClients) { if (_scrollController.hasClients) {
final maxScroll = _scrollController.position.maxScrollExtent;
final currentScroll = _scrollController.position.pixels; final currentScroll = _scrollController.position.pixels;
if (maxScroll - currentScroll < 300) { if (currentScroll <= 300) {
_scrollToBottom(smooth: true); _scrollToBottom(smooth: true);
} }
} }

View File

@@ -18,7 +18,7 @@ import 'enhanced_attachment.dart';
import 'package:conduit/shared/widgets/chat_action_button.dart'; import 'package:conduit/shared/widgets/chat_action_button.dart';
import '../../../shared/widgets/model_avatar.dart'; import '../../../shared/widgets/model_avatar.dart';
import 'package:url_launcher/url_launcher_string.dart'; import 'package:url_launcher/url_launcher_string.dart';
import '../providers/chat_providers.dart' show sendMessage; import '../providers/chat_providers.dart' show sendMessageWithContainer;
import '../../../core/utils/debug_logger.dart'; import '../../../core/utils/debug_logger.dart';
import 'sources/openwebui_sources.dart'; import 'sources/openwebui_sources.dart';
import '../providers/assistant_response_builder_provider.dart'; import '../providers/assistant_response_builder_provider.dart';
@@ -70,7 +70,8 @@ class _AssistantMessageWidgetState extends ConsumerState<AssistantMessageWidget>
return; return;
} }
try { try {
await sendMessage(ref, trimmed, null); final container = ProviderScope.containerOf(context, listen: false);
await sendMessageWithContainer(container, trimmed, null);
} catch (err, stack) { } catch (err, stack) {
DebugLogger.log( DebugLogger.log(
'Failed to send follow-up: $err', 'Failed to send follow-up: $err',

View File

@@ -146,6 +146,8 @@ class _OptimizedListState<T> extends ConsumerState<OptimizedList<T>> {
? const AlwaysScrollableScrollPhysics() ? const AlwaysScrollableScrollPhysics()
: const ClampingScrollPhysics()); : const ClampingScrollPhysics());
final reverse = widget.reverse;
if (widget.separatorBuilder != null) { if (widget.separatorBuilder != null) {
listWidget = ListView.separated( listWidget = ListView.separated(
controller: _scrollController, controller: _scrollController,
@@ -154,7 +156,7 @@ class _OptimizedListState<T> extends ConsumerState<OptimizedList<T>> {
keyboardDismissBehavior: widget.keyboardDismissBehavior, keyboardDismissBehavior: widget.keyboardDismissBehavior,
shrinkWrap: widget.shrinkWrap, shrinkWrap: widget.shrinkWrap,
scrollDirection: widget.scrollDirection, scrollDirection: widget.scrollDirection,
reverse: widget.reverse, reverse: reverse,
cacheExtent: widget.cacheExtent ?? 250.0, cacheExtent: widget.cacheExtent ?? 250.0,
addAutomaticKeepAlives: widget.addAutomaticKeepAlives, addAutomaticKeepAlives: widget.addAutomaticKeepAlives,
addRepaintBoundaries: widget.addRepaintBoundaries, addRepaintBoundaries: widget.addRepaintBoundaries,
@@ -165,7 +167,7 @@ class _OptimizedListState<T> extends ConsumerState<OptimizedList<T>> {
return _buildLoadMoreIndicator(); return _buildLoadMoreIndicator();
} }
return _buildOptimizedItem(context, index); return _buildOptimizedItem(context, index, reverse: reverse);
}, },
); );
} else { } else {
@@ -176,7 +178,7 @@ class _OptimizedListState<T> extends ConsumerState<OptimizedList<T>> {
keyboardDismissBehavior: widget.keyboardDismissBehavior, keyboardDismissBehavior: widget.keyboardDismissBehavior,
shrinkWrap: widget.shrinkWrap, shrinkWrap: widget.shrinkWrap,
scrollDirection: widget.scrollDirection, scrollDirection: widget.scrollDirection,
reverse: widget.reverse, reverse: reverse,
cacheExtent: widget.cacheExtent ?? 250.0, cacheExtent: widget.cacheExtent ?? 250.0,
addAutomaticKeepAlives: widget.addAutomaticKeepAlives, addAutomaticKeepAlives: widget.addAutomaticKeepAlives,
addRepaintBoundaries: widget.addRepaintBoundaries, addRepaintBoundaries: widget.addRepaintBoundaries,
@@ -187,7 +189,7 @@ class _OptimizedListState<T> extends ConsumerState<OptimizedList<T>> {
return _buildLoadMoreIndicator(); return _buildLoadMoreIndicator();
} }
return _buildOptimizedItem(context, index); return _buildOptimizedItem(context, index, reverse: reverse);
}, },
); );
} }
@@ -200,15 +202,21 @@ class _OptimizedListState<T> extends ConsumerState<OptimizedList<T>> {
return listWidget; return listWidget;
} }
Widget _buildOptimizedItem(BuildContext context, int index) { Widget _buildOptimizedItem(
final item = widget.items[index]; BuildContext context,
int index, {
required bool reverse,
}) {
final effectiveIndex = reverse ? widget.items.length - index - 1 : index;
final item = widget.items[effectiveIndex];
// Wrap in repaint boundary for performance
if (widget.addRepaintBoundaries) { if (widget.addRepaintBoundaries) {
return RepaintBoundary(child: widget.itemBuilder(context, item, index)); return RepaintBoundary(
child: widget.itemBuilder(context, item, effectiveIndex),
);
} }
return widget.itemBuilder(context, item, index); return widget.itemBuilder(context, item, effectiveIndex);
} }
Widget _buildLoadMoreIndicator() { Widget _buildLoadMoreIndicator() {