Make the remote feel native with a Magic Keyboard / trackpad, and give iPad a pairing path that survives a blocked camera. Keyboard shortcuts (mirroring the Mac's .commands): - Cmd+Return sends in the session follow-up composer and the start-chat composer (plain Return stays a newline in the multiline fields). - On the approval card, Return allows and Esc denies -- the approval bar replaces the composer, so Return is unclaimed there. Allow stays gated on the biometric. Pointer: - .hoverEffect on the diff file-list rows (native List rows already hover). Camera-free pairing (ManualPairingView): - "Enter code manually" in both the pairing intro and Settings opens a sheet to paste the Mac's nucleic://pair?d=... code, parsed with the same PairingPayload(qrString:) the scanner uses (+ a Paste-from-clipboard button). The iPad scanner can't run while mirrored to an external display or in some Stage Manager states; this always works. Note: the Mac's RemoteAccessView currently shows only the QR, so a follow-up host change is needed to surface a copyable pairing link for this to be end-to-end. Builds clean. Co-Authored-By: Claude Opus 4.8 <[email protected]>
158 lines
6.9 KiB
Swift
158 lines
6.9 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
|
|
@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
|
|
// 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()
|
|
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 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 = ""
|
|
}
|
|
}
|