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; bool _autoScrollQueued = false; int _lastMessageCount = 0; List _lastMessageSigs = const []; static const double _bottomThreshold = 56; bool _isAtBottom() { if (!_scrollController.hasClients) return true; final pos = _scrollController.position; return pos.maxScrollExtent - pos.pixels <= _bottomThreshold; } void _scheduleAutoScroll() { if (_autoScrollQueued) return; _autoScrollQueued = true; WidgetsBinding.instance.addPostFrameCallback((_) { _autoScrollQueued = false; if (!mounted || !_scrollController.hasClients) return; _scrollController.jumpTo(_scrollController.position.maxScrollExtent); }); } @override Widget build(BuildContext context) { return Consumer( builder: (context, chatProvider, _) { final currentMessages = chatProvider.messages; final currentSigs = currentMessages.map((m) => "${m.role}:${m.content}").toList(growable: false); final messagesChanged = currentMessages.length != _lastMessageCount || currentSigs.length != _lastMessageSigs.length || !_listEquals(currentSigs, _lastMessageSigs); if (messagesChanged && currentMessages.isNotEmpty) { if (_isAtBottom()) _scheduleAutoScroll(); WidgetsBinding.instance.addPostFrameCallback((_) { if (!mounted) return; _lastMessageCount = currentMessages.length; _lastMessageSigs = currentSigs; }); } else if (currentMessages.isEmpty) { _lastMessageCount = 0; _lastMessageSigs = const []; } final entries = _buildEntries(currentMessages); return Stack( children: [ ScrollConfiguration( behavior: ScrollConfiguration.of(context).copyWith(scrollbars: false), child: SingleChildScrollView( controller: _scrollController, physics: const ClampingScrollPhysics(), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ for (var index = 0; index < entries.length; index++) Padding( key: ValueKey('${entries[index].stableKey}#$index'), padding: const EdgeInsets.only(bottom: 12), child: _buildBubble(context, chatProvider, entries[index], index, entries.length), ), ], ), ), ), _JumpToBottomButton(scrollController: _scrollController), ], ); }, ); } Widget _buildBubble(BuildContext context, ChatProvider chatProvider, _ChatEntry entry, int index, int total) { final pending = chatProvider.pendingPermission; final isThisPending = pending != null && index == total - 1 && entry is _ToolEntry && entry.toolName == pending.toolName; if (entry is _MessageEntry) { final msg = entry.message; if (msg.role == "user") return UserBubble(content: msg.content, attachments: msg.attachments); if (msg.role == "assistant") return AssistantBubble(content: msg.content); return Text(msg.content); } if (entry is _ToolEntry) { return ToolBubble(toolName: entry.toolName, toolInput: entry.toolInput, result: entry.result, isPendingPermission: isThisPending); } return const SizedBox.shrink(); } 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); 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; } final (toolName, _) = ToolBubble.parseContent(msg.content); result.add(_ToolEntry(toolName: toolName)); i++; } else { result.add(_MessageEntry(msg)); i++; } } return result; } bool _listEquals(List a, List b) { if (identical(a, b)) return true; if (a.length != b.length) return false; for (var i = 0; i < a.length; i++) { if (a[i] != b[i]) return false; } return true; } } sealed class _ChatEntry { String get stableKey; } class _MessageEntry extends _ChatEntry { _MessageEntry(this.message); final Message message; @override String get stableKey => 'msg:${message.role}:${message.content}'; } class _ToolEntry extends _ChatEntry { _ToolEntry({required this.toolName, this.toolInput, this.result}); final String toolName; final Map? toolInput; final String? result; @override String get stableKey => 'tool:$toolName:${toolInput?.toString() ?? ""}:${result ?? ""}:${identityHashCode(this)}'; } class _JumpToBottomButton extends StatefulWidget { final ScrollController scrollController; const _JumpToBottomButton({required this.scrollController}); @override State<_JumpToBottomButton> createState() => _JumpToBottomButtonState(); } class _JumpToBottomButtonState extends State<_JumpToBottomButton> { bool _show = false; static const double _bottomThreshold = 56; @override void initState() { super.initState(); widget.scrollController.addListener(_onScroll); } @override void dispose() { widget.scrollController.removeListener(_onScroll); super.dispose(); } void _onScroll() { if (!widget.scrollController.hasClients) return; final pos = widget.scrollController.position; final atBottom = pos.maxScrollExtent - pos.pixels <= _bottomThreshold; final shouldShow = !atBottom; if (_show != shouldShow) { setState(() => _show = shouldShow); } } void _jump() { if (!widget.scrollController.hasClients) return; widget.scrollController.jumpTo(widget.scrollController.position.maxScrollExtent); } @override Widget build(BuildContext context) { if (!_show) return const SizedBox.shrink(); return Positioned( bottom: 16, right: 16, child: GestureDetector( onTap: _jump, child: Container( 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: Icon(LucideIcons.arrowDown, size: 16, color: Theme.of(context).colorScheme.foreground), ), ), ); } } class ChatScrollBar extends StatefulWidget { final ScrollController controller; const ChatScrollBar({super.key, required this.controller}); @override State createState() => _ChatScrollBarState(); } class _ChatScrollBarState extends State { bool _hovering = false; bool _scrolling = false; DateTime _lastScroll = DateTime.fromMillisecondsSinceEpoch(0); double? _stableThumbHeight; double? _lastThumbHeight; double? _lastThumbTop; bool _lastVisible = false; @override void initState() { super.initState(); widget.controller.addListener(_onScroll); } @override void didUpdateWidget(covariant ChatScrollBar oldWidget) { super.didUpdateWidget(oldWidget); if (oldWidget.controller != widget.controller) { oldWidget.controller.removeListener(_onScroll); widget.controller.addListener(_onScroll); } } void _onScroll() { _lastScroll = DateTime.now(); if (!_scrolling && mounted) { setState(() => _scrolling = true); } WidgetsBinding.instance.addPostFrameCallback((_) { if (!mounted) return; if (DateTime.now().difference(_lastScroll).inMilliseconds >= 500 && _scrolling) { 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) { 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 totalHeight = constraints.maxHeight; final liveThumbHeight = ((pos.viewportDimension / (pos.viewportDimension + maxScroll)) * totalHeight).clamp(32.0, totalHeight); final thumbHeight = _stableThumbHeight ?? liveThumbHeight; if (!_scrolling && (_stableThumbHeight == null || (_stableThumbHeight! - liveThumbHeight).abs() > 2)) { WidgetsBinding.instance.addPostFrameCallback((_) { if (!mounted) return; if (_stableThumbHeight != liveThumbHeight) { setState(() => _stableThumbHeight = liveThumbHeight); } }); } final thumbTop = (pos.pixels / maxScroll) * (totalHeight - thumbHeight); if (_lastThumbHeight != thumbHeight || _lastThumbTop != thumbTop || _lastVisible != visible) { _lastThumbHeight = thumbHeight; _lastThumbTop = thumbTop; _lastVisible = visible; } return GestureDetector( behavior: HitTestBehavior.translucent, onVerticalDragStart: (details) { final trackTravel = (totalHeight - thumbHeight).clamp(1.0, double.infinity); final nextThumbTop = (details.localPosition.dy - thumbHeight / 2).clamp(0.0, trackTravel); final nextPixels = (nextThumbTop / trackTravel) * maxScroll; widget.controller.jumpTo(nextPixels.clamp(0.0, maxScroll)); }, onVerticalDragUpdate: (details) { final trackTravel = (totalHeight - thumbHeight).clamp(1.0, double.infinity); final nextThumbTop = (details.localPosition.dy - thumbHeight / 2).clamp(0.0, trackTravel); final nextPixels = (nextThumbTop / trackTravel) * maxScroll; widget.controller.jumpTo(nextPixels.clamp(0.0, maxScroll)); }, onTapDown: (details) { final trackTravel = (totalHeight - thumbHeight).clamp(1.0, double.infinity); final centerTop = (details.localPosition.dy - thumbHeight / 2).clamp(0.0, trackTravel); final nextPixels = (centerTop / trackTravel) * maxScroll; widget.controller.jumpTo(nextPixels.clamp(0.0, maxScroll)); }, child: Stack( children: [ Positioned.fill( child: const SizedBox.shrink(), ), Positioned( top: thumbTop, left: 0, right: 0, height: thumbHeight, child: Padding( padding: const EdgeInsets.symmetric(horizontal: 2), child: DecoratedBox( decoration: BoxDecoration( color: Theme.of(context).colorScheme.mutedForeground.withValues(alpha: 0.45), borderRadius: BorderRadius.circular(4), ), ), ), ), ], ), ); }, ), ), ); } }