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) } } } }