Merge nucleic/sleek-maple-dingo into dev

This commit is contained in:
2026-07-10 16:55:18 -07:00
parent b651dce028
commit 841207fc43
3 changed files with 52 additions and 10 deletions
@@ -1365,14 +1365,30 @@ final class RemoteStore: ObservableObject {
// MARK: - Intents (UX_IOS §9)
func open(_ sessionID: SessionID) {
openSessionID = sessionID
/// Ownership token for the open-session state: `open()` bumps and returns it, and the detail
/// view hands its own back to `closeOpen`. Two detail views for the *same* session can be
/// mounted at once a Live Activity deep link onto an already-open chat pushes a duplicate,
/// and a tab switch can remount the open session in another tab's stack. `closeOpen`'s
/// session-id guard can't tell those copies apart, so without the token the covered copy's
/// teardown would wipe the state the copy on screen is using (blank transcript, dead
/// subscription, tab bar restored over the chat bar).
private var openGeneration = 0
@discardableResult
func open(_ sessionID: SessionID) -> Int {
openGeneration += 1
// Hide the compact shell's floating tab bar the instant a session view opens from *any*
// entry point (Sessions, Home, Projects, a notification tap), since every one funnels
// through here. Tied to the session view opening rather than the Sessions list being
// navigated away from, so the bar slides out even when the chat wasn't launched from that
// list. `SessionsView` still clears it early on back-swipe for a responsive re-show.
compactDetailPresented = true
markOpened(sessionID)
// A second mount of the already-open session (see `openGeneration`): the transcript,
// approvals, and wire subscription are live and already this session's keep them,
// don't wipe and reload. The new mount just takes ownership via the fresh generation.
guard openSessionID != sessionID else { return openGeneration }
openSessionID = sessionID
// Record which Mac owns this session (mesh P3) so its connection forwards the transcript and
// the host-specific projected values follow it.
openSessionHostID = connection(owningSession: sessionID)?.hostID
@@ -1381,8 +1397,7 @@ final class RemoteStore: ObservableObject {
openApprovals = []
openDiff = nil
diffLoading = false
markOpened(sessionID)
if demoMode { seedDemoTranscript(sessionID); return }
if demoMode { seedDemoTranscript(sessionID); return openGeneration }
// 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)
@@ -1394,6 +1409,7 @@ final class RemoteStore: ObservableObject {
// Pull the full history too the subscribe above only returns a 200-event tail, so without
// this the transcript would start at the connection point with no events from before it.
connection(owningSession: sessionID)?.fetchFullTranscript(sessionID)
return openGeneration
}
/// Bind the open session to its owning Mac once that Mac is live the deferred half of `open()`
@@ -1584,8 +1600,22 @@ final class RemoteStore: ObservableObject {
/// unsubscribe the named session but only tear down the shared open-state when it still
/// belongs to that session otherwise we'd wipe B's freshly-loaded transcript. Called with
/// no argument it closes whatever is currently open (the iPhone push/pop path, unchanged).
func closeOpen(_ id: SessionID? = nil) {
///
/// `token` is the value the closing view got from its `open()` call. A stale token means
/// another detail has since taken ownership possibly of the *same* session (a Live Activity
/// deep link onto the already-open chat mounts a duplicate detail): then even the unsubscribe
/// must be skipped, because the wire subscription now belongs to the copy still on screen.
func closeOpen(_ id: SessionID? = nil, token: Int? = nil) {
guard let target = id ?? openSessionID else { return }
if let token, token != openGeneration {
// Stale mount unmounting after a newer `open()`. Same session: everything belongs to
// the current owner full no-op. Different session (the iPad AB switch): it still
// unsubscribes itself, exactly as before.
guard target != openSessionID else { return }
send(.unsubscribe(target))
markOpened(target)
return
}
send(.unsubscribe(target))
markOpened(target) // everything up to now has been seen
guard openSessionID == target else { return }
@@ -30,6 +30,11 @@ struct SessionDetailView: View {
// itself to the screen: a long host command or a long list of options must never grow the card
// past the screen edge and push its Allow/Deny (or Submit) buttons out of reach.
@State private var availableHeight: CGFloat = 0
// This mount's claim on the store's open-session state (`RemoteStore.openGeneration`). Two
// details for the same session can be mounted at once (a Live Activity deep link onto the
// already-open chat); the token lets `closeOpen` ignore the covered copy's teardown instead
// of wiping the transcript the visible copy is showing.
@State private var openToken = 0
private var summary: WireSessionSummary? {
store.sessions.first { $0.sessionID == sessionID }
@@ -83,10 +88,11 @@ struct SessionDetailView: View {
Button("Discard", role: .destructive) { store.discard(sessionID) }
Button("Cancel", role: .cancel) {}
}
.onAppear { store.open(sessionID) }
// Pass our own id so an iPad split-view AB switch (which may mount B before A
// disappears) unsubscribes A without tearing down B's just-opened state.
.onDisappear { store.closeOpen(sessionID) }
.onAppear { openToken = store.open(sessionID) }
// Pass our own id + token so an iPad split-view AB switch (which may mount B
// before A disappears) unsubscribes A without tearing down B's just-opened state
// and so a covered duplicate of the *same* session tears down nothing at all.
.onDisappear { store.closeOpen(sessionID, token: openToken) }
.background { interruptShortcut }
// Publish the parent-determined height to the cards. `initial: true` seeds it on the
// first layout; it refreshes if the container resizes (rotation, keyboard, iPad split
@@ -6,7 +6,9 @@ import NucleicProtocol
struct SessionsView: View {
@EnvironmentObject var store: RemoteStore
@State private var showArchived = false
@State private var path = NavigationPath()
// Typed (not `NavigationPath`) so `consumeRoute` can see what's already pushed a deep link
// to the session on top must land on it, not stack a duplicate detail over it.
@State private var path: [SessionID] = []
private var grouped: [(title: String, rows: [WireSessionSummary])] {
let pool = (showArchived ? store.sessions : store.liveSessions)
@@ -117,6 +119,10 @@ struct SessionsView: View {
private func consumeRoute() {
guard let route = store.pendingRoute else { return }
store.pendingRoute = nil
// Already showing this session (the app was backgrounded with the chat open and a Live
// Activity tap deep-linked back to it): pushing again would mount a duplicate detail whose
// eventual teardown races the visible one's state. The open chat is the destination.
guard path.last != route else { return }
path.append(route)
}
}