Files
wristcode/watch/WristCode/Views/ThreadView.swift
T
gibandClaude Opus 5.5 9731fd621f Watch: demo screenshots, wrapping reply chips, feed toolbar and inset fixes, README
Screenshots of Pair, Feed, Thread (finished, approval, question) and New
thread, taken in the Series 12 46mm simulator in demo mode.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 02:33:15 -05:00

310 lines
11 KiB
Swift

// 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 {
FlowLayout {
ForEach(["Yes", "Continue", "Commit it"], id: \.self) { reply in
Button(reply) { send(reply) }
.buttonStyle(ChipButtonStyle())
}
}
}
}
#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())
}