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
20 changes: 20 additions & 0 deletions apps/macos/Sources/OpenClaw/CockpitData.swift
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,26 @@ struct CockpitWorkerLogs: Codable, Sendable {
let stderrTail: String
}

struct CockpitLayoutLane: Codable, Identifiable, Sendable {
let id: String
let type: String
let label: String?
let order: Int
let widthFraction: Double?
let worktreeBinding: String?
let backendId: String?
}

struct CockpitProjectLayout: Codable, Identifiable, Sendable {
let id: String
let projectRoot: String
let name: String
let lanes: [CockpitLayoutLane]
let isActive: Bool
let createdAt: String
let updatedAt: String
}

struct CockpitSupervisorTickResult: Codable, Sendable {
let action: String
let reason: String?
Expand Down
74 changes: 73 additions & 1 deletion apps/macos/Sources/OpenClaw/CockpitStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@ typealias CockpitWorkerLogsLoader = @Sendable (_ workerId: String) async throws
typealias CockpitSupervisorTickPerformer = @Sendable (_ repoRoot: String?) async throws -> CockpitSupervisorTickResult
typealias CockpitWorkerActionPerformer = @Sendable (_ action: CockpitWorkerAction, _ workerId: String) async throws -> Void
typealias CockpitRemoteReconnectAction = @Sendable () async throws -> Void
typealias CockpitLayoutSaveAction = @Sendable (
_ projectRoot: String, _ name: String, _ lanes: [CockpitLayoutLane]) async throws -> CockpitProjectLayout
typealias CockpitLayoutLoader = @Sendable (_ projectRoot: String) async throws -> CockpitProjectLayout?

enum CockpitLoadError: LocalizedError {
case gatewayUnavailable(String)
Expand Down Expand Up @@ -47,6 +50,8 @@ final class CockpitStore {
var isPerformingWorkerAction = false
var activeWorkerAction: CockpitWorkerAction?
var isRepairingRemoteConnection = false
var activeLayout: CockpitProjectLayout?
var isSavingLayout = false

private let logger = Logger(subsystem: "ai.openclaw", category: "cockpit.ui")
private let isPreview: Bool
Expand All @@ -56,6 +61,8 @@ final class CockpitStore {
private let performSupervisorTickImpl: CockpitSupervisorTickPerformer
private let performWorkerActionImpl: CockpitWorkerActionPerformer
private let reconnectRemoteGatewayImpl: CockpitRemoteReconnectAction
private let saveLayoutImpl: CockpitLayoutSaveAction
private let loadActiveLayoutImpl: CockpitLayoutLoader

var selectedLane: CockpitLaneSummary? {
guard let snapshot = self.snapshot else { return nil }
Expand All @@ -80,7 +87,9 @@ final class CockpitStore {
loadWorkerLogs: CockpitWorkerLogsLoader? = nil,
performSupervisorTick: CockpitSupervisorTickPerformer? = nil,
performWorkerAction: CockpitWorkerActionPerformer? = nil,
reconnectRemoteGateway: CockpitRemoteReconnectAction? = nil)
reconnectRemoteGateway: CockpitRemoteReconnectAction? = nil,
saveLayout: CockpitLayoutSaveAction? = nil,
loadActiveLayout: CockpitLayoutLoader? = nil)
{
self.isPreview = isPreview
self.loadGatewayStatus = loadGatewayStatus ?? {
Expand Down Expand Up @@ -113,6 +122,25 @@ final class CockpitStore {
_ = try await GatewayEndpointStore.shared.ensureRemoteControlTunnel()
await GatewayEndpointStore.shared.refresh()
}
self.saveLayoutImpl = saveLayout ?? { projectRoot, name, lanes in
let laneDicts: [[String: AnyCodable]] = lanes.map { lane in
var dict: [String: AnyCodable] = [
"id": AnyCodable(lane.id),
"type": AnyCodable(lane.type),
"order": AnyCodable(lane.order),
]
if let label = lane.label { dict["label"] = AnyCodable(label) }
if let wf = lane.widthFraction { dict["widthFraction"] = AnyCodable(wf) }
if let wb = lane.worktreeBinding { dict["worktreeBinding"] = AnyCodable(wb) }
if let bi = lane.backendId { dict["backendId"] = AnyCodable(bi) }
return dict
}
return try await GatewayConnection.shared.codeLayoutSave(
projectRoot: projectRoot, name: name, lanes: laneDicts, isActive: true)
}
self.loadActiveLayoutImpl = loadActiveLayout ?? { projectRoot in
try await GatewayConnection.shared.codeLayoutActive(projectRoot: projectRoot)
}
}

func startNextWorker() async {
Expand Down Expand Up @@ -185,6 +213,50 @@ final class CockpitStore {
}
}

func saveCurrentLayout(name: String = "default") async {
guard !self.isSavingLayout else { return }
guard let projectRoot = self.projectRootLabel else { return }
guard let snapshot = self.snapshot, !snapshot.activeLanes.isEmpty else { return }

self.isSavingLayout = true
defer { self.isSavingLayout = false }

let lanes = snapshot.activeLanes.enumerated().map { index, lane in
CockpitLayoutLane(
id: lane.workerId,
type: lane.lane,
label: lane.workerName,
order: index,
widthFraction: nil,
worktreeBinding: lane.worktreePath,
backendId: lane.backendId)
}

do {
let saved = try await self.saveLayoutImpl(projectRoot, name, lanes)
self.activeLayout = saved
self.logger.info("code cockpit layout saved: \(saved.id, privacy: .public)")
} catch {
let message = (error as? LocalizedError)?.errorDescription ?? error.localizedDescription
self.logger.error("code cockpit layout save failed \(message, privacy: .public)")
}
}

func restoreLayout() async {
guard let projectRoot = self.projectRootLabel else { return }
if self.isPreview { return }

do {
self.activeLayout = try await self.loadActiveLayoutImpl(projectRoot)
if let layout = self.activeLayout {
self.logger.info("code cockpit layout restored: \(layout.name, privacy: .public)")
}
} catch {
let message = (error as? LocalizedError)?.errorDescription ?? error.localizedDescription
self.logger.error("code cockpit layout restore failed \(message, privacy: .public)")
}
}

func selectWorker(_ workerId: String) async {
self.selectedWorkerId = workerId
await self.refreshSelectedWorkerLogs()
Expand Down
24 changes: 23 additions & 1 deletion apps/macos/Sources/OpenClaw/CockpitWindow.swift
Original file line number Diff line number Diff line change
Expand Up @@ -22,10 +22,26 @@ struct CockpitWindow: View {
}
}
Spacer()
if let layout = self.store.activeLayout {
Text(layout.name)
.font(.caption.weight(.medium))
.padding(.horizontal, 8)
.padding(.vertical, 3)
.background(
Capsule()
.fill(Color.accentColor.opacity(0.12)))
}
if self.store.isLoading {
ProgressView()
.controlSize(.small)
}
Button {
Task { await self.store.saveCurrentLayout() }
} label: {
Label("Save Layout", systemImage: "square.and.arrow.down")
}
.buttonStyle(.bordered)
.disabled(self.store.isSavingLayout || self.store.snapshot?.activeLanes.isEmpty ?? true)
Button {
Task { await self.store.startNextWorker() }
} label: {
Expand Down Expand Up @@ -98,12 +114,13 @@ struct CockpitWindow: View {
.frame(minWidth: 1180, minHeight: 760)
.task {
await self.store.refreshIfNeeded()
await self.store.restoreLayout()
}
}
}

@MainActor
final class CockpitWindowController: NSWindowController {
final class CockpitWindowController: NSWindowController, NSWindowDelegate {
let store: CockpitStore

init(store: CockpitStore) {
Expand All @@ -120,12 +137,17 @@ final class CockpitWindowController: NSWindowController {
window.setFrameAutosaveName("OpenClawCockpitWindow")
super.init(window: window)
self.shouldCascadeWindows = true
window.delegate = self
}

@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}

func windowWillClose(_ notification: Notification) {
Task { await self.store.saveCurrentLayout() }
}
}

@MainActor
Expand Down
44 changes: 44 additions & 0 deletions apps/macos/Sources/OpenClaw/GatewayConnection.swift
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,10 @@ actor GatewayConnection {
case codeWorkerResume = "code.worker.resume"
case codeWorkerCancel = "code.worker.cancel"
case codeWorkerLogs = "code.worker.logs"
case codeLayoutSave = "code.layout.save"
case codeLayoutList = "code.layout.list"
case codeLayoutActive = "code.layout.active"
case codeLayoutDelete = "code.layout.delete"
}

private let configProvider: @Sendable () async throws -> Config
Expand Down Expand Up @@ -848,6 +852,46 @@ extension GatewayConnection {
timeoutMs: 10000)
}

func codeLayoutSave(
projectRoot: String,
name: String,
lanes: [[String: AnyCodable]],
isActive: Bool? = nil
) async throws -> CockpitProjectLayout {
var params: [String: AnyCodable] = [
"projectRoot": AnyCodable(projectRoot),
"name": AnyCodable(name),
"lanes": AnyCodable(lanes),
]
if let isActive {
params["isActive"] = AnyCodable(isActive)
}
return try await self.requestDecoded(
method: .codeLayoutSave,
params: params,
timeoutMs: 10000)
}

func codeLayoutList(projectRoot: String) async throws -> [CockpitProjectLayout] {
try await self.requestDecoded(
method: .codeLayoutList,
params: ["projectRoot": AnyCodable(projectRoot)],
timeoutMs: 10000)
}

func codeLayoutActive(projectRoot: String) async throws -> CockpitProjectLayout? {
try await self.requestDecoded(
method: .codeLayoutActive,
params: ["projectRoot": AnyCodable(projectRoot)],
timeoutMs: 10000)
}

func codeLayoutDelete(layoutId: String) async throws {
try await self.requestVoid(
method: .codeLayoutDelete,
params: ["layoutId": AnyCodable(layoutId)])
}

nonisolated static func decodeCronListResponse(_ data: Data) throws -> [CronJob] {
let decoded = try JSONDecoder().decode(LossyCronListResponse.self, from: data)
let jobs = decoded.jobs.compactMap(\.value)
Expand Down
26 changes: 26 additions & 0 deletions src/code-cockpit/runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,15 +18,21 @@ import type { ProcessSupervisor, RunExit, SpawnInput } from "../process/supervis
import {
type CreateCodeReviewRequestInput,
type CreateCodeTaskInput,
type SaveCodeProjectLayoutInput,
type CodeProjectLayout,
createCodeTask,
createCodeReviewRequest,
createCodeRun,
createCodeWorkerSession,
deleteCodeProjectLayout,
getActiveCodeProjectLayout,
getCodeTask,
getCodeCockpitWorkspaceSummary,
getCodeRun,
getCodeWorkerSession,
listCodeProjectLayouts,
loadCodeCockpitStore,
saveCodeProjectLayout,
type CodeWorkerAuthHealth,
type CodeWorkerEngineId,
type CodePullRequestState,
Expand Down Expand Up @@ -1740,6 +1746,26 @@ class CodeCockpitRuntime {
return { action: "started", task: refreshedTask, worker: started.worker, run: started.run };
}

async saveLayout(params: SaveCodeProjectLayoutInput): Promise<CodeProjectLayout> {
await this.ensureInitialized();
return await saveCodeProjectLayout(params);
}

async listLayouts(params: { projectRoot: string }): Promise<CodeProjectLayout[]> {
await this.ensureInitialized();
return await listCodeProjectLayouts(params.projectRoot);
}

async getActiveLayout(params: { projectRoot: string }): Promise<CodeProjectLayout | null> {
await this.ensureInitialized();
return await getActiveCodeProjectLayout(params.projectRoot);
}

async deleteLayout(params: { layoutId: string }): Promise<void> {
await this.ensureInitialized();
return await deleteCodeProjectLayout(params.layoutId);
}

async getWorkspaceSummary(): Promise<CodeCockpitWorkspaceSummary> {
await this.ensureInitialized();
return await getCodeCockpitWorkspaceSummary();
Expand Down
Loading
Loading