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();