Skip to content

Commit 37fb84f

Browse files
bloveclaude
andauthored
feat(render): wire VIEW_REGISTRY + add overrideViews; trim chat re-export; fix docs (#569)
* docs(spec): render VIEW_REGISTRY drift + chat re-export cleanup + nits Closes a chain of compounding bugs: render exports public API with no engine behavior; chat leaks render tokens into its surface; chat README's markdown override example is broken three ways. Adds overrideViews, wires VIEW_REGISTRY into the render engine, drops chat's re-export, fixes all documentation (README + chat markdown guide + render views API + CHANGELOG + regenerated api-docs.json). Plus three nits: ag-ui README interrupt docs + new docs guide, .gitignore Playwright dirs, rename dashboard.md → generative-ui.md. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * docs(plan): render VIEW_REGISTRY drift + chat re-export cleanup + nits implementation plan * feat(render): add overrideViews helper for override-semantics composition * feat(render): wire VIEW_REGISTRY as third-priority registry fallback in engine Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * refactor(chat): drop re-export of provideViews/VIEW_REGISTRY from @threadplane/render * docs(chat): fix markdown view override example (MARKDOWN_VIEW_REGISTRY + overrideViews) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * docs(website): correct markdown view override path in chat guide Fix the chat markdown guide to use MARKDOWN_VIEW_REGISTRY (correct token), overrideViews from @threadplane/render (not the old provideViews pattern), and 'code-block' (not 'code') as the node-type key. Add app-wide and per-instance override examples, a 22-entry node-type reference table, overrideViews-vs-withViews callout, and brief theming cross-link. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * docs(render): document engine resolution order + overrideViews helper Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * docs(website): add overrideViews + clarify provideViews engine consumption Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * docs(website): regenerate API JSON for chat re-export drop + render overrideViews Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * docs(ag-ui): document Agent.interrupt + submit({ resume }) HITL feature * docs(website): add ag-ui interrupts guide + register in nav New guide covers the AG-UI HITL contract: CUSTOM on_interrupt wire format, dump_json_safe auto-parsing, submit({ resume }) flow, and cross-adapter parity with the LangGraph guide. Registers the page in the ag-ui Guides section of docs-config.ts. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * chore: gitignore Playwright test-results under cockpit examples * fix(cockpit): rename dashboard.md → generative-ui.md to match manifest convention * fix(cockpit): align e2e wiring test with post-#567 ag-ui example shape Pre-existing failure inherited from main: - cockpit-e2e-wiring.spec.ts parsed only 'langgraphCwd' from global-setup-impl.ts, but ag-ui examples use 'pythonCwd' (the createAgUiGlobalSetup field). Accept either. - capability-registry.ts had the old "FakeAgent, no Python backend" shape for ag-ui-streaming (port 4600) and no entry for the new ag-ui-interrupts example. Update both to match the real uvicorn ag-ui-langgraph backend ports (4320/5320 + 4321/5321 from cockpit/ports.mjs). --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 006d420 commit 37fb84f

25 files changed

Lines changed: 1475 additions & 35 deletions

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ dist/
1414
tmp/
1515
apps/website/test-results/
1616
apps/website/public/demo/
17+
cockpit/**/angular/test-results/
1718

1819
# Env
1920
.env

apps/cockpit/cockpit-e2e-wiring.spec.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -87,7 +87,12 @@ function activeCockpitE2eWiring(): E2eWiring[] {
8787
const projectRoot = dirname(projectJsonPath);
8888
const globalSetupPath = join(projectRoot, 'e2e/global-setup-impl.ts');
8989
const globalSetup = readFileSync(globalSetupPath, 'utf8');
90-
const langgraphCwd = parseStringProperty(globalSetup, 'langgraphCwd');
90+
// langgraph-shaped global-setup uses `langgraphCwd`; ag-ui-shaped
91+
// global-setup (createAgUiGlobalSetup) uses `pythonCwd`. Both name
92+
// the python project's cwd — accept either.
93+
const langgraphCwd =
94+
parseStringProperty(globalSetup, 'langgraphCwd') ??
95+
parseStringProperty(globalSetup, 'pythonCwd');
9196

9297
// Post-port-registry migration: ports are imported from
9398
// cockpit/ports.mjs rather than living as literals in

apps/cockpit/scripts/capability-registry.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -49,8 +49,9 @@ export const capabilities: readonly Capability[] = [
4949
{ id: 'c-debug', product: 'chat', topic: 'debug', angularProject: 'cockpit-chat-debug-angular', port: 4509, pythonPort: 5509, pythonDir: 'cockpit/chat/debug/python', graphName: 'c-debug' },
5050
{ id: 'c-theming', product: 'chat', topic: 'theming', angularProject: 'cockpit-chat-theming-angular', port: 4510, pythonPort: 5510, pythonDir: 'cockpit/chat/theming/python', graphName: 'c-theming' },
5151
{ id: 'c-a2ui', product: 'chat', topic: 'a2ui', angularProject: 'cockpit-chat-a2ui-angular', port: 4511, pythonPort: 5511, pythonDir: 'cockpit/chat/a2ui/python', graphName: 'c-a2ui' },
52-
// AG-UI capabilities (in-process FakeAgent; no Python backend, not deployed to LangSmith)
53-
{ id: 'ag-ui-streaming', product: 'ag-ui', topic: 'streaming', angularProject: 'cockpit-ag-ui-streaming-angular', port: 4600 },
52+
// AG-UI capabilities (uvicorn ag-ui-langgraph backend; not deployed to LangSmith)
53+
{ 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' },
54+
{ 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' },
5455
] as const;
5556

5657
export function findCapability(id: string): Capability | undefined {
Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
1+
# Interrupts (Human-in-the-Loop)
2+
3+
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).
4+
5+
## The Wire Format
6+
7+
AG-UI interrupts arrive as a `CUSTOM` event with `name: "on_interrupt"`:
8+
9+
```json
10+
{
11+
"type": "CUSTOM",
12+
"name": "on_interrupt",
13+
"value": "{\"kind\":\"refund_approval\",\"amount\":47.50,\"customer_id\":\"cus_a8x2k\",\"reason\":\"Duplicate charge\"}"
14+
}
15+
```
16+
17+
Two things to note:
18+
19+
- 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.
20+
- The adapter `JSON.parse`s the string automatically. Consumers always see the structured object — you never need to parse it yourself.
21+
22+
**Structuring the payload:** Use a `kind` field so `<chat-approval-card matchKind="…">` can match the right interrupt:
23+
24+
```python
25+
decision = interrupt({
26+
"kind": "refund_approval",
27+
"amount": amount,
28+
"customer_id": customer_id,
29+
"reason": reason,
30+
})
31+
```
32+
33+
## Reading the Interrupt in Your Component
34+
35+
`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:
36+
37+
```typescript
38+
import { Component } from '@angular/core';
39+
import { ChatComponent, ChatApprovalCardComponent } from '@threadplane/chat';
40+
import { injectAgent } from '@threadplane/ag-ui';
41+
import type { ChatApprovalAction } from '@threadplane/chat';
42+
43+
@Component({
44+
standalone: true,
45+
imports: [ChatComponent, ChatApprovalCardComponent],
46+
changeDetection: ChangeDetectionStrategy.OnPush,
47+
template: `
48+
<chat [agent]="agent" />
49+
<chat-approval-card
50+
[agent]="agent"
51+
matchKind="refund_approval"
52+
title="Refund approval required"
53+
(action)="onAction($event)"
54+
/>
55+
`,
56+
})
57+
export class RefundApprovalComponent {
58+
protected readonly agent = injectAgent();
59+
60+
onAction(action: ChatApprovalAction): void {
61+
if (action === 'approve') {
62+
void this.agent.submit({ resume: { approved: true } });
63+
} else if (action === 'cancel') {
64+
void this.agent.submit({ resume: { approved: false } });
65+
}
66+
}
67+
}
68+
```
69+
70+
`matchKind` filters on `interrupt().value.kind`. The card renders only when the active interrupt matches — other interrupt kinds are ignored.
71+
72+
## Resuming
73+
74+
Call `agent.submit({ resume })` with your decision object:
75+
76+
```typescript
77+
// Approve
78+
void this.agent.submit({ resume: { approved: true } });
79+
80+
// Reject
81+
void this.agent.submit({ resume: { approved: false } });
82+
83+
// Approve with an edited field
84+
void this.agent.submit({ resume: { approved: true, amount: 35.00 } });
85+
```
86+
87+
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.
88+
89+
<Callout type="info" title="Backend reads forwarded_props">
90+
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.
91+
</Callout>
92+
93+
## End-to-End Example
94+
95+
`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.
96+
97+
**Angular component** (`cockpit/ag-ui/interrupts/angular/src/app/interrupts.component.ts`):
98+
99+
```typescript
100+
import { Component, ChangeDetectionStrategy, signal } from '@angular/core';
101+
import {
102+
ChatComponent,
103+
ChatApprovalCardComponent,
104+
type ChatApprovalAction,
105+
} from '@threadplane/chat';
106+
import { injectAgent } from '@threadplane/ag-ui';
107+
108+
@Component({
109+
standalone: true,
110+
imports: [ChatComponent, ChatApprovalCardComponent],
111+
changeDetection: ChangeDetectionStrategy.OnPush,
112+
template: `
113+
<chat [agent]="agent" />
114+
<chat-approval-card
115+
[agent]="agent"
116+
matchKind="refund_approval"
117+
title="Refund approval required"
118+
[showEdit]="true"
119+
(action)="onAction($event)"
120+
/>
121+
`,
122+
})
123+
export class InterruptsComponent {
124+
protected readonly agent = injectAgent();
125+
126+
protected onAction(action: ChatApprovalAction): void {
127+
if (action === 'approve') {
128+
void this.agent.submit({ resume: { approved: true } });
129+
} else if (action === 'cancel') {
130+
void this.agent.submit({ resume: { approved: false } });
131+
}
132+
}
133+
}
134+
```
135+
136+
**Python graph** (`cockpit/ag-ui/interrupts/python/src/graph.py`) uses `ag-ui-langgraph` to front a standard LangGraph graph:
137+
138+
```python
139+
from langgraph.types import interrupt
140+
from ag_ui_langgraph import LangGraphAgent, add_langgraph_fastapi_endpoint
141+
142+
def request_approval(state):
143+
decision = interrupt({
144+
"kind": "refund_approval",
145+
"amount": state["amount"],
146+
"customer_id": state["customer_id"],
147+
"reason": state["reason"],
148+
})
149+
approved = isinstance(decision, dict) and decision.get("approved")
150+
return {"decision_approved": approved}
151+
```
152+
153+
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.
154+
155+
## Cross-Adapter Parity
156+
157+
The consumer Angular code is byte-identical except the `injectAgent` import:
158+
159+
```diff
160+
- import { injectAgent } from '@threadplane/langgraph';
161+
+ import { injectAgent } from '@threadplane/ag-ui';
162+
```
163+
164+
`<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.

apps/website/content/docs/chat/api/api-docs.json

Lines changed: 0 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -6968,25 +6968,6 @@
69686968
},
69696969
"examples": []
69706970
},
6971-
{
6972-
"name": "provideViews",
6973-
"kind": "function",
6974-
"description": "",
6975-
"signature": "provideViews(registry: ViewRegistry): EnvironmentProviders",
6976-
"params": [
6977-
{
6978-
"name": "registry",
6979-
"type": "ViewRegistry",
6980-
"description": "",
6981-
"optional": false
6982-
}
6983-
],
6984-
"returns": {
6985-
"type": "EnvironmentProviders",
6986-
"description": ""
6987-
},
6988-
"examples": []
6989-
},
69906971
{
69916972
"name": "renderMarkdown",
69926973
"kind": "function",

apps/website/content/docs/chat/guides/markdown.mdx

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,108 @@ export class ChatViewComponent {
128128
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.
129129
</Callout>
130130

131+
## Streaming Markdown with chat-streaming-md
132+
133+
`<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`.
134+
135+
By default the component provides `cacheplaneMarkdownViews` (the full 22-node registry) on its own component injector. You can override this at two levels:
136+
137+
- **App-wide** — provide a custom registry in your root or feature providers.
138+
- **Per-instance** — pass a `ViewRegistry` via the `[viewRegistry]` input; the component uses that value instead of the DI tree.
139+
140+
## Overriding Markdown Components
141+
142+
### App-wide override
143+
144+
To replace a node-type renderer for every `<chat-streaming-md>` in your app, provide a custom `MARKDOWN_VIEW_REGISTRY` in your application config:
145+
146+
```typescript
147+
import { ApplicationConfig } from '@angular/core';
148+
import { MARKDOWN_VIEW_REGISTRY, cacheplaneMarkdownViews } from '@threadplane/chat';
149+
import { overrideViews } from '@threadplane/render';
150+
import { MyCodeBlockComponent } from './my-code-block.component';
151+
152+
export const appConfig: ApplicationConfig = {
153+
providers: [
154+
{
155+
provide: MARKDOWN_VIEW_REGISTRY,
156+
useValue: overrideViews(cacheplaneMarkdownViews, {
157+
'code-block': MyCodeBlockComponent,
158+
}),
159+
},
160+
],
161+
};
162+
```
163+
164+
`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).
165+
166+
<Callout type="info" title="overrideViews vs withViews">
167+
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.
168+
</Callout>
169+
170+
### Per-instance override
171+
172+
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:
173+
174+
```typescript
175+
import { Component } from '@angular/core';
176+
import { ChatStreamingMdComponent, cacheplaneMarkdownViews } from '@threadplane/chat';
177+
import { overrideViews } from '@threadplane/render';
178+
import { MyCodeBlockComponent } from './my-code-block.component';
179+
180+
@Component({
181+
selector: 'app-custom-chat',
182+
standalone: true,
183+
imports: [ChatStreamingMdComponent],
184+
template: `
185+
<chat-streaming-md [content]="content" [viewRegistry]="myRegistry" />
186+
`,
187+
})
188+
export class CustomChatComponent {
189+
content = '';
190+
myRegistry = overrideViews(cacheplaneMarkdownViews, {
191+
'code-block': MyCodeBlockComponent,
192+
});
193+
}
194+
```
195+
196+
## Node-Type Reference
197+
198+
`cacheplaneMarkdownViews` covers every node type emitted by `@cacheplane/partial-markdown`. Use these keys when calling `overrideViews` or `withViews`.
199+
200+
<Callout type="warning" title="Use 'code-block', not 'code'">
201+
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'`.
202+
</Callout>
203+
204+
| Key | Description |
205+
|-----|-------------|
206+
| `'document'` | Root node wrapping the entire parsed document |
207+
| `'paragraph'` | Block-level paragraph (`<p>`) |
208+
| `'heading'` | Heading element (`<h1>` through `<h6>`) |
209+
| `'blockquote'` | Block-level quotation (`<blockquote>`) |
210+
| `'list'` | Ordered or unordered list (`<ol>` / `<ul>`) |
211+
| `'list-item'` | Individual list item (`<li>`) |
212+
| `'code-block'` | Fenced code block (`<pre><code>`) |
213+
| `'thematic-break'` | Horizontal rule (`<hr>`) |
214+
| `'text'` | Inline text run |
215+
| `'emphasis'` | Italic emphasis (`<em>`) |
216+
| `'strong'` | Bold emphasis (`<strong>`) |
217+
| `'strikethrough'` | Strikethrough text (`<del>`) |
218+
| `'inline-code'` | Inline code span (`<code>`) |
219+
| `'link'` | Hyperlink (`<a>`) |
220+
| `'autolink'` | Auto-detected URL or email link |
221+
| `'image'` | Image (`<img>`) |
222+
| `'soft-break'` | Soft line break (space or newline within a paragraph) |
223+
| `'hard-break'` | Hard line break (`<br>`) |
224+
| `'citation-reference'` | In-text citation reference rendered by the chat pipeline |
225+
| `'table'` | Table container (`<table>`) |
226+
| `'table-row'` | Table row (`<tr>`) |
227+
| `'table-cell'` | Table header or data cell (`<th>` / `<td>`) |
228+
229+
## Theming Markdown Components
230+
231+
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.
232+
131233
## Without marked
132234

133235
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.

apps/website/content/docs/render/api/api-docs.json

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -536,6 +536,31 @@
536536
},
537537
"examples": []
538538
},
539+
{
540+
"name": "overrideViews",
541+
"kind": "function",
542+
"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.",
543+
"signature": "overrideViews(base: ViewRegistry, overrides: Record<string, Type<unknown> | RenderViewEntry>): ViewRegistry",
544+
"params": [
545+
{
546+
"name": "base",
547+
"type": "ViewRegistry",
548+
"description": "",
549+
"optional": false
550+
},
551+
{
552+
"name": "overrides",
553+
"type": "Record<string, Type<unknown> | RenderViewEntry>",
554+
"description": "",
555+
"optional": false
556+
}
557+
],
558+
"returns": {
559+
"type": "ViewRegistry",
560+
"description": ""
561+
},
562+
"examples": []
563+
},
539564
{
540565
"name": "provideRender",
541566
"kind": "function",

apps/website/content/docs/render/api/views.mdx

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,20 @@ const extended = withViews(base, {
6868
});
6969
```
7070

71+
### `overrideViews(base, overrides)`
72+
73+
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.
74+
75+
```typescript
76+
import { overrideViews } from '@threadplane/render';
77+
78+
const myRegistry = overrideViews(baseRegistry, {
79+
'code-block': MyCodeBlockComponent,
80+
});
81+
```
82+
83+
Returns a new frozen `ViewRegistry`. The `base` argument is not mutated.
84+
7185
### `withoutViews(base, ...names)`
7286

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

105+
`<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.
106+
91107
### `toRenderRegistry(registry)`
92108

93109
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.

0 commit comments

Comments
 (0)