Watch: activity line on working cards, All threads browsing, a thread's subagents, Load earlier, and markdown messages
No files matched your search
@@ -40,7 +40,9 @@ register their token with the gateway as `production`, Debug builds as `sandbox`
|
||||
`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, and Settle hides a thread from
|
||||
the feed. One card carries a subagent's request, so its Allow goes to the subagent's thread. Recent
|
||||
holds 14 threads, so the feed shows its first 10 and a "4 more" row. The SwiftUI previews use it,
|
||||
holds 14 threads, so the feed shows its first 10 and a "4 more" row. The working cards show
|
||||
activity lines, the Containerfile thread has two subagents, and "Fix hyprlock crash" has two older
|
||||
pages behind Load earlier. The SwiftUI previews use it,
|
||||
the pairing screen has a "Try the demo" button, and launch arguments open a screen directly:
|
||||
|
||||
```sh
|
||||
@@ -51,12 +53,14 @@ xcrun simctl launch <udid> org.gbrown.wristcode.watchkitapp -demo -screen recent
|
||||
xcrun simctl launch <udid> org.gbrown.wristcode.watchkitapp -demo -screen status # status overview
|
||||
xcrun simctl launch <udid> org.gbrown.wristcode.watchkitapp -demo -host laptop # one environment
|
||||
xcrun simctl launch <udid> org.gbrown.wristcode.watchkitapp -demo -screen notifications # notifications
|
||||
xcrun simctl launch <udid> org.gbrown.wristcode.watchkitapp -demo -screen threads # All threads
|
||||
```
|
||||
|
||||
`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 the "N more" row, the full
|
||||
Recent list, a finished thread with Settle, a running thread with Ask before acting, and New thread
|
||||
with its model list are rendered by `SnapshotTests` instead, which only run when given a directory:
|
||||
Recent list, a finished thread with Settle, a running thread with Ask before acting, New thread
|
||||
with its model list, the working cards' activity lines, All threads' hosts and a project's threads,
|
||||
a thread's subagents, and Load earlier with markdown are rendered by `SnapshotTests` instead, which only run when given a directory:
|
||||
|
||||
```sh
|
||||
TEST_RUNNER_WC_SCREENSHOT_DIR=$PWD/screenshots xcodebuild -scheme WristCode \
|
||||
@@ -83,6 +87,29 @@ and the full list, is one `POST /v1/recent/settle` for every completed thread in
|
||||
those past the first 10. Failed threads stay. The completed rows leave at once, and the header says
|
||||
so when the call fails or some threads did not settle.
|
||||
|
||||
## Activity, All threads and history
|
||||
|
||||
A working card shows the step the agent is on instead of its last message: a teal symbol for the
|
||||
kind (command, edit, search, web, subagent, thinking or tool), the text, commands and file names in
|
||||
monospace, and how long the step has run on the right. The time is worked out when the card renders,
|
||||
so it moves on with the feed's 10 s refresh. Nothing ticks between refreshes. A card falls back to
|
||||
the last message when the gateway names no step.
|
||||
|
||||
The feed's last row opens All threads: hosts from `GET /v1/projects` (offline ones dimmed), then a
|
||||
host's projects by title, then the project's threads from
|
||||
`GET /v1/projects/:hostId/:projectId/threads`, settled ones included. Each thread is one line with
|
||||
a status dot and its age, and opens the usual thread screen. Each screen loads when it appears.
|
||||
|
||||
A thread screen lists the thread's subagents under its messages, above Reply, each with a status
|
||||
word. A row opens that subagent's thread. When the thread has messages before the latest 20, Load
|
||||
earlier at the top fetches the page before them (`GET /v1/threads/:hostId/:threadId/history`), and
|
||||
the next tap uses the cursor that page returned. The 10 s refresh replaces only the latest window.
|
||||
Loaded pages stay, and a message shows once by id. Message text renders as markdown: bold, italics,
|
||||
inline code and links inline, headings in bold, list items as bullets, and fenced code in monospace.
|
||||
|
||||
Against a gateway older than these fields, the cards show the last message, and Load earlier and
|
||||
the subagents stay hidden.
|
||||
|
||||
## Status and settings
|
||||
|
||||
The gear at the top left of the feed opens the status overview (`GET /v1/status`). It leads with
|
||||
@@ -137,6 +164,8 @@ To add it: long-press the watch face, Edit, swipe to Complications, pick a slot,
|
||||
| `Shared/SharedState.swift` | Demo flag and last summary in the shared Keychain group, for the complication |
|
||||
| `Demo/DemoGateway.swift` | In-memory gateway with fixtures |
|
||||
| `Views/` | Pair, Feed, full Recent list, Thread, New thread, Status, and the mock B theme |
|
||||
| `Views/BrowseView.swift` | All threads: hosts, a host's projects, a project's threads |
|
||||
| `Views/Markdown.swift` | Message text as styled text |
|
||||
| `Views/StatusReport.swift` | What the status overview says: headline, issues worst first, healthy hosts |
|
||||
| `Notifications/NotificationHandler.swift` | Categories `approval`, `finished`, `failed`, `input`, `host`, `test` and their actions |
|
||||
| `../WristCodeWidget/` | The complication: timeline provider, and one view per family |
|
||||
@@ -46,6 +46,8 @@ protocol GatewayAPI: Sendable {
|
||||
func feed() async throws -> Feed
|
||||
func summary() async throws -> Summary
|
||||
func thread(hostId: String, threadId: String) async throws -> ThreadDetail
|
||||
/// The 20 messages before `cursor`, a thread's `earlierCursor` or the last page's.
|
||||
func history(hostId: String, threadId: String, cursor: String) async throws -> HistoryResponse
|
||||
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
|
||||
@@ -58,6 +60,8 @@ protocol GatewayAPI: Sendable {
|
||||
/// Settles every completed thread in Recent. Failed threads stay.
|
||||
func settleRecent() async throws -> SettleRecentResponse
|
||||
func projects() async throws -> ProjectsResponse
|
||||
/// A project's newest 50 threads, settled ones included. Subagents are left out.
|
||||
func projectThreads(hostId: String, projectId: String) async throws -> ThreadListResponse
|
||||
func newThread(_ request: NewThreadRequest) async throws -> NewThreadResponse
|
||||
func registerPush(_ registration: PushRegistration) async throws
|
||||
func status() async throws -> StatusResponse
|
||||
@@ -93,6 +97,10 @@ struct GatewayClient: GatewayAPI {
|
||||
try await call("GET", ["v1", "threads", hostId, threadId])
|
||||
}
|
||||
|
||||
func history(hostId: String, threadId: String, cursor: String) async throws -> HistoryResponse {
|
||||
try await call("GET", ["v1", "threads", hostId, threadId, "history"], query: [URLQueryItem(name: "cursor", value: cursor)])
|
||||
}
|
||||
|
||||
func sendMessage(hostId: String, threadId: String, text: String) async throws {
|
||||
try await callIgnoringBody("POST", ["v1", "threads", hostId, threadId, "messages"], body: SendMessageRequest(text: text))
|
||||
}
|
||||
@@ -125,6 +133,10 @@ struct GatewayClient: GatewayAPI {
|
||||
try await call("GET", ["v1", "projects"])
|
||||
}
|
||||
|
||||
func projectThreads(hostId: String, projectId: String) async throws -> ThreadListResponse {
|
||||
try await call("GET", ["v1", "projects", hostId, projectId, "threads"])
|
||||
}
|
||||
|
||||
func newThread(_ request: NewThreadRequest) async throws -> NewThreadResponse {
|
||||
try await call("POST", ["v1", "threads"], body: request)
|
||||
}
|
||||
@@ -155,14 +167,16 @@ struct GatewayClient: GatewayAPI {
|
||||
|
||||
// MARK: - Plumbing
|
||||
|
||||
private func call<Response: Decodable>(_ method: String, _ path: [String]) async throws -> Response {
|
||||
try await call(method, path, body: nil as SendMessageRequest?)
|
||||
private func call<Response: Decodable>(
|
||||
_ method: String, _ path: [String], query: [URLQueryItem] = []
|
||||
) async throws -> Response {
|
||||
try await call(method, path, query: query, body: nil as SendMessageRequest?)
|
||||
}
|
||||
|
||||
private func call<Body: Encodable, Response: Decodable>(
|
||||
_ method: String, _ path: [String], body: Body?
|
||||
_ method: String, _ path: [String], query: [URLQueryItem] = [], body: Body?
|
||||
) async throws -> Response {
|
||||
let data = try await perform(method, path, body: body)
|
||||
let data = try await perform(method, path, query: query, body: body)
|
||||
do {
|
||||
return try JSON.decoder().decode(Response.self, from: data)
|
||||
} catch {
|
||||
@@ -174,9 +188,12 @@ struct GatewayClient: GatewayAPI {
|
||||
_ = try await perform(method, path, body: body)
|
||||
}
|
||||
|
||||
private func perform<Body: Encodable>(_ method: String, _ path: [String], body: Body?) async throws -> Data {
|
||||
private func perform<Body: Encodable>(
|
||||
_ method: String, _ path: [String], query: [URLQueryItem] = [], body: Body?
|
||||
) async throws -> Data {
|
||||
// appending(component:) percent-encodes each segment, slashes included.
|
||||
let url = path.reduce(baseURL) { $0.appending(component: $1) }
|
||||
var url = path.reduce(baseURL) { $0.appending(component: $1) }
|
||||
if !query.isEmpty { url.append(queryItems: query) }
|
||||
var request = URLRequest(url: url, timeoutInterval: 20)
|
||||
request.httpMethod = method
|
||||
request.setValue("application/json", forHTTPHeaderField: "Accept")
|
||||
|
||||
@@ -154,6 +154,9 @@ struct FeedItem: Codable, Hashable, Sendable, Identifiable {
|
||||
/// 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
|
||||
/// What a working thread is doing right now. Nil when nothing is in progress, and from older
|
||||
/// gateways.
|
||||
var activity: Activity? = nil
|
||||
|
||||
var id: String { "\(hostId)/\(threadId)" }
|
||||
var route: Route { .thread(hostId: hostId, threadId: threadId) }
|
||||
@@ -164,6 +167,66 @@ struct FeedItem: Codable, Hashable, Sendable, Identifiable {
|
||||
}
|
||||
}
|
||||
|
||||
/// One step a thread is taking now, for the working card's activity line.
|
||||
struct Activity: Codable, Hashable, Sendable {
|
||||
struct Kind: RawRepresentable, Codable, Hashable, Sendable {
|
||||
let rawValue: String
|
||||
init(rawValue: String) { self.rawValue = rawValue }
|
||||
|
||||
static let command = Kind(rawValue: "command")
|
||||
static let edit = Kind(rawValue: "edit")
|
||||
static let search = Kind(rawValue: "search")
|
||||
static let web = Kind(rawValue: "web")
|
||||
static let subagent = Kind(rawValue: "subagent")
|
||||
static let thinking = Kind(rawValue: "thinking")
|
||||
static let tool = Kind(rawValue: "tool")
|
||||
}
|
||||
|
||||
var kind: Kind
|
||||
/// The command line, file name, search, subagent title or tool name, trimmed by the gateway.
|
||||
var text: String
|
||||
/// When the step started, for its elapsed time.
|
||||
var startedAt: Date?
|
||||
}
|
||||
|
||||
/// A thread in a list: a project's threads, or a thread's subagents.
|
||||
struct ThreadRow: Codable, Hashable, Sendable, Identifiable {
|
||||
var hostId: String
|
||||
var threadId: String
|
||||
var title: String
|
||||
/// Nil for a thread that never ran or was stopped.
|
||||
var phase: Phase?
|
||||
var settled: Bool
|
||||
var updatedAt: Date
|
||||
|
||||
var id: String { "\(hostId)/\(threadId)" }
|
||||
var route: Route { .thread(hostId: hostId, threadId: threadId) }
|
||||
|
||||
/// "working", "waiting", "settled", "failed" or "done", for a subagent's row.
|
||||
var statusWord: String {
|
||||
if phase?.isWorking == true { return "working" }
|
||||
if phase?.needsYou == true { return "waiting" }
|
||||
if settled { return "settled" }
|
||||
return phase == .failed ? "failed" : "done"
|
||||
}
|
||||
|
||||
/// False once it finished or settled, which dims its title.
|
||||
var isActive: Bool { !settled && (phase?.isWorking == true || phase?.needsYou == true) }
|
||||
}
|
||||
|
||||
/// `GET /v1/projects/:hostId/:projectId/threads`: a project's newest 50 threads, newest first.
|
||||
struct ThreadListResponse: Codable, Hashable, Sendable {
|
||||
var items: [ThreadRow]
|
||||
}
|
||||
|
||||
/// `GET /v1/threads/:hostId/:threadId/history`: the page before the messages the watch has.
|
||||
struct HistoryResponse: Codable, Hashable, Sendable {
|
||||
/// Oldest first.
|
||||
var messages: [Message]
|
||||
/// The cursor for the page before these. Nil at the start of the thread.
|
||||
var earlierCursor: String?
|
||||
}
|
||||
|
||||
struct Feed: Codable, Hashable, Sendable {
|
||||
var needsYou: [FeedItem]
|
||||
var working: [FeedItem]
|
||||
@@ -268,6 +331,29 @@ struct ThreadDetail: Codable, Hashable, Sendable {
|
||||
/// True when the thread asks before each action (T3's approval-required mode). Older gateways
|
||||
/// leave it out, which decodes as nil, and the screen hides the switch.
|
||||
var askFirst: Bool? = nil
|
||||
/// Pass to the history route for older messages. Nil when `messages` starts the thread, and from
|
||||
/// older gateways, which hides Load earlier.
|
||||
var earlierCursor: String? = nil
|
||||
/// Subagents and delegated threads this thread started, newest first.
|
||||
var subagents: [ThreadRow] = []
|
||||
}
|
||||
|
||||
extension ThreadDetail {
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case item, messages, canStop, askFirst, earlierCursor, subagents
|
||||
}
|
||||
|
||||
/// `subagents` is newer than the rest, so a gateway that leaves it out lists none.
|
||||
init(from decoder: any Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
self.init(
|
||||
item: try container.decode(FeedItem.self, forKey: .item),
|
||||
messages: try container.decode([Message].self, forKey: .messages),
|
||||
canStop: try container.decode(Bool.self, forKey: .canStop),
|
||||
askFirst: try container.decodeIfPresent(Bool.self, forKey: .askFirst),
|
||||
earlierCursor: try container.decodeIfPresent(String.self, forKey: .earlierCursor),
|
||||
subagents: try container.decodeIfPresent([ThreadRow].self, forKey: .subagents) ?? [])
|
||||
}
|
||||
}
|
||||
|
||||
/// `POST /v1/threads/:hostId/:threadId/mode`.
|
||||
|
||||
@@ -11,20 +11,26 @@ enum Route: Hashable, Sendable {
|
||||
case status
|
||||
case host(id: String)
|
||||
case notifications
|
||||
/// All threads: every host, then one host's projects, then one project's threads.
|
||||
case allThreads
|
||||
case hostProjects(hostId: String)
|
||||
case projectThreads(hostId: String, projectId: String)
|
||||
|
||||
static let scheme = "wristcode"
|
||||
|
||||
/// `wristcode://thread/<hostId>/<threadId>`, `wristcode://new`, `wristcode://recent`, `wristcode://status`,
|
||||
/// `wristcode://host/<hostId>` or `wristcode://notifications`.
|
||||
/// `wristcode://host/<hostId>`, `wristcode://notifications`, `wristcode://threads`,
|
||||
/// `wristcode://projects/<hostId>` or `wristcode://project/<hostId>/<projectId>`.
|
||||
var url: URL {
|
||||
var components = URLComponents()
|
||||
components.scheme = Self.scheme
|
||||
func path(_ parts: String...) -> String {
|
||||
"/" + parts.map { $0.addingPercentEncoding(withAllowedCharacters: .alphanumerics) ?? $0 }.joined(separator: "/")
|
||||
}
|
||||
switch self {
|
||||
case let .thread(hostId, threadId):
|
||||
components.host = "thread"
|
||||
components.percentEncodedPath = "/" + [hostId, threadId]
|
||||
.map { $0.addingPercentEncoding(withAllowedCharacters: .alphanumerics) ?? $0 }
|
||||
.joined(separator: "/")
|
||||
components.percentEncodedPath = path(hostId, threadId)
|
||||
case .newThread:
|
||||
components.host = "new"
|
||||
case .recent:
|
||||
@@ -33,9 +39,17 @@ enum Route: Hashable, Sendable {
|
||||
components.host = "status"
|
||||
case let .host(id):
|
||||
components.host = "host"
|
||||
components.percentEncodedPath = "/" + (id.addingPercentEncoding(withAllowedCharacters: .alphanumerics) ?? id)
|
||||
components.percentEncodedPath = path(id)
|
||||
case .notifications:
|
||||
components.host = "notifications"
|
||||
case .allThreads:
|
||||
components.host = "threads"
|
||||
case let .hostProjects(hostId):
|
||||
components.host = "projects"
|
||||
components.percentEncodedPath = path(hostId)
|
||||
case let .projectThreads(hostId, projectId):
|
||||
components.host = "project"
|
||||
components.percentEncodedPath = path(hostId, projectId)
|
||||
}
|
||||
return components.url!
|
||||
}
|
||||
@@ -59,6 +73,12 @@ enum Route: Hashable, Sendable {
|
||||
self = .host(id: parts[0])
|
||||
case "notifications":
|
||||
self = .notifications
|
||||
case "threads":
|
||||
self = .allThreads
|
||||
case "projects" where parts.count == 1 && !parts[0].isEmpty:
|
||||
self = .hostProjects(hostId: parts[0])
|
||||
case "project" where parts.count == 2 && parts.allSatisfy { !$0.isEmpty }:
|
||||
self = .projectThreads(hostId: parts[0], projectId: parts[1])
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
// App-wide state: whether the watch is paired, the navigation path, and the latest feed. It also
|
||||
// App-wide state: whether the watch is paired, the navigation path, the latest feed, and the hosts
|
||||
// and projects All threads browses. It also
|
||||
// keeps the complication current: it shares the mode and latest counts through the shared Keychain
|
||||
// group and reloads the complication's timeline when they change.
|
||||
|
||||
@@ -37,19 +38,26 @@ final class AppModel {
|
||||
private(set) var settlingAll = false
|
||||
/// Why Settle all failed, or how many threads it could not settle. The Recent header shows it.
|
||||
private(set) var settleAllError: String?
|
||||
/// Hosts and their projects for All threads, loaded when its screens appear.
|
||||
private(set) var projects: ProjectsResponse?
|
||||
private(set) var projectsError: String?
|
||||
private var apnsToken: String?
|
||||
private var refreshing = false
|
||||
/// The counts the complication was last told about.
|
||||
private var sharedSummary: Summary?
|
||||
|
||||
init(mode: Mode, feed: Feed? = nil, allRecent: [FeedItem]? = nil, path: [Route] = []) {
|
||||
init(
|
||||
mode: Mode, feed: Feed? = nil, allRecent: [FeedItem]? = nil, projects: ProjectsResponse? = nil,
|
||||
path: [Route] = []
|
||||
) {
|
||||
self.mode = mode
|
||||
self.feed = feed
|
||||
self.allRecent = allRecent
|
||||
self.projects = projects
|
||||
self.path = path
|
||||
}
|
||||
|
||||
/// `-demo` starts in demo mode. `-screen new|recent|status|notifications`, `-host <hostId>` or
|
||||
/// `-demo` starts in demo mode. `-screen new|recent|status|notifications|threads`, `-host <hostId>` or
|
||||
/// `-thread <hostId>/<threadId>` opens that screen, which is how the screenshots in
|
||||
/// watch/screenshots are taken.
|
||||
convenience init(launchArguments args: [String]) {
|
||||
@@ -66,6 +74,7 @@ final class AppModel {
|
||||
case "recent": path = [.recent]
|
||||
case "status": path = [.status]
|
||||
case "notifications": path = [.status, .notifications]
|
||||
case "threads": path = [.allThreads]
|
||||
default: break
|
||||
}
|
||||
if let hostId = argument("-host") {
|
||||
@@ -105,6 +114,7 @@ final class AppModel {
|
||||
mode = .paired(credentials)
|
||||
feed = nil
|
||||
feedError = nil
|
||||
projects = nil
|
||||
path = []
|
||||
await enableNotifications()
|
||||
}
|
||||
@@ -113,6 +123,7 @@ final class AppModel {
|
||||
mode = .demo
|
||||
feed = nil
|
||||
feedError = nil
|
||||
projects = nil
|
||||
path = []
|
||||
}
|
||||
|
||||
@@ -122,6 +133,7 @@ final class AppModel {
|
||||
mode = .unpaired
|
||||
feed = nil
|
||||
feedError = nil
|
||||
projects = nil
|
||||
path = []
|
||||
}
|
||||
|
||||
@@ -241,6 +253,19 @@ final class AppModel {
|
||||
self.feed = feed
|
||||
}
|
||||
|
||||
// MARK: - All threads
|
||||
|
||||
/// `GET /v1/projects`, for the hosts and projects All threads lists.
|
||||
func loadProjects() async {
|
||||
guard mode != .unpaired else { return }
|
||||
do {
|
||||
projects = try await api.projects()
|
||||
projectsError = nil
|
||||
} catch {
|
||||
projectsError = message(for: error)
|
||||
}
|
||||
}
|
||||
|
||||
func open(_ route: Route) {
|
||||
path = [route]
|
||||
}
|
||||
@@ -316,6 +341,8 @@ final class AppModel {
|
||||
extension AppModel {
|
||||
/// A demo model with the fixture feed already loaded, for previews.
|
||||
static func preview(path: [Route] = []) -> AppModel {
|
||||
AppModel(mode: .demo, feed: DemoFixtures.previewFeed(), allRecent: DemoFixtures.previewRecent(), path: path)
|
||||
AppModel(
|
||||
mode: .demo, feed: DemoFixtures.previewFeed(), allRecent: DemoFixtures.previewRecent(),
|
||||
projects: ProjectsResponse(hosts: DemoFixtures(now: .now).projectHosts), path: path)
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
// 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, settling
|
||||
// hides a thread from the feed, Ask before acting and the notification switches stick, and so on.
|
||||
// Subagent threads open like any other but stay out of the feed, as the gateway's do.
|
||||
|
||||
import Foundation
|
||||
|
||||
@@ -10,11 +11,16 @@ actor DemoGateway: GatewayAPI {
|
||||
|
||||
private var items: [FeedItem]
|
||||
private var messages: [String: [Message]]
|
||||
/// Pages before each thread's messages, newest page first.
|
||||
private let earlierPages: [String: [[Message]]]
|
||||
/// Subagent ids by parent id. Subagents are in `items` but not the feed.
|
||||
private let children: [String: [String]]
|
||||
private let subagentIds: Set<String>
|
||||
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 settled: Set<String>
|
||||
/// Ids of threads in Ask before acting.
|
||||
private var askingFirst: Set<String> = []
|
||||
private var nextId = 1
|
||||
@@ -23,13 +29,17 @@ actor DemoGateway: GatewayAPI {
|
||||
let fixtures = DemoFixtures(now: now)
|
||||
items = fixtures.items
|
||||
messages = fixtures.messages
|
||||
earlierPages = fixtures.history
|
||||
children = fixtures.children
|
||||
subagentIds = Set(fixtures.children.values.joined())
|
||||
settled = fixtures.settled
|
||||
hosts = fixtures.hosts
|
||||
projectHosts = fixtures.projectHosts
|
||||
statusResponse = fixtures.status
|
||||
}
|
||||
|
||||
func feed() async throws -> Feed {
|
||||
DemoFixtures.feed(items.filter { !settled.contains($0.id) }, hosts: hosts)
|
||||
DemoFixtures.feed(unsettled, hosts: hosts)
|
||||
}
|
||||
|
||||
func summary() async throws -> Summary {
|
||||
@@ -40,7 +50,17 @@ actor DemoGateway: GatewayAPI {
|
||||
guard let item = item(hostId, threadId) else { throw GatewayError.http(status: 404, message: "Unknown thread.") }
|
||||
return ThreadDetail(
|
||||
item: item, messages: messages[item.id] ?? [], canStop: item.phase.isWorking,
|
||||
askFirst: askingFirst.contains(item.id))
|
||||
askFirst: askingFirst.contains(item.id),
|
||||
earlierCursor: earlierPages[item.id] == nil ? nil : "p0",
|
||||
subagents: (children[item.id] ?? []).compactMap { id in items.first { $0.id == id }.map(row) })
|
||||
}
|
||||
|
||||
/// Cursors are `p<n>`, the n-th page back from the thread's messages.
|
||||
func history(hostId: String, threadId: String, cursor: String) async throws -> HistoryResponse {
|
||||
guard let item = item(hostId, threadId), let pages = earlierPages[item.id],
|
||||
let index = Int(cursor.dropFirst()), pages.indices.contains(index)
|
||||
else { throw GatewayError.http(status: 404, message: "Unknown cursor.") }
|
||||
return HistoryResponse(messages: pages[index], earlierCursor: index + 1 < pages.count ? "p\(index + 1)" : nil)
|
||||
}
|
||||
|
||||
func sendMessage(hostId: String, threadId: String, text: String) async throws {
|
||||
@@ -78,6 +98,12 @@ actor DemoGateway: GatewayAPI {
|
||||
append(Message(id: newId(), role: .assistant, text: line, createdAt: .now), to: item.id)
|
||||
}
|
||||
}
|
||||
// A subagent's own thread stops waiting too.
|
||||
if let child = items.firstIndex(where: { $0.hostId == hostId && $0.threadId == threadId }), child != index,
|
||||
items[child].request != nil {
|
||||
items[child].request = nil
|
||||
items[child].phase = .running
|
||||
}
|
||||
}
|
||||
|
||||
func stop(hostId: String, threadId: String) async throws {
|
||||
@@ -98,11 +124,11 @@ actor DemoGateway: GatewayAPI {
|
||||
}
|
||||
|
||||
func recent() async throws -> RecentResponse {
|
||||
RecentResponse(items: DemoFixtures.recent(items.filter { !settled.contains($0.id) }))
|
||||
RecentResponse(items: DemoFixtures.recent(unsettled))
|
||||
}
|
||||
|
||||
func settleRecent() async throws -> SettleRecentResponse {
|
||||
let completed = DemoFixtures.recent(items.filter { !settled.contains($0.id) }).filter { $0.phase == .completed }
|
||||
let completed = DemoFixtures.recent(unsettled).filter { $0.phase == .completed }
|
||||
settled.formUnion(completed.map(\.id))
|
||||
return SettleRecentResponse(settled: completed.count, failed: 0)
|
||||
}
|
||||
@@ -111,6 +137,18 @@ actor DemoGateway: GatewayAPI {
|
||||
ProjectsResponse(hosts: projectHosts)
|
||||
}
|
||||
|
||||
func projectThreads(hostId: String, projectId: String) async throws -> ThreadListResponse {
|
||||
guard let host = projectHosts.first(where: { $0.id == hostId }) else {
|
||||
throw GatewayError.http(status: 404, message: "Unknown host.")
|
||||
}
|
||||
guard host.status == .online else { throw GatewayError.http(status: 503, message: nil) }
|
||||
let rows = items
|
||||
.filter { $0.hostId == hostId && $0.projectId == projectId && !subagentIds.contains($0.id) }
|
||||
.sorted { $0.updatedAt > $1.updatedAt }
|
||||
.map(row)
|
||||
return ThreadListResponse(items: rows)
|
||||
}
|
||||
|
||||
func newThread(_ request: NewThreadRequest) async throws -> NewThreadResponse {
|
||||
guard let host = projectHosts.first(where: { $0.id == request.hostId }),
|
||||
let project = host.projects.first(where: { $0.id == request.projectId })
|
||||
@@ -154,6 +192,17 @@ actor DemoGateway: GatewayAPI {
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
/// Threads the feed lists: not settled, and not a subagent.
|
||||
private var unsettled: [FeedItem] {
|
||||
items.filter { !settled.contains($0.id) && !subagentIds.contains($0.id) }
|
||||
}
|
||||
|
||||
private func row(_ item: FeedItem) -> ThreadRow {
|
||||
ThreadRow(
|
||||
hostId: item.hostId, threadId: item.threadId, title: item.title, phase: item.phase,
|
||||
settled: settled.contains(item.id), updatedAt: item.updatedAt)
|
||||
}
|
||||
|
||||
private func item(_ hostId: String, _ threadId: String) -> FeedItem? {
|
||||
items.first { $0.hostId == hostId && $0.threadId == threadId }
|
||||
}
|
||||
@@ -179,6 +228,11 @@ actor DemoGateway: GatewayAPI {
|
||||
struct DemoFixtures {
|
||||
let items: [FeedItem]
|
||||
let messages: [String: [Message]]
|
||||
let history: [String: [[Message]]]
|
||||
/// Subagent ids by parent id.
|
||||
let children: [String: [String]]
|
||||
/// Threads settled from the start, which only a project's thread list shows.
|
||||
let settled: Set<String>
|
||||
let hosts: [HostSummary]
|
||||
let projectHosts: [ProjectsResponse.Host]
|
||||
let status: StatusResponse
|
||||
@@ -248,15 +302,35 @@ struct DemoFixtures {
|
||||
questions: []),
|
||||
via: .init(hostId: "mac-mini", threadId: "t-path", title: "Check Panama PATH"))
|
||||
|
||||
// The subagent that asks on the homelab card. It has no card of its own.
|
||||
let path = FeedItem(
|
||||
hostId: "mac-mini", hostLabel: "Mac mini", threadId: "t-path", projectId: "p-wristlab",
|
||||
projectTitle: "WristLab", title: "Check Panama PATH", phase: .waitingForApproval, updatedAt: ago(4),
|
||||
lastMessage: "jq is missing from PATH. I want to install it.",
|
||||
request: homelab.request)
|
||||
|
||||
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),
|
||||
lastMessage: "Writing ThreadListView.swift", request: nil)
|
||||
lastMessage: "Writing ThreadListView.swift", request: nil,
|
||||
activity: Activity(kind: .command, text: "xcodebuild test -scheme WristCode", startedAt: ago(0.7)))
|
||||
|
||||
let containerfile = FeedItem(
|
||||
hostId: "server-gib", hostLabel: "server-gib", threadId: "t-container", projectId: "p-wc",
|
||||
projectTitle: "wristcode", title: "Gateway Containerfile", phase: .running, updatedAt: ago(2),
|
||||
lastMessage: "Running podman build", request: nil)
|
||||
lastMessage: "Split the image build and the healthcheck into two subagents. Waiting on them.", request: nil,
|
||||
activity: Activity(kind: .edit, text: "Containerfile", startedAt: ago(3)))
|
||||
|
||||
// The Containerfile thread's subagents, one delegated to the Mac mini.
|
||||
let image = FeedItem(
|
||||
hostId: "mac-mini", hostLabel: "Mac mini", threadId: "t-image", projectId: "p-wc-mac",
|
||||
projectTitle: "wristcode", title: "Build the image", phase: .running, updatedAt: ago(1),
|
||||
lastMessage: "Running podman build.", request: nil,
|
||||
activity: Activity(kind: .command, text: "podman build -t wristcode-gateway .", startedAt: ago(1)))
|
||||
let healthcheck = FeedItem(
|
||||
hostId: "server-gib", hostLabel: "server-gib", threadId: "t-health", projectId: "p-wc",
|
||||
projectTitle: "wristcode", title: "Read the healthcheck docs", phase: .completed, updatedAt: ago(2.5),
|
||||
lastMessage: "Podman runs `HEALTHCHECK` on its own timer. Use `--health-interval`.", request: nil)
|
||||
|
||||
let nextjs = FeedItem(
|
||||
hostId: "littleman", hostLabel: "LittleMan", threadId: "t-next", projectId: "p-site",
|
||||
@@ -274,10 +348,12 @@ struct DemoFixtures {
|
||||
lastMessage: "Done. Status, environment and notification screens are in TestFlight build 202611011516.",
|
||||
request: nil)
|
||||
|
||||
let hyprlockReply = "Fixed. hyprlock no longer crashes when a monitor sleeps. The fix is in `lock.qml`:\n"
|
||||
+ "- a **bounded** retry when the monitor wakes\n- a *3 s* timeout on the surface"
|
||||
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)
|
||||
lastMessage: hyprlockReply, request: nil)
|
||||
|
||||
// Older finished threads, past the feed's first 10 in Recent. The gateway has not read most of
|
||||
// them, so they have no last message.
|
||||
@@ -300,7 +376,21 @@ struct DemoFixtures {
|
||||
older(laptop, "t-pair", "wristcode", "Pairing code expiry", hours: 120),
|
||||
]
|
||||
|
||||
items = [approval, question, release, homelab, scaffold, containerfile, statusScreens, nextjs, hyprlock, cdn] + olderRecent
|
||||
// Settled threads, which only the project's thread list shows.
|
||||
let settledThreads = [
|
||||
older(mac, "t-switches", "wristcode", "Phase 2 switches", hours: 26),
|
||||
older(mac, "t-gear", "wristcode", "Gear icon fix", hours: 50),
|
||||
older(mac, "t-window", "wristcode", "Recent window", hours: 53),
|
||||
].map { item in
|
||||
var item = item
|
||||
item.projectId = "p-wc-mac"
|
||||
return item
|
||||
}
|
||||
settled = Set(settledThreads.map(\.id))
|
||||
|
||||
items = [approval, question, release, homelab, scaffold, containerfile, statusScreens, nextjs, hyprlock, cdn]
|
||||
+ olderRecent + settledThreads + [path, image, healthcheck]
|
||||
children = [homelab.id: [path.id], containerfile.id: [image.id, healthcheck.id]]
|
||||
|
||||
func m(_ id: String, _ role: Message.Role, _ minutes: Double, _ text: String) -> Message {
|
||||
Message(id: id, role: role, text: text, createdAt: ago(minutes))
|
||||
@@ -330,8 +420,11 @@ struct DemoFixtures {
|
||||
],
|
||||
containerfile.id: [
|
||||
m("d1", .user, 15, "Write a Containerfile for the gateway."),
|
||||
m("d2", .assistant, 2, "Running podman build"),
|
||||
m("d2", .assistant, 2, "Split the image build and the healthcheck into two subagents. Waiting on them."),
|
||||
],
|
||||
path.id: [m("p1", .assistant, 4, "jq is missing from PATH. I want to install it.")],
|
||||
image.id: [m("i1", .assistant, 1, "Running podman build.")],
|
||||
healthcheck.id: [m("g1", .assistant, 2.5, "Podman runs `HEALTHCHECK` on its own timer. Use `--health-interval`.")],
|
||||
nextjs.id: [
|
||||
m("e1", .user, 90, "Bump Next.js to the latest release."),
|
||||
m("e2", .assistant, 80, "Updating next and eslint-config-next, then running the build."),
|
||||
@@ -343,7 +436,7 @@ struct DemoFixtures {
|
||||
],
|
||||
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."),
|
||||
m("k2", .assistant, 95, hyprlockReply),
|
||||
],
|
||||
cdn.id: [
|
||||
m("f1", .user, 150, "Fix the cache headers the CDN sends for images."),
|
||||
@@ -351,6 +444,19 @@ struct DemoFixtures {
|
||||
],
|
||||
]
|
||||
|
||||
history = [
|
||||
hyprlock.id: [
|
||||
[
|
||||
m("k-3", .user, 300, "Show the clock in 24 hours."),
|
||||
m("k-4", .assistant, 290, "Switched the clock to `HH:mm`."),
|
||||
],
|
||||
[
|
||||
m("k-1", .user, 420, "Theme hyprlock like the bar."),
|
||||
m("k-2", .assistant, 400, "## Done\nhyprlock uses the **bar's** colors and font now."),
|
||||
],
|
||||
],
|
||||
]
|
||||
|
||||
let claude = AgentProvider(
|
||||
instanceId: "claudeAgent", name: "Claude", canAskFirst: true,
|
||||
models: [
|
||||
@@ -436,14 +542,20 @@ struct DemoFixtures {
|
||||
items.filter { $0.phase == .completed || $0.phase == .failed }.sorted { $0.updatedAt > $1.updatedAt }
|
||||
}
|
||||
|
||||
/// Threads the feed lists: not settled, and not a subagent.
|
||||
var listed: [FeedItem] {
|
||||
let subagents = Set(children.values.joined())
|
||||
return items.filter { !settled.contains($0.id) && !subagents.contains($0.id) }
|
||||
}
|
||||
|
||||
/// A feed for previews, without going through the actor.
|
||||
static func previewFeed(now: Date = .now) -> Feed {
|
||||
let fixtures = DemoFixtures(now: now)
|
||||
return feed(fixtures.items, hosts: fixtures.hosts)
|
||||
return feed(fixtures.listed, hosts: fixtures.hosts)
|
||||
}
|
||||
|
||||
/// The full Recent list for previews.
|
||||
static func previewRecent(now: Date = .now) -> [FeedItem] {
|
||||
recent(DemoFixtures(now: now).items)
|
||||
recent(DemoFixtures(now: now).listed)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
// All threads, opened from the last row of the feed: hosts, then a host's projects sorted by title,
|
||||
// then a project's threads with settled ones included. Hosts and projects come from
|
||||
// `GET /v1/projects`, threads from `GET /v1/projects/:hostId/:projectId/threads`. Each screen loads
|
||||
// when it appears; nothing polls. A thread opens the usual thread screen.
|
||||
|
||||
import SwiftUI
|
||||
|
||||
/// Every host, offline ones dimmed. Each opens its projects.
|
||||
struct HostsView: View {
|
||||
@Environment(AppModel.self) private var model
|
||||
/// Snapshots render the rows without their List, which ImageRenderer leaves blank.
|
||||
var scrolls = true
|
||||
|
||||
var body: some View {
|
||||
BrowseList(scrolls: scrolls, error: model.projectsError, loaded: model.projects != nil, reload: model.loadProjects) {
|
||||
if let hosts = model.projects?.hosts {
|
||||
ForEach(hosts) { host in
|
||||
BrowseRow(
|
||||
title: host.label,
|
||||
subtitle: host.status.problem ?? (host.projects.count == 1 ? "1 project" : "\(host.projects.count) projects"),
|
||||
route: .hostProjects(hostId: host.id))
|
||||
.opacity(host.status == .online ? 1 : 0.5)
|
||||
.feedRow()
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("All threads")
|
||||
}
|
||||
}
|
||||
|
||||
/// One host's projects, sorted by title. Each opens its threads.
|
||||
struct ProjectsView: View {
|
||||
@Environment(AppModel.self) private var model
|
||||
let hostId: String
|
||||
var scrolls = true
|
||||
|
||||
var body: some View {
|
||||
let host = model.projects?.hosts.first { $0.id == hostId }
|
||||
BrowseList(scrolls: scrolls, error: model.projectsError, loaded: model.projects != nil, reload: model.loadProjects) {
|
||||
if let host {
|
||||
let projects = host.projects.sorted { $0.title.localizedStandardCompare($1.title) == .orderedAscending }
|
||||
ForEach(projects) { project in
|
||||
BrowseRow(title: project.title, route: .projectThreads(hostId: host.id, projectId: project.id))
|
||||
.feedRow()
|
||||
}
|
||||
if projects.isEmpty {
|
||||
BrowseNote(text: "No projects.")
|
||||
}
|
||||
} else if model.projects != nil {
|
||||
BrowseNote(text: "This host is gone.")
|
||||
}
|
||||
}
|
||||
.navigationTitle(host?.label ?? "Projects")
|
||||
}
|
||||
}
|
||||
|
||||
/// One project's threads, newest first: a status dot, the title, dimmed once finished or settled,
|
||||
/// and the age.
|
||||
struct ProjectThreadsView: View {
|
||||
@Environment(AppModel.self) private var model
|
||||
let hostId: String
|
||||
let projectId: String
|
||||
@State private var rows: [ThreadRow]?
|
||||
@State private var error: String?
|
||||
private let scrolls: Bool
|
||||
|
||||
/// `rows` starts the screen loaded, for snapshots.
|
||||
init(hostId: String, projectId: String, rows: [ThreadRow]? = nil, scrolls: Bool = true) {
|
||||
self.hostId = hostId
|
||||
self.projectId = projectId
|
||||
self.scrolls = scrolls
|
||||
_rows = State(initialValue: rows)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
let title = model.projects?.hosts.first { $0.id == hostId }?.projects.first { $0.id == projectId }?.title
|
||||
BrowseList(scrolls: scrolls, error: error, loaded: rows != nil, reload: load) {
|
||||
if let rows {
|
||||
ForEach(rows) { row in
|
||||
ThreadLine(row: row) {
|
||||
Text(Format.elapsed(since: row.updatedAt))
|
||||
.foregroundStyle(Theme.secondaryText)
|
||||
}
|
||||
.feedRow()
|
||||
}
|
||||
if rows.isEmpty {
|
||||
BrowseNote(text: "No threads yet.")
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle(title ?? "Threads")
|
||||
.task {
|
||||
// The title comes from the projects list, which a wristcode:// link may skip.
|
||||
if model.projects == nil { await model.loadProjects() }
|
||||
}
|
||||
}
|
||||
|
||||
private func load() async {
|
||||
do {
|
||||
rows = try await model.api.projectThreads(hostId: hostId, projectId: projectId).items
|
||||
error = nil
|
||||
} catch {
|
||||
self.error = model.message(for: error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A thread on one line: status dot, title, and `trailing` on the right. Opens the thread.
|
||||
struct ThreadLine<Trailing: View>: View {
|
||||
@Environment(AppModel.self) private var model
|
||||
var row: ThreadRow
|
||||
@ViewBuilder var trailing: Trailing
|
||||
|
||||
var body: some View {
|
||||
Button { model.path.append(row.route) } label: {
|
||||
Card(edge: Theme.card) {
|
||||
HStack(spacing: 7) {
|
||||
Circle()
|
||||
.fill(row.color)
|
||||
.frame(width: 7, height: 7)
|
||||
Text(row.title)
|
||||
.font(.system(size: 13, weight: .semibold))
|
||||
.foregroundStyle(row.isActive ? Color.white : Theme.secondaryText)
|
||||
.lineLimit(1)
|
||||
Spacer(minLength: 4)
|
||||
trailing
|
||||
.font(.system(size: 11, weight: .semibold))
|
||||
.lineLimit(1)
|
||||
}
|
||||
}
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
|
||||
/// The List each browse screen scrolls, with loading, an error and Try again. Loads on appear.
|
||||
private struct BrowseList<Rows: View>: View {
|
||||
var scrolls: Bool
|
||||
var error: String?
|
||||
var loaded: Bool
|
||||
var reload: () async -> Void
|
||||
@ViewBuilder var rows: Rows
|
||||
|
||||
var body: some View {
|
||||
if scrolls {
|
||||
List { content }
|
||||
.listStyle(.plain)
|
||||
.refreshable { await reload() }
|
||||
.task { await reload() }
|
||||
} else {
|
||||
VStack(spacing: 6) { content }
|
||||
.padding(.horizontal, 4)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder private var content: some View {
|
||||
if loaded {
|
||||
rows
|
||||
} else if error == nil {
|
||||
ProgressView()
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.top, 20)
|
||||
.feedRow()
|
||||
}
|
||||
if let error {
|
||||
BrowseNote(text: error)
|
||||
if !loaded {
|
||||
Button("Try again") { Task { await reload() } }
|
||||
.buttonStyle(PillButtonStyle(fill: Theme.control, foreground: .white))
|
||||
.feedRow()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A plain card with a title, an optional line under it, and a chevron.
|
||||
private struct BrowseRow: View {
|
||||
@Environment(AppModel.self) private var model
|
||||
var title: String
|
||||
var subtitle: String?
|
||||
var route: Route
|
||||
|
||||
var body: some View {
|
||||
Button { model.path.append(route) } label: {
|
||||
Card(edge: Theme.done) {
|
||||
HStack {
|
||||
VStack(alignment: .leading, spacing: 1) {
|
||||
Text(title)
|
||||
.font(.system(size: 14, weight: .semibold))
|
||||
.lineLimit(2)
|
||||
if let subtitle {
|
||||
Text(subtitle)
|
||||
.font(.system(size: 11))
|
||||
.foregroundStyle(Theme.secondaryText)
|
||||
.lineLimit(1)
|
||||
}
|
||||
}
|
||||
Spacer(minLength: 4)
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.system(size: 11, weight: .bold))
|
||||
.foregroundStyle(Theme.secondaryText)
|
||||
}
|
||||
}
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
|
||||
private struct BrowseNote: View {
|
||||
var text: String
|
||||
|
||||
var body: some View {
|
||||
Text(text)
|
||||
.font(.system(size: 12))
|
||||
.foregroundStyle(Theme.secondaryText)
|
||||
.padding(.horizontal, 8)
|
||||
.feedRow()
|
||||
}
|
||||
}
|
||||
|
||||
#Preview("Hosts") {
|
||||
NavigationStack {
|
||||
HostsView()
|
||||
}
|
||||
.environment(AppModel.preview())
|
||||
.fontDesign(.rounded)
|
||||
}
|
||||
|
||||
#Preview("Threads") {
|
||||
NavigationStack {
|
||||
ProjectThreadsView(hostId: "mac-mini", projectId: "p-wc-mac")
|
||||
}
|
||||
.environment(AppModel.preview())
|
||||
.fontDesign(.rounded)
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
// 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. Past
|
||||
// Recent's first 10, an "N more" row opens the full Recent list.
|
||||
// (with Allow and Deny on the card), then working (with the step the agent is on and how long it
|
||||
// has run), then recent. A Recent row swipes left to settle, and the Recent header settles every
|
||||
// completed thread. Past Recent's first 10, an "N more" row opens the full Recent list. The last
|
||||
// row opens All threads.
|
||||
|
||||
import SwiftUI
|
||||
|
||||
@@ -39,6 +40,7 @@ struct FeedView: View {
|
||||
.foregroundStyle(Theme.secondaryText)
|
||||
.feedRow()
|
||||
}
|
||||
AllThreadsRow().feedRow()
|
||||
} else if model.feedError != nil {
|
||||
Button("Try again") { Task { await model.refreshFeed() } }
|
||||
.buttonStyle(PillButtonStyle(fill: Theme.control, foreground: .white))
|
||||
@@ -207,6 +209,28 @@ struct MoreRecentRow: View {
|
||||
}
|
||||
}
|
||||
|
||||
/// The feed's last row, a plain card opening All threads.
|
||||
struct AllThreadsRow: View {
|
||||
@Environment(AppModel.self) private var model
|
||||
|
||||
var body: some View {
|
||||
Button { model.path.append(.allThreads) } label: {
|
||||
Card(edge: Theme.done) {
|
||||
HStack {
|
||||
Text("All threads")
|
||||
.font(.system(size: 13, weight: .semibold))
|
||||
Spacer(minLength: 4)
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.system(size: 11, weight: .bold))
|
||||
.foregroundStyle(Theme.secondaryText)
|
||||
}
|
||||
}
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
|
||||
/// An amber card for a thread waiting on an approval or an answer, with Allow and Deny inline.
|
||||
/// When a subagent asked, one line names it, and the answer goes to the subagent.
|
||||
struct NeedsYouCard: View {
|
||||
@@ -292,7 +316,8 @@ struct NeedsYouCard: View {
|
||||
}
|
||||
}
|
||||
|
||||
/// A teal card for a working thread, or a grey one for a recent thread. A recent thread that failed
|
||||
/// A teal card for a working thread, or a grey one for a recent thread. A working thread shows the
|
||||
/// step it is on, or its latest message when the gateway names no step. A recent thread that failed
|
||||
/// to settle shows why.
|
||||
struct StatusCard: View {
|
||||
@Environment(AppModel.self) private var model
|
||||
@@ -310,8 +335,11 @@ struct StatusCard: View {
|
||||
.foregroundStyle(item.phase.isWorking ? Color.white : Theme.secondaryText)
|
||||
.lineLimit(2)
|
||||
.padding(.top, 1)
|
||||
if item.phase.isWorking, let line = item.lastMessage {
|
||||
Text(line)
|
||||
if item.phase.isWorking, let activity = item.activity {
|
||||
ActivityLine(activity: activity)
|
||||
.padding(.top, 3)
|
||||
} else if item.phase.isWorking, let line = item.lastMessage {
|
||||
Text(Markdown.render(line))
|
||||
.font(.system(size: 12))
|
||||
.foregroundStyle(Theme.secondaryText)
|
||||
.lineLimit(2)
|
||||
@@ -339,6 +367,58 @@ struct StatusCard: View {
|
||||
}
|
||||
}
|
||||
|
||||
/// What a working thread is doing: a teal symbol for the kind of step, its text, and how long it has
|
||||
/// run. The elapsed time is worked out when the card renders, so the feed's 10 s refresh moves it on.
|
||||
struct ActivityLine: View {
|
||||
var activity: Activity
|
||||
|
||||
var body: some View {
|
||||
HStack(alignment: .firstTextBaseline, spacing: 4) {
|
||||
Image(systemName: symbol)
|
||||
.font(.system(size: 10, weight: .bold))
|
||||
.foregroundStyle(Theme.work)
|
||||
.frame(width: 14)
|
||||
label
|
||||
.font(.system(size: 12))
|
||||
.foregroundStyle(Theme.secondaryText)
|
||||
.lineLimit(2)
|
||||
Spacer(minLength: 4)
|
||||
if let startedAt = activity.startedAt {
|
||||
Text(Format.elapsed(since: startedAt))
|
||||
.font(.system(size: 11, weight: .semibold))
|
||||
.monospacedDigit()
|
||||
.foregroundStyle(Theme.work)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var label: Text {
|
||||
// Marked as code, which Text sets in monospace. A monospaced Font loses to the app's rounded design.
|
||||
let mono = { (text: String) -> Text in
|
||||
var code = AttributedString(text)
|
||||
code.inlinePresentationIntent = .code
|
||||
return Text(code).font(.system(size: 11))
|
||||
}
|
||||
return switch activity.kind {
|
||||
case .command: mono(activity.text)
|
||||
case .edit: Text("Editing \(mono(activity.text))")
|
||||
default: Text(activity.text)
|
||||
}
|
||||
}
|
||||
|
||||
private var symbol: String {
|
||||
switch activity.kind {
|
||||
case .command: "terminal"
|
||||
case .edit: "pencil"
|
||||
case .search: "magnifyingglass"
|
||||
case .web: "globe"
|
||||
case .subagent: "arrow.triangle.branch"
|
||||
case .thinking: "ellipsis"
|
||||
default: "wrench.adjustable"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#Preview("Feed") {
|
||||
NavigationStack {
|
||||
FeedView()
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
// Message text as styled text. Inline markdown (bold, italics, inline code, links) goes through
|
||||
// `AttributedString(markdown:)`. Block syntax is rewritten line by line first, since SwiftUI's Text
|
||||
// runs full-parsed paragraphs together: headings turn bold, list markers turn into bullets, quote
|
||||
// markers go, and fenced code keeps its lines in monospace.
|
||||
|
||||
import Foundation
|
||||
|
||||
enum Markdown {
|
||||
static func render(_ source: String) -> AttributedString {
|
||||
var result = AttributedString()
|
||||
var fenced = false
|
||||
var first = true
|
||||
for line in source.split(separator: "\n", omittingEmptySubsequences: false).map(String.init) {
|
||||
if line.trimmingCharacters(in: .whitespaces).hasPrefix("```") {
|
||||
fenced.toggle()
|
||||
continue
|
||||
}
|
||||
if !first { result += AttributedString("\n") }
|
||||
first = false
|
||||
if fenced {
|
||||
var code = AttributedString(line)
|
||||
code.inlinePresentationIntent = .code
|
||||
result += code
|
||||
} else {
|
||||
result += inline(block(line))
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
/// One line with its block syntax rewritten as inline markdown or plain text.
|
||||
static func block(_ line: String) -> String {
|
||||
if let heading = line.wholeMatch(of: /\s{0,3}#{1,6}\s+(.+?)[\s#]*/) {
|
||||
return "**\(heading.1)**"
|
||||
}
|
||||
if let item = line.wholeMatch(of: /(\s*)[-*+]\s+(.*)/) {
|
||||
return "\(item.1)• \(item.2)"
|
||||
}
|
||||
if let quote = line.wholeMatch(of: /\s{0,3}>\s?(.*)/) {
|
||||
return String(quote.1)
|
||||
}
|
||||
return line
|
||||
}
|
||||
|
||||
/// Inline markdown, or the line as plain text when it does not parse.
|
||||
private static func inline(_ line: String) -> AttributedString {
|
||||
let options = AttributedString.MarkdownParsingOptions(interpretedSyntax: .inlineOnlyPreservingWhitespace)
|
||||
return (try? AttributedString(markdown: line, options: options)) ?? AttributedString(line)
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,11 @@ extension Phase {
|
||||
}
|
||||
}
|
||||
|
||||
extension ThreadRow {
|
||||
/// The status dot: amber waiting, teal working, red failed, grey otherwise.
|
||||
var color: Color { phase == .failed ? Theme.bad : phase?.color ?? Theme.done }
|
||||
}
|
||||
|
||||
/// A dark rounded card with a 4 pt colored left edge.
|
||||
struct Card<Content: View>: View {
|
||||
var edge: Color
|
||||
@@ -135,6 +140,16 @@ enum Format {
|
||||
return "\(hours / 24) d"
|
||||
}
|
||||
|
||||
/// A compact time since a date, such as "42s", "3m", "1h" or "2d", for an activity's elapsed time
|
||||
/// and a thread row's age.
|
||||
static func elapsed(since date: Date, now: Date = .now) -> String {
|
||||
let seconds = max(0, Int(now.timeIntervalSince(date)))
|
||||
if seconds < 60 { return "\(seconds)s" }
|
||||
if seconds < 3600 { return "\(seconds / 60)m" }
|
||||
if seconds < 86_400 { return "\(seconds / 3600)h" }
|
||||
return "\(seconds / 86_400)d"
|
||||
}
|
||||
|
||||
/// How long until a date, such as "40 s", "3 min" or "2 h".
|
||||
static func countdown(to date: Date, now: Date = .now) -> String {
|
||||
let seconds = max(0, Int(date.timeIntervalSince(now).rounded()))
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
// 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, Settle once it finished or failed,
|
||||
// an Ask before acting switch, and Stop while a run is active. A subagent's request shows first,
|
||||
// labeled with the subagent.
|
||||
// One thread: the agent's latest message large, older messages one scroll up with Load earlier above
|
||||
// them, the pending approval or question with its options, the thread's subagents, Reply by
|
||||
// dictation or Scribble, Settle once it finished or failed, an Ask before acting switch, and Stop
|
||||
// while a run is active. A subagent's request shows first, labeled with the subagent. Message text
|
||||
// renders as markdown.
|
||||
|
||||
import SwiftUI
|
||||
|
||||
@@ -24,6 +25,9 @@ struct ThreadView: View {
|
||||
@State private var modeError: String?
|
||||
/// The Ask before acting value still being sent, which a poll landing meanwhile keeps.
|
||||
@State private var pendingAskFirst: Bool?
|
||||
/// Pages Load earlier fetched. The 10 s refresh replaces only the latest window.
|
||||
@State private var history = MessageHistory()
|
||||
@State private var loadingEarlier = false
|
||||
|
||||
/// Snapshots render the column without its ScrollView, which ImageRenderer leaves blank.
|
||||
private let scrolls: Bool
|
||||
@@ -98,9 +102,15 @@ struct ThreadView: View {
|
||||
|
||||
@ViewBuilder
|
||||
private func content(_ detail: ThreadDetail) -> some View {
|
||||
let split = MessageSplit(detail.messages)
|
||||
let split = MessageSplit(history.messages(before: detail.messages))
|
||||
let item = detail.item
|
||||
|
||||
if let cursor = history.cursor(detail) {
|
||||
Button(loadingEarlier ? "Loading" : "Load earlier") { loadEarlier(cursor) }
|
||||
.buttonStyle(ChoiceButtonStyle(fill: Theme.card, foreground: Theme.secondaryText))
|
||||
.disabled(loadingEarlier)
|
||||
}
|
||||
|
||||
ForEach(split.earlier) { message in
|
||||
MessageRow(message: message)
|
||||
}
|
||||
@@ -112,7 +122,7 @@ struct ThreadView: View {
|
||||
.padding(.top, split.earlier.isEmpty ? 0 : 6)
|
||||
}
|
||||
|
||||
Text(split.latest?.text ?? item.lastMessage ?? "No reply yet.")
|
||||
Text(Markdown.render(split.latest?.text ?? item.lastMessage ?? "No reply yet."))
|
||||
.font(.system(size: 16))
|
||||
.foregroundStyle(Theme.bodyText)
|
||||
.lineSpacing(1)
|
||||
@@ -132,6 +142,20 @@ struct ThreadView: View {
|
||||
requestCards(request, from: nil)
|
||||
}
|
||||
|
||||
if !detail.subagents.isEmpty {
|
||||
Text("Subagents")
|
||||
.font(.system(size: 11, weight: .semibold))
|
||||
.foregroundStyle(Theme.secondaryText)
|
||||
.padding(.horizontal, 6)
|
||||
.padding(.top, 4)
|
||||
ForEach(detail.subagents) { row in
|
||||
ThreadLine(row: row) {
|
||||
Text(row.statusWord)
|
||||
.foregroundStyle(row.isActive ? row.color : Theme.secondaryText)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if item.phase == .completed && item.request == nil {
|
||||
QuickReplies { send($0) }
|
||||
.disabled(sending)
|
||||
@@ -316,6 +340,7 @@ struct ThreadView: View {
|
||||
do {
|
||||
var fresh = try await model.api.thread(hostId: hostId, threadId: threadId)
|
||||
if let pendingAskFirst { fresh.askFirst = pendingAskFirst }
|
||||
if let detail { history.keep(detail.messages) }
|
||||
detail = fresh
|
||||
error = nil
|
||||
} catch {
|
||||
@@ -323,6 +348,19 @@ struct ThreadView: View {
|
||||
}
|
||||
}
|
||||
|
||||
/// Fetches the page before `cursor` and puts it above the messages already shown.
|
||||
private func loadEarlier(_ cursor: String) {
|
||||
loadingEarlier = true
|
||||
Task {
|
||||
do {
|
||||
history.prepend(try await model.api.history(hostId: hostId, threadId: threadId, cursor: cursor))
|
||||
} catch {
|
||||
self.error = model.message(for: error)
|
||||
}
|
||||
loadingEarlier = false
|
||||
}
|
||||
}
|
||||
|
||||
private func perform(_ action: @escaping @MainActor () async throws -> Void) {
|
||||
sending = true
|
||||
error = nil
|
||||
@@ -426,12 +464,50 @@ struct MessageSplit {
|
||||
}
|
||||
}
|
||||
|
||||
/// Older pages fetched by Load earlier, oldest first, kept in front of the latest window the thread
|
||||
/// route returns. Once a page is in, a refresh's outgoing window is kept too, so no gap opens when
|
||||
/// new messages push the window forward. A message shows once, by id, the latest window's copy first.
|
||||
struct MessageHistory {
|
||||
private(set) var older: [Message] = []
|
||||
/// The cursor for the page before `older`, once a page has loaded.
|
||||
private var nextCursor: String?
|
||||
private var loaded = false
|
||||
|
||||
/// Where Load earlier fetches from next, or nil when the thread starts in view.
|
||||
func cursor(_ detail: ThreadDetail) -> String? {
|
||||
loaded ? nextCursor : detail.earlierCursor
|
||||
}
|
||||
|
||||
/// The older pages, then `latest`.
|
||||
func messages(before latest: [Message]) -> [Message] {
|
||||
let ids = Set(latest.map(\.id))
|
||||
return older.filter { !ids.contains($0.id) } + latest
|
||||
}
|
||||
|
||||
mutating func prepend(_ page: HistoryResponse) {
|
||||
older = Self.unique(page.messages + older)
|
||||
nextCursor = page.earlierCursor
|
||||
loaded = true
|
||||
}
|
||||
|
||||
/// Keeps the window a refresh replaces, once older pages are in.
|
||||
mutating func keep(_ window: [Message]) {
|
||||
guard loaded else { return }
|
||||
older = Self.unique(older + window)
|
||||
}
|
||||
|
||||
private static func unique(_ messages: [Message]) -> [Message] {
|
||||
var seen = Set<String>()
|
||||
return messages.filter { seen.insert($0.id).inserted }
|
||||
}
|
||||
}
|
||||
|
||||
private struct MessageRow: View {
|
||||
var message: Message
|
||||
|
||||
var body: some View {
|
||||
let mine = message.role == .user
|
||||
Text(message.text)
|
||||
Text(Markdown.render(message.text))
|
||||
.font(.system(size: 13))
|
||||
.foregroundStyle(mine ? Color.white : Theme.secondaryText)
|
||||
.padding(.horizontal, mine ? 9 : 0)
|
||||
|
||||
@@ -41,6 +41,12 @@ struct RootView: View {
|
||||
HostStatusView(hostId: id)
|
||||
case .notifications:
|
||||
NotificationsView()
|
||||
case .allThreads:
|
||||
HostsView()
|
||||
case let .hostProjects(hostId):
|
||||
ProjectsView(hostId: hostId)
|
||||
case let .projectThreads(hostId, projectId):
|
||||
ProjectThreadsView(hostId: hostId, projectId: projectId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -155,8 +155,11 @@ private let feedItemJSON = """
|
||||
"canStop": true }
|
||||
""")
|
||||
#expect(detail.canStop)
|
||||
// An older gateway leaves out askFirst, so the switch stays hidden.
|
||||
// An older gateway leaves out askFirst, so the switch stays hidden, and the phase 3 fields.
|
||||
#expect(detail.askFirst == nil)
|
||||
#expect(detail.earlierCursor == nil)
|
||||
#expect(detail.subagents.isEmpty)
|
||||
#expect(detail.item.activity == nil)
|
||||
#expect(detail.messages.first?.role == .assistant)
|
||||
#expect(detail.messages.first?.createdAt == Date(timeIntervalSince1970: 1_793_526_000))
|
||||
}
|
||||
@@ -201,6 +204,45 @@ private let feedItemJSON = """
|
||||
#expect(plain["model"] == nil && plain["askFirst"] == nil)
|
||||
}
|
||||
|
||||
@Test func phase3FromDocs() throws {
|
||||
let working = feedItemJSON
|
||||
.replacingOccurrences(of: "waiting_for_approval", with: "running")
|
||||
.replacingOccurrences(
|
||||
of: #""request": {"#,
|
||||
with: #""activity": { "kind": "command", "text": "bun test", "startedAt": "2026-11-01T09:40:00.000Z" }, "request": {"#)
|
||||
let detail = try decode(ThreadDetail.self, """
|
||||
{ "item": \(working), "messages": [], "canStop": true, "askFirst": false, "earlierCursor": "w12",
|
||||
"subagents": [
|
||||
{ "hostId": "h", "threadId": "s1", "title": "Check PATH", "phase": "running", "settled": false, "updatedAt": "2026-11-01T09:41:00.000Z" },
|
||||
{ "hostId": "h", "threadId": "s2", "title": "Stopped", "phase": null, "settled": true, "updatedAt": "2026-11-01T09:30:00.000Z" }
|
||||
] }
|
||||
""")
|
||||
let activity = try #require(detail.item.activity)
|
||||
#expect(activity.kind == .command)
|
||||
#expect(activity.text == "bun test")
|
||||
#expect(activity.startedAt == Date(timeIntervalSince1970: 1_793_526_000))
|
||||
#expect(detail.earlierCursor == "w12")
|
||||
#expect(detail.subagents.map(\.statusWord) == ["working", "settled"])
|
||||
#expect(detail.subagents.map(\.isActive) == [true, false])
|
||||
#expect(detail.subagents.last?.phase == nil)
|
||||
|
||||
let thinking = try decode(Activity.self, #"{ "kind": "thinking", "text": "Thinking", "startedAt": null }"#)
|
||||
#expect(thinking.startedAt == nil)
|
||||
|
||||
let history = try decode(HistoryResponse.self, """
|
||||
{ "messages": [{ "id": "m1", "role": "user", "text": "Hi", "createdAt": "2026-11-01T09:00:00Z" }], "earlierCursor": null }
|
||||
""")
|
||||
#expect(history.messages.map(\.id) == ["m1"])
|
||||
#expect(history.earlierCursor == nil)
|
||||
|
||||
let list = try decode(ThreadListResponse.self, """
|
||||
{ "items": [{ "hostId": "h", "threadId": "t", "title": "Phase 3", "phase": "failed", "settled": false,
|
||||
"updatedAt": "2026-11-01T09:41:00.000Z" }] }
|
||||
""")
|
||||
#expect(list.items.first?.statusWord == "failed")
|
||||
#expect(list.items.first?.route == .thread(hostId: "h", threadId: "t"))
|
||||
}
|
||||
|
||||
@Test func smallResponses() throws {
|
||||
#expect(try decode(PairResponse.self, #"{ "deviceId": "9b0c", "deviceToken": "wc_x" }"#).deviceToken == "wc_x")
|
||||
#expect(try decode(NewThreadResponse.self, #"{ "hostId": "h", "threadId": "t" }"#).threadId == "t")
|
||||
@@ -291,6 +333,52 @@ private let feedItemJSON = """
|
||||
#expect(Format.age(of: now.addingTimeInterval(-50 * 3600), now: now) == "2 d")
|
||||
}
|
||||
|
||||
@Test func elapsedTimes() {
|
||||
let now = Date(timeIntervalSince1970: 100_000)
|
||||
#expect(Format.elapsed(since: now.addingTimeInterval(5), now: now) == "0s")
|
||||
#expect(Format.elapsed(since: now.addingTimeInterval(-42), now: now) == "42s")
|
||||
#expect(Format.elapsed(since: now.addingTimeInterval(-60), now: now) == "1m")
|
||||
#expect(Format.elapsed(since: now.addingTimeInterval(-199), now: now) == "3m")
|
||||
#expect(Format.elapsed(since: now.addingTimeInterval(-3600), now: now) == "1h")
|
||||
#expect(Format.elapsed(since: now.addingTimeInterval(-50 * 3600), now: now) == "2d")
|
||||
}
|
||||
|
||||
@Test func olderMessagesMerge() throws {
|
||||
func m(_ id: String) -> Message { Message(id: id, role: .assistant, text: id, createdAt: .now) }
|
||||
let detail = try decode(ThreadDetail.self, #"{ "item": \#(feedItemJSON), "messages": [], "canStop": false, "earlierCursor": "w3" }"#)
|
||||
var history = MessageHistory()
|
||||
#expect(history.cursor(detail) == "w3")
|
||||
#expect(history.messages(before: [m("4"), m("5")]).map(\.id) == ["4", "5"])
|
||||
|
||||
// A page that overlaps the window shows each message once.
|
||||
history.prepend(HistoryResponse(messages: [m("2"), m("3"), m("4")], earlierCursor: "w1"))
|
||||
#expect(history.cursor(detail) == "w1")
|
||||
#expect(history.messages(before: [m("4"), m("5")]).map(\.id) == ["2", "3", "4", "5"])
|
||||
|
||||
// A refresh moves the window on. The outgoing window stays, so no gap opens.
|
||||
history.keep([m("4"), m("5")])
|
||||
#expect(history.messages(before: [m("6"), m("7")]).map(\.id) == ["2", "3", "4", "5", "6", "7"])
|
||||
history.keep([m("6"), m("7")])
|
||||
|
||||
history.prepend(HistoryResponse(messages: [m("1"), m("2")], earlierCursor: nil))
|
||||
#expect(history.cursor(detail) == nil)
|
||||
#expect(history.messages(before: [m("7")]).map(\.id) == ["1", "2", "3", "4", "5", "6", "7"])
|
||||
|
||||
// Nothing is kept before Load earlier is used.
|
||||
var fresh = MessageHistory()
|
||||
fresh.keep([m("4")])
|
||||
#expect(fresh.messages(before: [m("5")]).map(\.id) == ["5"])
|
||||
}
|
||||
|
||||
@Test func markdownReadsCleanly() {
|
||||
#expect(Markdown.block("## Done") == "**Done**")
|
||||
#expect(Markdown.block(" - item") == " • item")
|
||||
#expect(Markdown.block("> quoted") == "quoted")
|
||||
#expect(Markdown.block("**bold** stays") == "**bold** stays")
|
||||
let text = String(Markdown.render("All **48** pass in `host.ts`:\n- bounded retry\n```\n# not a heading\n```").characters)
|
||||
#expect(text == "All 48 pass in host.ts:\n• bounded retry\n# not a heading")
|
||||
}
|
||||
|
||||
@Test func messageSplitShowsLatestAgentMessage() {
|
||||
func m(_ id: String, _ role: Message.Role) -> Message { Message(id: id, role: role, text: id, createdAt: .now) }
|
||||
let split = MessageSplit([m("1", .user), m("2", .assistant), m("3", .assistant), m("4", .user)])
|
||||
@@ -361,7 +449,10 @@ private let feedItemJSON = """
|
||||
#expect(thread.url.absoluteString == "wristcode://thread/mac%20mini/t%2F1")
|
||||
#expect(Route(url: thread.url) == thread)
|
||||
#expect(Route(url: Route.newThread.url) == .newThread)
|
||||
for route in [Route.recent, .status, .host(id: "mac mini"), .notifications] {
|
||||
for route in [
|
||||
Route.recent, .status, .host(id: "mac mini"), .notifications, .allThreads, .hostProjects(hostId: "mac mini"),
|
||||
.projectThreads(hostId: "mac mini", projectId: "p/1"),
|
||||
] {
|
||||
#expect(Route(url: route.url) == route)
|
||||
}
|
||||
#expect(Route(url: URL(string: "wristcode://thread/only-host")!) == nil)
|
||||
@@ -548,6 +639,19 @@ private let feedItemJSON = """
|
||||
#expect(try await gateway.feed().moreRecent == 0)
|
||||
}
|
||||
|
||||
@Test func subagentsOpenButStayOutOfTheFeed() async throws {
|
||||
let gateway = DemoGateway()
|
||||
let feed = try await gateway.feed()
|
||||
let parent = try #require(feed.working.first { $0.threadId == "t-container" })
|
||||
let detail = try await gateway.thread(hostId: parent.hostId, threadId: parent.threadId)
|
||||
#expect(!detail.subagents.isEmpty)
|
||||
let listed = feed.needsYou + feed.working + feed.recent
|
||||
for row in detail.subagents {
|
||||
#expect(!listed.contains { $0.threadId == row.threadId })
|
||||
#expect(try await gateway.thread(hostId: row.hostId, threadId: row.threadId).item.title == row.title)
|
||||
}
|
||||
}
|
||||
|
||||
@Test func multiSelectAnswerMovesThreadToWorking() async throws {
|
||||
let gateway = DemoGateway()
|
||||
let item = try #require(try await gateway.feed().needsYou.first { $0.request?.questions.first?.multiSelect == true })
|
||||
|
||||
@@ -55,9 +55,32 @@ struct SnapshotTests {
|
||||
RecentHeader(recent: feed.recent, more: feed.moreRecent)
|
||||
ForEach(feed.recent) { StatusCard(item: $0) }
|
||||
MoreRecentRow(count: feed.moreRecent)
|
||||
AllThreadsRow()
|
||||
}, "feed-recent.png")
|
||||
}
|
||||
|
||||
@Test func workingCardsWithActivity() async throws {
|
||||
let feed = try await DemoGateway().feed()
|
||||
try write(feedColumn { ForEach(feed.working) { StatusCard(item: $0) } }, "feed-activity.png")
|
||||
}
|
||||
|
||||
@Test func browseHostsAndThreads() async throws {
|
||||
// The preview model has the demo hosts and projects loaded.
|
||||
try write(titled("All threads", HostsView(scrolls: false)), "browse-hosts.png")
|
||||
let rows = try await DemoGateway().projectThreads(hostId: "mac-mini", projectId: "p-wc-mac").items
|
||||
try write(titled("wristcode", ProjectThreadsView(hostId: "mac-mini", projectId: "p-wc-mac", rows: rows, scrolls: false)), "browse-threads.png")
|
||||
}
|
||||
|
||||
@Test func threadWithSubagents() async throws {
|
||||
let detail = try await DemoGateway().thread(hostId: "server-gib", threadId: "t-container")
|
||||
try write(titled("Thread", ThreadView(hostId: "server-gib", threadId: "t-container", detail: detail, scrolls: false)), "thread-subagents.png")
|
||||
}
|
||||
|
||||
@Test func threadWithEarlierAndMarkdown() async throws {
|
||||
let detail = try await DemoGateway().thread(hostId: "server-gib", threadId: "t-hyprlock")
|
||||
try write(titled("Thread", ThreadView(hostId: "server-gib", threadId: "t-hyprlock", detail: detail, scrolls: false)), "thread-earlier.png")
|
||||
}
|
||||
|
||||
@Test func fullRecentList() async throws {
|
||||
// The preview model has every demo Recent thread loaded.
|
||||
try write(titled("Recent", RecentView(scrolls: false)), "recent.png")
|
||||
|
||||
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 37 KiB |
|
After Width: | Height: | Size: 41 KiB |
|
Before Width: | Height: | Size: 111 KiB After Width: | Height: | Size: 117 KiB |
|
After Width: | Height: | Size: 96 KiB |
|
Before Width: | Height: | Size: 67 KiB After Width: | Height: | Size: 88 KiB |
|
After Width: | Height: | Size: 78 KiB |