Commit Graph

217 Commits

Author SHA1 Message Date
Lucio Lelii a426a2c5e7 Summarise an array row by the branch it is on
An upload row taking its file from a global listed as "GLOBAL · false".
The summary takes the first two values a row holds, and the false was a
"several files" box nobody had touched - while the global the row names,
the one thing that tells it apart from the next row, never got a look in.

Two kinds of value are skipped now, both for the same reason: they
identify nothing. A false flag is an absence dressed as a fact, and a
field the row's own choice hides is irrelevant by construction.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-14 14:55:29 +02:00
Lucio Lelii f1305c16e7 Say that a file is stored where it is chosen, not on Save
Replacing a file uploads it straight away, while every other input waits
for the Save at the foot of the panel. Nothing said so, so a replacement
left the footer reading "All changes saved" with Save greyed out - which
is exactly what an edit that failed to register looks like.

The row now says it, and a test covers the path it describes: Replace
reopens the picker and the chosen file uploads on its own.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-14 14:51:41 +02:00
Lucio Lelii b4302bf7db Mark the node inside a container that carries the error
A container's errors are reported against the container, with the node
that actually carries one in relatedNodeIds - so opening the subflow to
look for it found a canvas with nothing marked, and an error naming a
container the view no longer showed. Entering a subflow also cleared the
marks outright, and the set was only ever computed at the root, where the
errors grouped under a container are not among the flow-level ones.

The marks now follow whatever the current view reports: the container at
the root, the block that carries the error once inside. Focusing one
error from the panel still narrows them, and dropping that focus goes
back to all of them rather than to none.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-14 14:28:54 +02:00
Lucio Lelii 3af6f14bb7 Send a global file input to the endpoint that accepts files
The upload went to /globals/{key}, which takes JSON, and came back as an
unsupported content type; the array variant additionally named its parts
after the input, which nothing binds on. Both now use the multipart routes
that exist for this, with the part names those routes read.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-14 12:31:43 +02:00
Lucio Lelii 84f528203c Give a file input a picker that says what it takes and what went wrong
The native file control said "Choose file / No file chosen": nothing about
what the input accepts, nothing about the file once chosen, and on failure
a bare "Failed to upload file" that threw away the server's explanation -
the wrong type, a size, a name it could not build a file from.

Replace it with a drop zone that states the accepted types and size up
front, then gives way to the file itself while it uploads and once it
lands, with Replace to change it. A failed upload now names the file that
failed next to what the server actually said, and the zone invites another
try - which also needed the native control cleared, since picking the same
file twice fires no change event and picking the same file again is
exactly what a retry is.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-14 12:27:52 +02:00
Lucio Lelii f41962993c Show step preview placeholders as expandable values, not a flat string replace
A node's Condition/prompt preview resolved ${{name}} placeholders with a
single-pass string replace, so a runtime value got duplicated wherever the
same placeholder repeated in the source text (e.g. a Conditional's
${{x}} != null && ${{x}}.contains(...) pattern) and long/verbose values were
dumped inline unbounded. Reuse the existing template-placeholder machinery
(already used for HumanDecisionBlock/HumanInteractionBlock text) instead: a
new "template" field type on the settings dialog renders each placeholder as
its own expandable segment.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-12 13:26:58 +02:00
Lucio Lelii 81f2a890f2 Validate MCP file uploads in execution UI 2026-09-11 14:50:21 +02:00
Lucio Lelii 86e61690bb Open a step's failure instead of squeezing it into a tooltip
A step's error is regularly a provider payload or a stack, and a hover
tooltip could only ever clip it: there was no way to read past the first
few lines, let alone paste it into a bug report.

