feat(editor): three-tab WYSIWYG editor — graph / text / run

Full rewrite of the editor surface, layered on top of the
FlowGraph foundation. One in-memory flow drives three tabs
that the operator can flip between freely:

 - Graph: a drag-and-drop canvas. Nodes are step cards with
   port dots on their left (one per `with:` field) and a
   combined output port on the right. Pinned inputs and
   outputs pseudo-nodes sit at the left and right edges so
   every flow has a visually obvious source and sink. Pan +
   zoom via InteractiveViewer; drag a node by its body to
   reposition it (positions persisted to a sidecar JSON file
   under ~/.fai/data/flows/.layout/<name>.json — kept OUT of
   the YAML so `fai run` stays byte-stable).
 - Text: the existing YAML CodeField with expands:true so
   line 1 anchors at the top edge. YAML-aware syntax
   highlighting picks up the theme's primary / secondary /
   tertiary palette for keys / strings / numbers.
 - Run: an inputs form (text fields + file-pick), a Start
   button that calls the host's FlowRunDriver, a live step
   list driven by the driver's event stream (matches the
   `fai run` CLI rendering — ◻ pending, · running, ✔ done +
   duration, ✗ failed, ⏸ awaiting approval), and the typed
   outputs once the run resolves.

Source of truth = the YAML text. Graph edits emit fresh YAML
into the shared CodeController; text edits re-parse the
graph on a 350 ms debounce. Layout sidecar persists drag
positions only.

New public API (lib/fai_studio_flow_editor.dart):

  FlowEditorPage(
    initialFlowName: ...,
    locale: ...,
    runDriver: FlowRunDriver?,        // NEW — host bridge
    availableCapabilities: List<String>, // NEW — for the
                                          // capability picker
                                          // dialog when adding
                                          // a step
  )

The host (Studio) implements FlowRunDriver to bridge the
hub's gRPC SDK into the editor's event vocabulary. The
StepStarted/Completed/Failed/AwaitingApproval events are
shared verbatim with the CLI's run_progress renderer so
both surfaces speak the same visual language.

Files in this commit:
 - lib/src/editor_controller.dart       — shared state +
   debounced reparse loop
 - lib/src/run_driver.dart              — host bridge
   interface + event types
 - lib/src/widgets/flow_canvas.dart     — pan / zoom / drag /
   port-to-port connection drawing
 - lib/src/widgets/flow_node.dart       — node card primitive
   (module / approval / inputs / outputs variants)
 - lib/src/widgets/edge_painter.dart    — single CustomPainter
   for every edge + draft drag line, cubic bezier with
   arrow-head caps
 - lib/src/widgets/properties_panel.dart — right-side editor
   when a step is selected (rename id, change capability, add
   / remove / rename with-fields, delete step)
 - lib/src/widgets/capability_picker.dart — searchable list
   dialog used by Add-step
 - lib/src/widgets/run_tab.dart         — inputs form +
   live step progress + outputs renderer
 - lib/src/flow_editor_page.dart        — host scaffolding,
   toolbar, file list, three-tab body, keyboard shortcuts
 - lib/src/l10n.dart                    — EN + DE strings for
   every new label
 - lib/fai_studio_flow_editor.dart      — exports the new
   public types (FlowRunDriver, FlowRunEvent variants,
   FlowOutputValue variants)

flutter analyze: 0 issues. flutter test: 7/7 green.

Signed-off-by: flemming-it <sf@flemming.it>
This commit is contained in:
flemming-it 2026-06-01 00:48:35 +02:00
parent dbd9a0004f
commit 870cbc29f7
12 changed files with 2769 additions and 408 deletions

View file

@ -0,0 +1,103 @@
// Capability picker modal dialog for choosing which
// capability a new step should use. Studio provides the
// list (it lives on the hub); the picker is a thin
// searchable list on top.
import 'package:flutter/material.dart';
import '../l10n.dart';
import '../tokens.dart';
class CapabilityPicker extends StatefulWidget {
final List<String> capabilities;
final FlowEditorStrings strings;
const CapabilityPicker({
super.key,
required this.capabilities,
required this.strings,
});
static Future<String?> show(
BuildContext context, {
required List<String> capabilities,
required FlowEditorStrings strings,
}) {
return showDialog<String>(
context: context,
builder: (_) =>
CapabilityPicker(capabilities: capabilities, strings: strings),
);
}
@override
State<CapabilityPicker> createState() => _CapabilityPickerState();
}
class _CapabilityPickerState extends State<CapabilityPicker> {
String _query = '';
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final filtered = _query.isEmpty
? widget.capabilities
: widget.capabilities
.where((c) => c.toLowerCase().contains(_query.toLowerCase()))
.toList();
return Dialog(
child: SizedBox(
width: 480,
height: 520,
child: Padding(
padding: const EdgeInsets.all(FaiSpace.md),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
widget.strings.pickerTitle,
style: theme.textTheme.titleMedium,
),
const SizedBox(height: FaiSpace.sm),
TextField(
autofocus: true,
decoration: InputDecoration(
prefixIcon: const Icon(Icons.search, size: 18),
hintText: widget.strings.pickerSearch,
isDense: true,
border: const OutlineInputBorder(),
),
onChanged: (v) => setState(() => _query = v),
),
const SizedBox(height: FaiSpace.sm),
Expanded(
child: filtered.isEmpty
? Center(
child: Text(
widget.strings.pickerEmpty,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
)
: ListView.builder(
itemCount: filtered.length,
itemBuilder: (_, i) {
final cap = filtered[i];
return ListTile(
dense: true,
title: Text(
cap,
style: const TextStyle(fontFamily: 'monospace'),
),
onTap: () => Navigator.pop(context, cap),
);
},
),
),
],
),
),
),
);
}
}

