Files
nucleic-remote-ios/NucleicRemote/Shared/SessionActivityAttributes.swift
T
abkslmandNucleic 0c6841a8fb iOS Live Activity Updates
Nucleic-Session: 665980BA-E8A5-4E84-9DF4-1757CF792353
Co-authored-by: Nucleic <[email protected]>
2026-07-06 12:55:08 -07:00

141 lines
6.1 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 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/<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
}
}
}