Skip to content

[FE feat] Mustache support#4465

Open
ardaerzin wants to merge 75 commits into
feat/add-mustache-renderingfrom
fe-feat/mustache-support
Open

[FE feat] Mustache support#4465
ardaerzin wants to merge 75 commits into
feat/add-mustache-renderingfrom
fe-feat/mustache-support

Conversation

@ardaerzin
Copy link
Copy Markdown
Contributor

Summary

Mustache support

  • Mustache section openers ({{#name}} / {{^name}}) surface as input ports — extracted, validated, and typed (array when referenced only via a section opener)
  • TokenTypeaheadPlugin fires on {{#… / {{^… for mustache prompts with the same suggestion sources as flat mode
  • JSONPath validator relaxed to accept testcase-spread roots ({{$.profile.name}}) — matches the SDK's dual-access envelope in completion_v0 / chat_v0
  • Editor accepts mustache section close tags ({{/name}})
  • TemplateFormatPicker wired into the playground prompt-config header
  • Mustache-specific features (section openers, prefix stripping, array-type inference) are gated to templateFormat === "mustache". Curly / jinja2 prompts don't silently extract mustache syntax as variables

Playground inputs

  • Replaces the per-variable VariableControlAdapter with one bordered card per variable: name + type chip + action cluster + "View as ▾" dropdown + value editor
  • Visibility rule: referenced variables render as cards; testcase columns the prompt doesn't reference collapse under a "N unused columns hidden…" footer (expandable, editable when expanded)
  • Schema-aware draft defaults: when an object-typed port is referenced via sub-paths, Form mode opens with the expected sub-fields pre-rendered as empty inputs; JSON / YAML modes get the same skeleton seeded into the buffer. Render-only — the testcase value stays untouched until the user actually edits a field
  • Unified action cluster per card: copy-value button + testset-sync indicator
  • View modes are pure representation transforms — they never change the value's type
  • Consistent dropdown ordering across every kind: kind-specific modes first, JSON / YAML always at the bottom
  • Wired into SingleLayout, ComparisonLayout, and the grouped evaluator layout (with section blocks for the inputs / outputs envelopes)

Others

  • valueToDisplay never auto-parses JSON-shaped strings into objects — a string stays a string in every view mode, addressing the recurring QA finding that the FE was mistaking stringified JSONs for objects
  • Execution input builders pass native objects / arrays / numbers straight through to the request body; no implicit stringification anywhere in the completion + chat path

Testing

Verified locally

  • pnpm --filter @agenta/entities exec vitest run → 399 tests pass (template extraction, port grouping, visibility rule, mustache section openers, JSONPath validator, format-gated extraction)
  • pnpm --filter @agenta/entity-ui exec vitest run → 79 tests pass (view-types, formatters, build-empty-shape, dropdown ordering)
  • pnpm --filter @agenta/playground-ui exec tsc --noEmit → clean
  • pnpm --filter @agenta/ui exec tsc --noEmit → clean
  • Lint clean across @agenta/entities, @agenta/entity-ui, @agenta/playground-ui, @agenta/ui
  • Manual playground E2E, checking trace for transformed prompts

Added or updated tests

  • extract-template-variables.test.ts — mustache section openers, mustache vs curly format gating, dedup
  • port-helpers.test.ts — sectionOpeners hint produces array-typed groups; sub-pathed names still object
  • playground-inputs-formatters.test.ts — strings stay strings in JSON / YAML modes
  • view-types.test.ts — schema-aware defaults, expected-type fallback for empty values, buildEmptyShapeFromSchema with _pathHints, consistent option ordering across kinds
  • playground-inputs-visibility.test.ts — referenced vs unreferenced split, system-field filtering

QA follow-up

  • Existing curly configs — confirm nothing breaks
  • Comparison view + evaluator playgrounds — both consume PlaygroundInputsBody now; visual parity with single view, including the section-block layout for evaluator envelopes
  • Testset drawer — the valueToDisplay fix applies there too (shared formatter). Verify chat editor in the drawer no longer renders stringified JSON as a parsed object

Checklist

  • I have included a video or screen recording for UI changes, or marked Demo as N/A
  • Relevant tests pass locally
  • Relevant linting and formatting pass locally
  • I have signed the CLA, or I will sign it when the bot prompts me

Contributor Resources

ardaerzin added 30 commits May 26, 2026 00:55
Remove normalizeCompact() wraps from buildEvaluatorExecutionInputs in
runnable/utils.ts so testcase values, upstream output, and ground_truth
arrive at the backend as native JSON (object/array/number/...) rather
than stringified text. Required for mustache nested access (e.g.
{{geo.region}}) to work over object-typed variables.

The completion-path was already preserving native types post-#4394
(loadableController.selectors.row().data is native). Added a clarifying
comment to toDisplayString in execution/selectors.ts to prevent future
misuse for transport — it is a UI-display helper only.

13 new tests in build-evaluator-execution-inputs.test.ts pin the contract:
schema-driven and legacy paths, object/array/primitive preservation,
gap-04 invariant (JSON-shaped strings stay strings).
Move Mahmoud's V2 view-mode vocabulary from the design-mockups POC
(web/apps/design-mockups/src/components/proposed/) into @agenta/entity-ui
under a new ./view-types subpath. Exports:

- ViewType (6-way: text / markdown / chat / form / json / yaml)
- FieldKind (4-way bucketing for view-options decision)
- NestedKind (precise nested kind for form widgets)
- isChatMessagesArray, detectFieldKind, detectNestedKind
- getViewOptions, getDefaultViewForValue
- ViewTypeSelect (the "View as ▾" dropdown component)
- FormView (recursive form rendering for objects/arrays)
- Pure formatters: valueToDisplay, coerceTextEdit, parseJsonEdit, parseYamlEdit

The chip vocabulary stays distinct from FieldKind. For type chips,
consumers use TypeChip + inferLogicalType from @agenta/ui + @agenta/shared
(granular: string / number / boolean / null / json-object / json-array).
V2's FieldKind is INTERNAL to view-options decision logic only.

Tests live in agenta-entities/tests/unit (stopgap until entity-ui gets
its own vitest runner from #4393): 22 view-types tests + 23 formatters
tests pinning the contract.
New component in @agenta/playground-ui/playground-inputs-body that
renders a list of per-variable bordered cards composed from
@agenta/entity-ui/view-types primitives. Replaces the per-variable
SharedEditor rendering with type chips + per-variable "View as ▾"
dropdown (text / markdown / chat / form / json / yaml).

All edits write NATIVE values via onValueChange — never stringifies
on the way out (RFC: "native JSON stays native until template
rendering").

Components:
- PlaygroundInputsBody: top-level orchestrator. Props: rowId, inputs,
  unreferencedColumns, editable, onValueChange, onAddDraftColumn,
  onViewModeChange.
- VariableCard: single card with header (name + TypeChip via
  inferLogicalType + ViewTypeSelect + optional [draft] badge) and
  body switched by mode (SharedEditor / FormView / ChatMessageList /
  JSON / YAML code editor / InputNumber / Switch per type).
- UnreferencedColumnsFooter: collapsed-by-default footer rendered
  once below all cards. "N unused testcase columns hidden..."
- viewModeAtoms: atom family keyed by (rowId, varName) — session
  scoped per-variable view-mode state.

Wiring into existing playground (SingleLayout / ComparisonLayout)
lives in Step 6 — this commit ships the presentational component.
…body

The new V2-aligned input UX renders one card per variable that the
prompt references — including draft variables (referenced but not yet
on the testcase). Testcase columns the prompt does NOT reference are
collapsed under an "N unused testcase columns" footer.

This commit adds the pure split helper + the atom layer that feeds it:

- splitInputsVisibility({referencedKeys, testcaseData}) → {inputs,
  unreferencedColumns}. Pure function in execution/visibility.ts.
  inputs[i].isDraft = true when name is referenced but missing from
  testcaseData.

- referencedVariableKeysAtomFamily(downstreamKey): schema-referenced
  variable keys only (template input ports + downstream evaluator
  expected columns). Excludes testcase-only extras.

- rowVariableKeysAtomFamily refactored to call referencedVariableKeys
  + add testcase-extras on top. Same external contract — connected
  testset still merges expected_output and friends.

- playgroundInputsAtomFamily({testcaseId, downstreamKey}): wraps the
  pure split with the live atom sources. System fields stripped from
  testcase data before splitting so __id__ and friends don't bleed
  into the unused-columns footer.

- executionItemController.selectors.inputsVisibility(...) +
  .referencedVariableKeys(...) expose the new atoms on the controller
  surface — matches PlaygroundInputsBody's props shape directly.

13 new tests pin the contract: referenced+testcase intersection
(native preservation), draft annotation (referenced - testcase),
null/undefined value handling, unreferenced collection, edge cases.
Small antd Select for choosing how a prompt template renders
(Mustache / Jinja2 / [Curly] / [F-string]). Wraps a vendored
buildTemplateFormatOptions(currentFormat) helper that matches the
WP-B3 web-handoff contract:

  - New / mustache / jinja2 prompts → ["mustache", "jinja2"]
  - Prompts on curly → ["mustache", "jinja2", "curly"]
  - Prompts on fstring → ["mustache", "jinja2", "fstring"]
  - Default for new prompts = "mustache"
  - Legacy formats never offered to other prompts
  - Unknown formats appended defensively, never coerced

VENDORING NOTE: The buildTemplateFormatOptions helper is vendored
from #4393 (still OPEN). When #4393 lands, the canonical version
ships at agenta-entity-ui/src/DrillInView/SchemaControls/
templateFormatOptions.ts — this branch's copy gets deleted then
and TemplateFormatPicker re-imports from there. See the file
header for the full vendoring note. Differences vs #4393's version
(labels, option shape, nullable input) are documented inline.

TemplateFormatPicker itself is genuinely additive — #4393 only
ships the options helper + wires it into PromptSchemaControl
(drawer). The playground needs its own picker component.

15 new tests pin the options contract: new prompts, mustache/jinja2
stored, curly/fstring legacy appending, hint tags, unknown defensive
fallback, never-coerce idempotency.
Missed the package.json hunk in the TemplateFormatPicker commit
(cace2ec). Adds the "./template-format" subpath so consumers can
import {TemplateFormatPicker} from "@agenta/entity-ui/template-format".
…flagged)

Adds an opt-in path through SingleLayout's flat (non-grouped) branch
that renders PlaygroundInputsBodyHost in place of the per-variable
VariableControlAdapter loop. Off by default — existing UX is preserved
on merge; OSS (or a dev toggle) flips useNewPlaygroundInputsBodyAtom
to true to surface the new V2-aligned cards.

New files:
- agenta-playground-ui/src/state/featureFlags.ts — defines
  useNewPlaygroundInputsBodyAtom (boolean, default false). Plain
  session atom; promote to atomWithStorage if it becomes a user pref.
- agenta-playground-ui/.../PlaygroundInputsBody/PlaygroundInputsBodyHost.tsx
  atom-aware wrapper that reads inputsVisibility({testcaseId, downstreamKey})
  and writes via setTestcaseCellValue. Drafts route through the same
  setter (it creates the column on first set).

Modified:
- agenta-playground-ui/src/state/index.ts — exports the flag atom.
- PlaygroundInputsBody/index.tsx — re-exports the Host.
- SingleLayout.tsx — non-grouped branch checks the flag and renders
  Host instead of variableIds.map(renderVariable) when enabled.

Deferred (explicit follow-ups documented in approved design doc):
- ComparisonLayout — second adapter consumer, same swap pattern.
- Grouped evaluator layout — keeps VariableControlAdapter per design
  doc ("the adapter stays for evaluator-playground / chain-step").
- TemplateFormatPicker placement into an OSS prompt-config surface —
  needs design-team sign-off on placement per design doc Open Q2.
- Default-flip the feature flag — small follow-up commit after the
  user verifies the new UX in dev.
…o fe-feat/mustache-support

# Conflicts:
#	api/uv.lock
#	services/uv.lock
Now that WP-B3 (#4393) is merged in, drop the vendored duplicates and
align on the canonical exports:

- Delete agenta-entity-ui/src/template-format/templateFormatOptions.ts
  (vendored copy). Re-point TemplateFormatPicker to import
  buildTemplateFormatOptions + TemplateFormat from #4393's canonical
  location: src/DrillInView/SchemaControls/templateFormatOptions.ts.
- Adopt #4393's labels ("Prompt Syntax: Mustache" / "Jinja2" /
  "Curly" / "F-string") via TEMPLATE_FORMAT_LABELS — drawer + playground
  now share the same vocabulary.
- Drop my hint chip system; #4393's option shape is {label, value}.
- Drop my vendored template-format-options.test.ts — superseded by
  #4393's agenta-entity-ui/tests/unit/templateFormatOptions.test.ts.
- Migrate the other two stopgap tests (view-types + formatters) from
  agenta-entities/tests/unit/ to agenta-entity-ui/tests/unit/ now that
  #4393 ships a vitest runner in entity-ui (vitest.config.ts). Relative
  imports tightened to ../../src/view-types/.

Remaining stopgap in agenta-entities/tests/unit/:
- playground-inputs-visibility.test.ts — tests @agenta/playground code,
  which still has no vitest runner. Keep there.
- build-evaluator-execution-inputs.test.ts — tests @agenta/entities
  code, naturally lives in entities. Stays.

Test totals after reconciliation:
- @agenta/entity-ui — 58 tests (4 files: view-types, formatters, my +
  #4393's chatPromptsMustache, #4393's templateFormatOptions)
- @agenta/entities — 353 tests
…orts up)

ESLint import/order rule requires `@agenta/*` workspace imports to come
before relative imports. The pre-existing `@agenta/playground-ui/adapters`
import was already in the wrong place; my Step 6 additions
(PlaygroundInputsBodyHost, useNewPlaygroundInputsBodyAtom) sat next to it.
Move all three to the right block — no behavior change.
Flip useNewPlaygroundInputsBodyAtom default to true. The V2-aligned
PlaygroundInputsBody is now the default rendering for SingleLayout's
flat (non-grouped) path: bordered card per variable, granular type
chips, "View as ▾" dropdown with Chat / Form / Text / Markdown / JSON
/ YAML, native-JSON edits.

VariableControlAdapter is still used for:
- the grouped evaluator layout (useGroupedLayout === true) — field
  ports nested under envelope sections, follow-up swap deferred.
- ComparisonLayout — multi-variant side-by-side view, same pattern as
  SingleLayout but the swap is the next ticket per the design doc.

Once ComparisonLayout is also swapped, the flag + conditional in
SingleLayout can be removed entirely.
The prompt-editor token validator at templateVariable.ts:130 treated
any `{{/...}}` as a JSON Pointer and required the first segment to be a
known envelope slot. That rejected mustache section close tags like
`{{/languages}}` (paired with `{{#languages}}`) as "Unknown envelope
slot."

Fix: short-circuit single-segment identifier-shaped paths
(`/^/[a-zA-Z_][\w.]*$/`) as valid. They can't be multi-segment JSON
Pointers, and in mustache they're section close tags. Multi-segment
paths (`/inputs/foo`) still get the envelope-slot check. Numeric-led
paths (`/123abc`) fall through and are rejected.

Trade-off: legacy curly users writing `{{/input}}` (singular, typo of
`{{/inputs}}`) lose the typo-detection hint at the editor. The runtime
remains the source of truth — mustache renderer surfaces a clear
error for unmatched close tags, and curly's `/input` lookup returns
no value. Accepted because mustache is the new default and section
close tags are common-path syntax.

Also corrects A5 in the test plan: `{{$.geo.region}}` is by-design
rejected by the validator (JSONPath must root at an envelope slot).
Switched the suggested syntax to `{{$.inputs.geo.region}}` with a
note about the runtime-spread vs static-validator gap.

14 tests in template-variable-validation.test.ts pin: plain names,
dotted access, JSONPath envelope rooting, JSON Pointer envelope
rooting, mustache section close acceptance, numeric-led rejection.
3-row JSON array (Vanuatu / Kiribati / Switzerland) with 9 columns
covering every type the playground UX has to handle:

- string         : country, correct_answer (plain {{name}} subst.)
- number         : population_thousands (NUMBER chip, native transport)
- boolean        : is_island_nation (BOOLEAN chip, Switch widget)
- object         : geo (2-level nested — the headline mustache test)
- array          : languages (ARRAY chip, section iteration)
- string-as-JSON : metadata (gap-04: stays STRING, not parsed)
- messages       : chat-shaped role-tagged array (MESSAGES chip, Chat view)
- unused-column  : notes (drives the unreferenced-columns footer)

Uploads via the testset UI in bare-array form (matches the
/simple/testsets/upload endpoint, NOT the {name, csvdata} wrapper).

Referenced by test-plan.md (same folder) §1 (upload) and the
scenarios in §3.
The RFC's canonical mustache JSONPath examples use a testcase top-level
column as the root (e.g. `{{$.profile.name}}` against a `profile` column
that's spread into the render context). The editor's validator at
templateVariable.ts:108 was rejecting these as "Unknown envelope slot",
because it required the root segment to be one of {inputs, outputs,
parameters, testcase, trace, revision}.

That contradicts the RFC. Fix: relax the JSONPath check — accept any
root that ISN'T a near-miss typo of an envelope slot. Typo detection
stays as an actionable hint (e.g. `$.input.country` → suggests
`inputs`), but legit testcase columns (`$.geo.region`, `$.profile.name`,
`$.country`) now pass through.

The bare root `{{$}}` (whole context as compact JSON) is also now
accepted; it was previously rejected for having no segments. RFC docs
this as canonical syntax for serializing the whole context.

JSON Pointer rule unchanged. Per RFC, JSON Pointer is legacy-curly
only; mustache uses `$.` JSONPath.

Reverts the A5 test-plan workaround — `{{$.geo.region}}` is now what
the user should type, matching the RFC examples.

Updated `template-variable-validation.test.ts` from 14 → 19 tests:
adds the testcase-column rooting cases, the bare-`$` case, and the
typo-hint mention of the testcase-column escape.
… testcase-spread roots

Two related fixes to keep input-port discovery aligned with the RFC's
spread semantics:

1. extractTemplateVariables now skips mustache block markers — they're
   structural syntax, not variables, and shouldn't produce phantom
   input ports:
   - `{{#name}}` / `{{^name}}` — section opens / inverted sections
   - `{{/name}}` — section closes
   - `{{!comment}}` — comments
   - `{{> partial}}` — partials (also rejected at render time)
   - `{{.}}` — the implicit iterator
   Filter applied for all formats (mustache uses these natively; curly
   doesn't but the filter is defensive against paste-from-mustache).

2. parseTemplateExpression's JSONPath branch now routes non-envelope
   first segments through the testcase-spread path: `{{$.geo.region}}`
   parses as {envelope: "inputs", key: "geo", subPath: "region"},
   equivalent to {{$.inputs.geo.region}}. Matches RFC: "testcase
   top-level keys are spread into the render context, so `{{$.profile.
   name}}` resolves against the spread `profile`."

   Same shape already in place for plain dot-notation (`profile.name`);
   this change brings JSONPath in line.

Test updates:
- port-helpers.test.ts: `$.invalid.x` no longer rejected — replaced
  with `$.input.x` (still rejected as envelope-slot typo). Added a
  positive test for the testcase-spread case.
- New extract-template-variables.test.ts: 15 tests pinning block-
  marker filtering across mustache / curly / fstring.

Full @agenta/entities suite: 385 tests pass.
playgroundInputsAtomFamily was treating the testcaseMolecule entity
as if it were the row data dict. `testcaseMolecule.data(id)` returns
`{id, data: {...row...}, flags, tags, meta}` — the row columns live
at `entity.data`, not at the entity's top level.

Symptom: after loading a testset, every referenced variable rendered
with the `draft` badge ("Not on testcase yet"), and the unreferenced-
columns footer counted the entity's own fields (`flags`, `tags`, ...)
instead of testcase columns. Native data was on the molecule but my
atom couldn't see it because `"country" in entity` was always false.

Fix: switch to `testcaseMolecule.data(testcaseId)` (the canonical
accessor pattern used by `testcaseDataAtomFamily`) and access
`entity.data` for the row columns.
…path

Step 1 only killed `normalizeCompact` in the evaluator path. The
completion + chat playground request body went through TWO MORE
stringification points in executionItems.ts:

1. `resolveVariableValues` returned `Record<string, string>` via
   `stringifyValue`, which `JSON.stringify`'d every object/array.
   That dict gets merged into `data.inputs` by `transformToRequestBody`
   — so the backend received `geo: '{"region":"..."}'` (string) instead
   of `geo: {region: "..."}` (object).

2. `buildCompletionInputRow` wrapped each value as `{value: String(v)}`,
   turning `{region: "..."}` into `"[object Object]"` for completion
   prompts.

Symptom user hit: `{{$.geo.region}}` raised "Unreplaced variables in
mustache template" because the JSONPath resolver can't navigate into a
string. mystache's `{{geo.region}}` silently returned empty (mystache
is permissive on missing keys) so plain dotted access LOOKED like it
worked while actually rendering blank.

Fix:
- `resolveVariableValues` now returns `Record<string, unknown>`,
  passes values through native.
- `buildCompletionInputRow` drops the `String(value)` wrap; the
  request-body's `extractInputValues` already handles native object
  values without coercion.
- `variableValues` type signatures widened to `Record<string, unknown>`
  in both ResolveVariableValues callers and BuildRequestBody params.

`transformToRequestBody` already typed `variableValues?: Record<string,
unknown>` so no API change there — this just fixes the producers.

After this, `{{geo.region}}` and `{{$.geo.region}}` both resolve to
the same value at the backend.
Mirror SingleLayout's feature-flagged swap. When
`useNewPlaygroundInputsBodyAtom` is on, the comparison view renders a
single shared `PlaygroundInputsBodyHost` (V2 bordered cards + type chips
+ "View as ▾" dropdown) instead of the per-variable
`VariableControlAdapter` loop.

Row-level controls (open focus drawer + delete row) move out of each
variable's header cluster into a small toolbar above the shared inputs
body — one cluster instead of N — since the new cards have their own
header design and the actions are testcase-row scoped, not per-variable.

The existing per-variable loop stays intact behind the flag for any
consumer not opted in; the public Props surface is unchanged.

Closes the second of the deferred follow-ups from the
mustache-support design (ComparisonLayout swap). The feature flag can
be removed once the grouped evaluator layout swap is also done.
Migrate the `useGroupedLayout === true` branch in SingleLayout off the
per-variable `VariableControlAdapter` loop and onto
`PlaygroundInputsBodyHost`. The host now takes an optional `sections`
prop that partitions referenced variables into named left-border blocks,
mirroring the legacy `<SectionBlock>` accent — `inputs` envelope + the
extracted field ports share one block, `outputs` envelope sits in its
own block.

`VariableCard` gains optional `helpText`, surfaced as a small Info
tooltip next to the variable name. The host enriches each visibility
input with `helpText` from the input-port schema map, so evaluator
envelope variables (`inputs`/`outputs`) keep the guidance tooltip the
legacy `VariableHeader` used to render.

The grouped + flat layouts now both flow through one component;
SingleLayout's legacy SectionBlock + per-variable path is kept behind
the feature flag for any consumer not opted in.

Closes the grouped-evaluator deferred follow-up from the
mustache-support design.
…ader

Add the prompt template_format picker to the PlaygroundConfigSection
prompt section header, alongside the existing Refine + Configure-model
controls. The picker lives where the user already looks for prompt-
level settings — top right of the section — instead of being buried
in the action bar at the bottom of the messages list.

Wires through `updatePromptRootField("template_format", next)`, which
already handles both canonical (root-level `parameters.template_format`)
and legacy (`parameters.prompt.template_format`) shapes. Reads from
`promptModelInfo.promptValue.template_format` / `templateFormat` so
both naming variants are respected.

Note: `PromptSchemaControl` still renders its own inline Select in the
action bar (kept from #4393's BE+SDK work) so any consumer using
PromptSchemaControl directly outside PlaygroundConfigSection keeps a
picker. In the playground, both pickers stay in sync via the existing
value→localTemplateFormat sync effect. Removing the action-bar picker
inside PlaygroundConfigSection is a follow-up consolidation if the
header placement is approved.

Closes the picker-placement deferred follow-up from the
mustache-support design.
Previously the variable extractor skipped every `{{...}}` token whose
inner expression started with `#`, `^`, `/`, `!`, `>`, or was exactly
`.` — meaning `{{#languages}}...{{/languages}}` produced no port at
all, so the playground had no input card for `languages`.

`#name` (section opener) and `^name` (inverted section opener) ARE
variables — `name` still needs a value at render time (the iterable to
loop, or the truthiness to test). Strip the prefix and surface the
base name as a port. `&name` (unescaped variable) gets the same
treatment. Closers / comments / partials / the implicit `.` stay
skipped — they're structural only.

Tests updated to pin the new semantics:
  {{#languages}}{{.}}{{/languages}}  → ["languages"]
  {{^empty}}none{{/empty}}            → ["empty"]
  {{&html}}                           → ["html"]
  {{/languages}}                      → []
  {{! comment }}                      → []
  {{> partial}}                       → []
  {{.}}                               → []
When a referenced variable is still a draft (no value on the testcase
yet), the inputs body had no way to know its shape — it fell through
to `inferLogicalType(undefined) → "null"` and defaulted to a Text
input with a `null` chip. So a `geo` port referenced via
`{{geo.region}}` / `{{geo.coordinates.lat}}` opened as a single-line
text input even though the prompt clearly authored it as an object.

Plumb the declared port type (from `inputPortSchemaMap`) through:

  PlaygroundInputsBodyHost  reads `inputPortSchemaMap[name].type` and
                            adds `expectedType` to each visibility entry
  PlaygroundInputsBody      uses new `getViewOptionsForExpectedType` /
                            `getDefaultViewForExpectedType` helpers that
                            fall back to expectedType when value is empty
  VariableCard              uses expectedType to derive the TypeChip
                            variant (`object` / `array` / `boolean` /
                            `number` / `string`) when the value is empty

A draft `geo` port now opens as Form with an `object` chip; a draft
`languages` opens with the right shape too. Once the user types a
value, the runtime value takes over (chat-shaped arrays still detect
as Chat, etc.) — `expectedType` is a draft-time hint only.

Tests added in entity-ui covering the empty-value fallback for every
expectedType, the runtime-value-wins case (real string beats
`expectedType: "object"`), and the chat-shaped array override.
When a referenced variable is still a draft (no value on the testcase
yet) AND the port schema describes its expected shape, render the
Form / JSON / YAML view pre-populated with that empty skeleton so the
user sees the fields the prompt is actually asking for.

`geo` referenced via `{{geo.region}}` / `{{geo.subregion}}` /
`{{geo.coordinates.lat}}` / `{{geo.coordinates.lng}}` now opens as a
Form with all four sub-fields visible — instead of an empty form
where the user has to know which keys to add.

How it works:

  new helper  buildEmptyShapeFromSchema  reads `_pathHints` (the
                                          original nested sub-paths
                                          preserved by buildSubPathSchema)
                                          to reconstruct nesting that the
                                          flat `properties` would lose.
                                          Returns null for primitives.

  Host         injects `expectedSchema` (`inputPortSchemaMap[name].schema`)
              onto each variable.

  VariableCard computes a `seedShape` once per render — only when the
              value is empty AND the schema yields a shape. Passes it
              into CardBody.

  CardBody     uses `seedShape ?? value` for Form / JSON / YAML modes
              (where structure is meaningful). Text / Markdown / Chat
              modes use the raw value — seeding wouldn't help there.

Render-only: `onChange` only fires on real user edits, so the testcase
value stays untouched until the user actually types into a field. Once
they do, the full shape is persisted (untouched fields persist as `""`,
which mustache renders identically to `undefined`).

Unit tests cover the helper end-to-end:
  - primitive schemas → null
  - flat properties → flat empty object
  - nested properties → recursive empty object
  - `_pathHints` preferred over flat properties (the playground case)
  - empty `_pathHints` falls through to properties
  - non-string `_pathHints` entries are skipped (defensive)
The "View as ▾" dropdown previously rendered with a custom antd
Dropdown trigger ("View as Text ▾"), a "SELECT HOW TO VIEW" section
header, and a "default" hint pill on the selected row. That was
visually inconsistent with the other small dropdowns in the playground
(the prompt config view-mode picker is a borderless `antd Select` with
just option labels).

Replace the custom Dropdown with a plain borderless `antd Select` —
same visual treatment as the surrounding playground UI. The trigger
now just shows the current mode (e.g. "Text", "Form"); the menu is
just the options list with the selected one highlighted. Same
function, less chrome.

Props are backward compatible — `value`, `options`, `onChange`,
`disabled` all unchanged. Added optional `variant` (default
`"borderless"`) and `className` for surfaces that need an outlined
chip or layout overrides.
…orts

A name referenced ONLY as a mustache section opener — like
`{{#languages}}{{.}}{{/languages}}` — was previously bucketed as a
plain `string` port. The iteration intent (`#name` says "iterate over
this if it's an array") is the strongest signal we have about its
shape; the new behaviour surfaces it as an `array` port with the
matching TypeChip and view-mode defaults.

How:

  new helper  extractMustacheSectionOpeners(input, fmt)
              → Set<string> of names that appeared as `{{#name}}` /
                `{{^name}}` in the source template. Empty for non-
                mustache formats.

  new helper  extractSectionOpenersFromConfig(agConfig)
              → mirrors extractVariablesFromConfig — collects section
                openers across every prompt-like entry's messages.

  groupTemplateVariables now accepts an optional
  `{sectionOpeners?: Set<string>}` hint. Type inference priority:

    1. Sub-paths present     → "object"  (strongest signal)
    2. In sectionOpeners AND no sub-paths → "array"  (iteration intent)
    3. Otherwise             → "string"

  workflow molecule callers (`buildEvaluatorFieldPortsFromTemplate` +
  the two non-evaluator branches in `inputPortsAtomFamily`) compute
  the opener set per content/config and pass it through. Array ports
  emit `{schema: {type: "array"}}` so the empty-shape seed produces
  `[]` and the new playground inputs body can offer a sensible JSON
  skeleton on drafts.

Defaults adjusted:

  array drafts default to JSON view (not Form). FormView has no
  add-item affordance for an empty `[]`, so JSON's editable buffer is
  the more useful entry point. Form stays in the dropdown — it works
  well once items exist.

Tests added: section-opener extraction (mustache only), grouping with
the hint (priority: object > array > string), array default view, the
sub-pathed-AND-section case (object wins).
`getViewOptionsForExpectedType` now special-cases `expectedType: "array"`
to put JSON first in the dropdown. FormView has no "add item"
affordance for an empty `[]` — the user would see "(empty object)"
with no path forward. JSON's editable buffer is the natural entry
point: the user types `["en", "fr"]` and they're done.

Form is still in the options list (`[json default, form, yaml]`) so
arrays with items can use it for per-index editing.

Tests pin both cases: empty array defaults to JSON, real array
(non-empty) defaults to Form (value-driven path takes over once a
value exists).
Previous styling commit (83693af) over-corrected — moving to a plain
antd Select dropped the "View as {mode}" prefix on the trigger, but
the trigger label was the part that was working. The original beef
was the LIST styling (the "SELECT HOW TO VIEW" group header and the
"default" hint pills), not the trigger.

Revert to the Dropdown + Button trigger reading "View as X ▾", but
keep the menu items flat — no group header, no hint pills. Best of
both: the trigger still discloses the dropdown's purpose, the menu
matches the rest of the playground's lightweight dropdowns.
…down

`FormView`'s `formOuter` has `paddingRight: 20px` so the form's leaf
cards (the input rectangles) sit inside that padding, away from the
variable card's right border. But the labelRow holding each per-field
`View as ▾` button also lived inside that padding, so the per-field
dropdown sat 20px to the LEFT of the card-level dropdown above — a
visible misalignment when scanning down the inputs.

Stretch the labelRow past `formOuter.paddingRight` with a negative
right margin so the per-field dropdown right-aligns with the
card-level one. The field BODY (leafCard, nested rail, nested form
fields) stays inside the padding, so the input rectangles keep their
breathing room from the card border.

Same fix applies to deeper nesting: `nestedRail` adds left padding only,
so a nested field's labelRow has the same right edge as a top-level
one — both extend 20px to the right via the same margin, both land at
the card content's right edge.
Children labels were stealing visual weight from their parent variable
name — 14px / weight 600 / near-black / sans-serif inside a nested
form, versus the parent card's name at 12px / weight 500 / blue /
mono. `region` was screaming louder than `geo`.

Unify nested field labels to the same vocabulary the parent uses:
mono, 12px, weight 500, brand blue (#1677FF). The indent + 2px rail
already communicates nesting; the label itself doesn't need to shout.

Also swap the antd `<Tag>` kind chip for the shared `TypeChip`
component used in the parent card, mapping the nested 6-way kind to
the chip vocabulary (`object` → `json-object`, `array` → `json-array`).
Nested fields now show the SAME chip family as the parent — `object`
chip with brand-blue tone instead of antd's gold.

Result: visual hierarchy reads top-down — parent name + chip set the
bar, children echo with the same look at the same weight.
…abels

Previous fix (89f73b3) used a negative margin-right on the label
row to right-align the per-field `View as ▾` button with the
card-level dropdown above — but that left the field BODY (leaf cards,
nested rails, input rectangles) still indented inside
`formOuter.paddingRight: 20`. So the buttons aligned but the input
rectangles below them did NOT.

Simpler fix: drop the right padding entirely. Now every form-rendered
element — labels, View-as buttons, leaf input cards, nested rails —
shares the same right edge as the card content. One consistent
vertical line down the right side of every variable card.

Removed the labelRow `marginRight: -20` compensation it was paired
with — no longer needed once `formOuter.paddingRight` is 0.
Follow-up on `3731ac9d49` per Arda's DM with Kaosiso (2026-06-01):

> Kaosiso: "I see here that legacy app should still include Curly.
>           However when we select Mustache or jinja2 we no longer
>           see the Curly option. Is that the expected behavior?"
> Arda:    "untill their config change, was my read on this"
> Arda:    "does curly disappear before committing?"
> Kaosiso: "Yes"
> Arda:    "gotcha, shouldn't have happened"

Refined spec: the curly escape hatch should stay visible WHILE the
draft change is uncommitted, and drop once the user has actually
persisted the change. My first cut used `useRef` captured at mount,
which covered the draft-change case correctly — but it ALSO left
curly visible indefinitely after the commit, past the point Arda
expects.

Fix: track the previous `entityId` and re-capture the original format
whenever the id changes. A commit produces a new revision with a new
entityId; the ref resets to the just-persisted format, dropping the
legacy escape hatch as intended. Draft changes within the same
revision (which don't change entityId) leave the ref alone — that's
the escape-hatch window.

Verified `@agenta/entity-ui` tsc + prettier clean, 13/13
`templateFormatOptions.test.ts` still pass (the helper itself is
unchanged; the semantic refinement is in the caller).
…opdown

Kaosiso's screenshot of the prompt-template System message dropdown on
2026-06-01 showed:

  [Markdown]  <- highlighted/selected
  [Text]

Arda's directive: should be [Text, Markdown, ...] always. Audit and
fix every place where the order could flip.

Root cause: `@agenta/ui/drill-in/utils/getViewOptions.ts` had a "long
string" heuristic — when the string was >100 chars OR contained a
newline, the function returned `[Markdown, Text, ...jsonYaml]` instead
of the regular `[Text, Markdown, ...jsonYaml]`. The screenshot's
system message hit the heuristic (multi-line, definitely >100 chars).

Theory of the original heuristic: "long multi-line text is more likely
markdown content, so default to markdown view". Doesn't pan out — the
DEFAULT MODE for the chat-message editor is hardcoded to `"text"` in
`ChatMessageList.tsx:92`, so the option-order flip doesn't change the
initial view, only confuses the user when they open the dropdown
(option positions change based on what they typed).

Fix: drop the conditional. `getViewOptions` for any string returns
`[Text, Markdown, JSON, YAML]` regardless of length. The order is now
content-independent everywhere.

Audit of other text/markdown option lists across the packages:
  - @agenta/ui drill-in/utils/getViewOptions.ts: FIXED (this commit)
  - @agenta/ui drill-in/core/DrillInRootToolbar.tsx: already correct
  - @agenta/entity-ui view-types/viewTypes.ts (getViewOptions and
    getViewOptionsForExpectedType): already correct
  - @agenta/entity-ui view-types/ViewTypeSelect.tsx (labels map only,
    no ordering): N/A

Consumers of the fixed helper:
  - `ChatMessageList.tsx` (the screenshot's dropdown)
  - `JsonObjectField.tsx` (drill-in JSON object editor)

No tests touched — @agenta/ui has no vitest config; the helper is
small and the fix is straightforward. The behaviour is pinned by the
@agenta/entity-ui view-types tests for the parallel helper.

Inventory #4 marked fixed.
Follow-up on `1e6dfa92ee`. Per Arda: "no more 'long' string logic in
the playground variables / inputs". Tighten the docstring to spell
out that the rule applies to ALL content-based view-mode heuristics,
not just the option-order flip the previous comment described.

Audit of the remaining length-based logic across the inputs/variable
path turned up nothing else to remove:

  * `@agenta/entity-ui` `getViewOptions` and `getDefaultViewForValue`
    in `view-types/viewTypes.ts`: no length checks. Always Text-first
    for strings, default derived from the first option.
  * `@agenta/ui drill-in/core/DrillInRootToolbar.tsx`: fixed
    `[Text, Markdown, JSON, YAML]` constant. No conditional.
  * `disableLongText` / `LongTextNode` in `@agenta/ui/Editor/plugins/
    code/`: unrelated — those control truncation of long string
    literals INSIDE the JSON / YAML editor, not view-mode selection
    for inputs/variables. Kept as-is.
  * `ChatMessageList.tsx` `useState<ChatViewMode>("text")`: hardcoded
    Text default. No length bias.

No code change in this commit beyond comments; the heuristic itself
was removed in `1e6dfa92ee`.
…`{{|}}`

Mahmoud (Slack #release-v100, 2026-06-01):
> Variables keep closing automatically after the first letter.

Kaosiso (later in the same thread):
> When using Mustache prompt syntax, typing inside a variable
> placeholder causes the cursor to unexpectedly jump outside the
> curly braces after entering the first character.

Arda's repro after my earlier #7 regex fix was deployed:
> typing `{{` creates the token, autocloses in the editor `{{|}}`
> where `|` is the cursor position. then typing a single letter `a`
> results in `{{a}} |` a variable named "a" is created and cursor
> jumps out of the token automatically. this doesn't match the
> behavior we had before this branch.

Root cause: `TokenPlugin.$transformNode` always assumes the user
just CLOSED the token manually — every FULL_TOKEN_REGEX match
inserts a space after the new TokenNode and moves the cursor
to it (the `no-afterToken` branch) OR moves the cursor to the
afterNode (the `afterToken` branch). Both branches treat the
match as "user finished authoring this token, here's where to keep
typing past it".

That worked fine before `AutoCloseTokenBracesPlugin` existed —
typing `{{name}}` character-by-character, the FULL match only
fires when the user types the closing `}}`, so jumping the cursor
out matches their last action. Once auto-close turned `{{`
straight into `{{|}}` with the cursor between the brace pairs,
the very first character typed inside (`{{a}}`) ALSO triggers the
match — but now the user is mid-authoring, not done, so the
cursor-jump-out reads as "the editor stole my cursor".

Fix: capture the cursor offset BEFORE the transform. If it was
strictly inside the matched braces (between `{{` and `}}`, not
at the closing edge), the user is typing inside an auto-closed
token — place the cursor inside the new TokenNode at the same
relative offset. Existing manual-close behaviour (space + jump
out) is preserved for the case where the cursor was at the
closing edge or beyond. Empty `{{}}` token case keeps its
legacy "cursor inside at offset 2" treatment.

Traced through four scenarios:

  - Auto-close + type `a` (`{{a}}`, cursor=3 before transform):
    cursor stays inside TokenNode at offset 3 (between `a` and `}`).
  - Manual `{{a}}` (cursor=5 before transform): manual-close path,
    space inserted, cursor past it.
  - Empty auto-closed `{{}}` (cursor=2): empty-token branch, cursor
    at TokenNode offset 2 (legacy).
  - Token mid-text `hello {{a}}world` (cursor=9, between `a` and
    `}}`): cursor stays inside TokenNode at offset 3.

Inventory #2 and #8 both mark fixed — they were the same root
cause described from two angles.
After Arda's note that tracing works on main but not on our branch
(suspected interaction between PR #4469 and our merge), did a full
static-analysis pass on the FE tracing pipeline.

Findings:

* All tracing-related FE files are BYTE-IDENTICAL to main —
  `runnableSetup.ts` (references builder), the two tracing API
  endpoint files, `executionItems.ts` references-passing block,
  `store.ts` sourceRef setter.

* All commits from PR #4491 (`fix/broken-tracing-and-workflow-events`,
  the fix JP shipped after PR #4469 broke tracing) are reachable
  from branch HEAD — `b1b5b899df`, `83495a340a`, `601c9de468`,
  `69379a9f04`, `301f74f65f`, `92be0a87e2`.

* Our branch's actual diff vs main does NOT touch the trace
  pipeline, references building, sourceRef construction, or
  tracing endpoints. The diff is mustache token plugin,
  playground inputs body, visibility rule, template format
  picker, schema-aware seeding, view-type ordering, chat single-
  testcase gate, native-vs-stringified input transport.

Recorded three plausible hypotheses in the inventory:

1. Premise re-check — "main works" may need re-verification with
   the same repro Mahmoud used.
2. Indirect effect from native-input transport — the only
   semantically meaningful FE change that touches the request
   body. Backend trace-save might choke on nested-object inputs
   even though trace_id was already returned.
3. Environmental — staging state, build cache, env var. Not code.

Recommended next steps documented in inventory: re-verify main →
capture wire-format diff → hand off to JP if backend-side.
…rk locally

Confirmed via:

  * Local HEAD == origin HEAD (`396ecd5eda`).
  * `origin/main` (`d7c60c14e6`, frozen since 2026-06-01 10:21Z, i.e.
    6 minutes BEFORE Mahmoud's tracing report) is an ancestor of our
    HEAD.
  * `origin/feat/add-mustache-rendering` (`a12751604a`, Arda's main-
    merge at 15:08Z) is an ancestor of our HEAD via merge
    `8dd4da1870`.
  * Static analysis earlier (commit `396ecd5eda`) confirmed all FE
    tracing files (`runnableSetup.ts`, `tracing/api/index.ts`,
    `trace/api/api.ts`, executionItems `payloadRefs` block) are
    byte-identical to main.
  * Arda re-tested locally on the synced branch and traces work.

Net read: Mahmoud's #1 was a transient state at QA time, almost
certainly the staging deploy catching FE post-#4469 / backend
pre-#4491 (or the inverse) before JP's tracing fix had fully
propagated. The code on our branch IS the post-fix state; staging
behaves like local once redeployed.

All nine inventory items now marked fixed/resolved.
…yped scalar

Follow-up on `e26ca33a47` after Arda asked whether the merge handles
deep nesting and string→object transitions. My earlier statement was
WRONG: deep nesting (`{{country.x.y}}`) IS already reconstructed via
`buildEmptyShapeFromSchema`'s `_pathHints` branch and the existing
`buildShapeFromPathHints` helper. The test at
`view-types.test.ts:302-321` proves it.

The actual edge case my merge missed is shape CONFLICT: user typed
`x: "foo"` when the prompt was `{{country.x}}`, then changed the
prompt to `{{country.x.y}}` — the schema now expects `x: {y: ""}`
but the user's value carries `x: "foo"`. My merge kept the scalar
(no data loss) but left the user with no way to see/edit `y`.

This commit adds detection + a per-card banner:

* `VariableCard.seedShape` useMemo now also computes
  `shapeConflicts: ShapeConflict[]` — top-level keys where the
  schema expects an object/array but the value carries a scalar.

* When `shapeConflicts.length > 0` and the card is editable, an
  antd `Alert` (warning) renders above the form: "The prompt now
  expects nested fields at `x`. Adopting the new shape will discard
  your current scalar value." with a single "Use prompt shape"
  button.

* The button overwrites each conflicting key with the schema's
  expected nested skeleton, preserving every other key. The
  user's scalar is discarded for now (loss documented in
  comments) — slot-picker follow-up: when the new schema has a
  single sub-key we could drop the scalar there automatically;
  when multiple we'd surface a small picker so the user chooses.
  Punted because the simpler path ships the core UX without
  blocking on a richer migration flow.

* Opposite-direction conflicts (schema says scalar but value is
  object) are NOT flagged — the user's data is structurally
  richer than the schema knows, and overwriting would lose
  information. Left as-is.

`shapeConflicts` is only surfaced when the card is editable —
read-only surfaces don't need the migration affordance.

Verified `@agenta/playground-ui` tsc + prettier clean.
Follow-up to `3731ac9d49`/`909ba25129` (the picker escape hatch).
The original-format ref means a user who switched off curly mid-
session can still revert by re-picking curly from the dropdown.
But once they commit, the next revision starts on the new format
and curly disappears from the picker for good. Surfacing that
boundary explicitly was Arda's ask.

Adds a compact inline `Alert` (info-style) above the prompt
section's action bar. Visible ONLY when:

  1. The original (server-persisted) format for this revision is
     `curly` or `fstring` (the two legacy formats hidden from the
     picker for new prompts).
  2. The user's local pick differs from the original — i.e. they
     have an uncommitted switch in progress.
  3. The card is editable (`!disabled`).

Wording: "Switching from `curly` is permanent — once you commit,
you won't be able to switch back. Discard the draft to revert."

The banner disappears as soon as the user either reverts the
picker to `curly` (no longer differs from original) or commits
(new revision → ref re-syncs → conditions stop matching). So
it's only present in the actionable window where the warning
matters.

Verified `@agenta/entity-ui` tsc + prettier clean.
Follow-up on `fa88012d0b`. Placing the banner ABOVE the action bar
caused a layout shift each time it toggled visibility — picking
mustache made the banner appear and pushed every button (Message,
Tool, Output type, Format) downward; reverting to curly removed
the banner and the buttons jumped back up. That's mid-interaction
target-loss, exactly the wart Arda flagged on touching down on a
moving picker.

Move the banner to render BELOW the action bar instead. The
buttons stay anchored in place; the banner appears/disappears at
the bottom of the prompt section. Same conditions, same wording —
only the DOM order changed.
…el "unused" behaviour

Arda flagged on 2026-06-01: after deleting `{{obj.b}}` from the prompt
(keeping `{{obj.a}}` and `{{obj.c}}`), the `obj` variable card still
renders `b` as an active input field. The user's value carries
`{a, b: {y}, c}` from when they typed b.y earlier, and my previous
merge (`e26ca33a47` / `f59b702a13`) iterated VALUE keys with schema
ones spread on top — so anything the user had typed survived, even
when the schema no longer declared it.

This mirrors the top-level behaviour of "removed prompt variable's
testcase column moves to the unused-columns footer" — but does it at
one level deeper, inside the form view of an object variable.

Three changes inside `VariableCard.seedShape`:

  1. Flip the iteration: walk the SCHEMA's keys (not the value's).
     Schema-declared keys render; schema-missing keys don't.
  2. Track `unreferencedNested` — value-only keys (in value, not in
     schema). These are the prompt-removed sub-paths.
  3. New `handleValueChange` interceptor: when the user edits the
     rendered shape via FormView, restore the `unreferencedNested`
     keys from the original value before writing back. The testcase
     data KEEPS the dropped sub-keys silently, so re-adding
     `{{obj.b}}` to the prompt restores them without data loss.

Concrete scenarios after this fix:

  - Schema {a, c}, value {a, b: {y}, c}     → form shows {a, c}.
    Edits write `{a: <new>, c: <new>, b: <kept silently>}`.
  - Schema {a, c}, value {a, c}              → form shows {a, c}.
    No interceptor work, unreferencedNested empty.
  - Schema {a, b}, value {a}                 → form shows {a, b: ""}.
    Mahmoud's earlier missing-sub-field case still works.
  - Schema {a}, value {a, b: {y}, c}         → form shows {a}. Both
    `b` and `c` preserved silently.
  - Schema {x: {y: ""}}, value {x: "scalar"} → form shows {x: ""}
    (string field). `shapeConflicts` banner offers "Use prompt
    shape" to convert. Unchanged from `f59b702a13`.

`shapeConflicts` is computed the same way — when schema expects
nested but value carries a scalar at a key the schema declares.
Value-only keys (the new `unreferencedNested`) are NOT counted as
conflicts; they're filtered out cleanly.

Verified `@agenta/playground-ui` tsc + prettier clean.
… overlaps text

Arda screenshot 2026-06-01: the shape-conflict banner showed
"Adopting the new shape will discard your current scalar value" with
the "Use prompt shape" button visually wedged in the middle of the
text. Antd `Alert`'s `action` prop renders the action alongside the
message in a fixed right-side slot — when the message wraps to
multiple lines, the action stays anchored at the top-right and the
text reads as if it wraps under or around the button. Hard to parse.

Embed the button INSIDE the message slot with a `flex flex-wrap`
container. The button either sits inline at the end of the text (when
short) or wraps cleanly to its own line (when long), with no overlap.
The action prop is dropped — message is the sole content slot now.

Also drops the dead "shapeConflicts.length === 1 ? X : X" ternary
that emitted the same string in both branches (originally was meant
to differ by singular/plural; the plural variant landed only on the
trailing "value(s)" word so the ternary on the prefix was a no-op).

Verified `@agenta/playground-ui` tsc + prettier clean.
…n chrome

Arda screenshot 2026-06-01 spotted two issues on the banner:

1. The code chip for the conflicting key name (`a` / `b` / etc.)
   used `bg-[#fff7e6]` — the SAME pale-yellow as the warning
   Alert's background. The chip blended in completely, reading as
   plain inline text rather than a code reference.

2. The "Use prompt shape" button used `type="link"` which renders
   without button chrome — looked like a third paragraph of warning
   text, not an interactive action.

Fixes:

  * Code chip: white background with a thin amber border
    (`border-[#FFD591]`) and amber text (`text-[#874D00]`) so it
    contrasts cleanly against the cream-yellow Alert background
    while still reading as amber-themed in the warning context.
  * Button: drop `type="link"` → default antd Button (white bg,
    grey border, the standard "secondary" look). `size="small"`
    keeps it compact; `shrink-0` prevents text wrap inside the
    button itself.

Verified `@agenta/playground-ui` tsc + prettier clean.
…appear

Regression spotted by Arda 2026-06-01: prompt had `{{obj.a.y}}`, user
renamed it to `{{obj.a.t}}`, and the inputs card still rendered `obj.a.y`
in form view (with a stale empty input slot for the old key).

The previous schema-strict seed-shape logic only iterated the SCHEMA's
keys at the TOP level. When it found a top-level key in both schema
and value, it copied the value verbatim — never descending. For
`{obj: {a: {y}}}` vs schema `{obj: {a: {t}}}` that meant `merged.obj =
valueRec.obj` → `{a: {y}}` survived intact, and FormView faithfully
rendered the stale `y`.

Fix — recursion at every depth:

  * `buildSchemaStrictShape(skel, val, pathPrefix)` — recursive helper.
    At each level, walks the schema's keys (not the value's), descends
    into nested objects, and reports structural conflicts with dotted
    paths (`obj.a` when the user has a scalar where the schema now
    expects an object).

  * `mergeEditWithStash(edit, original, skel)` — write-back inverse.
    Walks `original` alongside `edit` against the same schema skeleton
    and preserves any value-only keys at every depth. The user only
    sees / edits schema keys, but the underlying testcase value keeps
    the stash so re-adding `{{obj.<path>}}` to the prompt later
    restores the data without loss.

  * `setAtPath(obj, dottedPath, value)` — used by the "Use prompt
    shape" affordance to overwrite nested conflicts (`obj.a`, not just
    top-level `a`).

  * Removed the separate `unreferencedNested` tracking from the memo
    — recovery is inline in `mergeEditWithStash` now, so a single
    recursive walk handles both the render filter and the stash
    preservation. Banner chips now display dotted paths for nested
    conflicts.

Behavior matrix (all verified mentally against the new code):

  | Scenario                                  | Form render        | Underlying value after edit |
  |-------------------------------------------|--------------------|------------------------------|
  | rename `obj.a.y` → `obj.a.t`              | shows only `t`     | both `y` (stashed) + `t`    |
  | remove `{{obj.b}}` from prompt            | `obj.b` hidden     | `b` value preserved          |
  | add `{{obj.a.z}}` (new sub-key)           | `z` appears empty  | edits merge into `obj.a`    |
  | conflict: scalar at `obj.a`, schema wants | banner shows       | adoption overwrites with    |
  | `{obj.a.t}`                               | `obj.a` chip        | expected nested shape       |

Verified `@agenta/playground-ui` tsc + prettier clean.
…disclosure

The previous commit makes the form view drop nested keys the prompt no
longer references (e.g. `obj.a.y` after a rename to `obj.a.t`) while
silently preserving them in the underlying testcase value. The user
asked for symmetry with the top-level `UnreferencedColumnsFooter` so
that the stash is discoverable instead of invisible.

New `StashedPathsFooter` inside `VariableCard`:

  * Collapsed-by-default disclosure rendered below the body, inside the
    card's border. Summary matches the top-level wording:
    "N unused nested keys hidden because the prompt does not reference
    them." Click to expand → flat list of `<variableName>.<dotted-path>`
    entries with a compact preview of each stashed value (primitives
    raw, objects JSON.stringify'd and truncated at 80 chars).

  * Re-mounts when the stashed-path set changes (`key={paths.join("|")}`
    from parent). Same anti-leak as the top-level footer — adding or
    removing a stash entry collapses the disclosure rather than letting
    a freshly-stashed value silently appear inside an already-expanded
    view.

  * Read-only references. The user "restores" a stashed key by
    re-adding `{{variableName.<path>}}` to the prompt — the value comes
    back automatically through `mergeEditWithStash`.

`buildSchemaStrictShape` was extended to collect a flat list of
`StashedPath { path, value }` entries as it walks. Stash entries from
deeper recursion are pushed BEFORE the current level's, so the list
groups related branches naturally (e.g. `obj.a.y`, `obj.a.z`, then
`obj.b`).

No change to write-back semantics — `mergeEditWithStash` already
preserved stashed keys at every depth. This commit just makes the
stash visible.

Verified `@agenta/playground-ui` tsc + prettier clean.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request Frontend size:XXL This PR changes 1000+ lines, ignoring generated files.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants