Nucleic-Session: 747527BF-AEE9-4939-BE70-A14F1F3BBBA8 Co-authored-by: Nucleic <[email protected]>
212 lines
11 KiB
Swift
212 lines
11 KiB
Swift
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)
|
||
}
|
||
}
|
||
}
|
||
}
|