forked from molecule-ai/molecule-core
Merge pull request #1693 from Molecule-AI/feat/saas-tier-default-t3
feat(canvas): add T4 tier (full-host) + default T4 on SaaS
This commit is contained in:
commit
e8523d7e02
@ -1,8 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useRef, useCallback, useId } from "react";
|
||||
import { useState, useEffect, useRef, useCallback, useId, useMemo } from "react";
|
||||
import * as Dialog from "@radix-ui/react-dialog";
|
||||
import { api } from "@/lib/api";
|
||||
import { isSaaSTenant } from "@/lib/tenant";
|
||||
|
||||
interface WorkspaceOption {
|
||||
id: string;
|
||||
@ -39,7 +40,6 @@ export function CreateWorkspaceButton() {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [name, setName] = useState("");
|
||||
const [role, setRole] = useState("");
|
||||
const [tier, setTier] = useState(1);
|
||||
const [template, setTemplate] = useState("");
|
||||
const [parentId, setParentId] = useState("");
|
||||
const [budgetLimit, setBudgetLimit] = useState("");
|
||||
@ -51,13 +51,33 @@ export function CreateWorkspaceButton() {
|
||||
const [hermesProvider, setHermesProvider] = useState("anthropic");
|
||||
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/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: 4, label: "T4", desc: "Full Access" }]
|
||||
: [
|
||||
{ value: 1, label: "T1", desc: "Sandboxed" },
|
||||
{ value: 2, label: "T2", desc: "Standard" },
|
||||
{ value: 3, label: "T3", desc: "Privileged" },
|
||||
{ value: 4, label: "T4", desc: "Full Access" },
|
||||
],
|
||||
[isSaaS],
|
||||
);
|
||||
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)
|
||||
const radioRefs = useRef<Array<HTMLButtonElement | null>>([]);
|
||||
const TIERS = [
|
||||
{ value: 1, label: "T1", desc: "Sandboxed" },
|
||||
{ value: 2, label: "T2", desc: "Standard" },
|
||||
{ value: 3, label: "T3", desc: "Full Access" },
|
||||
];
|
||||
|
||||
const handleRadioKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent, currentIndex: number) => {
|
||||
@ -85,7 +105,7 @@ export function CreateWorkspaceButton() {
|
||||
if (!open) return;
|
||||
setName("");
|
||||
setRole("");
|
||||
setTier(1);
|
||||
setTier(defaultTier);
|
||||
setTemplate("");
|
||||
setParentId("");
|
||||
setBudgetLimit("");
|
||||
@ -96,6 +116,9 @@ export function CreateWorkspaceButton() {
|
||||
.get<WorkspaceOption[]>("/workspaces")
|
||||
.then((ws) => setWorkspaces(ws))
|
||||
.catch(() => {});
|
||||
// defaultTier is stable for the session (derived from window.location),
|
||||
// safe to omit from deps.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [open]);
|
||||
|
||||
const handleCreate = async () => {
|
||||
@ -209,10 +232,10 @@ export function CreateWorkspaceButton() {
|
||||
<div
|
||||
role="radiogroup"
|
||||
aria-label="Workspace tier"
|
||||
className="grid grid-cols-3 gap-1.5"
|
||||
className={`grid gap-1.5 ${isSaaS ? "grid-cols-1" : "grid-cols-4"}`}
|
||||
>
|
||||
<div className="col-span-3 text-[11px] text-zinc-400 mb-1">
|
||||
Tier
|
||||
<div className={`text-[11px] text-zinc-400 mb-1 ${isSaaS ? "" : "col-span-4"}`}>
|
||||
Tier{isSaaS ? " — dedicated VM" : ""}
|
||||
</div>
|
||||
{TIERS.map((t, idx) => (
|
||||
<button
|
||||
|
||||
@ -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"));
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@ -54,3 +54,18 @@ export function getTenantSlug(): string {
|
||||
if (reservedSubdomains.has(slug)) return "";
|
||||
return slug;
|
||||
}
|
||||
|
||||
/**
|
||||
* isSaaSTenant reports whether the canvas is running as the UI for a
|
||||
* SaaS tenant (served at <slug>.moleculesai.app). Use for client-side
|
||||
* UX branches that should behave differently on SaaS vs self-hosted —
|
||||
* e.g. the workspace tier picker hides T1/T2 sandbox tiers because every
|
||||
* SaaS workspace gets its own EC2 VM (inherently T3 Full Access).
|
||||
*
|
||||
* SSR-safe: returns false on the server to avoid hydration drift; call
|
||||
* sites should tolerate a flip from false→true on first client render.
|
||||
*/
|
||||
export function isSaaSTenant(): boolean {
|
||||
if (typeof window === "undefined") return false;
|
||||
return getTenantSlug() !== "";
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user