Files
nucleic-remote-ios/NucleicRemote/NucleicRemote/Views/SessionsView.swift
T
abkslmandNucleic 6eed2f2868 Remove Home Header
Nucleic-Session: 21C33A6B-CE4E-4155-84C1-CB881C03DD3A
Co-authored-by: Nucleic <[email protected]>
2026-07-05 23:15:39 -07:00

253 lines
13 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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".
struct SessionsView: View {
@EnvironmentObject var store: RemoteStore
@State private var showArchived = false
@State private var path = NavigationPath()
private var grouped: [(title: String, rows: [WireSessionSummary])] {
let pool = (showArchived ? store.sessions : store.liveSessions)
.sorted(by: StatusStyle.attentionThenRecency)
let needs = pool.filter { $0.status.needsYou($0.disposition) }
let running = pool.filter { $0.status == .running || $0.status == .provisioning || $0.status == .idle }
let done = pool.filter { !needs.contains($0) && !running.contains($0) }
return [("Needs you", needs), ("Running", running), ("Done", done)].filter { !$0.rows.isEmpty }
}
var body: some View {
NavigationStack(path: $path) {
Group {
if store.liveSessions.isEmpty && !showArchived {
ContentUnavailableView(
"No sessions", systemImage: "square.stack.3d.up",
description: Text(store.connectivity.isLive
? "Start a session from Home or the Mac to see it here."
: "Waiting to connect to your Mac…"))
} else {
List {
// Offline: the rows below are the saved history, not a live view — say so.
if !store.connectivity.isLive {
Section {
HStack(spacing: 8) {
Image(systemName: "wifi.slash").foregroundStyle(.secondary)
Text("Offline — showing saved chats")
.font(.footnote).foregroundStyle(.secondary)
Spacer(minLength: 8)
if store.isPaired {
Button("Reconnect") { store.reconnect() }
.font(.caption.weight(.semibold))
.buttonStyle(.borderless)
}
}
}
.listRowBackground(Color.clear)
}
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)
}
}
}
}
}
.listStyle(.insetGrouped)
.refreshable { store.refreshSessions() }
.compactTabBarInset()
}
}
// The title is rendered as content (`sessionsHeader`) rather than a system
// `.navigationTitle`, so the archive toggle can sit on the same row, centered with it
// (a system toolbar item can only ride in the compact bar, floating above a large
// title). The empty inline bar keeps the top band matching the Home dashboard.
.navigationBarTitleDisplayMode(.inline)
.safeAreaInset(edge: .top, spacing: 0) { sessionsHeader }
.navigationDestination(for: SessionID.self) { id in
SessionDetailView(sessionID: id)
}
// Offline UI previews (with NUCLEIC_DEMO): jump straight into one session.
.onAppear {
if let raw = ProcessInfo.processInfo.environment["NUCLEIC_DEMO_SESSION"],
!raw.isEmpty, path.isEmpty {
path.append(SessionID(rawValue: raw))
}
consumeRoute()
// Sync the shell's tab-bar visibility to the current push depth on (re)appear —
// `onChange` alone won't fire for a path restored across a remount.
store.compactDetailPresented = !path.isEmpty
}
// A notification tap while this tab is already up.
.onChange(of: store.pendingRoute) { consumeRoute() }
// Drive the floating tab bar straight off the navigation path: it empties the instant a
// back-swipe/tap begins, so the bar slides back in immediately instead of waiting for
// `SessionDetailView.onDisappear` (which fires only at the end of the pop transition).
.onChange(of: path) { _, newPath in
store.compactDetailPresented = !newPath.isEmpty
}
}
}
/// The page's large title as content, so the archive toggle can share its row (vertically
/// centered with the title) instead of floating in the compact bar above it. Mirrors the Home
/// dashboard's greeting header — same large-title font and top band — for a consistent look.
private var sessionsHeader: some View {
HStack {
Text("Sessions").font(.largeTitle.bold())
Spacer()
Button {
showArchived.toggle()
} label: {
Image(systemName: showArchived ? "archivebox.fill" : "archivebox")
.font(.title3)
}
.foregroundStyle(Palette.accent)
.accessibilityLabel(showArchived ? "Hide archived sessions" : "Show archived sessions")
}
.padding(.horizontal)
.padding(.top, 52)
.padding(.bottom, 8)
.frame(maxWidth: .infinity)
// Cover the list rows that scroll up behind the pinned header.
.background(Color(.systemGroupedBackground))
}
/// 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
path.append(route)
}
}
struct SessionRow: View {
@EnvironmentObject var store: RemoteStore
let summary: WireSessionSummary
/// Show the project name as subtext. Suppressed when the row already lives inside
/// its project's own section (e.g. `ProjectDetailView`), where the name is redundant.
var showProjectName = true
/// Whether Orchestra is latched on (the effort carries the sentinel).
private var orchestra: Bool {
guard let effort = summary.effort else { return false }
return effort.caseInsensitiveCompare(store.modelCatalog.orchestraSentinelOrFallback) == .orderedSame
}
var body: some View {
HStack(spacing: 12) {
Image(systemName: StatusStyle.symbol(summary.status, disposition: summary.disposition))
.foregroundStyle(Palette.status(summary.status, disposition: summary.disposition))
.font(.title3)
.frame(width: 24)
VStack(alignment: .leading, spacing: 2) {
HStack(spacing: 5) {
if summary.favorite {
Image(systemName: "star.fill").font(.caption2).foregroundStyle(.yellow)
}
Text(summary.title).font(.body.weight(.medium)).lineLimit(1)
// The same trailing markers the Mac sidebar row carries: auto-approval
// bolt, Orchestra note, autoship box. Suppressed on a moved-away tombstone.
if summary.movedTo == nil {
if summary.auto {
Image(systemName: "bolt.fill").font(.caption2).foregroundStyle(Palette.accent)
}
if orchestra {
Image(systemName: "music.note.list").font(.caption2).foregroundStyle(Palette.orchestra)
}
if summary.autoShip {
Image(systemName: "shippingbox.fill").font(.caption2).foregroundStyle(Palette.accent)
}
}
}
if let moved = summary.movedTo {
// Moved to another Mac (mesh P5): the name is baked by the host, so the phone
// renders it directly without needing to know that Mac itself.
Label("Moved to \(moved.deviceName)", systemImage: "arrow.up.forward")
.font(.caption).foregroundStyle(.secondary).lineLimit(1)
} else if let arrived = summary.arrivedFrom {
// Arrived from another Mac (mesh P5) — subtle provenance, with the project name
// folded in when it would otherwise show.
Label(showProjectName ? "\(summary.projectName) · from \(arrived.deviceName)"
: "from \(arrived.deviceName)",
systemImage: "arrow.down.forward")
.font(.caption).foregroundStyle(.secondary).lineLimit(1)
} else if showProjectName {
// With more than one Mac connected, tag the owning Mac so the merged list stays
// distinguishable (mesh P3); a single-Mac view just shows the project.
if store.connectedHostCount > 1, let host = store.hostName(forSession: summary.sessionID) {
Text("\(summary.projectName) · \(host)").font(.caption).foregroundStyle(.secondary)
} else {
Text(summary.projectName).font(.caption).foregroundStyle(.secondary)
}
}
}
Spacer()
VStack(alignment: .trailing, spacing: 2) {
if summary.pendingApprovalCount > 0 {
Text("\(summary.pendingApprovalCount)")
.font(.caption2.bold())
.padding(.horizontal, 6).padding(.vertical, 2)
.background(Palette.attention, in: Capsule())
.foregroundStyle(.white)
}
if let diff = summary.diffStat, diff.filesChanged > 0 {
Text("+\(diff.added)/−\(diff.removed)")
.font(.caption.monospacedDigit())
.foregroundStyle(.secondary)
}
}
}
.padding(.vertical, 2)
}
}
/// The Mac sidebar's row washes, mirrored: a breathing amber fill on a chat that's blocked on
/// you, and a steady green fill on a chat that finished while you weren't looking. Applied as
/// a `listRowBackground` so the wash sits under the whole row.
struct SessionRowWash: View {
@EnvironmentObject var store: RemoteStore
let summary: WireSessionSummary
@Environment(\.accessibilityReduceMotion) private var reduceMotion
@State private var pulsing = false
var body: some View {
ZStack {
Color(.secondarySystemGroupedBackground) // the list row's normal surface
if summary.status.needsYou(summary.disposition) {
// Breathing amber (0.10 → 0.30 on a 1.3s ease, matching the Mac); a steady
// mid-tint under Reduce Motion.
Palette.attention.opacity(reduceMotion ? 0.18 : (pulsing ? 0.30 : 0.10))
.animation(
reduceMotion ? nil
: .easeInOut(duration: 1.3).repeatForever(autoreverses: true),
value: pulsing)
.onAppear { pulsing = true }
} else if store.unseenCompletion(summary) {
Palette.success.opacity(0.18)
}
}
}
}