From a33ab80313cfc9bcf640946a7aa428d98bf54f84 Mon Sep 17 00:00:00 2001 From: Andrew Blakeslee Moore Date: Thu, 2 Jul 2026 03:43:13 -0700 Subject: [PATCH 1/4] ios: full feature/appearance parity with the macOS app (client-side) Port the Mac's MarkdownText renderer, AppPalette (color-vision modes + night-softening), appearance settings (theme/text size/color vision), and BuildBanner channel strip to the iPhone remote. Add session-row attention/unseen-completion washes and marker icons, Discard action, branch/worktree options in the new-chat composer, and a transient error bubble for host wire errors. Stamp the build channel via NucleicChannel in Info.plist (ios-release.sh passes NUCLEIC_CHANNEL, default beta). Demo mode gains NUCLEIC_DEMO_SESSION and skips the notifications prompt so UI previews are scriptable. Co-Authored-By: Claude Fable 5 --- NucleicRemote/NucleicRemote/Info.plist | 2 + .../NucleicRemote/Models/RemoteStore.swift | 76 +++++- .../NucleicRemote/NucleicRemoteApp.swift | 80 ++++-- .../NucleicRemote/Views/BuildBanner.swift | 133 ++++++++++ .../NucleicRemote/Views/Composer.swift | 36 ++- .../Views/SessionDetailView.swift | 11 + .../NucleicRemote/Views/SessionsView.swift | 64 ++++- .../NucleicRemote/Views/SettingsView.swift | 25 ++ NucleicRemote/NucleicRemote/Views/Theme.swift | 221 +++++++++++++++-- .../Views/Transcript/MarkdownText.swift | 233 ++++++++++++++++++ .../NucleicRemote/Views/TranscriptRow.swift | 9 +- 11 files changed, 843 insertions(+), 47 deletions(-) create mode 100644 NucleicRemote/NucleicRemote/Views/BuildBanner.swift create mode 100644 NucleicRemote/NucleicRemote/Views/Transcript/MarkdownText.swift diff --git a/NucleicRemote/NucleicRemote/Info.plist b/NucleicRemote/NucleicRemote/Info.plist index 8eb7f6c..119a3e9 100644 --- a/NucleicRemote/NucleicRemote/Info.plist +++ b/NucleicRemote/NucleicRemote/Info.plist @@ -6,5 +6,7 @@ _nucleic._tcp + NucleicChannel + $(NUCLEIC_CHANNEL) diff --git a/NucleicRemote/NucleicRemote/Models/RemoteStore.swift b/NucleicRemote/NucleicRemote/Models/RemoteStore.swift index 1698841..cec5d59 100644 --- a/NucleicRemote/NucleicRemote/Models/RemoteStore.swift +++ b/NucleicRemote/NucleicRemote/Models/RemoteStore.swift @@ -50,6 +50,42 @@ final class RemoteStore: ObservableObject { @Published private(set) var openEvents: [AgentEvent] = [] @Published private(set) var openApprovals: [ApprovalRequest] = [] + /// A transient host-reported error (the mobile echo of the Mac's last-error bubble): + /// shown as a red bubble at the bottom of the screen, auto-dismissed after a few seconds. + struct LastError: Equatable, Identifiable { + let id = UUID() + let message: String + let sessionID: SessionID? + } + @Published var lastError: LastError? + private var errorDismissTask: Task? + + /// When each session was last opened on this device, for the green "finished while you + /// weren't looking" wash on the session list (the Mac's unseen-completion marker; the wire + /// doesn't carry the host's flag, so the phone tracks its own view locally). + @Published private(set) var lastOpenedAt: [SessionID: Date] = RemoteStore.loadLastOpened() + + private static let lastOpenedKey = "nucleic.lastOpenedAt" + private static func loadLastOpened() -> [SessionID: Date] { + guard let raw = UserDefaults.standard.dictionary(forKey: lastOpenedKey) else { return [:] } + return raw.reduce(into: [:]) { result, entry in + if let date = entry.value as? Date { result[SessionID(rawValue: entry.key)] = date } + } + } + private func persistLastOpened() { + let raw = lastOpenedAt.reduce(into: [String: Date]()) { $0[$1.key.rawValue] = $1.value } + UserDefaults.standard.set(raw, forKey: Self.lastOpenedKey) + } + + /// Whether `summary` completed its work after the user last looked at it on this device. + func unseenCompletion(_ summary: WireSessionSummary) -> Bool { + let done = summary.status == .finished + || (summary.status == .awaitingInput && summary.disposition == .completed) + guard done, summary.sessionID != openSessionID else { return false } + guard let opened = lastOpenedAt[summary.sessionID] else { return true } + return summary.updatedAt > opened + } + /// Non-archived sessions (archived chats are hidden, matching the Mac sidebar). var liveSessions: [WireSessionSummary] { sessions.filter { !$0.archived } } @@ -205,10 +241,17 @@ final class RemoteStore: ObservableObject { openEvents = [] openApprovals = [] seenSeq.removeAll() + markOpened(sessionID) if demoMode { seedDemoTranscript(sessionID); return } send(.subscribe(Subscribe(sessionID: sessionID, sinceSeq: nil, verbosity: .full))) } + /// Record that the user looked at this session now (clears its unseen-completion wash). + func markOpened(_ sessionID: SessionID) { + lastOpenedAt[sessionID] = Date() + persistLastOpened() + } + /// Offline transcript fixture (NUCLEIC_DEMO) so the richer transcript surfaces — grouped /// tools, Orchestra card, usage/cost, file changes, run outcome — render without a host. private func seedDemoTranscript(_ sessionID: SessionID) { @@ -237,7 +280,10 @@ final class RemoteStore: ObservableObject { } func closeOpen() { - if let id = openSessionID { send(.unsubscribe(id)) } + if let id = openSessionID { + send(.unsubscribe(id)) + markOpened(id) // everything up to now has been seen + } openSessionID = nil openEvents = [] openApprovals = [] @@ -290,6 +336,8 @@ final class RemoteStore: ObservableObject { if id == openSessionID { closeOpen() } } func integrate(_ id: SessionID, _ mode: IntegrationMode) { send(.integrate(id, mode)) } + /// Throw away the session's branch/worktree without landing it (the Mac's Discard…). + func discard(_ id: SessionID) { send(.discard(id)) } func interrupt(_ id: SessionID) { send(.interrupt(id)) } /// Cancel one queued (not-yet-sent) follow-up by id — the phone's per-message ✕. func cancelQueuedMessage(_ id: SessionID, _ messageID: UUID) { send(.cancelQueuedMessage(id, messageID)) } @@ -370,8 +418,12 @@ final class RemoteStore: ObservableObject { } case .approvalResolved(let resolved): openApprovals.removeAll { $0.id == resolved.id } - case .wireError: - break // surfaced contextually by callers; not fatal + case .wireError(let error): + // Not fatal — surface as a transient bubble (the Mac's last-error overlay). + // Losing an approval race isn't an error worth interrupting for; the card + // collapses on the matching `approvalResolved`. + guard error.code != .alreadyResolved else { break } + showError(error.message, sessionID: error.sessionID) case .failed(let message): connectivity = .failed(message) scheduleRetry() @@ -381,6 +433,24 @@ final class RemoteStore: ObservableObject { } } + /// Show a transient error bubble, replacing any current one; auto-dismisses after 6s + /// (matching the Mac's last-error overlay cadence). + private func showError(_ message: String, sessionID: SessionID?) { + let error = LastError(message: message, sessionID: sessionID) + lastError = error + errorDismissTask?.cancel() + errorDismissTask = Task { [weak self] in + try? await Task.sleep(for: .seconds(6)) + guard let self, self.lastError == error else { return } + self.lastError = nil + } + } + + func dismissError() { + errorDismissTask?.cancel() + lastError = nil + } + private func scheduleRetry() { guard isPaired else { return } reconnectAttempts += 1 diff --git a/NucleicRemote/NucleicRemote/NucleicRemoteApp.swift b/NucleicRemote/NucleicRemote/NucleicRemoteApp.swift index 4d5fd12..fbed750 100644 --- a/NucleicRemote/NucleicRemote/NucleicRemoteApp.swift +++ b/NucleicRemote/NucleicRemote/NucleicRemoteApp.swift @@ -14,7 +14,10 @@ struct NucleicRemoteApp: App { store.onAppear() // Surface the notifications prompt + register for APNS. The token rides along // in the sync Hello; the relay uses it to wake the phone for approvals (§3). - PushRegistrar.shared.requestAuthorizationAndRegister() + // Skipped in demo mode so offline UI previews aren't blocked by the system dialog. + if ProcessInfo.processInfo.environment["NUCLEIC_DEMO"] != "1" { + PushRegistrar.shared.requestAuthorizationAndRegister() + } // Anonymous, opt-out, once-a-day DAI heartbeat (docs/CLOUD_INFRA.md §4). Task { await HeartbeatReporter.reportIfDue() } } @@ -30,27 +33,70 @@ 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 + @AppStorage(AppTextSize.storageKey) private var textSizeRaw = AppTextSize.medium.rawValue + @AppStorage(ColorVisionMode.storageKey) private var colorVisionRaw = ColorVisionMode.standard.rawValue var body: some View { - 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) + VStack(spacing: 0) { + // The same non-release warning strip the Mac window carries (dev/canary/beta/rc). + BuildBanner() + 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) + } + } else { + PairingIntroView() } - } else { - PairingIntroView() } } + .overlay(alignment: .bottom) { ErrorBubble() } .tint(Palette.accent) + .preferredColorScheme((AppAppearance(rawValue: appearanceRaw) ?? .system).colorScheme) + .dynamicTypeSize((AppTextSize(rawValue: textSizeRaw) ?? .medium).dynamicTypeSize) + // The palette resolves against the stored color-vision mode at render time; rebuild + // the hierarchy when it changes so every cached view picks up the remapped colors. + .id(colorVisionRaw) + } +} + +/// A transient red bubble for host-reported errors — the mobile echo of the Mac's last-error +/// overlay at the detail pane's bottom. Auto-dismissed by the store after a few seconds; a tap +/// dismisses it immediately. +private struct ErrorBubble: View { + @EnvironmentObject var store: RemoteStore + + var body: some View { + if let error = store.lastError { + HStack(spacing: 8) { + Image(systemName: "exclamationmark.triangle.fill") + Text(error.message).lineLimit(3) + Spacer(minLength: 0) + Image(systemName: "xmark").opacity(0.7) + } + .font(.footnote.weight(.medium)) + .foregroundStyle(.white) + .padding(.horizontal, 12) + .padding(.vertical, 10) + .background(Palette.danger, in: RoundedRectangle(cornerRadius: 12)) + .padding(.horizontal, 16) + .padding(.bottom, 58) // clear the tab bar + .onTapGesture { store.dismissError() } + .transition(.move(edge: .bottom).combined(with: .opacity)) + .animation(.easeInOut(duration: 0.2), value: store.lastError) + } } } diff --git a/NucleicRemote/NucleicRemote/Views/BuildBanner.swift b/NucleicRemote/NucleicRemote/Views/BuildBanner.swift new file mode 100644 index 0000000..500e9da --- /dev/null +++ b/NucleicRemote/NucleicRemote/Views/BuildBanner.swift @@ -0,0 +1,133 @@ +import SwiftUI + +/// How this copy of the remote was built — the iOS mirror of the Mac's `BuildChannel`. +/// Stamped at archive time via the `NucleicChannel` Info.plist key (`$(NUCLEIC_CHANNEL)`, +/// see scripts/ios-release.sh); unset builds fall back to runtime detection (Debug → local, +/// TestFlight → beta, App Store → release). +enum BuildChannel { + /// The `dev` channel — red "Local Development Build" banner. + case local + /// The `canary` channel — canary-yellow "Canary" banner with the bird. The bleeding-edge + /// distributed channel, cut before beta (see BUILD.md). + case canary + /// The `beta` channel — blue "Beta" banner. + case beta + /// The `rc` channel — gold "Release Candidate" banner. + case releaseCandidate + /// The `stable` channel — no banner. + case release +} + +/// Build identity for the running app: which channel it came from and its build label. +/// The Mac embeds a git commit via a prebuild plugin; the iOS build number is derived from +/// `git rev-list --count` at archive time (scripts/ios-release.sh), so `CFBundleVersion` +/// plays the same role here. +struct BuildInfo { + let channel: BuildChannel + let buildLabel: String + /// `CFBundleShortVersionString` when present, else `nil`. + let version: String? + + /// Resolved once — none of this changes while the app is running. + static let current = BuildInfo() + + init() { + let info = Bundle.main.infoDictionary + version = info?["CFBundleShortVersionString"] as? String + buildLabel = (info?["CFBundleVersion"] as? String) ?? "local" + channel = Self.detectChannel(stamped: info?["NucleicChannel"] as? String) + } + + /// Prefer the archive-time stamp (mirrors the Mac's `NUCLEIC_CHANNEL` build setting); + /// fall back to what the runtime can tell on its own. + private static func detectChannel(stamped: String?) -> BuildChannel { + switch stamped?.lowercased() { + case "dev", "local": return .local + case "canary": return .canary + case "beta": return .beta + case "rc": return .releaseCandidate + case "stable", "release": return .release + default: break + } + #if DEBUG + return .local + #else + // TestFlight installs carry the sandbox receipt; App Store installs don't. + if Bundle.main.appStoreReceiptURL?.lastPathComponent == "sandboxReceipt" { return .beta } + return .release + #endif + } +} + +/// Header strip warning that this is a non-release build — the same channel colors, icons, +/// and copy as the Mac's `BuildBanner`: red for local builds, canary-yellow for canary, blue +/// for beta, gold for a release candidate. The leading edge names the channel; the build +/// label and an all-caps "EXPERIMENTAL" badge sit on the trailing edge. Renders nothing for +/// a shipping App Store release. +struct BuildBanner: View { + var info: BuildInfo = .current + + var body: some View { + if let style = Style(channel: info.channel) { + HStack(spacing: 8) { + Image(systemName: style.icon) + .font(.system(size: 15, weight: .heavy)) + Text(style.label) + .font(.footnote.weight(.semibold)) + .lineLimit(1) + Spacer(minLength: 12) + Text(info.buildLabel) + .font(.caption.monospaced()) + .opacity(0.85) + Text("EXPERIMENTAL") + .font(.caption2.weight(.heavy)) + .tracking(0.9) + .padding(.horizontal, 6) + .padding(.vertical, 2) + .background(.white.opacity(0.16), in: .capsule) + } + .foregroundStyle(.white.opacity(0.92)) + .padding(.horizontal, 12) + .padding(.vertical, 5) + .frame(maxWidth: .infinity) + .background(style.tint.opacity(0.5)) + .accessibilityLabel(style.tooltip(version: info.version, build: info.buildLabel)) + } + } + + /// Per-channel appearance — identical constants to the Mac. `nil` for `.release` so the + /// banner disappears entirely. + private struct Style { + var tint: Color + var label: String + var icon: String + + init?(channel: BuildChannel) { + switch channel { + case .local: + tint = Color(red: 0.78, green: 0.18, blue: 0.18) // red + label = "Local Development Build" + icon = "hammer.fill" + case .canary: + tint = Color(red: 1.00, green: 0.87, blue: 0.00) // bright canary yellow + label = "Canary Build" + icon = "bird.fill" + case .beta: + tint = Color(red: 0.13, green: 0.40, blue: 0.86) // blue + label = "Engineering Beta" + icon = "person.2.badge.gearshape.fill" + case .releaseCandidate: + tint = Color(red: 0.83, green: 0.65, blue: 0.12) // gold + label = "Release Candidate" + icon = "checkmark.seal.fill" + case .release: + return nil + } + } + + func tooltip(version: String?, build: String) -> String { + let v = version.map { "\($0) " } ?? "" + return "\(label) — \(v)build \(build). Not a release build." + } + } +} diff --git a/NucleicRemote/NucleicRemote/Views/Composer.swift b/NucleicRemote/NucleicRemote/Views/Composer.swift index a06edc2..b7897a7 100644 --- a/NucleicRemote/NucleicRemote/Views/Composer.swift +++ b/NucleicRemote/NucleicRemote/Views/Composer.swift @@ -10,6 +10,10 @@ struct StartChatComposer: View { @State private var auto = false @State private var model: String? @State private var effort = MobileEfforts.fallback + // Advanced options (the Mac new-session sheet's base branch + worktree fields). + @State private var showOptions = false + @State private var baseBranch = "" + @State private var useWorktree = true private var projects: [WireProject] { store.dashboard.projects } private var selected: WireProject? { @@ -42,6 +46,32 @@ struct StartChatComposer: View { ModelMenu(model: $model, catalog: store.modelCatalog, backend: nil) Spacer() EffortMenu(effort: $effort, catalog: store.modelCatalog, modelSKU: model, controlled: controlled) + Button { + withAnimation(.easeInOut(duration: 0.18)) { showOptions.toggle() } + } label: { + Image(systemName: showOptions ? "chevron.up.circle" : "ellipsis.circle") + .font(.caption) + .foregroundStyle(Palette.accent) + } + .buttonStyle(.plain) + } + if showOptions { + VStack(alignment: .leading, spacing: 8) { + HStack(spacing: 8) { + Image(systemName: "arrow.triangle.branch").foregroundStyle(.secondary) + TextField(selected?.defaultBranch ?? "Base branch", text: $baseBranch) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + .textFieldStyle(.roundedBorder) + .font(.caption) + } + Toggle(isOn: $useWorktree) { + Label("Run in a worktree", systemImage: "square.stack.3d.up") + .font(.caption) + } + .tint(Palette.accent) + } + .transition(.move(edge: .top).combined(with: .opacity)) } HStack(alignment: .bottom, spacing: 8) { TextField("Describe a task…", text: $draft, axis: .vertical) @@ -51,7 +81,11 @@ struct StartChatComposer: View { .keyboardDismissable() Button { if let project = selected { - store.startChat(in: project.id, message: draft, model: model, effort: effort, auto: auto) + let branch = baseBranch.trimmingCharacters(in: .whitespaces) + store.startChat( + in: project.id, message: draft, model: model, effort: effort, + baseBranch: branch.isEmpty ? nil : branch, + useWorktree: useWorktree, auto: auto) draft = "" } } label: { diff --git a/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift b/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift index 1fd2c07..ea38e32 100644 --- a/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift +++ b/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift @@ -11,6 +11,7 @@ struct SessionDetailView: View { @State private var showRename = false @State private var renameDraft = "" @State private var showIntegrate = false + @State private var showDiscard = false private var summary: WireSessionSummary? { store.sessions.first { $0.sessionID == sessionID } @@ -56,6 +57,13 @@ struct SessionDetailView: View { Button("Rebase") { store.integrate(sessionID, .rebase) } Button("Cancel", role: .cancel) {} } + .confirmationDialog( + "Discard this session's branch and worktree? Unmerged work is lost.", + isPresented: $showDiscard, titleVisibility: .visible + ) { + Button("Discard", role: .destructive) { store.discard(sessionID) } + Button("Cancel", role: .cancel) {} + } .onAppear { store.open(sessionID) } .onDisappear { store.closeOpen() } } @@ -147,6 +155,9 @@ struct SessionDetailView: View { } Section { Button { showIntegrate = true } label: { Label("Integrate…", systemImage: "arrow.triangle.merge") } + Button(role: .destructive) { showDiscard = true } label: { + Label("Discard…", systemImage: "xmark.bin") + } Button { store.setArchived(sessionID, !summary.archived) } label: { Label(summary.archived ? "Unarchive" : "Archive", systemImage: summary.archived ? "tray.and.arrow.up" : "archivebox") diff --git a/NucleicRemote/NucleicRemote/Views/SessionsView.swift b/NucleicRemote/NucleicRemote/Views/SessionsView.swift index cc454dd..8f7dd67 100644 --- a/NucleicRemote/NucleicRemote/Views/SessionsView.swift +++ b/NucleicRemote/NucleicRemote/Views/SessionsView.swift @@ -6,6 +6,7 @@ import NucleicProtocol struct SessionsView: View { @EnvironmentObject var store: RemoteStore @State private var showArchived = false + @State private var path = NavigationPath() private var grouped: [(title: String, rows: [WireSessionSummary])] { let pool = (showArchived ? store.sessions : store.liveSessions) @@ -20,7 +21,7 @@ struct SessionsView: View { } var body: some View { - NavigationStack { + NavigationStack(path: $path) { Group { if store.liveSessions.isEmpty && !showArchived { ContentUnavailableView( @@ -33,11 +34,10 @@ struct SessionsView: View { ForEach(grouped, id: \.title) { group in Section(group.title.uppercased()) { ForEach(group.rows, id: \.sessionID) { summary in - NavigationLink { - SessionDetailView(sessionID: summary.sessionID) - } label: { + NavigationLink(value: summary.sessionID) { SessionRow(summary: summary) } + .listRowBackground(SessionRowWash(summary: summary)) .swipeActions(edge: .leading) { Button { store.setFavorite(summary.sessionID, !summary.favorite) @@ -65,6 +65,16 @@ struct SessionsView: View { } } .navigationTitle("Sessions") + .navigationDestination(for: SessionID.self) { id in + SessionDetailView(sessionID: id) + } + // Offline UI previews (with NUCLEIC_DEMO): jump straight into one session. + .onAppear { + if let raw = ProcessInfo.processInfo.environment["NUCLEIC_DEMO_SESSION"], + !raw.isEmpty, path.isEmpty { + path.append(SessionID(rawValue: raw)) + } + } .toolbar { ToolbarItem(placement: .topBarTrailing) { Button { @@ -80,11 +90,18 @@ struct SessionsView: View { } struct SessionRow: View { + @EnvironmentObject var store: RemoteStore let summary: WireSessionSummary /// Show the project name as subtext. Suppressed when the row already lives inside /// its project's own section (e.g. `ProjectDetailView`), where the name is redundant. var showProjectName = true + /// Whether Orchestra is latched on (the effort carries the sentinel). + private var orchestra: Bool { + guard let effort = summary.effort else { return false } + return effort.caseInsensitiveCompare(store.modelCatalog.orchestraSentinelOrFallback) == .orderedSame + } + var body: some View { HStack(spacing: 12) { Image(systemName: StatusStyle.symbol(summary.status, disposition: summary.disposition)) @@ -97,6 +114,17 @@ struct SessionRow: View { Image(systemName: "star.fill").font(.caption2).foregroundStyle(.yellow) } 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) + } } if showProjectName { Text(summary.projectName).font(.caption).foregroundStyle(.secondary) @@ -121,3 +149,31 @@ struct SessionRow: View { .padding(.vertical, 2) } } + +/// The Mac sidebar's row washes, mirrored: a breathing amber fill on a chat that's blocked on +/// you, and a steady green fill on a chat that finished while you weren't looking. Applied as +/// a `listRowBackground` so the wash sits under the whole row. +struct SessionRowWash: View { + @EnvironmentObject var store: RemoteStore + let summary: WireSessionSummary + @Environment(\.accessibilityReduceMotion) private var reduceMotion + @State private var pulsing = false + + var body: some View { + ZStack { + Color(.secondarySystemGroupedBackground) // the list row's normal surface + if summary.status.needsYou(summary.disposition) { + // Breathing amber (0.10 → 0.30 on a 1.3s ease, matching the Mac); a steady + // mid-tint under Reduce Motion. + Palette.attention.opacity(reduceMotion ? 0.18 : (pulsing ? 0.30 : 0.10)) + .animation( + reduceMotion ? nil + : .easeInOut(duration: 1.3).repeatForever(autoreverses: true), + value: pulsing) + .onAppear { pulsing = true } + } else if store.unseenCompletion(summary) { + Palette.success.opacity(0.18) + } + } + } +} diff --git a/NucleicRemote/NucleicRemote/Views/SettingsView.swift b/NucleicRemote/NucleicRemote/Views/SettingsView.swift index 94d020f..d657298 100644 --- a/NucleicRemote/NucleicRemote/Views/SettingsView.swift +++ b/NucleicRemote/NucleicRemote/Views/SettingsView.swift @@ -7,10 +7,35 @@ struct SettingsView: View { @AppStorage("nucleic.showRawEvents") private var showRaw = false @AppStorage("nucleic.showLockEvents") private var showLockEvents = true @AppStorage(HeartbeatSettings.shareAnonymousUsageKey) private var shareAnonymousUsage = true + @AppStorage(AppAppearance.storageKey) private var appearanceRaw = AppAppearance.system.rawValue + @AppStorage(AppTextSize.storageKey) private var textSizeRaw = AppTextSize.medium.rawValue + @AppStorage(ColorVisionMode.storageKey) private var colorVisionRaw = ColorVisionMode.standard.rawValue var body: some View { NavigationStack { Form { + Section { + Picker("Theme", selection: $appearanceRaw) { + ForEach(AppAppearance.allCases) { appearance in + Text(appearance.label).tag(appearance.rawValue) + } + } + Picker("Text size", selection: $textSizeRaw) { + ForEach(AppTextSize.allCases) { size in + Text(size.label).tag(size.rawValue) + } + } + Picker("Color vision", selection: $colorVisionRaw) { + ForEach(ColorVisionMode.allCases) { mode in + Text(mode.label).tag(mode.rawValue) + } + } + } header: { + Text("Appearance") + } footer: { + Text("Color vision remaps status colors to hues that stay distinguishable. Every status also carries a text label, so color is never the only cue.") + } + Section("Connection") { LabeledContent("Status", value: store.connectivity.label) if !store.hostName.isEmpty { diff --git a/NucleicRemote/NucleicRemote/Views/Theme.swift b/NucleicRemote/NucleicRemote/Views/Theme.swift index 596d822..e50b5fd 100644 --- a/NucleicRemote/NucleicRemote/Views/Theme.swift +++ b/NucleicRemote/NucleicRemote/Views/Theme.swift @@ -1,25 +1,208 @@ import SwiftUI import NucleicProtocol -/// The mobile mirror of the Mac's design language (AppTheme/AppPalette on desktop): a deep -/// teal accent, the same categorical status palette, and the same status→color mapping -/// refined by turn disposition — so the two devices read identically. Pure SwiftUI Color -/// (no AppKit), adapting to light/dark via the system. +/// The user's preferred appearance (Settings → Appearance). `system` follows iOS. +/// Same storage key and semantics as the Mac's `AppAppearance`. +enum AppAppearance: String, CaseIterable, Identifiable { + case system, light, dark + + var id: String { rawValue } + static let storageKey = "nucleic.appearance" + + var label: String { + switch self { + case .system: "System" + case .light: "Light" + case .dark: "Dark" + } + } + + /// `nil` means follow the system setting. + var colorScheme: ColorScheme? { + switch self { + case .system: nil + case .light: .light + case .dark: .dark + } + } +} + +/// User-selectable global text size (Settings → Appearance), applied app-wide via +/// `.dynamicTypeSize` so the semantic fonts the UI uses scale together. Mirrors the Mac's +/// `AppTextSize` (same storage key and steps). +enum AppTextSize: String, CaseIterable, Identifiable { + case small, medium, large, xLarge, xxLarge + + var id: String { rawValue } + static let storageKey = "nucleic.textSize" + + var label: String { + switch self { + case .small: "Small" + case .medium: "Default" + case .large: "Large" + case .xLarge: "Larger" + case .xxLarge: "Largest" + } + } + + /// `.large` is the system default content size, so "Default" maps to it. + var dynamicTypeSize: DynamicTypeSize { + switch self { + case .small: .small + case .medium: .large + case .large: .xLarge + case .xLarge: .xxLarge + case .xxLarge: .xxxLarge + } + } +} + +/// Color-vision mode. Remaps the categorical palette (status dots, activity grid, accent) to +/// hues that stay distinguishable under the common color-vision deficiencies — the same +/// palettes as the Mac's `ColorVisionMode`/`AppPalette`, same storage key, so both devices +/// read identically for the same user. +enum ColorVisionMode: String, CaseIterable, Identifiable { + case standard, deuteranopia, protanopia, tritanopia + + var id: String { rawValue } + static let storageKey = "nucleic.colorVision" + + var label: String { + switch self { + case .standard: "Standard" + case .deuteranopia: "Deuteranopia (red–green)" + case .protanopia: "Protanopia (red–green)" + case .tritanopia: "Tritanopia (blue–yellow)" + } + } + + /// The persisted mode (Settings → Appearance). + static var stored: ColorVisionMode { + ColorVisionMode( + rawValue: UserDefaults.standard.string(forKey: storageKey) ?? "") ?? .standard + } +} + +/// A resolved set of semantic colors for one `ColorVisionMode` — the mobile mirror of the +/// Mac's `AppPalette`, including its night-softening: vivid in light mode, desaturated and +/// dimmed in dark so status accents read calmly instead of glowing. +struct ResolvedPalette { + var accent: Color + var neutral: Color + var active: Color + var attention: Color + var success: Color + var paused: Color + var danger: Color + var frozen: Color + var activityBase: Color + + /// The vivid, light-mode categorical colors (identical constants to the Mac's + /// `AppPalette.base`); `make` night-softens them for dark mode. + private static func base(_ mode: ColorVisionMode) -> ResolvedPalette { + switch mode { + case .standard: + ResolvedPalette( + accent: Color(red: 0.04, green: 0.52, blue: 0.50), // deep teal + neutral: .secondary, + active: Color(red: 0.20, green: 0.55, blue: 0.95), // blue + attention: Color(red: 1.00, green: 0.65, blue: 0.15), // amber + success: Color(red: 0.30, green: 0.80, blue: 0.45), // green + paused: Color(red: 0.62, green: 0.49, blue: 0.93), // purple + danger: Color(red: 0.92, green: 0.34, blue: 0.34), // red + frozen: Color(red: 0.26, green: 0.64, blue: 0.96), // cold "iced" blue + activityBase: Color(red: 0.30, green: 0.80, blue: 0.45)) + case .deuteranopia, .protanopia: + // Okabe–Ito-derived: avoids relying on red↔green separation. + ResolvedPalette( + accent: Color(red: 0.00, green: 0.50, blue: 0.58), // deep teal + neutral: .secondary, + active: Color(red: 0.00, green: 0.45, blue: 0.70), // blue + attention: Color(red: 0.90, green: 0.62, blue: 0.00), // orange + success: Color(red: 0.34, green: 0.71, blue: 0.91), // sky blue + paused: Color(red: 0.80, green: 0.47, blue: 0.65), // reddish purple + danger: Color(red: 0.84, green: 0.37, blue: 0.00), // vermillion + frozen: Color(red: 0.20, green: 0.58, blue: 0.86), // cool sky blue + activityBase: Color(red: 0.00, green: 0.45, blue: 0.70)) + case .tritanopia: + // Blue↔yellow weak; red↔green is intact, so lean on those plus magenta. + ResolvedPalette( + accent: Color(red: 0.46, green: 0.33, blue: 0.78), // indigo-violet + neutral: .secondary, + active: Color(red: 0.00, green: 0.62, blue: 0.45), // green-teal + attention: Color(red: 0.85, green: 0.20, blue: 0.46), // magenta + success: Color(red: 0.00, green: 0.45, blue: 0.70), // blue + paused: Color(red: 0.67, green: 0.36, blue: 0.66), // purple + danger: Color(red: 0.79, green: 0.10, blue: 0.15), // red + frozen: Color(red: 0.30, green: 0.72, blue: 0.85), // light cyan + activityBase: Color(red: 0.00, green: 0.45, blue: 0.70)) + } + } + + static func make(_ mode: ColorVisionMode) -> ResolvedPalette { + var p = base(mode) + p.accent = Palette.nightSoft(p.accent) + p.active = Palette.nightSoft(p.active) + p.attention = Palette.nightSoft(p.attention) + p.success = Palette.nightSoft(p.success) + p.paused = Palette.nightSoft(p.paused) + p.danger = Palette.nightSoft(p.danger) + p.frozen = Palette.nightSoft(p.frozen) + p.activityBase = Palette.nightSoft(p.activityBase) + return p + } +} + +/// The mobile mirror of the Mac's design language (AppTheme/AppPalette on desktop): the same +/// categorical status palette per color-vision mode, night-softened in dark mode, and the +/// same status→color mapping refined by turn disposition — so the two devices read +/// identically. The accessors resolve against the persisted `ColorVisionMode` on each read; +/// the resolved palettes themselves are built once. enum Palette { - static let accent = Color(red: 0.04, green: 0.52, blue: 0.50) // deep teal - static let active = Color(red: 0.20, green: 0.55, blue: 0.95) // blue - static let attention = Color(red: 1.00, green: 0.65, blue: 0.15) // amber - static let success = Color(red: 0.30, green: 0.80, blue: 0.45) // green - static let paused = Color(red: 0.62, green: 0.49, blue: 0.93) // purple - static let danger = Color(red: 0.92, green: 0.34, blue: 0.34) // red + private static let resolved: [ColorVisionMode: ResolvedPalette] = Dictionary( + uniqueKeysWithValues: ColorVisionMode.allCases.map { ($0, ResolvedPalette.make($0)) }) + + static var current: ResolvedPalette { resolved[ColorVisionMode.stored] ?? resolved[.standard]! } + + static var accent: Color { current.accent } + static var active: Color { current.active } + static var attention: Color { current.attention } + static var success: Color { current.success } + static var paused: Color { current.paused } + static var danger: Color { current.danger } /// Cold "iced" blue for a streak-frozen day in the activity grid — mirrors the Mac's /// `AppPalette.frozen`. - static let frozen = Color(red: 0.26, green: 0.64, blue: 0.96) + static var frozen: Color { current.frozen } static let neutral = Color.secondary - /// The orchestra (orchestration mode) signature gold — mirrors the Mac's - /// `AppTheme.orchestra`. Used for the effort picker's Orchestra option and the - /// composer's golden ring. - static let orchestra = Color(red: 0.90, green: 0.66, blue: 0.16) + /// The orchestra (orchestration mode) signature gold — the exact appearance-aware pair the + /// Mac's `AppTheme.orchestra` uses: a rich amber-gold in light (dark enough to read on + /// white), a bright warm gold in dark so it glows without burning. A brand color, not a + /// categorical status, so it doesn't remap with color-vision mode. + static let orchestra = dynamic( + light: UIColor(red: 0.80, green: 0.57, blue: 0.09, alpha: 1), + dark: UIColor(red: 1.0, green: 0.82, blue: 0.34, alpha: 1)) + /// Primary prose color for chat text — the Mac's `AppTheme.primaryText`: a soft off-white + /// on the deep dark background (reads calmly at night) and a near-black in light. + static let primaryText = dynamic( + light: UIColor(red: 0.24, green: 0.24, blue: 0.27, alpha: 1), + dark: UIColor(red: 0.80, green: 0.81, blue: 0.83, alpha: 1)) + + /// A `Color` that resolves to `light`/`dark` with the active trait collection (driven by + /// `.preferredColorScheme`) — the UIKit twin of the Mac's `NSColor(name:)` provider. + private static func dynamic(light: UIColor, dark: UIColor) -> Color { + Color(UIColor { traits in traits.userInterfaceStyle == .dark ? dark : light }) + } + + /// Light mode keeps `base`; dark mode gets a desaturated, dimmed variant — the same + /// night-softening curve as the Mac's `AppPalette.nightSoft`. + static func nightSoft(_ base: Color) -> Color { + let ui = UIColor(base) + var h: CGFloat = 0, s: CGFloat = 0, b: CGFloat = 0, a: CGFloat = 0 + guard ui.getHue(&h, saturation: &s, brightness: &b, alpha: &a) else { return base } + let dark = UIColor(hue: h, saturation: s * 0.68, brightness: min(b, 0.80) * 0.82, alpha: a) + return dynamic(light: ui, dark: dark) + } /// Dot/accent color for a session's status, refined by the last turn's disposition — a /// finished-the-work turn reads as "done" (success) rather than the calm "ready" accent. @@ -39,9 +222,9 @@ enum Palette { static func activity(level: Int) -> Color { switch level { case 0: Color.secondary.opacity(0.15) - case 1: success.opacity(0.4) - case 2: success.opacity(0.7) - default: success + case 1: current.activityBase.opacity(0.4) + case 2: current.activityBase.opacity(0.7) + default: current.activityBase } } @@ -49,7 +232,7 @@ enum Palette { /// activity, outliers excluded — see `ActivityScale`). `0` is an empty day's neutral gray. /// Mirrors the Mac's `AppPalette.activity(intensity:)`. static func activity(intensity: Double) -> Color { - intensity <= 0 ? Color.secondary.opacity(0.15) : success.opacity(intensity) + intensity <= 0 ? Color.secondary.opacity(0.15) : current.activityBase.opacity(intensity) } } diff --git a/NucleicRemote/NucleicRemote/Views/Transcript/MarkdownText.swift b/NucleicRemote/NucleicRemote/Views/Transcript/MarkdownText.swift new file mode 100644 index 0000000..4655543 --- /dev/null +++ b/NucleicRemote/NucleicRemote/Views/Transcript/MarkdownText.swift @@ -0,0 +1,233 @@ +import Foundation +import SwiftUI + +/// A lightweight Markdown renderer for chat messages — the exact port of the Mac's +/// `MarkdownText`: fenced code blocks, headings, bullet/numbered lists, GitHub-style tables, +/// and inline emphasis/links/`code`. Not a full CommonMark implementation — just the +/// constructs agents actually emit — so a reply renders identically on both devices. +struct MarkdownText: View { + let markdown: String + /// Base prose size; every other size (headings, code, tables) is derived from it so a + /// response renders on one consistent scale. Scaled with the user's text-size setting + /// (which drives `.dynamicTypeSize`), anchored to the Mac's transcript prose size. + @ScaledMetric(relativeTo: .body) private var bodySize: CGFloat = 15 + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + ForEach(Array(Self.parse(markdown).enumerated()), id: \.offset) { _, block in + switch block { + case .code(let code): + codeBlock(code) + case .text(let text): + textBlock(text) + case .table(let rows): + tableView(rows) + } + } + } + // Anchor the whole response to one base size; inline code and body text + // inherit it, so nothing drifts smaller than the prose around it. + .font(.system(size: bodySize)) + // Soft off-white (vs. pure white) so chat prose reads calmly at night. + .foregroundStyle(Palette.primaryText) + } + + // MARK: - blocks + + private enum Block { case code(String), text(String), table([[String]]) } + + /// Parsed-block cache. The same message string is re-parsed on every body re-evaluation + /// (scrolling, a sibling row streaming) and on every chat reopen, yet structural parsing + /// is independent of `bodySize` — so the source string is a complete key. Bounded; + /// `NSCache` also evicts under memory pressure. + private final class ParsedBlocks { let blocks: [Block]; init(_ b: [Block]) { self.blocks = b } } + private static let blockCache: NSCache = { + let cache = NSCache() + cache.countLimit = 2048 + return cache + }() + + private static func parse(_ markdown: String) -> [Block] { + let key = markdown as NSString + if let hit = blockCache.object(forKey: key) { return hit.blocks } + let blocks = parseUncached(markdown) + blockCache.setObject(ParsedBlocks(blocks), forKey: key) + return blocks + } + + private static func parseUncached(_ markdown: String) -> [Block] { + var blocks: [Block] = [] + var textBuffer: [String] = [] + func flush() { + let joined = textBuffer.joined(separator: "\n").trimmingCharacters(in: .whitespacesAndNewlines) + if !joined.isEmpty { blocks.append(.text(joined)) } + textBuffer = [] + } + let lines = markdown.components(separatedBy: "\n") + var index = 0 + while index < lines.count { + if lines[index].trimmingCharacters(in: .whitespaces).hasPrefix("```") { + flush() + var code: [String] = [] + index += 1 + while index < lines.count, + !lines[index].trimmingCharacters(in: .whitespaces).hasPrefix("```") { + code.append(lines[index]) + index += 1 + } + blocks.append(.code(code.joined(separator: "\n"))) + index += 1 // consume closing fence + } else if isTableStart(lines, index) { + flush() + var rows: [[String]] = [tableCells(lines[index])] + index += 2 // header row + the |---|--- separator + while index < lines.count, lines[index].contains("|"), + !lines[index].trimmingCharacters(in: .whitespaces).isEmpty { + rows.append(tableCells(lines[index])) + index += 1 + } + blocks.append(.table(rows)) + } else { + textBuffer.append(lines[index]) + index += 1 + } + } + flush() + return blocks + } + + // MARK: - tables + + /// A GitHub-style table: a `|`-bearing header line immediately followed by a + /// `|---|:--:|` separator line. + private static func isTableStart(_ lines: [String], _ index: Int) -> Bool { + guard lines[index].contains("|"), index + 1 < lines.count else { return false } + return isSeparatorRow(lines[index + 1]) + } + + private static func isSeparatorRow(_ line: String) -> Bool { + let cells = tableCells(line) + guard !cells.isEmpty else { return false } + return cells.allSatisfy { cell in + !cell.isEmpty && cell.allSatisfy { $0 == "-" || $0 == ":" } && cell.contains("-") + } + } + + /// Split a table row into trimmed cells, dropping the empties created by the + /// leading/trailing pipes. + private static func tableCells(_ line: String) -> [String] { + var trimmed = line.trimmingCharacters(in: .whitespaces) + if trimmed.hasPrefix("|") { trimmed.removeFirst() } + if trimmed.hasSuffix("|") { trimmed.removeLast() } + return trimmed.components(separatedBy: "|").map { $0.trimmingCharacters(in: .whitespaces) } + } + + private func tableView(_ rows: [[String]]) -> some View { + let columns = rows.map(\.count).max() ?? 0 + return Grid(alignment: .topLeading, horizontalSpacing: 14, verticalSpacing: 6) { + ForEach(Array(rows.enumerated()), id: \.offset) { rowIndex, row in + GridRow { + ForEach(0.. 0 { + Divider().gridCellColumns(columns) + } + } + } + .padding(10) + .background(.quaternary.opacity(0.25), in: .rect(cornerRadius: 8)) + .overlay(RoundedRectangle(cornerRadius: 8).strokeBorder(.quaternary, lineWidth: 1)) + } + + private func codeBlock(_ code: String) -> some View { + ScrollView(.horizontal, showsIndicators: false) { + Text(code) + // Same point size as body/inline code, just monospaced — so code + // doesn't shrink relative to the prose. + .font(.system(size: bodySize, design: .monospaced)) + .textSelection(.enabled) + .padding(8) + .frame(maxWidth: .infinity, alignment: .leading) + } + .background(.quaternary.opacity(0.4), in: .rect(cornerRadius: 6)) + } + + private func textBlock(_ text: String) -> some View { + VStack(alignment: .leading, spacing: 5) { + ForEach(Array(text.components(separatedBy: "\n").enumerated()), id: \.offset) { _, raw in + lineView(raw) + } + } + // Breathing room between the visual lines of a single wrapped paragraph, + // which the per-line VStack spacing above can't reach. + .lineSpacing(4) + } + + @ViewBuilder + private func lineView(_ raw: String) -> some View { + let trimmed = raw.trimmingCharacters(in: .whitespaces) + if trimmed.isEmpty { + Color.clear.frame(height: 3) + } else if trimmed.hasPrefix("### ") { + // Headings scale relative to the base prose size so the hierarchy holds + // at any base and never collapses to the body size. + inline(String(trimmed.dropFirst(4))).font(.system(size: bodySize * 1.13, weight: .semibold)) + } else if trimmed.hasPrefix("## ") { + inline(String(trimmed.dropFirst(3))).font(.system(size: bodySize * 1.28, weight: .bold)) + } else if trimmed.hasPrefix("# ") { + inline(String(trimmed.dropFirst(2))).font(.system(size: bodySize * 1.5, weight: .bold)) + } else if let bullet = Self.bulletContent(trimmed) { + HStack(alignment: .firstTextBaseline, spacing: 6) { + Text("•").foregroundStyle(.secondary) + inline(bullet) + } + } else { + inline(raw) + } + } + + /// Returns the content after a `- `, `* `, `+ ` or `N. ` list marker, else nil. + private static func bulletContent(_ trimmed: String) -> String? { + for marker in ["- ", "* ", "+ "] where trimmed.hasPrefix(marker) { + return String(trimmed.dropFirst(marker.count)) + } + // Numbered list: "12. text" + let parts = trimmed.split(separator: " ", maxSplits: 1) + if let first = parts.first, parts.count == 2, + first.hasSuffix("."), Int(first.dropLast()) != nil { + return String(parts[1]) + } + return nil + } + + private func inline(_ string: String) -> Text { + Text(Self.attributedInline(string)) + } + + /// Inline-Markdown cache. `AttributedString(markdown:)` is the dominant per-row cost + /// when a transcript first lays out — it runs once per prose line — and the same lines + /// recur across re-renders and reopens, so memoize the parsed result. Independent of + /// `bodySize` (callers apply the font), so the source string is a complete key. + private final class InlineBox { let value: AttributedString; init(_ v: AttributedString) { self.value = v } } + private static let inlineCache: NSCache = { + let cache = NSCache() + cache.countLimit = 16384 + return cache + }() + + private static func attributedInline(_ string: String) -> AttributedString { + let key = string as NSString + if let hit = inlineCache.object(forKey: key) { return hit.value } + let options = AttributedString.MarkdownParsingOptions( + interpretedSyntax: .inlineOnlyPreservingWhitespace) + // Fall back to the plain string on parse failure — `Text(AttributedString(string))` + // renders identically to `Text(string)`, so callers see no behavioral change. + let parsed = (try? AttributedString(markdown: string, options: options)) ?? AttributedString(string) + inlineCache.setObject(InlineBox(parsed), forKey: key) + return parsed + } +} diff --git a/NucleicRemote/NucleicRemote/Views/TranscriptRow.swift b/NucleicRemote/NucleicRemote/Views/TranscriptRow.swift index d2474f1..b73264b 100644 --- a/NucleicRemote/NucleicRemote/Views/TranscriptRow.swift +++ b/NucleicRemote/NucleicRemote/Views/TranscriptRow.swift @@ -60,8 +60,9 @@ struct TranscriptRow: View { private func modelLabel(_ model: String) -> String { model.isEmpty ? "agent" : model } } -/// Render an assistant/user message as Markdown (inline bold/italic/code/links), preserving the -/// line breaks of multi-paragraph replies. Falls back to plain text if parsing fails. +/// Render an assistant/user message with the full Markdown renderer the Mac transcript uses +/// (fenced code blocks, headings, lists, tables, and inline emphasis/links/`code`), preserving +/// the line breaks of multi-paragraph replies. Falls back to plain text if parsing fails. struct MessageBubble: View { let role: TranscriptItem.Role let text: String @@ -69,7 +70,7 @@ struct MessageBubble: View { var body: some View { HStack { if role == .user { Spacer(minLength: 40) } - Text(Self.markdown(text)) + MarkdownText(markdown: text) .textSelection(.enabled) .padding(10) .background(role == .user ? Palette.accent.opacity(0.15) : Color(.secondarySystemBackground), @@ -79,6 +80,8 @@ struct MessageBubble: View { } } + /// Inline-only Markdown for one-line contexts (host-exec purpose lines etc.), where the + /// block renderer above would be overkill. static func markdown(_ string: String) -> AttributedString { (try? AttributedString(markdown: string, options: .init( interpretedSyntax: .inlineOnlyPreservingWhitespace, From 480b29207fe9a4616830c5b5ab90b83fa04aa796 Mon Sep 17 00:00:00 2001 From: Andrew Blakeslee Moore Date: Thu, 2 Jul 2026 04:20:25 -0700 Subject: [PATCH 2/4] sync: project quota, provider status, todo triage, and full diffs to the phone MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four Mac surfaces the iOS remote couldn't render now ride the wire, forward-compatibly (decodeIfPresent defaults; unknown HostMsg tags already decode to .unknown): - DashboardSnapshot gains `usage` (WireSubscriptionUsage — the Mac's 5-hour/weekly quota gauges) and `statusFeeds` (WireStatusFeed — active provider incidents); the host re-pushes the dashboard when a poll changes either. - WireTodo gains `triage` (raw TriageLevel name); the phone renders the Mac's TriageBadge honoring the encouraging/classic label setting. - ClientMsg.fetchDiff / HostMsg.sessionDiff deliver the full worktree patch on demand (capped at 512 KB with a truncated flag), gated on the new WireCapabilities.canFetchDiff so a new phone never sends it to an old host. iOS renders a file list + colored unified patch, falling back to the diffstat summary against older hosts. Round-trip and legacy-decode tests cover the new fields; the full suite passes apart from the pre-existing fake-backend fixture gaps and the flaky nvrsion lock-domain test (same failure rate on the base commit). Co-Authored-By: Claude Fable 5 --- .../NucleicRemote/Models/RemoteStore.swift | 68 ++++++- .../NucleicRemote/Views/Composer.swift | 1 + .../NucleicRemote/Views/HomeView.swift | 10 +- .../Views/SessionDetailView.swift | 114 ++++++++++- .../NucleicRemote/Views/StatusAndQuota.swift | 191 ++++++++++++++++++ .../NucleicRemote/Views/TodosView.swift | 62 +++++- 6 files changed, 438 insertions(+), 8 deletions(-) create mode 100644 NucleicRemote/NucleicRemote/Views/StatusAndQuota.swift diff --git a/NucleicRemote/NucleicRemote/Models/RemoteStore.swift b/NucleicRemote/NucleicRemote/Models/RemoteStore.swift index cec5d59..0496521 100644 --- a/NucleicRemote/NucleicRemote/Models/RemoteStore.swift +++ b/NucleicRemote/NucleicRemote/Models/RemoteStore.swift @@ -49,6 +49,10 @@ final class RemoteStore: ObservableObject { @Published private(set) var openSessionID: SessionID? @Published private(set) var openEvents: [AgentEvent] = [] @Published private(set) var openApprovals: [ApprovalRequest] = [] + /// The open session's full diff (the Mac Diff tab's patch), fetched on demand when the + /// user opens the Diff tab and the host advertises `canFetchDiff`. Nil until it arrives. + @Published private(set) var openDiff: WireSessionDiff? + @Published private(set) var diffLoading = false /// A transient host-reported error (the mobile echo of the Mac's last-error bubble): /// shown as a red bubble at the bottom of the screen, auto-dismissed after a few seconds. @@ -123,7 +127,8 @@ final class RemoteStore: ObservableObject { connectivity = .connected hostName = "Andrew's Mac" grantedScope = .control - capabilities = WireCapabilities(canModifyToolInput: true, allowAlwaysScopes: [.session, .toolName]) + capabilities = WireCapabilities( + canModifyToolInput: true, allowAlwaysScopes: [.session, .toolName], canFetchDiff: true) modelCatalog = WireModelCatalog( groups: [ [WireModelCatalog.Model(sku: "claude-opus-4-8[1m]", displayName: "Opus 4.8", backend: .claudeCode, @@ -179,14 +184,52 @@ final class RemoteStore: ObservableObject { ], todos: [ WireTodo(id: TodoID(rawValue: "t1"), text: "Add dark mode to settings", summary: "Dark mode in settings", - projectID: p2, projectName: "website", status: .open, dispatchedSessionID: nil, updatedAt: Date()), + projectID: p2, projectName: "website", status: .open, dispatchedSessionID: nil, + triage: "high", updatedAt: Date()), WireTodo(id: TodoID(rawValue: "t2"), text: "Investigate the memory leak in the sync server", summary: "Sync server memory leak", - projectID: p1, projectName: "nucleic", status: .open, dispatchedSessionID: nil, updatedAt: Date()), + projectID: p1, projectName: "nucleic", status: .open, dispatchedSessionID: nil, + triage: "critical", updatedAt: Date()), WireTodo(id: TodoID(rawValue: "t3"), text: "Write release notes", summary: nil, - projectID: nil, projectName: nil, status: .open, dispatchedSessionID: nil, updatedAt: Date()), + projectID: nil, projectName: nil, status: .open, dispatchedSessionID: nil, + triage: "low", updatedAt: Date()), + ], + usage: WireSubscriptionUsage( + fiveHour: WireUsageWindow(utilization: 42, resetsAt: Date().addingTimeInterval(3 * 3600)), + sevenDay: WireUsageWindow(utilization: 78, resetsAt: Date().addingTimeInterval(2.4 * 86_400))), + statusFeeds: [ + WireStatusFeed(provider: "claude", providerName: "Claude", incidents: []), + WireStatusFeed(provider: "openai", providerName: "OpenAI", incidents: [ + WireStatusIncident( + id: "i1", title: "Elevated errors on Codex", url: URL(string: "https://status.openai.com"), + updatedAt: Date(), state: "Monitoring", isResolved: false, components: ["Codex"]), + ]), + WireStatusFeed(provider: "xai", providerName: "xAI", incidents: []), ]) } + /// Offline diff fixture (NUCLEIC_DEMO) so the full-patch Diff tab renders without a host. + private func demoDiff(_ sessionID: SessionID) -> WireSessionDiff { + WireSessionDiff( + sessionID: sessionID, + stat: DiffStat(filesChanged: 2, added: 312, removed: 40), + files: [ + WireFileDiff(path: "auth/middleware.ts", oldPath: nil, status: "modified", added: 290, removed: 38), + WireFileDiff(path: "auth/session.ts", oldPath: nil, status: "added", added: 22, removed: 2), + ], + patch: """ + diff --git a/auth/middleware.ts b/auth/middleware.ts + --- a/auth/middleware.ts + +++ b/auth/middleware.ts + @@ -10,7 +10,9 @@ export function requireSession(req: Request) { + - const token = req.headers.get("x-auth") + + const header = req.headers.get("authorization") ?? "" + + const token = header.replace(/^Bearer /, "") + + if (!token) throw new AuthError("missing bearer token") + return verify(token) + } + """) + } + /// Pair from a scanned QR (SYNC §4.2): connect (LAN hint first, else Bonjour), run XXpsk0, /// and on success pin the host key for future IK reconnects. func pair(with payload: PairingPayload) { @@ -240,12 +283,23 @@ final class RemoteStore: ObservableObject { openSessionID = sessionID openEvents = [] openApprovals = [] + openDiff = nil + diffLoading = false seenSeq.removeAll() markOpened(sessionID) if demoMode { seedDemoTranscript(sessionID); return } send(.subscribe(Subscribe(sessionID: sessionID, sinceSeq: nil, verbosity: .full))) } + /// Ask the host for the open session's full patch (Diff tab). No-op when the host + /// doesn't advertise the capability — the view falls back to the diffstat summary. + func fetchDiff(_ sessionID: SessionID) { + if demoMode { openDiff = demoDiff(sessionID); return } + guard capabilities.canFetchDiff else { return } + diffLoading = openDiff == nil + send(.fetchDiff(sessionID)) + } + /// Record that the user looked at this session now (clears its unseen-completion wash). func markOpened(_ sessionID: SessionID) { lastOpenedAt[sessionID] = Date() @@ -287,6 +341,8 @@ final class RemoteStore: ObservableObject { openSessionID = nil openEvents = [] openApprovals = [] + openDiff = nil + diffLoading = false } func respond(_ approval: ApprovalRequest, _ decision: Decision) { @@ -418,6 +474,10 @@ final class RemoteStore: ObservableObject { } case .approvalResolved(let resolved): openApprovals.removeAll { $0.id == resolved.id } + case .sessionDiff(let diff): + guard diff.sessionID == openSessionID else { break } + openDiff = diff + diffLoading = false case .wireError(let error): // Not fatal — surface as a transient bubble (the Mac's last-error overlay). // Losing an approval race isn't an error worth interrupting for; the card diff --git a/NucleicRemote/NucleicRemote/Views/Composer.swift b/NucleicRemote/NucleicRemote/Views/Composer.swift index b7897a7..8d8d5f0 100644 --- a/NucleicRemote/NucleicRemote/Views/Composer.swift +++ b/NucleicRemote/NucleicRemote/Views/Composer.swift @@ -138,6 +138,7 @@ struct QuickTodos: View { }.buttonStyle(.plain) Text(todo.summary?.isEmpty == false ? todo.summary! : todo.text) .font(.subheadline).lineLimit(1) + if let triage = todo.triage { TriageBadge(rawLevel: triage) } Spacer() if let name = todo.projectName { Text(name).font(.caption2).foregroundStyle(.secondary) diff --git a/NucleicRemote/NucleicRemote/Views/HomeView.swift b/NucleicRemote/NucleicRemote/Views/HomeView.swift index 773b729..acddf44 100644 --- a/NucleicRemote/NucleicRemote/Views/HomeView.swift +++ b/NucleicRemote/NucleicRemote/Views/HomeView.swift @@ -54,11 +54,19 @@ struct HomeView: View { Text(subtitle).font(.subheadline).foregroundStyle(.secondary) } Spacer() - if streak.streak > 0 { StreakBadge(days: streak.streak, freezes: streak.availableFreezes) } + VStack(alignment: .trailing, spacing: 8) { + if streak.streak > 0 { StreakBadge(days: streak.streak, freezes: streak.availableFreezes) } + // The Mac's service-status pill, fed by the dashboard projection. + StatusFeedPill(feeds: store.dashboard.statusFeeds) + } } statCards + // The Mac's usage gauges (5-hour / weekly windows); hidden when the host + // doesn't project usage. + QuotaCard(usage: store.dashboard.usage) + VStack(alignment: .leading, spacing: 10) { Text("Activity").font(.headline) ActivityGrid( diff --git a/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift b/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift index ea38e32..64473d3 100644 --- a/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift +++ b/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift @@ -6,7 +6,9 @@ import NucleicProtocol struct SessionDetailView: View { @EnvironmentObject var store: RemoteStore let sessionID: SessionID - @State private var tab = 0 + // Initial tab (0 = Transcript, 1 = Diff); overridable via NUCLEIC_DETAIL_TAB for + // offline UI previews, matching NUCLEIC_TAB / NUCLEIC_DEMO_SESSION. + @State private var tab = Int(ProcessInfo.processInfo.environment["NUCLEIC_DETAIL_TAB"] ?? "") ?? 0 @State private var draft = "" @State private var showRename = false @State private var renameDraft = "" @@ -33,7 +35,11 @@ struct SessionDetailView: View { if tab == 0 { TranscriptList(events: store.openEvents) + } else if store.capabilities.canFetchDiff || store.openDiff != nil { + SessionDiffView(diff: store.openDiff, loading: store.diffLoading) + .onAppear { store.fetchDiff(sessionID) } } else { + // Older host with no on-demand diff — the stat summary is all it projects. DiffSummaryView(diffStat: summary?.diffStat) } @@ -304,6 +310,112 @@ 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`. +struct SessionDiffView: View { + let diff: WireSessionDiff? + let loading: Bool + + 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) + } + } + .padding() + } + } else if loading { + ProgressView("Fetching diff…") + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else { + ContentUnavailableView("No changes yet", systemImage: "doc") + } + } + + 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) + } + } + } + .card() + } + + private func statusIcon(_ status: String) -> String { + switch status { + case "added", "untracked": "plus.circle" + case "deleted": "minus.circle" + case "renamed": "arrow.right.circle" + default: "pencil.circle" + } + } + + private func statusColor(_ status: String) -> Color { + switch status { + case "added", "untracked": Palette.success + case "deleted": Palette.danger + default: .secondary + } + } +} + +/// 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 { + let patch: String + + var body: some View { + ScrollView(.horizontal, showsIndicators: false) { + VStack(alignment: .leading, spacing: 0) { + ForEach(Array(patch.components(separatedBy: "\n").enumerated()), id: \.offset) { _, line in + Text(line.isEmpty ? " " : line) + .font(.caption.monospaced()) + .foregroundStyle(lineColor(line)) + .frame(maxWidth: .infinity, alignment: .leading) + .background(lineBackground(line)) + } + } + .textSelection(.enabled) + .padding(8) + } + .background(Color(.secondarySystemBackground), in: RoundedRectangle(cornerRadius: 10)) + } + + private func lineColor(_ line: String) -> Color { + if line.hasPrefix("+++") || line.hasPrefix("---") { return .secondary } + if line.hasPrefix("+") { return Palette.success } + if line.hasPrefix("-") { return Palette.danger } + if line.hasPrefix("@@") { return Palette.active } + if line.hasPrefix("diff ") { return .secondary } + return Palette.primaryText + } + + private func lineBackground(_ line: String) -> Color { + if line.hasPrefix("+++") || line.hasPrefix("---") { return .clear } + if line.hasPrefix("+") { return Palette.success.opacity(0.10) } + if line.hasPrefix("-") { return Palette.danger.opacity(0.10) } + return .clear + } +} + struct DiffSummaryView: View { let diffStat: DiffStat? diff --git a/NucleicRemote/NucleicRemote/Views/StatusAndQuota.swift b/NucleicRemote/NucleicRemote/Views/StatusAndQuota.swift new file mode 100644 index 0000000..a73f198 --- /dev/null +++ b/NucleicRemote/NucleicRemote/Views/StatusAndQuota.swift @@ -0,0 +1,191 @@ +import SwiftUI +import NucleicProtocol + +/// The Mac's usage gauges and provider-status pill, rendered from the dashboard's wire +/// projections (`DashboardSnapshot.usage` / `.statusFeeds`) — same thresholds, icons, and +/// copy as `QuotaIndicator` / `StatusFeedIndicator` on desktop. + +// MARK: - Shared formatting (mirrors the Mac's QuotaFormat) + +enum QuotaFormat { + static let fiveHourIcon = "clock" + static let weeklyIcon = "calendar" + + static func percent(_ value: Double) -> String { "\(Int(value.rounded()))%" } + + /// "6d 23h" / "2h 14m" / "14m" — the Mac's countdown formatting. + static func duration(_ interval: TimeInterval) -> String { + let total = Int(interval) + let days = total / 86_400 + let hours = (total % 86_400) / 3600 + let minutes = (total % 3600) / 60 + if days > 0 { return "\(days)d \(hours)h" } + if hours > 0 { return "\(hours)h \(minutes)m" } + if minutes > 0 { return "\(minutes)m" } + return "<1m" + } + + static func resetCaption(_ window: WireUsageWindow, now: Date) -> String? { + guard let resetsAt = window.resetsAt else { return nil } + let remaining = resetsAt.timeIntervalSince(now) + guard remaining > 0 else { return "resetting…" } + return "resets in \(duration(remaining))" + } + + /// Calm until 75%, amber to 90%, red beyond — the Mac's escalation. + static func color(forPercent value: Double) -> Color { + switch value { + case ..<75: return Palette.accent + case ..<90: return Palette.attention + default: return Palette.danger + } + } +} + +// MARK: - Quota card (the Mac's QuotaCard, dashboard form) + +/// Labeled progress bars for the 5-hour and weekly windows with reset countdowns. +/// Renders nothing when the host hasn't projected usage (older host, logged out) so the +/// home screen doesn't carry a permanently empty card. +struct QuotaCard: View { + let usage: WireSubscriptionUsage? + + var body: some View { + if let usage, usage.peakUtilization != nil { + TimelineView(.periodic(from: .now, by: 30)) { context in + VStack(alignment: .leading, spacing: 12) { + HStack(spacing: 6) { + Image(systemName: "gauge.with.dots.needle.67percent").foregroundStyle(.secondary) + Text("Usage").font(.headline) + } + windowRow("5-hour limit", icon: QuotaFormat.fiveHourIcon, usage.fiveHour, now: context.date) + windowRow("Weekly limit", icon: QuotaFormat.weeklyIcon, usage.sevenDay, now: context.date) + } + .frame(maxWidth: .infinity, alignment: .leading) + } + .card() + } + } + + @ViewBuilder + private func windowRow(_ label: String, icon: String, _ window: WireUsageWindow?, now: Date) -> some View { + if let window { + let utilization = window.utilization(at: now) + let color = QuotaFormat.color(forPercent: utilization) + VStack(alignment: .leading, spacing: 4) { + HStack(spacing: 5) { + Image(systemName: icon).font(.caption).foregroundStyle(.secondary) + Text(label).font(.subheadline) + Spacer() + Text(QuotaFormat.percent(utilization)) + .font(.subheadline.monospacedDigit().weight(.semibold)) + .foregroundStyle(color) + } + bar(fraction: utilization / 100, color: color) + if let caption = QuotaFormat.resetCaption(window, now: now) { + Text(caption).font(.caption2).foregroundStyle(.secondary) + } + } + } + } + + private func bar(fraction: Double, color: Color) -> some View { + GeometryReader { geo in + ZStack(alignment: .leading) { + Capsule().fill(Color.secondary.opacity(0.18)) + Capsule().fill(color) + .frame(width: max(3, geo.size.width * min(1, max(0, fraction)))) + } + } + .frame(height: 6) + } +} + +// MARK: - Status pill (the Mac's StatusFeedIndicator) + +/// A compact service-status pill: a calm green check while every watched provider is +/// operational, amber naming the affected providers when any has an active incident. +/// Tapping opens a sheet listing current incidents with links to the status pages. +/// Renders nothing before the host projects any feed (older host / first poll pending). +struct StatusFeedPill: View { + let feeds: [WireStatusFeed] + @State private var showingSheet = false + + private var active: [WireStatusIncident] { feeds.flatMap(\.activeIncidents) } + private var affected: [String] { feeds.filter(\.hasActiveIncident).map(\.providerName) } + + var body: some View { + if !feeds.isEmpty { + Button { showingSheet = true } label: { + if active.isEmpty { + Label("Operational", systemImage: "checkmark.circle.fill") + .foregroundStyle(Palette.success) + } else { + Label(affected.joined(separator: ", "), systemImage: "exclamationmark.triangle.fill") + .foregroundStyle(Palette.attention) + } + } + .buttonStyle(.plain) + .font(.caption) + .sheet(isPresented: $showingSheet) { StatusFeedSheet(feeds: feeds) } + } + } +} + +/// The pill's detail: current incidents grouped by provider (the Mac popover, sheet-form). +private struct StatusFeedSheet: View { + let feeds: [WireStatusFeed] + @Environment(\.dismiss) private var dismiss + + var body: some View { + NavigationStack { + List { + ForEach(feeds) { feed in + Section(feed.providerName) { + if feed.activeIncidents.isEmpty { + Label("Operational", systemImage: "checkmark.circle.fill") + .foregroundStyle(Palette.success) + .font(.subheadline) + } else { + ForEach(feed.activeIncidents) { incident in + incidentRow(incident) + } + } + } + } + } + .navigationTitle("Service status") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Button("Done") { dismiss() } + } + } + } + .presentationDetents([.medium, .large]) + } + + @ViewBuilder + private func incidentRow(_ incident: WireStatusIncident) -> some View { + VStack(alignment: .leading, spacing: 3) { + Text(incident.title).font(.subheadline.weight(.medium)) + HStack(spacing: 6) { + if let state = incident.state { + Text(state).font(.caption).foregroundStyle(Palette.attention) + } + if !incident.components.isEmpty { + Text(incident.components.joined(separator: " · ")) + .font(.caption).foregroundStyle(.secondary) + } + if let updated = incident.updatedAt { + Text(updated.formatted(date: .omitted, time: .shortened)) + .font(.caption).foregroundStyle(.tertiary) + } + } + if let url = incident.url { + Link("Status page", destination: url).font(.caption) + } + } + .padding(.vertical, 2) + } +} diff --git a/NucleicRemote/NucleicRemote/Views/TodosView.swift b/NucleicRemote/NucleicRemote/Views/TodosView.swift index 2635025..7ebfa94 100644 --- a/NucleicRemote/NucleicRemote/Views/TodosView.swift +++ b/NucleicRemote/NucleicRemote/Views/TodosView.swift @@ -65,8 +65,13 @@ private struct TodoRow: View { VStack(alignment: .leading, spacing: 2) { Text(todo.summary?.isEmpty == false ? todo.summary! : todo.text) .font(.subheadline).lineLimit(2) - if todo.status == .dispatched { - Text("Dispatched").font(.caption2).foregroundStyle(Palette.active) + HStack(spacing: 6) { + if let triage = todo.triage { + TriageBadge(rawLevel: triage) + } + if todo.status == .dispatched { + Text("Dispatched").font(.caption2).foregroundStyle(Palette.active) + } } } Spacer() @@ -99,3 +104,56 @@ private struct TodoRow: View { } } } + +/// A small colored badge showing an idea's triage level — the Mac's `TriageBadge`, fed by +/// the wire's raw level name. Honors the encouraging/classic labeling setting (same storage +/// key as the Mac); an unrecognized level from a newer host renders no badge. +struct TriageBadge: View { + let rawLevel: String + @AppStorage("nucleic.triageEncouragingLabels") private var encouraging = true + + private var label: String? { + switch (rawLevel, encouraging) { + case ("critical", true): "Max Impact" + case ("high", true): "High Impact" + case ("medium", true): "Worthwhile" + case ("low", true): "Someday" + case ("critical", false): "Critical" + case ("high", false): "High" + case ("medium", false): "Medium" + case ("low", false): "Low" + default: nil + } + } + + /// The Mac's badge hues: a red→orange→yellow→gray urgency scale for classic labels, a + /// magenta→blue→green→slate reward scale for the encouraging framing. + private var tint: Color? { + switch (rawLevel, encouraging) { + case ("critical", false): .red + case ("high", false): .orange + case ("medium", false): .yellow + case ("low", false): .gray + case ("critical", true): Color(red: 0.95, green: 0.33, blue: 0.78) // vivid magenta + case ("high", true): Color(red: 0.26, green: 0.54, blue: 1.00) // bright blue + case ("medium", true): Color(red: 0.18, green: 0.78, blue: 0.45) // green + case ("low", true): Color(red: 0.56, green: 0.60, blue: 0.67) // calm slate + default: nil + } + } + + var body: some View { + if let label, let tint { + HStack(spacing: 4) { + Circle().fill(tint).frame(width: 7, height: 7) + Text(label) + .font(.caption2.weight(.semibold)) + .foregroundStyle(.secondary) + } + .padding(.horizontal, 7).padding(.vertical, 3) + .background(tint.opacity(0.14), in: .capsule) + .overlay(Capsule().strokeBorder(tint.opacity(0.35), lineWidth: 0.5)) + .fixedSize() + } + } +} From 761dca5f1d5175b93217d9c74ebcfedd730b4e01 Mon Sep 17 00:00:00 2001 From: Andrew Blakeslee Moore Date: Thu, 2 Jul 2026 04:42:59 -0700 Subject: [PATCH 3/4] ios: notifications, actionable approvals, Live Activity, and the relay push path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Brings the phone's ambient surfaces (UX_IOS §5/§8) to maturity: - Notification pipeline (NotificationRouter): local notifications for approvals and needs-input transitions while backgrounded; low-risk approvals are actionable from the banner (Allow requires device auth, high-risk must open the app's Face ID gate); taps deep-link to the session; app-icon badge = NEEDS YOU count; resolutions withdraw the notification (first-responder-wins). The relay's content-free approval.pending tickle localizes via Localizable.strings. - Live Activity: new NucleicRemoteWidgets extension target (lock screen + Dynamic Island) rendering one aggregate Activity — N running / M waiting + the most urgent session — started/updated/ ended by LiveActivityManager as session state changes. - Out-of-band push path: nucleic-edge gains POST /v1/push/register (admin) so the host can upload tokens for LAN-only pairings; the host's new PushRelayClient (config-gated on NUCLEIC_RELAY_URL + NUCLEIC_RELAY_ADMIN_SECRET) mirrors Hello.pushToken to the relay and wakes non-connected phones when an approval arrives, throttled per device. Everything stays off until the relay is provisioned. Worker tests (23) and Swift suites pass apart from the pre-existing fixture gaps and nvrsion flake. Simulated APNs delivery is blocked in this environment (notification auth can't be granted headlessly). Co-Authored-By: Claude Fable 5 --- .../NucleicRemote.xcodeproj/project.pbxproj | 175 ++++++++++++++++++ NucleicRemote/NucleicRemote/Info.plist | 2 + .../NucleicRemote/LiveActivityManager.swift | 62 +++++++ .../NucleicRemote/Localizable.strings | 5 + .../NucleicRemote/Models/RemoteStore.swift | 67 ++++++- .../NucleicRemote/Notifications.swift | 145 +++++++++++++++ .../NucleicRemote/NucleicRemoteApp.swift | 12 +- .../NucleicRemote/Views/SessionsView.swift | 10 + NucleicRemote/NucleicRemoteWidgets/Info.plist | 11 ++ .../NucleicRemoteWidgets.swift | 9 + .../SessionLiveActivity.swift | 126 +++++++++++++ .../Shared/SessionActivityAttributes.swift | 22 +++ 12 files changed, 643 insertions(+), 3 deletions(-) create mode 100644 NucleicRemote/NucleicRemote/LiveActivityManager.swift create mode 100644 NucleicRemote/NucleicRemote/Localizable.strings create mode 100644 NucleicRemote/NucleicRemote/Notifications.swift create mode 100644 NucleicRemote/NucleicRemoteWidgets/Info.plist create mode 100644 NucleicRemote/NucleicRemoteWidgets/NucleicRemoteWidgets.swift create mode 100644 NucleicRemote/NucleicRemoteWidgets/SessionLiveActivity.swift create mode 100644 NucleicRemote/Shared/SessionActivityAttributes.swift diff --git a/NucleicRemote/NucleicRemote.xcodeproj/project.pbxproj b/NucleicRemote/NucleicRemote.xcodeproj/project.pbxproj index 8af6cf0..290f12e 100644 --- a/NucleicRemote/NucleicRemote.xcodeproj/project.pbxproj +++ b/NucleicRemote/NucleicRemote.xcodeproj/project.pbxproj @@ -8,12 +8,38 @@ /* Begin PBXBuildFile section */ BF00000000000000000001 /* NucleicProtocol in Frameworks */ = {isa = PBXBuildFile; productRef = PD00000000000000000001 /* NucleicProtocol */; }; + BF00000000000000000002 /* NucleicRemoteWidgets.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = FR00000000000000000002 /* NucleicRemoteWidgets.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; E7D43FF12FF0806800BF2407 /* app-logo-file.icon in Resources */ = {isa = PBXBuildFile; fileRef = E7D43FF02FF0806800BF2407 /* app-logo-file.icon */; }; /* End PBXBuildFile section */ +/* Begin PBXContainerItemProxy section */ + CP00000000000000000001 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = PJ00000000000000000001 /* Project object */; + proxyType = 1; + remoteGlobalIDString = TG00000000000000000002; + remoteInfo = NucleicRemoteWidgets; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXCopyFilesBuildPhase section */ + BP00000000000000000005 /* Embed Foundation Extensions */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 13; + files = ( + BF00000000000000000002 /* NucleicRemoteWidgets.appex in Embed Foundation Extensions */, + ); + name = "Embed Foundation Extensions"; + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXCopyFilesBuildPhase section */ + /* Begin PBXFileReference section */ E7D43FF02FF0806800BF2407 /* app-logo-file.icon */ = {isa = PBXFileReference; lastKnownFileType = folder.iconcomposer.icon; path = "app-logo-file.icon"; sourceTree = ""; }; FR00000000000000000001 /* NucleicRemote.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = NucleicRemote.app; sourceTree = BUILT_PRODUCTS_DIR; }; + FR00000000000000000002 /* NucleicRemoteWidgets.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = NucleicRemoteWidgets.appex; sourceTree = BUILT_PRODUCTS_DIR; }; /* End PBXFileReference section */ /* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ @@ -24,6 +50,13 @@ ); target = TG00000000000000000001 /* NucleicRemote */; }; + EX00000000000000000002 /* Exceptions for "NucleicRemoteWidgets" folder in "NucleicRemoteWidgets" target */ = { + isa = PBXFileSystemSynchronizedBuildFileExceptionSet; + membershipExceptions = ( + Info.plist, + ); + target = TG00000000000000000002 /* NucleicRemoteWidgets */; + }; /* End PBXFileSystemSynchronizedBuildFileExceptionSet section */ /* Begin PBXFileSystemSynchronizedRootGroup section */ @@ -35,6 +68,19 @@ path = NucleicRemote; sourceTree = ""; }; + SG00000000000000000002 /* NucleicRemoteWidgets */ = { + isa = PBXFileSystemSynchronizedRootGroup; + exceptions = ( + EX00000000000000000002 /* Exceptions for "NucleicRemoteWidgets" folder in "NucleicRemoteWidgets" target */, + ); + path = NucleicRemoteWidgets; + sourceTree = ""; + }; + SG00000000000000000003 /* Shared */ = { + isa = PBXFileSystemSynchronizedRootGroup; + path = Shared; + sourceTree = ""; + }; /* End PBXFileSystemSynchronizedRootGroup section */ /* Begin PBXFrameworksBuildPhase section */ @@ -46,6 +92,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + BP00000000000000000007 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ @@ -53,6 +106,8 @@ isa = PBXGroup; children = ( SG00000000000000000001 /* NucleicRemote */, + SG00000000000000000002 /* NucleicRemoteWidgets */, + SG00000000000000000003 /* Shared */, GR00000000000000000002 /* Products */, E7D43FF02FF0806800BF2407 /* app-logo-file.icon */, ); @@ -62,6 +117,7 @@ isa = PBXGroup; children = ( FR00000000000000000001 /* NucleicRemote.app */, + FR00000000000000000002 /* NucleicRemoteWidgets.appex */, ); name = Products; sourceTree = ""; @@ -76,13 +132,16 @@ BP00000000000000000002 /* Sources */, BP00000000000000000003 /* Frameworks */, BP00000000000000000004 /* Resources */, + BP00000000000000000005 /* Embed Foundation Extensions */, ); buildRules = ( ); dependencies = ( + TD00000000000000000001 /* PBXTargetDependency */, ); fileSystemSynchronizedGroups = ( SG00000000000000000001 /* NucleicRemote */, + SG00000000000000000003 /* Shared */, ); name = NucleicRemote; packageProductDependencies = ( @@ -92,8 +151,37 @@ productReference = FR00000000000000000001 /* NucleicRemote.app */; productType = "com.apple.product-type.application"; }; + TG00000000000000000002 /* NucleicRemoteWidgets */ = { + isa = PBXNativeTarget; + buildConfigurationList = CL00000000000000000003 /* Build configuration list for PBXNativeTarget "NucleicRemoteWidgets" */; + buildPhases = ( + BP00000000000000000006 /* Sources */, + BP00000000000000000007 /* Frameworks */, + BP00000000000000000008 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + fileSystemSynchronizedGroups = ( + SG00000000000000000002 /* NucleicRemoteWidgets */, + SG00000000000000000003 /* Shared */, + ); + name = NucleicRemoteWidgets; + productName = NucleicRemoteWidgets; + productReference = FR00000000000000000002 /* NucleicRemoteWidgets.appex */; + productType = "com.apple.product-type.app-extension"; + }; /* End PBXNativeTarget section */ +/* Begin PBXTargetDependency section */ + TD00000000000000000001 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = TG00000000000000000002 /* NucleicRemoteWidgets */; + targetProxy = CP00000000000000000001 /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + /* Begin PBXProject section */ PJ00000000000000000001 /* Project object */ = { isa = PBXProject; @@ -105,6 +193,9 @@ TG00000000000000000001 = { CreatedOnToolsVersion = 27.0; }; + TG00000000000000000002 = { + CreatedOnToolsVersion = 27.0; + }; }; }; buildConfigurationList = CL00000000000000000001 /* Build configuration list for PBXProject "NucleicRemote" */; @@ -125,6 +216,7 @@ projectRoot = ""; targets = ( TG00000000000000000001 /* NucleicRemote */, + TG00000000000000000002 /* NucleicRemoteWidgets */, ); }; /* End PBXProject section */ @@ -138,6 +230,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + BP00000000000000000008 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXResourcesBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ @@ -148,6 +247,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + BP00000000000000000006 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXSourcesBuildPhase section */ /* Begin XCBuildConfiguration section */ @@ -256,6 +362,66 @@ }; name = Release; }; + BC00000000000000000005 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_IDENTITY = "Apple Development"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = L7UDTQ6F5W; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = NucleicRemoteWidgets/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = NucleicRemoteWidgets; + INFOPLIST_KEY_NSHumanReadableCopyright = ""; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + MARKETING_VERSION = 0.1.0; + PRODUCT_BUNDLE_IDENTIFIER = "xyz.blakeslee.nucleic-remote.widgets"; + PRODUCT_NAME = "$(TARGET_NAME)"; + SKIP_INSTALL = YES; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SUPPORTS_MACCATALYST = NO; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + BC00000000000000000006 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_IDENTITY = "Apple Development"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = L7UDTQ6F5W; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = NucleicRemoteWidgets/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = NucleicRemoteWidgets; + INFOPLIST_KEY_NSHumanReadableCopyright = ""; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + MARKETING_VERSION = 0.1.0; + PRODUCT_BUNDLE_IDENTIFIER = "xyz.blakeslee.nucleic-remote.widgets"; + PRODUCT_NAME = "$(TARGET_NAME)"; + SKIP_INSTALL = YES; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SUPPORTS_MACCATALYST = NO; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Release; + }; /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ @@ -277,6 +443,15 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; + CL00000000000000000003 /* Build configuration list for PBXNativeTarget "NucleicRemoteWidgets" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + BC00000000000000000005 /* Debug */, + BC00000000000000000006 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; /* End XCConfigurationList section */ /* Begin XCLocalSwiftPackageReference section */ diff --git a/NucleicRemote/NucleicRemote/Info.plist b/NucleicRemote/NucleicRemote/Info.plist index 119a3e9..2fa3083 100644 --- a/NucleicRemote/NucleicRemote/Info.plist +++ b/NucleicRemote/NucleicRemote/Info.plist @@ -8,5 +8,7 @@ NucleicChannel $(NUCLEIC_CHANNEL) + NSSupportsLiveActivities + diff --git a/NucleicRemote/NucleicRemote/LiveActivityManager.swift b/NucleicRemote/NucleicRemote/LiveActivityManager.swift new file mode 100644 index 0000000..55d18b7 --- /dev/null +++ b/NucleicRemote/NucleicRemote/LiveActivityManager.swift @@ -0,0 +1,62 @@ +import ActivityKit +import Foundation +import NucleicProtocol + +/// Owns the one aggregate session Live Activity (UX_IOS §5.3): started when work exists, +/// updated as sessions change, ended when everything is idle or the device unpairs. State +/// flows in from `RemoteStore` on every session-list change; the widget extension renders it +/// (`SessionLiveActivity`). +@MainActor +final class LiveActivityManager { + static let shared = LiveActivityManager() + private init() {} + + private var activity: Activity? + + /// Reconcile the Activity with the current session set. + func sync(hostName: String, sessions: [WireSessionSummary]) { + guard ActivityAuthorizationInfo().areActivitiesEnabled else { return } + let live = sessions.filter { !$0.archived } + let running = live.filter { $0.status == .running || $0.status == .provisioning } + let needsYou = live.filter { $0.status.needsYou($0.disposition) } + + guard !running.isEmpty || !needsYou.isEmpty else { + end() + return + } + + // The most urgent session headlines: the top waiter, else the freshest runner. + let top = needsYou.max { $0.updatedAt < $1.updatedAt } + ?? running.max { $0.updatedAt < $1.updatedAt } + let state = NucleicSessionAttributes.ContentState( + runningCount: running.count, + needsYouCount: needsYou.count, + topTitle: top?.title, + topNeedsYou: top.map { $0.status.needsYou($0.disposition) } ?? false) + + if let activity { + Task { await activity.update(ActivityContent(state: state, staleDate: nil)) } + } else { + // Recover an Activity that survived an app relaunch before starting a new one. + if let existing = Activity.activities.first { + activity = existing + Task { await existing.update(ActivityContent(state: state, staleDate: nil)) } + return + } + activity = try? Activity.request( + attributes: NucleicSessionAttributes(hostName: hostName), + content: ActivityContent(state: state, staleDate: nil)) + } + } + + /// End the Activity (all idle, or unpaired). + func end() { + guard let activity else { return } + self.activity = nil + Task { + await activity.end( + ActivityContent(state: activity.content.state, staleDate: nil), + dismissalPolicy: .immediate) + } + } +} diff --git a/NucleicRemote/NucleicRemote/Localizable.strings b/NucleicRemote/NucleicRemote/Localizable.strings new file mode 100644 index 0000000..a690f5a --- /dev/null +++ b/NucleicRemote/NucleicRemote/Localizable.strings @@ -0,0 +1,5 @@ +/* The relay's content-free APNS wake (cloud/nucleic-edge apns.ts `approvalPayload`): + the push deliberately carries no approval detail (the relay can't read it — E2EE), so + this localized alert is all the lock screen shows. The app pulls the real approval + over the encrypted channel on open. */ +"approval.pending" = "A session is waiting for your approval"; diff --git a/NucleicRemote/NucleicRemote/Models/RemoteStore.swift b/NucleicRemote/NucleicRemote/Models/RemoteStore.swift index 0496521..094ed7e 100644 --- a/NucleicRemote/NucleicRemote/Models/RemoteStore.swift +++ b/NucleicRemote/NucleicRemote/Models/RemoteStore.swift @@ -69,6 +69,43 @@ final class RemoteStore: ObservableObject { /// doesn't carry the host's flag, so the phone tracks its own view locally). @Published private(set) var lastOpenedAt: [SessionID: Date] = RemoteStore.loadLastOpened() + /// A navigation request from outside the view hierarchy (notification tap → this session). + /// `RootView` switches to the Sessions tab; `SessionsView` pushes it and clears. + @Published var pendingRoute: SessionID? + + /// Whether the app is foreground-active (scene phase), mirrored here so the store can + /// decide which transitions deserve a notification. + private(set) var isActive = true + + func setScenePhaseActive(_ active: Bool) { isActive = active } + + /// Route to a session from a notification tap (deep link). + func route(to sessionID: SessionID) { pendingRoute = sessionID } + + /// An Allow/Deny straight from a notification action (UX_IOS §5.1). Requires a live + /// channel; if the socket dropped while backgrounded, reconnect and send once ready — + /// but only briefly (a stale queued approval must never fire minutes later; see + /// UX_IOS §11.5, and the host dedupes/`alreadyResolved`s a lost race anyway). + func respondFromNotification(_ id: ApprovalID, allow: Bool) { + let decision: Decision = allow ? .allow(updatedInput: nil) : .deny(reason: nil) + if connectivity.isLive { + send(.approvalRespond(id, decision)) + } else { + pendingNotificationDecision = (id, decision, Date()) + reconnect() + } + } + + /// At most one decision waits for reconnect, and it expires after 30s. + private var pendingNotificationDecision: (ApprovalID, Decision, Date)? + + private func flushPendingNotificationDecision() { + guard let (id, decision, at) = pendingNotificationDecision else { return } + pendingNotificationDecision = nil + guard Date().timeIntervalSince(at) < 30 else { return } // stale — require the app + send(.approvalRespond(id, decision)) + } + private static let lastOpenedKey = "nucleic.lastOpenedAt" private static func loadLastOpened() -> [SessionID: Date] { guard let raw = UserDefaults.standard.dictionary(forKey: lastOpenedKey) else { return [:] } @@ -205,6 +242,7 @@ final class RemoteStore: ObservableObject { ]), WireStatusFeed(provider: "xai", providerName: "xAI", incidents: []), ]) + LiveActivityManager.shared.sync(hostName: hostName, sessions: sessions) } /// Offline diff fixture (NUCLEIC_DEMO) so the full-patch Diff tab renders without a host. @@ -275,6 +313,8 @@ final class RemoteStore: ObservableObject { IdentityStore.clearPairedHost() connectivity = .unpaired sessions = [] + LiveActivityManager.shared.end() + NotificationRouter.shared.updateBadge(0) } // MARK: - Intents (UX_IOS §9) @@ -450,11 +490,29 @@ final class RemoteStore: ObservableObject { send(.listSessions) send(.listDashboard) if let id = openSessionID { send(.subscribe(Subscribe(sessionID: id, sinceSeq: nil, verbosity: .full))) } + flushPendingNotificationDecision() case .sessionList(let list): sessions = list + NotificationRouter.shared.updateBadge(needsYouCount) + LiveActivityManager.shared.sync(hostName: hostName, sessions: sessions) case .sessionUpdated(let summary): - if let i = sessions.firstIndex(where: { $0.sessionID == summary.sessionID }) { sessions[i] = summary } - else { sessions.append(summary) } + let previous: WireSessionSummary? + if let i = sessions.firstIndex(where: { $0.sessionID == summary.sessionID }) { + previous = sessions[i] + sessions[i] = summary + } else { + previous = nil + sessions.append(summary) + } + // Notify on the transition into "waiting on you" / "finished" — only when the + // app isn't foreground-active (in-app, the list's washes and badges carry it). + let becameWaiting = summary.status == .awaitingInput + && previous?.status != .awaitingInput + if becameWaiting, !isActive, !summary.archived { + NotificationRouter.shared.postSessionUpdate(summary) + } + NotificationRouter.shared.updateBadge(needsYouCount) + LiveActivityManager.shared.sync(hostName: hostName, sessions: sessions) case .dashboard(let snapshot): dashboard = snapshot case .snapshot(let snapshot): @@ -472,8 +530,13 @@ final class RemoteStore: ObservableObject { if req.sessionID == openSessionID, !openApprovals.contains(where: { $0.id == req.id }) { openApprovals.append(req) } + // Always post; the router suppresses the banner when the user is already + // looking at this session, and resolution (any device) withdraws it. + let title = sessions.first { $0.sessionID == req.sessionID }?.title ?? "Approval" + NotificationRouter.shared.postApproval(req, sessionTitle: title) case .approvalResolved(let resolved): openApprovals.removeAll { $0.id == resolved.id } + NotificationRouter.shared.withdrawApproval(resolved.id) case .sessionDiff(let diff): guard diff.sessionID == openSessionID else { break } openDiff = diff diff --git a/NucleicRemote/NucleicRemote/Notifications.swift b/NucleicRemote/NucleicRemote/Notifications.swift new file mode 100644 index 0000000..f79d70f --- /dev/null +++ b/NucleicRemote/NucleicRemote/Notifications.swift @@ -0,0 +1,145 @@ +import Foundation +import UserNotifications +import UIKit +import NucleicProtocol + +/// The phone's notification pipeline (UX_IOS §5.1): local notifications for approvals and +/// needs-input transitions while the app is backgrounded, actionable Allow/Deny for low-risk +/// approvals, deep-link routing on tap, and the app-icon badge (= NEEDS YOU count). +/// +/// Two arrival paths converge here: +/// - **Local** (LAN): the app is backgrounded but its socket is briefly alive; `RemoteStore` +/// posts a rich local notification (content composed on-device — nothing rides APNs). +/// - **Remote** (relay, M5): a content-free `approval.pending` tickle wakes the phone; the +/// alert text comes from Localizable.strings and the app pulls the real approval over the +/// encrypted channel on open. +@MainActor +final class NotificationRouter: NSObject { + static let shared = NotificationRouter() + + /// The store notifications act on; set once at app start. + weak var store: RemoteStore? + + // Category / action identifiers (also referenced from the notification service side). + static let approvalCategory = "NUCLEIC_APPROVAL" + static let approvalActionableCategory = "NUCLEIC_APPROVAL_ACTIONABLE" + static let inputCategory = "NUCLEIC_INPUT" + static let allowAction = "NUCLEIC_ALLOW" + static let denyAction = "NUCLEIC_DENY" + + /// Install the delegate + categories. Call once at launch (before any notification can + /// arrive, so actions are always registered). + func install(store: RemoteStore) { + self.store = store + let center = UNUserNotificationCenter.current() + center.delegate = self + + // Low/medium-risk approvals resolve straight from the banner (UX_IOS §5.1); + // Allow requires device auth so a pocket-tap can't grant. High-risk approvals use + // the action-less category — they must open the app (Face ID gate on the card). + let allow = UNNotificationAction( + identifier: Self.allowAction, title: "Allow", + options: [.authenticationRequired]) + let deny = UNNotificationAction( + identifier: Self.denyAction, title: "Deny", + options: [.destructive]) + let actionable = UNNotificationCategory( + identifier: Self.approvalActionableCategory, + actions: [deny, allow], intentIdentifiers: []) + let plain = UNNotificationCategory( + identifier: Self.approvalCategory, actions: [], intentIdentifiers: []) + let input = UNNotificationCategory( + identifier: Self.inputCategory, actions: [], intentIdentifiers: []) + center.setNotificationCategories([actionable, plain, input]) + } + + // MARK: - Posting (local path, composed on-device) + + /// Post a local notification for a pending approval. Rich because it never leaves the + /// device; the remote tickle stays content-free. + func postApproval(_ approval: ApprovalRequest, sessionTitle: String) { + let content = UNMutableNotificationContent() + content.title = sessionTitle + content.body = "\(approval.toolName) wants: \(approval.title)" + content.sound = .default + content.categoryIdentifier = approval.risk.isHigh + ? Self.approvalCategory : Self.approvalActionableCategory + content.userInfo = [ + "sessionID": approval.sessionID.rawValue, + "approvalID": approval.id.rawValue, + ] + // One notification per approval; a re-post for the same id replaces, and resolution + // (any device) withdraws it. + let request = UNNotificationRequest( + identifier: "approval-\(approval.id.rawValue)", content: content, trigger: nil) + UNUserNotificationCenter.current().add(request) + } + + /// Post a "session needs you / finished" transition notification. + func postSessionUpdate(_ summary: WireSessionSummary) { + let content = UNMutableNotificationContent() + content.title = summary.title + content.body = summary.status == .awaitingInput && summary.disposition == .completed + ? "Finished its work." : "Waiting for your next prompt." + content.sound = .default + content.categoryIdentifier = Self.inputCategory + content.userInfo = ["sessionID": summary.sessionID.rawValue] + let request = UNNotificationRequest( + identifier: "input-\(summary.sessionID.rawValue)", content: content, trigger: nil) + UNUserNotificationCenter.current().add(request) + } + + /// Withdraw an approval's notification once it's resolved (first-responder-wins — the + /// Mac may have answered). + func withdrawApproval(_ id: ApprovalID) { + let identifier = "approval-\(id.rawValue)" + let center = UNUserNotificationCenter.current() + center.removeDeliveredNotifications(withIdentifiers: [identifier]) + center.removePendingNotificationRequests(withIdentifiers: [identifier]) + } + + /// Keep the app-icon badge equal to the NEEDS YOU count (UX_IOS §8). + func updateBadge(_ count: Int) { + UNUserNotificationCenter.current().setBadgeCount(count) + } +} + +extension NotificationRouter: UNUserNotificationCenterDelegate { + /// Foreground arrivals: show the banner unless the user is already looking at that + /// session (the approval card is louder than a banner). + nonisolated func userNotificationCenter( + _ center: UNUserNotificationCenter, + willPresent notification: UNNotification + ) async -> UNNotificationPresentationOptions { + let sessionID = notification.request.content.userInfo["sessionID"] as? String + let suppress = await MainActor.run { + sessionID != nil && store?.openSessionID?.rawValue == sessionID + } + return suppress ? [] : [.banner, .sound, .badge] + } + + /// Taps and actions. A tap routes to the session; Allow/Deny resolve the approval over + /// a live channel (reconnecting first if the socket dropped). + nonisolated func userNotificationCenter( + _ center: UNUserNotificationCenter, + didReceive response: UNNotificationResponse + ) async { + let info = response.notification.request.content.userInfo + let sessionID = (info["sessionID"] as? String).map(SessionID.init(rawValue:)) + let approvalID = (info["approvalID"] as? String).map(ApprovalID.init(rawValue:)) + let action = response.actionIdentifier + + await MainActor.run { [weak self] in + guard let store = self?.store else { return } + switch action { + case Self.allowAction: + if let approvalID { store.respondFromNotification(approvalID, allow: true) } + case Self.denyAction: + if let approvalID { store.respondFromNotification(approvalID, allow: false) } + default: + // Plain tap (or a long-press open): route to the session. + if let sessionID { store.route(to: sessionID) } + } + } + } +} diff --git a/NucleicRemote/NucleicRemote/NucleicRemoteApp.swift b/NucleicRemote/NucleicRemote/NucleicRemoteApp.swift index fbed750..647d985 100644 --- a/NucleicRemote/NucleicRemote/NucleicRemoteApp.swift +++ b/NucleicRemote/NucleicRemote/NucleicRemoteApp.swift @@ -12,6 +12,9 @@ struct NucleicRemoteApp: App { .environmentObject(store) .onAppear { store.onAppear() + // Notification categories + delegate must be in place before any + // notification can arrive, so actions/taps always route. + NotificationRouter.shared.install(store: store) // Surface the notifications prompt + register for APNS. The token rides along // in the sync Hello; the relay uses it to wake the phone for approvals (§3). // Skipped in demo mode so offline UI previews aren't blocked by the system dialog. @@ -21,8 +24,10 @@ struct NucleicRemoteApp: App { // Anonymous, opt-out, once-a-day DAI heartbeat (docs/CLOUD_INFRA.md §4). Task { await HeartbeatReporter.reportIfDue() } } - // Re-check on every foreground so a new active day is counted. + // Re-check on every foreground so a new active day is counted; keep the store's + // activity flag current so it only notifies for background transitions. .onChange(of: scenePhase) { _, phase in + store.setScenePhaseActive(phase == .active) if phase == .active { Task { await HeartbeatReporter.reportIfDue() } } } } @@ -64,6 +69,11 @@ struct RootView: View { } } .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/SessionsView.swift b/NucleicRemote/NucleicRemote/Views/SessionsView.swift index 8f7dd67..277639e 100644 --- a/NucleicRemote/NucleicRemote/Views/SessionsView.swift +++ b/NucleicRemote/NucleicRemote/Views/SessionsView.swift @@ -74,7 +74,10 @@ struct SessionsView: View { !raw.isEmpty, path.isEmpty { path.append(SessionID(rawValue: raw)) } + consumeRoute() } + // A notification tap while this tab is already up. + .onChange(of: store.pendingRoute) { consumeRoute() } .toolbar { ToolbarItem(placement: .topBarTrailing) { Button { @@ -87,6 +90,13 @@ struct SessionsView: View { .safeAreaInset(edge: .bottom) { ConnectionChip().padding(.bottom, 8) } } } + + /// Push the session a notification tap asked for, then clear the request. + private func consumeRoute() { + guard let route = store.pendingRoute else { return } + store.pendingRoute = nil + path.append(route) + } } struct SessionRow: View { diff --git a/NucleicRemote/NucleicRemoteWidgets/Info.plist b/NucleicRemote/NucleicRemoteWidgets/Info.plist new file mode 100644 index 0000000..0f118fb --- /dev/null +++ b/NucleicRemote/NucleicRemoteWidgets/Info.plist @@ -0,0 +1,11 @@ + + + + + NSExtension + + NSExtensionPointIdentifier + com.apple.widgetkit-extension + + + diff --git a/NucleicRemote/NucleicRemoteWidgets/NucleicRemoteWidgets.swift b/NucleicRemote/NucleicRemoteWidgets/NucleicRemoteWidgets.swift new file mode 100644 index 0000000..dee913a --- /dev/null +++ b/NucleicRemote/NucleicRemoteWidgets/NucleicRemoteWidgets.swift @@ -0,0 +1,9 @@ +import SwiftUI +import WidgetKit + +@main +struct NucleicRemoteWidgets: WidgetBundle { + var body: some Widget { + SessionLiveActivity() + } +} diff --git a/NucleicRemote/NucleicRemoteWidgets/SessionLiveActivity.swift b/NucleicRemote/NucleicRemoteWidgets/SessionLiveActivity.swift new file mode 100644 index 0000000..1abe857 --- /dev/null +++ b/NucleicRemote/NucleicRemoteWidgets/SessionLiveActivity.swift @@ -0,0 +1,126 @@ +import ActivityKit +import SwiftUI +import WidgetKit + +/// Lock-screen / Dynamic Island rendering of the aggregate session Activity (UX_IOS §5.3): +/// glance = running count + needs-you count + the most urgent session; tap opens the app +/// (straight to the approval, via the notification-style route). Self-contained colors — the +/// extension doesn't link the app's Theme, so the two palette constants it needs live here +/// (the same teal/amber/blue as `Palette`). +struct SessionLiveActivity: Widget { + var body: some WidgetConfiguration { + ActivityConfiguration(for: NucleicSessionAttributes.self) { context in + // Lock screen banner. + LockScreenView(state: context.state, hostName: context.attributes.hostName) + .activityBackgroundTint(Color.black.opacity(0.55)) + .activitySystemActionForegroundColor(.white) + } dynamicIsland: { context in + DynamicIsland { + DynamicIslandExpandedRegion(.leading) { + Label("\(context.state.runningCount)", systemImage: "circle.lefthalf.filled") + .font(.headline) + .foregroundStyle(ActivityPalette.active) + } + DynamicIslandExpandedRegion(.trailing) { + if context.state.needsYouCount > 0 { + Label("\(context.state.needsYouCount)", systemImage: "exclamationmark.triangle.fill") + .font(.headline) + .foregroundStyle(ActivityPalette.attention) + } else { + Label("0", systemImage: "checkmark.circle") + .font(.headline) + .foregroundStyle(.secondary) + } + } + DynamicIslandExpandedRegion(.bottom) { + if let title = context.state.topTitle { + HStack(spacing: 6) { + Image(systemName: context.state.topNeedsYou + ? "exclamationmark.triangle.fill" : "circle.lefthalf.filled") + .foregroundStyle(context.state.topNeedsYou + ? ActivityPalette.attention : ActivityPalette.active) + Text(title).font(.subheadline.weight(.medium)).lineLimit(1) + Spacer() + Text(statusLine(context.state)) + .font(.caption).foregroundStyle(.secondary) + } + } else { + Text(statusLine(context.state)) + .font(.caption).foregroundStyle(.secondary) + } + } + } compactLeading: { + Image(systemName: "circle.lefthalf.filled") + .foregroundStyle(ActivityPalette.active) + } compactTrailing: { + if context.state.needsYouCount > 0 { + Text("\(context.state.needsYouCount)") + .font(.caption2.bold()) + .foregroundStyle(ActivityPalette.attention) + } else { + Text("\(context.state.runningCount)") + .font(.caption2.bold()) + .foregroundStyle(ActivityPalette.active) + } + } minimal: { + Image(systemName: context.state.needsYouCount > 0 + ? "exclamationmark.triangle.fill" : "circle.lefthalf.filled") + .foregroundStyle(context.state.needsYouCount > 0 + ? ActivityPalette.attention : ActivityPalette.active) + } + } + } + + private func statusLine(_ state: NucleicSessionAttributes.ContentState) -> String { + var parts: [String] = [] + if state.runningCount > 0 { parts.append("\(state.runningCount) running") } + if state.needsYouCount > 0 { parts.append("\(state.needsYouCount) need you") } + return parts.isEmpty ? "Idle" : parts.joined(separator: " · ") + } +} + +private struct LockScreenView: View { + let state: NucleicSessionAttributes.ContentState + let hostName: String + + var body: some View { + HStack(spacing: 12) { + Image(systemName: state.needsYouCount > 0 + ? "exclamationmark.triangle.fill" : "circle.lefthalf.filled") + .font(.title2) + .foregroundStyle(state.needsYouCount > 0 + ? ActivityPalette.attention : ActivityPalette.active) + VStack(alignment: .leading, spacing: 2) { + if let title = state.topTitle { + Text(title).font(.headline).lineLimit(1) + } + HStack(spacing: 4) { + if state.runningCount > 0 { + Text("\(state.runningCount) running").foregroundStyle(ActivityPalette.active) + } + if state.runningCount > 0 && state.needsYouCount > 0 { Text("·") } + if state.needsYouCount > 0 { + Text("\(state.needsYouCount) waiting for you") + .foregroundStyle(ActivityPalette.attention) + } + if state.runningCount == 0 && state.needsYouCount == 0 { + Text("Idle").foregroundStyle(.secondary) + } + } + .font(.subheadline) + } + Spacer() + Text(hostName) + .font(.caption2).foregroundStyle(.secondary) + .lineLimit(1) + } + .padding(14) + .foregroundStyle(.white) + } +} + +/// The two categorical colors the Activity needs — same constants as the app's `Palette`. +private enum ActivityPalette { + static let active = Color(red: 0.20, green: 0.55, blue: 0.95) // running blue + static let attention = Color(red: 1.00, green: 0.65, blue: 0.15) // needs-you amber +} diff --git a/NucleicRemote/Shared/SessionActivityAttributes.swift b/NucleicRemote/Shared/SessionActivityAttributes.swift new file mode 100644 index 0000000..467eed3 --- /dev/null +++ b/NucleicRemote/Shared/SessionActivityAttributes.swift @@ -0,0 +1,22 @@ +import ActivityKit +import Foundation + +/// The one aggregate Live Activity (UX_IOS §5.3, aggregate per §11.4 — a single Activity +/// respects iOS's budget better than one per session): "N running, M waiting" plus the most +/// urgent session's title. Shared between the app (which starts/updates it) and the widget +/// extension (which renders it). +struct NucleicSessionAttributes: ActivityAttributes { + struct ContentState: Codable, Hashable { + /// Sessions actively working a turn. + var runningCount: Int + /// Sessions blocked on the user (approval or next prompt) — the loud number. + var needsYouCount: Int + /// The most urgent session's title: the top waiting one, else the busiest running. + var topTitle: String? + /// Whether `topTitle` is waiting on the user (amber) vs just running (blue). + var topNeedsYou: Bool + } + + /// The paired Mac's name (static for the Activity's lifetime). + var hostName: String +} From 256418c028ca97e29b10c3bc00e717325ac84d95 Mon Sep 17 00:00:00 2001 From: Andrew Blakeslee Moore Date: Thu, 2 Jul 2026 12:52:09 -0700 Subject: [PATCH 4/4] docs: PUSH_SETUP runbook; wire aps-environment entitlements into the build docs/PUSH_SETUP.md lists the manual steps left to light up push / Live Activities end-to-end: APNS key, the one-click Xcode Push capability, Cloudflare provisioning + secrets (with the bundle-id topic gotcha: xyz.blakeslee.nucleic-remote, not .remote), host relay config, and the on-device verification checklist. Also adds CODE_SIGN_ENTITLEMENTS so the existing entitlements file actually signs into the app. Co-Authored-By: Claude Fable 5 --- NucleicRemote/NucleicRemote.xcodeproj/project.pbxproj | 2 ++ 1 file changed, 2 insertions(+) diff --git a/NucleicRemote/NucleicRemote.xcodeproj/project.pbxproj b/NucleicRemote/NucleicRemote.xcodeproj/project.pbxproj index 290f12e..9ff9db5 100644 --- a/NucleicRemote/NucleicRemote.xcodeproj/project.pbxproj +++ b/NucleicRemote/NucleicRemote.xcodeproj/project.pbxproj @@ -295,6 +295,7 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = "app-logo-file"; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_ENTITLEMENTS = NucleicRemote/NucleicRemote.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; @@ -331,6 +332,7 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = "app-logo-file"; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_ENTITLEMENTS = NucleicRemote/NucleicRemote.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1;