From 387602b61a0768e1996a5f3abbc7de3aa500c7e8 Mon Sep 17 00:00:00 2001 From: VelikovPetar Date: Mon, 26 Oct 2026 21:46:18 +0100 Subject: [PATCH 1/4] fix(ai): align tool-call rows, the parts view and the approval card with iOS - A declined call's icon follows its status (shimmering hand while still awaiting approval, then the cross) and keeps its duration once finished. - The waiting shimmer draws the icon in the accent with a title-colored band. - Tool row icon 16 with a gap of 8; parts spaced 10 and full width. - Unsupported-step placeholder reads like a tool row. - Approval card spacing 4 / 12 / 8, spinner after the buttons. Co-Authored-By: Claude Opus 5.5 --- packages/stream_chat_flutter_ai/CHANGELOG.md | 10 +++ .../lib/src/parts/ai_message_parts_view.dart | 45 +++++++++---- .../lib/src/parts/ai_tool_approval_view.dart | 64 +++++++------------ .../lib/src/parts/ai_tool_call_view.dart | 59 ++++++++++------- .../src/parts/resolved_tool_call_theme.dart | 2 +- .../src/theme/components/tool_call_theme.dart | 17 ++--- .../src/parts/ai_message_parts_view_test.dart | 61 +++++++++++++++++- .../src/parts/ai_tool_approval_view_test.dart | 40 ++++++++---- .../src/parts/ai_tool_call_view_test.dart | 37 +++++++---- 9 files changed, 220 insertions(+), 115 deletions(-) diff --git a/packages/stream_chat_flutter_ai/CHANGELOG.md b/packages/stream_chat_flutter_ai/CHANGELOG.md index 8a6b17eb..61b96768 100644 --- a/packages/stream_chat_flutter_ai/CHANGELOG.md +++ b/packages/stream_chat_flutter_ai/CHANGELOG.md @@ -169,10 +169,20 @@ - A pie's screen-reader summary lists its slice names. - `ChartSemantics.isEmpty` is `true` for a pie with no positive slice. - The README documents the new components β€” agent steps, reasoning, tool calls, approvals, the client tool runner, `AILocalModel` and chart legends β€” and its `SpeechToTextButton` snippet now uses `SpeechToTextConfig`. +- Agent steps are laid out as on iOS and Android: `AIMessagePartsView` fills its width and spaces + steps 10 apart (was 8); `AIToolCallView`'s icon defaults to 16 (`ToolCallThemeData.iconSize`, was + 14) with a gap of 8 after it; `AIUnsupportedPartView` reads like a tool call's row, in its title's + font and its detail color, with the same icon size; and `AIToolApprovalCard` spaces its lines 4 + apart and its buttons and failure line 12, with the sending spinner after the buttons instead of + inside the pressed one. 🐞 Fixed - `AITypingIndicatorView`'s dots were black by default, which made them invisible in dark mode. +- `AIToolCallView`: a declined call's icon follows its status, as on iOS and Android β€” the + shimmering hand while it still waits for approval, then the cross β€” and keeps its duration once + finished. A waiting call's icon is drawn in `ToolCallThemeData.accentColor` with a band of the + title's color sweeping across it; the two were reversed. - `StreamingMessageView.onTypewriterStateChanged` is no longer called during a build, so the host can call `setState` from it. A state that changes mid-frame is reported after the frame. - A `NaN` or infinite value no longer breaks a chart: the parsers drop it, and a pie leaves such diff --git a/packages/stream_chat_flutter_ai/lib/src/parts/ai_message_parts_view.dart b/packages/stream_chat_flutter_ai/lib/src/parts/ai_message_parts_view.dart index ea6ce5bb..1b018ebc 100644 --- a/packages/stream_chat_flutter_ai/lib/src/parts/ai_message_parts_view.dart +++ b/packages/stream_chat_flutter_ai/lib/src/parts/ai_message_parts_view.dart @@ -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 @@ -88,7 +91,7 @@ class AIMessagePartsView extends StatelessWidget { this.partBuilder, this.toolCallBuilder, this.approver, - this.spacing = 8, + this.spacing = 10, }); /// The steps to show, in order. @@ -137,11 +140,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, + ), ); } @@ -216,12 +225,15 @@ 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. @@ -229,15 +241,22 @@ class AIUnsupportedPartView extends StatelessWidget { @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)), ], ); diff --git a/packages/stream_chat_flutter_ai/lib/src/parts/ai_tool_approval_view.dart b/packages/stream_chat_flutter_ai/lib/src/parts/ai_tool_approval_view.dart index 565a8c3c..ca4578ec 100644 --- a/packages/stream_chat_flutter_ai/lib/src/parts/ai_tool_approval_view.dart +++ b/packages/stream_chat_flutter_ai/lib/src/parts/ai_tool_approval_view.dart @@ -178,8 +178,8 @@ class _AIToolApprovalViewState extends State { /// [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, @@ -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, @@ -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), @@ -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, @@ -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), @@ -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), + ), ); } } diff --git a/packages/stream_chat_flutter_ai/lib/src/parts/ai_tool_call_view.dart b/packages/stream_chat_flutter_ai/lib/src/parts/ai_tool_call_view.dart index 7c122ce6..4203ad57 100644 --- a/packages/stream_chat_flutter_ai/lib/src/parts/ai_tool_call_view.dart +++ b/packages/stream_chat_flutter_ai/lib/src/parts/ai_tool_call_view.dart @@ -21,11 +21,12 @@ import 'package:stream_chat_flutter_ai/src/util/shimmer.dart'; /// /// The row reads, from the start: /// -/// * **A status icon.** A small spinner while the call runs (and for a status -/// this package doesn't know, which counts as still in progress β€” debug -/// builds say so); a shimmering hand while it waits for approval, or a -/// phone while it waits for a device; a check once completed, an -/// exclamation mark once failed, and a cross once cancelled or declined. +/// * **A status icon,** following [AIToolCallPart.status]. A small spinner +/// while the call runs (and for a status this package doesn't know, which +/// counts as still in progress β€” debug builds say so); a shimmering hand +/// while it waits for approval, or a phone while it waits for a device; a +/// check once completed, an exclamation mark once failed, and a cross once +/// cancelled. /// * **The title:** [AIToolCallPart.displayName] (the agent's title, or the /// tool's name with underscores turned into spaces), or /// [AITranslations.toolCallFallbackTitle] when the agent sent neither. @@ -35,11 +36,12 @@ import 'package:stream_chat_flutter_ai/src/util/shimmer.dart'; /// running or completed call without a summary has no detail line. /// * **The duration**, when known, at the end once the call has finished: /// `0.4s` under a second, otherwise `12s` or `1m 5s` -/// ([AITranslations.toolCallDuration]). A declined call never ran, so it -/// shows none. +/// ([AITranslations.toolCallDuration]). /// -/// A declined call ([AIToolCallPart.isDeclined]) reads as declined whatever -/// its status says. +/// A declined call ([AIToolCallPart.isDeclined]) says so in its detail line +/// and to screen readers whatever its status says, while its icon still +/// follows the status: the hand while the backend has yet to cancel the call, +/// then the cross. /// /// It is a row of text, so give it a bounded width, as a chat list does. /// @@ -86,9 +88,12 @@ enum _ToolCallState { cancelled, declined; - static _ToolCallState of(AIToolCallPart call) { - // Declined comes first: the call never ran, whatever its status says yet. - if (call.isDeclined) return declined; + /// What the detail line and screen readers say. Declined comes first: the + /// call never ran, whatever its status says yet. + static _ToolCallState of(AIToolCallPart call) => call.isDeclined ? declined : ofStatus(call); + + /// What the icon shows: the status alone, declined or not. + static _ToolCallState ofStatus(AIToolCallPart call) { return switch (call.status) { AIToolCallStatus.awaitingApproval => awaitingApproval, AIToolCallStatus.awaitingClient => awaitingClient, @@ -181,9 +186,9 @@ class _AIToolCallViewState extends State with SingleTickerProvid ); } - /// Runs the shimmer only while the call waits and motion is on. + /// Runs the shimmer only while the call's icon waits and motion is on. void _syncShimmer() { - if (_ToolCallState.of(widget.call).isAwaiting && !_reduceMotion) { + if (_ToolCallState.ofStatus(widget.call).isAwaiting && !_reduceMotion) { final shimmer = _shimmer ??= AnimationController(vsync: this, duration: const Duration(milliseconds: 1400)); if (!shimmer.isAnimating) shimmer.repeat(); } else { @@ -201,10 +206,10 @@ class _AIToolCallViewState extends State with SingleTickerProvid final title = call.displayName.isEmpty ? translations.toolCallFallbackTitle : call.displayName; final summary = call.summaryText; final detail = summary ?? state.detail(translations); - final duration = call.status.isFinished && state != _ToolCallState.declined ? call.duration : null; + final duration = call.status.isFinished ? call.duration : null; final durationText = duration == null ? null : translations.toolCallDuration(duration); - final gap = math.max(6, theme.iconSize * 0.5); + const gap = 8.0; return Semantics( container: true, liveRegion: _announces, @@ -219,13 +224,18 @@ class _AIToolCallViewState extends State with SingleTickerProvid SizedBox.square( dimension: theme.iconSize, child: Center( - child: _StatusIcon(state: state, theme: theme, reduceMotion: _reduceMotion, shimmer: _shimmer), + child: _StatusIcon( + state: _ToolCallState.ofStatus(call), + theme: theme, + reduceMotion: _reduceMotion, + shimmer: _shimmer, + ), ), ), - SizedBox(width: gap), + const SizedBox(width: gap), Expanded(child: Text(title, style: theme.titleStyle)), if (durationText != null) ...[ - SizedBox(width: gap), + const SizedBox(width: gap), Text( durationText, style: theme.detailStyle.copyWith( @@ -262,14 +272,14 @@ class _StatusIcon extends StatelessWidget { final size = theme.iconSize; Widget icon(IconData data, Color color) => Icon(data, size: size, color: color); Widget waiting(IconData data) { - // Still, the icon is drawn in the accent outright. Moving, as for the - // reasoning header, a band of the accent sweeps over the neutral icon. + // Drawn in the accent; moving, a band of the title's color sweeps over + // it. if (reduceMotion || shimmer == null) return icon(data, theme.accentColor); return Shimmer( animation: shimmer!, - base: theme.iconColor, - highlight: theme.accentColor, - child: icon(data, theme.iconColor), + base: theme.accentColor, + highlight: theme.titleStyle.color ?? theme.titleStyle.foreground?.color ?? theme.accentColor, + child: icon(data, theme.accentColor), ); } @@ -285,6 +295,7 @@ class _StatusIcon extends StatelessWidget { _ToolCallState.awaitingClient => waiting(Icons.smartphone_outlined), _ToolCallState.completed => icon(Icons.check_rounded, theme.successColor), _ToolCallState.failed => icon(Icons.priority_high_rounded, theme.failureColor), + // `declined` never gets here, as the icon follows the status alone. _ToolCallState.cancelled || _ToolCallState.declined => icon(Icons.close_rounded, theme.iconColor), }; } diff --git a/packages/stream_chat_flutter_ai/lib/src/parts/resolved_tool_call_theme.dart b/packages/stream_chat_flutter_ai/lib/src/parts/resolved_tool_call_theme.dart index 16f80ec4..406f1a82 100644 --- a/packages/stream_chat_flutter_ai/lib/src/parts/resolved_tool_call_theme.dart +++ b/packages/stream_chat_flutter_ai/lib/src/parts/resolved_tool_call_theme.dart @@ -8,7 +8,7 @@ import 'package:stream_chat_flutter_ai/src/theme/ai_theme.dart'; import 'package:stream_chat_flutter_ai/src/theme/components/tool_call_theme.dart'; import 'package:stream_chat_flutter_ai/src/theme/theme_lerp.dart'; -const _kDefaultIconSize = 14.0; +const _kDefaultIconSize = 16.0; /// The default success green on light surfaces: dark enough for 4.5:1 on /// white. diff --git a/packages/stream_chat_flutter_ai/lib/src/theme/components/tool_call_theme.dart b/packages/stream_chat_flutter_ai/lib/src/theme/components/tool_call_theme.dart index 9cb99c92..4836a751 100644 --- a/packages/stream_chat_flutter_ai/lib/src/theme/components/tool_call_theme.dart +++ b/packages/stream_chat_flutter_ai/lib/src/theme/components/tool_call_theme.dart @@ -4,8 +4,6 @@ import 'package:flutter/material.dart'; import 'package:stream_chat_flutter_ai/src/parts/ai_tool_call_view.dart'; // Not doc-only: [ToolCallTheme.of] reads it. import 'package:stream_chat_flutter_ai/src/theme/ai_theme.dart'; -// Imported for the doc links below only. -import 'package:stream_chat_flutter_ai/src/theme/components/reasoning_theme.dart'; import 'package:stream_chat_flutter_ai/src/theme/theme_lerp.dart'; /// Overrides for how [AIToolCallView] is drawn: the title and detail text, the @@ -49,10 +47,10 @@ class ToolCallThemeData with Diagnosticable { /// [TextTheme.bodySmall] in [ColorScheme.onSurfaceVariant]. final TextStyle? detailStyle; - /// The spinner of a running call, and the band of light sweeping across the - /// icon of a call waiting for approval or for a device β€” the same role as - /// [ReasoningThemeData.shimmerColor]. With reduced motion on, the waiting - /// icon is drawn in it outright. Defaults to [ColorScheme.primary]. + /// The spinner of a running call, and the icon of a call waiting for + /// approval or for a device, with a band of the [titleStyle]'s color + /// sweeping across it unless reduced motion is on. Defaults to + /// [ColorScheme.primary]. final Color? accentColor; /// The check of a completed call. Defaults to a green that reads on the @@ -62,12 +60,11 @@ class ToolCallThemeData with Diagnosticable { /// The icon of a failed call. Defaults to [ColorScheme.error]. final Color? failureColor; - /// The neutral icons: the cross of a cancelled or declined call, and a - /// waiting call's icon beneath its [accentColor] band. Defaults to the - /// resolved [detailStyle]'s color. + /// The cross of a cancelled call. Defaults to the resolved [detailStyle]'s + /// color. final Color? iconColor; - /// The status icon's size, and the spinner's. Defaults to 14. + /// The status icon's size, and the spinner's. Defaults to 16. final double? iconSize; /// Returns a copy with the given fields replaced. A `null` argument keeps the diff --git a/packages/stream_chat_flutter_ai/test/src/parts/ai_message_parts_view_test.dart b/packages/stream_chat_flutter_ai/test/src/parts/ai_message_parts_view_test.dart index fc398f01..2b2ef607 100644 --- a/packages/stream_chat_flutter_ai/test/src/parts/ai_message_parts_view_test.dart +++ b/packages/stream_chat_flutter_ai/test/src/parts/ai_message_parts_view_test.dart @@ -364,6 +364,32 @@ void main() { expect(gap, 20); }); + testWidgets('spaces parts 10 apart by default', (tester) async { + await tester.pumpWidget( + _wrap( + AIMessagePartsView( + parts: _parts.take(2).toList(), + partBuilder: (context, part) => SizedBox(height: 10, child: Text(part.id)), + ), + ), + ); + + final gap = tester.getTopLeft(find.text('call_1')).dy - tester.getBottomLeft(find.text('r1')).dy; + expect(gap, 10); + }); + + testWidgets('fills a bounded width and shrink-wraps an unbounded one', (tester) async { + Widget parts() => AIMessagePartsView( + parts: _parts.take(1).toList(), + partBuilder: (context, part) => const SizedBox(width: 40, height: 10), + ); + await tester.pumpWidget(_wrap(SizedBox(width: 300, child: parts()))); + expect(tester.getSize(find.byType(AIMessagePartsView)), const Size(300, 10)); + + await tester.pumpWidget(_wrap(Row(mainAxisSize: MainAxisSize.min, children: [parts()]))); + expect(tester.getSize(find.byType(AIMessagePartsView)), const Size(40, 10)); + }); + testWidgets('renders nothing for no parts', (tester) async { await tester.pumpWidget(_wrap(const AIMessagePartsView(parts: []))); @@ -549,8 +575,39 @@ void main() { expect(text.style!.color, const Color(0xFFFF0000)); final icon = tester.widget(find.byType(Icon)); expect(icon.color, const Color(0xFF00FF00)); - // Scales with the text rather than staying a fixed size. - expect(icon.size, greaterThan(20)); + // A tool call's icon size, whatever the text's. + expect(icon.size, 16); + }); + + testWidgets("reads like a tool call's row by default", (tester) async { + await tester.pumpWidget(_wrap(const AIUnsupportedPartView())); + final theme = Theme.of(tester.element(find.byType(AIUnsupportedPartView))); + + final style = tester.widget(find.byType(Text)).style!; + expect(style.fontSize, theme.textTheme.bodyMedium!.fontSize, reason: "the tool call title's size"); + expect(style.color, theme.colorScheme.onSurfaceVariant, reason: "the tool call detail's color"); + expect(tester.widget(find.byType(Icon)).size, 16); + expect(tester.getTopLeft(find.byType(Text)).dx - tester.getTopRight(find.byType(Icon)).dx, 8); + }); + + testWidgets('follows the tool call theme', (tester) async { + await tester.pumpWidget( + _wrap( + const ToolCallTheme( + data: ToolCallThemeData( + titleStyle: TextStyle(fontSize: 17), + detailStyle: TextStyle(color: Color(0xFF0000E1)), + iconSize: 20, + ), + child: AIUnsupportedPartView(), + ), + ), + ); + + final style = tester.widget(find.byType(Text)).style!; + expect(style.fontSize, 17); + expect(style.color, const Color(0xFF0000E1)); + expect(tester.widget(find.byType(Icon)).size, 20); }); testWidgets('the icon takes the text color by default', (tester) async { diff --git a/packages/stream_chat_flutter_ai/test/src/parts/ai_tool_approval_view_test.dart b/packages/stream_chat_flutter_ai/test/src/parts/ai_tool_approval_view_test.dart index 147705ba..e3d7827f 100644 --- a/packages/stream_chat_flutter_ai/test/src/parts/ai_tool_approval_view_test.dart +++ b/packages/stream_chat_flutter_ai/test/src/parts/ai_tool_approval_view_test.dart @@ -520,14 +520,15 @@ void main() { semantics.dispose(); }); - testWidgets('while sending, the buttons are disabled and the pressed one says so', (tester) async { + testWidgets('while sending, the buttons are disabled and the indicator after them says so', (tester) async { final semantics = tester.ensureSemantics(); await tester.pumpWidget(_wrap(AIToolApprovalView(call: _call(), approver: approver))); await tester.tap(find.text('Allow')); await tester.pump(); + expect(find.bySemanticsLabel('Sending your answer…'), findsOneWidget); final allow = tester.getSemantics(find.byType(FilledButton)); - expect(allow.label, contains('Sending your answer…')); + expect(allow.label, isNot(contains('Sending your answer…'))); expect(allow.flagsCollection.isEnabled, Tristate.isFalse); expect(tester.getSemantics(find.byType(OutlinedButton)).flagsCollection.isEnabled, Tristate.isFalse); semantics.dispose(); @@ -557,12 +558,12 @@ void main() { expect(backend.answers, [('call_1', _decline)]); expect(_enabled(tester, 'Allow'), isFalse); expect(_enabled(tester, "Don't Allow"), isFalse); - // The pressed button shows the progress; reduced motion makes it still. + // The progress follows the buttons; reduced motion makes it still. + expect(find.byIcon(Icons.more_horiz_rounded), findsOneWidget); expect( find.descendant(of: find.byType(OutlinedButton), matching: find.byIcon(Icons.more_horiz_rounded)), - findsOneWidget, + findsNothing, ); - expect(find.descendant(of: find.byType(FilledButton), matching: find.byType(Icon)), findsNothing); await tester.tap(find.text('Allow'), warnIfMissed: false); await tester.tap(find.text("Don't Allow"), warnIfMissed: false); @@ -570,18 +571,31 @@ void main() { expect(backend.answers, hasLength(1), reason: 'taps while sending are ignored'); }); - testWidgets('allowing shows a spinner on the allow button with motion on', (tester) async { - await tester.pumpWidget(_wrap(AIToolApprovalView(call: _call(), approver: approver))); + testWidgets('allowing shows a spinner after the buttons with motion on', (tester) async { + // Wide enough for the buttons and the spinner on one line in the test font. + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Align( + alignment: Alignment.topLeft, + child: SizedBox( + width: 600, + child: AIToolApprovalView(call: _call(), approver: approver), + ), + ), + ), + ), + ); await tester.tap(find.text('Allow')); await tester.pump(); expect(backend.answers, [('call_1', _allow)]); + final spinner = find.byType(CircularProgressIndicator); + expect(spinner, findsOneWidget); + expect(find.descendant(of: find.byType(FilledButton), matching: spinner), findsNothing); expect( - find.descendant(of: find.byType(FilledButton), matching: find.byType(CircularProgressIndicator)), - findsOneWidget, - ); - expect( - find.descendant(of: find.byType(OutlinedButton), matching: find.byType(CircularProgressIndicator)), - findsNothing, + tester.getTopLeft(spinner).dx - tester.getTopRight(find.byType(OutlinedButton)).dx, + 8, + reason: 'one button gap after the decline button', ); }); diff --git a/packages/stream_chat_flutter_ai/test/src/parts/ai_tool_call_view_test.dart b/packages/stream_chat_flutter_ai/test/src/parts/ai_tool_call_view_test.dart index 8a555014..c857bee3 100644 --- a/packages/stream_chat_flutter_ai/test/src/parts/ai_tool_call_view_test.dart +++ b/packages/stream_chat_flutter_ai/test/src/parts/ai_tool_call_view_test.dart @@ -2,6 +2,7 @@ import 'package:alchemist/alchemist.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:stream_chat_flutter_ai/src/parts/json_fields.dart'; +import 'package:stream_chat_flutter_ai/src/util/shimmer.dart'; import 'package:stream_chat_flutter_ai/stream_chat_flutter_ai.dart'; Widget _wrap(Widget child, {ThemeData? theme}) => MaterialApp( @@ -58,22 +59,22 @@ List _texts(WidgetTester tester) => void main() { group('AIToolCallView', () { - testWidgets('a call declined while its status still waits stops shimmering', (tester) async { - const waiting = AIToolApproval(title: 'Send it?'); + testWidgets("a declined call's icon follows its status", (tester) async { await tester.pumpWidget( _wrap( AIToolCallView( - call: _call(status: AIToolCallStatus.awaitingApproval, approval: waiting), + call: _call(status: AIToolCallStatus.awaitingApproval, approval: _declined), ), ), ); await tester.pump(const Duration(milliseconds: 100)); - expect(tester.binding.hasScheduledFrame, isTrue, reason: 'the hand shimmers while it waits'); + expect(_icon(tester), Icons.back_hand_outlined, reason: 'the backend has yet to cancel it'); + expect(tester.binding.hasScheduledFrame, isTrue, reason: 'the hand shimmers while the status waits'); await tester.pumpWidget( _wrap( AIToolCallView( - call: _call(status: AIToolCallStatus.awaitingApproval, approval: _declined), + call: _call(status: AIToolCallStatus.cancelled, approval: _declined), ), ), ); @@ -196,7 +197,7 @@ void main() { } }); - testWidgets('a declined call shows none', (tester) async { + testWidgets('a declined call keeps it once finished', (tester) async { _reduceMotion(tester); await tester.pumpWidget( _wrap( @@ -205,7 +206,7 @@ void main() { ), ), ); - expect(find.text('0.4s'), findsNothing); + expect(find.text('0.4s'), findsOneWidget); }); testWidgets('shows nothing when the agent sent none', (tester) async { @@ -366,8 +367,12 @@ void main() { expect(tester.hasRunningAnimations, isTrue, reason: 'the stopped shimmer starts again'); }); - testWidgets('the shimmer sweeps an accent band over the neutral icon', (tester) async { - const data = ToolCallThemeData(accentColor: Color(0xFF0000D1), iconColor: Color(0xFF0000D2)); + testWidgets("the shimmer sweeps a band of the title's color over the accent icon", (tester) async { + const data = ToolCallThemeData( + accentColor: Color(0xFF0000D1), + iconColor: Color(0xFF0000D2), + titleStyle: TextStyle(color: Color(0xFF0000D3)), + ); await tester.pumpWidget( _wrap( AIToolCallView( @@ -376,7 +381,10 @@ void main() { ), ), ); - expect(_iconColor(tester), isSameColorAs(const Color(0xFF0000D2))); + final shimmer = tester.widget(_inView(find.byType(Shimmer))); + expect(shimmer.base, const Color(0xFF0000D1)); + expect(shimmer.highlight, const Color(0xFF0000D3)); + expect(_iconColor(tester), const Color(0xFF0000D1)); }); testWidgets('swaps spinner and still icon both ways as reduced motion toggles', (tester) async { @@ -541,7 +549,7 @@ void main() { final labels = { _call(): 'get weather, Running', _call(status: AIToolCallStatus.failed): "get weather, Didn't complete", - _call(status: AIToolCallStatus.cancelled, approval: _declined, durationMs: 1200): 'get weather, Declined', + _call(status: AIToolCallStatus.cancelled, approval: _declined, durationMs: 1200): 'get weather, Declined, 1s', _call(status: const AIToolCallStatus('queued')): 'get weather, Running', }; for (final MapEntry(key: call, value: label) in labels.entries) { @@ -574,7 +582,12 @@ void main() { final detail = tester.widget(find.text('Timed out')).style!; expect(detail.color, theme.colorScheme.onSurfaceVariant); expect(detail.fontSize, textTheme.bodySmall!.fontSize); - expect(tester.widget(_inView(find.byType(Icon))).size, 14); + expect(tester.widget(_inView(find.byType(Icon))).size, 16); + expect( + tester.getTopLeft(find.text('get weather')).dx - tester.getTopRight(_inView(find.byType(Icon))).dx, + 8, + reason: 'the gap after the icon', + ); await tester.pumpWidget(_wrap(AIToolCallView(call: _call()), theme: theme)); expect(spinnerColor(tester), theme.colorScheme.primary); From 3728a208e416b5c7e6b37decd6a386d132e625e8 Mon Sep 17 00:00:00 2001 From: VelikovPetar <15679533+VelikovPetar@users.noreply.github.com> Date: Mon, 26 Oct 2026 20:48:07 +0000 Subject: [PATCH 2/4] chore: Update Goldens --- .../goldens/ci/ai_conversation_view_dark.png | Bin 3248 -> 3242 bytes .../goldens/ci/ai_conversation_view_light.png | Bin 3060 -> 3057 bytes .../goldens/ci/ai_tool_approval_card_dark.png | Bin 12373 -> 12506 bytes .../ci/ai_tool_approval_card_light.png | Bin 12321 -> 12467 bytes .../goldens/ci/ai_tool_call_view_dark.png | Bin 1720 -> 1684 bytes .../goldens/ci/ai_tool_call_view_light.png | Bin 1710 -> 1674 bytes .../goldens/ci/ai_unsupported_part_view.png | Bin 438 -> 443 bytes 7 files changed, 0 insertions(+), 0 deletions(-) diff --git a/packages/stream_chat_flutter_ai/test/src/conversation/goldens/ci/ai_conversation_view_dark.png b/packages/stream_chat_flutter_ai/test/src/conversation/goldens/ci/ai_conversation_view_dark.png index c047b4b58547a7b73dd817e253f940fa52bbc105..52dd308b8c271a5b99f64ca1c2d0bbde659149fc 100644 GIT binary patch delta 2001 zcmXApdsxz08pjb8WE4qfOVbH!YO`%t>>^fDqGgelSd)f>qLwykhCYdS0qmFAjJwN7 z&9YmQm%^OXA}>vfp@J5<VSQF&x zDo3B^=cr-Z6AO?tW&S{$d3%EZs>2F5TO0>%Msrllex~L3Pdd zi0U6m-EqGsm0aCd2P>|D6`xk8%zx8N1>*UG4^Hu8_u&IG9>onvBoemBuIpAIa)*+) z)k!4PWyIRSpeVI=&OQ(ng)EWyxR+qj*D^vXW0eBz9kpU)gi?QsUn=ellfbZ1C*g2- zODC}YRs7xbvSBs8ZX)MYUS1w(NK$5wp*)~S-yPm=lY@K(7{r490SS1!!RR?TF-#_t zPNxfhK8y-U@~E57PpGXuH`?L2nHwo8ppmOj7%h~dA30!thGCM9goP%yp9%6A4|G_a0|nz69d0;o z9|E)bV|s4b@&|wXH1ld)r|HAfo0pF8)^RmotOxQu_oEWcoS@L?Tp6Uw$wxjQpsF{n zs$*;S^G-)mmH!H|_nvPdGm%9pE>|Z;e@O$8F!254&7Ht;0)YVb z;yRnnX0TXs8Ax23uB**2LU2?mFzEG97DJ%Ob#a)RU%!?Htf7%EhW)#|yj)CJ4}>WN zbS6{TLk8c!!`w8?C3}gcW@cw`!YkEIK5cXwjUtsw!O+pSZ{MDrX(%6|fnA+iS`uOc z0~5SN#liEBz0!0;*}{jty=?J|_s$?hZBI>blSL8Mm-t70^viTn)kt5V|u;kg(!1;yG;PC0hUU z{Jjl*X)%&2U;m*fP**p4YZ(l(JbNjI-F@Ko_{cu>k|o<;!&wV5&tfH5EO2Zk(SnAD zx}s1hIshao6r@0NM^=%_1Mj-f)AJ23)f53{HW*?liHYLFbg7M){C8LVsfq&hm`3H5`auc^8p?k9-!84Hl%GH!(YB{OmTr;2kK|j7=A9GclJf zHR&sYH`ta7{AaV^qQ92aT->wHX*ZbVf>(en{%&tE)RaTXyy3(+daU-bm;e!D%jpXR zf6)cVf}+mBotnh+`PRts;?T{q_~hX7!Q}S@L?kF*Iglu+Ww;*OjQ0H^eQ|MfM&kxq zd{@f6Hs_$`&p)0&wvijJW#vAIDEjr}rDB(kTGr%y7-QxE=zG zOpXEqsK^cz9p8*=-t)2qKAoz^l3U+ z?W=KP;WY@0&j!_@Uwk4I-v5}rtRCuOfaZLDFH!EP3+NM2DG3SseXvg*d_JGpP}K0? z{K}t>`|h7E0N(U8ORiESs#Cg%AhiaqpPh8he|j1mSbJU)NolThE!>R<6cejsp`x>B zGYiA43F*om1RZJjRq6%pW3bgv{=R6CDKq8AoYNF-7qw+_C= z&1y|xci)|v$(+)h=Dvf6?X?r1*{w#q4jjNvLwx6gvhZ5d8XP#ej9H$2lmH0jQ+9I+ z&@$r!x%1(UeIhBi&E{v-%r+VrD?cgLH-@%2MpYVDb#k&|976vKZpj-*^JI|r-`%+y z7jI82Ij`vLh2htmAo1UaCO!Pq?I+|w+KBVm!ogi!$lTFSkBbm%9l6I5s5N#+nIGY- TxO7!B1YD5^i4hI^ldt?AQf?Ef delta 2017 zcmX9;c~sKr8b(RQR9w2_npoFqneM1rG)^X{nRUxBY;tT+Q^&3rBrd54W&KU45PC5^hBdJ!l1s9$m6OjdiuoPk&Q75|QKLPD=O*%ezvRKC2qWAAy0T^KB| zCVbAqHb0E@gt^Ce|9r}^Ykj5RvF)8KR>=t;!RoJTA(OWTTmB~Cue??K`9G7Yi&Gpo zjKpAe(yC4}4|ta2_g=v7b=RR>`9~!jfQ@UyvvH$BVXJFdBnpk@P0UuDQ~i=$>R?R` z4hdW!6h<_X^>yRNth1AgFPNh5*ctJf)d;CvF2=Jbl(TloOIQ&yFDnac zG}`oXcT_R!*Vd?2(dl^rkOUgCRcYv(P8Y`S^dw_zYcug72t8`E*IvNgenzRR)9G|G z1_R#rE+UurcPyA-U|^u1Y;$rajbhX_9(^>g@3M68_JWt@Q4CCLXl6@dplA7vR{LBd z@9>2Mk03GKWX-n^NJCjGv5&i^cK-nP_VymlQnq#^9zdu^dwRmtXKj(W=aHVHmpF30 zyLRrwho^1p;kE!WcM8m}c7_chG2RG7ersh|gw4@MF)9_tfS@{wrWjV6ker<><=J}SZcOAM=jJ0$i;3XUo z!$^Pj_MxXtPDW%^(lcDs*tv+B6pZDYcIOMQX9;LM$}$Bks7lkWHcgivP^o=JhKFP1 zsZLW%ORd$_2r;e??iu`a-s`UFEhhsN8rsx-8*E0U@X0?lm&^L!$8NndrPG0V0N4;G zLn@W#Z8YmxrSM-U^hBh(v$V7{y7|NIk{d*+Og17EM#)o$XbOd*ldn$GCs;a2DP(dz zixnk~RM%ik^I%gs3I4M)Gp*{tf=JeAgMUD1zHa!{APjlwrAYKmVgi>06~YTBlm9))I1PNz(VF9bxle|L3AHK~jhy?k3eqa6T^SDRDb=Fj-H8n+^x=47Q5f28N$d46% z0u|BEI(OotjYCUah6MMeEBox12E=u>h2<_=S_;e^Xr{_is692H7%;U-Qe+^af&daM zOZmz+Uxa5jd|K+LX8g)dK#RX$JgXs-21(}T_s3tPT2sTleSE}bb2z3}#1oXLuL}eM zNI!#`80R5l=9XqH*heiYm|T-DwEdV&Dcx@P4# zE!{dg%MY-k*ujuf9&j=L&5@e+FgKvSgqZg%+ePU&guGgQhgr5oFM9|1ZIkD=e}_S@ zZG3ZBykihv3LCdx^usj+~hI4H=YSPl;wg$K)_$IbF;TW0O`!100*qI zy1LqcuvocKTPt9Tos@oi8H0xoZnwx)n9WyJ2R2dcKvHQ|x$3~;7Eo1UjYcyu3)5EK z8}!+5!?|@ST$HNp!oqmrCj;=TWEnIUi{t2xJBH#PW=J}@+_^&gak5w}rYArXV@DtG zi^Tb@F3!N!1**KUySsa1T+eY$GlEca%RuymBs}v!dNse1vmE4R$jV;iiT=^Bi6RU^ zqsRLDjT^?7^YUUzBoe(D1gZojO(YTx2x$JRzP^Ye)5>ofGmk1-$%-kvPI#0e0tH^Q z`pM1fJIV20KT=N4>GX6(QH4eP7t=Z&)8F6!t9)7dS_^&0#PV_;3WXX~F!-}mhu#*q zaDjCmF*C>*L(8)H?DMWJ7KTP!0 zFD;!e>wh?}R)gD=CbbERFh%#SoAm3-!cEChrZKfCU+#Nl-^Sv*3tr-d!F6o_xIG=s z3wp6(l?`2U3pEH&89Mmpk4a6&w~+WZ$F|ZIH~abGRkiQPd%Rz!6(EpL+s2B{X_%`w zq1(<}HtP~4dMAi!&mmgRX7?>46C{^`G!b#9#rQtW>i12Ib}H?z?JbA$vBYF_Qtp0R o2>*9@o3kC&q7A$Gv4!h$sCqn-=z{Zhg@EUHWb~2d!^uDVA2cf}b^rhX diff --git a/packages/stream_chat_flutter_ai/test/src/conversation/goldens/ci/ai_conversation_view_light.png b/packages/stream_chat_flutter_ai/test/src/conversation/goldens/ci/ai_conversation_view_light.png index 07806ae2586a6b97d3c25f354f6821e7a9e0c1c9..ea54cd05a8279d96b37155800dece98052569a79 100644 GIT binary patch delta 1878 zcmV-c2dVh<7x5R6L4TV`L_t(|obBC9jx1Sy$MJJAEAm-Y7bIW`NQ{sr+XK`CmSD;9 z22gK=Asaz#09o=97$63)03=}UNB6C&d}o9QbR)ZI9(^;i;#ZNMk923ZPX49J_}+6f z<5Amf*A?JZbX`}D$5S~RkL7%BOV@R!ZQG&8;xDMRmRf6>)PGtg(@B}nn$k4Q>%G1} z!}YaoTeiD>IUJ6qbI84EwbnA9&&qN!FSXX|zLr44^>kfVcDsGq?GM5{GPTyST+GX2 zF~8nx2sB(r+jiyo<)xfX=b=a8ie^ny*6Za}cGCh4S8zI=%k#@iX` z)9Iwwb^BcnD1Ya+Elmf4|AUc8hW(Z z?#kI|b2saBI+xvU-`jcu4Lv!Z+p^yuh8}}~ZFl?9_Vhm}frg%Jw%egcAVAl3WxL(= zvX($Y56GDXIBgI|Ml^n5NPOui&FTaKHd`o4Hpt;zDystkrimT zm_TzK0u2z#SsXBcAAS7sRoDO7Pk(yV^?4n3lhFw-7Ruf1fbtLD|Hq+6i1mN(Jowso z3ABTgu?Zi4D8;bL{@0T?NPh6_z50?nVBOfJu>Ktm5inlI|( zJt5H0gXwfS^azCM<2_N^ZP)cO4cxHZeqT15-OytYtbyEz{5bG)+@xvu3bc$huJF#&lg*Hk)nP?f2z)JPth$ zya`QnSLU;MS*@0@8f$^R5ub5> zcG6C>8>y$KXK{vCa6BH%C!c(Jm9+(Wgf`o4*=)B%j{!I1a6Fdx@1J^KSD;7ca5!A$ zjRO32*Vq^d^vHZF&M=s#r{`YJmU0%yh-|jo4|;<1M?d`ND)Yd9TldRf{PHXP{jTfE z^UJ0@KHT^6`gYJG(RH2E=^(&%yX|ckEzl#e>G4ege7HV0O9DL-hlBfSK#Ifh(91dk zJp!NKl@7>oI-Seu^wqx)`XYfIfy41wIv0dOl>L5x@frd>0!JTjK%C?0bnzMjJp#TQ zggkBQBMwR_XMu))IDKrK1bPJQpdrusEYN&*76*`}kKE*|zBYOm<)9KJ70AJ9@#z~+@U}^^qaVC??H%^Z15x8qS z7zffccb6ZR0zCq=rtw7~gqhFh7q21EBT#ECv)OFuF@Vp1+}%yf-JOp(_{@AR(2$~! z-E$G>@mMYv&gOsswbrs;t$JBYphu$CTJG2Dp~rxmw^}WGyEdV=Tem_RlmGm$|1PH< zp5y?0b+y*=t#5v#w=eDOphxE2YTe7F+<%Ke->A}Wm&COz1)cZ z?|xl@zFnu&x$O44ayT4H+jgaE+tPNO9X2>+B6Pmw)}YtFHea|Nirfe%=m~VF@)ERoiXX4L!tPCHm5g?i(Fu$TsM>D3Zs;KZ_;Yr%(FqL#vtS7t0T-zM1wM&?1aW#Q QPXGV_07*qoM6N<$f?1=^;{X5v delta 1892 zcmV-q2b=iu7xWj9L4Te}L_t(|obBC9jwIV%$MJJAE8kVswggN8i4j5q29^h20uo{a zmN&wXjaX~|A+ZDomItr^Bw)_t-ma>AX9NRZThP7+Zs*C0Uq^mEQqQ_h{-w(J_UX)c z)Na@J1$Y;I-!^_kHQQZs@W24Qj2W)>Wx3_S`rG@s4NX0y72F~Y8d3}2;UFYz+2h-`KJZ?5+ zI-N9Kx9w^`xqozBd3hD*dvdvS<@N2Y`NvoS4Gr~uUtV9@AE@NJTz>Hnb4vmZ4Q+S( za&g++-8!EyWxqc(TTh^&k;|nkhr@B`F&Nlxe<)p}|3L{fG_u|8h8}?ceczYeZr@}r zfrbVym#&;n=b^_S#G%o7?*a`CoX)4AM*n^-V*{1 z4NRxgp+_K0i}ytBc75Mu8n|Qo!=Y@q`=Q4m$kXGd%x2e}C}#%^4a^>9Lyth1YvWu5 z8rqmnCru7W#$mCTH(5uZp^^1!IrInwsI`<;f1~$;Ktm(b>9oveKI*u4i^ZZ$YMZlq z1R5Gzua`~Ef91>c@Gvc_<)Ybo-bv8VWHKpFkLyw!?V$K_{Ni`NY5KbtXlQIYotDSV zdgw8DN43`S{M0ti#d*+hJ6+e6m)C8%TrNY8!VS%459M)loBuTlG~C#Je<-{Cq4W;5 zR#BPMT2`xNvs3E6jX=XY==#3wcKdQXo`m`fS8FYc#k{PRi<^JfAkgrR`o1rx)43c^ zr*gS;rSJRFb=}Zo@f*}yfAwcNos{`}_O7Q=3N%0{XK?_N!3h_CJObj5^?hHC$K$Uq znz&pp<#M^WaDuy4Yb_7cX_|><@=>I-Q0d2fhfi z*+W^(7iGO(y=$xm`cC{Z%x~MR9Ws2TT5DOaR%NqUze}hEdgO!|+Iz21`wV*I-nQHF z`uaBX7%(b*-`@Veq_)>QJ{m`SpsLf`x^8N39xXDi1 zX?7#^^71Oq@D5I=Q~BzvuWz!pK#$ONw=3J-Zs;-KZX8di^6}$K^K}J!WRAz@5}4k zwmd&QHhFzJ=#l9A-syA@V7J>f+eHiXNNgLvDS*${a2kmKMjQKm&vlXn;@x4G>D8N8nb^Ljyiv zi{nzDN8mjk{|mySHAinq8YvyPaF1jmf|L`+t;kgC{uv z-(9V>{LXLxR0?#5yn1ni|9`)( zK;N(P`BL_WeK{VFrR)0AcU|fF-VU3)RckG^)-svYQtPD5=d-e0EXr&)%opwg4G_v{ zc9YQvG)Ab0p$GUjzxxNjKlBK|Nc`(R{qxWxd^S61fKZV;Xn^+1B6aoM?A(-(XH_>?EA0IhhU z3INST?w|p%g9eC`@d*|VU45yytNsAK-L+RS+Ch_13XBVCx9j_%hXCN$*+H{W3W)); e& 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ϖQ}6J{B7$4arn7K_XHB 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 Vl@!Q<|2lx^Trv0scgZ&N{{V0VKD7V< literal 12321 zcmeI2cT|(vzW#%NC>AU<6^Jva3?Rh-C;~=?78FEE zNCHZc5{d|s8U;fF2oWhUgd&7c0wKSRGjr~ld(O-~=idAK@4DW#T<(>3yYs%^?EQJ3 z=X?L0g~=xIo#GG(WRvOX6IKw2kUa#lW?|i0Fk<-2)FJSfNZ>J3n|0t1dfk5GN8`SHoy7VWNLAF3SYMU#_--%yt?gIB9UH3B z>{HV|DxS5poG;;NhyQS~@lrxtgGbJtENV~!*Ilc5O3Y*3qslS4w!B*B{n8XuwG0?z ztKs>Pz}m;Nl8}v0gF3fEZn~aC2-U5N5``#=NJC<_*h4gr{Vxn8UM1T=it45ILu7v_ zf*^jbTXS>knhlUs>;7&a!KRNLl!z69Y*Zxtd;jH?A%lxsL<(%#1!34RE)(u5+oUT3 zdAl#UwFT)c^^j8Al9M&Fi1ud2`xGb7mxTnOoiL9Rhopy(#5vK#&XqyNDQ8~f z2683oyFJD_`o$b{YoA-?)4NVUK9}TO@N#er%SzKBD$^Qp-w3PI3{(fgX?{){Jf z@5qKwAHS(!Bzm}Rt?|>Lp#o`XX4TFukhRTUzEp3HkCPHH9=@z7R_1^=H#@1Y4zkwP ztbjU}An{O+jv}d()lRIAVTO(rP)YB|CnXA*t^VongoWy!1~{8=YedQ>o004H-~2FG z8E2q@LMR#;?}~PP9$)KH5wUk{OI;m`J>Y0+DYW)wPho2NS9U>Bs`OvH&VP8n|Mft^ zyRd+hjS43rr=;vamAqXRpQF-hJYXWF@k3T-^)#()gE^`L~<@w05@!ABI2_|GR7BP)o9Ia2(TkMv*lHe5~Sy_+14T zc6qMFKhKrI75kgy1Q>5mBKR(2MEO*TI?hRXHeA7#o;Vdcv)sgqL~Xiya0ivmt)X0% zJYr#-;%!S{zeCFD5){cS0YjgsI$hW873Cu65919Pq4T1l`(W~ciKpu7u5-HwR5k3ab(K z=i8|4(=&Oikx<8Tu@S5*p{o*D6kakD{Qj>*L8=lQH=`;PlJz%ofe)71ezg$kb{9WyMHSjs=_ z3*Y2D@%kBjX@uyt#PT<~D}MxEgog{_BMD1>-rOK8sXts^UV-(ck<;R~y|zD2<`>;V z_z}!r*z4NVS(`0MHT;jI4nF)|4M9S1SwS&&-e{RtE5lwyO-l&6BkxZ*WmuTNc~m1R zMZke}_l(?sN7lLhA~<+HXrgz7K00d|!PB{{Pb*r)2$rpj)5%DAuxoi-9#Sq3j6TXB zAOuX>Kxa0U6tPK15>3o!tjv^G>IM7wIIF6f7gN^Hy?CJ{b$HDMTg;aVGn!c-@(>xS+PVIYLuYOMo-k+oh;9Ai~ruzB>uK7WFZ?ZY7=(5x58I zUpm8x8?{j1~c?UYo?hTu+Ci8Q5y-OO|Av^JJ#6K2!Wcuh1$se)VH z#8mfela7wXEU8LAEHxu`YOBT`_ke(7r4DBY*GE=-nLQve-Zx)H74PeA*horRTQ<3f z774s3DJ~jXlRSI){a#Mpz*6ggg4UbB`2l*TsY3iL48D2Z<2q7TuT0DDX_i9H$|7duO@!MN{U|TXM!mKCceVNHJGN&Pjrvn1=4)2T# zs3`2bM!2&!VUf{Gf8E~uBHP1zIZ3#cUpd$i&aHP-p!-Uz)C>I3omBffs`T@}4YJF7 zHY;wngJZ2#xL>9eUp6#y!YejM$=i%ZkG}cYWAr{6h20uR}T%%haTUQ zk)M)iIDf|@GT7bC-3H%1OkI@he~PLuBV3g}1HBPmrORu<>6E^HyIu7B@XQYWtP!uU zleN9Nin~=+m040Y_x1{?n(}huv1dmPfDeJCqhe(1LX@2?Ec#?^10gYjEx zQO;sZP8Uh1RWO~Dl{XS&bL*!_5$SxAdx5Q&gNv>V`x7G9WxizP#&J8Mvl&Wd;hxUQ z453D_WGKb@rKW~tbYNr*y4*!+^$+CB1@Gp!*DmE_zZA^R2k*?x`#LyQ<*D6s{!2+; zPfr(bXp?gV>HsT$_rCDR2v7fO$7&b+BAsN^UyZGhl`$M1xpqO3_Y6wf*1Fh4R&}sX zr!`PRO3KTjt-4!@MLWh;XS?%^;8OMcqp8A;rlY5ag{W4#MEHV?QDH5A-iY%iJ~Hls zjY<-I8XRV2r3ycYf7q#ca_Ki?oR+oA6zew^TDdD|j4o6mPgg>mK8_IG$ixT=Fq z#qB-}F`w}i%_FL@<|CPem9JcCLci=ZZz7cc*0Nt)U_bq(CzM*C1-rY6`og^(^Qz)FgduCsDK*ihaqc?w$UJnk%-!XEV zZc&BP6EYeS^H_5uruAfGt4aXEnReCK9ilk0*s<+HdF=x^`lSz78A=evMh;r)Az!?Z z@?a5i>h9E{iqyk|cz`hh`FR#4lzou8o9sNN$>RvlVs(@1WmIK_RH!CI_Hf!R$5dIU zow`i8R2@N~D>WnIeD>BnTRbxG2xQ~W;7r@SE@-qZ$c`MsmmAM{$h>#}T6fMdtc1{- zT*)ZWnz^LG!1rwAj%F-@iX*%C3T=!9HOW;nPBai;VsrXsP(6{k-TZG`Wv?r%o?-IauR127u3ZsZ`u#} ze7x|5=pUK+&&i}Yg4J4^Fe?W^{FLL{zp&A3YfRCb3XMH5QG~};iOnI2SmA9p75;}< z%8=s^!~TrP|9t*GN1@-jzS-7r-rSTT1c~0ZN^}2|`M=L^|tt1NR1WJ{Wpx+)EMVZWh3CHX-MlT(~wRMzM+&^Pd5+W&%; zS)Ajep(B8Jd%O!biQT0C?D`XhGZI#VuKfY2S76h0SYLm8>tfhuZ`o*v@P*THY!6pg z^(Ur6N3F2FzWIYXL99;Es|QoC9aL+??Wg(rAyAKzwy7{J_O{(=4|Z8ls<%U~+sV#BN&qg5`LV`uDT&laolX$mB@z`Ej{`f>l;=^1?4E}YIeG?q zwp&CJ;$!9Lo~7!_QlYycvi8dPwm1{R{Q(Fzw^FWr%j%d-+)$qF{Etu>SP@o(?Ej;L z2;_DPQl7fBjb5ol+gk*FXKcNh>aS6;%OXCq(Fukts1Y6}?>I!5jSutN3lo)n=l04| zJDZ4mU5|9egQ^yQPlI8?%d0Who=x`iuT2 zx%e+m{rhU(qG9&k!y=jm-)n5#%p7( z@K9{7=0>V-In}fLBMT_t?xXLXA2Q4BYPoVSK+c4~R>_zYOi?two97KeBZd}@NYQ`T zj?ZX=Y9H2%lcNEtn241gNWOvSnDX_GSckcI~jo2b+Vdy-2xskp21FQH%QX zIacS@lMg{_9q`ve+`-P%?C)G*VSE|$z>O)cBDoM%-sxL95{fmT(ELjaGBTXCc5XU* z*7BOazafhiTm5!TA1lZPM-6g<*;bvkS6kQwkIjgF(3sWK)bzOd%9*Af`i%l`{9Id1 ze|a(OzruU+`6s6l`WbO^bMqfP4kyY+JP-PK9`BOG_@$Vd3HJeI}mPJtFO_w!dvJ44?3EJ}t;q*3;en?(4aWIBMtQB*B34 za0D$TW`e3L#kbnIbbg$@vY&aP`ejdyOXUrh3U6D9$=dd%9qYe(tv{UAtaQa)6UBozs-a$QYvb406nv%jAYbUzB|JX5!B{)~f^`+O$P>FAY39g)89I-J# zfrbS8PIhc;?xzk`c8zm=x2Kk<@q6OnxAnalWt$}=s{RDq4<6)gqX$>vhlYkCHs0Cp zDH8i`{&IPFh?chP6Skfk=N2BrF?R?n!Ok8&bqemgRZd<$1vb<>VZL|-9xPFiovo-F zmu;iMeKX~Ac&FZt;5=13jk&A7MF|$Ur8k0IEW34(zSj=0SQ}yBM;Tnzo_oV0zeuWO zp1(bOYO(|C=I-9c6bVa8+Fn5Q6i)+!_26vV>L#W4W%^Oq*z+iSm?5-Y?#DaFjImIP zV%Wk!n{Zes6tQRMd884*l)(Y^`|D07HDt;QEl%A`8=KWVSwi&;pD{m4;e=lHdiOd` zG5#SmaBd#qSa|4qFX^WuC(>pp0kPCzP=wdk>9nkp3I91!l|E?w(=gRLdVk547}0W} zx_hCBL)V@V^(S0PG|R!Zc0*U!W@qft3McJ^lRYN)7c+hJ8sML=&1WGmRls%7uZaWD zQEuzi3A-+n`GYAN?TQrdEsgPA73|4_8q zQc--S;WwSzbq(_%XFFPnw>Pc1z~;eRNPt`LDXQmLmyDCg)>4N`k!r6HD8}r>jsmls zPO9|YdvJi$!&gFfNf8RuyLx+*=wKmXVM{8K;ws4%bXIar|Iem5R;k$3?vBZh4Ms~5 zJ2$fNcOP59d>9$6Bdaw@_OdLg4p4uh+gLg!SyPVc@0#qW0qFa9*5;HM+(`6M^OeIe zEqu@e3Fw}jgD$Ma4;A?=(^yNJ+3Q?qt+d<4_eXyE4Z4G~PakENCxdx)baIbj8`GAQ zGc4F`Z96dLBlaCShAto8e5qC-)MS%L{8i0;r~RCS9pi%PMkKd)uR`*fqdz7mvVt5A z9Xgbn{>v5on#;;LHY!gJ>*&+CK2cLS_Cd%Ao|M+|(W&ZJ+M13pC93`jU&j`nfc~s1 z{V>4Uj!Y(ls&(6?7^jl^d3L0|vGEnB;DQ(GWlcV+KZ&gu;AAqn1Xtph$Yyz!_X2Mb zO-W`h&Y<*Gl<A}AZRB-algd?~;`8bT zuH~Nk62fXk{7%&BLE-3v31sB) z(!WD$%6~%YDS*_b;CZZ~*5WZ2z%!(DamcpvisBr=>WiIsuy)VcxSP7XW$X8ijMC-m?%W}-v1%>{~`7NA@v^uo_~?p|9>F0ZQ(&y3@{@BILna} z*p+6vOM)=|{e{x;ZUb{{4Yc!Gzu zwO^3E*Z~g-($b?qPh0{_9r+tmM@Zmp&8w=aW(+i*aOCCWSgh_cb$F<$byrex`u6D- zw?in_m(Y=y7h$gjbMHx0nk~GYt9ZJ)iUzk;gIpdtJKpmO8@YaBVv_y!+i4jMtetk+ z!bEU&U0cdEVED7@`BY#>|1vv~JE>nT`bIN`TMb_#5gSo8F4Z%aSD`shub07~wY8nG z^NY~>dp{gQjM!TixG9b160Y`y^O}&|)3d|J+h8QPW6Re*DQ~>3^%fESPT!wO^nK(N z6oQfCy}gyTwofi)ZXXBYM#TeUh+d|aqOh@6DetQO?&)cNqv)M_u}^=XaE2=<@5lEH z3{dIz7YXcNIyME{c4)8$`pai$+P#!`D?qSbvK$HznrFIDXcMm+UTD}AnV6U;PRHOd z+1c6KYo|YW91i+6elC#nYLs&;BS;Sqa09SL%B;s#t6V%qE0 zAm3hABI#X2u(1>q&AX?coqNP2bKWWNCOWj?b*r^c_%OP(pKs&H$*7SrU22`WBQPuH0W7}0C2#!64(dG`g_K)i-fJ)|5B%X*+k1KArv03{QBG)%l``8k z8f$%|M|ad?p5>Xn!L0-pQrnq|*3xm_w!<^|mad|BVTD~B#(%m83B z?{>6xcp$mXp-4;L>0$P}FS9p*eUDji!QLr4=Uw0|6~pjI(^(X z`wa#Y2x8xDOX&zdKR-B7JtF}n@WLbEyx>43jM+7mr-Oc#zQ%IPvOg9Ak*Hnm1vnEf z=|6cJnfmn^k0g>Ur_ZJJbJMqjkGVENPc9{_sBxg8p4-?z0kBLjvv8hDSv$s}RLFLlm$js)no zx3}j%azFnyKFJ~%={6eEk`u6>xeH44aP@ZqsQqDJAYxS;zj4dEi<>HtimpUWZ1YGw%g3 zK|y%wFJCbgnK9dS=^mlq6L@z)8aV8#CU zMTo4jtE@`$!<8o&hU+Q`t+zTLr&>4}d$TmsCBK7rjpeW49bA0SIV6g^>NtP>27~8Z znvE6yQLM~KrJ#%TJ(ssL83}-{Nuw~eTy}JIwd3F22ElyKCwk}Gg&TIzt5ahK@SrlZ zc1C$p9dzyr*B#BX^%B2z)VPH^>&9 zXcvkXmT^uhDwz~=@CsczEEHJ8Wby@5WnEw6xS@AQ`KzyrwdEj}wnslRpd?Q%&hVo@ zvhu8Ubd8Un+0UHPEdob4cTiE6kPQ-W`+cGi(@(FG zTOB(Gj4Ox!hiIqSI#i7u`M$vx$VL?!(q_sW9E?uMb!%kh!B)b_64Rd9Ug9cyinqYV zu&pD6b^V9$ z=4v7g(R}mYT-yQoS=9$RwWaw24wK#6d&G+S`k6!NGq?9GU)fTZz6h8w&F74dDr1K<~sU@dVQNRpob{naTb0Q}U=>?>(2uRtr z+a$b6(qFj@Qm)ZT!(Gn>*Uqj3A?UB{h4-}Xre~A26*9UltlOgl&2loAq>^XFvZ!Z z=i8`iXMO&BbbiwC{Y=YhLX;JFVtL#8!YM6o<_aImo~Y_G-59BY)RPY%imb(f zmJybF#AIOQ_0WA~fiy299uNEQ>C=u3Sk;!k&<|-gW~u4v?|~RlAmUrqDnUD%Im+SQ zc?M&+Hv&}{Ij;d%A}|^T4_Vgoh=SyrMJ2D|^r_FEPg|CkAs1WBQ&X`qBkA`5Y=1D9 zp{ORekJa}olOT3TA6)Z48ca5zjv!}10_F*tV z-Nk_Dtv%g0aU&|_P?rPfQ@g!{1H62I)o1C9qjnSdGtg`9xSi}!K|>@ztQbp_5q$IG zCj%Rt6*QXz=UelajRdY4&e(fs^dS#V_Yg}O!8y*yLNJ^gd1w{f;foX_sn$>@+K2J+ z7;SBAfWG^>*$MHv$cPCwm_t9CcKvw$d@653BNB(ZOzZA0efd&y6}CK4R>q3wN*#bv z`t>|~jU&wWt3(9Tca`3!YDhRge$UWpvbW!qr+49g8nSIv)KQh0Tr%eY18pKE;p@i* z3llmfUv}833ivvVbFV^QN;nxT{;I>D%_dIjKZL1Fq>0FY529g)O-OIEdja_VZr{T%T{KCrg;`hURo zFjGrm=YtaCq}uU`^NI!`Q?Y6p!_%i3oRRBONRJ}~MFt0S+EiXqpvRKMx{t?*)DC=n zMY4>r>*1gW=sOP*hTd`3k3R)7O#h(a_az|cTeFeJuLikTS||c8DVm-RG}25YQ#|;q zq)a9D94n0#!yomX z3>eAmEp#9~=&dw&OnZPJ&yHrX@UTU#((~}nQI2usZ%ki}wm6UrU04OFo+ zKP+o1BPFGKE=+$k@D|CPve`yO>f-+Jo7|Dk8L~_G#R|E!aU>gK=ZF0}y7z8A^v(fa z20~iar_M{O?M5IOX?dLuT~0{m9r*Y@B~_Uf2){Sy9`d_oZuwzT0T8+lZuPTKwDPoU zVl~S8p}E2NEhp78KUfN~DJmI;^;M40;+FvyKq#L9Rr8y59!ZmC$ezhBlkwTmTDveR zpyK;6iOE-zUlMwW#{f zc+bq0PTn{BP%uYE4j#l!My7*?l*r|WMp;na2i4+qKXi50oyJg@Q@9EE602feJnh-4 zTCkNJ8!I(8%NUMw-}v(hg>!14|K(2PbOWz1QgBFPd4ZXCI>(APF5&#%Xi10ocBM7< z?|t?cn!}!Rhrb2^MIQO094H9j;E&1x+hE(JCv@Rp#`>0AL2qJ&AY^hZ-UTiXke|rF z(kz!h`f%ca#D1CpQk~beYAYuy?3pdE+1PnN9)HT z-u>>l%U()={HrGM_s;22udUT9{KB@)>1!kT8n#K;H}3}jJrewf2JF+rTp&0K-)P!s zhrK+0@%Fa5ryCm1RXEL#yi$bMR}xey9L4X6J_lVv*tPijU6h)%^P-4!-MVQ5c;lto zHEf+<6aRTM=0PLq{@v_dVPliq^&OiCaV;Z;BHEc0uj+#@fI3bP6TWHu3Rv*>r~O9) zD&CQUxxaLS4H+b8rJne-1|h_58yv_dpB{35QKeVIW+F744Tt~}`xQ1ratC^|kM)S(M}sJ{lue@j`8OveJ#9{5V2`9T@} z&dcAwCfQo(=of9BG9V_@;gwH=D$6CR=H%#8bl|@XekgvNxN71V3dV9)APD7+$APH( z-ogP?=RT#Tsv0Kme6gr);;r51@V0jN$$Xdj;ZSU9yC$Ja_-Ns8F#WZ_W;{KXe~ diff --git a/packages/stream_chat_flutter_ai/test/src/parts/goldens/ci/ai_tool_call_view_dark.png b/packages/stream_chat_flutter_ai/test/src/parts/goldens/ci/ai_tool_call_view_dark.png index e6eb2efec5ed2d396e2b193d028e384597a48e31..3832bb09febf8769f305e7175fcee0c8d695cd8a 100644 GIT binary patch literal 1684 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 diff --git a/packages/stream_chat_flutter_ai/test/src/parts/goldens/ci/ai_unsupported_part_view.png b/packages/stream_chat_flutter_ai/test/src/parts/goldens/ci/ai_unsupported_part_view.png index dc9b58e3e80b979d4183b4d41ad816d3fe28e1a1..a7e4308595fd0d3d680901d5c8e5e42334b42f72 100644 GIT binary patch literal 443 zcmeAS@N?(olHy`uVBq!ia0y~yV9Wrr131`#q{;603xO0%v6E*A2N2Y7q;vrJjKx9j zP7LeL$-D%zQ$1ZALn`LHz2nGr$U%hFQTd*qK)+T~`f0tw^-XLCGdH|qslWO;%vksH zu6GP4ZuZ+TG+bwqQt(>9xCkG`x0t`8#AdGhe1`gmWqW(<8qIYw8djX;{&lO}suvgnnMk3w{4uLK$P6gy5%WOSLVz?dtxm!axAUuU7dRX76>c)I$z JtaD0e0sv*xM>PNd From 8667c6673335380e785e39461a8248d2b43b1626 Mon Sep 17 00:00:00 2001 From: VelikovPetar Date: Tue, 27 Oct 2026 06:53:44 +0100 Subject: [PATCH 3/4] docs(ai): describe the tool-call and step changes in the changelog by what they do Co-Authored-By: Claude Opus 5.5 --- packages/stream_chat_flutter_ai/CHANGELOG.md | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/packages/stream_chat_flutter_ai/CHANGELOG.md b/packages/stream_chat_flutter_ai/CHANGELOG.md index 61b96768..5a2ff3fc 100644 --- a/packages/stream_chat_flutter_ai/CHANGELOG.md +++ b/packages/stream_chat_flutter_ai/CHANGELOG.md @@ -169,8 +169,8 @@ - A pie's screen-reader summary lists its slice names. - `ChartSemantics.isEmpty` is `true` for a pie with no positive slice. - The README documents the new components β€” agent steps, reasoning, tool calls, approvals, the client tool runner, `AILocalModel` and chart legends β€” and its `SpeechToTextButton` snippet now uses `SpeechToTextConfig`. -- Agent steps are laid out as on iOS and Android: `AIMessagePartsView` fills its width and spaces - steps 10 apart (was 8); `AIToolCallView`'s icon defaults to 16 (`ToolCallThemeData.iconSize`, was +- New layout defaults for agent steps: `AIMessagePartsView` fills its width and spaces steps 10 + apart (was 8); `AIToolCallView`'s icon defaults to 16 (`ToolCallThemeData.iconSize`, was 14) with a gap of 8 after it; `AIUnsupportedPartView` reads like a tool call's row, in its title's font and its detail color, with the same icon size; and `AIToolApprovalCard` spaces its lines 4 apart and its buttons and failure line 12, with the sending spinner after the buttons instead of @@ -179,9 +179,8 @@ 🐞 Fixed - `AITypingIndicatorView`'s dots were black by default, which made them invisible in dark mode. -- `AIToolCallView`: a declined call's icon follows its status, as on iOS and Android β€” the - shimmering hand while it still waits for approval, then the cross β€” and keeps its duration once - finished. A waiting call's icon is drawn in `ToolCallThemeData.accentColor` with a band of the +- `AIToolCallView`: a declined call's icon follows its status β€” the shimmering hand while it + still waits for approval, then the cross β€” and keeps its duration once finished. A waiting call's icon is drawn in `ToolCallThemeData.accentColor` with a band of the title's color sweeping across it; the two were reversed. - `StreamingMessageView.onTypewriterStateChanged` is no longer called during a build, so the host can call `setState` from it. A state that changes mid-frame is reported after the frame. From fdbcc487aae2a76a145a4cc6001eede4e8aee9e4 Mon Sep 17 00:00:00 2001 From: VelikovPetar Date: Thu, 29 Oct 2026 11:47:00 +0100 Subject: [PATCH 4/4] fix(ai): keep a tool call's question 8 under its row `AIMessagePartsView.spacing` also set the gap between a tool call's row and its approval card, so raising it to 10 moved that gap too. The row and its question now sit a fixed 8 apart, and `spacing` only spaces steps. The running spinner is sized to 12 with a 1.5 stroke at the default icon size, and still scales with it. The width test uses a loose constraint, so it checks that the view fills its width. The CHANGELOG describes these views as added rather than listing changes to them. Co-Authored-By: Claude Opus 5.5 --- packages/stream_chat_flutter_ai/CHANGELOG.md | 22 ++++++------------- .../lib/src/parts/ai_message_parts_view.dart | 9 +++++--- .../lib/src/parts/ai_tool_call_view.dart | 7 +++--- .../src/parts/ai_message_parts_view_test.dart | 16 +++++++++++++- .../src/parts/ai_tool_call_view_test.dart | 8 +++++++ 5 files changed, 39 insertions(+), 23 deletions(-) diff --git a/packages/stream_chat_flutter_ai/CHANGELOG.md b/packages/stream_chat_flutter_ai/CHANGELOG.md index 5a2ff3fc..e5bf777f 100644 --- a/packages/stream_chat_flutter_ai/CHANGELOG.md +++ b/packages/stream_chat_flutter_ai/CHANGELOG.md @@ -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 @@ -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`. @@ -169,19 +170,10 @@ - A pie's screen-reader summary lists its slice names. - `ChartSemantics.isEmpty` is `true` for a pie with no positive slice. - The README documents the new components β€” agent steps, reasoning, tool calls, approvals, the client tool runner, `AILocalModel` and chart legends β€” and its `SpeechToTextButton` snippet now uses `SpeechToTextConfig`. -- New layout defaults for agent steps: `AIMessagePartsView` fills its width and spaces steps 10 - apart (was 8); `AIToolCallView`'s icon defaults to 16 (`ToolCallThemeData.iconSize`, was - 14) with a gap of 8 after it; `AIUnsupportedPartView` reads like a tool call's row, in its title's - font and its detail color, with the same icon size; and `AIToolApprovalCard` spaces its lines 4 - apart and its buttons and failure line 12, with the sending spinner after the buttons instead of - inside the pressed one. 🐞 Fixed - `AITypingIndicatorView`'s dots were black by default, which made them invisible in dark mode. -- `AIToolCallView`: a declined call's icon follows its status β€” the shimmering hand while it - still waits for approval, then the cross β€” and keeps its duration once finished. A waiting call's icon is drawn in `ToolCallThemeData.accentColor` with a band of the - title's color sweeping across it; the two were reversed. - `StreamingMessageView.onTypewriterStateChanged` is no longer called during a build, so the host can call `setState` from it. A state that changes mid-frame is reported after the frame. - A `NaN` or infinite value no longer breaks a chart: the parsers drop it, and a pie leaves such diff --git a/packages/stream_chat_flutter_ai/lib/src/parts/ai_message_parts_view.dart b/packages/stream_chat_flutter_ai/lib/src/parts/ai_message_parts_view.dart index 1b018ebc..a75868a8 100644 --- a/packages/stream_chat_flutter_ai/lib/src/parts/ai_message_parts_view.dart +++ b/packages/stream_chat_flutter_ai/lib/src/parts/ai_message_parts_view.dart @@ -111,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 @@ -205,7 +204,7 @@ 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), ), ], @@ -213,6 +212,10 @@ class AIMessagePartsView extends StatelessWidget { } } +/// 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(); diff --git a/packages/stream_chat_flutter_ai/lib/src/parts/ai_tool_call_view.dart b/packages/stream_chat_flutter_ai/lib/src/parts/ai_tool_call_view.dart index 4203ad57..a9eb5067 100644 --- a/packages/stream_chat_flutter_ai/lib/src/parts/ai_tool_call_view.dart +++ b/packages/stream_chat_flutter_ai/lib/src/parts/ai_tool_call_view.dart @@ -1,5 +1,3 @@ -import 'dart:math' as math; - import 'package:flutter/material.dart'; import 'package:stream_chat_flutter_ai/src/localization/ai_translations.dart'; import 'package:stream_chat_flutter_ai/src/parts/ai_tool_call_part.dart'; @@ -288,8 +286,9 @@ class _StatusIcon extends StatelessWidget { _ToolCallState.running when reduceMotion => icon(Icons.more_horiz_rounded, theme.accentColor), _ToolCallState.running => SizedBox.square( // Inset a little: a ring fills its box, where a glyph leaves a margin. - dimension: size * 0.85, - child: CircularProgressIndicator(strokeWidth: math.max(1.5, size / 8), color: theme.accentColor), + // 12 with a 1.5 stroke at the default size. + dimension: size * 0.75, + child: CircularProgressIndicator(strokeWidth: size * 3 / 32, color: theme.accentColor), ), _ToolCallState.awaitingApproval => waiting(Icons.back_hand_outlined), _ToolCallState.awaitingClient => waiting(Icons.smartphone_outlined), diff --git a/packages/stream_chat_flutter_ai/test/src/parts/ai_message_parts_view_test.dart b/packages/stream_chat_flutter_ai/test/src/parts/ai_message_parts_view_test.dart index 2b2ef607..3df6e6d8 100644 --- a/packages/stream_chat_flutter_ai/test/src/parts/ai_message_parts_view_test.dart +++ b/packages/stream_chat_flutter_ai/test/src/parts/ai_message_parts_view_test.dart @@ -383,7 +383,7 @@ void main() { parts: _parts.take(1).toList(), partBuilder: (context, part) => const SizedBox(width: 40, height: 10), ); - await tester.pumpWidget(_wrap(SizedBox(width: 300, child: parts()))); + await tester.pumpWidget(_wrap(ConstrainedBox(constraints: const BoxConstraints(maxWidth: 300), child: parts()))); expect(tester.getSize(find.byType(AIMessagePartsView)), const Size(300, 10)); await tester.pumpWidget(_wrap(Row(mainAxisSize: MainAxisSize.min, children: [parts()]))); @@ -446,6 +446,20 @@ void main() { expect(answers, [('call_1', AIToolApprovalDecision.allowed)]); }); + testWidgets('leaves 8 between a call and its question, whatever the spacing', (tester) async { + await tester.pumpWidget( + _wrap( + SizedBox( + width: 360, + child: AIMessagePartsView(parts: parts(), approver: approver, spacing: 20), + ), + ), + ); + + final row = find.ancestor(of: find.text('send email'), matching: find.byType(AIToolCallView)); + expect(tester.getTopLeft(find.byType(AIToolApprovalCard)).dy - tester.getBottomLeft(row).dy, 8); + }); + testWidgets('asks nothing without an approver', (tester) async { await tester.pumpWidget(_wrap(SizedBox(width: 360, child: AIMessagePartsView(parts: parts())))); expect(find.byType(AIToolApprovalView), findsNothing); diff --git a/packages/stream_chat_flutter_ai/test/src/parts/ai_tool_call_view_test.dart b/packages/stream_chat_flutter_ai/test/src/parts/ai_tool_call_view_test.dart index c857bee3..27b1ad00 100644 --- a/packages/stream_chat_flutter_ai/test/src/parts/ai_tool_call_view_test.dart +++ b/packages/stream_chat_flutter_ai/test/src/parts/ai_tool_call_view_test.dart @@ -293,6 +293,14 @@ void main() { expect(success.g, greaterThan(success.r), reason: 'a green'); }); + testWidgets('spins a 12 dp ring with a 1.5 stroke at the default size', (tester) async { + await tester.pumpWidget(_wrap(AIToolCallView(call: _call(status: AIToolCallStatus.running)))); + + final spinner = find.byType(CircularProgressIndicator); + expect(tester.getSize(spinner), const Size.square(12)); + expect(tester.widget(spinner).strokeWidth, 1.5); + }); + testWidgets('an unknown status spins, and debug builds report it once per change', (tester) async { final reports = []; debugPartIssueReporter = reports.add;