Description
Build the markdown renderer for Dialogue — hybrid approach: custom MarkupVisitor over apple/swift-markdown AST for block elements (headings, paragraphs, code fences, lists, blockquotes, thematic breaks), Apple's AttributedString(markdown:, options: .inlineOnlyPreservingWhitespace) for inline inside paragraphs.
Spec: Epic #250 §4 (apple/swift-markdown as AST provider); architecture plan in docs/architecture/dialogue.md §4.
Scope
File layout
MacApp/Packages/AgentChatUI/Sources/AgentChatUI/Markdown/
DialogueMarkdownView.swift — public view.
MarkdownBlockVisitor.swift — MarkupVisitor with Result = RenderedBlock.
RenderedBlock.swift — enum of block kinds, each variant carries rendered data (AttributedString for inline, metadata for special blocks).
MarkdownStreamingBuffer.swift — throttle / coalesce layer for streaming input.
MarkdownBlockCache.swift — LRUCache<UInt64, RenderedBlock> keyed by hash(blockSource).
Public API
```swift
public struct DialogueMarkdownView: View {
public init(
source: String,
streaming: Bool = false,
cache: MarkdownBlockCache? = nil
)
}
```
source — full markdown content. For streaming, the view splits it into stableSource (up to last \n\n) and tail (unfinished).
streaming = true — use tail-fast-path (render tail as plain Text, re-parse only stable on boundary changes).
cache — optional external cache (shared across many DialogueMarkdownView instances, e.g., one per message).
Block subset (MVP)
Supported blocks:
Document — VStack (DS.Spacing.md between blocks)
Heading (1–6) — DS.Text.{title, headline, body} with level-specific weight
Paragraph — Text(AttributedString(markdown: paragraph.format(), options: .inlineOnlyPreservingWhitespace))
CodeBlock (fenced) — BlockCodeContainer (D-3) wrapping monospaced Text
UnorderedList / OrderedList — ForEach with bullet / number + indented content
ListItem — paragraph children; task-list checkbox (SF Symbol, read-only)
BlockQuote — left border + indent
ThematicBreak — Divider
Text / inline — delegated to AttributedString
Not supported in MVP (render as plain text with warning):
Table (v1.1)
Image (v1.1)
HTMLBlock / InlineHTML (render as plain text)
- Math / LaTeX
Streaming strategy
MarkdownStreamingBuffer:
- Input: stream of
String (growing source).
- Debounce:
Task.sleep(for: .milliseconds(16)) cancellable on each new input. When debounce fires, split source at last \n\n.
- Stable block source → parse with swift-markdown (hashed, cache lookup before parse).
- Tail → plain
Text at bottom of rendered list.
Swift 6 concurrency
Document, Markup types not explicitly Sendable — parse on @MainActor, never cross actor boundary with AST types; pass only RenderedBlock / AttributedString (which are Sendable).
Acceptance Criteria
Relationships
Description
Build the markdown renderer for Dialogue — hybrid approach: custom
MarkupVisitoroverapple/swift-markdownAST for block elements (headings, paragraphs, code fences, lists, blockquotes, thematic breaks), Apple'sAttributedString(markdown:, options: .inlineOnlyPreservingWhitespace)for inline inside paragraphs.Spec: Epic #250 §4 (apple/swift-markdown as AST provider); architecture plan in
docs/architecture/dialogue.md§4.Scope
File layout
MacApp/Packages/AgentChatUI/Sources/AgentChatUI/Markdown/DialogueMarkdownView.swift— public view.MarkdownBlockVisitor.swift—MarkupVisitorwithResult = RenderedBlock.RenderedBlock.swift— enum of block kinds, each variant carries rendered data (AttributedString for inline, metadata for special blocks).MarkdownStreamingBuffer.swift— throttle / coalesce layer for streaming input.MarkdownBlockCache.swift—LRUCache<UInt64, RenderedBlock>keyed by hash(blockSource).Public API
```swift
public struct DialogueMarkdownView: View {
public init(
source: String,
streaming: Bool = false,
cache: MarkdownBlockCache? = nil
)
}
```
source— full markdown content. For streaming, the view splits it intostableSource(up to last\n\n) andtail(unfinished).streaming = true— use tail-fast-path (render tail as plainText, re-parse only stable on boundary changes).cache— optional external cache (shared across manyDialogueMarkdownViewinstances, e.g., one per message).Block subset (MVP)
Supported blocks:
Document— VStack (DS.Spacing.mdbetween blocks)Heading(1–6) —DS.Text.{title, headline, body}with level-specific weightParagraph—Text(AttributedString(markdown: paragraph.format(), options: .inlineOnlyPreservingWhitespace))CodeBlock(fenced) —BlockCodeContainer(D-3) wrapping monospaced TextUnorderedList/OrderedList—ForEachwith bullet / number + indented contentListItem— paragraph children; task-list checkbox (SF Symbol, read-only)BlockQuote— left border + indentThematicBreak—DividerText/ inline — delegated to AttributedStringNot supported in MVP (render as plain text with warning):
Table(v1.1)Image(v1.1)HTMLBlock/InlineHTML(render as plain text)Streaming strategy
MarkdownStreamingBuffer:String(growing source).Task.sleep(for: .milliseconds(16))cancellable on each new input. When debounce fires, split source at last\n\n.Textat bottom of rendered list.Swift 6 concurrency
Document,Markuptypes not explicitlySendable— parse on@MainActor, never cross actor boundary with AST types; pass onlyRenderedBlock/AttributedString(which are Sendable).Acceptance Criteria
DialogueMarkdownViewrenders all MVP block types correctly — verified via snapshot tests for 10 representative markdown samples (mix of paragraphs + code + lists + headings + blockquote)..textSelection(.enabled)).@Environment(\.openURL).Document/Markupkept behind@MainActor.Relationships