import SwiftUI import NucleicProtocol /// Start a new chat, revealed as a floating bar when the "+" is tapped (see `NewChatAffordance`). /// 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 /// The prompt text, owned by `NewChatAffordance` so it can coordinate auto-focus and the /// close-on-empty-dismiss behaviour with the composer. @Binding var draft: String /// The prompt field's focus, likewise owned by the affordance (auto-focused on open, resigned /// to dismiss the keyboard). var focus: FocusState.Binding /// Called after a chat is kicked off, so the affordance can dismiss the composer. 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 autoOverride: Bool? @State private var model: String? @State private var effort = MobileEfforts.fallback // Advanced options (the Mac new-session sheet's base branch + worktree fields). @State private var showOptions = false @State private var baseBranch = "" @State private var useWorktree = true private var projects: [WireProject] { store.dashboard.projects } /// The picker defaults to "No project" (nil) rather than auto-selecting the first project, so a /// typed prompt never quietly starts an agent in a repo the user didn't choose — they pick one /// explicitly before the send button lights. A project page's composer stays scoped via /// `lockedProject`. private var selected: WireProject? { lockedProject ?? projects.first { $0.id == projectID } } private var controlled: Bool { selected?.isNucleicControlled ?? false } /// Auto-approve for the chat about to start: the user's choice off-control, but forced on for a /// Nucleic Control project. The host locks Auto on for those chats regardless of what the phone /// sends (they run autonomously — nvrsion needs it: see `AppStore.createSession`), so the /// composer shows it locked on to match the Mac rather than offering a toggle that wouldn't take. private var effectiveAuto: Bool { controlled || (autoOverride ?? false) } var body: some View { VStack(alignment: .leading, spacing: 10) { HStack { if lockedProject == nil { Menu { Button("No project") { projectID = nil } if !projects.isEmpty { Divider() } ForEach(projects) { project in Button(project.name) { projectID = project.id } } } label: { // The model also selects the backend, so the picker offers every project. // Defaults to "No project" until one is chosen. Label(selected?.name ?? "No 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: Binding(get: { effectiveAuto }, set: { autoOverride = $0 })) { Label("Auto", systemImage: effectiveAuto ? "bolt.fill" : "bolt.slash") } .toggleStyle(.button) .tint(Palette.accent) .font(.caption) // Locked on for a Nucleic Control project — those chats always run autonomously. .disabled(controlled) .help(controlled ? "Auto-approve is always on for Nucleic Control chats — they run autonomously." : "Auto-approve safe actions; destructive ones still ask.") } HStack { ModelMenu(model: $model, catalog: store.modelCatalog, backend: nil) Spacer() EffortMenu(effort: $effort, catalog: store.modelCatalog, modelSKU: model, controlled: controlled) Button { withAnimation(.easeInOut(duration: 0.18)) { showOptions.toggle() } } label: { Image(systemName: showOptions ? "chevron.up.circle" : "ellipsis.circle") .font(.caption) .foregroundStyle(Palette.accent) } .buttonStyle(.plain) } if showOptions { VStack(alignment: .leading, spacing: 8) { HStack(spacing: 8) { Image(systemName: "arrow.triangle.branch").foregroundStyle(.secondary) TextField(selected?.defaultBranch ?? "Base branch", text: $baseBranch) .textInputAutocapitalization(.never) .autocorrectionDisabled() .textFieldStyle(.roundedBorder) .font(.caption) } Toggle(isOn: $useWorktree) { Label("Run in a worktree", systemImage: "square.stack.3d.up") .font(.caption) } .tint(Palette.accent) } .transition(.move(edge: .top).combined(with: .opacity)) } HStack(alignment: .bottom, spacing: 8) { // Plain, borderless field — matching the open session's chat bar rather than a // boxed form control. TextField("Describe a task…", text: $draft, axis: .vertical) .textFieldStyle(.plain) .lineLimit(1...5) .padding(.vertical, 3) .orchestraGlow(active: MobileEfforts.isOrchestra(effort) && controlled) .focused(focus) Button { if let project = selected { let branch = baseBranch.trimmingCharacters(in: .whitespaces) store.startChat( in: project.id, message: draft, model: model, effort: effort, baseBranch: branch.isEmpty ? nil : branch, useWorktree: useWorktree, auto: effectiveAuto) draft = "" onStart?() } } label: { Image(systemName: "arrow.up.circle.fill").font(.title) } .disabled(selected == nil || draft.trimmingCharacters(in: .whitespaces).isEmpty || !store.canControl) .keyboardShortcut(.return, modifiers: .command) } if !store.canControl { Text("This device is view-only.").font(.caption2).foregroundStyle(.secondary) } } // The same Liquid Glass bar the open session carries, so the new-chat entry box is visually // identical to the in-chat composer. .padding(.horizontal, 14) .padding(.vertical, 12) .glassSurface(cornerRadius: 26) // Prefill the base branch with the project's default so it actually appears (mirroring the // Mac composer's `effectiveBranch`), rather than sitting as an empty placeholder. .onAppear(perform: syncToProject) .onChange(of: projectID) { _, _ in syncToProject() } // 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) let sentinel = store.modelCatalog.orchestraSentinelOrFallback if effort.caseInsensitiveCompare(sentinel) != .orderedSame, !levels.contains(effort) { effort = levels.last ?? MobileEfforts.fallback } } } /// Sync the composer to the freshly-selected project: show its default branch as the base, and /// resolve a carried-over Orchestra selection down to a real level when the new project isn't /// under Nucleic Control (Orchestra can't run there — mirrors the Mac's `effectiveEffort`). private func syncToProject() { baseBranch = selected?.defaultBranch ?? "" if MobileEfforts.isOrchestra(effort), !controlled { effort = store.modelCatalog.offeredEfforts(forModel: model).last ?? MobileEfforts.fallback } } } /// The floating "+" start-chat affordance: a Liquid Glass circle in the lower-right that reveals /// the glass `StartChatComposer` as a floating bar over the page — the same entry box the open /// session carries, not a full modal pane. Shared by Home, Sessions, and a project page (which /// scopes it to `project`), so all three offer the same control. 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 composer programmatically (iPad ⌘N on Home, /// the compact shell's shared tab-bar "+"). @Binding var isPresented: Bool /// Whether to draw the floating "+" disc. The compact shell hosts its own "+" beside the tab /// bar and only wants the composer presentation, so it passes `false`; per-page callers (iPad) /// keep their own disc. var showsButton: Bool = true /// Prompt text and its focus live here so the scrim tap and auto-focus can coordinate with the /// composer (whether it's empty, whether the keyboard is up). @State private var draft = "" @FocusState private var draftFocused: 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 var isEmpty: Bool { draft.trimmingCharacters(in: .whitespaces).isEmpty } func body(content: Content) -> some View { content // The "+" disc — hidden while the composer is up so it doesn't sit over the bar. .overlay(alignment: .bottomTrailing) { if showsButton && canStart && !isPresented { 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) .transition(.scale.combined(with: .opacity)) .accessibilityLabel("New chat") } } // The composer itself, floating up from the bottom over a light scrim — no modal pane, // no nav chrome, just the entry box. .overlay { if isPresented { ZStack(alignment: .bottom) { Rectangle() .fill(.black.opacity(0.2)) .ignoresSafeArea() .transition(.opacity) // Tapping away drops the keyboard; with nothing typed there's nothing to // keep the bar around for, so close it too. A draft in progress stays // open (keyboard down) so a stray tap doesn't discard it — a second tap // (keyboard already down) then closes it. .onTapGesture { if draftFocused { draftFocused = false if isEmpty { isPresented = false } } else { isPresented = false } } StartChatComposer(draft: $draft, focus: $draftFocused, onStart: { isPresented = false }, lockedProject: project) // Taps on the bar's own empty spots (rounded corners, gaps between // controls) must not fall through to the scrim and close it — only taps // outside the bar dismiss. A clear tap-catcher sized to the bar absorbs // them; the controls, in front, still handle their own taps. .background(Color.clear.contentShape(Rectangle()).onTapGesture {}) .padding(.horizontal, 12) .padding(.bottom, 8) .transition(.move(edge: .bottom).combined(with: .opacity)) // A deliberate swipe down dismisses the bar (drops the keyboard first), // the gestural twin of tapping the scrim. minimumDistance keeps taps on // the field / menus / send button working; the vertical-dominant check // ignores horizontal drags (e.g. moving the text cursor). .gesture( DragGesture(minimumDistance: 20) .onEnded { value in if value.translation.height > 60, value.translation.height > abs(value.translation.width) { draftFocused = false isPresented = false } } ) } } } .animation(.spring(response: 0.35, dampingFraction: 0.86), value: isPresented) .onChange(of: isPresented) { _, open in if open { draft = "" // Focus in the same turn the bar is presented so the keyboard rises immediately, // riding up together with the bar (both animate from the bottom edge at t=0) // instead of waiting for the spring to settle first — no perceptible delay // between the composer appearing and the keyboard opening. draftFocused = true } else { draftFocused = false draft = "" } } } } extension View { /// Add the floating "+" start-chat affordance (glass circle → glass composer sheet). See /// `NewChatAffordance`. /// `enabled: false` drops the affordance entirely — the compact shell hosts a single shared "+" /// beside its tab bar, so the per-page discs (Home, a project page) apply this only at regular /// width, where there's no tab bar to carry it. @ViewBuilder func newChatAffordance(project: WireProject? = nil, isPresented: Binding, showsButton: Bool = true, enabled: Bool = true) -> some View { if enabled { modifier(NewChatAffordance(project: project, isPresented: isPresented, showsButton: showsButton)) } else { self } } } /// A compact peek at open ideas with inline capture; the full inbox is the To-Dos tab. struct QuickTodos: View { @EnvironmentObject var store: RemoteStore @State private var draft = "" private var open: [WireTodo] { store.dashboard.todos.filter { $0.status == .open } } var body: some View { VStack(alignment: .leading, spacing: 10) { HStack { Text("To-dos").font(.headline) Spacer() if !open.isEmpty { Text("\(open.count)").foregroundStyle(.secondary) } } HStack { TextField("Capture an idea…", text: $draft) .textFieldStyle(.roundedBorder) .onSubmit(capture) Button(action: capture) { Image(systemName: "plus.circle.fill").font(.title3) } .disabled(draft.trimmingCharacters(in: .whitespaces).isEmpty || !store.canControl) } ForEach(open.prefix(3)) { todo in HStack(spacing: 10) { Button { store.completeTodo(todo.id) } label: { Image(systemName: "circle").foregroundStyle(.secondary) }.buttonStyle(.plain) Text(todo.summary?.isEmpty == false ? todo.summary! : todo.text) .font(.subheadline).lineLimit(1) if let triage = todo.triage { TriageBadge(rawLevel: triage) } Spacer() if let name = todo.projectName { Text(name).font(.caption2).foregroundStyle(.secondary) } } } } .card() } private func capture() { store.captureTodo(draft, projectID: nil) draft = "" } }