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:
2026-07-06 22:10:43 -07:00
co-authored by Nucleic
parent 0d43cf4791
commit a04a35aac5
4 changed files with 92 additions and 10 deletions
@@ -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 {