Files
nucleic-remote-ios/NucleicRemote/NucleicRemote/Views/Composer.swift
T
abkslmandClaude Opus 4.8 005776685d iPad: more keyboard shortcuts, a New-chat button, and hover polish
Round out the Mac-parity keyboard/pointer affordances on the iPad split view.

Keyboard shortcuts (hidden buttons in SplitRootView carry them):
- ⌘N — start a new chat: select Home and focus the composer (also defaults the
  project). A one-shot Bool binding threads Home→StartChatComposer and is
  consumed on use, so it grabs focus on ⌘N but never on a later Home appearance
  (handles both "already on Home" via onChange and "switched from a session"
  via onAppear).
- ⌘1–4 — jump to Home / Projects / To-dos / Settings.
- ⌘R — refresh sessions.
- ⌘. — interrupt a running session (Mac's "stop"; otherwise only in the ⋯ menu).

UI:
- A visible "New chat" (compose) button in the sidebar toolbar runs the same
  action, so touch users get the affordance too (gated on control scope).

Hover (pointer):
- .hoverEffect on the transcript tool-call / tool-block rows and the Home
  in-progress session rows (native List rows already hover).

Verified in the iPad simulator: the New-chat action scrolls to + focuses the
composer with the project defaulted. Builds clean.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-04 01:18:35 -07:00

176 lines
8.0 KiB
Swift

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.
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)
@State private var projectID: ProjectID?
@State private var draft = ""
@State private var auto = false
@State private var model: String?
@State private var effort = MobileEfforts.fallback
@FocusState private var draftFocused: Bool
// 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? {
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 }
}
} label: {
Label(selected?.name ?? "Select a project", systemImage: "folder")
.font(.subheadline)
}
Spacer()
Toggle(isOn: $auto) {
Label("Auto", systemImage: auto ? "bolt.fill" : "bolt.slash")
}
.toggleStyle(.button)
.tint(Palette.accent)
.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)
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) {
TextField("Describe a task…", text: $draft, axis: .vertical)
.textFieldStyle(.roundedBorder)
.lineLimit(1...5)
.orchestraGlow(active: MobileEfforts.isOrchestra(effort) && controlled)
.keyboardDismissable()
.focused($draftFocused)
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 = ""
}
} 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)
}
}
.card()
// 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
}
}
// 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
}
}
/// 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)
.keyboardDismissable()
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 = ""
}
}