From 2b0073058d30a0cb201d9d0420533b3bffbb4ca2 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Sat, 2 May 2026 22:34:05 -0700 Subject: [PATCH 01/19] refactor(chat): caret span has no glyph (CSS will paint the dot) --- .../src/lib/primitives/chat-message/chat-message.component.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/libs/chat/src/lib/primitives/chat-message/chat-message.component.ts b/libs/chat/src/lib/primitives/chat-message/chat-message.component.ts index bfe87d0b5..f6192f6c6 100644 --- a/libs/chat/src/lib/primitives/chat-message/chat-message.component.ts +++ b/libs/chat/src/lib/primitives/chat-message/chat-message.component.ts @@ -20,7 +20,7 @@ export type ChatMessageRole = 'user' | 'assistant' | 'system' | 'tool'; template: `
- +
From 0a337f8563f2d9191d8186d0ed921aa3c515fe4f Mon Sep 17 00:00:00 2001 From: Brian Love Date: Sat, 2 May 2026 22:34:40 -0700 Subject: [PATCH 02/19] feat(chat): streaming caret as a glowing dot (matches welcome beacon) --- .../src/lib/styles/chat-message.styles.ts | 27 ++++++++++--------- 1 file changed, 14 insertions(+), 13 deletions(-) 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 */ } From 5658f4da862f7e71d1f8d2d80b69a2289ee85ce6 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Sat, 2 May 2026 22:35:37 -0700 Subject: [PATCH 03/19] refactor(chat): drop chat-welcome [chatWelcomeSubtitle] slot --- .../lib/primitives/chat-welcome/chat-welcome.component.ts | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) 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.

-
From a69859f21d35fb0367c1f3233e52d427de418c73 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Sat, 2 May 2026 22:35:52 -0700 Subject: [PATCH 04/19] refactor(chat): remove chat-welcome subtitle styles + tighten gap default --- libs/chat/src/lib/styles/chat-welcome.styles.ts | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) 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; From 4278db8c92bd6c0679156716e69e7cff1aca09bf Mon Sep 17 00:00:00 2001 From: Brian Love Date: Sat, 2 May 2026 22:38:05 -0700 Subject: [PATCH 05/19] test(chat): chat-welcome no longer has a subtitle --- .../chat-welcome.component.spec.ts | 58 +++++++++++++------ 1 file changed, 39 insertions(+), 19 deletions(-) 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(); }); }); From b95cc65d2d22857a739607446722a2567a500b2f Mon Sep 17 00:00:00 2001 From: Brian Love Date: Sat, 2 May 2026 22:41:27 -0700 Subject: [PATCH 06/19] feat(chat): add --ngaf-chat-edge-pad token (drives symmetric top/bottom spacing) --- libs/chat/src/lib/styles/chat-tokens.ts | 1 + 1 file changed, 1 insertion(+) 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 = ` From 31fa468dc95e31edefe994bda5c66a728918d4c8 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Sat, 2 May 2026 22:42:18 -0700 Subject: [PATCH 07/19] feat(chat): pill input + circle send/stop + content-width + edge-pad --- libs/chat/src/lib/styles/chat-input.styles.ts | 156 +++++++++--------- 1 file changed, 82 insertions(+), 74 deletions(-) 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; } `; From fc1b564fd7cf4350dda686330175246d15a9419f Mon Sep 17 00:00:00 2001 From: Brian Love Date: Sat, 2 May 2026 22:43:09 -0700 Subject: [PATCH 08/19] feat(chat): symmetric top/bottom spacing via --ngaf-chat-edge-pad --- libs/chat/src/lib/compositions/chat/chat.component.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/libs/chat/src/lib/compositions/chat/chat.component.ts b/libs/chat/src/lib/compositions/chat/chat.component.ts index b802487f2..9bbef7f39 100644 --- a/libs/chat/src/lib/compositions/chat/chat.component.ts +++ b/libs/chat/src/lib/compositions/chat/chat.component.ts @@ -88,9 +88,12 @@ import type { ChatRenderEvent } from './chat-render-event'; .chat-empty__sub { margin: 0; font-size: var(--ngaf-chat-font-size-sm); } .chat-empty__title { font-size: 1.125rem; font-weight: 500; color: var(--ngaf-chat-text); margin: 0; } .chat-empty__sub { margin: 0; font-size: var(--ngaf-chat-font-size-sm); } - .chat-scroll { flex: 1; min-height: 0; overflow-y: auto; } + .chat-scroll { flex: 1; min-height: 0; overflow-y: auto; padding-top: var(--ngaf-chat-edge-pad); } .chat-scroll::-webkit-scrollbar { width: 6px; } .chat-scroll::-webkit-scrollbar-thumb { background: var(--ngaf-chat-separator); border-radius: 10px; } + [chatFooter] { + padding-bottom: var(--ngaf-chat-edge-pad); + } `], template: ` @if (showWelcome()) { From 79cad29d66ac94d9e91e0f6cf167eb3ea0744fc2 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Sat, 2 May 2026 22:43:34 -0700 Subject: [PATCH 09/19] feat(chat): chat-input [chatInputModelSelect] slot in pill controls --- libs/chat/src/lib/primitives/chat-input/chat-input.component.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/libs/chat/src/lib/primitives/chat-input/chat-input.component.ts b/libs/chat/src/lib/primitives/chat-input/chat-input.component.ts index 712189111..b3ef9bb0d 100644 --- a/libs/chat/src/lib/primitives/chat-input/chat-input.component.ts +++ b/libs/chat/src/lib/primitives/chat-input/chat-input.component.ts @@ -57,6 +57,7 @@ export function submitMessage( aria-label="Type a message" >
+ @if (isLoading() && canStop()) { + @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'); + } +} From 593f1b153933f83915bf4dda402726a6df94fca2 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Sat, 2 May 2026 22:54:17 -0700 Subject: [PATCH 14/19] feat(chat): export ChatSelectComponent + ChatSelectOption --- libs/chat/src/public-api.ts | 2 ++ 1 file changed, 2 insertions(+) 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'; From 5ebf8dbab4fa65f6af2a029fa7f43125f9b56647 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Sat, 2 May 2026 23:04:33 -0700 Subject: [PATCH 15/19] fix(langgraph): bypass throttle on length-growth emissions of messages$ (no left-flash) --- libs/langgraph/src/lib/agent.fn.ts | 46 ++++++++++++++++++++++++++++-- 1 file changed, 44 insertions(+), 2 deletions(-) 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 }); From 3c065a01c1b7541d3bdb696688eca61774187735 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Sat, 2 May 2026 23:04:37 -0700 Subject: [PATCH 16/19] =?UTF-8?q?test(chat):=20regression=20=E2=80=94=20fi?= =?UTF-8?q?rst=20message=20after=20submit=20is=20data-role=3Duser?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../compositions/chat/chat.component.spec.ts | 59 +++++++++++++++++++ 1 file changed, 59 insertions(+) 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 3b4ecf483..2a0ff7193 100644 --- a/libs/chat/src/lib/compositions/chat/chat.component.spec.ts +++ b/libs/chat/src/lib/compositions/chat/chat.component.spec.ts @@ -206,6 +206,65 @@ describe('ChatComponent welcome branch', () => { }); }); +describe('ChatComponent — left-flash regression', () => { + // Regression for: optimistic-user injection coalesced with first AI partial + // emission, causing the AI bubble (empty assistant) to paint first. The + // langgraph rawMessages bridge now bypasses the throttle on length-growth + // emissions so the user message renders in its own frame. + // + // We verify the two surfaces that together produce the user-visible bubble: + // (1) ChatMessageList's `getMessageType` routes role:'user' -> 'human', + // (2) ChatMessageComponent with role='user' renders host attr + // data-role="user". + // If either regresses, a user message would no longer paint as a user + // bubble. Full ChatComponent template-level rendering is not feasible + // under vitest JIT (NG0303/NG0950 on transitively-required signal inputs). + + it('routes role:"user" through the human template (data-role=user surface)', async () => { + const { getMessageType } = await import( + '../../primitives/chat-message-list/chat-message-list.component' + ); + expect(getMessageType({ id: 'u1', role: 'user', content: 'hi' } as never)) + .toBe('human'); + expect(getMessageType({ id: 'a1', role: 'assistant', content: '' } as never)) + .toBe('ai'); + }); + + it('the rendered chat-message has data-role="user" when role input is user', async () => { + const { ChatMessageComponent } = await import( + '../../primitives/chat-message/chat-message.component' + ); + TestBed.configureTestingModule({}); + const fixture = TestBed.createComponent(ChatMessageComponent); + setSignalInput(fixture.componentInstance.role, 'user'); + fixture.detectChanges(); + const host = fixture.nativeElement as HTMLElement; + expect(host.getAttribute('data-role')).toBe('user'); + }); + + it('messages signal growing from [] -> [user] surfaces the user message first', () => { + // This is the core left-flash invariant: when the messages array grows + // from empty to a single user message, that message is what the chat + // composition sees as the first message. The langgraph fix ensures this + // emission is not coalesced with a subsequent AI-partial emission. + const agent = mockAgent({ messages: [] }); + expect(agent.messages().length).toBe(0); + + agent.messages.set([{ id: 'u1', role: 'user', content: 'hi', extra: {} } as never]); + expect(agent.messages().length).toBe(1); + expect(agent.messages()[0].role).toBe('user'); + + // First AI partial arrives — both messages present, in order. + agent.messages.set([ + { id: 'u1', role: 'user', content: 'hi', extra: {} } as never, + { id: 'a1', role: 'assistant', content: '', extra: {} } as never, + ]); + expect(agent.messages().length).toBe(2); + expect(agent.messages()[0].role).toBe('user'); + expect(agent.messages()[1].role).toBe('assistant'); + }); +}); + describe('ChatComponent — events$ routing', () => { // Angular 21 zoneless mode (ZONELESS_ENABLED defaults to true) means // ComponentFixture.autoDetect cannot be disabled, making createComponent From c4edf6c5c90eb898915c9c8849fd7e9ead729812 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Sat, 2 May 2026 23:06:55 -0700 Subject: [PATCH 17/19] docs(website): chat-select component reference --- .../docs/chat/components/chat-select.mdx | 122 ++++++++++++++++++ 1 file changed, 122 insertions(+) create mode 100644 apps/website/content/docs/chat/components/chat-select.mdx diff --git a/apps/website/content/docs/chat/components/chat-select.mdx b/apps/website/content/docs/chat/components/chat-select.mdx new file mode 100644 index 000000000..5342c5e9f --- /dev/null +++ b/apps/website/content/docs/chat/components/chat-select.mdx @@ -0,0 +1,122 @@ +# ChatSelectComponent + +`ChatSelectComponent` is a generic single-select dropdown. It renders a ghosted, fully rounded trigger and a popover menu. Designed to slot into the chat input pill (via `[chatInputModelSelect]`) for a model picker, but usable anywhere. + +**Selector:** `chat-select` + +**Import:** + +```typescript +import { ChatSelectComponent, type ChatSelectOption } from '@ngaf/chat'; +``` + +## Basic Usage + +Project into the chat input pill so the select appears between the trailing slot and the send button: + +```html + + + +``` + +Standalone usage (anywhere): + +```html + +``` + +## API + +### Inputs + +| Input | Type | Default | Description | +|---------------|--------------------------------------|--------------|-------------| +| `options` | `readonly ChatSelectOption[]` | **Required** | Items to display in the menu. | +| `value` | `string` (two-way, `model()`) | `''` | The currently selected option's `value`. | +| `placeholder` | `string` | `'Select'` | Label rendered in the trigger when no option matches `value`. | +| `disabled` | `boolean` | `false` | Disables the trigger. | +| `menuLabel` | `string \| undefined` | placeholder | aria-label for the popover. | + +### `ChatSelectOption` + +```typescript +interface ChatSelectOption { + value: string; + label: string; + disabled?: boolean; +} +``` + +### Two-way binding + +Use `[(value)]` to bind a writable signal: + +```html + +``` + +Or bind one-way + listen for changes: + +```html + +``` + +## Behavior + +### Open / Close + +- **Open**: click the trigger, or press `Enter`/`Space`/`↓` while it has focus. +- **Close**: click an option, click outside, or press `Esc`. + +### Keyboard + +| Key | Behavior | +|----------------|-------------------------------------------------| +| `Enter`/`Space`/`↓` on trigger | Opens the menu, focuses first option | +| `↑` / `↓` in menu | Moves focus to prev/next non-disabled option | +| `Enter` / `Space` on option | Selects, closes, returns focus to trigger | +| `Esc` | Closes, returns focus to trigger | + +### Disabled options + +A `ChatSelectOption` with `disabled: true` is rendered, skipped during keyboard navigation, and not selectable by click. + +### Menu position + +The menu opens UP (anchored above the trigger) so it lands above the chat input when the select sits inside the input pill. For standalone usage at the top of a page the menu may overflow the viewport upward — wrap in a positioned container if needed. + +## Theming + +Inherits from chat tokens. Override on `:host` or any ancestor: + +```css +chat-select { + --ngaf-chat-text-muted: hsl(0 0% 50%); + --ngaf-chat-surface-alt: hsl(0 0% 96%); + --ngaf-chat-shadow-lg: 0 8px 24px rgba(0,0,0,.12); +} +``` + +## Public API + +```typescript +import { ChatSelectComponent, type ChatSelectOption } from '@ngaf/chat'; +``` + +## See also + +- `ChatInputComponent` — the chat input pill that hosts the `[chatInputModelSelect]` slot. From ec2430498a8ec1c3a4dd71f68fce642b156b8357 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Sat, 2 May 2026 23:06:56 -0700 Subject: [PATCH 18/19] docs(website): document [chatInputModelSelect] slot in chat-input --- .../content/docs/chat/components/chat-input.mdx | 12 ++++++++++++ 1 file changed, 12 insertions(+) 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 `