// 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 `~/.chain/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 'package:flutter/material.dart'; import 'package:flutter/services.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), _startButtonRow(theme, strings), 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), _CopyableErrorBox( text: _error.toString(), strings: widget.strings, ), ], ], ), ); } 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 strings = widget.strings; 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; case _StepKind.awaiting: glyph = Icons.pause_circle_outline; color = theme.colorScheme.tertiary; suffix = ' ${strings.runAwaitingApproval}'; } final isError = state.kind == _StepKind.error && state.error != null && state.error!.isNotEmpty; return Padding( padding: const EdgeInsets.symmetric(vertical: 2), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ Icon(glyph, size: 16, color: color), const SizedBox(width: FaiSpace.sm), Flexible( child: SelectableText( '$id$suffix', style: TextStyle( fontFamily: 'monospace', fontSize: 13, color: color, ), ), ), ], ), if (isError) ...[ const SizedBox(height: 4), Padding( padding: const EdgeInsets.only(left: 24), child: _CopyableErrorBox( text: state.error!, strings: widget.strings, ), ), ], if (state.kind == _StepKind.awaiting) ...[ const SizedBox(height: 6), Padding( padding: const EdgeInsets.only(left: 24), child: _InlineApprovalCard( stepId: id, flowName: _runFlowName ?? '', driver: widget.driver, strings: widget.strings, onDecided: () { // After approve/reject the hub keeps polling // and either resumes the flow or fails it; the // event stream surfaces the outcome. Nothing to // do here besides redraw so the buttons go away. if (mounted) setState(() {}); }, ), ), ], ], ), ); } /// Run button row. Disabled when the buffer carries analyzer /// errors — an info chip next to the button names the count /// and (when collapsed) the first message so the operator /// sees the blocker without scrolling to the bottom strip. Widget _startButtonRow(ThemeData theme, FlowEditorStrings strings) { final errors = widget.controller.analyzerErrorCount; final blocked = errors > 0; return Row( children: [ Tooltip( message: blocked ? strings.runErrorsBlockTooltip(errors) : '', child: FilledButton.icon( onPressed: _running || widget.driver == null || blocked ? null : _start, icon: _running ? const SizedBox( width: 14, height: 14, child: CircularProgressIndicator(strokeWidth: 2), ) : Icon( blocked ? Icons.block : Icons.play_arrow, size: 18, ), label: Text(strings.runStart), ), ), if (blocked) ...[ const SizedBox(width: FaiSpace.sm), Flexible( child: Text( '${strings.runErrorsBlockRun(errors)} · ' '${strings.runErrorsBlockHint}', style: theme.textTheme.bodySmall?.copyWith( color: theme.colorScheme.error, ), 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}); } /// Selectable error box used for the run-failure detail + the /// per-step failure detail. SelectableText + an explicit Copy /// button cover both keyboard and trackpad operators — the /// implicit selection gesture is too easy to miss on a long /// failure message. Matches the visual treatment Studio's /// `ChainErrorBox` uses elsewhere; kept local to this package /// so flow_editor doesn't depend on Studio internals. class _CopyableErrorBox extends StatefulWidget { final String text; final FlowEditorStrings strings; const _CopyableErrorBox({required this.text, required this.strings}); @override State<_CopyableErrorBox> createState() => _CopyableErrorBoxState(); } class _CopyableErrorBoxState extends State<_CopyableErrorBox> { bool _justCopied = false; Future _copy() async { await Clipboard.setData(ClipboardData(text: widget.text)); if (!mounted) return; setState(() => _justCopied = true); Future.delayed(const Duration(seconds: 2), () { if (mounted) setState(() => _justCopied = false); }); } @override Widget build(BuildContext context) { final theme = Theme.of(context); return Container( width: double.infinity, padding: const EdgeInsets.fromLTRB( FaiSpace.sm, FaiSpace.xs, FaiSpace.xs, FaiSpace.sm, ), decoration: BoxDecoration( color: theme.colorScheme.errorContainer, borderRadius: BorderRadius.circular(FaiRadius.sm), border: Border.all( color: theme.colorScheme.error.withValues(alpha: 0.4), ), ), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Align( alignment: Alignment.centerRight, child: Tooltip( message: _justCopied ? widget.strings.runCopied : widget.strings.runCopy, child: IconButton( icon: Icon( _justCopied ? Icons.check : Icons.content_copy, size: 14, ), visualDensity: VisualDensity.compact, padding: const EdgeInsets.all(4), constraints: const BoxConstraints(), onPressed: _copy, ), ), ), ConstrainedBox( constraints: const BoxConstraints(maxHeight: 220), child: Scrollbar( child: SingleChildScrollView( child: SelectableText( widget.text, style: TextStyle( fontFamily: 'JetBrains Mono', fontFamilyFallback: const [ 'Menlo', 'Consolas', 'Courier New', 'monospace', ], fontSize: 12, color: theme.colorScheme.onErrorContainer, ), ), ), ), ), ], ), ); } } /// Inline Approve / Reject card rendered under a step that /// the hub has paused on `system.approval@^0`. Removes the /// "go to the Approvals page" detour the operator otherwise /// has to take — the same decision can happen in-context while /// the rest of the run is on screen. /// /// Lifecycle: /// 1. Card mounts in awaiting-state with a fetch-in-flight /// indicator. /// 2. Driver returns the pending approval id for this /// (flowName, stepId) pair. Card switches to the Approve / /// Reject form. /// 3. Operator submits a decision. Card disables further /// interaction and waits — the next event (step.approved / /// step.rejected) is what causes the hub to resume the /// flow, and the StepStarted/StepFailed event will redraw /// the parent step row + remove this card. class _InlineApprovalCard extends StatefulWidget { final String stepId; final String flowName; final FlowRunDriver? driver; final FlowEditorStrings strings; final VoidCallback onDecided; const _InlineApprovalCard({ required this.stepId, required this.flowName, required this.driver, required this.strings, required this.onDecided, }); @override State<_InlineApprovalCard> createState() => _InlineApprovalCardState(); } class _InlineApprovalCardState extends State<_InlineApprovalCard> { String? _approvalId; bool _fetching = true; bool _submitting = false; bool _submitted = false; String? _error; late final TextEditingController _reasonCtrl; late final TextEditingController _reviewerCtrl; @override void initState() { super.initState(); _reasonCtrl = TextEditingController(); _reviewerCtrl = TextEditingController(text: _defaultReviewer()); _fetch(); } @override void dispose() { _reasonCtrl.dispose(); _reviewerCtrl.dispose(); super.dispose(); } String _defaultReviewer() { final user = Platform.environment['USER'] ?? Platform.environment['USERNAME'] ?? 'studio'; return '$user@studio'; } Future _fetch() async { final driver = widget.driver; if (driver == null) { setState(() => _fetching = false); return; } // The hub creates the approval row when the flow hits the // awaiting state. Race against the event arrival: poll up // to 5 × 600 ms before giving up. for (var i = 0; i < 5; i++) { try { final id = await driver.pendingApprovalIdForStep( flowName: widget.flowName, stepId: widget.stepId, ); if (!mounted) return; if (id != null) { setState(() { _approvalId = id; _fetching = false; }); return; } } catch (e) { if (!mounted) return; setState(() { _error = e.toString(); _fetching = false; }); return; } await Future.delayed(const Duration(milliseconds: 600)); } if (mounted) setState(() => _fetching = false); } Future _decide({required bool approve}) async { final id = _approvalId; final driver = widget.driver; if (id == null || driver == null) return; setState(() => _submitting = true); try { if (approve) { await driver.approveApproval( approvalId: id, reviewer: _reviewerCtrl.text.trim(), ); } else { await driver.rejectApproval( approvalId: id, reviewer: _reviewerCtrl.text.trim(), reason: _reasonCtrl.text.trim(), ); } if (!mounted) return; setState(() { _submitted = true; _submitting = false; }); widget.onDecided(); } catch (e) { if (!mounted) return; setState(() { _error = e.toString(); _submitting = false; }); } } @override Widget build(BuildContext context) { final theme = Theme.of(context); final strings = widget.strings; return Container( padding: const EdgeInsets.all(FaiSpace.sm), decoration: BoxDecoration( color: theme.colorScheme.tertiaryContainer.withValues(alpha: 0.5), borderRadius: BorderRadius.circular(FaiRadius.sm), border: Border.all( color: theme.colorScheme.tertiary.withValues(alpha: 0.4), ), ), child: _fetching ? Row( children: [ const SizedBox( width: 14, height: 14, child: CircularProgressIndicator(strokeWidth: 2), ), const SizedBox(width: FaiSpace.sm), Text(strings.runAwaitingApproval), ], ) : _submitted ? Row( children: [ Icon( Icons.check_circle_outline, size: 16, color: theme.colorScheme.tertiary, ), const SizedBox(width: FaiSpace.sm), Flexible(child: Text(strings.approvalDecided)), ], ) : _approvalId == null ? Row( children: [ Icon( Icons.hourglass_empty, size: 16, color: theme.colorScheme.onSurfaceVariant, ), const SizedBox(width: FaiSpace.sm), Flexible( child: Text( _error ?? strings.approvalNotFound, style: theme.textTheme.bodySmall, ), ), ], ) : Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ Expanded( child: TextField( controller: _reviewerCtrl, decoration: InputDecoration( labelText: strings.approvalReviewerLabel, border: const OutlineInputBorder(), isDense: true, ), ), ), const SizedBox(width: FaiSpace.md), Expanded( flex: 2, child: TextField( controller: _reasonCtrl, decoration: InputDecoration( labelText: strings.approvalReasonLabel, border: const OutlineInputBorder(), isDense: true, ), ), ), ], ), if (_error != null) ...[ const SizedBox(height: FaiSpace.sm), Text( _error!, style: theme.textTheme.bodySmall?.copyWith( color: theme.colorScheme.error, ), ), ], const SizedBox(height: FaiSpace.sm), Row( children: [ FilledButton.icon( onPressed: _submitting ? null : () => _decide(approve: true), icon: const Icon(Icons.check, size: 16), label: Text( _submitting ? strings.approvalSubmitting : strings.approvalApprove, ), ), const SizedBox(width: FaiSpace.sm), OutlinedButton.icon( onPressed: _submitting ? null : () => _decide(approve: false), icon: const Icon(Icons.close, size: 16), label: Text(strings.approvalReject), ), ], ), ], ), ); } }