From dc771adc7e47266d76ecd1289aa03645d3b2824f Mon Sep 17 00:00:00 2001 From: Brian Love Date: Sat, 9 May 2026 21:28:11 -0700 Subject: [PATCH] fix(render,chat): A2UI datamodel back-propagation (Finding K) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A2UI input components (TextField, MultipleChoice, CheckBox, Slider, DateTimeInput) emit `a2ui:datamodel::` strings via their `emit` callback when the user changes value. No consumer existed — neither libs/render's `emitFn` nor libs/chat's handled the magic-string format — so user input went nowhere. Forms rendered but were inert: validation never saw input, Submit stayed disabled, binding-dependent UI never updated. Two coordinated fixes: 1. **`libs/render/src/lib/render-element.component.ts`** — `emitFn` now intercepts `a2ui:datamodel::` strings before delegating to `el.on[event]` handlers. Splits on the LAST colon (path may itself contain colons per RFC 6901; values certainly can — URLs, time strings, JSON arrays). Coerces the raw string back to typed values: numeric (`/^-?\d+(?:\.\d+)?$/`), boolean (`true`/`false`), JSON arrays (passthrough for MultipleChoice's stringified arrays), else string. Writes via the render context's state store, which triggers re-render with the new resolved value. 2. **`libs/chat/src/lib/a2ui/surface-to-spec.ts`** — Path-bound props were eagerly resolved at conversion time, so catalog components received a static literal snapshot that never changed even after datamodel writes hit the store. Now leaves path refs as `{$bindState: '/path'}` markers — json-render's prop resolution reads the live state store on every render, so user writes propagate immediately. Literal DynamicValues (`{literalString:...}`, etc.) still resolve eagerly. The `_bindings` prop continues to map prop name → path so catalog components emit the magic string with the correct path on user input. Verified live: feedback form's Name TextField receives focus, accepts input, the catalog component's `text()` input updates reactively from $bindState resolution against the json-render store. End-to-end data flow (user types → emit → store.set → $bindState re-resolves → component re-renders with new value) works. Two surface-to-spec tests updated to assert $bindState markers instead of pre-resolved literals. Out of scope (future): syncing the json-render store back into `A2uiSurfaceStore.surface.dataModel` so cross-render persistence (thread reload, view in chat-debug) reflects user input. For Phase 5 the in-form interactivity is the user-visible win; the round-trip (Submit → action message → re-submit) already captures values from the live store via `buildA2uiActionMessage`. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../chat/src/lib/a2ui/surface-to-spec.spec.ts | 18 ++++- libs/chat/src/lib/a2ui/surface-to-spec.ts | 18 ++++- .../src/lib/render-element.component.ts | 68 ++++++++++++++++++- 3 files changed, 98 insertions(+), 6 deletions(-) diff --git a/libs/chat/src/lib/a2ui/surface-to-spec.spec.ts b/libs/chat/src/lib/a2ui/surface-to-spec.spec.ts index d0ee9a871..ca544ba5b 100644 --- a/libs/chat/src/lib/a2ui/surface-to-spec.spec.ts +++ b/libs/chat/src/lib/a2ui/surface-to-spec.spec.ts @@ -26,13 +26,21 @@ describe('surfaceToSpec (v1)', () => { expect(spec.elements['root'].props['text']).toBe('Hi'); }); - it('resolves DynamicString path prop against dataModel', () => { + it('leaves DynamicString path prop as $bindState marker for json-render', () => { + // Path refs preserve their dynamic resolution: surface-to-spec emits + // a `$bindState` marker so json-render reads the current value from + // its state store on every render. This is what enables user input + // (TextField, MultipleChoice, etc.) to write back through the + // a2ui:datamodel:: emit protocol and have the UI + // reflect those writes immediately. const surface = makeSurface( [{ id: 'root', component: { Text: { text: { path: '/greeting' } } } }], { greeting: 'Hello World' }, ); const spec = surfaceToSpec(surface)!; - expect(spec.elements['root'].props['text']).toBe('Hello World'); + expect(spec.elements['root'].props['text']).toEqual({ $bindState: '/greeting' }); + // Spec.state seeds the json-render store with the initial value. + expect(spec.state).toEqual({ greeting: 'Hello World' }); }); it('returns null when surface has no components', () => { @@ -175,7 +183,11 @@ describe('surfaceToSpec (v1)', () => { { name: 'Alice' }, ); const spec = surfaceToSpec(surface)!; - expect(spec.elements['root'].props['text']).toBe('Alice'); + // Path refs become $bindState markers (see "leaves DynamicString + // path prop" test above). _bindings still maps prop name → path so + // catalog components emit a2ui:datamodel:: on user + // input. + expect(spec.elements['root'].props['text']).toEqual({ $bindState: '/name' }); expect(spec.elements['root'].props['_bindings']).toEqual({ text: '/name' }); }); diff --git a/libs/chat/src/lib/a2ui/surface-to-spec.ts b/libs/chat/src/lib/a2ui/surface-to-spec.ts index e3d869ca0..1e3262443 100644 --- a/libs/chat/src/lib/a2ui/surface-to-spec.ts +++ b/libs/chat/src/lib/a2ui/surface-to-spec.ts @@ -76,8 +76,22 @@ export function surfaceToSpec(surface: A2uiSurface): Spec | null { for (const [key, value] of Object.entries(rawProps)) { if (RESERVED_PROP_KEYS.has(key)) continue; - if (isPathRef(value)) bindings[key] = (value as { path: string }).path; - resolvedProps[key] = resolveDynamic(value, surface.dataModel); + if (isPathRef(value)) { + // Leave path refs as json-render two-way binding markers so the + // render lib resolves them against its state store on every + // render. Without this, the catalog component receives a static + // snapshot taken at conversion time and never reflects user + // input writes back into the store. The `_bindings` map below + // tells the catalog component which prop names map to which + // paths so its emit() callback can write back via the + // a2ui:datamodel:: magic-string protocol that + // render-element's emitFn intercepts. + const path = (value as { path: string }).path; + bindings[key] = path; + resolvedProps[key] = { $bindState: path }; + } else { + resolvedProps[key] = resolveDynamic(value, surface.dataModel); + } } if (Object.keys(bindings).length > 0) { resolvedProps['_bindings'] = bindings; diff --git a/libs/render/src/lib/render-element.component.ts b/libs/render/src/lib/render-element.component.ts index 3f9ce5355..1c94c9573 100644 --- a/libs/render/src/lib/render-element.component.ts +++ b/libs/render/src/lib/render-element.component.ts @@ -25,6 +25,33 @@ import type { RepeatScope } from './contexts/repeat-scope'; import { buildPropResolutionContext } from './internals/prop-signal'; import type { AngularComponentRenderer } from './render.types'; +/** Magic prefix on `emit()` strings that catalog components use to + * write back to the data model (binding `path` and the new value). The + * render-element's emitFn intercepts this and writes via the state + * store, sidestepping the normal `el.on[event]` handler binding which + * the catalog components have no way to declare for arbitrary paths. */ +const A2UI_DATAMODEL_PREFIX = 'a2ui:datamodel:'; + +/** Best-effort string→typed coercion for datamodel writes. Catalog + * components emit raw string values; the underlying state may have + * been declared as number/boolean/array, and consumers reading the + * resolved props expect the correct type. */ +function coerceValue(raw: string): unknown { + if (raw === '') return ''; + if (raw === 'true') return true; + if (raw === 'false') return false; + // JSON-array passthrough (MultipleChoice emits stringified arrays) + if (raw.startsWith('[') && raw.endsWith(']')) { + try { return JSON.parse(raw); } catch { /* fall through */ } + } + // Numeric — only if the entire string parses cleanly as a number + if (/^-?\d+(?:\.\d+)?$/.test(raw)) { + const n = Number(raw); + if (!Number.isNaN(n)) return n; + } + return raw; +} + /** * Recursive element renderer. * @@ -126,8 +153,28 @@ export class RenderElementComponent implements OnInit { return evaluateVisibility(el.visible, this.propCtx()); }); - /** Emit function that delegates to context handlers. */ + /** Emit function that delegates to context handlers AND handles the + * canonical `a2ui:datamodel::` write-back protocol that + * input components (TextField, MultipleChoice, CheckBox, Slider, + * DateTimeInput) emit when the user changes their value. The render + * lib's state store is the single source of truth for in-surface UI + * state; writing through it triggers re-render with the new value + * and re-evaluates any path-bound props (validation, computed + * visibility, etc.). + * + * The string format is `a2ui:datamodel::` where: + * - `` is a JSON-Pointer-style path (e.g. `/name`, `/form/email`) + * - `` is the raw value rendered as a string. We attempt to + * coerce numeric and boolean literals back to their typed form + * so downstream consumers see correct types; arrays come through + * as JSON-stringified payloads (catalog components emit them via + * `JSON.stringify`). + */ private readonly emitFn = (event: string) => { + if (event.startsWith(A2UI_DATAMODEL_PREFIX)) { + this.applyDatamodelWrite(event); + return; + } const el = this.element(); if (!el?.on) return; const binding = el.on[event]; @@ -143,6 +190,25 @@ export class RenderElementComponent implements OnInit { } }; + private applyDatamodelWrite(event: string): void { + // Strip the prefix, then split path and value at the last `:` — + // path may itself contain `:` characters (rare but legal in + // JSON-Pointer per RFC 6901), and values can certainly contain + // them (URLs, time strings). Catalog components emit + // `a2ui:datamodel::` where path is the binding's + // path-ref (usually starts with `/`); split the LAST `:` because + // the value is the only field guaranteed to come last. + const rest = event.slice(A2UI_DATAMODEL_PREFIX.length); + const lastColon = rest.lastIndexOf(':'); + if (lastColon === -1) return; + const path = rest.slice(0, lastColon); + const rawValue = rest.slice(lastColon + 1); + if (!path) return; + const store = this.ctx.store; + if (!store) return; + store.set(path, coerceValue(rawValue)); + } + /** Resolved inputs for non-repeat elements. */ readonly resolvedInputs = computed(() => { const el = this.element();