The badge now teases the failure - "Error executing" plus its first
line, clamped - and opens the whole text in a dialog where it keeps its
own line breaks, scrolls, stays selectable, and copies in one click.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-09 16:49:40 +02:00
Lucio Lelii 0aec372d73 Connect the rerun button to the handler that was waiting for it
The list emitted executionRerunRequested and the executor had a handler
for it, but the template never bound the two: pressing rerun on a
finished run emitted into nothing, not even an HTTP call. The button
stayed enabled because it only looks at the status.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-09 16:30:33 +02:00
Lucio Lelii 497eba6e1b Let the admin console scroll instead of clipping at the viewport
The admin shell was a fixed-height panel whose only scroller was the
content column, and both wrappers above it hide their overflow. Below
900px the sidebar and the content stack into one column, the two rows
no longer fit, and nothing could scroll to reach what fell off the
bottom.

Make the shell itself the single scroller, so header, sidebar and
content scroll together, and give the host the flex sizing that its
height was already assuming.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-09 12:52:20 +02:00
Lucio Lelii 71aacef9af Note how to build the GUI image
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-09 12:25:07 +02:00
Lucio Lelii d634343df5 Pre-fill no temperature for an LLM assessment
The assessment dialog opened with temperature 0, for a judgement that reads the same twice. But on
the JSON path the provider already forces a low baseline of its own, and a 0 typed in here overrode
it; the field that actually makes an assessment repeatable is the seed, which sits next to it. Every
sampling box now starts empty, meaning "the provider decides", and the request no longer carries
defaults for the shared picker to merge.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-09 12:23:32 +02:00
Lucio Lelii 8a57d74fb9 Say when a flow has no global input to take a field from
"From global inputs" was offered on every bindable field, so on a flow that declares none it led to
an empty picker and a form that could not be saved - a dead end with nothing explaining it. The
choice stays visible, because it is what tells you the possibility exists, but it is disabled and
says "(none declared)", and the value field it replaces is hidden while it is selected.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-09 12:23:25 +02:00
Lucio Lelii eef5dfb3ed Stop offering a default on a field the state has made required
A schema-driven dialog read only the schema's own `required` list, so a field made required by
`x-ui-required-when` - an MCP server's `url` once the catalog is off, its `name` - was drawn with
"Use default" and could be saved empty. The inline editor already accounted for the conditional
rule; this brings the dialog to the same answer, and the same rule now gates both the button and
saving.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-09 12:23:19 +02:00
Lucio Lelii 074c8fb763 Give the model parameters the same treatment a node's own panel gives them
The picker for a simulator or a judge and a node's parameter panel are the
same dialog component, and the dialog has always known how to offer "Use
default" on an optional field - showUseDefault, the "Using the default" hint,
the reset. It reads one flag, defaultsWhenEmpty, which the node panels set on
every optional field and this hand-written list never set at all. So a
temperature typed here by mistake had no way back to unset: clearing the box
by hand looks the same as never having decided.

Set it on all five, and brought the rest of each field in line with what the
schema-driven panel produces for the same object: an arrow step a decimal can
actually move by, an integer step on the integers, and the tips ModelParameters
itself declares, so the same explanation appears in both places.

Temperature's max was 2 here and is 1.0 on the server, which has a comment
explaining why - the dialog was offering a value the run would be rejected
for.

Still hand-written rather than derived from the published schema: this dialog
picks a model for a run, not a node's configuration, and reaching for a block
type's schema to render five known fields would buy a network call and a way
to fail.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-08 15:15:10 +02:00
Lucio Lelii 984af08f36 Show every LLM assessment as a history, and drop one that arrives too late
The report carried a single `judge`, so asking a second model overwrote the
first - there was no way to compare two opinions, and reopening a report
after a re-evaluation only ever showed the newest one. The viewer now renders
`judgements`, newest first, each collapsible: the current one open and
labelled so, the earlier ones a click away with their own verdicts, narrative
and errors. A report saved with the old single field still reads, as a
history of one.

