From 4bd4e817788e972a90db168f49b35fb63c5b317b Mon Sep 17 00:00:00 2001 From: Andrew Blakeslee Moore Date: Mon, 6 Jul 2026 18:49:35 -0700 Subject: [PATCH] Transcript Incremental Projection Nucleic-Session: 0FFD007B-0696-4517-9429-129C7B0FD5AC Co-authored-by: Nucleic --- .../NucleicRemote/Models/RemoteStore.swift | 45 ++++++++++++------- 1 file changed, 29 insertions(+), 16 deletions(-) diff --git a/NucleicRemote/NucleicRemote/Models/RemoteStore.swift b/NucleicRemote/NucleicRemote/Models/RemoteStore.swift index 7cd57f1..5ccc3cb 100644 --- a/NucleicRemote/NucleicRemote/Models/RemoteStore.swift +++ b/NucleicRemote/NucleicRemote/Models/RemoteStore.swift @@ -725,41 +725,54 @@ final class RemoteStore: ObservableObject { /// or a representative one. No-op in demo, which seeds the aggregate directly. private func rebuildAggregate() { guard !demoMode else { return } + // Every assignment below goes through `setIfChanged`: `didUpdate` fires on *every* wire + // frame from *any* host (session churn, dashboard refresh, diff-stat ticks), and a plain + // `@Published` assignment fires `objectWillChange` even when the value is identical — + // re-evaluating every view observing the store for nothing. Equality checks over these + // small aggregates are far cheaper than a whole-tree SwiftUI invalidation. let live = aggregatedSessions() if !live.isEmpty { - sessions = live - cachedSummaries = live - persistSummaries(live) + if sessions != live { + sessions = live + cachedSummaries = live + persistSummaries(live) + } } else if connections.values.contains(where: { $0.connectivity.isLive }) { // Connected, but the host genuinely has no sessions — reflect that honestly. - sessions = [] + setIfChanged(\.sessions, []) } else { // Offline: keep showing the saved history rather than blanking the list. - sessions = cachedSummaries + setIfChanged(\.sessions, cachedSummaries) } - dashboard = DashboardSnapshot.merged(connections.values.map(\.dashboard)) - meshPeers = connections.values.flatMap(\.meshPeers) + setIfChanged(\.dashboard, DashboardSnapshot.merged(connections.values.map(\.dashboard))) + setIfChanged(\.meshPeers, connections.values.flatMap(\.meshPeers)) let ctx = contextConnection - hostName = ctx?.hostName ?? "" - capabilities = ctx?.capabilities - ?? WireCapabilities(canModifyToolInput: false, allowAlwaysScopes: []) - modelCatalog = ctx?.modelCatalog ?? .empty + setIfChanged(\.hostName, ctx?.hostName ?? "") + setIfChanged(\.capabilities, ctx?.capabilities + ?? WireCapabilities(canModifyToolInput: false, allowAlwaysScopes: [])) + setIfChanged(\.modelCatalog, ctx?.modelCatalog ?? .empty) if let id = openSessionHostID, let conn = connections[id] { // While a transcript is open, the composer/controls act on *that* Mac — its connectivity // gates send and its scope drives the control affordances. - connectivity = conn.connectivity - grantedScope = conn.grantedScope + setIfChanged(\.connectivity, conn.connectivity) + setIfChanged(\.grantedScope, conn.grantedScope) } else { - connectivity = aggregateConnectivity() + setIfChanged(\.connectivity, aggregateConnectivity()) // Optimistic new-chat gating: enabled if *any* Mac grants control (the owning Mac still // enforces scope when the intent lands there). - grantedScope = connections.values - .filter { $0.connectivity.isLive }.map(\.grantedScope).max() ?? .approve + setIfChanged(\.grantedScope, connections.values + .filter { $0.connectivity.isLive }.map(\.grantedScope).max() ?? .approve) } } + /// Assign a `@Published` property only when the value actually differs, so a no-op rebuild + /// doesn't fire `objectWillChange` (and with it a whole-tree view re-evaluation). + private func setIfChanged(_ keyPath: ReferenceWritableKeyPath, _ value: T) { + if self[keyPath: keyPath] != value { self[keyPath: keyPath] = value } + } + /// Merge transcript events by `seq` (monotonic, globally unique within a session), keeping the /// union sorted. Lets a reconnect's snapshot fold its events into the transcript already on /// screen without duplicating what's shown or dropping history outside the host's tail window.