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,