Also: closing the dialog while an assessment was running left `judging` stuck
true forever, so reopening any report showed a disabled button stuck on
"Evaluating...". And opening a different report while one was still running
let the late answer land on it, silently replacing the report on screen with
someone else's assessment. Both dialogs now carry a token that advances
whenever what they're showing changes; a response that arrives after its
token is stale gets discarded instead of applied. The job itself is
unaffected - it keeps running server-side and its verdicts land on the report
regardless, which is what makes reopening it later still show them.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-08 11:42:06 +02:00
Lucio Lelii 03ac4901ed Put the provider/model picker above the dialog that opens it
The picker (node-settings-dialog) shared --z-modal with every other dialog
guest, so opening it from "Evaluate impact with LLM" - itself a modal - tied
on z-index with the report behind it and lost on DOM order: it rendered, and
its backdrop even blocked clicks, but neither was visible. It read as a
button that did nothing.

Named the layer this actually is - --z-dialog-over-modal, the same one the
confirmation dialog already needed and had defined ad hoc as --z-confirm -
and moved the picker onto it.

While chasing this, closed a real silence next to it: with no LLM provider
published at all, the picker answered null and the caller treated that like
a dismissal, so the button did nothing for a second, unrelated reason. It now
throws with a message, and Simulate surfaces it as a notification instead of
swallowing it.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-08 11:41:41 +02:00
Lucio Lelii 68f4b6db85 Open a bias report in its own window, and make the reports list a list
A report is a wide, two-column document. It rendered inside the run's side
panel - a few hundred pixels at most - where the two-column diff collapsed
into a ribbon and the summary ran off the row's right edge with a horizontal
scrollbar to prove it. It now opens in a dialog of its own, at the same width
the comparison already uses, wired next to the other dialog hosts in the app
shell. The list behind it goes back to being a list: one row read top to
bottom (kind, changed/unchanged, date; a two-line summary; annotation count
and node id), and it no longer owns the fetching or the LLM-assessment state
that the detail view needs - the dialog host does, the same way the compare
dialog already did.

Separately, the empty Bias impact tab offered "Run a biased rerun" on a run
that already is one - asking to make a variant of a variant. On a run that is
itself a comparable variant, the tab now offers "Compare with baseline"
instead, wired to the same dialog the toolbar button opens.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-08 11:41:24 +02:00
Lucio Lelii c298c352b3 Let a configurable field take a global input, and show its own tip
The small parameter editor offered two sources for a configurable-as-input
field: a static value, or a workflow input. A global input was reachable only
by typing ${{global.<name>}} by hand into the value box - and on a provider
whose model list is closed there is no value box to type into, which left the
one flow-level way of deciding such a field unreachable from the editor.

There is now a third source that lists the flow's declared globals and writes
the placeholder for you, with a plain sentence when the flow declares none
rather than an empty select.

The modal also never rendered a field's description, so every tip written on
one of these fields was invisible.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-08 10:53:01 +02:00
Lucio Lelii f5386cdb7e Say which subject a bias intervention moved, and let an LLM assess it
The report now reads the per-subject sections the service produces: a row per
iterated subject with what its labelled numbers did (Score 7 -> 4), the two
texts behind it a click away, and the changed ones listed first. The band at
the top leads with what the run actually did - whether the final decision
changed, how many subjects moved, the largest delta - because the counts of
changed nodes that used to open the report were the least actionable thing in
it. A container's iterations are listed with the inner node that changed,
which is what a per-subject iterator run needs and the accumulated list could
never show. Reports produced before any of this exists still render, from
their raw outputs.

"Evaluate impact with LLM" sits next to the report it is about, in all three
places one is mounted, and opens the provider and model picker the interaction
simulator uses - extracted so both call the same dialog rather than two of
their own, with temperature 0 offered by default because a verdict that reads
differently every time it is asked for is worse than none. The assessment runs
as a job, polled like the isolated experiment, and is stored on the report, so
reopening it later shows the same verdicts and the model that produced them.
It is labelled an assessment throughout, and a pair the model could not answer
for is marked without hiding that pair's own figures.

