diff --git a/NucleicRemote/NucleicRemote/Assets.xcassets/AccentColor.colorset/Contents.json b/NucleicRemote/NucleicRemote/Assets.xcassets/AccentColor.colorset/Contents.json index 8e37716..948deb1 100644 --- a/NucleicRemote/NucleicRemote/Assets.xcassets/AccentColor.colorset/Contents.json +++ b/NucleicRemote/NucleicRemote/Assets.xcassets/AccentColor.colorset/Contents.json @@ -1,4 +1,7 @@ { - "colors" : [ { "color" : { "color-space" : "srgb", "components" : { "red" : "0.118", "green" : "0.565", "blue" : "0.553", "alpha" : "1.000" } }, "idiom" : "universal" } ], + "colors" : [ + { "color" : { "color-space" : "srgb", "components" : { "red" : "0.118", "green" : "0.565", "blue" : "0.553", "alpha" : "1.000" } }, "idiom" : "universal" }, + { "appearances" : [ { "appearance" : "luminosity", "value" : "dark" } ], "color" : { "color-space" : "srgb", "components" : { "red" : "0.366", "green" : "0.850", "blue" : "0.834", "alpha" : "1.000" } }, "idiom" : "universal" } + ], "info" : { "author" : "xcode", "version" : 1 } } 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/Notifications.swift b/NucleicRemote/NucleicRemote/Notifications.swift index fa8ef92..c12a4b5 100644 --- a/NucleicRemote/NucleicRemote/Notifications.swift +++ b/NucleicRemote/NucleicRemote/Notifications.swift @@ -62,7 +62,11 @@ final class NotificationRouter: NSObject { content.title = sessionTitle content.body = "\(approval.toolName) wants: \(approval.title)" content.sound = .default - content.categoryIdentifier = approval.risk.isHigh + // AskUserQuestion collects answers, it doesn't gate — a banner Allow would reply with no + // selection (updatedInput: nil) and the CLI reports the question went unanswered. So, like + // a high-risk approval, it uses the action-less category: it must open the app and be + // answered on the picker card. + content.categoryIdentifier = approval.risk.isHigh || approval.toolName == AskUserQuestion.toolName ? Self.approvalCategory : Self.approvalActionableCategory content.userInfo = [ "sessionID": approval.sessionID.rawValue, 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/AskUserQuestionCardView.swift b/NucleicRemote/NucleicRemote/Views/AskUserQuestionCardView.swift new file mode 100644 index 0000000..18e55c5 --- /dev/null +++ b/NucleicRemote/NucleicRemote/Views/AskUserQuestionCardView.swift @@ -0,0 +1,216 @@ +import SwiftUI +import NucleicProtocol + +/// Answer UI for Claude Code's built-in `AskUserQuestion` tool — the mobile mirror of +/// the Mac's `AskUserQuestionBar`. Unlike a permission gate, this *collects* the user's +/// choice per question and folds the selections into the approval reply's `updatedInput` +/// (see `AskUserQuestion`); replying with a plain allow makes the CLI report "the user +/// did not answer the questions", which is exactly the bug the generic `ApprovalCardView` +/// produced here before. +/// +/// Questions are shown one at a time as a wizard — Back/Next between them, Submit on the +/// last — so a multi-question ask reads as a focused sequence rather than one long form. +struct AskUserQuestionCardView: View { + @EnvironmentObject var store: RemoteStore + let approval: ApprovalRequest + let questions: [AskUserQuestion.Question] + + /// Selected option labels per question text. + @State private var selected: [String: Set] = [:] + /// Whether the free-text "Other" answer is active per question text. + @State private var otherActive: [String: Bool] = [:] + /// The free-text "Other" answer per question text. + @State private var otherText: [String: String] = [:] + /// Index of the question currently shown (the wizard step). + @State private var step = 0 + + private var submitEnabled: Bool { store.connectivity.isLive } + + var body: some View { + VStack(alignment: .leading, spacing: 14) { + HStack(spacing: 6) { + Image(systemName: "questionmark.bubble.fill").foregroundStyle(Palette.attention) + Text(questions.count > 1 ? "Questions" : "Question") + .font(.subheadline.weight(.semibold)) + } + + if let question = currentQuestion { + VStack(alignment: .leading, spacing: 8) { + if questions.count > 1 { + Text("Question \(step + 1) of \(questions.count)") + .font(.caption).foregroundStyle(.secondary) + } + if let header = question.header, !header.isEmpty { + Text(header.uppercased()) + .font(.caption).bold().foregroundStyle(.secondary) + } + Text(question.question).font(.headline) + ForEach(question.options) { option in + optionRow(question, label: option.label, description: option.description) + } + otherRow(question) + } + } + + HStack(spacing: 10) { + Button(role: .destructive) { + respond(.deny(reason: nil)) + } label: { + Text("Deny").frame(maxWidth: .infinity) + } + .buttonStyle(.bordered) + .keyboardShortcut(.cancelAction) + + if step > 0 { + Button("Back") { step -= 1 } + .buttonStyle(.bordered) + } + + if isLastStep { + Button { + submit() + } label: { + Text("Submit").frame(maxWidth: .infinity) + } + .buttonStyle(.borderedProminent) + .disabled(!allAnswered || !submitEnabled) + .keyboardShortcut(.defaultAction) + } else { + Button { + step += 1 + } label: { + Text("Next").frame(maxWidth: .infinity) + } + .buttonStyle(.borderedProminent) + .disabled(!currentAnswered) + .keyboardShortcut(.defaultAction) + } + } + } + .padding() + .glassSurface(cornerRadius: 20) + } + + // MARK: - Wizard state + + /// The question for the active step, or `nil` if the index is somehow out of range. + private var currentQuestion: AskUserQuestion.Question? { + questions.indices.contains(step) ? questions[step] : nil + } + + private var isLastStep: Bool { step >= questions.count - 1 } + + /// Whether the question on the current step has at least one answer — gates `Next`. + private var currentAnswered: Bool { + guard let currentQuestion else { return false } + return !answers(for: currentQuestion).isEmpty + } + + private var allAnswered: Bool { + questions.allSatisfy { !answers(for: $0).isEmpty } + } + + // MARK: - Rows + + @ViewBuilder + private func optionRow(_ question: AskUserQuestion.Question, label: String, description: String?) + -> some View + { + let isOn = selected[question.question]?.contains(label) ?? false + Button { toggle(question, label) } label: { + HStack(alignment: .top, spacing: 8) { + Image(systemName: symbol(multiSelect: question.multiSelect, on: isOn)) + .foregroundStyle(isOn ? Palette.attention : .secondary) + VStack(alignment: .leading, spacing: 2) { + Text(label).font(.body) + .fixedSize(horizontal: false, vertical: true) + if let description, !description.isEmpty { + // Let the subtext wrap freely — inside a Button label with a trailing + // Spacer, an unconstrained Text truncates to one line and hides the rest. + Text(description).font(.subheadline).foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + } + Spacer(minLength: 0) + } + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + } + + @ViewBuilder + private func otherRow(_ question: AskUserQuestion.Question) -> some View { + let isOn = otherActive[question.question] ?? false + HStack(alignment: .center, spacing: 8) { + Button { toggleOther(question) } label: { + Image(systemName: symbol(multiSelect: question.multiSelect, on: isOn)) + .foregroundStyle(isOn ? Palette.attention : .secondary) + } + .buttonStyle(.plain) + TextField( + "Other…", + text: Binding( + get: { otherText[question.question] ?? "" }, + set: { newValue in + otherText[question.question] = newValue + if !newValue.isEmpty { activateOther(question) } + }) + ) + .textFieldStyle(.roundedBorder) + } + } + + private func symbol(multiSelect: Bool, on: Bool) -> String { + if multiSelect { return on ? "checkmark.square.fill" : "square" } + return on ? "largecircle.fill.circle" : "circle" + } + + // MARK: - Selection + + private func toggle(_ question: AskUserQuestion.Question, _ label: String) { + var set = selected[question.question] ?? [] + if question.multiSelect { + if set.contains(label) { set.remove(label) } else { set.insert(label) } + } else { + set = set.contains(label) ? [] : [label] + otherActive[question.question] = false + } + selected[question.question] = set + } + + private func toggleOther(_ question: AskUserQuestion.Question) { + let next = !(otherActive[question.question] ?? false) + otherActive[question.question] = next + if next, !question.multiSelect { selected[question.question] = [] } + } + + private func activateOther(_ question: AskUserQuestion.Question) { + otherActive[question.question] = true + if !question.multiSelect { selected[question.question] = [] } + } + + /// Chosen labels for a question: picked options plus any non-empty "Other". + private func answers(for question: AskUserQuestion.Question) -> [String] { + var labels = Array(selected[question.question] ?? []) + if otherActive[question.question] ?? false { + let custom = (otherText[question.question] ?? "") + .trimmingCharacters(in: .whitespacesAndNewlines) + if !custom.isEmpty { labels.append(custom) } + } + return labels + } + + // MARK: - Submit + + private func submit() { + var selections: [String: [String]] = [:] + for question in questions { selections[question.question] = answers(for: question) } + let updatedInput = AskUserQuestion.updatedInput( + original: approval.input, selections: selections) + respond(.allow(updatedInput: updatedInput)) + } + + private func respond(_ decision: Decision) { + store.respond(approval, decision) + } +} diff --git a/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift b/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift index f4f7752..80f3c37 100644 --- a/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift +++ b/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift @@ -221,7 +221,17 @@ struct SessionDetailView: View { private var actionArea: some View { Group { if let approval = store.openApprovals.first { - ApprovalCardView(approval: approval) + // AskUserQuestion isn't a permission gate — it collects the user's answers and + // needs them folded back into `updatedInput`. Render the picker (matching the + // Mac's `AskUserQuestionBar`) instead of a plain allow/deny card, which would + // reply with no selection and make the CLI report the question went unanswered. + if approval.toolName == AskUserQuestion.toolName, + let questions = AskUserQuestion.questions(from: approval.input) + { + AskUserQuestionCardView(approval: approval, questions: questions) + } else { + ApprovalCardView(approval: approval) + } } else if let status = summary?.status { let running = status == .running let canCompose = running || status == .awaitingInput || status.isTerminal 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") diff --git a/NucleicRemote/NucleicRemote/Views/Theme.swift b/NucleicRemote/NucleicRemote/Views/Theme.swift index 8406b5f..9da8d5b 100644 --- a/NucleicRemote/NucleicRemote/Views/Theme.swift +++ b/NucleicRemote/NucleicRemote/Views/Theme.swift @@ -142,7 +142,10 @@ struct ResolvedPalette { static func make(_ mode: ColorVisionMode) -> ResolvedPalette { var p = base(mode) - p.accent = Palette.nightSoft(p.accent) + // The accent is the interactive tint (buttons, menus, toggles, links), not a calm + // status hue — night-softening the already-deep teal made it vanish on dark + // backgrounds, so dark mode brightens it instead. + p.accent = Palette.nightBright(p.accent) p.active = Palette.nightSoft(p.active) p.attention = Palette.nightSoft(p.attention) p.success = Palette.nightSoft(p.success) @@ -194,6 +197,17 @@ enum Palette { Color(UIColor { traits in traits.userInterfaceStyle == .dark ? dark : light }) } + /// The inverse of `nightSoft`, for the interactive accent: light mode keeps `base`; dark + /// mode gets a brighter, lightly desaturated variant so the tint stays legible against + /// deep backgrounds and glass instead of sinking into them. + static func nightBright(_ base: Color) -> Color { + let ui = UIColor(base) + var h: CGFloat = 0, s: CGFloat = 0, b: CGFloat = 0, a: CGFloat = 0 + guard ui.getHue(&h, saturation: &s, brightness: &b, alpha: &a) else { return base } + let dark = UIColor(hue: h, saturation: s * 0.72, brightness: max(b, 0.85), alpha: a) + return dynamic(light: ui, dark: dark) + } + /// Light mode keeps `base`; dark mode gets a desaturated, dimmed variant — the same /// night-softening curve as the Mac's `AppPalette.nightSoft`. static func nightSoft(_ base: Color) -> Color { diff --git a/NucleicRemote/NucleicRemoteWidgets/SessionLiveActivity.swift b/NucleicRemote/NucleicRemoteWidgets/SessionLiveActivity.swift index 1d74750..deee70b 100644 --- a/NucleicRemote/NucleicRemoteWidgets/SessionLiveActivity.swift +++ b/NucleicRemote/NucleicRemoteWidgets/SessionLiveActivity.swift @@ -109,10 +109,23 @@ private struct LockScreenView: View { // MARK: - Shared pieces /// One session: state glyph + title + "project · Backend" + a right-aligned status/diff. +/// +/// Each row is its own tap target: wrapped in a `Link` to `nucleic://session/`, so tapping a +/// specific session on the lock screen or expanded Dynamic Island opens *that* session — overriding +/// the activity-wide `widgetURL` (which still governs the header, churn footer, and empty space, and +/// remains the only tap target the compact/minimal presentations can offer). private struct SessionRow: View { let line: NucleicSessionAttributes.SessionLine var body: some View { + if let url = NucleicDeepLink.session(line.id) { + Link(destination: url) { rowContent } + } else { + rowContent + } + } + + private var rowContent: some View { HStack(spacing: 8) { Image(systemName: ActivityPalette.glyph(line.kind)) .font(.footnote)