From 59f18a11c3220af0a784594c83b3ec0b0cdd1163 Mon Sep 17 00:00:00 2001 From: John Lightner Date: Tue, 7 Apr 2026 03:17:47 -0500 Subject: [PATCH] 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. --- Auto Run Docs/02b-frontend-dashboard.md | 3 +- .../src/components/SteeringControls.test.tsx | 398 +++++++++++++++ frontend/src/components/SteeringControls.tsx | 454 ++++++++++++++++++ frontend/src/pages/LivePage.tsx | 181 +------ 4 files changed, 855 insertions(+), 181 deletions(-) create mode 100644 frontend/src/components/SteeringControls.test.tsx create mode 100644 frontend/src/components/SteeringControls.tsx diff --git a/Auto Run Docs/02b-frontend-dashboard.md b/Auto Run Docs/02b-frontend-dashboard.md index 396cc25..5aa4e50 100644 --- a/Auto Run Docs/02b-frontend-dashboard.md +++ b/Auto Run Docs/02b-frontend-dashboard.md @@ -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. -- [ ] 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. + - [ ] 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. diff --git a/frontend/src/components/SteeringControls.test.tsx b/frontend/src/components/SteeringControls.test.tsx new file mode 100644 index 0000000..acd4ee9 --- /dev/null +++ b/frontend/src/components/SteeringControls.test.tsx @@ -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 { + 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(); + expect(screen.getByTestId("steering-controls")).toBeInTheDocument(); + }); + + it("shows Pause and Stop buttons when running", () => { + render(); + 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(); + 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(); + 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(); + expect(screen.getByTestId("fork-btn")).toBeInTheDocument(); + expect(screen.getByTestId("export-btn")).toBeInTheDocument(); + }); + + // ---- Progress bar ---- + + it("renders progress bar with correct percentage", () => { + render(); + 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(); + const fill = screen.getByTestId("progress-bar-fill"); + expect(fill).toHaveStyle({ width: "0%" }); + }); + + // ---- Stats ---- + + it("displays token count", () => { + render(); + expect(screen.getByText("12,500")).toBeInTheDocument(); + }); + + it("displays estimated cost", () => { + render(); + expect(screen.getByText("$0.0312")).toBeInTheDocument(); + }); + + it("displays cache rate percentage", () => { + render(); + expect(screen.getByText("40%")).toBeInTheDocument(); + }); + + it("displays experiment status", () => { + render(); + expect(screen.getByText("running")).toBeInTheDocument(); + }); + + // ---- ETA ---- + + it("shows estimated time remaining when elapsedSeconds is provided", () => { + render( + , + ); + // 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(); + expect(screen.queryByTestId("eta-stat")).not.toBeInTheDocument(); + }); + + it("hides ETA when all runs are completed", () => { + const progress = { ...DEFAULT_PROGRESS, completed: 20, total: 20 }; + render( + , + ); + expect(screen.queryByTestId("eta-stat")).not.toBeInTheDocument(); + }); + + // ---- Pause flow ---- + + it("shows confirmation when Pause is clicked", async () => { + const user = userEvent.setup(); + render(); + 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(); + 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(); + 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( + , + ); + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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( + , + ); + 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( + , + ); + expect(screen.getByText("4h 10m")).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/components/SteeringControls.tsx b/frontend/src/components/SteeringControls.tsx new file mode 100644 index 0000000..e12825b --- /dev/null +++ b/frontend/src/components/SteeringControls.tsx @@ -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(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 ( +
+ {/* Action buttons */} +
+ {/* Pause */} + {isRunning && confirming !== "pause" && ( + + )} + {isRunning && confirming === "pause" && ( +
+ + Pause sweep? + + + +
+ )} + + {/* Resume */} + {isPaused && ( + + )} + + {/* Stop */} + {isActive && confirming !== "stop" && ( + + )} + {isActive && confirming === "stop" && ( +
+ + Stop sweep? + + + +
+ )} + + {/* Fork */} + + + {/* Export Best */} +
+ + {showExportMenu && ( +
+ + + +
+ )} +
+
+ + {/* Progress bar */} +
+
+ + {progress.completed} / {progress.total} runs + + {pct}% +
+
+
+
+
+ + {/* Stats */} +
+
+
+ Tokens +
+
+ {progress.tokens_total.toLocaleString()} +
+
+
+
+ Est. Cost +
+
+ ${progress.cost_total.toFixed(4)} +
+
+
+
+ Cache Rate +
+
+ {cacheRate}% +
+
+
+
+ Status +
+
+ {experimentStatus} +
+
+ {estimatedTimeRemaining !== null && ( +
+
+ Est. Time Remaining +
+
+ {formatDuration(estimatedTimeRemaining)} +
+
+ )} +
+ + {/* Fork Modal */} + {showForkModal && ( +
{ + if (e.target === e.currentTarget) setShowForkModal(false); + }} + > +
+

+ Fork Experiment +

+

+ Create a new experiment based on the current best configuration. +

+ 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 && ( +

+ {forkError} +

+ )} +
+ + +
+
+
+ )} +
+ ); +} diff --git a/frontend/src/pages/LivePage.tsx b/frontend/src/pages/LivePage.tsx index 4923c73..b2d198c 100644 --- a/frontend/src/pages/LivePage.tsx +++ b/frontend/src/pages/LivePage.tsx @@ -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 { } -// --------------------------------------------------------------------------- -// 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 ( -
- {/* Action buttons */} -
- {isRunning && confirming !== "pause" && ( - - )} - {isRunning && confirming === "pause" && ( -
- - Pause sweep? - - - -
- )} - - {isPaused && ( - - )} - - {(isRunning || isPaused) && confirming !== "stop" && ( - - )} - {(isRunning || isPaused) && confirming === "stop" && ( -
- - Stop sweep? - - - -
- )} -
- - {/* Progress bar */} -
-
- - {progress.completed} / {progress.total} runs - - {pct}% -
-
-
-
-
- - {/* Stats */} -
-
-
Tokens
-
- {progress.tokens_total.toLocaleString()} -
-
-
-
Est. Cost
-
- ${progress.cost_total.toFixed(4)} -
-
-
-
Cache Rate
-
- {cacheRate}% -
-
-
-
Status
-
- {experimentStatus} -
-
-
-
- ); -} - // --------------------------------------------------------------------------- // Connection Status Indicator // ---------------------------------------------------------------------------