diff --git a/NucleicRemote/NucleicRemote/Models/RemoteStore.swift b/NucleicRemote/NucleicRemote/Models/RemoteStore.swift index 21044f5..7841000 100644 --- a/NucleicRemote/NucleicRemote/Models/RemoteStore.swift +++ b/NucleicRemote/NucleicRemote/Models/RemoteStore.swift @@ -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 A→B 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 } diff --git a/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift b/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift index a621241..81e6278 100644 --- a/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift +++ b/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift @@ -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 A→B 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 A→B 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 diff --git a/NucleicRemote/NucleicRemote/Views/SessionsView.swift b/NucleicRemote/NucleicRemote/Views/SessionsView.swift index 9b6ef9d..ad9694a 100644 --- a/NucleicRemote/NucleicRemote/Views/SessionsView.swift +++ b/NucleicRemote/NucleicRemote/Views/SessionsView.swift @@ -1,15 +1,18 @@ import SwiftUI import NucleicProtocol -/// Attention-first home (UX_IOS §3): NEEDS YOU pinned at top, then RUNNING, then DONE. Archived -/// chats are hidden (matching the Mac sidebar); disposition splits "needs you" from "done". +/// Attention-first home (UX_IOS §3): NEEDS YOU pinned at top, then RUNNING, then DONE, then a +/// collapsible ARCHIVED group at the bottom (mirrors the Mac sidebar); disposition splits "needs +/// you" from "done". struct SessionsView: View { @EnvironmentObject var store: RemoteStore - @State private var showArchived = false - @State private var path = NavigationPath() + @State private var archivedExpanded = false + // 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) + let pool = store.liveSessions .sorted(by: StatusStyle.attentionThenRecency) // One pass, first bucket wins — the old `done = pool.filter { !needs.contains($0) … }` // ran O(rows²) full-summary equality scans on every body evaluation. @@ -23,10 +26,17 @@ struct SessionsView: View { return [("Needs you", needs), ("Running", running), ("Done", done)].filter { !$0.rows.isEmpty } } + /// Archived chats, shown in their own collapsible section below "Done". These are excluded + /// from `liveSessions` (and therefore from `grouped`), so they can only appear here. + private var archivedSessions: [WireSessionSummary] { + store.sessions.filter { $0.archived } + .sorted(by: StatusStyle.attentionThenRecency) + } + var body: some View { NavigationStack(path: $path) { Group { - if store.liveSessions.isEmpty && !showArchived { + if store.liveSessions.isEmpty && archivedSessions.isEmpty { ContentUnavailableView( "No sessions", systemImage: "square.stack.3d.up", description: Text(store.connectivity.isLive @@ -54,29 +64,33 @@ struct SessionsView: View { ForEach(grouped, id: \.title) { group in Section(group.title.uppercased()) { ForEach(group.rows, id: \.sessionID) { summary in - NavigationLink(value: summary.sessionID) { - SessionRow(summary: summary) - } - .listRowBackground(SessionRowWash(summary: summary)) - .swipeActions(edge: .leading) { - Button { - store.setFavorite(summary.sessionID, !summary.favorite) - } label: { - Label("Favorite", systemImage: summary.favorite ? "star.slash" : "star.fill") - }.tint(.yellow) - } - .swipeActions(edge: .trailing) { - Button(role: .destructive) { - store.deleteSession(summary.sessionID) - } label: { Label("Delete", systemImage: "trash") } - Button { - store.setArchived(summary.sessionID, !summary.archived) - } label: { - Label(summary.archived ? "Unarchive" : "Archive", - systemImage: summary.archived ? "tray.and.arrow.up" : "archivebox") - }.tint(.gray) + sessionRow(summary) + } + } + } + // Archived chats live in their own collapsible section, pinned below + // "Done" (mirrors the Mac sidebar's "Archived Projects" group). + if !archivedSessions.isEmpty { + Section { + if archivedExpanded { + ForEach(archivedSessions, id: \.sessionID) { summary in + sessionRow(summary) } } + } header: { + Button { + withAnimation(.easeInOut(duration: 0.22)) { archivedExpanded.toggle() } + } label: { + HStack(spacing: 6) { + Text("Archived (\(archivedSessions.count))") + Spacer(minLength: 0) + Image(systemName: "chevron.right") + .font(.caption2.weight(.semibold)) + .rotationEffect(.degrees(archivedExpanded ? 90 : 0)) + } + .contentShape(Rectangle()) + } + .buttonStyle(.plain) } } } @@ -113,10 +127,42 @@ struct SessionsView: View { } } + /// One tappable session row with its favorite / delete / archive swipe actions. Shared by + /// the live sections (Needs you / Running / Done) and the collapsible Archived section. + @ViewBuilder + private func sessionRow(_ summary: WireSessionSummary) -> some View { + NavigationLink(value: summary.sessionID) { + SessionRow(summary: summary) + } + .listRowBackground(SessionRowWash(summary: summary)) + .swipeActions(edge: .leading) { + Button { + store.setFavorite(summary.sessionID, !summary.favorite) + } label: { + Label("Favorite", systemImage: summary.favorite ? "star.slash" : "star.fill") + }.tint(.yellow) + } + .swipeActions(edge: .trailing) { + Button(role: .destructive) { + store.deleteSession(summary.sessionID) + } label: { Label("Delete", systemImage: "trash") } + Button { + store.setArchived(summary.sessionID, !summary.archived) + } label: { + Label(summary.archived ? "Unarchive" : "Archive", + systemImage: summary.archived ? "tray.and.arrow.up" : "archivebox") + }.tint(.gray) + } + } + /// Push the session a notification tap asked for, then clear the request. 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) } }