MAESTRO: Extract SteeringControls into standalone component with Fork, Export, and ETA

Extracted inline SteeringControls from LivePage into standalone component.
Added Fork button (modal to clone experiment config), Export Best dropdown
(JSON/YAML/.env download), and estimated time remaining stat. LivePage
updated to import the new component. 33 tests added, all 284 tests pass.
This commit is contained in:
John Lightner 2026-04-07 03:17:47 -05:00
parent 35d72e7fa8
commit 59f18a11c3
4 changed files with 855 additions and 181 deletions

View file

@ -32,7 +32,8 @@ Build the React frontend: setup wizard, experiment builder, real-time observabil
- [x] Build the Activity Timeline component (frontend/src/components/Timeline.tsx). Chronological feed of events received via WebSocket. Each event is a card: run.started (blue), run.completed (green), new_best_found (gold), cache_hit (gray), run.failed (red). Include timestamps and key metrics. Auto-scroll to latest, with a "pause scroll" button. Filterable by event type.
<!-- Extracted from LivePage's inline implementation into standalone component. Color-coded event cards (blue=started, green=completed, amber=new_best, slate=cache_hit, red=failed, indigo=progress, emerald=sweep_done). Each card shows event label, formatted timestamp, message, and optional detail. Filter dropdown with 6 options (all/started/completed/new best/cache hits/failed). Auto-scroll toggle with visual state indicator. Handles empty states ("Waiting for events…" vs "No matching events"). Entry animation via CSS keyframes. LivePage updated to import and delegate to Timeline component. 33 tests added. -->
- [ ] Build the Steering Controls component (frontend/src/components/SteeringControls.tsx). Buttons for: Pause (yellow, shows confirmation), Resume (green), Stop (red, shows confirmation), Fork (opens modal to create new experiment from current best), Export Best (dropdown: JSON/YAML/.env). Also show: progress bar (X of Y runs), token counter (running total), estimated cost, cache hit rate percentage, and estimated time remaining.
- [x] Build the Steering Controls component (frontend/src/components/SteeringControls.tsx). Buttons for: Pause (yellow, shows confirmation), Resume (green), Stop (red, shows confirmation), Fork (opens modal to create new experiment from current best), Export Best (dropdown: JSON/YAML/.env). Also show: progress bar (X of Y runs), token counter (running total), estimated cost, cache hit rate percentage, and estimated time remaining.
<!-- Extracted from LivePage's inline SteeringControls into standalone component. Pause (amber, confirmation dialog), Resume (green, direct action), Stop (red, confirmation dialog). Fork button opens modal to create new experiment from current config (fetches experiment, clones config, calls create). Export Best dropdown with JSON/YAML/.env options using downloadBlob helper. Progress bar with X/Y runs and percentage. Stats grid: token count, estimated cost, cache hit rate, status, and estimated time remaining (computed from elapsedSeconds prop). LivePage updated to import from standalone component. 33 tests added. -->
- [ ] Build the Run Card component (frontend/src/components/RunCard.tsx). Expandable card showing: config summary, all scores with visual bars, prompt sent (collapsible), raw response (collapsible with copy button), timing breakdown per stage, cache status badge. Used in both the leaderboard detail view and the Compare page.

View file

