Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
2b00730
refactor(chat): caret span has no glyph (CSS will paint the dot)
blove May 3, 2026
0a337f8
feat(chat): streaming caret as a glowing dot (matches welcome beacon)
blove May 3, 2026
5658f4d
refactor(chat): drop chat-welcome [chatWelcomeSubtitle] slot
blove May 3, 2026
a69859f
refactor(chat): remove chat-welcome subtitle styles + tighten gap def…
blove May 3, 2026
4278db8
test(chat): chat-welcome no longer has a subtitle
blove May 3, 2026
b95cc65
feat(chat): add --ngaf-chat-edge-pad token (drives symmetric top/bott…
blove May 3, 2026
31fa468
feat(chat): pill input + circle send/stop + content-width + edge-pad
blove May 3, 2026
fc1b564
feat(chat): symmetric top/bottom spacing via --ngaf-chat-edge-pad
blove May 3, 2026
79cad29
feat(chat): chat-input [chatInputModelSelect] slot in pill controls
blove May 3, 2026
245a03c
test(chat): chat-input pill + circle send + model-select slot
blove May 3, 2026
4ff223a
feat(chat): chat-select styles
blove May 3, 2026
be84afc
test(chat): failing specs for chat-select primitive
blove May 3, 2026
3c1c4ee
feat(chat): chat-select primitive (ghosted, fully rounded, popover)
blove May 3, 2026
593f1b1
feat(chat): export ChatSelectComponent + ChatSelectOption
blove May 3, 2026
5ebf8db
fix(langgraph): bypass throttle on length-growth emissions of message…
blove May 3, 2026
3c065a0
test(chat): regression — first message after submit is data-role=user
blove May 3, 2026
c4edf6c
docs(website): chat-select component reference
blove May 3, 2026
ec24304
docs(website): document [chatInputModelSelect] slot in chat-input
blove May 3, 2026
14b5d71
fix(chat): chat-select listbox a11y + bump 0.0.16 → 0.0.17
blove May 3, 2026
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
12 changes: 12 additions & 0 deletions apps/website/content/docs/chat/components/chat-input.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<chat-select>` (a model picker), but accepts any element.

```html
<chat-input [agent]="agent">
<chat-select chatInputModelSelect [options]="opts" [(value)]="selected" />
</chat-input>
```

## Styling

The component renders a `<form>` containing a `<textarea>` and a `<button>`. It uses the following CSS custom properties from the chat theme:
Expand Down
122 changes: 122 additions & 0 deletions apps/website/content/docs/chat/components/chat-select.mdx
Original file line number Diff line number Diff line change
@@ -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
<chat [agent]="agent">
<chat-select
chatInputModelSelect
[options]="models()"
[(value)]="selectedModel"
placeholder="Choose a model"
/>
</chat>
```

Standalone usage (anywhere):

```html
<chat-select
[options]="opts"
[(value)]="selected"
placeholder="Pick one"
/>
```

## 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
<chat-select [options]="opts" [(value)]="selected" />
```

Or bind one-way + listen for changes:

```html
<chat-select
[options]="opts"
[value]="selected()"
(valueChange)="selected.set($event)"
/>
```

## 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.
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.16",
"version": "0.0.17",
"exports": {
".": {
"types": "./index.d.ts",
Expand Down
59 changes: 59 additions & 0 deletions libs/chat/src/lib/compositions/chat/chat.component.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 4 additions & 1 deletion libs/chat/src/lib/compositions/chat/chat.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()) {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,9 +1,27 @@
// SPDX-License-Identifier: MIT
import { describe, it, expect } from 'vitest';
import { describe, it, expect, beforeEach } from 'vitest';
import { signal, computed } from '@angular/core';
import { submitMessage } from './chat-input.component';
import { TestBed, ComponentFixture } from '@angular/core/testing';
import { ChatInputComponent, submitMessage } from './chat-input.component';
import { mockAgent } from '../../testing/mock-agent';

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('submitMessage()', () => {
it('calls agent.submit with { message: trimmed text }', async () => {
const agent = mockAgent();
Expand Down Expand Up @@ -74,3 +92,33 @@ describe('ChatInputComponent — isDisabled computed', () => {
expect(isDisabled()).toBe(true);
});
});

describe('ChatInputComponent', () => {
let fixture: ComponentFixture<ChatInputComponent>;

beforeEach(() => {
TestBed.configureTestingModule({});
fixture = TestBed.createComponent(ChatInputComponent);
setSignalInput(fixture.componentInstance.agent, mockAgent({ isLoading: false }));
fixture.detectChanges();
});

it('renders the pill with full border-radius', () => {
const pill = (fixture.nativeElement as HTMLElement).querySelector('.chat-input__pill') as HTMLElement;
expect(pill).not.toBeNull();
const cs = getComputedStyle(pill);
expect(cs.borderRadius).toBe('9999px');
});

it('renders the send button as a circle', () => {
const btn = (fixture.nativeElement as HTMLElement).querySelector('.chat-input__send') as HTMLElement;
expect(btn).not.toBeNull();
const cs = getComputedStyle(btn);
expect(cs.borderRadius).toBe('50%');
});

it('exposes [chatInputModelSelect] slot inside the controls row', () => {
const controls = (fixture.nativeElement as HTMLElement).querySelector('.chat-input__controls');
expect(controls).not.toBeNull();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ export function submitMessage(
aria-label="Type a message"
></textarea>
<div class="chat-input__controls">
<ng-content select="[chatInputModelSelect]" />
<ng-content select="[chatInputTrailing]" />
@if (isLoading() && canStop()) {
<button
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ export type ChatMessageRole = 'user' | 'assistant' | 'system' | 'tool';
template: `
<div [class]="bodyClass()">
<ng-content />
<span class="chat-message__caret" aria-hidden="true"></span>
<span class="chat-message__caret" aria-hidden="true"></span>
</div>
<div class="chat-message__controls">
<ng-content select="[chatMessageControls]" />
Expand Down
Loading
Loading