feat(studio): typed run-flow form, file picker, copyable errors (v0.41.0)

The free-form key=value run-flow dialog produced two
unhelpful failures every time an operator hit Run:

  1. Submit empty → "step references missing value
     '\$inputs.document'" from the hub. Studio sent a zero-
     entry inputs map because nothing told the operator the
     flow declared a required input.
  2. Type `document=@/path/to/file` → "path not found".
     Dart's `File()` doesn't expand `~`, and on macOS
     sandboxed Studio can't read arbitrary paths anyway.

Both failure modes are gone. The dialog is now a typed form:

- On open, fetches `getFlowDefinition(name)` (new SDK
  v0.15.0 wrapper around the v0.10.89 hub RPC). While that
  resolves, a small spinner shows
  "Loading inputs…"; on failure, an inline `FaiErrorBox`
  with the exact RPC error and a copy button replaces the
  spinner.
- Renders one form-field per declared input. The flow
  YAML's verbatim type tag drives the widget choice:
  `bytes` / `file` → "Choose file…" button + picked-file
  readout, plain TextField for everything else. The type
  tag is shown next to the input name as a pill so a flow
  author who picks a less-common type still gets a hint.
- Bytes inputs route through `file_picker` with
  `withData: true`, which means the OS file dialog handles
  read access — sandboxed Studio gets the bytes inline
  rather than a path it can't open. Falls back to
  `File(path).readAsBytes()` on Linux configs that don't
  honour `withData` for large files; failures show a
  copyable SnackBar.
- The Run button enables only when every declared input has
  a value (text non-empty / file picked). Empty submission
  is impossible; the cryptic hub-side
  "missing value" error stops surfacing.
- The run-result dialog's error path uses `FaiErrorBox`
  instead of the previous plain `SelectableText`, so any
  hub-side flow error (permission denied, module crash,
  whatever) is one click to clipboard.

11 new ARB keys cover the form's labels, the file-picker
states, the loading / failed-definition messages, the
"This flow declares no inputs" empty state, and the
run-error dialog title.

`file_picker: ^8.0.0` added to pubspec.yaml.

Signed-off-by: flemming-it <stefan.a.flemming@googlemail.com>
This commit is contained in:
flemming-it 2026-05-09 10:18:08 +02:00
parent 63e19974c0
commit f90d8cc7a7
10 changed files with 508 additions and 105 deletions

View file

@ -546,6 +546,15 @@ class HubService {
..sort((a, b) => a.name.compareTo(b.name));
}
/// Return the parsed input schema of a saved flow. Used by
/// Studio's Flows page to render a typed run-flow form
/// before calling [runSavedFlow]. Each entry's `type` is
/// the verbatim type tag from the flow YAML (`text`,
/// `bytes`, `json`, ).
Future<List<FlowInputDef>> getFlowDefinition(String name) async {
return _client.getFlowDefinition(name);
}
/// Run a saved flow with the supplied inputs. Text values
/// land as text Payloads, byte values as bytes Payloads
/// flows that mix both (e.g. extract taking a `document:

View file

