Commit Graph

233 Commits

Author SHA1 Message Date
Lucio Lelii b278f7f9c7 Let the schema-driven form carry a whole object as one field
Two changes to the core of the form, both needed before anything can render an
optional group as a single control.

collectSchemaLeafFields stops unfolding an object marked x-ui-optional-group and
emits the object itself instead. Until now no code path produced a leaf of type
object at all: `hasChildren && !isArray` always recursed, which is why five
settings most nodes never touch took more room on the card than the prompt.

Emitting it is opt-in, for the same reason arrays are: most callers want editable
scalars and would choke on an object. An unmarked nested object still unfolds
exactly as before, and a test pins that - the change has to be per object, not a
new rule for nested objects in general.

SchemaDisplayItem gains a fourth slot beside field / richContent / array, with
its lookup map in buildOrderedSchemaDisplay. Nothing fills it yet.

588 frontend tests green.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-04 12:29:54 +02:00
Lucio Lelii 1e381aa0ef Cover the array item modal before refactoring it
This round trip - item schema to dialog fields and back to an object - is the
machinery an optional-group modal wants to reuse, and nothing covered it: the
dialog mock in this spec resolved null, so no test ever reached the builder or
the parser.

Six characterisation tests pin what it does today: which fields it builds and
with what labels and types, that it writes the parsed item into the array,
that it edits in place rather than appending, and that a cancelled dialog
changes nothing.

One of them pins behaviour I intend to change and deliberately does not endorse:
an emptied required number becomes 0. Writing it down is the point - the optional
case has to differ, and the difference should be visible as a changed assertion
rather than as a silent shift.

584 frontend tests green.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-04 12:22:00 +02:00
Lucio Lelii b53a22dcc4 Name a nested fieldset readably instead of by its dotted path
A field inside a nested object whose object declares no name of its own took the
raw parent path as its fieldset legend, which the uppercasing CSS then rendered
as "LLMDESCRIPTOR.PARAMETERS".

The expression `?? parentPath(path)` appeared in four places - twice in
generic-node, twice in container-node - plus a fifth in task-step-node and a
sixth as the default in schema-driven-fields. That is how one mistake came to be
wrong in six places at once, so this is one exported helper rather than six
corrected copies.

Independent of the optional-group work it was found during, and worth having on
its own.

579 frontend tests green; the assertion fails when the helper returns the raw
path again.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-04 12:20:50 +02:00
Lucio Lelii 307552cde1 Fold the simulation parameters into a section that starts closed
Provider and model are what anyone opening the simulation dialog came for. Shown
flat beside them, the five optional parameters turned the common case into a
seven-field form for a choice most runs do not make.

NodeSettingField gains an optional group, and the dialog renders those fields in
a collapsible section, closed until opened. A closed section that holds values
says how many, so one that is doing something never looks like one that is not -
and it counts a temperature of 0, which is a real setting rather than an empty
field.

The field markup moved into one ng-template used by both the plain list and the
sections. It is about a hundred lines of switch; a second copy would have drifted.

The open-state is a signal rather than a mutated Set. The component is OnPush, so
a Set only re-rendered when the change arrived through a template event - true
here by luck, and false the moment anything toggled a section from code. A test
caught it.

577 frontend tests green; the collapsed-by-default assertions fail when the group
is forced open. Initial bundle now 7.28 kB over budget, up from 4.26.

The node editor is untouched: that one is still to be discussed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-04 12:04:31 +02:00
Lucio Lelii 7ac7d08c98 Let the model parameters be set from the editor and the simulation dialog
Numeric fields inside a nested object were already collected, grouped and saved
by the schema-driven form, so most of this is what that form could not yet do.

A cleared numeric field now removes its key instead of saving 0. Number('') is 0,
so an emptied box used to persist a real zero - and on a temperature that is the
worst confusion available, because 0 is a valid and useful setting, which meant
that once a value had been given there was no way back to the provider default.
The container node carried its own copy of the same parsing and the same defect;
both now agree, and its maxIterations can no longer be cleared into a 0 its own
constraint forbids.

