Skip to content

Commit 403482a

Browse files
committed
Merge remote-tracking branch 'origin/dev' into dev
2 parents f4d3814 + 94976f9 commit 403482a

11 files changed

Lines changed: 387 additions & 33 deletions

File tree

‎app/components/assessment/home/RunRowActions.tsx‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
"use client";
22

33
import { EyeIcon } from "@/app/components/icons";
4+
import { useAssessmentData } from "@/app/hooks";
5+
import { loadSubmissionInputs } from "@/app/lib/assessment/submissionInputs";
46
import type {
57
HomeRunRow,
68
RunRowActionsProps,
@@ -15,12 +17,26 @@ function resultsHref(row: HomeRunRow): string {
1517
}
1618

1719
export default function RunRowActions({ row }: RunRowActionsProps) {
20+
const data = useAssessmentData();
1821
const href = resultsHref(row);
22+
const { submission_id: submissionId, total_items: totalItems } =
23+
row.assessment;
24+
25+
/* Warms the submission cache during the hover before the click, so the
26+
results sheet has its source columns by the time it paints. */
27+
const prefetchInputs = () => {
28+
if (!submissionId) return;
29+
void loadSubmissionInputs(data, submissionId, totalItems).catch(() => {
30+
// A cold cache is the only cost of a failed warm-up.
31+
});
32+
};
1933

2034
return (
2135
<div className="mt-2.5 flex flex-wrap items-center justify-end gap-2">
2236
<a
2337
href={href}
38+
onMouseEnter={prefetchInputs}
39+
onFocus={prefetchInputs}
2440
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"
2541
>
2642
<EyeIcon className="w-3.5 h-3.5" />

‎app/hooks/useRunResults.ts‎

Lines changed: 108 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1,31 +1,37 @@
11
"use client";
22

3-
/**
4-
* One run's results, through the data source: the raw rows (for the detail
5-
* modal) plus the table projection the grids render.
6-
*
7-
* Polls while the run is in flight and stops at a terminal status, so an open
8-
* results tab fills in as stages land.
9-
*/
10-
import { useCallback, useEffect, useRef, useState } from "react";
3+
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
114
import { useToast } from "@/app/hooks/useToast";
125
import { useAssessmentData } from "@/app/hooks/useAssessmentData";
136
import {
147
getAsyncErrorMessage,
158
jsonResultsToTableData,
169
normalizeStatus,
1710
} from "@/app/lib/assessment/results";
11+
import {
12+
buildColumnOrder,
13+
mergeSubmissionInputs,
14+
type SubmissionInputs,
15+
} from "@/app/lib/assessment/inputJoin";
16+
import { loadSubmissionInputs } from "@/app/lib/assessment/submissionInputs";
1817
import {
1918
RESULTS_POLL_INTERVAL_MS,
2019
SPREADSHEET_PREVIEW_ROW_LIMIT,
2120
TERMINAL_ASSESSMENT_STATUSES,
2221
} from "@/app/lib/assessment/constants";
2322
import type {
23+
AssessmentConfigRef,
2424
AssessmentStatusValue,
2525
BatchCounts,
2626
ResultsTarget,
2727
} from "@/app/lib/types/assessment";
2828

29+
/** A fetched extra plus the id it was fetched for, so a stale one is spottable. */
30+
interface OwnedBy<T> {
31+
owner: string;
32+
value: T;
33+
}
34+
2935
export interface UseRunResultsResult {
3036
results: Record<string, unknown>[];
3137
headers: string[];
@@ -44,39 +50,43 @@ export function useRunResults(
4450
const toast = useToast();
4551
const data = useAssessmentData();
4652
const [results, setResults] = useState<Record<string, unknown>[]>([]);
47-
const [table, setTable] = useState<{ headers: string[]; rows: string[][] }>({
48-
headers: [],
49-
rows: [],
50-
});
5153
const [status, setStatus] = useState<AssessmentStatusValue | null>(null);
5254
const [counts, setCounts] = useState<BatchCounts | null>(null);
5355
const [totalItems, setTotalItems] = useState(0);
56+
const [submissionId, setSubmissionId] = useState<string | null>(null);
57+
const [config, setConfig] = useState<AssessmentConfigRef | null>(null);
58+
const [inputs, setInputs] = useState<OwnedBy<SubmissionInputs> | null>(null);
59+
const [outputSchema, setOutputSchema] = useState<OwnedBy<Record<
60+
string,
61+
unknown
62+
> | null> | null>(null);
5463
const [isLoading, setIsLoading] = useState(true);
5564
const [error, setError] = useState<string | null>(null);
5665
const warnedRef = useRef(false);
57-
const cancelledRef = useRef(false);
66+
const targetRef = useRef<string | null>(null);
5867

5968
const assessmentId = target?.assessment_id ?? null;
6069
const method = target?.method ?? null;
70+
const targetKey = assessmentId && method ? `${assessmentId}:${method}` : null;
71+
const configKey = config ? `${config.id}@${config.version}` : null;
6172

6273
const load = useCallback(async () => {
6374
if (!assessmentId || !method) return;
75+
const startedFor = `${assessmentId}:${method}`;
76+
const isStale = () => targetRef.current !== startedFor;
6477
try {
6578
const payload = await data.getRunResults({
6679
assessment_id: assessmentId,
6780
method,
6881
});
69-
if (cancelledRef.current) return;
82+
if (isStale()) return;
7083

7184
setResults(payload.rows);
7285
setStatus(payload.status);
7386
setCounts(payload.counts);
7487
setTotalItems(payload.total_items);
75-
setTable(
76-
jsonResultsToTableData(payload.rows, {
77-
rowLimit: SPREADSHEET_PREVIEW_ROW_LIMIT,
78-
}),
79-
);
88+
setSubmissionId(payload.submission_id);
89+
setConfig(payload.config);
8090
setError(null);
8191

8292
if (
@@ -89,28 +99,98 @@ export function useRunResults(
8999
);
90100
}
91101
} catch (caught) {
92-
if (!cancelledRef.current) {
93-
setError(getAsyncErrorMessage("load results", caught));
94-
}
102+
if (!isStale()) setError(getAsyncErrorMessage("load results", caught));
95103
} finally {
96-
if (!cancelledRef.current) setIsLoading(false);
104+
if (!isStale()) setIsLoading(false);
97105
}
98106
}, [assessmentId, data, method, toast]);
99107

100108
useEffect(() => {
101-
cancelledRef.current = false;
102-
if (!assessmentId) {
109+
if (!assessmentId || !targetKey) {
103110
setError("Invalid assessment id.");
104111
setIsLoading(false);
105112
return;
106113
}
107114

115+
targetRef.current = targetKey;
116+
// The previous run's rows are not this run's; show nothing until it loads.
117+
setResults([]);
118+
setStatus(null);
119+
setCounts(null);
120+
setTotalItems(0);
121+
setSubmissionId(null);
122+
setConfig(null);
123+
warnedRef.current = false;
108124
setIsLoading(true);
109125
void load();
126+
127+
return () => {
128+
targetRef.current = null;
129+
};
130+
}, [assessmentId, load, targetKey]);
131+
132+
// The source rows, once per submission. Immutable, so polling never refetches.
133+
useEffect(() => {
134+
if (!submissionId) return;
135+
let cancelled = false;
136+
137+
void loadSubmissionInputs(data, submissionId, totalItems)
138+
.then((loaded) => {
139+
if (!cancelled && loaded.records.length > 0) {
140+
setInputs({ owner: submissionId, value: loaded });
141+
}
142+
})
143+
.catch(() => {
144+
// Source columns are additive; without them the results still stand.
145+
});
146+
110147
return () => {
111-
cancelledRef.current = true;
148+
cancelled = true;
112149
};
113-
}, [assessmentId, load]);
150+
}, [data, submissionId, totalItems]);
151+
152+
// The output schema fixes column order, so it follows the config, not the rows.
153+
useEffect(() => {
154+
if (!config?.id || !configKey) return;
155+
let cancelled = false;
156+
157+
void data
158+
.getAssessorVersion(config.id, config.version)
159+
.then((version) => {
160+
if (!cancelled) {
161+
setOutputSchema({ owner: configKey, value: version.output_schema });
162+
}
163+
})
164+
.catch(() => {
165+
// Without a schema the columns keep their discovered order.
166+
});
167+
168+
return () => {
169+
cancelled = true;
170+
};
171+
}, [config?.id, config?.version, configKey, data]);
172+
173+
// A fetch that outlived its run must not colour the next one.
174+
const ownInputs =
175+
inputs && inputs.owner === submissionId ? inputs.value : null;
176+
const ownSchema =
177+
outputSchema && outputSchema.owner === configKey
178+
? outputSchema.value
179+
: null;
180+
181+
const joined = useMemo(
182+
() => (ownInputs ? mergeSubmissionInputs(results, ownInputs) : results),
183+
[ownInputs, results],
184+
);
185+
186+
const table = useMemo(
187+
() =>
188+
jsonResultsToTableData(joined, {
189+
rowLimit: SPREADSHEET_PREVIEW_ROW_LIMIT,
190+
columnOrder: buildColumnOrder(ownInputs?.headers ?? [], ownSchema),
191+
}),
192+
[joined, ownInputs, ownSchema],
193+
);
114194

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

125205
return {
126-
results,
206+
results: joined,
127207
headers: table.headers,
128208
rows: table.rows,
129209
status,

‎app/lib/assessment/api/runs.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,5 +80,7 @@ export async function getRunResults(
8080
rows: flattenBatchDetail(detail),
8181
total_items: detail.total_items ?? 0,
8282
counts: detail.counts ?? null,
83+
submission_id: detail.submission_id ?? null,
84+
config: detail.config ?? null,
8385
};
8486
}

‎app/lib/assessment/api/submissions.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,9 +31,10 @@ export async function listSubmissions(
3131
export async function getSubmissionPreview(
3232
apiKey: string,
3333
submissionId: string,
34+
limitRows: number = DATASET_SAMPLE_ROW_LIMIT,
3435
): Promise<SubmissionPreviewPayload> {
3536
const response = await apiFetch<Envelope<AssessmentSubmission>>(
36-
`${ENDPOINT}/${submissionId}?limit_rows=${DATASET_SAMPLE_ROW_LIMIT}`,
37+
`${ENDPOINT}/${submissionId}?limit_rows=${limitRows}`,
3738
apiKey,
3839
);
3940
const submission = unwrap(response, {} as AssessmentSubmission);

‎app/lib/assessment/apiSource.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,8 @@ export function createApiAssessmentSource(
1111
): AssessmentDataSource {
1212
return {
1313
listSubmissions: () => submissions.listSubmissions(apiKey),
14-
getSubmissionPreview: (submissionId) =>
15-
submissions.getSubmissionPreview(apiKey, submissionId),
14+
getSubmissionPreview: (submissionId, limitRows) =>
15+
submissions.getSubmissionPreview(apiKey, submissionId, limitRows),
1616
createSubmission: (input) => submissions.createSubmission(apiKey, input),
1717
deleteSubmission: (submissionId) =>
1818
submissions.deleteSubmission(apiKey, submissionId),

0 commit comments

Comments
 (0)