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() } } .sheet(isPresented: directoryBrowserPresented) { RemoteDirectoryBrowserView( model: model.directoryBrowser, onSelect: model.selectBrowsedDirectory ) } } // 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) { ForEach(model.machines) { machine in Text(machine.label).tag(machine.id) } } if let health = selectedMachineHealth { Text(health) .font(.caption) .foregroundStyle(.secondary) } } 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 { 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") } // 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 let status = model.directoryStatus { Text(status.message) .foregroundStyle(status.isError ? AnyShapeStyle(.red) : AnyShapeStyle(.secondary)) } } .disabled(model.isSpawning) } private var directoryBrowserPresented: Binding { 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 { 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 { 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.retryCodexModels() } .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 { Binding( get: { model.form.permissionMode }, set: { model.setPermissionMode($0) } ) } private var collaborationModeBinding: Binding { Binding( get: { model.form.collaborationMode }, set: { model.setCollaborationMode($0) } ) } private var copilotAgentModeBinding: Binding { Binding( get: { model.form.copilotAgentMode }, set: { model.setCopilotAgentMode($0) } ) } private var serviceTierBinding: Binding { Binding( get: { model.form.serviceTier }, set: { model.setServiceTier($0) } ) } private func optionPicker( _ title: LocalizedStringKey, options: [NewSessionOption], selection: Binding ) -> 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") } }