diff --git a/apps/website/content/docs/chat/api/api-docs.json b/apps/website/content/docs/chat/api/api-docs.json index 19ed84ff3..496dec675 100644 --- a/apps/website/content/docs/chat/api/api-docs.json +++ b/apps/website/content/docs/chat/api/api-docs.json @@ -2023,6 +2023,15 @@ ], "methods": [] }, + { + "name": "ChatGenuiSkeletonComponent", + "kind": "class", + "description": "", + "params": [], + "examples": [], + "properties": [], + "methods": [] + }, { "name": "ChatInputComponent", "kind": "class", @@ -2337,6 +2346,18 @@ "description": "", "optional": false }, + { + "name": "genuiToolNames", + "type": "InputSignal", + "description": "Tool names whose call/result messages should render a skeleton in\n place of the streaming body. Defaults to the A2UI / json-render\n pair; consumers can override or extend.", + "optional": false + }, + { + "name": "isGenUiToolCall", + "type": "Signal", + "description": "True when this message represents (or results from) a GenUI tool\n call whose body should be suppressed in favor of a skeleton.", + "optional": false + }, { "name": "message", "type": "InputSignal", diff --git a/libs/chat/src/lib/primitives/chat-genui-skeleton/chat-genui-skeleton.component.spec.ts b/libs/chat/src/lib/primitives/chat-genui-skeleton/chat-genui-skeleton.component.spec.ts new file mode 100644 index 000000000..6a2c2d94a --- /dev/null +++ b/libs/chat/src/lib/primitives/chat-genui-skeleton/chat-genui-skeleton.component.spec.ts @@ -0,0 +1,24 @@ +// libs/chat/src/lib/primitives/chat-genui-skeleton/chat-genui-skeleton.component.spec.ts +// 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); + }); +}); diff --git a/libs/chat/src/lib/primitives/chat-genui-skeleton/chat-genui-skeleton.component.ts b/libs/chat/src/lib/primitives/chat-genui-skeleton/chat-genui-skeleton.component.ts new file mode 100644 index 000000000..f41e4ba8e --- /dev/null +++ b/libs/chat/src/lib/primitives/chat-genui-skeleton/chat-genui-skeleton.component.ts @@ -0,0 +1,68 @@ +// libs/chat/src/lib/primitives/chat-genui-skeleton/chat-genui-skeleton.component.ts +// 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: ` +
+
+ + Building UI… +
+
+
+
+
+
+
+ `, +}) +export class ChatGenuiSkeletonComponent {} diff --git a/libs/chat/src/lib/primitives/chat-message/chat-message.component.spec.ts b/libs/chat/src/lib/primitives/chat-message/chat-message.component.spec.ts index 92b7305fe..8028803d9 100644 --- a/libs/chat/src/lib/primitives/chat-message/chat-message.component.spec.ts +++ b/libs/chat/src/lib/primitives/chat-message/chat-message.component.spec.ts @@ -5,6 +5,7 @@ import { TestBed } from '@angular/core/testing'; import { Component } from '@angular/core'; import { ChatMessageComponent } from './chat-message.component'; import { CitationsResolverService } from '../../markdown/citations-resolver.service'; +import type { Message } from '../../agent/message'; describe('ChatMessageComponent', () => { it('instantiates without error', () => { @@ -68,3 +69,141 @@ describe('ChatMessageComponent — gutter checkpoint marker', () => { expect(fx.componentInstance.forked).toEqual(['cp-99']); }); }); + +@Component({ + standalone: true, + imports: [ChatMessageComponent], + template: `Streaming body`, +}) +class GenuiHost { + msg: Message | undefined = undefined; + streaming = false; +} + +function makeMessage(toolCalls: Array<{ name: string; id?: string }>): Message { + return { + id: 'm-1', + role: 'assistant', + content: '', + extra: { tool_calls: toolCalls }, + }; +} + +describe('ChatMessageComponent — GenUI tool-call suppression', () => { + it('renders the skeleton when message has a generate_a2ui_schema tool call and is streaming', () => { + TestBed.configureTestingModule({ imports: [GenuiHost] }); + const fx = TestBed.createComponent(GenuiHost); + fx.componentInstance.msg = makeMessage([{ name: 'generate_a2ui_schema' }]); + fx.componentInstance.streaming = true; + fx.detectChanges(); + expect(fx.nativeElement.querySelector('chat-genui-skeleton')).toBeTruthy(); + expect(fx.nativeElement.querySelector('.chat-message__assistant-body')).toBeNull(); + }); + + it('renders the skeleton when message has a generate_json_render_spec tool call', () => { + TestBed.configureTestingModule({ imports: [GenuiHost] }); + const fx = TestBed.createComponent(GenuiHost); + fx.componentInstance.msg = makeMessage([{ name: 'generate_json_render_spec' }]); + fx.componentInstance.streaming = true; + fx.detectChanges(); + expect(fx.nativeElement.querySelector('chat-genui-skeleton')).toBeTruthy(); + }); + + it('keeps the skeleton after streaming completes (body remains suppressed)', () => { + TestBed.configureTestingModule({ imports: [GenuiHost] }); + const fx = TestBed.createComponent(GenuiHost); + fx.componentInstance.msg = makeMessage([{ name: 'generate_a2ui_schema' }]); + fx.componentInstance.streaming = false; + fx.detectChanges(); + expect(fx.nativeElement.querySelector('chat-genui-skeleton')).toBeTruthy(); + }); + + it('renders the normal body when tool call is a non-GenUI tool (e.g. search_documents)', () => { + TestBed.configureTestingModule({ imports: [GenuiHost] }); + const fx = TestBed.createComponent(GenuiHost); + fx.componentInstance.msg = makeMessage([{ name: 'search_documents' }]); + fx.componentInstance.streaming = true; + fx.detectChanges(); + expect(fx.nativeElement.querySelector('chat-genui-skeleton')).toBeNull(); + expect(fx.nativeElement.querySelector('.chat-message__assistant-body')).toBeTruthy(); + }); + + it('renders the normal body when message has no tool calls', () => { + TestBed.configureTestingModule({ imports: [GenuiHost] }); + const fx = TestBed.createComponent(GenuiHost); + fx.componentInstance.msg = { id: 'm-1', role: 'assistant', content: 'hi', extra: {} }; + fx.componentInstance.streaming = false; + fx.detectChanges(); + expect(fx.nativeElement.querySelector('chat-genui-skeleton')).toBeNull(); + expect(fx.nativeElement.querySelector('.chat-message__assistant-body')).toBeTruthy(); + }); + + it('detects function_call content block during streaming (before tool_calls populates)', () => { + TestBed.configureTestingModule({ imports: [GenuiHost] }); + const fx = TestBed.createComponent(GenuiHost); + // Mid-stream OpenAI Responses-API shape: tool_calls is still empty but + // the content array carries the function_call block with the tool name. + fx.componentInstance.msg = { + id: 'm-1', + role: 'assistant', + content: '', + extra: { + tool_calls: [], + content: [ + { type: 'reasoning', summary: [] }, + { type: 'function_call', name: 'generate_a2ui_schema', arguments: '{"req' }, + ], + }, + }; + fx.componentInstance.streaming = true; + fx.detectChanges(); + expect(fx.nativeElement.querySelector('chat-genui-skeleton')).toBeTruthy(); + }); + + it('detects A2UI sentinel prefix on the emit-phase message', () => { + TestBed.configureTestingModule({ imports: [GenuiHost] }); + const fx = TestBed.createComponent(GenuiHost); + fx.componentInstance.msg = { + id: 'm-1', + role: 'assistant', + content: '---a2ui_JSON---\n{"surfaceUpdate":{"surfaceId":"main"}}', + extra: {}, + }; + fx.componentInstance.streaming = true; + fx.detectChanges(); + expect(fx.nativeElement.querySelector('chat-genui-skeleton')).toBeTruthy(); + }); + + it('detects PARTIAL A2UI sentinel during the first streaming chunks', () => { + TestBed.configureTestingModule({ imports: [GenuiHost] }); + const fx = TestBed.createComponent(GenuiHost); + // After only a few tokens have arrived, content is a prefix of the sentinel. + fx.componentInstance.msg = { + id: 'm-1', + role: 'assistant', + content: '---a', + extra: {}, + }; + fx.componentInstance.streaming = true; + fx.detectChanges(); + expect(fx.nativeElement.querySelector('chat-genui-skeleton')).toBeTruthy(); + }); + + it('does not match an unrelated assistant message that happens to start with dashes', () => { + TestBed.configureTestingModule({ imports: [GenuiHost] }); + const fx = TestBed.createComponent(GenuiHost); + fx.componentInstance.msg = { + id: 'm-1', + role: 'assistant', + content: '---some-other-marker---', + extra: {}, + }; + fx.componentInstance.streaming = false; + fx.detectChanges(); + expect(fx.nativeElement.querySelector('chat-genui-skeleton')).toBeNull(); + }); +}); diff --git a/libs/chat/src/lib/primitives/chat-message/chat-message.component.ts b/libs/chat/src/lib/primitives/chat-message/chat-message.component.ts index 3a547e5b7..19981cafe 100644 --- a/libs/chat/src/lib/primitives/chat-message/chat-message.component.ts +++ b/libs/chat/src/lib/primitives/chat-message/chat-message.component.ts @@ -5,15 +5,23 @@ import { CHAT_HOST_TOKENS } from '../../styles/chat-tokens'; import { CHAT_MESSAGE_STYLES } from '../../styles/chat-message.styles'; import { ChatCitationsComponent } from '../chat-citations/chat-citations.component'; import { ChatCheckpointMarkerComponent } from '../chat-checkpoint-marker/chat-checkpoint-marker.component'; +import { ChatGenuiSkeletonComponent } from '../chat-genui-skeleton/chat-genui-skeleton.component'; import { CitationsResolverService } from '../../markdown/citations-resolver.service'; import type { Message } from '../../agent/message'; export type ChatMessageRole = 'user' | 'assistant' | 'system' | 'tool'; +/** Default set of tool names that produce a rendered surface rather than + * visible text. Consumers can override via the `genuiToolNames` input. */ +const DEFAULT_GENUI_TOOL_NAMES: readonly string[] = [ + 'generate_a2ui_schema', + 'generate_json_render_spec', +]; + @Component({ selector: 'chat-message', standalone: true, - imports: [ChatCitationsComponent, ChatCheckpointMarkerComponent], + imports: [ChatCitationsComponent, ChatCheckpointMarkerComponent, ChatGenuiSkeletonComponent], changeDetection: ChangeDetectionStrategy.OnPush, styles: [CHAT_HOST_TOKENS, CHAT_MESSAGE_STYLES, ` .chat-message__layout { display: flex; gap: 8px; align-items: flex-start; } @@ -41,16 +49,20 @@ export type ChatMessageRole = 'user' | 'assistant' | 'system' | 'tool'; }
-
- - -
- @if (message()?.role === 'assistant' && message(); as msg) { - + @if (isGenUiToolCall()) { + + } @else { +
+ + +
+ @if (message()?.role === 'assistant' && message(); as msg) { + + } +
+ +
} -
- -
`, @@ -68,6 +80,11 @@ export class ChatMessageComponent { readonly checkpointId = input(undefined); readonly checkpointActive = input(false); + /** Tool names whose call/result messages should render a skeleton in + * place of the streaming body. Defaults to the A2UI / json-render + * pair; consumers can override or extend. */ + readonly genuiToolNames = input(DEFAULT_GENUI_TOOL_NAMES); + readonly replayRequested = output(); readonly forkRequested = output(); @@ -89,4 +106,63 @@ export class ChatMessageComponent { default: return 'chat-message__plain'; } }); + + /** True when this message represents (or results from) a GenUI tool + * call whose body should be suppressed in favor of a skeleton. + * + * Detection layers — each catches a distinct phase of the streaming + * pipeline so the skeleton replaces the body from the first token: + * + * 1a. Post-streaming AI message with `extra.tool_calls[].name` + * referencing a GenUI tool. + * 1b. Live-streaming AI message whose OpenAI Responses-API content + * array contains a `function_call` block with the tool name — + * arrives on the first chunk, before `tool_calls` populates. + * 1c. Emit-phase AI message whose content starts with the A2UI + * sentinel `---a2ui_JSON---` (or any prefix of it during the + * first few stream tokens). + * 2. Tool result message whose `name` matches a GenUI tool. */ + readonly isGenUiToolCall = computed(() => { + const m = this.message(); + if (!m) return false; + const names = new Set(this.genuiToolNames()); + + if (m.role === 'assistant') { + // 1a: tool_calls field (post-streaming). + const calls = (m.extra?.['tool_calls'] as Array<{ name?: string }> | undefined) ?? []; + if (calls.some(c => c.name != null && names.has(c.name))) return true; + + // 1b: OpenAI Responses-API content-array `function_call` block — + // available from the first streaming chunk. + 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; + } + } + } + + // 1c: A2UI sentinel prefix on the emit-phase message. Matches + // both the full prefix and any partial prefix that arrives + // during the first few stream tokens (e.g. `--`, `---a`). + if (typeof m.content === 'string' && m.content.length > 0) { + const A2UI_SENTINEL = '---a2ui_JSON---'; + if (m.content.startsWith(A2UI_SENTINEL)) return true; + if (A2UI_SENTINEL.startsWith(m.content)) return true; + } + } + + // 2: tool result message tagged with the GenUI tool name. + if (m.role === 'tool') { + const name = (m.extra?.['name'] as string | undefined) ?? m.name; + if (typeof name === 'string' && names.has(name)) return true; + } + + return false; + }); } diff --git a/libs/chat/src/public-api.ts b/libs/chat/src/public-api.ts index e1dafc065..7031cd910 100644 --- a/libs/chat/src/public-api.ts +++ b/libs/chat/src/public-api.ts @@ -55,6 +55,7 @@ export { ChatSubagentsComponent } from './lib/primitives/chat-subagents/chat-sub export { ChatThreadListComponent } from './lib/primitives/chat-thread-list/chat-thread-list.component'; export type { Thread } from './lib/primitives/chat-thread-list/chat-thread-list.component'; export { ChatCheckpointMarkerComponent } from './lib/primitives/chat-checkpoint-marker/chat-checkpoint-marker.component'; +export { ChatGenuiSkeletonComponent } from './lib/primitives/chat-genui-skeleton/chat-genui-skeleton.component'; export { ChatTimelineComponent } from './lib/primitives/chat-timeline/chat-timeline.component'; export { ChatGenerativeUiComponent } from './lib/primitives/chat-generative-ui/chat-generative-ui.component'; export { ChatWelcomeComponent } from './lib/primitives/chat-welcome/chat-welcome.component';