From b08d5323206286bbc517b95e5cefe8a8f87aac07 Mon Sep 17 00:00:00 2001 From: Arc Date: Fri, 20 Mar 2026 23:35:35 +0000 Subject: [PATCH] Arc: add first-class review queue for finished runs Replaces the minimal Recent Runs list with a full Review Queue section in the cockpit window. Finished runs (succeeded, failed, cancelled) are shown in a table with status icons, worker, summary, duration, and relative timestamps. A segmented picker filters by status category. Clicking a row opens a detail sheet with all run metadata. Preview data enriched with succeeded/failed/cancelled sample runs. --- apps/macos/Sources/OpenClaw/CockpitData.swift | 48 +++ .../Sources/OpenClaw/CockpitReviewQueue.swift | 296 ++++++++++++++++++ .../Sources/OpenClaw/CockpitWindow.swift | 45 +-- 3 files changed, 346 insertions(+), 43 deletions(-) create mode 100644 apps/macos/Sources/OpenClaw/CockpitReviewQueue.swift diff --git a/apps/macos/Sources/OpenClaw/CockpitData.swift b/apps/macos/Sources/OpenClaw/CockpitData.swift index 50dc049f8..ae9f62fa2 100644 --- a/apps/macos/Sources/OpenClaw/CockpitData.swift +++ b/apps/macos/Sources/OpenClaw/CockpitData.swift @@ -355,6 +355,54 @@ extension CockpitWorkspaceSummary { finishedAt: nil, terminationReason: nil, updatedAt: "2026-03-19T12:58:00.000Z"), + CockpitRunSummary( + id: "run_prev_ok", + taskId: "task_shell", + workerId: "worker_shell", + status: "succeeded", + summary: "Wired lane grid and metric strip", + backendId: "codex-cli", + threadId: "thread_prev_ok", + startedAt: "2026-03-19T12:30:00.000Z", + finishedAt: "2026-03-19T12:42:00.000Z", + terminationReason: nil, + updatedAt: "2026-03-19T12:42:00.000Z"), + CockpitRunSummary( + id: "run_prev_fail", + taskId: "task_review", + workerId: "worker_review", + status: "failed", + summary: "Type-check failed after refactor", + backendId: "codex-cli", + threadId: "thread_prev_fail", + startedAt: "2026-03-19T12:10:00.000Z", + finishedAt: "2026-03-19T12:18:00.000Z", + terminationReason: "exit_code", + updatedAt: "2026-03-19T12:18:00.000Z"), + CockpitRunSummary( + id: "run_prev_cancel", + taskId: "task_review", + workerId: "worker_review", + status: "cancelled", + summary: "Operator cancelled stale review lane", + backendId: "codex-cli", + threadId: "thread_prev_cancel", + startedAt: "2026-03-19T11:50:00.000Z", + finishedAt: "2026-03-19T11:52:00.000Z", + terminationReason: "operator", + updatedAt: "2026-03-19T11:52:00.000Z"), + CockpitRunSummary( + id: "run_early_ok", + taskId: "task_shell", + workerId: "worker_shell", + status: "succeeded", + summary: "Initial cockpit scaffolding", + backendId: "codex-cli", + threadId: "thread_early_ok", + startedAt: "2026-03-19T11:00:00.000Z", + finishedAt: "2026-03-19T11:25:00.000Z", + terminationReason: nil, + updatedAt: "2026-03-19T11:25:00.000Z"), ], activeLanes: [ CockpitLaneSummary( diff --git a/apps/macos/Sources/OpenClaw/CockpitReviewQueue.swift b/apps/macos/Sources/OpenClaw/CockpitReviewQueue.swift new file mode 100644 index 000000000..8928d8103 --- /dev/null +++ b/apps/macos/Sources/OpenClaw/CockpitReviewQueue.swift @@ -0,0 +1,296 @@ +import SwiftUI + +// MARK: - Run status helpers + +enum RunStatusCategory: String, CaseIterable, Identifiable { + case all = "All" + case succeeded = "Succeeded" + case failed = "Failed" + case cancelled = "Cancelled" + + var id: String { self.rawValue } + + func matches(_ status: String) -> Bool { + switch self { + case .all: true + case .succeeded: status == "succeeded" + case .failed: status == "failed" + case .cancelled: status == "cancelled" + } + } +} + +private extension CockpitRunSummary { + var isFinished: Bool { + self.status == "succeeded" || self.status == "failed" || self.status == "cancelled" + } + + var statusColor: Color { + switch self.status { + case "succeeded": .green + case "failed": .red + case "cancelled": .orange + default: .secondary + } + } + + var statusIcon: String { + switch self.status { + case "succeeded": "checkmark.circle.fill" + case "failed": "xmark.circle.fill" + case "cancelled": "minus.circle.fill" + default: "circle" + } + } + + var durationText: String? { + guard let startedAt, let finishedAt else { return nil } + let iso = ISO8601DateFormatter() + guard let start = iso.date(from: startedAt), + let end = iso.date(from: finishedAt) + else { return nil } + let seconds = Int(end.timeIntervalSince(start)) + if seconds < 60 { return "\(seconds)s" } + let minutes = seconds / 60 + let remainder = seconds % 60 + if minutes < 60 { return "\(minutes)m \(remainder)s" } + let hours = minutes / 60 + return "\(hours)h \(minutes % 60)m" + } + + var finishedDate: Date? { + guard let finishedAt else { return nil } + return ISO8601DateFormatter().date(from: finishedAt) + } +} + +// MARK: - Review queue section (embedded in cockpit) + +struct CockpitReviewQueueSection: View { + let runs: [CockpitRunSummary] + @State private var filter: RunStatusCategory = .all + @State private var selectedRun: CockpitRunSummary? + + private var finishedRuns: [CockpitRunSummary] { + let finished = self.runs.filter(\.isFinished) + if self.filter == .all { return finished } + return finished.filter { self.filter.matches($0.status) } + } + + var body: some View { + VStack(alignment: .leading, spacing: 10) { + HStack { + Text("Review Queue") + .font(.title3.weight(.semibold)) + Spacer() + Picker("Filter", selection: self.$filter) { + ForEach(RunStatusCategory.allCases) { category in + Text(category.rawValue).tag(category) + } + } + .pickerStyle(.segmented) + .frame(maxWidth: 320) + } + + if self.finishedRuns.isEmpty { + reviewQueuePlaceholder + } else { + VStack(alignment: .leading, spacing: 0) { + reviewQueueHeader + Divider() + ForEach(self.finishedRuns) { run in + Button { + self.selectedRun = run + } label: { + CockpitReviewQueueRow(run: run) + } + .buttonStyle(.plain) + Divider() + } + } + .background( + RoundedRectangle(cornerRadius: 12, style: .continuous) + .fill(Color.primary.opacity(0.04))) + .clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous)) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .sheet(item: self.$selectedRun) { run in + CockpitRunDetailSheet(run: run) + } + } + + private var reviewQueuePlaceholder: some View { + Text(self.filter == .all + ? "No finished runs to review." + : "No \(self.filter.rawValue.lowercased()) runs.") + .font(.callout) + .foregroundStyle(.secondary) + .padding(14) + .frame(maxWidth: .infinity, alignment: .leading) + .background( + RoundedRectangle(cornerRadius: 12, style: .continuous) + .fill(Color.primary.opacity(0.04))) + } + + private var reviewQueueHeader: some View { + HStack(spacing: 0) { + Text("Status") + .frame(width: 90, alignment: .leading) + Text("Worker") + .frame(width: 140, alignment: .leading) + Text("Summary") + .frame(maxWidth: .infinity, alignment: .leading) + Text("Duration") + .frame(width: 80, alignment: .trailing) + Text("Finished") + .frame(width: 100, alignment: .trailing) + } + .font(.caption.weight(.medium)) + .foregroundStyle(.secondary) + .padding(.horizontal, 14) + .padding(.vertical, 8) + } +} + +// MARK: - Row + +private struct CockpitReviewQueueRow: View { + let run: CockpitRunSummary + + var body: some View { + HStack(spacing: 0) { + HStack(spacing: 4) { + Image(systemName: self.run.statusIcon) + .foregroundStyle(self.run.statusColor) + .font(.caption) + Text(self.run.status) + .font(.caption.weight(.medium)) + .foregroundStyle(self.run.statusColor) + } + .frame(width: 90, alignment: .leading) + + Text(self.run.workerId ?? "—") + .font(.caption.monospaced()) + .lineLimit(1) + .frame(width: 140, alignment: .leading) + + Text(self.run.summary ?? "—") + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(2) + .multilineTextAlignment(.leading) + .frame(maxWidth: .infinity, alignment: .leading) + + Text(self.run.durationText ?? "—") + .font(.caption.monospaced()) + .foregroundStyle(.secondary) + .frame(width: 80, alignment: .trailing) + + Text(relativeAge(from: self.run.finishedDate)) + .font(.caption) + .foregroundStyle(.secondary) + .frame(width: 100, alignment: .trailing) + } + .padding(.horizontal, 14) + .padding(.vertical, 10) + .contentShape(Rectangle()) + .background(Color.clear) + } +} + +// MARK: - Detail sheet + +struct CockpitRunDetailSheet: View { + let run: CockpitRunSummary + @Environment(\.dismiss) private var dismiss + + var body: some View { + VStack(alignment: .leading, spacing: 16) { + HStack { + Image(systemName: self.run.statusIcon) + .font(.title2) + .foregroundStyle(self.run.statusColor) + VStack(alignment: .leading, spacing: 2) { + Text("Run \(self.run.id)") + .font(.headline.monospaced()) + Text(self.run.status.replacingOccurrences(of: "_", with: " ")) + .font(.subheadline.weight(.medium)) + .foregroundStyle(self.run.statusColor) + } + Spacer() + Button("Done") { self.dismiss() } + .keyboardShortcut(.defaultAction) + } + + if let summary = self.run.summary, !summary.isEmpty { + GroupBox("Summary") { + Text(summary) + .font(.body) + .frame(maxWidth: .infinity, alignment: .leading) + .textSelection(.enabled) + } + } + + GroupBox("Details") { + VStack(alignment: .leading, spacing: 8) { + if let taskId = self.run.taskId { + detailRow("Task", taskId) + } + if let workerId = self.run.workerId { + detailRow("Worker", workerId) + } + if let backendId = self.run.backendId { + detailRow("Backend", backendId) + } + if let threadId = self.run.threadId { + detailRow("Thread", threadId) + } + if let startedAt = self.run.startedAt { + detailRow("Started", self.formatTimestamp(startedAt)) + } + if let finishedAt = self.run.finishedAt { + detailRow("Finished", self.formatTimestamp(finishedAt)) + } + if let duration = self.run.durationText { + detailRow("Duration", duration) + } + if let reason = self.run.terminationReason, !reason.isEmpty { + detailRow("Termination Reason", reason) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + } + } + .padding(24) + .frame(minWidth: 480, idealWidth: 540, minHeight: 300) + } + + private func detailRow(_ label: String, _ value: String) -> some View { + HStack(alignment: .top) { + Text(label) + .font(.caption.weight(.semibold)) + .foregroundStyle(.secondary) + .frame(width: 120, alignment: .trailing) + Text(value) + .font(.caption.monospaced()) + .textSelection(.enabled) + } + } + + private func formatTimestamp(_ iso: String) -> String { + guard let date = ISO8601DateFormatter().date(from: iso) else { return iso } + let formatter = DateFormatter() + formatter.dateStyle = .medium + formatter.timeStyle = .medium + return formatter.string(from: date) + } +} + +// MARK: - Previews + +#Preview("Review Queue") { + CockpitReviewQueueSection(runs: CockpitWorkspaceSummary.preview.recentRuns) + .padding() + .frame(width: 900) +} diff --git a/apps/macos/Sources/OpenClaw/CockpitWindow.swift b/apps/macos/Sources/OpenClaw/CockpitWindow.swift index 88f7769c0..07345a8d7 100644 --- a/apps/macos/Sources/OpenClaw/CockpitWindow.swift +++ b/apps/macos/Sources/OpenClaw/CockpitWindow.swift @@ -73,11 +73,11 @@ struct CockpitWindow: View { }) CockpitSelectedWorkerSection(store: self.store) } + CockpitReviewQueueSection(runs: snapshot.recentRuns) HStack(alignment: .top, spacing: 16) { CockpitReviewSection(reviews: snapshot.pendingReviews) - CockpitRunsSection(runs: snapshot.recentRuns) + CockpitTasksSection(tasks: snapshot.recentTasks) } - CockpitTasksSection(tasks: snapshot.recentTasks) } } } else if self.store.isLoading { @@ -492,47 +492,6 @@ private struct CockpitReviewSection: View { } } -private struct CockpitRunsSection: View { - let runs: [CockpitRunSummary] - - var body: some View { - VStack(alignment: .leading, spacing: 10) { - Text("Recent Runs") - .font(.title3.weight(.semibold)) - if self.runs.isEmpty { - sectionPlaceholder("No worker runs yet.") - } else { - VStack(alignment: .leading, spacing: 8) { - ForEach(self.runs.prefix(6)) { run in - VStack(alignment: .leading, spacing: 4) { - HStack { - Text(run.id) - .font(.caption.monospaced()) - Spacer() - Text(run.status) - .font(.caption2) - .foregroundStyle(.secondary) - } - if let summary = run.summary, !summary.isEmpty { - Text(summary) - .font(.caption) - .foregroundStyle(.secondary) - } - } - .padding(.bottom, 6) - } - } - .padding(14) - .frame(maxWidth: .infinity, alignment: .leading) - .background( - RoundedRectangle(cornerRadius: 12, style: .continuous) - .fill(Color.primary.opacity(0.04))) - } - } - .frame(maxWidth: .infinity, alignment: .leading) - } -} - private struct CockpitTasksSection: View { let tasks: [CockpitTaskSummary]