From 5dcdee066f1090f697943effd95c19e708064f09 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Fri, 10 Apr 2026 12:09:19 -0700 Subject: [PATCH 1/4] fix(agent): filter SDK metadata from messages/partial events MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit normalizeMessages() had two code paths: event['messages'] (returned unfiltered) and event['data'] (filtered by isMessageLike). In production, FetchStreamTransport's normalizeSdkEvent wraps the raw SDK data array—which includes metadata objects like { langgraph_node, langgraph_triggers }—into event.messages. These metadata objects lack content/type/id fields, causing messageContent() to return undefined and crashing the content classifier's detectType() on undefined.length. The fix applies the existing isMessageLike filter to the event['messages'] path. Tests now simulate post-normalization event shapes matching what FetchStreamTransport produces. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../internals/stream-manager.bridge.spec.ts | 82 +++++++++++++++++++ .../lib/internals/stream-manager.bridge.ts | 5 +- 2 files changed, 86 insertions(+), 1 deletion(-) diff --git a/libs/agent/src/lib/internals/stream-manager.bridge.spec.ts b/libs/agent/src/lib/internals/stream-manager.bridge.spec.ts index 1af26243d..744661f47 100644 --- a/libs/agent/src/lib/internals/stream-manager.bridge.spec.ts +++ b/libs/agent/src/lib/internals/stream-manager.bridge.spec.ts @@ -139,6 +139,88 @@ describe('createStreamManagerBridge', () => { } ); + it.each(['messages/partial', 'messages/complete'] as const)( + 'filters metadata from normalized SDK %s events (messages array path)', + async (type) => { + const transport = new MockAgentTransport(); + const subjects = makeSubjects(); + const destroy$ = new Subject(); + const bridge = createStreamManagerBridge({ + options: { apiUrl: '', assistantId: 'test', transport }, + subjects, + threadId$: of(null), + destroy$: destroy$.asObservable(), + }); + + bridge.submit({}); + // Simulate post-normalizeSdkEvent shape: messages array includes metadata + // This is what FetchStreamTransport produces in production + transport.emit([{ + type, + messages: [ + { id: 'ai-1', type: 'ai', content: 'Hello' }, + { langgraph_node: 'chatbot', langgraph_triggers: ['start:chatbot'] }, + ], + data: [ + { id: 'ai-1', type: 'ai', content: 'Hello' }, + { langgraph_node: 'chatbot', langgraph_triggers: ['start:chatbot'] }, + ], + } as any]); + transport.close(); + + await new Promise(r => setTimeout(r, 10)); + + // Only the real message should be in messages$, not the metadata + expect(subjects.messages$.value).toHaveLength(1); + expect(subjects.messages$.value[0]).toMatchObject({ id: 'ai-1', content: 'Hello' }); + destroy$.next(); + } + ); + + it('does not accumulate metadata across multiple messages/partial events', async () => { + const transport = new MockAgentTransport(); + const subjects = makeSubjects(); + const destroy$ = new Subject(); + const bridge = createStreamManagerBridge({ + options: { apiUrl: '', assistantId: 'test', transport }, + subjects, + threadId$: of(null), + destroy$: destroy$.asObservable(), + }); + + bridge.submit({}); + + // First values event — sets up the human message + transport.emit([{ + type: 'values', + values: { messages: [{ id: 'h-1', type: 'human', content: 'hi' }] }, + } as any]); + + // Simulate multiple messages/partial events (production SDK shape) + for (let i = 0; i < 5; i++) { + transport.emit([{ + type: 'messages/partial', + messages: [ + { id: 'ai-1', type: 'ai', content: 'Hello'.slice(0, i + 1) }, + { langgraph_node: 'chatbot' }, + ], + data: [ + { id: 'ai-1', type: 'ai', content: 'Hello'.slice(0, i + 1) }, + { langgraph_node: 'chatbot' }, + ], + } as any]); + } + + transport.close(); + await new Promise(r => setTimeout(r, 10)); + + // Should only have human + AI messages, no accumulated metadata + expect(subjects.messages$.value).toHaveLength(2); + expect(subjects.messages$.value[0]).toMatchObject({ id: 'h-1', content: 'hi' }); + expect(subjects.messages$.value[1]).toMatchObject({ id: 'ai-1', content: 'Hello' }); + destroy$.next(); + }); + it('ignores late events from the previous stream after threadId changes', async () => { const transport = new MockAgentTransport(); const subjects = makeSubjects(); diff --git a/libs/agent/src/lib/internals/stream-manager.bridge.ts b/libs/agent/src/lib/internals/stream-manager.bridge.ts index fbce3cc0b..ba9adec47 100644 --- a/libs/agent/src/lib/internals/stream-manager.bridge.ts +++ b/libs/agent/src/lib/internals/stream-manager.bridge.ts @@ -255,7 +255,10 @@ function isMessagesEvent(type: StreamEvent['type']): boolean { function normalizeMessages(event: StreamEvent): unknown[] | null { const directMessages = event['messages']; if (Array.isArray(directMessages)) { - return directMessages; + // Filter out non-message metadata objects (e.g. { langgraph_node, langgraph_triggers }) + // that the LangGraph SDK includes alongside real messages in messages/* events. + const filtered = directMessages.filter(isMessageLike); + return filtered.length > 0 ? filtered : null; } const data = event['data']; From a00541e9871471e0ceac34fa0d862f237b7efcf8 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Fri, 10 Apr 2026 13:25:04 -0700 Subject: [PATCH 2/4] fix(chat): wrap content classifier update in untracked() to prevent NG0600 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit classifyMessage() is called during Angular template rendering (in the AI message template via @let). The classifier's update() method writes to signals (typeSignal.set, markdownSignal.set, etc.), which Angular 21's stricter signal write guards flag as NG0600 — writing signals during change detection is forbidden. Wrapping update() in untracked() opts out of the reactive graph for this imperative push-based API. The template reads the classifier's signals after the update call returns, so reactivity is preserved. Verified with multi-turn streaming conversation against production LangGraph backend — markdown renders correctly, zero console errors. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/lib/streaming/content-classifier.ts | 132 +++++++++--------- 1 file changed, 69 insertions(+), 63 deletions(-) diff --git a/libs/chat/src/lib/streaming/content-classifier.ts b/libs/chat/src/lib/streaming/content-classifier.ts index b1d02eedf..1fc5cb4b0 100644 --- a/libs/chat/src/lib/streaming/content-classifier.ts +++ b/libs/chat/src/lib/streaming/content-classifier.ts @@ -1,5 +1,5 @@ // SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 -import { signal, type Signal } from '@angular/core'; +import { signal, untracked, type Signal } from '@angular/core'; import type { Spec } from '@json-render/core'; import { createPartialJsonParser } from '@cacheplane/partial-json'; import { createParseTreeStore, type ElementAccumulationState, type ParseTreeStore } from './parse-tree-store'; @@ -98,82 +98,88 @@ export function createContentClassifier(): ContentClassifier { } function update(content: string): void { - const currentType = typeSignal(); + // Wrap in untracked() because this is called during template rendering + // (via classifyMessage in ChatComponent's AI message template). Angular's + // NG0600 forbids writing signals during change detection; untracked() + // opts out of the reactive graph for this imperative push-based update. + untracked(() => { + const currentType = typeSignal(); + + if (currentType === 'undetermined') { + const detected = detectType(content); + if (detected === 'undetermined') return; + + typeSignal.set(detected); + + if (detected === 'markdown') { + markdownSignal.set(content); + processedLength = content.length; + } else if (detected === 'json-render') { + streamingSignal.set(true); + // Find where JSON starts (skip whitespace) + jsonStartIndex = 0; + for (let i = 0; i < content.length; i++) { + if (content[i] !== ' ' && content[i] !== '\t' && content[i] !== '\n' && content[i] !== '\r') { + jsonStartIndex = i; + break; + } + } + const jsonContent = content.slice(jsonStartIndex); + try { + initJsonStore(jsonContent); + } catch (err) { + errorsSignal.update(prev => [...prev, err instanceof Error ? err.message : String(err)]); + } + processedLength = content.length; + } else if (detected === 'a2ui') { + streamingSignal.set(true); + a2uiParser = createA2uiMessageParser(); + a2uiStore = createA2uiSurfaceStore(); + jsonStartIndex = content.indexOf(A2UI_PREFIX) + A2UI_PREFIX.length; + const a2uiContent = content.slice(jsonStartIndex); + if (a2uiContent.length > 0) { + try { + const msgs = a2uiParser.push(a2uiContent); + for (const msg of msgs) a2uiStore.apply(msg); + a2uiSurfacesSignal.set(a2uiStore.surfaces()); + } catch (err) { + errorsSignal.update(prev => [...prev, err instanceof Error ? err.message : String(err)]); + } + } + processedLength = content.length; + } + return; + } - if (currentType === 'undetermined') { - const detected = detectType(content); - if (detected === 'undetermined') return; + // Compute delta + const delta = content.slice(processedLength); + processedLength = content.length; - typeSignal.set(detected); + if (delta.length === 0) return; - if (detected === 'markdown') { + if (currentType === 'markdown' || currentType === 'mixed') { markdownSignal.set(content); - processedLength = content.length; - } else if (detected === 'json-render') { - streamingSignal.set(true); - // Find where JSON starts (skip whitespace) - jsonStartIndex = 0; - for (let i = 0; i < content.length; i++) { - if (content[i] !== ' ' && content[i] !== '\t' && content[i] !== '\n' && content[i] !== '\r') { - jsonStartIndex = i; - break; + } else if (currentType === 'json-render') { + if (store) { + try { + store.push(delta); + syncJsonSignals(); + } catch (err) { + errorsSignal.update(prev => [...prev, err instanceof Error ? err.message : String(err)]); } } - const jsonContent = content.slice(jsonStartIndex); - try { - initJsonStore(jsonContent); - } catch (err) { - errorsSignal.update(prev => [...prev, err instanceof Error ? err.message : String(err)]); - } - processedLength = content.length; - } else if (detected === 'a2ui') { - streamingSignal.set(true); - a2uiParser = createA2uiMessageParser(); - a2uiStore = createA2uiSurfaceStore(); - jsonStartIndex = content.indexOf(A2UI_PREFIX) + A2UI_PREFIX.length; - const a2uiContent = content.slice(jsonStartIndex); - if (a2uiContent.length > 0) { + } else if (currentType === 'a2ui') { + if (a2uiParser && a2uiStore) { try { - const msgs = a2uiParser.push(a2uiContent); + const msgs = a2uiParser.push(delta); for (const msg of msgs) a2uiStore.apply(msg); a2uiSurfacesSignal.set(a2uiStore.surfaces()); } catch (err) { errorsSignal.update(prev => [...prev, err instanceof Error ? err.message : String(err)]); } } - processedLength = content.length; } - return; - } - - // Compute delta - const delta = content.slice(processedLength); - processedLength = content.length; - - if (delta.length === 0) return; - - if (currentType === 'markdown' || currentType === 'mixed') { - markdownSignal.set(content); - } else if (currentType === 'json-render') { - if (store) { - try { - store.push(delta); - syncJsonSignals(); - } catch (err) { - errorsSignal.update(prev => [...prev, err instanceof Error ? err.message : String(err)]); - } - } - } else if (currentType === 'a2ui') { - if (a2uiParser && a2uiStore) { - try { - const msgs = a2uiParser.push(delta); - for (const msg of msgs) a2uiStore.apply(msg); - a2uiSurfacesSignal.set(a2uiStore.surfaces()); - } catch (err) { - errorsSignal.update(prev => [...prev, err instanceof Error ? err.message : String(err)]); - } - } - } + }); } function dispose(): void { From 35635708837588fbaed7563a445228e7ee90aa5b Mon Sep 17 00:00:00 2001 From: Brian Love Date: Fri, 10 Apr 2026 16:24:21 -0700 Subject: [PATCH 3/4] feat(chat): eliminate streaming jank with append-only markdown renderer and frame-synced pipeline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Architecture changes to fix streaming chat jank at the root: **Streaming markdown renderer** (new) - Append-only DOM renderer that never uses innerHTML during streaming - Processes text deltas incrementally via a line-based state machine - Handles paragraphs, bold/italic, headers, lists, code blocks, links, blockquotes, and tables — all rendered by appending DOM nodes - On stream completion, does a single high-quality marked.parse() render - 38 new tests covering all markdown features + streaming simulation **Frame-synced signal pipeline** - Default throttle changed from 0 (every token) to 16ms (~60fps) - Batches SSE token updates so at most one signal update fires per frame - Eliminates change detection storms during high-throughput streaming **Typing indicator fix** - Now only shows before the first AI token arrives (time-to-first-token) - Previously showed the entire duration of streaming, overlapping content **Optimistic human message** - Human message bubble appears immediately on submit - Previously waited for server to echo it back via messages/partial **Auto-scroll fix** - Removed isLoading tracking from scroll effect - Now triggers on message content changes, not loading state transitions Co-Authored-By: Claude Opus 4.6 (1M context) --- libs/agent/src/lib/agent.fn.spec.ts | 2 +- libs/agent/src/lib/agent.fn.ts | 5 +- .../lib/internals/stream-manager.bridge.ts | 8 + .../lib/compositions/chat/chat.component.ts | 26 +- .../chat-typing-indicator.component.ts | 11 +- .../streaming/streaming-markdown.component.ts | 82 +++ .../lib/streaming/streaming-markdown.spec.ts | 405 +++++++++++++++ .../src/lib/streaming/streaming-markdown.ts | 485 ++++++++++++++++++ libs/chat/src/lib/styles/chat-markdown.ts | 12 + 9 files changed, 1020 insertions(+), 16 deletions(-) create mode 100644 libs/chat/src/lib/streaming/streaming-markdown.component.ts create mode 100644 libs/chat/src/lib/streaming/streaming-markdown.spec.ts create mode 100644 libs/chat/src/lib/streaming/streaming-markdown.ts diff --git a/libs/agent/src/lib/agent.fn.spec.ts b/libs/agent/src/lib/agent.fn.spec.ts index 3fe1834f1..e3eb98efa 100644 --- a/libs/agent/src/lib/agent.fn.spec.ts +++ b/libs/agent/src/lib/agent.fn.spec.ts @@ -147,7 +147,7 @@ describe('agent', () => { expect(ref.messages()).toHaveLength(1); threadId.set('thread-2'); - await new Promise(r => setTimeout(r, 0)); + await new Promise(r => setTimeout(r, 30)); expect(ref.hasValue()).toBe(false); expect(ref.status()).toBe(ResourceStatus.Idle); diff --git a/libs/agent/src/lib/agent.fn.ts b/libs/agent/src/lib/agent.fn.ts index 2b1b0229e..a5b496b73 100644 --- a/libs/agent/src/lib/agent.fn.ts +++ b/libs/agent/src/lib/agent.fn.ts @@ -123,8 +123,9 @@ export function agent< destroy$: destroy$.asObservable(), }); - // Throttle helper - const ms = typeof options.throttle === 'number' ? options.throttle : 0; + // Throttle helper — default 16ms (~60fps) to batch SSE token updates into + // at most one signal update per frame, preventing change detection storms. + const ms = typeof options.throttle === 'number' ? options.throttle : 16; const maybeThrottle = (obs: BehaviorSubject) => ms > 0 ? obs.pipe(throttleTime(ms, asyncScheduler, { leading: true, trailing: true })) diff --git a/libs/agent/src/lib/internals/stream-manager.bridge.ts b/libs/agent/src/lib/internals/stream-manager.bridge.ts index ba9adec47..9c43c6752 100644 --- a/libs/agent/src/lib/internals/stream-manager.bridge.ts +++ b/libs/agent/src/lib/internals/stream-manager.bridge.ts @@ -80,6 +80,14 @@ export function createStreamManagerBridge)?.['messages']; + if (Array.isArray(inputMessages) && inputMessages.length > 0) { + const existing = subjects.messages$.value; + subjects.messages$.next([...existing, ...inputMessages as BaseMessage[]]); + } + try { const iter = transport.stream( options.assistantId, diff --git a/libs/chat/src/lib/compositions/chat/chat.component.ts b/libs/chat/src/lib/compositions/chat/chat.component.ts index c5ecd584a..ad66cd45c 100644 --- a/libs/chat/src/lib/compositions/chat/chat.component.ts +++ b/libs/chat/src/lib/compositions/chat/chat.component.ts @@ -10,9 +10,7 @@ import { viewChild, ElementRef, ChangeDetectionStrategy, - inject, } from '@angular/core'; -import { DomSanitizer } from '@angular/platform-browser'; import type { AgentRef } from '@cacheplane/angular'; import type { ViewRegistry, RenderEvent } from '@cacheplane/render'; import type { StateStore } from '@json-render/core'; @@ -28,7 +26,8 @@ import { toRenderRegistry } from '@cacheplane/render'; import { createContentClassifier, type ContentClassifier } from '../../streaming/content-classifier'; import { messageContent } from '../shared/message-utils'; import { CHAT_THEME_STYLES } from '../../styles/chat-theme'; -import { CHAT_MARKDOWN_STYLES, renderMarkdown } from '../../styles/chat-markdown'; +import { CHAT_MARKDOWN_STYLES } from '../../styles/chat-markdown'; +import { ChatStreamingMdComponent } from '../../streaming/streaming-markdown.component'; import { A2uiSurfaceComponent } from '../../a2ui/surface.component'; import type { ChatRenderEvent } from './chat-render-event'; import { KeyValuePipe } from '@angular/common'; @@ -45,6 +44,7 @@ import { KeyValuePipe } from '@angular/common'; ChatInterruptComponent, ChatThreadListComponent, ChatGenerativeUiComponent, + ChatStreamingMdComponent, A2uiSurfaceComponent, KeyValuePipe, ], @@ -129,11 +129,12 @@ import { KeyValuePipe } from '@angular/common'; >A
@if (classified.markdown(); as md) { -
+ [content]="md" + [streaming]="ref().isLoading()" + /> } @if (classified.spec(); as spec) { @@ -214,7 +215,6 @@ import { KeyValuePipe } from '@angular/common'; `, }) export class ChatComponent { - private readonly sanitizer = inject(DomSanitizer); readonly ref = input.required>(); readonly views = input(undefined); @@ -251,7 +251,13 @@ export class ChatComponent { // - During streaming partials, only scroll if user is near bottom effect(() => { const count = this.messageCount(); - this.ref().isLoading(); // track + // Track last message content to trigger scroll during streaming partials + const msgs = this.ref().messages(); + const lastContent = msgs.length > 0 + ? (msgs[msgs.length - 1] as unknown as Record)['content'] + : undefined; + void lastContent; // consume the tracked value + const el = this.scrollContainer()?.nativeElement; if (!el) return; @@ -284,10 +290,6 @@ export class ChatComponent { this.classifiers.clear(); } - renderMd(content: string) { - return renderMarkdown(content, this.sanitizer); - } - onSpecEvent(event: RenderEvent, messageIndex: number): void { this.renderEvent.emit({ messageIndex, event }); } diff --git a/libs/chat/src/lib/primitives/chat-typing-indicator/chat-typing-indicator.component.ts b/libs/chat/src/lib/primitives/chat-typing-indicator/chat-typing-indicator.component.ts index b739c0e2d..0d53d1e1e 100644 --- a/libs/chat/src/lib/primitives/chat-typing-indicator/chat-typing-indicator.component.ts +++ b/libs/chat/src/lib/primitives/chat-typing-indicator/chat-typing-indicator.component.ts @@ -49,5 +49,14 @@ export function isTyping(ref: AgentRef): boolean { }) export class ChatTypingIndicatorComponent { readonly ref = input.required>(); - readonly visible = computed(() => this.ref().isLoading()); + readonly visible = computed(() => { + if (!this.ref().isLoading()) return false; + const msgs = this.ref().messages(); + if (msgs.length === 0) return true; + const last = msgs[msgs.length - 1]; + const type = typeof last._getType === 'function' + ? last._getType() + : (last as unknown as Record)['type'] as string; + return type !== 'ai'; + }); } diff --git a/libs/chat/src/lib/streaming/streaming-markdown.component.ts b/libs/chat/src/lib/streaming/streaming-markdown.component.ts new file mode 100644 index 000000000..b775de388 --- /dev/null +++ b/libs/chat/src/lib/streaming/streaming-markdown.component.ts @@ -0,0 +1,82 @@ +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +import { + Component, + input, + effect, + ElementRef, + inject, + ChangeDetectionStrategy, + untracked, +} from '@angular/core'; +import { DomSanitizer } from '@angular/platform-browser'; +import { createStreamingMarkdownRenderer, type StreamingMarkdownRenderer } from './streaming-markdown'; +import { renderMarkdownToString } from '../styles/chat-markdown'; + +/** + * Renders markdown content using a streaming append-only DOM renderer + * during active streaming, then switches to a full marked.parse() render + * once the content stabilises (no new content for a frame). + * + * This eliminates the jank caused by full innerHTML replacement on every + * SSE token — the streaming renderer only appends new DOM nodes. + */ +@Component({ + selector: 'chat-streaming-md', + standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, + template: '', + styles: `:host { display: block; }`, +}) +export class ChatStreamingMdComponent { + private readonly el = inject(ElementRef).nativeElement as HTMLElement; + private readonly sanitizer = inject(DomSanitizer); + + /** Full markdown content (updated on every partial) */ + readonly content = input.required(); + /** Whether the parent stream is still loading */ + readonly streaming = input(false); + + private renderer: StreamingMarkdownRenderer | null = null; + private lastContent = ''; + private finalised = false; + + constructor() { + effect(() => { + const content = this.content(); + const isStreaming = this.streaming(); + + untracked(() => this.render(content, isStreaming)); + }); + } + + private render(content: string, isStreaming: boolean): void { + if (!content) return; + + if (isStreaming) { + // Streaming mode: use append-only renderer with deltas + if (!this.renderer) { + this.renderer = createStreamingMarkdownRenderer(); + this.el.textContent = ''; + this.el.appendChild(this.renderer.container); + this.finalised = false; + } + + // Compute delta from last known content + const delta = content.slice(this.lastContent.length); + this.lastContent = content; + + if (delta) { + this.renderer.push(delta); + } + } else { + // Stream complete: do a single high-quality marked.parse() render + if (!this.finalised || content !== this.lastContent) { + this.lastContent = content; + this.finalised = true; + this.renderer = null; + + this.el.innerHTML = renderMarkdownToString(content, this.sanitizer); + } + } + } +} diff --git a/libs/chat/src/lib/streaming/streaming-markdown.spec.ts b/libs/chat/src/lib/streaming/streaming-markdown.spec.ts new file mode 100644 index 000000000..4ef62a007 --- /dev/null +++ b/libs/chat/src/lib/streaming/streaming-markdown.spec.ts @@ -0,0 +1,405 @@ +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +import { describe, it, expect, beforeEach } from 'vitest'; +import { + createStreamingMarkdownRenderer, + type StreamingMarkdownRenderer, +} from './streaming-markdown'; + +describe('StreamingMarkdownRenderer', () => { + let renderer: StreamingMarkdownRenderer; + + beforeEach(() => { + renderer = createStreamingMarkdownRenderer(); + }); + + describe('container', () => { + it('should have class chat-md', () => { + expect(renderer.container.className).toBe('chat-md'); + }); + + it('should be a div element', () => { + expect(renderer.container.tagName).toBe('DIV'); + }); + }); + + describe('plain text renders as paragraph', () => { + it('should wrap plain text in a

tag', () => { + renderer.push('Hello world'); + renderer.finish(); + const p = renderer.container.querySelector('p'); + expect(p).not.toBeNull(); + expect(p!.textContent).toBe('Hello world'); + }); + + it('should create separate paragraphs for text separated by blank lines', () => { + renderer.push('First paragraph\n\nSecond paragraph'); + renderer.finish(); + const paragraphs = renderer.container.querySelectorAll('p'); + expect(paragraphs).toHaveLength(2); + expect(paragraphs[0].textContent).toBe('First paragraph'); + expect(paragraphs[1].textContent).toBe('Second paragraph'); + }); + + it('should join consecutive non-blank lines in the same paragraph', () => { + renderer.push('Line one\nLine two'); + renderer.finish(); + const paragraphs = renderer.container.querySelectorAll('p'); + expect(paragraphs).toHaveLength(1); + expect(paragraphs[0].textContent).toBe('Line one Line two'); + }); + }); + + describe('bold and italic inline formatting', () => { + it('should render **text** as ', () => { + renderer.push('This is **bold** text'); + renderer.finish(); + const strong = renderer.container.querySelector('strong'); + expect(strong).not.toBeNull(); + expect(strong!.textContent).toBe('bold'); + }); + + it('should render *text* as ', () => { + renderer.push('This is *italic* text'); + renderer.finish(); + const em = renderer.container.querySelector('em'); + expect(em).not.toBeNull(); + expect(em!.textContent).toBe('italic'); + }); + + it('should handle bold and italic in the same line', () => { + renderer.push('**bold** and *italic*'); + renderer.finish(); + expect(renderer.container.querySelector('strong')!.textContent).toBe('bold'); + expect(renderer.container.querySelector('em')!.textContent).toBe('italic'); + }); + + it('should handle nested bold inside text', () => { + renderer.push('Start **middle** end'); + renderer.finish(); + const p = renderer.container.querySelector('p')!; + expect(p.innerHTML).toBe('Start middle end'); + }); + }); + + describe('headers (h1-h4)', () => { + it('should render # as h1', () => { + renderer.push('# Heading 1'); + renderer.finish(); + const h1 = renderer.container.querySelector('h1'); + expect(h1).not.toBeNull(); + expect(h1!.textContent).toBe('Heading 1'); + }); + + it('should render ## as h2', () => { + renderer.push('## Heading 2'); + renderer.finish(); + const h2 = renderer.container.querySelector('h2'); + expect(h2).not.toBeNull(); + expect(h2!.textContent).toBe('Heading 2'); + }); + + it('should render ### as h3', () => { + renderer.push('### Heading 3'); + renderer.finish(); + const h3 = renderer.container.querySelector('h3'); + expect(h3).not.toBeNull(); + expect(h3!.textContent).toBe('Heading 3'); + }); + + it('should render #### as h4', () => { + renderer.push('#### Heading 4'); + renderer.finish(); + const h4 = renderer.container.querySelector('h4'); + expect(h4).not.toBeNull(); + expect(h4!.textContent).toBe('Heading 4'); + }); + + it('should support inline formatting in headers', () => { + renderer.push('## A **bold** heading'); + renderer.finish(); + const h2 = renderer.container.querySelector('h2')!; + expect(h2.querySelector('strong')!.textContent).toBe('bold'); + }); + }); + + describe('unordered and ordered lists', () => { + it('should render - items as