From 040f2a5e4605acb53e8c34dbe08d7e00ce4b8e97 Mon Sep 17 00:00:00 2001 From: Andrew Blakeslee Moore Date: Tue, 7 Jul 2026 02:59:49 -0700 Subject: [PATCH 1/7] Live Activity Session Retention Nucleic-Session: F2CCC9F2-A7BA-4A94-ADAA-5414F168045E Co-authored-by: Nucleic --- .../NucleicRemote/LiveActivityManager.swift | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/NucleicRemote/NucleicRemote/LiveActivityManager.swift b/NucleicRemote/NucleicRemote/LiveActivityManager.swift index 36400cf..72dfa34 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? From 44116a20d1160f261787f2d1ec4b54fa2197f4ec Mon Sep 17 00:00:00 2001 From: Andrew Blakeslee Moore Date: Tue, 7 Jul 2026 03:00:02 -0700 Subject: [PATCH 2/7] Live Activity Session Retention Nucleic-Session: F2CCC9F2-A7BA-4A94-ADAA-5414F168045E Co-authored-by: Nucleic --- NucleicRemote/NucleicRemote/LiveActivityManager.swift | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/NucleicRemote/NucleicRemote/LiveActivityManager.swift b/NucleicRemote/NucleicRemote/LiveActivityManager.swift index 72dfa34..703b405 100644 --- a/NucleicRemote/NucleicRemote/LiveActivityManager.swift +++ b/NucleicRemote/NucleicRemote/LiveActivityManager.swift @@ -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, From fb465600d844c653683ddc7674de6dc791034b67 Mon Sep 17 00:00:00 2001 From: Andrew Blakeslee Moore Date: Tue, 7 Jul 2026 03:00:06 -0700 Subject: [PATCH 3/7] Live Activity Session Retention Nucleic-Session: F2CCC9F2-A7BA-4A94-ADAA-5414F168045E Co-authored-by: Nucleic --- NucleicRemote/NucleicRemote/LiveActivityManager.swift | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/NucleicRemote/NucleicRemote/LiveActivityManager.swift b/NucleicRemote/NucleicRemote/LiveActivityManager.swift index 703b405..ae2c422 100644 --- a/NucleicRemote/NucleicRemote/LiveActivityManager.swift +++ b/NucleicRemote/NucleicRemote/LiveActivityManager.swift @@ -98,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 From d7cab5a3c4fbb7f0d4953f89f0007ce6d3b740ce Mon Sep 17 00:00:00 2001 From: Andrew Blakeslee Moore Date: Tue, 7 Jul 2026 03:00:23 -0700 Subject: [PATCH 4/7] 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 From ae92be76ff66f4807b0cd92c23855c2871f5dae6 Mon Sep 17 00:00:00 2001 From: Andrew Blakeslee Moore Date: Tue, 7 Jul 2026 03:00:29 -0700 Subject: [PATCH 5/7] Live Activity Session Retention Nucleic-Session: F2CCC9F2-A7BA-4A94-ADAA-5414F168045E Co-authored-by: Nucleic --- NucleicRemote/NucleicRemote/LiveActivityManager.swift | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/NucleicRemote/NucleicRemote/LiveActivityManager.swift b/NucleicRemote/NucleicRemote/LiveActivityManager.swift index 9fe2402..cb3d8c3 100644 --- a/NucleicRemote/NucleicRemote/LiveActivityManager.swift +++ b/NucleicRemote/NucleicRemote/LiveActivityManager.swift @@ -327,8 +327,7 @@ final class LiveActivityManager { updateTask = nil tokenObservation?.cancel() tokenObservation = nil - dismissTask?.cancel() - dismissTask = nil + showingDoneGlance = false pendingState = nil pendingAlert = nil lastState = nil From 7de020cfa8b8367a1c4150407d218ff16a52449f Mon Sep 17 00:00:00 2001 From: Andrew Blakeslee Moore Date: Tue, 7 Jul 2026 04:41:06 -0700 Subject: [PATCH 6/7] Move Model Selector Position Nucleic-Session: 855E352B-51E5-4752-9C04-2C23B973FA44 Co-authored-by: Nucleic --- .../Views/SessionDetailView.swift | 19 +++++-------------- 1 file changed, 5 insertions(+), 14 deletions(-) diff --git a/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift b/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift index 73323df..ae0c725 100644 --- a/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift +++ b/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift @@ -159,23 +159,13 @@ struct SessionDetailView: View { return min(100, Int((Double(used) / Double(window)) * 100)) } - /// The model / effort / auto controls for the open session — the mobile echo of the Mac's - /// session header, folded into the glass chat bar so they don't cost a strip of transcript - /// height. Reads current state from the summary; each change is a control intent. + /// The context-occupancy readout and the Auto toggle for the open session, folded into the + /// glass chat bar so they don't cost a strip of transcript height. The model / effort + /// selectors used to sit here too, but they moved into the ⋯ menu (`modelEffortMenu`) to keep + /// the bar compact. Reads current state from the summary; each change is a control intent. @ViewBuilder private func controlRow(_ summary: WireSessionSummary) -> some View { HStack(spacing: 10) { - // A session's backend is fixed at creation, so only same-backend models are offered. - ModelMenu(model: modelBinding, catalog: store.modelCatalog, backend: summary.backend) - EffortMenu(effort: effortBinding, catalog: store.modelCatalog, - // No explicit model yet → use the session backend's default, so the menu - // shows that backend's effort range rather than collapsing to one level. - modelSKU: summary.model ?? store.modelCatalog.models(for: summary.backend).first?.sku, - controlled: controlled, - // Orchestra is a one-way latch for an existing chat — the host won't turn it - // off, so disable the other levels here to match. - lockWhenOrchestra: true) - Spacer(minLength: 4) if let percent = contextPercent { // Weight and primary ink (not `.secondary`) so the metric stays legible over // whatever the Liquid Glass bar is blurring through. @@ -183,6 +173,7 @@ struct SessionDetailView: View { .font(.caption2.weight(.semibold)).foregroundStyle(.primary) .help("Context window used") } + Spacer(minLength: 4) // Locked on for a Nucleic Control project — those chats always run autonomously, and // the host keeps Auto on regardless of what the phone sends, so show it locked on to // match rather than offering a toggle that wouldn't take. From d16731db728b8194044b6c25c20497a2bfc4f76f Mon Sep 17 00:00:00 2001 From: Andrew Blakeslee Moore Date: Tue, 7 Jul 2026 04:41:20 -0700 Subject: [PATCH 7/7] Move Model Selector Position Nucleic-Session: 855E352B-51E5-4752-9C04-2C23B973FA44 Co-authored-by: Nucleic --- .../Views/SessionDetailView.swift | 22 ++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift b/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift index ae0c725..ea3f2e1 100644 --- a/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift +++ b/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift @@ -189,7 +189,8 @@ struct SessionDetailView: View { } } - /// The ⋯ menu: the diff viewer for everyone, then the control-scope session actions. + /// The ⋯ menu: the diff viewer for everyone, then — at control scope — the next-turn model / + /// effort selectors (moved off the composer to keep it compact) and the session actions. @ViewBuilder private var sessionMenu: some View { Menu { @@ -197,6 +198,8 @@ struct SessionDetailView: View { Label(diffLabel, systemImage: "plus.forwardslash.minus") } if let summary, store.canControl { + Divider() + modelEffortMenu(summary) Divider() sessionActions(summary) } @@ -205,6 +208,23 @@ struct SessionDetailView: View { } } + /// The model and effort selectors for the next turn, relocated from the composer's control row + /// into the ⋯ menu so the glass chat bar stays compact — each opens as a submenu of levels. + /// `ModelMenu` / `EffortMenu` are themselves menus, so they nest cleanly here. + @ViewBuilder + private func modelEffortMenu(_ summary: WireSessionSummary) -> some View { + // A session's backend is fixed at creation, so only same-backend models are offered. + ModelMenu(model: modelBinding, catalog: store.modelCatalog, backend: summary.backend) + EffortMenu(effort: effortBinding, catalog: store.modelCatalog, + // No explicit model yet → use the session backend's default, so the menu + // shows that backend's effort range rather than collapsing to one level. + modelSKU: summary.model ?? store.modelCatalog.models(for: summary.backend).first?.sku, + controlled: controlled, + // Orchestra is a one-way latch for an existing chat — the host won't turn it + // off, so disable the other levels here to match. + lockWhenOrchestra: true) + } + /// "View changes" with the live +/− counts when the host projects them, so the menu hints /// whether there's anything to look at before opening the sheet. private var diffLabel: String {