219 lines
10 KiB
Swift
219 lines
10 KiB
Swift
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
|
||
}
|
||
}
|
||
}
|