Nucleic-Session: EEC60FAE-8368-49CA-9202-7026C12E7F8C Co-authored-by: Nucleic <[email protected]>
424 lines
20 KiB
Swift
424 lines
20 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 iPhone shell: a five-tab `TabView` whose *system* tab bar is suppressed in favour of our
|
||
/// own `NucleicTabBar` (below). We draw the bar ourselves because SwiftUI's per-navigation
|
||
/// `.toolbar(.hidden, for: .tabBar)` restores the system bar late (~1s) and un-animated when you
|
||
/// pop back from a pushed `SessionDetailView` — long enough that the floating "+" renders low
|
||
/// (anchored to a bottom safe-area inset that hasn't grown yet) and is briefly untappable before
|
||
/// snapping up. Owning the bar lets us slide it out/in with a spring the instant a session opens
|
||
/// or closes, so the return is smooth and the "+" reflows in one coordinated animation.
|
||
///
|
||
/// A notification tap routes to the Sessions tab, where `SessionsView` consumes `pendingRoute`
|
||
/// and pushes the detail.
|
||
struct CompactRootView: View {
|
||
@EnvironmentObject var store: RemoteStore
|
||
@Environment(\.accessibilityReduceMotion) private var reduceMotion
|
||
// Initial tab; overridable via NUCLEIC_TAB for offline UI previews.
|
||
@State private var tab = Int(ProcessInfo.processInfo.environment["NUCLEIC_TAB"] ?? "") ?? 0
|
||
/// The shared "+" beside the tab bar opens the project-picker composer (the per-page discs are
|
||
/// gone on compact — this one bar owns "new chat").
|
||
@State private var showNewChat = false
|
||
/// Measured height of the floating bar, fed back into the bottom inset so tab content lays out
|
||
/// exactly above it (no guessing, no overlap).
|
||
@State private var barHeight: CGFloat = 72
|
||
|
||
/// The bar owns the bottom edge on the top-level tabs, but yields it to a pushed session's
|
||
/// floating glass chat bar (`openSessionID`, set by `SessionDetailView`'s appear/disappear) and
|
||
/// to the new-chat composer while it's up (`showNewChat`) — both slide it out, so the composer
|
||
/// doesn't just stack on top of a lingering tab bar.
|
||
private var showsTabBar: Bool { store.openSessionID == nil && !showNewChat }
|
||
|
||
/// The "+" is inert without control scope or a project to start in, so it's shown only when a
|
||
/// chat could actually be kicked off (mirrors `NewChatAffordance.canStart`).
|
||
private var canStartChat: Bool { store.canControl && !store.dashboard.projects.isEmpty }
|
||
|
||
/// Height reserved under each tab's content for the floating bar — the measured bar height when
|
||
/// it's up, nothing when a session or the composer hides it (so those run full-bleed).
|
||
private var contentInset: CGFloat { showsTabBar ? barHeight : 0 }
|
||
|
||
var body: some View {
|
||
TabView(selection: $tab) {
|
||
// The inset is applied per tab (not to the `TabView`): a bottom `safeAreaInset` on the
|
||
// container does NOT propagate into the tab pages' scroll content, so the list would run
|
||
// under the bar. On each tab's `NavigationStack` it reaches the scroll content and pushes
|
||
// it up above the bar.
|
||
HomeView().modifier(TabBarInset(height: contentInset)).tag(0)
|
||
SessionsView().modifier(TabBarInset(height: contentInset)).tag(1)
|
||
ProjectsView().modifier(TabBarInset(height: contentInset)).tag(2)
|
||
SettingsView().modifier(TabBarInset(height: contentInset)).tag(3)
|
||
}
|
||
// The bar itself lives in an always-mounted overlay and slides on `offset`, so it travels
|
||
// continuously off the bottom of the screen (a fixed distance past the home-indicator curve)
|
||
// instead of a transition that removes it mid-descent and makes it pop.
|
||
.overlay(alignment: .bottom) {
|
||
bottomBar
|
||
.background(
|
||
GeometryReader { geo in
|
||
Color.clear.preference(key: BarHeightKey.self, value: geo.size.height)
|
||
})
|
||
.onPreferenceChange(BarHeightKey.self) { barHeight = $0 }
|
||
.offset(y: showsTabBar ? 0 : barHeight + 120)
|
||
}
|
||
.animation(reduceMotion ? nil : .spring(response: 0.38, dampingFraction: 0.9),
|
||
value: showsTabBar)
|
||
// The project-picker composer, presented for the shared "+" (no per-page disc on compact).
|
||
.newChatAffordance(isPresented: $showNewChat, showsButton: false)
|
||
// 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 }
|
||
}
|
||
}
|
||
|
||
/// The floating bottom bar: the tab capsule, with the "+" disc riding to its right in the width
|
||
/// freed by dropping the To-dos tab (the iOS 26 tab-bar-plus-accessory shape).
|
||
private var bottomBar: some View {
|
||
HStack(spacing: 10) {
|
||
NucleicTabBar(selection: $tab, needsYou: store.needsYouCount)
|
||
if canStartChat {
|
||
Button { showNewChat = true } label: {
|
||
Image(systemName: "plus")
|
||
.font(.system(size: 22, weight: .semibold))
|
||
.foregroundStyle(Palette.accent)
|
||
.frame(width: 56, height: 56)
|
||
.contentShape(Circle())
|
||
.glassCircle()
|
||
}
|
||
.buttonStyle(.plain)
|
||
.accessibilityLabel("New chat")
|
||
}
|
||
}
|
||
.padding(.horizontal, 16)
|
||
.padding(.bottom, 4)
|
||
}
|
||
}
|
||
|
||
/// A preference carrying the floating bar's measured height up to `CompactRootView`, which feeds it
|
||
/// back into each tab's bottom inset so content sits exactly above the bar.
|
||
private struct BarHeightKey: PreferenceKey {
|
||
static var defaultValue: CGFloat = 72
|
||
static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) { value = nextValue() }
|
||
}
|
||
|
||
/// Per-tab chrome for the compact shell: suppress the system tab bar (we draw our own) and reserve
|
||
/// `height` at the bottom of this tab's content so its scroll view lays out above the floating bar.
|
||
/// Applied to each tab rather than the `TabView`, whose container-level inset doesn't reach the tab
|
||
/// pages' scroll content.
|
||
private struct TabBarInset: ViewModifier {
|
||
let height: CGFloat
|
||
func body(content: Content) -> some View {
|
||
content
|
||
.toolbar(.hidden, for: .tabBar)
|
||
.safeAreaInset(edge: .bottom, spacing: 0) {
|
||
Color.clear.frame(height: height)
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Our own bottom tab bar for the compact shell — a floating Liquid Glass capsule of four items
|
||
/// (Home, Sessions, Projects, Settings), standing in for the system tab bar so `CompactRootView`
|
||
/// can animate its visibility itself (see that view for why) while keeping the iOS 26 floating-glass
|
||
/// look. Real `glassEffect` on iOS 26; an ultra-thin material capsule with a hairline edge + soft
|
||
/// shadow on earlier systems, matching the app's `glassSurface`/`glassCircle`. The Sessions item
|
||
/// carries the "needs you" badge the system tab item used to.
|
||
struct NucleicTabBar: View {
|
||
@Binding var selection: Int
|
||
/// Sessions blocking on a human — the red badge on the Sessions item (was the tab item badge).
|
||
var needsYou: Int
|
||
|
||
private struct Item { let index: Int; let title: String; let icon: String }
|
||
private let items = [
|
||
Item(index: 0, title: "Home", icon: "house"),
|
||
Item(index: 1, title: "Sessions", icon: "square.stack.3d.up"),
|
||
Item(index: 2, title: "Projects", icon: "folder"),
|
||
Item(index: 3, title: "Settings", icon: "gearshape"),
|
||
]
|
||
|
||
var body: some View {
|
||
HStack(spacing: 0) {
|
||
ForEach(items, id: \.index) { item in
|
||
itemButton(item)
|
||
}
|
||
}
|
||
.padding(.horizontal, 6)
|
||
.padding(.vertical, 8)
|
||
.glassBar()
|
||
}
|
||
|
||
private func itemButton(_ item: Item) -> some View {
|
||
let selected = selection == item.index
|
||
return Button {
|
||
selection = item.index
|
||
} label: {
|
||
VStack(spacing: 3) {
|
||
Image(systemName: item.icon)
|
||
.font(.system(size: 21, weight: selected ? .semibold : .regular))
|
||
.frame(height: 24)
|
||
.overlay(alignment: .topTrailing) {
|
||
if item.index == 1, needsYou > 0 {
|
||
Text(needsYou > 99 ? "99+" : "\(needsYou)")
|
||
.font(.system(size: 11, weight: .bold))
|
||
.foregroundStyle(.white)
|
||
.padding(.horizontal, 5).padding(.vertical, 1)
|
||
.background(Palette.attention, in: Capsule())
|
||
.offset(x: 14, y: -6)
|
||
.fixedSize()
|
||
}
|
||
}
|
||
Text(item.title).font(.system(size: 10, weight: .medium))
|
||
}
|
||
.foregroundStyle(selected ? Palette.accent : Color.secondary)
|
||
.frame(maxWidth: .infinity)
|
||
.padding(.vertical, 4)
|
||
// The selected item rides a soft accent-tinted capsule, echoing the system floating
|
||
// bar's highlighted tab.
|
||
.background {
|
||
if selected {
|
||
Capsule().fill(Palette.accent.opacity(0.15))
|
||
}
|
||
}
|
||
.contentShape(Rectangle())
|
||
}
|
||
.buttonStyle(.plain)
|
||
.accessibilityLabel(item.title)
|
||
.accessibilityAddTraits(selected ? [.isButton, .isSelected] : .isButton)
|
||
}
|
||
}
|
||
|
||
private extension View {
|
||
/// The floating tab bar's Liquid Glass capsule backing — the capsule twin of `glassSurface`.
|
||
/// Real `glassEffect` on iOS 26; an ultra-thin material capsule with a hairline edge and soft
|
||
/// shadow on earlier systems so it still lifts off the content.
|
||
@ViewBuilder
|
||
func glassBar() -> some View {
|
||
if #available(iOS 26.0, *) {
|
||
glassEffect(.regular, in: .capsule)
|
||
} else {
|
||
background(.ultraThinMaterial, in: Capsule())
|
||
.overlay(Capsule().strokeBorder(Color.primary.opacity(0.08), lineWidth: 1))
|
||
.shadow(color: .black.opacity(0.18), radius: 10, y: 3)
|
||
}
|
||
}
|
||
}
|
||
|
||
// 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)")
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/// 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)
|
||
}
|
||
}
|
||
}
|