@ -389,8 +389,23 @@
"flowsNoneHint": "Flow speichern mit `fai admin flows save <name> <flow.yaml>`, dann taucht er hier auf.",
"flowsRunDialogTitle": "{name} ausführen",
"@flowsRunDialogTitle": { "placeholders": { "name": { "type": "String" } } },
"flowsRunDialogBody": "Eingaben als key=value, eine pro Zeile. Klartext-Werte landen als Text. Wert mit `@` voranstellen liest die Datei als Bytes — wie `fai run --input document=@/pfad/zu/datei.pdf`.",
"flowsInputsHint": "name=Welt\ndocument=@/Users/ich/Dokumente/beispiel.pdf\nstil=drei Stichpunkte",
"flowsRunDialogBody": "Pro deklariertem Input ein Feld. Bytes-Inputs öffnen einen Datei-Picker; alle anderen sind Klartext. Der Starten-Button aktiviert sich, wenn alle Pflichtfelder gefüllt sind.",
"flowsLoadingDefinition": "Inputs werden geladen…",
"flowsDefinitionFailed": "Flow-Definition konnte nicht geladen werden",
"flowsNoInputs": "Dieser Flow deklariert keine Inputs. Starten klicken, um loszulegen.",
"flowsTypeBytesPick": "Datei wählen…",
"flowsTypeBytesChange": "Datei ändern",
"flowsTypeBytesNoFile": "Keine Datei ausgewählt",
"flowsTypeBytesSize": "{n} Bytes",
"@flowsTypeBytesSize": { "placeholders": { "n": { "type": "int" } } },
"flowsRunErrorTitle": "Flow-Lauf fehlgeschlagen",
"flowsFileReadFailed": "„{path}\" konnte nicht gelesen werden: {error}",
"@flowsFileReadFailed": {
"placeholders": {
"path": { "type": "String" },
"error": { "type": "String" }
}
},
"flowsRunButton": "Starten",
"flowsRunningTitle": "{name} läuft",
"@flowsRunningTitle": { "placeholders": { "name": { "type": "String" } } },

View file

@ -390,8 +390,23 @@
"flowsNoneHint": "Save a flow with `fai admin flows save <name> <flow.yaml>` and it shows up here.",
"flowsRunDialogTitle": "Run {name}",
"@flowsRunDialogTitle": { "placeholders": { "name": { "type": "String" } } },
"flowsRunDialogBody": "Inputs as key=value, one per line. Plain values send as text. Prefix the value with `@` to read a file as bytes — same as `fai run --input document=@/path/to/file.pdf`.",
"flowsInputsHint": "name=World\ndocument=@/Users/me/Documents/sample.pdf\nstyle=three bullet points",
"flowsRunDialogBody": "One field per declared input. Pick a file for bytes-shaped inputs; type plain text for the rest. The Run button enables when every required input is set.",
"flowsLoadingDefinition": "Loading inputs…",
"flowsDefinitionFailed": "Could not load flow definition",
"flowsNoInputs": "This flow declares no inputs. Click Run to start.",
"flowsTypeBytesPick": "Choose file…",
"flowsTypeBytesChange": "Change file",
"flowsTypeBytesNoFile": "No file selected",
"flowsTypeBytesSize": "{n} bytes",
"@flowsTypeBytesSize": { "placeholders": { "n": { "type": "int" } } },
"flowsRunErrorTitle": "Flow run failed",
"flowsFileReadFailed": "Could not read \"{path}\": {error}",
"@flowsFileReadFailed": {
"placeholders": {
"path": { "type": "String" },
"error": { "type": "String" }
}
},
"flowsRunButton": "Run",
"flowsRunningTitle": "Running {name}",
"@flowsRunningTitle": { "placeholders": { "name": { "type": "String" } } },

View file

