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 = .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 { let a = StatusStyle.sortRank($0), b = StatusStyle.sortRank($1) return a == b ? $0.updatedAt > $1.updatedAt : a < b } var groups: [(name: String, rows: [WireSessionSummary])] = [] var placed = Set() 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 = .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) } } }