Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 54 additions & 1 deletion apps/website/content/docs/chat/api/api-docs.json
Original file line number Diff line number Diff line change
Expand Up @@ -1563,6 +1563,12 @@
"description": "Bubbled from chat-message gutter markers when the user requests a checkpoint fork.",
"optional": false
},
{
"name": "genuiToolNames",
"type": "InputSignal<readonly string[]>",
"description": "Tool names whose calls produce a rendered GenUI surface rather than\nvisible text. Used to (a) filter <chat-tool-calls> so internal\ndispatchers don't render args JSON as cards, and (b) detect\n\"this is a GenUI turn\" for the building-UI skeleton.\nDefault covers the canonical A2UI + json-render schema tools.",
"optional": false
},
{
"name": "handlers",
"type": "InputSignal<Record<string, object>>",
Expand Down Expand Up @@ -1711,6 +1717,25 @@
"description": "",
"params": []
},
{
"name": "isGenuiTurn",
"signature": "isGenuiTurn(message: unknown, prevMsg: unknown)",
"description": "True when this assistant message is part of a GenUI render turn —\neither it has a tool_call to a GenUI tool, OR its content array\ncontains a function_call block for one (live during streaming),\nOR the previous message was a tool result for a GenUI tool. Used\nto gate the building-UI skeleton.",
"params": [
{
"name": "message",
"type": "unknown",
"description": "",
"optional": false
},
{
"name": "prevMsg",
"type": "unknown",
"description": "",
"optional": false
}
]
},
{
"name": "isReasoningStreaming",
"signature": "isReasoningStreaming(message: Message, index: number)",
Expand Down Expand Up @@ -1838,6 +1863,19 @@
}
]
},
{
"name": "prevMessage",
"signature": "prevMessage(index: number)",
"description": "Look up the previous message in the agent's messages list.\nReturns undefined for the first message.",
"params": [
{
"name": "index",
"type": "number",
"description": "",
"optional": false
}
]
},
{
"name": "prevRole",
"signature": "prevRole(index: number)",
Expand Down Expand Up @@ -2023,6 +2061,15 @@
],
"methods": []
},
{
"name": "ChatGenuiSkeletonComponent",
"kind": "class",
"description": "",
"params": [],
"examples": [],
"properties": [],
"methods": []
},
{
"name": "ChatInputComponent",
"kind": "class",
Expand Down Expand Up @@ -3155,6 +3202,12 @@
"description": "",
"optional": false
},
{
"name": "excludeToolNames",
"type": "InputSignal<readonly string[]>",
"description": "Tool names whose groups should be hidden. Used by chat compositions\nto filter out internal/orchestration tools (e.g. GenUI dispatchers)\nwhose args streaming is not meaningful to surface in the chat.\nDefault empty — preserves prior behavior for non-filtering consumers.",
"optional": false
},
{
"name": "expandedGroups",
"type": "Signal<Set<number>>",
Expand Down Expand Up @@ -5090,7 +5143,7 @@
"name": "ContentType",
"kind": "type",
"description": "",
"signature": "\"undetermined\" | \"markdown\" | \"json-render\" | \"a2ui\" | \"mixed\"",
"signature": "\"pending\" | \"markdown\" | \"json-render\" | \"a2ui\" | \"mixed\"",
"examples": []
},
{
Expand Down
62 changes: 61 additions & 1 deletion libs/chat/src/lib/compositions/chat/chat.component.spec.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
// SPDX-License-Identifier: MIT
import { describe, it, expect } from 'vitest';
import { describe, it, expect, beforeEach } from 'vitest';
import { TestBed } from '@angular/core/testing';
import { Subject } from 'rxjs';
import { signal, effect, DestroyRef, inject, Injector, runInInjectionContext } from '@angular/core';
Expand Down Expand Up @@ -344,3 +344,63 @@ describe('ChatComponent — events$ routing', () => {
});
});
});

