Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
24477aa
docs(spec): render VIEW_REGISTRY drift + chat re-export cleanup + nits
blove Jun 3, 2026
5f3f9ee
docs(plan): render VIEW_REGISTRY drift + chat re-export cleanup + nit…
blove Jun 3, 2026
5e7f82f
feat(render): add overrideViews helper for override-semantics composi…
blove Jun 3, 2026
3e93527
feat(render): wire VIEW_REGISTRY as third-priority registry fallback …
blove Jun 3, 2026
3059621
refactor(chat): drop re-export of provideViews/VIEW_REGISTRY from @th…
blove Jun 3, 2026
2bc4627
docs(chat): fix markdown view override example (MARKDOWN_VIEW_REGISTR…
blove Jun 3, 2026
350ac81
docs(website): correct markdown view override path in chat guide
blove Jun 3, 2026
c4cf3b1
docs(render): document engine resolution order + overrideViews helper
blove Jun 3, 2026
c71799c
docs(website): add overrideViews + clarify provideViews engine consum…
blove Jun 3, 2026
cb56454
docs(website): regenerate API JSON for chat re-export drop + render o…
blove Jun 3, 2026
31f6181
docs(ag-ui): document Agent.interrupt + submit({ resume }) HITL feature
blove Jun 3, 2026
8deda0d
docs(website): add ag-ui interrupts guide + register in nav
blove Jun 3, 2026
8345ec6
chore: gitignore Playwright test-results under cockpit examples
blove Jun 3, 2026
39638fa
fix(cockpit): rename dashboard.md → generative-ui.md to match manifes…
blove Jun 3, 2026
400d051
fix(cockpit): align e2e wiring test with post-#567 ag-ui example shape
blove Jun 4, 2026
bd889ec
Merge branch 'main' into claude/render-drift-cleanup
blove Jun 4, 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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ dist/
tmp/
apps/website/test-results/
apps/website/public/demo/
cockpit/**/angular/test-results/

# Env
.env
Expand Down
7 changes: 6 additions & 1 deletion apps/cockpit/cockpit-e2e-wiring.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,12 @@ function activeCockpitE2eWiring(): E2eWiring[] {
const projectRoot = dirname(projectJsonPath);
const globalSetupPath = join(projectRoot, 'e2e/global-setup-impl.ts');
const globalSetup = readFileSync(globalSetupPath, 'utf8');
const langgraphCwd = parseStringProperty(globalSetup, 'langgraphCwd');
// langgraph-shaped global-setup uses `langgraphCwd`; ag-ui-shaped
// global-setup (createAgUiGlobalSetup) uses `pythonCwd`. Both name
// the python project's cwd — accept either.
const langgraphCwd =
parseStringProperty(globalSetup, 'langgraphCwd') ??
parseStringProperty(globalSetup, 'pythonCwd');

// Post-port-registry migration: ports are imported from
// cockpit/ports.mjs rather than living as literals in
Expand Down
5 changes: 3 additions & 2 deletions apps/cockpit/scripts/capability-registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,8 +49,9 @@ export const capabilities: readonly Capability[] = [
{ id: 'c-debug', product: 'chat', topic: 'debug', angularProject: 'cockpit-chat-debug-angular', port: 4509, pythonPort: 5509, pythonDir: 'cockpit/chat/debug/python', graphName: 'c-debug' },
{ id: 'c-theming', product: 'chat', topic: 'theming', angularProject: 'cockpit-chat-theming-angular', port: 4510, pythonPort: 5510, pythonDir: 'cockpit/chat/theming/python', graphName: 'c-theming' },
{ id: 'c-a2ui', product: 'chat', topic: 'a2ui', angularProject: 'cockpit-chat-a2ui-angular', port: 4511, pythonPort: 5511, pythonDir: 'cockpit/chat/a2ui/python', graphName: 'c-a2ui' },
// AG-UI capabilities (in-process FakeAgent; no Python backend, not deployed to LangSmith)
{ id: 'ag-ui-streaming', product: 'ag-ui', topic: 'streaming', angularProject: 'cockpit-ag-ui-streaming-angular', port: 4600 },
// AG-UI capabilities (uvicorn ag-ui-langgraph backend; not deployed to LangSmith)
{ id: 'ag-ui-interrupts', product: 'ag-ui', topic: 'interrupts', angularProject: 'cockpit-ag-ui-interrupts-angular', port: 4320, pythonPort: 5320, pythonDir: 'cockpit/ag-ui/interrupts/python' },
{ id: 'ag-ui-streaming', product: 'ag-ui', topic: 'streaming', angularProject: 'cockpit-ag-ui-streaming-angular', port: 4321, pythonPort: 5321, pythonDir: 'cockpit/ag-ui/streaming/python' },
] as const;

export function findCapability(id: string): Capability | undefined {
Expand Down
164 changes: 164 additions & 0 deletions apps/website/content/docs/ag-ui/guides/interrupts.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
# Interrupts (Human-in-the-Loop)

Interrupts let your AG-UI agent pause mid-run and hand control to a human. The agent proposes an action, the run freezes, your Angular UI shows an approval dialog, the user decides, and the agent resumes with the human's decision. This guide covers the AG-UI adapter specifics. For the broader conceptual model — lifecycle stages, timeout strategies, typed payloads — see the [LangGraph interrupts guide](/docs/langgraph/guides/interrupts).

## The Wire Format

AG-UI interrupts arrive as a `CUSTOM` event with `name: "on_interrupt"`:

```json
{
"type": "CUSTOM",
"name": "on_interrupt",
"value": "{\"kind\":\"refund_approval\",\"amount\":47.50,\"customer_id\":\"cus_a8x2k\",\"reason\":\"Duplicate charge\"}"
}
```

Two things to note:

- The `value` is a **JSON string**, not an object. The `ag-ui-langgraph` Python package serializes the interrupt payload via `dump_json_safe` before emitting the event.
- The adapter `JSON.parse`s the string automatically. Consumers always see the structured object — you never need to parse it yourself.

**Structuring the payload:** Use a `kind` field so `<chat-approval-card matchKind="…">` can match the right interrupt:

```python
decision = interrupt({
"kind": "refund_approval",
"amount": amount,
"customer_id": customer_id,
"reason": reason,
})
```

## Reading the Interrupt in Your Component

`injectAgent()` exposes a `interrupt()` signal that is populated whenever the adapter receives an `on_interrupt` CUSTOM event. Pair it with `<chat-approval-card>` from `@threadplane/chat` to render an approval dialog without manual event wiring:

```typescript
import { Component } from '@angular/core';
import { ChatComponent, ChatApprovalCardComponent } from '@threadplane/chat';
import { injectAgent } from '@threadplane/ag-ui';
import type { ChatApprovalAction } from '@threadplane/chat';

@Component({
standalone: true,
imports: [ChatComponent, ChatApprovalCardComponent],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<chat [agent]="agent" />
<chat-approval-card
[agent]="agent"
matchKind="refund_approval"
title="Refund approval required"
(action)="onAction($event)"
/>
`,
})
export class RefundApprovalComponent {
protected readonly agent = injectAgent();

onAction(action: ChatApprovalAction): void {
if (action === 'approve') {
void this.agent.submit({ resume: { approved: true } });
} else if (action === 'cancel') {
void this.agent.submit({ resume: { approved: false } });
}
}
}
```

`matchKind` filters on `interrupt().value.kind`. The card renders only when the active interrupt matches — other interrupt kinds are ignored.

## Resuming

Call `agent.submit({ resume })` with your decision object:

```typescript
// Approve
void this.agent.submit({ resume: { approved: true } });

// Reject
void this.agent.submit({ resume: { approved: false } });

// Approve with an edited field
void this.agent.submit({ resume: { approved: true, amount: 35.00 } });
```

Under the hood, `submit({ resume })` calls `runAgent({ forwardedProps: { command: { resume } } })`. The server receives `forwarded_props.command.resume` — the convention the [`ag-ui-langgraph`](https://pypi.org/project/ag-ui-langgraph/) package reads to resume the LangGraph checkpoint.

<Callout type="info" title="Backend reads forwarded_props">
In your LangGraph node, `interrupt({...})` returns the `resume` value directly. You do not need to unwrap `forwarded_props` yourself — `ag-ui-langgraph` does that before resuming the graph.
</Callout>

## End-to-End Example

`cockpit/ag-ui/interrupts` is a complete Angular + Python example: a refund-authorization agent that drafts a refund, pauses for operator approval, and issues (or cancels) based on the decision.

**Angular component** (`cockpit/ag-ui/interrupts/angular/src/app/interrupts.component.ts`):

```typescript
import { Component, ChangeDetectionStrategy, signal } from '@angular/core';
import {
ChatComponent,
ChatApprovalCardComponent,
type ChatApprovalAction,
} from '@threadplane/chat';
import { injectAgent } from '@threadplane/ag-ui';

@Component({
standalone: true,
imports: [ChatComponent, ChatApprovalCardComponent],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<chat [agent]="agent" />
<chat-approval-card
[agent]="agent"
matchKind="refund_approval"
title="Refund approval required"
[showEdit]="true"
(action)="onAction($event)"
/>
`,
})
export class InterruptsComponent {
protected readonly agent = injectAgent();

protected onAction(action: ChatApprovalAction): void {
if (action === 'approve') {
void this.agent.submit({ resume: { approved: true } });
} else if (action === 'cancel') {
void this.agent.submit({ resume: { approved: false } });
}
}
}
```

**Python graph** (`cockpit/ag-ui/interrupts/python/src/graph.py`) uses `ag-ui-langgraph` to front a standard LangGraph graph:

```python
from langgraph.types import interrupt
from ag_ui_langgraph import LangGraphAgent, add_langgraph_fastapi_endpoint

def request_approval(state):
decision = interrupt({
"kind": "refund_approval",
"amount": state["amount"],
"customer_id": state["customer_id"],
"reason": state["reason"],
})
approved = isinstance(decision, dict) and decision.get("approved")
return {"decision_approved": approved}
```

The `LangGraphAgent` wrapper handles streaming the `CUSTOM on_interrupt` event and reading `forwarded_props.command.resume` on resume. Refer to [`ag-ui-langgraph` on PyPI](https://pypi.org/project/ag-ui-langgraph/) for installation and configuration.

## Cross-Adapter Parity

The consumer Angular code is byte-identical except the `injectAgent` import:

```diff
- import { injectAgent } from '@threadplane/langgraph';
+ import { injectAgent } from '@threadplane/ag-ui';
```

`<chat-approval-card>`, the `interrupt()` signal, and `submit({ resume })` are part of the runtime-neutral `Agent` contract from `@threadplane/chat`. Switching adapters is a provider change, not a component rewrite. See the [LangGraph interrupts guide](/docs/langgraph/guides/interrupts) for the full HITL pattern including multi-step approvals, typed payloads, and timeout strategies.
19 changes: 0 additions & 19 deletions apps/website/content/docs/chat/api/api-docs.json
Original file line number Diff line number Diff line change
Expand Up @@ -6968,25 +6968,6 @@
},
"examples": []
},
{
"name": "provideViews",
"kind": "function",
"description": "",
"signature": "provideViews(registry: ViewRegistry): EnvironmentProviders",
"params": [
{
"name": "registry",
"type": "ViewRegistry",
"description": "",
"optional": false
}
],
"returns": {
"type": "EnvironmentProviders",
"description": ""
},
"examples": []
},
{
"name": "renderMarkdown",
"kind": "function",
Expand Down
102 changes: 102 additions & 0 deletions apps/website/content/docs/chat/guides/markdown.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,108 @@ export class ChatViewComponent {
The markdown styles are scoped to `.chat-md`. Make sure the container element receiving `[innerHTML]` has this class, otherwise the rendered HTML will appear unstyled.
</Callout>

## Streaming Markdown with chat-streaming-md

`<chat-streaming-md>` is the component that renders AI message content token-by-token using the node-based rendering pipeline. It resolves each markdown node type against `MARKDOWN_VIEW_REGISTRY` — a chat-internal DI token exported from `@threadplane/chat`.

By default the component provides `cacheplaneMarkdownViews` (the full 22-node registry) on its own component injector. You can override this at two levels:

- **App-wide** — provide a custom registry in your root or feature providers.
- **Per-instance** — pass a `ViewRegistry` via the `[viewRegistry]` input; the component uses that value instead of the DI tree.

## Overriding Markdown Components

### App-wide override

To replace a node-type renderer for every `<chat-streaming-md>` in your app, provide a custom `MARKDOWN_VIEW_REGISTRY` in your application config:

```typescript
import { ApplicationConfig } from '@angular/core';
import { MARKDOWN_VIEW_REGISTRY, cacheplaneMarkdownViews } from '@threadplane/chat';
import { overrideViews } from '@threadplane/render';
import { MyCodeBlockComponent } from './my-code-block.component';

export const appConfig: ApplicationConfig = {
providers: [
{
provide: MARKDOWN_VIEW_REGISTRY,
useValue: overrideViews(cacheplaneMarkdownViews, {
'code-block': MyCodeBlockComponent,
}),
},
],
};
```

`overrideViews(base, overrides)` replaces every key listed in `overrides` and preserves all other entries from `base`. Import it from `@threadplane/render` (chat does not re-export it).

<Callout type="info" title="overrideViews vs withViews">
Use `overrideViews` when replacing an existing node type. Use `withViews` when adding a brand-new node type that `cacheplaneMarkdownViews` does not yet cover — `withViews` is additive-only and the base registry wins on conflicts. See the [render views API](/docs/render/api/views) for full signatures.
</Callout>

### Per-instance override

Pass a `ViewRegistry` directly to a single `<chat-streaming-md>` via its `[viewRegistry]` input. The component uses the provided value and ignores the DI tree for that instance:

```typescript
import { Component } from '@angular/core';
import { ChatStreamingMdComponent, cacheplaneMarkdownViews } from '@threadplane/chat';
import { overrideViews } from '@threadplane/render';
import { MyCodeBlockComponent } from './my-code-block.component';

@Component({
selector: 'app-custom-chat',
standalone: true,
imports: [ChatStreamingMdComponent],
template: `
<chat-streaming-md [content]="content" [viewRegistry]="myRegistry" />
`,
})
export class CustomChatComponent {
content = '';
myRegistry = overrideViews(cacheplaneMarkdownViews, {
'code-block': MyCodeBlockComponent,
});
}
```

## Node-Type Reference

`cacheplaneMarkdownViews` covers every node type emitted by `@cacheplane/partial-markdown`. Use these keys when calling `overrideViews` or `withViews`.

<Callout type="warning" title="Use 'code-block', not 'code'">
The most common mistake is providing `'code'` as an override key — it does not match anything in the registry. The correct key for fenced code blocks is `'code-block'`.
</Callout>

| Key | Description |
|-----|-------------|
| `'document'` | Root node wrapping the entire parsed document |
| `'paragraph'` | Block-level paragraph (`<p>`) |
| `'heading'` | Heading element (`<h1>` through `<h6>`) |
| `'blockquote'` | Block-level quotation (`<blockquote>`) |
| `'list'` | Ordered or unordered list (`<ol>` / `<ul>`) |
| `'list-item'` | Individual list item (`<li>`) |
| `'code-block'` | Fenced code block (`<pre><code>`) |
| `'thematic-break'` | Horizontal rule (`<hr>`) |
| `'text'` | Inline text run |
| `'emphasis'` | Italic emphasis (`<em>`) |
| `'strong'` | Bold emphasis (`<strong>`) |
| `'strikethrough'` | Strikethrough text (`<del>`) |
| `'inline-code'` | Inline code span (`<code>`) |
| `'link'` | Hyperlink (`<a>`) |
| `'autolink'` | Auto-detected URL or email link |
| `'image'` | Image (`<img>`) |
| `'soft-break'` | Soft line break (space or newline within a paragraph) |
| `'hard-break'` | Hard line break (`<br>`) |
| `'citation-reference'` | In-text citation reference rendered by the chat pipeline |
| `'table'` | Table container (`<table>`) |
| `'table-row'` | Table row (`<tr>`) |
| `'table-cell'` | Table header or data cell (`<th>` / `<td>`) |

## Theming Markdown Components

All built-in markdown view components consume the same `--ngaf-chat-*` and `--a2ui-*` CSS custom properties as the rest of the chat UI. No extra tokens are needed — changing the active theme automatically re-styles markdown output. See the [chat theming guide](/docs/chat/guides/theming) for the full token reference.

## Without marked

If you choose not to install `marked`, markdown content will render as plain text with line breaks preserved. This can be appropriate for simple chat applications that do not need rich formatting.
Expand Down
25 changes: 25 additions & 0 deletions apps/website/content/docs/render/api/api-docs.json
Original file line number Diff line number Diff line change
Expand Up @@ -536,6 +536,31 @@
},
"examples": []
},
{
"name": "overrideViews",
"kind": "function",
"description": "Replaces views in a registry. Keys in `overrides` win over `base`.\nUse this to swap an existing renderer; use `withViews` to add NEW\nnode types without touching existing entries.",
"signature": "overrideViews(base: ViewRegistry, overrides: Record<string, Type<unknown> | RenderViewEntry>): ViewRegistry",
"params": [
{
"name": "base",
"type": "ViewRegistry",
"description": "",
"optional": false
},
{
"name": "overrides",
"type": "Record<string, Type<unknown> | RenderViewEntry>",
"description": "",
"optional": false
}
],
"returns": {
"type": "ViewRegistry",
"description": ""
},
"examples": []
},
{
"name": "provideRender",
"kind": "function",
Expand Down
16 changes: 16 additions & 0 deletions apps/website/content/docs/render/api/views.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,20 @@ const extended = withViews(base, {
});
```

### `overrideViews(base, overrides)`

Replaces entries in a registry. Keys in `overrides` win over `base`. Use this when you want to swap an existing renderer; use [`withViews`](#withviewsbase-additions) when you want to add new node types without touching existing ones.

```typescript
import { overrideViews } from '@threadplane/render';

const myRegistry = overrideViews(baseRegistry, {
'code-block': MyCodeBlockComponent,
});
```

Returns a new frozen `ViewRegistry`. The `base` argument is not mutated.

### `withoutViews(base, ...names)`

Removes views by name:
Expand All @@ -88,6 +102,8 @@ providers: [provideViews(ui)]
{ path: 'planning', providers: [provideViews(planningViews)] }
```

`<render-spec>` and `<render-element>` consume `VIEW_REGISTRY` as a third-priority fallback in registry resolution. The full priority order is: the `[registry]` template input → `RENDER_CONFIG.registry` (from `provideRender(...)`) → `VIEW_REGISTRY` (from `provideViews(...)`) → the existing empty fallback. So `provideViews(myRegistry)` drives rendering when no `provideRender({ registry })` is wired and no `[registry]` input is bound.

### `toRenderRegistry(registry)`

Converts a `ViewRegistry` to the low-level `AngularRegistry` type used by `<render-spec>`. Called internally by the chat component — most developers won't need this.
Expand Down
Loading
Loading