Files
nucleic-remote-ios/NucleicRemote/NucleicRemote/Views/SessionsView.swift
T
abkslmandClaude Opus 4.8 7b7e13aa20 Mesh P3: host switcher (switch active Mac) + 2-host demo
The safe, verifiable slice of iOS multi-host: switch which paired Mac the phone views, reusing the
existing (proven) single-connection reconnect — no rewrite of the connection state machine. The
simultaneous [HostID: HostConnection] multiplexer stays deferred (it needs two real Macs to verify).

RemoteStore: an `activeHostID` (the paired Mac the flat projection reflects); `hostChoices` (paired
registry live, mock hosts in demo); `switchHost(to:)` — live re-points via reconnect(), demo swaps
the mock host preserving in-demo edits; `reconnect()`/`pair()` set the active host; `unpair()` now
forgets the *active* Mac and switches to a remaining one if any (identical for a single host). Demo
seeds two mock Macs ("Andrew's Mac", "Studio Mac") with distinct sessions/dashboards.

SessionsView: a host-switcher menu in the toolbar, shown only when >1 Mac.

Verified in the iOS Simulator (demo mode): the switcher lists both Macs, and switching swaps the
whole session projection + the tab badge (screenshots taken). Single-host behavior is unchanged
(one host ⇒ switcher hidden ⇒ flat state exactly as before).

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-04 17:40:00 -07:00

225 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 path = NavigationPath()
private var grouped: [(title: String, rows: [WireSessionSummary])] {
let pool = (showArchived ? store.sessions : store.liveSessions)
.sorted {
let a = StatusStyle.sortRank($0), b = StatusStyle.sortRank($1)
return a == b ? $0.updatedAt > $1.updatedAt : a < b
}
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 \(store.hostName)…"))
} 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 {
// Host switcher (mesh P3): pick which paired Mac to view. Hidden with a single Mac.
ToolbarItem(placement: .topBarLeading) {
if store.hostChoices.count > 1 {
Menu {
ForEach(store.hostChoices) { choice in
Button {
store.switchHost(to: choice.id)
} label: {
Label(choice.name,
systemImage: choice.isActive ? "checkmark" : "desktopcomputer")
}
}
} label: {
HStack(spacing: 4) {
Image(systemName: "desktopcomputer")
Text(store.hostName).lineLimit(1)
Image(systemName: "chevron.down").font(.caption2)
}
}
}
}
ToolbarItem(placement: .topBarTrailing) {
Button {
showArchived.toggle()
} label: {
Image(systemName: showArchived ? "archivebox.fill" : "archivebox")
}
}
}
.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 {
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)
}
}
}
}