View file

@ -0,0 +1,105 @@
// EdgePainter draws every connection on the canvas as a
// cubic-bezier curve. Pure render layer: receives a flat list
// of port-to-port positions (already resolved against node
// positions) and paints them in one go.
//
// Connections are painted in a single CustomPaint so we don't
// pay the widget-tree cost of one painter per edge. Hit
// testing is delegated to port hit boxes drawn over the
// canvas; the painter itself is wrapped in IgnorePointer in
// the canvas so nothing here eats taps meant for nodes.
import 'package:flutter/material.dart';
class EdgeSegment {
final Offset from;
final Offset to;
final EdgeAccent accent;
const EdgeSegment({
required this.from,
required this.to,
this.accent = EdgeAccent.normal,
});
}
enum EdgeAccent { normal, highlight, draftDrag }
class EdgePainter extends CustomPainter {
final List<EdgeSegment> segments;
final Color baseColor;
final Color highlightColor;
final Color draftColor;
EdgePainter({
required this.segments,
required this.baseColor,
required this.highlightColor,
required this.draftColor,
});
@override
void paint(Canvas canvas, Size size) {
for (final seg in segments) {
final color = switch (seg.accent) {
EdgeAccent.normal => baseColor,
EdgeAccent.highlight => highlightColor,
EdgeAccent.draftDrag => draftColor,
};
final paint = Paint()
..color = color
..style = PaintingStyle.stroke
..strokeWidth = seg.accent == EdgeAccent.highlight ? 2.5 : 1.8
..strokeCap = StrokeCap.round;
final path = _bezier(seg.from, seg.to);
canvas.drawPath(path, paint);
// End-cap arrow: small filled triangle pointing at the
// target port so the reader can tell direction at a
// glance without inspecting the geometry.
_drawArrowHead(canvas, seg.to, seg.from, color);
}
}
Path _bezier(Offset from, Offset to) {
// Cubic with horizontal handles typical "flow diagram"
// smooth curve. The handle length scales with the
// horizontal distance so steep verticals still look
// graceful.
final dx = (to.dx - from.dx).abs();
final handleLen = (dx / 2).clamp(40.0, 220.0);
return Path()
..moveTo(from.dx, from.dy)
..cubicTo(
from.dx + handleLen,
from.dy,
to.dx - handleLen,
to.dy,
to.dx,
to.dy,
);
}
void _drawArrowHead(Canvas canvas, Offset tip, Offset from, Color color) {
// Approximate the incoming direction by sampling a
// little before the tip. Good enough since the curve is
// nearly horizontal near the end.
final dir = tip.dx - from.dx;
final orient = dir >= 0 ? 1.0 : -1.0;
const size = 6.0;
final p1 = tip;
final p2 = Offset(tip.dx - size * orient, tip.dy - size * 0.7);
final p3 = Offset(tip.dx - size * orient, tip.dy + size * 0.7);
final path = Path()
..moveTo(p1.dx, p1.dy)
..lineTo(p2.dx, p2.dy)
..lineTo(p3.dx, p3.dy)
..close();
canvas.drawPath(path, Paint()..color = color);
}
@override
bool shouldRepaint(EdgePainter old) =>
old.segments != segments ||
old.baseColor != baseColor ||
old.highlightColor != highlightColor ||
old.draftColor != draftColor;
}

View file

