Files
nucleic-remote-ios/NucleicRemote/NucleicRemote/Views/Composer.swift
T
abkslmandNucleic 04eb9dbc10 Bottom Tab Bar Visibility Adjustment
Nucleic-Session: 747527BF-AEE9-4939-BE70-A14F1F3BBBA8
Co-authored-by: Nucleic <[email protected]>
2026-07-05 18:22:40 -07:00

250 lines
12 KiB
Swift

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<Bool>.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 auto = false
@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 }
private var selected: WireProject? {
lockedProject ?? (projects.first { $0.id == projectID } ?? projects.first)
}
private var controlled: Bool { selected?.isNucleicControlled ?? false }
var body: some View {
VStack(alignment: .leading, spacing: 10) {
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")
}
.toggleStyle(.button)
.tint(Palette.accent)
.font(.caption)
}
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: auto)
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)
// 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
}
}
}
}
/// 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).
@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
// The "+" disc — hidden while the composer is up so it doesn't sit over the bar.
.overlay(alignment: .bottomTrailing) {
if 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. Tapping the scrim dismisses it.
.overlay {
if isPresented {
ZStack(alignment: .bottom) {
Rectangle()
.fill(.black.opacity(0.2))
.ignoresSafeArea()
.transition(.opacity)
.onTapGesture { isPresented = false }
StartChatComposer(onStart: { isPresented = false }, lockedProject: project)
.padding(.horizontal, 12)
.padding(.bottom, 8)
.transition(.move(edge: .bottom).combined(with: .opacity))
}
}
}
.animation(.spring(response: 0.35, dampingFraction: 0.86), value: isPresented)
}
}
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))
}
}
/// 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 = ""
}
}