From 5e0d58a0e202cbc10cd87a866060f2e60ed1bd6e Mon Sep 17 00:00:00 2001 From: Andrew Blakeslee Moore Date: Fri, 10 Jul 2026 16:59:35 -0700 Subject: [PATCH] Merge nucleic/warm-breezy-raven into dev --- .../NucleicRemote/Views/SessionsView.swift | 92 +++++++++++++------ 1 file changed, 66 insertions(+), 26 deletions(-) diff --git a/NucleicRemote/NucleicRemote/Views/SessionsView.swift b/NucleicRemote/NucleicRemote/Views/SessionsView.swift index 90d0a59..ad9694a 100644 --- a/NucleicRemote/NucleicRemote/Views/SessionsView.swift +++ b/NucleicRemote/NucleicRemote/Views/SessionsView.swift @@ -1,17 +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 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. @@ -25,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 @@ -56,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) } } } @@ -115,6 +127,34 @@ 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 }