describe('ChatComponent — isGenuiTurn', () => {
let comp: ChatComponent;

beforeEach(() => {
TestBed.configureTestingModule({ imports: [ChatComponent] });
TestBed.runInInjectionContext(() => {
comp = new ChatComponent();
});
});

const isGenuiTurn = (m: unknown, p: unknown): boolean =>
(comp as unknown as { isGenuiTurn: (a: unknown, b: unknown) => boolean }).isGenuiTurn(m, p);

it('returns true for an assistant message with tool_calls referencing a GenUI tool', () => {
const msg = { role: 'assistant', extra: { tool_calls: [{ name: 'generate_a2ui_schema' }] } };
expect(isGenuiTurn(msg, undefined)).toBe(true);
});

it('returns true for an assistant message with a function_call content block (live streaming)', () => {
const msg = {
role: 'assistant',
extra: {
content: [
{ type: 'reasoning', summary: [] },
{ type: 'function_call', name: 'generate_a2ui_schema', arguments: '{"req' },
],
tool_calls: [],
},
};
expect(isGenuiTurn(msg, undefined)).toBe(true);
});

it('returns true for an assistant message whose previous message is a GenUI tool result', () => {
const prev = { role: 'tool', name: 'generate_a2ui_schema', extra: {} };
const msg = { role: 'assistant', content: '', extra: {} };
expect(isGenuiTurn(msg, prev)).toBe(true);
});

it('returns true when the previous tool message has the name nested under extra.name', () => {
const prev = { role: 'tool', extra: { name: 'generate_json_render_spec' } };
const msg = { role: 'assistant', content: '', extra: {} };
expect(isGenuiTurn(msg, prev)).toBe(true);
});

it('returns false for a non-GenUI tool call (e.g. search_documents)', () => {
const msg = { role: 'assistant', extra: { tool_calls: [{ name: 'search_documents' }] } };
expect(isGenuiTurn(msg, undefined)).toBe(false);
});

it('returns false for an assistant message with no tool_calls and no qualifying previous message', () => {
const msg = { role: 'assistant', content: 'hi', extra: {} };
const prev = { role: 'user', content: 'hello' };
expect(isGenuiTurn(msg, prev)).toBe(false);
});

it('returns false when called with null message', () => {
expect(isGenuiTurn(null, undefined)).toBe(false);
});
});
67 changes: 66 additions & 1 deletion libs/chat/src/lib/compositions/chat/chat.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import { ChatMessageActionsComponent } from '../../primitives/chat-message-actio
import { ChatWelcomeComponent } from '../../primitives/chat-welcome/chat-welcome.component';
import { ChatSelectComponent, type ChatSelectOption } from '../../primitives/chat-select/chat-select.component';
import { A2uiSurfaceComponent } from '../../a2ui/surface.component';
import { ChatGenuiSkeletonComponent } from '../../primitives/chat-genui-skeleton/chat-genui-skeleton.component';
import { createContentClassifier, type ContentClassifier } from '../../streaming/content-classifier';
import { messageContent } from '../shared/message-utils';
import { CHAT_HOST_TOKENS } from '../../styles/chat-tokens';
Expand All @@ -44,6 +45,7 @@ import type { ChatRenderEvent } from './chat-render-event';
ChatThreadListComponent, ChatGenerativeUiComponent,
ChatStreamingMdComponent, ChatToolCallsComponent, ChatSubagentsComponent, A2uiSurfaceComponent,
ChatMessageActionsComponent, ChatWelcomeComponent, ChatSelectComponent, ChatReasoningComponent,
ChatGenuiSkeletonComponent,
],
changeDetection: ChangeDetectionStrategy.OnPush,
styles: [CHAT_HOST_TOKENS, `
Expand Down Expand Up @@ -137,6 +139,8 @@ import type { ChatRenderEvent } from './chat-render-event';
<ng-template chatMessageTemplate="ai" let-message let-i="index">
@let content = messageContent(message);
@let classified = classifyMessage(content, message);
@let pending = classified.type() === 'pending';
@let genuiTurn = isGenuiTurn(message, prevMessage(i));
<chat-message
[role]="'assistant'"
[message]="message"
Expand All @@ -154,12 +158,15 @@ import type { ChatRenderEvent } from './chat-render-event';
[durationMs]="message.reasoningDurationMs"
/>
}
<chat-tool-calls [agent]="agent()" [message]="message">
<chat-tool-calls [agent]="agent()" [message]="message" [excludeToolNames]="genuiToolNames()">
<ng-container ngProjectAs="[chatToolCallTemplate]">
<ng-content select="[chatToolCallTemplate]" />
</ng-container>
</chat-tool-calls>
<chat-subagents [agent]="agent()" />
@if ((pending || (classified.type() === 'a2ui' && classified.a2uiSurfaces().size === 0)) && genuiTurn) {
<chat-genui-skeleton />
}
@if (classified.markdown(); as md) {
<chat-streaming-md [content]="md" [streaming]="agent().isLoading() && i === agent().messages().length - 1" />
}
Expand Down Expand Up @@ -250,6 +257,18 @@ export class ChatComponent {
readonly selectedModel = model<string>('');
readonly modelPickerPlaceholder = input<string>('Choose a model');

/**
* Tool names whose calls produce a rendered GenUI surface rather than
* visible text. Used to (a) filter <chat-tool-calls> so internal
* dispatchers don't render args JSON as cards, and (b) detect
* "this is a GenUI turn" for the building-UI skeleton.
* Default covers the canonical A2UI + json-render schema tools.
*/
readonly genuiToolNames = input<readonly string[]>([
'generate_a2ui_schema',
'generate_json_render_spec',
]);

readonly showWelcome = computed(() => {
if (this.welcomeDisabled()) return false;
const a = this.agent() as unknown as { isThreadLoading?: () => boolean };
Expand Down Expand Up @@ -377,6 +396,52 @@ export class ChatComponent {
return undefined;
}

/**
* Look up the previous message in the agent's messages list.
* Returns undefined for the first message.
*/
protected prevMessage(index: number): unknown {
if (index === 0) return undefined;
return this.agent().messages()[index - 1];
}

/**
* True when this assistant message is part of a GenUI render turn —
* either it has a tool_call to a GenUI tool, OR its content array
* contains a function_call block for one (live during streaming),
* OR the previous message was a tool result for a GenUI tool. Used
* to gate the building-UI skeleton.
*/
protected isGenuiTurn(message: unknown, prevMsg: unknown): boolean {
const names = new Set(this.genuiToolNames());
const m = message as { extra?: Record<string, unknown> } | null | undefined;
if (!m) return false;

const calls = (m.extra?.['tool_calls'] as Array<{ name?: string }> | undefined) ?? [];
if (calls.some(c => c.name != null && names.has(c.name))) return true;

const rawContent = m.extra?.['content'];
if (Array.isArray(rawContent)) {
for (const block of rawContent) {
if (block != null
&& typeof block === 'object'
&& (block as { type?: unknown }).type === 'function_call'
&& typeof (block as { name?: unknown }).name === 'string'
&& names.has((block as { name: string }).name)) {
return true;
}
}
}

const p = prevMsg as { role?: string; name?: string; extra?: Record<string, unknown> } | null | undefined;
if (p && p.role === 'tool') {
const toolName = (p.extra?.['name'] as string | undefined) ?? p.name;
if (typeof toolName === 'string' && names.has(toolName)) return true;
}

return false;
}

classifyMessage(content: string, message: { id?: string }): ContentClassifier {
const id = message.id ?? '';
let c = this.classifiers.get(id);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
// SPDX-License-Identifier: MIT
import { describe, it, expect, beforeEach } from 'vitest';
import { TestBed } from '@angular/core/testing';
import { ChatGenuiSkeletonComponent } from './chat-genui-skeleton.component';

describe('ChatGenuiSkeletonComponent', () => {
beforeEach(() => TestBed.configureTestingModule({ imports: [ChatGenuiSkeletonComponent] }));

it('renders a region role with the Building UI status text', () => {
const fx = TestBed.createComponent(ChatGenuiSkeletonComponent);
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(ChatGenuiSkeletonComponent);
fx.detectChanges();
const rows = fx.nativeElement.querySelectorAll('.chat-genui-skeleton__row');
expect(rows.length).toBe(3);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
// SPDX-License-Identifier: MIT
import { Component, ChangeDetectionStrategy } from '@angular/core';
import { CHAT_HOST_TOKENS } from '../../styles/chat-tokens';

@Component({
selector: 'chat-genui-skeleton',
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
styles: [CHAT_HOST_TOKENS, `
:host { display: block; width: 100%; }
.chat-genui-skeleton {
border: 1px solid var(--ngaf-chat-separator);
border-radius: 10px;
padding: 14px;
background: var(--ngaf-chat-surface-alt);
}
.chat-genui-skeleton__label {
font-size: 12px;
color: var(--ngaf-chat-text-muted);
margin-bottom: 10px;
display: flex;
align-items: center;
gap: 6px;
}
.chat-genui-skeleton__rows {
display: flex;
flex-direction: column;
gap: 8px;
}
.chat-genui-skeleton__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: chat-genui-skeleton-shimmer 1.4s ease-in-out infinite;
}
.chat-genui-skeleton__row:nth-child(1) { width: 70%; }
.chat-genui-skeleton__row:nth-child(2) { width: 90%; }
.chat-genui-skeleton__row:nth-child(3) { width: 50%; }
@keyframes chat-genui-skeleton-shimmer {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
`],
template: `
<div class="chat-genui-skeleton" role="status" aria-live="polite">
<div class="chat-genui-skeleton__label">
<span aria-hidden="true">✨</span>
<span>Building UI…</span>
</div>
<div class="chat-genui-skeleton__rows">
<div class="chat-genui-skeleton__row"></div>
<div class="chat-genui-skeleton__row"></div>
<div class="chat-genui-skeleton__row"></div>
</div>
</div>
`,
})
export class ChatGenuiSkeletonComponent {}
Loading
Loading