From 8140edc0a75c96a66756efb748809f4aa7150e4c Mon Sep 17 00:00:00 2001 From: Brian Love Date: Sun, 6 Sep 2026 14:41:07 -0700 Subject: [PATCH 01/29] docs(chat): state that no library component reads CHAT_CONFIG Co-Authored-By: Claude Fable 5.1 --- apps/website/content/docs/chat/api/chat-config.mdx | 6 ++++++ apps/website/content/docs/chat/api/provide-chat.mdx | 13 +++++-------- 2 files changed, 11 insertions(+), 8 deletions(-) diff --git a/apps/website/content/docs/chat/api/chat-config.mdx b/apps/website/content/docs/chat/api/chat-config.mdx index 09e09cab6..0e8c63559 100644 --- a/apps/website/content/docs/chat/api/chat-config.mdx +++ b/apps/website/content/docs/chat/api/chat-config.mdx @@ -1,7 +1,13 @@ +--- +description: The ChatConfig interface accepted by provideChat(), its three optional fields, and how to read the CHAT_CONFIG token from your own components. +--- + # ChatConfig `ChatConfig` is the configuration interface accepted by `provideChat()`. The object is stored under `CHAT_CONFIG` for application code that wants shared chat defaults. +No library component reads `CHAT_CONFIG` today. Every field below is a value your own components inject and apply; the shipped chat components ignore the token entirely. + **Import:** ```typescript diff --git a/apps/website/content/docs/chat/api/provide-chat.mdx b/apps/website/content/docs/chat/api/provide-chat.mdx index 047dd0e3a..69929ba64 100644 --- a/apps/website/content/docs/chat/api/provide-chat.mdx +++ b/apps/website/content/docs/chat/api/provide-chat.mdx @@ -1,3 +1,7 @@ +--- +description: How provideChat() registers the CHAT_CONFIG token, what ChatConfig carries, and why the values are for components you write yourself. +--- + # provideChat() `provideChat` is the provider factory that registers `@threadplane/chat` configuration in Angular's dependency injection system. Call it in your `ApplicationConfig` or at the route level when you need a shared `CHAT_CONFIG` value. @@ -123,11 +127,7 @@ export const routes: Routes = [ ### Without provideChat() -All chat components work without `provideChat()`. They use defaults: - -- Avatar label: `"A"` -- Assistant name: `"Assistant"` -- Generative UI still requires the `[views]` input on `ChatComponent` +All chat components work without `provideChat()`. No library component reads `CHAT_CONFIG` today, so the `avatarLabel` and `assistantName` values are conventions for wrapper components you write yourself rather than settings the shipped components consume. Generative UI still requires the `[views]` input on `ChatComponent`. ```typescript // This works fine without provideChat() @@ -154,21 +154,18 @@ export class SimpleChatComponent { Full ChatConfig interface reference. Set up view registries for dynamic UI components. Configuration patterns and best practices. From 95bc59143d0ba8bbea82b2d7cb464bcea41a2bba Mon Sep 17 00:00:00 2001 From: Brian Love Date: Sun, 6 Sep 2026 14:42:04 -0700 Subject: [PATCH 02/29] docs(chat): rewrite chat-select around the body-level overlay portal Co-Authored-By: Claude Fable 5.1 --- .../docs/chat/components/chat-select.mdx | 120 +++++++++++++++--- 1 file changed, 104 insertions(+), 16 deletions(-) diff --git a/apps/website/content/docs/chat/components/chat-select.mdx b/apps/website/content/docs/chat/components/chat-select.mdx index 594daff83..39f68b3d9 100644 --- a/apps/website/content/docs/chat/components/chat-select.mdx +++ b/apps/website/content/docs/chat/components/chat-select.mdx @@ -1,6 +1,10 @@ +--- +description: ChatSelectComponent renders its trigger in the host and portals its menu into a body-level overlay container, so panelClass is the styling seam. +--- + # 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. +`ChatSelectComponent` is a generic single-select dropdown. It renders a ghosted, fully rounded trigger in the host element and portals the menu into a body-level overlay container, so the menu is never clipped by an ancestor `overflow` and never trapped by an ancestor `transform`. It is designed to slot into the chat input pill (via `[chatInputModelSelect]`) as a model picker, but it works anywhere. **Selector:** `chat-select` @@ -12,7 +16,7 @@ import { ChatSelectComponent, type ChatSelectOption } from '@threadplane/chat'; ## Basic Usage -Project into the chat input pill so the select appears between the trailing slot and the send button: +Project into the chat input pill so the select appears in the control row, before the trailing slot and the send button: ```html @@ -35,6 +39,20 @@ Standalone usage (anywhere): /> ``` +## Where the markup lives + +The component renders two things, in two different places in the DOM: + +- **The trigger** — a ` - - - } - `, - changeDetection: ChangeDetectionStrategy.OnPush, -}) -export class ApprovalComponent { - protected readonly agent = injectAgent(); - - approve() { - this.agent.submit({ resume: { approved: true } }); - } - - reject() { - this.agent.submit({ resume: { approved: false } }); - } -} -``` + +`interrupt` is optional on the neutral `Agent` contract, because a runtime without human-in-the-loop support omits it. Under `strictNullChecks` the call site therefore needs `?.()`. The LangGraph adapter always provides it, and `langGraphInterrupts()` is a required signal carrying the raw array. + - - +Resume the paused run with `chat.submit({ resume: { approved: true } })`, exactly as a component would. -## Testing Errors +### Testing errors and retry -Inject errors with `emitError()` to verify your component handles failures gracefully. - - - +`emitError()` throws from inside the stream. The adapter classifies the failure through `toAgentError()`, so `error()` holds an `AgentError` with `kind`, `retryable`, and `status` rather than the raw thrown object. Note that the promise `submit()` returned rejects, so catch it: ```typescript +import { describe, it, expect } from 'vitest'; import { TestBed } from '@angular/core/testing'; -import { MockAgentTransport, provideAgent, injectAgent } from '@threadplane/langgraph'; +import { MockAgentTransport, injectAgent, provideAgent } from '@threadplane/langgraph'; + +const flush = () => new Promise((resolve) => setTimeout(resolve, 0)); describe('error handling', () => { - function configureWithTransport(transport: MockAgentTransport) { - TestBed.resetTestingModule(); + it('classifies a transport failure', async () => { + const transport = new MockAgentTransport(); TestBed.configureTestingModule({ providers: [ - provideAgent({ apiUrl: '', assistantId: 'test_agent', transport }), + provideAgent({ apiUrl: '', assistantId: 'test_agent', transport, throttle: false }), ], }); - } - - it('should surface errors and set error status', () => { - const transport = new MockAgentTransport(); - configureWithTransport(transport); - - TestBed.runInInjectionContext(() => { - const chat = injectAgent(); - - chat.submit({ message: 'Hello' }); - - // Simulate a connection failure - transport.emitError(new Error('Connection lost')); - - expect(chat.error()).toBeDefined(); - expect(chat.error()?.message).toBe('Connection lost'); - expect(chat.status()).toBe('error'); - expect(chat.isLoading()).toBe(false); - }); + const chat = TestBed.runInInjectionContext(() => injectAgent()); + + const submitted = chat.submit({ message: 'Hello' }); + transport.emitError(new Error('HTTP 500: connection lost')); + await submitted.catch(() => undefined); + await flush(); + + const err = chat.error(); + expect(err?.kind).toBe('server'); + expect(err?.retryable).toBe(true); + expect(chat.status()).toBe('error'); + expect(chat.isLoading()).toBe(false); }); - it('should recover from errors on retry', () => { + it('clears the error and starts a new run on retry()', async () => { const transport = new MockAgentTransport(); - configureWithTransport(transport); - - TestBed.runInInjectionContext(() => { - const chat = injectAgent(); - - // First attempt fails - chat.submit({ message: 'Hello' }); - transport.emitError(new Error('Timeout')); - expect(chat.status()).toBe('error'); - - // Retry succeeds - chat.submit({ message: 'Hello' }); - transport.emit([ - { - type: 'values', - messages: [{ role: 'assistant', content: 'Sorry for the delay!' }], - }, - ]); - - expect(chat.status()).not.toBe('error'); - expect(chat.messages()[0].content).toBe('Sorry for the delay!'); + TestBed.configureTestingModule({ + providers: [ + provideAgent({ apiUrl: '', assistantId: 'test_agent', transport, throttle: false }), + ], }); - }); -}); -``` + const chat = TestBed.runInInjectionContext(() => injectAgent()); - - + const submitted = chat.submit({ message: 'Hello' }); + transport.emitError(new Error('HTTP 503: service unavailable')); + await submitted.catch(() => undefined); + await flush(); + expect(chat.status()).toBe('error'); -```typescript -import { ChangeDetectionStrategy, Component } from '@angular/core'; -import { injectAgent } from '@threadplane/langgraph'; + const retried = chat.retry(); + expect(chat.error()).toBeUndefined(); + expect(chat.isLoading()).toBe(true); -@Component({ - selector: 'app-chat', - template: ` - @if (chat.error(); as err) { -
-

