diff --git a/NucleicRemote/NucleicRemote/Models/RemoteStore.swift b/NucleicRemote/NucleicRemote/Models/RemoteStore.swift index bfaa22c..31c7870 100644 --- a/NucleicRemote/NucleicRemote/Models/RemoteStore.swift +++ b/NucleicRemote/NucleicRemote/Models/RemoteStore.swift @@ -527,32 +527,66 @@ final class RemoteStore: ObservableObject { return cb } - /// Mirror the active connection's projection onto the flat @Published state the UI binds to. - private func mirrorActive() { - guard let c = activeConnection else { return } - connectivity = c.connectivity - hostName = c.hostName - sessions = c.sessions - capabilities = c.capabilities - grantedScope = c.grantedScope - modelCatalog = c.modelCatalog - dashboard = c.dashboard - meshPeers = c.meshPeers + /// Re-merge every connected Mac's projection into the flat @Published state the UI binds to + /// (mesh P3): all hosts' sessions and dashboards shown together, with the host-specific values + /// (host name, capabilities, catalog, connectivity, scope) following the open session's Mac — + /// or a representative one. No-op in demo, which seeds the aggregate directly. + private func rebuildAggregate() { + guard !demoMode else { return } + sessions = aggregatedSessions() + dashboard = DashboardSnapshot.merged(connections.values.map(\.dashboard)) + meshPeers = connections.values.flatMap(\.meshPeers) + + let ctx = contextConnection + hostName = ctx?.hostName ?? "" + capabilities = ctx?.capabilities + ?? WireCapabilities(canModifyToolInput: false, allowAlwaysScopes: []) + 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 + } else { + 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 + } } - /// Every connected Mac's live (non-archived) sessions — the aggregate the app-icon badge and - /// Live Activity summarize across hosts. - private var allLiveSessions: [WireSessionSummary] { - if demoMode { return demoHosts.flatMap(\.sessions).filter { !$0.archived } } - return connections.values.flatMap(\.sessions).filter { !$0.archived } + /// The flat sessions list: every connected Mac's sessions, deduped by id (ids are globally + /// unique). Views handle sorting/grouping. + private func aggregatedSessions() -> [WireSessionSummary] { + var seen = Set() + var result: [WireSessionSummary] = [] + for conn in connections.values { + for summary in conn.sessions where seen.insert(summary.sessionID).inserted { + result.append(summary) + } + } + return result + } + + /// One connectivity value for the whole app when no transcript is open (the chip + global + /// gating): connected if any Mac is live, else the most-informative in-progress/offline state. + private func aggregateConnectivity() -> Connectivity { + let all = connections.values.map(\.connectivity) + guard !all.isEmpty else { return .unpaired } + if let live = all.first(where: { $0.isLive }) { return live } + if all.contains(where: { if case .connecting = $0 { return true } else { return false } }) { return .connecting } + if all.contains(where: { if case .reconnecting = $0 { return true } else { return false } }) { return .reconnecting } + if all.contains(.hostOffline) { return .hostOffline } + return all.first ?? .unpaired } /// Refresh cross-host aggregates: the app-icon badge (needs-you across *all* Macs) + the Live - /// Activity summary. + /// Activity summary. `sessions`/`liveSessions` are already the merged aggregate. private func refreshAggregate() { - let needs = allLiveSessions.filter { $0.status.needsYou($0.disposition) }.count - NotificationRouter.shared.updateBadge(needs) - LiveActivityManager.shared.sync(hostName: hostName, sessions: allLiveSessions) + NotificationRouter.shared.updateBadge(needsYouCount) + LiveActivityManager.shared.sync(hostName: hostName, sessions: liveSessions) } /// Tear down every live connection (leaving the paired registry intact) — for entering demo.