Watch: activity line on working cards, All threads browsing, a thread's subagents, Load earlier, and markdown messages

This commit is contained in:
gib committed 2026-11-02 13:10:02 -05:00
1 parent abd2fd0e13
commit d916bfbab5
21 files changed
+928 -46

No files matched your search

+32 -3
View File
@@ -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 |
+23 -6
View File
@@ -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")
+86
View File
@@ -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`.
+25 -5
View File
@@ -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
}
+31 -4
View File
@@ -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)
}
}
+125 -13
View File
@@ -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)
}
}
+237
View File
@@ -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)
}
+86 -6
View File
@@ -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()
+50
View File
@@ -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)
}
}
+15
View File
@@ -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()))
+83 -7
View File
@@ -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)
+6
View File
@@ -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)
}
}
}
+106 -2
View File
@@ -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 })
+23
View File
@@ -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")
Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 111 KiB

After

Width:  |  Height:  |  Size: 117 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 67 KiB

After

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB