From 0c8b60dd9225aa082495693877bbb186527c12f9 Mon Sep 17 00:00:00 2001 From: grunjol Date: Mon, 29 Jun 2026 17:43:16 -0300 Subject: [PATCH 1/2] feat: consolidated tool card with emoji display, filter empty bubbles --- lib/core/screens/chat_screen.dart | 172 ++++++++++++++++++++++++++++-- 1 file changed, 161 insertions(+), 11 deletions(-) diff --git a/lib/core/screens/chat_screen.dart b/lib/core/screens/chat_screen.dart index 96a629a..62a82b4 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(); @@ -261,6 +320,7 @@ class _ChatScreenState extends State { _sending = true; _streaming = true; _showScrollToBottom = false; + _toolMessages.clear(); _messages.add({'role': 'user', 'content': text}); // Insert a placeholder streaming message _messages.add({'role': 'assistant', 'content': ''}); @@ -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,41 @@ class _ChatScreenState extends State { ); } + const chatRoles = {'user', 'assistant'}; + final chatMessages = _messages.where((m) { + final role = (m['role'] as String?) ?? 'assistant'; + if (!chatRoles.contains(role)) return false; + final content = (m['content'] as String?) ?? ''; + return content.isNotEmpty; + }).toList(); + + final displayMessages = []; + if (_toolMessages.isNotEmpty) { + final lastAssistantIdx = chatMessages.indexWhere( + (m) => (m['role'] as String?) == 'assistant', + ); + final insertAt = lastAssistantIdx >= 0 + ? lastAssistantIdx + : chatMessages.length; + displayMessages.addAll(chatMessages.take(insertAt)); + displayMessages.add(_toolMessages.toList()); + displayMessages.addAll(chatMessages.skip(insertAt)); + } else { + displayMessages.addAll(chatMessages); + } + 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 +821,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 From abd84dc92731ad8e8223544f7e6f1ff0c9c451d9 Mon Sep 17 00:00:00 2001 From: grunjol Date: Sat, 4 Jul 2026 16:36:25 -0300 Subject: [PATCH 2/2] fix: group consecutive tools into separate cards, show SSE tools during streaming --- lib/core/screens/chat_screen.dart | 53 +++++++++++++++++++------------ 1 file changed, 32 insertions(+), 21 deletions(-) diff --git a/lib/core/screens/chat_screen.dart b/lib/core/screens/chat_screen.dart index 62a82b4..4ce427e 100644 --- a/lib/core/screens/chat_screen.dart +++ b/lib/core/screens/chat_screen.dart @@ -320,7 +320,6 @@ class _ChatScreenState extends State { _sending = true; _streaming = true; _showScrollToBottom = false; - _toolMessages.clear(); _messages.add({'role': 'user', 'content': text}); // Insert a placeholder streaming message _messages.add({'role': 'assistant', 'content': ''}); @@ -352,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; @@ -614,27 +614,38 @@ class _ChatScreenState extends State { ); } - const chatRoles = {'user', 'assistant'}; - final chatMessages = _messages.where((m) { - final role = (m['role'] as String?) ?? 'assistant'; - if (!chatRoles.contains(role)) return false; - final content = (m['content'] as String?) ?? ''; - return content.isNotEmpty; - }).toList(); - + // Build display list: consecutive tool messages grouped into cards, + // interleaved with user/assistant bubbles. + final toolQueue = List>.from(_toolMessages); final displayMessages = []; - if (_toolMessages.isNotEmpty) { - final lastAssistantIdx = chatMessages.indexWhere( - (m) => (m['role'] as String?) == 'assistant', - ); - final insertAt = lastAssistantIdx >= 0 - ? lastAssistantIdx - : chatMessages.length; - displayMessages.addAll(chatMessages.take(insertAt)); - displayMessages.add(_toolMessages.toList()); - displayMessages.addAll(chatMessages.skip(insertAt)); - } else { - displayMessages.addAll(chatMessages); + 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(