perf(chat): stop O(n)-per-token re-render of settled bubbles (#46)

Long chats progressively bog down and eventually crash because every
streamed ACP token triggers a full messageGroups rebuild plus a body
re-evaluation of every MessageGroupView and RichMessageBubble — even
the n-1 settled groups that haven't changed. Three changes cap per-chunk
work at "patch the trailing group + re-render the streaming bubble":

- MessageGroupView and RichMessageBubble are now Equatable, applied
  via .equatable() in the ForEach. Settled groups (no streaming
  message inside) short-circuit body re-evaluation entirely; the
  streaming group compares content/reasoning/toolCalls.count so it
  still redraws on every chunk.
- RichChatViewModel.upsertStreamingMessage no longer calls
  buildMessageGroups() per chunk. New patchTrailingGroupForStreaming
  mutates only the trailing group's assistant entry in place. The 9
  other call sites of buildMessageGroups() are untouched — they cover
  structural events (user message, tool-call complete, finalize,
  session resume) where group boundaries can actually change, and a
  full rebuild is correct there.
- MessageGroup.toolKindCounts is now a model property (was a
  MessageGroupView computed prop that re-walked O(m × k) per body
  render). Lives behind the Equatable short-circuit.
- ToolCallCard.formatJSON cached via .task(id: call.callId) so JSON
  pretty-printing runs once per card lifetime instead of on every
  expand/collapse + every neighbour's re-render. Seeded with raw
  arguments to avoid a first-frame empty-text flicker.
- ToolResultContent.lines/preview cached via .task(id: content) — the
  prior pair of computed properties split content on \n twice per
  render, expensive on long command/file output.

Skipped from the original plan: the per-message parse cache
(rendered moot once Equatable already short-circuits settled bubbles)
and the LazyVStack switch (deferred — RichChatMessageList comments
flag scroll-anchor regression risk; revisit separately if needed).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Alan Wizemann
2026-04-27 12:12:12 +02:00
parent e0f0fad192
commit 8d9de4c576
4 changed files with 141 additions and 20 deletions
@@ -27,6 +27,21 @@ public struct MessageGroup: Identifiable {
public var toolCallCount: Int {
assistantMessages.reduce(0) { $0 + $1.toolCalls.count }
}
/// Aggregated `ToolKind count` over all assistant tool calls in
/// this group. Lives on the model so SwiftUI's Equatable
/// short-circuit (issue #46) covers it previously this was a
/// `MessageGroupView` computed property that re-walked O(m × k)
/// per group on every body re-evaluation.
public var toolKindCounts: [ToolKind: Int] {
var counts: [ToolKind: Int] = [:]
for msg in assistantMessages where msg.isAssistant {
for call in msg.toolCalls {
counts[call.toolKind, default: 0] += 1
}
}
return counts
}
}
@Observable
@@ -759,7 +774,42 @@ public final class RichChatViewModel {
} else {
messages.append(msg)
}
buildMessageGroups()
patchTrailingGroupForStreaming(streamingMsg: msg)
}
/// Per-chunk fast path for `messageGroups` (issue #46). Mutates
/// only the trailing group's assistant entry instead of rebuilding
/// the entire `messageGroups` array via `buildMessageGroups()` on
/// every streamed token.
///
/// Falls back to a full rebuild whenever it can't safely patch:
/// - no trailing group exists yet (e.g. first chunk after `reset`)
/// - the trailing group is a user-only group (the very first chunk
/// of a brand-new turn we need a full rebuild so the assistant
/// is grouped under the right user message)
///
/// Other call sites of `buildMessageGroups()` are intentionally
/// untouched: they handle structural events (user message, tool
/// call complete, finalize, session resume) where group boundaries
/// can change, and a full rebuild is the right move there.
private func patchTrailingGroupForStreaming(streamingMsg: HermesMessage) {
guard let lastIdx = messageGroups.indices.last else {
buildMessageGroups()
return
}
let trailing = messageGroups[lastIdx]
var assistants = trailing.assistantMessages
if let i = assistants.firstIndex(where: { $0.id == Self.streamingId }) {
assistants[i] = streamingMsg
} else {
assistants.append(streamingMsg)
}
messageGroups[lastIdx] = MessageGroup(
id: trailing.id,
userMessage: trailing.userMessage,
assistantMessages: assistants,
toolResults: trailing.toolResults
)
}
/// Convert the streaming message (id=0) into a permanent message and reset streaming state.