-
+
-
onTabSwitch(tabId as typeof activeTab)}
- />
-
- {activeTab === "datasets" && (
-
-
-
- )}
-
-
-
-
-
- {activeTab === "results" && (
-
-
-
+ {view === "home" ? (
+
+ ) : (
+
)}
diff --git a/app/components/assessment/PostProcessingStep.tsx b/app/components/assessment/PostProcessingStep.tsx
index 134c8aec..fad865f9 100644
--- a/app/components/assessment/PostProcessingStep.tsx
+++ b/app/components/assessment/PostProcessingStep.tsx
@@ -39,13 +39,7 @@ export default function PostProcessingStep({
// Derive available columns: input columns + L2 output schema fields + fixed L1 fields
const inputCols = columnMapping.textColumns;
const outputCols = outputSchema.map((f) => f.name).filter(Boolean);
- const prefilterCols = [
- "topic_relevance_decision",
- "topic_relevance_reasoning",
- "duplicate_detection_verdict",
- "duplicate_detection_reason",
- "duplicate_detection_match_title",
- ];
+ const prefilterCols = ["prefilter_decision", "prefilter_reasoning"];
const availableCols = [...inputCols, ...outputCols, ...prefilterCols];
// Computed columns available for formula autocomplete (includes user-defined ones)
diff --git a/app/components/assessment/PrefilterStep.tsx b/app/components/assessment/PrefilterStep.tsx
deleted file mode 100644
index 0ad16cd5..00000000
--- a/app/components/assessment/PrefilterStep.tsx
+++ /dev/null
@@ -1,311 +0,0 @@
-"use client";
-
-import { useState } from "react";
-import { Button, Modal } from "@/app/components/ui";
-import { ExpandIcon } from "@/app/components/icons";
-import CompactToggleSwitch from "@/app/components/assessment/CompactToggleSwitch";
-import { DEFAULT_PREFILTER_TOPIC_RELEVANCE_PROMPT } from "@/app/lib/assessment/constants";
-import type {
- PrefilterConfig,
- PrefilterStepProps,
-} from "@/app/lib/types/assessment";
-
-function ColumnChips({
- columns,
- selected,
- onChange,
-}: {
- columns: string[];
- selected: string[];
- onChange: (cols: string[]) => void;
-}) {
- const toggle = (col: string) => {
- onChange(
- selected.includes(col)
- ? selected.filter((c) => c !== col)
- : [...selected, col],
- );
- };
-
- return (
-
- {columns.map((col) => {
- const active = selected.includes(col);
- return (
- toggle(col)}
- className="!text-xs font-mono"
- >
- {col}
-
- );
- })}
- {columns.length === 0 && (
-
- No columns available. Map columns in the previous step.
-
- )}
-
- );
-}
-
-export default function PrefilterStep({
- columns,
- attachmentColumns = [],
- prefilterConfig,
- setPrefilterConfig,
- onNext,
- onBack,
-}: PrefilterStepProps) {
- const [trEnabled, setTrEnabled] = useState(
- () => !!prefilterConfig?.topic_relevance,
- );
- const [dupEnabled, setDupEnabled] = useState(
- () => !!prefilterConfig?.duplicate_detection,
- );
- const [trColumns, setTrColumns] = useState
(
- () => prefilterConfig?.topic_relevance?.columns ?? [],
- );
- const [trAttachmentColumns, setTrAttachmentColumns] = useState(
- () =>
- prefilterConfig?.topic_relevance?.attachment_columns ?? attachmentColumns,
- );
- const [trPrompt, setTrPrompt] = useState(
- () =>
- prefilterConfig?.topic_relevance?.prompt ??
- DEFAULT_PREFILTER_TOPIC_RELEVANCE_PROMPT,
- );
- const [dupColumns, setDupColumns] = useState(
- () => prefilterConfig?.duplicate_detection?.columns ?? [],
- );
- const [isPromptModalOpen, setIsPromptModalOpen] = useState(false);
-
- const trHasColumns = trColumns.length > 0 || trAttachmentColumns.length > 0;
-
- const handleNext = () => {
- const config: PrefilterConfig = {};
- if (trEnabled && trHasColumns && trPrompt.trim()) {
- config.topic_relevance = {
- columns: trColumns,
- prompt: trPrompt.trim(),
- ...(trAttachmentColumns.length > 0
- ? { attachment_columns: trAttachmentColumns }
- : {}),
- };
- }
- if (dupEnabled && dupColumns.length > 0) {
- config.duplicate_detection = { columns: dupColumns };
- }
- setPrefilterConfig(Object.keys(config).length > 0 ? config : null);
- onNext();
- };
-
- const trValid = !trEnabled || (trHasColumns && !!trPrompt.trim());
- const dupValid = !dupEnabled || dupColumns.length > 0;
- const canProceed = trValid && dupValid;
-
- return (
-
-
-
-
- Eliminatory
-
-
- Optional pre-filters run before the LLM batch. Rows that fail Topic
- Relevance are excluded from Evaluation and flagged in the export.
-
-
-
-
-
-
-
- Topic Relevance
-
-
- Gate: rows with decision=REJECT are excluded from Evaluation.
-
-
-
setTrEnabled((v) => !v)}
- title="Enable Topic Relevance"
- />
-
-
- {trEnabled && (
-
-
-
- Columns to evaluate
- {attachmentColumns.length === 0 && (
- *
- )}
-
-
- {trColumns.length === 0 && trAttachmentColumns.length === 0 && (
-
- Select at least one text or document column.
-
- )}
- {trColumns.length > 0 && (
-
- Export will include:{" "}
- {trColumns
- .map((c) => (
-
- topic_relevance_{c}
-
- ))
- .reduce((acc, el, i) => {
- if (i === 0) return [el];
- return [...acc, ", ", el];
- }, [])}{" "}
- (true/false per column)
-
- )}
-
-
- {attachmentColumns.length > 0 && (
-
-
- Documents to include
-
-
-
- {trAttachmentColumns.length > 0
- ? "Selected documents are sent to the model — Topic Relevance is judged on text and these documents."
- : "No documents selected — Topic Relevance uses text columns only."}
-
-
- )}
-
-
-
-
- Evaluation prompt / rubric
- *
-
- setIsPromptModalOpen(true)}
- aria-label="Expand prompt editor"
- title="Expand"
- className="cursor-pointer rounded-md p-1 text-text-secondary transition-colors hover:bg-bg-secondary hover:text-text-primary"
- >
-
-
-
-
-
- )}
-
-
-
-
-
-
- Duplicate Detection
-
-
- Passthrough: runs only on rows that passed Topic Relevance.
- Results appear in export; does not gate Evaluation.
-
-
-
setDupEnabled((v) => !v)}
- title="Enable Duplicate Detection"
- />
-
-
- {dupEnabled && (
-
-
- Columns to check for duplicates
-
-
- {dupEnabled && dupColumns.length === 0 && (
-
- Select at least one column.
-
- )}
-
- )}
-
-
-
-
setIsPromptModalOpen(false)}
- title="Evaluation prompt / rubric"
- maxWidth="max-w-4xl"
- maxHeight="max-h-[85vh]"
- >
-
-
-
-
-
-
- Back
-
-
-
-
- {!trEnabled && !dupEnabled
- ? "No filters enabled — Eliminatory will be skipped."
- : canProceed
- ? "Ready to continue."
- : "Complete required fields above."}
-
-
- Next: Evaluation
-
-
-
-
-
- );
-}
diff --git a/app/components/assessment/PromptAndConfigStep.tsx b/app/components/assessment/PromptAndConfigStep.tsx
deleted file mode 100644
index 6e1eafb1..00000000
--- a/app/components/assessment/PromptAndConfigStep.tsx
+++ /dev/null
@@ -1,178 +0,0 @@
-"use client";
-
-import { Button } from "@/app/components/ui";
-import { ChevronLeftIcon } from "@/app/components/icons";
-import { usePromptAndConfigStep } from "@/app/hooks/usePromptAndConfigStep";
-import type { PromptAndConfigStepProps } from "@/app/lib/types/assessment";
-import {
- AssessmentConfiguration,
- PromptPanel,
- ResponseSchema,
- SetupProgress,
-} from "./prompt-config";
-
-export default function PromptAndConfigStep(props: PromptAndConfigStepProps) {
- const {
- textColumns,
- sampleRow,
- systemInstruction,
- setSystemInstruction,
- promptTemplate,
- setPromptTemplate,
- configs,
- outputSchema,
- setOutputSchema,
- onNext,
- onBack,
- } = props;
-
- const {
- promptStatus,
- responseSummary,
- hasConfiguredResponseFormat,
- canProceed,
- nextBlockerMessage,
- configMode,
- setConfigMode,
- removeSelection,
- filteredConfigCards,
- searchQuery,
- setSearchQuery,
- isLoadingConfigs,
- hasMoreConfigs,
- nextConfigSkip,
- expandedConfigId,
- versionStateByConfig,
- latestModelByConfig,
- loadingSelectionKeys,
- isSelected,
- loadConfigs,
- loadVersions,
- toggleConfigExpansion,
- toggleVersionSelection,
- currentProvider,
- currentModel,
- providerModels,
- currentParamDefs,
- draftParams,
- configName,
- commitMessage,
- isSaving,
- setConfigName,
- setCommitMessage,
- handleProviderChange,
- handleModelChange,
- updateDraftParam,
- handleCreateAndAdd,
- } = usePromptAndConfigStep(props);
-
- return (
-
-
-
-
-
- Evaluation
-
-
- Write the task on the left. Tune behavior and output on the right.
-
-
-
-
-
-
-
-
-
-
-
- loadConfigs(skip, false)}
- onLoadVersions={(configId, skip) =>
- void loadVersions(configId, skip)
- }
- onToggleConfigExpansion={toggleConfigExpansion}
- onToggleVersionSelection={toggleVersionSelection}
- currentProvider={currentProvider}
- currentModel={currentModel}
- providerModels={providerModels}
- currentParamDefs={currentParamDefs}
- draftParams={draftParams}
- configName={configName}
- commitMessage={commitMessage}
- isSaving={isSaving}
- setConfigName={setConfigName}
- setCommitMessage={setCommitMessage}
- onProviderChange={handleProviderChange}
- onModelChange={handleModelChange}
- onParamChange={updateDraftParam}
- onSaveConfig={handleCreateAndAdd}
- />
-
-
-
-
-
-
-
-
- Back
-
-
- {!canProceed && (
-
- {nextBlockerMessage}
-
- )}
-
- Next: Review
-
-
-
-
-
- );
-}
diff --git a/app/components/assessment/ReviewStep.tsx b/app/components/assessment/ReviewStep.tsx
deleted file mode 100644
index 3f68cdd8..00000000
--- a/app/components/assessment/ReviewStep.tsx
+++ /dev/null
@@ -1,154 +0,0 @@
-"use client";
-
-import { useState } from "react";
-import {
- INITIAL_REVIEW_OPEN_SECTIONS,
- REVIEW_SECTIONS,
-} from "@/app/lib/assessment/constants";
-import type {
- AssessmentFormState,
- ValueSetter,
-} from "@/app/lib/types/assessment";
-import { buildMappedColumns } from "@/app/lib/utils/assessment";
-
-interface ReviewStepProps {
- formState: AssessmentFormState;
- experimentName: string;
- setExperimentName: ValueSetter;
- isSubmitting: boolean;
- canSubmit: boolean;
- submitBlockerMessage: string;
- onSubmit: () => void;
- onBack: () => void;
- onEditStep: ValueSetter;
-}
-import ColumnsReview from "./review/ColumnsReview";
-import ConfigsReview from "./review/ConfigsReview";
-import DatasetReview from "./review/DatasetReview";
-import ExperimentReview from "./review/ExperimentReview";
-import InputReview from "./review/InputReview";
-import PostProcessingReview from "./review/PostProcessingReview";
-import PrefilterReview from "./review/PrefilterReview";
-import SchemaReview from "./review/SchemaReview";
-import SubmitReview from "./review/SubmitReview";
-
-export default function ReviewStep({
- formState,
- experimentName,
- setExperimentName,
- isSubmitting,
- canSubmit,
- submitBlockerMessage,
- onSubmit,
- onBack,
- onEditStep,
-}: ReviewStepProps) {
- const {
- datasetName,
- columnMapping,
- systemInstruction,
- promptTemplate,
- outputSchema,
- configs,
- prefilterConfig,
- postProcessingConfig,
- } = formState;
- const [openSections, setOpenSections] = useState>(
- () => new Set(INITIAL_REVIEW_OPEN_SECTIONS),
- );
-
- const mappedColumns = buildMappedColumns(columnMapping);
- const mappedColumnCount =
- columnMapping.textColumns.length +
- columnMapping.attachments.length +
- columnMapping.groundTruthColumns.length;
-
- const toggleSection = (section: number) => {
- setOpenSections((prev) => {
- const next = new Set(prev);
- if (next.has(section)) {
- next.delete(section);
- } else {
- next.add(section);
- }
- return next;
- });
- };
-
- return (
-
-
-
-
- Review & Submit
-
-
- Verify your evaluation configuration before submitting.
-
-
-
-
-
-
toggleSection(REVIEW_SECTIONS.dataset)}
- />
-
- toggleSection(REVIEW_SECTIONS.columns)}
- onEdit={() => onEditStep(1)}
- />
-
- toggleSection(REVIEW_SECTIONS.prefilter)}
- onEdit={() => onEditStep(2)}
- />
-
- toggleSection(REVIEW_SECTIONS.input)}
- />
-
- toggleSection(REVIEW_SECTIONS.configs)}
- onEdit={() => onEditStep(3)}
- />
-
- toggleSection(REVIEW_SECTIONS.schema)}
- onEdit={() => onEditStep(3)}
- />
-
- toggleSection(REVIEW_SECTIONS.postProcessing)}
- onEdit={() => onEditStep(4)}
- />
-
-
-
-
- );
-}
diff --git a/app/components/assessment/SpreadsheetView.tsx b/app/components/assessment/SpreadsheetView.tsx
index 217b5fa4..8514777f 100644
--- a/app/components/assessment/SpreadsheetView.tsx
+++ b/app/components/assessment/SpreadsheetView.tsx
@@ -11,7 +11,7 @@ import {
buildSpreadsheetWorkbookData,
loadSpreadsheetState,
persistSpreadsheetState,
- rowsToCsv,
+ downloadCsv,
savedSnapshotMatchesHeaders,
spreadsheetSnapshotToRows,
} from "@/app/lib/assessment/results";
@@ -21,10 +21,8 @@ import {
} from "@/app/lib/assessment/constants";
import type { UniverAPI } from "@/app/lib/types/assessment";
-const BOM_UTF8 = String.fromCharCode(0xfeff);
-
interface SpreadsheetViewProps {
- runId: number;
+ runId: string;
title: string;
subtitle?: string;
headers: string[];
@@ -47,15 +45,7 @@ export default function SpreadsheetView({
const matrix = snapshot
? spreadsheetSnapshotToRows(snapshot)
: [headers, ...rows];
- const csv = rowsToCsv(matrix);
- // Prepend BOM so Excel reads UTF-8 (preserves Hindi/Telugu text).
- const blob = new Blob([BOM_UTF8, csv], { type: "text/csv;charset=utf-8;" });
- const url = URL.createObjectURL(blob);
- const link = document.createElement("a");
- link.href = url;
- link.download = `${title || "results"}.csv`;
- link.click();
- URL.revokeObjectURL(url);
+ downloadCsv(title, matrix);
};
useEffect(() => {
diff --git a/app/components/assessment/Stepper.tsx b/app/components/assessment/Stepper.tsx
index 32ade0af..38c15408 100644
--- a/app/components/assessment/Stepper.tsx
+++ b/app/components/assessment/Stepper.tsx
@@ -1,7 +1,9 @@
"use client";
+import { ReactNode } from "react";
import { Button } from "@/app/components/ui";
-import { CheckIcon } from "@/app/components/icons";
+import { CheckIcon, HomeIcon } from "@/app/components/icons";
+import { getStepState, stepPillClasses } from "@/app/lib/assessment/wizard";
import type { Step, ValueSetter } from "@/app/lib/types/assessment";
interface StepperProps {
@@ -9,6 +11,13 @@ interface StepperProps {
currentStep: number;
onStepClick: ValueSetter;
completedSteps: Set;
+ /** Home renders the same strip as an inert map of the flow. */
+ locked?: boolean;
+ /** Flow-specific gating; falls back to the sequential rule when omitted. */
+ isStepAllowed?: (step: number) => boolean;
+ onHome?: () => void;
+ leading?: ReactNode;
+ trailing?: ReactNode;
}
export default function Stepper({
@@ -16,55 +25,83 @@ export default function Stepper({
currentStep,
onStepClick,
completedSteps,
+ locked = false,
+ isStepAllowed,
+ onHome,
+ leading,
+ trailing,
}: StepperProps) {
return (
-
- {steps.map((step, index) => {
- const isActive = currentStep === step.id;
- const isCompleted = completedSteps.has(step.id);
- const isSequentiallyUnlocked =
- step.id > currentStep &&
- steps
- .filter((s) => s.id < step.id)
- .every((s) => completedSteps.has(s.id));
- const isClickable =
- isCompleted || step.id <= currentStep || isSequentiallyUnlocked;
+
+ {leading}
- return (
-
- {index > 0 && (
-
- )}
-
isClickable && onStepClick(step.id)}
- disabled={!isClickable}
- className={`rounded-full! px-3! py-1.5! text-left! text-xs! ${
- isActive
- ? "border-accent-primary! bg-accent-primary! text-white!"
- : isCompleted
- ? "bg-bg-secondary! text-text-primary!"
- : "bg-transparent! text-text-secondary!"
- } ${isClickable ? "opacity-100" : "cursor-default opacity-50"}`}
- >
- {isCompleted && !isActive ? (
-
- ) : (
-
- {step.id}
-
+ {onHome && (
+ <>
+
+
+
+
+ >
+ )}
+
+ {/* The pills scroll rather than wrap, so the bar stays one row tall. */}
+
+ {steps.map((step, index) => {
+ const state = getStepState({
+ step,
+ steps,
+ currentStep,
+ completedSteps,
+ locked,
+ isStepAllowed,
+ });
+
+ return (
+
+ {index > 0 && (
+
)}
-
{step.label}
-
-
- );
- })}
+
state.isClickable && onStepClick(step.id)}
+ disabled={!state.isClickable}
+ className={`rounded-full! px-3! py-1.5! text-left! text-xs! whitespace-nowrap ${stepPillClasses(
+ state,
+ )} ${
+ state.isClickable
+ ? "opacity-100"
+ : "cursor-default opacity-50"
+ }`}
+ >
+ {state.isCompleted && !state.isActive ? (
+
+ ) : (
+
+ {step.id}
+
+ )}
+ {step.label}
+
+
+ );
+ })}
+
+
+ {trailing}
);
}
diff --git a/app/components/assessment/datasets/CreatePanel.tsx b/app/components/assessment/datasets/CreatePanel.tsx
index ce0dd22f..833c1109 100644
--- a/app/components/assessment/datasets/CreatePanel.tsx
+++ b/app/components/assessment/datasets/CreatePanel.tsx
@@ -2,104 +2,94 @@
import { Button, Field } from "@/app/components/ui";
import { CheckIcon, CloseIcon, CloudUploadIcon } from "@/app/components/icons";
-import { DATASET_LEFT_PANEL_CLASSES } from "@/app/lib/assessment/constants";
-import type { ChangeEvent, DragEvent, RefObject } from "react";
-import type { ValueSetter } from "@/app/lib/types/assessment";
+import { SUBMISSION_FORM_PANEL_CLASSES } from "@/app/lib/assessment/constants";
+import type { UseSubmissionFormResult } from "@/app/hooks/useSubmissionForm";
interface CreatePanelProps {
- datasetName: string;
- datasetDescription: string;
- uploadedFile: File | null;
- isDragging: boolean;
- isUploading: boolean;
- fileInputRef: RefObject
;
- onDatasetNameChange: ValueSetter;
- onDatasetDescriptionChange: ValueSetter;
- onFileSelect: (event: ChangeEvent) => void;
- onDragOver: (event: DragEvent) => void;
- onDragLeave: () => void;
- onDrop: (event: DragEvent) => void;
- onRemoveFile: () => void;
- onResetForm: () => void;
- onCreateDataset: () => void;
+ form: UseSubmissionFormResult;
+ isCreating: boolean;
+ onCreate: () => void;
+ /** Step 1 shows it as the right-hand pane; step 4 embeds it inline. */
+ layout?: "panel" | "inline";
}
+const LAYOUT_CLASSES: Record<"panel" | "inline", string> = {
+ panel: `${SUBMISSION_FORM_PANEL_CLASSES} shrink-0 border-l border-border bg-bg-primary`,
+ inline: "w-full bg-transparent",
+};
+
export default function CreatePanel({
- datasetName,
- datasetDescription,
- uploadedFile,
- isDragging,
- isUploading,
- fileInputRef,
- onDatasetNameChange,
- onDatasetDescriptionChange,
- onFileSelect,
- onDragOver,
- onDragLeave,
- onDrop,
- onRemoveFile,
- onResetForm,
- onCreateDataset,
+ form,
+ isCreating,
+ onCreate,
+ layout = "panel",
}: CreatePanelProps) {
+ const { file, fileInputRef } = form;
+
return (
-
-
-
- Create New Dataset
-
-
- Upload a CSV file for evaluation
-
-
+
+ {layout === "panel" && (
+
+
+ Create New Submission
+
+
+ Upload a CSV file for assessment
+
+
+ )}
+
- Upload Dataset *
+ Upload Submissions *
- {uploadedFile ? (
+ {file ? (
-
-
-
- {uploadedFile.name}
+
+
+
+ {file.name}
- {(uploadedFile.size / 1024).toFixed(1)} KB
+ {(file.size / 1024).toFixed(1)} KB
@@ -108,18 +98,33 @@ export default function CreatePanel({
) : (
-
fileInputRef.current?.click()}
+ onDragEnter={(event) => {
+ if (event.dataTransfer.types.includes("Files")) {
+ form.setIsDragging(true);
+ }
+ }}
+ onDragOver={(event) => {
+ event.preventDefault();
+ event.dataTransfer.dropEffect = "copy";
+ }}
+ onDragLeave={(event) => {
+ if (
+ !event.currentTarget.contains(event.relatedTarget as Node)
+ ) {
+ form.setIsDragging(false);
+ }
+ }}
+ onDrop={form.handleDrop}
+ className={`w-full cursor-pointer rounded-lg border-2 border-dashed p-6 text-center transition-colors ${
+ form.isDragging
? "border-accent-primary bg-accent-subtle/20"
- : "border-border"
+ : "border-border hover:border-accent-muted"
}`}
- onClick={() => fileInputRef.current?.click()}
- onDragOver={onDragOver}
- onDragLeave={onDragLeave}
- onDrop={onDrop}
>
-
+
@@ -128,26 +133,26 @@ export default function CreatePanel({
CSV or Excel (.xlsx, .xls)
-
+
)}
-
+
Cancel
- {isUploading ? (
+ {isCreating ? (
<>
Creating...
>
) : (
- "Create Dataset"
+ "Create Submission"
)}
diff --git a/app/components/assessment/datasets/DatasetList.tsx b/app/components/assessment/datasets/DatasetList.tsx
deleted file mode 100644
index cbaf5a5a..00000000
--- a/app/components/assessment/datasets/DatasetList.tsx
+++ /dev/null
@@ -1,167 +0,0 @@
-"use client";
-
-import { DatasetListSkeleton } from "@/app/components";
-import { DatabaseIcon } from "@/app/components/icons";
-import EvalDatasetDescription from "@/app/components/evaluations/EvalDatasetDescription";
-import type { ValueSetter } from "@/app/lib/types/assessment";
-import type { Dataset } from "@/app/lib/types/dataset";
-import { Button } from "@/app/components/ui";
-
-interface DatasetListProps {
- datasets: Dataset[];
- datasetId: string;
- isLoading: boolean;
- isLoadingColumns: boolean;
- viewingId: number | null;
- canProceed: boolean;
- onSelectDataset: (id: string, name?: string) => void;
- onViewDataset: (datasetId: number, name: string) => void;
- onRequestDelete: ValueSetter
;
- onNext: () => void;
-}
-
-export default function DatasetList({
- datasets,
- datasetId,
- isLoading,
- isLoadingColumns,
- viewingId,
- canProceed,
- onSelectDataset,
- onViewDataset,
- onRequestDelete,
- onNext,
-}: DatasetListProps) {
- return (
-
-
-
-
-
- Datasets
-
-
- Use an existing dataset from the list, or create a new dataset
- from the form on the right.
-
-
- {isLoadingColumns && (
-
- Loading columns...
-
- )}
-
-
- {isLoading ? (
-
- ) : datasets.length === 0 ? (
-
-
-
- No datasets yet
-
-
- Create your first dataset using the form on the right
-
-
- ) : (
-
- {datasets.map((dataset) => {
- const isSelected = datasetId === dataset.dataset_id.toString();
- return (
-
- onSelectDataset(
- dataset.dataset_id.toString(),
- dataset.dataset_name,
- )
- }
- >
-
-
-
-
-
- {dataset.dataset_name}
-
-
- {dataset.description && (
-
- )}
-
- {dataset.total_items} items
- {dataset.original_items > 0 &&
- dataset.original_items !== dataset.total_items && (
- <>
- ·
- {dataset.original_items} original
- >
- )}
-
-
-
- {
- event.stopPropagation();
- onViewDataset(
- dataset.dataset_id,
- dataset.dataset_name,
- );
- }}
- disabled={viewingId === dataset.dataset_id}
- className={` ${
- viewingId === dataset.dataset_id ? "opacity-50" : ""
- }`}
- >
- {viewingId === dataset.dataset_id
- ? "Loading..."
- : "View"}
-
- {
- event.stopPropagation();
- onRequestDelete(dataset.dataset_id);
- }}
- aria-label={`Delete ${dataset.dataset_name}`}
- >
- Delete
-
-
-
-
-
- );
- })}
-
- )}
-
-
-
-
-
- {canProceed
- ? "Dataset selected. Continue to AI configuration."
- : "Select a dataset to continue."}
-
-
- Next: AI Configuration
-
-
-
-
- );
-}
diff --git a/app/components/assessment/datasets/SubmissionList.tsx b/app/components/assessment/datasets/SubmissionList.tsx
new file mode 100644
index 00000000..fd2d9835
--- /dev/null
+++ b/app/components/assessment/datasets/SubmissionList.tsx
@@ -0,0 +1,155 @@
+"use client";
+
+import { DatasetListSkeleton } from "@/app/components";
+import { DatabaseIcon } from "@/app/components/icons";
+import EvalDatasetDescription from "@/app/components/evaluations/EvalDatasetDescription";
+import { Button } from "@/app/components/ui";
+import type {
+ AssessmentSubmission,
+ ValueSetter,
+} from "@/app/lib/types/assessment";
+
+interface SubmissionListProps {
+ submissions: AssessmentSubmission[];
+ selectedId: string;
+ isLoading: boolean;
+ isLoadingColumns: boolean;
+ viewingId: string | null;
+ onSelect: (id: string, name?: string) => void;
+ onView: (submissionId: string, name: string) => void;
+ onRequestDelete: ValueSetter;
+}
+
+export default function SubmissionList({
+ submissions,
+ selectedId,
+ isLoading,
+ isLoadingColumns,
+ viewingId,
+ onSelect,
+ onView,
+ onRequestDelete,
+}: SubmissionListProps) {
+ return (
+
+
+
+
+
+ Submissions
+
+
+ Pick the submission set for this assessor, or upload a new one.
+
+
+ Each row becomes one submission. Its columns load into the system,
+ so you can tag them with “@” in the pre-filter and assessment
+ steps.
+
+
+ {isLoadingColumns && (
+
+ Loading columns...
+
+ )}
+
+
+ {isLoading ? (
+
+ ) : submissions.length === 0 ? (
+
+
+
+ No submissions yet
+
+
+ Create your first submission set using the form on the right
+
+
+ ) : (
+
+ {submissions.map((submission) => {
+ const isSelected =
+ selectedId === submission.submission_id;
+ return (
+
+
+
+
+ onSelect(
+ submission.submission_id,
+ submission.name,
+ )
+ }
+ className="min-w-0 flex-1 cursor-pointer text-left"
+ >
+
+ {submission.name}
+
+ {submission.description && (
+
+ )}
+
+ {submission.total_items} items
+ {submission.total_items > 0 &&
+ submission.total_items !==
+ submission.total_items && (
+ <>
+ ·
+
+ {submission.total_items} original
+
+ >
+ )}
+
+
+
+
+
+ onView(
+ submission.submission_id,
+ submission.name,
+ )
+ }
+ disabled={viewingId === submission.submission_id}
+ >
+ {viewingId === submission.submission_id
+ ? "Loading..."
+ : "View"}
+
+ onRequestDelete(submission.submission_id)}
+ aria-label={`Delete ${submission.name}`}
+ >
+ Delete
+
+
+
+
+
+ );
+ })}
+
+ )}
+
+
+ );
+}
diff --git a/app/components/assessment/editor/EditorStepLayout.tsx b/app/components/assessment/editor/EditorStepLayout.tsx
new file mode 100644
index 00000000..9ee2e51e
--- /dev/null
+++ b/app/components/assessment/editor/EditorStepLayout.tsx
@@ -0,0 +1,64 @@
+"use client";
+
+import { ReactNode, useRef } from "react";
+import { SplitPane } from "@/app/components/ui";
+import { useSyncedScroll } from "@/app/hooks/useSyncedScroll";
+import PreviewPane from "./PreviewPane";
+import type { PromptFieldType, PromptZones } from "@/app/lib/types/assessment";
+
+interface EditorStepLayoutProps {
+ children: ReactNode;
+ zones: PromptZones;
+ columns: string[];
+ fieldTypes: Record;
+ sampleRow: Record;
+ previewDisabledNote?: string;
+}
+
+/**
+ * Steps 2 and 3 share this shape: editor on the left, live preview on the right,
+ * a draggable divider between them, and scroll kept in step on the zone headings.
+ */
+export default function EditorStepLayout({
+ children,
+ zones,
+ columns,
+ fieldTypes,
+ sampleRow,
+ previewDisabledNote,
+}: EditorStepLayoutProps) {
+ const editorRef = useRef(null);
+ const previewRef = useRef(null);
+
+ useSyncedScroll({
+ editorRef,
+ previewRef,
+ editorAnchorSelector: "[data-zone-heading]",
+ previewAnchorSelector: "h1",
+ });
+
+ return (
+
+ {children}
+
+ }
+ right={
+
+ }
+ />
+ );
+}
diff --git a/app/components/assessment/editor/MentionDropdown.tsx b/app/components/assessment/editor/MentionDropdown.tsx
new file mode 100644
index 00000000..912f1fdc
--- /dev/null
+++ b/app/components/assessment/editor/MentionDropdown.tsx
@@ -0,0 +1,172 @@
+"use client";
+
+import { DocumentFileIcon, ImageIcon } from "@/app/components/icons";
+import type { PromptFieldType } from "@/app/lib/types/assessment";
+import type {
+ MentionColumnOption,
+ MentionStage,
+ MentionStrictOption,
+ MentionTypeOption,
+} from "@/app/hooks/usePromptMentions";
+
+interface MentionDropdownProps {
+ dropdownRef: React.RefObject
;
+ position: { top: number; left: number } | null;
+ activeIndex: number;
+ pendingField: string | null;
+ stage: MentionStage | null;
+ columnOptions: MentionColumnOption[];
+ typeOptions: MentionTypeOption[];
+ strictOptions: MentionStrictOption[];
+ onPickColumn: (option: MentionColumnOption) => void;
+ onPickType: (type: PromptFieldType) => void;
+ onPickStrict: (strict: boolean) => void;
+ onMouseEnter?: () => void;
+ onMouseLeave?: () => void;
+}
+
+const TYPE_ICONS: Record = {
+ text: Aa ,
+ image: ,
+ pdf: ,
+};
+
+const rowBase =
+ "flex w-full cursor-pointer items-center gap-2 px-3 py-2 text-left text-sm";
+const eyebrow =
+ "px-3 pt-2 pb-1 text-[10px] font-semibold tracking-wider uppercase text-text-secondary";
+
+/** Stage 1 picks the column; stage 2 asks what it carries; stage 3 whether it may be blank. */
+export default function MentionDropdown({
+ dropdownRef,
+ position,
+ activeIndex,
+ pendingField,
+ stage,
+ columnOptions,
+ typeOptions,
+ strictOptions,
+ onPickColumn,
+ onPickType,
+ onPickStrict,
+ onMouseEnter,
+ onMouseLeave,
+}: MentionDropdownProps) {
+ if (!position) return null;
+
+ return (
+
+ {stage === "type" && (
+ <>
+
What is “{pendingField}”?
+ {typeOptions.map((option, index) => (
+
onPickType(option.type)}
+ />
+ ))}
+ >
+ )}
+
+ {stage === "strict" && (
+ <>
+ Is “{pendingField}” required?
+ {strictOptions.map((option, index) => (
+
+ {option.strict ? "*" : "?"}
+
+ }
+ label={option.label}
+ hint={option.hint}
+ isCurrent={option.isCurrent}
+ onPick={() => onPickStrict(option.strict)}
+ />
+ ))}
+ >
+ )}
+
+ {!stage &&
+ columnOptions.map((option, index) => (
+ {
+ event.preventDefault();
+ onPickColumn(option);
+ }}
+ className={`${rowBase} ${
+ index === activeIndex ? "bg-bg-secondary" : ""
+ } ${option.isCreate ? "text-accent-primary" : "text-text-primary"}`}
+ >
+ {option.isCreate ? (
+ + Create field “{option.name}”
+ ) : (
+ <>
+
+ @
+
+
+ {option.name}
+
+ {option.type !== "text" && (
+
+ attached
+
+ )}
+ >
+ )}
+
+ ))}
+
+ );
+}
+
+function OptionRow({
+ active,
+ badge,
+ label,
+ hint,
+ isCurrent,
+ onPick,
+}: {
+ active: boolean;
+ badge: React.ReactNode;
+ label: string;
+ hint?: string;
+ isCurrent: boolean;
+ onPick: () => void;
+}) {
+ return (
+ {
+ event.preventDefault();
+ onPick();
+ }}
+ className={`${rowBase} ${active ? "bg-bg-secondary" : ""}`}
+ >
+
+ {badge}
+
+ {label}
+ {hint && {hint} }
+ {isCurrent && (
+ current
+ )}
+
+ );
+}
diff --git a/app/components/assessment/editor/PreviewPane.tsx b/app/components/assessment/editor/PreviewPane.tsx
new file mode 100644
index 00000000..2dac678e
--- /dev/null
+++ b/app/components/assessment/editor/PreviewPane.tsx
@@ -0,0 +1,152 @@
+"use client";
+
+import {
+ DocumentFileIcon,
+ ImageIcon,
+ LockClosedIcon,
+} from "@/app/components/icons";
+import {
+ buildPreviewBlocks,
+ fieldTypeOf,
+} from "@/app/lib/assessment/promptTokens";
+import type {
+ PreviewBlock,
+ PromptFieldType,
+ PromptSegment,
+ PromptZones,
+} from "@/app/lib/types/assessment";
+
+interface PreviewPaneProps {
+ paneRef: React.RefObject;
+ zones: PromptZones;
+ columns: string[];
+ fieldTypes: Record;
+ sampleRow: Record;
+ /** Shown instead of the document when the step is switched off. */
+ disabledNote?: string;
+}
+
+const BLOCK_CLASSES: Record = {
+ h1: "mt-6 mb-2.5 text-[15px] font-bold tracking-wider uppercase text-text-primary first:mt-0",
+ h2: "mt-4 mb-2 text-base font-semibold text-text-primary",
+ li: "my-0.5 ml-5 list-disc text-sm leading-7 text-text-primary",
+ p: "mb-2.5 text-sm leading-7 text-text-primary",
+};
+
+/** Read-only mirror of the prompt the model will see, filled with row 1. */
+export default function PreviewPane({
+ paneRef,
+ zones,
+ columns,
+ fieldTypes,
+ sampleRow,
+ disabledNote,
+}: PreviewPaneProps) {
+ const blocks = buildPreviewBlocks({ zones, columns, fieldTypes, sampleRow });
+
+ return (
+
+
+ Preview
+
+
+ Read only
+
+
+
+ {disabledNote ? (
+
{disabledNote}
+ ) : (
+ <>
+
+ A sample of the prompt the AI sees for one submission, filled in
+ with row 1 of the selected set. Edit it on the left.
+
+
+ {blocks.length === 0 ? (
+
+ Start typing in the editor — the assembled prompt previews here
+ with row 1 of the submission set filled in.
+
+ ) : (
+
+ {blocks.map((block, index) => (
+
+ ))}
+
+ )}
+ >
+ )}
+
+ );
+}
+
+function PreviewBlockView({
+ block,
+ fieldTypes,
+}: {
+ block: PreviewBlock;
+ fieldTypes: Record;
+}) {
+ const content = block.segments.map((segment, index) => (
+
+ ));
+
+ if (block.type === "h1") {
+ return {content} ;
+ }
+ if (block.type === "h2") {
+ return {content} ;
+ }
+ if (block.type === "li") {
+ return {content} ;
+ }
+ return {content}
;
+}
+
+function SegmentView({
+ segment,
+ fieldTypes,
+}: {
+ segment: PromptSegment;
+ fieldTypes: Record;
+}) {
+ if (segment.kind === "plain") return <>{segment.text}>;
+
+ if (segment.kind === "attachment" && segment.name) {
+ const isImage = fieldTypeOf(fieldTypes, segment.name) === "image";
+ return (
+
+ {isImage ? (
+
+ ) : (
+
+ )}
+ {segment.name}
+
+ );
+ }
+
+ if (segment.kind === "unknown") {
+ return (
+
+ {segment.text}
+
+ );
+ }
+
+ return (
+ {segment.text}
+ );
+}
diff --git a/app/components/assessment/editor/PromptZoneCard.tsx b/app/components/assessment/editor/PromptZoneCard.tsx
new file mode 100644
index 00000000..a15c9fb3
--- /dev/null
+++ b/app/components/assessment/editor/PromptZoneCard.tsx
@@ -0,0 +1,74 @@
+"use client";
+
+import PromptZoneEditor from "./PromptZoneEditor";
+import type {
+ PromptFieldType,
+ PromptZoneCopy,
+ PromptZoneId,
+ PromptZones,
+} from "@/app/lib/types/assessment";
+
+interface PromptZoneCardProps {
+ zones: PromptZones;
+ copy: Record;
+ /** Applied to both zones so they stay the same size. */
+ zoneMinHeight: number;
+ columns: string[];
+ fieldTypes: Record;
+ fieldStrict: Record;
+ onZoneChange: (zone: PromptZoneId, value: string) => void;
+ onFieldType: (name: string, type: PromptFieldType) => void;
+ onFieldStrict: (name: string, strict: boolean) => void;
+}
+
+const ORDER: PromptZoneId[] = ["instructions", "submission"];
+
+/** The Instructions + Submission pair. Its headings anchor the preview sync. */
+export default function PromptZoneCard({
+ zones,
+ copy,
+ zoneMinHeight,
+ columns,
+ fieldTypes,
+ fieldStrict,
+ onZoneChange,
+ onFieldType,
+ onFieldStrict,
+}: PromptZoneCardProps) {
+ return (
+
+ {ORDER.map((zone, index) => (
+
+
0 ? "border-t border-border" : ""
+ } border-b border-border`}
+ >
+
+ {copy[zone].label}
+
+
+ {copy[zone].hint}
+
+
+
+
onZoneChange(zone, value)}
+ placeholder={copy[zone].placeholder}
+ minHeight={zoneMinHeight}
+ enableMentions={zone === "submission"}
+ columns={columns}
+ fieldTypes={fieldTypes}
+ fieldStrict={fieldStrict}
+ onFieldType={onFieldType}
+ onFieldStrict={onFieldStrict}
+ ariaLabel={`${copy[zone].label} prompt`}
+ />
+
+
+ ))}
+
+ );
+}
diff --git a/app/components/assessment/editor/PromptZoneEditor.tsx b/app/components/assessment/editor/PromptZoneEditor.tsx
new file mode 100644
index 00000000..f32c5eb4
--- /dev/null
+++ b/app/components/assessment/editor/PromptZoneEditor.tsx
@@ -0,0 +1,217 @@
+"use client";
+
+import { splitTokens } from "@/app/lib/assessment/promptTokens";
+import { usePromptMentions } from "@/app/hooks/usePromptMentions";
+import MentionDropdown from "./MentionDropdown";
+import type { PromptFieldType } from "@/app/lib/types/assessment";
+
+interface PromptZoneEditorProps {
+ value: string;
+ onChange: (value: string) => void;
+ placeholder: string;
+ minHeight: number;
+ /** Only the per-row zone offers `@` mentions. */
+ enableMentions: boolean;
+ columns: string[];
+ fieldTypes: Record;
+ fieldStrict: Record;
+ onFieldType: (name: string, type: PromptFieldType) => void;
+ onFieldStrict: (name: string, strict: boolean) => void;
+ ariaLabel: string;
+}
+
+const SEGMENT_CLASSES: Record = {
+ plain: "",
+ text: "rounded bg-accent-subtle/40 text-accent-primary",
+ attachment: "rounded bg-status-warning-bg text-status-warning-text",
+ unknown:
+ "rounded bg-status-warning-bg text-status-warning-text underline decoration-status-warning decoration-wavy",
+};
+
+const MARKER_CLASSES = {
+ required: "text-status-error-text",
+ optional: "text-accent-primary",
+};
+
+/** Where a marker's dropdown opens, in the editor's own coordinate space. */
+function markerPosition(marker: HTMLElement): { top: number; left: number } {
+ const host = marker.closest("[data-mention-host]");
+ const rect = marker.getBoundingClientRect();
+ const origin = host?.getBoundingClientRect() ?? { top: 0, left: 0 };
+ return { top: rect.bottom - origin.top, left: rect.left - origin.left };
+}
+
+/**
+ * A prompt zone: a transparent textarea over a highlighted mirror, so tokens can
+ * be coloured while the caret, selection and IME stay native. The same layering
+ * JsonEditor uses for JSON. A third, transparent copy sits above the textarea to
+ * carry each token's clickable required/optional marker.
+ */
+export default function PromptZoneEditor({
+ value,
+ onChange,
+ placeholder,
+ minHeight,
+ enableMentions,
+ columns,
+ fieldTypes,
+ fieldStrict,
+ onFieldType,
+ onFieldStrict,
+ ariaLabel,
+}: PromptZoneEditorProps) {
+ const {
+ inputRef,
+ mirrorRef,
+ dropdownRef,
+ isOpen,
+ position,
+ activeIndex,
+ pendingField,
+ stage,
+ columnOptions,
+ typeOptions,
+ strictOptions,
+ onInput,
+ onKeyDown,
+ pickColumn,
+ pickType,
+ pickStrict,
+ openStrictFor,
+ scheduleHoverClose,
+ cancelHoverClose,
+ closeAll,
+ } = usePromptMentions({
+ columns,
+ fieldTypes,
+ fieldStrict,
+ value,
+ onChange,
+ onFieldType,
+ onFieldStrict,
+ });
+
+ const segments = splitTokens(value, columns, fieldTypes);
+ const layer =
+ "m-0 px-0 font-sans text-sm leading-5 whitespace-pre-wrap break-words";
+
+ return (
+ inputRef.current?.focus()}
+ >
+ {!value && (
+
+ {placeholder}
+
+ )}
+
+
+ {segments.map((segment, index) => (
+
+ {segment.text}
+
+ ))}
+ {""}
+
+
+
+ );
+}
diff --git a/app/components/assessment/home/AssessorRow.tsx b/app/components/assessment/home/AssessorRow.tsx
new file mode 100644
index 00000000..3767f8a6
--- /dev/null
+++ b/app/components/assessment/home/AssessorRow.tsx
@@ -0,0 +1,110 @@
+"use client";
+
+import { TrashIcon } from "@/app/components/icons";
+import { formatRelativeTime } from "@/app/lib/utils";
+import AssessorVersionChips from "./AssessorVersionChips";
+import AssessorVersionList from "./AssessorVersionList";
+import type {
+ AssessorSelection,
+ AssessorSummary,
+ AssessorVersion,
+} from "@/app/lib/types/assessment";
+
+interface AssessorRowProps {
+ assessor: AssessorSummary;
+ selection: AssessorSelection | null;
+ versions: AssessorVersion[] | undefined;
+ isExpanded: boolean;
+ deletingKey: string | null;
+ onSelectAssessor: (configId: string) => void;
+ onSelectVersion: (configId: string, version: number) => void;
+ onToggleExpanded: (configId: string) => void;
+ onRequestDeleteAssessor: (assessor: AssessorSummary) => void;
+ onRequestDeleteVersion: (configId: string, version: number) => void;
+}
+
+const CHIP_COUNT = 2;
+
+export default function AssessorRow({
+ assessor,
+ selection,
+ versions,
+ isExpanded,
+ deletingKey,
+ onSelectAssessor,
+ onSelectVersion,
+ onToggleExpanded,
+ onRequestDeleteAssessor,
+ onRequestDeleteVersion,
+}: AssessorRowProps) {
+ const isCurrent = selection?.configId === assessor.id;
+ const selectedVersion = isCurrent ? (selection?.version ?? null) : null;
+
+ return (
+
+
+
onSelectAssessor(assessor.id)}
+ aria-pressed={isCurrent}
+ title="Select this assessor — its latest version is picked automatically"
+ className="min-w-0 flex-1 cursor-pointer text-left"
+ >
+
+ {assessor.name}
+
+
+ {assessor.description ?? "No description"}
+
+
+ Updated {formatRelativeTime(assessor.updated_at)}
+ {versions && (
+ <>
+ ·
+ {versions.length} version{versions.length === 1 ? "" : "s"}
+ >
+ )}
+
+
+
+
+
+
onRequestDeleteAssessor(assessor)}
+ aria-label={`Delete ${assessor.name}`}
+ title="Delete this assessor and all its versions"
+ className="mt-0.5 inline-flex h-6 w-6 shrink-0 cursor-pointer items-center justify-center rounded-md border border-border bg-bg-primary text-text-secondary transition-colors hover:border-status-error hover:text-status-error-text disabled:cursor-default disabled:opacity-40"
+ >
+
+
+
+
+ {isExpanded && (
+
+ )}
+
+ );
+}
diff --git a/app/components/assessment/home/AssessorVersionChips.tsx b/app/components/assessment/home/AssessorVersionChips.tsx
new file mode 100644
index 00000000..a61dc886
--- /dev/null
+++ b/app/components/assessment/home/AssessorVersionChips.tsx
@@ -0,0 +1,73 @@
+"use client";
+
+import { ChevronDownIcon } from "@/app/components/icons";
+import type { AssessorSummary, AssessorVersion } from "@/app/lib/types/assessment";
+
+interface AssessorVersionChipsProps {
+ assessor: AssessorSummary;
+ versions: AssessorVersion[] | undefined;
+ chipCount: number;
+ selectedVersion: number | null;
+ isExpanded: boolean;
+ onSelectVersion: (configId: string, version: number) => void;
+ onToggleExpanded: (configId: string) => void;
+}
+
+const chipBase =
+ "rounded-full border px-2.5 py-[3px] text-[11px] font-semibold transition-colors cursor-pointer";
+const chipIdle =
+ "border-border bg-bg-secondary text-text-secondary hover:border-accent-muted hover:text-text-primary";
+const chipSelected = "border-accent-primary bg-accent-primary text-white";
+
+/**
+ * The chevron is the only thing shown until versions are loaded — a row's
+ * version list costs its own request, so nothing fetches it up front.
+ */
+export default function AssessorVersionChips({
+ assessor,
+ versions,
+ chipCount,
+ selectedVersion,
+ isExpanded,
+ onSelectVersion,
+ onToggleExpanded,
+}: AssessorVersionChipsProps) {
+ const recent = (versions ?? []).slice(0, chipCount);
+ const hiddenCount = (versions?.length ?? 0) - recent.length;
+ const expandTitle = isExpanded
+ ? "Hide versions"
+ : versions
+ ? `Show ${hiddenCount} older version${hiddenCount === 1 ? "" : "s"}`
+ : "Show versions";
+
+ return (
+
+ {recent.map(({ version }) => (
+ onSelectVersion(assessor.id, version)}
+ aria-pressed={selectedVersion === version}
+ className={`${chipBase} ${
+ selectedVersion === version ? chipSelected : chipIdle
+ }`}
+ >
+ v{version}
+
+ ))}
+
+ onToggleExpanded(assessor.id)}
+ aria-expanded={isExpanded}
+ aria-label={`Show all versions of ${assessor.name}`}
+ title={expandTitle}
+ className="inline-flex h-6 w-6 cursor-pointer items-center justify-center rounded-md border border-border bg-bg-primary text-text-secondary transition-colors hover:border-accent-muted hover:text-text-primary"
+ >
+
+
+
+ );
+}
diff --git a/app/components/assessment/home/AssessorVersionList.tsx b/app/components/assessment/home/AssessorVersionList.tsx
new file mode 100644
index 00000000..4e45bbe0
--- /dev/null
+++ b/app/components/assessment/home/AssessorVersionList.tsx
@@ -0,0 +1,75 @@
+"use client";
+
+import { TrashIcon } from "@/app/components/icons";
+import type { AssessorVersion } from "@/app/lib/types/assessment";
+
+interface AssessorVersionListProps {
+ configId: string;
+ versions: AssessorVersion[] | undefined;
+ selectedVersion: number | null;
+ deletingKey: string | null;
+ onSelectVersion: (configId: string, version: number) => void;
+ onRequestDeleteVersion: (configId: string, version: number) => void;
+}
+
+/** Every version of one assessor, revealed in place under its row. */
+export default function AssessorVersionList({
+ configId,
+ versions,
+ selectedVersion,
+ deletingKey,
+ onSelectVersion,
+ onRequestDeleteVersion,
+}: AssessorVersionListProps) {
+ if (!versions) {
+ return (
+
+ Loading versions…
+
+ );
+ }
+
+ return (
+
+ {versions.map((version) => {
+ const isSelected = selectedVersion === version.version;
+ const isDeleting = deletingKey === `${configId}:v${version.version}`;
+ return (
+
+ onSelectVersion(configId, version.version)}
+ aria-pressed={isSelected}
+ className={`flex min-w-0 flex-1 cursor-pointer items-center gap-2.5 rounded-lg border px-2.5 py-1.5 text-left transition-colors ${
+ isSelected
+ ? "border-accent-primary bg-accent-primary/10"
+ : "border-border bg-bg-secondary hover:border-accent-muted"
+ }`}
+ >
+
+ v{version.version}
+
+
+ {version.commit_message ?? "No note"}
+
+
+ onRequestDeleteVersion(configId, version.version)}
+ aria-label={`Delete version v${version.version}`}
+ title={`Delete v${version.version}`}
+ className="inline-flex h-6 w-6 shrink-0 cursor-pointer items-center justify-center rounded-md border border-border bg-bg-primary text-text-secondary transition-colors hover:border-status-error hover:text-status-error-text disabled:cursor-default disabled:opacity-40"
+ >
+
+
+
+ );
+ })}
+
+ );
+}
diff --git a/app/components/assessment/home/AssessorsPanel.tsx b/app/components/assessment/home/AssessorsPanel.tsx
new file mode 100644
index 00000000..9bc38390
--- /dev/null
+++ b/app/components/assessment/home/AssessorsPanel.tsx
@@ -0,0 +1,127 @@
+"use client";
+
+import { useState } from "react";
+import { PlusIcon, SearchIcon } from "@/app/components/icons";
+import { Button } from "@/app/components/ui";
+import AssessorRow from "./AssessorRow";
+import DeleteAssessorDialog, {
+ type DeleteTarget,
+} from "./DeleteAssessorDialog";
+import type { UseAssessmentHomeResult } from "@/app/lib/types/assessment";
+import HomePanel from "./HomePanel";
+
+interface AssessorsPanelProps {
+ home: UseAssessmentHomeResult;
+ onNewAssessor: () => void;
+ onEditVersion: () => void;
+}
+
+export default function AssessorsPanel({
+ home,
+ onNewAssessor,
+ onEditVersion,
+}: AssessorsPanelProps) {
+ const { assessors, selection } = home;
+ const [pendingDelete, setPendingDelete] = useState(null);
+
+ return (
+
+
+
+ home.setAssessorSearch(event.target.value)}
+ placeholder="Search assessors..."
+ aria-label="Search assessors"
+ className="w-full rounded-full border border-border bg-bg-primary py-1.5 pr-3 pl-8 text-[13px] text-text-primary transition-colors placeholder:text-neutral-400 focus:border-accent-primary focus:outline-none"
+ />
+
+
+
+ New
+
+
+ Edit
+
+ >
+ }
+ >
+ {assessors.map((assessor) => (
+
+ setPendingDelete({
+ kind: "assessor",
+ configId: target.id,
+ name: target.name,
+ })
+ }
+ onRequestDeleteVersion={(configId, version) =>
+ setPendingDelete({ kind: "version", configId, version })
+ }
+ />
+ ))}
+
+ {assessors.length === 0 && (
+
+ {home.assessorSearch
+ ? "No assessors match your search."
+ : "No assessors yet — start with New."}
+
+ )}
+
+ {pendingDelete && (
+ setPendingDelete(null)}
+ onConfirm={() => {
+ const target = pendingDelete;
+ setPendingDelete(null);
+ void (target.kind === "assessor"
+ ? home.deleteAssessor(target.configId)
+ : home.deleteAssessorVersion(target.configId, target.version));
+ }}
+ />
+ )}
+
+ );
+}
diff --git a/app/components/assessment/home/DeleteAssessorDialog.tsx b/app/components/assessment/home/DeleteAssessorDialog.tsx
new file mode 100644
index 00000000..3c5a0865
--- /dev/null
+++ b/app/components/assessment/home/DeleteAssessorDialog.tsx
@@ -0,0 +1,70 @@
+"use client";
+
+import { WarningIcon } from "@/app/components/icons";
+import { Button, Modal } from "@/app/components/ui";
+
+export type DeleteTarget =
+ | { kind: "assessor"; configId: string; name: string }
+ | { kind: "version"; configId: string; version: number };
+
+interface DeleteAssessorDialogProps {
+ target: DeleteTarget;
+ onCancel: () => void;
+ onConfirm: () => void;
+}
+
+/** Confirms deleting an assessor (with every version) or a single version. */
+export default function DeleteAssessorDialog({
+ target,
+ onCancel,
+ onConfirm,
+}: DeleteAssessorDialogProps) {
+ const isAssessor = target.kind === "assessor";
+
+ return (
+
+
+
+
+
+
+
+
+ {isAssessor ? "Delete assessor" : "Delete version"}
+
+
+ {isAssessor ? (
+ <>
+ Are you sure you want to delete{" "}
+ {target.name} {" "}
+ and every version saved under it? Runs already made with it
+ stay. This action cannot be undone.
+ >
+ ) : (
+ <>
+ Are you sure you want to delete{" "}
+ v{target.version} ? This
+ action cannot be undone.
+ >
+ )}
+
+
+
+
+
+
+ Cancel
+
+
+ Delete
+
+
+
+ );
+}
diff --git a/app/components/assessment/home/HomePanel.tsx b/app/components/assessment/home/HomePanel.tsx
new file mode 100644
index 00000000..4689b271
--- /dev/null
+++ b/app/components/assessment/home/HomePanel.tsx
@@ -0,0 +1,69 @@
+import { ReactNode } from "react";
+import { CursorPager, Pager } from "@/app/components/ui";
+
+interface HomePanelProps {
+ title: string;
+ headerActions?: ReactNode;
+ banner?: ReactNode;
+ children: ReactNode;
+ countLabel: string;
+ /** Numbered pages, for a list held entirely in memory. */
+ page?: number;
+ pages?: number;
+ onGoto?: (page: number) => void;
+ /** Prev/next only, for a list paged through the API. */
+ cursor?: {
+ hasPrev: boolean;
+ hasNext: boolean;
+ onPrev: () => void;
+ onNext: () => void;
+ };
+ className?: string;
+}
+
+/** Shared shell for Home's two panels: header row, scrolling body, count + pager. */
+export default function HomePanel({
+ title,
+ headerActions,
+ banner,
+ children,
+ countLabel,
+ page,
+ pages,
+ onGoto,
+ cursor,
+ className = "",
+}: HomePanelProps) {
+ return (
+
+ {/* Fixed height, no wrap: both panels' headers align with each other. */}
+
+
+ {title}
+
+ {headerActions}
+
+
+ {banner}
+
+
+ {children}
+
+
+ {/* Fixed height so both panels' footers line up whether or not a pager shows. */}
+
+
{countLabel}
+ {cursor ? (
+
+ ) : (
+
{})}
+ label={`${title} pagination`}
+ />
+ )}
+
+
+ );
+}
diff --git a/app/components/assessment/home/HomeView.tsx b/app/components/assessment/home/HomeView.tsx
new file mode 100644
index 00000000..02a17a2c
--- /dev/null
+++ b/app/components/assessment/home/HomeView.tsx
@@ -0,0 +1,91 @@
+"use client";
+
+import { LoaderBox } from "@/app/components/ui";
+import Stepper from "@/app/components/assessment/Stepper";
+import { ASSESSMENT_WIZARD_STEPS } from "@/app/lib/assessment/constants";
+import { useAssessmentHome } from "@/app/hooks/useAssessmentHome";
+import AssessorsPanel from "./AssessorsPanel";
+import RunsPanel from "./RunsPanel";
+import type {
+ AssessorSelection,
+ WizardContext,
+} from "@/app/lib/types/assessment";
+
+interface HomeViewProps {
+ initialSelection: AssessorSelection | null;
+ onNewAssessor: () => void;
+ onEditVersion: (context: WizardContext) => void;
+ onNewRun: (context: WizardContext) => void;
+}
+
+const NO_COMPLETED_STEPS = new Set();
+const NO_STEP_CLICK = () => {};
+
+export default function HomeView({
+ initialSelection,
+ onNewAssessor,
+ onEditVersion,
+ onNewRun,
+}: HomeViewProps) {
+ const home = useAssessmentHome(initialSelection);
+ const { selection } = home;
+
+ const contextForSelection = (): WizardContext | null => {
+ if (!selection) return null;
+ const assessor = home.assessors.find(
+ (item) => item.id === selection.configId,
+ );
+ return {
+ configId: selection.configId,
+ version: selection.version,
+ assessorName: assessor?.name ?? "Assessor",
+ };
+ };
+
+ const openWith = (open: (context: WizardContext) => void) => () => {
+ const context = contextForSelection();
+ if (context) open(context);
+ };
+
+ return (
+
+
{}}
+ trailing={
+
+ Start with “New”, or select a version to edit or run
+
+ }
+ />
+
+ {home.error && (
+
+ {home.error}
+
+ )}
+
+ {home.isLoading ? (
+
+
+
+ ) : (
+
+ )}
+
+ );
+}
diff --git a/app/components/assessment/home/RunRow.tsx b/app/components/assessment/home/RunRow.tsx
new file mode 100644
index 00000000..13dd2746
--- /dev/null
+++ b/app/components/assessment/home/RunRow.tsx
@@ -0,0 +1,63 @@
+"use client";
+
+import ChildRunStageProgress from "@/app/components/assessment/ChildRunStageProgress";
+import {
+ ASSESSMENT_CARD_CLASSES,
+ STATUS_BADGE_CLASSES,
+} from "@/app/lib/assessment/constants";
+import { formatStatusLabel, getResultTone } from "@/app/lib/assessment/results";
+import RunRowActions from "./RunRowActions";
+import RunRowMeta from "./RunRowMeta";
+import type { HomeRunRow, ResultsTarget } from "@/app/lib/types/assessment";
+
+interface RunRowProps {
+ row: HomeRunRow;
+ isExporting: boolean;
+ onExport: (target: ResultsTarget, fileName: string) => void;
+}
+
+export default function RunRow({
+ row,
+ isExporting,
+ onExport,
+}: RunRowProps) {
+ const { assessment, stages, isActive } = row;
+ const tone = getResultTone(assessment.status);
+
+ return (
+
+
+
+ {assessment.experiment_name}
+
+
+ {formatStatusLabel(assessment.status)}
+
+
+
+
+
+ {assessment.error && (
+
+ {assessment.error}
+
+ )}
+
+ {isActive && stages.length > 0 && (
+
+
+
+ )}
+
+
+
+ );
+}
diff --git a/app/components/assessment/home/RunRowActions.tsx b/app/components/assessment/home/RunRowActions.tsx
new file mode 100644
index 00000000..fed9927d
--- /dev/null
+++ b/app/components/assessment/home/RunRowActions.tsx
@@ -0,0 +1,64 @@
+"use client";
+
+import { DownloadIcon, EyeIcon } from "@/app/components/icons";
+import { Button } from "@/app/components/ui";
+import { isTerminalStatus } from "@/app/lib/assessment/results";
+import type { HomeRunRow, ResultsTarget } from "@/app/lib/types/assessment";
+
+interface RunRowActionsProps {
+ row: HomeRunRow;
+ isExporting: boolean;
+ onExport: (target: ResultsTarget, fileName: string) => void;
+}
+
+function resultsHref(row: HomeRunRow): string {
+ const { assessment } = row;
+ const version = row.version === null ? "" : ` v${row.version}`;
+ const title = `${assessment.experiment_name ?? "Run"} · ${row.assessorName}${version}`;
+ const query = new URLSearchParams({ title, method: assessment.method });
+ return `/assessment/results/${assessment.assessment_id}?${query}`;
+}
+
+/** Export / View results, per the run's status. Retry has no BATCH endpoint yet. */
+export default function RunRowActions({
+ row,
+ isExporting,
+ onExport,
+}: RunRowActionsProps) {
+ const { assessment } = row;
+ const href = resultsHref(row);
+ // Exportable once the run stops, errors included — those rows are results too.
+ const canExport = isTerminalStatus(assessment.status);
+
+ return (
+
+ {canExport && (
+
+ onExport(
+ {
+ assessment_id: assessment.assessment_id,
+ method: assessment.method,
+ },
+ assessment.experiment_name ?? assessment.assessment_id,
+ )
+ }
+ >
+
+ {isExporting ? "Exporting…" : "Export"}
+
+ )}
+
+
+
+ View results
+
+
+ );
+}
diff --git a/app/components/assessment/home/RunRowMeta.tsx b/app/components/assessment/home/RunRowMeta.tsx
new file mode 100644
index 00000000..9a6e30c7
--- /dev/null
+++ b/app/components/assessment/home/RunRowMeta.tsx
@@ -0,0 +1,36 @@
+import { DatabaseIcon } from "@/app/components/icons";
+import { formatRelativeTime } from "@/app/lib/utils";
+import type { HomeRunRow } from "@/app/lib/types/assessment";
+
+interface RunRowMetaProps {
+ row: HomeRunRow;
+}
+
+/** The two meta lines under a run's name: when · assessor vN, then the set. */
+export default function RunRowMeta({ row }: RunRowMetaProps) {
+ const { assessment, assessorName, version, cost } = row;
+
+ return (
+ <>
+
+ {formatRelativeTime(assessment.updated_at)}
+ ·
+
+ {assessorName}
+ {version === null ? "" : ` v${version}`}
+
+ {cost && (
+ <>
+ ·
+ {cost}
+ >
+ )}
+
+
+
+
+ {assessment.submission_name ?? "—"}
+
+ >
+ );
+}
diff --git a/app/components/assessment/home/RunsPanel.tsx b/app/components/assessment/home/RunsPanel.tsx
new file mode 100644
index 00000000..948ab907
--- /dev/null
+++ b/app/components/assessment/home/RunsPanel.tsx
@@ -0,0 +1,76 @@
+"use client";
+
+import { Button, Select } from "@/app/components/ui";
+import { PlusIcon } from "@/app/components/icons";
+import RunRow from "./RunRow";
+import HomePanel from "./HomePanel";
+import type { UseAssessmentHomeResult } from "@/app/lib/types/assessment";
+
+interface RunsPanelProps {
+ home: UseAssessmentHomeResult;
+ onNewRun: () => void;
+}
+
+export default function RunsPanel({ home, onNewRun }: RunsPanelProps) {
+ const { runSlice, selection } = home;
+ const assessorName = selection
+ ? home.assessors.find((item) => item.id === selection.configId)?.name
+ : null;
+
+ return (
+
+ home.setRunFilter(event.target.value)}
+ className="max-w-[220px] text-[13px]"
+ />
+ {/* Always present, disabled with a reason — it used to vanish instead. */}
+
+
+ New run
+
+
+ }
+ >
+ {runSlice.items.map((row) => (
+
+ ))}
+
+ {runSlice.items.length === 0 && (
+
+ {selection
+ ? "No runs yet for this version — start one with New run."
+ : "No runs for this filter. Select an assessor version on the left to start one."}
+
+ )}
+
+ );
+}
diff --git a/app/components/assessment/model/ModelChip.tsx b/app/components/assessment/model/ModelChip.tsx
new file mode 100644
index 00000000..eade370b
--- /dev/null
+++ b/app/components/assessment/model/ModelChip.tsx
@@ -0,0 +1,79 @@
+"use client";
+
+import { useEffect, useRef, useState } from "react";
+import { ChevronDownIcon, GearIcon } from "@/app/components/icons";
+import ModelPicker from "./ModelPicker";
+import type {
+ AssessorModelSelection,
+ PromptStepId,
+} from "@/app/lib/types/assessment";
+import type { ProviderType } from "@/app/lib/types/configs";
+
+interface ModelChipProps {
+ step: PromptStepId;
+ selection: AssessorModelSelection;
+ onModel: (step: PromptStepId, provider: ProviderType, model: string) => void;
+ onParam: (step: PromptStepId, key: string, value: string | number) => void;
+}
+
+/** The model in use for this step, opening its picker in the flow strip. */
+export default function ModelChip({
+ step,
+ selection,
+ onModel,
+ onParam,
+}: ModelChipProps) {
+ const [isOpen, setIsOpen] = useState(false);
+ const wrapperRef = useRef
(null);
+
+ useEffect(() => {
+ if (!isOpen) return;
+ const onPointerDown = (event: MouseEvent) => {
+ if (!wrapperRef.current?.contains(event.target as Node)) {
+ setIsOpen(false);
+ }
+ };
+ const onKeyDown = (event: KeyboardEvent) => {
+ if (event.key === "Escape") setIsOpen(false);
+ };
+ document.addEventListener("mousedown", onPointerDown);
+ document.addEventListener("keydown", onKeyDown);
+ return () => {
+ document.removeEventListener("mousedown", onPointerDown);
+ document.removeEventListener("keydown", onKeyDown);
+ };
+ }, [isOpen]);
+
+ return (
+
+
setIsOpen((current) => !current)}
+ className="inline-flex cursor-pointer items-center gap-2 rounded-full border-[1.5px] border-accent-primary bg-accent-primary/[0.07] px-3.5 py-1.5 text-xs font-semibold text-accent-primary transition-colors hover:bg-accent-primary/[0.14]"
+ >
+
+
+ Model
+
+
+ {selection.model}
+
+
+
+
+ {isOpen && (
+
+
+
+ )}
+
+ );
+}
diff --git a/app/components/assessment/model/ModelPicker.tsx b/app/components/assessment/model/ModelPicker.tsx
new file mode 100644
index 00000000..4691841e
--- /dev/null
+++ b/app/components/assessment/model/ModelPicker.tsx
@@ -0,0 +1,102 @@
+"use client";
+
+import { Select } from "@/app/components/ui";
+import ConfigParamControl from "@/app/components/assessment/prompt-config/ConfigParamControl";
+import {
+ PROVIDER_OPTIONS,
+ getModelConfigDefinition,
+ getModelsByProvider,
+} from "@/app/lib/data/assessmentModels";
+import type {
+ AssessorModelSelection,
+ PromptStepId,
+} from "@/app/lib/types/assessment";
+import type { ProviderType } from "@/app/lib/types/configs";
+
+interface ModelPickerProps {
+ step: PromptStepId;
+ selection: AssessorModelSelection;
+ onModel: (step: PromptStepId, provider: ProviderType, model: string) => void;
+ onParam: (step: PromptStepId, key: string, value: string | number) => void;
+}
+
+const fieldLabel =
+ "block text-[10px] font-semibold tracking-wider uppercase text-text-secondary";
+
+/**
+ * Provider → model → that model's own parameters. The parameter set differs per
+ * model (a reasoning model takes effort, a chat model takes temperature), so it
+ * is read from the catalog rather than hardcoded.
+ */
+export default function ModelPicker({
+ step,
+ selection,
+ onModel,
+ onParam,
+}: ModelPickerProps) {
+ const models = getModelsByProvider(selection.provider);
+ const definition = getModelConfigDefinition(selection.model);
+
+ return (
+
+
+
+
+ Provider
+
+ ({ ...option }))}
+ onChange={(event) =>
+ onModel(step, event.target.value as ProviderType, "")
+ }
+ />
+
+
+
+
+ Model
+
+
+ onModel(step, selection.provider, event.target.value)
+ }
+ />
+
+
+
+
+
Parameters · {selection.model}
+
+ {Object.entries(definition).map(([key, param]) => (
+
+
+
+ {key}
+
+
+ {param.description}
+
+
+
onParam(step, key, value)}
+ />
+
+ ))}
+ {Object.keys(definition).length === 0 && (
+
+ This model takes no tunable parameters.
+
+ )}
+
+
+
+ );
+}
diff --git a/app/components/assessment/output-schema/OutputSchemaModal.tsx b/app/components/assessment/output-schema/OutputSchemaModal.tsx
deleted file mode 100644
index 23ecd682..00000000
--- a/app/components/assessment/output-schema/OutputSchemaModal.tsx
+++ /dev/null
@@ -1,101 +0,0 @@
-"use client";
-
-import { useEffect, useRef, useState } from "react";
-import { createPortal } from "react-dom";
-import { Button, Modal } from "@/app/components/ui";
-import { CloseIcon } from "@/app/components/icons";
-import type { SchemaProperty, ValueSetter } from "@/app/lib/types/assessment";
-import { createProperty } from "@/app/lib/utils/outputSchema";
-import OutputSchemaEditorInner from "./OutputSchemaEditorInner";
-
-interface OutputSchemaModalProps {
- open: boolean;
- onClose: () => void;
- schema: SchemaProperty[];
- setSchema: ValueSetter;
-}
-
-export default function OutputSchemaModal({
- open,
- onClose,
- schema,
- setSchema,
-}: OutputSchemaModalProps) {
- const [draftSchema, setDraftSchema] = useState([]);
- const [mounted, setMounted] = useState(false);
- const schemaRef = useRef(schema);
-
- useEffect(() => {
- setMounted(true);
- }, []);
- useEffect(() => {
- schemaRef.current = schema;
- }, [schema]);
- useEffect(() => {
- if (open) {
- setDraftSchema(
- JSON.parse(JSON.stringify(schemaRef.current)) as SchemaProperty[],
- );
- }
- }, [open]);
-
- if (!open || !mounted) return null;
-
- const handleSave = () => {
- setSchema(draftSchema);
- onClose();
- };
- const handleClose = () => {
- setSchema(draftSchema);
- onClose();
- };
- const handleReset = () => {
- setDraftSchema([createProperty()]);
- };
-
- return createPortal(
-
-
-
- Response format
-
-
-
-
-
-
-
- Define the structure of the AI response. Use the visual editor or switch
- to JSON.
-
-
-
-
-
-
-
-
- Reset
-
-
- Save
-
-
- ,
- document.body,
- );
-}
diff --git a/app/components/assessment/prompt-config/AssessmentConfiguration.tsx b/app/components/assessment/prompt-config/AssessmentConfiguration.tsx
deleted file mode 100644
index a0465778..00000000
--- a/app/components/assessment/prompt-config/AssessmentConfiguration.tsx
+++ /dev/null
@@ -1,120 +0,0 @@
-"use client";
-
-import { RadioGroup } from "@/app/components/ui";
-import type { AssessmentConfigurationProps } from "@/app/lib/types/assessment";
-import type { ConfigMode } from "@/app/lib/types/assessment/config";
-import ConfigCreator from "./ConfigCreator";
-import SavedConfigs from "./SavedConfigs";
-import SelectedConfigs from "./SelectedConfigs";
-
-export default function AssessmentConfiguration({
- configMode,
- setConfigMode,
- configs,
- onRemoveConfig,
- configCards,
- searchQuery,
- setSearchQuery,
- isLoadingConfigs,
- hasMoreConfigs,
- nextConfigSkip,
- expandedConfigId,
- versionStateByConfig,
- latestModelByConfig,
- loadingSelectionKeys,
- isSelected,
- onLoadMoreConfigs,
- onLoadVersions,
- onToggleConfigExpansion,
- onToggleVersionSelection,
- currentProvider,
- currentModel,
- providerModels,
- currentParamDefs,
- draftParams,
- configName,
- commitMessage,
- isSaving,
- setConfigName,
- setCommitMessage,
- onProviderChange,
- onModelChange,
- onParamChange,
- onSaveConfig,
-}: AssessmentConfigurationProps) {
- return (
-
-
-
-
- AI Configuration
-
-
- {configs.length > 0
- ? `${configs.length} selected`
- : "Choose at least one configuration"}
-
-
-
-
- {configs.length > 0 && (
-
-
-
- )}
-
-
-
-
- value={configMode}
- onChange={setConfigMode}
- ariaLabel="Config source"
- options={[
- { value: "existing", label: "Saved" },
- { value: "create", label: "New" },
- ]}
- />
-
-
- {configMode === "existing" && (
-
- )}
-
- {configMode === "create" && (
-
- )}
-
-
- );
-}
diff --git a/app/components/assessment/prompt-config/ConfigCreator.tsx b/app/components/assessment/prompt-config/ConfigCreator.tsx
deleted file mode 100644
index 524f9d58..00000000
--- a/app/components/assessment/prompt-config/ConfigCreator.tsx
+++ /dev/null
@@ -1,115 +0,0 @@
-import { Button, Field, Select } from "@/app/components/ui";
-import { PROVIDER_OPTIONS } from "@/app/lib/data/assessmentModels";
-import type { ConfigCreatorProps } from "@/app/lib/types/assessment";
-import type { CompletionConfig } from "@/app/lib/types/configs";
-import ConfigParamControl from "./ConfigParamControl";
-
-const selectClass =
- "w-full rounded-full border border-border bg-bg-primary px-2 py-2 text-sm text-text-primary outline-none focus:ring-1";
-
-export default function ConfigCreator({
- currentProvider,
- currentModel,
- providerModels,
- currentParamDefs,
- draftParams,
- configName,
- commitMessage,
- isSaving,
- setConfigName,
- setCommitMessage,
- onProviderChange,
- onModelChange,
- onParamChange,
- onSave,
-}: ConfigCreatorProps) {
- const saveDisabled = isSaving || !configName.trim();
-
- return (
-
-
-
-
- Provider
-
-
- onProviderChange(e.target.value as CompletionConfig["provider"])
- }
- className={selectClass}
- options={[...PROVIDER_OPTIONS]}
- />
-
-
-
- Model
-
- onModelChange(e.target.value)}
- className={selectClass}
- options={providerModels}
- />
-
-
-
-
-
- Advanced
-
-
- {Object.entries(currentParamDefs).map(([paramKey, definition]) => (
-
-
-
-
- {paramKey}
-
-
- {definition.description}
-
-
-
- {String(definition.default)}
-
-
-
onParamChange(paramKey, value)}
- />
-
- ))}
-
-
-
-
-
-
-
-
-
void onSave()}
- disabled={saveDisabled}
- >
- {isSaving ? "Saving..." : "Save behavior"}
-
-
- );
-}
diff --git a/app/components/assessment/prompt-config/PromptEditor.tsx b/app/components/assessment/prompt-config/PromptEditor.tsx
deleted file mode 100644
index 93b86d9f..00000000
--- a/app/components/assessment/prompt-config/PromptEditor.tsx
+++ /dev/null
@@ -1,157 +0,0 @@
-"use client";
-
-import { Button } from "@/app/components/ui";
-import { usePromptPlaceholderEditor } from "@/app/hooks/usePromptPlaceholderEditor";
-import type { SampleRow, ValueSetter } from "@/app/lib/types/assessment";
-
-interface PromptEditorProps {
- value: string;
- onChange: ValueSetter;
- previewMode: boolean;
- placeholder: string;
- emptyPreviewText: string;
- textColumns?: string[];
- sampleRow?: SampleRow;
- enablePlaceholders?: boolean;
-}
-
-export default function PromptEditor({
- value,
- onChange,
- previewMode,
- placeholder,
- emptyPreviewText,
- textColumns = [],
- sampleRow = {},
- enablePlaceholders = true,
-}: PromptEditorProps) {
- const {
- textareaRef,
- mirrorRef,
- dropdownRef,
- mentionQuery,
- mentionIndex,
- mentionPos,
- mentionOptions,
- setMentionIndex,
- handleInput,
- handleKeyDown,
- insertMention,
- insertPlaceholder,
- usedColumns,
- orderedColumns,
- previewText,
- } = usePromptPlaceholderEditor({
- value,
- onChange,
- previewMode,
- textColumns,
- sampleRow,
- enablePlaceholders,
- });
-
- if (previewMode) {
- return (
-
- {!value.trim() ? (
- {emptyPreviewText}
- ) : enablePlaceholders && Object.keys(sampleRow).length === 0 ? (
-
- Sample data not available. Go back to Datasets and choose a row with
- values.
-
- ) : (
- previewText || (
- {emptyPreviewText}
- )
- )}
-
- );
- }
-
- return (
- <>
- {enablePlaceholders && (
-
-
- Use `@` or tap a column chip to insert placeholders.
-
-
- {orderedColumns.map((col) => {
- const isUsed = usedColumns.includes(col);
- return (
- insertPlaceholder(col)}
- className={`!rounded-full !px-3 !py-1.5 !font-mono !text-xs ${
- isUsed
- ? "!border-status-success-border !bg-status-success-bg !text-status-success-text"
- : "!bg-bg-primary"
- }`}
- >
- {`{${col}}`}
-
- );
- })}
-
-
- )}
-
-
- >
- );
-}
diff --git a/app/components/assessment/prompt-config/PromptPanel.tsx b/app/components/assessment/prompt-config/PromptPanel.tsx
deleted file mode 100644
index 1c96ec3f..00000000
--- a/app/components/assessment/prompt-config/PromptPanel.tsx
+++ /dev/null
@@ -1,50 +0,0 @@
-"use client";
-
-import { useState } from "react";
-import { RadioGroup } from "@/app/components/ui";
-import type { PromptPanelProps } from "@/app/lib/types/assessment";
-
-type PromptViewMode = "edit" | "preview";
-import UserPrompt from "./UserPrompt";
-import SystemPrompt from "./SystemPrompt";
-
-export default function PromptPanel({
- textColumns,
- sampleRow,
- systemInstruction,
- setSystemInstruction,
- promptTemplate,
- setPromptTemplate,
-}: PromptPanelProps) {
- const [viewMode, setViewMode] = useState("edit");
- const previewMode = viewMode === "preview";
-
- return (
-
-
-
- value={viewMode}
- onChange={setViewMode}
- ariaLabel="Prompt view mode"
- options={[
- { value: "edit", label: "Edit" },
- { value: "preview", label: "Preview" },
- ]}
- />
-
-
-
-
-
- );
-}
diff --git a/app/components/assessment/prompt-config/ResponseSchema.tsx b/app/components/assessment/prompt-config/ResponseSchema.tsx
deleted file mode 100644
index b40940f1..00000000
--- a/app/components/assessment/prompt-config/ResponseSchema.tsx
+++ /dev/null
@@ -1,47 +0,0 @@
-"use client";
-
-import { useState } from "react";
-import { Button } from "@/app/components/ui";
-import type { ResponseSchemaProps } from "@/app/lib/types/assessment";
-import { OutputSchemaModal } from "../OutputSchemaEditor";
-
-export default function ResponseSchema({
- schema,
- setSchema,
- summary,
- hasFields,
-}: ResponseSchemaProps) {
- const [schemaModalOpen, setSchemaModalOpen] = useState(false);
-
- return (
- <>
-
-
-
-
- Response Format
-
-
{summary}
-
- {
- event.preventDefault();
- setSchemaModalOpen(true);
- }}
- >
- {hasFields ? "Edit" : "Set"}
-
-
-
-
- setSchemaModalOpen(false)}
- schema={schema}
- setSchema={setSchema}
- />
- >
- );
-}
diff --git a/app/components/assessment/prompt-config/SavedConfigCard.tsx b/app/components/assessment/prompt-config/SavedConfigCard.tsx
deleted file mode 100644
index 262ae437..00000000
--- a/app/components/assessment/prompt-config/SavedConfigCard.tsx
+++ /dev/null
@@ -1,286 +0,0 @@
-import { Button } from "@/app/components/ui";
-import { ChevronDownIcon } from "@/app/components/icons";
-import { formatRelativeTime } from "@/app/lib/utils";
-import type { ConfigPublic, ConfigVersionItems } from "@/app/lib/types/configs";
-import type {
- SavedConfigCardProps,
- VersionListState,
-} from "@/app/lib/types/assessment";
-
-function VersionSummary({
- previewVersions,
- knownVersionCount,
-}: {
- previewVersions: ConfigVersionItems[];
- knownVersionCount: number;
-}) {
- return (
-
-
- {previewVersions.length > 0
- ? previewVersions.map((v) => `v${v.version}`).join(", ")
- : "Versions"}
-
-
- {previewVersions.length > 0
- ? `${knownVersionCount} saved version${knownVersionCount === 1 ? "" : "s"}`
- : 'Use "Show versions" to view history'}
-
-
- );
-}
-
-function VersionPanel({
- config,
- versions,
- expanded,
- loadingSelectionKeys,
- isSelected,
- onLoadVersions,
- onToggleVersionSelection,
-}: {
- config: ConfigPublic;
- versions: VersionListState;
- expanded: boolean;
- loadingSelectionKeys: Record;
- isSelected: (configId: string, version: number) => boolean;
- onLoadVersions: (configId: string, skip: number) => void;
- onToggleVersionSelection: (
- config: ConfigPublic,
- version: number,
- ) => void | Promise;
-}) {
- return (
-
-
-
-
-
- Saved versions
-
-
- Pick a specific version to reuse.
-
-
-
- {versions.items.length}
-
-
- {versions.isLoading && versions.items.length === 0 ? (
-
- Loading versions...
-
- ) : (
-
- {versions.items.map((version) => {
- const selected = isSelected(config.id, version.version);
- const loading =
- loadingSelectionKeys[`${config.id}:${version.version}`];
-
- return (
-
-
-
-
- v{version.version}
-
-
- Version {version.version}
-
-
- {version.commit_message && (
-
- {version.commit_message}
-
- )}
-
-
- void onToggleVersionSelection(config, version.version)
- }
- disabled={Boolean(loading)}
- className={`rounded-full! px-3! py-1.5! text-[11px]! ${
- selected ? "bg-bg-secondary!" : ""
- }`}
- >
- {loading ? "..." : selected ? "Added" : "Use"}
-
-
- );
- })}
- {versions.hasMore && !versions.isLoading && (
-
onLoadVersions(config.id, versions.nextSkip)}
- className="py-1.5! text-xs! text-accent-primary!"
- >
- Load more
-
- )}
-
- )}
-
-
- );
-}
-
-export default function SavedConfigCard({
- config,
- versions,
- latestModel,
- expanded,
- loadingSelectionKeys,
- isSelected,
- onLoadVersions,
- onToggleExpansion,
- onToggleVersionSelection,
-}: SavedConfigCardProps) {
- const latestVersion =
- versions.items.reduce(
- (maxVersion, item) =>
- item.version > maxVersion ? item.version : maxVersion,
- 0,
- ) || 1;
- const versionsNotYetLoaded =
- versions.items.length === 0 && versions.isLoading;
- const defaultSelected = isSelected(config.id, latestVersion);
- const defaultLoading = loadingSelectionKeys[`${config.id}:${latestVersion}`];
- const knownVersionCount = versions.items.length;
- const hasVersionsPanel =
- knownVersionCount > 0 ||
- versions.hasMore ||
- versions.isLoading ||
- Boolean(versions.error);
- const previewVersions = versions.items.slice(0, 3);
- const versionCountLabel =
- knownVersionCount > 0
- ? `${knownVersionCount}${versions.hasMore ? "+" : ""}`
- : "Check";
-
- const cardBorder =
- expanded || defaultSelected ? "border-accent-primary" : "border-border";
-
- return (
-
-
-
-
- {config.name}
-
-
- {config.updated_at
- ? formatRelativeTime(config.updated_at)
- : "Saved behavior"}
-
- {config.description && (
-
- {config.description}
-
- )}
- {latestModel?.model && (
-
- {latestModel.provider
- ? `${latestModel.provider} · ${latestModel.model}`
- : latestModel.model}
-
- )}
-
- {defaultSelected && (
-
- Added
-
- )}
-
-
-
- void onToggleVersionSelection(config, latestVersion)}
- disabled={versionsNotYetLoaded || Boolean(defaultLoading)}
- className={`min-w-[126px]! rounded-full! px-3.5! py-2! text-[12px]! ${
- defaultLoading ? "cursor-progress" : ""
- } ${defaultSelected ? "bg-bg-secondary!" : ""}`}
- >
- {defaultLoading
- ? "Working..."
- : defaultSelected
- ? "Added"
- : "Use this behavior"}
-
-
- {hasVersionsPanel && (
- onToggleExpansion(config.id)}
- aria-label={
- expanded ? "Hide saved versions" : "View saved versions"
- }
- className={`min-w-[146px]! rounded-full! px-3.5! py-2! text-[12px]! ${
- expanded
- ? "border-accent-primary! bg-bg-secondary!"
- : "bg-bg-primary!"
- }`}
- >
-
- {expanded ? "Hide versions" : "Show versions"}
-
-
- {versionCountLabel}
-
-
-
- )}
-
-
- {hasVersionsPanel && (
-
- )}
-
- {hasVersionsPanel && (
-
- )}
-
- );
-}
diff --git a/app/components/assessment/prompt-config/SavedConfigs.tsx b/app/components/assessment/prompt-config/SavedConfigs.tsx
deleted file mode 100644
index e15456c1..00000000
--- a/app/components/assessment/prompt-config/SavedConfigs.tsx
+++ /dev/null
@@ -1,156 +0,0 @@
-import { useEffect, useRef, useState } from "react";
-import { Button, Loader } from "@/app/components/ui";
-import type { ConfigPublic } from "@/app/lib/types/configs";
-import type {
- LatestConfigModel,
- ValueSetter,
- VersionListState,
-} from "@/app/lib/types/assessment";
-import { buildInitialAssessmentVersionState } from "@/app/lib/utils/assessmentFetcher";
-import { CONFIGS_VISIBLE_BATCH_SIZE } from "@/app/lib/assessment/constants";
-import SavedConfigCard from "./SavedConfigCard";
-
-interface SavedConfigsProps {
- configCards: ConfigPublic[];
- searchQuery: string;
- setSearchQuery: ValueSetter;
- isLoadingConfigs: boolean;
- hasMoreConfigs: boolean;
- nextConfigSkip: number;
- expandedConfigId: string | null;
- versionStateByConfig: Record;
- latestModelByConfig: Record;
- loadingSelectionKeys: Record;
- isSelected: (configId: string, version: number) => boolean;
- onLoadMoreConfigs: (skip: number) => void | Promise;
- onLoadVersions: (configId: string, skip: number) => void;
- onToggleConfigExpansion: ValueSetter;
- onToggleVersionSelection: (
- config: ConfigPublic,
- version: number,
- ) => void | Promise;
-}
-
-export default function SavedConfigs({
- configCards,
- searchQuery,
- setSearchQuery,
- isLoadingConfigs,
- hasMoreConfigs,
- nextConfigSkip,
- expandedConfigId,
- versionStateByConfig,
- latestModelByConfig,
- loadingSelectionKeys,
- isSelected,
- onLoadMoreConfigs,
- onLoadVersions,
- onToggleConfigExpansion,
- onToggleVersionSelection,
-}: SavedConfigsProps) {
- const listRef = useRef(null);
- const [visibleConfigCount, setVisibleConfigCount] = useState(
- CONFIGS_VISIBLE_BATCH_SIZE,
- );
- const [isLoadingMore, setIsLoadingMore] = useState(false);
- const visibleConfigCards = configCards.slice(0, visibleConfigCount);
- const hasHiddenLoadedConfigs = visibleConfigCount < configCards.length;
- const canViewMoreConfigs = hasHiddenLoadedConfigs || hasMoreConfigs;
-
- useEffect(() => {
- setVisibleConfigCount(CONFIGS_VISIBLE_BATCH_SIZE);
- }, [searchQuery]);
-
- const scrollToListEnd = () => {
- requestAnimationFrame(() => {
- const listElement = listRef.current;
- if (!listElement) return;
- listElement.scrollTo({
- top: listElement.scrollHeight,
- behavior: "smooth",
- });
- });
- };
-
- const handleViewMore = async () => {
- if (isLoadingMore) return;
- if (hasHiddenLoadedConfigs) {
- setVisibleConfigCount((prev) =>
- Math.min(prev + CONFIGS_VISIBLE_BATCH_SIZE, configCards.length),
- );
- scrollToListEnd();
- return;
- }
- if (!hasMoreConfigs) return;
-
- setIsLoadingMore(true);
- try {
- await onLoadMoreConfigs(nextConfigSkip);
- setVisibleConfigCount((prev) => prev + CONFIGS_VISIBLE_BATCH_SIZE);
- scrollToListEnd();
- } finally {
- setIsLoadingMore(false);
- }
- };
-
- return (
-
-
- setSearchQuery(e.target.value)}
- placeholder="Search behaviors..."
- className="w-full rounded-xl border border-border bg-bg-secondary px-4 py-2.5 text-sm text-text-primary outline-none"
- />
-
-
- {isLoadingConfigs ? (
-
-
-
- ) : configCards.length === 0 ? (
-
- {searchQuery
- ? "No behaviors match your search."
- : "No saved behaviors found."}
-
- ) : (
-
- {visibleConfigCards.map((config) => (
-
- ))}
- {canViewMoreConfigs && (
- void handleViewMore()}
- disabled={isLoadingMore}
- >
- {isLoadingMore ? "Loading..." : "View more"}
-
- )}
-
- )}
-
- );
-}
diff --git a/app/components/assessment/prompt-config/SelectedConfigs.tsx b/app/components/assessment/prompt-config/SelectedConfigs.tsx
deleted file mode 100644
index 0cb3f4c5..00000000
--- a/app/components/assessment/prompt-config/SelectedConfigs.tsx
+++ /dev/null
@@ -1,53 +0,0 @@
-import { CloseIcon } from "@/app/components/icons";
-import type { ConfigSelection } from "@/app/lib/types/assessment";
-
-interface SelectedConfigsProps {
- configs: ConfigSelection[];
- onRemove: (configId: string, version: number) => void;
-}
-
-export default function SelectedConfigs({
- configs,
- onRemove,
-}: SelectedConfigsProps) {
- if (configs.length === 0) return null;
-
- return (
-
- {configs.map((config) => (
-
-
- {config.name}
-
-
- v{config.config_version}{" "}
- {config.provider && config.model
- ? `· ${config.provider}/${config.model}`
- : ""}
-
- onRemove(config.config_id, config.config_version)}
- className="inline-flex h-[22px] w-[22px] cursor-pointer items-center justify-center rounded-full text-text-secondary transition-colors hover:bg-bg-secondary hover:text-text-primary"
- aria-label={`Remove ${config.name}`}
- >
-
-
-
- ))}
-
- );
-}
diff --git a/app/components/assessment/prompt-config/SetupProgress.tsx b/app/components/assessment/prompt-config/SetupProgress.tsx
deleted file mode 100644
index 078fa7e5..00000000
--- a/app/components/assessment/prompt-config/SetupProgress.tsx
+++ /dev/null
@@ -1,37 +0,0 @@
-import { MAX_CONFIGS } from "@/app/lib/assessment/constants";
-
-interface StatusPillProps {
- label: string;
- value: string;
-}
-
-interface SetupProgressProps {
- promptStatus: string;
- selectedConfigCount: number;
- responseSummary: string;
-}
-
-function StatusPill({ label, value }: StatusPillProps) {
- return (
-
- {label}: {value}
-
- );
-}
-
-export default function SetupProgress({
- promptStatus,
- selectedConfigCount,
- responseSummary,
-}: SetupProgressProps) {
- return (
-
-
-
-
-
- );
-}
diff --git a/app/components/assessment/prompt-config/SystemPrompt.tsx b/app/components/assessment/prompt-config/SystemPrompt.tsx
deleted file mode 100644
index 596cbc2c..00000000
--- a/app/components/assessment/prompt-config/SystemPrompt.tsx
+++ /dev/null
@@ -1,63 +0,0 @@
-"use client";
-
-import { useState } from "react";
-import { ChevronDownIcon } from "@/app/components/icons";
-import { InfoTooltip } from "@/app/components/ui";
-import type { ValueSetter } from "@/app/lib/types/assessment";
-import PromptEditor from "./PromptEditor";
-
-interface SystemPromptProps {
- value: string;
- onChange: ValueSetter;
- previewMode: boolean;
-}
-
-export default function SystemPrompt({
- value,
- onChange,
- previewMode,
-}: SystemPromptProps) {
- const [isOpen, setIsOpen] = useState(true);
-
- return (
-
-
- setIsOpen((prev) => !prev)}
- className="flex min-w-0 flex-1 cursor-pointer items-center gap-2 rounded-md text-left"
- >
-
-
- System prompt
-
-
-
- Set the evaluation rules. Example: judge every answer fairly,
- follow the scoring format, and explain the result briefly.
-
- }
- />
-
-
- {isOpen && (
-
- )}
-
- );
-}
diff --git a/app/components/assessment/prompt-config/UserPrompt.tsx b/app/components/assessment/prompt-config/UserPrompt.tsx
deleted file mode 100644
index 1c5101ae..00000000
--- a/app/components/assessment/prompt-config/UserPrompt.tsx
+++ /dev/null
@@ -1,74 +0,0 @@
-"use client";
-
-import { useState } from "react";
-import { Button, InfoTooltip } from "@/app/components/ui";
-import { ChevronDownIcon } from "@/app/components/icons";
-import type { SampleRow, ValueSetter } from "@/app/lib/types/assessment";
-import PromptEditor from "./PromptEditor";
-
-interface UserPromptProps {
- textColumns: string[];
- sampleRow: SampleRow;
- promptTemplate: string;
- setPromptTemplate: ValueSetter;
- previewMode: boolean;
-}
-
-export default function UserPrompt({
- textColumns,
- sampleRow,
- promptTemplate,
- setPromptTemplate,
- previewMode,
-}: UserPromptProps) {
- const [isOpen, setIsOpen] = useState(true);
-
- return (
-
-
- setIsOpen((prev) => !prev)}
- className="!min-w-0 !flex-1 !justify-start !rounded-md !p-0 hover:bg-transparent focus-visible:bg-transparent"
- >
-
-
- User prompt
-
-
- Optional
-
-
-
- Tell the AI what to check in each row. Example: Read{" "}
- {"{question}"}
- and {"{answer}"}, then say if the answer is good and why.
-
- }
- />
-
-
- {isOpen && (
-
- )}
-
- );
-}
diff --git a/app/components/assessment/prompt-config/index.ts b/app/components/assessment/prompt-config/index.ts
deleted file mode 100644
index 76370177..00000000
--- a/app/components/assessment/prompt-config/index.ts
+++ /dev/null
@@ -1,14 +0,0 @@
-// Barrel for prompt-config components.
-// Import from "@/app/components/assessment/prompt-config".
-export { default as AssessmentConfiguration } from "./AssessmentConfiguration";
-export { default as ConfigCreator } from "./ConfigCreator";
-export { default as ConfigParamControl } from "./ConfigParamControl";
-export { default as PromptEditor } from "./PromptEditor";
-export { default as PromptPanel } from "./PromptPanel";
-export { default as ResponseSchema } from "./ResponseSchema";
-export { default as SavedConfigCard } from "./SavedConfigCard";
-export { default as SavedConfigs } from "./SavedConfigs";
-export { default as SelectedConfigs } from "./SelectedConfigs";
-export { default as SetupProgress } from "./SetupProgress";
-export { default as SystemPrompt } from "./SystemPrompt";
-export { default as UserPrompt } from "./UserPrompt";
diff --git a/app/components/assessment/results/ResultRowModal.tsx b/app/components/assessment/results/ResultRowModal.tsx
new file mode 100644
index 00000000..5cb1f820
--- /dev/null
+++ b/app/components/assessment/results/ResultRowModal.tsx
@@ -0,0 +1,94 @@
+"use client";
+
+import { Modal } from "@/app/components/ui";
+import { readResultRow, resultRowTitle } from "@/app/lib/assessment/resultRow";
+
+interface ResultRowModalProps {
+ row: Record | null;
+ onClose: () => void;
+}
+
+const eyebrow =
+ "mb-2 text-[11px] font-semibold tracking-wider uppercase text-text-secondary";
+
+/** One submission's full assessment: scores with reasons, then the long text. */
+export default function ResultRowModal({ row, onClose }: ResultRowModalProps) {
+ if (!row) return null;
+
+ const detail = readResultRow(row);
+ const subtitle = detail.meta
+ .slice(0, 4)
+ .map((field) => field.value)
+ .join(" · ");
+
+ return (
+
+
+ {subtitle &&
{subtitle}
}
+
+ {detail.scores.length > 0 && (
+
+ Scores
+
+ {detail.scores.map((score) => (
+
+
+ {score.metric}
+
+
+ {score.score}
+
+ / 10
+
+
+ {score.reason && (
+
+ {score.reason}
+
+ )}
+
+ ))}
+
+
+ )}
+
+ {detail.longText.map((field) => (
+
+ {field.label}
+
+ {field.value}
+
+
+ ))}
+
+ {detail.documents.length > 0 && (
+
+ Attachments
+
+ {detail.documents.map((href) => (
+
+
+ {href}
+
+
+ ))}
+
+
+ )}
+
+
+ );
+}
diff --git a/app/components/assessment/results/ResultsHeader.tsx b/app/components/assessment/results/ResultsHeader.tsx
deleted file mode 100644
index 16990fa2..00000000
--- a/app/components/assessment/results/ResultsHeader.tsx
+++ /dev/null
@@ -1,73 +0,0 @@
-import { RefreshIcon } from "@/app/components/icons";
-import { Select } from "@/app/components/ui";
-import {
- RESULT_SUMMARY_ITEMS,
- SUMMARY_BADGE_CLASSES,
- STATUS_FILTER_OPTIONS,
-} from "@/app/lib/assessment/constants";
-import type {
- ResultsCounts,
- StatusFilter,
- ValueSetter,
-} from "@/app/lib/types/assessment";
-
-interface ResultsHeaderProps {
- counts: ResultsCounts;
- statusFilter: StatusFilter;
- isLoading: boolean;
- onStatusFilterChange: ValueSetter;
- onRefresh: () => void;
-}
-
-export default function ResultsHeader({
- counts,
- statusFilter,
- isLoading,
- onStatusFilterChange,
- onRefresh,
-}: ResultsHeaderProps) {
- return (
-
-
-
- Assessments
-
-
- {RESULT_SUMMARY_ITEMS.map((item) => (
-
- {counts[item.key]}
-
- {item.label.toLowerCase()}
-
-
- ))}
-
-
-
-
- onStatusFilterChange(e.target.value as StatusFilter)}
- options={STATUS_FILTER_OPTIONS}
- className="w-full cursor-pointer rounded-md border border-border bg-bg-primary px-3 py-1.5 text-sm text-text-primary outline-none focus:ring-1"
- />
-
-
-
-
-
-
- );
-}
diff --git a/app/components/assessment/results/ResultsTable.tsx b/app/components/assessment/results/ResultsTable.tsx
new file mode 100644
index 00000000..cae2bfed
--- /dev/null
+++ b/app/components/assessment/results/ResultsTable.tsx
@@ -0,0 +1,78 @@
+"use client";
+
+interface ResultsTableProps {
+ headers: string[];
+ rows: string[][];
+ onRowClick: (index: number) => void;
+}
+
+const NARROW_COLUMNS = ["cid", "language", "state", "district", "id"];
+
+const isNumeric = (value: string) =>
+ value.trim() !== "" && Number.isFinite(Number(value));
+
+/** Spreadsheet-style read-only grid; a row opens its full assessment. */
+export default function ResultsTable({
+ headers,
+ rows,
+ onRowClick,
+}: ResultsTableProps) {
+ return (
+
+
+
+
+
+ #
+
+ {headers.map((header) => (
+
+ {header}
+
+ ))}
+
+
+
+ {rows.map((row, rowIndex) => (
+ onRowClick(rowIndex)}
+ className="cursor-pointer hover:bg-accent-subtle/10"
+ >
+
+ {rowIndex + 1}
+
+ {row.map((cell, cellIndex) => {
+ const header = headers[cellIndex]?.toLowerCase() ?? "";
+ const narrow = NARROW_COLUMNS.includes(header);
+ return (
+
+
+ {cell}
+
+
+ );
+ })}
+
+ ))}
+
+
+
+ );
+}
diff --git a/app/components/assessment/results/ResultsToolbar.tsx b/app/components/assessment/results/ResultsToolbar.tsx
new file mode 100644
index 00000000..69d459c4
--- /dev/null
+++ b/app/components/assessment/results/ResultsToolbar.tsx
@@ -0,0 +1,69 @@
+"use client";
+
+import { ArrowLeftIcon, DownloadIcon } from "@/app/components/icons";
+import { Button, RadioGroup } from "@/app/components/ui";
+
+export type ResultsViewMode = "table" | "sheet";
+
+interface ResultsToolbarProps {
+ title: string;
+ subtitle: string;
+ view: ResultsViewMode;
+ onViewChange: (view: ResultsViewMode) => void;
+ onBack: () => void;
+ onDownload: () => void;
+}
+
+const VIEW_OPTIONS: { value: ResultsViewMode; label: string }[] = [
+ { value: "table", label: "Table" },
+ { value: "sheet", label: "Spreadsheet" },
+];
+
+export default function ResultsToolbar({
+ title,
+ subtitle,
+ view,
+ onViewChange,
+ onBack,
+ onDownload,
+}: ResultsToolbarProps) {
+ return (
+
+
+
+ {title}
+
+
{subtitle}
+
+
+
+
+
+
+ Back to runs
+
+
+
+ Download CSV
+
+
+
+ );
+}
diff --git a/app/components/assessment/review/ColumnsReview.tsx b/app/components/assessment/review/ColumnsReview.tsx
deleted file mode 100644
index 6fb49db6..00000000
--- a/app/components/assessment/review/ColumnsReview.tsx
+++ /dev/null
@@ -1,52 +0,0 @@
-"use client";
-
-import type { ReviewColumn } from "@/app/lib/types/assessment";
-import ReviewSection from "./ReviewSection";
-
-interface ColumnsReviewProps {
- mappedColumns: ReviewColumn[];
- mappedCount: number;
- isOpen: boolean;
- onToggle: () => void;
- onEdit: () => void;
-}
-
-export default function ColumnsReview({
- mappedColumns,
- mappedCount,
- isOpen,
- onToggle,
- onEdit,
-}: ColumnsReviewProps) {
- return (
-
-
- {mappedColumns.length === 0 ? (
-
- No columns mapped.
-
- ) : (
-
- {mappedColumns.map((item) => (
-
-
- {item.role}
-
- -
- {item.column}
-
- ))}
-
- )}
-
-
- );
-}
diff --git a/app/components/assessment/review/ConfigsReview.tsx b/app/components/assessment/review/ConfigsReview.tsx
deleted file mode 100644
index cea161e6..00000000
--- a/app/components/assessment/review/ConfigsReview.tsx
+++ /dev/null
@@ -1,52 +0,0 @@
-"use client";
-
-import type { ConfigSelection } from "@/app/lib/types/assessment";
-import ReviewSection from "./ReviewSection";
-
-interface ConfigsReviewProps {
- configs: ConfigSelection[];
- isOpen: boolean;
- onToggle: () => void;
- onEdit: () => void;
-}
-
-export default function ConfigsReview({
- configs,
- isOpen,
- onToggle,
- onEdit,
-}: ConfigsReviewProps) {
- return (
-
-
- {configs.map((config, index) => (
-
-
- {index + 1}
-
-
-
- {config.name}
-
-
- v{config.config_version}
-
-
-
- {config.provider}/{config.model}
-
-
- ))}
-
-
- );
-}
diff --git a/app/components/assessment/review/DatasetReview.tsx b/app/components/assessment/review/DatasetReview.tsx
deleted file mode 100644
index e1154eaf..00000000
--- a/app/components/assessment/review/DatasetReview.tsx
+++ /dev/null
@@ -1,28 +0,0 @@
-"use client";
-
-import ReviewSection from "./ReviewSection";
-
-interface DatasetReviewProps {
- datasetName: string;
- isOpen: boolean;
- onToggle: () => void;
-}
-
-export default function DatasetReview({
- datasetName,
- isOpen,
- onToggle,
-}: DatasetReviewProps) {
- return (
-
-
-
- Dataset Name
-
-
- {datasetName || "Unknown dataset"}
-
-
-
- );
-}
diff --git a/app/components/assessment/review/ExperimentReview.tsx b/app/components/assessment/review/ExperimentReview.tsx
deleted file mode 100644
index 8ed347fb..00000000
--- a/app/components/assessment/review/ExperimentReview.tsx
+++ /dev/null
@@ -1,29 +0,0 @@
-"use client";
-
-import { Field } from "@/app/components/ui";
-import type { ValueSetter } from "@/app/lib/types/assessment";
-
-interface ExperimentReviewProps {
- experimentName: string;
- setExperimentName: ValueSetter;
-}
-
-export default function ExperimentReview({
- experimentName,
- setExperimentName,
-}: ExperimentReviewProps) {
- return (
-
-
-
- A descriptive name to identify this evaluation run.
-
-
- );
-}
diff --git a/app/components/assessment/review/InputReview.tsx b/app/components/assessment/review/InputReview.tsx
deleted file mode 100644
index 202575f8..00000000
--- a/app/components/assessment/review/InputReview.tsx
+++ /dev/null
@@ -1,165 +0,0 @@
-"use client";
-
-import { useMemo, useState } from "react";
-import { Modal } from "@/app/components/ui";
-import { ChevronRightIcon } from "@/app/components/icons";
-import {
- DEFAULT_SYSTEM_PROMPT,
- DEFAULT_USER_PROMPT,
-} from "@/app/lib/assessment/constants";
-import ReviewSection from "./ReviewSection";
-
-interface InputReviewProps {
- systemInstruction: string;
- promptTemplate: string;
- isOpen: boolean;
- onToggle: () => void;
-}
-
-interface PromptNodeProps {
- title: string;
- value: string;
- fallback: string;
- isOpen: boolean;
- onToggle: () => void;
-}
-
-function PromptNode({
- title,
- value,
- fallback,
- isOpen,
- onToggle,
-}: PromptNodeProps) {
- return (
-
-
-
-
-
-
{title}
-
- {isOpen && (
-
- {value.trim() || fallback}
-
- )}
-
- );
-}
-
-export default function InputReview({
- systemInstruction,
- promptTemplate,
- isOpen,
- onToggle,
-}: InputReviewProps) {
- const [isModalOpen, setIsModalOpen] = useState(false);
- const [isSystemNodeOpen, setIsSystemNodeOpen] = useState(true);
- const [isUserNodeOpen, setIsUserNodeOpen] = useState(true);
-
- const llmInputGist = useMemo(
- () => ({
- system: systemInstruction.trim() || DEFAULT_SYSTEM_PROMPT,
- user: promptTemplate.trim() || DEFAULT_USER_PROMPT,
- }),
- [promptTemplate, systemInstruction],
- );
-
- return (
- <>
- {
- event.stopPropagation();
- setIsModalOpen(true);
- }}
- className="flex h-7 w-7 cursor-pointer items-center justify-center rounded-md bg-accent-secondary text-text-primary transition-colors hover:bg-accent-secondary-hover hover:text-text-primary"
- aria-label="Expand LLM input"
- title="Expand LLM input"
- >
- ⤢
-
- }
- badge={
- systemInstruction.trim() || promptTemplate.trim()
- ? "Configured"
- : "Default"
- }
- >
-
-
-
- System Prompt
-
-
- {llmInputGist.system}
-
-
-
-
User Prompt
-
- {llmInputGist.user}
-
-
-
-
-
- setIsModalOpen(false)}
- title="LLM Input"
- maxWidth="max-w-3xl"
- >
-
-
-
setIsSystemNodeOpen((prev) => !prev)}
- />
- setIsUserNodeOpen((prev) => !prev)}
- />
-
-
-
- >
- );
-}
diff --git a/app/components/assessment/review/PostProcessingReview.tsx b/app/components/assessment/review/PostProcessingReview.tsx
deleted file mode 100644
index f6d3f814..00000000
--- a/app/components/assessment/review/PostProcessingReview.tsx
+++ /dev/null
@@ -1,104 +0,0 @@
-"use client";
-
-import { POST_PROCESSING_FILTER_OPS } from "@/app/lib/assessment/constants";
-import type { PostProcessingConfig } from "@/app/lib/types/assessment";
-import ReviewSection from "./ReviewSection";
-
-interface PostProcessingReviewProps {
- postProcessingConfig: PostProcessingConfig | null;
- isOpen: boolean;
- onToggle: () => void;
- onEdit: () => void;
-}
-
-const OP_LABELS: Record = Object.fromEntries(
- POST_PROCESSING_FILTER_OPS.map((op) => [op.value, op.label]),
-);
-
-export default function PostProcessingReview({
- postProcessingConfig,
- isOpen,
- onToggle,
- onEdit,
-}: PostProcessingReviewProps) {
- const computed = postProcessingConfig?.computed_columns ?? [];
- const filters = postProcessingConfig?.filter ?? [];
- const sorts = postProcessingConfig?.sort ?? [];
- const ruleCount = computed.length + filters.length + sorts.length;
- const isSkipped = ruleCount === 0;
-
- return (
- 1 ? "s" : ""}`
- }
- >
-
- {isSkipped ? (
-
- Skipped — no post-processing configured.
-
- ) : (
- <>
- {computed.length > 0 && (
-
-
- Computed columns
-
-
- {computed.map((col, i) => (
-
-
- {col.name || "(unnamed)"}
-
- =
- {col.formula || "(empty)"}
-
- ))}
-
-
- )}
- {filters.length > 0 && (
-
-
- Filters
-
-
- {filters.map((rule, i) => (
-
- {rule.column} {" "}
- {OP_LABELS[rule.op] ?? rule.op}
- {rule.value !== undefined && rule.value !== ""
- ? ` ${rule.value}`
- : ""}
-
- ))}
-
-
- )}
- {sorts.length > 0 && (
-
-
- Sort
-
-
- {sorts.map((rule, i) => (
-
- {i + 1}.{" "}
- {rule.column} {" "}
- {rule.direction === "asc" ? "↑ Asc" : "↓ Desc"}
-
- ))}
-
-
- )}
- >
- )}
-
-
- );
-}
diff --git a/app/components/assessment/review/PrefilterReview.tsx b/app/components/assessment/review/PrefilterReview.tsx
deleted file mode 100644
index 542d4e0b..00000000
--- a/app/components/assessment/review/PrefilterReview.tsx
+++ /dev/null
@@ -1,116 +0,0 @@
-"use client";
-
-import type { PrefilterConfig } from "@/app/lib/types/assessment";
-import ReviewSection from "./ReviewSection";
-
-interface PrefilterReviewProps {
- prefilterConfig: PrefilterConfig | null;
- isOpen: boolean;
- onToggle: () => void;
- onEdit: () => void;
-}
-
-function ColumnChips({ columns }: { columns: string[] }) {
- return (
-
- {columns.map((col) => (
-
- {col}
-
- ))}
-
- );
-}
-
-export default function PrefilterReview({
- prefilterConfig,
- isOpen,
- onToggle,
- onEdit,
-}: PrefilterReviewProps) {
- const topicRelevance = prefilterConfig?.topic_relevance;
- const duplicateDetection = prefilterConfig?.duplicate_detection;
- const enabledCount = (topicRelevance ? 1 : 0) + (duplicateDetection ? 1 : 0);
- const isSkipped = enabledCount === 0;
-
- return (
- 1 ? "s" : ""}`
- }
- >
-
- {isSkipped ? (
-
- Skipped — no pre-filters enabled.
-
- ) : (
- <>
- {topicRelevance && (
-
-
- Topic Relevance
-
-
- {topicRelevance.attachment_columns &&
- topicRelevance.attachment_columns.length > 0 && (
-
- )}
-
-
- Prompt
-
-
- {topicRelevance.prompt}
-
-
-
- )}
- {duplicateDetection && (
-
-
- Duplicate Detection
-
-
-
- )}
- >
- )}
-
-
- );
-}
diff --git a/app/components/assessment/review/ReviewSection.tsx b/app/components/assessment/review/ReviewSection.tsx
deleted file mode 100644
index a21ca96f..00000000
--- a/app/components/assessment/review/ReviewSection.tsx
+++ /dev/null
@@ -1,84 +0,0 @@
-"use client";
-
-import { Button } from "@/app/components/ui";
-import { ChevronRightIcon } from "@/app/components/icons";
-import type { ReactNode } from "react";
-
-interface ReviewSectionProps {
- title: string;
- isOpen: boolean;
- onToggle: () => void;
- onEdit?: () => void;
- headerAction?: ReactNode;
- badge?: string;
- children: ReactNode;
-}
-
-export default function ReviewSection({
- title,
- isOpen,
- onToggle,
- onEdit,
- headerAction,
- badge,
- children,
-}: ReviewSectionProps) {
- return (
-
-
{
- if (event.key === "Enter" || event.key === " ") {
- event.preventDefault();
- onToggle();
- }
- }}
- >
-
-
-
- {title}
-
- {badge && (
-
- {badge}
-
- )}
-
-
- {headerAction}
- {onEdit && (
- {
- event.stopPropagation();
- onEdit();
- }}
- >
- Edit
-
- )}
-
-
-
- {isOpen && (
-
{children}
- )}
-
- );
-}
diff --git a/app/components/assessment/review/SchemaReview.tsx b/app/components/assessment/review/SchemaReview.tsx
deleted file mode 100644
index 9be4a785..00000000
--- a/app/components/assessment/review/SchemaReview.tsx
+++ /dev/null
@@ -1,52 +0,0 @@
-"use client";
-
-import type { SchemaProperty } from "@/app/lib/types/assessment";
-import ReviewSection from "./ReviewSection";
-
-interface SchemaReviewProps {
- outputSchema: SchemaProperty[];
- isOpen: boolean;
- onToggle: () => void;
- onEdit: () => void;
-}
-
-export default function SchemaReview({
- outputSchema,
- isOpen,
- onToggle,
- onEdit,
-}: SchemaReviewProps) {
- return (
- 0 ? `${outputSchema.length} fields` : "Free text"
- }
- >
-
- {outputSchema.length > 0 ? (
-
- {outputSchema.map((property) => (
-
- {property.name || "(unnamed)"}
-
- {property.isArray ? `${property.type}[]` : property.type}
-
-
- ))}
-
- ) : (
-
- No response format defined — model will return free-form text.
-
- )}
-
-
- );
-}
diff --git a/app/components/assessment/review/SubmitReview.tsx b/app/components/assessment/review/SubmitReview.tsx
deleted file mode 100644
index defde80f..00000000
--- a/app/components/assessment/review/SubmitReview.tsx
+++ /dev/null
@@ -1,64 +0,0 @@
-"use client";
-
-import { Button } from "@/app/components/ui";
-import { ChevronLeftIcon, PlayIcon } from "@/app/components/icons";
-
-interface SubmitReviewProps {
- isSubmitting: boolean;
- canSubmit: boolean;
- submitBlockerMessage: string;
- onSubmit: () => void;
- onBack: () => void;
-}
-
-export default function SubmitReview({
- isSubmitting,
- canSubmit,
- submitBlockerMessage,
- onSubmit,
- onBack,
-}: SubmitReviewProps) {
- return (
-
-
-
-
- Back
-
-
- {!isSubmitting && !canSubmit && (
-
- {submitBlockerMessage}
-
- )}
-
- {isSubmitting ? (
- <>
-
- Submitting...
- >
- ) : (
- <>
-
- Submit Evaluation
- >
- )}
-
-
-
-
- );
-}
diff --git a/app/components/assessment/wizard/AssessmentStep.tsx b/app/components/assessment/wizard/AssessmentStep.tsx
new file mode 100644
index 00000000..581820d9
--- /dev/null
+++ b/app/components/assessment/wizard/AssessmentStep.tsx
@@ -0,0 +1,89 @@
+"use client";
+
+import EditorStepLayout from "@/app/components/assessment/editor/EditorStepLayout";
+import PromptZoneCard from "@/app/components/assessment/editor/PromptZoneCard";
+import OutputSchemaEditorInner from "@/app/components/assessment/output-schema/OutputSchemaEditorInner";
+import {
+ ASSESSMENT_ZONE_COPY,
+ ASSESSMENT_ZONE_MIN_HEIGHT,
+} from "@/app/lib/assessment/promptCopy";
+import type {
+ PromptFieldType,
+ PromptZoneId,
+ PromptZones,
+ SchemaProperty,
+} from "@/app/lib/types/assessment";
+
+interface AssessmentStepProps {
+ zones: PromptZones;
+ columns: string[];
+ fieldTypes: Record;
+ fieldStrict: Record;
+ sampleRow: Record;
+ outputSchema: SchemaProperty[];
+ onZoneChange: (zone: PromptZoneId, value: string) => void;
+ onFieldType: (name: string, type: PromptFieldType) => void;
+ onFieldStrict: (name: string, strict: boolean) => void;
+ onOutputSchema: (schema: SchemaProperty[]) => void;
+}
+
+/** Wizard step 3: the assessor's prompt and the schema its answer must fill. */
+export default function AssessmentStep({
+ zones,
+ columns,
+ fieldTypes,
+ fieldStrict,
+ sampleRow,
+ outputSchema,
+ onZoneChange,
+ onFieldType,
+ onFieldStrict,
+ onOutputSchema,
+}: AssessmentStepProps) {
+ return (
+
+
+
Assessment
+
+ Write the assessor like a document: who the AI is, what it assesses,
+ what it returns.
+
+
+
+
+
+
+
+
+ Output schema
+
+
+ Enforced as structured output — every field becomes a results
+ column.
+
+
+
+
+
+
+
+ );
+}
diff --git a/app/components/assessment/wizard/PrefilterStep.tsx b/app/components/assessment/wizard/PrefilterStep.tsx
new file mode 100644
index 00000000..884aee20
--- /dev/null
+++ b/app/components/assessment/wizard/PrefilterStep.tsx
@@ -0,0 +1,104 @@
+"use client";
+
+import CompactToggleSwitch from "@/app/components/assessment/CompactToggleSwitch";
+import EditorStepLayout from "@/app/components/assessment/editor/EditorStepLayout";
+import PromptZoneCard from "@/app/components/assessment/editor/PromptZoneCard";
+import {
+ PREFILTER_ZONE_COPY,
+ PREFILTER_ZONE_MIN_HEIGHT,
+} from "@/app/lib/assessment/promptCopy";
+import type {
+ PromptFieldType,
+ PromptZoneId,
+ PromptZones,
+} from "@/app/lib/types/assessment";
+
+interface PrefilterStepProps {
+ enabled: boolean;
+ zones: PromptZones;
+ columns: string[];
+ fieldTypes: Record;
+ fieldStrict: Record;
+ sampleRow: Record;
+ onToggle: (enabled: boolean) => void;
+ onZoneChange: (zone: PromptZoneId, value: string) => void;
+ onFieldType: (name: string, type: PromptFieldType) => void;
+ onFieldStrict: (name: string, strict: boolean) => void;
+}
+
+/** Wizard step 2: the optional relevance gate. Rejected rows never get assessed. */
+export default function PrefilterStep({
+ enabled,
+ zones,
+ columns,
+ fieldTypes,
+ fieldStrict,
+ sampleRow,
+ onToggle,
+ onZoneChange,
+ onFieldType,
+ onFieldStrict,
+}: PrefilterStepProps) {
+ return (
+
+
+
+
+ Pre-filter
+
+
+ Optional relevance check — rejected submissions never reach the
+ assessment.
+
+
+
+
+ {enabled ? "On" : "Off"}
+
+ onToggle(!enabled)}
+ title={enabled ? "Turn pre-filter off" : "Turn pre-filter on"}
+ />
+
+
+
+ {enabled && (
+ <>
+
+
+
+ Output is fixed:
+
+ ✓ Accepted
+
+
+ ✗ Rejected
+
+ with a reason.
+
+ >
+ )}
+
+ );
+}
diff --git a/app/components/assessment/wizard/ReviewSaveModal.tsx b/app/components/assessment/wizard/ReviewSaveModal.tsx
new file mode 100644
index 00000000..4f2863b0
--- /dev/null
+++ b/app/components/assessment/wizard/ReviewSaveModal.tsx
@@ -0,0 +1,181 @@
+"use client";
+
+import { useState } from "react";
+import { Button, Field, Modal } from "@/app/components/ui";
+import { DocumentFileIcon, ImageIcon } from "@/app/components/icons";
+import {
+ fieldTypeOf,
+ TOKEN_RE,
+ tokenName,
+} from "@/app/lib/assessment/promptTokens";
+import { schemaToJsonSchema } from "@/app/lib/utils/assessment";
+import type { PromptFieldType, WizardDraft } from "@/app/lib/types/assessment";
+
+interface ReviewSaveModalProps {
+ open: boolean;
+ draft: WizardDraft;
+ sampleRow: Record;
+ defaultName: string;
+ isSaving: boolean;
+ onClose: () => void;
+ onSave: (name: string, commitMessage: string) => void;
+}
+
+const PREVIEW_LIMIT = 420;
+const VALUE_LIMIT = 90;
+
+const eyebrow =
+ "mb-2 text-[11px] font-semibold tracking-wider uppercase text-text-secondary";
+const blockHeading =
+ "bg-bg-secondary px-3 py-1.5 text-[10px] font-semibold tracking-wider uppercase text-text-secondary";
+const blockBody =
+ "max-h-48 overflow-auto px-3 py-2 font-mono text-xs leading-6 whitespace-pre-wrap";
+
+/** Last stop before a version is written: what changed, and what to call it. */
+export default function ReviewSaveModal({
+ open,
+ draft,
+ sampleRow,
+ defaultName,
+ isSaving,
+ onClose,
+ onSave,
+}: ReviewSaveModalProps) {
+ const [name, setName] = useState(defaultName);
+ const [commitMessage, setCommitMessage] = useState("");
+
+ const referenced = referencedFields(draft);
+ const schemaJson = schemaToJsonSchema(draft.outputSchema);
+
+ return (
+
+
+
+ Your submission set needs these columns
+ {referenced.length === 0 ? (
+
+ No columns referenced yet.
+
+ ) : (
+
+ {referenced.map(({ name: column, type, strict }) => (
+
+ {type === "text" ? (
+ "@"
+ ) : type === "image" ? (
+
+ ) : (
+
+ )}
+ {column}
+
+ {type}
+ {strict && " · required"}
+
+
+ ))}
+
+ )}
+
+
+
+ Assembled prompt
+
+
+ Instructions (same for every submission)
+
+
+ {truncate(draft.assessment.instructions, PREVIEW_LIMIT) ||
+ "(empty)"}
+
+
+ Submission (preview with the first row)
+
+
+ {fillTokens(draft.assessment.submission, sampleRow) || "(empty)"}
+
+
+ Output schema (enforced as structured output)
+
+
+ {schemaJson
+ ? JSON.stringify(schemaJson, null, 2)
+ : "No output fields defined yet."}
+
+
+
+
+
+
+
+
+
+ Cancel
+
+ onSave(name.trim(), commitMessage.trim())}
+ >
+ {isSaving ? "Saving..." : "Save assessor"}
+
+
+
+ );
+}
+
+function referencedFields(
+ draft: WizardDraft,
+): { name: string; type: PromptFieldType; strict: boolean }[] {
+ const names = new Set();
+ [draft.prefilter, draft.assessment].forEach((zones) => {
+ [zones.instructions, zones.submission].forEach((text) => {
+ for (const match of text.matchAll(TOKEN_RE)) {
+ names.add(tokenName(match[0]));
+ }
+ });
+ });
+
+ return [...names].map((name) => ({
+ name,
+ type: fieldTypeOf(draft.fieldTypes, name),
+ strict: draft.fieldStrict[name] ?? false,
+ }));
+}
+
+function truncate(value: string, limit: number): string {
+ return value.length > limit ? `${value.slice(0, limit)} …` : value;
+}
+
+function fillTokens(text: string, sampleRow: Record): string {
+ return text.replace(TOKEN_RE, (raw) => {
+ const value = sampleRow[tokenName(raw)];
+ if (!value) return raw;
+ return value.length > VALUE_LIMIT
+ ? `${value.slice(0, VALUE_LIMIT)}…`
+ : value;
+ });
+}
diff --git a/app/components/assessment/wizard/RunStep.tsx b/app/components/assessment/wizard/RunStep.tsx
new file mode 100644
index 00000000..ea5125d7
--- /dev/null
+++ b/app/components/assessment/wizard/RunStep.tsx
@@ -0,0 +1,130 @@
+"use client";
+
+import { useState } from "react";
+import { Button, Field, Select } from "@/app/components/ui";
+import { CloudUploadIcon } from "@/app/components/icons";
+import CreatePanel from "@/app/components/assessment/datasets/CreatePanel";
+import type { UseSubmissionStepResult } from "@/app/hooks/useSubmissionStep";
+import type { UseAssessmentWizardResult } from "@/app/lib/types/assessment";
+
+interface RunStepProps {
+ wizard: UseAssessmentWizardResult;
+ step: UseSubmissionStepResult;
+}
+
+/** Wizard step 4: confirm the submission set and assessor version, then run. */
+export default function RunStep({ wizard, step }: RunStepProps) {
+ const [showUpload, setShowUpload] = useState(false);
+ const linkedId = wizard.versionDetail?.submission_id;
+
+ const options = step.submissions.map((submission) => {
+ const isDefault = submission.submission_id === linkedId;
+ return {
+ value: submission.submission_id,
+ label: `${submission.name} (${submission.total_items} items)${
+ isDefault ? " — default" : ""
+ }`,
+ };
+ });
+
+ return (
+
+
+
+
+ Run assessment
+
+
+ Confirm the submission set and assessor version, then run.
+
+
+
+
+
+ Submission
+
+
+ Which submissions should this run assess?
+
+
+ void step.handleSelect(event.target.value)}
+ />
+ setShowUpload((current) => !current)}
+ >
+
+ Upload new
+
+
+
+ Defaults to the set linked to this assessor version.
+
+
+ {showUpload && (
+
+ {
+ await step.handleCreate();
+ setShowUpload(false);
+ }}
+ />
+
+ )}
+
+
+
+ Assessor
+
+ Every row runs through the pre-filter, then this assessor version.
+
+ {wizard.context ? (
+
+
+ {wizard.context.assessorName}
+
+
+ v{wizard.context.version}
+
+ {wizard.versionDetail?.model && (
+
+ {wizard.versionDetail.model}
+
+ )}
+
+ ) : (
+
+ This assessor hasn’t been saved yet, so there is no version to
+ run. Review & save lands in the next slice — until then, start
+ a run from Home by selecting an existing assessor version.
+
+ )}
+
+
+
+
+
+ );
+}
diff --git a/app/components/assessment/wizard/SavedNextModal.tsx b/app/components/assessment/wizard/SavedNextModal.tsx
new file mode 100644
index 00000000..ff272880
--- /dev/null
+++ b/app/components/assessment/wizard/SavedNextModal.tsx
@@ -0,0 +1,48 @@
+"use client";
+
+import { Button, Modal } from "@/app/components/ui";
+import { CheckIcon } from "@/app/components/icons";
+
+interface SavedNextModalProps {
+ open: boolean;
+ title: string;
+ onRun: () => void;
+ onHome: () => void;
+}
+
+/** After a save: run this version now, or go back to Home. */
+export default function SavedNextModal({
+ open,
+ title,
+ onRun,
+ onHome,
+}: SavedNextModalProps) {
+ return (
+
+
+
+
+
+
{title}
+
+ Run an assessment on this version now, or go back to the assessment
+ home.
+
+
+
+
+ Go to home
+
+
+ Run assessment
+
+
+
+ );
+}
diff --git a/app/components/assessment/wizard/SubmissionStep.tsx b/app/components/assessment/wizard/SubmissionStep.tsx
new file mode 100644
index 00000000..bb7c36f9
--- /dev/null
+++ b/app/components/assessment/wizard/SubmissionStep.tsx
@@ -0,0 +1,97 @@
+"use client";
+
+import { Button, Modal } from "@/app/components/ui";
+import { WarningIcon } from "@/app/components/icons";
+import DataViewModal from "@/app/components/assessment/DataViewModal";
+import CreatePanel from "@/app/components/assessment/datasets/CreatePanel";
+import SubmissionList from "@/app/components/assessment/datasets/SubmissionList";
+import type { UseSubmissionStepResult } from "@/app/hooks/useSubmissionStep";
+
+interface SubmissionStepProps {
+ step: UseSubmissionStepResult;
+}
+
+/** Wizard step 1: pick the submission set for this assessor, or upload a new one. */
+export default function SubmissionStep({ step }: SubmissionStepProps) {
+ const { confirmDeleteId, pendingDelete } = step;
+
+ return (
+
+
+
+
+
+ {step.viewModalData && (
+
step.setViewModalData(null)}
+ />
+ )}
+
+ {confirmDeleteId !== null && (
+ step.setConfirmDeleteId(null)}
+ maxWidth="max-w-md"
+ maxHeight="max-h-[90vh]"
+ showClose={false}
+ >
+
+
+
+
+
+
+
+ Delete submission
+
+
+ Are you sure you want to delete{" "}
+
+ {pendingDelete?.name}
+
+ ? This action cannot be undone.
+
+
+
+
+
+ step.setConfirmDeleteId(null)}
+ >
+ Cancel
+
+ {
+ void step.handleDelete(confirmDeleteId);
+ step.setConfirmDeleteId(null);
+ }}
+ >
+ {step.deletingId === confirmDeleteId ? "Deleting..." : "Delete"}
+
+
+
+ )}
+
+ );
+}
diff --git a/app/components/assessment/wizard/WizardFooter.tsx b/app/components/assessment/wizard/WizardFooter.tsx
new file mode 100644
index 00000000..bd31f598
--- /dev/null
+++ b/app/components/assessment/wizard/WizardFooter.tsx
@@ -0,0 +1,49 @@
+"use client";
+
+import { Button } from "@/app/components/ui";
+
+interface WizardFooterProps {
+ showBack: boolean;
+ hint: string;
+ nextLabel: string;
+ nextDisabled: boolean;
+ isBusy: boolean;
+ onBack: () => void;
+ onNext: () => void;
+}
+
+/** The wizard's single pinned footer: Back · status · primary action. */
+export default function WizardFooter({
+ showBack,
+ hint,
+ nextLabel,
+ nextDisabled,
+ isBusy,
+ onBack,
+ onNext,
+}: WizardFooterProps) {
+ return (
+
+
+ ‹ Back
+
+
+
+ {hint}
+
+ {isBusy ? "Submitting..." : nextLabel}
+
+
+
+ );
+}
diff --git a/app/components/assessment/wizard/WizardStepBody.tsx b/app/components/assessment/wizard/WizardStepBody.tsx
new file mode 100644
index 00000000..8203e9e8
--- /dev/null
+++ b/app/components/assessment/wizard/WizardStepBody.tsx
@@ -0,0 +1,63 @@
+"use client";
+
+import SubmissionStep from "./SubmissionStep";
+import PrefilterStep from "./PrefilterStep";
+import AssessmentStep from "./AssessmentStep";
+import RunStep from "./RunStep";
+import type { UseSubmissionStepResult } from "@/app/hooks/useSubmissionStep";
+import type { UseAssessmentWizardResult } from "@/app/lib/types/assessment";
+
+interface WizardStepBodyProps {
+ wizard: UseAssessmentWizardResult;
+ submission: UseSubmissionStepResult;
+ columns: string[];
+ sampleRow: Record;
+}
+
+/** Renders whichever step is open. Kept apart so WizardView stays a shell. */
+export default function WizardStepBody({
+ wizard,
+ submission,
+ columns,
+ sampleRow,
+}: WizardStepBodyProps) {
+ const { draft } = wizard;
+
+ if (wizard.step === 1) return ;
+
+ if (wizard.step === 2) {
+ return (
+ draft.setZone("prefilter", zone, value)}
+ onFieldType={draft.setFieldType}
+ onFieldStrict={draft.setFieldStrict}
+ />
+ );
+ }
+
+ if (wizard.step === 3) {
+ return (
+ draft.setZone("assessment", zone, value)}
+ onFieldType={draft.setFieldType}
+ onFieldStrict={draft.setFieldStrict}
+ onOutputSchema={draft.setOutputSchema}
+ />
+ );
+ }
+
+ return ;
+}
diff --git a/app/components/assessment/wizard/WizardView.tsx b/app/components/assessment/wizard/WizardView.tsx
new file mode 100644
index 00000000..065e3a1c
--- /dev/null
+++ b/app/components/assessment/wizard/WizardView.tsx
@@ -0,0 +1,120 @@
+"use client";
+
+import Stepper from "@/app/components/assessment/Stepper";
+import ModelChip from "@/app/components/assessment/model/ModelChip";
+import { ASSESSMENT_WIZARD_STEPS } from "@/app/lib/assessment/constants";
+import {
+ isWizardStepAllowed,
+ promptStepFor,
+} from "@/app/lib/assessment/wizard";
+import { useSubmissionStep } from "@/app/hooks/useSubmissionStep";
+import { useAssessmentDatasetStore } from "@/app/lib/store/assessment";
+import ReviewSaveModal from "./ReviewSaveModal";
+import SavedNextModal from "./SavedNextModal";
+import WizardFooter from "./WizardFooter";
+import WizardStepBody from "./WizardStepBody";
+import { wizardFooterState, wizardContextLabel } from "./wizardCopy";
+import type { UseAssessmentWizardResult } from "@/app/lib/types/assessment";
+
+interface WizardViewProps {
+ wizard: UseAssessmentWizardResult;
+ onHome: () => void;
+}
+
+/** Step 4 runs; step 3 saves — except an untouched run flow, which moves on. */
+function runPrimaryAction(wizard: UseAssessmentWizardResult): void {
+ if (wizard.step === 4) {
+ void wizard.submitRun();
+ return;
+ }
+ if (wizard.step === 3 && (wizard.flow !== "run" || wizard.isDirty)) {
+ wizard.openReview();
+ return;
+ }
+ wizard.next();
+}
+
+export default function WizardView({ wizard, onHome }: WizardViewProps) {
+ const submission = useSubmissionStep();
+ const columns = useAssessmentDatasetStore((state) => state.columns);
+ const sampleRow = useAssessmentDatasetStore((state) => state.sampleRow);
+ const draft = wizard.draft;
+ const promptStep = promptStepFor(wizard.step);
+ const selectedSubmission = submission.submissions.find(
+ (item) => item.submission_id === wizard.submissionId,
+ );
+ const footer = wizardFooterState(
+ wizard,
+ Boolean(wizard.submissionId),
+ selectedSubmission?.total_items ?? null,
+ );
+
+ return (
+
+
+ isWizardStepAllowed({
+ flow: wizard.flow,
+ step: candidate,
+ currentStep: wizard.step,
+ completedSteps: wizard.completedSteps,
+ })
+ }
+ trailing={
+ <>
+
+ {wizardContextLabel(wizard)}
+
+ {promptStep && (
+
+ )}
+ >
+ }
+ />
+
+
+
+ runPrimaryAction(wizard)}
+ />
+
+
+
+ wizard.dismissSaved("run")}
+ onHome={() => wizard.dismissSaved("home")}
+ />
+
+ );
+}
diff --git a/app/components/assessment/wizard/wizardCopy.ts b/app/components/assessment/wizard/wizardCopy.ts
new file mode 100644
index 00000000..2ceeb76a
--- /dev/null
+++ b/app/components/assessment/wizard/wizardCopy.ts
@@ -0,0 +1,121 @@
+/** Footer labels and the strip's context line — pure, so the view stays markup. */
+import { wizardEntryStep } from "@/app/lib/assessment/wizard";
+import type {
+ AssessmentWizardStep,
+ UseAssessmentWizardResult,
+} from "@/app/lib/types/assessment";
+
+interface FooterState {
+ showBack: boolean;
+ hint: string;
+ nextLabel: string;
+ nextDisabled: boolean;
+}
+
+const NEXT_LABELS: Record = {
+ 1: "Next: Pre-filter",
+ 2: "Next: Assessment",
+ 3: "Review & save",
+ 4: "Run assessment",
+};
+
+const STEP_HINTS: Record = {
+ 1: "",
+ 2: "Pre-filter is optional — toggle it off to skip",
+ 3: "",
+ 4: "",
+};
+
+export function wizardFooterState(
+ wizard: UseAssessmentWizardResult,
+ hasSubmission: boolean,
+ rowCount: number | null,
+): FooterState {
+ const { step, flow, submissionName } = wizard;
+ // The entry step has no Back — the round home button is the way out.
+ const showBack = step !== wizardEntryStep(flow);
+
+ if (step === 1) {
+ return {
+ showBack,
+ hint: hasSubmission
+ ? `Selected: ${submissionName}`
+ : "Select or upload a submission set",
+ nextLabel: NEXT_LABELS[1],
+ nextDisabled: !hasSubmission,
+ };
+ }
+
+ if (step === 3) {
+ return { showBack, ...assessmentStepFooter(wizard) };
+ }
+
+ if (step !== 4) {
+ return {
+ showBack,
+ hint: STEP_HINTS[step],
+ nextLabel: NEXT_LABELS[step],
+ nextDisabled: false,
+ };
+ }
+
+ return {
+ showBack,
+ hint: runStepHint(Boolean(wizard.context), hasSubmission, rowCount),
+ nextLabel: NEXT_LABELS[4],
+ nextDisabled: !hasSubmission || !wizard.context,
+ };
+}
+
+/**
+ * Step 3 is the save gate. A brand-new assessor needs content; an existing one
+ * needs a real diff. In the run flow an untouched draft just moves on, and any
+ * edit turns the action into a version bump.
+ */
+function assessmentStepFooter(
+ wizard: UseAssessmentWizardResult,
+): Omit {
+ const { flow, canSave, isDirty } = wizard;
+
+ if (flow === "run") {
+ return isDirty
+ ? {
+ hint: "Saving bumps a new version — the run uses it",
+ nextLabel: "Review & save",
+ nextDisabled: false,
+ }
+ : {
+ hint: "Unchanged — runs the selected version",
+ nextLabel: "Next: Run assessment",
+ nextDisabled: false,
+ };
+ }
+
+ const hint = canSave
+ ? "Saving creates a new assessor version"
+ : flow === "edit"
+ ? "No changes yet — edit something to save a new version"
+ : "Write the assessment prompt to enable Review & save";
+
+ return { hint, nextLabel: NEXT_LABELS[3], nextDisabled: !canSave };
+}
+
+function runStepHint(
+ hasVersion: boolean,
+ hasSubmission: boolean,
+ rowCount: number | null,
+): string {
+ if (!hasVersion) return "Save the assessor first to run it";
+ if (!hasSubmission) return "Select a submission set";
+ if (rowCount === null) return "Every row of the set will be queued";
+ return `${rowCount} row${rowCount === 1 ? "" : "s"} will be queued`;
+}
+
+export function wizardContextLabel(wizard: UseAssessmentWizardResult): string {
+ const { flow, context } = wizard;
+ if (flow === "new") return "New assessor";
+ if (!context) return "";
+ return flow === "edit"
+ ? `Editing ${context.assessorName} · from v${context.version}`
+ : `${context.assessorName} v${context.version}`;
+}
diff --git a/app/components/datasets/UploadDatasetModal.tsx b/app/components/datasets/UploadDatasetModal.tsx
index 7c0be20e..923f5804 100644
--- a/app/components/datasets/UploadDatasetModal.tsx
+++ b/app/components/datasets/UploadDatasetModal.tsx
@@ -48,7 +48,7 @@ export default function UploadDatasetModal({
-
+
-
+
+
+
+ );
+}
diff --git a/app/components/icons/common/ImageIcon.tsx b/app/components/icons/common/ImageIcon.tsx
new file mode 100644
index 00000000..3c9f4aaa
--- /dev/null
+++ b/app/components/icons/common/ImageIcon.tsx
@@ -0,0 +1,25 @@
+interface IconProps {
+ className?: string;
+ style?: React.CSSProperties;
+}
+
+export default function ImageIcon({ className, style }: IconProps) {
+ return (
+
+
+
+
+
+ );
+}
diff --git a/app/components/icons/common/LockClosedIcon.tsx b/app/components/icons/common/LockClosedIcon.tsx
new file mode 100644
index 00000000..d823ec8d
--- /dev/null
+++ b/app/components/icons/common/LockClosedIcon.tsx
@@ -0,0 +1,20 @@
+interface IconProps {
+ className?: string;
+ style?: React.CSSProperties;
+}
+
+export default function LockClosedIcon({ className, style }: IconProps) {
+ return (
+
+
+
+
+ );
+}
diff --git a/app/components/icons/document/CloudUploadIcon.tsx b/app/components/icons/document/CloudUploadIcon.tsx
index 01917fa5..5240812d 100644
--- a/app/components/icons/document/CloudUploadIcon.tsx
+++ b/app/components/icons/document/CloudUploadIcon.tsx
@@ -5,7 +5,7 @@ interface IconProps {
export default function CloudUploadIcon({ className }: IconProps) {
return (
void;
+ onNext: () => void;
+ label?: string;
+ className?: string;
+}
+
+const buttonBase =
+ "min-w-7 h-7 inline-flex items-center justify-center rounded-lg border text-xs font-medium transition-colors cursor-pointer disabled:cursor-default disabled:opacity-35";
+const idleStyles =
+ "border-border bg-bg-primary text-text-secondary hover:border-accent-muted hover:text-text-primary";
+
+/** Prev/next only — the API reports `has_more`, never a total page count. */
+export default function CursorPager({
+ hasPrev,
+ hasNext,
+ onPrev,
+ onNext,
+ label = "Pagination",
+ className = "",
+}: CursorPagerProps) {
+ if (!hasPrev && !hasNext) return null;
+
+ return (
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/app/components/ui/Pager.tsx b/app/components/ui/Pager.tsx
new file mode 100644
index 00000000..4367c2d6
--- /dev/null
+++ b/app/components/ui/Pager.tsx
@@ -0,0 +1,72 @@
+"use client";
+
+import { ChevronLeftIcon, ChevronRightIcon } from "@/app/components/icons";
+import { PAGER_WINDOW_SIZE } from "@/app/lib/assessment/constants";
+import { pageWindow } from "@/app/lib/utils/assessment";
+
+interface PagerProps {
+ page: number;
+ pages: number;
+ onGoto: (page: number) => void;
+ label?: string;
+ className?: string;
+}
+
+const buttonBase =
+ "min-w-7 h-7 px-2 inline-flex items-center justify-center rounded-lg border text-xs font-medium transition-colors cursor-pointer disabled:cursor-default disabled:opacity-35";
+const idleStyles =
+ "border-border bg-bg-primary text-text-secondary hover:border-accent-muted hover:text-text-primary";
+const currentStyles =
+ "border-accent-primary bg-accent-primary text-white font-semibold";
+
+export default function Pager({
+ page,
+ pages,
+ onGoto,
+ label = "Pagination",
+ className = "",
+}: PagerProps) {
+ if (pages <= 1) return null;
+
+ return (
+
+ onGoto(page - 1)}
+ className={`${buttonBase} ${idleStyles} px-0`}
+ >
+
+
+
+ {pageWindow(page, pages, PAGER_WINDOW_SIZE).map((candidate) => (
+ onGoto(candidate)}
+ className={`${buttonBase} ${
+ candidate === page ? currentStyles : idleStyles
+ }`}
+ >
+ {candidate}
+
+ ))}
+
+ = pages}
+ onClick={() => onGoto(page + 1)}
+ className={`${buttonBase} ${idleStyles} px-0`}
+ >
+
+
+
+ );
+}
diff --git a/app/components/ui/Select.tsx b/app/components/ui/Select.tsx
index 4d7643da..4cecfe23 100644
--- a/app/components/ui/Select.tsx
+++ b/app/components/ui/Select.tsx
@@ -11,6 +11,7 @@ interface SelectProps extends SelectHTMLAttributes {
export default function Select({
options,
placeholder,
+ className = "",
...props
}: SelectProps) {
const isGrouped = options.some((o) => o.group !== undefined);
@@ -31,7 +32,7 @@ export default function Select({
return (
{placeholder && {placeholder} }
diff --git a/app/components/ui/SplitPane.tsx b/app/components/ui/SplitPane.tsx
new file mode 100644
index 00000000..f389168a
--- /dev/null
+++ b/app/components/ui/SplitPane.tsx
@@ -0,0 +1,95 @@
+"use client";
+
+import { ReactNode, useCallback, useRef, useState } from "react";
+
+interface SplitPaneProps {
+ left: ReactNode;
+ right: ReactNode;
+ /** Left pane width as a percentage; clamped to MIN..MAX. */
+ defaultLeftPercent?: number;
+ label?: string;
+}
+
+const MIN_PERCENT = 28;
+const MAX_PERCENT = 72;
+const KEY_STEP = 4;
+
+const clamp = (value: number) =>
+ Math.min(MAX_PERCENT, Math.max(MIN_PERCENT, value));
+
+/**
+ * Two panes with a draggable divider. Stacks vertically below `lg`, where a
+ * horizontal split has no room. Keyboard-resizable via the divider's arrow keys.
+ */
+export default function SplitPane({
+ left,
+ right,
+ defaultLeftPercent = 52,
+ label = "Resize panes",
+}: SplitPaneProps) {
+ const containerRef = useRef(null);
+ const [leftPercent, setLeftPercent] = useState(clamp(defaultLeftPercent));
+ const [isDragging, setIsDragging] = useState(false);
+
+ const resizeTo = useCallback((clientX: number) => {
+ const bounds = containerRef.current?.getBoundingClientRect();
+ if (!bounds || bounds.width === 0) return;
+ setLeftPercent(clamp(((clientX - bounds.left) / bounds.width) * 100));
+ }, []);
+
+ const onPointerDown = useCallback(
+ (event: React.PointerEvent) => {
+ event.currentTarget.setPointerCapture(event.pointerId);
+ setIsDragging(true);
+ },
+ [],
+ );
+
+ const onPointerMove = useCallback(
+ (event: React.PointerEvent) => {
+ if (isDragging) resizeTo(event.clientX);
+ },
+ [isDragging, resizeTo],
+ );
+
+ const onKeyDown = useCallback((event: React.KeyboardEvent) => {
+ if (event.key === "ArrowLeft") {
+ setLeftPercent((current) => clamp(current - KEY_STEP));
+ } else if (event.key === "ArrowRight") {
+ setLeftPercent((current) => clamp(current + KEY_STEP));
+ }
+ }, []);
+
+ return (
+
+
+ {left}
+
+
+
setIsDragging(false)}
+ onKeyDown={onKeyDown}
+ className={`hidden w-[5px] shrink-0 cursor-col-resize transition-colors focus-visible:bg-accent-primary lg:block ${
+ isDragging ? "bg-accent-primary" : "bg-border hover:bg-accent-muted"
+ }`}
+ />
+
+
+ {right}
+
+
+ );
+}
diff --git a/app/components/ui/index.ts b/app/components/ui/index.ts
index 7f5c4c4c..5eeff6a6 100644
--- a/app/components/ui/index.ts
+++ b/app/components/ui/index.ts
@@ -2,8 +2,11 @@ export { default as Button } from "./Button";
export { default as Checkbox } from "./Checkbox";
export { default as Field } from "./Field";
export { default as Select } from "./Select";
+export { default as SplitPane } from "./SplitPane";
export { default as MultiSelect } from "./MultiSelect";
export { default as Modal } from "./Modal";
+export { default as CursorPager } from "./CursorPager";
+export { default as Pager } from "./Pager";
export { default as RadioGroup } from "./RadioGroup";
export { default as Loader, LoaderBox } from "./Loader";
export { ToggleOnIcon, ToggleOffIcon } from "./ToggleThumbIcons";
diff --git a/app/hooks/index.ts b/app/hooks/index.ts
index c8c473c9..e5eebfef 100644
--- a/app/hooks/index.ts
+++ b/app/hooks/index.ts
@@ -1,3 +1,6 @@
+export { useAssessmentData } from "./useAssessmentData";
+export { useAssessmentHome } from "./useAssessmentHome";
+export { useAssessmentHomeData } from "./useAssessmentHomeData";
export { useConfigs } from "./useConfigs";
export { useDebouncedValue } from "./useDebouncedValue";
export { useOnboardingActivation } from "./useOnboardingActivation";
diff --git a/app/hooks/useAssessmentData.ts b/app/hooks/useAssessmentData.ts
new file mode 100644
index 00000000..0d02e119
--- /dev/null
+++ b/app/hooks/useAssessmentData.ts
@@ -0,0 +1,17 @@
+"use client";
+
+/**
+ * Single access point for assessment data: the API-backed source bound to the
+ * active API key.
+ */
+import { useMemo } from "react";
+import { useAuth } from "@/app/lib/context/AuthContext";
+import { createApiAssessmentSource } from "@/app/lib/assessment/apiSource";
+import type { AssessmentDataSource } from "@/app/lib/types/assessment";
+
+export function useAssessmentData(): AssessmentDataSource {
+ const { activeKey } = useAuth();
+ const apiKey = activeKey?.key ?? "";
+
+ return useMemo(() => createApiAssessmentSource(apiKey), [apiKey]);
+}
diff --git a/app/hooks/useAssessmentDatasetsTab.ts b/app/hooks/useAssessmentDatasetsTab.ts
deleted file mode 100644
index 319f3281..00000000
--- a/app/hooks/useAssessmentDatasetsTab.ts
+++ /dev/null
@@ -1,361 +0,0 @@
-"use client";
-
-import { useCallback, useEffect, useRef, useState } from "react";
-import { apiFetch } from "@/app/lib/apiClient";
-import type { Dataset } from "@/app/lib/types/dataset";
-import { useToast } from "@/app/hooks/useToast";
-import { useAuth } from "@/app/lib/context/AuthContext";
-import {
- extractCreatedDataset,
- fetchDatasetPreview,
- handleForbiddenError,
- isAllowedDatasetFile,
- isBlankCell,
-} from "@/app/lib/utils/assessment";
-import { PREVIEW_ROW_LIMIT } from "@/app/lib/assessment/results";
-import {
- DATASET_SAMPLE_ROW_LIMIT,
- MAX_DATASET_FILE_BYTES,
-} from "@/app/lib/assessment/constants";
-import type {
- CreateDatasetResponse,
- DatasetPreview,
- DatasetResponse,
- DatasetsTabProps,
- DatasetViewModalData,
-} from "@/app/lib/types/assessment";
-
-type UseAssessmentDatasetsTabParams = Pick<
- DatasetsTabProps,
- | "onForbidden"
- | "datasetId"
- | "setDatasetId"
- | "setSelectedDatasetName"
- | "onColumnsLoaded"
->;
-
-export interface UseAssessmentDatasetsTabResult {
- datasets: Dataset[];
- isLoading: boolean;
- isLoadingColumns: boolean;
- viewingId: number | null;
- canProceed: boolean;
- datasetName: string;
- datasetDescription: string;
- uploadedFile: File | null;
- isDragging: boolean;
- isUploading: boolean;
- fileInputRef: React.RefObject
;
- viewModalData: DatasetViewModalData | null;
- confirmDeleteId: number | null;
- deletingId: number | null;
- datasetPendingDelete: Dataset | undefined;
- setDatasetName: (value: string) => void;
- setDatasetDescription: (value: string) => void;
- setUploadedFile: (value: File | null) => void;
- setIsDragging: (value: boolean) => void;
- setConfirmDeleteId: (value: number | null) => void;
- setViewModalData: (value: DatasetViewModalData | null) => void;
- handleFileSelect: (event: React.ChangeEvent) => void;
- resetForm: () => void;
- handleCreateDataset: () => Promise;
- handleDatasetSelect: (id: string, name?: string) => Promise;
- handleViewDataset: (datasetId: number, name: string) => Promise;
- handleDeleteDataset: (id: number) => Promise;
- handleDrop: (event: React.DragEvent) => void;
-}
-
-export function useAssessmentDatasetsTab({
- onForbidden,
- datasetId,
- setDatasetId,
- setSelectedDatasetName,
- onColumnsLoaded,
-}: UseAssessmentDatasetsTabParams): UseAssessmentDatasetsTabResult {
- const toast = useToast();
- const { activeKey, isAuthenticated } = useAuth();
- const apiKey = activeKey?.key ?? "";
- const fileInputRef = useRef(null);
-
- const [datasets, setDatasets] = useState([]);
- const [isLoading, setIsLoading] = useState(false);
- const [isLoadingColumns, setIsLoadingColumns] = useState(false);
- const [datasetName, setDatasetName] = useState("");
- const [datasetDescription, setDatasetDescription] = useState("");
- const [uploadedFile, setUploadedFile] = useState(null);
- const [isUploading, setIsUploading] = useState(false);
- const [isDragging, setIsDragging] = useState(false);
- const [viewingId, setViewingId] = useState(null);
- const [viewModalData, setViewModalData] =
- useState(null);
- const [confirmDeleteId, setConfirmDeleteId] = useState(null);
- const [deletingId, setDeletingId] = useState(null);
- const [previewCache, setPreviewCache] = useState<
- Record
- >({});
-
- const loadDatasets = useCallback(async () => {
- if (!isAuthenticated) return;
- setIsLoading(true);
- try {
- const data = await apiFetch(
- "/api/assessment/datasets",
- apiKey,
- );
- setDatasets(Array.isArray(data) ? data : data.data || []);
- } catch (error) {
- if (handleForbiddenError(error, onForbidden)) return;
- console.error("Failed to load datasets:", error);
- } finally {
- setIsLoading(false);
- }
- }, [apiKey, isAuthenticated, onForbidden]);
-
- useEffect(() => {
- loadDatasets();
- }, [loadDatasets]);
-
- useEffect(() => {
- if (!datasetId || datasets.length === 0) return;
- const selected = datasets.find(
- (dataset) => dataset.dataset_id.toString() === datasetId,
- );
- if (selected?.dataset_name) {
- setSelectedDatasetName(selected.dataset_name);
- }
- }, [datasetId, datasets, setSelectedDatasetName]);
-
- const handleFileSelect = (event: React.ChangeEvent) => {
- const file = event.target.files?.[0];
- if (!file) return;
-
- if (!isAllowedDatasetFile(file.name)) {
- toast.error("Please select a CSV or Excel (.xlsx, .xls) file");
- event.target.value = "";
- return;
- }
-
- if (file.size > MAX_DATASET_FILE_BYTES) {
- toast.error(
- `File too large. Max ${MAX_DATASET_FILE_BYTES / (1024 * 1024)}MB allowed.`,
- );
- event.target.value = "";
- return;
- }
-
- setUploadedFile(file);
- if (!datasetName) {
- setDatasetName(file.name.replace(/\.(csv|xlsx|xls)$/i, ""));
- }
- };
-
- const resetForm = () => {
- setDatasetName("");
- setDatasetDescription("");
- setUploadedFile(null);
- if (fileInputRef.current) fileInputRef.current.value = "";
- };
-
- const handleDatasetSelect = async (id: string, name?: string) => {
- setDatasetId(id);
- if (!id) {
- setSelectedDatasetName("");
- onColumnsLoaded([]);
- return;
- }
-
- const resolvedName =
- name ??
- datasets.find((dataset) => dataset.dataset_id.toString() === id)
- ?.dataset_name ??
- "";
- setSelectedDatasetName(resolvedName);
-
- setIsLoadingColumns(true);
- try {
- const cached = previewCache[id];
- const parsed =
- cached ??
- (await fetchDatasetPreview(id, apiKey, DATASET_SAMPLE_ROW_LIMIT));
- if (!cached) {
- setPreviewCache((prev) => ({ ...prev, [id]: parsed }));
- }
- const keptIdx = parsed.headers
- .map((_, colIdx) => colIdx)
- .filter((colIdx) =>
- parsed.rows.some((row) => !isBlankCell(row[colIdx])),
- );
-
- const filteredHeaders = keptIdx.map((idx) => parsed.headers[idx]);
- const firstRow = parsed.rows[0] || [];
- const sampleRow = Object.fromEntries(
- keptIdx.map((idx) => [
- parsed.headers[idx],
- String(firstRow[idx] ?? ""),
- ]),
- );
- onColumnsLoaded(filteredHeaders, sampleRow);
- } catch (error) {
- if (handleForbiddenError(error, onForbidden)) return;
- const message =
- error instanceof Error
- ? error.message
- : "Failed to fetch dataset columns.";
- onColumnsLoaded([]);
- setDatasetId("");
- setSelectedDatasetName("");
- toast.error(message);
- } finally {
- setIsLoadingColumns(false);
- }
- };
-
- const handleCreateDataset = async () => {
- if (!uploadedFile || !datasetName.trim() || !isAuthenticated) return;
-
- setIsUploading(true);
- try {
- const formData = new FormData();
- formData.append("file", uploadedFile);
- formData.append("dataset_name", datasetName.trim());
- if (datasetDescription.trim()) {
- formData.append("description", datasetDescription.trim());
- }
-
- const data = await apiFetch(
- "/api/assessment/datasets",
- apiKey,
- {
- method: "POST",
- body: formData,
- },
- );
- await loadDatasets();
-
- const created = extractCreatedDataset(data);
- if (created?.dataset_id) {
- void handleDatasetSelect(
- created.dataset_id.toString(),
- created.dataset_name ?? datasetName.trim(),
- );
- }
-
- resetForm();
- toast.success("Dataset created successfully!");
- } catch (error) {
- if (handleForbiddenError(error, onForbidden)) return;
- toast.error(
- `Failed to create dataset: ${error instanceof Error ? error.message : "Unknown error"}`,
- );
- } finally {
- setIsUploading(false);
- }
- };
-
- const handleViewDataset = async (selectedDatasetId: number, name: string) => {
- const cacheKey = String(selectedDatasetId);
- const cached = previewCache[cacheKey];
- if (cached) {
- setViewModalData({ name, headers: cached.headers, rows: cached.rows });
- return;
- }
-
- setViewingId(selectedDatasetId);
- try {
- const parsed = await fetchDatasetPreview(
- selectedDatasetId,
- apiKey,
- PREVIEW_ROW_LIMIT,
- );
- setPreviewCache((prev) => ({ ...prev, [cacheKey]: parsed }));
- setViewModalData({ name, headers: parsed.headers, rows: parsed.rows });
- } catch (error) {
- if (handleForbiddenError(error, onForbidden)) return;
- toast.error(
- error instanceof Error ? error.message : "Failed to view dataset",
- );
- } finally {
- setViewingId(null);
- }
- };
-
- const handleDeleteDataset = async (id: number) => {
- setDeletingId(id);
- try {
- await apiFetch(`/api/assessment/datasets/${id}`, apiKey, {
- method: "DELETE",
- });
- toast.success("Dataset deleted");
- if (datasetId === id.toString()) {
- setDatasetId("");
- setSelectedDatasetName("");
- }
- void loadDatasets();
- } catch (error) {
- if (handleForbiddenError(error, onForbidden)) return;
- toast.error(
- error instanceof Error ? error.message : "Failed to delete dataset",
- );
- } finally {
- setDeletingId(null);
- }
- };
-
- const handleDrop = (event: React.DragEvent) => {
- event.preventDefault();
- setIsDragging(false);
-
- const file = event.dataTransfer.files?.[0];
- if (!file || !isAllowedDatasetFile(file.name)) return;
-
- if (file.size > MAX_DATASET_FILE_BYTES) {
- toast.error(
- `File too large. Max ${MAX_DATASET_FILE_BYTES / (1024 * 1024)}MB allowed.`,
- );
- return;
- }
-
- const dataTransfer = new DataTransfer();
- dataTransfer.items.add(file);
- if (!fileInputRef.current) return;
-
- fileInputRef.current.files = dataTransfer.files;
- fileInputRef.current.dispatchEvent(new Event("change", { bubbles: true }));
- };
-
- const canProceed = Boolean(datasetId) && !isLoadingColumns;
- const datasetPendingDelete = datasets.find(
- (dataset) => dataset.dataset_id === confirmDeleteId,
- );
-
- return {
- datasets,
- isLoading,
- isLoadingColumns,
- viewingId,
- canProceed,
- datasetName,
- datasetDescription,
- uploadedFile,
- isDragging,
- isUploading,
- fileInputRef,
- viewModalData,
- confirmDeleteId,
- deletingId,
- datasetPendingDelete,
- setDatasetName,
- setDatasetDescription,
- setUploadedFile,
- setIsDragging,
- setConfirmDeleteId,
- setViewModalData,
- handleFileSelect,
- resetForm,
- handleCreateDataset,
- handleDatasetSelect,
- handleViewDataset,
- handleDeleteDataset,
- handleDrop,
- };
-}
diff --git a/app/hooks/useAssessmentDownload.ts b/app/hooks/useAssessmentDownload.ts
deleted file mode 100644
index 045e23c6..00000000
--- a/app/hooks/useAssessmentDownload.ts
+++ /dev/null
@@ -1,94 +0,0 @@
-"use client";
-
-import { useCallback, useState } from "react";
-import { getAsyncErrorMessage } from "@/app/lib/assessment/results";
-import type { ExportFormat } from "@/app/lib/types/assessment";
-import type { ToastContextType } from "@/app/lib/types/toast";
-
-interface UseAssessmentDownloadParams {
- apiKey: string;
- isAuthenticated: boolean;
- onForbidden?: () => void;
- toast: ToastContextType;
-}
-
-export default function useAssessmentDownload({
- apiKey,
- isAuthenticated,
- onForbidden,
- toast,
-}: UseAssessmentDownloadParams) {
- const [downloadingId, setDownloadingId] = useState(null);
-
- const buildAuthHeaders = useCallback(() => {
- const headers = new Headers();
- if (apiKey) headers.set("X-API-KEY", apiKey);
- return headers;
- }, [apiKey]);
-
- const triggerDownload = useCallback(
- async (url: string, format: ExportFormat, key: string) => {
- if (!isAuthenticated) return;
- setDownloadingId(key);
- try {
- const response = await fetch(`${url}?export_format=${format}`, {
- headers: buildAuthHeaders(),
- credentials: "include",
- });
- if (response.status === 403) {
- onForbidden?.();
- return;
- }
- if (!response.ok) {
- const err = await response.json().catch(() => ({}));
- throw new Error(
- err.error ||
- err.message ||
- err.detail ||
- `Export failed (${response.status})`,
- );
- }
- const blob = await response.blob();
- const disposition = response.headers.get("content-disposition") || "";
- const filenameMatch = disposition.match(/filename="?([^"]+)"?/);
- const filename = filenameMatch?.[1] || `export.${format}`;
-
- const a = document.createElement("a");
- a.href = URL.createObjectURL(blob);
- a.download = filename;
- document.body.appendChild(a);
- a.click();
- a.remove();
- URL.revokeObjectURL(a.href);
- toast.success("Download started");
- } catch (error) {
- toast.error(getAsyncErrorMessage("Export failed", error));
- } finally {
- setDownloadingId(null);
- }
- },
- [buildAuthHeaders, isAuthenticated, onForbidden, toast],
- );
-
- const handleAssessmentDownload = useCallback(
- (assessmentId: number, format: ExportFormat) =>
- triggerDownload(
- `/api/assessment/assessments/${assessmentId}/results`,
- format,
- `assessment-${assessmentId}`,
- ),
- [triggerDownload],
- );
-
- const handleRunDownload = useCallback(
- (runId: number, format: ExportFormat) =>
- triggerDownload(
- `/api/assessment/runs/${runId}/results`,
- format,
- `run-${runId}`,
- ),
- [triggerDownload],
- );
-
- return { downloadingId, handleAssessmentDownload, handleRunDownload };
-}
diff --git a/app/hooks/useAssessmentFeatureGuard.ts b/app/hooks/useAssessmentFeatureGuard.ts
new file mode 100644
index 00000000..44cd2078
--- /dev/null
+++ b/app/hooks/useAssessmentFeatureGuard.ts
@@ -0,0 +1,40 @@
+"use client";
+
+/**
+ * A 403 from the assessment API means the feature is off for this org/project:
+ * drop the client flag and leave the page. Data hooks call `guard(error)` in their
+ * catch blocks and skip their own toast when it returns true.
+ */
+import { useCallback, useRef } from "react";
+import { useRouter } from "next/navigation";
+import { useToast } from "@/app/hooks/useToast";
+import { handleForbiddenError } from "@/app/lib/utils/assessment";
+import { removeFeatureFromClient } from "@/app/lib/utils/features";
+import { FeatureFlag } from "@/app/lib/constants";
+
+export interface UseAssessmentFeatureGuardResult {
+ guard: (error: unknown) => boolean;
+}
+
+export function useAssessmentFeatureGuard(): UseAssessmentFeatureGuardResult {
+ const router = useRouter();
+ const toast = useToast();
+ const redirectingRef = useRef(false);
+
+ const onForbidden = useCallback(() => {
+ if (redirectingRef.current) return;
+ redirectingRef.current = true;
+ toast.error(
+ "Assessment feature is disabled for this organization/project.",
+ );
+ removeFeatureFromClient(FeatureFlag.ASSESSMENT);
+ if (typeof window !== "undefined") router.replace("/");
+ }, [router, toast]);
+
+ const guard = useCallback(
+ (error: unknown) => handleForbiddenError(error, onForbidden),
+ [onForbidden],
+ );
+
+ return { guard };
+}
diff --git a/app/hooks/useAssessmentHome.ts b/app/hooks/useAssessmentHome.ts
new file mode 100644
index 00000000..0c7241f8
--- /dev/null
+++ b/app/hooks/useAssessmentHome.ts
@@ -0,0 +1,163 @@
+"use client";
+
+/**
+ * View state for Assessment Home. The version selection is the pivot: it enables
+ * Edit, reveals New run, and filters the runs panel. Loading lives in
+ * useAssessmentHomeData.
+ */
+import { useCallback, useMemo, useState } from "react";
+import { useAssessmentHomeData } from "@/app/hooks/useAssessmentHomeData";
+import {
+ buildHomeRunRows,
+ buildRunFilterOptions,
+ matchesRunFilter,
+ versionFilterValue,
+} from "@/app/lib/assessment/home";
+import { RUNS_PER_PAGE } from "@/app/lib/assessment/constants";
+import { paginate } from "@/app/lib/utils/assessment";
+import {
+ RUN_FILTER_ALL,
+ type AssessorSelection,
+ type RunFilterValue,
+ type UseAssessmentHomeResult,
+} from "@/app/lib/types/assessment";
+
+export function useAssessmentHome(
+ initialSelection: AssessorSelection | null = null,
+): UseAssessmentHomeResult {
+ const source = useAssessmentHomeData();
+ const [selection, setSelection] = useState(
+ initialSelection,
+ );
+ const [expandedAssessorIds, setExpandedAssessorIds] = useState>(
+ new Set(),
+ );
+ const [runPage, setRunPage] = useState(1);
+ const [runFilter, setRunFilter] = useState(
+ initialSelection
+ ? versionFilterValue(initialSelection.configId, initialSelection.version)
+ : RUN_FILTER_ALL,
+ );
+
+ const {
+ assessors,
+ knownAssessors,
+ assessments,
+ versionsByAssessor,
+ loadVersions,
+ } = source;
+
+ const applySelection = useCallback((next: AssessorSelection | null) => {
+ setSelection(next);
+ setRunFilter(
+ next ? versionFilterValue(next.configId, next.version) : RUN_FILTER_ALL,
+ );
+ setRunPage(1);
+ }, []);
+
+ // Selecting an assessor means its latest version, which is one fetch away.
+ const selectAssessor = useCallback(
+ (configId: string) => {
+ if (selection?.configId === configId) {
+ applySelection(null);
+ return;
+ }
+ const known = versionsByAssessor[configId];
+ const resolve = known ? Promise.resolve(known) : loadVersions(configId);
+ void resolve.then((versions) => {
+ const latest = versions[0]?.version;
+ if (latest) applySelection({ configId, version: latest });
+ });
+ },
+ [applySelection, loadVersions, selection, versionsByAssessor],
+ );
+
+ // A deleted assessor or version must not stay selected — Edit would 404.
+ const deleteAssessor = useCallback(
+ async (configId: string) => {
+ await source.deleteAssessor(configId);
+ if (selection?.configId === configId) applySelection(null);
+ },
+ [applySelection, selection, source],
+ );
+
+ const deleteAssessorVersion = useCallback(
+ async (configId: string, version: number) => {
+ await source.deleteAssessorVersion(configId, version);
+ if (selection?.configId === configId && selection.version === version) {
+ applySelection(null);
+ }
+ },
+ [applySelection, selection, source],
+ );
+
+ const selectVersion = useCallback(
+ (configId: string, version: number) => {
+ const isSelected =
+ selection?.configId === configId && selection.version === version;
+ applySelection(isSelected ? null : { configId, version });
+ },
+ [applySelection, selection],
+ );
+
+ const toggleAssessorExpanded = useCallback(
+ (configId: string) => {
+ setExpandedAssessorIds((current) => {
+ const next = new Set(current);
+ if (next.has(configId)) next.delete(configId);
+ else next.add(configId);
+ return next;
+ });
+ if (!versionsByAssessor[configId]) loadVersions(configId);
+ },
+ [loadVersions, versionsByAssessor],
+ );
+
+ const changeRunFilter = useCallback((value: RunFilterValue) => {
+ setRunFilter(value);
+ setRunPage(1);
+ }, []);
+
+ // Newest run first; the source list arrives oldest-first from the API.
+ // Named off every assessor seen so far, not just the page on screen.
+ const runSlice = useMemo(() => {
+ const rows = buildHomeRunRows(assessments, knownAssessors)
+ .reverse()
+ .filter((row) => matchesRunFilter(row, runFilter));
+ return paginate(rows, runPage, RUNS_PER_PAGE);
+ }, [assessments, knownAssessors, runFilter, runPage]);
+
+ const runFilterOptions = useMemo(
+ () => buildRunFilterOptions(knownAssessors, assessments),
+ [assessments, knownAssessors],
+ );
+
+ return {
+ isLoading: source.isLoading,
+ error: source.error,
+ refresh: source.refresh,
+ assessors,
+ hasPrevAssessors: source.hasPrevAssessors,
+ hasNextAssessors: source.hasNextAssessors,
+ showPrevAssessors: source.showPrevAssessors,
+ showNextAssessors: source.showNextAssessors,
+ assessorSearch: source.assessorSearch,
+ setAssessorSearch: source.setAssessorSearch,
+ expandedAssessorIds,
+ toggleAssessorExpanded,
+ versionsByAssessor,
+ selection,
+ selectAssessor,
+ selectVersion,
+ deleteAssessor,
+ deleteAssessorVersion,
+ deletingKey: source.deletingKey,
+ runSlice,
+ runFilter,
+ setRunFilter: changeRunFilter,
+ runFilterOptions,
+ gotoRunPage: setRunPage,
+ exportRun: source.exportRun,
+ exportingId: source.exportingId,
+ };
+}
diff --git a/app/hooks/useAssessmentHomeData.ts b/app/hooks/useAssessmentHomeData.ts
new file mode 100644
index 00000000..1304b8d5
--- /dev/null
+++ b/app/hooks/useAssessmentHomeData.ts
@@ -0,0 +1,251 @@
+"use client";
+
+/**
+ * Loading side of Assessment Home: assessors, runs, version lists and retry.
+ * The runs list polls on one slow cadence; assessors load on user action.
+ */
+import { useCallback, useEffect, useRef, useState } from "react";
+import { useToast } from "@/app/hooks/useToast";
+import { useAssessmentData } from "@/app/hooks/useAssessmentData";
+import { useAssessmentFeatureGuard } from "@/app/hooks/useAssessmentFeatureGuard";
+import {
+ ASSESSORS_PER_PAGE,
+ ASSESSOR_SEARCH_DEBOUNCE_MS,
+ RESULTS_POLL_INTERVAL_MS,
+} from "@/app/lib/assessment/constants";
+import {
+ downloadCsv,
+ getAsyncErrorMessage,
+ jsonResultsToTableData,
+} from "@/app/lib/assessment/results";
+import type {
+ AssessmentRun,
+ AssessorSummary,
+ AssessorVersion,
+ ResultsTarget,
+} from "@/app/lib/types/assessment";
+
+export interface UseAssessmentHomeDataResult {
+ /** The current page of assessors. */
+ assessors: AssessorSummary[];
+ /** Every assessor seen so far, so a run row off the current page keeps its name. */
+ knownAssessors: AssessorSummary[];
+ hasPrevAssessors: boolean;
+ hasNextAssessors: boolean;
+ showPrevAssessors: () => void;
+ showNextAssessors: () => void;
+ assessorSearch: string;
+ setAssessorSearch: (value: string) => void;
+ assessments: AssessmentRun[];
+ versionsByAssessor: Record;
+ loadVersions: (configId: string) => Promise;
+ deleteAssessor: (configId: string) => Promise;
+ deleteAssessorVersion: (configId: string, version: number) => Promise;
+ /** Config id, or `:v`, while its delete is in flight. */
+ deletingKey: string | null;
+ isLoading: boolean;
+ error: string | null;
+ refresh: () => Promise;
+ exportRun: (target: ResultsTarget, fileName: string) => Promise;
+ exportingId: string | null;
+}
+
+export function useAssessmentHomeData(): UseAssessmentHomeDataResult {
+ const toast = useToast();
+ const data = useAssessmentData();
+ const { guard } = useAssessmentFeatureGuard();
+
+ const [assessors, setAssessors] = useState([]);
+ const [assessorIndex, setAssessorIndex] = useState<
+ Record
+ >({});
+ const [assessorSkip, setAssessorSkip] = useState(0);
+ const [hasNextAssessors, setHasNextAssessors] = useState(false);
+ const [assessorSearch, setAssessorSearch] = useState("");
+ const [searchTerm, setSearchTerm] = useState("");
+ const [assessments, setAssessments] = useState([]);
+ const [versionsByAssessor, setVersionsByAssessor] = useState<
+ Record
+ >({});
+ const [isLoading, setIsLoading] = useState(true);
+ const [error, setError] = useState(null);
+ const [exportingId, setExportingId] = useState(null);
+ const [deletingKey, setDeletingKey] = useState(null);
+ const isMountedRef = useRef(true);
+
+ const loadAssessments = useCallback(async () => {
+ try {
+ const next = await data.listAssessments();
+ if (!isMountedRef.current) return;
+ setAssessments(next);
+ setError(null);
+ } catch (caught) {
+ if (guard(caught)) return;
+ if (isMountedRef.current) {
+ setError(getAsyncErrorMessage("load assessments", caught));
+ }
+ }
+ }, [data, guard]);
+
+ const loadAssessors = useCallback(async () => {
+ try {
+ const page = await data.listAssessors({
+ skip: assessorSkip,
+ limit: ASSESSORS_PER_PAGE,
+ search: searchTerm,
+ });
+ if (!isMountedRef.current) return;
+ setAssessors(page.items);
+ setHasNextAssessors(page.hasMore);
+ setAssessorIndex((current) => ({
+ ...current,
+ ...Object.fromEntries(page.items.map((item) => [item.id, item])),
+ }));
+ } catch (caught) {
+ if (guard(caught)) return;
+ if (isMountedRef.current) {
+ setError(getAsyncErrorMessage("load assessors", caught));
+ }
+ }
+ }, [assessorSkip, data, guard, searchTerm]);
+
+ // Only the runs list rides the interval; assessors change on user action.
+ const refresh = useCallback(async () => {
+ await Promise.all([loadAssessors(), loadAssessments()]);
+ if (isMountedRef.current) setIsLoading(false);
+ }, [loadAssessments, loadAssessors]);
+
+ const loadVersions = useCallback(
+ async (configId: string) => {
+ try {
+ const versions = await data.listAssessorVersions(configId);
+ if (isMountedRef.current) {
+ setVersionsByAssessor((current) => ({
+ ...current,
+ [configId]: versions,
+ }));
+ }
+ return versions;
+ } catch (caught) {
+ toast.error(getAsyncErrorMessage("load versions", caught));
+ return [];
+ }
+ },
+ [data, toast],
+ );
+
+ const deleteAssessor = useCallback(
+ async (configId: string) => {
+ setDeletingKey(configId);
+ try {
+ await data.deleteAssessor(configId);
+ setVersionsByAssessor(({ [configId]: _dropped, ...rest }) => rest);
+ setAssessorIndex(({ [configId]: _gone, ...rest }) => rest);
+ await loadAssessors();
+ toast.success("Assessor deleted");
+ } catch (caught) {
+ toast.error(getAsyncErrorMessage("delete assessor", caught));
+ } finally {
+ if (isMountedRef.current) setDeletingKey(null);
+ }
+ },
+ [data, loadAssessors, toast],
+ );
+
+ const deleteAssessorVersion = useCallback(
+ async (configId: string, version: number) => {
+ setDeletingKey(`${configId}:v${version}`);
+ try {
+ await data.deleteAssessorVersion(configId, version);
+ await loadVersions(configId);
+ toast.success(`Version v${version} deleted`);
+ } catch (caught) {
+ toast.error(getAsyncErrorMessage("delete version", caught));
+ } finally {
+ if (isMountedRef.current) setDeletingKey(null);
+ }
+ },
+ [data, loadVersions, toast],
+ );
+
+ const exportRun = useCallback(
+ async (target: ResultsTarget, fileName: string) => {
+ setExportingId(target.assessment_id);
+ try {
+ const payload = await data.getRunResults(target);
+ const table = jsonResultsToTableData(payload.rows);
+ downloadCsv(fileName, [table.headers, ...table.rows]);
+ } catch (caught) {
+ toast.error(getAsyncErrorMessage("export results", caught));
+ } finally {
+ if (isMountedRef.current) setExportingId(null);
+ }
+ },
+ [data, toast],
+ );
+
+ const changeSearch = useCallback((value: string) => {
+ setAssessorSearch(value);
+ setAssessorSkip(0);
+ }, []);
+
+ // The search box hits the API, so let typing settle before asking for a page.
+ useEffect(() => {
+ const timer = setTimeout(
+ () => setSearchTerm(assessorSearch),
+ ASSESSOR_SEARCH_DEBOUNCE_MS,
+ );
+ return () => clearTimeout(timer);
+ }, [assessorSearch]);
+
+ // Owns the assessors fetch: mount, and every page or search change after it.
+ useEffect(() => {
+ void loadAssessors();
+ }, [loadAssessors]);
+
+ useEffect(() => {
+ isMountedRef.current = true;
+ void loadAssessments().finally(() => {
+ if (isMountedRef.current) setIsLoading(false);
+ });
+ return () => {
+ isMountedRef.current = false;
+ };
+ }, [loadAssessments]);
+
+ useEffect(() => {
+ const interval = setInterval(
+ () => void loadAssessments(),
+ RESULTS_POLL_INTERVAL_MS,
+ );
+ return () => clearInterval(interval);
+ }, [loadAssessments]);
+
+ return {
+ assessors,
+ knownAssessors: Object.values(assessorIndex),
+ hasPrevAssessors: assessorSkip > 0,
+ hasNextAssessors,
+ showPrevAssessors: useCallback(
+ () => setAssessorSkip((skip) => Math.max(0, skip - ASSESSORS_PER_PAGE)),
+ [],
+ ),
+ showNextAssessors: useCallback(
+ () => setAssessorSkip((skip) => skip + ASSESSORS_PER_PAGE),
+ [],
+ ),
+ assessorSearch,
+ setAssessorSearch: changeSearch,
+ assessments,
+ versionsByAssessor,
+ loadVersions,
+ deleteAssessor,
+ deleteAssessorVersion,
+ deletingKey,
+ isLoading,
+ error,
+ refresh,
+ exportRun,
+ exportingId,
+ };
+}
diff --git a/app/hooks/useAssessmentResults.ts b/app/hooks/useAssessmentResults.ts
deleted file mode 100644
index ec7da3da..00000000
--- a/app/hooks/useAssessmentResults.ts
+++ /dev/null
@@ -1,331 +0,0 @@
-"use client";
-
-// Fetches, polls, and manages assessment run list state including filtering and retry.
-import { useCallback, useEffect, useState } from "react";
-import { apiFetch } from "@/app/lib/apiClient";
-import { useAuth } from "@/app/lib/context/AuthContext";
-import { handleForbiddenError } from "@/app/lib/utils/assessment";
-import {
- filterAssessments,
- getAsyncErrorMessage,
- getResultsCounts,
- normalizeAssessmentRun,
-} from "@/app/lib/assessment/results";
-import { RESULTS_POLL_INTERVAL_MS } from "@/app/lib/assessment/constants";
-import useConfigRunDetails from "@/app/hooks/useConfigRunDetails";
-import useAssessmentDownload from "@/app/hooks/useAssessmentDownload";
-import type {
- AssessmentChildRun,
- AssessmentChildRunListResponse,
- AssessmentListResponse,
- AssessmentRun,
- PostProcessingConfig,
- StatusFilter,
-} from "@/app/lib/types/assessment";
-import type { ToastContextType } from "@/app/lib/types/toast";
-
-interface UseAssessmentResultsParams {
- onForbidden?: () => void;
- toast: ToastContextType;
-}
-
-const POST_PROCESSING_NON_DATA_FIELDS = new Set([
- "assessment_id",
- "dataset_id",
- "dataset_name",
- "run_id",
- "run_name",
- "run_status",
- "config_id",
- "config_version",
- "response_id",
- "input_tokens",
- "output_tokens",
- "total_tokens",
- "updated_at",
- "result_status",
- "error",
- "row_id",
- "experiment_name",
-]);
-
-export default function useAssessmentResults({
- onForbidden,
- toast,
-}: UseAssessmentResultsParams) {
- const { activeKey, isAuthenticated } = useAuth();
- const apiKey = activeKey?.key ?? "";
- const [assessments, setAssessments] = useState([]);
- const [childRunsByAssessment, setChildRunsByAssessment] = useState<
- Record
- >({});
- const [isLoading, setIsLoading] = useState(false);
- const [statusFilter, setStatusFilter] = useState("all");
- const [rerunningId, setRerunningId] = useState(null);
- const [resumingId, setResumingId] = useState(null);
- const [retryingAssessmentId, setRetryingAssessmentId] = useState<
- number | null
- >(null);
- const [expandedId, setExpandedId] = useState(null);
-
- const {
- configDetailsByKey,
- configLoadingKeys,
- configErrorKeys,
- loadConfigDetail,
- } = useConfigRunDetails({ apiKey, isAuthenticated });
-
- const { downloadingId, handleAssessmentDownload, handleRunDownload } =
- useAssessmentDownload({ apiKey, isAuthenticated, onForbidden, toast });
-
- const loadAssessments = useCallback(async () => {
- if (!isAuthenticated) return;
- setIsLoading(true);
- try {
- const data = await apiFetch(
- "/api/assessment/assessments",
- apiKey,
- );
- const list = Array.isArray(data) ? data : data.data || [];
- setAssessments(list.map(normalizeAssessmentRun));
- } catch (e) {
- if (handleForbiddenError(e, onForbidden)) return;
- console.error("Failed to load assessments:", e);
- } finally {
- setIsLoading(false);
- }
- }, [apiKey, isAuthenticated, onForbidden]);
-
- const loadChildRuns = useCallback(
- async (assessmentId: number) => {
- if (!isAuthenticated) return;
- try {
- const data = await apiFetch(
- `/api/assessment/runs?assessment_id=${assessmentId}`,
- apiKey,
- );
- const list = Array.isArray(data) ? data : data.data || [];
- setChildRunsByAssessment((prev) => ({ ...prev, [assessmentId]: list }));
- } catch (e) {
- if (handleForbiddenError(e, onForbidden)) return;
- console.error("Failed to load child runs:", e);
- }
- },
- [apiKey, isAuthenticated, onForbidden],
- );
-
- useEffect(() => {
- loadAssessments();
- }, [loadAssessments]);
-
- useEffect(() => {
- if (!isAuthenticated) return;
- const timer = setInterval(() => {
- void loadAssessments();
- if (expandedId !== null) {
- void loadChildRuns(expandedId);
- }
- }, RESULTS_POLL_INTERVAL_MS);
-
- return () => clearInterval(timer);
- }, [expandedId, isAuthenticated, loadAssessments, loadChildRuns]);
-
- useEffect(() => {
- if (expandedId === null) return;
- const runs = childRunsByAssessment[expandedId] || [];
- runs.forEach((run) => {
- if (run.config_id && run.config_version) {
- void loadConfigDetail(run.config_id, run.config_version);
- }
- });
- }, [childRunsByAssessment, expandedId, loadConfigDetail]);
-
- const handleRerun = useCallback(
- async (run: AssessmentChildRun) => {
- if (!isAuthenticated) {
- toast.error("Please sign in to retry this run");
- return;
- }
-
- setRerunningId(run.id);
- try {
- await apiFetch(`/api/assessment/runs/${run.id}/retry`, apiKey, {
- method: "POST",
- });
-
- toast.success("Run re-submitted successfully!");
- void loadAssessments();
- if (run.assessment_id) {
- void loadChildRuns(run.assessment_id);
- }
- } catch (error) {
- if (handleForbiddenError(error, onForbidden)) return;
- toast.error(getAsyncErrorMessage("Re-run failed", error));
- } finally {
- setRerunningId(null);
- }
- },
- [
- apiKey,
- isAuthenticated,
- loadAssessments,
- loadChildRuns,
- onForbidden,
- toast,
- ],
- );
-
- const handleResume = useCallback(
- async (run: AssessmentChildRun) => {
- if (!isAuthenticated) {
- toast.error("Please sign in to resume this run");
- return;
- }
-
- setResumingId(run.id);
- try {
- await apiFetch(`/api/assessment/runs/${run.id}/resume`, apiKey, {
- method: "POST",
- });
-
- toast.success("Run resumed from failed stage!");
- void loadAssessments();
- if (run.assessment_id) {
- void loadChildRuns(run.assessment_id);
- }
- } catch (error) {
- if (handleForbiddenError(error, onForbidden)) return;
- toast.error(getAsyncErrorMessage("Resume failed", error));
- } finally {
- setResumingId(null);
- }
- },
- [
- apiKey,
- isAuthenticated,
- loadAssessments,
- loadChildRuns,
- onForbidden,
- toast,
- ],
- );
-
- const handleRetryAssessment = useCallback(
- async (assessmentId: number) => {
- if (!isAuthenticated) {
- toast.error("Please sign in to retry this assessment");
- return;
- }
-
- setRetryingAssessmentId(assessmentId);
- try {
- await apiFetch(
- `/api/assessment/assessments/${assessmentId}/retry`,
- apiKey,
- {
- method: "POST",
- },
- );
-
- toast.success("Assessment re-submitted successfully!");
- void loadAssessments();
- if (expandedId !== null) {
- void loadChildRuns(expandedId);
- }
- } catch (error) {
- if (handleForbiddenError(error, onForbidden)) return;
- toast.error(getAsyncErrorMessage("Retry failed", error));
- } finally {
- setRetryingAssessmentId(null);
- }
- },
- [
- apiKey,
- expandedId,
- isAuthenticated,
- loadAssessments,
- loadChildRuns,
- onForbidden,
- toast,
- ],
- );
-
- const handleExpand = useCallback(
- (assessmentId: number) => {
- const next = expandedId === assessmentId ? null : assessmentId;
- setExpandedId(next);
- if (next !== null && !childRunsByAssessment[next]) {
- loadChildRuns(next);
- }
- },
- [childRunsByAssessment, expandedId, loadChildRuns],
- );
-
- const handlePreview = useCallback(
- (runId: number, label: string) => {
- if (!isAuthenticated) return;
- const url = `/assessment/results/${runId}?title=${encodeURIComponent(label)}`;
- window.open(url, "_blank", "noopener,noreferrer");
- },
- [isAuthenticated],
- );
-
- const handleSavePostProcessing = useCallback(
- async (runId: number, config: PostProcessingConfig | null) => {
- await apiFetch(`/api/assessment/runs/${runId}/post-processing`, apiKey, {
- method: "PATCH",
- body: JSON.stringify(config),
- });
- if (expandedId !== null) void loadChildRuns(expandedId);
- },
- [apiKey, expandedId, loadChildRuns],
- );
-
- const handleFetchRunColumns = useCallback(
- async (runId: number): Promise => {
- try {
- const res = await apiFetch<
- { data?: Record[] } | Record[]
- >(`/api/assessment/runs/${runId}/results?export_format=json`, apiKey);
- const rows: Record[] = Array.isArray(res)
- ? res
- : (res.data ?? []);
- if (rows.length === 0) return [];
- return Object.keys(rows[0]).filter(
- (k) => !POST_PROCESSING_NON_DATA_FIELDS.has(k),
- );
- } catch {
- return [];
- }
- },
- [apiKey],
- );
-
- return {
- assessments,
- counts: getResultsCounts(assessments),
- filteredRuns: filterAssessments(assessments, statusFilter),
- childRunsByAssessment,
- configDetailsByKey,
- configLoadingKeys,
- configErrorKeys,
- isLoading,
- statusFilter,
- setStatusFilter,
- rerunningId,
- resumingId,
- retryingAssessmentId,
- expandedId,
- downloadingId,
- loadAssessments,
- handleExpand,
- handleRetryAssessment,
- handleRerun,
- handleResume,
- handlePreview,
- handleAssessmentDownload,
- handleRunDownload,
- handleSavePostProcessing,
- handleFetchRunColumns,
- };
-}
diff --git a/app/hooks/useAssessmentWizard.ts b/app/hooks/useAssessmentWizard.ts
new file mode 100644
index 00000000..bc4642f4
--- /dev/null
+++ b/app/hooks/useAssessmentWizard.ts
@@ -0,0 +1,176 @@
+"use client";
+
+/**
+ * The 4-step assessment wizard: which flow is running, which step is open, and the
+ * run it ends with. Step 1 owns submission state (useSubmissionStep); this hook only
+ * needs the selected set, which it reads from the assessment store.
+ */
+import { useCallback, useMemo, useState } from "react";
+import { useAssessorVersionContext } from "@/app/hooks/useAssessorVersionContext";
+import { useRunNameDraft } from "@/app/hooks/useRunNameDraft";
+import { useWizardDraft } from "@/app/hooks/useWizardDraft";
+import { useVersionSaveFlow } from "@/app/hooks/useVersionSaveFlow";
+import { useRunSubmit } from "@/app/hooks/useRunSubmit";
+import { useAssessmentDatasetStore } from "@/app/lib/store/assessment";
+import {
+ isWizardStepAllowed,
+ wizardEntryStep,
+ wizardOpenSteps,
+} from "@/app/lib/assessment/wizard";
+import type {
+ AssessmentWizardFlow,
+ AssessorVersionDetail,
+ AssessmentWizardStep,
+ UseAssessmentWizardResult,
+ WizardContext,
+} from "@/app/lib/types/assessment";
+
+interface UseAssessmentWizardParams {
+ onExit: () => void;
+ onRunCreated: (context: WizardContext) => void;
+}
+
+export function useAssessmentWizard({
+ onExit,
+ onRunCreated,
+}: UseAssessmentWizardParams): UseAssessmentWizardResult {
+ const version = useAssessorVersionContext();
+ const draft = useWizardDraft();
+
+ const submissionId = useAssessmentDatasetStore((state) => state.datasetId);
+ const submissionName = useAssessmentDatasetStore(
+ (state) => state.datasetName,
+ );
+ const submissionColumns = useAssessmentDatasetStore((state) => state.columns);
+ const clearDataset = useAssessmentDatasetStore((state) => state.clearDataset);
+
+ const [flow, setFlow] = useState("new");
+ const [step, setStep] = useState(1);
+ const [completedSteps, setCompletedSteps] = useState>(new Set());
+ const [context, setContext] = useState(null);
+ const { runName, setRunName } = useRunNameDraft(context, step === 4);
+
+ // A saved version becomes the wizard's context, and the flow continues as a run.
+ const onSaved = useCallback(
+ (next: WizardContext, detail: AssessorVersionDetail) => {
+ setContext(next);
+ setFlow("run");
+ setCompletedSteps(new Set([1, 2, 3]));
+ version.adopt(detail);
+ },
+ [version],
+ );
+
+ const saveFlow = useVersionSaveFlow({
+ draft: draft.draft,
+ submissionId,
+ submissionColumns,
+ context,
+ onSaved,
+ });
+
+ const { isSubmitting, submitRun } = useRunSubmit({
+ context,
+ submissionId,
+ runName,
+ onRunCreated,
+ });
+
+ const start = useCallback(
+ (nextFlow: AssessmentWizardFlow, nextContext: WizardContext | null) => {
+ const entry = wizardEntryStep(nextFlow);
+ setFlow(nextFlow);
+ setContext(nextContext);
+ setStep(entry);
+ setCompletedSteps(
+ new Set(wizardOpenSteps(nextFlow).filter((open) => open < entry)),
+ );
+ if (nextContext) {
+ version.load(nextContext, (detail) => {
+ draft.loadFromVersion(detail);
+ saveFlow.rebaseline(detail);
+ });
+ } else {
+ version.reset();
+ draft.reset();
+ clearDataset();
+ saveFlow.rebaseline(null);
+ }
+ },
+ [clearDataset, draft, saveFlow, version],
+ );
+
+ const starters = useMemo(
+ () => ({
+ startNew: () => start("new", null),
+ startEdit: (nextContext: WizardContext) => start("edit", nextContext),
+ startRun: (nextContext: WizardContext) => start("run", nextContext),
+ }),
+ [start],
+ );
+
+ const goToStep = useCallback(
+ (nextStep: number) => {
+ const allowed = isWizardStepAllowed({
+ flow,
+ step: nextStep,
+ currentStep: step,
+ completedSteps,
+ });
+ if (allowed) setStep(nextStep as AssessmentWizardStep);
+ },
+ [completedSteps, flow, step],
+ );
+
+ const next = useCallback(() => {
+ const open = wizardOpenSteps(flow);
+ setCompletedSteps((current) => new Set(current).add(step));
+ const following = open[open.indexOf(step) + 1];
+ if (following) setStep(following);
+ }, [flow, step]);
+
+ const back = useCallback(() => {
+ const open = wizardOpenSteps(flow);
+ const index = open.indexOf(step);
+ if (index <= 0) onExit();
+ else setStep(open[index - 1]);
+ }, [flow, onExit, step]);
+
+ const dismissSaved = useCallback(
+ (next: "run" | "home") => {
+ saveFlow.dismissSaved();
+ if (next === "run") setStep(4);
+ else onExit();
+ },
+ [onExit, saveFlow],
+ );
+
+ return {
+ flow,
+ step,
+ completedSteps,
+ context,
+ versionDetail: version.versionDetail,
+ isLoadingContext: version.isLoading,
+ draft,
+ submissionId,
+ submissionName,
+ runName,
+ setRunName,
+ isSubmitting,
+ isDirty: saveFlow.isDirty,
+ canSave: saveFlow.canSave,
+ isSaving: saveFlow.isSaving,
+ isReviewOpen: saveFlow.isReviewOpen,
+ savedTitle: saveFlow.savedTitle,
+ openReview: saveFlow.openReview,
+ closeReview: saveFlow.closeReview,
+ saveVersion: saveFlow.saveVersion,
+ dismissSaved,
+ ...starters,
+ goToStep,
+ next,
+ back,
+ submitRun,
+ };
+}
diff --git a/app/hooks/useAssessmentWorkflow.ts b/app/hooks/useAssessmentWorkflow.ts
index 10e8ce30..aa3209cb 100644
--- a/app/hooks/useAssessmentWorkflow.ts
+++ b/app/hooks/useAssessmentWorkflow.ts
@@ -1,297 +1,54 @@
"use client";
-import { useCallback, useEffect, useMemo, useRef, useState } from "react";
-import { useRouter } from "next/navigation";
-import { useToast } from "@/app/hooks/useToast";
-import { useAuth } from "@/app/lib/context/AuthContext";
-import { apiFetch } from "@/app/lib/apiClient";
-import {
- getAssessmentSubmitBlocker,
- getAssessmentSubmitError,
- handleForbiddenError,
- schemaToJsonSchema,
-} from "@/app/lib/utils/assessment";
-import { removeFeatureFromClient } from "@/app/lib/utils/features";
-import { FeatureFlag } from "@/app/lib/constants";
-import { PAGE_TABS } from "@/app/lib/assessment/constants";
-import { useAssessmentDatasetStore } from "@/app/lib/store/assessment";
+/**
+ * Composition root for /assessment: which surface is showing (Home or the wizard),
+ * and the version Home should preselect when the wizard hands control back.
+ */
+import { useCallback, useState } from "react";
+import { useAssessmentWizard } from "@/app/hooks/useAssessmentWizard";
import type {
- AssessmentFormState,
- AssessmentTabId,
- ConfigSelection,
+ AssessmentView,
+ AssessorSelection,
PageLayoutProps,
- PrefilterConfig,
- PostProcessingConfig,
- SchemaProperty,
+ WizardContext,
} from "@/app/lib/types/assessment";
export type UseAssessmentWorkflowResult = PageLayoutProps;
export function useAssessmentWorkflow(): UseAssessmentWorkflowResult {
- const router = useRouter();
- const { error: showToastError, success: showToastSuccess } = useToast();
- const { activeKey } = useAuth();
- const [activeTab, setActiveTab] = useState("datasets");
- const [configStep, setConfigStep] = useState(1);
- const [completedConfigSteps, setCompletedConfigSteps] = useState>(
- new Set(),
+ const [view, setView] = useState("home");
+ const [homeSelection, setHomeSelection] = useState(
+ null,
);
- const [isSubmitting, setIsSubmitting] = useState(false);
- const [experimentName, setExperimentName] = useState("");
- const featureRedirectingRef = useRef(false);
- const {
- datasetId,
- datasetName,
- columns,
- sampleRow,
- columnMapping,
- setDatasetId,
- setDatasetName,
- setDataset,
- setColumnMapping,
- clearDataset,
- } = useAssessmentDatasetStore();
- const [promptTemplate, setPromptTemplate] = useState("");
- const [systemInstruction, setSystemInstruction] = useState("");
- const [outputSchema, setOutputSchema] = useState([]);
- const [configs, setConfigs] = useState([]);
- const [prefilterConfig, setPrefilterConfig] =
- useState(null);
- const [postProcessingConfig, setPostProcessingConfig] =
- useState(null);
- const handleForbidden = useCallback(
- (options?: { notify?: boolean }) => {
- if (featureRedirectingRef.current) return;
- featureRedirectingRef.current = true;
+ const onGoHome = useCallback(() => setView("home"), []);
- if (options?.notify) {
- showToastError(
- "Assessment feature is disabled for this organization/project.",
- );
- }
-
- removeFeatureFromClient(FeatureFlag.ASSESSMENT);
- if (
- typeof window !== "undefined" &&
- window.location.pathname !== "/evaluations"
- ) {
- router.replace("/");
- }
- },
- [router, showToastError],
- );
-
- const handleForbiddenWithNotify = useCallback(() => {
- handleForbidden({ notify: true });
- }, [handleForbidden]);
-
- const markConfigCompleted = useCallback((step: number) => {
- setCompletedConfigSteps((prev) => new Set([...prev, step]));
- }, []);
-
- const handleConfigNext = useCallback(
- (fromStep: number) => {
- markConfigCompleted(fromStep);
- setConfigStep(fromStep + 1);
- },
- [markConfigCompleted],
- );
-
- const handleColumnsLoaded = useCallback(
- (loadedColumns: string[], firstRow: Record = {}) => {
- const currentId = useAssessmentDatasetStore.getState().datasetId;
- setDataset(currentId, loadedColumns, firstRow);
- setPromptTemplate("");
- },
- [setDataset],
- );
-
- useEffect(() => {
- setPrefilterConfig(null);
- }, [datasetId]);
-
- const outputSchemaJson = useMemo(
- () => schemaToJsonSchema(outputSchema),
- [outputSchema],
- );
-
- const handleSubmit = useCallback(async () => {
- const validationError = getAssessmentSubmitError({
- datasetId,
- hasMapperSelection:
- columnMapping.textColumns.length > 0 ||
- columnMapping.attachments.length > 0,
- hasResponseFormat: outputSchema.some((field) => field.name.trim()),
- configCount: configs.length,
- experimentName,
+ const onRunCreated = useCallback((context: WizardContext) => {
+ setHomeSelection({
+ configId: context.configId,
+ version: context.version,
});
- if (validationError) {
- showToastError(validationError);
- return;
- }
-
- setIsSubmitting(true);
- try {
- await apiFetch("/api/assessment/runs", activeKey?.key ?? "", {
- method: "POST",
- body: JSON.stringify({
- experiment_name: experimentName.trim(),
- dataset_id: parseInt(datasetId, 10),
- prompt_template: promptTemplate || null,
- system_instruction: systemInstruction.trim() || null,
- text_columns: columnMapping.textColumns,
- attachments: columnMapping.attachments.map(
- ({ column, type, format, type_column, type_value_map }) => ({
- column,
- type,
- format,
- ...(type_column ? { type_column, type_value_map } : {}),
- }),
- ),
- output_schema: outputSchemaJson,
- configs: configs.map(({ config_id, config_version }) => ({
- config_id,
- config_version,
- })),
- prefilter_config: prefilterConfig ?? null,
- post_processing_config: postProcessingConfig ?? null,
- }),
- });
-
- showToastSuccess("Assessment submitted!");
- setConfigStep(1);
- setCompletedConfigSteps(new Set());
- setExperimentName("");
- clearDataset();
- setSystemInstruction("");
- setPromptTemplate("");
- setOutputSchema([]);
- setConfigs([]);
- setPrefilterConfig(null);
- setPostProcessingConfig(null);
- setActiveTab("results");
- } catch (error) {
- if (handleForbiddenError(error, handleForbiddenWithNotify)) return;
- showToastError(
- `Failed to submit: ${error instanceof Error ? error.message : "Unknown error"}`,
- );
- } finally {
- setIsSubmitting(false);
- }
- }, [
- clearDataset,
- columnMapping,
- configs,
- datasetId,
- experimentName,
- handleForbiddenWithNotify,
- prefilterConfig,
- outputSchema,
- outputSchemaJson,
- postProcessingConfig,
- promptTemplate,
- activeKey,
- systemInstruction,
- showToastError,
- showToastSuccess,
- ]);
+ setView("home");
+ }, []);
- const formState: AssessmentFormState = {
- experimentName,
- datasetId,
- datasetName,
- columns,
- sampleRow,
- columnMapping,
- systemInstruction,
- promptTemplate,
- outputSchema,
- configs,
- prefilterConfig,
- postProcessingConfig,
- };
+ const wizard = useAssessmentWizard({ onExit: onGoHome, onRunCreated });
- const hasDataset = !!datasetId && columns.length > 0;
- const hasMapperSelection =
- columnMapping.textColumns.length > 0 ||
- columnMapping.attachments.length > 0;
- const hasConfiguredResponseFormat = outputSchema.some((field) =>
- field.name.trim(),
- );
- const canReachReview = configs.length > 0 && hasConfiguredResponseFormat;
- const canSubmitAssessment =
- !!datasetId &&
- hasMapperSelection &&
- hasConfiguredResponseFormat &&
- configs.length > 0 &&
- experimentName.trim().length > 0 &&
- !isSubmitting;
- const submitBlockerMessage = getAssessmentSubmitBlocker({
- datasetId,
- hasMapperSelection,
- hasResponseFormat: hasConfiguredResponseFormat,
- configCount: configs.length,
- experimentName,
- });
- const effectiveCompletedConfigSteps = useMemo(() => {
- const merged = new Set(completedConfigSteps);
- if (hasMapperSelection) merged.add(1);
- if (hasMapperSelection) merged.add(2); // Prefilter is optional and always passable
- if (canReachReview) merged.add(3);
- if (canReachReview) merged.add(4); // Post Processing is optional and always passable
- return merged;
- }, [canReachReview, completedConfigSteps, hasMapperSelection]);
+ const openWizard = useCallback((start: () => void) => {
+ start();
+ setView("wizard");
+ }, []);
return {
- activeTab,
- tabs: [...PAGE_TABS],
- onTabSwitch: setActiveTab,
- datasetsTabProps: {
- onForbidden: handleForbiddenWithNotify,
- datasetId,
- setDatasetId,
- setSelectedDatasetName: setDatasetName,
- onColumnsLoaded: handleColumnsLoaded,
- onNext: () => {
- setActiveTab("config");
- setConfigStep(1);
- },
- },
- configPanelProps: {
- canSubmitAssessment,
- columns,
- columnMapping,
- completedSteps: effectiveCompletedConfigSteps,
- configStep,
- configs,
- datasetId,
- experimentName,
- formState,
- hasDataset,
- isSubmitting,
- prefilterConfig,
- outputSchema,
- systemInstruction,
- promptTemplate,
- sampleRow,
- setActiveTabToDatasets: () => setActiveTab("datasets"),
- setColumnMapping,
- setConfigStep,
- setConfigs,
- setExperimentName,
- setPrefilterConfig,
- setOutputSchema,
- setSystemInstruction,
- setPromptTemplate,
- postProcessingConfig,
- setPostProcessingConfig,
- submitBlockerMessage,
- onSubmit: handleSubmit,
- onStepComplete: handleConfigNext,
- },
- evaluationsTabProps: {
- onForbidden: handleForbiddenWithNotify,
+ view,
+ homeSelection,
+ onGoHome,
+ wizard: {
+ ...wizard,
+ startNew: () => openWizard(wizard.startNew),
+ startEdit: (context: WizardContext) =>
+ openWizard(() => wizard.startEdit(context)),
+ startRun: (context: WizardContext) =>
+ openWizard(() => wizard.startRun(context)),
},
};
}
diff --git a/app/hooks/useAssessorSave.ts b/app/hooks/useAssessorSave.ts
new file mode 100644
index 00000000..235835de
--- /dev/null
+++ b/app/hooks/useAssessorSave.ts
@@ -0,0 +1,67 @@
+"use client";
+
+/**
+ * Saving an assessor version: bump `vN+1` (or create `v1`), then re-baseline so
+ * the wizard stops reporting a diff. After a save the flow continues in run
+ * context, which is why the caller gets the new version back.
+ */
+import { useCallback, useState } from "react";
+import { useToast } from "@/app/hooks/useToast";
+import { useAssessmentData } from "@/app/hooks/useAssessmentData";
+import {
+ draftToVersionInput,
+ versionDetailFrom,
+} from "@/app/lib/assessment/draft";
+import { getAsyncErrorMessage } from "@/app/lib/assessment/results";
+import type {
+ AssessorVersionDetail,
+ SaveAssessorVersionResult,
+ WizardDraft,
+} from "@/app/lib/types/assessment";
+
+export interface SaveAssessorParams {
+ draft: WizardDraft;
+ submissionId: string;
+ submissionColumns: string[];
+ configId: string | null;
+ name: string;
+ commitMessage: string;
+}
+
+export interface UseAssessorSaveResult {
+ isSaving: boolean;
+ save: (
+ params: SaveAssessorParams,
+ ) => Promise<
+ (SaveAssessorVersionResult & { detail: AssessorVersionDetail }) | null
+ >;
+}
+
+export function useAssessorSave(): UseAssessorSaveResult {
+ const toast = useToast();
+ const data = useAssessmentData();
+ const [isSaving, setIsSaving] = useState(false);
+
+ const save = useCallback(
+ async (params: SaveAssessorParams) => {
+ setIsSaving(true);
+ try {
+ const input = draftToVersionInput(params);
+ const saved = await data.saveAssessorVersion(input);
+ toast.success(`Assessor "${params.name}" saved as v${saved.version}`);
+ return {
+ ...saved,
+ detail: versionDetailFrom(input, saved.config_id, saved.version),
+ };
+ } catch (caught) {
+ toast.error(getAsyncErrorMessage("save assessor", caught));
+ return null;
+ } finally {
+ setIsSaving(false);
+ }
+ },
+ [data, toast],
+ );
+
+ return { isSaving, save };
+}
diff --git a/app/hooks/useAssessorVersionContext.ts b/app/hooks/useAssessorVersionContext.ts
new file mode 100644
index 00000000..207726cc
--- /dev/null
+++ b/app/hooks/useAssessorVersionContext.ts
@@ -0,0 +1,111 @@
+"use client";
+
+/**
+ * Loads the assessor version an `edit` / `run` flow is anchored to, plus the
+ * submission set it links to — the `edit` flow opens on step 2, so the prompt
+ * zones need that set's columns and sample row before step 1 is ever visited.
+ */
+import { useCallback, useState } from "react";
+import { useToast } from "@/app/hooks/useToast";
+import { useAssessmentData } from "@/app/hooks/useAssessmentData";
+import { useAssessmentDatasetStore } from "@/app/lib/store/assessment";
+import { nonBlankColumns, toDatasetPreview } from "@/app/lib/utils/assessment";
+import { getAsyncErrorMessage } from "@/app/lib/assessment/results";
+import type {
+ AssessmentDataSource,
+ AssessorVersionDetail,
+ WizardContext,
+} from "@/app/lib/types/assessment";
+
+export interface UseAssessorVersionContextResult {
+ versionDetail: AssessorVersionDetail | null;
+ isLoading: boolean;
+ load: (
+ context: WizardContext,
+ onLoaded: (detail: AssessorVersionDetail) => void,
+ ) => void;
+ /** Adopt a version we just wrote, without a refetch. */
+ adopt: (detail: AssessorVersionDetail) => void;
+ reset: () => void;
+}
+
+export function useAssessorVersionContext(): UseAssessorVersionContextResult {
+ const toast = useToast();
+ const data = useAssessmentData();
+ const setDataset = useAssessmentDatasetStore((state) => state.setDataset);
+ const [versionDetail, setVersionDetail] =
+ useState(null);
+ const [isLoading, setIsLoading] = useState(false);
+
+ const load = useCallback(
+ (
+ context: WizardContext,
+ onLoaded: (detail: AssessorVersionDetail) => void,
+ ) => {
+ setIsLoading(true);
+ setVersionDetail(null);
+ data
+ .getAssessorVersion(context.configId, context.version)
+ .then(async (detail) => {
+ setVersionDetail(detail);
+ onLoaded(detail);
+ await selectLinkedSubmission(data, detail, setDataset);
+ })
+ .catch((caught) =>
+ toast.error(getAsyncErrorMessage("load assessor version", caught)),
+ )
+ .finally(() => setIsLoading(false));
+ },
+ [data, setDataset, toast],
+ );
+
+ const adopt = useCallback((detail: AssessorVersionDetail) => {
+ setVersionDetail(detail);
+ }, []);
+
+ const reset = useCallback(() => setVersionDetail(null), []);
+
+ return { versionDetail, isLoading, load, adopt, reset };
+}
+
+type SetDataset = ReturnType<
+ typeof useAssessmentDatasetStore.getState
+>["setDataset"];
+
+const LINKED_RUN_LOOKUP_LIMIT = 5;
+
+/**
+ * The config blob stores no submission, so a reopened version takes the one its
+ * most recent run used. A version never run leaves step 1 for the user to fill.
+ */
+async function linkedSubmissionId(
+ data: AssessmentDataSource,
+ detail: AssessorVersionDetail,
+): Promise {
+ if (detail.submission_id) return String(detail.submission_id);
+
+ const runs = await data.listAssessments({
+ config_id: detail.config_id,
+ version: detail.version,
+ limit: LINKED_RUN_LOOKUP_LIMIT,
+ });
+ return runs.find((run) => run.submission_id)?.submission_id ?? null;
+}
+
+async function selectLinkedSubmission(
+ data: AssessmentDataSource,
+ detail: AssessorVersionDetail,
+ setDataset: SetDataset,
+): Promise {
+ const id = await linkedSubmissionId(data, detail);
+ if (!id) return;
+
+ const [submissions, preview] = await Promise.all([
+ data.listSubmissions(),
+ data.getSubmissionPreview(id).then(toDatasetPreview),
+ ]);
+
+ const name = submissions.find((item) => item.submission_id === id)?.name;
+ const { headers, sampleRow } = nonBlankColumns(preview);
+ setDataset(id, headers, sampleRow, name);
+}
diff --git a/app/hooks/useLatestConfigModels.ts b/app/hooks/useLatestConfigModels.ts
deleted file mode 100644
index 48b61fc7..00000000
--- a/app/hooks/useLatestConfigModels.ts
+++ /dev/null
@@ -1,60 +0,0 @@
-"use client";
-
-import { useEffect, useState } from "react";
-import {
- fetchConfigSelection,
- fetchConfigVersionsPage,
-} from "@/app/lib/utils/assessmentFetcher";
-import type { ConfigPublic } from "@/app/lib/types/configs";
-import type { LatestConfigModel } from "@/app/lib/types/assessment";
-
-/**
- * Resolves the provider/model of each config's latest version, lazily and once
- * per config. Entries are `null` while loading or when unavailable so the UI
- * can hide the model badge without a separate loading flag.
- */
-export default function useLatestConfigModels(
- configCards: ConfigPublic[],
- apiKey: string,
- isAuthenticated: boolean,
-): Record {
- const [latestModelByConfig, setLatestModelByConfig] = useState<
- Record
- >({});
-
- useEffect(() => {
- if (!isAuthenticated || configCards.length === 0) return;
- configCards.forEach((card) => {
- if (latestModelByConfig[card.id] !== undefined) return;
- setLatestModelByConfig((prev) =>
- prev[card.id] !== undefined ? prev : { ...prev, [card.id]: null },
- );
- void (async () => {
- try {
- const versions = await fetchConfigVersionsPage(apiKey, card.id, {
- skip: 0,
- limit: 1,
- });
- const latest = versions.items[0];
- if (!latest) return;
- const selection = await fetchConfigSelection(
- apiKey,
- { id: card.id, name: card.name },
- latest.version,
- );
- setLatestModelByConfig((prev) => ({
- ...prev,
- [card.id]: {
- provider: selection.provider,
- model: selection.model,
- },
- }));
- } catch {
- // leave entry as null; UI hides model when unavailable
- }
- })();
- });
- }, [apiKey, configCards, isAuthenticated, latestModelByConfig]);
-
- return latestModelByConfig;
-}
diff --git a/app/hooks/usePromptAndConfigStep.ts b/app/hooks/usePromptAndConfigStep.ts
deleted file mode 100644
index a65f931d..00000000
--- a/app/hooks/usePromptAndConfigStep.ts
+++ /dev/null
@@ -1,414 +0,0 @@
-"use client";
-
-import { useCallback, useEffect, useMemo, useRef, useState } from "react";
-import { useAuth } from "@/app/lib/context/AuthContext";
-import { useToast } from "@/app/hooks/useToast";
-import { DEFAULT_PAGE_LIMIT } from "@/app/lib/constants";
-import {
- ASSESSMENT_CONFIG_VERSION_PAGE_SIZE,
- MAX_CONFIGS,
-} from "@/app/lib/assessment/constants";
-import {
- buildDefaultParams,
- buildInitialAssessmentConfigDraft,
- buildInitialAssessmentVersionState,
- fetchConfigPage,
- fetchConfigSelection,
- fetchConfigVersionsPage,
- getDefaultModelForProvider,
- getModelConfigDefinition,
- getModelsByProvider,
- saveAssessmentConfig,
-} from "@/app/lib/utils/assessmentFetcher";
-import {
- type ConfigMode,
- type ConfigSelection,
- type PromptAndConfigStepProps,
- type UsePromptAndConfigStepResult,
- type VersionListState,
-} from "@/app/lib/types/assessment";
-import type {
- CompletionConfig,
- ConfigBlob,
- ConfigPublic,
-} from "@/app/lib/types/configs";
-import useLatestConfigModels from "@/app/hooks/useLatestConfigModels";
-
-type UsePromptAndConfigStepParams = Pick<
- PromptAndConfigStepProps,
- "textColumns" | "promptTemplate" | "configs" | "setConfigs" | "outputSchema"
->;
-
-export function usePromptAndConfigStep({
- textColumns,
- promptTemplate,
- configs,
- setConfigs,
- outputSchema,
-}: UsePromptAndConfigStepParams): UsePromptAndConfigStepResult {
- const toast = useToast();
- const { activeKey, isAuthenticated } = useAuth();
- const apiKey = activeKey?.key ?? "";
-
- const [configMode, setConfigMode] = useState("existing");
- const [configCards, setConfigCards] = useState([]);
- const [searchQuery, setSearchQuery] = useState("");
- const [isLoadingConfigs, setIsLoadingConfigs] = useState(true);
- const [hasMoreConfigs, setHasMoreConfigs] = useState(true);
- const [nextConfigSkip, setNextConfigSkip] = useState(0);
- const [expandedConfigId, setExpandedConfigId] = useState(null);
- const [versionStateByConfig, setVersionStateByConfig] = useState<
- Record
- >({});
- const [loadingSelectionKeys, setLoadingSelectionKeys] = useState<
- Record
- >({});
- const hasLoadedInitialConfigsRef = useRef(false);
-
- const [draft, setDraft] = useState(() =>
- buildInitialAssessmentConfigDraft(),
- );
- const [configName, setConfigName] = useState("");
- const [commitMessage, setCommitMessage] = useState("");
- const [isSaving, setIsSaving] = useState(false);
-
- const draftParams = draft.completion.params as Record<
- string,
- string | number | undefined
- >;
- const currentProvider = draft.completion.provider ?? "openai";
- const providerModels = useMemo(
- () => getModelsByProvider(currentProvider),
- [currentProvider],
- );
- const currentModel = String(draftParams.model || providerModels[0]?.value);
- const currentParamDefs = useMemo(
- () => getModelConfigDefinition(currentModel),
- [currentModel],
- );
-
- const usedColumns = useMemo(
- () => textColumns.filter((col) => promptTemplate.includes(`{${col}}`)),
- [promptTemplate, textColumns],
- );
- const namedSchemaFields = outputSchema.filter((field) => field.name.trim());
- const hasConfiguredResponseFormat = namedSchemaFields.length > 0;
- const canProceed = configs.length > 0 && hasConfiguredResponseFormat;
- const nextBlockerMessage =
- configs.length === 0
- ? "Select at least one configuration to continue"
- : !hasConfiguredResponseFormat
- ? "Set response format to continue"
- : "";
- const responseSummary =
- namedSchemaFields.length > 0
- ? `${namedSchemaFields.length} fields`
- : "Not set";
- const promptStatus = promptTemplate.trim()
- ? `${usedColumns.length} placeholders`
- : "Empty";
-
- const isSelected = useCallback(
- (configId: string, version: number) =>
- configs.some(
- (config) =>
- config.config_id === configId && config.config_version === version,
- ),
- [configs],
- );
-
- const addSelection = useCallback(
- (selection: ConfigSelection) => {
- if (
- configs.some(
- (config) =>
- config.config_id === selection.config_id &&
- config.config_version === selection.config_version,
- )
- ) {
- toast.error("This configuration version is already selected");
- return;
- }
- if (configs.length >= MAX_CONFIGS) {
- toast.error(`You can select up to ${MAX_CONFIGS} configurations`);
- return;
- }
- setConfigs((prev) => [...prev, selection]);
- },
- [configs, setConfigs, toast],
- );
-
- const removeSelection = useCallback(
- (configId: string, version: number) => {
- setConfigs((prev) =>
- prev.filter(
- (config) =>
- !(
- config.config_id === configId && config.config_version === version
- ),
- ),
- );
- },
- [setConfigs],
- );
-
- const toggleVersionSelection = useCallback(
- async (config: ConfigPublic, version: number) => {
- if (!isAuthenticated) return;
- const key = `${config.id}:${version}`;
- if (isSelected(config.id, version)) {
- removeSelection(config.id, version);
- return;
- }
- setLoadingSelectionKeys((prev) => ({ ...prev, [key]: true }));
- try {
- const selection = await fetchConfigSelection(apiKey, config, version);
- addSelection(selection);
- } catch {
- toast.error("Failed to load configuration details");
- } finally {
- setLoadingSelectionKeys((prev) => ({ ...prev, [key]: false }));
- }
- },
- [addSelection, apiKey, isAuthenticated, isSelected, removeSelection, toast],
- );
-
- const loadConfigs = useCallback(
- async (skip: number, replace: boolean) => {
- if (!isAuthenticated) {
- if (replace) setIsLoadingConfigs(false);
- return;
- }
- if (replace) setIsLoadingConfigs(true);
- try {
- const result = await fetchConfigPage({
- apiKey,
- skip,
- limit: DEFAULT_PAGE_LIMIT,
- });
- setConfigCards((prev) =>
- replace ? result.items : [...prev, ...result.items],
- );
- setHasMoreConfigs(result.hasMore);
- setNextConfigSkip(result.nextSkip);
- } catch {
- toast.error("Failed to load configurations");
- } finally {
- setIsLoadingConfigs(false);
- }
- },
- [apiKey, isAuthenticated, toast],
- );
-
- useEffect(() => {
- if (!isAuthenticated) return;
- if (!hasLoadedInitialConfigsRef.current) {
- hasLoadedInitialConfigsRef.current = true;
- void loadConfigs(0, true);
- }
- }, [isAuthenticated, loadConfigs]);
-
- const latestModelByConfig = useLatestConfigModels(
- configCards,
- apiKey,
- isAuthenticated,
- );
-
- const filteredConfigCards = useMemo(() => {
- const query = searchQuery.trim().toLowerCase();
- if (!query) return configCards;
- return configCards.filter((config) =>
- `${config.name} ${config.description || ""}`
- .toLowerCase()
- .includes(query),
- );
- }, [configCards, searchQuery]);
-
- const loadVersions = useCallback(
- async (configId: string, skip: number) => {
- if (!isAuthenticated) return;
- setVersionStateByConfig((prev) => ({
- ...prev,
- [configId]: {
- ...(prev[configId] ?? buildInitialAssessmentVersionState()),
- isLoading: true,
- error: null,
- },
- }));
- try {
- const result = await fetchConfigVersionsPage(apiKey, configId, {
- skip,
- limit: ASSESSMENT_CONFIG_VERSION_PAGE_SIZE,
- });
- setVersionStateByConfig((prev) => {
- const existing =
- prev[configId] ?? buildInitialAssessmentVersionState();
- return {
- ...prev,
- [configId]: {
- items:
- skip === 0
- ? result.items
- : [...existing.items, ...result.items],
- isLoading: false,
- error: null,
- hasMore: result.hasMore,
- nextSkip: result.nextSkip,
- },
- };
- });
- } catch {
- setVersionStateByConfig((prev) => ({
- ...prev,
- [configId]: {
- ...(prev[configId] ?? buildInitialAssessmentVersionState()),
- isLoading: false,
- error: "Failed to load versions",
- },
- }));
- }
- },
- [apiKey, isAuthenticated],
- );
-
- const toggleConfigExpansion = useCallback(
- (configId: string) => {
- if (expandedConfigId === configId) {
- setExpandedConfigId(null);
- return;
- }
- setExpandedConfigId(configId);
- if (!versionStateByConfig[configId]) {
- void loadVersions(configId, 0);
- }
- },
- [expandedConfigId, loadVersions, versionStateByConfig],
- );
-
- const updateDraftParam = (key: string, value: string | number) => {
- setDraft((prev) => ({
- ...prev,
- completion: {
- ...prev.completion,
- params: { ...prev.completion.params, [key]: value },
- },
- }));
- };
-
- const handleProviderChange = (provider: CompletionConfig["provider"]) => {
- const defaultModel = getDefaultModelForProvider(provider);
- setDraft((prev) => ({
- ...prev,
- completion: {
- ...prev.completion,
- provider,
- params: {
- instructions: String(prev.completion.params.instructions || ""),
- model: defaultModel,
- ...buildDefaultParams(defaultModel),
- },
- },
- }));
- };
-
- const handleModelChange = (modelName: string) => {
- setDraft((prev) => ({
- ...prev,
- completion: {
- ...prev.completion,
- params: {
- instructions: String(prev.completion.params.instructions || ""),
- model: modelName,
- ...buildDefaultParams(modelName),
- },
- },
- }));
- };
-
- const handleCreateAndAdd = async () => {
- if (!isAuthenticated) {
- toast.error("Please sign in to create configurations");
- return;
- }
- if (!configName.trim()) {
- toast.error("Configuration name is required");
- return;
- }
- setIsSaving(true);
- try {
- const existingConfig =
- configCards.find(
- (c) =>
- c.name.trim().toLowerCase() === configName.trim().toLowerCase(),
- ) ?? null;
- const saved = await saveAssessmentConfig({
- apiKey,
- configName: configName.trim(),
- commitMessage: commitMessage.trim(),
- configBlob: draft,
- existingConfig: existingConfig
- ? { id: existingConfig.id, name: existingConfig.name }
- : null,
- });
- addSelection({
- config_id: saved.config_id,
- config_version: saved.config_version,
- name: configName.trim(),
- provider: draft.completion.provider,
- model: currentModel,
- });
- setDraft(buildInitialAssessmentConfigDraft());
- setConfigName("");
- setCommitMessage("");
- setConfigMode("existing");
- toast.success("Configuration saved and added!");
- void loadConfigs(0, true);
- } catch (err) {
- toast.error(
- err instanceof Error ? err.message : "Failed to save configuration",
- );
- } finally {
- setIsSaving(false);
- }
- };
-
- return {
- promptStatus,
- responseSummary,
- hasConfiguredResponseFormat,
- canProceed,
- nextBlockerMessage,
- configMode,
- setConfigMode,
- removeSelection,
- filteredConfigCards,
- searchQuery,
- setSearchQuery,
- isLoadingConfigs,
- hasMoreConfigs,
- nextConfigSkip,
- expandedConfigId,
- versionStateByConfig,
- latestModelByConfig,
- loadingSelectionKeys,
- isSelected,
- loadConfigs,
- loadVersions,
- toggleConfigExpansion,
- toggleVersionSelection,
- currentProvider,
- currentModel,
- providerModels,
- currentParamDefs,
- draftParams,
- configName,
- commitMessage,
- isSaving,
- setConfigName,
- setCommitMessage,
- handleProviderChange,
- handleModelChange,
- updateDraftParam,
- handleCreateAndAdd,
- };
-}
diff --git a/app/hooks/usePromptMentions.ts b/app/hooks/usePromptMentions.ts
new file mode 100644
index 00000000..d7a84489
--- /dev/null
+++ b/app/hooks/usePromptMentions.ts
@@ -0,0 +1,334 @@
+"use client";
+
+/**
+ * Three-stage `@`-mentions for the prompt zones. Stage 1 suggests submission
+ * columns (with a "create field" escape hatch); picking one asks its type — Text /
+ * Image / PDF — and then whether a row may leave it blank, because those two
+ * choices define the input schema. Stage 1 reuses useAtMention for trigger
+ * detection, caret placement and insertion; stages 2–3 are this hook's own small
+ * state machine, which a token's marker can also open directly at stage 3.
+ */
+import { useCallback, useMemo, useRef, useState } from "react";
+import { useAtMention } from "@/app/lib/hooks/useAtMention";
+import {
+ PROMPT_FIELD_TYPE_LABELS,
+ canonicalToken,
+ fieldTypeOf,
+} from "@/app/lib/assessment/promptTokens";
+import type { PromptFieldType } from "@/app/lib/types/assessment";
+
+export interface MentionColumnOption {
+ name: string;
+ isCreate: boolean;
+ type: PromptFieldType;
+}
+
+export interface MentionTypeOption {
+ type: PromptFieldType;
+ label: string;
+ isCurrent: boolean;
+}
+
+export interface MentionStrictOption {
+ strict: boolean;
+ label: string;
+ hint: string;
+ isCurrent: boolean;
+}
+
+/** Which follow-up question is open for `pendingField`. */
+export type MentionStage = "type" | "strict";
+
+const TYPE_ORDER: PromptFieldType[] = ["text", "image", "pdf"];
+
+/** Grace for the pointer to travel from a token's marker into its dropdown. */
+const HOVER_CLOSE_DELAY_MS = 200;
+
+// Optional first: it is the backend default, so it is what a plain `@Column` means.
+const STRICT_CHOICES: Omit[] = [
+ { strict: false, label: "Optional", hint: "rows may leave it blank" },
+ { strict: true, label: "Required", hint: "a blank row fails the run" },
+];
+
+function strictIndexOf(
+ fieldStrict: Record,
+ name: string,
+): number {
+ return STRICT_CHOICES.findIndex(
+ (choice) => choice.strict === (fieldStrict[name] ?? false),
+ );
+}
+
+/** Timer for hover-opened boxes: closes once the pointer has left marker and box. */
+function useHoverClose() {
+ const openedByHover = useRef(false);
+ const timer = useRef | null>(null);
+
+ const cancelHoverClose = useCallback(() => {
+ if (timer.current) clearTimeout(timer.current);
+ timer.current = null;
+ }, []);
+
+ const armHover = useCallback(
+ (via: "hover" | "click") => {
+ cancelHoverClose();
+ openedByHover.current = via === "hover";
+ },
+ [cancelHoverClose],
+ );
+
+ const resetHover = useCallback(() => {
+ cancelHoverClose();
+ openedByHover.current = false;
+ }, [cancelHoverClose]);
+
+ const scheduleHover = useCallback(
+ (close: () => void) => {
+ if (!openedByHover.current) return;
+ cancelHoverClose();
+ timer.current = setTimeout(close, HOVER_CLOSE_DELAY_MS);
+ },
+ [cancelHoverClose],
+ );
+
+ return { armHover, resetHover, cancelHoverClose, scheduleHover };
+}
+
+interface UsePromptMentionsParams {
+ columns: string[];
+ fieldTypes: Record;
+ fieldStrict: Record;
+ value: string;
+ onChange: (value: string) => void;
+ onFieldType: (name: string, type: PromptFieldType) => void;
+ onFieldStrict: (name: string, strict: boolean) => void;
+}
+
+export function usePromptMentions({
+ columns,
+ fieldTypes,
+ fieldStrict,
+ value,
+ onChange,
+ onFieldType,
+ onFieldStrict,
+}: UsePromptMentionsParams) {
+ const inputRef = useRef(null);
+ const mirrorRef = useRef(null);
+ const dropdownRef = useRef(null);
+ const [pendingField, setPendingField] = useState(null);
+ const [stage, setStage] = useState(null);
+ const [optionIndex, setOptionIndex] = useState(0);
+ // Inserting closes stage 1, which clears its caret position — keep it for stages 2–3.
+ const [stagePosition, setStagePosition] = useState<{
+ top: number;
+ left: number;
+ } | null>(null);
+ const { armHover, resetHover, cancelHoverClose, scheduleHover } =
+ useHoverClose();
+
+ const mention = useAtMention({
+ columns,
+ inputRef: inputRef as React.RefObject,
+ mirrorRef: mirrorRef as React.RefObject,
+ dropdownRef: dropdownRef as React.RefObject,
+ insertFormat: (column) =>
+ canonicalToken(column, fieldTypeOf(fieldTypes, column)),
+ });
+
+ const columnOptions = useMemo(() => {
+ const query = mention.state.query;
+ if (query === null) return [];
+ const options: MentionColumnOption[] = mention.options.map((name) => ({
+ name,
+ isCreate: false,
+ type: fieldTypeOf(fieldTypes, name),
+ }));
+ const exact = columns.some(
+ (column) => column.toLowerCase() === query.toLowerCase(),
+ );
+ if (query && !exact) {
+ options.push({ name: query, isCreate: true, type: "text" });
+ }
+ return options;
+ }, [columns, fieldTypes, mention.options, mention.state.query]);
+
+ const typeOptions = useMemo(() => {
+ if (!pendingField) return [];
+ const current = fieldTypeOf(fieldTypes, pendingField);
+ return TYPE_ORDER.map((type) => ({
+ type,
+ label: PROMPT_FIELD_TYPE_LABELS[type],
+ isCurrent: type === current,
+ }));
+ }, [fieldTypes, pendingField]);
+
+ const strictOptions = useMemo(() => {
+ if (!pendingField) return [];
+ const current = fieldStrict[pendingField] ?? false;
+ return STRICT_CHOICES.map((choice) => ({
+ ...choice,
+ isCurrent: choice.strict === current,
+ }));
+ }, [fieldStrict, pendingField]);
+
+ const closeStages = useCallback(() => {
+ resetHover();
+ setPendingField(null);
+ setStage(null);
+ setStagePosition(null);
+ }, [resetHover]);
+
+ const closeAll = useCallback(() => {
+ closeStages();
+ mention.close();
+ }, [closeStages, mention]);
+
+ /** Stage 1 → insert the token, then ask for its type. */
+ const pickColumn = useCallback(
+ (option: MentionColumnOption) => {
+ setStagePosition(mention.state.pos);
+ mention.insert(option.name, value, onChange);
+ setPendingField(option.name);
+ setStage("type");
+ setOptionIndex(
+ Math.max(0, TYPE_ORDER.indexOf(fieldTypeOf(fieldTypes, option.name))),
+ );
+ },
+ [fieldTypes, mention, onChange, value],
+ );
+
+ /** Stage 2 → record the type, then ask whether a row may leave it blank. */
+ const pickType = useCallback(
+ (type: PromptFieldType) => {
+ if (!pendingField) return;
+ onFieldType(pendingField, type);
+ setStage("strict");
+ setOptionIndex(strictIndexOf(fieldStrict, pendingField));
+ },
+ [fieldStrict, onFieldType, pendingField],
+ );
+
+ /** Stage 3 → record strictness and hand focus back to the textarea. */
+ const pickStrict = useCallback(
+ (strict: boolean) => {
+ if (pendingField) onFieldStrict(pendingField, strict);
+ closeStages();
+ inputRef.current?.focus();
+ },
+ [closeStages, onFieldStrict, pendingField],
+ );
+
+ /** From a token's marker: straight to the required/optional question. */
+ const openStrictFor = useCallback(
+ (
+ name: string,
+ position: { top: number; left: number },
+ via: "hover" | "click",
+ ) => {
+ armHover(via);
+ mention.close();
+ setPendingField(name);
+ setStage("strict");
+ setStagePosition(position);
+ setOptionIndex(strictIndexOf(fieldStrict, name));
+ if (via === "click") inputRef.current?.focus();
+ },
+ [armHover, fieldStrict, mention],
+ );
+
+ const onKeyDown = useCallback(
+ (event: React.KeyboardEvent) => {
+ if (!stage) {
+ if (mention.state.query !== null && columnOptions.length > 0) {
+ handleColumnKeys(event, columnOptions, mention, pickColumn);
+ }
+ return;
+ }
+ handleOptionKeys(event, {
+ count: stage === "type" ? TYPE_ORDER.length : STRICT_CHOICES.length,
+ optionIndex,
+ setOptionIndex,
+ pick: (index) =>
+ stage === "type"
+ ? pickType(TYPE_ORDER[index])
+ : pickStrict(STRICT_CHOICES[index].strict),
+ cancel: closeStages,
+ });
+ },
+ [
+ closeStages,
+ columnOptions,
+ mention,
+ optionIndex,
+ pickColumn,
+ pickStrict,
+ pickType,
+ stage,
+ ],
+ );
+
+ return {
+ inputRef,
+ mirrorRef,
+ dropdownRef,
+ isOpen: stage !== null || mention.state.query !== null,
+ position: stage ? stagePosition : mention.state.pos,
+ activeIndex: stage ? optionIndex : mention.state.index,
+ pendingField,
+ stage,
+ columnOptions,
+ typeOptions,
+ strictOptions,
+ onInput: mention.onInput,
+ onKeyDown,
+ pickColumn,
+ pickType,
+ pickStrict,
+ openStrictFor,
+ scheduleHoverClose: () => scheduleHover(closeStages),
+ cancelHoverClose,
+ closeAll,
+ };
+}
+
+function handleColumnKeys(
+ event: React.KeyboardEvent,
+ options: MentionColumnOption[],
+ mention: ReturnType,
+ pick: (option: MentionColumnOption) => void,
+): void {
+ if (event.key === "Enter" || event.key === "Tab") {
+ event.preventDefault();
+ pick(options[Math.min(mention.state.index, options.length - 1)]);
+ return;
+ }
+ // Arrow keys and Escape are already handled by the stage-1 engine.
+ mention.onKeyDown(event, "", () => {});
+}
+
+function handleOptionKeys(
+ event: React.KeyboardEvent,
+ handlers: {
+ count: number;
+ optionIndex: number;
+ setOptionIndex: (value: number) => void;
+ pick: (index: number) => void;
+ cancel: () => void;
+ },
+): void {
+ const { count, optionIndex, setOptionIndex, pick, cancel } = handlers;
+ if (event.key === "ArrowDown") {
+ event.preventDefault();
+ setOptionIndex((optionIndex + 1) % count);
+ } else if (event.key === "ArrowUp") {
+ event.preventDefault();
+ setOptionIndex((optionIndex - 1 + count) % count);
+ } else if (event.key === "Enter" || event.key === "Tab") {
+ event.preventDefault();
+ pick(optionIndex);
+ } else if (event.key === "Escape") {
+ event.preventDefault();
+ cancel();
+ }
+}
diff --git a/app/hooks/usePromptPlaceholderEditor.ts b/app/hooks/usePromptPlaceholderEditor.ts
deleted file mode 100644
index 2ffd69bc..00000000
--- a/app/hooks/usePromptPlaceholderEditor.ts
+++ /dev/null
@@ -1,292 +0,0 @@
-"use client";
-
-import {
- type KeyboardEvent,
- type RefObject,
- useCallback,
- useEffect,
- useMemo,
- useRef,
- useState,
-} from "react";
-import type { SampleRow, ValueSetter } from "@/app/lib/types/assessment";
-
-interface UsePromptPlaceholderEditorParams {
- value: string;
- onChange: ValueSetter;
- previewMode: boolean;
- textColumns: string[];
- sampleRow: SampleRow;
- enablePlaceholders: boolean;
-}
-
-export interface UsePromptPlaceholderEditorResult {
- textareaRef: RefObject;
- mirrorRef: RefObject;
- dropdownRef: RefObject;
- mentionQuery: string | null;
- mentionIndex: number;
- mentionPos: { top: number; left: number } | null;
- mentionOptions: string[];
- setMentionIndex: (index: number) => void;
- handleInput: () => void;
- handleKeyDown: (event: KeyboardEvent) => void;
- insertMention: (column: string) => void;
- insertPlaceholder: (column: string) => void;
- usedColumns: string[];
- orderedColumns: string[];
- previewText: string;
-}
-
-export function usePromptPlaceholderEditor({
- value,
- onChange,
- previewMode,
- textColumns,
- sampleRow,
- enablePlaceholders,
-}: UsePromptPlaceholderEditorParams): UsePromptPlaceholderEditorResult {
- const [mentionQuery, setMentionQuery] = useState(null);
- const [mentionIndex, setMentionIndex] = useState(0);
- const [mentionPos, setMentionPos] = useState<{
- top: number;
- left: number;
- } | null>(null);
- const [mentionStart, setMentionStart] = useState(null);
- const textareaRef = useRef(null);
- const mirrorRef = useRef(null);
- const dropdownRef = useRef(null);
-
- const mentionOptions = useMemo(() => {
- if (!enablePlaceholders || mentionQuery === null) return [];
- const normalized = mentionQuery.toLowerCase();
- return textColumns.filter((col) => col.toLowerCase().includes(normalized));
- }, [enablePlaceholders, mentionQuery, textColumns]);
-
- const computeCaretPosition = useCallback(() => {
- const textarea = textareaRef.current;
- const mirror = mirrorRef.current;
- if (!textarea || !mirror) return null;
-
- const style = window.getComputedStyle(textarea);
- [
- "font-family",
- "font-size",
- "font-weight",
- "line-height",
- "letter-spacing",
- "padding-top",
- "padding-left",
- "padding-right",
- "padding-bottom",
- "border-top-width",
- "border-left-width",
- "word-wrap",
- "overflow-wrap",
- "tab-size",
- "box-sizing",
- ].forEach((prop) => {
- mirror.style.setProperty(prop, style.getPropertyValue(prop));
- });
- mirror.style.width = `${textarea.clientWidth}px`;
- mirror.style.whiteSpace = "pre-wrap";
- mirror.style.wordWrap = "break-word";
- mirror.style.position = "absolute";
- mirror.style.visibility = "hidden";
- mirror.style.left = "0";
- mirror.style.top = "0";
-
- mirror.textContent = textarea.value.substring(0, textarea.selectionStart);
- const marker = document.createElement("span");
- marker.textContent = "";
- mirror.appendChild(marker);
-
- const markerRect = marker.getBoundingClientRect();
- const mirrorRect = mirror.getBoundingClientRect();
-
- return {
- top:
- markerRect.top -
- mirrorRect.top -
- textarea.scrollTop +
- parseInt(style.lineHeight, 10) +
- 6,
- left: markerRect.left - mirrorRect.left,
- };
- }, []);
-
- const closeMention = useCallback(() => {
- setMentionQuery(null);
- setMentionStart(null);
- setMentionPos(null);
- }, []);
-
- const handleInput = useCallback(() => {
- if (!enablePlaceholders) {
- closeMention();
- return;
- }
-
- const textarea = textareaRef.current;
- if (!textarea) return;
- const cursor = textarea.selectionStart;
- const editorValue = textarea.value;
- let index = cursor - 1;
- while (
- index >= 0 &&
- editorValue[index] !== "@" &&
- editorValue[index] !== " " &&
- editorValue[index] !== "\n" &&
- editorValue[index] !== "\t"
- ) {
- index -= 1;
- }
- if (index >= 0 && editorValue[index] === "@") {
- setMentionQuery(editorValue.substring(index + 1, cursor));
- setMentionStart(index);
- setMentionIndex(0);
- setMentionPos(computeCaretPosition());
- return;
- }
- closeMention();
- }, [closeMention, computeCaretPosition, enablePlaceholders]);
-
- const insertMention = useCallback(
- (column: string) => {
- const textarea = textareaRef.current;
- if (!textarea || mentionStart === null) return;
- const cursor = textarea.selectionStart;
- const nextValue = `${value.substring(0, mentionStart)}{${column}}${value.substring(cursor)}`;
- const nextCursor = mentionStart + column.length + 2;
- onChange(nextValue);
- closeMention();
- setTimeout(() => {
- textarea.focus();
- textarea.setSelectionRange(nextCursor, nextCursor);
- }, 0);
- },
- [closeMention, mentionStart, onChange, value],
- );
-
- const insertPlaceholder = (column: string) => {
- const textarea = textareaRef.current;
- if (!textarea) return;
- const start = textarea.selectionStart;
- const end = textarea.selectionEnd;
- const nextValue = `${value.slice(0, start)}{${column}}${value.slice(end)}`;
- const nextCursor = start + column.length + 2;
- onChange(nextValue);
- setTimeout(() => {
- textarea.focus();
- textarea.setSelectionRange(nextCursor, nextCursor);
- }, 0);
- };
-
- const handleKeyDown = useCallback(
- (event: KeyboardEvent) => {
- if (
- !enablePlaceholders ||
- mentionQuery === null ||
- mentionOptions.length === 0
- )
- return;
- if (event.key === "ArrowDown") {
- event.preventDefault();
- setMentionIndex((prev) => (prev + 1) % mentionOptions.length);
- } else if (event.key === "ArrowUp") {
- event.preventDefault();
- setMentionIndex(
- (prev) => (prev - 1 + mentionOptions.length) % mentionOptions.length,
- );
- } else if (event.key === "Enter" || event.key === "Tab") {
- event.preventDefault();
- insertMention(mentionOptions[mentionIndex]);
- } else if (event.key === "Escape") {
- closeMention();
- }
- },
- [
- closeMention,
- enablePlaceholders,
- insertMention,
- mentionIndex,
- mentionOptions,
- mentionQuery,
- ],
- );
-
- useEffect(() => {
- const handleClick = (event: MouseEvent) => {
- const target = event.target as Node;
- if (
- textareaRef.current &&
- !textareaRef.current.contains(target) &&
- dropdownRef.current &&
- !dropdownRef.current.contains(target)
- ) {
- closeMention();
- }
- };
- document.addEventListener("mousedown", handleClick);
- return () => document.removeEventListener("mousedown", handleClick);
- }, [closeMention]);
-
- useEffect(() => {
- if (dropdownRef.current && mentionOptions.length > 0) {
- const active = dropdownRef.current.children[mentionIndex] as
- | HTMLElement
- | undefined;
- active?.scrollIntoView({ block: "nearest" });
- }
- }, [mentionIndex, mentionOptions.length]);
-
- useEffect(() => {
- if (previewMode || !enablePlaceholders) {
- closeMention();
- }
- }, [closeMention, enablePlaceholders, previewMode]);
-
- const usedColumns = useMemo(
- () => textColumns.filter((col) => value.includes(`{${col}}`)),
- [textColumns, value],
- );
-
- const orderedColumns = useMemo(() => {
- const used = textColumns.filter((col) => usedColumns.includes(col));
- const unused = textColumns.filter((col) => !usedColumns.includes(col));
- return [...used, ...unused];
- }, [textColumns, usedColumns]);
-
- const previewText = useMemo(() => {
- if (!value.trim()) return "";
- if (!enablePlaceholders) return value;
-
- let next = value;
- textColumns.forEach((col) => {
- const safe = col.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
- next = next.replace(
- new RegExp(`\\{${safe}\\}`, "g"),
- sampleRow[col] || "",
- );
- });
- return next;
- }, [enablePlaceholders, sampleRow, textColumns, value]);
-
- return {
- textareaRef,
- mirrorRef,
- dropdownRef,
- mentionQuery,
- mentionIndex,
- mentionPos,
- mentionOptions,
- setMentionIndex,
- handleInput,
- handleKeyDown,
- insertMention,
- insertPlaceholder,
- usedColumns,
- orderedColumns,
- previewText,
- };
-}
diff --git a/app/hooks/useRunNameDraft.ts b/app/hooks/useRunNameDraft.ts
new file mode 100644
index 00000000..655671a8
--- /dev/null
+++ b/app/hooks/useRunNameDraft.ts
@@ -0,0 +1,38 @@
+"use client";
+
+/**
+ * The run-name field on step 4. Defaults to `{assessor} — run {n}` once the
+ * assessor's existing runs are counted, the way the prototype names runs.
+ */
+import { useEffect, useState } from "react";
+import { useAssessmentData } from "@/app/hooks/useAssessmentData";
+import type { WizardContext } from "@/app/lib/types/assessment";
+
+export interface UseRunNameDraftResult {
+ runName: string;
+ setRunName: (value: string) => void;
+}
+
+export function useRunNameDraft(
+ context: WizardContext | null,
+ isRunStep: boolean,
+): UseRunNameDraftResult {
+ const data = useAssessmentData();
+ const [runName, setRunName] = useState("");
+
+ useEffect(() => {
+ if (!isRunStep || !context) return;
+ let cancelled = false;
+ void data.listAssessments().then((runs) => {
+ const count = runs.filter(
+ (run) => run.config?.id === context.configId,
+ ).length;
+ if (!cancelled) setRunName(`${context.assessorName} — run ${count + 1}`);
+ });
+ return () => {
+ cancelled = true;
+ };
+ }, [context, data, isRunStep]);
+
+ return { runName, setRunName };
+}
diff --git a/app/hooks/useRunResults.ts b/app/hooks/useRunResults.ts
new file mode 100644
index 00000000..68d37771
--- /dev/null
+++ b/app/hooks/useRunResults.ts
@@ -0,0 +1,132 @@
+"use client";
+
+/**
+ * One run's results, through the data source: the raw rows (for the detail
+ * modal) plus the table projection the grids render.
+ *
+ * Polls while the run is in flight and stops at a terminal status, so an open
+ * results tab fills in as stages land.
+ */
+import { useCallback, useEffect, useRef, useState } from "react";
+import { useToast } from "@/app/hooks/useToast";
+import { useAssessmentData } from "@/app/hooks/useAssessmentData";
+import {
+ getAsyncErrorMessage,
+ jsonResultsToTableData,
+ normalizeStatus,
+} from "@/app/lib/assessment/results";
+import {
+ RESULTS_POLL_INTERVAL_MS,
+ SPREADSHEET_PREVIEW_ROW_LIMIT,
+ TERMINAL_ASSESSMENT_STATUSES,
+} from "@/app/lib/assessment/constants";
+import type {
+ AssessmentStatusValue,
+ BatchCounts,
+ ResultsTarget,
+} from "@/app/lib/types/assessment";
+
+export interface UseRunResultsResult {
+ results: Record[];
+ headers: string[];
+ rows: string[][];
+ status: AssessmentStatusValue | null;
+ counts: BatchCounts | null;
+ totalItems: number;
+ isPolling: boolean;
+ isLoading: boolean;
+ error: string | null;
+}
+
+export function useRunResults(
+ target: ResultsTarget | null,
+): UseRunResultsResult {
+ const toast = useToast();
+ const data = useAssessmentData();
+ const [results, setResults] = useState[]>([]);
+ const [table, setTable] = useState<{ headers: string[]; rows: string[][] }>({
+ headers: [],
+ rows: [],
+ });
+ const [status, setStatus] = useState(null);
+ const [counts, setCounts] = useState(null);
+ const [totalItems, setTotalItems] = useState(0);
+ const [isLoading, setIsLoading] = useState(true);
+ const [error, setError] = useState(null);
+ const warnedRef = useRef(false);
+ const cancelledRef = useRef(false);
+
+ const assessmentId = target?.assessment_id ?? null;
+ const method = target?.method ?? null;
+
+ const load = useCallback(async () => {
+ if (!assessmentId || !method) return;
+ try {
+ const payload = await data.getRunResults({
+ assessment_id: assessmentId,
+ method,
+ });
+ if (cancelledRef.current) return;
+
+ setResults(payload.rows);
+ setStatus(payload.status);
+ setCounts(payload.counts);
+ setTotalItems(payload.total_items);
+ setTable(
+ jsonResultsToTableData(payload.rows, {
+ rowLimit: SPREADSHEET_PREVIEW_ROW_LIMIT,
+ }),
+ );
+ setError(null);
+
+ if (payload.rows.length > SPREADSHEET_PREVIEW_ROW_LIMIT && !warnedRef.current) {
+ warnedRef.current = true;
+ toast.warning(
+ `Preview capped at ${SPREADSHEET_PREVIEW_ROW_LIMIT} rows. Download CSV for full data.`,
+ );
+ }
+ } catch (caught) {
+ if (!cancelledRef.current) {
+ setError(getAsyncErrorMessage("load results", caught));
+ }
+ } finally {
+ if (!cancelledRef.current) setIsLoading(false);
+ }
+ }, [assessmentId, data, method, toast]);
+
+ useEffect(() => {
+ cancelledRef.current = false;
+ if (!assessmentId) {
+ setError("Invalid assessment id.");
+ setIsLoading(false);
+ return;
+ }
+
+ setIsLoading(true);
+ void load();
+ return () => {
+ cancelledRef.current = true;
+ };
+ }, [assessmentId, load]);
+
+ const isPolling =
+ status !== null && !TERMINAL_ASSESSMENT_STATUSES.has(normalizeStatus(status));
+
+ useEffect(() => {
+ if (!isPolling) return;
+ const interval = setInterval(() => void load(), RESULTS_POLL_INTERVAL_MS);
+ return () => clearInterval(interval);
+ }, [isPolling, load]);
+
+ return {
+ results,
+ headers: table.headers,
+ rows: table.rows,
+ status,
+ counts,
+ totalItems,
+ isPolling,
+ isLoading,
+ error,
+ };
+}
diff --git a/app/hooks/useRunSubmit.ts b/app/hooks/useRunSubmit.ts
new file mode 100644
index 00000000..aa38b73d
--- /dev/null
+++ b/app/hooks/useRunSubmit.ts
@@ -0,0 +1,52 @@
+"use client";
+
+/** Creates the run that ends the wizard, then hands control back to Home. */
+import { useCallback, useState } from "react";
+import { useToast } from "@/app/hooks/useToast";
+import { useAssessmentData } from "@/app/hooks/useAssessmentData";
+import { getAsyncErrorMessage } from "@/app/lib/assessment/results";
+import type { WizardContext } from "@/app/lib/types/assessment";
+
+interface UseRunSubmitParams {
+ context: WizardContext | null;
+ submissionId: string;
+ runName: string;
+ onRunCreated: (context: WizardContext) => void;
+}
+
+export interface UseRunSubmitResult {
+ isSubmitting: boolean;
+ submitRun: () => Promise;
+}
+
+export function useRunSubmit({
+ context,
+ submissionId,
+ runName,
+ onRunCreated,
+}: UseRunSubmitParams): UseRunSubmitResult {
+ const toast = useToast();
+ const data = useAssessmentData();
+ const [isSubmitting, setIsSubmitting] = useState(false);
+
+ const submitRun = useCallback(async () => {
+ if (!context || !submissionId) return;
+ setIsSubmitting(true);
+ try {
+ const run = await data.createRun({
+ experiment_name: runName.trim() || `${context.assessorName} — new run`,
+ submission_id: submissionId,
+ config_id: context.configId,
+ config_version: context.version,
+ });
+ toast.success(`Run "${run.experiment_name}" submitted`);
+ onRunCreated(context);
+ } catch (caught) {
+ toast.error(getAsyncErrorMessage("start run", caught));
+ } finally {
+ setIsSubmitting(false);
+ }
+ }, [context, data, onRunCreated, runName, submissionId, toast]);
+
+ return { isSubmitting, submitRun };
+}
diff --git a/app/hooks/useSubmissionForm.ts b/app/hooks/useSubmissionForm.ts
new file mode 100644
index 00000000..722aa9d4
--- /dev/null
+++ b/app/hooks/useSubmissionForm.ts
@@ -0,0 +1,98 @@
+"use client";
+
+/**
+ * The "create submission set" form: name, description, and the file — picked from
+ * the browser dialog or dropped on the zone, validated the same way either route.
+ */
+import { useCallback, useRef, useState } from "react";
+import { useToast } from "@/app/hooks/useToast";
+import { isAllowedDatasetFile } from "@/app/lib/utils/assessment";
+import { MAX_DATASET_FILE_BYTES } from "@/app/lib/assessment/constants";
+
+export interface UseSubmissionFormResult {
+ name: string;
+ description: string;
+ file: File | null;
+ isDragging: boolean;
+ fileInputRef: React.RefObject;
+ setName: (value: string) => void;
+ setDescription: (value: string) => void;
+ setIsDragging: (value: boolean) => void;
+ removeFile: () => void;
+ reset: () => void;
+ handleFileSelect: (event: React.ChangeEvent) => void;
+ handleDrop: (event: React.DragEvent) => void;
+}
+
+const MAX_FILE_MB = MAX_DATASET_FILE_BYTES / (1024 * 1024);
+const EXTENSION_RE = /\.(csv|xlsx|xls)$/i;
+
+export function useSubmissionForm(): UseSubmissionFormResult {
+ const toast = useToast();
+ const fileInputRef = useRef(null);
+ const [name, setName] = useState("");
+ const [description, setDescription] = useState("");
+ const [file, setFile] = useState(null);
+ const [isDragging, setIsDragging] = useState(false);
+
+ const accept = useCallback(
+ (candidate: File) => {
+ if (!isAllowedDatasetFile(candidate.name)) {
+ toast.error("Please select a CSV or Excel (.xlsx, .xls) file");
+ return;
+ }
+ if (candidate.size > MAX_DATASET_FILE_BYTES) {
+ toast.error(`File too large. Max ${MAX_FILE_MB}MB allowed.`);
+ return;
+ }
+ setFile(candidate);
+ setName((current) => current || candidate.name.replace(EXTENSION_RE, ""));
+ },
+ [toast],
+ );
+
+ const removeFile = useCallback(() => {
+ setFile(null);
+ if (fileInputRef.current) fileInputRef.current.value = "";
+ }, []);
+
+ const reset = useCallback(() => {
+ setName("");
+ setDescription("");
+ removeFile();
+ }, [removeFile]);
+
+ const handleFileSelect = useCallback(
+ (event: React.ChangeEvent) => {
+ const selected = event.target.files?.[0];
+ if (selected) accept(selected);
+ event.target.value = "";
+ },
+ [accept],
+ );
+
+ const handleDrop = useCallback(
+ (event: React.DragEvent) => {
+ event.preventDefault();
+ setIsDragging(false);
+ const dropped = event.dataTransfer.files?.[0];
+ if (dropped) accept(dropped);
+ },
+ [accept],
+ );
+
+ return {
+ name,
+ description,
+ file,
+ isDragging,
+ fileInputRef,
+ setName,
+ setDescription,
+ setIsDragging,
+ removeFile,
+ reset,
+ handleFileSelect,
+ handleDrop,
+ };
+}
diff --git a/app/hooks/useSubmissionList.ts b/app/hooks/useSubmissionList.ts
new file mode 100644
index 00000000..76c0b8f9
--- /dev/null
+++ b/app/hooks/useSubmissionList.ts
@@ -0,0 +1,64 @@
+"use client";
+
+/** Loads submission sets and their previews through the data source, caching previews. */
+import { useCallback, useEffect, useRef, useState } from "react";
+import { useToast } from "@/app/hooks/useToast";
+import { useAssessmentData } from "@/app/hooks/useAssessmentData";
+import { useAssessmentFeatureGuard } from "@/app/hooks/useAssessmentFeatureGuard";
+import { toDatasetPreview } from "@/app/lib/utils/assessment";
+import { getAsyncErrorMessage } from "@/app/lib/assessment/results";
+import type {
+ AssessmentSubmission,
+ DatasetPreview,
+} from "@/app/lib/types/assessment";
+
+export interface UseSubmissionListResult {
+ submissions: AssessmentSubmission[];
+ isLoading: boolean;
+ reload: () => Promise;
+ loadPreview: (id: string) => Promise;
+ forgetPreview: (id: string) => void;
+}
+
+export function useSubmissionList(): UseSubmissionListResult {
+ const toast = useToast();
+ const data = useAssessmentData();
+ const { guard } = useAssessmentFeatureGuard();
+ const [submissions, setSubmissions] = useState([]);
+ const [isLoading, setIsLoading] = useState(true);
+ const previewCache = useRef>({});
+
+ const reload = useCallback(async () => {
+ try {
+ setSubmissions(await data.listSubmissions());
+ } catch (caught) {
+ if (guard(caught)) return;
+ toast.error(getAsyncErrorMessage("load submissions", caught));
+ } finally {
+ setIsLoading(false);
+ }
+ }, [data, guard, toast]);
+
+ const loadPreview = useCallback(
+ async (id: string) => {
+ const cached = previewCache.current[id];
+ if (cached) return cached;
+ const preview = toDatasetPreview(
+ await data.getSubmissionPreview(id),
+ );
+ previewCache.current[id] = preview;
+ return preview;
+ },
+ [data],
+ );
+
+ const forgetPreview = useCallback((id: string) => {
+ delete previewCache.current[id];
+ }, []);
+
+ useEffect(() => {
+ void reload();
+ }, [reload]);
+
+ return { submissions, isLoading, reload, loadPreview, forgetPreview };
+}
diff --git a/app/hooks/useSubmissionStep.ts b/app/hooks/useSubmissionStep.ts
new file mode 100644
index 00000000..df6c4c3c
--- /dev/null
+++ b/app/hooks/useSubmissionStep.ts
@@ -0,0 +1,185 @@
+"use client";
+
+/**
+ * Wizard step 1 — submission sets. Composes the list (useSubmissionList) and the
+ * create form (useSubmissionForm), owns selection plus the create/view/delete
+ * actions, and reports the selection into the assessment store so later steps can
+ * read the set's columns and sample row.
+ */
+import { useCallback, useEffect, useState } from "react";
+import { useToast } from "@/app/hooks/useToast";
+import { useAssessmentData } from "@/app/hooks/useAssessmentData";
+import {
+ useSubmissionForm,
+ type UseSubmissionFormResult,
+} from "@/app/hooks/useSubmissionForm";
+import { useSubmissionList } from "@/app/hooks/useSubmissionList";
+import { useAssessmentDatasetStore } from "@/app/lib/store/assessment";
+import { nonBlankColumns } from "@/app/lib/utils/assessment";
+import { getAsyncErrorMessage } from "@/app/lib/assessment/results";
+import type {
+ AssessmentSubmission,
+ DatasetViewModalData,
+} from "@/app/lib/types/assessment";
+
+export interface UseSubmissionStepResult {
+ form: UseSubmissionFormResult;
+ submissions: AssessmentSubmission[];
+ selectedId: string;
+ isLoading: boolean;
+ isLoadingColumns: boolean;
+ isCreating: boolean;
+ viewingId: string | null;
+ deletingId: string | null;
+ viewModalData: DatasetViewModalData | null;
+ confirmDeleteId: string | null;
+ pendingDelete: AssessmentSubmission | undefined;
+ setConfirmDeleteId: (value: string | null) => void;
+ setViewModalData: (value: DatasetViewModalData | null) => void;
+ handleCreate: () => Promise;
+ handleSelect: (id: string, name?: string) => Promise;
+ handleView: (submissionId: string, name: string) => Promise;
+ handleDelete: (id: string) => Promise;
+}
+
+export function useSubmissionStep(): UseSubmissionStepResult {
+ const toast = useToast();
+ const data = useAssessmentData();
+ const form = useSubmissionForm();
+ const list = useSubmissionList();
+
+ const selectedId = useAssessmentDatasetStore((state) => state.datasetId);
+ const setDataset = useAssessmentDatasetStore((state) => state.setDataset);
+ const setDatasetId = useAssessmentDatasetStore((state) => state.setDatasetId);
+ const setDatasetName = useAssessmentDatasetStore(
+ (state) => state.setDatasetName,
+ );
+
+ const [isLoadingColumns, setIsLoadingColumns] = useState(false);
+ const [isCreating, setIsCreating] = useState(false);
+ const [viewingId, setViewingId] = useState(null);
+ const [deletingId, setDeletingId] = useState(null);
+ const [viewModalData, setViewModalData] =
+ useState(null);
+ const [confirmDeleteId, setConfirmDeleteId] = useState(null);
+
+ const handleSelect = useCallback(
+ async (id: string, selectedName?: string) => {
+ setDatasetId(id);
+ if (!id) {
+ setDatasetName("");
+ return;
+ }
+
+ const resolvedName =
+ selectedName ??
+ list.submissions.find((item) => item.submission_id === id)
+ ?.name ??
+ "";
+ setDatasetName(resolvedName);
+ setIsLoadingColumns(true);
+ try {
+ const preview = await list.loadPreview(id);
+ const { headers, sampleRow } = nonBlankColumns(preview);
+ setDataset(id, headers, sampleRow, resolvedName);
+ } catch (caught) {
+ setDatasetId("");
+ setDatasetName("");
+ toast.error(getAsyncErrorMessage("load submission columns", caught));
+ } finally {
+ setIsLoadingColumns(false);
+ }
+ },
+ [list, setDataset, setDatasetId, setDatasetName, toast],
+ );
+
+ const handleCreate = useCallback(async () => {
+ if (!form.file || !form.name.trim()) return;
+ setIsCreating(true);
+ try {
+ const created = await data.createSubmission({
+ name: form.name.trim(),
+ description: form.description.trim() || undefined,
+ file: form.file,
+ });
+ await list.reload();
+ form.reset();
+ toast.success(`Submission set "${created.name}" created`);
+ await handleSelect(created.submission_id, created.name);
+ } catch (caught) {
+ toast.error(getAsyncErrorMessage("create submission set", caught));
+ } finally {
+ setIsCreating(false);
+ }
+ }, [data, form, handleSelect, list, toast]);
+
+ const handleView = useCallback(
+ async (submissionId: string, viewName: string) => {
+ setViewingId(submissionId);
+ try {
+ const preview = await list.loadPreview(String(submissionId));
+ setViewModalData({
+ name: viewName,
+ headers: preview.headers,
+ rows: preview.rows,
+ });
+ } catch (caught) {
+ toast.error(getAsyncErrorMessage("open submission set", caught));
+ } finally {
+ setViewingId(null);
+ }
+ },
+ [list, toast],
+ );
+
+ const handleDelete = useCallback(
+ async (id: string) => {
+ setDeletingId(id);
+ try {
+ await data.deleteSubmission(id);
+ list.forgetPreview(String(id));
+ if (selectedId === id.toString()) {
+ setDatasetId("");
+ setDatasetName("");
+ }
+ await list.reload();
+ toast.success("Submission set deleted");
+ } catch (caught) {
+ toast.error(getAsyncErrorMessage("delete submission set", caught));
+ } finally {
+ setDeletingId(null);
+ }
+ },
+ [data, list, selectedId, setDatasetId, setDatasetName, toast],
+ );
+
+ // The wizard can preselect a version's linked set before the list arrives.
+ useEffect(() => {
+ const selected = list.submissions.find(
+ (item) => item.submission_id === selectedId,
+ );
+ if (selected) setDatasetName(selected.name);
+ }, [list.submissions, selectedId, setDatasetName]);
+
+ return {
+ form,
+ submissions: list.submissions,
+ selectedId,
+ isLoading: list.isLoading,
+ isLoadingColumns,
+ isCreating,
+ viewingId,
+ deletingId,
+ viewModalData,
+ confirmDeleteId,
+ pendingDelete: list.submissions.find(
+ (item) => item.submission_id === confirmDeleteId,
+ ),
+ setConfirmDeleteId,
+ setViewModalData,
+ handleCreate,
+ handleSelect,
+ handleView,
+ handleDelete,
+ };
+}
diff --git a/app/hooks/useSyncedScroll.ts b/app/hooks/useSyncedScroll.ts
new file mode 100644
index 00000000..0fd79c31
--- /dev/null
+++ b/app/hooks/useSyncedScroll.ts
@@ -0,0 +1,275 @@
+"use client";
+
+/**
+ * Keeps the editor and its preview aligned on their section headings rather than
+ * on total height — the editor carries cards (output schema, the fixed-output
+ * strip) the preview has no counterpart for.
+ *
+ * What makes it feel smooth:
+ * - **monotone cubic mapping** (`createScrollMapper`), so the follower's speed
+ * doesn't jump when crossing an anchor;
+ * - a **tail anchor**, so the end of the last zone lands the preview at its
+ * bottom instead of holding at the last heading;
+ * - **damped follow** in one rAF loop — the follower eases toward its target
+ * instead of snapping to it once per wheel event;
+ * - **echo suppression**, because writing `scrollTop` fires the follower's own
+ * scroll event, which would otherwise drive back and fight the leader;
+ * - **cached anchors**, re-measured only when a pane resizes or its content
+ * changes, so starting a scroll doesn't force a layout.
+ */
+import { useCallback, useEffect, useRef } from "react";
+import {
+ createScrollMapper,
+ type ScrollAnchor,
+ type ScrollMapper,
+} from "@/app/lib/assessment/scrollMap";
+
+type PaneKey = "editor" | "preview";
+
+/** How long the driving pane keeps the lock after its last scroll event. */
+const DRIVER_IDLE_MS = 140;
+/**
+ * Fraction of the remaining distance covered per frame. Heavier easing for a
+ * long jump (crossing to another section) so it glides; lighter for the small
+ * per-frame deltas of a normal scroll, where easing would read as lag —
+ * measured at ≤16px of trail even at 24px/frame.
+ */
+const EASE_NEAR = 0.6;
+const EASE_FAR = 0.3;
+const FAR_DISTANCE_PX = 150;
+const SETTLE_PX = 0.5;
+const ANCHOR_GAP = 14;
+const ECHO_TOLERANCE_PX = 1.5;
+const REMEASURE_DEBOUNCE_MS = 120;
+
+interface FollowState {
+ goal: { pane: HTMLElement; key: PaneKey; to: number } | null;
+ frame: number | null;
+ /** The last value we wrote to each pane, to recognise our own echo. */
+ written: Record;
+ driver: PaneKey | null;
+ snap: boolean;
+}
+
+const maxScroll = (el: HTMLElement) =>
+ Math.max(0, el.scrollHeight - el.clientHeight);
+
+const clamp = (value: number, low: number, high: number) =>
+ Math.min(high, Math.max(low, value));
+
+function offsetIn(pane: HTMLElement, el: HTMLElement, bottom = false): number {
+ const rect = el.getBoundingClientRect();
+ const paneRect = pane.getBoundingClientRect();
+ return (bottom ? rect.bottom : rect.top) - paneRect.top + pane.scrollTop;
+}
+
+/** Strictly increasing on both axes, so the mapping stays invertible. */
+function pushAnchor(anchors: ScrollAnchor[], anchor: ScrollAnchor): void {
+ const last = anchors[anchors.length - 1];
+ if (!last || (anchor[0] > last[0] + 2 && anchor[1] > last[1] + 2)) {
+ anchors.push(anchor);
+ }
+}
+
+function measureAnchors(
+ editor: HTMLElement,
+ preview: HTMLElement,
+ editorSelector: string,
+ previewSelector: string,
+): ScrollAnchor[] {
+ const headings = [...editor.querySelectorAll(editorSelector)];
+ const targets = [...preview.querySelectorAll(previewSelector)];
+ const editorMax = maxScroll(editor);
+ const previewMax = maxScroll(preview);
+
+ const anchors: ScrollAnchor[] = [[0, 0]];
+ const count = Math.min(headings.length, targets.length);
+ for (let i = 0; i < count; i += 1) {
+ pushAnchor(anchors, [
+ clamp(offsetIn(editor, headings[i]) - ANCHOR_GAP, 0, editorMax),
+ clamp(offsetIn(preview, targets[i]) - ANCHOR_GAP, 0, previewMax),
+ ]);
+ }
+
+ // Tail: the end of the last zone lines up with the end of the preview.
+ const tail = count > 0 ? headings[count - 1].nextElementSibling : null;
+ if (tail instanceof HTMLElement) {
+ pushAnchor(anchors, [
+ clamp(offsetIn(editor, tail, true) - editor.clientHeight, 0, editorMax),
+ previewMax,
+ ]);
+ }
+
+ return anchors;
+}
+
+function proportional(source: HTMLElement, target: HTMLElement): number | null {
+ const sourceMax = maxScroll(source);
+ const targetMax = maxScroll(target);
+ if (sourceMax <= 2 || targetMax <= 2) return null;
+ return (source.scrollTop / sourceMax) * targetMax;
+}
+
+/** Writing `scrollTop` fires the follower's own scroll event; skip that one. */
+function isEcho(state: FollowState, key: PaneKey, scrollTop: number): boolean {
+ const value = state.written[key];
+ if (value === null || Math.abs(scrollTop - value) > ECHO_TOLERANCE_PX) {
+ return false;
+ }
+ state.written[key] = null;
+ return true;
+}
+
+function stepFollow(state: FollowState): void {
+ state.frame = null;
+ const goal = state.goal;
+ if (!goal) return;
+
+ const { pane, key, to } = goal;
+ const distance = to - pane.scrollTop;
+ const settled = state.snap || Math.abs(distance) <= SETTLE_PX;
+ const ease = Math.abs(distance) > FAR_DISTANCE_PX ? EASE_FAR : EASE_NEAR;
+ const next = settled ? to : pane.scrollTop + distance * ease;
+
+ state.written[key] = next;
+ pane.scrollTop = next;
+
+ if (settled) {
+ state.goal = null;
+ } else {
+ state.frame = requestAnimationFrame(() => stepFollow(state));
+ }
+}
+
+function scheduleFollow(state: FollowState): void {
+ if (state.frame === null) {
+ state.frame = requestAnimationFrame(() => stepFollow(state));
+ }
+}
+
+export function useSyncedScroll(params: {
+ editorRef: React.RefObject;
+ previewRef: React.RefObject;
+ /** Elements to align on, e.g. the zone headings and the preview's `h1`s. */
+ editorAnchorSelector: string;
+ previewAnchorSelector: string;
+}) {
+ const { editorRef, previewRef, editorAnchorSelector, previewAnchorSelector } =
+ params;
+
+ const stateRef = useRef({
+ goal: null,
+ frame: null,
+ written: { editor: null, preview: null },
+ driver: null,
+ snap: false,
+ });
+ const mapperRef = useRef(null);
+ const idleRef = useRef | null>(null);
+
+ const invalidate = useCallback(() => {
+ mapperRef.current = null;
+ }, []);
+
+ const getMapper = useCallback((): ScrollMapper | null => {
+ const editor = editorRef.current;
+ const preview = previewRef.current;
+ if (!editor || !preview) return null;
+ if (!mapperRef.current) {
+ const anchors = measureAnchors(
+ editor,
+ preview,
+ editorAnchorSelector,
+ previewAnchorSelector,
+ );
+ mapperRef.current =
+ anchors.length > 1 ? createScrollMapper(anchors) : null;
+ }
+ return mapperRef.current;
+ }, [editorAnchorSelector, editorRef, previewAnchorSelector, previewRef]);
+
+ const claimDriver = useCallback((key: PaneKey): boolean => {
+ const state = stateRef.current;
+ if (state.driver && state.driver !== key) return false;
+
+ state.driver = key;
+ if (idleRef.current) clearTimeout(idleRef.current);
+ idleRef.current = setTimeout(() => {
+ state.driver = null;
+ }, DRIVER_IDLE_MS);
+ return true;
+ }, []);
+
+ const sync = useCallback(
+ (source: HTMLElement, sourceKey: PaneKey) => {
+ const state = stateRef.current;
+ const editor = editorRef.current;
+ const preview = previewRef.current;
+ if (!editor || !preview) return;
+ if (isEcho(state, sourceKey, source.scrollTop)) return;
+ if (!claimDriver(sourceKey)) return;
+
+ const fromEditor = sourceKey === "editor";
+ const pane = fromEditor ? preview : editor;
+ const mapper = getMapper();
+ const mapped = mapper
+ ? mapper[fromEditor ? "forward" : "backward"](source.scrollTop)
+ : proportional(source, pane);
+ if (mapped === null) return;
+
+ state.goal = {
+ pane,
+ key: fromEditor ? "preview" : "editor",
+ to: clamp(mapped, 0, maxScroll(pane)),
+ };
+ scheduleFollow(state);
+ },
+ [claimDriver, editorRef, getMapper, previewRef],
+ );
+
+ useEffect(() => {
+ const editor = editorRef.current;
+ const preview = previewRef.current;
+ const state = stateRef.current;
+ if (!editor || !preview) return;
+
+ const motionQuery = window.matchMedia("(prefers-reduced-motion: reduce)");
+ state.snap = motionQuery.matches;
+ const onMotionChange = () => {
+ state.snap = motionQuery.matches;
+ };
+ motionQuery.addEventListener("change", onMotionChange);
+
+ const onEditorScroll = () => sync(editor, "editor");
+ const onPreviewScroll = () => sync(preview, "preview");
+ editor.addEventListener("scroll", onEditorScroll, { passive: true });
+ preview.addEventListener("scroll", onPreviewScroll, { passive: true });
+
+ // Anchors only move when a pane resizes or its content changes.
+ let debounce: ReturnType | null = null;
+ const scheduleRemeasure = () => {
+ if (debounce) clearTimeout(debounce);
+ debounce = setTimeout(invalidate, REMEASURE_DEBOUNCE_MS);
+ };
+ const resizeObserver = new ResizeObserver(scheduleRemeasure);
+ resizeObserver.observe(editor);
+ resizeObserver.observe(preview);
+ const mutationObserver = new MutationObserver(scheduleRemeasure);
+ mutationObserver.observe(preview, {
+ childList: true,
+ subtree: true,
+ characterData: true,
+ });
+
+ return () => {
+ motionQuery.removeEventListener("change", onMotionChange);
+ editor.removeEventListener("scroll", onEditorScroll);
+ preview.removeEventListener("scroll", onPreviewScroll);
+ resizeObserver.disconnect();
+ mutationObserver.disconnect();
+ if (debounce) clearTimeout(debounce);
+ if (state.frame !== null) cancelAnimationFrame(state.frame);
+ if (idleRef.current) clearTimeout(idleRef.current);
+ };
+ }, [editorRef, invalidate, previewRef, sync]);
+}
diff --git a/app/hooks/useVersionSaveFlow.ts b/app/hooks/useVersionSaveFlow.ts
new file mode 100644
index 00000000..3d488f7b
--- /dev/null
+++ b/app/hooks/useVersionSaveFlow.ts
@@ -0,0 +1,118 @@
+"use client";
+
+/**
+ * The save half of the wizard: whether the draft differs from the version it was
+ * loaded from, the Review & save dialog, and what happens after a save.
+ *
+ * The baseline is derived from the loaded version's own detail rather than from
+ * whatever the draft happens to hold at that moment — the draft and the linked
+ * submission set arrive in separate renders, and comparing against a
+ * half-applied draft would report a phantom diff.
+ */
+import { useCallback, useMemo, useState } from "react";
+import { useAssessorSave } from "@/app/hooks/useAssessorSave";
+import {
+ draftFromVersion,
+ emptyDraft,
+ hasAssessmentContent,
+ serializeDraft,
+} from "@/app/lib/assessment/draft";
+import type {
+ AssessorVersionDetail,
+ WizardContext,
+ WizardDraft,
+} from "@/app/lib/types/assessment";
+
+interface UseVersionSaveFlowParams {
+ draft: WizardDraft;
+ submissionId: string;
+ submissionColumns: string[];
+ context: WizardContext | null;
+ onSaved: (context: WizardContext, detail: AssessorVersionDetail) => void;
+}
+
+export interface UseVersionSaveFlowResult {
+ isDirty: boolean;
+ canSave: boolean;
+ isSaving: boolean;
+ isReviewOpen: boolean;
+ savedTitle: string | null;
+ openReview: () => void;
+ closeReview: () => void;
+ saveVersion: (name: string, commitMessage: string) => Promise;
+ dismissSaved: () => void;
+ /** Called when a flow starts, so the diff is measured from the right place. */
+ rebaseline: (detail: AssessorVersionDetail | null) => void;
+}
+
+function baselineOf(detail: AssessorVersionDetail | null): string {
+ return detail
+ ? serializeDraft(
+ draftFromVersion(detail),
+ String(detail.submission_id ?? ""),
+ )
+ : serializeDraft(emptyDraft(), "");
+}
+
+export function useVersionSaveFlow({
+ draft,
+ submissionId,
+ submissionColumns,
+ context,
+ onSaved,
+}: UseVersionSaveFlowParams): UseVersionSaveFlowResult {
+ const { isSaving, save } = useAssessorSave();
+ const [baseline, setBaseline] = useState(() => baselineOf(null));
+ const [isReviewOpen, setIsReviewOpen] = useState(false);
+ const [savedTitle, setSavedTitle] = useState(null);
+ const isDirty = useMemo(
+ () => serializeDraft(draft, submissionId) !== baseline,
+ [baseline, draft, submissionId],
+ );
+
+ const rebaseline = useCallback((detail: AssessorVersionDetail | null) => {
+ setBaseline(baselineOf(detail));
+ setSavedTitle(null);
+ setIsReviewOpen(false);
+ }, []);
+
+ const saveVersion = useCallback(
+ async (name: string, commitMessage: string) => {
+ const saved = await save({
+ draft,
+ submissionId,
+ submissionColumns,
+ configId: context?.configId ?? null,
+ name,
+ commitMessage,
+ });
+ if (!saved) return;
+
+ setIsReviewOpen(false);
+ setBaseline(baselineOf(saved.detail));
+ setSavedTitle(`Assessor "${name}" saved as v${saved.version}`);
+ onSaved(
+ {
+ configId: saved.config_id,
+ version: saved.version,
+ assessorName: name,
+ },
+ saved.detail,
+ );
+ },
+ [context, draft, onSaved, save, submissionColumns, submissionId],
+ );
+
+ return {
+ isDirty,
+ canSave: context ? isDirty : hasAssessmentContent(draft),
+ isSaving,
+ isReviewOpen,
+ savedTitle,
+ openReview: useCallback(() => setIsReviewOpen(true), []),
+ closeReview: useCallback(() => setIsReviewOpen(false), []),
+ saveVersion,
+ dismissSaved: useCallback(() => setSavedTitle(null), []),
+ rebaseline,
+ };
+}
diff --git a/app/hooks/useWizardDraft.ts b/app/hooks/useWizardDraft.ts
new file mode 100644
index 00000000..d433be17
--- /dev/null
+++ b/app/hooks/useWizardDraft.ts
@@ -0,0 +1,126 @@
+"use client";
+
+/**
+ * What steps 2–3 edit: the two prompt zones per step, each referenced column's
+ * type, and the output schema. Loading a saved version fills it from the wire
+ * shapes, so an assessor reopens exactly as it was authored.
+ */
+import { useCallback, useState } from "react";
+import { rewriteToken } from "@/app/lib/assessment/promptTokens";
+import { draftFromVersion, emptyDraft } from "@/app/lib/assessment/draft";
+import {
+ buildDefaultParams,
+ getDefaultModelForProvider,
+} from "@/app/lib/data/assessmentModels";
+import type {
+ AssessorVersionDetail,
+ PromptFieldType,
+ PromptStepId,
+ PromptZoneId,
+ PromptZones,
+ SchemaProperty,
+ UseWizardDraftResult,
+ WizardDraft,
+} from "@/app/lib/types/assessment";
+import type { ProviderType } from "@/app/lib/types/configs";
+
+export function useWizardDraft(): UseWizardDraftResult {
+ const [draft, setDraft] = useState(emptyDraft);
+
+ const setPrefilterEnabled = useCallback((prefilterEnabled: boolean) => {
+ setDraft((current) => ({ ...current, prefilterEnabled }));
+ }, []);
+
+ const setZone = useCallback(
+ (step: PromptStepId, zone: PromptZoneId, value: string) => {
+ setDraft((current) => ({
+ ...current,
+ [step]: { ...current[step], [zone]: value },
+ }));
+ },
+ [],
+ );
+
+ /** A type change rewrites that column's token in every zone. */
+ const setFieldType = useCallback((name: string, type: PromptFieldType) => {
+ setDraft((current) => ({
+ ...current,
+ fieldTypes: { ...current.fieldTypes, [name]: type },
+ prefilter: rewriteZones(current.prefilter, name, type),
+ assessment: rewriteZones(current.assessment, name, type),
+ }));
+ }, []);
+
+ const setFieldStrict = useCallback((name: string, strict: boolean) => {
+ setDraft((current) => ({
+ ...current,
+ fieldStrict: { ...current.fieldStrict, [name]: strict },
+ }));
+ }, []);
+
+ const setOutputSchema = useCallback((outputSchema: SchemaProperty[]) => {
+ setDraft((current) => ({ ...current, outputSchema }));
+ }, []);
+
+ /** Switching model resets its parameters — each model accepts a different set. */
+ const setModel = useCallback(
+ (step: PromptStepId, provider: ProviderType, model: string) => {
+ // Changing provider passes no model — fall back to that provider's first.
+ const next = model || getDefaultModelForProvider(provider);
+ setDraft((current) => ({
+ ...current,
+ models: {
+ ...current.models,
+ [step]: { provider, model: next, params: buildDefaultParams(next) },
+ },
+ }));
+ },
+ [],
+ );
+
+ const setModelParam = useCallback(
+ (step: PromptStepId, key: string, value: string | number) => {
+ setDraft((current) => ({
+ ...current,
+ models: {
+ ...current.models,
+ [step]: {
+ ...current.models[step],
+ params: { ...current.models[step].params, [key]: value },
+ },
+ },
+ }));
+ },
+ [],
+ );
+
+ const loadFromVersion = useCallback((detail: AssessorVersionDetail) => {
+ setDraft(draftFromVersion(detail));
+ }, []);
+
+ const reset = useCallback(() => setDraft(emptyDraft()), []);
+
+ return {
+ draft,
+ setPrefilterEnabled,
+ setZone,
+ setFieldType,
+ setFieldStrict,
+ setOutputSchema,
+ setModel,
+ setModelParam,
+ loadFromVersion,
+ reset,
+ };
+}
+
+function rewriteZones(
+ zones: PromptZones,
+ name: string,
+ type: PromptFieldType,
+): PromptZones {
+ return {
+ instructions: rewriteToken(zones.instructions, name, type),
+ submission: rewriteToken(zones.submission, name, type),
+ };
+}
diff --git a/app/lib/assessment/api/assessors.ts b/app/lib/assessment/api/assessors.ts
new file mode 100644
index 00000000..e39b436a
--- /dev/null
+++ b/app/lib/assessment/api/assessors.ts
@@ -0,0 +1,147 @@
+/**
+ * Assessor fetchers. Assessors are ASSESSMENT-tagged configs, so these ride the
+ * existing `/api/configs` routes. Network only, no React.
+ */
+import { apiFetch } from "@/app/lib/apiClient";
+import { ASSESSMENT_TAG } from "@/app/lib/assessment/constants";
+import {
+ blobToVersionDetail,
+ versionDetailToBlob,
+ type AssessmentBlob,
+} from "@/app/lib/assessment/configBlob";
+import {
+ fetchConfigPage,
+ fetchConfigVersionDetail,
+ fetchConfigVersionsPage,
+} from "@/app/lib/utils/assessmentFetcher";
+import type {
+ AssessorPageQuery,
+ AssessorSummary,
+ AssessorVersion,
+ AssessorVersionDetail,
+ PagedResult,
+ SaveAssessorVersionInput,
+ SaveAssessorVersionResult,
+} from "@/app/lib/types/assessment";
+import type {
+ ConfigVersionResponse,
+ ConfigWithVersion,
+} from "@/app/lib/types/configs";
+
+const VERSION_PAGE_LIMIT = 100;
+
+/** One page of assessors; `has_more` from the API drives the next/prev chevrons. */
+export async function listAssessors(
+ apiKey: string,
+ page: AssessorPageQuery = {},
+): Promise> {
+ return fetchConfigPage({
+ apiKey,
+ skip: page.skip,
+ limit: page.limit,
+ search: page.search,
+ });
+}
+
+export async function listAssessorVersions(
+ apiKey: string,
+ configId: string,
+): Promise {
+ const page = await fetchConfigVersionsPage(apiKey, configId, {
+ limit: VERSION_PAGE_LIMIT,
+ });
+ return page.items;
+}
+
+export async function getAssessorVersion(
+ apiKey: string,
+ configId: string,
+ version: number,
+): Promise {
+ const detail = await fetchConfigVersionDetail(apiKey, configId, version);
+ return blobToVersionDetail(
+ configId,
+ detail.version,
+ detail.commit_message ?? null,
+ detail.config_blob as unknown as AssessmentBlob,
+ );
+}
+
+export async function deleteAssessor(
+ apiKey: string,
+ configId: string,
+): Promise {
+ const response = await apiFetch<{ success: boolean; error?: string }>(
+ `/api/configs/${configId}`,
+ apiKey,
+ { method: "DELETE" },
+ );
+ if (!response.success) {
+ throw new Error(response.error || "Failed to delete the assessor");
+ }
+}
+
+export async function deleteAssessorVersion(
+ apiKey: string,
+ configId: string,
+ version: number,
+): Promise {
+ const response = await apiFetch<{ success: boolean; error?: string }>(
+ `/api/configs/${configId}/versions/${version}`,
+ apiKey,
+ { method: "DELETE" },
+ );
+ if (!response.success) {
+ throw new Error(response.error || "Failed to delete the version");
+ }
+}
+
+/** First save creates the config with its v1 blob; later saves append a version. */
+export async function saveAssessorVersion(
+ apiKey: string,
+ input: SaveAssessorVersionInput,
+): Promise {
+ const configBlob = versionDetailToBlob(input);
+ const commitMessage = input.commit_message || "Updated assessor";
+
+ if (!input.config_id) {
+ const created = await apiFetch<{
+ success: boolean;
+ data?: ConfigWithVersion;
+ error?: string;
+ }>("/api/configs", apiKey, {
+ method: "POST",
+ body: JSON.stringify({
+ name: input.name,
+ description: input.description ?? null,
+ tag: ASSESSMENT_TAG,
+ config_blob: configBlob,
+ commit_message: commitMessage,
+ }),
+ });
+ if (!created.success || !created.data) {
+ throw new Error(created.error || "Failed to create the assessor");
+ }
+ return {
+ config_id: created.data.id,
+ version: created.data.version.version,
+ };
+ }
+
+ const query = new URLSearchParams({ tag: ASSESSMENT_TAG });
+ const response = await apiFetch(
+ `/api/configs/${input.config_id}/versions?${query}`,
+ apiKey,
+ {
+ method: "POST",
+ body: JSON.stringify({
+ config_blob: configBlob,
+ commit_message: commitMessage,
+ }),
+ },
+ );
+ if (!response.success || !response.data) {
+ throw new Error(response.error || "Failed to save the assessor version");
+ }
+ return { config_id: input.config_id, version: response.data.version };
+}
diff --git a/app/lib/assessment/api/runs.ts b/app/lib/assessment/api/runs.ts
new file mode 100644
index 00000000..ed83c651
--- /dev/null
+++ b/app/lib/assessment/api/runs.ts
@@ -0,0 +1,84 @@
+/**
+ * Assessment run fetchers against the BATCH API. Network only, no React.
+ */
+import { apiFetch } from "@/app/lib/apiClient";
+import { ASSESSMENT_BATCH_ENDPOINT } from "@/app/lib/assessment/constants";
+import { flattenBatchDetail } from "@/app/lib/assessment/batchResults";
+import type {
+ AssessmentDetail,
+ AssessmentResultsPayload,
+ AssessmentRun,
+ CreateRunInput,
+ ListAssessmentsQuery,
+ ResultsTarget,
+} from "@/app/lib/types/assessment";
+
+type Envelope = T | { data?: T };
+
+const unwrap = (response: Envelope, fallback: T): T =>
+ (response as { data?: T })?.data ?? (response as T) ?? fallback;
+
+function buildQuery(query: ListAssessmentsQuery): string {
+ const params = new URLSearchParams();
+ for (const [key, value] of Object.entries(query)) {
+ if (value !== undefined && value !== null) params.set(key, String(value));
+ }
+ const search = params.toString();
+ return search ? `?${search}` : "";
+}
+
+export async function listAssessments(
+ apiKey: string,
+ query: ListAssessmentsQuery = {},
+): Promise {
+ const response = await apiFetch>(
+ `${ASSESSMENT_BATCH_ENDPOINT}${buildQuery(query)}`,
+ apiKey,
+ );
+ return Array.isArray(response) ? response : unwrap(response, []);
+}
+
+export async function getAssessmentDetail(
+ apiKey: string,
+ assessmentId: string,
+): Promise {
+ const response = await apiFetch>(
+ `${ASSESSMENT_BATCH_ENDPOINT}/${assessmentId}`,
+ apiKey,
+ );
+ return unwrap(response, {} as AssessmentDetail);
+}
+
+export async function createRun(
+ apiKey: string,
+ input: CreateRunInput,
+): Promise {
+ const response = await apiFetch>(
+ ASSESSMENT_BATCH_ENDPOINT,
+ apiKey,
+ {
+ method: "POST",
+ body: JSON.stringify({
+ experiment_name: input.experiment_name,
+ config: { id: input.config_id, version: input.config_version },
+ input: { submission_doc_id: input.submission_id },
+ }),
+ },
+ );
+ const ack = unwrap(response, {} as { assessment_id: string });
+ // The submit ack is flat; read the full row back so callers get a list-shaped run.
+ return getAssessmentDetail(apiKey, ack.assessment_id);
+}
+
+export async function getRunResults(
+ apiKey: string,
+ target: ResultsTarget,
+): Promise {
+ const detail = await getAssessmentDetail(apiKey, target.assessment_id);
+ return {
+ status: detail.status,
+ rows: flattenBatchDetail(detail),
+ total_items: detail.total_items ?? 0,
+ counts: detail.counts ?? null,
+ };
+}
diff --git a/app/lib/assessment/api/submissions.ts b/app/lib/assessment/api/submissions.ts
new file mode 100644
index 00000000..85d18071
--- /dev/null
+++ b/app/lib/assessment/api/submissions.ts
@@ -0,0 +1,71 @@
+/**
+ * Submission-file fetchers. Network only, no React.
+ *
+ * The backend paths still read `/datasets`; everything they return is a submission.
+ */
+import { apiFetch } from "@/app/lib/apiClient";
+import { DATASET_SAMPLE_ROW_LIMIT } from "@/app/lib/assessment/constants";
+import type {
+ AssessmentSubmission,
+ CreateSubmissionInput,
+ SubmissionPreviewPayload,
+} from "@/app/lib/types/assessment";
+
+const ENDPOINT = "/api/assessment/datasets";
+
+type Envelope = T | { data?: T };
+
+const unwrap = (response: Envelope, fallback: T): T =>
+ (response as { data?: T })?.data ?? (response as T) ?? fallback;
+
+export async function listSubmissions(
+ apiKey: string,
+): Promise {
+ const response = await apiFetch>(
+ ENDPOINT,
+ apiKey,
+ );
+ return Array.isArray(response) ? response : unwrap(response, []);
+}
+
+export async function getSubmissionPreview(
+ apiKey: string,
+ submissionId: string,
+): Promise {
+ const response = await apiFetch>(
+ `${ENDPOINT}/${submissionId}?limit_rows=${DATASET_SAMPLE_ROW_LIMIT}`,
+ apiKey,
+ );
+ const submission = unwrap(response, {} as AssessmentSubmission);
+ return {
+ total_items: submission.total_items,
+ preview: submission.preview ?? undefined,
+ };
+}
+
+export async function createSubmission(
+ apiKey: string,
+ { name, description, file }: CreateSubmissionInput,
+): Promise {
+ const body = new FormData();
+ body.append("dataset_name", name);
+ if (description) body.append("description", description);
+ body.append("file", file);
+
+ const response = await apiFetch>(
+ ENDPOINT,
+ apiKey,
+ { method: "POST", body },
+ );
+ return unwrap(response, {} as AssessmentSubmission);
+}
+
+export async function deleteSubmission(
+ apiKey: string,
+ submissionId: string,
+): Promise