mirror of
https://github.com/wu736139669/hapi.git
synced 2026-10-13 20:09:55 +00:00
* refactor(web): route create-form permission control through one native-select predicate Extract usesNativePermissionSelect(flavor) (grok || codex-family, matching the existing iOS/Android predicate of the same name) and route PermissionField's select-vs-toggle gate through it instead of an inline condition. Rename the codex-family-only state codexFamilyPermissionMode to nativePermissionMode since it now backs a shared predicate, not just the codex family. Behavior is unchanged: any stale sessionStorage draft written under the old codexFamilyPermissionMode key has no value under the new key and falls back to 'default', which only matters within a single browser tab's lifetime. * feat(web): let Claude pick a permission mode when creating a session Claude was the only create-form flavor still on the global HAPI YOLO toggle while grok and the codex family got the native permission select, so there was no way to start a session in Plan Mode without creating it first and switching the mode from the composer. usesNativePermissionSelect now gates the control for claude as well, and the spawn body carries permissionMode (including 'default') instead of yolo, which is the shape the other native-select flavors already send. The stored hapi:newSession:yolo preference is bridged into the select rather than dropped, but only for the flavors that have actually moved onto it (LEGACY_YOLO_BRIDGE_AGENTS = codex, claude). copilot, gemini, kimi and opencode moved earlier and settled on 'default'; re-enabling Yolo for them now would widen permissions rather than migrate a preference. This narrows the sessionStorage draft bridge too, which until now fired for the whole codex family with no allow-list, so their draft restores yield 'default' instead of 'yolo' — same-tab-lifetime state only. Claude and the codex family share one nativePermissionMode state and their mode sets do not overlap, so the existing agent-change reset plus the flavor filters in the draft loader and the stored launch settings are what keep a codex mode out of a Claude spawn. Adds the regression test that pins it: pick a mode under codex, switch to Claude, create, assert the payload carries 'default'. * feat(ios): let Claude pick a permission mode when creating a session Extend usesNativePermissionSelect to include claude alongside grok and the codex family, matching the web change. buildSpawnRequest now derives both yolo and permissionMode from that single predicate instead of two separate local flags, so claude sends permissionMode (including 'default') and no longer sends yolo. Unlike web, iOS carries no persistent YOLO preference across sessions to migrate — the toggle only lives in the in-memory form or a draft deleted on success — so there is no bridging logic to add here. * feat(android): let Claude pick a permission mode when creating a session Extend usesNativePermissionSelect to include claude alongside grok and the codex family, matching the web and iOS changes. buildSpawnRequest derives both yolo and permissionMode from that single predicate, so claude sends permissionMode (including 'default') and no longer sends yolo. Unlike web, Android has no persistent YOLO preference to migrate: the toggle only lives in the form draft, which is deleted once a session is created. The agent-switch test asserted claude renders the YOLO toggle; it now checks the native select for claude and keeps the toggle assertion on cursor, which still carries it.
972 lines
36 KiB
Swift
972 lines
36 KiB
Swift
import Foundation
|
|
import HapiClient
|
|
import HapiProtocol
|
|
import Observation
|
|
|
|
// MARK: - UI models
|
|
|
|
/// One machine row of the picker (Android `MachineOptionUi` port).
|
|
struct MachineOptionUI: Identifiable, Equatable {
|
|
let id: String
|
|
/// `displayName || host || id.prefix(8)` plus ` (platform)` and CLI version.
|
|
let label: String
|
|
/// e.g. `CPU 12% · Mem 45%`; nil when the runner reports no health.
|
|
let healthLabel: String?
|
|
}
|
|
|
|
/// Directory hint under the input (web `directoryStatusMessage` + tone).
|
|
struct DirectoryStatusUI: Equatable {
|
|
let message: String
|
|
let isError: Bool
|
|
}
|
|
|
|
/// Which permission control the current flavor renders (web `PermissionField`).
|
|
enum PermissionUI: Equatable {
|
|
/// Native permission-mode picker (claude, grok, codex-family —
|
|
/// `NewSessionLogic.usesNativePermissionSelect`).
|
|
case nativeSelect([PermissionModeOption])
|
|
/// HAPI YOLO toggle (agy/cursor) with the native mode it maps to.
|
|
case yoloToggle(nativeModeLabel: String?)
|
|
/// Pi: the agent manages its own permissions.
|
|
case managed
|
|
}
|
|
|
|
/// Machine codex-models catalog state (web `useCodexModels`).
|
|
enum CodexModelsState: Equatable {
|
|
case hidden
|
|
case loading
|
|
case loaded([CodexModelSummary])
|
|
/// Runner has no machine RPC (`rpc_target_missing`) — hide the picker.
|
|
case unsupported
|
|
case failed(String)
|
|
}
|
|
|
|
/// Installed/static-configured Agent catalog for the selected machine.
|
|
enum AgentAvailabilityState: Equatable {
|
|
case loading
|
|
case loaded([AgentAvailabilityEntry])
|
|
case failed(message: String, upgradeRequired: Bool)
|
|
}
|
|
|
|
// MARK: - Persistence
|
|
|
|
/// Create-form persistence: last-used machine + per-machine recent paths
|
|
/// (web `useRecentPaths`) and the in-progress draft (web
|
|
/// `newSessionFormDraft.ts`) so backing out of the sheet loses nothing.
|
|
/// JSON blobs in `UserDefaults`, keyed per hub (the Android original keys
|
|
/// app-wide; per-hub matches this app's multi-hub stores).
|
|
struct NewSessionPrefsData: Codable, Equatable {
|
|
var lastMachineId: String?
|
|
/// Machine id → most-recent-first spawn directories
|
|
/// (cap `NewSessionLogic.maxRecentPaths`).
|
|
var recentPaths: [String: [String]] = [:]
|
|
}
|
|
|
|
struct NewSessionPrefsStore {
|
|
let hubUrl: String
|
|
var defaults: UserDefaults = .standard
|
|
|
|
private var prefsKey: String { "newSession.prefs.\(hubUrl)" }
|
|
private var draftKey: String { "newSession.draft.\(hubUrl)" }
|
|
|
|
func readPrefs() -> NewSessionPrefsData {
|
|
decode(prefsKey) ?? NewSessionPrefsData()
|
|
}
|
|
|
|
func writePrefs(_ data: NewSessionPrefsData) {
|
|
encode(data, key: prefsKey)
|
|
}
|
|
|
|
/// Nil when no draft is stored (or it fails to decode).
|
|
func readDraft() -> NewSessionForm? {
|
|
decode(draftKey)
|
|
}
|
|
|
|
func writeDraft(_ draft: NewSessionForm) {
|
|
encode(draft, key: draftKey)
|
|
}
|
|
|
|
func clearDraft() {
|
|
defaults.removeObject(forKey: draftKey)
|
|
}
|
|
|
|
private func decode<T: Decodable>(_ key: String) -> T? {
|
|
guard let data = defaults.data(forKey: key) else { return nil }
|
|
return try? HapiJSON.decoder.decode(T.self, from: data)
|
|
}
|
|
|
|
private func encode<T: Encodable>(_ value: T, key: String) {
|
|
guard let data = try? HapiJSON.encoder.encode(value) else { return }
|
|
defaults.set(data, forKey: key)
|
|
}
|
|
}
|
|
|
|
// MARK: - Model
|
|
|
|
/// New-session state machine (A-M3c): machine → directory → agent/options →
|
|
/// spawn — the iOS counterpart of the Android reference's
|
|
/// `NewSessionViewModel` in this app's viewModel-less `@Observable` style.
|
|
/// Pure mapping/validation lives in `NewSessionLogic` (HapiClient, tested);
|
|
/// this model orchestrates debounce, caching, fetches, and persistence.
|
|
/// Web reference: `web/src/components/NewSession/index.tsx`.
|
|
///
|
|
/// v1 notes (deliberate cuts, mirrored from the Android/web option matrix):
|
|
/// - model pickers: claude (static presets) + codex (machine catalog; hidden
|
|
/// when the runner lacks the RPC). agy/opencode/grok/copilot/cursor/pi
|
|
/// model discovery is TODO — spawn omits `model` so the agent default
|
|
/// applies.
|
|
/// - effort: claude static levels; codex reasoning effort from the catalog.
|
|
/// - `startingMode` stays unset → the runner spawns `'remote'` (pty deferred).
|
|
@MainActor @Observable
|
|
final class NewSessionModel {
|
|
static let debounce: Duration = .milliseconds(250)
|
|
|
|
static let msgWorktreeMissing =
|
|
String(localized: "Worktree sessions require an existing repository directory.")
|
|
static let msgDirectoryMissing =
|
|
String(localized: "Directory does not exist. Creating the session will create it automatically.")
|
|
static let msgDirectoryMissingConfirm =
|
|
String(localized: "Directory does not exist. Tap Create again to create it automatically.")
|
|
static let msgDirectoryOutsideWorkspaceRoots =
|
|
String(localized: "Directory must be inside one of this machine's workspace roots.")
|
|
static let msgDirectoryLookupFailed = String(localized: "Failed to browse directories")
|
|
static let msgAgentAvailabilityFailed = String(localized: "Failed to check installed Agents")
|
|
static let msgRunnerUpgradeRequired =
|
|
String(localized: "Upgrade and restart this machine's HAPI runner before creating sessions.")
|
|
static let msgNoAvailableAgents =
|
|
String(localized: "No supported Agents are installed on this machine.")
|
|
static let msgSelectedAgentUnavailable =
|
|
String(localized: "The selected Agent is not available on this machine.")
|
|
|
|
// MARK: Observable state
|
|
|
|
private(set) var form = NewSessionForm()
|
|
private(set) var suggestions: [String] = []
|
|
private(set) var codexModels: CodexModelsState = .hidden
|
|
private(set) var agentAvailability: AgentAvailabilityState = .loading
|
|
private(set) var isSpawning = false
|
|
private(set) var spawnError: String?
|
|
private(set) var confirmCreateDirectoryArmed = false
|
|
private(set) var machinesSettled = false
|
|
/// Probed existence per trimmed path (feeds the directory status hint).
|
|
private(set) var pathExistence: [String: Bool] = [:]
|
|
private(set) var outsideWorkspaceRoots: Set<String> = []
|
|
private(set) var directoryLookupError: String?
|
|
private(set) var prefsData = NewSessionPrefsData()
|
|
let directoryBrowser: RemoteDirectoryBrowserModel
|
|
|
|
// MARK: Wiring
|
|
|
|
private let session: HubSession
|
|
private let prefsStore: NewSessionPrefsStore
|
|
/// Fired once with the new session id — navigate-replace to the chat.
|
|
private let onCreated: @MainActor (String) -> Void
|
|
|
|
@ObservationIgnored private var directoryTask: Task<Void, Never>?
|
|
@ObservationIgnored private var codexTask: Task<Void, Never>?
|
|
@ObservationIgnored private var availabilityTask: Task<Void, Never>?
|
|
@ObservationIgnored private var defaultDirectoryTask: Task<Void, Never>?
|
|
@ObservationIgnored private var codexFetchedForMachine: String?
|
|
@ObservationIgnored private var availabilityFetchedForMachine: String?
|
|
@ObservationIgnored private var suppressSuggestions = false
|
|
@ObservationIgnored private var spawnInFlight = false
|
|
@ObservationIgnored private var directoryRequestVersion = 0
|
|
/// Parent-listing cache: retyping within the same parent re-filters
|
|
/// locally instead of re-requesting.
|
|
@ObservationIgnored private var cachedListing:
|
|
(machineId: String, parent: String, entries: [MachineDirectoryEntry])?
|
|
|
|
init(session: HubSession, onCreated: @escaping @MainActor (String) -> Void) {
|
|
self.session = session
|
|
self.prefsStore = NewSessionPrefsStore(hubUrl: session.hubUrl)
|
|
self.onCreated = onCreated
|
|
self.directoryBrowser = RemoteDirectoryBrowserModel(
|
|
requester: session.api,
|
|
fallbackError: Self.msgDirectoryLookupFailed
|
|
)
|
|
}
|
|
|
|
// MARK: - Lifecycle (paired with the sheet's `.task`)
|
|
|
|
/// Restore prefs + sanitized draft, preselect a machine, refresh the
|
|
/// roster. Call once per presentation.
|
|
func start() async {
|
|
prefsData = prefsStore.readPrefs()
|
|
let initial = prefsStore.readDraft().map(NewSessionLogic.sanitizeDraft) ?? NewSessionForm()
|
|
form = initial
|
|
reconcileMachineSelection()
|
|
refreshAgentAvailability()
|
|
refreshCodexModelsIfNeeded()
|
|
// A restored directory should probe existence but not pop the
|
|
// autocomplete dropdown — suggestions belong to typing.
|
|
suppressSuggestions = true
|
|
scheduleDirectoryWork()
|
|
|
|
do {
|
|
try await session.machineStore.refresh()
|
|
} catch {
|
|
// Snapshot (if any) keeps serving; the picker shows what it has.
|
|
}
|
|
machinesSettled = true
|
|
reconcileMachineSelection()
|
|
}
|
|
|
|
/// Machine roster changed (SSE / refresh) — re-run the preselection:
|
|
/// keep a still-online selection; otherwise last-used, else first.
|
|
func machinesChanged() {
|
|
reconcileMachineSelection()
|
|
}
|
|
|
|
// MARK: - Derived state
|
|
|
|
var machines: [MachineOptionUI] {
|
|
session.machineStore.machines.map(Self.machineOption)
|
|
}
|
|
|
|
var machinesLoading: Bool {
|
|
session.machineStore.machines.isEmpty && !machinesSettled
|
|
}
|
|
|
|
/// `runnerState.lastSpawnError` of the selected machine, formatted
|
|
/// (`web/src/utils/formatRunnerSpawnError.ts`).
|
|
var runnerSpawnError: String? {
|
|
Self.formatRunnerSpawnError(selectedMachine)
|
|
}
|
|
|
|
var recentPaths: [String] {
|
|
recentPaths(for: form.machineId)
|
|
}
|
|
|
|
var directoryStatus: DirectoryStatusUI? {
|
|
if directoryOutsideWorkspaceRoots {
|
|
return DirectoryStatusUI(message: Self.msgDirectoryOutsideWorkspaceRoots, isError: true)
|
|
}
|
|
if missingWorktreeDirectory {
|
|
return DirectoryStatusUI(message: Self.msgWorktreeMissing, isError: true)
|
|
}
|
|
if needsCreationWarning {
|
|
return DirectoryStatusUI(
|
|
message: confirmCreateDirectory
|
|
? Self.msgDirectoryMissingConfirm
|
|
: Self.msgDirectoryMissing,
|
|
isError: false
|
|
)
|
|
}
|
|
if let directoryLookupError {
|
|
return DirectoryStatusUI(message: directoryLookupError, isError: true)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
/// Installed creatable flavors, in runner catalog order.
|
|
var agents: [NewSessionOption] {
|
|
availableAgentFlavors.map {
|
|
NewSessionOption(value: $0.rawValue, label: $0.displayLabel)
|
|
}
|
|
}
|
|
|
|
var agentAvailabilityLoading: Bool {
|
|
agentAvailability == .loading
|
|
}
|
|
|
|
var agentAvailabilityError: String? {
|
|
switch agentAvailability {
|
|
case .loading:
|
|
return nil
|
|
case .failed(let message, _):
|
|
return message
|
|
case .loaded:
|
|
return availableAgentFlavors.isEmpty ? Self.msgNoAvailableAgents : nil
|
|
}
|
|
}
|
|
|
|
/// Nil hides the model picker (v1: only claude + supported codex).
|
|
var modelOptions: [NewSessionOption]? {
|
|
switch (form.agent, codexModels) {
|
|
case (.claude, _):
|
|
return NewSessionCatalogs.claudeModels
|
|
case (.codex, .loaded(let models)):
|
|
return [NewSessionOption(value: "auto", label: "Default")]
|
|
+ models.map { NewSessionOption(value: $0.id, label: $0.displayName) }
|
|
case (.codex, .loading), (.codex, .failed):
|
|
return [NewSessionOption(value: "auto", label: "Default")]
|
|
default:
|
|
// codex `.unsupported` (old runner) and every other flavor: hidden.
|
|
return nil
|
|
}
|
|
}
|
|
|
|
var modelsLoading: Bool {
|
|
form.agent == .codex && codexModels == .loading
|
|
}
|
|
|
|
var modelsError: String? {
|
|
if case .failed(let message) = codexModels {
|
|
return String(format: String(localized: "Failed to load models: %@"), message)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
/// Claude launch-effort options; nil hides the field.
|
|
var effortOptions: [NewSessionOption]? {
|
|
form.agent == .claude ? NewSessionCatalogs.claudeEfforts : nil
|
|
}
|
|
|
|
/// Codex reasoning-effort options; nil hides the field.
|
|
var reasoningEffortOptions: [NewSessionOption]? {
|
|
guard form.agent == .codex, codexModels != .unsupported else { return nil }
|
|
if case .loaded(let models) = codexModels,
|
|
let advertised = NewSessionLogic.codexReasoningEfforts(models: models, model: form.model) {
|
|
return [NewSessionOption(value: "default", label: "Default")]
|
|
+ advertised.map {
|
|
NewSessionOption(value: $0, label: NewSessionCatalogs.effortLabel($0))
|
|
}
|
|
}
|
|
return NewSessionCatalogs.codexReasoningEfforts
|
|
}
|
|
|
|
var permission: PermissionUI {
|
|
let agent = form.agent
|
|
if agent == .pi || agent == .dsh {
|
|
return .managed
|
|
}
|
|
if NewSessionLogic.usesNativePermissionSelect(agent) {
|
|
return .nativeSelect(agent.permissionModes.map { PermissionModeOption(mode: $0) })
|
|
}
|
|
return .yoloToggle(nativeModeLabel: NewSessionLogic.hapiYoloNativeMode(for: agent)?.label)
|
|
}
|
|
|
|
var showCollaborationMode: Bool {
|
|
form.agent == .codex
|
|
}
|
|
|
|
var showCopilotAgentMode: Bool {
|
|
form.agent == .copilot
|
|
}
|
|
|
|
var showFastMode: Bool {
|
|
guard form.agent == .codex, case .loaded(let models) = codexModels else { return false }
|
|
return NewSessionLogic.codexModelAdvertisesFastTier(model: form.model, models: models)
|
|
}
|
|
|
|
var worktreeNameError: String? {
|
|
form.sessionType == .worktree
|
|
? NewSessionLogic.worktreeNameError(form.worktreeName)
|
|
: nil
|
|
}
|
|
|
|
/// Armed after the first Create tap on a missing simple directory.
|
|
var confirmCreateDirectory: Bool {
|
|
confirmCreateDirectoryArmed && needsCreationWarning
|
|
}
|
|
|
|
var canCreate: Bool {
|
|
form.machineId != nil
|
|
&& !form.trimmedDirectory.isEmpty
|
|
&& !isSpawning
|
|
&& !missingWorktreeDirectory
|
|
&& !directoryOutsideWorkspaceRoots
|
|
&& selectedAgentAvailable
|
|
&& worktreeNameError == nil
|
|
&& !codexValidationPending
|
|
}
|
|
|
|
private var selectedMachine: Machine? {
|
|
session.machineStore.machines.first { $0.id == form.machineId }
|
|
}
|
|
|
|
private var directoryExists: Bool? {
|
|
let trimmed = form.trimmedDirectory
|
|
guard !trimmed.isEmpty else { return nil }
|
|
return pathExistence[trimmed]
|
|
}
|
|
|
|
private var directoryOutsideWorkspaceRoots: Bool {
|
|
let trimmed = form.trimmedDirectory
|
|
return !trimmed.isEmpty && outsideWorkspaceRoots.contains(trimmed)
|
|
}
|
|
|
|
private var missingWorktreeDirectory: Bool {
|
|
!directoryOutsideWorkspaceRoots && form.sessionType == .worktree && directoryExists == false
|
|
}
|
|
|
|
private var needsCreationWarning: Bool {
|
|
!directoryOutsideWorkspaceRoots && form.sessionType == .simple && directoryExists == false
|
|
}
|
|
|
|
private var availableAgentFlavors: [AgentFlavor] {
|
|
guard case .loaded(let entries) = agentAvailability else { return [] }
|
|
return entries.compactMap { entry in
|
|
guard entry.available, AgentFlavor.creatableFlavors.contains(entry.agent) else { return nil }
|
|
return entry.agent
|
|
}
|
|
}
|
|
|
|
private var selectedAgentAvailable: Bool {
|
|
availableAgentFlavors.contains(form.agent)
|
|
}
|
|
|
|
/// Web `isLaunchPreferenceValidationPending` (codex slice): a restored
|
|
/// codex model/effort must not spawn before the catalog validated it.
|
|
private var codexValidationPending: Bool {
|
|
form.agent == .codex && codexModels == .loading
|
|
&& (form.model != "auto"
|
|
|| form.modelReasoningEffort != "default"
|
|
|| form.serviceTier == .fast)
|
|
}
|
|
|
|
// MARK: - Actions
|
|
|
|
func setMachine(_ machineId: String) {
|
|
guard machineId != form.machineId else { return }
|
|
applyMachineSelection(machineId, resetDirectory: true)
|
|
}
|
|
|
|
func setDirectory(_ value: String) {
|
|
suppressSuggestions = false
|
|
confirmCreateDirectoryArmed = false
|
|
form.directory = value
|
|
persistDraft()
|
|
scheduleDirectoryWork()
|
|
}
|
|
|
|
func pickSuggestion(_ path: String) {
|
|
pickPath(path)
|
|
}
|
|
|
|
func pickRecentPath(_ path: String) {
|
|
pickPath(path)
|
|
}
|
|
|
|
func openDirectoryBrowser() {
|
|
guard let machine = selectedMachine else { return }
|
|
directoryBrowser.open(
|
|
machineId: machine.id,
|
|
roots: RemoteDirectoryPath.browseRoots(for: machine),
|
|
initialPath: form.trimmedDirectory
|
|
)
|
|
}
|
|
|
|
func selectBrowsedDirectory(_ path: String) {
|
|
if !path.isEmpty {
|
|
pickPath(path)
|
|
}
|
|
}
|
|
|
|
func setAgent(_ agent: AgentFlavor) {
|
|
guard agent != form.agent else { return }
|
|
// Web parity: switching agents resets every agent-dependent field
|
|
// (yolo is a cross-flavor preference and survives).
|
|
form.agent = agent
|
|
form.model = "auto"
|
|
form.effort = "auto"
|
|
form.modelReasoningEffort = "default"
|
|
form.permissionMode = .default
|
|
form.serviceTier = .standard
|
|
form.collaborationMode = .default
|
|
form.copilotAgentMode = .interactive
|
|
persistDraft()
|
|
refreshCodexModelsIfNeeded()
|
|
}
|
|
|
|
func setModel(_ model: String) {
|
|
form.model = model
|
|
if form.agent == .codex {
|
|
form = reconcileCodexSelections(form)
|
|
}
|
|
persistDraft()
|
|
}
|
|
|
|
func setEffort(_ effort: String) {
|
|
form.effort = effort
|
|
persistDraft()
|
|
}
|
|
|
|
func setModelReasoningEffort(_ value: String) {
|
|
form.modelReasoningEffort = value
|
|
persistDraft()
|
|
}
|
|
|
|
func setPermissionMode(_ mode: PermissionMode) {
|
|
form.permissionMode = mode
|
|
persistDraft()
|
|
}
|
|
|
|
func setYolo(_ enabled: Bool) {
|
|
form.yolo = enabled
|
|
persistDraft()
|
|
}
|
|
|
|
func setSessionType(_ sessionType: SpawnSessionType) {
|
|
confirmCreateDirectoryArmed = false
|
|
form.sessionType = sessionType
|
|
persistDraft()
|
|
}
|
|
|
|
func setWorktreeName(_ name: String) {
|
|
form.worktreeName = name
|
|
persistDraft()
|
|
}
|
|
|
|
func setServiceTier(_ tier: ServiceTier) {
|
|
form.serviceTier = tier
|
|
persistDraft()
|
|
}
|
|
|
|
func setCollaborationMode(_ mode: CodexCollaborationMode) {
|
|
form.collaborationMode = mode
|
|
persistDraft()
|
|
}
|
|
|
|
func setCopilotAgentMode(_ mode: CopilotAgentMode) {
|
|
form.copilotAgentMode = mode
|
|
persistDraft()
|
|
}
|
|
|
|
func retryCodexModels() {
|
|
codexFetchedForMachine = nil
|
|
refreshCodexModelsIfNeeded()
|
|
}
|
|
|
|
func retryAgentAvailability() {
|
|
refreshAgentAvailability(force: true)
|
|
}
|
|
|
|
/// Spawn. Directory existence is re-checked server-side first (web
|
|
/// `handleCreate`): a missing worktree base is an error; a missing
|
|
/// simple directory arms a second-tap confirmation, after which the hub
|
|
/// creates it. Success persists prefs, clears the draft, and hands the
|
|
/// new session id to `onCreated`; failure lands in the inline error.
|
|
func create() {
|
|
let current = form
|
|
guard let machineId = current.machineId else { return }
|
|
let directory = current.trimmedDirectory
|
|
guard !directory.isEmpty, !spawnInFlight else { return }
|
|
if current.sessionType == .worktree,
|
|
NewSessionLogic.worktreeNameError(current.worktreeName) != nil {
|
|
return
|
|
}
|
|
guard selectedAgentAvailable else {
|
|
spawnError = agentAvailabilityError ?? Self.msgSelectedAgentUnavailable
|
|
return
|
|
}
|
|
spawnInFlight = true
|
|
isSpawning = true
|
|
spawnError = nil
|
|
Task { [weak self] in
|
|
defer {
|
|
self?.spawnInFlight = false
|
|
self?.isSpawning = false
|
|
}
|
|
guard let self else { return }
|
|
let api = self.session.api
|
|
do {
|
|
let pathResult = try await api.machinePathsExist(
|
|
machineId: machineId,
|
|
paths: [directory]
|
|
)
|
|
let exists = pathResult.exists[directory]
|
|
if let exists {
|
|
self.pathExistence[directory] = exists
|
|
}
|
|
self.outsideWorkspaceRoots.remove(directory)
|
|
self.outsideWorkspaceRoots.formUnion(pathResult.outsideWorkspaceRoots ?? [])
|
|
if pathResult.outsideWorkspaceRoots?.contains(directory) == true {
|
|
self.spawnError = Self.msgDirectoryOutsideWorkspaceRoots
|
|
return
|
|
}
|
|
if current.sessionType == .worktree, exists == false {
|
|
self.spawnError = Self.msgWorktreeMissing
|
|
return
|
|
}
|
|
if current.sessionType == .simple,
|
|
exists == false,
|
|
!self.confirmCreateDirectoryArmed {
|
|
self.confirmCreateDirectoryArmed = true
|
|
return
|
|
}
|
|
|
|
let request = NewSessionLogic.buildSpawnRequest(
|
|
form: current,
|
|
codexFastTierVisible: self.codexFastTierVisible(current)
|
|
)
|
|
switch try await api.spawnSession(machineId: machineId, request) {
|
|
case .success(let sessionId):
|
|
self.persistOnSuccess(machineId: machineId, directory: directory)
|
|
self.onCreated(sessionId)
|
|
case .error(let message, let code, _):
|
|
self.spawnError = Self.spawnErrorMessage(code: code, fallback: message)
|
|
}
|
|
} catch let error as APIError {
|
|
self.spawnError = Self.spawnErrorMessage(
|
|
code: error.code,
|
|
fallback: error.errorDescription
|
|
)
|
|
} catch {
|
|
self.spawnError = (error as? LocalizedError)?.errorDescription
|
|
?? String(localized: "Failed to create session")
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Internals
|
|
|
|
private func pickPath(_ path: String) {
|
|
suppressSuggestions = true
|
|
confirmCreateDirectoryArmed = false
|
|
suggestions = []
|
|
form.directory = path
|
|
persistDraft()
|
|
scheduleDirectoryWork()
|
|
}
|
|
|
|
private func recentPaths(for machineId: String?) -> [String] {
|
|
machineId.flatMap { prefsData.recentPaths[$0] } ?? []
|
|
}
|
|
|
|
private func reconcileMachineSelection() {
|
|
let machines = session.machineStore.machines
|
|
guard !machines.isEmpty else { return }
|
|
if let current = form.machineId, machines.contains(where: { $0.id == current }) {
|
|
if form.trimmedDirectory.isEmpty {
|
|
applyMachineSelection(current, resetDirectory: true)
|
|
}
|
|
return
|
|
}
|
|
let target = machines.first { $0.id == prefsData.lastMachineId } ?? machines[0]
|
|
applyMachineSelection(target.id, resetDirectory: form.trimmedDirectory.isEmpty)
|
|
}
|
|
|
|
private func applyMachineSelection(_ machineId: String, resetDirectory: Bool) {
|
|
directoryBrowser.close()
|
|
defaultDirectoryTask?.cancel()
|
|
pathExistence = [:]
|
|
outsideWorkspaceRoots = []
|
|
directoryLookupError = nil
|
|
suggestions = []
|
|
cachedListing = nil
|
|
confirmCreateDirectoryArmed = false
|
|
// The seeded recent path is a pick, not typing — no dropdown.
|
|
suppressSuggestions = true
|
|
form.machineId = machineId
|
|
form.model = "auto"
|
|
if resetDirectory {
|
|
let machine = session.machineStore.machines.first { $0.id == machineId }
|
|
form.directory = machine.flatMap { RemoteDirectoryPath.browseRoots(for: $0).first } ?? ""
|
|
}
|
|
persistDraft()
|
|
if resetDirectory {
|
|
resolveDefaultDirectory(machineId: machineId, fallback: form.directory)
|
|
}
|
|
refreshAgentAvailability(force: true)
|
|
refreshCodexModelsIfNeeded()
|
|
scheduleDirectoryWork()
|
|
}
|
|
|
|
private func resolveDefaultDirectory(machineId: String, fallback: String) {
|
|
defaultDirectoryTask?.cancel()
|
|
let recent = recentPaths(for: machineId)
|
|
guard !recent.isEmpty else { return }
|
|
defaultDirectoryTask = Task { [weak self] in
|
|
guard let self else { return }
|
|
let result: MachinePathsExistsResponse
|
|
do {
|
|
result = try await self.session.api.machinePathsExist(
|
|
machineId: machineId,
|
|
paths: recent
|
|
)
|
|
} catch is CancellationError {
|
|
return
|
|
} catch {
|
|
return
|
|
}
|
|
guard !Task.isCancelled else { return }
|
|
let outside = Set(result.outsideWorkspaceRoots ?? [])
|
|
guard let valid = recent.first(where: {
|
|
result.exists[$0] == true && !outside.contains($0)
|
|
}) else {
|
|
return
|
|
}
|
|
guard self.form.machineId == machineId, self.form.directory == fallback else { return }
|
|
self.suppressSuggestions = true
|
|
self.form.directory = valid
|
|
self.persistDraft()
|
|
self.scheduleDirectoryWork()
|
|
}
|
|
}
|
|
|
|
/// Debounced directory work: parent listing for autocomplete + exists
|
|
/// probe, both riding one 250 ms debounce like the Android reference.
|
|
private func scheduleDirectoryWork() {
|
|
directoryTask?.cancel()
|
|
directoryRequestVersion += 1
|
|
let requestVersion = directoryRequestVersion
|
|
guard let machineId = form.machineId else {
|
|
suggestions = []
|
|
return
|
|
}
|
|
directoryTask = Task { [weak self] in
|
|
try? await Task.sleep(for: Self.debounce)
|
|
guard !Task.isCancelled, let self else { return }
|
|
guard self.directoryRequestVersion == requestVersion,
|
|
self.form.machineId == machineId
|
|
else {
|
|
return
|
|
}
|
|
await self.performDirectoryWork(machineId: machineId, requestVersion: requestVersion)
|
|
}
|
|
}
|
|
|
|
private func performDirectoryWork(machineId: String, requestVersion: Int) async {
|
|
let text = form.directory
|
|
let trimmed = form.trimmedDirectory
|
|
let api = session.api
|
|
|
|
let query = suppressSuggestions ? nil : NewSessionLogic.parentQuery(for: text)
|
|
if let query {
|
|
let cacheKey = (machineId, query.parent)
|
|
let entries: [MachineDirectoryEntry]
|
|
if let cached = cachedListing, (cached.machineId, cached.parent) == cacheKey {
|
|
entries = cached.entries
|
|
directoryLookupError = nil
|
|
} else {
|
|
do {
|
|
let response = try await api.listMachineDirectory(
|
|
machineId: machineId,
|
|
path: query.parent
|
|
)
|
|
guard isCurrentDirectoryRequest(requestVersion, machineId: machineId) else { return }
|
|
if response.success {
|
|
entries = response.entries ?? []
|
|
cachedListing = (machineId, query.parent, entries)
|
|
directoryLookupError = nil
|
|
} else {
|
|
entries = []
|
|
directoryLookupError = response.error ?? Self.msgDirectoryLookupFailed
|
|
}
|
|
} catch is CancellationError {
|
|
return
|
|
} catch {
|
|
guard isCurrentDirectoryRequest(requestVersion, machineId: machineId) else { return }
|
|
directoryLookupError = (error as? LocalizedError)?.errorDescription
|
|
?? Self.msgDirectoryLookupFailed
|
|
entries = []
|
|
}
|
|
}
|
|
guard isCurrentDirectoryRequest(requestVersion, machineId: machineId) else { return }
|
|
// Never suggest the path already typed verbatim.
|
|
suggestions = NewSessionLogic.buildSuggestions(query: query, entries: entries)
|
|
.filter { $0 != trimmed }
|
|
} else {
|
|
suggestions = []
|
|
directoryLookupError = nil
|
|
}
|
|
|
|
if !trimmed.isEmpty {
|
|
// Unknown existence (request failed): no status hint, the spawn
|
|
// re-checks anyway.
|
|
do {
|
|
let result = try await api.machinePathsExist(machineId: machineId, paths: [trimmed])
|
|
guard isCurrentDirectoryRequest(requestVersion, machineId: machineId) else { return }
|
|
pathExistence.merge(result.exists) { _, new in new }
|
|
outsideWorkspaceRoots.remove(trimmed)
|
|
outsideWorkspaceRoots.formUnion(result.outsideWorkspaceRoots ?? [])
|
|
} catch is CancellationError {
|
|
return
|
|
} catch {
|
|
// Unknown existence: no status hint, spawn re-checks anyway.
|
|
}
|
|
}
|
|
}
|
|
|
|
private func isCurrentDirectoryRequest(_ requestVersion: Int, machineId: String) -> Bool {
|
|
!Task.isCancelled
|
|
&& directoryRequestVersion == requestVersion
|
|
&& form.machineId == machineId
|
|
}
|
|
|
|
private func refreshAgentAvailability(force: Bool = false) {
|
|
guard let machineId = form.machineId else {
|
|
availabilityTask?.cancel()
|
|
availabilityFetchedForMachine = nil
|
|
agentAvailability = .loading
|
|
return
|
|
}
|
|
if !force,
|
|
availabilityFetchedForMachine == machineId,
|
|
agentAvailability != .loading {
|
|
return
|
|
}
|
|
availabilityTask?.cancel()
|
|
availabilityFetchedForMachine = machineId
|
|
agentAvailability = .loading
|
|
availabilityTask = Task { [weak self] in
|
|
guard let self else { return }
|
|
let state: AgentAvailabilityState
|
|
do {
|
|
let response = try await self.session.api.machineAgentAvailability(machineId: machineId)
|
|
state = .loaded(response.agents)
|
|
} catch let error as APIError where error.code == "runner_upgrade_required" {
|
|
state = .failed(message: Self.msgRunnerUpgradeRequired, upgradeRequired: true)
|
|
} catch is CancellationError {
|
|
return
|
|
} catch is APIError {
|
|
state = .failed(message: Self.msgAgentAvailabilityFailed, upgradeRequired: false)
|
|
} catch {
|
|
state = .failed(
|
|
message: (error as? LocalizedError)?.errorDescription
|
|
?? Self.msgAgentAvailabilityFailed,
|
|
upgradeRequired: false
|
|
)
|
|
}
|
|
guard !Task.isCancelled, self.form.machineId == machineId else { return }
|
|
self.agentAvailability = state
|
|
guard case .loaded = state,
|
|
let firstAvailable = self.availableAgentFlavors.first,
|
|
!self.availableAgentFlavors.contains(self.form.agent)
|
|
else {
|
|
return
|
|
}
|
|
self.setAgent(firstAvailable)
|
|
}
|
|
}
|
|
|
|
private func refreshCodexModelsIfNeeded() {
|
|
guard form.agent == .codex else {
|
|
codexTask?.cancel()
|
|
codexFetchedForMachine = nil
|
|
codexModels = .hidden
|
|
return
|
|
}
|
|
guard let machineId = form.machineId else {
|
|
codexModels = .hidden
|
|
return
|
|
}
|
|
if codexFetchedForMachine == machineId, codexModels != .hidden {
|
|
return
|
|
}
|
|
codexFetchedForMachine = machineId
|
|
codexTask?.cancel()
|
|
codexModels = .loading
|
|
codexTask = Task { [weak self] in
|
|
guard let self else { return }
|
|
let state: CodexModelsState
|
|
do {
|
|
let response = try await self.session.api.machineCodexModels(machineId: machineId)
|
|
state = response.success
|
|
? .loaded(response.models ?? [])
|
|
: .failed(response.error ?? String(localized: "Failed to load Codex models"))
|
|
} catch let error as APIError where error.code == "rpc_target_missing" {
|
|
state = .unsupported
|
|
} catch is CancellationError {
|
|
return
|
|
} catch {
|
|
state = .failed(
|
|
(error as? LocalizedError)?.errorDescription
|
|
?? String(localized: "Failed to load Codex models")
|
|
)
|
|
}
|
|
guard !Task.isCancelled,
|
|
self.form.machineId == machineId,
|
|
self.form.agent == .codex
|
|
else {
|
|
return
|
|
}
|
|
self.codexModels = state
|
|
if case .loaded = state {
|
|
// Reconcile restored selections with the live catalog (web
|
|
// validation effects): unknown model → auto; unsupported
|
|
// effort → default; no fast tier → standard.
|
|
self.form = self.reconcileCodexSelections(self.form)
|
|
self.persistDraft()
|
|
}
|
|
}
|
|
}
|
|
|
|
private func reconcileCodexSelections(_ current: NewSessionForm) -> NewSessionForm {
|
|
guard case .loaded(let models) = codexModels else { return current }
|
|
var next = current
|
|
if next.model != "auto", !models.contains(where: { $0.id == next.model }) {
|
|
next.model = "auto"
|
|
}
|
|
if next.modelReasoningEffort != "default",
|
|
let supported = NewSessionLogic.codexReasoningEfforts(models: models, model: next.model),
|
|
!supported.contains(next.modelReasoningEffort) {
|
|
next.modelReasoningEffort = "default"
|
|
}
|
|
if next.serviceTier != .standard,
|
|
!NewSessionLogic.codexModelAdvertisesFastTier(model: next.model, models: models) {
|
|
next.serviceTier = .standard
|
|
}
|
|
return next
|
|
}
|
|
|
|
private func codexFastTierVisible(_ current: NewSessionForm) -> Bool {
|
|
guard current.agent == .codex, case .loaded(let models) = codexModels else { return false }
|
|
return NewSessionLogic.codexModelAdvertisesFastTier(model: current.model, models: models)
|
|
}
|
|
|
|
private func persistDraft() {
|
|
prefsStore.writeDraft(form)
|
|
}
|
|
|
|
private func persistOnSuccess(machineId: String, directory: String) {
|
|
prefsData.lastMachineId = machineId
|
|
prefsData.recentPaths[machineId] = NewSessionLogic.pushRecent(
|
|
prefsData.recentPaths[machineId] ?? [],
|
|
path: directory
|
|
)
|
|
prefsStore.writePrefs(prefsData)
|
|
prefsStore.clearDraft()
|
|
}
|
|
|
|
private static func spawnErrorMessage(code: String?, fallback: String?) -> String {
|
|
switch code {
|
|
case "runner_upgrade_required":
|
|
return msgRunnerUpgradeRequired
|
|
case "agent_unavailable":
|
|
return msgSelectedAgentUnavailable
|
|
case "outside_workspace_roots":
|
|
return msgDirectoryOutsideWorkspaceRoots
|
|
default:
|
|
let trimmed = fallback?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
|
return trimmed.isEmpty ? String(localized: "Failed to create session") : trimmed
|
|
}
|
|
}
|
|
|
|
// MARK: - Formatting (Android `NewSessionViewModel` companion ports)
|
|
|
|
/// `getMachineOptionLabel` (web `MachineSelector`), minus capability-skew
|
|
/// (TODO with the machine detail screen).
|
|
static func machineOption(_ machine: Machine) -> MachineOptionUI {
|
|
let metadata = machine.metadata
|
|
let title = metadata?.displayName.flatMap { name in
|
|
name.trimmingCharacters(in: .whitespaces).isEmpty ? nil : name
|
|
}
|
|
?? metadata?.host
|
|
?? String(machine.id.prefix(8))
|
|
let platform = metadata.map { " (\($0.platform))" } ?? ""
|
|
let version = metadata.map { " · CLI \($0.happyCliVersion)" } ?? ""
|
|
let healthParts: [String] = [
|
|
machine.health?.cpuPercent.map { "CPU \(Int($0))%" },
|
|
machine.health?.memoryPercent.map { "Mem \(Int($0))%" },
|
|
].compactMap { $0 }
|
|
return MachineOptionUI(
|
|
id: machine.id,
|
|
label: "\(title)\(platform)\(version)",
|
|
healthLabel: healthParts.isEmpty ? nil : healthParts.joined(separator: " · ")
|
|
)
|
|
}
|
|
|
|
/// `formatRunnerSpawnError` (`web/src/utils/formatRunnerSpawnError.ts`).
|
|
static func formatRunnerSpawnError(_ machine: Machine?) -> String? {
|
|
guard case .object(let error)? = machine?.runnerState?.lastSpawnError else { return nil }
|
|
guard case .string(let message)? = error["message"], !message.isEmpty else { return nil }
|
|
if case .number(let at)? = error["at"] {
|
|
let date = Date(timeIntervalSince1970: at / 1000)
|
|
return "\(message) (\(date.formatted(date: .abbreviated, time: .shortened)))"
|
|
}
|
|
return message
|
|
}
|
|
}
|