From 4af015c28355bde7efa90a4eb9cd8626cf4290c6 Mon Sep 17 00:00:00 2001 From: Nucleic Date: Fri, 17 Jul 2026 22:39:03 -0700 Subject: [PATCH] Merge nucleic/brisk-lunar-seal-yhdq into dev --- .../NucleicRemote/Models/HostConnection.swift | 18 +++++++++++++ .../NucleicRemote/Models/RemoteStore.swift | 16 ++++++++++++ .../NucleicRemote/Models/SessionCache.swift | 25 +++++++++++++++++++ 3 files changed, 59 insertions(+) diff --git a/NucleicRemote/NucleicRemote/Models/HostConnection.swift b/NucleicRemote/NucleicRemote/Models/HostConnection.swift index cba7db1..c637b05 100644 --- a/NucleicRemote/NucleicRemote/Models/HostConnection.swift +++ b/NucleicRemote/NucleicRemote/Models/HostConnection.swift @@ -83,6 +83,10 @@ final class HostConnection { /// Backfilled history for the open session (the full-transcript fetch). Merged into the /// transcript by seq, not appended — these events precede the tail already on screen. var openBackfill: (EventBatch) -> Void = { _ in } + /// The open session was reverted/undone on the owner — drop every event past `throughSeq` + /// (the one callback that *shrinks* the on-screen transcript). Carries the sessionID so + /// RemoteStore can confirm it's still the open one before mutating. + var openReverted: (SessionID, UInt64) -> Void = { _, _ in } /// A background transcript prefetch finished — everything it fetched (empty when the /// host had nothing / the fetch died with the connection). RemoteStore merges it into /// the offline cache and starts the next queued prefetch either way. @@ -740,6 +744,20 @@ final class HostConnection { // Collapsed Bash summary lines the owner pushes to peer *Macs* (mesh session sync). // The phone renders its own deterministic command summaries, so it ignores these. break + case .transcriptReverted(let reverted): + // The owner reverted/undid this chat — the transcript was truncated to `throughSeq`. + if reverted.sessionID == openSessionID { + // On screen: drop our cursor/dedup state for the dropped seqs (so a warm-resubscribe + // replays from the new tip, not the stale one) and have RemoteStore shrink the live + // transcript + rewrite its cache to match. + seenSeq = seenSeq.filter { $0 <= reverted.throughSeq } + openMaxSeq = reverted.throughSeq == 0 ? nil : reverted.throughSeq + callbacks.openReverted(reverted.sessionID, reverted.throughSeq) + } else { + // Not on screen: nothing live to shrink, but truncate the offline cache so a later + // open doesn't re-seed the dropped tail via the grow-only seed merge. + SessionCache.truncateEvents(for: reverted.sessionID, throughSeq: reverted.throughSeq) + } case .relayMembership(let membership): // The host issued/refreshed this device's relay credential (mesh P2). Persist it // in place (no reordering — this can arrive from a non-active Mac) so the relay diff --git a/NucleicRemote/NucleicRemote/Models/RemoteStore.swift b/NucleicRemote/NucleicRemote/Models/RemoteStore.swift index dd38a0f..2db78ea 100644 --- a/NucleicRemote/NucleicRemote/Models/RemoteStore.swift +++ b/NucleicRemote/NucleicRemote/Models/RemoteStore.swift @@ -1226,6 +1226,22 @@ final class RemoteStore: ObservableObject { self.openEvents = Self.mergedEvents(self.openEvents, batch.events) self.persistOpenTranscript() } + cb.openReverted = { [weak self] sessionID, throughSeq in + guard let self, hostID == self.openSessionHostID, sessionID == self.openSessionID else { return } + // A revert/undo on the owner truncated the transcript. Drain buffered deltas first (they + // belong to the transcript being truncated), then drop every event past the new tip — + // the one path that *shrinks* the on-screen transcript rather than growing it. + self.drainPendingOpenEvents() + self.openEvents = self.openEvents.filter { $0.seq <= throughSeq } + if self.openEvents.isEmpty { + // A full revert cleared the transcript — drop the cache too, else a later + // reopen-from-cache (offline) would resurrect the reverted events via the grow-only + // seed merge (`saveEvents` refuses an empty write, so it can't clear it for us). + SessionCache.removeEvents(for: sessionID) + } else { + self.persistOpenTranscript() + } + } cb.transcriptPrefetched = { [weak self] sessionID, events in guard let self else { return } self.prefetchInFlight = nil diff --git a/NucleicRemote/NucleicRemote/Models/SessionCache.swift b/NucleicRemote/NucleicRemote/Models/SessionCache.swift index ed68dba..9c062d6 100644 --- a/NucleicRemote/NucleicRemote/Models/SessionCache.swift +++ b/NucleicRemote/NucleicRemote/Models/SessionCache.swift @@ -101,6 +101,31 @@ enum SessionCache { if let data = try? JSONEncoder().encode(tail) { try? data.write(to: url, options: .atomic) } } + /// Drop one session's cached transcript. Used when a revert/undo clears the whole transcript: + /// `saveEvents` refuses an empty write (so it can't overwrite with nothing), but a reopen-from- + /// cache seeds via a grow-only merge, so a stale cache would resurrect the reverted events — this + /// removes the file outright so the reopened transcript starts empty and the live stream refills. + static func removeEvents(for id: SessionID) { + guard let url = transcriptURL(id) else { return } + try? FileManager.default.removeItem(at: url) + } + + /// Shrink one session's cached transcript to `throughSeq` — drop every event past it — so a + /// revert/undo on the owner doesn't leave a later reopen-from-cache reseeding the dropped tail + /// via the grow-only seed merge. Removes the file when nothing is left. No-op if uncached or + /// already at/below `throughSeq`. + static func truncateEvents(for id: SessionID, throughSeq: UInt64) { + guard let url = transcriptURL(id) else { return } + let cached = readEvents(id) + guard cached.contains(where: { $0.seq > throughSeq }) else { return } + let kept = cached.filter { $0.seq <= throughSeq } + if kept.isEmpty { + try? FileManager.default.removeItem(at: url) + } else if let data = try? JSONEncoder().encode(kept) { + 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)