diff --git a/NucleicRemote/NucleicRemote/Views/Composer.swift b/NucleicRemote/NucleicRemote/Views/Composer.swift index 24fa52f..0e8da3f 100644 --- a/NucleicRemote/NucleicRemote/Views/Composer.swift +++ b/NucleicRemote/NucleicRemote/Views/Composer.swift @@ -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 = .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,21 +24,27 @@ 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 { - Menu { - ForEach(projects) { project in - Button(project.name) { projectID = project.id } + 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) } - } label: { - 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) { @@ -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() } + } +} + +/// 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) } - private func consumeFocusRequest() { - guard focusNewChat.wrappedValue else { return } - if projectID == nil { projectID = store.dashboard.projects.first?.id } - draftFocused = true - focusNewChat.wrappedValue = false + 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) -> some View { + modifier(NewChatAffordance(project: project, isPresented: isPresented)) } }