import ActivityKit import Foundation /// The one aggregate Live Activity (UX_IOS §5.3, aggregate per §11.4 — a single Activity /// respects iOS's budget better than one per session). It answers "what is Nucleic doing right /// now?" at a glance: the headline counts (running / waiting / approvals), the total churn in /// flight, and a few of the most urgent sessions with their state and diff. Shared between the /// app (which starts/updates it) and the widget extension (which renders it). /// /// Deliberately free of `NucleicProtocol` types — the widget extension links only this file, so /// the state carries plain value types and its own small `Backend`/`Kind` enums rather than the /// wire `BackendID`/`SessionStatus`. struct NucleicSessionAttributes: ActivityAttributes { /// The agent behind a session — drives a row's provider tag/tint. enum Backend: String, Codable, Hashable { case claude, codex, grok, other var label: String { switch self { case .claude: "Claude" case .codex: "Codex" case .grok: "Grok" case .other: "Agent" } } } /// A session's live state, collapsed to what the glance needs — drives each row's glyph/tint. enum Kind: String, Codable, Hashable { case running // agent actively working a turn case provisioning // worktree/setup spinning up case approval // blocked on a human approval decision (loudest) case needsInput // turn ended asking the user something case done // turn finished its work; nothing required case error // errored / interrupted case idle // created, not yet working } /// One agent session surfaced in the activity's detail rows. struct SessionLine: Codable, Hashable, Identifiable { var id: String /// The session's title (falls back to the project name upstream). var title: String /// Project the session belongs to. var project: String var backend: Backend var kind: Kind /// A compact right-aligned status: its diff ("3 files +42 −7"), "2 to approve", /// "Waiting on you", "Working…", etc. var detail: String /// The single most-urgent pending approval on this session, when it has one — the id the /// inline Allow/Deny buttons resolve (`ApproveFromActivityIntent`). `nil` when the session /// isn't blocked on an approval, or when the producer doesn't carry it (an older host, or a /// summary that predates the field) — in which case the row falls back to a plain deep-link /// tap, exactly as before. Optional so a host that omits it still decodes. var approvalID: String? /// Whether that approval is high-risk (destructive / network / host-exec). The surface hides /// inline Allow on a high-risk request (§3.3) — only Deny / open-the-app is offered. `nil` /// (treated as unknown → no inline Allow) when the producer doesn't carry it. var approvalIsHighRisk: Bool? init( id: String, title: String, project: String, backend: Backend, kind: Kind, detail: String, approvalID: String? = nil, approvalIsHighRisk: Bool? = nil ) { self.id = id self.title = title self.project = project self.backend = backend self.kind = kind self.detail = detail self.approvalID = approvalID self.approvalIsHighRisk = approvalIsHighRisk } } struct ContentState: Codable, Hashable { /// Sessions actively working a turn. var runningCount: Int /// Sessions blocked on the user (approval or next prompt) — the loud number. var needsYouCount: Int /// Total individual approval decisions waiting across all sessions — the "act now" count. var approvalCount: Int /// Aggregate worktree churn across the active sessions. var filesChanged: Int var linesAdded: Int var linesRemoved: Int /// The most urgent sessions, attention-first (capped, typically ≤3): the detail rows. var lines: [SessionLine] /// The attention-bearing fingerprint of the glance: the headline counts plus each row's /// identity and `kind`. Deliberately EXCLUDES the mid-turn churn (files/±lines and the /// churn-derived detail), which ticks on every transcript edit while the agent works. /// `LiveActivityManager` gates its `activity.update` on this so the glance refreshes on a /// status change — a session starting, finishing, needing approval/input — not on every diff /// delta mid-turn (which would burn ActivityKit's update budget for an illegible number). /// Mirrors the host's `LiveActivitySnapshot.attentionSignature`. var attentionSignature: [String] { var parts = ["run:\(runningCount)", "need:\(needsYouCount)", "appr:\(approvalCount)"] parts.append(contentsOf: lines.map { "\($0.id):\($0.kind.rawValue)" }) return parts } /// The single most urgent session (drives the compact/minimal regions). var top: SessionLine? { lines.first } /// Whether anything needs the user (waiting session or a pending approval). var needsAttention: Bool { needsYouCount > 0 || approvalCount > 0 } /// Whether any worktree has uncommitted changes to summarize. var hasChurn: Bool { filesChanged > 0 } } /// The paired Mac's name (static for the Activity's lifetime). var hostName: String } extension NucleicSessionAttributes.ContentState { /// Where a tap on the whole activity should go (UX_IOS §5.3): a session waiting on the user /// opens straight into that session; otherwise (work running, nothing waiting) the sessions /// list. `lines` is attention-first, so any waiting session is `lines.first` — and always in /// the capped list, since waiting outranks running in the sort. var tapURL: URL? { if let waiting = lines.first(where: { $0.kind == .approval || $0.kind == .needsInput }) { return NucleicDeepLink.session(waiting.id) } return NucleicDeepLink.sessionsList } } /// The `nucleic://` deep links the Live Activity emits and the app consumes. Kept in the shared /// file so the widget that *builds* a URL and the app that *parses* one can't drift. enum NucleicDeepLink { static let scheme = "nucleic" /// Open a specific session's detail: `nucleic://session/`. static func session(_ id: String) -> URL? { var c = URLComponents() c.scheme = scheme c.host = "session" c.path = "/\(id)" return c.url } /// Surface the sessions list, no specific session: `nucleic://sessions`. static var sessionsList: URL? { URL(string: "\(scheme)://sessions") } /// What an incoming link asks the app to do. enum Route: Equatable { case session(String) case sessionsList } static func route(for url: URL) -> Route? { guard url.scheme == scheme else { return nil } switch url.host { case "session": let id = url.path.trimmingCharacters(in: CharacterSet(charactersIn: "/")) return id.isEmpty ? nil : .session(id) case "sessions": return .sessionsList default: return nil } } }