Skip to content

fix(ai-chat): cache markdown parse, equatable code blocks, native scrollPosition (#1239)#1244

Merged
datlechin merged 4 commits into
mainfrom
fix/aichat-perf-1239
May 13, 2026
Merged

fix(ai-chat): cache markdown parse, equatable code blocks, native scrollPosition (#1239)#1244
datlechin merged 4 commits into
mainfrom
fix/aichat-perf-1239

Conversation

@datlechin
Copy link
Copy Markdown
Member

Closes #1239.

Depends on #1243 (build fix for main). Merge that first.

Symptoms

  • CPU pegs at 100% when scrolling in AI chat that has any non-trivial response.
  • Chat appears empty mid-stream / on completion; clicking the chevron-down brings content back.

Root cause

Three compounding bugs in MarkdownView.swift + AIChatPanelView.swift:

  1. MarkdownBlock.id = UUID() (and MarkdownListItem.id = UUID()). Every parse generated fresh UUIDs, so ForEach thought every block was new. SwiftUI tore down + rebuilt all child views on every re-render — including AIChatCodeBlockView, which instantiates a full CodeEditSourceEditor (tree-sitter, theme, layout) per block.
  2. MarkdownView.body re-parsed every evaluation. MarkdownBlockParser.parse(source) ran inline as a let in body. Combined with bug 1, every scroll frame and every streaming chunk re-parsed every visible message and recreated every code block.
  3. Bottom-anchor scroll detection. A 1-px Color.clear at the end of the LazyVStack toggled isUserScrolledUp via onAppear/onDisappear. When the heavy re-render churn (from bugs 1+2) caused cells to flicker out of materialization, the chevron overlaid an apparently-empty chat. The user pressing the chevron forced a layout pass that re-materialized cells.

For a 5000-character response with 3 code blocks, each scroll frame was re-parsing ~100 markdown blocks and recreating 3 tree-sitter editors. That's the 100% CPU.

Fix

Four changes, no quick wins, native macOS APIs throughout:

MarkdownView.swift

  • Stable identity: MarkdownBlock.id and MarkdownListItem.id switched from UUID() to deterministic Int indexes (top-level position, item-index within list). SwiftUI now preserves view identity across re-parses.
  • Parse cache: new MarkdownDocumentCache stored as @State per MarkdownView instance. Identical source returns cached blocks, no re-parse.
  • Inline-parse cache: MarkdownInline.parse backed by an NSCache<NSString, NSAttributedString> (bounded at 4000 entries). Repeated inline parses (table cells, list items) are now O(1) lookups.
  • Equatable everywhere: MarkdownBlock, MarkdownBlock.Kind, MarkdownListItem, MarkdownTableAlignment all conform. MarkdownBlockView adopts Equatable + .equatable() so SwiftUI skips re-render when the block is unchanged.

AIChatCodeBlockView.swift

  • Added Equatable conformance keyed on (code, language). The SourceEditor is no longer recreated when only ancestor state changes.

AIChatPanelView.swift

  • Replaced the Color.clear bottom anchor + onAppear/onDisappear toggle with the native macOS 14+ .scrollPosition(id:anchor: .bottom) API.
  • New state model: bottomVisibleMessageID (driven by SwiftUI) + pinnedToBottom (derived: true when the bottommost visible message is the last message). The chevron-down button shows only when pinnedToBottom is false.
  • Programmatic auto-scroll-to-latest writes bottomVisibleMessageID = lastMessageID instead of using ScrollViewProxy.scrollTo("bottomAnchor"). ScrollViewReader removed.
  • .scrollTargetLayout() added so SwiftUI can resolve target IDs.

Why this is the right architecture

  • Identity is content-addressed, not allocation-addressed. SwiftUI diffing works correctly across re-renders.
  • Parsing happens once per unique source string, not once per body call.
  • SourceEditor lifecycle is decoupled from parent re-renders via Equatable. The expensive tree-sitter setup only runs when the underlying code actually changes.
  • Scroll detection is one native API call instead of a fragile geometry-driven side effect. Survives content reflow during streaming.

Test plan

  • Send the AI a prompt that yields ~3000 chars of mixed markdown + 2 code blocks. Scroll up/down rapidly. CPU should stay reasonable; chat content stays visible the whole time.
  • Stream completion (text → markdown transition) no longer flashes blank.
  • Scroll up; chevron-down appears. Click it; smooth scroll to bottom.
  • Scroll back to bottom manually; chevron disappears, auto-scroll resumes for new messages.
  • Switch between conversations; auto-scroll to bottom of new conversation.

Files changed

  • Views/AIChat/MarkdownView.swift (+~140 / -~100, structural rewrite)
  • Views/AIChat/AIChatCodeBlockView.swift (+5)
  • Views/AIChat/AIChatPanelView.swift (+~30 / -~30 messageList rewrite, scrollToBottom helper deleted)
  • CHANGELOG.md (+1)

@datlechin datlechin merged commit 947dde4 into main May 13, 2026
2 checks passed
@datlechin datlechin deleted the fix/aichat-perf-1239 branch May 13, 2026 02:50
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

bug: App hangs when scrolling AI Chat with data

1 participant