Files
wristcode/watch/WristCode/Views/NewThreadView.swift
T

369 lines
15 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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())
}