@ -0,0 +1,611 @@
// FlowCanvas the graphical flow editor surface.
//
// Layout:
//
//
//
//
// in step step out
//
//
//
// InteractiveViewer (pan + zoom)
//
// Special "inputs" and "outputs" pseudo-nodes are pinned to
// the left and right of the layout so every flow has a
// visually obvious source and sink. Step nodes live in the
// middle and can be dragged anywhere by the operator. Edges
// are derived live from the FlowGraph's $ref expressions.
//
// Interactions:
//
// - Click a node: selects it; properties panel hooks
// into the selection.
// - Drag the node body: repositions the node in canvas
// coords (sidecar saved on pan end).
// - Drag an output port drop on an input port:
// creates a `$source.field` reference
// in the target step's `with:` block.
// The properties panel will let the
// operator refine which output field
// the reference points at.
// - Background pan: scrolls the canvas via the parent
// InteractiveViewer.
import 'package:flutter/material.dart';
import '../editor_controller.dart';
import '../model/flow_graph.dart';
import '../model/layout_store.dart';
import 'edge_painter.dart';
import 'flow_node.dart';
/// Canvas dimensions. Big enough that any plausible flow fits
/// with margin to spare; the InteractiveViewer scrolls /
/// scales as needed.
const double _canvasWidth = 4000;
const double _canvasHeight = 3000;
// Fixed canvas-coords for the inputs/outputs pseudo-nodes.
// Steps auto-layout starts at AutoLayout.originX (=320), so
// inputs at x=40 leaves a comfortable gap; outputs slides to
// the right of the right-most step on render.
const double _inputsX = 40;
const double _inputsY = 80;
class FlowCanvas extends StatefulWidget {
final FlowEditorController controller;
final Map<String, FlowNodeStatus> stepStatuses;
const FlowCanvas({
super.key,
required this.controller,
this.stepStatuses = const {},
});
@override
State<FlowCanvas> createState() => _FlowCanvasState();
}
class _FlowCanvasState extends State<FlowCanvas> {
final TransformationController _transform = TransformationController();
// Active connection-drag state. When non-null, the canvas
// paints a draft edge from the source port to the cursor
// and accepts a drop on any input port.
_ConnectionDraft? _draft;
@override
void initState() {
super.initState();
widget.controller.addListener(_onControllerChanged);
}
@override
void dispose() {
widget.controller.removeListener(_onControllerChanged);
_transform.dispose();
super.dispose();
}
void _onControllerChanged() {
if (mounted) setState(() {});
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final graph = widget.controller.graph;
final layout = widget.controller.layout;
final outputsX = _outputsX(graph, layout);
return Container(
color: theme.colorScheme.surface,
child: InteractiveViewer(
transformationController: _transform,
constrained: false,
boundaryMargin: const EdgeInsets.all(400),
minScale: 0.4,
maxScale: 2.0,
child: SizedBox(
width: _canvasWidth,
height: _canvasHeight,
child: Stack(
children: [
_grid(theme),
// Edges first so nodes paint on top of them.
Positioned.fill(
child: IgnorePointer(
child: CustomPaint(
painter: EdgePainter(
segments: _buildSegments(graph, layout, outputsX),
baseColor: theme.colorScheme.onSurfaceVariant.withValues(
alpha: 0.55,
),
highlightColor: theme.colorScheme.primary,
draftColor: theme.colorScheme.primary,
),
),
),
),
// Inputs pseudo-node.
Positioned(
left: _inputsX,
top: _inputsY,
child: FlowNode(
id: '__inputs__',
title: 'inputs',
kind: NodeVisualKind.inputs,
inputPortLabels: graph.inputs.keys
.map((k) => '$k: ${graph.inputs[k]!.type}')
.toList(),
selected: false,
),
),
// Outputs pseudo-node.
Positioned(
left: outputsX,
top: _inputsY,
child: FlowNode(
id: '__outputs__',
title: 'outputs',
kind: NodeVisualKind.outputs,
inputPortLabels: graph.outputs.keys.toList(),
selected: false,
),
),
// Step nodes positioned absolutely, drag to
// move, click to select.
for (final step in graph.steps)
_stepPositioned(step, layout, outputsX),
// Port hit-targets for connection drawing. A
// transparent overlay positioned over each port
// easier to manage than per-port GestureDetectors
// inside the node widget because connection drags
// need to cross node boundaries (start in one node,
// end in another).
..._portOverlays(graph, layout, outputsX),
if (_draft != null)
Positioned.fill(
child: IgnorePointer(
child: CustomPaint(
painter: EdgePainter(
segments: [
EdgeSegment(
from: _draft!.from,
to: _draft!.cursor,
accent: EdgeAccent.draftDrag,
),
],
baseColor: theme.colorScheme.primary,
highlightColor: theme.colorScheme.primary,
draftColor: theme.colorScheme.primary,
),
),
),
),
],
),
),
),
);
}
// --- Step positioning + drag ---
Widget _stepPositioned(FlowStep step, FlowLayout layout, double _) {
final pos = layout.positions[step.id];
if (pos == null) return const SizedBox.shrink();
final selected = widget.controller.selectedStepId == step.id;
final status = widget.stepStatuses[step.id] ?? FlowNodeStatus.idle;
return Positioned(
left: pos.x,
top: pos.y,
child: FlowNode(
id: step.id,
title: step.id,
subtitle: step.use,
inputPortLabels: step.with_.keys.toList(),
kind: kindForStep(step),
selected: selected,
status: status,
onTap: () => widget.controller.selectStep(step.id),
onDrag: (delta) {
final scale = _transform.value.getMaxScaleOnAxis();
final scaledDelta = delta / scale;
final newPos = NodePosition(
(pos.x + scaledDelta.dx).clamp(
0.0,
_canvasWidth - NodeGeometry.width,
),
(pos.y + scaledDelta.dy).clamp(
0.0,
_canvasHeight - NodeGeometry.heightFor(step.with_.length),
),
);
widget.controller.moveStep(step.id, newPos);
},
),
);
}
// --- Port positions in canvas coordinates ---
Offset _outputPortPosition(
String nodeId,
FlowLayout layout,
double outputsX,
) {
if (nodeId == '__inputs__') {
// Doesn't have an "output" in the canvas sense — the
// inputs pseudo-node exposes its declared inputs as
// ports on its right edge, one per input. The
// _outputPortPosition is called only for step nodes.
// This branch is unreachable; return safely.
return const Offset(0, 0);
}
final pos = layout.positions[nodeId];
if (pos == null) return const Offset(0, 0);
return Offset(
pos.x + NodeGeometry.width,
pos.y + NodeGeometry.outputPortY(),
);
}
Offset _inputPortPosition(
String nodeId,
int portIndex,
FlowLayout layout,
double outputsX,
) {
if (nodeId == '__outputs__') {
return Offset(outputsX, _inputsY + NodeGeometry.inputPortY(portIndex));
}
final pos = layout.positions[nodeId];
if (pos == null) return const Offset(0, 0);
return Offset(pos.x, pos.y + NodeGeometry.inputPortY(portIndex));
}
// Inputs node exposes one port per declared input on its
// RIGHT edge every input is a "source" of data.
Offset _inputsPseudoPortPosition(int portIndex) {
return Offset(
_inputsX + NodeGeometry.width,
_inputsY + NodeGeometry.inputPortY(portIndex),
);
}
// --- Edge build (graph -> render segments) ---
List<EdgeSegment> _buildSegments(
FlowGraph graph,
FlowLayout layout,
double outputsX,
) {
final out = <EdgeSegment>[];
final inputsList = graph.inputs.keys.toList();
for (final edge in graph.edges) {
Offset? from;
Offset? to;
if (edge.fromKind == EdgeEndpointKind.inputs) {
final idx = inputsList.indexOf(edge.fromField);
if (idx >= 0) from = _inputsPseudoPortPosition(idx);
} else if (edge.fromKind == EdgeEndpointKind.step) {
from = _outputPortPosition(edge.fromId, layout, outputsX);
}
if (edge.toKind == EdgeEndpointKind.step) {
final step = graph.steps.firstWhere(
(s) => s.id == edge.toId,
orElse: () => const FlowStep(id: '__missing__', use: ''),
);
final idx = step.with_.keys.toList().indexOf(edge.toField);
if (idx >= 0) {
to = _inputPortPosition(edge.toId, idx, layout, outputsX);
}
} else if (edge.toKind == EdgeEndpointKind.outputs) {
final outputsList = graph.outputs.keys.toList();
final idx = outputsList.indexOf(edge.toField);
if (idx >= 0) {
to = _inputPortPosition('__outputs__', idx, layout, outputsX);
}
}
if (from == null || to == null) continue;
final highlight =
edge.fromId == widget.controller.selectedStepId ||
edge.toId == widget.controller.selectedStepId;
out.add(
EdgeSegment(
from: from,
to: to,
accent: highlight ? EdgeAccent.highlight : EdgeAccent.normal,
),
);
}
return out;
}
// --- Port overlays (drag handles for creating edges) ---
Iterable<Widget> _portOverlays(
FlowGraph graph,
FlowLayout layout,
double outputsX,
) sync* {
// Output ports only on step nodes. Inputs node has its
// own input-list port handles below.
for (final step in graph.steps) {
final p = _outputPortPosition(step.id, layout, outputsX);
yield _portDot(
center: p,
isSource: true,
onDragStart: () => _draft = _ConnectionDraft(
fromKind: _DraftSourceKind.step,
fromId: step.id,
from: p,
cursor: p,
),
);
}
// Inputs pseudo-node output ports (one per input).
final inputs = graph.inputs.keys.toList();
for (var i = 0; i < inputs.length; i++) {
final p = _inputsPseudoPortPosition(i);
final fieldName = inputs[i];
yield _portDot(
center: p,
isSource: true,
onDragStart: () => _draft = _ConnectionDraft(
fromKind: _DraftSourceKind.inputsField,
fromId: fieldName,
from: p,
cursor: p,
),
);
}
// Step input port targets.
for (final step in graph.steps) {
final keys = step.with_.keys.toList();
for (var i = 0; i < keys.length; i++) {
final p = _inputPortPosition(step.id, i, layout, outputsX);
yield _portDot(
center: p,
isSource: false,
onDropTarget: _dropTargetFor(step.id, keys[i], _DraftTargetKind.step),
);
}
}
// Outputs pseudo-node input ports.
final outs = graph.outputs.keys.toList();
for (var i = 0; i < outs.length; i++) {
final p = _inputPortPosition('__outputs__', i, layout, outputsX);
yield _portDot(
center: p,
isSource: false,
onDropTarget: _dropTargetFor(
'__outputs__',
outs[i],
_DraftTargetKind.outputsField,
),
);
}
}
Widget _portDot({
required Offset center,
required bool isSource,
VoidCallback? onDragStart,
void Function(Offset cursor)? onDropTarget,
}) {
final theme = Theme.of(context);
const size = 16.0;
return Positioned(
left: center.dx - size / 2,
top: center.dy - size / 2,
width: size,
height: size,
child: MouseRegion(
cursor: isSource ? SystemMouseCursors.grab : SystemMouseCursors.cell,
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onPanStart: !isSource
? null
: (details) {
onDragStart?.call();
setState(() {});
},
onPanUpdate: !isSource || _draft == null
? null
: (details) {
final scale = _transform.value.getMaxScaleOnAxis();
setState(() {
_draft = _draft!.withCursor(
_draft!.cursor + details.delta / scale,
);
});
},
onPanEnd: !isSource || _draft == null
? null
: (_) {
_finalizeDraft();
},
child: Container(
decoration: BoxDecoration(
shape: BoxShape.circle,
color: isSource
? theme.colorScheme.primary
: theme.colorScheme.surfaceContainerHighest,
border: Border.all(color: theme.colorScheme.primary, width: 1.5),
),
),
),
),
);
}
void Function(Offset) _dropTargetFor(
String targetId,
String targetField,
_DraftTargetKind kind,
) {
return (cursor) {
// Snap detection happens in _finalizeDraft via spatial
// search; this callback is for future port-level hit
// tracking if we add fine-grained drop highlighting.
};
}
void _finalizeDraft() {
final draft = _draft;
setState(() => _draft = null);
if (draft == null) return;
// Find the closest input port within tolerance.
final graph = widget.controller.graph;
final layout = widget.controller.layout;
final outputsX = _outputsX(graph, layout);
_DropTarget? best;
double bestDist = double.infinity;
const maxDist = 32.0;
for (final step in graph.steps) {
final keys = step.with_.keys.toList();
for (var i = 0; i < keys.length; i++) {
final p = _inputPortPosition(step.id, i, layout, outputsX);
final d = (draft.cursor - p).distance;
if (d < bestDist && d <= maxDist) {
bestDist = d;
best = _DropTarget(
kind: _DraftTargetKind.step,
id: step.id,
field: keys[i],
);
}
}
}
final outs = graph.outputs.keys.toList();
for (var i = 0; i < outs.length; i++) {
final p = _inputPortPosition('__outputs__', i, layout, outputsX);
final d = (draft.cursor - p).distance;
if (d < bestDist && d <= maxDist) {
bestDist = d;
best = _DropTarget(
kind: _DraftTargetKind.outputsField,
id: '__outputs__',
field: outs[i],
);
}
}
if (best == null) return;
_applyConnection(draft, best);
}
void _applyConnection(_ConnectionDraft draft, _DropTarget target) {
final graph = widget.controller.graph;
// Compose the $source.field expression. For step
// sources, we don't know the precise output field name
// (modules have varied output names); use "result" as a
// placeholder so the YAML is syntactically valid, and
// let the operator refine it in the properties panel.
final String expression;
switch (draft.fromKind) {
case _DraftSourceKind.step:
expression = '\$${draft.fromId}.result';
case _DraftSourceKind.inputsField:
expression = '\$inputs.${draft.fromId}';
}
switch (target.kind) {
case _DraftTargetKind.step:
final step = graph.steps.firstWhere((s) => s.id == target.id);
final newWith = {...step.with_, target.field: expression};
widget.controller.applyGraphEdit(
graph.withStepUpdated(target.id, step.copyWith(with_: newWith)),
);
case _DraftTargetKind.outputsField:
widget.controller.applyGraphEdit(
FlowGraph(
name: graph.name,
inputs: graph.inputs,
steps: graph.steps,
outputs: {...graph.outputs, target.field: expression},
leadingComment: graph.leadingComment,
),
);
}
}
// --- Outputs node placement ---
double _outputsX(FlowGraph graph, FlowLayout layout) {
double maxX = _inputsX + NodeGeometry.width + 200;
for (final step in graph.steps) {
final pos = layout.positions[step.id];
if (pos == null) continue;
final right = pos.x + NodeGeometry.width;
if (right > maxX) maxX = right;
}
return maxX + 120;
}
// --- Background ---
Widget _grid(ThemeData theme) {
return Positioned.fill(
child: IgnorePointer(
child: CustomPaint(
painter: _DotGridPainter(
color: theme.dividerColor.withValues(alpha: 0.55),
),
),
),
);
}
}
enum _DraftSourceKind { step, inputsField }
enum _DraftTargetKind { step, outputsField }
class _ConnectionDraft {
final _DraftSourceKind fromKind;
final String fromId;
final Offset from;
final Offset cursor;
const _ConnectionDraft({
required this.fromKind,
required this.fromId,
required this.from,
required this.cursor,
});
_ConnectionDraft withCursor(Offset c) => _ConnectionDraft(
fromKind: fromKind,
fromId: fromId,
from: from,
cursor: c,
);
}
class _DropTarget {
final _DraftTargetKind kind;
final String id;
final String field;
const _DropTarget({
required this.kind,
required this.id,
required this.field,
});
}
class _DotGridPainter extends CustomPainter {
final Color color;
_DotGridPainter({required this.color});
@override
void paint(Canvas canvas, Size size) {
const spacing = 24.0;
final paint = Paint()..color = color;
for (double x = 0; x < size.width; x += spacing) {
for (double y = 0; y < size.height; y += spacing) {
canvas.drawCircle(Offset(x, y), 0.8, paint);
}
}
}
@override
bool shouldRepaint(_DotGridPainter old) => old.color != color;
}

