Skip to content
Merged
Show file tree
Hide file tree
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
13 changes: 7 additions & 6 deletions packages/stream_chat_flutter_ai/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,8 +41,8 @@
- `AIToolCallPart` — the typed view of an `ai_tool_call` step, with `AIToolCallStatus` and
`AIToolExecutor` (server or client).
- `AIToolApproval` — what a tool call asks before it runs, with `AIToolApprovalDecision`.
- `AIMessagePartsView` — shows a reply's steps in order, keyed by id. `AIMessagePartWidgetBuilder`
builds parts your own way.
- `AIMessagePartsView` — shows a reply's steps in order, keyed by id, filling its width.
`AIMessagePartWidgetBuilder` builds parts your own way.
- `AIUnsupportedPartView` — the placeholder for a step this app version can't show.
- `AITranslations.unsupportedMessagePart` — that placeholder's text.
- `StreamingReasoningView` — a collapsible "Thinking… 7s / Thought for 12s" panel for the model's
Expand All @@ -52,10 +52,11 @@
translated through the new `AITranslations` members `reasoningThinking`, `reasoningThinkingFor`,
`reasoningTitle`, `reasoningThoughtFor`, `reasoningShowHint` and `reasoningHideHint`.
`DefaultAITranslations.reasoningDuration` spells the durations.
- `AIToolCallView` — a one-line row for a tool-call step: a status icon, the title, a detail line
(the summary, or the status in words) and the duration once the call has finished. Screen readers
hear it as one node, announced as a live call finishes or starts waiting. `AIMessagePartsView`
shows tool calls with it.
- `AIToolCallView` — a one-line row for a tool-call step: a status icon that follows the call's
status (a shimmering hand while it waits for approval, a cross once declined), the title, a detail
line (the summary, or the status in words) and the duration once the call has finished, declined
included. Screen readers hear it as one node, announced as a live call finishes or starts
waiting. `AIMessagePartsView` shows tool calls with it.
- `ToolCallThemeData` and `ToolCallTheme`, and `AITheme.toolCallTheme`, for styling tool-call rows.
- `AIMessagePartsView.toolCallBuilder` (an `AIToolCallWidgetBuilder`) — builds some tools your own
way and leaves the rest to `AIToolCallView`.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,10 @@ import 'package:stream_chat_flutter_ai/src/parts/ai_tool_approval_view.dart';
import 'package:stream_chat_flutter_ai/src/parts/ai_tool_approver.dart';
import 'package:stream_chat_flutter_ai/src/parts/ai_tool_call_part.dart';
import 'package:stream_chat_flutter_ai/src/parts/ai_tool_call_view.dart';
import 'package:stream_chat_flutter_ai/src/parts/resolved_tool_call_theme.dart';
import 'package:stream_chat_flutter_ai/src/reasoning/streaming_reasoning_view.dart';
// Imported for the doc links below only.
import 'package:stream_chat_flutter_ai/src/theme/components/tool_call_theme.dart';

/// Builds the widget for one [AIMessagePart], or returns `null` to fall back to
/// [AIMessagePartsView]'s default for it, or [AIMessagePartsView.hidden] to
Expand Down Expand Up @@ -88,7 +91,7 @@ class AIMessagePartsView extends StatelessWidget {
this.partBuilder,
this.toolCallBuilder,
this.approver,
this.spacing = 8,
this.spacing = 10,
Comment thread
xsahil03x marked this conversation as resolved.
});

/// The steps to show, in order.
Expand All @@ -108,8 +111,7 @@ class AIMessagePartsView extends StatelessWidget {
/// row; without one, no questions are asked.
final AIToolApprover? approver;

/// The vertical gap between two parts, and between a tool call's row and its
/// question.
/// The vertical gap between two parts.
final double spacing;

/// Returned from [partBuilder], [toolCallBuilder] or an
Expand Down Expand Up @@ -137,11 +139,17 @@ class AIMessagePartsView extends StatelessWidget {
}
if (children.isEmpty) return const SizedBox.shrink();

return Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
spacing: spacing,
children: children,
// Fills a bounded width, so the steps line up with the reply's text
// whatever their own widths; shrink-wraps an unbounded one.
return Align(
alignment: AlignmentDirectional.topStart,
heightFactor: 1,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
spacing: spacing,
children: children,
),
);
}

Expand Down Expand Up @@ -196,14 +204,18 @@ class AIMessagePartsView extends StatelessWidget {
if (showRow) row,
if (approver != null)
Padding(
padding: EdgeInsets.only(top: showRow && asks ? spacing : 0),
padding: EdgeInsets.only(top: showRow && asks ? _questionGap : 0),
child: AIToolApprovalView(call: call, approver: approver),
),
],
);
}
}

/// The gap between a tool call's row and its question, closer than the gap
/// between steps so the two read as one.
const double _questionGap = 8;

