From a45795184ea7ec812dc0258a2ad3def142008f05 Mon Sep 17 00:00:00 2001 From: Andrew Blakeslee Moore Date: Sat, 4 Jul 2026 23:30:15 -0700 Subject: [PATCH] Live Activity Content Density Nucleic-Session: B3643133-163C-4C5D-BE20-1A6B7FB83DA9 Co-authored-by: Nucleic --- .../NucleicRemote/LiveActivityManager.swift | 87 +++++++++++++++++-- 1 file changed, 82 insertions(+), 5 deletions(-) diff --git a/NucleicRemote/NucleicRemote/LiveActivityManager.swift b/NucleicRemote/NucleicRemote/LiveActivityManager.swift index 55d18b7..98f60a5 100644 --- a/NucleicRemote/NucleicRemote/LiveActivityManager.swift +++ b/NucleicRemote/NucleicRemote/LiveActivityManager.swift @@ -11,6 +11,10 @@ final class LiveActivityManager { static let shared = LiveActivityManager() 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? /// Reconcile the Activity with the current session set. @@ -25,14 +29,33 @@ final class LiveActivityManager { return } - // The most urgent session headlines: the top waiter, else the freshest runner. - let top = needsYou.max { $0.updatedAt < $1.updatedAt } - ?? running.max { $0.updatedAt < $1.updatedAt } + // Everything in flight or waiting on the user, attention-first (approvals, then waiting + // input, then running), freshest within a rank. This is both the detail-row source and + // 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( runningCount: running.count, needsYouCount: needsYou.count, - topTitle: top?.title, - topNeedsYou: top.map { $0.status.needsYou($0.disposition) } ?? false) + approvalCount: approvals, + filesChanged: files, + linesAdded: added, + linesRemoved: removed, + lines: Array(lines)) if let activity { Task { await activity.update(ActivityContent(state: state, staleDate: nil)) } @@ -59,4 +82,58 @@ final class LiveActivityManager { 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) + } + } }