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 { public var toolCallCount: Int {
assistantMessages.reduce(0) { $0 + $1.toolCalls.count } 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 @Observable
@@ -759,7 +774,42 @@ public final class RichChatViewModel {
} else { } else {
messages.append(msg) messages.append(msg)
} }
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() 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. /// Convert the streaming message (id=0) into a permanent message and reset streaming state.
@@ -59,6 +59,7 @@ struct RichChatMessageList: View {
ForEach(groups) { group in ForEach(groups) { group in
MessageGroupView(group: group, turnDurations: turnDurations) MessageGroupView(group: group, turnDurations: turnDurations)
.equatable()
.id("group-\(group.id)") .id("group-\(group.id)")
} }
@@ -136,7 +137,7 @@ struct RichChatMessageList: View {
} }
} }
struct MessageGroupView: View { struct MessageGroupView: View, Equatable {
let group: MessageGroup let group: MessageGroup
/// Wall-clock turn durations keyed by assistant-message id (v2.5). /// Wall-clock turn durations keyed by assistant-message id (v2.5).
/// Forwarded into `RichMessageBubble` so the metadata footer can /// Forwarded into `RichMessageBubble` so the metadata footer can
@@ -144,10 +145,47 @@ struct MessageGroupView: View {
/// that haven't been updated yet still compile. /// that haven't been updated yet still compile.
var turnDurations: [Int: TimeInterval] = [:] var turnDurations: [Int: TimeInterval] = [:]
/// Equatable short-circuit for SwiftUI: when the trailing group's
/// streaming bubble grows, only that group's `==` returns false.
/// All earlier groups skip body re-evaluation, dropping per-chunk
/// render work from O(n) to O(1) for settled groups (issue #46).
///
/// What participates:
/// - `group.id` (primary key stable sequential index).
/// - assistant-message id list (additions / finalize-id-flip).
/// - For the streaming message (id == 0): content, reasoning,
/// reasoningContent, toolCalls.count the only fields that
/// mutate while streaming.
/// - `turnDurations[msg.id]` for assistants in this group only
/// the dict is large and shared across groups, but each group
/// only renders its own entries.
/// - `group.toolResults.count` append-only within a group.
static func == (lhs: MessageGroupView, rhs: MessageGroupView) -> Bool {
guard lhs.group.id == rhs.group.id else { return false }
guard lhs.group.userMessage?.id == rhs.group.userMessage?.id else { return false }
guard lhs.group.userMessage?.content == rhs.group.userMessage?.content else { return false }
guard lhs.group.assistantMessages.count == rhs.group.assistantMessages.count else { return false }
for (l, r) in zip(lhs.group.assistantMessages, rhs.group.assistantMessages) {
if l.id != r.id { return false }
if l.id == 0 {
if l.content != r.content { return false }
if l.reasoning != r.reasoning { return false }
if l.reasoningContent != r.reasoningContent { return false }
if l.toolCalls.count != r.toolCalls.count { return false }
}
}
if lhs.group.toolResults.count != rhs.group.toolResults.count { return false }
for msg in lhs.group.assistantMessages where msg.isAssistant && msg.id != 0 {
if lhs.turnDurations[msg.id] != rhs.turnDurations[msg.id] { return false }
}
return true
}
var body: some View { var body: some View {
VStack(alignment: .leading, spacing: 8) { VStack(alignment: .leading, spacing: 8) {
if let user = group.userMessage { if let user = group.userMessage {
RichMessageBubble(message: user, toolResults: [:]) RichMessageBubble(message: user, toolResults: [:])
.equatable()
} }
// Identify by array offset rather than `message.id`. The // Identify by array offset rather than `message.id`. The
@@ -166,6 +204,7 @@ struct MessageGroupView: View {
toolResults: group.toolResults, toolResults: group.toolResults,
turnDuration: turnDurations[message.id] turnDuration: turnDurations[message.id]
) )
.equatable()
} }
if group.toolCallCount > 1 { if group.toolCallCount > 1 {
@@ -176,7 +215,7 @@ struct MessageGroupView: View {
@ViewBuilder @ViewBuilder
private var toolSummary: some View { private var toolSummary: some View {
let kinds = toolKindCounts let kinds = group.toolKindCounts
if !kinds.isEmpty { if !kinds.isEmpty {
HStack(spacing: 4) { HStack(spacing: 4) {
Image(systemName: "wrench") Image(systemName: "wrench")
@@ -190,16 +229,6 @@ struct MessageGroupView: View {
} }
} }
private var toolKindCounts: [ToolKind: Int] {
var counts: [ToolKind: Int] = [:]
for msg in group.assistantMessages where msg.isAssistant {
for call in msg.toolCalls {
counts[call.toolKind, default: 0] += 1
}
}
return counts
}
private func summaryText(_ kinds: [ToolKind: Int]) -> String { private func summaryText(_ kinds: [ToolKind: Int]) -> String {
let total = kinds.values.reduce(0, +) let total = kinds.values.reduce(0, +)
let parts = kinds.sorted(by: { $0.value > $1.value }) let parts = kinds.sorted(by: { $0.value > $1.value })
@@ -2,7 +2,7 @@ import SwiftUI
import ScarfCore import ScarfCore
import ScarfDesign import ScarfDesign
struct RichMessageBubble: View { struct RichMessageBubble: View, Equatable {
let message: HermesMessage let message: HermesMessage
let toolResults: [String: HermesMessage] let toolResults: [String: HermesMessage]
/// Wall-clock duration of the agent turn this assistant message /// Wall-clock duration of the agent turn this assistant message
@@ -14,6 +14,29 @@ struct RichMessageBubble: View {
@Environment(ChatViewModel.self) private var chatViewModel @Environment(ChatViewModel.self) private var chatViewModel
/// SwiftUI body short-circuit (issue #46). Settled bubbles
/// (`message.id != 0`) are immutable id equality plus a couple
/// of cheap stored-field comparisons is sufficient. The streaming
/// bubble (id == 0) gets a content + reasoning + toolCalls.count
/// comparison so it correctly redraws on every chunk.
/// `toolResults` is compared by count: results are append-only
/// within a group, so a count change implies a new tool result.
static func == (lhs: RichMessageBubble, rhs: RichMessageBubble) -> Bool {
guard lhs.message.id == rhs.message.id else { return false }
if lhs.message.id == 0 {
return lhs.message.content == rhs.message.content
&& lhs.message.reasoning == rhs.message.reasoning
&& lhs.message.reasoningContent == rhs.message.reasoningContent
&& lhs.message.toolCalls.count == rhs.message.toolCalls.count
&& lhs.turnDuration == rhs.turnDuration
&& lhs.toolResults.count == rhs.toolResults.count
}
return lhs.turnDuration == rhs.turnDuration
&& lhs.toolResults.count == rhs.toolResults.count
&& lhs.message.tokenCount == rhs.message.tokenCount
&& lhs.message.finishReason == rhs.message.finishReason
}
var body: some View { var body: some View {
if message.isUser { if message.isUser {
userBubble userBubble
@@ -16,6 +16,12 @@ struct ToolCallCard: View {
var onFocus: (() -> Void)? = nil var onFocus: (() -> Void)? = nil
@State private var expanded = false @State private var expanded = false
/// Pretty-printed `call.arguments`. Computed once per `call.callId`
/// via `.task(id:)` instead of on every card re-render (issue #46).
/// Seeded with the raw arguments so the first frame after expand
/// shows readable text instead of a flicker of empty space while
/// the task runs.
@State private var formattedArgs: String = ""
var body: some View { var body: some View {
VStack(alignment: .leading, spacing: 6) { VStack(alignment: .leading, spacing: 6) {
@@ -77,7 +83,7 @@ struct ToolCallCard: View {
Text("ARGUMENTS") Text("ARGUMENTS")
.scarfStyle(.captionUppercase) .scarfStyle(.captionUppercase)
.foregroundStyle(ScarfColor.foregroundMuted) .foregroundStyle(ScarfColor.foregroundMuted)
Text(formatJSON(call.arguments)) Text(formattedArgs.isEmpty ? call.arguments : formattedArgs)
.font(ScarfFont.monoSmall) .font(ScarfFont.monoSmall)
.foregroundStyle(ScarfColor.foregroundPrimary) .foregroundStyle(ScarfColor.foregroundPrimary)
.textSelection(.enabled) .textSelection(.enabled)
@@ -102,6 +108,9 @@ struct ToolCallCard: View {
.padding(.leading, 4) .padding(.leading, 4)
} }
} }
.task(id: call.callId) {
formattedArgs = formatJSON(call.arguments)
}
} }
private var toolLabel: String { private var toolLabel: String {
@@ -141,13 +150,18 @@ struct ToolResultContent: View {
let content: String let content: String
@State private var showAll = false @State private var showAll = false
/// Cached line split. The previous computed-property pair
private var lines: [String] { content.components(separatedBy: "\n") } /// (`lines` + `isLong`) split `content` twice on every render
private var isLong: Bool { lines.count > 8 } /// once for the count check, once for the prefix join. With long
/// tool outputs (file contents, command output) this was O(n)
/// per render, repeated for every settled card on every chunk
/// (issue #46). Now split once per content change via `.task(id:)`.
@State private var lines: [String] = []
@State private var preview: String = ""
var body: some View { var body: some View {
VStack(alignment: .leading, spacing: 4) { VStack(alignment: .leading, spacing: 4) {
Text(showAll ? content : lines.prefix(8).joined(separator: "\n")) Text(showAll ? content : preview)
.font(ScarfFont.monoSmall) .font(ScarfFont.monoSmall)
.foregroundStyle(ScarfColor.foregroundPrimary) .foregroundStyle(ScarfColor.foregroundPrimary)
.textSelection(.enabled) .textSelection(.enabled)
@@ -162,7 +176,7 @@ struct ToolResultContent: View {
) )
) )
if isLong { if lines.count > 8 {
Button(showAll ? "Show less" : "Show all \(lines.count) lines") { Button(showAll ? "Show less" : "Show all \(lines.count) lines") {
withAnimation { showAll.toggle() } withAnimation { showAll.toggle() }
} }
@@ -171,5 +185,10 @@ struct ToolResultContent: View {
.buttonStyle(.plain) .buttonStyle(.plain)
} }
} }
.task(id: content) {
let split = content.components(separatedBy: "\n")
lines = split
preview = split.prefix(8).joined(separator: "\n")
}
} }
} }