Skip to content

Commit cf218aa

Browse files
bloveclaudegithub-actions[bot]
authored
feat(chat): render A2UI surface theme chrome (agentDisplayName + iconUrl) (#823)
* feat(chat): render A2UI surface theme chrome (agentDisplayName + iconUrl) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * chore(docs): regenerate api docs --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
1 parent 7038b05 commit cf218aa

5 files changed

Lines changed: 128 additions & 2 deletions

File tree

apps/website/content/docs/chat/a2ui/overview.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -194,7 +194,7 @@ Catalog components receive resolved props as Angular inputs from the render engi
194194

195195
## Theming
196196

197-
`createSurface.theme` carries agent-supplied presentation hints. `primaryColor` flows to `<a2ui-surface>` 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.
197+
`createSurface.theme` carries agent-supplied presentation hints. `primaryColor` flows to `<a2ui-surface>` 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, `<a2ui-surface>` 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.
198198

199199
## Local Handlers
200200

apps/website/content/docs/chat/a2ui/surface-component.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,7 @@ When a component's `children` field is a template (`{ path, componentId }`), the
6161

6262
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.
6363

64-
**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.
64+
**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.
6565

6666
## Usage Outside ChatComponent
6767

apps/website/content/docs/chat/api/api-docs.json

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1088,6 +1088,12 @@
10881088
"description": "",
10891089
"optional": false
10901090
},
1091+
{
1092+
"name": "agentDisplayName",
1093+
"type": "Signal<string | null>",
1094+
"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).",
1095+
"optional": false
1096+
},
10911097
{
10921098
"name": "catalog",
10931099
"type": "InputSignal<Readonly<Record<string, Type<unknown> | RenderViewEntry>> | Readonly<Record<string, Type<unknown> | RenderViewEntry>>>",
@@ -1106,6 +1112,12 @@
11061112
"description": "",
11071113
"optional": false
11081114
},
1115+
{
1116+
"name": "iconUrl",
1117+
"type": "Signal<string | null>",
1118+
"description": "",
1119+
"optional": false
1120+
},
11091121
{
11101122
"name": "internalHandlers",
11111123
"type": "Signal<object>",

libs/chat/src/lib/a2ui/surface.component.spec.ts

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -196,6 +196,84 @@ describe('A2uiSurfaceComponent — validation gate + live context (Phase 3)', ()
196196
});
197197
});
198198

