chain-studio-flow-editor/lib/src/widgets/run_tab.dart
flemming-it 6b70ba65fd feat(editor): live step status on the graph during runs
Step run events were previously visible only on the run tab.
Operators who triggered a run there and then switched to
the graph tab saw a static graph — the canvas had no idea
something was executing.

Wire the run events through the FlowEditorController so all
three tabs share the same status snapshot:

 - New StepRunStatus enum on the controller
   (idle / running / done / failed / awaiting).
 - controller.stepStatuses exposes the live map.
 - controller.updateStepStatus is called from the run tab's
   event handler — one source of truth, both views read it.
 - FlowCanvas drops its own stepStatuses parameter and
   pulls from the controller instead, so the canvas now
   shows the running pulse + done check + error cross + pause
   icon in each step's header live, in lockstep with the
   step list on the run tab.

When a fresh run starts (controller.running = true) the
status map is cleared so stale events from a previous run
don't paint the new one.

Signed-off-by: flemming-it <sf@flemming.it>
2026-06-01 01:47:30 +02:00

577 lines
17 KiB
Dart

// 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/<name>.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<RunTab> createState() => _RunTabState();
}
class _RunTabState extends State<RunTab> {
final Map<String, TextEditingController> _textInputs = {};
final Map<String, _FilePick> _fileInputs = {};
StreamSubscription<FlowRunEvent>? _eventSub;
bool _running = false;
Map<String, FlowOutputValue>? _outputs;
Object? _error;
// Insertion-ordered: shows steps in the order the hub
// actually started them, matching the CLI rendering.
final Map<String, _StepState> _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 = <String>{};
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<void> _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<String>(
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<void> _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 = <String, String>{
for (final entry in _textInputs.entries) entry.key: entry.value.text,
};
final fileInputs = <String, Uint8List>{};
final fileMimes = <String, String>{};
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});
}