Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions app/components/assessment/home/RunRowActions.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
"use client";

import { EyeIcon } from "@/app/components/icons";
import { useAssessmentData } from "@/app/hooks";
import { loadSubmissionInputs } from "@/app/lib/assessment/submissionInputs";
import type {
HomeRunRow,
RunRowActionsProps,
Expand All @@ -15,12 +17,26 @@ function resultsHref(row: HomeRunRow): string {
}

export default function RunRowActions({ row }: RunRowActionsProps) {
const data = useAssessmentData();
const href = resultsHref(row);
const { submission_id: submissionId, total_items: totalItems } =
row.assessment;

/* Warms the submission cache during the hover before the click, so the
results sheet has its source columns by the time it paints. */
const prefetchInputs = () => {
if (!submissionId) return;
void loadSubmissionInputs(data, submissionId, totalItems).catch(() => {
// A cold cache is the only cost of a failed warm-up.
});
};

return (
<div className="mt-2.5 flex flex-wrap items-center justify-end gap-2">
<a
href={href}
onMouseEnter={prefetchInputs}
onFocus={prefetchInputs}
className="inline-flex cursor-pointer items-center gap-2 rounded-md border border-border bg-bg-primary px-3 py-1.5 text-sm font-medium text-text-primary transition-colors hover:bg-neutral-50"
>
<EyeIcon className="w-3.5 h-3.5" />
Expand Down
136 changes: 108 additions & 28 deletions app/hooks/useRunResults.ts
Original file line number Diff line number Diff line change
@@ -1,31 +1,37 @@
"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 { useCallback, useEffect, useMemo, 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 {
buildColumnOrder,
mergeSubmissionInputs,
type SubmissionInputs,
} from "@/app/lib/assessment/inputJoin";
import { loadSubmissionInputs } from "@/app/lib/assessment/submissionInputs";
import {
RESULTS_POLL_INTERVAL_MS,
SPREADSHEET_PREVIEW_ROW_LIMIT,
TERMINAL_ASSESSMENT_STATUSES,
} from "@/app/lib/assessment/constants";
import type {
AssessmentConfigRef,
AssessmentStatusValue,
BatchCounts,
ResultsTarget,
} from "@/app/lib/types/assessment";

/** A fetched extra plus the id it was fetched for, so a stale one is spottable. */
interface OwnedBy<T> {
owner: string;
value: T;
}

export interface UseRunResultsResult {
results: Record<string, unknown>[];
headers: string[];
Expand All @@ -44,39 +50,43 @@ export function useRunResults(
const toast = useToast();
const data = useAssessmentData();
const [results, setResults] = useState<Record<string, unknown>[]>([]);
const [table, setTable] = useState<{ headers: string[]; rows: string[][] }>({
headers: [],
rows: [],
});
const [status, setStatus] = useState<AssessmentStatusValue | null>(null);
const [counts, setCounts] = useState<BatchCounts | null>(null);
const [totalItems, setTotalItems] = useState(0);
const [submissionId, setSubmissionId] = useState<string | null>(null);
const [config, setConfig] = useState<AssessmentConfigRef | null>(null);
const [inputs, setInputs] = useState<OwnedBy<SubmissionInputs> | null>(null);
const [outputSchema, setOutputSchema] = useState<OwnedBy<Record<
string,
unknown
> | null> | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const warnedRef = useRef(false);
const cancelledRef = useRef(false);
const targetRef = useRef<string | null>(null);

const assessmentId = target?.assessment_id ?? null;
const method = target?.method ?? null;
const targetKey = assessmentId && method ? `${assessmentId}:${method}` : null;
const configKey = config ? `${config.id}@${config.version}` : null;

const load = useCallback(async () => {
if (!assessmentId || !method) return;
const startedFor = `${assessmentId}:${method}`;
const isStale = () => targetRef.current !== startedFor;
try {
const payload = await data.getRunResults({
assessment_id: assessmentId,
method,
});
if (cancelledRef.current) return;
if (isStale()) return;

setResults(payload.rows);
setStatus(payload.status);
setCounts(payload.counts);
setTotalItems(payload.total_items);
setTable(
jsonResultsToTableData(payload.rows, {
rowLimit: SPREADSHEET_PREVIEW_ROW_LIMIT,
}),
);
setSubmissionId(payload.submission_id);
setConfig(payload.config);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
setError(null);

if (
Expand All @@ -89,28 +99,98 @@ export function useRunResults(
);
}
} catch (caught) {
if (!cancelledRef.current) {
setError(getAsyncErrorMessage("load results", caught));
}
if (!isStale()) setError(getAsyncErrorMessage("load results", caught));
} finally {
if (!cancelledRef.current) setIsLoading(false);
if (!isStale()) setIsLoading(false);
}
}, [assessmentId, data, method, toast]);

useEffect(() => {
cancelledRef.current = false;
if (!assessmentId) {
if (!assessmentId || !targetKey) {
setError("Invalid assessment id.");
setIsLoading(false);
return;
}

targetRef.current = targetKey;
// The previous run's rows are not this run's; show nothing until it loads.
setResults([]);
setStatus(null);
setCounts(null);
setTotalItems(0);
setSubmissionId(null);
setConfig(null);
warnedRef.current = false;
setIsLoading(true);
void load();

return () => {
targetRef.current = null;
};
}, [assessmentId, load, targetKey]);

// The source rows, once per submission. Immutable, so polling never refetches.
useEffect(() => {
if (!submissionId) return;
let cancelled = false;

void loadSubmissionInputs(data, submissionId, totalItems)
.then((loaded) => {
if (!cancelled && loaded.records.length > 0) {
setInputs({ owner: submissionId, value: loaded });
}
})
.catch(() => {
// Source columns are additive; without them the results still stand.
});

return () => {
cancelledRef.current = true;
cancelled = true;
};
}, [assessmentId, load]);
}, [data, submissionId, totalItems]);

// The output schema fixes column order, so it follows the config, not the rows.
useEffect(() => {
if (!config?.id || !configKey) return;
let cancelled = false;

void data
.getAssessorVersion(config.id, config.version)
.then((version) => {
if (!cancelled) {
setOutputSchema({ owner: configKey, value: version.output_schema });
}
})
.catch(() => {
// Without a schema the columns keep their discovered order.
});

return () => {
cancelled = true;
};
}, [config?.id, config?.version, configKey, data]);

// A fetch that outlived its run must not colour the next one.
const ownInputs =
inputs && inputs.owner === submissionId ? inputs.value : null;
const ownSchema =
outputSchema && outputSchema.owner === configKey
? outputSchema.value
: null;

const joined = useMemo(
() => (ownInputs ? mergeSubmissionInputs(results, ownInputs) : results),
[ownInputs, results],
);

const table = useMemo(
() =>
jsonResultsToTableData(joined, {
rowLimit: SPREADSHEET_PREVIEW_ROW_LIMIT,
columnOrder: buildColumnOrder(ownInputs?.headers ?? [], ownSchema),
}),
[joined, ownInputs, ownSchema],
);

const isPolling =
status !== null &&
Expand All @@ -123,7 +203,7 @@ export function useRunResults(
}, [isPolling, load]);

return {
results,
results: joined,
headers: table.headers,
rows: table.rows,
status,
Expand Down
2 changes: 2 additions & 0 deletions app/lib/assessment/api/runs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,5 +80,7 @@ export async function getRunResults(
rows: flattenBatchDetail(detail),
total_items: detail.total_items ?? 0,
counts: detail.counts ?? null,
submission_id: detail.submission_id ?? null,
config: detail.config ?? null,
};
}
3 changes: 2 additions & 1 deletion app/lib/assessment/api/submissions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,9 +31,10 @@ export async function listSubmissions(
export async function getSubmissionPreview(
apiKey: string,
submissionId: string,
limitRows: number = DATASET_SAMPLE_ROW_LIMIT,
): Promise<SubmissionPreviewPayload> {
const response = await apiFetch<Envelope<AssessmentSubmission>>(
`${ENDPOINT}/${submissionId}?limit_rows=${DATASET_SAMPLE_ROW_LIMIT}`,
`${ENDPOINT}/${submissionId}?limit_rows=${limitRows}`,
apiKey,
);
const submission = unwrap(response, {} as AssessmentSubmission);
Expand Down
4 changes: 2 additions & 2 deletions app/lib/assessment/apiSource.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,8 @@ export function createApiAssessmentSource(
): AssessmentDataSource {
return {
listSubmissions: () => submissions.listSubmissions(apiKey),
getSubmissionPreview: (submissionId) =>
submissions.getSubmissionPreview(apiKey, submissionId),
getSubmissionPreview: (submissionId, limitRows) =>
submissions.getSubmissionPreview(apiKey, submissionId, limitRows),
createSubmission: (input) => submissions.createSubmission(apiKey, input),
deleteSubmission: (submissionId) =>
submissions.deleteSubmission(apiKey, submissionId),
Expand Down
Loading
Loading