From f8b847a79f90f816f7c3e8040184c9826d1a02be Mon Sep 17 00:00:00 2001 From: Arc Date: Sat, 21 Mar 2026 05:31:52 +0000 Subject: [PATCH] feat: save and restore project layouts in cockpit Add CodeProjectLayout type with lane definitions (worker/review/terminal) to the cockpit store with full CRUD: save (upsert by projectRoot+name), list, get-active, and delete. Wire through runtime, gateway RPC (code.layout.save/list/active/delete), and Swift data models. CockpitWindow restores the active layout on appear and auto-saves on window close. A Save Layout button in the header toolbar lets the operator persist the current lane arrangement at any time. Includes 8 store-level tests covering persistence, upsert, active-flag deactivation, validation, and cross-reload durability. Co-Authored-By: Claude Opus 4.6 --- README.md | 18 +- apps/macos/Sources/OpenClaw/CockpitData.swift | 20 +++ .../macos/Sources/OpenClaw/CockpitStore.swift | 74 ++++++++- .../Sources/OpenClaw/CockpitWindow.swift | 24 ++- .../Sources/OpenClaw/GatewayConnection.swift | 44 +++++ src/code-cockpit/runtime.ts | 26 +++ src/code-cockpit/store.test.ts | 142 ++++++++++++++++ src/code-cockpit/store.ts | 157 ++++++++++++++++++ src/gateway/server-methods/code-cockpit.ts | 39 +++++ 9 files changed, 533 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index a04a13293..1ba5a65d2 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/apps/macos/Sources/OpenClaw/CockpitData.swift b/apps/macos/Sources/OpenClaw/CockpitData.swift index 50dc049f8..d47e69c7f 100644 --- a/apps/macos/Sources/OpenClaw/CockpitData.swift +++ b/apps/macos/Sources/OpenClaw/CockpitData.swift @@ -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? diff --git a/apps/macos/Sources/OpenClaw/CockpitStore.swift b/apps/macos/Sources/OpenClaw/CockpitStore.swift index 94df591b0..041b2fb7c 100644 --- a/apps/macos/Sources/OpenClaw/CockpitStore.swift +++ b/apps/macos/Sources/OpenClaw/CockpitStore.swift @@ -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) @@ -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 @@ -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 } @@ -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 ?? { @@ -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 { @@ -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() diff --git a/apps/macos/Sources/OpenClaw/CockpitWindow.swift b/apps/macos/Sources/OpenClaw/CockpitWindow.swift index 88f7769c0..17537999d 100644 --- a/apps/macos/Sources/OpenClaw/CockpitWindow.swift +++ b/apps/macos/Sources/OpenClaw/CockpitWindow.swift @@ -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: { @@ -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) { @@ -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 diff --git a/apps/macos/Sources/OpenClaw/GatewayConnection.swift b/apps/macos/Sources/OpenClaw/GatewayConnection.swift index 454e17dd2..c78dd382c 100644 --- a/apps/macos/Sources/OpenClaw/GatewayConnection.swift +++ b/apps/macos/Sources/OpenClaw/GatewayConnection.swift @@ -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 @@ -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) diff --git a/src/code-cockpit/runtime.ts b/src/code-cockpit/runtime.ts index d1288aed8..a65b28774 100644 --- a/src/code-cockpit/runtime.ts +++ b/src/code-cockpit/runtime.ts @@ -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, @@ -1740,6 +1746,26 @@ class CodeCockpitRuntime { return { action: "started", task: refreshedTask, worker: started.worker, run: started.run }; } + async saveLayout(params: SaveCodeProjectLayoutInput): Promise { + await this.ensureInitialized(); + return await saveCodeProjectLayout(params); + } + + async listLayouts(params: { projectRoot: string }): Promise { + await this.ensureInitialized(); + return await listCodeProjectLayouts(params.projectRoot); + } + + async getActiveLayout(params: { projectRoot: string }): Promise { + await this.ensureInitialized(); + return await getActiveCodeProjectLayout(params.projectRoot); + } + + async deleteLayout(params: { layoutId: string }): Promise { + await this.ensureInitialized(); + return await deleteCodeProjectLayout(params.layoutId); + } + async getWorkspaceSummary(): Promise { await this.ensureInitialized(); return await getCodeCockpitWorkspaceSummary(); diff --git a/src/code-cockpit/store.test.ts b/src/code-cockpit/store.test.ts index 08f91d285..8ff7e3e28 100644 --- a/src/code-cockpit/store.test.ts +++ b/src/code-cockpit/store.test.ts @@ -415,4 +415,146 @@ describe("code cockpit store", () => { ]), ); }); + + it("saves, lists, and deletes project layouts", async () => { + const storeModule = await importStoreModule(); + + const layout = await storeModule.saveCodeProjectLayout({ + projectRoot: "/tmp/openclaw", + name: "default", + lanes: [ + { id: "lane_1", type: "worker", order: 0 }, + { id: "lane_2", type: "worker", order: 1 }, + { id: "lane_3", type: "worker", order: 2 }, + { id: "lane_4", type: "review", order: 3 }, + ], + }); + + expect(layout.id).toMatch(/^layout_/); + expect(layout.projectRoot).toBe("/tmp/openclaw"); + expect(layout.name).toBe("default"); + expect(layout.lanes).toHaveLength(4); + expect(layout.isActive).toBe(true); + + const layouts = await storeModule.listCodeProjectLayouts("/tmp/openclaw"); + expect(layouts).toHaveLength(1); + expect(layouts[0].id).toBe(layout.id); + + const active = await storeModule.getActiveCodeProjectLayout("/tmp/openclaw"); + expect(active).not.toBeNull(); + expect(active!.id).toBe(layout.id); + + await storeModule.deleteCodeProjectLayout(layout.id); + const afterDelete = await storeModule.listCodeProjectLayouts("/tmp/openclaw"); + expect(afterDelete).toHaveLength(0); + }); + + it("upserts layouts by projectRoot + name", async () => { + const storeModule = await importStoreModule(); + + const first = await storeModule.saveCodeProjectLayout({ + projectRoot: "/tmp/openclaw", + name: "default", + lanes: [{ id: "lane_1", type: "worker", order: 0 }], + }); + + const updated = await storeModule.saveCodeProjectLayout({ + projectRoot: "/tmp/openclaw", + name: "default", + lanes: [ + { id: "lane_1", type: "worker", order: 0 }, + { id: "lane_2", type: "review", order: 1 }, + ], + }); + + expect(updated.id).toBe(first.id); + expect(updated.lanes).toHaveLength(2); + + const layouts = await storeModule.listCodeProjectLayouts("/tmp/openclaw"); + expect(layouts).toHaveLength(1); + }); + + it("deactivates other layouts for same project when saving an active layout", async () => { + const storeModule = await importStoreModule(); + + const first = await storeModule.saveCodeProjectLayout({ + projectRoot: "/tmp/openclaw", + name: "wide", + lanes: [{ id: "lane_1", type: "worker", order: 0 }], + isActive: true, + }); + + const second = await storeModule.saveCodeProjectLayout({ + projectRoot: "/tmp/openclaw", + name: "narrow", + lanes: [{ id: "lane_2", type: "terminal", order: 0 }], + isActive: true, + }); + + const layouts = await storeModule.listCodeProjectLayouts("/tmp/openclaw"); + const wide = layouts.find((l) => l.id === first.id)!; + const narrow = layouts.find((l) => l.id === second.id)!; + expect(wide.isActive).toBe(false); + expect(narrow.isActive).toBe(true); + }); + + it("validates layout lane types", async () => { + const storeModule = await importStoreModule(); + + await expect( + storeModule.saveCodeProjectLayout({ + projectRoot: "/tmp/openclaw", + name: "bad", + lanes: [{ id: "lane_1", type: "invalid" as never, order: 0 }], + }), + ).rejects.toThrow(/Invalid layout lane type/); + }); + + it("requires at least one lane", async () => { + const storeModule = await importStoreModule(); + + await expect( + storeModule.saveCodeProjectLayout({ + projectRoot: "/tmp/openclaw", + name: "empty", + lanes: [], + }), + ).rejects.toThrow(/At least one layout lane is required/); + }); + + it("returns empty list for unknown project root", async () => { + const storeModule = await importStoreModule(); + const layouts = await storeModule.listCodeProjectLayouts("/does/not/exist"); + expect(layouts).toHaveLength(0); + + const active = await storeModule.getActiveCodeProjectLayout("/does/not/exist"); + expect(active).toBeNull(); + }); + + it("throws when deleting a non-existent layout", async () => { + const storeModule = await importStoreModule(); + await expect(storeModule.deleteCodeProjectLayout("layout_nonexistent")).rejects.toThrow( + /Layout "layout_nonexistent" not found/, + ); + }); + + it("persists layouts across store reloads", async () => { + const storeModule = await importStoreModule(); + + await storeModule.saveCodeProjectLayout({ + projectRoot: "/tmp/openclaw", + name: "persistent", + lanes: [ + { id: "lane_1", type: "worker", order: 0 }, + { id: "lane_2", type: "review", order: 1 }, + ], + }); + + // Re-import to force a fresh read from disk + const freshModule = await importStoreModule(); + const layouts = await freshModule.listCodeProjectLayouts("/tmp/openclaw"); + expect(layouts).toHaveLength(1); + expect(layouts[0].name).toBe("persistent"); + expect(layouts[0].lanes).toHaveLength(2); + }); }); diff --git a/src/code-cockpit/store.ts b/src/code-cockpit/store.ts index 90f8d744f..20e08d450 100644 --- a/src/code-cockpit/store.ts +++ b/src/code-cockpit/store.ts @@ -52,6 +52,8 @@ export const CODE_REVIEW_STATUSES = [ export const CODE_CONTEXT_SNAPSHOT_KINDS = ["repo", "obsidian", "brief", "handoff"] as const; +export const CODE_LAYOUT_LANE_TYPES = ["worker", "review", "terminal"] as const; + export const CODE_RUN_STATUSES = ["queued", "running", "succeeded", "failed", "cancelled"] as const; export type CodeTaskStatus = (typeof CODE_TASK_STATUSES)[number]; @@ -64,6 +66,7 @@ export type CodePullRequestState = (typeof CODE_PULL_REQUEST_STATES)[number]; export type CodeReviewStatus = (typeof CODE_REVIEW_STATUSES)[number]; export type CodeContextSnapshotKind = (typeof CODE_CONTEXT_SNAPSHOT_KINDS)[number]; export type CodeRunStatus = (typeof CODE_RUN_STATUSES)[number]; +export type CodeLayoutLaneType = (typeof CODE_LAYOUT_LANE_TYPES)[number]; export type CodeTask = { id: string; @@ -170,6 +173,26 @@ export type CodeRun = { updatedAt: string; }; +export type CodeLayoutLane = { + id: string; + type: CodeLayoutLaneType; + label?: string; + order: number; + widthFraction?: number; + worktreeBinding?: string; + backendId?: string; +}; + +export type CodeProjectLayout = { + id: string; + projectRoot: string; + name: string; + lanes: CodeLayoutLane[]; + isActive: boolean; + createdAt: string; + updatedAt: string; +}; + export type CodeCockpitStore = { version: number; updatedAt: string; @@ -179,6 +202,7 @@ export type CodeCockpitStore = { decisions: CodeDecisionLog[]; contextSnapshots: CodeContextSnapshot[]; runs: CodeRun[]; + projectLayouts?: CodeProjectLayout[]; }; export type CodeCockpitSummary = { @@ -348,6 +372,13 @@ export type UpdateCodeTaskInput = { lastOperatorHint?: string | null; }; +export type SaveCodeProjectLayoutInput = { + projectRoot: string; + name: string; + lanes: CodeLayoutLane[]; + isActive?: boolean; +}; + export type UpdateCodeRunInput = { status?: CodeRunStatus; summary?: string | null; @@ -415,6 +446,7 @@ function createEmptyStore(updatedAt: string): CodeCockpitStore { decisions: [], contextSnapshots: [], runs: [], + projectLayouts: [], }; } @@ -469,6 +501,7 @@ function normalizeStore( decisions: Array.isArray(candidate.decisions) ? candidate.decisions : [], contextSnapshots: Array.isArray(candidate.contextSnapshots) ? candidate.contextSnapshots : [], runs: Array.isArray(candidate.runs) ? candidate.runs : [], + projectLayouts: Array.isArray(candidate.projectLayouts) ? candidate.projectLayouts : [], }; } @@ -1215,6 +1248,130 @@ export async function updateCodeRun( }); } +function assertLayoutLaneType(value: string): CodeLayoutLaneType { + if ((CODE_LAYOUT_LANE_TYPES as readonly string[]).includes(value)) { + return value as CodeLayoutLaneType; + } + throw new Error( + `Invalid layout lane type "${value}". Expected one of: ${CODE_LAYOUT_LANE_TYPES.join(", ")}`, + ); +} + +function normalizeLayoutLanes(lanes: CodeLayoutLane[]): CodeLayoutLane[] { + return lanes.map((lane, index) => ({ + id: normalizeString(lane.id) ?? createId("lane"), + type: assertLayoutLaneType(lane.type), + label: normalizeString(lane.label), + order: typeof lane.order === "number" && Number.isFinite(lane.order) ? lane.order : index, + widthFraction: + typeof lane.widthFraction === "number" && lane.widthFraction > 0 && lane.widthFraction <= 1 + ? lane.widthFraction + : undefined, + worktreeBinding: normalizeString(lane.worktreeBinding), + backendId: normalizeString(lane.backendId), + })); +} + +export async function saveCodeProjectLayout( + input: SaveCodeProjectLayoutInput, + options?: CodeCockpitStoreOptions, +): Promise { + const projectRoot = normalizeString(input.projectRoot); + if (!projectRoot) { + throw new Error("Project root is required"); + } + const name = normalizeString(input.name); + if (!name) { + throw new Error("Layout name is required"); + } + if (!Array.isArray(input.lanes) || input.lanes.length === 0) { + throw new Error("At least one layout lane is required"); + } + const lanes = normalizeLayoutLanes(input.lanes); + return await mutateStore(options, (store, updatedAt) => { + const layouts = store.projectLayouts ?? []; + store.projectLayouts = layouts; + + // Upsert: match on projectRoot + name + const existing = layouts.find( + (layout) => layout.projectRoot === projectRoot && layout.name === name, + ); + if (existing) { + existing.lanes = lanes; + existing.updatedAt = updatedAt; + if (input.isActive !== undefined) { + existing.isActive = input.isActive; + } + if (existing.isActive) { + for (const other of layouts) { + if (other !== existing && other.projectRoot === projectRoot) { + other.isActive = false; + } + } + } + return existing; + } + + const isActive = input.isActive ?? true; + if (isActive) { + for (const other of layouts) { + if (other.projectRoot === projectRoot) { + other.isActive = false; + } + } + } + + const layout: CodeProjectLayout = { + id: createId("layout"), + projectRoot, + name, + lanes, + isActive, + createdAt: updatedAt, + updatedAt, + }; + layouts.push(layout); + return layout; + }); +} + +export async function listCodeProjectLayouts( + projectRoot: string, + options?: CodeCockpitStoreOptions, +): Promise { + const store = await loadCodeCockpitStore(options); + const normalized = normalizeString(projectRoot); + if (!normalized) { + return []; + } + return (store.projectLayouts ?? []) + .filter((layout) => layout.projectRoot === normalized) + .toSorted((left, right) => right.updatedAt.localeCompare(left.updatedAt)); +} + +export async function getActiveCodeProjectLayout( + projectRoot: string, + options?: CodeCockpitStoreOptions, +): Promise { + const layouts = await listCodeProjectLayouts(projectRoot, options); + return layouts.find((layout) => layout.isActive) ?? layouts[0] ?? null; +} + +export async function deleteCodeProjectLayout( + layoutId: string, + options?: CodeCockpitStoreOptions, +): Promise { + await mutateStore(options, (store) => { + const layouts = store.projectLayouts ?? []; + const index = layouts.findIndex((layout) => layout.id === layoutId); + if (index === -1) { + throw new Error(`Layout "${layoutId}" not found`); + } + layouts.splice(index, 1); + store.projectLayouts = layouts; + }); +} + export async function getCodeTask( taskId: string, options?: CodeCockpitStoreOptions, diff --git a/src/gateway/server-methods/code-cockpit.ts b/src/gateway/server-methods/code-cockpit.ts index 73f9f1e1f..17c589675 100644 --- a/src/gateway/server-methods/code-cockpit.ts +++ b/src/gateway/server-methods/code-cockpit.ts @@ -253,4 +253,43 @@ export const codeCockpitHandlers: GatewayRequestHandlers = { }), ); }, + "code.layout.save": async ({ params, respond }) => { + await withRuntimeResult(respond, async () => { + const projectRoot = requireTitle(params.projectRoot, "projectRoot"); + const name = requireTitle(params.name, "name"); + const lanes = Array.isArray(params.lanes) ? params.lanes : []; + return await getCodeCockpitRuntime().saveLayout({ + projectRoot, + name, + lanes, + isActive: typeof params.isActive === "boolean" ? params.isActive : undefined, + }); + }); + }, + "code.layout.list": async ({ params, respond }) => { + await withRuntimeResult( + respond, + async () => + await getCodeCockpitRuntime().listLayouts({ + projectRoot: requireTitle(params.projectRoot, "projectRoot"), + }), + ); + }, + "code.layout.active": async ({ params, respond }) => { + await withRuntimeResult( + respond, + async () => + await getCodeCockpitRuntime().getActiveLayout({ + projectRoot: requireTitle(params.projectRoot, "projectRoot"), + }), + ); + }, + "code.layout.delete": async ({ params, respond }) => { + await withRuntimeResult(respond, async () => { + await getCodeCockpitRuntime().deleteLayout({ + layoutId: requireTitle(params.layoutId, "layoutId"), + }); + return { deleted: true }; + }); + }, };