Files
nucleic-remote-ios/NucleicRemote/NucleicRemote/Views/SessionsView.swift
T
abkslmandNucleic 16ec20a4ac Bottom Tab Bar Visibility Adjustment
Nucleic-Session: 747527BF-AEE9-4939-BE70-A14F1F3BBBA8
Co-authored-by: Nucleic <[email protected]>
2026-07-05 17:44:00 -07:00

212 lines
11 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 showNewChat = 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 {
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() }
}
}
.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()
}
// A notification tap while this tab is already up.
.onChange(of: store.pendingRoute) { consumeRoute() }
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Button {
showArchived.toggle()
} label: {
Image(systemName: showArchived ? "archivebox.fill" : "archivebox")
}
}
}
// A floating "new chat" affordance in the lower-right, mirroring the Mac's per-project
// "New chat" button. Picks a project + prompt in a glass sheet, then kicks off the chat.
// Applied before the chip inset so it floats clear above the connection chip.
.newChatAffordance(isPresented: $showNewChat)
.safeAreaInset(edge: .bottom) { ConnectionChip().padding(.bottom, 8) }
}
}
/// 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)
}
}
}
}