Live Activity Content Density

Nucleic-Session: B3643133-163C-4C5D-BE20-1A6B7FB83DA9
Co-authored-by: Nucleic <[email protected]>
This commit is contained in:
2026-07-04 23:30:15 -07:00
co-authored by Nucleic
parent 0dda487167
commit a45795184e
@@ -11,6 +11,10 @@ final class LiveActivityManager {
static let shared = LiveActivityManager() static let shared = LiveActivityManager()
private init() {} private init() {}
/// How many sessions the detail rows show. The glance stays a glance — the counts and churn
/// still summarize everything, this just bounds the per-session list.
private static let maxLines = 3
private var activity: Activity<NucleicSessionAttributes>? private var activity: Activity<NucleicSessionAttributes>?
/// Reconcile the Activity with the current session set. /// Reconcile the Activity with the current session set.
@@ -25,14 +29,33 @@ final class LiveActivityManager {
return return
} }
// The most urgent session headlines: the top waiter, else the freshest runner. // Everything in flight or waiting on the user, attention-first (approvals, then waiting
let top = needsYou.max { $0.updatedAt < $1.updatedAt } // input, then running), freshest within a rank. This is both the detail-row source and
?? running.max { $0.updatedAt < $1.updatedAt } // the set the aggregate churn/approval totals sum over.
let active = live
.filter {
$0.status == .running || $0.status == .provisioning
|| $0.status.needsYou($0.disposition)
}
.sorted { lhs, rhs in
let l = StatusStyle.sortRank(lhs), r = StatusStyle.sortRank(rhs)
return l == r ? lhs.updatedAt > rhs.updatedAt : l < r
}
let approvals = active.reduce(0) { $0 + $1.pendingApprovalCount }
let files = active.reduce(0) { $0 + ($1.diffStat?.filesChanged ?? 0) }
let added = active.reduce(0) { $0 + ($1.diffStat?.added ?? 0) }
let removed = active.reduce(0) { $0 + ($1.diffStat?.removed ?? 0) }
let lines = active.prefix(Self.maxLines).map(Self.line(for:))
let state = NucleicSessionAttributes.ContentState( let state = NucleicSessionAttributes.ContentState(
runningCount: running.count, runningCount: running.count,
needsYouCount: needsYou.count, needsYouCount: needsYou.count,
topTitle: top?.title, approvalCount: approvals,
topNeedsYou: top.map { $0.status.needsYou($0.disposition) } ?? false) filesChanged: files,
linesAdded: added,
linesRemoved: removed,
lines: Array(lines))
if let activity { if let activity {
Task { await activity.update(ActivityContent(state: state, staleDate: nil)) } Task { await activity.update(ActivityContent(state: state, staleDate: nil)) }
@@ -59,4 +82,58 @@ final class LiveActivityManager {
dismissalPolicy: .immediate) dismissalPolicy: .immediate)
} }
} }
// MARK: - Projection
/// Project a wire summary into the widget's self-contained row model.
private static func line(for s: WireSessionSummary) -> NucleicSessionAttributes.SessionLine {
NucleicSessionAttributes.SessionLine(
id: s.sessionID.rawValue,
title: s.title.isEmpty ? s.projectName : s.title,
project: s.projectName,
backend: backend(s.backend),
kind: kind(for: s),
detail: detail(for: s))
}
private static func kind(for s: WireSessionSummary) -> NucleicSessionAttributes.Kind {
switch s.status {
case .awaitingApproval: .approval
case .running: .running
case .provisioning: .provisioning
case .awaitingInput: s.disposition == .completed ? .done : .needsInput
case .idle: .idle
case .finished: .done
case .interrupted, .error: .error
}
}
private static func backend(_ id: BackendID) -> NucleicSessionAttributes.Backend {
switch id {
case .claudeCode: .claude
case .codex, .codexExec: .codex
case .grok: .grok
}
}
/// The compact right-aligned status for a row: the actionable ask wins (approvals, then
/// "waiting on you"), else the worktree churn, else what the agent is up to.
private static func detail(for s: WireSessionSummary) -> 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 StatusStyle.label(s.status, disposition: s.disposition)
}
}
} }