2026-07-02 04:42:59 -07:00
|
|
|
|
import ActivityKit
|
|
|
|
|
|
import Foundation
|
|
|
|
|
|
|
|
|
|
|
|
/// The one aggregate Live Activity (UX_IOS §5.3, aggregate per §11.4 — a single Activity
|
2026-07-05 00:28:21 -07:00
|
|
|
|
/// 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`.
|
2026-07-02 04:42:59 -07:00
|
|
|
|
struct NucleicSessionAttributes: ActivityAttributes {
|
2026-07-05 00:28:21 -07:00
|
|
|
|
/// 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
|
2026-07-06 14:48:38 -07:00
|
|
|
|
/// 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
|
|
|
|
|
|
}
|
2026-07-05 00:28:21 -07:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-02 04:42:59 -07:00
|
|
|
|
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
|
2026-07-05 00:28:21 -07:00
|
|
|
|
/// 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]
|
|
|
|
|
|
|
2026-07-06 12:55:08 -07:00
|
|
|
|
/// 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
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-05 00:28:21 -07:00
|
|
|
|
/// 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 }
|
2026-07-02 04:42:59 -07:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// The paired Mac's name (static for the Activity's lifetime).
|
|
|
|
|
|
var hostName: String
|
|
|
|
|
|
}
|
2026-07-05 00:28:21 -07:00
|
|
|
|
|
|
|
|
|
|
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/<id>`.
|
|
|
|
|
|
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
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|