mirror of
https://github.com/awizemann/scarf.git
synced 2026-05-10 18:44:45 +00:00
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:
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user