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
+
+
+
+
+
+
+
+
+ );
+}
+
+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;