From 2be87e66a99439fdbf3de7da93729ca1204d1924 Mon Sep 17 00:00:00 2001 From: fullstack-engineer Date: Fri, 22 May 2026 23:32:47 -0700 Subject: [PATCH] Add container config tab skeleton --- canvas/src/components/SidePanel.tsx | 3 + .../__tests__/SidePanel.tabs.test.tsx | 18 ++-- .../components/tabs/ContainerConfigTab.tsx | 96 +++++++++++++++++++ .../__tests__/ContainerConfigTab.test.tsx | 42 ++++++++ canvas/src/store/canvas-topology.ts | 2 + canvas/src/store/canvas.ts | 4 +- canvas/src/store/socket.ts | 2 + 7 files changed, 160 insertions(+), 7 deletions(-) create mode 100644 canvas/src/components/tabs/ContainerConfigTab.tsx create mode 100644 canvas/src/components/tabs/__tests__/ContainerConfigTab.test.tsx diff --git a/canvas/src/components/SidePanel.tsx b/canvas/src/components/SidePanel.tsx index 8ae568753..a3e2793b4 100644 --- a/canvas/src/components/SidePanel.tsx +++ b/canvas/src/components/SidePanel.tsx @@ -9,6 +9,7 @@ import { DetailsTab } from "./tabs/DetailsTab"; import { SkillsTab } from "./tabs/SkillsTab"; import { ChatTab } from "./tabs/ChatTab"; import { ConfigTab } from "./tabs/ConfigTab"; +import { ContainerConfigTab } from "./tabs/ContainerConfigTab"; import { DisplayTab } from "./tabs/DisplayTab"; import { TerminalTab } from "./tabs/TerminalTab"; import { FilesTab } from "./tabs/FilesTab"; @@ -33,6 +34,7 @@ const TABS: { id: PanelTab; label: string; icon: string }[] = [ { id: "skills", label: "Plugins", icon: "✦" }, { id: "terminal", label: "Terminal", icon: "▸" }, { id: "display", label: "Display", icon: "▣" }, + { id: "container-config", label: "Container", icon: "▤" }, { id: "config", label: "Config", icon: "⚙" }, { id: "schedule", label: "Schedule", icon: "⏲" }, { id: "channels", label: "Channels", icon: "⇌" }, @@ -303,6 +305,7 @@ export function SidePanel() { {panelTab === "chat" && } {panelTab === "terminal" && } {panelTab === "display" && } + {panelTab === "container-config" && } {panelTab === "config" && } {panelTab === "schedule" && } {panelTab === "channels" && } diff --git a/canvas/src/components/__tests__/SidePanel.tabs.test.tsx b/canvas/src/components/__tests__/SidePanel.tabs.test.tsx index f9d1e4fe2..2f0910076 100644 --- a/canvas/src/components/__tests__/SidePanel.tabs.test.tsx +++ b/canvas/src/components/__tests__/SidePanel.tabs.test.tsx @@ -11,6 +11,7 @@ vi.mock("../tabs/DetailsTab", () => ({ DetailsTab: () => null })); vi.mock("../tabs/SkillsTab", () => ({ SkillsTab: () => null })); vi.mock("../tabs/ChatTab", () => ({ ChatTab: () => null })); vi.mock("../tabs/ConfigTab", () => ({ ConfigTab: () => null })); +vi.mock("../tabs/ContainerConfigTab", () => ({ ContainerConfigTab: () => null })); vi.mock("../tabs/DisplayTab", () => ({ DisplayTab: () => null })); vi.mock("../tabs/TerminalTab", () => ({ TerminalTab: () => null })); vi.mock("../tabs/FilesTab", () => ({ FilesTab: () => null })); @@ -75,7 +76,7 @@ import { SidePanel } from "../SidePanel"; const TABS = [ "chat", "activity", "details", "skills", "terminal", - "display", "config", "schedule", "channels", "files", "memory", "traces", "events", "audit", + "display", "container-config", "config", "schedule", "channels", "files", "memory", "traces", "events", "audit", ]; describe("SidePanel — ARIA tablist pattern", () => { @@ -86,10 +87,10 @@ describe("SidePanel — ARIA tablist pattern", () => { expect(tablist.getAttribute("aria-label")).toBe("Workspace panel tabs"); }); - it("renders exactly 14 tab buttons", () => { + it("renders exactly 15 tab buttons", () => { render(); const tabs = screen.getAllByRole("tab"); - expect(tabs.length).toBe(14); + expect(tabs.length).toBe(15); }); it("renders the Display tab", () => { @@ -97,6 +98,11 @@ describe("SidePanel — ARIA tablist pattern", () => { expect(document.getElementById("tab-display")).toBeTruthy(); }); + it("renders the Container Config tab", () => { + render(); + expect(document.getElementById("tab-container-config")).toBeTruthy(); + }); + it("active tab (chat) has aria-selected='true'", () => { render(); const chatTab = screen.getAllByRole("tab").find( @@ -105,11 +111,11 @@ describe("SidePanel — ARIA tablist pattern", () => { expect(chatTab?.getAttribute("aria-selected")).toBe("true"); }); - it("all other 13 tabs have aria-selected='false'", () => { + it("all other 14 tabs have aria-selected='false'", () => { render(); const tabs = screen.getAllByRole("tab"); const inactive = tabs.filter((t) => t.id !== "tab-chat"); - expect(inactive.length).toBe(13); + expect(inactive.length).toBe(14); for (const tab of inactive) { expect(tab.getAttribute("aria-selected")).toBe("false"); } @@ -122,7 +128,7 @@ describe("SidePanel — ARIA tablist pattern", () => { const minusOnes = tabs.filter((t) => t.getAttribute("tabindex") === "-1"); expect(zeros.length).toBe(1); expect(zeros[0].id).toBe("tab-chat"); - expect(minusOnes.length).toBe(13); + expect(minusOnes.length).toBe(14); }); it("active tab has aria-controls='panel-chat' and id='tab-chat'", () => { diff --git a/canvas/src/components/tabs/ContainerConfigTab.tsx b/canvas/src/components/tabs/ContainerConfigTab.tsx new file mode 100644 index 000000000..0b9de1572 --- /dev/null +++ b/canvas/src/components/tabs/ContainerConfigTab.tsx @@ -0,0 +1,96 @@ +"use client"; + +import { runtimeDisplayName } from "@/lib/runtime-names"; +import type { WorkspaceNodeData } from "@/store/canvas"; + +type Props = { + data: Pick< + WorkspaceNodeData, + "runtime" | "status" | "needsRestart" | "activeTasks" | "deliveryMode" + | "workspaceAccess" | "maxConcurrentTasks" + >; +}; + +export function ContainerConfigTab({ data }: Props) { + const runtime = data.runtime || "unknown"; + const workspaceAccess = formatAccess(data.workspaceAccess); + const maxConcurrentTasks = data.maxConcurrentTasks ? String(data.maxConcurrentTasks) : "platform-managed"; + const mountedPath = "/workspace"; + const privilegeStatus = "standard"; + const deliveryMode = data.deliveryMode || "push"; + + return ( +
+
+
+

Container Config

+
+ +
+ + + + + + +
+
+ +
+

Session Controls

+
+ + +
+
+ +
+

Status

+
+ + + +
+
+
+ ); +} + +function formatAccess(value: string | null | undefined): string { + if (!value) return "none"; + return value.replace(/_/g, "-"); +} + +function ConfigRow({ + label, + value, + detail, +}: { + label: string; + value: string; + detail?: string; +}) { + return ( +
+
{label}
+
+
{value}
+ {detail && detail !== value && ( +
{detail}
+ )} +
+
+ ); +} + +function ReadOnlyAction({ label }: { label: string }) { + return ( + + ); +} diff --git a/canvas/src/components/tabs/__tests__/ContainerConfigTab.test.tsx b/canvas/src/components/tabs/__tests__/ContainerConfigTab.test.tsx new file mode 100644 index 000000000..9d89abbd0 --- /dev/null +++ b/canvas/src/components/tabs/__tests__/ContainerConfigTab.test.tsx @@ -0,0 +1,42 @@ +// @vitest-environment jsdom +import { cleanup, render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +vi.mock("@/lib/runtime-names", () => ({ + runtimeDisplayName: (runtime: string) => runtime, +})); + +import { ContainerConfigTab } from "../ContainerConfigTab"; + +afterEach(() => { + cleanup(); +}); + +describe("ContainerConfigTab", () => { + it("renders read-only runtime and container settings separate from compute shape", () => { + render( + , + ); + + expect(screen.getByText("Runtime image")).toBeTruthy(); + expect(screen.getByText("claude-code")).toBeTruthy(); + expect(screen.getByText("Workspace access")).toBeTruthy(); + expect(screen.getByText("read-write")).toBeTruthy(); + expect(screen.getByText("Max concurrent tasks")).toBeTruthy(); + expect(screen.getByText("3")).toBeTruthy(); + expect(screen.getByText("/workspace")).toBeTruthy(); + expect(screen.getByText("Container privileges")).toBeTruthy(); + expect(screen.queryByText("Instance type")).toBeNull(); + expect(screen.queryByText("Root volume")).toBeNull(); + }); +}); diff --git a/canvas/src/store/canvas-topology.ts b/canvas/src/store/canvas-topology.ts index ec67282f2..5fae2f352 100644 --- a/canvas/src/store/canvas-topology.ts +++ b/canvas/src/store/canvas-topology.ts @@ -513,6 +513,8 @@ export function buildNodesAndEdges( parentId: ws.parent_id, currentTask: ws.current_task || "", runtime: ws.runtime || "", + workspaceAccess: ws.workspace_access, + maxConcurrentTasks: ws.max_concurrent_tasks ?? null, needsRestart: false, budgetLimit: ws.budget_limit ?? null, budgetUsed: ws.budget_used ?? null, diff --git a/canvas/src/store/canvas.ts b/canvas/src/store/canvas.ts index 6bdabf715..9b1484f6f 100644 --- a/canvas/src/store/canvas.ts +++ b/canvas/src/store/canvas.ts @@ -88,6 +88,8 @@ export interface WorkspaceNodeData extends Record { parentId: string | null; currentTask: string; runtime: string; + workspaceAccess?: string | null; + maxConcurrentTasks?: number | null; needsRestart: boolean; /** USD spend ceiling set by the user; null = unlimited. Added by issue #541. */ budgetLimit: number | null; @@ -130,7 +132,7 @@ export interface WorkspaceNodeData extends Record { deliveryMode?: string; } -export type PanelTab = "details" | "skills" | "chat" | "terminal" | "display" | "config" | "schedule" | "channels" | "files" | "memory" | "traces" | "events" | "activity" | "audit"; +export type PanelTab = "details" | "skills" | "chat" | "terminal" | "display" | "container-config" | "config" | "schedule" | "channels" | "files" | "memory" | "traces" | "events" | "activity" | "audit"; export interface ContextMenuState { x: number; diff --git a/canvas/src/store/socket.ts b/canvas/src/store/socket.ts index a4d1f97d9..0ed6c57e0 100644 --- a/canvas/src/store/socket.ts +++ b/canvas/src/store/socket.ts @@ -320,11 +320,13 @@ export interface WorkspaceData { url: string; parent_id: string | null; active_tasks: number; + max_concurrent_tasks?: number | null; last_error_rate: number; last_sample_error: string; uptime_seconds: number; current_task: string; runtime: string; + workspace_access?: string | null; x: number; y: number; collapsed: boolean; -- 2.52.0