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; // cached entries — only rebuilt when message list actually changes List _cachedMessages = const []; List<_ChatEntry> _cachedEntries = const []; // track last message identity + count to detect changes cheaply int _lastMessageCount = 0; Object? _lastMessageTail; // identity of the last message object 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); }); } List<_ChatEntry> _getEntries(List messages) { final tail = messages.isEmpty ? null : messages.last; final changed = messages.length != _lastMessageCount || !identical(tail, _lastMessageTail); if (changed) { _cachedMessages = messages; _cachedEntries = _buildEntries(messages); _lastMessageCount = messages.length; _lastMessageTail = tail; } return _cachedEntries; } @override Widget build(BuildContext context) { return Consumer( builder: (context, chatProvider, _) { final currentMessages = chatProvider.messages; final prevCount = _lastMessageCount; final entries = _getEntries(currentMessages); final messagesChanged = currentMessages.length != prevCount; if (messagesChanged && currentMessages.isNotEmpty) { if (_isAtBottom()) _scheduleAutoScroll(); } return Stack( children: [ ScrollConfiguration( behavior: ScrollConfiguration.of(context).copyWith(scrollbars: false), child: SingleChildScrollView( controller: _scrollController, physics: const ClampingScrollPhysics(), child: Padding( padding: const EdgeInsets.symmetric(horizontal: 12), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ const Gap(12), for (var i = 0; i < entries.length; i++) RepaintBoundary( key: ValueKey('${entries[i].stableKey}#$i'), child: Padding( padding: const EdgeInsets.only(bottom: 6), child: _buildBubble(context, chatProvider, entries[i], i, 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, timestamp: msg.timestamp); if (msg.role == "assistant") { final streaming = chatProvider.isLoading && index == total - 1; return AssistantBubble(content: msg.content, isStreaming: streaming); } return Text(msg.content); } if (entry is _ToolEntry) { return ToolBubble(toolName: entry.toolName, toolInput: entry.toolInput, result: entry.result, pendingPermission: isThisPending ? pending : null); } 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; // check if streamed output was appended directly to this call message // format: "Name call\n{json}\nchunks..." final inlineResult = _extractInlineResult(msg.content); if (inlineResult != null) { toolResult = inlineResult; } else if (i + 1 < messages.length) { // normal non-streaming path: result is in the next message 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 if (msg.role == "compact_boundary") { // internal marker — not rendered, the assistant note after it says "compacted" i++; } else { result.add(_MessageEntry(msg)); i++; } } return result; } // if a call message has streamed output appended after its JSON body, // extract it. format is: "Name call\n{json}\noutput..." // returns null if there's no trailing content beyond the json block. String? _extractInlineResult(String content) { final newline = content.indexOf("\n"); if (newline == -1) return null; final body = content.substring(newline + 1); final jsonEnd = _jsonObjectEnd(body); if (jsonEnd == -1) return null; final trailing = body.substring(jsonEnd + 1).trimLeft(); return trailing.isEmpty ? null : trailing; } // walk the json object to find its true closing brace index int _jsonObjectEnd(String s) { int depth = 0; bool inString = false; for (int i = 0; i < s.length; i++) { final c = s[i]; if (inString) { if (c == "\\") { i++; continue; } if (c == "\"") inString = false; } else { if (c == "\"") inString = true; else if (c == "{") depth++; else if (c == "}") { depth--; if (depth == 0) return i; } } } return -1; } } 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), ), ), ), ), ], ), ); }, ), ), ); } }