View file

@ -0,0 +1,264 @@
// FlowNode the visual representation of one step on the
// canvas. Three visual variants:
//
// - module step: rounded card, gear icon, primary border
// when selected
// - approval step: tertiary tint, hand-up icon, marks where
// the flow waits for a human
// - inputs / outputs sidebar nodes: tall, pinned to edges
//
// The card exposes named ports on its left (one per `with:`
// key) and a single combined port on its right that all
// downstream edges leave from. Ports are small filled circles
// just outside the card border so edges can visually anchor
// to them.
import 'package:flutter/material.dart';
import '../model/flow_graph.dart';
import '../tokens.dart';
/// Geometry constants the canvas + edge painter rely on to
/// place ports without measuring widgets at runtime. Changing
/// any of these requires reviewing edge_painter.dart.
class NodeGeometry {
static const double width = 220;
static const double headerHeight = 36;
static const double portRowHeight = 22;
static const double bottomPadding = 12;
static const double portRadius = 6;
// Offset from the card's outer edge for the port's center.
// Half-out so the dot visually "attaches" to the side.
static const double portInset = 0;
/// Total height for a step node given its with-field count.
/// Approval and inputs/outputs variants use the same maths
/// so the canvas can place every node identically.
static double heightFor(int portCount) {
final base = headerHeight + bottomPadding;
final ports = portCount * portRowHeight;
return base + ports + 8;
}
/// Y offset of an input port's centre, measured from the
/// top of the card. The first input port sits below the
/// header with a small gap.
static double inputPortY(int index) {
return headerHeight + 6 + index * portRowHeight + portRowHeight / 2;
}
/// Y offset of the single output port. Vertically centred
/// in the header band so the edge entry feels balanced.
static double outputPortY() {
return headerHeight / 2;
}
}
enum NodeVisualKind { module, approval, inputs, outputs }
class FlowNode extends StatelessWidget {
final String id;
final String title;
final String? subtitle;
final List<String> inputPortLabels;
final NodeVisualKind kind;
final bool selected;
final VoidCallback? onTap;
final void Function(Offset delta)? onDrag;
final VoidCallback? onDragEnd;
/// Live status from the most recent run, when this node is
/// a step. Coloured dot in the header so the operator can
/// glance at the canvas and see what's running.
final FlowNodeStatus status;
const FlowNode({
super.key,
required this.id,
required this.title,
this.subtitle,
this.inputPortLabels = const [],
this.kind = NodeVisualKind.module,
this.selected = false,
this.status = FlowNodeStatus.idle,
this.onTap,
this.onDrag,
this.onDragEnd,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final accent = _accent(theme.colorScheme);
final height = NodeGeometry.heightFor(inputPortLabels.length);
return SizedBox(
width: NodeGeometry.width,
height: height,
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: onTap,
onPanUpdate: onDrag == null ? null : (d) => onDrag!(d.delta),
onPanEnd: onDragEnd == null ? null : (_) => onDragEnd!(),
child: Material(
color: theme.colorScheme.surfaceContainerHigh,
elevation: selected ? 6 : 2,
shadowColor: selected ? accent.withValues(alpha: 0.35) : null,
borderRadius: BorderRadius.circular(FaiRadius.md),
child: Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(FaiRadius.md),
border: Border.all(
color: selected ? accent : theme.dividerColor,
width: selected ? 2 : 1,
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_header(theme, accent),
Expanded(child: _body(theme)),
],
),
),
),
),
);
}
Widget _header(ThemeData theme, Color accent) {
return Container(
height: NodeGeometry.headerHeight,
padding: const EdgeInsets.symmetric(horizontal: FaiSpace.sm),
decoration: BoxDecoration(
color: accent.withValues(alpha: 0.16),
borderRadius: const BorderRadius.only(
topLeft: Radius.circular(FaiRadius.md),
topRight: Radius.circular(FaiRadius.md),
),
),
child: Row(
children: [
Icon(_iconFor(kind), size: 16, color: accent),
const SizedBox(width: FaiSpace.xs),
Expanded(
child: Text(
title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.titleSmall?.copyWith(
fontFamily: 'monospace',
fontWeight: FontWeight.w600,
color: accent,
),
),
),
if (status != FlowNodeStatus.idle) _statusDot(theme),
],
),
);
}
Widget _body(ThemeData theme) {
return Padding(
padding: const EdgeInsets.fromLTRB(
FaiSpace.sm,
4,
FaiSpace.sm,
FaiSpace.sm,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (subtitle != null)
Padding(
padding: const EdgeInsets.only(bottom: 4),
child: Text(
subtitle!,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
fontFamily: 'monospace',
),
),
),
for (final label in inputPortLabels)
SizedBox(
height: NodeGeometry.portRowHeight,
child: Row(
children: [
Container(
width: 6,
height: 6,
decoration: BoxDecoration(
color: theme.colorScheme.onSurfaceVariant,
shape: BoxShape.circle,
),
),
const SizedBox(width: FaiSpace.xs),
Flexible(
child: Text(
label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.labelSmall?.copyWith(
color: theme.colorScheme.onSurface,
),
),
),
],
),
),
],
),
);
}
Widget _statusDot(ThemeData theme) {
final color = switch (status) {
FlowNodeStatus.running => theme.colorScheme.primary,
FlowNodeStatus.done => Colors.green.shade400,
FlowNodeStatus.failed => theme.colorScheme.error,
FlowNodeStatus.awaiting => theme.colorScheme.tertiary,
FlowNodeStatus.idle => Colors.transparent,
};
return Container(
width: 8,
height: 8,
decoration: BoxDecoration(
color: color,
shape: BoxShape.circle,
boxShadow: status == FlowNodeStatus.running
? [BoxShadow(color: color.withValues(alpha: 0.6), blurRadius: 6)]
: null,
),
);
}
IconData _iconFor(NodeVisualKind k) {
return switch (k) {
NodeVisualKind.module => Icons.widgets_outlined,
NodeVisualKind.approval => Icons.pan_tool_outlined,
NodeVisualKind.inputs => Icons.input,
NodeVisualKind.outputs => Icons.output,
};
}
Color _accent(ColorScheme cs) {
return switch (kind) {
NodeVisualKind.module => cs.primary,
NodeVisualKind.approval => cs.tertiary,
NodeVisualKind.inputs => cs.secondary,
NodeVisualKind.outputs => cs.secondary,
};
}
}
enum FlowNodeStatus { idle, running, done, failed, awaiting }
/// Render-time helper: figure out which visual variant to use
/// for a parsed step.
NodeVisualKind kindForStep(FlowStep step) {
if (step.isApproval) return NodeVisualKind.approval;
return NodeVisualKind.module;
}

