Watch: show subagent requests on the parent's card, and settle threads by swipe, Settle all and the thread screen
No files matched your search
@@ -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
|
||||||
|
|||||||
@@ -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"])
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"),
|
||||||
|
|||||||
@@ -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())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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 })
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 49 KiB After Width: | Height: | Size: 47 KiB |
|
After Width: | Height: | Size: 45 KiB |
|
After Width: | Height: | Size: 36 KiB |
|
Before Width: | Height: | Size: 46 KiB After Width: | Height: | Size: 46 KiB |
|
Before Width: | Height: | Size: 44 KiB After Width: | Height: | Size: 44 KiB |
|
After Width: | Height: | Size: 63 KiB |
|
After Width: | Height: | Size: 67 KiB |