diff --git a/apps/android/core/workfeed/src/main/kotlin/dev/optio/core/workfeed/WorkFeed.kt b/apps/android/core/workfeed/src/main/kotlin/dev/optio/core/workfeed/WorkFeed.kt index 1178c85db..608b7aed6 100644 --- a/apps/android/core/workfeed/src/main/kotlin/dev/optio/core/workfeed/WorkFeed.kt +++ b/apps/android/core/workfeed/src/main/kotlin/dev/optio/core/workfeed/WorkFeed.kt @@ -68,6 +68,10 @@ enum class WorkThen(val raw: String, val label: String) { data class WorkWhere( val target: Target, val detail: String?, + /** On a machine: the `local_hosts` id it runs on (the web's `where.hostId`). */ + val hostId: String? = null, + /** On a machine: the directory it runs in, as stored. */ + val dir: String? = null, ) { enum class Target { POD, MACHINE } @@ -454,7 +458,7 @@ object WorkFeed { dir: String?, ): WorkWhere { val parts = listOfNotNull(hostName[hostId.orEmpty()], shortDir(dir)).filter { it.isNotEmpty() } - return WorkWhere(WorkWhere.Target.MACHINE, parts.takeIf { it.isNotEmpty() }?.joinToString(" · ")) + return WorkWhere(WorkWhere.Target.MACHINE, parts.takeIf { it.isNotEmpty() }?.joinToString(" · "), hostId, dir) } fun pod(detail: String?) = WorkWhere(WorkWhere.Target.POD, detail) diff --git a/apps/android/core/workfeed/src/test/kotlin/dev/optio/core/workfeed/WorkFeedTest.kt b/apps/android/core/workfeed/src/test/kotlin/dev/optio/core/workfeed/WorkFeedTest.kt index ecb3570ba..c314f36f7 100644 --- a/apps/android/core/workfeed/src/test/kotlin/dev/optio/core/workfeed/WorkFeedTest.kt +++ b/apps/android/core/workfeed/src/test/kotlin/dev/optio/core/workfeed/WorkFeedTest.kt @@ -84,7 +84,7 @@ class WorkFeedTest { assertEquals(listOf(WorkStatus.NEEDS_YOU, WorkStatus.NEEDS_YOU), rows.take(2).map { it.status }) assertEquals("terminal-lt1", rows.first().key, "most recent needs-you first") - assertEquals(WorkWhere(WorkWhere.Target.MACHINE, "M1 · ~/app"), rows.row("task-t2").where) + assertEquals(WorkWhere(WorkWhere.Target.MACHINE, "M1 · ~/app", "h1", "/Users/dev/app"), rows.row("task-t2").where) assertEquals("PR 7", rows.row("task-t1").note) assertEquals("/tasks/t1", rows.row("task-t1").href) assertEquals(WorkStatus.PAUSED, rows.row("job-j1").status) @@ -284,7 +284,7 @@ class WorkFeedTest { assertNull(it.prUrl, "an empty PR link is no link") } rows.row("job-j").let { - assertEquals(WorkWhere(WorkWhere.Target.MACHINE, "/srv/jobs"), it.where, "an unknown host drops out") + assertEquals(WorkWhere(WorkWhere.Target.MACHINE, "/srv/jobs", "h9", "/srv/jobs"), it.where, "an unknown host drops out of the label") assertEquals(WhenKind.TRIGGER, it.whenKind) assertEquals("Claude Code", it.whoLabel) } diff --git a/apps/api/src/routes/local.ts b/apps/api/src/routes/local.ts index 3d24cc1b7..adaac16cd 100644 --- a/apps/api/src/routes/local.ts +++ b/apps/api/src/routes/local.ts @@ -589,16 +589,21 @@ export async function localRoutes(rawApp: FastifyInstance) { operationId: "getLocalTerminalTranscript", summary: "The conversation of an agent session (prompts, replies, tool calls)", description: - "Distilled by the daemon from the agent CLI's own transcript, so it covers the whole session — not just the last screen — and reads on any device. Grows while the session runs; `after` fetches only entries past a seq.", + "Distilled by the daemon from the agent CLI's own transcript, so it covers the whole session — not just the last screen — and reads on any device. Grows while the session runs; `after` fetches only entries past a seq. `before` instead returns the last `limit` entries before a seq (pass one past the highest seq for the latest page), with `hasEarlier` saying whether more precede them — how a phone opens a long session without downloading every tool output.", tags: ["Local"], params: z.object({ id: z.string().uuid() }), querystring: z.object({ after: z.coerce.number().int().min(0).default(0), + before: z.coerce.number().int().min(1).optional(), limit: z.coerce.number().int().min(1).max(5000).default(2000), }), response: { 200: z.object({ entries: z.array(LocalTranscriptEntrySchema), + hasEarlier: z + .boolean() + .optional() + .describe("With `before`: entries precede the first one returned"), /** True when fewer than `limit` entries came back, i.e. the caller has everything stored. */ complete: z.boolean(), backfilling: z @@ -616,18 +621,27 @@ export async function localRoutes(rawApp: FastifyInstance) { if (!terminal || !terminalService.canAccessTerminal(terminal, req.user?.id)) { return reply.status(404).send({ error: "Terminal not found" }); } - const entries = await terminalService.getTranscript( - terminal.id, - req.query.after, - req.query.limit, - ); + const { after, before, limit } = req.query; + const page = + before !== undefined + ? await terminalService.getTranscriptBefore(terminal.id, before, limit) + : { + entries: await terminalService.getTranscript(terminal.id, after, limit), + hasEarlier: undefined, + }; + const { entries } = page; // Nothing stored at all: a finished session's machine can still read // it off disk. const backfilling = - req.query.after === 0 && + (before !== undefined ? !page.hasEarlier : after === 0) && entries.length === 0 && terminalService.requestTranscriptBackfill(terminal); - reply.send({ entries, complete: entries.length < req.query.limit, backfilling }); + reply.send({ + entries, + complete: entries.length < limit, + backfilling, + ...(page.hasEarlier !== undefined && { hasEarlier: page.hasEarlier }), + }); }, ); diff --git a/apps/api/src/schemas/work.ts b/apps/api/src/schemas/work.ts index 5d34ef7ab..e21ee1b74 100644 --- a/apps/api/src/schemas/work.ts +++ b/apps/api/src/schemas/work.ts @@ -23,6 +23,13 @@ export const WorkRowSchema = z where: z.object({ target: z.enum(["pod", "machine"]), detail: z.string().nullable(), + hostId: z.string().nullable().optional().describe("On a machine: the local host it runs on"), + hostName: z + .string() + .nullable() + .optional() + .describe("On a machine: its name, when it is one of the caller's machines"), + dir: z.string().nullable().optional().describe("On a machine: the directory it runs in"), }), who: z.string().describe('Agent runtime id, or "terminal"'), then: z.enum(WORK_THENS), diff --git a/apps/api/src/services/local-terminal-service.ts b/apps/api/src/services/local-terminal-service.ts index 0bdbcad6b..f6e77cdcd 100644 --- a/apps/api/src/services/local-terminal-service.ts +++ b/apps/api/src/services/local-terminal-service.ts @@ -1067,20 +1067,50 @@ export async function getTranscript( ) .orderBy(asc(localTerminalTranscripts.seq)) .limit(limit); - return rows.map((r) => - reclassifyStoredTurn({ - seq: r.seq, - role: r.role as LocalTranscriptRole, - kind: r.kind as LocalTranscriptKind, - source: (r.source as LocalTranscriptSource | null) ?? null, - text: r.text, - detail: r.detail, - toolName: r.toolName, - toolUseId: r.toolUseId, - isError: r.isError, - at: r.at ? r.at.toISOString() : null, - }), - ); + return rows.map(transcriptRowToEntry); +} + +/** + * The last `limit` entries before `beforeSeq`, in order — how a phone opens + * a long conversation: the latest exchange first, earlier pages on request, + * instead of every tool output since the session began. `hasEarlier` says + * whether anything precedes the first entry returned. + */ +export async function getTranscriptBefore( + terminalId: string, + beforeSeq: number, + limit: number, +): Promise<{ entries: LocalTranscriptEntry[]; hasEarlier: boolean }> { + const rows = await db + .select() + .from(localTerminalTranscripts) + .where( + and( + eq(localTerminalTranscripts.terminalId, terminalId), + lt(localTerminalTranscripts.seq, beforeSeq), + ), + ) + .orderBy(desc(localTerminalTranscripts.seq)) + .limit(limit + 1); + const hasEarlier = rows.length > limit; + return { entries: rows.slice(0, limit).reverse().map(transcriptRowToEntry), hasEarlier }; +} + +function transcriptRowToEntry( + r: typeof localTerminalTranscripts.$inferSelect, +): LocalTranscriptEntry { + return reclassifyStoredTurn({ + seq: r.seq, + role: r.role as LocalTranscriptRole, + kind: r.kind as LocalTranscriptKind, + source: (r.source as LocalTranscriptSource | null) ?? null, + text: r.text, + detail: r.detail, + toolName: r.toolName, + toolUseId: r.toolUseId, + isError: r.isError, + at: r.at ? r.at.toISOString() : null, + }); } /** diff --git a/apps/api/src/services/local-transcript.int.test.ts b/apps/api/src/services/local-transcript.int.test.ts index 07fe4b65f..6cec75309 100644 --- a/apps/api/src/services/local-transcript.int.test.ts +++ b/apps/api/src/services/local-transcript.int.test.ts @@ -14,6 +14,7 @@ import { deleteTerminal, getTerminal, getTranscript, + getTranscriptBefore, handleExit, handleSession, handleStarted, @@ -156,6 +157,21 @@ describe("handleTranscript / getTranscript", () => { expect(await countTranscript(terminal.id)).toBe(4); }); + it("pages backwards from the latest entry", async () => { + const { host, terminal } = await runningAgentTerminal(); + await handleTranscript( + host.id, + terminal.id, + [1, 2, 3, 4, 5].map((n) => entry(n)), + ); + const latest = await getTranscriptBefore(terminal.id, 20_001, 2); + expect(latest.entries.map((e) => e.seq)).toEqual([4, 5]); + expect(latest.hasEarlier).toBe(true); + const earlier = await getTranscriptBefore(terminal.id, 4, 3); + expect(earlier.entries.map((e) => e.seq)).toEqual([1, 2, 3]); + expect(earlier.hasEarlier).toBe(false); + }); + it("refuses writes from another host and after the terminal exited", async () => { const { host, terminal } = await runningAgentTerminal(); const other = await makeHost(); diff --git a/apps/api/src/services/work-service.int.test.ts b/apps/api/src/services/work-service.int.test.ts index ce6863476..7d9b80013 100644 --- a/apps/api/src/services/work-service.int.test.ts +++ b/apps/api/src/services/work-service.int.test.ts @@ -119,7 +119,12 @@ describe("listWork", () => { expect(terminal).toBeLessThan(live); expect(rows[terminal]).toMatchObject({ status: "needs_you", - where: { target: "machine", detail: "me-mac · ~/app" }, + where: { + target: "machine", + detail: "me-mac · ~/app", + hostId: me.host.id, + hostName: "me-mac", + }, }); }); }); diff --git a/apps/api/src/services/work-service.test.ts b/apps/api/src/services/work-service.test.ts index b1eef9d42..4238a2585 100644 --- a/apps/api/src/services/work-service.test.ts +++ b/apps/api/src/services/work-service.test.ts @@ -173,7 +173,13 @@ describe("projectWork", () => { }); const t2 = rows.find((r) => r.key === "task-t2")!; - expect(t2.where).toEqual({ target: "machine", detail: "M1 · ~/app" }); + expect(t2.where).toEqual({ + target: "machine", + detail: "M1 · ~/app", + hostId: "h1", + hostName: "M1", + dir: "/Users/dev/app", + }); expect(t2).toMatchObject({ when: "on a trigger", spawned: true, then: "until-merged" }); expect(rows.find((r) => r.key === "task-t1")).toMatchObject({ note: "PR 7", @@ -250,7 +256,14 @@ describe("projectWork", () => { }), ); expect(row).toMatchObject({ - where: { target: "machine", detail: "~/app" }, + // Another person's machine goes unnamed, but its id still groups the row. + where: { + target: "machine", + detail: "~/app", + hostId: "someone-elses", + hostName: null, + dir: "/home/dev/app", + }, who: "codex", then: "exits", when: "trigger", diff --git a/apps/api/src/services/work-service.ts b/apps/api/src/services/work-service.ts index da1279954..6224bb2f6 100644 --- a/apps/api/src/services/work-service.ts +++ b/apps/api/src/services/work-service.ts @@ -215,11 +215,18 @@ function context(hosts: WorkSources["hosts"], triggers: TriggerRow[]): Context { byId.set(t.id, t); } return { - machine: (hostId, dir) => ({ - target: "machine", - detail: - [hostName.get(hostId ?? "") ?? null, shortDir(dir)].filter(Boolean).join(" · ") || null, - }), + machine: (hostId, dir) => { + // Only the caller's own machines are named; the id is kept either way + // so the Machines page can group work by the machine it runs on. + const name = hostName.get(hostId ?? "") ?? null; + return { + target: "machine", + detail: [name, shortDir(dir)].filter(Boolean).join(" · ") || null, + hostId: hostId ?? null, + hostName: name, + dir: dir ?? null, + }; + }, triggersOf: (id) => distinctTriggers(byTarget.get(id) ?? []), startedBy: (ticketSource, triggerId) => { if (ticketSource) return [{ type: "ticket", source: ticketSource }]; diff --git a/apps/ios/Optio/Core/UI/AgentLogView.swift b/apps/ios/Optio/Core/UI/AgentLogView.swift index 559a61f04..b022ca6e6 100644 --- a/apps/ios/Optio/Core/UI/AgentLogView.swift +++ b/apps/ios/Optio/Core/UI/AgentLogView.swift @@ -7,16 +7,56 @@ import SwiftUI struct AgentLogView: View { let entries: [AgentLogEntry] var autoScroll = true + /// Fold each turn's in-between work (tool calls, thinking, running + /// commentary) into one row, leaving what was said and the last reply — + /// `AgentLogFold`. + var foldSteps = false + /// The log ends before the first entry: offer to load earlier ones. + var onLoadEarlier: (() async -> Void)? + var loadingEarlier = false + /// The last steps row is the agent at work: it shows the step it's on. + var working = false /// The reader is at the end: new entries scroll into view. Once they scroll /// up to read back, a live log stops pulling them down. @State private var atBottom = true + @State private var blocks: [AgentLogBlock] = [] var body: some View { ScrollViewReader { proxy in ScrollView { LazyVStack(alignment: .leading, spacing: 10) { - ForEach(Array(entries.enumerated()), id: \.offset) { idx, entry in - AgentLogRow(entry: entry).id(idx) + if let onLoadEarlier { + Button { + Task { await onLoadEarlier() } + } label: { + HStack(spacing: 6) { + if loadingEarlier { ProgressView().controlSize(.mini) } + Text("Load earlier messages") + } + .font(.caption.weight(.medium)) + .frame(maxWidth: .infinity) + } + .buttonStyle(.bordered) + .controlSize(.small) + .disabled(loadingEarlier) + } + if foldSteps { + ForEach(blocks, id: \.id) { block in + switch block { + case .entry(let i): + AgentLogRow(entry: entries[i]).id(block.id) + case .steps(let range): + AgentLogStepsRow( + entries: entries[range], + working: working && block == blocks.last + ) + .id(block.id) + } + } + } else { + ForEach(Array(entries.enumerated()), id: \.offset) { idx, entry in + AgentLogRow(entry: entry).id(idx) + } } // iOS 17 has no scroll geometry: the end coming into view stands in. Color.clear.frame(height: 1) @@ -26,13 +66,154 @@ struct AgentLogView: View { .padding() } .modifier(TracksBottom(atBottom: $atBottom)) - .onChange(of: entries.count) { old, count in + .onChange(of: entries.count, initial: true) { old, count in + if foldSteps { blocks = AgentLogFold.blocks(entries) } // Land at the end when the log first loads; after that, follow // only a reader who is already there. guard autoScroll, count > 0, old == 0 || atBottom else { return } - withAnimation { proxy.scrollTo(count - 1, anchor: .bottom) } + let last = foldSteps ? blocks.last?.id : count - 1 + guard let last else { return } + if old == count { + proxy.scrollTo(last, anchor: .bottom) + } else { + withAnimation { proxy.scrollTo(last, anchor: .bottom) } + } + } + } + } +} + +/// One row of a folded log: an entry, or a run of steps (indices into the entries). +enum AgentLogBlock: Hashable { + case entry(Int) + case steps(Range) + + /// The first entry's index: stays put as a turn grows. + var id: Int { + switch self { + case .entry(let i): return i + case .steps(let r): return r.lowerBound + } + } +} + +/// Port of `foldTranscript()` in the web's `transcript-view.tsx`. What reads as +/// the conversation: what you (or a background task, another agent, a +/// compaction) put in, and the agent's last reply to it. Everything between — +/// tool calls, thinking, the agent's running commentary — folds into one +/// steps row, so a turn with a hundred tool calls reads as your message, one +/// folded line, and the answer. +enum AgentLogFold { + static func blocks(_ entries: [AgentLogEntry]) -> [AgentLogBlock] { + var out: [AgentLogBlock] = [] + func pushSteps(_ r: Range) { + if r.count == 1 { out.append(.entry(r.lowerBound)) } else if r.count > 1 { out.append(.steps(r)) } + } + // Per turn (what follows each opener): the steps before its last reply, + // the reply, then the steps after it — a turn still at work has those. + func flush(_ turn: Range) { + guard let reply = turn.last(where: { isReply(entries[$0]) }) else { + pushSteps(turn) + return } + pushSteps(turn.lowerBound.. Bool { + switch e.type { + case .text: return e.metadata?["role"]?.stringValue != nil + case .system: return e.metadata?["source"]?.stringValue != nil + default: return false + } + } + + static func isReply(_ e: AgentLogEntry) -> Bool { + e.type == .text && e.metadata?["role"]?.stringValue == nil + } + + /// "12 tool calls · 3 messages · thinking". + static func summary(_ entries: ArraySlice) -> String { + var tools = 0, messages = 0, thinking = 0 + for e in entries { + switch e.type { + case .toolUse, .toolResult: tools += 1 + case .thinking: thinking += 1 + default: messages += 1 + } + } + func plural(_ n: Int, _ noun: String) -> String { "\(n) \(noun)\(n == 1 ? "" : "s")" } + return [ + tools > 0 ? plural(tools, "tool call") : nil, + messages > 0 ? plural(messages, "message") : nil, + thinking > 0 ? "thinking" : nil, + ].compactMap { $0 }.joined(separator: " · ") + } + + static func failures(_ entries: ArraySlice) -> Int { + entries.filter { $0.metadata?["resultIsError"]?.boolValue == true || $0.type == .error }.count + } +} + +/// A turn's in-between work folded to one line — what it holds, and while the +/// agent is at it the step it's on. Open, every step reads as its own row. +struct AgentLogStepsRow: View { + let entries: ArraySlice + var working = false + @State private var expanded = false + + var body: some View { + VStack(alignment: .leading, spacing: 10) { + Button { + withAnimation(.snappy) { expanded.toggle() } + } label: { + HStack(spacing: 6) { + Image(systemName: expanded ? "chevron.down" : "chevron.right").font(.caption2) + Image(systemName: "wrench.and.screwdriver").font(.caption2) + Text(AgentLogFold.summary(entries)).font(.caption) + let failed = AgentLogFold.failures(entries) + if failed > 0 { + Text("· \(failed) failed").font(.caption).foregroundStyle(Tone.danger.textStyle) + } + if working, !expanded, let current { + Text("· \(current)") + .font(.caption2.monospaced()) + .foregroundStyle(.tertiary) + .lineLimit(1) + } + Spacer(minLength: 0) + } + .foregroundStyle(.secondary) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .accessibilityLabel("\(AgentLogFold.summary(entries)), \(expanded ? "expanded" : "collapsed")") + if expanded { + ForEach(entries.indices, id: \.self) { i in + AgentLogRow(entry: entries[i]) + } + .padding(.leading, 10) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + } + + /// The last step, while working: "Shell · npm test". + private var current: String? { + guard let last = entries.last, last.type == .toolUse else { return nil } + let name = last.metadata?["toolName"]?.stringValue ?? "tool" + let summary = (last.metadata?["summary"]?.stringValue ?? "").replacingOccurrences(of: "\n", with: " ") + return summary.isEmpty ? name : "\(name) · \(summary.prefix(60))" } } diff --git a/apps/ios/Optio/Features/Library/Machines/MachinesView.swift b/apps/ios/Optio/Features/Library/Machines/MachinesView.swift index e18967c93..39cd01501 100644 --- a/apps/ios/Optio/Features/Library/Machines/MachinesView.swift +++ b/apps/ios/Optio/Features/Library/Machines/MachinesView.swift @@ -1,11 +1,13 @@ import SwiftUI -/// Paired Optio Local hosts (`GET /api/local/hosts`): name, online state, -/// platform / arch / daemon version, last seen, and the directory allowlist -/// with detected checkouts marked. Sessions that run on a machine are in the -/// Sessions list; this is the machine itself — plus the Local Automations that -/// fire on one of its directories (per-machine configuration, like the web's -/// `/machines#automations`). +/// Where work runs (the web's `/machines`). Each paired Optio Local host — +/// online first — with the work on it: what's live there now and what's set up +/// to run there (automations, Jobs and scheduled Tasks pointed at it), then, as +/// the machine's setup, its directory allowlist. Then the Optio pods: the +/// cluster as the other place work runs, grouped by repo, Jobs and persistent +/// agents. Rows come from the Work feed (`WorkFeedModel`), grouped by +/// `where.hostId` in `WorkPlaces`; tapping one opens the same detail screen as +/// on the Work tab. Local Automations are still created from the link below. struct MachinesView: View { @Environment(APIClient.self) private var api @State private var hosts: [LocalHost] = [] @@ -13,6 +15,7 @@ struct MachinesView: View { @State private var error: Error? @State private var actionError: String? @State private var pendingForget: LocalHost? + @State private var feed: WorkFeedModel? private var sorted: [LocalHost] { hosts.sorted { a, b in @@ -21,7 +24,14 @@ struct MachinesView: View { } } + private var places: WorkPlaces { + WorkPlaces(rows: feed?.rows ?? [], hostIds: hosts.map(\.id)) + } + + private var feedLoading: Bool { feed?.loading ?? true } + var body: some View { + let places = places List { if let error { ErrorRow(error: error, what: "machines") { Task { await refresh() } } @@ -36,15 +46,31 @@ struct MachinesView: View { } else { ForEach(sorted, id: \.id) { host in Section { - MachineCard(host: host) - .listRowInsets(EdgeInsets()) - .listRowBackground(Color.clear) - .listRowSeparator(.hidden) - .contextMenu { - Button(role: .destructive) { pendingForget = host } label: { Label("Forget machine", systemImage: "trash") } - } + placeRows(places.machines[host.id] ?? PlaceWork(), + whereLabel: { WorkFeed.shortDir($0.where.dir) }, + empty: "Nothing running here, and nothing set up to run here.") + MachineDirectories(host: host) + } header: { + MachineHeader(host: host, summary: places.machines[host.id]?.nowSummary) { + pendingForget = host + } + .textCase(nil) } } + if !places.otherMachines.isEmpty { + Section { + placeRows(places.otherMachines, empty: "") + } header: { + SectionHeader(title: "Other machines", detail: "a teammate's, or removed").textCase(nil) + } + } + } + + if loaded { + podSections(places.pods) + } + + if loaded, !hosts.isEmpty { Section { NavigationLink(value: MachinesRoute.automations) { Label("Automations", systemImage: "square.stack.3d.up") @@ -55,6 +81,7 @@ struct MachinesView: View { } } .listStyle(.plain) + .workDestinations() .navigationDestination(for: MachinesRoute.self) { route in switch route { case .automations: LocalBlueprintsView(hosts: hosts) @@ -67,13 +94,20 @@ struct MachinesView: View { } } .task { + if feed == nil { feed = WorkFeedModel(api: api) } + feed?.start() await refresh() while !Task.isCancelled { try? await Task.sleep(for: .seconds(30)) await refresh() } } - .refreshable { await refresh() } + .onDisappear { feed?.stop() } + .refreshable { + async let machines: Void = refresh() + async let work: Void? = feed?.refresh() + _ = await (machines, work) + } .confirmationDialog("Forget “\(pendingForget?.name ?? "")”?", isPresented: Binding(get: { pendingForget != nil }, set: { if !$0 { pendingForget = nil } }), titleVisibility: .visible) { Button("Forget", role: .destructive) { if let h = pendingForget { Task { await forget(h) } } @@ -85,6 +119,72 @@ struct MachinesView: View { .errorToast($actionError) } + /// The cluster: one section per repo / Jobs / agents, after a heading row. + @ViewBuilder + private func podSections(_ groups: [PodGroup]) -> some View { + Section { + if groups.isEmpty { + Text(feedLoading ? "Loading…" : "Nothing running in Optio pods, and nothing set up to.") + .font(.footnote).foregroundStyle(.tertiary) + } + } header: { + VStack(alignment: .leading, spacing: 2) { + Label("Optio pods", systemImage: "server.rack").font(.headline).foregroundStyle(.primary) + Text("Work that runs in the cluster, with the workspace's secrets and connections.") + .font(.footnote).foregroundStyle(.secondary) + } + .textCase(nil) + .padding(.top, Spacing.m) + } + ForEach(groups) { group in + Section { + placeRows(group.work, + whereLabel: group.kind == .repo ? { _ in "Optio pod" } : nil, + setUpTitle: group.kind == .agents ? "Standing by" : "Set up to run", + empty: "") + } header: { + HStack(spacing: Spacing.xs) { + Image(systemName: group.systemImage).font(.caption).foregroundStyle(.secondary) + Text(group.label) + .font(group.kind == .repo ? .footnote.monospaced().weight(.semibold) : .footnote.weight(.semibold)) + .foregroundStyle(Color(.secondaryLabel)) + .lineLimit(1).truncationMode(.head) + Spacer(minLength: Spacing.s) + if let summary = group.work.nowSummary { + Text(summary).font(.footnote).foregroundStyle(Color(.tertiaryLabel)) + } + } + .textCase(nil) + } + } + } + + /// A place's rows: "Now", then what is set up to run there; a quiet line when empty. + @ViewBuilder + private func placeRows(_ work: PlaceWork, + whereLabel: ((WorkRow) -> String?)? = nil, + setUpTitle: String = "Set up to run here", + empty: String) -> some View { + if work.isEmpty { + if !empty.isEmpty { + Text(feedLoading ? "Loading…" : empty).font(.footnote).foregroundStyle(.tertiary) + } + } else { + if !work.now.isEmpty { + SubHeading(title: "Now") + ForEach(work.now) { row in + NavigationLink(value: row.destination) { WorkRowView(row: row, whereLabel: whereLabel?(row)) } + } + } + if !work.setUp.isEmpty { + SubHeading(title: setUpTitle) + ForEach(work.setUp) { row in + NavigationLink(value: row.destination) { WorkRowView(row: row, whereLabel: whereLabel?(row)) } + } + } + } + } + private func refresh() async { do { hosts = try await api.listLocalHosts() @@ -110,9 +210,25 @@ enum MachinesRoute: Hashable { case automations } -/// One machine: identity line, facts, then its directories. -private struct MachineCard: View { +/// "Now" / "Set up to run here" inside a place's section. +private struct SubHeading: View { + let title: String + + var body: some View { + Text(title.uppercased()) + .font(.caption2.weight(.semibold)) + .foregroundStyle(.tertiary) + .listRowSeparator(.hidden) + .padding(.top, Spacing.xs) + } +} + +/// A machine's section header: who it is, whether it's connected, its facts, +/// what's live on it, and a menu to forget it. +private struct MachineHeader: View { let host: LocalHost + let summary: String? + let onForget: () -> Void private var online: Bool { host.state == .online } @@ -123,56 +239,75 @@ private struct MachineCard: View { return parts.filter { !$0.isEmpty }.joined(separator: " · ") } - private var seen: String? { + private var seen: String { if online { return "online" } if let seen = host.lastSeenAt { return "seen \(seen.relativeDescription)" } return "offline" } var body: some View { - VStack(alignment: .leading, spacing: Spacing.s) { + VStack(alignment: .leading, spacing: 2) { HStack(alignment: .firstTextBaseline, spacing: Spacing.s) { - StateDot(tone: online ? .success : .idle).padding(.top, 2) - Text(host.name).font(.body.weight(.semibold)).lineLimit(1) + StateDot(tone: online ? .success : .idle) + Text(host.name).font(.headline).foregroundStyle(.primary).lineLimit(1) + Text(seen) + .font(.footnote) + .foregroundStyle(online ? Tone.success.textStyle : AnyShapeStyle(Color(.tertiaryLabel))) + .lineLimit(1) Spacer(minLength: Spacing.s) - if let seen { - Text(seen) - .font(.footnote) - .foregroundStyle(online ? Tone.success.textStyle : AnyShapeStyle(.tertiary)) - .lineLimit(1) + Menu { + Button(role: .destructive, action: onForget) { Label("Forget machine", systemImage: "trash") } + } label: { + Image(systemName: "ellipsis.circle").foregroundStyle(.secondary) } + .accessibilityLabel("\(host.name) options") } - Text(facts).font(.footnote).foregroundStyle(.secondary).lineLimit(1).truncationMode(.middle) + HStack(spacing: Spacing.s) { + Text(facts).lineLimit(1).truncationMode(.middle) + if let summary { + Spacer(minLength: Spacing.s) + Text(summary).foregroundStyle(Color(.secondaryLabel)).lineLimit(1) + } + } + .font(.footnote) + .foregroundStyle(Color(.tertiaryLabel)) + } + .padding(.top, Spacing.m) + } +} +/// The machine's setup: its directory allowlist, folded away. +private struct MachineDirectories: View { + let host: LocalHost + @State private var open = false + + var body: some View { + DisclosureGroup(isExpanded: $open) { if host.dirs.isEmpty { Text("No directories exposed — `optio local add ` on the machine.") .font(.footnote).foregroundStyle(.tertiary) } else { - VStack(alignment: .leading, spacing: 4) { - ForEach(host.dirs, id: \.path) { dir in - HStack(spacing: 6) { - Image(systemName: dir.repoUrl == nil ? "folder" : "arrow.triangle.branch") - .font(.caption) - .foregroundStyle(dir.repoUrl == nil ? AnyShapeStyle(.tertiary) : AnyShapeStyle(AppTheme.accent)) - .frame(width: 14) - Text(WorkFeed.shortDir(dir.path) ?? dir.path) - .font(.caption.monospaced()) - .lineLimit(1) - .truncationMode(.head) - if let repo = WorkFeed.shortRepo(dir.repoUrl) { - Spacer(minLength: Spacing.s) - Text(repo).font(.caption2).foregroundStyle(.secondary).lineLimit(1).truncationMode(.head) - } + ForEach(host.dirs, id: \.path) { dir in + HStack(spacing: 6) { + Image(systemName: dir.repoUrl == nil ? "folder" : "arrow.triangle.branch") + .font(.caption) + .foregroundStyle(dir.repoUrl == nil ? AnyShapeStyle(.tertiary) : AnyShapeStyle(AppTheme.accent)) + .frame(width: 14) + Text(WorkFeed.shortDir(dir.path) ?? dir.path) + .font(.caption.monospaced()) + .lineLimit(1) + .truncationMode(.head) + if let repo = WorkFeed.shortRepo(dir.repoUrl) { + Spacer(minLength: Spacing.s) + Text(repo).font(.caption2).foregroundStyle(.secondary).lineLimit(1).truncationMode(.head) } } } - .padding(.top, 2) } + } label: { + Label("Directories (\(host.dirs.count))", systemImage: "folder") + .font(.footnote) + .foregroundStyle(.secondary) } - .padding(Spacing.m) - .frame(maxWidth: .infinity, alignment: .leading) - .background(Surface.card, in: Radius.cardShape) - .padding(.horizontal, Spacing.l) - .padding(.vertical, Spacing.xs) } } diff --git a/apps/ios/Optio/Features/Library/Machines/WorkPlaces.swift b/apps/ios/Optio/Features/Library/Machines/WorkPlaces.swift new file mode 100644 index 000000000..21a0b1854 --- /dev/null +++ b/apps/ios/Optio/Features/Library/Machines/WorkPlaces.swift @@ -0,0 +1,116 @@ +import Foundation + +// Where work runs, for the Machines screen: the Work feed sorted into each +// paired machine (by `where.hostId`) and the Optio pods (grouped by repo, Jobs, +// and persistent agents). A port of the web's `apps/web/src/lib/work-places.ts`; +// pure, so `OptioTests/WorkPlacesTests.swift` mirrors its tests. + +/// The work at one place: what is live now, and what is set up to run there. +struct PlaceWork: Hashable, Sendable { + /// Running, queued, waiting, or needing you — needs-you first. + var now: [WorkRow] = [] + /// Recurring definitions (automations, Jobs, scheduled Tasks) and standing agents. + var setUp: [WorkRow] = [] + + var isEmpty: Bool { now.isEmpty && setUp.isEmpty } + + /// "1 needs you · 2 active", or nil when nothing is live. + var nowSummary: String? { + let needsYou = now.filter { $0.status == .needsYou }.count + let live = now.count - needsYou + let parts = [ + needsYou > 0 ? "\(needsYou) need\(needsYou == 1 ? "s" : "") you" : nil, + live > 0 ? "\(live) active" : nil, + ].compactMap { $0 } + return parts.isEmpty ? nil : parts.joined(separator: " · ") + } +} + +struct PodGroup: Hashable, Sendable, Identifiable { + enum Kind: Int, Hashable, Sendable { case repo = 0, jobs, agents, other } + /// `repo:`, `jobs`, `agents`, or `other`. + let id: String + let kind: Kind + /// "acme/app", "Jobs", "Persistent agents", "Other". + let label: String + var work = PlaceWork() + + var systemImage: String { + switch kind { + case .repo: return "arrow.triangle.branch" + case .jobs: return "briefcase" + case .agents: return "cpu" + case .other: return "server.rack" + } + } +} + +struct WorkPlaces: Hashable, Sendable { + /// Per paired machine id; every id passed in has an entry, empty or not. + var machines: [String: PlaceWork] = [:] + /// Machine work whose machine isn't one of the caller's (or has none set). + var otherMachines = PlaceWork() + /// Repos first (busiest first, then by name), then Jobs, agents, other. + var pods: [PodGroup] = [] + + init(rows: [WorkRow], hostIds: [String]) { + for id in hostIds { machines[id] = PlaceWork() } + var groups: [String: PodGroup] = [:] + + for row in WorkFeed.sort(rows) { + guard let half = Self.half(of: row) else { continue } + if row.where.target == .machine { + if let id = row.where.hostId, machines[id] != nil { + Self.append(row, to: &machines[id]!, half) + } else { + Self.append(row, to: &otherMachines, half) + } + continue + } + let g = Self.group(of: row) + var group = groups[g.id] ?? g + Self.append(row, to: &group.work, half) + groups[g.id] = group + } + + for id in machines.keys { machines[id]!.now = Self.needsYouFirst(machines[id]!.now) } + otherMachines.now = Self.needsYouFirst(otherMachines.now) + pods = groups.values + .map { var g = $0; g.work.now = Self.needsYouFirst(g.work.now); return g } + .sorted { a, b in + if a.kind != b.kind { return a.kind.rawValue < b.kind.rawValue } + if a.work.now.count != b.work.now.count { return a.work.now.count > b.work.now.count } + return a.label.localizedCaseInsensitiveCompare(b.label) == .orderedAscending + } + } + + private enum Half { case now, setUp } + + /// Which half a row belongs in, or nil when it is history: a persistent agent + /// that isn't archived is standing work even while idle. + private static func half(of row: WorkRow) -> Half? { + if WorkFeed.inView(row, .active) { return .now } + if row.recurring { return .setUp } + if row.source == .persistentAgent, row.status != .done { return .setUp } + return nil + } + + private static func append(_ row: WorkRow, to place: inout PlaceWork, _ half: Half) { + switch half { + case .now: place.now.append(row) + case .setUp: place.setUp.append(row) + } + } + + private static func group(of row: WorkRow) -> PodGroup { + if row.source == .persistentAgent { return PodGroup(id: "agents", kind: .agents, label: "Persistent agents") } + if row.source == .standalone { return PodGroup(id: "jobs", kind: .jobs, label: "Jobs") } + // Repo Tasks, scheduled Tasks and pod sessions are named by their repo. + if let repo = row.where.detail, !repo.isEmpty { return PodGroup(id: "repo:\(repo)", kind: .repo, label: repo) } + return PodGroup(id: "other", kind: .other, label: "Other") + } + + private static func needsYouFirst(_ rows: [WorkRow]) -> [WorkRow] { + rows.filter { $0.status == .needsYou } + rows.filter { $0.status != .needsYou } + } +} diff --git a/apps/ios/Optio/Features/Live/Local/LocalAPI.swift b/apps/ios/Optio/Features/Live/Local/LocalAPI.swift index a8e589aa8..3ecdf3b5e 100644 --- a/apps/ios/Optio/Features/Live/Local/LocalAPI.swift +++ b/apps/ios/Optio/Features/Live/Local/LocalAPI.swift @@ -63,6 +63,8 @@ struct LocalTranscriptPage: Decodable { /// The session's machine is reading its conversation off disk (a finished /// session whose transcript was never streamed): read again shortly. let backfilling: Bool? + /// With `before`: entries precede the first one returned. + let hasEarlier: Bool? } /// Shared by create (POST) and update (PATCH). Nil fields are omitted, matching @@ -169,11 +171,16 @@ extension APIClient { /// The conversation of an agent session (prompts, replies, tool calls), /// distilled by the daemon from the agent CLI's own transcript. `after` - /// fetches only entries past a seq. - func getLocalTerminalTranscript(_ id: String, after: Int = 0, limit: Int = 2000) async throws -> LocalTranscriptPage { + /// fetches only entries past a seq; `before` instead fetches the last + /// `limit` entries before one (the page's `hasEarlier` says if more precede). + func getLocalTerminalTranscript(_ id: String, after: Int = 0, before: Int? = nil, limit: Int = 2000) async throws -> LocalTranscriptPage { try await get( "/api/local/terminals/\(id)/transcript", - query: ["after": after > 0 ? String(after) : nil, "limit": String(limit)], + query: [ + "after": after > 0 ? String(after) : nil, + "before": before.map(String.init), + "limit": String(limit), + ], as: LocalTranscriptPage.self ) } diff --git a/apps/ios/Optio/Features/Live/Local/LocalTerminalScreen.swift b/apps/ios/Optio/Features/Live/Local/LocalTerminalScreen.swift index 48b721556..b8b539778 100644 --- a/apps/ios/Optio/Features/Live/Local/LocalTerminalScreen.swift +++ b/apps/ios/Optio/Features/Live/Local/LocalTerminalScreen.swift @@ -171,6 +171,7 @@ struct LocalTerminalScreen: View { LocalTranscriptFace( terminalId: terminalId, entries: transcript?.entries ?? [], + transcript: transcript, live: !LocalPresentation.isDead(terminal), canSend: terminal.state == .running, working: terminal.attentionState == .working, @@ -363,6 +364,8 @@ struct SessionViewToggle: View { struct LocalTranscriptFace: View { let terminalId: String let entries: [LocalTranscriptEntry] + /// Paging and load errors (nil in previews: `entries` is everything). + var transcript: LocalTranscriptModel? let live: Bool let canSend: Bool let working: Bool @@ -373,7 +376,16 @@ struct LocalTranscriptFace: View { var body: some View { VStack(spacing: 0) { - if entries.isEmpty { + if entries.isEmpty, let error = transcript?.loadError { + ContentUnavailableView { + Label("Couldn't load the conversation", systemImage: "exclamationmark.bubble") + } description: { + Text(error) + } actions: { + Button("Try again") { transcript?.retry() } + } + .frame(maxHeight: .infinity) + } else if entries.isEmpty { ContentUnavailableView { Label(live ? "Nothing yet" : "No conversation recorded", systemImage: "text.bubble") } description: { @@ -381,8 +393,14 @@ struct LocalTranscriptFace: View { } .frame(maxHeight: .infinity) } else { - AgentLogView(entries: log) - .frame(maxHeight: .infinity) + AgentLogView( + entries: log, + foldSteps: true, + onLoadEarlier: transcript?.hasEarlier == true ? { await transcript?.loadEarlier() } : nil, + loadingEarlier: transcript?.loadingEarlier ?? false, + working: live && working + ) + .frame(maxHeight: .infinity) } if live, !entries.isEmpty { HStack(spacing: 6) { @@ -395,7 +413,7 @@ struct LocalTranscriptFace: View { } if canSend { if working { - Text("Claude is working — sending will queue your message") + Text("The agent is working — sending will queue your message") .font(.caption2) .foregroundStyle(.secondary) .frame(maxWidth: .infinity, alignment: .leading) diff --git a/apps/ios/Optio/Features/Live/Local/LocalTranscript.swift b/apps/ios/Optio/Features/Live/Local/LocalTranscript.swift index dda84b6e3..d36f03ca5 100644 --- a/apps/ios/Optio/Features/Live/Local/LocalTranscript.swift +++ b/apps/ios/Optio/Features/Live/Local/LocalTranscript.swift @@ -1,11 +1,13 @@ import Foundation import Observation -/// A terminal's stored conversation: everything at start (paged), then — while -/// `live` — only the entries past the last seq every few seconds. Port of the -/// web's `use-transcript.ts`. `loaded` flips once the first fetch settles, so -/// the screen can decide its default face (transcript vs. screen) without a -/// flash of the wrong one. +/// A terminal's stored conversation: the latest page at start, earlier pages +/// on request (`loadEarlier`), then — while `live` — only the entries past +/// the last seq every few seconds. A long Codex or Claude session can hold +/// thousands of tool outputs; fetching them all before showing anything +/// timed out on a phone. `loaded` flips once the first fetch settles, so the +/// screen can decide its default face (transcript vs. screen) without a flash +/// of the wrong one. /// /// A finished session whose conversation was never streamed is read off its /// machine on that first fetch (the server answers `backfilling`): the model @@ -17,11 +19,21 @@ final class LocalTranscriptModel { private(set) var entries: [LocalTranscriptEntry] = [] private(set) var loaded = false private(set) var backfilling = false + /// Entries precede the first one held (`loadEarlier` fetches them). + private(set) var hasEarlier = false + private(set) var loadingEarlier = false + /// Why the conversation couldn't be fetched, while nothing is showing. + private(set) var loadError: String? private static let livePoll: Duration = .seconds(4) private static let backfillPoll: Duration = .seconds(1) private static let backfillPolls = 12 - private static let page = 2000 + /// The first page: enough for the last few exchanges, small enough for a phone. + private static let latestPage = 300 + private static let earlierPage = 300 + private static let livePage = 500 + /// Past any seq the server stores, for the latest page. + private static let pastEnd = Int(Int32.max) private let api: APIClient private let terminalId: String @@ -41,35 +53,44 @@ final class LocalTranscriptModel { /// The machine is still reading the conversation and nothing has landed yet. var readingConversation: Bool { backfilling && entries.isEmpty } - /// Fetch everything stored, then keep polling while `live`. + /// Fetch the latest page, then keep polling while `live`. func start(live: Bool) { self.live = live loadTask?.cancel() loadTask = Task { [weak self] in guard let self else { return } - var all: [LocalTranscriptEntry] = [] - var backfill = false do { - var after = 0 - while !Task.isCancelled { - let page = try await api.getLocalTerminalTranscript(terminalId, after: after, limit: Self.page) - all.append(contentsOf: page.entries) - backfill = page.backfilling ?? false - if page.complete || page.entries.isEmpty { break } - after = Int(page.entries[page.entries.count - 1].seq) - } + let page = try await api.getLocalTerminalTranscript(terminalId, before: Self.pastEnd, limit: Self.latestPage) + if Task.isCancelled { return } + entries = page.entries + hasEarlier = page.hasEarlier ?? false + backfilling = (page.backfilling ?? false) && page.entries.isEmpty + lastSeq = page.entries.last.map { Int($0.seq) } ?? 0 + loadError = nil } catch { - // No transcript (older row, non-agent session) — the screen view stands in. + if Task.isCancelled { return } + // Not a dead end: the poll below keeps trying, and the face says why it's empty. + loadError = error.localizedDescription } - if Task.isCancelled { return } - lastSeq = all.last.map { Int($0.seq) } ?? 0 - entries = all - backfilling = backfill && all.isEmpty loaded = true restartPolling() } } + /// Fetch the page before the first entry held. + func loadEarlier() async { + guard hasEarlier, !loadingEarlier, let first = entries.first else { return } + loadingEarlier = true + defer { loadingEarlier = false } + do { + let page = try await api.getLocalTerminalTranscript(terminalId, before: Int(first.seq), limit: Self.earlierPage) + entries.insert(contentsOf: page.entries, at: 0) + hasEarlier = page.hasEarlier ?? false + } catch { + // The button stays; tapping again retries. + } + } + /// The session ended (or came back): stop or start the live poll. A session /// that just ended gets one extra fetch — the daemon flushes the final turn /// right before `exit`, after the last poll may have run. @@ -79,6 +100,12 @@ final class LocalTranscriptModel { if loaded { restartPolling() } } + /// Try the first fetch again (after `loadError`). + func retry() { + loadError = nil + start(live: live) + } + func stop() { loadTask?.cancel() pollTask?.cancel() @@ -101,10 +128,11 @@ final class LocalTranscriptModel { } return } - if !live { + if !live && loadError == nil { pollTask = Task { [weak self] in await self?.fetchMore() } return } + // Live, or the first fetch failed: keep asking. pollTask = Task { [weak self] in while !Task.isCancelled { try? await Task.sleep(for: Self.livePoll) @@ -114,25 +142,36 @@ final class LocalTranscriptModel { } } - /// Entries past the last seq. + /// Entries past the last seq — or, while nothing is held yet (the first + /// fetch failed, or a backfill is landing), the latest page. private func fetchMore() async { guard !inflight else { return } inflight = true defer { inflight = false } do { - let page = try await api.getLocalTerminalTranscript(terminalId, after: lastSeq, limit: Self.page) + let fromLatest = entries.isEmpty + let page = fromLatest + ? try await api.getLocalTerminalTranscript(terminalId, before: Self.pastEnd, limit: Self.latestPage) + : try await api.getLocalTerminalTranscript(terminalId, after: lastSeq, limit: Self.livePage) + let recovered = loadError != nil + loadError = nil guard !page.entries.isEmpty else { // Nothing new and nothing more being read: a backfill is over. if backfilling && !(page.backfilling ?? false) { backfilling = false restartPolling() + } else if recovered { + restartPolling() } return } + if fromLatest { hasEarlier = page.hasEarlier ?? false } lastSeq = Int(page.entries[page.entries.count - 1].seq) entries.append(contentsOf: page.entries) + if recovered { restartPolling() } } catch { - // transient — the next tick retries + // Transient once something is showing — the next tick retries. + if entries.isEmpty { loadError = error.localizedDescription } } } } diff --git a/apps/ios/Optio/Features/Work/Feed/WorkFeed.swift b/apps/ios/Optio/Features/Work/Feed/WorkFeed.swift index df22bd705..e73619429 100644 --- a/apps/ios/Optio/Features/Work/Feed/WorkFeed.swift +++ b/apps/ios/Optio/Features/Work/Feed/WorkFeed.swift @@ -104,6 +104,11 @@ struct SessionWhere: Hashable, Sendable { enum Target: String, Hashable, Sendable { case pod, machine } let target: Target let detail: String? + /// On a machine: the `local_hosts` id it runs on, so the Machines screen can + /// list each machine's work under it (`where.hostId` on the web). + var hostId: String? = nil + /// On a machine: the directory it runs in, as stored. + var dir: String? = nil /// Chip copy: the detail, or the generic place. var label: String { detail ?? (target == .pod ? "Optio pod" : "machine") } @@ -460,7 +465,8 @@ enum WorkFeed { for h in src.hosts { if let id = h.id, let name = h.name { hostName[id] = name } } func machine(_ hostId: String?, _ dir: String?) -> SessionWhere { let parts = [hostName[hostId ?? ""], shortDir(dir)].compactMap { $0 } - return SessionWhere(target: .machine, detail: parts.isEmpty ? nil : parts.joined(separator: " · ")) + return SessionWhere(target: .machine, detail: parts.isEmpty ? nil : parts.joined(separator: " · "), + hostId: hostId, dir: dir) } var rows: [WorkRow] = [] diff --git a/apps/ios/Optio/Features/Work/Feed/WorkRowView.swift b/apps/ios/Optio/Features/Work/Feed/WorkRowView.swift index bf4d58fa3..729ab3d3b 100644 --- a/apps/ios/Optio/Features/Work/Feed/WorkRowView.swift +++ b/apps/ios/Optio/Features/Work/Feed/WorkRowView.swift @@ -5,6 +5,10 @@ import SwiftUI /// the Sessions list and the Overview board (`session-row.tsx`). struct WorkRowView: View { let row: WorkRow + /// Replaces the Where chip's text where the place is already said around the + /// row (the Machines screen lists a machine's work under it, so its rows + /// name only the directory). + var whereLabel: String? = nil var body: some View { HStack(alignment: .top, spacing: Spacing.s) { @@ -39,7 +43,7 @@ struct WorkRowView: View { .font(.subheadline) LazyVGrid(columns: [GridItem(.flexible(), alignment: .leading), GridItem(.flexible(), alignment: .leading)], alignment: .leading, spacing: 2) { attr(row.whenGlyph, row.when, a11y: row.origin?.label) - attr(.symbol(row.where.systemImage), row.where.label, mono: true) + attr(.symbol(row.where.systemImage), whereLabel ?? row.where.label, mono: true) attr(row.whoGlyph, row.whoLabel) attr(.symbol(row.then.systemImage), row.then.label) } diff --git a/apps/ios/OptioTests/AgentLogFoldTests.swift b/apps/ios/OptioTests/AgentLogFoldTests.swift new file mode 100644 index 000000000..f9dbe9b01 --- /dev/null +++ b/apps/ios/OptioTests/AgentLogFoldTests.swift @@ -0,0 +1,53 @@ +import XCTest +@testable import Optio + +/// Mirrors `foldTranscript` in `apps/web/src/components/local/transcript-view.test.ts`. +final class AgentLogFoldTests: XCTestCase { + private func entry(_ type: AgentLogEntry.TypeValue, role: String? = nil, source: String? = nil, error: Bool = false) -> AgentLogEntry { + var meta: [String: AnyCodable] = [:] + if let role { meta["role"] = .string(role) } + if let source { meta["source"] = .string(source) } + if error { meta["resultIsError"] = .bool(true) } + return AgentLogEntry(taskId: "t", timestamp: "", type: type, content: "", metadata: meta.isEmpty ? nil : meta) + } + + private func shape(_ entries: [AgentLogEntry]) -> [String] { + AgentLogFold.blocks(entries).map { + switch $0 { + case .entry(let i): return "e\(i)" + case .steps(let r): return "s\(r.lowerBound)-\(r.upperBound - 1)" + } + } + } + + func testKeepsOpenerAndLastReplyFoldingTheRest() { + let log = [ + entry(.text, role: "user"), + entry(.thinking), + entry(.toolUse), + entry(.text), + entry(.toolUse), + entry(.text), + entry(.text, role: "user"), + entry(.text), + ] + XCTAssertEqual(shape(log), ["e0", "s1-4", "e5", "e6", "e7"]) + } + + func testFoldsWorkAfterTheLastReplyAndLeavesALoneStep() { + XCTAssertEqual(shape([entry(.text, role: "user"), entry(.text), entry(.toolUse), entry(.toolUse)]), + ["e0", "e1", "s2-3"]) + XCTAssertEqual(shape([entry(.text, role: "user"), entry(.toolUse), entry(.text)]), ["e0", "e1", "e2"]) + } + + func testSystemTurnsOpenATurn() { + XCTAssertEqual(shape([entry(.system, source: "task"), entry(.toolUse), entry(.toolUse), entry(.text)]), + ["e0", "s1-2", "e3"]) + } + + func testSummarizesSteps() { + let steps = [entry(.toolUse, error: true), entry(.toolUse), entry(.text), entry(.thinking)] + XCTAssertEqual(AgentLogFold.summary(steps[...]), "2 tool calls · 1 message · thinking") + XCTAssertEqual(AgentLogFold.failures(steps[...]), 1) + } +} diff --git a/apps/ios/OptioTests/WorkFeedTests.swift b/apps/ios/OptioTests/WorkFeedTests.swift index c68a0c4f2..60b22623f 100644 --- a/apps/ios/OptioTests/WorkFeedTests.swift +++ b/apps/ios/OptioTests/WorkFeedTests.swift @@ -46,7 +46,7 @@ final class WorkFeedTests: XCTestCase { XCTAssertEqual(rows.prefix(2).map(\.status), [.needsYou, .needsYou]) XCTAssertEqual(rows.first?.key, "terminal-lt1", "most recent needs-you first") - XCTAssertEqual(row("task-t2")?.where, SessionWhere(target: .machine, detail: "M1 · ~/app")) + XCTAssertEqual(row("task-t2")?.where, SessionWhere(target: .machine, detail: "M1 · ~/app", hostId: "h1", dir: "/Users/dev/app")) XCTAssertEqual(row("task-t1")?.note, "PR 7") XCTAssertEqual(row("task-t1")?.href, "/tasks/t1") XCTAssertEqual(row("job-j1")?.status, .paused) diff --git a/apps/ios/OptioTests/WorkPlacesTests.swift b/apps/ios/OptioTests/WorkPlacesTests.swift new file mode 100644 index 000000000..612667c93 --- /dev/null +++ b/apps/ios/OptioTests/WorkPlacesTests.swift @@ -0,0 +1,55 @@ +import XCTest +@testable import Optio + +/// Mirrors `apps/web/src/lib/work-places.test.ts`. +final class WorkPlacesTests: XCTestCase { + private func row(_ key: String, _ source: WorkSource, _ status: WorkStatus, + _ where: SessionWhere, recurring: Bool = false) -> WorkRow { + WorkRow(key: key, source: source, sourceId: key, href: "/x/\(key)", name: key, when: "now", + where: `where`, who: "claude-code", then: .exits, status: status, statusLabel: "", + note: nil, prUrl: nil, lastActivity: "2026-09-24T10:00:00Z", + recurring: recurring, spawned: false) + } + + private func machine(_ hostId: String?) -> SessionWhere { + SessionWhere(target: .machine, detail: "M · ~/app", hostId: hostId, dir: "/Users/dev/app") + } + + private func pod(_ detail: String?) -> SessionWhere { SessionWhere(target: .pod, detail: detail) } + + private var places: WorkPlaces { + WorkPlaces(rows: [ + row("terminal", .localTerminal, .needsYou, machine("h1")), + row("local-task", .repoTask, .running, machine("h1")), + row("automation", .localBlueprint, .scheduled, machine("h1"), recurring: true), + row("old-terminal", .localTerminal, .done, machine("h1")), + row("teammate", .repoTask, .running, machine("h-other")), + row("task-a", .repoTask, .running, pod("acme/a")), + row("blueprint-a", .repoBlueprint, .scheduled, pod("acme/a"), recurring: true), + row("session-b", .podSession, .waiting, pod("acme/b")), + row("task-b", .repoTask, .waiting, pod("acme/b")), + row("job", .standalone, .scheduled, pod(nil), recurring: true), + row("agent-idle", .persistentAgent, .waiting, pod("@forge")), + row("agent-paused", .persistentAgent, .paused, pod("@old")), + row("agent-archived", .persistentAgent, .done, pod("@gone")), + ], hostIds: ["h1", "h2"]) + } + + func testEachMachineHoldsItsLiveAndRecurringWork() { + let p = places + XCTAssertEqual(p.machines["h1"]?.now.map(\.key), ["terminal", "local-task"]) + XCTAssertEqual(p.machines["h1"]?.setUp.map(\.key), ["automation"]) + XCTAssertEqual(p.machines["h2"], PlaceWork()) + XCTAssertEqual(p.otherMachines.now.map(\.key), ["teammate"]) + XCTAssertEqual(p.machines["h1"]?.nowSummary, "1 needs you · 1 active") + } + + func testPodWorkGroupsByRepoThenJobsThenAgents() { + let p = places + XCTAssertEqual(p.pods.map(\.label), ["acme/b", "acme/a", "Jobs", "Persistent agents"]) + XCTAssertEqual(p.pods[1].work.setUp.map(\.key), ["blueprint-a"]) + XCTAssertEqual(p.pods[2].work.setUp.map(\.key), ["job"]) + XCTAssertEqual(p.pods[3].work.now.map(\.key), ["agent-idle"]) + XCTAssertEqual(p.pods[3].work.setUp.map(\.key), ["agent-paused"]) + } +} diff --git a/apps/web/e2e/machines.spec.ts b/apps/web/e2e/machines.spec.ts index ff0b411a6..b061c3d96 100644 --- a/apps/web/e2e/machines.spec.ts +++ b/apps/web/e2e/machines.spec.ts @@ -1,17 +1,66 @@ /** - * The Machines page against the seeded stack: "Add machine" walks through - * pairing with this server's own commands, and the seeded laptop — offline, - * since no daemon runs here — says what to run instead of offering to - * change its directories. + * The Machines page against the seeded stack: each machine lists the work + * on it and the Optio pods list theirs; "Add machine" walks through pairing + * with this server's own commands, and the seeded laptop — offline, since no + * daemon runs here — says what to run instead of offering to change its + * directories. */ import { expect, test } from "@playwright/test"; +const API = "http://127.0.0.1:4931"; + +async function api(path: string, init?: RequestInit): Promise { + // No content-type on a bodiless request — Fastify 400s an empty JSON body. + const res = await fetch(`${API}${path}`, { + ...init, + headers: init?.body ? { "content-type": "application/json" } : undefined, + }); + if (!res.ok) throw new Error(`${init?.method ?? "GET"} ${path} → ${res.status}`); + return (await res.json()) as T; +} + +test("each machine lists the work set up on it, and the pods list theirs", async ({ page }) => { + const { hosts } = await api("/api/local/hosts"); + const host = hosts.find((h: any) => h.name === "E2E laptop"); + const name = `E2E machine automation ${Date.now().toString(36)}`; + const { blueprint } = await api("/api/local/blueprints", { + method: "POST", + body: JSON.stringify({ + name, + hostId: host.id, + dir: "/Users/e2e/notes", + commandTemplate: "Summarize {{title}}", + agent: "claude-code", + sessionMode: "headless", + }), + }); + + await page.goto("/machines"); + const laptop = page.getByRole("region", { name: "E2E laptop", exact: true }); + const row = laptop.getByRole("link", { name: new RegExp(name) }); + await expect(row).toBeVisible({ timeout: 30_000 }); + await expect(row).toHaveAttribute("href", `/local/automations/${blueprint.id}`); + // Under its machine a row names only the directory. + await expect(row.getByText("~/notes", { exact: true })).toBeVisible(); + await expect(laptop.getByText("Set up to run here")).toBeVisible(); + + // The seeded persistent agent runs in a pod. + await expect(page.getByRole("heading", { name: "Optio pods" })).toBeVisible(); + const agents = page.getByRole("region", { name: "Persistent agents", exact: true }); + await expect(agents.getByRole("link", { name: /e2e-seed-agent/ })).toBeVisible(); +}); + test("Add machine shows this server's pairing commands", async ({ page }) => { await page.goto("/machines"); const main = page.locator("main"); await expect(main.getByRole("heading", { name: "E2E laptop" })).toBeVisible({ timeout: 30_000, }); + // Directories are the machine's setup, folded under it. + await main + .getByRole("region", { name: "E2E laptop", exact: true }) + .getByRole("button", { name: /Directories/ }) + .click(); await expect(main.getByText(/E2E laptop is offline — start optio local up on it/)).toBeVisible(); await expect(main.getByRole("button", { name: /Remove/ })).toHaveCount(0); diff --git a/apps/web/src/app/machines/page.tsx b/apps/web/src/app/machines/page.tsx index 5d9164f95..c17c48b5f 100644 --- a/apps/web/src/app/machines/page.tsx +++ b/apps/web/src/app/machines/page.tsx @@ -1,27 +1,52 @@ "use client"; -import { useEffect, useState } from "react"; +import { useEffect, useMemo, useState } from "react"; import Link from "next/link"; import { toast } from "sonner"; -import { ExternalLink, FolderPlus, Laptop, Merge, Plus, RefreshCw, X } from "lucide-react"; +import { + Bot, + Briefcase, + ChevronDown, + ChevronRight, + FolderGit2, + FolderOpen, + FolderPlus, + Laptop, + Merge, + Plus, + RefreshCw, + Server, + X, +} from "lucide-react"; import { api } from "@/lib/api-client"; import { cn, formatRelativeTime } from "@/lib/utils"; +import { shortDir, type WorkRow } from "@/lib/work-feed"; +import { + groupWorkByPlace, + nowSummary, + placeSize, + type PlaceWork, + type PodGroup, +} from "@/lib/work-places"; import { usePageTitle } from "@/hooks/use-page-title"; import { useLocalHosts } from "@/hooks/use-local-hosts"; +import { useWorkFeed } from "@/hooks/use-work-feed"; import { PageHeader } from "@/components/page-header"; +import { WorkRowView } from "@/components/work-row"; import { AutomationsSection } from "@/components/local/automations-section"; import { likelySameComputer, mergeTargets } from "@/components/local/host-merge"; import { PairMachineGuide } from "@/components/local/pair-machine"; import { AddDirForm, HostDirList, dirsLockedReason } from "@/components/local/host-dirs"; /** - * Your paired machines (Optio Local hosts) and the directories each one - * offers as a place to run work. Pairing is setup, not daily work, so - * this lives in the Library next to Repos. "Add machine" walks through - * pairing another computer; each machine's directories are added and - * removed here, through its daemon. Local Automations (agent / terminal - * specs that fire on events in one of these directories) are edited here - * too — they're per-machine configuration, not live work. + * Where work runs. Each paired machine (Optio Local host) with the work on + * it — what's live there now, and what's set up to run there (automations, + * Jobs and scheduled Tasks pointed at it) — and, as that machine's setup, + * the directories it offers. Then the Optio pods: the cluster as the other + * place work runs, grouped by repo, Jobs and persistent agents. Rows come + * from the Work feed (`GET /api/work`), grouped by `where.hostId` in + * `lib/work-places.ts`. "Add machine" walks through pairing another + * computer; Local Automations are created from the section at the bottom. */ export default function MachinesPage() { usePageTitle("Machines"); @@ -31,6 +56,7 @@ export default function MachinesPage() { const { hosts, loading, refetch, replaceHost } = useLocalHosts({ pollMs: fastPoll ? 3000 : undefined, }); + const work = useWorkFeed(); const noHosts = !loading && hosts.length === 0; const showGuide = pairing || noHosts; useEffect(() => setFastPoll(showGuide), [showGuide]); @@ -40,12 +66,35 @@ export default function MachinesPage() { if (new URLSearchParams(window.location.search).get("pair") === "1") setPairing(true); }, []); + // Online machines first, then by name. + const sortedHosts = useMemo( + () => + [...hosts].sort( + (a: any, b: any) => + Number(b.state === "online") - Number(a.state === "online") || + String(a.name).localeCompare(String(b.name)), + ), + [hosts], + ); + const places = useMemo( + () => + groupWorkByPlace( + work.rows, + hosts.map((h: any) => h.id), + ), + [work.rows, hosts], + ); + const refresh = () => { + refetch(); + work.refetch(); + }; + return (
{!showGuide && ( @@ -58,11 +107,11 @@ export default function MachinesPage() { )}
} @@ -92,47 +141,289 @@ export default function MachinesPage() { {loading && hosts.length === 0 ? (
) : hosts.length === 0 ? null : ( -
- {hosts.map((h: any) => ( -
-
-
-
- -

{h.name}

-
-

- {h.platform} - {h.arch ? ` · ${h.arch}` : ""} - {h.daemonVersion ? ` · daemon ${h.daemonVersion}` : ""} - {h.lastSeenAt ? ` · seen ${formatRelativeTime(h.lastSeenAt)}` : ""} -

-
- - Work - -
-
- - -
- {h.state !== "online" && likelySameComputer(h, hosts) && ( - - )} -
+
+ + {sortedHosts.map((h: any) => ( + ))} + {placeSize(places.otherMachines) > 0 && ( +
+
+

Other machines

+

+ Work set to run on a machine that isn't one of yours — a teammate's, or + one that was removed. +

+
+ +
+ )}
)} - {hosts.length > 0 && } + + + {hosts.length > 0 && } +
+ ); +} + +/** A heading over one kind of place ("Your machines", "Optio pods"). */ +function PlaceHeading({ + icon: Icon, + title, + hint, +}: { + icon: React.ComponentType<{ className?: string }>; + title: string; + hint?: string; +}) { + return ( +
+

+ + {title} +

+ {hint && {hint}} +
+ ); +} + +/** How many rows of each half a place shows before "N more in Work". */ +const ROWS_SHOWN = 6; + +/** + * A place's work: "Now" (live, needs-you first), then "Set up to run here" + * (recurring definitions and standing agents), each a flush list of Work rows. + */ +function PlaceRows({ + work, + whereLabel, + empty = "Nothing running here, and nothing set up to run here.", + setUpTitle = "Set up to run here", +}: { + work: PlaceWork; + whereLabel?: (row: WorkRow) => string | undefined; + empty?: string; + setUpTitle?: string; +}) { + if (placeSize(work) === 0) { + return

{empty}

; + } + return ( + <> + + + + ); +} + +function RowList({ + title, + rows, + more, + whereLabel, +}: { + title: string; + rows: WorkRow[]; + more: string; + whereLabel?: (row: WorkRow) => string | undefined; +}) { + if (rows.length === 0) return null; + const hidden = rows.length - ROWS_SHOWN; + return ( +
+
+ {title} +
+
+ {rows.slice(0, ROWS_SHOWN).map((r) => ( + + ))} +
+ {hidden > 0 && ( + + {hidden} more in Work → + + )} +
+ ); +} + +/** On a machine the row already sits under its name: its chip names only the directory. */ +const machineWhere = (row: WorkRow) => shortDir(row.where.dir) ?? undefined; + +/** + * One paired machine: who it is and whether it's connected, the work on it, + * and its setup — the directories it offers (collapsed unless it has none) + * and, for a stale duplicate, a merge offer. + */ +function MachineSection({ + host, + hosts, + work, + loadingWork, + onHostChanged, + onMerged, +}: { + host: any; + hosts: any[]; + work: PlaceWork; + loadingWork: boolean; + onHostChanged: (host: any) => void; + onMerged: () => void; +}) { + const online = host.state === "online"; + const dirs: any[] = host.dirs ?? []; + const [dirsOpen, setDirsOpen] = useState(dirs.length === 0); + const summary = nowSummary(work); + return ( +
+
+
+
+ +

{host.name}

+ + {online ? "online" : "offline"} + +
+

+ {host.platform} + {host.arch ? ` · ${host.arch}` : ""} + {host.daemonVersion ? ` · daemon ${host.daemonVersion}` : ""} + {host.lastSeenAt ? ` · seen ${formatRelativeTime(host.lastSeenAt)}` : ""} +

+
+ {summary && ( + {summary} + )} +
+ + {loadingWork && placeSize(work) === 0 ? ( +
+ ) : ( + + )} + +
+ + {dirsOpen && ( +
+ + +
+ )} + {!online && likelySameComputer(host, hosts) && ( + + )} +
+
+ ); +} + +const GROUP_ICON: Record> = { + repo: FolderGit2, + jobs: Briefcase, + agents: Bot, + other: Server, +}; + +/** + * The cluster as the other place work runs: Repo Tasks, pod sessions and + * scheduled Tasks by repo (one pod set per repo), Jobs and their runs + * (pooled pods per Job), and persistent agents (a pod each). + */ +function PodsSection({ groups, loading }: { groups: PodGroup[]; loading: boolean }) { + return ( +
+ + {loading ? ( +
+ ) : groups.length === 0 ? ( +

+ Nothing running in Optio pods, and nothing set up to.{" "} + + New work + +

+ ) : ( + groups.map((g) => { + const Icon = GROUP_ICON[g.kind]; + const summary = nowSummary(g.work); + return ( +
+
+

+ + {g.label} +

+ {summary && ( + {summary} + )} +
+ "Optio pod" : undefined} + /> +
+ ); + }) + )}
); } diff --git a/apps/web/src/components/local/transcript-view.test.ts b/apps/web/src/components/local/transcript-view.test.ts index 40b6067b2..f2bbaa38c 100644 --- a/apps/web/src/components/local/transcript-view.test.ts +++ b/apps/web/src/components/local/transcript-view.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; import type { LocalTranscriptEntry } from "@optio/shared"; -import { groupTranscript } from "./transcript-view"; +import { foldTranscript, groupTranscript, stepsSummary } from "./transcript-view"; const e = (seq: number, over: Partial): LocalTranscriptEntry => ({ seq, @@ -43,3 +43,68 @@ describe("groupTranscript", () => { ]); }); }); + +describe("foldTranscript", () => { + const shape = (entries: LocalTranscriptEntry[]) => + foldTranscript(groupTranscript(entries)).map((b) => + b.kind === "steps" + ? `steps:${b.items.length}` + : b.item.kind === "tool" + ? `tool:${b.item.use.seq}` + : `${b.item.entry.role}:${b.item.entry.seq}`, + ); + + it("keeps each turn's opener and last reply, folding everything between", () => { + expect( + shape([ + e(1, { role: "user" }), + e(2, { kind: "thinking" }), + e(3, { kind: "tool_use", toolUseId: "a" }), + e(4, { role: "tool", kind: "tool_result", toolUseId: "a" }), + e(5, {}), + e(6, { kind: "tool_use", toolUseId: "b" }), + e(7, {}), + e(8, { role: "user" }), + e(9, {}), + ]), + ).toEqual(["user:1", "steps:4", "assistant:7", "user:8", "assistant:9"]); + }); + + it("folds a turn still at work after its last reply, and leaves a lone step unfolded", () => { + expect( + shape([ + e(1, { role: "user" }), + e(2, {}), + e(3, { kind: "tool_use", toolUseId: "a" }), + e(4, { kind: "tool_use", toolUseId: "b" }), + ]), + ).toEqual(["user:1", "assistant:2", "steps:2"]); + expect(shape([e(1, { role: "user" }), e(2, { kind: "tool_use" }), e(3, {})])).toEqual([ + "user:1", + "tool:2", + "assistant:3", + ]); + }); + + it("treats system turns as openers", () => { + expect( + shape([ + e(1, { role: "system", source: "task" }), + e(2, { kind: "tool_use" }), + e(3, { kind: "tool_use" }), + e(4, {}), + ]), + ).toEqual(["system:1", "steps:2", "assistant:4"]); + }); + + it("summarizes what a steps block holds", () => { + const items = groupTranscript([ + e(1, { kind: "tool_use", toolUseId: "a" }), + e(2, { role: "tool", kind: "tool_result", toolUseId: "a" }), + e(3, { kind: "tool_use" }), + e(4, {}), + e(5, { kind: "thinking" }), + ]); + expect(stepsSummary(items)).toBe("2 tool calls · 1 message · thinking"); + }); +}); diff --git a/apps/web/src/components/local/transcript-view.tsx b/apps/web/src/components/local/transcript-view.tsx index 728f163b0..2a0ffe29a 100644 --- a/apps/web/src/components/local/transcript-view.tsx +++ b/apps/web/src/components/local/transcript-view.tsx @@ -96,6 +96,92 @@ export function groupTranscript(entries: LocalTranscriptEntry[]): TranscriptItem return out; } +/** + * What reads as the conversation: what you (or a background task, another + * agent, a compaction) put in, and the agent's last reply to it. Everything + * between — tool calls, thinking, the agent's running commentary — folds into + * one "steps" block, so a turn with a hundred tool calls reads as your + * message, one folded line, and the answer. + */ +export type TranscriptBlock = + | { kind: "item"; item: TranscriptItem } + | { kind: "steps"; items: TranscriptItem[] }; + +function opensTurn(item: TranscriptItem): boolean { + return item.kind === "entry" && (item.entry.role === "user" || item.entry.role === "system"); +} + +function isReply(item: TranscriptItem): boolean { + return item.kind === "entry" && item.entry.kind === "text" && item.entry.role === "assistant"; +} + +/** + * Per turn (what follows each `opensTurn` item): the steps before the + * turn's last reply, the reply, then the steps after it — a turn still at + * work has those. A run of one step isn't folded (a tool call is a folded + * row already). + */ +export function foldTranscript(items: TranscriptItem[]): TranscriptBlock[] { + const out: TranscriptBlock[] = []; + const pushSteps = (steps: TranscriptItem[]) => { + if (steps.length === 1) out.push({ kind: "item", item: steps[0]! }); + else if (steps.length > 1) out.push({ kind: "steps", items: steps }); + }; + let turn: TranscriptItem[] = []; + const flush = () => { + let reply = -1; + for (let i = turn.length - 1; i >= 0; i--) { + if (isReply(turn[i]!)) { + reply = i; + break; + } + } + if (reply < 0) { + pushSteps(turn); + } else { + pushSteps(turn.slice(0, reply)); + out.push({ kind: "item", item: turn[reply]! }); + pushSteps(turn.slice(reply + 1)); + } + turn = []; + }; + for (const item of items) { + if (opensTurn(item)) { + flush(); + out.push({ kind: "item", item }); + } else { + turn.push(item); + } + } + flush(); + return out; +} + +/** "12 tool calls · 3 messages · thinking" — what a folded steps block holds. */ +export function stepsSummary(items: TranscriptItem[]): string { + let tools = 0; + let messages = 0; + let thinking = 0; + for (const item of items) { + if ( + item.kind === "tool" || + item.entry.kind === "tool_result" || + item.entry.kind === "tool_use" + ) { + tools++; + } else if (item.entry.kind === "thinking") thinking++; + else messages++; + } + const plural = (n: number, noun: string) => `${n} ${noun}${n === 1 ? "" : "s"}`; + return [ + tools > 0 && plural(tools, "tool call"), + messages > 0 && plural(messages, "message"), + thinking > 0 && "thinking", + ] + .filter(Boolean) + .join(" · "); +} + function formatTime(at: string | null): string { if (!at) return ""; const d = new Date(at); @@ -120,9 +206,11 @@ export function TranscriptView({ className?: string; }) { const items = useMemo(() => groupTranscript(entries), [entries]); + const blocks = useMemo(() => foldTranscript(items), [items]); const scrollRef = useRef(null); const stickToBottom = useRef(true); - const [showThinking, setShowThinking] = useState(false); + // Every steps block open or shut at once; each one still toggles on its own. + const [allSteps, setAllSteps] = useState(false); // Land at the end (the latest exchange is what you came for), then follow // new entries only while the reader is already at the bottom. @@ -153,7 +241,7 @@ export function TranscriptView({ e.preventDefault(); }; - const thinkingCount = entries.reduce((n, e) => n + (e.kind === "thinking" ? 1 : 0), 0); + const stepsCount = blocks.reduce((n, b) => n + (b.kind === "steps" ? 1 : 0), 0); if (entries.length === 0) { return ( @@ -181,13 +269,13 @@ export function TranscriptView({ data-testid="local-transcript" >
- {thinkingCount > 0 && ( + {stepsCount > 0 && ( )} @@ -197,25 +285,18 @@ export function TranscriptView({ style={{ fontSize, maxWidth: CHAT_WIDTH_PX[width] ?? "none" }} data-testid="local-transcript-column" > - {items.map((item) => - item.kind === "tool" ? ( - - ) : item.entry.kind === "thinking" ? ( - showThinking ? ( - - ) : null - ) : item.entry.role === "system" ? ( - - ) : item.entry.role === "user" ? ( - item.entry.source === "prompt" ? ( - - ) : ( - - ) - ) : item.entry.kind === "tool_result" ? ( - + {blocks.map((block) => + block.kind === "steps" ? ( + ) : ( - + ), )} {live && ( @@ -229,6 +310,74 @@ export function TranscriptView({ ); } +function itemSeq(item: TranscriptItem): number { + return item.kind === "tool" ? item.use.seq : item.entry.seq; +} + +function TranscriptItemRow({ item }: { item: TranscriptItem }) { + if (item.kind === "tool") return ; + const entry = item.entry; + if (entry.kind === "thinking") return ; + if (entry.role === "system") return ; + if (entry.role === "user") { + return entry.source === "prompt" ? : ; + } + if (entry.kind === "tool_result") return ; + return ; +} + +/** + * A turn's in-between work, folded to one line: what it holds, and — while + * the agent is at it — the step it's on. Open, it lists every step as the + * rows they'd be on their own. + */ +function StepsRow({ + items, + defaultOpen, + working, +}: { + items: TranscriptItem[]; + defaultOpen: boolean; + working: boolean; +}) { + const [open, setOpen] = useState(defaultOpen); + const errors = items.filter((i) => i.kind === "tool" && i.result?.isError).length; + const last = items[items.length - 1]!; + const current = + working && last.kind === "tool" + ? `${last.use.toolName ?? "Tool"}${last.use.text ? ` · ${last.use.text}` : ""}` + : null; + return ( +
+ + {open && ( +
+ {items.map((item) => ( + + ))} +
+ )} +
+ ); +} + const UserRow = memo(function UserRow({ entry }: { entry: LocalTranscriptEntry }) { return (
diff --git a/apps/web/src/components/work-row.tsx b/apps/web/src/components/work-row.tsx index 3ae03abab..177a100d0 100644 --- a/apps/web/src/components/work-row.tsx +++ b/apps/web/src/components/work-row.tsx @@ -42,7 +42,12 @@ const THEN_ICON = { "waits-for-messages": Bot, } as const; -export function WorkRowView({ row }: { row: WorkRow }) { +/** + * `whereLabel` replaces the Where chip's text where the place is already + * said around the row (the Machines page lists a machine's work under it, + * so its rows name only the directory). + */ +export function WorkRowView({ row, whereLabel }: { row: WorkRow; whereLabel?: string }) { const router = useRouter(); const ThenIcon = THEN_ICON[row.then]; const WhenIcon = row.when === "now" ? Play : row.when === "messages" ? Bot : Clock; @@ -71,7 +76,9 @@ export function WorkRowView({ row }: { row: WorkRow }) { )} & Pick, +): WorkRow => + ({ + key, + id: key, + href: `/x/${key}`, + name: key, + when: "now", + who: "claude-code", + then: "exits", + statusLabel: over.status, + note: null, + prUrl: null, + lastActivity: "2026-09-24T10:00:00Z", + recurring: false, + editHref: null, + spawned: false, + ...over, + }) as WorkRow; + +const onMachine = (hostId: string | null) => ({ + target: "machine" as const, + detail: "M · ~/app", + hostId, +}); +const inPod = (detail: string | null) => ({ target: "pod" as const, detail }); + +describe("groupWorkByPlace", () => { + const rows = [ + row("terminal", { source: "local-terminal", status: "needs_you", where: onMachine("h1") }), + row("local-task", { source: "repo-task", status: "running", where: onMachine("h1") }), + row("automation", { + source: "local-blueprint", + status: "scheduled", + recurring: true, + where: onMachine("h1"), + }), + row("old-terminal", { source: "local-terminal", status: "done", where: onMachine("h1") }), + row("teammate", { source: "repo-task", status: "running", where: onMachine("h-other") }), + row("unset", { + source: "local-blueprint", + status: "paused", + recurring: true, + where: onMachine(null), + }), + row("task-a", { source: "repo-task", status: "running", where: inPod("acme/a") }), + row("blueprint-a", { + source: "repo-blueprint", + status: "scheduled", + recurring: true, + where: inPod("acme/a"), + }), + row("session-b", { source: "pod-session", status: "waiting", where: inPod("acme/b") }), + row("task-b", { source: "repo-task", status: "waiting", where: inPod("acme/b") }), + row("done-c", { source: "repo-task", status: "done", where: inPod("acme/c") }), + row("job", { source: "standalone", status: "scheduled", recurring: true, where: inPod(null) }), + row("job-run", { source: "standalone", status: "running", spawned: true, where: inPod(null) }), + row("agent-idle", { source: "persistent-agent", status: "waiting", where: inPod("@forge") }), + row("agent-paused", { source: "persistent-agent", status: "paused", where: inPod("@old") }), + row("agent-archived", { source: "persistent-agent", status: "done", where: inPod("@gone") }), + ]; + const places = groupWorkByPlace(rows, ["h1", "h2"]); + const keys = (rs: WorkRow[]) => rs.map((r) => r.key); + + it("puts each machine's live and recurring work under it, history left out", () => { + expect(keys(places.machines.h1.now)).toEqual(["terminal", "local-task"]); + expect(keys(places.machines.h1.setUp)).toEqual(["automation"]); + expect(places.machines.h2).toEqual({ now: [], setUp: [] }); + }); + + it("collects work on machines that aren't yours (or none) apart", () => { + expect(keys(places.otherMachines.now)).toEqual(["teammate"]); + expect(keys(places.otherMachines.setUp)).toEqual(["unset"]); + }); + + it("groups pod work by repo (busiest first), then Jobs, then agents", () => { + expect(places.pods.map((g) => g.label)).toEqual([ + "acme/b", + "acme/a", + "Jobs", + "Persistent agents", + ]); + const [b, a, jobs, agents] = places.pods; + expect(keys(b.work.now).sort()).toEqual(["session-b", "task-b"]); + expect(keys(a.work.now)).toEqual(["task-a"]); + expect(keys(a.work.setUp)).toEqual(["blueprint-a"]); + expect(keys(jobs.work.now)).toEqual(["job-run"]); + expect(keys(jobs.work.setUp)).toEqual(["job"]); + // An idle agent is live; a paused one is still standing; an archived one is gone. + expect(keys(agents.work.now)).toEqual(["agent-idle"]); + expect(keys(agents.work.setUp)).toEqual(["agent-paused"]); + }); + + it("summarises a place", () => { + expect(placeSize(places.machines.h1)).toBe(3); + expect(nowSummary(places.machines.h1)).toBe("1 needs you · 1 active"); + expect(nowSummary(places.machines.h2)).toBeNull(); + }); +}); diff --git a/apps/web/src/lib/work-places.ts b/apps/web/src/lib/work-places.ts new file mode 100644 index 000000000..4227a7cc5 --- /dev/null +++ b/apps/web/src/lib/work-places.ts @@ -0,0 +1,121 @@ +/** + * Where work runs, for the Machines page: the Work feed (`GET /api/work`) + * sorted into each paired machine (by `where.hostId`) and the Optio pods + * (grouped by repo, Jobs, and persistent agents). Pure, so it is tested on + * its own (`work-places.test.ts`). + */ +import { inView, sortWork, type WorkRow } from "./work-feed"; + +/** The work at one place: what is live now, and what is set up to run there. */ +export interface PlaceWork { + /** Running, queued, waiting, or needing you. */ + now: WorkRow[]; + /** Recurring definitions (automations, Jobs, scheduled Tasks) and standing agents. */ + setUp: WorkRow[]; +} + +export interface PodGroup { + /** Stable key: `repo:`, `jobs`, `agents`, or `other`. */ + key: string; + kind: "repo" | "jobs" | "agents" | "other"; + /** "acme/app", "Jobs", "Persistent agents", "Other". */ + label: string; + work: PlaceWork; +} + +export interface WorkPlaces { + /** Per paired machine id. Every id passed in has an entry, empty or not. */ + machines: Record; + /** Machine work whose machine isn't one of the caller's (or has none set). */ + otherMachines: PlaceWork; + /** Pod work: repos first (busiest first, then by name), then Jobs, agents, other. */ + pods: PodGroup[]; +} + +const empty = (): PlaceWork => ({ now: [], setUp: [] }); + +/** + * Which half of a place a row belongs in, or null when it is history: a + * persistent agent that isn't archived is standing work even while idle. + */ +function bucket(row: WorkRow): keyof PlaceWork | null { + if (inView(row, "active")) return "now"; + if (row.recurring) return "setUp"; + if (row.source === "persistent-agent" && row.status !== "done") return "setUp"; + return null; +} + +function podGroupOf(row: WorkRow): Pick { + if (row.source === "persistent-agent") { + return { key: "agents", kind: "agents", label: "Persistent agents" }; + } + if (row.source === "standalone") return { key: "jobs", kind: "jobs", label: "Jobs" }; + // Repo Tasks, scheduled Tasks and pod sessions are named by their repo. + if (row.where.detail) { + return { key: `repo:${row.where.detail}`, kind: "repo", label: row.where.detail }; + } + return { key: "other", kind: "other", label: "Other" }; +} + +const KIND_ORDER: Record = { repo: 0, jobs: 1, agents: 2, other: 3 }; + +export function groupWorkByPlace(rows: WorkRow[], hostIds: string[]): WorkPlaces { + const machines: Record = Object.fromEntries( + hostIds.map((id) => [id, empty()]), + ); + const otherMachines = empty(); + const pods = new Map(); + + for (const row of sortWork(rows)) { + const half = bucket(row); + if (!half) continue; + if (row.where.target === "machine") { + const place = (row.where.hostId && machines[row.where.hostId]) || otherMachines; + place[half].push(row); + continue; + } + const g = podGroupOf(row); + let group = pods.get(g.key); + if (!group) { + group = { ...g, work: empty() }; + pods.set(g.key, group); + } + group.work[half].push(row); + } + + // Within a place, what waits on you leads (the feed's order otherwise). + const needsYouFirst = (w: PlaceWork) => { + w.now = [ + ...w.now.filter((r) => r.status === "needs_you"), + ...w.now.filter((r) => r.status !== "needs_you"), + ]; + }; + Object.values(machines).forEach(needsYouFirst); + needsYouFirst(otherMachines); + pods.forEach((g) => needsYouFirst(g.work)); + + return { + machines, + otherMachines, + pods: [...pods.values()].sort( + (a, b) => + KIND_ORDER[a.kind] - KIND_ORDER[b.kind] || + b.work.now.length - a.work.now.length || + a.label.localeCompare(b.label), + ), + }; +} + +/** How many rows a place holds. */ +export const placeSize = (w: PlaceWork) => w.now.length + w.setUp.length; + +/** "2 running · 1 needs you" style summary of what is live at a place. */ +export function nowSummary(w: PlaceWork): string | null { + const needsYou = w.now.filter((r) => r.status === "needs_you").length; + const live = w.now.length - needsYou; + const parts = [ + needsYou ? `${needsYou} need${needsYou === 1 ? "s" : ""} you` : null, + live ? `${live} active` : null, + ].filter(Boolean); + return parts.length ? parts.join(" · ") : null; +} diff --git a/docs/optio-local.md b/docs/optio-local.md index 2df44e73b..2ccdf509a 100644 --- a/docs/optio-local.md +++ b/docs/optio-local.md @@ -639,8 +639,10 @@ before. ## Web UI - `/work` — local terminals are rows in the unified Work list (the old `/local` - cockpit redirects here; `/local?new=1` redirects to `/work/new`). Paired hosts, their - directories, and the **Automations** (blueprints) editor live on `/machines`. **Add + cockpit redirects here; `/local?new=1` redirects to `/work/new`). Paired hosts, the work + on each (its terminals, automations, and Tasks / Jobs run there — Work rows carry + `where.hostId`), their directories, and the **Automations** (blueprints) editor live on + `/machines`, followed by the work running in Optio pods. **Add machine** there (and "My machine" in the New work form while none is paired) shows the pairing steps with this server's own commands — `--server` is the API as the page reaches it, sign-in is skipped when auth is off — and watches for the machine to connect diff --git a/docs/tasks.md b/docs/tasks.md index 292cb86a3..6f4111392 100644 --- a/docs/tasks.md +++ b/docs/tasks.md @@ -84,7 +84,7 @@ Sidebar (v0.6): **Overview** · **Work** · **Reviews** · **Inbox** · **Librar - **`/work`**, **`/work/new`** — the unified feed and the one creation form. `/tasks/new`, `/jobs/new`, `/agents/new`, and the v0.5 `/sessions/new` redirect here. - **`/reviews`**, **`/reviews/:id`** — code-review subtasks plus external PR reviews, with CI / review / merge tracking. - **`/issues`** (Inbox) — GitHub / GitLab Issues across connected repos. "Assign to Optio" creates a `repo-task`. -- **`/machines`** — paired Optio Local hosts and their directory allowlists. +- **`/machines`** — where work runs: each paired Optio Local host with the work on it (live now, and set up to run there — grouped by the feed's `where.hostId`, `lib/work-places.ts`) and its directory allowlist, then the Optio pods' work grouped by repo, Jobs, and persistent agents. - Detail pages per kind still exist and link back to `/work`: `/tasks/:id`, `/tasks/scheduled/:id`, `/jobs/:id`, `/jobs/:id/runs/:runId`, `/agents/:id`, `/local/:id`, `/sessions/:id` (pod sessions — the one place the word keeps its narrow meaning). - The per-kind list pages are retired: `/tasks`, `/jobs`, `/tasks/scheduled`, `/agents`, `/local`, and the v0.5 `/sessions` redirect to the matching `/work?view=…`. Recurring work (scheduled Tasks, Jobs, Local Automations) each have a page about them (`/tasks/scheduled/:id`, `/jobs/:id`, `/local/automations/:id`: stats, triggers, prior runs) and are edited at `/work/:id/edit` — the New work form reopened on the saved row, with the kind locked. - Legacy `/tasks?tab=standalone|issues|prs` URLs redirect to `/work?view=recurring`, `/issues`, `/reviews`. diff --git a/packages/shared/src/work/feed.ts b/packages/shared/src/work/feed.ts index 1521dc23c..a3382c4db 100644 --- a/packages/shared/src/work/feed.ts +++ b/packages/shared/src/work/feed.ts @@ -44,6 +44,12 @@ export interface WorkWhere { target: "pod" | "machine"; /** Repo, `@slug`, or "machine · ~/dir"; null when there is nothing to say. */ detail: string | null; + /** On a machine: the `local_hosts` id it runs on (null when none is set). */ + hostId?: string | null; + /** On a machine: its name, when it is one of the caller's machines. */ + hostName?: string | null; + /** On a machine: the directory it runs in, as stored. */ + dir?: string | null; } /** A trigger that starts (or started) a piece of work: its type, and a ticket trigger's source. */