From 693ca29691d5f449d558d22c4cdf52fc4c1cf435 Mon Sep 17 00:00:00 2001 From: Andrew Blakeslee Moore Date: Sat, 4 Jul 2026 23:30:53 -0700 Subject: [PATCH] Live Activity Content Density Nucleic-Session: B3643133-163C-4C5D-BE20-1A6B7FB83DA9 Co-authored-by: Nucleic --- .../SessionLiveActivity.swift | 283 +++++++++++++----- 1 file changed, 203 insertions(+), 80 deletions(-) diff --git a/NucleicRemote/NucleicRemoteWidgets/SessionLiveActivity.swift b/NucleicRemote/NucleicRemoteWidgets/SessionLiveActivity.swift index 1abe857..5303e72 100644 --- a/NucleicRemote/NucleicRemoteWidgets/SessionLiveActivity.swift +++ b/NucleicRemote/NucleicRemoteWidgets/SessionLiveActivity.swift @@ -2,125 +2,248 @@ import ActivityKit import SwiftUI import WidgetKit -/// Lock-screen / Dynamic Island rendering of the aggregate session Activity (UX_IOS §5.3): -/// glance = running count + needs-you count + the most urgent session; tap opens the app -/// (straight to the approval, via the notification-style route). Self-contained colors — the -/// extension doesn't link the app's Theme, so the two palette constants it needs live here -/// (the same teal/amber/blue as `Palette`). +/// Lock-screen / Dynamic Island rendering of the aggregate session Activity (UX_IOS §5.3). +/// The glance answers "what is Nucleic doing right now?": headline counts (running / waiting / +/// approvals), the total churn in flight, and the most urgent sessions with their state and diff. +/// Tapping opens the app (straight to the approval, via the notification-style route). +/// +/// Self-contained colors/glyphs — the extension doesn't link the app's `Theme`, so the small +/// palette + glyph maps it needs live here (the same teal/amber/blue/green as `Palette`). struct SessionLiveActivity: Widget { var body: some WidgetConfiguration { ActivityConfiguration(for: NucleicSessionAttributes.self) { context in - // Lock screen banner. LockScreenView(state: context.state, hostName: context.attributes.hostName) .activityBackgroundTint(Color.black.opacity(0.55)) .activitySystemActionForegroundColor(.white) } dynamicIsland: { context in - DynamicIsland { + let state = context.state + return DynamicIsland { DynamicIslandExpandedRegion(.leading) { - Label("\(context.state.runningCount)", systemImage: "circle.lefthalf.filled") - .font(.headline) - .foregroundStyle(ActivityPalette.active) + CountBadge( + systemImage: "circle.lefthalf.filled", + count: state.runningCount, tint: ActivityPalette.active) } DynamicIslandExpandedRegion(.trailing) { - if context.state.needsYouCount > 0 { - Label("\(context.state.needsYouCount)", systemImage: "exclamationmark.triangle.fill") - .font(.headline) - .foregroundStyle(ActivityPalette.attention) + if state.approvalCount > 0 { + CountBadge( + systemImage: "exclamationmark.shield.fill", + count: state.approvalCount, tint: ActivityPalette.attention) + } else if state.needsYouCount > 0 { + CountBadge( + systemImage: "bubble.left.fill", + count: state.needsYouCount, tint: ActivityPalette.accent) } else { Label("0", systemImage: "checkmark.circle") - .font(.headline) - .foregroundStyle(.secondary) + .font(.headline).foregroundStyle(.secondary) } } DynamicIslandExpandedRegion(.bottom) { - if let title = context.state.topTitle { - HStack(spacing: 6) { - Image(systemName: context.state.topNeedsYou - ? "exclamationmark.triangle.fill" : "circle.lefthalf.filled") - .foregroundStyle(context.state.topNeedsYou - ? ActivityPalette.attention : ActivityPalette.active) - Text(title).font(.subheadline.weight(.medium)).lineLimit(1) - Spacer() - Text(statusLine(context.state)) - .font(.caption).foregroundStyle(.secondary) + VStack(alignment: .leading, spacing: 5) { + ForEach(state.lines.prefix(2)) { line in + SessionRow(line: line) + } + if state.hasChurn { + ChurnLine(state: state).padding(.top, 1) } - } else { - Text(statusLine(context.state)) - .font(.caption).foregroundStyle(.secondary) } } } compactLeading: { - Image(systemName: "circle.lefthalf.filled") - .foregroundStyle(ActivityPalette.active) + Image(systemName: state.needsAttention + ? ActivityPalette.glyph(.approval) : ActivityPalette.glyph(.running)) + .foregroundStyle(state.needsAttention + ? ActivityPalette.attention : ActivityPalette.active) } compactTrailing: { - if context.state.needsYouCount > 0 { - Text("\(context.state.needsYouCount)") - .font(.caption2.bold()) - .foregroundStyle(ActivityPalette.attention) - } else { - Text("\(context.state.runningCount)") - .font(.caption2.bold()) - .foregroundStyle(ActivityPalette.active) - } + Text("\(state.needsAttention ? max(state.approvalCount, state.needsYouCount) : state.runningCount)") + .font(.caption2.bold()) + .foregroundStyle(state.needsAttention ? ActivityPalette.attention : ActivityPalette.active) } minimal: { - Image(systemName: context.state.needsYouCount > 0 - ? "exclamationmark.triangle.fill" : "circle.lefthalf.filled") - .foregroundStyle(context.state.needsYouCount > 0 + Image(systemName: state.needsAttention + ? ActivityPalette.glyph(.approval) : ActivityPalette.glyph(.running)) + .foregroundStyle(state.needsAttention ? ActivityPalette.attention : ActivityPalette.active) } } } - - private func statusLine(_ state: NucleicSessionAttributes.ContentState) -> String { - var parts: [String] = [] - if state.runningCount > 0 { parts.append("\(state.runningCount) running") } - if state.needsYouCount > 0 { parts.append("\(state.needsYouCount) need you") } - return parts.isEmpty ? "Idle" : parts.joined(separator: " · ") - } } +// MARK: - Lock screen + private struct LockScreenView: View { let state: NucleicSessionAttributes.ContentState let hostName: String var body: some View { - HStack(spacing: 12) { - Image(systemName: state.needsYouCount > 0 - ? "exclamationmark.triangle.fill" : "circle.lefthalf.filled") - .font(.title2) - .foregroundStyle(state.needsYouCount > 0 - ? ActivityPalette.attention : ActivityPalette.active) - VStack(alignment: .leading, spacing: 2) { - if let title = state.topTitle { - Text(title).font(.headline).lineLimit(1) - } - HStack(spacing: 4) { - if state.runningCount > 0 { - Text("\(state.runningCount) running").foregroundStyle(ActivityPalette.active) - } - if state.runningCount > 0 && state.needsYouCount > 0 { Text("·") } - if state.needsYouCount > 0 { - Text("\(state.needsYouCount) waiting for you") - .foregroundStyle(ActivityPalette.attention) - } - if state.runningCount == 0 && state.needsYouCount == 0 { - Text("Idle").foregroundStyle(.secondary) - } - } - .font(.subheadline) + VStack(alignment: .leading, spacing: 8) { + // Header: brand + host, and the headline counts. + HStack(spacing: 8) { + Image(systemName: "hexagon.fill") + .font(.footnote) + .foregroundStyle(ActivityPalette.active) + Text(hostName) + .font(.caption.weight(.semibold)) + .lineLimit(1) + Spacer(minLength: 8) + CountChips(state: state) + } + + // The most urgent sessions — what's actually happening. + ForEach(state.lines.prefix(3)) { line in + SessionRow(line: line) + } + + // Aggregate churn footer — how much work is in flight right now. + if state.hasChurn { + Divider().overlay(Color.white.opacity(0.12)) + ChurnLine(state: state) } - Spacer() - Text(hostName) - .font(.caption2).foregroundStyle(.secondary) - .lineLimit(1) } .padding(14) .foregroundStyle(.white) } } -/// The two categorical colors the Activity needs — same constants as the app's `Palette`. +// MARK: - Shared pieces + +/// One session: state glyph + title + "project · Backend" + a right-aligned status/diff. +private struct SessionRow: View { + let line: NucleicSessionAttributes.SessionLine + + var body: some View { + HStack(spacing: 8) { + Image(systemName: ActivityPalette.glyph(line.kind)) + .font(.footnote) + .foregroundStyle(ActivityPalette.color(line.kind)) + .frame(width: 16) + VStack(alignment: .leading, spacing: 1) { + Text(line.title) + .font(.subheadline.weight(.medium)) + .lineLimit(1) + HStack(spacing: 4) { + Text(line.project) + Text("·").foregroundStyle(.secondary) + Text(line.backend.label) + .foregroundStyle(ActivityPalette.backendColor(line.backend)) + } + .font(.caption2) + .foregroundStyle(.secondary) + .lineLimit(1) + } + Spacer(minLength: 6) + Text(line.detail) + .font(.caption2.weight(.medium)) + .monospacedDigit() + .foregroundStyle(ActivityPalette.color(line.kind)) + .lineLimit(1) + } + } +} + +/// Headline count chips: running (blue), waiting (teal), approvals (amber). Only nonzero show. +private struct CountChips: View { + let state: NucleicSessionAttributes.ContentState + + var body: some View { + HStack(spacing: 10) { + if state.runningCount > 0 { + chip("circle.lefthalf.filled", state.runningCount, ActivityPalette.active) + } + if state.needsYouCount > 0 { + chip("bubble.left.fill", state.needsYouCount, ActivityPalette.accent) + } + if state.approvalCount > 0 { + chip("exclamationmark.shield.fill", state.approvalCount, ActivityPalette.attention) + } + } + } + + private func chip(_ systemImage: String, _ count: Int, _ tint: Color) -> some View { + HStack(spacing: 3) { + Image(systemName: systemImage).font(.caption2) + Text("\(count)").font(.caption.weight(.semibold)).monospacedDigit() + } + .foregroundStyle(tint) + } +} + +/// Aggregate worktree churn: "N files +A −R". +private struct ChurnLine: View { + let state: NucleicSessionAttributes.ContentState + + var body: some View { + HStack(spacing: 6) { + Image(systemName: "chevron.left.forwardslash.chevron.right") + .font(.caption2) + .foregroundStyle(.secondary) + Text("\(state.filesChanged) file\(state.filesChanged == 1 ? "" : "s") changed") + .foregroundStyle(.secondary) + Text("+\(state.linesAdded)") + .foregroundStyle(ActivityPalette.added) + Text("−\(state.linesRemoved)") + .foregroundStyle(ActivityPalette.removed) + } + .font(.caption2.weight(.medium)) + .monospacedDigit() + .lineLimit(1) + } +} + +/// Dynamic Island expanded leading/trailing count badge. +private struct CountBadge: View { + let systemImage: String + let count: Int + let tint: Color + + var body: some View { + Label("\(count)", systemImage: systemImage) + .font(.headline) + .monospacedDigit() + .foregroundStyle(tint) + } +} + +/// The categorical colors + glyphs the Activity needs — the same semantics as the app's +/// `Palette.status` / `StatusStyle.symbol`, re-declared because the widget extension can't link +/// the app's `Theme`. private enum ActivityPalette { static let active = Color(red: 0.20, green: 0.55, blue: 0.95) // running blue - static let attention = Color(red: 1.00, green: 0.65, blue: 0.15) // needs-you amber + static let attention = Color(red: 1.00, green: 0.65, blue: 0.15) // approval amber + static let accent = Color(red: 0.16, green: 0.72, blue: 0.68) // needs-input teal + static let success = Color(red: 0.30, green: 0.78, blue: 0.45) // done green + static let danger = Color(red: 0.92, green: 0.34, blue: 0.34) // error red + static let neutral = Color.secondary + static let added = Color(red: 0.30, green: 0.78, blue: 0.45) + static let removed = Color(red: 0.92, green: 0.34, blue: 0.34) + + static func glyph(_ kind: NucleicSessionAttributes.Kind) -> String { + switch kind { + case .running: "circle.lefthalf.filled" + case .provisioning: "circle.dotted" + case .approval: "exclamationmark.triangle.fill" + case .needsInput: "bubble.left.fill" + case .done: "checkmark.circle.fill" + case .error: "xmark.octagon.fill" + case .idle: "circle" + } + } + + static func color(_ kind: NucleicSessionAttributes.Kind) -> Color { + switch kind { + case .running: active + case .provisioning: neutral + case .approval: attention + case .needsInput: accent + case .done: success + case .error: danger + case .idle: neutral + } + } + + static func backendColor(_ backend: NucleicSessionAttributes.Backend) -> Color { + switch backend { + case .claude: Color(red: 0.85, green: 0.52, blue: 0.30) // Anthropic clay + case .codex: Color(red: 0.45, green: 0.72, blue: 0.55) // OpenAI green + case .grok: Color(red: 0.55, green: 0.58, blue: 0.66) // xAI graphite + case .other: Color.secondary + } + } }