Files
wristcode/watch/WristCode/Views/NewThreadView.swift
T
gibandClaude Opus 5.5 bdfb79b891 Watch app MVP: XcodeGen project, gateway client, pair, feed, thread, new thread, notifications
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]>
2026-11-01 02:33:15 -05:00

211 lines
8.4 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), 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())
}