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": [] }, { 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); 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 {} 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('*'); 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') { 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';