nvrsion: Add: Global Icon For Remote Projects to display globe icon next to remote projects in sidebar, matching local projects in appearance, Fix: Chat: Remove Face ID Permission Text, Refactor: Chat: Live Activity Content Density to open app directly into waiting sessions when tapped, Refactor: Chat: Live Activity Content Density to refresh live activity info significantly faster, Refactor: Chat: Live Activity Content Density to refresh live activity info significantly faster, Fix: Chat: Live Activity Content Density to refresh live activity info significantly faster, Fix: Chat: Network Type Transition Optimization to reduce delay during network type switches, Feature Enhancement Planning: All devices in a mesh group should see and be aware of each other; “pair” reframed into “join”; scanning
Nucleic-Promote: 1 Co-authored-by: Nucleic <[email protected]>
This commit is contained in:
@@ -2,125 +2,252 @@ 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)
|
||||
.widgetURL(context.state.tapURL)
|
||||
.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)
|
||||
}
|
||||
// The compact/minimal presentations tap through to the same target as the lock
|
||||
// screen: a waiting session opens itself; otherwise the sessions list.
|
||||
.widgetURL(state.tapURL)
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user