minimum and maximum are read from the schema and bound on the input, and the
placeholder says the range and that empty is allowed - otherwise the only way to
learn either is to save and be refused.

The simulation dialog gains the same five fields, which needed NodeSettingField
to grow a number type and NodeSettingsValues to admit numbers. That widening
rippled into three signatures that assumed string | boolean; the Angular compiler
found them, tsc --noEmit did not.

readSimulatorParameters is pure and tested rather than buried in the viewer: it
is where "the user left this empty" has to survive contact with Number('').

571 frontend tests green, and the cleared-field assertion fails when the parsing
is put back. Initial bundle 4.26 kB over budget, up from 2.88 - reported, not
raised.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-04 11:29:32 +02:00
Lucio Lelii bf4ed026b2 Tell the two run buttons apart by who answers the human steps
A green play and a flask said nothing about the one thing that separates them:
whether you answer the flow's chat and decision steps, or an LLM answers them for
you. The flask was actively misleading - "experiment" is the bias experiments'
word, so it pointed at a different feature.

Both buttons now keep the play, and a small badge carries the difference: a
person, or a robot. A gear was the first thought and is the wrong glyph - it is
the settings icon everywhere else in the product, so on a run button it reads as
"configure this run" rather than "a machine runs it".

They also sit next to each other now. Stop and resume used to separate them, so
each had to be understood alone - which is precisely the job a 17px badge cannot
do. Side by side they are one choice, and each is what makes the other legible.

The tooltips stop repeating the button and say who answers: "Run - you answer the
human steps", "Run simulated - an LLM answers the human steps". That is the
sentence the icon can only gesture at.

557 frontend tests green.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-04 09:26:49 +02:00
Lucio Lelii d31d48e1c2 Move the execution tree to the bottom left, and hide it without a subtree
Anchored to the bottom-left corner instead of the top-left: the top is where the
run's own title and toolbar are, so a panel there covered the thing it was meant
to sit beside. It grows upward, which keeps its toggle in one place as the tree
gets longer.

The panel is no longer rendered at all for a run with no container steps. It used
to render as a permanently disabled toggle explaining there was no subtree - fine
in a rail, but a floating panel that exists only to say it has nothing is worse
than no panel.

With the section behind that condition, the guards inside it became unreachable:
a disabled state, an aria-expanded term and a tooltip that could never appear.
Removed, so the markup stops describing a case that cannot happen.

The page's test stub grew three methods so the viewer can actually render in it.
That is what makes these assertions real rather than vacuous - the first attempt
at the absence test passed against a deliberately broken template, because the
patch that was supposed to break it never matched.

557 frontend tests green; both assertions fail when the condition is removed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-04 09:11:39 +02:00
Lucio Lelii a8101bee63 Keep the execution tree reachable in fullscreen
Fullscreen makes the viewer a fixed layer over the whole page, and the tree
lives in the rail underneath it. Covered, there is no way back from a
container's child run to the parent - which turns fullscreen into a one-way door
on exactly the runs that most need navigating.

The viewer's fullscreen state becomes a model so the page can follow it, and the
page lifts the *same* panel above that layer. The same one on purpose: a second
instance would start with its expansion state reset every time fullscreen is
toggled, and expansion is how you find the iteration you were reading.

It is lifted only when the tree has something to show. Floating an empty toggle
over the view would obstruct the very thing fullscreen was opened to see.

The two-term condition moved out of the template into a named computed. That is
also what made it testable: rendering the panel drags in the whole viewer, whose
service stub this spec does not have, while the condition is the part that
carries the meaning.

