diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index cc167ed..4410326 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -10,6 +10,7 @@ import {
} from "react-router-dom";
import { AppHeader } from "./components/AppHeader";
import { AssessmentPage } from "./components/AssessmentPage";
+import { BatchStatusPage } from "./components/BatchStatusPage";
import { CostAnalysisPanel } from "./components/CostAnalysisPanel";
import { EmptyState } from "./components/EmptyState";
import { EvaluationInputPanel } from "./components/EvaluationInputPanel";
@@ -653,6 +654,37 @@ function AppContent() {
[api, navigate],
);
+ // Handle batch URL submission
+ const handleBatchSubmit = useCallback(
+ async (
+ urls: string[],
+ _evaluators: number,
+ provider?: "claude" | "opencode" | "cursor" | "github-copilot",
+ evaluatorFilter?: EvaluatorFilter,
+ concurrency?: number,
+ ) => {
+ setApiError(null);
+ try {
+ const response = await api.submitBatch(
+ urls,
+ undefined,
+ provider,
+ evaluatorFilter,
+ undefined,
+ concurrency,
+ );
+ navigate(`/batch/${response.batchId}`);
+ } catch (error) {
+ setApiError(
+ error instanceof Error
+ ? error.message
+ : "Failed to submit batch evaluation",
+ );
+ }
+ },
+ [api, navigate],
+ );
+
// Handle job cancellation
const handleCancelJob = useCallback(async () => {
if (currentJobId) {
@@ -1776,6 +1808,7 @@ function AppContent() {
@@ -1881,6 +1914,9 @@ function AppRoutes() {
{assessmentEnabled && (
} />
)}
+ {!cloudMode && (
+ } />
+ )}
);
}
diff --git a/frontend/src/components/BatchStatusPage.tsx b/frontend/src/components/BatchStatusPage.tsx
new file mode 100644
index 0000000..f7cfca1
--- /dev/null
+++ b/frontend/src/components/BatchStatusPage.tsx
@@ -0,0 +1,260 @@
+import { useCallback, useEffect, useRef, useState } from "react";
+import { Link, useParams } from "react-router-dom";
+import { useEvaluationApi } from "../hooks/useEvaluationApi";
+import { parseGitUrl } from "../lib/url-validation";
+import type { BatchEntryStatus, IBatchStatusResponse } from "../types/job";
+
+const POLL_INTERVAL_MS = 3000;
+
+function getStatusBadge(status: BatchEntryStatus) {
+ switch (status) {
+ case "completed":
+ return (
+
+
+ Completed
+
+ );
+ case "failed":
+ return (
+
+
+ Failed
+
+ );
+ case "running":
+ return (
+
+
+ Running
+
+ );
+ case "queued":
+ return (
+
+ Queued
+
+ );
+ case "pending":
+ return (
+
+ Pending
+
+ );
+ }
+}
+
+function getRepoName(url: string): string {
+ const parsed = parseGitUrl(url);
+ if (parsed) {
+ return `${parsed.owner}/${parsed.repo}`;
+ }
+ // Fallback: extract last two path segments
+ try {
+ const urlObj = new URL(url);
+ const parts = urlObj.pathname.split("/").filter(Boolean);
+ if (parts.length >= 2) {
+ return `${parts[parts.length - 2]}/${parts[parts.length - 1]}`;
+ }
+ } catch {
+ // ignore
+ }
+ return url;
+}
+
+export function BatchStatusPage() {
+ const { batchId } = useParams<{ batchId: string }>();
+ const api = useEvaluationApi();
+ const [batchStatus, setBatchStatus] = useState(null);
+ const [loadError, setLoadError] = useState(null);
+ const pollRef = useRef | null>(null);
+
+ const fetchStatus = useCallback(async () => {
+ if (!batchId) return;
+ try {
+ const status = await api.getBatchStatus(batchId);
+ setBatchStatus(status);
+ setLoadError(null);
+
+ // Stop polling when finished
+ if (status.isFinished && pollRef.current) {
+ clearInterval(pollRef.current);
+ pollRef.current = null;
+ }
+ } catch (err) {
+ setLoadError(
+ err instanceof Error ? err.message : "Failed to load batch status",
+ );
+ }
+ }, [batchId, api]);
+
+ // Initial fetch and polling
+ useEffect(() => {
+ fetchStatus();
+ pollRef.current = setInterval(fetchStatus, POLL_INTERVAL_MS);
+
+ return () => {
+ if (pollRef.current) {
+ clearInterval(pollRef.current);
+ pollRef.current = null;
+ }
+ };
+ }, [fetchStatus]);
+
+ if (loadError) {
+ return (
+
+
+
+
+
+
Error loading batch
+
{loadError}
+
+
+
+
+ );
+ }
+
+ if (!batchStatus) {
+ return (
+
+
+
+
Loading batch status...
+
+
+ );
+ }
+
+ const completedOrFailed = batchStatus.completed + batchStatus.failed;
+ const progressPercent =
+ batchStatus.totalUrls > 0
+ ? Math.round((completedOrFailed / batchStatus.totalUrls) * 100)
+ : 0;
+
+ return (
+
+ {/* Header */}
+
+
+
+
+
+
Batch Evaluation
+
+
+ {batchStatus.totalUrls} repositories —{" "}
+ {batchStatus.isFinished
+ ? `Finished (${batchStatus.completed} completed, ${batchStatus.failed} failed)`
+ : `Processing sequentially...`}
+
+
+
+ {/* Progress Bar */}
+
+
+
+ Overall Progress
+
+
+ {completedOrFailed} / {batchStatus.totalUrls}
+
+
+
+
+
+ {/* Stats Row */}
+
+
+
{batchStatus.completed}
+
Completed
+
+
+
{batchStatus.failed}
+
Failed
+
+
+
{batchStatus.running}
+
Running
+
+
+
{batchStatus.queued}
+
Queued
+
+
+
{batchStatus.pending}
+
Pending
+
+
+
+ {/* Jobs List */}
+
+
+
+ {batchStatus.jobs.map((job, index) => (
+
+
+
+ {index + 1}
+
+
+
+ {getRepoName(job.url)}
+
+
+ {job.url}
+
+
+
+
+ {getStatusBadge(job.status)}
+ {job.status === "completed" && (
+
+ View Results
+
+ )}
+
+
+ ))}
+
+
+
+ );
+}
diff --git a/frontend/src/components/EvaluationInputPanel.tsx b/frontend/src/components/EvaluationInputPanel.tsx
index 2c31ecf..c39e816 100644
--- a/frontend/src/components/EvaluationInputPanel.tsx
+++ b/frontend/src/components/EvaluationInputPanel.tsx
@@ -11,6 +11,13 @@ interface IEvaluationInputPanelProps {
evaluatorFilter: EvaluatorFilter,
concurrency: number,
) => Promise;
+ onBatchSubmit?: (
+ urls: string[],
+ evaluators: number,
+ provider: ProviderName,
+ evaluatorFilter: EvaluatorFilter,
+ concurrency: number,
+ ) => Promise;
isLoading: boolean;
urlError?: string | null;
hasData: boolean;
@@ -18,6 +25,7 @@ interface IEvaluationInputPanelProps {
export const EvaluationInputPanel: React.FC = ({
onUrlSubmit,
+ onBatchSubmit,
isLoading,
urlError,
hasData,
@@ -31,6 +39,7 @@ export const EvaluationInputPanel: React.FC = ({
diff --git a/frontend/src/components/RepositoryUrlInput.tsx b/frontend/src/components/RepositoryUrlInput.tsx
index 00c732b..7729673 100644
--- a/frontend/src/components/RepositoryUrlInput.tsx
+++ b/frontend/src/components/RepositoryUrlInput.tsx
@@ -1,4 +1,4 @@
-import React, { useCallback, useEffect, useRef, useState } from "react";
+import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useFeatureFlags } from "../contexts/FeatureFlagContext";
import type { ProviderName } from "../hooks/useEvaluationApi";
import { useEvaluatorsApi } from "../hooks/useEvaluatorsApi";
@@ -7,6 +7,8 @@ import { isValidGitUrl } from "../lib/url-validation";
import type { EvaluatorFilter } from "../types/evaluation";
import type { IEvaluator } from "../types/evaluator";
+type InputMode = "single" | "batch";
+
interface IRepositoryUrlInputProps {
onSubmit: (
url: string,
@@ -16,6 +18,13 @@ interface IRepositoryUrlInputProps {
concurrency: number,
selectedEvaluators?: string[],
) => Promise
;
+ onBatchSubmit?: (
+ urls: string[],
+ evaluators: number,
+ provider: ProviderName,
+ evaluatorFilter: EvaluatorFilter,
+ concurrency: number,
+ ) => Promise;
isLoading: boolean;
error?: string | null;
disabled?: boolean;
@@ -32,6 +41,7 @@ const PROVIDERS: { name: ProviderName; displayName: string }[] = [
export const RepositoryUrlInput: React.FC = ({
onSubmit,
+ onBatchSubmit,
isLoading,
error,
disabled = false,
@@ -44,6 +54,8 @@ export const RepositoryUrlInput: React.FC = ({
getProviderStatus,
} = useProviderDetection();
const [url, setUrl] = useState("");
+ const [batchText, setBatchText] = useState("");
+ const [inputMode, setInputMode] = useState("single");
const [concurrency, setConcurrency] = useState(3);
const [totalEvaluators, setTotalEvaluators] = useState(17); // Default fallback
const [evaluatorsList, setEvaluatorsList] = useState([]);
@@ -55,6 +67,23 @@ export const RepositoryUrlInput: React.FC = ({
const [provider, setProvider] = useState("claude");
const [validationError, setValidationError] = useState(null);
+ // Parse batch URLs from textarea
+ const parsedBatchUrls = useMemo(() => {
+ return batchText
+ .split("\n")
+ .map((line) => line.trim())
+ .filter((line) => line.length > 0);
+ }, [batchText]);
+
+ const validBatchUrls = useMemo(() => {
+ return parsedBatchUrls.filter((u) => isValidGitUrl(u));
+ }, [parsedBatchUrls]);
+
+ const invalidBatchUrls = useMemo(() => {
+ return parsedBatchUrls.filter((u) => !isValidGitUrl(u));
+ }, [parsedBatchUrls]);
+
+ // Fetch evaluators list on mount to get the total count
// Fetch evaluators list on mount to get the total count and details
useEffect(() => {
fetchEvaluatorsList()
@@ -99,10 +128,47 @@ export const RepositoryUrlInput: React.FC = ({
return true;
}, []);
+ const validateBatchUrls = useCallback((): boolean => {
+ if (parsedBatchUrls.length === 0) {
+ setValidationError("Please enter at least one repository URL");
+ return false;
+ }
+ if (parsedBatchUrls.length > 50) {
+ setValidationError(
+ `Maximum 50 URLs allowed (${parsedBatchUrls.length} entered)`,
+ );
+ return false;
+ }
+ if (invalidBatchUrls.length > 0) {
+ setValidationError(
+ `${invalidBatchUrls.length} invalid URL(s) found. Please fix them before submitting.`,
+ );
+ return false;
+ }
+ setValidationError(null);
+ return true;
+ }, [parsedBatchUrls, invalidBatchUrls]);
+
const handleSubmit = useCallback(
async (e: React.FormEvent) => {
e.preventDefault();
+ if (inputMode === "batch") {
+ if (!validateBatchUrls() || !onBatchSubmit) return;
+ try {
+ await onBatchSubmit(
+ validBatchUrls,
+ totalEvaluators,
+ provider,
+ evaluatorFilter,
+ concurrency,
+ );
+ } catch {
+ // Error is handled by parent component
+ }
+ return;
+ }
+
if (!validateUrl(url)) {
return;
}
@@ -129,13 +195,17 @@ export const RepositoryUrlInput: React.FC = ({
}
},
[
+ inputMode,
url,
+ validBatchUrls,
totalEvaluators,
provider,
selectedEvaluatorIds,
concurrency,
validateUrl,
+ validateBatchUrls,
onSubmit,
+ onBatchSubmit,
cloudMode,
],
);
@@ -151,6 +221,16 @@ export const RepositoryUrlInput: React.FC = ({
[validationError],
);
+ const handleBatchTextChange = useCallback(
+ (e: React.ChangeEvent) => {
+ setBatchText(e.target.value);
+ if (validationError) {
+ setValidationError(null);
+ }
+ },
+ [validationError],
+ );
+
const displayError = validationError || error;
/**
@@ -270,6 +350,13 @@ export const RepositoryUrlInput: React.FC = ({
);
};
+ // Determine if submit button should be enabled
+ const isSubmitDisabled =
+ disabled ||
+ isLoading ||
+ (inputMode === "single" && !url.trim()) ||
+ (inputMode === "batch" && validBatchUrls.length === 0);
+
return (