Files
nucleic/Sources/NucleicProtocol/Sync/LiveActivity.swift
T

219 lines
10 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 Foundation
/// The content the iOS Live Activity renders (UX_IOS §5.3), in a form the **host** can build and
/// push over APNs so the glance stays fresh while the phone is locked — the phone's local update
/// path is dead then (its socket is suspended), so the Mac has to supply the new state.
///
/// This is the wire mirror of the phone's `NucleicSessionAttributes.ContentState`: the APNs
/// `content-state` JSON this encodes to MUST decode into that type. So the stored-property names
/// here are kept byte-identical to it, and `backend`/`kind` are the exact enum *raw values* the
/// phone's `Backend`/`Kind` use ("claude"/"codex"/"grok"/"other" and "running"/"provisioning"/
/// "approval"/"needsInput"/"done"/"error"/"idle"). They're plain `String`s, not enums, so this
/// module needn't import the phone's ActivityKit types. `LiveActivitySnapshotTests` pins the JSON
/// shape; keep the two in lockstep when either changes.
public struct LiveActivitySnapshot: Sendable, Codable, Equatable {
/// One agent session in the glance's detail rows.
public struct Line: Sendable, Codable, Equatable {
public let id: String
public let title: String
public let project: String
public let backend: String
public let kind: String
public let detail: String
/// The session's single most-urgent pending approval, when it's blocked on one — the id the
/// glance's inline Allow/Deny resolve. Mirrors the phone's `SessionLine.approvalID`; the JSON
/// keys MUST match so a pushed content-state decodes there. `nil` (omitted) when the session
/// isn't awaiting approval, so a plain row falls back to a deep-link tap exactly as before.
public let approvalID: String?
/// Whether that approval is high-risk — the glance then hides inline Allow (§3.3). `nil` when
/// there's no inline approval.
public let approvalIsHighRisk: Bool?
public init(
id: String, title: String, project: String,
backend: String, kind: String, 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
}
}
public let runningCount: Int
public let needsYouCount: Int
public let approvalCount: Int
public let filesChanged: Int
public let linesAdded: Int
public let linesRemoved: Int
public let lines: [Line]
public init(
runningCount: Int, needsYouCount: Int, approvalCount: Int,
filesChanged: Int, linesAdded: Int, linesRemoved: Int, lines: [Line]
) {
self.runningCount = runningCount
self.needsYouCount = needsYouCount
self.approvalCount = approvalCount
self.filesChanged = filesChanged
self.linesAdded = linesAdded
self.linesRemoved = linesRemoved
self.lines = lines
}
}
extension LiveActivitySnapshot {
/// How many detail rows the glance carries — matches the phone's `LiveActivityManager.maxLines`.
public static let maxLines = 3
/// The attention-bearing fingerprint of the glance: the headline counts plus each row's
/// identity and `kind`. It deliberately EXCLUDES the mid-turn churn (files/±lines and the
/// churn-derived row detail), which ticks on every transcript edit while the agent works.
/// Live Activity pushes are gated on this so a push fires when a session's *status* changes —
/// starts, finishes, needs approval/input — not on every diff delta mid-turn. Rapid churn
/// pushes drain the phone's battery and burn APNs's Live Activity budget (risking throttling)
/// for a number that's illegible at a glance anyway. Mirrors the phone's `ContentState`
/// equivalent; the churn still rides along whenever a status change *does* push. (When the
/// signature matches, the last-pushed glance's churn is a few edits stale — an accepted trade.)
public var attentionSignature: [String] {
var parts = ["run:\(runningCount)", "need:\(needsYouCount)", "appr:\(approvalCount)"]
parts.append(contentsOf: lines.map { "\($0.id):\($0.kind)" })
return parts
}
/// Aggregate a host's live sessions into the glance, or `nil` when nothing is running or
/// waiting (the host then pushes an "end"). This mirrors the phone's `LiveActivityManager.sync`
/// exactly — same attention-first ordering, counts, churn totals, per-row detail — so a pushed
/// update while locked reads identically to the local update the phone would have made.
public static func build(from sessions: [SessionSummary]) -> LiveActivitySnapshot? {
let live = sessions.filter { !$0.archived }
let running = live.filter { $0.status == .running || $0.status == .provisioning }
let needsYou = live.filter(sessionNeedsYou)
guard !running.isEmpty || !needsYou.isEmpty else { return nil }
let active = live
.filter { $0.status == .running || $0.status == .provisioning || sessionNeedsYou($0) }
.sorted { lhs, rhs in
let l = rank(lhs), r = rank(rhs)
return l == r ? lhs.updatedAt > rhs.updatedAt : l < r
}
return LiveActivitySnapshot(
runningCount: running.count,
needsYouCount: needsYou.count,
approvalCount: active.reduce(0) { $0 + $1.pendingApprovalCount },
filesChanged: active.reduce(0) { $0 + ($1.diffStat?.filesChanged ?? 0) },
linesAdded: active.reduce(0) { $0 + ($1.diffStat?.added ?? 0) },
linesRemoved: active.reduce(0) { $0 + ($1.diffStat?.removed ?? 0) },
lines: active.prefix(maxLines).map(line(for:)))
}
/// The terminal "all clear / Done" glance: the sessions that just finished, shown as `.done`
/// rows with the counts zeroed. `build` returns nil once nothing is running or waiting; the host
/// pushes this as an ordinary update to *hold* on an away phone's lock screen until the user opens
/// the app and sees the finished sessions (`SyncHost.performLiveActivityPush`), so a completed run
/// reads as finished and stays put rather than vanishing the instant it ends. Mirrors the phone's
/// `LiveActivityManager.finishWithDoneGlance`. Nil when nothing completed is left to show (e.g.
/// the work was discarded or archived), in which case the host ends the glance immediately.
public static func done(from sessions: [SessionSummary]) -> LiveActivitySnapshot? {
let done = sessions
.filter { !$0.archived }
.filter { $0.status == .finished || ($0.status == .awaitingInput && $0.disposition == .completed) }
.sorted { lhs, rhs in
let l = rank(lhs), r = rank(rhs)
return l == r ? lhs.updatedAt > rhs.updatedAt : l < r
}
.prefix(maxLines)
.map(line(for:))
guard !done.isEmpty else { return nil }
return LiveActivitySnapshot(
runningCount: 0, needsYouCount: 0, approvalCount: 0,
filesChanged: 0, linesAdded: 0, linesRemoved: 0, lines: Array(done))
}
/// "Needs you" vs background work — mirrors the phone's `SessionStatus.needsYou(_:)`.
private static func sessionNeedsYou(_ s: SessionSummary) -> Bool {
switch s.status {
case .awaitingApproval: return true
case .awaitingInput: return s.disposition != .completed
default: return false
}
}
/// Attention-first ordering — mirrors the phone's `StatusStyle.sortRank`.
private static func rank(_ s: SessionSummary) -> Int {
switch s.status {
case .awaitingApproval: return 0
case .awaitingInput: return s.disposition == .completed ? 4 : 1
case .running, .provisioning: return 2
case .idle: return 3
case .finished, .interrupted, .error: return 5
}
}
private static func line(for s: SessionSummary) -> Line {
// Carry the approval id/risk only for a session awaiting a *tool approval* — the row the
// glance renders inline Allow/Deny on. An `AskUserQuestion` block (pendingQuestionCount set)
// is excluded: it needs an answer selection the glance can't collect, so it deep-links to the
// app's picker card, exactly like the notification path. Other states leave it nil too.
let approvalID = (s.status == .awaitingApproval && s.pendingQuestionCount == nil)
? s.firstApprovalID?.rawValue : nil
return Line(
id: s.sessionID.rawValue,
title: s.title.isEmpty ? s.projectName : s.title,
project: s.projectName,
backend: backendTag(s.backend),
kind: kind(for: s),
detail: detail(for: s),
approvalID: approvalID,
approvalIsHighRisk: approvalID == nil ? nil : s.firstApprovalIsHighRisk)
}
private static func kind(for s: SessionSummary) -> String {
switch s.status {
case .awaitingApproval: return "approval"
case .running: return "running"
case .provisioning: return "provisioning"
case .awaitingInput: return s.disposition == .completed ? "done" : "needsInput"
case .idle: return "idle"
case .finished: return "done"
case .interrupted, .error: return "error"
}
}
private static func backendTag(_ id: BackendID) -> String {
switch id {
case .claudeCode: return "claude"
case .codex, .codexExec: return "codex"
case .grok: return "grok"
case .opencode: return "opencode"
case .hermes: return "hermes"
case .cursorAgent: return "cursor"
}
}
private static func detail(for s: SessionSummary) -> String {
if s.status == .awaitingApproval || s.pendingApprovalCount > 0 {
let n = max(s.pendingApprovalCount, 1)
return "\(n) to approve"
}
if s.status == .awaitingInput, s.disposition != .completed {
return "Waiting on you"
}
if let d = s.diffStat, d.filesChanged > 0 {
return "\(d.filesChanged) file\(d.filesChanged == 1 ? "" : "s") +\(d.added) \(d.removed)"
}
switch s.status {
case .provisioning: return "Starting…"
case .running: return "Working…"
case .awaitingInput: return "Done"
default: return s.status.displayName
}
}
}