Watch: activity line on working cards, All threads browsing, a thread's subagents, Load earlier, and markdown messages
This commit is contained in:
1 parent
abd2fd0e13
commit
d916bfbab5
21 files changed
+928
-46
No files matched your search
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user