Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
f1521ef
docs(canary-release): flag as aspirational; link to current state
Apr 22, 2026
4501252
docs(security): move sensitive runbooks to private internal repo
Apr 22, 2026
edd922f
feat: add tool_trace to activity_logs for platform-level agent observ…
HongmingWang-Rabbit Apr 22, 2026
0ddb0b0
feat: platform instructions system with global/team/workspace scope
HongmingWang-Rabbit Apr 22, 2026
fb81045
ci: trigger CI from PAT push
HongmingWang-Rabbit Apr 22, 2026
bbc1c1b
fix(review): address code review blockers on tool-trace + instructions
HongmingWang-Rabbit Apr 22, 2026
f2c71c7
fix(test): guard msg.metadata assignment for non-Message returns
HongmingWang-Rabbit Apr 22, 2026
8a05724
feat(provision): propagate workspace model into runtime env
Apr 22, 2026
d3842fc
docs(marketing+research): move sensitive strategy + research to inter…
Apr 23, 2026
62a2118
fix(ssrf): honour saasMode for RFC-1918 private IPs
Apr 23, 2026
06aba14
ci: retrigger after retarget to main
Apr 23, 2026
275ee83
fix(orgtoken): cast org_id to text in COALESCE to prevent 500
Apr 23, 2026
4e13596
ci: retrigger after retarget to main
Apr 23, 2026
9ea9ab5
fix(cp-provisioner): use CP_ADMIN_API_TOKEN bearer for /cp/admin/* ro…
Apr 23, 2026
82add13
ci: retrigger after retarget to main
Apr 23, 2026
702f444
feat(files-api): SSH-backed write for SaaS workspaces (fixes 500 dock…
Apr 23, 2026
5d6c1a4
feat(canvas): default tier T3 and hide T1/T2 on SaaS
Apr 23, 2026
afa1b0f
ci: retrigger after retarget to main
Apr 23, 2026
0a87cf0
feat(canvas): add T4 tier (full-host access); SaaS default T4
Apr 23, 2026
cdfe579
refactor(secrets): strip Service dropdown from Add-Key form
Apr 22, 2026
3631ff9
fix(canvas): require hermes model at create + send to CP (fixes silen…
Apr 23, 2026
d42e153
chore: extract ContextMenu Zustand fix + a2a_proxy local-docker SSRF …
Apr 23, 2026
c568a66
feat(workspace): 45-min gh-token refresh daemon + credential helper c…
Apr 23, 2026
466ed72
feat(scheduler): sweepPhantomBusy — clear stuck active_tasks from cra…
Apr 23, 2026
3adad5f
fix(registry): auto-recover failed/provisioning workspaces on success…
Apr 23, 2026
b34c6b7
fix(handlers): unblock Platform (Go) CI — sqlmock budget-check + test…
Apr 23, 2026
3a82b61
fix(workspace): credential helper security hardening
HongmingWang-Rabbit Apr 23, 2026
854f1d7
feat(org-template): SHARED_RULES.md + platform_instructions seeds fro…
HongmingWang-Rabbit Apr 23, 2026
043b442
ci: trigger CI run
HongmingWang-Rabbit Apr 23, 2026
a57a490
ci: add trailing newline to nudge CI trigger
HongmingWang-Rabbit Apr 23, 2026
95133a4
fix(rules): add merge authority + multi-role approval gate (rules 9, …
HongmingWang-Rabbit Apr 23, 2026
19eff59
feat(rules): rules 11-14 — secrets matrix, escalation ladder, task qu…
HongmingWang-Rabbit Apr 23, 2026
bfec9e5
chore(repo): remove org-templates/molecule-dev/ — standalone repo is …
HongmingWang-Rabbit Apr 23, 2026
122032e
feat(rules): seed 4 Philosophy rules into platform_instructions globa…
HongmingWang-Rabbit Apr 23, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 4 additions & 5 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -117,12 +117,11 @@ backups/

# Cloned-via-manifest dirs — populated locally by scripts/clone-manifest.sh,
# tracked in their own standalone repos. Never commit to core.
# org-templates live in Molecule-AI/molecule-ai-org-template-* repos.
# org-templates live in Molecule-AI/molecule-ai-org-template-* repos
# (canonical source of truth; previous molecule-dev exception removed
# so the monorepo doesn't drift from the standalone repo).
# plugins live in Molecule-AI/molecule-ai-plugin-* repos.
# Exception: molecule-dev is checked in so it doubles as the internal-team
# seed template (not fetched via clone-manifest).
/org-templates/*
!/org-templates/molecule-dev/
/org-templates/
/plugins/
/workspace-configs-templates/
# Cloned by publish-workspace-server-image.yml so the Dockerfile's
Expand Down
17 changes: 5 additions & 12 deletions canvas/src/components/ContextMenu.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
"use client";

import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import { useCanvasStore, type WorkspaceNodeData } from "@/store/canvas";
import { api } from "@/lib/api";
import { showToast } from "./Toaster";
Expand All @@ -23,17 +23,9 @@
const setPanelTab = useCanvasStore((s) => s.setPanelTab);
const nestNode = useCanvasStore((s) => s.nestNode);
const contextNodeId = contextMenu?.nodeId ?? null;
// Select the full nodes array (stable reference across unrelated store
// updates) and derive children via useMemo. Filtering inside the
// selector returned a new array every call, which Zustand's
// useSyncExternalStore saw as "snapshot changed" → schedule
// re-render → loop → React error #185. See canvas-store-snapshots.
const nodes = useCanvasStore((s) => s.nodes);
const children = useMemo(
() => (contextNodeId ? nodes.filter((n) => n.data.parentId === contextNodeId) : []),
[nodes, contextNodeId],
const hasChildren = useCanvasStore((s) =>
contextNodeId ? s.nodes.some((n) => n.data.parentId === contextNodeId) : false
);
const hasChildren = children.length > 0;
const setPendingDelete = useCanvasStore((s) => s.setPendingDelete);
const ref = useRef<HTMLDivElement>(null);
const [actionLoading, setActionLoading] = useState(false);
Expand Down Expand Up @@ -174,7 +166,8 @@
// it survives ContextMenu unmount. Closing the menu here avoids the
// prior race where the portal dialog's Confirm click was treated as
// "outside" by the menu's outside-click handler.
setPendingDelete({ id: contextMenu.nodeId, name: contextMenu.nodeData.name, hasChildren, children: children.map(c => ({ id: c.id, name: c.data.name })) });
const childNodes = useCanvasStore.getState().nodes.filter((n) => n.data.parentId === contextMenu.nodeId);

Check failure on line 169 in canvas/src/components/ContextMenu.tsx

View workflow job for this annotation

GitHub Actions / Canvas (Next.js)

Unhandled error

TypeError: useCanvasStore.getState is not a function ❯ src/components/ContextMenu.tsx:169:39 ❯ executeDispatch node_modules/react-dom/cjs/react-dom-client.development.js:19116:9 ❯ runWithFiberInDEV node_modules/react-dom/cjs/react-dom-client.development.js:874:13 ❯ processDispatchQueue node_modules/react-dom/cjs/react-dom-client.development.js:19166:19 ❯ node_modules/react-dom/cjs/react-dom-client.development.js:19767:9 ❯ batchedUpdates$1 node_modules/react-dom/cjs/react-dom-client.development.js:3255:40 ❯ dispatchEventForPluginEventSystem node_modules/react-dom/cjs/react-dom-client.development.js:19320:7 ❯ dispatchEvent node_modules/react-dom/cjs/react-dom-client.development.js:23585:11 ❯ dispatchDiscreteEvent node_modules/react-dom/cjs/react-dom-client.development.js:23553:11 ❯ HTMLDivElement.callTheUserObjectsOperation node_modules/jsdom/lib/jsdom/living/generated/EventListener.js:26:30 This error originated in "src/components/__tests__/ContextMenu.keyboard.test.tsx" test file. It doesn't mean the error was thrown inside the file itself, but while it was running. The latest test that might've caused the error is "clicking 'Delete' hoists state to the store and closes the menu". It might mean one of the following: - The error was thrown, while Vitest was running this test. - If the error occurred after the test had been completed, this was the last documented test before it was thrown.
setPendingDelete({ id: contextMenu.nodeId, name: contextMenu.nodeData.name, hasChildren, children: childNodes.map(c => ({ id: c.id, name: c.data.name })) });
closeContextMenu();
}, [contextMenu, setPendingDelete, closeContextMenu]);

Expand Down
152 changes: 124 additions & 28 deletions canvas/src/components/CreateWorkspaceDialog.tsx
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -14,32 +15,39 @@ interface HermesProvider {
id: string;
label: string;
envVar: string;
defaultModel: string;
models: string[];
}

// All providers supported by Hermes runtime via providers.resolve_provider()
// All providers supported by Hermes runtime via providers.resolve_provider().
// `defaultModel` is the slug injected into the workspace provision request
// when the user picks this provider — template-hermes's derive-provider.sh
// maps the prefix back to the provider name at install time, so this is
// the canonical handshake. `models` are additional suggestions surfaced in
// the datalist so the user can pick a different size without typing the
// whole slug.
export const HERMES_PROVIDERS: HermesProvider[] = [
{ id: "anthropic", label: "Anthropic (Claude)", envVar: "ANTHROPIC_API_KEY" },
{ id: "openai", label: "OpenAI", envVar: "OPENAI_API_KEY" },
{ id: "openrouter", label: "OpenRouter", envVar: "OPENROUTER_API_KEY" },
{ id: "xai", label: "xAI (Grok)", envVar: "XAI_API_KEY" },
{ id: "gemini", label: "Google Gemini", envVar: "GEMINI_API_KEY" },
{ id: "qwen", label: "Qwen (Alibaba)", envVar: "QWEN_API_KEY" },
{ id: "glm", label: "GLM (Zhipu AI)", envVar: "GLM_API_KEY" },
{ id: "kimi", label: "Kimi (Moonshot)", envVar: "KIMI_API_KEY" },
{ id: "minimax", label: "MiniMax", envVar: "MINIMAX_API_KEY" },
{ id: "deepseek", label: "DeepSeek", envVar: "DEEPSEEK_API_KEY" },
{ id: "groq", label: "Groq", envVar: "GROQ_API_KEY" },
{ id: "mistral", label: "Mistral", envVar: "MISTRAL_API_KEY" },
{ id: "together", label: "Together AI", envVar: "TOGETHER_API_KEY" },
{ id: "fireworks", label: "Fireworks AI", envVar: "FIREWORKS_API_KEY" },
{ id: "hermes", label: "Hermes / Nous (legacy)", envVar: "HERMES_API_KEY" },
{ id: "anthropic", label: "Anthropic (Claude)", envVar: "ANTHROPIC_API_KEY", defaultModel: "anthropic/claude-sonnet-4-5", models: ["anthropic/claude-opus-4-5", "anthropic/claude-sonnet-4-5", "anthropic/claude-haiku-4-5"] },
{ id: "openai", label: "OpenAI", envVar: "OPENAI_API_KEY", defaultModel: "openai/gpt-4o", models: ["openai/gpt-4o", "openai/gpt-4o-mini", "openai/o3-mini"] },
{ id: "openrouter", label: "OpenRouter", envVar: "OPENROUTER_API_KEY", defaultModel: "openrouter/auto", models: ["openrouter/auto", "openrouter/anthropic/claude-sonnet-4", "openrouter/meta-llama/llama-3.3-70b"] },
{ id: "xai", label: "xAI (Grok)", envVar: "XAI_API_KEY", defaultModel: "xai/grok-4", models: ["xai/grok-4", "xai/grok-4-mini"] },
{ id: "gemini", label: "Google Gemini", envVar: "GEMINI_API_KEY", defaultModel: "gemini/gemini-2.5-pro", models: ["gemini/gemini-2.5-pro", "gemini/gemini-2.5-flash"] },
{ id: "qwen", label: "Qwen (Alibaba)", envVar: "QWEN_API_KEY", defaultModel: "alibaba/qwen3-max", models: ["alibaba/qwen3-max", "alibaba/qwen3-coder"] },
{ id: "glm", label: "GLM (Zhipu AI)", envVar: "GLM_API_KEY", defaultModel: "zai/glm-4.6", models: ["zai/glm-4.6", "zai/glm-4.5-air"] },
{ id: "kimi", label: "Kimi (Moonshot)", envVar: "KIMI_API_KEY", defaultModel: "kimi-coding/kimi-k2", models: ["kimi-coding/kimi-k2", "kimi-coding/kimi-k1.5"] },
{ id: "minimax", label: "MiniMax", envVar: "MINIMAX_API_KEY", defaultModel: "minimax/MiniMax-M2.7", models: ["minimax/MiniMax-M2.7", "minimax/MiniMax-M2.7-highspeed", "minimax/MiniMax-M1"] },
{ id: "deepseek", label: "DeepSeek", envVar: "DEEPSEEK_API_KEY", defaultModel: "deepseek/deepseek-chat", models: ["deepseek/deepseek-chat", "deepseek/deepseek-reasoner"] },
{ id: "groq", label: "Groq", envVar: "GROQ_API_KEY", defaultModel: "openrouter/groq/llama-3.3-70b", models: ["openrouter/groq/llama-3.3-70b"] },
{ id: "mistral", label: "Mistral", envVar: "MISTRAL_API_KEY", defaultModel: "openrouter/mistralai/mistral-large", models: ["openrouter/mistralai/mistral-large"] },
{ id: "together", label: "Together AI", envVar: "TOGETHER_API_KEY", defaultModel: "openrouter/meta-llama/llama-3.3-70b", models: ["openrouter/meta-llama/llama-3.3-70b"] },
{ id: "fireworks", label: "Fireworks AI", envVar: "FIREWORKS_API_KEY", defaultModel: "openrouter/meta-llama/llama-3.3-70b", models: ["openrouter/meta-llama/llama-3.3-70b"] },
{ id: "hermes", label: "Hermes / Nous (legacy)", envVar: "HERMES_API_KEY", defaultModel: "nousresearch/Hermes-3-Llama-3.1-405B", models: ["nousresearch/Hermes-3-Llama-3.1-405B", "nousresearch/Hermes-4-14B"] },
];

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("");
Expand All @@ -50,14 +58,42 @@ export function CreateWorkspaceButton() {
// Hermes-specific state
const [hermesProvider, setHermesProvider] = useState("anthropic");
const [hermesApiKey, setHermesApiKey] = useState("");
// Model slug is sent to CP as `model` and plumbed to the workspace EC2
// as HERMES_DEFAULT_MODEL env var. template-hermes's derive-provider.sh
// reads the prefix (`minimax/…`, `anthropic/…`) to set
// HERMES_INFERENCE_PROVIDER at install time. Missing model → provider
// falls back to "auto" and hermes picks its compiled-in default
// (Anthropic), which 401s if the user's key is for a different
// provider. Hence: require model when template=hermes.
const [hermesModel, setHermesModel] = 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) => {
Expand All @@ -80,22 +116,42 @@ export function CreateWorkspaceButton() {

const isHermes = template.trim().toLowerCase() === "hermes";

// Auto-fill hermesModel with the provider's defaultModel whenever the
// provider changes, but only if the user hasn't already typed their own
// slug. Prevents the empty-model → "auto" → Anthropic-default 401 trap.
useEffect(() => {
if (!isHermes) return;
const p = HERMES_PROVIDERS.find((x) => x.id === hermesProvider);
if (!p) return;
// Replace model only if current value matches another provider's
// default (user hasn't customized it) OR is empty.
const isUntouched =
hermesModel === "" ||
HERMES_PROVIDERS.some((x) => x.defaultModel === hermesModel);
if (isUntouched) setHermesModel(p.defaultModel);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [hermesProvider, isHermes]);

// Reset form and load workspaces whenever dialog opens
useEffect(() => {
if (!open) return;
setName("");
setRole("");
setTier(1);
setTier(defaultTier);
setTemplate("");
setParentId("");
setBudgetLimit("");
setError(null);
setHermesProvider("anthropic");
setHermesApiKey("");
setHermesModel("");
api
.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 () => {
Expand All @@ -107,6 +163,10 @@ export function CreateWorkspaceButton() {
setError("API key is required for Hermes workspaces");
return;
}
if (isHermes && !hermesModel.trim()) {
setError("Model is required for Hermes workspaces — provider routing depends on the model slug prefix");
return;
}
setCreating(true);
setError(null);

Expand All @@ -128,7 +188,10 @@ export function CreateWorkspaceButton() {
budget_limit: parsedBudget,
canvas: { x: Math.random() * 400 + 100, y: Math.random() * 300 + 100 },
...(isHermes && provider
? { secrets: { [provider.envVar]: hermesApiKey.trim() } }
? {
secrets: { [provider.envVar]: hermesApiKey.trim() },
model: hermesModel.trim(),
}
: {}),
});
setOpen(false);
Expand Down Expand Up @@ -208,10 +271,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
Expand Down Expand Up @@ -316,6 +379,39 @@ export function CreateWorkspaceButton() {
className="w-full bg-zinc-800/60 border border-zinc-700/50 rounded-lg px-3 py-2 text-sm text-zinc-100 placeholder-zinc-600 focus:outline-none focus:border-violet-500/60 focus:ring-1 focus:ring-violet-500/20 transition-colors font-mono"
/>
</div>

<div>
<label
htmlFor="hermes-model-input"
className="text-[11px] text-zinc-400 block mb-1"
>
Model{" "}
<span aria-hidden="true" className="text-red-400">
*
</span>
<span className="sr-only"> (required)</span>
</label>
<input
id="hermes-model-input"
type="text"
value={hermesModel}
onChange={(e) => setHermesModel(e.target.value)}
placeholder="e.g. minimax/MiniMax-M2.7"
aria-label="Hermes model slug"
autoComplete="off"
spellCheck={false}
list="hermes-model-suggestions"
className="w-full bg-zinc-800/60 border border-zinc-700/50 rounded-lg px-3 py-2 text-sm text-zinc-100 placeholder-zinc-600 focus:outline-none focus:border-violet-500/60 focus:ring-1 focus:ring-violet-500/20 transition-colors font-mono"
/>
<datalist id="hermes-model-suggestions">
{HERMES_PROVIDERS.find((p) => p.id === hermesProvider)?.models.map(
(m) => <option key={m} value={m} />,
)}
</datalist>
<p className="text-[10px] text-zinc-500 mt-1">
Slug determines which provider hermes routes to at install time.
</p>
</div>
</div>
)}

Expand Down
26 changes: 15 additions & 11 deletions canvas/src/components/__tests__/CreateWorkspaceDialog.a11y.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"));
Expand All @@ -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 () => {
Expand All @@ -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"));
});

Expand All @@ -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"));
});
});

Expand Down
Loading
Loading