Skip to content

Commit 92c7143

Browse files
bloveclaude
andcommitted
fix(chat): let an ancestor MARKDOWN_VIEW_REGISTRY override markdown views
<chat-streaming-md> provided MARKDOWN_VIEW_REGISTRY on its own component injector from its own default, so a provider at the application root or on a route was always shadowed and never reached the markdown node components. The [viewRegistry] input was the only override that worked. Resolution is now most-specific-first: the [viewRegistry] input, then a registry found through skipSelf on an ancestor injector, then cacheplaneMarkdownViews. The resolved value is still provided on the component injector, so <chat-md-children> and the table-row view are unchanged, and <chat> needs to forward nothing for an app-wide override to reach the markdown it renders for assistant messages. The new spec fails on the old resolution in two of its four cases (ancestor provider, and the same through <chat>) and passes on the other two, which pin the default and the input precedence. Restores an accurate "App-wide override" section in the markdown guide, which previously carried a Callout asserting the opposite, and corrects the CHANGELOG entry that made the same claim. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
1 parent ddb3e5e commit 92c7143

5 files changed

Lines changed: 214 additions & 20 deletions

File tree

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

Lines changed: 40 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -126,16 +126,14 @@ Use the exported `markdownDocument(content, delivery)` helper to derive it from
126126

127127
It resolves each markdown node type against `MARKDOWN_VIEW_REGISTRY` — a chat-internal DI token exported from `@threadplane/chat` and consumed by the markdown node components.
128128

129-
Every `<chat-streaming-md>` instance provides `MARKDOWN_VIEW_REGISTRY` on its own
130-
component injector, from `[viewRegistry]` when you pass one and from
131-
`cacheplaneMarkdownViews` (the full 26-node registry) otherwise.
132-
133-
<Callout type="warning" title="There is no app-wide markdown override">
134-
Because the component always provides the token itself, a
135-
`MARKDOWN_VIEW_REGISTRY` provider in your root or route providers is shadowed and
136-
never reaches the markdown node components. `[viewRegistry]` on
137-
`<chat-streaming-md>` is the only override point.
138-
</Callout>
129+
Every `<chat-streaming-md>` instance provides the resolved registry on its own
130+
component injector, so the markdown node components below it read one value.
131+
Resolution runs most-specific-first:
132+
133+
1. The `[viewRegistry]` input on that `<chat-streaming-md>`.
134+
2. A `MARKDOWN_VIEW_REGISTRY` provider on an ancestor injector -- your
135+
application root or a route.
136+
3. `cacheplaneMarkdownViews`, the full 26-node registry.
139137

140138
## Overriding Markdown Components
141139

