Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion apps/website/content/docs/chat/api/api-docs.json
Original file line number Diff line number Diff line change
Expand Up @@ -1121,7 +1121,7 @@
{
"name": "spec",
"type": "Signal<Spec | null>",
"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
},
{
Expand Down
90 changes: 53 additions & 37 deletions libs/chat/src/lib/a2ui/surface.component.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: '<span data-role="real"></span>', changeDetection: ChangeDetectionStrategy.OnPush })
class RealCmp {}
@Component({ standalone: true, selector: 'a2ui-test-custom-fb', template: '<span data-role="custom-fb"></span>', changeDetection: ChangeDetectionStrategy.OnPush })
class CustomFallback {}

function makeState(componentViews: Map<string, unknown>): A2uiSurfaceState {
function makeState(components: Array<{ id: string; type: string; props?: Record<string, unknown> }> = []): A2uiSurfaceState {
const compsMap = new Map<string, never>(
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<string, unknown>([['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<string, unknown>([['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 <div>.
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<string, unknown>([['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 <render-spec> 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');
});
});
40 changes: 21 additions & 19 deletions libs/chat/src/lib/a2ui/surface.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
<ng-container *ngComponentOutlet="fb" />
} @else {
<a2ui-default-fallback />
}
} @else {
@for (id of rootIds(); track id) {
@if (st.componentViews.get(id); as view) {
<ng-container *a2uiSlot="view; views: catalog()" />
}
}
}
} @else if (spec(); as s) {
@if (spec(); as s) {
<render-spec
[spec]="s"
[registry]="registry()"
[handlers]="internalHandlers()"
(events)="onRenderEvent($event)"
/>
} @else if (state(); as st) {
@if (surfaceFallback(); as fb) {
<ng-container *ngComponentOutlet="fb" />
} @else {
<a2ui-default-fallback />
}
}
`,
})
Expand Down Expand Up @@ -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. */
Expand Down
Loading