mirror of
https://github.com/wu736139669/hapi.git
synced 2026-11-07 01:21:37 +00:00
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>
513 lines
18 KiB
Swift
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")
|
|
}
|
|
}
|