Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
183 changes: 172 additions & 11 deletions lib/core/screens/chat_screen.dart
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ class ChatScreen extends StatefulWidget {

class _ChatScreenState extends State<ChatScreen> {
List<Map<String, dynamic>> _messages = [];
final List<Map<String, dynamic>> _toolMessages = [];
bool _loading = true;
String? _error;
late final ApiClient _client;
Expand Down Expand Up @@ -219,6 +220,7 @@ class _ChatScreenState extends State<ChatScreen> {
try {
final messages = await _client.getMessages(widget.session.id);
if (!mounted) return;
_extractToolMessages(messages);
setState(() {
_messages = messages;
_loading = false;
Expand All @@ -241,6 +243,63 @@ class _ChatScreenState extends State<ChatScreen> {
}
}

void _extractToolMessages(List<Map<String, dynamic>> 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<void> _sendMessage({bool speakResponse = false}) async {
final text = _textController.text.trim();
Expand Down Expand Up @@ -292,6 +351,7 @@ class _ChatScreenState extends State<ChatScreen> {
try {
final messages = await _client.getMessages(widget.session.id);
if (!mounted) return;
_extractToolMessages(messages);
setState(() {
_messages = messages;
_streaming = false;
Expand Down Expand Up @@ -372,9 +432,8 @@ class _ChatScreenState extends State<ChatScreen> {
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',
Expand All @@ -384,13 +443,9 @@ class _ChatScreenState extends State<ChatScreen> {
'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);
}
});

Expand Down Expand Up @@ -559,12 +614,52 @@ class _ChatScreenState extends State<ChatScreen> {
);
}

// Build display list: consecutive tool messages grouped into cards,
// interleaved with user/assistant bubbles.
final toolQueue = List<Map<String, dynamic>>.from(_toolMessages);
final displayMessages = <dynamic>[];
final currentGroup = <Map<String, dynamic>>[];

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<Map<String, dynamic>>) {
return _ToolProgressCard(items: item, verbose: _verboseMode);
}

final msg = item as Map<String, dynamic>;
final role = (msg['role'] as String?) ?? 'assistant';
final content = (msg['content'] as String?) ?? '';
final isUser = role == 'user';
Expand Down Expand Up @@ -737,3 +832,69 @@ class _MessageBubble extends StatelessWidget {
);
}
}


class _ToolProgressCard extends StatelessWidget {
final List<Map<String, dynamic>> 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,
),
),
),
],
),
);
}
}
Loading