Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 9 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,21 +40,21 @@ openclaw onboard

Arc has one runtime with two surfaces:

| Surface | Role |
| --- | --- |
| Surface | Role |
| ------------------- | ------------------------------------------------------ |
| **Swift macOS app** | Flagship review workstation — diffs, queues, decisions |
| **VPS TUI** | Fast remote operator console — queue, inspect, unblock |
| **VPS TUI** | Fast remote operator console — queue, inspect, unblock |

### The Layer Model

Arc only makes sense if the layers stay clean:

| Layer | Role |
| --- | --- |
| **Arc** | product, workflow, workstation, project cockpit |
| **OpenClaw** | runtime, gateway, worktrees, worker lifecycle, durable state |
| **Claude + Codex** | worker engines that do the coding work |
| **Obsidian** | planning, notes, specs, architecture, project memory |
| Layer | Role |
| ------------------ | ------------------------------------------------------------ |
| **Arc** | product, workflow, workstation, project cockpit |
| **OpenClaw** | runtime, gateway, worktrees, worker lifecycle, durable state |
| **Claude + Codex** | worker engines that do the coding work |
| **Obsidian** | planning, notes, specs, architecture, project memory |

Obsidian should hold thinking. Arc should hold execution.

Expand Down
34 changes: 34 additions & 0 deletions apps/macos/Sources/OpenClaw/CockpitData.swift
Original file line number Diff line number Diff line change
Expand Up @@ -216,6 +216,40 @@ struct CockpitWorkspaceSummary: Codable, Sendable {
let activeLanes: [CockpitLaneSummary]
}

// MARK: - Layout

enum CockpitLayoutPreset: String, CaseIterable, Codable, Sendable, Equatable, Identifiable {
/// 3 worker slots across the top, 1 review slot spanning the bottom-right.
case threeWorkerOneReview = "3w1r"

var id: String { self.rawValue }

var label: String {
switch self {
case .threeWorkerOneReview:
"3 Workers + 1 Review"
}
}

var workerSlotCount: Int {
switch self {
case .threeWorkerOneReview:
3
}
}

var hasReviewSlot: Bool {
switch self {
case .threeWorkerOneReview:
true
}
}
}

extension CockpitLayoutPreset {
static let `default`: CockpitLayoutPreset = .threeWorkerOneReview
}