{{ err.message }}

- -
- } - `, - changeDetection: ChangeDetectionStrategy.OnPush, -}) -export class ChatComponent { - protected readonly chat = injectAgent(); - private lastMessage = ''; - - send(text: string) { - this.lastMessage = text; - this.chat.submit({ message: text }); - } - - retry() { - this.send(this.lastMessage); - } -} + transport.close(); + await retried; + }); +}); ``` -
-
+`retry()` re-sends the last payload, so an error banner in a component wires straight to `agent.retry()` — no need to stash the last message yourself. -## Testing Thread Switching +### Testing thread switching -Verify that switching threads loads the correct conversation state and clears the previous thread's messages. +`switchThread(null)` starts a fresh conversation: the transcript, the value, and any pending interrupt are cleared. Passing a thread id instead loads that thread through the transport `getHistory()` hook. ```typescript -describe('thread switching', () => { - it('should load new thread state on switch', () => { - const transport = new MockAgentTransport(); - const threadId = signal('thread_A'); +import { describe, it, expect } from 'vitest'; +import { TestBed } from '@angular/core/testing'; +import { MockAgentTransport, injectAgent, provideAgent } from '@threadplane/langgraph'; - TestBed.configureTestingModule({ - providers: [ - provideAgent({ - apiUrl: '', - assistantId: 'test_agent', - threadId, - transport, - }), - ], - }); +const flush = () => new Promise((resolve) => setTimeout(resolve, 0)); - TestBed.runInInjectionContext(() => { - const chat = injectAgent(); - - // Thread A has messages - transport.emit([ - { - type: 'values', - messages: [{ role: 'assistant', content: 'Thread A response' }], - }, - ]); - expect(chat.messages()[0].content).toBe('Thread A response'); - - // Switch to thread B - chat.switchThread('thread_B'); - - // Thread B loads its own state - transport.emit([ - { - type: 'values', - messages: [{ role: 'assistant', content: 'Thread B response' }], - }, - ]); - expect(chat.messages()[0].content).toBe('Thread B response'); - }); - }); - - it('should create a new thread when switching to null', () => { +describe('thread switching', () => { + it('clears the transcript when switching to a new thread', async () => { const transport = new MockAgentTransport(); - - TestBed.resetTestingModule(); TestBed.configureTestingModule({ providers: [ - provideAgent({ apiUrl: '', assistantId: 'test_agent', transport }), + provideAgent({ apiUrl: '', assistantId: 'test_agent', transport, throttle: false }), ], }); + const chat = TestBed.runInInjectionContext(() => injectAgent()); - TestBed.runInInjectionContext(() => { - const chat = injectAgent(); - - // Start a conversation - transport.emit([ - { - type: 'values', - messages: [{ role: 'assistant', content: 'Hello' }], - }, - ]); - - // Switch to new thread - chat.switchThread(null); - expect(chat.messages()).toEqual([]); - }); + const submitted = chat.submit({ message: 'Hi' }); + transport.emit([ + { type: 'messages', messages: [{ id: 'ai-1', type: 'ai', content: 'Thread A response' }] }, + ]); + await flush(); + transport.close(); + await submitted; + expect(chat.messages().at(-1)?.content).toBe('Thread A response'); + + chat.switchThread(null); + await flush(); + expect(chat.messages()).toEqual([]); }); }); ``` -## Test Setup Workflow +## Test setup workflow -Make sure `@threadplane/langgraph` is available in your test environment. MockAgentTransport ships with the main package — no extra install needed. +Make sure `@threadplane/langgraph` is available in your test environment. `MockAgentTransport` ships with the main package — no extra install is needed. Instantiate `MockAgentTransport` with optional pre-scripted batches for sequential playback, or leave it empty for imperative `emit()` calls. - -Call `TestBed.runInInjectionContext(() => { ... })` so `injectAgent()` can access Angular's injector for signal creation and cleanup. - -Pass the transport into `provideAgent({ ..., transport })` in TestBed's `providers` array. All other options (assistantId, threadId, onThreadId) work identically to production code. +Pass the transport into `provideAgent({ ..., transport })` in the TestBed `providers` array. All other options (`assistantId`, `threadId`, `onThreadId`) work identically to production code. + + +Call `TestBed.runInInjectionContext(() => injectAgent())` so the adapter can reach Angular's injector for signal creation and cleanup. - -Use `transport.emit()` for ad-hoc events, `transport.nextBatch()` for pre-scripted sequences, or `transport.emitError()` for failure scenarios. + +Use `emit()` for ad-hoc events, `nextBatch()` for pre-scripted sequences, and `emitError()` for failures — awaiting one macrotask after each call. -Read signals like `chat.messages()`, `chat.status()`, `chat.interrupt()`, and `chat.error()` to verify your component reacts correctly. +Read `chat.messages()`, `chat.status()`, `chat.interrupt?.()`, and `chat.error()` to verify the agent reacted, then `close()` the stream and await the submit promise. -## Integration Testing +## Integration testing For end-to-end confidence, run tests against a real LangGraph dev server. The LangGraph CLI starts a local server your tests can hit directly. @@ -655,7 +437,7 @@ ng test --watch=false ``` -Integration tests hit a real server and (potentially) a real LLM. Reserve them for CI pipelines or pre-release smoke tests. Use MockAgentTransport for the vast majority of your test suite — it runs in milliseconds with zero external dependencies. +Integration tests hit a real server and, potentially, a real LLM. Reserve them for CI pipelines or pre-release smoke tests. Use `provideFakeAgent()` or `MockAgentTransport` for the vast majority of your suite — both run in milliseconds with zero external dependencies. ## What's Next From 73d5773ad92e0e1b7c4df3eaccd564bc015bc31c Mon Sep 17 00:00:00 2001 From: Brian Love Date: Sun, 6 Sep 2026 14:46:31 -0700 Subject: [PATCH 10/29] docs(chat): correct A2UI catalog props and surface-component handler/fallback semantics Co-Authored-By: Claude Fable 5.1 --- .../content/docs/chat/a2ui/catalog.mdx | 23 +++++++++++-------- .../docs/chat/a2ui/surface-component.mdx | 11 +++++---- .../content/docs/chat/a2ui/surface-store.mdx | 6 +++-- 3 files changed, 24 insertions(+), 16 deletions(-) diff --git a/apps/website/content/docs/chat/a2ui/catalog.mdx b/apps/website/content/docs/chat/a2ui/catalog.mdx index 808eebb43..bf9e42a30 100644 --- a/apps/website/content/docs/chat/a2ui/catalog.mdx +++ b/apps/website/content/docs/chat/a2ui/catalog.mdx @@ -1,3 +1,7 @@ +--- +description: Every component in the built-in A2UI catalog - its Angular class, selector, props and defaults, plus how bound inputs write values back. +--- + # Component Catalog The built-in A2UI catalog provides 18 Angular components implementing the A2UI v0.9 basic catalog — display, layout, interactive controls, media, and advanced inputs. Pass `a2uiBasicCatalog()` to the `ChatComponent` `views` input to enable A2UI rendering, or instantiate it directly for custom setups. @@ -103,6 +107,7 @@ Arranges children horizontally with a flex row layout. | `childKeys` | `string[]` | Ordered list of child component IDs (from the wire `children` array) | | `justify` | `'start' \| 'center' \| 'end' \| 'spaceAround' \| 'spaceBetween' \| 'spaceEvenly' \| 'stretch'` | Main-axis arrangement. Defaults to `start` | | `align` | `'start' \| 'center' \| 'end' \| 'stretch'` | Cross-axis alignment. Defaults to `stretch` | +| `gap` | `number \| 'small' \| 'medium' \| 'large' \| undefined` | Space between children. A number is a spacing unit rendered as `n * 4` pixels; `small`/`medium`/`large` render as 8/12/16 pixels. Unset falls back to the stylesheet default | | `spec` | `Spec` | Injected automatically by the render engine | ### Column @@ -118,6 +123,7 @@ Arranges children vertically with a flex column layout. | `childKeys` | `string[]` | Ordered list of child component IDs | | `justify` | `'start' \| 'center' \| 'end' \| 'spaceAround' \| 'spaceBetween' \| 'spaceEvenly' \| 'stretch'` | Main-axis arrangement. Defaults to `start` | | `align` | `'start' \| 'center' \| 'end' \| 'stretch'` | Cross-axis alignment. Defaults to `stretch` | +| `gap` | `number \| 'small' \| 'medium' \| 'large' \| undefined` | Space between children. A number is a spacing unit rendered as `n * 4` pixels; `small`/`medium`/`large` render as 8/12/16 pixels. Unset falls back to the stylesheet default | | `spec` | `Spec` | Injected automatically by the render engine | ### Card @@ -197,6 +203,10 @@ Renders a button that dispatches an action when clicked. On the wire, a Button h Context values can be path references (resolved at click time) or bare literals. The resulting `A2uiActionMessage` is emitted on ``'s `(action)` output. + +The five components below (`TextField`, `CheckBox`, `ChoicePicker`, `DateTimeInput`, `Slider`) do not declare an `emit` prop. A user edit is written back by calling `emitBinding()` against the component's `_bindings` map, which the render engine populates from the path references in the wire payload. + + ### TextField A text input with optional label, supporting single-line, multi-line, numeric, and obscured variants. @@ -213,7 +223,6 @@ A text input with optional label, supporting single-line, multi-line, numeric, a | `placeholder` | `string` | Placeholder text | | `validationRegexp` | `string` | Client-side validation pattern | | `_bindings` | `Record` | Auto-populated from path references | -| `emit` | injected | Event emitter provided by the render engine | ```json { @@ -239,7 +248,6 @@ A labeled checkbox with two-way binding for its checked state. | `label` | `string` | Checkbox label | | `value` | `boolean` | Current checked state (resolved from a path reference) | | `_bindings` | `Record` | Auto-populated from path references | -| `emit` | injected | Event emitter provided by the render engine | ### ChoicePicker @@ -258,7 +266,6 @@ Selects one or more options from a list. Replaces the pre-v0.9 `MultipleChoice` | `displayStyle` | `'checkbox' \| 'chips'` | Visual style. Defaults to `checkbox` | | `filterable` | `boolean` | Shows a client-side option filter input when `true` | | `_bindings` | `Record` | Auto-populated from path references | -| `emit` | injected | Event emitter provided by the render engine | ```json { @@ -291,7 +298,6 @@ A date, time, or datetime input with two-way binding. | `min` | `string` | ISO 8601 lower bound (native `min`) | | `max` | `string` | ISO 8601 upper bound (native `max`) | | `_bindings` | `Record` | Auto-populated from path references | -| `emit` | injected | Event emitter provided by the render engine | The HTML input type (`date`, `time`, or `datetime-local`) is derived internally from `enableDate` and `enableTime`. @@ -319,9 +325,8 @@ A range slider input with two-way binding. | `label` | `string` | Slider label | | `value` | `number` | Current value (bind via a path reference) | | `min` | `number` | Minimum value. Defaults to `0` | -| `max` | `number` | Maximum value | +| `max` | `number` | Maximum value. Defaults to `100` | | `_bindings` | `Record` | Auto-populated from path references | -| `emit` | injected | Event emitter provided by the render engine | ```json { @@ -397,7 +402,7 @@ Renders an HTML5 `