// Start a thread: pick where (host, then project), pick the model from the host's providers, choose // whether it asks before acting, and dictate the task. Defaults to the last host and project a // thread was started in, the last model picked on that host, and the last Ask before acting. import SwiftUI struct ProjectRef: Hashable { var hostId: String var projectId: String } struct NewThreadView: View { @Environment(AppModel.self) private var model @AppStorage("lastHostId") private var lastHostId = "" @AppStorage("lastProjectId") private var lastProjectId = "" /// The last model picked on each host, a JSON `[hostId: ModelRef]`. @AppStorage("lastModels") private var lastModelsData = Data() @AppStorage("askFirst") private var askFirst = false @State private var projects: ProjectsResponse? @State private var selection: ProjectRef? @State private var picking = false @State private var choosingAgent = false @State private var sending = false @State private var error: String? /// Snapshots render the column without its ScrollView, which ImageRenderer leaves blank. private let scrolls: Bool /// `projects` starts the screen loaded, on the first project a thread can start in, for snapshots. init(projects: ProjectsResponse? = nil, scrolls: Bool = true) { self.scrolls = scrolls _projects = State(initialValue: projects) _selection = State(initialValue: projects.flatMap { Self.defaultSelection(in: $0, last: ProjectRef(hostId: "", projectId: "")) }) } var body: some View { if scrolls { ScrollView { column } .navigationTitle("New thread") .toolbarForegroundStyle(Theme.you, for: .navigationBar) .sheet(isPresented: $picking) { if let projects { ProjectPicker(projects: projects) { ref in selection = ref picking = false } } } .navigationDestination(isPresented: $choosingAgent) { if let projects, let chosen = resolve(selection, in: projects) { AgentList( providers: chosen.host.providers, selected: agent(for: chosen), defaultInstanceId: chosen.project.instanceId ) { pick in lastModels[chosen.host.id] = pick } } } .task { await load() } } else { column } } private var column: some View { VStack(alignment: .leading, spacing: 6) { if let projects { let chosen = resolve(selection, in: projects) Button { picking = true } label: { Card(edge: Theme.done) { Text("Where") .font(.system(size: 11, weight: .semibold)) .foregroundStyle(Theme.secondaryText) Text(chosen.map { "\($0.host.label) › \($0.project.title)" } ?? "Choose a project") .font(.system(size: 15, weight: .semibold)) .lineLimit(2) } .contentShape(Rectangle()) } .buttonStyle(.plain) if let chosen { let agent = agent(for: chosen) let provider = agent.flatMap { chosen.host.provider($0.instanceId) } agentRow(chosen, agent: agent) if provider?.canAskFirst == true { SwitchCard(title: "Ask before acting", isOn: $askFirst, tint: Theme.you, edge: Theme.done) } TextFieldLink(prompt: Text("What should it do?")) { Label(sending ? "Starting" : "Say the task", systemImage: "mic.fill") } onSubmit: { text in start(text, in: chosen, agent: agent, provider: provider) } .buttonStyle(PillButtonStyle(fill: Theme.you)) .disabled(sending || chosen.host.status != .online) .padding(.top, 4) if chosen.host.status != .online { Text("\(chosen.host.label) is \(chosen.host.status.problem ?? "offline").") .font(.system(size: 12)) .foregroundStyle(Theme.secondaryText) } } } else if error == nil { Text("Loading") .font(.system(size: 15, weight: .semibold)) .foregroundStyle(Theme.secondaryText) } if let error { Text(error) .font(.system(size: 12)) .foregroundStyle(Theme.secondaryText) } } .padding(.horizontal, 4) } /// The model the thread will use. It opens the model list when the host lists its providers. @ViewBuilder private func agentRow(_ chosen: Chosen, agent: ModelRef?) -> some View { let card = Card(edge: Theme.done) { Text("Agent") .font(.system(size: 11, weight: .semibold)) .foregroundStyle(Theme.secondaryText) HStack { Text(agent.flatMap { modelName($0, on: chosen.host) } ?? chosen.project.model.map(Format.modelName) ?? "") .font(.system(size: 15, weight: .semibold)) if !chosen.host.providers.isEmpty { Spacer(minLength: 4) Image(systemName: "chevron.right") .font(.system(size: 11, weight: .bold)) .foregroundStyle(Theme.secondaryText) } } } if chosen.host.providers.isEmpty { card } else { Button { choosingAgent = true } label: { card.contentShape(Rectangle()) } .buttonStyle(.plain) } } private func modelName(_ ref: ModelRef, on host: ProjectsResponse.Host) -> String? { host.provider(ref.instanceId)?.models.first { $0.slug == ref.slug }?.name } private var lastModels: [String: ModelRef] { get { (try? JSONDecoder().decode([String: ModelRef].self, from: lastModelsData)) ?? [:] } nonmutating set { lastModelsData = (try? JSONEncoder().encode(newValue)) ?? Data() } } private func agent(for chosen: Chosen) -> ModelRef? { Self.agent(on: chosen.host, project: chosen.project, last: lastModels[chosen.host.id]) } /// The last model picked on the host while the host still offers it, else the project's default. nonisolated static func agent( on host: ProjectsResponse.Host, project: ProjectsResponse.Project, last: ModelRef? ) -> ModelRef? { if let last, host.offers(last) { return last } return project.defaultModel } /// What a new thread sends besides its text. The model goes only when it differs from the /// project's default, so the default keeps its options, such as reasoning effort. Ask before /// acting is false for a provider that cannot ask, and left out when the host lists no providers. nonisolated static func options( project: ProjectsResponse.Project, agent: ModelRef?, provider: AgentProvider?, askFirst: Bool ) -> (model: ModelRef?, askFirst: Bool?) { let model = agent == project.defaultModel ? nil : agent return (model, provider.map { $0.canAskFirst && askFirst }) } private func load() async { do { let response = try await model.api.projects() projects = response if selection == nil { selection = Self.defaultSelection( in: response, last: ProjectRef(hostId: lastHostId, projectId: lastProjectId)) } error = nil } catch { self.error = model.message(for: error) } } private typealias Chosen = (host: ProjectsResponse.Host, project: ProjectsResponse.Project) private func resolve(_ ref: ProjectRef?, in projects: ProjectsResponse) -> Chosen? { guard let ref, let host = projects.hosts.first(where: { $0.id == ref.hostId }), let project = host.launchable.first(where: { $0.id == ref.projectId }) else { return nil } return (host, project) } /// The last used project when it can still start threads, else the first project on the /// first online host. nonisolated static func defaultSelection(in projects: ProjectsResponse, last: ProjectRef) -> ProjectRef? { if let host = projects.hosts.first(where: { $0.id == last.hostId }), host.launchable.contains(where: { $0.id == last.projectId }) { return last } let hosts = projects.hosts.filter { $0.status == .online } + projects.hosts.filter { $0.status != .online } for host in hosts { if let project = host.launchable.first { return ProjectRef(hostId: host.id, projectId: project.id) } } return nil } private func start(_ text: String, in chosen: Chosen, agent: ModelRef?, provider: AgentProvider?) { let text = text.trimmingCharacters(in: .whitespacesAndNewlines) guard !text.isEmpty else { return } sending = true error = nil let options = Self.options(project: chosen.project, agent: agent, provider: provider, askFirst: askFirst) Task { do { let created = try await model.api.newThread(NewThreadRequest( hostId: chosen.host.id, projectId: chosen.project.id, text: text, model: options.model, askFirst: options.askFirst)) lastHostId = chosen.host.id lastProjectId = chosen.project.id await model.refreshFeed() // Replace this screen with the new thread, so Back returns to the feed. let route = Route.thread(hostId: created.hostId, threadId: created.threadId) if model.path.last == .newThread { model.path[model.path.count - 1] = route } else { model.path.append(route) } } catch { self.error = model.message(for: error) } sending = false } } } /// Every model the host offers, grouped by provider, with the project default's provider on top. /// The current pick has a check. Tapping a model picks it and goes back. struct AgentList: View { @Environment(\.dismiss) private var dismiss var providers: [AgentProvider] var selected: ModelRef? var defaultInstanceId: String? var scrolls = true var pick: (ModelRef) -> Void var body: some View { if scrolls { ScrollView { column } .navigationTitle("Agent") } else { column } } private var column: some View { let ordered = providers.filter { $0.instanceId == defaultInstanceId } + providers.filter { $0.instanceId != defaultInstanceId } return VStack(alignment: .leading, spacing: 5) { ForEach(ordered) { provider in Text(provider.name) .font(.system(size: 11, weight: .semibold)) .foregroundStyle(Theme.secondaryText) .padding(.horizontal, 6) .padding(.top, 4) ForEach(provider.models) { model in let ref = ModelRef(instanceId: provider.instanceId, slug: model.slug) Button { pick(ref) dismiss() } label: { Card(edge: Theme.card) { HStack { Text(Self.shortName(model.name, provider: provider.name)) .font(.system(size: 14, weight: .semibold)) .foregroundStyle(.white) Spacer(minLength: 4) if ref == selected { Image(systemName: "checkmark") .font(.system(size: 13, weight: .bold)) .foregroundStyle(Theme.work) } } } .contentShape(Rectangle()) } .buttonStyle(.plain) .accessibilityAddTraits(ref == selected ? .isSelected : []) } } } .padding(.horizontal, 4) } /// "Claude Opus 5.5" under the Claude label reads "Opus 5.5". static func shortName(_ name: String, provider: String) -> String { let prefix = provider + " " return name.hasPrefix(prefix) && name.count > prefix.count ? String(name.dropFirst(prefix.count)) : name } } /// Two steps: the environment (host), then one of its projects. private struct ProjectPicker: View { var projects: ProjectsResponse var pick: (ProjectRef) -> Void var body: some View { NavigationStack { List(projects.hosts) { host in NavigationLink { List(host.launchable) { project in Button { pick(ProjectRef(hostId: host.id, projectId: project.id)) } label: { VStack(alignment: .leading, spacing: 1) { Text(project.title) .font(.system(size: 15, weight: .semibold)) Text(project.model.map(Format.modelName) ?? "") .font(.system(size: 11)) .foregroundStyle(Theme.secondaryText) } } } .navigationTitle(host.label) .overlay { if host.launchable.isEmpty { Text("No projects with a default model.") .font(.system(size: 13)) .foregroundStyle(Theme.secondaryText) .multilineTextAlignment(.center) } } } label: { VStack(alignment: .leading, spacing: 1) { Text(host.label) .font(.system(size: 15, weight: .semibold)) Text(host.status.problem ?? "\(host.launchable.count) projects") .font(.system(size: 11)) .foregroundStyle(host.status == .online ? Theme.secondaryText : Theme.done) } } } .navigationTitle("Where") } .fontDesign(.rounded) } } #Preview("New thread") { NavigationStack { NewThreadView() } .environment(AppModel.preview()) }