Skip to content

Commit 633319e

Browse files
bloveclaude
andcommitted
refactor(chat): drop the unreachable 'mixed' content type, widen messageContent, tokenize the popup launcher
Three small cleanups: - ContentType no longer carries 'mixed'. createContentClassifier never emitted it, so the `|| currentType === 'mixed'` branch in the delta path was dead and any consumer switch on it had a case no input reaches. The docs said as much and told readers not to branch on it; the member is simply gone instead. - messageContent() takes { content: unknown } rather than LangChain's BaseMessage. Every caller in this library holds the runtime-neutral Message from agent.messages(), so ChatComponent.humanContent carried a cast and the docs told consumers to pass something other than the message they actually have. The function reads nothing but .content, so the structural parameter is the honest signature; the cast is gone. - --tplane-chat-launcher-offset-x / -y (both 1rem) position the <chat-popup> launcher, which was pinned with hard-coded corner offsets. The popup window reads the horizontal one too, so it stays aligned when the launcher moves clear of a bottom bar or a consent banner. The new message-utils spec fails to type-check against the old BaseMessage signature (four errors under tsconfig.spec.json) and passes after. The new chat-popup styles spec fails four of its five cases before the change, and its window-alignment guard was mutation-checked by reverting that one declaration. Regenerates the chat and render api-docs. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
1 parent 92c7143 commit 633319e

17 files changed

Lines changed: 192 additions & 101 deletions

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

