-
Notifications
You must be signed in to change notification settings - Fork 2
ios(widgets+live activity): polish notifs, lock screen, and live activity surfaces #207
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. Weβll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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<Void, Never>? | ||
| /// Push-to-start listener (iOS 17.2+). | ||
| private var pushToStartTask: Task<Void, Never>? | ||
| /// 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<Void, Never>? | ||
| /// 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<Void, Never>? | ||
|
|
||
| /// 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( | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [π‘ Medium] [π΅ Bug] The If the Configuration doc is correct (attention should override), urgent signals like failed CI, awaiting user input, or review requests will be silently suppressed for 10 minutes after a user dismisses the Live Activity. If the function doc is correct, the Configuration property doc is misleading and should be updated. // Configuration doc says attention overrides, but this never checks state.attention:
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
}
return false // attention signals are silently suppressed
}Either add |
||
| 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 | ||
| } | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
|
|
||
| // 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,30 +278,28 @@ public final class LiveActivityCoordinator: ObservableObject { | |
| failingCheckCount: failingChecks, | ||
| awaitingReviewCount: awaitingReviews, | ||
| mergeReadyCount: mergeReady, | ||
| awaitingInputCount: awaitingInputCount, | ||
| idleCount: idleCount, | ||
| generatedAt: Date() | ||
| ) | ||
| } | ||
|
|
||
| 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<ADESessionAttributes>) { | ||
| 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<ADESessionAttributes>) { | ||
| pushTokenTask?.cancel() | ||
| pushTokenTask = Task { [weak self] in | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.