diff --git a/panel/src/components/projects/__tests__/ladder-validation.test.ts b/panel/src/components/projects/__tests__/ladder-validation.test.ts
new file mode 100644
index 00000000..ec623b90
--- /dev/null
+++ b/panel/src/components/projects/__tests__/ladder-validation.test.ts
@@ -0,0 +1,36 @@
+import { describe, it, expect } from "vitest";
+import { validateLadder } from "@/components/projects/ladder-validation";
+import type { EnvironmentRung } from "@/types";
+
+const rungs = (rows: [string, string][]): EnvironmentRung[] =>
+ rows.map(([name, branch]) => ({ name, branch }));
+
+describe("validateLadder", () => {
+ it("accepts null (inherits default_branch via the shim)", () => {
+ expect(validateLadder(null)).toBeNull();
+ });
+
+ it("accepts an empty ladder", () => {
+ expect(validateLadder([])).toBeNull();
+ });
+
+ it("accepts a clean ordered ladder", () => {
+ expect(
+ validateLadder(rungs([["head", "dev"], ["prod", "master"]])),
+ ).toBeNull();
+ });
+
+ it("rejects a rung missing a name", () => {
+ expect(validateLadder(rungs([["", "dev"]]))).toMatch(/name and a branch/);
+ });
+
+ it("rejects a rung missing a branch", () => {
+ expect(validateLadder(rungs([["head", " "]]))).toMatch(/name and a branch/);
+ });
+
+ it("rejects duplicate branches", () => {
+ expect(
+ validateLadder(rungs([["head", "dev"], ["prod", "dev"]])),
+ ).toMatch(/Duplicate branch "dev"/);
+ });
+});
\ No newline at end of file
diff --git a/panel/src/components/projects/create-project-dialog.tsx b/panel/src/components/projects/create-project-dialog.tsx
index 1b0adb79..d2431087 100644
--- a/panel/src/components/projects/create-project-dialog.tsx
+++ b/panel/src/components/projects/create-project-dialog.tsx
@@ -24,6 +24,8 @@ import {
import { Plus, Key } from "lucide-react";
import { toast } from "sonner";
import { Team, type ProjectCreate } from "@/types";
+import { EnvironmentLadderEditor } from "@/components/projects/environment-ladder-editor";
+import { validateLadder } from "@/components/projects/ladder-validation";
const cells: { value: Team; label: string }[] = [
{ value: Team.BACKEND, label: "Backend" },
@@ -48,6 +50,7 @@ export function CreateProjectDialog() {
git_token: "",
assigned_cell: Team.BACKEND,
default_branch: "main",
+ environments: null,
});
const [showAdvanced, setShowAdvanced] = useState(false);
@@ -74,6 +77,12 @@ export function CreateProjectDialog() {
return;
}
+ const envError = validateLadder(formData.environments ?? null);
+ if (envError) {
+ toast.error(envError);
+ return;
+ }
+
try {
await createProject.mutateAsync({
name: formData.name,
@@ -82,6 +91,7 @@ export function CreateProjectDialog() {
assigned_cell: formData.assigned_cell,
git_token: formData.git_token || undefined,
default_branch: formData.default_branch || "main",
+ environments: formData.environments ?? undefined,
test_command: formData.test_command || undefined,
lint_command: formData.lint_command || undefined,
format_command: formData.format_command || undefined,
@@ -98,6 +108,7 @@ export function CreateProjectDialog() {
git_token: "",
assigned_cell: Team.BACKEND,
default_branch: "main",
+ environments: null,
});
setShowAdvanced(false);
} catch (error) {
@@ -222,8 +233,17 @@ export function CreateProjectDialog() {
}
placeholder="main"
/>
+
+ Fallback head+prod branch when no environment ladder is set below.
+
+ {/* Environment ladder */}
+ setFormData({ ...formData, environments: rungs })}
+ />
+
{/* Advanced Options Toggle */}