From d7cab5a3c4fbb7f0d4953f89f0007ce6d3b740ce Mon Sep 17 00:00:00 2001 From: Andrew Blakeslee Moore Date: Tue, 7 Jul 2026 03:00:23 -0700 Subject: [PATCH] Live Activity Session Retention Nucleic-Session: F2CCC9F2-A7BA-4A94-ADAA-5414F168045E Co-authored-by: Nucleic --- .../NucleicRemote/LiveActivityManager.swift | 25 +++++++++++-------- 1 file changed, 14 insertions(+), 11 deletions(-) diff --git a/NucleicRemote/NucleicRemote/LiveActivityManager.swift b/NucleicRemote/NucleicRemote/LiveActivityManager.swift index ae2c422..9fe2402 100644 --- a/NucleicRemote/NucleicRemote/LiveActivityManager.swift +++ b/NucleicRemote/NucleicRemote/LiveActivityManager.swift @@ -130,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 @@ -151,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