diff --git a/AGENTS.md b/AGENTS.md
index f214d3f..6c33f00 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -88,11 +88,12 @@ Status legend: ⬜ Todo · ✅ Done
| 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 | ✅ |
+| 24 | Pill | removable tool/mode pill for the composer's action row; label auto-drops on phones | ✅ |
### Surfaces
| # | Component | Variants / notes | Status |
|---|-----------|------------------|--------|
-| 24 | Chat View | centred 760 rail; zero state (greeting, lifted composer, starters); jump to latest | ✅ |
-| 25 | SidePanel | | ⬜ |
-| 26 | Modal | | ⬜ |
+| 25 | Chat View | centred 760 rail; zero state (greeting, lifted composer, starters); jump to latest | ✅ |
+| 26 | SidePanel | | ⬜ |
+| 27 | Modal | | ⬜ |
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 4acb7b2..3991096 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -15,6 +15,10 @@
retry pill) and a `FlowErrorPart` message part, with
`onRetry`/`errorTitle`/`retryLabel` threaded through `FlowMessage` and
`FlowThread`.
+- **Pill** — `FlowPill`, a removable pill showing an enabled tool or mode
+ in the composer's action row: host-passed icon, label and tooltips,
+ removal intent on `onRemove`, and a label that auto-drops to the
+ icon-only form on phones (`showLabel` forces either).
- **Breaking**: `FlowChatScreen` is renamed to `FlowChatView`. The widget
was never a screen — it is body-only and embeddable, and upcoming
surfaces (side panel, modal) will host it — so the name now follows
diff --git a/CLAUDE.md b/CLAUDE.md
index 94a88f0..83fc7fd 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -90,11 +90,12 @@ Values come from the Flow UI Figma file. Role names follow Material 3's `ColorSc
| 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 | ✅ |
+| 24 | Pill | removable tool/mode pill for the composer's action row; label auto-drops on phones | ✅ |
### Surfaces
| # | Component | Variants / notes | Status |
|---|-----------|------------------|--------|
-| 24 | Chat View | centred 760 rail; zero state (greeting, lifted composer, starters); jump to latest | ✅ |
-| 25 | SidePanel | | ⬜ |
-| 26 | Modal | | ⬜ |
+| 25 | Chat View | centred 760 rail; zero state (greeting, lifted composer, starters); jump to latest | ✅ |
+| 26 | SidePanel | | ⬜ |
+| 27 | Modal | | ⬜ |
diff --git a/README.md b/README.md
index 81f1ab9..c82b3f5 100644
--- a/README.md
+++ b/README.md
@@ -45,6 +45,7 @@
| [`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 |
| [`FlowModelSelector`](https://flowui.stac.dev/components/model-selector) | Model picker with effort and overflow submenus, sheet on phones |
+| [`FlowPill`](https://flowui.stac.dev/components/pill) | Removable pill for an enabled tool or mode in the composer's action row — label auto-drops on phones |
| [`FlowAttachmentGroup`](https://flowui.stac.dev/components/attachments) | Image and file tiles with a type pill |
| [`FlowAttachmentPreview`](https://flowui.stac.dev/components/attachments) | Full-screen image viewer with zoom and paging |
| [`FlowSuggestion`](https://flowui.stac.dev/components/suggestions) / [`FlowSuggestionGroup`](https://flowui.stac.dev/components/suggestions) | Prompt starters — plain or outlined; scroll, wrap, or column layouts |
diff --git a/docs/src/content/docs/components/pill.mdx b/docs/src/content/docs/components/pill.mdx
new file mode 100644
index 0000000..3ce2414
--- /dev/null
+++ b/docs/src/content/docs/components/pill.mdx
@@ -0,0 +1,97 @@
+---
+title: Pill
+description: A removable pill for an enabled tool or mode — icon, label, and an X that reports removal.
+sidebar:
+ order: 15
+---
+
+import FlowDemo from '../../../components/FlowDemo.astro';
+
+`FlowPill` is the composer's tool token: while the host has Research or
+Web Search switched on, a pill says so — an icon and label on the faint
+fill, with an X that reports removal through `onRemove`. Presence *is*
+the state: render a pill while its tool is on, and actually turning the
+tool off is the host's move. The package ships no strings, so `label`
+and both tooltips are host-localized.
+
+## Removable
+
+
+
+```dart title="A pill per enabled tool"
+FlowComposer(
+ onSend: send,
+ leadingActions: [
+ FlowMenu(...),
+ if (researchOn)
+ FlowPill(
+ icon: Icons.school_outlined,
+ label: 'Research',
+ removeTooltip: 'Turn off Research',
+ onRemove: () => setResearch(false),
+ ),
+ ],
+)
+```
+
+Pass a `removeTooltip` whenever `onRemove` is set — it is the X's
+tooltip *and* its accessible name, so without one assistive tech
+announces an unnamed button.
+
+## Icon-only on phones
+
+On iOS and Android the label drops away to the design's compact form —
+icon and X alone. The check reads the theme's platform rather than the
+real one, like the menus' sheet resolution, so hosts and tests can steer
+it without a device. `showLabel` forces either form outright; a host
+forcing icon-only on a hovering device can pass `tooltip` so the tool's
+name survives as a hover:
+
+
+
+```dart title="Forcing the compact form"
+FlowPill(
+ icon: Icons.language,
+ label: 'Web Search', // still the accessible name
+ showLabel: false,
+ tooltip: 'Web Search',
+ removeTooltip: 'Turn off Web Search',
+ onRemove: () => setWebSearch(false),
+)
+```
+
+## Static and disabled
+
+A pill without `onRemove` is a static status token in full ink — not a
+disabled control, which is a deliberate difference from the suggestion
+row, where a null tap reads disabled. Disabling here is explicit:
+`enabled: false` fades the ink and inerts the targets, keeping the fill
+and hairline in place:
+
+
+
+## In the composer
+
+The pill is drawn 32 tall to sit flush in `FlowComposer.leadingActions`,
+next to the add menu that toggles it. The action row doesn't scroll, so
+keep concurrent pills few:
+
+
+
+## Key API
+
+- `icon` — always drawn; the pill's whole identity in the icon-only
+ form.
+- `label` — always the pill's accessible name, painted only in the
+ labeled form.
+- `onRemove` — removal intent from the X; null leaves the X off
+ entirely.
+- `onTap` — optional tap on the pill's body, e.g. reopening the tool's
+ options; null leaves the body inert.
+- `removeTooltip`, `tooltip` — host-localized; the X's name, and the
+ body's hover.
+- `showLabel` — null resolves by platform; `true`/`false` forces.
+- `enabled` — explicit disabling; static and disabled are different
+ states.
+- `padding` (the design's 8 horizontally) and `borderRadius` (8)
+ override the metrics.
diff --git a/docs/src/content/docs/roadmap.md b/docs/src/content/docs/roadmap.md
index 3d7b0de..4e9e693 100644
--- a/docs/src/content/docs/roadmap.md
+++ b/docs/src/content/docs/roadmap.md
@@ -43,6 +43,7 @@ elements and the remaining AI states are on the way.
| Code block | Shipped |
| Thinking indicator | Shipped |
| Shimmer | Shipped |
+| Pill | Shipped |
## Surfaces
diff --git a/lib/flow_ui.dart b/lib/flow_ui.dart
index 5e5cc9f..7fc8765 100644
--- a/lib/flow_ui.dart
+++ b/lib/flow_ui.dart
@@ -25,6 +25,7 @@ export 'src/widgets/flow_menu_style.dart';
export 'src/widgets/flow_message.dart';
export 'src/widgets/flow_model_selector.dart';
export 'src/widgets/flow_message_actions.dart';
+export 'src/widgets/flow_pill.dart';
export 'src/widgets/flow_shimmer_text.dart';
export 'src/widgets/flow_streaming_text.dart';
export 'src/widgets/flow_suggestion.dart';
diff --git a/lib/src/widgets/flow_pill.dart b/lib/src/widgets/flow_pill.dart
new file mode 100644
index 0000000..8509e46
--- /dev/null
+++ b/lib/src/widgets/flow_pill.dart
@@ -0,0 +1,252 @@
+import 'package:material_ui/material_ui.dart';
+
+import '../theme/flow_theme.dart';
+import '../utils/flow_state_colors.dart';
+
+/// A removable pill showing an enabled tool or mode — "Research", "Web
+/// Search" — in the composer's action row, typically appended to
+/// `FlowComposer.leadingActions` while the host's toggle is on:
+///
+/// ```dart
+/// if (researchOn)
+/// FlowPill(
+/// icon: Icons.school_outlined,
+/// label: 'Research',
+/// removeTooltip: 'Turn off Research',
+/// onRemove: () => setResearch(false),
+/// )
+/// ```
+///
+/// The X reports removal through [onRemove]; actually turning the tool off
+/// is the host's move. On phones the label drops away to the design's
+/// icon-only form (see [showLabel]).
+///
+/// A pill without [onRemove] is a static status token in full ink, not a
+/// disabled control — unlike `FlowSuggestion`, where a null tap renders
+/// the row disabled. Disabling here is explicit, through [enabled].
+class FlowPill extends StatefulWidget {
+ const FlowPill({
+ super.key,
+ required this.icon,
+ required this.label,
+ this.onRemove,
+ this.onTap,
+ this.removeTooltip,
+ this.tooltip,
+ this.showLabel,
+ this.enabled = true,
+ this.padding,
+ this.borderRadius,
+ });
+
+ /// The tool's glyph — always drawn; the pill's whole identity in the
+ /// icon-only form.
+ final IconData icon;
+
+ /// The tool's name. Always the pill's accessible name, drawn only while
+ /// the labeled form is in effect (see [showLabel]).
+ final String label;
+
+ /// Remove intent from the trailing X; null leaves the X off entirely —
+ /// a static status pill.
+ final VoidCallback? onRemove;
+
+ /// Optional tap on the pill's body, e.g. reopening the tool's options.
+ /// Null leaves the body inert without reading disabled.
+ final VoidCallback? onTap;
+
+ /// Host-localized label for the X, e.g. 'Turn off Research' — its
+ /// tooltip and accessible name. Pass one whenever [onRemove] is set, or
+ /// assistive tech announces an unnamed button.
+ final String? removeTooltip;
+
+ /// Host-localized tooltip over the pill's body — the tool's name when a
+ /// host forces the icon-only form on a hovering device.
+ final String? tooltip;
+
+ /// Whether the label is drawn. Null resolves by platform — hidden on
+ /// iOS and Android (the design's compact composer), shown elsewhere —
+ /// reading the theme's platform rather than the real one, like the
+ /// menus' sheet resolution, so hosts and tests can steer it without a
+ /// device.
+ final bool? showLabel;
+
+ final bool enabled;
+
+ /// Inside the pill. Defaults to the design's 8 horizontally.
+ final EdgeInsetsGeometry? padding;
+
+ /// The pill's corner. Defaults to the design's 8.
+ final BorderRadius? borderRadius;
+
+ @override
+ State createState() => _FlowPillState();
+}
+
+class _FlowPillState extends State {
+ /// The design's pill: 32 tall on an 8px corner, padded 8, an 18px glyph
+ /// 6 from its label with the 14px X another 6 along — the gap closing
+ /// to 4 in the icon-only form, per the compact composer.
+ static const double _height = 32;
+ static const BorderRadius _radius = BorderRadius.all(Radius.circular(8));
+ static const EdgeInsetsGeometry _padding = EdgeInsets.symmetric(
+ horizontal: 8,
+ );
+ static const double _iconSize = 18;
+ static const double _removeSize = 14;
+ static const double _gap = 6;
+ static const double _compactGap = 4;
+
+ bool _removeHovered = false;
+
+ bool _labelVisible(BuildContext context) {
+ final show = widget.showLabel;
+ if (show != null) return show;
+ final platform = Theme.of(context).platform;
+ return platform != TargetPlatform.iOS && platform != TargetPlatform.android;
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ final colors = context.flowColors;
+ final typography = context.flowTypography;
+ final enabled = widget.enabled;
+ final showLabel = _labelVisible(context);
+ final gap = showLabel ? _gap : _compactGap;
+ final hasRemove = widget.onRemove != null;
+
+ // Content rests in full ink; the X a step down at the muted chrome
+ // level, lifting to full on hover. Disabling fades each from its own
+ // rest, so the X stays proportionally fainter.
+ final foreground = enabled
+ ? colors.onSurface
+ : flowDisabledColor(colors.onSurface);
+ final removeRest = enabled
+ ? colors.onSurfaceMuted
+ : flowDisabledColor(colors.onSurfaceMuted);
+ final removeForeground = _removeHovered && enabled
+ ? colors.onSurface
+ : removeRest;
+
+ final shape = RoundedRectangleBorder(
+ borderRadius: widget.borderRadius ?? _radius,
+ side: BorderSide(color: colors.outlineVariant),
+ );
+
+ // The X's target spans the pill's full height and absorbs the end
+ // inset, so splitting the padding needs the resolved sides — start on
+ // the body, end inside the X — kept directional so RTL swaps them.
+ final direction = Directionality.of(context);
+ final resolved = (widget.padding ?? _padding).resolve(direction);
+ final startInset = direction == TextDirection.ltr
+ ? resolved.left
+ : resolved.right;
+ final endInset = direction == TextDirection.ltr
+ ? resolved.right
+ : resolved.left;
+
+ Widget body = Padding(
+ padding: EdgeInsetsDirectional.only(
+ start: startInset,
+ end: hasRemove ? 0 : endInset,
+ top: resolved.top,
+ bottom: resolved.bottom,
+ ),
+ child: Row(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ Icon(widget.icon, size: _iconSize, color: foreground),
+ if (showLabel) ...[
+ const SizedBox(width: _gap),
+ Text(
+ widget.label,
+ style: typography.labelLarge.copyWith(
+ fontWeight: FontWeight.w500,
+ color: foreground,
+ ),
+ ),
+ ],
+ ],
+ ),
+ );
+
+ if (widget.onTap != null) {
+ body = InkWell(
+ onTap: enabled ? widget.onTap : null,
+ hoverColor: colors.surfaceContainerLow,
+ child: body,
+ );
+ // 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.
+ body = Semantics(
+ button: true,
+ label: widget.label,
+ excludeSemantics: true,
+ onTap: enabled ? widget.onTap : null,
+ child: body,
+ );
+ } else if (!showLabel) {
+ // The inert icon-only body still announces the tool's name.
+ body = Semantics(
+ label: widget.label,
+ child: ExcludeSemantics(child: body),
+ );
+ }
+
+ // On the body only — the X carries its own tooltip, and covering it
+ // from an ancestor would raise both at once.
+ final tooltip = widget.tooltip;
+ if (tooltip != null) {
+ body = Tooltip(message: tooltip, child: body);
+ }
+
+ Widget? remove;
+ if (hasRemove) {
+ remove = InkWell(
+ onTap: enabled ? widget.onRemove : null,
+ onHover: enabled
+ ? (value) => setState(() => _removeHovered = value)
+ : null,
+ hoverColor: colors.surfaceContainerLow,
+ child: Padding(
+ padding: EdgeInsetsDirectional.only(
+ start: gap,
+ end: endInset,
+ top: resolved.top,
+ bottom: resolved.bottom,
+ ),
+ child: Center(
+ child: Icon(
+ Icons.close,
+ size: _removeSize,
+ color: removeForeground,
+ ),
+ ),
+ ),
+ );
+ // Tooltip already contributes the message to the semantics node, so
+ // it doubles as the X's accessible name — the circle button's idiom.
+ final removeTooltip = widget.removeTooltip;
+ if (removeTooltip != null) {
+ remove = Tooltip(message: removeTooltip, child: remove);
+ }
+ }
+
+ return Material(
+ color: colors.surfaceContainerLow,
+ shape: shape,
+ clipBehavior: Clip.antiAlias,
+ child: SizedBox(
+ height: _height,
+ child: Row(
+ mainAxisSize: MainAxisSize.min,
+ // Stretched, so the body's ink and the X's target span the
+ // pill's full height; each region centres its own glyphs.
+ crossAxisAlignment: CrossAxisAlignment.stretch,
+ children: [body, ?remove],
+ ),
+ ),
+ );
+ }
+}
diff --git a/playground/lib/src/demo_registry.dart b/playground/lib/src/demo_registry.dart
index 96d249f..7eceaff 100644
--- a/playground/lib/src/demo_registry.dart
+++ b/playground/lib/src/demo_registry.dart
@@ -10,6 +10,7 @@ import 'demos/greeting_demo.dart';
import 'demos/message_actions_demo.dart';
import 'demos/message_demo.dart';
import 'demos/model_selector_demo.dart';
+import 'demos/pill_demo.dart';
import 'demos/shimmer_text_demo.dart';
import 'demos/streaming_message_demo.dart';
import 'demos/streaming_text_demo.dart';
@@ -35,6 +36,7 @@ Widget demoFor(PlaygroundItem item, {String? variant}) {
PlaygroundItem.codeBlock => CodeBlockDemo(key: key, variant: variant),
PlaygroundItem.errorState => ErrorStateDemo(key: key, variant: variant),
PlaygroundItem.addToChat => AddToChatDemo(key: key),
+ PlaygroundItem.pill => PillDemo(key: key, variant: variant),
PlaygroundItem.attachments => AttachmentsDemo(key: key, variant: variant),
PlaygroundItem.thread => ThreadDemo(key: key, variant: variant),
PlaygroundItem.messageActions => MessageActionsDemo(key: key),
@@ -84,6 +86,12 @@ List<(String, String)> variantsFor(PlaygroundItem item) {
('minimal', 'Minimal'),
('thread', 'Failed turn'),
],
+ PlaygroundItem.pill => const [
+ ('default', 'Default'),
+ ('icon', 'Icon only'),
+ ('static', 'No remove'),
+ ('composer', 'In composer'),
+ ],
PlaygroundItem.attachments => const [
('composer', 'In composer'),
('tiles', 'Tiles only'),
@@ -133,6 +141,7 @@ String snippetFor(PlaygroundItem item) {
PlaygroundItem.codeBlock => codeBlockSnippet,
PlaygroundItem.errorState => errorStateSnippet,
PlaygroundItem.addToChat => addToChatSnippet,
+ PlaygroundItem.pill => pillSnippet,
PlaygroundItem.attachments => attachmentsSnippet,
PlaygroundItem.thread => threadSnippet,
PlaygroundItem.messageActions => messageActionsSnippet,
@@ -182,6 +191,13 @@ FlowChatView(
entries: [...],
onSelected: toggleTool,
),
+ if (researchOn)
+ FlowPill(
+ icon: PhosphorIconsRegular.graduationCap,
+ label: 'Research',
+ removeTooltip: 'Turn off Research',
+ onRemove: () => setResearch(false),
+ ),
],
trailingActions: [
FlowModelSelector(
diff --git a/playground/lib/src/demos/full_chat_demo.dart b/playground/lib/src/demos/full_chat_demo.dart
index 7bfd138..0f255f3 100644
--- a/playground/lib/src/demos/full_chat_demo.dart
+++ b/playground/lib/src/demos/full_chat_demo.dart
@@ -189,6 +189,20 @@ class _FullChatDemoState extends State {
}
},
),
+ if (_researchOn)
+ FlowPill(
+ icon: PhosphorIconsRegular.graduationCap,
+ label: 'Research',
+ removeTooltip: 'Turn off Research',
+ onRemove: () => setState(() => _researchOn = false),
+ ),
+ if (_webSearchOn)
+ FlowPill(
+ icon: PhosphorIconsRegular.globe,
+ label: 'Web Search',
+ removeTooltip: 'Turn off Web Search',
+ onRemove: () => setState(() => _webSearchOn = false),
+ ),
],
trailingActions: [
FlowModelSelector(
diff --git a/playground/lib/src/demos/pill_demo.dart b/playground/lib/src/demos/pill_demo.dart
new file mode 100644
index 0000000..fc72bbc
--- /dev/null
+++ b/playground/lib/src/demos/pill_demo.dart
@@ -0,0 +1,161 @@
+import 'package:flow_ui/flow_ui.dart';
+import 'package:material_ui/material_ui.dart';
+import 'package:phosphoricons_flutter/phosphoricons_flutter.dart';
+
+const String pillSnippet = '''
+// Presence is the state: the host renders a pill while its tool is on,
+// and the X only reports intent — removal is the host's move.
+FlowComposer(
+ onSend: send,
+ leadingActions: [
+ FlowMenu(
+ icon: PhosphorIconsRegular.plus,
+ sheetTitle: 'Add to Chat',
+ entries: [...],
+ onSelected: toggleTool,
+ ),
+ if (researchOn)
+ FlowPill(
+ icon: PhosphorIconsRegular.graduationCap,
+ label: 'Research',
+ removeTooltip: 'Turn off Research',
+ onRemove: () => setResearch(false),
+ ),
+ ],
+)
+
+// On phones the label auto-drops to the design's icon-only form;
+// showLabel forces either. No onRemove renders a static pill.
+FlowPill(
+ icon: PhosphorIconsRegular.globe,
+ label: 'Web Search',
+ showLabel: false,
+ removeTooltip: 'Turn off Web Search',
+ onRemove: () => setWebSearch(false),
+)''';
+
+/// Stage demo for `FlowPill` — removable tool pills, the forced icon-only
+/// form, the static and disabled tokens, and live wiring in a composer
+/// whose add menu toggles them.
+class PillDemo extends StatefulWidget {
+ const PillDemo({super.key, this.variant});
+
+ final String? variant;
+
+ @override
+ State createState() => _PillDemoState();
+}
+
+class _PillDemoState extends State {
+ static const double _pillGap = 8;
+
+ bool _researchOn = true;
+ bool _webSearchOn = true;
+
+ List _pills({bool? showLabel}) => [
+ if (_researchOn)
+ FlowPill(
+ icon: PhosphorIconsRegular.graduationCap,
+ label: 'Research',
+ showLabel: showLabel,
+ removeTooltip: 'Turn off Research',
+ onRemove: () => setState(() => _researchOn = false),
+ ),
+ if (_webSearchOn)
+ FlowPill(
+ icon: PhosphorIconsRegular.globe,
+ label: 'Web Search',
+ showLabel: showLabel,
+ removeTooltip: 'Turn off Web Search',
+ onRemove: () => setState(() => _webSearchOn = false),
+ ),
+ ];
+
+ /// Both pills removed: the stage resets on a variant switch, so say so
+ /// rather than standing empty.
+ Widget _row(BuildContext context, {bool? showLabel}) {
+ final pills = _pills(showLabel: showLabel);
+ if (pills.isEmpty) {
+ return Text(
+ 'Removed — switch variants to bring the pills back.',
+ textAlign: TextAlign.center,
+ style: context.flowTypography.bodyMedium.copyWith(
+ color: context.flowColors.onSurfaceMuted,
+ ),
+ );
+ }
+ return Wrap(
+ spacing: _pillGap,
+ runSpacing: _pillGap,
+ alignment: WrapAlignment.center,
+ children: pills,
+ );
+ }
+
+ Widget _composer() {
+ return FlowComposer(
+ placeholder: 'How can I help you today?',
+ onSend: (_) {},
+ leadingActions: [
+ FlowMenu(
+ icon: PhosphorIconsRegular.plus,
+ tooltip: 'Add to chat',
+ sheetTitle: 'Add to Chat',
+ entries: [
+ FlowMenuOption(
+ id: 'research',
+ icon: PhosphorIconsRegular.graduationCap,
+ label: 'Research',
+ selected: _researchOn,
+ ),
+ FlowMenuOption(
+ id: 'web-search',
+ icon: PhosphorIconsRegular.globe,
+ label: 'Web Search',
+ selected: _webSearchOn,
+ ),
+ ],
+ onSelected: (id) => setState(() {
+ if (id == 'research') _researchOn = !_researchOn;
+ if (id == 'web-search') _webSearchOn = !_webSearchOn;
+ }),
+ ),
+ ..._pills(),
+ ],
+ );
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ final child = switch (widget.variant) {
+ 'icon' => _row(context, showLabel: false),
+ 'static' => Wrap(
+ spacing: _pillGap,
+ runSpacing: _pillGap,
+ alignment: WrapAlignment.center,
+ children: [
+ const FlowPill(
+ icon: PhosphorIconsRegular.graduationCap,
+ label: 'Research',
+ ),
+ FlowPill(
+ icon: PhosphorIconsRegular.globe,
+ label: 'Web Search',
+ enabled: false,
+ removeTooltip: 'Turn off Web Search',
+ onRemove: () {},
+ ),
+ ],
+ ),
+ 'composer' => _composer(),
+ _ => _row(context),
+ };
+
+ 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 efd0aed..82f19d7 100644
--- a/playground/lib/src/playground_item.dart
+++ b/playground/lib/src/playground_item.dart
@@ -39,6 +39,7 @@ enum PlaygroundItem {
PhosphorIconsRegular.plus,
'flow_add_to_chat_menu.dart',
),
+ pill('Pill', PhosphorIconsRegular.pill, 'flow_pill.dart'),
attachments(
'Attachments',
PhosphorIconsRegular.paperclip,