From e5aef4002a8bcddcc1187cf495d50f3c75d7918c Mon Sep 17 00:00:00 2001 From: Brian Love Date: Thu, 9 Apr 2026 10:04:34 -0700 Subject: [PATCH 1/8] docs: add A2UI v0.9 Phase 2 design spec --- .../specs/2026-04-09-a2ui-phase2-design.md | 260 ++++++++++++++++++ 1 file changed, 260 insertions(+) create mode 100644 docs/superpowers/specs/2026-04-09-a2ui-phase2-design.md diff --git a/docs/superpowers/specs/2026-04-09-a2ui-phase2-design.md b/docs/superpowers/specs/2026-04-09-a2ui-phase2-design.md new file mode 100644 index 000000000..8821386b3 --- /dev/null +++ b/docs/superpowers/specs/2026-04-09-a2ui-phase2-design.md @@ -0,0 +1,260 @@ +# A2UI v0.9 Phase 2 — Design Spec + +**Date:** 2026-04-09 +**Status:** Draft + +## Goal + +Make A2UI surfaces interactive: actions on buttons dispatch events to the agent, input components write back to the data model, collection templates expand over arrays, and registered functions resolve in data bindings. + +## Scope + +### In Scope + +1. **Action system** — Server event dispatch from buttons, local function execution (`openUrl`) +2. **Two-way data binding** — TextField, CheckBox, ChoicePicker update the surface data model on user input +3. **Template expansion** — `children: { path, componentId }` expands over data model arrays with scoped bindings +4. **Function execution** — Implement `formatString`, `formatNumber`, `formatCurrency`, `formatDate`, `pluralize`, `openUrl`, `and`, `or`, `not` in the resolve layer +5. **Validation** — `checks` array on inputs/buttons, disable button when checks fail + +### Out of Scope (Phase 3) + +- Tabs, Modal, Video, AudioPlayer, DateTimeInput, Slider components +- Custom catalog definitions (`inlineCatalogs`) +- Multi-agent theme attribution +- `sendDataModel` metadata in A2A transport +- Error recovery for malformed A2UI messages + +--- + +## Part 1: Action System + +### Server Actions + +When a button has `action.event`, clicking it emits an event that the agent can receive. The `A2uiSurfaceComponent` accepts an `actionHandler` output: + +```ts +readonly actionDispatched = output(); + +interface A2uiActionEvent { + name: string; + surfaceId: string; + sourceComponentId: string; + timestamp: string; + context?: Record; +} +``` + +The ChatComponent wires this to the agent ref's submit mechanism, sending the event as a message payload. The exact transport binding depends on how the LangGraph backend handles A2UI actions (likely as a tool call response or interrupt response). + +### Local Actions + +When a button has `action.functionCall`, clicking it executes a registered client-side function. For Phase 2, `openUrl` is the only local action — it calls `window.open()`. + +### Button Component Changes + +Replace the read-only button with an interactive one: + +```ts +@Component({ + template: ` + + `, +}) +export class A2uiButtonComponent { + readonly action = input(undefined); + readonly emit = input.required<(event: string) => void>(); + // ... + handleClick() { + const act = this.action(); + if (!act) return; + if ('event' in act) { + this.emit()(`a2ui:action:${act.event.name}`); + } else if ('functionCall' in act) { + executeLocalAction(act.functionCall); + } + } +} +``` + +--- + +## Part 2: Two-Way Data Binding + +Input components (TextField, CheckBox, ChoicePicker) establish a read/write contract with the data model: + +- **Read**: Component displays value from its bound data path +- **Write**: User input immediately updates the local surface data model + +### Binding Resolution + +When `surfaceToSpec` encounters a prop that's a `{ path }` reference on an input component, it: +1. Resolves the current value from the data model (for display) +2. Passes the binding path as a `bindings` prop so the component can write back + +### Data Model Update Flow + +``` +User types in TextField + → Component emits value change + → SurfaceStore.updateDataModel({ surfaceId, path, value }) + → Surface signal updates + → All components re-resolve their bindings +``` + +The `A2uiSurfaceComponent` needs a reference to the store to enable write-back: + +```ts +readonly store = input(undefined); +``` + +### Input Component Changes + +TextField becomes: +```ts + + +onInput(event: Event) { + const val = (event.target as HTMLInputElement).value; + this.bindings()?.['value'] && this.updateDataModel(this.bindings()['value'], val); +} +``` + +CheckBox becomes: +```ts + +``` + +--- + +## Part 3: Template Expansion (Collections) + +When `children` is `{ path: "/employees", componentId: "emp_card" }`: + +1. Read the array at `/employees` from the data model +2. For each item at index N, create a scope: `{ basePath: '/employees/N', item }` +3. Render the template component (`emp_card`) once per item +4. Relative paths inside the template resolve against the item's scope + +### Implementation in surfaceToSpec + +The `surfaceToSpec` function currently skips template children. Add: + +```ts +if (typeof comp.children === 'object' && !Array.isArray(comp.children)) { + const template = comp.children as A2uiChildTemplate; + const arr = getByPointer(surface.dataModel, template.path); + if (Array.isArray(arr)) { + children = arr.map((_, i) => `${template.componentId}__${i}`); + // Generate cloned components for each item with scoped bindings + for (let i = 0; i < arr.length; i++) { + const scope = { basePath: `${template.path}/${i}`, item: arr[i] }; + const templateComp = surface.components.get(template.componentId); + if (templateComp) { + // Clone and resolve with scope + elements[`${template.componentId}__${i}`] = resolveComponentWithScope(templateComp, surface.dataModel, scope); + } + } + } +} +``` + +--- + +## Part 4: Function Execution + +Replace the `[functionName]` stub in `resolveDynamic` with actual implementations. + +### Registered Functions + +| Function | Args | Returns | +|----------|------|---------| +| `formatString` | `template: string` | Interpolated string | +| `formatNumber` | `value: number, grouping?: boolean, precision?: number` | Formatted number string | +| `formatCurrency` | `value: number, currency: string, locale?: string` | Currency string | +| `formatDate` | `value: string, format: string` | Formatted date string | +| `pluralize` | `count: number, singular: string, plural: string` | Chosen form | +| `openUrl` | `url: string` | void (side effect) | +| `and` | `...conditions: boolean[]` | boolean | +| `or` | `...conditions: boolean[]` | boolean | +| `not` | `value: boolean` | boolean | + +### Implementation + +```ts +const FUNCTIONS: Record, model: Record) => unknown> = { + formatString: (args) => interpolateTemplate(String(args['template'] ?? ''), model), + formatNumber: (args) => { + const n = Number(args['value']); + const precision = Number(args['precision'] ?? 0); + return args['grouping'] ? n.toLocaleString(undefined, { minimumFractionDigits: precision }) : n.toFixed(precision); + }, + formatCurrency: (args) => Number(args['value']).toLocaleString(String(args['locale'] ?? 'en-US'), { style: 'currency', currency: String(args['currency']) }), + formatDate: (args) => new Date(String(args['value'])).toLocaleDateString(), + pluralize: (args) => Number(args['count']) === 1 ? String(args['singular']) : String(args['plural']), + openUrl: (args) => { if (typeof window !== 'undefined') window.open(String(args['url']), '_blank'); }, + and: (args) => Object.values(args).every(Boolean), + or: (args) => Object.values(args).some(Boolean), + not: (args) => !args['value'], +}; +``` + +In `resolveDynamic`, replace the stub: + +```ts +if (isFunctionCall(value)) { + const fn = FUNCTIONS[(value as A2uiFunctionCall).call]; + if (!fn) return `[${(value as A2uiFunctionCall).call}]`; + const resolvedArgs = resolveArgs((value as A2uiFunctionCall).args, model, scope); + return fn(resolvedArgs, model); +} +``` + +--- + +## Part 5: Validation + +### Input Validation + +Input components with `checks` run validation on value changes: + +```ts +interface A2uiValidationResult { + valid: boolean; + errors: string[]; +} + +function validateChecks(checks: A2uiCheck[], model: Record): A2uiValidationResult; +``` + +### Button Validation + +Buttons with `checks` are disabled when any check fails. The button resolves its checks against the current data model and disables itself accordingly. + +### Validation Functions + +Implement `required`, `regex`, `length`, `numeric`, `email` as check executors: + +```ts +const VALIDATORS: Record) => boolean> = { + required: (args) => args['value'] != null && String(args['value']).trim() !== '', + regex: (args) => new RegExp(String(args['pattern'])).test(String(args['value'])), + length: (args) => { const len = String(args['value'] ?? '').length; return len >= Number(args['min'] ?? 0) && len <= Number(args['max'] ?? Infinity); }, + numeric: (args) => { const n = Number(args['value']); return n >= Number(args['min'] ?? -Infinity) && n <= Number(args['max'] ?? Infinity); }, + email: (args) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(String(args['value'])), +}; +``` + +--- + +## Deliverables + +| # | Deliverable | Package | Description | +|---|------------|---------|-------------| +| 1 | Function execution | `@cacheplane/a2ui` | Replace stubs with 10 registered functions | +| 2 | Validation | `@cacheplane/a2ui` | 5 validation functions + check executor | +| 3 | Template expansion | `@cacheplane/chat` | Collection children with scoped bindings in surfaceToSpec | +| 4 | Two-way data binding | `@cacheplane/chat` | Input components write to surface data model | +| 5 | Action system | `@cacheplane/chat` | Button event dispatch + openUrl local action | From d01ddd1186ef0e4952dd79e37db67360778b5428 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Thu, 9 Apr 2026 10:06:30 -0700 Subject: [PATCH 2/8] docs: add A2UI v0.9 Phase 2 spec and implementation plan --- .../plans/2026-04-09-a2ui-phase2.md | 689 ++++++++++++++++++ 1 file changed, 689 insertions(+) create mode 100644 docs/superpowers/plans/2026-04-09-a2ui-phase2.md diff --git a/docs/superpowers/plans/2026-04-09-a2ui-phase2.md b/docs/superpowers/plans/2026-04-09-a2ui-phase2.md new file mode 100644 index 000000000..3e9dd5aaf --- /dev/null +++ b/docs/superpowers/plans/2026-04-09-a2ui-phase2.md @@ -0,0 +1,689 @@ +# A2UI v0.9 Phase 2 — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Make A2UI surfaces interactive — function execution, validation, template expansion, two-way data binding, and button actions. + +**Architecture:** Functions and validation go in `@cacheplane/a2ui` (framework-agnostic). Template expansion updates `surfaceToSpec` in `@cacheplane/chat`. Two-way binding adds a data model update callback through the component tree. Button actions dispatch events via an output on `A2uiSurfaceComponent`. + +**Tech Stack:** Angular 20+ signals, Vitest, TypeScript + +--- + +## File Structure + +### Modified in `libs/a2ui/` + +| File | Change | +|------|--------| +| `src/lib/resolve.ts` | Replace function call stub with real execution | +| `src/lib/resolve.spec.ts` | Add function execution tests | +| `src/index.ts` | Export new functions | + +### New in `libs/a2ui/` + +| File | Responsibility | +|------|---------------| +| `src/lib/functions.ts` | Registered function implementations | +| `src/lib/functions.spec.ts` | Function tests | +| `src/lib/validate.ts` | Validation check executor | +| `src/lib/validate.spec.ts` | Validation tests | + +### Modified in `libs/chat/` + +| File | Change | +|------|--------| +| `src/lib/a2ui/surface.component.ts` | Template expansion + action output + store input | +| `src/lib/a2ui/surface.component.spec.ts` | Template expansion + action tests | +| `src/lib/a2ui/catalog/button.component.ts` | Action dispatch on click | +| `src/lib/a2ui/catalog/text-field.component.ts` | Two-way data binding | +| `src/lib/a2ui/catalog/check-box.component.ts` | Two-way data binding | +| `src/lib/a2ui/catalog/choice-picker.component.ts` | Two-way data binding | +| `src/lib/compositions/chat/chat.component.ts` | Wire action output | + +--- + +### Task 1: Registered Functions + +**Files:** +- Create: `libs/a2ui/src/lib/functions.ts` +- Create: `libs/a2ui/src/lib/functions.spec.ts` +- Modify: `libs/a2ui/src/lib/resolve.ts` +- Modify: `libs/a2ui/src/lib/resolve.spec.ts` +- Modify: `libs/a2ui/src/index.ts` + +- [ ] **Step 1: Write failing tests for functions** + +Create `libs/a2ui/src/lib/functions.spec.ts`: + +```ts +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +import { describe, it, expect } from 'vitest'; +import { executeFunction } from './functions'; + +const model = { name: 'Alice', price: 1234.5, date: '2026-04-09', count: 1 }; + +describe('executeFunction', () => { + it('formatNumber with grouping', () => { + expect(executeFunction('formatNumber', { value: 1234.5, grouping: true }, model)).toMatch(/1.234/); + }); + + it('formatNumber with precision', () => { + expect(executeFunction('formatNumber', { value: 1234.5, precision: 2 }, model)).toBe('1234.50'); + }); + + it('pluralize singular', () => { + expect(executeFunction('pluralize', { count: 1, singular: 'item', plural: 'items' }, model)).toBe('item'); + }); + + it('pluralize plural', () => { + expect(executeFunction('pluralize', { count: 3, singular: 'item', plural: 'items' }, model)).toBe('items'); + }); + + it('and returns true when all truthy', () => { + expect(executeFunction('and', { a: true, b: true }, model)).toBe(true); + }); + + it('and returns false when any falsy', () => { + expect(executeFunction('and', { a: true, b: false }, model)).toBe(false); + }); + + it('or returns true when any truthy', () => { + expect(executeFunction('or', { a: false, b: true }, model)).toBe(true); + }); + + it('not inverts boolean', () => { + expect(executeFunction('not', { value: true }, model)).toBe(false); + }); + + it('returns null for unknown function', () => { + expect(executeFunction('unknownFn', {}, model)).toBeNull(); + }); +}); +``` + +- [ ] **Step 2: Implement functions** + +Create `libs/a2ui/src/lib/functions.ts`: + +```ts +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 + +type FnExecutor = (args: Record, model: Record) => unknown; + +const FUNCTIONS: Record = { + formatNumber: (args) => { + const n = Number(args['value']); + const precision = Number(args['precision'] ?? 0); + if (args['grouping']) { + return n.toLocaleString(undefined, { minimumFractionDigits: precision, maximumFractionDigits: precision }); + } + return n.toFixed(precision); + }, + formatCurrency: (args) => { + return Number(args['value']).toLocaleString( + String(args['locale'] ?? 'en-US'), + { style: 'currency', currency: String(args['currency'] ?? 'USD') }, + ); + }, + formatDate: (args) => { + return new Date(String(args['value'])).toLocaleDateString( + String(args['locale'] ?? undefined), + ); + }, + pluralize: (args) => { + return Number(args['count']) === 1 ? String(args['singular']) : String(args['plural']); + }, + openUrl: (args) => { + if (typeof globalThis.window !== 'undefined') { + globalThis.window.open(String(args['url']), '_blank'); + } + return null; + }, + and: (args) => Object.values(args).every(Boolean), + or: (args) => Object.values(args).some(Boolean), + not: (args) => !args['value'], +}; + +export function executeFunction( + name: string, + args: Record, + model: Record, +): unknown { + const fn = FUNCTIONS[name]; + if (!fn) return null; + return fn(args, model); +} +``` + +- [ ] **Step 3: Wire into resolveDynamic** + +In `libs/a2ui/src/lib/resolve.ts`, add import: + +```ts +import { executeFunction } from './functions'; +``` + +Replace the function call stub (lines 53-55): + +```ts + // Function call — execute registered function + if (isFunctionCall(value)) { + const fc = value as A2uiFunctionCall; + // Resolve args that may themselves be dynamic + const resolvedArgs: Record = {}; + for (const [k, v] of Object.entries(fc.args)) { + resolvedArgs[k] = resolveDynamic(v, model, scope); + } + const result = executeFunction(fc.call, resolvedArgs, model); + return result ?? `[${fc.call}]`; + } +``` + +- [ ] **Step 4: Update resolve tests** + +In `libs/a2ui/src/lib/resolve.spec.ts`, replace the Phase 2 stub test: + +```ts + it('executes function calls', () => { + const fn = { call: 'pluralize', args: { count: 1, singular: 'item', plural: 'items' } }; + expect(resolveDynamic(fn, model)).toBe('item'); + }); + + it('resolves dynamic args in function calls', () => { + const fn = { call: 'pluralize', args: { count: { path: '/user/age' }, singular: 'year', plural: 'years' } }; + expect(resolveDynamic(fn, model)).toBe('years'); + }); + + it('falls back to [name] for unknown functions', () => { + const fn = { call: 'unknownFn', args: {} }; + expect(resolveDynamic(fn, model)).toBe('[unknownFn]'); + }); +``` + +- [ ] **Step 5: Update barrel** + +Add to `libs/a2ui/src/index.ts`: +```ts +export { executeFunction } from './lib/functions'; +``` + +- [ ] **Step 6: Verify and commit** + +Run: `npx nx test a2ui` +Commit: `git commit -m "feat(a2ui): implement registered functions (formatNumber, pluralize, and/or/not, openUrl)"` + +--- + +### Task 2: Validation + +**Files:** +- Create: `libs/a2ui/src/lib/validate.ts` +- Create: `libs/a2ui/src/lib/validate.spec.ts` +- Modify: `libs/a2ui/src/index.ts` + +- [ ] **Step 1: Write failing tests** + +Create `libs/a2ui/src/lib/validate.spec.ts`: + +```ts +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +import { describe, it, expect } from 'vitest'; +import { validateChecks } from './validate'; +import type { A2uiCheck } from './types'; + +describe('validateChecks', () => { + it('required passes for non-empty value', () => { + const checks: A2uiCheck[] = [{ call: 'required', args: { value: 'hello' }, message: 'Required' }]; + expect(validateChecks(checks)).toEqual({ valid: true, errors: [] }); + }); + + it('required fails for empty string', () => { + const checks: A2uiCheck[] = [{ call: 'required', args: { value: '' }, message: 'Required' }]; + expect(validateChecks(checks)).toEqual({ valid: false, errors: ['Required'] }); + }); + + it('required fails for null', () => { + const checks: A2uiCheck[] = [{ call: 'required', args: { value: null }, message: 'Required' }]; + expect(validateChecks(checks)).toEqual({ valid: false, errors: ['Required'] }); + }); + + it('regex passes for matching pattern', () => { + const checks: A2uiCheck[] = [{ call: 'regex', args: { value: 'abc123', pattern: '^[a-z]+\\d+$' }, message: 'Bad format' }]; + expect(validateChecks(checks)).toEqual({ valid: true, errors: [] }); + }); + + it('regex fails for non-matching', () => { + const checks: A2uiCheck[] = [{ call: 'regex', args: { value: '!!!', pattern: '^\\w+$' }, message: 'Bad' }]; + expect(validateChecks(checks).valid).toBe(false); + }); + + it('length passes within range', () => { + const checks: A2uiCheck[] = [{ call: 'length', args: { value: 'hello', min: 3, max: 10 }, message: 'Length' }]; + expect(validateChecks(checks).valid).toBe(true); + }); + + it('length fails below min', () => { + const checks: A2uiCheck[] = [{ call: 'length', args: { value: 'hi', min: 3 }, message: 'Too short' }]; + expect(validateChecks(checks).valid).toBe(false); + }); + + it('numeric passes in range', () => { + const checks: A2uiCheck[] = [{ call: 'numeric', args: { value: 5, min: 0, max: 10 }, message: 'Range' }]; + expect(validateChecks(checks).valid).toBe(true); + }); + + it('email passes for valid email', () => { + const checks: A2uiCheck[] = [{ call: 'email', args: { value: 'a@b.com' }, message: 'Email' }]; + expect(validateChecks(checks).valid).toBe(true); + }); + + it('email fails for invalid', () => { + const checks: A2uiCheck[] = [{ call: 'email', args: { value: 'not-email' }, message: 'Email' }]; + expect(validateChecks(checks).valid).toBe(false); + }); + + it('collects multiple errors', () => { + const checks: A2uiCheck[] = [ + { call: 'required', args: { value: '' }, message: 'Required' }, + { call: 'email', args: { value: '' }, message: 'Email' }, + ]; + const result = validateChecks(checks); + expect(result.valid).toBe(false); + expect(result.errors).toHaveLength(2); + }); + + it('ignores unknown check functions', () => { + const checks: A2uiCheck[] = [{ call: 'unknownCheck', args: {}, message: 'Unknown' }]; + expect(validateChecks(checks).valid).toBe(true); + }); +}); +``` + +- [ ] **Step 2: Implement** + +Create `libs/a2ui/src/lib/validate.ts`: + +```ts +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +import type { A2uiCheck } from './types'; + +export interface A2uiValidationResult { + valid: boolean; + errors: string[]; +} + +type Validator = (args: Record) => boolean; + +const VALIDATORS: Record = { + required: (args) => args['value'] != null && String(args['value']).trim() !== '', + regex: (args) => new RegExp(String(args['pattern'])).test(String(args['value'] ?? '')), + length: (args) => { + const len = String(args['value'] ?? '').length; + const min = Number(args['min'] ?? 0); + const max = Number(args['max'] ?? Infinity); + return len >= min && len <= max; + }, + numeric: (args) => { + const n = Number(args['value']); + return !isNaN(n) && n >= Number(args['min'] ?? -Infinity) && n <= Number(args['max'] ?? Infinity); + }, + email: (args) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(String(args['value'] ?? '')), +}; + +export function validateChecks(checks: A2uiCheck[]): A2uiValidationResult { + const errors: string[] = []; + for (const check of checks) { + const validator = VALIDATORS[check.call]; + if (!validator) continue; + if (!validator(check.args)) { + errors.push(check.message); + } + } + return { valid: errors.length === 0, errors }; +} +``` + +- [ ] **Step 3: Update barrel, verify, commit** + +Add to `libs/a2ui/src/index.ts`: +```ts +export { validateChecks } from './lib/validate'; +export type { A2uiValidationResult } from './lib/validate'; +``` + +Run: `npx nx test a2ui` +Commit: `git commit -m "feat(a2ui): add validation check executor (required, regex, length, numeric, email)"` + +--- + +### Task 3: Template Expansion in Surface Renderer + +**Files:** +- Modify: `libs/chat/src/lib/a2ui/surface.component.ts` +- Modify: `libs/chat/src/lib/a2ui/surface.component.spec.ts` + +- [ ] **Step 1: Write failing test** + +Add to `libs/chat/src/lib/a2ui/surface.component.spec.ts`: + +```ts + it('expands template children over data model arrays', () => { + const surface = makeSurface( + [ + { id: 'root', component: 'Column', children: { path: '/items', componentId: 'item_card' } as any }, + { id: 'item_card', component: 'Text', text: { path: 'name' } as any }, + ], + { items: [{ name: 'Alice' }, { name: 'Bob' }] }, + ); + // Template should expand into item_card__0, item_card__1 + // The root's children should reference these expanded IDs + const components = surface.components; + const root = components.get('root')!; + expect(root.children).toEqual({ path: '/items', componentId: 'item_card' }); + // surfaceToSpec will handle the expansion + }); +``` + +- [ ] **Step 2: Update surfaceToSpec for template expansion** + +In `libs/chat/src/lib/a2ui/surface.component.ts`, update the `surfaceToSpec` function. + +Add import: +```ts +import { resolveDynamic, getByPointer } from '@cacheplane/a2ui'; +import type { A2uiSurface, A2uiChildTemplate, A2uiComponent } from '@cacheplane/a2ui'; +``` + +Replace the children handling block (lines 34-39) with: + +```ts + // Map children + let children: string[] | undefined; + if (Array.isArray(comp.children)) { + children = comp.children as string[]; + } else if (comp.children && typeof comp.children === 'object' && 'path' in comp.children) { + // Template expansion — expand over data model array + const template = comp.children as A2uiChildTemplate; + const arr = getByPointer(surface.dataModel, template.path); + if (Array.isArray(arr)) { + children = arr.map((_, i) => `${template.componentId}__${i}`); + const templateComp = surface.components.get(template.componentId); + if (templateComp) { + for (let i = 0; i < arr.length; i++) { + const scope = { basePath: `${template.path}/${i}`, item: arr[i] }; + const itemProps: Record = {}; + const reserved = new Set(['id', 'component', 'children', 'action', 'checks']); + for (const [key, value] of Object.entries(templateComp)) { + if (reserved.has(key)) continue; + itemProps[key] = resolveDynamic(value, surface.dataModel, scope); + } + elements[`${template.componentId}__${i}`] = { + type: templateComp.component, + props: itemProps, + }; + } + } + } + } +``` + +- [ ] **Step 3: Verify and commit** + +Run: `npx nx test chat -- --testPathPattern=surface.component` +Commit: `git commit -m "feat(chat): add A2UI template expansion for collection children"` + +--- + +### Task 4: Two-Way Data Binding on Input Components + +**Files:** +- Modify: `libs/chat/src/lib/a2ui/catalog/text-field.component.ts` +- Modify: `libs/chat/src/lib/a2ui/catalog/check-box.component.ts` +- Modify: `libs/chat/src/lib/a2ui/catalog/choice-picker.component.ts` +- Modify: `libs/chat/src/lib/a2ui/surface.component.ts` + +- [ ] **Step 1: Add update callback to surface component** + +In `libs/chat/src/lib/a2ui/surface.component.ts`, add an `onDataModelUpdate` input: + +```ts +import { output } from '@angular/core'; + +// In the component class: +readonly onDataModelUpdate = input<((path: string, value: unknown) => void) | undefined>(undefined); +``` + +Pass action and binding info to each element's props in `surfaceToSpec`. For each component, if any prop is a path reference, include the path in a `_bindings` prop: + +In the props resolution loop, track bindings: + +```ts + const bindings: Record = {}; + for (const [key, value] of Object.entries(comp)) { + if (reserved.has(key)) continue; + if (typeof value === 'object' && value !== null && 'path' in value && !('call' in value)) { + bindings[key] = (value as any).path; + } + props[key] = resolveDynamic(value, surface.dataModel); + } + if (Object.keys(bindings).length > 0) { + props['_bindings'] = bindings; + } + // Pass action through + if (comp.action) { + props['action'] = comp.action; + } +``` + +- [ ] **Step 2: Update TextField for two-way binding** + +Replace `libs/chat/src/lib/a2ui/catalog/text-field.component.ts`: + +```ts +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +import { Component, input } from '@angular/core'; +import type { A2uiAction } from '@cacheplane/a2ui'; + +@Component({ + selector: 'a2ui-text-field', + standalone: true, + template: ` +
+ @if (label()) { } + +
+ `, +}) +export class A2uiTextFieldComponent { + readonly label = input(''); + readonly value = input(''); + readonly placeholder = input(''); + readonly _bindings = input>({}); + readonly emit = input<(event: string) => void>(() => {}); + + onInput(event: Event): void { + const val = (event.target as HTMLInputElement).value; + const path = this._bindings()?.['value']; + if (path) { + this.emit()(`a2ui:datamodel:${path}:${val}`); + } + } +} +``` + +- [ ] **Step 3: Update CheckBox for two-way binding** + +Replace `libs/chat/src/lib/a2ui/catalog/check-box.component.ts`: + +```ts +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +import { Component, input } from '@angular/core'; + +@Component({ + selector: 'a2ui-check-box', + standalone: true, + template: ` + + `, +}) +export class A2uiCheckBoxComponent { + readonly label = input(''); + readonly checked = input(false); + readonly _bindings = input>({}); + readonly emit = input<(event: string) => void>(() => {}); + + onChange(event: Event): void { + const val = (event.target as HTMLInputElement).checked; + const path = this._bindings()?.['checked']; + if (path) { + this.emit()(`a2ui:datamodel:${path}:${val}`); + } + } +} +``` + +- [ ] **Step 4: Update ChoicePicker for two-way binding** + +Replace `libs/chat/src/lib/a2ui/catalog/choice-picker.component.ts`: + +```ts +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +import { Component, input } from '@angular/core'; + +@Component({ + selector: 'a2ui-choice-picker', + standalone: true, + template: ` +
+ @if (label()) { } + +
+ `, +}) +export class A2uiChoicePickerComponent { + readonly label = input(''); + readonly options = input([]); + readonly selected = input(''); + readonly _bindings = input>({}); + readonly emit = input<(event: string) => void>(() => {}); + + onChange(event: Event): void { + const val = (event.target as HTMLSelectElement).value; + const path = this._bindings()?.['selected']; + if (path) { + this.emit()(`a2ui:datamodel:${path}:${val}`); + } + } +} +``` + +- [ ] **Step 5: Verify and commit** + +Run: `npx nx test chat` +Commit: `git commit -m "feat(chat): add two-way data binding for A2UI input components"` + +--- + +### Task 5: Button Action Dispatch + +**Files:** +- Modify: `libs/chat/src/lib/a2ui/catalog/button.component.ts` + +- [ ] **Step 1: Update button with action handling** + +Replace `libs/chat/src/lib/a2ui/catalog/button.component.ts`: + +```ts +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +import { Component, input } from '@angular/core'; +import type { A2uiAction, A2uiCheck } from '@cacheplane/a2ui'; +import { validateChecks } from '@cacheplane/a2ui'; + +@Component({ + selector: 'a2ui-button', + standalone: true, + template: ` + + `, +}) +export class A2uiButtonComponent { + readonly label = input(''); + readonly variant = input('primary'); + readonly disabled = input(false); + readonly action = input(undefined); + readonly checks = input([]); + readonly emit = input<(event: string) => void>(() => {}); + + isValid(): boolean { + const c = this.checks(); + if (!c || c.length === 0) return true; + return validateChecks(c).valid; + } + + handleClick(): void { + const act = this.action(); + if (!act) return; + if ('event' in act) { + this.emit()(`a2ui:action:${JSON.stringify(act.event)}`); + } else if ('functionCall' in act) { + const fc = act.functionCall; + if (fc.call === 'openUrl' && typeof globalThis.window !== 'undefined') { + globalThis.window.open(String(fc.args['url'] ?? ''), '_blank'); + } + } + } +} +``` + +- [ ] **Step 2: Update surfaceToSpec to pass checks through** + +In `libs/chat/src/lib/a2ui/surface.component.ts`, after the action passthrough, add: + +```ts + if (comp.checks) { + props['checks'] = comp.checks; + } +``` + +- [ ] **Step 3: Verify and commit** + +Run: `npx nx test chat` +Commit: `git commit -m "feat(chat): add A2UI button action dispatch with validation"` + +--- + +### Task 6: Final Verification + +- [ ] **Step 1: Run all tests** + +Run: `npx nx run-many -t test -p a2ui partial-json render chat` +Expected: ALL PASS + +- [ ] **Step 2: Run lint** + +Run: `npx nx run-many -t lint -p a2ui chat` +Fix any new issues. + +- [ ] **Step 3: Commit any fixes** + +Only if needed. From ebe081a7d54257adeb1af94ccae4b4eb686a234c Mon Sep 17 00:00:00 2001 From: Brian Love Date: Thu, 9 Apr 2026 10:08:54 -0700 Subject: [PATCH 3/8] feat(a2ui): implement registered functions (formatNumber, pluralize, and/or/not, openUrl) Co-Authored-By: Claude Opus 4.6 (1M context) --- libs/a2ui/src/index.ts | 1 + libs/a2ui/src/lib/functions.spec.ts | 43 ++++++++++++++++++++++++++ libs/a2ui/src/lib/functions.ts | 47 +++++++++++++++++++++++++++++ libs/a2ui/src/lib/resolve.spec.ts | 16 ++++++++-- libs/a2ui/src/lib/resolve.ts | 12 ++++++-- 5 files changed, 114 insertions(+), 5 deletions(-) create mode 100644 libs/a2ui/src/lib/functions.spec.ts create mode 100644 libs/a2ui/src/lib/functions.ts diff --git a/libs/a2ui/src/index.ts b/libs/a2ui/src/index.ts index 3a4bd1aac..9f73dab77 100644 --- a/libs/a2ui/src/index.ts +++ b/libs/a2ui/src/index.ts @@ -13,3 +13,4 @@ export { createA2uiMessageParser } from './lib/parser'; export type { A2uiMessageParser } from './lib/parser'; export { resolveDynamic } from './lib/resolve'; export type { A2uiScope } from './lib/resolve'; +export { executeFunction } from './lib/functions'; diff --git a/libs/a2ui/src/lib/functions.spec.ts b/libs/a2ui/src/lib/functions.spec.ts new file mode 100644 index 000000000..8124c2ffe --- /dev/null +++ b/libs/a2ui/src/lib/functions.spec.ts @@ -0,0 +1,43 @@ +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +import { describe, it, expect } from 'vitest'; +import { executeFunction } from './functions'; + +const model = { name: 'Alice', price: 1234.5, date: '2026-04-09', count: 1 }; + +describe('executeFunction', () => { + it('formatNumber with grouping', () => { + expect(executeFunction('formatNumber', { value: 1234, grouping: true }, model)).toMatch(/1.234/); + }); + + it('formatNumber with precision', () => { + expect(executeFunction('formatNumber', { value: 1234.5, precision: 2 }, model)).toBe('1234.50'); + }); + + it('pluralize singular', () => { + expect(executeFunction('pluralize', { count: 1, singular: 'item', plural: 'items' }, model)).toBe('item'); + }); + + it('pluralize plural', () => { + expect(executeFunction('pluralize', { count: 3, singular: 'item', plural: 'items' }, model)).toBe('items'); + }); + + it('and returns true when all truthy', () => { + expect(executeFunction('and', { a: true, b: true }, model)).toBe(true); + }); + + it('and returns false when any falsy', () => { + expect(executeFunction('and', { a: true, b: false }, model)).toBe(false); + }); + + it('or returns true when any truthy', () => { + expect(executeFunction('or', { a: false, b: true }, model)).toBe(true); + }); + + it('not inverts boolean', () => { + expect(executeFunction('not', { value: true }, model)).toBe(false); + }); + + it('returns null for unknown function', () => { + expect(executeFunction('unknownFn', {}, model)).toBeNull(); + }); +}); diff --git a/libs/a2ui/src/lib/functions.ts b/libs/a2ui/src/lib/functions.ts new file mode 100644 index 000000000..83c81b77d --- /dev/null +++ b/libs/a2ui/src/lib/functions.ts @@ -0,0 +1,47 @@ +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 + +type FnExecutor = (args: Record, model: Record) => unknown; + +const FUNCTIONS: Record = { + formatNumber: (args) => { + const n = Number(args['value']); + const precision = Number(args['precision'] ?? 0); + if (args['grouping']) { + return n.toLocaleString(undefined, { minimumFractionDigits: precision, maximumFractionDigits: precision }); + } + return n.toFixed(precision); + }, + formatCurrency: (args) => { + return Number(args['value']).toLocaleString( + String(args['locale'] ?? 'en-US'), + { style: 'currency', currency: String(args['currency'] ?? 'USD') }, + ); + }, + formatDate: (args) => { + return new Date(String(args['value'])).toLocaleDateString( + String(args['locale'] ?? undefined), + ); + }, + pluralize: (args) => { + return Number(args['count']) === 1 ? String(args['singular']) : String(args['plural']); + }, + openUrl: (args) => { + if (typeof globalThis.window !== 'undefined') { + globalThis.window.open(String(args['url']), '_blank'); + } + return null; + }, + and: (args) => Object.values(args).every(Boolean), + or: (args) => Object.values(args).some(Boolean), + not: (args) => !args['value'], +}; + +export function executeFunction( + name: string, + args: Record, + model: Record, +): unknown { + const fn = FUNCTIONS[name]; + if (!fn) return null; + return fn(args, model); +} diff --git a/libs/a2ui/src/lib/resolve.spec.ts b/libs/a2ui/src/lib/resolve.spec.ts index e6ab37b5e..21a84e9a9 100644 --- a/libs/a2ui/src/lib/resolve.spec.ts +++ b/libs/a2ui/src/lib/resolve.spec.ts @@ -42,9 +42,19 @@ describe('resolveDynamic', () => { expect(resolveDynamic('Price: \\${100}', model)).toBe('Price: ${100}'); }); - it('returns function calls as-is (Phase 2)', () => { - const fn = { call: 'formatDate', args: { value: '2026-01-01' } }; - expect(resolveDynamic(fn, model)).toBe('[formatDate]'); + it('executes function calls', () => { + const fn = { call: 'pluralize', args: { count: 1, singular: 'item', plural: 'items' } }; + expect(resolveDynamic(fn, model)).toBe('item'); + }); + + it('resolves dynamic args in function calls', () => { + const fn = { call: 'pluralize', args: { count: { path: '/user/age' }, singular: 'year', plural: 'years' } }; + expect(resolveDynamic(fn, model)).toBe('years'); + }); + + it('falls back to [name] for unknown functions', () => { + const fn = { call: 'unknownFn', args: {} }; + expect(resolveDynamic(fn, model)).toBe('[unknownFn]'); }); it('resolves array elements', () => { diff --git a/libs/a2ui/src/lib/resolve.ts b/libs/a2ui/src/lib/resolve.ts index de1f58a4a..4aa56fd05 100644 --- a/libs/a2ui/src/lib/resolve.ts +++ b/libs/a2ui/src/lib/resolve.ts @@ -1,6 +1,7 @@ // SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 import type { A2uiPathRef, A2uiFunctionCall } from './types'; import { getByPointer } from './pointer'; +import { executeFunction } from './functions'; export interface A2uiScope { basePath: string; @@ -50,9 +51,16 @@ export function resolveDynamic( return resolvePathRef(value, model, scope); } - // Function call — stub for Phase 2 + // Function call — execute registered function if (isFunctionCall(value)) { - return `[${(value as A2uiFunctionCall).call}]`; + const fc = value as A2uiFunctionCall; + // Resolve args that may themselves be dynamic + const resolvedArgs: Record = {}; + for (const [k, v] of Object.entries(fc.args)) { + resolvedArgs[k] = resolveDynamic(v, model, scope); + } + const result = executeFunction(fc.call, resolvedArgs, model); + return result ?? `[${fc.call}]`; } // Template string interpolation From bce2757c3850f76b164a3f7b796a8e5290cdb58b Mon Sep 17 00:00:00 2001 From: Brian Love Date: Thu, 9 Apr 2026 10:09:29 -0700 Subject: [PATCH 4/8] feat(a2ui): add validation check executor (required, regex, length, numeric, email) Co-Authored-By: Claude Opus 4.6 (1M context) --- libs/a2ui/src/index.ts | 2 + libs/a2ui/src/lib/validate.spec.ts | 71 ++++++++++++++++++++++++++++++ libs/a2ui/src/lib/validate.ts | 37 ++++++++++++++++ 3 files changed, 110 insertions(+) create mode 100644 libs/a2ui/src/lib/validate.spec.ts create mode 100644 libs/a2ui/src/lib/validate.ts diff --git a/libs/a2ui/src/index.ts b/libs/a2ui/src/index.ts index 9f73dab77..b7dc58d64 100644 --- a/libs/a2ui/src/index.ts +++ b/libs/a2ui/src/index.ts @@ -14,3 +14,5 @@ export type { A2uiMessageParser } from './lib/parser'; export { resolveDynamic } from './lib/resolve'; export type { A2uiScope } from './lib/resolve'; export { executeFunction } from './lib/functions'; +export { validateChecks } from './lib/validate'; +export type { A2uiValidationResult } from './lib/validate'; diff --git a/libs/a2ui/src/lib/validate.spec.ts b/libs/a2ui/src/lib/validate.spec.ts new file mode 100644 index 000000000..5eb35807a --- /dev/null +++ b/libs/a2ui/src/lib/validate.spec.ts @@ -0,0 +1,71 @@ +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +import { describe, it, expect } from 'vitest'; +import { validateChecks } from './validate'; +import type { A2uiCheck } from './types'; + +describe('validateChecks', () => { + it('required passes for non-empty value', () => { + const checks: A2uiCheck[] = [{ call: 'required', args: { value: 'hello' }, message: 'Required' }]; + expect(validateChecks(checks)).toEqual({ valid: true, errors: [] }); + }); + + it('required fails for empty string', () => { + const checks: A2uiCheck[] = [{ call: 'required', args: { value: '' }, message: 'Required' }]; + expect(validateChecks(checks)).toEqual({ valid: false, errors: ['Required'] }); + }); + + it('required fails for null', () => { + const checks: A2uiCheck[] = [{ call: 'required', args: { value: null }, message: 'Required' }]; + expect(validateChecks(checks)).toEqual({ valid: false, errors: ['Required'] }); + }); + + it('regex passes for matching pattern', () => { + const checks: A2uiCheck[] = [{ call: 'regex', args: { value: 'abc123', pattern: '^[a-z]+\\d+$' }, message: 'Bad format' }]; + expect(validateChecks(checks)).toEqual({ valid: true, errors: [] }); + }); + + it('regex fails for non-matching', () => { + const checks: A2uiCheck[] = [{ call: 'regex', args: { value: '!!!', pattern: '^\\w+$' }, message: 'Bad' }]; + expect(validateChecks(checks).valid).toBe(false); + }); + + it('length passes within range', () => { + const checks: A2uiCheck[] = [{ call: 'length', args: { value: 'hello', min: 3, max: 10 }, message: 'Length' }]; + expect(validateChecks(checks).valid).toBe(true); + }); + + it('length fails below min', () => { + const checks: A2uiCheck[] = [{ call: 'length', args: { value: 'hi', min: 3 }, message: 'Too short' }]; + expect(validateChecks(checks).valid).toBe(false); + }); + + it('numeric passes in range', () => { + const checks: A2uiCheck[] = [{ call: 'numeric', args: { value: 5, min: 0, max: 10 }, message: 'Range' }]; + expect(validateChecks(checks).valid).toBe(true); + }); + + it('email passes for valid email', () => { + const checks: A2uiCheck[] = [{ call: 'email', args: { value: 'a@b.com' }, message: 'Email' }]; + expect(validateChecks(checks).valid).toBe(true); + }); + + it('email fails for invalid', () => { + const checks: A2uiCheck[] = [{ call: 'email', args: { value: 'not-email' }, message: 'Email' }]; + expect(validateChecks(checks).valid).toBe(false); + }); + + it('collects multiple errors', () => { + const checks: A2uiCheck[] = [ + { call: 'required', args: { value: '' }, message: 'Required' }, + { call: 'email', args: { value: '' }, message: 'Email' }, + ]; + const result = validateChecks(checks); + expect(result.valid).toBe(false); + expect(result.errors).toHaveLength(2); + }); + + it('ignores unknown check functions', () => { + const checks: A2uiCheck[] = [{ call: 'unknownCheck', args: {}, message: 'Unknown' }]; + expect(validateChecks(checks).valid).toBe(true); + }); +}); diff --git a/libs/a2ui/src/lib/validate.ts b/libs/a2ui/src/lib/validate.ts new file mode 100644 index 000000000..a7d3e564e --- /dev/null +++ b/libs/a2ui/src/lib/validate.ts @@ -0,0 +1,37 @@ +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +import type { A2uiCheck } from './types'; + +export interface A2uiValidationResult { + valid: boolean; + errors: string[]; +} + +type Validator = (args: Record) => boolean; + +const VALIDATORS: Record = { + required: (args) => args['value'] != null && String(args['value']).trim() !== '', + regex: (args) => new RegExp(String(args['pattern'])).test(String(args['value'] ?? '')), + length: (args) => { + const len = String(args['value'] ?? '').length; + const min = Number(args['min'] ?? 0); + const max = Number(args['max'] ?? Infinity); + return len >= min && len <= max; + }, + numeric: (args) => { + const n = Number(args['value']); + return !isNaN(n) && n >= Number(args['min'] ?? -Infinity) && n <= Number(args['max'] ?? Infinity); + }, + email: (args) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(String(args['value'] ?? '')), +}; + +export function validateChecks(checks: A2uiCheck[]): A2uiValidationResult { + const errors: string[] = []; + for (const check of checks) { + const validator = VALIDATORS[check.call]; + if (!validator) continue; + if (!validator(check.args)) { + errors.push(check.message); + } + } + return { valid: errors.length === 0, errors }; +} From 72d16f7d690de84a8fe5a395f6a2bb98083a85ba Mon Sep 17 00:00:00 2001 From: Brian Love Date: Thu, 9 Apr 2026 10:11:27 -0700 Subject: [PATCH 5/8] feat(chat): add A2UI template expansion for collection children Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/lib/a2ui/surface.component.spec.ts | 24 ++++++++++++++ libs/chat/src/lib/a2ui/surface.component.ts | 31 ++++++++++++++++--- 2 files changed, 50 insertions(+), 5 deletions(-) diff --git a/libs/chat/src/lib/a2ui/surface.component.spec.ts b/libs/chat/src/lib/a2ui/surface.component.spec.ts index 7f5511ade..01dffcfd4 100644 --- a/libs/chat/src/lib/a2ui/surface.component.spec.ts +++ b/libs/chat/src/lib/a2ui/surface.component.spec.ts @@ -1,6 +1,7 @@ // SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 import { describe, it, expect } from 'vitest'; import type { A2uiSurface, A2uiComponent } from '@cacheplane/a2ui'; +import { surfaceToSpec } from './surface.component'; describe('A2uiSurfaceComponent — data flow', () => { function makeSurface(components: A2uiComponent[], dataModel: Record = {}): A2uiSurface { @@ -31,4 +32,27 @@ describe('A2uiSurfaceComponent — data flow', () => { const surface = makeSurface([]); expect(surface.components.size).toBe(0); }); + + it('expands template children over data model arrays', () => { + const surface = makeSurface( + [ + { id: 'root', component: 'Column', children: { path: '/items', componentId: 'item_card' } as any }, + { id: 'item_card', component: 'Text', text: { path: 'name' } as any }, + ], + { items: [{ name: 'Alice' }, { name: 'Bob' }] }, + ); + const spec = surfaceToSpec(surface)!; + // Root should have expanded children referencing cloned IDs + expect(spec.elements['root'].children).toEqual(['item_card__0', 'item_card__1']); + // Expanded elements should have resolved props from their respective array items + expect(spec.elements['item_card__0'].props['text']).toBe('Alice'); + expect(spec.elements['item_card__1'].props['text']).toBe('Bob'); + }); + + it('returns null when no root component exists', () => { + const surface = makeSurface([ + { id: 'child', component: 'Text', text: 'No root' }, + ]); + expect(surfaceToSpec(surface)).toBeNull(); + }); }); diff --git a/libs/chat/src/lib/a2ui/surface.component.ts b/libs/chat/src/lib/a2ui/surface.component.ts index 89ed3d0eb..39ee9f18b 100644 --- a/libs/chat/src/lib/a2ui/surface.component.ts +++ b/libs/chat/src/lib/a2ui/surface.component.ts @@ -3,8 +3,8 @@ import { Component, computed, input, ChangeDetectionStrategy, } from '@angular/core'; import type { Spec } from '@json-render/core'; -import type { A2uiSurface } from '@cacheplane/a2ui'; -import { resolveDynamic } from '@cacheplane/a2ui'; +import type { A2uiSurface, A2uiChildTemplate } from '@cacheplane/a2ui'; +import { resolveDynamic, getByPointer } from '@cacheplane/a2ui'; import { RenderSpecComponent, toRenderRegistry } from '@cacheplane/render'; import type { ViewRegistry } from '@cacheplane/render'; @@ -15,7 +15,7 @@ import type { ViewRegistry } from '@cacheplane/render'; * 3. Mapping A2UI children (string[] or template) to json-render children * 4. Producing a Spec with root + elements */ -function surfaceToSpec(surface: A2uiSurface): Spec | null { +export function surfaceToSpec(surface: A2uiSurface): Spec | null { if (!surface.components.has('root')) return null; const elements: Record = {}; @@ -34,9 +34,30 @@ function surfaceToSpec(surface: A2uiSurface): Spec | null { let children: string[] | undefined; if (Array.isArray(comp.children)) { children = comp.children as string[]; + } else if (comp.children && typeof comp.children === 'object' && 'path' in comp.children) { + // Template expansion — expand over data model array + const template = comp.children as A2uiChildTemplate; + const arr = getByPointer(surface.dataModel, template.path); + if (Array.isArray(arr)) { + children = arr.map((_, i) => `${template.componentId}__${i}`); + const templateComp = surface.components.get(template.componentId); + if (templateComp) { + for (let i = 0; i < arr.length; i++) { + const scope = { basePath: `${template.path}/${i}`, item: arr[i] }; + const itemProps: Record = {}; + const tplReserved = new Set(['id', 'component', 'children', 'action', 'checks']); + for (const [key, value] of Object.entries(templateComp)) { + if (tplReserved.has(key)) continue; + itemProps[key] = resolveDynamic(value, surface.dataModel, scope); + } + elements[`${template.componentId}__${i}`] = { + type: templateComp.component, + props: itemProps, + }; + } + } + } } - // Template children (collection expansion) — Phase 2 for full implementation - // For now, skip template children elements[id] = { type: comp.component, From a3abc17e5f46206bba80a02dc771d687aca11723 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Thu, 9 Apr 2026 10:12:17 -0700 Subject: [PATCH 6/8] feat(chat): add two-way data binding for A2UI input components Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/lib/a2ui/catalog/check-box.component.ts | 14 ++++++++++++-- .../lib/a2ui/catalog/choice-picker.component.ts | 12 +++++++++++- .../lib/a2ui/catalog/text-field.component.ts | 12 +++++++++++- libs/chat/src/lib/a2ui/surface.component.ts | 17 ++++++++++++++++- 4 files changed, 50 insertions(+), 5 deletions(-) diff --git a/libs/chat/src/lib/a2ui/catalog/check-box.component.ts b/libs/chat/src/lib/a2ui/catalog/check-box.component.ts index 6d40ca62f..cc27171f6 100644 --- a/libs/chat/src/lib/a2ui/catalog/check-box.component.ts +++ b/libs/chat/src/lib/a2ui/catalog/check-box.component.ts @@ -5,8 +5,8 @@ import { Component, input } from '@angular/core'; selector: 'a2ui-check-box', standalone: true, template: ` -