From 4cc54cb18fd6e90a7573501fd5104108928ad22e Mon Sep 17 00:00:00 2001 From: Andrew Blakeslee Moore Date: Sun, 5 Jul 2026 02:03:17 -0700 Subject: [PATCH] nvrsion: Add: Unified session/project representation across all hosts in iOS app, refactor views and models to remove selector dependency. Nucleic-Promote: 1 Co-authored-by: Nucleic --- .../NucleicRemote/Models/RemoteStore.swift | 418 ++++++++++++------ .../Views/AdaptiveRootView.swift | 6 +- .../NucleicRemote/Views/SessionsView.swift | 31 +- .../NucleicRemote/Views/SettingsView.swift | 19 +- 4 files changed, 291 insertions(+), 183 deletions(-) diff --git a/NucleicRemote/NucleicRemote/Models/RemoteStore.swift b/NucleicRemote/NucleicRemote/Models/RemoteStore.swift index df9c481..e05c020 100644 --- a/NucleicRemote/NucleicRemote/Models/RemoteStore.swift +++ b/NucleicRemote/NucleicRemote/Models/RemoteStore.swift @@ -38,11 +38,11 @@ final class RemoteStore: ObservableObject { } @Published private(set) var connectivity: Connectivity = .unpaired + /// A representative host name for the aggregate — the open session's Mac when a transcript is + /// open, otherwise a live host. Sessions/projects from *every* connected Mac are merged into the + /// flat state above (mesh P3), so no single "active host" is chosen; per-row provenance comes + /// from `hostName(forSession:)` instead. @Published private(set) var hostName: String = "" - /// The paired Mac this connection currently reflects (mesh P3), by its `HostID` (fingerprint). - /// `hostName`/`sessions`/etc. above are that host's projection; switching hosts re-points them - /// (live: reconnect to a different paired Mac; demo: swap the mock host). `nil` until connected. - @Published private(set) var activeHostID: String? @Published private(set) var sessions: [WireSessionSummary] = [] @Published private(set) var capabilities = WireCapabilities(canModifyToolInput: false, allowAlwaysScopes: []) @Published private(set) var grantedScope: DeviceScope = .approve @@ -60,6 +60,10 @@ final class RemoteStore: ObservableObject { // Open session projection. @Published private(set) var openSessionID: SessionID? + /// The paired Mac that owns the open session (mesh P3). The open transcript's intents route to + /// it, and the host-specific projected values (capabilities/scope/catalog/connectivity) follow + /// it while a session is open. `nil` when nothing is open. + private var openSessionHostID: String? @Published private(set) var openEvents: [AgentEvent] = [] @Published private(set) var openApprovals: [ApprovalRequest] = [] /// The open session's full diff (the Mac Diff tab's patch), fetched on demand when the @@ -180,13 +184,57 @@ final class RemoteStore: ObservableObject { /// Proactive network-path awareness (shared across every host connection): flips transports the /// instant Wi-Fi drops or returns, instead of waiting for a dead LAN socket to time out. let pathMonitor = NetworkPathMonitor() - /// One live connection per paired Mac (mesh P3 multiplexer). All connected at once; the flat - /// `sessions`/`connectivity`/`capabilities`/… above mirror the *active* one (`activeHostID`), so - /// switching hosts is instant (no reconnect). Empty in demo mode (demo seeds state directly). + /// One live connection per paired Mac (mesh P3 multiplexer). All connected at once, and the flat + /// `sessions`/`dashboard` above are the *merged* projection across every one of them — the phone + /// shows all your Macs together, with no active-host selector. Intents route to the Mac that owns + /// the target session/project (see `send`). Empty in demo mode (demo seeds state directly). private var connections: [String: HostConnection] = [:] - /// The active connection — the Mac whose projection the flat state reflects + whose transcript - /// is open. - private var activeConnection: HostConnection? { activeHostID.flatMap { connections[$0] } } + + /// The connection whose projection drives the host-specific flat values (capabilities, scope, + /// catalog, connectivity, host name): the open session's Mac while a transcript is open, else a + /// representative live host (one with a populated catalog, falling back to any live/any host). + private var contextConnection: HostConnection? { + if let id = openSessionHostID, let conn = connections[id] { return conn } + return connections.values.first { $0.connectivity.isLive && !$0.modelCatalog.groups.isEmpty } + ?? connections.values.first { $0.connectivity.isLive } + ?? connections.values.first + } + + /// A live connection to fall back on for host-agnostic intents (e.g. a project-less to-do + /// capture, which has no owning Mac). + private var firstLiveConnection: HostConnection? { + connections.values.first { $0.connectivity.isLive } ?? connections.values.first + } + + // MARK: Intent routing (mesh P3) — resolve the Mac that owns a session / project / to-do. + + private func connection(owningSession id: SessionID) -> HostConnection? { + connections.values.first { $0.sessions.contains { $0.sessionID == id } } + } + private func connection(owningProject id: ProjectID) -> HostConnection? { + connections.values.first { $0.dashboard.projects.contains { $0.id == id } } + } + private func connection(owningTodo id: TodoID) -> HostConnection? { + connections.values.first { $0.dashboard.todos.contains { $0.id == id } } + } + /// Send to every live connection — for host-agnostic pulls (list sessions/dashboard) and as the + /// safety fallback for an approval whose owning Mac isn't known (a notification-driven decision). + private func broadcastLive(_ msg: ClientMsg) { + for conn in connections.values where conn.connectivity.isLive { conn.send(msg) } + } + + /// How many Macs are currently connected — the Sessions list shows a per-row host tag only when + /// more than one, so a single-Mac view stays clean. + var connectedHostCount: Int { + if demoMode { return 1 } + return connections.values.filter { $0.connectivity.isLive }.count + } + + /// The name of the Mac that owns a session, for the per-row host tag. `nil` in demo (no + /// connections) or when the session isn't found on any connected Mac. + func hostName(forSession id: SessionID) -> String? { + connection(owningSession: id)?.hostName + } /// The phone's embedded Tailscale node state, for Settings (nil = not running). Shared across all /// connections (one embedded node, many dials). @Published private(set) var tailnetStatus: String? @@ -207,75 +255,6 @@ final class RemoteStore: ObservableObject { /// In-flight simulated-run tasks (canned streaming), cancelled on `exitDemo()`. private var demoTasks: [Task] = [] - // MARK: - Multi-host switching (mesh P3) - - /// One selectable Mac for the host switcher — the paired Macs (live) or the mock hosts (demo). - struct HostChoice: Identifiable, Equatable { - let id: String // HostID (fingerprint), or a demo id - let name: String - let isActive: Bool - } - - /// The Macs this phone can switch between. Live: the paired-host registry. Demo: the mock hosts. - /// One entry ⇒ the switcher hides itself and the app reads single-host. Today the phone holds one - /// *live* connection at a time (switching reconnects); simultaneous connections are the deferred - /// multiplexer step. - var hostChoices: [HostChoice] { - if demoMode { - return demoHosts.map { HostChoice(id: $0.id, name: $0.name, isActive: $0.id == activeHostID) } - } - return IdentityStore.pairedHosts().map { - HostChoice(id: $0.fingerprint, name: $0.hostName, isActive: $0.fingerprint == activeHostID) - } - } - - /// Switch which paired Mac is active. Live: every Mac is already connected, so this is instant — - /// close the current transcript, re-point at the target connection, and mirror its state. Demo: - /// swap the mock host's projection, preserving in-demo edits. - func switchHost(to id: String) { - guard id != activeHostID else { return } - if demoMode { - if let i = demoHosts.firstIndex(where: { $0.id == activeHostID }) { - demoHosts[i].sessions = sessions // keep any in-demo mutations - demoHosts[i].dashboard = dashboard - } - guard let next = demoHosts.first(where: { $0.id == id }) else { return } - applyDemoHost(next) - } else { - // Close the transcript open on the old host. - activeConnection?.openSessionID = nil - openSessionID = nil; openEvents = []; openApprovals = []; openDiff = nil; diffLoading = false - activeHostID = id - // The target should already be connected; (re)dial only if it isn't. - if let host = IdentityStore.pairedHost(id: id) { - let conn = connection(for: host) - if !conn.connectivity.isLive { conn.reconnect(to: host) } - } - mirrorActive() - } - } - - /// Mock hosts for the multi-host demo. Each carries its own name + sessions + dashboard; - /// switching swaps the flat active-host projection. Empty outside demo. - private struct DemoHost { - let id: String - let name: String - var sessions: [WireSessionSummary] - var dashboard: DashboardSnapshot - } - private var demoHosts: [DemoHost] = [] - - /// Project a mock host onto the flat active-host state (demo host switch). - private func applyDemoHost(_ host: DemoHost) { - activeHostID = host.id - hostName = host.name - sessions = host.sessions - dashboard = host.dashboard - openSessionID = nil; openEvents = []; openApprovals = []; openDiff = nil - LiveActivityManager.shared.sync(hostName: hostName, sessions: sessions) - NotificationRouter.shared.updateBadge(needsYouCount) - } - var isPaired: Bool { demoMode || IdentityStore.loadPairedHost() != nil } var deviceFingerprint: String { identity.fingerprint } @@ -411,11 +390,13 @@ final class RemoteStore: ObservableObject { fiveHour: WireUsageWindow(utilization: 18, resetsAt: Date().addingTimeInterval(2 * 3600)), sevenDay: WireUsageWindow(utilization: 40, resetsAt: nil)), statusFeeds: []) - demoHosts = [ - DemoHost(id: "demo-1", name: "Andrew's Mac", sessions: host1Sessions, dashboard: host1Dashboard), - DemoHost(id: "demo-2", name: "Studio Mac", sessions: host2Sessions, dashboard: host2Dashboard), - ] - applyDemoHost(demoHosts[0]) + // Mesh P3: seed BOTH demo Macs' worlds merged into the flat aggregate, exactly as the app + // presents real paired Macs — every host's sessions/projects/to-dos shown together, no + // switcher. `demoHandle` then mutates this aggregate directly for the interactive demo. + sessions = host1Sessions + host2Sessions + dashboard = DashboardSnapshot.merged([host1Dashboard, host2Dashboard]) + LiveActivityManager.shared.sync(hostName: hostName, sessions: liveSessions) + NotificationRouter.shared.updateBadge(needsYouCount) } /// Offline diff fixture (NUCLEIC_DEMO) so the full-patch Diff tab renders without a host. @@ -461,8 +442,7 @@ final class RemoteStore: ObservableObject { connections[id]?.teardown() let conn = makeConnection(hostID: id, hostName: payload.hostName) connections[id] = conn - activeHostID = id - mirrorActive() + rebuildAggregate() conn.pair(with: payload) } @@ -481,13 +461,14 @@ final class RemoteStore: ObservableObject { } /// The callbacks one `HostConnection` uses to drive shared/aggregate state. `hostID` is captured - /// so a change updates the flat projection only for the active host, while badges, notifications, - /// and the single open transcript are handled globally across hosts. + /// so the open-transcript forwarding fires only for the Mac that owns the open session, while the + /// merged list/dashboard, badges, and notifications are rebuilt across every host on any change. private func callbacks(for hostID: String) -> HostConnection.Callbacks { var cb = HostConnection.Callbacks() cb.didUpdate = { [weak self] in guard let self else { return } - if hostID == self.activeHostID { self.mirrorActive() } + // Any host's change re-merges the aggregate the flat state binds to (mesh P3). + self.rebuildAggregate() self.refreshAggregate() self.flushPendingNotificationDecision() // A host that just connected (or reconnected) needs the current Live Activity token @@ -495,22 +476,22 @@ final class RemoteStore: ObservableObject { self.syncLiveActivityRegistration() } cb.openSnapshot = { [weak self] snap in - guard let self, hostID == self.activeHostID, snap.summary.sessionID == self.openSessionID else { return } + guard let self, hostID == self.openSessionHostID, snap.summary.sessionID == self.openSessionID else { return } self.openEvents = snap.recentEvents self.openApprovals = snap.pendingApprovals } cb.openEvents = { [weak self] batch in - guard let self, hostID == self.activeHostID, batch.sessionID == self.openSessionID else { return } + guard let self, hostID == self.openSessionHostID, batch.sessionID == self.openSessionID else { return } self.openEvents.append(contentsOf: batch.events) } cb.openDiff = { [weak self] diff in - guard let self, hostID == self.activeHostID, diff.sessionID == self.openSessionID else { return } + guard let self, hostID == self.openSessionHostID, diff.sessionID == self.openSessionID else { return } self.openDiff = diff self.diffLoading = false } cb.approvalRequested = { [weak self] req, title in guard let self else { return } - if hostID == self.activeHostID, req.sessionID == self.openSessionID, + if hostID == self.openSessionHostID, req.sessionID == self.openSessionID, !self.openApprovals.contains(where: { $0.id == req.id }) { self.openApprovals.append(req) } @@ -532,7 +513,7 @@ final class RemoteStore: ObservableObject { cb.didPair = { [weak self] host in guard let self else { return } IdentityStore.savePairedHost(host) - self.activeHostID = host.fingerprint + self.rebuildAggregate() } cb.meshRosterChanged = { [weak self] in // Mesh "join": a Mac was learned or revoked via gossip. Reconnect to every paired Mac @@ -546,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. @@ -594,33 +609,43 @@ final class RemoteStore: ObservableObject { let conn = connection(for: host) if !conn.connectivity.isLive { conn.reconnect(to: host) } } - if activeHostID == nil || connections[activeHostID!] == nil { - activeHostID = hosts.last?.fingerprint - } - mirrorActive() + rebuildAggregate() refreshAggregate() } + /// Unpair this device entirely — drop every paired Mac and its connection (the "Unpair this + /// device" button). Nothing left to show, so the app returns to the pairing intro. func unpair() { - // Forget + drop the active Mac's connection specifically (the switcher may have made it not - // the last). Other Macs' connections keep running. - if let id = activeHostID { - connections[id]?.teardown() - connections[id] = nil - IdentityStore.removePairedHost(id: id) + for (id, conn) in connections { conn.teardown(); connections[id] = nil } + for host in IdentityStore.pairedHosts() { IdentityStore.removePairedHost(id: host.fingerprint) } + IdentityStore.clearPairedHost() + finishUnpairIfEmpty() + } + + /// Forget one paired Mac (the Settings ▸ Mesh per-row remove): drop just its connection + + /// registry record. Other Macs keep running and stay in the merged view. + func unpair(_ hostID: String) { + connections[hostID]?.teardown() + connections[hostID] = nil + IdentityStore.removePairedHost(id: hostID) + if IdentityStore.pairedHosts().isEmpty { + finishUnpairIfEmpty() } else { - IdentityStore.clearPairedHost() - } - activeHostID = nil - // Mesh P3: if other Macs remain paired, switch to one (already connected) rather than unpairing. - if let next = IdentityStore.pairedHosts().last { - activeHostID = next.fingerprint - reconnect() - return + rebuildAggregate() + refreshAggregate() } + } + + /// Wind the app back to the unpaired state once no Macs remain: clear the open transcript + flat + /// aggregate and spin the embedded Tailscale node down. + private func finishUnpairIfEmpty() { + openSessionID = nil + openSessionHostID = nil + openEvents = []; openApprovals = []; openDiff = nil; diffLoading = false connectivity = .unpaired sessions = [] + dashboard = .empty // Nothing left to dial — spin the embedded Tailscale node down if it was running. Task { await TailnetNode.shared.stop() } tailnetStatus = nil @@ -645,9 +670,8 @@ final class RemoteStore: ObservableObject { UserDefaults.standard.set(false, forKey: Self.demoModeKey) demoTasks.forEach { $0.cancel() } demoTasks.removeAll() - demoHosts = [] - activeHostID = nil openSessionID = nil + openSessionHostID = nil openEvents = [] openApprovals = [] openDiff = nil @@ -667,15 +691,19 @@ final class RemoteStore: ObservableObject { func open(_ sessionID: SessionID) { openSessionID = sessionID + // Record which Mac owns this session (mesh P3) so its connection forwards the transcript and + // the host-specific projected values follow it. + openSessionHostID = connection(owningSession: sessionID)?.hostID openEvents = [] openApprovals = [] openDiff = nil diffLoading = false markOpened(sessionID) if demoMode { seedDemoTranscript(sessionID); return } - // The open transcript belongs to the active host; tell its connection so it forwards the - // snapshot/events (and dedupes them), then subscribe. - activeConnection?.openSessionID = sessionID + // Tell the owning connection so it forwards the snapshot/events (and dedupes them), re-derive + // the context host (capabilities/scope/catalog/connectivity now follow it), then subscribe. + connection(owningSession: sessionID)?.openSessionID = sessionID + rebuildAggregate() send(.subscribe(Subscribe(sessionID: sessionID, sinceSeq: nil, verbosity: .full))) } @@ -759,16 +787,25 @@ final class RemoteStore: ObservableObject { send(.unsubscribe(target)) markOpened(target) // everything up to now has been seen guard openSessionID == target else { return } - activeConnection?.openSessionID = nil + connection(owningSession: target)?.openSessionID = nil openSessionID = nil + openSessionHostID = nil openEvents = [] openApprovals = [] openDiff = nil diffLoading = false + // Context reverts to the aggregate now that no transcript is open. + if !demoMode { rebuildAggregate() } } func respond(_ approval: ApprovalRequest, _ decision: Decision) { - send(.approvalRespond(approval.id, decision)) + // Route to the Mac that owns the approval's session (the approval message carries no + // sessionID of its own). Demo resolves locally. + if demoMode { + demoHandle(.approvalRespond(approval.id, decision)) + } else { + connection(owningSession: approval.sessionID)?.send(.approvalRespond(approval.id, decision)) + } openApprovals.removeAll { $0.id == approval.id } // optimistic dismiss; host confirms } @@ -830,12 +867,54 @@ final class RemoteStore: ObservableObject { // MARK: - Plumbing - /// Route an intent to the active host's connection (mesh P3). Every current intent — subscribe, - /// send-input, approvals, favorite/archive/delete, start-chat — targets a session or project on - /// the Mac whose list is on screen, i.e. the active one. + /// Route an intent to the Mac that owns its target (mesh P3). Sessions/projects/to-dos are shown + /// merged across every connected Mac, so an intent goes to the connection that owns the session, + /// project, or to-do it names — not to a single "active" host. Host-agnostic pulls (list + /// sessions/dashboard) broadcast to every live Mac; a project-less to-do capture (no owner) goes + /// to the first live Mac. Demo has no connections and mutates the seeded aggregate directly. private func send(_ msg: ClientMsg) { if demoMode { demoHandle(msg); return } - activeConnection?.send(msg) + switch msg { + // Session-owning intents → the Mac that has this session. + case .subscribe(let s): + connection(owningSession: s.sessionID)?.send(msg) + case .unsubscribe(let id), .interrupt(let id), .deleteSession(let id), .discard(let id), + .integrate(let id, _), .renameSession(let id, _), .setFavorite(let id, _), + .setArchived(let id, _), .setSessionModel(let id, _), .setSessionEffort(let id, _), + .setSessionAuto(let id, _), .setSessionAutoShip(let id, _), .setSessionShipBranch(let id, _), + .sendInput(let id, _), .cancelQueuedMessage(let id, _), .fetchDiff(let id): + connection(owningSession: id)?.send(msg) + + // Project-owning intents → the Mac that has this project. + case .startChat(let req): + connection(owningProject: req.projectID)?.send(msg) + case .dispatchTodo(_, let projectID): + connection(owningProject: projectID)?.send(msg) + + // To-do-owning intents → the Mac that has this to-do. + case .setTodoStatus(let id, _), .deleteTodo(let id): + connection(owningTodo: id)?.send(msg) + + // A capture with a project goes to that Mac; a project-less one has no owner → first live. + case .captureTodo(let req): + if let pid = req.projectID, let conn = connection(owningProject: pid) { conn.send(msg) } + else { firstLiveConnection?.send(msg) } + + // Approvals carry no sessionID — `respond(_:_:)` routes by the approval's session directly; + // a stray one here (or a notification-driven decision) broadcasts and the owner resolves it. + case .approvalRespond: + broadcastLive(msg) + + // Host-agnostic pulls → every live Mac. + case .listSessions, .listDashboard: + broadcastLive(msg) + + // Never originated from here (connection-internal, or handled by dedicated loops). + case .hello, .ping, .listPeers, .addressUpdate, .meshRoster, + .registerLiveActivity, .endLiveActivity, + .transferOffer, .transferChunk, .transferCommit, .transferCancel, .fetchTranscript: + break + } } // MARK: - Live Activity push registration (UX_IOS §5.3) @@ -1109,6 +1188,59 @@ extension Data { } } +extension DashboardSnapshot { + /// Merge every connected Mac's dashboard into one aggregate projection (mesh P3), so Home, + /// Projects, and To-Dos show all your Macs together: projects and to-dos concatenated, activity + /// summed per day, counts summed (active-days recomputed from the merged activity), subscription + /// usage taken from the first Mac that reports it (a user's Macs share one account, so their + /// windows match — summing would double-count), and provider status feeds unioned by provider + /// (provider status is global, not per-Mac). + static func merged(_ snaps: [DashboardSnapshot]) -> DashboardSnapshot { + guard snaps.count != 1 else { return snaps[0] } + guard !snaps.isEmpty else { return .empty } + + let projects = snaps.flatMap(\.projects) + let todos = snaps.flatMap(\.todos) + + // Activity summed per start-of-day. + var byDay: [Date: (count: Int, tokens: Int)] = [:] + for snap in snaps { + for day in snap.activity { + let key = Calendar.current.startOfDay(for: day.day) + var entry = byDay[key] ?? (0, 0) + entry.count += day.count + entry.tokens += day.tokens + byDay[key] = entry + } + } + let activity = byDay + .map { ActivityDay(day: $0.key, count: $0.value.count, tokens: $0.value.tokens) } + .sorted { $0.day < $1.day } + + let counts = DashboardCounts( + projects: projects.count, + chats: snaps.reduce(0) { $0 + $1.counts.chats }, + activeChats: snaps.reduce(0) { $0 + $1.counts.activeChats }, + messages: snaps.reduce(0) { $0 + $1.counts.messages }, + activeDays: activity.filter { $0.count > 0 || $0.tokens > 0 }.count, + tokens: snaps.reduce(0) { $0 + $1.counts.tokens }) + + let usage = snaps.compactMap(\.usage).first + + var seenProviders = Set() + var statusFeeds: [WireStatusFeed] = [] + for snap in snaps { + for feed in snap.statusFeeds where seenProviders.insert(feed.provider).inserted { + statusFeeds.append(feed) + } + } + + return DashboardSnapshot( + counts: counts, activity: activity, projects: projects, + todos: todos, usage: usage, statusFeeds: statusFeeds) + } +} + extension WireSessionSummary { /// A copy with selected fields overridden — the demo simulator's only way to "mutate" a /// summary, whose stored properties are all `let`. Nullable fields use a double optional so a diff --git a/NucleicRemote/NucleicRemote/Views/AdaptiveRootView.swift b/NucleicRemote/NucleicRemote/Views/AdaptiveRootView.swift index 8c32dd6..574c482 100644 --- a/NucleicRemote/NucleicRemote/Views/AdaptiveRootView.swift +++ b/NucleicRemote/NucleicRemote/Views/AdaptiveRootView.swift @@ -169,7 +169,7 @@ private struct SplitSidebar: View { Section("Sessions") { Text(store.connectivity.isLive ? "Start a session from Home or the Mac to see it here." - : "Waiting to connect to \(store.hostName)…") + : "Waiting to connect to your Mac…") .font(.callout).foregroundStyle(.secondary) } } else { @@ -203,7 +203,9 @@ private struct SplitSidebar: View { } } .listStyle(.sidebar) - .navigationTitle(store.hostName.isEmpty ? "Nucleic" : store.hostName) + // Every Mac's sessions are merged into this sidebar (mesh P3), so a single host name would + // misrepresent it — title it for the app. + .navigationTitle("Nucleic") .toolbar { ToolbarItem(placement: .primaryAction) { Button { diff --git a/NucleicRemote/NucleicRemote/Views/SessionsView.swift b/NucleicRemote/NucleicRemote/Views/SessionsView.swift index 38be72b..e42285a 100644 --- a/NucleicRemote/NucleicRemote/Views/SessionsView.swift +++ b/NucleicRemote/NucleicRemote/Views/SessionsView.swift @@ -28,7 +28,7 @@ struct SessionsView: View { "No sessions", systemImage: "square.stack.3d.up", description: Text(store.connectivity.isLive ? "Start a session from Home or the Mac to see it here." - : "Waiting to connect to \(store.hostName)…")) + : "Waiting to connect to your Mac…")) } else { List { ForEach(grouped, id: \.title) { group in @@ -79,27 +79,6 @@ struct SessionsView: View { // A notification tap while this tab is already up. .onChange(of: store.pendingRoute) { consumeRoute() } .toolbar { - // Host switcher (mesh P3): pick which paired Mac to view. Hidden with a single Mac. - ToolbarItem(placement: .topBarLeading) { - if store.hostChoices.count > 1 { - Menu { - ForEach(store.hostChoices) { choice in - Button { - store.switchHost(to: choice.id) - } label: { - Label(choice.name, - systemImage: choice.isActive ? "checkmark" : "desktopcomputer") - } - } - } label: { - HStack(spacing: 4) { - Image(systemName: "desktopcomputer") - Text(store.hostName).lineLimit(1) - Image(systemName: "chevron.down").font(.caption2) - } - } - } - } ToolbarItem(placement: .topBarTrailing) { Button { showArchived.toggle() @@ -172,7 +151,13 @@ struct SessionRow: View { systemImage: "arrow.down.forward") .font(.caption).foregroundStyle(.secondary).lineLimit(1) } else if showProjectName { - Text(summary.projectName).font(.caption).foregroundStyle(.secondary) + // With more than one Mac connected, tag the owning Mac so the merged list stays + // distinguishable (mesh P3); a single-Mac view just shows the project. + if store.connectedHostCount > 1, let host = store.hostName(forSession: summary.sessionID) { + Text("\(summary.projectName) · \(host)").font(.caption).foregroundStyle(.secondary) + } else { + Text(summary.projectName).font(.caption).foregroundStyle(.secondary) + } } } Spacer() diff --git a/NucleicRemote/NucleicRemote/Views/SettingsView.swift b/NucleicRemote/NucleicRemote/Views/SettingsView.swift index 920cd8f..8953b07 100644 --- a/NucleicRemote/NucleicRemote/Views/SettingsView.swift +++ b/NucleicRemote/NucleicRemote/Views/SettingsView.swift @@ -55,9 +55,6 @@ struct SettingsView: View { Section("Connection") { LabeledContent("Status", value: store.connectivity.label) - if !store.hostName.isEmpty { - LabeledContent("Mac", value: store.hostName) - } // What this pairing can dial — the live transport is in Status above. if let host = IdentityStore.loadPairedHost() { LabeledContent("Transports", value: Self.transportsLabel(host)) @@ -66,14 +63,13 @@ struct SettingsView: View { .disabled(!store.isPaired) } - // Mesh P3: every Mac this phone is paired with. Today the connection uses the - // "Active" one (most-recently paired); the multiplexer will connect to all. Removing - // the active Mac unpairs the live connection; removing another just forgets it. + // Mesh P3: every Mac this phone is paired with — all connected at once and shown + // together in the app (no active-host selector). Removing one forgets just that Mac + // and drops its connection; the others keep running. let pairedHosts = IdentityStore.pairedHosts() if !pairedHosts.isEmpty { Section("Mesh") { ForEach(pairedHosts, id: \.fingerprint) { host in - let isActive = host.fingerprint == pairedHosts.last?.fingerprint HStack(spacing: 10) { Image(systemName: "desktopcomputer").foregroundStyle(.secondary) VStack(alignment: .leading, spacing: 2) { @@ -82,15 +78,8 @@ struct SettingsView: View { .font(.footnote.monospaced()).foregroundStyle(.secondary) } Spacer() - if isActive { - Text("Active").font(.caption).foregroundStyle(.secondary) - } Button(role: .destructive) { - if isActive { - store.unpair() - } else { - IdentityStore.removePairedHost(id: host.fingerprint) - } + store.unpair(host.fingerprint) pairedHostsToken = UUID() } label: { Image(systemName: "minus.circle")