557 frontend tests green.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-04 09:05:22 +02:00
Lucio Lelii 1abdb5b591 Copy the inputs of another run of the same flow
Starting a fresh run means retyping values you already typed. The panel now
offers the other runs of this execution's own group - the group is keyed by
source flow, so a run of a different flow is never offered: matching its inputs
by name would be a coincidence, not a copy.

The copy fills the panel's pending edits rather than writing anything. That is
the whole trick: the values arrive with the unsaved-change styling already on
them, the user reviews them, and the single Save sends them in one bulk request.
No new write path, no new error handling.

It is deliberately partial, and says so. A file input holds a temp file on the
server, which the backend copies by reference when it reruns an execution; from
here there is only a path, and copying it would point this run at another run's
upload. Credentials are not copyable at all - the vault decrypts them
server-side. Both are reported in the summary rather than dropped quietly,
because a copy that silently leaves gaps is worse than one that names them.

Worth knowing before reaching for this: a rerun, bias rerun included, already
arrives fully populated - createBiasRerun calls copyReusableInputs, which carries
over every node input, the global descriptors, the authorizations and the
simulation descriptor. This is for the case that copies nothing: a new run
created from the flow.

An input already matching the source is not offered as a change, since it would
join the unsaved count and invite a pointless save.

555 frontend tests green; initial bundle unchanged at 2.88 kB over budget.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-03 22:53:54 +02:00
Lucio Lelii 360ed5bd82 Compare two runs of a flow, picked from the run list
The second half: picking the pair, and showing the join.

Selection is a mode of one group, and its own state. It is deliberately not
folded into selectedExecutionId - that drives which run the main panel shows, so
ticking a box would navigate away from what the user is reading. Confining it to
a group is not tidiness either: runs of different flows share no step ids, so the
join would report every node as replaced. A third pick replaces the older one
rather than refusing the click, which would leave the user hunting for which box
to clear.

The view puts the two values side by side with the changed words marked, shows
only the differing nodes by default, and compares outcomes alongside nodes -
which is where the answer lives on a flow ending in an End node. An identical
value is not diffed at all: running the table over text known to be the same can
only output "all the same".

Two things it says out loud rather than leaving to be inferred: that model output
varies between runs on its own, so a difference is not by itself evidence of
changed behaviour; and that two runs sharing no node are almost certainly runs of
different versions of the flow, rather than a flow that changed entirely.

540 frontend tests green. The initial bundle grew 0.02 kB - the view lands in the
lazy tasks-executor chunk - leaving it 2.88 kB over its budget.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-03 22:17:25 +02:00
Lucio Lelii 3afaf9a169 Add a word-level diff for the comparison view
Written here rather than pulled in: there is no diff library in the project, the
initial bundle is already over budget, and what is needed is small. Side by side
without it, two paragraphs of model output differing in one clause have to be
read twice to find the clause.

Word-level, not character-level: the values are prose, and a character diff on a
rewritten sentence produces confetti rather than an explanation.

Each word carries its trailing whitespace. As separate tokens the spaces match
between any two texts, so a wholly rewritten sentence came back as alternating
removed-word / kept-space parts - the same confetti, arrived at from the other
direction. A test caught this; the tokenizer changed, not the expectation.

The LCS table is quadratic in tokens, and two long model outputs are exactly what
this exists for, so past a guard it falls back to whole-value replacement rather
than freezing the view it is meant to render.

525 frontend tests green.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-03 22:13:25 +02:00
Lucio Lelii 381e3d3817 Join two runs of a flow node by node
The first half of comparing two executions: a pure function, so the semantics
are testable without a TestBed - the same shape as flow-grouping and
planInputSaves.

It is not the bias comparison. That one scopes itself to the nodes a probe was
activated on and refuses a run that is not an experiment, so for two ordinary
runs its node set is empty and it compares nothing. Generalising it would mean
rewriting its scoping, not relaxing a condition.

