Skip to content
Closed
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
21 changes: 21 additions & 0 deletions apps/website/content/docs/chat/api/api-docs.json
Original file line number Diff line number Diff line change
Expand Up @@ -2023,6 +2023,15 @@
],
"methods": []
},
{
"name": "ChatGenuiSkeletonComponent",
"kind": "class",
"description": "",
"params": [],
"examples": [],
"properties": [],
"methods": []
},
{
"name": "ChatInputComponent",
"kind": "class",
Expand Down Expand Up @@ -2337,6 +2346,18 @@
"description": "",
"optional": false
},
{
"name": "genuiToolNames",
"type": "InputSignal<readonly string[]>",
"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<boolean>",
"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<Message | undefined>",
Expand Down
Original file line number Diff line number Diff line change
@@ -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);
});
});
Original file line number Diff line number Diff line change
@@ -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: `
<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 {}
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down Expand Up @@ -68,3 +69,141 @@ describe('ChatMessageComponent — gutter checkpoint marker', () => {
expect(fx.componentInstance.forked).toEqual(['cp-99']);
});
});

@Component({
standalone: true,
imports: [ChatMessageComponent],
template: `<chat-message
role="assistant"
[message]="msg"
[streaming]="streaming"
>Streaming body</chat-message>`,
})
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();
});
});
Loading
Loading