A rerun of a simulated run now opens the Simulate dialog on the simulator it
inherited, with the inherited sampling out where it can be seen - a seed
carried over is the reason the two runs are comparable, and behind a closed
section nobody would find it. Before it is started, a run says which simulator
the run it repeats used; afterwards, both the bias report and the run-to-run
comparison say so when the two sides were not answered the same way, since
that difference is not the intervention's doing and nothing said it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-08 10:50:54 +02:00
Lucio Lelii d11e2f5a9a Show a compared output field by field, not as two JSON trees
"Compare with baseline" rendered each side as a JSON tree, side by side in a
760px modal. Two things made it unreadable. The keys are <node id>.<output
name>, so every row opened with forty characters of uuid before the one word
saying what the value is; and the tree sized each row to its longest value,
which pushed a paragraph of model output out of the visible box and left the
row looking like an empty key.

Outputs are now flattened into named fields and paired by key, with the
output name in the heading and the node id kept as a small chip. Each changed
field shows the two sides with the words that moved highlighted, reusing the
word diff the run comparison already uses; identical fields collapse to a
single column, and a filter hides them by default. With several repetitions
one variant is compared at a time instead of all of them stacked.

The tree's min-width: max-content is gone, which also fixes the same
disappearing values in the execution viewer, the other place it is used.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-08 10:50:31 +02:00
Lucio Lelii a8cad0d3cd Let the placeholder flag alone decide, without requiring a textarea
buildTemplatedRichContentParts asked for x-ui-widget: textarea as well as
the flag, so a one-line field could not have its ${{...}} marked even when
its value really is interpolated. Whether a value is interpolated is a
property of the value, not of the control used to type it.

A no-op for everything that exists today: every field carrying the flag
gets it from LongText, whose widget defaults to textarea. It is what lets
LLMDescriptor.model declare the same thing without becoming a textarea.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-07 14:31:20 +02:00
Lucio Lelii 3ba14244c3 Stop the HumanInteractive config type claiming fields the server drops
The type declared llmDescriptor, inputAsList and outputAsList. None of the
three exists on the Java class, which has only actionDescription, and
BlockConfiguration carries @JsonIgnoreProperties(ignoreUnknown = true), so
everything the fakes were sending was discarded in silence. They are
leftovers from when simulation was configured on the node, before it moved
to the execution - the descriptor a human task's executor uses is the
simulator's, passed in when the run is launched.

Worse than dead: the fake block-type schema declared simulateWith as a
*required* property, so in dev mode the editor rendered a field the real
server has no idea about. That schema also carried an LLMDescriptor
definition nothing referenced once simulateWith was gone.

A type that lies costs more than the fields it saves. This one sent me
planning work for a block that has no LLM.

Also pins what the node editor does with a nested field bound to an input,
which needed no change to support llmDescriptor.model: a blank value at a
dotted path plus a matching port reads as provided by that input, a set
value does not, and neither does a blank one before the server has created
the port.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-07 14:24:13 +02:00
Lucio Lelii 9e42ca154a Fold the assistant's panels away, and name the default it will use
Quick prompts and the assistant model panel both start collapsed now: the
prompt box is what the copilot is opened for, and everything else is a
detour until asked for. A saved session still reopens with whatever was
last expanded, the same as the other collapsible sections.

The assistant model panel gains the toggle it was already written for -
modelPickerOpen and toggleModelPicker existed, were persisted in the
session snapshot, and were never wired to the template, so the panel could
not be folded at all.