The join key is the step id, stable across runs of one flow. Not the node name:
names are not unique and can be edited, and a rename would report every node as
replaced. Two runs sharing no step at all are reported as disjoint - almost
always the flow was edited between them, which makes the join meaningless rather
than merely empty.

resolveStepOutputs is the part that makes this work on a real flow.
context.result holds only the *unconnected* outputs, so on a flow whose nodes
feed one another it is nearly empty, and on one ending in an End node it is
empty outright - the run this was built for has result {} and one outcome. The
value of a connected output is still observable as what arrived at the input on
the other end of the wire, which is how the backend reconstructs node outputs
for a bias report. Outcomes are compared too, for the same reason.

516 frontend tests green.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-03 22:12:11 +02:00
Lucio Lelii b2d744fe42 Make the bias experiment reachable, and say why when it is not
The Bias impact tab is empty because no experiment has ever run - zero rows in
bias_impact_report_entity - and nothing in the UI said how one is started.

The empty state now explains what a report is, says how many nodes carry a probe
that can be activated on this run, and offers the action. When something is in
the way it states that instead: a subflow cannot be a baseline, an unfinished run
cannot be compared against a rerun, a flow with no activatable probe has nothing
to measure.

"Create biased rerun" no longer returns silently when no node qualifies - a
button that does nothing and explains nothing is indistinguishable from a broken
one. It now names the reason.

The per-node measure control is shown on interactive blocks instead of hidden.
ChatInteraction cannot be replayed in isolation, so the isolated experiment is
genuinely unavailable there - but hiding the control made an annotated node look
identical to an unannotated one. It is rendered disabled, and the tooltip points
at the full-flow route that does work. That is a rule of the domain, worth
stating rather than concealing.

Splitting the node scan out of biasRerunCandidates keeps this free: the count is
computed from the flow snapshot, while only the capability check needs the
network.

506 frontend tests green; each new assertion fails when its behaviour is undone.
Initial bundle now 2.86 kB over budget, up from 2.21 kB - reported, not raised.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-03 22:10:16 +02:00
Lucio Lelii 53ac229790 Refresh the Bias impact tab when a report is produced
The list fetched only when executionId changed. The experiment and compare
dialogs are global hosts rendered over the still-mounted aside, so the sequence
that actually happens - open the tab, run an experiment, read the report in the
dialog, close it - returned the user to a tab still claiming there were no
reports. There was no refresh either: retry() is rendered only in the error
branch.

A small shared signal announces that a report now exists. The dialogs raise it
rather than the viewer, because they are what knows one was actually produced -
a failed experiment produces none - and it keeps the viewer out of a path it has
no part in.

A reload of the same execution keeps an open report open; only a change of
execution closes it, since that is a different subject.

