From 34aa01a7240f77992d2ac0679c8ba3a88cbbabc7 Mon Sep 17 00:00:00 2001 From: Andrew Blakeslee Moore Date: Fri, 3 Jul 2026 23:59:29 -0700 Subject: [PATCH 01/14] iPad: adaptive sidebar+detail shell and Mac-style diff (remote Phases 1-2) Grow the universal NucleicRemote app into a width-adaptive shell so a regular-width iPad renders the macOS sidebar+detail IA while the iPhone keeps its TabView -- both over the same RemoteStore projection (one host authority, N renderers). Phase 1 (adaptive shell): - AdaptiveRootView branches on horizontalSizeClass: CompactRootView (the existing iPhone TabView, moved verbatim) vs SplitRootView (NavigationSplitView) on regular width. - SplitSidebar: Home/Projects/To-dos/Settings destinations + sessions grouped under their projects (attention-sorted), connection chip footer. - SplitDetail selects a destination or a session; a selected session reuses SessionDetailView keyed .id(sessionID) so switching drives open/close. - RemoteStore.closeOpen(_:) is now id-guarded so a split-view A->B switch (onAppear(B) before onDisappear(A)) can't tear down B's fresh subscription. - IdentityStore.deviceID idiom-tags the prefix (ipad-/iphone-) for new installs so the host lists a paired iPad correctly. Phase 2 (width tuning + diff): - readableColumn() caps+centers Home and the transcript on wide layouts; a no-op at phone/portrait width. - SessionDiffView switches on available width (GeometryReader): a Mac-style two-pane diff (file list + selected file's patch) on wide/landscape, the phone stack otherwise. UnifiedPatch splits the combined patch per file. Read-only, same wire, no protocol change. - Demo diff fixture now carries both files' patches. iPhone layout and behavior unchanged. Builds clean; verified in the iPad simulator (demo mode). Co-Authored-By: Claude Opus 4.8 --- .../NucleicRemote/Models/IdentityStore.swift | 16 +- .../NucleicRemote/Models/RemoteStore.swift | 27 ++- .../NucleicRemote/NucleicRemoteApp.swift | 23 +- .../Views/AdaptiveRootView.swift | 213 +++++++++++++++++ .../NucleicRemote/Views/HomeView.swift | 3 + .../NucleicRemote/Views/Layout.swift | 12 + .../Views/SessionDetailView.swift | 214 +++++++++++++++--- 7 files changed, 450 insertions(+), 58 deletions(-) create mode 100644 NucleicRemote/NucleicRemote/Views/AdaptiveRootView.swift create mode 100644 NucleicRemote/NucleicRemote/Views/Layout.swift diff --git a/NucleicRemote/NucleicRemote/Models/IdentityStore.swift b/NucleicRemote/NucleicRemote/Models/IdentityStore.swift index 4277b5f..4203cd1 100644 --- a/NucleicRemote/NucleicRemote/Models/IdentityStore.swift +++ b/NucleicRemote/NucleicRemote/Models/IdentityStore.swift @@ -1,6 +1,9 @@ import Foundation import Security import NucleicProtocol +#if canImport(UIKit) +import UIKit +#endif /// What the phone pins about its Mac at pairing (SYNC §4.2): the host's static key (for IK /// reconnect), a display name, and the transport + connection hint from the QR — LAN @@ -42,11 +45,22 @@ enum IdentityStore { static func deviceID() -> String { let defaults = UserDefaults.standard if let existing = defaults.string(forKey: deviceIDKey) { return existing } - let id = "iphone-" + UUID().uuidString.prefix(8).lowercased() + let id = deviceIDPrefix + UUID().uuidString.prefix(8).lowercased() defaults.set(id, forKey: deviceIDKey) return id } + /// Idiom-tagged prefix so the host lists a paired device with the right kind/icon + /// (`ipad-…` vs `iphone-…`). Only stamps *freshly generated* ids — an existing install + /// keeps whatever id it already persisted, so upgrading a phone never changes its identity. + private static var deviceIDPrefix: String { + #if canImport(UIKit) + return UIDevice.current.userInterfaceIdiom == .pad ? "ipad-" : "iphone-" + #else + return "iphone-" + #endif + } + static func loadPairedHost() -> PairedHost? { guard let data = UserDefaults.standard.data(forKey: pairedHostKey) else { return nil } return try? JSONDecoder().decode(PairedHost.self, from: data) diff --git a/NucleicRemote/NucleicRemote/Models/RemoteStore.swift b/NucleicRemote/NucleicRemote/Models/RemoteStore.swift index 68398c0..f7e2215 100644 --- a/NucleicRemote/NucleicRemote/Models/RemoteStore.swift +++ b/NucleicRemote/NucleicRemote/Models/RemoteStore.swift @@ -290,6 +290,17 @@ final class RemoteStore: ObservableObject { + if (!token) throw new AuthError("missing bearer token") return verify(token) } + diff --git a/auth/session.ts b/auth/session.ts + new file mode 100644 + --- /dev/null + +++ b/auth/session.ts + @@ -0,0 +1,6 @@ + +export interface Session { + + userId: string + + issuedAt: number + +} + + + +export const SESSION_TTL = 3600 """) } @@ -648,11 +659,17 @@ final class RemoteStore: ObservableObject { ApprovalID(rawValue: "demo-appr-\(sessionID.rawValue)") } - func closeOpen() { - if let id = openSessionID { - send(.unsubscribe(id)) - markOpened(id) // everything up to now has been seen - } + /// Close a session's live subscription. `id` names *which* session is closing — the detail + /// view passes its own. On iPad's split view, switching session A→B can mount B (which calls + /// `open(B)`, setting `openSessionID = B`) *before* A's detail disappears; so we always + /// unsubscribe the named session but only tear down the shared open-state when it still + /// belongs to that session — otherwise we'd wipe B's freshly-loaded transcript. Called with + /// no argument it closes whatever is currently open (the iPhone push/pop path, unchanged). + func closeOpen(_ id: SessionID? = nil) { + guard let target = id ?? openSessionID else { return } + send(.unsubscribe(target)) + markOpened(target) // everything up to now has been seen + guard openSessionID == target else { return } openSessionID = nil openEvents = [] openApprovals = [] diff --git a/NucleicRemote/NucleicRemote/NucleicRemoteApp.swift b/NucleicRemote/NucleicRemote/NucleicRemoteApp.swift index 7a5fc6f..267c7c1 100644 --- a/NucleicRemote/NucleicRemote/NucleicRemoteApp.swift +++ b/NucleicRemote/NucleicRemote/NucleicRemoteApp.swift @@ -36,8 +36,6 @@ struct NucleicRemoteApp: App { struct RootView: View { @EnvironmentObject var store: RemoteStore - // Initial tab; overridable via NUCLEIC_TAB for offline UI previews. - @State private var tab = Int(ProcessInfo.processInfo.environment["NUCLEIC_TAB"] ?? "") ?? 0 // Appearance preferences — same storage keys and semantics as the Mac's Settings → // Appearance, so both devices honor the same choices. @AppStorage(AppAppearance.storageKey) private var appearanceRaw = AppAppearance.system.rawValue @@ -52,30 +50,15 @@ struct RootView: View { DemoBanner() Group { if store.isPaired { - TabView(selection: $tab) { - HomeView() - .tabItem { Label("Home", systemImage: "house") }.tag(0) - SessionsView() - .tabItem { Label("Sessions", systemImage: "square.stack.3d.up") } - .badge(store.needsYouCount).tag(1) - ProjectsView() - .tabItem { Label("Projects", systemImage: "folder") }.tag(2) - TodosView() - .tabItem { Label("To-dos", systemImage: "checklist") }.tag(3) - SettingsView() - .tabItem { Label("Settings", systemImage: "gearshape") }.tag(4) - } + // Width-adaptive shell: the iPhone's TabView on compact width, the Mac's + // sidebar+detail split on a regular-width iPad (see AdaptiveRootView). + AdaptiveRootView() } else { PairingIntroView() } } } .overlay(alignment: .bottom) { ErrorBubble() } - // A notification tap routes to its session: jump to the Sessions tab, where - // `SessionsView` consumes `pendingRoute` and pushes the detail. - .onChange(of: store.pendingRoute) { _, route in - if route != nil { tab = 1 } - } .tint(Palette.accent) .preferredColorScheme((AppAppearance(rawValue: appearanceRaw) ?? .system).colorScheme) .dynamicTypeSize((AppTextSize(rawValue: textSizeRaw) ?? .medium).dynamicTypeSize) diff --git a/NucleicRemote/NucleicRemote/Views/AdaptiveRootView.swift b/NucleicRemote/NucleicRemote/Views/AdaptiveRootView.swift new file mode 100644 index 0000000..653c521 --- /dev/null +++ b/NucleicRemote/NucleicRemote/Views/AdaptiveRootView.swift @@ -0,0 +1,213 @@ +import SwiftUI +import NucleicProtocol + +/// Chooses the navigation shell by width so one universal binary serves both idioms: +/// • **compact** (iPhone, and iPad in Slide Over / narrow split) → the five-tab `TabView`. +/// • **regular** (iPad full-screen / Stage Manager) → the Mac's sidebar + detail split. +/// +/// Both render the SAME `RemoteStore` projection — the iPad is a third renderer of the one host +/// authority (UX_MACOS §8), not a fork. The store holds all real state, so a size-class flip +/// (e.g. resizing a Stage Manager window) only swaps chrome; sessions, connection, and the open +/// transcript survive it. +struct AdaptiveRootView: View { + @Environment(\.horizontalSizeClass) private var sizeClass + + var body: some View { + if sizeClass == .regular { + SplitRootView() + } else { + CompactRootView() + } + } +} + +// MARK: - Compact (iPhone / narrow multitasking) + +/// The original iPhone shell, relocated verbatim from `RootView`: a five-tab `TabView`. A +/// notification tap routes to the Sessions tab, where `SessionsView` consumes `pendingRoute` +/// and pushes the detail. +struct CompactRootView: View { + @EnvironmentObject var store: RemoteStore + // Initial tab; overridable via NUCLEIC_TAB for offline UI previews. + @State private var tab = Int(ProcessInfo.processInfo.environment["NUCLEIC_TAB"] ?? "") ?? 0 + + var body: some View { + TabView(selection: $tab) { + HomeView() + .tabItem { Label("Home", systemImage: "house") }.tag(0) + SessionsView() + .tabItem { Label("Sessions", systemImage: "square.stack.3d.up") } + .badge(store.needsYouCount).tag(1) + ProjectsView() + .tabItem { Label("Projects", systemImage: "folder") }.tag(2) + TodosView() + .tabItem { Label("To-dos", systemImage: "checklist") }.tag(3) + SettingsView() + .tabItem { Label("Settings", systemImage: "gearshape") }.tag(4) + } + // A notification tap routes to its session by jumping to the Sessions tab. + .onChange(of: store.pendingRoute) { _, route in + if route != nil { tab = 1 } + } + } +} + +// MARK: - Regular (iPad sidebar + detail) + +/// What the sidebar can select. Mirrors the Mac sidebar: a few global destinations plus the +/// live sessions themselves (grouped by project). Projects are reached through the `projects` +/// destination (`ProjectsView`), which also hosts the scoped "start a chat here" composer. +enum SidebarItem: Hashable { + case home + case projects + case todos + case settings + case session(SessionID) +} + +/// The Mac's information architecture on iPad: a two-column `NavigationSplitView` whose leading +/// column carries the destinations + the project→session tree, and whose detail is the selected +/// session (or a destination view). Selection lives here and binds the same `openSessionID` +/// lifecycle the iPhone push/pop path uses — the detail is keyed by session id so switching one +/// session for another fires `open`/`closeOpen` exactly as a push/pop would. +struct SplitRootView: View { + @EnvironmentObject var store: RemoteStore + @State private var selection: SidebarItem? = .home + @State private var columnVisibility: NavigationSplitViewVisibility = .all + + var body: some View { + NavigationSplitView(columnVisibility: $columnVisibility) { + SplitSidebar(selection: $selection) + } detail: { + SplitDetail(selection: selection) + } + .navigationSplitViewStyle(.balanced) + // Notification tap → select its session directly (the regular-width analogue of the + // compact path's jump-to-Sessions-tab). Then clear the request so it isn't re-consumed. + .onChange(of: store.pendingRoute) { _, route in + guard let route else { return } + selection = .session(route) + store.pendingRoute = nil + } + .onAppear { + // Offline UI previews (NUCLEIC_DEMO): open straight into one session. + if let raw = ProcessInfo.processInfo.environment["NUCLEIC_DEMO_SESSION"], !raw.isEmpty { + selection = .session(SessionID(rawValue: raw)) + } + } + } +} + +/// The leading column: global destinations, then the live sessions grouped under their projects +/// (attention-first within each group), mirroring the Mac sidebar tree. The persistent +/// connection status sits in the footer — one home for it, versus the phone's per-tab inset. +private struct SplitSidebar: View { + @EnvironmentObject var store: RemoteStore + @Binding var selection: SidebarItem? + + /// 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 + } + var groups: [(name: String, rows: [WireSessionSummary])] = [] + var placed = Set() + for project in store.dashboard.projects { + let rows = live.filter { $0.projectID == project.id.rawValue } + guard !rows.isEmpty else { continue } + groups.append((project.name, rows)) + rows.forEach { placed.insert($0.sessionID) } + } + let orphans = live.filter { !placed.contains($0.sessionID) } + if !orphans.isEmpty { groups.append(("Other", orphans)) } + return groups + } + + var body: some View { + List(selection: $selection) { + Section { + Label("Home", systemImage: "house").tag(SidebarItem.home) + Label("Projects", systemImage: "folder").tag(SidebarItem.projects) + Label("To-dos", systemImage: "checklist").tag(SidebarItem.todos) + Label("Settings", systemImage: "gearshape").tag(SidebarItem.settings) + } + if projectGroups.isEmpty { + Section("Sessions") { + Text(store.connectivity.isLive + ? "Start a session from Home or the Mac to see it here." + : "Waiting to connect to \(store.hostName)…") + .font(.callout).foregroundStyle(.secondary) + } + } else { + ForEach(projectGroups, id: \.name) { group in + Section(group.name) { + ForEach(group.rows, id: \.sessionID) { summary in + SessionRow(summary: summary, showProjectName: false) + .tag(SidebarItem.session(summary.sessionID)) + .listRowBackground(SessionRowWash(summary: summary)) + .swipeActions(edge: .leading) { + Button { + store.setFavorite(summary.sessionID, !summary.favorite) + } label: { + Label("Favorite", systemImage: summary.favorite ? "star.slash" : "star.fill") + }.tint(.yellow) + } + .swipeActions(edge: .trailing) { + Button(role: .destructive) { + store.deleteSession(summary.sessionID) + } label: { Label("Delete", systemImage: "trash") } + Button { + store.setArchived(summary.sessionID, !summary.archived) + } label: { + Label(summary.archived ? "Unarchive" : "Archive", + systemImage: summary.archived ? "tray.and.arrow.up" : "archivebox") + }.tint(.gray) + } + } + } + } + } + } + .listStyle(.sidebar) + .navigationTitle(store.hostName.isEmpty ? "Nucleic" : store.hostName) + .toolbar { + ToolbarItem(placement: .primaryAction) { + Button { store.refreshSessions() } label: { + Image(systemName: "arrow.clockwise") + } + .help("Refresh") + } + } + .safeAreaInset(edge: .bottom) { + ConnectionChip().padding(.bottom, 10) + } + } +} + +/// The detail column. A selected session reuses the existing `SessionDetailView` unchanged +/// (it owns its own subscribe/unsubscribe on appear/disappear); it's keyed `.id(sessionID)` so +/// switching sessions remounts and drives that lifecycle. The destination views already embed +/// their own `NavigationStack`, so they're shown directly. +private struct SplitDetail: View { + let selection: SidebarItem? + + var body: some View { + switch selection { + case .home, .none: + HomeView() + case .projects: + ProjectsView() + case .todos: + TodosView() + case .settings: + SettingsView() + case .session(let id): + NavigationStack { + SessionDetailView(sessionID: id) + } + .id(id) + } + } +} diff --git a/NucleicRemote/NucleicRemote/Views/HomeView.swift b/NucleicRemote/NucleicRemote/Views/HomeView.swift index 28f6941..f1bd9c7 100644 --- a/NucleicRemote/NucleicRemote/Views/HomeView.swift +++ b/NucleicRemote/NucleicRemote/Views/HomeView.swift @@ -86,6 +86,9 @@ struct HomeView: View { StartChatComposer() } .padding() + // Cap + center the dashboard on a wide iPad so it reads like the Mac home rather + // than a stretched phone; a no-op at phone width. + .readableColumn() } .navigationTitle("Home") .navigationBarTitleDisplayMode(.inline) diff --git a/NucleicRemote/NucleicRemote/Views/Layout.swift b/NucleicRemote/NucleicRemote/Views/Layout.swift new file mode 100644 index 0000000..efdd553 --- /dev/null +++ b/NucleicRemote/NucleicRemote/Views/Layout.swift @@ -0,0 +1,12 @@ +import SwiftUI + +/// Width tuning for the universal layout. In a wide iPad detail column, content designed for a +/// phone runs edge-to-edge and loses the Mac's comfortable reading measure; `readableColumn` +/// caps its width and centers it. It's a no-op on the phone and on iPad compact multitasking, +/// where the available width is already below the cap — so the iPhone layout is unchanged. +extension View { + func readableColumn(_ maxWidth: CGFloat = 820) -> some View { + frame(maxWidth: maxWidth, alignment: .leading) + .frame(maxWidth: .infinity, alignment: .center) + } +} diff --git a/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift b/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift index 64473d3..5cad92b 100644 --- a/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift +++ b/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift @@ -71,7 +71,9 @@ struct SessionDetailView: View { Button("Cancel", role: .cancel) {} } .onAppear { store.open(sessionID) } - .onDisappear { store.closeOpen() } + // Pass our own id so an iPad split-view A→B switch (which may mount B before A + // disappears) unsubscribes A without tearing down B's just-opened state. + .onDisappear { store.closeOpen(sessionID) } } /// Whether this session's project is under Nucleic Control (gates Orchestra + autoship). @@ -297,6 +299,9 @@ struct TranscriptList: View { } } .padding() + // Cap the transcript to a readable measure on a wide iPad so lines don't run + // edge-to-edge on a 13-inch screen; a no-op at phone width. + .readableColumn() } // A drag on the transcript dismisses the keyboard, so a tall multiline composer can // be put away without leaving the session. @@ -310,25 +315,26 @@ struct TranscriptList: View { } } -/// The Diff tab with the real patch (the Mac Diff tab, phone form): a per-file summary list -/// over the unified patch, colored +/− per line, fetched on demand via `fetchDiff`. +/// The Diff tab, 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. struct SessionDiffView: View { let diff: WireSessionDiff? let loading: Bool + /// Below this available width the two-pane diff would leave the patch too cramped, so we + /// keep the phone stack. Picks two-pane on a wide iPad (landscape, or a large iPad) and the + /// stack on a phone or a narrow portrait split — based on real width, not just size class. + private let twoPaneMinWidth: CGFloat = 700 var body: some View { if let diff, diff.stat.filesChanged > 0 { - ScrollView { - VStack(alignment: .leading, spacing: 14) { - fileList(diff) - PatchText(patch: diff.patch) - if diff.truncated { - Label("Patch truncated — open the Mac for the rest.", - systemImage: "scissors") - .font(.caption).foregroundStyle(.secondary) - } + GeometryReader { geo in + if geo.size.width >= twoPaneMinWidth { + SplitDiffView(diff: diff) + } else { + stacked(diff) } - .padding() } } else if loading { ProgressView("Fetching diff…") @@ -338,28 +344,122 @@ struct SessionDiffView: View { } } - private func fileList(_ diff: WireSessionDiff) -> some View { - VStack(alignment: .leading, spacing: 6) { - Text("\(diff.stat.filesChanged) file\(diff.stat.filesChanged == 1 ? "" : "s") changed · +\(diff.stat.added) −\(diff.stat.removed)") - .font(.subheadline.weight(.semibold)) - ForEach(diff.files) { file in - HStack(spacing: 8) { - Image(systemName: statusIcon(file.status)) - .font(.caption) - .foregroundStyle(statusColor(file.status)) - Text(file.path) - .font(.caption.monospaced()) - .lineLimit(1).truncationMode(.middle) - Spacer(minLength: 8) - Text("+\(file.added)").font(.caption2.monospacedDigit()).foregroundStyle(Palette.success) - Text("−\(file.removed)").font(.caption2.monospacedDigit()).foregroundStyle(Palette.danger) + /// Phone form: the file summary over one scrolling unified patch. + private func stacked(_ diff: WireSessionDiff) -> some View { + ScrollView { + VStack(alignment: .leading, spacing: 14) { + VStack(alignment: .leading, spacing: 6) { + DiffStatHeader(stat: diff.stat) + ForEach(diff.files) { DiffFileRow(file: $0) } } + .card() + PatchText(patch: diff.patch) + if diff.truncated { TruncatedPatchNote() } + } + .padding() + .readableColumn() + } + } +} + +/// iPad form: the Mac's two-pane diff. A tappable file list on the left drives the selected +/// file's patch on the right. Row taps use a plain `Button` (not `List(selection:)`, whose +/// single-select tap handling is unreliable outside an edit-mode / split-view context). +private struct SplitDiffView: View { + let diff: WireSessionDiff + @State private var selected: String? + + /// The combined patch split into per-file sections, keyed by file path. + private var sections: [String: String] { UnifiedPatch.sections(diff.patch, files: diff.files) } + private var current: String? { selected ?? diff.files.first?.path } + + var body: some View { + HStack(spacing: 0) { + fileList + .frame(width: 280) + .background(Color(.secondarySystemBackground)) + Divider() + patchPane + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + .onAppear { if selected == nil { selected = diff.files.first?.path } } + // Keep the selection valid as the diff refetches (files can appear/vanish between turns). + .onChange(of: diff.files) { _, files in + if selected == nil || !files.contains(where: { $0.path == selected }) { + selected = files.first?.path } } - .card() } - private func statusIcon(_ status: String) -> String { + private var fileList: some View { + ScrollView { + LazyVStack(alignment: .leading, spacing: 0) { + DiffStatHeader(stat: diff.stat) + .padding(.horizontal, 12).padding(.vertical, 10) + .frame(maxWidth: .infinity, alignment: .leading) + Divider() + ForEach(diff.files) { file in + Button { selected = file.path } label: { + DiffFileRow(file: file) + .padding(.horizontal, 12).padding(.vertical, 9) + .frame(maxWidth: .infinity, alignment: .leading) + .background(current == file.path + ? Palette.accent.opacity(0.15) : Color.clear) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + } + if diff.truncated { TruncatedPatchNote().padding(12) } + } + } + } + + @ViewBuilder + private var patchPane: some View { + if let path = current, let patch = sections[path], !patch.isEmpty { + ScrollView { PatchText(patch: patch).padding() } + } else if current != nil { + // A file with no textual hunk (binary, or a section dropped by patch truncation). + ContentUnavailableView( + "No preview", systemImage: "doc", + description: Text("This file has no textual diff\(diff.truncated ? " in the fetched patch" : "").") + ) + } else { + ContentUnavailableView("Select a file", systemImage: "sidebar.left") + } + } +} + +/// The "N files changed · +A −R" header shared by both diff forms. +private struct DiffStatHeader: View { + let stat: DiffStat + var body: some View { + Text("\(stat.filesChanged) file\(stat.filesChanged == 1 ? "" : "s") changed · +\(stat.added) −\(stat.removed)") + .font(.subheadline.weight(.semibold)) + } +} + +/// One file's row: status glyph, path, and its +/− counts. Shared by the phone summary and the +/// iPad file list. +struct DiffFileRow: View { + let file: WireFileDiff + var body: some View { + HStack(spacing: 8) { + Image(systemName: DiffStatus.icon(file.status)) + .font(.caption) + .foregroundStyle(DiffStatus.color(file.status)) + Text(file.path) + .font(.caption.monospaced()) + .lineLimit(1).truncationMode(.middle) + Spacer(minLength: 8) + Text("+\(file.added)").font(.caption2.monospacedDigit()).foregroundStyle(Palette.success) + Text("−\(file.removed)").font(.caption2.monospacedDigit()).foregroundStyle(Palette.danger) + } + } +} + +enum DiffStatus { + static func icon(_ status: String) -> String { switch status { case "added", "untracked": "plus.circle" case "deleted": "minus.circle" @@ -367,8 +467,7 @@ struct SessionDiffView: View { default: "pencil.circle" } } - - private func statusColor(_ status: String) -> Color { + static func color(_ status: String) -> Color { switch status { case "added", "untracked": Palette.success case "deleted": Palette.danger @@ -377,6 +476,57 @@ struct SessionDiffView: View { } } +private struct TruncatedPatchNote: View { + var body: some View { + Label("Patch truncated — open the Mac for the rest.", systemImage: "scissors") + .font(.caption).foregroundStyle(.secondary) + .frame(maxWidth: .infinity, alignment: .leading) + } +} + +/// Splits a combined `git diff` patch into per-file sections for the iPad's two-pane view. +enum UnifiedPatch { + /// Map from file path to that file's slice of the unified patch. Sections begin at a + /// `diff --git a/… b/…` line. When the section count matches `files` we pair positionally + /// (the host emits patch and files together, in order); otherwise we key each section by the + /// new path parsed from its header. Callers fall back to a "no preview" state on a miss. + static func sections(_ patch: String, files: [WireFileDiff]) -> [String: String] { + guard patch.contains("diff --git ") else { + // A single-file patch with no git header: attribute the whole thing to the sole file. + return files.count == 1 ? [files[0].path: patch] : [:] + } + var chunks: [String] = [] + var current: [Substring] = [] + for line in patch.split(separator: "\n", omittingEmptySubsequences: false) { + if line.hasPrefix("diff --git ") { + if !current.isEmpty { chunks.append(current.joined(separator: "\n")) } + current = [line] + } else if !current.isEmpty { + current.append(line) + } + } + if !current.isEmpty { chunks.append(current.joined(separator: "\n")) } + + var result: [String: String] = [:] + if chunks.count == files.count { + for (file, chunk) in zip(files, chunks) { result[file.path] = chunk } + } else { + for chunk in chunks where newPath(chunk) != nil { result[newPath(chunk)!] = chunk } + } + return result + } + + /// The new-side path from a section's `diff --git a/OLD b/NEW` header. Nil when the header is + /// absent or the path is quoted/spaced in a way we don't split cleanly. + private static func newPath(_ chunk: String) -> String? { + guard let header = chunk.split(separator: "\n", maxSplits: 1).first, + header.hasPrefix("diff --git "), + let bRange = header.range(of: " b/") else { return nil } + let path = header[bRange.upperBound...] + return path.isEmpty ? nil : String(path) + } +} + /// The unified patch, one `Text` per line with the classic +/− coloring. Lines scroll /// horizontally as a block so long lines don't wrap into noise. private struct PatchText: View { From 850ee0cd961c9b0e6e02c2df65055fd7e3367917 Mon Sep 17 00:00:00 2001 From: Andrew Blakeslee Moore Date: Sat, 4 Jul 2026 00:21:32 -0700 Subject: [PATCH 02/14] iPad: render git commits as a structured card in the transcript Port the desktop GitBlockCard's commit rendering (+ CommandDisclosure) to the remote transcript: a Bash `git commit` now shows the commit subject as a headline and the message body as Markdown, with the raw command one tap away under "Show command" -- instead of a raw `git commit -F - <<'EOF' ...` blob. - GitCommitCard.swift: the card + the CommandDisclosure helper (portable SwiftUI, mirroring Sources/NucleicApp/{GitBlockCard,CommandDisclosure}). - GitCommitSummary.parse (in HostCommandSummary.swift) extracts the message from -m/--message args or a `-F -` heredoc body, reusing the file-private shell Lexer already used for host-exec summaries; skips env prefixes and git global flags, and only fires for real `git commit` segments. - Hooked into ToolCallCard.details, gated to shell tools so non-shell input is never misread as a commit. - Demo transcript gains a git-commit call so the card is exercisable offline. SummaryCard is intentionally not ported -- it's the host-only Apple-Intelligence recap, which a remote has no way to generate. Verified in the iPad simulator (demo mode); parser covered by a standalone test. Co-Authored-By: Claude Opus 4.8 --- .../NucleicRemote/Models/RemoteStore.swift | 10 ++- .../Views/Transcript/GitCommitCard.swift | 89 +++++++++++++++++++ .../Views/Transcript/HostCommandSummary.swift | 81 +++++++++++++++++ .../Views/Transcript/ToolGroupRow.swift | 10 ++- 4 files changed, 187 insertions(+), 3 deletions(-) create mode 100644 NucleicRemote/NucleicRemote/Views/Transcript/GitCommitCard.swift diff --git a/NucleicRemote/NucleicRemote/Models/RemoteStore.swift b/NucleicRemote/NucleicRemote/Models/RemoteStore.swift index f7e2215..6ceabc8 100644 --- a/NucleicRemote/NucleicRemote/Models/RemoteStore.swift +++ b/NucleicRemote/NucleicRemote/Models/RemoteStore.swift @@ -624,6 +624,9 @@ final class RemoteStore: ObservableObject { AgentEvent(sessionID: sessionID, seq: seq, at: Date(), backend: .claudeCode, nativeType: nil, kind: kind) } + // A realistic `git commit` (heredoc message) so the transcript's structured commit card + // is exercisable offline: expand the Bash call to see the subject + Markdown body. + let demoCommitCommand = "git commit -F - <<'EOF'\nfix: harden auth middleware\n\nRequire a Bearer token and reject a missing or blank one.\n\n- extract `requireSession`\n- add a `Bearer` prefix check\nEOF" openEvents = [ event(1, .sessionStarted(SessionStarted( backendSessionID: "demo", model: "claude-opus-4-8[1m]", cwd: "~/code/nucleic", toolNames: []))), @@ -639,8 +642,11 @@ final class RemoteStore: ObservableObject { event(11, .toolCallStarted(ToolCall(toolCallID: "t3", name: "Task", input: ["description": "Audit other call sites", "prompt": "Find every caller of the old auth API."]))), event(12, .toolCallCompleted(ToolCall(toolCallID: "t3", name: "Task", input: ["description": "Audit other call sites"]))), event(13, .toolResult(ToolResult(toolCallID: "t3", content: "Checked 7 files; 1 stale caller updated.", isError: false))), - event(14, .usage(Usage(inputTokens: 84_300, outputTokens: 2_140, costUSD: 0.0421, contextInputTokens: 84_300))), - event(15, .runFinished(RunFinished(outcome: .completed, finalText: "Done."))), + event(14, .toolCallStarted(ToolCall(toolCallID: "t4", name: "Bash", input: ["command": .string(demoCommitCommand)]))), + event(15, .toolCallCompleted(ToolCall(toolCallID: "t4", name: "Bash", input: ["command": .string(demoCommitCommand)]))), + event(16, .toolResult(ToolResult(toolCallID: "t4", content: "[nucleic/auth-refactor 1a2b3c4] fix: harden auth middleware\n 2 files changed, 312 insertions(+), 40 deletions(-)", isError: false))), + event(17, .usage(Usage(inputTokens: 84_300, outputTokens: 2_140, costUSD: 0.0421, contextInputTokens: 84_300))), + event(18, .runFinished(RunFinished(outcome: .completed, finalText: "Done."))), ] // If this session is blocked on a human, surface a real approval card so the // Allow/Deny loop is exercisable in the demo (the seeded `a1` session). diff --git a/NucleicRemote/NucleicRemote/Views/Transcript/GitCommitCard.swift b/NucleicRemote/NucleicRemote/Views/Transcript/GitCommitCard.swift new file mode 100644 index 0000000..d01754b --- /dev/null +++ b/NucleicRemote/NucleicRemote/Views/Transcript/GitCommitCard.swift @@ -0,0 +1,89 @@ +import SwiftUI + +/// A `git commit` rendered as a structured card — the remote's echo of the desktop `GitBlockCard`: +/// the commit subject reads as a headline and the message body as Markdown, so a commit in the +/// transcript is legible at a glance instead of a raw `git commit -F - <<'EOF' …` blob. The literal +/// command stays one tap away under "Show command", so the card never hides what actually ran. +struct GitCommitCard: View { + let commit: GitCommitSummary.Commit + /// The literal command, revealed under "Show command". Omit to hide the disclosure. + var rawCommand: String? = nil + + private var accent: Color { Palette.accent } + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + HStack(alignment: .firstTextBaseline, spacing: 9) { + Image(systemName: commit.amend ? "pencil.circle" : "checkmark.seal") + .font(.callout).foregroundStyle(accent).frame(width: 16) + VStack(alignment: .leading, spacing: 2) { + Text(commit.amend ? "Amend commit" : "Commit") + .font(.caption.weight(.semibold)) + .foregroundStyle(.secondary) + if !commit.subject.isEmpty { + Text(commit.subject) + .font(.callout.weight(.semibold)) + .fixedSize(horizontal: false, vertical: true) + .textSelection(.enabled) + } + } + } + if !commit.body.isEmpty { + // The body is real commit-message Markdown (paragraphs, bullet lists) — render it + // formatted, indented under the subject. + MarkdownText(markdown: commit.body) + .padding(.leading, 25) + .textSelection(.enabled) + } + if let rawCommand, !rawCommand.isEmpty { + CommandDisclosure(command: rawCommand, accent: accent) + } + } + .padding(.horizontal, 12).padding(.vertical, 10) + .frame(maxWidth: .infinity, alignment: .leading) + .background(accent.opacity(0.06)) + .overlay(RoundedRectangle(cornerRadius: 8).strokeBorder(accent.opacity(0.16), lineWidth: 1)) + .clipShape(RoundedRectangle(cornerRadius: 8)) + } +} + +/// A disclosure that reveals the literal shell command beneath a structured command card — so a +/// command rendered as a GUI element never hides what's actually being run. The remote's port of +/// the desktop `CommandDisclosure`. +struct CommandDisclosure: View { + /// The literal command to reveal. + let command: String + /// The card's accent, applied to the toggle and the revealed block. + let accent: Color + + @State private var showCommand = false + + var body: some View { + VStack(alignment: .leading, spacing: 6) { + Button { + withAnimation(.easeInOut(duration: 0.15)) { showCommand.toggle() } + } label: { + HStack(spacing: 5) { + Image(systemName: "chevron.right") + .font(.caption2.weight(.semibold)) + .rotationEffect(.degrees(showCommand ? 90 : 0)) + Text(showCommand ? "Hide command" : "Show command") + .font(.caption.weight(.medium)) + } + .foregroundStyle(accent) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + if showCommand { + Text(command) + .font(.system(.caption, design: .monospaced)) + .foregroundStyle(.secondary) + .textSelection(.enabled) + .multilineTextAlignment(.leading) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(8) + .background(accent.opacity(0.05), in: RoundedRectangle(cornerRadius: 6)) + } + } + } +} diff --git a/NucleicRemote/NucleicRemote/Views/Transcript/HostCommandSummary.swift b/NucleicRemote/NucleicRemote/Views/Transcript/HostCommandSummary.swift index 500038d..a4f51a1 100644 --- a/NucleicRemote/NucleicRemote/Views/Transcript/HostCommandSummary.swift +++ b/NucleicRemote/NucleicRemote/Views/Transcript/HostCommandSummary.swift @@ -577,6 +577,87 @@ private enum Lexer { } } +// MARK: - Git commit summary + +/// Pulls the commit message out of a command that runs `git commit`, so a commit reads as a +/// structured card (subject headline + Markdown body) instead of a raw `git commit -F - <<'EOF' …` +/// blob — the remote's echo of the desktop `GitCommandSummary`/`GitBlockCard` (which live in +/// NucleicCore, unavailable here). Handles the two shapes agents actually use: `-m`/`--message` +/// arguments and a `-F -`/`--file=-` heredoc body. Parsed from the command string alone, reusing +/// the same shell-aware ``Lexer`` the host-exec summary uses. +enum GitCommitSummary { + struct Commit: Equatable { + var subject: String + var body: String + var amend: Bool + } + + static func parse(_ command: String) -> Commit? { + let (flattened, bodies) = Lexer.stripHeredocs(command) + guard let args = commitArguments(in: flattened) else { return nil } + + let amend = args.contains("--amend") + var messages: [String] = [] + var usesStdinFile = false + var i = 0 + while i < args.count { + let a = args[i] + if a == "-m" || a == "--message" { + if i + 1 < args.count { messages.append(args[i + 1]); i += 2; continue } + } else if a.hasPrefix("--message=") { + messages.append(String(a.dropFirst("--message=".count))) + } else if a.hasPrefix("-m"), a.count > 2 { + messages.append(String(a.dropFirst(2))) + } else if (a == "-F" || a == "--file"), i + 1 < args.count, args[i + 1] == "-" { + usesStdinFile = true + } else if a == "--file=-" || a == "-F-" { + usesStdinFile = true + } + i += 1 + } + + // Prefer explicit `-m` messages; otherwise a `-F -` heredoc body is the message. + let message: String + if !messages.isEmpty { + message = messages.joined(separator: "\n\n") + } else if usesStdinFile, + let body = bodies.first(where: { !$0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }) { + message = body + } else { + return nil + } + + // Subject = first non-blank line; body = everything after it. + let lines = message.components(separatedBy: "\n") + guard let subjectIndex = lines.firstIndex(where: { !$0.trimmingCharacters(in: .whitespaces).isEmpty }) else { + return nil + } + let subject = lines[subjectIndex].trimmingCharacters(in: .whitespaces) + let body = lines[(subjectIndex + 1)...].joined(separator: "\n") + .trimmingCharacters(in: .whitespacesAndNewlines) + return Commit(subject: subject, body: body, amend: amend) + } + + /// The arguments after `git commit` in whichever segment runs it (skipping env assignments and + /// git's global flags like `-C ` / `-c k=v`), or nil when no segment is a `git commit`. + private static func commitArguments(in flattened: String) -> [String]? { + for segment in Lexer.splitSegments(flattened) { + var tokens = Lexer.tokenize(segment) + while let first = tokens.first, Lexer.isEnvAssignment(first) { tokens.removeFirst() } + guard tokens.first == "git" else { continue } + var rest = Array(tokens.dropFirst()) + while let flag = rest.first, flag.hasPrefix("-") { + let takesValue = (flag == "-C" || flag == "-c") + rest.removeFirst() + if takesValue, !rest.isEmpty { rest.removeFirst() } + } + guard rest.first == "commit" else { continue } + return Array(rest.dropFirst()) + } + return nil + } +} + // MARK: - ToolGroup convenience extension ToolGroup { diff --git a/NucleicRemote/NucleicRemote/Views/Transcript/ToolGroupRow.swift b/NucleicRemote/NucleicRemote/Views/Transcript/ToolGroupRow.swift index 6e5da10..f097150 100644 --- a/NucleicRemote/NucleicRemote/Views/Transcript/ToolGroupRow.swift +++ b/NucleicRemote/NucleicRemote/Views/Transcript/ToolGroupRow.swift @@ -43,9 +43,17 @@ struct ToolCallCard: View { .contentShape(Rectangle()) } + /// Only shell tools carry a literal command worth structuring (a git commit); everything else + /// keeps the plain input block, so a non-shell input can't be misread as a commit. + private var isShellTool: Bool { group.name == "Bash" || group.name == "Shell" } + @ViewBuilder private var details: some View { let input = group.input.approvalDetail - if !input.isEmpty { + // A git commit reads as a structured commit card (subject + Markdown body), like the Mac — + // the raw command stays one tap away under "Show command". + if isShellTool, let commit = GitCommitSummary.parse(input) { + GitCommitCard(commit: commit, rawCommand: input) + } else if !input.isEmpty { ToolBlock(label: "Input", text: input, mono: true) } if let result = group.result { From 149af312dcc889e23dda943befafefbf8040517b Mon Sep 17 00:00:00 2001 From: Andrew Blakeslee Moore Date: Sat, 4 Jul 2026 00:27:20 -0700 Subject: [PATCH 03/14] iPad: show the commit card for host-run and pending-approval commits too Extend the git commit card beyond the Bash transcript path to the other two surfaces a commit appears on, so all three read like the Mac: - ApprovalCardView: a pending `git commit` approval (Bash or host_exec) renders the structured commit card (subject + Markdown body) with the literal command under "Show command", instead of a raw blob or the generic host breakdown -- so you see exactly the message you're granting. - HostExecToolCard: a host-run `git commit` surfaces the commit card in its expanded body (the `$ command` already sits in the header), rather than only the generic "Commit changes" purpose. Both reuse GitCommitSummary.parse + GitCommitCard from the previous commit. Co-Authored-By: Claude Opus 4.8 --- .../NucleicRemote/Views/ApprovalCardView.swift | 15 +++++++++------ .../Views/Transcript/HostExecCard.swift | 11 +++++++++-- 2 files changed, 18 insertions(+), 8 deletions(-) diff --git a/NucleicRemote/NucleicRemote/Views/ApprovalCardView.swift b/NucleicRemote/NucleicRemote/Views/ApprovalCardView.swift index 76edfbf..b8a09bb 100644 --- a/NucleicRemote/NucleicRemote/Views/ApprovalCardView.swift +++ b/NucleicRemote/NucleicRemote/Views/ApprovalCardView.swift @@ -33,13 +33,16 @@ struct ApprovalCardView: View { .background(approval.risk.color.opacity(0.2), in: Capsule()) .foregroundStyle(approval.risk.color) } - // A host_exec gate escapes the sandbox onto the macOS host, so lay out what it's - // about to run — the inferred purpose and the program/actions/flags, parsed from the - // command itself (never from the agent) — then the exact command beneath it. Every + // A git commit reads as a structured commit card (subject + Markdown body) so you can + // see exactly what you're granting; the literal command stays under "Show command". + // Otherwise a host_exec gate lays out its parsed breakdown + exact command, and every // other tool shows its untruncated detail in a single scrollable box. - if approval.toolName == HostCommandSummary.hostExecToolName, - let command = approval.input["command"]?.stringValue, - let parsed = HostCommandSummary.summary(for: command) { + if let command = approval.input["command"]?.stringValue, + let commit = GitCommitSummary.parse(command) { + GitCommitCard(commit: commit, rawCommand: command) + } else if approval.toolName == HostCommandSummary.hostExecToolName, + let command = approval.input["command"]?.stringValue, + let parsed = HostCommandSummary.summary(for: command) { HostCommandBreakdown(summary: parsed) Text("Exact command").font(.caption2.weight(.semibold)).foregroundStyle(.secondary) ApprovalDetailBox(text: command) diff --git a/NucleicRemote/NucleicRemote/Views/Transcript/HostExecCard.swift b/NucleicRemote/NucleicRemote/Views/Transcript/HostExecCard.swift index f502a28..b59726f 100644 --- a/NucleicRemote/NucleicRemote/Views/Transcript/HostExecCard.swift +++ b/NucleicRemote/NucleicRemote/Views/Transcript/HostExecCard.swift @@ -29,7 +29,10 @@ struct HostExecToolCard: View { !$0.flags.isEmpty || !$0.arguments.isEmpty || !$0.env.isEmpty } } - private var canExpand: Bool { hasOutput || hasBreakdown } + /// A host-run `git commit` — surfaced as a structured commit card (subject + Markdown body), + /// like the Mac, instead of only the generic "Commit changes" purpose. + private var commit: GitCommitSummary.Commit? { GitCommitSummary.parse(command) } + private var canExpand: Bool { hasOutput || hasBreakdown || commit != nil } var body: some View { VStack(alignment: .leading, spacing: 8) { @@ -37,7 +40,11 @@ struct HostExecToolCard: View { label: { header } .buttonStyle(.plain) if expanded { - if hasBreakdown, let parsed { + if let commit { + // The `$ command` is already shown in the header, so omit the disclosure here. + Divider().overlay(Color.primary.opacity(0.06)) + GitCommitCard(commit: commit) + } else if hasBreakdown, let parsed { Divider().overlay(Color.primary.opacity(0.06)) HostCommandBreakdown(summary: parsed, showPurpose: false) } From 15ea0f1dd20114219ed27b3e44bcd158027e5bb1 Mon Sep 17 00:00:00 2001 From: Andrew Blakeslee Moore Date: Sat, 4 Jul 2026 00:38:59 -0700 Subject: [PATCH 04/14] iPad: render multi-step / destructive shell pipelines as a step list The desktop CommandStepsCard breaks a shell pipeline (a rm/git chain) into a step list with the destructive delete flagged, instead of a raw blob. Rather than duplicate a parser, deliver that value by reusing the existing, tested HostCommandSummary + HostCommandBreakdown (a general command parser whose Invocation already carries a `destructive` flag for rm/rmdir) on the two paths that still showed raw text: - ToolCallCard.details: a shell tool whose command has >1 invocation or is destructive now renders the compact step breakdown (deletes glyphed/tinted in red) with the literal command under "Show command"; simple one-liners keep the plain input block. - ApprovalCardView: the parsed breakdown (with its sudo/deletes risk banner) now covers any Bash pipeline / destructive approval, not just host_exec. Demo transcript gains a destructive cleanup pipeline (rm -rf && git worktree prune && git branch -D) so the step list is exercisable offline. Verified in the iPad simulator: the pipeline expands to a step list with `rm -rf` flagged in red. Co-Authored-By: Claude Opus 4.8 --- NucleicRemote/NucleicRemote/Models/RemoteStore.swift | 10 ++++++++-- .../NucleicRemote/Views/ApprovalCardView.swift | 7 ++++--- .../NucleicRemote/Views/Transcript/ToolGroupRow.swift | 10 ++++++++-- 3 files changed, 20 insertions(+), 7 deletions(-) diff --git a/NucleicRemote/NucleicRemote/Models/RemoteStore.swift b/NucleicRemote/NucleicRemote/Models/RemoteStore.swift index 6ceabc8..d237e58 100644 --- a/NucleicRemote/NucleicRemote/Models/RemoteStore.swift +++ b/NucleicRemote/NucleicRemote/Models/RemoteStore.swift @@ -627,6 +627,9 @@ final class RemoteStore: ObservableObject { // A realistic `git commit` (heredoc message) so the transcript's structured commit card // is exercisable offline: expand the Bash call to see the subject + Markdown body. let demoCommitCommand = "git commit -F - <<'EOF'\nfix: harden auth middleware\n\nRequire a Bearer token and reject a missing or blank one.\n\n- extract `requireSession`\n- add a `Bearer` prefix check\nEOF" + // A multi-step, destructive shell pipeline so the transcript's step list (with the delete + // flagged in red) is exercisable offline: expand the Bash call to see the breakdown. + let demoCleanupCommand = "cd ~/code/nucleic && rm -rf .worktrees/auth-old && git worktree prune && git branch -D nucleic/auth-old" openEvents = [ event(1, .sessionStarted(SessionStarted( backendSessionID: "demo", model: "claude-opus-4-8[1m]", cwd: "~/code/nucleic", toolNames: []))), @@ -645,8 +648,11 @@ final class RemoteStore: ObservableObject { event(14, .toolCallStarted(ToolCall(toolCallID: "t4", name: "Bash", input: ["command": .string(demoCommitCommand)]))), event(15, .toolCallCompleted(ToolCall(toolCallID: "t4", name: "Bash", input: ["command": .string(demoCommitCommand)]))), event(16, .toolResult(ToolResult(toolCallID: "t4", content: "[nucleic/auth-refactor 1a2b3c4] fix: harden auth middleware\n 2 files changed, 312 insertions(+), 40 deletions(-)", isError: false))), - event(17, .usage(Usage(inputTokens: 84_300, outputTokens: 2_140, costUSD: 0.0421, contextInputTokens: 84_300))), - event(18, .runFinished(RunFinished(outcome: .completed, finalText: "Done."))), + event(17, .toolCallStarted(ToolCall(toolCallID: "t5", name: "Bash", input: ["command": .string(demoCleanupCommand)]))), + event(18, .toolCallCompleted(ToolCall(toolCallID: "t5", name: "Bash", input: ["command": .string(demoCleanupCommand)]))), + event(19, .toolResult(ToolResult(toolCallID: "t5", content: "Removed 1 worktree; deleted branch nucleic/auth-old.", isError: false))), + event(20, .usage(Usage(inputTokens: 84_300, outputTokens: 2_140, costUSD: 0.0421, contextInputTokens: 84_300))), + event(21, .runFinished(RunFinished(outcome: .completed, finalText: "Done."))), ] // If this session is blocked on a human, surface a real approval card so the // Allow/Deny loop is exercisable in the demo (the seeded `a1` session). diff --git a/NucleicRemote/NucleicRemote/Views/ApprovalCardView.swift b/NucleicRemote/NucleicRemote/Views/ApprovalCardView.swift index b8a09bb..8bdbad9 100644 --- a/NucleicRemote/NucleicRemote/Views/ApprovalCardView.swift +++ b/NucleicRemote/NucleicRemote/Views/ApprovalCardView.swift @@ -40,9 +40,10 @@ struct ApprovalCardView: View { if let command = approval.input["command"]?.stringValue, let commit = GitCommitSummary.parse(command) { GitCommitCard(commit: commit, rawCommand: command) - } else if approval.toolName == HostCommandSummary.hostExecToolName, - let command = approval.input["command"]?.stringValue, - let parsed = HostCommandSummary.summary(for: command) { + } else if let command = approval.input["command"]?.stringValue, + let parsed = HostCommandSummary.summary(for: command), + approval.toolName == HostCommandSummary.hostExecToolName + || parsed.invocations.count > 1 || parsed.isDestructive { HostCommandBreakdown(summary: parsed) Text("Exact command").font(.caption2.weight(.semibold)).foregroundStyle(.secondary) ApprovalDetailBox(text: command) diff --git a/NucleicRemote/NucleicRemote/Views/Transcript/ToolGroupRow.swift b/NucleicRemote/NucleicRemote/Views/Transcript/ToolGroupRow.swift index f097150..3eda77d 100644 --- a/NucleicRemote/NucleicRemote/Views/Transcript/ToolGroupRow.swift +++ b/NucleicRemote/NucleicRemote/Views/Transcript/ToolGroupRow.swift @@ -49,10 +49,16 @@ struct ToolCallCard: View { @ViewBuilder private var details: some View { let input = group.input.approvalDetail - // A git commit reads as a structured commit card (subject + Markdown body), like the Mac — - // the raw command stays one tap away under "Show command". + // A git commit reads as a structured commit card (subject + Markdown body); a multi-step or + // destructive shell pipeline reads as a compact step list (deletes flagged in red) — both + // like the Mac, with the literal command one tap away under "Show command". Everything else + // keeps the plain input block. if isShellTool, let commit = GitCommitSummary.parse(input) { GitCommitCard(commit: commit, rawCommand: input) + } else if isShellTool, let summary = HostCommandSummary.summary(for: input), + summary.invocations.count > 1 || summary.isDestructive { + HostCommandBreakdown(summary: summary, showPurpose: false) + CommandDisclosure(command: input, accent: Palette.accent) } else if !input.isEmpty { ToolBlock(label: "Input", text: input, mono: true) } From 25fb2eb8ad09ad2fa395a5dfcc8769f8e202d5f4 Mon Sep 17 00:00:00 2001 From: Andrew Blakeslee Moore Date: Sat, 4 Jul 2026 00:39:30 -0700 Subject: [PATCH 05/14] Mesh + session transfer: P1 done, P2 core, P4 foundation Multi-device mesh + session-transfer program (docs/MESH_TRANSFER.md). P1 (multi-select connection methods, Mac): SyncTransportSet with legacy migration; CompositeSyncListener partial-failure tolerant + per-method health; AppStore listens on all enabled methods; HostInfo.hostID = DeviceIdentity.hostID (key hash, not display name); Settings 3 method toggles + LAN-only recommendation banner. P2 core (relay hardening, security-critical, tested): nucleic-edge relayEnroll.ts X25519 proof-of-possession enroll + server-side roomId derivation; room.ts role-routed per-peer forwarding with deviceId-tag envelope + frame cap + one-host-per-room eviction; host-bearer minting bound to the PoP room. Swift cross-stack contracts pinned to test vectors: RelayEnrollment (PoP proof), RelayEnvelope (routing tag), PairingPayload relay fields. Socket transport + iOS un-gating remain deploy-gated. P4 foundation (peer model + listPeers, tested): PeerTypes (PeerKind/ PeerCapabilities/PeerSummary); Hello.deviceKind+clientCaps; WireCapabilities.canListPeers; ClientMsg.listPeers -> HostMsg.peerList; PairedDevice.kind+capabilities (decode-defaulted); ConnectionHandler records kind at pairing; SyncHost.connectedDeviceIDs(); AppStore.peerSummaries(); SyncClient .peerList event; iOS RemoteStore.meshPeers. PeerClient + Mac<->Mac pairing UI remain. All additive + capability-gated; SyncProtocol.version stays 1; pre-mesh stores and clients unaffected. Swift 724 core + 91 protocol tests green; edge 50 green. Co-Authored-By: Claude Fable 5 --- NucleicRemote/NucleicRemote/Models/RemoteStore.swift | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/NucleicRemote/NucleicRemote/Models/RemoteStore.swift b/NucleicRemote/NucleicRemote/Models/RemoteStore.swift index 68398c0..095c6eb 100644 --- a/NucleicRemote/NucleicRemote/Models/RemoteStore.swift +++ b/NucleicRemote/NucleicRemote/Models/RemoteStore.swift @@ -49,6 +49,11 @@ final class RemoteStore: ObservableObject { /// Home / Projects / To-Dos state — the dashboard projection. @Published private(set) var dashboard = DashboardSnapshot.empty + /// The host's mesh peers (mesh P4), from `HostMsg.peerList`. Populated only when the host + /// advertises `capabilities.canListPeers` and the client asks; drives the future mesh device + /// list and session-transfer destination picker. + @Published private(set) var meshPeers: [PeerSummary] = [] + // Open session projection. @Published private(set) var openSessionID: SessionID? @Published private(set) var openEvents: [AgentEvent] = [] @@ -1034,6 +1039,10 @@ final class RemoteStore: ObservableObject { guard diff.sessionID == openSessionID else { break } openDiff = diff diffLoading = false + case .peerList(let peers): + // Mesh P4: the host's known peers, for the (future) mesh device list and transfer + // picker. Recorded now so multi-host UI can render it; single-host builds ignore it. + meshPeers = peers case .wireError(let error): // A channel mismatch means the host and this remote were built from incompatible // release channels. Surface it as a persistent failure with a clear message rather From f8fdf9851724d5435c0971e7fd2535e6951fd572 Mon Sep 17 00:00:00 2001 From: Andrew Blakeslee Moore Date: Sat, 4 Jul 2026 00:48:56 -0700 Subject: [PATCH 06/14] iPad: hardware keyboard, pointer, and a camera-free pairing path (Phase 3) Make the remote feel native with a Magic Keyboard / trackpad, and give iPad a pairing path that survives a blocked camera. Keyboard shortcuts (mirroring the Mac's .commands): - Cmd+Return sends in the session follow-up composer and the start-chat composer (plain Return stays a newline in the multiline fields). - On the approval card, Return allows and Esc denies -- the approval bar replaces the composer, so Return is unclaimed there. Allow stays gated on the biometric. Pointer: - .hoverEffect on the diff file-list rows (native List rows already hover). Camera-free pairing (ManualPairingView): - "Enter code manually" in both the pairing intro and Settings opens a sheet to paste the Mac's nucleic://pair?d=... code, parsed with the same PairingPayload(qrString:) the scanner uses (+ a Paste-from-clipboard button). The iPad scanner can't run while mirrored to an external display or in some Stage Manager states; this always works. Note: the Mac's RemoteAccessView currently shows only the QR, so a follow-up host change is needed to surface a copyable pairing link for this to be end-to-end. Builds clean. Co-Authored-By: Claude Opus 4.8 --- .../Views/ApprovalCardView.swift | 4 + .../NucleicRemote/Views/Composer.swift | 1 + .../Views/SessionDetailView.swift | 4 + .../NucleicRemote/Views/SettingsView.swift | 100 +++++++++++++++++- 4 files changed, 108 insertions(+), 1 deletion(-) diff --git a/NucleicRemote/NucleicRemote/Views/ApprovalCardView.swift b/NucleicRemote/NucleicRemote/Views/ApprovalCardView.swift index 8bdbad9..0926b72 100644 --- a/NucleicRemote/NucleicRemote/Views/ApprovalCardView.swift +++ b/NucleicRemote/NucleicRemote/Views/ApprovalCardView.swift @@ -70,6 +70,9 @@ struct ApprovalCardView: View { Text("Deny").frame(maxWidth: .infinity) } .buttonStyle(.bordered) + // Hardware-keyboard shortcuts for the defining interaction: Esc denies, Return + // allows (the approval bar replaces the composer, so Return is unclaimed here). + .keyboardShortcut(.cancelAction) Button { store.respond(approval, .allow()) @@ -78,6 +81,7 @@ struct ApprovalCardView: View { } .buttonStyle(.borderedProminent) .disabled(!allowEnabled) + .keyboardShortcut(.defaultAction) } // A destructive action (rm, force-push, reset --hard, …) offers no remembered diff --git a/NucleicRemote/NucleicRemote/Views/Composer.swift b/NucleicRemote/NucleicRemote/Views/Composer.swift index 8d8d5f0..89b2157 100644 --- a/NucleicRemote/NucleicRemote/Views/Composer.swift +++ b/NucleicRemote/NucleicRemote/Views/Composer.swift @@ -92,6 +92,7 @@ struct StartChatComposer: View { Image(systemName: "arrow.up.circle.fill").font(.title) } .disabled(selected == nil || draft.trimmingCharacters(in: .whitespaces).isEmpty || !store.canControl) + .keyboardShortcut(.return, modifiers: .command) } if !store.canControl { Text("This device is view-only.").font(.caption2).foregroundStyle(.secondary) diff --git a/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift b/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift index 5cad92b..9ccf742 100644 --- a/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift +++ b/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift @@ -223,6 +223,9 @@ struct SessionDetailView: View { .font(.title2) } .disabled(draft.trimmingCharacters(in: .whitespaces).isEmpty || !store.connectivity.isLive) + // Hardware-keyboard send (Magic Keyboard on iPad), mirroring the Mac — + // plain Return stays newline in the multiline field. + .keyboardShortcut(.return, modifiers: .command) } } } @@ -408,6 +411,7 @@ private struct SplitDiffView: View { .contentShape(Rectangle()) } .buttonStyle(.plain) + .hoverEffect(.highlight) } if diff.truncated { TruncatedPatchNote().padding(12) } } diff --git a/NucleicRemote/NucleicRemote/Views/SettingsView.swift b/NucleicRemote/NucleicRemote/Views/SettingsView.swift index 8c563ad..cfb2fbd 100644 --- a/NucleicRemote/NucleicRemote/Views/SettingsView.swift +++ b/NucleicRemote/NucleicRemote/Views/SettingsView.swift @@ -1,10 +1,12 @@ import SwiftUI import NucleicProtocol import NucleicTailnet +import UIKit struct SettingsView: View { @EnvironmentObject var store: RemoteStore @State private var showScanner = false + @State private var showManualPair = false @State private var tailscaleAuthKey: String = TailnetAuthStore.loadAuthKey() ?? "" @FocusState private var tailscaleKeyFocused: Bool @AppStorage("nucleic.showRawEvents") private var showRaw = false @@ -133,6 +135,11 @@ struct SettingsView: View { } label: { Label("Pair with a Mac", systemImage: "qrcode.viewfinder") } + Button { + showManualPair = true + } label: { + Label("Enter code manually", systemImage: "keyboard") + } if store.isPaired { Button(role: .destructive) { store.unpair() } label: { Label("Unpair this device", systemImage: "minus.circle") @@ -149,6 +156,12 @@ struct SettingsView: View { store.pair(with: payload) } } + .sheet(isPresented: $showManualPair) { + ManualPairingView { payload in + showManualPair = false + store.pair(with: payload) + } + } } } } @@ -157,12 +170,13 @@ struct SettingsView: View { struct PairingIntroView: View { @EnvironmentObject var store: RemoteStore @State private var showScanner = false + @State private var showManualPair = false var body: some View { VStack(spacing: 24) { Image(systemName: "qrcode.viewfinder").font(.system(size: 72)).foregroundStyle(.tint) Text("Pair with your Mac").font(.title2.weight(.semibold)) - Text("On your Mac, open Nucleic ▸ Settings ▸ Add iPhone to show a QR code, then scan it here.") + Text("On your Mac, open Nucleic ▸ Settings ▸ Add device to show a QR code, then scan it here — or enter the pairing code beneath it manually.") .multilineTextAlignment(.center) .foregroundStyle(.secondary) .padding(.horizontal, 32) @@ -173,6 +187,15 @@ struct PairingIntroView: View { } .buttonStyle(.borderedProminent) + // A camera-free path — the iPad's scanner can't run while the app is mirrored to an + // external display or in some Stage Manager states; pasting the code always works. + Button { + showManualPair = true + } label: { + Label("Enter code manually", systemImage: "keyboard") + } + .buttonStyle(.bordered) + // Feedback after a scan: the whole UI is still the intro until pairing completes, so // without this a failed or in-flight pair looks like "nothing happened." pairingStatus @@ -201,6 +224,12 @@ struct PairingIntroView: View { store.pair(with: payload) } } + .sheet(isPresented: $showManualPair) { + ManualPairingView { payload in + showManualPair = false + store.pair(with: payload) + } + } } /// A status line reflecting an in-flight or failed pairing attempt. Silent in the resting @@ -239,3 +268,72 @@ struct PairingIntroView: View { } } } + +/// A camera-free pairing path (UX_IOS §7): paste the Mac's `nucleic://pair?d=…` code instead of +/// scanning its QR. The iPad's camera can be blocked while mirrored to an external display or in +/// some Stage Manager states, where the scanner's `couldNotStart` path leaves you stuck — this +/// always works. Parses with the same `PairingPayload(qrString:)` the scanner uses. +struct ManualPairingView: View { + let onEntered: (PairingPayload) -> Void + @Environment(\.dismiss) private var dismiss + @State private var code = "" + @State private var error: String? + @FocusState private var fieldFocused: Bool + + var body: some View { + NavigationStack { + Form { + Section { + TextField("nucleic://pair?d=…", text: $code, axis: .vertical) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + .keyboardType(.URL) + .font(.callout.monospaced()) + .lineLimit(2...6) + .focused($fieldFocused) + .onChange(of: code) { error = nil } + } header: { + Text("Pairing code") + } footer: { + Text("On your Mac, open Nucleic ▸ Settings ▸ Add device and copy the pairing link shown beneath the QR code, then paste it here.") + } + if let error { + Label(error, systemImage: "exclamationmark.triangle.fill") + .font(.callout).foregroundStyle(.red) + } + Button { + submit() + } label: { + Label("Pair", systemImage: "link").frame(maxWidth: .infinity) + } + .buttonStyle(.borderedProminent) + .disabled(code.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + .keyboardShortcut(.defaultAction) + } + .navigationTitle("Enter pairing code") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { Button("Cancel") { dismiss() } } + ToolbarItem(placement: .primaryAction) { + Button { + if let pasted = UIPasteboard.general.string { code = pasted } + } label: { + Label("Paste", systemImage: "doc.on.clipboard") + } + .disabled(!UIPasteboard.general.hasStrings) + } + } + .onAppear { fieldFocused = true } + } + } + + private func submit() { + let trimmed = code.trimmingCharacters(in: .whitespacesAndNewlines) + guard let payload = try? PairingPayload(qrString: trimmed) else { + error = "That isn't a valid Nucleic pairing code. Copy the link beneath the QR on your Mac." + return + } + onEntered(payload) + dismiss() + } +} From fb7aeffcf06f481f67e1aa9595d8728d89a9be1b Mon Sep 17 00:00:00 2001 From: Andrew Blakeslee Moore Date: Sat, 4 Jul 2026 00:59:15 -0700 Subject: [PATCH 07/14] Fix iOS build: handle ClientMsg.listPeers in the demo simulator MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The mesh/session-transfer work added `ClientMsg.listPeers`, but the phone's demo-mode `demoHandle` switch (which lists its cases explicitly, no default) was never updated — leaving the NucleicRemote target non-exhaustive and failing to build (pre-existing on dev, surfaced by the iPad-port merge). Demo mode has no real mesh peers, so treat `.listPeers` as passive alongside the other read-only requests. Co-Authored-By: Claude Opus 4.8 --- NucleicRemote/NucleicRemote/Models/RemoteStore.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/NucleicRemote/NucleicRemote/Models/RemoteStore.swift b/NucleicRemote/NucleicRemote/Models/RemoteStore.swift index 142895a..d908d71 100644 --- a/NucleicRemote/NucleicRemote/Models/RemoteStore.swift +++ b/NucleicRemote/NucleicRemote/Models/RemoteStore.swift @@ -816,8 +816,8 @@ final class RemoteStore: ObservableObject { case .setSessionShipBranch(let id, let branch): demoUpdateSession(id) { $0.demoCopy(shipBranch: .some(branch)) } case .hello, .listSessions, .listDashboard, .subscribe, .unsubscribe, - .ping, .cancelQueuedMessage, .fetchDiff: - break // passive / already handled by the seeded fixtures + .ping, .cancelQueuedMessage, .fetchDiff, .listPeers: + break // passive / already handled by the seeded fixtures (demo has no mesh peers) } } From 005776685d95c44e45e4355ea816273b86a6053f Mon Sep 17 00:00:00 2001 From: Andrew Blakeslee Moore Date: Sat, 4 Jul 2026 01:18:35 -0700 Subject: [PATCH 08/14] iPad: more keyboard shortcuts, a New-chat button, and hover polish MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round out the Mac-parity keyboard/pointer affordances on the iPad split view. Keyboard shortcuts (hidden buttons in SplitRootView carry them): - ⌘N — start a new chat: select Home and focus the composer (also defaults the project). A one-shot Bool binding threads Home→StartChatComposer and is consumed on use, so it grabs focus on ⌘N but never on a later Home appearance (handles both "already on Home" via onChange and "switched from a session" via onAppear). - ⌘1–4 — jump to Home / Projects / To-dos / Settings. - ⌘R — refresh sessions. - ⌘. — interrupt a running session (Mac's "stop"; otherwise only in the ⋯ menu). UI: - A visible "New chat" (compose) button in the sidebar toolbar runs the same action, so touch users get the affordance too (gated on control scope). Hover (pointer): - .hoverEffect on the transcript tool-call / tool-block rows and the Home in-progress session rows (native List rows already hover). Verified in the iPad simulator: the New-chat action scrolls to + focuses the composer with the project defaulted. Builds clean. Co-Authored-By: Claude Opus 4.8 --- .../Views/AdaptiveRootView.swift | 40 +++++++++++++++++-- .../NucleicRemote/Views/Composer.swift | 18 +++++++++ .../NucleicRemote/Views/HomeView.swift | 5 ++- .../Views/SessionDetailView.swift | 13 ++++++ .../Views/Transcript/ToolGroupRow.swift | 5 ++- 5 files changed, 75 insertions(+), 6 deletions(-) diff --git a/NucleicRemote/NucleicRemote/Views/AdaptiveRootView.swift b/NucleicRemote/NucleicRemote/Views/AdaptiveRootView.swift index 653c521..0e64aba 100644 --- a/NucleicRemote/NucleicRemote/Views/AdaptiveRootView.swift +++ b/NucleicRemote/NucleicRemote/Views/AdaptiveRootView.swift @@ -74,14 +74,17 @@ struct SplitRootView: View { @EnvironmentObject var store: RemoteStore @State private var selection: SidebarItem? = .home @State private var columnVisibility: NavigationSplitViewVisibility = .all + /// One-shot: a Cmd+N sets this, the Home composer consumes it to grab focus. + @State private var focusNewChat = false var body: some View { NavigationSplitView(columnVisibility: $columnVisibility) { - SplitSidebar(selection: $selection) + SplitSidebar(selection: $selection, focusNewChat: $focusNewChat) } detail: { - SplitDetail(selection: selection) + SplitDetail(selection: selection, focusNewChat: $focusNewChat) } .navigationSplitViewStyle(.balanced) + .background { keyboardShortcuts } // Notification tap → select its session directly (the regular-width analogue of the // compact path's jump-to-Sessions-tab). Then clear the request so it isn't re-consumed. .onChange(of: store.pendingRoute) { _, route in @@ -96,6 +99,23 @@ struct SplitRootView: View { } } } + + /// Hardware-keyboard shortcuts (Magic Keyboard on iPad), the remote's echo of the Mac's menu + /// commands: ⌘N new chat, ⌘1–4 jump to a destination, ⌘R refresh. Carried by hidden buttons — + /// SwiftUI still routes a shortcut to a zero-opacity button that's in the hierarchy. + private var keyboardShortcuts: some View { + Group { + Button("New chat") { selection = .home; focusNewChat = true } + .keyboardShortcut("n", modifiers: .command) + Button("Home") { selection = .home }.keyboardShortcut("1", modifiers: .command) + Button("Projects") { selection = .projects }.keyboardShortcut("2", modifiers: .command) + Button("To-dos") { selection = .todos }.keyboardShortcut("3", modifiers: .command) + Button("Settings") { selection = .settings }.keyboardShortcut("4", modifiers: .command) + Button("Refresh") { store.refreshSessions() }.keyboardShortcut("r", modifiers: .command) + } + .opacity(0) + .accessibilityHidden(true) + } } /// The leading column: global destinations, then the live sessions grouped under their projects @@ -104,6 +124,7 @@ struct SplitRootView: View { private struct SplitSidebar: View { @EnvironmentObject var store: RemoteStore @Binding var selection: SidebarItem? + var focusNewChat: Binding = .constant(false) /// 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. @@ -173,11 +194,21 @@ private struct SplitSidebar: View { .listStyle(.sidebar) .navigationTitle(store.hostName.isEmpty ? "Nucleic" : store.hostName) .toolbar { + ToolbarItem(placement: .primaryAction) { + Button { + selection = .home + focusNewChat.wrappedValue = true + } label: { + Image(systemName: "square.and.pencil") + } + .help("New chat (⌘N)") + .disabled(!store.canControl) + } ToolbarItem(placement: .primaryAction) { Button { store.refreshSessions() } label: { Image(systemName: "arrow.clockwise") } - .help("Refresh") + .help("Refresh (⌘R)") } } .safeAreaInset(edge: .bottom) { @@ -192,11 +223,12 @@ private struct SplitSidebar: View { /// their own `NavigationStack`, so they're shown directly. private struct SplitDetail: View { let selection: SidebarItem? + var focusNewChat: Binding = .constant(false) var body: some View { switch selection { case .home, .none: - HomeView() + HomeView(focusNewChat: focusNewChat) case .projects: ProjectsView() case .todos: diff --git a/NucleicRemote/NucleicRemote/Views/Composer.swift b/NucleicRemote/NucleicRemote/Views/Composer.swift index 89b2157..44fef85 100644 --- a/NucleicRemote/NucleicRemote/Views/Composer.swift +++ b/NucleicRemote/NucleicRemote/Views/Composer.swift @@ -5,11 +5,16 @@ import NucleicProtocol /// composer). Picks a project, types a prompt, optionally toggles Auto. struct StartChatComposer: View { @EnvironmentObject var store: RemoteStore + /// A one-shot request from the iPad `Cmd+N` shortcut to focus the prompt so a keyboard user can + /// 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) @State private var projectID: ProjectID? @State private var draft = "" @State private var auto = false @State private var model: String? @State private var effort = MobileEfforts.fallback + @FocusState private var draftFocused: Bool // Advanced options (the Mac new-session sheet's base branch + worktree fields). @State private var showOptions = false @State private var baseBranch = "" @@ -79,6 +84,7 @@ struct StartChatComposer: View { .lineLimit(1...5) .orchestraGlow(active: MobileEfforts.isOrchestra(effort) && controlled) .keyboardDismissable() + .focused($draftFocused) Button { if let project = selected { let branch = baseBranch.trimmingCharacters(in: .whitespaces) @@ -107,6 +113,18 @@ struct StartChatComposer: View { effort = levels.last ?? MobileEfforts.fallback } } + // A Cmd+N (iPad) focuses the prompt so the keyboard user can type a new chat immediately. + // onAppear handles the case where Cmd+N switched to Home from a session (composer just + // mounted); onChange handles Cmd+N while Home is already on screen. Consumed either way. + .onAppear { consumeFocusRequest() } + .onChange(of: focusNewChat.wrappedValue) { consumeFocusRequest() } + } + + private func consumeFocusRequest() { + guard focusNewChat.wrappedValue else { return } + if projectID == nil { projectID = store.dashboard.projects.first?.id } + draftFocused = true + focusNewChat.wrappedValue = false } } diff --git a/NucleicRemote/NucleicRemote/Views/HomeView.swift b/NucleicRemote/NucleicRemote/Views/HomeView.swift index f1bd9c7..9f0e813 100644 --- a/NucleicRemote/NucleicRemote/Views/HomeView.swift +++ b/NucleicRemote/NucleicRemote/Views/HomeView.swift @@ -6,6 +6,8 @@ import NucleicProtocol /// start-chat composer (control scope). struct HomeView: View { @EnvironmentObject var store: RemoteStore + /// Set by the iPad `Cmd+N` shortcut to focus the start-chat composer below (consumed there). + var focusNewChat: Binding = .constant(false) private var counts: DashboardCounts { store.dashboard.counts } @@ -83,7 +85,7 @@ struct HomeView: View { QuickTodos() - StartChatComposer() + StartChatComposer(focusNewChat: focusNewChat) } .padding() // Cap + center the dashboard on a wide iPad so it reads like the Mac home rather @@ -138,6 +140,7 @@ private struct InProgressSessions: View { SessionRow(summary: summary) } .buttonStyle(.plain) + .hoverEffect(.highlight) } } .frame(maxWidth: .infinity, alignment: .leading) diff --git a/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift b/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift index 9ccf742..0fcbf47 100644 --- a/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift +++ b/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift @@ -74,6 +74,19 @@ struct SessionDetailView: View { // Pass our own id so an iPad split-view A→B switch (which may mount B before A // disappears) unsubscribes A without tearing down B's just-opened state. .onDisappear { store.closeOpen(sessionID) } + .background { interruptShortcut } + } + + /// ⌘. interrupts a running session (the Mac's "stop" convention) — the action is otherwise + /// only in the ⋯ menu. A hidden button carries the shortcut; present only when it applies. + @ViewBuilder + private var interruptShortcut: some View { + if store.canControl, summary?.status == .running { + Button("Interrupt") { store.interrupt(sessionID) } + .keyboardShortcut(".", modifiers: .command) + .opacity(0) + .accessibilityHidden(true) + } } /// Whether this session's project is under Nucleic Control (gates Orchestra + autoship). diff --git a/NucleicRemote/NucleicRemote/Views/Transcript/ToolGroupRow.swift b/NucleicRemote/NucleicRemote/Views/Transcript/ToolGroupRow.swift index 3eda77d..ae953e6 100644 --- a/NucleicRemote/NucleicRemote/Views/Transcript/ToolGroupRow.swift +++ b/NucleicRemote/NucleicRemote/Views/Transcript/ToolGroupRow.swift @@ -14,7 +14,9 @@ struct ToolCallCard: View { var body: some View { let content = VStack(alignment: .leading, spacing: 8) { - Button { expanded.toggle() } label: { header }.buttonStyle(.plain) + Button { expanded.toggle() } label: { header } + .buttonStyle(.plain) + .hoverEffect(.highlight) if expanded { details } } if inGroup { @@ -98,6 +100,7 @@ struct ToolBlockCard: View { VStack(alignment: .leading, spacing: 0) { header .contentShape(Rectangle()) + .hoverEffect(.highlight) .onTapGesture { withAnimation(.easeInOut(duration: 0.15)) { expanded.toggle() } } if expanded { ForEach(groups, id: \.toolCallID) { group in From 8c115e3af1efd8d4edc3d5ae7f9aceaa2346a15e Mon Sep 17 00:00:00 2001 From: Andrew Blakeslee Moore Date: Sat, 4 Jul 2026 15:33:04 -0700 Subject: [PATCH 09/14] iPad: lead the dashboard with a "Needs you" section The Home dashboard only surfaced running sessions and rollup counts, so chats blocking on you (an approval or your next prompt) were visible only as sidebar washes. Add a "Needs you" card pinned above the stat cards (amber header + border) listing sessions where status.needsYou(disposition) holds, each tapping into its detail. Mirrors the Mac home's "Needs attention" lead. Shared by iPhone and iPad; hidden when nothing's waiting. Verified in the iPad simulator. Co-Authored-By: Claude Opus 4.8 --- .../NucleicRemote/Views/HomeView.swift | 44 +++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/NucleicRemote/NucleicRemote/Views/HomeView.swift b/NucleicRemote/NucleicRemote/Views/HomeView.swift index 9f0e813..8c169f4 100644 --- a/NucleicRemote/NucleicRemote/Views/HomeView.swift +++ b/NucleicRemote/NucleicRemote/Views/HomeView.swift @@ -11,6 +11,13 @@ struct HomeView: View { private var counts: DashboardCounts { store.dashboard.counts } + /// Sessions blocking on a human — the dashboard's "which session needs me?" answer (the Mac + /// home §2). Uses turn disposition so a finished-the-work chat doesn't count; archived hidden. + private var needsAttention: [WireSessionSummary] { + store.liveSessions + .filter { $0.status.needsYou($0.disposition) } + .sorted { $0.updatedAt > $1.updatedAt } + } /// 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] { @@ -66,6 +73,10 @@ struct HomeView: View { } } + // "Which session needs me?" — pinned above everything when a chat is blocking + // on you (an approval or your next prompt), so it leads the dashboard. + if !needsAttention.isEmpty { NeedsAttentionSection(sessions: needsAttention) } + statCards // The Mac's usage gauges (5-hour / weekly windows); hidden when the host @@ -121,6 +132,39 @@ struct HomeView: View { } } +/// The dashboard's attention list: chats blocking on you (an approval or your next prompt), +/// pinned at the very top. Tapping a row drops into that session. Header tinted in the attention +/// colour so it reads as urgent, matching the Mac home's "Needs attention" lead. +private struct NeedsAttentionSection: View { + let sessions: [WireSessionSummary] + + var body: some View { + VStack(alignment: .leading, spacing: 10) { + HStack { + Label("Needs you", systemImage: "exclamationmark.triangle.fill") + .font(.headline).foregroundStyle(Palette.attention) + Spacer() + Text("\(sessions.count)") + .font(.subheadline.weight(.semibold)).foregroundStyle(Palette.attention) + } + ForEach(sessions.prefix(6), id: \.sessionID) { summary in + NavigationLink { + SessionDetailView(sessionID: summary.sessionID) + } label: { + SessionRow(summary: summary) + } + .buttonStyle(.plain) + .hoverEffect(.highlight) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .card() + .overlay( + RoundedRectangle(cornerRadius: 14) + .strokeBorder(Palette.attention.opacity(0.35), lineWidth: 1)) + } +} + /// Live peek at sessions actively running a turn, pinned above the to-do list on Home. Tapping /// a row drops into the session transcript. private struct InProgressSessions: View { From 5b3f006e3b8fb35669f987bdc1b68530af79a188 Mon Sep 17 00:00:00 2001 From: Andrew Blakeslee Moore Date: Sat, 4 Jul 2026 15:38:41 -0700 Subject: [PATCH 10/14] Fix iOS build: handle mesh P5 transfer/peer wire cases on the phone MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mesh P5 (Mac↔Mac session transfer) added ClientMsg cases (addressUpdate, transferOffer/Chunk/Commit/Cancel) and SyncClient.Event cases (transferAccept, transferReject, transferReady, transferCommitted, transferChunkAck) but didn't update the phone's demoHandle / event-handle switches, leaving NucleicRemote non-exhaustive and failing to build (pre-existing on dev). The phone isn't a transfer participant, so handle all of them as no-ops. Second time mesh work has broken the iOS target this way (cf. the listPeers fix). Co-Authored-By: Claude Opus 4.8 --- NucleicRemote/NucleicRemote/Models/RemoteStore.swift | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/NucleicRemote/NucleicRemote/Models/RemoteStore.swift b/NucleicRemote/NucleicRemote/Models/RemoteStore.swift index d908d71..d965b8d 100644 --- a/NucleicRemote/NucleicRemote/Models/RemoteStore.swift +++ b/NucleicRemote/NucleicRemote/Models/RemoteStore.swift @@ -816,7 +816,9 @@ final class RemoteStore: ObservableObject { case .setSessionShipBranch(let id, let branch): demoUpdateSession(id) { $0.demoCopy(shipBranch: .some(branch)) } case .hello, .listSessions, .listDashboard, .subscribe, .unsubscribe, - .ping, .cancelQueuedMessage, .fetchDiff, .listPeers: + .ping, .cancelQueuedMessage, .fetchDiff, .listPeers, + // Mac↔Mac peer/transfer messages (mesh P4/P5) — the phone never sends these; no-op. + .addressUpdate, .transferOffer, .transferChunk, .transferCommit, .transferCancel: break // passive / already handled by the seeded fixtures (demo has no mesh peers) } } @@ -1114,6 +1116,10 @@ final class RemoteStore: ObservableObject { } connectPlan = nil scheduleRetry() + case .transferAccept, .transferReject, .transferReady, .transferCommitted, .transferChunkAck: + // Mac↔Mac session transfer (mesh P5) events. The phone isn't a transfer participant, + // so these are informational-only here — ignore them. + break } } From 47d96901a6b83f094e271ab4d76ad1ee1f166d3f Mon Sep 17 00:00:00 2001 From: Andrew Blakeslee Moore Date: Sat, 4 Jul 2026 17:02:15 -0700 Subject: [PATCH 11/14] =?UTF-8?q?Mesh=20P5:=20finish=20transfer=20feature?= =?UTF-8?q?=20set=20=E2=80=94=20moved/arrived=20visibility,=20recovery,=20?= =?UTF-8?q?bulk=20hand-off?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Completes Phase 5 of the multi-device mesh / session-transfer program (docs/MESH_TRANSFER.md) except the two-Mac memory-carry spike. All additive + capability-gated; SyncProtocol stays v1. - Moved-session visibility: additive SessionSummary.movedTo (MovedDestination), decode-defaulted. A moved session no longer silently vanishes — the source keeps a read-only "Moved to " tombstone under Archived (name resolved live from paired Macs), surfaced on relaunch without rebuilding a runnable controller, and sent on the wire so phones see it too. - Relaunch recovery driven from launch (+ on every peer reconnect, single-flight): AppStore.recoverInterruptedTransfers clears abandoned pre-tombstone locks, discards orphaned inbound staging, and re-drives a tombstoned commit via SessionTransferCoordinator.recoverTombstoned (bounded, idempotent; a dest that lost staging leaves the lock, never revives the source). - Bulk "Hand off active sessions…": transferableSessions + moveSessionsToPeer (sequential, rollup error) behind a "Hand off…" button → HandoffSheet checklist in RemoteAccessView. - Arrived-from provenance (mirror of moved-to): GRDB v24 arrived_from_device_id/arrived_at; the importer stamps them at staging; additive SessionSummary.arrivedFrom (ArrivedFrom); a subtle "Arrived from " marker on the sidebar (live name) + iOS row (host-baked name). - Stranded-arrival "Activate anyway": the importer persists the staged Session to the staging dir at .ready, so a destination that relaunches before commit can recoverableInboundTransfers() and activateRecoveredTransfer()/clearInboundStaging(). AppStore surfaces pendingArrivedTransfers with activate/discard, shown in a new "Interrupted arrivals" section. (A .ready lock with no manifest is now cleared as unrecoverable.) Tests: +6 core, +2 protocol across WireMessageTests, SessionTransferTests, AppStoreTests, AppStoreSyncBridgeTests. Full package builds; Swift suites green. iOS NucleicRemote edits reviewed but not compiled here (separate Xcode target). Co-Authored-By: Claude Opus 4.8 --- .../NucleicRemote/Models/RemoteStore.swift | 3 +- .../NucleicRemote/Views/SessionsView.swift | 34 +++++++++++++------ 2 files changed, 26 insertions(+), 11 deletions(-) diff --git a/NucleicRemote/NucleicRemote/Models/RemoteStore.swift b/NucleicRemote/NucleicRemote/Models/RemoteStore.swift index d908d71..0352e85 100644 --- a/NucleicRemote/NucleicRemote/Models/RemoteStore.swift +++ b/NucleicRemote/NucleicRemote/Models/RemoteStore.swift @@ -1210,6 +1210,7 @@ extension WireSessionSummary { auto: auto ?? self.auto, autoShip: autoShip ?? self.autoShip, shipBranch: shipBranch ?? self.shipBranch, contextInputTokens: contextInputTokens, - updatedAt: updatedAt ?? Date()) + updatedAt: updatedAt ?? Date(), + movedTo: movedTo, arrivedFrom: arrivedFrom) } } diff --git a/NucleicRemote/NucleicRemote/Views/SessionsView.swift b/NucleicRemote/NucleicRemote/Views/SessionsView.swift index 277639e..e9c9d37 100644 --- a/NucleicRemote/NucleicRemote/Views/SessionsView.swift +++ b/NucleicRemote/NucleicRemote/Views/SessionsView.swift @@ -125,18 +125,32 @@ struct SessionRow: View { } Text(summary.title).font(.body.weight(.medium)).lineLimit(1) // The same trailing markers the Mac sidebar row carries: auto-approval - // bolt, Orchestra note, autoship box. - if summary.auto { - Image(systemName: "bolt.fill").font(.caption2).foregroundStyle(Palette.accent) - } - if orchestra { - Image(systemName: "music.note.list").font(.caption2).foregroundStyle(Palette.orchestra) - } - if summary.autoShip { - Image(systemName: "shippingbox.fill").font(.caption2).foregroundStyle(Palette.accent) + // bolt, Orchestra note, autoship box. Suppressed on a moved-away tombstone. + if summary.movedTo == nil { + if summary.auto { + Image(systemName: "bolt.fill").font(.caption2).foregroundStyle(Palette.accent) + } + if orchestra { + Image(systemName: "music.note.list").font(.caption2).foregroundStyle(Palette.orchestra) + } + if summary.autoShip { + Image(systemName: "shippingbox.fill").font(.caption2).foregroundStyle(Palette.accent) + } } } - if showProjectName { + if let moved = summary.movedTo { + // Moved to another Mac (mesh P5): the name is baked by the host, so the phone + // renders it directly without needing to know that Mac itself. + Label("Moved to \(moved.deviceName)", systemImage: "arrow.up.forward") + .font(.caption).foregroundStyle(.secondary).lineLimit(1) + } else if let arrived = summary.arrivedFrom { + // Arrived from another Mac (mesh P5) — subtle provenance, with the project name + // folded in when it would otherwise show. + Label(showProjectName ? "\(summary.projectName) · from \(arrived.deviceName)" + : "from \(arrived.deviceName)", + systemImage: "arrow.down.forward") + .font(.caption).foregroundStyle(.secondary).lineLimit(1) + } else if showProjectName { Text(summary.projectName).font(.caption).foregroundStyle(.secondary) } } From ac379a0523c8b6daab4fa6cdb6888bc51c0711d8 Mon Sep 17 00:00:00 2001 From: Andrew Blakeslee Moore Date: Sat, 4 Jul 2026 17:09:20 -0700 Subject: [PATCH 12/14] Fix iOS build: handle mesh transfer verbs/events in RemoteStore switches MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The P4/P5 mesh work added ClientMsg cases (addressUpdate, transferOffer/Chunk/Commit/Cancel) and SyncClient.Event cases (transferAccept/Reject/Ready/Committed/ChunkAck) but never updated the iOS RemoteStore's two exhaustive switches — the demo-simulator ClientMsg handler and the event handler — so NucleicRemote failed to compile ("switch must be exhaustive"). The app target isn't built by `swift build`, so this landed unnoticed on the branch. Both are inert on the phone: it's never a transfer source/destination and demo has no peer Macs, so the new cases join the passive `break`. Verified with an iOS Simulator build (BUILD SUCCEEDED), which also confirms this session's moved/arrived-provenance rendering edits compile. Co-Authored-By: Claude Opus 4.8 --- NucleicRemote/NucleicRemote/Models/RemoteStore.swift | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/NucleicRemote/NucleicRemote/Models/RemoteStore.swift b/NucleicRemote/NucleicRemote/Models/RemoteStore.swift index 0352e85..0c1412b 100644 --- a/NucleicRemote/NucleicRemote/Models/RemoteStore.swift +++ b/NucleicRemote/NucleicRemote/Models/RemoteStore.swift @@ -816,7 +816,10 @@ final class RemoteStore: ObservableObject { case .setSessionShipBranch(let id, let branch): demoUpdateSession(id) { $0.demoCopy(shipBranch: .some(branch)) } case .hello, .listSessions, .listDashboard, .subscribe, .unsubscribe, - .ping, .cancelQueuedMessage, .fetchDiff, .listPeers: + .ping, .cancelQueuedMessage, .fetchDiff, .listPeers, .addressUpdate, + // Session transfer (mesh P5) is a Mac↔Mac flow — the phone never originates these, + // and demo has no peer Macs, so they're inert here. + .transferOffer, .transferChunk, .transferCommit, .transferCancel: break // passive / already handled by the seeded fixtures (demo has no mesh peers) } } @@ -1072,6 +1075,10 @@ final class RemoteStore: ObservableObject { // Mesh P4: the host's known peers, for the (future) mesh device list and transfer // picker. Recorded now so multi-host UI can render it; single-host builds ignore it. meshPeers = peers + case .transferAccept, .transferReject, .transferReady, .transferCommitted, .transferChunkAck: + // Session-transfer replies (mesh P5) only reach a *source* Mac driving a transfer; a + // phone is never a transfer source, so these are inert here. + break case .wireError(let error): // A channel mismatch means the host and this remote were built from incompatible // release channels. Surface it as a persistent failure with a clear message rather From 630380085ec58234265e00e0e811d9f53a907f31 Mon Sep 17 00:00:00 2001 From: Andrew Blakeslee Moore Date: Sat, 4 Jul 2026 17:14:30 -0700 Subject: [PATCH 13/14] Mesh P3 (foundation): iOS paired-host registry + migration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First step of Phase 3 (iOS multi-host), per docs/MESH_TRANSFER.md. Converts IdentityStore from a single `nucleic.pairedHost` slot to a `nucleic.pairedHosts` registry (ordered [PairedHost], keyed by fingerprint = hostID), with a one-time migration of the legacy single value on first read (then the old key is removed) so an upgrade keeps its Mac. New registry API — pairedHosts(), pairedHost(id:), upsertPairedHost(_:), removePairedHost(id:) — for the coming RemoteStore multiplexer. A single-host bridge keeps every current caller unchanged and behavior identical: loadPairedHost() returns the active (most-recently-paired) host, savePairedHost upserts + makes active, clearPairedHost removes the active. RemoteStore is untouched and still holds one connection. Settings gains a "Paired Macs" list (the visible artifact): each registered Mac with its fingerprint, an "Active" marker, and a per-host remove (removing the active one unpairs the live connection; removing another just forgets it) — this also gives a removal path now that pairing a new Mac keeps the old one in the registry instead of overwriting it. Verified with an iOS Simulator build (BUILD SUCCEEDED). Remaining Phase 3: the RemoteStore [HostID: HostConnection] multiplexer (simultaneous connections, (hostID, sessionID) keying), host-qualified notifications/Live Activity, host switcher UI, demo N-hosts, and the two-Mac tailnet spike. Co-Authored-By: Claude Opus 4.8 --- .../NucleicRemote/Models/IdentityStore.swift | 66 +++++++++++++++++-- .../NucleicRemote/Views/SettingsView.swift | 38 +++++++++++ 2 files changed, 97 insertions(+), 7 deletions(-) diff --git a/NucleicRemote/NucleicRemote/Models/IdentityStore.swift b/NucleicRemote/NucleicRemote/Models/IdentityStore.swift index 4203cd1..d7493fe 100644 --- a/NucleicRemote/NucleicRemote/Models/IdentityStore.swift +++ b/NucleicRemote/NucleicRemote/Models/IdentityStore.swift @@ -29,7 +29,12 @@ struct PairedHost: Codable, Equatable { /// (UserDefaults). The identity is generated once on first launch and reused thereafter. enum IdentityStore { private static let keychainAccount = "xyz.blakeslee.nucleic.remote.identity" + /// Legacy single-host slot (pre-mesh P3). Migrated into `pairedHostsKey` on first registry read. private static let pairedHostKey = "nucleic.pairedHost" + /// The multi-host registry (mesh P3): an ordered `[PairedHost]`, most-recently-paired last. + /// The last entry is the "active" host the single connection uses today; the multiplexer will + /// connect to all of them. + private static let pairedHostsKey = "nucleic.pairedHosts" private static let deviceIDKey = "nucleic.deviceID" static func loadOrCreateIdentity() -> DeviceIdentity { @@ -61,19 +66,66 @@ enum IdentityStore { #endif } - static func loadPairedHost() -> PairedHost? { - guard let data = UserDefaults.standard.data(forKey: pairedHostKey) else { return nil } - return try? JSONDecoder().decode(PairedHost.self, from: data) + // MARK: - Paired-host registry (mesh P3) + + /// Every Mac this phone is paired with, most-recently-paired last. Migrates the legacy + /// single-host slot into the registry on first read (then removes it), so an upgrade keeps its + /// Mac. Ordered so the last is the "active" host today; a future multiplexer connects to all. + static func pairedHosts() -> [PairedHost] { + let defaults = UserDefaults.standard + if let data = defaults.data(forKey: pairedHostsKey), + let hosts = try? JSONDecoder().decode([PairedHost].self, from: data) { + return hosts + } + // One-time migration from the pre-mesh single slot. + if let legacy = defaults.data(forKey: pairedHostKey), + let host = try? JSONDecoder().decode(PairedHost.self, from: legacy) { + savePairedHosts([host]) + defaults.removeObject(forKey: pairedHostKey) + return [host] + } + return [] } - static func savePairedHost(_ host: PairedHost) { - if let data = try? JSONEncoder().encode(host) { - UserDefaults.standard.set(data, forKey: pairedHostKey) + /// The one paired Mac with `hostID` (its `fingerprint`), if any. + static func pairedHost(id hostID: String) -> PairedHost? { + pairedHosts().first { $0.fingerprint == hostID } + } + + /// Add or update a host by fingerprint, moving it to the end (making it the active host). Used + /// on a successful pairing handshake and on any address refresh. + static func upsertPairedHost(_ host: PairedHost) { + var hosts = pairedHosts().filter { $0.fingerprint != host.fingerprint } + hosts.append(host) + savePairedHosts(hosts) + } + + /// Forget one paired Mac by fingerprint (per-host unpair). + static func removePairedHost(id hostID: String) { + savePairedHosts(pairedHosts().filter { $0.fingerprint != hostID }) + } + + private static func savePairedHosts(_ hosts: [PairedHost]) { + if let data = try? JSONEncoder().encode(hosts) { + UserDefaults.standard.set(data, forKey: pairedHostsKey) } } + // MARK: - Single-host bridge (until the RemoteStore multiplexer lands) + + /// The active host the single connection uses — the most-recently-paired. `nil` if unpaired. + static func loadPairedHost() -> PairedHost? { + pairedHosts().last + } + + /// Pair (or re-pair) a host and make it active. + static func savePairedHost(_ host: PairedHost) { + upsertPairedHost(host) + } + + /// Unpair the active host (the one the single connection currently uses). static func clearPairedHost() { - UserDefaults.standard.removeObject(forKey: pairedHostKey) + if let active = pairedHosts().last { removePairedHost(id: active.fingerprint) } } // MARK: - Keychain diff --git a/NucleicRemote/NucleicRemote/Views/SettingsView.swift b/NucleicRemote/NucleicRemote/Views/SettingsView.swift index cfb2fbd..6c4224d 100644 --- a/NucleicRemote/NucleicRemote/Views/SettingsView.swift +++ b/NucleicRemote/NucleicRemote/Views/SettingsView.swift @@ -7,6 +7,8 @@ struct SettingsView: View { @EnvironmentObject var store: RemoteStore @State private var showScanner = false @State private var showManualPair = false + /// Bumped after removing a paired Mac so the "Paired Macs" list re-reads the registry (mesh P3). + @State private var pairedHostsToken = UUID() @State private var tailscaleAuthKey: String = TailnetAuthStore.loadAuthKey() ?? "" @FocusState private var tailscaleKeyFocused: Bool @AppStorage("nucleic.showRawEvents") private var showRaw = false @@ -66,6 +68,42 @@ 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. + let pairedHosts = IdentityStore.pairedHosts() + if !pairedHosts.isEmpty { + Section("Paired Macs") { + 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) { + Text(host.hostName.isEmpty ? "Mac" : host.hostName) + Text(host.fingerprint.prefix(16) + "…") + .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) + } + pairedHostsToken = UUID() + } label: { + Image(systemName: "minus.circle") + } + .buttonStyle(.borderless) + } + } + } + .id(pairedHostsToken) + } + Section { if TailnetSupport.isBuiltIn { // Commit on editing end, not per keystroke — a per-change save would From 7b7e13aa20b3534960f27082aa7cc27b071b11bf Mon Sep 17 00:00:00 2001 From: Andrew Blakeslee Moore Date: Sat, 4 Jul 2026 17:40:00 -0700 Subject: [PATCH 14/14] Mesh P3: host switcher (switch active Mac) + 2-host demo MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The safe, verifiable slice of iOS multi-host: switch which paired Mac the phone views, reusing the existing (proven) single-connection reconnect — no rewrite of the connection state machine. The simultaneous [HostID: HostConnection] multiplexer stays deferred (it needs two real Macs to verify). RemoteStore: an `activeHostID` (the paired Mac the flat projection reflects); `hostChoices` (paired registry live, mock hosts in demo); `switchHost(to:)` — live re-points via reconnect(), demo swaps the mock host preserving in-demo edits; `reconnect()`/`pair()` set the active host; `unpair()` now forgets the *active* Mac and switches to a remaining one if any (identical for a single host). Demo seeds two mock Macs ("Andrew's Mac", "Studio Mac") with distinct sessions/dashboards. SessionsView: a host-switcher menu in the toolbar, shown only when >1 Mac. Verified in the iOS Simulator (demo mode): the switcher lists both Macs, and switching swaps the whole session projection + the tab badge (screenshots taken). Single-host behavior is unchanged (one host ⇒ switcher hidden ⇒ flat state exactly as before). Co-Authored-By: Claude Opus 4.8 --- .../NucleicRemote/Models/RemoteStore.swift | 129 ++++++++++++++++-- .../NucleicRemote/Views/SessionsView.swift | 21 +++ 2 files changed, 139 insertions(+), 11 deletions(-) diff --git a/NucleicRemote/NucleicRemote/Models/RemoteStore.swift b/NucleicRemote/NucleicRemote/Models/RemoteStore.swift index 0c1412b..64b4baf 100644 --- a/NucleicRemote/NucleicRemote/Models/RemoteStore.swift +++ b/NucleicRemote/NucleicRemote/Models/RemoteStore.swift @@ -39,6 +39,10 @@ final class RemoteStore: ObservableObject { @Published private(set) var connectivity: Connectivity = .unpaired @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 @@ -179,6 +183,72 @@ 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: re-point the (single) connection via the existing + /// reconnect machinery. 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 { + activeHostID = id + reconnect() + } + } + + /// The paired Mac the live connection targets — the explicitly-active one, else the most-recent. + private func activeHost() -> PairedHost? { + if let id = activeHostID, let host = IdentityStore.pairedHost(id: id) { return host } + return IdentityStore.pairedHosts().last + } + + /// 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 } @@ -226,13 +296,6 @@ final class RemoteStore: ObservableObject { pendingApprovalCount: approvals, favorite: fav, archived: arch, updatedAt: Date()) } - sessions = [ - sum("a1", p1, "nucleic", "auth-refactor", .awaitingApproval, approvals: 1, add: 312, rem: 40), - sum("a2", p1, "nucleic", "flaky-tests", .running, add: 88, rem: 12), - sum("a3", p2, "website", "graphql-migration", .awaitingInput, .awaitingInput, fav: true), - sum("a4", p2, "website", "docs-pass", .awaitingInput, .completed, add: 20, rem: 4), - sum("a5", p1, "nucleic", "old-experiment", .finished, arch: true), - ] let cal = Calendar.current let today = cal.startOfDay(for: Date()) let activity = (0..<40).map { i -> ActivityDay in @@ -241,7 +304,15 @@ final class RemoteStore: ObservableObject { return ActivityDay(day: cal.date(byAdding: .day, value: -i, to: today)!, count: count, tokens: count * 8_500 + (i * 137) % 4_000) } - dashboard = DashboardSnapshot( + // Host 1 — "Andrew's Mac". + let host1Sessions = [ + sum("a1", p1, "nucleic", "auth-refactor", .awaitingApproval, approvals: 1, add: 312, rem: 40), + sum("a2", p1, "nucleic", "flaky-tests", .running, add: 88, rem: 12), + sum("a3", p2, "website", "graphql-migration", .awaitingInput, .awaitingInput, fav: true), + sum("a4", p2, "website", "docs-pass", .awaitingInput, .completed, add: 20, rem: 4), + sum("a5", p1, "nucleic", "old-experiment", .finished, arch: true), + ] + let host1Dashboard = DashboardSnapshot( counts: DashboardCounts( projects: 2, chats: 5, activeChats: 2, messages: 142, activeDays: 9, tokens: 1_284_000), activity: activity, @@ -272,7 +343,32 @@ final class RemoteStore: ObservableObject { ]), WireStatusFeed(provider: "xai", providerName: "xAI", incidents: []), ]) - LiveActivityManager.shared.sync(hostName: hostName, sessions: sessions) + // Host 2 — "Studio Mac" (mesh P3: a second paired Mac, for the host switcher). + let p3 = ProjectID(rawValue: "p3") + let host2Sessions = [ + sum("b1", p3, "renderer", "shadow-mapping", .running, add: 140, rem: 22), + sum("b2", p3, "renderer", "gpu-profiling", .awaitingApproval, approvals: 1), + sum("b3", p1, "nucleic", "cloud-runtime", .awaitingInput, .completed, add: 60, rem: 8), + ] + let host2Dashboard = DashboardSnapshot( + counts: DashboardCounts( + projects: 1, chats: 3, activeChats: 2, messages: 61, activeDays: 5, tokens: 540_000), + activity: activity, + projects: [WireProject(id: p3, name: "renderer", defaultBranch: "main", sessionCount: 2, activeCount: 2)], + todos: [ + WireTodo(id: TodoID(rawValue: "t4"), text: "Bake the light probes overnight", summary: "Bake light probes", + projectID: p3, projectName: "renderer", status: .open, dispatchedSessionID: nil, + triage: "medium", updatedAt: Date()), + ], + usage: WireSubscriptionUsage( + 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]) } /// Offline diff fixture (NUCLEIC_DEMO) so the full-patch Diff tab renders without a host. @@ -338,6 +434,7 @@ final class RemoteStore: ObservableObject { teardown() connectivity = .connecting hostName = payload.hostName + activeHostID = payload.hostStaticKey.fingerprintHex guard let hint = payload.transportHint else { connectivity = .failed("This pairing code needs a newer version of Nucleic Remote.") return @@ -367,8 +464,9 @@ final class RemoteStore: ObservableObject { /// when reachable, else the pairing's tailnet hint — so a phone that leaves the Mac's /// Wi‑Fi rolls over to the tailnet and rolls back when it returns. func reconnect() { - guard let host = IdentityStore.loadPairedHost() else { connectivity = .unpaired; return } + guard let host = activeHost() else { connectivity = .unpaired; return } teardown() + activeHostID = host.fingerprint connectivity = reconnectAttempts == 0 ? .connecting : .reconnecting hostName = host.hostName guard host.transportHint != .relay else { @@ -549,7 +647,14 @@ final class RemoteStore: ObservableObject { func unpair() { teardown() - IdentityStore.clearPairedHost() + // Forget the *active* Mac specifically (the switcher may have made it not the last). + if let id = activeHostID { IdentityStore.removePairedHost(id: id) } else { IdentityStore.clearPairedHost() } + activeHostID = nil + // Mesh P3: if other Macs remain paired, switch to one rather than going fully unpaired. + if IdentityStore.pairedHosts().last != nil { + reconnect() + return + } connectivity = .unpaired sessions = [] // Nothing left to dial — spin the embedded Tailscale node down if it was running. @@ -577,6 +682,8 @@ final class RemoteStore: ObservableObject { UserDefaults.standard.set(false, forKey: Self.demoModeKey) demoTasks.forEach { $0.cancel() } demoTasks.removeAll() + demoHosts = [] + activeHostID = nil openSessionID = nil openEvents = [] openApprovals = [] diff --git a/NucleicRemote/NucleicRemote/Views/SessionsView.swift b/NucleicRemote/NucleicRemote/Views/SessionsView.swift index e9c9d37..38be72b 100644 --- a/NucleicRemote/NucleicRemote/Views/SessionsView.swift +++ b/NucleicRemote/NucleicRemote/Views/SessionsView.swift @@ -79,6 +79,27 @@ 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()