Ticking Use default configuration used to leave the panel empty: it said a
default applied without saying which. The backend has been publishing
defaultProvider, defaultModel and defaultPhaseModels alongside the
retriever URLs all along, so nothing new was needed there - only the
showing. The effective selection also appears in the section header, so
the collapsed panel still answers what the run will use, and the per-phase
lines list only the phases actually overridden rather than three rows
repeating the main model.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-07 12:30:03 +02:00
Lucio Lelii 37bf2cb853 Drop the Provider credentials panel from the assistant
A key gets added by picking a provider that needs one, so a second place
to manage the vault was redundant. The panel is gone, and with it the
rotate and enable/disable paths that only it could reach - the assistant
answers a provider's requirement now, it does not administer the vault.
That leaves creation as the only credential operation in the app; a
management screen, if one is wanted, belongs somewhere it can be found.

The picker's Add credential no longer appears only when no compatible key
exists: it was the panel that covered adding a second one, so limiting it
to the empty case would have made that unreachable. It also carries the
saving state the panel used to show.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-07 12:23:40 +02:00
Lucio Lelii ffbd8c192b Open Add credential as a modal, and close the empty credential select
Add credential set a flag whose form rendered only inside the credentials
panel, further down the aside. Pressed from the credential picker beside
the model - where you actually notice a key is missing - it scrolled
nothing into view and looked like it did nothing at all. Both entry points
now open the shared settings dialog, which is hosted at the app root and
so works wherever it is asked for.

The dialog carries the rules the inline forms carried by hand: a label and
a key are required to create one, while rotating an existing key leaves it
optional because empty there means "keep the current one", and the
provider is read-only because it is what makes a credential compatible -
rotating must not move it. The current provider is offered even when the
provider list never loaded, so the select cannot be a dead end.

A credential select with nothing in it is now disabled and says "No
credentials available" instead of opening onto an empty list. That replaces
the viewer's separate amber note, which said the same thing a second time
in a second place.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-07 12:19:50 +02:00
Lucio Lelii 8f8d2cb9f7 Let the copilot take a typed model too
Making Gemini's model list empty left the assistant's own picker with an
empty select and no way out: it has its own provider and model controls,
not the schema-driven field machinery, so the rule that an unlistable
catalogue has to be typed never reached it. Worse, it called the empty
list "No models are available for the selected provider" - wrong twice,
since the models exist and the message hid the fix.

It now asks the same /open endpoint, and the four model controls - the
main one and the three per-phase overrides, which were disabled outright
while the list was empty - take a typed name. The question is asked
alongside the list rather than derived from it: an open provider is
exactly the one whose list comes back empty, so "nothing to show" and
"nothing to offer" must not collapse into one answer. Closed on failure,
which leaves a select the user can see is broken.

The URL derivation moved to a shared helper. Both callers suffix the path
while keeping the query string, and the provider rides in that query - a
second copy of that detail is where the two would have drifted apart.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-07 12:02:00 +02:00
Lucio Lelii a6a811549a Enforce schema bounds in the editors, and say what an empty field does
Every bound was already in the schema and already enforced by the server,
but nothing passed it to the control: a temperature of 5 was typeable and
only failed on save. The settings dialog and the inline node editor now
share one validator, so a bound declared once reads the same wherever a
value can be typed. Numeric properties finally get a numeric control.

Arrow increment and required granularity are kept apart: step says what
the value must be a multiple of - 1 on an integer, nothing on a decimal -
while stepIncrement only moves the spinner. Arrows on a 0-to-1 field used
to jump by 1, reaching only the two ends of the range; they now move by a
tenth without making 0.35 wrong. FieldValueConstraints omits stepIncrement
so the increment cannot reach the validator to try.

An empty optional field now states that it is using the default, with a
reset beside the control that stays in place and greys out rather than
appearing once a value is typed. Going back to unset is the one thing a
filled box cannot express: clearing it by hand looks identical to never
having decided. Generic - it follows from the schema not requiring the
field, on all three editing surfaces, container included.

Also fixes the dialog reading as broken: descriptions were rendered twice,
once as a mat-hint and once below in error red, and the wrapping hint
overflowed the fixed-height subscript area onto the button beside it.

