Bottom Tab Bar Visibility Adjustment

Nucleic-Session: 747527BF-AEE9-4939-BE70-A14F1F3BBBA8
Co-authored-by: Nucleic <[email protected]>
This commit is contained in:
2026-07-05 17:43:18 -07:00
co-authored by Nucleic
parent 4a15632956
commit d772b92723
@@ -1,17 +1,16 @@
import SwiftUI
import NucleicProtocol
/// Start a new chat in a project, straight from Home (control scope — mirrors the Mac's home
/// composer). Picks a project, types a prompt, optionally toggles Auto.
/// Start a new chat, presented in the `NewChatSheet` that the floating "+" opens. Styled as the
/// same Liquid Glass composer bar the open session carries, for consistency: picks a project (or
/// is scoped to one), types a prompt, optionally toggles Auto.
struct StartChatComposer: View {
@EnvironmentObject var store: RemoteStore
/// A one-shot request from the iPad `Cmd+N` shortcut to focus the prompt so a keyboard user can
/// start typing a new chat immediately. Consumed (reset to false) once handled, so it never
/// steals focus on a later Home appearance. `.constant(false)` for the iPhone/compact path.
var focusNewChat: Binding<Bool> = .constant(false)
/// Called after a chat is kicked off, so a host that presents the composer modally (e.g. the
/// Sessions tab's floating New-chat button) can dismiss itself. Home leaves it nil and stays put.
/// Called after a chat is kicked off, so the presenting sheet can dismiss itself.
var onStart: (() -> Void)? = nil
/// When set, the composer is scoped to this project and the picker is hidden — used by a
/// project page's "+", so the chat always lands in that project.
var lockedProject: WireProject? = nil
@State private var projectID: ProjectID?
@State private var draft = ""
@State private var auto = false
@@ -25,22 +24,28 @@ struct StartChatComposer: View {
private var projects: [WireProject] { store.dashboard.projects }
private var selected: WireProject? {
projects.first { $0.id == projectID } ?? projects.first
lockedProject ?? (projects.first { $0.id == projectID } ?? projects.first)
}
private var controlled: Bool { selected?.isNucleicControlled ?? false }
var body: some View {
VStack(alignment: .leading, spacing: 10) {
Text("New chat").font(.headline)
HStack {
if lockedProject == nil {
Menu {
ForEach(projects) { project in
Button(project.name) { projectID = project.id }
}
} label: {
// The model also selects the backend, so the picker offers every project.
Label(selected?.name ?? "Select a project", systemImage: "folder")
.font(.subheadline)
}
} else {
// Scoped to this project's page — show it as context, not a picker.
Label(selected?.name ?? "", systemImage: "folder")
.font(.subheadline).foregroundStyle(.secondary)
}
Spacer()
Toggle(isOn: $auto) {
Label("Auto", systemImage: auto ? "bolt.fill" : "bolt.slash")
@@ -50,7 +55,6 @@ struct StartChatComposer: View {
.font(.caption)
}
HStack {
// The model also selects the backend, so the home composer offers every provider.
ModelMenu(model: $model, catalog: store.modelCatalog, backend: nil)
Spacer()
EffortMenu(effort: $effort, catalog: store.modelCatalog, modelSKU: model, controlled: controlled)
@@ -82,9 +86,12 @@ struct StartChatComposer: View {
.transition(.move(edge: .top).combined(with: .opacity))
}
HStack(alignment: .bottom, spacing: 8) {
// Plain, borderless field over the glass — matching the open session's chat bar
// rather than a boxed form control.
TextField("Describe a task…", text: $draft, axis: .vertical)
.textFieldStyle(.roundedBorder)
.textFieldStyle(.plain)
.lineLimit(1...5)
.padding(.vertical, 3)
.orchestraGlow(active: MobileEfforts.isOrchestra(effort) && controlled)
.keyboardDismissable()
.focused($draftFocused)
@@ -108,7 +115,9 @@ struct StartChatComposer: View {
Text("This device is view-only.").font(.caption2).foregroundStyle(.secondary)
}
}
.card()
.padding(.horizontal, 14)
.padding(.vertical, 12)
.glassSurface(cornerRadius: 26)
// Switching to a model with a lower effort cap can't leave an unsupported level selected.
.onChange(of: model) { _, newModel in
let levels = store.modelCatalog.offeredEfforts(forModel: newModel)
@@ -117,18 +126,84 @@ struct StartChatComposer: View {
effort = levels.last ?? MobileEfforts.fallback
}
}
// A Cmd+N (iPad) focuses the prompt so the keyboard user can type a new chat immediately.
// onAppear handles the case where Cmd+N switched to Home from a session (composer just
// mounted); onChange handles Cmd+N while Home is already on screen. Consumed either way.
.onAppear { consumeFocusRequest() }
.onChange(of: focusNewChat.wrappedValue) { consumeFocusRequest() }
}
}
private func consumeFocusRequest() {
guard focusNewChat.wrappedValue else { return }
if projectID == nil { projectID = store.dashboard.projects.first?.id }
draftFocused = true
focusNewChat.wrappedValue = false
/// The sheet the floating "+" opens: the glass `StartChatComposer` in a compact panel. `project`
/// scopes it to one project (a project page's "+"); nil offers the picker (Home, Sessions).
struct NewChatSheet: View {
var project: WireProject? = nil
var dismiss: () -> Void
var body: some View {
NavigationStack {
ScrollView {
StartChatComposer(onStart: dismiss, lockedProject: project)
.padding()
}
.navigationTitle("New chat")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .topBarLeading) {
Button("Cancel") { dismiss() }
}
}
}
// The composer needs only a fraction of the screen — a compact height by default, still
// expandable if the advanced options are opened.
.presentationDetents([.height(360), .large])
.presentationDragIndicator(.visible)
}
}
/// The floating "+" start-chat affordance: a Liquid Glass circle in the lower-right that opens the
/// `NewChatSheet`. Shared by Home, Sessions, and a project page (which scopes it to `project`), so
/// all three offer the same control instead of an inline composer card.
struct NewChatAffordance: ViewModifier {
@EnvironmentObject var store: RemoteStore
/// Scopes the composer to one project (project page); nil offers the picker (Home, Sessions).
var project: WireProject? = nil
/// Owned by the caller so it can also open the sheet programmatically (iPad ⌘N on Home).
@Binding var isPresented: Bool
/// The button only makes sense when this device can control a Mac and there's a project to
/// start a chat in; otherwise the composer it opens would be inert.
private var canStart: Bool {
store.canControl && (project != nil || !store.dashboard.projects.isEmpty)
}
func body(content: Content) -> some View {
content
.overlay(alignment: .bottomTrailing) {
if canStart {
Button { isPresented = true } label: {
Image(systemName: "plus")
.font(.system(size: 22, weight: .semibold))
.foregroundStyle(Palette.accent)
.frame(width: 56, height: 56)
// The whole disc is the hit target — without this, taps on the
// translucent glass outside the glyph fall through to the list row
// behind the button.
.contentShape(Circle())
.glassCircle()
}
.buttonStyle(.plain)
.padding(.trailing, 20)
.padding(.bottom, 6)
.accessibilityLabel("New chat")
}
}
.sheet(isPresented: $isPresented) {
NewChatSheet(project: project) { isPresented = false }
}
}
}
extension View {
/// Add the floating "+" start-chat affordance (glass circle → glass composer sheet). See
/// `NewChatAffordance`.
func newChatAffordance(project: WireProject? = nil, isPresented: Binding<Bool>) -> some View {
modifier(NewChatAffordance(project: project, isPresented: isPresented))
}
}