Skip to content

Commit 9303e31

Browse files
authored
client: implement reactive surface state with signals (google#903)
* client: implement reactive surface state with signals - Introduces surfacesSignal inside the Angular MessageProcessor to act as a reactive wrapper around Core's Surface map. Angular's new signal-based change detection operates on strict reference equality. During streaming execution, the web core A2uiMessageProcessor mutates the underlying surface models in-place for fast iteration. Because the memory references are unchanged, the Angular UI doesn't know it needs to re-render. By overriding processMessages and shallow-cloning the active surfaces into a brand new Map via a notify() broadcast, we force Angular to observe the mutated data model and reliably trigger UI updates. - Adds @if (child) structural directives to iterative component templates (e.g. Row, Column, List, Modal) to safely render nested children as properties incrementally arrive over the network. * feat(streaming): implement reactive client-side streaming support By default, the Angular client uses the non-streaming API to communicate with the agent. To enable streaming, set the `ENABLE_STREAMIING` env var to `true. ```bash export ENABLE_STREAMING=true npm start -- contact ```
1 parent fdf96b3 commit 9303e31

14 files changed

Lines changed: 259 additions & 87 deletions

File tree

renderers/angular/src/v0_8/components/button.ts

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -29,11 +29,13 @@ import { Renderer } from '../rendering/renderer';
2929
[style]="theme.additionalStyles?.Button"
3030
(click)="handleClick()"
3131
>
32-
<ng-container
33-
a2ui-renderer
34-
[surfaceId]="surfaceId()!"
35-
[component]="child() ?? component().properties.child"
36-
/>
32+
@if (child()) {
33+
<ng-container
34+
a2ui-renderer
35+
[surfaceId]="surfaceId()!"
36+
[component]="child() ?? component().properties.child"
37+
/>
38+
}
3739
</button>
3840
`,
3941
styles: `

renderers/angular/src/v0_8/components/column.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -79,8 +79,10 @@ import { Renderer } from '../rendering/renderer';
7979
`,
8080
template: `
8181
<section [class]="classes()" [style]="theme.additionalStyles?.Column">
82-
@for (child of children() ?? component().properties.children; track child) {
83-
<ng-container a2ui-renderer [surfaceId]="surfaceId()!" [component]="child" />
82+
@for (child of children() ?? component().properties.children; track child?.id ?? child) {
83+
@if (child) {
84+
<ng-container a2ui-renderer [surfaceId]="surfaceId()!" [component]="child" />
85+
}
8486
}
8587
</section>
8688
`,

renderers/angular/src/v0_8/components/list.ts

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -56,10 +56,12 @@ import { Renderer } from '../rendering/renderer';
5656
`,
5757
template: `
5858
<section [class]="theme.components.List" [style]="theme.additionalStyles?.List">
59-
@for (child of children() ?? component().properties.children; track child) {
60-
<div class="a2ui-list-item">
61-
<ng-container a2ui-renderer [surfaceId]="surfaceId()!" [component]="child" />
62-
</div>
59+
@for (child of children() ?? component().properties.children; track child?.id ?? child) {
60+
@if (child) {
61+
<div class="a2ui-list-item">
62+
<ng-container a2ui-renderer [surfaceId]="surfaceId()!" [component]="child" />
63+
</div>
64+
}
6365
}
6466
</section>
6567
`,

renderers/angular/src/v0_8/components/modal.ts

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,13 +24,25 @@ import { Types } from '../types';
2424
imports: [Renderer],
2525
template: `
2626
<div class="a2ui-modal-entry-point" (click)="openModal()">
27-
<ng-container a2ui-renderer [surfaceId]="surfaceId()!" [component]="entryPointChild()" />
27+
@if (entryPointChild()) {
28+
<ng-container
29+
a2ui-renderer
30+
[surfaceId]="surfaceId()!"
31+
[component]="entryPointChild()!"
32+
/>
33+
}
2834
</div>
2935
3036
@if (isOpen()) {
3137
<div [class]="theme.components.Modal.backdrop" (click)="closeModal()">
3238
<div [class]="theme.components.Modal.element" (click)="$event.stopPropagation()">
33-
<ng-container a2ui-renderer [surfaceId]="surfaceId()!" [component]="contentChild()" />
39+
@if (contentChild()) {
40+
<ng-container
41+
a2ui-renderer
42+
[surfaceId]="surfaceId()!"
43+
[component]="contentChild()!"
44+
/>
45+
}
3446
</div>
3547
</div>
3648
}

renderers/angular/src/v0_8/components/multiple-choice.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ import { Types } from '../types';
3131
<select
3232
[class]="theme.components.MultipleChoice.element"
3333
[id]="selectId"
34-
[value]="resolvedSelections()[0] ?? ''"
34+
[value]="resolvedSelections()[0] || ''"
3535
(change)="onChange($event)"
3636
>
3737
@for (option of resolvedOptions(); track option.value) {

renderers/angular/src/v0_8/components/row.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -83,8 +83,10 @@ import { Types } from '../types';
8383
`,
8484
template: `
8585
<section [class]="classes()" [style]="theme.additionalStyles?.Row">
86-
@for (child of children() ?? component().properties.children; track child) {
87-
<ng-container a2ui-renderer [surfaceId]="surfaceId()!" [component]="child" />
86+
@for (child of children() ?? component().properties.children; track child?.id ?? child) {
87+
@if (child) {
88+
<ng-container a2ui-renderer [surfaceId]="surfaceId()!" [component]="child" />
89+
}
8890
}
8991
</section>
9092
`,

renderers/angular/src/v0_8/components/text.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,7 @@ export class Text extends DynamicComponent<Types.TextNode> {
7777
let value = super.resolvePrimitive(this.text());
7878

7979
if (value == null) {
80-
return Promise.resolve('(empty)');
80+
return Promise.resolve('');
8181
}
8282

8383
switch (usageHint) {

renderers/angular/src/v0_8/data/processor.ts

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
* limitations under the License.
1515
*/
1616

17-
import { Injectable } from '@angular/core';
17+
import { Injectable, signal } from '@angular/core';
1818
import { Subject, Observable } from 'rxjs';
1919
import * as WebCore from '@a2ui/web_core/v0_8';
2020

@@ -35,13 +35,28 @@ export class MessageProcessor {
3535

3636
private readonly eventsSubject = new Subject<A2UIClientEvent>();
3737
readonly events: Observable<A2UIClientEvent> = this.eventsSubject.asObservable();
38+
readonly surfacesSignal = signal<ReadonlyMap<string, WebCore.Surface>>(new Map());
3839

3940
constructor() {
4041
this.baseProcessor = new WebCore.A2uiMessageProcessor();
4142
}
4243

44+
private notify() {
45+
// Angular signals (and change detection) are based on reference equality for
46+
// objects. During streaming, the base MessageProcessor updates surfaces in-place.
47+
// By shallow-cloning the surface objects into a new Map, we ensure that
48+
// anything watching surfacesSignal() correctly detects that the data has
49+
// changed, even if only internal properties of a surface were updated.
50+
const clonedSurfaces = new Map<string, WebCore.Surface>();
51+
for (const [id, surface] of this.getSurfaces()) {
52+
clonedSurfaces.set(id, { ...surface });
53+
}
54+
this.surfacesSignal.set(clonedSurfaces);
55+
}
56+
4357
processMessages(messages: Types.ServerToClientMessage[]) {
4458
this.baseProcessor.processMessages(messages as WebCore.ServerToClientMessage[]);
59+
this.notify();
4560
}
4661

4762
dispatch(message: Types.A2UIClientEventMessage): Promise<Types.ServerToClientMessage[]> {
@@ -67,6 +82,7 @@ export class MessageProcessor {
6782

6883
setData(node: Types.AnyComponentNode | null, path: string, value: any, surfaceId: string) {
6984
this.baseProcessor.setData(node as WebCore.AnyComponentNode | null, path, value, surfaceId);
85+
this.notify();
7086
}
7187

7288
resolvePath(path: string, dataContextPath?: string): string {

samples/client/angular/README.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,15 @@ Here are the instructions if you want to do each step manually.
4141
* `npm start -- gallery` (Client-only, no server required)
4242
5. Open http://localhost:4200/
4343

44+
## Streaming
45+
46+
By default, the Angular client uses the non-streaming API to communicate with the agent. To enable streaming, set the `ENABLE_STREAMING` environment variable to `true`:
47+
48+
```bash
49+
export ENABLE_STREAMING=true
50+
npm start -- contact
51+
```
52+
4453
Important: The sample code provided is for demonstration purposes and illustrates the mechanics of A2UI and the Agent-to-Agent (A2A) protocol. When building production applications, it is critical to treat any agent operating outside of your direct control as a potentially untrusted entity.
4554

4655
All operational data received from an external agent—including its AgentCard, messages, artifacts, and task statuses—should be handled as untrusted input. For example, a malicious agent could provide crafted data in its fields (e.g., name, skills.description) that, if used without sanitization to construct prompts for a Large Language Model (LLM), could expose your application to prompt injection attacks.

samples/client/angular/projects/contact/src/app/app.css

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,23 @@ h1 {
100100
}
101101
}
102102

103+
.rendering-indicator {
104+
display: flex;
105+
align-items: center;
106+
justify-content: center;
107+
padding: 16px;
108+
color: var(--p-40);
109+
font-size: 14px;
110+
border-top: 1px solid var(--n-90);
111+
margin-top: 16px;
112+
margin-top: 16px;
113+
114+
& .g-icon {
115+
margin-right: 8px;
116+
font-size: 16px;
117+
}
118+
}
119+
103120
.g-icon {
104121
font-family: "Google Symbols";
105122
font-weight: normal;

0 commit comments

Comments
 (0)