diff --git a/apps/website/content/docs/chat/api/api-docs.json b/apps/website/content/docs/chat/api/api-docs.json index 2dc3925dc..3b3a8ae14 100644 --- a/apps/website/content/docs/chat/api/api-docs.json +++ b/apps/website/content/docs/chat/api/api-docs.json @@ -1121,7 +1121,7 @@ { "name": "spec", "type": "Signal", - "description": "Convert the A2UI surface to a json-render Spec for rendering.", + "description": "Convert the A2UI surface to a json-render Spec for rendering.\n Prefers `state().surface` (the progressively-built wire surface)\n over the legacy `surface` input. surfaceToSpec handles\n children.explicitList → spec.children translation + reserved-key\n filtering + path-ref → $bindState rewriting; the rendered tree\n then uses render-element's standard input-mapping\n (`childKeys: el.children`) so catalog components receive the\n inputs they actually declare.\n\n This supersedes the earlier slot-based progressive renderer,\n which mounted root components but never populated their\n childKeys input — leaving Columns/Rows/etc. with no children.", "optional": false }, { diff --git a/libs/chat/src/lib/a2ui/surface.component.spec.ts b/libs/chat/src/lib/a2ui/surface.component.spec.ts index 3da60db41..536f29e65 100644 --- a/libs/chat/src/lib/a2ui/surface.component.spec.ts +++ b/libs/chat/src/lib/a2ui/surface.component.spec.ts @@ -4,72 +4,88 @@ import { Component, ChangeDetectionStrategy } from '@angular/core'; import { TestBed } from '@angular/core/testing'; import { A2uiSurfaceComponent } from './surface.component'; import type { A2uiSurfaceState } from './surface-store'; +import { createA2uiSurfaceStore } from './surface-store'; import type { A2uiViews } from './views'; +import { a2uiBasicCatalog } from './catalog'; @Component({ standalone: true, selector: 'a2ui-test-real', template: '', changeDetection: ChangeDetectionStrategy.OnPush }) class RealCmp {} @Component({ standalone: true, selector: 'a2ui-test-custom-fb', template: '', changeDetection: ChangeDetectionStrategy.OnPush }) class CustomFallback {} -function makeState(componentViews: Map): A2uiSurfaceState { +function makeState(components: Array<{ id: string; type: string; props?: Record }> = []): A2uiSurfaceState { + const compsMap = new Map( + components.map((c) => [c.id, { + id: c.id, + component: { [c.type]: c.props ?? {} }, + } as never]), + ); return { surface: { surfaceId: 's1', catalogId: 'basic', - components: new Map(), dataModel: {}, + components: compsMap, dataModel: {}, } as never, - componentViews: componentViews as never, + componentViews: new Map() as never, }; } -describe('A2uiSurfaceComponent — progressive rendering', () => { +describe('A2uiSurfaceComponent — empty surface', () => { beforeEach(() => TestBed.configureTestingModule({ imports: [A2uiSurfaceComponent] })); - it('renders the default fallback when state.componentViews is empty', () => { + it('renders the default fallback when state.surface has no components', () => { const fx = TestBed.createComponent(A2uiSurfaceComponent); - fx.componentRef.setInput('state', makeState(new Map())); + fx.componentRef.setInput('state', makeState([])); fx.componentRef.setInput('catalog', { t: RealCmp }); fx.detectChanges(); expect(fx.nativeElement.querySelector('.a2ui-default-fallback')).toBeTruthy(); }); - it('renders the catalog fallback when a component is not ready', () => { - const views = new Map([['c1', { - id: 'c1', type: 't', bindings: ['$.x'], ready: false, props: {}, def: { t: {} }, - }]]); + it('renders a custom fallback when surfaceFallback is set and surface is empty', () => { const fx = TestBed.createComponent(A2uiSurfaceComponent); - fx.componentRef.setInput('state', makeState(views)); - fx.componentRef.setInput('catalog', { t: { component: RealCmp, fallback: CustomFallback } } satisfies A2uiViews); + fx.componentRef.setInput('state', makeState([])); + fx.componentRef.setInput('catalog', { t: RealCmp }); + fx.componentRef.setInput('surfaceFallback', CustomFallback); fx.detectChanges(); expect(fx.nativeElement.querySelector('[data-role="custom-fb"]')).toBeTruthy(); }); +}); - it('renders the real component when ready=true', () => { - const views = new Map([['c1', { - id: 'c1', type: 't', bindings: [], ready: true, props: {}, def: { t: {} }, - }]]); - const fx = TestBed.createComponent(A2uiSurfaceComponent); - fx.componentRef.setInput('state', makeState(views)); - fx.componentRef.setInput('catalog', { t: { component: RealCmp } }); - fx.detectChanges(); - expect(fx.nativeElement.querySelector('[data-role="real"]')).toBeTruthy(); - }); +describe('A2uiSurfaceComponent — nested children with real catalog (regression)', () => { + beforeEach(() => TestBed.configureTestingModule({ imports: [A2uiSurfaceComponent] })); + + it('renders Column children defined via children.explicitList', () => { + // Reproduces the contact-form bug: a Column with explicitList children + // must actually render those children. Prior to the fix, the slot path + // pushed wrapped wire-format props onto the catalog component which + // had no matching `Column` input — so childKeys stayed empty and the + // Column rendered as an empty
. + const store = createA2uiSurfaceStore(); + store.apply({ surfaceUpdate: { + surfaceId: 's1', + components: [ + { id: 'root', component: { Column: { + children: { explicitList: ['leaf'] }, + distribution: 'start', + alignment: 'stretch', + } } }, + { id: 'leaf', component: { Text: { + text: { literalString: 'Hello' }, + usageHint: 'h2', + } } }, + ], + } } as never); + store.apply({ beginRendering: { surfaceId: 's1', root: 'root' } } as never); + + const state = store.surfaceState('s1')(); + expect(state).toBeDefined(); - it('state takes priority over surface when both inputs are set', () => { - const views = new Map([['c1', { - id: 'c1', type: 't', bindings: [], ready: true, props: {}, def: { t: {} }, - }]]); - const legacySurface = { - surfaceId: 'legacy', catalogId: 'basic', - components: new Map(), dataModel: {}, - }; const fx = TestBed.createComponent(A2uiSurfaceComponent); - fx.componentRef.setInput('state', makeState(views)); - fx.componentRef.setInput('surface', legacySurface); - fx.componentRef.setInput('catalog', { t: { component: RealCmp } }); + fx.componentRef.setInput('state', state); + fx.componentRef.setInput('catalog', a2uiBasicCatalog()); fx.detectChanges(); - // state path mounts the real component via a2uiSlot - expect(fx.nativeElement.querySelector('[data-role="real"]')).toBeTruthy(); - // legacy path must NOT have rendered - expect(fx.nativeElement.querySelector('render-spec')).toBeFalsy(); + + // The Text leaf must appear inside the rendered surface. If the + // Column's childKeys input was never set, no Text gets rendered. + expect(fx.nativeElement.textContent).toContain('Hello'); }); }); diff --git a/libs/chat/src/lib/a2ui/surface.component.ts b/libs/chat/src/lib/a2ui/surface.component.ts index ba9b92ba1..067f876aa 100644 --- a/libs/chat/src/lib/a2ui/surface.component.ts +++ b/libs/chat/src/lib/a2ui/surface.component.ts @@ -32,27 +32,19 @@ import type { A2uiViews } from './views'; '[style.font-family]': 'fontFamily()', }, template: ` - @if (state(); as st) { - @if (st.componentViews.size === 0) { - @if (surfaceFallback(); as fb) { - - } @else { - - } - } @else { - @for (id of rootIds(); track id) { - @if (st.componentViews.get(id); as view) { - - } - } - } - } @else if (spec(); as s) { + @if (spec(); as s) { + } @else if (state(); as st) { + @if (surfaceFallback(); as fb) { + + } @else { + + } } `, }) @@ -109,11 +101,21 @@ export class A2uiSurfaceComponent { return [...st.componentViews.keys()].slice(0, 1); }); - // ---- Legacy path (no state) ---- - /** Convert the A2UI surface to a json-render Spec for rendering. */ + /** Convert the A2UI surface to a json-render Spec for rendering. + * Prefers `state().surface` (the progressively-built wire surface) + * over the legacy `surface` input. surfaceToSpec handles + * children.explicitList → spec.children translation + reserved-key + * filtering + path-ref → $bindState rewriting; the rendered tree + * then uses render-element's standard input-mapping + * (`childKeys: el.children`) so catalog components receive the + * inputs they actually declare. + * + * This supersedes the earlier slot-based progressive renderer, + * which mounted root components but never populated their + * childKeys input — leaving Columns/Rows/etc. with no children. */ readonly spec = computed(() => { - const surf = this.surface(); - return surf ? surfaceToSpec(surf) : null; + const surf = this.state()?.surface ?? this.surface(); + return surf && surf.components.size > 0 ? surfaceToSpec(surf) : null; }); /** Convert ViewRegistry to AngularRegistry for RenderSpecComponent. */