diff --git a/apps/website/content/docs/chat/components/chat-input.mdx b/apps/website/content/docs/chat/components/chat-input.mdx index 7e25f376e..973db590a 100644 --- a/apps/website/content/docs/chat/components/chat-input.mdx +++ b/apps/website/content/docs/chat/components/chat-input.mdx @@ -96,6 +96,18 @@ function submitMessage( The function calls `ref.submit({ message: trimmed })` under the hood. +## Slots + +### `[chatInputModelSelect]` + +Projects content into the controls row of the input pill, between `[chatInputTrailing]` and the send button. Designed for `` (a model picker), but accepts any element. + +```html + + + +``` + ## Styling The component renders a `
` containing a `
+ @if (isLoading() && canStop()) {
diff --git a/libs/chat/src/lib/primitives/chat-select/chat-select.component.spec.ts b/libs/chat/src/lib/primitives/chat-select/chat-select.component.spec.ts new file mode 100644 index 000000000..b7c933268 --- /dev/null +++ b/libs/chat/src/lib/primitives/chat-select/chat-select.component.spec.ts @@ -0,0 +1,106 @@ +// libs/chat/src/lib/primitives/chat-select/chat-select.component.spec.ts +// SPDX-License-Identifier: MIT +import { describe, it, expect, beforeEach } from 'vitest'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { ChatSelectComponent, type ChatSelectOption } from './chat-select.component'; + +const OPTS: readonly ChatSelectOption[] = [ + { value: 'a', label: 'Alpha' }, + { value: 'b', label: 'Bravo' }, + { value: 'c', label: 'Charlie', disabled: true }, +]; + +// SIGNAL-symbol writer for input signals (canonical pattern; setInput() +// silently no-ops with NG0303 under vitest JIT). +function setSignalInput(fixture: ComponentFixture, name: string, value: T): void { + const inputs = fixture.componentInstance as Record; + const sig = inputs[name]; + 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 "${name}"`); + 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('ChatSelectComponent', () => { + let fixture: ComponentFixture; + let host: HTMLElement; + + beforeEach(() => { + fixture = TestBed.createComponent(ChatSelectComponent); + setSignalInput(fixture, 'options', OPTS); + setSignalInput(fixture, 'value', 'a'); + fixture.detectChanges(); + host = fixture.nativeElement as HTMLElement; + }); + + it('renders the selected option label', () => { + expect(host.querySelector('.chat-select__label')?.textContent).toContain('Alpha'); + }); + + it('falls back to placeholder when value not in options', () => { + setSignalInput(fixture, 'value', ''); + setSignalInput(fixture, 'placeholder', 'Pick one'); + fixture.detectChanges(); + expect(host.querySelector('.chat-select__label')?.textContent).toContain('Pick one'); + }); + + it('opens the menu on trigger click', () => { + expect(host.querySelector('.chat-select__menu')).toBeNull(); + host.querySelector('.chat-select__trigger')!.click(); + fixture.detectChanges(); + expect(host.querySelector('.chat-select__menu')).not.toBeNull(); + const opts = host.querySelectorAll('.chat-select__option'); + expect(opts.length).toBe(3); + }); + + it('emits valueChange and closes the menu on option click', () => { + let emitted: string | undefined; + fixture.componentInstance.value.subscribe((v) => { emitted = v; }); + host.querySelector('.chat-select__trigger')!.click(); + fixture.detectChanges(); + const bravo = host.querySelectorAll('.chat-select__option')[1]; + bravo.click(); + fixture.detectChanges(); + expect(emitted).toBe('b'); + expect(host.querySelector('.chat-select__menu')).toBeNull(); + }); + + it('does not select a disabled option', () => { + let emitted: string | undefined; + fixture.componentInstance.value.subscribe((v) => { emitted = v; }); + host.querySelector('.chat-select__trigger')!.click(); + fixture.detectChanges(); + const charlie = host.querySelectorAll('.chat-select__option')[2]; + expect(charlie.disabled).toBe(true); + charlie.click(); + fixture.detectChanges(); + expect(emitted).toBeUndefined(); + }); + + it('closes the menu on Escape', () => { + host.querySelector('.chat-select__trigger')!.click(); + fixture.detectChanges(); + expect(host.querySelector('.chat-select__menu')).not.toBeNull(); + const evt = new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }); + host.querySelector('.chat-select__menu')!.dispatchEvent(evt); + fixture.detectChanges(); + expect(host.querySelector('.chat-select__menu')).toBeNull(); + }); + + it('disables the trigger when [disabled]=true', () => { + setSignalInput(fixture, 'disabled', true); + fixture.detectChanges(); + const btn = host.querySelector('.chat-select__trigger')!; + expect(btn.disabled).toBe(true); + }); +}); diff --git a/libs/chat/src/lib/primitives/chat-select/chat-select.component.ts b/libs/chat/src/lib/primitives/chat-select/chat-select.component.ts new file mode 100644 index 000000000..80114ae9b --- /dev/null +++ b/libs/chat/src/lib/primitives/chat-select/chat-select.component.ts @@ -0,0 +1,195 @@ +// libs/chat/src/lib/primitives/chat-select/chat-select.component.ts +// SPDX-License-Identifier: MIT +import { + ChangeDetectionStrategy, + Component, + DestroyRef, + ElementRef, + computed, + effect, + inject, + input, + model, + signal, + DOCUMENT, +} from '@angular/core'; +import { CHAT_HOST_TOKENS } from '../../styles/chat-tokens'; +import { CHAT_SELECT_STYLES } from '../../styles/chat-select.styles'; + +export interface ChatSelectOption { + value: string; + label: string; + disabled?: boolean; +} + +/** + * Generic single-select dropdown. Designed to slot into the chat input pill + * (via [chatInputModelSelect]) but usable anywhere. + * + * Inputs: + * options — array of { value, label, disabled? }; required + * value — currently selected value (two-way via model()) + * placeholder — trigger label when no option matches; default 'Select' + * disabled — disables the trigger; default false + * menuLabel — aria-label for the popover; defaults to placeholder + */ +@Component({ + selector: 'chat-select', + standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, + styles: [CHAT_HOST_TOKENS, CHAT_SELECT_STYLES], + template: ` + + @if (open()) { +
+ @for (opt of options(); track opt.value) { + + } +
+ } + `, +}) +export class ChatSelectComponent { + readonly options = input.required(); + readonly value = model(''); + readonly placeholder = input('Select'); + readonly disabled = input(false); + readonly menuLabel = input(undefined); + + protected readonly open = signal(false); + + protected readonly currentLabel = computed(() => { + const v = this.value(); + const match = this.options().find((o) => o.value === v); + return match?.label ?? this.placeholder(); + }); + + private readonly hostEl = inject(ElementRef).nativeElement as HTMLElement; + private readonly document = inject(DOCUMENT); + private readonly destroyRef = inject(DestroyRef); + + constructor() { + let onDocClick: ((e: Event) => void) | null = null; + effect(() => { + const isOpen = this.open(); + const win = this.document.defaultView; + if (!win) return; + if (isOpen && !onDocClick) { + onDocClick = (e) => { + const path = (e as Event & { composedPath?: () => EventTarget[] }).composedPath?.() ?? []; + if (!path.includes(this.hostEl as EventTarget)) { + this.open.set(false); + } + }; + win.addEventListener('mousedown', onDocClick, true); + } else if (!isOpen && onDocClick) { + win.removeEventListener('mousedown', onDocClick, true); + onDocClick = null; + } + }); + this.destroyRef.onDestroy(() => { + if (onDocClick) { + const win = this.document.defaultView; + win?.removeEventListener('mousedown', onDocClick, true); + onDocClick = null; + } + }); + } + + protected toggle(): void { + if (this.disabled()) return; + this.open.update((v) => !v); + } + + protected selectOption(opt: ChatSelectOption): void { + if (opt.disabled) return; + this.value.set(opt.value); + this.open.set(false); + } + + protected onTriggerKeydown(e: KeyboardEvent): void { + if (this.disabled()) return; + if (e.key === 'Enter' || e.key === ' ' || e.key === 'ArrowDown') { + e.preventDefault(); + this.open.set(true); + requestAnimationFrame(() => this.focusOption(0)); + } + } + + protected onMenuKeydown(e: KeyboardEvent): void { + if (e.key === 'Escape') { + e.preventDefault(); + this.open.set(false); + this.focusTrigger(); + return; + } + if (e.key === 'ArrowDown' || e.key === 'ArrowUp') { + e.preventDefault(); + this.moveFocus(e.key === 'ArrowDown' ? 1 : -1); + return; + } + if (e.key === 'Enter' || e.key === ' ') { + const t = e.target as HTMLElement; + if (t.classList.contains('chat-select__option')) { + e.preventDefault(); + (t as HTMLButtonElement).click(); + } + } + } + + private focusOption(index: number): void { + const opts = this.queryOptions(); + opts[index]?.focus(); + } + + private focusTrigger(): void { + this.queryTrigger()?.focus(); + } + + private moveFocus(dir: 1 | -1): void { + const opts = this.queryOptions().filter((b) => !b.disabled); + if (!opts.length) return; + const active = this.document.activeElement as HTMLElement | null; + const idx = active ? opts.indexOf(active as HTMLButtonElement) : -1; + const next = (idx + dir + opts.length) % opts.length; + opts[next]?.focus(); + } + + private queryOptions(): HTMLButtonElement[] { + return Array.from(this.hostEl.querySelectorAll('.chat-select__option')); + } + + private queryTrigger(): HTMLButtonElement | null { + return this.hostEl.querySelector('.chat-select__trigger'); + } +} 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 index ba545e2f8..19467c8b3 100644 --- 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 @@ -1,32 +1,52 @@ // SPDX-License-Identifier: MIT -import { describe, it, expect, beforeEach } from 'vitest'; -import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { describe, it, expect } from 'vitest'; +import { Component } from '@angular/core'; +import { 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(); - }); +@Component({ + standalone: true, + imports: [ChatWelcomeComponent], + template: ` + + + + `, +}) +class Host {} +describe('ChatWelcomeComponent', () => { 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'); + TestBed.configureTestingModule({}); + const fx = TestBed.createComponent(Host); + fx.detectChanges(); + const win = fx.nativeElement.querySelector('chat-welcome') as HTMLElement; + expect(win.querySelector('.chat-welcome__title')?.textContent).toContain('How can I help?'); }); it('renders the beacon dot', () => { - const el = fixture.nativeElement as HTMLElement; - expect(el.querySelector('.chat-welcome__beacon')).not.toBeNull(); + TestBed.configureTestingModule({}); + const fx = TestBed.createComponent(Host); + fx.detectChanges(); + const win = fx.nativeElement.querySelector('chat-welcome') as HTMLElement; + expect(win.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(); + TestBed.configureTestingModule({}); + const fx = TestBed.createComponent(Host); + fx.detectChanges(); + const win = fx.nativeElement.querySelector('chat-welcome') as HTMLElement; + expect(win.querySelector('.chat-welcome__inner')).not.toBeNull(); + expect(win.querySelector('.chat-welcome__input')).not.toBeNull(); + expect(win.querySelector('.chat-welcome__suggestions')).not.toBeNull(); + }); + + it('does not render a subtitle slot', () => { + TestBed.configureTestingModule({}); + const fx = TestBed.createComponent(Host); + fx.detectChanges(); + const win = fx.nativeElement.querySelector('chat-welcome') as HTMLElement; + expect(win.querySelector('.chat-welcome__subtitle')).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 index 2d0d558f8..e7e4c48bc 100644 --- a/libs/chat/src/lib/primitives/chat-welcome/chat-welcome.component.ts +++ b/libs/chat/src/lib/primitives/chat-welcome/chat-welcome.component.ts @@ -10,13 +10,12 @@ import { CHAT_WELCOME_STYLES } from '../../styles/chat-welcome.styles'; * * 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-gap default 1.25rem * --ngaf-chat-welcome-padding default 24px */ @Component({ @@ -30,9 +29,6 @@ import { CHAT_WELCOME_STYLES } from '../../styles/chat-welcome.styles';

How can I help?

- -

Ask anything to get started.

-
diff --git a/libs/chat/src/lib/styles/chat-input.styles.ts b/libs/chat/src/lib/styles/chat-input.styles.ts index 8a89a5efa..e6f79c8e9 100644 --- a/libs/chat/src/lib/styles/chat-input.styles.ts +++ b/libs/chat/src/lib/styles/chat-input.styles.ts @@ -1,78 +1,86 @@ // libs/chat/src/lib/styles/chat-input.styles.ts // SPDX-License-Identifier: MIT export const CHAT_INPUT_STYLES = ` - :host { display: block; background: var(--ngaf-chat-bg); } - .chat-input__container { padding: 0 0 15px 0; background: var(--ngaf-chat-bg); } - .chat-input__pill { - cursor: text; - position: relative; - background: var(--ngaf-chat-surface-alt); - border: 1px solid var(--ngaf-chat-separator); - border-radius: var(--ngaf-chat-radius-input); - padding: 12px 14px; - padding-right: 56px; - min-height: 75px; - margin: 0 auto; - width: 95%; - box-sizing: border-box; - display: flex; - align-items: flex-start; - transition: border-color 200ms ease; - } - .chat-input__pill:focus-within { border-color: var(--ngaf-chat-text-muted); } - .chat-input__textarea { - flex: 1; - outline: 0; - border: 0; - resize: none; - background: transparent; - color: var(--ngaf-chat-text); - font-family: inherit; - font-size: var(--ngaf-chat-font-size-sm); - line-height: 1.5rem; - width: 100%; - margin: 0; - padding: 0; - field-sizing: content; - min-height: 1.5rem; - max-height: 9rem; - overflow-y: auto; - } - .chat-input__textarea::placeholder { color: var(--ngaf-chat-text-muted); opacity: 1; } - .chat-input__textarea::-webkit-scrollbar { width: 6px; } - .chat-input__textarea::-webkit-scrollbar-thumb { background: var(--ngaf-chat-separator); border-radius: 10px; } - .chat-input__controls { - position: absolute; - right: 14px; - bottom: 12px; - display: flex; - gap: 3px; - } - .chat-input__send { - width: 32px; - height: 32px; - display: flex; - align-items: center; - justify-content: center; - border: 0; - background: var(--ngaf-chat-primary); - color: var(--ngaf-chat-on-primary); - border-radius: 9999px; - cursor: pointer; - transition: transform 200ms ease, background 200ms ease; - } - .chat-input__send:hover:not(:disabled) { transform: scale(1.05); } - .chat-input__send:disabled { - background: var(--ngaf-chat-surface-alt); - color: var(--ngaf-chat-muted); - cursor: not-allowed; - opacity: 0.7; - } - .chat-input__send--stop { - /* Slightly subdued vs the active send so the user doesn't fear the button. */ - background: var(--ngaf-chat-text); - color: var(--ngaf-chat-bg); - } - .chat-input__send--stop:hover { transform: scale(1.05); } - .chat-input__send svg { width: 16px; height: 16px; } +:host { + display: block; + width: 100%; + padding: 0 var(--ngaf-chat-edge-pad); + box-sizing: border-box; +} + +.chat-input__container { + width: 100%; + max-width: var(--ngaf-chat-max-width); + margin: 0 auto; +} + +.chat-input__pill { + display: flex; + align-items: center; + gap: 8px; + background: var(--ngaf-chat-surface); + border: 1px solid var(--ngaf-chat-separator); + border-radius: 9999px; + padding: 8px 8px 8px 16px; + min-height: 56px; + box-sizing: border-box; +} + +.chat-input__textarea { + flex: 1 1 auto; + border: 0; + outline: none; + resize: none; + background: transparent; + color: var(--ngaf-chat-text); + font: inherit; + font-size: 1rem; + line-height: 1.5; + max-height: 1.5em; + padding: 0; + field-sizing: content; + overflow-y: auto; +} +.chat-input__textarea::placeholder { color: var(--ngaf-chat-text-muted); } +.chat-input__textarea::-webkit-scrollbar { width: 4px; } +.chat-input__textarea::-webkit-scrollbar-thumb { background: var(--ngaf-chat-separator); border-radius: 4px; } + +.chat-input__controls { + display: flex; + align-items: center; + gap: 4px; + flex: none; +} + +.chat-input__send, +.chat-input__send--stop { + width: 36px; + height: 36px; + border-radius: 50%; + border: 0; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + transition: opacity 150ms ease, transform 150ms ease, background 150ms ease; + padding: 0; +} +.chat-input__send { + background: var(--ngaf-chat-text); + color: var(--ngaf-chat-bg); +} +.chat-input__send:disabled { + opacity: 0.35; + cursor: not-allowed; + background: var(--ngaf-chat-text-muted); +} +.chat-input__send:not(:disabled):hover { transform: scale(1.05); } +.chat-input__send svg { width: 16px; height: 16px; } + +.chat-input__send--stop { + background: var(--ngaf-chat-text-muted); + color: var(--ngaf-chat-bg); +} +.chat-input__send--stop:hover { transform: scale(1.05); } +.chat-input__send--stop svg { width: 14px; height: 14px; } `; diff --git a/libs/chat/src/lib/styles/chat-message.styles.ts b/libs/chat/src/lib/styles/chat-message.styles.ts index 1f9f556ad..fcd37baa5 100644 --- a/libs/chat/src/lib/styles/chat-message.styles.ts +++ b/libs/chat/src/lib/styles/chat-message.styles.ts @@ -38,22 +38,23 @@ export const CHAT_MESSAGE_STYLES = ` .chat-message__caret { display: none; - margin-left: 2px; - margin-top: 0.25rem; - color: var(--ngaf-chat-text-muted); - vertical-align: text-bottom; + width: 8px; + height: 8px; + border-radius: 50%; + vertical-align: middle; + margin-left: 4px; + margin-bottom: 2px; + background: radial-gradient(circle at 30% 30%, + var(--ngaf-chat-text) 0%, + var(--ngaf-chat-text-muted) 70%, + transparent 100%); + box-shadow: 0 0 6px var(--ngaf-chat-text-muted); + animation: ngaf-chat-caret-fade-in 200ms ease-out 300ms forwards, + ngaf-chat-pulse 2s cubic-bezier(0.4, 0, 0.6, 1) 500ms infinite; + opacity: 0; } :host([data-role="assistant"][data-current="true"][data-streaming="true"]) .chat-message__caret { display: inline-block; - /* The caret is suppressed for the first 300ms of streaming so quick - responses (one-or-two-token "hello"-style replies) never flash the - cursor. Past 300ms the smooth pulse takes over (copilotkit-style) - — easier on the eyes than a hard blink during long streams. - Note: animations restart whenever the element is created/inserted, - so this delay re-applies on every new streaming message. */ - opacity: 0; - animation: ngaf-chat-caret-fade-in 200ms ease-out 300ms forwards, - ngaf-chat-pulse 2s cubic-bezier(0.4, 0, 0.6, 1) 500ms infinite; } .chat-message__plain { /* system / tool fallback */ } diff --git a/libs/chat/src/lib/styles/chat-select.styles.ts b/libs/chat/src/lib/styles/chat-select.styles.ts new file mode 100644 index 000000000..280e1793c --- /dev/null +++ b/libs/chat/src/lib/styles/chat-select.styles.ts @@ -0,0 +1,80 @@ +// libs/chat/src/lib/styles/chat-select.styles.ts +// SPDX-License-Identifier: MIT +export const CHAT_SELECT_STYLES = ` + :host { + display: inline-block; + position: relative; + } + .chat-select__trigger { + height: 32px; + padding: 0 10px; + border: 0; + border-radius: 9999px; + background: transparent; + color: var(--ngaf-chat-text-muted); + font: inherit; + font-size: var(--ngaf-chat-font-size-sm); + display: inline-flex; + align-items: center; + gap: 4px; + cursor: pointer; + transition: background 120ms ease, color 120ms ease; + } + .chat-select__trigger:hover:not(:disabled) { + background: var(--ngaf-chat-surface-alt); + color: var(--ngaf-chat-text); + } + .chat-select__trigger:disabled { + opacity: 0.5; + cursor: not-allowed; + } + .chat-select__chevron { + width: 12px; + height: 12px; + transition: transform 120ms ease; + flex: none; + } + .chat-select__trigger.is-open .chat-select__chevron { + transform: rotate(180deg); + } + .chat-select__menu { + position: absolute; + bottom: calc(100% + 8px); + right: 0; + min-width: 180px; + max-height: 320px; + overflow-y: auto; + background: var(--ngaf-chat-surface); + border: 1px solid var(--ngaf-chat-separator); + border-radius: 12px; + box-shadow: var(--ngaf-chat-shadow-lg); + padding: 4px; + z-index: 10; + } + .chat-select__option { + display: block; + width: 100%; + text-align: left; + border: 0; + background: transparent; + padding: 8px 10px; + border-radius: 8px; + color: var(--ngaf-chat-text); + font: inherit; + font-size: var(--ngaf-chat-font-size-sm); + cursor: pointer; + } + .chat-select__option:hover:not(:disabled), + .chat-select__option:focus-visible { + background: var(--ngaf-chat-surface-alt); + outline: none; + } + .chat-select__option.is-active { + background: var(--ngaf-chat-surface-alt); + font-weight: 500; + } + .chat-select__option:disabled { + opacity: 0.4; + cursor: not-allowed; + } +`; diff --git a/libs/chat/src/lib/styles/chat-tokens.ts b/libs/chat/src/lib/styles/chat-tokens.ts index d4cb91d33..2ae0789d4 100644 --- a/libs/chat/src/lib/styles/chat-tokens.ts +++ b/libs/chat/src/lib/styles/chat-tokens.ts @@ -67,6 +67,7 @@ const SPACING_TOKENS = ` --ngaf-chat-space-5: 20px; --ngaf-chat-space-6: 24px; --ngaf-chat-space-8: 32px; + --ngaf-chat-edge-pad: 16px; `; const KEYFRAMES = ` diff --git a/libs/chat/src/lib/styles/chat-welcome.styles.ts b/libs/chat/src/lib/styles/chat-welcome.styles.ts index bdc514c75..7118e3b84 100644 --- a/libs/chat/src/lib/styles/chat-welcome.styles.ts +++ b/libs/chat/src/lib/styles/chat-welcome.styles.ts @@ -16,7 +16,7 @@ export const CHAT_WELCOME_STYLES = ` display: flex; flex-direction: column; align-items: center; - gap: var(--ngaf-chat-welcome-gap, 1.5rem); + gap: var(--ngaf-chat-welcome-gap, 1.25rem); width: 100%; max-width: var(--ngaf-chat-welcome-max-width, 36rem); text-align: center; @@ -43,12 +43,6 @@ export const CHAT_WELCOME_STYLES = ` @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; diff --git a/libs/chat/src/public-api.ts b/libs/chat/src/public-api.ts index 16cdf5ec7..1b3a5636f 100644 --- a/libs/chat/src/public-api.ts +++ b/libs/chat/src/public-api.ts @@ -54,6 +54,8 @@ export { ChatTimelineComponent } from './lib/primitives/chat-timeline/chat-timel 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'; +export { ChatSelectComponent } from './lib/primitives/chat-select/chat-select.component'; +export type { ChatSelectOption } from './lib/primitives/chat-select/chat-select.component'; // DI provider export { provideChat, CHAT_CONFIG } from './lib/provide-chat'; diff --git a/libs/langgraph/src/lib/agent.fn.ts b/libs/langgraph/src/lib/agent.fn.ts index 1b9770e28..ea96b0ebc 100644 --- a/libs/langgraph/src/lib/agent.fn.ts +++ b/libs/langgraph/src/lib/agent.fn.ts @@ -1,7 +1,7 @@ // SPDX-License-Identifier: MIT import { inject, DestroyRef, computed, effect, - isSignal, Signal, + isSignal, Signal, signal, } from '@angular/core'; import { AGENT_CONFIG } from './agent.provider'; import { toSignal, toObservable } from '@angular/core/rxjs-interop'; @@ -162,7 +162,49 @@ export function agent< // Convert to Angular Signals (must happen in injection context) const value = toSignal(maybeThrottle(values$), { initialValue: init }); - const rawMessages = toSignal(maybeThrottle(messages$), { initialValue: [] as BaseMessage[] }); + // rawMessages is hand-rolled instead of `toSignal(maybeThrottle(messages$))` + // to avoid the leading/trailing throttle collapsing the optimistic-user + // injection into the same emission as the first AI partial. We bypass the + // throttle whenever the messages array grows in length (new message added) + // so user-visible message additions render in their own frame. Same-length + // updates (token-by-token AI streaming) still get throttled to ~60fps. + const rawMessagesSig = signal([]); + { + let lastLen = 0; + let throttleHandle: ReturnType | null = null; + let pending: BaseMessage[] | null = null; + const flushPending = () => { + throttleHandle = null; + if (pending) { + rawMessagesSig.set(pending); + pending = null; + } + }; + messages$ + .pipe(takeUntil(destroy$)) + .subscribe((m) => { + if (m.length !== lastLen) { + // Length changed (add or remove): emit synchronously, cancel pending. + lastLen = m.length; + if (throttleHandle !== null) { + clearTimeout(throttleHandle); + throttleHandle = null; + pending = null; + } + rawMessagesSig.set(m); + } else if (ms > 0) { + // Same-length update (token streaming): coalesce within the throttle window. + pending = m; + if (throttleHandle === null) { + throttleHandle = setTimeout(flushPending, ms); + } + } else { + // No throttle configured: emit immediately. + rawMessagesSig.set(m); + } + }); + } + const rawMessages = rawMessagesSig.asReadonly(); const statusSig = toSignal(status$, { initialValue: ResourceStatus.Idle }); const errorSig = toSignal(error$, { initialValue: undefined as unknown }); const hasValueSig = toSignal(hasValue$, { initialValue: false });