Skip to content
Merged
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
2 changes: 1 addition & 1 deletion libs/chat/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@ngaf/chat",
"version": "0.0.14",
"version": "0.0.15",
"exports": {
".": {
"types": "./index.d.ts",
Expand Down
64 changes: 63 additions & 1 deletion libs/chat/src/lib/compositions/chat/chat.component.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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<T>(sig: unknown, value: T): void {
const obj = sig as Record<symbol, unknown>;
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
Expand Down
29 changes: 21 additions & 8 deletions libs/chat/src/lib/compositions/chat/chat.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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, `
Expand All @@ -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;
Expand Down Expand Up @@ -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()) {
<chat-welcome>
<chat-input chatWelcomeInput [agent]="agent()" [submitOnEnter]="true" placeholder="Type a message..." />
</chat-welcome>
} @else {
<div class="chat-shell">
@if (threads().length > 0) {
<aside class="chat-shell__sidebar">
Expand All @@ -101,13 +112,6 @@ import type { ChatRenderEvent } from './chat-render-event';
<chat-window>
<ng-content select="[chatHeader]" chatHeader />
<div chatBody class="chat-scroll" #scrollContainer>
<div class="chat-empty" [hidden]="agent().messages().length !== 0 || agent().isLoading()">
<ng-content select="[chatEmptyState]">
<p class="chat-empty__title">How can I help?</p>
<p class="chat-empty__sub">Ask anything to get started.</p>
</ng-content>
</div>

<chat-message-list [agent]="agent()">
<ng-template chatMessageTemplate="human" let-message let-i="index">
<chat-message [role]="'user'" [prevRole]="prevRole(i)">{{ messageContent(message) }}</chat-message>
Expand Down Expand Up @@ -177,6 +181,7 @@ import type { ChatRenderEvent } from './chat-render-event';
</chat-window>
</div>
</div>
}
`,
})
export class ChatComponent {
Expand All @@ -186,6 +191,14 @@ export class ChatComponent {
readonly handlers = input<Record<string, (params: Record<string, unknown>) => unknown | Promise<unknown>>>({});
readonly threads = input<Thread[]>([]);
readonly activeThreadId = input<string>('');
readonly welcomeDisabled = input<boolean>(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<string>();
readonly renderEvent = output<ChatRenderEvent>();
/** Emitted when the user clicks the regenerate button on an assistant message. */
Expand Down
Original file line number Diff line number Diff line change
@@ -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<T>(sig: unknown, value: T): void {
const obj = sig as Record<symbol, unknown>;
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<ChatWelcomeSuggestionComponent>;

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');
});
});
Original file line number Diff line number Diff line change
@@ -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: `
<button type="button" class="chat-welcome-suggestion" (click)="selected.emit(value())">
<ng-content select="[chatWelcomeSuggestionIcon]" />
<span class="chat-welcome-suggestion__label">{{ label() }}</span>
<span class="chat-welcome-suggestion__chevron" aria-hidden="true">›</span>
</button>
`,
})
export class ChatWelcomeSuggestionComponent {
readonly label = input.required<string>();
readonly value = input.required<string>();
readonly selected = output<string>();
}
Original file line number Diff line number Diff line change
@@ -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<ChatWelcomeComponent>;

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();
});
});
Original file line number Diff line number Diff line change
@@ -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 <h1> "How can I help?"
* [chatWelcomeSubtitle] — replaces the default <p> "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: `
<div class="chat-welcome__inner">
<span class="chat-welcome__beacon" aria-hidden="true"></span>
<ng-content select="[chatWelcomeTitle]">
<h1 class="chat-welcome__title">How can I help?</h1>
</ng-content>
<ng-content select="[chatWelcomeSubtitle]">
<p class="chat-welcome__subtitle">Ask anything to get started.</p>
</ng-content>
<div class="chat-welcome__input"><ng-content select="[chatWelcomeInput]" /></div>
<div class="chat-welcome__suggestions">
<ng-content select="[chatWelcomeSuggestions]" />
</div>
</div>
`,
})
export class ChatWelcomeComponent {}
4 changes: 4 additions & 0 deletions libs/chat/src/lib/styles/chat-tokens.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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); }
}
`;

/**
Expand Down
Loading
Loading