199+
describe('A2uiSurfaceComponent — surface theme chrome', () => {
200+
beforeEach(() => TestBed.configureTestingModule({ imports: [A2uiSurfaceComponent] }));
201+
202+
function makeThemedState(theme?: Record<string, unknown>) {
203+
const store = createA2uiSurfaceStore();
204+
store.apply({ version: 'v0.9', createSurface: {
205+
surfaceId: 's1', catalogId: 'basic', ...(theme ? { theme } : {}),
206+
} } as never);
207+
store.apply({ version: 'v0.9', updateComponents: {
208+
surfaceId: 's1',
209+
components: [{ id: 'root', component: 'Text', text: 'Hello' }],
210+
} } as never);
211+
return store.surfaceState('s1')()!;
212+
}
213+
214+
it('renders agentDisplayName and iconUrl as a header above the surface', () => {
215+
const fx = TestBed.createComponent(A2uiSurfaceComponent);
216+
fx.componentRef.setInput('state', makeThemedState({
217+
agentDisplayName: 'Flight Bot', iconUrl: 'https://x/icon.png',
218+
}));
219+
fx.componentRef.setInput('catalog', a2uiBasicCatalog());
220+
fx.detectChanges();
221+
222+
const chrome = fx.nativeElement.querySelector('.a2ui-surface-chrome');
223+
expect(chrome).toBeTruthy();
224+
expect(chrome.textContent).toContain('Flight Bot');
225+
const img = chrome.querySelector('img');
226+
expect(img).toBeTruthy();
227+
expect(img.getAttribute('src')).toBe('https://x/icon.png');
228+
expect(img.getAttribute('referrerpolicy')).toBe('no-referrer');
229+
expect(img.getAttribute('alt')).toBe('');
230+
});
231+
232+
it('renders the name alone when only agentDisplayName is set', () => {
233+
const fx = TestBed.createComponent(A2uiSurfaceComponent);
234+
fx.componentRef.setInput('state', makeThemedState({ agentDisplayName: 'Flight Bot' }));
235+
fx.componentRef.setInput('catalog', a2uiBasicCatalog());
236+
fx.detectChanges();
237+
238+
const chrome = fx.nativeElement.querySelector('.a2ui-surface-chrome');
239+
expect(chrome).toBeTruthy();
240+
expect(chrome.textContent).toContain('Flight Bot');
241+
expect(chrome.querySelector('img')).toBeNull();
242+
});
243+
244+
it('renders no chrome element at all for a themeless surface', () => {
245+
const fx = TestBed.createComponent(A2uiSurfaceComponent);
246+
fx.componentRef.setInput('state', makeThemedState());
247+
fx.componentRef.setInput('catalog', a2uiBasicCatalog());
248+
fx.detectChanges();
249+
250+
expect(fx.nativeElement.querySelector('.a2ui-surface-chrome')).toBeNull();
251+
// The surface itself still renders.
252+
expect(fx.nativeElement.textContent).toContain('Hello');
253+
});
254+
255+
it('renders no chrome when the theme only carries primaryColor', () => {
256+
const fx = TestBed.createComponent(A2uiSurfaceComponent);
257+
fx.componentRef.setInput('state', makeThemedState({ primaryColor: '#ff0066' }));
258+
fx.componentRef.setInput('catalog', a2uiBasicCatalog());
259+
fx.detectChanges();
260+
261+
expect(fx.nativeElement.querySelector('.a2ui-surface-chrome')).toBeNull();
262+
});
263+
264+
it('still applies primaryColor as the --a2ui-primary host style', () => {
265+
const fx = TestBed.createComponent(A2uiSurfaceComponent);
266+
fx.componentRef.setInput('state', makeThemedState({
267+
primaryColor: '#ff0066', agentDisplayName: 'Flight Bot',
268+
}));
269+
fx.componentRef.setInput('catalog', a2uiBasicCatalog());
270+
fx.detectChanges();
271+
272+
expect(fx.nativeElement.style.getPropertyValue('--a2ui-primary')).toBe('#ff0066');
273+
expect(fx.nativeElement.querySelector('.a2ui-surface-chrome')).toBeTruthy();
274+
});
275+
});
276+
199277
describe('A2uiSurfaceComponent — sendDataModel live round-trip (Phase 4)', () => {
200278
beforeEach(() => TestBed.configureTestingModule({ imports: [A2uiSurfaceComponent] }));
201279

libs/chat/src/lib/a2ui/surface.component.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,33 @@ import type { A2uiViews } from './views';
3030
host: {
3131
'[style.--a2ui-primary]': 'primaryColor()',
3232
},
33+
styles: `
34+
.a2ui-surface-chrome {
35+
display: flex;
36+
align-items: center;
37+
gap: var(--a2ui-spacing-2);
38+
margin-bottom: var(--a2ui-spacing-2);
39+
color: var(--a2ui-label);
40+
font-size: var(--a2ui-typography-label-size);
41+
}
42+
.a2ui-surface-chrome img {
43+
width: 16px;
44+
height: 16px;
45+
border-radius: 50%;
46+
object-fit: cover;
47+
}
48+
`,
3349
template: `
50+
@if (agentDisplayName() || iconUrl()) {
51+
<div class="a2ui-surface-chrome">
52+
@if (iconUrl(); as icon) {
53+
<img [src]="icon" alt="" referrerpolicy="no-referrer" />
54+
}
55+
@if (agentDisplayName(); as name) {
56+
<span>{{ name }}</span>
57+
}
58+
</div>
59+
}
3460
@if (spec(); as s) {
3561
<render-spec
3662
[spec]="s"
@@ -115,6 +141,16 @@ export class A2uiSurfaceComponent {
115141
(this.state()?.surface ?? this.surface())?.theme?.primaryColor ?? null
116142
);
117143

144+
/** Agent identity chrome from `createSurface.theme`. When neither
145+
* `agentDisplayName` nor `iconUrl` is set, no header renders at all
146+
* (zero layout impact for themeless surfaces — the common case). */
147+
protected readonly agentDisplayName = computed<string | null>(() =>
148+
(this.state()?.surface ?? this.surface())?.theme?.agentDisplayName ?? null
149+
);
150+
protected readonly iconUrl = computed<string | null>(() =>
151+
(this.state()?.surface ?? this.surface())?.theme?.iconUrl ?? null
152+
);
153+
118154
/** Roots from the surface state. The v0.9 wire contract reserves the
119155
* component id `root` as the single tree root; we keep the renderer
120156
* permissive in case future surfaces emit multiple top-level

0 commit comments

Comments
 (0)