Merge branch 'dev' into canary

This commit is contained in:
2026-07-05 03:31:02 -07:00
10 changed files with 555 additions and 187 deletions
@@ -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 } "info" : { "author" : "xcode", "version" : 1 }
} }
@@ -38,11 +38,11 @@ final class RemoteStore: ObservableObject {
} }
@Published private(set) var connectivity: Connectivity = .unpaired @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 = "" @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 sessions: [WireSessionSummary] = []
@Published private(set) var capabilities = WireCapabilities(canModifyToolInput: false, allowAlwaysScopes: []) @Published private(set) var capabilities = WireCapabilities(canModifyToolInput: false, allowAlwaysScopes: [])
@Published private(set) var grantedScope: DeviceScope = .approve @Published private(set) var grantedScope: DeviceScope = .approve
@@ -60,6 +60,10 @@ final class RemoteStore: ObservableObject {
// Open session projection. // Open session projection.
@Published private(set) var openSessionID: SessionID? @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 openEvents: [AgentEvent] = []
@Published private(set) var openApprovals: [ApprovalRequest] = [] @Published private(set) var openApprovals: [ApprovalRequest] = []
/// The open session's full diff (the Mac Diff tab's patch), fetched on demand when the /// 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 /// 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. /// instant Wi-Fi drops or returns, instead of waiting for a dead LAN socket to time out.
let pathMonitor = NetworkPathMonitor() let pathMonitor = NetworkPathMonitor()
/// One live connection per paired Mac (mesh P3 multiplexer). All connected at once; the flat /// One live connection per paired Mac (mesh P3 multiplexer). All connected at once, and the flat
/// `sessions`/`connectivity`/`capabilities`/… above mirror the *active* one (`activeHostID`), so /// `sessions`/`dashboard` above are the *merged* projection across every one of them — the phone
/// switching hosts is instant (no reconnect). Empty in demo mode (demo seeds state directly). /// 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] = [:] private var connections: [String: HostConnection] = [:]
/// The active connection — the Mac whose projection the flat state reflects + whose transcript
/// is open. /// The connection whose projection drives the host-specific flat values (capabilities, scope,
private var activeConnection: HostConnection? { activeHostID.flatMap { connections[$0] } } /// 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 /// The phone's embedded Tailscale node state, for Settings (nil = not running). Shared across all
/// connections (one embedded node, many dials). /// connections (one embedded node, many dials).
@Published private(set) var tailnetStatus: String? @Published private(set) var tailnetStatus: String?
@@ -207,75 +255,6 @@ final class RemoteStore: ObservableObject {
/// In-flight simulated-run tasks (canned streaming), cancelled on `exitDemo()`. /// In-flight simulated-run tasks (canned streaming), cancelled on `exitDemo()`.
private var demoTasks: [Task<Void, Never>] = [] private var demoTasks: [Task<Void, Never>] = []
// 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 isPaired: Bool { demoMode || IdentityStore.loadPairedHost() != nil }
var deviceFingerprint: String { identity.fingerprint } var deviceFingerprint: String { identity.fingerprint }
@@ -411,11 +390,13 @@ final class RemoteStore: ObservableObject {
fiveHour: WireUsageWindow(utilization: 18, resetsAt: Date().addingTimeInterval(2 * 3600)), fiveHour: WireUsageWindow(utilization: 18, resetsAt: Date().addingTimeInterval(2 * 3600)),
sevenDay: WireUsageWindow(utilization: 40, resetsAt: nil)), sevenDay: WireUsageWindow(utilization: 40, resetsAt: nil)),
statusFeeds: []) statusFeeds: [])
demoHosts = [ // Mesh P3: seed BOTH demo Macs' worlds merged into the flat aggregate, exactly as the app
DemoHost(id: "demo-1", name: "Andrew's Mac", sessions: host1Sessions, dashboard: host1Dashboard), // presents real paired Macs — every host's sessions/projects/to-dos shown together, no
DemoHost(id: "demo-2", name: "Studio Mac", sessions: host2Sessions, dashboard: host2Dashboard), // switcher. `demoHandle` then mutates this aggregate directly for the interactive demo.
] sessions = host1Sessions + host2Sessions
applyDemoHost(demoHosts[0]) 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. /// 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() connections[id]?.teardown()
let conn = makeConnection(hostID: id, hostName: payload.hostName) let conn = makeConnection(hostID: id, hostName: payload.hostName)
connections[id] = conn connections[id] = conn
activeHostID = id rebuildAggregate()
mirrorActive()
conn.pair(with: payload) 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 /// 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, /// so the open-transcript forwarding fires only for the Mac that owns the open session, while the
/// and the single open transcript are handled globally across hosts. /// merged list/dashboard, badges, and notifications are rebuilt across every host on any change.
private func callbacks(for hostID: String) -> HostConnection.Callbacks { private func callbacks(for hostID: String) -> HostConnection.Callbacks {
var cb = HostConnection.Callbacks() var cb = HostConnection.Callbacks()
cb.didUpdate = { [weak self] in cb.didUpdate = { [weak self] in
guard let self else { return } 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.refreshAggregate()
self.flushPendingNotificationDecision() self.flushPendingNotificationDecision()
// A host that just connected (or reconnected) needs the current Live Activity token // A host that just connected (or reconnected) needs the current Live Activity token
@@ -495,22 +476,22 @@ final class RemoteStore: ObservableObject {
self.syncLiveActivityRegistration() self.syncLiveActivityRegistration()
} }
cb.openSnapshot = { [weak self] snap in 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.openEvents = snap.recentEvents
self.openApprovals = snap.pendingApprovals self.openApprovals = snap.pendingApprovals
} }
cb.openEvents = { [weak self] batch in 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) self.openEvents.append(contentsOf: batch.events)
} }
cb.openDiff = { [weak self] diff in 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.openDiff = diff
self.diffLoading = false self.diffLoading = false
} }
cb.approvalRequested = { [weak self] req, title in cb.approvalRequested = { [weak self] req, title in
guard let self else { return } 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.contains(where: { $0.id == req.id }) {
self.openApprovals.append(req) self.openApprovals.append(req)
} }
@@ -532,7 +513,7 @@ final class RemoteStore: ObservableObject {
cb.didPair = { [weak self] host in cb.didPair = { [weak self] host in
guard let self else { return } guard let self else { return }
IdentityStore.savePairedHost(host) IdentityStore.savePairedHost(host)
self.activeHostID = host.fingerprint self.rebuildAggregate()
} }
cb.meshRosterChanged = { [weak self] in cb.meshRosterChanged = { [weak self] in
// Mesh "join": a Mac was learned or revoked via gossip. Reconnect to every paired Mac // 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 return cb
} }
/// Mirror the active connection's projection onto the flat @Published state the UI binds to. /// Re-merge every connected Mac's projection into the flat @Published state the UI binds to
private func mirrorActive() { /// (mesh P3): all hosts' sessions and dashboards shown together, with the host-specific values
guard let c = activeConnection else { return } /// (host name, capabilities, catalog, connectivity, scope) following the open session's Mac —
connectivity = c.connectivity /// or a representative one. No-op in demo, which seeds the aggregate directly.
hostName = c.hostName private func rebuildAggregate() {
sessions = c.sessions guard !demoMode else { return }
capabilities = c.capabilities sessions = aggregatedSessions()
grantedScope = c.grantedScope dashboard = DashboardSnapshot.merged(connections.values.map(\.dashboard))
modelCatalog = c.modelCatalog meshPeers = connections.values.flatMap(\.meshPeers)
dashboard = c.dashboard
meshPeers = c.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 /// The flat sessions list: every connected Mac's sessions, deduped by id (ids are globally
/// Live Activity summarize across hosts. /// unique). Views handle sorting/grouping.
private var allLiveSessions: [WireSessionSummary] { private func aggregatedSessions() -> [WireSessionSummary] {
if demoMode { return demoHosts.flatMap(\.sessions).filter { !$0.archived } } var seen = Set<SessionID>()
return connections.values.flatMap(\.sessions).filter { !$0.archived } 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 /// 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() { private func refreshAggregate() {
let needs = allLiveSessions.filter { $0.status.needsYou($0.disposition) }.count NotificationRouter.shared.updateBadge(needsYouCount)
NotificationRouter.shared.updateBadge(needs) LiveActivityManager.shared.sync(hostName: hostName, sessions: liveSessions)
LiveActivityManager.shared.sync(hostName: hostName, sessions: allLiveSessions)
} }
/// Tear down every live connection (leaving the paired registry intact) — for entering demo. /// 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) let conn = connection(for: host)
if !conn.connectivity.isLive { conn.reconnect(to: host) } if !conn.connectivity.isLive { conn.reconnect(to: host) }
} }
if activeHostID == nil || connections[activeHostID!] == nil { rebuildAggregate()
activeHostID = hosts.last?.fingerprint
}
mirrorActive()
refreshAggregate() 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() { func unpair() {
// Forget + drop the active Mac's connection specifically (the switcher may have made it not for (id, conn) in connections { conn.teardown(); connections[id] = nil }
// the last). Other Macs' connections keep running. for host in IdentityStore.pairedHosts() { IdentityStore.removePairedHost(id: host.fingerprint) }
if let id = activeHostID { IdentityStore.clearPairedHost()
connections[id]?.teardown() finishUnpairIfEmpty()
connections[id] = nil }
IdentityStore.removePairedHost(id: id)
/// 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 { } else {
IdentityStore.clearPairedHost() rebuildAggregate()
} refreshAggregate()
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
} }
}
/// 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 connectivity = .unpaired
sessions = [] sessions = []
dashboard = .empty
// Nothing left to dial — spin the embedded Tailscale node down if it was running. // Nothing left to dial — spin the embedded Tailscale node down if it was running.
Task { await TailnetNode.shared.stop() } Task { await TailnetNode.shared.stop() }
tailnetStatus = nil tailnetStatus = nil
@@ -645,9 +670,8 @@ final class RemoteStore: ObservableObject {
UserDefaults.standard.set(false, forKey: Self.demoModeKey) UserDefaults.standard.set(false, forKey: Self.demoModeKey)
demoTasks.forEach { $0.cancel() } demoTasks.forEach { $0.cancel() }
demoTasks.removeAll() demoTasks.removeAll()
demoHosts = []
activeHostID = nil
openSessionID = nil openSessionID = nil
openSessionHostID = nil
openEvents = [] openEvents = []
openApprovals = [] openApprovals = []
openDiff = nil openDiff = nil
@@ -667,15 +691,19 @@ final class RemoteStore: ObservableObject {
func open(_ sessionID: SessionID) { func open(_ sessionID: SessionID) {
openSessionID = 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 = [] openEvents = []
openApprovals = [] openApprovals = []
openDiff = nil openDiff = nil
diffLoading = false diffLoading = false
markOpened(sessionID) markOpened(sessionID)
if demoMode { seedDemoTranscript(sessionID); return } if demoMode { seedDemoTranscript(sessionID); return }
// The open transcript belongs to the active host; tell its connection so it forwards the // Tell the owning connection so it forwards the snapshot/events (and dedupes them), re-derive
// snapshot/events (and dedupes them), then subscribe. // the context host (capabilities/scope/catalog/connectivity now follow it), then subscribe.
activeConnection?.openSessionID = sessionID connection(owningSession: sessionID)?.openSessionID = sessionID
rebuildAggregate()
send(.subscribe(Subscribe(sessionID: sessionID, sinceSeq: nil, verbosity: .full))) send(.subscribe(Subscribe(sessionID: sessionID, sinceSeq: nil, verbosity: .full)))
} }
@@ -759,16 +787,25 @@ final class RemoteStore: ObservableObject {
send(.unsubscribe(target)) send(.unsubscribe(target))
markOpened(target) // everything up to now has been seen markOpened(target) // everything up to now has been seen
guard openSessionID == target else { return } guard openSessionID == target else { return }
activeConnection?.openSessionID = nil connection(owningSession: target)?.openSessionID = nil
openSessionID = nil openSessionID = nil
openSessionHostID = nil
openEvents = [] openEvents = []
openApprovals = [] openApprovals = []
openDiff = nil openDiff = nil
diffLoading = false diffLoading = false
// Context reverts to the aggregate now that no transcript is open.
if !demoMode { rebuildAggregate() }
} }
func respond(_ approval: ApprovalRequest, _ decision: Decision) { 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 openApprovals.removeAll { $0.id == approval.id } // optimistic dismiss; host confirms
} }
@@ -830,12 +867,54 @@ final class RemoteStore: ObservableObject {
// MARK: - Plumbing // MARK: - Plumbing
/// Route an intent to the active host's connection (mesh P3). Every current intent — subscribe, /// Route an intent to the Mac that owns its target (mesh P3). Sessions/projects/to-dos are shown
/// send-input, approvals, favorite/archive/delete, start-chat — targets a session or project on /// merged across every connected Mac, so an intent goes to the connection that owns the session,
/// the Mac whose list is on screen, i.e. the active one. /// 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) { private func send(_ msg: ClientMsg) {
if demoMode { demoHandle(msg); return } 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) // 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<String>()
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 { extension WireSessionSummary {
/// A copy with selected fields overridden — the demo simulator's only way to "mutate" a /// 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 /// summary, whose stored properties are all `let`. Nullable fields use a double optional so a
@@ -62,7 +62,11 @@ final class NotificationRouter: NSObject {
content.title = sessionTitle content.title = sessionTitle
content.body = "\(approval.toolName) wants: \(approval.title)" content.body = "\(approval.toolName) wants: \(approval.title)"
content.sound = .default 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 ? Self.approvalCategory : Self.approvalActionableCategory
content.userInfo = [ content.userInfo = [
"sessionID": approval.sessionID.rawValue, "sessionID": approval.sessionID.rawValue,
@@ -169,7 +169,7 @@ private struct SplitSidebar: View {
Section("Sessions") { Section("Sessions") {
Text(store.connectivity.isLive Text(store.connectivity.isLive
? "Start a session from Home or the Mac to see it here." ? "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) .font(.callout).foregroundStyle(.secondary)
} }
} else { } else {
@@ -203,7 +203,9 @@ private struct SplitSidebar: View {
} }
} }
.listStyle(.sidebar) .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 { .toolbar {
ToolbarItem(placement: .primaryAction) { ToolbarItem(placement: .primaryAction) {
Button { Button {
@@ -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<String>] = [:]
/// 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)
}
}
@@ -221,7 +221,17 @@ struct SessionDetailView: View {
private var actionArea: some View { private var actionArea: some View {
Group { Group {
if let approval = store.openApprovals.first { 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 { } else if let status = summary?.status {
let running = status == .running let running = status == .running
let canCompose = running || status == .awaitingInput || status.isTerminal let canCompose = running || status == .awaitingInput || status.isTerminal
@@ -28,7 +28,7 @@ struct SessionsView: View {
"No sessions", systemImage: "square.stack.3d.up", "No sessions", systemImage: "square.stack.3d.up",
description: Text(store.connectivity.isLive description: Text(store.connectivity.isLive
? "Start a session from Home or the Mac to see it here." ? "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 { } else {
List { List {
ForEach(grouped, id: \.title) { group in ForEach(grouped, id: \.title) { group in
@@ -79,27 +79,6 @@ struct SessionsView: View {
// A notification tap while this tab is already up. // A notification tap while this tab is already up.
.onChange(of: store.pendingRoute) { consumeRoute() } .onChange(of: store.pendingRoute) { consumeRoute() }
.toolbar { .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) { ToolbarItem(placement: .topBarTrailing) {
Button { Button {
showArchived.toggle() showArchived.toggle()
@@ -172,7 +151,13 @@ struct SessionRow: View {
systemImage: "arrow.down.forward") systemImage: "arrow.down.forward")
.font(.caption).foregroundStyle(.secondary).lineLimit(1) .font(.caption).foregroundStyle(.secondary).lineLimit(1)
} else if showProjectName { } 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() Spacer()
@@ -55,9 +55,6 @@ struct SettingsView: View {
Section("Connection") { Section("Connection") {
LabeledContent("Status", value: store.connectivity.label) 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. // What this pairing can dial — the live transport is in Status above.
if let host = IdentityStore.loadPairedHost() { if let host = IdentityStore.loadPairedHost() {
LabeledContent("Transports", value: Self.transportsLabel(host)) LabeledContent("Transports", value: Self.transportsLabel(host))
@@ -66,14 +63,13 @@ struct SettingsView: View {
.disabled(!store.isPaired) .disabled(!store.isPaired)
} }
// Mesh P3: every Mac this phone is paired with. Today the connection uses the // Mesh P3: every Mac this phone is paired with — all connected at once and shown
// "Active" one (most-recently paired); the multiplexer will connect to all. Removing // together in the app (no active-host selector). Removing one forgets just that Mac
// the active Mac unpairs the live connection; removing another just forgets it. // and drops its connection; the others keep running.
let pairedHosts = IdentityStore.pairedHosts() let pairedHosts = IdentityStore.pairedHosts()
if !pairedHosts.isEmpty { if !pairedHosts.isEmpty {
Section("Mesh") { Section("Mesh") {
ForEach(pairedHosts, id: \.fingerprint) { host in ForEach(pairedHosts, id: \.fingerprint) { host in
let isActive = host.fingerprint == pairedHosts.last?.fingerprint
HStack(spacing: 10) { HStack(spacing: 10) {
Image(systemName: "desktopcomputer").foregroundStyle(.secondary) Image(systemName: "desktopcomputer").foregroundStyle(.secondary)
VStack(alignment: .leading, spacing: 2) { VStack(alignment: .leading, spacing: 2) {
@@ -82,15 +78,8 @@ struct SettingsView: View {
.font(.footnote.monospaced()).foregroundStyle(.secondary) .font(.footnote.monospaced()).foregroundStyle(.secondary)
} }
Spacer() Spacer()
if isActive {
Text("Active").font(.caption).foregroundStyle(.secondary)
}
Button(role: .destructive) { Button(role: .destructive) {
if isActive { store.unpair(host.fingerprint)
store.unpair()
} else {
IdentityStore.removePairedHost(id: host.fingerprint)
}
pairedHostsToken = UUID() pairedHostsToken = UUID()
} label: { } label: {
Image(systemName: "minus.circle") Image(systemName: "minus.circle")
+15 -1
View File
@@ -142,7 +142,10 @@ struct ResolvedPalette {
static func make(_ mode: ColorVisionMode) -> ResolvedPalette { static func make(_ mode: ColorVisionMode) -> ResolvedPalette {
var p = base(mode) 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.active = Palette.nightSoft(p.active)
p.attention = Palette.nightSoft(p.attention) p.attention = Palette.nightSoft(p.attention)
p.success = Palette.nightSoft(p.success) p.success = Palette.nightSoft(p.success)
@@ -194,6 +197,17 @@ enum Palette {
Color(UIColor { traits in traits.userInterfaceStyle == .dark ? dark : light }) 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 /// Light mode keeps `base`; dark mode gets a desaturated, dimmed variant — the same
/// night-softening curve as the Mac's `AppPalette.nightSoft`. /// night-softening curve as the Mac's `AppPalette.nightSoft`.
static func nightSoft(_ base: Color) -> Color { static func nightSoft(_ base: Color) -> Color {
@@ -109,10 +109,23 @@ private struct LockScreenView: View {
// MARK: - Shared pieces // MARK: - Shared pieces
/// One session: state glyph + title + "project · Backend" + a right-aligned status/diff. /// 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/<id>`, 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 { private struct SessionRow: View {
let line: NucleicSessionAttributes.SessionLine let line: NucleicSessionAttributes.SessionLine
var body: some View { 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) { HStack(spacing: 8) {
Image(systemName: ActivityPalette.glyph(line.kind)) Image(systemName: ActivityPalette.glyph(line.kind))
.font(.footnote) .font(.footnote)