This repository has been archived on 2026-06-02. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
FabledApp/lib/widgets/chat_message_bubble.dart
T
bvandeusen a3fe0b4b61 feat(chat): render tool-call chips and live peek status in mobile bubbles
The mobile app was receiving SSE status events but showing the peek text
only while the assistant bubble was empty — as soon as the first token
arrived, the status line was replaced by streaming content and any tool
calls fired mid-turn left no trace. Tool call SSE events were also being
dropped by the parser, and Message.fromJson never read the persisted
tool_calls array, so chips never rendered after reload either.

Parse tool_call SSE frames into a new ChatToolCall event, carry tool
calls on Message, and update the chat and briefing streaming loops to
append chips to the in-flight assistant message as they arrive. Rework
ChatMessageBubble to show a chip row + rolling peek status line above
any streamed text, matching the web ToolCallCard/status indicator
behaviour across chat, briefing, and briefing history surfaces.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-13 17:48:16 -04:00

164 lines
5.9 KiB
Dart

import 'dart:math' show min;
import 'package:flutter/material.dart';
import 'package:flutter_markdown_plus/flutter_markdown_plus.dart';
import '../data/models/message.dart';
import 'tool_call_chip.dart';
class ChatMessageBubble extends StatelessWidget {
final Message message;
final String streamingStatus;
const ChatMessageBubble({
super.key,
required this.message,
this.streamingStatus = '',
});
@override
Widget build(BuildContext context) {
final isUser = message.role == MessageRole.user;
final scheme = Theme.of(context).colorScheme;
final isGenerating = message.status == 'generating';
final toolCalls = message.toolCalls ?? const [];
// An assistant bubble with no text, no tool calls, and still generating
// falls back to the spinner+status "waiting for the first token" view.
final showSpinnerOnly =
isGenerating && message.content.isEmpty && toolCalls.isEmpty;
return Align(
alignment: isUser ? Alignment.centerRight : Alignment.centerLeft,
child: Container(
constraints: BoxConstraints(
maxWidth: min(MediaQuery.of(context).size.width * 0.82, 480),
),
margin: const EdgeInsets.symmetric(vertical: 4, horizontal: 4),
decoration: isUser
? BoxDecoration(
color: Colors.transparent,
border: Border.all(
color: scheme.primary.withValues(alpha: 0.35),
width: 1,
),
borderRadius: const BorderRadius.only(
topLeft: Radius.circular(16),
topRight: Radius.circular(16),
bottomLeft: Radius.circular(16),
bottomRight: Radius.circular(4),
),
)
: BoxDecoration(
color: scheme.surfaceContainerHighest,
border: Border(
left: BorderSide(color: scheme.primary, width: 2),
),
borderRadius: const BorderRadius.only(
topLeft: Radius.circular(4),
topRight: Radius.circular(16),
bottomLeft: Radius.circular(4),
bottomRight: Radius.circular(16),
),
),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
child: showSpinnerOnly
? _buildSpinner(scheme)
: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
// Accumulated tool-call chips: visible both during
// streaming (fed live over SSE) and after reload (from
// the persisted message.tool_calls array).
if (toolCalls.isNotEmpty) ...[
Wrap(
spacing: 6,
runSpacing: 6,
children: [
for (final tc in toolCalls) ToolCallChip(toolCall: tc),
],
),
const SizedBox(height: 6),
],
// Rolling status line — shows backend stage text
// ("Creating note", "Searching calendar") while the
// model is between tool rounds or just before the
// first token. Stays above any already-streamed text
// so the user can see what's happening mid-turn.
if (isGenerating && streamingStatus.isNotEmpty) ...[
Row(
mainAxisSize: MainAxisSize.min,
children: [
SizedBox(
width: 12,
height: 12,
child: CircularProgressIndicator(
strokeWidth: 1.5,
color: scheme.onSurfaceVariant,
),
),
const SizedBox(width: 6),
Flexible(
child: Text(
streamingStatus,
style: TextStyle(
fontSize: 11,
color: scheme.onSurfaceVariant,
fontStyle: FontStyle.italic,
),
),
),
],
),
const SizedBox(height: 6),
],
if (message.content.isNotEmpty)
MarkdownBody(
data: message.content,
styleSheet: MarkdownStyleSheet(
p: TextStyle(
color: isUser
? scheme.onSurface.withValues(alpha: 0.75)
: scheme.onSurface,
fontSize: 14,
),
),
),
],
),
),
),
);
}
Widget _buildSpinner(ColorScheme scheme) {
return Row(
mainAxisSize: MainAxisSize.min,
children: [
SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(
strokeWidth: 2,
color: scheme.onSurfaceVariant,
),
),
if (streamingStatus.isNotEmpty) ...[
const SizedBox(width: 8),
Flexible(
child: Text(
streamingStatus,
style: TextStyle(
fontSize: 12,
color: scheme.onSurfaceVariant,
fontStyle: FontStyle.italic,
),
),
),
],
],
);
}
}