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]>
This commit is contained in:
gibandClaude Opus 5.5 committed 2026-11-01 02:33:15 -05:00
1 parent ba1624eb9e
commit bdfb79b891
19 files changed
+2413

No files matched your search

+210
View File
@@ -0,0 +1,210 @@
// 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())
}