From a43748e2bd8a8b5cc7793c991025721d09778e93 Mon Sep 17 00:00:00 2001 From: Divyanshu Bhargava Date: Fri, 25 Sep 2026 22:56:35 +0530 Subject: [PATCH 1/2] feat: add a compact composer layout and a fixed-height mode --- AGENTS.md | 2 +- CLAUDE.md | 2 +- docs/src/content/docs/components/composer.mdx | 95 ++- packages/flow_ui/CHANGELOG.md | 6 + packages/flow_ui/README.md | 4 +- packages/flow_ui/example/lib/main.dart | 2 + .../lib/src/widgets/flow_composer.dart | 539 ++++++++++++------ packages/stacflow/CHANGELOG.md | 2 + .../lib/src/chat/stacflow_chat_view.dart | 5 + packages/stacflow/lib/src/strings.dart | 2 + playground/lib/src/demo_registry.dart | 2 + playground/lib/src/demos/composer_demo.dart | 88 ++- playground/lib/src/demos/full_chat_demo.dart | 2 + 13 files changed, 568 insertions(+), 183 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 8cc12c1..cba78d4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -98,7 +98,7 @@ Status legend: ⬜ Todo · ✅ Done | 9 | Thread List | host-labeled sections; unread dot, pinned glyph, leading icon slot; single selection by id | ✅ | | 10 | Message actions | | ✅ | | 11 | Streaming text | | ✅ | -| 12 | Message composer | | ✅ | +| 12 | Message composer | full card; compact single-row pill that opens into the card as the draft grows; expands to fill a fixed height | ✅ | | 13 | Model selector | effort & overflow submenus; sheet on phones | ✅ | | 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 | ✅ | diff --git a/CLAUDE.md b/CLAUDE.md index 602895c..a370ee2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -102,7 +102,7 @@ Values come from the Flow UI Figma file. Role names follow Material 3's `ColorSc | 9 | Thread List | host-labeled sections; unread dot, pinned glyph, leading icon slot; single selection by id | ✅ | | 10 | Message actions | | ✅ | | 11 | Streaming text | | ✅ | -| 12 | Message composer | | ✅ | +| 12 | Message composer | full card; compact single-row pill that opens into the card as the draft grows; expands to fill a fixed height | ✅ | | 13 | Model selector | effort & overflow submenus; sheet on phones | ✅ | | 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 | ✅ | diff --git a/docs/src/content/docs/components/composer.mdx b/docs/src/content/docs/components/composer.mdx index 5f17161..3637187 100644 --- a/docs/src/content/docs/components/composer.mdx +++ b/docs/src/content/docs/components/composer.mdx @@ -13,10 +13,12 @@ the draft, a send button that becomes **stop** while the model is generating, and slots for host actions on the design's rail. `onSend` is required — a composer needs somewhere to send to. The card sits on `surfaceBright` behind a gradient hairline that firms on hover and focus, -standing at the design's 116px with an empty draft — which also drains the -send disc to the disabled ink. The whole card is the field's click target: +its field a single line with an empty draft — which also drains the send +disc to the disabled ink. The whole card is the field's click target: a click anywhere on it that is not a control focuses the field, and the -pointer reads as a text cursor over all of it. +pointer reads as a text cursor over all of it. For simpler flows there is +also a [compact](#compact) layout: a single-row pill that opens into this +card as the draft grows. ## Composed @@ -63,23 +65,97 @@ FlowComposer( ) ``` +## Compact + +`layout: FlowComposerLayout.compact` folds the card into a single row: the +attach button and `leadingActions` on the left, the field in the middle, +`trailingActions` and send on the right, and no action row beneath. Only +what you wire is drawn, so the bare form is a field and a send button in a +50px pill. + + + +```dart title="The same composer, one row" +FlowComposer( + layout: FlowComposerLayout.compact, + onSend: send, + onAttachmentsPicked: (picked) => setState(() => pending.addAll(picked)), + attachTooltip: 'Attach files', + attachments: pending, + onRemoveAttachment: removePending, +) +``` + +Attachments don't change the layout: the pending strip sits above the row +inside the card, and the field stays inline with the controls. The pill +opens into the full card by itself only when the draft needs the room, once +it wraps past one line from a long line or an explicit newline. It folds +back when the draft is empty, which is what a send leaves behind with +`clearOnSend`, so deleting back to one line never moves the controls out +from under the caret. The field keeps its focus and its draft through both. + +`layout` is the form the composer takes with an empty draft. To open the +card by hand, from a button of your own, flip it to +`FlowComposerLayout.expanded`. + + + +## Fixed height + +The card normally hugs its draft: one line when empty, growing to +`maxLines`. For a composer at a fixed height, set `expands: true` and size +it from the parent. The card fills the height it is given, and the field +takes the space above the action row and scrolls inside it, so `maxLines` +no longer applies. + + + +```dart title="Filling a 240px box" +SizedBox( + height: 240, + child: FlowComposer( + expands: true, + onSend: send, + ), +) +``` + +Like `TextField.expands`, it needs a bounded height from its parent, so +inside a `Column` wrap it in `Expanded`. It works with the full card only: +the compact layout is a single row, and combining the two asserts. +`FlowChatView` docks its composer at its natural height, so `expands` is +for a composer you place and size yourself. + ## Send and stop `isStreaming` drives the send button: `false` shows send, `true` swaps it for stop and routes taps to `onStop`. Flip it from your generation state and the composer stays honest about what a tap will do. +Name the two buttons with `sendTooltip` and `stopTooltip`. flow_ui ships +no copy, so the words are yours: they show on hover and are what a screen +reader announces, and both buttons read as buttons with their enabled +state. + ## Typing On hardware keyboards **Enter sends** and **Shift+Enter** inserts a newline — `submitOnEnter: false` opts out; mobile soft keyboards keep -their newline key and send from the button. The field grows with the draft +their newline key and send from the button. While an input method is +composing, Enter confirms the composition instead of sending, and while a +reply streams it does nothing rather than adding a newline. The field grows with the draft to `maxLines` (6) and scrolls beyond it, and `clearOnSend` empties it after a successful send. `onSend` receives the trimmed text, and fires empty only when something is attached — a picture with no caption is -still a message. +still a message. In the [compact](#compact) layout the field holds a +single line, and the pill opens into the full card the moment the draft +wraps. ## Attach @@ -345,9 +421,16 @@ wins field by field. ## Key API - `onSend` — required; receives the trimmed draft, never empty text. -- `isStreaming` + `onStop` — the send/stop swap. +- `isStreaming` + `onStop` — the send/stop swap; `sendTooltip` and + `stopTooltip` name the two buttons. - `enabled`, `submitOnEnter`, `clearOnSend`, `maxLines` — field behavior; all default on, with a 6-line growth cap. +- `layout` — `FlowComposerLayout.expanded`, the card and the default, or + `FlowComposerLayout.compact`, the single-row pill that opens into the + card as the draft grows and folds back once it is empty. +- `expands` — fills the height the parent gives the card, the field taking + the space above the action row and scrolling inside it; needs a bounded + height and the full card. - `leadingActions` / `trailingActions` — widget slots on the input rail; any widget works, the flow_ui menus just fit the design out of the box. - `attachments`, `onRemoveAttachment`, `removeAttachmentTooltip` — the diff --git a/packages/flow_ui/CHANGELOG.md b/packages/flow_ui/CHANGELOG.md index 08cf7ef..7ad47ea 100644 --- a/packages/flow_ui/CHANGELOG.md +++ b/packages/flow_ui/CHANGELOG.md @@ -4,6 +4,12 @@ - Makes `FlowTypography.standard` and `FlowTheme.typography` `const` again, and `FlowTypography.code` / `codeInline` plain fields with const defaults. - Deprecates `FlowTypography.recut` in favour of `copyWith(fontWeight:, fontStyle:)` — the bundled families declare every cut, so the engine resolves it from the weight on the style. Removed in 0.6.0. - Adds 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), the host's title and primary argument sit on the row, and the input and output render as code blocks behind a disclosure, with 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. +- Adds a compact layout to FlowComposer: `layout: FlowComposerLayout.compact` folds the card into a single-row pill, with the attach button, `leadingActions`, the field, `trailingActions` and send inline, no action row, and nothing drawn for what is not wired. Pending attachments sit in a strip above the row, the field still inline. It opens into the full card by itself once the draft wraps past one line, and folds back when the draft is empty, keeping the field's focus and draft through both. `FlowComposerLayout.expanded`, the default, is unchanged. Metrics are provisional pending a design frame. +- Tightens the full FlowComposer card: the field no longer keeps a 38px floor under a one-line draft, and the gap above the action row drops from 16 to 12, so an empty card stands at about 95px instead of 116. +- Adds FlowComposer.expands for a composer at a fixed height: the card fills the height its parent gives it, and the field takes the space above the action row and scrolls inside it. Like TextField.expands it needs a bounded height, and it works with the full card only. +- Adds FlowComposer.sendTooltip and stopTooltip, host-localized names for the send and stop buttons. Both now read as buttons with their enabled state, so a disabled send button no longer drops out of the accessibility tree. +- Fixes Enter sending the draft while an input method is still composing: Enter now confirms the composition, as in any text field. +- Enter no longer adds a newline while a reply streams, or when held down after a send; Shift+Enter still inserts one. ## 0.3.0 diff --git a/packages/flow_ui/README.md b/packages/flow_ui/README.md index 91eb94a..80d70d2 100644 --- a/packages/flow_ui/README.md +++ b/packages/flow_ui/README.md @@ -47,7 +47,7 @@ | [`FlowToast`](https://flowui.stac.dev/components/toast) | Floating notice: a glyph, one wrapping line and a cross on a frosted card; `showFlowToast` floats it in the nearest Overlay, stacked and auto-dismissing, with a handle to dismiss it early | | [`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 | +| [`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; a full card, or a compact single-row pill that opens into it as the draft grows | | [`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 | @@ -187,6 +187,8 @@ class _ChatPageState extends State { isStreaming: _generating, onSend: _send, onStop: myBackend.stop, + sendTooltip: 'Send', + stopTooltip: 'Stop', ), ), ); diff --git a/packages/flow_ui/example/lib/main.dart b/packages/flow_ui/example/lib/main.dart index e507399..3749793 100644 --- a/packages/flow_ui/example/lib/main.dart +++ b/packages/flow_ui/example/lib/main.dart @@ -499,6 +499,8 @@ class _ChatScreenState extends State { isStreaming: _generating, onSend: _send, onStop: _stop, + sendTooltip: 'Send', + stopTooltip: 'Stop', // Picking goes through the "+" menu below; paste and drop land // in the same place — three ways in, one handler. onAttachmentsPasted: _addAttachments, diff --git a/packages/flow_ui/lib/src/widgets/flow_composer.dart b/packages/flow_ui/lib/src/widgets/flow_composer.dart index 4e4e459..489d76a 100644 --- a/packages/flow_ui/lib/src/widgets/flow_composer.dart +++ b/packages/flow_ui/lib/src/widgets/flow_composer.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'dart:math' as math; +import 'package:flutter/rendering.dart' show RenderProxyBox; import 'package:flutter/services.dart'; import 'package:material_ui/material_ui.dart'; @@ -18,6 +19,8 @@ import 'flow_attachment_group.dart'; import 'flow_drop_target.dart'; import 'flow_pill.dart'; +enum FlowComposerLayout { expanded, compact } + /// The message input area: an auto-growing text field with an action bar /// and a send button that morphs into stop while [isStreaming]. /// @@ -82,6 +85,16 @@ import 'flow_pill.dart'; /// Once something is pending, an empty field is no longer nothing to /// send: [onSend] fires with an empty string, because a picture with no /// caption is a message. +/// +/// ## Compact +/// +/// [layout] set to [FlowComposerLayout.compact] folds the card into one +/// row: the attach button and [leadingActions], the field, then +/// [trailingActions] and send, with no action bar beneath. Pending +/// [attachments] sit in a strip above that row, the field still inline. +/// It opens into the full card on its own once the draft wraps past one +/// line, and folds back when the draft is empty. The field keeps its focus +/// and draft through both. class FlowComposer extends StatefulWidget { const FlowComposer({ super.key, @@ -95,6 +108,8 @@ class FlowComposer extends StatefulWidget { this.clearOnSend = true, this.submitOnEnter = true, this.maxLines = 6, + this.layout = FlowComposerLayout.expanded, + this.expands = false, this.attachments = const [], this.onRemoveAttachment, this.onAttachmentTap, @@ -108,6 +123,8 @@ class FlowComposer extends StatefulWidget { this.onAttachmentRejected, this.attachmentsEnabled = true, this.attachTooltip, + this.sendTooltip, + this.stopTooltip, this.onContentInserted, this.errorMessage, this.errorIcon, @@ -124,6 +141,12 @@ class FlowComposer extends StatefulWidget { 'Pass onAttach or onAttachmentsPicked, not both: there is one ' 'attach button and it can only have one owner. onAttach means the ' 'host picks; onAttachmentsPicked means the package does.', + ), + assert( + !expands || layout == FlowComposerLayout.expanded, + 'expands fills a fixed height with the full card, and the compact ' + 'layout is a single row. Pass layout: FlowComposerLayout.expanded ' + 'with expands: true.', ); /// Called with the trimmed text — empty only when [attachments] is not, @@ -158,6 +181,10 @@ class FlowComposer extends StatefulWidget { /// Auto-grow cap; the field scrolls beyond it. final int maxLines; + final FlowComposerLayout layout; + + final bool expands; + /// Pending attachments, shown above the input. Empty renders nothing. final List attachments; @@ -277,6 +304,10 @@ class FlowComposer extends StatefulWidget { /// name. final String? attachTooltip; + final String? sendTooltip; + + final String? stopTooltip; + /// Raises the error banner: the design's tab above the card, in the /// error wash with a warning glyph and this line. Null draws nothing. /// @@ -350,7 +381,7 @@ class _FlowComposerState extends State { static const double _contentInset = 18; static const double _actionInset = 10; static const double _attachmentGap = 12; - static const double _fieldGap = 16; + static const double _fieldGap = 12; static const double _leadingGap = 4; static const double _trailingGap = 8; @@ -359,10 +390,6 @@ class _FlowComposerState extends State { static const double _pillGap = 8; static const double _mobilePillGap = 6; - /// The field's floor, sized so an empty composer stands at the design's - /// 116px: 19 + 38 + 16 (gap) + 32 (action row) + 11. - static const double _fieldMinHeight = 38; - /// The design's outline: a 1px hairline over the ink, sweeping from the /// top-left toward the bottom-right where it thins — 14% → 8% at rest, /// 20% → 12% while the composer is hovered or focused. @@ -414,6 +441,15 @@ class _FlowComposerState extends State { static const double _errorTextHeight = 1.3; static const Duration _errorReveal = Duration(milliseconds: 150); + static const EdgeInsetsGeometry _compactCardPadding = EdgeInsets.symmetric( + horizontal: 1, + vertical: 9, + ); + static const double _compactInset = 8; + static const double _compactFieldInset = 10; + static const double _cursorWidth = 2; + static const double _caretGap = 1; + TextEditingController? _internalController; FocusNode? _internalFocusNode; late FocusNode _attachedFocusNode; @@ -424,6 +460,10 @@ class _FlowComposerState extends State { bool _dropHover = false; bool _disposed = false; FlowPasteRegistration? _pasteRegistration; + late TextEditingController _attachedController; + final GlobalKey _fieldKey = GlobalKey(); + bool _promoted = false; + double? _fieldWidth; TextEditingController get _controller => widget.controller ?? (_internalController ??= TextEditingController()); @@ -435,6 +475,8 @@ class _FlowComposerState extends State { void initState() { super.initState(); _attachedFocusNode = _focusNode..addListener(_handleFocusChange); + _attachedController = _controller..addListener(_syncPromotion); + _promoted = _wantsPromotion(context); _registerPaste(); } @@ -446,6 +488,11 @@ class _FlowComposerState extends State { _attachedFocusNode = _focusNode..addListener(_handleFocusChange); _handleFocusChange(); } + if (_controller != _attachedController) { + _attachedController.removeListener(_syncPromotion); + _attachedController = _controller..addListener(_syncPromotion); + } + _promoted = _wantsPromotion(context); if ((widget.onAttachmentsPasted == null) != (oldWidget.onAttachmentsPasted == null)) { _registerPaste(); @@ -457,6 +504,7 @@ class _FlowComposerState extends State { _disposed = true; _pasteRegistration?.dispose(); _attachedFocusNode.removeListener(_handleFocusChange); + _attachedController.removeListener(_syncPromotion); _internalController?.dispose(); _internalFocusNode?.dispose(); super.dispose(); @@ -524,10 +572,61 @@ class _FlowComposerState extends State { } } + bool _wantsPromotion(BuildContext context) { + if (widget.layout == FlowComposerLayout.expanded) return false; + final text = _controller.text; + if (text.isEmpty) return false; + if (_promoted || text.contains('\n')) return true; + final width = _fieldWidth; + return width != null && _wraps(context, text, width); + } + + bool _wraps(BuildContext context, String text, double width) { + final painter = TextPainter( + text: TextSpan(text: text, style: _measuredStyle(context)), + maxLines: 1, + textDirection: Directionality.of(context), + textScaler: MediaQuery.textScalerOf(context), + locale: Localizations.maybeLocaleOf(context), + )..layout(maxWidth: math.max(0.0, width - _caretGap - _cursorWidth)); + final wraps = painter.didExceedMaxLines; + painter.dispose(); + return wraps; + } + + void _syncPromotion() { + if (!mounted) return; + final promoted = _wantsPromotion(context); + if (promoted != _promoted) setState(() => _promoted = promoted); + } + + void _handleFieldWidth(double width) { + if (!mounted) return; + _fieldWidth = width; + _syncPromotion(); + } + /// The effective style: the widget's over the theme's, tokens beneath. FlowComposerStyle? _styleOf(BuildContext context) => context.flowTheme.composerStyle?.merge(widget.style) ?? widget.style; + TextStyle _fieldStyle(BuildContext context) => context + .flowTypography + .bodyLarge + .copyWith(height: 1.3, color: context.flowColors.onSurface) + .merge(_styleOf(context)?.textStyle); + + TextStyle _measuredStyle(BuildContext context) { + final theme = Theme.of(context); + final base = theme.useMaterial3 + ? theme.textTheme.bodyLarge + : theme.textTheme.titleMedium; + final style = (base ?? const TextStyle()).merge(_fieldStyle(context)); + return MediaQuery.boldTextOf(context) + ? style.merge(const TextStyle(fontWeight: FontWeight.bold)) + : style; + } + /// The theme's platform rather than the real one, like the menus' sheet /// resolution, so hosts and tests can steer it without a device. static bool _isMobile(BuildContext context) { @@ -580,19 +679,18 @@ class _FlowComposerState extends State { } KeyEventResult _handleKeyEvent(FocusNode node, KeyEvent event) { - if (!widget.submitOnEnter || widget.isStreaming) { - return KeyEventResult.ignored; - } final isEnter = event.logicalKey == LogicalKeyboardKey.enter || event.logicalKey == LogicalKeyboardKey.numpadEnter; - if (event is KeyDownEvent && - isEnter && - !HardwareKeyboard.instance.isShiftPressed) { - _send(); - return KeyEventResult.handled; + if (!widget.submitOnEnter || + !isEnter || + event is KeyUpEvent || + HardwareKeyboard.instance.isShiftPressed || + _controller.value.isComposingRangeValid) { + return KeyEventResult.ignored; } - return KeyEventResult.ignored; + if (event is KeyDownEvent && !widget.isStreaming) _send(); + return KeyEventResult.handled; } /// The design's button anatomy: the disc floats inside a gap of the @@ -680,15 +778,19 @@ class _FlowComposerState extends State { final discColor = style?.sendBackgroundColor ?? colors.primary; final glyphColor = style?.sendForegroundColor ?? colors.onPrimary; if (widget.isStreaming) { - return _ringed( - context, - active: true, - disc: FlowCircleButton( - icon: Icons.stop_rounded, - background: discColor, - foreground: glyphColor, - padding: _stopPadding, - onTap: widget.onStop, + return _buttonSemantics( + label: widget.stopTooltip, + enabled: widget.onStop != null, + child: _ringed( + context, + active: true, + disc: FlowCircleButton( + icon: Icons.stop_rounded, + background: discColor, + foreground: glyphColor, + padding: _stopPadding, + onTap: widget.onStop, + ), ), ); } @@ -696,22 +798,26 @@ class _FlowComposerState extends State { valueListenable: _controller, builder: (context, value, _) { final canSend = _canSend(value.text.trim()); - return _ringed( - context, - active: canSend, - disc: Material( - // Disabled keeps the arrow's ink and only drains the disc: - // primary gives way to the 30% disabled wash. - color: canSend ? discColor : colors.onSurfaceDisabled, - shape: const CircleBorder(), - clipBehavior: Clip.antiAlias, - child: InkWell( - onTap: canSend ? _send : null, - customBorder: const CircleBorder(), - child: CustomPaint( - // The design's arrow is a thin stroke, not the chunky - // Material glyph. - painter: _ArrowUpPainter(color: glyphColor), + return _buttonSemantics( + label: widget.sendTooltip, + enabled: canSend, + child: _ringed( + context, + active: canSend, + disc: Material( + // Disabled keeps the arrow's ink and only drains the disc: + // primary gives way to the 30% disabled wash. + color: canSend ? discColor : colors.onSurfaceDisabled, + shape: const CircleBorder(), + clipBehavior: Clip.antiAlias, + child: InkWell( + onTap: canSend ? _send : null, + customBorder: const CircleBorder(), + child: CustomPaint( + // The design's arrow is a thin stroke, not the chunky + // Material glyph. + painter: _ArrowUpPainter(color: glyphColor), + ), ), ), ), @@ -720,6 +826,22 @@ class _FlowComposerState extends State { ); } + Widget _buttonSemantics({ + required String? label, + required bool enabled, + required Widget child, + }) { + final button = Semantics( + container: true, + button: true, + enabled: enabled, + label: label, + child: child, + ); + if (label == null) return button; + return Tooltip(message: label, excludeFromSemantics: true, child: button); + } + /// The error banner: a tab in the error wash with matching hairline, /// open at the bottom where it meets the card. The line starts beside /// the glyph and wraps from it when long. @@ -828,7 +950,10 @@ class _FlowComposerState extends State { ? const SizedBox.shrink() : _buildErrorBanner(context, errorMessage), ), - _buildCard(context), + if (widget.expands) + Expanded(child: _buildCard(context)) + else + _buildCard(context), ], ); } @@ -837,11 +962,11 @@ class _FlowComposerState extends State { /// the composer's drop target. Widget _buildCard(BuildContext context) { final colors = context.flowColors; - final typography = context.flowTypography; final active = widget.enabled && (_focused || _hovered); final radius = widget.borderRadius ?? _cardRadius; final style = _styleOf(context); + final expanded = widget.layout == FlowComposerLayout.expanded || _promoted; // A style's outline flattens the default gradient to one solid color // in every state, like the menu card's border override does. final outline = style?.outlineColor; @@ -933,138 +1058,12 @@ class _FlowComposerState extends State { BoxShadow(color: colors.shadow, blurRadius: _shadowBlur), ], ), - padding: widget.padding ?? _cardPadding, - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - if (widget.attachments.isNotEmpty) ...[ - // The inset rides inside the strip's scroll view rather - // than around it: at rest nothing moves, but once the - // strip overflows the tiles scroll under the gutter and - // the last one is cut at the card's edge — the cue that - // there is more, with no counter to draw. - FlowAttachmentGroup( - attachments: widget.attachments, - padding: const EdgeInsets.symmetric( - horizontal: _contentInset, - ), - onTap: widget.onAttachmentTap, - // Editing is what `enabled` gates; viewing an attachment - // that is already pending stays available, as does the - // send button while streaming. - onRemove: widget.enabled - ? widget.onRemoveAttachment - : null, - removeTooltip: widget.removeAttachmentTooltip, - previewCloseTooltip: widget.previewCloseTooltip, - ), - const SizedBox(height: _attachmentGap), - ], - Padding( - padding: const EdgeInsets.symmetric( - horizontal: _contentInset, - ), - child: Container( - constraints: const BoxConstraints( - minHeight: _fieldMinHeight, - ), - alignment: AlignmentDirectional.topStart, - child: Focus( - onKeyEvent: _handleKeyEvent, - // The caret and the highlight in the theme's - // selection colours, matching the thread's; a - // host's own textSelectionTheme still wins. - child: TextSelectionTheme( - data: flowTextSelectionTheme(context), - child: TextField( - controller: _controller, - focusNode: _focusNode, - enabled: widget.enabled, - minLines: 1, - maxLines: widget.maxLines, - // The design's compressed composer: body face on - // the 1.3 control line, so the empty card stands - // at 116. - style: typography.bodyLarge - .copyWith( - height: 1.3, - color: colors.onSurface, - ) - .merge(style?.textStyle), - // Android's IME rich-content path, the one media - // input the SDK covers without a plugin. - contentInsertionConfiguration: - widget.onContentInserted == null || - !widget.attachmentsEnabled - ? null - : ContentInsertionConfiguration( - onContentInserted: - widget.onContentInserted!, - ), - decoration: InputDecoration( - isDense: true, - border: InputBorder.none, - hintText: widget.placeholder, - hintStyle: typography.bodyLarge.copyWith( - height: 1.3, - color: - style?.hintColor ?? colors.onSurfaceMuted, - ), - contentPadding: EdgeInsets.zero, - ), - ), - ), - ), - ), - ), - const SizedBox(height: _fieldGap), - Padding( - padding: const EdgeInsets.symmetric( - horizontal: _actionInset, - ), - child: Row( - children: [ - // The built-in attach affordance leads the row; being - // outside the loop keeps the pill-pair gap logic - // reading only the host's actions. - if (widget.attachmentsEnabled && - (widget.onAttach != null || - widget.onAttachmentsPicked != null)) ...[ - _buildAttachButton(context), - const SizedBox(width: _leadingGap), - ], - for ( - var i = 0; - i < widget.leadingActions.length; - i++ - ) ...[ - widget.leadingActions[i], - // Two neighbouring pills read as a set and take the - // design's wider step — 8, closing to 6 on phones — - // while everything else keeps the action row's 4. - SizedBox( - width: - i + 1 < widget.leadingActions.length && - widget.leadingActions[i] is FlowPill && - widget.leadingActions[i + 1] is FlowPill - ? (_isMobile(context) - ? _mobilePillGap - : _pillGap) - : _leadingGap, - ), - ], - const Spacer(), - for (final action in widget.trailingActions) ...[ - action, - const SizedBox(width: _trailingGap), - ], - _buildSendStopButton(context), - ], - ), - ), - ], - ), + padding: + widget.padding ?? + (expanded ? _cardPadding : _compactCardPadding), + child: expanded + ? _buildExpandedBody(context) + : _buildCompactBody(context), ), ), ), @@ -1072,6 +1071,166 @@ class _FlowComposerState extends State { ), ); } + + Widget _buildField(BuildContext context, {required bool compact}) { + final colors = context.flowColors; + final typography = context.flowTypography; + final style = _styleOf(context); + return Focus( + key: _fieldKey, + onKeyEvent: _handleKeyEvent, + // The caret and the highlight in the theme's selection colours, + // matching the thread's; a host's own textSelectionTheme still wins. + child: TextSelectionTheme( + data: flowTextSelectionTheme(context), + child: TextField( + controller: _controller, + focusNode: _focusNode, + enabled: widget.enabled, + minLines: widget.expands ? null : 1, + maxLines: widget.expands ? null : widget.maxLines, + expands: widget.expands, + cursorWidth: _cursorWidth, + // The design's compressed composer: body face on the 1.3 control + // line. + style: _fieldStyle(context), + // Android's IME rich-content path, the one media input the SDK + // covers without a plugin. + contentInsertionConfiguration: + widget.onContentInserted == null || !widget.attachmentsEnabled + ? null + : ContentInsertionConfiguration( + onContentInserted: widget.onContentInserted!, + ), + decoration: InputDecoration( + isDense: true, + border: InputBorder.none, + hintText: widget.placeholder, + hintMaxLines: compact ? 1 : null, + hintStyle: typography.bodyLarge.copyWith( + height: 1.3, + color: style?.hintColor ?? colors.onSurfaceMuted, + ), + contentPadding: EdgeInsets.zero, + ), + ), + ), + ); + } + + List _leadingChildren(BuildContext context) => [ + // The built-in attach affordance leads the row; being outside the loop + // keeps the pill-pair gap logic reading only the host's actions. + if (widget.attachmentsEnabled && + (widget.onAttach != null || widget.onAttachmentsPicked != null)) ...[ + _buildAttachButton(context), + const SizedBox(width: _leadingGap), + ], + for (var i = 0; i < widget.leadingActions.length; i++) ...[ + widget.leadingActions[i], + // Two neighbouring pills read as a set and take the design's wider + // step — 8, closing to 6 on phones — while everything else keeps the + // action row's 4. + SizedBox( + width: + i + 1 < widget.leadingActions.length && + widget.leadingActions[i] is FlowPill && + widget.leadingActions[i + 1] is FlowPill + ? (_isMobile(context) ? _mobilePillGap : _pillGap) + : _leadingGap, + ), + ], + ]; + + List _trailingChildren(BuildContext context) => [ + for (final action in widget.trailingActions) ...[ + action, + const SizedBox(width: _trailingGap), + ], + _buildSendStopButton(context), + ]; + + Widget _buildExpandedBody(BuildContext context) { + final field = Padding( + padding: const EdgeInsets.symmetric(horizontal: _contentInset), + child: _buildField(context, compact: false), + ); + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + if (widget.attachments.isNotEmpty) ...[ + _buildAttachmentStrip(context, _contentInset), + const SizedBox(height: _attachmentGap), + ], + if (widget.expands) Expanded(child: field) else field, + const SizedBox(height: _fieldGap), + Padding( + padding: const EdgeInsets.symmetric(horizontal: _actionInset), + child: Row( + children: [ + ..._leadingChildren(context), + const Spacer(), + ..._trailingChildren(context), + ], + ), + ), + ], + ); + } + + Widget _buildAttachmentStrip(BuildContext context, double inset) { + // The inset rides inside the strip's scroll view rather than around it: + // at rest nothing moves, but once the strip overflows the tiles scroll + // under the gutter and the last one is cut at the card's edge — the cue + // that there is more, with no counter to draw. + return FlowAttachmentGroup( + attachments: widget.attachments, + padding: EdgeInsets.symmetric(horizontal: inset), + onTap: widget.onAttachmentTap, + // Editing is what `enabled` gates; viewing an attachment that is + // already pending stays available, as does the send button while + // streaming. + onRemove: widget.enabled ? widget.onRemoveAttachment : null, + removeTooltip: widget.removeAttachmentTooltip, + previewCloseTooltip: widget.previewCloseTooltip, + ); + } + + Widget _buildCompactBody(BuildContext context) { + final leading = _leadingChildren(context); + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + if (widget.attachments.isNotEmpty) ...[ + _buildAttachmentStrip(context, _compactInset), + const SizedBox(height: _attachmentGap), + ], + Padding( + padding: const EdgeInsets.symmetric(horizontal: _compactInset), + child: Row( + children: [ + ...leading, + Expanded( + child: Padding( + padding: EdgeInsetsDirectional.only( + start: leading.isEmpty ? _compactFieldInset : 0, + end: _trailingGap, + ), + child: _ReportWidth( + onWidth: _handleFieldWidth, + child: _buildField(context, compact: true), + ), + ), + ), + ..._trailingChildren(context), + ], + ), + ), + ], + ); + } } /// The send arrow: a thin rounded stroke, matching the design's 1.5-weight @@ -1159,3 +1318,37 @@ class _WarningDiamondPainter extends CustomPainter { bool shouldRepaint(_WarningDiamondPainter oldDelegate) => oldDelegate.color != color; } + +class _ReportWidth extends SingleChildRenderObjectWidget { + const _ReportWidth({required this.onWidth, required super.child}); + + final ValueChanged onWidth; + + @override + RenderObject createRenderObject(BuildContext context) => + _RenderReportWidth(onWidth); + + @override + void updateRenderObject( + BuildContext context, + _RenderReportWidth renderObject, + ) { + renderObject.onWidth = onWidth; + } +} + +class _RenderReportWidth extends RenderProxyBox { + _RenderReportWidth(this.onWidth); + + ValueChanged onWidth; + double? _reported; + + @override + void performLayout() { + super.performLayout(); + final width = size.width; + if (width == _reported) return; + _reported = width; + WidgetsBinding.instance.addPostFrameCallback((_) => onWidth(width)); + } +} diff --git a/packages/stacflow/CHANGELOG.md b/packages/stacflow/CHANGELOG.md index 59e5c75..979ec82 100644 --- a/packages/stacflow/CHANGELOG.md +++ b/packages/stacflow/CHANGELOG.md @@ -2,6 +2,8 @@ - First release: `StacFlowChat`, the chat controller, and `StacFlowChatView`, the ready-made screen built on flow_ui, with streaming, stop, retry, regenerate, edit-and-resend, image attachments, copy, feedback and a model selector. - `GeminiProvider`, `OpenAIProvider` (Chat Completions, with `baseUrl` for Ollama, Groq, OpenRouter, LM Studio and vLLM) and `AnthropicProvider`, each streaming replies and taking image input, all called with the developer's own key. +- `StacFlowChatView.composerLayout` switches the screen to flow_ui's compact composer. +- `FlowStrings.send` and `FlowStrings.stop` name the composer's send and stop buttons. - `AgentConfig` for instructions and sampling, `FlowStrings` for every string the SDK shows, `ChatError` for failed turns. - Tools: `Tool` registered with `StacFlowChat(tools:)`, run by the pure-Dart `runToolLoop` with results fed back to the model; permission classes `read`, `write` and `destructive`, the confirmation card for gated calls, a tool card per call, `toolBodyBuilder` on the view, `ChatState.toolCalls`, `respondToToolCall`, `maxToolRounds` and the new `FlowStrings` for approvals and tool failures. Calls and results are replayed to the model in later turns. - Adapters emit the wire `SseEvent` union, tool calls included; `TurnTransport`, `TurnRequest` and the wire types are public for custom providers. diff --git a/packages/stacflow/lib/src/chat/stacflow_chat_view.dart b/packages/stacflow/lib/src/chat/stacflow_chat_view.dart index 4b3eba7..6d2a428 100644 --- a/packages/stacflow/lib/src/chat/stacflow_chat_view.dart +++ b/packages/stacflow/lib/src/chat/stacflow_chat_view.dart @@ -36,6 +36,7 @@ class StacFlowChatView extends StatefulWidget { this.maxContentWidth = 760, this.style, this.composerStyle, + this.composerLayout = FlowComposerLayout.expanded, }); /// The chat this screen shows and drives. @@ -81,6 +82,7 @@ class StacFlowChatView extends StatefulWidget { final double maxContentWidth; final FlowChatViewStyle? style; final FlowComposerStyle? composerStyle; + final FlowComposerLayout composerLayout; @override State createState() => _StacFlowChatViewState(); @@ -273,9 +275,12 @@ class _StacFlowChatViewState extends State { dropLabel: strings.dropFiles, style: widget.style, composer: FlowComposer( + layout: widget.composerLayout, isStreaming: state.isGenerating, onSend: _send, onStop: _chat.stop, + sendTooltip: strings.send, + stopTooltip: strings.stop, controller: _text, placeholder: strings.placeholder, attachments: state.pendingAttachments, diff --git a/packages/stacflow/lib/src/strings.dart b/packages/stacflow/lib/src/strings.dart index 3c71203..7bf1a21 100644 --- a/packages/stacflow/lib/src/strings.dart +++ b/packages/stacflow/lib/src/strings.dart @@ -19,6 +19,8 @@ class FlowStrings { String get edit => 'Edit'; String get jumpToLatest => 'Jump to latest'; String get attach => 'Attach'; + String get send => 'Send'; + String get stop => 'Stop'; String get dropFiles => 'Drop files to attach'; String get removeAttachment => 'Remove'; String get approvalTitle => 'Approval required'; diff --git a/playground/lib/src/demo_registry.dart b/playground/lib/src/demo_registry.dart index 80db319..96180cd 100644 --- a/playground/lib/src/demo_registry.dart +++ b/playground/lib/src/demo_registry.dart @@ -70,6 +70,8 @@ List<(String, String)> variantsFor(PlaygroundItem item) { return switch (item) { PlaygroundItem.composer => const [ ('default', 'Default'), + ('compact', 'Compact'), + ('fixed', 'Fixed height'), ('streaming', 'Streaming'), ], PlaygroundItem.message => const [ diff --git a/playground/lib/src/demos/composer_demo.dart b/playground/lib/src/demos/composer_demo.dart index 4e2c725..630ff0f 100644 --- a/playground/lib/src/demos/composer_demo.dart +++ b/playground/lib/src/demos/composer_demo.dart @@ -7,10 +7,84 @@ import 'package:phosphoricons_flutter/phosphoricons_flutter.dart'; import 'demo_content.dart'; String composerSnippet([String? variant]) => switch (variant) { + 'compact' => _compact, + 'fixed' => _fixed, 'streaming' => _streaming, _ => _default, }; +const String _compact = ''' +// One row: the "+" menu, the field, the model selector and send, with no +// action row beneath; attachments sit in a strip above it. It opens into +// the full card by itself once the draft wraps past one line, and folds +// back when the draft is empty. +FlowComposer( + layout: FlowComposerLayout.compact, + controller: input, + placeholder: 'How can I help you today?', + isStreaming: generating, + onSend: send, + onStop: stop, + attachments: pending, + onRemoveAttachment: removePending, + leadingActions: [ + FlowMenu( + icon: PhosphorIconsRegular.plus, + sheetTitle: 'Add to Chat', + entries: [ + FlowMenuOption(id: 'files', label: 'Add Files or Photos'), + ..., + ], + onSelected: (id) => id == 'files' ? pickFiles() : toggleTool(id), + ), + ], + trailingActions: [ + FlowModelSelector( + models: models, + selectedId: modelId, + onSelected: setModel, + efforts: efforts, + selectedEffortId: effortId, + onEffortSelected: setEffort, + ), + ], +)'''; + +const String _fixed = ''' +// expands makes the card fill the height its parent gives it: the field +// takes the space above the action row and scrolls inside it. +SizedBox( + height: 240, + child: FlowComposer( + expands: true, + controller: input, + placeholder: 'How can I help you today?', + isStreaming: generating, + onSend: send, + onStop: stop, + attachments: pending, + onRemoveAttachment: removePending, + leadingActions: [ + FlowMenu( + icon: PhosphorIconsRegular.plus, + sheetTitle: 'Add to Chat', + entries: [ + FlowMenuOption(id: 'files', label: 'Add Files or Photos'), + ..., + ], + onSelected: (id) => id == 'files' ? pickFiles() : toggleTool(id), + ), + ], + trailingActions: [ + FlowModelSelector( + models: models, + selectedId: modelId, + onSelected: setModel, + ), + ], + ), +)'''; + const String _streaming = ''' // While a reply streams the send disc reads as stop — onStop is the // only intent it reports; sending resumes when the stream settles. @@ -19,6 +93,7 @@ FlowComposer( isStreaming: true, onSend: send, onStop: stop, + stopTooltip: 'Stop', )'''; const String _default = ''' @@ -28,6 +103,8 @@ FlowComposer( isStreaming: generating, onSend: send, onStop: stop, + sendTooltip: 'Send', + stopTooltip: 'Stop', // Drag-and-drop scoped to the card, which lights up while a file is // over it. FlowChatView.onAttachmentsDropped is the same thing over // the whole surface; wire either, or both — the innermost wins. @@ -139,15 +216,21 @@ class _ComposerDemoState extends State { @override Widget build(BuildContext context) { - return Center( + final stage = Center( child: ConstrainedBox( constraints: const BoxConstraints(maxWidth: 640), child: FlowComposer( + layout: widget.variant == 'compact' + ? FlowComposerLayout.compact + : FlowComposerLayout.expanded, + expands: widget.variant == 'fixed', controller: _input, placeholder: 'How can I help you today?', isStreaming: widget.variant == 'streaming', onSend: (_) {}, onStop: () {}, + sendTooltip: 'Send', + stopTooltip: 'Stop', // Drop scoped to the card: drag an image anywhere else on the // stage and nothing happens — over the composer it lights up. onAttachmentsDropped: _add, @@ -205,5 +288,8 @@ class _ComposerDemoState extends State { ), ), ); + return widget.variant == 'fixed' + ? SizedBox(height: 240, child: stage) + : stage; } } diff --git a/playground/lib/src/demos/full_chat_demo.dart b/playground/lib/src/demos/full_chat_demo.dart index 3482278..ec15dc3 100644 --- a/playground/lib/src/demos/full_chat_demo.dart +++ b/playground/lib/src/demos/full_chat_demo.dart @@ -205,6 +205,8 @@ class _FullChatDemoState extends State { isStreaming: _generating, onSend: _send, onStop: _stop, + sendTooltip: 'Send', + stopTooltip: 'Stop', // Picking goes through the "+" menu below; paste lands here. onAttachmentsPasted: _addAttachments, onAttachmentRejected: _reject, From 98bfa5cc93f986fd2694d92d7a13825ab59915f3 Mon Sep 17 00:00:00 2001 From: Divyanshu Bhargava Date: Sat, 26 Sep 2026 14:04:58 +0530 Subject: [PATCH 2/2] fix: re-check compact promotion when inherited text metrics change --- packages/flow_ui/lib/src/widgets/flow_composer.dart | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/flow_ui/lib/src/widgets/flow_composer.dart b/packages/flow_ui/lib/src/widgets/flow_composer.dart index 489d76a..73943c3 100644 --- a/packages/flow_ui/lib/src/widgets/flow_composer.dart +++ b/packages/flow_ui/lib/src/widgets/flow_composer.dart @@ -476,10 +476,15 @@ class _FlowComposerState extends State { super.initState(); _attachedFocusNode = _focusNode..addListener(_handleFocusChange); _attachedController = _controller..addListener(_syncPromotion); - _promoted = _wantsPromotion(context); _registerPaste(); } + @override + void didChangeDependencies() { + super.didChangeDependencies(); + _promoted = _wantsPromotion(context); + } + @override void didUpdateWidget(FlowComposer oldWidget) { super.didUpdateWidget(oldWidget);