The backend enum is NORMAL | EXPERIMENT. isBiasVariantContext compared against
'BIAS_VARIANT', a value no endpoint has ever emitted, so it was always false and
took a whole feature path down with it:
- "Compare with baseline" is wrapped in @if (isBiasVariant()), so it never
rendered - the only route to a FULL_FLOW bias report;
- the run list never labelled a rerun as a bias variant, which is the very thing
it was changed to do;
- biasInterventionMix always returned null, so BIAS / MITIGATION / MIXED never
showed.
I introduced this while fixing a real bug - presence of biasExecutionContext was
marking every run a variant - by correcting the condition to the wrong literal.
The fixtures used the same invented value, so the tests passed and the change
looked verified. They are corrected here too: with the old literal restored,
eight assertions now fail.
The two names are kept apart deliberately and both are commented: 'BIAS_VARIANT'
remains the list's own TaskExecutionKind vocabulary, while the API mode is
'EXPERIMENT'. Treating them as interchangeable is what caused this.
The dev fake was also seeding 'BIAS_VARIANT', so development agreed with the bug
and disagreed with the service.
497 frontend tests green.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
In the parent's execution graph a container's own status is WAITING_FOR_SUBFLOW,
so it took none of the in-progress styling and sat there inert for minutes while
its child ran. Combined with a tree that was not refreshing, a working run was
indistinguishable from a stuck one.
The card now pulses on the same rhythm as a running node, in indigo rather than
yellow because the work is a level down: the node is not computing, it is
waiting on a child, and that child is the thing worth opening. A badge names the
iteration in flight - __containerIterationIndex is new on the graph payload - so
the signal says what is happening and not merely that something is.
An errored container does not pulse: there the error is what matters.
Motion is now suppressed under prefers-reduced-motion, with the pulses settling
on their emphasised frame so the state survives without the animation. That
covers the two pre-existing pulses as well - adding a third blinking element is
the point where honouring the setting stops being optional.
Budgets: task-step-node.css went 122 bytes over, so the badge icon lost its spin
(the border pulse already carries "in progress"). The initial bundle is still
2.21 kB over its 1.75 MB budget, up from being just inside it - reported, not
raised.
497 frontend tests green; the new assertions fail with the class binding removed.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The tree fetched a container's iterations once, when the step was first
expanded, and kept that list until the root run id changed. Polling refreshed
the run but never that list - and it is the only source both for which children
exist and for what state each one is in.
So a three-iteration run displayed "Iteration 1, RUNNING" from start to finish.
A run that was working looked stuck, and then looked as though it had done one
iteration and produced nothing, while the container was in fact on iteration 3
with two results already accumulated.
The list now refreshes on each poll tick while the run is live, and once more on
the tick that reports it finished - that tick carries the final status and with
it the last child's real state. Nothing is fetched for a step nobody opened, a
refresh already in flight is not duplicated, and a failed refresh leaves what is
on screen alone rather than replacing valid iterations with an error.
493 frontend tests green. Both new assertions fail with the refresh disabled.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
At the top of the panel the message could be scrolled out of sight by the very
fields it was about, while the button that produced it sits at the foot. It now
shares the sticky bar with that button, so it cannot scroll away.
The bar stacks: the message takes a full-width line that wraps, above the row
with the status and the button, so Save stays exactly where it was and the text
is not clipped to one line in a 320px aside.
The pending count stays alongside it. The edits are still there and the save is
retryable, which is worth showing rather than replacing with the failure.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The globals go in a single request, so a failure belongs to the batch and not to
any field in it. Repeating the same text on every input said one thing N times
and implied N separate problems.
One message now sits above the fields, and it can say what the per-field version
could not: the request was all-or-nothing, so none of the values were saved. It
clears on the next edit, so a stale notice cannot outlive the state it described.
Node inputs keep their per-input errors: each is its own request, so a failure
there is attributable and partial success is real. File uploads likewise.
489 frontend tests green.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The Save bar fired a request per edited input, all at once, and they all mutated
the same execution. That is how two typed values went missing: the bulk global
endpoint replaced the whole set of globals, so the list input's save landed last
and took its neighbours down with it. The endpoint now merges (service-side fix),
but a save should not depend on request ordering to be correct.
Every edited global now goes in a single PUT /executions/{id}/globals, and the
node inputs follow one at a time - there is no bulk endpoint per step, so the
best available is not to have them in flight together. The single-input save
uses the same two requests, a global batch of exactly one, so there is one code
path and one value normalisation instead of a second copy that could drift.
planInputSaves and preparedInputValue are pure and live with the other viewer
utils, which is what made them testable: the component has no spec harness (14
injected services), and the parts worth pinning are which endpoint gets called
and what shape the value takes.
The fake now writes globals to context.globalInputs and the descriptors, where
the viewer actually reads them. The older single-key fakes only ever touched
context.inputs, so a saved global never showed up in development at all.
A failed batch reports the same error on every input in it: it failed as a
batch, and guessing a culprit would be worse than saying so.
487 frontend tests green.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The import action moves onto the name line: it was a flex container in a
baseline-aligned row, so it floated above the name and the type chip. It also
loses the data_array glyph for a plain upload arrow, since the dialog it opens
explains itself.
A list input now folds, on the same rule the two groups follow: unfolded while
it still needs attention, folded once it is satisfied. Five long answers filled
the whole aside otherwise. Folded, the row says how many items it is hiding, so
nothing disappears silently.
Long values were being typed through a slit ~320px wide. Every text value now
offers a larger box - on the head for a single value, per row for one item of a
list. Both write back through the ordinary pending change, so the panel's single
Save still decides when anything is sent, and the box is read-only when the
panel is.
Both dialogs now resolve the input by key when applying: editableInputs is
rebuilt on every poll, so the object captured when the dialog opened could be a
stale value to rebase an edit onto.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Filling a TEXT[] input meant clicking Add item and typing into one box after
another - painful when the items are five long interview questions that already
exist as an array somewhere. A multi-value input now carries an import icon next
to its type chip, opening a dialog to paste a JSON array whose elements become
the items.
The parser is deliberately strict and specific about what it refuses, because the
whole point is to save typing: a silent misread would be worse than typing the
items by hand. It names the offending element rather than coercing it - an object
would otherwise have arrived as "[object Object]" - and refuses an empty array
instead of quietly wiping the items. Numbers and booleans are converted, since
those are unambiguous as text.
The imported items are emitted as an ordinary pending change, so the panel's
single Save still governs when they are sent, and an import can be reviewed or
abandoned like any other edit.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Global and node inputs are now collapsible sections, each headed by a badge: red
with the number still to fill in, green with a tick once none are. The count is
what makes collapsing safe - a folded group can never quietly be the thing
holding the run back.
A group starts open exactly when something in it is missing, and collapsed when
it is complete, so what needs attention is what you see without expanding
anything first. That is only the default: once the user toggles a group their
choice sticks, in both directions, rather than being overridden the next time the
counts change.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The tally said "0 of 3" while ignoring the manual inputs below it, which are just
as required: the backend only reaches READY when every step is ready, so a step
missing its manual input blocks the start exactly as an unsatisfied global does.
It now counts the whole panel.
"Provided" is decided per input and passed down, rather than inferred in the
panel from a globals-only list. A global uses the backend's own
missingGlobalInputKeys; a node input is judged on its stored value, ignoring
unsaved edits - otherwise typing would make an input look satisfied before it was
sent. An empty list, or a list of blanks, does not count as supplied.
"Flow inputs" and "Manual inputs" are now "Global inputs" and "Node inputs",
which is what they are called everywhere else in the codebase and in the API.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Each input opened with a centred gradient badge whose largest text read "Flow" -
the same word on every global input, so the most visual weight carried the least
information - followed by a "Type:" line and a four-row textarea. A one-word
positionTitle got the same box as a CV, and three short answers filled the panel.
An input is now one line of chrome - name, a small type chip, and a dot saying
whether it is still required - over a field that starts small. Removing an item
from a list is an icon rather than a full-width "Remove" button, which at this
width used to push the field out of the panel.
Saving is one action for the panel. Per-input buttons meant a click and a round
trip each, with nothing to say how much was still unsaved; a sticky bar now
reports the pending count and saves them together. Each one still goes through
the same single-input request, so a failure is still reported against its own
input.
The panel also stops ignoring missingGlobalInputKeys, which the backend has been
sending all along: it now shows "2 of 5 provided" and marks exactly the inputs
that block the start.
The editor is one template instead of four near-identical copies - global and
node, each single and multiple. That was not the goal here, but writing the same
change four times is how those four drifted apart in the first place. First tests
for this component.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The direction badge always said "Bias". It derived the direction from
biasExecutionContext.activeBiasProbes, and that field is not on the bias context
at all - probes belong to a step view - so the lookup was always undefined and
every variant fell through to the bias branch. My own change one commit ago.
A persisted snapshot settles what the payload really is: bias and mitigation are
tracked in four separate collections - annotations per node and activated
subflows per container, one pair for each direction. The direction is which of
them are non-empty, so a variant can now correctly read as Bias, Mitigation, or
Bias + mitigation, including when an intervention was switched on for a whole
container subflow rather than per annotation. A variant with nothing recorded
says "unspecified" instead of guessing.
The model was wrong in a second way, with a second victim:
activeAnnotationIdsByNode is not sent either, so the bias highlighting on graph
nodes read an absent field and never lit anything up. It now combines both
directions.
The model is corrected to the real shape and the two absent fields are gone from
it, so nothing can quietly read undefined again. The derivation lives in pure,
tested helpers rather than inline, and the dev fake now splits activations by
direction like the real payload - flattening them hid this very difference in
development.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Nothing in the run history said whether a run was a bias variant. Worse, the
viewer header claimed "Bias variant" for *every* execution: it tested for the
presence of biasExecutionContext, which the backend sets on all of them,
defaulting to NORMAL. Only the mode distinguishes them. The same faulty test also
offered the bias comparison action on any plain rerun.
Each history row now carries a kind badge - Run, Rerun, Bias, Mitigation, or Bias
+ mitigation - coloured and with a tooltip saying what it means for the result. A
bias variant reads as a variant even when it is also a rerun, because carrying
probes is what changes how its output should be read.
The rows also stop showing raw uuids. A rerun names the run it came from by
number ("from #1") instead of repeating a 36-character id, the execution id is
gone from the row entirely - the viewer header owns it - and the group header no
longer prints the source flow id. "1 runs" reads "1 run".
The viewer header keeps only what changes how a result should be read: Simulated,
the bias variant, Subflow. Execution id, simulator descriptor, experiment id,
baseline and probe internals moved behind a Details toggle, closed by default.
Two Italian strings in that block are now English, like the rest of the app.
Cards are flat here too, matching the flows list: no gradients, no lift on hover,
a left accent for the selected run.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
An outcome payload is often a generated document - the local example is a full
rejection email - which pushed the execution graph down the page. The section now
collapses.
It starts open, because this is the flow's answer and it was invisible until a
moment ago. Collapsed, the header keeps the outcome codes, so the conclusion is
never hidden entirely: only the payload folds away.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The tree renders nothing for a run without container steps, so the panel offered
to open onto an empty box. The toggle is now inert in that case, greyed, with a
tooltip saying why, and the chevron - which promised something to unfold - is
gone.
The host decides "is there anything to show" with the tree's own rule rather than
a second guess at it: the container-step test moved out of the component into an
exported function taking the container-type predicate, so both call one
definition. Guessing separately is how a panel ends up claiming content the tree
will not draw.
An existing test caught the behaviour change, which is the right outcome: it is
rewritten to cover both halves - inert with no run selected, and toggling once a
run actually has a container step.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A flow result is built only from *unconnected* outputs, so wiring the last block
into an End node moves its value out of the result and into the outcome payload -
which nothing in the UI read. The run then looked like it produced nothing at all.
There is already an execution in a local database whose outcome payload is a full
generated rejection email that was invisible for exactly this reason.
The run view now has an Outcomes section above the graph, listing each End the run
passed through: its code, its label, the step it came from, and its payload. An
End reached with no value says so, rather than showing an empty box - the two
cases mean different things.
A text payload renders as text rather than through the JSON tree. The tree would
have kept the line breaks but wraps strings in quotes, and the common case here is
a generated document, not a data structure.
This is the smallest of the options for the underlying gap: the data was already
persisted and already in the API payload, so nothing in the engine changed. The
gap itself remains - End cannot be attached as a pure ordering dependency
(canDependOnOtherNodes is false), so a single-exit flow still has to choose
between a labelled end and a value in `result`.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Finalizing a flow makes it permanently read-only and cannot be undone, and it is
not part of the current workflow, so the controls that create that state are now
hidden: the Finalized toggle in the title toolbar and the Finalized entry in the
flows list filter. Gated by FLOW_FINALIZATION_ENABLED, matching SWIMLANES_ENABLED,
so nothing is deleted and re-enabling is one line.
Only the controls are gated. The badge on a flow row, the disabled delete and the
read-only editor stay, because rows finalized before the switch still exist and
hiding the explanation of why such a flow cannot be edited would make the app
inexplicable. For the same reason the toggle reappears for a flow that is already
finalized, so its state is never invisible in the place that owns it.
A persisted FINALIZED list filter now falls back to showing everything: a filter
whose control is hidden would otherwise keep narrowing the list with nothing on
screen to clear it.
The backend is untouched - finalized still gates editing and deletion there.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Rewriting those two stylesheets from scratch dropped the :host rule they had, so
the host had no height for .blocks-list-items to be constrained by. Its
overflow-y never engaged and the palette simply ran off the bottom of the sidebar
once there were more entries than fitted. Restored, with a note saying what it is
load-bearing for.
Two related sizing fixes while here. The flows list capped itself at
calc(100vh - 380px), a number tuned for the tall header that the collapsible
filter panel replaced; height now comes from the flex column, which cannot go
stale the next time the header changes. And the executions history was missing
min-height: 0, so its flex item refused to shrink below its content and never
scrolled either.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Each card carried an avatar, a title, a subtitle, the full description as body
copy and a chip that repeated the subtitle - five elements to say what is really
a name and a kind. In a narrow sidebar that reads as a wall of text you have to
scroll past to find the block you want to drag.
A card is now one line plus a kind icon, with the description behind the info
button. It expands in place rather than in a tooltip or a modal: these
descriptions run to several hundred characters, which a tooltip cannot render
readably, and a modal is heavy for a hint you read while reaching for a drag.
Only one description is open at a time, so the list cannot unfold into the same
wall of text by another route.
Dragging is untouched and now says so: the whole card shows a grab cursor and a
delayed tooltip naming the gesture, and a test pins the draggable attribute so a
future restyle cannot quietly break the palette's only real job.
The chip removal also drops MatChipsModule from both components.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Three things the sidebar was getting wrong.
The flow card was doing too much: two gradients, a 24px shadow, a 14px radius and
a blue tint, repeated a dozen times down a narrow list. A list of flows should
read as a list, not as a stack of raised panels, so the card is now flat with a
hairline border, and depth is spent only on the one card that is open - a left
accent rather than a wash of colour.
Every action moved into a single overflow menu next to the name, which also
settles the overflow for good: four icon buttons never fit a 320px sidebar, and
less still beside the reorder arrows. The visibility lock became what it always
was - an indicator, not a permanently disabled button - and moved up next to the
name, so the footer row disappears entirely and each row is two lines.
The filter toggle looked exactly like a project group: same bordered, rounded,
grey box. It is a control, not a section, so it is borderless and muted now, with
a rule under the open panel to separate it from the groups below.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The flow card carried four Material icon buttons at their default 40px, with
flex-shrink disabled, in a 320px sidebar - and a grouped row adds reorder arrows
on top. The icons pushed past the edge. They are compact now, the date can
shrink, and the footer wraps rather than overflowing.
Search, visibility, sorting and the project selector together filled most of a
short viewport, so the controls were all you could see and the list itself was
pushed off screen. Everything except the search now sits in a panel that starts
collapsed, in both the flows list and the executions history. The search box
stays out of it: it is the control people reach for first. The collapsed header
carries a count of the active filters, so a hidden filter can never silently be
holding items back.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Groups were built only from flows, so a project with none did not render at all.
Creating your first project therefore looked like the opposite of what happened:
the new project was nowhere to be seen, while every pre-existing flow suddenly
appeared inside a group - the "No project" bucket, which the list only starts
showing once grouping switches on.
Every project now gets a group, empty ones included, with an empty state that
says how to put a flow in it. Empty groups are still dropped while a search or a
filter is narrowing the list, where they would be noise rather than reassurance.
The ungrouped bucket, in turn, only appears when something is actually in it.
Also stops the dev fake deriving new project ids from the current count: after a
delete that reissued a freed id, and any flow still pointing at it reappeared
inside the new project.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Projects group the flow list in place: no new page or tab, the existing sidebar
list gains collapsible per-project sections plus a project filter. Everything is
behind PROJECTS_ENABLED in shared/feature-flags, and with no projects the list
renders exactly as it did before - flows-list.spec guards that.
FlowsList stays the single owner of the load, filter, sort and list state; the
grouping is extracted into a pure flow-grouping.ts and a presentational
flows-group component. A wrapper rendering N flows lists would have re-registered
the same list state and re-triggered the load N times. The group chrome mirrors
tasks-executions-list, which already implements collapsible groups, so the two
sidebars read as one product.
toFlowCreateRequest deliberately still carries no project. It builds the
full-replace PUT the editor issues on every save, so a project sent there would
be silently dropped each time; membership changes only through
assignFlowToProject. flow-mapper.spec guards it.
Deleting a project destroys its flows, so it gets its own dialog rather than a
wider ConfirmDialogService: it names the count, lists the flows, says that
finalized flows go too - which the flow list otherwise forbids - and requires the
project name to be typed. Widening the shared confirm service for one destructive
caller would have rippled through every other call site.
Moving a flow between projects is a menu item, not drag-and-drop: the sidebar is
320px with its own scroll, and the whole card is already a click target, so a
drag gesture would fight the open-flow gesture. Flow order inside a project uses
up/down arrows for the same reason.
Shared context is edited in a dialog modelled on the Global Inputs panel, which
is the mental model users already have for ${{global.x}}; the title toolbar shows
the inherited values read-only, because that is where prompts are written.
"Run project" creates and starts a run - creating alone would look like nothing
happened. Runs come back BLOCKED or STOPPED when a step needs inputs or failed,
and the UI says so instead of claiming progress. Project runs surface in /tasks
as sibling groups labelled with a project chip, derived in tasks-executor: no
second nesting level and no backend change.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
- Lower rete editor min zoom (0.35 -> 0.1) so wide flows fit on screen.
- Remove the Export action from the read-only subflow preview dialog,
since it's only ever opened from the execution view.
- Add a fullscreen toggle to the flow editor and task execution viewer,
placed next to the title; disables the "flows" sidebar tab and hides
the right assistant/validation panel while the flow editor is
fullscreen, and collapses the left sidebar if it was open.
- Allow dragging nodes in the read-only execution graph and persist
custom positions client-side in sessionStorage, keyed by execution id,
restored on reload/refresh.
hasCancellableCall() was hardcoded to false (from an earlier commit that
gutted the real async call tracking), which silently blocked
minimizeCreateWithAi() from ever minimizing an in-progress AI flow creation
into the floating banner, and blocked closeCreateWithAi() from prompting to
cancel a running request.
restoreSessionForFlow() also never resumed polling for a call that was still
QUEUED/RUNNING when its snapshot was saved (e.g. after minimizing and
reopening, or a page reload), so a resumed session lost live progress
updates and could get stuck showing a stale phase.
Restores real hasCancellableCall()/cancelActiveCall() backed by the
submitMessage/getCall/cancelCall flow, resumes polling on restore for an
active call, and refreshes the session from the backend otherwise. Adds
getSession() across the assistant call service stack to support this.
Replace the direct draft/refine/fix/explain HTTP calls (which no longer exist
on the backend) with submitMessage + polling on getCall, wiring the
currentCall/progress-phase UI that was already scaffolded but never
connected. Intent (draft vs refine vs fix vs explain) is now inferred
server-side instead of guessed client-side. Updates the fake service and
specs to match.
The backend replaced GET /blocks/types/catalog with
GET /blocks/types/configurations/catalog. Same payload, same contract.
The container catalog endpoint is unchanged and is left alone.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
An execution cannot start until every requiredAuthorizations entry is
satisfied. A key of the form LLMProvider::<provider>::authorization is
answered with a vault secret id chosen from a picker filtered by that
provider, or from a credential created on the spot; anything else keeps
the literal-value panel it had.
A provider key never falls through to the literal-value panel, whatever
the provider catalog says or fails to say, so the API key itself can no
longer be pasted as the authorization value. The catalog is therefore no
longer part of the gate: an outstanding requirement blocks the start on
its own, and a failed catalog read is a notice with a retry.
The authorizations PUT answers with the recomputed execution, so its
response replaces the execution in the store rather than triggering a
list refresh, and the backend's 400 message reaches the user. A settled
requirement stays on screen with the credential label and a change
action, and a banner above the graph names the missing providers when
the context aside is collapsed.
The gate itself is now pure - buildAuthorizationGate and
isExecutionStartable in execution-viewer.utils.ts - and covered by
tests, since the component cannot be instantiated under the test
environment.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Lets the assistant panel pick the provider and model for a session, and
choose or create the vault credential an external provider needs. Status
messages for the credential endpoints come from the shared
CREDENTIAL_ERROR_MESSAGES map, so the assistant and the vault report
400/401/409 the same way.
Also moves the remaining Italian UI strings to English, matching the
rest of the application.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Adds the services the credential flows need, following the existing
call/fake pattern: a base, an HTTP implementation and a fake for the
vault, the LLM provider catalog and the per-provider credential listing,
registered in all three environments.
The two fakes share an in-memory vault store that reproduces the
server-side rules of the credential listing (owner, active, provider,
soft delete), so a credential created from one panel shows up in the
other exactly as it does against the real backend.
Also teaches extractHttpErrorMessage the RFC 7807 `detail` field, which
is what the execution endpoints answer with.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
question/actionDescription arrive with literal ${{name}} / ${{global.name}}
placeholders since no block type ever persists a "resolved" prompt. Resolves
them client-side in a single pass (not sequential replaces, to avoid
re-resolving a value that itself contains ${{...}}), then renders each
value as its own expandable card or collapsed accordion instead of
concatenating long/array values inline into the surrounding text.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Adds GET /executions/{id}/node/{stepId}/iterations to the task
executions API and a recursive execution-tree component that lets
users navigate the full iteration history of looping containers
(not just the currently active one), rendered in the left rail
alongside the run list.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Implements the frontend side of the control-flow engine's Fase 4-5
backend extension: presentation-only swimlanes for grouping nodes by
actor/responsibility, and a new IOType.JSON for structured input/
output descriptors. Both are additive/optional fields, backward
compatible with existing flows.
Swimlanes:
- Model: FlowLane (id, name, description, order, color), FlowData.lanes,
and an optional laneId on every block/container.
- Lane management (add/rename/reorder/color/delete) in a new "Lanes"
section of the title toolbar, mirroring the existing Global Inputs
editor. Deleting a lane clears laneId on any node that referenced it,
avoiding an immediate NODE_LANE_NOT_FOUND validation error.
- Real visual swimlanes on the rete.js canvas: horizontal color-coded
bands with labels that pan/zoom together with the nodes, backed by a
transform layer kept in sync with the area's live transform.
- Drag-to-reassign: moving a node updates its laneId based on the drop
Y position, but only for genuine pointer drags — programmatic moves
(initial load, clone, server-side node regeneration) are excluded via
the existing programmatic-translation tracking, so loading a flow
never silently reassigns lanes.
- A small lane badge on the node header confirms the current
assignment after a drag.
- The readonly/execution-view diff and patch logic now accounts for
laneId and lanes so live updates are detected correctly.
Also fixes a data-loss bug found while wiring this up: exportGraph()
rebuilt each node and the top-level FlowData as explicit object
literals that never carried laneId/lanes through, and flowFromApi()
never normalized lanes coming back from the backend — both would have
silently dropped the field on save/reload, the same class of bug fixed
earlier for biasAnnotations.
JSON IO type:
- Global input type picker now offers JSON alongside TEXT/FILE.
- Behavioral probe editor: JSON-typed mock outputs default to {} and
use the JSON textarea editor instead of a plain text input.
- portSelectableKinds (generic-node) includes JSON among the concrete
types offered wherever a port's kind is ANY.
Verified with the full suite (253/253, 21 new tests) and a live
end-to-end browser check: created two lanes, dragged a node from the
unassigned area into a lane (confirmed via the node's lane badge),
saved, and reopened the saved flow with the lane assignment intact.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>