From ab5775bf76b52bca3c173fe620b2f00d0929e490 Mon Sep 17 00:00:00 2001 From: Divyanshu Bhargava Date: Wed, 2 Sep 2026 13:21:19 +0530 Subject: [PATCH] feat: add FlowTool, the tool-call card, and its message part --- AGENTS.md | 2 +- CHANGELOG.md | 9 + CLAUDE.md | 2 +- README.md | 1 + docs/public/_redirects | 1 + .../docs/components/message-thread.mdx | 4 +- docs/src/content/docs/components/tool.mdx | 225 +++++++ docs/src/content/docs/roadmap.md | 2 +- docs/src/content/docs/theming.mdx | 2 +- lib/flow_ui.dart | 2 + lib/src/models/flow_message_part.dart | 94 ++- lib/src/styles/flow_tool_style.dart | 177 +++++ lib/src/theme/flow_theme.dart | 11 + lib/src/widgets/flow_message.dart | 60 +- lib/src/widgets/flow_thread.dart | 11 + lib/src/widgets/flow_tool.dart | 624 ++++++++++++++++++ playground/lib/src/demo_registry.dart | 10 + playground/lib/src/demos/tool_demo.dart | 355 ++++++++++ playground/lib/src/playground_item.dart | 1 + 19 files changed, 1574 insertions(+), 19 deletions(-) create mode 100644 docs/src/content/docs/components/tool.mdx create mode 100644 lib/src/styles/flow_tool_style.dart create mode 100644 lib/src/widgets/flow_tool.dart create mode 100644 playground/lib/src/demos/tool_demo.dart diff --git a/AGENTS.md b/AGENTS.md index 87b1059..5f462fd 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -81,7 +81,7 @@ Status legend: ⬜ Todo · ✅ Done | 14 | Menu | icon-trigger menu: groups, submenus, toggles; sheet on phones | ✅ | | 15 | Attachments | images and files, type pill; built-in picker and web file drop; videos pending | ✅ | | 16 | Preview | full-screen image viewer: zoom, paging | ✅ | -| 17 | Tool | TBD | ⬜ | +| 17 | Tool | pending, running, complete, error; morphing status mark; collapsible input/output blocks; parts render in a thread | ✅ | | 18 | Suggestion & Suggestion Group | plain & outlined rows; scroll, wrap, column | ✅ | | 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 | ✅ | diff --git a/CHANGELOG.md b/CHANGELOG.md index 536c855..b3772ce 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,15 @@ ## 0.3.0 (unreleased) +- **Tool**: `FlowTool`, the tool-call card. One mark morphs with the + call's status (a still asterisk while staged, turning while it runs, a + check or an error glyph once settled) beside the host's title and + primary argument, with the raw input and output as code blocks behind + a disclosure and a failure readable without a tap. `FlowToolPart` + renders it in a thread, keyed by the provider's call id and copying + through `FlowThread.onCodeCopy`; `FlowToolStyle` joins the component + styles with a `FlowTheme.toolStyle` default. Metrics are provisional + pending a design frame. - **Confirmation** — `FlowConfirmation`, the approval card: an asterisk-marked request with approve and reject buttons that settle into the outcome, every label host-localized. `FlowConfirmationPart` diff --git a/CLAUDE.md b/CLAUDE.md index cbf7b93..94e5562 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -83,7 +83,7 @@ Values come from the Flow UI Figma file. Role names follow Material 3's `ColorSc | 14 | Menu | icon-trigger menu: groups, submenus, toggles; sheet on phones | ✅ | | 15 | Attachments | images and files, type pill; built-in picker and web file drop; videos pending | ✅ | | 16 | Preview | full-screen image viewer: zoom, paging | ✅ | -| 17 | Tool | TBD | ⬜ | +| 17 | Tool | pending, running, complete, error; morphing status mark; collapsible input/output blocks; parts render in a thread | ✅ | | 18 | Suggestion & Suggestion Group | plain & outlined rows; scroll, wrap, column | ✅ | | 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 | ✅ | diff --git a/README.md b/README.md index 6b996dc..1270430 100644 --- a/README.md +++ b/README.md @@ -44,6 +44,7 @@ | [`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 | +| [`FlowTool`](https://flowui.stac.dev/components/tool) | Tool-call card: one status mark that settles from a turning asterisk into a check or an error glyph, the call's title and primary argument, and its input and output behind a disclosure; tool 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 a083110..35a5f7e 100644 --- a/docs/public/_redirects +++ b/docs/public/_redirects @@ -27,6 +27,7 @@ /playground/markdown /playground/ 200 /playground/error-state /playground/ 200 /playground/confirmation /playground/ 200 +/playground/tool /playground/ 200 /playground/add-to-chat /playground/ 200 /playground/pill /playground/ 200 /playground/attachments /playground/ 200 diff --git a/docs/src/content/docs/components/message-thread.mdx b/docs/src/content/docs/components/message-thread.mdx index 21452f5..f6112cd 100644 --- a/docs/src/content/docs/components/message-thread.mdx +++ b/docs/src/content/docs/components/message-thread.mdx @@ -97,8 +97,8 @@ FlowMessage( Message content is typed parts, not strings: a sealed `FlowMessagePart` with `FlowTextPart`, `FlowAttachmentPart`, `FlowImagePart`, and -`FlowCustomPart` subtypes (plus `FlowCodePart`, `FlowErrorPart` and -`FlowConfirmationPart`). +`FlowCustomPart` subtypes (plus `FlowCodePart`, `FlowErrorPart`, +`FlowConfirmationPart` and `FlowToolPart`). `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/components/tool.mdx b/docs/src/content/docs/components/tool.mdx new file mode 100644 index 0000000..be34c55 --- /dev/null +++ b/docs/src/content/docs/components/tool.mdx @@ -0,0 +1,225 @@ +--- +title: Tool +description: "The tool-call card: one mark for the call's status, its title and primary argument on a row, and the input and output behind a disclosure." +sidebar: + order: 19 +--- + +import FlowDemo from '../../../components/FlowDemo.astro'; + +`FlowTool` is the tool-call card: a record of one call the runtime made, +with a single mark for where it stands, the host's title and the call's +primary argument on the header row, and the raw input and output as code +blocks behind a disclosure. It renders state and never advances on its +own; the host re-renders the part as its provider reports each step. The +package ships no strings, so the title, the detail chip, the error +message and the section labels are all host-written. + +Status is the mark, never words: a still asterisk while the call is +staged, the thinking line's turning asterisk beside a shimmering title +while it runs, a check once it completes, the error glyph when it fails. +That leaves no status vocabulary to localize, and keeps the card quiet in +a turn that made five calls. The chrome is the code block's flat one +rather than the confirmation's raised card: a tool call is a record of +the runtime's work, where the confirmation is the host's gate that asks +for an answer. A failure reads without a tap (the message hangs under the +header, outside the disclosure), and every card lands collapsed unless +told otherwise. + +## Live run + +A call replayed: staged while its arguments stream in, then executing, +then settled with its output. Each step is a re-render with the next +status; the card animates the mark and reveals nothing on its own. + + + +```dart title="Three re-renders" +// 1. Staged: the arguments still arriving, the input block streaming. +FlowTool( + name: 'search_docs', + title: 'Searching the docs', + status: FlowToolStatus.pending, + input: '{"query": "draft pers', +) + +// 2. Executing: the asterisk turns and the title shimmers. +FlowTool( + name: 'search_docs', + title: 'Searching the docs', + detail: 'draft persistence', + status: FlowToolStatus.running, + input: '{"query": "draft persistence"}', +) + +// 3. Settled: the check lands and the output joins the disclosure. +FlowTool( + name: 'search_docs', + title: 'Searched the docs', + detail: 'draft persistence', + status: FlowToolStatus.complete, + input: '{"query": "draft persistence"}', + output: '{"count": 2, "best": "/components/composer#drafts"}', + inputLabel: 'Input', + outputLabel: 'Output', +) +``` + +## Complete + +The full anatomy, landed open: the settled check, the title, the primary +argument as a chip, then the input and output as code blocks under the +host's section labels. Copy on either block hands back a `FlowCodePart` +carrying that block's text, the same contract as any code block, so one +clipboard routine serves them all. `initiallyExpanded` is read once, when +the card mounts; the disclosure is the user's from then on, and +`onExpandedChanged` reports each toggle. + + + +```dart title="A settled call, open" +FlowTool( + name: 'search_docs', + title: 'Searched the docs', + detail: 'draft persistence', + status: FlowToolStatus.complete, + input: '{"query": "draft persistence"}', + output: '{"count": 2, "best": "/components/composer#drafts"}', + inputLabel: 'Input', + outputLabel: 'Output', + initiallyExpanded: true, + codeCopyTooltip: 'Copy', + copiedCodePart: copied, + onCodeCopy: (part) => copy(part), +) +``` + +## Running + +Executing: the thinking line's asterisk turns and breathes, and the +title sweeps from the muted ink to the full one. Pending looks the same +but still, for a call staged and waiting (its arguments arriving, or an +approval outstanding; a call that needs one is a `FlowConfirmationPart` +beside it, not a tool state). Reduced motion renders both static. + + + +```dart title="Nothing to localize" +FlowTool( + name: 'search_docs', + title: 'Searching the docs', + detail: 'draft persistence', + status: FlowToolStatus.running, + input: '{"query": "draft persistence"}', + inputLabel: 'Input', +) +``` + +## Error + +A failed call carries the error glyph, and the host's message sits under +the header where it reads without a tap. The input stays behind the +disclosure for whoever wants to see what was asked; the message is +announced as a live region, since failures arrive unprompted. + + + +```dart title="The failure, readable at a glance" +FlowTool( + name: 'fetch_weather', + title: 'Fetching the forecast', + detail: 'Berlin', + status: FlowToolStatus.error, + input: '{"city": "Berlin", "units": "metric"}', + errorMessage: 'The weather service timed out after 10 seconds.', + inputLabel: 'Input', +) +``` + +## In a thread + +A `FlowToolPart` in any turn becomes this card. The section labels are +thread-level, since they are the same words on every card; copy goes +through `FlowThread.onCodeCopy` like every code block's; and the part's +`id` (the provider's call id) keys the card, so its disclosure survives +the turn's parts changing shape as the reply streams on. Keep the +message's own status `complete` while a tool is pending or running: the +wait belongs to the part, and a `pending` message renders the thinking +indicator instead of its parts. + + + +```dart title="The host contract" +FlowThread( + messages: messages, + toolInputLabel: 'Input', + toolOutputLabel: 'Output', + codeCopyTooltip: 'Copy', + copiedCodePart: copied, + onCodeCopy: (part) => copy(part), +) + +FlowMessageData( + id: 'a1', + role: FlowMessageRole.assistant, + parts: [ + FlowTextPart('Let me check the docs and the forecast.'), + FlowToolPart( + id: 'call_1', + name: 'search_docs', + title: 'Searched the docs', + detail: 'draft persistence', + status: FlowToolStatus.complete, + input: '{"query": "draft persistence"}', + output: '{"count": 2, "best": "/components/composer#drafts"}', + ), + FlowToolPart( + id: 'call_2', + name: 'fetch_weather', + title: 'Fetching the forecast', + detail: 'Berlin', + status: FlowToolStatus.error, + input: '{"city": "Berlin", "units": "metric"}', + errorMessage: 'The weather service timed out after 10 seconds.', + ), + FlowTextPart('Drafts persist per thread. I could not reach the weather service.'), + ], +) +``` + +## Restyling + +`FlowToolStyle` carries the card's overrides. Install one on +`FlowTheme.toolStyle` 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 four accents color the mark, one per status, and never the +words: the title reads in the ink ramp, so an accent that falls short of +WCAG AA for text still signals beside it. The nested code blocks follow +`FlowTheme.codeBlockStyle`, not this style. + +```dart title="A branded running mark" +FlowTool( + name: 'search_docs', + title: 'Searching the docs', + status: FlowToolStatus.running, + style: const FlowToolStyle(runningColor: Color(0xFFB65C33)), +) +``` + +Beyond the style class, `padding:` (around the body's blocks) and +`borderRadius:` override the card's own metrics, the per-component +convention. The metrics themselves are provisional pending a design +frame. + +## Key API + +| Member | What it does | +|---|---| +| `name` / `title` / `detail` | The tool's identifier (shown in the code face without a title), the host's label, and the primary argument as a chip | +| `status` | `FlowToolStatus.pending` (a still asterisk), `running` (turning, the title shimmering), `complete` (a check), `error` (the error glyph) | +| `input` / `inputLanguage` / `output` / `outputLanguage` | Raw text for the two blocks; `inputLanguage` defaults to `json`, a null `outputLanguage` renders plain | +| `errorMessage` | Host-written failure, under the header and outside the disclosure; a live region | +| `initiallyExpanded` / `onExpandedChanged` | The disclosure: read once on mount, then the user's; each toggle reported | +| `onCodeCopy` / `copiedCodePart` / `codeCopyTooltip` | The blocks' copy contract, shared with every code block | +| `inputLabel` / `outputLabel` | Host-localized block headers; null falls back to the language id | +| `style` | `FlowToolStyle` overrides, merged over `FlowTheme.toolStyle` | diff --git a/docs/src/content/docs/roadmap.md b/docs/src/content/docs/roadmap.md index 58894b6..038501f 100644 --- a/docs/src/content/docs/roadmap.md +++ b/docs/src/content/docs/roadmap.md @@ -36,7 +36,7 @@ elements and the remaining AI states are on the way. | Menu | Shipped | | Attachments | Shipped | | Attachment preview | Shipped | -| Tool | Planned | +| Tool | Shipped | | Suggestions | Shipped | | Confirmation | Shipped | | Error state | Shipped | diff --git a/docs/src/content/docs/theming.mdx b/docs/src/content/docs/theming.mdx index 439530f..53b41a0 100644 --- a/docs/src/content/docs/theming.mdx +++ b/docs/src/content/docs/theming.mdx @@ -138,7 +138,7 @@ data bag of optional overrides (`FlowMenuStyle`, `FlowMarkdownStyle`, `FlowComposerStyle`, `FlowMessageStyle`, `FlowCodeBlockStyle`, `FlowConfirmationStyle`, `FlowErrorStateStyle`, `FlowMessageActionsStyle`, `FlowPillStyle`, -`FlowSuggestionStyle`), and the theme can carry an app-wide default for +`FlowSuggestionStyle`, `FlowToolStyle`), and the theme can carry an app-wide default for each: ```dart title="Restyle every instance once" diff --git a/lib/flow_ui.dart b/lib/flow_ui.dart index 4104faa..3b92b93 100644 --- a/lib/flow_ui.dart +++ b/lib/flow_ui.dart @@ -55,3 +55,5 @@ export 'src/widgets/flow_thinking_indicator.dart'; export 'src/widgets/flow_thread.dart'; export 'src/widgets/flow_thread_list.dart'; export 'src/styles/flow_thread_list_style.dart'; +export 'src/widgets/flow_tool.dart'; +export 'src/styles/flow_tool_style.dart'; diff --git a/lib/src/models/flow_message_part.dart b/lib/src/models/flow_message_part.dart index 1d6288f..cc0014b 100644 --- a/lib/src/models/flow_message_part.dart +++ b/lib/src/models/flow_message_part.dart @@ -5,9 +5,9 @@ import 'flow_attachment.dart'; /// One piece of content inside a `FlowMessageData`. /// -/// Sealed so renderers can switch exhaustively. More part types (tool) -/// arrive alongside their components; [FlowCustomPart] is how hosts inject -/// arbitrary content today. +/// Sealed so renderers can switch exhaustively. Each part type arrives +/// alongside its component; [FlowCustomPart] is how hosts inject anything +/// else. @immutable sealed class FlowMessagePart { const FlowMessagePart(); @@ -146,6 +146,94 @@ class FlowConfirmationPart extends FlowMessagePart { final FlowConfirmationStatus status; } +/// Where a tool call stands. +/// +/// The host owns every transition and re-renders the part; the card never +/// advances on its own. [pending] is staged — the arguments still +/// arriving, or an approval outstanding (a call that needs one is a +/// [FlowConfirmationPart] beside it, not a tool state); [running] is +/// executing; [complete] and [error] are settled. A cancelled call is the +/// host's to map: [error] with a message, or [complete]. +enum FlowToolStatus { pending, running, complete, error } + +/// One tool invocation, rendered by a `FlowTool` card. +/// +/// A record of the runtime's work, composed by the host from what its +/// provider reports: the tool's [name], the call's [status], the raw +/// [input] and [output] as text, and a [title] and [detail] in the host's +/// words. The card carries status as a mark, never as words, so nothing +/// here needs localizing beyond what the host writes itself. The section +/// labels over the input and output are thread-level +/// (`FlowThread.toolInputLabel` and friends), since they are the same +/// words on every card. +class FlowToolPart extends FlowMessagePart { + const FlowToolPart({ + required this.name, + this.id, + this.title, + this.detail, + this.input, + this.inputLanguage = 'json', + this.output, + this.outputLanguage, + this.status = FlowToolStatus.pending, + this.errorMessage, + this.semanticLabel, + }); + + /// The tool's identifier as the model called it, e.g. 'search_docs'. + /// Shown in the code face when there is no [title]. + final String name; + + /// The provider's stable id for this call. Keys the card in a thread so + /// its disclosure survives the turn's parts changing shape — and the + /// thread remounting — so pass one where the provider has one. Unique + /// within the conversation. + final String? id; + + /// Host-written label, e.g. 'Searching the docs'. The host re-renders + /// with a settled tense — 'Searched the docs' — when the call lands. + /// Null shows [name]. + final String? title; + + /// A one-line summary of the input, usually its primary argument (the + /// query, the path), shown as a chip beside the title so a collapsed + /// card still says what the call was about. + final String? detail; + + /// The arguments as raw text, usually JSON — partial while [pending], + /// rendered streaming. Null renders no input block. + final String? input; + + /// `FlowCodeLanguage` id or alias for [input]. Defaults to `'json'`; + /// `'plain'` for no highlighting. + final String inputLanguage; + + /// The result as raw text. Null renders no output block. + final String? output; + + /// `FlowCodeLanguage` id or alias for [output]. Null renders plain. + final String? outputLanguage; + + /// Drives the header's mark and the blocks' streaming treatment. + /// + /// Keep the message's own status `complete` while a tool is pending or + /// running — the wait belongs to this part, and a `pending` message + /// renders the thinking indicator instead of its parts. + final FlowToolStatus status; + + /// Why the call failed, host-written and sentence-case, to pair with + /// [FlowToolStatus.error]. Rendered under the header whenever set — + /// outside the disclosure, so a failure reads without a tap — and + /// announced as a live region. + final String? errorMessage; + + /// Read to assistive tech in place of the title, e.g. 'Searched the + /// docs, complete'. The mark carries status with no words, so this is + /// the only spoken form of it; null reads [title], then [name]. + final String? semanticLabel; +} + /// 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_tool_style.dart b/lib/src/styles/flow_tool_style.dart new file mode 100644 index 0000000..6e3eaaf --- /dev/null +++ b/lib/src/styles/flow_tool_style.dart @@ -0,0 +1,177 @@ +import 'package:material_ui/material_ui.dart'; + +/// Host overrides for [FlowTool]'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.toolStyle] to restyle +/// every tool card — tool parts in a thread included; a widget's own +/// `style` wins field by field: +/// +/// ```dart +/// FlowTool( +/// name: 'search_docs', +/// title: 'Searching the docs', +/// status: FlowToolStatus.running, +/// style: const FlowToolStyle(runningColor: Color(0xFFB65C33)), +/// ) +/// ``` +/// +/// The four accents color the header's mark, one per status — the still +/// and the turning asterisk, the settled check, the error glyph — never +/// the words: the title reads in the ink ramp, so an accent that falls +/// short of WCAG AA for text still signals beside it. The nested code +/// blocks follow `FlowTheme.codeBlockStyle`, not this style. +@immutable +class FlowToolStyle { + const FlowToolStyle({ + this.backgroundColor, + this.borderColor, + this.hoverBorderColor, + this.hoverColor, + this.pendingColor, + this.runningColor, + this.completeColor, + this.errorColor, + this.titleStyle, + this.nameStyle, + this.detailStyle, + this.detailChipColor, + this.dividerColor, + this.errorMessageStyle, + }); + + /// The card's fill. Defaults to `surfaceContainerLowest`. + final Color? backgroundColor; + + /// The card's hairline. Defaults to `outline`. + final Color? borderColor; + + /// The hairline while hovered. Defaults to `outlineVariant`; when only + /// [borderColor] is set, hover keeps that color instead of firming. + final Color? hoverBorderColor; + + /// The header row's wash while hovered. Defaults to + /// `surfaceContainerLow`. + final Color? hoverColor; + + /// The still asterisk while pending. Defaults to `onSurfaceMuted`. + final Color? pendingColor; + + /// The turning asterisk while running. Defaults to `onSurfaceMuted`. + final Color? runningColor; + + /// The settled check. Defaults to `success`. + final Color? completeColor; + + /// The error glyph. Defaults to `error`. + final Color? errorColor; + + /// Merged over the title's default `labelMedium` + state ink. Its + /// color only shows while static: the running shimmer draws with the + /// muted and full inks. + final TextStyle? titleStyle; + + /// Merged over the name's default `codeInline` + state ink, for a card + /// with no title. Same shimmer caveat as [titleStyle]. + final TextStyle? nameStyle; + + /// Merged over the detail chip's default `codeInline` + + /// `onSurfaceVariant` style. + final TextStyle? detailStyle; + + /// The detail chip's fill. Defaults to `surfaceContainer`. + final Color? detailChipColor; + + /// The rule between the header and the open body. Defaults to + /// `outline`. + final Color? dividerColor; + + /// Merged over the error message's default `bodyMedium` + + /// `onSurfaceVariant` style. + final TextStyle? errorMessageStyle; + + /// A copy where [other]'s fields win over this style's. + FlowToolStyle merge(FlowToolStyle? other) { + if (other == null) return this; + return FlowToolStyle( + backgroundColor: other.backgroundColor ?? backgroundColor, + borderColor: other.borderColor ?? borderColor, + hoverBorderColor: other.hoverBorderColor ?? hoverBorderColor, + hoverColor: other.hoverColor ?? hoverColor, + pendingColor: other.pendingColor ?? pendingColor, + runningColor: other.runningColor ?? runningColor, + completeColor: other.completeColor ?? completeColor, + errorColor: other.errorColor ?? errorColor, + titleStyle: other.titleStyle ?? titleStyle, + nameStyle: other.nameStyle ?? nameStyle, + detailStyle: other.detailStyle ?? detailStyle, + detailChipColor: other.detailChipColor ?? detailChipColor, + dividerColor: other.dividerColor ?? dividerColor, + errorMessageStyle: other.errorMessageStyle ?? errorMessageStyle, + ); + } + + /// Linear interpolation, for theme transitions. A null [other] returns + /// this style unchanged. + FlowToolStyle lerp(FlowToolStyle? other, double t) { + if (other == null) return this; + return FlowToolStyle( + backgroundColor: Color.lerp(backgroundColor, other.backgroundColor, t), + borderColor: Color.lerp(borderColor, other.borderColor, t), + hoverBorderColor: Color.lerp(hoverBorderColor, other.hoverBorderColor, t), + hoverColor: Color.lerp(hoverColor, other.hoverColor, t), + pendingColor: Color.lerp(pendingColor, other.pendingColor, t), + runningColor: Color.lerp(runningColor, other.runningColor, t), + completeColor: Color.lerp(completeColor, other.completeColor, t), + errorColor: Color.lerp(errorColor, other.errorColor, t), + titleStyle: TextStyle.lerp(titleStyle, other.titleStyle, t), + nameStyle: TextStyle.lerp(nameStyle, other.nameStyle, t), + detailStyle: TextStyle.lerp(detailStyle, other.detailStyle, t), + detailChipColor: Color.lerp(detailChipColor, other.detailChipColor, t), + dividerColor: Color.lerp(dividerColor, other.dividerColor, t), + errorMessageStyle: TextStyle.lerp( + errorMessageStyle, + other.errorMessageStyle, + t, + ), + ); + } + + @override + bool operator ==(Object other) { + if (identical(this, other)) return true; + return other is FlowToolStyle && + other.backgroundColor == backgroundColor && + other.borderColor == borderColor && + other.hoverBorderColor == hoverBorderColor && + other.hoverColor == hoverColor && + other.pendingColor == pendingColor && + other.runningColor == runningColor && + other.completeColor == completeColor && + other.errorColor == errorColor && + other.titleStyle == titleStyle && + other.nameStyle == nameStyle && + other.detailStyle == detailStyle && + other.detailChipColor == detailChipColor && + other.dividerColor == dividerColor && + other.errorMessageStyle == errorMessageStyle; + } + + @override + int get hashCode => Object.hash( + backgroundColor, + borderColor, + hoverBorderColor, + hoverColor, + pendingColor, + runningColor, + completeColor, + errorColor, + titleStyle, + nameStyle, + detailStyle, + detailChipColor, + dividerColor, + errorMessageStyle, + ); +} diff --git a/lib/src/theme/flow_theme.dart b/lib/src/theme/flow_theme.dart index 2c3dbd3..e7a609e 100644 --- a/lib/src/theme/flow_theme.dart +++ b/lib/src/theme/flow_theme.dart @@ -12,6 +12,7 @@ import '../styles/flow_message_style.dart'; import '../styles/flow_pill_style.dart'; import '../styles/flow_suggestion_style.dart'; import '../styles/flow_thread_list_style.dart'; +import '../styles/flow_tool_style.dart'; import 'flow_colors.dart'; import 'flow_syntax_colors.dart'; import 'flow_typography.dart'; @@ -58,6 +59,7 @@ class FlowTheme extends ThemeExtension { this.pillStyle, this.suggestionStyle, this.threadListStyle, + this.toolStyle, this.chatViewStyle, }); @@ -117,6 +119,10 @@ class FlowTheme extends ThemeExtension { /// App-wide default for every `FlowThreadList`. final FlowThreadListStyle? threadListStyle; + /// App-wide default for every `FlowTool` — tool parts in a thread + /// included. + final FlowToolStyle? toolStyle; + /// App-wide default for `FlowChatView.style` — the drop treatment's /// gradient, glyph and label. final FlowChatViewStyle? chatViewStyle; @@ -137,6 +143,7 @@ class FlowTheme extends ThemeExtension { FlowPillStyle? pillStyle, FlowSuggestionStyle? suggestionStyle, FlowThreadListStyle? threadListStyle, + FlowToolStyle? toolStyle, FlowChatViewStyle? chatViewStyle, }) { return FlowTheme( @@ -154,6 +161,7 @@ class FlowTheme extends ThemeExtension { pillStyle: pillStyle ?? this.pillStyle, suggestionStyle: suggestionStyle ?? this.suggestionStyle, threadListStyle: threadListStyle ?? this.threadListStyle, + toolStyle: toolStyle ?? this.toolStyle, chatViewStyle: chatViewStyle ?? this.chatViewStyle, ); } @@ -198,6 +206,9 @@ class FlowTheme extends ThemeExtension { threadListStyle: threadListStyle == null ? other.threadListStyle : threadListStyle!.lerp(other.threadListStyle, t), + toolStyle: toolStyle == null + ? other.toolStyle + : toolStyle!.lerp(other.toolStyle, t), chatViewStyle: chatViewStyle == null ? other.chatViewStyle : chatViewStyle!.lerp(other.chatViewStyle, t), diff --git a/lib/src/widgets/flow_message.dart b/lib/src/widgets/flow_message.dart index 1a3b097..7bbc215 100644 --- a/lib/src/widgets/flow_message.dart +++ b/lib/src/widgets/flow_message.dart @@ -15,6 +15,7 @@ import 'flow_error_state.dart'; import 'flow_markdown.dart'; import 'flow_streaming_text.dart'; import 'flow_thinking_indicator.dart'; +import 'flow_tool.dart'; /// Renders a [FlowCustomPart]; return null to skip it. /// @@ -40,7 +41,9 @@ typedef FlowCustomPartBuilder = /// 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]. +/// reporting through [onConfirmationRespond]; a [FlowToolPart] renders a +/// [FlowTool] card, its blocks copying through [onCodeCopy] like any code +/// part. class FlowMessage extends StatelessWidget { const FlowMessage( this.message, { @@ -61,6 +64,8 @@ class FlowMessage extends StatelessWidget { this.rejectLabel, this.approvedLabel, this.rejectedLabel, + this.toolInputLabel, + this.toolOutputLabel, this.leading, this.footer, this.maxBubbleWidthFraction = 0.75, @@ -142,6 +147,12 @@ class FlowMessage extends StatelessWidget { final String? approvedLabel; final String? rejectedLabel; + /// Host-localized section labels over a tool card's input and output + /// blocks, e.g. 'Input' and 'Output'. Null lets each block fall back to + /// its language id, as a fence does. + final String? toolInputLabel; + final String? toolOutputLabel; + /// Slot beside the content, e.g. an avatar. final Widget? leading; @@ -377,13 +388,14 @@ class FlowMessage extends StatelessWidget { textAlign: TextAlign.center, ), // System messages are centered notices; attachments, images, - // code, failures and confirmations belong to user and - // assistant turns. + // code, failures, confirmations and tool calls belong to + // user and assistant turns. FlowAttachmentPart() || FlowImagePart() || FlowCodePart() || FlowErrorPart() || - FlowConfirmationPart() => const SizedBox.shrink(), + FlowConfirmationPart() || + FlowToolPart() => const SizedBox.shrink(), FlowCustomPart() => customPartBuilder?.call(context, message, part) ?? const SizedBox.shrink(), @@ -642,16 +654,44 @@ class FlowMessage extends StatelessWidget { ? null : () => onConfirmationRespond(confirmation, false), ), + // Bound whole for the same reason; the blocks' copy intent goes + // through [onCodeCopy] like any code part's. + FlowToolPart tool => FlowTool( + name: tool.name, + title: tool.title, + detail: tool.detail, + status: tool.status, + input: tool.input, + inputLanguage: tool.inputLanguage, + output: tool.output, + outputLanguage: tool.outputLanguage, + errorMessage: tool.errorMessage, + semanticLabel: tool.semanticLabel, + inputLabel: toolInputLabel, + outputLabel: toolOutputLabel, + onCodeCopy: onCodeCopy, + copiedCodePart: copiedCodePart, + codeCopyTooltip: codeCopyTooltip, + ), FlowCustomPart() => customPartBuilder?.call(context, message, part), }; if (child == null) continue; + // One wrapper type for every part, so the column matches unkeyed + // parts by position and a keyed one by its key — which must sit on + // the outermost child, where the column sees it. A tool card is + // keyed by its call id so its disclosure survives the turn's parts + // changing shape; a PageStorageKey rather than a ValueKey, since + // the thread remounts its list at the viewport flip and the card + // reads its disclosure back through PageStorage. + final id = part is FlowToolPart ? part.id : null; children.add( - children.isEmpty - ? child - : Padding( - padding: const EdgeInsets.only(top: _partGap), - child: child, - ), + Padding( + key: id == null ? null : PageStorageKey(id), + padding: children.isEmpty + ? EdgeInsets.zero + : const EdgeInsets.only(top: _partGap), + child: child, + ), ); } diff --git a/lib/src/widgets/flow_thread.dart b/lib/src/widgets/flow_thread.dart index b33baf1..ac93b06 100644 --- a/lib/src/widgets/flow_thread.dart +++ b/lib/src/widgets/flow_thread.dart @@ -35,6 +35,8 @@ class FlowThread extends StatefulWidget { this.rejectLabel, this.approvedLabel, this.rejectedLabel, + this.toolInputLabel, + this.toolOutputLabel, this.controller, this.keyboardDismissBehavior = ScrollViewKeyboardDismissBehavior.onDrag, this.padding, @@ -115,6 +117,13 @@ class FlowThread extends StatefulWidget { final String? approvedLabel; final String? rejectedLabel; + /// Host-localized section labels for the thread's tool cards — over + /// each card's input and output blocks, e.g. 'Input' and 'Output'. + /// Forwarded to each [FlowMessage]; null lets a block fall back to its + /// language id. + final String? toolInputLabel; + final String? toolOutputLabel; + /// Optional external scroll controller. final ScrollController? controller; @@ -334,6 +343,8 @@ class _FlowThreadState extends State { rejectLabel: widget.rejectLabel, approvedLabel: widget.approvedLabel, rejectedLabel: widget.rejectedLabel, + toolInputLabel: widget.toolInputLabel, + toolOutputLabel: widget.toolOutputLabel, charactersPerSecond: widget.charactersPerSecond, thinkingLabel: widget.thinkingLabel, footer: widget.messageFooter?.call(message), diff --git a/lib/src/widgets/flow_tool.dart b/lib/src/widgets/flow_tool.dart new file mode 100644 index 0000000..412d007 --- /dev/null +++ b/lib/src/widgets/flow_tool.dart @@ -0,0 +1,624 @@ +import 'package:flutter/foundation.dart' show listEquals; +import 'package:material_ui/material_ui.dart'; + +import '../models/flow_message_part.dart'; +import '../styles/flow_tool_style.dart'; +import '../theme/flow_colors.dart'; +import '../theme/flow_theme.dart'; +import 'flow_code_block.dart'; +import 'flow_shimmer_text.dart'; +import 'flow_thinking_indicator.dart'; + +/// The tool-call card: one mark for the call's status, its title and +/// primary argument on a row, and the raw input and output behind a +/// disclosure. +/// +/// ```dart +/// FlowTool( +/// name: 'search_docs', +/// title: 'Searched the docs', +/// detail: 'draft persistence', +/// status: FlowToolStatus.complete, +/// input: '{"query": "draft persistence"}', +/// output: '{"count": 2}', +/// inputLabel: 'Input', +/// outputLabel: 'Output', +/// ) +/// ``` +/// +/// In a thread this renders on its own: a `FlowToolPart` in any turn +/// becomes this card, its blocks copying through `FlowThread.onCodeCopy` +/// like any code part and the section labels thread-level. Standalone it +/// serves hosts that show a call outside a conversation. +/// +/// The widget renders state; the host owns every status transition and +/// re-renders with the next one — the card never advances on its own. +/// Status is the header's mark, never words: a still asterisk while +/// [FlowToolStatus.pending], the thinking line's turning asterisk beside +/// a shimmering title while [FlowToolStatus.running], a check once +/// [FlowToolStatus.complete], the error glyph on [FlowToolStatus.error] — +/// so there is no status vocabulary to localize. The disclosure is the +/// one thing the card owns: collapsed unless [initiallyExpanded], toggled +/// by the header, reported through [onExpandedChanged]. +/// +/// The chrome is the code block's flat one, not the confirmation's raised +/// card: a tool call is a record of the runtime's work, where the +/// confirmation is the host's gate that asks for an answer. A failure +/// reads without a tap — [errorMessage] hangs under the header, outside +/// the disclosure. The package ships no strings: the title, the detail, +/// the message and the section labels are host-written. Metrics are +/// provisional pending a design frame. +class FlowTool extends StatefulWidget { + const FlowTool({ + super.key, + required this.name, + this.title, + this.detail, + this.status = FlowToolStatus.pending, + this.input, + this.inputLanguage = 'json', + this.output, + this.outputLanguage, + this.errorMessage, + this.inputLabel, + this.outputLabel, + this.initiallyExpanded = false, + this.onExpandedChanged, + this.onCodeCopy, + this.copiedCodePart, + this.codeCopyTooltip, + this.semanticLabel, + this.padding, + this.borderRadius, + this.style, + }); + + /// The tool's identifier as the model called it, e.g. 'search_docs'. + /// Shown in the code face when there is no [title]. + final String name; + + /// Host-written label, e.g. 'Searching the docs' — re-rendered in a + /// settled tense when the call lands. Null shows [name]. + final String? title; + + /// A one-line summary of the input, usually its primary argument, shown + /// as a chip beside the title so the collapsed card still says what the + /// call was about. + final String? detail; + + /// Drives the mark and the blocks' streaming treatment. + final FlowToolStatus status; + + /// The arguments as raw text, usually JSON. Rendered streaming while + /// [status] is pending; null renders no input block. + final String? input; + + /// `FlowCodeLanguage` id or alias for [input]; `'plain'` for none. + final String inputLanguage; + + /// The result as raw text. Rendered streaming while [status] is + /// running; null renders no output block. + final String? output; + + /// `FlowCodeLanguage` id or alias for [output]. Null renders plain. + final String? outputLanguage; + + /// Why the call failed, host-written and sentence-case. Rendered under + /// the header whenever set, outside the disclosure, and announced as a + /// live region. + final String? errorMessage; + + /// Host-localized header of the input block, e.g. 'Input'. Null falls + /// back to the block's language id, as a fence does. + final String? inputLabel; + + /// Host-localized header of the output block, e.g. 'Output'. Null falls + /// back to the language id, and with neither the block keeps only its + /// copy affordance. + final String? outputLabel; + + /// Whether the body starts open. Read once, when the card mounts — + /// changing it later does nothing, `ExpansionTile`'s contract; the + /// disclosure is the user's from then on. + final bool initiallyExpanded; + + /// The disclosure toggled by the user, handed the new state. Not called + /// on mount or restore. + final ValueChanged? onExpandedChanged; + + /// Copy intent from either block, handed a `FlowCodePart` carrying that + /// block's text — the same handler that serves code parts and fences, + /// so one host clipboard routine covers them all. Null hides both copy + /// affordances. + final ValueChanged? onCodeCopy; + + /// The part whose block shows the copied check — pass back the instance + /// received from [onCodeCopy] for as long as the confirmation should + /// last; the host owns the timing. + final FlowCodePart? copiedCodePart; + + /// Host-localized label for the blocks' copy affordance. + final String? codeCopyTooltip; + + /// Read to assistive tech in place of the title and detail, e.g. + /// 'Searched the docs, complete'. The mark carries status with no + /// words, so this is the only spoken form of it; null reads [title] (or + /// [name]) and [detail]. + final String? semanticLabel; + + /// Around the body's blocks. Defaults to the design's 12. + final EdgeInsetsGeometry? padding; + + /// The card's corner. Defaults to the design's 12. + final BorderRadius? borderRadius; + + /// Per-instance restyling, merged over [FlowTheme.toolStyle]'s fields; + /// nulls fall through to the theme tokens. + final FlowToolStyle? style; + + @override + State createState() => _FlowToolState(); +} + +class _FlowToolState extends State + with SingleTickerProviderStateMixin { + /// The card: the code block's 12px corner on the lowest wash, under the + /// faint hairline that firms while hovered. + static const BorderRadius _radius = BorderRadius.all(Radius.circular(12)); + + /// The header row: at least 36 tall — the code block header's height, + /// a minimum so large type grows it — inset 12 at the start, 10 at the + /// end where the chevron's own frame pads it, 6 above and below. + static const double _headerMinHeight = 36; + static const EdgeInsetsGeometry _headerPadding = + EdgeInsetsDirectional.fromSTEB(12, 6, 10, 6); + + /// The mark: a 16px slot, the confirmation's asterisk size, a 6 gap + /// from the title — the error card's glyph rhythm — and the same 6 + /// before the chip and the chevron. + static const double _markSize = 16; + static const double _gap = 6; + + /// The chip: the markdown inline-code chip's 4px corner, padded 4/1. + static const BorderRadius _chipRadius = BorderRadius.all(Radius.circular(4)); + static const EdgeInsetsGeometry _chipPadding = EdgeInsets.symmetric( + horizontal: 4, + vertical: 1, + ); + + /// The chevron: the menus' 18, making a half turn as the body opens. + static const double _chevronSize = 18; + static const double _chevronTurns = 0.5; + + /// The error line, hung under the header and inset to its text. + static const EdgeInsetsGeometry _errorPadding = + EdgeInsetsDirectional.fromSTEB(12, 0, 12, 10); + + /// The body: a rule above it, 12 around the blocks, 8 between them — + /// the message's part gap — and the blocks' corner a step under the + /// card's. + static const double _dividerThickness = 1; + static const EdgeInsetsGeometry _bodyPadding = EdgeInsets.all(12); + static const double _blockGap = 8; + static const BorderRadius _blockRadius = BorderRadius.all(Radius.circular(8)); + + /// The body's reveal and the chevron's turn share one clock — the + /// markdown's growth beat — and the mark crossfades at the image + /// fade's. + static const Duration _bodyDuration = Duration(milliseconds: 140); + static const Duration _markDuration = Duration(milliseconds: 180); + + late final AnimationController _controller; + late final CurvedAnimation _eased; + late bool _expanded; + bool _hovered = false; + + /// The disclosure's slot in the route's PageStorage: the chain of + /// PageStorageKeys from this card up to the bucket — the framework's + /// own identity for the spot — under a private type, so it can never + /// collide with what the blocks' scrollables keep at the same chain (a + /// scroll offset, read back as a double that a bool would break). Null + /// with no key in the chain: standalone use keeps the flag local. + _DisclosureId? _storageId; + + /// The blocks' synthesized parts, kept while their text holds so the + /// host's `identical` copied check finds them — the markdown fence's + /// idiom. A grown streaming input yields a new instance, clearing a + /// stale check. + FlowCodePart? _inputPart; + FlowCodePart? _outputPart; + + @override + void initState() { + super.initState(); + // The thread remounts its list when a conversation crosses the + // viewport — which opening this card can cause — so the flag lives in + // the route's PageStorage under the part's key, where a remount finds + // it. Without a key up the tree (standalone use) the bucket reads + // null and writes nothing, and the flag is simply local. + final storageId = _storageId = _computeStorageId(); + _expanded = + (storageId == null + ? null + : PageStorage.maybeOf( + context, + )?.readState(context, identifier: storageId) + as bool?) ?? + widget.initiallyExpanded; + _controller = AnimationController( + vsync: this, + duration: _bodyDuration, + value: _expanded ? 1 : 0, + ); + _eased = CurvedAnimation(parent: _controller, curve: Curves.easeOut); + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + _controller.duration = MediaQuery.disableAnimationsOf(context) + ? Duration.zero + : _bodyDuration; + } + + @override + void dispose() { + _eased.dispose(); + _controller.dispose(); + super.dispose(); + } + + void _toggle() { + setState(() => _expanded = !_expanded); + if (_expanded) { + _controller.forward(); + } else { + // Rebuild once dismissed so the body unmounts — its blocks stop + // costing layout, and nothing hidden stays focusable. + _controller.reverse().then((_) { + if (mounted) setState(() {}); + }); + } + final storageId = _storageId; + if (storageId != null) { + PageStorage.maybeOf( + context, + )?.writeState(context, _expanded, identifier: storageId); + } + widget.onExpandedChanged?.call(_expanded); + } + + /// The PageStorageKeys from this widget up to the nearest PageStorage, + /// the walk the bucket itself makes to name a context's entry. + _DisclosureId? _computeStorageId() { + final keys = >[]; + void collect(Widget widget) { + final key = widget.key; + if (key is PageStorageKey) keys.add(key); + } + + collect(widget); + context.visitAncestorElements((element) { + collect(element.widget); + return element.widget is! PageStorage; + }); + return keys.isEmpty ? null : _DisclosureId(keys); + } + + FlowCodePart get _inputCodePart { + final input = widget.input!; + final cached = _inputPart; + if (cached != null && + cached.code == input && + cached.language == widget.inputLanguage) { + return cached; + } + return _inputPart = FlowCodePart(input, language: widget.inputLanguage); + } + + FlowCodePart get _outputCodePart { + final output = widget.output!; + final cached = _outputPart; + if (cached != null && + cached.code == output && + cached.language == widget.outputLanguage) { + return cached; + } + return _outputPart = FlowCodePart(output, language: widget.outputLanguage); + } + + @override + Widget build(BuildContext context) { + final colors = context.flowColors; + final typography = context.flowTypography; + final style = + context.flowTheme.toolStyle?.merge(widget.style) ?? widget.style; + + final status = widget.status; + final title = widget.title; + final detail = widget.detail; + final errorMessage = widget.errorMessage; + final hasBody = widget.input != null || widget.output != null; + final running = status == FlowToolStatus.running; + final settled = + status == FlowToolStatus.complete || status == FlowToolStatus.error; + final reduceMotion = MediaQuery.disableAnimationsOf(context); + + // The mark: one 16px slot the status morphs in. Pending and running + // share the thinking line's asterisk under one key — still, then + // turning — so the flip starts the spin from upright without a + // remount; the settled glyphs crossfade in at the same size. + final Widget mark = switch (status) { + FlowToolStatus.pending || FlowToolStatus.running => FlowThinkingIndicator( + key: const ValueKey('asterisk'), + active: running, + size: _markSize, + color: running + ? style?.runningColor ?? colors.onSurfaceMuted + : style?.pendingColor ?? colors.onSurfaceMuted, + ), + FlowToolStatus.complete => Icon( + Icons.check, + key: const ValueKey('complete'), + size: _markSize, + color: style?.completeColor ?? colors.success, + ), + FlowToolStatus.error => Icon( + Icons.error_outline, + key: const ValueKey('error'), + size: _markSize, + color: style?.errorColor ?? colors.error, + ), + }; + + // The title: muted while the runtime holds the call, sweeping muted + // to full while it runs, the ramp's variant once settled — the state + // ink rides the shimmer's base, since the sweep draws with its own + // inks and a style colour only shows while static. + final titleStyle = title != null + ? typography.labelMedium.merge(style?.titleStyle) + : typography.codeInline.merge(style?.nameStyle); + final Widget titleText = DefaultTextStyle.merge( + maxLines: 1, + overflow: TextOverflow.ellipsis, + child: FlowShimmerText( + text: title ?? widget.name, + enabled: running, + style: titleStyle, + baseColor: settled ? colors.onSurfaceVariant : colors.onSurfaceMuted, + ), + ); + + Widget? chip; + if (detail != null) { + chip = Container( + padding: _chipPadding, + decoration: BoxDecoration( + color: style?.detailChipColor ?? colors.surfaceContainer, + borderRadius: _chipRadius, + ), + child: Text( + detail, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: typography.codeInline + .copyWith(color: colors.onSurfaceVariant, height: 1.3) + .merge(style?.detailStyle), + ), + ); + } + + // The chevron rides the body's clock, so one reduced-motion gate + // covers both. + Widget? chevron; + if (hasBody) { + chevron = RotationTransition( + turns: _eased.drive(Tween(begin: 0, end: _chevronTurns)), + child: Icon( + Icons.expand_more, + size: _chevronSize, + color: colors.onSurfaceMuted, + ), + ); + } + + Widget header = Container( + constraints: const BoxConstraints(minHeight: _headerMinHeight), + padding: _headerPadding, + child: Row( + children: [ + SizedBox.square( + dimension: _markSize, + child: AnimatedSwitcher( + duration: reduceMotion ? Duration.zero : _markDuration, + child: mark, + ), + ), + const SizedBox(width: _gap), + // The title and chip share the row's middle, each giving way + // at half of it under pressure, and the chevron holds the + // trailing edge. + Expanded( + child: Row( + children: [ + Flexible(child: titleText), + if (chip != null) ...[ + const SizedBox(width: _gap), + Flexible(child: chip), + ], + ], + ), + ), + if (chevron != null) ...[const SizedBox(width: _gap), chevron], + ], + ), + ); + // A transparent Material inside the decorated card, so the row's ink + // paints over the fill rather than under it — the copy button's + // idiom; the card's clip rounds the wash's corners. + header = Material( + type: MaterialType.transparency, + child: InkWell( + onTap: hasBody ? _toggle : null, + hoverColor: style?.hoverColor ?? colors.surfaceContainerLow, + child: header, + ), + ); + // Excluding the subtree keeps the title from reading twice, but it + // drops the InkWell's tap action with it — the node re-owns + // activation, and says whether the body is open. + header = Semantics( + button: hasBody, + expanded: hasBody ? _expanded : null, + label: widget.semanticLabel ?? [title ?? widget.name, ?detail].join(', '), + excludeSemantics: true, + onTap: hasBody ? _toggle : null, + child: header, + ); + + Widget? errorLine; + if (errorMessage != null) { + errorLine = Padding( + padding: _errorPadding, + // A failure lands unprompted — announce it. + child: Semantics( + liveRegion: true, + child: Text( + errorMessage, + style: typography.bodyMedium + .copyWith(color: colors.onSurfaceVariant) + .merge(style?.errorMessageStyle), + ), + ), + ); + } + + Widget? body; + if (hasBody) { + // The disclosure: a clipped box whose height factor eases between + // shut and open, the framework's own structure — AnimatedSize would + // drop the content on the first frame of a collapse. The body is + // built only while open or closing, so nothing hidden costs layout + // or takes focus. + final closed = !_expanded && _controller.isDismissed; + body = AnimatedBuilder( + animation: _eased, + builder: (context, child) => ClipRect( + child: Align( + alignment: AlignmentDirectional.topStart, + heightFactor: _eased.value, + child: child, + ), + ), + child: closed ? null : _buildBody(colors, style), + ); + } + + return MouseRegion( + onEnter: (_) => setState(() => _hovered = true), + onExit: (_) => setState(() => _hovered = false), + child: Container( + width: double.infinity, + clipBehavior: Clip.antiAlias, + decoration: BoxDecoration( + color: style?.backgroundColor ?? colors.surfaceContainerLowest, + borderRadius: widget.borderRadius ?? _radius, + // Hover lives on the edge, the code block's idiom: the hairline + // firms from `outline` to `outlineVariant`, and a style's + // borderColor holds through hover unless the style names its + // own hover edge. + border: Border.all( + color: _hovered + ? (style?.hoverBorderColor ?? + style?.borderColor ?? + colors.outlineVariant) + : (style?.borderColor ?? colors.outline), + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + mainAxisSize: MainAxisSize.min, + children: [header, ?errorLine, ?body], + ), + ), + ); + } + + /// The open body: the rule, then the blocks — input streaming while + /// the call is still being assembled, output while it runs. + Widget _buildBody(FlowColors colors, FlowToolStyle? style) { + final input = widget.input; + final output = widget.output; + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + mainAxisSize: MainAxisSize.min, + children: [ + Container( + height: _dividerThickness, + color: style?.dividerColor ?? colors.outline, + ), + Padding( + padding: widget.padding ?? _bodyPadding, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + mainAxisSize: MainAxisSize.min, + children: [ + if (input != null) + _buildBlock( + part: _inputCodePart, + label: widget.inputLabel, + streaming: widget.status == FlowToolStatus.pending, + ), + if (input != null && output != null) + const SizedBox(height: _blockGap), + if (output != null) + _buildBlock( + part: _outputCodePart, + label: widget.outputLabel, + streaming: widget.status == FlowToolStatus.running, + ), + ], + ), + ), + ], + ); + } + + /// One block, wrapping rather than scrolling: a horizontal scroll + /// inside a disclosure inside a thread is a scroll too many, and a + /// result already delivered must not hide past an edge. + Widget _buildBlock({ + required FlowCodePart part, + required String? label, + required bool streaming, + }) { + final onCodeCopy = widget.onCodeCopy; + return FlowCodeBlock( + code: part.code, + language: part.language, + filename: label, + wrap: true, + borderRadius: _blockRadius, + isStreaming: streaming, + onCopy: onCodeCopy == null ? null : () => onCodeCopy(part), + copied: identical(part, widget.copiedCodePart), + copyTooltip: widget.codeCopyTooltip, + ); + } +} + +/// A tool card's PageStorage identifier: the key chain the bucket would +/// compute for the card's context, in a type of its own. +@immutable +class _DisclosureId { + const _DisclosureId(this.keys); + + final List> keys; + + @override + bool operator ==(Object other) => + other is _DisclosureId && listEquals(other.keys, keys); + + @override + int get hashCode => Object.hashAll(keys); +} diff --git a/playground/lib/src/demo_registry.dart b/playground/lib/src/demo_registry.dart index 5e9b75b..f732ef0 100644 --- a/playground/lib/src/demo_registry.dart +++ b/playground/lib/src/demo_registry.dart @@ -20,6 +20,7 @@ import 'demos/suggestions_demo.dart'; import 'demos/thinking_indicator_demo.dart'; import 'demos/thread_demo.dart'; import 'demos/thread_list_demo.dart'; +import 'demos/tool_demo.dart'; import 'playground_item.dart'; /// The stage's demo for [item]. Keyed on the variant so switching pills @@ -40,6 +41,7 @@ Widget demoFor(PlaygroundItem item, {String? variant}) { PlaygroundItem.markdown => MarkdownDemo(key: key, variant: variant), PlaygroundItem.errorState => ErrorStateDemo(key: key, variant: variant), PlaygroundItem.confirmation => ConfirmationDemo(key: key, variant: variant), + PlaygroundItem.tool => ToolDemo(key: key, variant: variant), PlaygroundItem.addToChat => AddToChatDemo(key: key), PlaygroundItem.pill => PillDemo(key: key, variant: variant), PlaygroundItem.attachments => AttachmentsDemo(key: key, variant: variant), @@ -106,6 +108,13 @@ List<(String, String)> variantsFor(PlaygroundItem item) { ('rejected', 'Rejected'), ('thread', 'In a thread'), ], + PlaygroundItem.tool => const [ + ('live', 'Live run'), + ('complete', 'Complete'), + ('running', 'Running'), + ('error', 'Error'), + ('thread', 'In a thread'), + ], PlaygroundItem.pill => const [ ('default', 'Default'), ('icon', 'Icon only'), @@ -174,6 +183,7 @@ String snippetFor(PlaygroundItem item, {String? variant}) { PlaygroundItem.markdown => markdownSnippet(variant), PlaygroundItem.errorState => errorStateSnippet(variant), PlaygroundItem.confirmation => confirmationSnippet(variant), + PlaygroundItem.tool => toolSnippet(variant), PlaygroundItem.addToChat => addToChatSnippet, PlaygroundItem.pill => pillSnippet(variant), PlaygroundItem.attachments => attachmentsSnippet(variant), diff --git a/playground/lib/src/demos/tool_demo.dart b/playground/lib/src/demos/tool_demo.dart new file mode 100644 index 0000000..5e228a7 --- /dev/null +++ b/playground/lib/src/demos/tool_demo.dart @@ -0,0 +1,355 @@ +import 'dart:async'; +import 'dart:math' as math; + +import 'package:flow_ui/flow_ui.dart'; +import 'package:flutter/services.dart'; +import 'package:material_ui/material_ui.dart'; + +String toolSnippet([String? variant]) => switch (variant) { + 'complete' => _completeSnip, + 'running' => _runningSnip, + 'error' => _errorSnip, + 'thread' => _threadSnip, + _ => _liveSnip, +}; + +const String _liveSnip = ''' +// The card renders state and never advances on its own: the host +// re-renders the part as its provider reports each step. + +// 1. Staged: the arguments still arriving, the input block streaming. +FlowTool( + name: 'search_docs', + title: 'Searching the docs', + status: FlowToolStatus.pending, + input: '{"query": "draft pers', +) + +// 2. Executing: the asterisk turns and the title shimmers. +FlowTool( + name: 'search_docs', + title: 'Searching the docs', + detail: 'draft persistence', + status: FlowToolStatus.running, + input: '{"query": "draft persistence"}', +) + +// 3. Settled: the check lands and the output joins the disclosure. +FlowTool( + name: 'search_docs', + title: 'Searched the docs', + detail: 'draft persistence', + status: FlowToolStatus.complete, + input: '{"query": "draft persistence"}', + output: '{"count": 2, "best": "/components/composer#drafts"}', + inputLabel: 'Input', + outputLabel: 'Output', +)'''; + +const String _completeSnip = ''' +// The full anatomy, landed open: the settled check, the host's title +// and the call's primary argument on the row, then the input and +// output as code blocks behind the disclosure. Copy hands back a +// FlowCodePart, the same contract as any code block. +FlowTool( + name: 'search_docs', + title: 'Searched the docs', + detail: 'draft persistence', + status: FlowToolStatus.complete, + input: '{"query": "draft persistence"}', + output: '{"count": 2, "best": "/components/composer#drafts"}', + inputLabel: 'Input', + outputLabel: 'Output', + initiallyExpanded: true, + codeCopyTooltip: 'Copy', + copiedCodePart: copied, + onCodeCopy: (part) => copy(part), +)'''; + +const String _runningSnip = ''' +// Executing: the thinking line's asterisk turns and the title shimmers. +// Status is the mark, never words, so there is nothing to localize. +FlowTool( + name: 'search_docs', + title: 'Searching the docs', + detail: 'draft persistence', + status: FlowToolStatus.running, + input: '{"query": "draft persistence"}', + inputLabel: 'Input', +)'''; + +const String _errorSnip = ''' +// A failed call: the error glyph, and the host's message under the +// header where it reads without a tap. The input stays behind the +// disclosure for whoever wants to see what was asked. +FlowTool( + name: 'fetch_weather', + title: 'Fetching the forecast', + detail: 'Berlin', + status: FlowToolStatus.error, + input: '{"city": "Berlin", "units": "metric"}', + errorMessage: 'The weather service timed out after 10 seconds.', + inputLabel: 'Input', +)'''; + +const String _threadSnip = ''' +// In a thread the card renders on its own: a FlowToolPart in any turn +// becomes it. The section labels are thread-level, copy goes through +// the same handler as every code block, and the part's id keys the +// card so its disclosure survives the turn growing. +FlowThread( + messages: messages, + toolInputLabel: 'Input', + toolOutputLabel: 'Output', + codeCopyTooltip: 'Copy', + copiedCodePart: copied, + onCodeCopy: (part) => copy(part), +) + +FlowMessageData( + id: 'a1', + role: FlowMessageRole.assistant, + parts: [ + FlowTextPart('Let me check the docs and the forecast.'), + FlowToolPart( + id: 'call_1', + name: 'search_docs', + title: 'Searched the docs', + detail: 'draft persistence', + status: FlowToolStatus.complete, + input: '{"query": "draft persistence"}', + output: '{"count": 2, "best": "/components/composer#drafts"}', + ), + FlowToolPart( + id: 'call_2', + name: 'fetch_weather', + title: 'Fetching the forecast', + detail: 'Berlin', + status: FlowToolStatus.error, + input: '{"city": "Berlin", "units": "metric"}', + errorMessage: 'The weather service timed out after 10 seconds.', + ), + FlowTextPart('Drafts persist per thread. I could not reach the ' + 'weather service.'), + ], +)'''; + +const String _searchInput = ''' +{ + "query": "draft persistence", + "limit": 5 +}'''; + +const String _searchOutput = ''' +{ + "count": 2, + "results": [ + {"title": "Composer drafts", "path": "/components/composer#drafts"}, + {"title": "Restoring a thread", "path": "/components/message-thread#restore"} + ] +}'''; + +const String _weatherInput = ''' +{ + "city": "Berlin", + "units": "metric" +}'''; + +const String _weatherError = 'The weather service timed out after 10 seconds.'; + +/// Stage demo for `FlowTool` — a live run replayed on a loop, the three +/// settled forms, and a turn in a thread carrying two calls, one of which +/// failed. +class ToolDemo extends StatefulWidget { + const ToolDemo({super.key, this.variant}); + + final String? variant; + + @override + State createState() => _ToolDemoState(); +} + +class _ToolDemoState extends State { + /// The live run's clock: the input fed three characters a beat while + /// staged, then the two settles, a pause, and around again. + static const Duration _feedTick = Duration(milliseconds: 45); + static const int _feedStep = 3; + static const Duration _runDelay = Duration(milliseconds: 600); + static const Duration _settleDelay = Duration(milliseconds: 1400); + static const Duration _restartDelay = Duration(milliseconds: 2400); + static const Duration _copiedHold = Duration(milliseconds: 1500); + + FlowToolStatus _status = FlowToolStatus.pending; + int _fed = 0; + Timer? _feed; + Timer? _step; + Timer? _copiedReset; + FlowCodePart? _copied; + + bool get _live => widget.variant == null || widget.variant == 'live'; + + @override + void initState() { + super.initState(); + if (_live) _startRun(); + } + + @override + void dispose() { + _feed?.cancel(); + _step?.cancel(); + _copiedReset?.cancel(); + super.dispose(); + } + + void _startRun() { + _feed?.cancel(); + _step?.cancel(); + _status = FlowToolStatus.pending; + _fed = 0; + _feed = Timer.periodic(_feedTick, (timer) { + if (!mounted) return; + setState(() => _fed = math.min(_fed + _feedStep, _searchInput.length)); + if (_fed < _searchInput.length) return; + timer.cancel(); + _step = Timer(_runDelay, () { + if (!mounted) return; + setState(() => _status = FlowToolStatus.running); + _step = Timer(_settleDelay, () { + if (!mounted) return; + setState(() => _status = FlowToolStatus.complete); + _step = Timer(_restartDelay, () { + if (mounted) setState(_startRun); + }); + }); + }); + }); + } + + void _copy(FlowCodePart part) { + Clipboard.setData(ClipboardData(text: part.code)); + _copiedReset?.cancel(); + setState(() => _copied = part); + _copiedReset = Timer(_copiedHold, () { + if (mounted) setState(() => _copied = null); + }); + } + + /// Thread variant: a turn that made two calls, one of which failed, + /// and the prose that follows them — the shape of an agent's turn. + List get _messages => [ + FlowMessageData.text( + id: 'u1', + role: FlowMessageRole.user, + text: + 'Do drafts persist between sessions? And is it raining in ' + 'Berlin?', + ), + const FlowMessageData( + id: 'a1', + role: FlowMessageRole.assistant, + parts: [ + FlowTextPart('Let me check the docs and the forecast.'), + FlowToolPart( + id: 'call_1', + name: 'search_docs', + title: 'Searched the docs', + detail: 'draft persistence', + status: FlowToolStatus.complete, + input: _searchInput, + output: _searchOutput, + ), + FlowToolPart( + id: 'call_2', + name: 'fetch_weather', + title: 'Fetching the forecast', + detail: 'Berlin', + status: FlowToolStatus.error, + input: _weatherInput, + errorMessage: _weatherError, + ), + FlowTextPart( + 'Drafts persist per thread: the composer keeps what you typed ' + 'and restores it when you come back. I could not reach the ' + 'weather service, so I cannot say whether it is raining.', + ), + ], + ), + ]; + + @override + Widget build(BuildContext context) { + final complete = _status == FlowToolStatus.complete; + final pending = _status == FlowToolStatus.pending; + final child = switch (widget.variant) { + 'complete' => FlowTool( + name: 'search_docs', + title: 'Searched the docs', + detail: 'draft persistence', + status: FlowToolStatus.complete, + input: _searchInput, + output: _searchOutput, + inputLabel: 'Input', + outputLabel: 'Output', + initiallyExpanded: true, + codeCopyTooltip: 'Copy', + copiedCodePart: _copied, + onCodeCopy: _copy, + ), + 'running' => const FlowTool( + name: 'search_docs', + title: 'Searching the docs', + detail: 'draft persistence', + status: FlowToolStatus.running, + input: _searchInput, + inputLabel: 'Input', + ), + 'error' => const FlowTool( + name: 'fetch_weather', + title: 'Fetching the forecast', + detail: 'Berlin', + status: FlowToolStatus.error, + input: _weatherInput, + errorMessage: _weatherError, + inputLabel: 'Input', + ), + 'thread' => SizedBox( + height: 420, + child: FlowThread( + messages: _messages, + toolInputLabel: 'Input', + toolOutputLabel: 'Output', + codeCopyTooltip: 'Copy', + copiedCodePart: _copied, + onCodeCopy: _copy, + ), + ), + _ => FlowTool( + name: 'search_docs', + title: complete ? 'Searched the docs' : 'Searching the docs', + detail: pending ? null : 'draft persistence', + status: _status, + input: !pending + ? _searchInput + : _fed == 0 + ? null + : _searchInput.substring(0, _fed), + output: complete ? _searchOutput : null, + inputLabel: 'Input', + outputLabel: 'Output', + codeCopyTooltip: 'Copy', + copiedCodePart: _copied, + onCodeCopy: _copy, + ), + }; + + 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 a1b678f..7bbc1e4 100644 --- a/playground/lib/src/playground_item.dart +++ b/playground/lib/src/playground_item.dart @@ -40,6 +40,7 @@ enum PlaygroundItem { PhosphorIconsRegular.shieldCheck, 'flow_confirmation.dart', ), + tool('Tool', PhosphorIconsRegular.wrench, 'flow_tool.dart'), addToChat( 'Add to Chat', PhosphorIconsRegular.plus,