An optional group now sits in the fieldset of the object that owns it, so
a node holding two LLM descriptors cannot show two identical "Model
parameters" controls with nothing to tell them apart.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-07 11:53:40 +02:00
Lucio Lelii 5464ad952f Stop the AI creation button coming back after the modal is closed
The floating "AI flow creation" button was never tied to a running generation: it
appeared whenever a snapshot existed under the create-modal key. Two paths put
one there without anything being in flight - the assistant persists on destroy
unconditionally, and closing the modal only cleared the snapshot inside the
branch that had just cancelled a call. So opening the modal once and closing it
was enough to make the button return on every later load, and it survived
reloads because the store persists to localStorage.

Closing now discards the conversation whichever path it takes. Minimize is what
keeps one alive, and reloading during a running generation still restores it -
which is what the button is for.

Both clears are needed: the component one also stops its own ngOnDestroy from
writing the snapshot straight back, and the store one still applies when the
assistant is already gone.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-04 18:02:53 +02:00
Lucio Lelii eb6618044d Import a flow from a JSON file
"From JSON" in the New menu picks a file and creates the flow from it. The name
is de-duplicated the way "Empty flow" already does it, so importing the same
file twice gives two flows you can tell apart, and the result opens in the editor
- which is where the import's real failure mode shows: a flow can save and still
not be executable here.

The type check before the request is the part that matters. A typeName this
server does not know makes the backend validator dereference a null and answer
500; an unknown configuration id fails inside Jackson with a raw 400. Both are
unreadable, so the file is refused up front with the offending names. The check
honours the two type names the server has renamed, or it would reject files the
server would have accepted.

A bare graph is accepted as well as an envelope: the backend hands whole graphs
around in that shape, so a JSON copied from the container import or out of the
database still works.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-04 16:50:51 +02:00
Lucio Lelii c41ad1092c Export a flow as a JSON file from the actions menu
A flow could be cloned or nested in a container, but never left the installation
it lived in - no copy for a ticket, for another machine, or for outside a
database that run_service.sh recreates from scratch on every restart.

The file is an envelope, not a bare graph: the name and description survive, and
there is somewhere to put a format version. What the server decides for itself
stays behind - id, author, published, finalized, projectId, status - so an
import can never be a way to mint a public or finalized flow. Node ids inside the
graph are kept: each flow stores its own, and rewriting them would mean remapping
every connection.

Export re-reads the flow rather than trusting the cached row, and unlike opening
it does not fall back to that row on failure: a file that looks complete and is
not would be worse than an error.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-04 16:48:01 +02:00
Lucio Lelii 43bcc38b9e Honour optional groups in the container node too
Same annotation, same control, same write-back as in a block: one button that
opens the whole object, a badge saying how many settings are set, and a group
that disappears when everything is cleared.

The one container-specific piece is how a select resolves its options, which the
shared round trip takes as a callback.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-04 14:08:17 +02:00
Lucio Lelii 0c0fac3643 Move the object dialog round trip into the shared schema module
An annotation honoured by one node type is worse than no annotation, so the
container needs the same round trip the block editor has. It moves as it is -
the characterisation tests written before the extraction still pass unchanged -
with only the two genuinely component-specific parts left as callbacks: how
select options are resolved, and how a dynamic sub-schema is expanded.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-04 14:04:49 +02:00
Lucio Lelii c6060540f9 Render an optional group as one button that opens a modal
Five empty chips for parameters nobody sets on most nodes took more room than
the prompt. The group now shows as a single control saying how many of its
settings are set, and opens the whole object in one dialog.

The write-back reuses the object round trip rather than the array one, so an
optional numeric cleared in the modal removes the key instead of persisting 0 -
otherwise the provider default would be unreachable, which is the bug fixed
yesterday for the inline editor. Clearing everything drops the object entirely,
so a saved flow never carries an empty husk that implies a choice was made.

A temperature of 0 counts towards the badge: it is the repeatable setting, not
an absence, and a collapsed control must never hide a value.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-04 12:39:55 +02:00
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