diff --git a/Auto Run Docs/02b-frontend-dashboard.md b/Auto Run Docs/02b-frontend-dashboard.md
index 9d86b00..f9b980f 100644
--- a/Auto Run Docs/02b-frontend-dashboard.md
+++ b/Auto Run Docs/02b-frontend-dashboard.md
@@ -53,6 +53,8 @@ Build the React frontend: setup wizard, experiment builder, real-time observabil
- [x] Build the WebSocket hook (frontend/src/hooks/useExperimentWS.ts). Custom React hook that manages WebSocket connection to /ws/experiments/{id}. Handles connect/disconnect/reconnect, parses incoming events, exposes typed event stream, and provides connection status. Reconnect with exponential backoff on disconnect.
-- [ ] Style pass — go through every page and component ensuring consistent Tailwind usage, proper dark mode support (use Tailwind dark: prefix), responsive layout (works on tablet+), smooth transitions on state changes, and accessible form inputs. The UI should feel alive and dynamic, not static. Use subtle animations for new data arriving.
+- [x] Style pass — go through every page and component ensuring consistent Tailwind usage, proper dark mode support (use Tailwind dark: prefix), responsive layout (works on tablet+), smooth transitions on state changes, and accessible form inputs. The UI should feel alive and dynamic, not static. Use subtle animations for new data arriving.
+
-- [ ] Build a "Wizard" flow for first-time users (frontend/src/components/Wizard.tsx). A guided multi-step flow: (1) Configure your first LLM endpoint, (2) Paste some sample text, (3) Write a prompt, (4) Run it and see the result, (5) Congratulations, now try a sweep! This should be accessible from the Dashboard for new users.
+- [x] Build a "Wizard" flow for first-time users (frontend/src/components/Wizard.tsx). A guided multi-step flow: (1) Configure your first LLM endpoint, (2) Paste some sample text, (3) Write a prompt, (4) Run it and see the result, (5) Congratulations, now try a sweep! This should be accessible from the Dashboard for new users.
+
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index 3eb7eba..6cea163 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -9,6 +9,7 @@ import ExperimentPage from "./pages/ExperimentPage";
import LivePage from "./pages/LivePage";
import ComparePage from "./pages/ComparePage";
import AdminPage from "./pages/AdminPage";
+import WizardPage from "./pages/WizardPage";
function AuthenticatedRoutes() {
return (
@@ -20,6 +21,7 @@ function AuthenticatedRoutes() {
} />
} />
} />
+ } />
} />
diff --git a/frontend/src/components/Wizard.test.tsx b/frontend/src/components/Wizard.test.tsx
new file mode 100644
index 0000000..ec5e3b5
--- /dev/null
+++ b/frontend/src/components/Wizard.test.tsx
@@ -0,0 +1,712 @@
+import { render, screen, waitFor } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { MemoryRouter } from "react-router-dom";
+import { describe, it, expect, vi, beforeEach } from "vitest";
+import Wizard from "./Wizard";
+import * as client from "../api/client";
+
+// ---------------------------------------------------------------------------
+// Mock data
+// ---------------------------------------------------------------------------
+
+const MOCK_ENDPOINT: client.EndpointResponse = {
+ id: "ep1",
+ name: "Test LLM",
+ url: "http://localhost:11434/v1",
+ default_model: "llama3",
+};
+
+const MOCK_PROJECT: client.ProjectResponse = {
+ id: "proj1",
+ name: "Quick Start",
+ description: "Created by the Getting Started wizard",
+ owner_id: "u1",
+ created_at: "2026-04-07T10:00:00Z",
+ updated_at: "2026-04-07T10:00:00Z",
+};
+
+const MOCK_EXPERIMENT: client.ExperimentResponse = {
+ id: "exp1",
+ project_id: "proj1",
+ name: "Wizard Run",
+ description: "First experiment from the wizard",
+ sample_data: { input_data: "Hello world" },
+ pipeline_stages: null,
+ scoring_config: null,
+ parameter_space: null,
+ status: "draft",
+ created_at: "2026-04-07T10:00:00Z",
+ updated_at: "2026-04-07T10:00:00Z",
+};
+
+const MOCK_RUN: client.RunResponse = {
+ id: "run1",
+ experiment_id: "exp1",
+ config_hash: "abc123",
+ config: { model: "llama3" },
+ status: "running",
+ started_at: "2026-04-07T10:00:00Z",
+ completed_at: null,
+ duration_ms: null,
+ tokens_in: null,
+ tokens_out: null,
+ cost_estimate: null,
+};
+
+const MOCK_RUN_DETAIL: client.RunDetailResponse = {
+ ...MOCK_RUN,
+ status: "completed",
+ completed_at: "2026-04-07T10:01:00Z",
+ duration_ms: 2500,
+ tokens_in: 100,
+ tokens_out: 50,
+ cost_estimate: 0.001,
+ stage_results: [
+ {
+ id: "sr1",
+ run_id: "run1",
+ stage_index: 0,
+ prompt_sent: "Summarize: Hello world",
+ response_raw: "This is a greeting text.",
+ model_used: "llama3",
+ parameters: null,
+ tokens_in: 100,
+ tokens_out: 50,
+ latency_ms: 2500,
+ },
+ ],
+ scores: [],
+};
+
+// ---------------------------------------------------------------------------
+// Helpers
+// ---------------------------------------------------------------------------
+
+const mockedNavigate = vi.fn();
+
+vi.mock("react-router-dom", async () => {
+ const actual = await vi.importActual("react-router-dom");
+ return {
+ ...actual,
+ useNavigate: () => mockedNavigate,
+ };
+});
+
+function renderWizard(onDismiss?: () => void) {
+ return render(
+
+
+ ,
+ );
+}
+
+// ---------------------------------------------------------------------------
+// Tests
+// ---------------------------------------------------------------------------
+
+describe("Wizard", () => {
+ beforeEach(() => {
+ vi.restoreAllMocks();
+ mockedNavigate.mockClear();
+ });
+
+ // -- Rendering & navigation -----------------------------------------------
+
+ it("renders the wizard container and step indicator", () => {
+ renderWizard();
+ expect(screen.getByTestId("wizard")).toBeInTheDocument();
+ expect(screen.getByTestId("step-indicator")).toBeInTheDocument();
+ });
+
+ it("starts on step 1 — endpoint configuration", () => {
+ renderWizard();
+ expect(
+ screen.getByText("Connect your first LLM endpoint"),
+ ).toBeInTheDocument();
+ expect(screen.getByTestId("wiz-ep-url")).toBeInTheDocument();
+ });
+
+ it("shows back button disabled on first step", () => {
+ renderWizard();
+ const back = screen.getByTestId("wiz-back");
+ expect(back).toBeDisabled();
+ });
+
+ it("calls onDismiss when skip wizard is clicked", async () => {
+ const dismiss = vi.fn();
+ const user = userEvent.setup();
+ renderWizard(dismiss);
+
+ await user.click(screen.getByTestId("wiz-skip"));
+ expect(dismiss).toHaveBeenCalledOnce();
+ });
+
+ // -- Step 1: Endpoint ----------------------------------------------------
+
+ it("shows validation error when URL is whitespace only", async () => {
+ const user = userEvent.setup();
+ renderWizard();
+
+ // Type whitespace-only URL (bypasses HTML required but fails our trim check)
+ const urlInput = screen.getByTestId("wiz-ep-url");
+ await user.type(urlInput, " ");
+ await user.click(screen.getByTestId("wiz-ep-submit"));
+
+ expect(screen.getByRole("alert")).toHaveTextContent(
+ "Endpoint URL is required.",
+ );
+ });
+
+ it("creates endpoint and advances to step 2", async () => {
+ const user = userEvent.setup();
+ vi.spyOn(client.endpoints, "create").mockResolvedValue(MOCK_ENDPOINT);
+
+ renderWizard();
+
+ await user.clear(screen.getByTestId("wiz-ep-url"));
+ await user.type(
+ screen.getByTestId("wiz-ep-url"),
+ "http://localhost:11434/v1",
+ );
+ await user.click(screen.getByTestId("wiz-ep-submit"));
+
+ await waitFor(() => {
+ expect(
+ screen.getByText("Paste some sample text"),
+ ).toBeInTheDocument();
+ });
+ });
+
+ it("shows API error when endpoint creation fails", async () => {
+ const user = userEvent.setup();
+ vi.spyOn(client.endpoints, "create").mockRejectedValue(
+ new client.ApiError(422, "Unprocessable", {}),
+ );
+
+ renderWizard();
+
+ await user.type(
+ screen.getByTestId("wiz-ep-url"),
+ "http://bad",
+ );
+ await user.click(screen.getByTestId("wiz-ep-submit"));
+
+ await waitFor(() => {
+ expect(screen.getByRole("alert")).toHaveTextContent(
+ "Failed to create endpoint (422).",
+ );
+ });
+ });
+
+ it("shows network error on non-API failure", async () => {
+ const user = userEvent.setup();
+ vi.spyOn(client.endpoints, "create").mockRejectedValue(
+ new Error("fetch failed"),
+ );
+
+ renderWizard();
+
+ await user.type(screen.getByTestId("wiz-ep-url"), "http://x");
+ await user.click(screen.getByTestId("wiz-ep-submit"));
+
+ await waitFor(() => {
+ expect(screen.getByRole("alert")).toHaveTextContent(
+ "Network error. Is the server running?",
+ );
+ });
+ });
+
+ it("shows submitting state on endpoint form", async () => {
+ const user = userEvent.setup();
+ // Never resolving promise to keep submitting state
+ vi.spyOn(client.endpoints, "create").mockReturnValue(
+ new Promise(() => {}),
+ );
+
+ renderWizard();
+
+ await user.type(screen.getByTestId("wiz-ep-url"), "http://x");
+ await user.click(screen.getByTestId("wiz-ep-submit"));
+
+ expect(screen.getByTestId("wiz-ep-submit")).toHaveTextContent(
+ "Connecting...",
+ );
+ expect(screen.getByTestId("wiz-ep-submit")).toBeDisabled();
+ });
+
+ // -- Step 2: Sample data -------------------------------------------------
+
+ it("disables continue when sample text is empty", async () => {
+ const user = userEvent.setup();
+ vi.spyOn(client.endpoints, "create").mockResolvedValue(MOCK_ENDPOINT);
+
+ renderWizard();
+
+ // Advance to step 2
+ await user.type(screen.getByTestId("wiz-ep-url"), "http://x");
+ await user.click(screen.getByTestId("wiz-ep-submit"));
+
+ await waitFor(() => {
+ expect(screen.getByTestId("wiz-sample-next")).toBeInTheDocument();
+ });
+
+ expect(screen.getByTestId("wiz-sample-next")).toBeDisabled();
+ });
+
+ it("advances to step 3 when sample text is provided", async () => {
+ const user = userEvent.setup();
+ vi.spyOn(client.endpoints, "create").mockResolvedValue(MOCK_ENDPOINT);
+
+ renderWizard();
+
+ // Step 1
+ await user.type(screen.getByTestId("wiz-ep-url"), "http://x");
+ await user.click(screen.getByTestId("wiz-ep-submit"));
+
+ // Step 2
+ await waitFor(() => {
+ expect(screen.getByTestId("wiz-sample-text")).toBeInTheDocument();
+ });
+ await user.type(screen.getByTestId("wiz-sample-text"), "Hello world");
+ await user.click(screen.getByTestId("wiz-sample-next"));
+
+ await waitFor(() => {
+ expect(screen.getByText("Write a prompt")).toBeInTheDocument();
+ });
+ });
+
+ // -- Step 3: Prompt -------------------------------------------------------
+
+ it("disables continue when prompt text is empty", async () => {
+ const user = userEvent.setup();
+ vi.spyOn(client.endpoints, "create").mockResolvedValue(MOCK_ENDPOINT);
+
+ renderWizard();
+
+ // Step 1
+ await user.type(screen.getByTestId("wiz-ep-url"), "http://x");
+ await user.click(screen.getByTestId("wiz-ep-submit"));
+
+ // Step 2
+ await waitFor(() => {
+ expect(screen.getByTestId("wiz-sample-text")).toBeInTheDocument();
+ });
+ await user.type(screen.getByTestId("wiz-sample-text"), "Hello");
+ await user.click(screen.getByTestId("wiz-sample-next"));
+
+ // Step 3
+ await waitFor(() => {
+ expect(screen.getByTestId("wiz-prompt-next")).toBeInTheDocument();
+ });
+ expect(screen.getByTestId("wiz-prompt-next")).toBeDisabled();
+ });
+
+ it("advances to step 4 when prompt is provided", async () => {
+ const user = userEvent.setup();
+ vi.spyOn(client.endpoints, "create").mockResolvedValue(MOCK_ENDPOINT);
+
+ renderWizard();
+
+ // Step 1
+ await user.type(screen.getByTestId("wiz-ep-url"), "http://x");
+ await user.click(screen.getByTestId("wiz-ep-submit"));
+
+ // Step 2
+ await waitFor(() => {
+ expect(screen.getByTestId("wiz-sample-text")).toBeInTheDocument();
+ });
+ await user.type(screen.getByTestId("wiz-sample-text"), "Hello");
+ await user.click(screen.getByTestId("wiz-sample-next"));
+
+ // Step 3
+ await waitFor(() => {
+ expect(screen.getByTestId("wiz-prompt-text")).toBeInTheDocument();
+ });
+ await user.type(
+ screen.getByTestId("wiz-prompt-text"),
+ "Summarize this",
+ );
+ await user.click(screen.getByTestId("wiz-prompt-next"));
+
+ await waitFor(() => {
+ expect(screen.getByText("Run it!")).toBeInTheDocument();
+ });
+ });
+
+ // -- Step 4: Run ----------------------------------------------------------
+
+ it("shows run button on step 4 with endpoint name", async () => {
+ const user = userEvent.setup();
+ vi.spyOn(client.endpoints, "create").mockResolvedValue(MOCK_ENDPOINT);
+
+ renderWizard();
+
+ // Navigate to step 4
+ await user.type(screen.getByTestId("wiz-ep-url"), "http://x");
+ await user.click(screen.getByTestId("wiz-ep-submit"));
+ await waitFor(() => {
+ expect(screen.getByTestId("wiz-sample-text")).toBeInTheDocument();
+ });
+ await user.type(screen.getByTestId("wiz-sample-text"), "Hello");
+ await user.click(screen.getByTestId("wiz-sample-next"));
+ await waitFor(() => {
+ expect(screen.getByTestId("wiz-prompt-text")).toBeInTheDocument();
+ });
+ await user.type(screen.getByTestId("wiz-prompt-text"), "Summarize");
+ await user.click(screen.getByTestId("wiz-prompt-next"));
+
+ await waitFor(() => {
+ expect(screen.getByTestId("wiz-run-btn")).toBeInTheDocument();
+ });
+ expect(screen.getByText("Test LLM")).toBeInTheDocument();
+ });
+
+ it("runs experiment and shows result then advances to congrats", async () => {
+ const user = userEvent.setup();
+ vi.spyOn(client.endpoints, "create").mockResolvedValue(MOCK_ENDPOINT);
+ vi.spyOn(client.projects, "create").mockResolvedValue(MOCK_PROJECT);
+ vi.spyOn(client.experiments, "create").mockResolvedValue(MOCK_EXPERIMENT);
+ vi.spyOn(client.experiments, "update").mockResolvedValue(MOCK_EXPERIMENT);
+ vi.spyOn(client.runs, "create").mockResolvedValue(MOCK_RUN);
+ vi.spyOn(client.runs, "get").mockResolvedValue(MOCK_RUN_DETAIL);
+
+ renderWizard();
+
+ // Navigate to step 4
+ await user.type(screen.getByTestId("wiz-ep-url"), "http://x");
+ await user.click(screen.getByTestId("wiz-ep-submit"));
+ await waitFor(() => {
+ expect(screen.getByTestId("wiz-sample-text")).toBeInTheDocument();
+ });
+ await user.type(screen.getByTestId("wiz-sample-text"), "Hello");
+ await user.click(screen.getByTestId("wiz-sample-next"));
+ await waitFor(() => {
+ expect(screen.getByTestId("wiz-prompt-text")).toBeInTheDocument();
+ });
+ await user.type(screen.getByTestId("wiz-prompt-text"), "Summarize");
+ await user.click(screen.getByTestId("wiz-prompt-next"));
+
+ await waitFor(() => {
+ expect(screen.getByTestId("wiz-run-btn")).toBeInTheDocument();
+ });
+
+ // Click run
+ await user.click(screen.getByTestId("wiz-run-btn"));
+
+ // Should show running state
+ await waitFor(() => {
+ expect(screen.getByTestId("wiz-running")).toBeInTheDocument();
+ });
+
+ // Should eventually show result and advance to congrats
+ await waitFor(
+ () => {
+ expect(
+ screen.getByText("You're all set!"),
+ ).toBeInTheDocument();
+ },
+ { timeout: 5000 },
+ );
+ });
+
+ it("shows error when run fails", async () => {
+ const user = userEvent.setup();
+ vi.spyOn(client.endpoints, "create").mockResolvedValue(MOCK_ENDPOINT);
+ vi.spyOn(client.projects, "create").mockRejectedValue(
+ new client.ApiError(500, "Server Error", {}),
+ );
+
+ renderWizard();
+
+ // Navigate to step 4
+ await user.type(screen.getByTestId("wiz-ep-url"), "http://x");
+ await user.click(screen.getByTestId("wiz-ep-submit"));
+ await waitFor(() => {
+ expect(screen.getByTestId("wiz-sample-text")).toBeInTheDocument();
+ });
+ await user.type(screen.getByTestId("wiz-sample-text"), "Hello");
+ await user.click(screen.getByTestId("wiz-sample-next"));
+ await waitFor(() => {
+ expect(screen.getByTestId("wiz-prompt-text")).toBeInTheDocument();
+ });
+ await user.type(screen.getByTestId("wiz-prompt-text"), "Summarize");
+ await user.click(screen.getByTestId("wiz-prompt-next"));
+
+ await waitFor(() => {
+ expect(screen.getByTestId("wiz-run-btn")).toBeInTheDocument();
+ });
+
+ await user.click(screen.getByTestId("wiz-run-btn"));
+
+ await waitFor(() => {
+ expect(screen.getByRole("alert")).toHaveTextContent(
+ "Request failed (500).",
+ );
+ });
+ });
+
+ // -- Step 5: Congrats -----------------------------------------------------
+
+ it("shows try a sweep and dashboard buttons on congrats step", async () => {
+ const user = userEvent.setup();
+ vi.spyOn(client.endpoints, "create").mockResolvedValue(MOCK_ENDPOINT);
+ vi.spyOn(client.projects, "create").mockResolvedValue(MOCK_PROJECT);
+ vi.spyOn(client.experiments, "create").mockResolvedValue(MOCK_EXPERIMENT);
+ vi.spyOn(client.experiments, "update").mockResolvedValue(MOCK_EXPERIMENT);
+ vi.spyOn(client.runs, "create").mockResolvedValue(MOCK_RUN);
+ vi.spyOn(client.runs, "get").mockResolvedValue(MOCK_RUN_DETAIL);
+
+ renderWizard();
+
+ // Navigate through all steps
+ await user.type(screen.getByTestId("wiz-ep-url"), "http://x");
+ await user.click(screen.getByTestId("wiz-ep-submit"));
+ await waitFor(() => {
+ expect(screen.getByTestId("wiz-sample-text")).toBeInTheDocument();
+ });
+ await user.type(screen.getByTestId("wiz-sample-text"), "Hello");
+ await user.click(screen.getByTestId("wiz-sample-next"));
+ await waitFor(() => {
+ expect(screen.getByTestId("wiz-prompt-text")).toBeInTheDocument();
+ });
+ await user.type(screen.getByTestId("wiz-prompt-text"), "Summarize");
+ await user.click(screen.getByTestId("wiz-prompt-next"));
+ await waitFor(() => {
+ expect(screen.getByTestId("wiz-run-btn")).toBeInTheDocument();
+ });
+ await user.click(screen.getByTestId("wiz-run-btn"));
+
+ await waitFor(
+ () => {
+ expect(screen.getByTestId("wiz-try-sweep")).toBeInTheDocument();
+ },
+ { timeout: 5000 },
+ );
+
+ expect(screen.getByTestId("wiz-go-dashboard")).toBeInTheDocument();
+ });
+
+ it("try sweep navigates to experiment page", async () => {
+ const user = userEvent.setup();
+ vi.spyOn(client.endpoints, "create").mockResolvedValue(MOCK_ENDPOINT);
+ vi.spyOn(client.projects, "create").mockResolvedValue(MOCK_PROJECT);
+ vi.spyOn(client.experiments, "create").mockResolvedValue(MOCK_EXPERIMENT);
+ vi.spyOn(client.experiments, "update").mockResolvedValue(MOCK_EXPERIMENT);
+ vi.spyOn(client.runs, "create").mockResolvedValue(MOCK_RUN);
+ vi.spyOn(client.runs, "get").mockResolvedValue(MOCK_RUN_DETAIL);
+
+ renderWizard();
+
+ // Navigate through all steps
+ await user.type(screen.getByTestId("wiz-ep-url"), "http://x");
+ await user.click(screen.getByTestId("wiz-ep-submit"));
+ await waitFor(() => {
+ expect(screen.getByTestId("wiz-sample-text")).toBeInTheDocument();
+ });
+ await user.type(screen.getByTestId("wiz-sample-text"), "Hello");
+ await user.click(screen.getByTestId("wiz-sample-next"));
+ await waitFor(() => {
+ expect(screen.getByTestId("wiz-prompt-text")).toBeInTheDocument();
+ });
+ await user.type(screen.getByTestId("wiz-prompt-text"), "Summarize");
+ await user.click(screen.getByTestId("wiz-prompt-next"));
+ await waitFor(() => {
+ expect(screen.getByTestId("wiz-run-btn")).toBeInTheDocument();
+ });
+ await user.click(screen.getByTestId("wiz-run-btn"));
+
+ await waitFor(
+ () => {
+ expect(screen.getByTestId("wiz-try-sweep")).toBeInTheDocument();
+ },
+ { timeout: 5000 },
+ );
+
+ await user.click(screen.getByTestId("wiz-try-sweep"));
+ expect(mockedNavigate).toHaveBeenCalledWith("/experiments/exp1");
+ });
+
+ it("go to dashboard navigates to root and calls onDismiss", async () => {
+ const dismiss = vi.fn();
+ const user = userEvent.setup();
+ vi.spyOn(client.endpoints, "create").mockResolvedValue(MOCK_ENDPOINT);
+ vi.spyOn(client.projects, "create").mockResolvedValue(MOCK_PROJECT);
+ vi.spyOn(client.experiments, "create").mockResolvedValue(MOCK_EXPERIMENT);
+ vi.spyOn(client.experiments, "update").mockResolvedValue(MOCK_EXPERIMENT);
+ vi.spyOn(client.runs, "create").mockResolvedValue(MOCK_RUN);
+ vi.spyOn(client.runs, "get").mockResolvedValue(MOCK_RUN_DETAIL);
+
+ renderWizard(dismiss);
+
+ // Navigate through all steps
+ await user.type(screen.getByTestId("wiz-ep-url"), "http://x");
+ await user.click(screen.getByTestId("wiz-ep-submit"));
+ await waitFor(() => {
+ expect(screen.getByTestId("wiz-sample-text")).toBeInTheDocument();
+ });
+ await user.type(screen.getByTestId("wiz-sample-text"), "Hello");
+ await user.click(screen.getByTestId("wiz-sample-next"));
+ await waitFor(() => {
+ expect(screen.getByTestId("wiz-prompt-text")).toBeInTheDocument();
+ });
+ await user.type(screen.getByTestId("wiz-prompt-text"), "Summarize");
+ await user.click(screen.getByTestId("wiz-prompt-next"));
+ await waitFor(() => {
+ expect(screen.getByTestId("wiz-run-btn")).toBeInTheDocument();
+ });
+ await user.click(screen.getByTestId("wiz-run-btn"));
+
+ await waitFor(
+ () => {
+ expect(screen.getByTestId("wiz-go-dashboard")).toBeInTheDocument();
+ },
+ { timeout: 5000 },
+ );
+
+ await user.click(screen.getByTestId("wiz-go-dashboard"));
+ expect(dismiss).toHaveBeenCalledOnce();
+ expect(mockedNavigate).toHaveBeenCalledWith("/");
+ });
+
+ // -- Back navigation ------------------------------------------------------
+
+ it("back button navigates to previous step", async () => {
+ const user = userEvent.setup();
+ vi.spyOn(client.endpoints, "create").mockResolvedValue(MOCK_ENDPOINT);
+
+ renderWizard();
+
+ // Advance to step 2
+ await user.type(screen.getByTestId("wiz-ep-url"), "http://x");
+ await user.click(screen.getByTestId("wiz-ep-submit"));
+
+ await waitFor(() => {
+ expect(
+ screen.getByText("Paste some sample text"),
+ ).toBeInTheDocument();
+ });
+
+ // Go back to step 1
+ await user.click(screen.getByTestId("wiz-back"));
+
+ await waitFor(() => {
+ expect(
+ screen.getByText("Connect your first LLM endpoint"),
+ ).toBeInTheDocument();
+ });
+ });
+
+ // -- Step indicator -------------------------------------------------------
+
+ it("shows correct step labels in indicator", () => {
+ renderWizard();
+ const indicator = screen.getByTestId("step-indicator");
+ expect(indicator).toBeInTheDocument();
+ // Step 1 is active
+ expect(screen.getByText("Connect LLM")).toBeInTheDocument();
+ });
+
+ // -- Congrats hides back/skip -------------------------------------------------
+
+ it("hides back and skip buttons on congrats step", async () => {
+ const user = userEvent.setup();
+ vi.spyOn(client.endpoints, "create").mockResolvedValue(MOCK_ENDPOINT);
+ vi.spyOn(client.projects, "create").mockResolvedValue(MOCK_PROJECT);
+ vi.spyOn(client.experiments, "create").mockResolvedValue(MOCK_EXPERIMENT);
+ vi.spyOn(client.experiments, "update").mockResolvedValue(MOCK_EXPERIMENT);
+ vi.spyOn(client.runs, "create").mockResolvedValue(MOCK_RUN);
+ vi.spyOn(client.runs, "get").mockResolvedValue(MOCK_RUN_DETAIL);
+
+ renderWizard();
+
+ // Navigate through all steps to congrats
+ await user.type(screen.getByTestId("wiz-ep-url"), "http://x");
+ await user.click(screen.getByTestId("wiz-ep-submit"));
+ await waitFor(() => {
+ expect(screen.getByTestId("wiz-sample-text")).toBeInTheDocument();
+ });
+ await user.type(screen.getByTestId("wiz-sample-text"), "Hello");
+ await user.click(screen.getByTestId("wiz-sample-next"));
+ await waitFor(() => {
+ expect(screen.getByTestId("wiz-prompt-text")).toBeInTheDocument();
+ });
+ await user.type(screen.getByTestId("wiz-prompt-text"), "Summarize");
+ await user.click(screen.getByTestId("wiz-prompt-next"));
+ await waitFor(() => {
+ expect(screen.getByTestId("wiz-run-btn")).toBeInTheDocument();
+ });
+ await user.click(screen.getByTestId("wiz-run-btn"));
+
+ await waitFor(
+ () => {
+ expect(
+ screen.getByText("You're all set!"),
+ ).toBeInTheDocument();
+ },
+ { timeout: 5000 },
+ );
+
+ expect(screen.queryByTestId("wiz-back")).not.toBeInTheDocument();
+ expect(screen.queryByTestId("wiz-skip")).not.toBeInTheDocument();
+ });
+
+ // -- Endpoint form fields -------------------------------------------------
+
+ it("renders all endpoint form fields", () => {
+ renderWizard();
+ expect(screen.getByTestId("wiz-ep-name")).toBeInTheDocument();
+ expect(screen.getByTestId("wiz-ep-url")).toBeInTheDocument();
+ expect(screen.getByTestId("wiz-ep-key")).toBeInTheDocument();
+ expect(screen.getByTestId("wiz-ep-model")).toBeInTheDocument();
+ });
+
+ it("sends correct data when creating endpoint", async () => {
+ const user = userEvent.setup();
+ const createSpy = vi
+ .spyOn(client.endpoints, "create")
+ .mockResolvedValue(MOCK_ENDPOINT);
+
+ renderWizard();
+
+ await user.clear(screen.getByTestId("wiz-ep-name"));
+ await user.type(screen.getByTestId("wiz-ep-name"), "Custom Name");
+ await user.type(
+ screen.getByTestId("wiz-ep-url"),
+ "http://localhost:8080/v1",
+ );
+ await user.type(screen.getByTestId("wiz-ep-key"), "sk-test123");
+ await user.type(screen.getByTestId("wiz-ep-model"), "gpt-4o");
+ await user.click(screen.getByTestId("wiz-ep-submit"));
+
+ await waitFor(() => {
+ expect(createSpy).toHaveBeenCalledWith({
+ name: "Custom Name",
+ url: "http://localhost:8080/v1",
+ api_key: "sk-test123",
+ default_model: "gpt-4o",
+ });
+ });
+ });
+
+ // -- Prompt template hint -------------------------------------------------
+
+ it("shows template variable hint on prompt step", async () => {
+ const user = userEvent.setup();
+ vi.spyOn(client.endpoints, "create").mockResolvedValue(MOCK_ENDPOINT);
+
+ renderWizard();
+
+ // Navigate to step 3
+ await user.type(screen.getByTestId("wiz-ep-url"), "http://x");
+ await user.click(screen.getByTestId("wiz-ep-submit"));
+ await waitFor(() => {
+ expect(screen.getByTestId("wiz-sample-text")).toBeInTheDocument();
+ });
+ await user.type(screen.getByTestId("wiz-sample-text"), "Hello");
+ await user.click(screen.getByTestId("wiz-sample-next"));
+
+ await waitFor(() => {
+ expect(screen.getByText("{{ input_data }}")).toBeInTheDocument();
+ });
+ });
+});
diff --git a/frontend/src/components/Wizard.tsx b/frontend/src/components/Wizard.tsx
new file mode 100644
index 0000000..c185f76
--- /dev/null
+++ b/frontend/src/components/Wizard.tsx
@@ -0,0 +1,724 @@
+import { useState, useCallback } from "react";
+import { useNavigate } from "react-router-dom";
+import {
+ endpoints as endpointsApi,
+ experiments,
+ projects,
+ runs,
+ ApiError,
+} from "../api/client";
+import type {
+ EndpointCreate,
+ EndpointResponse,
+ ProjectResponse,
+ ExperimentResponse,
+ RunDetailResponse,
+} from "../api/client";
+
+// ---------------------------------------------------------------------------
+// Types
+// ---------------------------------------------------------------------------
+
+interface WizardProps {
+ /** Called when the wizard is dismissed (skip or finish). */
+ onDismiss?: () => void;
+}
+
+type StepId =
+ | "endpoint"
+ | "sample"
+ | "prompt"
+ | "run"
+ | "congrats";
+
+const STEPS: { id: StepId; label: string }[] = [
+ { id: "endpoint", label: "Connect LLM" },
+ { id: "sample", label: "Sample Data" },
+ { id: "prompt", label: "Write Prompt" },
+ { id: "run", label: "Run It" },
+ { id: "congrats", label: "Done!" },
+];
+
+// ---------------------------------------------------------------------------
+// Step indicator
+// ---------------------------------------------------------------------------
+
+function StepIndicator({
+ steps,
+ currentIndex,
+}: {
+ steps: typeof STEPS;
+ currentIndex: number;
+}) {
+ return (
+
+
+ {steps.map((step, i) => {
+ const isActive = i === currentIndex;
+ const isCompleted = i < currentIndex;
+ return (
+
+ {i > 0 && (
+
+ )}
+
+
+ {isCompleted ? (
+
+
+
+ ) : (
+ i + 1
+ )}
+
+
+ {step.label}
+
+
+
+ );
+ })}
+
+
+ );
+}
+
+// ---------------------------------------------------------------------------
+// Step 1 — Configure Endpoint
+// ---------------------------------------------------------------------------
+
+function EndpointStep({
+ onComplete,
+ error,
+ setError,
+ submitting,
+ setSubmitting,
+}: {
+ onComplete: (ep: EndpointResponse) => void;
+ error: string | null;
+ setError: (e: string | null) => void;
+ submitting: boolean;
+ setSubmitting: (s: boolean) => void;
+}) {
+ const [name, setName] = useState("My LLM");
+ const [url, setUrl] = useState("");
+ const [apiKey, setApiKey] = useState("");
+ const [model, setModel] = useState("");
+
+ async function handleSubmit(e: React.FormEvent) {
+ e.preventDefault();
+ setError(null);
+
+ if (!url.trim()) {
+ setError("Endpoint URL is required.");
+ return;
+ }
+
+ setSubmitting(true);
+ try {
+ const payload: EndpointCreate = {
+ name: name.trim() || "My LLM",
+ url: url.trim(),
+ api_key: apiKey.trim() || null,
+ default_model: model.trim() || null,
+ };
+ const created = await endpointsApi.create(payload);
+ onComplete(created);
+ } catch (err: unknown) {
+ if (err instanceof ApiError) {
+ setError(`Failed to create endpoint (${err.status}).`);
+ } else {
+ setError("Network error. Is the server running?");
+ }
+ } finally {
+ setSubmitting(false);
+ }
+ }
+
+ return (
+
+
+ Connect your first LLM endpoint
+
+
+ PromptLooper talks to any OpenAI-compatible API. Point it at OpenAI,
+ Ollama, vLLM, or any other provider.
+
+
+ {error && (
+
+ {error}
+
+ )}
+
+
+
+ );
+}
+
+// ---------------------------------------------------------------------------
+// Step 2 — Sample Data
+// ---------------------------------------------------------------------------
+
+function SampleStep({
+ sampleText,
+ setSampleText,
+ onNext,
+}: {
+ sampleText: string;
+ setSampleText: (s: string) => void;
+ onNext: () => void;
+}) {
+ return (
+
+
+ Paste some sample text
+
+
+ This is the input your prompt will process. It can be anything — an
+ article, a support ticket, raw data, a question. We'll use it to
+ test your prompt in the next step.
+
+
+
+ );
+}
+
+// ---------------------------------------------------------------------------
+// Step 3 — Write Prompt
+// ---------------------------------------------------------------------------
+
+function PromptStep({
+ promptText,
+ setPromptText,
+ onNext,
+}: {
+ promptText: string;
+ setPromptText: (s: string) => void;
+ onNext: () => void;
+}) {
+ return (
+
+
+ Write a prompt
+
+
+ Write the instructions for the LLM. Use{" "}
+
+ {"{{ input_data }}"}
+ {" "}
+ to reference your sample text.
+
+
+ Tip: A good prompt is specific about what you want and how the output
+ should look.
+
+
+
+ );
+}
+
+// ---------------------------------------------------------------------------
+// Step 4 — Run It
+// ---------------------------------------------------------------------------
+
+function RunStep({
+ endpoint,
+ sampleText,
+ promptText,
+ onComplete,
+ error,
+ setError,
+}: {
+ endpoint: EndpointResponse;
+ sampleText: string;
+ promptText: string;
+ onComplete: (result: RunDetailResponse, experiment: ExperimentResponse) => void;
+ error: string | null;
+ setError: (e: string | null) => void;
+}) {
+ const [running, setRunning] = useState(false);
+ const [response, setResponse] = useState(null);
+
+ const handleRun = useCallback(async () => {
+ setError(null);
+ setRunning(true);
+ setResponse(null);
+
+ try {
+ // 1. Create a wizard project if needed
+ const projectResp = await projects.create({
+ name: "Quick Start",
+ description: "Created by the Getting Started wizard",
+ });
+
+ // 2. Create experiment with the prompt + sample data
+ const experimentResp = await experiments.create({
+ name: "Wizard Run",
+ description: "First experiment from the wizard",
+ sample_data: { input_data: sampleText },
+ pipeline_stages: {
+ stages: [
+ {
+ prompt_template: promptText,
+ model: endpoint.default_model || "default",
+ endpoint_id: endpoint.id,
+ },
+ ],
+ },
+ scoring_config: null,
+ parameter_space: null,
+ });
+
+ // Update the experiment's project_id via the API
+ await experiments.update(experimentResp.id, {
+ description: `Project: ${projectResp.id}`,
+ });
+
+ // 3. Create and run a single run
+ const runResp = await runs.create({
+ experiment_id: experimentResp.id,
+ config: {
+ model: endpoint.default_model || "default",
+ endpoint_id: endpoint.id,
+ temperature: 0.7,
+ },
+ });
+
+ // 4. Poll for completion (simple poll with timeout)
+ let detail: RunDetailResponse | null = null;
+ const maxAttempts = 30;
+ for (let i = 0; i < maxAttempts; i++) {
+ await new Promise((r) => setTimeout(r, 2000));
+ const fetched = await runs.get(runResp.id);
+ if (
+ fetched.status === "completed" ||
+ fetched.status === "failed"
+ ) {
+ detail = fetched;
+ break;
+ }
+ }
+
+ if (!detail) {
+ setError("Run timed out. The LLM may be slow — try again.");
+ return;
+ }
+
+ if (detail.status === "failed") {
+ setError("Run failed. Check your endpoint configuration.");
+ return;
+ }
+
+ // Extract response text from stage results
+ const text =
+ detail.stage_results?.[0]?.response_raw || "(no response)";
+ setResponse(text);
+ onComplete(detail, experimentResp);
+ } catch (err: unknown) {
+ if (err instanceof ApiError) {
+ setError(`Request failed (${err.status}).`);
+ } else {
+ setError("Network error. Is the server running?");
+ }
+ } finally {
+ setRunning(false);
+ }
+ }, [endpoint, sampleText, promptText, onComplete, setError]);
+
+ return (
+
+
+ Run it!
+
+
+ We'll send your prompt to{" "}
+
+ {endpoint.name}
+ {" "}
+ and show the result right here.
+
+
+ {error && (
+
+ {error}
+
+ )}
+
+ {!response && !running && (
+
+ Run Experiment
+
+ )}
+
+ {running && (
+
+
+
+ Running your prompt... this may take a moment.
+
+
+ )}
+
+ {response && (
+
+
+
+ Result
+
+
+ {response}
+
+
+
+ Looking good? Let's move on!
+
+
+ )}
+
+ );
+}
+
+// ---------------------------------------------------------------------------
+// Step 5 — Congrats
+// ---------------------------------------------------------------------------
+
+function CongratsStep({
+ experiment,
+ onDismiss,
+}: {
+ experiment: ExperimentResponse | null;
+ onDismiss?: () => void;
+}) {
+ const navigate = useNavigate();
+
+ return (
+
+
+
+
+ You're all set!
+
+
+ You've connected an endpoint, written a prompt, and seen a result.
+ Now try a sweep — PromptLooper will automatically
+ explore different parameter combinations to find the best config.
+
+
+
+ {experiment && (
+ navigate(`/experiments/${experiment.id}`)}
+ data-testid="wiz-try-sweep"
+ className="rounded-lg bg-indigo-600 px-6 py-2.5 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 focus:outline-none focus:ring-2 focus:ring-indigo-500/50 transition"
+ >
+ Try a Sweep
+
+ )}
+ {
+ onDismiss?.();
+ navigate("/");
+ }}
+ data-testid="wiz-go-dashboard"
+ className="rounded-lg bg-white dark:bg-slate-700 px-6 py-2.5 text-sm font-semibold text-slate-700 dark:text-slate-200 shadow-sm ring-1 ring-slate-300 dark:ring-slate-600 hover:bg-slate-50 dark:hover:bg-slate-600 focus:outline-none focus:ring-2 focus:ring-indigo-500/50 transition"
+ >
+ Go to Dashboard
+
+
+
+ );
+}
+
+// ---------------------------------------------------------------------------
+// Main Wizard
+// ---------------------------------------------------------------------------
+
+export default function Wizard({ onDismiss }: WizardProps) {
+ const [stepIndex, setStepIndex] = useState(0);
+ const [error, setError] = useState(null);
+ const [submitting, setSubmitting] = useState(false);
+
+ // State accumulated across steps
+ const [endpoint, setEndpoint] = useState(null);
+ const [sampleText, setSampleText] = useState("");
+ const [promptText, setPromptText] = useState("");
+ const [experiment, setExperiment] = useState(null);
+
+ const currentStep = STEPS[stepIndex].id;
+
+ function goNext() {
+ setError(null);
+ setStepIndex((i) => Math.min(i + 1, STEPS.length - 1));
+ }
+
+ function goBack() {
+ setError(null);
+ setStepIndex((i) => Math.max(i - 1, 0));
+ }
+
+ return (
+
+ {/* Step indicator */}
+
+
+
+
+ {/* Step content */}
+
+ {currentStep === "endpoint" && (
+
{
+ setEndpoint(ep);
+ goNext();
+ }}
+ error={error}
+ setError={setError}
+ submitting={submitting}
+ setSubmitting={setSubmitting}
+ />
+ )}
+
+ {currentStep === "sample" && (
+
+ )}
+
+ {currentStep === "prompt" && (
+
+ )}
+
+ {currentStep === "run" && endpoint && (
+ {
+ setExperiment(exp);
+ goNext();
+ }}
+ error={error}
+ setError={setError}
+ />
+ )}
+
+ {currentStep === "congrats" && (
+
+ )}
+
+ {/* Back / Skip footer */}
+ {currentStep !== "congrats" && (
+
+
+ Back
+
+ {
+ onDismiss?.();
+ }}
+ data-testid="wiz-skip"
+ className="text-sm text-slate-400 dark:text-slate-500 hover:text-slate-600 dark:hover:text-slate-300 transition"
+ >
+ Skip wizard
+
+
+ )}
+
+
+ );
+}
diff --git a/frontend/src/pages/DashboardPage.test.tsx b/frontend/src/pages/DashboardPage.test.tsx
index 10ae225..e2ce682 100644
--- a/frontend/src/pages/DashboardPage.test.tsx
+++ b/frontend/src/pages/DashboardPage.test.tsx
@@ -261,7 +261,7 @@ describe("DashboardPage", () => {
expect(screen.queryByText("Active Sweeps")).not.toBeInTheDocument();
});
- it("shows empty state when no projects exist", async () => {
+ it("shows empty state when no projects exist with wizard link", async () => {
vi.spyOn(client.projects, "list").mockResolvedValue({
items: [],
total: 0,
@@ -281,13 +281,37 @@ describe("DashboardPage", () => {
});
expect(
- screen.getByText("Create your first project to get started."),
+ screen.getByTestId("empty-wizard-btn"),
).toBeInTheDocument();
expect(
screen.getByTestId("empty-create-project-btn"),
).toBeInTheDocument();
});
+ it("navigates to /wizard when Get Started is clicked", async () => {
+ vi.spyOn(client.projects, "list").mockResolvedValue({
+ items: [],
+ total: 0,
+ });
+ vi.spyOn(client.experiments, "list").mockResolvedValue({
+ items: [],
+ total: 0,
+ });
+ vi.spyOn(client.admin, "getStats").mockResolvedValue(
+ {} as Record,
+ );
+
+ const user = userEvent.setup();
+ renderDashboard();
+
+ await waitFor(() => {
+ expect(screen.getByTestId("empty-wizard-btn")).toBeInTheDocument();
+ });
+
+ await user.click(screen.getByTestId("empty-wizard-btn"));
+ expect(mockedNavigate).toHaveBeenCalledWith("/wizard");
+ });
+
it("navigates to /projects when New Project is clicked", async () => {
mockAllApis();
const user = userEvent.setup();
diff --git a/frontend/src/pages/DashboardPage.tsx b/frontend/src/pages/DashboardPage.tsx
index b7c7912..398b2cc 100644
--- a/frontend/src/pages/DashboardPage.tsx
+++ b/frontend/src/pages/DashboardPage.tsx
@@ -382,16 +382,27 @@ export default function DashboardPage() {
No projects yet
- Create your first project to get started.
+ New here? The guided wizard will walk you through your first
+ experiment in under 5 minutes.
- navigate("/projects")}
- data-testid="empty-create-project-btn"
- className="mt-5 rounded-lg bg-indigo-600 px-4 py-2 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 transition"
- >
- Create First Project
-
+
+ navigate("/wizard")}
+ data-testid="empty-wizard-btn"
+ className="rounded-lg bg-indigo-600 px-4 py-2 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 transition"
+ >
+ Get Started
+
+ navigate("/projects")}
+ data-testid="empty-create-project-btn"
+ className="rounded-lg bg-white dark:bg-slate-700 px-4 py-2 text-sm font-semibold text-slate-700 dark:text-slate-200 shadow-sm ring-1 ring-slate-300 dark:ring-slate-600 hover:bg-slate-50 dark:hover:bg-slate-600 transition"
+ >
+ Create Project Manually
+
+
) : (
diff --git a/frontend/src/pages/WizardPage.tsx b/frontend/src/pages/WizardPage.tsx
new file mode 100644
index 0000000..206fb3b
--- /dev/null
+++ b/frontend/src/pages/WizardPage.tsx
@@ -0,0 +1,22 @@
+import Wizard from "../components/Wizard";
+import { useNavigate } from "react-router-dom";
+
+export default function WizardPage() {
+ const navigate = useNavigate();
+
+ return (
+
+
+
+
+ Getting Started
+
+
+ Let's set up your first experiment in under 5 minutes.
+
+
+
navigate("/")} />
+
+
+ );
+}