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:
1 parent
ba1624eb9e
commit
bdfb79b891
19 files changed
+2413
No files matched your search
@@ -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())
|
||||
}
|
||||
Reference in new issue
Block a user