From 8d6560496edcc78eb086d6296ee49afaf92a8ce7 Mon Sep 17 00:00:00 2001 From: Andrew Blakeslee Moore Date: Sun, 5 Jul 2026 22:38:11 -0700 Subject: [PATCH] =?UTF-8?q?nvrsion:=20Add:=20Fix=20Xcode=20build=20error?= =?UTF-8?q?=20when=20running=20=E2=80=98make=20ios-release-canary=E2=80=99?= =?UTF-8?q?,=20update=20ios/VERSION,=20and=20refactor=20session=20cache=20?= =?UTF-8?q?implementation=20for=20iOS.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Nucleic-Promote: 1 Co-authored-by: Nucleic --- .../NucleicRemote/Models/RemoteStore.swift | 79 +++++++++++- .../NucleicRemote/Models/SessionCache.swift | 118 ++++++++++++++++++ .../Views/SessionDetailView.swift | 36 +++++- .../NucleicRemote/Views/SessionsView.swift | 17 +++ VERSION | 2 +- 5 files changed, 246 insertions(+), 6 deletions(-) create mode 100644 NucleicRemote/NucleicRemote/Models/SessionCache.swift diff --git a/NucleicRemote/NucleicRemote/Models/RemoteStore.swift b/NucleicRemote/NucleicRemote/Models/RemoteStore.swift index 8a276b7..62a16c2 100644 --- a/NucleicRemote/NucleicRemote/Models/RemoteStore.swift +++ b/NucleicRemote/NucleicRemote/Models/RemoteStore.swift @@ -44,6 +44,13 @@ final class RemoteStore: ObservableObject { /// from `hostName(forSession:)` instead. @Published private(set) var hostName: String = "" @Published private(set) var sessions: [WireSessionSummary] = [] + /// The last session list seen from a live host, persisted to disk (`SessionCache`) and reloaded + /// at launch. It backs `sessions` whenever nothing is connected, so the list shows a read-only + /// history while offline instead of an empty "waiting to connect" screen. + private var cachedSummaries: [WireSessionSummary] = SessionCache.loadSummaries() + /// Debounced disk-write tasks for the offline cache (coalesce a burst of updates into one write). + private var summaryPersistTask: Task? + private var transcriptPersistTask: Task? @Published private(set) var capabilities = WireCapabilities(canModifyToolInput: false, allowAlwaysScopes: []) @Published private(set) var grantedScope: DeviceScope = .approve /// The host's model/effort catalog (SYNC §5.2), driving the composer + session-header pickers. @@ -296,7 +303,11 @@ final class RemoteStore: ObservableObject { pathMonitor.onChange = { [weak self] in self?.handlePathChange() } pathMonitor.start() discovery.start() - if isPaired { reconnect() } + if isPaired { + // Show the saved chat history immediately, before any host connects. + if sessions.isEmpty { sessions = cachedSummaries } + reconnect() + } } /// A network-path change: tell each host connection to re-plan its transport now. @@ -510,16 +521,19 @@ final class RemoteStore: ObservableObject { // instead of truncating it to the host's 200-event tail. self.openEvents = Self.mergedEvents(self.openEvents, snap.recentEvents) self.openApprovals = snap.pendingApprovals + self.persistOpenTranscript() } cb.openEvents = { [weak self] batch in guard let self, hostID == self.openSessionHostID, batch.sessionID == self.openSessionID else { return } self.openEvents.append(contentsOf: batch.events) + self.persistOpenTranscript() } cb.openBackfill = { [weak self] batch in guard let self, hostID == self.openSessionHostID, batch.sessionID == self.openSessionID else { return } // Full-history backfill precedes what's on screen — merge by seq so it slots in above the // tail rather than appending out of order. self.openEvents = Self.mergedEvents(self.openEvents, batch.events) + self.persistOpenTranscript() } cb.openDiff = { [weak self] diff in guard let self, hostID == self.openSessionHostID, diff.sessionID == self.openSessionID else { return } @@ -587,7 +601,18 @@ final class RemoteStore: ObservableObject { /// or a representative one. No-op in demo, which seeds the aggregate directly. private func rebuildAggregate() { guard !demoMode else { return } - sessions = aggregatedSessions() + let live = aggregatedSessions() + if !live.isEmpty { + sessions = live + cachedSummaries = live + persistSummaries(live) + } else if connections.values.contains(where: { $0.connectivity.isLive }) { + // Connected, but the host genuinely has no sessions — reflect that honestly. + sessions = [] + } else { + // Offline: keep showing the saved history rather than blanking the list. + sessions = cachedSummaries + } dashboard = DashboardSnapshot.merged(connections.values.map(\.dashboard)) meshPeers = connections.values.flatMap(\.meshPeers) @@ -625,6 +650,48 @@ final class RemoteStore: ObservableObject { return bySeq.values.sorted { $0.seq < $1.seq } } + // MARK: - Offline cache (SessionCache) + + /// Debounced write of the live session list to disk, so a read-only history survives a + /// disconnect / relaunch. + private func persistSummaries(_ list: [WireSessionSummary]) { + summaryPersistTask?.cancel() + summaryPersistTask = Task { [list] in + try? await Task.sleep(nanoseconds: 2_000_000_000) + guard !Task.isCancelled else { return } + await SessionCache.saveSummaries(list) + } + } + + /// Seed the open transcript from disk so a cached session's history shows instantly — even fully + /// offline. A live snapshot/backfill merges on top by seq (dedup), so this never double-counts. + private func loadCachedTranscript(_ sessionID: SessionID) { + Task { [weak self] in + let cached = await SessionCache.loadEvents(sessionID) + guard let self, self.openSessionID == sessionID, !cached.isEmpty else { return } + self.openEvents = Self.mergedEvents(cached, self.openEvents) + } + } + + /// Debounced write of the open transcript, called after each batch of events lands. + private func persistOpenTranscript() { + guard !demoMode, let id = openSessionID, !openEvents.isEmpty else { return } + let events = openEvents + transcriptPersistTask?.cancel() + transcriptPersistTask = Task { [events, id] in + try? await Task.sleep(nanoseconds: 1_500_000_000) + guard !Task.isCancelled else { return } + await SessionCache.saveEvents(events, for: id) + } + } + + /// Flush the open transcript to disk immediately (on close), cancelling any pending debounce. + private func flushOpenTranscript(_ id: SessionID, _ events: [AgentEvent]) { + transcriptPersistTask?.cancel() + guard !demoMode, !events.isEmpty else { return } + Task { await SessionCache.saveEvents(events, for: id) } + } + /// The flat sessions list: every connected Mac's sessions, deduped by id (ids are globally /// unique). Views handle sorting/grouping. private func aggregatedSessions() -> [WireSessionSummary] { @@ -713,6 +780,9 @@ final class RemoteStore: ObservableObject { openSessionHostID = nil openEvents = []; openApprovals = []; openDiff = nil; diffLoading = false connectivity = .unpaired + // No Mac left whose history to hold — drop the offline cache too. + cachedSummaries = [] + SessionCache.clear() sessions = [] dashboard = .empty // Nothing left to dial — spin the embedded Tailscale node down if it was running. @@ -770,6 +840,9 @@ final class RemoteStore: ObservableObject { diffLoading = false markOpened(sessionID) if demoMode { seedDemoTranscript(sessionID); return } + // Seed from the on-device cache so the transcript shows instantly — including fully offline, + // where the subscribe below is a no-op. Live events merge on top by seq (dedup). + loadCachedTranscript(sessionID) // Tell the owning connection so it forwards the snapshot/events (and dedupes them), re-derive // the context host (capabilities/scope/catalog/connectivity now follow it), then subscribe. connection(owningSession: sessionID)?.openSessionID = sessionID @@ -913,6 +986,8 @@ final class RemoteStore: ObservableObject { connection(owningSession: target)?.openSessionID = nil openSessionID = nil openSessionHostID = nil + // Persist the final transcript before clearing it, so it's warm for the next open / offline. + flushOpenTranscript(target, openEvents) openEvents = [] openApprovals = [] openDiff = nil diff --git a/NucleicRemote/NucleicRemote/Models/SessionCache.swift b/NucleicRemote/NucleicRemote/Models/SessionCache.swift new file mode 100644 index 0000000..fa8b0f2 --- /dev/null +++ b/NucleicRemote/NucleicRemote/Models/SessionCache.swift @@ -0,0 +1,118 @@ +import Foundation +import NucleicProtocol + +/// An on-device cache of the phone's recent chat history — the session list and the transcripts of +/// the sessions the user has actually opened — so the app can show a read-only history while it's +/// disconnected from every Mac host (UX_IOS offline). It is a *projection cache*, never canonical +/// state: a live host's data always supersedes it, and it's written straight from the same wire +/// types the sync stream delivers (`SessionSummary` / `AgentEvent`), so nothing is invented. +/// +/// Stored as JSON files under Application Support (durable across launches, unlike Caches, which the +/// OS may purge under storage pressure): +/// +/// NucleicSessionCache/summaries.json — the merged session list, recency-capped + pruned +/// NucleicSessionCache/transcripts/.json — one opened session's event tail +/// +/// File I/O is synchronous, but the write/read paths hop onto a utility `Task.detached` so nothing +/// touches disk on the main actor; the wire types are `Sendable`, so the arrays cross the boundary +/// cleanly. Per-session transcript files keep an append-heavy live session from rewriting the whole +/// history on every event, and cap the tail so a long run can't grow unbounded. +enum SessionCache { + /// Keep the most-recently-updated N sessions' summaries; older ones (and their transcript + /// files) are pruned on every save so the cache stays bounded. + private static let sessionLimit = 50 + /// Keep the newest N events per session — the tail is what a returning reader wants, and it + /// bounds a long-running session's file. + private static let eventLimit = 1500 + + // MARK: - Paths + + private static var root: URL? { + guard let base = try? FileManager.default.url( + for: .applicationSupportDirectory, in: .userDomainMask, appropriateFor: nil, create: true) + else { return nil } + return base.appendingPathComponent("NucleicSessionCache", isDirectory: true) + } + private static var summariesURL: URL? { root?.appendingPathComponent("summaries.json") } + private static var transcriptsDir: URL? { + root?.appendingPathComponent("transcripts", isDirectory: true) + } + private static func transcriptURL(_ id: SessionID) -> URL? { + transcriptsDir?.appendingPathComponent(transcriptFileName(id)) + } + private static func transcriptFileName(_ id: SessionID) -> String { + // Session ids are UUID strings today, but sanitize defensively so an id can never escape + // the cache directory or collide with the summaries file. + let allowed = Set("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_") + return String(id.rawValue.map { allowed.contains($0) ? $0 : "_" }) + ".json" + } + + // MARK: - Summaries + + /// Persist the recent session list (recency-capped) and prune transcript files whose session is + /// no longer cached. Off the main actor. + static func saveSummaries(_ summaries: [WireSessionSummary]) async { + await Task.detached(priority: .utility) { writeSummaries(summaries) }.value + } + + /// Load the cached session list — called once at launch to seed the list before any host + /// connects. Small (≤ `sessionLimit`), so a synchronous read is fine. + static func loadSummaries() -> [WireSessionSummary] { + guard let url = summariesURL, let data = try? Data(contentsOf: url), + let list = try? JSONDecoder().decode([WireSessionSummary].self, from: data) + else { return [] } + return list + } + + private static func writeSummaries(_ summaries: [WireSessionSummary]) { + guard let root, let url = summariesURL, let dir = transcriptsDir else { return } + try? FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + let kept = Array(summaries.sorted { $0.updatedAt > $1.updatedAt }.prefix(sessionLimit)) + if let data = try? JSONEncoder().encode(kept) { try? data.write(to: url, options: .atomic) } + // Drop transcript files for sessions that fell out of the recent set. + let keep = Set(kept.map { transcriptFileName($0.sessionID) }) + if let files = try? FileManager.default.contentsOfDirectory( + at: dir, includingPropertiesForKeys: nil) { + for file in files where !keep.contains(file.lastPathComponent) { + try? FileManager.default.removeItem(at: file) + } + } + } + + // MARK: - Transcripts + + /// Persist one session's transcript (newest `eventLimit` events by seq). Off the main actor. + static func saveEvents(_ events: [AgentEvent], for id: SessionID) async { + await Task.detached(priority: .utility) { writeEvents(events, id) }.value + } + + /// Load a session's cached transcript — used to seed the open transcript instantly, before the + /// host's snapshot/backfill arrives (and merges on top by seq). Off the main actor. + static func loadEvents(_ id: SessionID) async -> [AgentEvent] { + await Task.detached(priority: .utility) { readEvents(id) }.value + } + + private static func writeEvents(_ events: [AgentEvent], _ id: SessionID) { + guard !events.isEmpty, let dir = transcriptsDir, let url = transcriptURL(id) else { return } + try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + let sorted = events.sorted { $0.seq < $1.seq } + let tail = sorted.count > eventLimit ? Array(sorted.suffix(eventLimit)) : sorted + if let data = try? JSONEncoder().encode(tail) { try? data.write(to: url, options: .atomic) } + } + + private static func readEvents(_ id: SessionID) -> [AgentEvent] { + guard let url = transcriptURL(id), let data = try? Data(contentsOf: url), + let events = try? JSONDecoder().decode([AgentEvent].self, from: data) + else { return [] } + return events + } + + // MARK: - Clear + + /// Drop the whole cache — on full unpair, when there's no longer any Mac whose history this + /// device should hold. + static func clear() { + guard let root else { return } + try? FileManager.default.removeItem(at: root) + } +} diff --git a/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift b/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift index 789927b..82258ae 100644 --- a/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift +++ b/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift @@ -222,8 +222,40 @@ struct SessionDetailView: View { /// The floating action layer: one Liquid Glass bar carrying the queued pills, the working /// indicator, the session controls, and the composer — or the approval card when a gate is /// pending. Content scrolls beneath it; nothing renders when there's nothing to act on. - @ViewBuilder private var actionArea: some View { + VStack(spacing: 8) { + // Interacting with a chat while the owning Mac is unreachable surfaces this first, so a + // disabled composer reads as "offline / read-only history" rather than broken. + if !store.connectivity.isLive { disconnectedBanner } + actionContent + } + .padding(.horizontal, 12) + .padding(.bottom, 8) + } + + /// The offline notice over the transcript: the chat's history is cached and readable, but the + /// composer + controls below stay disabled until the Mac that owns it is reachable again. + private var disconnectedBanner: some View { + HStack(spacing: 10) { + Image(systemName: "wifi.slash").foregroundStyle(.secondary) + VStack(alignment: .leading, spacing: 1) { + Text("Disconnected").font(.footnote.weight(.semibold)) + Text("Showing saved history · reconnect to send") + .font(.caption2).foregroundStyle(.secondary) + } + Spacer(minLength: 8) + Button("Reconnect") { store.reconnect() } + .font(.caption.weight(.semibold)) + .buttonStyle(.borderless) + .disabled(!store.isPaired) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 14).padding(.vertical, 10) + .glassSurface(cornerRadius: 20) + } + + @ViewBuilder + private var actionContent: some View { Group { if let approval = store.openApprovals.first { // AskUserQuestion isn't a permission gate — it collects the user's answers and @@ -293,8 +325,6 @@ struct SessionDetailView: View { } } } - .padding(.horizontal, 12) - .padding(.bottom, 8) } /// A queued follow-up shown above the composer: its text (or attachment names) with the diff --git a/NucleicRemote/NucleicRemote/Views/SessionsView.swift b/NucleicRemote/NucleicRemote/Views/SessionsView.swift index 45c70ab..172578c 100644 --- a/NucleicRemote/NucleicRemote/Views/SessionsView.swift +++ b/NucleicRemote/NucleicRemote/Views/SessionsView.swift @@ -28,6 +28,23 @@ struct SessionsView: View { : "Waiting to connect to your Mac…")) } else { List { + // Offline: the rows below are the saved history, not a live view — say so. + if !store.connectivity.isLive { + Section { + HStack(spacing: 8) { + Image(systemName: "wifi.slash").foregroundStyle(.secondary) + Text("Offline — showing saved chats") + .font(.footnote).foregroundStyle(.secondary) + Spacer(minLength: 8) + if store.isPaired { + Button("Reconnect") { store.reconnect() } + .font(.caption.weight(.semibold)) + .buttonStyle(.borderless) + } + } + } + .listRowBackground(Color.clear) + } ForEach(grouped, id: \.title) { group in Section(group.title.uppercased()) { ForEach(group.rows, id: \.sessionID) { summary in diff --git a/VERSION b/VERSION index b979118..9e37c72 100644 --- a/VERSION +++ b/VERSION @@ -10,4 +10,4 @@ # NUCLEIC_MARKETING_VERSION semantic version -> CFBundleShortVersionString # NUCLEIC_BUILD_NUMBER monotonic build -> CFBundleVersion (always +1 per release) NUCLEIC_MARKETING_VERSION=0.2.0 -NUCLEIC_BUILD_NUMBER=5 +NUCLEIC_BUILD_NUMBER=13