From 4399eb8ded854ef3a0b534ea1273d80e30b871b8 Mon Sep 17 00:00:00 2001 From: Divyanshu Bhargava Date: Tue, 18 Aug 2026 14:13:41 +0530 Subject: [PATCH 1/3] feat: error state with retry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A failed assistant turn no longer recolors its parts into the errorContainer bubble: delivered content keeps its ink and the turn closes with a FlowErrorState card — a failure must not swallow what the user has already read. The card is the old FlowErrorBubble translated to the token system, and the first consumer of the error color role. --- AGENTS.md | 2 +- CHANGELOG.md | 9 + CLAUDE.md | 2 +- README.md | 1 + .../content/docs/components/error-state.mdx | 120 +++++++++ docs/src/content/docs/roadmap.md | 2 +- lib/flow_ui.dart | 1 + lib/src/models/flow_message_data.dart | 3 +- lib/src/models/flow_message_part.dart | 13 + lib/src/widgets/flow_error_state.dart | 251 ++++++++++++++++++ lib/src/widgets/flow_message.dart | 83 ++++-- lib/src/widgets/flow_thread.dart | 19 ++ playground/lib/src/demo_registry.dart | 8 + .../lib/src/demos/error_state_demo.dart | 137 ++++++++++ playground/lib/src/playground_item.dart | 5 + 15 files changed, 625 insertions(+), 31 deletions(-) create mode 100644 docs/src/content/docs/components/error-state.mdx create mode 100644 lib/src/widgets/flow_error_state.dart create mode 100644 playground/lib/src/demos/error_state_demo.dart diff --git a/AGENTS.md b/AGENTS.md index 46a7a48..0ec4abc 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -84,7 +84,7 @@ Status legend: ⬜ Todo · ✅ Done | 17 | Tool | TBD | ⬜ | | 18 | Suggestion & Suggestion Group | plain & outlined rows; scroll, wrap, column | ✅ | | 19 | Confirmation | default, approved, rejected | ⬜ | -| 20 | Error state | | ⬜ | +| 20 | Error state | failure card + retry pill; failed assistant turns render it automatically | ✅ | | 21 | Code block | | ⬜ | | 22 | Thinking indicator | turning, breathing asterisk + shimmer label; active & settled | ✅ | | 23 | Shimmer | text only; sweeping highlight, static when settled | ✅ | diff --git a/CHANGELOG.md b/CHANGELOG.md index 5451eb9..b1f8596 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,15 @@ ## 0.2.0 (unreleased) +- **Error state** — `FlowErrorState` (error glyph, host-written message, + retry pill) and a `FlowErrorPart` message part, with + `onRetry`/`errorTitle`/`retryLabel` threaded through `FlowMessage` and + `FlowThread`. +- **Breaking**: a failed assistant turn no longer recolors its content + into an `errorContainer` bubble — parts keep their normal ink and the + turn closes with an error card (a default one when no `FlowErrorPart` + is present). The user bubble's error treatment is unchanged. + - **Breaking**: migrated from `package:flutter/material.dart` to `package:material_ui` (Material's home since Flutter 3.47) — no API changes, but the two Materials are distinct types, so the host app must diff --git a/CLAUDE.md b/CLAUDE.md index 1253858..56507a9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -86,7 +86,7 @@ Values come from the Flow UI Figma file. Role names follow Material 3's `ColorSc | 17 | Tool | TBD | ⬜ | | 18 | Suggestion & Suggestion Group | plain & outlined rows; scroll, wrap, column | ✅ | | 19 | Confirmation | default, approved, rejected | ⬜ | -| 20 | Error state | | ⬜ | +| 20 | Error state | failure card + retry pill; failed assistant turns render it automatically | ✅ | | 21 | Code block | | ⬜ | | 22 | Thinking indicator | turning, breathing asterisk + shimmer label; active & settled | ✅ | | 23 | Shimmer | text only; sweeping highlight, static when settled | ✅ | diff --git a/README.md b/README.md index f0aff52..cce8f55 100644 --- a/README.md +++ b/README.md @@ -39,6 +39,7 @@ | [`FlowStreamingText`](https://flowui.stac.dev/components/streaming-text) | Animated text reveal while a reply arrives | | [`FlowThinkingIndicator`](https://flowui.stac.dev/components/thinking-indicator) | Turning, breathing asterisk with a shimmering label | | [`FlowShimmerText`](https://flowui.stac.dev/components/shimmer-text) | Sweeping text highlight, static once settled | +| [`FlowErrorState`](https://flowui.stac.dev/components/error-state) | Failure card with a host-written message and retry pill — failed turns render it automatically | | [`FlowMessageActions`](https://flowui.stac.dev/components/message-actions) | Copy / regenerate / edit / feedback row under a message | | [`FlowComposer`](https://flowui.stac.dev/components/composer) | Multiline input with send/stop, attachments strip, and leading/trailing action slots | | [`FlowMenu`](https://flowui.stac.dev/components/menu) | Icon-triggered menu with groups, submenus, and toggles — anchored card on desktop, bottom sheet on phones | diff --git a/docs/src/content/docs/components/error-state.mdx b/docs/src/content/docs/components/error-state.mdx new file mode 100644 index 0000000..78c27d9 --- /dev/null +++ b/docs/src/content/docs/components/error-state.mdx @@ -0,0 +1,120 @@ +--- +title: Error state +description: The failure card — an error glyph, a host-written message, and a retry pill that reports intent. +sidebar: + order: 14 +--- + +import FlowDemo from '../../../components/FlowDemo.astro'; + +`FlowErrorState` is the failure surface: an error glyph and a host-written +explanation on a hairline card, with an optional retry pill. It renders +state and reports one intent — what retry *means* (re-run the turn, +refetch, reconnect, resend) is the host's business. The package ships no +strings, so `title`, `message` and `retryLabel` are all host-localized; +the message announces to assistive tech as a live region, and the pill is +a visible control rather than a hover-revealed action, because hover does +not exist on touch. + +## Card + + + +```dart title="The full anatomy" +FlowErrorState( + title: 'Connection error', + message: 'The API is overloaded right now. Retry in a moment.', + retryLabel: 'Retry', + onRetry: resend, +) +``` + +## Minimal + +Every part is optional: without a `title` the message takes the glyph +row, and a null `onRetry` hides the pill: + + + +```dart title="Just the failure" +FlowErrorState( + message: 'The API is overloaded right now. Retry in a moment.', +) +``` + +## A failed turn + +In a thread the card renders on its own. A `FlowErrorPart` in any turn +becomes this card — and because parts render in order, everything the +turn already delivered keeps its normal ink, with the failure closing the +turn below it. Retry hands the failed message back through +`FlowThread.onRetry`: + + + +```dart title="The host contract" +FlowThread( + messages: messages, + errorTitle: 'Connection error', + retryLabel: 'Retry', + // Typically: drop or reset the failed message, re-run the turn. + onRetry: (message) => rerun(message), +) +``` + +A turn whose status is `FlowMessageStatus.error` but carries no +`FlowErrorPart` still closes with a default card — zero-wiring hosts keep +a visible failure state. + +## Not retryable + +`retryable: false` on the part suppresses the pill even when the thread +wires `onRetry` — for failures retrying can't fix: + +```dart title="A terminal failure" +FlowMessageData( + id: 'a2', + role: FlowMessageRole.assistant, + status: FlowMessageStatus.error, + parts: [ + FlowErrorPart( + message: 'This conversation exceeds the context window.', + retryable: false, + ), + ], +) +``` + +## Elsewhere + +Standalone, the same card serves the other failure surfaces — a thread +that failed to load, a failed send below the composer, or a connection +notice pinned above the input via `FlowChatScreen.aboveComposer`, the +slot that already exists for exactly this: + +```dart title="A connection notice above the composer" +FlowChatScreen( + thread: FlowThread(messages: messages), + aboveComposer: offline + ? FlowErrorState( + message: 'Connection lost.', + retryLabel: 'Reconnect', + onRetry: reconnect, + ) + : null, + composer: FlowComposer(onSend: send), +) +``` + +## Key API + +- `FlowErrorState` — `title`, `message`, `onRetry` (null hides the + pill), `retryLabel` (doubles as the pill's accessible name; null + renders the glyph alone), plus `padding` and `borderRadius` over the + design's 16/14 inset and 12px corner. +- `FlowErrorPart` — `message`, `retryable` (default true); rendered by + `FlowMessage` in part order, skipped in system messages. +- `FlowThread` / `FlowMessage` — `onRetry` (thread-level is handed the + failed `FlowMessageData`), `errorTitle`, `retryLabel`. +- A failed assistant turn keeps its parts in normal ink; the + `errorContainer` recolor now applies only to the user bubble. diff --git a/docs/src/content/docs/roadmap.md b/docs/src/content/docs/roadmap.md index 024ad9e..0046ad2 100644 --- a/docs/src/content/docs/roadmap.md +++ b/docs/src/content/docs/roadmap.md @@ -39,7 +39,7 @@ elements and the remaining AI states are on the way. | Tool | Planned | | Suggestions | Shipped | | Confirmation | Planned | -| Error state | Planned | +| Error state | Shipped | | Code block | Planned | | Thinking indicator | Shipped | | Shimmer | Shipped | diff --git a/lib/flow_ui.dart b/lib/flow_ui.dart index 2d1abab..6fa1974 100644 --- a/lib/flow_ui.dart +++ b/lib/flow_ui.dart @@ -16,6 +16,7 @@ export 'src/widgets/flow_attachment_group.dart'; export 'src/widgets/flow_attachment_preview.dart'; export 'src/widgets/flow_chat_screen.dart'; export 'src/widgets/flow_composer.dart'; +export 'src/widgets/flow_error_state.dart'; export 'src/widgets/flow_greeting.dart'; export 'src/widgets/flow_menu.dart'; export 'src/widgets/flow_menu_style.dart'; diff --git a/lib/src/models/flow_message_data.dart b/lib/src/models/flow_message_data.dart index 1a3ec12..975c6a5 100644 --- a/lib/src/models/flow_message_data.dart +++ b/lib/src/models/flow_message_data.dart @@ -16,7 +16,8 @@ enum FlowMessageStatus { /// Settled; renders statically. complete, - /// Failed; content renders in an error bubble. + /// Failed. An assistant turn keeps its parts in normal ink and closes + /// with an error card; a user bubble recolors to the error container. error, } diff --git a/lib/src/models/flow_message_part.dart b/lib/src/models/flow_message_part.dart index 8bfdcd6..0b7063a 100644 --- a/lib/src/models/flow_message_part.dart +++ b/lib/src/models/flow_message_part.dart @@ -26,6 +26,19 @@ class FlowAttachmentPart extends FlowMessagePart { final List attachments; } +/// A failure surfaced in the turn, rendered by a `FlowErrorState`. +class FlowErrorPart extends FlowMessagePart { + const FlowErrorPart({this.message, this.retryable = true}); + + /// Host-written and sentence-case. Null renders the card without one — + /// the package ships no strings. + final String? message; + + /// False suppresses the retry affordance even when the host wires + /// retry — for failures retrying can't fix. + final bool retryable; +} + /// Host-defined content, rendered through a `FlowCustomPartBuilder`. class FlowCustomPart extends FlowMessagePart { const FlowCustomPart({required this.type, this.data}); diff --git a/lib/src/widgets/flow_error_state.dart b/lib/src/widgets/flow_error_state.dart new file mode 100644 index 0000000..5ef38d2 --- /dev/null +++ b/lib/src/widgets/flow_error_state.dart @@ -0,0 +1,251 @@ +import 'package:material_ui/material_ui.dart'; + +import '../theme/flow_theme.dart'; + +/// A failure surface: an error glyph and a host-written explanation on a +/// hairline card, with an optional retry pill. +/// +/// ```dart +/// FlowErrorState( +/// title: 'Connection error', +/// message: 'The API is overloaded right now. Retry in a moment.', +/// retryLabel: 'Retry', +/// onRetry: resend, +/// ) +/// ``` +/// +/// In a thread this renders on its own: a `FlowErrorPart` in any turn +/// becomes this card, and a failed assistant turn closes with a default +/// one even when the host supplies no part. Standalone it serves the +/// other failure surfaces — a thread that failed to load, a connection +/// notice pinned in `FlowChatScreen.aboveComposer`, a failed send below +/// the composer. +/// +/// Retry reports intent; what it means — re-run the turn, refetch, +/// reconnect — is the host's business. The affordance is a visible pill +/// rather than a hover-revealed action, because hover does not exist on +/// touch. The package ships no strings: [title], [message] and +/// [retryLabel] are host-localized, and [retryLabel] doubles as the +/// pill's accessible name. +class FlowErrorState extends StatelessWidget { + const FlowErrorState({ + super.key, + this.title, + this.message, + this.onRetry, + this.retryLabel, + this.padding, + this.borderRadius, + }); + + /// Host-localized headline, e.g. 'Connection error'. Null lets + /// [message] take the glyph row. + final String? title; + + /// The failure, host-written and sentence-case. Announced to assistive + /// tech as a live region, since failures arrive unprompted. + final String? message; + + /// Retry intent. Null hides the pill. + final VoidCallback? onRetry; + + /// Host-localized pill label and accessible name; null renders the + /// glyph alone. + final String? retryLabel; + + /// Inside the card. Defaults to the design's 16/14. + final EdgeInsetsGeometry? padding; + + /// The card's corner. Defaults to the design's 12. + final BorderRadius? borderRadius; + + /// The card: the message bubble's 12px corner over the outlined + /// suggestion's 2% ink wash, edged in the error ink at 40% — a + /// translucent hairline composites correctly on the page and on a + /// raised card, like the rest of the outline ramp. + static const BorderRadius _radius = BorderRadius.all(Radius.circular(12)); + static const EdgeInsetsGeometry _cardPadding = EdgeInsets.fromLTRB( + 16, + 14, + 16, + 14, + ); + static const double _groundOpacity = 0.02; + static const double _borderOpacity = 0.4; + + /// The glyph, and the indent that hangs the message and the pill under + /// the text rather than under the glyph. + static const double _iconSize = 20; + static const double _iconGap = 10; + + /// Gaps: glyph row to message, content to the retry pill. + static const double _messageGap = 4; + static const double _retryGap = 12; + + @override + Widget build(BuildContext context) { + final colors = context.flowColors; + final typography = context.flowTypography; + + final title = this.title; + final message = this.message; + final onRetry = this.onRetry; + + // The title takes the glyph row when present and the message hangs + // below; without one the message moves up beside the glyph. + final rowText = title ?? message; + final below = title == null ? null : message; + + Widget? rowLabel; + if (rowText != null) { + rowLabel = Text( + rowText, + style: title != null + ? typography.labelLarge.copyWith( + fontWeight: FontWeight.w600, + color: colors.onSurface, + ) + : typography.bodyMedium.copyWith(color: colors.onSurfaceVariant), + ); + if (title == null) { + // The row text IS the message here — announce it. + rowLabel = Semantics(liveRegion: true, child: rowLabel); + } + } + + return Container( + padding: padding ?? _cardPadding, + decoration: BoxDecoration( + color: colors.onSurface.withValues(alpha: _groundOpacity), + borderRadius: borderRadius ?? _radius, + border: Border.all( + color: colors.error.withValues(alpha: _borderOpacity), + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Row( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon(Icons.error_outline, size: _iconSize, color: colors.error), + if (rowLabel != null) ...[ + const SizedBox(width: _iconGap), + Flexible(child: rowLabel), + ], + ], + ), + if (below != null) + Padding( + padding: const EdgeInsets.only( + left: _iconSize + _iconGap, + top: _messageGap, + ), + child: Semantics( + liveRegion: true, + child: Text( + below, + style: typography.bodyMedium.copyWith( + color: colors.onSurfaceVariant, + ), + ), + ), + ), + if (onRetry != null) + Padding( + padding: const EdgeInsets.only( + left: _iconSize + _iconGap, + top: _retryGap, + ), + child: _RetryButton(onTap: onRetry, label: retryLabel), + ), + ], + ), + ); + } +} + +/// The retry pill: a hairline border with no fill, the refresh glyph and +/// a semibold label — the failure surfaces' shared affordance, private +/// until the design system's Button lands and absorbs it. +class _RetryButton extends StatefulWidget { + const _RetryButton({required this.onTap, this.label}); + + final VoidCallback onTap; + final String? label; + + @override + State<_RetryButton> createState() => _RetryButtonState(); +} + +class _RetryButtonState extends State<_RetryButton> { + /// The design's pill: 32 tall on an 8px corner, padded 12, a 14px + /// glyph a 6px gap from the label. + static const double _height = 32; + static const BorderRadius _radius = BorderRadius.all(Radius.circular(8)); + static const EdgeInsetsGeometry _padding = EdgeInsets.symmetric( + horizontal: 12, + ); + static const double _glyphSize = 14; + static const double _glyphGap = 6; + + bool _hovered = false; + + @override + Widget build(BuildContext context) { + final colors = context.flowColors; + final typography = context.flowTypography; + + // Rest at the secondary ink, lifting to full on hover — the + // suggestion row's ladder on a control-sized frame. + final foreground = _hovered ? colors.onSurface : colors.onSurfaceVariant; + final shape = RoundedRectangleBorder( + borderRadius: _radius, + side: BorderSide(color: colors.outline), + ); + + final label = widget.label; + final button = Material( + color: Colors.transparent, + shape: shape, + clipBehavior: Clip.antiAlias, + child: InkWell( + onTap: widget.onTap, + onHover: (value) => setState(() => _hovered = value), + customBorder: shape, + hoverColor: colors.surfaceContainerLow, + child: SizedBox( + height: _height, + child: Padding( + padding: _padding, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.refresh, size: _glyphSize, color: foreground), + if (label != null) ...[ + const SizedBox(width: _glyphGap), + Text( + label, + style: typography.labelLarge.copyWith( + fontWeight: FontWeight.w600, + color: foreground, + ), + ), + ], + ], + ), + ), + ), + ), + ); + + return Semantics( + button: true, + label: label, + excludeSemantics: label != null, + child: button, + ); + } +} diff --git a/lib/src/widgets/flow_message.dart b/lib/src/widgets/flow_message.dart index 6e44899..e7355b6 100644 --- a/lib/src/widgets/flow_message.dart +++ b/lib/src/widgets/flow_message.dart @@ -4,6 +4,7 @@ import '../models/flow_message_data.dart'; import '../models/flow_message_part.dart'; import '../theme/flow_theme.dart'; import 'flow_attachment_group.dart'; +import 'flow_error_state.dart'; import 'flow_thinking_indicator.dart'; import 'flow_streaming_text.dart'; @@ -25,9 +26,11 @@ typedef FlowCustomPartBuilder = /// - **system** — centered muted text (notices, dividers). /// /// [FlowMessageStatus.pending] assistant messages show a -/// [FlowThinkingIndicator]; [FlowMessageStatus.error] content renders in an -/// `errorContainer` bubble; [FlowMessageStatus.streaming] animates the last -/// text part via [FlowStreamingText]. +/// [FlowThinkingIndicator]; [FlowMessageStatus.streaming] animates the last +/// text part via [FlowStreamingText]. A [FlowMessageStatus.error] assistant +/// turn keeps its parts in normal ink and closes with a [FlowErrorState] +/// card — the message's own [FlowErrorPart], or a default one when the host +/// supplies none; an error user bubble recolors to the error container. class FlowMessage extends StatelessWidget { const FlowMessage( this.message, { @@ -35,6 +38,9 @@ class FlowMessage extends StatelessWidget { this.customPartBuilder, this.onAttachmentTap, this.previewCloseTooltip, + this.onRetry, + this.errorTitle, + this.retryLabel, this.leading, this.footer, this.maxBubbleWidthFraction = 0.75, @@ -64,6 +70,19 @@ class FlowMessage extends StatelessWidget { /// Host-localized label for the built-in preview's close button. final String? previewCloseTooltip; + /// Retry intent from the turn's error card — the default card a failed + /// assistant turn renders, or any [FlowErrorPart]'s (unless the part + /// says `retryable: false`). Null hides every retry affordance. + final VoidCallback? onRetry; + + /// Host-localized headline for the error cards, e.g. 'Connection + /// error'. Null lets each card's message take the glyph row. + final String? errorTitle; + + /// Host-localized label for the error cards' retry pill; null renders + /// the pill glyph-only. + final String? retryLabel; + /// Slot beside the content, e.g. an avatar. final Widget? leading; @@ -84,12 +103,11 @@ class FlowMessage extends StatelessWidget { /// strings. final String? thinkingLabel; - /// Corner radius of the user bubble and the error bubbles. Defaults to - /// the design's 12. + /// Corner radius of the user bubble, its error state included. + /// Defaults to the design's 12. final BorderRadius? bubbleRadius; - /// Inside the user bubble. Defaults to the design's 16/10; the error - /// bubbles keep their own spec padding. + /// Inside the user bubble. Defaults to the design's 16/10. final EdgeInsetsGeometry? bubblePadding; /// The user bubble's ground, as an alpha over the ink — the same wash the @@ -111,10 +129,6 @@ class FlowMessage extends StatelessWidget { ); static const double _bubbleHorizontalPadding = 16; - /// The error bubble sits a step deeper than the user bubble's 10 — the - /// design's asymmetry, not a leftover. - static const double _errorBubbleVerticalPadding = 12; - /// Gaps: between a message's parts, under a user bubble, under assistant /// content before its actions, and beside a leading slot. static const double _partGap = 8; @@ -197,20 +211,25 @@ class FlowMessage extends StatelessWidget { Widget content; if (message.status == FlowMessageStatus.pending && message.parts.isEmpty) { content = FlowThinkingIndicator(label: thinkingLabel); - } else if (_isError) { - content = Align( - alignment: AlignmentDirectional.centerStart, - child: Container( - padding: const EdgeInsets.symmetric( - horizontal: _bubbleHorizontalPadding, - vertical: _errorBubbleVerticalPadding, - ), - decoration: BoxDecoration( - color: colors.errorContainer, - borderRadius: bubbleRadius ?? _bubbleRadius, + } else if (_isError && + !message.parts.any((part) => part is FlowErrorPart)) { + // A failure must not swallow what the user has already read: parts + // keep their normal ink, and a default card closes the turn when + // the host supplied no FlowErrorPart of its own. + content = Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + if (message.parts.isNotEmpty) ...[ + _buildParts(context, colors.onSurface), + const SizedBox(height: _partGap), + ], + FlowErrorState( + title: errorTitle, + retryLabel: retryLabel, + onRetry: onRetry, ), - child: _buildParts(context, colors.onErrorContainer), - ), + ], ); } else { content = _buildParts(context, colors.onSurface); @@ -257,9 +276,10 @@ class FlowMessage extends StatelessWidget { style: style, textAlign: TextAlign.center, ), - // System messages are centered notices; attachments belong to - // user and assistant turns. - FlowAttachmentPart() => const SizedBox.shrink(), + // System messages are centered notices; attachments and + // failures belong to user and assistant turns. + FlowAttachmentPart() || + FlowErrorPart() => const SizedBox.shrink(), FlowCustomPart() => customPartBuilder?.call(context, message, part) ?? const SizedBox.shrink(), @@ -306,6 +326,15 @@ class FlowMessage extends StatelessWidget { onTap: onAttachmentTap, previewCloseTooltip: previewCloseTooltip, ), + // `message` names the FlowMessageData here, so the part's text + // binds under its own name. + FlowErrorPart(message: final errorMessage, :final retryable) => + FlowErrorState( + title: errorTitle, + message: errorMessage, + retryLabel: retryLabel, + onRetry: retryable ? onRetry : null, + ), FlowCustomPart() => customPartBuilder?.call(context, message, part), }; if (child == null) continue; diff --git a/lib/src/widgets/flow_thread.dart b/lib/src/widgets/flow_thread.dart index 473f549..42f3290 100644 --- a/lib/src/widgets/flow_thread.dart +++ b/lib/src/widgets/flow_thread.dart @@ -16,6 +16,9 @@ class FlowThread extends StatelessWidget { this.customPartBuilder, this.onAttachmentTap, this.previewCloseTooltip, + this.onRetry, + this.errorTitle, + this.retryLabel, this.controller, this.padding, this.itemSpacing, @@ -42,6 +45,18 @@ class FlowThread extends StatelessWidget { /// Host-localized label for the built-in preview's close button. final String? previewCloseTooltip; + /// Retry intent from a failed turn's error card, handed the message so + /// the host can re-run it. Forwarded to each [FlowMessage]. + final void Function(FlowMessageData message)? onRetry; + + /// Host-localized headline for the thread's error cards, e.g. + /// 'Connection error'. + final String? errorTitle; + + /// Host-localized label for the error cards' retry pill; null renders + /// the pill glyph-only. + final String? retryLabel; + /// Optional external scroll controller. final ScrollController? controller; @@ -72,6 +87,7 @@ class FlowThread extends StatelessWidget { Widget build(BuildContext context) { final gap = itemSpacing ?? _defaultGap; final onAttachmentTap = this.onAttachmentTap; + final onRetry = this.onRetry; return ListView.builder( controller: controller, @@ -94,6 +110,9 @@ class FlowThread extends StatelessWidget { ? null : (attachmentId) => onAttachmentTap(message, attachmentId), previewCloseTooltip: previewCloseTooltip, + onRetry: onRetry == null ? null : () => onRetry(message), + errorTitle: errorTitle, + retryLabel: retryLabel, charactersPerSecond: charactersPerSecond, thinkingLabel: thinkingLabel, ), diff --git a/playground/lib/src/demo_registry.dart b/playground/lib/src/demo_registry.dart index e4b13c8..e6b853a 100644 --- a/playground/lib/src/demo_registry.dart +++ b/playground/lib/src/demo_registry.dart @@ -3,6 +3,7 @@ import 'package:material_ui/material_ui.dart'; import 'demos/add_to_chat_demo.dart'; import 'demos/attachments_demo.dart'; import 'demos/composer_demo.dart'; +import 'demos/error_state_demo.dart'; import 'demos/full_chat_demo.dart'; import 'demos/greeting_demo.dart'; import 'demos/message_actions_demo.dart'; @@ -30,6 +31,7 @@ Widget demoFor(PlaygroundItem item, {String? variant}) { key: key, variant: variant, ), + PlaygroundItem.errorState => ErrorStateDemo(key: key, variant: variant), PlaygroundItem.addToChat => AddToChatDemo(key: key), PlaygroundItem.attachments => AttachmentsDemo(key: key, variant: variant), PlaygroundItem.thread => ThreadDemo(key: key, variant: variant), @@ -65,6 +67,11 @@ List<(String, String)> variantsFor(PlaygroundItem item) { ('animated', 'Animated'), ('static', 'Static'), ], + PlaygroundItem.errorState => const [ + ('card', 'Card'), + ('minimal', 'Minimal'), + ('thread', 'Failed turn'), + ], PlaygroundItem.attachments => const [ ('composer', 'In composer'), ('tiles', 'Tiles only'), @@ -111,6 +118,7 @@ String snippetFor(PlaygroundItem item) { PlaygroundItem.modalSelector => modelSelectorSnippet, PlaygroundItem.message => messageSnippet, PlaygroundItem.streamingMessage => streamingMessageSnippet, + PlaygroundItem.errorState => errorStateSnippet, PlaygroundItem.addToChat => addToChatSnippet, PlaygroundItem.attachments => attachmentsSnippet, PlaygroundItem.thread => threadSnippet, diff --git a/playground/lib/src/demos/error_state_demo.dart b/playground/lib/src/demos/error_state_demo.dart new file mode 100644 index 0000000..b0ee5c8 --- /dev/null +++ b/playground/lib/src/demos/error_state_demo.dart @@ -0,0 +1,137 @@ +import 'dart:async'; + +import 'package:flow_ui/flow_ui.dart'; +import 'package:material_ui/material_ui.dart'; + +const String errorStateSnippet = ''' +// The card renders state and reports one intent; what retry means — +// re-run the turn, refetch, reconnect — is the host's business. +FlowErrorState( + title: 'Connection error', + message: 'The API is overloaded right now. Retry in a moment.', + retryLabel: 'Retry', + onRetry: resend, +) + +// In a thread the card renders on its own: parts a failed turn already +// delivered keep their ink, and its FlowErrorPart closes the turn. +FlowThread( + messages: messages, + errorTitle: 'Connection error', + retryLabel: 'Retry', + onRetry: (message) => rerun(message), +) + +// retryable: false suppresses the pill — for failures retrying +// can't fix. +FlowMessageData( + id: 'a2', + role: FlowMessageRole.assistant, + status: FlowMessageStatus.error, + parts: [ + FlowErrorPart(message: 'This conversation exceeds the context window.'), + ], +)'''; + +const String _partialReply = + 'FlowThread lays the conversation out as a reversed list, so the newest ' + 'message sits at the bottom and history loads'; + +const String _fullReply = + '$_partialReply upward. Messages keep their identity by id, which is ' + 'what makes streaming updates cheap.'; + +const String _failureMessage = + 'The API is overloaded right now. Retry in a moment.'; + +/// Stage demo for `FlowErrorState` — the full card, the message-only +/// minimal form, and a failed turn in a thread whose retry actually +/// re-runs the reply, the way a host would. +class ErrorStateDemo extends StatefulWidget { + const ErrorStateDemo({super.key, this.variant}); + + final String? variant; + + @override + State createState() => _ErrorStateDemoState(); +} + +class _ErrorStateDemoState extends State { + static const Duration _feedTick = Duration(milliseconds: 30); + static const int _feedStep = 3; + + /// Thread variant: the failed reply's lifecycle. Retry resumes the + /// stream from the partial text and completes it. + FlowMessageStatus _replyStatus = FlowMessageStatus.error; + int _fed = _partialReply.length; + Timer? _feed; + + @override + void dispose() { + _feed?.cancel(); + super.dispose(); + } + + void _retry() { + if (_replyStatus == FlowMessageStatus.streaming) return; + setState(() => _replyStatus = FlowMessageStatus.streaming); + _feed = Timer.periodic(_feedTick, (timer) { + setState(() { + _fed = (_fed + _feedStep).clamp(0, _fullReply.length); + if (_fed == _fullReply.length) { + _replyStatus = FlowMessageStatus.complete; + timer.cancel(); + } + }); + }); + } + + List get _messages => [ + FlowMessageData.text( + id: 'u1', + role: FlowMessageRole.user, + text: 'What does FlowThread actually do?', + ), + FlowMessageData( + id: 'a1', + role: FlowMessageRole.assistant, + status: _replyStatus, + parts: [ + FlowTextPart(_fullReply.substring(0, _fed)), + // The failure closes the turn; once retry re-runs it, the part + // goes with it. + if (_replyStatus == FlowMessageStatus.error) + const FlowErrorPart(message: _failureMessage), + ], + ), + ]; + + @override + Widget build(BuildContext context) { + final child = switch (widget.variant) { + 'minimal' => const FlowErrorState(message: _failureMessage), + 'thread' => SizedBox( + height: 420, + child: FlowThread( + messages: _messages, + errorTitle: 'Connection error', + retryLabel: 'Retry', + onRetry: (_) => _retry(), + ), + ), + _ => FlowErrorState( + title: 'Connection error', + message: _failureMessage, + retryLabel: 'Retry', + onRetry: () {}, + ), + }; + + return Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 560), + child: child, + ), + ); + } +} diff --git a/playground/lib/src/playground_item.dart b/playground/lib/src/playground_item.dart index b63dd47..6b6bc41 100644 --- a/playground/lib/src/playground_item.dart +++ b/playground/lib/src/playground_item.dart @@ -28,6 +28,11 @@ enum PlaygroundItem { PhosphorIconsRegular.chatCircleDots, 'flow_streaming_message.dart', ), + errorState( + 'Error State', + PhosphorIconsRegular.warningCircle, + 'flow_error_state.dart', + ), addToChat( 'Add to Chat', PhosphorIconsRegular.plus, From bc72b546bfb635a60a38075b713cbe3ef937a891 Mon Sep 17 00:00:00 2001 From: Divyanshu Bhargava Date: Thu, 20 Aug 2026 20:20:23 +0530 Subject: [PATCH 2/3] fix: announce the title-only error card as a live region --- lib/src/widgets/flow_error_state.dart | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/lib/src/widgets/flow_error_state.dart b/lib/src/widgets/flow_error_state.dart index 5ef38d2..7abf30c 100644 --- a/lib/src/widgets/flow_error_state.dart +++ b/lib/src/widgets/flow_error_state.dart @@ -39,7 +39,8 @@ class FlowErrorState extends StatelessWidget { }); /// Host-localized headline, e.g. 'Connection error'. Null lets - /// [message] take the glyph row. + /// [message] take the glyph row; without a [message] the title itself + /// announces as the live region. final String? title; /// The failure, host-written and sentence-case. Announced to assistive @@ -107,8 +108,9 @@ class FlowErrorState extends StatelessWidget { ) : typography.bodyMedium.copyWith(color: colors.onSurfaceVariant), ); - if (title == null) { - // The row text IS the message here — announce it. + if (below == null) { + // The row text is all the card says — a lone title as much as a + // lone message — and failures arrive unprompted: announce it. rowLabel = Semantics(liveRegion: true, child: rowLabel); } } From 69fcda54cdf912c6afe29a96f0f81ac0ad75bcaa Mon Sep 17 00:00:00 2001 From: Divyanshu Bhargava Date: Thu, 20 Aug 2026 20:52:05 +0530 Subject: [PATCH 3/3] fix: keep the retry pill activatable under its semantics label --- lib/src/widgets/flow_error_state.dart | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/lib/src/widgets/flow_error_state.dart b/lib/src/widgets/flow_error_state.dart index 7abf30c..f6426a9 100644 --- a/lib/src/widgets/flow_error_state.dart +++ b/lib/src/widgets/flow_error_state.dart @@ -243,10 +243,14 @@ class _RetryButtonState extends State<_RetryButton> { ), ); + // Excluding the subtree keeps the label from reading twice, but it + // drops the InkWell's tap action with it — the node re-owns + // activation or assistive tech can announce the pill yet not tap it. return Semantics( button: true, label: label, excludeSemantics: label != null, + onTap: label == null ? null : widget.onTap, child: button, ); }