Files
hapi/ios/Hapi/Features/NewSession/NewSessionModel.swift
T

971 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 (grok + codex-family).
case nativeSelect([PermissionModeOption])
/// HAPI YOLO toggle (claude/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
}
}