import Foundation import Network import SwiftUI import NucleicProtocol /// 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 // LAN case hostOffline case failed(String) var label: String { switch self { case .unpaired: "Not paired" case .connecting: "Connecting…" case .reconnecting: "Reconnecting…" case .connected: "Connected · LAN" case .hostOffline: "Mac offline" case .failed(let m): m } } var isLive: Bool { self == .connected } } @Published private(set) var connectivity: Connectivity = .unpaired @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 // Open session projection. @Published private(set) var openSessionID: SessionID? @Published private(set) var openEvents: [AgentEvent] = [] @Published private(set) var openApprovals: [ApprovalRequest] = [] /// The open session's full diff (the Mac Diff tab's patch), fetched on demand when the /// 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? /// 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? /// 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 } /// 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) if connectivity.isLive { 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 } pendingNotificationDecision = nil guard Date().timeIntervalSince(at) < 30 else { return } // stale — require the app 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() private var client: SyncClient? private var eventTask: Task? private var seenSeq: Set = [] private var reconnectAttempts = 0 /// 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] = [] var isPaired: Bool { demoMode || IdentityStore.loadPairedHost() != nil } var deviceFingerprint: String { identity.fingerprint } // MARK: - Lifecycle func onAppear() { if demoMode { seedDemo(); return } discovery.start() if isPaired { reconnect() } } private func seedDemo() { connectivity = .connected 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()) } sessions = [ 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 cal = Calendar.current let today = cal.startOfDay(for: Date()) let activity = (0..<40).map { i -> ActivityDay in 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) } dashboard = DashboardSnapshot( 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: []), ]) LiveActivityManager.shared.sync(hostName: hostName, sessions: sessions) } /// 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) } """) } /// Pair from a scanned QR (SYNC §4.2): connect (LAN hint first, else Bonjour), run XXpsk0, /// and on success pin the host key for future IK reconnects. func pair(with payload: PairingPayload) { teardown() connectivity = .connecting hostName = payload.hostName let deviceID = IdentityStore.deviceID() guard let endpoint = resolveEndpoint( fingerprint: payload.hostStaticKey.fingerprintHex, lanHost: payload.lanHost, lanPort: payload.lanPort) else { connectivity = .failed("No Mac found on this network"); return } let channel = makeChannel(endpoint) let client = SyncClient( channel: channel, identity: identity, hostStaticKey: payload.hostStaticKey, mode: .pair(secret: payload.pairingSecret), deviceID: deviceID, deviceLabel: UIDevice.current.name, pushToken: PushRegistrar.shared.tokenHex) self.client = client consume(client, pairingPayload: payload) } /// Reconnect to the already-paired host using IK against the pinned static key. func reconnect() { guard let host = IdentityStore.loadPairedHost() else { connectivity = .unpaired; return } teardown() connectivity = reconnectAttempts == 0 ? .connecting : .reconnecting hostName = host.hostName guard let endpoint = resolveEndpoint( fingerprint: host.fingerprint, lanHost: host.lanHost, lanPort: host.lanPort) else { connectivity = .hostOffline; scheduleRetry(); return } let channel = makeChannel(endpoint) let client = SyncClient( channel: channel, identity: identity, hostStaticKey: host.hostStaticKey, mode: .reconnect, deviceID: host.deviceID, deviceLabel: UIDevice.current.name, pushToken: PushRegistrar.shared.tokenHex) self.client = client consume(client, pairingPayload: nil) } func unpair() { teardown() IdentityStore.clearPairedHost() connectivity = .unpaired sessions = [] 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() { teardown() demoMode = true UserDefaults.standard.set(true, forKey: Self.demoModeKey) reconnectAttempts = 0 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 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 openEvents = [] openApprovals = [] openDiff = nil diffLoading = false seenSeq.removeAll() markOpened(sessionID) if demoMode { seedDemoTranscript(sessionID); return } send(.subscribe(Subscribe(sessionID: sessionID, sinceSeq: nil, verbosity: .full))) } /// 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) } 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, .usage(Usage(inputTokens: 84_300, outputTokens: 2_140, costUSD: 0.0421, contextInputTokens: 84_300))), event(15, .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)") } func closeOpen() { if let id = openSessionID { send(.unsubscribe(id)) markOpened(id) // everything up to now has been seen } openSessionID = nil openEvents = [] openApprovals = [] openDiff = nil diffLoading = false } func respond(_ approval: ApprovalRequest, _ decision: Decision) { 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 private func send(_ msg: ClientMsg) { if demoMode { demoHandle(msg); return } guard let client else { return } Task { await client.send(msg) } } // 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: break // passive / already handled by the seeded fixtures } } /// 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) } private func makeChannel(_ endpoint: NWEndpoint) -> NWFrameChannel { NWFrameChannel(endpoint: endpoint) } private func resolveEndpoint(fingerprint: String?, lanHost: String?, lanPort: UInt16?) -> NWEndpoint? { discovery.endpoint(forFingerprint: fingerprint, lanHost: lanHost, lanPort: lanPort) } private func consume(_ client: SyncClient, pairingPayload: PairingPayload?) { eventTask = Task { [weak self] in let stream = await client.start() for await event in stream { await self?.handle(event, pairingPayload: pairingPayload) } } } private func handle(_ event: SyncClient.Event, pairingPayload: PairingPayload?) async { switch event { case .connecting: break case .ready(let welcome): reconnectAttempts = 0 connectivity = .connected hostName = welcome.host.hostName capabilities = welcome.capabilities grantedScope = welcome.grantedScope modelCatalog = welcome.modelCatalog if let payload = pairingPayload, let hostKey = await client?.hostKey() { IdentityStore.savePairedHost(PairedHost( deviceID: IdentityStore.deviceID(), hostName: welcome.host.hostName, hostStaticKey: hostKey, fingerprint: hostKey.fingerprintHex, lanHost: payload.lanHost, lanPort: payload.lanPort)) } send(.listSessions) send(.listDashboard) if let id = openSessionID { send(.subscribe(Subscribe(sessionID: id, sinceSeq: nil, verbosity: .full))) } flushPendingNotificationDecision() case .sessionList(let list): sessions = list NotificationRouter.shared.updateBadge(needsYouCount) LiveActivityManager.shared.sync(hostName: hostName, sessions: sessions) case .sessionUpdated(let summary): let previous: WireSessionSummary? if let i = sessions.firstIndex(where: { $0.sessionID == summary.sessionID }) { previous = sessions[i] sessions[i] = summary } else { previous = nil sessions.append(summary) } // Notify on the transition into "waiting on you" / "finished" — only when the // app isn't foreground-active (in-app, the list's washes and badges carry it). let becameWaiting = summary.status == .awaitingInput && previous?.status != .awaitingInput if becameWaiting, !isActive, !summary.archived { NotificationRouter.shared.postSessionUpdate(summary) } NotificationRouter.shared.updateBadge(needsYouCount) LiveActivityManager.shared.sync(hostName: hostName, sessions: sessions) case .dashboard(let snapshot): dashboard = snapshot case .snapshot(let snapshot): guard snapshot.summary.sessionID == openSessionID else { break } seenSeq = Set(snapshot.recentEvents.map(\.seq)) openEvents = snapshot.recentEvents openApprovals = snapshot.pendingApprovals case .events(let batch): guard batch.sessionID == openSessionID else { break } for e in batch.events where !seenSeq.contains(e.seq) { seenSeq.insert(e.seq) openEvents.append(e) } case .approvalRequested(let req): if req.sessionID == openSessionID, !openApprovals.contains(where: { $0.id == req.id }) { openApprovals.append(req) } // Always post; the router suppresses the banner when the user is already // looking at this session, and resolution (any device) withdraws it. let title = sessions.first { $0.sessionID == req.sessionID }?.title ?? "Approval" NotificationRouter.shared.postApproval(req, sessionTitle: title) case .approvalResolved(let resolved): openApprovals.removeAll { $0.id == resolved.id } NotificationRouter.shared.withdrawApproval(resolved.id) case .sessionDiff(let diff): guard diff.sessionID == openSessionID else { break } openDiff = diff diffLoading = false case .wireError(let error): // Not fatal — surface as a transient bubble (the Mac's last-error overlay). // Losing an approval race isn't an error worth interrupting for; the card // collapses on the matching `approvalResolved`. guard error.code != .alreadyResolved else { break } showError(error.message, sessionID: error.sessionID) case .failed(let message): connectivity = .failed(message) scheduleRetry() case .closed: if connectivity == .connected { connectivity = .reconnecting } scheduleRetry() } } /// 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 } private func scheduleRetry() { guard isPaired else { return } reconnectAttempts += 1 let delay = min(Double(reconnectAttempts) * 1.5, 10) Task { [weak self] in try? await Task.sleep(for: .seconds(delay)) guard let self, self.connectivity != .connected else { return } self.reconnect() } } private func teardown() { eventTask?.cancel() eventTask = nil if let client { Task { await client.disconnect() } } client = nil } } extension Data { /// Same fingerprint scheme as `DeviceIdentity.fingerprint` (first 8 bytes of SHA-256). var fingerprintHex: String { DeviceIdentity.fingerprint(ofStaticKey: self) } } extension WireSessionSummary { /// A copy with selected fields overridden — the demo simulator's only way to "mutate" a /// summary, whose stored properties are all `let`. Nullable fields use a double optional so a /// 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()) } }