Merge branch 'dev' into nucleic/plucky-umber-viper
This commit is contained in:
@@ -1365,14 +1365,30 @@ final class RemoteStore: ObservableObject {
|
|||||||
|
|
||||||
// MARK: - Intents (UX_IOS §9)
|
// MARK: - Intents (UX_IOS §9)
|
||||||
|
|
||||||
func open(_ sessionID: SessionID) {
|
/// Ownership token for the open-session state: `open()` bumps and returns it, and the detail
|
||||||
openSessionID = sessionID
|
/// 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*
|
// 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
|
// 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
|
// 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
|
// 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.
|
// list. `SessionsView` still clears it early on back-swipe for a responsive re-show.
|
||||||
compactDetailPresented = true
|
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
|
// Record which Mac owns this session (mesh P3) so its connection forwards the transcript and
|
||||||
// the host-specific projected values follow it.
|
// the host-specific projected values follow it.
|
||||||
openSessionHostID = connection(owningSession: sessionID)?.hostID
|
openSessionHostID = connection(owningSession: sessionID)?.hostID
|
||||||
@@ -1381,8 +1397,7 @@ final class RemoteStore: ObservableObject {
|
|||||||
openApprovals = []
|
openApprovals = []
|
||||||
openDiff = nil
|
openDiff = nil
|
||||||
diffLoading = false
|
diffLoading = false
|
||||||
markOpened(sessionID)
|
if demoMode { seedDemoTranscript(sessionID); return openGeneration }
|
||||||
if demoMode { seedDemoTranscript(sessionID); return }
|
|
||||||
// Seed from the on-device cache so the transcript shows instantly — including fully offline,
|
// 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).
|
// where the subscribe below is a no-op. Live events merge on top by seq (dedup).
|
||||||
loadCachedTranscript(sessionID)
|
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
|
// 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.
|
// this the transcript would start at the connection point with no events from before it.
|
||||||
connection(owningSession: sessionID)?.fetchFullTranscript(sessionID)
|
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()`
|
/// 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
|
/// 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
|
/// 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).
|
/// 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 }
|
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))
|
send(.unsubscribe(target))
|
||||||
markOpened(target) // everything up to now has been seen
|
markOpened(target) // everything up to now has been seen
|
||||||
guard openSessionID == target else { return }
|
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
|
// 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.
|
// past the screen edge and push its Allow/Deny (or Submit) buttons out of reach.
|
||||||
@State private var availableHeight: CGFloat = 0
|
@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? {
|
private var summary: WireSessionSummary? {
|
||||||
store.sessions.first { $0.sessionID == sessionID }
|
store.sessions.first { $0.sessionID == sessionID }
|
||||||
@@ -83,10 +88,11 @@ struct SessionDetailView: View {
|
|||||||
Button("Discard", role: .destructive) { store.discard(sessionID) }
|
Button("Discard", role: .destructive) { store.discard(sessionID) }
|
||||||
Button("Cancel", role: .cancel) {}
|
Button("Cancel", role: .cancel) {}
|
||||||
}
|
}
|
||||||
.onAppear { store.open(sessionID) }
|
.onAppear { openToken = store.open(sessionID) }
|
||||||
// Pass our own id so an iPad split-view A→B switch (which may mount B before A
|
// Pass our own id + token so an iPad split-view A→B switch (which may mount B
|
||||||
// disappears) unsubscribes A without tearing down B's just-opened state.
|
// before A disappears) unsubscribes A without tearing down B's just-opened state —
|
||||||
.onDisappear { store.closeOpen(sessionID) }
|
// and so a covered duplicate of the *same* session tears down nothing at all.
|
||||||
|
.onDisappear { store.closeOpen(sessionID, token: openToken) }
|
||||||
.background { interruptShortcut }
|
.background { interruptShortcut }
|
||||||
// Publish the parent-determined height to the cards. `initial: true` seeds it on the
|
// 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
|
// first layout; it refreshes if the container resizes (rotation, keyboard, iPad split
|
||||||
|
|||||||
@@ -1,15 +1,18 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
import NucleicProtocol
|
import NucleicProtocol
|
||||||
|
|
||||||
/// Attention-first home (UX_IOS §3): NEEDS YOU pinned at top, then RUNNING, then DONE. Archived
|
/// Attention-first home (UX_IOS §3): NEEDS YOU pinned at top, then RUNNING, then DONE, then a
|
||||||
/// chats are hidden (matching the Mac sidebar); disposition splits "needs you" from "done".
|
/// collapsible ARCHIVED group at the bottom (mirrors the Mac sidebar); disposition splits "needs
|
||||||
|
/// you" from "done".
|
||||||
struct SessionsView: View {
|
struct SessionsView: View {
|
||||||
@EnvironmentObject var store: RemoteStore
|
@EnvironmentObject var store: RemoteStore
|
||||||
@State private var showArchived = false
|
@State private var archivedExpanded = 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])] {
|
private var grouped: [(title: String, rows: [WireSessionSummary])] {
|
||||||
let pool = (showArchived ? store.sessions : store.liveSessions)
|
let pool = store.liveSessions
|
||||||
.sorted(by: StatusStyle.attentionThenRecency)
|
.sorted(by: StatusStyle.attentionThenRecency)
|
||||||
// One pass, first bucket wins — the old `done = pool.filter { !needs.contains($0) … }`
|
// One pass, first bucket wins — the old `done = pool.filter { !needs.contains($0) … }`
|
||||||
// ran O(rows²) full-summary equality scans on every body evaluation.
|
// 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 }
|
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 {
|
var body: some View {
|
||||||
NavigationStack(path: $path) {
|
NavigationStack(path: $path) {
|
||||||
Group {
|
Group {
|
||||||
if store.liveSessions.isEmpty && !showArchived {
|
if store.liveSessions.isEmpty && archivedSessions.isEmpty {
|
||||||
ContentUnavailableView(
|
ContentUnavailableView(
|
||||||
"No sessions", systemImage: "square.stack.3d.up",
|
"No sessions", systemImage: "square.stack.3d.up",
|
||||||
description: Text(store.connectivity.isLive
|
description: Text(store.connectivity.isLive
|
||||||
@@ -54,29 +64,33 @@ struct SessionsView: View {
|
|||||||
ForEach(grouped, id: \.title) { group in
|
ForEach(grouped, id: \.title) { group in
|
||||||
Section(group.title.uppercased()) {
|
Section(group.title.uppercased()) {
|
||||||
ForEach(group.rows, id: \.sessionID) { summary in
|
ForEach(group.rows, id: \.sessionID) { summary in
|
||||||
NavigationLink(value: summary.sessionID) {
|
sessionRow(summary)
|
||||||
SessionRow(summary: summary)
|
|
||||||
}
|
}
|
||||||
.listRowBackground(SessionRowWash(summary: summary))
|
}
|
||||||
.swipeActions(edge: .leading) {
|
}
|
||||||
|
// 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 {
|
Button {
|
||||||
store.setFavorite(summary.sessionID, !summary.favorite)
|
withAnimation(.easeInOut(duration: 0.22)) { archivedExpanded.toggle() }
|
||||||
} label: {
|
} label: {
|
||||||
Label("Favorite", systemImage: summary.favorite ? "star.slash" : "star.fill")
|
HStack(spacing: 6) {
|
||||||
}.tint(.yellow)
|
Text("Archived (\(archivedSessions.count))")
|
||||||
}
|
Spacer(minLength: 0)
|
||||||
.swipeActions(edge: .trailing) {
|
Image(systemName: "chevron.right")
|
||||||
Button(role: .destructive) {
|
.font(.caption2.weight(.semibold))
|
||||||
store.deleteSession(summary.sessionID)
|
.rotationEffect(.degrees(archivedExpanded ? 90 : 0))
|
||||||
} 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)
|
|
||||||
}
|
}
|
||||||
|
.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.
|
/// Push the session a notification tap asked for, then clear the request.
|
||||||
private func consumeRoute() {
|
private func consumeRoute() {
|
||||||
guard let route = store.pendingRoute else { return }
|
guard let route = store.pendingRoute else { return }
|
||||||
store.pendingRoute = nil
|
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)
|
path.append(route)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user