From 3cd7da15a048a56c325eec5a0d0afa2b5c8990a9 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Mon, 11 May 2026 12:33:42 -0700 Subject: [PATCH 1/6] feat(chat): chat-genui-skeleton primitive MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Card-shaped placeholder rendered in place of streaming tool-call JSON while an A2UI / json-render surface is being built. Three shimmer rows + 'Building UI…' status label. Themed via existing chat-tokens (separator color, surface-alt background) so it inherits theme overrides. --- .../chat-genui-skeleton.component.spec.ts | 23 +++++++ .../chat-genui-skeleton.component.ts | 64 +++++++++++++++++++ 2 files changed, 87 insertions(+) create mode 100644 libs/chat/src/lib/primitives/chat-genui-skeleton/chat-genui-skeleton.component.spec.ts create mode 100644 libs/chat/src/lib/primitives/chat-genui-skeleton/chat-genui-skeleton.component.ts 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..fa97b33d7 --- /dev/null +++ b/libs/chat/src/lib/primitives/chat-genui-skeleton/chat-genui-skeleton.component.spec.ts @@ -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); + }); +}); 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..b55139610 --- /dev/null +++ b/libs/chat/src/lib/primitives/chat-genui-skeleton/chat-genui-skeleton.component.ts @@ -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: ` +
+
+ + Building UI… +
+
+
+
+
+
+
+ `, +}) +export class ChatGenuiSkeletonComponent {} From 53c690f0406f4c857a325690d20c90401f4cc034 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Mon, 11 May 2026 12:34:54 -0700 Subject: [PATCH 2/6] =?UTF-8?q?refactor(chat):=20classifier=20=E2=80=94=20?= =?UTF-8?q?rename=20undetermined=E2=86=92pending=20+=20A2UI=20patience?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two changes: 1. Rename ContentType value 'undetermined' to 'pending'. The new name better reflects what the state means (we're waiting for enough content to decide), and is the state the chat composition reads when deciding whether to show the GenUI skeleton. 2. Add patience for the A2UI prefix. When the first non-whitespace char is '-', stay 'pending' until either the full prefix matches (commit to 'a2ui') or enough chars arrive without matching (commit to 'markdown'). --- .../lib/streaming/content-classifier.spec.ts | 54 ++++++++++++++++++- .../src/lib/streaming/content-classifier.ts | 51 +++++++++++------- 2 files changed, 84 insertions(+), 21 deletions(-) diff --git a/libs/chat/src/lib/streaming/content-classifier.spec.ts b/libs/chat/src/lib/streaming/content-classifier.spec.ts index c1936aa8b..ff574b6e2 100644 --- a/libs/chat/src/lib/streaming/content-classifier.spec.ts +++ b/libs/chat/src/lib/streaming/content-classifier.spec.ts @@ -14,9 +14,9 @@ describe('ContentClassifier', () => { } describe('initial state', () => { - it('type is undetermined', () => { + it('type is pending', () => { const c = setup(); - expect(c.type()).toBe('undetermined'); + expect(c.type()).toBe('pending'); }); it('markdown is empty', () => { @@ -239,3 +239,53 @@ describe('ContentClassifier', () => { }); }); }); + +describe('ContentClassifier — A2UI prefix patience', () => { + it('stays pending on a single dash (partial A2UI prefix)', () => { + const c = createContentClassifier(); + c.update('-'); + expect(c.type()).toBe('pending'); + }); + + it('stays pending on partial A2UI prefix like --- or ---a or ---a2u', () => { + const c1 = createContentClassifier(); + c1.update('---'); + expect(c1.type()).toBe('pending'); + const c2 = createContentClassifier(); + c2.update('---a'); + expect(c2.type()).toBe('pending'); + const c3 = createContentClassifier(); + c3.update('---a2u'); + expect(c3.type()).toBe('pending'); + }); + + it('transitions to a2ui when the full A2UI prefix arrives', () => { + const c = createContentClassifier(); + c.update('-'); + c.update('---a2ui_JSON---'); + expect(c.type()).toBe('a2ui'); + }); + + it('commits to markdown when content starting with - is disproven early', () => { + const c = createContentClassifier(); + c.update('-x'); + expect(c.type()).toBe('markdown'); + }); + + it('commits to markdown once enough chars are seen without matching prefix', () => { + const c = createContentClassifier(); + c.update('-this is just dashes leading text'); + expect(c.type()).toBe('markdown'); + }); + + it('commits to json-render on a single { with no patience needed', () => { + const c = createContentClassifier(); + c.update('{'); + expect(c.type()).toBe('json-render'); + }); + + it('initial state is pending (renamed from undetermined)', () => { + const c = createContentClassifier(); + expect(c.type()).toBe('pending'); + }); +}); diff --git a/libs/chat/src/lib/streaming/content-classifier.ts b/libs/chat/src/lib/streaming/content-classifier.ts index c0bd30cef..19899d299 100644 --- a/libs/chat/src/lib/streaming/content-classifier.ts +++ b/libs/chat/src/lib/streaming/content-classifier.ts @@ -8,7 +8,7 @@ import type { A2uiSurface } from '@ngaf/a2ui'; import { createA2uiSurfaceStore, type A2uiSurfaceStore } from '../a2ui/surface-store'; import { isTraceEnabled, trace } from './trace'; -export type ContentType = 'undetermined' | 'markdown' | 'json-render' | 'a2ui' | 'mixed'; +export type ContentType = 'pending' | 'markdown' | 'json-render' | 'a2ui' | 'mixed'; const A2UI_PREFIX = '---a2ui_JSON---'; @@ -25,7 +25,7 @@ export interface ContentClassifier { } export function createContentClassifier(): ContentClassifier { - const typeSignal = signal('undetermined'); + const typeSignal = signal('pending'); const markdownSignal = signal(''); const specSignal = signal(null); const elementStatesSignal = signal>(new Map()); @@ -40,21 +40,43 @@ export function createContentClassifier(): ContentClassifier { let a2uiStore: A2uiSurfaceStore | null = null; const a2uiSurfacesSignal = signal>(new Map()); + /** + * Decide the content type from the first non-whitespace character. + * Returns 'pending' when: + * - content is empty or all whitespace, OR + * - the first non-whitespace char is '-' AND content is too short to + * confirm or disprove the full A2UI_PREFIX (the "patience" case). + * Once we have at least A2UI_PREFIX.length non-prefix chars after the + * first '-', we commit to either 'a2ui' (full match) or 'markdown' + * (definitively not the prefix). + */ function detectType(content: string): ContentType { - // Find first non-whitespace character for (let i = 0; i < content.length; i++) { const ch = content[i]; if (ch === ' ' || ch === '\t' || ch === '\n' || ch === '\r') continue; - if (content.startsWith(A2UI_PREFIX, i)) { - return 'a2ui'; - } if (ch === '{') { return 'json-render'; } + + if (ch === '-') { + if (content.startsWith(A2UI_PREFIX, i)) { + return 'a2ui'; + } + const remaining = content.length - i; + if (remaining < A2UI_PREFIX.length) { + const candidate = content.slice(i); + if (A2UI_PREFIX.startsWith(candidate)) { + return 'pending'; + } + return 'markdown'; + } + return 'markdown'; + } + return 'markdown'; } - return 'undetermined'; + return 'pending'; } function initJsonStore(jsonContent: string): void { @@ -71,13 +93,8 @@ export function createContentClassifier(): ContentClassifier { specSignal.set(store.spec()); elementStatesSignal.set(store.elementStates()); - // Determine streaming state from the parser root node status const spec = store.spec(); if (spec) { - // Check if the root JSON object is complete by seeing if materialize produced a complete object - // We check by looking at the parse tree store's underlying parser root status - // A simpler heuristic: if the spec has both root and elements defined and the last char was }, it's likely complete - // But we can use the parser events approach. Let's check the element states for streaming. streamingSignal.set(isStillStreaming()); } else { streamingSignal.set(true); @@ -86,20 +103,17 @@ export function createContentClassifier(): ContentClassifier { function isStillStreaming(): boolean { if (!store) return false; - // If the store has a spec, check if any elements are still streaming - // or if the root object itself hasn't closed yet const states = store.elementStates(); for (const state of states.values()) { if (state.streaming) return true; } - // Also check if the spec has basic completeness: root + elements const spec = store.spec(); if (!spec || !spec.root || !spec.elements) return true; return false; } function resetState(): void { - typeSignal.set('undetermined'); + typeSignal.set('pending'); markdownSignal.set(''); specSignal.set(null); elementStatesSignal.set(new Map()); @@ -130,9 +144,9 @@ export function createContentClassifier(): ContentClassifier { } const currentType = typeSignal(); - if (currentType === 'undetermined') { + if (currentType === 'pending') { const detected = detectType(content); - if (detected === 'undetermined') return; + if (detected === 'pending') return; typeSignal.set(detected); @@ -141,7 +155,6 @@ export function createContentClassifier(): ContentClassifier { processedLength = content.length; } else if (detected === 'json-render') { streamingSignal.set(true); - // Find where JSON starts (skip whitespace) jsonStartIndex = 0; for (let i = 0; i < content.length; i++) { if (content[i] !== ' ' && content[i] !== '\t' && content[i] !== '\n' && content[i] !== '\r') { From f159003ca691933a1442ff0ce10eb81491a29469 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Mon, 11 May 2026 12:35:36 -0700 Subject: [PATCH 3/6] feat(chat): chat-tool-calls excludeToolNames input New optional input that filters out tool groups whose name is in the exclude set. Used by chat compositions to hide orchestration-only tool calls (e.g. GenUI dispatchers) whose streaming args are not meaningful to the user. --- .../chat-tool-calls.component.spec.ts | 79 +++++++++++++++++++ .../chat-tool-calls.component.ts | 11 ++- 2 files changed, 89 insertions(+), 1 deletion(-) diff --git a/libs/chat/src/lib/primitives/chat-tool-calls/chat-tool-calls.component.spec.ts b/libs/chat/src/lib/primitives/chat-tool-calls/chat-tool-calls.component.spec.ts index afe007a12..dc3922001 100644 --- a/libs/chat/src/lib/primitives/chat-tool-calls/chat-tool-calls.component.spec.ts +++ b/libs/chat/src/lib/primitives/chat-tool-calls/chat-tool-calls.component.spec.ts @@ -233,3 +233,82 @@ describe('summarize-group label registry', () => { expect(summarize('foo', 4)).toBe('Called foo 4 times'); }); }); + +import { TestBed } from '@angular/core/testing'; +import { Component, signal } from '@angular/core'; +import type { Agent, ToolCall } from '../../agent'; +import { ChatToolCallsComponent } from './chat-tool-calls.component'; + +function makeStubAgent(toolCalls: ToolCall[]): Agent { + return { + messages: signal([]), + status: signal('idle'), + isLoading: signal(false), + error: signal(undefined), + toolCalls: signal(toolCalls), + state: signal({}), + interrupt: signal(undefined), + subagents: signal(new Map()), + events$: { subscribe: () => ({ unsubscribe: () => undefined }) }, + submit: () => Promise.resolve(), + stop: () => undefined, + } as unknown as Agent; +} + +const mkCall = (name: string, id: string): ToolCall => ({ + id, + name, + args: {}, + status: 'complete', +}); + +@Component({ + standalone: true, + imports: [ChatToolCallsComponent], + template: ``, +}) +class FilterHost { + agent = makeStubAgent([ + mkCall('generate_a2ui_schema', 'tc-1'), + mkCall('search_documents', 'tc-2'), + mkCall('research', 'tc-3'), + ]); + excluded: readonly string[] = []; +} + +describe('ChatToolCallsComponent — excludeToolNames filter', () => { + it('renders all groups when excludeToolNames is empty (default)', () => { + TestBed.configureTestingModule({ imports: [FilterHost] }); + const fx = TestBed.createComponent(FilterHost); + fx.detectChanges(); + const text = fx.nativeElement.textContent ?? ''; + expect(text).toContain('search_documents'); + expect(text).toContain('research'); + }); + + it('omits a group whose name is in excludeToolNames', () => { + TestBed.configureTestingModule({ imports: [FilterHost] }); + const fx = TestBed.createComponent(FilterHost); + fx.componentInstance.excluded = ['generate_a2ui_schema']; + fx.detectChanges(); + const text = fx.nativeElement.textContent ?? ''; + expect(text).not.toContain('generate_a2ui_schema'); + expect(text).toContain('search_documents'); + expect(text).toContain('research'); + }); + + it('omits ALL groups when every tool name is excluded', () => { + TestBed.configureTestingModule({ imports: [FilterHost] }); + const fx = TestBed.createComponent(FilterHost); + fx.componentInstance.excluded = [ + 'generate_a2ui_schema', + 'search_documents', + 'research', + ]; + fx.detectChanges(); + const text = fx.nativeElement.textContent?.trim() ?? ''; + expect(text).not.toContain('generate_a2ui_schema'); + expect(text).not.toContain('search_documents'); + expect(text).not.toContain('research'); + }); +}); diff --git a/libs/chat/src/lib/primitives/chat-tool-calls/chat-tool-calls.component.ts b/libs/chat/src/lib/primitives/chat-tool-calls/chat-tool-calls.component.ts index dcec1bad9..036135200 100644 --- a/libs/chat/src/lib/primitives/chat-tool-calls/chat-tool-calls.component.ts +++ b/libs/chat/src/lib/primitives/chat-tool-calls/chat-tool-calls.component.ts @@ -92,6 +92,14 @@ export class ChatToolCallsComponent { readonly grouping = input<'auto' | 'none'>('auto'); readonly groupSummary = input<((name: string, count: number) => string) | undefined>(undefined); + /** + * Tool names whose groups should be hidden. Used by chat compositions + * to filter out internal/orchestration tools (e.g. GenUI dispatchers) + * whose args streaming is not meaningful to surface in the chat. + * Default empty — preserves prior behavior for non-filtering consumers. + */ + readonly excludeToolNames = input([]); + /** Per-tool-name + wildcard templates registered as content children. */ readonly templates = contentChildren(ChatToolCallTemplateDirective); @@ -114,7 +122,8 @@ export class ChatToolCallsComponent { }); readonly groups = computed((): Group[] => { - const calls = this.toolCalls(); + const excludeSet = new Set(this.excludeToolNames()); + const calls = this.toolCalls().filter(tc => !excludeSet.has(tc.name)); const groupingMode = this.grouping(); const registry = this.templateRegistry(); const wildcard = registry.get('*'); From 6404684ab31830b6da4ec511b62361645f11e3b1 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Mon, 11 May 2026 12:37:48 -0700 Subject: [PATCH 4/6] feat(chat): composition owns GenUI turn orchestration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds isGenuiTurn(message, prevMessage) to the chat composition, which inspects message structure across three independent signals (tool_calls field, function_call content block, prev-tool-message name) to decide whether this assistant turn is producing a GenUI surface. Template changes inside the AI message branch: - now gets [excludeToolNames]="genuiToolNames()", so internal GenUI dispatchers don't render args JSON as cards. - New branch renders when the classifier is 'pending' (or 'a2ui' with no surfaces yet) AND isGenuiTurn is true — bridging the gap between streaming start and the rendered surface mounting. The skeleton is a SIBLING of and , not a wrapper around them — so it cleanly hands off once content disambiguates. --- .../compositions/chat/chat.component.spec.ts | 62 ++++++++++++++++- .../lib/compositions/chat/chat.component.ts | 67 ++++++++++++++++++- 2 files changed, 127 insertions(+), 2 deletions(-) diff --git a/libs/chat/src/lib/compositions/chat/chat.component.spec.ts b/libs/chat/src/lib/compositions/chat/chat.component.spec.ts index ddbbeb14e..82ca11839 100644 --- a/libs/chat/src/lib/compositions/chat/chat.component.spec.ts +++ b/libs/chat/src/lib/compositions/chat/chat.component.spec.ts @@ -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'; @@ -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); + }); +}); diff --git a/libs/chat/src/lib/compositions/chat/chat.component.ts b/libs/chat/src/lib/compositions/chat/chat.component.ts index 93c1b58d4..86888fc56 100644 --- a/libs/chat/src/lib/compositions/chat/chat.component.ts +++ b/libs/chat/src/lib/compositions/chat/chat.component.ts @@ -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'; @@ -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, ` @@ -137,6 +139,8 @@ import type { ChatRenderEvent } from './chat-render-event'; @let content = messageContent(message); @let classified = classifyMessage(content, message); + @let pending = classified.type() === 'pending'; + @let genuiTurn = isGenuiTurn(message, prevMessage(i)); } - + + @if ((pending || (classified.type() === 'a2ui' && classified.a2uiSurfaces().size === 0)) && genuiTurn) { + + } @if (classified.markdown(); as md) { } @@ -250,6 +257,18 @@ export class ChatComponent { readonly selectedModel = model(''); readonly modelPickerPlaceholder = input('Choose a model'); + /** + * Tool names whose calls produce a rendered GenUI surface rather than + * visible text. Used to (a) filter 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([ + 'generate_a2ui_schema', + 'generate_json_render_spec', + ]); + readonly showWelcome = computed(() => { if (this.welcomeDisabled()) return false; const a = this.agent() as unknown as { isThreadLoading?: () => boolean }; @@ -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 } | 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 } | 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); From df6dc589ee7b53118ace7a9b184da6c0fefe07bd Mon Sep 17 00:00:00 2001 From: Brian Love Date: Mon, 11 May 2026 12:38:26 -0700 Subject: [PATCH 5/6] feat(chat): export ChatGenuiSkeletonComponent --- libs/chat/src/public-api.ts | 1 + 1 file changed, 1 insertion(+) 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'; From 0f4a074e67625932ccea25bd82f79f483b9621a6 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Mon, 11 May 2026 12:38:51 -0700 Subject: [PATCH 6/6] chore: regenerate api-docs for ChatGenuiSkeleton export --- .../content/docs/chat/api/api-docs.json | 55 ++++++++++++++++++- 1 file changed, 54 insertions(+), 1 deletion(-) diff --git a/apps/website/content/docs/chat/api/api-docs.json b/apps/website/content/docs/chat/api/api-docs.json index 19ed84ff3..5b82d5c64 100644 --- a/apps/website/content/docs/chat/api/api-docs.json +++ b/apps/website/content/docs/chat/api/api-docs.json @@ -1563,6 +1563,12 @@ "description": "Bubbled from chat-message gutter markers when the user requests a checkpoint fork.", "optional": false }, + { + "name": "genuiToolNames", + "type": "InputSignal", + "description": "Tool names whose calls produce a rendered GenUI surface rather than\nvisible text. Used to (a) filter 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>", @@ -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)", @@ -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)", @@ -2023,6 +2061,15 @@ ], "methods": [] }, + { + "name": "ChatGenuiSkeletonComponent", + "kind": "class", + "description": "", + "params": [], + "examples": [], + "properties": [], + "methods": [] + }, { "name": "ChatInputComponent", "kind": "class", @@ -3155,6 +3202,12 @@ "description": "", "optional": false }, + { + "name": "excludeToolNames", + "type": "InputSignal", + "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>", @@ -5090,7 +5143,7 @@ "name": "ContentType", "kind": "type", "description": "", - "signature": "\"undetermined\" | \"markdown\" | \"json-render\" | \"a2ui\" | \"mixed\"", + "signature": "\"pending\" | \"markdown\" | \"json-render\" | \"a2ui\" | \"mixed\"", "examples": [] }, {