361 lines
15 KiB
Swift
361 lines
15 KiB
Swift
// 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)
|
||
}
|
||
}
|
||
}
|
||
LoadStatus(loaded: projects != nil, error: error, retry: load)
|
||
}
|
||
.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 {
|
||
if let message = model.message(for: error) { self.error = message }
|
||
}
|
||
}
|
||
|
||
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())
|
||
}
|