diff --git a/NucleicRemote/NucleicRemote/LiveActivityManager.swift b/NucleicRemote/NucleicRemote/LiveActivityManager.swift index 36400cf..cb3d8c3 100644 --- a/NucleicRemote/NucleicRemote/LiveActivityManager.swift +++ b/NucleicRemote/NucleicRemote/LiveActivityManager.swift @@ -15,12 +15,12 @@ final class LiveActivityManager { /// still summarize everything, this just bounds the per-session list. private static let maxLines = 3 - /// How long the terminal "all clear / Done" glance lingers before the Activity dismisses. Long - /// enough to read at a glance, short enough not to loiter on the lock screen. - private static let doneLinger: Duration = .seconds(4) - /// The delayed dismissal after the Done glance is shown, cancelled if work resumes first — so a - /// completed run flashes "Done" and then clears instead of vanishing the instant it finishes. - private var dismissTask: Task? + /// Whether the glance is currently holding the terminal "Done" summary. When work finishes while + /// the app is away, the Activity is kept on the lock screen showing the finished sessions and + /// *held there* — not dismissed on a timer — until the user opens the app and sees them, so a + /// completed run isn't dropped after a few seconds unseen (UX_IOS §5.3). Set when the Done glance + /// goes up; cleared when work resumes, or when the app foregrounds and the glance ends. + private var showingDoneGlance = false private var activity: Activity? @@ -34,7 +34,14 @@ final class LiveActivityManager { /// 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 + 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 /// 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) return } - // Work is active again — abort any pending "Done" dismissal so the glance doesn't clear - // out from under a run that just resumed (or a new one that just started). - dismissTask?.cancel() - dismissTask = nil + // Work is active again — drop any held "Done" glance so the fresh active state renders + // instead of the finished summary (a run just resumed, or a new one started). + showingDoneGlance = false // 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 @@ -124,17 +130,22 @@ final class LiveActivityManager { push(state, hostName: hostName) } - /// Nothing is running or waiting. If the glance was showing active work, flash a brief "Done" - /// summary of the just-completed sessions and then dismiss (UX_IOS §5.3 — a run should read as - /// *finished*, not just vanish). If nothing was on screen, there's nothing to close. + /// Nothing is running or waiting. If the glance was showing active work, replace it with a "Done" + /// summary of the just-completed sessions and hold it on the lock screen until the user opens the + /// 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]) { - // Already flashing "Done" and scheduled to dismiss — let that run rather than restart it. - guard dismissTask == nil else { return } + // Already holding the "Done" summary — leave it up until the app foregrounds, don't rebuild it. + guard !showingDoneGlance else { return } // Nothing tracked on screen: only reach into ActivityKit if an untracked orphan is lingering. guard activity != nil else { if !Activity.activities.isEmpty { end() } 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 // are exactly the ones the active-work filter above drops, surfaced now as `.done` rows. let done = live @@ -145,18 +156,16 @@ final class LiveActivityManager { .sorted(by: StatusStyle.attentionThenRecency) .prefix(Self.maxLines) .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 } let state = NucleicSessionAttributes.ContentState( runningCount: 0, needsYouCount: 0, approvalCount: 0, filesChanged: 0, linesAdded: 0, linesRemoved: 0, lines: Array(done)) push(state, hostName: hostName) - dismissTask = Task { [weak self] in - try? await Task.sleep(for: Self.doneLinger) - guard !Task.isCancelled else { return } - self?.end() - } + // Hold it: the Activity stays alive showing "Done". No scheduled dismissal — `foreground` + // flipping true (the app coming forward) is what ends it. + showingDoneGlance = true } /// Apply `state` to the Activity — coalesced through one serial task so the newest state always @@ -318,8 +327,7 @@ final class LiveActivityManager { updateTask = nil tokenObservation?.cancel() tokenObservation = nil - dismissTask?.cancel() - dismissTask = nil + showingDoneGlance = false pendingState = nil pendingAlert = nil lastState = nil