From a33ab80313cfc9bcf640946a7aa428d98bf54f84 Mon Sep 17 00:00:00 2001 From: Andrew Blakeslee Moore Date: Thu, 2 Jul 2026 03:43:13 -0700 Subject: [PATCH] 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,