@@ -177,10 +175,38 @@ export class CustomChatComponent {
177175
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.
178176
</Callout>
179177

180-
`<chat>` renders assistant markdown through its own `<chat-streaming-md>`, and
181-
does not forward a `[viewRegistry]`. To ship a custom node renderer inside a
182-
conversation, project your own `ai` message template into
183-
`<chat-message-list>` and mount `<chat-streaming-md [viewRegistry]="…">` there.
178+
### App-wide override
179+
180+
Provide `MARKDOWN_VIEW_REGISTRY` once and every markdown surface below that
181+
injector picks it up, including the `<chat-streaming-md>` that `<chat>` mounts
182+
for assistant messages. `<chat>` forwards nothing, and needs to forward nothing.
183+
184+
```typescript
185+
// app.config.ts
186+
import { ApplicationConfig } from '@angular/core';
187+
import {
188+
MARKDOWN_VIEW_REGISTRY,
189+
cacheplaneMarkdownViews,
190+
} from '@threadplane/chat';
191+
import { overrideViews } from '@threadplane/render';
192+
import { MyCodeBlockComponent } from './my-code-block.component';
193+
194+
export const appConfig: ApplicationConfig = {
195+
providers: [
196+
{
197+
provide: MARKDOWN_VIEW_REGISTRY,
198+
useValue: overrideViews(cacheplaneMarkdownViews, {
199+
'code-block': MyCodeBlockComponent,
200+
}),
201+
},
202+
],
203+
};
204+
```
205+
206+
The same provider works in a route's `providers` array when only one section of
207+
the application should render markdown differently. A `[viewRegistry]` input on
208+
an individual `<chat-streaming-md>` still wins over both, so a single surface can
209+
opt out of the application-wide choice.
184210

185211
## Node-Type Reference
186212

libs/chat/CHANGELOG.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,13 @@
66

77
- **`provideChat()`, `ChatConfig`, and `CHAT_CONFIG` are gone.** No component in the library ever injected the token, so calling `provideChat({})` configured nothing: `renderRegistry`, `avatarLabel`, and `assistantName` were values only your own wrappers could read back. Delete the call and the import; `provideAgent()` from your runtime adapter is the only provider the chat components require, and everything they render is driven by component inputs. If you were reading `CHAT_CONFIG` from your own components, define your own injection token for those values.
88

9+
### Fixed
10+
11+
- **An application-level `MARKDOWN_VIEW_REGISTRY` provider now takes effect.** `<chat-streaming-md>` provided the token on its own injector from its own default, which shadowed any provider at the application root or on a route. It now resolves most-specific-first — the `[viewRegistry]` input, then an ancestor injector, then `cacheplaneMarkdownViews` — so one root provider overrides markdown rendering across every chat surface, including inside `<chat>`, with nothing to forward.
12+
913
### Changed
1014

1115
- **`@angular/forms` peer dependency removed:** `chat-input` now binds its textarea with a direct `[value]`/`(input)` pair (fixes the composer keeping sent text under zoneless + OnPush). `@threadplane/chat` no longer requires `@angular/forms` — consumers may drop it unless they use it themselves.
1216
- **json-render store isolation:** `<chat>`'s json-render message surfaces no longer fall back to the conversation-wide internal store — each surface self-seeds from its spec's `state` unless you pass an explicit `[store]`. Pass `[store]` (e.g. `signalStateStore({})`) when dashboards should receive backend agent state (STATE_SNAPSHOT) or share live values across surfaces; same-key dashboards in different messages are now isolated by default. Tool views (`chat-tool-views`) keep the previous shared-store behavior.
13-
- **Public API trim:** `@threadplane/chat` no longer re-exports `provideViews` / `VIEW_REGISTRY` from `@threadplane/render`. Consumers using `<render-spec>` / `<render-element>` directly should import from `@threadplane/render`. For chat's markdown view overrides, pass `overrideViews(cacheplaneMarkdownViews, { … })` from `@threadplane/render` to the `[viewRegistry]` input on `<chat-streaming-md>`. That input is the only override point: the component always provides `MARKDOWN_VIEW_REGISTRY` on its own injector, so an app-level or route-level provider for that token is shadowed and never reaches the markdown node components. The previously-documented `provideViews(withViews(…))` pattern never drove rendering either.
17+
- **Public API trim:** `@threadplane/chat` no longer re-exports `provideViews` / `VIEW_REGISTRY` from `@threadplane/render`. Consumers using `<render-spec>` / `<render-element>` directly should import from `@threadplane/render`. For chat's markdown view overrides, pass `overrideViews(cacheplaneMarkdownViews, { … })` from `@threadplane/render` to the `[viewRegistry]` input on `<chat-streaming-md>`, or provide the same value for `MARKDOWN_VIEW_REGISTRY` at the application root or on a route to override every markdown surface at once. The previously-documented `provideViews(withViews(…))` pattern never drove rendering.
1418
- **License:** `@threadplane/chat` is now MIT-licensed for commercial and noncommercial use. The package no longer accepts or checks activation tokens.

libs/chat/src/lib/markdown/markdown-view-registry.ts

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,12 @@ import type { ViewRegistry } from '@threadplane/render';
77
* and <md-children>. Maps MarkdownNode.type strings (e.g. "paragraph",
88
* "heading") to Angular components that render that node type.
99
*
10-
* `<chat-streaming-md>` provides the runtime registry on its component-level
11-
* injector — either the consumer-supplied [viewRegistry] input, or
12-
* `cacheplaneMarkdownViews` (the default) — so descendant <md-children>
13-
* components resolve the right components for each node.
10+
* `<chat-streaming-md>` provides the resolved registry on its component-level
11+
* injector so descendant <md-children> components resolve the right component
12+
* for each node. It resolves most-specific-first: the `[viewRegistry]` input,
13+
* then a registry provided by an ancestor injector (application root or route),
14+
* then `cacheplaneMarkdownViews` (the default). Providing this token at the
15+
* application root is therefore a supported app-wide override.
1416
*/
1517
export const MARKDOWN_VIEW_REGISTRY = new InjectionToken<ViewRegistry>(
1618
'MARKDOWN_VIEW_REGISTRY',

libs/chat/src/lib/streaming/streaming-markdown.component.ts

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -127,8 +127,25 @@ export class ChatStreamingMdComponent {
127127
readonly document = input.required<StreamingMarkdownDocument>();
128128
readonly viewRegistry = input<ViewRegistry | undefined>(undefined);
129129

130+
/**
131+
* A registry provided further up the injector tree — at the application root
132+
* or on a route. `skipSelf` is what makes an app-wide override possible: this
133+
* component provides `MARKDOWN_VIEW_REGISTRY` on its own injector for its
134+
* descendants, so without it the component would only ever find its own
135+
* value and shadow the ancestor.
136+
*/
137+
private readonly ancestorRegistry = inject<ViewRegistry | null>(
138+
MARKDOWN_VIEW_REGISTRY,
139+
{ optional: true, skipSelf: true }
140+
);
141+
142+
/**
143+
* Most specific wins: the `[viewRegistry]` input, then a registry provided by
144+
* an ancestor injector, then the built-in markdown views.
145+
*/
130146
readonly resolvedRegistry = computed(
131-
() => this.viewRegistry() ?? cacheplaneMarkdownViews
147+
() =>
148+
this.viewRegistry() ?? this.ancestorRegistry ?? cacheplaneMarkdownViews
132149
);
133150

134151
private readonly resolver = inject(CitationsResolverService, {
Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
// libs/chat/src/lib/streaming/streaming-markdown.registry-override.spec.ts
2+
//
3+
// WHAT THIS PINS. `<chat-streaming-md>` provides MARKDOWN_VIEW_REGISTRY on its
4+
// own component injector so `<chat-md-children>` and the table-row view can
5+
// resolve it. Providing it unconditionally from the component's own default
6+
// made an application- or route-level provider for the same token unreachable:
7+
// the component injector always won. Resolution now runs input first, then an
8+
// ancestor injector, then the built-in default — so an app-wide override
9+
// actually reaches the markdown node components.
10+
import { Component, input, signal } from '@angular/core';
11+
import { TestBed } from '@angular/core/testing';
12+
import { overrideViews, type ViewRegistry } from '@threadplane/render';
13+
import { describe, expect, it } from 'vitest';
14+
import { cacheplaneMarkdownViews } from '../markdown/cacheplane-markdown-views';
15+
import { MARKDOWN_VIEW_REGISTRY } from '../markdown/markdown-view-registry';
16+
import { ChatComponent } from '../compositions/chat/chat.component';
17+
import { staticDelivery } from '../agent/message-delivery';
18+
import { mockAgent } from '../testing/mock-agent';
19+
import {
20+
ChatStreamingMdComponent,
21+
type StreamingMarkdownDocument,
22+
} from './streaming-markdown.component';
23+
24+
@Component({
25+
standalone: true,
26+
selector: 'test-loud-paragraph',
27+
template: `<p class="loud-paragraph"><ng-content /></p>`,
28+
})
29+
class LoudParagraphComponent {
30+
/** The markdown node the registry binds onto every view component. */
31+
readonly node = input<unknown>();
32+
}
33+
34+
const customViews = (): ViewRegistry =>
35+
overrideViews(cacheplaneMarkdownViews, {
36+
paragraph: LoudParagraphComponent,
37+
});
38+
39+
const doc = (content: string): StreamingMarkdownDocument => ({
40+
generation: 'g1',
41+
phase: 'complete',
42+
content,
43+
});
44+
45+
@Component({
46+
standalone: true,
47+
imports: [ChatStreamingMdComponent],
48+
template: `<chat-streaming-md [document]="document()" />`,
49+
})
50+
class PlainHost {
51+
readonly document = signal(doc('Hello world'));
52+
}
53+
54+
@Component({
55+
standalone: true,
56+
imports: [ChatStreamingMdComponent],
57+
template: `<chat-streaming-md
58+
[document]="document()"
59+
[viewRegistry]="registry()"
60+
/>`,
61+
})
62+
class InputHost {
63+
readonly document = signal(doc('Hello world'));
64+
readonly registry = signal<ViewRegistry | undefined>(undefined);
65+
}
66+
67+
@Component({
68+
standalone: true,
69+
imports: [ChatComponent],
70+
template: `<chat [agent]="agent" />`,
71+
})
72+
class ChatHost {
73+
readonly agent = mockAgent({
74+
messages: [
75+
{
76+
id: 'm1',
77+
role: 'assistant',
78+
content: 'Hello world',
79+
delivery: staticDelivery('m1'),
80+
},
81+
],
82+
});
83+
}
84+
85+
describe('<chat-streaming-md> markdown view registry resolution', () => {
86+
it('uses the built-in default when nothing overrides it', () => {
87+
const fixture = TestBed.createComponent(PlainHost);
88+
fixture.detectChanges();
89+
90+
expect(
91+
fixture.nativeElement.querySelector('.loud-paragraph')
92+
).toBeNull();
93+
expect(fixture.nativeElement.querySelector('p')).toBeTruthy();
94+
});
95+
96+
it('uses a registry provided by an ancestor injector', () => {
97+
TestBed.configureTestingModule({
98+
providers: [
99+
{ provide: MARKDOWN_VIEW_REGISTRY, useValue: customViews() },
100+
],
101+
});
102+
103+
const fixture = TestBed.createComponent(PlainHost);
104+
fixture.detectChanges();
105+
106+
expect(
107+
fixture.nativeElement.querySelector('.loud-paragraph'),
108+
'an app-level MARKDOWN_VIEW_REGISTRY must reach the markdown nodes'
109+
).toBeTruthy();
110+
});
111+
112+
it('lets the [viewRegistry] input win over an ancestor provider', () => {
113+
TestBed.configureTestingModule({
114+
providers: [
115+
{ provide: MARKDOWN_VIEW_REGISTRY, useValue: customViews() },
116+
],
117+
});
118+
119+
const fixture = TestBed.createComponent(InputHost);
120+
fixture.componentInstance.registry.set(cacheplaneMarkdownViews);
121+
fixture.detectChanges();
122+
123+
expect(
124+
fixture.nativeElement.querySelector('.loud-paragraph'),
125+
'the explicit input is the most specific override'
126+
).toBeNull();
127+
expect(fixture.nativeElement.querySelector('p')).toBeTruthy();
128+
});
129+
130+
it('reaches markdown rendered inside <chat> without forwarding anything', () => {
131+
TestBed.configureTestingModule({
132+
providers: [
133+
{ provide: MARKDOWN_VIEW_REGISTRY, useValue: customViews() },
134+
],
135+
});
136+
137+
const fixture = TestBed.createComponent(ChatHost);
138+
fixture.detectChanges();
139+
140+
expect(
141+
fixture.nativeElement.querySelector('.loud-paragraph'),
142+
'<chat> renders assistant markdown through <chat-streaming-md>'
143+
).toBeTruthy();
144+
});
145+
});

0 commit comments

Comments
 (0)