Watch: notification switches, a model list and Ask before acting on new threads, and Ask before acting on a thread

This commit is contained in:
gib committed 2026-11-02 12:10:48 -05:00
1 parent 3cc7e40852
commit 6bc9cac6ca
14 files changed
+623 -81

No files matched your search

+222 -64
View File
@@ -1,5 +1,6 @@
// 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.
// 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
@@ -12,79 +13,170 @@ 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 {
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)
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
}
}
} 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)
.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
}
}
}
}
.padding(.horizontal, 4)
.task { await load() }
} else {
column
}
.navigationTitle("New thread")
.toolbarForegroundStyle(Theme.you, for: .navigationBar)
.sheet(isPresented: $picking) {
}
private var column: some View {
VStack(alignment: .leading, spacing: 6) {
if let projects {
ProjectPicker(projects: projects) { ref in
selection = ref
picking = false
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)
}
}
}
.task { await load() }
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 {
@@ -101,9 +193,9 @@ struct NewThreadView: View {
}
}
private func resolve(_ ref: ProjectRef?, in projects: ProjectsResponse)
-> (host: ProjectsResponse.Host, project: ProjectsResponse.Project)?
{
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 })
@@ -128,15 +220,17 @@ struct NewThreadView: View {
return nil
}
private func start(_ text: String, in chosen: (host: ProjectsResponse.Host, project: ProjectsResponse.Project)) {
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))
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()
@@ -155,6 +249,70 @@ struct NewThreadView: View {
}
}
/// 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