nvrsion: Add: Provisioned VM Image Preparation, refactor PushRelayClient.swift, SyncHost.swift, LiveActivity.swift, apns.ts, LiveActivityManager.swift, and hold “Done” sessions in view in Live Activity until the user opens the app and views the sessions.

Nucleic-Promote: 1
Co-authored-by: Nucleic <[email protected]>
This commit is contained in:
2026-07-07 04:09:22 -07:00
co-authored by Nucleic
parent 68fecc4812
commit 8b852d1520
@@ -15,12 +15,12 @@ final class LiveActivityManager {
/// still summarize everything, this just bounds the per-session list. /// still summarize everything, this just bounds the per-session list.
private static let maxLines = 3 private static let maxLines = 3
/// How long the terminal "all clear / Done" glance lingers before the Activity dismisses. Long /// Whether the glance is currently holding the terminal "Done" summary. When work finishes while
/// enough to read at a glance, short enough not to loiter on the lock screen. /// the app is away, the Activity is kept on the lock screen showing the finished sessions and
private static let doneLinger: Duration = .seconds(4) /// *held there* — not dismissed on a timer — until the user opens the app and sees them, so a
/// The delayed dismissal after the Done glance is shown, cancelled if work resumes first — so a /// completed run isn't dropped after a few seconds unseen (UX_IOS §5.3). Set when the Done glance
/// completed run flashes "Done" and then clears instead of vanishing the instant it finishes. /// goes up; cleared when work resumes, or when the app foregrounds and the glance ends.
private var dismissTask: Task<Void, Never>? private var showingDoneGlance = false
private var activity: Activity<NucleicSessionAttributes>? private var activity: Activity<NucleicSessionAttributes>?
@@ -34,7 +34,14 @@ final class LiveActivityManager {
/// itself carries an `AlertConfiguration` (sound/haptic). Starts `true` — `onAppear` runs /// 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 /// 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. /// the pushed glance, but for the still-connected phone whose own socket is alive.
var foreground = true var foreground = true {
didSet {
// The app just came forward and the user can now see the in-app session list — so dismiss
// the "Done" glance we were holding on the lock screen for exactly this moment. The
// counterpart to `finishWithDoneGlance` keeping it up while the app was away.
if foreground, !oldValue, showingDoneGlance { end() }
}
}
/// 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,
@@ -91,10 +98,9 @@ final class LiveActivityManager {
finishWithDoneGlance(hostName: hostName, live: live) finishWithDoneGlance(hostName: hostName, live: live)
return return
} }
// Work is active again — abort any pending "Done" dismissal so the glance doesn't clear // Work is active again — drop any held "Done" glance so the fresh active state renders
// out from under a run that just resumed (or a new one that just started). // instead of the finished summary (a run just resumed, or a new one started).
dismissTask?.cancel() showingDoneGlance = false
dismissTask = nil
// Everything in flight or waiting on the user, attention-first (approvals, then waiting // Everything in flight or waiting on the user, attention-first (approvals, then waiting
// input, then running), freshest within a rank. This is both the detail-row source and // input, then running), freshest within a rank. This is both the detail-row source and
@@ -124,17 +130,22 @@ final class LiveActivityManager {
push(state, hostName: hostName) push(state, hostName: hostName)
} }
/// Nothing is running or waiting. If the glance was showing active work, flash a brief "Done" /// Nothing is running or waiting. If the glance was showing active work, replace it with a "Done"
/// summary of the just-completed sessions and then dismiss (UX_IOS §5.3 — a run should read as /// summary of the just-completed sessions and hold it on the lock screen until the user opens the
/// *finished*, not just vanish). If nothing was on screen, there's nothing to close. /// app and sees them (UX_IOS §5.3 — a finished run should read as *done* and stay put, not vanish
/// after a few seconds unseen). Foreground, the user is already on the in-app session list, so
/// there's nothing to hold — dismiss. If nothing was on screen, there's nothing to close.
private func finishWithDoneGlance(hostName: String, live: [WireSessionSummary]) { private func finishWithDoneGlance(hostName: String, live: [WireSessionSummary]) {
// Already flashing "Done" and scheduled to dismiss — let that run rather than restart it. // Already holding the "Done" summary — leave it up until the app foregrounds, don't rebuild it.
guard dismissTask == nil else { return } guard !showingDoneGlance else { return }
// Nothing tracked on screen: only reach into ActivityKit if an untracked orphan is lingering. // Nothing tracked on screen: only reach into ActivityKit if an untracked orphan is lingering.
guard activity != nil else { guard activity != nil else {
if !Activity<NucleicSessionAttributes>.activities.isEmpty { end() } if !Activity<NucleicSessionAttributes>.activities.isEmpty { end() }
return return
} }
// Foreground: the user is already looking at the in-app session list (the glance isn't even
// visible over the app), so there's nothing to hold for later — just dismiss.
guard !foreground else { end(); return }
// The sessions that just finished — completed conversational turns and finished runs. These // The sessions that just finished — completed conversational turns and finished runs. These
// are exactly the ones the active-work filter above drops, surfaced now as `.done` rows. // are exactly the ones the active-work filter above drops, surfaced now as `.done` rows.
let done = live let done = live
@@ -145,18 +156,16 @@ final class LiveActivityManager {
.sorted(by: StatusStyle.attentionThenRecency) .sorted(by: StatusStyle.attentionThenRecency)
.prefix(Self.maxLines) .prefix(Self.maxLines)
.map(Self.line(for:)) .map(Self.line(for:))
// Nothing to celebrate (e.g. the work was discarded/deleted) → just dismiss. // Nothing to show (e.g. the work was discarded/deleted) → just dismiss.
guard !done.isEmpty else { end(); return } guard !done.isEmpty else { end(); return }
let state = NucleicSessionAttributes.ContentState( let state = NucleicSessionAttributes.ContentState(
runningCount: 0, needsYouCount: 0, approvalCount: 0, runningCount: 0, needsYouCount: 0, approvalCount: 0,
filesChanged: 0, linesAdded: 0, linesRemoved: 0, lines: Array(done)) filesChanged: 0, linesAdded: 0, linesRemoved: 0, lines: Array(done))
push(state, hostName: hostName) push(state, hostName: hostName)
dismissTask = Task { [weak self] in // Hold it: the Activity stays alive showing "Done". No scheduled dismissal — `foreground`
try? await Task.sleep(for: Self.doneLinger) // flipping true (the app coming forward) is what ends it.
guard !Task.isCancelled else { return } showingDoneGlance = true
self?.end()
}
} }
/// Apply `state` to the Activity — coalesced through one serial task so the newest state always /// Apply `state` to the Activity — coalesced through one serial task so the newest state always
@@ -318,8 +327,7 @@ final class LiveActivityManager {
updateTask = nil updateTask = nil
tokenObservation?.cancel() tokenObservation?.cancel()
tokenObservation = nil tokenObservation = nil
dismissTask?.cancel() showingDoneGlance = false
dismissTask = nil
pendingState = nil pendingState = nil
pendingAlert = nil pendingAlert = nil
lastState = nil lastState = nil