perf(chat-ios): mirror Mac equatable short-circuit on ScarfGo bubbles (#46)

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) <noreply@anthropic.com>
This commit is contained in:
Alan Wizemann
2026-04-27 12:23:32 +02:00
parent 8d9de4c576
commit 558970a09a
+29 -1
View File
@@ -200,6 +200,7 @@ struct ChatView: View {
message: msg, message: msg,
turnDuration: controller.vm.turnDuration(forMessageId: msg.id) turnDuration: controller.vm.turnDuration(forMessageId: msg.id)
) )
.equatable()
.id(msg.id) .id(msg.id)
} }
if controller.vm.isGenerating { if controller.vm.isGenerating {
@@ -1006,7 +1007,7 @@ private struct PermissionWrapper: Identifiable {
// MARK: - Message bubble // MARK: - Message bubble
private struct MessageBubble: View { private struct MessageBubble: View, Equatable {
let message: HermesMessage let message: HermesMessage
/// Wall-clock duration of the agent turn this assistant message /// Wall-clock duration of the agent turn this assistant message
/// belongs to (v2.5). Renders as a small `4.2s` pill below the /// belongs to (v2.5). Renders as a small `4.2s` pill below the
@@ -1014,6 +1015,33 @@ private struct MessageBubble: View {
/// resumed messages. /// resumed messages.
var turnDuration: TimeInterval? = nil 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 { var body: some View {
if message.isToolResult { if message.isToolResult {
ToolResultRow(message: message) ToolResultRow(message: message)