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)) } } diff --git a/NucleicRemote/NucleicRemote/Views/HomeView.swift b/NucleicRemote/NucleicRemote/Views/HomeView.swift index dfab55d..b9b4487 100644 --- a/NucleicRemote/NucleicRemote/Views/HomeView.swift +++ b/NucleicRemote/NucleicRemote/Views/HomeView.swift @@ -6,8 +6,9 @@ import NucleicProtocol /// start-chat composer (control scope). struct HomeView: View { @EnvironmentObject var store: RemoteStore - /// Set by the iPad `Cmd+N` shortcut to focus the start-chat composer below (consumed there). + /// Set by the iPad `Cmd+N` shortcut; opens the new-chat sheet (consumed here). var focusNewChat: Binding = .constant(false) + @State private var showNewChat = false private var counts: DashboardCounts { store.dashboard.counts } @@ -95,8 +96,6 @@ struct HomeView: View { if !running.isEmpty { InProgressSessions(sessions: running) } QuickTodos() - - StartChatComposer(focusNewChat: focusNewChat) } .padding() // Cap + center the dashboard on a wide iPad so it reads like the Mac home rather @@ -107,6 +106,12 @@ struct HomeView: View { .navigationBarTitleDisplayMode(.inline) .scrollDismissesKeyboard(.interactively) .refreshable { store.refreshSessions() } + // A floating "+" replaces the old inline start-chat card; opens the glass composer + // sheet. An iPad ⌘N (via focusNewChat) opens the same sheet. + .newChatAffordance(isPresented: $showNewChat) + .onChange(of: focusNewChat.wrappedValue) { _, want in + if want { showNewChat = true; focusNewChat.wrappedValue = false } + } .safeAreaInset(edge: .bottom) { ConnectionChip().padding(.bottom, 8) } } } diff --git a/NucleicRemote/NucleicRemote/Views/ProjectsView.swift b/NucleicRemote/NucleicRemote/Views/ProjectsView.swift index 0ad0cfc..26d2c7c 100644 --- a/NucleicRemote/NucleicRemote/Views/ProjectsView.swift +++ b/NucleicRemote/NucleicRemote/Views/ProjectsView.swift @@ -46,9 +46,7 @@ struct ProjectsView: View { struct ProjectDetailView: View { @EnvironmentObject var store: RemoteStore let project: WireProject - @State private var draft = "" - @State private var model: String? - @State private var effort = MobileEfforts.fallback + @State private var showNewChat = false private var sessions: [WireSessionSummary] { store.liveSessions @@ -58,27 +56,6 @@ struct ProjectDetailView: View { var body: some View { List { - Section { - VStack(alignment: .leading, spacing: 8) { - HStack { - ModelMenu(model: $model, catalog: store.modelCatalog, backend: nil) - Spacer() - EffortMenu(effort: $effort, catalog: store.modelCatalog, modelSKU: model, - controlled: project.isNucleicControlled) - } - HStack(alignment: .bottom, spacing: 8) { - TextField("Start a chat in \(project.name)…", text: $draft, axis: .vertical) - .textFieldStyle(.roundedBorder).lineLimit(1...4) - .orchestraGlow(active: MobileEfforts.isOrchestra(effort) && project.isNucleicControlled) - .keyboardDismissable() - Button { - store.startChat(in: project.id, message: draft, model: model, effort: effort) - draft = "" - } label: { Image(systemName: "arrow.up.circle.fill").font(.title2) } - .disabled(draft.trimmingCharacters(in: .whitespaces).isEmpty || !store.canControl) - } - } - } Section("Sessions") { if sessions.isEmpty { Text("No sessions yet.").foregroundStyle(.secondary) @@ -93,12 +70,8 @@ struct ProjectDetailView: View { .navigationTitle(project.name) .navigationBarTitleDisplayMode(.inline) .scrollDismissesKeyboard(.interactively) - .onChange(of: model) { _, newModel in - let levels = store.modelCatalog.offeredEfforts(forModel: newModel) - let sentinel = store.modelCatalog.orchestraSentinelOrFallback - if effort.caseInsensitiveCompare(sentinel) != .orderedSame, !levels.contains(effort) { - effort = levels.last ?? MobileEfforts.fallback - } - } + // A floating "+" scoped to this project — the glass composer sheet opens pre-locked to it, + // replacing the old inline start-chat card at the top of the page. + .newChatAffordance(project: project, isPresented: $showNewChat) } } diff --git a/NucleicRemote/NucleicRemote/Views/SessionsView.swift b/NucleicRemote/NucleicRemote/Views/SessionsView.swift index 5597625..d6c9449 100644 --- a/NucleicRemote/NucleicRemote/Views/SessionsView.swift +++ b/NucleicRemote/NucleicRemote/Views/SessionsView.swift @@ -9,10 +9,6 @@ struct SessionsView: View { @State private var showNewChat = false @State private var path = NavigationPath() - /// The floating New-chat button only makes sense when this device can control a Mac and there's - /// at least one project to start a chat in; otherwise the composer it opens would be inert. - private var canStartChat: Bool { store.canControl && !store.dashboard.projects.isEmpty } - private var grouped: [(title: String, rows: [WireSessionSummary])] { let pool = (showArchived ? store.sessions : store.liveSessions) .sorted(by: StatusStyle.attentionThenRecency) @@ -90,52 +86,13 @@ struct SessionsView: View { } } // A floating "new chat" affordance in the lower-right, mirroring the Mac's per-project - // "New chat" button. Picks a project + prompt in a sheet, then kicks off the chat. - .overlay(alignment: .bottomTrailing) { - if canStartChat { newChatButton } - } - .sheet(isPresented: $showNewChat) { - NavigationStack { - ScrollView { - StartChatComposer(onStart: { showNewChat = false }) - .padding() - } - .navigationTitle("New chat") - .navigationBarTitleDisplayMode(.inline) - .toolbar { - ToolbarItem(placement: .topBarLeading) { - Button("Cancel") { showNewChat = false } - } - } - } - // 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) - } + // "New chat" button. Picks a project + prompt in a glass sheet, then kicks off the chat. + // Applied before the chip inset so it floats clear above the connection chip. + .newChatAffordance(isPresented: $showNewChat) .safeAreaInset(edge: .bottom) { ConnectionChip().padding(.bottom, 8) } } } - /// The circular plus button, on Liquid Glass rather than a solid teal disc — an accent-tinted - /// glyph over a translucent glass circle so it reads as a floating control, not a stamped - /// button. Sits low over the list, clear of the connection chip. - private var newChatButton: some View { - Button { - showNewChat = true - } label: { - Image(systemName: "plus") - .font(.system(size: 22, weight: .semibold)) - .foregroundStyle(Palette.accent) - .frame(width: 56, height: 56) - .glassCircle() - } - .buttonStyle(.plain) - .padding(.trailing, 20) - .padding(.bottom, 6) - .accessibilityLabel("New chat") - } - /// Push the session a notification tap asked for, then clear the request. private func consumeRoute() { guard let route = store.pendingRoute else { return }