diff --git a/NucleicRemote/NucleicRemote/Views/AdaptiveRootView.swift b/NucleicRemote/NucleicRemote/Views/AdaptiveRootView.swift index f248d0a..753e2fd 100644 --- a/NucleicRemote/NucleicRemote/Views/AdaptiveRootView.swift +++ b/NucleicRemote/NucleicRemote/Views/AdaptiveRootView.swift @@ -23,42 +23,76 @@ struct AdaptiveRootView: View { // MARK: - Compact (iPhone / narrow multitasking) -/// The iPhone shell: a four-tab `TabView` using SwiftUI's *native* tab bar — on iOS 26 that's the -/// Liquid Glass bar with the animated selection indicator ("bubble slider"). +/// The iPhone shell: a four-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. (Confirmed still true on iOS 26's native Liquid Glass bar — a "go native" experiment +/// reproduced exactly this ~1s un-animated restore, so owning the bar remains the right call.) +/// 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 — and lets the "+" ride +/// beside the tabs, which the native bar can't host. `NucleicTabBar` draws its own animated +/// selection "bubble" so we keep the native look the experiment was after. /// -/// **This is the "go native" experiment.** We previously drew the bar ourselves (`NucleicTabBar`, -/// still defined below) because `.toolbar(.hidden, for: .tabBar)` used to restore the system bar -/// late (~1s) and un-animated when popping back from a pushed `SessionDetailView`. We're now testing -/// whether iOS 26's native bar restores smoothly on pop-back: the pushed `SessionDetailView` hides -/// the bar via `.toolbar(.hidden, for: .tabBar)` (see `SessionsView`), and we watch the return for -/// the old jank. To revert, swap this body back to the `NucleicTabBar` overlay below. -/// -/// The "New Chat" affordance rides in the iOS 26 `tabViewBottomAccessory` (a floating "+" on -/// earlier systems). A notification tap routes to the Sessions tab, where `SessionsView` consumes -/// `pendingRoute` and pushes the detail. +/// 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 "New Chat" entry opens the project-picker composer (the per-page discs are gone on - /// compact — this one entry owns "new chat"). + /// 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 (`compactDetailPresented`, driven by `SessionsView`'s navigation + /// path) 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. We key off the path rather than + /// `openSessionID` because the path flips the instant a back-swipe begins, whereas + /// `openSessionID` isn't cleared until the detail's `onDisappear` fires at the *end* of the pop + /// transition — which left the bar sliding back up 1–2s late. + private var showsTabBar: Bool { !store.compactDetailPresented && !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) { - Tab("Home", systemImage: "house", value: 0) { HomeView() } - Tab("Sessions", systemImage: "square.stack.3d.up", value: 1) { SessionsView() } - // The "needs you" badge the custom bar carried, back on the native tab item. - .badge(store.needsYouCount) - Tab("Projects", systemImage: "folder", value: 2) { ProjectsView() } - Tab("Settings", systemImage: "gearshape", value: 3) { SettingsView() } + HomeView().toolbar(.hidden, for: .tabBar).tag(0) + SessionsView().toolbar(.hidden, for: .tabBar).tag(1) + ProjectsView().toolbar(.hidden, for: .tabBar).tag(2) + SettingsView().toolbar(.hidden, for: .tabBar).tag(3) } - .newChatEntry(show: $showNewChat, enabled: canStartChat) - // The project-picker composer, presented for the shared "New Chat" entry. + // Each tab's scroll view reads this and reserves room for the floating bar directly on + // itself (via `.compactTabBarInset()`). Neither a `safeAreaInset` nor `contentMargins` on + // the tab root reaches the pages' scroll content through the `TabView`, so it must be applied + // to the `List`/`ScrollView` itself. Zero (its default) on iPad, which has no bar. + .environment(\.compactTabBarInset, contentInset) + // 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 @@ -69,30 +103,14 @@ struct CompactRootView: View { if show { tab = 1; store.pendingSessionsList = false } } } -} -private extension View { - /// Hangs the "New Chat" entry off the tab bar: on iOS 26 as a native `tabViewBottomAccessory` - /// (a pill that morphs with the Liquid Glass bar), and on earlier systems as a floating glass - /// "+" above the bar. A no-op when a chat can't be started. - @ViewBuilder - func newChatEntry(show: Binding, enabled: Bool) -> some View { - if !enabled { - self - } else if #available(iOS 26.0, *) { - self.tabViewBottomAccessory { - Button { show.wrappedValue = true } label: { - Label("New Chat", systemImage: "plus") - .font(.body.weight(.semibold)) - .frame(maxWidth: .infinity) - } - .buttonStyle(.plain) - .foregroundStyle(Palette.accent) - .accessibilityLabel("New chat") - } - } else { - self.overlay(alignment: .bottomTrailing) { - Button { show.wrappedValue = true } label: { + /// 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) @@ -101,14 +119,21 @@ private extension View { .glassCircle() } .buttonStyle(.plain) - .padding(.trailing, 16) - .padding(.bottom, 70) // clear the native tab bar .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() } +} + /// Height the compact shell's floating tab bar wants reserved at the bottom of a tab's scroll /// content, published down the environment by `CompactRootView`. Zero on iPad (no bar). private struct CompactTabBarInsetKey: EnvironmentKey { @@ -150,6 +175,10 @@ 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 + @Environment(\.accessibilityReduceMotion) private var reduceMotion + /// Ties every item's selected-capsule to one shared geometry, so the "bubble" interpolates its + /// frame from the old tab to the new one instead of cross-fading — the native bar's slide. + @Namespace private var bubble private struct Item { let index: Int; let title: String; let icon: String } private let items = [ @@ -168,6 +197,10 @@ struct NucleicTabBar: View { .padding(.horizontal, 6) .padding(.vertical, 8) .glassBar() + // Drive the bubble's slide (and the label/tint crossfade) off the selection itself, so any + // change — tap here or a programmatic jump from a notification — springs across. + .animation(reduceMotion ? nil : .spring(response: 0.32, dampingFraction: 0.78), + value: selection) } private func itemButton(_ item: Item) -> some View { @@ -195,11 +228,14 @@ struct NucleicTabBar: View { .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. + // The selected item rides a soft accent-tinted capsule that slides between tabs — only + // the selected item mounts it, and the shared `bubble` geometry makes the single capsule + // travel to the new tab, echoing the system floating bar's highlighted-tab indicator. .background { if selected { - Capsule().fill(Palette.accent.opacity(0.15)) + Capsule() + .fill(Palette.accent.opacity(0.15)) + .matchedGeometryEffect(id: "selectionBubble", in: bubble) } } .contentShape(Rectangle()) diff --git a/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift b/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift index 82258ae..33e4355 100644 --- a/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift +++ b/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift @@ -131,10 +131,18 @@ struct SessionDetailView: View { .font(.caption2.weight(.semibold)).foregroundStyle(.primary) .help("Context window used") } - Toggle(isOn: Binding(get: { summary.auto }, set: { store.setSessionAuto(sessionID, $0) })) { - Label("Auto", systemImage: summary.auto ? "bolt.fill" : "bolt.slash") + // Locked on for a Nucleic Control project — those chats always run autonomously, and + // the host keeps Auto on regardless of what the phone sends, so show it locked on to + // match rather than offering a toggle that wouldn't take. + Toggle(isOn: Binding(get: { controlled || summary.auto }, + set: { store.setSessionAuto(sessionID, $0) })) { + Label("Auto", systemImage: (controlled || summary.auto) ? "bolt.fill" : "bolt.slash") } .toggleStyle(.button).tint(Palette.accent).font(.caption2.weight(.semibold)) + .disabled(controlled) + .help(controlled + ? "Auto-approve is always on for Nucleic Control chats — they run autonomously." + : "Auto-approve safe actions; destructive ones still ask.") } } diff --git a/NucleicRemote/NucleicRemote/Views/SessionsView.swift b/NucleicRemote/NucleicRemote/Views/SessionsView.swift index 9772ef6..4389191 100644 --- a/NucleicRemote/NucleicRemote/Views/SessionsView.swift +++ b/NucleicRemote/NucleicRemote/Views/SessionsView.swift @@ -82,10 +82,6 @@ struct SessionsView: View { .navigationTitle("Sessions") .navigationDestination(for: SessionID.self) { id in SessionDetailView(sessionID: id) - // Native-tab-bar experiment: hide the system bar on the pushed detail so the - // full-bleed chat owns the bottom edge. Whether it restores smoothly on pop-back - // (vs. the old ~1s un-animated snap) is exactly what we're testing here. - .toolbar(.hidden, for: .tabBar) } // Offline UI previews (with NUCLEIC_DEMO): jump straight into one session. .onAppear {