nvrsion: Add: Fix Xcode build error when running ‘make ios-release-canary’, update ios/VERSION, and refactor session cache implementation for iOS.

Nucleic-Promote: 1
Co-authored-by: Nucleic <[email protected]>
This commit is contained in:
2026-07-05 22:38:11 -07:00
co-authored by Nucleic
parent e06f0c78f5
commit 8d6560496e
5 changed files with 246 additions and 6 deletions
@@ -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<Void, Never>?
private var transcriptPersistTask: Task<Void, Never>?
@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
@@ -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/<id>.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)
}
}
@@ -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
@@ -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
+1 -1
View File
@@ -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