diff --git a/apps/website/content/docs/chat/api/api-docs.json b/apps/website/content/docs/chat/api/api-docs.json index 12c85aad..b64ecfc8 100644 --- a/apps/website/content/docs/chat/api/api-docs.json +++ b/apps/website/content/docs/chat/api/api-docs.json @@ -1072,7 +1072,7 @@ }, { "name": "catalog", - "type": "InputSignal | RenderViewEntry>>>", + "type": "InputSignal | RenderViewEntry>> | Readonly | RenderViewEntry>>>", "description": "", "optional": false }, @@ -1112,16 +1112,34 @@ "description": "Convert ViewRegistry to AngularRegistry for RenderSpecComponent.", "optional": false }, + { + "name": "rootIds", + "type": "Signal", + "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", "description": "Convert the A2UI surface to a json-render Spec for rendering.", "optional": false }, + { + "name": "state", + "type": "InputSignal", + "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", - "description": "", + "type": "InputSignal", + "description": "Wire-format surface (legacy path — kept for backwards compat).", + "optional": false + }, + { + "name": "surfaceFallback", + "type": "InputSignal | undefined>", + "description": "Optional top-level placeholder when the surface has no components\nyet. Defaults to A2uiDefaultFallbackComponent.", "optional": false } ], @@ -6070,6 +6088,12 @@ "description": "", "optional": false }, + { + "name": "a2uiSurfaceStates", + "type": "Signal>", + "description": "", + "optional": false + }, { "name": "elementStates", "type": "Signal>", diff --git a/libs/chat/src/lib/a2ui/a2ui-default-fallback.component.spec.ts b/libs/chat/src/lib/a2ui/a2ui-default-fallback.component.spec.ts new file mode 100644 index 00000000..cf11aa7f --- /dev/null +++ b/libs/chat/src/lib/a2ui/a2ui-default-fallback.component.spec.ts @@ -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); + }); +}); diff --git a/libs/chat/src/lib/a2ui/a2ui-default-fallback.component.ts b/libs/chat/src/lib/a2ui/a2ui-default-fallback.component.ts new file mode 100644 index 00000000..0e684104 --- /dev/null +++ b/libs/chat/src/lib/a2ui/a2ui-default-fallback.component.ts @@ -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: ` +
+
+ + Building UI… +
+
+
+
+
+
+
+ `, +}) +export class A2uiDefaultFallbackComponent {} diff --git a/libs/chat/src/lib/a2ui/a2ui-slot.directive.spec.ts b/libs/chat/src/lib/a2ui/a2ui-slot.directive.spec.ts new file mode 100644 index 00000000..4ced51a4 --- /dev/null +++ b/libs/chat/src/lib/a2ui/a2ui-slot.directive.spec.ts @@ -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: 'REAL:{{ label() ?? "" }}', +}) +class RealCmp { readonly label = input(); } + +@Component({ + standalone: true, selector: 'a2ui-test-fallback', changeDetection: ChangeDetectionStrategy.OnPush, + template: 'FB', +}) +class FallbackCmp {} + +@Component({ + standalone: true, + imports: [A2uiSlotDirective], + template: ``, +}) +class HostCmp { + readonly view = input.required(); + readonly views = input.required(); +} + +function makeView(over: Partial = {}): 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(); + }); +}); diff --git a/libs/chat/src/lib/a2ui/a2ui-slot.directive.ts b/libs/chat/src/lib/a2ui/a2ui-slot.directive.ts new file mode 100644 index 00000000..40f4e162 --- /dev/null +++ b/libs/chat/src/lib/a2ui/a2ui-slot.directive.ts @@ -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 | 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 = + 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, props: Record): 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). + } + } + } +} diff --git a/libs/chat/src/lib/a2ui/surface.component.spec.ts b/libs/chat/src/lib/a2ui/surface.component.spec.ts new file mode 100644 index 00000000..3da60db4 --- /dev/null +++ b/libs/chat/src/lib/a2ui/surface.component.spec.ts @@ -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: '', changeDetection: ChangeDetectionStrategy.OnPush }) +class RealCmp {} +@Component({ standalone: true, selector: 'a2ui-test-custom-fb', template: '', changeDetection: ChangeDetectionStrategy.OnPush }) +class CustomFallback {} + +function makeState(componentViews: Map): 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([['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([['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([['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 path must NOT have rendered + expect(fx.nativeElement.querySelector('render-spec')).toBeFalsy(); + }); +}); diff --git a/libs/chat/src/lib/a2ui/surface.component.ts b/libs/chat/src/lib/a2ui/surface.component.ts index c2bf0c05..ba9b92ba 100644 --- a/libs/chat/src/lib/a2ui/surface.component.ts +++ b/libs/chat/src/lib/a2ui/surface.component.ts @@ -1,17 +1,27 @@ // SPDX-License-Identifier: MIT import { - Component, computed, input, output, ChangeDetectionStrategy, + Component, computed, input, output, ChangeDetectionStrategy, Type, } from '@angular/core'; +import { NgComponentOutlet } from '@angular/common'; import type { A2uiSurface, A2uiActionMessage } from '@ngaf/a2ui'; import { RenderSpecComponent, toRenderRegistry } from '@ngaf/render'; import type { ViewRegistry, RenderEvent } from '@ngaf/render'; import { surfaceToSpec } from './surface-to-spec'; import { buildA2uiActionMessage } from './build-action-message'; +import { A2uiSlotDirective } from './a2ui-slot.directive'; +import { A2uiDefaultFallbackComponent } from './a2ui-default-fallback.component'; +import type { A2uiSurfaceState } from './surface-store'; +import type { A2uiViews } from './views'; @Component({ selector: 'a2ui-surface', standalone: true, - imports: [RenderSpecComponent], + imports: [ + RenderSpecComponent, + A2uiSlotDirective, + A2uiDefaultFallbackComponent, + NgComponentOutlet, + ], changeDetection: ChangeDetectionStrategy.OnPush, // The host applies the agent-set v1 styles (`beginRendering.styles`) // as inline CSS custom properties + font-family. Catalog components @@ -21,14 +31,22 @@ import { buildA2uiActionMessage } from './build-action-message'; '[style.--a2ui-primary]': 'primaryColor()', '[style.font-family]': 'fontFamily()', }, - // Token defaults live in chat.css at :root so :root[data-theme=...] - // preset overrides take precedence (specificity: same selector + a - // matching attribute beats bare :root by ~1 specificity unit). The - // host bindings above override --a2ui-primary per-surface for the - // agent's beginRendering.styles.primaryColor (highest specificity: - // inline style). template: ` - @if (spec(); as s) { + @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) { `; + * kept for backwards compatibility. + * + * When both inputs are set, `state` takes priority for rendering AND for + * action-message construction; `surface` is only consulted when `state` + * is unset. + */ export class A2uiSurfaceComponent { - readonly surface = input.required(); - readonly catalog = input.required(); + /** Wire-format surface (legacy path — kept for backwards compat). */ + readonly surface = input(); + /** Chat-side surface state with per-component readiness. When set, + * this takes priority and the progressive renderer is used. */ + readonly state = input(); + readonly catalog = input.required(); readonly handlers = input) => unknown | Promise>>({}); + /** Optional top-level placeholder when the surface has no components + * yet. Defaults to A2uiDefaultFallbackComponent. */ + readonly surfaceFallback = input | undefined>(undefined); readonly events = output(); readonly action = output(); @@ -49,27 +85,51 @@ export class A2uiSurfaceComponent { * Returns null when unset so the host binding doesn't override the * consumer's `:root`-level `--a2ui-primary` default. */ readonly primaryColor = computed(() => - this.surface().styles?.primaryColor ?? null + (this.state()?.surface ?? this.surface())?.styles?.primaryColor ?? null ); /** Agent-set font family from `beginRendering.styles.font`. Returns * null when unset so the host doesn't override consumer fonts. */ readonly fontFamily = computed(() => - this.surface().styles?.font ?? null + (this.state()?.surface ?? this.surface())?.styles?.font ?? null ); + /** Roots from the surface state — components whose ids appear as + * children of no other component. The wire spec includes + * `beginRendering.root` as the single root; that path stays usable + * but we keep the renderer permissive in case future surfaces emit + * multiple top-level components. + * + * Conservative: returns only the first key from componentViews + * insertion order. The wire format's beginRendering.root carries the + * true root id; plumbing it through A2uiSurfaceState is a follow-up. */ + readonly rootIds = computed(() => { + const st = this.state(); + if (!st) return []; + return [...st.componentViews.keys()].slice(0, 1); + }); + + // ---- Legacy path (no state) ---- /** Convert the A2UI surface to a json-render Spec for rendering. */ - readonly spec = computed(() => surfaceToSpec(this.surface())); + readonly spec = computed(() => { + const surf = this.surface(); + return surf ? surfaceToSpec(surf) : null; + }); /** Convert ViewRegistry to AngularRegistry for RenderSpecComponent. */ - readonly registry = computed(() => toRenderRegistry(this.catalog())); + readonly registry = computed(() => toRenderRegistry(this.catalog() as ViewRegistry)); /** Merge built-in A2UI handlers with consumer-provided handlers. */ readonly internalHandlers = computed(() => { const consumerHandlers = this.handlers(); return { 'a2ui:event': (params: Record) => { - const message = buildA2uiActionMessage(params, this.surface()); + // Prefer state.surface so action messages reference the surface + // we actually rendered, even if a legacy `[surface]` input with + // a mismatched id is also bound. + const surf = this.state()?.surface ?? this.surface(); + if (!surf) return undefined; + const message = buildA2uiActionMessage(params, surf); this.action.emit(message); return message; }, diff --git a/libs/chat/src/lib/compositions/chat/chat.component.ts b/libs/chat/src/lib/compositions/chat/chat.component.ts index 75e1abe9..da776130 100644 --- a/libs/chat/src/lib/compositions/chat/chat.component.ts +++ b/libs/chat/src/lib/compositions/chat/chat.component.ts @@ -195,6 +195,7 @@ export function isPinned( @for (entry of classified.a2uiSurfaces() | keyvalue; track entry.key) { ; readonly elementStates: Signal>; readonly a2uiSurfaces: Signal>; + readonly a2uiSurfaceStates: Signal>; readonly streaming: Signal; readonly errors: Signal; dispose(): void; @@ -39,6 +40,7 @@ export function createContentClassifier(): ContentClassifier { let a2uiParser: A2uiMessageParser | null = null; let a2uiStore: A2uiSurfaceStore | null = null; const a2uiSurfacesSignal = signal>(new Map()); + const a2uiSurfaceStatesSignal = signal>(new Map()); /** * Decide the content type from the first non-whitespace character. @@ -125,6 +127,7 @@ export function createContentClassifier(): ContentClassifier { a2uiParser = null; a2uiStore = null; a2uiSurfacesSignal.set(new Map()); + a2uiSurfaceStatesSignal.set(new Map()); } function update(content: string): void { @@ -180,6 +183,7 @@ export function createContentClassifier(): ContentClassifier { const msgs = a2uiParser.push(a2uiContent); for (const msg of msgs) a2uiStore.apply(msg); a2uiSurfacesSignal.set(a2uiStore.surfaces()); + a2uiSurfaceStatesSignal.set(a2uiStore.surfaceStates()); } catch (err) { errorsSignal.update(prev => [...prev, err instanceof Error ? err.message : String(err)]); } @@ -212,6 +216,7 @@ export function createContentClassifier(): ContentClassifier { const msgs = a2uiParser.push(delta); for (const msg of msgs) a2uiStore.apply(msg); a2uiSurfacesSignal.set(a2uiStore.surfaces()); + a2uiSurfaceStatesSignal.set(a2uiStore.surfaceStates()); } catch (err) { errorsSignal.update(prev => [...prev, err instanceof Error ? err.message : String(err)]); } @@ -237,6 +242,7 @@ export function createContentClassifier(): ContentClassifier { spec: specSignal.asReadonly(), elementStates: elementStatesSignal.asReadonly(), a2uiSurfaces: a2uiSurfacesSignal.asReadonly(), + a2uiSurfaceStates: a2uiSurfaceStatesSignal.asReadonly(), streaming: streamingSignal.asReadonly(), errors: errorsSignal.asReadonly(), dispose,