import "package:provider/provider.dart"; import "package:shadcn_flutter/shadcn_flutter.dart"; import "../../../src/session/session_types.dart"; import "../../providers/chat_provider.dart"; import "bubbles/assistant_bubble.dart"; import "bubbles/tool_bubble.dart"; import "bubbles/user_bubble.dart"; class ChatView extends StatefulWidget { final ScrollController scrollController; const ChatView({super.key, required this.scrollController}); @override State createState() => _ChatViewState(); } class _ChatViewState extends State { ScrollController get _scrollController => widget.scrollController; List _previousMessageContents = []; bool _isUserScrolling = false; DateTime? _lastScrollTime; bool _showJumpToBottom = false; bool _hasNewMessagesWhileScrolledAway = false; @override void initState() { super.initState(); _scrollController.addListener(_handleScroll); } @override void dispose() { _scrollController.removeListener(_handleScroll); super.dispose(); } void _handleScroll() { _lastScrollTime = DateTime.now(); _isUserScrolling = true; if (_scrollController.hasClients) { final position = _scrollController.position; final isFarFromBottom = position.pixels < position.maxScrollExtent - 200; if (isFarFromBottom != _showJumpToBottom) { setState(() { _showJumpToBottom = isFarFromBottom; }); } if (!isFarFromBottom) { setState(() { _hasNewMessagesWhileScrolledAway = false; }); } } Future.delayed(const Duration(milliseconds: 150), () { if (_lastScrollTime != null && DateTime.now().difference(_lastScrollTime!) >= const Duration(milliseconds: 150)) { if (mounted) { setState(() { _isUserScrolling = false; }); } } }); } bool _isNearBottom() { if (!_scrollController.hasClients) return false; final position = _scrollController.position; return position.pixels >= position.maxScrollExtent - 150; } void _jumpToBottom() { if (_scrollController.hasClients) { _scrollController.animateTo( _scrollController.position.maxScrollExtent, duration: const Duration(milliseconds: 300), curve: Curves.easeOut, ); setState(() { _showJumpToBottom = false; _hasNewMessagesWhileScrolledAway = false; }); } } @override Widget build(BuildContext context) { return Consumer( builder: (context, chatProvider, _) { final currentMessages = chatProvider.messages; bool messagesChanged = false; if (currentMessages.length != _previousMessageContents.length) { messagesChanged = true; } else { for (int i = 0; i < currentMessages.length; i++) { if (currentMessages[i].content != _previousMessageContents[i]) { messagesChanged = true; break; } } } if (messagesChanged && currentMessages.isNotEmpty) { final nearBottom = _isNearBottom(); if (nearBottom && !_isUserScrolling) { WidgetsBinding.instance.addPostFrameCallback((_) { if (_scrollController.hasClients) { _scrollController.animateTo( _scrollController.position.maxScrollExtent, duration: const Duration(milliseconds: 300), curve: Curves.easeOut, ); } }); _hasNewMessagesWhileScrolledAway = false; } else if (!nearBottom) { _hasNewMessagesWhileScrolledAway = true; } } WidgetsBinding.instance.addPostFrameCallback((_) { _previousMessageContents = currentMessages.map((m) => m.content).toList(); }); final entries = _buildEntries(currentMessages); return Stack( children: [ Padding( padding: const EdgeInsets.symmetric(horizontal: 8), child: ScrollConfiguration( behavior: ScrollConfiguration.of(context).copyWith(scrollbars: false), child: ListView.builder( controller: _scrollController, physics: const ClampingScrollPhysics(), itemCount: entries.length, itemBuilder: (context, index) { final entry = entries[index]; final pending = chatProvider.pendingPermission; final isThisPending = pending != null && index == entries.length - 1 && entry is _ToolEntry && entry.toolName == pending.toolName; Widget bubble; if (entry is _MessageEntry) { final msg = entry.message; if (msg.role == "user") { bubble = UserBubble(content: msg.content); } else if (msg.role == "assistant") { bubble = AssistantBubble(content: msg.content); } else { bubble = Text(msg.content); } } else if (entry is _ToolEntry) { bubble = ToolBubble( toolName: entry.toolName, toolInput: entry.toolInput, result: entry.result, isPendingPermission: isThisPending, ); } else { bubble = const SizedBox.shrink(); } return Padding( padding: const EdgeInsets.only(bottom: 12), child: bubble, ); }, ), ), ), if (_showJumpToBottom && _hasNewMessagesWhileScrolledAway) Positioned( bottom: 16, right: 16, child: GestureDetector( onTap: _jumpToBottom, child: AnimatedContainer( duration: const Duration(milliseconds: 200), padding: const EdgeInsets.all(12), decoration: BoxDecoration( color: Theme.of(context).colorScheme.background.withOpacity(0.9), borderRadius: BorderRadius.circular(24), boxShadow: [ BoxShadow( color: const Color(0xFF000000).withOpacity(0.1), blurRadius: 8, offset: const Offset(0, 2), ), ], ), child: Row( mainAxisSize: MainAxisSize.min, children: [ Icon( LucideIcons.arrowDown, size: 16, color: Theme.of(context).colorScheme.foreground, ), const SizedBox(width: 6), Text( "New messages", style: TextStyle( fontSize: 12, fontWeight: FontWeight.w500, color: Theme.of(context).colorScheme.foreground, ), ), ], ), ), ), ), ], ); }, ); } // merge consecutive tool call + result messages into single entries List<_ChatEntry> _buildEntries(List messages) { final result = <_ChatEntry>[]; int i = 0; while (i < messages.length) { final msg = messages[i]; if (msg.role == "tool") { final firstLine = msg.content.split("\n").first.trim(); if (firstLine.endsWith(" call")) { final (toolName, toolInput) = ToolBubble.parseContent(msg.content); // check if next message is the matching result String? toolResult; if (i + 1 < messages.length) { final next = messages[i + 1]; final nextFirst = next.content.split("\n").first.trim(); if (next.role == "tool" && nextFirst == "$toolName result") { final body = next.content.indexOf("\n"); toolResult = body != -1 ? next.content.substring(body + 1).trim() : null; i++; } } result.add(_ToolEntry( toolName: toolName, toolInput: toolInput, result: toolResult, )); i++; continue; } // orphan result or unknown tool message — skip it // (already consumed as part of a call above, or genuinely standalone) final (toolName, _) = ToolBubble.parseContent(msg.content); result.add(_ToolEntry(toolName: toolName)); i++; } else { result.add(_MessageEntry(msg)); i++; } } return result; } } sealed class _ChatEntry {} class _MessageEntry extends _ChatEntry { _MessageEntry(this.message); final Message message; } class _ToolEntry extends _ChatEntry { _ToolEntry({required this.toolName, this.toolInput, this.result}); final String toolName; final Map? toolInput; final String? result; } class FullHeightScrollbar extends StatefulWidget { final ScrollController controller; const FullHeightScrollbar({super.key, required this.controller}); @override State createState() => _FullHeightScrollbarState(); } class _FullHeightScrollbarState extends State { bool _hovering = false; bool _scrolling = false; DateTime _lastScroll = DateTime.fromMillisecondsSinceEpoch(0); @override void initState() { super.initState(); widget.controller.addListener(_onScroll); } void _onScroll() { _lastScroll = DateTime.now(); setState(() => _scrolling = true); Future.delayed(const Duration(milliseconds: 800), () { if (!mounted) return; if (DateTime.now().difference(_lastScroll).inMilliseconds >= 800) { setState(() => _scrolling = false); } }); } @override void dispose() { widget.controller.removeListener(_onScroll); super.dispose(); } @override Widget build(BuildContext context) { final visible = _hovering || _scrolling; return MouseRegion( onEnter: (_) => setState(() => _hovering = true), onExit: (_) => setState(() => _hovering = false), child: AnimatedOpacity( opacity: visible ? 1.0 : 0.0, duration: const Duration(milliseconds: 200), child: LayoutBuilder( builder: (context, constraints) { final totalHeight = constraints.maxHeight; if (!widget.controller.hasClients) return const SizedBox.shrink(); final pos = widget.controller.position; final maxScroll = pos.maxScrollExtent; if (maxScroll <= 0) return const SizedBox.shrink(); final viewportFraction = pos.viewportDimension / (pos.viewportDimension + maxScroll); final thumbHeight = (viewportFraction * totalHeight).clamp(32.0, totalHeight); final scrollFraction = pos.pixels / maxScroll; final thumbTop = scrollFraction * (totalHeight - thumbHeight); final color = Theme.of(context).colorScheme.mutedForeground.withValues(alpha: 0.4); return Stack( children: [ Positioned( top: thumbTop, left: 2, right: 2, height: thumbHeight, child: Container( margin: const EdgeInsets.symmetric(vertical: 4), decoration: BoxDecoration( color: color, borderRadius: BorderRadius.circular(4), ), ), ), ], ); }, ), ), ); } }