256 lines
12 KiB
Swift
256 lines
12 KiB
Swift
import SwiftUI
|
||
import NucleicProtocol
|
||
|
||
/// Chooses the navigation shell by width so one universal binary serves both idioms:
|
||
/// • **compact** (iPhone, and iPad in Slide Over / narrow split) → the five-tab `TabView`.
|
||
/// • **regular** (iPad full-screen / Stage Manager) → the Mac's sidebar + detail split.
|
||
///
|
||
/// Both render the SAME `RemoteStore` projection — the iPad is a third renderer of the one host
|
||
/// authority (UX_MACOS §8), not a fork. The store holds all real state, so a size-class flip
|
||
/// (e.g. resizing a Stage Manager window) only swaps chrome; sessions, connection, and the open
|
||
/// transcript survive it.
|
||
struct AdaptiveRootView: View {
|
||
@Environment(\.horizontalSizeClass) private var sizeClass
|
||
|
||
var body: some View {
|
||
if sizeClass == .regular {
|
||
SplitRootView()
|
||
} else {
|
||
CompactRootView()
|
||
}
|
||
}
|
||
}
|
||
|
||
// MARK: - Compact (iPhone / narrow multitasking)
|
||
|
||
/// The original iPhone shell, relocated verbatim from `RootView`: a five-tab `TabView`. A
|
||
/// notification tap routes to the Sessions tab, where `SessionsView` consumes `pendingRoute`
|
||
/// and pushes the detail.
|
||
struct CompactRootView: View {
|
||
@EnvironmentObject var store: RemoteStore
|
||
// Initial tab; overridable via NUCLEIC_TAB for offline UI previews.
|
||
@State private var tab = Int(ProcessInfo.processInfo.environment["NUCLEIC_TAB"] ?? "") ?? 0
|
||
|
||
var body: some View {
|
||
TabView(selection: $tab) {
|
||
HomeView()
|
||
.tabItem { Label("Home", systemImage: "house") }.tag(0)
|
||
SessionsView()
|
||
.tabItem { Label("Sessions", systemImage: "square.stack.3d.up") }
|
||
.badge(store.needsYouCount).tag(1)
|
||
ProjectsView()
|
||
.tabItem { Label("Projects", systemImage: "folder") }.tag(2)
|
||
TodosView()
|
||
.tabItem { Label("To-dos", systemImage: "checklist") }.tag(3)
|
||
SettingsView()
|
||
.tabItem { Label("Settings", systemImage: "gearshape") }.tag(4)
|
||
}
|
||
// A notification tap routes to its session by jumping to the Sessions tab.
|
||
.onChange(of: store.pendingRoute) { _, route in
|
||
if route != nil { tab = 1 }
|
||
}
|
||
// A Live Activity tap with work running but nothing waiting: show the Sessions list.
|
||
.onChange(of: store.pendingSessionsList) { _, show in
|
||
if show { tab = 1; store.pendingSessionsList = false }
|
||
}
|
||
}
|
||
}
|
||
|
||
// MARK: - Regular (iPad sidebar + detail)
|
||
|
||
/// What the sidebar can select. Mirrors the Mac sidebar: a few global destinations plus the
|
||
/// live sessions themselves (grouped by project). Projects are reached through the `projects`
|
||
/// destination (`ProjectsView`), which also hosts the scoped "start a chat here" composer.
|
||
enum SidebarItem: Hashable {
|
||
case home
|
||
case projects
|
||
case todos
|
||
case settings
|
||
case session(SessionID)
|
||
}
|
||
|
||
/// The Mac's information architecture on iPad: a two-column `NavigationSplitView` whose leading
|
||
/// column carries the destinations + the project→session tree, and whose detail is the selected
|
||
/// session (or a destination view). Selection lives here and binds the same `openSessionID`
|
||
/// lifecycle the iPhone push/pop path uses — the detail is keyed by session id so switching one
|
||
/// session for another fires `open`/`closeOpen` exactly as a push/pop would.
|
||
struct SplitRootView: View {
|
||
@EnvironmentObject var store: RemoteStore
|
||
@State private var selection: SidebarItem? = .home
|
||
@State private var columnVisibility: NavigationSplitViewVisibility = .all
|
||
/// One-shot: a Cmd+N sets this, the Home composer consumes it to grab focus.
|
||
@State private var focusNewChat = false
|
||
|
||
var body: some View {
|
||
NavigationSplitView(columnVisibility: $columnVisibility) {
|
||
SplitSidebar(selection: $selection, focusNewChat: $focusNewChat)
|
||
} detail: {
|
||
SplitDetail(selection: selection, focusNewChat: $focusNewChat)
|
||
}
|
||
.navigationSplitViewStyle(.balanced)
|
||
.background { keyboardShortcuts }
|
||
// Notification tap → select its session directly (the regular-width analogue of the
|
||
// compact path's jump-to-Sessions-tab). Then clear the request so it isn't re-consumed.
|
||
.onChange(of: store.pendingRoute) { _, route in
|
||
guard let route else { return }
|
||
selection = .session(route)
|
||
store.pendingRoute = nil
|
||
}
|
||
// A Live Activity tap with work running but nothing waiting: reveal the sidebar, which
|
||
// always lists the live sessions (the regular-width analogue of the Sessions tab).
|
||
.onChange(of: store.pendingSessionsList) { _, show in
|
||
guard show else { return }
|
||
columnVisibility = .all
|
||
store.pendingSessionsList = false
|
||
}
|
||
.onAppear {
|
||
// Offline UI previews (NUCLEIC_DEMO): open straight into one session.
|
||
if let raw = ProcessInfo.processInfo.environment["NUCLEIC_DEMO_SESSION"], !raw.isEmpty {
|
||
selection = .session(SessionID(rawValue: raw))
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Hardware-keyboard shortcuts (Magic Keyboard on iPad), the remote's echo of the Mac's menu
|
||
/// commands: ⌘N new chat, ⌘1–4 jump to a destination, ⌘R refresh. Carried by hidden buttons —
|
||
/// SwiftUI still routes a shortcut to a zero-opacity button that's in the hierarchy.
|
||
private var keyboardShortcuts: some View {
|
||
Group {
|
||
Button("New chat") { selection = .home; focusNewChat = true }
|
||
.keyboardShortcut("n", modifiers: .command)
|
||
Button("Home") { selection = .home }.keyboardShortcut("1", modifiers: .command)
|
||
Button("Projects") { selection = .projects }.keyboardShortcut("2", modifiers: .command)
|
||
Button("To-dos") { selection = .todos }.keyboardShortcut("3", modifiers: .command)
|
||
Button("Settings") { selection = .settings }.keyboardShortcut("4", modifiers: .command)
|
||
Button("Refresh") { store.refreshSessions() }.keyboardShortcut("r", modifiers: .command)
|
||
}
|
||
.opacity(0)
|
||
.accessibilityHidden(true)
|
||
}
|
||
}
|
||
|
||
/// The leading column: global destinations, then the live sessions grouped under their projects
|
||
/// (attention-first within each group), mirroring the Mac sidebar tree. The persistent
|
||
/// connection status sits in the footer — one home for it, versus the phone's per-tab inset.
|
||
private struct SplitSidebar: View {
|
||
@EnvironmentObject var store: RemoteStore
|
||
@Binding var selection: SidebarItem?
|
||
var focusNewChat: Binding<Bool> = .constant(false)
|
||
|
||
/// Live sessions grouped under their project (dashboard order), with anything whose project
|
||
/// isn't in the dashboard collected into a trailing "Other" group. Attention-first sort.
|
||
private var projectGroups: [(name: String, rows: [WireSessionSummary])] {
|
||
let live = store.liveSessions.sorted(by: StatusStyle.attentionThenRecency)
|
||
var groups: [(name: String, rows: [WireSessionSummary])] = []
|
||
var placed = Set<SessionID>()
|
||
for project in store.dashboard.projects {
|
||
let rows = live.filter { $0.projectID == project.id.rawValue }
|
||
guard !rows.isEmpty else { continue }
|
||
groups.append((project.name, rows))
|
||
rows.forEach { placed.insert($0.sessionID) }
|
||
}
|
||
let orphans = live.filter { !placed.contains($0.sessionID) }
|
||
if !orphans.isEmpty { groups.append(("Other", orphans)) }
|
||
return groups
|
||
}
|
||
|
||
var body: some View {
|
||
List(selection: $selection) {
|
||
Section {
|
||
Label("Home", systemImage: "house").tag(SidebarItem.home)
|
||
Label("Projects", systemImage: "folder").tag(SidebarItem.projects)
|
||
Label("To-dos", systemImage: "checklist").tag(SidebarItem.todos)
|
||
Label("Settings", systemImage: "gearshape").tag(SidebarItem.settings)
|
||
}
|
||
if projectGroups.isEmpty {
|
||
Section("Sessions") {
|
||
Text(store.connectivity.isLive
|
||
? "Start a session from Home or the Mac to see it here."
|
||
: "Waiting to connect to your Mac…")
|
||
.font(.callout).foregroundStyle(.secondary)
|
||
}
|
||
} else {
|
||
ForEach(projectGroups, id: \.name) { group in
|
||
Section(group.name) {
|
||
ForEach(group.rows, id: \.sessionID) { summary in
|
||
SessionRow(summary: summary, showProjectName: false)
|
||
.tag(SidebarItem.session(summary.sessionID))
|
||
.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(.sidebar)
|
||
// Every Mac's sessions are merged into this sidebar (mesh P3), so a single host name would
|
||
// misrepresent it — title it for the app.
|
||
.navigationTitle("Nucleic")
|
||
.toolbar {
|
||
ToolbarItem(placement: .primaryAction) {
|
||
Button {
|
||
selection = .home
|
||
focusNewChat.wrappedValue = true
|
||
} label: {
|
||
Image(systemName: "square.and.pencil")
|
||
}
|
||
.help("New chat (⌘N)")
|
||
.disabled(!store.canControl)
|
||
}
|
||
ToolbarItem(placement: .primaryAction) {
|
||
Button { store.refreshSessions() } label: {
|
||
Image(systemName: "arrow.clockwise")
|
||
}
|
||
.help("Refresh (⌘R)")
|
||
}
|
||
}
|
||
.safeAreaInset(edge: .bottom) {
|
||
ConnectionChip().padding(.bottom, 10)
|
||
}
|
||
}
|
||
}
|
||
|
||
/// The detail column. A selected session reuses the existing `SessionDetailView` unchanged
|
||
/// (it owns its own subscribe/unsubscribe on appear/disappear); it's keyed `.id(sessionID)` so
|
||
/// switching sessions remounts and drives that lifecycle. The destination views already embed
|
||
/// their own `NavigationStack`, so they're shown directly.
|
||
private struct SplitDetail: View {
|
||
let selection: SidebarItem?
|
||
var focusNewChat: Binding<Bool> = .constant(false)
|
||
|
||
var body: some View {
|
||
switch selection {
|
||
case .home, .none:
|
||
HomeView(focusNewChat: focusNewChat)
|
||
case .projects:
|
||
ProjectsView()
|
||
case .todos:
|
||
TodosView()
|
||
case .settings:
|
||
SettingsView()
|
||
case .session(let id):
|
||
NavigationStack {
|
||
SessionDetailView(sessionID: id)
|
||
}
|
||
.id(id)
|
||
}
|
||
}
|
||
}
|