// 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[.. 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()) }