SwiftUI watch-only app in watch/, generated by XcodeGen. Codable models mirror gateway/src/contract.ts, with lenient string enums so new phases or decisions still decode. A DemoGateway serves fixtures from the docs/api.md examples for previews and demo mode. Swift Testing covers decoding the documented JSON, request encoding, notification action parsing and the small view helpers. Co-Authored-By: Claude Opus 5.5 <[email protected]>
211 lines
8.4 KiB
Swift
211 lines
8.4 KiB
Swift
// 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())
|
||
}
|