@ -0,0 +1,398 @@
import { render, screen, within, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, it, expect, vi, beforeEach } from "vitest";
import SteeringControls from "./SteeringControls";
import type { SteeringControlsProps, SteeringProgress } from "./SteeringControls";
// ---------------------------------------------------------------------------
// Mocks
// ---------------------------------------------------------------------------
const mockPause = vi.fn().mockResolvedValue(undefined);
const mockResume = vi.fn().mockResolvedValue(undefined);
const mockStop = vi.fn().mockResolvedValue(undefined);
const mockGet = vi.fn().mockResolvedValue({
id: "exp-1",
name: "Test Experiment",
description: null,
sample_data: { text: "hello" },
pipeline_stages: { stages: [] },
scoring_config: { scorers: [] },
parameter_space: { params: [] },
status: "running",
project_id: "proj-1",
created_at: "2026-01-01",
updated_at: "2026-01-01",
});
const mockCreate = vi.fn().mockResolvedValue({ id: "exp-2" });
const mockExportBest = vi.fn().mockResolvedValue({ model: "gpt-4", temperature: 0.7 });
const mockExportYaml = vi.fn().mockResolvedValue("model: gpt-4\ntemperature: 0.7");
const mockExportEnv = vi.fn().mockResolvedValue("MODEL=gpt-4\nTEMPERATURE=0.7");
vi.mock("../api/client", () => ({
experiments: {
pause: (...args: unknown[]) => mockPause(...args),
resume: (...args: unknown[]) => mockResume(...args),
stop: (...args: unknown[]) => mockStop(...args),
get: (...args: unknown[]) => mockGet(...args),
create: (...args: unknown[]) => mockCreate(...args),
},
exportApi: {
best: (...args: unknown[]) => mockExportBest(...args),
yaml: (...args: unknown[]) => mockExportYaml(...args),
env: (...args: unknown[]) => mockExportEnv(...args),
},
}));
// ---------------------------------------------------------------------------
// Fixtures
// ---------------------------------------------------------------------------
const DEFAULT_PROGRESS: SteeringProgress = {
completed: 5,
total: 20,
cache_hits: 2,
tokens_total: 12500,
cost_total: 0.0312,
};
function defaultProps(overrides: Partial<SteeringControlsProps> = {}): SteeringControlsProps {
return {
experimentId: "exp-1",
experimentStatus: "running",
progress: DEFAULT_PROGRESS,
onStatusChange: vi.fn(),
...overrides,
};
}
beforeEach(() => {
vi.clearAllMocks();
});
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
describe("SteeringControls", () => {
// ---- Rendering ----
it("renders the component with data-testid", () => {
render(<SteeringControls {...defaultProps()} />);
expect(screen.getByTestId("steering-controls")).toBeInTheDocument();
});
it("shows Pause and Stop buttons when running", () => {
render(<SteeringControls {...defaultProps({ experimentStatus: "running" })} />);
expect(screen.getByTestId("pause-btn")).toBeInTheDocument();
expect(screen.getByTestId("stop-btn")).toBeInTheDocument();
expect(screen.queryByTestId("resume-btn")).not.toBeInTheDocument();
});
it("shows Resume and Stop buttons when paused", () => {
render(<SteeringControls {...defaultProps({ experimentStatus: "paused" })} />);
expect(screen.getByTestId("resume-btn")).toBeInTheDocument();
expect(screen.getByTestId("stop-btn")).toBeInTheDocument();
expect(screen.queryByTestId("pause-btn")).not.toBeInTheDocument();
});
it("hides Pause/Resume/Stop when idle", () => {
render(<SteeringControls {...defaultProps({ experimentStatus: "idle" })} />);
expect(screen.queryByTestId("pause-btn")).not.toBeInTheDocument();
expect(screen.queryByTestId("resume-btn")).not.toBeInTheDocument();
expect(screen.queryByTestId("stop-btn")).not.toBeInTheDocument();
});
it("always shows Fork and Export Best buttons", () => {
render(<SteeringControls {...defaultProps({ experimentStatus: "idle" })} />);
expect(screen.getByTestId("fork-btn")).toBeInTheDocument();
expect(screen.getByTestId("export-btn")).toBeInTheDocument();
});
// ---- Progress bar ----
it("renders progress bar with correct percentage", () => {
render(<SteeringControls {...defaultProps()} />);
const fill = screen.getByTestId("progress-bar-fill");
expect(fill).toHaveStyle({ width: "25%" });
expect(screen.getByText("5 / 20 runs")).toBeInTheDocument();
expect(screen.getByText("25%")).toBeInTheDocument();
});
it("renders 0% when total is 0", () => {
const progress = { ...DEFAULT_PROGRESS, completed: 0, total: 0 };
render(<SteeringControls {...defaultProps({ progress })} />);
const fill = screen.getByTestId("progress-bar-fill");
expect(fill).toHaveStyle({ width: "0%" });
});
// ---- Stats ----
it("displays token count", () => {
render(<SteeringControls {...defaultProps()} />);
expect(screen.getByText("12,500")).toBeInTheDocument();
});
it("displays estimated cost", () => {
render(<SteeringControls {...defaultProps()} />);
expect(screen.getByText("$0.0312")).toBeInTheDocument();
});
it("displays cache rate percentage", () => {
render(<SteeringControls {...defaultProps()} />);
expect(screen.getByText("40%")).toBeInTheDocument();
});
it("displays experiment status", () => {
render(<SteeringControls {...defaultProps({ experimentStatus: "running" })} />);
expect(screen.getByText("running")).toBeInTheDocument();
});
// ---- ETA ----
it("shows estimated time remaining when elapsedSeconds is provided", () => {
render(
<SteeringControls
{...defaultProps({ elapsedSeconds: 50 })}
/>,
);
// 5 runs in 50s = 10s/run, 15 remaining => 150s => 2m 30s
expect(screen.getByTestId("eta-stat")).toBeInTheDocument();
expect(screen.getByText("2m 30s")).toBeInTheDocument();
});
it("hides ETA when no elapsedSeconds", () => {
render(<SteeringControls {...defaultProps()} />);
expect(screen.queryByTestId("eta-stat")).not.toBeInTheDocument();
});
it("hides ETA when all runs are completed", () => {
const progress = { ...DEFAULT_PROGRESS, completed: 20, total: 20 };
render(
<SteeringControls
{...defaultProps({ progress, elapsedSeconds: 100 })}
/>,
);
expect(screen.queryByTestId("eta-stat")).not.toBeInTheDocument();
});
// ---- Pause flow ----
it("shows confirmation when Pause is clicked", async () => {
const user = userEvent.setup();
render(<SteeringControls {...defaultProps()} />);
await user.click(screen.getByTestId("pause-btn"));
expect(screen.getByTestId("pause-confirm")).toBeInTheDocument();
expect(screen.getByText("Pause sweep?")).toBeInTheDocument();
});
it("calls pause API and updates status on confirm", async () => {
const user = userEvent.setup();
const onStatusChange = vi.fn();
render(<SteeringControls {...defaultProps({ onStatusChange })} />);
await user.click(screen.getByTestId("pause-btn"));
await user.click(screen.getByTestId("pause-confirm-btn"));
await waitFor(() => expect(mockPause).toHaveBeenCalledWith("exp-1"));
expect(onStatusChange).toHaveBeenCalledWith("paused");
});
it("hides confirmation when Cancel is clicked during pause", async () => {
const user = userEvent.setup();
render(<SteeringControls {...defaultProps()} />);
await user.click(screen.getByTestId("pause-btn"));
expect(screen.getByTestId("pause-confirm")).toBeInTheDocument();
await user.click(screen.getByText("Cancel"));
expect(screen.queryByTestId("pause-confirm")).not.toBeInTheDocument();
});
// ---- Resume ----
it("calls resume API on click", async () => {
const user = userEvent.setup();
const onStatusChange = vi.fn();
render(
<SteeringControls
{...defaultProps({ experimentStatus: "paused", onStatusChange })}
/>,
);
await user.click(screen.getByTestId("resume-btn"));
await waitFor(() => expect(mockResume).toHaveBeenCalledWith("exp-1"));
expect(onStatusChange).toHaveBeenCalledWith("running");
});
// ---- Stop flow ----
it("shows confirmation when Stop is clicked", async () => {
const user = userEvent.setup();
render(<SteeringControls {...defaultProps()} />);
await user.click(screen.getByTestId("stop-btn"));
expect(screen.getByTestId("stop-confirm")).toBeInTheDocument();
expect(screen.getByText("Stop sweep?")).toBeInTheDocument();
});
it("calls stop API and updates status on confirm", async () => {
const user = userEvent.setup();
const onStatusChange = vi.fn();
render(<SteeringControls {...defaultProps({ onStatusChange })} />);
await user.click(screen.getByTestId("stop-btn"));
await user.click(screen.getByTestId("stop-confirm-btn"));
await waitFor(() => expect(mockStop).toHaveBeenCalledWith("exp-1"));
expect(onStatusChange).toHaveBeenCalledWith("stopped");
});
// ---- Fork ----
it("opens fork modal when Fork is clicked", async () => {
const user = userEvent.setup();
render(<SteeringControls {...defaultProps()} />);
await user.click(screen.getByTestId("fork-btn"));
expect(screen.getByTestId("fork-modal")).toBeInTheDocument();
expect(screen.getByText("Fork Experiment")).toBeInTheDocument();
});
it("shows error when fork name is empty", async () => {
const user = userEvent.setup();
render(<SteeringControls {...defaultProps()} />);
await user.click(screen.getByTestId("fork-btn"));
await user.click(screen.getByTestId("fork-submit-btn"));
expect(screen.getByTestId("fork-error")).toHaveTextContent("Name is required");
});
it("creates forked experiment with name", async () => {
const user = userEvent.setup();
const onFork = vi.fn();
render(<SteeringControls {...defaultProps({ onFork })} />);
await user.click(screen.getByTestId("fork-btn"));
await user.type(screen.getByTestId("fork-name-input"), "My Fork");
await user.click(screen.getByTestId("fork-submit-btn"));
await waitFor(() => expect(mockGet).toHaveBeenCalledWith("exp-1"));
await waitFor(() =>
expect(mockCreate).toHaveBeenCalledWith(
expect.objectContaining({ name: "My Fork" }),
),
);
expect(onFork).toHaveBeenCalledWith("exp-1");
});
it("shows error when fork API fails", async () => {
mockCreate.mockRejectedValueOnce(new Error("fail"));
const user = userEvent.setup();
render(<SteeringControls {...defaultProps()} />);
await user.click(screen.getByTestId("fork-btn"));
await user.type(screen.getByTestId("fork-name-input"), "Bad Fork");
await user.click(screen.getByTestId("fork-submit-btn"));
await waitFor(() =>
expect(screen.getByTestId("fork-error")).toHaveTextContent(
"Failed to fork experiment",
),
);
});
it("closes fork modal on Cancel", async () => {
const user = userEvent.setup();
render(<SteeringControls {...defaultProps()} />);
await user.click(screen.getByTestId("fork-btn"));
expect(screen.getByTestId("fork-modal")).toBeInTheDocument();
// Click cancel button inside modal
const modal = screen.getByTestId("fork-modal");
await user.click(within(modal).getByText("Cancel"));
expect(screen.queryByTestId("fork-modal")).not.toBeInTheDocument();
});
// ---- Export ----
it("toggles export menu on click", async () => {
const user = userEvent.setup();
render(<SteeringControls {...defaultProps()} />);
expect(screen.queryByTestId("export-menu")).not.toBeInTheDocument();
await user.click(screen.getByTestId("export-btn"));
expect(screen.getByTestId("export-menu")).toBeInTheDocument();
await user.click(screen.getByTestId("export-btn"));
expect(screen.queryByTestId("export-menu")).not.toBeInTheDocument();
});
it("exports JSON on click", async () => {
const user = userEvent.setup();
// Mock URL.createObjectURL and revokeObjectURL
const mockCreateObjectURL = vi.fn().mockReturnValue("blob:mock");
const mockRevokeObjectURL = vi.fn();
global.URL.createObjectURL = mockCreateObjectURL;
global.URL.revokeObjectURL = mockRevokeObjectURL;
render(<SteeringControls {...defaultProps()} />);
await user.click(screen.getByTestId("export-btn"));
await user.click(screen.getByTestId("export-json"));
await waitFor(() => expect(mockExportBest).toHaveBeenCalledWith("exp-1"));
});
it("exports YAML on click", async () => {
const user = userEvent.setup();
global.URL.createObjectURL = vi.fn().mockReturnValue("blob:mock");
global.URL.revokeObjectURL = vi.fn();
render(<SteeringControls {...defaultProps()} />);
await user.click(screen.getByTestId("export-btn"));
await user.click(screen.getByTestId("export-yaml"));
await waitFor(() => expect(mockExportYaml).toHaveBeenCalledWith("exp-1"));
});
it("exports .env on click", async () => {
const user = userEvent.setup();
global.URL.createObjectURL = vi.fn().mockReturnValue("blob:mock");
global.URL.revokeObjectURL = vi.fn();
render(<SteeringControls {...defaultProps()} />);
await user.click(screen.getByTestId("export-btn"));
await user.click(screen.getByTestId("export-env"));
await waitFor(() => expect(mockExportEnv).toHaveBeenCalledWith("exp-1"));
});
// ---- Sweeping status treated as running ----
it("treats sweeping status like running", () => {
render(<SteeringControls {...defaultProps({ experimentStatus: "sweeping" })} />);
expect(screen.getByTestId("pause-btn")).toBeInTheDocument();
expect(screen.getByTestId("stop-btn")).toBeInTheDocument();
});
// ---- API failure silence ----
it("handles pause API failure gracefully", async () => {
mockPause.mockRejectedValueOnce(new Error("network"));
const user = userEvent.setup();
const onStatusChange = vi.fn();
render(<SteeringControls {...defaultProps({ onStatusChange })} />);
await user.click(screen.getByTestId("pause-btn"));
await user.click(screen.getByTestId("pause-confirm-btn"));
await waitFor(() => expect(mockPause).toHaveBeenCalled());
// Status change should NOT be called on failure
expect(onStatusChange).not.toHaveBeenCalled();
});
// ---- ETA formatting ----
it("formats ETA in hours when large", () => {
// 5 runs in 500s = 100s/run, 15 remaining => 1500s => 25m
render(
<SteeringControls
{...defaultProps({ elapsedSeconds: 500 })}
/>,
);
expect(screen.getByText("25m 0s")).toBeInTheDocument();
});
it("formats ETA with hours for very long estimates", () => {
// 5 runs in 5000s = 1000s/run, 15 remaining => 15000s => 4h 10m
render(
<SteeringControls
{...defaultProps({ elapsedSeconds: 5000 })}
/>,
);
expect(screen.getByText("4h 10m")).toBeInTheDocument();
});
});

