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

+16 -3
View File
@@ -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 <udid> org.gbrown.wristcode.watchkitapp -demo -host laptop
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
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
<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
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 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"])
}
+15
View File
@@ -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 {
+36
View File
@@ -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]
}
+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
// 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"),
+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
// (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())
}
+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")
+40 -1
View File
@@ -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 })
+41 -3
View File
@@ -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)
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