Files
nucleic-remote-ios/NucleicRemote/NucleicRemote/Models/RemoteStore.swift
T

1324 lines
71 KiB
Swift
Raw Normal View History

import Foundation
import Network
import SwiftUI
import NucleicProtocol
import NucleicTailnet
/// On the phone there's no app-side `SessionSummary` view-model to collide with, so the wire
/// type *is* the model. Alias it under the host's name so the shared vocabulary reads the same.
typealias WireSessionSummary = NucleicProtocol.SessionSummary
/// The phone's single source of UI state — a pure projection of the host (UX_IOS §1.2). Owns
/// the `SyncClient`, reflects connectivity truth, and exposes the session list + the open
/// session's transcript/approvals. No canonical state is invented on-device.
@MainActor
final class RemoteStore: ObservableObject {
enum Connectivity: Equatable {
case unpaired
case connecting
case reconnecting
case connected(SyncTransportHint)
case hostOffline
case failed(String)
var label: String {
switch self {
case .unpaired: "Not paired"
case .connecting: "Connecting…"
case .reconnecting: "Reconnecting…"
case .connected(let transport): "Connected · \(transport.label)"
case .hostOffline: "Mac offline"
case .failed(let m): m
}
}
var isLive: Bool {
if case .connected = self { return true }
return false
}
}
@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 sessions: [WireSessionSummary] = []
@Published private(set) var capabilities = WireCapabilities(canModifyToolInput: false, allowAlwaysScopes: [])
@Published private(set) var grantedScope: DeviceScope = .approve
/// The host's model/effort catalog (SYNC §5.2), driving the composer + session-header pickers.
/// `.empty` until `Welcome` arrives; the pickers fall back to the built-in effort list.
@Published private(set) var modelCatalog: WireModelCatalog = .empty
/// Home / Projects / To-Dos state — the dashboard projection.
@Published private(set) var dashboard = DashboardSnapshot.empty
/// The host's mesh peers (mesh P4), from `HostMsg.peerList`. Populated only when the host
/// advertises `capabilities.canListPeers` and the client asks; drives the future mesh device
/// list and session-transfer destination picker.
@Published private(set) var meshPeers: [PeerSummary] = []
// 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] = []
2026-07-05 21:51:39 -07:00
/// Whether the compact (iPhone) Sessions tab currently has a session detail pushed. Distinct
/// from `openSessionID`, which is the *data* subscription and is only torn down on the detail's
/// `onDisappear` — and SwiftUI fires that at the *end* of the pop transition, so keying the
/// floating tab bar's visibility off it left the bar sliding back up 1–2s after a back-swipe.
/// `SessionsView` sets this straight from its navigation path, which flips the instant the pop
/// begins, so the bar reflows immediately while the transcript state survives the animation.
@Published var compactDetailPresented = false
/// The open session's full diff (the Mac Diff tab's patch), fetched on demand when the
/// user opens the Diff tab and the host advertises `canFetchDiff`. Nil until it arrives.
@Published private(set) var openDiff: WireSessionDiff?
@Published private(set) var diffLoading = false
/// A transient host-reported error (the mobile echo of the Mac's last-error bubble):
/// shown as a red bubble at the bottom of the screen, auto-dismissed after a few seconds.
struct LastError: Equatable, Identifiable {
let id = UUID()
let message: String
let sessionID: SessionID?
}
@Published var lastError: LastError?
private var errorDismissTask: Task<Void, Never>?
/// When each session was last opened on this device, for the green "finished while you
/// weren't looking" wash on the session list (the Mac's unseen-completion marker; the wire
/// doesn't carry the host's flag, so the phone tracks its own view locally).
@Published private(set) var lastOpenedAt: [SessionID: Date] = RemoteStore.loadLastOpened()
/// A navigation request from outside the view hierarchy (notification tap → this session).
/// `RootView` switches to the Sessions tab; `SessionsView` pushes it and clears.
@Published var pendingRoute: SessionID?
/// A request to surface the sessions list itself, no specific session — the Live Activity's
/// "work running, nothing waiting" tap. Compact jumps to the Sessions tab; the iPad split
/// reveals its sidebar (which always lists sessions). One-shot: the shell clears it.
@Published var pendingSessionsList = false
/// Whether the app is foreground-active (scene phase), mirrored here so the store can
/// decide which transitions deserve a notification.
private(set) var isActive = true
func setScenePhaseActive(_ active: Bool) { isActive = active }
/// Route to a session from a notification tap (deep link).
func route(to sessionID: SessionID) { pendingRoute = sessionID }
/// Ask the shell to show the sessions list (no specific session opened).
func showSessionsList() { pendingSessionsList = true }
/// Follow a `nucleic://` deep link — the Live Activity tap. A session waiting on the user
/// opens straight into that session; the plain sessions link just surfaces the list.
func handleDeepLink(_ url: URL) {
switch NucleicDeepLink.route(for: url) {
case .session(let id): route(to: SessionID(rawValue: id))
case .sessionsList: showSessionsList()
case .none: break
}
}
/// An Allow/Deny straight from a notification action (UX_IOS §5.1). Requires a live
/// channel; if the socket dropped while backgrounded, reconnect and send once ready —
/// but only briefly (a stale queued approval must never fire minutes later; see
/// UX_IOS §11.5, and the host dedupes/`alreadyResolved`s a lost race anyway).
func respondFromNotification(_ id: ApprovalID, allow: Bool) {
let decision: Decision = allow ? .allow(updatedInput: nil) : .deny(reason: nil)
// The approval may belong to any connected Mac (mesh P3) and the notification carries no
// hostID — broadcast to every live connection; the owning host resolves it, the rest see an
// unknown/`alreadyResolved` approval and no-op.
let live = connections.values.filter { $0.connectivity.isLive }
if !live.isEmpty {
for conn in live { conn.send(.approvalRespond(id, decision)) }
} else {
pendingNotificationDecision = (id, decision, Date())
reconnect()
}
}
/// At most one decision waits for reconnect, and it expires after 30s.
private var pendingNotificationDecision: (ApprovalID, Decision, Date)?
private func flushPendingNotificationDecision() {
guard let (id, decision, at) = pendingNotificationDecision else { return }
let live = connections.values.filter { $0.connectivity.isLive }
guard !live.isEmpty else { return } // wait until something's connected
pendingNotificationDecision = nil
guard Date().timeIntervalSince(at) < 30 else { return } // stale — require the app
for conn in live { conn.send(.approvalRespond(id, decision)) }
}
private static let lastOpenedKey = "nucleic.lastOpenedAt"
private static func loadLastOpened() -> [SessionID: Date] {
guard let raw = UserDefaults.standard.dictionary(forKey: lastOpenedKey) else { return [:] }
return raw.reduce(into: [:]) { result, entry in
if let date = entry.value as? Date { result[SessionID(rawValue: entry.key)] = date }
}
}
private func persistLastOpened() {
let raw = lastOpenedAt.reduce(into: [String: Date]()) { $0[$1.key.rawValue] = $1.value }
UserDefaults.standard.set(raw, forKey: Self.lastOpenedKey)
}
/// Whether `summary` completed its work after the user last looked at it on this device.
func unseenCompletion(_ summary: WireSessionSummary) -> Bool {
let done = summary.status == .finished
|| (summary.status == .awaitingInput && summary.disposition == .completed)
guard done, summary.sessionID != openSessionID else { return false }
guard let opened = lastOpenedAt[summary.sessionID] else { return true }
return summary.updatedAt > opened
}
/// Non-archived sessions (archived chats are hidden, matching the Mac sidebar).
var liveSessions: [WireSessionSummary] { sessions.filter { !$0.archived } }
/// Sessions needing a human (drives the app-icon badge + NEEDS YOU section). Uses turn
/// disposition so a finished-the-work session doesn't count, and excludes archived.
var needsYouCount: Int {
liveSessions.filter { $0.status.needsYou($0.disposition) }.count
}
var canControl: Bool { grantedScope >= .control }
private let identity = IdentityStore.loadOrCreateIdentity()
let discovery = LANDiscovery()
/// 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, 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 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?
/// The Tailscale interactive-login URL while the node waits for a browser login (a
/// first tailnet start with no auth key). Auto-opened; Settings shows a re-open button.
@Published private(set) var tailnetLoginURL: URL?
/// Offline demo mode: seeds mock state and simulates the agent locally so every surface
/// renders — and the core loops (send, approve, start chat, to-dos) actually respond —
/// without a paired Mac. Reachable in two ways: the `NUCLEIC_DEMO=1` env var (dev /
/// screenshots, forced on at launch) and a persisted in-app toggle
/// (`enterDemo()` / `exitDemo()`) so an App Store reviewer with no Mac can exercise the app
/// (App Review Guideline 2.1). `@Published` so the UI can show a DEMO indicator and the
/// "Leave demo" affordance.
private static let demoModeKey = "nucleic.demoMode"
@Published private(set) var demoMode = ProcessInfo.processInfo.environment["NUCLEIC_DEMO"] == "1"
|| UserDefaults.standard.bool(forKey: RemoteStore.demoModeKey)
/// In-flight simulated-run tasks (canned streaming), cancelled on `exitDemo()`.
private var demoTasks: [Task<Void, Never>] = []
var isPaired: Bool { demoMode || IdentityStore.loadPairedHost() != nil }
var deviceFingerprint: String { identity.fingerprint }
// MARK: - Lifecycle
func onAppear() {
setupLiveActivityBridge()
if demoMode { seedDemo(); return }
// Re-plan every connection the moment the network path changes (left Wi-Fi, joined a
// network, cellular⇄Wi-Fi) — the proactive half of "immediate switchover".
pathMonitor.onChange = { [weak self] in self?.handlePathChange() }
pathMonitor.start()
discovery.start()
if isPaired { reconnect() }
}
/// A network-path change: tell each host connection to re-plan its transport now.
private func handlePathChange() {
guard !demoMode else { return }
for conn in connections.values { conn.networkPathChanged() }
}
/// Returning to the foreground: the network may have changed while the app was suspended (and
/// path/keepalive callbacks were frozen). Re-read the path and re-dial anything not live so the
/// transport is correct by the time the UI is on screen, rather than after a backoff.
func onForeground() {
guard !demoMode, isPaired else { return }
pathMonitor.refresh()
reconnect()
}
private func seedDemo() {
connectivity = .connected(.lan)
hostName = "Andrew's Mac"
grantedScope = .control
capabilities = WireCapabilities(
canModifyToolInput: true, allowAlwaysScopes: [.session, .toolName], canFetchDiff: true)
modelCatalog = WireModelCatalog(
groups: [
[WireModelCatalog.Model(sku: "claude-opus-4-8[1m]", displayName: "Opus 4.8", backend: .claudeCode,
contextBadge: "1M", contextWindow: 1_000_000,
efforts: ["low", "medium", "high", "xhigh", "max"], effortNoun: "Effort"),
WireModelCatalog.Model(sku: "claude-sonnet-4-6", displayName: "Sonnet 4.6", backend: .claudeCode,
contextBadge: nil, contextWindow: 200_000,
efforts: ["low", "medium", "high", "xhigh", "max"], effortNoun: "Effort")],
[WireModelCatalog.Model(sku: "gpt-5.5", displayName: "GPT-5.5", backend: .codex,
contextBadge: nil, contextWindow: 350_000,
efforts: ["low", "medium", "high", "xhigh"], effortNoun: "Reasoning")],
[WireModelCatalog.Model(sku: "grok-build", displayName: "Grok Build", backend: .grok,
contextBadge: nil, contextWindow: 256_000,
efforts: ["auto"], effortNoun: "Reasoning")],
],
effortDisplayNames: ["auto": "Auto", "orchestra": "Orchestra"],
orchestraSentinel: "orchestra", orchestraRequiresControlNote: "Requires Nucleic Control",
fallbackModel: "claude-opus-4-8[1m]", fallbackEffort: "high")
let p1 = ProjectID(rawValue: "p1"), p2 = ProjectID(rawValue: "p2")
func sum(_ id: String, _ project: ProjectID, _ name: String, _ title: String,
_ status: SessionStatus, _ disp: TurnDisposition? = nil, approvals: Int = 0,
fav: Bool = false, arch: Bool = false, add: Int = 0, rem: Int = 0) -> WireSessionSummary {
WireSessionSummary(
sessionID: SessionID(rawValue: id), projectID: project.rawValue, projectName: name,
backend: .claudeCode, status: status, disposition: disp, title: title,
branch: "nucleic/\(id)", lastSeq: 10,
diffStat: add + rem > 0 ? DiffStat(filesChanged: 2, added: add, removed: rem) : nil,
pendingApprovalCount: approvals, favorite: fav, archived: arch,
updatedAt: Date())
}
let cal = Calendar.current
let today = cal.startOfDay(for: Date())
let activity = (0..<40).map { i -> ActivityDay in
2026-06-27 19:15:37 -07:00
let count = (i * 7) % 6
// Sample tokens roughly track messages so the preview grid shades by usage.
return ActivityDay(day: cal.date(byAdding: .day, value: -i, to: today)!,
count: count, tokens: count * 8_500 + (i * 137) % 4_000)
}
// Host 1 — "Andrew's Mac".
let host1Sessions = [
sum("a1", p1, "nucleic", "auth-refactor", .awaitingApproval, approvals: 1, add: 312, rem: 40),
sum("a2", p1, "nucleic", "flaky-tests", .running, add: 88, rem: 12),
sum("a3", p2, "website", "graphql-migration", .awaitingInput, .awaitingInput, fav: true),
sum("a4", p2, "website", "docs-pass", .awaitingInput, .completed, add: 20, rem: 4),
sum("a5", p1, "nucleic", "old-experiment", .finished, arch: true),
]
let host1Dashboard = DashboardSnapshot(
2026-06-27 19:15:37 -07:00
counts: DashboardCounts(
projects: 2, chats: 5, activeChats: 2, messages: 142, activeDays: 9, tokens: 1_284_000),
activity: activity,
projects: [
WireProject(id: p1, name: "nucleic", defaultBranch: "main", sessionCount: 3, activeCount: 2),
WireProject(id: p2, name: "website", defaultBranch: "main", sessionCount: 2, activeCount: 1),
],
todos: [
WireTodo(id: TodoID(rawValue: "t1"), text: "Add dark mode to settings", summary: "Dark mode in settings",
projectID: p2, projectName: "website", status: .open, dispatchedSessionID: nil,
triage: "high", updatedAt: Date()),
WireTodo(id: TodoID(rawValue: "t2"), text: "Investigate the memory leak in the sync server", summary: "Sync server memory leak",
projectID: p1, projectName: "nucleic", status: .open, dispatchedSessionID: nil,
triage: "critical", updatedAt: Date()),
WireTodo(id: TodoID(rawValue: "t3"), text: "Write release notes", summary: nil,
projectID: nil, projectName: nil, status: .open, dispatchedSessionID: nil,
triage: "low", updatedAt: Date()),
],
usage: WireSubscriptionUsage(
fiveHour: WireUsageWindow(utilization: 42, resetsAt: Date().addingTimeInterval(3 * 3600)),
sevenDay: WireUsageWindow(utilization: 78, resetsAt: Date().addingTimeInterval(2.4 * 86_400))),
statusFeeds: [
WireStatusFeed(provider: "claude", providerName: "Claude", incidents: []),
WireStatusFeed(provider: "openai", providerName: "OpenAI", incidents: [
WireStatusIncident(
id: "i1", title: "Elevated errors on Codex", url: URL(string: "https://status.openai.com"),
updatedAt: Date(), state: "Monitoring", isResolved: false, components: ["Codex"]),
]),
WireStatusFeed(provider: "xai", providerName: "xAI", incidents: []),
])
// Host 2 — "Studio Mac" (mesh P3: a second paired Mac, for the host switcher).
let p3 = ProjectID(rawValue: "p3")
let host2Sessions = [
sum("b1", p3, "renderer", "shadow-mapping", .running, add: 140, rem: 22),
sum("b2", p3, "renderer", "gpu-profiling", .awaitingApproval, approvals: 1),
sum("b3", p1, "nucleic", "cloud-runtime", .awaitingInput, .completed, add: 60, rem: 8),
]
let host2Dashboard = DashboardSnapshot(
counts: DashboardCounts(
projects: 1, chats: 3, activeChats: 2, messages: 61, activeDays: 5, tokens: 540_000),
activity: activity,
projects: [WireProject(id: p3, name: "renderer", defaultBranch: "main", sessionCount: 2, activeCount: 2)],
todos: [
WireTodo(id: TodoID(rawValue: "t4"), text: "Bake the light probes overnight", summary: "Bake light probes",
projectID: p3, projectName: "renderer", status: .open, dispatchedSessionID: nil,
triage: "medium", updatedAt: Date()),
],
usage: WireSubscriptionUsage(
fiveHour: WireUsageWindow(utilization: 18, resetsAt: Date().addingTimeInterval(2 * 3600)),
sevenDay: WireUsageWindow(utilization: 40, resetsAt: nil)),
statusFeeds: [])
// 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.
private func demoDiff(_ sessionID: SessionID) -> WireSessionDiff {
WireSessionDiff(
sessionID: sessionID,
stat: DiffStat(filesChanged: 2, added: 312, removed: 40),
files: [
WireFileDiff(path: "auth/middleware.ts", oldPath: nil, status: "modified", added: 290, removed: 38),
WireFileDiff(path: "auth/session.ts", oldPath: nil, status: "added", added: 22, removed: 2),
],
patch: """
diff --git a/auth/middleware.ts b/auth/middleware.ts
--- a/auth/middleware.ts
+++ b/auth/middleware.ts
@@ -10,7 +10,9 @@ export function requireSession(req: Request) {
- const token = req.headers.get("x-auth")
+ const header = req.headers.get("authorization") ?? ""
+ const token = header.replace(/^Bearer /, "")
+ if (!token) throw new AuthError("missing bearer token")
return verify(token)
}
diff --git a/auth/session.ts b/auth/session.ts
new file mode 100644
--- /dev/null
+++ b/auth/session.ts
@@ -0,0 +1,6 @@
+export interface Session {
+ userId: string
+ issuedAt: number
+}
+
+export const SESSION_TTL = 3600
""")
}
// MARK: - Connections (mesh P3 multiplexer)
/// Pair a newly-scanned Mac, make it the active host, and start its connection — while any
/// existing connections keep running.
func pair(with payload: PairingPayload) {
let id = payload.hostStaticKey.fingerprintHex
connections[id]?.teardown()
let conn = makeConnection(hostID: id, hostName: payload.hostName)
connections[id] = conn
rebuildAggregate()
conn.pair(with: payload)
}
/// Get or build the connection for a paired Mac.
private func connection(for host: PairedHost) -> HostConnection {
if let existing = connections[host.fingerprint] { return existing }
let conn = makeConnection(hostID: host.fingerprint, hostName: host.hostName)
connections[host.fingerprint] = conn
return conn
}
private func makeConnection(hostID: String, hostName: String) -> HostConnection {
HostConnection(
hostID: hostID, hostName: hostName, identity: identity, discovery: discovery,
pathMonitor: pathMonitor, callbacks: callbacks(for: hostID))
}
/// The callbacks one `HostConnection` uses to drive shared/aggregate state. `hostID` is captured
/// 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 }
// 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
// so it can push while the phone is away.
self.syncLiveActivityRegistration()
}
cb.openSnapshot = { [weak self] snap in
guard let self, hostID == self.openSessionHostID, snap.summary.sessionID == self.openSessionID else { return }
// Merge, don't replace: a fresh open merges into an empty transcript (the tail window),
// while a reconnect's warm-resubscribe delta appends to the history already on screen
// instead of truncating it to the host's 200-event tail.
self.openEvents = Self.mergedEvents(self.openEvents, snap.recentEvents)
self.openApprovals = snap.pendingApprovals
}
cb.openEvents = { [weak self] batch in
guard let self, hostID == self.openSessionHostID, batch.sessionID == self.openSessionID else { return }
self.openEvents.append(contentsOf: batch.events)
}
2026-07-05 19:27:51 -07:00
cb.openBackfill = { [weak self] batch in
guard let self, hostID == self.openSessionHostID, batch.sessionID == self.openSessionID else { return }
// Full-history backfill precedes what's on screen — merge by seq so it slots in above the
// tail rather than appending out of order.
self.openEvents = Self.mergedEvents(self.openEvents, batch.events)
}
cb.openDiff = { [weak self] diff in
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.openSessionHostID, req.sessionID == self.openSessionID,
!self.openApprovals.contains(where: { $0.id == req.id }) {
self.openApprovals.append(req)
}
// Post for any host; the router suppresses the banner if the user is on this session.
NotificationRouter.shared.postApproval(req, sessionTitle: title)
}
cb.approvalResolved = { [weak self] resolved in
guard let self else { return }
self.openApprovals.removeAll { $0.id == resolved.id }
NotificationRouter.shared.withdrawApproval(resolved.id)
}
cb.sessionBecameWaiting = { [weak self] summary in
guard let self, !self.isActive else { return }
NotificationRouter.shared.postSessionUpdate(summary)
}
cb.wireError = { [weak self] error in
self?.showError(error.message, sessionID: error.sessionID)
}
cb.didPair = { [weak self] host in
guard let self else { return }
IdentityStore.savePairedHost(host)
self.rebuildAggregate()
}
cb.meshRosterChanged = { [weak self] in
// Mesh "join": a Mac was learned or revoked via gossip. Reconnect to every paired Mac
// (connecting the newcomer) and drop any that left — without switching the active host.
self?.reconnect()
}
cb.tailnetStatus = { [weak self] status, loginURL in
self?.tailnetStatus = status
self?.tailnetLoginURL = loginURL
}
return cb
}
/// 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
}
}
/// Merge transcript events by `seq` (monotonic, globally unique within a session), keeping the
/// union sorted. Lets a reconnect's snapshot fold its events into the transcript already on
/// screen without duplicating what's shown or dropping history outside the host's tail window.
/// A fresh open merges into `[]`, so it's just the tail — the same result as a plain replace.
private static func mergedEvents(_ existing: [AgentEvent], _ incoming: [AgentEvent]) -> [AgentEvent] {
guard !existing.isEmpty else { return incoming }
guard !incoming.isEmpty else { return existing }
var bySeq: [UInt64: AgentEvent] = [:]
bySeq.reserveCapacity(existing.count + incoming.count)
for e in existing { bySeq[e.seq] = e }
for e in incoming { bySeq[e.seq] = e }
return bySeq.values.sorted { $0.seq < $1.seq }
}
/// 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<SessionID>()
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. `sessions`/`liveSessions` are already the merged aggregate.
private func refreshAggregate() {
NotificationRouter.shared.updateBadge(needsYouCount)
LiveActivityManager.shared.sync(hostName: hostName, sessions: liveSessions)
}
/// Tear down every live connection (leaving the paired registry intact) — for entering demo.
private func teardownAll() {
for conn in connections.values { conn.teardown() }
connections.removeAll()
}
/// Connect to every paired Mac at once (mesh P3 multiplexer): each `HostConnection` runs its own
/// IK reconnect (LAN→tailnet, with backoff), so all your Macs are live simultaneously and the
/// switcher flips between them instantly. Idempotent — an already-live connection is left alone;
/// an offline one (re)dials. Connections for since-unpaired Macs are dropped. The launch +
/// "Reconnect" path.
func reconnect() {
let hosts = IdentityStore.pairedHosts()
guard !hosts.isEmpty else { connectivity = .unpaired; return }
let paired = Set(hosts.map(\.fingerprint))
for (id, conn) in connections where !paired.contains(id) { conn.teardown(); connections[id] = nil }
for host in hosts {
let conn = connection(for: host)
if !conn.connectivity.isLive { conn.reconnect(to: host) }
}
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() {
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 {
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
2026-07-05 21:51:42 -07:00
compactDetailPresented = false
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
LiveActivityManager.shared.end()
NotificationRouter.shared.updateBadge(0)
}
/// Enter the in-app demo (the "Explore a demo" button): drop any live connection, persist the
/// flag so it survives relaunch (a reviewer may relaunch), and seed the mock world. `isPaired`
/// then returns true, so `RootView` shows the full TabView.
func enterDemo() {
teardownAll()
demoMode = true
UserDefaults.standard.set(true, forKey: Self.demoModeKey)
seedDemo()
}
/// Leave the demo (Settings ▸ Leave demo): cancel any simulated runs, clear the mock world,
/// and return to the real state — reconnect if a Mac is actually paired, else the pairing intro.
func exitDemo() {
demoMode = false
UserDefaults.standard.set(false, forKey: Self.demoModeKey)
demoTasks.forEach { $0.cancel() }
demoTasks.removeAll()
openSessionID = nil
2026-07-05 21:51:51 -07:00
compactDetailPresented = false
openSessionHostID = nil
openEvents = []
openApprovals = []
openDiff = nil
diffLoading = false
sessions = []
dashboard = .empty
LiveActivityManager.shared.end()
NotificationRouter.shared.updateBadge(0)
if IdentityStore.loadPairedHost() != nil {
reconnect()
} else {
connectivity = .unpaired
}
}
// MARK: - Intents (UX_IOS §9)
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 }
// 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)))
2026-07-05 19:28:00 -07:00
// Pull the full history too — the subscribe above only returns a 200-event tail, so without
// this the transcript would start at the connection point with no events from before it.
connection(owningSession: sessionID)?.fetchFullTranscript(sessionID)
}
/// Ask the host for the open session's full patch (Diff tab). No-op when the host
/// doesn't advertise the capability — the view falls back to the diffstat summary.
func fetchDiff(_ sessionID: SessionID) {
if demoMode { openDiff = demoDiff(sessionID); return }
guard capabilities.canFetchDiff else { return }
diffLoading = openDiff == nil
send(.fetchDiff(sessionID))
}
/// Record that the user looked at this session now (clears its unseen-completion wash).
func markOpened(_ sessionID: SessionID) {
lastOpenedAt[sessionID] = Date()
persistLastOpened()
}
/// Offline transcript fixture (NUCLEIC_DEMO) so the richer transcript surfaces — grouped
/// tools, Orchestra card, usage/cost, file changes, run outcome — render without a host.
private func seedDemoTranscript(_ sessionID: SessionID) {
func event(_ seq: UInt64, _ kind: AgentEvent.Kind) -> AgentEvent {
AgentEvent(sessionID: sessionID, seq: seq, at: Date(), backend: .claudeCode,
nativeType: nil, kind: kind)
}
// A realistic `git commit` (heredoc message) so the transcript's structured commit card
// is exercisable offline: expand the Bash call to see the subject + Markdown body.
let demoCommitCommand = "git commit -F - <<'EOF'\nfix: harden auth middleware\n\nRequire a Bearer token and reject a missing or blank one.\n\n- extract `requireSession`\n- add a `Bearer` prefix check\nEOF"
// A multi-step, destructive shell pipeline so the transcript's step list (with the delete
// flagged in red) is exercisable offline: expand the Bash call to see the breakdown.
let demoCleanupCommand = "cd ~/code/nucleic && rm -rf .worktrees/auth-old && git worktree prune && git branch -D nucleic/auth-old"
openEvents = [
event(1, .sessionStarted(SessionStarted(
backendSessionID: "demo", model: "claude-opus-4-8[1m]", cwd: "~/code/nucleic", toolNames: []))),
event(2, .userText(TextChunk(messageID: "u1", text: "Refactor the auth middleware and run the tests.", isPartial: false))),
event(3, .assistantText(TextChunk(messageID: "a1", text: "I'll update the auth middleware, then run the suite.\n\n**Plan:**\n- extract `requireSession`\n- add a `Bearer` check", isPartial: false))),
event(4, .toolCallStarted(ToolCall(toolCallID: "t1", name: "Edit", input: ["file_path": "auth/middleware.ts"]))),
event(5, .toolCallCompleted(ToolCall(toolCallID: "t1", name: "Edit", input: ["file_path": "auth/middleware.ts"]))),
event(6, .fileChange(FileChange(path: "auth/middleware.ts", kind: .update, toolCallID: "t1"))),
event(7, .toolResult(ToolResult(toolCallID: "t1", content: "Applied 2 edits to auth/middleware.ts", isError: false))),
event(8, .toolCallStarted(ToolCall(toolCallID: "t2", name: "Bash", input: ["command": "npm test"]))),
event(9, .toolCallCompleted(ToolCall(toolCallID: "t2", name: "Bash", input: ["command": "npm test"]))),
event(10, .toolResult(ToolResult(toolCallID: "t2", content: "42 passing\n0 failing", isError: false))),
event(11, .toolCallStarted(ToolCall(toolCallID: "t3", name: "Task", input: ["description": "Audit other call sites", "prompt": "Find every caller of the old auth API."]))),
event(12, .toolCallCompleted(ToolCall(toolCallID: "t3", name: "Task", input: ["description": "Audit other call sites"]))),
event(13, .toolResult(ToolResult(toolCallID: "t3", content: "Checked 7 files; 1 stale caller updated.", isError: false))),
event(14, .toolCallStarted(ToolCall(toolCallID: "t4", name: "Bash", input: ["command": .string(demoCommitCommand)]))),
event(15, .toolCallCompleted(ToolCall(toolCallID: "t4", name: "Bash", input: ["command": .string(demoCommitCommand)]))),
event(16, .toolResult(ToolResult(toolCallID: "t4", content: "[nucleic/auth-refactor 1a2b3c4] fix: harden auth middleware\n 2 files changed, 312 insertions(+), 40 deletions(-)", isError: false))),
event(17, .toolCallStarted(ToolCall(toolCallID: "t5", name: "Bash", input: ["command": .string(demoCleanupCommand)]))),
event(18, .toolCallCompleted(ToolCall(toolCallID: "t5", name: "Bash", input: ["command": .string(demoCleanupCommand)]))),
event(19, .toolResult(ToolResult(toolCallID: "t5", content: "Removed 1 worktree; deleted branch nucleic/auth-old.", isError: false))),
event(20, .usage(Usage(inputTokens: 84_300, outputTokens: 2_140, costUSD: 0.0421, contextInputTokens: 84_300))),
event(21, .runFinished(RunFinished(outcome: .completed, finalText: "Done."))),
]
// If this session is blocked on a human, surface a real approval card so the
// Allow/Deny loop is exercisable in the demo (the seeded `a1` session).
if sessions.first(where: { $0.sessionID == sessionID })?.status == .awaitingApproval {
openApprovals = [ApprovalRequest(
id: Self.demoApprovalID(for: sessionID),
sessionID: sessionID, toolCallID: "t-appr", toolName: "Bash",
input: ["command": "npm run deploy"], title: "Run npm run deploy",
risk: .execute, createdAt: Date())]
}
}
/// Deterministic approval id for a demo session's seeded approval, so re-opening the same
/// session doesn't stack duplicate cards.
private static func demoApprovalID(for sessionID: SessionID) -> ApprovalID {
ApprovalID(rawValue: "demo-appr-\(sessionID.rawValue)")
}
/// Close a session's live subscription. `id` names *which* session is closing — the detail
/// view passes its own. On iPad's split view, switching session A→B can mount B (which calls
/// `open(B)`, setting `openSessionID = B`) *before* A's detail disappears; so we always
/// unsubscribe the named session but only tear down the shared open-state when it still
/// belongs to that session — otherwise we'd wipe B's freshly-loaded transcript. Called with
/// no argument it closes whatever is currently open (the iPhone push/pop path, unchanged).
func closeOpen(_ id: SessionID? = nil) {
guard let target = id ?? openSessionID else { return }
send(.unsubscribe(target))
markOpened(target) // everything up to now has been seen
guard openSessionID == target else { return }
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) {
// 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
}
func sendInput(_ text: String, to sessionID: SessionID) {
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return }
send(.sendInput(sessionID, AgentInput(text: trimmed)))
}
func refreshSessions() { send(.listSessions); send(.listDashboard) }
// MARK: - Control intents (control scope; the same actions the Mac can take)
func startChat(in projectID: ProjectID, message: String, model: String? = nil,
effort: String? = nil, baseBranch: String? = nil,
useWorktree: Bool = true, auto: Bool? = nil) {
let text = message.trimmingCharacters(in: .whitespacesAndNewlines)
guard !text.isEmpty else { return }
send(.startChat(StartChatRequest(
projectID: projectID, message: text, model: model, effort: effort,
baseBranch: baseBranch, useWorktree: useWorktree, auto: auto)))
}
func captureTodo(_ text: String, projectID: ProjectID?) {
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return }
send(.captureTodo(CaptureTodoRequest(text: trimmed, projectID: projectID)))
}
func dispatchTodo(_ id: TodoID, in projectID: ProjectID) { send(.dispatchTodo(id, projectID)) }
func completeTodo(_ id: TodoID) { send(.setTodoStatus(id, .done)) }
func deleteTodo(_ id: TodoID) { send(.deleteTodo(id)) }
func renameSession(_ id: SessionID, to title: String) {
let trimmed = title.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return }
send(.renameSession(id, trimmed))
}
func setFavorite(_ id: SessionID, _ favorite: Bool) { send(.setFavorite(id, favorite)) }
func setArchived(_ id: SessionID, _ archived: Bool) { send(.setArchived(id, archived)) }
func deleteSession(_ id: SessionID) {
send(.deleteSession(id))
if id == openSessionID { closeOpen() }
}
func integrate(_ id: SessionID, _ mode: IntegrationMode) { send(.integrate(id, mode)) }
/// Throw away the session's branch/worktree without landing it (the Mac's Discard…).
func discard(_ id: SessionID) { send(.discard(id)) }
func interrupt(_ id: SessionID) { send(.interrupt(id)) }
/// Cancel one queued (not-yet-sent) follow-up by id — the phone's per-message ✕.
func cancelQueuedMessage(_ id: SessionID, _ messageID: UUID) { send(.cancelQueuedMessage(id, messageID)) }
// Mid-session model / reasoning / automation — the same affordances the Mac header exposes.
// `nil` model/effort resets the session to the host/app default.
func setSessionModel(_ id: SessionID, _ model: String?) { send(.setSessionModel(id, model)) }
func setSessionEffort(_ id: SessionID, _ effort: String?) { send(.setSessionEffort(id, effort)) }
func setSessionAuto(_ id: SessionID, _ auto: Bool) { send(.setSessionAuto(id, auto)) }
func setSessionAutoShip(_ id: SessionID, _ autoShip: Bool) { send(.setSessionAutoShip(id, autoShip)) }
func setSessionShipBranch(_ id: SessionID, _ branch: String?) { send(.setSessionShipBranch(id, branch)) }
// MARK: - Plumbing
/// 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 }
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)
/// The current Live Activity's APNS update token + id, shipped to every capable Mac so it can
/// refresh the lock-screen glance over APNs while the phone is backgrounded and its socket is
/// suspended. Sent to *all* connected hosts (not just the active one) — while the phone is
/// away, whichever Mac has work to report should be able to push.
private var liveActivityReg: (token: String, activityID: String)?
/// Host ids that already have the current token (re-sent to a host that (re)connects, and
/// re-sent to everyone when the token rotates).
private var liveActivitySentTo: Set<String> = []
private func setupLiveActivityBridge() {
LiveActivityManager.shared.onPushToken = { [weak self] token, activityID in
guard let self, self.liveActivityReg?.token != token else { return }
self.liveActivityReg = (token, activityID)
self.liveActivitySentTo.removeAll() // a fresh token must reach every host again
self.syncLiveActivityRegistration()
}
LiveActivityManager.shared.onActivityEnded = { [weak self] activityID in
guard let self else { return }
self.liveActivityReg = nil
self.liveActivitySentTo.removeAll()
for conn in self.connections.values where conn.capabilities.canPushLiveActivity {
conn.send(.endLiveActivity(activityID))
}
}
}
/// Send the current Live Activity token to every live, capable host that hasn't got it yet.
/// Called when the token changes and on every connection update, so a host that just connected
/// — or reconnected while the phone was away — learns the token it needs to push.
private func syncLiveActivityRegistration() {
guard let reg = liveActivityReg else { return }
// A host that dropped should re-register when it returns.
liveActivitySentTo = liveActivitySentTo.filter { connections[$0]?.connectivity.isLive == true }
for (id, conn) in connections {
guard conn.connectivity.isLive, conn.capabilities.canPushLiveActivity,
!liveActivitySentTo.contains(id) else { continue }
conn.send(.registerLiveActivity(reg.token, reg.activityID))
liveActivitySentTo.insert(id)
}
}
// MARK: - Demo simulator (offline, interactive)
//
// In demo mode there's no host, so writes can't go over the wire. Instead they mutate the
// already-`@Published` projection directly and (for the agent loop) stream a short canned run,
// so a reviewer can send messages, approve actions, start chats, and manage to-dos and see the
// UI respond — the read-only fixtures (`seedDemo`/`seedDemoTranscript`/`demoDiff`) already
// cover the passive surfaces.
private func demoHandle(_ msg: ClientMsg) {
switch msg {
case .sendInput(let id, let input):
demoRun(id, userText: input.plainText ?? "")
case .startChat(let req):
demoStartChat(req)
case .approvalRespond(let id, let decision):
demoResolveApproval(id, decision)
case .captureTodo(let req):
demoCaptureTodo(req)
case .setTodoStatus(let id, let status):
demoSetTodoStatus(id, status)
case .deleteTodo(let id):
demoMutateTodos { $0.filter { $0.id != id } }
case .dispatchTodo(let id, _):
demoSetTodoStatus(id, .dispatched)
case .renameSession(let id, let title):
demoUpdateSession(id) { $0.demoCopy(title: title) }
case .setFavorite(let id, let favorite):
demoUpdateSession(id) { $0.demoCopy(favorite: favorite) }
case .setArchived(let id, let archived):
demoUpdateSession(id) { $0.demoCopy(archived: archived) }
case .deleteSession(let id):
sessions.removeAll { $0.sessionID == id }
NotificationRouter.shared.updateBadge(needsYouCount)
LiveActivityManager.shared.sync(hostName: hostName, sessions: sessions)
case .discard(let id):
sessions.removeAll { $0.sessionID == id }
NotificationRouter.shared.updateBadge(needsYouCount)
case .interrupt(let id):
demoUpdateSession(id) { $0.demoCopy(status: .interrupted, disposition: .some(nil)) }
case .integrate(let id, _):
demoAppend(id, .note(NoteEvent(text: "nvrsion: Landed to trunk.", icon: "arrow.triangle.branch")))
demoUpdateSession(id) { $0.demoCopy(status: .finished, disposition: .some(.completed)) }
case .setSessionModel(let id, let model):
demoUpdateSession(id) { $0.demoCopy(model: .some(model)) }
case .setSessionEffort(let id, let effort):
demoUpdateSession(id) { $0.demoCopy(effort: .some(effort)) }
case .setSessionAuto(let id, let auto):
demoUpdateSession(id) { $0.demoCopy(auto: auto) }
case .setSessionAutoShip(let id, let autoShip):
demoUpdateSession(id) { $0.demoCopy(autoShip: autoShip) }
case .setSessionShipBranch(let id, let branch):
demoUpdateSession(id) { $0.demoCopy(shipBranch: .some(branch)) }
case .hello, .listSessions, .listDashboard, .subscribe, .unsubscribe,
.ping, .cancelQueuedMessage, .fetchDiff, .fetchTranscript, .listPeers,
.addressUpdate, .meshRoster,
// Live Activity push registration is a real-connection concern (there's no host to
// push in demo), so it's inert here.
.registerLiveActivity, .endLiveActivity,
// Session transfer (mesh P5) is a Mac↔Mac flow — the phone never originates these,
// and demo has no peer Macs, so they're inert here.
.transferOffer, .transferChunk, .transferCommit, .transferCancel:
break // passive / already handled by the seeded fixtures (demo has no mesh peers)
}
}
/// Append a transcript event to the open session (no-op if it isn't the one on screen).
private func demoAppend(_ sessionID: SessionID, _ kind: AgentEvent.Kind) {
guard sessionID == openSessionID else { return }
let seq = (openEvents.map(\.seq).max() ?? 0) + 1
let backend = sessions.first { $0.sessionID == sessionID }?.backend ?? .claudeCode
openEvents.append(AgentEvent(
sessionID: sessionID, seq: seq, at: Date(), backend: backend, nativeType: nil, kind: kind))
}
/// Replace a session summary in the list, keeping the badge / Live Activity in sync.
private func demoUpdateSession(_ id: SessionID, _ transform: (WireSessionSummary) -> WireSessionSummary) {
guard let i = sessions.firstIndex(where: { $0.sessionID == id }) else { return }
sessions[i] = transform(sessions[i])
NotificationRouter.shared.updateBadge(needsYouCount)
LiveActivityManager.shared.sync(hostName: hostName, sessions: sessions)
}
/// Stream a short, believable canned turn for a session: assistant prose, a tool call +
/// result, usage, and a finish — flipping the summary running → awaiting-input. Each step
/// re-checks demo mode and cancellation so `exitDemo()` stops it cleanly. Runs entirely on the
/// main actor (the store is `@MainActor`, and a `Task` in an isolated method inherits it).
private func demoRun(_ sessionID: SessionID, userText: String?) {
if let userText, !userText.isEmpty {
demoAppend(sessionID, .userText(TextChunk(messageID: UUID().uuidString, text: userText, isPartial: false)))
}
demoUpdateSession(sessionID) { $0.demoCopy(status: .running, disposition: .some(nil)) }
let toolID = UUID().uuidString
let task = Task { [weak self] in
guard let self else { return }
@MainActor func pause(_ ms: Int) async -> Bool {
try? await Task.sleep(for: .milliseconds(ms))
if Task.isCancelled { return false }
return self.demoMode
}
guard await pause(600) else { return }
self.demoAppend(sessionID, .assistantText(TextChunk(
messageID: UUID().uuidString, text: "On it — let me take a look.", isPartial: false)))
guard await pause(700) else { return }
self.demoAppend(sessionID, .toolCallStarted(ToolCall(
toolCallID: toolID, name: "Bash", input: ["command": "npm test"])))
guard await pause(900) else { return }
self.demoAppend(sessionID, .toolResult(ToolResult(
toolCallID: toolID, content: "42 passing\n0 failing", isError: false)))
guard await pause(700) else { return }
self.demoAppend(sessionID, .assistantText(TextChunk(
messageID: UUID().uuidString,
text: "All green — tests pass and the change is in place. Anything else?",
isPartial: false)))
self.demoAppend(sessionID, .usage(Usage(
inputTokens: 12_400, outputTokens: 640, costUSD: 0.0088, contextInputTokens: 12_400)))
self.demoAppend(sessionID, .runFinished(RunFinished(outcome: .completed, finalText: "Done.")))
self.demoUpdateSession(sessionID) {
$0.demoCopy(status: .awaitingInput, disposition: .some(.completed))
}
}
demoTasks.append(task)
}
private func demoStartChat(_ req: StartChatRequest) {
let project = dashboard.projects.first { $0.id == req.projectID }
let id = SessionID(rawValue: "demo-\(UUID().uuidString.prefix(8))")
let title = String(req.message.prefix(48))
let summary = WireSessionSummary(
sessionID: id, projectID: req.projectID.rawValue,
projectName: project?.name ?? "project", backend: BackendID.forModel(req.model) ?? .claudeCode,
status: .running, disposition: nil, title: title.isEmpty ? "New chat" : title,
branch: "nucleic/\(id.rawValue)", lastSeq: 0, diffStat: nil,
pendingApprovalCount: 0, favorite: false, archived: false,
model: req.model, effort: req.effort, auto: req.auto ?? false,
updatedAt: Date())
sessions.insert(summary, at: 0)
LiveActivityManager.shared.sync(hostName: hostName, sessions: sessions)
demoRun(id, userText: req.message)
}
private func demoResolveApproval(_ id: ApprovalID, _ decision: Decision) {
openApprovals.removeAll { $0.id == id }
NotificationRouter.shared.withdrawApproval(id)
guard let sessionID = openSessionID else { return }
switch decision {
case .deny, .cancelRun:
demoAppend(sessionID, .note(NoteEvent(text: "You denied the command.", icon: "hand.raised")))
demoUpdateSession(sessionID) {
$0.demoCopy(status: .awaitingInput, disposition: .some(.awaitingInput), pendingApprovalCount: 0)
}
case .allow, .allowAlways:
demoAppend(sessionID, .toolResult(ToolResult(
toolCallID: "t-appr", content: "Deployed successfully.", isError: false)))
demoUpdateSession(sessionID) { $0.demoCopy(pendingApprovalCount: 0) }
demoRun(sessionID, userText: nil) // wrap up the turn after the approved tool runs
}
}
private func demoCaptureTodo(_ req: CaptureTodoRequest) {
let project = req.projectID.flatMap { pid in dashboard.projects.first { $0.id == pid } }
let todo = WireTodo(
id: .generate(), text: req.text, summary: nil, projectID: req.projectID,
projectName: project?.name, status: .open, dispatchedSessionID: nil,
triage: nil, updatedAt: Date())
demoMutateTodos { [todo] + $0 }
}
private func demoSetTodoStatus(_ id: TodoID, _ status: TodoStatus) {
demoMutateTodos { todos in
todos.map { todo in
todo.id == id
? WireTodo(id: todo.id, text: todo.text, summary: todo.summary,
projectID: todo.projectID, projectName: todo.projectName,
status: status, dispatchedSessionID: todo.dispatchedSessionID,
triage: todo.triage, updatedAt: Date())
: todo
}
}
}
/// Rebuild the dashboard with a transformed to-do list (its fields are `let`).
private func demoMutateTodos(_ transform: ([WireTodo]) -> [WireTodo]) {
dashboard = DashboardSnapshot(
counts: dashboard.counts, activity: dashboard.activity, projects: dashboard.projects,
todos: transform(dashboard.todos), usage: dashboard.usage, statusFeeds: dashboard.statusFeeds)
}
/// Turn a raw `NWError` string into a short, fixable hint. The common onboarding failures are
/// a denied Local Network permission (EPERM / -65555) and the Mac not listening (refused).
static func friendlyTransportError(_ error: String) -> String {
let lower = error.lowercased()
if lower.contains("denied") || lower.contains("not permitted") || lower.contains("65555") {
return "Can't reach the local network. In Settings ▸ Nucleic, allow Local Network access, then try again."
}
if lower.contains("refused") {
return "Your Mac refused the connection. Check that remote access is still on in Nucleic ▸ Settings."
}
return "Couldn't connect to your Mac — make sure it's on the same Wi‑Fi and remote access is on."
}
/// Show a transient error bubble, replacing any current one; auto-dismisses after 6s
/// (matching the Mac's last-error overlay cadence).
private func showError(_ message: String, sessionID: SessionID?) {
let error = LastError(message: message, sessionID: sessionID)
lastError = error
errorDismissTask?.cancel()
errorDismissTask = Task { [weak self] in
try? await Task.sleep(for: .seconds(6))
guard let self, self.lastError == error else { return }
self.lastError = nil
}
}
func dismissError() {
errorDismissTask?.cancel()
lastError = nil
}
}
extension Data {
/// Same fingerprint scheme as `DeviceIdentity.fingerprint` (first 8 bytes of SHA-256).
var fingerprintHex: String {
DeviceIdentity.fingerprint(ofStaticKey: self)
}
}
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 {
/// Recency key for ordering session lists: the last *user* message, falling back to `updatedAt`
/// for sessions with no user message yet (or summaries from a host that predates the field).
/// Ordering on this keeps a chat's position steady until the user speaks again, rather than
/// reshuffling the list on every bit of agent activity (which bumps `updatedAt` every few
/// seconds). Mirrors the Mac sidebar's last-turn ordering.
var lastTurnAt: Date { lastUserMessageAt ?? updatedAt }
/// 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
/// caller can distinguish "keep" (omit) from "set to nil" (`.some(nil)`). `updatedAt` bumps
/// to now unless given. Only the fields the simulator touches are exposed.
func demoCopy(
status: SessionStatus? = nil,
disposition: TurnDisposition?? = nil,
title: String? = nil,
favorite: Bool? = nil,
archived: Bool? = nil,
pendingApprovalCount: Int? = nil,
diffStat: DiffStat?? = nil,
model: String?? = nil,
effort: String?? = nil,
auto: Bool? = nil,
autoShip: Bool? = nil,
shipBranch: String?? = nil,
updatedAt: Date? = nil
) -> WireSessionSummary {
WireSessionSummary(
sessionID: sessionID, projectID: projectID, projectName: projectName, backend: backend,
status: status ?? self.status,
disposition: disposition ?? self.disposition,
title: title ?? self.title, branch: branch, lastSeq: lastSeq,
diffStat: diffStat ?? self.diffStat,
pendingApprovalCount: pendingApprovalCount ?? self.pendingApprovalCount,
favorite: favorite ?? self.favorite,
archived: archived ?? self.archived,
queuedMessage: queuedMessage, queuedMessages: queuedMessages,
model: model ?? self.model, effort: effort ?? self.effort,
auto: auto ?? self.auto, autoShip: autoShip ?? self.autoShip,
shipBranch: shipBranch ?? self.shipBranch,
contextInputTokens: contextInputTokens,
updatedAt: updatedAt ?? Date(),
movedTo: movedTo, arrivedFrom: arrivedFrom)
}
}