View file

@ -0,0 +1,454 @@
import { useState, useMemo } from "react";
import { experiments, exportApi } from "../api/client";
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export interface SteeringProgress {
completed: number;
total: number;
cache_hits: number;
tokens_total: number;
cost_total: number;
}
export interface SteeringControlsProps {
experimentId: string;
experimentStatus: string;
progress: SteeringProgress;
onStatusChange: (status: string) => void;
/** Called when user wants to fork from current best config */
onFork?: (experimentId: string) => void;
/** Elapsed time in seconds since sweep started (for ETA calculation) */
elapsedSeconds?: number;
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function formatDuration(seconds: number): string {
if (!Number.isFinite(seconds) || seconds <= 0) return "—";
const h = Math.floor(seconds / 3600);
const m = Math.floor((seconds % 3600) / 60);
const s = Math.floor(seconds % 60);
if (h > 0) return `${h}h ${m}m`;
if (m > 0) return `${m}m ${s}s`;
return `${s}s`;
}
function downloadBlob(content: string, filename: string, mime: string) {
const blob = new Blob([content], { type: mime });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
// ---------------------------------------------------------------------------
// Component
// ---------------------------------------------------------------------------
export default function SteeringControls({
experimentId,
experimentStatus,
progress,
onStatusChange,
onFork,
elapsedSeconds,
}: SteeringControlsProps) {
const [confirming, setConfirming] = useState<"pause" | "stop" | null>(null);
const [actionLoading, setActionLoading] = useState(false);
const [showForkModal, setShowForkModal] = useState(false);
const [forkName, setForkName] = useState("");
const [forkLoading, setForkLoading] = useState(false);
const [forkError, setForkError] = useState<string | null>(null);
const [showExportMenu, setShowExportMenu] = useState(false);
const [exportLoading, setExportLoading] = useState(false);
// -------------------------------------------------------------------------
// Derived state
// -------------------------------------------------------------------------
const isRunning =
experimentStatus === "running" || experimentStatus === "sweeping";
const isPaused = experimentStatus === "paused";
const isActive = isRunning || isPaused;
const pct =
progress.total > 0
? Math.round((progress.completed / progress.total) * 100)
: 0;
const cacheRate =
progress.completed > 0
? Math.round((progress.cache_hits / progress.completed) * 100)
: 0;
const estimatedTimeRemaining = useMemo(() => {
if (
!elapsedSeconds ||
elapsedSeconds <= 0 ||
progress.completed <= 0 ||
progress.total <= 0
)
return null;
const remaining = progress.total - progress.completed;
if (remaining <= 0) return null;
const secsPerRun = elapsedSeconds / progress.completed;
return secsPerRun * remaining;
}, [elapsedSeconds, progress.completed, progress.total]);
// -------------------------------------------------------------------------
// Action handlers
// -------------------------------------------------------------------------
async function handleAction(action: "pause" | "resume" | "stop") {
setActionLoading(true);
try {
if (action === "pause") {
await experiments.pause(experimentId);
onStatusChange("paused");
} else if (action === "resume") {
await experiments.resume(experimentId);
onStatusChange("running");
} else if (action === "stop") {
await experiments.stop(experimentId);
onStatusChange("stopped");
}
} catch {
// Silently handle — status will sync via WS
} finally {
setActionLoading(false);
setConfirming(null);
}
}
async function handleFork() {
if (!forkName.trim()) {
setForkError("Name is required");
return;
}
setForkLoading(true);
setForkError(null);
try {
// Get current experiment to clone its config
const exp = await experiments.get(experimentId);
await experiments.create({
name: forkName.trim(),
description: `Forked from "${exp.name}"`,
sample_data: exp.sample_data,
pipeline_stages: exp.pipeline_stages,
scoring_config: exp.scoring_config,
parameter_space: exp.parameter_space,
});
setShowForkModal(false);
setForkName("");
if (onFork) onFork(experimentId);
} catch {
setForkError("Failed to fork experiment");
} finally {
setForkLoading(false);
}
}
async function handleExport(format: "json" | "yaml" | "env") {
setExportLoading(true);
setShowExportMenu(false);
try {
if (format === "json") {
const data = await exportApi.best(experimentId);
downloadBlob(JSON.stringify(data, null, 2), "best-config.json", "application/json");
} else if (format === "yaml") {
const data = await exportApi.yaml(experimentId);
downloadBlob(String(data), "best-config.yaml", "text/yaml");
} else if (format === "env") {
const data = await exportApi.env(experimentId);
downloadBlob(String(data), "best-config.env", "text/plain");
}
} catch {
// Export failed silently — could show toast in future
} finally {
setExportLoading(false);
}
}
// -------------------------------------------------------------------------
// Render
// -------------------------------------------------------------------------
return (
<div data-testid="steering-controls" className="space-y-4">
{/* Action buttons */}
<div className="flex flex-wrap gap-2">
{/* Pause */}
{isRunning && confirming !== "pause" && (
<button
type="button"
data-testid="pause-btn"
onClick={() => setConfirming("pause")}
disabled={actionLoading}
className="rounded-lg bg-amber-500 px-3 py-2 text-sm font-medium text-white hover:bg-amber-400 transition disabled:opacity-50"
>
Pause
</button>
)}
{isRunning && confirming === "pause" && (
<div className="flex items-center gap-2" data-testid="pause-confirm">
<span className="text-sm text-amber-600 dark:text-amber-400">
Pause sweep?
</span>
<button
type="button"
data-testid="pause-confirm-btn"
onClick={() => handleAction("pause")}
disabled={actionLoading}
className="rounded-lg bg-amber-500 px-3 py-1.5 text-xs font-medium text-white hover:bg-amber-400 transition"
>
Confirm
</button>
<button
type="button"
onClick={() => setConfirming(null)}
className="text-xs text-slate-500 hover:text-slate-700 dark:hover:text-slate-300"
>
Cancel
</button>
</div>
)}
{/* Resume */}
{isPaused && (
<button
type="button"
data-testid="resume-btn"
onClick={() => handleAction("resume")}
disabled={actionLoading}
className="rounded-lg bg-green-600 px-3 py-2 text-sm font-medium text-white hover:bg-green-500 transition disabled:opacity-50"
>
Resume
</button>
)}
{/* Stop */}
{isActive && confirming !== "stop" && (
<button
type="button"
data-testid="stop-btn"
onClick={() => setConfirming("stop")}
disabled={actionLoading}
className="rounded-lg bg-red-600 px-3 py-2 text-sm font-medium text-white hover:bg-red-500 transition disabled:opacity-50"
>
Stop
</button>
)}
{isActive && confirming === "stop" && (
<div className="flex items-center gap-2" data-testid="stop-confirm">
<span className="text-sm text-red-600 dark:text-red-400">
Stop sweep?
</span>
<button
type="button"
data-testid="stop-confirm-btn"
onClick={() => handleAction("stop")}
disabled={actionLoading}
className="rounded-lg bg-red-600 px-3 py-1.5 text-xs font-medium text-white hover:bg-red-500 transition"
>
Confirm
</button>
<button
type="button"
onClick={() => setConfirming(null)}
className="text-xs text-slate-500 hover:text-slate-700 dark:hover:text-slate-300"
>
Cancel
</button>
</div>
)}
{/* Fork */}
<button
type="button"
data-testid="fork-btn"
onClick={() => setShowForkModal(true)}
className="rounded-lg bg-indigo-600 px-3 py-2 text-sm font-medium text-white hover:bg-indigo-500 transition"
>
Fork
</button>
{/* Export Best */}
<div className="relative">
<button
type="button"
data-testid="export-btn"
onClick={() => setShowExportMenu((prev) => !prev)}
disabled={exportLoading}
className="rounded-lg bg-slate-600 px-3 py-2 text-sm font-medium text-white hover:bg-slate-500 transition disabled:opacity-50"
>
{exportLoading ? "Exporting…" : "Export Best"}
</button>
{showExportMenu && (
<div
data-testid="export-menu"
className="absolute right-0 mt-1 w-36 rounded-lg bg-white dark:bg-slate-700 shadow-lg ring-1 ring-slate-200 dark:ring-slate-600 z-10"
>
<button
type="button"
data-testid="export-json"
onClick={() => handleExport("json")}
className="block w-full text-left px-3 py-2 text-sm text-slate-700 dark:text-slate-200 hover:bg-slate-100 dark:hover:bg-slate-600 rounded-t-lg"
>
JSON
</button>
<button
type="button"
data-testid="export-yaml"
onClick={() => handleExport("yaml")}
className="block w-full text-left px-3 py-2 text-sm text-slate-700 dark:text-slate-200 hover:bg-slate-100 dark:hover:bg-slate-600"
>
YAML
</button>
<button
type="button"
data-testid="export-env"
onClick={() => handleExport("env")}
className="block w-full text-left px-3 py-2 text-sm text-slate-700 dark:text-slate-200 hover:bg-slate-100 dark:hover:bg-slate-600 rounded-b-lg"
>
.env
</button>
</div>
)}
</div>
</div>
{/* Progress bar */}
<div>
<div className="flex items-center justify-between text-xs text-slate-500 dark:text-slate-400 mb-1">
<span>
{progress.completed} / {progress.total} runs
</span>
<span>{pct}%</span>
</div>
<div className="w-full h-2 rounded-full bg-slate-200 dark:bg-slate-700 overflow-hidden">
<div
data-testid="progress-bar-fill"
className="h-full rounded-full bg-indigo-500 transition-all duration-500"
style={{ width: `${pct}%` }}
/>
</div>
</div>
{/* Stats */}
<div className="grid grid-cols-2 gap-3 text-sm">
<div className="rounded-lg bg-slate-50 dark:bg-slate-800 p-2.5">
<div className="text-xs text-slate-400 dark:text-slate-500">
Tokens
</div>
<div className="font-semibold text-slate-700 dark:text-slate-300 tabular-nums">
{progress.tokens_total.toLocaleString()}
</div>
</div>
<div className="rounded-lg bg-slate-50 dark:bg-slate-800 p-2.5">
<div className="text-xs text-slate-400 dark:text-slate-500">
Est. Cost
</div>
<div className="font-semibold text-slate-700 dark:text-slate-300 tabular-nums">
${progress.cost_total.toFixed(4)}
</div>
</div>
<div className="rounded-lg bg-slate-50 dark:bg-slate-800 p-2.5">
<div className="text-xs text-slate-400 dark:text-slate-500">
Cache Rate
</div>
<div className="font-semibold text-slate-700 dark:text-slate-300 tabular-nums">
{cacheRate}%
</div>
</div>
<div className="rounded-lg bg-slate-50 dark:bg-slate-800 p-2.5">
<div className="text-xs text-slate-400 dark:text-slate-500">
Status
</div>
<div className="font-semibold text-slate-700 dark:text-slate-300 capitalize">
{experimentStatus}
</div>
</div>
{estimatedTimeRemaining !== null && (
<div
className="col-span-2 rounded-lg bg-slate-50 dark:bg-slate-800 p-2.5"
data-testid="eta-stat"
>
<div className="text-xs text-slate-400 dark:text-slate-500">
Est. Time Remaining
</div>
<div className="font-semibold text-slate-700 dark:text-slate-300 tabular-nums">
{formatDuration(estimatedTimeRemaining)}
</div>
</div>
)}
</div>
{/* Fork Modal */}
{showForkModal && (
<div
data-testid="fork-modal"
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50"
onClick={(e) => {
if (e.target === e.currentTarget) setShowForkModal(false);
}}
>
<div className="w-full max-w-md rounded-xl bg-white dark:bg-slate-800 shadow-xl p-6">
<h3 className="text-lg font-semibold text-slate-900 dark:text-white mb-4">
Fork Experiment
</h3>
<p className="text-sm text-slate-500 dark:text-slate-400 mb-4">
Create a new experiment based on the current best configuration.
</p>
<input
type="text"
data-testid="fork-name-input"
placeholder="New experiment name"
value={forkName}
onChange={(e) => setForkName(e.target.value)}
className="w-full rounded-lg border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-700 px-3 py-2 text-sm text-slate-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-indigo-500"
/>
{forkError && (
<p
data-testid="fork-error"
className="mt-2 text-sm text-red-600 dark:text-red-400"
>
{forkError}
</p>
)}
<div className="mt-4 flex justify-end gap-2">
<button
type="button"
onClick={() => {
setShowForkModal(false);
setForkName("");
setForkError(null);
}}
className="rounded-lg px-3 py-2 text-sm font-medium text-slate-700 dark:text-slate-300 hover:bg-slate-100 dark:hover:bg-slate-700 transition"
>
Cancel
</button>
<button
type="button"
data-testid="fork-submit-btn"
onClick={handleFork}
disabled={forkLoading}
className="rounded-lg bg-indigo-600 px-4 py-2 text-sm font-medium text-white hover:bg-indigo-500 transition disabled:opacity-50"
>
{forkLoading ? "Creating…" : "Fork"}
</button>
</div>
</div>
</div>
)}
</div>
);
}

View file

@ -15,6 +15,7 @@ import Leaderboard from "../components/Leaderboard";
import type { LeaderboardRow } from "../components/Leaderboard";
import Timeline from "../components/Timeline";
import type { TimelineEntry } from "../components/Timeline";
import SteeringControls from "../components/SteeringControls";
// ---------------------------------------------------------------------------
// Types
@ -70,186 +71,6 @@ function configSummary(config?: Record<string, unknown>): string {
}
// ---------------------------------------------------------------------------
// Steering Controls
// ---------------------------------------------------------------------------
function SteeringControls({
experimentId,
experimentStatus,
progress,
onStatusChange,
}: {
experimentId: string;
experimentStatus: string;
progress: { completed: number; total: number; cache_hits: number; tokens_total: number; cost_total: number };
onStatusChange: (status: string) => void;
}) {
const [confirming, setConfirming] = useState<"pause" | "stop" | null>(null);
const [actionLoading, setActionLoading] = useState(false);
async function handleAction(action: "pause" | "resume" | "stop") {
setActionLoading(true);
try {
if (action === "pause") {
await experiments.pause(experimentId);
onStatusChange("paused");
} else if (action === "resume") {
await experiments.resume(experimentId);
onStatusChange("running");
} else if (action === "stop") {
await experiments.stop(experimentId);
onStatusChange("stopped");
}
} catch {
// Silently handle — status will sync via WS
} finally {
setActionLoading(false);
setConfirming(null);
}
}
const isRunning = experimentStatus === "running" || experimentStatus === "sweeping";
const isPaused = experimentStatus === "paused";
const pct =
progress.total > 0
? Math.round((progress.completed / progress.total) * 100)
: 0;
const cacheRate =
progress.completed > 0
? Math.round((progress.cache_hits / progress.completed) * 100)
: 0;
return (
<div data-testid="steering-controls" className="space-y-4">
{/* Action buttons */}
<div className="flex flex-wrap gap-2">
{isRunning && confirming !== "pause" && (
<button
type="button"
onClick={() => setConfirming("pause")}
disabled={actionLoading}
className="rounded-lg bg-amber-500 px-3 py-2 text-sm font-medium text-white hover:bg-amber-400 transition disabled:opacity-50"
>
Pause
</button>
)}
{isRunning && confirming === "pause" && (
<div className="flex items-center gap-2">
<span className="text-sm text-amber-600 dark:text-amber-400">
Pause sweep?
</span>
<button
type="button"
onClick={() => handleAction("pause")}
disabled={actionLoading}
className="rounded-lg bg-amber-500 px-3 py-1.5 text-xs font-medium text-white hover:bg-amber-400 transition"
>
Confirm
</button>
<button
type="button"
onClick={() => setConfirming(null)}
className="text-xs text-slate-500 hover:text-slate-700 dark:hover:text-slate-300"
>
Cancel
</button>
</div>
)}
{isPaused && (
<button
type="button"
onClick={() => handleAction("resume")}
disabled={actionLoading}
className="rounded-lg bg-green-600 px-3 py-2 text-sm font-medium text-white hover:bg-green-500 transition disabled:opacity-50"
>
Resume
</button>
)}
{(isRunning || isPaused) && confirming !== "stop" && (
<button
type="button"
onClick={() => setConfirming("stop")}
disabled={actionLoading}
className="rounded-lg bg-red-600 px-3 py-2 text-sm font-medium text-white hover:bg-red-500 transition disabled:opacity-50"
>
Stop
</button>
)}
{(isRunning || isPaused) && confirming === "stop" && (
<div className="flex items-center gap-2">
<span className="text-sm text-red-600 dark:text-red-400">
Stop sweep?
</span>
<button
type="button"
onClick={() => handleAction("stop")}
disabled={actionLoading}
className="rounded-lg bg-red-600 px-3 py-1.5 text-xs font-medium text-white hover:bg-red-500 transition"
>
Confirm
</button>
<button
type="button"
onClick={() => setConfirming(null)}
className="text-xs text-slate-500 hover:text-slate-700 dark:hover:text-slate-300"
>
Cancel
</button>
</div>
)}
</div>
{/* Progress bar */}
<div>
<div className="flex items-center justify-between text-xs text-slate-500 dark:text-slate-400 mb-1">
<span>
{progress.completed} / {progress.total} runs
</span>
<span>{pct}%</span>
</div>
<div className="w-full h-2 rounded-full bg-slate-200 dark:bg-slate-700 overflow-hidden">
<div
data-testid="progress-bar-fill"
className="h-full rounded-full bg-indigo-500 transition-all duration-500"
style={{ width: `${pct}%` }}
/>
</div>
</div>
{/* Stats */}
<div className="grid grid-cols-2 gap-3 text-sm">
<div className="rounded-lg bg-slate-50 dark:bg-slate-800 p-2.5">
<div className="text-xs text-slate-400 dark:text-slate-500">Tokens</div>
<div className="font-semibold text-slate-700 dark:text-slate-300 tabular-nums">
{progress.tokens_total.toLocaleString()}
</div>
</div>
<div className="rounded-lg bg-slate-50 dark:bg-slate-800 p-2.5">
<div className="text-xs text-slate-400 dark:text-slate-500">Est. Cost</div>
<div className="font-semibold text-slate-700 dark:text-slate-300 tabular-nums">
${progress.cost_total.toFixed(4)}
</div>
</div>
<div className="rounded-lg bg-slate-50 dark:bg-slate-800 p-2.5">
<div className="text-xs text-slate-400 dark:text-slate-500">Cache Rate</div>
<div className="font-semibold text-slate-700 dark:text-slate-300 tabular-nums">
{cacheRate}%
</div>
</div>
<div className="rounded-lg bg-slate-50 dark:bg-slate-800 p-2.5">
<div className="text-xs text-slate-400 dark:text-slate-500">Status</div>
<div className="font-semibold text-slate-700 dark:text-slate-300 capitalize">
{experimentStatus}
</div>
</div>
</div>
</div>
);
}
// ---------------------------------------------------------------------------
// Connection Status Indicator
// ---------------------------------------------------------------------------