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