diff --git a/Sources/Halos/Views/ActivityListView.swift b/Sources/Halos/Views/ActivityListView.swift index c18fff4..4162eee 100644 --- a/Sources/Halos/Views/ActivityListView.swift +++ b/Sources/Halos/Views/ActivityListView.swift @@ -19,6 +19,7 @@ struct ActivityListView: View { } else { ForEach(messages) { message in ActivityRowView(message: message, resolveVeyraApproval: resolveVeyraApproval) + .equatable() .id(message.id) } } @@ -77,24 +78,15 @@ struct ActivityListView: View { guard viewportHeight > 0 else { return } isPinnedToBottom = bottomOffset <= viewportHeight + bottomFollowThreshold } - .onChange(of: contentFingerprint) { _, _ in + .onChange(of: contentToken) { _, _ in guard isPinnedToBottom else { return } scrollToBottom(proxy, animated: true) } } } - private var contentFingerprint: String { - messages - .map { message in - [ - message.id, - message.title, - message.body, - message.streamState.rawValue, - ].joined(separator: "\u{1F}") - } - .joined(separator: "\u{1E}") + private var contentToken: [ActivityListContentToken] { + messages.map(ActivityListContentToken.init) } private func scrollToBottom(_ proxy: ScrollViewProxy, animated: Bool) { @@ -124,6 +116,18 @@ struct ActivityListView: View { } } +private struct ActivityListContentToken: Equatable { + let id: String + let bodyByteCount: Int + let streamState: MessageStreamState + + init(message: ActivityMessage) { + self.id = message.id + self.bodyByteCount = message.body.utf8.count + self.streamState = message.streamState + } +} + private enum ActivityListCoordinateSpace { static let name = "activity-list-scroll" } diff --git a/Sources/Halos/Views/ActivityRowView.swift b/Sources/Halos/Views/ActivityRowView.swift index bbb4390..5c1f777 100644 --- a/Sources/Halos/Views/ActivityRowView.swift +++ b/Sources/Halos/Views/ActivityRowView.swift @@ -1,6 +1,6 @@ import SwiftUI -struct ActivityRowView: View { +struct ActivityRowView: View, Equatable { let message: ActivityMessage let resolveVeyraApproval: ((String, VeyraApprovalDecision) -> Void)? @Environment(\.accessibilityReduceMotion) private var reduceMotion @@ -15,6 +15,10 @@ struct ActivityRowView: View { self.resolveVeyraApproval = resolveVeyraApproval } + nonisolated static func == (lhs: ActivityRowView, rhs: ActivityRowView) -> Bool { + lhs.message == rhs.message + } + var body: some View { if let approval = message.veyraApproval { veyraApprovalCard(approval) @@ -295,8 +299,8 @@ struct ActivityRowView: View { if isWorkSummaryExpanded { VStack(alignment: .leading, spacing: 7) { - ForEach(workSummaryLines, id: \.self) { line in - markdownText(line) + ForEach(workSummaryLines) { line in + markdownText(line.text) .font(.system(size: 11.5, weight: .medium)) .foregroundStyle(HalosTheme.tertiaryText) .fixedSize(horizontal: false, vertical: true) @@ -314,10 +318,11 @@ struct ActivityRowView: View { .frame(maxWidth: .infinity, alignment: .leading) } - private var workSummaryLines: [String] { + private var workSummaryLines: [WorkSummaryLine] { message.body .split(separator: "\n", omittingEmptySubsequences: true) - .map(String.init) + .enumerated() + .map { index, line in WorkSummaryLine(index: index, text: String(line)) } } private func syncWorkSummaryExpansionIfNeeded() { @@ -336,6 +341,15 @@ struct ActivityRowView: View { } } +private struct WorkSummaryLine: Identifiable { + let index: Int + let text: String + + var id: Int { + index + } +} + struct HalosThinkingEyesView: View { let isActive: Bool @Environment(\.accessibilityReduceMotion) private var reduceMotion