Files
nucleic-remote-ios/NucleicRemote/NucleicRemote/Views/SessionsView.swift
T
abkslmandNucleic 3dbb3616f2 Native Liquid Glass Tab Bar Request
Nucleic-Session: 11F2B35E-E813-4A5E-AD28-9173ECC0B789
Co-authored-by: Nucleic <[email protected]>
2026-07-05 23:41:27 -07:00

224 lines
12 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()
}
}
.navigationTitle("Sessions")
.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
}
}
}
/// 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)
}
}
}
}