From 0574e7c1d0750e471c42b24807e879a244154452 Mon Sep 17 00:00:00 2001 From: Hongming Wang Date: Wed, 22 Apr 2026 18:17:13 -0700 Subject: [PATCH] feat(canvas): add T4 tier (full-host access); SaaS default T4 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Following feedback that T4 — not T3 — is the full-access tier: - Non-SaaS picker now shows all four tiers: T1 Sandboxed, T2 Standard, T3 Privileged, T4 Full Access. Four-column grid. - SaaS picker stays single-option but now locks to T4 (was T3). Every SaaS workspace gets a dedicated EC2 VM, which is unambiguously the "full host" case — T3 (privileged container) was a category mismatch. - Default tier on SaaS is 4 (was 3). CP provisioner already supports tier 4 (t3.large / 80 GB). TIER_CONFIG already has T4's amber color. Tests updated for the four-tier picker: wrap tests now go T4 ↔ T1, and the selection/tabIndex tests cover the fourth button. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../src/components/CreateWorkspaceDialog.tsx | 24 ++++++++++------- .../CreateWorkspaceDialog.a11y.test.tsx | 26 +++++++++++-------- 2 files changed, 29 insertions(+), 21 deletions(-) diff --git a/canvas/src/components/CreateWorkspaceDialog.tsx b/canvas/src/components/CreateWorkspaceDialog.tsx index bcd8d040..b6c71960 100644 --- a/canvas/src/components/CreateWorkspaceDialog.tsx +++ b/canvas/src/components/CreateWorkspaceDialog.tsx @@ -52,24 +52,28 @@ export function CreateWorkspaceButton() { const [hermesApiKey, setHermesApiKey] = useState(""); // Tier picker: on SaaS every workspace gets its own EC2 VM (Full Access - // by construction), so we hide the T1/T2 sandbox options and lock to T3. - // On self-hosted we still offer all three because Docker-sandbox tiers - // are a real choice there. useMemo is SSR-safe via the isSaaSTenant() - // contract (returns false on server); first client render may flip the - // picker — acceptable one-frame reflow, no data loss. + // by construction), so we hide the T1/T2/T3 Docker-sandbox tiers and + // lock to T4 — the full-host access tier, which maps to t3.large at the + // CP level. On self-hosted we still offer T1/T2/T3 because the Docker- + // sandbox distinction is a real choice there; T4 is available too for + // operators who want the full-host tier. + // + // SSR-safe via isSaaSTenant() contract (returns false on server); first + // client render may flip the picker — acceptable one-frame reflow. const isSaaS = useMemo(() => isSaaSTenant(), []); const TIERS = useMemo( () => isSaaS - ? [{ value: 3, label: "T3", desc: "Full Access" }] + ? [{ value: 4, label: "T4", desc: "Full Access" }] : [ { value: 1, label: "T1", desc: "Sandboxed" }, { value: 2, label: "T2", desc: "Standard" }, - { value: 3, label: "T3", desc: "Full Access" }, + { value: 3, label: "T3", desc: "Privileged" }, + { value: 4, label: "T4", desc: "Full Access" }, ], [isSaaS], ); - const defaultTier = isSaaS ? 3 : 1; + const defaultTier = isSaaS ? 4 : 1; const [tier, setTier] = useState(defaultTier); // Refs for roving tabIndex on the tier radio group (WCAG 2.1 arrow-key nav) @@ -228,9 +232,9 @@ export function CreateWorkspaceButton() {
-
+
Tier{isSaaS ? " — dedicated VM" : ""}
{TIERS.map((t, idx) => ( diff --git a/canvas/src/components/__tests__/CreateWorkspaceDialog.a11y.test.tsx b/canvas/src/components/__tests__/CreateWorkspaceDialog.a11y.test.tsx index 6f42037c..d370a9cc 100644 --- a/canvas/src/components/__tests__/CreateWorkspaceDialog.a11y.test.tsx +++ b/canvas/src/components/__tests__/CreateWorkspaceDialog.a11y.test.tsx @@ -77,7 +77,9 @@ describe("CreateWorkspaceDialog — accessibility", () => { it("tier buttons have role=radio and aria-checked reflects selection", async () => { await openDialog(); const radios = screen.getAllByRole("radio"); - expect(radios.length).toBe(3); + // Non-SaaS build (jsdom hostname is localhost) shows all four tiers: + // T1 Sandboxed, T2 Standard, T3 Privileged, T4 Full Access. + expect(radios.length).toBe(4); // T1 is default selection const t1 = radios.find((r) => r.textContent?.includes("T1")); const t2 = radios.find((r) => r.textContent?.includes("T2")); @@ -98,10 +100,12 @@ describe("CreateWorkspaceDialog — accessibility", () => { const t1 = radios.find((r) => r.textContent?.includes("T1"))!; const t2 = radios.find((r) => r.textContent?.includes("T2"))!; const t3 = radios.find((r) => r.textContent?.includes("T3"))!; - // T1 is default selected + const t4 = radios.find((r) => r.textContent?.includes("T4"))!; + // T1 is default selected (non-SaaS test env; SaaS would default to T4) expect(t1.getAttribute("tabindex")).toBe("0"); expect(t2.getAttribute("tabindex")).toBe("-1"); expect(t3.getAttribute("tabindex")).toBe("-1"); + expect(t4.getAttribute("tabindex")).toBe("-1"); }); it("ArrowDown moves selection from T1 to T2", async () => { @@ -127,15 +131,15 @@ describe("CreateWorkspaceDialog — accessibility", () => { await waitFor(() => expect(t3.getAttribute("aria-checked")).toBe("true")); }); - it("ArrowDown wraps from T3 back to T1", async () => { + it("ArrowDown wraps from T4 back to T1", async () => { await openDialog(); const radios = screen.getAllByRole("radio"); const t1 = radios.find((r) => r.textContent?.includes("T1"))!; - const t3 = radios.find((r) => r.textContent?.includes("T3"))!; - fireEvent.click(t3); // select T3 first - await waitFor(() => expect(t3.getAttribute("aria-checked")).toBe("true")); - t3.focus(); - fireEvent.keyDown(t3, { key: "ArrowDown" }); + const t4 = radios.find((r) => r.textContent?.includes("T4"))!; + fireEvent.click(t4); // select T4 (last) first + await waitFor(() => expect(t4.getAttribute("aria-checked")).toBe("true")); + t4.focus(); + fireEvent.keyDown(t4, { key: "ArrowDown" }); await waitFor(() => expect(t1.getAttribute("aria-checked")).toBe("true")); }); @@ -151,14 +155,14 @@ describe("CreateWorkspaceDialog — accessibility", () => { await waitFor(() => expect(t1.getAttribute("aria-checked")).toBe("true")); }); - it("ArrowLeft wraps from T1 back to T3", async () => { + it("ArrowLeft wraps from T1 back to T4", async () => { await openDialog(); const radios = screen.getAllByRole("radio"); const t1 = radios.find((r) => r.textContent?.includes("T1"))!; - const t3 = radios.find((r) => r.textContent?.includes("T3"))!; + const t4 = radios.find((r) => r.textContent?.includes("T4"))!; t1.focus(); fireEvent.keyDown(t1, { key: "ArrowLeft" }); - await waitFor(() => expect(t3.getAttribute("aria-checked")).toBe("true")); + await waitFor(() => expect(t4.getAttribute("aria-checked")).toBe("true")); }); });