// One thread: the agent's latest message large, older messages one scroll up with Load earlier above // them, the pending approval or question with its options, the thread's subagents, Reply by // dictation or Scribble, Settle once it finished or failed, an Ask before acting switch, and Stop // while a run is active. A subagent's request shows first, labeled with the subagent. Message text // renders as markdown. import SwiftUI struct ThreadView: View { @Environment(AppModel.self) private var model @Environment(\.scenePhase) private var scenePhase @Environment(\.dismiss) private var dismiss 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: RespondRequest.Answer] = [:] /// Options ticked so far on multi-select questions, by question id. @State private var selections: [String: Set] = [:] @State private var scrolledToLatest = false /// Why the last Ask before acting change failed. It shows under the switch. @State private var modeError: String? /// The Ask before acting value still being sent, which a poll landing meanwhile keeps. @State private var pendingAskFirst: Bool? /// Pages Load earlier fetched. The 10 s refresh replaces only the latest window. @State private var history = MessageHistory() @State private var loadingEarlier = false /// Snapshots render the column without its ScrollView, which ImageRenderer leaves blank. private let scrolls: Bool /// `detail` and `selections` start the screen with a thread loaded and options ticked, for /// snapshots and previews. init( hostId: String, threadId: String, detail: ThreadDetail? = nil, selections: [String: Set] = [:], scrolls: Bool = true ) { self.hostId = hostId self.threadId = threadId self.scrolls = scrolls _detail = State(initialValue: detail) _selections = State(initialValue: selections) } var body: some View { if scrolls { screen } else { column } } private var column: some View { 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) } private var screen: some View { ScrollViewReader { proxy in ScrollView { column } .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(history.messages(before: detail.messages)) let item = detail.item if let cursor = history.cursor(detail) { Button(loadingEarlier ? "Loading" : "Load earlier") { loadEarlier(cursor) } .buttonStyle(ChoiceButtonStyle(fill: Theme.card, foreground: Theme.secondaryText)) .disabled(loadingEarlier) } ForEach(split.earlier) { message in MessageRow(message: message) } // A subagent's request opens the screen, above the parent's latest message. if let via = item.via, let request = item.request { requestCards(request, from: via) .id("latest") .padding(.top, split.earlier.isEmpty ? 0 : 6) } Text(Markdown.render(split.latest?.text ?? item.lastMessage ?? "No reply yet.")) .font(.system(size: 16)) .foregroundStyle(Theme.bodyText) .lineSpacing(1) .frame(maxWidth: .infinity, alignment: .leading) .id(item.via == nil ? "latest" : "message") .padding(.top, split.earlier.isEmpty || item.via != nil ? 0 : 6) ForEach(split.after) { message in MessageRow(message: message) } Text(statusLine(item)) .font(.system(size: 11, weight: .semibold)) .foregroundStyle(item.phase.color) if item.via == nil, let request = item.request { requestCards(request, from: nil) } if !detail.subagents.isEmpty { Text("Subagents") .font(.system(size: 11, weight: .semibold)) .foregroundStyle(Theme.secondaryText) .padding(.horizontal, 6) .padding(.top, 4) ForEach(detail.subagents) { row in ThreadLine(row: row) { Text(row.statusWord) .foregroundStyle(row.isActive ? row.color : Theme.secondaryText) } } } if item.phase == .completed && item.request == nil { QuickReplies { send($0) } .disabled(sending) } let settleable = (item.phase == .completed || item.phase == .failed) && item.request == nil TextFieldLink(prompt: Text("Reply")) { Label("Reply", systemImage: "mic.fill") } onSubmit: { text in send(text) } .buttonStyle(settleable ? PillButtonStyle(fill: Theme.card, foreground: .white) : PillButtonStyle(fill: Theme.work)) .disabled(sending) if settleable { Button { settle() } label: { Label("Settle", systemImage: "checkmark") } .buttonStyle(PillButtonStyle(fill: Theme.work)) .disabled(sending) } if let askFirst = detail.askFirst { SwitchCard(title: "Ask before acting", isOn: Binding(get: { askFirst }, set: { setAskFirst($0) })) if let modeError { Text(modeError) .font(.system(size: 11)) .foregroundStyle(Theme.bad) } } if detail.canStop { Button(role: .destructive) { stop() } label: { Label("Stop", systemImage: "stop.fill") } .buttonStyle(PillButtonStyle(fill: Theme.control, foreground: .red)) .disabled(sending) } } @ViewBuilder private func requestCards(_ request: PendingRequest, from via: FeedItem.Via?) -> some View { if request.isQuestion { questions(request, from: via) } else { approval(request, from: via) } } /// "From subagent" and its title, at the top of a request card a subagent sent. @ViewBuilder private func subagentLabel(_ via: FeedItem.Via?) -> some View { if let via { Text("From subagent") .font(.system(size: 11, weight: .semibold)) .foregroundStyle(Theme.you) Text(via.title) .font(.system(size: 13, weight: .semibold)) .padding(.top, 1) .padding(.bottom, 3) } } private func approval(_ request: PendingRequest, from via: FeedItem.Via?) -> some View { Card(edge: Theme.you) { subagentLabel(via) 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, from via: FeedItem.Via?) -> some View { ForEach(request.questions) { question in Card(edge: Theme.you) { subagentLabel(via) Text(question.header) .font(.system(size: 11, weight: .semibold)) .foregroundStyle(Theme.you) Text(question.question) .font(.system(size: 14, weight: .semibold)) .padding(.top, 1) if question.multiSelect { Text("Choose any") .font(.system(size: 11)) .foregroundStyle(Theme.secondaryText) .padding(.top, 1) } VStack(spacing: 6) { if question.multiSelect { multiSelectOptions(request, question) } else { ForEach(question.options, id: \.value) { choice in let chosen = answers[question.id] == .text(choice.value) Button(choice.label) { answer(request, question.id, .text(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(text)) } .buttonStyle(ChoiceButtonStyle()) } } .padding(.top, 8) .disabled(sending) } } } /// Checkmark toggles, then Send. With more questions still open, the button says Done and the /// answers go out together once the last one is in. @ViewBuilder private func multiSelectOptions(_ request: PendingRequest, _ question: PendingRequest.Question) -> some View { let picked = selections[question.id, default: []] ForEach(question.options, id: \.value) { choice in let checked = picked.contains(choice.value) Button { toggle(question.id, choice.value) } label: { HStack(spacing: 6) { Image(systemName: checked ? "checkmark.circle.fill" : "circle") Text(choice.label) .multilineTextAlignment(.leading) Spacer(minLength: 0) } } .buttonStyle(ChoiceButtonStyle( fill: checked ? Theme.you : Theme.control, foreground: checked ? .black : .white)) .accessibilityAddTraits(checked ? .isSelected : []) } let last = request.questions.allSatisfy { $0.id == question.id || answers[$0.id] != nil } Button(last ? "Send" : "Done") { let values = question.options.map(\.value).filter(picked.contains) answer(request, question.id, .values(values)) } .buttonStyle(PillButtonStyle( fill: picked.isEmpty ? Theme.control : Theme.you, foreground: picked.isEmpty ? Theme.done : .black)) .disabled(picked.isEmpty) } 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 { var fresh = try await model.api.thread(hostId: hostId, threadId: threadId) if let pendingAskFirst { fresh.askFirst = pendingAskFirst } if let detail { history.keep(detail.messages) } detail = fresh error = nil } catch { self.error = model.message(for: error) } } /// Fetches the page before `cursor` and puts it above the messages already shown. private func loadEarlier(_ cursor: String) { loadingEarlier = true Task { do { history.prepend(try await model.api.history(hostId: hostId, threadId: threadId, cursor: cursor)) } catch { self.error = model.message(for: error) } loadingEarlier = false } } 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) } } /// Answers the pending request on the thread that asked, which is the subagent when `via` is set. private func respond(_ request: RespondRequest) { let target: (hostId: String, threadId: String) = detail?.item.requestThread ?? (hostId, threadId) perform { try await model.api.respond(hostId: target.hostId, threadId: target.threadId, request: request) } } private func toggle(_ questionId: String, _ value: String) { if selections[questionId, default: []].remove(value) == nil { selections[questionId, default: []].insert(value) } } /// Records one answer. Once every question has one, sends them together. private func answer(_ request: PendingRequest, _ questionId: String, _ value: RespondRequest.Answer) { 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 = [:] selections = [:] respond(.answers(requestId: request.id, answers: payload)) } private func stop() { perform { try await model.api.stop(hostId: hostId, threadId: threadId) } } /// Flips the switch at once and tells the gateway. If it refuses, the switch flips back and the /// line under it shows why. private func setAskFirst(_ on: Bool) { detail?.askFirst = on pendingAskFirst = on modeError = nil Task { do { try await model.api.setAskFirst(hostId: hostId, threadId: threadId, askFirst: on) } catch { detail?.askFirst = !on modeError = model.message(for: error) } pendingAskFirst = nil } } /// Settles the thread and goes back to the feed. On failure the screen stays and shows why. private func settle() { sending = true error = nil Task { do { try await model.api.settle(hostId: hostId, threadId: threadId) dismiss() await model.refreshFeed() } catch { self.error = model.message(for: error) } sending = false } } } /// 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[.. String? { loaded ? nextCursor : detail.earlierCursor } /// The older pages, then `latest`. func messages(before latest: [Message]) -> [Message] { let ids = Set(latest.map(\.id)) return older.filter { !ids.contains($0.id) } + latest } mutating func prepend(_ page: HistoryResponse) { older = Self.unique(page.messages + older) nextCursor = page.earlierCursor loaded = true } /// Keeps the window a refresh replaces, once older pages are in. mutating func keep(_ window: [Message]) { guard loaded else { return } older = Self.unique(older + window) } private static func unique(_ messages: [Message]) -> [Message] { var seen = Set() return messages.filter { seen.insert($0.id).inserted } } } private struct MessageRow: View { var message: Message var body: some View { let mine = message.role == .user Text(Markdown.render(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("Subagent") { NavigationStack { ThreadView(hostId: "mac-mini", threadId: "t-homelab") } .environment(AppModel.preview()) } #Preview("Question") { NavigationStack { ThreadView(hostId: "server-gib", threadId: "t-db") } .environment(AppModel.preview()) } #Preview("Multi-select") { NavigationStack { ThreadView(hostId: "mac-mini", threadId: "t-release", selections: ["checks": ["unit", "lint"]]) } .environment(AppModel.preview()) }