@ -1823,14 +1823,62 @@ abstract class AppLocalizations {
/// No description provided for @flowsRunDialogBody.
///
/// In en, this message translates to:
/// **'Inputs as key=value, one per line. Plain values send as text. Prefix the value with `@` to read a file as bytes — same as `fai run --input document=@/path/to/file.pdf`.'**
/// **'One field per declared input. Pick a file for bytes-shaped inputs; type plain text for the rest. The Run button enables when every required input is set.'**
String get flowsRunDialogBody;
/// No description provided for @flowsInputsHint.
/// No description provided for @flowsLoadingDefinition.
///
/// In en, this message translates to:
/// **'name=World\ndocument=@/Users/me/Documents/sample.pdf\nstyle=three bullet points'**
String get flowsInputsHint;
/// **'Loading inputs…'**
String get flowsLoadingDefinition;
/// No description provided for @flowsDefinitionFailed.
///
/// In en, this message translates to:
/// **'Could not load flow definition'**
String get flowsDefinitionFailed;
/// No description provided for @flowsNoInputs.
///
/// In en, this message translates to:
/// **'This flow declares no inputs. Click Run to start.'**
String get flowsNoInputs;
/// No description provided for @flowsTypeBytesPick.
///
/// In en, this message translates to:
/// **'Choose file…'**
String get flowsTypeBytesPick;
/// No description provided for @flowsTypeBytesChange.
///
/// In en, this message translates to:
/// **'Change file'**
String get flowsTypeBytesChange;
/// No description provided for @flowsTypeBytesNoFile.
///
/// In en, this message translates to:
/// **'No file selected'**
String get flowsTypeBytesNoFile;
/// No description provided for @flowsTypeBytesSize.
///
/// In en, this message translates to:
/// **'{n} bytes'**
String flowsTypeBytesSize(int n);
/// No description provided for @flowsRunErrorTitle.
///
/// In en, this message translates to:
/// **'Flow run failed'**
String get flowsRunErrorTitle;
/// No description provided for @flowsFileReadFailed.
///
/// In en, this message translates to:
/// **'Could not read \"{path}\": {error}'**
String flowsFileReadFailed(String path, String error);
/// No description provided for @flowsRunButton.
///

View file

@ -1025,11 +1025,40 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get flowsRunDialogBody =>
'Eingaben als key=value, eine pro Zeile. Klartext-Werte landen als Text. Wert mit `@` voranstellen liest die Datei als Bytes — wie `fai run --input document=@/pfad/zu/datei.pdf`.';
'Pro deklariertem Input ein Feld. Bytes-Inputs öffnen einen Datei-Picker; alle anderen sind Klartext. Der Starten-Button aktiviert sich, wenn alle Pflichtfelder gefüllt sind.';
@override
String get flowsInputsHint =>
'name=Welt\ndocument=@/Users/ich/Dokumente/beispiel.pdf\nstil=drei Stichpunkte';
String get flowsLoadingDefinition => 'Inputs werden geladen…';
@override
String get flowsDefinitionFailed =>
'Flow-Definition konnte nicht geladen werden';
@override
String get flowsNoInputs =>
'Dieser Flow deklariert keine Inputs. Starten klicken, um loszulegen.';
@override
String get flowsTypeBytesPick => 'Datei wählen…';
@override
String get flowsTypeBytesChange => 'Datei ändern';
@override
String get flowsTypeBytesNoFile => 'Keine Datei ausgewählt';
@override
String flowsTypeBytesSize(int n) {
return '$n Bytes';
}
@override
String get flowsRunErrorTitle => 'Flow-Lauf fehlgeschlagen';
@override
String flowsFileReadFailed(String path, String error) {
return '$path\" konnte nicht gelesen werden: $error';
}
@override
String get flowsRunButton => 'Starten';

View file

@ -1040,11 +1040,39 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get flowsRunDialogBody =>
'Inputs as key=value, one per line. Plain values send as text. Prefix the value with `@` to read a file as bytes — same as `fai run --input document=@/path/to/file.pdf`.';
'One field per declared input. Pick a file for bytes-shaped inputs; type plain text for the rest. The Run button enables when every required input is set.';
@override
String get flowsInputsHint =>
'name=World\ndocument=@/Users/me/Documents/sample.pdf\nstyle=three bullet points';
String get flowsLoadingDefinition => 'Loading inputs…';
@override
String get flowsDefinitionFailed => 'Could not load flow definition';
@override
String get flowsNoInputs =>
'This flow declares no inputs. Click Run to start.';
@override
String get flowsTypeBytesPick => 'Choose file…';
@override
String get flowsTypeBytesChange => 'Change file';
@override
String get flowsTypeBytesNoFile => 'No file selected';
@override
String flowsTypeBytesSize(int n) {
return '$n bytes';
}
@override
String get flowsRunErrorTitle => 'Flow run failed';
@override
String flowsFileReadFailed(String path, String error) {
return 'Could not read \"$path\": $error';
}
@override
String get flowsRunButton => 'Run';

View file

@ -26,7 +26,7 @@ import 'widgets/widgets.dart';
/// Studio's own build version. Bump on every UI commit so the
/// running app self-identifies visible in the sidebar header
/// and quick-glance proof that you're seeing the current build.
const String kStudioVersion = '0.40.0';
const String kStudioVersion = '0.41.0';
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();

View file

@ -1,6 +1,8 @@
import 'dart:io';
import 'dart:typed_data';
import 'package:fai_dart_sdk/fai_dart_sdk.dart' show FlowInputDef;
import 'package:file_picker/file_picker.dart';
import 'package:flutter/material.dart';
import '../data/hub.dart';
@ -32,43 +34,14 @@ class _FlowsPageState extends State<FlowsPage> {
Future<void> _runFlow(SavedFlow flow) async {
final inputs = await _FlowInputDialog.show(context, flow);
if (inputs == null) return;
// Split the dialog's text-shaped key=value pairs into text
// vs file payloads. Values starting with `@` mean "treat
// the rest as a path; read the file as bytes" — same
// syntax `fai run --input` uses on the CLI. File-read
// failures bubble up as the run dialog's error state so
// the operator sees a useful message.
final textInputs = <String, String>{};
final fileInputs = <String, Uint8List>{};
String? readError;
for (final entry in inputs.entries) {
final value = entry.value;
if (value.startsWith('@')) {
final path = value.substring(1).trim();
try {
fileInputs[entry.key] = await File(path).readAsBytes();
} catch (e) {
readError = '${entry.key}: $e';
break;
}
} else {
textInputs[entry.key] = value;
}
}
if (!mounted) return;
if (readError != null) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(readError)),
);
return;
}
showDialog<void>(
context: context,
barrierDismissible: false,
builder: (_) => _FlowRunDialog(
flow: flow,
textInputs: textInputs,
fileInputs: fileInputs,
textInputs: inputs.textInputs,
fileInputs: inputs.fileInputs,
),
);
}
@ -184,16 +157,56 @@ class _FlowCard extends StatelessWidget {
}
}
/// Inputs collected by `_FlowInputDialog` and forwarded to
/// the run dialog. Text and file values arrive in separate
/// maps so the SDK can wrap each as the appropriate Payload
/// variant.
class _FlowRunInputs {
final Map<String, String> textInputs;
final Map<String, Uint8List> fileInputs;
const _FlowRunInputs({
required this.textInputs,
required this.fileInputs,
});
}
class _PickedFile {
final String name;
final Uint8List bytes;
const _PickedFile({required this.name, required this.bytes});
}
/// Run-flow input dialog. Fetches the flow's declared input
/// schema first via `getFlowDefinition`, then renders one
/// type-aware field per declared input:
///
/// * `bytes` / `file` "Choose file" button + picked-file
/// readout. Routes through the OS
/// file dialog so sandboxed Studio
/// still gets read access to the
/// chosen file. `withData: true` so
/// the bytes flow through the
/// FilePicker result instead of a
/// path that may be unreadable.
/// * everything else TextField. Opaque type tag is
/// shown as a pill next to the input
/// name so a flow author who picks
/// a less-common type still gets a
/// hint.
///
/// The Run button enables only when every input has a value
/// required-field validation happens client-side before
/// the hub sees an empty inputs map.
class _FlowInputDialog extends StatefulWidget {
final SavedFlow flow;
const _FlowInputDialog({required this.flow});
static Future<Map<String, String>?> show(
static Future<_FlowRunInputs?> show(
BuildContext context,
SavedFlow flow,
) {
return showDialog<Map<String, String>>(
return showDialog<_FlowRunInputs>(
context: context,
builder: (_) => _FlowInputDialog(flow: flow),
);
@ -204,28 +217,92 @@ class _FlowInputDialog extends StatefulWidget {
}
class _FlowInputDialogState extends State<_FlowInputDialog> {
// The hub does not yet expose the flow's input schema over
// gRPC, so this dialog accepts free-form key=value pairs.
// Operator pastes pairs separated by newlines; we split on
// the first `=` per line.
final _ctrl = TextEditingController();
late final Future<List<FlowInputDef>> _defFuture;
final Map<String, TextEditingController> _textControllers = {};
final Map<String, _PickedFile> _pickedFiles = {};
@override
void initState() {
super.initState();
_defFuture = HubService.instance.getFlowDefinition(widget.flow.name);
}
@override
void dispose() {
_ctrl.dispose();
for (final c in _textControllers.values) {
c.dispose();
}
super.dispose();
}
Map<String, String> _parsePairs() {
final out = <String, String>{};
for (final raw in _ctrl.text.split('\n')) {
final line = raw.trim();
if (line.isEmpty) continue;
final eq = line.indexOf('=');
if (eq < 0) continue;
out[line.substring(0, eq).trim()] = line.substring(eq + 1).trim();
bool _isBytesType(String type) => type == 'bytes' || type == 'file';
bool _isComplete(List<FlowInputDef> defs) {
for (final d in defs) {
if (_isBytesType(d.type)) {
if (_pickedFiles[d.name] == null) return false;
} else {
final v = _textControllers[d.name]?.text.trim() ?? '';
if (v.isEmpty) return false;
}
}
return true;
}
_FlowRunInputs _collect(List<FlowInputDef> defs) {
final textInputs = <String, String>{};
final fileInputs = <String, Uint8List>{};
for (final d in defs) {
if (_isBytesType(d.type)) {
final picked = _pickedFiles[d.name];
if (picked != null) fileInputs[d.name] = picked.bytes;
} else {
final c = _textControllers[d.name];
if (c != null) textInputs[d.name] = c.text;
}
}
return _FlowRunInputs(textInputs: textInputs, fileInputs: fileInputs);
}
Future<void> _pickFile(String inputName) async {
final result = await FilePicker.platform.pickFiles(withData: true);
if (result == null || result.files.isEmpty) return;
final f = result.files.first;
var bytes = f.bytes;
if (bytes == null && f.path != null) {
// Some Linux configurations don't honour `withData: true`
// for large files; fall back to reading via path.
try {
bytes = await File(f.path!).readAsBytes();
} catch (e) {
if (!mounted) return;
final l = AppLocalizations.of(context)!;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(l.flowsFileReadFailed(f.path!, '$e'))),
);
return;
}
}
if (bytes == null || !mounted) return;
setState(() {
_pickedFiles[inputName] = _PickedFile(name: f.name, bytes: bytes!);
});
}
/// Lazily ensure each text-shaped input has a controller +
/// a setState-triggering listener so the Run button reflects
/// validity as the operator types.
void _ensureTextControllers(List<FlowInputDef> defs) {
for (final d in defs) {
if (_isBytesType(d.type)) continue;
_textControllers.putIfAbsent(d.name, () {
final c = TextEditingController();
c.addListener(() {
if (mounted) setState(() {});
});
return c;
});
}
return out;
}
@override
@ -238,33 +315,77 @@ class _FlowInputDialogState extends State<_FlowInputDialog> {
borderRadius: BorderRadius.circular(FaiRadius.md),
),
content: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 480, maxHeight: 360),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
l.flowsRunDialogBody,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
constraints: const BoxConstraints(maxWidth: 540, maxHeight: 540),
child: FutureBuilder<List<FlowInputDef>>(
future: _defFuture,
builder: (context, snap) {
if (snap.connectionState == ConnectionState.waiting) {
return Row(
mainAxisSize: MainAxisSize.min,
children: [
const SizedBox(
width: 14,
height: 14,
child: CircularProgressIndicator(strokeWidth: 2),
),
const SizedBox(width: FaiSpace.sm),
Text(l.flowsLoadingDefinition),
],
);
}
if (snap.hasError) {
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
l.flowsDefinitionFailed,
style: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.error,
),
),
const SizedBox(height: FaiSpace.sm),
FaiErrorBox(
text: snap.error.toString(),
isError: true,
maxHeight: 200,
),
],
);
}
final defs = snap.data ?? const <FlowInputDef>[];
_ensureTextControllers(defs);
return SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
l.flowsRunDialogBody,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: FaiSpace.md),
if (defs.isEmpty)
Text(
l.flowsNoInputs,
style: theme.textTheme.bodyMedium,
),
for (final d in defs) ...[
_InputField(
def: d,
isBytes: _isBytesType(d.type),
textController: _textControllers[d.name],
pickedFile: _pickedFiles[d.name],
onPick: () => _pickFile(d.name),
),
const SizedBox(height: FaiSpace.md),
],
],
),
),
const SizedBox(height: FaiSpace.md),
Expanded(
child: TextField(
controller: _ctrl,
maxLines: null,
expands: true,
textAlignVertical: TextAlignVertical.top,
style: FaiTheme.mono(size: 12),
decoration: InputDecoration(
hintText: l.flowsInputsHint,
border: const OutlineInputBorder(),
isDense: true,
),
),
),
],
);
},
),
),
actions: [
@ -272,16 +393,106 @@ class _FlowInputDialogState extends State<_FlowInputDialog> {
onPressed: () => Navigator.pop(context, null),
child: Text(l.buttonCancel),
),
FilledButton.icon(
icon: const Icon(Icons.play_arrow, size: 16),
label: Text(l.flowsRunButton),
onPressed: () => Navigator.pop(context, _parsePairs()),
FutureBuilder<List<FlowInputDef>>(
future: _defFuture,
builder: (context, snap) {
final defs = snap.data ?? const <FlowInputDef>[];
final canRun = snap.hasData && _isComplete(defs);
return FilledButton.icon(
icon: const Icon(Icons.play_arrow, size: 16),
label: Text(l.flowsRunButton),
onPressed: canRun
? () => Navigator.pop(context, _collect(defs))
: null,
);
},
),
],
);
}
}
class _InputField extends StatelessWidget {
final FlowInputDef def;
final bool isBytes;
final TextEditingController? textController;
final _PickedFile? pickedFile;
final VoidCallback onPick;
const _InputField({
required this.def,
required this.isBytes,
required this.textController,
required this.pickedFile,
required this.onPick,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final l = AppLocalizations.of(context)!;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Text(
def.name,
style: FaiTheme.mono(
size: 12,
weight: FontWeight.w600,
color: theme.colorScheme.onSurface,
),
),
const SizedBox(width: FaiSpace.sm),
FaiPill(
label: def.type,
tone: isBytes ? FaiPillTone.warning : FaiPillTone.neutral,
monospace: true,
),
],
),
const SizedBox(height: 6),
if (isBytes)
Row(
children: [
OutlinedButton.icon(
onPressed: onPick,
icon: const Icon(Icons.attach_file, size: 14),
label: Text(
pickedFile == null
? l.flowsTypeBytesPick
: l.flowsTypeBytesChange,
),
),
const SizedBox(width: FaiSpace.md),
Expanded(
child: Text(
pickedFile == null
? l.flowsTypeBytesNoFile
: '${pickedFile!.name} · '
'${l.flowsTypeBytesSize(pickedFile!.bytes.length)}',
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
overflow: TextOverflow.ellipsis,
),
),
],
)
else
TextField(
controller: textController,
decoration: const InputDecoration(
border: OutlineInputBorder(),
isDense: true,
),
),
],
);
}
}
class _FlowRunDialog extends StatefulWidget {
final SavedFlow flow;
final Map<String, String> textInputs;
@ -346,18 +557,28 @@ class _FlowRunDialogState extends State<_FlowRunDialog> {
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.error_outline,
color: theme.colorScheme.error,
size: 32,
Row(
children: [
Icon(
Icons.error_outline,
color: theme.colorScheme.error,
size: 20,
),
const SizedBox(width: FaiSpace.sm),
Text(
l.flowsRunErrorTitle,
style: theme.textTheme.titleSmall?.copyWith(
color: theme.colorScheme.error,
fontWeight: FontWeight.w600,
),
),
],
),
const SizedBox(height: FaiSpace.md),
SelectableText(
snapshot.error.toString(),
style: FaiTheme.mono(
size: 11,
color: theme.colorScheme.error,
),
FaiErrorBox(
text: snapshot.error.toString(),
isError: true,
maxHeight: 280,
),
],
),