diff --git a/libs/chat/package.json b/libs/chat/package.json index f967657c2..2bf82b204 100644 --- a/libs/chat/package.json +++ b/libs/chat/package.json @@ -1,6 +1,6 @@ { "name": "@ngaf/chat", - "version": "0.0.14", + "version": "0.0.15", "exports": { ".": { "types": "./index.d.ts", 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 b661f3445..3b4ecf483 100644 --- a/libs/chat/src/lib/compositions/chat/chat.component.spec.ts +++ b/libs/chat/src/lib/compositions/chat/chat.component.spec.ts @@ -2,7 +2,7 @@ import { describe, it, expect } from 'vitest'; import { TestBed } from '@angular/core/testing'; import { Subject } from 'rxjs'; -import { signal, effect, DestroyRef, inject } from '@angular/core'; +import { signal, effect, DestroyRef, inject, Injector, runInInjectionContext } from '@angular/core'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { HumanMessage, AIMessage } from '@langchain/core/messages'; import { ChatComponent } from './chat.component'; @@ -144,6 +144,68 @@ describe('ChatComponent — prevRole', () => { }); }); +// Helper: write into an InputSignal by reaching its underlying SIGNAL node. +// (See streaming-markdown.component.spec.ts for the same pattern — vitest JIT +// does not process signal-input metadata so componentRef.setInput throws +// NG0303 for required signal inputs, and creating the fixture's full template +// trips required-input checks on child primitives that are bound transitively.) +function setSignalInput(sig: unknown, value: T): void { + const obj = sig as Record; + const signalSymbol = Object.getOwnPropertySymbols(obj).find( + (s) => s.description === 'SIGNAL', + ); + if (!signalSymbol) throw new Error('Could not find SIGNAL symbol on input'); + const node = obj[signalSymbol] as { + applyValueToInputSignal?: (n: unknown, v: T) => void; + value?: T; + }; + if (typeof node.applyValueToInputSignal === 'function') { + node.applyValueToInputSignal(node, value); + } else { + node.value = value; + } +} + +describe('ChatComponent welcome branch', () => { + // We construct the real ChatComponent inside an injection context and + // directly write its signal inputs using the SIGNAL writer (the same pattern + // as streaming-markdown.component.spec.ts). This exercises the real + // showWelcome computed declared on the class — not a re-implementation — + // without invoking the template (which transitively requires inputs on + // child primitives that JIT cannot resolve). + + it('shows welcome when messages are empty', () => { + TestBed.configureTestingModule({}); + const injector = TestBed.inject(Injector); + runInInjectionContext(injector, () => { + const c = new ChatComponent(); + setSignalInput(c.agent, mockAgent({ messages: [] })); + expect(c.showWelcome()).toBe(true); + }); + }); + + it('hides welcome when messages exist', () => { + TestBed.configureTestingModule({}); + const injector = TestBed.inject(Injector); + runInInjectionContext(injector, () => { + const c = new ChatComponent(); + setSignalInput(c.agent, mockAgent({ messages: [new HumanMessage('hi')] })); + expect(c.showWelcome()).toBe(false); + }); + }); + + it('hides welcome when welcomeDisabled=true', () => { + TestBed.configureTestingModule({}); + const injector = TestBed.inject(Injector); + runInInjectionContext(injector, () => { + const c = new ChatComponent(); + setSignalInput(c.agent, mockAgent({ messages: [] })); + setSignalInput(c.welcomeDisabled, true); + expect(c.showWelcome()).toBe(false); + }); + }); +}); + describe('ChatComponent — events$ routing', () => { // Angular 21 zoneless mode (ZONELESS_ENABLED defaults to true) means // ComponentFixture.autoDetect cannot be disabled, making createComponent diff --git a/libs/chat/src/lib/compositions/chat/chat.component.ts b/libs/chat/src/lib/compositions/chat/chat.component.ts index 6803f5247..aad960615 100644 --- a/libs/chat/src/lib/compositions/chat/chat.component.ts +++ b/libs/chat/src/lib/compositions/chat/chat.component.ts @@ -25,6 +25,7 @@ import { ChatStreamingMdComponent } from '../../streaming/streaming-markdown.com import { ChatToolCallsComponent } from '../../primitives/chat-tool-calls/chat-tool-calls.component'; import { ChatSubagentsComponent } from '../../primitives/chat-subagents/chat-subagents.component'; import { ChatMessageActionsComponent } from '../../primitives/chat-message-actions/chat-message-actions.component'; +import { ChatWelcomeComponent } from '../../primitives/chat-welcome/chat-welcome.component'; import { A2uiSurfaceComponent } from '../../a2ui/surface.component'; import { createContentClassifier, type ContentClassifier } from '../../streaming/content-classifier'; import { messageContent } from '../shared/message-utils'; @@ -40,7 +41,7 @@ import type { ChatRenderEvent } from './chat-render-event'; ChatInputComponent, ChatTypingIndicatorComponent, ChatErrorComponent, ChatInterruptComponent, ChatThreadListComponent, ChatGenerativeUiComponent, ChatStreamingMdComponent, ChatToolCallsComponent, ChatSubagentsComponent, A2uiSurfaceComponent, - ChatMessageActionsComponent, + ChatMessageActionsComponent, ChatWelcomeComponent, ], changeDetection: ChangeDetectionStrategy.OnPush, styles: [CHAT_HOST_TOKENS, ` @@ -54,6 +55,11 @@ import type { ChatRenderEvent } from './chat-render-event'; overflow: hidden; background: var(--ngaf-chat-bg); } + :host > chat-welcome { + display: flex; + flex: 1 1 auto; + width: 100%; + } .chat-shell { display: flex; flex: 1; min-height: 0; overflow: hidden; } .chat-shell__sidebar { width: 240px; @@ -87,6 +93,11 @@ import type { ChatRenderEvent } from './chat-render-event'; .chat-scroll::-webkit-scrollbar-thumb { background: var(--ngaf-chat-separator); border-radius: 10px; } `], template: ` + @if (showWelcome()) { + + + + } @else {
@if (threads().length > 0) {
+ } `, }) export class ChatComponent { @@ -186,6 +191,14 @@ export class ChatComponent { readonly handlers = input) => unknown | Promise>>({}); readonly threads = input([]); readonly activeThreadId = input(''); + readonly welcomeDisabled = input(false); + + readonly showWelcome = computed(() => { + if (this.welcomeDisabled()) return false; + const a = this.agent() as unknown as { isThreadLoading?: () => boolean }; + if (a.isThreadLoading?.()) return false; + return this.agent().messages().length === 0; + }); readonly threadSelected = output(); readonly renderEvent = output(); /** Emitted when the user clicks the regenerate button on an assistant message. */ diff --git a/libs/chat/src/lib/primitives/chat-welcome/chat-welcome-suggestion.component.spec.ts b/libs/chat/src/lib/primitives/chat-welcome/chat-welcome-suggestion.component.spec.ts new file mode 100644 index 000000000..72df7b28d --- /dev/null +++ b/libs/chat/src/lib/primitives/chat-welcome/chat-welcome-suggestion.component.spec.ts @@ -0,0 +1,54 @@ +// SPDX-License-Identifier: MIT +// NOTE: Angular signal-based inputs cannot be exercised via +// componentRef.setInput() under vitest JIT (NG0303). We follow the established +// pattern used by streaming-markdown.component.spec.ts and use a +// SIGNAL-symbol writer for input signals, with TestBed.createComponent to +// exercise template rendering and DOM event wiring. +import { describe, it, expect, beforeEach } from 'vitest'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { ChatWelcomeSuggestionComponent } from './chat-welcome-suggestion.component'; + +function setSignalInput(sig: unknown, value: T): void { + const obj = sig as Record; + const signalSymbol = Object.getOwnPropertySymbols(obj).find( + (s) => s.description === 'SIGNAL', + ); + if (!signalSymbol) throw new Error('Could not find SIGNAL symbol on input'); + const node = obj[signalSymbol] as { + applyValueToInputSignal?: (n: unknown, v: T) => void; + value?: T; + }; + if (typeof node.applyValueToInputSignal === 'function') { + node.applyValueToInputSignal(node, value); + } else { + node.value = value; + } +} + +describe('ChatWelcomeSuggestionComponent', () => { + let fixture: ComponentFixture; + + beforeEach(() => { + TestBed.configureTestingModule({}); + fixture = TestBed.createComponent(ChatWelcomeSuggestionComponent); + setSignalInput(fixture.componentInstance.label, 'Tell me about yourself'); + setSignalInput(fixture.componentInstance.value, 'tell-me'); + fixture.detectChanges(); + }); + + it('renders the label', () => { + const el = fixture.nativeElement as HTMLElement; + expect(el.textContent).toContain('Tell me about yourself'); + }); + + it('emits select with the value on click', () => { + let emitted: string | undefined; + fixture.componentInstance.selected.subscribe((v: string) => { + emitted = v; + }); + const button = (fixture.nativeElement as HTMLElement).querySelector('button'); + expect(button).not.toBeNull(); + button!.click(); + expect(emitted).toBe('tell-me'); + }); +}); diff --git a/libs/chat/src/lib/primitives/chat-welcome/chat-welcome-suggestion.component.ts b/libs/chat/src/lib/primitives/chat-welcome/chat-welcome-suggestion.component.ts new file mode 100644 index 000000000..90dbff885 --- /dev/null +++ b/libs/chat/src/lib/primitives/chat-welcome/chat-welcome-suggestion.component.ts @@ -0,0 +1,23 @@ +// SPDX-License-Identifier: MIT +import { ChangeDetectionStrategy, Component, input, output } from '@angular/core'; +import { CHAT_HOST_TOKENS } from '../../styles/chat-tokens'; +import { CHAT_WELCOME_SUGGESTION_STYLES } from '../../styles/chat-welcome.styles'; + +@Component({ + selector: 'chat-welcome-suggestion', + standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, + styles: [CHAT_HOST_TOKENS, CHAT_WELCOME_SUGGESTION_STYLES], + template: ` + + `, +}) +export class ChatWelcomeSuggestionComponent { + readonly label = input.required(); + readonly value = input.required(); + readonly selected = output(); +} diff --git a/libs/chat/src/lib/primitives/chat-welcome/chat-welcome.component.spec.ts b/libs/chat/src/lib/primitives/chat-welcome/chat-welcome.component.spec.ts new file mode 100644 index 000000000..ba545e2f8 --- /dev/null +++ b/libs/chat/src/lib/primitives/chat-welcome/chat-welcome.component.spec.ts @@ -0,0 +1,32 @@ +// SPDX-License-Identifier: MIT +import { describe, it, expect, beforeEach } from 'vitest'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { ChatWelcomeComponent } from './chat-welcome.component'; + +describe('ChatWelcomeComponent', () => { + let fixture: ComponentFixture; + + beforeEach(() => { + TestBed.configureTestingModule({}); + fixture = TestBed.createComponent(ChatWelcomeComponent); + fixture.detectChanges(); + }); + + it('renders default greeting', () => { + const el = fixture.nativeElement as HTMLElement; + expect(el.querySelector('.chat-welcome__title')?.textContent).toContain('How can I help?'); + expect(el.querySelector('.chat-welcome__subtitle')?.textContent).toContain('Ask anything'); + }); + + it('renders the beacon dot', () => { + const el = fixture.nativeElement as HTMLElement; + expect(el.querySelector('.chat-welcome__beacon')).not.toBeNull(); + }); + + it('exposes slots for title, subtitle, input, suggestions', () => { + const el = fixture.nativeElement as HTMLElement; + expect(el.querySelector('.chat-welcome__inner')).not.toBeNull(); + expect(el.querySelector('.chat-welcome__input')).not.toBeNull(); + expect(el.querySelector('.chat-welcome__suggestions')).not.toBeNull(); + }); +}); diff --git a/libs/chat/src/lib/primitives/chat-welcome/chat-welcome.component.ts b/libs/chat/src/lib/primitives/chat-welcome/chat-welcome.component.ts new file mode 100644 index 000000000..2d0d558f8 --- /dev/null +++ b/libs/chat/src/lib/primitives/chat-welcome/chat-welcome.component.ts @@ -0,0 +1,43 @@ +// SPDX-License-Identifier: MIT +import { ChangeDetectionStrategy, Component } from '@angular/core'; +import { CHAT_HOST_TOKENS } from '../../styles/chat-tokens'; +import { CHAT_WELCOME_STYLES } from '../../styles/chat-welcome.styles'; + +/** + * Empty-state owner. Renders a centered greeting + slot-projected input + + * optional vertical suggestion rows. Mounted only when the parent chat has + * no messages and welcome is not disabled. + * + * Slots: + * [chatWelcomeTitle] — replaces the default

"How can I help?" + * [chatWelcomeSubtitle] — replaces the default

"Ask anything to get started." + * [chatWelcomeInput] — projects the chat input into the center column + * [chatWelcomeSuggestions] — projects suggestion rows below the input + * + * Host CSS variables (override on :host or any ancestor): + * --ngaf-chat-welcome-max-width default 36rem + * --ngaf-chat-welcome-gap default 1.5rem + * --ngaf-chat-welcome-padding default 24px + */ +@Component({ + selector: 'chat-welcome', + standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, + styles: [CHAT_HOST_TOKENS, CHAT_WELCOME_STYLES], + template: ` +

+ + +

How can I help?

+
+ +

Ask anything to get started.

+
+
+
+ +
+
+ `, +}) +export class ChatWelcomeComponent {} diff --git a/libs/chat/src/lib/styles/chat-tokens.ts b/libs/chat/src/lib/styles/chat-tokens.ts index 1dae6e800..d4cb91d33 100644 --- a/libs/chat/src/lib/styles/chat-tokens.ts +++ b/libs/chat/src/lib/styles/chat-tokens.ts @@ -90,6 +90,10 @@ const KEYFRAMES = ` from { opacity: 0; } to { opacity: 1; } } + @keyframes ngaf-chat-welcome-mount { + from { opacity: 0; transform: translateY(-8px); } + to { opacity: 1; transform: translateY(0); } + } `; /** diff --git a/libs/chat/src/lib/styles/chat-welcome.styles.ts b/libs/chat/src/lib/styles/chat-welcome.styles.ts new file mode 100644 index 000000000..bdc514c75 --- /dev/null +++ b/libs/chat/src/lib/styles/chat-welcome.styles.ts @@ -0,0 +1,94 @@ +// libs/chat/src/lib/styles/chat-welcome.styles.ts +// SPDX-License-Identifier: MIT +export const CHAT_WELCOME_STYLES = ` + :host { + display: flex; + flex: 1 1 auto; + align-items: center; + justify-content: center; + width: 100%; + min-height: 0; + padding: var(--ngaf-chat-welcome-padding, 24px); + box-sizing: border-box; + animation: ngaf-chat-welcome-mount 200ms ease-out both; + } + .chat-welcome__inner { + display: flex; + flex-direction: column; + align-items: center; + gap: var(--ngaf-chat-welcome-gap, 1.5rem); + width: 100%; + max-width: var(--ngaf-chat-welcome-max-width, 36rem); + text-align: center; + } + .chat-welcome__beacon { + display: inline-block; + width: 14px; + height: 14px; + border-radius: 50%; + background: radial-gradient(circle at 30% 30%, + var(--ngaf-chat-text) 0%, + var(--ngaf-chat-text-muted) 70%, + transparent 100%); + animation: ngaf-chat-pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite; + margin-bottom: 8px; + } + .chat-welcome__title { + margin: 0; + font-size: 1.25rem; + font-weight: 500; + color: var(--ngaf-chat-text); + line-height: 1.3; + } + @media (min-width: 768px) { + .chat-welcome__title { font-size: 1.5rem; } + } + .chat-welcome__subtitle { + margin: 0; + font-size: var(--ngaf-chat-font-size-sm); + color: var(--ngaf-chat-text-muted); + line-height: 1.5; + } + .chat-welcome__input { + width: 100%; + margin-top: 0.5rem; + } + .chat-welcome__suggestions { + width: 100%; + display: flex; + flex-direction: column; + align-items: stretch; + gap: 0; + } + .chat-welcome__suggestions:empty { display: none; } +`; + +export const CHAT_WELCOME_SUGGESTION_STYLES = ` + :host { display: block; width: 100%; } + .chat-welcome-suggestion { + width: 100%; + display: flex; + align-items: center; + gap: 0.75rem; + padding: 12px 14px; + background: transparent; + border: 0; + border-bottom: 1px solid var(--ngaf-chat-separator); + color: var(--ngaf-chat-text); + font-family: inherit; + font-size: var(--ngaf-chat-font-size-sm); + text-align: left; + cursor: pointer; + transition: background 150ms ease; + } + .chat-welcome-suggestion:hover { background: var(--ngaf-chat-surface-alt); } + .chat-welcome-suggestion:focus-visible { + outline: 2px solid var(--ngaf-chat-text-muted); + outline-offset: -2px; + } + .chat-welcome-suggestion__label { flex: 1 1 auto; } + .chat-welcome-suggestion__chevron { + color: var(--ngaf-chat-text-muted); + font-size: 1.1em; + } +`; diff --git a/libs/chat/src/public-api.ts b/libs/chat/src/public-api.ts index 7e4ebc928..16cdf5ec7 100644 --- a/libs/chat/src/public-api.ts +++ b/libs/chat/src/public-api.ts @@ -52,6 +52,8 @@ export { ChatThreadListComponent } from './lib/primitives/chat-thread-list/chat- export type { Thread } from './lib/primitives/chat-thread-list/chat-thread-list.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'; +export { ChatWelcomeSuggestionComponent } from './lib/primitives/chat-welcome/chat-welcome-suggestion.component'; // DI provider export { provideChat, CHAT_CONFIG } from './lib/provide-chat';