// Start a thread: pick where (host, then project), see the model it will use, dictate the task. // Defaults to the last host and project a thread was started in. 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 = "" @State private var projects: ProjectsResponse? @State private var selection: ProjectRef? @State private var picking = false @State private var sending = false @State private var error: String? var body: some View { ScrollView { 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 { Card(edge: Theme.done) { Text("Agent") .font(.system(size: 11, weight: .semibold)) .foregroundStyle(Theme.secondaryText) Text(chosen.project.model.map(Format.modelName) ?? "") .font(.system(size: 15, weight: .semibold)) } TextFieldLink(prompt: Text("What should it do?")) { Label(sending ? "Starting" : "Say the task", systemImage: "mic.fill") } onSubmit: { text in start(text, in: chosen) } .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) } .navigationTitle("New thread") .toolbarForegroundStyle(Theme.you, for: .navigationBar) .sheet(isPresented: $picking) { if let projects { ProjectPicker(projects: projects) { ref in selection = ref picking = false } } } .task { await load() } } 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 func resolve(_ ref: ProjectRef?, in projects: ProjectsResponse) -> (host: ProjectsResponse.Host, project: ProjectsResponse.Project)? { 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: (host: ProjectsResponse.Host, project: ProjectsResponse.Project)) { let text = text.trimmingCharacters(in: .whitespacesAndNewlines) guard !text.isEmpty else { return } sending = true error = nil Task { do { let created = try await model.api.newThread( NewThreadRequest(hostId: chosen.host.id, projectId: chosen.project.id, text: text)) 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 } } } /// 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()) }