/// [AIMessagePartsView.hidden]. Never built: the view leaves it out.
class _HiddenPart extends StatelessWidget {
const _HiddenPart();
Expand All @@ -216,28 +228,38 @@ class _HiddenPart extends StatelessWidget {
/// it doesn't know, a format newer than it reads, or a payload it couldn't
/// decode. It says that something happened without guessing what.
///
/// The text is [AITranslations.unsupportedMessagePart].
/// The text is [AITranslations.unsupportedMessagePart]. It reads like a tool
/// call's row: its font, its icon size, and the color of its detail line.
class AIUnsupportedPartView extends StatelessWidget {
/// Creates an [AIUnsupportedPartView].
const AIUnsupportedPartView({super.key, this.textStyle, this.iconColor});

/// The style of the text, merged over `bodySmall` in `onSurfaceVariant`.
/// The style of the text, merged over a tool call's title style
/// ([ToolCallThemeData.titleStyle]) in the color of its detail line
/// ([ToolCallThemeData.detailStyle]).
final TextStyle? textStyle;

/// The color of the sparkles icon; defaults to the text's color.
final Color? iconColor;

@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final style = theme.textTheme.bodySmall!.copyWith(color: theme.colorScheme.onSurfaceVariant).merge(textStyle);
final iconSize = (style.fontSize ?? 12) * 1.25;
final toolCall = ResolvedToolCallTheme.resolve(context);
final step = toolCall.titleStyle;
final detail = toolCall.detailStyle;
final detailColor = detail.color ?? detail.foreground?.color;
// A title painted with a `foreground` keeps its paint.
final style = (step.foreground == null ? step.copyWith(color: detailColor) : step).merge(textStyle);

return Row(
mainAxisSize: MainAxisSize.min,
spacing: 8,
children: [
Icon(Icons.auto_awesome_outlined, size: iconSize, color: iconColor ?? style.color),
const SizedBox(width: 6),
Icon(
Icons.auto_awesome_outlined,
size: toolCall.iconSize,
color: iconColor ?? style.color ?? style.foreground?.color,
),
Flexible(child: Text(AITranslations.of(context).unsupportedMessagePart, style: style)),
],
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -178,8 +178,8 @@ class _AIToolApprovalViewState extends State<AIToolApprovalView> {
/// [AITranslations.toolApprovalTitle] with [toolName], the buttons to
/// [AITranslations.toolApprovalAllow] and [AITranslations.toolApprovalDecline].
///
/// While [state] is sending, both buttons are disabled and the pressed one
/// shows a small progress indicator (a static icon with reduced motion on).
/// While [state] is sending, both buttons are disabled and a small progress
/// indicator follows them (a static icon with reduced motion on).
/// Once sent, they stay disabled and a check with
/// [AITranslations.toolApprovalSent] follows them. When it has failed, a line
/// in [ToolApprovalThemeData.failureStyle] says the answer couldn't be sent,
Expand Down Expand Up @@ -266,10 +266,10 @@ class AIToolApprovalCard extends StatelessWidget {
Text(title, style: theme.titleStyle),
for (final line in _lines(approval))
Padding(
padding: const EdgeInsets.only(top: 2),
padding: const EdgeInsets.only(top: 4),
child: Text(line, style: theme.messageStyle),
),
const SizedBox(height: 10),
const SizedBox(height: 12),
Wrap(
spacing: 8,
runSpacing: 8,
Expand All @@ -282,10 +282,7 @@ class AIToolApprovalCard extends StatelessWidget {
foregroundColor: theme.onAccentColor,
visualDensity: VisualDensity.compact,
),
child: _ButtonLabel(
allowLabel,
busy: sending && state.decision == AIToolApprovalDecision.allowed,
),
child: Text(allowLabel),
),
OutlinedButton(
onPressed: busy ? null : () => onAnswer(AIToolApprovalDecision.declined),
Expand All @@ -294,11 +291,9 @@ class AIToolApprovalCard extends StatelessWidget {
side: BorderSide(color: theme.borderColor),
visualDensity: VisualDensity.compact,
),
child: _ButtonLabel(
declineLabel,
busy: sending && state.decision == AIToolApprovalDecision.declined,
),
child: Text(declineLabel),
),
if (sending) _SendingIndicator(color: theme.accentColor),
if (state.isSent)
Semantics(
liveRegion: true,
Expand All @@ -315,7 +310,7 @@ class AIToolApprovalCard extends StatelessWidget {
),
if (state.hasFailed)
Padding(
padding: const EdgeInsets.only(top: 8),
padding: const EdgeInsets.only(top: 12),
child: Semantics(
liveRegion: true,
child: Text(translations.toolApprovalNotSent, style: theme.failureStyle),
Expand All @@ -330,38 +325,27 @@ class AIToolApprovalCard extends StatelessWidget {
}
}

/// A button's label, led by a small progress indicator while its answer is
/// on its way.
class _ButtonLabel extends StatelessWidget {
const _ButtonLabel(this.text, {required this.busy});
/// The small progress indicator after the buttons while the answer is on its
/// way.
class _SendingIndicator extends StatelessWidget {
const _SendingIndicator({required this.color});

final String text;
final bool busy;
final Color color;

@override
Widget build(BuildContext context) {
if (!busy) return Text(text);

// The button's own (disabled) foreground, which it puts in the icon theme.
final color = IconTheme.of(context).color;
final reduceMotion = MediaQuery.maybeDisableAnimationsOf(context) ?? false;
const size = 14.0;
return Row(
mainAxisSize: MainAxisSize.min,
children: [
Semantics(
label: AITranslations.of(context).toolApprovalSending,
child: SizedBox.square(
dimension: size,
// A spinner never stops, so reduced motion gets a still "in progress".
child: reduceMotion
? Icon(Icons.more_horiz_rounded, size: size, color: color)
: CircularProgressIndicator(strokeWidth: 2, color: color),
),
),
const SizedBox(width: 8),
Text(text),
],
const size = 16.0;
return Semantics(
container: true,
label: AITranslations.of(context).toolApprovalSending,
child: SizedBox.square(
dimension: size,
// A spinner never stops, so reduced motion gets a still "in progress".
child: reduceMotion
? Icon(Icons.more_horiz_rounded, size: size, color: color)
: CircularProgressIndicator(strokeWidth: 2, color: color),
),
);
}
}
Loading
Loading