/**
* Primary "Run the ranker" control panel.
*
* Three orthogonal ways to feed candidates to the API:
* 1. Upload your own `.json/.jsonl/.jsonl.gz` (drag-and-drop).
* 2. Click "Feed sample candidates" → uses the default fixture.
* 3. Use the `prefilled` prop from the parent (e.g. resume parser).
*
* Schema-validation errors are NOT caught here - they bubble up to
* App.tsx so it can render the dedicated panel.
*/
import React, { useEffect, useState } from "react";
import { useDropzone } from "react-dropzone";
import { motion, AnimatePresence } from "framer-motion";
import { postRank, SchemaValidationError } from "../api";
import type { RankResponse } from "../types";
interface Props {
onResult: (r: RankResponse) => void;
onError: (msg: string) => void;
onSchemaError: (err: SchemaValidationError) => void;
prefilled?: File | null;
}
export const RunPanel: React.FC = ({
onResult,
onError,
onSchemaError,
prefilled,
}) => {
const [candidates, setCandidates] = useState(null);
const [jd, setJd] = useState(null);
const [topK, setTopK] = useState(10);
const [busy, setBusy] = useState(false);
const [mode, setMode] = useState<"upload" | "sample">("upload");
useEffect(() => {
if (prefilled) {
setCandidates(prefilled);
setMode("upload");
}
}, [prefilled]);
async function runUpload() {
if (!candidates) return;
await runRequest({ candidates, jd, topK });
}
async function runSample() {
setMode("sample");
await runRequest({ useSample: true, jd, topK });
}
async function runRequest(opts: {
candidates?: File | null;
jd?: File | null;
topK?: number;
useSample?: boolean;
}) {
setBusy(true);
try {
const res = await postRank(opts);
onResult(res);
} catch (e) {
if (e instanceof SchemaValidationError) {
onSchemaError(e);
} else {
onError(e instanceof Error ? e.message : String(e));
}
} finally {
setBusy(false);
}
}
// NOTE: many browsers report `.jsonl` as `application/octet-stream` and
// some report `.gz` as `application/x-gzip`. Listing the extensions under
// a wildcard MIME key tells react-dropzone to match by extension first,
// which is what we want.
const candidateDrop = useDropzone({
onDrop: (files) => files[0] && setCandidates(files[0]),
multiple: false,
accept: {
"application/json": [".json"],
"application/x-ndjson": [".jsonl"],
"application/octet-stream": [".jsonl", ".gz"],
"application/gzip": [".gz"],
"application/x-gzip": [".gz"],
},
// 600 MB - the official candidates.jsonl is ~480 MB.
maxSize: 600 * 1024 * 1024,
});
// JD upload — accept text, markdown, .docx and .pdf. The official Redrob
// bundle ships `job_description.docx`, so this must Just Work.
const jdDrop = useDropzone({
onDrop: (files) => files[0] && setJd(files[0]),
multiple: false,
accept: {
"text/plain": [".txt", ".md"],
"text/markdown": [".md"],
"application/vnd.openxmlformats-officedocument.wordprocessingml.document": [".docx"],
"application/pdf": [".pdf"],
"application/octet-stream": [".docx", ".pdf", ".txt", ".md"],
},
maxSize: 10 * 1024 * 1024,
});
return (
Run the ranker
Drop your candidates file, or click "Feed sample candidates" to
explore with the default 50-row fixture. Every upload is validated
against the official Redrob schema first - mismatches surface as a
git diff style report, not a stack trace.
setCandidates(null)}
accent="bone"
/>
setJd(null)}
accent="bone"
hint={
jd
? `Will rank against your uploaded JD`
: `If empty, the default Senior-AI-Engineer JD is used`
}
/>