Merge branch 'dev' into canary
This commit is contained in:
@@ -24,6 +24,18 @@ final class LiveActivityManager {
|
||||
|
||||
private var activity: Activity<NucleicSessionAttributes>?
|
||||
|
||||
/// Whether an aggregate glance is currently on screen — `RemoteStore` checks this to decide
|
||||
/// whether to fall back to a banner (no glance ⇒ the banner is the only surface).
|
||||
var hasLiveActivity: Bool { activity != nil }
|
||||
|
||||
/// Whether the app is foreground, mirrored from `RemoteStore` (its `isForeground`). Drives where
|
||||
/// a "needs you" arrival is announced: **foreground** the app UI / a banner does it, so the glance
|
||||
/// updates silently; **backgrounded** there's no banner (we prefer the glance), so the update
|
||||
/// itself carries an `AlertConfiguration` (sound/haptic). Starts `true` — `onAppear` runs
|
||||
/// foreground; the background adopt path flips it. Mirrors the host's connected-vs-away gate for
|
||||
/// the pushed glance, but for the still-connected phone whose own socket is alive.
|
||||
var foreground = true
|
||||
|
||||
/// The last content we pushed. Updates that don't change it are skipped so we don't spend
|
||||
/// ActivityKit's update budget on no-ops — `sync` fires on every host message (dashboard,
|
||||
/// connectivity, pong, diff ticks…), most of which leave the aggregate identical. Burning the
|
||||
@@ -42,6 +54,10 @@ final class LiveActivityManager {
|
||||
/// latest through one serial task means the newest data always wins — firing an unstructured
|
||||
/// `Task` per `sync` let a later update lose a race to an earlier one and freeze the glance.
|
||||
private var pendingState: NucleicSessionAttributes.ContentState?
|
||||
/// The alert the next applied update should carry (sound/haptic when a backgrounded session newly
|
||||
/// needs the user). Latched alongside `pendingState` so an update coalesced away by a fresher one
|
||||
/// can't drop the alert; consumed (and cleared) when an update is applied.
|
||||
private var pendingAlert: AlertConfiguration?
|
||||
private var updateTask: Task<Void, Never>?
|
||||
|
||||
/// Set by `RemoteStore` to ship the activity's APNS push token to the paired Macs (and to tell
|
||||
@@ -193,9 +209,35 @@ final class LiveActivityManager {
|
||||
Date().timeIntervalSince($0) >= Self.churnRefreshInterval
|
||||
} ?? true
|
||||
guard signatureChanged || (state != lastState && churnRefreshDue) else { return }
|
||||
// When a session newly needs the user while we're backgrounded, this update carries the alert
|
||||
// (sound/haptic) — there's no banner then, we prefer the glance. Foreground, the app UI / a
|
||||
// banner announces it, so the glance updates silently. Computed against the *previous* state
|
||||
// before `lastState` is overwritten.
|
||||
let alert = foreground ? nil : Self.alert(from: lastState, to: state)
|
||||
lastState = state
|
||||
lastPushAt = Date()
|
||||
enqueue(state)
|
||||
enqueue(state, alert: alert)
|
||||
}
|
||||
|
||||
/// The alert an update should carry when backgrounded: a session newly needs the user. An approval
|
||||
/// (its Allow/Deny live on the glance) outranks a needs-input for the wording; a rise in neither ⇒
|
||||
/// nil (silent update). Reuses the same localized keys as the host's pushed alert so a local and a
|
||||
/// pushed alert read identically. Approvals/inputs that merely persist (or resolve) don't re-alert.
|
||||
private static func alert(
|
||||
from previous: NucleicSessionAttributes.ContentState?,
|
||||
to next: NucleicSessionAttributes.ContentState
|
||||
) -> AlertConfiguration? {
|
||||
if next.approvalCount > (previous?.approvalCount ?? 0) {
|
||||
return AlertConfiguration(
|
||||
title: LocalizedStringResource("approval.title"),
|
||||
body: LocalizedStringResource("approval.pending"), sound: .default)
|
||||
}
|
||||
if next.needsYouCount > (previous?.needsYouCount ?? 0) {
|
||||
return AlertConfiguration(
|
||||
title: LocalizedStringResource("input.title"),
|
||||
body: LocalizedStringResource("input.pending"), sound: .default)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
/// Start the process-lived observers that make push-to-start work: the device's push-to-start
|
||||
@@ -251,14 +293,20 @@ final class LiveActivityManager {
|
||||
}
|
||||
|
||||
/// Hand the newest state to the serial drainer (starting it if idle).
|
||||
private func enqueue(_ state: NucleicSessionAttributes.ContentState) {
|
||||
private func enqueue(
|
||||
_ state: NucleicSessionAttributes.ContentState, alert: AlertConfiguration? = nil
|
||||
) {
|
||||
pendingState = state
|
||||
if let alert { pendingAlert = alert } // latch — a coalesced-away update must not drop it
|
||||
guard updateTask == nil else { return } // the running drainer will pick this up
|
||||
updateTask = Task { @MainActor [weak self] in
|
||||
guard let self else { return }
|
||||
while let next = self.pendingState {
|
||||
self.pendingState = nil
|
||||
await self.activity?.update(ActivityContent(state: next, staleDate: nil))
|
||||
let alert = self.pendingAlert
|
||||
self.pendingAlert = nil
|
||||
await self.activity?.update(
|
||||
ActivityContent(state: next, staleDate: nil), alertConfiguration: alert)
|
||||
}
|
||||
self.updateTask = nil
|
||||
}
|
||||
@@ -273,6 +321,7 @@ final class LiveActivityManager {
|
||||
dismissTask?.cancel()
|
||||
dismissTask = nil
|
||||
pendingState = nil
|
||||
pendingAlert = nil
|
||||
lastState = nil
|
||||
lastPushAt = nil
|
||||
let tracked = activity
|
||||
@@ -307,13 +356,22 @@ final class LiveActivityManager {
|
||||
|
||||
/// Project a wire summary into the widget's self-contained row model.
|
||||
private static func line(for s: WireSessionSummary) -> NucleicSessionAttributes.SessionLine {
|
||||
NucleicSessionAttributes.SessionLine(
|
||||
// Carry the approval id/risk only for a session awaiting a *tool approval* — the row the
|
||||
// glance renders inline Allow/Deny on (`ApproveFromActivityIntent`). An `AskUserQuestion`
|
||||
// block (pendingQuestionCount set) is excluded: it needs an answer selection the glance can't
|
||||
// collect, so it deep-links to the app's picker card, like the notification path. Other states
|
||||
// leave it nil so the row stays a plain deep-link tap.
|
||||
let approvalID = (s.status == .awaitingApproval && s.pendingQuestionCount == nil)
|
||||
? s.firstApprovalID?.rawValue : nil
|
||||
return NucleicSessionAttributes.SessionLine(
|
||||
id: s.sessionID.rawValue,
|
||||
title: s.title.isEmpty ? s.projectName : s.title,
|
||||
project: s.projectName,
|
||||
backend: backend(s.backend),
|
||||
kind: kind(for: s),
|
||||
detail: detail(for: s))
|
||||
detail: detail(for: s),
|
||||
approvalID: approvalID,
|
||||
approvalIsHighRisk: approvalID == nil ? nil : s.firstApprovalIsHighRisk)
|
||||
}
|
||||
|
||||
private static func kind(for s: WireSessionSummary) -> NucleicSessionAttributes.Kind {
|
||||
|
||||
@@ -4,7 +4,21 @@
|
||||
over the encrypted channel on open. */
|
||||
"approval.pending" = "A session is waiting for your approval";
|
||||
|
||||
/* Title of an *alerting* Live Activity update (apns.ts `liveActivityAlert`): a new approval landed
|
||||
while the phone was away, so the glance itself sounds/vibrates instead of a separate wake tickle.
|
||||
Shown only on Apple Watch (on iPhone the glance updates in place); the body reuses
|
||||
`approval.pending`. Still content-free — the real approval is pulled over the encrypted channel. */
|
||||
"approval.title" = "Approval needed";
|
||||
|
||||
/* The same content-free wake, but the block is an agent question (`AskUserQuestion`) rather than
|
||||
a tool approval — the host tags the wake `kind:"question"` so the lock screen reads accurately.
|
||||
Still content-free: the real question is pulled over the encrypted channel on open. */
|
||||
"question.pending" = "A session is waiting for your answer";
|
||||
|
||||
/* Title + body of an *alerting* Live Activity update for a session that finished its turn and is
|
||||
waiting on the user's next prompt (apns.ts `liveActivityAlert("input")`, and the phone's local
|
||||
`AlertConfiguration`). This is the needs-input counterpart to `approval.*`: when the phone is
|
||||
backgrounded the glance itself carries the sound/haptic instead of a separate banner. Shown only
|
||||
on Apple Watch on iPhone the glance updates in place. */
|
||||
"input.title" = "Your turn";
|
||||
"input.pending" = "A session is waiting for you";
|
||||
|
||||
@@ -698,8 +698,15 @@ final class RemoteStore: ObservableObject {
|
||||
!self.openApprovals.contains(where: { $0.id == req.id }) {
|
||||
self.openApprovals.append(req)
|
||||
}
|
||||
// Post for any host; the router suppresses the banner if the user is on this session.
|
||||
NotificationRouter.shared.postApproval(req, sessionTitle: title)
|
||||
// Prefer the Live Activity: when backgrounded with a glance on screen, the glance itself
|
||||
// carries the approval (its inline Allow/Deny) and alerts (sound/haptic) via the local
|
||||
// update path — so don't also post a banner. In-app the glance isn't the surface (it
|
||||
// doesn't show over the app that controls it), so the banner is; and if there's no glance
|
||||
// at all (Live Activities off) the banner is the only surface, so keep it as the fallback.
|
||||
// willPresent still suppresses the banner when the user is already on this session.
|
||||
if self.isForeground || !LiveActivityManager.shared.hasLiveActivity {
|
||||
NotificationRouter.shared.postApproval(req, sessionTitle: title)
|
||||
}
|
||||
}
|
||||
cb.approvalResolved = { [weak self] resolved in
|
||||
guard let self else { return }
|
||||
@@ -708,7 +715,12 @@ final class RemoteStore: ObservableObject {
|
||||
}
|
||||
cb.sessionBecameWaiting = { [weak self] summary in
|
||||
guard let self, !self.isActive else { return }
|
||||
NotificationRouter.shared.postSessionUpdate(summary)
|
||||
// Backgrounded: prefer the glance — it shows the waiting session and alerts through the
|
||||
// local update (sound/haptic). Only fall back to a banner when there's no glance at all
|
||||
// (Live Activities off), so a needs-input arrival is never silent.
|
||||
if !LiveActivityManager.shared.hasLiveActivity {
|
||||
NotificationRouter.shared.postSessionUpdate(summary)
|
||||
}
|
||||
}
|
||||
cb.wireError = { [weak self] error in
|
||||
self?.showError(error.message, sessionID: error.sessionID)
|
||||
@@ -1530,7 +1542,11 @@ final class RemoteStore: ObservableObject {
|
||||
/// so they know whether to defer to the phone's own Live Activity creation (foreground) or
|
||||
/// push-to-start the glance themselves (backgrounded — the phone can't reliably start one). Starts
|
||||
/// `true`: `onAppear` runs in the foreground; the background adopt-wake flips it first (§5.3).
|
||||
private var isForeground = true
|
||||
/// Mirrored to `LiveActivityManager` so it knows whether a "needs you" arrival should alert
|
||||
/// through the glance (backgrounded) or stay silent while a banner announces it (foreground).
|
||||
private var isForeground = true {
|
||||
didSet { LiveActivityManager.shared.foreground = isForeground }
|
||||
}
|
||||
/// Host ids already told the *current* `isForeground` value — cleared whenever it flips so the
|
||||
/// next sync re-notifies every host (mirrors `pushToStartSentTo`).
|
||||
private var foregroundSentTo: Set<String> = []
|
||||
|
||||
@@ -303,7 +303,7 @@ extension SessionStatus {
|
||||
}
|
||||
|
||||
extension Risk {
|
||||
var isHigh: Bool { self == .destructive || self == .network || self == .hostExec }
|
||||
// `isHigh` now lives on `Risk` in NucleicProtocol so the host and phone share one definition.
|
||||
var label: String { self == .hostExec ? "host machine" : rawValue }
|
||||
var color: Color {
|
||||
switch self {
|
||||
|
||||
@@ -43,8 +43,9 @@ struct TranscriptRow: View {
|
||||
InfoLine(icon: "play.circle", text: "Session started · \(modelLabel(model))", detail: cwd)
|
||||
case .usage(let usage):
|
||||
UsageRow(usage: usage)
|
||||
case .rateLimit(let limit):
|
||||
RateLimitRow(limit: limit)
|
||||
case .rateLimit:
|
||||
// Rate-limit notices are intentionally hidden from the transcript.
|
||||
EmptyView()
|
||||
case .turnBoundary:
|
||||
TurnBoundaryRow()
|
||||
case .approval(let toolName):
|
||||
|
||||
Reference in New Issue
Block a user