Merge nucleic/warm-breezy-raven into dev

This commit is contained in:
2026-07-10 16:59:35 -07:00
parent 2a3238fa69
commit 5e0d58a0e2
@@ -1,17 +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
// Typed (not `NavigationPath`) so `consumeRoute` can see what's already pushed — a deep link // 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. // to the session on top must land on it, not stack a duplicate detail over it.
@State private var path: [SessionID] = [] @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.
@@ -25,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
@@ -56,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
Button { // "Done" (mirrors the Mac sidebar's "Archived Projects" group).
store.setFavorite(summary.sessionID, !summary.favorite) if !archivedSessions.isEmpty {
} label: { Section {
Label("Favorite", systemImage: summary.favorite ? "star.slash" : "star.fill") if archivedExpanded {
}.tint(.yellow) ForEach(archivedSessions, id: \.sessionID) { summary in
} sessionRow(summary)
.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)
} }
} }
} 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. /// 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 }