feat(canvas): default tier T3 and hide T1/T2 on SaaS

On SaaS every workspace gets its own EC2 VM — the Docker-sandbox
distinction between T1 (sandboxed), T2 (standard Docker), and T3
(full host access) doesn't apply. A SaaS workspace is always a
dedicated VM, which is "full access" by construction. Showing T1/T2
in that UI is a category error: users pick a sandbox level that has
no effect on the actual EC2 machine they get.

Changes:
- tenant.ts: export isSaaSTenant() — returns true when canvas is
  served at <slug>.moleculesai.app (SSR-safe: false on server)
- CreateWorkspaceDialog: when isSaaSTenant(), render only the T3
  option, default tier=3, grid collapses to a single column. Label
  gets a " — dedicated VM" hint so the user knows what they're
  getting. On self-hosted the full T1/T2/T3 picker is unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Hongming Wang 2026-04-22 17:02:48 -07:00
parent 2b603164de
commit 8b1af9708c
2 changed files with 45 additions and 11 deletions

View File

@ -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,29 @@ 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 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.
const isSaaS = useMemo(() => isSaaSTenant(), []);
const TIERS = useMemo(
() =>
isSaaS
? [{ value: 3, label: "T3", desc: "Full Access" }]
: [
{ value: 1, label: "T1", desc: "Sandboxed" },
{ value: 2, label: "T2", desc: "Standard" },
{ value: 3, label: "T3", desc: "Full Access" },
],
[isSaaS],
);
const defaultTier = isSaaS ? 3 : 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 +101,7 @@ export function CreateWorkspaceButton() {
if (!open) return;
setName("");
setRole("");
setTier(1);
setTier(defaultTier);
setTemplate("");
setParentId("");
setBudgetLimit("");
@ -96,6 +112,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 +228,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-3"}`}
>
<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-3"}`}>
Tier{isSaaS ? " — dedicated VM" : ""}
</div>
{TIERS.map((t, idx) => (
<button

View File

@ -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 falsetrue on first client render.
*/
export function isSaaSTenant(): boolean {
if (typeof window === "undefined") return false;
return getTenantSlug() !== "";
}