From 16da64da3bb31a437c26e1c5286725067a655bab Mon Sep 17 00:00:00 2001 From: Andrew Blakeslee Moore Date: Fri, 3 Jul 2026 01:40:46 -0700 Subject: [PATCH 001/143] QR Code Scan Failure Nucleic-Session: 80E30D7B-70A8-479A-A9A8-7E267486EAE1 Co-authored-by: Nucleic --- NucleicRemote/NucleicRemote/Info.plist | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/NucleicRemote/NucleicRemote/Info.plist b/NucleicRemote/NucleicRemote/Info.plist index 6b1a38f..15712f4 100644 --- a/NucleicRemote/NucleicRemote/Info.plist +++ b/NucleicRemote/NucleicRemote/Info.plist @@ -6,6 +6,13 @@ _nucleic._tcp + + NSLocalNetworkUsageDescription + Nucleic connects to your Mac on the local network to pair and to sync your sessions and approvals. NucleicChannel $(NUCLEIC_CHANNEL) + CFBundleURLTypes + + + CFBundleTypeRole + Editor + CFBundleURLName + com.nucleic.remote + CFBundleURLSchemes + + nucleic + + + + NSSupportsLiveActivitiesFrequentUpdates + From 6f492de70f64d657370e0838bf61c8e0fae7acf1 Mon Sep 17 00:00:00 2001 From: Andrew Blakeslee Moore Date: Sun, 5 Jul 2026 01:12:45 -0700 Subject: [PATCH 073/143] Chat View Refinement Nucleic-Session: 4E56990A-96AE-4597-9140-753CB82E769D Co-authored-by: Nucleic --- .../NucleicRemote/Views/SessionDetailView.swift | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift b/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift index 8068246..04d557e 100644 --- a/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift +++ b/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift @@ -1,15 +1,15 @@ import SwiftUI import NucleicProtocol -/// One session: a full-height Transcript/Diff read view (mode flip in the navigation bar) with -/// a status-driven action bar floating over it on Liquid Glass (UX_IOS §4). Subscribes on +/// One session: a full-height transcript with a status-driven action bar floating over it on +/// Liquid Glass; the diff viewer sits behind the ⋯ menu as a sheet (UX_IOS §4). Subscribes on /// appear, unsubscribes on disappear. struct SessionDetailView: View { @EnvironmentObject var store: RemoteStore let sessionID: SessionID - // Initial tab (0 = Transcript, 1 = Diff); overridable via NUCLEIC_DETAIL_TAB for - // offline UI previews, matching NUCLEIC_TAB / NUCLEIC_DEMO_SESSION. - @State private var tab = Int(ProcessInfo.processInfo.environment["NUCLEIC_DETAIL_TAB"] ?? "") ?? 0 + // Diff sheet visibility; NUCLEIC_DETAIL_TAB=1 opens it for offline UI previews, + // matching NUCLEIC_TAB / NUCLEIC_DEMO_SESSION. + @State private var showDiff = ProcessInfo.processInfo.environment["NUCLEIC_DETAIL_TAB"] == "1" @State private var draft = "" @State private var showRename = false @State private var renameDraft = "" From 3c26b295056e4de155989b7c4ad530634dd2bd6e Mon Sep 17 00:00:00 2001 From: Andrew Blakeslee Moore Date: Sun, 5 Jul 2026 01:12:57 -0700 Subject: [PATCH 074/143] Chat View Refinement Nucleic-Session: 4E56990A-96AE-4597-9140-753CB82E769D Co-authored-by: Nucleic --- .../Views/SessionDetailView.swift | 41 ++++--------------- 1 file changed, 9 insertions(+), 32 deletions(-) diff --git a/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift b/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift index 04d557e..836b7ff 100644 --- a/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift +++ b/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift @@ -21,39 +21,16 @@ struct SessionDetailView: View { } var body: some View { - Group { - if tab == 0 { - TranscriptList(events: store.openEvents) - } else if store.capabilities.canFetchDiff || store.openDiff != nil { - SessionDiffView(diff: store.openDiff, loading: store.diffLoading) - .onAppear { store.fetchDiff(sessionID) } - } else { - // Older host with no on-demand diff — the stat summary is all it projects. - DiffSummaryView(diffStat: summary?.diffStat) + TranscriptList(events: store.openEvents) + // The chat bar floats over the scrolling content on Liquid Glass instead of sitting + // in a boxed strip below it, so the transcript runs the full height of the screen. + .safeAreaInset(edge: .bottom) { actionArea } + .navigationTitle(summary?.title ?? "Session") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .topBarTrailing) { sessionMenu } } - } - // The chat bar floats over the scrolling content on Liquid Glass instead of sitting in - // a boxed strip below it, so the transcript runs the full height of the screen. - .safeAreaInset(edge: .bottom) { actionArea } - .navigationTitle(summary?.title ?? "Session") - .navigationBarTitleDisplayMode(.inline) - .toolbar { - // Transcript/Diff lives in the navigation bar (icon segments) rather than a - // dedicated row — it's a mode flip, not content, and shouldn't cost chat height. - ToolbarItem(placement: .topBarTrailing) { - Picker("View", selection: $tab) { - Image(systemName: "text.bubble").tag(0) - .accessibilityLabel("Transcript") - Image(systemName: "plus.forwardslash.minus").tag(1) - .accessibilityLabel("Diff") - } - .pickerStyle(.segmented) - .fixedSize() - } - if let summary, store.canControl { - ToolbarItem(placement: .topBarTrailing) { sessionMenu(summary) } - } - } + .sheet(isPresented: $showDiff) { diffSheet } .alert("Rename chat", isPresented: $showRename) { TextField("Title", text: $renameDraft) Button("Cancel", role: .cancel) {} From 48283674faa454f885c42a95b163153d3a820618 Mon Sep 17 00:00:00 2001 From: Andrew Blakeslee Moore Date: Sun, 5 Jul 2026 01:13:16 -0700 Subject: [PATCH 075/143] Chat View Refinement Nucleic-Session: 4E56990A-96AE-4597-9140-753CB82E769D Co-authored-by: Nucleic --- .../Views/SessionDetailView.swift | 47 ++++++++++++++++++- 1 file changed, 46 insertions(+), 1 deletion(-) diff --git a/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift b/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift index 836b7ff..cdc8759 100644 --- a/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift +++ b/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift @@ -131,9 +131,54 @@ struct SessionDetailView: View { } } + /// The ⋯ menu: the diff viewer for everyone, then the control-scope session actions. @ViewBuilder - private func sessionMenu(_ summary: WireSessionSummary) -> some View { + private var sessionMenu: some View { Menu { + Button { showDiff = true } label: { + Label(diffLabel, systemImage: "plus.forwardslash.minus") + } + if let summary, store.canControl { + Divider() + sessionActions(summary) + } + } label: { + Image(systemName: "ellipsis.circle") + } + } + + /// "View changes" with the live +/− counts when the host projects them, so the menu hints + /// whether there's anything to look at before opening the sheet. + private var diffLabel: String { + guard let stat = summary?.diffStat, stat.filesChanged > 0 else { return "View changes" } + return "View changes · +\(stat.added) −\(stat.removed)" + } + + /// The diff, presented as a sheet from the ⋯ menu — a viewer you visit, not a mode the + /// chat lives in. + private var diffSheet: some View { + NavigationStack { + Group { + if store.capabilities.canFetchDiff || store.openDiff != nil { + SessionDiffView(diff: store.openDiff, loading: store.diffLoading) + .onAppear { store.fetchDiff(sessionID) } + } else { + // Older host with no on-demand diff — the stat summary is all it projects. + DiffSummaryView(diffStat: summary?.diffStat) + } + } + .navigationTitle("Changes") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Button("Done") { showDiff = false } + } + } + } + } + + @ViewBuilder + private func sessionActions(_ summary: WireSessionSummary) -> some View { Button { renameDraft = summary.title; showRename = true } label: { Label("Rename", systemImage: "pencil") } From 81c2461419b8b3503872b7df36a355c99a7a8c3c Mon Sep 17 00:00:00 2001 From: Andrew Blakeslee Moore Date: Sun, 5 Jul 2026 01:13:49 -0700 Subject: [PATCH 076/143] Chat View Refinement Nucleic-Session: 4E56990A-96AE-4597-9140-753CB82E769D Co-authored-by: Nucleic --- .../Views/SessionDetailView.swift | 57 +++++++++---------- 1 file changed, 27 insertions(+), 30 deletions(-) diff --git a/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift b/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift index cdc8759..c7854bc 100644 --- a/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift +++ b/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift @@ -179,39 +179,36 @@ struct SessionDetailView: View { @ViewBuilder private func sessionActions(_ summary: WireSessionSummary) -> some View { - Button { renameDraft = summary.title; showRename = true } label: { - Label("Rename", systemImage: "pencil") + Button { renameDraft = summary.title; showRename = true } label: { + Label("Rename", systemImage: "pencil") + } + Button { store.setFavorite(sessionID, !summary.favorite) } label: { + Label(summary.favorite ? "Unfavorite" : "Favorite", + systemImage: summary.favorite ? "star.slash" : "star") + } + // Autoship is a Nucleic Control capability — offered only for Control projects, and + // the host couples it with auto-approval (enabling it turns Auto on). + if controlled { + Button { store.setSessionAutoShip(sessionID, !summary.autoShip) } label: { + Label(summary.autoShip ? "Turn off Autoship" : "Turn on Autoship", + systemImage: summary.autoShip ? "shippingbox.fill" : "shippingbox") } - Button { store.setFavorite(sessionID, !summary.favorite) } label: { - Label(summary.favorite ? "Unfavorite" : "Favorite", - systemImage: summary.favorite ? "star.slash" : "star") + } + if summary.status == .running { + Button { store.interrupt(sessionID) } label: { Label("Interrupt", systemImage: "stop.circle") } + } + Section { + Button { showIntegrate = true } label: { Label("Integrate…", systemImage: "arrow.triangle.merge") } + Button(role: .destructive) { showDiscard = true } label: { + Label("Discard…", systemImage: "xmark.bin") } - // Autoship is a Nucleic Control capability — offered only for Control projects, and - // the host couples it with auto-approval (enabling it turns Auto on). - if controlled { - Button { store.setSessionAutoShip(sessionID, !summary.autoShip) } label: { - Label(summary.autoShip ? "Turn off Autoship" : "Turn on Autoship", - systemImage: summary.autoShip ? "shippingbox.fill" : "shippingbox") - } + Button { store.setArchived(sessionID, !summary.archived) } label: { + Label(summary.archived ? "Unarchive" : "Archive", + systemImage: summary.archived ? "tray.and.arrow.up" : "archivebox") } - if summary.status == .running { - Button { store.interrupt(sessionID) } label: { Label("Interrupt", systemImage: "stop.circle") } - } - Section { - Button { showIntegrate = true } label: { Label("Integrate…", systemImage: "arrow.triangle.merge") } - Button(role: .destructive) { showDiscard = true } label: { - Label("Discard…", systemImage: "xmark.bin") - } - Button { store.setArchived(sessionID, !summary.archived) } label: { - Label(summary.archived ? "Unarchive" : "Archive", - systemImage: summary.archived ? "tray.and.arrow.up" : "archivebox") - } - } - Button(role: .destructive) { store.deleteSession(sessionID) } label: { - Label("Delete", systemImage: "trash") - } - } label: { - Image(systemName: "ellipsis.circle") + } + Button(role: .destructive) { store.deleteSession(sessionID) } label: { + Label("Delete", systemImage: "trash") } } From 2978908ec6d026f934591d1b08b6896fc5e8ed69 Mon Sep 17 00:00:00 2001 From: Andrew Blakeslee Moore Date: Sun, 5 Jul 2026 01:13:59 -0700 Subject: [PATCH 077/143] Chat View Refinement Nucleic-Session: 4E56990A-96AE-4597-9140-753CB82E769D Co-authored-by: Nucleic --- NucleicRemote/NucleicRemote/Views/SessionDetailView.swift | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift b/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift index c7854bc..e41034a 100644 --- a/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift +++ b/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift @@ -248,11 +248,13 @@ struct SessionDetailView: View { if store.canControl, let summary { controlRow(summary) } if canCompose { HStack(alignment: .bottom, spacing: 8) { + // No keyboard-accessory Done button here (it floats awkwardly + // over the glass bar on iOS 26) — a drag on the transcript + // dismisses the keyboard instead (`scrollDismissesKeyboard`). TextField(running ? "Queue a follow-up…" : "Send a follow-up…", text: $draft, axis: .vertical) .textFieldStyle(.plain) .lineLimit(1...4) - .keyboardDismissable() .padding(.vertical, 3) Button { store.sendInput(draft, to: sessionID) From ded013af485ae0b2c91f2cb7e2a177dabad10f3b Mon Sep 17 00:00:00 2001 From: Andrew Blakeslee Moore Date: Sun, 5 Jul 2026 01:14:09 -0700 Subject: [PATCH 078/143] Chat View Refinement Nucleic-Session: 4E56990A-96AE-4597-9140-753CB82E769D Co-authored-by: Nucleic --- NucleicRemote/NucleicRemote/Views/SessionDetailView.swift | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift b/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift index e41034a..6bb1d85 100644 --- a/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift +++ b/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift @@ -120,14 +120,16 @@ struct SessionDetailView: View { lockWhenOrchestra: true) Spacer(minLength: 4) if let percent = contextPercent { + // Weight and primary ink (not `.secondary`) so the metric stays legible over + // whatever the Liquid Glass bar is blurring through. Label("\(percent)%", systemImage: "gauge.with.dots.needle.33percent") - .font(.caption2).foregroundStyle(.secondary) + .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") } - .toggleStyle(.button).tint(Palette.accent).font(.caption2) + .toggleStyle(.button).tint(Palette.accent).font(.caption2.weight(.semibold)) } } From 544a92bbd99c552a14b6fcbe3b76f68af02990fc Mon Sep 17 00:00:00 2001 From: Andrew Blakeslee Moore Date: Sun, 5 Jul 2026 01:14:19 -0700 Subject: [PATCH 079/143] Chat View Refinement Nucleic-Session: 4E56990A-96AE-4597-9140-753CB82E769D Co-authored-by: Nucleic --- NucleicRemote/NucleicRemote/Views/Theme.swift | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/NucleicRemote/NucleicRemote/Views/Theme.swift b/NucleicRemote/NucleicRemote/Views/Theme.swift index 5c2a21f..a603a4d 100644 --- a/NucleicRemote/NucleicRemote/Views/Theme.swift +++ b/NucleicRemote/NucleicRemote/Views/Theme.swift @@ -423,15 +423,17 @@ struct ModelMenu: View { } } } label: { + // Accent icon, primary-ink text: an all-accent caption washes out on the Liquid + // Glass chat bar, so only the glyph carries the tint. HStack(spacing: 4) { - Image(systemName: "cpu") + Image(systemName: "cpu").foregroundStyle(Palette.accent) Text(label) if let badge = model.flatMap({ catalog.contextBadge($0) }) { Text(badge).foregroundStyle(.secondary) } } - .font(.caption) - .foregroundStyle(Palette.accent) + .font(.caption.weight(.semibold)) + .foregroundStyle(.primary) } .disabled(groups.isEmpty) } From ab07a6fc99e9b44963a7dda9e01c4c3992e029dd Mon Sep 17 00:00:00 2001 From: Andrew Blakeslee Moore Date: Sun, 5 Jul 2026 01:14:29 -0700 Subject: [PATCH 080/143] Chat View Refinement Nucleic-Session: 4E56990A-96AE-4597-9140-753CB82E769D Co-authored-by: Nucleic --- NucleicRemote/NucleicRemote/Views/Theme.swift | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/NucleicRemote/NucleicRemote/Views/Theme.swift b/NucleicRemote/NucleicRemote/Views/Theme.swift index a603a4d..8406b5f 100644 --- a/NucleicRemote/NucleicRemote/Views/Theme.swift +++ b/NucleicRemote/NucleicRemote/Views/Theme.swift @@ -497,12 +497,15 @@ struct EffortMenu: View { } .disabled(!controlled) } label: { + // Same treatment as `ModelMenu`: tinted glyph, primary-ink text for glass-bar + // legibility. Orchestra keeps its full-gold signature — that state should glow. HStack(spacing: 4) { Image(systemName: orchestraActive ? "sparkles" : "slider.horizontal.3") + .foregroundStyle(orchestraActive ? Palette.orchestra : Palette.accent) Text(display(triggerEffort)) } - .font(.caption) - .foregroundStyle(orchestraActive ? Palette.orchestra : Palette.accent) + .font(.caption.weight(.semibold)) + .foregroundStyle(orchestraActive ? AnyShapeStyle(Palette.orchestra) : AnyShapeStyle(.primary)) } } } From 1a14d5259410bd8cc9c23ec015034f67ff3d2255 Mon Sep 17 00:00:00 2001 From: Andrew Blakeslee Moore Date: Sun, 5 Jul 2026 01:14:38 -0700 Subject: [PATCH 081/143] Chat View Refinement Nucleic-Session: 4E56990A-96AE-4597-9140-753CB82E769D 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 6bb1d85..b024334 100644 --- a/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift +++ b/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift @@ -242,7 +242,7 @@ struct SessionDetailView: View { if running { HStack(spacing: 8) { ProgressView().controlSize(.small) - Text("Agent is working…").font(.footnote).foregroundStyle(.secondary) + Text("Agent is working…").font(.footnote).foregroundStyle(.primary) Spacer() } .frame(maxWidth: .infinity, alignment: .leading) From 3939bf4c60c10a5a2f25f04d7ffd02e404dbfca0 Mon Sep 17 00:00:00 2001 From: Andrew Blakeslee Moore Date: Sun, 5 Jul 2026 01:14:51 -0700 Subject: [PATCH 082/143] Chat View Refinement Nucleic-Session: 4E56990A-96AE-4597-9140-753CB82E769D 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 b024334..f4f7752 100644 --- a/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift +++ b/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift @@ -367,7 +367,7 @@ struct TranscriptList: View { } } -/// The Diff tab, read-only over the on-demand `WireSessionDiff`. On a phone it's a per-file +/// The diff sheet's content, read-only over the on-demand `WireSessionDiff`. On a phone it's a per-file /// summary over one scrolling unified patch; on a regular-width iPad it becomes the Mac's /// two-pane diff — a selectable file list beside the selected file's patch — exploiting the /// wide detail column. Same wire, no new protocol. From 0544fd93efb95971d4087392404b5d8a5bb92686 Mon Sep 17 00:00:00 2001 From: Andrew Blakeslee Moore Date: Sun, 5 Jul 2026 01:51:28 -0700 Subject: [PATCH 083/143] Sessions Integration Feature Nucleic-Session: FC94353F-9F0D-4BCC-AD7C-0FB14E38C2E5 Co-authored-by: Nucleic --- NucleicRemote/NucleicRemote/Models/RemoteStore.swift | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/NucleicRemote/NucleicRemote/Models/RemoteStore.swift b/NucleicRemote/NucleicRemote/Models/RemoteStore.swift index df9c481..b4d4f09 100644 --- a/NucleicRemote/NucleicRemote/Models/RemoteStore.swift +++ b/NucleicRemote/NucleicRemote/Models/RemoteStore.swift @@ -38,11 +38,11 @@ final class RemoteStore: ObservableObject { } @Published private(set) var connectivity: Connectivity = .unpaired + /// A representative host name for the aggregate — the open session's Mac when a transcript is + /// open, otherwise a live host. Sessions/projects from *every* connected Mac are merged into the + /// flat state above (mesh P3), so no single "active host" is chosen; per-row provenance comes + /// from `hostName(forSession:)` instead. @Published private(set) var hostName: String = "" - /// The paired Mac this connection currently reflects (mesh P3), by its `HostID` (fingerprint). - /// `hostName`/`sessions`/etc. above are that host's projection; switching hosts re-points them - /// (live: reconnect to a different paired Mac; demo: swap the mock host). `nil` until connected. - @Published private(set) var activeHostID: String? @Published private(set) var sessions: [WireSessionSummary] = [] @Published private(set) var capabilities = WireCapabilities(canModifyToolInput: false, allowAlwaysScopes: []) @Published private(set) var grantedScope: DeviceScope = .approve From 87674f3d4257207f1e67ded794bcf9b3d88e0bed Mon Sep 17 00:00:00 2001 From: Andrew Blakeslee Moore Date: Sun, 5 Jul 2026 01:51:32 -0700 Subject: [PATCH 084/143] Sessions Integration Feature Nucleic-Session: FC94353F-9F0D-4BCC-AD7C-0FB14E38C2E5 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 b4d4f09..72acc23 100644 --- a/NucleicRemote/NucleicRemote/Models/RemoteStore.swift +++ b/NucleicRemote/NucleicRemote/Models/RemoteStore.swift @@ -60,6 +60,10 @@ final class RemoteStore: ObservableObject { // Open session projection. @Published private(set) var openSessionID: SessionID? + /// The paired Mac that owns the open session (mesh P3). The open transcript's intents route to + /// it, and the host-specific projected values (capabilities/scope/catalog/connectivity) follow + /// it while a session is open. `nil` when nothing is open. + private var openSessionHostID: String? @Published private(set) var openEvents: [AgentEvent] = [] @Published private(set) var openApprovals: [ApprovalRequest] = [] /// The open session's full diff (the Mac Diff tab's patch), fetched on demand when the From 90104be78871b4abb1c98959f37b9998fb71a76f Mon Sep 17 00:00:00 2001 From: Andrew Blakeslee Moore Date: Sun, 5 Jul 2026 01:51:49 -0700 Subject: [PATCH 085/143] Sessions Integration Feature Nucleic-Session: FC94353F-9F0D-4BCC-AD7C-0FB14E38C2E5 Co-authored-by: Nucleic --- .../NucleicRemote/Models/RemoteStore.swift | 56 +++++++++++++++++-- 1 file changed, 50 insertions(+), 6 deletions(-) diff --git a/NucleicRemote/NucleicRemote/Models/RemoteStore.swift b/NucleicRemote/NucleicRemote/Models/RemoteStore.swift index 72acc23..c9c63d0 100644 --- a/NucleicRemote/NucleicRemote/Models/RemoteStore.swift +++ b/NucleicRemote/NucleicRemote/Models/RemoteStore.swift @@ -184,13 +184,57 @@ final class RemoteStore: ObservableObject { /// Proactive network-path awareness (shared across every host connection): flips transports the /// instant Wi-Fi drops or returns, instead of waiting for a dead LAN socket to time out. let pathMonitor = NetworkPathMonitor() - /// One live connection per paired Mac (mesh P3 multiplexer). All connected at once; the flat - /// `sessions`/`connectivity`/`capabilities`/… above mirror the *active* one (`activeHostID`), so - /// switching hosts is instant (no reconnect). Empty in demo mode (demo seeds state directly). + /// One live connection per paired Mac (mesh P3 multiplexer). All connected at once, and the flat + /// `sessions`/`dashboard` above are the *merged* projection across every one of them — the phone + /// shows all your Macs together, with no active-host selector. Intents route to the Mac that owns + /// the target session/project (see `send`). Empty in demo mode (demo seeds state directly). private var connections: [String: HostConnection] = [:] - /// The active connection — the Mac whose projection the flat state reflects + whose transcript - /// is open. - private var activeConnection: HostConnection? { activeHostID.flatMap { connections[$0] } } + + /// The connection whose projection drives the host-specific flat values (capabilities, scope, + /// catalog, connectivity, host name): the open session's Mac while a transcript is open, else a + /// representative live host (one with a populated catalog, falling back to any live/any host). + private var contextConnection: HostConnection? { + if let id = openSessionHostID, let conn = connections[id] { return conn } + return connections.values.first { $0.connectivity.isLive && !$0.modelCatalog.groups.isEmpty } + ?? connections.values.first { $0.connectivity.isLive } + ?? connections.values.first + } + + /// A live connection to fall back on for host-agnostic intents (e.g. a project-less to-do + /// capture, which has no owning Mac). + private var firstLiveConnection: HostConnection? { + connections.values.first { $0.connectivity.isLive } ?? connections.values.first + } + + // MARK: Intent routing (mesh P3) — resolve the Mac that owns a session / project / to-do. + + private func connection(owningSession id: SessionID) -> HostConnection? { + connections.values.first { $0.sessions.contains { $0.sessionID == id } } + } + private func connection(owningProject id: ProjectID) -> HostConnection? { + connections.values.first { $0.dashboard.projects.contains { $0.id == id } } + } + private func connection(owningTodo id: TodoID) -> HostConnection? { + connections.values.first { $0.dashboard.todos.contains { $0.id == id } } + } + /// Send to every live connection — for host-agnostic pulls (list sessions/dashboard) and as the + /// safety fallback for an approval whose owning Mac isn't known (a notification-driven decision). + private func broadcastLive(_ msg: ClientMsg) { + for conn in connections.values where conn.connectivity.isLive { conn.send(msg) } + } + + /// How many Macs are currently connected — the Sessions list shows a per-row host tag only when + /// more than one, so a single-Mac view stays clean. + var connectedHostCount: Int { + if demoMode { return 1 } + return connections.values.filter { $0.connectivity.isLive }.count + } + + /// The name of the Mac that owns a session, for the per-row host tag. `nil` in demo (no + /// connections) or when the session isn't found on any connected Mac. + func hostName(forSession id: SessionID) -> String? { + connection(owningSession: id)?.hostName + } /// The phone's embedded Tailscale node state, for Settings (nil = not running). Shared across all /// connections (one embedded node, many dials). @Published private(set) var tailnetStatus: String? From af8deb63f7e04072029ae57e0ddd848d4186ee8a Mon Sep 17 00:00:00 2001 From: Andrew Blakeslee Moore Date: Sun, 5 Jul 2026 01:52:20 -0700 Subject: [PATCH 086/143] Sessions Integration Feature Nucleic-Session: FC94353F-9F0D-4BCC-AD7C-0FB14E38C2E5 Co-authored-by: Nucleic --- .../NucleicRemote/Models/RemoteStore.swift | 69 ------------------- 1 file changed, 69 deletions(-) diff --git a/NucleicRemote/NucleicRemote/Models/RemoteStore.swift b/NucleicRemote/NucleicRemote/Models/RemoteStore.swift index c9c63d0..097d046 100644 --- a/NucleicRemote/NucleicRemote/Models/RemoteStore.swift +++ b/NucleicRemote/NucleicRemote/Models/RemoteStore.swift @@ -255,75 +255,6 @@ final class RemoteStore: ObservableObject { /// In-flight simulated-run tasks (canned streaming), cancelled on `exitDemo()`. private var demoTasks: [Task] = [] - // MARK: - Multi-host switching (mesh P3) - - /// One selectable Mac for the host switcher — the paired Macs (live) or the mock hosts (demo). - struct HostChoice: Identifiable, Equatable { - let id: String // HostID (fingerprint), or a demo id - let name: String - let isActive: Bool - } - - /// The Macs this phone can switch between. Live: the paired-host registry. Demo: the mock hosts. - /// One entry ⇒ the switcher hides itself and the app reads single-host. Today the phone holds one - /// *live* connection at a time (switching reconnects); simultaneous connections are the deferred - /// multiplexer step. - var hostChoices: [HostChoice] { - if demoMode { - return demoHosts.map { HostChoice(id: $0.id, name: $0.name, isActive: $0.id == activeHostID) } - } - return IdentityStore.pairedHosts().map { - HostChoice(id: $0.fingerprint, name: $0.hostName, isActive: $0.fingerprint == activeHostID) - } - } - - /// Switch which paired Mac is active. Live: every Mac is already connected, so this is instant — - /// close the current transcript, re-point at the target connection, and mirror its state. Demo: - /// swap the mock host's projection, preserving in-demo edits. - func switchHost(to id: String) { - guard id != activeHostID else { return } - if demoMode { - if let i = demoHosts.firstIndex(where: { $0.id == activeHostID }) { - demoHosts[i].sessions = sessions // keep any in-demo mutations - demoHosts[i].dashboard = dashboard - } - guard let next = demoHosts.first(where: { $0.id == id }) else { return } - applyDemoHost(next) - } else { - // Close the transcript open on the old host. - activeConnection?.openSessionID = nil - openSessionID = nil; openEvents = []; openApprovals = []; openDiff = nil; diffLoading = false - activeHostID = id - // The target should already be connected; (re)dial only if it isn't. - if let host = IdentityStore.pairedHost(id: id) { - let conn = connection(for: host) - if !conn.connectivity.isLive { conn.reconnect(to: host) } - } - mirrorActive() - } - } - - /// Mock hosts for the multi-host demo. Each carries its own name + sessions + dashboard; - /// switching swaps the flat active-host projection. Empty outside demo. - private struct DemoHost { - let id: String - let name: String - var sessions: [WireSessionSummary] - var dashboard: DashboardSnapshot - } - private var demoHosts: [DemoHost] = [] - - /// Project a mock host onto the flat active-host state (demo host switch). - private func applyDemoHost(_ host: DemoHost) { - activeHostID = host.id - hostName = host.name - sessions = host.sessions - dashboard = host.dashboard - openSessionID = nil; openEvents = []; openApprovals = []; openDiff = nil - LiveActivityManager.shared.sync(hostName: hostName, sessions: sessions) - NotificationRouter.shared.updateBadge(needsYouCount) - } - var isPaired: Bool { demoMode || IdentityStore.loadPairedHost() != nil } var deviceFingerprint: String { identity.fingerprint } From 12931e52afd67f5f387d7c71f8b7ae648fa928cf Mon Sep 17 00:00:00 2001 From: Andrew Blakeslee Moore Date: Sun, 5 Jul 2026 01:52:33 -0700 Subject: [PATCH 087/143] Sessions Integration Feature Nucleic-Session: FC94353F-9F0D-4BCC-AD7C-0FB14E38C2E5 Co-authored-by: Nucleic --- NucleicRemote/NucleicRemote/Models/RemoteStore.swift | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/NucleicRemote/NucleicRemote/Models/RemoteStore.swift b/NucleicRemote/NucleicRemote/Models/RemoteStore.swift index 097d046..fcb7a50 100644 --- a/NucleicRemote/NucleicRemote/Models/RemoteStore.swift +++ b/NucleicRemote/NucleicRemote/Models/RemoteStore.swift @@ -390,11 +390,13 @@ final class RemoteStore: ObservableObject { fiveHour: WireUsageWindow(utilization: 18, resetsAt: Date().addingTimeInterval(2 * 3600)), sevenDay: WireUsageWindow(utilization: 40, resetsAt: nil)), statusFeeds: []) - demoHosts = [ - DemoHost(id: "demo-1", name: "Andrew's Mac", sessions: host1Sessions, dashboard: host1Dashboard), - DemoHost(id: "demo-2", name: "Studio Mac", sessions: host2Sessions, dashboard: host2Dashboard), - ] - applyDemoHost(demoHosts[0]) + // Mesh P3: seed BOTH demo Macs' worlds merged into the flat aggregate, exactly as the app + // presents real paired Macs — every host's sessions/projects/to-dos shown together, no + // switcher. `demoHandle` then mutates this aggregate directly for the interactive demo. + sessions = host1Sessions + host2Sessions + dashboard = DashboardSnapshot.merged([host1Dashboard, host2Dashboard]) + LiveActivityManager.shared.sync(hostName: hostName, sessions: liveSessions) + NotificationRouter.shared.updateBadge(needsYouCount) } /// Offline diff fixture (NUCLEIC_DEMO) so the full-patch Diff tab renders without a host. From 7f158904162b669e4a67d3d37998d827f9d1ffac Mon Sep 17 00:00:00 2001 From: Andrew Blakeslee Moore Date: Sun, 5 Jul 2026 01:52:41 -0700 Subject: [PATCH 088/143] Sessions Integration Feature Nucleic-Session: FC94353F-9F0D-4BCC-AD7C-0FB14E38C2E5 Co-authored-by: Nucleic --- NucleicRemote/NucleicRemote/Models/RemoteStore.swift | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/NucleicRemote/NucleicRemote/Models/RemoteStore.swift b/NucleicRemote/NucleicRemote/Models/RemoteStore.swift index fcb7a50..e957e25 100644 --- a/NucleicRemote/NucleicRemote/Models/RemoteStore.swift +++ b/NucleicRemote/NucleicRemote/Models/RemoteStore.swift @@ -442,8 +442,7 @@ final class RemoteStore: ObservableObject { connections[id]?.teardown() let conn = makeConnection(hostID: id, hostName: payload.hostName) connections[id] = conn - activeHostID = id - mirrorActive() + rebuildAggregate() conn.pair(with: payload) } From 5852ce04e7500e7683df28fc165bb74641906e26 Mon Sep 17 00:00:00 2001 From: Andrew Blakeslee Moore Date: Sun, 5 Jul 2026 01:52:57 -0700 Subject: [PATCH 089/143] Sessions Integration Feature Nucleic-Session: FC94353F-9F0D-4BCC-AD7C-0FB14E38C2E5 Co-authored-by: Nucleic --- .../NucleicRemote/Models/RemoteStore.swift | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/NucleicRemote/NucleicRemote/Models/RemoteStore.swift b/NucleicRemote/NucleicRemote/Models/RemoteStore.swift index e957e25..710cd6c 100644 --- a/NucleicRemote/NucleicRemote/Models/RemoteStore.swift +++ b/NucleicRemote/NucleicRemote/Models/RemoteStore.swift @@ -461,13 +461,14 @@ final class RemoteStore: ObservableObject { } /// The callbacks one `HostConnection` uses to drive shared/aggregate state. `hostID` is captured - /// so a change updates the flat projection only for the active host, while badges, notifications, - /// and the single open transcript are handled globally across hosts. + /// so the open-transcript forwarding fires only for the Mac that owns the open session, while the + /// merged list/dashboard, badges, and notifications are rebuilt across every host on any change. private func callbacks(for hostID: String) -> HostConnection.Callbacks { var cb = HostConnection.Callbacks() cb.didUpdate = { [weak self] in guard let self else { return } - if hostID == self.activeHostID { self.mirrorActive() } + // Any host's change re-merges the aggregate the flat state binds to (mesh P3). + self.rebuildAggregate() self.refreshAggregate() self.flushPendingNotificationDecision() // A host that just connected (or reconnected) needs the current Live Activity token @@ -475,22 +476,22 @@ final class RemoteStore: ObservableObject { self.syncLiveActivityRegistration() } cb.openSnapshot = { [weak self] snap in - guard let self, hostID == self.activeHostID, snap.summary.sessionID == self.openSessionID else { return } + guard let self, hostID == self.openSessionHostID, snap.summary.sessionID == self.openSessionID else { return } self.openEvents = snap.recentEvents self.openApprovals = snap.pendingApprovals } cb.openEvents = { [weak self] batch in - guard let self, hostID == self.activeHostID, batch.sessionID == self.openSessionID else { return } + guard let self, hostID == self.openSessionHostID, batch.sessionID == self.openSessionID else { return } self.openEvents.append(contentsOf: batch.events) } cb.openDiff = { [weak self] diff in - guard let self, hostID == self.activeHostID, diff.sessionID == self.openSessionID else { return } + guard let self, hostID == self.openSessionHostID, diff.sessionID == self.openSessionID else { return } self.openDiff = diff self.diffLoading = false } cb.approvalRequested = { [weak self] req, title in guard let self else { return } - if hostID == self.activeHostID, req.sessionID == self.openSessionID, + if hostID == self.openSessionHostID, req.sessionID == self.openSessionID, !self.openApprovals.contains(where: { $0.id == req.id }) { self.openApprovals.append(req) } From 6316e7d6b55b7f727615eb4a4fdf94eac2cc0160 Mon Sep 17 00:00:00 2001 From: Andrew Blakeslee Moore Date: Sun, 5 Jul 2026 01:53:01 -0700 Subject: [PATCH 090/143] Sessions Integration Feature Nucleic-Session: FC94353F-9F0D-4BCC-AD7C-0FB14E38C2E5 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 710cd6c..bfaa22c 100644 --- a/NucleicRemote/NucleicRemote/Models/RemoteStore.swift +++ b/NucleicRemote/NucleicRemote/Models/RemoteStore.swift @@ -513,7 +513,7 @@ final class RemoteStore: ObservableObject { cb.didPair = { [weak self] host in guard let self else { return } IdentityStore.savePairedHost(host) - self.activeHostID = host.fingerprint + self.rebuildAggregate() } cb.meshRosterChanged = { [weak self] in // Mesh "join": a Mac was learned or revoked via gossip. Reconnect to every paired Mac From a0dd6f6a7b774ee84749bd9e0e4c48eb8c56fec1 Mon Sep 17 00:00:00 2001 From: Andrew Blakeslee Moore Date: Sun, 5 Jul 2026 01:53:43 -0700 Subject: [PATCH 091/143] Sessions Integration Feature Nucleic-Session: FC94353F-9F0D-4BCC-AD7C-0FB14E38C2E5 Co-authored-by: Nucleic --- .../NucleicRemote/Models/RemoteStore.swift | 74 ++++++++++++++----- 1 file changed, 54 insertions(+), 20 deletions(-) diff --git a/NucleicRemote/NucleicRemote/Models/RemoteStore.swift b/NucleicRemote/NucleicRemote/Models/RemoteStore.swift index bfaa22c..31c7870 100644 --- a/NucleicRemote/NucleicRemote/Models/RemoteStore.swift +++ b/NucleicRemote/NucleicRemote/Models/RemoteStore.swift @@ -527,32 +527,66 @@ final class RemoteStore: ObservableObject { return cb } - /// Mirror the active connection's projection onto the flat @Published state the UI binds to. - private func mirrorActive() { - guard let c = activeConnection else { return } - connectivity = c.connectivity - hostName = c.hostName - sessions = c.sessions - capabilities = c.capabilities - grantedScope = c.grantedScope - modelCatalog = c.modelCatalog - dashboard = c.dashboard - meshPeers = c.meshPeers + /// Re-merge every connected Mac's projection into the flat @Published state the UI binds to + /// (mesh P3): all hosts' sessions and dashboards shown together, with the host-specific values + /// (host name, capabilities, catalog, connectivity, scope) following the open session's Mac — + /// or a representative one. No-op in demo, which seeds the aggregate directly. + private func rebuildAggregate() { + guard !demoMode else { return } + sessions = aggregatedSessions() + dashboard = DashboardSnapshot.merged(connections.values.map(\.dashboard)) + meshPeers = connections.values.flatMap(\.meshPeers) + + let ctx = contextConnection + hostName = ctx?.hostName ?? "" + capabilities = ctx?.capabilities + ?? WireCapabilities(canModifyToolInput: false, allowAlwaysScopes: []) + modelCatalog = ctx?.modelCatalog ?? .empty + + if let id = openSessionHostID, let conn = connections[id] { + // While a transcript is open, the composer/controls act on *that* Mac — its connectivity + // gates send and its scope drives the control affordances. + connectivity = conn.connectivity + grantedScope = conn.grantedScope + } else { + connectivity = aggregateConnectivity() + // Optimistic new-chat gating: enabled if *any* Mac grants control (the owning Mac still + // enforces scope when the intent lands there). + grantedScope = connections.values + .filter { $0.connectivity.isLive }.map(\.grantedScope).max() ?? .approve + } } - /// Every connected Mac's live (non-archived) sessions — the aggregate the app-icon badge and - /// Live Activity summarize across hosts. - private var allLiveSessions: [WireSessionSummary] { - if demoMode { return demoHosts.flatMap(\.sessions).filter { !$0.archived } } - return connections.values.flatMap(\.sessions).filter { !$0.archived } + /// The flat sessions list: every connected Mac's sessions, deduped by id (ids are globally + /// unique). Views handle sorting/grouping. + private func aggregatedSessions() -> [WireSessionSummary] { + var seen = Set() + var result: [WireSessionSummary] = [] + for conn in connections.values { + for summary in conn.sessions where seen.insert(summary.sessionID).inserted { + result.append(summary) + } + } + return result + } + + /// One connectivity value for the whole app when no transcript is open (the chip + global + /// gating): connected if any Mac is live, else the most-informative in-progress/offline state. + private func aggregateConnectivity() -> Connectivity { + let all = connections.values.map(\.connectivity) + guard !all.isEmpty else { return .unpaired } + if let live = all.first(where: { $0.isLive }) { return live } + if all.contains(where: { if case .connecting = $0 { return true } else { return false } }) { return .connecting } + if all.contains(where: { if case .reconnecting = $0 { return true } else { return false } }) { return .reconnecting } + if all.contains(.hostOffline) { return .hostOffline } + return all.first ?? .unpaired } /// Refresh cross-host aggregates: the app-icon badge (needs-you across *all* Macs) + the Live - /// Activity summary. + /// Activity summary. `sessions`/`liveSessions` are already the merged aggregate. private func refreshAggregate() { - let needs = allLiveSessions.filter { $0.status.needsYou($0.disposition) }.count - NotificationRouter.shared.updateBadge(needs) - LiveActivityManager.shared.sync(hostName: hostName, sessions: allLiveSessions) + NotificationRouter.shared.updateBadge(needsYouCount) + LiveActivityManager.shared.sync(hostName: hostName, sessions: liveSessions) } /// Tear down every live connection (leaving the paired registry intact) — for entering demo. From bf0d0a587eba0e7f1d91e661f9bea87a22ab8525 Mon Sep 17 00:00:00 2001 From: Andrew Blakeslee Moore Date: Sun, 5 Jul 2026 01:54:01 -0700 Subject: [PATCH 092/143] Sessions Integration Feature Nucleic-Session: FC94353F-9F0D-4BCC-AD7C-0FB14E38C2E5 Co-authored-by: Nucleic --- NucleicRemote/NucleicRemote/Models/RemoteStore.swift | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/NucleicRemote/NucleicRemote/Models/RemoteStore.swift b/NucleicRemote/NucleicRemote/Models/RemoteStore.swift index 31c7870..5ab7880 100644 --- a/NucleicRemote/NucleicRemote/Models/RemoteStore.swift +++ b/NucleicRemote/NucleicRemote/Models/RemoteStore.swift @@ -609,10 +609,7 @@ final class RemoteStore: ObservableObject { let conn = connection(for: host) if !conn.connectivity.isLive { conn.reconnect(to: host) } } - if activeHostID == nil || connections[activeHostID!] == nil { - activeHostID = hosts.last?.fingerprint - } - mirrorActive() + rebuildAggregate() refreshAggregate() } From e255ad18ba99cbc454e9b48604b80f4962b09d89 Mon Sep 17 00:00:00 2001 From: Andrew Blakeslee Moore Date: Sun, 5 Jul 2026 01:54:13 -0700 Subject: [PATCH 093/143] Sessions Integration Feature Nucleic-Session: FC94353F-9F0D-4BCC-AD7C-0FB14E38C2E5 Co-authored-by: Nucleic --- .../NucleicRemote/Models/RemoteStore.swift | 41 ++++++++++++------- 1 file changed, 27 insertions(+), 14 deletions(-) diff --git a/NucleicRemote/NucleicRemote/Models/RemoteStore.swift b/NucleicRemote/NucleicRemote/Models/RemoteStore.swift index 5ab7880..ed11a52 100644 --- a/NucleicRemote/NucleicRemote/Models/RemoteStore.swift +++ b/NucleicRemote/NucleicRemote/Models/RemoteStore.swift @@ -614,25 +614,38 @@ final class RemoteStore: ObservableObject { } + /// Unpair this device entirely — drop every paired Mac and its connection (the "Unpair this + /// device" button). Nothing left to show, so the app returns to the pairing intro. func unpair() { - // Forget + drop the active Mac's connection specifically (the switcher may have made it not - // the last). Other Macs' connections keep running. - if let id = activeHostID { - connections[id]?.teardown() - connections[id] = nil - IdentityStore.removePairedHost(id: id) + for (id, conn) in connections { conn.teardown(); connections[id] = nil } + for host in IdentityStore.pairedHosts() { IdentityStore.removePairedHost(id: host.fingerprint) } + IdentityStore.clearPairedHost() + finishUnpairIfEmpty() + } + + /// Forget one paired Mac (the Settings ▸ Mesh per-row remove): drop just its connection + + /// registry record. Other Macs keep running and stay in the merged view. + func unpair(_ hostID: String) { + connections[hostID]?.teardown() + connections[hostID] = nil + IdentityStore.removePairedHost(id: hostID) + if IdentityStore.pairedHosts().isEmpty { + finishUnpairIfEmpty() } else { - IdentityStore.clearPairedHost() - } - activeHostID = nil - // Mesh P3: if other Macs remain paired, switch to one (already connected) rather than unpairing. - if let next = IdentityStore.pairedHosts().last { - activeHostID = next.fingerprint - reconnect() - return + rebuildAggregate() + refreshAggregate() } + } + + /// Wind the app back to the unpaired state once no Macs remain: clear the open transcript + flat + /// aggregate and spin the embedded Tailscale node down. + private func finishUnpairIfEmpty() { + openSessionID = nil + openSessionHostID = nil + openEvents = []; openApprovals = []; openDiff = nil; diffLoading = false connectivity = .unpaired sessions = [] + dashboard = .empty // Nothing left to dial — spin the embedded Tailscale node down if it was running. Task { await TailnetNode.shared.stop() } tailnetStatus = nil From 62d5cfb3b1ad10e09f99638b29c72a162817efc3 Mon Sep 17 00:00:00 2001 From: Andrew Blakeslee Moore Date: Sun, 5 Jul 2026 01:54:22 -0700 Subject: [PATCH 094/143] Sessions Integration Feature Nucleic-Session: FC94353F-9F0D-4BCC-AD7C-0FB14E38C2E5 Co-authored-by: Nucleic --- NucleicRemote/NucleicRemote/Models/RemoteStore.swift | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/NucleicRemote/NucleicRemote/Models/RemoteStore.swift b/NucleicRemote/NucleicRemote/Models/RemoteStore.swift index ed11a52..5d89059 100644 --- a/NucleicRemote/NucleicRemote/Models/RemoteStore.swift +++ b/NucleicRemote/NucleicRemote/Models/RemoteStore.swift @@ -670,9 +670,8 @@ final class RemoteStore: ObservableObject { UserDefaults.standard.set(false, forKey: Self.demoModeKey) demoTasks.forEach { $0.cancel() } demoTasks.removeAll() - demoHosts = [] - activeHostID = nil openSessionID = nil + openSessionHostID = nil openEvents = [] openApprovals = [] openDiff = nil From 36e6725434d54628693a1765ce880d8194008b34 Mon Sep 17 00:00:00 2001 From: Andrew Blakeslee Moore Date: Sun, 5 Jul 2026 01:54:40 -0700 Subject: [PATCH 095/143] Sessions Integration Feature Nucleic-Session: FC94353F-9F0D-4BCC-AD7C-0FB14E38C2E5 Co-authored-by: Nucleic --- NucleicRemote/NucleicRemote/Models/RemoteStore.swift | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/NucleicRemote/NucleicRemote/Models/RemoteStore.swift b/NucleicRemote/NucleicRemote/Models/RemoteStore.swift index 5d89059..ab110a0 100644 --- a/NucleicRemote/NucleicRemote/Models/RemoteStore.swift +++ b/NucleicRemote/NucleicRemote/Models/RemoteStore.swift @@ -691,15 +691,19 @@ final class RemoteStore: ObservableObject { func open(_ sessionID: SessionID) { openSessionID = sessionID + // 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 openEvents = [] openApprovals = [] openDiff = nil diffLoading = false markOpened(sessionID) if demoMode { seedDemoTranscript(sessionID); return } - // The open transcript belongs to the active host; tell its connection so it forwards the - // snapshot/events (and dedupes them), then subscribe. - activeConnection?.openSessionID = 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 + rebuildAggregate() send(.subscribe(Subscribe(sessionID: sessionID, sinceSeq: nil, verbosity: .full))) } From e156ea2e59662a0b7bedfad74fe9b45dbef2cb81 Mon Sep 17 00:00:00 2001 From: Andrew Blakeslee Moore Date: Sun, 5 Jul 2026 01:55:02 -0700 Subject: [PATCH 096/143] Sessions Integration Feature Nucleic-Session: FC94353F-9F0D-4BCC-AD7C-0FB14E38C2E5 Co-authored-by: Nucleic --- .../NucleicRemote/Models/RemoteStore.swift | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/NucleicRemote/NucleicRemote/Models/RemoteStore.swift b/NucleicRemote/NucleicRemote/Models/RemoteStore.swift index ab110a0..ce8e335 100644 --- a/NucleicRemote/NucleicRemote/Models/RemoteStore.swift +++ b/NucleicRemote/NucleicRemote/Models/RemoteStore.swift @@ -787,16 +787,25 @@ final class RemoteStore: ObservableObject { send(.unsubscribe(target)) markOpened(target) // everything up to now has been seen guard openSessionID == target else { return } - activeConnection?.openSessionID = nil + connection(owningSession: target)?.openSessionID = nil openSessionID = nil + openSessionHostID = nil openEvents = [] openApprovals = [] openDiff = nil diffLoading = false + // Context reverts to the aggregate now that no transcript is open. + if !demoMode { rebuildAggregate() } } func respond(_ approval: ApprovalRequest, _ decision: Decision) { - send(.approvalRespond(approval.id, decision)) + // Route to the Mac that owns the approval's session (the approval message carries no + // sessionID of its own). Demo resolves locally. + if demoMode { + demoHandle(.approvalRespond(approval.id, decision)) + } else { + connection(owningSession: approval.sessionID)?.send(.approvalRespond(approval.id, decision)) + } openApprovals.removeAll { $0.id == approval.id } // optimistic dismiss; host confirms } From 4e8f5db2bb4f7fb986c1914e409ce5bd0eaa00a0 Mon Sep 17 00:00:00 2001 From: Andrew Blakeslee Moore Date: Sun, 5 Jul 2026 01:55:25 -0700 Subject: [PATCH 097/143] Sessions Integration Feature Nucleic-Session: FC94353F-9F0D-4BCC-AD7C-0FB14E38C2E5 Co-authored-by: Nucleic --- .../NucleicRemote/Models/RemoteStore.swift | 50 +++++++++++++++++-- 1 file changed, 46 insertions(+), 4 deletions(-) diff --git a/NucleicRemote/NucleicRemote/Models/RemoteStore.swift b/NucleicRemote/NucleicRemote/Models/RemoteStore.swift index ce8e335..10cf465 100644 --- a/NucleicRemote/NucleicRemote/Models/RemoteStore.swift +++ b/NucleicRemote/NucleicRemote/Models/RemoteStore.swift @@ -867,12 +867,54 @@ final class RemoteStore: ObservableObject { // MARK: - Plumbing - /// Route an intent to the active host's connection (mesh P3). Every current intent — subscribe, - /// send-input, approvals, favorite/archive/delete, start-chat — targets a session or project on - /// the Mac whose list is on screen, i.e. the active one. + /// Route an intent to the Mac that owns its target (mesh P3). Sessions/projects/to-dos are shown + /// merged across every connected Mac, so an intent goes to the connection that owns the session, + /// project, or to-do it names — not to a single "active" host. Host-agnostic pulls (list + /// sessions/dashboard) broadcast to every live Mac; a project-less to-do capture (no owner) goes + /// to the first live Mac. Demo has no connections and mutates the seeded aggregate directly. private func send(_ msg: ClientMsg) { if demoMode { demoHandle(msg); return } - activeConnection?.send(msg) + switch msg { + // Session-owning intents → the Mac that has this session. + case .subscribe(let s): + connection(owningSession: s.sessionID)?.send(msg) + case .unsubscribe(let id), .interrupt(let id), .deleteSession(let id), .discard(let id), + .integrate(let id, _), .renameSession(let id, _), .setFavorite(let id, _), + .setArchived(let id, _), .setSessionModel(let id, _), .setSessionEffort(let id, _), + .setSessionAuto(let id, _), .setSessionAutoShip(let id, _), .setSessionShipBranch(let id, _), + .sendInput(let id, _), .cancelQueuedMessage(let id, _), .fetchDiff(let id): + connection(owningSession: id)?.send(msg) + + // Project-owning intents → the Mac that has this project. + case .startChat(let req): + connection(owningProject: req.projectID)?.send(msg) + case .dispatchTodo(_, let projectID): + connection(owningProject: projectID)?.send(msg) + + // To-do-owning intents → the Mac that has this to-do. + case .setTodoStatus(let id, _), .deleteTodo(let id): + connection(owningTodo: id)?.send(msg) + + // A capture with a project goes to that Mac; a project-less one has no owner → first live. + case .captureTodo(let req): + if let pid = req.projectID, let conn = connection(owningProject: pid) { conn.send(msg) } + else { firstLiveConnection?.send(msg) } + + // Approvals carry no sessionID — `respond(_:_:)` routes by the approval's session directly; + // a stray one here (or a notification-driven decision) broadcasts and the owner resolves it. + case .approvalRespond: + broadcastLive(msg) + + // Host-agnostic pulls → every live Mac. + case .listSessions, .listDashboard: + broadcastLive(msg) + + // Never originated from here (connection-internal, or handled by dedicated loops). + case .hello, .ping, .listPeers, .addressUpdate, .meshRoster, + .registerLiveActivity, .endLiveActivity, + .transferOffer, .transferChunk, .transferCommit, .transferCancel, .fetchTranscript: + break + } } // MARK: - Live Activity push registration (UX_IOS §5.3) From e60da7ccb1962424f7fdcdc922536beaf041de22 Mon Sep 17 00:00:00 2001 From: Andrew Blakeslee Moore Date: Sun, 5 Jul 2026 01:55:48 -0700 Subject: [PATCH 098/143] Sessions Integration Feature Nucleic-Session: FC94353F-9F0D-4BCC-AD7C-0FB14E38C2E5 Co-authored-by: Nucleic --- .../NucleicRemote/Models/RemoteStore.swift | 54 +++++++++++++++++++ 1 file changed, 54 insertions(+) diff --git a/NucleicRemote/NucleicRemote/Models/RemoteStore.swift b/NucleicRemote/NucleicRemote/Models/RemoteStore.swift index 10cf465..9aa8d83 100644 --- a/NucleicRemote/NucleicRemote/Models/RemoteStore.swift +++ b/NucleicRemote/NucleicRemote/Models/RemoteStore.swift @@ -1188,6 +1188,60 @@ extension Data { } } +extension DashboardSnapshot { + /// Merge every connected Mac's dashboard into one aggregate projection (mesh P3), so Home, + /// Projects, and To-Dos show all your Macs together: projects and to-dos concatenated, activity + /// summed per day, counts summed (active-days recomputed from the merged activity), subscription + /// usage taken from the first Mac that reports it (a user's Macs share one account, so their + /// windows match — summing would double-count), and provider status feeds unioned by provider + /// (provider status is global, not per-Mac). + static func merged(_ snapshots: some Collection) -> DashboardSnapshot { + let snaps = Array(snapshots) + guard snaps.count != 1 else { return snaps[0] } + guard !snaps.isEmpty else { return .empty } + + let projects = snaps.flatMap(\.projects) + let todos = snaps.flatMap(\.todos) + + // Activity summed per start-of-day. + var byDay: [Date: (count: Int, tokens: Int)] = [:] + for snap in snaps { + for day in snap.activity { + let key = Calendar.current.startOfDay(for: day.day) + var entry = byDay[key] ?? (0, 0) + entry.count += day.count + entry.tokens += day.tokens + byDay[key] = entry + } + } + let activity = byDay + .map { ActivityDay(day: $0.key, count: $0.value.count, tokens: $0.value.tokens) } + .sorted { $0.day < $1.day } + + let counts = DashboardCounts( + projects: projects.count, + chats: snaps.reduce(0) { $0 + $1.counts.chats }, + activeChats: snaps.reduce(0) { $0 + $1.counts.activeChats }, + messages: snaps.reduce(0) { $0 + $1.counts.messages }, + activeDays: activity.filter { $0.count > 0 || $0.tokens > 0 }.count, + tokens: snaps.reduce(0) { $0 + $1.counts.tokens }) + + let usage = snaps.compactMap(\.usage).first + + var seenProviders = Set() + var statusFeeds: [WireStatusFeed] = [] + for snap in snaps { + for feed in snap.statusFeeds where seenProviders.insert(feed.provider).inserted { + statusFeeds.append(feed) + } + } + + return DashboardSnapshot( + counts: counts, activity: activity, projects: projects, + todos: todos, usage: usage, statusFeeds: statusFeeds) + } +} + extension WireSessionSummary { /// A copy with selected fields overridden — the demo simulator's only way to "mutate" a /// summary, whose stored properties are all `let`. Nullable fields use a double optional so a From 9a59f0cdffd7902740267c9ad8d202b0238e186f Mon Sep 17 00:00:00 2001 From: Andrew Blakeslee Moore Date: Sun, 5 Jul 2026 01:55:56 -0700 Subject: [PATCH 099/143] Sessions Integration Feature Nucleic-Session: FC94353F-9F0D-4BCC-AD7C-0FB14E38C2E5 Co-authored-by: Nucleic --- NucleicRemote/NucleicRemote/Models/RemoteStore.swift | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/NucleicRemote/NucleicRemote/Models/RemoteStore.swift b/NucleicRemote/NucleicRemote/Models/RemoteStore.swift index 9aa8d83..e05c020 100644 --- a/NucleicRemote/NucleicRemote/Models/RemoteStore.swift +++ b/NucleicRemote/NucleicRemote/Models/RemoteStore.swift @@ -1195,8 +1195,7 @@ extension DashboardSnapshot { /// usage taken from the first Mac that reports it (a user's Macs share one account, so their /// windows match — summing would double-count), and provider status feeds unioned by provider /// (provider status is global, not per-Mac). - static func merged(_ snapshots: some Collection) -> DashboardSnapshot { - let snaps = Array(snapshots) + static func merged(_ snaps: [DashboardSnapshot]) -> DashboardSnapshot { guard snaps.count != 1 else { return snaps[0] } guard !snaps.isEmpty else { return .empty } From ee47a61f49a91d58d9f57e4352d7848e18681dcd Mon Sep 17 00:00:00 2001 From: Andrew Blakeslee Moore Date: Sun, 5 Jul 2026 01:56:29 -0700 Subject: [PATCH 100/143] Sessions Integration Feature Nucleic-Session: FC94353F-9F0D-4BCC-AD7C-0FB14E38C2E5 Co-authored-by: Nucleic --- .../NucleicRemote/Views/SessionsView.swift | 21 ------------------- 1 file changed, 21 deletions(-) diff --git a/NucleicRemote/NucleicRemote/Views/SessionsView.swift b/NucleicRemote/NucleicRemote/Views/SessionsView.swift index 38be72b..e9c9d37 100644 --- a/NucleicRemote/NucleicRemote/Views/SessionsView.swift +++ b/NucleicRemote/NucleicRemote/Views/SessionsView.swift @@ -79,27 +79,6 @@ struct SessionsView: View { // A notification tap while this tab is already up. .onChange(of: store.pendingRoute) { consumeRoute() } .toolbar { - // Host switcher (mesh P3): pick which paired Mac to view. Hidden with a single Mac. - ToolbarItem(placement: .topBarLeading) { - if store.hostChoices.count > 1 { - Menu { - ForEach(store.hostChoices) { choice in - Button { - store.switchHost(to: choice.id) - } label: { - Label(choice.name, - systemImage: choice.isActive ? "checkmark" : "desktopcomputer") - } - } - } label: { - HStack(spacing: 4) { - Image(systemName: "desktopcomputer") - Text(store.hostName).lineLimit(1) - Image(systemName: "chevron.down").font(.caption2) - } - } - } - } ToolbarItem(placement: .topBarTrailing) { Button { showArchived.toggle() From 440a6f1cdf12f2d4c2be1a1e39eb590ebba69ff7 Mon Sep 17 00:00:00 2001 From: Andrew Blakeslee Moore Date: Sun, 5 Jul 2026 01:56:45 -0700 Subject: [PATCH 101/143] Sessions Integration Feature Nucleic-Session: FC94353F-9F0D-4BCC-AD7C-0FB14E38C2E5 Co-authored-by: Nucleic --- NucleicRemote/NucleicRemote/Views/SessionsView.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/NucleicRemote/NucleicRemote/Views/SessionsView.swift b/NucleicRemote/NucleicRemote/Views/SessionsView.swift index e9c9d37..37c3616 100644 --- a/NucleicRemote/NucleicRemote/Views/SessionsView.swift +++ b/NucleicRemote/NucleicRemote/Views/SessionsView.swift @@ -28,7 +28,7 @@ struct SessionsView: View { "No sessions", systemImage: "square.stack.3d.up", description: Text(store.connectivity.isLive ? "Start a session from Home or the Mac to see it here." - : "Waiting to connect to \(store.hostName)…")) + : "Waiting to connect to your Mac…")) } else { List { ForEach(grouped, id: \.title) { group in From 698717beae74596c917ebb482966055f3bbf2bea Mon Sep 17 00:00:00 2001 From: Andrew Blakeslee Moore Date: Sun, 5 Jul 2026 01:56:51 -0700 Subject: [PATCH 102/143] Sessions Integration Feature Nucleic-Session: FC94353F-9F0D-4BCC-AD7C-0FB14E38C2E5 Co-authored-by: Nucleic --- NucleicRemote/NucleicRemote/Views/SessionsView.swift | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/NucleicRemote/NucleicRemote/Views/SessionsView.swift b/NucleicRemote/NucleicRemote/Views/SessionsView.swift index 37c3616..e42285a 100644 --- a/NucleicRemote/NucleicRemote/Views/SessionsView.swift +++ b/NucleicRemote/NucleicRemote/Views/SessionsView.swift @@ -151,7 +151,13 @@ struct SessionRow: View { systemImage: "arrow.down.forward") .font(.caption).foregroundStyle(.secondary).lineLimit(1) } else if showProjectName { - Text(summary.projectName).font(.caption).foregroundStyle(.secondary) + // With more than one Mac connected, tag the owning Mac so the merged list stays + // distinguishable (mesh P3); a single-Mac view just shows the project. + if store.connectedHostCount > 1, let host = store.hostName(forSession: summary.sessionID) { + Text("\(summary.projectName) · \(host)").font(.caption).foregroundStyle(.secondary) + } else { + Text(summary.projectName).font(.caption).foregroundStyle(.secondary) + } } } Spacer() From 674f7ffcdb4ba088413e6c4126cc07e64a4a6c7e Mon Sep 17 00:00:00 2001 From: Andrew Blakeslee Moore Date: Sun, 5 Jul 2026 01:57:07 -0700 Subject: [PATCH 103/143] Sessions Integration Feature Nucleic-Session: FC94353F-9F0D-4BCC-AD7C-0FB14E38C2E5 Co-authored-by: Nucleic --- .../NucleicRemote/Views/SettingsView.swift | 19 ++++--------------- 1 file changed, 4 insertions(+), 15 deletions(-) diff --git a/NucleicRemote/NucleicRemote/Views/SettingsView.swift b/NucleicRemote/NucleicRemote/Views/SettingsView.swift index 920cd8f..8953b07 100644 --- a/NucleicRemote/NucleicRemote/Views/SettingsView.swift +++ b/NucleicRemote/NucleicRemote/Views/SettingsView.swift @@ -55,9 +55,6 @@ struct SettingsView: View { Section("Connection") { LabeledContent("Status", value: store.connectivity.label) - if !store.hostName.isEmpty { - LabeledContent("Mac", value: store.hostName) - } // What this pairing can dial — the live transport is in Status above. if let host = IdentityStore.loadPairedHost() { LabeledContent("Transports", value: Self.transportsLabel(host)) @@ -66,14 +63,13 @@ struct SettingsView: View { .disabled(!store.isPaired) } - // Mesh P3: every Mac this phone is paired with. Today the connection uses the - // "Active" one (most-recently paired); the multiplexer will connect to all. Removing - // the active Mac unpairs the live connection; removing another just forgets it. + // Mesh P3: every Mac this phone is paired with — all connected at once and shown + // together in the app (no active-host selector). Removing one forgets just that Mac + // and drops its connection; the others keep running. let pairedHosts = IdentityStore.pairedHosts() if !pairedHosts.isEmpty { Section("Mesh") { ForEach(pairedHosts, id: \.fingerprint) { host in - let isActive = host.fingerprint == pairedHosts.last?.fingerprint HStack(spacing: 10) { Image(systemName: "desktopcomputer").foregroundStyle(.secondary) VStack(alignment: .leading, spacing: 2) { @@ -82,15 +78,8 @@ struct SettingsView: View { .font(.footnote.monospaced()).foregroundStyle(.secondary) } Spacer() - if isActive { - Text("Active").font(.caption).foregroundStyle(.secondary) - } Button(role: .destructive) { - if isActive { - store.unpair() - } else { - IdentityStore.removePairedHost(id: host.fingerprint) - } + store.unpair(host.fingerprint) pairedHostsToken = UUID() } label: { Image(systemName: "minus.circle") From 4be9e987fdb752c5951ccd2652bd1bcb36431c3f Mon Sep 17 00:00:00 2001 From: Andrew Blakeslee Moore Date: Sun, 5 Jul 2026 01:57:15 -0700 Subject: [PATCH 104/143] Sessions Integration Feature Nucleic-Session: FC94353F-9F0D-4BCC-AD7C-0FB14E38C2E5 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 8c32dd6..8ece63e 100644 --- a/NucleicRemote/NucleicRemote/Views/AdaptiveRootView.swift +++ b/NucleicRemote/NucleicRemote/Views/AdaptiveRootView.swift @@ -169,7 +169,7 @@ private struct SplitSidebar: View { Section("Sessions") { Text(store.connectivity.isLive ? "Start a session from Home or the Mac to see it here." - : "Waiting to connect to \(store.hostName)…") + : "Waiting to connect to your Mac…") .font(.callout).foregroundStyle(.secondary) } } else { From bd7defa705e1eafae3f05c4ef8c83998f250a012 Mon Sep 17 00:00:00 2001 From: Andrew Blakeslee Moore Date: Sun, 5 Jul 2026 01:57:25 -0700 Subject: [PATCH 105/143] Sessions Integration Feature Nucleic-Session: FC94353F-9F0D-4BCC-AD7C-0FB14E38C2E5 Co-authored-by: Nucleic --- NucleicRemote/NucleicRemote/Views/AdaptiveRootView.swift | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/NucleicRemote/NucleicRemote/Views/AdaptiveRootView.swift b/NucleicRemote/NucleicRemote/Views/AdaptiveRootView.swift index 8ece63e..574c482 100644 --- a/NucleicRemote/NucleicRemote/Views/AdaptiveRootView.swift +++ b/NucleicRemote/NucleicRemote/Views/AdaptiveRootView.swift @@ -203,7 +203,9 @@ private struct SplitSidebar: View { } } .listStyle(.sidebar) - .navigationTitle(store.hostName.isEmpty ? "Nucleic" : store.hostName) + // Every Mac's sessions are merged into this sidebar (mesh P3), so a single host name would + // misrepresent it — title it for the app. + .navigationTitle("Nucleic") .toolbar { ToolbarItem(placement: .primaryAction) { Button { From 23208eeee057bf7d74ecfa46856ba1be91e9f540 Mon Sep 17 00:00:00 2001 From: Andrew Blakeslee Moore Date: Sun, 5 Jul 2026 02:10:57 -0700 Subject: [PATCH 106/143] Chat View Refinement Nucleic-Session: 4E56990A-96AE-4597-9140-753CB82E769D Co-authored-by: Nucleic --- NucleicRemote/NucleicRemote/Views/Theme.swift | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/NucleicRemote/NucleicRemote/Views/Theme.swift b/NucleicRemote/NucleicRemote/Views/Theme.swift index 8406b5f..fa9ff7a 100644 --- a/NucleicRemote/NucleicRemote/Views/Theme.swift +++ b/NucleicRemote/NucleicRemote/Views/Theme.swift @@ -142,7 +142,10 @@ struct ResolvedPalette { static func make(_ mode: ColorVisionMode) -> ResolvedPalette { var p = base(mode) - p.accent = Palette.nightSoft(p.accent) + // The accent is the interactive tint (buttons, menus, toggles, links), not a calm + // status hue — night-softening the already-deep teal made it vanish on dark + // backgrounds, so dark mode brightens it instead. + p.accent = Palette.nightBright(p.accent) p.active = Palette.nightSoft(p.active) p.attention = Palette.nightSoft(p.attention) p.success = Palette.nightSoft(p.success) From 78ca912e39895252a317ef33eaf22bd41e8f3b4b Mon Sep 17 00:00:00 2001 From: Andrew Blakeslee Moore Date: Sun, 5 Jul 2026 02:11:09 -0700 Subject: [PATCH 107/143] Chat View Refinement Nucleic-Session: 4E56990A-96AE-4597-9140-753CB82E769D Co-authored-by: Nucleic --- NucleicRemote/NucleicRemote/Views/Theme.swift | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/NucleicRemote/NucleicRemote/Views/Theme.swift b/NucleicRemote/NucleicRemote/Views/Theme.swift index fa9ff7a..9da8d5b 100644 --- a/NucleicRemote/NucleicRemote/Views/Theme.swift +++ b/NucleicRemote/NucleicRemote/Views/Theme.swift @@ -197,6 +197,17 @@ enum Palette { Color(UIColor { traits in traits.userInterfaceStyle == .dark ? dark : light }) } + /// The inverse of `nightSoft`, for the interactive accent: light mode keeps `base`; dark + /// mode gets a brighter, lightly desaturated variant so the tint stays legible against + /// deep backgrounds and glass instead of sinking into them. + static func nightBright(_ base: Color) -> Color { + let ui = UIColor(base) + var h: CGFloat = 0, s: CGFloat = 0, b: CGFloat = 0, a: CGFloat = 0 + guard ui.getHue(&h, saturation: &s, brightness: &b, alpha: &a) else { return base } + let dark = UIColor(hue: h, saturation: s * 0.72, brightness: max(b, 0.85), alpha: a) + return dynamic(light: ui, dark: dark) + } + /// Light mode keeps `base`; dark mode gets a desaturated, dimmed variant — the same /// night-softening curve as the Mac's `AppPalette.nightSoft`. static func nightSoft(_ base: Color) -> Color { From ec5225fdda27c179ed012e71f89de79a23d63219 Mon Sep 17 00:00:00 2001 From: Andrew Blakeslee Moore Date: Sun, 5 Jul 2026 02:11:43 -0700 Subject: [PATCH 108/143] Chat View Refinement Nucleic-Session: 4E56990A-96AE-4597-9140-753CB82E769D Co-authored-by: Nucleic --- .../Assets.xcassets/AccentColor.colorset/Contents.json | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/NucleicRemote/NucleicRemote/Assets.xcassets/AccentColor.colorset/Contents.json b/NucleicRemote/NucleicRemote/Assets.xcassets/AccentColor.colorset/Contents.json index 8e37716..948deb1 100644 --- a/NucleicRemote/NucleicRemote/Assets.xcassets/AccentColor.colorset/Contents.json +++ b/NucleicRemote/NucleicRemote/Assets.xcassets/AccentColor.colorset/Contents.json @@ -1,4 +1,7 @@ { - "colors" : [ { "color" : { "color-space" : "srgb", "components" : { "red" : "0.118", "green" : "0.565", "blue" : "0.553", "alpha" : "1.000" } }, "idiom" : "universal" } ], + "colors" : [ + { "color" : { "color-space" : "srgb", "components" : { "red" : "0.118", "green" : "0.565", "blue" : "0.553", "alpha" : "1.000" } }, "idiom" : "universal" }, + { "appearances" : [ { "appearance" : "luminosity", "value" : "dark" } ], "color" : { "color-space" : "srgb", "components" : { "red" : "0.366", "green" : "0.850", "blue" : "0.834", "alpha" : "1.000" } }, "idiom" : "universal" } + ], "info" : { "author" : "xcode", "version" : 1 } } From 59a8c1ea32a4ebbabe65bc4816b97391ca791855 Mon Sep 17 00:00:00 2001 From: Andrew Blakeslee Moore Date: Sun, 5 Jul 2026 02:35:51 -0700 Subject: [PATCH 109/143] Fix AskUserQuestion Rendering Nucleic-Session: A563CF12-F6B3-498A-8640-97DFC24F59EF Co-authored-by: Nucleic --- .../Views/AskUserQuestionCardView.swift | 216 ++++++++++++++++++ 1 file changed, 216 insertions(+) create mode 100644 NucleicRemote/NucleicRemote/Views/AskUserQuestionCardView.swift diff --git a/NucleicRemote/NucleicRemote/Views/AskUserQuestionCardView.swift b/NucleicRemote/NucleicRemote/Views/AskUserQuestionCardView.swift new file mode 100644 index 0000000..18e55c5 --- /dev/null +++ b/NucleicRemote/NucleicRemote/Views/AskUserQuestionCardView.swift @@ -0,0 +1,216 @@ +import SwiftUI +import NucleicProtocol + +/// Answer UI for Claude Code's built-in `AskUserQuestion` tool — the mobile mirror of +/// the Mac's `AskUserQuestionBar`. Unlike a permission gate, this *collects* the user's +/// choice per question and folds the selections into the approval reply's `updatedInput` +/// (see `AskUserQuestion`); replying with a plain allow makes the CLI report "the user +/// did not answer the questions", which is exactly the bug the generic `ApprovalCardView` +/// produced here before. +/// +/// Questions are shown one at a time as a wizard — Back/Next between them, Submit on the +/// last — so a multi-question ask reads as a focused sequence rather than one long form. +struct AskUserQuestionCardView: View { + @EnvironmentObject var store: RemoteStore + let approval: ApprovalRequest + let questions: [AskUserQuestion.Question] + + /// Selected option labels per question text. + @State private var selected: [String: Set] = [:] + /// Whether the free-text "Other" answer is active per question text. + @State private var otherActive: [String: Bool] = [:] + /// The free-text "Other" answer per question text. + @State private var otherText: [String: String] = [:] + /// Index of the question currently shown (the wizard step). + @State private var step = 0 + + private var submitEnabled: Bool { store.connectivity.isLive } + + var body: some View { + VStack(alignment: .leading, spacing: 14) { + HStack(spacing: 6) { + Image(systemName: "questionmark.bubble.fill").foregroundStyle(Palette.attention) + Text(questions.count > 1 ? "Questions" : "Question") + .font(.subheadline.weight(.semibold)) + } + + if let question = currentQuestion { + VStack(alignment: .leading, spacing: 8) { + if questions.count > 1 { + Text("Question \(step + 1) of \(questions.count)") + .font(.caption).foregroundStyle(.secondary) + } + if let header = question.header, !header.isEmpty { + Text(header.uppercased()) + .font(.caption).bold().foregroundStyle(.secondary) + } + Text(question.question).font(.headline) + ForEach(question.options) { option in + optionRow(question, label: option.label, description: option.description) + } + otherRow(question) + } + } + + HStack(spacing: 10) { + Button(role: .destructive) { + respond(.deny(reason: nil)) + } label: { + Text("Deny").frame(maxWidth: .infinity) + } + .buttonStyle(.bordered) + .keyboardShortcut(.cancelAction) + + if step > 0 { + Button("Back") { step -= 1 } + .buttonStyle(.bordered) + } + + if isLastStep { + Button { + submit() + } label: { + Text("Submit").frame(maxWidth: .infinity) + } + .buttonStyle(.borderedProminent) + .disabled(!allAnswered || !submitEnabled) + .keyboardShortcut(.defaultAction) + } else { + Button { + step += 1 + } label: { + Text("Next").frame(maxWidth: .infinity) + } + .buttonStyle(.borderedProminent) + .disabled(!currentAnswered) + .keyboardShortcut(.defaultAction) + } + } + } + .padding() + .glassSurface(cornerRadius: 20) + } + + // MARK: - Wizard state + + /// The question for the active step, or `nil` if the index is somehow out of range. + private var currentQuestion: AskUserQuestion.Question? { + questions.indices.contains(step) ? questions[step] : nil + } + + private var isLastStep: Bool { step >= questions.count - 1 } + + /// Whether the question on the current step has at least one answer — gates `Next`. + private var currentAnswered: Bool { + guard let currentQuestion else { return false } + return !answers(for: currentQuestion).isEmpty + } + + private var allAnswered: Bool { + questions.allSatisfy { !answers(for: $0).isEmpty } + } + + // MARK: - Rows + + @ViewBuilder + private func optionRow(_ question: AskUserQuestion.Question, label: String, description: String?) + -> some View + { + let isOn = selected[question.question]?.contains(label) ?? false + Button { toggle(question, label) } label: { + HStack(alignment: .top, spacing: 8) { + Image(systemName: symbol(multiSelect: question.multiSelect, on: isOn)) + .foregroundStyle(isOn ? Palette.attention : .secondary) + VStack(alignment: .leading, spacing: 2) { + Text(label).font(.body) + .fixedSize(horizontal: false, vertical: true) + if let description, !description.isEmpty { + // Let the subtext wrap freely — inside a Button label with a trailing + // Spacer, an unconstrained Text truncates to one line and hides the rest. + Text(description).font(.subheadline).foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + } + Spacer(minLength: 0) + } + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + } + + @ViewBuilder + private func otherRow(_ question: AskUserQuestion.Question) -> some View { + let isOn = otherActive[question.question] ?? false + HStack(alignment: .center, spacing: 8) { + Button { toggleOther(question) } label: { + Image(systemName: symbol(multiSelect: question.multiSelect, on: isOn)) + .foregroundStyle(isOn ? Palette.attention : .secondary) + } + .buttonStyle(.plain) + TextField( + "Other…", + text: Binding( + get: { otherText[question.question] ?? "" }, + set: { newValue in + otherText[question.question] = newValue + if !newValue.isEmpty { activateOther(question) } + }) + ) + .textFieldStyle(.roundedBorder) + } + } + + private func symbol(multiSelect: Bool, on: Bool) -> String { + if multiSelect { return on ? "checkmark.square.fill" : "square" } + return on ? "largecircle.fill.circle" : "circle" + } + + // MARK: - Selection + + private func toggle(_ question: AskUserQuestion.Question, _ label: String) { + var set = selected[question.question] ?? [] + if question.multiSelect { + if set.contains(label) { set.remove(label) } else { set.insert(label) } + } else { + set = set.contains(label) ? [] : [label] + otherActive[question.question] = false + } + selected[question.question] = set + } + + private func toggleOther(_ question: AskUserQuestion.Question) { + let next = !(otherActive[question.question] ?? false) + otherActive[question.question] = next + if next, !question.multiSelect { selected[question.question] = [] } + } + + private func activateOther(_ question: AskUserQuestion.Question) { + otherActive[question.question] = true + if !question.multiSelect { selected[question.question] = [] } + } + + /// Chosen labels for a question: picked options plus any non-empty "Other". + private func answers(for question: AskUserQuestion.Question) -> [String] { + var labels = Array(selected[question.question] ?? []) + if otherActive[question.question] ?? false { + let custom = (otherText[question.question] ?? "") + .trimmingCharacters(in: .whitespacesAndNewlines) + if !custom.isEmpty { labels.append(custom) } + } + return labels + } + + // MARK: - Submit + + private func submit() { + var selections: [String: [String]] = [:] + for question in questions { selections[question.question] = answers(for: question) } + let updatedInput = AskUserQuestion.updatedInput( + original: approval.input, selections: selections) + respond(.allow(updatedInput: updatedInput)) + } + + private func respond(_ decision: Decision) { + store.respond(approval, decision) + } +} From 27a69cfd4fd9c83044c7e3a3d10079dd1b218de9 Mon Sep 17 00:00:00 2001 From: Andrew Blakeslee Moore Date: Sun, 5 Jul 2026 02:35:57 -0700 Subject: [PATCH 110/143] Fix AskUserQuestion Rendering Nucleic-Session: A563CF12-F6B3-498A-8640-97DFC24F59EF Co-authored-by: Nucleic --- .../NucleicRemote/Views/SessionDetailView.swift | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift b/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift index f4f7752..80f3c37 100644 --- a/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift +++ b/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift @@ -221,7 +221,17 @@ struct SessionDetailView: View { private var actionArea: some View { Group { if let approval = store.openApprovals.first { - ApprovalCardView(approval: approval) + // AskUserQuestion isn't a permission gate — it collects the user's answers and + // needs them folded back into `updatedInput`. Render the picker (matching the + // Mac's `AskUserQuestionBar`) instead of a plain allow/deny card, which would + // reply with no selection and make the CLI report the question went unanswered. + if approval.toolName == AskUserQuestion.toolName, + let questions = AskUserQuestion.questions(from: approval.input) + { + AskUserQuestionCardView(approval: approval, questions: questions) + } else { + ApprovalCardView(approval: approval) + } } else if let status = summary?.status { let running = status == .running let canCompose = running || status == .awaitingInput || status.isTerminal From 35dcce14ae6f48fff7bca58ba4f8c0d966593701 Mon Sep 17 00:00:00 2001 From: Andrew Blakeslee Moore Date: Sun, 5 Jul 2026 02:36:28 -0700 Subject: [PATCH 111/143] Fix AskUserQuestion Rendering Nucleic-Session: A563CF12-F6B3-498A-8640-97DFC24F59EF Co-authored-by: Nucleic --- NucleicRemote/NucleicRemote/Notifications.swift | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/NucleicRemote/NucleicRemote/Notifications.swift b/NucleicRemote/NucleicRemote/Notifications.swift index fa8ef92..c12a4b5 100644 --- a/NucleicRemote/NucleicRemote/Notifications.swift +++ b/NucleicRemote/NucleicRemote/Notifications.swift @@ -62,7 +62,11 @@ final class NotificationRouter: NSObject { content.title = sessionTitle content.body = "\(approval.toolName) wants: \(approval.title)" content.sound = .default - content.categoryIdentifier = approval.risk.isHigh + // AskUserQuestion collects answers, it doesn't gate — a banner Allow would reply with no + // selection (updatedInput: nil) and the CLI reports the question went unanswered. So, like + // a high-risk approval, it uses the action-less category: it must open the app and be + // answered on the picker card. + content.categoryIdentifier = approval.risk.isHigh || approval.toolName == AskUserQuestion.toolName ? Self.approvalCategory : Self.approvalActionableCategory content.userInfo = [ "sessionID": approval.sessionID.rawValue, From 8219f19d4e1e1e58272d1d1bc8e8a342d200b441 Mon Sep 17 00:00:00 2001 From: Andrew Blakeslee Moore Date: Sun, 5 Jul 2026 02:38:01 -0700 Subject: [PATCH 112/143] Handle Session Interaction Nucleic-Session: B08BD1C3-178D-44BD-827A-331F72A95821 Co-authored-by: Nucleic --- .../NucleicRemoteWidgets/SessionLiveActivity.swift | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/NucleicRemote/NucleicRemoteWidgets/SessionLiveActivity.swift b/NucleicRemote/NucleicRemoteWidgets/SessionLiveActivity.swift index 1d74750..deee70b 100644 --- a/NucleicRemote/NucleicRemoteWidgets/SessionLiveActivity.swift +++ b/NucleicRemote/NucleicRemoteWidgets/SessionLiveActivity.swift @@ -109,10 +109,23 @@ private struct LockScreenView: View { // MARK: - Shared pieces /// One session: state glyph + title + "project · Backend" + a right-aligned status/diff. +/// +/// Each row is its own tap target: wrapped in a `Link` to `nucleic://session/`, so tapping a +/// specific session on the lock screen or expanded Dynamic Island opens *that* session — overriding +/// the activity-wide `widgetURL` (which still governs the header, churn footer, and empty space, and +/// remains the only tap target the compact/minimal presentations can offer). private struct SessionRow: View { let line: NucleicSessionAttributes.SessionLine var body: some View { + if let url = NucleicDeepLink.session(line.id) { + Link(destination: url) { rowContent } + } else { + rowContent + } + } + + private var rowContent: some View { HStack(spacing: 8) { Image(systemName: ActivityPalette.glyph(line.kind)) .font(.footnote) From cb7cdbd6285576335df71a1ad85914c87b55c6a0 Mon Sep 17 00:00:00 2001 From: Andrew Blakeslee Moore Date: Sun, 5 Jul 2026 04:12:27 -0700 Subject: [PATCH 113/143] Fix Tool Call Card Mesh Nucleic-Session: 217E0ED8-7E6C-4CC0-899E-A2B6C425F292 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 1523236..a6c9358 100644 --- a/NucleicRemote/NucleicRemote/Models/HostConnection.swift +++ b/NucleicRemote/NucleicRemote/Models/HostConnection.swift @@ -498,6 +498,10 @@ final class HostConnection { // `fetchTranscript` this device sent. A phone pulls full history on demand via a // dedicated fetch path (not this subscribe stream), so nothing to do here yet. break + case .toolSummaries: + // Collapsed Bash summary lines the owner pushes to peer *Macs* (mesh session sync). + // The phone renders its own deterministic command summaries, so it ignores these. + break case .relayMembership(let membership): // The host issued/refreshed this device's relay credential (mesh P2). Persist it // in place (no reordering — this can arrive from a non-active Mac) so the relay From 2f18f5968ba0700327f882535e2795aa01f239a9 Mon Sep 17 00:00:00 2001 From: Andrew Blakeslee Moore Date: Sun, 5 Jul 2026 04:13:23 -0700 Subject: [PATCH 114/143] Session Order Adjustment Nucleic-Session: 19C20841-87D4-45F9-A5AD-EBB92B735F82 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 e05c020..cfe8834 100644 --- a/NucleicRemote/NucleicRemote/Models/RemoteStore.swift +++ b/NucleicRemote/NucleicRemote/Models/RemoteStore.swift @@ -1242,6 +1242,13 @@ extension DashboardSnapshot { } extension WireSessionSummary { + /// Recency key for ordering session lists: the last *user* message, falling back to `updatedAt` + /// for sessions with no user message yet (or summaries from a host that predates the field). + /// Ordering on this keeps a chat's position steady until the user speaks again, rather than + /// reshuffling the list on every bit of agent activity (which bumps `updatedAt` every few + /// seconds). Mirrors the Mac sidebar's last-turn ordering. + var lastTurnAt: Date { lastUserMessageAt ?? updatedAt } + /// A copy with selected fields overridden — the demo simulator's only way to "mutate" a /// summary, whose stored properties are all `let`. Nullable fields use a double optional so a /// caller can distinguish "keep" (omit) from "set to nil" (`.some(nil)`). `updatedAt` bumps From 359f4ab45260fe2fec7fe025c65c506dec66c35f Mon Sep 17 00:00:00 2001 From: Andrew Blakeslee Moore Date: Sun, 5 Jul 2026 04:13:33 -0700 Subject: [PATCH 115/143] Session Order Adjustment Nucleic-Session: 19C20841-87D4-45F9-A5AD-EBB92B735F82 Co-authored-by: Nucleic --- NucleicRemote/NucleicRemote/Views/Theme.swift | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/NucleicRemote/NucleicRemote/Views/Theme.swift b/NucleicRemote/NucleicRemote/Views/Theme.swift index 9da8d5b..1417988 100644 --- a/NucleicRemote/NucleicRemote/Views/Theme.swift +++ b/NucleicRemote/NucleicRemote/Views/Theme.swift @@ -281,6 +281,14 @@ enum StatusStyle { case .finished, .interrupted, .error: return 5 } } + + /// The shared session-list sort: attention-first by `sortRank`, then, within a rank, by + /// last-turn recency (`lastTurnAt`) rather than raw activity — so rows hold their place + /// instead of reshuffling every few seconds as the agent works. Mirrors the Mac sidebar. + static func attentionThenRecency(_ lhs: WireSessionSummary, _ rhs: WireSessionSummary) -> Bool { + let l = sortRank(lhs), r = sortRank(rhs) + return l == r ? lhs.lastTurnAt > rhs.lastTurnAt : l < r + } } extension SessionStatus { From 86f6b308c6b355826ca470727ceec3f118f781d9 Mon Sep 17 00:00:00 2001 From: Andrew Blakeslee Moore Date: Sun, 5 Jul 2026 04:13:38 -0700 Subject: [PATCH 116/143] Session Order Adjustment Nucleic-Session: 19C20841-87D4-45F9-A5AD-EBB92B735F82 Co-authored-by: Nucleic --- NucleicRemote/NucleicRemote/Views/SessionsView.swift | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/NucleicRemote/NucleicRemote/Views/SessionsView.swift b/NucleicRemote/NucleicRemote/Views/SessionsView.swift index e42285a..22fb067 100644 --- a/NucleicRemote/NucleicRemote/Views/SessionsView.swift +++ b/NucleicRemote/NucleicRemote/Views/SessionsView.swift @@ -10,10 +10,7 @@ struct SessionsView: View { private var grouped: [(title: String, rows: [WireSessionSummary])] { let pool = (showArchived ? store.sessions : store.liveSessions) - .sorted { - let a = StatusStyle.sortRank($0), b = StatusStyle.sortRank($1) - return a == b ? $0.updatedAt > $1.updatedAt : a < b - } + .sorted(by: StatusStyle.attentionThenRecency) let needs = pool.filter { $0.status.needsYou($0.disposition) } let running = pool.filter { $0.status == .running || $0.status == .provisioning || $0.status == .idle } let done = pool.filter { !needs.contains($0) && !running.contains($0) } From 46587255d25664cd7e65c96fb79d57cdfd6eda73 Mon Sep 17 00:00:00 2001 From: Andrew Blakeslee Moore Date: Sun, 5 Jul 2026 04:13:42 -0700 Subject: [PATCH 117/143] Session Order Adjustment Nucleic-Session: 19C20841-87D4-45F9-A5AD-EBB92B735F82 Co-authored-by: Nucleic --- NucleicRemote/NucleicRemote/Views/AdaptiveRootView.swift | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/NucleicRemote/NucleicRemote/Views/AdaptiveRootView.swift b/NucleicRemote/NucleicRemote/Views/AdaptiveRootView.swift index 574c482..9c4a02b 100644 --- a/NucleicRemote/NucleicRemote/Views/AdaptiveRootView.swift +++ b/NucleicRemote/NucleicRemote/Views/AdaptiveRootView.swift @@ -140,10 +140,7 @@ private struct SplitSidebar: View { /// Live sessions grouped under their project (dashboard order), with anything whose project /// isn't in the dashboard collected into a trailing "Other" group. Attention-first sort. private var projectGroups: [(name: String, rows: [WireSessionSummary])] { - let live = store.liveSessions.sorted { - let a = StatusStyle.sortRank($0), b = StatusStyle.sortRank($1) - return a == b ? $0.updatedAt > $1.updatedAt : a < b - } + let live = store.liveSessions.sorted(by: StatusStyle.attentionThenRecency) var groups: [(name: String, rows: [WireSessionSummary])] = [] var placed = Set() for project in store.dashboard.projects { From 44420ae35e8d7f45508f89fc745f71fc9ac29046 Mon Sep 17 00:00:00 2001 From: Andrew Blakeslee Moore Date: Sun, 5 Jul 2026 04:13:49 -0700 Subject: [PATCH 118/143] Session Order Adjustment Nucleic-Session: 19C20841-87D4-45F9-A5AD-EBB92B735F82 Co-authored-by: Nucleic --- NucleicRemote/NucleicRemote/Views/ProjectsView.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/NucleicRemote/NucleicRemote/Views/ProjectsView.swift b/NucleicRemote/NucleicRemote/Views/ProjectsView.swift index 32290c1..0ad0cfc 100644 --- a/NucleicRemote/NucleicRemote/Views/ProjectsView.swift +++ b/NucleicRemote/NucleicRemote/Views/ProjectsView.swift @@ -53,7 +53,7 @@ struct ProjectDetailView: View { private var sessions: [WireSessionSummary] { store.liveSessions .filter { $0.projectID == project.id.rawValue } - .sorted { StatusStyle.sortRank($0) < StatusStyle.sortRank($1) } + .sorted(by: StatusStyle.attentionThenRecency) } var body: some View { From 37aa1ac643a04ad6318d2757e63ea33081e9dde4 Mon Sep 17 00:00:00 2001 From: Andrew Blakeslee Moore Date: Sun, 5 Jul 2026 04:13:53 -0700 Subject: [PATCH 119/143] Session Order Adjustment Nucleic-Session: 19C20841-87D4-45F9-A5AD-EBB92B735F82 Co-authored-by: Nucleic --- NucleicRemote/NucleicRemote/LiveActivityManager.swift | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/NucleicRemote/NucleicRemote/LiveActivityManager.swift b/NucleicRemote/NucleicRemote/LiveActivityManager.swift index 04f5cb6..0ed2631 100644 --- a/NucleicRemote/NucleicRemote/LiveActivityManager.swift +++ b/NucleicRemote/NucleicRemote/LiveActivityManager.swift @@ -56,10 +56,7 @@ final class LiveActivityManager { $0.status == .running || $0.status == .provisioning || $0.status.needsYou($0.disposition) } - .sorted { lhs, rhs in - let l = StatusStyle.sortRank(lhs), r = StatusStyle.sortRank(rhs) - return l == r ? lhs.updatedAt > rhs.updatedAt : l < r - } + .sorted(by: StatusStyle.attentionThenRecency) let approvals = active.reduce(0) { $0 + $1.pendingApprovalCount } let files = active.reduce(0) { $0 + ($1.diffStat?.filesChanged ?? 0) } From 3bed72f8a5fed8cdfd322190988705ce409cea86 Mon Sep 17 00:00:00 2001 From: Andrew Blakeslee Moore Date: Sun, 5 Jul 2026 04:14:00 -0700 Subject: [PATCH 120/143] Session Order Adjustment Nucleic-Session: 19C20841-87D4-45F9-A5AD-EBB92B735F82 Co-authored-by: Nucleic --- NucleicRemote/NucleicRemote/Views/HomeView.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/NucleicRemote/NucleicRemote/Views/HomeView.swift b/NucleicRemote/NucleicRemote/Views/HomeView.swift index 8c169f4..234c6a7 100644 --- a/NucleicRemote/NucleicRemote/Views/HomeView.swift +++ b/NucleicRemote/NucleicRemote/Views/HomeView.swift @@ -16,7 +16,7 @@ struct HomeView: View { private var needsAttention: [WireSessionSummary] { store.liveSessions .filter { $0.status.needsYou($0.disposition) } - .sorted { $0.updatedAt > $1.updatedAt } + .sorted { $0.lastTurnAt > $1.lastTurnAt } } /// Sessions actively working a turn — drives both the "In progress" stat card and the /// live list pinned above the to-dos. From 6bb4162a270ee43dc911502c744a694f5b064681 Mon Sep 17 00:00:00 2001 From: Andrew Blakeslee Moore Date: Sun, 5 Jul 2026 04:14:05 -0700 Subject: [PATCH 121/143] Session Order Adjustment Nucleic-Session: 19C20841-87D4-45F9-A5AD-EBB92B735F82 Co-authored-by: Nucleic --- NucleicRemote/NucleicRemote/Views/HomeView.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/NucleicRemote/NucleicRemote/Views/HomeView.swift b/NucleicRemote/NucleicRemote/Views/HomeView.swift index 234c6a7..dfab55d 100644 --- a/NucleicRemote/NucleicRemote/Views/HomeView.swift +++ b/NucleicRemote/NucleicRemote/Views/HomeView.swift @@ -21,7 +21,7 @@ struct HomeView: View { /// Sessions actively working a turn — drives both the "In progress" stat card and the /// live list pinned above the to-dos. private var running: [WireSessionSummary] { - store.liveSessions.filter { $0.status == .running }.sorted { $0.updatedAt > $1.updatedAt } + store.liveSessions.filter { $0.status == .running }.sorted { $0.lastTurnAt > $1.lastTurnAt } } /// Outstanding to-dos — everything not yet done (open + dispatched), matching the count the /// To-dos tab lists. The Mac surfaces this elsewhere on its home; the phone gets a stat card. From 3504c14faa3d1c091dab2bdc70fe8f1a819fb0bc Mon Sep 17 00:00:00 2001 From: Andrew Blakeslee Moore Date: Sun, 5 Jul 2026 04:16:00 -0700 Subject: [PATCH 122/143] Live Activity Height Adjustment Nucleic-Session: 89404784-EBA1-4E59-BE81-0698CA20F06A Co-authored-by: Nucleic --- .../NucleicRemoteWidgets/SessionLiveActivity.swift | 6 ------ 1 file changed, 6 deletions(-) diff --git a/NucleicRemote/NucleicRemoteWidgets/SessionLiveActivity.swift b/NucleicRemote/NucleicRemoteWidgets/SessionLiveActivity.swift index deee70b..0721977 100644 --- a/NucleicRemote/NucleicRemoteWidgets/SessionLiveActivity.swift +++ b/NucleicRemote/NucleicRemoteWidgets/SessionLiveActivity.swift @@ -94,12 +94,6 @@ private struct LockScreenView: View { ForEach(state.lines.prefix(3)) { line in SessionRow(line: line) } - - // Aggregate churn footer — how much work is in flight right now. - if state.hasChurn { - Divider().overlay(Color.white.opacity(0.12)) - ChurnLine(state: state) - } } .padding(14) .foregroundStyle(.white) From 9c07a975d5b76affb2d7479ccc4d92673d147b14 Mon Sep 17 00:00:00 2001 From: Andrew Blakeslee Moore Date: Sun, 5 Jul 2026 04:16:05 -0700 Subject: [PATCH 123/143] Live Activity Height Adjustment Nucleic-Session: 89404784-EBA1-4E59-BE81-0698CA20F06A Co-authored-by: Nucleic --- NucleicRemote/NucleicRemoteWidgets/SessionLiveActivity.swift | 3 --- 1 file changed, 3 deletions(-) diff --git a/NucleicRemote/NucleicRemoteWidgets/SessionLiveActivity.swift b/NucleicRemote/NucleicRemoteWidgets/SessionLiveActivity.swift index 0721977..ab603b7 100644 --- a/NucleicRemote/NucleicRemoteWidgets/SessionLiveActivity.swift +++ b/NucleicRemote/NucleicRemoteWidgets/SessionLiveActivity.swift @@ -43,9 +43,6 @@ struct SessionLiveActivity: Widget { ForEach(state.lines.prefix(2)) { line in SessionRow(line: line) } - if state.hasChurn { - ChurnLine(state: state).padding(.top, 1) - } } } } compactLeading: { From 7d71601b40ed48ef7f4d79758d141e5f451fc0e0 Mon Sep 17 00:00:00 2001 From: Andrew Blakeslee Moore Date: Sun, 5 Jul 2026 04:16:11 -0700 Subject: [PATCH 124/143] Live Activity Height Adjustment Nucleic-Session: 89404784-EBA1-4E59-BE81-0698CA20F06A Co-authored-by: Nucleic --- .../SessionLiveActivity.swift | 22 ------------------- 1 file changed, 22 deletions(-) diff --git a/NucleicRemote/NucleicRemoteWidgets/SessionLiveActivity.swift b/NucleicRemote/NucleicRemoteWidgets/SessionLiveActivity.swift index ab603b7..a58e4e3 100644 --- a/NucleicRemote/NucleicRemoteWidgets/SessionLiveActivity.swift +++ b/NucleicRemote/NucleicRemoteWidgets/SessionLiveActivity.swift @@ -173,28 +173,6 @@ private struct CountChips: View { } } -/// Aggregate worktree churn: "N files +A −R". -private struct ChurnLine: View { - let state: NucleicSessionAttributes.ContentState - - var body: some View { - HStack(spacing: 6) { - Image(systemName: "chevron.left.forwardslash.chevron.right") - .font(.caption2) - .foregroundStyle(.secondary) - Text("\(state.filesChanged) file\(state.filesChanged == 1 ? "" : "s") changed") - .foregroundStyle(.secondary) - Text("+\(state.linesAdded)") - .foregroundStyle(ActivityPalette.added) - Text("−\(state.linesRemoved)") - .foregroundStyle(ActivityPalette.removed) - } - .font(.caption2.weight(.medium)) - .monospacedDigit() - .lineLimit(1) - } -} - /// Dynamic Island expanded leading/trailing count badge. private struct CountBadge: View { let systemImage: String From a0db606f97caba778267b99ffef1aaa870c9711a Mon Sep 17 00:00:00 2001 From: Andrew Blakeslee Moore Date: Sun, 5 Jul 2026 04:17:24 -0700 Subject: [PATCH 125/143] Reconnect Icon Update Nucleic-Session: 183CCBC5-8629-4480-A7A9-C981D551DCFD Co-authored-by: Nucleic --- .../NucleicRemote/Models/HostConnection.swift | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/NucleicRemote/NucleicRemote/Models/HostConnection.swift b/NucleicRemote/NucleicRemote/Models/HostConnection.swift index a6c9358..654ab5f 100644 --- a/NucleicRemote/NucleicRemote/Models/HostConnection.swift +++ b/NucleicRemote/NucleicRemote/Models/HostConnection.swift @@ -478,8 +478,18 @@ final class HostConnection { else { continue } let fingerprint = member.staticPublicKey.fingerprintHex guard fingerprint != self.hostID else { continue } // that's the Mac we're on - if IdentityStore.pairedHost(id: fingerprint) == nil { changed = true } - IdentityStore.mergePairedHost(Self.pairedHost(from: member)) + let incoming = Self.pairedHost(from: member) + let existing = IdentityStore.pairedHost(id: fingerprint) + // Re-dial not only when a Mac is brand-new, but also when a known Mac's dialable + // address changed — a Mac's LAN port is OS-assigned (`.any`), so it lands on a + // fresh one every relaunch and re-gossips it. `mergePairedHost` updates the + // registry in place, but a connection already retrying the dead old address won't + // pick that up on its own (its retry loop reuses the address it was last handed), + // so without forcing a fresh reconnect here the phone never reconnects to it. + if existing == nil || Self.dialableAddressChanged(from: existing!, to: incoming) { + changed = true + } + IdentityStore.mergePairedHost(incoming) } for tombstone in push.tombstones { // fingerprint = first 16 hex of the hostID (sha256 prefix), the registry key. From 9243763e05e04cff69fd54c42af9bc2d5165ca17 Mon Sep 17 00:00:00 2001 From: Andrew Blakeslee Moore Date: Sun, 5 Jul 2026 04:17:39 -0700 Subject: [PATCH 126/143] Reconnect Icon Update Nucleic-Session: 183CCBC5-8629-4480-A7A9-C981D551DCFD Co-authored-by: Nucleic --- .../NucleicRemote/Models/HostConnection.swift | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/NucleicRemote/NucleicRemote/Models/HostConnection.swift b/NucleicRemote/NucleicRemote/Models/HostConnection.swift index 654ab5f..8436ad5 100644 --- a/NucleicRemote/NucleicRemote/Models/HostConnection.swift +++ b/NucleicRemote/NucleicRemote/Models/HostConnection.swift @@ -201,6 +201,18 @@ final class HostConnection { relayMembershipToken: nil, relayURL: nil) } + /// Whether the dialable endpoints of a gossiped record differ from the one on file — the + /// signal that a known Mac must be re-dialed (its LAN port, tailnet IP, or relay room moved). + /// Ignores relay *credentials* (token/URL), which `mergePairedHost` preserves and which don't + /// change where the Mac is reached. + private static func dialableAddressChanged(from existing: PairedHost, to incoming: PairedHost) -> Bool { + existing.lanHost != incoming.lanHost + || existing.lanPort != incoming.lanPort + || existing.tailnetHost != incoming.tailnetHost + || existing.tailnetPort != incoming.tailnetPort + || existing.relayRoomID != incoming.relayRoomID + } + /// Split a "host:port" hint (last-colon split so a bracketed IPv6 host survives). private static func splitHostPort(_ hint: String?) -> (String?, UInt16?) { guard let hint, let colon = hint.lastIndex(of: ":"), From 7ba9b896a3bdf0a2ccaed17b028720c37acc98c0 Mon Sep 17 00:00:00 2001 From: Andrew Blakeslee Moore Date: Sun, 5 Jul 2026 04:20:40 -0700 Subject: [PATCH 127/143] Activity Theme Adjustment Nucleic-Session: 5F808220-F280-49B7-8876-41EE93E714FA Co-authored-by: Nucleic --- .../NucleicRemoteWidgets/SessionLiveActivity.swift | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/NucleicRemote/NucleicRemoteWidgets/SessionLiveActivity.swift b/NucleicRemote/NucleicRemoteWidgets/SessionLiveActivity.swift index a58e4e3..d1d5dd5 100644 --- a/NucleicRemote/NucleicRemoteWidgets/SessionLiveActivity.swift +++ b/NucleicRemote/NucleicRemoteWidgets/SessionLiveActivity.swift @@ -14,7 +14,11 @@ struct SessionLiveActivity: Widget { ActivityConfiguration(for: NucleicSessionAttributes.self) { context in LockScreenView(state: context.state, hostName: context.attributes.hostName) .widgetURL(context.state.tapURL) - .activityBackgroundTint(Color.black.opacity(0.55)) + // When something needs the user, wash the whole card in the attention tint; + // otherwise the calm dark background (see the border/header cues in LockScreenView). + .activityBackgroundTint(context.state.needsAttention + ? ActivityPalette.attentionTint(context.state).opacity(0.28) + : Color.black.opacity(0.55)) .activitySystemActionForegroundColor(.white) } dynamicIsland: { context in let state = context.state From baa59f4c22ad851963ab4a2d64a9bb2c7af1903b Mon Sep 17 00:00:00 2001 From: Andrew Blakeslee Moore Date: Sun, 5 Jul 2026 04:20:56 -0700 Subject: [PATCH 128/143] Activity Theme Adjustment Nucleic-Session: 5F808220-F280-49B7-8876-41EE93E714FA Co-authored-by: Nucleic --- .../SessionLiveActivity.swift | 27 ++++++++++++++++--- 1 file changed, 23 insertions(+), 4 deletions(-) diff --git a/NucleicRemote/NucleicRemoteWidgets/SessionLiveActivity.swift b/NucleicRemote/NucleicRemoteWidgets/SessionLiveActivity.swift index d1d5dd5..ac4ad4f 100644 --- a/NucleicRemote/NucleicRemoteWidgets/SessionLiveActivity.swift +++ b/NucleicRemote/NucleicRemoteWidgets/SessionLiveActivity.swift @@ -78,12 +78,17 @@ private struct LockScreenView: View { let hostName: String var body: some View { - VStack(alignment: .leading, spacing: 8) { - // Header: brand + host, and the headline counts. + let attention = state.needsAttention + let tint = ActivityPalette.attentionTint(state) + return VStack(alignment: .leading, spacing: 8) { + // Header: brand + host, and the headline counts. When the user is needed the brand + // mark becomes the attention glyph in its tint, so the header itself reads "act now". HStack(spacing: 8) { - Image(systemName: "hexagon.fill") + Image(systemName: attention + ? ActivityPalette.glyph(state.approvalCount > 0 ? .approval : .needsInput) + : "hexagon.fill") .font(.footnote) - .foregroundStyle(ActivityPalette.active) + .foregroundStyle(attention ? tint : ActivityPalette.active) Text(hostName) .font(.caption.weight(.semibold)) .lineLimit(1) @@ -98,6 +103,20 @@ private struct LockScreenView: View { } .padding(14) .foregroundStyle(.white) + // Attention theme: a soft tinted wash + a colored border around the whole glance, so a + // session waiting on the user is unmistakable at a glance even before reading the rows. + .background { + if attention { + RoundedRectangle(cornerRadius: 22, style: .continuous) + .fill(tint.opacity(0.12)) + } + } + .overlay { + if attention { + RoundedRectangle(cornerRadius: 22, style: .continuous) + .strokeBorder(tint.opacity(0.9), lineWidth: 2) + } + } } } From d66b8daf8f57507e357f7fe3da8c1b96eb77fe66 Mon Sep 17 00:00:00 2001 From: Andrew Blakeslee Moore Date: Sun, 5 Jul 2026 04:21:04 -0700 Subject: [PATCH 129/143] Activity Theme Adjustment Nucleic-Session: 5F808220-F280-49B7-8876-41EE93E714FA Co-authored-by: Nucleic --- .../NucleicRemoteWidgets/SessionLiveActivity.swift | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/NucleicRemote/NucleicRemoteWidgets/SessionLiveActivity.swift b/NucleicRemote/NucleicRemoteWidgets/SessionLiveActivity.swift index ac4ad4f..5e2f336 100644 --- a/NucleicRemote/NucleicRemoteWidgets/SessionLiveActivity.swift +++ b/NucleicRemote/NucleicRemoteWidgets/SessionLiveActivity.swift @@ -223,6 +223,12 @@ private enum ActivityPalette { static let added = Color(red: 0.30, green: 0.78, blue: 0.45) static let removed = Color(red: 0.92, green: 0.34, blue: 0.34) + /// The color the "attention needed" theme paints with: approval amber is loudest, else the + /// needs-input teal. Only meaningful when `state.needsAttention` — callers gate on that. + static func attentionTint(_ state: NucleicSessionAttributes.ContentState) -> Color { + state.approvalCount > 0 ? attention : accent + } + static func glyph(_ kind: NucleicSessionAttributes.Kind) -> String { switch kind { case .running: "circle.lefthalf.filled" From 628f26bdfb7c1b86ef17754b86b9f351b6b2ba77 Mon Sep 17 00:00:00 2001 From: Andrew Blakeslee Moore Date: Sun, 5 Jul 2026 04:29:53 -0700 Subject: [PATCH 130/143] Session Sync Repair Nucleic-Session: 0C7BC346-0002-4533-ADDD-3B4C92E58527 Co-authored-by: Nucleic --- .../NucleicRemote/Models/HostConnection.swift | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/NucleicRemote/NucleicRemote/Models/HostConnection.swift b/NucleicRemote/NucleicRemote/Models/HostConnection.swift index 8436ad5..e1f1335 100644 --- a/NucleicRemote/NucleicRemote/Models/HostConnection.swift +++ b/NucleicRemote/NucleicRemote/Models/HostConnection.swift @@ -35,7 +35,20 @@ final class HostConnection { /// The session RemoteStore currently has open on this host, if any — so snapshot/events are only /// forwarded (and deduped) for the transcript on screen. Set by RemoteStore on open/close. - var openSessionID: SessionID? + /// Changing which session is open resets the per-session cursor + dedup set (they only make + /// sense within one transcript). + var openSessionID: SessionID? { + didSet { + guard openSessionID != oldValue else { return } + openMaxSeq = nil + seenSeq = [] + } + } + + /// The highest event `seq` delivered for the open session — the warm-resubscribe cursor. On a + /// reconnect we re-subscribe `sinceSeq: openMaxSeq` so the host replays only what we missed + /// instead of cold-resetting to the 200-event tail (which would truncate a long transcript). + private var openMaxSeq: UInt64? // MARK: Callbacks up to RemoteStore (aggregate concerns) From e650bdff4caaab6f808d18f31578692b698dc700 Mon Sep 17 00:00:00 2001 From: Andrew Blakeslee Moore Date: Sun, 5 Jul 2026 04:30:00 -0700 Subject: [PATCH 131/143] Session Sync Repair Nucleic-Session: 0C7BC346-0002-4533-ADDD-3B4C92E58527 Co-authored-by: Nucleic --- NucleicRemote/NucleicRemote/Models/HostConnection.swift | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/NucleicRemote/NucleicRemote/Models/HostConnection.swift b/NucleicRemote/NucleicRemote/Models/HostConnection.swift index e1f1335..2d20037 100644 --- a/NucleicRemote/NucleicRemote/Models/HostConnection.swift +++ b/NucleicRemote/NucleicRemote/Models/HostConnection.swift @@ -453,7 +453,9 @@ final class HostConnection { callbacks.didUpdate() send(.listSessions) send(.listDashboard) - if let id = openSessionID { send(.subscribe(Subscribe(sessionID: id, sinceSeq: nil, verbosity: .full))) } + // Warm-resubscribe from what we already have, so a reconnect on a long transcript replays + // only the gap rather than snapping back to the host's 200-event tail. + if let id = openSessionID { send(.subscribe(Subscribe(sessionID: id, sinceSeq: openMaxSeq, verbosity: .full))) } case .sessionList(let list): sessions = list callbacks.didUpdate() From c5b7846d7c8a8a681ee844d7f35d6623ecc3ddef Mon Sep 17 00:00:00 2001 From: Andrew Blakeslee Moore Date: Sun, 5 Jul 2026 04:30:11 -0700 Subject: [PATCH 132/143] Session Sync Repair Nucleic-Session: 0C7BC346-0002-4533-ADDD-3B4C92E58527 Co-authored-by: Nucleic --- NucleicRemote/NucleicRemote/Models/HostConnection.swift | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/NucleicRemote/NucleicRemote/Models/HostConnection.swift b/NucleicRemote/NucleicRemote/Models/HostConnection.swift index 2d20037..0dba1bd 100644 --- a/NucleicRemote/NucleicRemote/Models/HostConnection.swift +++ b/NucleicRemote/NucleicRemote/Models/HostConnection.swift @@ -476,12 +476,17 @@ final class HostConnection { callbacks.didUpdate() case .snapshot(let snapshot): guard snapshot.summary.sessionID == openSessionID else { break } - seenSeq = Set(snapshot.recentEvents.map(\.seq)) + // A snapshot may be a fresh open (tail window) or a warm-resubscribe delta; either way + // union its seqs into the dedup set and advance the cursor. RemoteStore merges the events + // into the transcript rather than replacing, so an existing transcript isn't truncated. + seenSeq.formUnion(snapshot.recentEvents.map(\.seq)) + if let maxSeq = snapshot.recentEvents.map(\.seq).max() { openMaxSeq = max(openMaxSeq ?? 0, maxSeq) } callbacks.openSnapshot(snapshot) case .events(let batch): guard batch.sessionID == openSessionID else { break } let fresh = batch.events.filter { !seenSeq.contains($0.seq) } for e in fresh { seenSeq.insert(e.seq) } + if let maxSeq = batch.events.map(\.seq).max() { openMaxSeq = max(openMaxSeq ?? 0, maxSeq) } if !fresh.isEmpty { callbacks.openEvents(EventBatch(sessionID: batch.sessionID, events: fresh)) } case .approvalRequested(let req): let title = sessions.first { $0.sessionID == req.sessionID }?.title ?? "Approval" From bbba9a666b7d31fb27cdb095cf94ed8c823e77f6 Mon Sep 17 00:00:00 2001 From: Andrew Blakeslee Moore Date: Sun, 5 Jul 2026 04:30:42 -0700 Subject: [PATCH 133/143] Session Sync Repair Nucleic-Session: 0C7BC346-0002-4533-ADDD-3B4C92E58527 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 cfe8834..97e1d0e 100644 --- a/NucleicRemote/NucleicRemote/Models/RemoteStore.swift +++ b/NucleicRemote/NucleicRemote/Models/RemoteStore.swift @@ -477,7 +477,10 @@ final class RemoteStore: ObservableObject { } cb.openSnapshot = { [weak self] snap in guard let self, hostID == self.openSessionHostID, snap.summary.sessionID == self.openSessionID else { return } - self.openEvents = snap.recentEvents + // Merge, don't replace: a fresh open merges into an empty transcript (the tail window), + // while a reconnect's warm-resubscribe delta appends to the history already on screen + // instead of truncating it to the host's 200-event tail. + self.openEvents = Self.mergedEvents(self.openEvents, snap.recentEvents) self.openApprovals = snap.pendingApprovals } cb.openEvents = { [weak self] batch in From dbfd6213366d9a7366556f8be41d03f41040993e Mon Sep 17 00:00:00 2001 From: Andrew Blakeslee Moore Date: Sun, 5 Jul 2026 04:30:57 -0700 Subject: [PATCH 134/143] Session Sync Repair Nucleic-Session: 0C7BC346-0002-4533-ADDD-3B4C92E58527 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 97e1d0e..f5016b2 100644 --- a/NucleicRemote/NucleicRemote/Models/RemoteStore.swift +++ b/NucleicRemote/NucleicRemote/Models/RemoteStore.swift @@ -560,6 +560,20 @@ final class RemoteStore: ObservableObject { } } + /// Merge transcript events by `seq` (monotonic, globally unique within a session), keeping the + /// union sorted. Lets a reconnect's snapshot fold its events into the transcript already on + /// screen without duplicating what's shown or dropping history outside the host's tail window. + /// A fresh open merges into `[]`, so it's just the tail — the same result as a plain replace. + private static func mergedEvents(_ existing: [AgentEvent], _ incoming: [AgentEvent]) -> [AgentEvent] { + guard !existing.isEmpty else { return incoming } + guard !incoming.isEmpty else { return existing } + var bySeq: [UInt64: AgentEvent] = [:] + bySeq.reserveCapacity(existing.count + incoming.count) + for e in existing { bySeq[e.seq] = e } + for e in incoming { bySeq[e.seq] = e } + return bySeq.values.sorted { $0.seq < $1.seq } + } + /// 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 7e059459ad35b5544d89d3df9a4470558e6ffec6 Mon Sep 17 00:00:00 2001 From: Andrew Blakeslee Moore Date: Sun, 5 Jul 2026 04:55:06 -0700 Subject: [PATCH 135/143] Add New Chat Button Nucleic-Session: BB7AA593-185A-4A8F-A3DA-598EE63874E2 Co-authored-by: Nucleic --- NucleicRemote/NucleicRemote/Views/Composer.swift | 3 +++ 1 file changed, 3 insertions(+) diff --git a/NucleicRemote/NucleicRemote/Views/Composer.swift b/NucleicRemote/NucleicRemote/Views/Composer.swift index 44fef85..4da79f5 100644 --- a/NucleicRemote/NucleicRemote/Views/Composer.swift +++ b/NucleicRemote/NucleicRemote/Views/Composer.swift @@ -9,6 +9,9 @@ struct StartChatComposer: View { /// start typing a new chat immediately. Consumed (reset to false) once handled, so it never /// steals focus on a later Home appearance. `.constant(false)` for the iPhone/compact path. var focusNewChat: Binding = .constant(false) + /// Called after a chat is kicked off, so a host that presents the composer modally (e.g. the + /// Sessions tab's floating New-chat button) can dismiss itself. Home leaves it nil and stays put. + var onStart: (() -> Void)? = nil @State private var projectID: ProjectID? @State private var draft = "" @State private var auto = false From 8726a66ab725a6a67e7a1c623c740ce06bfd39f5 Mon Sep 17 00:00:00 2001 From: Andrew Blakeslee Moore Date: Sun, 5 Jul 2026 04:55:09 -0700 Subject: [PATCH 136/143] Add New Chat Button Nucleic-Session: BB7AA593-185A-4A8F-A3DA-598EE63874E2 Co-authored-by: Nucleic --- NucleicRemote/NucleicRemote/Views/Composer.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/NucleicRemote/NucleicRemote/Views/Composer.swift b/NucleicRemote/NucleicRemote/Views/Composer.swift index 4da79f5..24fa52f 100644 --- a/NucleicRemote/NucleicRemote/Views/Composer.swift +++ b/NucleicRemote/NucleicRemote/Views/Composer.swift @@ -96,6 +96,7 @@ struct StartChatComposer: View { baseBranch: branch.isEmpty ? nil : branch, useWorktree: useWorktree, auto: auto) draft = "" + onStart?() } } label: { Image(systemName: "arrow.up.circle.fill").font(.title) From 3f9d4a9fa69df69f927fa7cf10bffbeabea07684 Mon Sep 17 00:00:00 2001 From: Andrew Blakeslee Moore Date: Sun, 5 Jul 2026 04:55:14 -0700 Subject: [PATCH 137/143] Add New Chat Button Nucleic-Session: BB7AA593-185A-4A8F-A3DA-598EE63874E2 Co-authored-by: Nucleic --- NucleicRemote/NucleicRemote/Views/SessionsView.swift | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/NucleicRemote/NucleicRemote/Views/SessionsView.swift b/NucleicRemote/NucleicRemote/Views/SessionsView.swift index 22fb067..9f515fd 100644 --- a/NucleicRemote/NucleicRemote/Views/SessionsView.swift +++ b/NucleicRemote/NucleicRemote/Views/SessionsView.swift @@ -6,8 +6,13 @@ import NucleicProtocol struct SessionsView: View { @EnvironmentObject var store: RemoteStore @State private var showArchived = false + @State private var showNewChat = false @State private var path = NavigationPath() + /// The floating New-chat button only makes sense when this device can control a Mac and there's + /// at least one project to start a chat in; otherwise the composer it opens would be inert. + private var canStartChat: Bool { store.canControl && !store.dashboard.projects.isEmpty } + private var grouped: [(title: String, rows: [WireSessionSummary])] { let pool = (showArchived ? store.sessions : store.liveSessions) .sorted(by: StatusStyle.attentionThenRecency) From f01b7437e122a5d2aae6ed33ac12514fdee46824 Mon Sep 17 00:00:00 2001 From: Andrew Blakeslee Moore Date: Sun, 5 Jul 2026 04:55:27 -0700 Subject: [PATCH 138/143] Add New Chat Button Nucleic-Session: BB7AA593-185A-4A8F-A3DA-598EE63874E2 Co-authored-by: Nucleic --- .../NucleicRemote/Views/SessionsView.swift | 40 +++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/NucleicRemote/NucleicRemote/Views/SessionsView.swift b/NucleicRemote/NucleicRemote/Views/SessionsView.swift index 9f515fd..81ad3bb 100644 --- a/NucleicRemote/NucleicRemote/Views/SessionsView.swift +++ b/NucleicRemote/NucleicRemote/Views/SessionsView.swift @@ -89,10 +89,50 @@ struct SessionsView: View { } } } + // A floating "new chat" affordance in the lower-right, mirroring the Mac's per-project + // "New chat" button. Picks a project + prompt in a sheet, then kicks off the chat. + .overlay(alignment: .bottomTrailing) { + if canStartChat { newChatButton } + } + .sheet(isPresented: $showNewChat) { + NavigationStack { + ScrollView { + StartChatComposer(onStart: { showNewChat = false }) + .padding() + } + .navigationTitle("New chat") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .topBarLeading) { + Button("Cancel") { showNewChat = false } + } + } + } + .presentationDetents([.medium, .large]) + } .safeAreaInset(edge: .bottom) { ConnectionChip().padding(.bottom, 8) } } } + /// The circular plus button, styled to match the transcript's jump-to-bottom FAB: an accent + /// disc with a soft shadow, floated clear of the connection chip. + private var newChatButton: some View { + Button { + showNewChat = true + } label: { + Image(systemName: "plus") + .font(.system(size: 22, weight: .semibold)) + .foregroundStyle(.white) + .frame(width: 56, height: 56) + .background(Palette.accent, in: .circle) + .shadow(color: .black.opacity(0.25), radius: 6, y: 2) + } + .buttonStyle(.plain) + .padding(.trailing, 20) + .padding(.bottom, 20) + .accessibilityLabel("New chat") + } + /// Push the session a notification tap asked for, then clear the request. private func consumeRoute() { guard let route = store.pendingRoute else { return } From 20584bded9d59f6257cdd17b425fed0c031791b1 Mon Sep 17 00:00:00 2001 From: Andrew Blakeslee Moore Date: Sun, 5 Jul 2026 16:23:32 -0700 Subject: [PATCH 139/143] Bottom Tab Bar Visibility Adjustment Nucleic-Session: 747527BF-AEE9-4939-BE70-A14F1F3BBBA8 Co-authored-by: Nucleic --- NucleicRemote/NucleicRemote/Views/SessionDetailView.swift | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift b/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift index 80f3c37..10f7ad2 100644 --- a/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift +++ b/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift @@ -27,6 +27,10 @@ struct SessionDetailView: View { .safeAreaInset(edge: .bottom) { actionArea } .navigationTitle(summary?.title ?? "Session") .navigationBarTitleDisplayMode(.inline) + // With a session open the floating chat bar owns the bottom edge; the tab bar below it + // would be a redundant second bar (and clip the glass action area), so hide it while + // this detail is pushed. A no-op in the iPad split, which has no tab bar. + .toolbar(.hidden, for: .tabBar) .toolbar { ToolbarItem(placement: .topBarTrailing) { sessionMenu } } From fb1eb21a1fc6d344f9546e886b75caa5a0cebd74 Mon Sep 17 00:00:00 2001 From: Andrew Blakeslee Moore Date: Sun, 5 Jul 2026 16:23:45 -0700 Subject: [PATCH 140/143] Bottom Tab Bar Visibility Adjustment Nucleic-Session: 747527BF-AEE9-4939-BE70-A14F1F3BBBA8 Co-authored-by: Nucleic --- NucleicRemote/NucleicRemote/Views/SessionsView.swift | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/NucleicRemote/NucleicRemote/Views/SessionsView.swift b/NucleicRemote/NucleicRemote/Views/SessionsView.swift index 81ad3bb..529aa2a 100644 --- a/NucleicRemote/NucleicRemote/Views/SessionsView.swift +++ b/NucleicRemote/NucleicRemote/Views/SessionsView.swift @@ -114,22 +114,22 @@ struct SessionsView: View { } } - /// The circular plus button, styled to match the transcript's jump-to-bottom FAB: an accent - /// disc with a soft shadow, floated clear of the connection chip. + /// The circular plus button, on Liquid Glass rather than a solid teal disc — an accent-tinted + /// glyph over a translucent glass circle so it reads as a floating control, not a stamped + /// button. Sits low over the list, clear of the connection chip. private var newChatButton: some View { Button { showNewChat = true } label: { Image(systemName: "plus") .font(.system(size: 22, weight: .semibold)) - .foregroundStyle(.white) + .foregroundStyle(Palette.accent) .frame(width: 56, height: 56) - .background(Palette.accent, in: .circle) - .shadow(color: .black.opacity(0.25), radius: 6, y: 2) + .glassCircle() } .buttonStyle(.plain) .padding(.trailing, 20) - .padding(.bottom, 20) + .padding(.bottom, 6) .accessibilityLabel("New chat") } From 24211f60d324acd076190039987473809585a88e Mon Sep 17 00:00:00 2001 From: Andrew Blakeslee Moore Date: Sun, 5 Jul 2026 16:23:50 -0700 Subject: [PATCH 141/143] Bottom Tab Bar Visibility Adjustment Nucleic-Session: 747527BF-AEE9-4939-BE70-A14F1F3BBBA8 Co-authored-by: Nucleic --- NucleicRemote/NucleicRemote/Views/SessionsView.swift | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/NucleicRemote/NucleicRemote/Views/SessionsView.swift b/NucleicRemote/NucleicRemote/Views/SessionsView.swift index 529aa2a..5597625 100644 --- a/NucleicRemote/NucleicRemote/Views/SessionsView.swift +++ b/NucleicRemote/NucleicRemote/Views/SessionsView.swift @@ -108,7 +108,10 @@ struct SessionsView: View { } } } - .presentationDetents([.medium, .large]) + // The composer needs only a fraction of the screen — a compact height by default, + // still expandable if the advanced options are opened. + .presentationDetents([.height(360), .large]) + .presentationDragIndicator(.visible) } .safeAreaInset(edge: .bottom) { ConnectionChip().padding(.bottom, 8) } } From b1ebd202e52c261991ac030d1e70aee5b41bcd86 Mon Sep 17 00:00:00 2001 From: Andrew Blakeslee Moore Date: Sun, 5 Jul 2026 16:24:03 -0700 Subject: [PATCH 142/143] Bottom Tab Bar Visibility Adjustment Nucleic-Session: 747527BF-AEE9-4939-BE70-A14F1F3BBBA8 Co-authored-by: Nucleic --- NucleicRemote/NucleicRemote/Views/Theme.swift | 3 +++ 1 file changed, 3 insertions(+) diff --git a/NucleicRemote/NucleicRemote/Views/Theme.swift b/NucleicRemote/NucleicRemote/Views/Theme.swift index 1417988..c6bcce3 100644 --- a/NucleicRemote/NucleicRemote/Views/Theme.swift +++ b/NucleicRemote/NucleicRemote/Views/Theme.swift @@ -373,6 +373,9 @@ extension View { func glassSurface(cornerRadius: CGFloat = 24) -> some View { modifier(GlassSurface(cornerRadius: cornerRadius)) } + /// Back a circular control (the floating New-chat FAB) with interactive Liquid Glass — the + /// round analogue of `glassSurface`, falling back to an ultra-thin material disc pre-iOS 26. + func glassCircle() -> some View { modifier(GlassCircle()) } /// Apply the teal accent app-wide. func nucleicTint() -> some View { tint(Palette.accent) } /// Give a text field a way to release the keyboard. Without this, iOS leaves a focused From 98d302c41d809746d1112ca85b343c09e0a5eacd Mon Sep 17 00:00:00 2001 From: Andrew Blakeslee Moore Date: Sun, 5 Jul 2026 16:24:10 -0700 Subject: [PATCH 143/143] Bottom Tab Bar Visibility Adjustment Nucleic-Session: 747527BF-AEE9-4939-BE70-A14F1F3BBBA8 Co-authored-by: Nucleic --- NucleicRemote/NucleicRemote/Views/Theme.swift | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/NucleicRemote/NucleicRemote/Views/Theme.swift b/NucleicRemote/NucleicRemote/Views/Theme.swift index c6bcce3..d6a3285 100644 --- a/NucleicRemote/NucleicRemote/Views/Theme.swift +++ b/NucleicRemote/NucleicRemote/Views/Theme.swift @@ -354,6 +354,22 @@ struct GlassSurface: ViewModifier { } } +/// A circular Liquid Glass backing for a floating round control (the New-chat FAB). Real +/// interactive `glassEffect` on iOS 26; an ultra-thin material disc with a hairline edge and a +/// soft shadow on earlier systems so it still lifts off the list. +struct GlassCircle: ViewModifier { + func body(content: Content) -> some View { + if #available(iOS 26.0, *) { + content.glassEffect(.regular.interactive(), in: .circle) + } else { + content + .background(.ultraThinMaterial, in: .circle) + .overlay(Circle().strokeBorder(Color.primary.opacity(0.08), lineWidth: 1)) + .shadow(color: .black.opacity(0.2), radius: 6, y: 2) + } + } +} + /// A lifted card surface with a hairline border — the mobile echo of `AppTheme.surface`. struct CardBackground: ViewModifier { var padding: CGFloat = 14