From cb026616dd7fad5e722573e03fe0e8477f51d5ec Mon Sep 17 00:00:00 2001 From: Andrew Blakeslee Moore Date: Mon, 6 Jul 2026 17:16:54 -0700 Subject: [PATCH 001/112] Remote Store Scope Nucleic-Session: F8081386-339F-46B4-A6A9-A0F53F618D34 Co-authored-by: Nucleic --- NucleicRemote/Shared/ApproveFromActivityIntent.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/NucleicRemote/Shared/ApproveFromActivityIntent.swift b/NucleicRemote/Shared/ApproveFromActivityIntent.swift index 51cad25..76fe813 100644 --- a/NucleicRemote/Shared/ApproveFromActivityIntent.swift +++ b/NucleicRemote/Shared/ApproveFromActivityIntent.swift @@ -1,5 +1,5 @@ import AppIntents -#if canImport(NucleicProtocol) +#if NUCLEIC_APP import NucleicProtocol #endif From 14aa335f6e5f5f4e8c8364d4120660d33274c0d8 Mon Sep 17 00:00:00 2001 From: Andrew Blakeslee Moore Date: Mon, 6 Jul 2026 17:16:58 -0700 Subject: [PATCH 002/112] Remote Store Scope Nucleic-Session: F8081386-339F-46B4-A6A9-A0F53F618D34 Co-authored-by: Nucleic --- NucleicRemote/Shared/ApproveFromActivityIntent.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/NucleicRemote/Shared/ApproveFromActivityIntent.swift b/NucleicRemote/Shared/ApproveFromActivityIntent.swift index 76fe813..ef7c62f 100644 --- a/NucleicRemote/Shared/ApproveFromActivityIntent.swift +++ b/NucleicRemote/Shared/ApproveFromActivityIntent.swift @@ -39,7 +39,7 @@ struct ApproveFromActivityIntent: AppIntent { @MainActor func perform() async throws -> some IntentResult { - #if canImport(NucleicProtocol) + #if NUCLEIC_APP let store = RemoteStore.shared // §3.3 — a high-risk allow is never resolved inline; route to the app's biometric-gated card. // (Surfaces shouldn't render an inline Allow for high-risk in the first place; this is the From 7aa27af3afc7c957ee71bc7bcf9294530efd9f68 Mon Sep 17 00:00:00 2001 From: Andrew Blakeslee Moore Date: Mon, 6 Jul 2026 17:17:16 -0700 Subject: [PATCH 003/112] Remote Store Scope Nucleic-Session: F8081386-339F-46B4-A6A9-A0F53F618D34 Co-authored-by: Nucleic --- NucleicRemote/Shared/ApproveFromActivityIntent.swift | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/NucleicRemote/Shared/ApproveFromActivityIntent.swift b/NucleicRemote/Shared/ApproveFromActivityIntent.swift index ef7c62f..747baad 100644 --- a/NucleicRemote/Shared/ApproveFromActivityIntent.swift +++ b/NucleicRemote/Shared/ApproveFromActivityIntent.swift @@ -7,12 +7,18 @@ import NucleicProtocol /// "resolve from the lock screen without unlocking" path (docs/APP_INTENTS_OPPORTUNITIES §4.1). /// /// It lives in the **Shared** group so both the app and the widget extension can reference it in -/// `Button(intent:)`. The widget extension links no `NucleicProtocol`, so this intent carries plain -/// `String` ids and compiles its real work only into the app (`#if canImport(NucleicProtocol)`). -/// That's sound because iOS runs a widget/Live-Activity button's intent in the **app's background +/// `Button(intent:)`. It carries plain `String` ids and compiles its real work only into the app, +/// gated on `#if NUCLEIC_APP` (a custom compilation condition set on the app target only). That's +/// sound because iOS runs a widget/Live-Activity button's intent in the **app's background /// process** — where `RemoteStore` owns the live E2EE channel — never in the extension. The /// extension-side copy exists solely to satisfy the `Button(intent:)` type reference. /// +/// NB: the guard is `#if NUCLEIC_APP`, *not* `#if canImport(NucleicProtocol)`. `canImport` tests +/// module findability, not linkage — and because the app builds `NucleicProtocol` into the shared +/// DerivedData products dir, it's findable from the widget extension too. So `canImport` is `true` +/// in the extension, which would compile this branch there and fail on the app-only `RemoteStore` +/// / `IntentError` types. `NUCLEIC_APP` tracks target membership, which is what we actually mean. +/// /// Not discoverable in Shortcuts/Spotlight: it's button-only, driven by ids embedded at render time /// (a human uses `AnswerApprovalIntent` for the spoken/Shortcuts path). struct ApproveFromActivityIntent: AppIntent { From d04acaa6e5381580ea8f99e06b2aa59df31e512e Mon Sep 17 00:00:00 2001 From: Andrew Blakeslee Moore Date: Mon, 6 Jul 2026 17:17:26 -0700 Subject: [PATCH 004/112] Remote Store Scope Nucleic-Session: F8081386-339F-46B4-A6A9-A0F53F618D34 Co-authored-by: Nucleic --- NucleicRemote/NucleicRemote.xcodeproj/project.pbxproj | 1 + 1 file changed, 1 insertion(+) diff --git a/NucleicRemote/NucleicRemote.xcodeproj/project.pbxproj b/NucleicRemote/NucleicRemote.xcodeproj/project.pbxproj index db9ea3e..716860d 100644 --- a/NucleicRemote/NucleicRemote.xcodeproj/project.pbxproj +++ b/NucleicRemote/NucleicRemote.xcodeproj/project.pbxproj @@ -357,6 +357,7 @@ SUPPORTS_MACCATALYST = NO; SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "$(inherited) NUCLEIC_APP"; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; From d532396743318ab51e6ff43a3ab6bee7190d338d Mon Sep 17 00:00:00 2001 From: Andrew Blakeslee Moore Date: Mon, 6 Jul 2026 17:17:40 -0700 Subject: [PATCH 005/112] Remote Store Scope Nucleic-Session: F8081386-339F-46B4-A6A9-A0F53F618D34 Co-authored-by: Nucleic --- NucleicRemote/NucleicRemote.xcodeproj/project.pbxproj | 1 + 1 file changed, 1 insertion(+) diff --git a/NucleicRemote/NucleicRemote.xcodeproj/project.pbxproj b/NucleicRemote/NucleicRemote.xcodeproj/project.pbxproj index 716860d..50da6ad 100644 --- a/NucleicRemote/NucleicRemote.xcodeproj/project.pbxproj +++ b/NucleicRemote/NucleicRemote.xcodeproj/project.pbxproj @@ -401,6 +401,7 @@ SUPPORTS_MACCATALYST = NO; SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "$(inherited) NUCLEIC_APP"; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; From e1bb0f07720e89d44d93c0f1f4f665035d5b0192 Mon Sep 17 00:00:00 2001 From: Andrew Blakeslee Moore Date: Mon, 6 Jul 2026 17:26:09 -0700 Subject: [PATCH 006/112] Chevron Button Behavior Nucleic-Session: 8BCB521F-EFDD-48A4-B1E8-1E0499B25516 Co-authored-by: Nucleic --- .../NucleicRemote/Views/SessionDetailView.swift | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift b/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift index 8247caf..ae2ab37 100644 --- a/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift +++ b/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift @@ -556,6 +556,17 @@ struct TranscriptList: View { guard transcriptSettling, transcriptContentHeight > 0 else { return } try? await Task.sleep(for: .milliseconds(80)) if Task.isCancelled { return } + // Pin to the true bottom before opening the gate. The settling layout lands a + // hair off the bottom, so the passive `.defaultScrollAnchor` alone leaves the + // scroll geometry reading "not at bottom" — and that stale reading latches + // `onScrollGeometryChange`'s tracked value at `false` while the gate above holds + // `isScrolledToBottom` at `true`. Because the callback only fires on a *change*, + // the user's first real scroll-up (still `false`) then never fires it: the chevron + // never appears and live output keeps yanking the view to the bottom, until a + // down-then-up round-trip finally re-emits the change. An explicit pin nudges the + // offset the last hair, forcing a fresh "at bottom" emission that re-syncs the + // tracker with the gate. Mirrors the Mac's reveal pin. + scrollToEnd(proxy, animated: false) transcriptSettling = false } // Coalescing means item count lags event count; key the autoscroll on the raw stream From e6fcaf52696f90bf8910573877ad875b9744ffcc Mon Sep 17 00:00:00 2001 From: Andrew Blakeslee Moore Date: Mon, 6 Jul 2026 17:31:50 -0700 Subject: [PATCH 007/112] Investigate Live Activity Session Load Nucleic-Session: 044A9B93-AB31-4B6B-9A27-3631261269B1 Co-authored-by: Nucleic --- .../Views/SessionDetailView.swift | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift b/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift index ae2ab37..ee776a1 100644 --- a/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift +++ b/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift @@ -34,18 +34,24 @@ struct SessionDetailView: View { TranscriptList(events: store.openEvents, scrollToBottomRequest: scrollToBottomRequest, isScrolledToBottom: $isScrolledToBottom) - // Measure the transcript's full height (the floating bar overlays it via safeAreaInset, - // so this frame is the whole screen area the card has to live within) and hand it to the - // action card so it can bound itself. + // The chat bar floats over the scrolling content on Liquid Glass instead of sitting + // in a boxed strip below it, so the transcript runs the full height of the screen. + .safeAreaInset(edge: .bottom) { actionArea } + // Measure the whole detail's height — the screen area the action card must fit within — + // and hand it to the card so it can cap itself and keep Allow/Deny (or Submit) on-screen. + // Measured *after* the safeAreaInset on purpose: this must be the fixed container height, + // not the transcript's inset-reduced height. The attention cards (approval / question) + // size themselves off `availableHeight` via `.frame(maxHeight:)`, so measuring it from the + // inset-reduced region fed the card's own height back into the value it was sized from — a + // layout cycle SwiftUI never resolved, pinning the main thread at 100% CPU during the push + // transition. It bit only the attention path (a card consumes this value; the plain + // composer doesn't), which is why a "Working" tap opened fine and an "attention" tap hung. .background { GeometryReader { proxy in Color.clear.preference(key: AvailableHeightKey.self, value: proxy.size.height) } } .onPreferenceChange(AvailableHeightKey.self) { availableHeight = $0 } - // The chat bar floats over the scrolling content on Liquid Glass instead of sitting - // in a boxed strip below it, so the transcript runs the full height of the screen. - .safeAreaInset(edge: .bottom) { actionArea } .navigationTitle(summary?.title ?? "Session") .navigationBarTitleDisplayMode(.inline) // With a session open the floating chat bar owns the bottom edge, so the compact From 5831d2286b1f0d7050ed8600549579cd795b03d8 Mon Sep 17 00:00:00 2001 From: Andrew Blakeslee Moore Date: Mon, 6 Jul 2026 17:35:53 -0700 Subject: [PATCH 008/112] Investigate Live Activity Session Load Nucleic-Session: 044A9B93-AB31-4B6B-9A27-3631261269B1 Co-authored-by: Nucleic --- .../Views/SessionDetailView.swift | 112 +++++++++--------- 1 file changed, 58 insertions(+), 54 deletions(-) diff --git a/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift b/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift index ee776a1..9687246 100644 --- a/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift +++ b/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift @@ -31,62 +31,66 @@ struct SessionDetailView: View { } var body: some View { - TranscriptList(events: store.openEvents, - scrollToBottomRequest: scrollToBottomRequest, - isScrolledToBottom: $isScrolledToBottom) - // The chat bar floats over the scrolling content on Liquid Glass instead of sitting - // in a boxed strip below it, so the transcript runs the full height of the screen. - .safeAreaInset(edge: .bottom) { actionArea } - // Measure the whole detail's height — the screen area the action card must fit within — - // and hand it to the card so it can cap itself and keep Allow/Deny (or Submit) on-screen. - // Measured *after* the safeAreaInset on purpose: this must be the fixed container height, - // not the transcript's inset-reduced height. The attention cards (approval / question) - // size themselves off `availableHeight` via `.frame(maxHeight:)`, so measuring it from the - // inset-reduced region fed the card's own height back into the value it was sized from — a - // layout cycle SwiftUI never resolved, pinning the main thread at 100% CPU during the push - // transition. It bit only the attention path (a card consumes this value; the plain - // composer doesn't), which is why a "Working" tap opened fine and an "attention" tap hung. - .background { - GeometryReader { proxy in - Color.clear.preference(key: AvailableHeightKey.self, value: proxy.size.height) + // The detail's height comes from the enclosing GeometryReader — a value fixed by the parent + // (the navigation content area), never by anything inside it. The attention cards (approval / + // question) cap themselves to this so their buttons stay on-screen. Sourcing it from a + // GeometryReader — rather than measuring the transcript, whose height the cards' own bottom + // `safeAreaInset` resizes — makes the measurement categorically independent of the card, so + // the card can't feed its height back into the value it's sized from. That feedback was an + // unresolved layout cycle that pinned the main thread at 100% CPU during the push transition; + // it showed up intermittently because a feedback loop only diverges for some content/card- + // height combinations. It bit only the attention path (a card consumes this value; the plain + // composer doesn't), which is why a "Working" tap opened fine and an "attention" tap hung. + GeometryReader { proxy in + TranscriptList(events: store.openEvents, + scrollToBottomRequest: scrollToBottomRequest, + isScrolledToBottom: $isScrolledToBottom) + // The chat bar floats over the scrolling content on Liquid Glass instead of sitting + // in a boxed strip below it, so the transcript runs the full height of the screen. + .safeAreaInset(edge: .bottom) { actionArea } + .navigationTitle(summary?.title ?? "Session") + .navigationBarTitleDisplayMode(.inline) + // With a session open the floating chat bar owns the bottom edge, so the compact + // shell's `NucleicTabBar` slides away while this detail is pushed — driven by the + // `open`/`closeOpen` below, which set `store.compactDetailPresented` (so the bar hides + // from any entry point, not just the Sessions list), not a per-navigation tab-bar + // toolbar hide (which restored the system bar late and made the "+" jump). A no-op in + // the iPad split, which has no tab bar. + .toolbar { + ToolbarItem(placement: .topBarTrailing) { sessionMenu } + } + .sheet(isPresented: $showDiff) { diffSheet } + .alert("Rename chat", isPresented: $showRename) { + TextField("Title", text: $renameDraft) + Button("Cancel", role: .cancel) {} + Button("Rename") { store.renameSession(sessionID, to: renameDraft) } + } + .confirmationDialog("Integrate this branch", isPresented: $showIntegrate, titleVisibility: .visible) { + Button("Merge") { store.integrate(sessionID, .merge) } + Button("Squash & merge") { store.integrate(sessionID, .squash) } + Button("Rebase") { store.integrate(sessionID, .rebase) } + Button("Cancel", role: .cancel) {} + } + .confirmationDialog( + "Discard this session's branch and worktree? Unmerged work is lost.", + isPresented: $showDiscard, titleVisibility: .visible + ) { + Button("Discard", role: .destructive) { store.discard(sessionID) } + Button("Cancel", role: .cancel) {} + } + .onAppear { store.open(sessionID) } + // Pass our own id so an iPad split-view A→B switch (which may mount B before A + // disappears) unsubscribes A without tearing down B's just-opened state. + .onDisappear { store.closeOpen(sessionID) } + .background { interruptShortcut } + // Publish the parent-determined height to the cards. `initial: true` seeds it on the + // first layout; it refreshes if the container resizes (rotation, keyboard, iPad split + // resize). Because `proxy.size.height` never depends on the card, updating this can't + // re-drive the measurement — no cycle. + .onChange(of: proxy.size.height, initial: true) { _, height in + availableHeight = height } - } - .onPreferenceChange(AvailableHeightKey.self) { availableHeight = $0 } - .navigationTitle(summary?.title ?? "Session") - .navigationBarTitleDisplayMode(.inline) - // With a session open the floating chat bar owns the bottom edge, so the compact - // shell's `NucleicTabBar` slides away while this detail is pushed — driven by the - // `open`/`closeOpen` below, which set `store.compactDetailPresented` (so the bar hides - // from any entry point, not just the Sessions list), not a per-navigation tab-bar - // toolbar hide (which restored the system bar late and made the "+" jump). A no-op in - // the iPad split, which has no tab bar. - .toolbar { - ToolbarItem(placement: .topBarTrailing) { sessionMenu } - } - .sheet(isPresented: $showDiff) { diffSheet } - .alert("Rename chat", isPresented: $showRename) { - TextField("Title", text: $renameDraft) - Button("Cancel", role: .cancel) {} - Button("Rename") { store.renameSession(sessionID, to: renameDraft) } } - .confirmationDialog("Integrate this branch", isPresented: $showIntegrate, titleVisibility: .visible) { - Button("Merge") { store.integrate(sessionID, .merge) } - Button("Squash & merge") { store.integrate(sessionID, .squash) } - Button("Rebase") { store.integrate(sessionID, .rebase) } - Button("Cancel", role: .cancel) {} - } - .confirmationDialog( - "Discard this session's branch and worktree? Unmerged work is lost.", - isPresented: $showDiscard, titleVisibility: .visible - ) { - Button("Discard", role: .destructive) { store.discard(sessionID) } - Button("Cancel", role: .cancel) {} - } - .onAppear { store.open(sessionID) } - // Pass our own id so an iPad split-view A→B switch (which may mount B before A - // disappears) unsubscribes A without tearing down B's just-opened state. - .onDisappear { store.closeOpen(sessionID) } - .background { interruptShortcut } } /// ⌘. interrupts a running session (the Mac's "stop" convention) — the action is otherwise From 20b60b566a6e7deb4e59480202c95684ab270c36 Mon Sep 17 00:00:00 2001 From: Andrew Blakeslee Moore Date: Mon, 6 Jul 2026 17:36:19 -0700 Subject: [PATCH 009/112] Investigate Live Activity Session Load Nucleic-Session: 044A9B93-AB31-4B6B-9A27-3631261269B1 Co-authored-by: Nucleic --- .../NucleicRemote/Views/SessionDetailView.swift | 9 --------- 1 file changed, 9 deletions(-) diff --git a/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift b/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift index 9687246..1f77503 100644 --- a/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift +++ b/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift @@ -458,15 +458,6 @@ struct SessionDetailView: View { } } -/// The transcript's measured height, fed to the floating action card so it can bound itself to the -/// screen (see `availableHeight`). -private struct AvailableHeightKey: PreferenceKey { - static var defaultValue: CGFloat { 0 } - static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) { - value = max(value, nextValue()) - } -} - struct TranscriptList: View { let events: [AgentEvent] /// Bumped by the parent when the user sends a message — a deliberate "show me what happens From 6f38baef34b6415f753c6474a88fcbd15967cc0c Mon Sep 17 00:00:00 2001 From: Andrew Blakeslee Moore Date: Mon, 6 Jul 2026 17:36:33 -0700 Subject: [PATCH 010/112] Add Stop Button Implementation Nucleic-Session: 1449BBC5-E23C-4D04-BB12-321F41ED2F11 Co-authored-by: Nucleic --- .../NucleicRemote/Views/SessionDetailView.swift | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift b/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift index 1f77503..2343df6 100644 --- a/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift +++ b/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift @@ -362,6 +362,18 @@ struct SessionDetailView: View { .textFieldStyle(.plain) .lineLimit(1...4) .padding(.vertical, 3) + // Stop the in-flight turn (the Mac's ⌘. / "Interrupt"). Shown only + // while running and at control scope; send stays at the far right so + // its position never shifts. Mirrors `interruptShortcut`. + if running && store.canControl { + Button { store.interrupt(sessionID) } label: { + Image(systemName: "stop.circle.fill") + .font(.title2) + .foregroundStyle(.red) + } + .disabled(!store.connectivity.isLive) + .accessibilityLabel("Stop") + } Button { store.sendInput(draft, to: sessionID) draft = "" From d1cc94d4d342d0a9f0a3576829efc062e4dfc1b8 Mon Sep 17 00:00:00 2001 From: Andrew Blakeslee Moore Date: Mon, 6 Jul 2026 17:42:28 -0700 Subject: [PATCH 011/112] Add Stop Button Implementation Nucleic-Session: 1449BBC5-E23C-4D04-BB12-321F41ED2F11 Co-authored-by: Nucleic --- NucleicRemote/NucleicRemote/Views/SessionDetailView.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift b/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift index 2343df6..f7e3f25 100644 --- a/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift +++ b/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift @@ -369,7 +369,7 @@ struct SessionDetailView: View { Button { store.interrupt(sessionID) } label: { Image(systemName: "stop.circle.fill") .font(.title2) - .foregroundStyle(.red) + .foregroundStyle(.secondary) } .disabled(!store.connectivity.isLive) .accessibilityLabel("Stop") From dc9800bb319e92fb7266f29042afcc4fecfe787e Mon Sep 17 00:00:00 2001 From: Andrew Blakeslee Moore Date: Mon, 6 Jul 2026 17:43:25 -0700 Subject: [PATCH 012/112] Session View Animation Nucleic-Session: A8FFA1A6-443F-444A-A735-C53958D7E570 Co-authored-by: Nucleic --- .../Views/SessionDetailView.swift | 34 ++++++++++++++++++- 1 file changed, 33 insertions(+), 1 deletion(-) diff --git a/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift b/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift index f7e3f25..4f8815d 100644 --- a/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift +++ b/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift @@ -494,8 +494,40 @@ struct TranscriptList: View { /// settling — a `.task(id:)` debounces it to detect when the opening layout has come to rest. @State private var transcriptContentHeight: CGFloat = 0 + /// Memoized transcript projection. `TranscriptProjection.build` folds the entire raw event + /// stream into rows on every read, but `body` re-evaluates far more often than `events` + /// actually changes: while the chat is opening, `onScrollGeometryChange` bumps + /// `transcriptContentHeight` on every settling-layout frame, and each bump re-runs `body` — + /// so the untouched event stream was re-folded dozens of times during the exact window the + /// tab bar is sliding away, starving that animation of the main thread and making the + /// load-in jitter. Reuse the last fold whenever the stream is unchanged. A class held in + /// `@State` so reading/updating it from `body` doesn't itself invalidate the view (SwiftUI + /// stores the reference and never diffs its interior). + private final class ProjectionCache { + /// The event stream is append-only with strictly increasing `seq`, so `(count, lastSeq)` + /// uniquely pins it; `firstSeq` guards the open reset (stream cleared to `[]`, then + /// refilled). The two display toggles are inputs to the fold, so they key it too. + private struct Key: Equatable { + var count: Int; var firstSeq: UInt64; var lastSeq: UInt64 + var showRaw: Bool; var showLockEvents: Bool + } + private var key: Key? + private var value: [TranscriptItem] = [] + + func items(for events: [AgentEvent], showRaw: Bool, showLockEvents: Bool) -> [TranscriptItem] { + let k = Key(count: events.count, firstSeq: events.first?.seq ?? 0, + lastSeq: events.last?.seq ?? 0, showRaw: showRaw, showLockEvents: showLockEvents) + if key == k { return value } + let built = TranscriptProjection.build(events, showRaw: showRaw, showLockEvents: showLockEvents) + key = k + value = built + return built + } + } + @State private var projectionCache = ProjectionCache() + private var items: [TranscriptItem] { - TranscriptProjection.build(events, showRaw: showRaw, showLockEvents: showLockEvents) + projectionCache.items(for: events, showRaw: showRaw, showLockEvents: showLockEvents) } /// How much content may still sit below the viewport's bottom edge and still count as "at From 12893fdc2d4a3a14cab5e0db2f3c628678fa3e43 Mon Sep 17 00:00:00 2001 From: Andrew Blakeslee Moore Date: Mon, 6 Jul 2026 18:01:12 -0700 Subject: [PATCH 013/112] Session View Animation Nucleic-Session: A8FFA1A6-443F-444A-A735-C53958D7E570 Co-authored-by: Nucleic --- .../Views/Transcript/MarkdownText.swift | 56 ++++++++++++++----- 1 file changed, 42 insertions(+), 14 deletions(-) diff --git a/NucleicRemote/NucleicRemote/Views/Transcript/MarkdownText.swift b/NucleicRemote/NucleicRemote/Views/Transcript/MarkdownText.swift index 4655543..82a5457 100644 --- a/NucleicRemote/NucleicRemote/Views/Transcript/MarkdownText.swift +++ b/NucleicRemote/NucleicRemote/Views/Transcript/MarkdownText.swift @@ -167,26 +167,54 @@ struct MarkdownText: View { .lineSpacing(4) } + /// One prose line's classification: the exact string that gets inline-parsed — the cache key + /// `prewarm` must match — paired with how it's styled. Single source of truth for both the + /// live `lineView` and the off-main `prewarm`, so a warmed key can never drift from the key + /// the layout later looks up (any drift and the warm entry would silently miss). + private enum LineStyle { + case blank + case heading(content: String, scale: CGFloat, weight: Font.Weight) + case bullet(content: String) + case plain(content: String) + + /// The string handed to `inline(_:)`, or nil for a line that renders without a parse. + var inlineContent: String? { + switch self { + case .blank: return nil + case .heading(let c, _, _), .bullet(let c), .plain(let c): return c + } + } + } + + /// Classify one raw prose line. Headings are checked before bullets (a heading marker wins), + /// and the plain case keeps the *raw* line (not the trimmed one) exactly as the old cascade + /// did — the inline parser preserves leading whitespace under `.inlineOnlyPreservingWhitespace`. + private static func classify(_ raw: String) -> LineStyle { + let trimmed = raw.trimmingCharacters(in: .whitespaces) + if trimmed.isEmpty { return .blank } + // Headings scale relative to the base prose size so the hierarchy holds at any base and + // never collapses to the body size. + if trimmed.hasPrefix("### ") { return .heading(content: String(trimmed.dropFirst(4)), scale: 1.13, weight: .semibold) } + if trimmed.hasPrefix("## ") { return .heading(content: String(trimmed.dropFirst(3)), scale: 1.28, weight: .bold) } + if trimmed.hasPrefix("# ") { return .heading(content: String(trimmed.dropFirst(2)), scale: 1.5, weight: .bold) } + if let bullet = bulletContent(trimmed) { return .bullet(content: bullet) } + return .plain(content: raw) + } + @ViewBuilder private func lineView(_ raw: String) -> some View { - let trimmed = raw.trimmingCharacters(in: .whitespaces) - if trimmed.isEmpty { + switch Self.classify(raw) { + case .blank: Color.clear.frame(height: 3) - } else if trimmed.hasPrefix("### ") { - // Headings scale relative to the base prose size so the hierarchy holds - // at any base and never collapses to the body size. - inline(String(trimmed.dropFirst(4))).font(.system(size: bodySize * 1.13, weight: .semibold)) - } else if trimmed.hasPrefix("## ") { - inline(String(trimmed.dropFirst(3))).font(.system(size: bodySize * 1.28, weight: .bold)) - } else if trimmed.hasPrefix("# ") { - inline(String(trimmed.dropFirst(2))).font(.system(size: bodySize * 1.5, weight: .bold)) - } else if let bullet = Self.bulletContent(trimmed) { + case .heading(let content, let scale, let weight): + inline(content).font(.system(size: bodySize * scale, weight: weight)) + case .bullet(let content): HStack(alignment: .firstTextBaseline, spacing: 6) { Text("•").foregroundStyle(.secondary) - inline(bullet) + inline(content) } - } else { - inline(raw) + case .plain(let content): + inline(content) } } From f69f3456b4429d09a93d0fd1f786d2e0b2e019db Mon Sep 17 00:00:00 2001 From: Andrew Blakeslee Moore Date: Mon, 6 Jul 2026 18:01:27 -0700 Subject: [PATCH 014/112] Session View Animation Nucleic-Session: A8FFA1A6-443F-444A-A735-C53958D7E570 Co-authored-by: Nucleic --- .../Views/Transcript/MarkdownText.swift | 31 +++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/NucleicRemote/NucleicRemote/Views/Transcript/MarkdownText.swift b/NucleicRemote/NucleicRemote/Views/Transcript/MarkdownText.swift index 82a5457..4ea8584 100644 --- a/NucleicRemote/NucleicRemote/Views/Transcript/MarkdownText.swift +++ b/NucleicRemote/NucleicRemote/Views/Transcript/MarkdownText.swift @@ -258,4 +258,35 @@ struct MarkdownText: View { inlineCache.setObject(InlineBox(parsed), forKey: key) return parsed } + + // MARK: - Pre-warming + + /// Populate the block and inline caches for `markdown` ahead of layout. `AttributedString(markdown:)` + /// is the dominant per-row cost when a long transcript first lays out, and the eager `VStack` + /// runs it for every prose line synchronously — right as the session is pushed and the tab bar + /// is sliding away, which is what makes the load-in jitter. Calling this off the main thread + /// (see `TranscriptList`) does that parsing in the background so the first layout hits ready + /// results instead. + /// + /// Safe to call from any thread and redundantly: the caches are `NSCache` (thread-safe) and + /// keyed only by the source string (independent of `bodySize`), so a warm value equals what + /// the main thread would compute, and a repeat call is a cheap cache hit. Cooperatively + /// cancellable — a huge transcript's warm loop bails the moment its owning task is cancelled. + static func prewarm(_ markdown: String) { + for block in parse(markdown) { // also warms the block cache + if Task.isCancelled { return } + switch block { + case .code: + break // a code block renders via `Text(code)` — no inline parse to warm + case .text(let text): + for raw in text.components(separatedBy: "\n") { + if let content = classify(raw).inlineContent { _ = attributedInline(content) } + } + case .table(let rows): + for row in rows where !Task.isCancelled { + for cell in row { _ = attributedInline(cell) } + } + } + } + } } From 501eb2d2adf1845fe44aa61013b16783f5e5b527 Mon Sep 17 00:00:00 2001 From: Andrew Blakeslee Moore Date: Mon, 6 Jul 2026 18:02:15 -0700 Subject: [PATCH 015/112] Session View Animation Nucleic-Session: A8FFA1A6-443F-444A-A735-C53958D7E570 Co-authored-by: Nucleic --- .../Views/SessionDetailView.swift | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift b/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift index 4f8815d..f6ac216 100644 --- a/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift +++ b/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift @@ -526,10 +526,36 @@ struct TranscriptList: View { } @State private var projectionCache = ProjectionCache() + /// The in-flight background Markdown pre-warm (below), cancelled when a newer one supersedes + /// it or the transcript goes away — so a long warm can't outlive the view or stack up behind + /// row churn. + @State private var prewarmTask: Task? + private var items: [TranscriptItem] { projectionCache.items(for: events, showRaw: showRaw, showLockEvents: showLockEvents) } + /// Kick off (or restart) the off-main Markdown pre-warm for the visible messages. `MarkdownText` + /// parses every prose line with `AttributedString(markdown:)` during the eager first layout — a + /// cost that lands on the main thread right as the session is pushed and the tab bar slides + /// away, jittering the load-in. Snapshot the message bodies here on the main actor (a cheap read + /// of the memoized projection), then parse them on a background task so that first layout finds + /// the caches already warm. Idempotent and self-cancelling; the parse results are the same + /// whichever thread fills the (thread-safe) caches. + private func prewarmMarkdown() { + let bodies: [String] = items.compactMap { + if case .message(_, let text) = $0.kind { return text } else { return nil } + } + guard !bodies.isEmpty else { return } + prewarmTask?.cancel() + prewarmTask = Task.detached(priority: .utility) { + for body in bodies { + if Task.isCancelled { return } + MarkdownText.prewarm(body) + } + } + } + /// How much content may still sit below the viewport's bottom edge and still count as "at /// the bottom" — a little slack so streaming deltas and rounding never flip the gate. Mirrors /// the Mac's `bottomFollowThreshold`. From 19ae2b029316c2544d70f523e16a8b7fd003b026 Mon Sep 17 00:00:00 2001 From: Andrew Blakeslee Moore Date: Mon, 6 Jul 2026 18:02:26 -0700 Subject: [PATCH 016/112] Session View Animation Nucleic-Session: A8FFA1A6-443F-444A-A735-C53958D7E570 Co-authored-by: Nucleic --- NucleicRemote/NucleicRemote/Views/SessionDetailView.swift | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift b/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift index f6ac216..e5f3b16 100644 --- a/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift +++ b/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift @@ -651,6 +651,12 @@ struct TranscriptList: View { // so it sits over the composer instead of behind the floating bar; a tap bumps this // same request, and following resumes once the geometry reader sees the bottom. .onChange(of: scrollToBottomRequest) { scrollToEnd(proxy) } + // Warm the Markdown parse caches off the main thread whenever the row set grows — + // `initial: true` fires it for the batch that lands on open (the expensive case), and + // each later new row tops it up. Keyed on the row *count*, so streaming deltas into an + // existing row (which don't change the count) never re-arm it. `items.count` is O(1). + .onChange(of: items.count, initial: true) { _, _ in prewarmMarkdown() } + .onDisappear { prewarmTask?.cancel() } } } From 1881d597c71561f6a4f19e4e3765e2ed1722613b Mon Sep 17 00:00:00 2001 From: Andrew Blakeslee Moore Date: Mon, 6 Jul 2026 18:20:22 -0700 Subject: [PATCH 017/112] Adjust Live Activity Icon Color Nucleic-Session: 0849E748-A24E-4E30-90B3-33FD4D06C4DB Co-authored-by: Nucleic --- .../NucleicRemoteWidgets/SessionLiveActivity.swift | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/NucleicRemote/NucleicRemoteWidgets/SessionLiveActivity.swift b/NucleicRemote/NucleicRemoteWidgets/SessionLiveActivity.swift index 38632c7..02af656 100644 --- a/NucleicRemote/NucleicRemoteWidgets/SessionLiveActivity.swift +++ b/NucleicRemote/NucleicRemoteWidgets/SessionLiveActivity.swift @@ -50,10 +50,12 @@ struct SessionLiveActivity: Widget { } } } compactLeading: { + // Calm state shows the Nucleic Control brand mark (the purple atom); when the + // user is needed it switches to the amber approval glyph. Image(systemName: state.needsAttention - ? ActivityPalette.glyph(.approval) : ActivityPalette.glyph(.running)) + ? ActivityPalette.glyph(.approval) : "atom") .foregroundStyle(state.needsAttention - ? ActivityPalette.attention : ActivityPalette.active) + ? ActivityPalette.attention : ActivityPalette.brand) } compactTrailing: { Text("\(state.needsAttention ? max(state.approvalCount, state.needsYouCount) : state.runningCount)") .font(.caption2.bold()) From 11e0fe2d661bb7b89a698ba89291f80b15dd5cd3 Mon Sep 17 00:00:00 2001 From: Andrew Blakeslee Moore Date: Mon, 6 Jul 2026 18:20:26 -0700 Subject: [PATCH 018/112] Adjust Live Activity Icon Color Nucleic-Session: 0849E748-A24E-4E30-90B3-33FD4D06C4DB Co-authored-by: Nucleic --- NucleicRemote/NucleicRemoteWidgets/SessionLiveActivity.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/NucleicRemote/NucleicRemoteWidgets/SessionLiveActivity.swift b/NucleicRemote/NucleicRemoteWidgets/SessionLiveActivity.swift index 02af656..8619626 100644 --- a/NucleicRemote/NucleicRemoteWidgets/SessionLiveActivity.swift +++ b/NucleicRemote/NucleicRemoteWidgets/SessionLiveActivity.swift @@ -282,6 +282,7 @@ private enum ActivityPalette { static let success = Color(red: 0.30, green: 0.78, blue: 0.45) // done green static let danger = Color(red: 0.92, green: 0.34, blue: 0.34) // error red static let neutral = Color.secondary + static let brand = Color(red: 0.62, green: 0.51, blue: 0.93) // Nucleic Control purple static let added = Color(red: 0.30, green: 0.78, blue: 0.45) static let removed = Color(red: 0.92, green: 0.34, blue: 0.34) From 6e22cde1e4bfd173f4c77135540d8680628cace1 Mon Sep 17 00:00:00 2001 From: Andrew Blakeslee Moore Date: Mon, 6 Jul 2026 18:26:35 -0700 Subject: [PATCH 019/112] Transcript Incremental Projection Nucleic-Session: 0FFD007B-0696-4517-9429-129C7B0FD5AC Co-authored-by: Nucleic --- .../Transcript/TranscriptProjection.swift | 53 +++++++++++++++++-- 1 file changed, 49 insertions(+), 4 deletions(-) diff --git a/NucleicRemote/NucleicRemote/Views/Transcript/TranscriptProjection.swift b/NucleicRemote/NucleicRemote/Views/Transcript/TranscriptProjection.swift index 38e04fc..2ca4205 100644 --- a/NucleicRemote/NucleicRemote/Views/Transcript/TranscriptProjection.swift +++ b/NucleicRemote/NucleicRemote/Views/Transcript/TranscriptProjection.swift @@ -102,26 +102,71 @@ enum TranscriptProjection { /// owns them, project the main agent's own events at the top level, and recursively project /// each subagent's events into the `children` of its spawn — so a subagent's inner work nests /// under its card instead of leaking (and interleaving) into the main transcript. + /// + /// One-shot form: folds the whole stream in one pass. The live transcript uses + /// `IncrementalTranscriptProjection`, which folds the stream as seam-delimited segments via + /// `buildSegment` — this wrapper is the `priorEdits: []` whole-stream case of that. static func build(_ events: [AgentEvent], showRaw: Bool, showLockEvents: Bool) -> [TranscriptItem] { + buildSegment(events[...], worktreeRoot: worktreeRoot(in: events[...]), priorEdits: [], + showRaw: showRaw, showLockEvents: showLockEvents).items + } + + /// An edit-class tool call folded in an *earlier* segment, carried forward so a later + /// segment's lock notes can still fold onto it across the seam (a lock's `released` note + /// lands when the file *lands* in the parent — potentially many turns after the edit). + struct PriorEdit: Equatable { + let toolCallID: String + /// Repo-relative, normalized paths this call writes (`editedPaths` ∘ `normalizeForLock`). + let paths: [String] + } + + /// The folded output of one contiguous slice of the stream. + struct SegmentFold { + var items: [TranscriptItem] + /// Lock lines whose backward path-match crossed the seam onto a `priorEdits` entry, in + /// note order. The caller owns those already-folded items and must attach these to them — + /// that's what keeps `fold(prefix) ++ fold(tail)` byte-identical to `fold(whole)` even + /// for a lock note released turns after its edit. + var priorLockPatches: [(toolCallID: String, lock: NoteLock)] + /// This segment's own edit-class calls (in item order), for the caller's registry. + var edits: [PriorEdit] + } + + /// Fold one contiguous slice of the stream with an injected worktree root (a mid-stream + /// slice lacks the seq-0 `sessionStarted` that `build` rescans for). The slice must be + /// seam-delimited — no coalescing key, subagent scope, or open tool run straddling either + /// end — which is exactly what `IncrementalTranscriptProjection` guarantees before calling. + static func buildSegment( + _ events: ArraySlice, worktreeRoot root: String?, priorEdits: [PriorEdit], + showRaw: Bool, showLockEvents: Bool + ) -> SegmentFold { let (topLevel, byParent) = partition(events) // Fold lock-lifecycle notes onto the edit cards they bracket, exactly as the Mac's // `items(_:worktreeRoot:)` does — matched against the session's working directory so an // edit's absolute `file_path` compares against the note's repo-relative paths. Folding // happens only at the top level (a subagent's inner edits are literal, unlocked); the // subagent recursion below stays plain, matching the desktop projection. - let root = worktreeRoot(in: topLevel) + var patches: [(toolCallID: String, lock: NoteLock)] = [] let flat = foldLockNotes(flatItems(topLevel, showRaw: showRaw, showLockEvents: showLockEvents), - worktreeRoot: root) - return coalesceToolRuns(flat).map { + worktreeRoot: root, priorEdits: priorEdits, priorLockPatches: &patches) + let items = coalesceToolRuns(flat).map { attachSubagentChildren($0, byParent: byParent, depth: 0, showRaw: showRaw, showLockEvents: showLockEvents) } + let edits = flat.compactMap { item -> PriorEdit? in + guard case .tool(let group) = item.kind else { return nil } + let paths = editedPaths(toolName: group.name, input: group.input) + .map { normalizeForLock($0, worktreeRoot: root) } + .filter { !$0.isEmpty } + return paths.isEmpty ? nil : PriorEdit(toolCallID: group.toolCallID, paths: paths) + } + return SegmentFold(items: items, priorLockPatches: patches, edits: edits) } /// The session's working directory, read from its `sessionStarted` event, so an edit's /// absolute `file_path` can be made repo-relative to compare against a lock note's /// repo-relative paths. `nil` before the start event is seen (nothing to fold against yet). - private static func worktreeRoot(in events: [AgentEvent]) -> String? { + static func worktreeRoot(in events: ArraySlice) -> String? { for event in events { if case .sessionStarted(let started) = event.kind, !started.cwd.isEmpty { return started.cwd From 2f084a7d1847f78172fc705149c8543cef7e9193 Mon Sep 17 00:00:00 2001 From: Andrew Blakeslee Moore Date: Mon, 6 Jul 2026 18:27:01 -0700 Subject: [PATCH 020/112] Transcript Incremental Projection Nucleic-Session: 0FFD007B-0696-4517-9429-129C7B0FD5AC Co-authored-by: Nucleic --- .../Transcript/TranscriptProjection.swift | 28 +++++++++++++++++-- 1 file changed, 25 insertions(+), 3 deletions(-) diff --git a/NucleicRemote/NucleicRemote/Views/Transcript/TranscriptProjection.swift b/NucleicRemote/NucleicRemote/Views/Transcript/TranscriptProjection.swift index 2ca4205..bb22f9c 100644 --- a/NucleicRemote/NucleicRemote/Views/Transcript/TranscriptProjection.swift +++ b/NucleicRemote/NucleicRemote/Views/Transcript/TranscriptProjection.swift @@ -183,7 +183,15 @@ enum TranscriptProjection { /// `file_path` is stripped of `worktreeRoot` and normalized so it compares against the note's /// repo-relative paths. A note overlapping no preceding edit is left in place (renders /// standalone), matching the Mac's `foldLockNotes`. - private static func foldLockNotes(_ flat: [TranscriptItem], worktreeRoot: String?) -> [TranscriptItem] { + /// + /// The backward scan continues past the front of `flat` into `priorEdits` — the edits of + /// already-folded earlier segments, oldest first — so a note whose edit was sealed in a prior + /// segment (a lock released turns later) still folds exactly where a whole-stream fold would + /// put it. Those hits are reported via `priorLockPatches` for the caller to attach. + private static func foldLockNotes( + _ flat: [TranscriptItem], worktreeRoot: String?, priorEdits: [PriorEdit], + priorLockPatches: inout [(toolCallID: String, lock: NoteLock)] + ) -> [TranscriptItem] { // Each tool item's repo-relative edited paths (only edit-class tools have any), by index. var editsByIndex: [Int: (id: String, paths: [String])] = [:] for (i, item) in flat.enumerated() { @@ -193,7 +201,8 @@ enum TranscriptProjection { .filter { !$0.isEmpty } if !paths.isEmpty { editsByIndex[i] = (group.toolCallID, paths) } } - guard !editsByIndex.isEmpty else { return flat } + guard !editsByIndex.isEmpty || !priorEdits.isEmpty else { return flat } + let priorIDs = Set(priorEdits.map(\.toolCallID)) var locksByCall: [String: [NoteLock]] = [:] var folded = Set() @@ -212,13 +221,26 @@ enum TranscriptProjection { guard let edit = editsByIndex[j] else { continue } if edit.paths.contains(where: { pathsOverlap($0, path) }) { hitID = edit.id; break } } + if hitID == nil { + // Nothing in this segment — keep scanning backward across the seam, newest + // prior edit first, exactly where a whole-stream scan would look next. + for prior in priorEdits.reversed() + where prior.paths.contains(where: { pathsOverlap($0, path) }) { + hitID = prior.toolCallID + break + } + } guard let hitID else { matchedAll = false; break } if let k = indexByID[hitID] { perCard[k].paths.append(path) } else { indexByID[hitID] = perCard.count; perCard.append((hitID, [path])) } } guard matchedAll, !perCard.isEmpty else { continue } for card in perCard { - locksByCall[card.id, default: []].append(NoteLock(state: lock.state, paths: card.paths)) + if priorIDs.contains(card.id) { + priorLockPatches.append((card.id, NoteLock(state: lock.state, paths: card.paths))) + } else { + locksByCall[card.id, default: []].append(NoteLock(state: lock.state, paths: card.paths)) + } } folded.insert(i) } From 564ec62715f33f1c011960020c36b3eb814f37ee Mon Sep 17 00:00:00 2001 From: Andrew Blakeslee Moore Date: Mon, 6 Jul 2026 18:27:09 -0700 Subject: [PATCH 021/112] Transcript Incremental Projection Nucleic-Session: 0FFD007B-0696-4517-9429-129C7B0FD5AC Co-authored-by: Nucleic --- .../NucleicRemote/Views/Transcript/TranscriptProjection.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/NucleicRemote/NucleicRemote/Views/Transcript/TranscriptProjection.swift b/NucleicRemote/NucleicRemote/Views/Transcript/TranscriptProjection.swift index bb22f9c..6b1a6ca 100644 --- a/NucleicRemote/NucleicRemote/Views/Transcript/TranscriptProjection.swift +++ b/NucleicRemote/NucleicRemote/Views/Transcript/TranscriptProjection.swift @@ -302,7 +302,7 @@ enum TranscriptProjection { /// Split a scope's events into the main agent's own (`topLevel`) and each subagent's, keyed by /// the spawning `Task`'s id. - private static func partition(_ events: [AgentEvent]) + private static func partition(_ events: ArraySlice) -> (topLevel: [AgentEvent], byParent: [String: [AgentEvent]]) { let parentOf = toolParentMap(events) From 40b9e6dfa4f0da96a1a2189b1c65609b73650238 Mon Sep 17 00:00:00 2001 From: Andrew Blakeslee Moore Date: Mon, 6 Jul 2026 18:27:11 -0700 Subject: [PATCH 022/112] Transcript Incremental Projection Nucleic-Session: 0FFD007B-0696-4517-9429-129C7B0FD5AC Co-authored-by: Nucleic --- .../NucleicRemote/Views/Transcript/TranscriptProjection.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/NucleicRemote/NucleicRemote/Views/Transcript/TranscriptProjection.swift b/NucleicRemote/NucleicRemote/Views/Transcript/TranscriptProjection.swift index 6b1a6ca..64ae655 100644 --- a/NucleicRemote/NucleicRemote/Views/Transcript/TranscriptProjection.swift +++ b/NucleicRemote/NucleicRemote/Views/Transcript/TranscriptProjection.swift @@ -321,7 +321,7 @@ enum TranscriptProjection { /// Maps each tool-call id to its parent subagent's id, built from the call start/complete /// events. A tool *result* or *file change* names only a tool id, so it inherits its subagent /// scope from the call it belongs to via this map. - private static func toolParentMap(_ events: [AgentEvent]) -> [String: String] { + private static func toolParentMap(_ events: ArraySlice) -> [String: String] { var map: [String: String] = [:] for event in events { switch event.kind { From 4ed1e0b9cdb1c9e42c7e2741cb4ac9b4095d1ae9 Mon Sep 17 00:00:00 2001 From: Andrew Blakeslee Moore Date: Mon, 6 Jul 2026 18:27:25 -0700 Subject: [PATCH 023/112] Transcript Incremental Projection Nucleic-Session: 0FFD007B-0696-4517-9429-129C7B0FD5AC Co-authored-by: Nucleic --- .../Views/Transcript/TranscriptProjection.swift | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/NucleicRemote/NucleicRemote/Views/Transcript/TranscriptProjection.swift b/NucleicRemote/NucleicRemote/Views/Transcript/TranscriptProjection.swift index 64ae655..6f52da4 100644 --- a/NucleicRemote/NucleicRemote/Views/Transcript/TranscriptProjection.swift +++ b/NucleicRemote/NucleicRemote/Views/Transcript/TranscriptProjection.swift @@ -579,4 +579,21 @@ extension JSONValue { default: return false } } + + /// A one-line gist of a tool input/result for the collapsed row. Lives here (not with the + /// row views) because the projection folds it into `.raw` bodies, and this file also builds + /// standalone as the host-testable `NucleicRemoteProjection` SwiftPM target. + var compactSummary: String { + switch self { + case .string(let s): return s + case .object(let o): + if let cmd = o["command"]?.stringValue { return cmd } + if let path = o["file_path"]?.stringValue ?? o["path"]?.stringValue { return path } + return o.keys.sorted().joined(separator: ", ") + case .array(let a): return "[\(a.count) items]" + case .number(let n): return String(n) + case .bool(let b): return String(b) + case .null: return "null" + } + } } From b3da4cfcac5fe7b4dcf4681de82b3f80e3b08eeb Mon Sep 17 00:00:00 2001 From: Andrew Blakeslee Moore Date: Mon, 6 Jul 2026 18:27:29 -0700 Subject: [PATCH 024/112] Transcript Incremental Projection Nucleic-Session: 0FFD007B-0696-4517-9429-129C7B0FD5AC Co-authored-by: Nucleic --- .../NucleicRemote/Views/TranscriptRow.swift | 16 ++-------------- 1 file changed, 2 insertions(+), 14 deletions(-) diff --git a/NucleicRemote/NucleicRemote/Views/TranscriptRow.swift b/NucleicRemote/NucleicRemote/Views/TranscriptRow.swift index f492cec..ebe23b2 100644 --- a/NucleicRemote/NucleicRemote/Views/TranscriptRow.swift +++ b/NucleicRemote/NucleicRemote/Views/TranscriptRow.swift @@ -250,20 +250,8 @@ extension JSONValue { return canonical == "null" ? "" : canonical } - /// A one-line gist of a tool input/result for the collapsed row. - var compactSummary: String { - switch self { - case .string(let s): return s - case .object(let o): - if let cmd = o["command"]?.stringValue { return cmd } - if let path = o["file_path"]?.stringValue ?? o["path"]?.stringValue { return path } - return o.keys.sorted().joined(separator: ", ") - case .array(let a): return "[\(a.count) items]" - case .number(let n): return String(n) - case .bool(let b): return String(b) - case .null: return "null" - } - } + // (`compactSummary` — the one-line gist — lives in TranscriptProjection.swift, which builds + // standalone as the host-testable NucleicRemoteProjection SwiftPM target and needs it there.) /// The full, **untruncated** content of a tool input for the approval card — /// the user must see exactly what they are granting before allowing. Unlike From 56e05a4f8156d3208849a6c4bd35b40fcae266b1 Mon Sep 17 00:00:00 2001 From: Andrew Blakeslee Moore Date: Mon, 6 Jul 2026 18:28:55 -0700 Subject: [PATCH 025/112] Transcript Incremental Projection Nucleic-Session: 0FFD007B-0696-4517-9429-129C7B0FD5AC Co-authored-by: Nucleic --- .../IncrementalTranscriptProjection.swift | 417 ++++++++++++++++++ 1 file changed, 417 insertions(+) create mode 100644 NucleicRemote/NucleicRemote/Views/Transcript/IncrementalTranscriptProjection.swift diff --git a/NucleicRemote/NucleicRemote/Views/Transcript/IncrementalTranscriptProjection.swift b/NucleicRemote/NucleicRemote/Views/Transcript/IncrementalTranscriptProjection.swift new file mode 100644 index 0000000..54a2ec6 --- /dev/null +++ b/NucleicRemote/NucleicRemote/Views/Transcript/IncrementalTranscriptProjection.swift @@ -0,0 +1,417 @@ +import Foundation +import NucleicProtocol + +/// Stable-prefix incremental projector (docs/TRANSCRIPT_INCREMENTAL_PROJECTION.md). +/// +/// `TranscriptProjection.build` folds the whole stream on every read, so a streaming turn of K +/// deltas over an N-event transcript costs O(N·K) ≈ O(N²) per session. But the stream is +/// immutable except at the tail: everything before the live turn is frozen — its `messageID`s and +/// `toolCallID`s never recur. So this projector **seals** the longest provably-stable prefix of +/// the folded item list once, and re-folds only the unstable suffix per delta: O(live-tail) +/// instead of O(N). +/// +/// The seal point (an index into the raw event stream) is chosen so that +/// `fold(prefix) ++ fold(tail)` is byte-identical to `fold(whole)`: +/// +/// 1. **No coalescing key crosses the seam.** Text/thinking/tool items coalesce on +/// `messageID`/`toolCallID` (and a subagent's events name their parent Task). An exact +/// interval check over the current stream forbids any seam inside an id's first→last +/// reference span, so no item can straddle it. +/// 2. **No id can recur after the seam.** Future arrivals are fenced by closure rules read off +/// the stream itself: a tool is closed once its result arrived; a message/thinking block once +/// a different message has started in its scope; everything, once its turn completed. These +/// are the premises of the design doc ("the stream is immutable except at the tail"); if one +/// is ever violated — a tail event referencing a sealed id — it is *detected* and the +/// projector resets and re-folds from scratch, so correctness never rests on them. +/// 3. **No tool run is split.** `coalesceToolRuns` merges adjacent `.tool` items, so the seam +/// only falls where the last folded item is a hard separator — a visible non-tool row that a +/// future tool call can't merge across. (Empty redacted-thinking rows are transparent to runs +/// and therefore to this rule too.) +/// 4. **Lock notes fold exactly, even across the seam.** A lock's `released` note lands when the +/// file lands in the parent — potentially many turns after the edit it brackets — so sealed +/// edit cards stay reachable through a registry (`TranscriptProjection.PriorEdit`): a tail +/// note that path-matches a sealed edit patches that card, exactly where a whole-stream fold +/// would put it. No lag heuristic, no divergence. +/// 5. **`worktreeRoot` is a carried constant.** Lock-path normalization needs the seq-0 +/// `sessionStarted` cwd; nothing seals until it is known, and it never changes once found. +/// +/// The equivalence test (`IncrementalProjectionEquivalenceTests`) replays streams and asserts +/// `incremental(prefix) == build(prefix)` for **every** prefix — the whole correctness argument, +/// checked mechanically. +/// +/// A class held in `@State` so reads/updates from `body` don't invalidate the view; not +/// thread-safe (main-actor use only, like the `ProjectionCache` it replaces). +final class IncrementalTranscriptProjection { + + // MARK: - Sealed state + + /// Folded output of the stable prefix — appended to at each seal, never re-walked. + private var sealedItems: [TranscriptItem] = [] + /// Watermark into the raw stream: `events[0.. = [] + /// Sealed edit-class calls in item order, for cross-seam lock-note folding (rule 4). + private var sealedEdits: [TranscriptProjection.PriorEdit] = [] + /// Where each sealed edit's card sits in `sealedItems` (it may live inside a `.toolBlock`). + private var sealedEditIndex: [String: Int] = [:] + /// Display toggles are fold inputs; flipping either resets. + private var showRaw = false + private var showLockEvents = true + + // MARK: - Read memo + + /// `body` re-evaluates far more often than the stream changes (settling layout, scroll + /// geometry); this collapses those redundant reads to a cached return, as the previous + /// `ProjectionCache` did. The stream is append-only with strictly increasing seq, so + /// `(count, firstSeq, lastSeq)` pins it. + private struct MemoKey: Equatable { + var count: Int + var firstSeq: UInt64 + var lastSeq: UInt64 + var showRaw: Bool + var showLockEvents: Bool + } + private var memoKey: MemoKey? + private var memoValue: [TranscriptItem] = [] + + /// Hold-back from the stream edge: never seal into the newest few events. Decoders emit + /// tightly-coupled events in one batch (a `toolResult` and its inferred `fileChange`; a + /// whole-message's final chunks) that sync may deliver one at a time — holding the edge back + /// keeps a mid-batch read from sealing an entity whose trailing batch-mates are still in + /// flight. Cheap insurance on top of the closure rules; violations would only cost a reset. + private let edgeLag = 4 + + /// Test hook: how far the watermark has advanced (the equivalence suite also asserts sealing + /// actually happens, so a regression to "never seal" can't pass silently). + var sealedEventCountForTesting: Int { sealedEventCount } + + // MARK: - Read + + func items(for events: [AgentEvent], showRaw: Bool, showLockEvents: Bool) -> [TranscriptItem] { + let key = MemoKey(count: events.count, firstSeq: events.first?.seq ?? 0, + lastSeq: events.last?.seq ?? 0, showRaw: showRaw, showLockEvents: showLockEvents) + if memoKey == key { return memoValue } + + if needsReset(events, showRaw: showRaw, showLockEvents: showLockEvents) { reset() } + self.showRaw = showRaw + self.showLockEvents = showLockEvents + streamFirstSeq = events.first?.seq + if worktreeRoot == nil { + // Only the unsealed region needs scanning: sealing requires the root, so a sealed + // region can only exist after it was found. + worktreeRoot = TranscriptProjection.worktreeRoot(in: events[sealedEventCount...]) + } + + var watermark = chooseWatermark(events) + if watermark == nil { + // A tail event referenced a sealed id — a closure premise was violated (late file + // change, resumed message, post-result subagent child). Refold from scratch; with no + // sealed ids the second pass cannot be violated. + reset() + streamFirstSeq = events.first?.seq + worktreeRoot = TranscriptProjection.worktreeRoot(in: events[...]) + watermark = chooseWatermark(events) + } + seal(events, upTo: watermark ?? sealedEventCount) + + let result = render(events) + memoKey = key + memoValue = result + return result + } + + // MARK: - Reset / identity + + private func needsReset(_ events: [AgentEvent], showRaw: Bool, showLockEvents: Bool) -> Bool { + if showRaw != self.showRaw || showLockEvents != self.showLockEvents { return true } + if events.count < sealedEventCount { return true } + if sealedEventCount > 0 { + if events.first?.seq != streamFirstSeq { return true } + if events[sealedEventCount - 1].seq != sealedLastSeq { return true } + } else if streamFirstSeq != nil, events.first?.seq != streamFirstSeq { + return true // nothing sealed, but the carried worktreeRoot belongs to the old stream + } + return false + } + + private func reset() { + sealedItems = [] + sealedEventCount = 0 + sealedLastSeq = 0 + streamFirstSeq = nil + worktreeRoot = nil + sealedIDs = [] + sealedEdits = [] + sealedEditIndex = [:] + memoKey = nil + memoValue = [] + } + + // MARK: - Watermark selection + + /// The coalescing ids an event mentions: its `messageID` or `toolCallID`, plus the parent + /// Task id for subagent-owned events. Two events sharing an id must land on the same side of + /// the seam; the parent link chains a subagent's whole scope (and, transitively, deeper + /// descendants) to its spawn. + private static func refs(of event: AgentEvent) -> [String] { + switch event.kind { + case .userText(let c), .assistantText(let c), .thinking(let c): + if let parent = c.parentToolCallID { return [c.messageID, parent] } + return [c.messageID] + case .toolCallStarted(let c), .toolCallCompleted(let c): + if let parent = c.parentToolCallID { return [c.toolCallID, parent] } + return [c.toolCallID] + case .toolCallInputDelta(let d): return [d.toolCallID] + case .toolResult(let r): return [r.toolCallID] + case .fileChange(let f): return f.toolCallID.map { [$0] } ?? [] + default: return [] + } + } + + /// One id's life within the unsealed region. + private struct IDSpan { + var firstRef: Int + var lastRef: Int + /// Result arrived → the tool (or Task, with its children) is done. + var resultSeen = false + /// A later chunk with a different messageID in the same scope → this message is done + /// (its authoritative non-partial text can only arrive before the next message starts). + var closedByChunk = false + /// For message/thinking ids: the owning subagent scope ("" = top level). The owner + /// Task's result closes everything inside it. + var chunkScope: String? + } + + /// What an event *creates* in the folded item list, for the run-split rule (3). + private enum Creation { + /// A visible, never-dropped, non-tool row — a safe last-item for a seam. + case separator + /// A `.tool` row a future adjacent call could merge with. + case tool + /// A thinking row: a separator iff its final text is non-empty (an empty redacted block + /// is transparent to run coalescing, so it must be transparent to the seam rule too). + case thinking(String) + /// A lock note that may fold away (dropping it can fuse the runs around it), so it + /// counts as nothing — the seam just waits for the next hard separator. + case transparent + } + + /// The furthest event index the stream can be sealed to right now, or nil when a region event + /// references an already-sealed id (premise violation → caller resets). + private func chooseWatermark(_ events: [AgentEvent]) -> Int? { + let start = sealedEventCount + let n = events.count - start + // Everything below needs the worktree root (rule 5); without it, just verify no sealed-id + // violation … but nothing is sealed if no root was ever found, so there is nothing to do. + guard worktreeRoot != nil else { return start } + guard n > edgeLag else { + // Too little unsealed to advance, but tail refs must still be validated against + // sealed ids so a violation triggers the reset path. + for r in 0..() + var seenThinkingItem = Set() + var seenToolItem = Set() + var lastChunkInScope: [String: String] = [:] + var lastBoundary = -1 // region index of the latest turnCompleted/runFinished + + for r in 0..= 1 { + runningMaxLast = max(runningMaxLast, maxLastAtFirst[w - 1]) + if separatorOK[w - 1] && runningMaxLast < w { best = w } + } + return start + best + } + + /// Track a text/thinking chunk for message-closure: a new messageID in a scope closes the + /// previous one (chunks of one message never resume after the next begins — decoder order). + private func trackChunk( + _ chunk: TextChunk, in spans: inout [String: IDSpan], lastChunkInScope: inout [String: String] + ) { + let scope = chunk.parentToolCallID ?? "" + spans[chunk.messageID]?.chunkScope = scope + if let previous = lastChunkInScope[scope], previous != chunk.messageID { + spans[previous]?.closedByChunk = true + } + lastChunkInScope[scope] = chunk.messageID + } + + private func isClosed(_ span: IDSpan, spans: [String: IDSpan], lastBoundary: Int) -> Bool { + if span.lastRef < lastBoundary { return true } // its turn completed; ids don't cross turns + if span.resultSeen { return true } + if span.closedByChunk { return true } + if let scope = span.chunkScope, !scope.isEmpty, spans[scope]?.resultSeen == true { + return true // the owning subagent returned; its inner stream is done + } + return false + } + + // MARK: - Sealing + + private func seal(_ events: [AgentEvent], upTo watermark: Int) { + guard watermark > sealedEventCount else { return } + let slice = events[sealedEventCount.. [TranscriptItem] { + let tailSlice = events[sealedEventCount...] + guard !tailSlice.isEmpty else { return sealedItems } + let tail = TranscriptProjection.buildSegment( + tailSlice, worktreeRoot: worktreeRoot, priorEdits: sealedEdits, + showRaw: showRaw, showLockEvents: showLockEvents) + if tail.priorLockPatches.isEmpty { return sealedItems + tail.items } + // A live (unsealed) lock note folded onto a sealed edit card: patch a copy per read — + // the note may still be re-evaluated until it seals, so the base stays unpatched. + var patched = sealedItems + for patch in tail.priorLockPatches { + Self.applyLock(patch.lock, to: patch.toolCallID, at: sealedEditIndex, in: &patched) + } + return patched + tail.items + } + + /// Append a folded lock line to a sealed edit's card, whether it renders alone or inside a + /// coalesced `.toolBlock`. + private static func applyLock( + _ lock: NoteLock, to toolCallID: String, at index: [String: Int], + in items: inout [TranscriptItem] + ) { + guard let i = index[toolCallID] else { return } + switch items[i].kind { + case .tool(var group) where group.toolCallID == toolCallID: + group.lockLines.append(lock) + items[i].kind = .tool(group) + case .toolBlock(var groups): + guard let k = groups.firstIndex(where: { $0.toolCallID == toolCallID }) else { return } + groups[k].lockLines.append(lock) + items[i].kind = .toolBlock(groups) + default: + break + } + } +} From d72c843286ca7087066f3a02578dd227e735caf6 Mon Sep 17 00:00:00 2001 From: Andrew Blakeslee Moore Date: Mon, 6 Jul 2026 18:29:10 -0700 Subject: [PATCH 026/112] Transcript Incremental Projection Nucleic-Session: 0FFD007B-0696-4517-9429-129C7B0FD5AC Co-authored-by: Nucleic --- .../Transcript/IncrementalTranscriptProjection.swift | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/NucleicRemote/NucleicRemote/Views/Transcript/IncrementalTranscriptProjection.swift b/NucleicRemote/NucleicRemote/Views/Transcript/IncrementalTranscriptProjection.swift index 54a2ec6..7f76c9d 100644 --- a/NucleicRemote/NucleicRemote/Views/Transcript/IncrementalTranscriptProjection.swift +++ b/NucleicRemote/NucleicRemote/Views/Transcript/IncrementalTranscriptProjection.swift @@ -310,9 +310,11 @@ final class IncrementalTranscriptProjection { var runningMaxLast = -1 var best = 0 - for w in 1...min(cap, n - edgeLag) where w >= 1 { - runningMaxLast = max(runningMaxLast, maxLastAtFirst[w - 1]) - if separatorOK[w - 1] && runningMaxLast < w { best = w } + if cap >= 1 { + for w in 1...cap { + runningMaxLast = max(runningMaxLast, maxLastAtFirst[w - 1]) + if separatorOK[w - 1] && runningMaxLast < w { best = w } + } } return start + best } From 59c1178dff05bc696c530e116a5135d352f9f879 Mon Sep 17 00:00:00 2001 From: Andrew Blakeslee Moore Date: Mon, 6 Jul 2026 18:29:26 -0700 Subject: [PATCH 027/112] Transcript Incremental Projection Nucleic-Session: 0FFD007B-0696-4517-9429-129C7B0FD5AC Co-authored-by: Nucleic --- .../Views/SessionDetailView.swift | 40 +++++-------------- 1 file changed, 9 insertions(+), 31 deletions(-) diff --git a/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift b/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift index e5f3b16..82482a8 100644 --- a/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift +++ b/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift @@ -494,37 +494,15 @@ struct TranscriptList: View { /// settling — a `.task(id:)` debounces it to detect when the opening layout has come to rest. @State private var transcriptContentHeight: CGFloat = 0 - /// Memoized transcript projection. `TranscriptProjection.build` folds the entire raw event - /// stream into rows on every read, but `body` re-evaluates far more often than `events` - /// actually changes: while the chat is opening, `onScrollGeometryChange` bumps - /// `transcriptContentHeight` on every settling-layout frame, and each bump re-runs `body` — - /// so the untouched event stream was re-folded dozens of times during the exact window the - /// tab bar is sliding away, starving that animation of the main thread and making the - /// load-in jitter. Reuse the last fold whenever the stream is unchanged. A class held in - /// `@State` so reading/updating it from `body` doesn't itself invalidate the view (SwiftUI - /// stores the reference and never diffs its interior). - private final class ProjectionCache { - /// The event stream is append-only with strictly increasing `seq`, so `(count, lastSeq)` - /// uniquely pins it; `firstSeq` guards the open reset (stream cleared to `[]`, then - /// refilled). The two display toggles are inputs to the fold, so they key it too. - private struct Key: Equatable { - var count: Int; var firstSeq: UInt64; var lastSeq: UInt64 - var showRaw: Bool; var showLockEvents: Bool - } - private var key: Key? - private var value: [TranscriptItem] = [] - - func items(for events: [AgentEvent], showRaw: Bool, showLockEvents: Bool) -> [TranscriptItem] { - let k = Key(count: events.count, firstSeq: events.first?.seq ?? 0, - lastSeq: events.last?.seq ?? 0, showRaw: showRaw, showLockEvents: showLockEvents) - if key == k { return value } - let built = TranscriptProjection.build(events, showRaw: showRaw, showLockEvents: showLockEvents) - key = k - value = built - return built - } - } - @State private var projectionCache = ProjectionCache() + /// Incremental transcript projection (docs/TRANSCRIPT_INCREMENTAL_PROJECTION.md). Two layers: + /// a read memo that collapses the redundant `body` re-evaluations (settling layout bumps + /// `transcriptContentHeight` every frame while opening, and each bump re-runs `body` against + /// an unchanged stream), and — when the stream *has* grown — a stable-prefix fold that seals + /// everything before the live turn once and re-folds only the tail, so a streaming delta + /// costs O(live-tail) instead of re-folding all N events (which made long sessions cost + /// O(N²) over their lifetime). A class held in `@State` so reading/updating it from `body` + /// doesn't itself invalidate the view (SwiftUI stores the reference, never diffs interior). + @State private var projectionCache = IncrementalTranscriptProjection() /// The in-flight background Markdown pre-warm (below), cancelled when a newer one supersedes /// it or the transcript goes away — so a long warm can't outlive the view or stack up behind From fd045f8ece6a0f544fc6d2dc0670ca8bbd562baf Mon Sep 17 00:00:00 2001 From: Andrew Blakeslee Moore Date: Mon, 6 Jul 2026 18:29:46 -0700 Subject: [PATCH 028/112] Transcript Incremental Projection Nucleic-Session: 0FFD007B-0696-4517-9429-129C7B0FD5AC Co-authored-by: Nucleic --- .../NucleicRemote/Views/SessionDetailView.swift | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift b/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift index 82482a8..2bbe148 100644 --- a/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift +++ b/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift @@ -509,6 +509,14 @@ struct TranscriptList: View { /// row churn. @State private var prewarmTask: Task? + /// How many projected rows the pre-warm has already covered, so each later trigger snapshots + /// only the *new* rows instead of re-walking (and re-hashing) the whole transcript on every + /// row that lands — which would quietly re-introduce an O(N) main-thread pass per row. Held + /// in a box (not `@State` value) because updating it from `body`-adjacent code must not + /// invalidate the view. + private final class PrewarmProgress { var count = 0 } + @State private var prewarmProgress = PrewarmProgress() + private var items: [TranscriptItem] { projectionCache.items(for: events, showRaw: showRaw, showLockEvents: showLockEvents) } @@ -519,11 +527,16 @@ struct TranscriptList: View { /// away, jittering the load-in. Snapshot the message bodies here on the main actor (a cheap read /// of the memoized projection), then parse them on a background task so that first layout finds /// the caches already warm. Idempotent and self-cancelling; the parse results are the same - /// whichever thread fills the (thread-safe) caches. + /// whichever thread fills the (thread-safe) caches. Incremental: only rows beyond the last + /// covered count are snapshotted (coalescing can shuffle nearby indices, but a missed body just + /// parses on first layout as before — the warm is an optimization, never a correctness gate). private func prewarmMarkdown() { - let bodies: [String] = items.compactMap { + let current = items + if current.count < prewarmProgress.count { prewarmProgress.count = 0 } // stream reset + let bodies: [String] = current[prewarmProgress.count...].compactMap { if case .message(_, let text) = $0.kind { return text } else { return nil } } + prewarmProgress.count = current.count guard !bodies.isEmpty else { return } prewarmTask?.cancel() prewarmTask = Task.detached(priority: .utility) { From aa6a8e267063f1771689fcc02e6e718f653f4740 Mon Sep 17 00:00:00 2001 From: Andrew Blakeslee Moore Date: Mon, 6 Jul 2026 18:30:14 -0700 Subject: [PATCH 029/112] Transcript Incremental Projection Nucleic-Session: 0FFD007B-0696-4517-9429-129C7B0FD5AC Co-authored-by: Nucleic --- NucleicRemote/NucleicRemote/Views/SessionDetailView.swift | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift b/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift index 2bbe148..343ca8a 100644 --- a/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift +++ b/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift @@ -538,8 +538,12 @@ struct TranscriptList: View { } prewarmProgress.count = current.count guard !bodies.isEmpty else { return } - prewarmTask?.cancel() - prewarmTask = Task.detached(priority: .utility) { + // Chain batches instead of cancelling the in-flight one: each batch covers *new* rows + // only, so cancelling a predecessor (say, the big open batch, superseded by the first + // streamed row) would permanently drop its coverage. `onDisappear` cancels the head of + // the chain; a predecessor mid-parse just finishes its bounded batch into shared caches. + prewarmTask = Task.detached(priority: .utility) { [previous = prewarmTask] in + await previous?.value for body in bodies { if Task.isCancelled { return } MarkdownText.prewarm(body) From a6c9496dfe805f1e55bfbb84ff296576a3385ce6 Mon Sep 17 00:00:00 2001 From: Andrew Blakeslee Moore Date: Mon, 6 Jul 2026 18:39:05 -0700 Subject: [PATCH 030/112] Transcript Incremental Projection Nucleic-Session: 0FFD007B-0696-4517-9429-129C7B0FD5AC Co-authored-by: Nucleic --- .../Views/Transcript/MarkdownText.swift | 27 +++++++++++-------- 1 file changed, 16 insertions(+), 11 deletions(-) diff --git a/NucleicRemote/NucleicRemote/Views/Transcript/MarkdownText.swift b/NucleicRemote/NucleicRemote/Views/Transcript/MarkdownText.swift index 4ea8584..3f5fb5e 100644 --- a/NucleicRemote/NucleicRemote/Views/Transcript/MarkdownText.swift +++ b/NucleicRemote/NucleicRemote/Views/Transcript/MarkdownText.swift @@ -40,14 +40,19 @@ struct MarkdownText: View { /// (scrolling, a sibling row streaming) and on every chat reopen, yet structural parsing /// is independent of `bodySize` — so the source string is a complete key. Bounded; /// `NSCache` also evicts under memory pressure. + /// + /// The whole parse pipeline (both caches and the statics below) is `nonisolated`: `prewarm` + /// runs it from a detached background task by design, and as `View` statics they'd otherwise + /// be implicitly MainActor (a Swift 6 error for that call). `(unsafe)` on the caches is + /// sound because `NSCache` is thread-safe and the boxed values are immutable. private final class ParsedBlocks { let blocks: [Block]; init(_ b: [Block]) { self.blocks = b } } - private static let blockCache: NSCache = { + nonisolated(unsafe) private static let blockCache: NSCache = { let cache = NSCache() cache.countLimit = 2048 return cache }() - private static func parse(_ markdown: String) -> [Block] { + nonisolated private static func parse(_ markdown: String) -> [Block] { let key = markdown as NSString if let hit = blockCache.object(forKey: key) { return hit.blocks } let blocks = parseUncached(markdown) @@ -55,7 +60,7 @@ struct MarkdownText: View { return blocks } - private static func parseUncached(_ markdown: String) -> [Block] { + nonisolated private static func parseUncached(_ markdown: String) -> [Block] { var blocks: [Block] = [] var textBuffer: [String] = [] func flush() { @@ -100,12 +105,12 @@ struct MarkdownText: View { /// A GitHub-style table: a `|`-bearing header line immediately followed by a /// `|---|:--:|` separator line. - private static func isTableStart(_ lines: [String], _ index: Int) -> Bool { + nonisolated private static func isTableStart(_ lines: [String], _ index: Int) -> Bool { guard lines[index].contains("|"), index + 1 < lines.count else { return false } return isSeparatorRow(lines[index + 1]) } - private static func isSeparatorRow(_ line: String) -> Bool { + nonisolated private static func isSeparatorRow(_ line: String) -> Bool { let cells = tableCells(line) guard !cells.isEmpty else { return false } return cells.allSatisfy { cell in @@ -115,7 +120,7 @@ struct MarkdownText: View { /// Split a table row into trimmed cells, dropping the empties created by the /// leading/trailing pipes. - private static func tableCells(_ line: String) -> [String] { + nonisolated private static func tableCells(_ line: String) -> [String] { var trimmed = line.trimmingCharacters(in: .whitespaces) if trimmed.hasPrefix("|") { trimmed.removeFirst() } if trimmed.hasSuffix("|") { trimmed.removeLast() } @@ -189,7 +194,7 @@ struct MarkdownText: View { /// Classify one raw prose line. Headings are checked before bullets (a heading marker wins), /// and the plain case keeps the *raw* line (not the trimmed one) exactly as the old cascade /// did — the inline parser preserves leading whitespace under `.inlineOnlyPreservingWhitespace`. - private static func classify(_ raw: String) -> LineStyle { + nonisolated private static func classify(_ raw: String) -> LineStyle { let trimmed = raw.trimmingCharacters(in: .whitespaces) if trimmed.isEmpty { return .blank } // Headings scale relative to the base prose size so the hierarchy holds at any base and @@ -219,7 +224,7 @@ struct MarkdownText: View { } /// Returns the content after a `- `, `* `, `+ ` or `N. ` list marker, else nil. - private static func bulletContent(_ trimmed: String) -> String? { + nonisolated private static func bulletContent(_ trimmed: String) -> String? { for marker in ["- ", "* ", "+ "] where trimmed.hasPrefix(marker) { return String(trimmed.dropFirst(marker.count)) } @@ -241,13 +246,13 @@ struct MarkdownText: View { /// recur across re-renders and reopens, so memoize the parsed result. Independent of /// `bodySize` (callers apply the font), so the source string is a complete key. private final class InlineBox { let value: AttributedString; init(_ v: AttributedString) { self.value = v } } - private static let inlineCache: NSCache = { + nonisolated(unsafe) private static let inlineCache: NSCache = { let cache = NSCache() cache.countLimit = 16384 return cache }() - private static func attributedInline(_ string: String) -> AttributedString { + nonisolated private static func attributedInline(_ string: String) -> AttributedString { let key = string as NSString if let hit = inlineCache.object(forKey: key) { return hit.value } let options = AttributedString.MarkdownParsingOptions( @@ -272,7 +277,7 @@ struct MarkdownText: View { /// keyed only by the source string (independent of `bodySize`), so a warm value equals what /// the main thread would compute, and a repeat call is a cheap cache hit. Cooperatively /// cancellable — a huge transcript's warm loop bails the moment its owning task is cancelled. - static func prewarm(_ markdown: String) { + nonisolated static func prewarm(_ markdown: String) { for block in parse(markdown) { // also warms the block cache if Task.isCancelled { return } switch block { From f381c373e3d8ab84c9393d24f091bf8dae655dd5 Mon Sep 17 00:00:00 2001 From: Andrew Blakeslee Moore Date: Mon, 6 Jul 2026 18:48:30 -0700 Subject: [PATCH 031/112] Transcript Incremental Projection Nucleic-Session: 0FFD007B-0696-4517-9429-129C7B0FD5AC Co-authored-by: Nucleic --- NucleicRemote/NucleicRemote/Models/RemoteStore.swift | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/NucleicRemote/NucleicRemote/Models/RemoteStore.swift b/NucleicRemote/NucleicRemote/Models/RemoteStore.swift index e02ecd0..d041181 100644 --- a/NucleicRemote/NucleicRemote/Models/RemoteStore.swift +++ b/NucleicRemote/NucleicRemote/Models/RemoteStore.swift @@ -639,20 +639,23 @@ final class RemoteStore: ObservableObject { guard let self, hostID == self.openSessionHostID, snap.summary.sessionID == self.openSessionID else { return } // Merge, don't replace: a fresh open merges into an empty transcript (the tail window), // while a reconnect's warm-resubscribe delta appends to the history already on screen - // instead of truncating it to the host's 200-event tail. + // instead of truncating it to the host's 200-event tail. Drain the streaming buffer + // first so the seq-dedup merge sees the full stream (a buffered event the snapshot + // also carries would otherwise be re-appended as a duplicate after the merge). + self.drainPendingOpenEvents() self.openEvents = Self.mergedEvents(self.openEvents, snap.recentEvents) self.openApprovals = snap.pendingApprovals self.persistOpenTranscript() } cb.openEvents = { [weak self] batch in guard let self, hostID == self.openSessionHostID, batch.sessionID == self.openSessionID else { return } - self.openEvents.append(contentsOf: batch.events) - self.persistOpenTranscript() + self.enqueueOpenEvents(batch.events) } cb.openBackfill = { [weak self] batch in guard let self, hostID == self.openSessionHostID, batch.sessionID == self.openSessionID else { return } // Full-history backfill precedes what's on screen — merge by seq so it slots in above the - // tail rather than appending out of order. + // tail rather than appending out of order. Drain first (same reason as openSnapshot). + self.drainPendingOpenEvents() self.openEvents = Self.mergedEvents(self.openEvents, batch.events) self.persistOpenTranscript() } From ad82a7eb09709ba93ad5f4f3d2ae0dd096f27337 Mon Sep 17 00:00:00 2001 From: Andrew Blakeslee Moore Date: Mon, 6 Jul 2026 18:48:48 -0700 Subject: [PATCH 032/112] Transcript Incremental Projection Nucleic-Session: 0FFD007B-0696-4517-9429-129C7B0FD5AC Co-authored-by: Nucleic --- .../NucleicRemote/Models/RemoteStore.swift | 55 +++++++++++++++++++ 1 file changed, 55 insertions(+) diff --git a/NucleicRemote/NucleicRemote/Models/RemoteStore.swift b/NucleicRemote/NucleicRemote/Models/RemoteStore.swift index d041181..f62f4e5 100644 --- a/NucleicRemote/NucleicRemote/Models/RemoteStore.swift +++ b/NucleicRemote/NucleicRemote/Models/RemoteStore.swift @@ -797,6 +797,61 @@ final class RemoteStore: ObservableObject { } } + // MARK: - Streaming delta coalescing + + /// Streaming transcript deltas arrive one wire frame at a time — often dozens per second + /// while the agent talks — and every `openEvents` mutation fires `objectWillChange`, which + /// re-evaluates *every* view observing the store (the Sessions/Home tabs stay mounted behind + /// the pushed session detail, so they pay this too). Buffer incoming deltas and publish at + /// most one append per `openEventsFlushInterval`: the first delta after a quiet gap applies + /// immediately (the leading edge — first-token latency stays imperceptible), followers ride + /// the next scheduled flush. ~10 UI updates/sec still reads as live streaming; the view tree + /// stops being invalidated per wire frame. Merge/close/flush paths drain the buffer first, so + /// nothing downstream ever sees a partial stream. + private var pendingOpenEvents: [AgentEvent] = [] + private var openEventsFlushTask: Task? + private var lastOpenEventsFlushAt = Date.distantPast + private static let openEventsFlushInterval: TimeInterval = 0.1 + + private func enqueueOpenEvents(_ events: [AgentEvent]) { + pendingOpenEvents.append(contentsOf: events) + guard openEventsFlushTask == nil else { return } // a trailing flush is already scheduled + let elapsed = Date().timeIntervalSince(lastOpenEventsFlushAt) + if elapsed >= Self.openEventsFlushInterval { + drainPendingOpenEvents() + } else { + let delay = Self.openEventsFlushInterval - elapsed + openEventsFlushTask = Task { [weak self] in + try? await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000)) + guard let self, !Task.isCancelled else { return } + self.openEventsFlushTask = nil + self.drainPendingOpenEvents() + } + } + } + + /// Publish the buffered deltas (and schedule persistence). Called on the flush cadence, and + /// eagerly by anything that merges, persists, or clears `openEvents`, so those paths always + /// operate on the complete stream. + private func drainPendingOpenEvents() { + openEventsFlushTask?.cancel() + openEventsFlushTask = nil + guard !pendingOpenEvents.isEmpty else { return } + lastOpenEventsFlushAt = Date() + openEvents.append(contentsOf: pendingOpenEvents) + pendingOpenEvents.removeAll(keepingCapacity: true) + persistOpenTranscript() + } + + /// Drop buffered deltas without publishing — for session switch/close/unpair, where the + /// buffer belongs to a transcript that is being cleared (a stale session's tail must never + /// leak into the next session's freshly-opened transcript). + private func discardPendingOpenEvents() { + openEventsFlushTask?.cancel() + openEventsFlushTask = nil + pendingOpenEvents.removeAll(keepingCapacity: true) + } + /// Debounced write of the open transcript, called after each batch of events lands. private func persistOpenTranscript() { guard !demoMode, let id = openSessionID, !openEvents.isEmpty else { return } From 8d8462806c5517da7801405f9f8c63b38e06beaa Mon Sep 17 00:00:00 2001 From: Andrew Blakeslee Moore Date: Mon, 6 Jul 2026 18:48:59 -0700 Subject: [PATCH 033/112] Transcript Incremental Projection Nucleic-Session: 0FFD007B-0696-4517-9429-129C7B0FD5AC Co-authored-by: Nucleic --- NucleicRemote/NucleicRemote/Models/RemoteStore.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/NucleicRemote/NucleicRemote/Models/RemoteStore.swift b/NucleicRemote/NucleicRemote/Models/RemoteStore.swift index f62f4e5..145e2e6 100644 --- a/NucleicRemote/NucleicRemote/Models/RemoteStore.swift +++ b/NucleicRemote/NucleicRemote/Models/RemoteStore.swift @@ -968,6 +968,7 @@ final class RemoteStore: ObservableObject { openSessionID = nil compactDetailPresented = false openSessionHostID = nil + discardPendingOpenEvents() openEvents = []; openApprovals = []; openDiff = nil; diffLoading = false connectivity = .unpaired // No Mac left whose history to hold — drop the offline cache too. From 57620c6124378147f726df1a1596b0d8d825ced6 Mon Sep 17 00:00:00 2001 From: Andrew Blakeslee Moore Date: Mon, 6 Jul 2026 18:49:02 -0700 Subject: [PATCH 034/112] Transcript Incremental Projection Nucleic-Session: 0FFD007B-0696-4517-9429-129C7B0FD5AC Co-authored-by: Nucleic --- NucleicRemote/NucleicRemote/Models/RemoteStore.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/NucleicRemote/NucleicRemote/Models/RemoteStore.swift b/NucleicRemote/NucleicRemote/Models/RemoteStore.swift index 145e2e6..a7f5a6f 100644 --- a/NucleicRemote/NucleicRemote/Models/RemoteStore.swift +++ b/NucleicRemote/NucleicRemote/Models/RemoteStore.swift @@ -1003,6 +1003,7 @@ final class RemoteStore: ObservableObject { openSessionID = nil compactDetailPresented = false openSessionHostID = nil + discardPendingOpenEvents() openEvents = [] openApprovals = [] openDiff = nil From 544eabebeb04331cc539ec815ff9833e131a32cf Mon Sep 17 00:00:00 2001 From: Andrew Blakeslee Moore Date: Mon, 6 Jul 2026 18:49:04 -0700 Subject: [PATCH 035/112] Transcript Incremental Projection Nucleic-Session: 0FFD007B-0696-4517-9429-129C7B0FD5AC Co-authored-by: Nucleic --- NucleicRemote/NucleicRemote/Models/RemoteStore.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/NucleicRemote/NucleicRemote/Models/RemoteStore.swift b/NucleicRemote/NucleicRemote/Models/RemoteStore.swift index a7f5a6f..fceacca 100644 --- a/NucleicRemote/NucleicRemote/Models/RemoteStore.swift +++ b/NucleicRemote/NucleicRemote/Models/RemoteStore.swift @@ -1032,6 +1032,7 @@ final class RemoteStore: ObservableObject { // Record which Mac owns this session (mesh P3) so its connection forwards the transcript and // the host-specific projected values follow it. openSessionHostID = connection(owningSession: sessionID)?.hostID + discardPendingOpenEvents() openEvents = [] openApprovals = [] openDiff = nil From 9734a73518a25dca3911797012f9e16dcb6c83cc Mon Sep 17 00:00:00 2001 From: Andrew Blakeslee Moore Date: Mon, 6 Jul 2026 18:49:07 -0700 Subject: [PATCH 036/112] Transcript Incremental Projection Nucleic-Session: 0FFD007B-0696-4517-9429-129C7B0FD5AC Co-authored-by: Nucleic --- NucleicRemote/NucleicRemote/Models/RemoteStore.swift | 2 ++ 1 file changed, 2 insertions(+) diff --git a/NucleicRemote/NucleicRemote/Models/RemoteStore.swift b/NucleicRemote/NucleicRemote/Models/RemoteStore.swift index fceacca..16cb7fa 100644 --- a/NucleicRemote/NucleicRemote/Models/RemoteStore.swift +++ b/NucleicRemote/NucleicRemote/Models/RemoteStore.swift @@ -1210,6 +1210,8 @@ final class RemoteStore: ObservableObject { compactDetailPresented = false openSessionHostID = nil // Persist the final transcript before clearing it, so it's warm for the next open / offline. + // Buffered streaming deltas are part of that transcript — publish them first. + drainPendingOpenEvents() flushOpenTranscript(target, openEvents) openEvents = [] openApprovals = [] From 085a6d5af9667d0a56abbd0a89382a061b0e47f4 Mon Sep 17 00:00:00 2001 From: Andrew Blakeslee Moore Date: Mon, 6 Jul 2026 18:49:10 -0700 Subject: [PATCH 037/112] Transcript Incremental Projection Nucleic-Session: 0FFD007B-0696-4517-9429-129C7B0FD5AC Co-authored-by: Nucleic --- NucleicRemote/NucleicRemote/Models/RemoteStore.swift | 2 ++ 1 file changed, 2 insertions(+) diff --git a/NucleicRemote/NucleicRemote/Models/RemoteStore.swift b/NucleicRemote/NucleicRemote/Models/RemoteStore.swift index 16cb7fa..7cd57f1 100644 --- a/NucleicRemote/NucleicRemote/Models/RemoteStore.swift +++ b/NucleicRemote/NucleicRemote/Models/RemoteStore.swift @@ -793,6 +793,8 @@ final class RemoteStore: ObservableObject { Task { [weak self] in let cached = await SessionCache.loadEvents(sessionID) guard let self, self.openSessionID == sessionID, !cached.isEmpty else { return } + // Drain any live deltas first so the seq-dedup merge sees the complete stream. + self.drainPendingOpenEvents() self.openEvents = Self.mergedEvents(cached, self.openEvents) } } From 4bd4e817788e972a90db168f49b35fb63c5b317b Mon Sep 17 00:00:00 2001 From: Andrew Blakeslee Moore Date: Mon, 6 Jul 2026 18:49:35 -0700 Subject: [PATCH 038/112] Transcript Incremental Projection Nucleic-Session: 0FFD007B-0696-4517-9429-129C7B0FD5AC Co-authored-by: Nucleic --- .../NucleicRemote/Models/RemoteStore.swift | 45 ++++++++++++------- 1 file changed, 29 insertions(+), 16 deletions(-) diff --git a/NucleicRemote/NucleicRemote/Models/RemoteStore.swift b/NucleicRemote/NucleicRemote/Models/RemoteStore.swift index 7cd57f1..5ccc3cb 100644 --- a/NucleicRemote/NucleicRemote/Models/RemoteStore.swift +++ b/NucleicRemote/NucleicRemote/Models/RemoteStore.swift @@ -725,41 +725,54 @@ final class RemoteStore: ObservableObject { /// or a representative one. No-op in demo, which seeds the aggregate directly. private func rebuildAggregate() { guard !demoMode else { return } + // Every assignment below goes through `setIfChanged`: `didUpdate` fires on *every* wire + // frame from *any* host (session churn, dashboard refresh, diff-stat ticks), and a plain + // `@Published` assignment fires `objectWillChange` even when the value is identical — + // re-evaluating every view observing the store for nothing. Equality checks over these + // small aggregates are far cheaper than a whole-tree SwiftUI invalidation. let live = aggregatedSessions() if !live.isEmpty { - sessions = live - cachedSummaries = live - persistSummaries(live) + if sessions != live { + sessions = live + cachedSummaries = live + persistSummaries(live) + } } else if connections.values.contains(where: { $0.connectivity.isLive }) { // Connected, but the host genuinely has no sessions — reflect that honestly. - sessions = [] + setIfChanged(\.sessions, []) } else { // Offline: keep showing the saved history rather than blanking the list. - sessions = cachedSummaries + setIfChanged(\.sessions, cachedSummaries) } - dashboard = DashboardSnapshot.merged(connections.values.map(\.dashboard)) - meshPeers = connections.values.flatMap(\.meshPeers) + setIfChanged(\.dashboard, DashboardSnapshot.merged(connections.values.map(\.dashboard))) + setIfChanged(\.meshPeers, connections.values.flatMap(\.meshPeers)) let ctx = contextConnection - hostName = ctx?.hostName ?? "" - capabilities = ctx?.capabilities - ?? WireCapabilities(canModifyToolInput: false, allowAlwaysScopes: []) - modelCatalog = ctx?.modelCatalog ?? .empty + setIfChanged(\.hostName, ctx?.hostName ?? "") + setIfChanged(\.capabilities, ctx?.capabilities + ?? WireCapabilities(canModifyToolInput: false, allowAlwaysScopes: [])) + setIfChanged(\.modelCatalog, ctx?.modelCatalog ?? .empty) if let id = openSessionHostID, let conn = connections[id] { // While a transcript is open, the composer/controls act on *that* Mac — its connectivity // gates send and its scope drives the control affordances. - connectivity = conn.connectivity - grantedScope = conn.grantedScope + setIfChanged(\.connectivity, conn.connectivity) + setIfChanged(\.grantedScope, conn.grantedScope) } else { - connectivity = aggregateConnectivity() + setIfChanged(\.connectivity, aggregateConnectivity()) // Optimistic new-chat gating: enabled if *any* Mac grants control (the owning Mac still // enforces scope when the intent lands there). - grantedScope = connections.values - .filter { $0.connectivity.isLive }.map(\.grantedScope).max() ?? .approve + setIfChanged(\.grantedScope, connections.values + .filter { $0.connectivity.isLive }.map(\.grantedScope).max() ?? .approve) } } + /// Assign a `@Published` property only when the value actually differs, so a no-op rebuild + /// doesn't fire `objectWillChange` (and with it a whole-tree view re-evaluation). + private func setIfChanged(_ keyPath: ReferenceWritableKeyPath, _ value: T) { + if self[keyPath: keyPath] != value { self[keyPath: keyPath] = value } + } + /// Merge transcript events by `seq` (monotonic, globally unique within a session), keeping the /// union sorted. Lets a reconnect's snapshot fold its events into the transcript already on /// screen without duplicating what's shown or dropping history outside the host's tail window. From f5423f981e514a5e570255557e25dacf3e791912 Mon Sep 17 00:00:00 2001 From: Andrew Blakeslee Moore Date: Mon, 6 Jul 2026 18:49:55 -0700 Subject: [PATCH 039/112] Transcript Incremental Projection Nucleic-Session: 0FFD007B-0696-4517-9429-129C7B0FD5AC Co-authored-by: Nucleic --- .../Views/SessionDetailView.swift | 38 ++++++++++++++----- 1 file changed, 29 insertions(+), 9 deletions(-) diff --git a/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift b/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift index 343ca8a..1e2ca5e 100644 --- a/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift +++ b/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift @@ -123,17 +123,37 @@ struct SessionDetailView: View { set: { store.setSessionEffort(sessionID, $0) }) } + /// Memo for the context-occupancy scan below. The backward scan usually stops at the last + /// turn's usage event, but a stream with sparse (or no) usage reporting walks the whole + /// transcript — and `body` re-evaluates on every store change and keystroke, so an O(N) scan + /// per evaluation quietly compounds on long sessions. `(count, lastSeq)` pins the stream, as + /// in the projection cache; a class held in `@State` so updating it from `body` doesn't + /// itself invalidate the view. + private final class ContextScanCache { + var count = -1 + var lastSeq: UInt64 = 0 + var used: Int? + } + @State private var contextScanCache = ContextScanCache() + /// Live context-window occupancy (newest turn's input tokens ÷ the model's window), read - /// from the transcript exactly as the Mac header does. + /// from the transcript exactly as the Mac header does. Only the token scan is memoized — + /// the window division stays live, so a model switch reflects immediately. private var contextPercent: Int? { - let used = store.openEvents.reversed().lazy.compactMap { event -> Int? in - switch event.kind { - case .turnCompleted(let turn): return turn.usage?.contextInputTokens - case .usage(let usage): return usage.contextInputTokens - default: return nil - } - }.first { $0 > 0 } - guard let used else { return nil } + let events = store.openEvents + let cache = contextScanCache + if cache.count != events.count || cache.lastSeq != events.last?.seq ?? 0 { + cache.count = events.count + cache.lastSeq = events.last?.seq ?? 0 + cache.used = events.reversed().lazy.compactMap { event -> Int? in + switch event.kind { + case .turnCompleted(let turn): return turn.usage?.contextInputTokens + case .usage(let usage): return usage.contextInputTokens + default: return nil + } + }.first { $0 > 0 } + } + guard let used = cache.used else { return nil } let window = store.modelCatalog.contextWindow(summary?.model) guard window > 0 else { return nil } return min(100, Int((Double(used) / Double(window)) * 100)) From ac965e3c6e9dd9e6159a44d2d7147c12663c87dc Mon Sep 17 00:00:00 2001 From: Andrew Blakeslee Moore Date: Mon, 6 Jul 2026 18:50:04 -0700 Subject: [PATCH 040/112] Transcript Incremental Projection Nucleic-Session: 0FFD007B-0696-4517-9429-129C7B0FD5AC Co-authored-by: Nucleic --- NucleicRemote/NucleicRemote/Views/SessionDetailView.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift b/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift index 1e2ca5e..fba176f 100644 --- a/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift +++ b/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift @@ -142,7 +142,7 @@ struct SessionDetailView: View { private var contextPercent: Int? { let events = store.openEvents let cache = contextScanCache - if cache.count != events.count || cache.lastSeq != events.last?.seq ?? 0 { + if cache.count != events.count || cache.lastSeq != (events.last?.seq ?? 0) { cache.count = events.count cache.lastSeq = events.last?.seq ?? 0 cache.used = events.reversed().lazy.compactMap { event -> Int? in From bbe193134a9827610be009939affead6c2796b11 Mon Sep 17 00:00:00 2001 From: Andrew Blakeslee Moore Date: Mon, 6 Jul 2026 18:50:43 -0700 Subject: [PATCH 041/112] Transcript Incremental Projection Nucleic-Session: 0FFD007B-0696-4517-9429-129C7B0FD5AC Co-authored-by: Nucleic --- NucleicRemote/NucleicRemote/Views/SessionsView.swift | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/NucleicRemote/NucleicRemote/Views/SessionsView.swift b/NucleicRemote/NucleicRemote/Views/SessionsView.swift index cd4be2a..9b6ef9d 100644 --- a/NucleicRemote/NucleicRemote/Views/SessionsView.swift +++ b/NucleicRemote/NucleicRemote/Views/SessionsView.swift @@ -11,9 +11,15 @@ struct SessionsView: View { private var grouped: [(title: String, rows: [WireSessionSummary])] { let pool = (showArchived ? store.sessions : store.liveSessions) .sorted(by: StatusStyle.attentionThenRecency) - let needs = pool.filter { $0.status.needsYou($0.disposition) } - let running = pool.filter { $0.status == .running || $0.status == .provisioning || $0.status == .idle } - let done = pool.filter { !needs.contains($0) && !running.contains($0) } + // One pass, first bucket wins — the old `done = pool.filter { !needs.contains($0) … }` + // ran O(rows²) full-summary equality scans on every body evaluation. + var needs: [WireSessionSummary] = [], running: [WireSessionSummary] = [], done: [WireSessionSummary] = [] + for summary in pool { + if summary.status.needsYou(summary.disposition) { needs.append(summary) } + else if summary.status == .running || summary.status == .provisioning || summary.status == .idle { + running.append(summary) + } else { done.append(summary) } + } return [("Needs you", needs), ("Running", running), ("Done", done)].filter { !$0.rows.isEmpty } } From 9c13f2dd485a03cc305b461fd32c7b020a5bad17 Mon Sep 17 00:00:00 2001 From: Andrew Blakeslee Moore Date: Mon, 6 Jul 2026 19:00:01 -0700 Subject: [PATCH 042/112] Transcript Incremental Projection Nucleic-Session: 0FFD007B-0696-4517-9429-129C7B0FD5AC Co-authored-by: Nucleic --- .../Views/SessionDetailView.swift | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift b/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift index fba176f..4947663 100644 --- a/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift +++ b/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift @@ -576,6 +576,26 @@ struct TranscriptList: View { /// the Mac's `bottomFollowThreshold`. private let bottomFollowThreshold: CGFloat = 24 + /// How far a *user* scroll must move away from the bottom before following disengages. Wider + /// than the re-engage threshold above on purpose (hysteresis): the action bar's height isn't + /// constant (composer lines grow, the working row appears, the keyboard dismisses + /// interactively), and every inset change perturbs the scroll geometry by tens of points. A + /// single threshold read both ways let one such perturbation flip the gate, whose reactions + /// (anchor toggle, bar animation) perturbed the geometry again — an oscillation that + /// re-layouted the whole eager transcript every frame and pinned the main thread at 100%. + /// The band is wider than any bar-height delta, so only a deliberate scroll crosses it. + private let bottomUnfollowThreshold: CGFloat = 64 + + /// The live scroll phase, used to tell *user* scrolling (finger down / flick decelerating) + /// from programmatic motion (autoscroll animations, anchor re-pins, inset changes). Only a + /// user-driven phase may disengage bottom-following — a programmatic perturbation can only + /// ever re-engage it — which structurally breaks every geometry→state→geometry feedback + /// cycle: no chain of layout reactions can take the gate false and sustain itself. + @State private var scrollPhase: ScrollPhase = .idle + private var isUserScrolling: Bool { + scrollPhase == .tracking || scrollPhase == .interacting || scrollPhase == .decelerating + } + var body: some View { ScrollViewReader { proxy in ScrollView { From cbd828c761cf6b98327bb7f01c55fada407eeec6 Mon Sep 17 00:00:00 2001 From: Andrew Blakeslee Moore Date: Mon, 6 Jul 2026 19:00:22 -0700 Subject: [PATCH 043/112] Transcript Incremental Projection Nucleic-Session: 0FFD007B-0696-4517-9429-129C7B0FD5AC Co-authored-by: Nucleic --- .../Views/SessionDetailView.swift | 36 +++++++++++-------- 1 file changed, 21 insertions(+), 15 deletions(-) diff --git a/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift b/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift index 4947663..c7f0f63 100644 --- a/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift +++ b/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift @@ -633,22 +633,28 @@ struct TranscriptList: View { // they scroll away, which lets new content land off-screen below instead of dragging // the viewport, and restore `.bottom` once they're back at the end. .defaultScrollAnchor(isScrolledToBottom ? .bottom : nil) + .onScrollPhaseChange { _, newPhase in scrollPhase = newPhase } // Track the live scroll position straight from the scroll view's geometry: how much - // content still sits below the viewport bottom. Within the slack threshold means the - // user is parked at the end (live output keeps following); scrolling up flips this - // false, which drops the anchor (above) and reveals the jump-to-bottom chevron. - .onScrollGeometryChange(for: Bool.self) { geo in - geo.contentSize.height - geo.containerSize.height - geo.contentOffset.y - <= bottomFollowThreshold - } action: { _, atBottom in - // While the chat is opening the layout grows over a few passes and the scroll - // offset lags each growth by a frame — sampling that frame reads "not at bottom" - // even though `.defaultScrollAnchor(.bottom)` is about to re-pin. So during the - // open window accept only "at bottom" readings; honor real scroll-ups after. - if transcriptSettling { - if atBottom { isScrolledToBottom = true } - } else { - isScrolledToBottom = atBottom + // content still sits below the viewport bottom. Parked within the follow threshold + // means live output keeps following; a *user* scroll past the (wider) unfollow + // threshold flips it false, which drops the anchor (above) and reveals the chevron. + // Asymmetric on purpose — see `bottomUnfollowThreshold` / `isUserScrolling`: a + // programmatic geometry change (anchor re-pin, bar resize, keyboard, autoscroll + // animation) may re-engage following but can never disengage it, so no layout + // feedback cycle through this gate can sustain itself. Tracking the rounded distance + // (not a Bool) also means every scroll emits fresh values, so the gate can't latch + // against a stale reading (the old settle-window latch bug). + .onScrollGeometryChange(for: CGFloat.self) { geo in + (geo.contentSize.height - geo.containerSize.height - geo.contentOffset.y).rounded() + } action: { _, distance in + if distance <= bottomFollowThreshold { + isScrolledToBottom = true + } else if !transcriptSettling, distance > bottomUnfollowThreshold, isUserScrolling { + // While the chat is opening the layout grows over a few passes and the offset + // lags each growth by a frame — those frames read "not at bottom" even though + // `.defaultScrollAnchor(.bottom)` is about to re-pin, so settling accepts + // only re-engagement (the `!transcriptSettling` above). + isScrolledToBottom = false } } // Detect when the opening layout has come to rest: track the content height while From a757192bd71e87e02ec19317a5a234498f29661a Mon Sep 17 00:00:00 2001 From: Andrew Blakeslee Moore Date: Mon, 6 Jul 2026 19:00:35 -0700 Subject: [PATCH 044/112] Transcript Incremental Projection Nucleic-Session: 0FFD007B-0696-4517-9429-129C7B0FD5AC Co-authored-by: Nucleic --- .../NucleicRemote/Views/SessionDetailView.swift | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift b/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift index c7f0f63..75c1d5d 100644 --- a/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift +++ b/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift @@ -683,9 +683,15 @@ struct TranscriptList: View { } // Coalescing means item count lags event count; key the autoscroll on the raw stream // so every streamed delta keeps the view pinned to the bottom — but only while the - // user is already parked there. Scrolling up to read history is never yanked down. + // user is already parked there. Scrolling up to read history is never yanked down, + // and a finger actively on the transcript is never fought mid-drag (the drag that + // takes them past the unfollow threshold flips the gate; until then the native + // bottom anchor alone keeps content pinned, without an animated scroll grabbing the + // viewport back out of their hand). .onChange(of: events.count) { - if isScrolledToBottom { scrollToEnd(proxy, animated: !transcriptSettling) } + if isScrolledToBottom, !isUserScrolling { + scrollToEnd(proxy, animated: !transcriptSettling) + } } // An explicit jump — the chevron or sending a message — always wins. The chevron // itself lives in the parent's chat bar (above the composer), not as an overlay here, From 71464d084f8a5ddc91cb3c6d7380ea8ec555fe0e Mon Sep 17 00:00:00 2001 From: Andrew Blakeslee Moore Date: Mon, 6 Jul 2026 19:00:53 -0700 Subject: [PATCH 045/112] Transcript Incremental Projection Nucleic-Session: 0FFD007B-0696-4517-9429-129C7B0FD5AC Co-authored-by: Nucleic --- .../Views/SessionDetailView.swift | 30 +++++++++++++------ 1 file changed, 21 insertions(+), 9 deletions(-) diff --git a/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift b/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift index 75c1d5d..33e7a30 100644 --- a/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift +++ b/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift @@ -284,15 +284,6 @@ struct SessionDetailView: View { /// pending. Content scrolls beneath it; nothing renders when there's nothing to act on. private var actionArea: some View { VStack(spacing: 8) { - // The jump-to-bottom chevron rides here, immediately above the chat bar — not as a - // transcript overlay, which aligned to the scroll view's full-height bounds and so sat - // behind this floating bar at the screen's bottom edge. Shown only while scrolled up; a - // tap bumps the same scroll request the send button uses, so following resumes once the - // transcript reaches the bottom. Mirrors the Mac's `JumpToBottomButton`. - if !isScrolledToBottom { - JumpToBottomButton { scrollToBottomRequest += 1 } - .transition(.move(edge: .bottom).combined(with: .opacity)) - } // Interacting with a chat while the owning Mac is unreachable surfaces this first, so a // disabled composer reads as "offline / read-only history" rather than broken. if !store.connectivity.isLive { disconnectedBanner } @@ -300,6 +291,27 @@ struct SessionDetailView: View { } .padding(.horizontal, 12) .padding(.bottom, 8) + // The jump-to-bottom chevron floats just above the chat bar as an *overlay* — deliberately + // not a stack member. This whole action area is the transcript's bottom `safeAreaInset`, + // so a stack-member chevron changed the scroll view's bottom inset by ~38pt every time it + // appeared — and its visibility is *decided by* that same scroll geometry + // (`isScrolledToBottom`). That geometry→chevron→geometry cycle could oscillate every + // frame near the follow threshold, re-layouting the whole eager transcript each time and + // pinning the main thread at 100% with the UI locked. An overlay contributes nothing to + // the inset height, so showing or hiding it can't move the scroll geometry at all. + // (It's not a transcript overlay either — that aligned to the scroll view's full-height + // bounds and sat behind this floating bar at the screen's bottom edge.) A tap bumps the + // same scroll request the send button uses, so following resumes once the transcript + // reaches the bottom. Mirrors the Mac's `JumpToBottomButton`. + .overlay(alignment: .top) { + if !isScrolledToBottom { + JumpToBottomButton { scrollToBottomRequest += 1 } + // Hang the button *above* the bar: report its bottom edge (plus a gap) as its + // top guide, so the overlay's top alignment places it outside the bar's bounds. + .alignmentGuide(.top) { $0[.bottom] + 8 } + .transition(.move(edge: .bottom).combined(with: .opacity)) + } + } .animation(.easeInOut(duration: 0.15), value: isScrolledToBottom) } From 936c90effb4ec1a3216ab9a938fa88ad28805648 Mon Sep 17 00:00:00 2001 From: Andrew Blakeslee Moore Date: Mon, 6 Jul 2026 19:15:16 -0700 Subject: [PATCH 046/112] Transcript Incremental Projection Nucleic-Session: 0FFD007B-0696-4517-9429-129C7B0FD5AC Co-authored-by: Nucleic --- .../NucleicRemote/Views/SessionDetailView.swift | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift b/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift index 33e7a30..7991bd2 100644 --- a/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift +++ b/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift @@ -705,11 +705,18 @@ struct TranscriptList: View { scrollToEnd(proxy, animated: !transcriptSettling) } } - // An explicit jump — the chevron or sending a message — always wins. The chevron - // itself lives in the parent's chat bar (above the composer), not as an overlay here, - // so it sits over the composer instead of behind the floating bar; a tap bumps this - // same request, and following resumes once the geometry reader sees the bottom. - .onChange(of: scrollToBottomRequest) { scrollToEnd(proxy) } + // An explicit jump — the chevron or sending a message — always wins, and it *is* the + // user saying "follow again": re-engage the gate directly rather than waiting for the + // geometry to confirm. The animated scroll can come to rest a few points short of the + // follow threshold (content padding, inset rounding), which sat inside the hysteresis + // band — following never re-engaged and the chevron lingered until the user manually + // scrolled the last few points. Setting the gate here hides the chevron immediately + // and hands pinning back to the bottom anchor; a later real scroll-up still + // disengages it as usual. + .onChange(of: scrollToBottomRequest) { + isScrolledToBottom = true + scrollToEnd(proxy) + } // Warm the Markdown parse caches off the main thread whenever the row set grows — // `initial: true` fires it for the batch that lands on open (the expensive case), and // each later new row tops it up. Keyed on the row *count*, so streaming deltas into an From 4a2bea86ddaf53dacbe01adf2eb5fd80cb3470e9 Mon Sep 17 00:00:00 2001 From: Andrew Blakeslee Moore Date: Mon, 6 Jul 2026 19:15:28 -0700 Subject: [PATCH 047/112] Transcript Incremental Projection Nucleic-Session: 0FFD007B-0696-4517-9429-129C7B0FD5AC Co-authored-by: Nucleic --- NucleicRemote/NucleicRemote/Views/SessionDetailView.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift b/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift index 7991bd2..550b96e 100644 --- a/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift +++ b/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift @@ -304,7 +304,7 @@ struct SessionDetailView: View { // same scroll request the send button uses, so following resumes once the transcript // reaches the bottom. Mirrors the Mac's `JumpToBottomButton`. .overlay(alignment: .top) { - if !isScrolledToBottom { + if true { // TEMP: force visible for screenshot verification JumpToBottomButton { scrollToBottomRequest += 1 } // Hang the button *above* the bar: report its bottom edge (plus a gap) as its // top guide, so the overlay's top alignment places it outside the bar's bounds. From 122015890068d296a10ad548b7473708747c41bf Mon Sep 17 00:00:00 2001 From: Andrew Blakeslee Moore Date: Mon, 6 Jul 2026 19:25:02 -0700 Subject: [PATCH 048/112] Transcript Incremental Projection Nucleic-Session: 0FFD007B-0696-4517-9429-129C7B0FD5AC Co-authored-by: Nucleic --- NucleicRemote/NucleicRemote/Views/SessionDetailView.swift | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift b/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift index 550b96e..702b81b 100644 --- a/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift +++ b/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift @@ -306,9 +306,11 @@ struct SessionDetailView: View { .overlay(alignment: .top) { if true { // TEMP: force visible for screenshot verification JumpToBottomButton { scrollToBottomRequest += 1 } - // Hang the button *above* the bar: report its bottom edge (plus a gap) as its - // top guide, so the overlay's top alignment places it outside the bar's bounds. - .alignmentGuide(.top) { $0[.bottom] + 8 } + // Hang the button fully *above* the bar: top-aligned, then shifted up by its + // own 30pt height plus an 8pt gap. A render-time offset (not an alignment + // guide, which misplaced it half-overlapping the bar's top edge) so the + // placement is exact and — critically — contributes nothing to the inset. + .offset(y: -38) .transition(.move(edge: .bottom).combined(with: .opacity)) } } From f93a84506629c3eb16013d3cd3a542afdd8bd87c Mon Sep 17 00:00:00 2001 From: Andrew Blakeslee Moore Date: Mon, 6 Jul 2026 19:26:37 -0700 Subject: [PATCH 049/112] Transcript Incremental Projection Nucleic-Session: 0FFD007B-0696-4517-9429-129C7B0FD5AC Co-authored-by: Nucleic --- NucleicRemote/NucleicRemote/Views/SessionDetailView.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift b/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift index 702b81b..678c49b 100644 --- a/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift +++ b/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift @@ -304,7 +304,7 @@ struct SessionDetailView: View { // same scroll request the send button uses, so following resumes once the transcript // reaches the bottom. Mirrors the Mac's `JumpToBottomButton`. .overlay(alignment: .top) { - if true { // TEMP: force visible for screenshot verification + if !isScrolledToBottom { JumpToBottomButton { scrollToBottomRequest += 1 } // Hang the button fully *above* the bar: top-aligned, then shifted up by its // own 30pt height plus an 8pt gap. A render-time offset (not an alignment From 0e942400d59d92c61e1815277ca5bd44bfc6d0a1 Mon Sep 17 00:00:00 2001 From: Andrew Blakeslee Moore Date: Mon, 6 Jul 2026 19:33:05 -0700 Subject: [PATCH 050/112] Transcript Incremental Projection Nucleic-Session: 0FFD007B-0696-4517-9429-129C7B0FD5AC Co-authored-by: Nucleic --- .../NucleicRemote/Views/SessionDetailView.swift | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift b/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift index 678c49b..2afc6f8 100644 --- a/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift +++ b/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift @@ -600,14 +600,21 @@ struct TranscriptList: View { /// The band is wider than any bar-height delta, so only a deliberate scroll crosses it. private let bottomUnfollowThreshold: CGFloat = 64 - /// The live scroll phase, used to tell *user* scrolling (finger down / flick decelerating) + /// The live scroll phase, used to tell *user* scrolling (an active drag / flick decelerating) /// from programmatic motion (autoscroll animations, anchor re-pins, inset changes). Only a /// user-driven phase may disengage bottom-following — a programmatic perturbation can only /// ever re-engage it — which structurally breaks every geometry→state→geometry feedback /// cycle: no chain of layout reactions can take the gate false and sustain itself. + /// + /// `.tracking` (finger down, no displacement yet) deliberately does NOT count: a finger + /// resting on the transcript scrolls nothing, but touching down *stops a live deceleration*, + /// and that stop emits a final far-from-bottom geometry reading under `.tracking`. Tapping + /// the chevron mid-deceleration raced exactly that emission against the tap's "follow again" + /// — when the stop reading landed after it, following disengaged right back and the chevron + /// stuck until a second tap. Real scroll-aways always pass through `.interacting`. @State private var scrollPhase: ScrollPhase = .idle private var isUserScrolling: Bool { - scrollPhase == .tracking || scrollPhase == .interacting || scrollPhase == .decelerating + scrollPhase == .interacting || scrollPhase == .decelerating } var body: some View { From a9e2bbe80eb95c8c9689b5d3f1ac7f155be28a90 Mon Sep 17 00:00:00 2001 From: Andrew Blakeslee Moore Date: Mon, 6 Jul 2026 19:33:25 -0700 Subject: [PATCH 051/112] Transcript Incremental Projection Nucleic-Session: 0FFD007B-0696-4517-9429-129C7B0FD5AC Co-authored-by: Nucleic --- .../NucleicRemote/Views/SessionDetailView.swift | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift b/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift index 2afc6f8..0a866ab 100644 --- a/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift +++ b/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift @@ -707,11 +707,15 @@ struct TranscriptList: View { // user is already parked there. Scrolling up to read history is never yanked down, // and a finger actively on the transcript is never fought mid-drag (the drag that // takes them past the unfollow threshold flips the gate; until then the native - // bottom anchor alone keeps content pinned, without an animated scroll grabbing the - // viewport back out of their hand). + // bottom anchor alone keeps content pinned, without a scroll grabbing the viewport + // back out of their hand). Never animated: while pinned, the bottom anchor provides + // the visual continuity and this call only closes the last few points — but a + // session still *opening* keeps receiving history merges (cached transcript, + // reconnect backfill) after the settle window closes, and animating those rode the + // viewport visibly down through the whole transcript instead of landing at the end. .onChange(of: events.count) { if isScrolledToBottom, !isUserScrolling { - scrollToEnd(proxy, animated: !transcriptSettling) + scrollToEnd(proxy, animated: false) } } // An explicit jump — the chevron or sending a message — always wins, and it *is* the From 1f4a8b785787149b12498569103f3941e43882d5 Mon Sep 17 00:00:00 2001 From: Andrew Blakeslee Moore Date: Mon, 6 Jul 2026 19:41:50 -0700 Subject: [PATCH 052/112] Transcript Incremental Projection Nucleic-Session: 0FFD007B-0696-4517-9429-129C7B0FD5AC Co-authored-by: Nucleic --- .../NucleicRemote/Views/SessionDetailView.swift | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift b/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift index 0a866ab..26b57fb 100644 --- a/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift +++ b/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift @@ -617,6 +617,16 @@ struct TranscriptList: View { scrollPhase == .interacting || scrollPhase == .decelerating } + /// True from an explicit jump (chevron / send) until the scroll actually reaches the bottom. + /// While set, the unfollow branch is suppressed entirely: phase changes and geometry + /// emissions are delivered on separate callbacks with no ordering guarantee, so a stopping + /// fling could emit one last far-from-bottom reading whose *recorded* phase was still + /// user-driven (`.decelerating`) — landing after the tap's "follow again" and disengaging it, + /// which left the chevron up until a second tap. The latch outlives any stale emission and + /// clears on arrival at the bottom, or the moment the user genuinely grabs the transcript + /// again (`.interacting`), so a mid-jump scroll-away still works. + @State private var jumpingToBottom = false + var body: some View { ScrollViewReader { proxy in ScrollView { From e4ada9145f78937757f1db3f3eb83be4d0f5254f Mon Sep 17 00:00:00 2001 From: Andrew Blakeslee Moore Date: Mon, 6 Jul 2026 19:41:53 -0700 Subject: [PATCH 053/112] Transcript Incremental Projection Nucleic-Session: 0FFD007B-0696-4517-9429-129C7B0FD5AC Co-authored-by: Nucleic --- NucleicRemote/NucleicRemote/Views/SessionDetailView.swift | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift b/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift index 26b57fb..ba56965 100644 --- a/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift +++ b/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift @@ -664,7 +664,12 @@ struct TranscriptList: View { // they scroll away, which lets new content land off-screen below instead of dragging // the viewport, and restore `.bottom` once they're back at the end. .defaultScrollAnchor(isScrolledToBottom ? .bottom : nil) - .onScrollPhaseChange { _, newPhase in scrollPhase = newPhase } + .onScrollPhaseChange { _, newPhase in + scrollPhase = newPhase + // A real grab (drag displacement, not a mere touch-down) takes over from an + // in-flight programmatic jump: the user may scroll away again immediately. + if newPhase == .interacting { jumpingToBottom = false } + } // Track the live scroll position straight from the scroll view's geometry: how much // content still sits below the viewport bottom. Parked within the follow threshold // means live output keeps following; a *user* scroll past the (wider) unfollow From 122f33fdbf264791b69e4e7c2420116c928b3de3 Mon Sep 17 00:00:00 2001 From: Andrew Blakeslee Moore Date: Mon, 6 Jul 2026 19:41:58 -0700 Subject: [PATCH 054/112] Transcript Incremental Projection Nucleic-Session: 0FFD007B-0696-4517-9429-129C7B0FD5AC Co-authored-by: Nucleic --- NucleicRemote/NucleicRemote/Views/SessionDetailView.swift | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift b/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift index ba56965..2576a4e 100644 --- a/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift +++ b/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift @@ -685,7 +685,9 @@ struct TranscriptList: View { } action: { _, distance in if distance <= bottomFollowThreshold { isScrolledToBottom = true - } else if !transcriptSettling, distance > bottomUnfollowThreshold, isUserScrolling { + jumpingToBottom = false // arrived — the jump is complete + } else if !transcriptSettling, distance > bottomUnfollowThreshold, isUserScrolling, + !jumpingToBottom { // While the chat is opening the layout grows over a few passes and the offset // lags each growth by a frame — those frames read "not at bottom" even though // `.defaultScrollAnchor(.bottom)` is about to re-pin, so settling accepts From e605f114e5a61b416ec869983ef7bd61afd6de1e Mon Sep 17 00:00:00 2001 From: Andrew Blakeslee Moore Date: Mon, 6 Jul 2026 19:42:00 -0700 Subject: [PATCH 055/112] Transcript Incremental Projection Nucleic-Session: 0FFD007B-0696-4517-9429-129C7B0FD5AC Co-authored-by: Nucleic --- NucleicRemote/NucleicRemote/Views/SessionDetailView.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift b/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift index 2576a4e..73323df 100644 --- a/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift +++ b/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift @@ -745,6 +745,7 @@ struct TranscriptList: View { // disengages it as usual. .onChange(of: scrollToBottomRequest) { isScrolledToBottom = true + jumpingToBottom = true scrollToEnd(proxy) } // Warm the Markdown parse caches off the main thread whenever the row set grows — From 0026d29b2767eb488525c3deea76a2b0854a3c83 Mon Sep 17 00:00:00 2001 From: Andrew Blakeslee Moore Date: Mon, 6 Jul 2026 19:43:21 -0700 Subject: [PATCH 056/112] Transcript Incremental Projection Nucleic-Session: 0FFD007B-0696-4517-9429-129C7B0FD5AC Co-authored-by: Nucleic --- NucleicRemote/NucleicRemote/Models/HostConnection.swift | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/NucleicRemote/NucleicRemote/Models/HostConnection.swift b/NucleicRemote/NucleicRemote/Models/HostConnection.swift index 6021e0a..0753185 100644 --- a/NucleicRemote/NucleicRemote/Models/HostConnection.swift +++ b/NucleicRemote/NucleicRemote/Models/HostConnection.swift @@ -61,6 +61,15 @@ final class HostConnection { private var transcriptFetchRequestID: String? private var didStartFullFetch = false + /// The in-flight *background* transcript prefetch (one at a time): its request id, target + /// session, and accumulated chunks. Independent of the open-session fetch above — replies + /// are matched on this id, so the two flows never mix, and prefetching keeps working while a + /// different session is open on screen. Driven by RemoteStore, which warms the offline cache + /// with the result so a never-before-opened session still opens at its end instantly. + private var prefetchRequestID: String? + private var prefetchSessionID: SessionID? + private var prefetchEvents: [AgentEvent] = [] + // MARK: Callbacks up to RemoteStore (aggregate concerns) /// How a `HostConnection` talks back to `RemoteStore`. All fire on the main actor. From 3e636f7df7db36e4b84763fbe145eed85bf1bf5a Mon Sep 17 00:00:00 2001 From: Andrew Blakeslee Moore Date: Mon, 6 Jul 2026 19:43:25 -0700 Subject: [PATCH 057/112] Transcript Incremental Projection Nucleic-Session: 0FFD007B-0696-4517-9429-129C7B0FD5AC Co-authored-by: Nucleic --- NucleicRemote/NucleicRemote/Models/HostConnection.swift | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/NucleicRemote/NucleicRemote/Models/HostConnection.swift b/NucleicRemote/NucleicRemote/Models/HostConnection.swift index 0753185..8365fbd 100644 --- a/NucleicRemote/NucleicRemote/Models/HostConnection.swift +++ b/NucleicRemote/NucleicRemote/Models/HostConnection.swift @@ -83,6 +83,10 @@ final class HostConnection { /// Backfilled history for the open session (the full-transcript fetch). Merged into the /// transcript by seq, not appended — these events precede the tail already on screen. var openBackfill: (EventBatch) -> Void = { _ in } + /// A background transcript prefetch finished — everything it fetched (empty when the + /// host had nothing / the fetch died with the connection). RemoteStore merges it into + /// the offline cache and starts the next queued prefetch either way. + var transcriptPrefetched: (SessionID, [AgentEvent]) -> Void = { _, _ in } /// The open session's full diff arrived. var openDiff: (WireSessionDiff) -> Void = { _ in } /// An approval was requested (with the resolved session title) — post the notification and, From 2f8fa2d52fa84d9f951b59b80045d361b796a303 Mon Sep 17 00:00:00 2001 From: Andrew Blakeslee Moore Date: Mon, 6 Jul 2026 19:43:42 -0700 Subject: [PATCH 058/112] Transcript Incremental Projection Nucleic-Session: 0FFD007B-0696-4517-9429-129C7B0FD5AC Co-authored-by: Nucleic --- NucleicRemote/NucleicRemote/Models/HostConnection.swift | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/NucleicRemote/NucleicRemote/Models/HostConnection.swift b/NucleicRemote/NucleicRemote/Models/HostConnection.swift index 8365fbd..7fa7d94 100644 --- a/NucleicRemote/NucleicRemote/Models/HostConnection.swift +++ b/NucleicRemote/NucleicRemote/Models/HostConnection.swift @@ -584,6 +584,12 @@ final class HostConnection { // Session-transfer replies (mesh P5) only reach a *source* Mac; a phone is never one. break case .transcriptChunk(let chunk): + // A slice of a *background prefetch*: accumulate it whole — it never touches the + // open transcript's cursor/dedup state, which belongs to the on-screen session. + if chunk.requestID == prefetchRequestID { + if chunk.sessionID == prefetchSessionID { prefetchEvents.append(contentsOf: chunk.events) } + break + } // One ordered slice of the full-history fetch we started on open. Match on request id so // a stale batch from a previous open (session switched underneath us) is ignored. guard chunk.requestID == transcriptFetchRequestID, chunk.sessionID == openSessionID else { break } @@ -595,10 +601,12 @@ final class HostConnection { if let maxSeq = chunk.events.map(\.seq).max() { openMaxSeq = max(openMaxSeq ?? 0, maxSeq) } if !fresh.isEmpty { callbacks.openBackfill(EventBatch(sessionID: chunk.sessionID, events: fresh)) } case .transcriptFetchComplete(let done): + if done.requestID == prefetchRequestID { finishPrefetch(delivering: true); break } // Terminal success — every batch shipped. Clear the in-flight id; the cursor/dedup state // is already advanced by the chunks above. if done.requestID == transcriptFetchRequestID { transcriptFetchRequestID = nil } case .transcriptUnavailable(let un): + if un.requestID == prefetchRequestID { finishPrefetch(delivering: false); break } // The host holds no transcript for this session (or can't serve the format). The cold tail // still shows; there's just no deeper history to add. Clear the in-flight id. if un.requestID == transcriptFetchRequestID { transcriptFetchRequestID = nil } From 5cb7e6659ffb08e4385bc2f4eec710ce4fd2a377 Mon Sep 17 00:00:00 2001 From: Andrew Blakeslee Moore Date: Mon, 6 Jul 2026 19:43:58 -0700 Subject: [PATCH 059/112] Transcript Incremental Projection Nucleic-Session: 0FFD007B-0696-4517-9429-129C7B0FD5AC Co-authored-by: Nucleic --- .../NucleicRemote/Models/HostConnection.swift | 33 +++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/NucleicRemote/NucleicRemote/Models/HostConnection.swift b/NucleicRemote/NucleicRemote/Models/HostConnection.swift index 7fa7d94..2771848 100644 --- a/NucleicRemote/NucleicRemote/Models/HostConnection.swift +++ b/NucleicRemote/NucleicRemote/Models/HostConnection.swift @@ -692,6 +692,39 @@ final class HostConnection { send(.fetchTranscript(TranscriptFetch(requestID: requestID, sessionID: sessionID, afterSeq: 0))) } + /// Whether a background transcript prefetch can start now: live, the host can serve + /// transcript fetches, and no prefetch is already in flight (they run one at a time). + var canPrefetchTranscript: Bool { + connectivity.isLive && capabilities.canSyncTranscripts && client != nil && prefetchRequestID == nil + } + + /// Fetch a session's transcript in the background — no subscribe, no open — so the offline + /// cache is warm before the user ever opens the session. `afterSeq` bounds the pull to what + /// the cache is missing. The result (all chunks, merged) arrives via + /// `callbacks.transcriptPrefetched`; returns false when it can't start (caller retries on a + /// later session-list update). + @discardableResult + func prefetchTranscript(_ sessionID: SessionID, afterSeq: UInt64) -> Bool { + guard canPrefetchTranscript else { return false } + let requestID = UUID().uuidString + prefetchRequestID = requestID + prefetchSessionID = sessionID + prefetchEvents = [] + send(.fetchTranscript(TranscriptFetch(requestID: requestID, sessionID: sessionID, afterSeq: afterSeq))) + return true + } + + /// Settle the in-flight prefetch: hand what arrived to RemoteStore (nothing on + /// unavailable/disconnect) and clear the slot so the next queued prefetch can start. + private func finishPrefetch(delivering: Bool) { + guard let sessionID = prefetchSessionID else { return } + let events = delivering ? prefetchEvents : [] + prefetchRequestID = nil + prefetchSessionID = nil + prefetchEvents = [] + callbacks.transcriptPrefetched(sessionID, events) + } + /// The device's network path changed (Wi-Fi ⇄ cellular, joined/left a network). React now rather /// than waiting for a zombie LAN socket to time out or the reconnect backoff to elapse — this is /// what makes the transport switch feel immediate. Only reconnect-managed hosts (a pinned host) From 0a236f447b3e13750c64d730027e5418a56552cb Mon Sep 17 00:00:00 2001 From: Andrew Blakeslee Moore Date: Mon, 6 Jul 2026 19:44:00 -0700 Subject: [PATCH 060/112] Live Activity Launch Consistency Nucleic-Session: 9D2EE9AB-2E44-485B-A196-54D250F9D89B Co-authored-by: Nucleic --- .../NucleicRemote/LiveActivityManager.swift | 51 +++++++++++-------- 1 file changed, 30 insertions(+), 21 deletions(-) diff --git a/NucleicRemote/NucleicRemote/LiveActivityManager.swift b/NucleicRemote/NucleicRemote/LiveActivityManager.swift index 73dddd9..61486bb 100644 --- a/NucleicRemote/NucleicRemote/LiveActivityManager.swift +++ b/NucleicRemote/NucleicRemote/LiveActivityManager.swift @@ -148,22 +148,15 @@ final class LiveActivityManager { /// totals on every transcript edit but no status) doesn't spend ActivityKit's update budget. The /// fresh churn still rides along on the next status-driven push. private func push(_ state: NucleicSessionAttributes.ContentState, hostName: String) { - // Apply immediately when the attention signature shifts (start/finish/approval/input — the - // alert-worthy changes). Otherwise the only difference is mid-turn churn (diff totals); let - // that refresh on a slow cadence so it stays roughly current without spending ActivityKit's - // budget on every delta. Elapsed-time gate, not a sleeping timer — a status change is never - // held behind it. - let signatureChanged = state.attentionSignature != lastState?.attentionSignature - let churnRefreshDue = lastPushAt.map { - Date().timeIntervalSince($0) >= Self.churnRefreshInterval - } ?? true - guard signatureChanged || (state != lastState && churnRefreshDue) else { return } - lastState = state - lastPushAt = Date() - guard let activity else { - // Recover an Activity that survived an app relaunch before starting a new one; the - // recovered one still needs the fresh state, so fall through to the update pipeline. + // No Activity yet — recover one that survived an app relaunch, or start a fresh one. The + // dedup gate below only guards *updates* to a live Activity; creation must never sit + // behind it. ActivityKit refuses a start unless the app has a foreground/background- + // assertion window, and committing the dedup state *before* the request meant a refused + // start left `lastState` matching the aggregate — so the next `sync` deduped the glance + // away and it never activated until the session's state changed. Record the pushed state + // only once an Activity actually exists; a refused start leaves `lastState` untouched so + // the next sync simply retries creation. if let existing = Activity.activities.first { activity = existing observePushToken(existing) @@ -173,19 +166,35 @@ final class LiveActivityManager { // expanding reveals the fresh one, and `end()` on completion would leave it lingering // on the last "needs attention" glance. Keep exactly one. endStrays(keeping: existing.id) + lastState = state + lastPushAt = Date() enqueue(state) - } else { + } else if let started = try? Activity.request( // `pushType: .token` opts the activity into APNs updates — the Macs push new // content-state to the token so the glance stays fresh while the phone is locked. - let started = try? Activity.request( - attributes: NucleicSessionAttributes(hostName: hostName), - content: ActivityContent(state: state, staleDate: nil), - pushType: .token) + attributes: NucleicSessionAttributes(hostName: hostName), + content: ActivityContent(state: state, staleDate: nil), + pushType: .token) { activity = started - if let started { observePushToken(started) } + observePushToken(started) + lastState = state + lastPushAt = Date() } return } + + // A live Activity exists — spend ActivityKit's update budget only when the glance actually + // moved: immediately when the attention signature shifts (start/finish/approval/input — the + // alert-worthy changes), otherwise on a slow cadence for mid-turn churn (diff totals ticking + // on every transcript delta). Elapsed-time gate, not a sleeping timer — a status change is + // never held behind it. + let signatureChanged = state.attentionSignature != lastState?.attentionSignature + let churnRefreshDue = lastPushAt.map { + Date().timeIntervalSince($0) >= Self.churnRefreshInterval + } ?? true + guard signatureChanged || (state != lastState && churnRefreshDue) else { return } + lastState = state + lastPushAt = Date() enqueue(state) } From e8d5133adb1e4d75d247df5233ac9f1e4f32da79 Mon Sep 17 00:00:00 2001 From: Andrew Blakeslee Moore Date: Mon, 6 Jul 2026 19:44:01 -0700 Subject: [PATCH 061/112] Transcript Incremental Projection Nucleic-Session: 0FFD007B-0696-4517-9429-129C7B0FD5AC Co-authored-by: Nucleic --- NucleicRemote/NucleicRemote/Models/HostConnection.swift | 3 +++ 1 file changed, 3 insertions(+) diff --git a/NucleicRemote/NucleicRemote/Models/HostConnection.swift b/NucleicRemote/NucleicRemote/Models/HostConnection.swift index 2771848..ef0f7e1 100644 --- a/NucleicRemote/NucleicRemote/Models/HostConnection.swift +++ b/NucleicRemote/NucleicRemote/Models/HostConnection.swift @@ -872,6 +872,9 @@ final class HostConnection { eventTask?.cancel(); eventTask = nil if let client { Task { await client.disconnect() } } client = nil + // A dropped connection loses the in-flight prefetch's remaining chunks — settle it empty + // so RemoteStore's queue isn't left waiting on a completion that will never arrive. + finishPrefetch(delivering: false) } } From 4c592e42e72f8a90bc08cdff7690f2714eb320bc Mon Sep 17 00:00:00 2001 From: Andrew Blakeslee Moore Date: Mon, 6 Jul 2026 19:44:16 -0700 Subject: [PATCH 062/112] Transcript Incremental Projection Nucleic-Session: 0FFD007B-0696-4517-9429-129C7B0FD5AC Co-authored-by: Nucleic --- NucleicRemote/NucleicRemote/Models/SessionCache.swift | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/NucleicRemote/NucleicRemote/Models/SessionCache.swift b/NucleicRemote/NucleicRemote/Models/SessionCache.swift index fa8b0f2..ed68dba 100644 --- a/NucleicRemote/NucleicRemote/Models/SessionCache.swift +++ b/NucleicRemote/NucleicRemote/Models/SessionCache.swift @@ -22,8 +22,9 @@ enum SessionCache { /// files) are pruned on every save so the cache stays bounded. private static let sessionLimit = 50 /// Keep the newest N events per session — the tail is what a returning reader wants, and it - /// bounds a long-running session's file. - private static let eventLimit = 1500 + /// bounds a long-running session's file. Internal (not private) so the background transcript + /// prefetch can bound a cold session's pull to the same window this cache would keep anyway. + static let eventLimit = 1500 // MARK: - Paths From 4d59e860645e50f13ee409a1250cfb42691745fd Mon Sep 17 00:00:00 2001 From: Andrew Blakeslee Moore Date: Mon, 6 Jul 2026 19:44:30 -0700 Subject: [PATCH 063/112] Transcript Incremental Projection Nucleic-Session: 0FFD007B-0696-4517-9429-129C7B0FD5AC Co-authored-by: Nucleic --- .../NucleicRemote/Models/RemoteStore.swift | 63 +++++++++++++++++++ 1 file changed, 63 insertions(+) diff --git a/NucleicRemote/NucleicRemote/Models/RemoteStore.swift b/NucleicRemote/NucleicRemote/Models/RemoteStore.swift index 5ccc3cb..503291d 100644 --- a/NucleicRemote/NucleicRemote/Models/RemoteStore.swift +++ b/NucleicRemote/NucleicRemote/Models/RemoteStore.swift @@ -812,6 +812,69 @@ final class RemoteStore: ObservableObject { } } + // MARK: - Background transcript prefetch + + /// Warm the offline transcript cache for recent sessions *before* they're ever opened. A + /// fresh open otherwise starts from an empty transcript and waits on the host's snapshot, so + /// the view renders at the top and visibly drops to the end as history lands; a session + /// opened before seeds instantly from `SessionCache` and opens at its end. Prefetching runs + /// the same `fetchTranscript` flow the open path uses — one session at a time, newest first, + /// pulling only what the cache is missing — so every recent session opens like a warm one. + private var prefetchQueue: [SessionID] = [] + private var prefetchInFlight: SessionID? + /// The summary `lastSeq` each session was last prefetched (or attempted) at, so a session is + /// re-queued only after it has moved on — not on every aggregate rebuild. + private var prefetchedSeq: [SessionID: UInt64] = [:] + + /// Rebuild the prefetch queue from the freshest summaries. Called when the session list + /// actually changes (connect, session churn); cheap when nothing needs pulling. + private func scheduleTranscriptPrefetch() { + guard !demoMode else { return } + prefetchQueue = sessions + .filter { !$0.archived && $0.sessionID != openSessionID && $0.sessionID != prefetchInFlight } + .sorted { $0.updatedAt > $1.updatedAt } + .prefix(8) + .filter { $0.lastSeq > (prefetchedSeq[$0.sessionID] ?? 0) } + .map(\.sessionID) + pumpTranscriptPrefetch() + } + + /// Start the next queued prefetch if none is in flight. Serial on purpose — background work + /// must trickle behind the live stream, not contend with it. + private func pumpTranscriptPrefetch() { + guard prefetchInFlight == nil, !prefetchQueue.isEmpty else { return } + let id = prefetchQueue.removeFirst() + guard let summary = sessions.first(where: { $0.sessionID == id }), + let conn = connection(owningSession: id), conn.canPrefetchTranscript + else { + // Gone / connection busy or incapable — skip; a later list update re-queues it. + pumpTranscriptPrefetch() + return + } + prefetchInFlight = id + // Mark the attempt at this watermark now, so an empty/unavailable result doesn't + // re-queue in a loop; the session re-qualifies once its lastSeq advances. + prefetchedSeq[id] = summary.lastSeq + Task { [weak self] in + // Pull only the gap beyond what's cached; a cold session is bounded to the same + // tail window the cache would keep anyway. + let cachedLast = await SessionCache.loadEvents(id).last?.seq ?? 0 + guard let self else { return } + if cachedLast >= summary.lastSeq { + self.prefetchInFlight = nil // cache already current + self.pumpTranscriptPrefetch() + return + } + let coldFloor = summary.lastSeq > UInt64(SessionCache.eventLimit) + ? summary.lastSeq - UInt64(SessionCache.eventLimit) : 0 + let afterSeq = cachedLast > 0 ? cachedLast : coldFloor + if !conn.prefetchTranscript(id, afterSeq: afterSeq) { + self.prefetchInFlight = nil // couldn't start (connection changed) — move on + self.pumpTranscriptPrefetch() + } + } + } + // MARK: - Streaming delta coalescing /// Streaming transcript deltas arrive one wire frame at a time — often dozens per second From b4f2f77a4f437df7f2d52e338ed729ad97751f97 Mon Sep 17 00:00:00 2001 From: Andrew Blakeslee Moore Date: Mon, 6 Jul 2026 19:44:42 -0700 Subject: [PATCH 064/112] Transcript Incremental Projection Nucleic-Session: 0FFD007B-0696-4517-9429-129C7B0FD5AC Co-authored-by: Nucleic --- .../NucleicRemote/Models/RemoteStore.swift | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/NucleicRemote/NucleicRemote/Models/RemoteStore.swift b/NucleicRemote/NucleicRemote/Models/RemoteStore.swift index 503291d..7d1da33 100644 --- a/NucleicRemote/NucleicRemote/Models/RemoteStore.swift +++ b/NucleicRemote/NucleicRemote/Models/RemoteStore.swift @@ -659,6 +659,19 @@ final class RemoteStore: ObservableObject { self.openEvents = Self.mergedEvents(self.openEvents, batch.events) self.persistOpenTranscript() } + cb.transcriptPrefetched = { [weak self] sessionID, events in + guard let self else { return } + self.prefetchInFlight = nil + if !events.isEmpty { + // Merge into whatever the cache already holds (the fetch pulled only the gap); + // `saveEvents` re-caps to the tail window on write. + Task { + let cached = await SessionCache.loadEvents(sessionID) + await SessionCache.saveEvents(Self.mergedEvents(cached, events), for: sessionID) + } + } + self.pumpTranscriptPrefetch() + } cb.openDiff = { [weak self] diff in guard let self, hostID == self.openSessionHostID, diff.sessionID == self.openSessionID else { return } self.openDiff = diff From c7f2ade5109d4a265b3b5b791cf85376486e1173 Mon Sep 17 00:00:00 2001 From: Andrew Blakeslee Moore Date: Mon, 6 Jul 2026 19:44:46 -0700 Subject: [PATCH 065/112] Transcript Incremental Projection Nucleic-Session: 0FFD007B-0696-4517-9429-129C7B0FD5AC Co-authored-by: Nucleic --- NucleicRemote/NucleicRemote/Models/RemoteStore.swift | 3 +++ 1 file changed, 3 insertions(+) diff --git a/NucleicRemote/NucleicRemote/Models/RemoteStore.swift b/NucleicRemote/NucleicRemote/Models/RemoteStore.swift index 7d1da33..5d64769 100644 --- a/NucleicRemote/NucleicRemote/Models/RemoteStore.swift +++ b/NucleicRemote/NucleicRemote/Models/RemoteStore.swift @@ -749,6 +749,9 @@ final class RemoteStore: ObservableObject { sessions = live cachedSummaries = live persistSummaries(live) + // The list moved — new or advanced sessions may need their transcript cache + // warmed so a first open starts at the end like a revisit does. + scheduleTranscriptPrefetch() } } else if connections.values.contains(where: { $0.connectivity.isLive }) { // Connected, but the host genuinely has no sessions — reflect that honestly. From acde1f4f68f693f064cf0c21836d4ebe07681425 Mon Sep 17 00:00:00 2001 From: Andrew Blakeslee Moore Date: Mon, 6 Jul 2026 19:45:36 -0700 Subject: [PATCH 066/112] Live Activity Launch Consistency Nucleic-Session: 9D2EE9AB-2E44-485B-A196-54D250F9D89B Co-authored-by: Nucleic --- NucleicRemote/NucleicRemote/LiveActivityManager.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/NucleicRemote/NucleicRemote/LiveActivityManager.swift b/NucleicRemote/NucleicRemote/LiveActivityManager.swift index 61486bb..44b1de3 100644 --- a/NucleicRemote/NucleicRemote/LiveActivityManager.swift +++ b/NucleicRemote/NucleicRemote/LiveActivityManager.swift @@ -148,7 +148,7 @@ final class LiveActivityManager { /// totals on every transcript edit but no status) doesn't spend ActivityKit's update budget. The /// fresh churn still rides along on the next status-driven push. private func push(_ state: NucleicSessionAttributes.ContentState, hostName: String) { - guard let activity else { + guard activity != nil else { // No Activity yet — recover one that survived an app relaunch, or start a fresh one. The // dedup gate below only guards *updates* to a live Activity; creation must never sit // behind it. ActivityKit refuses a start unless the app has a foreground/background- From 118e5ed37c5d3845b1e5a93d7aec41deef9bc292 Mon Sep 17 00:00:00 2001 From: Andrew Blakeslee Moore Date: Mon, 6 Jul 2026 20:04:36 -0700 Subject: [PATCH 067/112] Live Activity Launch Consistency Nucleic-Session: 9D2EE9AB-2E44-485B-A196-54D250F9D89B Co-authored-by: Nucleic --- NucleicRemote/NucleicRemote/Models/RemoteStore.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/NucleicRemote/NucleicRemote/Models/RemoteStore.swift b/NucleicRemote/NucleicRemote/Models/RemoteStore.swift index 5d64769..128465f 100644 --- a/NucleicRemote/NucleicRemote/Models/RemoteStore.swift +++ b/NucleicRemote/NucleicRemote/Models/RemoteStore.swift @@ -1488,7 +1488,7 @@ final class RemoteStore: ObservableObject { // `requestPairingCode`/`cancelPairingCode` are sent straight to the chosen host by // `requestPairingCode()`/`cancelPairingCode()`, not through this owner-routing switch. case .hello, .ping, .listPeers, .addressUpdate, .meshRoster, - .registerLiveActivity, .endLiveActivity, .registerPushToStartToken, + .registerLiveActivity, .endLiveActivity, .registerPushToStartToken, .setForeground, .transferOffer, .transferChunk, .transferCommit, .transferCancel, .fetchTranscript, .requestPairingCode, .cancelPairingCode, .respondMacPair: break From 63c92ee0939c8c71fd82273dc3b78cfe1e6ad471 Mon Sep 17 00:00:00 2001 From: Andrew Blakeslee Moore Date: Mon, 6 Jul 2026 20:04:41 -0700 Subject: [PATCH 068/112] Live Activity Launch Consistency Nucleic-Session: 9D2EE9AB-2E44-485B-A196-54D250F9D89B Co-authored-by: Nucleic --- NucleicRemote/NucleicRemote/Models/RemoteStore.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/NucleicRemote/NucleicRemote/Models/RemoteStore.swift b/NucleicRemote/NucleicRemote/Models/RemoteStore.swift index 128465f..137a3e8 100644 --- a/NucleicRemote/NucleicRemote/Models/RemoteStore.swift +++ b/NucleicRemote/NucleicRemote/Models/RemoteStore.swift @@ -1633,7 +1633,7 @@ final class RemoteStore: ObservableObject { .addressUpdate, .meshRoster, // Live Activity push registration is a real-connection concern (there's no host to // push in demo), so it's inert here. - .registerLiveActivity, .endLiveActivity, .registerPushToStartToken, + .registerLiveActivity, .endLiveActivity, .registerPushToStartToken, .setForeground, // Session transfer (mesh P5) is a Mac↔Mac flow — the phone never originates these, // and demo has no peer Macs, so they're inert here. .transferOffer, .transferChunk, .transferCommit, .transferCancel, From 3bbba4289519acd9cbd7bd37c2517b0afd9640ad Mon Sep 17 00:00:00 2001 From: Andrew Blakeslee Moore Date: Mon, 6 Jul 2026 20:05:06 -0700 Subject: [PATCH 069/112] Live Activity Launch Consistency Nucleic-Session: 9D2EE9AB-2E44-485B-A196-54D250F9D89B Co-authored-by: Nucleic --- NucleicRemote/NucleicRemote/Models/RemoteStore.swift | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/NucleicRemote/NucleicRemote/Models/RemoteStore.swift b/NucleicRemote/NucleicRemote/Models/RemoteStore.swift index 137a3e8..2d38b5a 100644 --- a/NucleicRemote/NucleicRemote/Models/RemoteStore.swift +++ b/NucleicRemote/NucleicRemote/Models/RemoteStore.swift @@ -1511,6 +1511,14 @@ final class RemoteStore: ObservableObject { private var pushToStartToken: String? /// Host ids that already have the current push-to-start token (mirrors `liveActivitySentTo`). private var pushToStartSentTo: Set = [] + /// Whether the app is currently foreground. Reported to capable Macs (`ClientMsg.setForeground`) + /// 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 + /// `true`: `onAppear` runs in the foreground; the background adopt-wake flips it first (§5.3). + private var isForeground = true + /// Host ids already told the *current* `isForeground` value — cleared whenever it flips so the + /// next sync re-notifies every host (mirrors `pushToStartSentTo`). + private var foregroundSentTo: Set = [] /// Guards `setupLiveActivityBridge` — it's called from both `onAppear` and the background adopt /// wake, and installing the callbacks / observers once is enough. From 81a11261c6cdf4bf4bcb77e6dc9c83462257773d Mon Sep 17 00:00:00 2001 From: Andrew Blakeslee Moore Date: Mon, 6 Jul 2026 20:05:21 -0700 Subject: [PATCH 070/112] Live Activity Launch Consistency Nucleic-Session: 9D2EE9AB-2E44-485B-A196-54D250F9D89B Co-authored-by: Nucleic --- .../NucleicRemote/Models/RemoteStore.swift | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/NucleicRemote/NucleicRemote/Models/RemoteStore.swift b/NucleicRemote/NucleicRemote/Models/RemoteStore.swift index 2d38b5a..4d388f5 100644 --- a/NucleicRemote/NucleicRemote/Models/RemoteStore.swift +++ b/NucleicRemote/NucleicRemote/Models/RemoteStore.swift @@ -1584,6 +1584,32 @@ final class RemoteStore: ObservableObject { } } + /// Tell every capable, live host the current foreground state so it can pick the Live Activity + /// path: defer to the phone's own creation while foreground, or push-to-start the glance while + /// backgrounded (UX_IOS §5.3). De-duped per host against the value already sent; the set is + /// cleared by `setForegroundState` when the value flips, and a host that dropped re-learns it when + /// it returns (via the `didUpdate` re-sync). Gated on the dedicated capability bit — an older host + /// (even one advertising push-to-start) throws on the unknown `setForeground` tag. + private func syncForegroundState() { + foregroundSentTo = foregroundSentTo.filter { connections[$0]?.connectivity.isLive == true } + for (id, conn) in connections { + guard conn.connectivity.isLive, conn.capabilities.canReceiveForegroundState, + !foregroundSentTo.contains(id) else { continue } + conn.send(.setForeground(isForeground)) + foregroundSentTo.insert(id) + } + } + + /// Record a foreground/background transition and push it to the hosts. No-op when unchanged so an + /// `inactive`↔`active` flutter (or a repeat call) doesn't re-notify; on a real flip it clears the + /// per-host dedup so `syncForegroundState` re-sends to everyone. + private func setForegroundState(_ foreground: Bool) { + guard isForeground != foreground else { return } + isForeground = foreground + foregroundSentTo.removeAll() + syncForegroundState() + } + // MARK: - Demo simulator (offline, interactive) // // In demo mode there's no host, so writes can't go over the wire. Instead they mutate the From fb57ad43990dc18d1e5babcf677719bbe01f6a4f Mon Sep 17 00:00:00 2001 From: Andrew Blakeslee Moore Date: Mon, 6 Jul 2026 20:05:27 -0700 Subject: [PATCH 071/112] Live Activity Launch Consistency Nucleic-Session: 9D2EE9AB-2E44-485B-A196-54D250F9D89B Co-authored-by: Nucleic --- NucleicRemote/NucleicRemote/Models/RemoteStore.swift | 3 +++ 1 file changed, 3 insertions(+) diff --git a/NucleicRemote/NucleicRemote/Models/RemoteStore.swift b/NucleicRemote/NucleicRemote/Models/RemoteStore.swift index 4d388f5..4753405 100644 --- a/NucleicRemote/NucleicRemote/Models/RemoteStore.swift +++ b/NucleicRemote/NucleicRemote/Models/RemoteStore.swift @@ -634,6 +634,9 @@ final class RemoteStore: ObservableObject { // the glance cold when work starts before the app is opened. self.syncLiveActivityRegistration() self.syncPushToStartRegistration() + // …and the current foreground state, so it push-to-starts the glance itself when the app + // is backgrounded rather than waiting on a self-create that can't happen (UX_IOS §5.3). + self.syncForegroundState() } cb.openSnapshot = { [weak self] snap in guard let self, hostID == self.openSessionHostID, snap.summary.sessionID == self.openSessionID else { return } From 613fc404cf33f976dd612ef2b9ce64848bc37485 Mon Sep 17 00:00:00 2001 From: Andrew Blakeslee Moore Date: Mon, 6 Jul 2026 20:05:32 -0700 Subject: [PATCH 072/112] Live Activity Launch Consistency Nucleic-Session: 9D2EE9AB-2E44-485B-A196-54D250F9D89B Co-authored-by: Nucleic --- NucleicRemote/NucleicRemote/Models/RemoteStore.swift | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/NucleicRemote/NucleicRemote/Models/RemoteStore.swift b/NucleicRemote/NucleicRemote/Models/RemoteStore.swift index 4753405..e9dd926 100644 --- a/NucleicRemote/NucleicRemote/Models/RemoteStore.swift +++ b/NucleicRemote/NucleicRemote/Models/RemoteStore.swift @@ -359,6 +359,10 @@ final class RemoteStore: ObservableObject { func onForeground() { endBackgroundHold() guard !demoMode, isPaired else { return } + // Foreground again: the app self-creates its own Live Activity now, so tell the hosts to stop + // push-to-starting it (UX_IOS §5.3). Sent to already-live hosts; ones still reconnecting get + // it via `didUpdate` once live. + setForegroundState(true) pathMonitor.refresh() // Ensure a connection exists for every paired host (and drop unpaired); this re-dials the // ones already known to be offline. From f13a94c7b02524ce6b3143125c3dc8704ace1993 Mon Sep 17 00:00:00 2001 From: Andrew Blakeslee Moore Date: Mon, 6 Jul 2026 20:05:39 -0700 Subject: [PATCH 073/112] Live Activity Launch Consistency Nucleic-Session: 9D2EE9AB-2E44-485B-A196-54D250F9D89B Co-authored-by: Nucleic --- NucleicRemote/NucleicRemote/Models/RemoteStore.swift | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/NucleicRemote/NucleicRemote/Models/RemoteStore.swift b/NucleicRemote/NucleicRemote/Models/RemoteStore.swift index e9dd926..e896ca4 100644 --- a/NucleicRemote/NucleicRemote/Models/RemoteStore.swift +++ b/NucleicRemote/NucleicRemote/Models/RemoteStore.swift @@ -383,6 +383,10 @@ final class RemoteStore: ObservableObject { /// suspends us and the socket dies, which the next `onForeground` revalidation reconnects fast. func onBackground() { guard !demoMode else { return } + // Heading to the background: the app can't reliably start a Live Activity anymore, so tell the + // hosts to push-to-start the glance themselves when work begins (UX_IOS §5.3). Sent now while + // the socket-hold below still keeps the connection live for a beat. + setForegroundState(false) #if canImport(UIKit) endBackgroundHold() backgroundTask = UIApplication.shared.beginBackgroundTask(withName: "nucleic.sync.hold") { From fc1d3a325691c3d1cd0c85433343075d595d9c6a Mon Sep 17 00:00:00 2001 From: Andrew Blakeslee Moore Date: Mon, 6 Jul 2026 20:05:52 -0700 Subject: [PATCH 074/112] Live Activity Launch Consistency Nucleic-Session: 9D2EE9AB-2E44-485B-A196-54D250F9D89B Co-authored-by: Nucleic --- NucleicRemote/NucleicRemote/Models/RemoteStore.swift | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/NucleicRemote/NucleicRemote/Models/RemoteStore.swift b/NucleicRemote/NucleicRemote/Models/RemoteStore.swift index e896ca4..1327198 100644 --- a/NucleicRemote/NucleicRemote/Models/RemoteStore.swift +++ b/NucleicRemote/NucleicRemote/Models/RemoteStore.swift @@ -414,6 +414,10 @@ final class RemoteStore: ObservableObject { /// state until the app next runs. func handleLiveActivityAdoptWake(completion: @escaping () -> Void) { guard !demoMode, isPaired else { completion(); return } + // A silent adopt wake means we're background (possibly cold-launched with no scene, where + // `isForeground`'s default would otherwise read stale-true) — record that so the reconnect + // below reports background and the host keeps owning the glance via push-to-start (§5.3). + setForegroundState(false) setupLiveActivityBridge() // starts the push-to-start + adoption observers if not already startNetworkingIfNeeded() #if canImport(UIKit) From d241d2be0bfc0062a1739a4f91136af48eac7789 Mon Sep 17 00:00:00 2001 From: Andrew Blakeslee Moore Date: Mon, 6 Jul 2026 20:55:16 -0700 Subject: [PATCH 075/112] Hide Chat Transcript Lines Nucleic-Session: 15C40739-6C13-4CD7-B795-5E218A160B62 Co-authored-by: Nucleic --- NucleicRemote/NucleicRemote/Views/TranscriptRow.swift | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/NucleicRemote/NucleicRemote/Views/TranscriptRow.swift b/NucleicRemote/NucleicRemote/Views/TranscriptRow.swift index ebe23b2..907ceca 100644 --- a/NucleicRemote/NucleicRemote/Views/TranscriptRow.swift +++ b/NucleicRemote/NucleicRemote/Views/TranscriptRow.swift @@ -43,8 +43,9 @@ struct TranscriptRow: View { InfoLine(icon: "play.circle", text: "Session started · \(modelLabel(model))", detail: cwd) case .usage(let usage): UsageRow(usage: usage) - case .rateLimit(let limit): - RateLimitRow(limit: limit) + case .rateLimit: + // Rate-limit notices are intentionally hidden from the transcript. + EmptyView() case .turnBoundary: TurnBoundaryRow() case .approval(let toolName): From 7c890cbd818650e4d90562effe4b2dce8bce2cef Mon Sep 17 00:00:00 2001 From: Andrew Blakeslee Moore Date: Mon, 6 Jul 2026 21:07:26 -0700 Subject: [PATCH 076/112] Push Notification Live Activity Redundancy Nucleic-Session: 73687ED9-C11C-438C-832B-979332B73893 Co-authored-by: Nucleic --- NucleicRemote/NucleicRemote/Localizable.strings | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/NucleicRemote/NucleicRemote/Localizable.strings b/NucleicRemote/NucleicRemote/Localizable.strings index b9b26a0..57ddd68 100644 --- a/NucleicRemote/NucleicRemote/Localizable.strings +++ b/NucleicRemote/NucleicRemote/Localizable.strings @@ -4,6 +4,12 @@ over the encrypted channel on open. */ "approval.pending" = "A session is waiting for your approval"; +/* Title of an *alerting* Live Activity update (apns.ts `liveActivityAlert`): a new approval landed + while the phone was away, so the glance itself sounds/vibrates instead of a separate wake tickle. + Shown only on Apple Watch (on iPhone the glance updates in place); the body reuses + `approval.pending`. Still content-free — the real approval is pulled over the encrypted channel. */ +"approval.title" = "Approval needed"; + /* The same content-free wake, but the block is an agent question (`AskUserQuestion`) rather than 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. */ From 9dc21cac8c0f015d38ec941e2fd388da03511d51 Mon Sep 17 00:00:00 2001 From: Andrew Blakeslee Moore Date: Mon, 6 Jul 2026 21:29:02 -0700 Subject: [PATCH 077/112] Push Notification Live Activity Redundancy Nucleic-Session: 73687ED9-C11C-438C-832B-979332B73893 Co-authored-by: Nucleic --- NucleicRemote/NucleicRemote/Localizable.strings | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/NucleicRemote/NucleicRemote/Localizable.strings b/NucleicRemote/NucleicRemote/Localizable.strings index 57ddd68..4b28177 100644 --- a/NucleicRemote/NucleicRemote/Localizable.strings +++ b/NucleicRemote/NucleicRemote/Localizable.strings @@ -14,3 +14,11 @@ 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. */ "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"; From ece9ab538ce9e3b2d6c135bbdc66c48efb6c3dfd Mon Sep 17 00:00:00 2001 From: Andrew Blakeslee Moore Date: Mon, 6 Jul 2026 21:29:35 -0700 Subject: [PATCH 078/112] Push Notification Live Activity Redundancy Nucleic-Session: 73687ED9-C11C-438C-832B-979332B73893 Co-authored-by: Nucleic --- NucleicRemote/NucleicRemote/Views/Theme.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/NucleicRemote/NucleicRemote/Views/Theme.swift b/NucleicRemote/NucleicRemote/Views/Theme.swift index 6adbd2f..ab77ad7 100644 --- a/NucleicRemote/NucleicRemote/Views/Theme.swift +++ b/NucleicRemote/NucleicRemote/Views/Theme.swift @@ -303,7 +303,7 @@ extension SessionStatus { } 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 color: Color { switch self { From e1195182fcf0f57945947466e5cc9b9ece4b4aab Mon Sep 17 00:00:00 2001 From: Andrew Blakeslee Moore Date: Mon, 6 Jul 2026 21:32:13 -0700 Subject: [PATCH 079/112] Push Notification Live Activity Redundancy Nucleic-Session: 73687ED9-C11C-438C-832B-979332B73893 Co-authored-by: Nucleic --- NucleicRemote/NucleicRemote/LiveActivityManager.swift | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/NucleicRemote/NucleicRemote/LiveActivityManager.swift b/NucleicRemote/NucleicRemote/LiveActivityManager.swift index 44b1de3..b57ecae 100644 --- a/NucleicRemote/NucleicRemote/LiveActivityManager.swift +++ b/NucleicRemote/NucleicRemote/LiveActivityManager.swift @@ -307,13 +307,19 @@ final class LiveActivityManager { /// Project a wire summary into the widget's self-contained row model. private static func line(for s: WireSessionSummary) -> NucleicSessionAttributes.SessionLine { - NucleicSessionAttributes.SessionLine( + // Carry the approval id/risk only for a session actually awaiting approval — that's the row + // the glance renders inline Allow/Deny on (`ApproveFromActivityIntent`). Other states leave + // it nil so the row stays a plain deep-link tap. + let approvalID = s.status == .awaitingApproval ? s.firstApprovalID?.rawValue : nil + return NucleicSessionAttributes.SessionLine( id: s.sessionID.rawValue, title: s.title.isEmpty ? s.projectName : s.title, project: s.projectName, backend: backend(s.backend), 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 { From 315dab9e048d93c674e96d6ad857d7abb5ef1285 Mon Sep 17 00:00:00 2001 From: Andrew Blakeslee Moore Date: Mon, 6 Jul 2026 21:32:28 -0700 Subject: [PATCH 080/112] Push Notification Live Activity Redundancy Nucleic-Session: 73687ED9-C11C-438C-832B-979332B73893 Co-authored-by: Nucleic --- .../NucleicRemote/LiveActivityManager.swift | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/NucleicRemote/NucleicRemote/LiveActivityManager.swift b/NucleicRemote/NucleicRemote/LiveActivityManager.swift index b57ecae..e97793d 100644 --- a/NucleicRemote/NucleicRemote/LiveActivityManager.swift +++ b/NucleicRemote/NucleicRemote/LiveActivityManager.swift @@ -24,6 +24,18 @@ final class LiveActivityManager { private var activity: Activity? + /// 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 /// 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 From 4f30e304b0a39bbf30c4d30ef2973a0637486f78 Mon Sep 17 00:00:00 2001 From: Andrew Blakeslee Moore Date: Mon, 6 Jul 2026 21:33:11 -0700 Subject: [PATCH 081/112] Push Notification Live Activity Redundancy Nucleic-Session: 73687ED9-C11C-438C-832B-979332B73893 Co-authored-by: Nucleic --- NucleicRemote/NucleicRemote/LiveActivityManager.swift | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/NucleicRemote/NucleicRemote/LiveActivityManager.swift b/NucleicRemote/NucleicRemote/LiveActivityManager.swift index e97793d..fd39e6d 100644 --- a/NucleicRemote/NucleicRemote/LiveActivityManager.swift +++ b/NucleicRemote/NucleicRemote/LiveActivityManager.swift @@ -54,6 +54,10 @@ final class LiveActivityManager { /// 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. 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? /// Set by `RemoteStore` to ship the activity's APNS push token to the paired Macs (and to tell From f62840f276fac2eb047cf3866f6780a2992a550c Mon Sep 17 00:00:00 2001 From: Andrew Blakeslee Moore Date: Mon, 6 Jul 2026 21:33:26 -0700 Subject: [PATCH 082/112] Push Notification Live Activity Redundancy Nucleic-Session: 73687ED9-C11C-438C-832B-979332B73893 Co-authored-by: Nucleic --- .../NucleicRemote/LiveActivityManager.swift | 28 ++++++++++++++++++- 1 file changed, 27 insertions(+), 1 deletion(-) diff --git a/NucleicRemote/NucleicRemote/LiveActivityManager.swift b/NucleicRemote/NucleicRemote/LiveActivityManager.swift index fd39e6d..194eacf 100644 --- a/NucleicRemote/NucleicRemote/LiveActivityManager.swift +++ b/NucleicRemote/NucleicRemote/LiveActivityManager.swift @@ -209,9 +209,35 @@ final class LiveActivityManager { Date().timeIntervalSince($0) >= Self.churnRefreshInterval } ?? true 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 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 From 93003404759aab0cb41e8c9e64ee355829596ef6 Mon Sep 17 00:00:00 2001 From: Andrew Blakeslee Moore Date: Mon, 6 Jul 2026 21:33:38 -0700 Subject: [PATCH 083/112] Push Notification Live Activity Redundancy Nucleic-Session: 73687ED9-C11C-438C-832B-979332B73893 Co-authored-by: Nucleic --- NucleicRemote/NucleicRemote/LiveActivityManager.swift | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/NucleicRemote/NucleicRemote/LiveActivityManager.swift b/NucleicRemote/NucleicRemote/LiveActivityManager.swift index 194eacf..224257e 100644 --- a/NucleicRemote/NucleicRemote/LiveActivityManager.swift +++ b/NucleicRemote/NucleicRemote/LiveActivityManager.swift @@ -293,14 +293,20 @@ final class LiveActivityManager { } /// 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 + 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 updateTask = Task { @MainActor [weak self] in guard let self else { return } while let next = self.pendingState { 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 } From 4be25b92930f7e56a8bf5f7e21ec4c2fcd8a68c0 Mon Sep 17 00:00:00 2001 From: Andrew Blakeslee Moore Date: Mon, 6 Jul 2026 21:33:51 -0700 Subject: [PATCH 084/112] Push Notification Live Activity Redundancy Nucleic-Session: 73687ED9-C11C-438C-832B-979332B73893 Co-authored-by: Nucleic --- NucleicRemote/NucleicRemote/LiveActivityManager.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/NucleicRemote/NucleicRemote/LiveActivityManager.swift b/NucleicRemote/NucleicRemote/LiveActivityManager.swift index 224257e..9b368ec 100644 --- a/NucleicRemote/NucleicRemote/LiveActivityManager.swift +++ b/NucleicRemote/NucleicRemote/LiveActivityManager.swift @@ -321,6 +321,7 @@ final class LiveActivityManager { dismissTask?.cancel() dismissTask = nil pendingState = nil + pendingAlert = nil lastState = nil lastPushAt = nil let tracked = activity From 890399e486e99363f68fa7c700ff2405cdea9fb8 Mon Sep 17 00:00:00 2001 From: Andrew Blakeslee Moore Date: Mon, 6 Jul 2026 21:34:09 -0700 Subject: [PATCH 085/112] Push Notification Live Activity Redundancy Nucleic-Session: 73687ED9-C11C-438C-832B-979332B73893 Co-authored-by: Nucleic --- NucleicRemote/NucleicRemote/Models/RemoteStore.swift | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/NucleicRemote/NucleicRemote/Models/RemoteStore.swift b/NucleicRemote/NucleicRemote/Models/RemoteStore.swift index 1327198..ff40b49 100644 --- a/NucleicRemote/NucleicRemote/Models/RemoteStore.swift +++ b/NucleicRemote/NucleicRemote/Models/RemoteStore.swift @@ -1530,7 +1530,11 @@ final class RemoteStore: ObservableObject { /// 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 /// `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 /// next sync re-notifies every host (mirrors `pushToStartSentTo`). private var foregroundSentTo: Set = [] From 5535152810d29468807e6a2530db88a4060a9af8 Mon Sep 17 00:00:00 2001 From: Andrew Blakeslee Moore Date: Mon, 6 Jul 2026 21:34:20 -0700 Subject: [PATCH 086/112] Push Notification Live Activity Redundancy Nucleic-Session: 73687ED9-C11C-438C-832B-979332B73893 Co-authored-by: Nucleic --- NucleicRemote/NucleicRemote/Models/RemoteStore.swift | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/NucleicRemote/NucleicRemote/Models/RemoteStore.swift b/NucleicRemote/NucleicRemote/Models/RemoteStore.swift index ff40b49..39b361d 100644 --- a/NucleicRemote/NucleicRemote/Models/RemoteStore.swift +++ b/NucleicRemote/NucleicRemote/Models/RemoteStore.swift @@ -698,8 +698,15 @@ final class RemoteStore: ObservableObject { !self.openApprovals.contains(where: { $0.id == req.id }) { self.openApprovals.append(req) } - // Post for any host; the router suppresses the banner if the user is on this session. - NotificationRouter.shared.postApproval(req, sessionTitle: title) + // 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 isForeground || !LiveActivityManager.shared.hasLiveActivity { + NotificationRouter.shared.postApproval(req, sessionTitle: title) + } } cb.approvalResolved = { [weak self] resolved in guard let self else { return } From c6427987b88643ba82069b0bdf12b5ed5dcd84d9 Mon Sep 17 00:00:00 2001 From: Andrew Blakeslee Moore Date: Mon, 6 Jul 2026 21:34:34 -0700 Subject: [PATCH 087/112] Push Notification Live Activity Redundancy Nucleic-Session: 73687ED9-C11C-438C-832B-979332B73893 Co-authored-by: Nucleic --- NucleicRemote/NucleicRemote/Models/RemoteStore.swift | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/NucleicRemote/NucleicRemote/Models/RemoteStore.swift b/NucleicRemote/NucleicRemote/Models/RemoteStore.swift index 39b361d..ab75ef3 100644 --- a/NucleicRemote/NucleicRemote/Models/RemoteStore.swift +++ b/NucleicRemote/NucleicRemote/Models/RemoteStore.swift @@ -715,7 +715,12 @@ final class RemoteStore: ObservableObject { } cb.sessionBecameWaiting = { [weak self] summary in guard let self, !self.isActive else { return } - NotificationRouter.shared.postSessionUpdate(summary) + // 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) + } } cb.wireError = { [weak self] error in self?.showError(error.message, sessionID: error.sessionID) From aa7b7e6717801ef5a81776afd64646ca1271e575 Mon Sep 17 00:00:00 2001 From: Andrew Blakeslee Moore Date: Mon, 6 Jul 2026 21:57:00 -0700 Subject: [PATCH 088/112] Push Notification Live Activity Redundancy Nucleic-Session: 73687ED9-C11C-438C-832B-979332B73893 Co-authored-by: Nucleic --- NucleicRemote/NucleicRemote/Models/RemoteStore.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/NucleicRemote/NucleicRemote/Models/RemoteStore.swift b/NucleicRemote/NucleicRemote/Models/RemoteStore.swift index ab75ef3..e116d6e 100644 --- a/NucleicRemote/NucleicRemote/Models/RemoteStore.swift +++ b/NucleicRemote/NucleicRemote/Models/RemoteStore.swift @@ -704,7 +704,7 @@ final class RemoteStore: ObservableObject { // 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 isForeground || !LiveActivityManager.shared.hasLiveActivity { + if self.isForeground || !LiveActivityManager.shared.hasLiveActivity { NotificationRouter.shared.postApproval(req, sessionTitle: title) } } From a38a83b2d94e6b724e0af61e4f8af7173556e6be Mon Sep 17 00:00:00 2001 From: Andrew Blakeslee Moore Date: Mon, 6 Jul 2026 22:08:41 -0700 Subject: [PATCH 089/112] Push Notification Live Activity Redundancy Nucleic-Session: 73687ED9-C11C-438C-832B-979332B73893 Co-authored-by: Nucleic --- NucleicRemote/NucleicRemote/LiveActivityManager.swift | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/NucleicRemote/NucleicRemote/LiveActivityManager.swift b/NucleicRemote/NucleicRemote/LiveActivityManager.swift index 9b368ec..36400cf 100644 --- a/NucleicRemote/NucleicRemote/LiveActivityManager.swift +++ b/NucleicRemote/NucleicRemote/LiveActivityManager.swift @@ -356,10 +356,13 @@ final class LiveActivityManager { /// Project a wire summary into the widget's self-contained row model. private static func line(for s: WireSessionSummary) -> NucleicSessionAttributes.SessionLine { - // Carry the approval id/risk only for a session actually awaiting approval — that's the row - // the glance renders inline Allow/Deny on (`ApproveFromActivityIntent`). Other states leave - // it nil so the row stays a plain deep-link tap. - let approvalID = s.status == .awaitingApproval ? s.firstApprovalID?.rawValue : nil + // 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, title: s.title.isEmpty ? s.projectName : s.title, From 952cfdd59da4d7dd2c952e7b3679919837053fca Mon Sep 17 00:00:00 2001 From: Andrew Blakeslee Moore Date: Tue, 7 Jul 2026 00:46:54 -0700 Subject: [PATCH 090/112] Update EXPERIMENTAL Pill Colors Nucleic-Session: F6358035-01F2-46A1-83D9-B26FE40148BF Co-authored-by: Nucleic --- NucleicRemote/NucleicRemote/Views/BuildBanner.swift | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/NucleicRemote/NucleicRemote/Views/BuildBanner.swift b/NucleicRemote/NucleicRemote/Views/BuildBanner.swift index 8e0372b..0d16f6a 100644 --- a/NucleicRemote/NucleicRemote/Views/BuildBanner.swift +++ b/NucleicRemote/NucleicRemote/Views/BuildBanner.swift @@ -84,10 +84,11 @@ struct BuildInfo { } /// Header strip warning that this is a non-release build — the same channel colors, icons, -/// and copy as the Mac's `BuildBanner`: red for local builds, canary-yellow for canary, blue -/// for beta, gold for a release candidate. The leading edge names the channel; the build -/// label and an all-caps "EXPERIMENTAL" badge sit on the trailing edge. Renders nothing for -/// a shipping App Store release. +/// and copy as the Mac's `BuildBanner`. The strip stays untinted; the channel color rides on +/// the all-caps "EXPERIMENTAL" pill instead: red for local builds, canary-yellow for canary, +/// blue for beta, gold for a release candidate. The leading edge names the channel; the build +/// label and the pill sit on the trailing edge. Renders nothing for a shipping App Store +/// release. struct BuildBanner: View { var info: BuildInfo = .current From 3a5a56752c4fe3de13f2bb13ded60263433071fb Mon Sep 17 00:00:00 2001 From: Andrew Blakeslee Moore Date: Tue, 7 Jul 2026 00:47:00 -0700 Subject: [PATCH 091/112] Update EXPERIMENTAL Pill Colors Nucleic-Session: F6358035-01F2-46A1-83D9-B26FE40148BF Co-authored-by: Nucleic --- NucleicRemote/NucleicRemote/Views/BuildBanner.swift | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/NucleicRemote/NucleicRemote/Views/BuildBanner.swift b/NucleicRemote/NucleicRemote/Views/BuildBanner.swift index 0d16f6a..d570c27 100644 --- a/NucleicRemote/NucleicRemote/Views/BuildBanner.swift +++ b/NucleicRemote/NucleicRemote/Views/BuildBanner.swift @@ -107,15 +107,15 @@ struct BuildBanner: View { Text("EXPERIMENTAL") .font(.caption2.weight(.heavy)) .tracking(0.9) + .foregroundStyle(.white) .padding(.horizontal, 6) .padding(.vertical, 2) - .background(.white.opacity(0.16), in: .capsule) + .background(style.tint, in: .capsule) } - .foregroundStyle(.white.opacity(0.92)) + .foregroundStyle(.primary) .padding(.horizontal, 12) .padding(.vertical, 5) .frame(maxWidth: .infinity) - .background(style.tint.opacity(0.5)) .accessibilityLabel(style.tooltip(version: info.version, build: info.buildLabel)) } } From dd90cc6f8cf1edda7875da0bf15361d2c3ce0828 Mon Sep 17 00:00:00 2001 From: Andrew Blakeslee Moore Date: Tue, 7 Jul 2026 00:54:04 -0700 Subject: [PATCH 092/112] Update EXPERIMENTAL Pill Colors Nucleic-Session: F6358035-01F2-46A1-83D9-B26FE40148BF Co-authored-by: Nucleic --- NucleicRemote/NucleicRemote/Views/BuildBanner.swift | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/NucleicRemote/NucleicRemote/Views/BuildBanner.swift b/NucleicRemote/NucleicRemote/Views/BuildBanner.swift index d570c27..21595a2 100644 --- a/NucleicRemote/NucleicRemote/Views/BuildBanner.swift +++ b/NucleicRemote/NucleicRemote/Views/BuildBanner.swift @@ -83,12 +83,12 @@ struct BuildInfo { } } -/// Header strip warning that this is a non-release build — the same channel colors, icons, -/// and copy as the Mac's `BuildBanner`. The strip stays untinted; the channel color rides on -/// the all-caps "EXPERIMENTAL" pill instead: red for local builds, canary-yellow for canary, -/// blue for beta, gold for a release candidate. The leading edge names the channel; the build -/// label and the pill sit on the trailing edge. Renders nothing for a shipping App Store -/// release. +/// Header warning that this is a non-release build — the same channel colors, icons, and copy +/// as the Mac's `BuildBanner`. A channel-colored stripe — red for local builds, canary-yellow +/// for canary, blue for beta, gold for a release candidate — hugs just the one row it labels +/// (channel name, build label, and an all-caps "EXPERIMENTAL" pill), pinned to the leading +/// edge rather than bleeding across the full window header. Renders nothing for a shipping App +/// Store release. struct BuildBanner: View { var info: BuildInfo = .current From b39fa87d316153d383e17db6129e86671357260d Mon Sep 17 00:00:00 2001 From: Andrew Blakeslee Moore Date: Tue, 7 Jul 2026 00:54:15 -0700 Subject: [PATCH 093/112] Update EXPERIMENTAL Pill Colors Nucleic-Session: F6358035-01F2-46A1-83D9-B26FE40148BF Co-authored-by: Nucleic --- .../NucleicRemote/Views/BuildBanner.swift | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/NucleicRemote/NucleicRemote/Views/BuildBanner.swift b/NucleicRemote/NucleicRemote/Views/BuildBanner.swift index 21595a2..7cab639 100644 --- a/NucleicRemote/NucleicRemote/Views/BuildBanner.swift +++ b/NucleicRemote/NucleicRemote/Views/BuildBanner.swift @@ -100,22 +100,26 @@ struct BuildBanner: View { Text(style.label) .font(.footnote.weight(.semibold)) .lineLimit(1) - Spacer(minLength: 12) Text(info.buildLabel) .font(.caption.monospaced()) .opacity(0.85) Text("EXPERIMENTAL") .font(.caption2.weight(.heavy)) .tracking(0.9) - .foregroundStyle(.white) .padding(.horizontal, 6) .padding(.vertical, 2) - .background(style.tint, in: .capsule) + .background(.white.opacity(0.16), in: .capsule) } - .foregroundStyle(.primary) + .foregroundStyle(.white.opacity(0.92)) .padding(.horizontal, 12) .padding(.vertical, 5) - .frame(maxWidth: .infinity) + // The channel-colored stripe wraps only this row of content — channel name, build + // label, and the EXPERIMENTAL pill — instead of bleeding across the full window + // header, then sits at the leading edge with a little breathing room around it. + .background(style.tint.opacity(0.9), in: .rect(cornerRadius: 8)) + .padding(.horizontal, 12) + .padding(.vertical, 4) + .frame(maxWidth: .infinity, alignment: .leading) .accessibilityLabel(style.tooltip(version: info.version, build: info.buildLabel)) } } From ac39fbcefb9c00f47c25304a6f4de0cea6916a64 Mon Sep 17 00:00:00 2001 From: Andrew Blakeslee Moore Date: Tue, 7 Jul 2026 01:04:19 -0700 Subject: [PATCH 094/112] Update EXPERIMENTAL Pill Colors Nucleic-Session: F6358035-01F2-46A1-83D9-B26FE40148BF Co-authored-by: Nucleic --- NucleicRemote/NucleicRemote/Views/BuildBanner.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/NucleicRemote/NucleicRemote/Views/BuildBanner.swift b/NucleicRemote/NucleicRemote/Views/BuildBanner.swift index 7cab639..f667850 100644 --- a/NucleicRemote/NucleicRemote/Views/BuildBanner.swift +++ b/NucleicRemote/NucleicRemote/Views/BuildBanner.swift @@ -96,7 +96,7 @@ struct BuildBanner: View { if let style = Style(channel: info.channel) { HStack(spacing: 8) { Image(systemName: style.icon) - .font(.system(size: 15, weight: .heavy)) + .font(.system(size: 12, weight: .heavy)) Text(style.label) .font(.footnote.weight(.semibold)) .lineLimit(1) From 371bb0b0ca6892617c98fefd2d57e5d2924a39f1 Mon Sep 17 00:00:00 2001 From: Andrew Blakeslee Moore Date: Tue, 7 Jul 2026 01:04:25 -0700 Subject: [PATCH 095/112] Update EXPERIMENTAL Pill Colors Nucleic-Session: F6358035-01F2-46A1-83D9-B26FE40148BF Co-authored-by: Nucleic --- NucleicRemote/NucleicRemote/Views/BuildBanner.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/NucleicRemote/NucleicRemote/Views/BuildBanner.swift b/NucleicRemote/NucleicRemote/Views/BuildBanner.swift index f667850..2e9bd79 100644 --- a/NucleicRemote/NucleicRemote/Views/BuildBanner.swift +++ b/NucleicRemote/NucleicRemote/Views/BuildBanner.swift @@ -112,7 +112,7 @@ struct BuildBanner: View { } .foregroundStyle(.white.opacity(0.92)) .padding(.horizontal, 12) - .padding(.vertical, 5) + .padding(.vertical, 3) // The channel-colored stripe wraps only this row of content — channel name, build // label, and the EXPERIMENTAL pill — instead of bleeding across the full window // header, then sits at the leading edge with a little breathing room around it. From e8995a74d3ee979f59533508647fb7ff7bdaf315 Mon Sep 17 00:00:00 2001 From: Andrew Blakeslee Moore Date: Tue, 7 Jul 2026 01:10:23 -0700 Subject: [PATCH 096/112] Update EXPERIMENTAL Pill Colors Nucleic-Session: F6358035-01F2-46A1-83D9-B26FE40148BF Co-authored-by: Nucleic --- NucleicRemote/NucleicRemote/Views/BuildBanner.swift | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/NucleicRemote/NucleicRemote/Views/BuildBanner.swift b/NucleicRemote/NucleicRemote/Views/BuildBanner.swift index 2e9bd79..2052bf3 100644 --- a/NucleicRemote/NucleicRemote/Views/BuildBanner.swift +++ b/NucleicRemote/NucleicRemote/Views/BuildBanner.swift @@ -114,12 +114,12 @@ struct BuildBanner: View { .padding(.horizontal, 12) .padding(.vertical, 3) // The channel-colored stripe wraps only this row of content — channel name, build - // label, and the EXPERIMENTAL pill — instead of bleeding across the full window - // header, then sits at the leading edge with a little breathing room around it. + // label, and the EXPERIMENTAL pill — instead of bleeding across the full header, + // then sits horizontally centered with a little breathing room around it. .background(style.tint.opacity(0.9), in: .rect(cornerRadius: 8)) .padding(.horizontal, 12) .padding(.vertical, 4) - .frame(maxWidth: .infinity, alignment: .leading) + .frame(maxWidth: .infinity, alignment: .center) .accessibilityLabel(style.tooltip(version: info.version, build: info.buildLabel)) } } From 741d9d445242b1d1745d6339f7044151a6c119d0 Mon Sep 17 00:00:00 2001 From: Andrew Blakeslee Moore Date: Tue, 7 Jul 2026 01:13:43 -0700 Subject: [PATCH 097/112] Update EXPERIMENTAL Pill Colors Nucleic-Session: F6358035-01F2-46A1-83D9-B26FE40148BF Co-authored-by: Nucleic --- NucleicRemote/NucleicRemote/Views/BuildBanner.swift | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/NucleicRemote/NucleicRemote/Views/BuildBanner.swift b/NucleicRemote/NucleicRemote/Views/BuildBanner.swift index 2052bf3..2e9bd79 100644 --- a/NucleicRemote/NucleicRemote/Views/BuildBanner.swift +++ b/NucleicRemote/NucleicRemote/Views/BuildBanner.swift @@ -114,12 +114,12 @@ struct BuildBanner: View { .padding(.horizontal, 12) .padding(.vertical, 3) // The channel-colored stripe wraps only this row of content — channel name, build - // label, and the EXPERIMENTAL pill — instead of bleeding across the full header, - // then sits horizontally centered with a little breathing room around it. + // label, and the EXPERIMENTAL pill — instead of bleeding across the full window + // header, then sits at the leading edge with a little breathing room around it. .background(style.tint.opacity(0.9), in: .rect(cornerRadius: 8)) .padding(.horizontal, 12) .padding(.vertical, 4) - .frame(maxWidth: .infinity, alignment: .center) + .frame(maxWidth: .infinity, alignment: .leading) .accessibilityLabel(style.tooltip(version: info.version, build: info.buildLabel)) } } From a16c9a8b5487dccb8f5d9322356122e746acb623 Mon Sep 17 00:00:00 2001 From: Andrew Blakeslee Moore Date: Tue, 7 Jul 2026 01:16:20 -0700 Subject: [PATCH 098/112] Update EXPERIMENTAL Pill Colors Nucleic-Session: F6358035-01F2-46A1-83D9-B26FE40148BF Co-authored-by: Nucleic --- NucleicRemote/NucleicRemote/Views/BuildBanner.swift | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/NucleicRemote/NucleicRemote/Views/BuildBanner.swift b/NucleicRemote/NucleicRemote/Views/BuildBanner.swift index 2e9bd79..615bbf8 100644 --- a/NucleicRemote/NucleicRemote/Views/BuildBanner.swift +++ b/NucleicRemote/NucleicRemote/Views/BuildBanner.swift @@ -113,13 +113,10 @@ struct BuildBanner: View { .foregroundStyle(.white.opacity(0.92)) .padding(.horizontal, 12) .padding(.vertical, 3) - // The channel-colored stripe wraps only this row of content — channel name, build - // label, and the EXPERIMENTAL pill — instead of bleeding across the full window - // header, then sits at the leading edge with a little breathing room around it. - .background(style.tint.opacity(0.9), in: .rect(cornerRadius: 8)) - .padding(.horizontal, 12) - .padding(.vertical, 4) .frame(maxWidth: .infinity, alignment: .leading) + // The channel-colored stripe spans the full width, with the content (channel name, + // build label, and the EXPERIMENTAL pill) held at the leading edge. + .background(style.tint.opacity(0.9)) .accessibilityLabel(style.tooltip(version: info.version, build: info.buildLabel)) } } From 794d45961312cde686e4053f18f457c6877db0f0 Mon Sep 17 00:00:00 2001 From: Andrew Blakeslee Moore Date: Tue, 7 Jul 2026 01:16:37 -0700 Subject: [PATCH 099/112] Update EXPERIMENTAL Pill Colors Nucleic-Session: F6358035-01F2-46A1-83D9-B26FE40148BF Co-authored-by: Nucleic --- NucleicRemote/NucleicRemote/Views/BuildBanner.swift | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/NucleicRemote/NucleicRemote/Views/BuildBanner.swift b/NucleicRemote/NucleicRemote/Views/BuildBanner.swift index 615bbf8..bbdee47 100644 --- a/NucleicRemote/NucleicRemote/Views/BuildBanner.swift +++ b/NucleicRemote/NucleicRemote/Views/BuildBanner.swift @@ -85,10 +85,9 @@ struct BuildInfo { /// Header warning that this is a non-release build — the same channel colors, icons, and copy /// as the Mac's `BuildBanner`. A channel-colored stripe — red for local builds, canary-yellow -/// for canary, blue for beta, gold for a release candidate — hugs just the one row it labels -/// (channel name, build label, and an all-caps "EXPERIMENTAL" pill), pinned to the leading -/// edge rather than bleeding across the full window header. Renders nothing for a shipping App -/// Store release. +/// for canary, blue for beta, gold for a release candidate — spans the full width, with its +/// content (channel name, build label, and an all-caps "EXPERIMENTAL" pill) held at the +/// leading edge. Renders nothing for a shipping App Store release. struct BuildBanner: View { var info: BuildInfo = .current From e593be578791273f489d004df76515b08c017ed6 Mon Sep 17 00:00:00 2001 From: Andrew Blakeslee Moore Date: Tue, 7 Jul 2026 01:20:44 -0700 Subject: [PATCH 100/112] Update EXPERIMENTAL Pill Colors Nucleic-Session: F6358035-01F2-46A1-83D9-B26FE40148BF Co-authored-by: Nucleic --- NucleicRemote/NucleicRemote/Views/BuildBanner.swift | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/NucleicRemote/NucleicRemote/Views/BuildBanner.swift b/NucleicRemote/NucleicRemote/Views/BuildBanner.swift index bbdee47..b5252c7 100644 --- a/NucleicRemote/NucleicRemote/Views/BuildBanner.swift +++ b/NucleicRemote/NucleicRemote/Views/BuildBanner.swift @@ -114,8 +114,11 @@ struct BuildBanner: View { .padding(.vertical, 3) .frame(maxWidth: .infinity, alignment: .leading) // The channel-colored stripe spans the full width, with the content (channel name, - // build label, and the EXPERIMENTAL pill) held at the leading edge. + // build label, and the EXPERIMENTAL pill) held at the leading edge. Its height is + // just the row of text plus padding; the trailing vertical padding is transparent + // margin that keeps the stripe from filling the header's full height. .background(style.tint.opacity(0.9)) + .padding(.vertical, 4) .accessibilityLabel(style.tooltip(version: info.version, build: info.buildLabel)) } } From 9bc7d362b757e317031fe30545ee6e5d46773789 Mon Sep 17 00:00:00 2001 From: Andrew Blakeslee Moore Date: Tue, 7 Jul 2026 01:23:58 -0700 Subject: [PATCH 101/112] Update EXPERIMENTAL Pill Colors Nucleic-Session: F6358035-01F2-46A1-83D9-B26FE40148BF Co-authored-by: Nucleic --- NucleicRemote/NucleicRemote/Views/BuildBanner.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/NucleicRemote/NucleicRemote/Views/BuildBanner.swift b/NucleicRemote/NucleicRemote/Views/BuildBanner.swift index b5252c7..59f37b3 100644 --- a/NucleicRemote/NucleicRemote/Views/BuildBanner.swift +++ b/NucleicRemote/NucleicRemote/Views/BuildBanner.swift @@ -99,6 +99,7 @@ struct BuildBanner: View { Text(style.label) .font(.footnote.weight(.semibold)) .lineLimit(1) + Spacer(minLength: 12) Text(info.buildLabel) .font(.caption.monospaced()) .opacity(0.85) From a42b7f32a9fdc8caeeadc0382f84d0a34e5c097d Mon Sep 17 00:00:00 2001 From: Andrew Blakeslee Moore Date: Tue, 7 Jul 2026 01:25:20 -0700 Subject: [PATCH 102/112] Update EXPERIMENTAL Pill Colors Nucleic-Session: F6358035-01F2-46A1-83D9-B26FE40148BF Co-authored-by: Nucleic --- NucleicRemote/NucleicRemote/Views/BuildBanner.swift | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/NucleicRemote/NucleicRemote/Views/BuildBanner.swift b/NucleicRemote/NucleicRemote/Views/BuildBanner.swift index 59f37b3..426cc4f 100644 --- a/NucleicRemote/NucleicRemote/Views/BuildBanner.swift +++ b/NucleicRemote/NucleicRemote/Views/BuildBanner.swift @@ -114,12 +114,10 @@ struct BuildBanner: View { .padding(.horizontal, 12) .padding(.vertical, 3) .frame(maxWidth: .infinity, alignment: .leading) - // The channel-colored stripe spans the full width, with the content (channel name, - // build label, and the EXPERIMENTAL pill) held at the leading edge. Its height is - // just the row of text plus padding; the trailing vertical padding is transparent - // margin that keeps the stripe from filling the header's full height. + // The channel-colored stripe fills the full width and height of the top area, with + // the content (channel name, build label, and the EXPERIMENTAL pill) laid across it + // — the channel name at the leading edge, the build label and pill at the trailing. .background(style.tint.opacity(0.9)) - .padding(.vertical, 4) .accessibilityLabel(style.tooltip(version: info.version, build: info.buildLabel)) } } From 3a427c962bc3336c86c2cd4716f03b94c4bcd3e0 Mon Sep 17 00:00:00 2001 From: Andrew Blakeslee Moore Date: Tue, 7 Jul 2026 01:49:28 -0700 Subject: [PATCH 103/112] Update EXPERIMENTAL Pill Colors Nucleic-Session: F6358035-01F2-46A1-83D9-B26FE40148BF Co-authored-by: Nucleic --- .../NucleicRemote/Views/BuildBanner.swift | 21 ++++++++----------- 1 file changed, 9 insertions(+), 12 deletions(-) diff --git a/NucleicRemote/NucleicRemote/Views/BuildBanner.swift b/NucleicRemote/NucleicRemote/Views/BuildBanner.swift index 426cc4f..8e0372b 100644 --- a/NucleicRemote/NucleicRemote/Views/BuildBanner.swift +++ b/NucleicRemote/NucleicRemote/Views/BuildBanner.swift @@ -83,11 +83,11 @@ struct BuildInfo { } } -/// Header warning that this is a non-release build — the same channel colors, icons, and copy -/// as the Mac's `BuildBanner`. A channel-colored stripe — red for local builds, canary-yellow -/// for canary, blue for beta, gold for a release candidate — spans the full width, with its -/// content (channel name, build label, and an all-caps "EXPERIMENTAL" pill) held at the -/// leading edge. Renders nothing for a shipping App Store release. +/// Header strip warning that this is a non-release build — the same channel colors, icons, +/// and copy as the Mac's `BuildBanner`: red for local builds, canary-yellow for canary, blue +/// for beta, gold for a release candidate. The leading edge names the channel; the build +/// label and an all-caps "EXPERIMENTAL" badge sit on the trailing edge. Renders nothing for +/// a shipping App Store release. struct BuildBanner: View { var info: BuildInfo = .current @@ -95,7 +95,7 @@ struct BuildBanner: View { if let style = Style(channel: info.channel) { HStack(spacing: 8) { Image(systemName: style.icon) - .font(.system(size: 12, weight: .heavy)) + .font(.system(size: 15, weight: .heavy)) Text(style.label) .font(.footnote.weight(.semibold)) .lineLimit(1) @@ -112,12 +112,9 @@ struct BuildBanner: View { } .foregroundStyle(.white.opacity(0.92)) .padding(.horizontal, 12) - .padding(.vertical, 3) - .frame(maxWidth: .infinity, alignment: .leading) - // The channel-colored stripe fills the full width and height of the top area, with - // the content (channel name, build label, and the EXPERIMENTAL pill) laid across it - // — the channel name at the leading edge, the build label and pill at the trailing. - .background(style.tint.opacity(0.9)) + .padding(.vertical, 5) + .frame(maxWidth: .infinity) + .background(style.tint.opacity(0.5)) .accessibilityLabel(style.tooltip(version: info.version, build: info.buildLabel)) } } From 040f2a5e4605acb53e8c34dbe08d7e00ce4b8e97 Mon Sep 17 00:00:00 2001 From: Andrew Blakeslee Moore Date: Tue, 7 Jul 2026 02:59:49 -0700 Subject: [PATCH 104/112] 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 105/112] 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 106/112] 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 107/112] 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 108/112] 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 8b852d15203d9998b33aae44bdcd9535230421b3 Mon Sep 17 00:00:00 2001 From: Andrew Blakeslee Moore Date: Tue, 7 Jul 2026 04:09:22 -0700 Subject: [PATCH 109/112] =?UTF-8?q?nvrsion:=20Add:=20Provisioned=20VM=20Im?= =?UTF-8?q?age=20Preparation,=20refactor=20PushRelayClient.swift,=20SyncHo?= =?UTF-8?q?st.swift,=20LiveActivity.swift,=20apns.ts,=20LiveActivityManage?= =?UTF-8?q?r.swift,=20and=20hold=20=E2=80=9CDone=E2=80=9D=20sessions=20in?= =?UTF-8?q?=20view=20in=20Live=20Activity=20until=20the=20user=20opens=20t?= =?UTF-8?q?he=20app=20and=20views=20the=20sessions.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Nucleic-Promote: 1 Co-authored-by: Nucleic --- .../NucleicRemote/LiveActivityManager.swift | 56 +++++++++++-------- 1 file changed, 32 insertions(+), 24 deletions(-) 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 From 7de020cfa8b8367a1c4150407d218ff16a52449f Mon Sep 17 00:00:00 2001 From: Andrew Blakeslee Moore Date: Tue, 7 Jul 2026 04:41:06 -0700 Subject: [PATCH 110/112] 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 111/112] 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 { From ade62ceff707b0c9a6de529d87d78f27791a0db4 Mon Sep 17 00:00:00 2001 From: Andrew Blakeslee Moore Date: Tue, 7 Jul 2026 05:17:27 -0700 Subject: [PATCH 112/112] nvrsion: Add: Provisioned VM Image Preparation to run on macvm-spike; Fix: Move Model Selector Position. Nucleic-Promote: 1 Co-authored-by: Nucleic --- .../Views/SessionDetailView.swift | 41 ++++++++++++------- 1 file changed, 26 insertions(+), 15 deletions(-) diff --git a/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift b/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift index 73323df..ea3f2e1 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. @@ -198,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 { @@ -206,6 +198,8 @@ struct SessionDetailView: View { Label(diffLabel, systemImage: "plus.forwardslash.minus") } if let summary, store.canControl { + Divider() + modelEffortMenu(summary) Divider() sessionActions(summary) } @@ -214,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 {