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,309 @@
|
||||
// One thread: the agent's latest message large, older messages one scroll up, the pending approval
|
||||
// or question with its options, Reply by dictation or Scribble, and Stop while a run is active.
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct ThreadView: View {
|
||||
@Environment(AppModel.self) private var model
|
||||
@Environment(\.scenePhase) private var scenePhase
|
||||
let hostId: String
|
||||
let threadId: String
|
||||
|
||||
@State private var detail: ThreadDetail?
|
||||
@State private var error: String?
|
||||
@State private var sending = false
|
||||
@State private var visible = false
|
||||
@State private var answers: [String: String] = [:]
|
||||
@State private var scrolledToLatest = false
|
||||
|
||||
var body: some View {
|
||||
ScrollViewReader { proxy in
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
if let detail {
|
||||
content(detail)
|
||||
} 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)
|
||||
}
|
||||
.onChange(of: detail?.messages.last?.id) {
|
||||
guard !scrolledToLatest, detail != nil else { return }
|
||||
scrolledToLatest = true
|
||||
proxy.scrollTo("latest", anchor: .top)
|
||||
}
|
||||
.onAppear {
|
||||
if detail != nil, !scrolledToLatest {
|
||||
scrolledToLatest = true
|
||||
proxy.scrollTo("latest", anchor: .top)
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle(detail?.item.title ?? "Thread")
|
||||
.onAppear { visible = true }
|
||||
.onDisappear { visible = false }
|
||||
.task(id: visible && scenePhase == .active) {
|
||||
guard visible && scenePhase == .active else { return }
|
||||
while !Task.isCancelled {
|
||||
await load()
|
||||
try? await Task.sleep(for: .seconds(10))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func content(_ detail: ThreadDetail) -> some View {
|
||||
let split = MessageSplit(detail.messages)
|
||||
let item = detail.item
|
||||
|
||||
ForEach(split.earlier) { message in
|
||||
MessageRow(message: message)
|
||||
}
|
||||
|
||||
Text(split.latest?.text ?? item.lastMessage ?? "No reply yet.")
|
||||
.font(.system(size: 16))
|
||||
.foregroundStyle(Theme.bodyText)
|
||||
.lineSpacing(1)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.id("latest")
|
||||
.padding(.top, split.earlier.isEmpty ? 0 : 6)
|
||||
|
||||
ForEach(split.after) { message in
|
||||
MessageRow(message: message)
|
||||
}
|
||||
|
||||
Text(statusLine(item))
|
||||
.font(.system(size: 11, weight: .semibold))
|
||||
.foregroundStyle(item.phase.color)
|
||||
|
||||
if let request = item.request {
|
||||
if request.isQuestion {
|
||||
questions(request)
|
||||
} else {
|
||||
approval(request)
|
||||
}
|
||||
}
|
||||
|
||||
if item.phase == .completed && item.request == nil {
|
||||
QuickReplies { send($0) }
|
||||
.disabled(sending)
|
||||
}
|
||||
|
||||
TextFieldLink(prompt: Text("Reply")) {
|
||||
Label("Reply", systemImage: "mic.fill")
|
||||
} onSubmit: { text in
|
||||
send(text)
|
||||
}
|
||||
.buttonStyle(PillButtonStyle(fill: Theme.work))
|
||||
.disabled(sending)
|
||||
|
||||
if detail.canStop {
|
||||
Button(role: .destructive) { stop() } label: {
|
||||
Label("Stop", systemImage: "stop.fill")
|
||||
}
|
||||
.buttonStyle(PillButtonStyle(fill: Theme.control, foreground: .red))
|
||||
.disabled(sending)
|
||||
}
|
||||
}
|
||||
|
||||
private func approval(_ request: PendingRequest) -> some View {
|
||||
Card(edge: Theme.you) {
|
||||
if let prompt = request.prompt {
|
||||
Text(prompt)
|
||||
.font(.system(size: 13, design: .monospaced))
|
||||
.foregroundStyle(.white)
|
||||
} else {
|
||||
Text(request.summary)
|
||||
.font(.system(size: 14, weight: .semibold))
|
||||
}
|
||||
if let reason = request.reason {
|
||||
Text(reason)
|
||||
.font(.system(size: 12))
|
||||
.foregroundStyle(Theme.secondaryText)
|
||||
.padding(.top, 3)
|
||||
}
|
||||
VStack(spacing: 6) {
|
||||
ForEach(Array(request.options.enumerated()), id: \.offset) { index, option in
|
||||
Button(option.label) { respond(.decision(requestId: request.id, decision: option.decision)) }
|
||||
.buttonStyle(ChoiceButtonStyle(
|
||||
fill: index == 0 ? Theme.you : Theme.control,
|
||||
foreground: index == 0 ? .black : .white))
|
||||
}
|
||||
}
|
||||
.padding(.top, 8)
|
||||
.disabled(sending)
|
||||
}
|
||||
}
|
||||
|
||||
private func questions(_ request: PendingRequest) -> some View {
|
||||
ForEach(request.questions) { question in
|
||||
Card(edge: Theme.you) {
|
||||
Text(question.header)
|
||||
.font(.system(size: 11, weight: .semibold))
|
||||
.foregroundStyle(Theme.you)
|
||||
Text(question.question)
|
||||
.font(.system(size: 14, weight: .semibold))
|
||||
.padding(.top, 1)
|
||||
VStack(spacing: 6) {
|
||||
ForEach(question.options, id: \.value) { choice in
|
||||
let chosen = answers[question.id] == choice.value
|
||||
Button(choice.label) { answer(request, question.id, choice.value) }
|
||||
.buttonStyle(ChoiceButtonStyle(
|
||||
fill: chosen ? Theme.you : Theme.control,
|
||||
foreground: chosen ? .black : .white))
|
||||
}
|
||||
if question.allowCustomAnswer {
|
||||
TextFieldLink(prompt: Text(question.question)) {
|
||||
Text("Other…")
|
||||
} onSubmit: { text in
|
||||
answer(request, question.id, text)
|
||||
}
|
||||
.buttonStyle(ChoiceButtonStyle())
|
||||
}
|
||||
}
|
||||
.padding(.top, 8)
|
||||
.disabled(sending)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func statusLine(_ item: FeedItem) -> String {
|
||||
let place = "\(item.hostLabel) · \(item.projectTitle)"
|
||||
return switch item.phase {
|
||||
case .waitingForApproval: "Needs approval · \(place)"
|
||||
case .waitingForInput: "Has a question · \(place)"
|
||||
case .starting, .running: "Working · \(place)"
|
||||
case .failed: "Failed · \(place)"
|
||||
default: place
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Actions
|
||||
|
||||
private func load() async {
|
||||
do {
|
||||
detail = try await model.api.thread(hostId: hostId, threadId: threadId)
|
||||
error = nil
|
||||
} catch {
|
||||
self.error = model.message(for: error)
|
||||
}
|
||||
}
|
||||
|
||||
private func perform(_ action: @escaping @MainActor () async throws -> Void) {
|
||||
sending = true
|
||||
error = nil
|
||||
Task {
|
||||
do {
|
||||
try await action()
|
||||
} catch let failure as GatewayError where failure.isStale {
|
||||
self.error = "Already answered."
|
||||
} catch {
|
||||
self.error = model.message(for: error)
|
||||
}
|
||||
await load()
|
||||
await model.refreshFeed()
|
||||
sending = false
|
||||
}
|
||||
}
|
||||
|
||||
private func send(_ text: String) {
|
||||
let text = text.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !text.isEmpty else { return }
|
||||
perform { try await model.api.sendMessage(hostId: hostId, threadId: threadId, text: text) }
|
||||
}
|
||||
|
||||
private func respond(_ request: RespondRequest) {
|
||||
perform { try await model.api.respond(hostId: hostId, threadId: threadId, request: request) }
|
||||
}
|
||||
|
||||
/// Records one answer. Once every question has one, sends them together.
|
||||
private func answer(_ request: PendingRequest, _ questionId: String, _ value: String) {
|
||||
answers[questionId] = value
|
||||
let all = request.questions.allSatisfy { answers[$0.id] != nil }
|
||||
guard all else { return }
|
||||
let payload = answers.filter { key, _ in request.questions.contains { $0.id == key } }
|
||||
answers = [:]
|
||||
respond(.answers(requestId: request.id, answers: payload))
|
||||
}
|
||||
|
||||
private func stop() {
|
||||
perform { try await model.api.stop(hostId: hostId, threadId: threadId) }
|
||||
}
|
||||
}
|
||||
|
||||
/// Splits a thread's messages around the agent's latest message, which the screen shows large.
|
||||
struct MessageSplit {
|
||||
var earlier: [Message]
|
||||
var latest: Message?
|
||||
var after: [Message]
|
||||
|
||||
init(_ messages: [Message]) {
|
||||
guard let index = messages.lastIndex(where: { $0.role == .assistant }) else {
|
||||
earlier = messages
|
||||
latest = nil
|
||||
after = []
|
||||
return
|
||||
}
|
||||
earlier = Array(messages[..<index])
|
||||
latest = messages[index]
|
||||
after = Array(messages[(index + 1)...])
|
||||
}
|
||||
}
|
||||
|
||||
private struct MessageRow: View {
|
||||
var message: Message
|
||||
|
||||
var body: some View {
|
||||
let mine = message.role == .user
|
||||
Text(message.text)
|
||||
.font(.system(size: 13))
|
||||
.foregroundStyle(mine ? Color.white : Theme.secondaryText)
|
||||
.padding(.horizontal, mine ? 9 : 0)
|
||||
.padding(.vertical, mine ? 6 : 0)
|
||||
.background(mine ? Theme.card : Color.clear, in: RoundedRectangle(cornerRadius: 12, style: .continuous))
|
||||
.frame(maxWidth: .infinity, alignment: mine ? .trailing : .leading)
|
||||
}
|
||||
}
|
||||
|
||||
/// One-tap replies for a finished turn, like mock B's chips.
|
||||
private struct QuickReplies: View {
|
||||
var send: (String) -> Void
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 6) {
|
||||
ForEach(["Yes", "Continue", "Commit it"], id: \.self) { reply in
|
||||
Button(reply) { send(reply) }
|
||||
.buttonStyle(ChoiceButtonStyle(fill: Theme.card))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#Preview("Finished") {
|
||||
NavigationStack {
|
||||
ThreadView(hostId: "littleman", threadId: "t-next")
|
||||
}
|
||||
.environment(AppModel.preview())
|
||||
}
|
||||
|
||||
#Preview("Approval") {
|
||||
NavigationStack {
|
||||
ThreadView(hostId: "mac-mini", threadId: "t-ocr")
|
||||
}
|
||||
.environment(AppModel.preview())
|
||||
}
|
||||
|
||||
#Preview("Question") {
|
||||
NavigationStack {
|
||||
ThreadView(hostId: "server-gib", threadId: "t-db")
|
||||
}
|
||||
.environment(AppModel.preview())
|
||||
}
|
||||
Reference in new issue
Block a user