diff --git a/watch/README.md b/watch/README.md index 2ccaf9a..c1f6a26 100644 --- a/watch/README.md +++ b/watch/README.md @@ -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 org.gbrown.wristcode.watchkitapp -demo -screen recent xcrun simctl launch org.gbrown.wristcode.watchkitapp -demo -screen status # status overview xcrun simctl launch org.gbrown.wristcode.watchkitapp -demo -host laptop # one environment xcrun simctl launch org.gbrown.wristcode.watchkitapp -demo -screen notifications # notifications +xcrun simctl launch 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 | diff --git a/watch/WristCode/API/GatewayClient.swift b/watch/WristCode/API/GatewayClient.swift index ca48b59..c924528 100644 --- a/watch/WristCode/API/GatewayClient.swift +++ b/watch/WristCode/API/GatewayClient.swift @@ -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(_ method: String, _ path: [String]) async throws -> Response { - try await call(method, path, body: nil as SendMessageRequest?) + private func call( + _ method: String, _ path: [String], query: [URLQueryItem] = [] + ) async throws -> Response { + try await call(method, path, query: query, body: nil as SendMessageRequest?) } private func call( - _ 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(_ method: String, _ path: [String], body: Body?) async throws -> Data { + private func perform( + _ 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") diff --git a/watch/WristCode/API/Models.swift b/watch/WristCode/API/Models.swift index ea5d279..bd582de 100644 --- a/watch/WristCode/API/Models.swift +++ b/watch/WristCode/API/Models.swift @@ -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`. diff --git a/watch/WristCode/API/Route.swift b/watch/WristCode/API/Route.swift index 5344524..afb5c50 100644 --- a/watch/WristCode/API/Route.swift +++ b/watch/WristCode/API/Route.swift @@ -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//`, `wristcode://new`, `wristcode://recent`, `wristcode://status`, - /// `wristcode://host/` or `wristcode://notifications`. + /// `wristcode://host/`, `wristcode://notifications`, `wristcode://threads`, + /// `wristcode://projects/` or `wristcode://project//`. 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 } diff --git a/watch/WristCode/AppModel.swift b/watch/WristCode/AppModel.swift index 9727306..b56ced1 100644 --- a/watch/WristCode/AppModel.swift +++ b/watch/WristCode/AppModel.swift @@ -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 ` or + /// `-demo` starts in demo mode. `-screen new|recent|status|notifications|threads`, `-host ` or /// `-thread /` 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) } } diff --git a/watch/WristCode/Demo/DemoGateway.swift b/watch/WristCode/Demo/DemoGateway.swift index 6782a79..757440f 100644 --- a/watch/WristCode/Demo/DemoGateway.swift +++ b/watch/WristCode/Demo/DemoGateway.swift @@ -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 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 = [] + private var settled: Set /// Ids of threads in Ask before acting. private var askingFirst: Set = [] 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`, 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 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) } } diff --git a/watch/WristCode/Views/BrowseView.swift b/watch/WristCode/Views/BrowseView.swift new file mode 100644 index 0000000..011aedf --- /dev/null +++ b/watch/WristCode/Views/BrowseView.swift @@ -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: 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: 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) +} diff --git a/watch/WristCode/Views/FeedView.swift b/watch/WristCode/Views/FeedView.swift index c041225..17dc078 100644 --- a/watch/WristCode/Views/FeedView.swift +++ b/watch/WristCode/Views/FeedView.swift @@ -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() diff --git a/watch/WristCode/Views/Markdown.swift b/watch/WristCode/Views/Markdown.swift new file mode 100644 index 0000000..e338474 --- /dev/null +++ b/watch/WristCode/Views/Markdown.swift @@ -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) + } +} diff --git a/watch/WristCode/Views/Theme.swift b/watch/WristCode/Views/Theme.swift index e68be1f..2e1c957 100644 --- a/watch/WristCode/Views/Theme.swift +++ b/watch/WristCode/Views/Theme.swift @@ -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: 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())) diff --git a/watch/WristCode/Views/ThreadView.swift b/watch/WristCode/Views/ThreadView.swift index 4c2a057..5eba2bb 100644 --- a/watch/WristCode/Views/ThreadView.swift +++ b/watch/WristCode/Views/ThreadView.swift @@ -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() + 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) diff --git a/watch/WristCode/WristCodeApp.swift b/watch/WristCode/WristCodeApp.swift index ae64e2c..0c988fc 100644 --- a/watch/WristCode/WristCodeApp.swift +++ b/watch/WristCode/WristCodeApp.swift @@ -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) } } } diff --git a/watch/WristCodeTests/ModelsTests.swift b/watch/WristCodeTests/ModelsTests.swift index f8ebb4f..7c13162 100644 --- a/watch/WristCodeTests/ModelsTests.swift +++ b/watch/WristCodeTests/ModelsTests.swift @@ -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 }) diff --git a/watch/WristCodeTests/SnapshotTests.swift b/watch/WristCodeTests/SnapshotTests.swift index 5b3607b..0038f06 100644 --- a/watch/WristCodeTests/SnapshotTests.swift +++ b/watch/WristCodeTests/SnapshotTests.swift @@ -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") diff --git a/watch/screenshots/browse-hosts.png b/watch/screenshots/browse-hosts.png new file mode 100644 index 0000000..6d7ba6e Binary files /dev/null and b/watch/screenshots/browse-hosts.png differ diff --git a/watch/screenshots/browse-threads.png b/watch/screenshots/browse-threads.png new file mode 100644 index 0000000..01c32b4 Binary files /dev/null and b/watch/screenshots/browse-threads.png differ diff --git a/watch/screenshots/feed-activity.png b/watch/screenshots/feed-activity.png new file mode 100644 index 0000000..38fea85 Binary files /dev/null and b/watch/screenshots/feed-activity.png differ diff --git a/watch/screenshots/feed-recent.png b/watch/screenshots/feed-recent.png index 57b1a76..d759bd8 100644 Binary files a/watch/screenshots/feed-recent.png and b/watch/screenshots/feed-recent.png differ diff --git a/watch/screenshots/thread-earlier.png b/watch/screenshots/thread-earlier.png new file mode 100644 index 0000000..ff2ac6b Binary files /dev/null and b/watch/screenshots/thread-earlier.png differ diff --git a/watch/screenshots/thread-subagent.png b/watch/screenshots/thread-subagent.png index d625ec7..c0383c1 100644 Binary files a/watch/screenshots/thread-subagent.png and b/watch/screenshots/thread-subagent.png differ diff --git a/watch/screenshots/thread-subagents.png b/watch/screenshots/thread-subagents.png new file mode 100644 index 0000000..19c962f Binary files /dev/null and b/watch/screenshots/thread-subagents.png differ