// RunTab — the third tab. Shows: // // 1. A form for the flow's declared inputs (one field per // `inputs:` entry). Text inputs use a TextField; bytes // inputs use a "Choose file…" button + filename badge. // // 2. A Start button that calls the host's FlowRunDriver // with the collected inputs. // // 3. A live step list driven by the driver's event stream, // identical visually to the CLI's `fai run` block: // ◻ pending, · running, ✔ done + duration, ✗ failed, // ⏸ awaiting approval. // // 4. The flow's outputs once the run resolves. // // The tab requires the file to be saved on disk — the hub's // runSavedFlow reads from `~/.fai/data/flows/.yaml`, // not from the in-memory buffer. The dirty banner reminds // the operator to save before running so they don't run a // stale version by surprise. import 'dart:async'; import 'dart:io'; import 'dart:typed_data'; import 'package:flutter/material.dart'; import '../editor_controller.dart'; import '../l10n.dart'; import '../model/flow_graph.dart'; import '../run_driver.dart'; import '../tokens.dart'; class RunTab extends StatefulWidget { final FlowEditorController controller; final FlowEditorStrings strings; final FlowRunDriver? driver; const RunTab({ super.key, required this.controller, required this.strings, this.driver, }); @override State createState() => _RunTabState(); } class _RunTabState extends State { final Map _textInputs = {}; final Map _fileInputs = {}; StreamSubscription? _eventSub; bool _running = false; Map? _outputs; Object? _error; // Insertion-ordered: shows steps in the order the hub // actually started them, matching the CLI rendering. final Map _liveSteps = {}; String? _runFlowName; @override void initState() { super.initState(); widget.controller.addListener(_onControllerChanged); _syncInputs(); } @override void dispose() { widget.controller.removeListener(_onControllerChanged); _eventSub?.cancel(); for (final c in _textInputs.values) { c.dispose(); } super.dispose(); } void _onControllerChanged() { if (!mounted) return; _syncInputs(); setState(() {}); } void _syncInputs() { final graph = widget.controller.graph; final keep = {}; for (final entry in graph.inputs.entries) { keep.add(entry.key); if (entry.value.type == 'bytes' || entry.value.type == 'file') { // bytes input — keep its file pick if already chosen. _fileInputs.putIfAbsent(entry.key, () => const _FilePick.empty()); } else { _textInputs.putIfAbsent( entry.key, () => TextEditingController(text: entry.value.defaultValue ?? ''), ); } } // Drop controllers for inputs that no longer exist. _textInputs.removeWhere((k, c) { if (keep.contains(k)) return false; c.dispose(); return true; }); _fileInputs.removeWhere((k, _) => !keep.contains(k)); } @override Widget build(BuildContext context) { final theme = Theme.of(context); final controller = widget.controller; final strings = widget.strings; if (controller.activeName == null) { return _empty(theme, strings.runNoFlow); } final graph = controller.graph; return Container( color: theme.colorScheme.surface, child: ListView( padding: const EdgeInsets.all(FaiSpace.lg), children: [ _titleRow(theme), const SizedBox(height: FaiSpace.md), if (controller.isDirty) Container( padding: const EdgeInsets.all(FaiSpace.sm), margin: const EdgeInsets.only(bottom: FaiSpace.md), decoration: BoxDecoration( color: theme.colorScheme.tertiaryContainer, borderRadius: BorderRadius.circular(FaiRadius.sm), ), child: Row( children: [ Icon( Icons.info_outline, size: 16, color: theme.colorScheme.onTertiaryContainer, ), const SizedBox(width: FaiSpace.sm), Expanded( child: Text( strings.runUnsavedBanner, style: theme.textTheme.bodySmall?.copyWith( color: theme.colorScheme.onTertiaryContainer, ), ), ), ], ), ), _section(theme, strings.runInputs), if (graph.inputs.isEmpty) Padding( padding: const EdgeInsets.symmetric(vertical: FaiSpace.sm), child: Text( '—', style: theme.textTheme.bodySmall?.copyWith( color: theme.colorScheme.onSurfaceVariant, ), ), ) else for (final entry in graph.inputs.entries) _inputField(theme, entry.key, entry.value), const SizedBox(height: FaiSpace.md), FilledButton.icon( onPressed: _running || widget.driver == null ? null : _start, icon: _running ? const SizedBox( width: 14, height: 14, child: CircularProgressIndicator(strokeWidth: 2), ) : const Icon(Icons.play_arrow, size: 18), label: Text(strings.runStart), ), if (_liveSteps.isNotEmpty || _running) ...[ const SizedBox(height: FaiSpace.lg), _section( theme, _running ? strings.runTitleRunning : strings.runTitleDone, ), _stepList(theme), ], if (_outputs != null) ...[ const SizedBox(height: FaiSpace.lg), _section(theme, strings.runOutputs), for (final entry in _outputs!.entries) _outputRow(theme, entry.key, entry.value), ], if (_error != null) ...[ const SizedBox(height: FaiSpace.lg), _section(theme, strings.runTitleFailed), Container( padding: const EdgeInsets.all(FaiSpace.sm), decoration: BoxDecoration( color: theme.colorScheme.errorContainer, borderRadius: BorderRadius.circular(FaiRadius.sm), ), child: Text( _error.toString(), style: TextStyle( fontFamily: 'monospace', fontSize: 12, color: theme.colorScheme.onErrorContainer, ), ), ), ], ], ), ); } Widget _titleRow(ThemeData theme) { final title = _running ? widget.strings.runTitleRunning : _error != null ? widget.strings.runTitleFailed : _outputs != null ? widget.strings.runTitleDone : widget.strings.runTitleIdle; return Text( title, style: theme.textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w600), ); } Widget _section(ThemeData theme, String label) { return Padding( padding: const EdgeInsets.only(bottom: 4), child: Text( label, style: theme.textTheme.labelSmall?.copyWith( color: theme.colorScheme.onSurfaceVariant, letterSpacing: 0.6, ), ), ); } Widget _empty(ThemeData theme, String hint) { return Container( color: theme.colorScheme.surface, padding: const EdgeInsets.all(FaiSpace.lg), child: Center( child: Text( hint, textAlign: TextAlign.center, style: theme.textTheme.bodySmall?.copyWith( color: theme.colorScheme.onSurfaceVariant, ), ), ), ); } Widget _inputField(ThemeData theme, String name, FlowInput input) { final type = input.type; if (type == 'bytes' || type == 'file') { final pick = _fileInputs[name] ?? const _FilePick.empty(); return Padding( padding: const EdgeInsets.symmetric(vertical: FaiSpace.xs), child: Row( children: [ SizedBox( width: 140, child: Text( '$name ($type)', style: const TextStyle(fontFamily: 'monospace'), ), ), const SizedBox(width: FaiSpace.sm), OutlinedButton.icon( onPressed: () => _pickFile(name), icon: const Icon(Icons.attach_file, size: 16), label: Text( pick.fileName ?? widget.strings.runChooseFile, overflow: TextOverflow.ellipsis, ), ), ], ), ); } return Padding( padding: const EdgeInsets.symmetric(vertical: FaiSpace.xs), child: Row( children: [ SizedBox( width: 140, child: Text( '$name ($type)', style: const TextStyle(fontFamily: 'monospace'), ), ), const SizedBox(width: FaiSpace.sm), Expanded( child: TextField( controller: _textInputs[name], style: const TextStyle(fontFamily: 'monospace', fontSize: 13), decoration: InputDecoration( hintText: input.hint, isDense: true, border: const OutlineInputBorder(), ), ), ), ], ), ); } Widget _stepList(ThemeData theme) { final entries = _liveSteps.entries.toList(); return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ for (final entry in entries) _stepRow(theme, entry.key, entry.value), ], ); } Widget _stepRow(ThemeData theme, String id, _StepState state) { final IconData glyph; final Color color; String suffix = ''; switch (state.kind) { case _StepKind.running: glyph = Icons.refresh; color = theme.colorScheme.primary; case _StepKind.done: glyph = Icons.check; color = Colors.green.shade600; suffix = ' ${(state.durationMs ?? 0) / 1000.0}s'; case _StepKind.error: glyph = Icons.close; color = theme.colorScheme.error; if (state.error != null && state.error!.isNotEmpty) { suffix = ' — ${state.error}'; } case _StepKind.awaiting: glyph = Icons.pause_circle_outline; color = theme.colorScheme.tertiary; suffix = ' awaiting approval'; } return Padding( padding: const EdgeInsets.symmetric(vertical: 2), child: Row( children: [ Icon(glyph, size: 16, color: color), const SizedBox(width: FaiSpace.sm), Flexible( child: Text( '$id$suffix', style: TextStyle( fontFamily: 'monospace', fontSize: 13, color: color, ), overflow: TextOverflow.ellipsis, ), ), ], ), ); } Widget _outputRow(ThemeData theme, String name, FlowOutputValue value) { return Padding( padding: const EdgeInsets.only(bottom: FaiSpace.sm), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( name.toUpperCase(), style: theme.textTheme.labelSmall?.copyWith( color: theme.colorScheme.onSurfaceVariant, letterSpacing: 0.6, ), ), const SizedBox(height: 2), _outputBody(theme, value), ], ), ); } Widget _outputBody(ThemeData theme, FlowOutputValue value) { if (value is FlowOutputText) { return SelectableText( value.value, style: const TextStyle(fontFamily: 'monospace', fontSize: 12), ); } if (value is FlowOutputJson) { return SelectableText( value.value.toString(), style: const TextStyle(fontFamily: 'monospace', fontSize: 12), ); } if (value is FlowOutputBytes) { return Text( '${value.value.length} bytes · ${value.mimeType}', style: theme.textTheme.bodySmall, ); } return const SizedBox.shrink(); } Future _pickFile(String name) async { // For 0.2.0 the editor relies on the host to inject a // file-picker via callback; minimal version uses // dart:io directly for desktop. Studio uses file_picker; // we fall back to a manual path entry dialog if no host // picker is available. final controller = TextEditingController(); final ctx = context; final result = await showDialog( context: ctx, builder: (_) => AlertDialog( title: Text(widget.strings.runChooseFile), content: TextField( controller: controller, decoration: const InputDecoration( hintText: '/path/to/file', border: OutlineInputBorder(), ), ), actions: [ TextButton( onPressed: () => Navigator.pop(ctx, null), child: Text(widget.strings.newDialogCancel), ), TextButton( onPressed: () => Navigator.pop(ctx, controller.text.trim()), child: const Text('OK'), ), ], ), ); if (result == null || result.isEmpty || !mounted) return; try { final file = File(result); if (!file.existsSync()) return; final bytes = await file.readAsBytes(); setState(() { _fileInputs[name] = _FilePick( fileName: file.uri.pathSegments.last, bytes: Uint8List.fromList(bytes), ); }); } catch (_) { // swallow — file unreadable, leave the pick empty } } Future _start() async { final driver = widget.driver; if (driver == null) return; final flowName = widget.controller.activeName; if (flowName == null) return; setState(() { _running = true; _outputs = null; _error = null; _liveSteps.clear(); _runFlowName = flowName; }); widget.controller.running = true; // Subscribe BEFORE submitting so the first event isn't // lost to the broadcast's dead-letter. _eventSub?.cancel(); _eventSub = driver.events().listen(_onEvent); final textInputs = { for (final entry in _textInputs.entries) entry.key: entry.value.text, }; final fileInputs = {}; final fileMimes = {}; for (final entry in _fileInputs.entries) { final bytes = entry.value.bytes; if (bytes == null) continue; fileInputs[entry.key] = bytes; fileMimes[entry.key] = _mimeFor(entry.value.fileName ?? ''); } try { final outputs = await driver.runFlow( flowName: flowName, textInputs: textInputs, fileInputs: fileInputs, fileMimes: fileMimes, ); if (!mounted) return; setState(() { _running = false; _outputs = outputs; }); } catch (e) { if (!mounted) return; setState(() { _running = false; _error = e; }); } finally { widget.controller.running = false; _eventSub?.cancel(); _eventSub = null; } } void _onEvent(FlowRunEvent event) { final flowName = _runFlowName; if (flowName == null) return; if (event.flowName != flowName) return; if (!mounted) return; setState(() { switch (event) { case StepStarted(): _liveSteps[event.stepId] = const _StepState(kind: _StepKind.running); widget.controller.updateStepStatus( event.stepId, StepRunStatus.running, ); case StepCompleted(): _liveSteps[event.stepId] = _StepState( kind: _StepKind.done, durationMs: event.durationMs, ); widget.controller.updateStepStatus(event.stepId, StepRunStatus.done); case StepFailed(): _liveSteps[event.stepId] = _StepState( kind: _StepKind.error, error: event.error, ); widget.controller.updateStepStatus( event.stepId, StepRunStatus.failed, ); case StepAwaitingApproval(): _liveSteps[event.stepId] = const _StepState(kind: _StepKind.awaiting); widget.controller.updateStepStatus( event.stepId, StepRunStatus.awaiting, ); } }); } String _mimeFor(String name) { final lower = name.toLowerCase(); if (lower.endsWith('.pdf')) return 'application/pdf'; if (lower.endsWith('.docx')) { return 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'; } if (lower.endsWith('.txt')) return 'text/plain'; if (lower.endsWith('.json')) return 'application/json'; return 'application/octet-stream'; } } class _FilePick { final String? fileName; final Uint8List? bytes; const _FilePick({this.fileName, this.bytes}); const _FilePick.empty() : fileName = null, bytes = null; } enum _StepKind { running, done, error, awaiting } class _StepState { final _StepKind kind; final int? durationMs; final String? error; const _StepState({required this.kind, this.durationMs, this.error}); }