From 558970a09a341343b391da30f14c3f849f65e63e Mon Sep 17 00:00:00 2001 From: Alan Wizemann Date: Mon, 27 Apr 2026 12:23:32 +0200 Subject: [PATCH] perf(chat-ios): mirror Mac equatable short-circuit on ScarfGo bubbles (#46) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ScarfGo's chat is a separate rendering path: LazyVStack + ForEach(controller.vm.messages) with a private MessageBubble struct (not the shared MessageGroupView/RichMessageBubble used on Mac). The Mac fix's Equatable conformances therefore didn't propagate. Without short-circuiting, every visible bubble re-evaluates body on each streamed ACP chunk because the @Observable VM's `messages` mutation invalidates anyone reading it — and each bubble's `ChatContentFormatter.segments` + `AttributedString(markdown:)` are both O(content) per render. LazyVStack already keeps off-screen bubbles dormant on iOS, but the 5–10 visible bubbles re-parsing on every chunk is enough to bog down a long turn on phone hardware. Add Equatable to MessageBubble (id-keyed, with content/reasoning/ toolCalls.count compared only for the streaming bubble id==0) and apply .equatable() at the ForEach call site. Settled bubbles short- circuit body re-eval; the streaming bubble still redraws per chunk. Note: the trailing-group patch helper (Mac fix part 2) already benefits iOS as a side effect — buildMessageGroups() is no longer called per chunk, and even though iOS doesn't read messageGroups directly, the elided rebuild is still wasted work avoided. Co-Authored-By: Claude Opus 4.7 (1M context) --- scarf/Scarf iOS/Chat/ChatView.swift | 30 ++++++++++++++++++++++++++++- 1 file changed, 29 insertions(+), 1 deletion(-) diff --git a/scarf/Scarf iOS/Chat/ChatView.swift b/scarf/Scarf iOS/Chat/ChatView.swift index 405fb8b..f6561a8 100644 --- a/scarf/Scarf iOS/Chat/ChatView.swift +++ b/scarf/Scarf iOS/Chat/ChatView.swift @@ -200,6 +200,7 @@ struct ChatView: View { message: msg, turnDuration: controller.vm.turnDuration(forMessageId: msg.id) ) + .equatable() .id(msg.id) } if controller.vm.isGenerating { @@ -1006,7 +1007,7 @@ private struct PermissionWrapper: Identifiable { // MARK: - Message bubble -private struct MessageBubble: View { +private struct MessageBubble: View, Equatable { let message: HermesMessage /// Wall-clock duration of the agent turn this assistant message /// belongs to (v2.5). Renders as a small `4.2s` pill below the @@ -1014,6 +1015,33 @@ private struct MessageBubble: View { /// resumed messages. var turnDuration: TimeInterval? = nil + /// SwiftUI body short-circuit (issue #46 — iOS path). On iOS the + /// chat list is `LazyVStack` over `controller.vm.messages` directly + /// (no message-group layer), so every visible bubble re-evaluates + /// its body on each streamed chunk because `messages` mutates and + /// the `@Observable` VM invalidates anyone reading it. Without + /// equatable short-circuiting, every visible bubble re-runs + /// `ChatContentFormatter.segments` + `AttributedString(markdown:)` + /// per chunk — CPU-expensive on phones, especially with long + /// content already on screen. + /// + /// Streaming message has `id == 0` (shared with Mac via + /// `RichChatViewModel.streamingId`); it correctly redraws on + /// every chunk via the content/reasoning/toolCalls.count compare. + static func == (lhs: MessageBubble, rhs: MessageBubble) -> 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 + } + return lhs.turnDuration == rhs.turnDuration + && lhs.message.tokenCount == rhs.message.tokenCount + && lhs.message.finishReason == rhs.message.finishReason + } + var body: some View { if message.isToolResult { ToolResultRow(message: message)