diff --git a/AGENTS.md b/AGENTS.md
index 7eeedc0..87b1059 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -83,7 +83,7 @@ Status legend: ⬜ Todo · ✅ Done
| 16 | Preview | full-screen image viewer: zoom, paging | ✅ |
| 17 | Tool | TBD | ⬜ |
| 18 | Suggestion & Suggestion Group | plain & outlined rows; scroll, wrap, column | ✅ |
-| 19 | Confirmation | default, approved, rejected | ⬜ |
+| 19 | Confirmation | pending, approved, rejected; approve/reject buttons; parts render in a thread | ✅ |
| 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 | ✅ |
diff --git a/CHANGELOG.md b/CHANGELOG.md
index bac9cc8..536c855 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,12 @@
## 0.3.0 (unreleased)
+- **Confirmation** — `FlowConfirmation`, the approval card: an
+ asterisk-marked request with approve and reject buttons that settle
+ into the outcome, every label host-localized. `FlowConfirmationPart`
+ renders it in a thread, reporting through
+ `FlowThread.onConfirmationRespond`; `FlowConfirmationStyle` joins the
+ component styles with a `FlowTheme.confirmationStyle` default.
- **Thread list** — `FlowThreadList`, the side panel's conversation
history: host-labeled sections of title-only rows, single selection by
id, an unread dot and pinned glyph, and a leading icon slot. Metrics
diff --git a/CLAUDE.md b/CLAUDE.md
index 626db94..cbf7b93 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -85,7 +85,7 @@ Values come from the Flow UI Figma file. Role names follow Material 3's `ColorSc
| 16 | Preview | full-screen image viewer: zoom, paging | ✅ |
| 17 | Tool | TBD | ⬜ |
| 18 | Suggestion & Suggestion Group | plain & outlined rows; scroll, wrap, column | ✅ |
-| 19 | Confirmation | default, approved, rejected | ⬜ |
+| 19 | Confirmation | pending, approved, rejected; approve/reject buttons; parts render in a thread | ✅ |
| 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 | ✅ |
diff --git a/README.md b/README.md
index d331a8e..f04b3c9 100644
--- a/README.md
+++ b/README.md
@@ -43,6 +43,7 @@
| [`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 |
| [`FlowMarkdown`](https://flowui.stac.dev/components/markdown) | Assistant prose typeset from a built-in parser — headings, emphasis, lists, quotes, tables, links, and fences composing the code block; assistant turns render it by default and it streams gracefully |
| [`FlowErrorState`](https://flowui.stac.dev/components/error-state) | Failure card with a host-written message and retry pill — failed turns render it automatically |
+| [`FlowConfirmation`](https://flowui.stac.dev/components/confirmation) | Approval card — an asterisk-marked request with approve and reject buttons that settles into the outcome; confirmation parts render it in a thread |
| [`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, the platform's file dialog (`showFlowAttachmentPicker` from your own menu, or a built-in attach button), image paste and card-scoped drop (web), 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/public/_redirects b/docs/public/_redirects
index 096d8d0..a083110 100644
--- a/docs/public/_redirects
+++ b/docs/public/_redirects
@@ -26,6 +26,7 @@
/playground/code-block /playground/ 200
/playground/markdown /playground/ 200
/playground/error-state /playground/ 200
+/playground/confirmation /playground/ 200
/playground/add-to-chat /playground/ 200
/playground/pill /playground/ 200
/playground/attachments /playground/ 200
diff --git a/docs/src/content/docs/components/confirmation.mdx b/docs/src/content/docs/components/confirmation.mdx
new file mode 100644
index 0000000..d1b5682
--- /dev/null
+++ b/docs/src/content/docs/components/confirmation.mdx
@@ -0,0 +1,146 @@
+---
+title: Confirmation
+description: The approval card — an asterisk-marked request with approve and reject buttons that settle into the outcome.
+sidebar:
+ order: 18
+---
+
+import FlowDemo from '../../../components/FlowDemo.astro';
+
+`FlowConfirmation` is the approval card: an asterisk-marked request on a
+raised card, with approve and reject buttons that settle into the
+outcome. It renders state and reports intent — recording the decision,
+and re-rendering the card settled, is the host's business. The package
+ships no strings, so the title, the request and every button label are
+host-localized; the request announces to assistive tech as a live
+region, since it arrives unprompted.
+
+The card is *runtime chrome*, not content: the asterisk header and the
+raised, hairline-and-shadow surface are its identity, the mark that
+distinguishes the host's own gate — a destructive tool call, a guarded
+action — from anything composed into the conversation. That is why the
+glyphs are fixed and only their colors restyle.
+
+## Pending
+
+The default state, straight from the design: the warning accent, the
+request, and the two buttons. A button renders only when both its label
+and its callback are set — a pending card with neither pair is a
+read-only notice, for chrome staged before the request is answerable.
+
+
+
+```dart title="The full anatomy"
+FlowConfirmation(
+ title: 'Approval required',
+ message: 'Delete 3 files in drafts/. This cannot be undone.',
+ approveLabel: 'Approve',
+ rejectLabel: 'Reject',
+ onApprove: () => respond(true),
+ onReject: () => respond(false),
+)
+```
+
+## Approved
+
+The widget holds no state: a tap reports intent, and the card settles
+only when the host passes the new status back. The buttons collapse into
+one row of the same footprint — so the card's height holds — and the
+accent flips to success:
+
+
+
+```dart title="The settled card"
+FlowConfirmation(
+ title: 'Approval required',
+ message: 'Delete 3 files in drafts/. This cannot be undone.',
+ status: FlowConfirmationStatus.approved,
+ approvedLabel: 'Approved',
+)
+```
+
+## Rejected
+
+The rejected outcome carries the error accent:
+
+
+
+```dart title="The declined request"
+FlowConfirmation(
+ title: 'Approval required',
+ message: 'Delete 3 files in drafts/. This cannot be undone.',
+ status: FlowConfirmationStatus.rejected,
+ rejectedLabel: 'Rejected',
+)
+```
+
+## In a thread
+
+A `FlowConfirmationPart` in any turn becomes this card. The buttons hand
+the message, the part and the decision back through
+`FlowThread.onConfirmationRespond`, so the host can find the request
+they belong to and re-render the part settled; the labels are
+thread-level, since they are the same words on every card. Keep the
+message's own status `complete` while the confirmation is pending — the
+wait belongs to the part:
+
+
+
+```dart title="The host contract"
+FlowThread(
+ messages: messages,
+ approveLabel: 'Approve',
+ rejectLabel: 'Reject',
+ approvedLabel: 'Approved',
+ rejectedLabel: 'Rejected',
+ // Typically: record the decision, run or skip the action, and
+ // re-render the part with the settled status.
+ onConfirmationRespond: (message, part, approved) =>
+ record(message, part, approved),
+)
+
+FlowMessageData(
+ id: 'a1',
+ role: FlowMessageRole.assistant,
+ parts: [
+ FlowTextPart('I can clear those drafts for you.'),
+ FlowConfirmationPart(
+ title: 'Approval required',
+ message: 'Delete 3 files in drafts/. This cannot be undone.',
+ ),
+ ],
+)
+```
+
+## Restyling
+
+`FlowConfirmationStyle` carries the card's overrides — install one on
+`FlowTheme.confirmationStyle` for every card, or pass `style:` to one
+widget; a widget's own style wins field by field, and nulls fall through
+to the tokens. The three accents color a state's marks — the asterisk,
+the settled glyph and its wash — so one override recolors it coherently.
+The title and the settled label stay in the ink ramp: the light accents
+fall short of WCAG AA for text on the card, so the words keep their
+contrast and the accent signals beside them:
+
+```dart title="A different pending accent"
+FlowConfirmation(
+ title: 'Approval required',
+ message: 'Delete 3 files in drafts/?',
+ style: const FlowConfirmationStyle(pendingColor: Color(0xFFB65C33)),
+)
+```
+
+Beyond the style class, `padding:` and `borderRadius:` override the
+card's own metrics, the per-component convention.
+
+## Key API
+
+| Member | What it does |
+|---|---|
+| `title` / `message` | Host-localized header and request; the message is a live region |
+| `status` | `FlowConfirmationStatus.pending` shows the buttons; `approved` / `rejected` settle the card |
+| `approveLabel` / `onApprove` | The filled button — renders only when both are set |
+| `rejectLabel` / `onReject` | The outlined button — same rule |
+| `approvedLabel` / `rejectedLabel` | The settled row's text; null leaves the outcome glyph alone |
+| `style` | `FlowConfirmationStyle` overrides, merged over `FlowTheme.confirmationStyle` |
diff --git a/docs/src/content/docs/components/message-thread.mdx b/docs/src/content/docs/components/message-thread.mdx
index f8235f9..21452f5 100644
--- a/docs/src/content/docs/components/message-thread.mdx
+++ b/docs/src/content/docs/components/message-thread.mdx
@@ -97,7 +97,8 @@ FlowMessage(
Message content is typed parts, not strings: a sealed `FlowMessagePart`
with `FlowTextPart`, `FlowAttachmentPart`, `FlowImagePart`, and
-`FlowCustomPart` subtypes (plus `FlowCodePart` and `FlowErrorPart`).
+`FlowCustomPart` subtypes (plus `FlowCodePart`, `FlowErrorPart` and
+`FlowConfirmationPart`).
`FlowAttachmentPart` renders sent files — lifted above a user bubble as
image cards, tiles elsewhere —
while `FlowImagePart` is the large-format picture, an AI-generated image
diff --git a/docs/src/content/docs/roadmap.md b/docs/src/content/docs/roadmap.md
index f465505..58894b6 100644
--- a/docs/src/content/docs/roadmap.md
+++ b/docs/src/content/docs/roadmap.md
@@ -38,7 +38,7 @@ elements and the remaining AI states are on the way.
| Attachment preview | Shipped |
| Tool | Planned |
| Suggestions | Shipped |
-| Confirmation | Planned |
+| Confirmation | Shipped |
| Error state | Shipped |
| Code block | Shipped |
| Markdown | Shipped |
diff --git a/docs/src/content/docs/theming.mdx b/docs/src/content/docs/theming.mdx
index e710fdb..439530f 100644
--- a/docs/src/content/docs/theming.mdx
+++ b/docs/src/content/docs/theming.mdx
@@ -136,7 +136,8 @@ Between the tokens and a single widget sit the component styles —
Material's component-theme tier. Each major widget has a `FlowXStyle`
data bag of optional overrides (`FlowMenuStyle`, `FlowMarkdownStyle`,
`FlowComposerStyle`, `FlowMessageStyle`, `FlowCodeBlockStyle`,
-`FlowErrorStateStyle`, `FlowMessageActionsStyle`, `FlowPillStyle`,
+`FlowConfirmationStyle`, `FlowErrorStateStyle`,
+`FlowMessageActionsStyle`, `FlowPillStyle`,
`FlowSuggestionStyle`), and the theme can carry an app-wide default for
each:
diff --git a/lib/flow_ui.dart b/lib/flow_ui.dart
index 36cbb92..4104faa 100644
--- a/lib/flow_ui.dart
+++ b/lib/flow_ui.dart
@@ -30,6 +30,8 @@ export 'src/widgets/flow_code_block.dart';
export 'src/styles/flow_code_block_style.dart';
export 'src/widgets/flow_composer.dart';
export 'src/styles/flow_composer_style.dart';
+export 'src/widgets/flow_confirmation.dart';
+export 'src/styles/flow_confirmation_style.dart';
export 'src/widgets/flow_drop_target.dart';
export 'src/widgets/flow_error_state.dart';
export 'src/styles/flow_error_state_style.dart';
diff --git a/lib/src/models/flow_message_part.dart b/lib/src/models/flow_message_part.dart
index 486a160..1d6288f 100644
--- a/lib/src/models/flow_message_part.dart
+++ b/lib/src/models/flow_message_part.dart
@@ -110,6 +110,42 @@ class FlowErrorPart extends FlowMessagePart {
final bool retryable;
}
+/// Where a confirmation request stands.
+///
+/// The host owns the transition: a tap on the card reports intent, and the
+/// card renders settled only when the host passes the new status back.
+enum FlowConfirmationStatus { pending, approved, rejected }
+
+/// A request for the user's go-ahead, rendered by a `FlowConfirmation`.
+///
+/// Runtime chrome, not model content: the host — a tool gate, a
+/// destructive-action guard — constructs it from facts it resolved itself,
+/// and flips [status] when the user answers. The buttons' labels are
+/// thread-level (`FlowThread.approveLabel` and friends), since they are
+/// the same words on every card.
+class FlowConfirmationPart extends FlowMessagePart {
+ const FlowConfirmationPart({
+ this.title,
+ this.message,
+ this.status = FlowConfirmationStatus.pending,
+ });
+
+ /// Host-localized header label, e.g. 'Approval required'. Null renders
+ /// the asterisk alone.
+ final String? title;
+
+ /// What is being asked, host-written and sentence-case. Announced as a
+ /// live region, since requests arrive unprompted.
+ final String? message;
+
+ /// Pending shows the buttons; approved and rejected settle the card.
+ ///
+ /// Keep the message's own status `complete` while the confirmation is
+ /// pending — the wait belongs to this part, and a `pending` message
+ /// renders the thinking indicator instead of its parts.
+ final FlowConfirmationStatus status;
+}
+
/// Host-defined content, rendered through a `FlowCustomPartBuilder`.
class FlowCustomPart extends FlowMessagePart {
const FlowCustomPart({required this.type, this.data});
diff --git a/lib/src/styles/flow_confirmation_style.dart b/lib/src/styles/flow_confirmation_style.dart
new file mode 100644
index 0000000..2810315
--- /dev/null
+++ b/lib/src/styles/flow_confirmation_style.dart
@@ -0,0 +1,173 @@
+import 'package:material_ui/material_ui.dart';
+
+/// Host overrides for [FlowConfirmation]'s look, on top of the theme tokens.
+///
+/// Every field is optional; null falls back to the token-derived default
+/// noted on the field. Install one on [FlowTheme.confirmationStyle] to
+/// restyle every confirmation card — confirmation parts in a thread
+/// included; a widget's own `style` wins field by field:
+///
+/// ```dart
+/// FlowConfirmation(
+/// title: 'Approval required',
+/// message: 'Delete 3 files in drafts/?',
+/// style: const FlowConfirmationStyle(pendingColor: Color(0xFFB65C33)),
+/// )
+/// ```
+///
+/// The three accents color a state's marks — the header asterisk, the
+/// settled glyph and its wash — so one override recolors a state
+/// coherently. The title and the settled label read in the ink ramp, not
+/// the accent: the light accents fall short of WCAG AA for text on the
+/// card, so the words stay legible and the accent signals beside them.
+@immutable
+class FlowConfirmationStyle {
+ const FlowConfirmationStyle({
+ this.backgroundColor,
+ this.borderColor,
+ this.pendingColor,
+ this.approvedColor,
+ this.rejectedColor,
+ this.titleStyle,
+ this.messageStyle,
+ this.approveButtonColor,
+ this.approveButtonForegroundColor,
+ this.rejectButtonColor,
+ this.rejectButtonBorderColor,
+ this.rejectButtonForegroundColor,
+ });
+
+ /// The card's fill. Defaults to `surfaceBright`.
+ final Color? backgroundColor;
+
+ /// The card's hairline. Defaults to `outline`.
+ final Color? borderColor;
+
+ /// The pending accent. Defaults to `warning`.
+ final Color? pendingColor;
+
+ /// The approved accent. Defaults to `success`.
+ final Color? approvedColor;
+
+ /// The rejected accent. Defaults to `error`.
+ final Color? rejectedColor;
+
+ /// Merged over the title's default `labelSmallEmphasised` +
+ /// `onSurfaceVariant` style.
+ final TextStyle? titleStyle;
+
+ /// Merged over the message's default `bodyMedium` + `onSurface` style.
+ final TextStyle? messageStyle;
+
+ /// The approve button's fill. Defaults to `inverseSurface`.
+ final Color? approveButtonColor;
+
+ /// The approve button's ink. Defaults to `onInverseSurface`.
+ final Color? approveButtonForegroundColor;
+
+ /// The reject button's fill. Defaults to `surfaceContainerLowest`.
+ final Color? rejectButtonColor;
+
+ /// The reject button's hairline. Defaults to `outlineVariant`.
+ final Color? rejectButtonBorderColor;
+
+ /// The reject button's resting ink. Defaults to `onSurfaceVariant`,
+ /// lifting to `onSurface` on hover.
+ final Color? rejectButtonForegroundColor;
+
+ /// A copy where [other]'s fields win over this style's.
+ FlowConfirmationStyle merge(FlowConfirmationStyle? other) {
+ if (other == null) return this;
+ return FlowConfirmationStyle(
+ backgroundColor: other.backgroundColor ?? backgroundColor,
+ borderColor: other.borderColor ?? borderColor,
+ pendingColor: other.pendingColor ?? pendingColor,
+ approvedColor: other.approvedColor ?? approvedColor,
+ rejectedColor: other.rejectedColor ?? rejectedColor,
+ titleStyle: other.titleStyle ?? titleStyle,
+ messageStyle: other.messageStyle ?? messageStyle,
+ approveButtonColor: other.approveButtonColor ?? approveButtonColor,
+ approveButtonForegroundColor:
+ other.approveButtonForegroundColor ?? approveButtonForegroundColor,
+ rejectButtonColor: other.rejectButtonColor ?? rejectButtonColor,
+ rejectButtonBorderColor:
+ other.rejectButtonBorderColor ?? rejectButtonBorderColor,
+ rejectButtonForegroundColor:
+ other.rejectButtonForegroundColor ?? rejectButtonForegroundColor,
+ );
+ }
+
+ /// Linear interpolation, for theme transitions. A null [other] returns
+ /// this style unchanged.
+ FlowConfirmationStyle lerp(FlowConfirmationStyle? other, double t) {
+ if (other == null) return this;
+ return FlowConfirmationStyle(
+ backgroundColor: Color.lerp(backgroundColor, other.backgroundColor, t),
+ borderColor: Color.lerp(borderColor, other.borderColor, t),
+ pendingColor: Color.lerp(pendingColor, other.pendingColor, t),
+ approvedColor: Color.lerp(approvedColor, other.approvedColor, t),
+ rejectedColor: Color.lerp(rejectedColor, other.rejectedColor, t),
+ titleStyle: TextStyle.lerp(titleStyle, other.titleStyle, t),
+ messageStyle: TextStyle.lerp(messageStyle, other.messageStyle, t),
+ approveButtonColor: Color.lerp(
+ approveButtonColor,
+ other.approveButtonColor,
+ t,
+ ),
+ approveButtonForegroundColor: Color.lerp(
+ approveButtonForegroundColor,
+ other.approveButtonForegroundColor,
+ t,
+ ),
+ rejectButtonColor: Color.lerp(
+ rejectButtonColor,
+ other.rejectButtonColor,
+ t,
+ ),
+ rejectButtonBorderColor: Color.lerp(
+ rejectButtonBorderColor,
+ other.rejectButtonBorderColor,
+ t,
+ ),
+ rejectButtonForegroundColor: Color.lerp(
+ rejectButtonForegroundColor,
+ other.rejectButtonForegroundColor,
+ t,
+ ),
+ );
+ }
+
+ @override
+ bool operator ==(Object other) {
+ if (identical(this, other)) return true;
+ return other is FlowConfirmationStyle &&
+ other.backgroundColor == backgroundColor &&
+ other.borderColor == borderColor &&
+ other.pendingColor == pendingColor &&
+ other.approvedColor == approvedColor &&
+ other.rejectedColor == rejectedColor &&
+ other.titleStyle == titleStyle &&
+ other.messageStyle == messageStyle &&
+ other.approveButtonColor == approveButtonColor &&
+ other.approveButtonForegroundColor == approveButtonForegroundColor &&
+ other.rejectButtonColor == rejectButtonColor &&
+ other.rejectButtonBorderColor == rejectButtonBorderColor &&
+ other.rejectButtonForegroundColor == rejectButtonForegroundColor;
+ }
+
+ @override
+ int get hashCode => Object.hash(
+ backgroundColor,
+ borderColor,
+ pendingColor,
+ approvedColor,
+ rejectedColor,
+ titleStyle,
+ messageStyle,
+ approveButtonColor,
+ approveButtonForegroundColor,
+ rejectButtonColor,
+ rejectButtonBorderColor,
+ rejectButtonForegroundColor,
+ );
+}
diff --git a/lib/src/theme/flow_theme.dart b/lib/src/theme/flow_theme.dart
index dd6e78b..2c3dbd3 100644
--- a/lib/src/theme/flow_theme.dart
+++ b/lib/src/theme/flow_theme.dart
@@ -3,6 +3,7 @@ import 'package:material_ui/material_ui.dart';
import '../styles/flow_chat_view_style.dart';
import '../styles/flow_code_block_style.dart';
import '../styles/flow_composer_style.dart';
+import '../styles/flow_confirmation_style.dart';
import '../styles/flow_error_state_style.dart';
import '../styles/flow_markdown_style.dart';
import '../styles/flow_menu_style.dart';
@@ -51,6 +52,7 @@ class FlowTheme extends ThemeExtension {
this.menuStyle,
this.markdownStyle,
this.codeBlockStyle,
+ this.confirmationStyle,
this.errorStateStyle,
this.messageActionsStyle,
this.pillStyle,
@@ -96,6 +98,10 @@ class FlowTheme extends ThemeExtension {
/// code parts included.
final FlowCodeBlockStyle? codeBlockStyle;
+ /// App-wide default for every `FlowConfirmation` — confirmation parts
+ /// in a thread included.
+ final FlowConfirmationStyle? confirmationStyle;
+
/// App-wide default for every `FlowErrorState` — failed turns included.
final FlowErrorStateStyle? errorStateStyle;
@@ -125,6 +131,7 @@ class FlowTheme extends ThemeExtension {
FlowMenuStyle? menuStyle,
FlowMarkdownStyle? markdownStyle,
FlowCodeBlockStyle? codeBlockStyle,
+ FlowConfirmationStyle? confirmationStyle,
FlowErrorStateStyle? errorStateStyle,
FlowMessageActionsStyle? messageActionsStyle,
FlowPillStyle? pillStyle,
@@ -141,6 +148,7 @@ class FlowTheme extends ThemeExtension {
menuStyle: menuStyle ?? this.menuStyle,
markdownStyle: markdownStyle ?? this.markdownStyle,
codeBlockStyle: codeBlockStyle ?? this.codeBlockStyle,
+ confirmationStyle: confirmationStyle ?? this.confirmationStyle,
errorStateStyle: errorStateStyle ?? this.errorStateStyle,
messageActionsStyle: messageActionsStyle ?? this.messageActionsStyle,
pillStyle: pillStyle ?? this.pillStyle,
@@ -172,6 +180,9 @@ class FlowTheme extends ThemeExtension {
codeBlockStyle: codeBlockStyle == null
? other.codeBlockStyle
: codeBlockStyle!.lerp(other.codeBlockStyle, t),
+ confirmationStyle: confirmationStyle == null
+ ? other.confirmationStyle
+ : confirmationStyle!.lerp(other.confirmationStyle, t),
errorStateStyle: errorStateStyle == null
? other.errorStateStyle
: errorStateStyle!.lerp(other.errorStateStyle, t),
diff --git a/lib/src/utils/flow_asterisk_painter.dart b/lib/src/utils/flow_asterisk_painter.dart
new file mode 100644
index 0000000..7815156
--- /dev/null
+++ b/lib/src/utils/flow_asterisk_painter.dart
@@ -0,0 +1,52 @@
+import 'dart:math' as math;
+
+import 'package:material_ui/material_ui.dart';
+
+/// The six-armed asterisk: three rounded strokes through the center. Drawn
+/// rather than shipped — no SDK glyph matches the design's mark, and paint
+/// stays crisp at any size and tint.
+///
+/// The mark of the library's own chrome — the thinking indicator turns it,
+/// the confirmation card plants it on the header — shared here rather than
+/// duplicated. Internal, like `FlowCircleButton`: not exported from the
+/// package barrel.
+class FlowAsteriskPainter extends CustomPainter {
+ const FlowAsteriskPainter({required this.color, required this.strokeWidth});
+
+ final Color color;
+ final double strokeWidth;
+
+ @override
+ void paint(Canvas canvas, Size size) {
+ final paint = Paint()
+ ..color = color
+ ..strokeWidth = strokeWidth
+ ..strokeCap = StrokeCap.round
+ ..style = PaintingStyle.stroke;
+ // A translucent ink would composite twice where the strokes cross,
+ // darkening the hub against text in the same ink. Flatten the mark
+ // into one layer and apply the ink's alpha to the whole glyph once.
+ final translucent = color.a < 1;
+ if (translucent) {
+ canvas.saveLayer(
+ (Offset.zero & size).inflate(strokeWidth),
+ Paint()..color = const Color(0xFFFFFFFF).withValues(alpha: color.a),
+ );
+ paint.color = color.withValues(alpha: 1);
+ }
+ final center = size.center(Offset.zero);
+ final radius = (size.shortestSide - strokeWidth) / 2;
+ for (var i = 0; i < 3; i++) {
+ // Three diameters at 60° steps, starting upright so the resting mark
+ // has the design's vertical arm.
+ final angle = math.pi / 2 + math.pi * i / 3;
+ final delta = Offset(math.cos(angle), math.sin(angle)) * radius;
+ canvas.drawLine(center - delta, center + delta, paint);
+ }
+ if (translucent) canvas.restore();
+ }
+
+ @override
+ bool shouldRepaint(FlowAsteriskPainter oldDelegate) =>
+ oldDelegate.color != color || oldDelegate.strokeWidth != strokeWidth;
+}
diff --git a/lib/src/widgets/flow_confirmation.dart b/lib/src/widgets/flow_confirmation.dart
new file mode 100644
index 0000000..b2906d4
--- /dev/null
+++ b/lib/src/widgets/flow_confirmation.dart
@@ -0,0 +1,469 @@
+import 'package:material_ui/material_ui.dart';
+
+import '../models/flow_message_part.dart';
+import '../styles/flow_confirmation_style.dart';
+import '../theme/flow_colors.dart';
+import '../theme/flow_theme.dart';
+import '../theme/flow_typography.dart';
+import '../utils/flow_asterisk_painter.dart';
+
+/// The approval card: an asterisk-marked request on a raised card, with
+/// approve and reject buttons that settle into the outcome.
+///
+/// ```dart
+/// FlowConfirmation(
+/// title: 'Approval required',
+/// message: 'Delete 3 files in drafts/. This cannot be undone.',
+/// approveLabel: 'Approve',
+/// rejectLabel: 'Reject',
+/// onApprove: () => respond(true),
+/// onReject: () => respond(false),
+/// )
+/// ```
+///
+/// In a thread this renders on its own: a `FlowConfirmationPart` in any
+/// turn becomes this card, its buttons reporting through
+/// `FlowThread.onConfirmationRespond`. Standalone it serves hosts that
+/// gate an action outside a conversation.
+///
+/// The widget holds no state: a tap reports intent, and the card renders
+/// settled only when the host re-renders with the new [status] — pending
+/// shows the buttons, approved and rejected replace them with one settled
+/// row of the same footprint, so the card's height holds. A pending card
+/// with no button pair is a read-only notice, for chrome staged before
+/// the request is answerable.
+///
+/// The asterisk header is the card's identity — the mark that
+/// distinguishes the runtime's own chrome from content — so the glyphs
+/// are not swappable; restyle their colors through
+/// [FlowConfirmationStyle]. The package ships no strings: every label is
+/// host-localized.
+class FlowConfirmation extends StatelessWidget {
+ const FlowConfirmation({
+ super.key,
+ this.title,
+ this.message,
+ this.status = FlowConfirmationStatus.pending,
+ this.approveLabel,
+ this.rejectLabel,
+ this.approvedLabel,
+ this.rejectedLabel,
+ this.onApprove,
+ this.onReject,
+ this.padding,
+ this.borderRadius,
+ this.style,
+ });
+
+ /// Host-localized header label, e.g. 'Approval required'. Stays on the
+ /// card in every state — the accent, not the words, carries the
+ /// outcome. Null renders the asterisk alone.
+ final String? title;
+
+ /// The request, host-written and sentence-case. Announced to assistive
+ /// tech as a live region, since requests arrive unprompted.
+ final String? message;
+
+ /// Drives the whole card: pending shows the buttons, approved and
+ /// rejected replace them with the settled row.
+ final FlowConfirmationStatus status;
+
+ /// Host-localized label and accessible name of the filled button. The
+ /// button renders only when both this and [onApprove] are set.
+ final String? approveLabel;
+
+ /// Host-localized label and accessible name of the outlined button. The
+ /// button renders only when both this and [onReject] are set.
+ final String? rejectLabel;
+
+ /// Host-localized text of the settled row when approved, e.g.
+ /// 'Approved'. Null shows the check alone — and announces nothing, so
+ /// pass one where the outcome should be heard.
+ final String? approvedLabel;
+
+ /// Host-localized text of the settled row when rejected, e.g.
+ /// 'Rejected'. Null shows the cross alone.
+ final String? rejectedLabel;
+
+ /// Approve intent. Null hides the button.
+ final VoidCallback? onApprove;
+
+ /// Reject intent. Null hides the button.
+ final VoidCallback? onReject;
+
+ /// Inside the card. Defaults to the design's 12 sides, 10 top, 12
+ /// bottom.
+ final EdgeInsetsGeometry? padding;
+
+ /// The card's corner. Defaults to the design's 12.
+ final BorderRadius? borderRadius;
+
+ /// Per-instance restyling, merged over [FlowTheme.confirmationStyle]'s
+ /// fields; nulls fall through to the theme tokens.
+ final FlowConfirmationStyle? style;
+
+ /// The card: the raised surface's 12px corner, the faint hairline and
+ /// the ambient 2%-ink shadow — the same lift as the composer and the
+ /// menu card, because this is chrome, not content.
+ static const BorderRadius _radius = BorderRadius.all(Radius.circular(12));
+ static const EdgeInsetsGeometry _cardPadding = EdgeInsets.fromLTRB(
+ 12,
+ 10,
+ 12,
+ 12,
+ );
+ static const double _shadowBlur = 12;
+
+ /// The header: a 16px asterisk a 4px gap from the title, centred on the
+ /// title's first line.
+ static const double _glyphSize = 16;
+ static const double _glyphGap = 4;
+
+ /// Gaps: header to the request, content to the actions or settled row.
+ static const double _messageGap = 6;
+ static const double _actionsGap = 12;
+
+ /// The buttons' row: right-aligned, 10 apart, wrapping onto an 8px-gapped
+ /// second run when two long localized labels outgrow a phone bubble.
+ static const double _buttonGap = 10;
+ static const double _buttonRunGap = 8;
+
+ /// The approve button's hover wash — its own ink at 8%, since the
+ /// translucent surface washes vanish on the inverse fill.
+ static const double _approveHoverOpacity = 0.08;
+
+ @override
+ Widget build(BuildContext context) {
+ final colors = context.flowColors;
+ final typography = context.flowTypography;
+
+ final title = this.title;
+ final message = this.message;
+
+ final effective =
+ context.flowTheme.confirmationStyle?.merge(style) ?? style;
+
+ // One accent per state: the pending warning, then success or error.
+ // It colors the marks — the asterisk, the settled glyph and its wash —
+ // never the words: the light accents sit at 2.5–4.5:1 on the card,
+ // under WCAG AA for text, so the title and the settled label read in
+ // the ink ramp and the accent carries the state beside them.
+ final accent = switch (status) {
+ FlowConfirmationStatus.pending =>
+ effective?.pendingColor ?? colors.warning,
+ FlowConfirmationStatus.approved =>
+ effective?.approvedColor ?? colors.success,
+ FlowConfirmationStatus.rejected =>
+ effective?.rejectedColor ?? colors.error,
+ };
+
+ final titleStyle = typography.labelSmallEmphasised
+ .copyWith(color: colors.onSurfaceVariant)
+ .merge(effective?.titleStyle);
+
+ Widget? titleLabel;
+ if (title != null) {
+ titleLabel = Text(title, style: titleStyle);
+ if (message == null) {
+ // The title is all the card says, and requests arrive unprompted:
+ // announce it.
+ titleLabel = Semantics(liveRegion: true, child: titleLabel);
+ }
+ }
+
+ // The asterisk centres on the title's first line — the error card's
+ // idiom, so a wrapped title keeps the glyph beside its opening line.
+ final firstLineHeight =
+ (titleStyle.fontSize ?? _glyphSize) * (titleStyle.height ?? 1);
+
+ return Container(
+ padding: padding ?? _cardPadding,
+ decoration: BoxDecoration(
+ color: effective?.backgroundColor ?? colors.surfaceBright,
+ borderRadius: borderRadius ?? _radius,
+ border: Border.all(color: effective?.borderColor ?? colors.outline),
+ boxShadow: [BoxShadow(color: colors.shadow, blurRadius: _shadowBlur)],
+ ),
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ Row(
+ mainAxisSize: MainAxisSize.min,
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ SizedBox(
+ height: firstLineHeight,
+ child: Center(
+ child: CustomPaint(
+ size: const Size.square(_glyphSize),
+ painter: FlowAsteriskPainter(
+ color: accent,
+ strokeWidth: _glyphSize / 9,
+ ),
+ ),
+ ),
+ ),
+ if (titleLabel != null) ...[
+ const SizedBox(width: _glyphGap),
+ Flexible(child: titleLabel),
+ ],
+ ],
+ ),
+ if (message != null)
+ Padding(
+ padding: const EdgeInsets.only(top: _messageGap),
+ child: Semantics(
+ liveRegion: true,
+ child: Text(
+ message,
+ style: typography.bodyMedium
+ .copyWith(color: colors.onSurface)
+ .merge(effective?.messageStyle),
+ ),
+ ),
+ ),
+ ..._buildFooter(colors, typography, effective, accent),
+ ],
+ ),
+ );
+ }
+
+ /// The card's closing row: the buttons while pending, the settled chip
+ /// after — or nothing, when a pending card has no button pair.
+ List _buildFooter(
+ FlowColors colors,
+ FlowTypography typography,
+ FlowConfirmationStyle? effective,
+ Color accent,
+ ) {
+ Widget footer;
+ if (status == FlowConfirmationStatus.pending) {
+ final approveLabel = this.approveLabel;
+ final rejectLabel = this.rejectLabel;
+ final approveForeground =
+ effective?.approveButtonForegroundColor ?? colors.onInverseSurface;
+ final buttons = [
+ if (rejectLabel != null && onReject != null)
+ _ConfirmationButton(
+ label: rejectLabel,
+ onTap: onReject!,
+ textStyle: typography.labelMediumEmphasised,
+ background:
+ effective?.rejectButtonColor ?? colors.surfaceContainerLowest,
+ foreground:
+ effective?.rejectButtonForegroundColor ??
+ colors.onSurfaceVariant,
+ hoverForeground: colors.onSurface,
+ hoverColor: colors.surfaceContainerLow,
+ borderColor:
+ effective?.rejectButtonBorderColor ?? colors.outlineVariant,
+ ),
+ if (approveLabel != null && onApprove != null)
+ _ConfirmationButton(
+ label: approveLabel,
+ onTap: onApprove!,
+ textStyle: FlowTypography.recut(
+ typography.labelMedium,
+ fontWeight: FontWeight.w600,
+ ),
+ background: effective?.approveButtonColor ?? colors.inverseSurface,
+ foreground: approveForeground,
+ hoverForeground: approveForeground,
+ hoverColor: approveForeground.withValues(
+ alpha: _approveHoverOpacity,
+ ),
+ ),
+ ];
+ if (buttons.isEmpty) return const [];
+ footer = SizedBox(
+ width: double.infinity,
+ child: Wrap(
+ alignment: WrapAlignment.end,
+ spacing: _buttonGap,
+ runSpacing: _buttonRunGap,
+ children: buttons,
+ ),
+ );
+ } else {
+ final approved = status == FlowConfirmationStatus.approved;
+ footer = Align(
+ alignment: AlignmentDirectional.centerEnd,
+ child: _SettledRow(
+ approved: approved,
+ label: approved ? approvedLabel : rejectedLabel,
+ accent: accent,
+ foreground: colors.onSurface,
+ textStyle: typography.labelMediumEmphasised,
+ ),
+ );
+ }
+ return [
+ Padding(
+ padding: const EdgeInsets.only(top: _actionsGap),
+ child: footer,
+ ),
+ ];
+ }
+}
+
+/// The card's action button in either cut — filled approve, outlined
+/// reject — private until the design system's Button lands and absorbs
+/// it, like the error card's retry pill.
+class _ConfirmationButton extends StatefulWidget {
+ const _ConfirmationButton({
+ required this.label,
+ required this.onTap,
+ required this.textStyle,
+ required this.background,
+ required this.foreground,
+ required this.hoverForeground,
+ required this.hoverColor,
+ this.borderColor,
+ });
+
+ final String label;
+ final VoidCallback onTap;
+ final TextStyle textStyle;
+ final Color background;
+ final Color foreground;
+ final Color hoverForeground;
+ final Color hoverColor;
+
+ /// The outlined cut's hairline; null renders the filled cut.
+ final Color? borderColor;
+
+ @override
+ State<_ConfirmationButton> createState() => _ConfirmationButtonState();
+}
+
+class _ConfirmationButtonState extends State<_ConfirmationButton> {
+ /// The design's button: 32 tall on an 8px corner, padded 12 — the
+ /// retry pill's frame.
+ static const double _height = 32;
+ static const BorderRadius _radius = BorderRadius.all(Radius.circular(8));
+ static const EdgeInsetsGeometry _padding = EdgeInsets.symmetric(
+ horizontal: 12,
+ );
+
+ bool _hovered = false;
+
+ @override
+ Widget build(BuildContext context) {
+ final borderColor = widget.borderColor;
+ final shape = RoundedRectangleBorder(
+ borderRadius: _radius,
+ side: borderColor == null
+ ? BorderSide.none
+ : BorderSide(color: borderColor),
+ );
+
+ final button = Material(
+ color: widget.background,
+ shape: shape,
+ clipBehavior: Clip.antiAlias,
+ child: InkWell(
+ onTap: widget.onTap,
+ onHover: (value) => setState(() => _hovered = value),
+ customBorder: shape,
+ hoverColor: widget.hoverColor,
+ child: SizedBox(
+ height: _height,
+ child: Padding(
+ padding: _padding,
+ child: Center(
+ widthFactor: 1,
+ child: Text(
+ widget.label,
+ style: widget.textStyle.copyWith(
+ color: _hovered ? widget.hoverForeground : widget.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 button yet not tap
+ // it.
+ return Semantics(
+ button: true,
+ label: widget.label,
+ excludeSemantics: true,
+ onTap: widget.onTap,
+ child: button,
+ );
+ }
+}
+
+/// The settled outcome: the pressed button's footprint, kept — a check or
+/// cross in the accent beside the label in the ink, on the accent's wash,
+/// non-interactive.
+class _SettledRow extends StatelessWidget {
+ const _SettledRow({
+ required this.approved,
+ required this.label,
+ required this.accent,
+ required this.foreground,
+ required this.textStyle,
+ });
+
+ final bool approved;
+ final String? label;
+
+ /// The glyph and the wash.
+ final Color accent;
+
+ /// The label's ink — the full-strength `onSurface`, since the light
+ /// accents fall short of AA on their own wash.
+ final Color foreground;
+ final TextStyle textStyle;
+
+ /// The button's frame, and the accent at the status containers' 6% —
+ /// so the default wash equals `successContainer` / `errorContainer`
+ /// and a restyled accent still gets a matching fill.
+ 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 _fillOpacity = 0.06;
+ static const double _glyphSize = 14;
+ static const double _glyphGap = 6;
+
+ @override
+ Widget build(BuildContext context) {
+ final label = this.label;
+ return Container(
+ height: _height,
+ padding: _padding,
+ decoration: BoxDecoration(
+ color: accent.withValues(alpha: _fillOpacity),
+ borderRadius: _radius,
+ ),
+ child: Row(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ ExcludeSemantics(
+ child: Icon(
+ approved ? Icons.check : Icons.close,
+ size: _glyphSize,
+ color: accent,
+ ),
+ ),
+ if (label != null) ...[
+ const SizedBox(width: _glyphGap),
+ // The outcome lands unprompted too — announce the flip.
+ Semantics(
+ liveRegion: true,
+ child: Text(label, style: textStyle.copyWith(color: foreground)),
+ ),
+ ],
+ ],
+ ),
+ );
+ }
+}
diff --git a/lib/src/widgets/flow_message.dart b/lib/src/widgets/flow_message.dart
index 8dee690..1a3b097 100644
--- a/lib/src/widgets/flow_message.dart
+++ b/lib/src/widgets/flow_message.dart
@@ -10,6 +10,7 @@ import '../utils/flow_shimmer_sweep.dart';
import 'flow_attachment_group.dart';
import 'flow_attachment_preview.dart';
import 'flow_code_block.dart';
+import 'flow_confirmation.dart';
import 'flow_error_state.dart';
import 'flow_markdown.dart';
import 'flow_streaming_text.dart';
@@ -38,6 +39,8 @@ typedef FlowCustomPartBuilder =
/// 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.
+/// A [FlowConfirmationPart] renders a [FlowConfirmation] card, its buttons
+/// reporting through [onConfirmationRespond].
class FlowMessage extends StatelessWidget {
const FlowMessage(
this.message, {
@@ -53,6 +56,11 @@ class FlowMessage extends StatelessWidget {
this.onRetry,
this.errorTitle,
this.retryLabel,
+ this.onConfirmationRespond,
+ this.approveLabel,
+ this.rejectLabel,
+ this.approvedLabel,
+ this.rejectedLabel,
this.leading,
this.footer,
this.maxBubbleWidthFraction = 0.75,
@@ -119,6 +127,21 @@ class FlowMessage extends StatelessWidget {
/// the pill glyph-only.
final String? retryLabel;
+ /// Approve/reject intent from a [FlowConfirmationPart]'s card, handed
+ /// the part and the decision. Null renders every pending card without
+ /// buttons — a read-only notice.
+ final void Function(FlowConfirmationPart part, bool approved)?
+ onConfirmationRespond;
+
+ /// Host-localized labels for the confirmation cards — the approve and
+ /// reject buttons, and the settled row's approved and rejected text. A
+ /// null button label hides that button; a null settled label leaves the
+ /// outcome glyph alone.
+ final String? approveLabel;
+ final String? rejectLabel;
+ final String? approvedLabel;
+ final String? rejectedLabel;
+
/// Slot beside the content, e.g. an avatar.
final Widget? leading;
@@ -354,11 +377,13 @@ class FlowMessage extends StatelessWidget {
textAlign: TextAlign.center,
),
// System messages are centered notices; attachments, images,
- // code and failures belong to user and assistant turns.
+ // code, failures and confirmations belong to user and
+ // assistant turns.
FlowAttachmentPart() ||
FlowImagePart() ||
FlowCodePart() ||
- FlowErrorPart() => const SizedBox.shrink(),
+ FlowErrorPart() ||
+ FlowConfirmationPart() => const SizedBox.shrink(),
FlowCustomPart() =>
customPartBuilder?.call(context, message, part) ??
const SizedBox.shrink(),
@@ -523,6 +548,7 @@ class FlowMessage extends StatelessWidget {
.copyWith(color: foreground)
.merge(textStyle);
final onCodeCopy = this.onCodeCopy;
+ final onConfirmationRespond = this.onConfirmationRespond;
// Only text parts get the streaming reveal; a message that ends in a
// code part streams its code without one (FlowCodeBlock renders each
@@ -599,6 +625,23 @@ class FlowMessage extends StatelessWidget {
retryLabel: retryLabel,
onRetry: retryable ? onRetry : null,
),
+ // Bound whole rather than destructured: the closures hand the
+ // part back so the host can find the request it belongs to.
+ FlowConfirmationPart confirmation => FlowConfirmation(
+ title: confirmation.title,
+ message: confirmation.message,
+ status: confirmation.status,
+ approveLabel: approveLabel,
+ rejectLabel: rejectLabel,
+ approvedLabel: approvedLabel,
+ rejectedLabel: rejectedLabel,
+ onApprove: onConfirmationRespond == null
+ ? null
+ : () => onConfirmationRespond(confirmation, true),
+ onReject: onConfirmationRespond == null
+ ? null
+ : () => onConfirmationRespond(confirmation, false),
+ ),
FlowCustomPart() => customPartBuilder?.call(context, message, part),
};
if (child == null) continue;
diff --git a/lib/src/widgets/flow_thinking_indicator.dart b/lib/src/widgets/flow_thinking_indicator.dart
index df0d676..d51e537 100644
--- a/lib/src/widgets/flow_thinking_indicator.dart
+++ b/lib/src/widgets/flow_thinking_indicator.dart
@@ -1,8 +1,7 @@
-import 'dart:math' as math;
-
import 'package:material_ui/material_ui.dart';
import '../theme/flow_theme.dart';
+import '../utils/flow_asterisk_painter.dart';
import 'flow_shimmer_text.dart';
/// The thinking line shown while the assistant has not started responding:
@@ -158,7 +157,7 @@ class _FlowThinkingIndicatorState extends State
opacity: depth.drive(Tween(begin: 1, end: _minOpacity)),
child: CustomPaint(
size: Size.square(widget.size),
- painter: _AsteriskPainter(
+ painter: FlowAsteriskPainter(
color: widget.color ?? colors.onSurfaceMuted,
// Proportional so the mark keeps its weight at any size.
strokeWidth: widget.size / 9,
@@ -196,47 +195,3 @@ class _FlowThinkingIndicatorState extends State
);
}
}
-
-/// The six-armed asterisk: three rounded strokes through the center. Drawn
-/// rather than shipped — no SDK glyph matches the design's mark, and paint
-/// stays crisp at any size and tint.
-class _AsteriskPainter extends CustomPainter {
- const _AsteriskPainter({required this.color, required this.strokeWidth});
-
- final Color color;
- final double strokeWidth;
-
- @override
- void paint(Canvas canvas, Size size) {
- final paint = Paint()
- ..color = color
- ..strokeWidth = strokeWidth
- ..strokeCap = StrokeCap.round
- ..style = PaintingStyle.stroke;
- // A translucent ink would composite twice where the strokes cross,
- // darkening the hub against text in the same ink. Flatten the mark
- // into one layer and apply the ink's alpha to the whole glyph once.
- final translucent = color.a < 1;
- if (translucent) {
- canvas.saveLayer(
- (Offset.zero & size).inflate(strokeWidth),
- Paint()..color = const Color(0xFFFFFFFF).withValues(alpha: color.a),
- );
- paint.color = color.withValues(alpha: 1);
- }
- final center = size.center(Offset.zero);
- final radius = (size.shortestSide - strokeWidth) / 2;
- for (var i = 0; i < 3; i++) {
- // Three diameters at 60° steps, starting upright so the resting mark
- // has the design's vertical arm.
- final angle = math.pi / 2 + math.pi * i / 3;
- final delta = Offset(math.cos(angle), math.sin(angle)) * radius;
- canvas.drawLine(center - delta, center + delta, paint);
- }
- if (translucent) canvas.restore();
- }
-
- @override
- bool shouldRepaint(_AsteriskPainter oldDelegate) =>
- oldDelegate.color != color || oldDelegate.strokeWidth != strokeWidth;
-}
diff --git a/lib/src/widgets/flow_thread.dart b/lib/src/widgets/flow_thread.dart
index c999849..b33baf1 100644
--- a/lib/src/widgets/flow_thread.dart
+++ b/lib/src/widgets/flow_thread.dart
@@ -30,6 +30,11 @@ class FlowThread extends StatefulWidget {
this.onRetry,
this.errorTitle,
this.retryLabel,
+ this.onConfirmationRespond,
+ this.approveLabel,
+ this.rejectLabel,
+ this.approvedLabel,
+ this.rejectedLabel,
this.controller,
this.keyboardDismissBehavior = ScrollViewKeyboardDismissBehavior.onDrag,
this.padding,
@@ -90,6 +95,26 @@ class FlowThread extends StatefulWidget {
/// the pill glyph-only.
final String? retryLabel;
+ /// Approve/reject intent from a [FlowConfirmationPart]'s card, handed
+ /// the message, the part and the decision so the host can find the
+ /// request it belongs to and re-render the part settled. Forwarded to
+ /// each [FlowMessage]; null renders every pending card without buttons.
+ final void Function(
+ FlowMessageData message,
+ FlowConfirmationPart part,
+ bool approved,
+ )?
+ onConfirmationRespond;
+
+ /// Host-localized labels for the thread's confirmation cards — the
+ /// approve and reject buttons, and the settled row's approved and
+ /// rejected text. A null button label hides that button; a null settled
+ /// label leaves the outcome glyph alone.
+ final String? approveLabel;
+ final String? rejectLabel;
+ final String? approvedLabel;
+ final String? rejectedLabel;
+
/// Optional external scroll controller.
final ScrollController? controller;
@@ -251,6 +276,7 @@ class _FlowThreadState extends State {
final gap = widget.itemSpacing ?? _defaultGap;
final onAttachmentTap = widget.onAttachmentTap;
final onRetry = widget.onRetry;
+ final onConfirmationRespond = widget.onConfirmationRespond;
final onLinkTap = widget.onLinkTap;
final messages = widget.messages;
_syncStreaming(messages);
@@ -300,6 +326,14 @@ class _FlowThreadState extends State {
onRetry: onRetry == null ? null : () => onRetry(message),
errorTitle: widget.errorTitle,
retryLabel: widget.retryLabel,
+ onConfirmationRespond: onConfirmationRespond == null
+ ? null
+ : (part, approved) =>
+ onConfirmationRespond(message, part, approved),
+ approveLabel: widget.approveLabel,
+ rejectLabel: widget.rejectLabel,
+ approvedLabel: widget.approvedLabel,
+ rejectedLabel: widget.rejectedLabel,
charactersPerSecond: widget.charactersPerSecond,
thinkingLabel: widget.thinkingLabel,
footer: widget.messageFooter?.call(message),
diff --git a/playground/lib/src/demo_registry.dart b/playground/lib/src/demo_registry.dart
index faf0784..5e9b75b 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/confirmation_demo.dart';
import 'demos/error_state_demo.dart';
import 'demos/full_chat_demo.dart';
import 'demos/greeting_demo.dart';
@@ -38,6 +39,7 @@ Widget demoFor(PlaygroundItem item, {String? variant}) {
PlaygroundItem.codeBlock => CodeBlockDemo(key: key, variant: variant),
PlaygroundItem.markdown => MarkdownDemo(key: key, variant: variant),
PlaygroundItem.errorState => ErrorStateDemo(key: key, variant: variant),
+ PlaygroundItem.confirmation => ConfirmationDemo(key: key, variant: variant),
PlaygroundItem.addToChat => AddToChatDemo(key: key),
PlaygroundItem.pill => PillDemo(key: key, variant: variant),
PlaygroundItem.attachments => AttachmentsDemo(key: key, variant: variant),
@@ -98,6 +100,12 @@ List<(String, String)> variantsFor(PlaygroundItem item) {
('minimal', 'Minimal'),
('thread', 'Failed turn'),
],
+ PlaygroundItem.confirmation => const [
+ ('pending', 'Pending'),
+ ('approved', 'Approved'),
+ ('rejected', 'Rejected'),
+ ('thread', 'In a thread'),
+ ],
PlaygroundItem.pill => const [
('default', 'Default'),
('icon', 'Icon only'),
@@ -165,6 +173,7 @@ String snippetFor(PlaygroundItem item, {String? variant}) {
PlaygroundItem.codeBlock => codeBlockSnippet(variant),
PlaygroundItem.markdown => markdownSnippet(variant),
PlaygroundItem.errorState => errorStateSnippet(variant),
+ PlaygroundItem.confirmation => confirmationSnippet(variant),
PlaygroundItem.addToChat => addToChatSnippet,
PlaygroundItem.pill => pillSnippet(variant),
PlaygroundItem.attachments => attachmentsSnippet(variant),
diff --git a/playground/lib/src/demos/confirmation_demo.dart b/playground/lib/src/demos/confirmation_demo.dart
new file mode 100644
index 0000000..c6dc293
--- /dev/null
+++ b/playground/lib/src/demos/confirmation_demo.dart
@@ -0,0 +1,169 @@
+import 'package:flow_ui/flow_ui.dart';
+import 'package:material_ui/material_ui.dart';
+
+String confirmationSnippet([String? variant]) => switch (variant) {
+ 'approved' => _approvedSnip,
+ 'rejected' => _rejectedSnip,
+ 'thread' => _threadSnip,
+ _ => _pendingSnip,
+};
+
+const String _pendingSnip = '''
+// The card renders state and reports intent; recording the decision —
+// and re-rendering with the new status — is the host's business.
+FlowConfirmation(
+ title: 'Approval required',
+ message: 'Delete 3 files in drafts/. This cannot be undone.',
+ approveLabel: 'Approve',
+ rejectLabel: 'Reject',
+ onApprove: () => respond(true),
+ onReject: () => respond(false),
+)''';
+
+const String _approvedSnip = '''
+// A settled card: the accent flips to success and the buttons collapse
+// into the outcome's row, in the host's words.
+FlowConfirmation(
+ title: 'Approval required',
+ message: 'Delete 3 files in drafts/. This cannot be undone.',
+ status: FlowConfirmationStatus.approved,
+ approvedLabel: 'Approved',
+)''';
+
+const String _rejectedSnip = '''
+// The rejected outcome carries the error accent.
+FlowConfirmation(
+ title: 'Approval required',
+ message: 'Delete 3 files in drafts/. This cannot be undone.',
+ status: FlowConfirmationStatus.rejected,
+ rejectedLabel: 'Rejected',
+)''';
+
+const String _threadSnip = '''
+// In a thread the card renders on its own: a FlowConfirmationPart in
+// any turn becomes it, and the buttons hand the message, the part and
+// the decision back so the host can settle the request.
+FlowThread(
+ messages: messages,
+ approveLabel: 'Approve',
+ rejectLabel: 'Reject',
+ approvedLabel: 'Approved',
+ rejectedLabel: 'Rejected',
+ onConfirmationRespond: (message, part, approved) =>
+ record(message, part, approved),
+)
+
+FlowMessageData(
+ id: 'a1',
+ role: FlowMessageRole.assistant,
+ parts: [
+ FlowTextPart('I can clear those drafts for you.'),
+ FlowConfirmationPart(
+ title: 'Approval required',
+ message: 'Delete 3 files in drafts/. This cannot be undone.',
+ ),
+ ],
+)''';
+
+const String _requestMessage =
+ 'Delete 3 files in drafts/. This cannot be undone.';
+
+/// Stage demo for `FlowConfirmation` — the live pending card whose
+/// buttons actually settle it, the two settled forms, and a turn in a
+/// thread where the decision lands the way a host would record it.
+class ConfirmationDemo extends StatefulWidget {
+ const ConfirmationDemo({super.key, this.variant});
+
+ final String? variant;
+
+ @override
+ State createState() => _ConfirmationDemoState();
+}
+
+class _ConfirmationDemoState extends State {
+ FlowConfirmationStatus _status = FlowConfirmationStatus.pending;
+
+ void _respond(bool approved) {
+ setState(
+ () => _status = approved
+ ? FlowConfirmationStatus.approved
+ : FlowConfirmationStatus.rejected,
+ );
+ }
+
+ /// Thread variant: the decision settles the card in place, and the
+ /// reply continues past it — the shape of a tool gate in a real host.
+ List get _messages => [
+ FlowMessageData.text(
+ id: 'u1',
+ role: FlowMessageRole.user,
+ text: 'Clear out my drafts folder.',
+ ),
+ FlowMessageData(
+ id: 'a1',
+ role: FlowMessageRole.assistant,
+ parts: [
+ const FlowTextPart('I can clear those drafts for you.'),
+ FlowConfirmationPart(
+ title: 'Approval required',
+ message: _requestMessage,
+ status: _status,
+ ),
+ if (_status == FlowConfirmationStatus.approved)
+ const FlowTextPart('Done — the drafts folder is empty.'),
+ if (_status == FlowConfirmationStatus.rejected)
+ const FlowTextPart('Understood — I left the drafts untouched.'),
+ ],
+ ),
+ ];
+
+ @override
+ Widget build(BuildContext context) {
+ final child = switch (widget.variant) {
+ 'approved' => const FlowConfirmation(
+ title: 'Approval required',
+ message: _requestMessage,
+ status: FlowConfirmationStatus.approved,
+ approvedLabel: 'Approved',
+ ),
+ 'rejected' => const FlowConfirmation(
+ title: 'Approval required',
+ message: _requestMessage,
+ status: FlowConfirmationStatus.rejected,
+ rejectedLabel: 'Rejected',
+ ),
+ 'thread' => SizedBox(
+ height: 420,
+ child: FlowThread(
+ messages: _messages,
+ approveLabel: 'Approve',
+ rejectLabel: 'Reject',
+ approvedLabel: 'Approved',
+ rejectedLabel: 'Rejected',
+ onConfirmationRespond: (message, part, approved) =>
+ _respond(approved),
+ ),
+ ),
+ _ => FlowConfirmation(
+ title: 'Approval required',
+ message: _requestMessage,
+ status: _status,
+ approveLabel: 'Approve',
+ rejectLabel: 'Reject',
+ approvedLabel: 'Approved',
+ rejectedLabel: 'Rejected',
+ onApprove: () => _respond(true),
+ onReject: () => _respond(false),
+ ),
+ };
+
+ return Center(
+ child: ConstrainedBox(
+ constraints: BoxConstraints(
+ maxWidth: widget.variant == 'thread' ? 560 : 480,
+ ),
+ child: child,
+ ),
+ );
+ }
+}
diff --git a/playground/lib/src/playground_item.dart b/playground/lib/src/playground_item.dart
index c89e217..a1b678f 100644
--- a/playground/lib/src/playground_item.dart
+++ b/playground/lib/src/playground_item.dart
@@ -35,6 +35,11 @@ enum PlaygroundItem {
PhosphorIconsRegular.warningCircle,
'flow_error_state.dart',
),
+ confirmation(
+ 'Confirmation',
+ PhosphorIconsRegular.shieldCheck,
+ 'flow_confirmation.dart',
+ ),
addToChat(
'Add to Chat',
PhosphorIconsRegular.plus,