Watch: show subagent requests on the parent's card, and settle threads by swipe, Settle all and the thread screen

This commit is contained in:
gib committed 2026-11-01 20:26:38 -05:00
1 parent a4754b65b0
commit cf8995e63f
16 files changed
+347 -31

No files matched your search

+77 -13
View File
@@ -1,11 +1,13 @@
// 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.
// or question with its options, Reply by dictation or Scribble, Stop while a run is active, and
// Settle once it finished or failed. A subagent's request shows first, labeled with the subagent.
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
@@ -98,13 +100,20 @@ struct ThreadView: View {
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(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)
.id(item.via == nil ? "latest" : "message")
.padding(.top, split.earlier.isEmpty || item.via != nil ? 0 : 6)
ForEach(split.after) { message in
MessageRow(message: message)
@@ -114,12 +123,8 @@ struct ThreadView: View {
.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.via == nil, let request = item.request {
requestCards(request, from: nil)
}
if item.phase == .completed && item.request == nil {
@@ -127,14 +132,23 @@ struct ThreadView: View {
.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(PillButtonStyle(fill: Theme.work))
.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 detail.canStop {
Button(role: .destructive) { stop() } label: {
Label("Stop", systemImage: "stop.fill")
@@ -144,8 +158,32 @@ struct ThreadView: View {
}
}
private func approval(_ request: PendingRequest) -> some View {
@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))
@@ -173,9 +211,10 @@ struct ThreadView: View {
}
}
private func questions(_ request: PendingRequest) -> some View {
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)
@@ -291,8 +330,10 @@ struct ThreadView: View {
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) {
perform { try await model.api.respond(hostId: hostId, threadId: threadId, request: request) }
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) {
@@ -315,6 +356,22 @@ struct ThreadView: View {
private func stop() {
perform { try await model.api.stop(hostId: hostId, threadId: threadId) }
}
/// 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.
@@ -379,6 +436,13 @@ private struct QuickReplies: View {
.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")