Lines changed: 11 additions & 65 deletions
Original file line numberDiff line numberDiff line change
@@ -1925,7 +1925,7 @@
19251925
},
19261926
{
19271927
"name": "messageContent",
1928-
"type": "(message: BaseMessage<>) => string",
1928+
"type": "(message: object) => string",
19291929
"description": "",
19301930
"optional": false
19311931
},
@@ -2072,12 +2072,12 @@
20722072
},
20732073
{
20742074
"name": "humanContent",
2075-
"signature": "humanContent(message: unknown): string",
2075+
"signature": "humanContent(message: object): string",
20762076
"description": "Renderable content for a human-role message bubble. Most human\nmessages are typed prompts and pass through `messageContent`\nunchanged. A2UI action messages (e.g. form submits, button clicks\non a rendered surface) flow through the same submit channel and\nland in the message stream as a HumanMessage whose content is a\nJSON-serialized `A2uiActionMessage`. Showing the raw JSON as if\nthe user typed it leaks the protocol; per the A2UI spec\nthose events resemble tool calls more than user utterances.\n\n`a2uiActionLabel` returns a short human-readable label for\nrecognized action shapes (\"Search flights\", \"Selected flight UA123\",\netc.) — or null for any non-action content, in which case we fall\nback to the original text.",
20772077
"params": [
20782078
{
20792079
"name": "message",
2080-
"type": "unknown",
2080+
"type": "object",
20812081
"description": "",
20822082
"optional": false
20832083
}
@@ -4002,7 +4002,7 @@
40024002
{
40034003
"name": "resolvedRegistry",
40044004
"type": "Signal<Readonly<Record<string, Type<unknown> | RenderViewEntry>>>",
4005-
"description": "",
4005+
"description": "Most specific wins: the `[viewRegistry]` input, then a registry provided by\nan ancestor injector, then the built-in markdown views.",
40064006
"optional": false
40074007
},
40084008
{
@@ -6467,32 +6467,6 @@
64676467
],
64686468
"examples": []
64696469
},
6470-
{
6471-
"name": "ChatConfig",
6472-
"kind": "interface",
6473-
"description": "Application-wide options for provideChat. Every field is optional;\nthe values are exposed to all chat components in the tree via the\n`CHAT_CONFIG` injection token, so you set them once at bootstrap instead of\nthreading props through every component.",
6474-
"properties": [
6475-
{
6476-
"name": "assistantName",
6477-
"type": "string",
6478-
"description": "Shared assistant display name for consumers that read CHAT_CONFIG (default: \"Assistant\").",
6479-
"optional": true
6480-
},
6481-
{
6482-
"name": "avatarLabel",
6483-
"type": "string",
6484-
"description": "Shared AI avatar label for consumers that read CHAT_CONFIG (default: \"A\").",
6485-
"optional": true
6486-
},
6487-
{
6488-
"name": "renderRegistry",
6489-
"type": "AngularRegistry",
6490-
"description": "Shared render registry for consumers that read CHAT_CONFIG.",
6491-
"optional": true
6492-
}
6493-
],
6494-
"examples": []
6495-
},
64966470
{
64976471
"name": "ChatLifecycle",
64986472
"kind": "interface",
@@ -8468,7 +8442,7 @@
84688442
"name": "ContentType",
84698443
"kind": "type",
84708444
"description": "",
8471-
"signature": "\"pending\" | \"markdown\" | \"json-render\" | \"a2ui\" | \"mixed\"",
8445+
"signature": "\"pending\" | \"markdown\" | \"json-render\" | \"a2ui\"",
84728446
"examples": []
84738447
},
84748448
{
@@ -8590,13 +8564,6 @@
85908564
"signature": "ViewRegistry",
85918565
"examples": []
85928566
},
8593-
{
8594-
"name": "CHAT_CONFIG",
8595-
"kind": "const",
8596-
"description": "",
8597-
"signature": "InjectionToken<ChatConfig>",
8598-
"examples": []
8599-
},
86008567
{
86018568
"name": "CHAT_LIFECYCLE",
86028569
"kind": "const",
@@ -8614,7 +8581,7 @@
86148581
{
86158582
"name": "MARKDOWN_VIEW_REGISTRY",
86168583
"kind": "const",
8617-
"description": "DI token for the markdown view registry consumed by <chat-streaming-md>\nand <md-children>. Maps MarkdownNode.type strings (e.g. \"paragraph\",\n\"heading\") to Angular components that render that node type.\n\n`<chat-streaming-md>` provides the runtime registry on its component-level\ninjector — either the consumer-supplied [viewRegistry] input, or\n`cacheplaneMarkdownViews` (the default) — so descendant <md-children>\ncomponents resolve the right components for each node.",
8584+
"description": "DI token for the markdown view registry consumed by <chat-streaming-md>\nand <md-children>. Maps MarkdownNode.type strings (e.g. \"paragraph\",\n\"heading\") to Angular components that render that node type.\n\n`<chat-streaming-md>` provides the resolved registry on its component-level\ninjector so descendant <md-children> components resolve the right component\nfor each node. It resolves most-specific-first: the `[viewRegistry]` input,\nthen a registry provided by an ancestor injector (application root or route),\nthen `cacheplaneMarkdownViews` (the default). Providing this token at the\napplication root is therefore a supported app-wide override.",
86188585
"signature": "InjectionToken<Readonly<Record<string, Type<unknown> | RenderViewEntry>>>",
86198586
"examples": []
86208587
},
@@ -9418,13 +9385,13 @@
94189385
{
94199386
"name": "messageContent",
94209387
"kind": "function",
9421-
"description": "Extracts a human-readable string from a message's content.\n\n`BaseMessage.content` is `string | MessageContentComplex[]`. Reasoning-\ncapable models (OpenAI gpt-5/o-series, Anthropic) emit complex arrays of\ntyped blocks: `{type:'text',text}`, `{type:'reasoning',...}`, tool-use\nblocks, etc. We render only the visible text portions and skip anything\nelse. Stringifying the whole array would dump raw JSON like\n`[{\"type\":\"text\",...}]` into the chat bubble.",
9422-
"signature": "messageContent(message: BaseMessage<>): string",
9388+
"description": "Extracts a human-readable string from a message's content.\n\nMessage content is either a plain string or an array of typed blocks.\nReasoning-capable models (OpenAI gpt-5/o-series, Anthropic) emit complex\narrays: `{type:'text',text}`, `{type:'reasoning',...}`, tool-use blocks, etc.\nOnly the visible text portions are rendered and anything else is skipped.\nStringifying the whole array would dump raw JSON like `[{\"type\":\"text\",...}]`\ninto the chat bubble.\n\nThe parameter is structural on purpose. This function reads nothing but\n`.content`, and callers hold either the runtime-neutral `Message` from\n`agent.messages()` or a LangChain `BaseMessage` depending on where the\nmessage came from. Both satisfy `{ content: unknown }`, so neither has to\ncast.",
9389+
"signature": "messageContent(message: object): string",
94239390
"params": [
94249391
{
94259392
"name": "message",
9426-
"type": "BaseMessage<>",
9427-
"description": "",
9393+
"type": "object",
9394+
"description": "Any object carrying a `content` field.",
94289395
"optional": false
94299396
}
94309397
],
@@ -9531,27 +9498,6 @@
95319498
},
95329499
"examples": []
95339500
},
9534-
{
9535-
"name": "provideChat",
9536-
"kind": "function",
9537-
"description": "Bootstrap `@threadplane/chat` in an Angular application or standalone\ncomponent tree.\n\nCall this once inside `bootstrapApplication` (or the `providers` array of a\nroot `ApplicationConfig`). It registers the shared ChatConfig token\nso every chat component in the tree can read the render registry, avatar\nlabel, and assistant display name without explicit prop threading.",
9538-
"signature": "provideChat(config: ChatConfig): EnvironmentProviders",
9539-
"params": [
9540-
{
9541-
"name": "config",
9542-
"type": "ChatConfig",
9543-
"description": "Options bag that controls the chat feature set:\n - `renderRegistry` — shared AngularRegistry wiring tool-view\n components to their names; pass the value returned by\n `defineAngularRegistry` from `\\@threadplane/render`.\n - `avatarLabel` — short label shown in the AI avatar bubble (default `\"A\"`).\n - `assistantName` — display name shown above assistant messages\n (default `\"Assistant\"`).",
9544-
"optional": false
9545-
}
9546-
],
9547-
"returns": {
9548-
"type": "EnvironmentProviders",
9549-
"description": ""
9550-
},
9551-
"examples": [
9552-
"```ts\n// main.ts\nimport { bootstrapApplication } from '@angular/platform-browser';\nimport { provideChat } from '@threadplane/chat';\nimport { defineAngularRegistry, provideRender } from '@threadplane/render';\nimport { DayCardComponent } from './day-card.component';\n\nconst registry = defineAngularRegistry({ day_card: DayCardComponent });\n\nbootstrapApplication(AppComponent, {\n providers: [\n provideChat({ renderRegistry: registry, avatarLabel: 'AI' }),\n provideRender({ registry }),\n ],\n});\n```"
9553-
]
9554-
},
95559501
{
95569502
"name": "renderMarkdown",
95579503
"kind": "function",
@@ -9771,7 +9717,7 @@
97719717
{
97729718
"name": "tools",
97739719
"kind": "function",
9774-
"description": "Collect named client tools into a frozen, name-keyed registry.\n\nThe overload is generic over the entire map (`const M`) so that each tool's\nprecise type (FunctionToolDef`<S,R>`, ViewToolDef`<S,C>`, or\nAskToolDef`<S,C>`) and every literal key are preserved in the\nClientToolRegistry passed to `provideChat`. This lets downstream\nconsumers look up individual tools without losing generic information.",
9720+
"description": "Collect named client tools into a frozen, name-keyed registry.\n\nThe overload is generic over the entire map (`const M`) so that each tool's\nprecise type (FunctionToolDef`<S,R>`, ViewToolDef`<S,C>`, or\nAskToolDef`<S,C>`) and every literal key are preserved in the\nClientToolRegistry passed to the `clientTools` input. This lets downstream\nconsumers look up individual tools without losing generic information.",
97759721
"signature": "tools(map: M): Readonly<M>",
97769722
"params": [
97779723
{

apps/website/content/docs/chat/api/content-classifier.mdx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,7 @@ interface ContentClassifier {
6060
## ContentType
6161

6262
```typescript
63-
type ContentType = 'pending' | 'markdown' | 'json-render' | 'a2ui' | 'mixed';
63+
type ContentType = 'pending' | 'markdown' | 'json-render' | 'a2ui';
6464
```
6565

6666
| Value | Meaning |
@@ -69,7 +69,8 @@ type ContentType = 'pending' | 'markdown' | 'json-render' | 'a2ui' | 'mixed';
6969
| `markdown` | Plain text / markdown prose |
7070
| `json-render` | JSON spec detected (first non-whitespace is `{`) |
7171
| `a2ui` | A2UI payload detected via `---a2ui_JSON---` prefix, parsed as JSONL messages |
72-
| `mixed` | Reserved. The current implementation never emits this value. |
72+
73+
The union has no member for interleaved content: prose with inline JSON-render specs classifies as `markdown` and the markdown path renders the embedded specs in place.
7374

7475
### a2uiSurfaces
7576

apps/website/content/docs/chat/components/chat-message-list.mdx

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -121,15 +121,13 @@ const type = getMessageType(message); // 'human' | 'ai' | 'tool' | 'system' | 'f
121121
Runtime-neutral messages have a `content` property that is either a `string` or a `ContentBlock[]`. The library exports a `messageContent()` utility that flattens either shape to a string:
122122

123123
```typescript
124-
import type { BaseMessage } from '@langchain/core/messages';
125-
126124
// If content is a string, returns it directly.
127125
// If content is a block array, concatenates the visible text blocks
128126
// (`text` and `output_text`) and skips reasoning, tool-use, and image blocks.
129-
function messageContent(message: BaseMessage): string
127+
function messageContent(message: { content: unknown }): string
130128
```
131129

132-
Note the parameter type: `messageContent()` consumes the LangChain `BaseMessage` shape, not the runtime-neutral `Message` handed to a `chatMessageTemplate`. Pass it a message from a LangChain-shaped source, not one straight out of `agent.messages()`.
130+
The parameter is structural: the function reads nothing but `content`. Pass it the `Message` handed to a `chatMessageTemplate` straight out of `agent.messages()`, or a LangChain `BaseMessage` from a LangChain-shaped source. Both type-check, and neither needs a cast.
133131

134132
For custom templates, access `message.content` directly and narrow the type in the component class. Angular template expressions have no `typeof` operator, so the check has to live in a method:
135133

apps/website/content/docs/chat/components/chat-popup.mdx

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -126,13 +126,22 @@ Project content into the window header with the `[chatHeader]` slot:
126126

127127
## Styling
128128

129-
The popup uses the standard `--tplane-chat-*` token system. The launcher position is not tokenized: the host is `position: fixed; bottom: 1rem; right: 1rem`, so move it by overriding those properties on the host element from a global stylesheet:
129+
The popup uses the standard `--tplane-chat-*` token system. Two tokens control where the launcher sits, both defaulting to `1rem`:
130+
131+
| Token | Default | Controls |
132+
|-------|---------|----------|
133+
| `--tplane-chat-launcher-offset-x` | `1rem` | Distance from the right edge of the viewport |
134+
| `--tplane-chat-launcher-offset-y` | `1rem` | Distance from the bottom edge of the viewport |
135+
136+
Set them anywhere the popup inherits from, such as `:root`, to clear a bottom bar or a consent banner:
130137

131138
```css
132-
chat-popup {
133-
bottom: 1.5rem;
134-
right: 1.5rem;
139+
:root {
140+
--tplane-chat-launcher-offset-x: 1.5rem;
141+
--tplane-chat-launcher-offset-y: 5rem;
135142
}
136143
```
137144

145+
The popup window reads the same horizontal offset, so it stays aligned with the launcher when you move it. Below 640px the window goes full screen and ignores both.
146+
138147
See [Theming](/docs/chat/guides/theming) for the full token reference.

apps/website/content/docs/chat/concepts/message-model.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -164,7 +164,7 @@ type ContentBlock =
164164

165165
Plain text is the common case, and it is the only case in the demo: the LangGraph adapter flattens LangChain content arrays down to their visible text before the message reaches a component, keeping the raw payload on `extra`. Adapters that can preserve more shape emit blocks instead.
166166

167-
Custom templates should therefore check the type before assuming they can interpolate `content` directly. `messageContent()` does this for you, taking a LangChain `BaseMessage` rather than the `Message` shape, and hand-written code can do the same:
167+
Custom templates should therefore check the type before assuming they can interpolate `content` directly. `messageContent()` does this for you and accepts the `Message` shape directly, and hand-written code can do the same:
168168

169169
```ts
170170
function textOf(message: Message): string {

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

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ Each AI message is processed by a `ContentClassifier` that examines the content
2020
| Prose with inline JSON-render specs | `markdown` | Markdown path, with embedded specs rendered in place |
2121
| Any other text | `markdown` | Rendered as markdown prose |
2222

23-
Prose that interleaves inline JSON-render specs still classifies as `'markdown'` — the markdown path renders the embedded specs in place. The `ContentType` union also includes `'mixed'`, but `createContentClassifier` does not currently emit it, so treat it as reserved: do not branch on `classifier.type() === 'mixed'` expecting inline-spec content to land there. (`'a2ui'` is covered under [A2UI Content Detection](#a2ui-content-detection) below.)
23+
Prose that interleaves inline JSON-render specs still classifies as `'markdown'` — the markdown path renders the embedded specs in place. There is no separate type for interleaved content. (`'a2ui'` is covered under [A2UI Content Detection](#a2ui-content-detection) below.)
2424

2525
<Callout type="info" title="Per-message classification">
2626
Each message gets its own classifier instance. Classification happens once per message — the type is determined by the first meaningful character and never changes.
@@ -68,7 +68,7 @@ classifier.dispose();
6868

6969
| Signal | Type | Description |
7070
|--------|------|-------------|
71-
| `type` | `Signal<ContentType>` | `'pending'`, `'markdown'`, `'json-render'`, `'a2ui'`, or `'mixed'` |
71+
| `type` | `Signal<ContentType>` | `'pending'`, `'markdown'`, `'json-render'`, or `'a2ui'` |
7272
| `markdown` | `Signal<string>` | Accumulated markdown prose (empty for pure JSON) |
7373
| `spec` | `Signal<Spec \| null>` | Materialized JSON-render spec with structural sharing |
7474
| `elementStates` | `Signal<Map<string, ElementAccumulationState>>` | Per-element tracking of which properties have been received |
@@ -77,7 +77,7 @@ classifier.dispose();
7777
### ContentType
7878

7979
```typescript
80-
type ContentType = 'pending' | 'markdown' | 'json-render' | 'a2ui' | 'mixed';
80+
type ContentType = 'pending' | 'markdown' | 'json-render' | 'a2ui';
8181
```
8282

8383
## Using ParseTreeStore Directly

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,8 @@ Three shadow tokens, `--tplane-chat-shadow-sm`, `--tplane-chat-shadow-md`, and `
9494
| `--tplane-chat-radius-card` | `8px` | Tool call cards, citations, generative UI surfaces |
9595
| `--tplane-chat-radius-button` | `8px` | Buttons in panels and lists |
9696
| `--tplane-chat-radius-launcher` | `9999px` | Circular launcher button |
97+
| `--tplane-chat-launcher-offset-x` | `1rem` | Distance from the viewport right edge to the `<chat-popup>` launcher and window |
98+
| `--tplane-chat-launcher-offset-y` | `1rem` | Distance from the viewport bottom edge to the `<chat-popup>` launcher |
9799
| `--tplane-chat-max-width` | `48rem` | Message column and input width |
98100
| `--tplane-chat-edge-pad` | `16px` | Horizontal padding at the edges of the conversation |
99101
| `--tplane-chat-space-1``--tplane-chat-space-6`, `--tplane-chat-space-8` | `4px``24px`, `32px` | Internal spacing scale |

0 commit comments

Comments
 (0)