extension CockpitGatewayStatus {
static let previewLocal = CockpitGatewayStatus(
mode: .local,
Expand Down
16 changes: 16 additions & 0 deletions apps/macos/Sources/OpenClaw/CockpitStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ final class CockpitStore {
var isPerformingWorkerAction = false
var activeWorkerAction: CockpitWorkerAction?
var isRepairingRemoteConnection = false
var layoutPreset: CockpitLayoutPreset = .default

private let logger = Logger(subsystem: "ai.openclaw", category: "cockpit.ui")
private let isPreview: Bool
Expand All @@ -65,6 +66,21 @@ final class CockpitStore {
return snapshot.activeLanes.first(where: { $0.workerId == selectedWorkerId }) ?? snapshot.activeLanes.first
}

/// Worker lanes padded or truncated to fill the layout's worker slot count.
var workerSlots: [CockpitLaneSummary?] {
let workers = (self.snapshot?.activeLanes ?? []).filter { $0.lane == "worker" || $0.lane == "code" || $0.lane.isEmpty }
let count = self.layoutPreset.workerSlotCount
if workers.count >= count {
return Array(workers.prefix(count))
}
return workers.map { Optional($0) } + Array(repeating: nil, count: count - workers.count)
}

/// The first review-lane worker, if any.
var reviewSlot: CockpitLaneSummary? {
(self.snapshot?.activeLanes ?? []).first(where: { $0.lane == "review" })
}

var projectRootLabel: String? {
Self.resolveProjectRoot(snapshot: self.snapshot, selectedLane: self.selectedLane)
}
Expand Down
217 changes: 115 additions & 102 deletions apps/macos/Sources/OpenClaw/CockpitWindow.swift
Original file line number Diff line number Diff line change
Expand Up @@ -64,17 +64,25 @@ struct CockpitWindow: View {
ScrollView {
VStack(alignment: .leading, spacing: 18) {
CockpitMetricStrip(snapshot: snapshot)
HStack(alignment: .top, spacing: 16) {
CockpitLaneSection(
lanes: snapshot.activeLanes,
selectedWorkerId: self.store.selectedWorkerId,
onSelect: { workerId in
Task { await self.store.selectWorker(workerId) }
})
CockpitSelectedWorkerSection(store: self.store)

HStack(alignment: .center, spacing: 4) {
Text("Layout")
.font(.caption)
.foregroundStyle(.secondary)
Picker("", selection: self.$store.layoutPreset) {
ForEach(CockpitLayoutPreset.allCases) { preset in
Text(preset.label).tag(preset)
}
}
.pickerStyle(.menu)
.fixedSize()
}

CockpitLayoutGrid(store: self.store)

CockpitSelectedWorkerSection(store: self.store)

HStack(alignment: .top, spacing: 16) {
CockpitReviewSection(reviews: snapshot.pendingReviews)
CockpitRunsSection(runs: snapshot.recentRuns)
}
CockpitTasksSection(tasks: snapshot.recentTasks)
Expand Down Expand Up @@ -268,65 +276,39 @@ private struct CockpitMetricCard: View {
}
}

private struct CockpitLaneSection: View {
let lanes: [CockpitLaneSummary]
let selectedWorkerId: String?
let onSelect: (String) -> Void

private let columns = [
GridItem(.flexible(minimum: 280), spacing: 12),
GridItem(.flexible(minimum: 280), spacing: 12),
]
/// Renders the structured layout grid: worker slots across the top row, review slot at the end.
private struct CockpitLayoutGrid: View {
@Bindable var store: CockpitStore

var body: some View {
VStack(alignment: .leading, spacing: 10) {
Text("Workers")
Text("Lanes")
.font(.title3.weight(.semibold))
if self.lanes.isEmpty {
sectionPlaceholder("No workers yet. Start the next worker to populate the cockpit.")
} else {
LazyVGrid(columns: self.columns, alignment: .leading, spacing: 12) {
ForEach(self.lanes) { lane in
Button {
self.onSelect(lane.workerId)
} label: {
VStack(alignment: .leading, spacing: 8) {
HStack {
Text(lane.workerName)
.font(.headline)
Spacer()
Text(lane.status.replacingOccurrences(of: "_", with: " "))
.font(.caption.weight(.medium))
.foregroundStyle(.secondary)
}
Text(lane.taskTitle)
.font(.subheadline)
.multilineTextAlignment(.leading)
if let branch = lane.branch {
Text(branch)
.font(.caption.monospaced())
.foregroundStyle(.secondary)
}
if let summary = lane.latestRun?.summary, !summary.isEmpty {
Text(summary)
.font(.caption)
.foregroundStyle(.secondary)
.multilineTextAlignment(.leading)
}
if let review = lane.pendingReview {
Text("Pending review: \(review.title)")
.font(.caption)
.foregroundStyle(.orange)
.multilineTextAlignment(.leading)
}
}
.padding(14)
.frame(maxWidth: .infinity, alignment: .leading)
.background(
RoundedRectangle(cornerRadius: 12, style: .continuous)
.fill(self.selectedWorkerId == lane.workerId ? Color.accentColor.opacity(0.14) : Color.primary.opacity(0.04)))
}
.buttonStyle(.plain)

let workerSlots = self.store.workerSlots
let reviewSlot = self.store.reviewSlot

HStack(alignment: .top, spacing: 12) {
ForEach(Array(workerSlots.enumerated()), id: \.offset) { index, lane in
if let lane {
CockpitLaneCard(
lane: lane,
isSelected: self.store.selectedWorkerId == lane.workerId,
onSelect: { Task { await self.store.selectWorker(lane.workerId) } })
} else {
CockpitEmptySlotCard(label: "Worker \(index + 1)", hint: "Start a worker to fill this slot.")
}
}

if self.store.layoutPreset.hasReviewSlot {
if let lane = reviewSlot {
CockpitLaneCard(
lane: lane,
isSelected: self.store.selectedWorkerId == lane.workerId,
onSelect: { Task { await self.store.selectWorker(lane.workerId) } },
accentColor: .orange)
} else {
CockpitEmptySlotCard(label: "Review", hint: "Pending reviews appear here.")
}
}
}
Expand All @@ -335,6 +317,75 @@ private struct CockpitLaneSection: View {
}
}

private struct CockpitLaneCard: View {
let lane: CockpitLaneSummary
let isSelected: Bool
let onSelect: () -> Void
var accentColor: Color = .accentColor

var body: some View {
Button(action: self.onSelect) {
VStack(alignment: .leading, spacing: 8) {
HStack {
Text(self.lane.workerName)
.font(.headline)
Spacer()
Text(self.lane.status.replacingOccurrences(of: "_", with: " "))
.font(.caption.weight(.medium))
.foregroundStyle(.secondary)
}
Text(self.lane.taskTitle)
.font(.subheadline)
.multilineTextAlignment(.leading)
if let branch = self.lane.branch {
Text(branch)
.font(.caption.monospaced())
.foregroundStyle(.secondary)
}
if let summary = self.lane.latestRun?.summary, !summary.isEmpty {
Text(summary)
.font(.caption)
.foregroundStyle(.secondary)
.multilineTextAlignment(.leading)
}
if let review = self.lane.pendingReview {
Text("Pending review: \(review.title)")
.font(.caption)
.foregroundStyle(.orange)
.multilineTextAlignment(.leading)
}
}
.padding(14)
.frame(maxWidth: .infinity, alignment: .leading)
.background(
RoundedRectangle(cornerRadius: 12, style: .continuous)
.fill(self.isSelected ? self.accentColor.opacity(0.14) : Color.primary.opacity(0.04)))
}
.buttonStyle(.plain)
}
}

private struct CockpitEmptySlotCard: View {
let label: String
let hint: String

var body: some View {
VStack(alignment: .leading, spacing: 8) {
Text(self.label)
.font(.headline)
.foregroundStyle(.tertiary)
Text(self.hint)
.font(.caption)
.foregroundStyle(.quaternary)
}
.padding(14)
.frame(maxWidth: .infinity, alignment: .leading)
.background(
RoundedRectangle(cornerRadius: 12, style: .continuous)
.strokeBorder(Color.primary.opacity(0.08), style: StrokeStyle(lineWidth: 1, dash: [6, 4])))
}
}

private struct CockpitSelectedWorkerSection: View {
@Bindable var store: CockpitStore

Expand Down Expand Up @@ -454,44 +505,6 @@ private struct CockpitSelectedWorkerSection: View {
}
}

private struct CockpitReviewSection: View {
let reviews: [CockpitReviewSummary]

var body: some View {
VStack(alignment: .leading, spacing: 10) {
Text("Pending Reviews")
.font(.title3.weight(.semibold))
if self.reviews.isEmpty {
sectionPlaceholder("No pending reviews.")
} else {
VStack(alignment: .leading, spacing: 8) {
ForEach(self.reviews.prefix(6)) { review in
VStack(alignment: .leading, spacing: 4) {
Text(review.title)
.font(.headline)
if let summary = review.summary, !summary.isEmpty {
Text(summary)
.font(.caption)
.foregroundStyle(.secondary)
}
Text(review.status)
.font(.caption2)
.foregroundStyle(.orange)
}
.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 CockpitRunsSection: View {
let runs: [CockpitRunSummary]

Expand Down
2 changes: 1 addition & 1 deletion docs/cockpit/FAST-TODO.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ Arc becomes the default daily surface when these are all done:

- [ ] Add embedded PTY terminal lanes
- [ ] Bind terminal lanes to worktrees
- [ ] Add default 3-worker + 1-review layout
- [x] Add default 3-worker + 1-review layout
- [ ] Save and restore project layouts

## Phase C: Always-On Arc
Expand Down
Loading