diff --git a/NucleicRemote/NucleicRemote/Models/HostConnection.swift b/NucleicRemote/NucleicRemote/Models/HostConnection.swift index 8436ad5..0dba1bd 100644 --- a/NucleicRemote/NucleicRemote/Models/HostConnection.swift +++ b/NucleicRemote/NucleicRemote/Models/HostConnection.swift @@ -35,7 +35,20 @@ final class HostConnection { /// The session RemoteStore currently has open on this host, if any — so snapshot/events are only /// forwarded (and deduped) for the transcript on screen. Set by RemoteStore on open/close. - var openSessionID: SessionID? + /// Changing which session is open resets the per-session cursor + dedup set (they only make + /// sense within one transcript). + var openSessionID: SessionID? { + didSet { + guard openSessionID != oldValue else { return } + openMaxSeq = nil + seenSeq = [] + } + } + + /// The highest event `seq` delivered for the open session — the warm-resubscribe cursor. On a + /// reconnect we re-subscribe `sinceSeq: openMaxSeq` so the host replays only what we missed + /// instead of cold-resetting to the 200-event tail (which would truncate a long transcript). + private var openMaxSeq: UInt64? // MARK: Callbacks up to RemoteStore (aggregate concerns) @@ -440,7 +453,9 @@ final class HostConnection { callbacks.didUpdate() send(.listSessions) send(.listDashboard) - if let id = openSessionID { send(.subscribe(Subscribe(sessionID: id, sinceSeq: nil, verbosity: .full))) } + // Warm-resubscribe from what we already have, so a reconnect on a long transcript replays + // only the gap rather than snapping back to the host's 200-event tail. + if let id = openSessionID { send(.subscribe(Subscribe(sessionID: id, sinceSeq: openMaxSeq, verbosity: .full))) } case .sessionList(let list): sessions = list callbacks.didUpdate() @@ -461,12 +476,17 @@ final class HostConnection { callbacks.didUpdate() case .snapshot(let snapshot): guard snapshot.summary.sessionID == openSessionID else { break } - seenSeq = Set(snapshot.recentEvents.map(\.seq)) + // A snapshot may be a fresh open (tail window) or a warm-resubscribe delta; either way + // union its seqs into the dedup set and advance the cursor. RemoteStore merges the events + // into the transcript rather than replacing, so an existing transcript isn't truncated. + seenSeq.formUnion(snapshot.recentEvents.map(\.seq)) + if let maxSeq = snapshot.recentEvents.map(\.seq).max() { openMaxSeq = max(openMaxSeq ?? 0, maxSeq) } callbacks.openSnapshot(snapshot) case .events(let batch): guard batch.sessionID == openSessionID else { break } let fresh = batch.events.filter { !seenSeq.contains($0.seq) } for e in fresh { seenSeq.insert(e.seq) } + if let maxSeq = batch.events.map(\.seq).max() { openMaxSeq = max(openMaxSeq ?? 0, maxSeq) } if !fresh.isEmpty { callbacks.openEvents(EventBatch(sessionID: batch.sessionID, events: fresh)) } case .approvalRequested(let req): let title = sessions.first { $0.sessionID == req.sessionID }?.title ?? "Approval" diff --git a/NucleicRemote/NucleicRemote/Models/RemoteStore.swift b/NucleicRemote/NucleicRemote/Models/RemoteStore.swift index cfe8834..f5016b2 100644 --- a/NucleicRemote/NucleicRemote/Models/RemoteStore.swift +++ b/NucleicRemote/NucleicRemote/Models/RemoteStore.swift @@ -477,7 +477,10 @@ final class RemoteStore: ObservableObject { } cb.openSnapshot = { [weak self] snap in guard let self, hostID == self.openSessionHostID, snap.summary.sessionID == self.openSessionID else { return } - self.openEvents = snap.recentEvents + // Merge, don't replace: a fresh open merges into an empty transcript (the tail window), + // while a reconnect's warm-resubscribe delta appends to the history already on screen + // instead of truncating it to the host's 200-event tail. + self.openEvents = Self.mergedEvents(self.openEvents, snap.recentEvents) self.openApprovals = snap.pendingApprovals } cb.openEvents = { [weak self] batch in @@ -557,6 +560,20 @@ final class RemoteStore: ObservableObject { } } + /// Merge transcript events by `seq` (monotonic, globally unique within a session), keeping the + /// union sorted. Lets a reconnect's snapshot fold its events into the transcript already on + /// screen without duplicating what's shown or dropping history outside the host's tail window. + /// A fresh open merges into `[]`, so it's just the tail — the same result as a plain replace. + private static func mergedEvents(_ existing: [AgentEvent], _ incoming: [AgentEvent]) -> [AgentEvent] { + guard !existing.isEmpty else { return incoming } + guard !incoming.isEmpty else { return existing } + var bySeq: [UInt64: AgentEvent] = [:] + bySeq.reserveCapacity(existing.count + incoming.count) + for e in existing { bySeq[e.seq] = e } + for e in incoming { bySeq[e.seq] = e } + return bySeq.values.sorted { $0.seq < $1.seq } + } + /// The flat sessions list: every connected Mac's sessions, deduped by id (ids are globally /// unique). Views handle sorting/grouping. private func aggregatedSessions() -> [WireSessionSummary] {