View file

@ -0,0 +1,404 @@
// PropertiesPanel side drawer showing the currently
// selected step's details.
//
// Lets the operator edit:
// - step `id` (with collision warning before applying)
// - step `use` (capability spec)
// - each `with:` key/value pair (rename key, edit value,
// add new key, remove key)
// - delete the entire step
//
// The panel emits FlowGraph mutations through the controller,
// which re-emits the YAML buffer + updates the canvas in one
// pass.
import 'package:flutter/material.dart';
import '../editor_controller.dart';
import '../l10n.dart';
import '../model/flow_graph.dart';
import '../tokens.dart';
class PropertiesPanel extends StatefulWidget {
final FlowEditorController controller;
final FlowEditorStrings strings;
final List<String> availableCapabilities;
const PropertiesPanel({
super.key,
required this.controller,
required this.strings,
this.availableCapabilities = const [],
});
@override
State<PropertiesPanel> createState() => _PropertiesPanelState();
}
class _PropertiesPanelState extends State<PropertiesPanel> {
// Local edit buffers committed back to the controller
// on focus loss / explicit save so each keystroke doesn't
// round-trip through YAML emission.
final _idCtrl = TextEditingController();
final _useCtrl = TextEditingController();
// Per-with-field controllers, keyed by current field name.
final Map<String, TextEditingController> _withCtrls = {};
String? _trackedStepId;
@override
void initState() {
super.initState();
widget.controller.addListener(_sync);
_sync();
}
@override
void dispose() {
widget.controller.removeListener(_sync);
_idCtrl.dispose();
_useCtrl.dispose();
for (final c in _withCtrls.values) {
c.dispose();
}
super.dispose();
}
void _sync() {
final selected = widget.controller.selectedStepId;
if (selected != _trackedStepId) {
_trackedStepId = selected;
for (final c in _withCtrls.values) {
c.dispose();
}
_withCtrls.clear();
if (selected != null) {
final step = widget.controller.graph.steps.firstWhere(
(s) => s.id == selected,
orElse: () => _empty,
);
_idCtrl.text = step.id;
_useCtrl.text = step.use;
for (final entry in step.with_.entries) {
_withCtrls[entry.key] = TextEditingController(
text: entry.value.toString(),
);
}
}
}
if (mounted) setState(() {});
}
static const _empty = FlowStep(id: '', use: '');
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final controller = widget.controller;
final selected = controller.selectedStepId;
if (selected == null) return _emptyHint(theme);
final step = controller.graph.steps.firstWhere(
(s) => s.id == selected,
orElse: () => _empty,
);
if (step.id.isEmpty) return _emptyHint(theme);
return Container(
color: theme.colorScheme.surface,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_header(theme, step),
Expanded(
child: SingleChildScrollView(
padding: const EdgeInsets.all(FaiSpace.md),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_section(theme, widget.strings.propStepId),
_idField(theme),
const SizedBox(height: FaiSpace.md),
_section(theme, widget.strings.propCapability),
_useField(theme),
const SizedBox(height: FaiSpace.md),
_section(theme, widget.strings.propInputs),
..._withFields(theme),
TextButton.icon(
onPressed: _addWithField,
icon: const Icon(Icons.add, size: 16),
label: Text(widget.strings.propAddInput),
),
const SizedBox(height: FaiSpace.lg),
OutlinedButton.icon(
onPressed: () => _deleteStep(step.id),
icon: const Icon(Icons.delete_outline, size: 16),
label: Text(widget.strings.propDeleteStep),
style: OutlinedButton.styleFrom(
foregroundColor: theme.colorScheme.error,
),
),
],
),
),
),
],
),
);
}
Widget _emptyHint(ThemeData theme) {
return Container(
color: theme.colorScheme.surface,
padding: const EdgeInsets.all(FaiSpace.lg),
child: Center(
child: Text(
widget.strings.propNoSelection,
textAlign: TextAlign.center,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
),
);
}
Widget _header(ThemeData theme, FlowStep step) {
return Container(
padding: const EdgeInsets.symmetric(
horizontal: FaiSpace.md,
vertical: FaiSpace.sm,
),
decoration: BoxDecoration(
color: theme.colorScheme.surfaceContainer,
border: Border(bottom: BorderSide(color: theme.dividerColor)),
),
child: Row(
children: [
Icon(
step.isApproval ? Icons.pan_tool_outlined : Icons.widgets_outlined,
size: 16,
color: theme.colorScheme.primary,
),
const SizedBox(width: FaiSpace.xs),
Expanded(
child: Text(
widget.strings.propPanelTitle,
style: theme.textTheme.titleSmall?.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 _idField(ThemeData theme) {
return TextField(
controller: _idCtrl,
style: const TextStyle(fontFamily: 'monospace'),
decoration: const InputDecoration(
isDense: true,
border: OutlineInputBorder(),
),
onSubmitted: (_) => _commitId(),
onTapOutside: (_) => _commitId(),
);
}
Widget _useField(ThemeData theme) {
final caps = widget.availableCapabilities;
if (caps.isEmpty) {
return TextField(
controller: _useCtrl,
style: const TextStyle(fontFamily: 'monospace'),
decoration: const InputDecoration(
isDense: true,
hintText: 'e.g. text.extract@^0',
border: OutlineInputBorder(),
),
onSubmitted: (_) => _commitUse(),
onTapOutside: (_) => _commitUse(),
);
}
return Autocomplete<String>(
initialValue: TextEditingValue(text: _useCtrl.text),
optionsBuilder: (input) {
final query = input.text.toLowerCase();
if (query.isEmpty) return caps;
return caps.where((c) => c.toLowerCase().contains(query));
},
onSelected: (sel) {
_useCtrl.text = sel;
_commitUse();
},
fieldViewBuilder: (ctx, fieldCtrl, focus, onSubmit) {
fieldCtrl.text = _useCtrl.text;
return TextField(
controller: fieldCtrl,
focusNode: focus,
style: const TextStyle(fontFamily: 'monospace'),
decoration: const InputDecoration(
isDense: true,
hintText: 'e.g. text.extract@^0',
border: OutlineInputBorder(),
),
onSubmitted: (val) {
_useCtrl.text = val;
_commitUse();
},
onTapOutside: (_) {
_useCtrl.text = fieldCtrl.text;
_commitUse();
},
);
},
);
}
List<Widget> _withFields(ThemeData theme) {
final entries = _withCtrls.entries.toList();
return [
for (final entry in entries)
Padding(
padding: const EdgeInsets.only(bottom: FaiSpace.sm),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
width: 100,
child: TextField(
controller: TextEditingController(text: entry.key),
style: const TextStyle(fontFamily: 'monospace'),
decoration: const InputDecoration(
isDense: true,
border: OutlineInputBorder(),
),
onSubmitted: (newKey) => _renameWithField(entry.key, newKey),
),
),
const SizedBox(width: FaiSpace.xs),
Expanded(
child: TextField(
controller: entry.value,
style: const TextStyle(fontFamily: 'monospace'),
decoration: const InputDecoration(
isDense: true,
border: OutlineInputBorder(),
),
onSubmitted: (_) => _commitWithFields(),
onTapOutside: (_) => _commitWithFields(),
),
),
IconButton(
onPressed: () => _removeWithField(entry.key),
icon: const Icon(Icons.close, size: 16),
tooltip: widget.strings.propRemoveInput,
visualDensity: VisualDensity.compact,
),
],
),
),
];
}
void _commitId() {
final selected = widget.controller.selectedStepId;
if (selected == null) return;
final newId = _idCtrl.text.trim();
if (newId.isEmpty || newId == selected) return;
final graph = widget.controller.graph;
// Reject collisions silently UI should ideally show a
// warning. For now: refuse to apply, snap controller back.
if (graph.steps.any((s) => s.id == newId)) {
_idCtrl.text = selected;
return;
}
final step = graph.steps.firstWhere((s) => s.id == selected);
widget.controller.applyGraphEdit(
graph.withStepUpdated(selected, step.copyWith(id: newId)),
);
widget.controller.selectStep(newId);
}
void _commitUse() {
final selected = widget.controller.selectedStepId;
if (selected == null) return;
final graph = widget.controller.graph;
final step = graph.steps.firstWhere((s) => s.id == selected);
if (step.use == _useCtrl.text) return;
widget.controller.applyGraphEdit(
graph.withStepUpdated(selected, step.copyWith(use: _useCtrl.text)),
);
}
void _commitWithFields() {
final selected = widget.controller.selectedStepId;
if (selected == null) return;
final graph = widget.controller.graph;
final step = graph.steps.firstWhere((s) => s.id == selected);
final newWith = <String, dynamic>{
for (final entry in _withCtrls.entries) entry.key: entry.value.text,
};
if (_mapsEqual(step.with_, newWith)) return;
widget.controller.applyGraphEdit(
graph.withStepUpdated(selected, step.copyWith(with_: newWith)),
);
}
void _renameWithField(String oldKey, String newKey) {
final selected = widget.controller.selectedStepId;
if (selected == null) return;
if (newKey.isEmpty || newKey == oldKey) return;
if (_withCtrls.containsKey(newKey)) return; // collision
final ctrl = _withCtrls.remove(oldKey);
if (ctrl != null) _withCtrls[newKey] = ctrl;
_commitWithFields();
}
void _addWithField() {
final base = 'value';
String name = base;
var i = 1;
while (_withCtrls.containsKey(name)) {
name = '${base}_$i';
i++;
}
setState(() {
_withCtrls[name] = TextEditingController(text: '');
});
_commitWithFields();
}
void _removeWithField(String key) {
final ctrl = _withCtrls.remove(key);
ctrl?.dispose();
setState(() {});
_commitWithFields();
}
void _deleteStep(String id) {
widget.controller.applyGraphEdit(
widget.controller.graph.withStepRemoved(id),
);
widget.controller.selectStep(null);
}
bool _mapsEqual(Map<String, dynamic> a, Map<String, dynamic> b) {
if (a.length != b.length) return false;
for (final entry in a.entries) {
if (b[entry.key].toString() != entry.value.toString()) return false;
}
return true;
}
}

View file

@ -0,0 +1,564 @@
// 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);
case StepCompleted():
_liveSteps[event.stepId] = _StepState(
kind: _StepKind.done,
durationMs: event.durationMs,
);
case StepFailed():
_liveSteps[event.stepId] = _StepState(
kind: _StepKind.error,
error: event.error,
);
case StepAwaitingApproval():
_liveSteps[event.stepId] = const _StepState(kind: _StepKind.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});
}