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:
1 parent
a4754b65b0
commit
cf8995e63f
16 files changed
+347
-31
No files matched your search
@@ -1,5 +1,6 @@
|
||||
// Mock B's attention feed: the count waiting on you, then cards for every host. Needs you first
|
||||
// (with Allow and Deny on the card), then working (with what the agent is doing), then recent.
|
||||
// A Recent row swipes left to settle, and the Recent header settles every completed thread.
|
||||
|
||||
import SwiftUI
|
||||
|
||||
@@ -14,16 +15,27 @@ struct FeedView: View {
|
||||
.feedRow()
|
||||
|
||||
if let feed = model.feed {
|
||||
let recent = feed.recent.filter { !model.settling.contains($0.id) }
|
||||
ForEach(feed.needsYou) { item in
|
||||
NeedsYouCard(item: item).feedRow()
|
||||
}
|
||||
ForEach(feed.working) { item in
|
||||
StatusCard(item: item).feedRow()
|
||||
}
|
||||
ForEach(feed.recent) { item in
|
||||
StatusCard(item: item).feedRow()
|
||||
if !recent.isEmpty {
|
||||
RecentHeader(recent: recent).feedRow()
|
||||
}
|
||||
if feed.needsYou.isEmpty && feed.working.isEmpty && feed.recent.isEmpty {
|
||||
ForEach(recent) { item in
|
||||
StatusCard(item: item)
|
||||
.feedRow()
|
||||
.swipeActions(edge: .trailing) {
|
||||
Button { Task { await model.settle([item]) } } label: {
|
||||
Label("Settle", systemImage: "checkmark")
|
||||
}
|
||||
.tint(Theme.work)
|
||||
}
|
||||
}
|
||||
if feed.needsYou.isEmpty && feed.working.isEmpty && recent.isEmpty {
|
||||
Text("No threads in the last day.")
|
||||
.font(.system(size: 13))
|
||||
.foregroundStyle(Theme.secondaryText)
|
||||
@@ -118,8 +130,33 @@ private struct FeedHeader: View {
|
||||
}
|
||||
}
|
||||
|
||||
/// "Recent" over the finished and failed threads, with Settle all when any completed. Settle all
|
||||
/// leaves failed threads, which settle one at a time by swipe.
|
||||
struct RecentHeader: View {
|
||||
@Environment(AppModel.self) private var model
|
||||
var recent: [FeedItem]
|
||||
|
||||
var body: some View {
|
||||
let completed = recent.filter { $0.phase == .completed }
|
||||
HStack {
|
||||
Text("Recent")
|
||||
.foregroundStyle(Theme.secondaryText)
|
||||
Spacer()
|
||||
if !completed.isEmpty {
|
||||
Button("Settle all") { Task { await model.settle(completed) } }
|
||||
.buttonStyle(.plain)
|
||||
.foregroundStyle(Theme.work)
|
||||
}
|
||||
}
|
||||
.font(.system(size: 12, weight: .semibold))
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.top, 4)
|
||||
}
|
||||
}
|
||||
|
||||
/// An amber card for a thread waiting on an approval or an answer, with Allow and Deny inline.
|
||||
private struct NeedsYouCard: View {
|
||||
/// When a subagent asked, one line names it, and the answer goes to the subagent.
|
||||
struct NeedsYouCard: View {
|
||||
@Environment(AppModel.self) private var model
|
||||
var item: FeedItem
|
||||
@State private var sending = false
|
||||
@@ -136,6 +173,14 @@ private struct NeedsYouCard: View {
|
||||
Text(item.title)
|
||||
.font(.system(size: 15, weight: .semibold))
|
||||
.lineLimit(2)
|
||||
if let via = item.via, let request = item.request {
|
||||
let name = Text(via.title).bold().foregroundStyle(.white)
|
||||
Text(request.isQuestion ? "Subagent \(name) asks:" : "Subagent \(name) wants to run:")
|
||||
.font(.system(size: 12))
|
||||
.foregroundStyle(Theme.secondaryText)
|
||||
.lineLimit(2)
|
||||
.padding(.top, 2)
|
||||
}
|
||||
if let request = item.request {
|
||||
Text(request.summary)
|
||||
.font(request.isQuestion ? .system(size: 12) : .system(size: 12, design: .monospaced))
|
||||
@@ -179,8 +224,9 @@ private struct NeedsYouCard: View {
|
||||
error = nil
|
||||
Task {
|
||||
do {
|
||||
let target = item.requestThread
|
||||
try await model.api.respond(
|
||||
hostId: item.hostId, threadId: item.threadId,
|
||||
hostId: target.hostId, threadId: target.threadId,
|
||||
request: .decision(requestId: request.id, decision: decision))
|
||||
} catch let failure as GatewayError where failure.isStale {
|
||||
// Someone answered it already; the refresh below drops the card.
|
||||
@@ -193,8 +239,9 @@ private struct NeedsYouCard: View {
|
||||
}
|
||||
}
|
||||
|
||||
/// A teal card for a working thread, or a grey one for a recent thread.
|
||||
private struct StatusCard: View {
|
||||
/// A teal card for a working thread, or a grey one for a recent thread. A recent thread that failed
|
||||
/// to settle shows why.
|
||||
struct StatusCard: View {
|
||||
@Environment(AppModel.self) private var model
|
||||
var item: FeedItem
|
||||
|
||||
@@ -217,6 +264,12 @@ private struct StatusCard: View {
|
||||
.lineLimit(2)
|
||||
.padding(.top, 3)
|
||||
}
|
||||
if let error = model.settleErrors[item.id] {
|
||||
Text(error)
|
||||
.font(.system(size: 11))
|
||||
.foregroundStyle(Theme.secondaryText)
|
||||
.padding(.top, 4)
|
||||
}
|
||||
}
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in new issue
Block a user