From e7278b57ef24d623daba7205df7bed57a63e8ac5 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Mon, 17 Aug 2026 12:56:05 -0700 Subject: [PATCH 1/2] feat(chat): render A2UI surface theme chrome (agentDisplayName + iconUrl) Co-Authored-By: Claude Fable 5 --- .../content/docs/chat/a2ui/overview.mdx | 2 +- .../docs/chat/a2ui/surface-component.mdx | 2 +- .../src/lib/a2ui/surface.component.spec.ts | 78 +++++++++++++++++++ libs/chat/src/lib/a2ui/surface.component.ts | 36 +++++++++ 4 files changed, 116 insertions(+), 2 deletions(-) diff --git a/apps/website/content/docs/chat/a2ui/overview.mdx b/apps/website/content/docs/chat/a2ui/overview.mdx index 05d86672b..e45df50b8 100644 --- a/apps/website/content/docs/chat/a2ui/overview.mdx +++ b/apps/website/content/docs/chat/a2ui/overview.mdx @@ -194,7 +194,7 @@ Catalog components receive resolved props as Angular inputs from the render engi ## Theming -`createSurface.theme` carries agent-supplied presentation hints. `primaryColor` flows to `` as the `--a2ui-primary` CSS custom property, which catalog components consume for accents (buttons, sliders, focus rings). `iconUrl` and `agentDisplayName` identify the agent that owns the surface. +`createSurface.theme` carries agent-supplied presentation hints. `primaryColor` flows to `` as the `--a2ui-primary` CSS custom property, which catalog components consume for accents (buttons, sliders, focus rings). `iconUrl` and `agentDisplayName` identify the agent that owns the surface: when either is set, `` renders a small identity header (a 16px round icon and the display name in muted label text) above the surface. Themeless surfaces render no header. ## Local Handlers diff --git a/apps/website/content/docs/chat/a2ui/surface-component.mdx b/apps/website/content/docs/chat/a2ui/surface-component.mdx index d8e8b53a5..8b65ba654 100644 --- a/apps/website/content/docs/chat/a2ui/surface-component.mdx +++ b/apps/website/content/docs/chat/a2ui/surface-component.mdx @@ -61,7 +61,7 @@ When a component's `children` field is a template (`{ path, componentId }`), the The final `Spec` is passed to `RenderSpecComponent` along with the `ViewRegistry` (converted to an Angular registry via `toRenderRegistry`). Rendering is fully reactive — when the surface signal updates, the spec recomputes and only affected elements re-render. -**Theming.** The component host applies the agent-set surface theme from `createSurface.theme`: `primaryColor` becomes the `--a2ui-primary` CSS custom property, which catalog components consume for accents. When the agent sets no theme, your `:root`-level `--a2ui-primary` default wins. +**Theming.** The component host applies the agent-set surface theme from `createSurface.theme`: `primaryColor` becomes the `--a2ui-primary` CSS custom property, which catalog components consume for accents. When the agent sets no theme, your `:root`-level `--a2ui-primary` default wins. If the theme carries `agentDisplayName` and/or `iconUrl`, the component also renders a small identity header above the surface — a 16px round icon and the display name in muted label text. Surfaces without those fields render no header. ## Usage Outside ChatComponent diff --git a/libs/chat/src/lib/a2ui/surface.component.spec.ts b/libs/chat/src/lib/a2ui/surface.component.spec.ts index bd2271aed..efae4a08e 100644 --- a/libs/chat/src/lib/a2ui/surface.component.spec.ts +++ b/libs/chat/src/lib/a2ui/surface.component.spec.ts @@ -196,6 +196,84 @@ describe('A2uiSurfaceComponent — validation gate + live context (Phase 3)', () }); }); +describe('A2uiSurfaceComponent — surface theme chrome', () => { + beforeEach(() => TestBed.configureTestingModule({ imports: [A2uiSurfaceComponent] })); + + function makeThemedState(theme?: Record) { + const store = createA2uiSurfaceStore(); + store.apply({ version: 'v0.9', createSurface: { + surfaceId: 's1', catalogId: 'basic', ...(theme ? { theme } : {}), + } } as never); + store.apply({ version: 'v0.9', updateComponents: { + surfaceId: 's1', + components: [{ id: 'root', component: 'Text', text: 'Hello' }], + } } as never); + return store.surfaceState('s1')()!; + } + + it('renders agentDisplayName and iconUrl as a header above the surface', () => { + const fx = TestBed.createComponent(A2uiSurfaceComponent); + fx.componentRef.setInput('state', makeThemedState({ + agentDisplayName: 'Flight Bot', iconUrl: 'https://x/icon.png', + })); + fx.componentRef.setInput('catalog', a2uiBasicCatalog()); + fx.detectChanges(); + + const chrome = fx.nativeElement.querySelector('.a2ui-surface-chrome'); + expect(chrome).toBeTruthy(); + expect(chrome.textContent).toContain('Flight Bot'); + const img = chrome.querySelector('img'); + expect(img).toBeTruthy(); + expect(img.getAttribute('src')).toBe('https://x/icon.png'); + expect(img.getAttribute('referrerpolicy')).toBe('no-referrer'); + expect(img.getAttribute('alt')).toBe(''); + }); + + it('renders the name alone when only agentDisplayName is set', () => { + const fx = TestBed.createComponent(A2uiSurfaceComponent); + fx.componentRef.setInput('state', makeThemedState({ agentDisplayName: 'Flight Bot' })); + fx.componentRef.setInput('catalog', a2uiBasicCatalog()); + fx.detectChanges(); + + const chrome = fx.nativeElement.querySelector('.a2ui-surface-chrome'); + expect(chrome).toBeTruthy(); + expect(chrome.textContent).toContain('Flight Bot'); + expect(chrome.querySelector('img')).toBeNull(); + }); + + it('renders no chrome element at all for a themeless surface', () => { + const fx = TestBed.createComponent(A2uiSurfaceComponent); + fx.componentRef.setInput('state', makeThemedState()); + fx.componentRef.setInput('catalog', a2uiBasicCatalog()); + fx.detectChanges(); + + expect(fx.nativeElement.querySelector('.a2ui-surface-chrome')).toBeNull(); + // The surface itself still renders. + expect(fx.nativeElement.textContent).toContain('Hello'); + }); + + it('renders no chrome when the theme only carries primaryColor', () => { + const fx = TestBed.createComponent(A2uiSurfaceComponent); + fx.componentRef.setInput('state', makeThemedState({ primaryColor: '#ff0066' })); + fx.componentRef.setInput('catalog', a2uiBasicCatalog()); + fx.detectChanges(); + + expect(fx.nativeElement.querySelector('.a2ui-surface-chrome')).toBeNull(); + }); + + it('still applies primaryColor as the --a2ui-primary host style', () => { + const fx = TestBed.createComponent(A2uiSurfaceComponent); + fx.componentRef.setInput('state', makeThemedState({ + primaryColor: '#ff0066', agentDisplayName: 'Flight Bot', + })); + fx.componentRef.setInput('catalog', a2uiBasicCatalog()); + fx.detectChanges(); + + expect(fx.nativeElement.style.getPropertyValue('--a2ui-primary')).toBe('#ff0066'); + expect(fx.nativeElement.querySelector('.a2ui-surface-chrome')).toBeTruthy(); + }); +}); + describe('A2uiSurfaceComponent — sendDataModel live round-trip (Phase 4)', () => { beforeEach(() => TestBed.configureTestingModule({ imports: [A2uiSurfaceComponent] })); diff --git a/libs/chat/src/lib/a2ui/surface.component.ts b/libs/chat/src/lib/a2ui/surface.component.ts index 218e461f3..88230333d 100644 --- a/libs/chat/src/lib/a2ui/surface.component.ts +++ b/libs/chat/src/lib/a2ui/surface.component.ts @@ -30,7 +30,33 @@ import type { A2uiViews } from './views'; host: { '[style.--a2ui-primary]': 'primaryColor()', }, + styles: ` + .a2ui-surface-chrome { + display: flex; + align-items: center; + gap: var(--a2ui-spacing-2); + margin-bottom: var(--a2ui-spacing-2); + color: var(--a2ui-label); + font-size: var(--a2ui-typography-label-size); + } + .a2ui-surface-chrome img { + width: 16px; + height: 16px; + border-radius: 50%; + object-fit: cover; + } + `, template: ` + @if (agentDisplayName() || iconUrl()) { +
+ @if (iconUrl(); as icon) { + + } + @if (agentDisplayName(); as name) { + {{ name }} + } +
+ } @if (spec(); as s) { (() => + (this.state()?.surface ?? this.surface())?.theme?.agentDisplayName ?? null + ); + protected readonly iconUrl = computed(() => + (this.state()?.surface ?? this.surface())?.theme?.iconUrl ?? null + ); + /** Roots from the surface state. The v0.9 wire contract reserves the * component id `root` as the single tree root; we keep the renderer * permissive in case future surfaces emit multiple top-level From ddaa5350bb5b2953aea29fa784382b6c8c709982 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 17 Aug 2026 20:03:47 +0000 Subject: [PATCH 2/2] chore(docs): regenerate api docs --- apps/website/content/docs/chat/api/api-docs.json | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/apps/website/content/docs/chat/api/api-docs.json b/apps/website/content/docs/chat/api/api-docs.json index db1ae3566..e0e57ab7d 100644 --- a/apps/website/content/docs/chat/api/api-docs.json +++ b/apps/website/content/docs/chat/api/api-docs.json @@ -1088,6 +1088,12 @@ "description": "", "optional": false }, + { + "name": "agentDisplayName", + "type": "Signal", + "description": "Agent identity chrome from `createSurface.theme`. When neither\n`agentDisplayName` nor `iconUrl` is set, no header renders at all\n(zero layout impact for themeless surfaces — the common case).", + "optional": false + }, { "name": "catalog", "type": "InputSignal | RenderViewEntry>> | Readonly | RenderViewEntry>>>", @@ -1106,6 +1112,12 @@ "description": "", "optional": false }, + { + "name": "iconUrl", + "type": "Signal", + "description": "", + "optional": false + }, { "name": "internalHandlers", "type": "Signal",