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
+346 -30

No files matched your search

+16 -3
View File
@@ -38,7 +38,8 @@ register their token with the gateway as `production`, Debug builds as `sandbox`
## Demo mode ## Demo mode
`DemoGateway` serves fixture data shaped like the docs/api.md examples, and it reacts to writes: `DemoGateway` serves fixture data shaped like the docs/api.md examples, and it reacts to writes:
Allow moves a thread to working, a reply restarts a finished thread. The SwiftUI previews use it, Allow moves a thread to working, a reply restarts a finished thread, and Settle hides a thread from
the feed. One card carries a subagent's request, so its Allow goes to the subagent's thread. The SwiftUI previews use it,
the pairing screen has a "Try the demo" button, and launch arguments open a screen directly: the pairing screen has a "Try the demo" button, and launch arguments open a screen directly:
```sh ```sh
@@ -50,8 +51,9 @@ xcrun simctl launch <udid> org.gbrown.wristcode.watchkitapp -demo -host laptop
xcrun simctl launch <udid> org.gbrown.wristcode.watchkitapp -demo -screen notifications # notifications xcrun simctl launch <udid> org.gbrown.wristcode.watchkitapp -demo -screen notifications # notifications
``` ```
`screenshots/` holds the screens captured this way. The complication, the multi-select question and `screenshots/` holds the screens captured this way. The complication, the multi-select question, the
the status screens are rendered by `SnapshotTests` instead, which only run when given a directory: status screens, the subagent card and thread, Recent with Settle all, and a finished thread with
Settle are rendered by `SnapshotTests` instead, which only run when given a directory:
```sh ```sh
TEST_RUNNER_WC_SCREENSHOT_DIR=$PWD/screenshots xcodebuild -scheme WristCode \ TEST_RUNNER_WC_SCREENSHOT_DIR=$PWD/screenshots xcodebuild -scheme WristCode \
@@ -61,6 +63,17 @@ TEST_RUNNER_WC_SCREENSHOT_DIR=$PWD/screenshots xcodebuild -scheme WristCode \
In demo mode the complication shows the demo counts too. In demo mode the complication shows the demo counts too.
## Subagents and settling
A thread an agent started for another thread has no card of its own. When it waits on you, the
gateway puts its request on the parent's card and names it in `via`. The card says "Subagent
<title> wants to run:" or "asks:", and the parent's thread screen shows the request first, labeled
"From subagent". Every answer goes to the subagent's thread.
Settle clears a finished or failed thread from the feed, as in T3. Swipe a Recent row left, tap
Settle all on the Recent header (completed threads only, failed ones stay), or tap Settle under
Reply on the thread screen. A row leaves at once and comes back with the error if its call fails.
## Status and settings ## Status and settings
The gear at the top left of the feed opens the status overview (`GET /v1/status`). It leads with The gear at the top left of the feed opens the status overview (`GET /v1/status`). It leads with
+6
View File
@@ -49,6 +49,8 @@ protocol GatewayAPI: Sendable {
func sendMessage(hostId: String, threadId: String, text: String) async throws func sendMessage(hostId: String, threadId: String, text: String) async throws
func respond(hostId: String, threadId: String, request: RespondRequest) async throws func respond(hostId: String, threadId: String, request: RespondRequest) async throws
func stop(hostId: String, threadId: String) async throws func stop(hostId: String, threadId: String) async throws
/// Settles a thread, as in T3. A settled thread that finished or failed leaves the feed.
func settle(hostId: String, threadId: String) async throws
func projects() async throws -> ProjectsResponse func projects() async throws -> ProjectsResponse
func newThread(_ request: NewThreadRequest) async throws -> NewThreadResponse func newThread(_ request: NewThreadRequest) async throws -> NewThreadResponse
func registerPush(_ registration: PushRegistration) async throws func registerPush(_ registration: PushRegistration) async throws
@@ -95,6 +97,10 @@ struct GatewayClient: GatewayAPI {
try await callIgnoringBody("POST", ["v1", "threads", hostId, threadId, "stop"], body: nil as SendMessageRequest?) try await callIgnoringBody("POST", ["v1", "threads", hostId, threadId, "stop"], body: nil as SendMessageRequest?)
} }
func settle(hostId: String, threadId: String) async throws {
try await callIgnoringBody("POST", ["v1", "threads", hostId, threadId, "settle"], body: nil as SendMessageRequest?)
}
func projects() async throws -> ProjectsResponse { func projects() async throws -> ProjectsResponse {
try await call("GET", ["v1", "projects"]) try await call("GET", ["v1", "projects"])
} }
+15
View File
@@ -134,6 +134,13 @@ extension PendingRequest.Question {
} }
struct FeedItem: Codable, Hashable, Sendable, Identifiable { struct FeedItem: Codable, Hashable, Sendable, Identifiable {
/// The subagent a request came from, when a thread delegated work and that work waits on you.
struct Via: Codable, Hashable, Sendable {
var hostId: String
var threadId: String
var title: String
}
var hostId: String var hostId: String
var hostLabel: String var hostLabel: String
var threadId: String var threadId: String
@@ -144,9 +151,17 @@ struct FeedItem: Codable, Hashable, Sendable, Identifiable {
var updatedAt: Date var updatedAt: Date
var lastMessage: String? var lastMessage: String?
var request: PendingRequest? var request: PendingRequest?
/// Set when `request` and `phase` belong to a subagent rather than this thread. Older gateways
/// leave it out, which decodes as nil.
var via: Via? = nil
var id: String { "\(hostId)/\(threadId)" } var id: String { "\(hostId)/\(threadId)" }
var route: Route { .thread(hostId: hostId, threadId: threadId) } var route: Route { .thread(hostId: hostId, threadId: threadId) }
/// The thread that answers `request`: the subagent named in `via`, or this thread.
var requestThread: (hostId: String, threadId: String) {
via.map { ($0.hostId, $0.threadId) } ?? (hostId, threadId)
}
} }
struct Feed: Codable, Hashable, Sendable { struct Feed: Codable, Hashable, Sendable {
+36
View File
@@ -26,6 +26,10 @@ final class AppModel {
var path: [Route] = [] var path: [Route] = []
private(set) var feed: Feed? private(set) var feed: Feed?
private(set) var feedError: String? private(set) var feedError: String?
/// Ids of feed items whose settle call is still out. The feed hides them meanwhile.
private(set) var settling: Set<String> = []
/// Why settling an item failed, by item id. Its row shows the message.
private(set) var settleErrors: [String: String] = [:]
private var apnsToken: String? private var apnsToken: String?
private var refreshing = false private var refreshing = false
/// The counts the complication was last told about. /// The counts the complication was last told about.
@@ -151,6 +155,38 @@ final class AppModel {
} }
} }
/// Settles items concurrently. Each one leaves the feed at once and comes back with its error
/// if its call fails. Refreshes once every call has answered.
func settle(_ items: [FeedItem]) async {
let ids = Set(items.map(\.id))
settling.formUnion(ids)
for id in ids { settleErrors[id] = nil }
let api = api
let failures = await withTaskGroup(of: (String, (any Error)?).self) { group in
for item in items {
group.addTask {
do {
try await api.settle(hostId: item.hostId, threadId: item.threadId)
return (item.id, nil)
} catch {
return (item.id, error)
}
}
}
var failures: [String: any Error] = [:]
for await (id, error) in group {
if let error { failures[id] = error }
}
return failures
}
for (id, error) in failures {
settleErrors[id] = message(for: error)
}
feed?.recent.removeAll { ids.contains($0.id) && failures[$0.id] == nil }
settling.subtract(ids)
await refreshFeed()
}
func open(_ route: Route) { func open(_ route: Route) {
path = [route] path = [route]
} }
+56 -4
View File
@@ -1,6 +1,7 @@
// Fixture data shaped like the docs/api.md examples, served by an in-memory gateway. Demo mode and // Fixture data shaped like the docs/api.md examples, served by an in-memory gateway. Demo mode and
// SwiftUI previews use it, so every screen renders without a live gateway. It reacts to writes: // SwiftUI previews use it, so every screen renders without a live gateway. It reacts to writes:
// answering an approval moves the thread to working, a reply restarts a finished thread, and so on. // answering an approval moves the thread to working, a reply restarts a finished thread, settling
// hides a thread from the feed, and so on.
import Foundation import Foundation
@@ -12,6 +13,8 @@ actor DemoGateway: GatewayAPI {
private let hosts: [HostSummary] private let hosts: [HostSummary]
private let projectHosts: [ProjectsResponse.Host] private let projectHosts: [ProjectsResponse.Host]
private var statusResponse: StatusResponse private var statusResponse: StatusResponse
/// Ids of settled threads, which the feed leaves out.
private var settled: Set<String> = []
private var nextId = 1 private var nextId = 1
init(now: Date = .now) { init(now: Date = .now) {
@@ -24,6 +27,7 @@ actor DemoGateway: GatewayAPI {
} }
func feed() async throws -> Feed { func feed() async throws -> Feed {
let items = items.filter { !settled.contains($0.id) }
let needsYou = items.filter(\.phase.needsYou).sorted { $0.updatedAt < $1.updatedAt } let needsYou = items.filter(\.phase.needsYou).sorted { $0.updatedAt < $1.updatedAt }
let working = items.filter(\.phase.isWorking).sorted { $0.updatedAt > $1.updatedAt } let working = items.filter(\.phase.isWorking).sorted { $0.updatedAt > $1.updatedAt }
let recent = items.filter { $0.phase == .completed || $0.phase == .failed } let recent = items.filter { $0.phase == .completed || $0.phase == .failed }
@@ -42,6 +46,7 @@ actor DemoGateway: GatewayAPI {
func sendMessage(hostId: String, threadId: String, text: String) async throws { func sendMessage(hostId: String, threadId: String, text: String) async throws {
try update(hostId, threadId) { item in try update(hostId, threadId) { item in
settled.remove(item.id)
append(Message(id: newId(), role: .user, text: text, createdAt: .now), to: item.id) append(Message(id: newId(), role: .user, text: text, createdAt: .now), to: item.id)
if !item.phase.isWorking { if !item.phase.isWorking {
item.phase = .running item.phase = .running
@@ -50,10 +55,18 @@ actor DemoGateway: GatewayAPI {
} }
} }
/// Answers go to the thread that asked. For a card showing a subagent's request, that is the
/// subagent in `via`, and the parent itself has nothing to answer.
func respond(hostId: String, threadId: String, request: RespondRequest) async throws { func respond(hostId: String, threadId: String, request: RespondRequest) async throws {
try update(hostId, threadId) { item in let index = items.firstIndex { $0.requestThread == (hostId, threadId) }
guard item.request != nil else { throw GatewayError.http(status: 409, message: "Already answered.") } ?? items.firstIndex { $0.hostId == hostId && $0.threadId == threadId }
guard let index else { throw GatewayError.http(status: 404, message: "Unknown thread.") }
try update(items[index].hostId, items[index].threadId) { item in
guard item.request != nil, item.requestThread == (hostId, threadId) else {
throw GatewayError.http(status: 409, message: "Already answered.")
}
item.request = nil item.request = nil
item.via = nil
switch request { switch request {
case let .decision(_, decision) where decision == .decline || decision == .cancel: case let .decision(_, decision) where decision == .decline || decision == .cancel:
item.phase = .completed item.phase = .completed
@@ -75,6 +88,11 @@ actor DemoGateway: GatewayAPI {
} }
} }
func settle(hostId: String, threadId: String) async throws {
guard let item = item(hostId, threadId) else { throw GatewayError.http(status: 404, message: "Unknown thread.") }
settled.insert(item.id)
}
func projects() async throws -> ProjectsResponse { func projects() async throws -> ProjectsResponse {
ProjectsResponse(hosts: projectHosts) ProjectsResponse(hosts: projectHosts)
} }
@@ -194,6 +212,17 @@ struct DemoFixtures {
allowCustomAnswer: true, multiSelect: true), allowCustomAnswer: true, multiSelect: true),
])) ]))
// A subagent's request, shown on the card of the thread that started it.
let homelab = FeedItem(
hostId: "mac-mini", hostLabel: "Mac mini", threadId: "t-homelab", projectId: "p-wristlab",
projectTitle: "WristLab", title: "HomeLab Stats Apple Watch App", phase: .waitingForApproval,
updatedAt: ago(4), lastMessage: "Delegated a PATH check to the Mac mini. Waiting on it before I wire up the build.",
request: PendingRequest(
id: "r-path", kind: "command", prompt: "brew install jq", reason: nil,
options: [.init(decision: .accept, label: "Allow"), .init(decision: .decline, label: "Deny")],
questions: []),
via: .init(hostId: "mac-mini", threadId: "t-path", title: "Check Panama PATH"))
let scaffold = FeedItem( let scaffold = FeedItem(
hostId: "mac-mini", hostLabel: "Mac mini", threadId: "t-scaffold", projectId: "p-wc-mac", hostId: "mac-mini", hostLabel: "Mac mini", threadId: "t-scaffold", projectId: "p-wc-mac",
projectTitle: "wristcode", title: "Watch app scaffold", phase: .running, updatedAt: ago(0.5), projectTitle: "wristcode", title: "Watch app scaffold", phase: .running, updatedAt: ago(0.5),
@@ -214,7 +243,18 @@ struct DemoFixtures {
projectTitle: "cdn", title: "Fix CDN headers", phase: .failed, updatedAt: ago(140), projectTitle: "cdn", title: "Fix CDN headers", phase: .failed, updatedAt: ago(140),
lastMessage: "cargo test failed: 2 tests in headers.rs", request: nil) lastMessage: "cargo test failed: 2 tests in headers.rs", request: nil)
items = [approval, question, release, scaffold, containerfile, nextjs, cdn] let statusScreens = FeedItem(
hostId: "laptop", hostLabel: "Laptop", threadId: "t-status", projectId: "p-wc-laptop",
projectTitle: "wristcode", title: "Status screens", phase: .completed, updatedAt: ago(35),
lastMessage: "Done. Status, environment and notification screens are in TestFlight build 202611011516.",
request: nil)
let hyprlock = FeedItem(
hostId: "server-gib", hostLabel: "server-gib", threadId: "t-hyprlock", projectId: "p-panama",
projectTitle: "Panama", title: "Fix hyprlock crash", phase: .completed, updatedAt: ago(95),
lastMessage: "Fixed. hyprlock no longer crashes when a monitor sleeps.", request: nil)
items = [approval, question, release, homelab, scaffold, containerfile, statusScreens, nextjs, hyprlock, cdn]
func m(_ id: String, _ role: Message.Role, _ minutes: Double, _ text: String) -> Message { func m(_ id: String, _ role: Message.Role, _ minutes: Double, _ text: String) -> Message {
Message(id: id, role: role, text: text, createdAt: ago(minutes)) Message(id: id, role: role, text: text, createdAt: ago(minutes))
@@ -234,6 +274,10 @@ struct DemoFixtures {
m("r1", .user, 25, "Get the watch app ready for TestFlight."), m("r1", .user, 25, "Get the watch app ready for TestFlight."),
m("r2", .assistant, 6, "The build is ready. Which checks should run first?"), m("r2", .assistant, 6, "The build is ready. Which checks should run first?"),
], ],
homelab.id: [
m("h1", .user, 30, "Build a HomeLab stats app for the watch."),
m("h2", .assistant, 4, "Delegated a PATH check to the Mac mini. Waiting on it before I wire up the build."),
],
scaffold.id: [ scaffold.id: [
m("c1", .user, 4, "Scaffold the watch app with XcodeGen."), m("c1", .user, 4, "Scaffold the watch app with XcodeGen."),
m("c2", .assistant, 0.5, "Writing ThreadListView.swift"), m("c2", .assistant, 0.5, "Writing ThreadListView.swift"),
@@ -247,6 +291,14 @@ struct DemoFixtures {
m("e2", .assistant, 80, "Updating next and eslint-config-next, then running the build."), m("e2", .assistant, 80, "Updating next and eslint-config-next, then running the build."),
m("e3", .assistant, 62, "Done. Next.js is on 17.1 and the build passes. Want me to commit it?"), m("e3", .assistant, 62, "Done. Next.js is on 17.1 and the build passes. Want me to commit it?"),
], ],
statusScreens.id: [
m("s1", .user, 120, "Build the status, environment and notification screens."),
m("s2", .assistant, 35, "Done. Status, environment and notification screens are in TestFlight build 202611011516."),
],
hyprlock.id: [
m("k1", .user, 110, "hyprlock crashes when a monitor sleeps. Fix it."),
m("k2", .assistant, 95, "Fixed. hyprlock no longer crashes when a monitor sleeps."),
],
cdn.id: [ cdn.id: [
m("f1", .user, 150, "Fix the cache headers the CDN sends for images."), m("f1", .user, 150, "Fix the cache headers the CDN sends for images."),
m("f2", .assistant, 140, "cargo test failed: 2 tests in headers.rs"), m("f2", .assistant, 140, "cargo test failed: 2 tests in headers.rs"),
+60 -7
View File
@@ -1,5 +1,6 @@
// Mock B's attention feed: the count waiting on you, then cards for every host. Needs you first // 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. // (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 import SwiftUI
@@ -14,16 +15,27 @@ struct FeedView: View {
.feedRow() .feedRow()
if let feed = model.feed { if let feed = model.feed {
let recent = feed.recent.filter { !model.settling.contains($0.id) }
ForEach(feed.needsYou) { item in ForEach(feed.needsYou) { item in
NeedsYouCard(item: item).feedRow() NeedsYouCard(item: item).feedRow()
} }
ForEach(feed.working) { item in ForEach(feed.working) { item in
StatusCard(item: item).feedRow() StatusCard(item: item).feedRow()
} }
ForEach(feed.recent) { item in if !recent.isEmpty {
StatusCard(item: item).feedRow() 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.") Text("No threads in the last day.")
.font(.system(size: 13)) .font(.system(size: 13))
.foregroundStyle(Theme.secondaryText) .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. /// 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 @Environment(AppModel.self) private var model
var item: FeedItem var item: FeedItem
@State private var sending = false @State private var sending = false
@@ -136,6 +173,14 @@ private struct NeedsYouCard: View {
Text(item.title) Text(item.title)
.font(.system(size: 15, weight: .semibold)) .font(.system(size: 15, weight: .semibold))
.lineLimit(2) .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 { if let request = item.request {
Text(request.summary) Text(request.summary)
.font(request.isQuestion ? .system(size: 12) : .system(size: 12, design: .monospaced)) .font(request.isQuestion ? .system(size: 12) : .system(size: 12, design: .monospaced))
@@ -179,8 +224,9 @@ private struct NeedsYouCard: View {
error = nil error = nil
Task { Task {
do { do {
let target = item.requestThread
try await model.api.respond( try await model.api.respond(
hostId: item.hostId, threadId: item.threadId, hostId: target.hostId, threadId: target.threadId,
request: .decision(requestId: request.id, decision: decision)) request: .decision(requestId: request.id, decision: decision))
} catch let failure as GatewayError where failure.isStale { } catch let failure as GatewayError where failure.isStale {
// Someone answered it already; the refresh below drops the card. // 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. /// A teal card for a working thread, or a grey one for a recent thread. A recent thread that failed
private struct StatusCard: View { /// to settle shows why.
struct StatusCard: View {
@Environment(AppModel.self) private var model @Environment(AppModel.self) private var model
var item: FeedItem var item: FeedItem
@@ -217,6 +264,12 @@ private struct StatusCard: View {
.lineLimit(2) .lineLimit(2)
.padding(.top, 3) .padding(.top, 3)
} }
if let error = model.settleErrors[item.id] {
Text(error)
.font(.system(size: 11))
.foregroundStyle(Theme.secondaryText)
.padding(.top, 4)
}
} }
.contentShape(Rectangle()) .contentShape(Rectangle())
} }
+76 -12
View File
@@ -1,11 +1,13 @@
// One thread: the agent's latest message large, older messages one scroll up, the pending approval // 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 import SwiftUI
struct ThreadView: View { struct ThreadView: View {
@Environment(AppModel.self) private var model @Environment(AppModel.self) private var model
@Environment(\.scenePhase) private var scenePhase @Environment(\.scenePhase) private var scenePhase
@Environment(\.dismiss) private var dismiss
let hostId: String let hostId: String
let threadId: String let threadId: String
@@ -98,13 +100,20 @@ struct ThreadView: View {
MessageRow(message: message) 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.") Text(split.latest?.text ?? item.lastMessage ?? "No reply yet.")
.font(.system(size: 16)) .font(.system(size: 16))
.foregroundStyle(Theme.bodyText) .foregroundStyle(Theme.bodyText)
.lineSpacing(1) .lineSpacing(1)
.frame(maxWidth: .infinity, alignment: .leading) .frame(maxWidth: .infinity, alignment: .leading)
.id("latest") .id(item.via == nil ? "latest" : "message")
.padding(.top, split.earlier.isEmpty ? 0 : 6) .padding(.top, split.earlier.isEmpty || item.via != nil ? 0 : 6)
ForEach(split.after) { message in ForEach(split.after) { message in
MessageRow(message: message) MessageRow(message: message)
@@ -114,12 +123,8 @@ struct ThreadView: View {
.font(.system(size: 11, weight: .semibold)) .font(.system(size: 11, weight: .semibold))
.foregroundStyle(item.phase.color) .foregroundStyle(item.phase.color)
if let request = item.request { if item.via == nil, let request = item.request {
if request.isQuestion { requestCards(request, from: nil)
questions(request)
} else {
approval(request)
}
} }
if item.phase == .completed && item.request == nil { if item.phase == .completed && item.request == nil {
@@ -127,13 +132,22 @@ struct ThreadView: View {
.disabled(sending) .disabled(sending)
} }
let settleable = (item.phase == .completed || item.phase == .failed) && item.request == nil
TextFieldLink(prompt: Text("Reply")) { TextFieldLink(prompt: Text("Reply")) {
Label("Reply", systemImage: "mic.fill") Label("Reply", systemImage: "mic.fill")
} onSubmit: { text in } onSubmit: { text in
send(text) 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)) .buttonStyle(PillButtonStyle(fill: Theme.work))
.disabled(sending) .disabled(sending)
}
if detail.canStop { if detail.canStop {
Button(role: .destructive) { stop() } label: { Button(role: .destructive) { stop() } label: {
@@ -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) { Card(edge: Theme.you) {
subagentLabel(via)
if let prompt = request.prompt { if let prompt = request.prompt {
Text(prompt) Text(prompt)
.font(.system(size: 13, design: .monospaced)) .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 ForEach(request.questions) { question in
Card(edge: Theme.you) { Card(edge: Theme.you) {
subagentLabel(via)
Text(question.header) Text(question.header)
.font(.system(size: 11, weight: .semibold)) .font(.system(size: 11, weight: .semibold))
.foregroundStyle(Theme.you) .foregroundStyle(Theme.you)
@@ -291,8 +330,10 @@ struct ThreadView: View {
perform { try await model.api.sendMessage(hostId: hostId, threadId: threadId, text: text) } 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) { 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) { private func toggle(_ questionId: String, _ value: String) {
@@ -315,6 +356,22 @@ struct ThreadView: View {
private func stop() { private func stop() {
perform { try await model.api.stop(hostId: hostId, threadId: threadId) } 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. /// 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()) .environment(AppModel.preview())
} }
#Preview("Subagent") {
NavigationStack {
ThreadView(hostId: "mac-mini", threadId: "t-homelab")
}
.environment(AppModel.preview())
}
#Preview("Question") { #Preview("Question") {
NavigationStack { NavigationStack {
ThreadView(hostId: "server-gib", threadId: "t-db") ThreadView(hostId: "server-gib", threadId: "t-db")
+40 -1
View File
@@ -60,6 +60,20 @@ private let feedItemJSON = """
#expect(request.summary == "bun test --filter ocr") #expect(request.summary == "bun test --filter ocr")
} }
@Test func subagentRequest() throws {
let item = try decode(FeedItem.self, feedItemJSON.replacingOccurrences(
of: "\"request\": {",
with: #""via": { "hostId": "mac", "threadId": "sub", "title": "Check Panama PATH" }, "request": {"#))
#expect(item.via == FeedItem.Via(hostId: "mac", threadId: "sub", title: "Check Panama PATH"))
#expect(item.requestThread == ("mac", "sub"))
#expect(item.route == .thread(hostId: "...", threadId: "..."))
// A gateway older than `via` leaves it out, and the request belongs to the thread itself.
let plain = try decode(FeedItem.self, feedItemJSON)
#expect(plain.via == nil)
#expect(plain.requestThread == ("...", "..."))
}
@Test func questionRequest() throws { @Test func questionRequest() throws {
let request = try decode(PendingRequest.self, """ let request = try decode(PendingRequest.self, """
{ "id": "r1", "kind": "user_input", "prompt": null, "reason": null, "options": [], { "id": "r1", "kind": "user_input", "prompt": null, "reason": null, "options": [],
@@ -391,7 +405,7 @@ private let feedItemJSON = """
@Test func allowMovesThreadToWorking() async throws { @Test func allowMovesThreadToWorking() async throws {
let gateway = DemoGateway() let gateway = DemoGateway()
let before = try await gateway.feed() let before = try await gateway.feed()
let item = try #require(before.needsYou.first { $0.request?.quickOptions != nil }) let item = try #require(before.needsYou.first { $0.request?.quickOptions != nil && $0.via == nil })
try await gateway.respond( try await gateway.respond(
hostId: item.hostId, threadId: item.threadId, hostId: item.hostId, threadId: item.threadId,
request: .decision(requestId: item.request!.id, decision: .accept)) request: .decision(requestId: item.request!.id, decision: .accept))
@@ -405,6 +419,31 @@ private let feedItemJSON = """
} }
} }
@Test func subagentApprovalGoesToTheSubagent() async throws {
let gateway = DemoGateway()
let item = try #require(try await gateway.feed().needsYou.first { $0.via != nil })
let via = try #require(item.via)
let decision = RespondRequest.decision(requestId: item.request!.id, decision: .accept)
// The parent has no request of its own to answer.
await #expect(throws: GatewayError.http(status: 409, message: "Already answered.")) {
try await gateway.respond(hostId: item.hostId, threadId: item.threadId, request: decision)
}
try await gateway.respond(hostId: via.hostId, threadId: via.threadId, request: decision)
let after = try await gateway.feed()
#expect(!after.needsYou.contains { $0.id == item.id })
#expect(after.working.first { $0.id == item.id }?.via == nil)
}
@Test func settleLeavesTheFeed() async throws {
let gateway = DemoGateway()
let item = try #require(try await gateway.feed().recent.first)
try await gateway.settle(hostId: item.hostId, threadId: item.threadId)
#expect(!(try await gateway.feed().recent.contains { $0.id == item.id }))
// A reply brings it back.
try await gateway.sendMessage(hostId: item.hostId, threadId: item.threadId, text: "One more thing")
#expect(try await gateway.feed().working.contains { $0.id == item.id })
}
@Test func multiSelectAnswerMovesThreadToWorking() async throws { @Test func multiSelectAnswerMovesThreadToWorking() async throws {
let gateway = DemoGateway() let gateway = DemoGateway()
let item = try #require(try await gateway.feed().needsYou.first { $0.request?.questions.first?.multiSelect == true }) let item = try #require(try await gateway.feed().needsYou.first { $0.request?.questions.first?.multiSelect == true })
+41 -3
View File
@@ -22,13 +22,14 @@ struct SnapshotTests {
let waiting = WaitingEntry(date: .now, content: .counts(try await gateway.summary(), stale: false)) let waiting = WaitingEntry(date: .now, content: .counts(try await gateway.summary(), stale: false))
let feed = try await gateway.feed() let feed = try await gateway.feed()
for item in feed.needsYou { for item in feed.needsYou {
let target = item.requestThread
try await gateway.respond( try await gateway.respond(
hostId: item.hostId, threadId: item.threadId, hostId: target.hostId, threadId: target.threadId,
request: .decision(requestId: item.request!.id, decision: .decline)) request: .decision(requestId: item.request!.id, decision: .decline))
} }
let idle = WaitingEntry(date: .now, content: .counts(try await gateway.summary(), stale: false)) let idle = WaitingEntry(date: .now, content: .counts(try await gateway.summary(), stale: false))
try write(ComplicationGallery(waiting: waiting, idle: idle), "complication.png") try write(ComplicationGallery(waiting: waiting, waitingCount: feed.needsYou.count, idle: idle), "complication.png")
} }
@Test func multiSelectQuestion() async throws { @Test func multiSelectQuestion() async throws {
@@ -42,6 +43,30 @@ struct SnapshotTests {
try write(view, "thread-multiselect.png") try write(view, "thread-multiselect.png")
} }
@Test func subagentCard() async throws {
let feed = try await DemoGateway().feed()
let item = try #require(feed.needsYou.first { $0.via != nil })
try write(feedColumn { NeedsYouCard(item: item) }, "feed-subagent.png")
}
@Test func recentWithSettleAll() async throws {
let feed = try await DemoGateway().feed()
try write(feedColumn {
RecentHeader(recent: feed.recent)
ForEach(feed.recent) { StatusCard(item: $0) }
}, "feed-recent.png")
}
@Test func subagentThread() async throws {
let detail = try await DemoGateway().thread(hostId: "mac-mini", threadId: "t-homelab")
try write(titled("Thread", ThreadView(hostId: "mac-mini", threadId: "t-homelab", detail: detail, scrolls: false)), "thread-subagent.png")
}
@Test func finishedThreadWithSettle() async throws {
let detail = try await DemoGateway().thread(hostId: "laptop", threadId: "t-status")
try write(titled("Thread", ThreadView(hostId: "laptop", threadId: "t-status", detail: detail, scrolls: false)), "thread-settle.png")
}
@Test func statusScreens() async throws { @Test func statusScreens() async throws {
let status = try await DemoGateway().status() let status = try await DemoGateway().status()
try write(titled("Status", StatusView(status: status, scrolls: false)), "status.png") try write(titled("Status", StatusView(status: status, scrolls: false)), "status.png")
@@ -50,6 +75,18 @@ struct SnapshotTests {
try write(titled("Notifications", NotificationsView(status: status, result: result, scrolls: false)), "status-notifications.png") try write(titled("Notifications", NotificationsView(status: status, result: result, scrolls: false)), "status-notifications.png")
} }
/// Feed rows stacked as the feed's List lays them out, which ImageRenderer leaves blank.
private func feedColumn(@ViewBuilder _ rows: () -> some View) -> some View {
VStack(spacing: 6) {
rows()
}
.padding(.horizontal, 4)
.padding(.top, 10)
.environment(AppModel.preview())
.fontDesign(.rounded)
.tint(Theme.work)
}
/// The screen under a title like the navigation bar's, which ImageRenderer leaves out. /// The screen under a title like the navigation bar's, which ImageRenderer leaves out.
private func titled(_ title: String, _ view: some View) -> some View { private func titled(_ title: String, _ view: some View) -> some View {
VStack(alignment: .leading, spacing: 6) { VStack(alignment: .leading, spacing: 6) {
@@ -85,11 +122,12 @@ struct SnapshotTests {
/// Every family at its 46mm size, once with threads waiting and once with nothing waiting. /// Every family at its 46mm size, once with threads waiting and once with nothing waiting.
private struct ComplicationGallery: View { private struct ComplicationGallery: View {
var waiting: WaitingEntry var waiting: WaitingEntry
var waitingCount: Int
var idle: WaitingEntry var idle: WaitingEntry
var body: some View { var body: some View {
VStack(alignment: .leading, spacing: 10) { VStack(alignment: .leading, spacing: 10) {
section("3 waiting", waiting) section("\(waitingCount) waiting", waiting)
section("Nothing waiting", idle) section("Nothing waiting", idle)
} }
.padding(.horizontal, 10) .padding(.horizontal, 10)
Binary file not shown.

Before

Width:  |  Height:  |  Size: 49 KiB

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 46 KiB

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 44 KiB

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB