diff --git a/AGENTS.md b/AGENTS.md
index 95812e7..ddf11e6 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 | built-in synchronous highlighter; languages host-extensible | ✅ |
| 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 376da86..5b8f481 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -11,6 +11,14 @@
behind new `code` / `codeInline` typography roles — `withFontFamily()`
no longer touches the mono roles; swap those with
`withCodeFontFamily()`.
+- **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 a04ddff..ee1eadb 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 | built-in synchronous highlighter; languages host-extensible | ✅ |
| 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 f0852a4..c83bfda 100644
--- a/README.md
+++ b/README.md
@@ -40,6 +40,7 @@
| [`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 |
| [`FlowCodeBlock`](https://flowui.stac.dev/components/code-block) | Fenced code with built-in synchronous highlighting, a header label, and a copy affordance — languages host-extensible |
+| [`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 355b009..5fae903 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 | Shipped |
| Thinking indicator | Shipped |
| Shimmer | Shipped |
diff --git a/lib/flow_ui.dart b/lib/flow_ui.dart
index 846b47d..5066a11 100644
--- a/lib/flow_ui.dart
+++ b/lib/flow_ui.dart
@@ -18,6 +18,7 @@ export 'src/widgets/flow_attachment_preview.dart';
export 'src/widgets/flow_chat_screen.dart';
export 'src/widgets/flow_code_block.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 1b3cf49..b3969fd 100644
--- a/lib/src/models/flow_message_part.dart
+++ b/lib/src/models/flow_message_part.dart
@@ -42,6 +42,19 @@ class FlowCodePart extends FlowMessagePart {
final String? filename;
}
+/// 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..f6426a9
--- /dev/null
+++ b/lib/src/widgets/flow_error_state.dart
@@ -0,0 +1,257 @@
+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; without a [message] the title itself
+ /// announces as the live region.
+ 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 (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);
+ }
+ }
+
+ 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,
+ ),
+ ),
+ ],
+ ],
+ ),
+ ),
+ ),
+ ),
+ );
+
+ // 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,
+ );
+ }
+}
diff --git a/lib/src/widgets/flow_message.dart b/lib/src/widgets/flow_message.dart
index a50f3f7..3ac9a86 100644
--- a/lib/src/widgets/flow_message.dart
+++ b/lib/src/widgets/flow_message.dart
@@ -5,6 +5,7 @@ import '../models/flow_message_part.dart';
import '../theme/flow_theme.dart';
import 'flow_attachment_group.dart';
import 'flow_code_block.dart';
+import 'flow_error_state.dart';
import 'flow_thinking_indicator.dart';
import 'flow_streaming_text.dart';
@@ -26,9 +27,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, {
@@ -39,6 +42,9 @@ class FlowMessage extends StatelessWidget {
this.onCodeCopy,
this.copiedCodePart,
this.codeCopyTooltip,
+ this.onRetry,
+ this.errorTitle,
+ this.retryLabel,
this.leading,
this.footer,
this.maxBubbleWidthFraction = 0.75,
@@ -81,6 +87,19 @@ class FlowMessage extends StatelessWidget {
/// Host-localized label for each code block's copy affordance.
final String? codeCopyTooltip;
+ /// 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;
@@ -101,12 +120,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
@@ -128,10 +146,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;
@@ -214,20 +228,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);
@@ -274,9 +293,11 @@ class FlowMessage extends StatelessWidget {
style: style,
textAlign: TextAlign.center,
),
- // System messages are centered notices; attachments and code
- // belong to user and assistant turns.
- FlowAttachmentPart() || FlowCodePart() => const SizedBox.shrink(),
+ // System messages are centered notices; attachments, code
+ // and failures belong to user and assistant turns.
+ FlowAttachmentPart() ||
+ FlowCodePart() ||
+ FlowErrorPart() => const SizedBox.shrink(),
FlowCustomPart() =>
customPartBuilder?.call(context, message, part) ??
const SizedBox.shrink(),
@@ -341,6 +362,15 @@ class FlowMessage extends StatelessWidget {
message.status == FlowMessageStatus.streaming &&
i == message.parts.length - 1,
),
+ // `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 c386bea..a64ace2 100644
--- a/lib/src/widgets/flow_thread.dart
+++ b/lib/src/widgets/flow_thread.dart
@@ -20,6 +20,9 @@ class FlowThread extends StatelessWidget {
this.onCodeCopy,
this.copiedCodePart,
this.codeCopyTooltip,
+ this.onRetry,
+ this.errorTitle,
+ this.retryLabel,
this.controller,
this.padding,
this.itemSpacing,
@@ -57,6 +60,18 @@ class FlowThread extends StatelessWidget {
/// Host-localized label for the code blocks' copy affordance.
final String? codeCopyTooltip;
+ /// 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;
@@ -87,6 +102,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,
@@ -112,6 +128,9 @@ class FlowThread extends StatelessWidget {
onCodeCopy: onCodeCopy,
copiedCodePart: copiedCodePart,
codeCopyTooltip: codeCopyTooltip,
+ 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 6213699..756dc57 100644
--- a/playground/lib/src/demo_registry.dart
+++ b/playground/lib/src/demo_registry.dart
@@ -4,6 +4,7 @@ import 'demos/add_to_chat_demo.dart';
import 'demos/attachments_demo.dart';
import 'demos/code_block_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';
@@ -32,6 +33,7 @@ Widget demoFor(PlaygroundItem item, {String? variant}) {
variant: variant,
),
PlaygroundItem.codeBlock => CodeBlockDemo(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),
@@ -77,6 +79,11 @@ List<(String, String)> variantsFor(PlaygroundItem item) {
('plain', 'Plain'),
('streaming', 'Streaming'),
],
+ PlaygroundItem.errorState => const [
+ ('card', 'Card'),
+ ('minimal', 'Minimal'),
+ ('thread', 'Failed turn'),
+ ],
PlaygroundItem.attachments => const [
('composer', 'In composer'),
('tiles', 'Tiles only'),
@@ -124,6 +131,7 @@ String snippetFor(PlaygroundItem item) {
PlaygroundItem.message => messageSnippet,
PlaygroundItem.streamingMessage => streamingMessageSnippet,
PlaygroundItem.codeBlock => codeBlockSnippet,
+ 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 ca25016..efd0aed 100644
--- a/playground/lib/src/playground_item.dart
+++ b/playground/lib/src/playground_item.dart
@@ -29,6 +29,11 @@ enum PlaygroundItem {
'flow_streaming_message.dart',
),
codeBlock('Code Block', PhosphorIconsRegular.code, 'flow_code_block.dart'),
+ errorState(
+ 'Error State',
+ PhosphorIconsRegular.warningCircle,
+ 'flow_error_state.dart',
+ ),
addToChat(
'Add to Chat',
PhosphorIconsRegular.plus,