diff --git a/watch/README.md b/watch/README.md index 0e372bd..73c26d9 100644 --- a/watch/README.md +++ b/watch/README.md @@ -38,7 +38,8 @@ register their token with the gateway as `production`, Debug builds as `sandbox` ## Demo mode `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: ```sh @@ -50,8 +51,9 @@ xcrun simctl launch org.gbrown.wristcode.watchkitapp -demo -host laptop xcrun simctl launch org.gbrown.wristcode.watchkitapp -demo -screen notifications # notifications ``` -`screenshots/` holds the screens captured this way. The complication, the multi-select question and -the status screens are rendered by `SnapshotTests` instead, which only run when given a directory: +`screenshots/` holds the screens captured this way. The complication, the multi-select question, the +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 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. +## 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 + 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 The gear at the top left of the feed opens the status overview (`GET /v1/status`). It leads with diff --git a/watch/WristCode/API/GatewayClient.swift b/watch/WristCode/API/GatewayClient.swift index 55c9e93..8eda37a 100644 --- a/watch/WristCode/API/GatewayClient.swift +++ b/watch/WristCode/API/GatewayClient.swift @@ -49,6 +49,8 @@ protocol GatewayAPI: Sendable { func sendMessage(hostId: String, threadId: String, text: String) async throws func respond(hostId: String, threadId: String, request: RespondRequest) 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 newThread(_ request: NewThreadRequest) async throws -> NewThreadResponse 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?) } + 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 { try await call("GET", ["v1", "projects"]) } diff --git a/watch/WristCode/API/Models.swift b/watch/WristCode/API/Models.swift index aa3b79b..c3d1982 100644 --- a/watch/WristCode/API/Models.swift +++ b/watch/WristCode/API/Models.swift @@ -134,6 +134,13 @@ extension PendingRequest.Question { } 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 hostLabel: String var threadId: String @@ -144,9 +151,17 @@ struct FeedItem: Codable, Hashable, Sendable, Identifiable { var updatedAt: Date var lastMessage: String? 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 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 { diff --git a/watch/WristCode/AppModel.swift b/watch/WristCode/AppModel.swift index d80113c..feaf74e 100644 --- a/watch/WristCode/AppModel.swift +++ b/watch/WristCode/AppModel.swift @@ -26,6 +26,10 @@ final class AppModel { var path: [Route] = [] private(set) var feed: Feed? 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 refreshing = false /// 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) { path = [route] } diff --git a/watch/WristCode/Demo/DemoGateway.swift b/watch/WristCode/Demo/DemoGateway.swift index c0a6d8b..5d9b713 100644 --- a/watch/WristCode/Demo/DemoGateway.swift +++ b/watch/WristCode/Demo/DemoGateway.swift @@ -1,6 +1,7 @@ // 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: -// 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 @@ -12,6 +13,8 @@ actor DemoGateway: GatewayAPI { private let hosts: [HostSummary] private let projectHosts: [ProjectsResponse.Host] private var statusResponse: StatusResponse + /// Ids of settled threads, which the feed leaves out. + private var settled: Set<String> = [] private var nextId = 1 init(now: Date = .now) { @@ -24,6 +27,7 @@ actor DemoGateway: GatewayAPI { } 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 working = items.filter(\.phase.isWorking).sorted { $0.updatedAt > $1.updatedAt } 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 { try update(hostId, threadId) { item in + settled.remove(item.id) append(Message(id: newId(), role: .user, text: text, createdAt: .now), to: item.id) if !item.phase.isWorking { 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 { - try update(hostId, threadId) { item in - guard item.request != nil else { throw GatewayError.http(status: 409, message: "Already answered.") } + let index = items.firstIndex { $0.requestThread == (hostId, threadId) } + ?? 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.via = nil switch request { case let .decision(_, decision) where decision == .decline || decision == .cancel: 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 { ProjectsResponse(hosts: projectHosts) } @@ -194,6 +212,17 @@ struct DemoFixtures { 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( 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), @@ -214,7 +243,18 @@ struct DemoFixtures { projectTitle: "cdn", title: "Fix CDN headers", phase: .failed, updatedAt: ago(140), 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 { 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("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: [ m("c1", .user, 4, "Scaffold the watch app with XcodeGen."), 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("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: [ 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"), diff --git a/watch/WristCode/Views/FeedView.swift b/watch/WristCode/Views/FeedView.swift index 781d285..abdfbbc 100644 --- a/watch/WristCode/Views/FeedView.swift +++ b/watch/WristCode/Views/FeedView.swift @@ -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()) } diff --git a/watch/WristCode/Views/ThreadView.swift b/watch/WristCode/Views/ThreadView.swift index 2b3dbd3..fdf166a 100644 --- a/watch/WristCode/Views/ThreadView.swift +++ b/watch/WristCode/Views/ThreadView.swift @@ -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") diff --git a/watch/WristCodeTests/ModelsTests.swift b/watch/WristCodeTests/ModelsTests.swift index 3eaa950..0a8cd29 100644 --- a/watch/WristCodeTests/ModelsTests.swift +++ b/watch/WristCodeTests/ModelsTests.swift @@ -60,6 +60,20 @@ private let feedItemJSON = """ #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 { let request = try decode(PendingRequest.self, """ { "id": "r1", "kind": "user_input", "prompt": null, "reason": null, "options": [], @@ -391,7 +405,7 @@ private let feedItemJSON = """ @Test func allowMovesThreadToWorking() async throws { let gateway = DemoGateway() 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( hostId: item.hostId, threadId: item.threadId, 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 { let gateway = DemoGateway() let item = try #require(try await gateway.feed().needsYou.first { $0.request?.questions.first?.multiSelect == true }) diff --git a/watch/WristCodeTests/SnapshotTests.swift b/watch/WristCodeTests/SnapshotTests.swift index cf4caf3..af08a5c 100644 --- a/watch/WristCodeTests/SnapshotTests.swift +++ b/watch/WristCodeTests/SnapshotTests.swift @@ -22,13 +22,14 @@ struct SnapshotTests { let waiting = WaitingEntry(date: .now, content: .counts(try await gateway.summary(), stale: false)) let feed = try await gateway.feed() for item in feed.needsYou { + let target = item.requestThread try await gateway.respond( - hostId: item.hostId, threadId: item.threadId, + hostId: target.hostId, threadId: target.threadId, request: .decision(requestId: item.request!.id, decision: .decline)) } 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 { @@ -42,6 +43,30 @@ struct SnapshotTests { 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 { let status = try await DemoGateway().status() 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") } + /// 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. private func titled(_ title: String, _ view: some View) -> some View { 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. private struct ComplicationGallery: View { var waiting: WaitingEntry + var waitingCount: Int var idle: WaitingEntry var body: some View { VStack(alignment: .leading, spacing: 10) { - section("3 waiting", waiting) + section("\(waitingCount) waiting", waiting) section("Nothing waiting", idle) } .padding(.horizontal, 10) diff --git a/watch/screenshots/complication.png b/watch/screenshots/complication.png index e350527..a232f35 100644 Binary files a/watch/screenshots/complication.png and b/watch/screenshots/complication.png differ diff --git a/watch/screenshots/feed-recent.png b/watch/screenshots/feed-recent.png new file mode 100644 index 0000000..5e9c381 Binary files /dev/null and b/watch/screenshots/feed-recent.png differ diff --git a/watch/screenshots/feed-subagent.png b/watch/screenshots/feed-subagent.png new file mode 100644 index 0000000..2102bdf Binary files /dev/null and b/watch/screenshots/feed-subagent.png differ diff --git a/watch/screenshots/status-environment.png b/watch/screenshots/status-environment.png index c4bd55c..1babae0 100644 Binary files a/watch/screenshots/status-environment.png and b/watch/screenshots/status-environment.png differ diff --git a/watch/screenshots/status-notifications.png b/watch/screenshots/status-notifications.png index fc8580c..02150b6 100644 Binary files a/watch/screenshots/status-notifications.png and b/watch/screenshots/status-notifications.png differ diff --git a/watch/screenshots/thread-settle.png b/watch/screenshots/thread-settle.png new file mode 100644 index 0000000..c223a1a Binary files /dev/null and b/watch/screenshots/thread-settle.png differ diff --git a/watch/screenshots/thread-subagent.png b/watch/screenshots/thread-subagent.png new file mode 100644 index 0000000..d625ec7 Binary files /dev/null and b/watch/screenshots/thread-subagent.png differ