Files
hapi/ios/Hapi/Features/NewSession/NewSessionView.swift
T
weishuandClaude Opus 5.5 d01f03eef7 feat(ios): pick models for every agent when creating a session
The create form only offered models for Claude and Codex, so every
other agent spawned on its default model. The form now loads the
catalogs web uses. Agy, cursor, dsh and pi read
/machines/:id/<flavor>-models. Copilot, grok, kimi and opencode probe
the same route with cwd once the directory exists; the view refetches
when the typed path turns out to exist.

Option rows follow the web builders:
- Copilot and cursor label the default row "Auto".
- Kimi and pi prefix the provider; pi values are provider/modelId.
- OpenCode marks the agent's own default.
- Cursor shows a flat list of ACP ids, without the variant drill-down.

Grok efforts and pi thinking levels follow the selected model, and an
OpenCode variant becomes the reasoning effort. Grok's auto permission
mode is hidden when the probe reports it unsupported. A restored
selection the catalog no longer offers falls back to Default, and
Create waits while a non-default selection is still being validated.

The spawn body sends model for all of these agents, and cursor always
sends it, with 'auto' as the default. Grok and pi send effort; opencode
sends modelReasoningEffort.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 18:00:00 +08:00

513 lines
18 KiB
Swift

import HapiClient
import HapiProtocol
import SwiftUI
/// NEW SESSION (A-M3c): machine → directory → agent/options → spawn,
/// presented as a sheet off the session list's "+" button. State and
/// behavior live in `NewSessionModel`; this view renders it and forwards
/// intents. A successful spawn surfaces through `onCreated` (the presenter
/// dismisses the sheet and pushes the chat — navigate-replace).
struct NewSessionView: View {
@State private var model: NewSessionModel
@Environment(\.dismiss) private var dismiss
private let session: HubSession
init(session: HubSession, onCreated: @escaping @MainActor (String) -> Void) {
_model = State(initialValue: NewSessionModel(session: session, onCreated: onCreated))
self.session = session
}
var body: some View {
NavigationStack {
Form {
machineSection
directorySection
sessionTypeSection
agentSection
optionsSection
createSection
}
.navigationTitle("New Session")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Cancel") {
dismiss()
}
}
}
.task {
await model.start()
}
.onChange(of: session.machineStore.listRevision) {
model.machinesChanged()
}
// Directory-scoped catalogs wait for the typed path to exist.
.onChange(of: model.agentModelsRequest) {
model.refreshAgentModelsIfNeeded()
}
}
.sheet(isPresented: directoryBrowserPresented) {
RemoteDirectoryBrowserView(
model: model.directoryBrowser,
onSelect: model.selectBrowsedDirectory
)
}
.onDisappear {
model.stopDirectoryWork()
}
}
// MARK: - Machine
private var machineSection: some View {
Section("Machine") {
if model.machines.isEmpty {
Text(model.machinesLoading
? String(localized: "Loading machines…")
: String(localized: "No machines online"))
.foregroundStyle(.secondary)
} else {
Picker("Machine", selection: machineBinding) {
if model.machineUnavailable, let machineId = model.form.machineId {
Text("Selected machine is offline").tag(machineId)
}
ForEach(model.machines) { machine in
Text(machine.label).tag(machine.id)
}
}
if let health = selectedMachineHealth {
Text(health)
.font(.caption)
.foregroundStyle(.secondary)
}
}
if model.machineUnavailable {
Text(NewSessionModel.msgMachineOffline)
.font(.footnote)
.foregroundStyle(.red)
}
if let error = model.runnerSpawnError {
Text("Runner last spawn error: \(error)")
.font(.footnote)
.foregroundStyle(.red)
}
}
.disabled(model.isSpawning)
}
private var selectedMachineHealth: String? {
model.machines.first { $0.id == model.form.machineId }?.healthLabel
}
private var machineBinding: Binding<String> {
Binding(
get: { model.form.machineId ?? "" },
set: { model.setMachine($0) }
)
}
// MARK: - Directory
private var directorySection: some View {
Section {
HStack(spacing: 8) {
TextField(
"/path/to/project",
text: Binding(get: { model.form.directory }, set: { model.setDirectory($0) })
)
.autocorrectionDisabled()
.textInputAutocapitalization(.never)
.font(.system(.callout, design: .monospaced))
Button {
model.openDirectoryBrowser()
} label: {
Image(systemName: "folder.badge.plus")
}
.buttonStyle(.borderless)
.accessibilityLabel("Browse")
.disabled(model.machineUnavailable || model.form.machineId == nil)
}
directoryLookupStatus
// Server-side autocomplete (list-directory on the parent path).
ForEach(model.suggestions, id: \.self) { suggestion in
Button {
model.pickSuggestion(suggestion)
} label: {
Label {
Text(suggestion)
.lineLimit(1)
.truncationMode(.head)
} icon: {
Image(systemName: "folder")
}
.font(.callout)
}
.buttonStyle(.plain)
}
if !model.recentPaths.isEmpty {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 8) {
ForEach(model.recentPaths, id: \.self) { path in
FilterChip(label: path, selected: false) {
model.pickRecentPath(path)
}
}
}
}
.listRowInsets(EdgeInsets(top: 6, leading: 12, bottom: 6, trailing: 12))
}
} header: {
Text("Directory")
} footer: {
if model.directoryInput.isCheckingExistence {
Text("Checking directory…")
} else if let status = model.directoryStatus {
HStack(alignment: .firstTextBaseline) {
Text(LocalizedNoticeMapper.map(status.message))
.foregroundStyle(status.isError ? AnyShapeStyle(.red) : AnyShapeStyle(.secondary))
if model.directoryInput.existenceError != nil {
Button("Retry") { model.directoryInput.retry() }
}
}
}
}
.disabled(model.isSpawning)
}
@ViewBuilder
private var directoryLookupStatus: some View {
switch model.directoryInput.lookupState {
case .idle:
EmptyView()
case .loading:
HStack(spacing: 8) {
ProgressView().controlSize(.small)
Text("Searching directories…").foregroundStyle(.secondary)
}
.font(.footnote)
case .loaded:
if model.suggestions.isEmpty {
Text("No matching directories")
.font(.footnote)
.foregroundStyle(.secondary)
}
case .outsideRoots:
Text(NewSessionModel.msgDirectoryOutsideWorkspaceRoots)
.font(.footnote)
.foregroundStyle(.red)
case .failed(let error):
HStack(alignment: .firstTextBaseline) {
Text(LocalizedNoticeMapper.map(error)).foregroundStyle(.red)
Spacer()
Button("Retry") { model.directoryInput.retry() }
}
.font(.footnote)
}
}
private var directoryBrowserPresented: Binding<Bool> {
Binding(
get: { model.directoryBrowser.isPresented },
set: { presented in
if !presented {
model.directoryBrowser.close()
}
}
)
}
// MARK: - Session type
private var sessionTypeSection: some View {
Section {
Picker("Session type", selection: sessionTypeBinding) {
Text("Simple").tag(SpawnSessionType.simple)
Text("Worktree").tag(SpawnSessionType.worktree)
}
.pickerStyle(.segmented)
.labelsHidden()
if model.form.sessionType == .worktree {
TextField(
"feature-x (optional)",
text: Binding(
get: { model.form.worktreeName },
set: { model.setWorktreeName($0) }
)
)
.autocorrectionDisabled()
.textInputAutocapitalization(.never)
}
} header: {
Text("Session Type")
} footer: {
if let error = model.worktreeNameError {
Text(LocalizedNoticeMapper.map(error)).foregroundStyle(.red)
} else {
Text(
model.form.sessionType == .worktree
? String(localized: "Create a new git worktree next to the repo")
: String(localized: "Use selected directory as-is")
)
}
}
.disabled(model.isSpawning)
}
private var sessionTypeBinding: Binding<SpawnSessionType> {
Binding(
get: { model.form.sessionType },
set: { model.setSessionType($0) }
)
}
// MARK: - Agent
private var agentSection: some View {
Section("Agent") {
Picker("Agent", selection: agentBinding) {
ForEach(model.agents, id: \.value) { agent in
// Plain `Image` icon so the menu representation keeps it
// (UIMenu drops custom icon views); template monos take
// the menu tint, color marks render as-is.
if let asset = AgentFlavorIconView.assetName(forFlavor: agent.value) {
Label {
Text(agent.label)
} icon: {
Image(asset)
}
.tag(agent.value)
} else {
Text(agent.label).tag(agent.value)
}
}
}
.disabled(
model.isSpawning
|| model.agentAvailabilityLoading
|| model.agentAvailabilityError != nil
)
if model.agentAvailabilityLoading {
HStack(spacing: 8) {
ProgressView().controlSize(.small)
Text("Checking installed Agents…")
.font(.footnote)
.foregroundStyle(.secondary)
}
}
if let error = model.agentAvailabilityError {
HStack(alignment: .firstTextBaseline) {
Text(error)
.font(.footnote)
.foregroundStyle(.red)
Spacer()
Button("Retry") {
model.retryAgentAvailability()
}
.font(.footnote)
}
}
}
}
private var agentBinding: Binding<String> {
Binding(
get: { model.form.agent.rawValue },
set: { model.setAgent(AgentFlavor(rawValue: $0)) }
)
}
// MARK: - Options
@ViewBuilder
private var optionsSection: some View {
Section("Options") {
if let options = model.modelOptions {
optionPicker(
"Model",
options: options,
selection: Binding(get: { model.form.model }, set: { model.setModel($0) })
)
.disabled(model.modelsLoading || model.modelsError != nil)
if model.modelsLoading {
Text("Loading…")
.font(.caption)
.foregroundStyle(.secondary)
}
if let error = model.modelsError {
HStack {
Text(error)
.font(.footnote)
.foregroundStyle(.red)
Spacer()
Button("Retry") {
model.retryModels()
}
.font(.footnote)
}
}
}
if let options = model.effortOptions {
optionPicker(
"Effort",
options: options,
selection: Binding(get: { model.form.effort }, set: { model.setEffort($0) })
)
}
if let options = model.reasoningEffortOptions {
optionPicker(
"Reasoning Effort",
options: options,
selection: Binding(
get: { model.form.modelReasoningEffort },
set: { model.setModelReasoningEffort($0) }
)
)
.disabled(model.modelsLoading)
}
permissionControl
if model.showCollaborationMode {
Picker("Collaboration Mode", selection: collaborationModeBinding) {
ForEach(CodexCollaborationMode.allCases, id: \.self) { mode in
Text(mode.label).tag(mode)
}
}
}
if model.showCopilotAgentMode {
Picker("Agent Mode", selection: copilotAgentModeBinding) {
ForEach(CopilotAgentMode.allCases, id: \.self) { mode in
Text(mode.label).tag(mode)
}
}
}
if model.showFastMode {
Picker("Fast Mode", selection: serviceTierBinding) {
Text("Standard").tag(ServiceTier.standard)
Text("Fast").tag(ServiceTier.fast)
}
}
}
.disabled(model.isSpawning)
}
@ViewBuilder
private var permissionControl: some View {
switch model.permission {
case .nativeSelect(let options):
Picker("Permission Mode", selection: permissionModeBinding) {
ForEach(options, id: \.mode) { option in
Text(option.label).tag(option.mode)
}
}
case .yoloToggle(let nativeModeLabel):
VStack(alignment: .leading, spacing: 2) {
Toggle(
"Bypass approvals and sandbox",
isOn: Binding(get: { model.form.yolo }, set: { model.setYolo($0) })
)
Text(
nativeModeLabel.map {
String(format: String(localized: "Uses dangerous agent flags when spawning. Applies native %@ mode."), $0)
}
?? String(localized: "Uses dangerous agent flags when spawning.")
)
.font(.caption)
.foregroundStyle(.secondary)
}
case .managed:
VStack(alignment: .leading, spacing: 2) {
LabeledContent("Permission Mode", value: String(localized: "Managed by agent"))
Text("This agent manages its own permissions. YOLO mode is not available.")
.font(.caption)
.foregroundStyle(.secondary)
}
}
}
private var permissionModeBinding: Binding<PermissionMode> {
Binding(
get: { model.form.permissionMode },
set: { model.setPermissionMode($0) }
)
}
private var collaborationModeBinding: Binding<CodexCollaborationMode> {
Binding(
get: { model.form.collaborationMode },
set: { model.setCollaborationMode($0) }
)
}
private var copilotAgentModeBinding: Binding<CopilotAgentMode> {
Binding(
get: { model.form.copilotAgentMode },
set: { model.setCopilotAgentMode($0) }
)
}
private var serviceTierBinding: Binding<ServiceTier> {
Binding(
get: { model.form.serviceTier },
set: { model.setServiceTier($0) }
)
}
private func optionPicker(
_ title: LocalizedStringKey,
options: [NewSessionOption],
selection: Binding<String>
) -> some View {
Picker(title, selection: selection) {
ForEach(options, id: \.value) { option in
Text(option.label).tag(option.value)
}
}
}
// MARK: - Create
private var createSection: some View {
Section {
Button {
model.create()
} label: {
HStack(spacing: 8) {
if model.isSpawning {
ProgressView()
.controlSize(.small)
}
Text(createLabel)
.fontWeight(.semibold)
}
.frame(maxWidth: .infinity)
}
.disabled(!model.canCreate)
} footer: {
if let error = model.spawnError {
Text(error).foregroundStyle(.red)
}
}
}
private var createLabel: String {
if model.isSpawning {
return String(localized: "Creating…")
}
if model.confirmCreateDirectory {
return String(localized: "Create and Make Directory")
}
return String(localized: "Create")
}
}