From a04a35aac5ef2650ddabe478197ac07d9e78860e Mon Sep 17 00:00:00 2001 From: Andrew Blakeslee Moore Date: Mon, 6 Jul 2026 22:10:43 -0700 Subject: [PATCH] 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 --- .../NucleicRemote/LiveActivityManager.swift | 68 +++++++++++++++++-- .../NucleicRemote/Localizable.strings | 8 +++ .../NucleicRemote/Models/RemoteStore.swift | 24 +++++-- NucleicRemote/NucleicRemote/Views/Theme.swift | 2 +- 4 files changed, 92 insertions(+), 10 deletions(-) diff --git a/NucleicRemote/NucleicRemote/LiveActivityManager.swift b/NucleicRemote/NucleicRemote/LiveActivityManager.swift index 44b1de3..36400cf 100644 --- a/NucleicRemote/NucleicRemote/LiveActivityManager.swift +++ b/NucleicRemote/NucleicRemote/LiveActivityManager.swift @@ -24,6 +24,18 @@ final class LiveActivityManager { private var activity: Activity? + /// 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? /// 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 { diff --git a/NucleicRemote/NucleicRemote/Localizable.strings b/NucleicRemote/NucleicRemote/Localizable.strings index 57ddd68..4b28177 100644 --- a/NucleicRemote/NucleicRemote/Localizable.strings +++ b/NucleicRemote/NucleicRemote/Localizable.strings @@ -14,3 +14,11 @@ 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"; diff --git a/NucleicRemote/NucleicRemote/Models/RemoteStore.swift b/NucleicRemote/NucleicRemote/Models/RemoteStore.swift index 1327198..e116d6e 100644 --- a/NucleicRemote/NucleicRemote/Models/RemoteStore.swift +++ b/NucleicRemote/NucleicRemote/Models/RemoteStore.swift @@ -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 = [] diff --git a/NucleicRemote/NucleicRemote/Views/Theme.swift b/NucleicRemote/NucleicRemote/Views/Theme.swift index 6adbd2f..ab77ad7 100644 --- a/NucleicRemote/NucleicRemote/Views/Theme.swift +++ b/NucleicRemote/NucleicRemote/Views/Theme.swift @@ -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 {