+
+ {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..ff463380 100644
--- a/app/hooks/index.ts
+++ b/app/hooks/index.ts
@@ -1,6 +1,13 @@
+export { useAssessmentData } from "./useAssessmentData";
+export { useAssessmentHome } from "./useAssessmentHome";
+export { useAssessmentHomeData } from "./useAssessmentHomeData";
export { useConfigs } from "./useConfigs";
export { useDebouncedValue } from "./useDebouncedValue";
-export { useOnboardingActivation } from "./useOnboardingActivation";
export { useInfiniteScroll } from "./useInfiniteScroll";
+export { useOnboardingActivation } from "./useOnboardingActivation";
export { usePaginatedList } from "./usePaginatedList";
+export { usePromptMentions } from "./usePromptMentions";
+export { useRunResults } from "./useRunResults";
+export { useSubmissionStep } from "./useSubmissionStep";
+export { useSyncedScroll } from "./useSyncedScroll";
export type { UsePaginatedListResult } from "@/app/lib/types/pagination";
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..8ee10f67
--- /dev/null
+++ b/app/hooks/useAssessmentHome.ts
@@ -0,0 +1,161 @@
+"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,
+ };
+}
diff --git a/app/hooks/useAssessmentHomeData.ts b/app/hooks/useAssessmentHomeData.ts
new file mode 100644
index 00000000..15a3e051
--- /dev/null
+++ b/app/hooks/useAssessmentHomeData.ts
@@ -0,0 +1,225 @@
+"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 { getAsyncErrorMessage } from "@/app/lib/assessment/results";
+import type {
+ AssessmentRun,
+ AssessorSummary,
+ AssessorVersion,
+} 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;
+}
+
+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 [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 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,
+ };
+}
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..b01f1273
--- /dev/null
+++ b/app/hooks/usePromptMentions.ts
@@ -0,0 +1,310 @@
+"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 {
+ MentionColumnOption,
+ MentionStage,
+ MentionStrictOption,
+ MentionTypeOption,
+ PromptFieldType,
+ UsePromptMentionsParams,
+ UsePromptMentionsResult,
+} from "@/app/lib/types/assessment";
+
+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 };
+}
+
+export function usePromptMentions({
+ columns,
+ fieldTypes,
+ fieldStrict,
+ value,
+ onChange,
+ onFieldType,
+ onFieldStrict,
+}: UsePromptMentionsParams): UsePromptMentionsResult {
+ 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..f1c49b70
--- /dev/null
+++ b/app/hooks/useRunResults.ts
@@ -0,0 +1,136 @@
+"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..9cefa821
--- /dev/null
+++ b/app/hooks/useSubmissionForm.ts
@@ -0,0 +1,84 @@
+"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";
+import type { UseSubmissionFormResult } from "@/app/lib/types/assessment";
+
+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..14d37433
--- /dev/null
+++ b/app/hooks/useSubmissionList.ts
@@ -0,0 +1,62 @@
+"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..94053980
--- /dev/null
+++ b/app/hooks/useSubmissionStep.ts
@@ -0,0 +1,161 @@
+"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 } 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 {
+ DatasetViewModalData,
+ UseSubmissionStepResult,
+} from "@/app/lib/types/assessment";
+
+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 {
+ await apiFetch(`${ENDPOINT}/${submissionId}`, apiKey, {
+ method: "DELETE",
+ acceptEmpty: true,
+ });
+}
diff --git a/app/lib/assessment/apiSource.ts b/app/lib/assessment/apiSource.ts
new file mode 100644
index 00000000..ae101be0
--- /dev/null
+++ b/app/lib/assessment/apiSource.ts
@@ -0,0 +1,35 @@
+/**
+ * API-backed assessment data source. Composition only — fetchers live in `api/`.
+ */
+import * as assessors from "@/app/lib/assessment/api/assessors";
+import * as runs from "@/app/lib/assessment/api/runs";
+import * as submissions from "@/app/lib/assessment/api/submissions";
+import type { AssessmentDataSource } from "@/app/lib/types/assessment";
+
+export function createApiAssessmentSource(
+ apiKey: string,
+): AssessmentDataSource {
+ return {
+ listSubmissions: () => submissions.listSubmissions(apiKey),
+ getSubmissionPreview: (submissionId) =>
+ submissions.getSubmissionPreview(apiKey, submissionId),
+ createSubmission: (input) => submissions.createSubmission(apiKey, input),
+ deleteSubmission: (submissionId) =>
+ submissions.deleteSubmission(apiKey, submissionId),
+
+ listAssessors: (page) => assessors.listAssessors(apiKey, page),
+ listAssessorVersions: (configId) =>
+ assessors.listAssessorVersions(apiKey, configId),
+ getAssessorVersion: (configId, version) =>
+ assessors.getAssessorVersion(apiKey, configId, version),
+ deleteAssessor: (configId) => assessors.deleteAssessor(apiKey, configId),
+ deleteAssessorVersion: (configId, version) =>
+ assessors.deleteAssessorVersion(apiKey, configId, version),
+ saveAssessorVersion: (input) =>
+ assessors.saveAssessorVersion(apiKey, input),
+
+ listAssessments: (query) => runs.listAssessments(apiKey, query),
+ createRun: (input) => runs.createRun(apiKey, input),
+ getRunResults: (target) => runs.getRunResults(apiKey, target),
+ };
+}
diff --git a/app/lib/assessment/batchResults.ts b/app/lib/assessment/batchResults.ts
new file mode 100644
index 00000000..fe77462e
--- /dev/null
+++ b/app/lib/assessment/batchResults.ts
@@ -0,0 +1,119 @@
+/**
+ * Flattens the nested BATCH result into the wide row the results table and detail
+ * modal already read. No React, no network.
+ *
+ * Kept on this side deliberately: `output.assessment` keys come from the user's own
+ * json_output_schema, and the `X_score`/`X_reason` pairing is this UI's convention,
+ * not something to freeze into a public API contract.
+ */
+import {
+ ASSESSMENT_OUTPUT_KEY_PREFIX,
+ MAX_OUTPUT_FLATTEN_DEPTH,
+ REASON_OBJECT_KEYS,
+ RESULT_REASON_SUFFIX,
+ RESULT_SCORE_SUFFIX,
+ SCORE_OBJECT_KEYS,
+ PREFILTER_DECISION_KEY,
+ PREFILTER_REASONING_KEY,
+} from "@/app/lib/assessment/constants";
+import type {
+ AssessmentDetail,
+ BatchItemOutput,
+ BatchResultRow,
+} from "@/app/lib/types/assessment";
+
+type FlatRow = Record