Skip to content
30 changes: 27 additions & 3 deletions apps/website/content/docs/chat/api/api-docs.json
Original file line number Diff line number Diff line change
Expand Up @@ -1072,7 +1072,7 @@
},
{
"name": "catalog",
"type": "InputSignal<Readonly<Record<string, Type<unknown> | RenderViewEntry>>>",
"type": "InputSignal<Readonly<Record<string, Type<unknown> | RenderViewEntry>> | Readonly<Record<string, Type<unknown> | RenderViewEntry>>>",
"description": "",
"optional": false
},
Expand Down Expand Up @@ -1112,16 +1112,34 @@
"description": "Convert ViewRegistry to AngularRegistry for RenderSpecComponent.",
"optional": false
},
{
"name": "rootIds",
"type": "Signal<string[]>",
"description": "Roots from the surface state — components whose ids appear as\nchildren of no other component. The wire spec includes\n`beginRendering.root` as the single root; that path stays usable\nbut we keep the renderer permissive in case future surfaces emit\nmultiple top-level components.\n\nConservative: returns only the first key from componentViews\ninsertion order. The wire format's beginRendering.root carries the\ntrue root id; plumbing it through A2uiSurfaceState is a follow-up.",
"optional": false
},
{
"name": "spec",
"type": "Signal<Spec | null>",
"description": "Convert the A2UI surface to a json-render Spec for rendering.",
"optional": false
},
{
"name": "state",
"type": "InputSignal<A2uiSurfaceState | undefined>",
"description": "Chat-side surface state with per-component readiness. When set,\nthis takes priority and the progressive renderer is used.",
"optional": false
},
{
"name": "surface",
"type": "InputSignal<A2uiSurface>",
"description": "",
"type": "InputSignal<A2uiSurface | undefined>",
"description": "Wire-format surface (legacy path — kept for backwards compat).",
"optional": false
},
{
"name": "surfaceFallback",
"type": "InputSignal<Type<unknown> | undefined>",
"description": "Optional top-level placeholder when the surface has no components\nyet. Defaults to A2uiDefaultFallbackComponent.",
"optional": false
}
],
Expand Down Expand Up @@ -6070,6 +6088,12 @@
"description": "",
"optional": false
},
{
"name": "a2uiSurfaceStates",
"type": "Signal<Map<string, A2uiSurfaceState>>",
"description": "",
"optional": false
},
{
"name": "elementStates",
"type": "Signal<Map<string, ElementAccumulationState>>",
Expand Down
23 changes: 23 additions & 0 deletions libs/chat/src/lib/a2ui/a2ui-default-fallback.component.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
// SPDX-License-Identifier: MIT
import { describe, it, expect, beforeEach } from 'vitest';
import { TestBed } from '@angular/core/testing';
import { A2uiDefaultFallbackComponent } from './a2ui-default-fallback.component';

describe('A2uiDefaultFallbackComponent', () => {
beforeEach(() => TestBed.configureTestingModule({ imports: [A2uiDefaultFallbackComponent] }));

it('renders a region role with the Building UI status text', () => {
const fx = TestBed.createComponent(A2uiDefaultFallbackComponent);
fx.detectChanges();
const status = fx.nativeElement.querySelector('[role="status"]');
expect(status).toBeTruthy();
expect(status.textContent).toContain('Building UI');
});

it('renders three shimmer rows', () => {
const fx = TestBed.createComponent(A2uiDefaultFallbackComponent);
fx.detectChanges();
const rows = fx.nativeElement.querySelectorAll('.a2ui-default-fallback__row');
expect(rows.length).toBe(3);
});
});
64 changes: 64 additions & 0 deletions libs/chat/src/lib/a2ui/a2ui-default-fallback.component.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
// SPDX-License-Identifier: MIT
import { Component, ChangeDetectionStrategy } from '@angular/core';
import { CHAT_HOST_TOKENS } from '../styles/chat-tokens';

@Component({
selector: 'a2ui-default-fallback',
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
styles: [CHAT_HOST_TOKENS, `
:host { display: block; width: 100%; }
.a2ui-default-fallback {
border: 1px solid var(--ngaf-chat-separator);
border-radius: 10px;
padding: 14px;
background: var(--ngaf-chat-surface-alt);
}
.a2ui-default-fallback__label {
font-size: 12px;
color: var(--ngaf-chat-text-muted);
margin-bottom: 10px;
display: flex;
align-items: center;
gap: 6px;
}
.a2ui-default-fallback__rows {
display: flex;
flex-direction: column;
gap: 8px;
}
.a2ui-default-fallback__row {
height: 10px;
border-radius: 5px;
background: linear-gradient(
90deg,
var(--ngaf-chat-separator) 0%,
color-mix(in srgb, var(--ngaf-chat-separator) 70%, transparent) 50%,
var(--ngaf-chat-separator) 100%
);
background-size: 200% 100%;
animation: a2ui-default-fallback-shimmer 1.4s ease-in-out infinite;
}
.a2ui-default-fallback__row:nth-child(1) { width: 70%; }
.a2ui-default-fallback__row:nth-child(2) { width: 90%; }
.a2ui-default-fallback__row:nth-child(3) { width: 50%; }
@keyframes a2ui-default-fallback-shimmer {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
`],
template: `
<div class="a2ui-default-fallback" role="status" aria-live="polite">
<div class="a2ui-default-fallback__label">
<span aria-hidden="true">&#10024;</span>
<span>Building UI&hellip;</span>
</div>
<div class="a2ui-default-fallback__rows">
<div class="a2ui-default-fallback__row"></div>
<div class="a2ui-default-fallback__row"></div>
<div class="a2ui-default-fallback__row"></div>
</div>
</div>
`,
})
export class A2uiDefaultFallbackComponent {}
88 changes: 88 additions & 0 deletions libs/chat/src/lib/a2ui/a2ui-slot.directive.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
// SPDX-License-Identifier: MIT
import { describe, it, expect, beforeEach } from 'vitest';
import { Component, ChangeDetectionStrategy, input, signal } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { A2uiSlotDirective } from './a2ui-slot.directive';
import type { A2uiComponentView } from './component-view';
import type { A2uiViews } from './views';

@Component({
standalone: true, selector: 'a2ui-test-real', changeDetection: ChangeDetectionStrategy.OnPush,
template: '<span data-role="real">REAL:{{ label() ?? "" }}</span>',
})
class RealCmp { readonly label = input<string>(); }

@Component({
standalone: true, selector: 'a2ui-test-fallback', changeDetection: ChangeDetectionStrategy.OnPush,
template: '<span data-role="fallback">FB</span>',
})
class FallbackCmp {}

@Component({
standalone: true,
imports: [A2uiSlotDirective],
template: `<ng-container *a2uiSlot="view(); views: views()" />`,
})
class HostCmp {
readonly view = input.required<A2uiComponentView>();
readonly views = input.required<A2uiViews>();
}

function makeView(over: Partial<A2uiComponentView> = {}): A2uiComponentView {
return {
id: 'c1', type: 't', bindings: [], ready: false, props: {}, def: { t: {} } as never,
...over,
};
}

describe('a2uiSlot', () => {
beforeEach(() => TestBed.configureTestingModule({}));

it('mounts the fallback while !ready', () => {
const fx = TestBed.createComponent(HostCmp);
fx.componentRef.setInput('view', makeView({ ready: false }));
fx.componentRef.setInput('views', { t: { component: RealCmp, fallback: FallbackCmp } });
fx.detectChanges();
expect(fx.nativeElement.querySelector('[data-role="fallback"]')).toBeTruthy();
expect(fx.nativeElement.querySelector('[data-role="real"]')).toBeFalsy();
});

it('mounts the real component once ready=true', () => {
const fx = TestBed.createComponent(HostCmp);
const v = signal(makeView({ ready: false }));
fx.componentRef.setInput('view', v());
fx.componentRef.setInput('views', { t: { component: RealCmp, fallback: FallbackCmp } });
fx.detectChanges();
fx.componentRef.setInput('view', makeView({ ready: true, props: { label: 'Ada' } }));
fx.detectChanges();
expect(fx.nativeElement.querySelector('[data-role="real"]')).toBeTruthy();
expect(fx.nativeElement.querySelector('[data-role="real"]')!.textContent).toContain('Ada');
});

it('monotonic: once real mounts, later ready=false does NOT remount fallback', () => {
const fx = TestBed.createComponent(HostCmp);
fx.componentRef.setInput('view', makeView({ ready: true, props: { label: 'Ada' } }));
fx.componentRef.setInput('views', { t: { component: RealCmp, fallback: FallbackCmp } });
fx.detectChanges();
fx.componentRef.setInput('view', makeView({ ready: false, props: {} }));
fx.detectChanges();
expect(fx.nativeElement.querySelector('[data-role="real"]')).toBeTruthy();
expect(fx.nativeElement.querySelector('[data-role="fallback"]')).toBeFalsy();
});

it('uses A2uiDefaultFallbackComponent when views[type].fallback is omitted', () => {
const fx = TestBed.createComponent(HostCmp);
fx.componentRef.setInput('view', makeView({ ready: false }));
fx.componentRef.setInput('views', { t: { component: RealCmp } });
fx.detectChanges();
expect(fx.nativeElement.querySelector('.a2ui-default-fallback')).toBeTruthy();
});

it('accepts bare-Type view entries (legacy shape)', () => {
const fx = TestBed.createComponent(HostCmp);
fx.componentRef.setInput('view', makeView({ ready: true, props: { label: 'X' } }));
fx.componentRef.setInput('views', { t: RealCmp });
fx.detectChanges();
expect(fx.nativeElement.querySelector('[data-role="real"]')).toBeTruthy();
});
});
76 changes: 76 additions & 0 deletions libs/chat/src/lib/a2ui/a2ui-slot.directive.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
// SPDX-License-Identifier: MIT
import {
Directive, Input, ViewContainerRef, ComponentRef, Type, inject,
} from '@angular/core';
import type { A2uiComponentView } from './component-view';
import type { A2uiViews } from './views';
import { normalizeViewEntry } from './views';
import { A2uiDefaultFallbackComponent } from './a2ui-default-fallback.component';

/** Internal recursive structural directive that mounts the right
* component for an `A2uiComponentView` instance. Monotonic: once the
* real component mounts, subsequent ticks only push new input values
* via `ComponentRef.setInput()` — no remount, no re-check of `ready`. */
@Directive({
selector: '[a2uiSlot]',
standalone: true,
})
export class A2uiSlotDirective {
private view: A2uiComponentView | null = null;
private views: A2uiViews = {};
private mountedReal = false;
private ref: ComponentRef<unknown> | null = null;
private readonly vcr = inject(ViewContainerRef);

@Input({ required: true }) set a2uiSlot(view: A2uiComponentView) {
this.view = view;
this.render();
}

@Input({ required: true }) set a2uiSlotViews(views: A2uiViews) {
this.views = views;
this.render();
}

private render(): void {
const view = this.view;
if (!view) return;
const entry = this.views[view.type];
const normalized = entry != null ? normalizeViewEntry(entry) : undefined;

// Monotonic gate: once real mounted, only push inputs.
if (this.mountedReal && this.ref) {
this.pushProps(this.ref, view.props);
return;
}

if (view.ready && normalized) {
this.vcr.clear();
const created = this.vcr.createComponent(normalized.component);
this.pushProps(created, view.props);
this.ref = created;
this.mountedReal = true;
return;
}

// Not ready (or no entry yet) → mount fallback.
const fallback: Type<unknown> =
normalized?.fallback ?? A2uiDefaultFallbackComponent;
// Avoid thrashing: only remount if the current ref isn't the fallback.
if (this.ref && this.ref.componentType === fallback) return;
this.vcr.clear();
this.ref = this.vcr.createComponent(fallback);
}

private pushProps(ref: ComponentRef<unknown>, props: Record<string, unknown>): void {
for (const [k, v] of Object.entries(props)) {
try {
ref.setInput(k, v);
} catch {
// Component doesn't declare this input — silently skip. The
// wire format may include keys the Angular component doesn't
// accept (e.g. children references handled separately).
}
}
}
}
75 changes: 75 additions & 0 deletions libs/chat/src/lib/a2ui/surface.component.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
// SPDX-License-Identifier: MIT
import { describe, it, expect, beforeEach } from 'vitest';
import { Component, ChangeDetectionStrategy } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { A2uiSurfaceComponent } from './surface.component';
import type { A2uiSurfaceState } from './surface-store';
import type { A2uiViews } from './views';

@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 {
return {
surface: {
surfaceId: 's1', catalogId: 'basic',
components: new Map(), dataModel: {},
} as never,
componentViews: componentViews as never,
};
}

describe('A2uiSurfaceComponent — progressive rendering', () => {
beforeEach(() => TestBed.configureTestingModule({ imports: [A2uiSurfaceComponent] }));

it('renders the default fallback when state.componentViews is empty', () => {
const fx = TestBed.createComponent(A2uiSurfaceComponent);
fx.componentRef.setInput('state', makeState(new Map()));
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: {} },
}]]);
const fx = TestBed.createComponent(A2uiSurfaceComponent);
fx.componentRef.setInput('state', makeState(views));
fx.componentRef.setInput('catalog', { t: { component: RealCmp, fallback: CustomFallback } } satisfies A2uiViews);
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();
});

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.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();
});
});
Loading
Loading