From 5fb4daefa0b269fd27503d7f3df2da3588eec64b Mon Sep 17 00:00:00 2001 From: Andrew Blakeslee Moore Date: Mon, 6 Jul 2026 04:32:02 +0000 Subject: [PATCH 01/78] =?UTF-8?q?iOS:=20fix=20tab=20bar=20content=20overla?= =?UTF-8?q?p=20=E2=80=94=20inset=20scroll=20views=20directly?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The bottom inset reserving space for the floating tab bar was applied to each tab's root (safeAreaInset on the NavigationStack), which the TabView does not forward to the pages' scroll content — so lists/scrollviews ran under the bar at the scroll bottom. Publish the inset height via an environment value and apply it (compactTabBarInset) directly to each tab's List/ScrollView/Form. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Views/AdaptiveRootView.swift | 55 ++++++++++++------- .../NucleicRemote/Views/HomeView.swift | 1 + .../NucleicRemote/Views/ProjectsView.swift | 2 + .../NucleicRemote/Views/SessionsView.swift | 1 + .../NucleicRemote/Views/SettingsView.swift | 1 + 5 files changed, 41 insertions(+), 19 deletions(-) diff --git a/NucleicRemote/NucleicRemote/Views/AdaptiveRootView.swift b/NucleicRemote/NucleicRemote/Views/AdaptiveRootView.swift index a2240d4..eeaab5e 100644 --- a/NucleicRemote/NucleicRemote/Views/AdaptiveRootView.swift +++ b/NucleicRemote/NucleicRemote/Views/AdaptiveRootView.swift @@ -61,15 +61,16 @@ struct CompactRootView: View { var body: some View { TabView(selection: $tab) { - // The inset is applied per tab (not to the `TabView`): a bottom `safeAreaInset` on the - // container does NOT propagate into the tab pages' scroll content, so the list would run - // under the bar. On each tab's `NavigationStack` it reaches the scroll content and pushes - // it up above the bar. - HomeView().modifier(TabBarInset(height: contentInset)).tag(0) - SessionsView().modifier(TabBarInset(height: contentInset)).tag(1) - ProjectsView().modifier(TabBarInset(height: contentInset)).tag(2) - SettingsView().modifier(TabBarInset(height: contentInset)).tag(3) + HomeView().toolbar(.hidden, for: .tabBar).tag(0) + SessionsView().toolbar(.hidden, for: .tabBar).tag(1) + ProjectsView().toolbar(.hidden, for: .tabBar).tag(2) + SettingsView().toolbar(.hidden, for: .tabBar).tag(3) } + // Each tab's scroll view reads this and reserves room for the floating bar directly on + // itself (via `.compactTabBarInset()`). Neither a `safeAreaInset` nor `contentMargins` on + // the tab root reaches the pages' scroll content through the `TabView`, so it must be applied + // to the `List`/`ScrollView` itself. Zero (its default) on iPad, which has no bar. + .environment(\.compactTabBarInset, contentInset) // The bar itself lives in an always-mounted overlay and slides on `offset`, so it travels // continuously off the bottom of the screen (a fixed distance past the home-indicator curve) // instead of a transition that removes it mid-descent and makes it pop. @@ -126,18 +127,34 @@ private struct BarHeightKey: PreferenceKey { static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) { value = nextValue() } } -/// Per-tab chrome for the compact shell: suppress the system tab bar (we draw our own) and reserve -/// `height` at the bottom of this tab's content so its scroll view lays out above the floating bar. -/// Applied to each tab rather than the `TabView`, whose container-level inset doesn't reach the tab -/// pages' scroll content. -private struct TabBarInset: ViewModifier { - let height: CGFloat +/// Height the compact shell's floating tab bar wants reserved at the bottom of a tab's scroll +/// content, published down the environment by `CompactRootView`. Zero on iPad (no bar). +private struct CompactTabBarInsetKey: EnvironmentKey { + static let defaultValue: CGFloat = 0 +} + +extension EnvironmentValues { + var compactTabBarInset: CGFloat { + get { self[CompactTabBarInsetKey.self] } + set { self[CompactTabBarInsetKey.self] = newValue } + } +} + +extension View { + /// Reserve room for the compact shell's floating tab bar at the bottom of a scroll view, so its + /// content clears the bar when scrolled to the end. Apply this directly to the `List`/`ScrollView` + /// inside a tab — a bottom `safeAreaInset` on the scroll view itself reliably insets its content, + /// unlike one on the tab root, which the `TabView` doesn't forward. A no-op at regular width + /// (iPad), where the environment inset is 0. + func compactTabBarInset() -> some View { modifier(CompactTabBarInsetReader()) } +} + +private struct CompactTabBarInsetReader: ViewModifier { + @Environment(\.compactTabBarInset) private var inset func body(content: Content) -> some View { - content - .toolbar(.hidden, for: .tabBar) - .safeAreaInset(edge: .bottom, spacing: 0) { - Color.clear.frame(height: height) - } + content.safeAreaInset(edge: .bottom, spacing: 0) { + Color.clear.frame(height: inset) + } } } diff --git a/NucleicRemote/NucleicRemote/Views/HomeView.swift b/NucleicRemote/NucleicRemote/Views/HomeView.swift index 61f1022..2ed6bd6 100644 --- a/NucleicRemote/NucleicRemote/Views/HomeView.swift +++ b/NucleicRemote/NucleicRemote/Views/HomeView.swift @@ -105,6 +105,7 @@ struct HomeView: View { } .navigationTitle("Home") .navigationBarTitleDisplayMode(.inline) + .compactTabBarInset() .scrollDismissesKeyboard(.interactively) .refreshable { store.refreshSessions() } .onChange(of: focusNewChat.wrappedValue) { _, want in diff --git a/NucleicRemote/NucleicRemote/Views/ProjectsView.swift b/NucleicRemote/NucleicRemote/Views/ProjectsView.swift index 1cb4845..23f85e9 100644 --- a/NucleicRemote/NucleicRemote/Views/ProjectsView.swift +++ b/NucleicRemote/NucleicRemote/Views/ProjectsView.swift @@ -35,6 +35,7 @@ struct ProjectsView: View { } } } + .compactTabBarInset() } } .navigationTitle("Projects") @@ -71,6 +72,7 @@ struct ProjectDetailView: View { .navigationTitle(project.name) .navigationBarTitleDisplayMode(.inline) .scrollDismissesKeyboard(.interactively) + .compactTabBarInset() // A floating "+" scoped to this project — the glass composer opens pre-locked to it. Only // at regular width; on compact the shared tab-bar "+" (project picker) covers new chats. .newChatAffordance(project: project, isPresented: $showNewChat, enabled: sizeClass == .regular) diff --git a/NucleicRemote/NucleicRemote/Views/SessionsView.swift b/NucleicRemote/NucleicRemote/Views/SessionsView.swift index 05101a8..5fc3328 100644 --- a/NucleicRemote/NucleicRemote/Views/SessionsView.swift +++ b/NucleicRemote/NucleicRemote/Views/SessionsView.swift @@ -59,6 +59,7 @@ struct SessionsView: View { } .listStyle(.insetGrouped) .refreshable { store.refreshSessions() } + .compactTabBarInset() } } .navigationTitle("Sessions") diff --git a/NucleicRemote/NucleicRemote/Views/SettingsView.swift b/NucleicRemote/NucleicRemote/Views/SettingsView.swift index 1c68dcf..1278510 100644 --- a/NucleicRemote/NucleicRemote/Views/SettingsView.swift +++ b/NucleicRemote/NucleicRemote/Views/SettingsView.swift @@ -158,6 +158,7 @@ struct SettingsView: View { } } .navigationTitle("Settings") + .compactTabBarInset() .sheet(isPresented: $showScanner) { PairingScannerView { payload in showScanner = false From f956d3d675308b74936f7d3f106b6e7d0071289a Mon Sep 17 00:00:00 2001 From: Andrew Blakeslee Moore Date: Sun, 5 Jul 2026 21:44:35 -0700 Subject: [PATCH 02/78] Remove Settings Descriptions Nucleic-Session: 7C7C48C3-887A-4733-B24C-677C9F3EA186 Co-authored-by: Nucleic --- NucleicRemote/NucleicRemote/Views/SettingsView.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/NucleicRemote/NucleicRemote/Views/SettingsView.swift b/NucleicRemote/NucleicRemote/Views/SettingsView.swift index 1278510..6e1020e 100644 --- a/NucleicRemote/NucleicRemote/Views/SettingsView.swift +++ b/NucleicRemote/NucleicRemote/Views/SettingsView.swift @@ -27,7 +27,7 @@ struct SettingsView: View { } header: { Text("Demo") } footer: { - Text("You're exploring Nucleic with sample data and a simulated agent. Leave the demo to pair with your Mac.") + Text("") } } From fdea06e10da1ff93e29ca8031c2732e370ddf399 Mon Sep 17 00:00:00 2001 From: Andrew Blakeslee Moore Date: Sun, 5 Jul 2026 21:44:37 -0700 Subject: [PATCH 03/78] Remove Settings Descriptions Nucleic-Session: 7C7C48C3-887A-4733-B24C-677C9F3EA186 Co-authored-by: Nucleic --- NucleicRemote/NucleicRemote/Views/SettingsView.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/NucleicRemote/NucleicRemote/Views/SettingsView.swift b/NucleicRemote/NucleicRemote/Views/SettingsView.swift index 6e1020e..3190977 100644 --- a/NucleicRemote/NucleicRemote/Views/SettingsView.swift +++ b/NucleicRemote/NucleicRemote/Views/SettingsView.swift @@ -50,7 +50,7 @@ struct SettingsView: View { } header: { Text("Appearance") } footer: { - Text("Color vision remaps status colors to hues that stay distinguishable. Every status also carries a text label, so color is never the only cue.") + Text("") } Section("Connection") { From 841998a041e72f52ec192d88d14462a9fdd026bb Mon Sep 17 00:00:00 2001 From: Andrew Blakeslee Moore Date: Sun, 5 Jul 2026 21:44:39 -0700 Subject: [PATCH 04/78] Remove Settings Descriptions Nucleic-Session: 7C7C48C3-887A-4733-B24C-677C9F3EA186 Co-authored-by: Nucleic --- NucleicRemote/NucleicRemote/Views/SettingsView.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/NucleicRemote/NucleicRemote/Views/SettingsView.swift b/NucleicRemote/NucleicRemote/Views/SettingsView.swift index 3190977..e5ecb96 100644 --- a/NucleicRemote/NucleicRemote/Views/SettingsView.swift +++ b/NucleicRemote/NucleicRemote/Views/SettingsView.swift @@ -111,7 +111,7 @@ struct SettingsView: View { } header: { Text("Tailscale") } footer: { - Text("Needed only when your Mac shares over Tailscale (Mac ▸ Settings ▸ Remote ▸ Connection methods). This iPhone joins your tailnet in the browser on first connect.") + Text("") } Section("This device") { From 2d098a524ce2b8bea4d09b401f67420bfb630cae Mon Sep 17 00:00:00 2001 From: Andrew Blakeslee Moore Date: Sun, 5 Jul 2026 21:44:42 -0700 Subject: [PATCH 05/78] Remove Settings Descriptions Nucleic-Session: 7C7C48C3-887A-4733-B24C-677C9F3EA186 Co-authored-by: Nucleic --- NucleicRemote/NucleicRemote/Views/SettingsView.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/NucleicRemote/NucleicRemote/Views/SettingsView.swift b/NucleicRemote/NucleicRemote/Views/SettingsView.swift index e5ecb96..97c3ccd 100644 --- a/NucleicRemote/NucleicRemote/Views/SettingsView.swift +++ b/NucleicRemote/NucleicRemote/Views/SettingsView.swift @@ -126,7 +126,7 @@ struct SettingsView: View { } header: { Text("Transcript") } footer: { - Text("Raw events are unrecognized backend output, shown for debugging.") + Text("") } Section { @@ -134,7 +134,7 @@ struct SettingsView: View { } header: { Text("Privacy") } footer: { - Text("A once-a-day anonymous ping — a random install ID, app version, iOS version, and language — so we can count active installs. No account, no IP, no content.") + Text("") } Section { From b6ff63d157cccc904cc4ac0efaa072efb39c040f Mon Sep 17 00:00:00 2001 From: Andrew Blakeslee Moore Date: Sun, 5 Jul 2026 21:44:43 -0700 Subject: [PATCH 06/78] Remove Settings Descriptions Nucleic-Session: 7C7C48C3-887A-4733-B24C-677C9F3EA186 Co-authored-by: Nucleic --- NucleicRemote/NucleicRemote/Views/SettingsView.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/NucleicRemote/NucleicRemote/Views/SettingsView.swift b/NucleicRemote/NucleicRemote/Views/SettingsView.swift index 97c3ccd..fdd2f05 100644 --- a/NucleicRemote/NucleicRemote/Views/SettingsView.swift +++ b/NucleicRemote/NucleicRemote/Views/SettingsView.swift @@ -154,7 +154,7 @@ struct SettingsView: View { } } } footer: { - Text("Code and diffs are end-to-end encrypted. The Nucleic Private Relay can't read them.") + Text("") } } .navigationTitle("Settings") From c4baa31f5291cdd658f7c9fb31cc7a441f3fd622 Mon Sep 17 00:00:00 2001 From: Andrew Blakeslee Moore Date: Sun, 5 Jul 2026 21:51:39 -0700 Subject: [PATCH 07/78] Tab Bar Animation Fix Nucleic-Session: 09F787AF-3A70-4978-8762-9827C1CC0774 Co-authored-by: Nucleic --- NucleicRemote/NucleicRemote/Models/RemoteStore.swift | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/NucleicRemote/NucleicRemote/Models/RemoteStore.swift b/NucleicRemote/NucleicRemote/Models/RemoteStore.swift index d5cfadc..eada7ca 100644 --- a/NucleicRemote/NucleicRemote/Models/RemoteStore.swift +++ b/NucleicRemote/NucleicRemote/Models/RemoteStore.swift @@ -66,6 +66,13 @@ final class RemoteStore: ObservableObject { private var openSessionHostID: String? @Published private(set) var openEvents: [AgentEvent] = [] @Published private(set) var openApprovals: [ApprovalRequest] = [] + /// Whether the compact (iPhone) Sessions tab currently has a session detail pushed. Distinct + /// from `openSessionID`, which is the *data* subscription and is only torn down on the detail's + /// `onDisappear` — and SwiftUI fires that at the *end* of the pop transition, so keying the + /// floating tab bar's visibility off it left the bar sliding back up 1–2s after a back-swipe. + /// `SessionsView` sets this straight from its navigation path, which flips the instant the pop + /// begins, so the bar reflows immediately while the transcript state survives the animation. + @Published var compactDetailPresented = false /// The open session's full diff (the Mac Diff tab's patch), fetched on demand when the /// user opens the Diff tab and the host advertises `canFetchDiff`. Nil until it arrives. @Published private(set) var openDiff: WireSessionDiff? From e185b15e93fb92c73e9fc30c80519bc5c20f7473 Mon Sep 17 00:00:00 2001 From: Andrew Blakeslee Moore Date: Sun, 5 Jul 2026 21:51:42 -0700 Subject: [PATCH 08/78] Tab Bar Animation Fix Nucleic-Session: 09F787AF-3A70-4978-8762-9827C1CC0774 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 eada7ca..251d54c 100644 --- a/NucleicRemote/NucleicRemote/Models/RemoteStore.swift +++ b/NucleicRemote/NucleicRemote/Models/RemoteStore.swift @@ -671,6 +671,7 @@ final class RemoteStore: ObservableObject { /// aggregate and spin the embedded Tailscale node down. private func finishUnpairIfEmpty() { openSessionID = nil + compactDetailPresented = false openSessionHostID = nil openEvents = []; openApprovals = []; openDiff = nil; diffLoading = false connectivity = .unpaired From 47ee7bd19baed7526be89174609b53166ef82394 Mon Sep 17 00:00:00 2001 From: Andrew Blakeslee Moore Date: Sun, 5 Jul 2026 21:51:51 -0700 Subject: [PATCH 09/78] Tab Bar Animation Fix Nucleic-Session: 09F787AF-3A70-4978-8762-9827C1CC0774 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 251d54c..d1541a6 100644 --- a/NucleicRemote/NucleicRemote/Models/RemoteStore.swift +++ b/NucleicRemote/NucleicRemote/Models/RemoteStore.swift @@ -702,6 +702,7 @@ final class RemoteStore: ObservableObject { demoTasks.forEach { $0.cancel() } demoTasks.removeAll() openSessionID = nil + compactDetailPresented = false openSessionHostID = nil openEvents = [] openApprovals = [] From 3db38a907e3b4c135b874f9e2eb96eb21eb7ce50 Mon Sep 17 00:00:00 2001 From: Andrew Blakeslee Moore Date: Sun, 5 Jul 2026 21:52:03 -0700 Subject: [PATCH 10/78] Tab Bar Animation Fix Nucleic-Session: 09F787AF-3A70-4978-8762-9827C1CC0774 Co-authored-by: Nucleic --- .../NucleicRemote/Views/AdaptiveRootView.swift | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/NucleicRemote/NucleicRemote/Views/AdaptiveRootView.swift b/NucleicRemote/NucleicRemote/Views/AdaptiveRootView.swift index eeaab5e..fd005d8 100644 --- a/NucleicRemote/NucleicRemote/Views/AdaptiveRootView.swift +++ b/NucleicRemote/NucleicRemote/Views/AdaptiveRootView.swift @@ -46,10 +46,13 @@ struct CompactRootView: View { @State private var barHeight: CGFloat = 72 /// The bar owns the bottom edge on the top-level tabs, but yields it to a pushed session's - /// floating glass chat bar (`openSessionID`, set by `SessionDetailView`'s appear/disappear) and - /// to the new-chat composer while it's up (`showNewChat`) — both slide it out, so the composer - /// doesn't just stack on top of a lingering tab bar. - private var showsTabBar: Bool { store.openSessionID == nil && !showNewChat } + /// floating glass chat bar (`compactDetailPresented`, driven by `SessionsView`'s navigation + /// path) and to the new-chat composer while it's up (`showNewChat`) — both slide it out, so the + /// composer doesn't just stack on top of a lingering tab bar. We key off the path rather than + /// `openSessionID` because the path flips the instant a back-swipe begins, whereas + /// `openSessionID` isn't cleared until the detail's `onDisappear` fires at the *end* of the pop + /// transition — which left the bar sliding back up 1–2s late. + private var showsTabBar: Bool { !store.compactDetailPresented && !showNewChat } /// The "+" is inert without control scope or a project to start in, so it's shown only when a /// chat could actually be kicked off (mirrors `NewChatAffordance.canStart`). From efc1d943608693b1a6a1efcbb8915bf4f1e5a854 Mon Sep 17 00:00:00 2001 From: Andrew Blakeslee Moore Date: Sun, 5 Jul 2026 21:52:12 -0700 Subject: [PATCH 11/78] Tab Bar Animation Fix Nucleic-Session: 09F787AF-3A70-4978-8762-9827C1CC0774 Co-authored-by: Nucleic --- NucleicRemote/NucleicRemote/Views/SessionsView.swift | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/NucleicRemote/NucleicRemote/Views/SessionsView.swift b/NucleicRemote/NucleicRemote/Views/SessionsView.swift index 5fc3328..45c70ab 100644 --- a/NucleicRemote/NucleicRemote/Views/SessionsView.swift +++ b/NucleicRemote/NucleicRemote/Views/SessionsView.swift @@ -73,9 +73,18 @@ struct SessionsView: View { path.append(SessionID(rawValue: raw)) } consumeRoute() + // Sync the shell's tab-bar visibility to the current push depth on (re)appear — + // `onChange` alone won't fire for a path restored across a remount. + store.compactDetailPresented = !path.isEmpty } // A notification tap while this tab is already up. .onChange(of: store.pendingRoute) { consumeRoute() } + // Drive the floating tab bar straight off the navigation path: it empties the instant a + // back-swipe/tap begins, so the bar slides back in immediately instead of waiting for + // `SessionDetailView.onDisappear` (which fires only at the end of the pop transition). + .onChange(of: path) { _, newPath in + store.compactDetailPresented = !newPath.isEmpty + } .toolbar { ToolbarItem(placement: .topBarTrailing) { Button { From 1a2dad28e7d8b224b36c6ca8816479bd661f704c Mon Sep 17 00:00:00 2001 From: Andrew Blakeslee Moore Date: Sun, 5 Jul 2026 21:54:08 -0700 Subject: [PATCH 12/78] Settings Feature Enhancement Nucleic-Session: 89BFAB13-D559-43FC-BE28-0B2639BECF0F Co-authored-by: Nucleic --- NucleicRemote/NucleicRemote/Views/SettingsView.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/NucleicRemote/NucleicRemote/Views/SettingsView.swift b/NucleicRemote/NucleicRemote/Views/SettingsView.swift index fdd2f05..281a3c2 100644 --- a/NucleicRemote/NucleicRemote/Views/SettingsView.swift +++ b/NucleicRemote/NucleicRemote/Views/SettingsView.swift @@ -141,7 +141,7 @@ struct SettingsView: View { Button { showScanner = true } label: { - Label("Join a Mac's group", systemImage: "qrcode.viewfinder") + Label("Join an Orbital mesh", systemImage: "qrcode.viewfinder") } Button { showManualPair = true From 8d940cf3c83bd2c3f4e3d0d7f5dc2a99828eecfd Mon Sep 17 00:00:00 2001 From: Andrew Blakeslee Moore Date: Sun, 5 Jul 2026 22:02:49 -0700 Subject: [PATCH 13/78] Settings Feature Enhancement Nucleic-Session: 89BFAB13-D559-43FC-BE28-0B2639BECF0F 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 8587ad9..108ec67 100644 --- a/NucleicRemote/NucleicRemote/Models/HostConnection.swift +++ b/NucleicRemote/NucleicRemote/Models/HostConnection.swift @@ -92,6 +92,9 @@ final class HostConnection { var meshRosterChanged: () -> Void = {} /// The embedded Tailscale node's status changed (for Settings). var tailnetStatus: (String?, URL?) -> Void = { _, _ in } + /// A join code this host minted at our request ("add a device to this mesh") — the + /// `nucleic://pair?d=…` string to show as a QR / copyable code, or nil if it couldn't. + var pairingCodeReceived: (String?) -> Void = { _ in } } private let callbacks: Callbacks From 383c8e10218aa369f7c9065bf2509e779d7e1898 Mon Sep 17 00:00:00 2001 From: Andrew Blakeslee Moore Date: Sun, 5 Jul 2026 22:02:55 -0700 Subject: [PATCH 14/78] Settings Feature Enhancement Nucleic-Session: 89BFAB13-D559-43FC-BE28-0B2639BECF0F 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 108ec67..cb6433c 100644 --- a/NucleicRemote/NucleicRemote/Models/HostConnection.swift +++ b/NucleicRemote/NucleicRemote/Models/HostConnection.swift @@ -596,6 +596,10 @@ final class HostConnection { pinnedHost?.relayMembershipToken = membership.token pinnedHost?.relayURL = membership.url } + case .pairingCode(let qr): + // This Mac minted a join code we asked for ("add a device to this mesh") — hand it up + // to RemoteStore for the QR/copy sheet. + callbacks.pairingCodeReceived(qr) case .wireError(let error): if error.code == .channelMismatch { connectivity = .failed(error.message) From ed509cfb5b8e0f1a0f4896537d6de926cddddafa Mon Sep 17 00:00:00 2001 From: Andrew Blakeslee Moore Date: Sun, 5 Jul 2026 22:03:35 -0700 Subject: [PATCH 15/78] Settings Feature Enhancement Nucleic-Session: 89BFAB13-D559-43FC-BE28-0B2639BECF0F Co-authored-by: Nucleic --- .../NucleicRemote/Models/RemoteStore.swift | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/NucleicRemote/NucleicRemote/Models/RemoteStore.swift b/NucleicRemote/NucleicRemote/Models/RemoteStore.swift index d1541a6..f8c4016 100644 --- a/NucleicRemote/NucleicRemote/Models/RemoteStore.swift +++ b/NucleicRemote/NucleicRemote/Models/RemoteStore.swift @@ -78,6 +78,20 @@ final class RemoteStore: ObservableObject { @Published private(set) var openDiff: WireSessionDiff? @Published private(set) var diffLoading = false + /// "Add a device to this mesh" (Settings): the phone asks a connected Mac to mint a join code + /// and shows it as a QR / copyable link. The phone can't mint one itself (it runs no listener), + /// so this is always relayed through a live, capable host. + enum AddDeviceState: Equatable { + case idle + case requesting // waiting on the host's `pairingCode` reply + case ready(String) // the `nucleic://pair?d=…` join code to display + case unavailable // no live host could mint one right now + } + @Published private(set) var addDevice: AddDeviceState = .idle + /// The host we asked to mint the current code, so a dismiss can tell the *same* Mac to close + /// its pairing window. + private var pairingMintHostID: String? + /// A transient host-reported error (the mobile echo of the Mac's last-error bubble): /// shown as a red bubble at the bottom of the screen, auto-dismissed after a few seconds. struct LastError: Equatable, Identifiable { From 831c30bcea6a73d04aa236306bdaf273b3c1de3a Mon Sep 17 00:00:00 2001 From: Andrew Blakeslee Moore Date: Sun, 5 Jul 2026 22:03:48 -0700 Subject: [PATCH 16/78] Settings Feature Enhancement Nucleic-Session: 89BFAB13-D559-43FC-BE28-0B2639BECF0F Co-authored-by: Nucleic --- .../NucleicRemote/Models/RemoteStore.swift | 38 +++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/NucleicRemote/NucleicRemote/Models/RemoteStore.swift b/NucleicRemote/NucleicRemote/Models/RemoteStore.swift index f8c4016..d3177af 100644 --- a/NucleicRemote/NucleicRemote/Models/RemoteStore.swift +++ b/NucleicRemote/NucleicRemote/Models/RemoteStore.swift @@ -765,6 +765,44 @@ final class RemoteStore: ObservableObject { send(.fetchDiff(sessionID)) } + // MARK: - Add a device to this mesh (relayed pairing-code mint) + + /// Whether the "add a device" button should appear: a live Mac that advertises + /// `canMintPairingCode` is reachable to mint a join code (or we're in demo). + var canAddDeviceToMesh: Bool { + demoMode || pairingMintConnection != nil + } + + /// The connection we route a mint request to: prefer the context host if it can mint, else any + /// live host that can. A code minted by *any* mesh member joins the whole group (mesh "join"). + private var pairingMintConnection: HostConnection? { + if let ctx = contextConnection, ctx.connectivity.isLive, ctx.capabilities.canMintPairingCode { + return ctx + } + return connections.values.first { $0.connectivity.isLive && $0.capabilities.canMintPairingCode } + } + + /// Ask a connected Mac to open a pairing window and hand back its join code. The reply arrives + /// asynchronously as `AddDeviceState.ready` (or `.unavailable`) via the host connection. + func requestPairingCode() { + if demoMode { addDevice = .ready(Self.demoPairingCode); return } + guard let conn = pairingMintConnection else { addDevice = .unavailable; return } + pairingMintHostID = conn.hostID + addDevice = .requesting + conn.send(.requestPairingCode) + } + + /// The user dismissed the "add a device" sheet — tell the minting Mac to close its pairing + /// window (retire the one-time secret) and reset to idle. + func cancelPairingCode() { + if !demoMode, let id = pairingMintHostID { connections[id]?.send(.cancelPairingCode) } + pairingMintHostID = nil + addDevice = .idle + } + + /// A stand-in join code for the offline demo so the QR/copy sheet renders without a Mac. + private static let demoPairingCode = "nucleic://pair?d=demo" + /// Record that the user looked at this session now (clears its unseen-completion wash). func markOpened(_ sessionID: SessionID) { lastOpenedAt[sessionID] = Date() From 76f33d0134865da4fac7fb3a9135ce8be52c51d6 Mon Sep 17 00:00:00 2001 From: Andrew Blakeslee Moore Date: Sun, 5 Jul 2026 22:03:56 -0700 Subject: [PATCH 17/78] Settings Feature Enhancement Nucleic-Session: 89BFAB13-D559-43FC-BE28-0B2639BECF0F Co-authored-by: Nucleic --- NucleicRemote/NucleicRemote/Models/RemoteStore.swift | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/NucleicRemote/NucleicRemote/Models/RemoteStore.swift b/NucleicRemote/NucleicRemote/Models/RemoteStore.swift index d3177af..b32401c 100644 --- a/NucleicRemote/NucleicRemote/Models/RemoteStore.swift +++ b/NucleicRemote/NucleicRemote/Models/RemoteStore.swift @@ -554,6 +554,13 @@ final class RemoteStore: ObservableObject { self?.tailnetStatus = status self?.tailnetLoginURL = loginURL } + cb.pairingCodeReceived = { [weak self] qr in + guard let self else { return } + // Only apply while a request is outstanding — ignore a late reply after the user + // dismissed the sheet (we already told the host to cancel). + guard case .requesting = self.addDevice else { return } + self.addDevice = qr.map(AddDeviceState.ready) ?? .unavailable + } return cb } From 6a98ca8287ea077b61c323b97f59f88450d17547 Mon Sep 17 00:00:00 2001 From: Andrew Blakeslee Moore Date: Sun, 5 Jul 2026 22:04:05 -0700 Subject: [PATCH 18/78] Settings Feature Enhancement Nucleic-Session: 89BFAB13-D559-43FC-BE28-0B2639BECF0F Co-authored-by: Nucleic --- NucleicRemote/NucleicRemote/Models/RemoteStore.swift | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/NucleicRemote/NucleicRemote/Models/RemoteStore.swift b/NucleicRemote/NucleicRemote/Models/RemoteStore.swift index b32401c..c002541 100644 --- a/NucleicRemote/NucleicRemote/Models/RemoteStore.swift +++ b/NucleicRemote/NucleicRemote/Models/RemoteStore.swift @@ -1004,9 +1004,12 @@ final class RemoteStore: ObservableObject { broadcastLive(msg) // Never originated from here (connection-internal, or handled by dedicated loops). + // `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, - .transferOffer, .transferChunk, .transferCommit, .transferCancel, .fetchTranscript: + .transferOffer, .transferChunk, .transferCommit, .transferCancel, .fetchTranscript, + .requestPairingCode, .cancelPairingCode: break } } From 977e405e70c44514e13e3105638db9c05f3cba0e Mon Sep 17 00:00:00 2001 From: Andrew Blakeslee Moore Date: Sun, 5 Jul 2026 22:04:13 -0700 Subject: [PATCH 19/78] Settings Feature Enhancement Nucleic-Session: 89BFAB13-D559-43FC-BE28-0B2639BECF0F Co-authored-by: Nucleic --- NucleicRemote/NucleicRemote/Models/RemoteStore.swift | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/NucleicRemote/NucleicRemote/Models/RemoteStore.swift b/NucleicRemote/NucleicRemote/Models/RemoteStore.swift index c002541..f9d24bc 100644 --- a/NucleicRemote/NucleicRemote/Models/RemoteStore.swift +++ b/NucleicRemote/NucleicRemote/Models/RemoteStore.swift @@ -1117,7 +1117,10 @@ final class RemoteStore: ObservableObject { .registerLiveActivity, .endLiveActivity, // 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: + .transferOffer, .transferChunk, .transferCommit, .transferCancel, + // "Add a device" mint is handled directly against a live host, not via demoHandle; + // the demo path short-circuits in `requestPairingCode()` with a stand-in code. + .requestPairingCode, .cancelPairingCode: break // passive / already handled by the seeded fixtures (demo has no mesh peers) } } From 186a2374d14a143eed4885b685b69a2e8a741c68 Mon Sep 17 00:00:00 2001 From: Andrew Blakeslee Moore Date: Sun, 5 Jul 2026 22:04:32 -0700 Subject: [PATCH 20/78] Settings Feature Enhancement Nucleic-Session: 89BFAB13-D559-43FC-BE28-0B2639BECF0F Co-authored-by: Nucleic --- NucleicRemote/NucleicRemote/Views/SettingsView.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/NucleicRemote/NucleicRemote/Views/SettingsView.swift b/NucleicRemote/NucleicRemote/Views/SettingsView.swift index 281a3c2..c05298c 100644 --- a/NucleicRemote/NucleicRemote/Views/SettingsView.swift +++ b/NucleicRemote/NucleicRemote/Views/SettingsView.swift @@ -1,4 +1,5 @@ import SwiftUI +import CoreImage.CIFilterBuiltins import NucleicProtocol import NucleicTailnet import UIKit From 45f2e2729e3cdea6fc5997380b6776c4f45cda34 Mon Sep 17 00:00:00 2001 From: Andrew Blakeslee Moore Date: Sun, 5 Jul 2026 22:04:45 -0700 Subject: [PATCH 21/78] Settings Feature Enhancement Nucleic-Session: 89BFAB13-D559-43FC-BE28-0B2639BECF0F Co-authored-by: Nucleic --- NucleicRemote/NucleicRemote/Views/SettingsView.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/NucleicRemote/NucleicRemote/Views/SettingsView.swift b/NucleicRemote/NucleicRemote/Views/SettingsView.swift index c05298c..cea45ea 100644 --- a/NucleicRemote/NucleicRemote/Views/SettingsView.swift +++ b/NucleicRemote/NucleicRemote/Views/SettingsView.swift @@ -8,6 +8,7 @@ struct SettingsView: View { @EnvironmentObject var store: RemoteStore @State private var showScanner = false @State private var showManualPair = false + @State private var showAddDevice = false /// Bumped after removing a paired Mac so the "Paired Macs" list re-reads the registry (mesh P3). @State private var pairedHostsToken = UUID() @AppStorage("nucleic.showRawEvents") private var showRaw = false From e86fe8ce854b1c549f0e637dca9b4a074119ad42 Mon Sep 17 00:00:00 2001 From: Andrew Blakeslee Moore Date: Sun, 5 Jul 2026 22:04:54 -0700 Subject: [PATCH 22/78] Settings Feature Enhancement Nucleic-Session: 89BFAB13-D559-43FC-BE28-0B2639BECF0F Co-authored-by: Nucleic --- NucleicRemote/NucleicRemote/Views/SettingsView.swift | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/NucleicRemote/NucleicRemote/Views/SettingsView.swift b/NucleicRemote/NucleicRemote/Views/SettingsView.swift index cea45ea..2e95b09 100644 --- a/NucleicRemote/NucleicRemote/Views/SettingsView.swift +++ b/NucleicRemote/NucleicRemote/Views/SettingsView.swift @@ -140,6 +140,16 @@ struct SettingsView: View { } Section { + // Grow the mesh: ask a connected Mac to mint a join code this phone shows — + // scan it from a new iPhone/iPad, or copy the link to a new Mac. Only when a + // live Mac can mint one (the phone can't originate a join code on its own). + if store.canAddDeviceToMesh { + Button { + showAddDevice = true + } label: { + Label("Add a device to this mesh", systemImage: "plus.circle") + } + } Button { showScanner = true } label: { From cb8c1f68d124b53fcda868f8336172bda0e12b26 Mon Sep 17 00:00:00 2001 From: Andrew Blakeslee Moore Date: Sun, 5 Jul 2026 22:04:59 -0700 Subject: [PATCH 23/78] Settings Feature Enhancement Nucleic-Session: 89BFAB13-D559-43FC-BE28-0B2639BECF0F Co-authored-by: Nucleic --- NucleicRemote/NucleicRemote/Views/SettingsView.swift | 3 +++ 1 file changed, 3 insertions(+) diff --git a/NucleicRemote/NucleicRemote/Views/SettingsView.swift b/NucleicRemote/NucleicRemote/Views/SettingsView.swift index 2e95b09..bba953b 100644 --- a/NucleicRemote/NucleicRemote/Views/SettingsView.swift +++ b/NucleicRemote/NucleicRemote/Views/SettingsView.swift @@ -183,6 +183,9 @@ struct SettingsView: View { store.pair(with: payload) } } + .sheet(isPresented: $showAddDevice, onDismiss: { store.cancelPairingCode() }) { + AddDeviceView() + } } } From 20afa55a7be11797514fad1c1127bcee9ac25b07 Mon Sep 17 00:00:00 2001 From: Andrew Blakeslee Moore Date: Sun, 5 Jul 2026 22:05:31 -0700 Subject: [PATCH 24/78] Settings Feature Enhancement Nucleic-Session: 89BFAB13-D559-43FC-BE28-0B2639BECF0F Co-authored-by: Nucleic --- .../NucleicRemote/Views/SettingsView.swift | 115 ++++++++++++++++++ 1 file changed, 115 insertions(+) diff --git a/NucleicRemote/NucleicRemote/Views/SettingsView.swift b/NucleicRemote/NucleicRemote/Views/SettingsView.swift index bba953b..c7ffbda 100644 --- a/NucleicRemote/NucleicRemote/Views/SettingsView.swift +++ b/NucleicRemote/NucleicRemote/Views/SettingsView.swift @@ -370,3 +370,118 @@ struct ManualPairingView: View { dismiss() } } + +/// "Add a device to this mesh": the phone can't mint a join code itself (it runs no listener), so +/// it asks a connected Mac to open a pairing window and relays back that Mac's code — shown here as +/// a QR to scan from a new iPhone/iPad, or a copyable link to send to a new Mac. Scanning/pasting it +/// joins the new device to the whole group. Dismissing the sheet closes the Mac's pairing window +/// (`RemoteStore.cancelPairingCode`, wired to the sheet's `onDismiss`). +struct AddDeviceView: View { + @EnvironmentObject var store: RemoteStore + @Environment(\.dismiss) private var dismiss + @State private var copied = false + @State private var copyResetTask: Task? + + var body: some View { + NavigationStack { + Form { + switch store.addDevice { + case .idle, .requesting: + Section { + HStack { + ProgressView() + Text("Asking a Mac in your mesh for a join code…") + .foregroundStyle(.secondary) + } + } + case .unavailable: + Section { + Label("Couldn't get a join code", systemImage: "exclamationmark.triangle.fill") + .foregroundStyle(.orange) + Text("This needs a Mac in your mesh that's connected right now. Make sure " + + "one is reachable with its meshing service on, then try again.") + .font(.callout).foregroundStyle(.secondary) + Button("Try again") { store.requestPairingCode() } + } + case .ready(let code): + codeSection(code) + } + } + .navigationTitle("Add a device") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .confirmationAction) { Button("Done") { dismiss() } } + } + } + // Fire the request as the sheet appears; `onDismiss` (on the presenter) tells the Mac to + // close its pairing window. + .onAppear { if case .ready = store.addDevice {} else { store.requestPairingCode() } } + .onDisappear { copyResetTask?.cancel() } + } + + @ViewBuilder + private func codeSection(_ code: String) -> some View { + Section { + if let image = Self.qrImage(code) { + HStack { + Spacer() + Image(uiImage: image) + .interpolation(.none) + .resizable() + .frame(width: 220, height: 220) + .padding(10) + .background(.white, in: RoundedRectangle(cornerRadius: 12)) + Spacer() + } + .listRowBackground(Color.clear) + } + } header: { + Text("Scan to join") + } footer: { + Text("On a new iPhone or iPad, open Nucleic Remote ▸ Join an Orbital mesh and scan this. " + + "It joins the whole group — every Mac and device here.") + } + + Section { + Text(code) + .font(.system(.caption, design: .monospaced)) + .lineLimit(2) + .truncationMode(.middle) + .textSelection(.enabled) + Button { + copyCode(code) + } label: { + Label(copied ? "Copied" : "Copy join code", + systemImage: copied ? "checkmark" : "doc.on.doc") + } + } header: { + Text("Or send a code") + } footer: { + Text("Paste into “Join mesh…” on another Mac, or “Enter code manually” on an iPhone or " + + "iPad. A Mac joining is confirmed on the Mac that shared the code.") + } + } + + private func copyCode(_ code: String) { + UIPasteboard.general.string = code + withAnimation(.easeOut(duration: 0.12)) { copied = true } + copyResetTask?.cancel() + copyResetTask = Task { + try? await Task.sleep(for: .seconds(1.4)) + guard !Task.isCancelled else { return } + withAnimation(.easeIn(duration: 0.2)) { copied = false } + } + } + + /// Render the join string as a QR code (CoreImage), mirroring the Mac's pairing sheet. + private static func qrImage(_ string: String) -> UIImage? { + let filter = CIFilter.qrCodeGenerator() + filter.message = Data(string.utf8) + filter.correctionLevel = "M" + guard let output = filter.outputImage else { return nil } + let scaled = output.transformed(by: CGAffineTransform(scaleX: 10, y: 10)) + let context = CIContext() + guard let cg = context.createCGImage(scaled, from: scaled.extent) else { return nil } + return UIImage(cgImage: cg) + } +} From 6d6abc5c296ec3efc65f8b8fbc639a348d0538fa Mon Sep 17 00:00:00 2001 From: Andrew Blakeslee Moore Date: Sun, 5 Jul 2026 22:15:45 -0700 Subject: [PATCH 25/78] Settings Feature Enhancement Nucleic-Session: 89BFAB13-D559-43FC-BE28-0B2639BECF0F Co-authored-by: Nucleic --- NucleicRemote/NucleicRemote/Models/HostConnection.swift | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/NucleicRemote/NucleicRemote/Models/HostConnection.swift b/NucleicRemote/NucleicRemote/Models/HostConnection.swift index cb6433c..37b1921 100644 --- a/NucleicRemote/NucleicRemote/Models/HostConnection.swift +++ b/NucleicRemote/NucleicRemote/Models/HostConnection.swift @@ -95,6 +95,11 @@ final class HostConnection { /// A join code this host minted at our request ("add a device to this mesh") — the /// `nucleic://pair?d=…` string to show as a QR / copyable code, or nil if it couldn't. var pairingCodeReceived: (String?) -> Void = { _ in } + /// This host forwarded a Mac-pair confirm — a Mac is joining via a code we shared and + /// wants the user's allow/deny. The phone can approve it (`respondMacPair`). + var macPairRequested: (WireMacPairRequest) -> Void = { _ in } + /// The forwarded Mac-pair (by deviceID) was answered/withdrawn — dismiss the prompt. + var macPairResolved: (String) -> Void = { _ in } } private let callbacks: Callbacks From ab4efa13cd14d65e4efdad47234036d39a162039 Mon Sep 17 00:00:00 2001 From: Andrew Blakeslee Moore Date: Sun, 5 Jul 2026 22:15:53 -0700 Subject: [PATCH 26/78] Settings Feature Enhancement Nucleic-Session: 89BFAB13-D559-43FC-BE28-0B2639BECF0F Co-authored-by: Nucleic --- NucleicRemote/NucleicRemote/Models/HostConnection.swift | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/NucleicRemote/NucleicRemote/Models/HostConnection.swift b/NucleicRemote/NucleicRemote/Models/HostConnection.swift index 37b1921..e020876 100644 --- a/NucleicRemote/NucleicRemote/Models/HostConnection.swift +++ b/NucleicRemote/NucleicRemote/Models/HostConnection.swift @@ -605,6 +605,13 @@ final class HostConnection { // This Mac minted a join code we asked for ("add a device to this mesh") — hand it up // to RemoteStore for the QR/copy sheet. callbacks.pairingCodeReceived(qr) + case .macPairRequested(let req): + // A Mac is joining via a code we shared and needs allow/deny — forward it up so the + // phone can approve the join. + callbacks.macPairRequested(req) + case .macPairResolved(let deviceID, _): + // Answered on this Mac or another device (or timed out) — dismiss the prompt. + callbacks.macPairResolved(deviceID) case .wireError(let error): if error.code == .channelMismatch { connectivity = .failed(error.message) From 901d7d37b6c5a2e8601066612403a00b15126fc6 Mon Sep 17 00:00:00 2001 From: Andrew Blakeslee Moore Date: Sun, 5 Jul 2026 22:16:01 -0700 Subject: [PATCH 27/78] Settings Feature Enhancement Nucleic-Session: 89BFAB13-D559-43FC-BE28-0B2639BECF0F Co-authored-by: Nucleic --- NucleicRemote/NucleicRemote/Models/RemoteStore.swift | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/NucleicRemote/NucleicRemote/Models/RemoteStore.swift b/NucleicRemote/NucleicRemote/Models/RemoteStore.swift index f9d24bc..187fd94 100644 --- a/NucleicRemote/NucleicRemote/Models/RemoteStore.swift +++ b/NucleicRemote/NucleicRemote/Models/RemoteStore.swift @@ -92,6 +92,13 @@ final class RemoteStore: ObservableObject { /// its pairing window. private var pairingMintHostID: String? + /// A Mac trying to join via a code this phone shared, awaiting the user's allow/deny — the Mac + /// forwarded its pairing confirm here (`HostMsg.macPairRequested`) so it can be approved from + /// the phone. The "add a device" sheet renders an allow/deny dialog for it. + @Published private(set) var pendingMacPairRequest: WireMacPairRequest? + /// The host that forwarded the pending confirm, so the answer routes back to it. + private var pendingMacPairHostID: String? + /// A transient host-reported error (the mobile echo of the Mac's last-error bubble): /// shown as a red bubble at the bottom of the screen, auto-dismissed after a few seconds. struct LastError: Equatable, Identifiable { From 772d6be528e438b371e043289c63faf6f64e3e57 Mon Sep 17 00:00:00 2001 From: Andrew Blakeslee Moore Date: Sun, 5 Jul 2026 22:16:12 -0700 Subject: [PATCH 28/78] Settings Feature Enhancement Nucleic-Session: 89BFAB13-D559-43FC-BE28-0B2639BECF0F Co-authored-by: Nucleic --- .../NucleicRemote/Models/RemoteStore.swift | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/NucleicRemote/NucleicRemote/Models/RemoteStore.swift b/NucleicRemote/NucleicRemote/Models/RemoteStore.swift index 187fd94..9435338 100644 --- a/NucleicRemote/NucleicRemote/Models/RemoteStore.swift +++ b/NucleicRemote/NucleicRemote/Models/RemoteStore.swift @@ -807,13 +807,25 @@ final class RemoteStore: ObservableObject { } /// The user dismissed the "add a device" sheet — tell the minting Mac to close its pairing - /// window (retire the one-time secret) and reset to idle. + /// window (retire the one-time secret) and reset to idle. Any Mac-join awaiting approval is + /// abandoned here too; the host fails it closed when its pairing window shuts. func cancelPairingCode() { if !demoMode, let id = pairingMintHostID { connections[id]?.send(.cancelPairingCode) } pairingMintHostID = nil + pendingMacPairRequest = nil + pendingMacPairHostID = nil addDevice = .idle } + /// Approve or deny a Mac joining via a code this phone shared (the forwarded confirm). Routes + /// the answer to the Mac that forwarded it and clears the prompt optimistically. + func respondMacPair(_ approve: Bool) { + guard let req = pendingMacPairRequest else { return } + if let id = pendingMacPairHostID { connections[id]?.send(.respondMacPair(req.deviceID, approve)) } + pendingMacPairRequest = nil + pendingMacPairHostID = nil + } + /// A stand-in join code for the offline demo so the QR/copy sheet renders without a Mac. private static let demoPairingCode = "nucleic://pair?d=demo" From 51d55be0cd064c14d4d052f73f75c19736409634 Mon Sep 17 00:00:00 2001 From: Andrew Blakeslee Moore Date: Sun, 5 Jul 2026 22:16:27 -0700 Subject: [PATCH 29/78] Settings Feature Enhancement Nucleic-Session: 89BFAB13-D559-43FC-BE28-0B2639BECF0F Co-authored-by: Nucleic --- NucleicRemote/NucleicRemote/Models/RemoteStore.swift | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/NucleicRemote/NucleicRemote/Models/RemoteStore.swift b/NucleicRemote/NucleicRemote/Models/RemoteStore.swift index 9435338..9681f29 100644 --- a/NucleicRemote/NucleicRemote/Models/RemoteStore.swift +++ b/NucleicRemote/NucleicRemote/Models/RemoteStore.swift @@ -568,6 +568,16 @@ final class RemoteStore: ObservableObject { guard case .requesting = self.addDevice else { return } self.addDevice = qr.map(AddDeviceState.ready) ?? .unavailable } + cb.macPairRequested = { [weak self] req in + guard let self else { return } + self.pendingMacPairRequest = req + self.pendingMacPairHostID = hostID + } + cb.macPairResolved = { [weak self] deviceID in + guard let self, self.pendingMacPairRequest?.deviceID == deviceID else { return } + self.pendingMacPairRequest = nil + self.pendingMacPairHostID = nil + } return cb } From 230ad1c3828c9f9664da473d1b5e05383d319441 Mon Sep 17 00:00:00 2001 From: Andrew Blakeslee Moore Date: Sun, 5 Jul 2026 22:16:34 -0700 Subject: [PATCH 30/78] Settings Feature Enhancement Nucleic-Session: 89BFAB13-D559-43FC-BE28-0B2639BECF0F 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 9681f29..2093e84 100644 --- a/NucleicRemote/NucleicRemote/Models/RemoteStore.swift +++ b/NucleicRemote/NucleicRemote/Models/RemoteStore.swift @@ -1038,7 +1038,7 @@ final class RemoteStore: ObservableObject { case .hello, .ping, .listPeers, .addressUpdate, .meshRoster, .registerLiveActivity, .endLiveActivity, .transferOffer, .transferChunk, .transferCommit, .transferCancel, .fetchTranscript, - .requestPairingCode, .cancelPairingCode: + .requestPairingCode, .cancelPairingCode, .respondMacPair: break } } From 766c0951c52f2faeba5d977c89da324c9af6293a Mon Sep 17 00:00:00 2001 From: Andrew Blakeslee Moore Date: Sun, 5 Jul 2026 22:16:39 -0700 Subject: [PATCH 31/78] Settings Feature Enhancement Nucleic-Session: 89BFAB13-D559-43FC-BE28-0B2639BECF0F 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 2093e84..8a276b7 100644 --- a/NucleicRemote/NucleicRemote/Models/RemoteStore.swift +++ b/NucleicRemote/NucleicRemote/Models/RemoteStore.swift @@ -1149,7 +1149,7 @@ final class RemoteStore: ObservableObject { .transferOffer, .transferChunk, .transferCommit, .transferCancel, // "Add a device" mint is handled directly against a live host, not via demoHandle; // the demo path short-circuits in `requestPairingCode()` with a stand-in code. - .requestPairingCode, .cancelPairingCode: + .requestPairingCode, .cancelPairingCode, .respondMacPair: break // passive / already handled by the seeded fixtures (demo has no mesh peers) } } From 2383450847c08328f6324fca095942e25019ee24 Mon Sep 17 00:00:00 2001 From: Andrew Blakeslee Moore Date: Sun, 5 Jul 2026 22:16:57 -0700 Subject: [PATCH 32/78] Settings Feature Enhancement Nucleic-Session: 89BFAB13-D559-43FC-BE28-0B2639BECF0F Co-authored-by: Nucleic --- .../NucleicRemote/Views/SettingsView.swift | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/NucleicRemote/NucleicRemote/Views/SettingsView.swift b/NucleicRemote/NucleicRemote/Views/SettingsView.swift index c7ffbda..158a3ac 100644 --- a/NucleicRemote/NucleicRemote/Views/SettingsView.swift +++ b/NucleicRemote/NucleicRemote/Views/SettingsView.swift @@ -412,6 +412,22 @@ struct AddDeviceView: View { .toolbar { ToolbarItem(placement: .confirmationAction) { Button("Done") { dismiss() } } } + // A Mac joining via this code needs the user's OK — the accepting Mac forwarded its + // confirm here so it can be approved from the phone (an iPhone/iPad joins hands-free). + .confirmationDialog( + "Let “\(store.pendingMacPairRequest?.label ?? "another Mac")” join?", + isPresented: Binding( + get: { store.pendingMacPairRequest != nil }, + set: { if !$0 { store.respondMacPair(false) } }), + titleVisibility: .visible + ) { + Button("Allow") { store.respondMacPair(true) } + Button("Don't allow", role: .destructive) { store.respondMacPair(false) } + Button("Cancel", role: .cancel) { store.respondMacPair(false) } + } message: { + Text("It joins the whole group — it gets the same control of your Macs' sessions an " + + "iPhone does, and every Mac here stays connected.") + } } // Fire the request as the sheet appears; `onDismiss` (on the presenter) tells the Mac to // close its pairing window. From 560f365cc34f713251050628c23d446542bfa852 Mon Sep 17 00:00:00 2001 From: Andrew Blakeslee Moore Date: Sun, 5 Jul 2026 22:17:05 -0700 Subject: [PATCH 33/78] Settings Feature Enhancement Nucleic-Session: 89BFAB13-D559-43FC-BE28-0B2639BECF0F Co-authored-by: Nucleic --- NucleicRemote/NucleicRemote/Views/SettingsView.swift | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/NucleicRemote/NucleicRemote/Views/SettingsView.swift b/NucleicRemote/NucleicRemote/Views/SettingsView.swift index 158a3ac..8be0893 100644 --- a/NucleicRemote/NucleicRemote/Views/SettingsView.swift +++ b/NucleicRemote/NucleicRemote/Views/SettingsView.swift @@ -474,7 +474,8 @@ struct AddDeviceView: View { Text("Or send a code") } footer: { Text("Paste into “Join mesh…” on another Mac, or “Enter code manually” on an iPhone or " - + "iPad. A Mac joining is confirmed on the Mac that shared the code.") + + "iPad. A Mac joining asks you to approve it right here; an iPhone or iPad joins " + + "on its own.") } } From e7ca7cdfa79f4ddcdc9bd08e4478cd9e78073a92 Mon Sep 17 00:00:00 2001 From: Andrew Blakeslee Moore Date: Sun, 5 Jul 2026 22:30:22 -0700 Subject: [PATCH 34/78] Session Cache Implementation Nucleic-Session: B2346642-3353-4BD5-9DE2-A8B77BD66C98 Co-authored-by: Nucleic --- .../NucleicRemote/Models/SessionCache.swift | 121 ++++++++++++++++++ 1 file changed, 121 insertions(+) create mode 100644 NucleicRemote/NucleicRemote/Models/SessionCache.swift diff --git a/NucleicRemote/NucleicRemote/Models/SessionCache.swift b/NucleicRemote/NucleicRemote/Models/SessionCache.swift new file mode 100644 index 0000000..7c27e16 --- /dev/null +++ b/NucleicRemote/NucleicRemote/Models/SessionCache.swift @@ -0,0 +1,121 @@ +import Foundation +import NucleicProtocol + +/// An on-device cache of the phone's recent chat history — the session list and the transcripts of +/// the sessions the user has actually opened — so the app can show a read-only history while it's +/// disconnected from every Mac host (UX_IOS offline). It is a *projection cache*, never canonical +/// state: a live host's data always supersedes it, and it's written straight from the same wire +/// types the sync stream delivers (`SessionSummary` / `AgentEvent`), so nothing is invented. +/// +/// Stored as JSON files under Application Support (durable across launches, unlike Caches, which the +/// OS may purge under storage pressure): +/// +/// NucleicSessionCache/summaries.json — the merged session list, recency-capped + pruned +/// NucleicSessionCache/transcripts/.json — one opened session's event tail +/// +/// File I/O is synchronous, but the write/read paths hop onto a utility `Task.detached` so nothing +/// touches disk on the main actor; the wire types are `Sendable`, so the arrays cross the boundary +/// cleanly. Per-session transcript files keep an append-heavy live session from rewriting the whole +/// history on every event, and cap the tail so a long run can't grow unbounded. +enum SessionCache { + /// Keep the most-recently-updated N sessions' summaries; older ones (and their transcript + /// 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 + + // MARK: - Paths + + private static var root: URL? { + guard let base = try? FileManager.default.url( + for: .applicationSupportDirectory, in: .userDomainMask, appropriateFor: nil, create: true) + else { return nil } + return base.appendingPathComponent("NucleicSessionCache", isDirectory: true) + } + private static var summariesURL: URL? { root?.appendingPathComponent("summaries.json") } + private static var transcriptsDir: URL? { + root?.appendingPathComponent("transcripts", isDirectory: true) + } + private static func transcriptURL(_ id: SessionID) -> URL? { + transcriptsDir?.appendingPathComponent(transcriptFileName(id)) + } + private static func transcriptFileName(_ id: SessionID) -> String { + // Session ids are UUID strings today, but sanitize defensively so an id can never escape + // the cache directory or collide with the summaries file. + let allowed = Set("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_") + return String(id.rawValue.map { allowed.contains($0) ? $0 : "_" }) + ".json" + } + + private static let encoder = JSONEncoder() + private static let decoder = JSONDecoder() + + // MARK: - Summaries + + /// Persist the recent session list (recency-capped) and prune transcript files whose session is + /// no longer cached. Off the main actor. + static func saveSummaries(_ summaries: [WireSessionSummary]) async { + await Task.detached(priority: .utility) { writeSummaries(summaries) }.value + } + + /// Load the cached session list — called once at launch to seed the list before any host + /// connects. Small (≤ `sessionLimit`), so a synchronous read is fine. + static func loadSummaries() -> [WireSessionSummary] { + guard let url = summariesURL, let data = try? Data(contentsOf: url), + let list = try? decoder.decode([WireSessionSummary].self, from: data) + else { return [] } + return list + } + + private static func writeSummaries(_ summaries: [WireSessionSummary]) { + guard let root, let url = summariesURL, let dir = transcriptsDir else { return } + try? FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + let kept = Array(summaries.sorted { $0.updatedAt > $1.updatedAt }.prefix(sessionLimit)) + if let data = try? encoder.encode(kept) { try? data.write(to: url, options: .atomic) } + // Drop transcript files for sessions that fell out of the recent set. + let keep = Set(kept.map(transcriptFileName)) + if let files = try? FileManager.default.contentsOfDirectory( + at: dir, includingPropertiesForKeys: nil) { + for file in files where !keep.contains(file.lastPathComponent) { + try? FileManager.default.removeItem(at: file) + } + } + } + + // MARK: - Transcripts + + /// Persist one session's transcript (newest `eventLimit` events by seq). Off the main actor. + static func saveEvents(_ events: [AgentEvent], for id: SessionID) async { + await Task.detached(priority: .utility) { writeEvents(events, id) }.value + } + + /// Load a session's cached transcript — used to seed the open transcript instantly, before the + /// host's snapshot/backfill arrives (and merges on top by seq). Off the main actor. + static func loadEvents(_ id: SessionID) async -> [AgentEvent] { + await Task.detached(priority: .utility) { readEvents(id) }.value + } + + private static func writeEvents(_ events: [AgentEvent], _ id: SessionID) { + guard !events.isEmpty, let dir = transcriptsDir, let url = transcriptURL(id) else { return } + try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + let sorted = events.sorted { $0.seq < $1.seq } + let tail = sorted.count > eventLimit ? Array(sorted.suffix(eventLimit)) : sorted + if let data = try? encoder.encode(tail) { try? data.write(to: url, options: .atomic) } + } + + private static func readEvents(_ id: SessionID) -> [AgentEvent] { + guard let url = transcriptURL(id), let data = try? Data(contentsOf: url), + let events = try? decoder.decode([AgentEvent].self, from: data) + else { return [] } + return events + } + + // MARK: - Clear + + /// Drop the whole cache — on full unpair, when there's no longer any Mac whose history this + /// device should hold. + static func clear() { + guard let root else { return } + try? FileManager.default.removeItem(at: root) + } +} From 292fd8e7a8475efcdd81c18372d3f61162b0f70a Mon Sep 17 00:00:00 2001 From: Andrew Blakeslee Moore Date: Sun, 5 Jul 2026 22:30:54 -0700 Subject: [PATCH 35/78] Session Cache Implementation Nucleic-Session: B2346642-3353-4BD5-9DE2-A8B77BD66C98 Co-authored-by: Nucleic --- NucleicRemote/NucleicRemote/Models/RemoteStore.swift | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/NucleicRemote/NucleicRemote/Models/RemoteStore.swift b/NucleicRemote/NucleicRemote/Models/RemoteStore.swift index 8a276b7..d141863 100644 --- a/NucleicRemote/NucleicRemote/Models/RemoteStore.swift +++ b/NucleicRemote/NucleicRemote/Models/RemoteStore.swift @@ -44,6 +44,13 @@ final class RemoteStore: ObservableObject { /// from `hostName(forSession:)` instead. @Published private(set) var hostName: String = "" @Published private(set) var sessions: [WireSessionSummary] = [] + /// The last session list seen from a live host, persisted to disk (`SessionCache`) and reloaded + /// at launch. It backs `sessions` whenever nothing is connected, so the list shows a read-only + /// history while offline instead of an empty "waiting to connect" screen. + private var cachedSummaries: [WireSessionSummary] = SessionCache.loadSummaries() + /// Debounced disk-write tasks for the offline cache (coalesce a burst of updates into one write). + private var summaryPersistTask: Task? + private var transcriptPersistTask: Task? @Published private(set) var capabilities = WireCapabilities(canModifyToolInput: false, allowAlwaysScopes: []) @Published private(set) var grantedScope: DeviceScope = .approve /// The host's model/effort catalog (SYNC §5.2), driving the composer + session-header pickers. From c91a3e6aa4ecd08b263b3c87c6091d67acf7d7b8 Mon Sep 17 00:00:00 2001 From: Andrew Blakeslee Moore Date: Sun, 5 Jul 2026 22:31:01 -0700 Subject: [PATCH 36/78] Session Cache Implementation Nucleic-Session: B2346642-3353-4BD5-9DE2-A8B77BD66C98 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 d141863..8aab27a 100644 --- a/NucleicRemote/NucleicRemote/Models/RemoteStore.swift +++ b/NucleicRemote/NucleicRemote/Models/RemoteStore.swift @@ -303,7 +303,11 @@ final class RemoteStore: ObservableObject { pathMonitor.onChange = { [weak self] in self?.handlePathChange() } pathMonitor.start() discovery.start() - if isPaired { reconnect() } + if isPaired { + // Show the saved chat history immediately, before any host connects. + if sessions.isEmpty { sessions = cachedSummaries } + reconnect() + } } /// A network-path change: tell each host connection to re-plan its transport now. From e126a78c125768c384c3bdcabe6ec01d4494c917 Mon Sep 17 00:00:00 2001 From: Andrew Blakeslee Moore Date: Sun, 5 Jul 2026 22:31:09 -0700 Subject: [PATCH 37/78] Session Cache Implementation Nucleic-Session: B2346642-3353-4BD5-9DE2-A8B77BD66C98 Co-authored-by: Nucleic --- .../NucleicRemote/Models/RemoteStore.swift | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/NucleicRemote/NucleicRemote/Models/RemoteStore.swift b/NucleicRemote/NucleicRemote/Models/RemoteStore.swift index 8aab27a..422f6d0 100644 --- a/NucleicRemote/NucleicRemote/Models/RemoteStore.swift +++ b/NucleicRemote/NucleicRemote/Models/RemoteStore.swift @@ -598,7 +598,18 @@ final class RemoteStore: ObservableObject { /// or a representative one. No-op in demo, which seeds the aggregate directly. private func rebuildAggregate() { guard !demoMode else { return } - sessions = aggregatedSessions() + let live = aggregatedSessions() + if !live.isEmpty { + 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 = [] + } else { + // Offline: keep showing the saved history rather than blanking the list. + sessions = cachedSummaries + } dashboard = DashboardSnapshot.merged(connections.values.map(\.dashboard)) meshPeers = connections.values.flatMap(\.meshPeers) From 3d86f11c6762b2807bd3890eccfd9c3fdcf246fd Mon Sep 17 00:00:00 2001 From: Andrew Blakeslee Moore Date: Sun, 5 Jul 2026 22:31:23 -0700 Subject: [PATCH 38/78] Session Cache Implementation Nucleic-Session: B2346642-3353-4BD5-9DE2-A8B77BD66C98 Co-authored-by: Nucleic --- .../NucleicRemote/Models/RemoteStore.swift | 42 +++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/NucleicRemote/NucleicRemote/Models/RemoteStore.swift b/NucleicRemote/NucleicRemote/Models/RemoteStore.swift index 422f6d0..2445edf 100644 --- a/NucleicRemote/NucleicRemote/Models/RemoteStore.swift +++ b/NucleicRemote/NucleicRemote/Models/RemoteStore.swift @@ -647,6 +647,48 @@ final class RemoteStore: ObservableObject { return bySeq.values.sorted { $0.seq < $1.seq } } + // MARK: - Offline cache (SessionCache) + + /// Debounced write of the live session list to disk, so a read-only history survives a + /// disconnect / relaunch. + private func persistSummaries(_ list: [WireSessionSummary]) { + summaryPersistTask?.cancel() + summaryPersistTask = Task { [list] in + try? await Task.sleep(nanoseconds: 2_000_000_000) + guard !Task.isCancelled else { return } + await SessionCache.saveSummaries(list) + } + } + + /// Seed the open transcript from disk so a cached session's history shows instantly — even fully + /// offline. A live snapshot/backfill merges on top by seq (dedup), so this never double-counts. + private func loadCachedTranscript(_ sessionID: SessionID) { + Task { [weak self] in + let cached = await SessionCache.loadEvents(sessionID) + guard let self, self.openSessionID == sessionID, !cached.isEmpty else { return } + self.openEvents = Self.mergedEvents(cached, self.openEvents) + } + } + + /// 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 } + let events = openEvents + transcriptPersistTask?.cancel() + transcriptPersistTask = Task { [events, id] in + try? await Task.sleep(nanoseconds: 1_500_000_000) + guard !Task.isCancelled else { return } + await SessionCache.saveEvents(events, for: id) + } + } + + /// Flush the open transcript to disk immediately (on close), cancelling any pending debounce. + private func flushOpenTranscript(_ id: SessionID, _ events: [AgentEvent]) { + transcriptPersistTask?.cancel() + guard !demoMode, !events.isEmpty else { return } + Task { await SessionCache.saveEvents(events, for: id) } + } + /// The flat sessions list: every connected Mac's sessions, deduped by id (ids are globally /// unique). Views handle sorting/grouping. private func aggregatedSessions() -> [WireSessionSummary] { From e2058a2f3c394c6b89b3f8c1e146519ed999343a Mon Sep 17 00:00:00 2001 From: Andrew Blakeslee Moore Date: Sun, 5 Jul 2026 22:31:34 -0700 Subject: [PATCH 39/78] Session Cache Implementation Nucleic-Session: B2346642-3353-4BD5-9DE2-A8B77BD66C98 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 2445edf..0b2fe2b 100644 --- a/NucleicRemote/NucleicRemote/Models/RemoteStore.swift +++ b/NucleicRemote/NucleicRemote/Models/RemoteStore.swift @@ -521,16 +521,19 @@ final class RemoteStore: ObservableObject { // instead of truncating it to the host's 200-event tail. 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() } 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. self.openEvents = Self.mergedEvents(self.openEvents, batch.events) + self.persistOpenTranscript() } cb.openDiff = { [weak self] diff in guard let self, hostID == self.openSessionHostID, diff.sessionID == self.openSessionID else { return } From aa5eaaee9ccac90d10260cd97b02d290532258bc Mon Sep 17 00:00:00 2001 From: Andrew Blakeslee Moore Date: Sun, 5 Jul 2026 22:31:45 -0700 Subject: [PATCH 40/78] Session Cache Implementation Nucleic-Session: B2346642-3353-4BD5-9DE2-A8B77BD66C98 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 0b2fe2b..3578223 100644 --- a/NucleicRemote/NucleicRemote/Models/RemoteStore.swift +++ b/NucleicRemote/NucleicRemote/Models/RemoteStore.swift @@ -837,6 +837,9 @@ final class RemoteStore: ObservableObject { diffLoading = false markOpened(sessionID) if demoMode { seedDemoTranscript(sessionID); return } + // Seed from the on-device cache so the transcript shows instantly — including fully offline, + // where the subscribe below is a no-op. Live events merge on top by seq (dedup). + loadCachedTranscript(sessionID) // Tell the owning connection so it forwards the snapshot/events (and dedupes them), re-derive // the context host (capabilities/scope/catalog/connectivity now follow it), then subscribe. connection(owningSession: sessionID)?.openSessionID = sessionID From f931f54b0ed5bce21d2acbf97d3fec1e25dcb1b5 Mon Sep 17 00:00:00 2001 From: Andrew Blakeslee Moore Date: Sun, 5 Jul 2026 22:31:51 -0700 Subject: [PATCH 41/78] Session Cache Implementation Nucleic-Session: B2346642-3353-4BD5-9DE2-A8B77BD66C98 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 3578223..a3618bf 100644 --- a/NucleicRemote/NucleicRemote/Models/RemoteStore.swift +++ b/NucleicRemote/NucleicRemote/Models/RemoteStore.swift @@ -983,6 +983,8 @@ final class RemoteStore: ObservableObject { connection(owningSession: target)?.openSessionID = nil openSessionID = nil openSessionHostID = nil + // Persist the final transcript before clearing it, so it's warm for the next open / offline. + flushOpenTranscript(target, openEvents) openEvents = [] openApprovals = [] openDiff = nil From 1a870c0436460f5ed6eebb6e4be9967a8fe282c4 Mon Sep 17 00:00:00 2001 From: Andrew Blakeslee Moore Date: Sun, 5 Jul 2026 22:32:01 -0700 Subject: [PATCH 42/78] Session Cache Implementation Nucleic-Session: B2346642-3353-4BD5-9DE2-A8B77BD66C98 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 a3618bf..62a16c2 100644 --- a/NucleicRemote/NucleicRemote/Models/RemoteStore.swift +++ b/NucleicRemote/NucleicRemote/Models/RemoteStore.swift @@ -780,6 +780,9 @@ final class RemoteStore: ObservableObject { openSessionHostID = nil openEvents = []; openApprovals = []; openDiff = nil; diffLoading = false connectivity = .unpaired + // No Mac left whose history to hold — drop the offline cache too. + cachedSummaries = [] + SessionCache.clear() sessions = [] dashboard = .empty // Nothing left to dial — spin the embedded Tailscale node down if it was running. From b9eff62f81f9e91a8d2745cd1c0218f40568a070 Mon Sep 17 00:00:00 2001 From: Andrew Blakeslee Moore Date: Sun, 5 Jul 2026 22:32:28 -0700 Subject: [PATCH 43/78] Session Cache Implementation Nucleic-Session: B2346642-3353-4BD5-9DE2-A8B77BD66C98 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 789927b..6d19d71 100644 --- a/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift +++ b/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift @@ -222,8 +222,40 @@ struct SessionDetailView: View { /// The floating action layer: one Liquid Glass bar carrying the queued pills, the working /// indicator, the session controls, and the composer — or the approval card when a gate is /// pending. Content scrolls beneath it; nothing renders when there's nothing to act on. - @ViewBuilder private var actionArea: some View { + VStack(spacing: 8) { + // 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 } + actionContent + } + .padding(.horizontal, 12) + .padding(.bottom, 8) + } + + /// The offline notice over the transcript: the chat's history is cached and readable, but the + /// composer + controls below stay disabled until the Mac that owns it is reachable again. + private var disconnectedBanner: some View { + HStack(spacing: 10) { + Image(systemName: "wifi.slash").foregroundStyle(.secondary) + VStack(alignment: .leading, spacing: 1) { + Text("Disconnected").font(.footnote.weight(.semibold)) + Text("Showing saved history · reconnect to send") + .font(.caption2).foregroundStyle(.secondary) + } + Spacer(minLength: 8) + Button("Reconnect") { store.reconnect() } + .font(.caption.weight(.semibold)) + .buttonStyle(.borderless) + .disabled(!store.isPaired) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 14).padding(.vertical, 10) + .glassSurface(cornerRadius: 20) + } + + @ViewBuilder + private var actionContent: some View { Group { if let approval = store.openApprovals.first { // AskUserQuestion isn't a permission gate — it collects the user's answers and From 0ce94031c74bff5113bf76a0c56577c960af3c0d Mon Sep 17 00:00:00 2001 From: Andrew Blakeslee Moore Date: Sun, 5 Jul 2026 22:32:38 -0700 Subject: [PATCH 44/78] Session Cache Implementation Nucleic-Session: B2346642-3353-4BD5-9DE2-A8B77BD66C98 Co-authored-by: Nucleic --- NucleicRemote/NucleicRemote/Views/SessionDetailView.swift | 2 -- 1 file changed, 2 deletions(-) diff --git a/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift b/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift index 6d19d71..82258ae 100644 --- a/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift +++ b/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift @@ -325,8 +325,6 @@ struct SessionDetailView: View { } } } - .padding(.horizontal, 12) - .padding(.bottom, 8) } /// A queued follow-up shown above the composer: its text (or attachment names) with the From 812aa6bfe3bc1f4de7399216395f08d3eb589677 Mon Sep 17 00:00:00 2001 From: Andrew Blakeslee Moore Date: Sun, 5 Jul 2026 22:32:51 -0700 Subject: [PATCH 45/78] Session Cache Implementation Nucleic-Session: B2346642-3353-4BD5-9DE2-A8B77BD66C98 Co-authored-by: Nucleic --- .../NucleicRemote/Views/SessionsView.swift | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/NucleicRemote/NucleicRemote/Views/SessionsView.swift b/NucleicRemote/NucleicRemote/Views/SessionsView.swift index 45c70ab..172578c 100644 --- a/NucleicRemote/NucleicRemote/Views/SessionsView.swift +++ b/NucleicRemote/NucleicRemote/Views/SessionsView.swift @@ -28,6 +28,23 @@ struct SessionsView: View { : "Waiting to connect to your Mac…")) } else { List { + // Offline: the rows below are the saved history, not a live view — say so. + if !store.connectivity.isLive { + Section { + HStack(spacing: 8) { + Image(systemName: "wifi.slash").foregroundStyle(.secondary) + Text("Offline — showing saved chats") + .font(.footnote).foregroundStyle(.secondary) + Spacer(minLength: 8) + if store.isPaired { + Button("Reconnect") { store.reconnect() } + .font(.caption.weight(.semibold)) + .buttonStyle(.borderless) + } + } + } + .listRowBackground(Color.clear) + } ForEach(grouped, id: \.title) { group in Section(group.title.uppercased()) { ForEach(group.rows, id: \.sessionID) { summary in From d0519e9ac3587c6af60f2c664ced3d1b40c3873c Mon Sep 17 00:00:00 2001 From: Andrew Blakeslee Moore Date: Sun, 5 Jul 2026 22:33:12 -0700 Subject: [PATCH 46/78] Session Cache Implementation Nucleic-Session: B2346642-3353-4BD5-9DE2-A8B77BD66C98 Co-authored-by: Nucleic --- NucleicRemote/NucleicRemote/Models/SessionCache.swift | 3 --- 1 file changed, 3 deletions(-) diff --git a/NucleicRemote/NucleicRemote/Models/SessionCache.swift b/NucleicRemote/NucleicRemote/Models/SessionCache.swift index 7c27e16..6ff8e55 100644 --- a/NucleicRemote/NucleicRemote/Models/SessionCache.swift +++ b/NucleicRemote/NucleicRemote/Models/SessionCache.swift @@ -47,9 +47,6 @@ enum SessionCache { return String(id.rawValue.map { allowed.contains($0) ? $0 : "_" }) + ".json" } - private static let encoder = JSONEncoder() - private static let decoder = JSONDecoder() - // MARK: - Summaries /// Persist the recent session list (recency-capped) and prune transcript files whose session is From 4ffa069191223102235063572b39611c830703fc Mon Sep 17 00:00:00 2001 From: Andrew Blakeslee Moore Date: Sun, 5 Jul 2026 22:33:24 -0700 Subject: [PATCH 47/78] Session Cache Implementation Nucleic-Session: B2346642-3353-4BD5-9DE2-A8B77BD66C98 Co-authored-by: Nucleic --- NucleicRemote/NucleicRemote/Models/SessionCache.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/NucleicRemote/NucleicRemote/Models/SessionCache.swift b/NucleicRemote/NucleicRemote/Models/SessionCache.swift index 6ff8e55..21b0b09 100644 --- a/NucleicRemote/NucleicRemote/Models/SessionCache.swift +++ b/NucleicRemote/NucleicRemote/Models/SessionCache.swift @@ -59,7 +59,7 @@ enum SessionCache { /// connects. Small (≤ `sessionLimit`), so a synchronous read is fine. static func loadSummaries() -> [WireSessionSummary] { guard let url = summariesURL, let data = try? Data(contentsOf: url), - let list = try? decoder.decode([WireSessionSummary].self, from: data) + let list = try? JSONDecoder().decode([WireSessionSummary].self, from: data) else { return [] } return list } From 711144fde890b7919a1af5d39d5f114cc8938c15 Mon Sep 17 00:00:00 2001 From: Andrew Blakeslee Moore Date: Sun, 5 Jul 2026 22:33:31 -0700 Subject: [PATCH 48/78] Session Cache Implementation Nucleic-Session: B2346642-3353-4BD5-9DE2-A8B77BD66C98 Co-authored-by: Nucleic --- NucleicRemote/NucleicRemote/Models/SessionCache.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/NucleicRemote/NucleicRemote/Models/SessionCache.swift b/NucleicRemote/NucleicRemote/Models/SessionCache.swift index 21b0b09..2b7b566 100644 --- a/NucleicRemote/NucleicRemote/Models/SessionCache.swift +++ b/NucleicRemote/NucleicRemote/Models/SessionCache.swift @@ -68,7 +68,7 @@ enum SessionCache { guard let root, let url = summariesURL, let dir = transcriptsDir else { return } try? FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) let kept = Array(summaries.sorted { $0.updatedAt > $1.updatedAt }.prefix(sessionLimit)) - if let data = try? encoder.encode(kept) { try? data.write(to: url, options: .atomic) } + if let data = try? JSONEncoder().encode(kept) { try? data.write(to: url, options: .atomic) } // Drop transcript files for sessions that fell out of the recent set. let keep = Set(kept.map(transcriptFileName)) if let files = try? FileManager.default.contentsOfDirectory( From f5d2a13a798cd1d36dae11aedbf6c5108e1c75a9 Mon Sep 17 00:00:00 2001 From: Andrew Blakeslee Moore Date: Sun, 5 Jul 2026 22:33:37 -0700 Subject: [PATCH 49/78] Session Cache Implementation Nucleic-Session: B2346642-3353-4BD5-9DE2-A8B77BD66C98 Co-authored-by: Nucleic --- NucleicRemote/NucleicRemote/Models/SessionCache.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/NucleicRemote/NucleicRemote/Models/SessionCache.swift b/NucleicRemote/NucleicRemote/Models/SessionCache.swift index 2b7b566..118d543 100644 --- a/NucleicRemote/NucleicRemote/Models/SessionCache.swift +++ b/NucleicRemote/NucleicRemote/Models/SessionCache.swift @@ -97,12 +97,12 @@ enum SessionCache { try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) let sorted = events.sorted { $0.seq < $1.seq } let tail = sorted.count > eventLimit ? Array(sorted.suffix(eventLimit)) : sorted - if let data = try? encoder.encode(tail) { try? data.write(to: url, options: .atomic) } + if let data = try? JSONEncoder().encode(tail) { try? data.write(to: url, options: .atomic) } } private static func readEvents(_ id: SessionID) -> [AgentEvent] { guard let url = transcriptURL(id), let data = try? Data(contentsOf: url), - let events = try? decoder.decode([AgentEvent].self, from: data) + let events = try? JSONDecoder().decode([AgentEvent].self, from: data) else { return [] } return events } From cb11fa21b4cc9ff61263f2f52dea56623ea36a32 Mon Sep 17 00:00:00 2001 From: Andrew Blakeslee Moore Date: Sun, 5 Jul 2026 22:36:29 -0700 Subject: [PATCH 50/78] Session Cache Implementation Nucleic-Session: B2346642-3353-4BD5-9DE2-A8B77BD66C98 Co-authored-by: Nucleic --- NucleicRemote/NucleicRemote/Models/SessionCache.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/NucleicRemote/NucleicRemote/Models/SessionCache.swift b/NucleicRemote/NucleicRemote/Models/SessionCache.swift index 118d543..fa8b0f2 100644 --- a/NucleicRemote/NucleicRemote/Models/SessionCache.swift +++ b/NucleicRemote/NucleicRemote/Models/SessionCache.swift @@ -70,7 +70,7 @@ enum SessionCache { let kept = Array(summaries.sorted { $0.updatedAt > $1.updatedAt }.prefix(sessionLimit)) if let data = try? JSONEncoder().encode(kept) { try? data.write(to: url, options: .atomic) } // Drop transcript files for sessions that fell out of the recent set. - let keep = Set(kept.map(transcriptFileName)) + let keep = Set(kept.map { transcriptFileName($0.sessionID) }) if let files = try? FileManager.default.contentsOfDirectory( at: dir, includingPropertiesForKeys: nil) { for file in files where !keep.contains(file.lastPathComponent) { From 5632e04f65598158e29292567b322a2ab9e093f7 Mon Sep 17 00:00:00 2001 From: Andrew Blakeslee Moore Date: Sun, 5 Jul 2026 22:37:46 -0700 Subject: [PATCH 51/78] Xcode Build Error Nucleic-Session: 97BCAB7F-C595-48E9-A4F5-73C0603BE1F5 Co-authored-by: Nucleic --- VERSION | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/VERSION b/VERSION index b979118..9e37c72 100644 --- a/VERSION +++ b/VERSION @@ -10,4 +10,4 @@ # NUCLEIC_MARKETING_VERSION semantic version -> CFBundleShortVersionString # NUCLEIC_BUILD_NUMBER monotonic build -> CFBundleVersion (always +1 per release) NUCLEIC_MARKETING_VERSION=0.2.0 -NUCLEIC_BUILD_NUMBER=5 +NUCLEIC_BUILD_NUMBER=13 From cee2596fcea5d00ed07a5dab5da76bbd079275ce Mon Sep 17 00:00:00 2001 From: Andrew Blakeslee Moore Date: Sun, 5 Jul 2026 22:56:36 -0700 Subject: [PATCH 52/78] Remove Home Header Nucleic-Session: 21C33A6B-CE4E-4155-84C1-CB881C03DD3A Co-authored-by: Nucleic --- NucleicRemote/NucleicRemote/Views/HomeView.swift | 1 - 1 file changed, 1 deletion(-) diff --git a/NucleicRemote/NucleicRemote/Views/HomeView.swift b/NucleicRemote/NucleicRemote/Views/HomeView.swift index 2ed6bd6..f43f954 100644 --- a/NucleicRemote/NucleicRemote/Views/HomeView.swift +++ b/NucleicRemote/NucleicRemote/Views/HomeView.swift @@ -103,7 +103,6 @@ struct HomeView: View { // than a stretched phone; a no-op at phone width. .readableColumn() } - .navigationTitle("Home") .navigationBarTitleDisplayMode(.inline) .compactTabBarInset() .scrollDismissesKeyboard(.interactively) From e67c0e0fdcc7edb0b7a0bc221fbf79641898e2f1 Mon Sep 17 00:00:00 2001 From: Andrew Blakeslee Moore Date: Sun, 5 Jul 2026 23:10:56 -0700 Subject: [PATCH 53/78] Remove Home Header Nucleic-Session: 21C33A6B-CE4E-4155-84C1-CB881C03DD3A Co-authored-by: Nucleic --- NucleicRemote/NucleicRemote/Views/HomeView.swift | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/NucleicRemote/NucleicRemote/Views/HomeView.swift b/NucleicRemote/NucleicRemote/Views/HomeView.swift index f43f954..1c79eee 100644 --- a/NucleicRemote/NucleicRemote/Views/HomeView.swift +++ b/NucleicRemote/NucleicRemote/Views/HomeView.swift @@ -99,6 +99,11 @@ struct HomeView: View { QuickTodos() } .padding() + // The greeting stands in for the page's large title (same 34pt bold font), so + // reserve the large-title band above it. Without the system `.navigationTitle`, + // the bar collapses and the greeting would otherwise ride ~52pt higher than the + // large titles on the other tabs (Sessions/Projects/Settings). + .padding(.top, 52) // Cap + center the dashboard on a wide iPad so it reads like the Mac home rather // than a stretched phone; a no-op at phone width. .readableColumn() From 9ef3c999c1330204962a90fa2f584978b4dd0294 Mon Sep 17 00:00:00 2001 From: Andrew Blakeslee Moore Date: Sun, 5 Jul 2026 23:15:28 -0700 Subject: [PATCH 54/78] Remove Home Header Nucleic-Session: 21C33A6B-CE4E-4155-84C1-CB881C03DD3A Co-authored-by: Nucleic --- NucleicRemote/NucleicRemote/Views/SessionsView.swift | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/NucleicRemote/NucleicRemote/Views/SessionsView.swift b/NucleicRemote/NucleicRemote/Views/SessionsView.swift index 172578c..c5e679d 100644 --- a/NucleicRemote/NucleicRemote/Views/SessionsView.swift +++ b/NucleicRemote/NucleicRemote/Views/SessionsView.swift @@ -79,7 +79,12 @@ struct SessionsView: View { .compactTabBarInset() } } - .navigationTitle("Sessions") + // The title is rendered as content (`sessionsHeader`) rather than a system + // `.navigationTitle`, so the archive toggle can sit on the same row, centered with it + // (a system toolbar item can only ride in the compact bar, floating above a large + // title). The empty inline bar keeps the top band matching the Home dashboard. + .navigationBarTitleDisplayMode(.inline) + .safeAreaInset(edge: .top, spacing: 0) { sessionsHeader } .navigationDestination(for: SessionID.self) { id in SessionDetailView(sessionID: id) } From 6eed2f2868dc6276e3f4a23aefe818b258b5ca63 Mon Sep 17 00:00:00 2001 From: Andrew Blakeslee Moore Date: Sun, 5 Jul 2026 23:15:39 -0700 Subject: [PATCH 55/78] Remove Home Header Nucleic-Session: 21C33A6B-CE4E-4155-84C1-CB881C03DD3A Co-authored-by: Nucleic --- .../NucleicRemote/Views/SessionsView.swift | 33 ++++++++++++++----- 1 file changed, 24 insertions(+), 9 deletions(-) diff --git a/NucleicRemote/NucleicRemote/Views/SessionsView.swift b/NucleicRemote/NucleicRemote/Views/SessionsView.swift index c5e679d..71d0d9e 100644 --- a/NucleicRemote/NucleicRemote/Views/SessionsView.swift +++ b/NucleicRemote/NucleicRemote/Views/SessionsView.swift @@ -107,18 +107,33 @@ struct SessionsView: View { .onChange(of: path) { _, newPath in store.compactDetailPresented = !newPath.isEmpty } - .toolbar { - ToolbarItem(placement: .topBarTrailing) { - Button { - showArchived.toggle() - } label: { - Image(systemName: showArchived ? "archivebox.fill" : "archivebox") - } - } - } } } + /// The page's large title as content, so the archive toggle can share its row (vertically + /// centered with the title) instead of floating in the compact bar above it. Mirrors the Home + /// dashboard's greeting header — same large-title font and top band — for a consistent look. + private var sessionsHeader: some View { + HStack { + Text("Sessions").font(.largeTitle.bold()) + Spacer() + Button { + showArchived.toggle() + } label: { + Image(systemName: showArchived ? "archivebox.fill" : "archivebox") + .font(.title3) + } + .foregroundStyle(Palette.accent) + .accessibilityLabel(showArchived ? "Hide archived sessions" : "Show archived sessions") + } + .padding(.horizontal) + .padding(.top, 52) + .padding(.bottom, 8) + .frame(maxWidth: .infinity) + // Cover the list rows that scroll up behind the pinned header. + .background(Color(.systemGroupedBackground)) + } + /// Push the session a notification tap asked for, then clear the request. private func consumeRoute() { guard let route = store.pendingRoute else { return } From c88a4ff7c39f2f19524aa2df017fc50bba542b0e Mon Sep 17 00:00:00 2001 From: Andrew Blakeslee Moore Date: Sun, 5 Jul 2026 23:20:36 -0700 Subject: [PATCH 56/78] Remove Home Header Nucleic-Session: 21C33A6B-CE4E-4155-84C1-CB881C03DD3A Co-authored-by: Nucleic --- NucleicRemote/NucleicRemote/Views/SessionsView.swift | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/NucleicRemote/NucleicRemote/Views/SessionsView.swift b/NucleicRemote/NucleicRemote/Views/SessionsView.swift index 71d0d9e..fe80031 100644 --- a/NucleicRemote/NucleicRemote/Views/SessionsView.swift +++ b/NucleicRemote/NucleicRemote/Views/SessionsView.swift @@ -79,12 +79,7 @@ struct SessionsView: View { .compactTabBarInset() } } - // The title is rendered as content (`sessionsHeader`) rather than a system - // `.navigationTitle`, so the archive toggle can sit on the same row, centered with it - // (a system toolbar item can only ride in the compact bar, floating above a large - // title). The empty inline bar keeps the top band matching the Home dashboard. - .navigationBarTitleDisplayMode(.inline) - .safeAreaInset(edge: .top, spacing: 0) { sessionsHeader } + .navigationTitle("Sessions") .navigationDestination(for: SessionID.self) { id in SessionDetailView(sessionID: id) } From cdfc697bdf3dca5f381aacc793fba220097c1620 Mon Sep 17 00:00:00 2001 From: Andrew Blakeslee Moore Date: Sun, 5 Jul 2026 23:20:44 -0700 Subject: [PATCH 57/78] Remove Home Header Nucleic-Session: 21C33A6B-CE4E-4155-84C1-CB881C03DD3A Co-authored-by: Nucleic --- .../NucleicRemote/Views/SessionsView.swift | 24 ------------------- 1 file changed, 24 deletions(-) diff --git a/NucleicRemote/NucleicRemote/Views/SessionsView.swift b/NucleicRemote/NucleicRemote/Views/SessionsView.swift index fe80031..4389191 100644 --- a/NucleicRemote/NucleicRemote/Views/SessionsView.swift +++ b/NucleicRemote/NucleicRemote/Views/SessionsView.swift @@ -105,30 +105,6 @@ struct SessionsView: View { } } - /// The page's large title as content, so the archive toggle can share its row (vertically - /// centered with the title) instead of floating in the compact bar above it. Mirrors the Home - /// dashboard's greeting header — same large-title font and top band — for a consistent look. - private var sessionsHeader: some View { - HStack { - Text("Sessions").font(.largeTitle.bold()) - Spacer() - Button { - showArchived.toggle() - } label: { - Image(systemName: showArchived ? "archivebox.fill" : "archivebox") - .font(.title3) - } - .foregroundStyle(Palette.accent) - .accessibilityLabel(showArchived ? "Hide archived sessions" : "Show archived sessions") - } - .padding(.horizontal) - .padding(.top, 52) - .padding(.bottom, 8) - .frame(maxWidth: .infinity) - // Cover the list rows that scroll up behind the pinned header. - .background(Color(.systemGroupedBackground)) - } - /// Push the session a notification tap asked for, then clear the request. private func consumeRoute() { guard let route = store.pendingRoute else { return } From f89f53748505b79df31fbeb6103759cc8a6da4a7 Mon Sep 17 00:00:00 2001 From: Andrew Blakeslee Moore Date: Sun, 5 Jul 2026 23:30:58 -0700 Subject: [PATCH 58/78] Native Liquid Glass Tab Bar Request Nucleic-Session: 11F2B35E-E813-4A5E-AD28-9173ECC0B789 Co-authored-by: Nucleic --- .../Views/AdaptiveRootView.swift | 115 +++++++----------- 1 file changed, 47 insertions(+), 68 deletions(-) diff --git a/NucleicRemote/NucleicRemote/Views/AdaptiveRootView.swift b/NucleicRemote/NucleicRemote/Views/AdaptiveRootView.swift index fd005d8..f248d0a 100644 --- a/NucleicRemote/NucleicRemote/Views/AdaptiveRootView.swift +++ b/NucleicRemote/NucleicRemote/Views/AdaptiveRootView.swift @@ -23,72 +23,42 @@ struct AdaptiveRootView: View { // MARK: - Compact (iPhone / narrow multitasking) -/// The iPhone shell: a five-tab `TabView` whose *system* tab bar is suppressed in favour of our -/// own `NucleicTabBar` (below). We draw the bar ourselves because SwiftUI's per-navigation -/// `.toolbar(.hidden, for: .tabBar)` restores the system bar late (~1s) and un-animated when you -/// pop back from a pushed `SessionDetailView` — long enough that the floating "+" renders low -/// (anchored to a bottom safe-area inset that hasn't grown yet) and is briefly untappable before -/// snapping up. Owning the bar lets us slide it out/in with a spring the instant a session opens -/// or closes, so the return is smooth and the "+" reflows in one coordinated animation. +/// The iPhone shell: a four-tab `TabView` using SwiftUI's *native* tab bar — on iOS 26 that's the +/// Liquid Glass bar with the animated selection indicator ("bubble slider"). /// -/// A notification tap routes to the Sessions tab, where `SessionsView` consumes `pendingRoute` -/// and pushes the detail. +/// **This is the "go native" experiment.** We previously drew the bar ourselves (`NucleicTabBar`, +/// still defined below) because `.toolbar(.hidden, for: .tabBar)` used to restore the system bar +/// late (~1s) and un-animated when popping back from a pushed `SessionDetailView`. We're now testing +/// whether iOS 26's native bar restores smoothly on pop-back: the pushed `SessionDetailView` hides +/// the bar via `.toolbar(.hidden, for: .tabBar)` (see `SessionsView`), and we watch the return for +/// the old jank. To revert, swap this body back to the `NucleicTabBar` overlay below. +/// +/// The "New Chat" affordance rides in the iOS 26 `tabViewBottomAccessory` (a floating "+" on +/// earlier systems). A notification tap routes to the Sessions tab, where `SessionsView` consumes +/// `pendingRoute` and pushes the detail. struct CompactRootView: View { @EnvironmentObject var store: RemoteStore - @Environment(\.accessibilityReduceMotion) private var reduceMotion // Initial tab; overridable via NUCLEIC_TAB for offline UI previews. @State private var tab = Int(ProcessInfo.processInfo.environment["NUCLEIC_TAB"] ?? "") ?? 0 - /// The shared "+" beside the tab bar opens the project-picker composer (the per-page discs are - /// gone on compact — this one bar owns "new chat"). + /// The shared "New Chat" entry opens the project-picker composer (the per-page discs are gone on + /// compact — this one entry owns "new chat"). @State private var showNewChat = false - /// Measured height of the floating bar, fed back into the bottom inset so tab content lays out - /// exactly above it (no guessing, no overlap). - @State private var barHeight: CGFloat = 72 - - /// The bar owns the bottom edge on the top-level tabs, but yields it to a pushed session's - /// floating glass chat bar (`compactDetailPresented`, driven by `SessionsView`'s navigation - /// path) and to the new-chat composer while it's up (`showNewChat`) — both slide it out, so the - /// composer doesn't just stack on top of a lingering tab bar. We key off the path rather than - /// `openSessionID` because the path flips the instant a back-swipe begins, whereas - /// `openSessionID` isn't cleared until the detail's `onDisappear` fires at the *end* of the pop - /// transition — which left the bar sliding back up 1–2s late. - private var showsTabBar: Bool { !store.compactDetailPresented && !showNewChat } /// The "+" is inert without control scope or a project to start in, so it's shown only when a /// chat could actually be kicked off (mirrors `NewChatAffordance.canStart`). private var canStartChat: Bool { store.canControl && !store.dashboard.projects.isEmpty } - /// Height reserved under each tab's content for the floating bar — the measured bar height when - /// it's up, nothing when a session or the composer hides it (so those run full-bleed). - private var contentInset: CGFloat { showsTabBar ? barHeight : 0 } - var body: some View { TabView(selection: $tab) { - HomeView().toolbar(.hidden, for: .tabBar).tag(0) - SessionsView().toolbar(.hidden, for: .tabBar).tag(1) - ProjectsView().toolbar(.hidden, for: .tabBar).tag(2) - SettingsView().toolbar(.hidden, for: .tabBar).tag(3) + Tab("Home", systemImage: "house", value: 0) { HomeView() } + Tab("Sessions", systemImage: "square.stack.3d.up", value: 1) { SessionsView() } + // The "needs you" badge the custom bar carried, back on the native tab item. + .badge(store.needsYouCount) + Tab("Projects", systemImage: "folder", value: 2) { ProjectsView() } + Tab("Settings", systemImage: "gearshape", value: 3) { SettingsView() } } - // Each tab's scroll view reads this and reserves room for the floating bar directly on - // itself (via `.compactTabBarInset()`). Neither a `safeAreaInset` nor `contentMargins` on - // the tab root reaches the pages' scroll content through the `TabView`, so it must be applied - // to the `List`/`ScrollView` itself. Zero (its default) on iPad, which has no bar. - .environment(\.compactTabBarInset, contentInset) - // The bar itself lives in an always-mounted overlay and slides on `offset`, so it travels - // continuously off the bottom of the screen (a fixed distance past the home-indicator curve) - // instead of a transition that removes it mid-descent and makes it pop. - .overlay(alignment: .bottom) { - bottomBar - .background( - GeometryReader { geo in - Color.clear.preference(key: BarHeightKey.self, value: geo.size.height) - }) - .onPreferenceChange(BarHeightKey.self) { barHeight = $0 } - .offset(y: showsTabBar ? 0 : barHeight + 120) - } - .animation(reduceMotion ? nil : .spring(response: 0.38, dampingFraction: 0.9), - value: showsTabBar) - // The project-picker composer, presented for the shared "+" (no per-page disc on compact). + .newChatEntry(show: $showNewChat, enabled: canStartChat) + // The project-picker composer, presented for the shared "New Chat" entry. .newChatAffordance(isPresented: $showNewChat, showsButton: false) // A notification tap routes to its session by jumping to the Sessions tab. .onChange(of: store.pendingRoute) { _, route in @@ -99,14 +69,30 @@ struct CompactRootView: View { if show { tab = 1; store.pendingSessionsList = false } } } +} - /// The floating bottom bar: the tab capsule, with the "+" disc riding to its right in the width - /// freed by dropping the To-dos tab (the iOS 26 tab-bar-plus-accessory shape). - private var bottomBar: some View { - HStack(spacing: 10) { - NucleicTabBar(selection: $tab, needsYou: store.needsYouCount) - if canStartChat { - Button { showNewChat = true } label: { +private extension View { + /// Hangs the "New Chat" entry off the tab bar: on iOS 26 as a native `tabViewBottomAccessory` + /// (a pill that morphs with the Liquid Glass bar), and on earlier systems as a floating glass + /// "+" above the bar. A no-op when a chat can't be started. + @ViewBuilder + func newChatEntry(show: Binding, enabled: Bool) -> some View { + if !enabled { + self + } else if #available(iOS 26.0, *) { + self.tabViewBottomAccessory { + Button { show.wrappedValue = true } label: { + Label("New Chat", systemImage: "plus") + .font(.body.weight(.semibold)) + .frame(maxWidth: .infinity) + } + .buttonStyle(.plain) + .foregroundStyle(Palette.accent) + .accessibilityLabel("New chat") + } + } else { + self.overlay(alignment: .bottomTrailing) { + Button { show.wrappedValue = true } label: { Image(systemName: "plus") .font(.system(size: 22, weight: .semibold)) .foregroundStyle(Palette.accent) @@ -115,21 +101,14 @@ struct CompactRootView: View { .glassCircle() } .buttonStyle(.plain) + .padding(.trailing, 16) + .padding(.bottom, 70) // clear the native tab bar .accessibilityLabel("New chat") } } - .padding(.horizontal, 16) - .padding(.bottom, 4) } } -/// A preference carrying the floating bar's measured height up to `CompactRootView`, which feeds it -/// back into each tab's bottom inset so content sits exactly above the bar. -private struct BarHeightKey: PreferenceKey { - static var defaultValue: CGFloat = 72 - static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) { value = nextValue() } -} - /// Height the compact shell's floating tab bar wants reserved at the bottom of a tab's scroll /// content, published down the environment by `CompactRootView`. Zero on iPad (no bar). private struct CompactTabBarInsetKey: EnvironmentKey { From 67b7beacd44a038825ba1f69dcda1bf10b9ee8fa Mon Sep 17 00:00:00 2001 From: Andrew Blakeslee Moore Date: Sun, 5 Jul 2026 23:31:04 -0700 Subject: [PATCH 59/78] Native Liquid Glass Tab Bar Request Nucleic-Session: 11F2B35E-E813-4A5E-AD28-9173ECC0B789 Co-authored-by: Nucleic --- NucleicRemote/NucleicRemote/Views/SessionsView.swift | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/NucleicRemote/NucleicRemote/Views/SessionsView.swift b/NucleicRemote/NucleicRemote/Views/SessionsView.swift index 4389191..9772ef6 100644 --- a/NucleicRemote/NucleicRemote/Views/SessionsView.swift +++ b/NucleicRemote/NucleicRemote/Views/SessionsView.swift @@ -82,6 +82,10 @@ struct SessionsView: View { .navigationTitle("Sessions") .navigationDestination(for: SessionID.self) { id in SessionDetailView(sessionID: id) + // Native-tab-bar experiment: hide the system bar on the pushed detail so the + // full-bleed chat owns the bottom edge. Whether it restores smoothly on pop-back + // (vs. the old ~1s un-animated snap) is exactly what we're testing here. + .toolbar(.hidden, for: .tabBar) } // Offline UI previews (with NUCLEIC_DEMO): jump straight into one session. .onAppear { From 9ba6627539325fb9c1fe3bccb0248bda771e665a Mon Sep 17 00:00:00 2001 From: Andrew Blakeslee Moore Date: Sun, 5 Jul 2026 23:40:44 -0700 Subject: [PATCH 60/78] Native Liquid Glass Tab Bar Request Nucleic-Session: 11F2B35E-E813-4A5E-AD28-9173ECC0B789 Co-authored-by: Nucleic --- .../Views/AdaptiveRootView.swift | 119 +++++++++++------- 1 file changed, 72 insertions(+), 47 deletions(-) diff --git a/NucleicRemote/NucleicRemote/Views/AdaptiveRootView.swift b/NucleicRemote/NucleicRemote/Views/AdaptiveRootView.swift index f248d0a..e8f692b 100644 --- a/NucleicRemote/NucleicRemote/Views/AdaptiveRootView.swift +++ b/NucleicRemote/NucleicRemote/Views/AdaptiveRootView.swift @@ -23,42 +23,76 @@ struct AdaptiveRootView: View { // MARK: - Compact (iPhone / narrow multitasking) -/// The iPhone shell: a four-tab `TabView` using SwiftUI's *native* tab bar — on iOS 26 that's the -/// Liquid Glass bar with the animated selection indicator ("bubble slider"). +/// The iPhone shell: a four-tab `TabView` whose *system* tab bar is suppressed in favour of our +/// own `NucleicTabBar` (below). We draw the bar ourselves because SwiftUI's per-navigation +/// `.toolbar(.hidden, for: .tabBar)` restores the system bar late (~1s) and un-animated when you +/// pop back from a pushed `SessionDetailView` — long enough that the floating "+" renders low +/// (anchored to a bottom safe-area inset that hasn't grown yet) and is briefly untappable before +/// snapping up. (Confirmed still true on iOS 26's native Liquid Glass bar — a "go native" experiment +/// reproduced exactly this ~1s un-animated restore, so owning the bar remains the right call.) +/// Owning the bar lets us slide it out/in with a spring the instant a session opens or closes, so +/// the return is smooth and the "+" reflows in one coordinated animation — and lets the "+" ride +/// beside the tabs, which the native bar can't host. `NucleicTabBar` draws its own animated +/// selection "bubble" so we keep the native look the experiment was after. /// -/// **This is the "go native" experiment.** We previously drew the bar ourselves (`NucleicTabBar`, -/// still defined below) because `.toolbar(.hidden, for: .tabBar)` used to restore the system bar -/// late (~1s) and un-animated when popping back from a pushed `SessionDetailView`. We're now testing -/// whether iOS 26's native bar restores smoothly on pop-back: the pushed `SessionDetailView` hides -/// the bar via `.toolbar(.hidden, for: .tabBar)` (see `SessionsView`), and we watch the return for -/// the old jank. To revert, swap this body back to the `NucleicTabBar` overlay below. -/// -/// The "New Chat" affordance rides in the iOS 26 `tabViewBottomAccessory` (a floating "+" on -/// earlier systems). A notification tap routes to the Sessions tab, where `SessionsView` consumes -/// `pendingRoute` and pushes the detail. +/// A notification tap routes to the Sessions tab, where `SessionsView` consumes `pendingRoute` +/// and pushes the detail. struct CompactRootView: View { @EnvironmentObject var store: RemoteStore + @Environment(\.accessibilityReduceMotion) private var reduceMotion // Initial tab; overridable via NUCLEIC_TAB for offline UI previews. @State private var tab = Int(ProcessInfo.processInfo.environment["NUCLEIC_TAB"] ?? "") ?? 0 - /// The shared "New Chat" entry opens the project-picker composer (the per-page discs are gone on - /// compact — this one entry owns "new chat"). + /// The shared "+" beside the tab bar opens the project-picker composer (the per-page discs are + /// gone on compact — this one bar owns "new chat"). @State private var showNewChat = false + /// Measured height of the floating bar, fed back into the bottom inset so tab content lays out + /// exactly above it (no guessing, no overlap). + @State private var barHeight: CGFloat = 72 + + /// The bar owns the bottom edge on the top-level tabs, but yields it to a pushed session's + /// floating glass chat bar (`compactDetailPresented`, driven by `SessionsView`'s navigation + /// path) and to the new-chat composer while it's up (`showNewChat`) — both slide it out, so the + /// composer doesn't just stack on top of a lingering tab bar. We key off the path rather than + /// `openSessionID` because the path flips the instant a back-swipe begins, whereas + /// `openSessionID` isn't cleared until the detail's `onDisappear` fires at the *end* of the pop + /// transition — which left the bar sliding back up 1–2s late. + private var showsTabBar: Bool { !store.compactDetailPresented && !showNewChat } /// The "+" is inert without control scope or a project to start in, so it's shown only when a /// chat could actually be kicked off (mirrors `NewChatAffordance.canStart`). private var canStartChat: Bool { store.canControl && !store.dashboard.projects.isEmpty } + /// Height reserved under each tab's content for the floating bar — the measured bar height when + /// it's up, nothing when a session or the composer hides it (so those run full-bleed). + private var contentInset: CGFloat { showsTabBar ? barHeight : 0 } + var body: some View { TabView(selection: $tab) { - Tab("Home", systemImage: "house", value: 0) { HomeView() } - Tab("Sessions", systemImage: "square.stack.3d.up", value: 1) { SessionsView() } - // The "needs you" badge the custom bar carried, back on the native tab item. - .badge(store.needsYouCount) - Tab("Projects", systemImage: "folder", value: 2) { ProjectsView() } - Tab("Settings", systemImage: "gearshape", value: 3) { SettingsView() } + HomeView().toolbar(.hidden, for: .tabBar).tag(0) + SessionsView().toolbar(.hidden, for: .tabBar).tag(1) + ProjectsView().toolbar(.hidden, for: .tabBar).tag(2) + SettingsView().toolbar(.hidden, for: .tabBar).tag(3) } - .newChatEntry(show: $showNewChat, enabled: canStartChat) - // The project-picker composer, presented for the shared "New Chat" entry. + // Each tab's scroll view reads this and reserves room for the floating bar directly on + // itself (via `.compactTabBarInset()`). Neither a `safeAreaInset` nor `contentMargins` on + // the tab root reaches the pages' scroll content through the `TabView`, so it must be applied + // to the `List`/`ScrollView` itself. Zero (its default) on iPad, which has no bar. + .environment(\.compactTabBarInset, contentInset) + // The bar itself lives in an always-mounted overlay and slides on `offset`, so it travels + // continuously off the bottom of the screen (a fixed distance past the home-indicator curve) + // instead of a transition that removes it mid-descent and makes it pop. + .overlay(alignment: .bottom) { + bottomBar + .background( + GeometryReader { geo in + Color.clear.preference(key: BarHeightKey.self, value: geo.size.height) + }) + .onPreferenceChange(BarHeightKey.self) { barHeight = $0 } + .offset(y: showsTabBar ? 0 : barHeight + 120) + } + .animation(reduceMotion ? nil : .spring(response: 0.38, dampingFraction: 0.9), + value: showsTabBar) + // The project-picker composer, presented for the shared "+" (no per-page disc on compact). .newChatAffordance(isPresented: $showNewChat, showsButton: false) // A notification tap routes to its session by jumping to the Sessions tab. .onChange(of: store.pendingRoute) { _, route in @@ -69,30 +103,14 @@ struct CompactRootView: View { if show { tab = 1; store.pendingSessionsList = false } } } -} -private extension View { - /// Hangs the "New Chat" entry off the tab bar: on iOS 26 as a native `tabViewBottomAccessory` - /// (a pill that morphs with the Liquid Glass bar), and on earlier systems as a floating glass - /// "+" above the bar. A no-op when a chat can't be started. - @ViewBuilder - func newChatEntry(show: Binding, enabled: Bool) -> some View { - if !enabled { - self - } else if #available(iOS 26.0, *) { - self.tabViewBottomAccessory { - Button { show.wrappedValue = true } label: { - Label("New Chat", systemImage: "plus") - .font(.body.weight(.semibold)) - .frame(maxWidth: .infinity) - } - .buttonStyle(.plain) - .foregroundStyle(Palette.accent) - .accessibilityLabel("New chat") - } - } else { - self.overlay(alignment: .bottomTrailing) { - Button { show.wrappedValue = true } label: { + /// The floating bottom bar: the tab capsule, with the "+" disc riding to its right in the width + /// freed by dropping the To-dos tab (the iOS 26 tab-bar-plus-accessory shape). + private var bottomBar: some View { + HStack(spacing: 10) { + NucleicTabBar(selection: $tab, needsYou: store.needsYouCount) + if canStartChat { + Button { showNewChat = true } label: { Image(systemName: "plus") .font(.system(size: 22, weight: .semibold)) .foregroundStyle(Palette.accent) @@ -101,14 +119,21 @@ private extension View { .glassCircle() } .buttonStyle(.plain) - .padding(.trailing, 16) - .padding(.bottom, 70) // clear the native tab bar .accessibilityLabel("New chat") } } + .padding(.horizontal, 16) + .padding(.bottom, 4) } } +/// A preference carrying the floating bar's measured height up to `CompactRootView`, which feeds it +/// back into each tab's bottom inset so content sits exactly above the bar. +private struct BarHeightKey: PreferenceKey { + static var defaultValue: CGFloat = 72 + static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) { value = nextValue() } +} + /// Height the compact shell's floating tab bar wants reserved at the bottom of a tab's scroll /// content, published down the environment by `CompactRootView`. Zero on iPad (no bar). private struct CompactTabBarInsetKey: EnvironmentKey { From 263385086584851ee4be71ad4f80505c053cdbc8 Mon Sep 17 00:00:00 2001 From: Andrew Blakeslee Moore Date: Sun, 5 Jul 2026 23:41:20 -0700 Subject: [PATCH 61/78] Native Liquid Glass Tab Bar Request Nucleic-Session: 11F2B35E-E813-4A5E-AD28-9173ECC0B789 Co-authored-by: Nucleic --- .../NucleicRemote/Views/AdaptiveRootView.swift | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/NucleicRemote/NucleicRemote/Views/AdaptiveRootView.swift b/NucleicRemote/NucleicRemote/Views/AdaptiveRootView.swift index e8f692b..753e2fd 100644 --- a/NucleicRemote/NucleicRemote/Views/AdaptiveRootView.swift +++ b/NucleicRemote/NucleicRemote/Views/AdaptiveRootView.swift @@ -175,6 +175,10 @@ struct NucleicTabBar: View { @Binding var selection: Int /// Sessions blocking on a human — the red badge on the Sessions item (was the tab item badge). var needsYou: Int + @Environment(\.accessibilityReduceMotion) private var reduceMotion + /// Ties every item's selected-capsule to one shared geometry, so the "bubble" interpolates its + /// frame from the old tab to the new one instead of cross-fading — the native bar's slide. + @Namespace private var bubble private struct Item { let index: Int; let title: String; let icon: String } private let items = [ @@ -193,6 +197,10 @@ struct NucleicTabBar: View { .padding(.horizontal, 6) .padding(.vertical, 8) .glassBar() + // Drive the bubble's slide (and the label/tint crossfade) off the selection itself, so any + // change — tap here or a programmatic jump from a notification — springs across. + .animation(reduceMotion ? nil : .spring(response: 0.32, dampingFraction: 0.78), + value: selection) } private func itemButton(_ item: Item) -> some View { @@ -220,11 +228,14 @@ struct NucleicTabBar: View { .foregroundStyle(selected ? Palette.accent : Color.secondary) .frame(maxWidth: .infinity) .padding(.vertical, 4) - // The selected item rides a soft accent-tinted capsule, echoing the system floating - // bar's highlighted tab. + // The selected item rides a soft accent-tinted capsule that slides between tabs — only + // the selected item mounts it, and the shared `bubble` geometry makes the single capsule + // travel to the new tab, echoing the system floating bar's highlighted-tab indicator. .background { if selected { - Capsule().fill(Palette.accent.opacity(0.15)) + Capsule() + .fill(Palette.accent.opacity(0.15)) + .matchedGeometryEffect(id: "selectionBubble", in: bubble) } } .contentShape(Rectangle()) From 3dbb3616f25106f7d6900d8d357de69d35165fe6 Mon Sep 17 00:00:00 2001 From: Andrew Blakeslee Moore Date: Sun, 5 Jul 2026 23:41:27 -0700 Subject: [PATCH 62/78] Native Liquid Glass Tab Bar Request Nucleic-Session: 11F2B35E-E813-4A5E-AD28-9173ECC0B789 Co-authored-by: Nucleic --- NucleicRemote/NucleicRemote/Views/SessionsView.swift | 4 ---- 1 file changed, 4 deletions(-) diff --git a/NucleicRemote/NucleicRemote/Views/SessionsView.swift b/NucleicRemote/NucleicRemote/Views/SessionsView.swift index 9772ef6..4389191 100644 --- a/NucleicRemote/NucleicRemote/Views/SessionsView.swift +++ b/NucleicRemote/NucleicRemote/Views/SessionsView.swift @@ -82,10 +82,6 @@ struct SessionsView: View { .navigationTitle("Sessions") .navigationDestination(for: SessionID.self) { id in SessionDetailView(sessionID: id) - // Native-tab-bar experiment: hide the system bar on the pushed detail so the - // full-bleed chat owns the bottom edge. Whether it restores smoothly on pop-back - // (vs. the old ~1s un-animated snap) is exactly what we're testing here. - .toolbar(.hidden, for: .tabBar) } // Offline UI previews (with NUCLEIC_DEMO): jump straight into one session. .onAppear { From 507306f3dda5f9adfb3f4b4566f2bcb01492df8a Mon Sep 17 00:00:00 2001 From: Andrew Blakeslee Moore Date: Sun, 5 Jul 2026 23:42:16 -0700 Subject: [PATCH 63/78] Lock Auto Mode Logic Nucleic-Session: 787E9BFA-F481-4804-95DB-E063FDB518B0 Co-authored-by: Nucleic --- .../NucleicRemote/Views/SessionDetailView.swift | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift b/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift index 82258ae..33e4355 100644 --- a/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift +++ b/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift @@ -131,10 +131,18 @@ struct SessionDetailView: View { .font(.caption2.weight(.semibold)).foregroundStyle(.primary) .help("Context window used") } - Toggle(isOn: Binding(get: { summary.auto }, set: { store.setSessionAuto(sessionID, $0) })) { - Label("Auto", systemImage: summary.auto ? "bolt.fill" : "bolt.slash") + // 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. + Toggle(isOn: Binding(get: { controlled || summary.auto }, + set: { store.setSessionAuto(sessionID, $0) })) { + Label("Auto", systemImage: (controlled || summary.auto) ? "bolt.fill" : "bolt.slash") } .toggleStyle(.button).tint(Palette.accent).font(.caption2.weight(.semibold)) + .disabled(controlled) + .help(controlled + ? "Auto-approve is always on for Nucleic Control chats — they run autonomously." + : "Auto-approve safe actions; destructive ones still ask.") } } From c9f58106d59899ad8e682f0343e3f8ad8cf79c29 Mon Sep 17 00:00:00 2001 From: Andrew Blakeslee Moore Date: Sun, 5 Jul 2026 23:53:19 -0700 Subject: [PATCH 64/78] Remove $ Cost Display Nucleic-Session: 01482273-9464-4AF9-B9D5-8EECAAFD7B12 Co-authored-by: Nucleic --- NucleicRemote/NucleicRemote/Views/TranscriptRow.swift | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/NucleicRemote/NucleicRemote/Views/TranscriptRow.swift b/NucleicRemote/NucleicRemote/Views/TranscriptRow.swift index bd68c17..f492cec 100644 --- a/NucleicRemote/NucleicRemote/Views/TranscriptRow.swift +++ b/NucleicRemote/NucleicRemote/Views/TranscriptRow.swift @@ -163,16 +163,13 @@ struct InfoLine: View { } } -/// Per-turn token accounting + cost — the signal the desktop shows and the old remote dropped. +/// Per-turn token accounting — the signal the desktop shows and the old remote dropped. struct UsageRow: View { let usage: Usage var body: some View { HStack(spacing: 10) { if let input = usage.inputTokens { metric("arrow.down", tokens(input)) } if let output = usage.outputTokens { metric("arrow.up", tokens(output)) } - if let cost = usage.costUSD, cost > 0 { - Text(String(format: "$%.4f", cost)).foregroundStyle(.secondary) - } Spacer(minLength: 0) } .font(.caption2.monospacedDigit()).foregroundStyle(.secondary) From 054c02460f3f3a951264651ca01310ccd9140dac Mon Sep 17 00:00:00 2001 From: Andrew Blakeslee Moore Date: Mon, 6 Jul 2026 00:13:52 -0700 Subject: [PATCH 65/78] Adjust Tab Bar Indicator Nucleic-Session: 2DA405D7-B197-4DB6-92EB-22357E77F450 Co-authored-by: Nucleic --- NucleicRemote/NucleicRemote/Views/AdaptiveRootView.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/NucleicRemote/NucleicRemote/Views/AdaptiveRootView.swift b/NucleicRemote/NucleicRemote/Views/AdaptiveRootView.swift index 753e2fd..bbd62e9 100644 --- a/NucleicRemote/NucleicRemote/Views/AdaptiveRootView.swift +++ b/NucleicRemote/NucleicRemote/Views/AdaptiveRootView.swift @@ -195,7 +195,7 @@ struct NucleicTabBar: View { } } .padding(.horizontal, 6) - .padding(.vertical, 8) + .padding(.vertical, 5) .glassBar() // Drive the bubble's slide (and the label/tint crossfade) off the selection itself, so any // change — tap here or a programmatic jump from a notification — springs across. From 848e073ff862abbe944a463353575874dc7145de Mon Sep 17 00:00:00 2001 From: Andrew Blakeslee Moore Date: Mon, 6 Jul 2026 00:13:54 -0700 Subject: [PATCH 66/78] Adjust Tab Bar Indicator Nucleic-Session: 2DA405D7-B197-4DB6-92EB-22357E77F450 Co-authored-by: Nucleic --- NucleicRemote/NucleicRemote/Views/AdaptiveRootView.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/NucleicRemote/NucleicRemote/Views/AdaptiveRootView.swift b/NucleicRemote/NucleicRemote/Views/AdaptiveRootView.swift index bbd62e9..7b29ced 100644 --- a/NucleicRemote/NucleicRemote/Views/AdaptiveRootView.swift +++ b/NucleicRemote/NucleicRemote/Views/AdaptiveRootView.swift @@ -227,7 +227,7 @@ struct NucleicTabBar: View { } .foregroundStyle(selected ? Palette.accent : Color.secondary) .frame(maxWidth: .infinity) - .padding(.vertical, 4) + .padding(.vertical, 8) // The selected item rides a soft accent-tinted capsule that slides between tabs — only // the selected item mounts it, and the shared `bubble` geometry makes the single capsule // travel to the new tab, echoing the system floating bar's highlighted-tab indicator. From 9246ef0d43fbf5ab4e6202883e6031d424c1c3b6 Mon Sep 17 00:00:00 2001 From: Andrew Blakeslee Moore Date: Mon, 6 Jul 2026 00:32:51 -0700 Subject: [PATCH 67/78] Scroll Position Adjustment Nucleic-Session: 4975C92B-B473-4ABC-97F0-12F41AC8BB87 Co-authored-by: Nucleic --- .../Views/SessionDetailView.swift | 114 +++++++++++++++++- 1 file changed, 112 insertions(+), 2 deletions(-) diff --git a/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift b/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift index 33e4355..08f9559 100644 --- a/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift +++ b/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift @@ -388,13 +388,36 @@ struct SessionDetailView: View { struct TranscriptList: View { let events: [AgentEvent] + /// Bumped by the parent when the user sends a message — a deliberate "show me what happens + /// next" that jumps to the bottom even if they'd scrolled up to read history. + var scrollToBottomRequest: Int = 0 @AppStorage("nucleic.showRawEvents") private var showRaw = false @AppStorage("nucleic.showLockEvents") private var showLockEvents = true + /// True while the user is parked at (within a hair of) the bottom, so live output keeps + /// following; once they scroll up it flips false, following stops, and the jump-to-bottom + /// chevron appears. Starts true — a freshly opened chat is anchored at the bottom. + @State private var isScrolledToBottom = true + + /// True for a short window right after the transcript appears, while it runs its first + /// layout passes and its events fill in. During it we accept only "at bottom" scroll + /// readings, so the settling layout can't strand the chat above its true bottom or flash the + /// chevron; live scrolls are honored once it clears. Mirrors the Mac's `transcriptSettling`. + @State private var transcriptSettling = true + + /// Total scroll-content height, tracked live from the scroll view's geometry, but only while + /// settling — a `.task(id:)` debounces it to detect when the opening layout has come to rest. + @State private var transcriptContentHeight: CGFloat = 0 + private var items: [TranscriptItem] { TranscriptProjection.build(events, showRaw: showRaw, showLockEvents: showLockEvents) } + /// 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`. + private let bottomFollowThreshold: CGFloat = 24 + var body: some View { ScrollViewReader { proxy in ScrollView { @@ -411,13 +434,100 @@ struct TranscriptList: View { // A drag on the transcript dismisses the keyboard, so a tall multiline composer can // be put away without leaving the session. .scrollDismissesKeyboard(.interactively) + // The single-argument `defaultScrollAnchor(.bottom)` governs both the initial offset + // and how the scroll view re-pins on content-size changes — it sticks the bottom edge + // in view as the transcript grows. That native stickiness is what we want while the + // user is parked at the bottom (the chat opens at the end and follows live output), + // but it's also what yanked them back down whenever anything changed while they'd + // scrolled up to read history. So gate the anchor itself: drop it to `nil` the moment + // 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) + // 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 + } + } + // Detect when the opening layout has come to rest: track the content height while + // settling and let `.task(id:)` debounce it — the task restarts on every change and + // only reaches its body once the height has held steady. Freezing the tracker after + // settling keeps live streaming (which changes the height every frame) from re-arming. + .onScrollGeometryChange(for: CGFloat.self) { $0.contentSize.height.rounded() } action: { _, height in + if transcriptSettling { transcriptContentHeight = height } + } + .task(id: transcriptContentHeight) { + guard transcriptSettling, transcriptContentHeight > 0 else { return } + try? await Task.sleep(for: .milliseconds(80)) + if Task.isCancelled { return } + transcriptSettling = false + } // 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. + // 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. .onChange(of: events.count) { - if let last = items.last { withAnimation { proxy.scrollTo(last.id, anchor: .bottom) } } + if isScrolledToBottom { scrollToLast(proxy, animated: !transcriptSettling) } + } + // An explicit jump — the chevron or sending a message — always wins. + .onChange(of: scrollToBottomRequest) { scrollToLast(proxy) } + // Floating chevron above the chat bar, shown only while scrolled up; tap to jump back + // to the latest output. Mirrors the Mac's `JumpToBottomButton`. + .overlay(alignment: .bottom) { + ZStack { + if !isScrolledToBottom { + JumpToBottomButton { scrollToLast(proxy) } + .padding(.bottom, 12) + .transition(.move(edge: .bottom).combined(with: .opacity)) + } + } + .animation(.easeInOut(duration: 0.15), value: isScrolledToBottom) } } } + + /// Scroll the last message to the bottom edge; `animated: false` during the open-settle + /// window so a chat that's still laying out snaps to the tail instead of easing into place. + private func scrollToLast(_ proxy: ScrollViewProxy, animated: Bool = true) { + guard let last = items.last else { return } + if animated { + withAnimation { proxy.scrollTo(last.id, anchor: .bottom) } + } else { + proxy.scrollTo(last.id, anchor: .bottom) + } + } +} + +/// The floating "jump to the latest" chevron shown above the chat bar while the user has +/// scrolled up away from the bottom of the transcript. The mobile twin of the Mac's +/// `JumpToBottomButton`. +private struct JumpToBottomButton: View { + let action: () -> Void + + var body: some View { + Button(action: action) { + Image(systemName: "chevron.down") + .font(.system(size: 13, weight: .semibold)) + .foregroundStyle(Palette.accent) + .frame(width: 30, height: 30) + .background(.regularMaterial, in: .circle) + .overlay(Circle().strokeBorder(.separator, lineWidth: 0.5)) + .shadow(color: .black.opacity(0.18), radius: 4, y: 1) + } + .buttonStyle(.plain) + .accessibilityLabel("Jump to the latest message") + } } /// The diff sheet's content, read-only over the on-demand `WireSessionDiff`. On a phone it's a per-file From 13b4b0146c6225634f01072f865649f08bd3dff3 Mon Sep 17 00:00:00 2001 From: Andrew Blakeslee Moore Date: Mon, 6 Jul 2026 00:32:57 -0700 Subject: [PATCH 68/78] Scroll Position Adjustment Nucleic-Session: 4975C92B-B473-4ABC-97F0-12F41AC8BB87 Co-authored-by: Nucleic --- NucleicRemote/NucleicRemote/Views/SessionDetailView.swift | 3 +++ 1 file changed, 3 insertions(+) diff --git a/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift b/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift index 08f9559..60cf709 100644 --- a/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift +++ b/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift @@ -15,6 +15,9 @@ struct SessionDetailView: View { @State private var renameDraft = "" @State private var showIntegrate = false @State private var showDiscard = false + // Bumped on send to jump the transcript to the bottom, even if the user had scrolled up to + // read history — sending is a deliberate "show me what happens next" (mirrors the Mac). + @State private var scrollToBottomRequest = 0 private var summary: WireSessionSummary? { store.sessions.first { $0.sessionID == sessionID } From e23758a0e46b62ab447730b95ea04722741a09c8 Mon Sep 17 00:00:00 2001 From: Andrew Blakeslee Moore Date: Mon, 6 Jul 2026 00:33:00 -0700 Subject: [PATCH 69/78] Scroll Position Adjustment Nucleic-Session: 4975C92B-B473-4ABC-97F0-12F41AC8BB87 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 60cf709..8fe1693 100644 --- a/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift +++ b/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift @@ -24,7 +24,7 @@ struct SessionDetailView: View { } var body: some View { - TranscriptList(events: store.openEvents) + TranscriptList(events: store.openEvents, scrollToBottomRequest: scrollToBottomRequest) // 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 } From 8a193139d505f59e8eaec4cf803fc5142bf2f192 Mon Sep 17 00:00:00 2001 From: Andrew Blakeslee Moore Date: Mon, 6 Jul 2026 00:33:06 -0700 Subject: [PATCH 70/78] Scroll Position Adjustment Nucleic-Session: 4975C92B-B473-4ABC-97F0-12F41AC8BB87 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 8fe1693..677832a 100644 --- a/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift +++ b/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift @@ -319,6 +319,7 @@ struct SessionDetailView: View { Button { store.sendInput(draft, to: sessionID) draft = "" + scrollToBottomRequest += 1 } label: { Image(systemName: running ? "clock.arrow.circlepath" : "arrow.up.circle.fill") .font(.title2) From b11c5e79ac10417bec5487b3130295d1b82758d7 Mon Sep 17 00:00:00 2001 From: Andrew Blakeslee Moore Date: Mon, 6 Jul 2026 00:40:31 -0700 Subject: [PATCH 71/78] New-Chat Composer Default Nucleic-Session: 7CB257B0-1B05-4EAF-B4FA-7BBFF1F8915D Co-authored-by: Nucleic --- NucleicRemote/NucleicRemote/Views/Composer.swift | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/NucleicRemote/NucleicRemote/Views/Composer.swift b/NucleicRemote/NucleicRemote/Views/Composer.swift index d896f0a..9e4613b 100644 --- a/NucleicRemote/NucleicRemote/Views/Composer.swift +++ b/NucleicRemote/NucleicRemote/Views/Composer.swift @@ -27,8 +27,12 @@ struct StartChatComposer: View { @State private var useWorktree = true private var projects: [WireProject] { store.dashboard.projects } + /// The picker defaults to "No project" (nil) rather than auto-selecting the first project, so a + /// typed prompt never quietly starts an agent in a repo the user didn't choose — they pick one + /// explicitly before the send button lights. A project page's composer stays scoped via + /// `lockedProject`. private var selected: WireProject? { - lockedProject ?? (projects.first { $0.id == projectID } ?? projects.first) + lockedProject ?? projects.first { $0.id == projectID } } private var controlled: Bool { selected?.isNucleicControlled ?? false } /// Auto-approve for the chat about to start: the user's choice off-control, but forced on for a From 5124205b7074d3ab57574e14c76af2242fb44897 Mon Sep 17 00:00:00 2001 From: Andrew Blakeslee Moore Date: Mon, 6 Jul 2026 00:40:36 -0700 Subject: [PATCH 72/78] New-Chat Composer Default Nucleic-Session: 7CB257B0-1B05-4EAF-B4FA-7BBFF1F8915D Co-authored-by: Nucleic --- NucleicRemote/NucleicRemote/Views/Composer.swift | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/NucleicRemote/NucleicRemote/Views/Composer.swift b/NucleicRemote/NucleicRemote/Views/Composer.swift index 9e4613b..843dbef 100644 --- a/NucleicRemote/NucleicRemote/Views/Composer.swift +++ b/NucleicRemote/NucleicRemote/Views/Composer.swift @@ -46,12 +46,15 @@ struct StartChatComposer: View { HStack { if lockedProject == nil { Menu { + Button("No project") { projectID = nil } + if !projects.isEmpty { Divider() } ForEach(projects) { project in Button(project.name) { projectID = project.id } } } label: { // The model also selects the backend, so the picker offers every project. - Label(selected?.name ?? "Select a project", systemImage: "folder") + // Defaults to "No project" until one is chosen. + Label(selected?.name ?? "No project", systemImage: "folder") .font(.subheadline) } } else { From aeb8583aa719ad8aad98c41153c3ab23f8d71fe3 Mon Sep 17 00:00:00 2001 From: Andrew Blakeslee Moore Date: Mon, 6 Jul 2026 00:40:47 -0700 Subject: [PATCH 73/78] Session View Animation Fix Nucleic-Session: 34001BF5-66C2-4D6D-844D-EE95727B8D85 Co-authored-by: Nucleic --- NucleicRemote/NucleicRemote/Models/RemoteStore.swift | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/NucleicRemote/NucleicRemote/Models/RemoteStore.swift b/NucleicRemote/NucleicRemote/Models/RemoteStore.swift index 62a16c2..350a7de 100644 --- a/NucleicRemote/NucleicRemote/Models/RemoteStore.swift +++ b/NucleicRemote/NucleicRemote/Models/RemoteStore.swift @@ -831,6 +831,12 @@ final class RemoteStore: ObservableObject { func open(_ sessionID: SessionID) { openSessionID = sessionID + // Hide the compact shell's floating tab bar the instant a session view opens — from *any* + // entry point (Sessions, Home, Projects, a notification tap), since every one funnels + // through here. Tied to the session view opening rather than the Sessions list being + // navigated away from, so the bar slides out even when the chat wasn't launched from that + // list. `SessionsView` still clears it early on back-swipe for a responsive re-show. + compactDetailPresented = true // 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 From 3e80092f7e8469a779a022fa3aaf8714d0c35b0e Mon Sep 17 00:00:00 2001 From: Andrew Blakeslee Moore Date: Mon, 6 Jul 2026 00:40:59 -0700 Subject: [PATCH 74/78] Session View Animation Fix Nucleic-Session: 34001BF5-66C2-4D6D-844D-EE95727B8D85 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 350a7de..8260c82 100644 --- a/NucleicRemote/NucleicRemote/Models/RemoteStore.swift +++ b/NucleicRemote/NucleicRemote/Models/RemoteStore.swift @@ -991,6 +991,10 @@ final class RemoteStore: ObservableObject { guard openSessionID == target else { return } connection(owningSession: target)?.openSessionID = nil openSessionID = nil + // Session view closed — restore the compact tab bar (the counterpart to `open`'s hide). + // On the Sessions tab the back-swipe has usually already cleared this early for a snappy + // re-show; this is the catch-all for the other entry points and programmatic closes. + compactDetailPresented = false openSessionHostID = nil // Persist the final transcript before clearing it, so it's warm for the next open / offline. flushOpenTranscript(target, openEvents) From 0f0c8a68a4d20c5e109000f08f8630387fc7db85 Mon Sep 17 00:00:00 2001 From: Andrew Blakeslee Moore Date: Mon, 6 Jul 2026 00:41:09 -0700 Subject: [PATCH 75/78] Session View Animation Fix Nucleic-Session: 34001BF5-66C2-4D6D-844D-EE95727B8D85 Co-authored-by: Nucleic --- NucleicRemote/NucleicRemote/Views/SessionsView.swift | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/NucleicRemote/NucleicRemote/Views/SessionsView.swift b/NucleicRemote/NucleicRemote/Views/SessionsView.swift index 4389191..cd4be2a 100644 --- a/NucleicRemote/NucleicRemote/Views/SessionsView.swift +++ b/NucleicRemote/NucleicRemote/Views/SessionsView.swift @@ -96,11 +96,13 @@ struct SessionsView: View { } // A notification tap while this tab is already up. .onChange(of: store.pendingRoute) { consumeRoute() } - // Drive the floating tab bar straight off the navigation path: it empties the instant a - // back-swipe/tap begins, so the bar slides back in immediately instead of waiting for - // `SessionDetailView.onDisappear` (which fires only at the end of the pop transition). + // The tab bar's visibility is owned by the session lifecycle (`RemoteStore.open`/ + // `closeOpen`), so it hides/shows no matter which tab a chat opens from. This handler is + // just the responsiveness refinement for *this* tab: the path empties the instant a + // back-swipe/tap begins, so clearing here slides the bar back in immediately instead of + // waiting for `SessionDetailView.onDisappear` (which fires only at the end of the pop). .onChange(of: path) { _, newPath in - store.compactDetailPresented = !newPath.isEmpty + if newPath.isEmpty { store.compactDetailPresented = false } } } } From 8fe9c61b2d0b5a1f97ed775510af05f1f214087b Mon Sep 17 00:00:00 2001 From: Andrew Blakeslee Moore Date: Mon, 6 Jul 2026 00:41:20 -0700 Subject: [PATCH 76/78] Session View Animation Fix Nucleic-Session: 34001BF5-66C2-4D6D-844D-EE95727B8D85 Co-authored-by: Nucleic --- .../NucleicRemote/Views/AdaptiveRootView.swift | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/NucleicRemote/NucleicRemote/Views/AdaptiveRootView.swift b/NucleicRemote/NucleicRemote/Views/AdaptiveRootView.swift index 7b29ced..48bccd8 100644 --- a/NucleicRemote/NucleicRemote/Views/AdaptiveRootView.swift +++ b/NucleicRemote/NucleicRemote/Views/AdaptiveRootView.swift @@ -50,12 +50,12 @@ struct CompactRootView: View { @State private var barHeight: CGFloat = 72 /// The bar owns the bottom edge on the top-level tabs, but yields it to a pushed session's - /// floating glass chat bar (`compactDetailPresented`, driven by `SessionsView`'s navigation - /// path) and to the new-chat composer while it's up (`showNewChat`) — both slide it out, so the - /// composer doesn't just stack on top of a lingering tab bar. We key off the path rather than - /// `openSessionID` because the path flips the instant a back-swipe begins, whereas - /// `openSessionID` isn't cleared until the detail's `onDisappear` fires at the *end* of the pop - /// transition — which left the bar sliding back up 1–2s late. + /// floating glass chat bar (`compactDetailPresented`) and to the new-chat composer while it's up + /// (`showNewChat`) — both slide it out, so the composer doesn't just stack on top of a lingering + /// tab bar. `compactDetailPresented` is driven by the session lifecycle (`RemoteStore.open`/ + /// `closeOpen`), so the bar hides whenever a session view opens — no matter which tab launched + /// it, not only the Sessions list. `SessionsView` additionally clears it the instant a + /// back-swipe begins (the path empties before `onDisappear` fires) for a snappy re-show. private var showsTabBar: Bool { !store.compactDetailPresented && !showNewChat } /// The "+" is inert without control scope or a project to start in, so it's shown only when a From ce0319fbde598d930808cbcd60137df1e0750197 Mon Sep 17 00:00:00 2001 From: Andrew Blakeslee Moore Date: Mon, 6 Jul 2026 00:41:30 -0700 Subject: [PATCH 77/78] Session View Animation Fix Nucleic-Session: 34001BF5-66C2-4D6D-844D-EE95727B8D85 Co-authored-by: Nucleic --- NucleicRemote/NucleicRemote/Views/SessionDetailView.swift | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift b/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift index 677832a..2b0ef81 100644 --- a/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift +++ b/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift @@ -32,7 +32,8 @@ struct SessionDetailView: View { .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 toggling `store.openSessionID`, not a per-navigation tab-bar + // `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 { From 6292c7e723b050d7a5631f64523d06e347c5ef4c Mon Sep 17 00:00:00 2001 From: Andrew Blakeslee Moore Date: Mon, 6 Jul 2026 01:34:44 -0700 Subject: [PATCH 78/78] Live Activity Refresh Delay Nucleic-Session: 8C954A45-A16B-4E91-B9E6-9865809096D7 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 e020876..d3234e9 100644 --- a/NucleicRemote/NucleicRemote/Models/HostConnection.swift +++ b/NucleicRemote/NucleicRemote/Models/HostConnection.swift @@ -370,6 +370,9 @@ final class HostConnection { channel: channel, identity: identity, hostStaticKey: hostStaticKey, mode: mode, deviceID: deviceID, deviceLabel: UIDevice.current.name, pushToken: PushRegistrar.shared.tokenHex, + // Our own bundle id is the exact APNs topic the relay must address; per-channel + // TestFlight builds are suffixed (…`.canary`), so a hardcoded topic would `BadTopic`. + pushTopic: Bundle.main.bundleIdentifier, releaseChannel: BuildInfo.current.channel.releaseChannel, // Mesh "join": advertise roster gossip so a host pushes its group view — the phone // then auto-learns and connects to every Mac in the mesh, not just the one it scanned.