Files
nucleic-remote-ios/NucleicRemote/Shared/SessionActivityAttributes.swift
T

128 lines
5.2 KiB
Swift
Raw Normal View History

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
}
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 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/<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
}
}
}