nvrsion: Add, fix, refactor, update, and format: Tool Output Display in Nucleic, Chat: Apply Glass Material Sidebar, RootView.swift, and Chat: Push Notification Live Activity Redundancy.
Nucleic-Promote: 1 Co-authored-by: Nucleic <[email protected]>
This commit is contained in:
@@ -24,6 +24,18 @@ final class LiveActivityManager {
|
|||||||
|
|
||||||
private var activity: Activity<NucleicSessionAttributes>?
|
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
|
/// 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,
|
/// 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
|
/// 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
|
/// 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.
|
/// `Task` per `sync` let a later update lose a race to an earlier one and freeze the glance.
|
||||||
private var pendingState: NucleicSessionAttributes.ContentState?
|
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>?
|
private var updateTask: Task<Void, Never>?
|
||||||
|
|
||||||
/// Set by `RemoteStore` to ship the activity's APNS push token to the paired Macs (and to tell
|
/// 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
|
Date().timeIntervalSince($0) >= Self.churnRefreshInterval
|
||||||
} ?? true
|
} ?? true
|
||||||
guard signatureChanged || (state != lastState && churnRefreshDue) else { return }
|
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
|
lastState = state
|
||||||
lastPushAt = Date()
|
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
|
/// 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).
|
/// 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
|
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
|
guard updateTask == nil else { return } // the running drainer will pick this up
|
||||||
updateTask = Task { @MainActor [weak self] in
|
updateTask = Task { @MainActor [weak self] in
|
||||||
guard let self else { return }
|
guard let self else { return }
|
||||||
while let next = self.pendingState {
|
while let next = self.pendingState {
|
||||||
self.pendingState = nil
|
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
|
self.updateTask = nil
|
||||||
}
|
}
|
||||||
@@ -273,6 +321,7 @@ final class LiveActivityManager {
|
|||||||
dismissTask?.cancel()
|
dismissTask?.cancel()
|
||||||
dismissTask = nil
|
dismissTask = nil
|
||||||
pendingState = nil
|
pendingState = nil
|
||||||
|
pendingAlert = nil
|
||||||
lastState = nil
|
lastState = nil
|
||||||
lastPushAt = nil
|
lastPushAt = nil
|
||||||
let tracked = activity
|
let tracked = activity
|
||||||
@@ -307,13 +356,22 @@ final class LiveActivityManager {
|
|||||||
|
|
||||||
/// Project a wire summary into the widget's self-contained row model.
|
/// Project a wire summary into the widget's self-contained row model.
|
||||||
private static func line(for s: WireSessionSummary) -> NucleicSessionAttributes.SessionLine {
|
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,
|
id: s.sessionID.rawValue,
|
||||||
title: s.title.isEmpty ? s.projectName : s.title,
|
title: s.title.isEmpty ? s.projectName : s.title,
|
||||||
project: s.projectName,
|
project: s.projectName,
|
||||||
backend: backend(s.backend),
|
backend: backend(s.backend),
|
||||||
kind: kind(for: s),
|
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 {
|
private static func kind(for s: WireSessionSummary) -> NucleicSessionAttributes.Kind {
|
||||||
|
|||||||
@@ -14,3 +14,11 @@
|
|||||||
a tool approval — the host tags the wake `kind:"question"` so the lock screen reads accurately.
|
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. */
|
Still content-free: the real question is pulled over the encrypted channel on open. */
|
||||||
"question.pending" = "A session is waiting for your answer";
|
"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,9 +698,16 @@ final class RemoteStore: ObservableObject {
|
|||||||
!self.openApprovals.contains(where: { $0.id == req.id }) {
|
!self.openApprovals.contains(where: { $0.id == req.id }) {
|
||||||
self.openApprovals.append(req)
|
self.openApprovals.append(req)
|
||||||
}
|
}
|
||||||
// Post for any host; the router suppresses the banner if the user is on this session.
|
// 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)
|
NotificationRouter.shared.postApproval(req, sessionTitle: title)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
cb.approvalResolved = { [weak self] resolved in
|
cb.approvalResolved = { [weak self] resolved in
|
||||||
guard let self else { return }
|
guard let self else { return }
|
||||||
self.openApprovals.removeAll { $0.id == resolved.id }
|
self.openApprovals.removeAll { $0.id == resolved.id }
|
||||||
@@ -708,8 +715,13 @@ final class RemoteStore: ObservableObject {
|
|||||||
}
|
}
|
||||||
cb.sessionBecameWaiting = { [weak self] summary in
|
cb.sessionBecameWaiting = { [weak self] summary in
|
||||||
guard let self, !self.isActive else { return }
|
guard let self, !self.isActive else { return }
|
||||||
|
// 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)
|
NotificationRouter.shared.postSessionUpdate(summary)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
cb.wireError = { [weak self] error in
|
cb.wireError = { [weak self] error in
|
||||||
self?.showError(error.message, sessionID: error.sessionID)
|
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
|
/// 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
|
/// 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).
|
/// `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
|
/// Host ids already told the *current* `isForeground` value — cleared whenever it flips so the
|
||||||
/// next sync re-notifies every host (mirrors `pushToStartSentTo`).
|
/// next sync re-notifies every host (mirrors `pushToStartSentTo`).
|
||||||
private var foregroundSentTo: Set<String> = []
|
private var foregroundSentTo: Set<String> = []
|
||||||
|
|||||||
@@ -303,7 +303,7 @@ extension SessionStatus {
|
|||||||
}
|
}
|
||||||
|
|
||||||
extension Risk {
|
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 label: String { self == .hostExec ? "host machine" : rawValue }
|
||||||
var color: Color {
|
var color: Color {
|
||||||
switch self {
|
switch self {
|
||||||
|
|||||||
Reference in New Issue
Block a user