nvrsion: Add native Liquid Glass tab bar, revert to custom bar, add sliding bubble indicator, and fix Auto Mode Logic for nucleic control projects.

Nucleic-Promote: 1
Co-authored-by: Nucleic <[email protected]>
This commit is contained in:
2026-07-05 23:42:27 -07:00
co-authored by Nucleic
parent ac44f32945
commit 8f1e117f6d
3 changed files with 96 additions and 56 deletions
@@ -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<Bool>, 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())
@@ -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.")
}
}
@@ -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 {