diff --git a/lib/core/screens/chat_screen.dart b/lib/core/screens/chat_screen.dart index 96a629a..4ce427e 100644 --- a/lib/core/screens/chat_screen.dart +++ b/lib/core/screens/chat_screen.dart @@ -28,6 +28,7 @@ class ChatScreen extends StatefulWidget { class _ChatScreenState extends State { List> _messages = []; + final List> _toolMessages = []; bool _loading = true; String? _error; late final ApiClient _client; @@ -219,6 +220,7 @@ class _ChatScreenState extends State { try { final messages = await _client.getMessages(widget.session.id); if (!mounted) return; + _extractToolMessages(messages); setState(() { _messages = messages; _loading = false; @@ -241,6 +243,63 @@ class _ChatScreenState extends State { } } + void _extractToolMessages(List> messages) { + _toolMessages.clear(); + for (final msg in messages) { + final role = (msg['role'] as String?) ?? ''; + if (role != 'tool') continue; + + final name = (msg['name'] as String?) ?? + (msg['tool_name'] as String?) ?? + (msg['toolCallName'] as String?) ?? + ''; + final toolCallId = (msg['tool_call_id'] as String?) ?? ''; + final content = (msg['content'] as String?) ?? ''; + + String toolName = name.isNotEmpty ? name : ''; + if (toolName.isEmpty && content.isNotEmpty) { + final match = RegExp(r'source="([^"]+)"').firstMatch(content); + if (match != null) toolName = match.group(1)!; + } + if (toolName.isEmpty) toolName = 'tool'; + + final emoji = _toolEmoji(toolName); + _toolMessages.add({ + 'role': 'tool_progress', + 'content': '$emoji $toolName — done', + 'toolCallId': toolCallId, + 'status': 'completed', + 'tool': toolName, + }); + } + } + + String _toolEmoji(String toolName) { + switch (toolName) { + case 'browser_navigate': + case 'browser_console': + case 'browser': + return '🌐'; + case 'read_file': + case 'read': + return '📄'; + case 'write_file': + case 'write': + return '✏️'; + case 'search': + case 'google_search': + return '🔍'; + case 'execute': + case 'shell': + return '💻'; + case 'think': + case 'reasoning': + return '🧠'; + default: + return '🔧'; + } + } + /// Send message via SSE streaming (Gateway API Server). Future _sendMessage({bool speakResponse = false}) async { final text = _textController.text.trim(); @@ -292,6 +351,7 @@ class _ChatScreenState extends State { try { final messages = await _client.getMessages(widget.session.id); if (!mounted) return; + _extractToolMessages(messages); setState(() { _messages = messages; _streaming = false; @@ -372,9 +432,8 @@ class _ChatScreenState extends State { setState(() { final idx = toolCallId.isEmpty ? -1 - : _messages.indexWhere( - (m) => - m['role'] == 'tool_progress' && m['toolCallId'] == toolCallId, + : _toolMessages.indexWhere( + (m) => m['toolCallId'] == toolCallId, ); final payload = { 'role': 'tool_progress', @@ -384,13 +443,9 @@ class _ChatScreenState extends State { 'tool': tool, }; if (idx >= 0) { - _messages[idx] = payload; + _toolMessages[idx] = payload; } else { - final insertAt = - _messages.isNotEmpty && _messages.last['role'] == 'assistant' - ? _messages.length - 1 - : _messages.length; - _messages.insert(insertAt, payload); + _toolMessages.add(payload); } }); @@ -559,12 +614,52 @@ class _ChatScreenState extends State { ); } + // Build display list: consecutive tool messages grouped into cards, + // interleaved with user/assistant bubbles. + final toolQueue = List>.from(_toolMessages); + final displayMessages = []; + final currentGroup = >[]; + + for (final msg in _messages) { + final role = (msg['role'] as String?) ?? 'assistant'; + if (role == 'tool') { + if (toolQueue.isNotEmpty) { + currentGroup.add(toolQueue.removeAt(0)); + } + continue; + } + if (role != 'user' && role != 'assistant') continue; + final content = (msg['content'] as String?) ?? ''; + if (content.isEmpty) continue; + + if (currentGroup.isNotEmpty) { + displayMessages.add(currentGroup.toList()); + currentGroup.clear(); + } + displayMessages.add(msg); + } + if (currentGroup.isNotEmpty) { + displayMessages.add(currentGroup.toList()); + } + + // Tools from SSE events that arrived during streaming but haven't been + // matched to server messages yet — show them as a card. + if (toolQueue.isNotEmpty) { + displayMessages.add(toolQueue.toList()); + } + return ListView.builder( controller: _scrollController, padding: const EdgeInsets.only(bottom: 4), - itemCount: _messages.length, + itemCount: displayMessages.length, itemBuilder: (context, index) { - final msg = _messages[index]; + final item = displayMessages[index]; + + if (item is List>) { + return _ToolProgressCard(items: item, verbose: _verboseMode); + } + + final msg = item as Map; final role = (msg['role'] as String?) ?? 'assistant'; final content = (msg['content'] as String?) ?? ''; final isUser = role == 'user'; @@ -737,3 +832,69 @@ class _MessageBubble extends StatelessWidget { ); } } + + +class _ToolProgressCard extends StatelessWidget { + final List> items; + final bool verbose; + + const _ToolProgressCard({ + required this.items, + this.verbose = false, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final isDark = theme.brightness == Brightness.dark; + final bg = isDark ? const Color(0xFF2A2A2A) : const Color(0xFFEAEAEA); + final fg = isDark ? Colors.white70 : Colors.black54; + + final active = items.any((item) { + final status = (item['status'] as String?) ?? ''; + return status != 'completed' && status != 'finished'; + }); + + final emojis = items.map((item) { + final content = (item['content'] as String?) ?? ''; + return content.isNotEmpty ? content.substring(0, content.length < 2 ? content.length : 2) : '\uD83D\uDD27'; + }).toList(); + + return Container( + margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), + constraints: BoxConstraints( + maxWidth: MediaQuery.of(context).size.width - 80, + ), + decoration: BoxDecoration( + color: bg, + borderRadius: BorderRadius.circular(16), + ), + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + child: Row( + children: [ + Text( + active ? '\u23F3' : '\u2705', + style: const TextStyle(fontSize: 13), + ), + const SizedBox(width: 6), + Text( + emojis.join(' '), + style: const TextStyle(fontSize: 13), + ), + if (active) + Padding( + padding: const EdgeInsets.only(left: 8), + child: SizedBox( + width: 12, + height: 12, + child: CircularProgressIndicator( + strokeWidth: 1.5, + color: fg, + ), + ), + ), + ], + ), + ); + } +} \ No newline at end of file