From 3d882117173422817ca4ab0383250d4c92d31bbd Mon Sep 17 00:00:00 2001 From: Brian Love Date: Wed, 13 May 2026 08:40:19 -0700 Subject: [PATCH 1/8] feat(chat): A2uiDefaultFallbackComponent primitive Internal skeleton component mounted by when a component's bindings are unresolved (and the catalog entry omits a fallback). Three shimmer rows, 'Building UI' label, themed via chat tokens. --- .../a2ui-default-fallback.component.spec.ts | 23 +++++++ .../a2ui/a2ui-default-fallback.component.ts | 64 +++++++++++++++++++ 2 files changed, 87 insertions(+) create mode 100644 libs/chat/src/lib/a2ui/a2ui-default-fallback.component.spec.ts create mode 100644 libs/chat/src/lib/a2ui/a2ui-default-fallback.component.ts 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 {} From 15db310a10820a7f7d3f588197aedb186c4004a6 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Wed, 13 May 2026 08:41:02 -0700 Subject: [PATCH 2/8] feat(chat): a2uiSlot recursive structural directive Internal directive used by to render a single A2uiComponentView. Mounts views[type].fallback (or the lib-default fallback) while !ready, then the real component once ready=true. A monotonic gate stops re-checking ready after the real mounts; later updates flow as ComponentRef.setInput() calls. --- .../src/lib/a2ui/a2ui-slot.directive.spec.ts | 88 +++++++++++++++++++ libs/chat/src/lib/a2ui/a2ui-slot.directive.ts | 80 +++++++++++++++++ 2 files changed, 168 insertions(+) create mode 100644 libs/chat/src/lib/a2ui/a2ui-slot.directive.spec.ts create mode 100644 libs/chat/src/lib/a2ui/a2ui-slot.directive.ts 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..5a764bcb --- /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: 't-real', changeDetection: ChangeDetectionStrategy.OnPush, + template: 'REAL:{{ label() ?? "" }}', +}) +class RealCmp { readonly label = input(); } + +@Component({ + standalone: true, selector: 't-fb', 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..ad710e5f --- /dev/null +++ b/libs/chat/src/lib/a2ui/a2ui-slot.directive.ts @@ -0,0 +1,80 @@ +// SPDX-License-Identifier: MIT +import { + Directive, Input, TemplateRef, ViewContainerRef, ComponentRef, Type, +} 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; + + constructor( + private readonly _tpl: TemplateRef, + private readonly vcr: 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). + } + } + } +} From d6b91c494dc5d12956ce3d85084d96161f2f905b Mon Sep 17 00:00:00 2001 From: Brian Love Date: Wed, 13 May 2026 08:42:21 -0700 Subject: [PATCH 3/8] feat(chat): progressive per-component rendering MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When given the new state input (A2uiSurfaceState), the surface walks componentViews via a2uiSlot — each node mounts its fallback while !ready, then the real component once ready, with a monotonic gate that prevents flicker. The legacy wire-format surface input keeps working via the existing render-spec path. --- .../src/lib/a2ui/surface.component.spec.ts | 56 +++++++++++++ libs/chat/src/lib/a2ui/surface.component.ts | 78 +++++++++++++++---- 2 files changed, 118 insertions(+), 16 deletions(-) create mode 100644 libs/chat/src/lib/a2ui/surface.component.spec.ts 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..0aed1520 --- /dev/null +++ b/libs/chat/src/lib/a2ui/surface.component.spec.ts @@ -0,0 +1,56 @@ +// 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: 't-real', template: '', changeDetection: ChangeDetectionStrategy.OnPush }) +class RealCmp {} +@Component({ standalone: true, selector: 't-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(); + }); +}); diff --git a/libs/chat/src/lib/a2ui/surface.component.ts b/libs/chat/src/lib/a2ui/surface.component.ts index c2bf0c05..5f230988 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) { (); - 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 +74,48 @@ 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()); + const surf = this.surface(); + if (!surf) return undefined; + const message = buildA2uiActionMessage(params, surf); this.action.emit(message); return message; }, From 7b9adde4a910cb432ee81933d4e246623f65a523 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Wed, 13 May 2026 08:44:45 -0700 Subject: [PATCH 4/8] fix(chat): drop unused TemplateRef injection in a2uiSlot MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Strict TS noUnusedLocals flags it under the production build. The structural directive only needs ViewContainerRef — TemplateRef was declared but never read. --- libs/chat/src/lib/a2ui/a2ui-slot.directive.ts | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/libs/chat/src/lib/a2ui/a2ui-slot.directive.ts b/libs/chat/src/lib/a2ui/a2ui-slot.directive.ts index ad710e5f..62c82859 100644 --- a/libs/chat/src/lib/a2ui/a2ui-slot.directive.ts +++ b/libs/chat/src/lib/a2ui/a2ui-slot.directive.ts @@ -1,6 +1,6 @@ // SPDX-License-Identifier: MIT import { - Directive, Input, TemplateRef, ViewContainerRef, ComponentRef, Type, + Directive, Input, ViewContainerRef, ComponentRef, Type, } from '@angular/core'; import type { A2uiComponentView } from './component-view'; import type { A2uiViews } from './views'; @@ -21,10 +21,7 @@ export class A2uiSlotDirective { private mountedReal = false; private ref: ComponentRef | null = null; - constructor( - private readonly _tpl: TemplateRef, - private readonly vcr: ViewContainerRef, - ) {} + constructor(private readonly vcr: ViewContainerRef) {} @Input({ required: true }) set a2uiSlot(view: A2uiComponentView) { this.view = view; From b56301e8c4888c221a0a6651d843603b96f531e8 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Wed, 13 May 2026 08:44:54 -0700 Subject: [PATCH 5/8] feat(chat): wire state input from classifier The chat composition now passes [state] to so the progressive per-component renderer activates. ContentClassifier exposes a new a2uiSurfaceStates signal (parallel to a2uiSurfaces) sourced from the live A2uiSurfaceStore. The bubble-level branch was removed in an earlier commit; the surface now owns its empty-state via the internal A2uiDefaultFallbackComponent. --- libs/chat/src/lib/compositions/chat/chat.component.ts | 1 + libs/chat/src/lib/streaming/content-classifier.ts | 8 +++++++- 2 files changed, 8 insertions(+), 1 deletion(-) 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, From d60cdd4d6324dda17e3bb0745770c461f0d059bc Mon Sep 17 00:00:00 2001 From: Brian Love Date: Wed, 13 May 2026 09:37:42 -0700 Subject: [PATCH 6/8] fix(chat): PR B review follow-ups (action-handler priority, dual-mode test, JSDoc) - internalHandlers prefers state.surface over the legacy surface input so action messages always reference the rendered surface, even when both inputs are set with mismatched surfaceIds. - Adds a surface.component.spec test asserting state takes priority over surface for rendering. - Adds class-level JSDoc on A2uiSurfaceComponent explaining the dual-input precedence contract. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../src/lib/a2ui/surface.component.spec.ts | 19 +++++++++++++++++++ libs/chat/src/lib/a2ui/surface.component.ts | 16 +++++++++++++++- 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/libs/chat/src/lib/a2ui/surface.component.spec.ts b/libs/chat/src/lib/a2ui/surface.component.spec.ts index 0aed1520..131f1773 100644 --- a/libs/chat/src/lib/a2ui/surface.component.spec.ts +++ b/libs/chat/src/lib/a2ui/surface.component.spec.ts @@ -53,4 +53,23 @@ describe('A2uiSurfaceComponent — progressive rendering', () => { 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 5f230988..ba9b92ba 100644 --- a/libs/chat/src/lib/a2ui/surface.component.ts +++ b/libs/chat/src/lib/a2ui/surface.component.ts @@ -56,6 +56,17 @@ import type { A2uiViews } from './views'; } `, }) +/** + * Renders an A2UI surface. Supports two input shapes: + * - `state` (preferred): chat-side `A2uiSurfaceState` driving progressive + * per-component rendering via `a2uiSlot` + readiness gates. + * - `surface` (legacy): wire-format `A2uiSurface` fed into ``; + * 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 { /** Wire-format surface (legacy path — kept for backwards compat). */ readonly surface = input(); @@ -113,7 +124,10 @@ export class A2uiSurfaceComponent { const consumerHandlers = this.handlers(); return { 'a2ui:event': (params: Record) => { - const surf = 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); From 2d8468ddf8d827e9aa17d1ae569950c4942e7192 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Wed, 13 May 2026 10:40:12 -0700 Subject: [PATCH 7/8] fix(chat): PR B lint compliance (selector prefixes, inject() usage) - Rename test-only components in a2ui-slot.directive.spec.ts and surface.component.spec.ts to use 'a2ui-test-*' selectors per @angular-eslint/component-selector. - Replace constructor parameter injection with inject() in a2ui-slot.directive.ts per @angular-eslint/prefer-inject. --- libs/chat/src/lib/a2ui/a2ui-slot.directive.spec.ts | 4 ++-- libs/chat/src/lib/a2ui/a2ui-slot.directive.ts | 5 ++--- libs/chat/src/lib/a2ui/surface.component.spec.ts | 4 ++-- 3 files changed, 6 insertions(+), 7 deletions(-) diff --git a/libs/chat/src/lib/a2ui/a2ui-slot.directive.spec.ts b/libs/chat/src/lib/a2ui/a2ui-slot.directive.spec.ts index 5a764bcb..4ced51a4 100644 --- a/libs/chat/src/lib/a2ui/a2ui-slot.directive.spec.ts +++ b/libs/chat/src/lib/a2ui/a2ui-slot.directive.spec.ts @@ -7,13 +7,13 @@ import type { A2uiComponentView } from './component-view'; import type { A2uiViews } from './views'; @Component({ - standalone: true, selector: 't-real', changeDetection: ChangeDetectionStrategy.OnPush, + standalone: true, selector: 'a2ui-test-real', changeDetection: ChangeDetectionStrategy.OnPush, template: 'REAL:{{ label() ?? "" }}', }) class RealCmp { readonly label = input(); } @Component({ - standalone: true, selector: 't-fb', changeDetection: ChangeDetectionStrategy.OnPush, + standalone: true, selector: 'a2ui-test-fallback', changeDetection: ChangeDetectionStrategy.OnPush, template: 'FB', }) class FallbackCmp {} diff --git a/libs/chat/src/lib/a2ui/a2ui-slot.directive.ts b/libs/chat/src/lib/a2ui/a2ui-slot.directive.ts index 62c82859..40f4e162 100644 --- a/libs/chat/src/lib/a2ui/a2ui-slot.directive.ts +++ b/libs/chat/src/lib/a2ui/a2ui-slot.directive.ts @@ -1,6 +1,6 @@ // SPDX-License-Identifier: MIT import { - Directive, Input, ViewContainerRef, ComponentRef, Type, + Directive, Input, ViewContainerRef, ComponentRef, Type, inject, } from '@angular/core'; import type { A2uiComponentView } from './component-view'; import type { A2uiViews } from './views'; @@ -20,8 +20,7 @@ export class A2uiSlotDirective { private views: A2uiViews = {}; private mountedReal = false; private ref: ComponentRef | null = null; - - constructor(private readonly vcr: ViewContainerRef) {} + private readonly vcr = inject(ViewContainerRef); @Input({ required: true }) set a2uiSlot(view: A2uiComponentView) { this.view = view; diff --git a/libs/chat/src/lib/a2ui/surface.component.spec.ts b/libs/chat/src/lib/a2ui/surface.component.spec.ts index 131f1773..3da60db4 100644 --- a/libs/chat/src/lib/a2ui/surface.component.spec.ts +++ b/libs/chat/src/lib/a2ui/surface.component.spec.ts @@ -6,9 +6,9 @@ import { A2uiSurfaceComponent } from './surface.component'; import type { A2uiSurfaceState } from './surface-store'; import type { A2uiViews } from './views'; -@Component({ standalone: true, selector: 't-real', template: '', changeDetection: ChangeDetectionStrategy.OnPush }) +@Component({ standalone: true, selector: 'a2ui-test-real', template: '', changeDetection: ChangeDetectionStrategy.OnPush }) class RealCmp {} -@Component({ standalone: true, selector: 't-fb', template: '', changeDetection: ChangeDetectionStrategy.OnPush }) +@Component({ standalone: true, selector: 'a2ui-test-custom-fb', template: '', changeDetection: ChangeDetectionStrategy.OnPush }) class CustomFallback {} function makeState(componentViews: Map): A2uiSurfaceState { From 732439f445ed69d56c7649239b91a8be3ffed13c Mon Sep 17 00:00:00 2001 From: Brian Love Date: Wed, 13 May 2026 10:57:45 -0700 Subject: [PATCH 8/8] chore: regenerate api-docs for surface progressive rendering Picks up the new state, surfaceFallback, rootIds members on A2uiSurfaceComponent and the a2uiSurfaceStates field on the content classifier. --- .../content/docs/chat/api/api-docs.json | 30 +++++++++++++++++-- 1 file changed, 27 insertions(+), 3 deletions(-) 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>",