diff --git a/apps/ios/ADE/Services/LiveActivityCoordinator.swift b/apps/ios/ADE/Services/LiveActivityCoordinator.swift index 401a7441a..197962d9f 100644 --- a/apps/ios/ADE/Services/LiveActivityCoordinator.swift +++ b/apps/ios/ADE/Services/LiveActivityCoordinator.swift @@ -56,13 +56,20 @@ public final class LiveActivityCoordinator: ObservableObject { /// How long to keep a terminal activity around before the OS /// dismisses it automatically. public var terminalDismissalDelay: TimeInterval + /// After the user dismisses an activity (swipe-off / long-press), + /// suppress recreating an ambient one for this long. Attention signals + /// (awaiting-input / failed / CI failing / etc.) override the cooldown + /// because the user actually needs to see those. + public var dismissedCooldown: TimeInterval public init( staleInterval: TimeInterval = 300, - terminalDismissalDelay: TimeInterval = 120 + terminalDismissalDelay: TimeInterval = 120, + dismissedCooldown: TimeInterval = 600 ) { self.staleInterval = staleInterval self.terminalDismissalDelay = terminalDismissalDelay + self.dismissedCooldown = dismissedCooldown } } @@ -76,9 +83,21 @@ public final class LiveActivityCoordinator: ObservableObject { private var pushTokenTask: Task? /// Push-to-start listener (iOS 17.2+). private var pushToStartTask: Task? + /// Per-activity state listener that flips `lastUserDismissalAt` when iOS + /// reports the user dismissed the LA from the Lock Screen / Dynamic Island. + private var activityStateTask: Task? + /// ID of the activity the listener above is attached to. Lets reconcile + /// skip re-attaching when we update the same activity repeatedly — the + /// cancel/restart gap was a window where a `.dismissed` event could be lost. + private var observedActivityId: String? /// Serializes ActivityKit mutations so updates/end/start calls do not race. private var reconcileTask: Task? + /// Last time the user dismissed our Live Activity. Within + /// `Configuration.dismissedCooldown`, we suppress ambient recreation; + /// attention signals always override. + private var lastUserDismissalAt: Date? + // MARK: - Init public init( @@ -104,6 +123,7 @@ public final class LiveActivityCoordinator: ObservableObject { MainActor.assumeIsolated { pushTokenTask?.cancel() pushToStartTask?.cancel() + activityStateTask?.cancel() reconcileTask?.cancel() } } @@ -112,35 +132,54 @@ public final class LiveActivityCoordinator: ObservableObject { /// Called by the host whenever the set of active sessions changes. /// - Parameters: - /// - sessions: live sessions (already pre-filtered for staleness by - /// `SyncService.refreshActiveSessionsAndSnapshot`). + /// - sessions: live sessions actively producing output (filtered to + /// `runtimeState == "running"` by `SyncService`). /// - prs: optional PR snapshot for the pending-PR counts. Pass nil /// to leave the PR counts unchanged from the previous tick. + /// - awaitingInputCount: chats waiting on user input — rendered as a + /// count chip rather than a roster row. + /// - idleCount: chats connected but not currently producing output. public func reconcile( with sessions: [AgentSnapshot], - prs: [PrSnapshot] = [] + prs: [PrSnapshot] = [], + awaitingInputCount: Int = 0, + idleCount: Int = 0 ) { let previousTask = reconcileTask reconcileTask = Task { @MainActor [weak self] in await previousTask?.value guard let self else { return } - await self.reconcileNow(with: sessions, prs: prs) + await self.reconcileNow( + with: sessions, + prs: prs, + awaitingInputCount: awaitingInputCount, + idleCount: idleCount + ) } } private func reconcileNow( with sessions: [AgentSnapshot], - prs: [PrSnapshot] + prs: [PrSnapshot], + awaitingInputCount: Int, + idleCount: Int ) async { guard ActivityAuthorizationInfo().areActivitiesEnabled else { await endAllActivities(dismissalPolicy: .immediate) return } - let desiredState = makeContentState(sessions: sessions, prs: prs) + let desiredState = makeContentState( + sessions: sessions, + prs: prs, + awaitingInputCount: awaitingInputCount, + idleCount: idleCount + ) // If there's literally nothing to show, make sure no activity is - // visible and return. + // visible and return. Counts alone (awaiting / idle) don't justify + // surfacing a Live Activity — only an actively-running roster, an + // attention signal, or pending PRs do. if sessions.isEmpty && desiredState.attention == nil && desiredState.pendingPrCount == 0 { await endAllActivities(dismissalPolicy: .after(Date().addingTimeInterval(configuration.terminalDismissalDelay))) return @@ -153,20 +192,48 @@ public final class LiveActivityCoordinator: ObservableObject { await stray.end(nil, dismissalPolicy: .immediate) } await update(canonical, to: desiredState) - } else { + observeActivityStateUpdates(for: canonical) + } else if shouldStartFreshActivity(for: desiredState) { await startActivity(with: desiredState) } + // else: user dismissed recently and there's no urgent reason to re-summon. + // Home widget still reflects state; the LA stays out of the way. + } + + /// Guard against re-summoning a freshly-dismissed Live Activity. Within + /// the cooldown window, ambient flavors (running roster, count summary) + /// stay suppressed — but attention signals (awaiting-input / failed / + /// CI failing / review-requested / merge-ready) override it because the + /// user actually needs to see those. + private func shouldStartFreshActivity( + for state: ADESessionAttributes.ContentState + ) -> Bool { + guard let dismissedAt = lastUserDismissalAt else { return true } + if Date().timeIntervalSince(dismissedAt) >= configuration.dismissedCooldown { + lastUserDismissalAt = nil + return true + } + // Attention signals (awaiting-input / failed / CI failing / review-requested + // / merge-ready) override the cooldown — the user needs to see these even + // if they dismissed an ambient activity recently. + if state.attention != nil { + lastUserDismissalAt = nil + return true + } + return false } // MARK: - State construction private func makeContentState( sessions: [AgentSnapshot], - prs: [PrSnapshot] + prs: [PrSnapshot], + awaitingInputCount: Int = 0, + idleCount: Int = 0 ) -> ADESessionAttributes.ContentState { - // Prioritise: awaiting-input first, then failed, then recently-started. + // Prioritise: failed first (rare, urgent), then most-recently-active. + // Awaiting-input is no longer in this list (rolled into the count chip). let sorted = sessions.sorted { a, b in - if a.awaitingInput != b.awaitingInput { return a.awaitingInput } if isFailed(a) != isFailed(b) { return isFailed(a) } return a.lastActivityAt > b.lastActivityAt } @@ -199,6 +266,7 @@ public final class LiveActivityCoordinator: ObservableObject { let attention: ADESessionAttributes.ContentState.Attention? = selectAttention( sessions: sorted, prs: prs, + awaitingInputCount: awaitingInputCount, failingChecks: failingChecks, awaitingReviews: awaitingReviews, mergeReady: mergeReady @@ -210,6 +278,8 @@ public final class LiveActivityCoordinator: ObservableObject { failingCheckCount: failingChecks, awaitingReviewCount: awaitingReviews, mergeReadyCount: mergeReady, + awaitingInputCount: awaitingInputCount, + idleCount: idleCount, generatedAt: Date() ) } @@ -217,23 +287,19 @@ public final class LiveActivityCoordinator: ObservableObject { private func selectAttention( sessions: [AgentSnapshot], prs: [PrSnapshot], + awaitingInputCount: Int, failingChecks: Int, awaitingReviews: Int, mergeReady: Int ) -> ADESessionAttributes.ContentState.Attention? { - if let awaiting = sessions.first(where: { $0.awaitingInput }) { - // Note: itemId isn't on AgentSnapshot today. Push-notification - // Approve actions carry itemId through APNs; Live Activity - // buttons currently dispatch without it and rely on the server - // to fall back to the most-recent pending input per session. - // TODO: plumb itemId through the snapshot so LA buttons work - // directly without the server-side fallback. + if awaitingInputCount > 0 { + let title = awaitingInputCount == 1 + ? "1 chat waiting for input" + : "\(awaitingInputCount) chats waiting for input" return .init( kind: .awaitingInput, - title: humanTitle(for: awaiting), - subtitle: "Approval needed", - providerSlug: awaiting.provider, - sessionId: awaiting.sessionId + title: title, + subtitle: "Tap to respond" ) } if let failed = sessions.first(where: { isFailed($0) }) { @@ -312,6 +378,7 @@ public final class LiveActivityCoordinator: ObservableObject { pushType: .token ) observePushTokenUpdates(for: activity) + observeActivityStateUpdates(for: activity) } catch { // Common failure modes: user disabled Live Activities in // Settings, the app was background-launched without a valid @@ -339,10 +406,43 @@ public final class LiveActivityCoordinator: ObservableObject { } pushTokenTask?.cancel() pushTokenTask = nil + activityStateTask?.cancel() + activityStateTask = nil + observedActivityId = nil } // MARK: - Push tokens + /// Observe the user-dismissal signal on a live activity. ActivityKit flips + /// state to `.dismissed` when the user swipes the LA away on the Lock + /// Screen or long-press → Hide on Dynamic Island. We record the timestamp + /// so `shouldStartFreshActivity(for:)` can suppress recreation. + /// + /// Idempotent — if we're already attached to this activity, skip. Prevents + /// the cancel/restart race where a `.dismissed` event could fire during + /// the gap and be lost. + private func observeActivityStateUpdates(for activity: Activity) { + if observedActivityId == activity.id, activityStateTask != nil { return } + activityStateTask?.cancel() + observedActivityId = activity.id + activityStateTask = Task { @MainActor [weak self] in + for await newState in activity.activityStateUpdates { + switch newState { + case .dismissed: + self?.lastUserDismissalAt = Date() + case .ended, .stale: + // Ended-by-us or system-staled — leave dismissal flag alone. + break + case .active: + // Re-activated (e.g. via a new request after cooldown). + self?.lastUserDismissalAt = nil + @unknown default: + break + } + } + } + } + private func observePushTokenUpdates(for activity: Activity) { pushTokenTask?.cancel() pushTokenTask = Task { [weak self] in diff --git a/apps/ios/ADE/Services/SyncService.swift b/apps/ios/ADE/Services/SyncService.swift index 389651661..7254af03e 100644 --- a/apps/ios/ADE/Services/SyncService.swift +++ b/apps/ios/ADE/Services/SyncService.swift @@ -683,8 +683,17 @@ final class SyncService: ObservableObject { /// Sessions currently eligible for a Live Activity. Rebuilt from /// `localStateRevision` changes and consumed by `LiveActivityCoordinator`. + /// Roster only — sessions whose runtime is *actively* producing output. @Published private(set) var activeSessions: [AgentSnapshot] = [] + /// Chat sessions currently waiting on user input. Surfaced as a count chip + /// instead of a roster row so old "awaiting" zombies don't pile up visually. + @Published private(set) var awaitingInputSessionsCount: Int = 0 + + /// Chat sessions paused / idle (connected but not producing output). Counted + /// for context but never listed. + @Published private(set) var idleSessionsCount: Int = 0 + /// Owns the iOS 16.2+ Live Activity lifecycle; wired with `self` as host. private var liveActivityCoordinator: Any? @@ -5476,34 +5485,48 @@ extension SyncService { func refreshActiveSessionsAndSnapshot() { let sessions = database.fetchSessions() let now = Date() - // Staleness guard: dev iteration leaves many zombie sessions in the DB - // with status="running" that were never cleanly terminated. Without a - // recency filter the Live Activity / widget fills up with multi-hour-old - // garbage. 2 hours covers long-running legitimate missions while - // excluding overnight zombies. Awaiting-input always passes because the - // user explicitly needs to see it even if it's old. - let staleCutoffSeconds: TimeInterval = 2 * 60 * 60 - let agents: [AgentSnapshot] = sessions.compactMap { session in + + // `activeSessions` holds every live chat session — running, awaiting-input, + // and idle. The Live Activity / widget filter to running-only at render + // time so the user-facing roster never lists multi-hour-old zombies, while + // the in-app Attention Drawer still gets its full set. Non-chat (shell / + // CLI) sessions are excluded entirely. Completed / failed / ended sessions + // are dropped since they're terminal. + var allAgents: [AgentSnapshot] = [] + var runningAgents: [AgentSnapshot] = [] + var awaitingInputCount = 0 + var idleCount = 0 + + for session in sessions { let isChat = (session.toolType?.contains("chat") == true) - guard isChat else { return nil } + guard isChat else { continue } + let status = session.status.lowercased() - guard status != "completed" && status != "failed" else { return nil } + guard status != "completed" && status != "failed" && status != "ended" else { + continue + } + + let runtime = session.runtimeState.lowercased() + // Trust runtimeState (per-tick) over status (high-level) for the + // running-vs-idle distinction the user cares about. + let isAwaiting = runtime == "waiting-input" || status == "awaiting_input" + let isRunningRuntime = runtime == "running" + let isIdleRuntime = runtime == "idle" + let isEndedRuntime = runtime == "exited" + + // Anything that's terminated mid-stream goes in the bin too. + if isEndedRuntime { continue } - let awaiting = status == "awaiting_input" let started = Self.parseIso8601(session.startedAt) ?? now let lastActivity = Self.parseIso8601(session.endedAt ?? "") ?? now let elapsed = Int(max(0, lastActivity.timeIntervalSince(started))) - if !awaiting && now.timeIntervalSince(started) > staleCutoffSeconds { - return nil - } - - return AgentSnapshot( + let snap = AgentSnapshot( sessionId: session.id, provider: session.toolType ?? "claude", title: session.title.isEmpty ? session.goal : session.title, - status: status, - awaitingInput: awaiting, + status: isRunningRuntime ? "running" : (isIdleRuntime ? "idle" : status), + awaitingInput: isAwaiting, lastActivityAt: lastActivity, elapsedSeconds: elapsed, preview: session.lastOutputPreview, @@ -5511,9 +5534,27 @@ extension SyncService { phase: nil, toolCalls: 0 ) + + allAgents.append(snap) + + if isAwaiting { + awaitingInputCount += 1 + } else if isRunningRuntime { + runningAgents.append(snap) + } else if isIdleRuntime { + idleCount += 1 + } else if status == "running" { + // Transient runtimeState we don't enumerate ("starting", "spawning", + // anything new from the desktop). Mirror `normalizedWorkChatSessionStatus`'s + // default branch — if the lane still says it's running, treat it as + // actively running so the LA + widgets agree. + runningAgents.append(snap) + } } - activeSessions = agents + activeSessions = allAgents + awaitingInputSessionsCount = awaitingInputCount + idleSessionsCount = idleCount if #available(iOS 16.2, *), let coord = liveActivityCoordinator as? LiveActivityCoordinator { @@ -5534,7 +5575,15 @@ extension SyncService { branch: item.headBranch.isEmpty ? nil : item.headBranch ) } - coord.reconcile(with: agents, prs: currentPrs) + // Coordinator only sees running sessions in the roster — counts roll + // up into a chip / minimal glyph so the LA never carries old, idle, or + // pending sessions through into the user-visible roster. + coord.reconcile( + with: runningAgents, + prs: currentPrs, + awaitingInputCount: awaitingInputCount, + idleCount: idleCount + ) } scheduleWorkspaceSnapshotWrite() @@ -5576,7 +5625,9 @@ extension SyncService { generatedAt: Date(), agents: activeSessions, prs: prs, - connection: connection + connection: connection, + awaitingInputCount: awaitingInputSessionsCount, + idleCount: idleSessionsCount ) if ADESharedContainer.writeWorkspaceSnapshot(snapshot) { diff --git a/apps/ios/ADE/Shared/ADESharedContainer.swift b/apps/ios/ADE/Shared/ADESharedContainer.swift index a498cbe87..bc8318563 100644 --- a/apps/ios/ADE/Shared/ADESharedContainer.swift +++ b/apps/ios/ADE/Shared/ADESharedContainer.swift @@ -75,19 +75,24 @@ public enum ADESharedContainer { /// Format: `"ADE · N agents · #NNN ✗"` / `"ADE · N agents"` / `"ADE · idle"`. public static func inlineSummary(for snapshot: WorkspaceSnapshot? = nil) -> String { let s = snapshot ?? readWorkspaceSnapshot() ?? .empty - let activeAgents = s.agents.filter { $0.status == "running" || $0.awaitingInput }.count + let runningAgents = s.runningAgents.count let openPrs = s.prs.filter { $0.state == "open" } let focusedPr: PrSnapshot? = openPrs.first(where: { $0.checks == "failing" }) ?? openPrs.first(where: { $0.review == "changes_requested" || $0.review == "pending" }) ?? openPrs.first(where: { $0.mergeReady }) ?? openPrs.first - if activeAgents == 0 && focusedPr == nil { + if runningAgents == 0 && focusedPr == nil + && s.awaitingInputCount == 0 && s.idleCount == 0 { return "ADE · idle" } var pieces: [String] = ["ADE"] - if activeAgents > 0 { - pieces.append("\(activeAgents) \(activeAgents == 1 ? "agent" : "agents")") + if runningAgents > 0 { + pieces.append("\(runningAgents) running") + } else if s.awaitingInputCount > 0 { + pieces.append("\(s.awaitingInputCount) waiting") + } else if s.idleCount > 0 { + pieces.append("\(s.idleCount) idle") } if let pr = focusedPr { let mark: String diff --git a/apps/ios/ADE/Shared/ADESharedModels.swift b/apps/ios/ADE/Shared/ADESharedModels.swift index baa5685e9..0b79a24e2 100644 --- a/apps/ios/ADE/Shared/ADESharedModels.swift +++ b/apps/ios/ADE/Shared/ADESharedModels.swift @@ -94,21 +94,80 @@ public struct PrSnapshot: Codable, Hashable, Identifiable, Sendable { public struct WorkspaceSnapshot: Codable, Hashable, Sendable { public let generatedAt: Date + /// All live chat sessions — running, awaiting-input, and idle. Widgets and + /// the Live Activity render `runningAgents` (only currently-producing + /// sessions) so old / pending sessions don't pollute the roster; the + /// in-app Attention Drawer reads the full set. public let agents: [AgentSnapshot] public let prs: [PrSnapshot] /// "connected" | "syncing" | "disconnected". public let connection: String + /// Chats waiting on user input. Surfaced as a count chip, not a row. + public let awaitingInputCount: Int + /// Chats connected but not currently producing output. + public let idleCount: Int public init( generatedAt: Date, agents: [AgentSnapshot], prs: [PrSnapshot], - connection: String + connection: String, + awaitingInputCount: Int = 0, + idleCount: Int = 0 ) { self.generatedAt = generatedAt self.agents = agents self.prs = prs self.connection = connection + self.awaitingInputCount = awaitingInputCount + self.idleCount = idleCount + } + + private enum CodingKeys: String, CodingKey { + case generatedAt, agents, prs, connection, awaitingInputCount, idleCount + } + + public init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + self.generatedAt = try c.decode(Date.self, forKey: .generatedAt) + let decodedAgents = try c.decode([AgentSnapshot].self, forKey: .agents) + self.agents = decodedAgents + self.prs = try c.decode([PrSnapshot].self, forKey: .prs) + self.connection = try c.decode(String.self, forKey: .connection) + // Fields added later — older snapshots written without them decode cleanly. + // When absent, derive from `agents` so legacy snapshots don't render + // as fully idle (runningAgents filters waiting/idle sessions out, so a + // 0 default would silently drop them from every count chip). + if let value = try c.decodeIfPresent(Int.self, forKey: .awaitingInputCount) { + self.awaitingInputCount = value + } else { + self.awaitingInputCount = decodedAgents.reduce(into: 0) { count, agent in + if agent.awaitingInput || agent.status.lowercased() == "awaiting_input" { + count += 1 + } + } + } + if let value = try c.decodeIfPresent(Int.self, forKey: .idleCount) { + self.idleCount = value + } else { + self.idleCount = decodedAgents.reduce(into: 0) { count, agent in + if agent.status.lowercased() == "idle" { count += 1 } + } + } + } + + /// Subset of `agents` that are *actively producing output* right now. + /// This is what the LA roster, home widget roster, and lock-screen + /// accessory should render — not the full set, which includes idle and + /// awaiting-input sessions surfaced via `awaitingInputCount` / `idleCount`. + public var runningAgents: [AgentSnapshot] { + agents.filter { agent in + !agent.awaitingInput + && agent.status.lowercased() != "idle" + && agent.status.lowercased() != "ended" + && agent.status.lowercased() != "completed" + && agent.status.lowercased() != "failed" + } } /// Empty snapshot used by widget previews and first-launch placeholders. diff --git a/apps/ios/ADEWidgets/ADEControlWidget.swift b/apps/ios/ADEWidgets/ADEControlWidget.swift index 5ae02dd30..40d4efbdc 100644 --- a/apps/ios/ADEWidgets/ADEControlWidget.swift +++ b/apps/ios/ADEWidgets/ADEControlWidget.swift @@ -17,7 +17,7 @@ struct ADEControlWidget: ControlWidget { var body: some ControlWidgetConfiguration { StaticControlConfiguration(kind: Self.kind) { ControlWidgetButton(action: OpenADEIntent()) { - Label("Open ADE", systemImage: "sparkles") + Label("Open", systemImage: "sparkles") } } .displayName("Open ADE") @@ -37,8 +37,8 @@ struct ADEMuteControlWidget: ControlWidget { action: ToggleMutePushIntent() ) { isOn in Label( - isOn ? mutedUntilLabel() : "Mute pushes", - systemImage: isOn ? "moon.fill" : "bell.fill" + isOn ? mutedUntilLabel() : "Mute", + systemImage: isOn ? "bell.slash.fill" : "bell.fill" ) } } @@ -49,7 +49,7 @@ struct ADEMuteControlWidget: ControlWidget { private func mutedUntilLabel() -> String { if let until = ADEMutePreferences.muteUntil, until.timeIntervalSinceNow > 0 { let formatted = until.formatted(date: .omitted, time: .shortened) - return "Muted · until \(formatted)" + return "Muted until \(formatted)" } return "Muted" } @@ -72,21 +72,21 @@ enum ADEMuteControlState { /// the canvas show the OFF / ON label content in isolation. @available(iOS 18.0, *) #Preview("Mute label · OFF") { - Label("Mute pushes", systemImage: "bell.fill") + Label("Mute", systemImage: "bell.fill") .labelStyle(.titleAndIcon) .padding() } @available(iOS 18.0, *) #Preview("Mute label · ON") { - Label("Muted · until 9:00 AM", systemImage: "moon.fill") + Label("Muted until 9:00 AM", systemImage: "bell.slash.fill") .labelStyle(.titleAndIcon) .padding() } @available(iOS 18.0, *) #Preview("Open ADE label") { - Label("Open ADE", systemImage: "sparkles") + Label("Open", systemImage: "sparkles") .labelStyle(.titleAndIcon) .padding() } diff --git a/apps/ios/ADEWidgets/ADELiveActivity.swift b/apps/ios/ADEWidgets/ADELiveActivity.swift index 636d251c1..f938d8c17 100644 --- a/apps/ios/ADEWidgets/ADELiveActivity.swift +++ b/apps/ios/ADEWidgets/ADELiveActivity.swift @@ -89,6 +89,8 @@ public struct ADESessionAttributes: ActivityAttributes { } /// Sorted by relevance (awaiting-input first, then running, then stale). + /// Sessions *actively* producing output. Awaiting-input / idle / + /// ended chats live in the counts below, never in this array. public var sessions: [ActiveSession] /// Nil when nothing urgent; presence flips compact/expanded layouts /// into the attention-first mode. @@ -96,6 +98,10 @@ public struct ADESessionAttributes: ActivityAttributes { public var failingCheckCount: Int public var awaitingReviewCount: Int public var mergeReadyCount: Int + /// Chats waiting on user input. Rendered as a count chip. + public var awaitingInputCount: Int + /// Chats connected but not currently producing output. + public var idleCount: Int public var generatedAt: Date public init( @@ -104,6 +110,8 @@ public struct ADESessionAttributes: ActivityAttributes { failingCheckCount: Int, awaitingReviewCount: Int, mergeReadyCount: Int, + awaitingInputCount: Int = 0, + idleCount: Int = 0, generatedAt: Date ) { self.sessions = sessions @@ -111,9 +119,28 @@ public struct ADESessionAttributes: ActivityAttributes { self.failingCheckCount = failingCheckCount self.awaitingReviewCount = awaitingReviewCount self.mergeReadyCount = mergeReadyCount + self.awaitingInputCount = awaitingInputCount + self.idleCount = idleCount self.generatedAt = generatedAt } + // Custom Decodable so older payloads (pre-counts) still decode. + private enum CodingKeys: String, CodingKey { + case sessions, attention, failingCheckCount, awaitingReviewCount, + mergeReadyCount, awaitingInputCount, idleCount, generatedAt + } + public init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + self.sessions = try c.decode([ActiveSession].self, forKey: .sessions) + self.attention = try c.decodeIfPresent(Attention.self, forKey: .attention) + self.failingCheckCount = try c.decode(Int.self, forKey: .failingCheckCount) + self.awaitingReviewCount = try c.decode(Int.self, forKey: .awaitingReviewCount) + self.mergeReadyCount = try c.decode(Int.self, forKey: .mergeReadyCount) + self.awaitingInputCount = try c.decodeIfPresent(Int.self, forKey: .awaitingInputCount) ?? 0 + self.idleCount = try c.decodeIfPresent(Int.self, forKey: .idleCount) ?? 0 + self.generatedAt = try c.decode(Date.self, forKey: .generatedAt) + } + // Derived helpers used by views. public var hasAttention: Bool { attention != nil } public var pendingPrCount: Int { @@ -179,3 +206,6 @@ public struct ADELiveActivity: Widget { } } } + +// Activity-level Xcode canvas previews live in ADELiveActivityPreviews.swift +// (widgets-only target) so they can reach ADEWidgetPreviewData fixtures. diff --git a/apps/ios/ADEWidgets/ADELiveActivityPreviews.swift b/apps/ios/ADEWidgets/ADELiveActivityPreviews.swift index af4769cb7..7083086c5 100644 --- a/apps/ios/ADEWidgets/ADELiveActivityPreviews.swift +++ b/apps/ios/ADEWidgets/ADELiveActivityPreviews.swift @@ -4,6 +4,7 @@ #if DEBUG import SwiftUI import ActivityKit +import WidgetKit @available(iOS 17.0, *) private struct CompactRow: View { @@ -130,4 +131,117 @@ private struct ExpandedCard: View { ) } +// MARK: - Activity-level previews +// +// These render the *whole* Live Activity through Apple's `as: .content` and +// `as: .dynamicIsland(...)` preview macros so the canvas mimics what iOS +// actually shows on Lock Screen + Dynamic Island. Open this file in Xcode, +// `⌥⌘↩` to show the canvas, and use the chip row at the bottom of each +// preview to flip between presentations. + +@available(iOS 17.0, *) +private let _activityPreviewAttrs = ADESessionAttributes(workspaceName: "ADE") + +@available(iOS 17.0, *) +#Preview("LA · Lock Screen · Multi/Single/Idle", as: .content, using: _activityPreviewAttrs) { + ADELiveActivity() +} contentStates: { + ADEWidgetPreviewData.STATE_MULTI + ADEWidgetPreviewData.STATE_SINGLE + ADEWidgetPreviewData.STATE_IDLE +} + +@available(iOS 17.0, *) +#Preview("LA · Lock Screen · Attention", as: .content, using: _activityPreviewAttrs) { + ADELiveActivity() +} contentStates: { + ADEWidgetPreviewData.ATTN_STATES[.awaitingInput]! + ADEWidgetPreviewData.ATTN_STATES[.failed]! + ADEWidgetPreviewData.ATTN_STATES[.ciFailing]! + ADEWidgetPreviewData.ATTN_STATES[.reviewRequested]! + ADEWidgetPreviewData.ATTN_STATES[.mergeReady]! +} + +@available(iOS 17.0, *) +#Preview("LA · Dynamic Island · Compact", as: .dynamicIsland(.compact), using: _activityPreviewAttrs) { + ADELiveActivity() +} contentStates: { + ADEWidgetPreviewData.STATE_MULTI + ADEWidgetPreviewData.STATE_SINGLE + ADEWidgetPreviewData.STATE_IDLE + ADEWidgetPreviewData.ATTN_STATES[.awaitingInput]! + ADEWidgetPreviewData.ATTN_STATES[.failed]! + ADEWidgetPreviewData.ATTN_STATES[.ciFailing]! + ADEWidgetPreviewData.ATTN_STATES[.reviewRequested]! + ADEWidgetPreviewData.ATTN_STATES[.mergeReady]! +} + +@available(iOS 17.0, *) +#Preview("LA · Dynamic Island · Expanded", as: .dynamicIsland(.expanded), using: _activityPreviewAttrs) { + ADELiveActivity() +} contentStates: { + ADEWidgetPreviewData.STATE_MULTI + ADEWidgetPreviewData.STATE_SINGLE + ADEWidgetPreviewData.STATE_IDLE + ADEWidgetPreviewData.ATTN_STATES[.awaitingInput]! + ADEWidgetPreviewData.ATTN_STATES[.failed]! + ADEWidgetPreviewData.ATTN_STATES[.ciFailing]! + ADEWidgetPreviewData.ATTN_STATES[.reviewRequested]! + ADEWidgetPreviewData.ATTN_STATES[.mergeReady]! +} + +@available(iOS 17.0, *) +#Preview("LA · Dynamic Island · Minimal", as: .dynamicIsland(.minimal), using: _activityPreviewAttrs) { + ADELiveActivity() +} contentStates: { + ADEWidgetPreviewData.STATE_MULTI + ADEWidgetPreviewData.STATE_SINGLE + ADEWidgetPreviewData.ATTN_STATES[.awaitingInput]! + ADEWidgetPreviewData.ATTN_STATES[.failed]! +} + +// MARK: - Real-data Live Activity previews +// +// Sourced from your real workspace DB: 1 running codex-chat, 2 failing-CI PRs. +// Three flavors per region: +// • CURRENT — exactly what the LA looks like right now +// • RICH — same chat + synthetic awaiting/idle counts overlay so you can see +// the AttentionLockCard + CountsStrip with realistic content +// • PRs ONLY — no chat running, just CI-failing PRs + +@available(iOS 17.0, *) +#Preview("REAL · LA Lock Screen", as: .content, using: _activityPreviewAttrs) { + ADELiveActivity() +} contentStates: { + ADEWidgetPreviewData.REAL_CURRENT + ADEWidgetPreviewData.REAL_RICH + ADEWidgetPreviewData.REAL_PRS_ONLY +} + +@available(iOS 17.0, *) +#Preview("REAL · DI Compact", as: .dynamicIsland(.compact), using: _activityPreviewAttrs) { + ADELiveActivity() +} contentStates: { + ADEWidgetPreviewData.REAL_CURRENT + ADEWidgetPreviewData.REAL_RICH + ADEWidgetPreviewData.REAL_PRS_ONLY +} + +@available(iOS 17.0, *) +#Preview("REAL · DI Expanded", as: .dynamicIsland(.expanded), using: _activityPreviewAttrs) { + ADELiveActivity() +} contentStates: { + ADEWidgetPreviewData.REAL_CURRENT + ADEWidgetPreviewData.REAL_RICH + ADEWidgetPreviewData.REAL_PRS_ONLY +} + +@available(iOS 17.0, *) +#Preview("REAL · DI Minimal", as: .dynamicIsland(.minimal), using: _activityPreviewAttrs) { + ADELiveActivity() +} contentStates: { + ADEWidgetPreviewData.REAL_CURRENT + ADEWidgetPreviewData.REAL_RICH +} + #endif diff --git a/apps/ios/ADEWidgets/ADELiveActivityViews.swift b/apps/ios/ADEWidgets/ADELiveActivityViews.swift index 454a550d6..cf92e14f0 100644 --- a/apps/ios/ADEWidgets/ADELiveActivityViews.swift +++ b/apps/ios/ADEWidgets/ADELiveActivityViews.swift @@ -80,28 +80,31 @@ struct WorkspaceCompactLeading: View { @Environment(\.accessibilityReduceMotion) private var reduceMotion var body: some View { - if let attention = state.attention { - let tint = WorkspaceStyle.attentionTint(for: attention) - Image(systemName: AttentionIcon.symbol(for: attention.kind)) - .font(.system(size: 14, weight: .semibold)) - .foregroundStyle(tint) - .modifier(BellWiggleCompat(active: !reduceMotion && attention.kind == .awaitingInput)) - .accessibilityLabel(Text(accessibilityLabel(for: attention))) - } else if state.sessions.count >= 2 { - StackedBrandDots( - slugs: state.sessions.prefix(3).map(\.providerSlug), - size: 11 - ) - .accessibilityLabel(Text("\(state.sessions.count) agents running")) - } else if state.sessions.count == 1, let s = state.sessions.first { - BrandDot(slug: s.providerSlug, size: 12, pulse: !reduceMotion && !s.isFailed) - .accessibilityLabel(Text("\(s.providerSlug) is working on \(s.title)")) - } else { - Image(systemName: "sparkles") - .font(.system(size: 13, weight: .semibold)) - .foregroundStyle(ADESharedTheme.statusIdle) - .accessibilityLabel(Text("ADE")) + Group { + if let attention = state.attention { + let tint = WorkspaceStyle.attentionTint(for: attention) + Image(systemName: AttentionIcon.symbol(for: attention.kind)) + .font(.system(size: 14, weight: .semibold)) + .foregroundStyle(tint) + .modifier(BellWiggleCompat(active: !reduceMotion && attention.kind == .awaitingInput)) + .accessibilityLabel(Text(accessibilityLabel(for: attention))) + } else if !state.sessions.isEmpty { + // One or many active sessions — single pulsing green dot, like the + // desktop session-status indicator. + ActiveDotMini( + color: ADESharedTheme.statusSuccess, + pulse: !reduceMotion + ) + .accessibilityLabel(Text("\(state.sessions.count) running")) + } else { + Image(systemName: "sparkles") + .font(.system(size: 13, weight: .semibold)) + .foregroundStyle(ADESharedTheme.statusIdle) + .accessibilityLabel(Text("ADE")) + } } + .frame(maxWidth: 50, maxHeight: 38) + .dynamicTypeSize(.small ... .large) } private func accessibilityLabel(for a: ADESessionAttributes.ContentState.Attention) -> String { @@ -123,37 +126,76 @@ struct WorkspaceCompactTrailing: View { let state: ADESessionAttributes.ContentState var body: some View { - if let attention = state.attention { - let tint = WorkspaceStyle.attentionTint(for: attention) - Text(WorkspaceStyle.shortLabel(for: attention)) - .font(.system(size: 12, weight: .semibold).monospacedDigit()) - .kerning(-0.2) - .foregroundStyle(tint) - .shadow(color: tint.opacity(0.5), radius: 4, x: 0, y: 0) - .lineLimit(1) - .truncationMode(.tail) - .frame(maxWidth: 96, alignment: .trailing) - .accessibilityLabel(Text(WorkspaceStyle.shortLabel(for: attention))) - } else if state.sessions.count >= 2 { - Text("\(state.sessions.count) agents") - .font(.system(size: 12, weight: .semibold).monospacedDigit()) - .kerning(-0.2) - .foregroundStyle(Color(red: 0xF0/255, green: 0xF0/255, blue: 0xF2/255)) - .lineLimit(1) - .frame(maxWidth: 96, alignment: .trailing) - } else if state.sessions.count == 1, let s = state.sessions.first { - TimerLabel( - startedAt: s.startedAt, - color: ADESharedTheme.brandColor(for: s.providerSlug) - ) - .lineLimit(1) - .frame(maxWidth: 78, alignment: .trailing) - } else { - Image(systemName: "moon.zzz.fill") - .font(.system(size: 13, weight: .semibold)) - .foregroundStyle(ADESharedTheme.statusIdle) - .accessibilityLabel(Text("Idle")) + Group { + if let attention = state.attention { + let tint = WorkspaceStyle.attentionTint(for: attention) + Text(WorkspaceStyle.shortLabel(for: attention)) + .font(.system(size: 12, weight: .semibold).monospacedDigit()) + .kerning(-0.2) + .foregroundStyle(tint) + .shadow(color: tint.opacity(0.5), radius: 4, x: 0, y: 0) + .lineLimit(1) + .truncationMode(.tail) + .minimumScaleFactor(0.85) + .frame(maxWidth: 96, alignment: .trailing) + .accessibilityLabel(Text(WorkspaceStyle.shortLabel(for: attention))) + } else if state.sessions.count >= 2 { + Text("\(state.sessions.count) running") + .font(.system(size: 12, weight: .semibold).monospacedDigit()) + .kerning(-0.2) + .foregroundStyle(ADESharedTheme.statusSuccess) + .lineLimit(1) + .truncationMode(.tail) + .minimumScaleFactor(0.85) + .frame(maxWidth: 96, alignment: .trailing) + } else if state.sessions.count == 1, let s = state.sessions.first { + // Single active session — show the chat title compactly. + Text(s.title.isEmpty ? s.providerSlug : s.title) + .font(.system(size: 12, weight: .semibold)) + .kerning(-0.2) + .foregroundStyle(Color(red: 0xF0/255, green: 0xF0/255, blue: 0xF2/255)) + .lineLimit(1) + .truncationMode(.tail) + .minimumScaleFactor(0.85) + .frame(maxWidth: 96, alignment: .trailing) + } else { + Image(systemName: "moon.zzz.fill") + .font(.system(size: 13, weight: .semibold)) + .foregroundStyle(ADESharedTheme.statusIdle) + .accessibilityLabel(Text("Idle")) + } } + .frame(maxWidth: 96, maxHeight: 38, alignment: .trailing) + .dynamicTypeSize(.small ... .large) + } +} + +/// Single small pulsing dot for the Dynamic Island compact leading region. +@available(iOS 16.2, *) +private struct ActiveDotMini: View { + let color: Color + let pulse: Bool + + var body: some View { + ZStack { + if pulse, #available(iOS 17.0, *) { + Circle() + .fill(color) + .frame(width: 10, height: 10) + .phaseAnimator([0, 1]) { circle, phase in + circle + .scaleEffect(phase == 0 ? 1.0 : 1.6) + .opacity(phase == 0 ? 0.45 : 0) + } animation: { _ in + .easeOut(duration: 1.4) + } + } + Circle() + .fill(color) + .frame(width: 10, height: 10) + .shadow(color: color.opacity(0.55), radius: 3) + } + .frame(width: 12, height: 12) } } @@ -217,6 +259,7 @@ struct WorkspaceMinimalGlyph: View { inner(color: color) } .frame(width: 28, height: 28) + .dynamicTypeSize(.small ... .large) .accessibilityLabel(accessibilityLabel) } @@ -229,9 +272,14 @@ struct WorkspaceMinimalGlyph: View { } else if state.sessions.count >= 2 { Text("\(state.sessions.count)") .font(.system(size: 11, weight: .bold).monospacedDigit()) + .lineLimit(1) + .minimumScaleFactor(0.7) .foregroundStyle(color) - } else if let only = state.sessions.first { - BrandDot(slug: only.providerSlug, size: 10, pulse: false) + .frame(maxWidth: 22) + } else if !state.sessions.isEmpty { + Circle() + .fill(color) + .frame(width: 8, height: 8) } else { Image(systemName: "sparkles") .font(.system(size: 11, weight: .semibold)) @@ -262,17 +310,28 @@ struct WorkspaceExpandedLeading: View { var body: some View { Group { if let attention = state.attention { - AttentionBadge(kind: attention.kind, size: 36) - } else if let focused = state.focusedSession { - BrandDot(slug: focused.providerSlug, size: 24, pulse: !focused.isFailed) + let tint = WorkspaceStyle.attentionTint(for: attention) + Image(systemName: AttentionIcon.symbol(for: attention.kind)) + .font(.system(size: 18, weight: .semibold)) + .foregroundStyle(tint) + .frame(width: 28, height: 28) + .background( + Circle().fill(tint.opacity(0.13)) + ) + } else if !state.sessions.isEmpty { + ActiveDotMini(color: ADESharedTheme.statusSuccess, pulse: true) + .scaleEffect(1.4) + .frame(width: 28, height: 28) } else { Image(systemName: "sparkles") - .font(.system(size: 22, weight: .semibold)) + .font(.system(size: 18, weight: .semibold)) .foregroundStyle(ADESharedTheme.brandCursor) + .frame(width: 28, height: 28) } } - .frame(maxWidth: .infinity, alignment: .leading) + .frame(maxWidth: 100, alignment: .leading) .padding(.leading, 4) + .dynamicTypeSize(.small ... .large) } } @@ -283,30 +342,28 @@ struct WorkspaceExpandedTrailing: View { var body: some View { Group { if state.attention != nil { - // Attention occupies the bottom action row; trailing stays - // empty so the header doesn't feel doubled up. EmptyView() } else if state.sessions.count >= 2 { - VStack(alignment: .trailing, spacing: 2) { + VStack(alignment: .trailing, spacing: 1) { Text("\(state.sessions.count)") - .font(.system(size: 20, weight: .bold).monospacedDigit()) - .foregroundStyle(ADESharedTheme.statusAttention) - Text("AGENTS") + .font(.system(size: 16, weight: .bold).monospacedDigit()) + .foregroundStyle(ADESharedTheme.statusSuccess) + .lineLimit(1) + .minimumScaleFactor(0.85) + Text("running") .font(.system(size: 9, weight: .semibold)) + .textCase(.lowercase) .foregroundStyle(.secondary) + .lineLimit(1) + .minimumScaleFactor(0.85) } - } else if let focused = state.focusedSession, state.sessions.count == 1 { - TimerLabel( - startedAt: focused.startedAt, - color: ADESharedTheme.brandColor(for: focused.providerSlug), - fontSize: 14 - ) } else { EmptyView() } } - .frame(maxWidth: .infinity, alignment: .trailing) + .frame(maxWidth: 90, alignment: .trailing) .padding(.trailing, 4) + .dynamicTypeSize(.small ... .large) } } @@ -316,48 +373,52 @@ struct WorkspaceExpandedCenter: View { let attrs: ADESessionAttributes var body: some View { - VStack(alignment: .leading, spacing: 3) { + VStack(alignment: .leading, spacing: 2) { if let attention = state.attention { Text(attention.title) - .font(.system(size: 15, weight: .bold)) - .kerning(-0.2) + .font(.system(size: 14, weight: .semibold)) + .kerning(-0.1) .lineLimit(1) + .truncationMode(.tail) .minimumScaleFactor(0.85) if let subtitle = attention.subtitle, !subtitle.isEmpty { Text(subtitle) - .font(.system(size: 12, weight: .medium)) + .font(.system(size: 11, weight: .medium).monospacedDigit()) .foregroundStyle(.secondary) .lineLimit(1) + .truncationMode(.tail) + .minimumScaleFactor(0.85) } } else if let focused = state.focusedSession { Text(focused.title.isEmpty ? focused.id : focused.title) - .font(.system(size: 15, weight: .bold)) - .kerning(-0.2) + .font(.system(size: 14, weight: .semibold)) + .kerning(-0.1) .lineLimit(1) + .truncationMode(.tail) .minimumScaleFactor(0.85) - if let preview = focused.preview, !preview.isEmpty { - Text("\(focused.providerSlug.capitalized) · \(preview)") - .font(.system(size: 12, weight: .medium)) - .foregroundStyle(.secondary) - .lineLimit(1) - } else { - Text(focused.providerSlug.capitalized) - .font(.system(size: 12, weight: .medium)) - .foregroundStyle(.secondary) - .lineLimit(1) - } - } else { - Text("ADE · No active agents") - .font(.system(size: 15, weight: .bold)) - .kerning(-0.2) - Text(attrs.workspaceName) - .font(.system(size: 12, weight: .medium).monospacedDigit()) + Text(focusedSubtitle(focused)) + .font(.system(size: 11, weight: .medium).monospacedDigit()) .foregroundStyle(.secondary) .lineLimit(1) + .truncationMode(.tail) + .minimumScaleFactor(0.85) + } else { + // Coordinator tears the activity down when nothing is + // active, so this branch is theoretically unreachable. + EmptyView() } } .frame(maxWidth: .infinity, alignment: .leading) .padding(.horizontal, 4) + .dynamicTypeSize(.small ... .large) + } + + private func focusedSubtitle(_ focused: ADESessionAttributes.ContentState.ActiveSession) -> String { + let provider = focused.providerSlug.lowercased() + if let preview = focused.preview, !preview.isEmpty { + return "\(provider) · \(preview)" + } + return "\(provider) · working…" } } @@ -379,14 +440,16 @@ struct WorkspaceExpandedBottom: View { } .frame(maxWidth: .infinity) .padding(.horizontal, 4) + .dynamicTypeSize(.small ... .large) } } // MARK: - Lock Screen -/// 358pt glass card via `.ultraThinMaterial`; MiniGlance trio header; -/// attention card (conditional) + roster rows (≤3 with attention, ≤4 without). -/// Mockup ref: `lock-activity.jsx` 5-85. +/// Edge-to-edge glass card. The system frames the whole thing with the app +/// name + icon already, so we don't repeat them here. Layout: optional +/// attention card → active-sessions roster → counts strip (waiting / idle / +/// PR glance). @available(iOS 16.2, *) struct WorkspaceLockScreenPresentation: View { let state: ADESessionAttributes.ContentState @@ -404,144 +467,66 @@ struct WorkspaceLockScreenPresentation: View { var body: some View { ZStack { - // Ambient tint wash (135° gradient from tint@14% → transparent). LinearGradient( - colors: [tint.opacity(0.14), .clear], + colors: [tint.opacity(0.12), .clear], startPoint: .topLeading, endPoint: .bottomTrailing ) .allowsHitTesting(false) - // Soft radial bloom in the top-left corner in the tint color. RadialGradient( - colors: [tint.opacity(0.22), .clear], + colors: [tint.opacity(0.18), .clear], center: UnitPoint(x: 0.08, y: 0.08), startRadius: 0, endRadius: 220 ) .allowsHitTesting(false) - VStack(alignment: .leading, spacing: 10) { - header - content - } - .padding(14) + content + .padding(.horizontal, 14) + .padding(.vertical, 12) + .frame(maxWidth: .infinity, alignment: .leading) } .background(.ultraThinMaterial) .overlay( - // Soft top-to-bottom white highlight — gives the glass its "wet" feel. RoundedRectangle(cornerRadius: 22, style: .continuous) - .fill( - LinearGradient( - colors: [Color.white.opacity(0.08), .clear], - startPoint: .top, - endPoint: .center - ) - ) + .strokeBorder(Color.white.opacity(0.10), lineWidth: 0.5) .allowsHitTesting(false) ) - .overlay( - // 1pt inner highlight (white top → fade). - RoundedRectangle(cornerRadius: 22, style: .continuous) - .strokeBorder( - LinearGradient( - colors: [Color.white.opacity(0.18), Color.white.opacity(0.02)], - startPoint: .top, - endPoint: .bottom - ), - lineWidth: 1 - ) - .allowsHitTesting(false) - ) - .overlay( - RoundedRectangle(cornerRadius: 22, style: .continuous) - .stroke(Color.white.opacity(0.12), lineWidth: 0.5) - ) .clipShape(RoundedRectangle(cornerRadius: 22, style: .continuous)) - .shadow(color: Color.black.opacity(0.40), radius: 18, x: 0, y: 6) - .frame(maxWidth: 358) - // Lock Screen Live Activities live in a fixed-height card — clamp - // Dynamic Type so accessibility sizes don't overflow the chrome. - // iOS still renders legibly; users at larger sizes can tap through - // to the in-app Attention Drawer for the full-size presentation. .dynamicTypeSize(.small ... .accessibility1) } - private var header: some View { - HStack(spacing: 7) { - AdeMark(size: 16) - Text("ADE") - .font(.system(size: 13, weight: .bold)) - .kerning(-0.1) - Text("· \(attrs.workspaceName)") - .font(.system(size: 12, weight: .medium).monospacedDigit()) - .foregroundStyle(.secondary) - .lineLimit(1) - Spacer(minLength: 8) - MiniGlanceStrip(state: state) - } - } - @ViewBuilder private var content: some View { - if let attention = state.attention { - AttentionLockCard(attention: attention) + VStack(alignment: .leading, spacing: 10) { + if let attention = state.attention { + AttentionLockCard(attention: attention) + } if !state.sessions.isEmpty { - Divider().opacity(0.25) - VStack(spacing: 9) { - ForEach(state.sessions.prefix(2)) { session in + VStack(spacing: 8) { + ForEach(state.sessions.prefix(state.attention == nil ? 4 : 2)) { session in LockRosterRow(session: session) } } - .padding(.top, 1) } - } else if !state.sessions.isEmpty { - VStack(spacing: 9) { - ForEach(state.sessions.prefix(3)) { session in - LockRosterRow(session: session) - } + if hasCounts { + CountsStrip(state: state) } - } else { - Text("Nothing active right now.") - .font(.system(size: 12, weight: .medium)) - .foregroundStyle(.secondary) } } -} -// MARK: - Lock-screen building blocks - -@available(iOS 16.2, *) -private struct MiniGlanceStrip: View { - let state: ADESessionAttributes.ContentState - - var body: some View { - HStack(spacing: 5) { - if state.failingCheckCount > 0 { - MiniGlance( - icon: "exclamationmark.triangle.fill", - count: state.failingCheckCount, - color: ADESharedTheme.statusFailed - ) - } - if state.awaitingReviewCount > 0 { - MiniGlance( - icon: "eye.fill", - count: state.awaitingReviewCount, - color: ADESharedTheme.warningAmber - ) - } - if state.mergeReadyCount > 0 { - MiniGlance( - icon: "checkmark.seal.fill", - count: state.mergeReadyCount, - color: ADESharedTheme.statusSuccess - ) - } - } + private var hasCounts: Bool { + state.awaitingInputCount > 0 + || state.idleCount > 0 + || state.failingCheckCount > 0 + || state.awaitingReviewCount > 0 + || state.mergeReadyCount > 0 } } +// MARK: - Lock-screen building blocks + @available(iOS 16.2, *) private struct AttentionLockCard: View { let attention: ADESessionAttributes.ContentState.Attention @@ -549,18 +534,21 @@ private struct AttentionLockCard: View { var body: some View { let tint = WorkspaceStyle.attentionTint(for: attention) VStack(alignment: .leading, spacing: 10) { - HStack(alignment: .top, spacing: 12) { - AttentionBadge(kind: attention.kind, size: 30) - VStack(alignment: .leading, spacing: 3) { + HStack(alignment: .center, spacing: 10) { + Image(systemName: AttentionIcon.symbol(for: attention.kind)) + .font(.system(size: 13, weight: .semibold)) + .foregroundStyle(tint) + .frame(width: 18, height: 18) + VStack(alignment: .leading, spacing: 1) { Text(attention.title) - .font(.system(size: 14, weight: .bold)) - .kerning(-0.2) - .lineLimit(2) + .font(.system(size: 13.5, weight: .semibold)) + .kerning(-0.1) + .lineLimit(1) if let subtitle = attention.subtitle, !subtitle.isEmpty { Text(subtitle) - .font(.system(size: 12, weight: .medium)) + .font(.system(size: 11, weight: .medium).monospacedDigit()) .foregroundStyle(.secondary) - .lineLimit(2) + .lineLimit(1) } } Spacer(minLength: 0) @@ -570,54 +558,27 @@ private struct AttentionLockCard: View { .padding(12) .background( RoundedRectangle(cornerRadius: 14, style: .continuous) - .fill(tint.opacity(0.16)) - ) - .background( - // Soft tint bloom in the top-left of the attention card. - RoundedRectangle(cornerRadius: 14, style: .continuous) - .fill( - RadialGradient( - colors: [tint.opacity(0.28), .clear], - center: UnitPoint(x: 0.05, y: 0.1), - startRadius: 0, - endRadius: 160 - ) - ) + .fill(tint.opacity(0.12)) ) .overlay( - // Top white highlight to lift the tint-tile off the glass behind. RoundedRectangle(cornerRadius: 14, style: .continuous) - .fill( - LinearGradient( - colors: [Color.white.opacity(0.10), .clear], - startPoint: .top, - endPoint: .center - ) - ) - .allowsHitTesting(false) + .strokeBorder(tint.opacity(0.25), lineWidth: 0.5) ) - .overlay( - RoundedRectangle(cornerRadius: 14, style: .continuous) - .strokeBorder( - LinearGradient( - colors: [tint.opacity(0.55), tint.opacity(0.15)], - startPoint: .top, - endPoint: .bottom - ), - lineWidth: 0.75 - ) - ) - .shadow(color: tint.opacity(0.40), radius: 10, x: 0, y: 4) } } +/// Single-line roster entry, modelled after the desktop `SessionCard`. Each +/// row in this list is *actively running* — the SyncService filter guarantees +/// it — so the only state to render is "running" (pulsing green dot) or the +/// rare "failed" terminal state. @available(iOS 16.2, *) private struct LockRosterRow: View { let session: ADESessionAttributes.ContentState.ActiveSession + @Environment(\.accessibilityReduceMotion) private var reduceMotion var body: some View { - HStack(spacing: 12) { - BrandDot(slug: session.providerSlug, size: 12, pulse: session.isAwaitingInput) + HStack(alignment: .center, spacing: 10) { + ActiveDot(failed: session.isFailed, pulse: !reduceMotion && !session.isFailed) VStack(alignment: .leading, spacing: 1) { Text(session.title.isEmpty ? session.id : session.title) .font(.system(size: 13.5, weight: .semibold)) @@ -628,8 +589,12 @@ private struct LockRosterRow: View { .foregroundStyle(.secondary) .lineLimit(1) } - Spacer(minLength: 4) - trailingStatus + .frame(maxWidth: .infinity, alignment: .leading) + if session.isFailed { + Text("failed") + .font(.system(size: 10.5, weight: .semibold).monospacedDigit()) + .foregroundStyle(ADESharedTheme.statusFailed) + } } .accessibilityElement(children: .combine) .accessibilityLabel(Text(accessibilityLabel)) @@ -643,33 +608,119 @@ private struct LockRosterRow: View { return "\(providerName) · working…" } - @ViewBuilder - private var trailingStatus: some View { + private var accessibilityLabel: String { if session.isFailed { - Text("failed") - .font(.system(size: 10.5, weight: .semibold).monospacedDigit()) - .foregroundStyle(ADESharedTheme.statusFailed) - } else if session.isAwaitingInput { - Text("waiting") - .font(.system(size: 10.5, weight: .semibold).monospacedDigit()) - .foregroundStyle(ADESharedTheme.warningAmber) - } else { - TimerLabel( - startedAt: session.startedAt, - color: ADESharedTheme.statusIdle, - fontSize: 10.5 - ) + return "\(session.providerSlug) on \(session.title), failed" } + return "\(session.providerSlug) on \(session.title), running" } +} - private var accessibilityLabel: String { - if session.isAwaitingInput { - return "\(session.providerSlug) on \(session.title), awaiting input" +/// 8pt status dot. Solid green with a soft phased halo when running, solid red +/// when failed. Mirrors the desktop session-status dot. +@available(iOS 16.2, *) +private struct ActiveDot: View { + let failed: Bool + let pulse: Bool + + var body: some View { + let color: Color = failed ? ADESharedTheme.statusFailed : ADESharedTheme.statusSuccess + ZStack { + if pulse, #available(iOS 17.0, *) { + Circle() + .fill(color) + .frame(width: 8, height: 8) + .phaseAnimator([0, 1]) { circle, phase in + circle + .scaleEffect(phase == 0 ? 1.0 : 1.7) + .opacity(phase == 0 ? 0.45 : 0) + } animation: { _ in + .easeOut(duration: 1.5) + } + } + Circle() + .fill(color) + .frame(width: 8, height: 8) + .shadow(color: color.opacity(0.55), radius: 3) } - if session.isFailed { - return "\(session.providerSlug) on \(session.title), failed" + .frame(width: 8, height: 8) + .accessibilityHidden(true) + } +} + +/// Counts row shown beneath the roster: waiting-for-input + idle chats + the +/// existing PR glance. Kept terse — small all-caps labels with monospaced +/// digits, like the desktop status pills. +@available(iOS 16.2, *) +private struct CountsStrip: View { + let state: ADESessionAttributes.ContentState + + var body: some View { + HStack(spacing: 6) { + if state.awaitingInputCount > 0 { + CountChip( + icon: "bell.fill", + label: chatLabel(state.awaitingInputCount, "waiting"), + color: ADESharedTheme.warningAmber + ) + } + if state.idleCount > 0 { + CountChip( + icon: "moon.zzz.fill", + label: chatLabel(state.idleCount, "idle"), + color: ADESharedTheme.statusIdle + ) + } + if state.failingCheckCount > 0 { + CountChip( + icon: "exclamationmark.triangle.fill", + label: "\(state.failingCheckCount) ci", + color: ADESharedTheme.statusFailed + ) + } + if state.awaitingReviewCount > 0 { + CountChip( + icon: "eye.fill", + label: "\(state.awaitingReviewCount) review", + color: ADESharedTheme.warningAmber + ) + } + if state.mergeReadyCount > 0 { + CountChip( + icon: "checkmark.seal.fill", + label: "\(state.mergeReadyCount) ready", + color: ADESharedTheme.statusSuccess + ) + } + Spacer(minLength: 0) } - return "\(session.providerSlug) on \(session.title), running" + } + + private func chatLabel(_ count: Int, _ verb: String) -> String { + "\(count) \(verb)" + } +} + +@available(iOS 16.2, *) +private struct CountChip: View { + let icon: String + let label: String + let color: Color + + var body: some View { + HStack(spacing: 4) { + Image(systemName: icon) + .font(.system(size: 9, weight: .bold)) + Text(label) + .font(.system(size: 10.5, weight: .semibold).monospacedDigit()) + .textCase(.lowercase) + } + .foregroundStyle(color) + .padding(.vertical, 3) + .padding(.horizontal, 7) + .background( + Capsule(style: .continuous).fill(color.opacity(0.13)) + ) } } @@ -678,70 +729,51 @@ private struct LockRosterRow: View { @available(iOS 16.2, *) private struct ExpandedRosterStrip: View { let sessions: [ADESessionAttributes.ContentState.ActiveSession] + @Environment(\.accessibilityReduceMotion) private var reduceMotion var body: some View { VStack(spacing: 7) { ForEach(sessions) { session in HStack(spacing: 10) { - BrandDot(slug: session.providerSlug, size: 10, pulse: session.isAwaitingInput) + ActiveDot(failed: session.isFailed, pulse: !reduceMotion && !session.isFailed) Text(session.title.isEmpty ? session.id : session.title) .font(.system(size: 12, weight: .semibold)) .kerning(-0.1) .lineLimit(1) .minimumScaleFactor(0.85) .frame(maxWidth: .infinity, alignment: .leading) - trailing(for: session) + Text(session.providerSlug.lowercased()) + .font(.system(size: 10.5, weight: .medium).monospacedDigit()) + .foregroundStyle(.secondary) + .lineLimit(1) } } } } - - @ViewBuilder - private func trailing(for session: ADESessionAttributes.ContentState.ActiveSession) -> some View { - if session.isFailed { - Text("failed") - .font(.system(size: 10.5, weight: .semibold).monospacedDigit()) - .foregroundStyle(ADESharedTheme.statusFailed) - } else if session.isAwaitingInput { - Text("waiting") - .font(.system(size: 10.5, weight: .semibold).monospacedDigit()) - .foregroundStyle(ADESharedTheme.warningAmber) - } else { - TimerLabel( - startedAt: session.startedAt, - color: ADESharedTheme.statusIdle, - fontSize: 10.5 - ) - } - } } @available(iOS 16.2, *) private struct FocusedCardBottom: View { let session: ADESessionAttributes.ContentState.ActiveSession + @Environment(\.accessibilityReduceMotion) private var reduceMotion var body: some View { - VStack(alignment: .leading, spacing: 10) { - ProgressBar( - progress: session.progress ?? 0.62, - color: ADESharedTheme.brandColor(for: session.providerSlug), - shimmer: !session.isFailed, - height: 4 - ) + HStack(spacing: 8) { + ActiveDot(failed: session.isFailed, pulse: !reduceMotion && !session.isFailed) + Text(subtitle) + .font(.system(size: 11, weight: .medium).monospacedDigit()) + .foregroundStyle(.secondary) + .lineLimit(1) + .frame(maxWidth: .infinity, alignment: .leading) + } + } - HStack { - Text(session.preview ?? "running") - .font(.system(size: 10.5, weight: .medium).monospacedDigit()) - .foregroundStyle(.secondary) - .lineLimit(1) - Spacer(minLength: 8) - TimerLabel( - startedAt: session.startedAt, - color: ADESharedTheme.statusIdle, - fontSize: 10.5 - ) - } + private var subtitle: String { + let provider = session.providerSlug.lowercased() + if let preview = session.preview, !preview.isEmpty { + return "\(provider) · \(preview)" } + return "\(provider) · working…" } } @@ -750,35 +782,9 @@ private struct ExpandedGlanceStrip: View { let state: ADESessionAttributes.ContentState var body: some View { - HStack(spacing: 8) { - if state.failingCheckCount > 0 { - GlanceChip( - icon: "exclamationmark.triangle.fill", - label: "CI \(state.failingCheckCount)", - color: ADESharedTheme.statusFailed - ) - } - if state.awaitingReviewCount > 0 { - GlanceChip( - icon: "eye.fill", - label: "Review \(state.awaitingReviewCount)", - color: ADESharedTheme.warningAmber - ) - } - if state.mergeReadyCount > 0 { - GlanceChip( - icon: "checkmark.seal.fill", - label: "Ready \(state.mergeReadyCount)", - color: ADESharedTheme.statusSuccess - ) - } - if state.pendingPrCount == 0 { - Text("Nothing pending") - .font(.system(size: 11, weight: .semibold).monospacedDigit()) - .foregroundStyle(.secondary) - } - Spacer(minLength: 0) - } + // Re-uses the same chips rendered on the Lock Screen so the visual + // language is identical across surfaces. + CountsStrip(state: state) } } diff --git a/apps/ios/ADEWidgets/ADELockScreenWidget.swift b/apps/ios/ADEWidgets/ADELockScreenWidget.swift index 484e7ee7f..1cd79d726 100644 --- a/apps/ios/ADEWidgets/ADELockScreenWidget.swift +++ b/apps/ios/ADEWidgets/ADELockScreenWidget.swift @@ -42,11 +42,8 @@ struct LockScreenWidgetEntryView: View { } private var destinationURL: URL { - if let awaiting = entry.snapshot.agents.first(where: \.awaitingInput), - let url = URL(string: "ade://session/\(awaiting.sessionId)") { - return url - } - + // Awaiting-input is now a count, not a per-agent flag — surface as a + // generic deep link to the workspace approvals view via PR fallback. let openPrs = entry.snapshot.prs.filter { $0.state == "open" } if let focusPr = openPrs.first(where: { $0.checks == "failing" }) ?? openPrs.first(where: { $0.review == "changes_requested" || $0.review == "pending" }) @@ -67,27 +64,38 @@ struct LockScreenRectangularView: View { var body: some View { let summary = ADESharedContainer.inlineSummary(for: snapshot) - let progress = averageProgress() + let running = snapshot.runningAgents + let isRunning = !running.isEmpty + let secondary = secondaryLine() + let progress = averageProgress(running: running) return ZStack { AccessoryWidgetBackground() - VStack(alignment: .leading, spacing: 4) { - HStack(spacing: 6) { - AdeMark(size: 13) - Text("Workspace") - .font(.system(size: 12, weight: .bold)) + VStack(alignment: .leading, spacing: 3) { + HStack(spacing: 5) { + Image(systemName: isRunning ? "circle.dotted" : "moon.zzz") + .font(.system(size: 11, weight: .semibold)) + .widgetAccentable() + Text(summary) + .font(.system(size: 13, weight: .semibold)) + .lineLimit(1) + .minimumScaleFactor(0.85) + .frame(maxWidth: .infinity, alignment: .leading) + } + if let secondary { + Text(secondary) + .font(.system(size: 11, weight: .regular)) + .foregroundStyle(.secondary) .lineLimit(1) + .frame(maxWidth: .infinity, alignment: .leading) + } + if isRunning { + ProgressView(value: progress) + .progressViewStyle(.linear) + .tint(.primary) + .frame(height: 3) + .widgetAccentable() } - Text(summary) - .font(.system(size: 12, weight: .semibold).monospaced()) - .kerning(-0.2) - .lineLimit(1) - .frame(maxWidth: .infinity, alignment: .leading) - ProgressView(value: progress) - .progressViewStyle(.linear) - .tint(.primary) - .frame(height: 4) - .widgetAccentable() } .padding(.horizontal, 2) .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading) @@ -98,11 +106,22 @@ struct LockScreenRectangularView: View { .accessibilityValue(summary) } - private func averageProgress() -> Double { - let active = snapshot.agents.filter { $0.status == "running" || $0.awaitingInput } - guard !active.isEmpty else { return 0 } - let sum = active.reduce(0.0) { $0 + ($1.progress ?? 0) } - return min(1, max(0, sum / Double(active.count))) + private func secondaryLine() -> String? { + let openPrs = snapshot.prs.filter { $0.state == "open" }.count + var parts: [String] = [] + if snapshot.awaitingInputCount > 0 { + parts.append("\(snapshot.awaitingInputCount) waiting") + } + if openPrs > 0 { + parts.append("\(openPrs) PR\(openPrs == 1 ? "" : "s")") + } + return parts.isEmpty ? nil : parts.joined(separator: " · ") + } + + private func averageProgress(running: [AgentSnapshot]) -> Double { + guard !running.isEmpty else { return 0 } + let sum = running.reduce(0.0) { $0 + ($1.progress ?? 0) } + return min(1, max(0, sum / Double(running.count))) } } @@ -113,28 +132,55 @@ struct LockScreenCircularView: View { @Environment(\.isLuminanceReduced) private var isLuminanceReduced var body: some View { - let active = snapshot.agents.filter { $0.status == "running" || $0.awaitingInput }.count - let total = max(active, max(1, snapshot.agents.count)) - - return Gauge(value: Double(active), in: 0...Double(total)) { - EmptyView() - } currentValueLabel: { - VStack(spacing: 0) { - Text("\(active)") - .font(.system(size: 20, weight: .black)) - .kerning(-0.5) - Text("AGENTS") - .font(.system(size: 8.5).monospaced()) - .tracking(0.2) - .textCase(.uppercase) + let active = snapshot.runningAgents.count + let waiting = snapshot.awaitingInputCount + + return Group { + if active > 0 { + Gauge(value: Double(active), in: 0...Double(max(active, 1))) { + EmptyView() + } currentValueLabel: { + VStack(spacing: -1) { + Text("\(active)") + .font(.system(size: active >= 10 ? 16 : 20, weight: .black)) + .kerning(-0.5) + .minimumScaleFactor(0.7) + .lineLimit(1) + Text("RUN") + .font(.system(size: 8).monospaced()) + .tracking(0.3) + } + } + .gaugeStyle(.accessoryCircular) + } else if waiting > 0 { + Gauge(value: 1, in: 0...1) { + EmptyView() + } currentValueLabel: { + VStack(spacing: -1) { + Text("\(waiting)") + .font(.system(size: waiting >= 10 ? 16 : 20, weight: .black)) + .kerning(-0.5) + .minimumScaleFactor(0.7) + .lineLimit(1) + Text("WAIT") + .font(.system(size: 8).monospaced()) + .tracking(0.3) + } + } + .gaugeStyle(.accessoryCircular) + } else { + ZStack { + AccessoryWidgetBackground() + Image(systemName: "moon.zzz") + .font(.system(size: 18, weight: .semibold)) + } } } - .gaugeStyle(.accessoryCircular) .widgetAccentable() .opacity(isLuminanceReduced ? 0.85 : 1) .accessibilityElement(children: .combine) - .accessibilityLabel("ADE active agents") - .accessibilityValue("\(active) of \(total)") + .accessibilityLabel("ADE workspace") + .accessibilityValue(active > 0 ? "\(active) running" : (waiting > 0 ? "\(waiting) waiting" : "idle")) } } @@ -145,15 +191,9 @@ struct LockScreenInlineView: View { var body: some View { let summary = ADESharedContainer.inlineSummary(for: snapshot) - Label { - Text(summary) - .dynamicTypeSize(.small ... .large) - } icon: { - Image(systemName: "sparkles") - .accessibilityHidden(true) - } - .accessibilityLabel("ADE") - .accessibilityValue(summary) + Text(summary) + .accessibilityLabel("ADE") + .accessibilityValue(summary) } } diff --git a/apps/ios/ADEWidgets/ADEWidgetPreviewData.swift b/apps/ios/ADEWidgets/ADEWidgetPreviewData.swift index 59e19c73c..de2548ce6 100644 --- a/apps/ios/ADEWidgets/ADEWidgetPreviewData.swift +++ b/apps/ios/ADEWidgets/ADEWidgetPreviewData.swift @@ -69,6 +69,150 @@ enum ADEWidgetPreviewData { static let emptySnapshot = WorkspaceSnapshot.empty + // MARK: - Realistic-shape ADE data (sanitized fixture, no real workspace data) + // + // Mirrors the shape of a real workspace snapshot — one running chat, a + // couple of open PRs with failing CI — but every identifier, title, and + // branch name here is synthetic so it's safe to ship in previews. + + static let realCurrentAgents: [AgentSnapshot] = [ + AgentSnapshot( + sessionId: "00000000-0000-0000-0000-00000000A001", + provider: "codex-chat", + title: "Refactor lane sidebar", + status: "running", + awaitingInput: false, + lastActivityAt: Date().addingTimeInterval(-22 * 60), + elapsedSeconds: 22 * 60, + preview: "All passed.", + progress: nil, + phase: nil, + toolCalls: 0 + ), + ] + + static let realCurrentPrs: [PrSnapshot] = [ + PrSnapshot( + id: "00000000-0000-0000-0000-0000000000B1", + number: 206, + title: "Add notification banner", + checks: "failing", + review: "none", + state: "open", + mergeReady: false, + branch: "feature/notifications" + ), + PrSnapshot( + id: "00000000-0000-0000-0000-0000000000B2", + number: 205, + title: "Stabilize lane deep-link routing", + checks: "failing", + review: "none", + state: "open", + mergeReady: false, + branch: "feature/lane-deep-links" + ), + ] + + /// Realistic-shape snapshot: 1 running chat + 2 open PRs with failing CI. + static let realCurrentSnapshot = WorkspaceSnapshot( + generatedAt: Date(), + agents: realCurrentAgents, + prs: realCurrentPrs, + connection: "connected", + awaitingInputCount: 0, + idleCount: 0 + ) + + /// Same real workspace, but overlaid with a couple of synthetic counts so + /// you can preview "what does it look like when something's actually + /// happening" — 2 chats waiting on you, 1 idle, plus the real failing-CI + /// PRs from your branch. + static let realRichSnapshot = WorkspaceSnapshot( + generatedAt: Date(), + agents: realCurrentAgents, + prs: realCurrentPrs, + connection: "connected", + awaitingInputCount: 2, + idleCount: 1 + ) + + /// Same real PRs, no running chats — what the surfaces look like when only + /// PR-side signals are active. Useful for "do the count strips read + /// correctly when the roster is empty." + static let realPrsOnlySnapshot = WorkspaceSnapshot( + generatedAt: Date(), + agents: [], + prs: realCurrentPrs, + connection: "connected", + awaitingInputCount: 0, + idleCount: 0 + ) + + // MARK: - Real-data Live Activity ContentStates + + static let realCurrentActiveSessions: [ADESessionAttributes.ContentState.ActiveSession] = realCurrentAgents.map { snap in + .init( + id: snap.sessionId, + providerSlug: snap.provider, + title: snap.title ?? snap.sessionId, + isAwaitingInput: snap.awaitingInput, + isFailed: snap.status.lowercased() == "failed", + startedAt: snap.lastActivityAt.addingTimeInterval(-Double(snap.elapsedSeconds)), + progress: snap.progress, + preview: snap.preview + ) + } + + /// LA ContentState: 1 running codex-chat, no attention, 2 failing PRs. + static let REAL_CURRENT = ADESessionAttributes.ContentState( + sessions: realCurrentActiveSessions, + attention: nil, + failingCheckCount: 2, + awaitingReviewCount: 0, + mergeReadyCount: 0, + awaitingInputCount: 0, + idleCount: 0, + generatedAt: previewNow + ) + + /// LA ContentState: real chat + the imagined "X waiting for input" + /// attention banner derived from the synthetic count. Lets you see the + /// CountsStrip + AttentionLockCard with realistic content. + static let REAL_RICH = ADESessionAttributes.ContentState( + sessions: realCurrentActiveSessions, + attention: ADESessionAttributes.ContentState.Attention( + kind: .awaitingInput, + title: "2 chats waiting for input", + subtitle: "Tap to respond" + ), + failingCheckCount: 2, + awaitingReviewCount: 0, + mergeReadyCount: 0, + awaitingInputCount: 2, + idleCount: 1, + generatedAt: previewNow + ) + + /// LA ContentState: only PR signals, no roster — what the LA looks like + /// when CI is failing on the open PRs and nothing else is going on. + static let REAL_PRS_ONLY = ADESessionAttributes.ContentState( + sessions: [], + attention: ADESessionAttributes.ContentState.Attention( + kind: .ciFailing, + title: "PR #206 · CI failing", + subtitle: "Add notification banner", + prId: "00000000-0000-0000-0000-0000000000B1", + prNumber: 206 + ), + failingCheckCount: 2, + awaitingReviewCount: 0, + mergeReadyCount: 0, + awaitingInputCount: 0, + idleCount: 0, + generatedAt: previewNow + ) + // MARK: - Live Activity ContentState fixtures (mirrors app.jsx) /// Anchor used so all "startedAt" offsets stay stable within a preview @@ -76,8 +220,8 @@ enum ADEWidgetPreviewData { /// relative timestamps across widgets + islands in the same canvas. static let previewNow = Date() - /// 4-session roster from `app.jsx` `SESSIONS` — claude / codex / - /// cursor / opencode. Awaiting-input lives on s2. + /// Active-only roster (sessions that are *currently producing output*). + /// Awaiting-input + idle chats live in the counts on ContentState now. static let previewSessions: [ADESessionAttributes.ContentState.ActiveSession] = [ ADESessionAttributes.ContentState.ActiveSession( id: "s1", @@ -89,16 +233,6 @@ enum ADEWidgetPreviewData { progress: 0.68, preview: "Running unit tests · src/billing/*" ), - ADESessionAttributes.ContentState.ActiveSession( - id: "s2", - providerSlug: "codex", - title: "auth-refactor", - isAwaitingInput: true, - isFailed: false, - startedAt: previewNow.addingTimeInterval(-8 * 60), - progress: 0.34, - preview: "Needs approval · DETACH PARTITION sessions_2025_q3" - ), ADESessionAttributes.ContentState.ActiveSession( id: "s3", providerSlug: "cursor", @@ -138,6 +272,8 @@ enum ADEWidgetPreviewData { failingCheckCount: 2, awaitingReviewCount: 1, mergeReadyCount: 1, + awaitingInputCount: 1, + idleCount: 2, generatedAt: previewNow ) @@ -147,6 +283,8 @@ enum ADEWidgetPreviewData { failingCheckCount: 0, awaitingReviewCount: 0, mergeReadyCount: 1, + awaitingInputCount: 0, + idleCount: 0, generatedAt: previewNow ) @@ -156,6 +294,8 @@ enum ADEWidgetPreviewData { failingCheckCount: 0, awaitingReviewCount: 0, mergeReadyCount: 0, + awaitingInputCount: 0, + idleCount: 0, generatedAt: previewNow ) @@ -166,14 +306,14 @@ enum ADEWidgetPreviewData { sessions: previewSessions, attention: ADESessionAttributes.ContentState.Attention( kind: .awaitingInput, - title: "Codex · auth-refactor", - subtitle: "3 file writes + 1 destructive SQL need approval", - providerSlug: "codex", - sessionId: "s2" + title: "1 chat waiting for input", + subtitle: "Tap to respond" ), failingCheckCount: 2, awaitingReviewCount: 1, mergeReadyCount: 1, + awaitingInputCount: 1, + idleCount: 2, generatedAt: previewNow ), .failed: ADESessionAttributes.ContentState( @@ -277,4 +417,103 @@ enum ADEWidgetPreviewData { ADEWorkspaceEntry(date: .now, snapshot: ADEWidgetPreviewData.populatedSnapshot) } +// MARK: - Realistic-shape previews +// +// Synthetic snapshots whose shape mirrors a real workspace: +// • 1 running codex-chat +// • 2 open PRs both with failing CI +// Three view conditions: as-is, with synthetic awaiting/idle counts (rich), +// and PRs-only (no chat). + +@available(iOS 17.0, *) +#Preview("REAL · Small · current", as: .systemSmall) { + ADEWorkspaceWidget() +} timeline: { + ADEWorkspaceEntry(date: .now, snapshot: ADEWidgetPreviewData.realCurrentSnapshot) +} + +@available(iOS 17.0, *) +#Preview("REAL · Small · rich", as: .systemSmall) { + ADEWorkspaceWidget() +} timeline: { + ADEWorkspaceEntry(date: .now, snapshot: ADEWidgetPreviewData.realRichSnapshot) +} + +@available(iOS 17.0, *) +#Preview("REAL · Small · PRs only", as: .systemSmall) { + ADEWorkspaceWidget() +} timeline: { + ADEWorkspaceEntry(date: .now, snapshot: ADEWidgetPreviewData.realPrsOnlySnapshot) +} + +@available(iOS 17.0, *) +#Preview("REAL · Medium · agents", as: .systemMedium) { + ADEWorkspaceWidget() +} timeline: { + ADEWorkspaceEntry(date: .now, snapshot: ADEWidgetPreviewData.realCurrentSnapshot, variant: .agents) +} + +@available(iOS 17.0, *) +#Preview("REAL · Medium · prs", as: .systemMedium) { + ADEWorkspaceWidget() +} timeline: { + ADEWorkspaceEntry(date: .now, snapshot: ADEWidgetPreviewData.realCurrentSnapshot, variant: .prs) +} + +@available(iOS 17.0, *) +#Preview("REAL · Medium · rich", as: .systemMedium) { + ADEWorkspaceWidget() +} timeline: { + ADEWorkspaceEntry(date: .now, snapshot: ADEWidgetPreviewData.realRichSnapshot, variant: .agents) +} + +@available(iOS 17.0, *) +#Preview("REAL · Large · current", as: .systemLarge) { + ADEWorkspaceWidget() +} timeline: { + ADEWorkspaceEntry(date: .now, snapshot: ADEWidgetPreviewData.realCurrentSnapshot) +} + +@available(iOS 17.0, *) +#Preview("REAL · Large · rich", as: .systemLarge) { + ADEWorkspaceWidget() +} timeline: { + ADEWorkspaceEntry(date: .now, snapshot: ADEWidgetPreviewData.realRichSnapshot) +} + +@available(iOS 17.0, *) +#Preview("REAL · Lock Rect · current", as: .accessoryRectangular) { + ADELockScreenWidget() +} timeline: { + ADEWorkspaceEntry(date: .now, snapshot: ADEWidgetPreviewData.realCurrentSnapshot) +} + +@available(iOS 17.0, *) +#Preview("REAL · Lock Rect · rich", as: .accessoryRectangular) { + ADELockScreenWidget() +} timeline: { + ADEWorkspaceEntry(date: .now, snapshot: ADEWidgetPreviewData.realRichSnapshot) +} + +@available(iOS 17.0, *) +#Preview("REAL · Lock Circular · current", as: .accessoryCircular) { + ADELockScreenWidget() +} timeline: { + ADEWorkspaceEntry(date: .now, snapshot: ADEWidgetPreviewData.realCurrentSnapshot) +} + +@available(iOS 17.0, *) +#Preview("REAL · Lock Inline · current", as: .accessoryInline) { + ADELockScreenWidget() +} timeline: { + ADEWorkspaceEntry(date: .now, snapshot: ADEWidgetPreviewData.realCurrentSnapshot) +} + +@available(iOS 17.0, *) +#Preview("REAL · Lock Inline · rich", as: .accessoryInline) { + ADELockScreenWidget() +} timeline: { + ADEWorkspaceEntry(date: .now, snapshot: ADEWidgetPreviewData.realRichSnapshot) +} + #endif diff --git a/apps/ios/ADEWidgets/ADEWorkspaceWidgetViews.swift b/apps/ios/ADEWidgets/ADEWorkspaceWidgetViews.swift index 15a9b7a28..38cbbe252 100644 --- a/apps/ios/ADEWidgets/ADEWorkspaceWidgetViews.swift +++ b/apps/ios/ADEWidgets/ADEWorkspaceWidgetViews.swift @@ -114,56 +114,40 @@ struct WorkspaceSmallView: View { return Link(destination: destination) { VStack(alignment: .leading, spacing: 0) { HStack(alignment: .center, spacing: 0) { - if let agent = focus { - BrandDotOrDim(slug: agent.provider, size: 14, pulse: agent.awaitingInput, accented: accented) - } else { - BrandDotOrDim(slug: "cto", size: 14, pulse: false, accented: accented) - } + SmallStatusDot(running: focus != nil) Spacer() - Text(smallStatusLabel(for: focus)) - .font(.system(size: 10, weight: .semibold).monospaced()) - .tracking(0.4) + Text(smallStatusLabel(for: focus, snapshot: snapshot).lowercased()) + .font(.system(size: 10.5, weight: .semibold).monospacedDigit()) + .tracking(0.2) .foregroundStyle(accented ? Color.primary : WorkspaceWidgetPalette.textSecondary) } - Spacer(minLength: 16) + Spacer(minLength: 12) - Text(focus?.title ?? "No active agents") - .font(.system(size: 16, weight: .bold)) - .kerning(-0.3) - .lineSpacing(2) + Text(smallTitle(focus: focus, snapshot: snapshot)) + .font(.system(size: 13.5, weight: .semibold)) + .kerning(-0.1) .foregroundStyle(accented ? Color.primary : WorkspaceWidgetPalette.textPrimary) - .lineLimit(3) - .minimumScaleFactor(0.8) + .lineLimit(2) + .minimumScaleFactor(0.9) .multilineTextAlignment(.leading) .frame(maxWidth: .infinity, alignment: .leading) Spacer(minLength: 0) if let agent = focus { - VStack(alignment: .leading, spacing: 8) { - ProgressBar( - progress: agent.progress ?? 0, - color: accented ? Color.primary : ADESharedTheme.brandColor(for: agent.provider), - shimmer: false, - height: 3 - ) - HStack { - Text(agent.provider.lowercased()) - .font(.system(size: 10).monospaced()) - .foregroundStyle(accented ? Color.primary : WorkspaceWidgetPalette.textTertiary) - Spacer() - TimerLabel( - startedAt: agent.lastActivityAt.addingTimeInterval(-Double(agent.elapsedSeconds)), - color: accented ? Color.primary : WorkspaceWidgetPalette.textTertiary, - fontSize: 10 - ) - } - } - } else { - Text("Tap to open ADE") - .font(.system(size: 10).monospaced()) - .foregroundStyle(accented ? Color.primary : WorkspaceWidgetPalette.textTertiary) + Text(smallSubline(for: agent)) + .font(.system(size: 11, weight: .medium).monospacedDigit()) + .foregroundStyle(accented ? Color.primary.opacity(0.7) : WorkspaceWidgetPalette.textSecondary) + .lineLimit(1) + .truncationMode(.tail) + .frame(maxWidth: .infinity, alignment: .leading) + } else if let summary = smallSummaryLine(snapshot: snapshot) { + Text(summary) + .font(.system(size: 11, weight: .medium).monospacedDigit()) + .foregroundStyle(accented ? Color.primary.opacity(0.7) : WorkspaceWidgetPalette.textSecondary) + .lineLimit(1) + .frame(maxWidth: .infinity, alignment: .leading) } } .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) @@ -175,13 +159,8 @@ struct WorkspaceSmallView: View { } private func focusAgent() -> AgentSnapshot? { - snapshot.agents - .sorted { lhs, rhs in - if lhs.awaitingInput != rhs.awaitingInput { return lhs.awaitingInput } - if lhs.status == "running" && rhs.status != "running" { return true } - if rhs.status == "running" && lhs.status != "running" { return false } - return lhs.lastActivityAt > rhs.lastActivityAt - } + snapshot.runningAgents + .sorted { lhs, rhs in lhs.lastActivityAt > rhs.lastActivityAt } .first } @@ -192,19 +171,53 @@ struct WorkspaceSmallView: View { return URL(string: "ade://workspace") ?? URL(fileURLWithPath: "/") } - private func smallStatusLabel(for agent: AgentSnapshot?) -> String { - guard let agent else { return "IDLE" } - if agent.awaitingInput { return "WAITING" } - switch agent.status { - case "failed": return "FAILED" - case "completed": return "DONE" - case "running": return "RUNNING" - default: return agent.status.uppercased() + private func smallStatusLabel(for agent: AgentSnapshot?, snapshot: WorkspaceSnapshot) -> String { + if agent != nil { return "running" } + if snapshot.awaitingInputCount > 0 { return "waiting" } + if snapshot.idleCount > 0 { return "idle" } + return "quiet" + } + + private func smallSubline(for agent: AgentSnapshot) -> String { + let preview = agent.preview?.trimmingCharacters(in: .whitespacesAndNewlines) + if let preview, !preview.isEmpty { + return "\(agent.provider.lowercased()) · \(preview)" + } + return agent.provider.lowercased() + } + + private func smallTitle(focus: AgentSnapshot?, snapshot: WorkspaceSnapshot) -> String { + if let focus { return focus.title ?? "Working" } + if snapshot.awaitingInputCount > 0 { + return snapshot.awaitingInputCount == 1 + ? "1 chat waiting" + : "\(snapshot.awaitingInputCount) chats waiting" + } + if snapshot.idleCount > 0 { + return snapshot.idleCount == 1 + ? "1 chat idle" + : "\(snapshot.idleCount) chats idle" } + return "ADE" + } + + private func smallSummaryLine(snapshot: WorkspaceSnapshot) -> String? { + var parts: [String] = [] + if snapshot.awaitingInputCount > 0 { + parts.append("\(snapshot.awaitingInputCount) waiting") + } + if snapshot.idleCount > 0 { + parts.append("\(snapshot.idleCount) idle") + } + let openPrs = snapshot.prs.filter { $0.state == "open" }.count + if openPrs > 0 { + parts.append("\(openPrs) prs") + } + return parts.isEmpty ? nil : parts.joined(separator: " · ") } private func accessibilityLabel(for agent: AgentSnapshot?) -> String { - agent.map { "Agent \($0.title ?? "untitled")" } ?? "No active agents" + agent.map { "Agent \($0.title ?? "untitled")" } ?? "No agents running" } private func accessibilityValue(for agent: AgentSnapshot?) -> String { @@ -212,22 +225,20 @@ struct WorkspaceSmallView: View { } } -/// BrandDot, but falls back to a neutral dimmed dot in accented rendering mode -/// (where the system tint controls color and saturated brand colors clash). -private struct BrandDotOrDim: View { - let slug: String - let size: CGFloat - let pulse: Bool - let accented: Bool +/// 10pt status dot for the small home widget. Solid green when something is +/// running, dim grey when nothing is active. +private struct SmallStatusDot: View { + let running: Bool var body: some View { - if accented { - Circle() - .fill(Color.primary.opacity(0.9)) - .frame(width: size, height: size) - } else { - BrandDot(slug: slug, size: size, pulse: pulse) - } + let color: Color = running + ? WorkspaceWidgetPalette.statusReady + : WorkspaceWidgetPalette.textQuaternary + Circle() + .fill(color) + .frame(width: 10, height: 10) + .shadow(color: color.opacity(running ? 0.55 : 0), radius: 3) + .accessibilityHidden(true) } } @@ -242,23 +253,23 @@ struct WorkspaceMediumView: View { let accented = renderingMode == .accented VStack(alignment: .leading, spacing: 10) { WorkspaceSectionHeader( - title: variant == .agents ? "Agents" : "Pull requests", + title: variant == .agents ? "Running" : "Pull requests", trailing: variant == .agents - ? "\(runningCount) running" + ? agentsTrailing : "\(openPrsCount) open", accented: accented ) if variant == .agents { WorkspaceAgentsList( - agents: Array(snapshot.agents.prefix(3)), - emptyMessage: "No agents running", + agents: Array(snapshot.runningAgents.prefix(3)), + emptyMessage: emptyAgentsMessage, accented: accented ) } else { WorkspacePrsList( prs: Array(openPrs.prefix(3)), - emptyMessage: "No open PRs", + emptyMessage: "no open prs", accented: accented ) } @@ -267,11 +278,31 @@ struct WorkspaceMediumView: View { } .accessibilityElement(children: .contain) .accessibilityLabel("ADE workspace") - .accessibilityValue("\(snapshot.agents.count) agents, \(openPrsCount) open pull requests") + .accessibilityValue("\(snapshot.runningAgents.count) running, \(openPrsCount) open pull requests") + } + + private var agentsTrailing: String { + let running = snapshot.runningAgents.count + if running == 0 { + if snapshot.awaitingInputCount > 0 { return "\(snapshot.awaitingInputCount) waiting" } + if snapshot.idleCount > 0 { return "\(snapshot.idleCount) idle" } + return "0 running" + } + return "\(running) running" + } + + private var emptyAgentsMessage: String { + if snapshot.awaitingInputCount > 0 { + return "\(snapshot.awaitingInputCount) waiting for input" + } + if snapshot.idleCount > 0 { + return "\(snapshot.idleCount) idle" + } + return "no chats running" } private var runningCount: Int { - snapshot.agents.filter { $0.status == "running" || $0.awaitingInput }.count + snapshot.runningAgents.count } private var openPrsCount: Int { @@ -295,18 +326,19 @@ struct WorkspaceLargeView: View { LargeHeader(connection: snapshot.connection, accented: accented) .padding(.bottom, 14) - if snapshot.agents.isEmpty && openPrs.isEmpty { - WorkspaceIdleState(accented: accented) + if snapshot.runningAgents.isEmpty && openPrs.isEmpty + && snapshot.awaitingInputCount == 0 && snapshot.idleCount == 0 { + WorkspaceIdleState(accented: accented, snapshot: snapshot) } else { SectionDivider( - title: "Agents · \(snapshot.agents.count)", - trailing: "\(runningCount) running", + title: "Agents · \(snapshot.runningAgents.count)", + trailing: largeChatsTrailing, accented: accented ) .padding(.bottom, 8) WorkspaceAgentsList( - agents: Array(snapshot.agents.prefix(3)), - emptyMessage: "No agents running", + agents: Array(snapshot.runningAgents.prefix(3)), + emptyMessage: emptyAgentsMessage, accented: accented ) .padding(.bottom, 14) @@ -317,14 +349,14 @@ struct WorkspaceLargeView: View { .padding(.bottom, 12) SectionDivider( - title: "Pull requests", - trailing: "\(openPrsCount) open", + title: "Pull requests · \(openPrsCount)", + trailing: prsTrailing, accented: accented ) .padding(.bottom, 8) WorkspacePrsList( prs: Array(openPrs.prefix(3)), - emptyMessage: "No open PRs", + emptyMessage: "no open prs", accented: accented ) } @@ -333,11 +365,38 @@ struct WorkspaceLargeView: View { } .accessibilityElement(children: .contain) .accessibilityLabel("ADE workspace dashboard") - .accessibilityValue("\(snapshot.agents.count) agents, \(openPrsCount) open pull requests") + .accessibilityValue("\(snapshot.runningAgents.count) running, \(openPrsCount) open pull requests") + } + + private var largeChatsTrailing: String { + var bits: [String] = [] + if snapshot.awaitingInputCount > 0 { bits.append("\(snapshot.awaitingInputCount) waiting") } + if snapshot.idleCount > 0 { bits.append("\(snapshot.idleCount) idle") } + if snapshot.connection.lowercased() == "disconnected" { bits.append("offline") } + return bits.isEmpty ? "" : bits.joined(separator: " · ") + } + + private var prsTrailing: String { + if snapshot.connection.lowercased() == "disconnected" { return "offline" } + let failing = snapshot.prs.filter { $0.checks == "failing" }.count + if failing > 0 { return "\(failing) failing" } + let ready = snapshot.prs.filter { $0.mergeReady && $0.checks == "passing" && $0.review == "approved" }.count + if ready > 0 { return "\(ready) ready" } + return "" + } + + private var emptyAgentsMessage: String { + if snapshot.awaitingInputCount > 0 { + return "\(snapshot.awaitingInputCount) waiting for input" + } + if snapshot.idleCount > 0 { + return "\(snapshot.idleCount) idle" + } + return "no chats running" } private var runningCount: Int { - snapshot.agents.filter { $0.status == "running" || $0.awaitingInput }.count + snapshot.runningAgents.count } private var openPrsCount: Int { @@ -353,31 +412,56 @@ struct WorkspaceLargeView: View { private struct WorkspaceIdleState: View { let accented: Bool + var snapshot: WorkspaceSnapshot? = nil var body: some View { - VStack(spacing: 10) { + VStack(alignment: .leading, spacing: 6) { Spacer(minLength: 0) - Image(systemName: "sparkles") - .font(.system(size: 30, weight: .light)) - .foregroundStyle( - accented ? Color.primary.opacity(0.7) : WorkspaceWidgetPalette.textSecondary - ) - Text("No agents running") - .font(.system(size: 13, weight: .semibold)) - .foregroundStyle( - accented ? Color.primary : WorkspaceWidgetPalette.textPrimary - ) - Text("Start a session to see live activity here.") - .font(.system(size: 11).monospaced()) - .foregroundStyle( - accented ? Color.primary.opacity(0.7) : WorkspaceWidgetPalette.textSecondary - ) - .multilineTextAlignment(.center) - .lineLimit(2) + HStack(spacing: 8) { + Circle() + .fill(accented ? Color.primary.opacity(0.5) : WorkspaceWidgetPalette.textQuaternary) + .frame(width: 8, height: 8) + Text(idleTitle) + .font(.system(size: 13.5, weight: .semibold)) + .kerning(-0.1) + .lineLimit(1) + .foregroundStyle(accented ? Color.primary : WorkspaceWidgetPalette.textPrimary) + } + if let summary = idleSummary { + Text(summary) + .font(.system(size: 11, weight: .medium).monospacedDigit()) + .foregroundStyle( + accented ? Color.primary.opacity(0.7) : WorkspaceWidgetPalette.textSecondary + ) + .lineLimit(1) + } Spacer(minLength: 0) } - .frame(maxWidth: .infinity) - .padding(.vertical, 8) + .frame(maxWidth: .infinity, alignment: .leading) + } + + private var idleTitle: String { + guard let snapshot else { return "ADE · idle" } + if snapshot.awaitingInputCount > 0 || snapshot.idleCount > 0 { + return "ADE · standby" + } + return "ADE · idle" + } + + private var idleSummary: String? { + guard let snapshot else { return nil } + var parts: [String] = [] + if snapshot.awaitingInputCount > 0 { + parts.append("\(snapshot.awaitingInputCount) waiting") + } + if snapshot.idleCount > 0 { + parts.append("\(snapshot.idleCount) idle") + } + let openPrs = snapshot.prs.filter { $0.state == "open" }.count + if openPrs > 0 { + parts.append("\(openPrs) prs") + } + return parts.isEmpty ? nil : parts.joined(separator: " · ") } } @@ -389,14 +473,16 @@ private struct LargeHeader: View { var body: some View { HStack(alignment: .center, spacing: 8) { - AdeMark(size: 18) + AdeMark(size: 16) VStack(alignment: .leading, spacing: 1) { Text("Workspace") - .font(.system(size: 14, weight: .bold)) + .font(.system(size: 13.5, weight: .semibold)) .kerning(-0.1) + .lineLimit(1) .foregroundStyle(accented ? Color.primary : WorkspaceWidgetPalette.textPrimary) Text(connectionSubtitle) - .font(.system(size: 11).monospaced()) + .font(.system(size: 11, weight: .medium).monospacedDigit()) + .lineLimit(1) .foregroundStyle(accented ? Color.primary.opacity(0.7) : WorkspaceWidgetPalette.textSecondary) } Spacer() @@ -415,10 +501,10 @@ private struct LargeHeader: View { private var connectionSubtitle: String { switch connection.lowercased() { - case "connected": return "default · linked" - case "syncing": return "default · syncing" - case "disconnected": return "default · offline" - default: return "default · \(connection.lowercased())" + case "connected": return "linked" + case "syncing": return "syncing" + case "disconnected": return "offline" + default: return connection.lowercased() } } } @@ -431,16 +517,18 @@ private struct WorkspaceSectionHeader: View { let accented: Bool var body: some View { - HStack(alignment: .center, spacing: 7) { + HStack(alignment: .center, spacing: 8) { AdeMark(size: 14) Text(title) - .font(.system(size: 12, weight: .bold)) + .font(.system(size: 13.5, weight: .semibold)) .kerning(-0.1) + .lineLimit(1) .foregroundStyle(accented ? Color.primary : WorkspaceWidgetPalette.textPrimary) - Spacer() - Text(trailing) - .font(.system(size: 10).monospaced()) - .tracking(0.3) + Spacer(minLength: 6) + Text(trailing.lowercased()) + .font(.system(size: 10.5, weight: .semibold).monospacedDigit()) + .tracking(0.2) + .lineLimit(1) .foregroundStyle(accented ? Color.primary.opacity(0.7) : WorkspaceWidgetPalette.textSecondary) } } @@ -479,7 +567,8 @@ private struct WorkspaceAgentsList: View { VStack(alignment: .leading, spacing: 9) { if agents.isEmpty { Text(emptyMessage) - .font(.system(size: 11).monospaced()) + .font(.system(size: 11, weight: .medium).monospacedDigit()) + .lineLimit(1) .foregroundStyle(accented ? Color.primary.opacity(0.7) : WorkspaceWidgetPalette.textSecondary) } else { ForEach(agents) { agent in @@ -505,7 +594,8 @@ private struct WorkspacePrsList: View { VStack(alignment: .leading, spacing: 9) { if prs.isEmpty { Text(emptyMessage) - .font(.system(size: 11).monospaced()) + .font(.system(size: 11, weight: .medium).monospacedDigit()) + .lineLimit(1) .foregroundStyle(accented ? Color.primary.opacity(0.7) : WorkspaceWidgetPalette.textSecondary) } else { ForEach(prs) { pr in @@ -528,23 +618,29 @@ private struct WidgetRosterRow: View { var body: some View { HStack(alignment: .center, spacing: 10) { - BrandDotOrDim(slug: agent.provider, size: 10, pulse: agent.awaitingInput, accented: accented) + Circle() + .fill(rowDotColor) + .frame(width: 8, height: 8) + .shadow(color: rowDotColor.opacity(0.55), radius: 3) VStack(alignment: .leading, spacing: 1) { - Text(agent.title ?? "Agent") - .font(.system(size: 13, weight: .semibold)) + Text(agent.title ?? "Chat") + .font(.system(size: 13.5, weight: .semibold)) .kerning(-0.1) .lineLimit(1) .foregroundStyle(accented ? Color.primary : WorkspaceWidgetPalette.textPrimary) Text(subline) - .font(.system(size: 10.5).monospaced()) + .font(.system(size: 11, weight: .medium).monospacedDigit()) .lineLimit(1) .foregroundStyle(accented ? Color.primary.opacity(0.7) : WorkspaceWidgetPalette.textSecondary) } .frame(maxWidth: .infinity, alignment: .leading) - trailingStatus } } + private var rowDotColor: Color { + accented ? Color.primary : WorkspaceWidgetPalette.statusReady + } + private var subline: String { let preview = agent.preview?.trimmingCharacters(in: .whitespacesAndNewlines) if let preview, !preview.isEmpty { @@ -552,25 +648,6 @@ private struct WidgetRosterRow: View { } return agent.provider.lowercased() } - - @ViewBuilder - private var trailingStatus: some View { - if agent.status == "failed" { - Text("failed") - .font(.system(size: 10, weight: .semibold).monospaced()) - .foregroundStyle(accented ? Color.primary : WorkspaceWidgetPalette.statusFailed) - } else if agent.awaitingInput { - Text("waiting") - .font(.system(size: 10, weight: .semibold).monospaced()) - .foregroundStyle(accented ? Color.primary : WorkspaceWidgetPalette.statusWaiting) - } else { - TimerLabel( - startedAt: agent.lastActivityAt.addingTimeInterval(-Double(agent.elapsedSeconds)), - color: accented ? Color.primary : WorkspaceWidgetPalette.textSecondary, - fontSize: 10 - ) - } - } } private struct WidgetPrRow: View { @@ -585,18 +662,19 @@ private struct WidgetPrRow: View { .foregroundStyle(accented ? Color.primary : tint) VStack(alignment: .leading, spacing: 1) { Text(pr.title) - .font(.system(size: 13, weight: .semibold)) + .font(.system(size: 13.5, weight: .semibold)) .kerning(-0.1) .lineLimit(1) .foregroundStyle(accented ? Color.primary : WorkspaceWidgetPalette.textPrimary) Text("#\(pr.number) · \(branchLabel)") - .font(.system(size: 10.5).monospaced()) + .font(.system(size: 11, weight: .medium).monospacedDigit()) .lineLimit(1) .foregroundStyle(accented ? Color.primary.opacity(0.7) : WorkspaceWidgetPalette.textSecondary) } .frame(maxWidth: .infinity, alignment: .leading) - Text(shortLabel) - .font(.system(size: 10, weight: .semibold).monospaced()) + Text(shortLabel.lowercased()) + .font(.system(size: 10.5, weight: .semibold).monospacedDigit()) + .tracking(0.2) .foregroundStyle(accented ? Color.primary : tint) } }