Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions apps/website/content/docs/chat/a2ui/catalog.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,10 @@ Renders a horizontal rule. Has no props.

Layout components receive `childKeys` — an array of component IDs — and render each child via json-render's `RenderElementComponent`. They also receive the full `spec` so that child elements can be looked up by key.

<Callout type="info" title="Six layout components total">
Row, Column, Card, and List are below. Two more layout components — [Tabs](#tabs) and [Modal](#modal) — live further down (after the Interactive section) because they also expose interactive state. All six share the same `childKeys` + `spec` rendering model.
</Callout>

### Row

Arranges children horizontally with a flex row layout.
Expand Down Expand Up @@ -155,6 +159,10 @@ Renders a button that dispatches an action when clicked.
| `validationResult` | `A2uiValidationResult` | Pre-computed validation result — button is disabled if `valid` is `false` |
| `emit` | injected | Event emitter provided by the render engine |

<Callout type="info" title="child vs childKeys">
The Angular `A2uiButtonComponent` input is `childKeys` (an array) — that's the resolved shape your component code sees. On the A2UI wire, a Button definition may use the singular `child` field (e.g. `"child": "submit_label"`, as shown in the [overview](/docs/chat/a2ui/overview)); the surface pipeline normalizes that singular `child` into the `childKeys` array before the component renders. Author surfaces with whichever the agent emits; both resolve to `childKeys` here.
</Callout>

**Action types:**

```json
Expand Down
65 changes: 62 additions & 3 deletions apps/website/content/docs/chat/a2ui/surface-component.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -14,16 +14,24 @@ import { A2uiSurfaceComponent } from '@threadplane/chat';

| Input | Type | Required | Description |
|-------|------|----------|-------------|
| `surface` | `A2uiSurface` | Yes | The surface to render |
| `catalog` | `ViewRegistry` | Yes | Component registry used to resolve A2UI type names to Angular components |
| `surface` | `A2uiSurface` | No* | The surface to render (legacy input shape) |
| `state` | `A2uiSurfaceState` | No* | Chat-side surface state driving progressive rendering. Preferred over `surface`; takes priority when both are set |
| `catalog` | `A2uiViews \| ViewRegistry` | Yes | Component registry used to resolve A2UI type names to Angular components |
| `handlers` | `Record<string, (params) => unknown \| Promise<unknown>>` | No | Consumer A2UI action handlers for `a2ui:localAction`. Merged with the built-in `a2ui:event` / `a2ui:localAction` handlers; consumer handlers take priority |
| `surfaceFallback` | `Type<unknown> \| undefined` | No | Component mounted while no `root` component has arrived yet (instead of rendering nothing) |

<Callout type="info" title="Provide one of surface or state">
`surface` and `state` are both optional individually, but the component needs one of them to render. `state` is the preferred progressive-rendering path; `surface` is the legacy shape.
</Callout>

## Outputs

| Output | Type | Description |
|--------|------|-------------|
| `events` | `RenderEvent` | Emits render events from the underlying `RenderSpecComponent` -- handler execution, state changes, and lifecycle signals |
| `action` | `A2uiActionMessage` | Emits an agent-bound action message when an A2UI `event` action fires. Forward it to your agent (e.g. `agent.submit`) to resume the agent loop |

The `events` output forwards all `RenderEvent` emissions from the render engine. This includes events triggered by the default A2UI handlers (`a2ui:event` and `a2ui:localAction`) as well as any state change or lifecycle events.
The `events` output forwards all `RenderEvent` emissions from the render engine. This includes events triggered by the default A2UI handlers (`a2ui:event` and `a2ui:localAction`) as well as any state change or lifecycle events. The `action` output is the agent-bound path: when a surface's `event` action fires, the component builds an `A2uiActionMessage` and emits it here so you can send it back to the agent.

## How It Works

Expand Down Expand Up @@ -90,6 +98,57 @@ export class AgentPanelComponent {
The surface must contain a component with `id: 'root'`. If no root component has been received yet (for example, while the agent is still streaming), `A2uiSurfaceComponent` renders nothing until one arrives.
</Callout>

### Wiring handlers and actions

The example above renders a surface but ignores interaction. To run client-owned behavior and forward agent-bound events, pass `[handlers]` and bind `(action)` / `(events)`. Consumer handlers run for `a2ui:localAction`; the `(action)` output carries the agent-bound `A2uiActionMessage`.

```typescript
import { Component, inject, signal } from '@angular/core';
import { Router } from '@angular/router';
import { A2uiSurfaceComponent, a2uiBasicCatalog } from '@threadplane/chat';
import { injectAgent } from '@threadplane/langgraph';
import type { A2uiActionMessage, A2uiSurface } from '@threadplane/chat';
import type { RenderEvent } from '@threadplane/render';

@Component({
selector: 'app-interactive-surface',
standalone: true,
imports: [A2uiSurfaceComponent],
template: `
<a2ui-surface
[surface]="surface()"
[catalog]="catalog"
[handlers]="handlers"
(action)="sendToAgent($event)"
(events)="onEvent($event)"
/>
`,
})
export class InteractiveSurfaceComponent {
private readonly router = inject(Router);
private readonly agent = injectAgent();

readonly catalog = a2uiBasicCatalog();
readonly surface = signal<A2uiSurface | undefined>(undefined);

// Client-owned local actions (a2ui:localAction).
readonly handlers = {
openDetails: async (args: Record<string, unknown>) => {
await this.router.navigate(['/orders', args['orderId']]);
},
};

// Agent-bound action: forward it back to the agent loop.
sendToAgent(message: A2uiActionMessage) {
this.agent.submit({ resume: message });
}

onEvent(event: RenderEvent) {
console.log('render event', event);
}
}
```

## surfaceToSpec()

The conversion function is exported for testing or custom rendering pipelines.
Expand Down
18 changes: 18 additions & 0 deletions apps/website/content/docs/chat/api/content-classifier.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,24 @@ jsonClassifier.spec(); // { root: 'r1', elements: {} }
classifier.dispose();
```

### A2UI content

When the snapshot starts with the `---a2ui_JSON---` prefix, the classifier switches to A2UI mode and parses the remaining lines as JSONL. Read `type()` (=> `'a2ui'`) and `a2uiSurfaces()` for the parsed surfaces:

```typescript
const a2uiClassifier = createContentClassifier();

a2uiClassifier.update(
'---a2ui_JSON---\n' +
'{"surfaceId":"s1","components":[{"id":"root","component":{"Text":{"text":{"literalString":"Hi"}}}}]}',
);

a2uiClassifier.type(); // 'a2ui'
a2uiClassifier.a2uiSurfaces(); // Map<string, A2uiSurface> keyed by surface ID

a2uiClassifier.dispose();
```

<Callout type="tip" title="Delta processing">
Pass the **full** message content each time, not just new characters. The classifier tracks `processedLength` internally and only processes the delta.
</Callout>
27 changes: 27 additions & 0 deletions apps/website/content/docs/chat/api/mock-agent.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,33 @@ expect(agent.submitCalls[1]?.input).toEqual({
});
```

## Lifecycle and History

`mockAgent()` exposes a readonly `lifecycle.streamStartedAt` signal plus an `_internal.streamStartedAt` writable escape hatch. Use the escape hatch to drive stream-start timing in a test without running a full submit/stream cycle:

```typescript
const agent = mockAgent();

// Flip the underlying writable; lifecycle.streamStartedAt() reflects it.
agent._internal.streamStartedAt.set(Date.now());
expect(agent.lifecycle.streamStartedAt()).not.toBeNull();
```

The `withInterrupt`, `withSubagents`, and `history` options unlock optional signals — `interrupt`, `subagents`, and `history` are `undefined` unless you opt in, so tests must null-check (or use `?.`) before driving them:

```typescript
const agent = mockAgent({
withInterrupt: true,
withSubagents: true,
history: [], // pass an AgentCheckpoint[] to enable agent.history
});

// Each optional signal is writable when its option was set.
agent.interrupt?.set({ id: 'int-1', value: 'Approve this action?', resumable: true });
agent.history?.set([{ id: 'ckpt-1', values: {} }]);
expect(agent.subagents?.()).toBeDefined();
```

## LangGraph-Specific Tests

Use `mockLangGraphAgent()` from `@threadplane/langgraph` only when the test needs LangGraph-specific signals such as raw LangGraph messages, checkpoint state, branches, queued runs, or transport metadata.
5 changes: 5 additions & 0 deletions apps/website/content/docs/chat/components/chat-debug.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -99,3 +99,8 @@ The footer button is labelled in expanded and drawer modes. In collapsed mode it
The debug implementation lives under `@threadplane/chat/debug`; the main `@threadplane/chat` entry point no longer exports it. In the canonical Angular demo, normal production builds set `THREADPLANE_CHAT_DEBUG=false` and externalize `@threadplane/chat/debug`, so the debug implementation is absent from the emitted bundle. The `production-debug` build opts back in with `THREADPLANE_CHAT_DEBUG=true`.

Keep debug controls for your app outside the debug panel. The debug panel intentionally exposes a small fixed surface so consumers do not expect demo-specific controls to appear in their own applications.

## See also

- [Time travel](/docs/langgraph/guides/time-travel) — what the `replayRequested` / `forkRequested` checkpoint hooks plug into, and how to wire replay and fork against the agent's history.
- [`<chat-sidenav>`](/docs/chat/concepts/primitives-vs-compositions#compositions) — the layout composition that can own the debug launcher (see Sidenav Integration above).
16 changes: 16 additions & 0 deletions apps/website/content/docs/chat/components/chat-interrupt-panel.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,22 @@ handleInterrupt(action: InterruptAction) {
}
```

<Callout type="info" title="The panel doesn't resume the agent for you">
`chat-interrupt-panel` only emits which button the user clicked — it never calls `agent.submit()` itself. You own resumption. `openEditor()` and `focusInput()` above are your own helpers (collect an edited proposal or a typed reply); once you have the value, send it back with `agent.submit({ resume })`. The shape of `resume` is whatever your graph's interrupt handler expects.

```typescript
// After the user edits the agent's proposal:
async resumeWithEdit(edited: Record<string, unknown>) {
await this.chatRef.submit({ resume: { action: 'edit', data: edited } });
}

// After the user types a free-text reply:
async resumeWithResponse(text: string) {
await this.chatRef.submit({ resume: { action: 'respond', text } });
}
```
</Callout>

## API

### Inputs
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -126,14 +126,16 @@ For custom templates, you can access `message.content` directly and handle the t

```html
<ng-template chatMessageTemplate="ai" let-message>
@if (isString(message.content)) {
@if (typeof message.content === 'string') {
<div [innerHTML]="renderMd(message.content)"></div>
} @else {
<pre>{{ message.content | json }}</pre>
}
</ng-template>
```

`typeof message.content === 'string'` works directly in the template — `content` is `string | ContentBlock[]`, so the string branch renders markdown and the array branch falls back to serialized JSON. (If you prefer a named guard, the exported `messageContent()` utility above collapses both cases to a string for you.)

## Full Example

```typescript
Expand All @@ -153,9 +155,9 @@ import {
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<chat-message-list [agent]="chatAgent">
<ng-template chatMessageTemplate="human" let-message>
<ng-template chatMessageTemplate="human" let-message let-idx="index">
<div style="display: flex; justify-content: flex-end; margin-bottom: 1rem;">
<div style="background: var(--ngaf-chat-primary); color: var(--ngaf-chat-on-primary); border-radius: var(--ngaf-chat-radius-bubble); padding: 0.5rem 1rem; max-width: 70%;">
<div style="background: var(--ngaf-chat-primary); color: var(--ngaf-chat-on-primary); border-radius: var(--ngaf-chat-radius-bubble); padding: 0.5rem 1rem; max-width: 70%;" [attr.data-message-index]="idx">
{{ message.content }}
</div>
</div>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,8 +31,10 @@ The `Subagent` type comes from `@threadplane/chat`. It provides reactive state f
| Property | Type | Description |
|----------|------|-------------|
| `toolCallId` | `string` | The ID linking this subagent to its parent tool call |
| `name` | `string \| undefined` | Optional human-readable name. The card's "Subagent" label uses it when present |
| `status()` | `Signal<'pending' \| 'running' \| 'complete' \| 'error'>` | Current execution status |
| `messages()` | `Signal<Message[]>` | Messages produced by the subagent |
| `state()` | `Signal<Record<string, unknown>>` | Arbitrary subagent state exposed by the runtime |

## Card Behavior

Expand Down
28 changes: 28 additions & 0 deletions apps/website/content/docs/chat/components/chat-tool-call-card.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -55,3 +55,31 @@ interface ToolCallInfo {
<!-- Always-expanded -->
<chat-tool-call-card [toolCall]="tc" [defaultCollapsed]="false" />
```

Define `tc` as a `ToolCallInfo` in the component class:

```typescript
import { Component } from '@angular/core';
import { ChatToolCallCardComponent, type ToolCallInfo } from '@threadplane/chat';

@Component({
selector: 'app-tool-call-demo',
standalone: true,
imports: [ChatToolCallCardComponent],
template: `<chat-tool-call-card [toolCall]="tc" />`,
})
export class ToolCallDemoComponent {
protected readonly tc: ToolCallInfo = {
id: 'call_1',
name: 'search_web',
args: { query: 'angular' },
status: 'complete',
result: [{ title: 'Angular', url: 'https://angular.dev' }],
};
}
```

## See also

- [`<chat-tool-calls>`](/docs/chat/components/chat-tool-calls) — renders many cards for an agent's tool-call list; the usual place this card is mounted.
- [`chatToolCallTemplate`](/docs/chat/components/chat-tool-call-template) — replace the card UX for a specific tool name.
55 changes: 55 additions & 0 deletions apps/website/content/docs/chat/components/chat-trace.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,61 @@ export class TracedChatComponent {
}
```

### Driving traces from `agent.toolCalls()`

The agent's `toolCalls()` signal is the real source for tool-call traces. `ToolCall.status` and `TraceState` use almost the same names, but `ToolCall` reports `'complete'` where `TraceState` expects `'done'` — map that one explicitly:

```typescript
import { ChangeDetectionStrategy, Component, computed } from '@angular/core';
import { injectAgent, provideAgent } from '@threadplane/langgraph';
import { signal } from '@angular/core';
import {
ChatTraceComponent,
ChatMessageListComponent,
} from '@threadplane/chat';
import type { ToolCallStatus } from '@threadplane/chat';
import type { TraceState } from '@threadplane/chat';

function toTraceState(status: ToolCallStatus): TraceState {
// 'pending' | 'running' | 'error' carry over; only 'complete' is renamed.
return status === 'complete' ? 'done' : status;
}

@Component({
selector: 'app-tool-traces',
standalone: true,
imports: [ChatMessageListComponent, ChatTraceComponent],
providers: [provideAgent({ assistantId: 'chat', threadId: signal(null) })],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<chat-message-list [agent]="chatAgent">
<ng-template chatMessageTemplate="ai" let-message>
@for (call of traces(); track call.id) {
<chat-trace [state]="call.state">
<span traceLabel>{{ call.name }}</span>
<pre>{{ call.args | json }}</pre>
</chat-trace>
}
<div>{{ message.content }}</div>
</ng-template>
</chat-message-list>
`,
})
export class ToolTracesComponent {
protected readonly chatAgent = injectAgent();

// Map each ToolCall to a trace-friendly view, translating status -> TraceState.
protected readonly traces = computed(() =>
this.chatAgent.toolCalls().map((call) => ({
id: call.id,
name: call.name,
args: call.args,
state: toTraceState(call.status),
})),
);
}
```

## Styling

The component uses the following `--ngaf-chat-*` tokens:
Expand Down
Loading
Loading