500 frontend tests green. The reload assertion fails when the revision is
ignored again.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-03 17:35:23 +02:00
Lucio Lelii c35bbc0ccf Compare the bias mode against the value the API actually sends
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>
2026-09-03 17:32:38 +02:00
Lucio Lelii 8659b798d5 Pulse a container while its subflow is working
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>
2026-09-03 16:35:33 +02:00
Lucio Lelii 408bcb9309 Keep the execution tree's iteration list in step with the run
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>
2026-09-03 16:28:26 +02:00
Lucio Lelii ef1c2081ec Put the failed-save message in the sticky bar, beside Save
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>
2026-09-03 16:14:29 +02:00
Lucio Lelii 96c3679f2b Report a failed global save once, at the top of the panel
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>
2026-09-03 16:11:41 +02:00
Lucio Lelii fee24f174b Save the edited global inputs in one request
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>
2026-09-03 16:07:33 +02:00
Lucio Lelii 9d3414d3cb Fold a list input away, and edit a long value in a box that fits it
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>
2026-09-03 14:35:32 +02:00
Lucio Lelii df8c122c12 Import the items of a list input from a pasted JSON array
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>
2026-09-03 14:25:44 +02:00
Lucio Lelii d7c92325c4 Collapse each input group behind a badge of what it is still missing
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>
2026-09-03 14:20:23 +02:00
Lucio Lelii 253e8ae483 Count node inputs too, and name the groups after the domain
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>
2026-09-03 14:17:31 +02:00
Lucio Lelii dcf3af5551 Make filling in execution inputs one form, not a stack of cards
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>
2026-09-03 14:13:09 +02:00
Lucio Lelii 0fe5a0109c Read the bias direction from the fields the API actually sends
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>
2026-09-03 11:40:33 +02:00
Lucio Lelii ad8a65fb18 Make a bias variant recognisable, and quieten the run history
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>
2026-09-03 11:34:36 +02:00
Lucio Lelii f086064e61 Make the outcomes section collapsible
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>
2026-09-03 11:29:35 +02:00
Lucio Lelii 323115fd40 Disable the execution tree toggle when there is no subtree
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>
2026-09-03 11:25:15 +02:00
Lucio Lelii 0dcdc6d135 Show a run's outcomes, where an End node puts the flow's answer
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>
2026-09-03 11:21:33 +02:00
Lucio Lelii 4fa449658e Hide flow finalization behind a feature flag
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>
2026-09-03 10:58:15 +02:00
Lucio Lelii 4c973b3922 Make the blocks and containers palettes scroll again
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>
2026-09-03 10:55:05 +02:00
Lucio Lelii 5476324d8c Quieten the blocks and containers palettes, description on demand
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>
2026-09-03 10:52:12 +02:00
Lucio Lelii 6d1efc4f69 Quieten the flow list: one action menu, flatter cards, subtler filter row
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>
2026-09-03 08:20:01 +02:00
Lucio Lelii 3d93a3f99a Keep the flow actions inside the sidebar, collapse the list filters
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>
2026-09-03 08:15:30 +02:00
Lucio Lelii 3eba5815ee Show projects with no flows, so a new one is visible
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>
2026-09-03 08:02:47 +02:00
Lucio Lelii 0b706ea95c Group the flows list by project, with shared context and project runs
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>
2026-09-03 07:53:42 +02:00
Lucio Lelii abf21b1c24 Add fullscreen mode, wider zoom-out, and draggable execution graph positions
- 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.
2026-09-02 20:00:34 +02:00
Lucio Lelii 3906bf8bd5 Fix create-with-AI minimize/resume regression from cancellable-call stub
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.
2026-09-02 13:39:55 +02:00
Lucio Lelii 38b01b2bee Migrate flow assistant chat to the async session call flow
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.
2026-09-02 13:32:21 +02:00
Lucio Lelii 3b48e71b82 fix(blocks): follow the renamed block type catalog endpoint
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>
2026-09-02 11:20:48 +02:00
Lucio Lelii c7cefaca96 feat(execution): gate execution start on vault credentials
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>
2026-09-01 23:23:14 +02:00
Lucio Lelii 4a40a8aa2a feat(assistant): select provider, model and vault credential
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>
2026-09-01 23:23:14 +02:00
Lucio Lelii 7e9b483882 feat(vault): add vault and LLM provider services
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>
2026-09-01 23:23:14 +02:00
Lucio Lelii 8b34f49056 fix(assistant): unlock configuration after failed request 2026-09-01 16:32:11 +02:00
Lucio Lelii d05a661473 feat: update flow editor enhancements 2026-09-01 15:53:39 +02:00
Lucio Lelii cf6b25bd1d feat(assistant): add provider and model selection 2026-09-01 15:53:28 +02:00
Lucio Lelii b6474def51 fix(validation): scope errors to active subflows 2026-08-03 21:52:53 +02:00
Lucio Lelii 4d92cbc51c feat(execution): refine tree and subflow layouts 2026-08-03 17:21:52 +02:00
Lucio Lelii 14f78b79ce fix(editor): render container subflows and long port labels 2026-08-03 17:21:28 +02:00