Two operator-visible fixes from the 0.5.2 review:
(1) Draft line stopped short of the cursor. EdgePainter
shortens every segment endpoint by portRadius to dock at
port-dot perimeters — but the DRAFT segment's `to` is the
cursor itself, not a port. Result: while dragging a
connection, the live line ended 6 px short of where the
operator's mouse actually was. Felt like the cable
wouldn't reach.
EdgeSegment gains `shortenFrom` / `shortenTo` flags
(default true). The draft segment passes `shortenTo:
false`, so the line tip tracks the cursor exactly. From-
side stays shortened because the FROM is still a real
port dot.
(2) Ports had no hover affordance — mouse-over was
invisible. Operators couldn't tell ahead of time that a
port was interactive.
Adds `_hoveredPort` state on the canvas, updated via the
existing MouseRegion's onEnter / onExit. Port dot now has
three focus levels:
resting → 12 px, 1.8 px border, no glow
hover → 15 px, 2.2 px border, soft accent glow
drop-target (drag is over it) → 18 px, 2.5 px border,
stronger glow
The hover state pre-shadows the drop-target state —
operators see "yes, this port is grabbable / droppable"
before they commit to dragging. When the drag does start,
the drop-target halo is the same family of treatment,
just stronger, so the visual progression reads as one
continuous interaction.
Signed-off-by: flemming-it <sf@flemming.it>
Stefan's "the connections to the round ports look
unelegant" feedback was about three concrete things:
(1) The bezier endpoint sat at the port dot's GEOMETRIC
CENTRE. The dot was rendered ON TOP, so the line visually
disappeared into the centre of the dot — looked like a
thread being eaten by a bead.
(2) A separate arrow-head triangle was drawn on top of the
dot, doubling the visual terminator and making the
endpoint look "noisy".
(3) The dots were hollow rings the same colour as the
line, so the boundary between line and dot blurred
visually. They didn't read as connectors.
Fix:
- EdgeSegment now carries `fromSide` + `toSide`
(left / right) so the painter knows which way to
shorten each endpoint. The bezier ends `portRadius` px
short of the centre — exactly on the dot's outer
perimeter, on the side facing the line. The line now
"docks" cleanly at the rim.
- The end-cap triangle arrow is gone. The dot itself is
the visual terminator; an arrow on top was redundant.
- Port dot redesigned as a connector-socket: outer ring
defines the footprint, inner 4-px surface-coloured pin
appears when connected. The result reads as "an active
socket with a plug seated in it" rather than "a hollow
circle next to a card edge". Empty ports stay surface-
filled rings — unambiguous empty-slot signal.
- Bezier handle length floor raised from 40 to 60 px so
vertical detours (e.g. inputs-endpoint → step far below)
curve gracefully out of the start before dropping
instead of kinking near the origin.
- Stroke width nudged from 1.8 to 2.0 px for the normal
accent so the line carries the right visual weight
against the new docked-at-perimeter port treatment.
Visual: lines now look like cables seated into sockets,
not strings vanishing under beads.
Signed-off-by: flemming-it <sf@flemming.it>
Two operator-visible improvements:
- Five payload types now get five distinct hues that
survive both light and dark themes:
text → blue (0xFF42A5F5)
bytes → deep orange (0xFFFF7043)
json → purple (0xFFAB47BC)
file → green (0xFF66BB6A)
number → amber (0xFFFFCA28)
Previously bytes + file shared a colour and the palette
collapsed under custom theme plugins. Hardcoded hues
keep them readable everywhere.
- Step headers now carry a small "wired/total" badge.
When all with-fields carry a `$src.field` reference,
the badge collapses to a green check icon — the visual
"this module is fully wired" signal Stefan asked for.
Partial-wiring shows e.g. "3/5" in a pill matching the
step accent. Endpoint nodes don't render the badge
(ratio doesn't apply there).
Computed live from graph.edges + step.with_ values, so the
indicator updates the moment an operator drags a
connection in or out.
Version 0.5.0 -> 0.5.1.
Signed-off-by: flemming-it <sf@flemming.it>
Rewrites the port-rendering model around the operator's
expectation that each port is a single visible dot with
clear state. Closes four concrete complaints from the
0.4.0 review.
Removes the redundant inline dot:
- FlowNode._body no longer paints any inline 8-px dot.
The canvas-side dot is the SINGLE visible port. Size
matches the label row (12-px diameter, NodeGeometry.
portDotSize). No more "two dots per port" doubling.
Sides + alignment:
- New NodePortSide enum on FlowNode. `left` for step
inputs + outputs endpoint inputs; `right` for the
inputs endpoint (its body labels represent OUTPUTS of
the node — data flows OUT to downstream steps, so the
port dots belong on the right edge).
- Body labels right-align on right-side nodes,
left-align elsewhere. The 18-px port-gutter inside the
body keeps the dot from overlapping label text on the
port-bearing edge.
- Inputs endpoint dots now hang off the RIGHT edge of
the node, matching where the visible label gravitates.
Previously the dot was on the right but the labels
crowded the left — visually disconnected.
State-aware rendering:
- Port dots render FILLED when participating in an
edge, OUTLINED when dangling. `_connectedPorts` walks
graph.edges once per build and marks every endpoint.
Operator sees at a glance which inputs are wired vs
which need attention.
- Type-coloured: inputs endpoint dots take the type
accent (text → primary, bytes/file → tertiary,
json → secondary, number → amber, unknown → muted).
Edge deletion via port context menu:
- Right-click on a wired input port (step or outputs
endpoint) opens a Disconnect popup. Confirming clears
the with-field / output expression to empty, edge
disappears, port flips outlined. No need to chase the
YAML buffer manually.
- Unwired ports get no context menu (nothing to
disconnect) — keeps the menu honest.
The drop-target halo on drag stays — closest input port
within snap distance still inflates to 18 px and gets a
brighter halo, so the connection-drawing UX is unchanged.
Version 0.4.0 -> 0.5.0 — refactored API + visible port
model change warrants the minor bump.
Signed-off-by: flemming-it <sf@flemming.it>
Four operator-visible improvements in one cut.
Port geometry (the "connection dots don't line up with the
labels" bug):
- Header now has a fixed two-line structure (title + 16-px
subtitle slot) regardless of node kind, so the body's
port-row Y-offsets are identical across step nodes and
endpoint nodes.
- NodeGeometry constants rewritten so the inline 8-px dot
inside each port row and the 16-px canvas-side dot both
compute to the same Y. Port rows now anchor where the
eye expects them.
Port type colours:
- Inputs endpoint labels carry `name: type` (e.g.
`doc: bytes`), so the FlowNode body parses the type and
tints the inline dot per type: text → primary,
bytes/file → tertiary, json → secondary, number → amber,
unknown → muted. Step input ports stay muted until a
future commit can read module manifests for type info.
Endpoint editor:
- Inputs and outputs nodes are now selectable. Selecting
opens a dedicated editor in the properties panel:
add / rename / retype / remove entries graphically
instead of forcing the operator into the text tab.
Inputs editor offers a Type dropdown
(text / bytes / json / file / number);
outputs editor exposes name + expression
(e.g. `$step.field`).
Unsaved badge:
- The toolbar's tiny 8-px dirty dot was easy to miss.
Replace it with a coloured "unsaved" / "ungespeichert"
chip + colour the file name itself in the primary accent
when dirty. Operators see at a glance that a flow has
unsaved changes, which matters because the Discard
confirmation prompt won't fire if they don't realise
they made changes.
Version 0.3.0 -> 0.4.0 — first new editor feature surface
since 0.3.
Signed-off-by: flemming-it <sf@flemming.it>
Two operator-visible features filling concrete gaps the
jai_client editor exposed:
- Right-click on any step node opens a popup menu at the
cursor position with three actions:
* Duplicate — clones the step with a unique id
(<base>_2, <base>_3, …) and offsets its position so
the copy is visible next to the original.
* Disconnect all inputs — keeps every with-field key
but clears its expression value, so the step still
declares the same inputs but no longer wires them to
any upstream step.
* Delete — removes the step. Any dangling $refs in
downstream steps become run-time errors; we don't
silently rewrite their YAML because the surprise
would be worse than the explicit error.
- Floating "Reset layout" button on the canvas (next to
Fit to screen). Wipes the layout sidecar so AutoLayout
re-runs from scratch and the topological column
placement is restored. Auto-fits afterwards. Useful
when manual drags have drifted into spaghetti and the
operator wants a clean slate without losing the flow.
Implementation:
- FlowNode gains an `onContextMenu(Offset globalPos)`
parameter, wired to GestureDetector.onSecondaryTapDown.
- FlowEditorController.resetLayout() — clears layout,
re-seeds via AutoLayout, saves sidecar, notifies.
- The menu uses Flutter's showMenu() at the global
cursor position for native-feeling positioning.
Version bumped 0.2.2 -> 0.3.0 — first new operator-visible
feature set since the v0.2 WYSIWYG release.
Signed-off-by: flemming-it <sf@flemming.it>
The 0.2.x canvas auto-recomputed the outputs endpoint's X
position from `max(step.x) + nodeWidth + 120` every build.
Dragging any step rebuilt the layout, the recompute kicked
in, and the outputs panel + every edge entering it
shifted across the screen. Mirror image: the inputs panel
was nailed to a hardcoded (40, 80) but the right side of
the world moved every time the operator touched a node.
Visually catastrophic; Stefan flagged it as the same kind
of "everything moves when I touch one thing" failure that
shelved the jai_client editor.
Structural fix: treat inputs and outputs as first-class
nodes with positions stored in the same layout sidecar as
every step. There is no more auto-recompute path.
Concretely:
- AutoLayout.layout now seeds NodePositions for the
reserved IDs `__inputs__` (column −1) and `__outputs__`
(column max+1) on first open. Existing positions are
never overwritten so subsequent layout passes don't
fight operator-chosen placements.
- FlowCanvas reads inputs/outputs positions from
layout.positions instead of hardcoded `_inputsX` /
`_inputsY` constants and the computed `_outputsX(...)`.
Both functions are deleted; the canvas now has one
single source of truth for ALL node positions, the
sidecar.
- The inputs / outputs endpoints render via a new
`_endpointPositioned` helper that mirrors
`_stepPositioned` — same FlowNode widget, same
drag handler, same `controller.moveStep` path. The
operator can grab the inputs panel and slide it
wherever; the position persists to the sidecar like
every step.
- All port-position helpers (`_outputPortPosition`,
`_inputPortPosition`, the inputs-endpoint port
position) now take a single `FlowLayout` and read
coordinates from there. No more `double outputsX`
parameter threaded through every method.
- Fit-to-content now expands the bounding box using the
endpoints' SIDECAR positions rather than a re-derived
outputs X. Same single source of truth.
Side-benefit: the editor's coordinate model is now strictly
sidecar-driven. No state derivation lives in render code.
This rules out the catastrophic "drag-mid-flight-redoes-
coordinate-transforms" failure mode jai_client hit (where
internal offsets grew during a drag and broke port
positions).
flutter analyze clean, 12/12 tests pass.
Signed-off-by: flemming-it <sf@flemming.it>
New test mirrors the most complex shipped example —
three-step chain with a system.approval gate in the middle,
multi-line prompts containing embedded \$step.field refs,
outputs that reference multiple steps. Verifies:
- step count + approval flagging
- edge detection (8 edges across inputs, steps, outputs)
- structural round-trip stability through toYaml() /
fromYaml()
If this test stays green, the editor is safe on every flow
currently shipped under flows/. 12/12 pass.
Signed-off-by: flemming-it <sf@flemming.it>
While the operator is dragging from an output port, every
input port now lights up with a tint so they can see at a
glance where the connection can land. The closest port
within the snap distance gets a larger halo + brighter
border so the operator knows which port will accept the
drop if they release now.
Visual states:
- input port, no drag: muted background
- input port, drag in flight: primary @ 35% alpha
- input port, closest target: primary fill + 2.5 px border
+ soft halo glow
The closest-target computation runs once per port per
frame during a drag — O(n) over visible input ports.
Negligible at flow sizes operators care about; revisit if
flows exceed a few dozen steps.
Signed-off-by: flemming-it <sf@flemming.it>
Brief 1.5 s green-check snackbar appears at the bottom of
the editor after a successful save. Confirmation matters
here because Cmd+S is the operator's most common keystroke
in any editor — without visible feedback, they'd need to
hunt for the dirty dot to verify the save landed.
The error path's existing snackbar (which has no duration
cap by design — operators must read errors) stays
unchanged.
Signed-off-by: flemming-it <sf@flemming.it>
Expands README to describe the three-tab UX, the source-of-
truth model (YAML + sidecar layout), the FlowRunDriver
contract, the folder layout, and how to swap the editor.
Version 0.2.0 -> 0.2.1 — picks up the live step status,
the empty-graph CTA, and the fit-to-screen button added
since the initial 0.2.0 commit.
Signed-off-by: flemming-it <sf@flemming.it>
Two polishing tweaks on the graph tab:
- When the active flow has no steps, the canvas would have
rendered as a near-empty grid (just inputs / outputs
sidebars). Overlay a centred call-to-action with the
graph icon, the "No steps yet" / "Noch keine Schritte"
heading, a one-liner explainer, and a primary Add Step
button that opens the capability picker directly.
- Auto-fit zoom on first open of each flow + a floating
"Fit to screen" button (bottom-right corner of the
canvas viewport). The auto-fit re-runs only when the
flow name changes, so the operator's manual pan / zoom
on the current flow is preserved. The fit math expands
the bounding box to include inputs + outputs pseudo-
nodes too, so wide flows zoom out enough to show every
port at once.
Fit math:
- Walk every step's stored position + height; merge with
the inputs sidebar's known position.
- Compute scale that makes the bounding box fit the
viewport with 80 px padding on each side.
- Clamp to [0.4, 2.0] — InteractiveViewer's own bounds.
- Translate so the box centres in the viewport.
`flutter analyze` clean, all 11 tests still pass.
Signed-off-by: flemming-it <sf@flemming.it>
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>
Four behaviour tests guarding the controller's contract with
the rest of the editor:
- openFlow seeds the graph + clears the dirty flag against
the freshly-captured baseline.
- graph edits re-emit YAML into the shared CodeController
so the text tab stays in lockstep.
- graph edits mark the buffer dirty against the baseline;
markSaved snaps it back to clean.
- selectStep is idempotent: re-selecting the same step
doesn't notify listeners (cheap rebuild guard for the
properties panel).
11/11 tests pass.
Signed-off-by: flemming-it <sf@flemming.it>
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>
First piece of the v0.2.0 rewrite. The graph view, text view,
and run view will all share a single source of truth: the
YAML text. This commit introduces the parsing layer + the
sidecar that keeps layout metadata OUT of the YAML.
FlowGraph (lib/src/model/flow_graph.dart):
- Parses + emits the F-Delta-I flow YAML shape (inputs +
steps + outputs + leading comment block).
- Recognises both inputs shorthand (`name: text`) and the
expanded form (`name: { type: text, default: ..., hint: ... }`).
- Detects edges by scanning step.with + outputs for
$source.field references; understands both the short
$x.y form (canonical) and the long ${{ x.y }} form (legacy).
- Flags system.approval@ steps via FlowStep.isApproval so
the graph can paint them with the human-gate styling.
- Immutable update helpers (withStepUpdated, withStepAdded,
withStepRemoved) used by the property panel + add-step
button.
- tryParse returns null on YAML syntax errors so the text
tab can keep the last valid graph while the operator
types.
LayoutStore + FlowLayout (lib/src/model/layout_store.dart):
- Per-flow JSON sidecar at
~/.fai/data/flows/.layout/<name>.json. Atomic write via
tmp + rename so a crash mid-save leaves the previous
copy intact.
- The YAML stays byte-identical to what `fai run` consumes
— positions never bleed into the flow file.
AutoLayout (lib/src/model/auto_layout.dart):
- Deterministic topological column layout for flows that
have never been opened in the graph view before.
- Steps with no $step refs sit in column 0; steps whose
refs all point at column-0 steps sit in column 1; etc.
- Cycles + dangling refs collapse to column 0 so nothing
is ever invisible.
Tests (test/flow_graph_test.dart): 7 cases cover canonical
parsing, edge detection across both ref forms, approval-step
flagging, leading-comment round-trip, structural round-trip,
parser robustness on broken YAML, immutability invariant.
All pass.
Version 0.1.4 -> 0.2.0 (minor bump — significant new module
graph; public FlowEditorPage constructor API unchanged).
Signed-off-by: flemming-it <sf@flemming.it>
Two changes Stefan flagged on 0.1.3:
(1) Short files were still vertically centered. The previous
fix (CrossAxisAlignment.stretch on the Row) only stretched
the SingleChildScrollView viewport — the CodeField inside
still had its natural content-sized height and sat centered
in the larger viewport. Drop the SCV wrapper entirely and
set `expands: true` (with `minLines: null` + `maxLines:
null`, the underlying TextField's required combination) on
the CodeField. The widget now fills its parent's bounded
height, the line-number gutter runs the full editor height,
and line 1 sits at the literal top edge. Standard IDE
behaviour.
(2) The round outlined Refresh button sandwiched between
New (filled-tonal) and Save (filled-tonal) in the editor
toolbar was a visual-style outlier — Stefan called it
"dazwischen, sieht falsch aus". Refresh is also
semantically scoped to the file list, not the open
editor, so move it out of the editor toolbar entirely and
into a small FLOWS header bar at the top of the file-list
panel. The editor toolbar now reads:
[Back] file.yaml ● [Spacer] [New] [Save] [Run]
— four filled-tonal/filled buttons, no style outliers.
The file-list panel reads:
┌───────────────────────┐
│ FLOWS ⟲ │
├───────────────────────┤
│ flow-a │
│ flow-b │
└───────────────────────┘
Adds `listHeader` to FlowEditorStrings (de + en both
"FLOWS" — same word, all-caps).
Version 0.1.3 -> 0.1.4.
Signed-off-by: flemming-it <sf@flemming.it>
Row was using CrossAxisAlignment.center by default, so a
short YAML file appeared visually centered in the editor
viewport — uncomfortable in any IDE-style surface where
content is expected to start at the top edge regardless of
how much of the viewport it fills.
Set CrossAxisAlignment.stretch on the outer Row so the
SingleChildScrollView fills the full vertical extent. The
SCV's default scroll origin is the top, so once the
viewport is taller than the content the file renders at
the top with empty space below.
Version 0.1.2 -> 0.1.3.
Signed-off-by: flemming-it <sf@flemming.it>
Stefan reported that opening a flow (hello) sometimes showed
content from a different flow (extract-with-approval). Cause:
flutter_code_editor's CodeController distinguishes between
`text` (the visible buffer, post-fold) and `fullText` (the
underlying source). Setting `_code.text = newFile` only
updates the visible buffer; the underlying `_code` data
structure retains the previous file's source, so subsequent
folding/scrolling can surface its content.
Switch all three load sites + the save site to the canonical
setter:
Before _code.text = text (visible buffer only)
After _code.fullText = text (replaces underlying source)
Save also moves to writing `_code.fullText` so any text the
operator collapsed behind a fold survives the round-trip.
Verified against flutter_code_editor 0.3.5 source — the
fullText setter calls `_updateCodeIfChanged` then re-emits
`TextEditingValue(text: _code.visibleText)`, which is the
documented "fully reset the editor" path.
Bump version 0.1.1 → 0.1.2. Studio's pubspec already pins
`ref: main` so the next `flutter pub upgrade` picks it up.
Signed-off-by: flemming-it <sf@flemming.it>
Stefan reported false-positive "Discard unsaved changes?"
dialogs when clicking around in the file list. Cause: the
naive `_code.text == _loadedText` check fired stale even
right after _code.text = text, because CodeController
silently rewrites the visible text during the assignment
(trailing-newline normalisation, fold marker insertion on
some YAML constructs, …) so _code.text deviated from the
just-stored _loadedText immediately.
Fix:
* Drop `_loadedText` field, replace with `_baseline`.
* Set `_code.text = ...` first (outside setState), then
capture `_baseline = _code.fullText` inside setState.
fullText is the canonical post-processed value, not the
visible-after-folding text — comparing fullText against
fullText eliminates the spurious diff.
* _dirty getter now compares `_code.fullText != _baseline`.
Applied to all four load / save / new-flow sites.
Bump version 0.1.0 → 0.1.1. Studio's pubspec already pins
`ref: main` so the next `flutter pub get` picks the fix up.
Signed-off-by: flemming-it <sf@flemming.it>
Extracted from fai/studio's lib/pages/flow_editor.dart so the
editor can be swapped out independently of the host.
Public surface kept minimal — a single FlowEditorPage widget
with three named parameters (initialFlowName, locale, onRun).
The package brings its own design tokens, empty/error
widgets, l10n table; no host-internal types leak through.
Studio depends on this repo via pubspec.yaml git reference.
Forks point Studio at a different URL and rebuild.
See README.md for the swap recipe.
Signed-off-by: F∆I Platform <platform@flemming.ai>