@crowandraven/evalkeeper 0.2.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/common.js +4 -2
- package/dist/common.js.map +1 -1
- package/dist/importCases.d.ts +94 -0
- package/dist/importCases.js +185 -0
- package/dist/importCases.js.map +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/labels.d.ts +9 -3
- package/dist/labels.js +2 -0
- package/dist/labels.js.map +1 -1
- package/dist/memoryStore.js +16 -2
- package/dist/memoryStore.js.map +1 -1
- package/dist/models.d.ts +17 -6
- package/dist/models.js +29 -5
- package/dist/models.js.map +1 -1
- package/dist/runner.d.ts +16 -1
- package/dist/runner.js +68 -18
- package/dist/runner.js.map +1 -1
- package/dist/settings.js +1 -0
- package/dist/settings.js.map +1 -1
- package/dist/stats.d.ts +5 -1
- package/dist/stats.js +21 -6
- package/dist/stats.js.map +1 -1
- package/dist/store.d.ts +24 -2
- package/dist/testing.js +77 -0
- package/dist/testing.js.map +1 -1
- package/dist/types.d.ts +5 -0
- package/dist/types.js.map +1 -1
- package/dist/weekly.js +3 -2
- package/dist/weekly.js.map +1 -1
- package/package.json +1 -1
- package/src/common.ts +5 -2
- package/src/importCases.ts +252 -0
- package/src/index.ts +1 -0
- package/src/labels.ts +9 -4
- package/src/memoryStore.ts +24 -2
- package/src/models.ts +35 -7
- package/src/runner.ts +126 -49
- package/src/settings.ts +1 -0
- package/src/stats.ts +19 -6
- package/src/store.ts +28 -2
- package/src/testing.ts +91 -0
- package/src/types.ts +5 -0
- package/src/weekly.ts +3 -2
- package/templates/app/evals/[suite]/page.tsx +15 -10
- package/templates/app/evals/feedback/page.tsx +11 -6
- package/templates/app/evals/page.tsx +59 -15
- package/templates/components/evalkeeper/AddCase.tsx +2 -1
- package/templates/components/evalkeeper/AnswerKey.tsx +14 -15
- package/templates/components/evalkeeper/ConfirmAnswer.tsx +46 -0
- package/templates/components/evalkeeper/RefreshWhile.tsx +16 -7
- package/templates/components/evalkeeper/RollBack.tsx +62 -0
- package/templates/components/evalkeeper/SettingsHistory.tsx +6 -20
- package/templates/components/evalkeeper/Submit.tsx +13 -4
|
@@ -7,7 +7,6 @@ import {
|
|
|
7
7
|
money,
|
|
8
8
|
probabilityQuality,
|
|
9
9
|
perAnswer,
|
|
10
|
-
type ReasoningEffort,
|
|
11
10
|
retiresLabel,
|
|
12
11
|
seconds,
|
|
13
12
|
seriousMistakes,
|
|
@@ -56,17 +55,23 @@ export default async function SuitePage({
|
|
|
56
55
|
}) {
|
|
57
56
|
await requireAdmin();
|
|
58
57
|
const { suite: key } = await params;
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
//
|
|
62
|
-
|
|
58
|
+
const operation = SUITE_BY_KEY[key]?.operation;
|
|
59
|
+
if (!operation) notFound();
|
|
60
|
+
// The board and the settings load together: on a remote database each
|
|
61
|
+
// waits on round trips, so one after the other doubles the wait.
|
|
62
|
+
const [b, [history, liveSettings]] = await Promise.all([
|
|
63
|
+
runner.board(key),
|
|
64
|
+
// Records a deploy if the instructions changed under the live
|
|
65
|
+
// settings, before reading them.
|
|
66
|
+
settings
|
|
67
|
+
.syncPrompt(operation)
|
|
68
|
+
.then(() =>
|
|
69
|
+
Promise.all([settings.history(operation), settings.current(operation)]),
|
|
70
|
+
),
|
|
71
|
+
]);
|
|
63
72
|
const { suite, rows } = b;
|
|
64
73
|
const s = suggest(rows, suite.checks, suite.priority, MODELS);
|
|
65
74
|
const testing = rows.some((r) => r.testing);
|
|
66
|
-
const [history, liveSettings] = await Promise.all([
|
|
67
|
-
settings.history(suite.operation),
|
|
68
|
-
settings.current(suite.operation),
|
|
69
|
-
]);
|
|
70
75
|
// What testing each model would cost now, counting answers it would reuse.
|
|
71
76
|
const estimates = new Map(
|
|
72
77
|
await Promise.all(
|
|
@@ -75,7 +80,7 @@ export default async function SuitePage({
|
|
|
75
80
|
[
|
|
76
81
|
r.key,
|
|
77
82
|
await runner.estimateRun(key, r.model, "ALL", 1, {
|
|
78
|
-
reasoningEffort: r.effort
|
|
83
|
+
reasoningEffort: r.effort,
|
|
79
84
|
}),
|
|
80
85
|
] as const,
|
|
81
86
|
),
|
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import Link from "next/link";
|
|
2
1
|
import {
|
|
3
2
|
asText,
|
|
4
3
|
thumbsSummary,
|
|
@@ -103,11 +102,17 @@ export default async function FeedbackPage() {
|
|
|
103
102
|
</h2>
|
|
104
103
|
{open.length === 0 && (
|
|
105
104
|
<p className="text-sm text-muted">
|
|
106
|
-
Nothing waiting.
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
105
|
+
Nothing waiting. Ratings show up here once an AI feature has a
|
|
106
|
+
thumbs up and down (step 8 of{" "}
|
|
107
|
+
<a
|
|
108
|
+
href="https://github.com/seanhudson/evalkeeper/blob/main/docs/adding-to-your-app.md#8-add-thumbs-to-your-ai-answers-optional"
|
|
109
|
+
target="_blank"
|
|
110
|
+
rel="noreferrer"
|
|
111
|
+
className="underline"
|
|
112
|
+
>
|
|
113
|
+
the guide
|
|
114
|
+
</a>
|
|
115
|
+
).
|
|
111
116
|
</p>
|
|
112
117
|
)}
|
|
113
118
|
<div className="space-y-3">
|
|
@@ -120,6 +120,7 @@ export default async function EvalsHub() {
|
|
|
120
120
|
Suites
|
|
121
121
|
<InfoTip term="suites" />
|
|
122
122
|
</h2>
|
|
123
|
+
{boards.length === 0 && <NoSuites />}
|
|
123
124
|
<div className="space-y-4">
|
|
124
125
|
{boards.map((b, i) => {
|
|
125
126
|
const h = suiteHealth(
|
|
@@ -147,6 +148,45 @@ export default async function EvalsHub() {
|
|
|
147
148
|
);
|
|
148
149
|
}
|
|
149
150
|
|
|
151
|
+
/** Before the first suite: what a suite is, and how to add one. */
|
|
152
|
+
function NoSuites() {
|
|
153
|
+
return (
|
|
154
|
+
<Card className="readable space-y-3 text-sm">
|
|
155
|
+
<p className="font-semibold">No suites yet</p>
|
|
156
|
+
<p className="text-muted">
|
|
157
|
+
A suite tests one AI feature: its cases, the answers a person confirms,
|
|
158
|
+
and the checks that grade any model against them. To add one:
|
|
159
|
+
</p>
|
|
160
|
+
<ol className="list-decimal space-y-2 pl-4">
|
|
161
|
+
<li>
|
|
162
|
+
Write it: the input and answer shapes with zod, two or three checks,{" "}
|
|
163
|
+
<code>run</code>, and <code>grade</code>.
|
|
164
|
+
</li>
|
|
165
|
+
<li>
|
|
166
|
+
Add it to <code>SUITES</code> in <code>lib/evalkeeper/index.ts</code>,
|
|
167
|
+
with its instructions hash.
|
|
168
|
+
</li>
|
|
169
|
+
<li>
|
|
170
|
+
Add cases (here, or with <code>importCases</code> from files you
|
|
171
|
+
have), confirm their answers, and test models.
|
|
172
|
+
</li>
|
|
173
|
+
</ol>
|
|
174
|
+
<p className="text-muted">
|
|
175
|
+
Step by step:{" "}
|
|
176
|
+
<a
|
|
177
|
+
href="https://github.com/seanhudson/evalkeeper/blob/main/docs/adding-to-your-app.md#5-write-your-first-suite"
|
|
178
|
+
target="_blank"
|
|
179
|
+
rel="noreferrer"
|
|
180
|
+
className="underline"
|
|
181
|
+
>
|
|
182
|
+
Write your first suite
|
|
183
|
+
</a>
|
|
184
|
+
.
|
|
185
|
+
</p>
|
|
186
|
+
</Card>
|
|
187
|
+
);
|
|
188
|
+
}
|
|
189
|
+
|
|
150
190
|
function Stat({
|
|
151
191
|
title,
|
|
152
192
|
term,
|
|
@@ -239,21 +279,21 @@ function SuiteCard({
|
|
|
239
279
|
</div>
|
|
240
280
|
|
|
241
281
|
{s && (
|
|
242
|
-
<
|
|
243
|
-
<
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
</
|
|
282
|
+
<p className="text-sm text-muted">
|
|
283
|
+
<span className="font-semibold text-ink">{label(s.model)}</span>
|
|
284
|
+
{s.fallbackModel ? (
|
|
285
|
+
<>
|
|
286
|
+
{" "}
|
|
287
|
+
· fallback{" "}
|
|
288
|
+
<span className="font-semibold text-ink">
|
|
289
|
+
{label(s.fallbackModel)}
|
|
290
|
+
</span>
|
|
291
|
+
</>
|
|
292
|
+
) : (
|
|
293
|
+
" · no fallback"
|
|
294
|
+
)}
|
|
295
|
+
{s.reasoningEffort ? ` · reasoning ${s.reasoningEffort}` : ""}
|
|
296
|
+
</p>
|
|
257
297
|
)}
|
|
258
298
|
|
|
259
299
|
<p className="text-sm">
|
|
@@ -289,6 +329,10 @@ function SuiteCard({
|
|
|
289
329
|
<summary className="flex list-none items-center justify-between gap-2 p-3 text-sm font-medium">
|
|
290
330
|
Versions
|
|
291
331
|
<span className="flex items-center gap-1 text-xs font-normal text-muted">
|
|
332
|
+
<span className="font-medium text-ink">
|
|
333
|
+
v{version?.number ?? 1}
|
|
334
|
+
</span>{" "}
|
|
335
|
+
{since ? `live since ${day(since)}` : "defaults from code"} ·{" "}
|
|
292
336
|
{versions} version{versions === 1 ? "" : "s"}
|
|
293
337
|
<ChevronDown
|
|
294
338
|
aria-hidden
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import { useState } from "react";
|
|
4
4
|
import { Plus } from "lucide-react";
|
|
5
|
+
import { QUIET_BUTTON } from "./Submit";
|
|
5
6
|
import { Card } from "./ui";
|
|
6
7
|
|
|
7
8
|
/**
|
|
@@ -31,7 +32,7 @@ export function AddCaseHeader({
|
|
|
31
32
|
onClick={() => {
|
|
32
33
|
setOpen(!open);
|
|
33
34
|
}}
|
|
34
|
-
className=
|
|
35
|
+
className={`inline-flex items-center gap-1.5 rounded-md px-3 py-1.5 text-sm ${QUIET_BUTTON}`}
|
|
35
36
|
>
|
|
36
37
|
<Plus aria-hidden className="h-4 w-4" />
|
|
37
38
|
Add a case
|
|
@@ -7,17 +7,13 @@ import {
|
|
|
7
7
|
type BoardRow,
|
|
8
8
|
type CheckResult,
|
|
9
9
|
} from "@crowandraven/evalkeeper";
|
|
10
|
-
import {
|
|
11
|
-
addCase,
|
|
12
|
-
confirmAnswer,
|
|
13
|
-
editAnswer,
|
|
14
|
-
retireCase,
|
|
15
|
-
} from "@/lib/evalkeeper/actions";
|
|
10
|
+
import { addCase, editAnswer, retireCase } from "@/lib/evalkeeper/actions";
|
|
16
11
|
import { Check, ChevronRight, CircleAlert, X } from "lucide-react";
|
|
17
12
|
import { store } from "@/lib/evalkeeper";
|
|
18
13
|
import { FieldValues, Fields } from "./Fields";
|
|
19
14
|
import { AddCaseHeader } from "./AddCase";
|
|
20
15
|
import { CaseMenu } from "./CaseMenu";
|
|
16
|
+
import { ConfirmAnswer } from "./ConfirmAnswer";
|
|
21
17
|
import { Submit } from "./Submit";
|
|
22
18
|
import { Chip, type ChipTone } from "./ui";
|
|
23
19
|
import { InfoTip, Tip } from "./Tip";
|
|
@@ -64,9 +60,12 @@ export async function AnswerKey({
|
|
|
64
60
|
r.test && !r.variant ? [[r.test.runId, r.label] as const] : [],
|
|
65
61
|
),
|
|
66
62
|
);
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
63
|
+
// One query where the store can: one per test is slow on a remote
|
|
64
|
+
// database.
|
|
65
|
+
const runIds = [...modelByRun.keys()];
|
|
66
|
+
const outputs = store.listOutputsFor
|
|
67
|
+
? await store.listOutputsFor(runIds)
|
|
68
|
+
: (await Promise.all(runIds.map((id) => store.listOutputs(id)))).flat();
|
|
70
69
|
const byCase = new Map<string, { model: string; output: OutputRecord }[]>();
|
|
71
70
|
for (const o of outputs) {
|
|
72
71
|
byCase.set(o.caseId, [
|
|
@@ -189,7 +188,11 @@ function CaseItem({
|
|
|
189
188
|
const tags = Array.isArray(c.tags) ? c.tags : [];
|
|
190
189
|
|
|
191
190
|
return (
|
|
192
|
-
<details
|
|
191
|
+
<details
|
|
192
|
+
className="group rounded-md border border-line bg-card"
|
|
193
|
+
// Where ConfirmAnswer moves on to after confirming the case above.
|
|
194
|
+
data-pending={answer?.status === "PENDING" ? "true" : undefined}
|
|
195
|
+
>
|
|
193
196
|
<summary className="flex cursor-pointer list-none flex-wrap items-center justify-between gap-2 p-3 text-sm">
|
|
194
197
|
<span className="font-medium">
|
|
195
198
|
<ChevronRight
|
|
@@ -312,11 +315,7 @@ function CaseItem({
|
|
|
312
315
|
)}
|
|
313
316
|
|
|
314
317
|
{answer?.status === "PENDING" && (
|
|
315
|
-
<
|
|
316
|
-
<input type="hidden" name="suite" value={suite.key} />
|
|
317
|
-
<input type="hidden" name="label" value={answer.id} />
|
|
318
|
-
<Submit>Confirm this answer</Submit>
|
|
319
|
-
</form>
|
|
318
|
+
<ConfirmAnswer suite={suite.key} labelId={answer.id} />
|
|
320
319
|
)}
|
|
321
320
|
|
|
322
321
|
<CaseMenu
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { confirmAnswer } from "@/lib/evalkeeper/actions";
|
|
4
|
+
import { Submit } from "./Submit";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Confirms a case's pending answer and moves straight on: this case closes
|
|
8
|
+
* and the next one waiting for a confirmation opens, while the save and
|
|
9
|
+
* the page's refresh finish behind it. Reviewing a run of drafts shouldn't
|
|
10
|
+
* wait on the whole page each time.
|
|
11
|
+
*/
|
|
12
|
+
export function ConfirmAnswer({
|
|
13
|
+
suite,
|
|
14
|
+
labelId,
|
|
15
|
+
}: {
|
|
16
|
+
suite: string;
|
|
17
|
+
labelId: string;
|
|
18
|
+
}) {
|
|
19
|
+
return (
|
|
20
|
+
<form
|
|
21
|
+
action={confirmAnswer}
|
|
22
|
+
onSubmit={(e) => {
|
|
23
|
+
const here = e.currentTarget.closest("details");
|
|
24
|
+
if (!here) return;
|
|
25
|
+
here.open = false;
|
|
26
|
+
let next = here.nextElementSibling;
|
|
27
|
+
while (
|
|
28
|
+
next &&
|
|
29
|
+
!(
|
|
30
|
+
next instanceof HTMLDetailsElement &&
|
|
31
|
+
next.dataset.pending === "true"
|
|
32
|
+
)
|
|
33
|
+
)
|
|
34
|
+
next = next.nextElementSibling;
|
|
35
|
+
if (next instanceof HTMLDetailsElement) {
|
|
36
|
+
next.open = true;
|
|
37
|
+
next.scrollIntoView({ block: "nearest" });
|
|
38
|
+
}
|
|
39
|
+
}}
|
|
40
|
+
>
|
|
41
|
+
<input type="hidden" name="suite" value={suite} />
|
|
42
|
+
<input type="hidden" name="label" value={labelId} />
|
|
43
|
+
<Submit>Confirm this answer</Submit>
|
|
44
|
+
</form>
|
|
45
|
+
);
|
|
46
|
+
}
|
|
@@ -1,9 +1,15 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
3
|
import { useRouter } from "next/navigation";
|
|
4
|
-
import { useEffect } from "react";
|
|
4
|
+
import { useEffect, useTransition } from "react";
|
|
5
5
|
|
|
6
|
-
/**
|
|
6
|
+
/**
|
|
7
|
+
* Refreshes the page every few seconds while something runs, waiting for
|
|
8
|
+
* each refresh to land before scheduling the next. A refresh on a timer
|
|
9
|
+
* cancels one still in flight, so when the page takes longer to render
|
|
10
|
+
* than the interval (the board against a remote database), no refresh ever
|
|
11
|
+
* lands and the page never updates (#25).
|
|
12
|
+
*/
|
|
7
13
|
export function RefreshWhile({
|
|
8
14
|
active,
|
|
9
15
|
ms = 2000,
|
|
@@ -12,14 +18,17 @@ export function RefreshWhile({
|
|
|
12
18
|
ms?: number;
|
|
13
19
|
}) {
|
|
14
20
|
const router = useRouter();
|
|
21
|
+
const [refreshing, startTransition] = useTransition();
|
|
15
22
|
useEffect(() => {
|
|
16
|
-
if (!active) return;
|
|
17
|
-
const t =
|
|
18
|
-
|
|
23
|
+
if (!active || refreshing) return;
|
|
24
|
+
const t = setTimeout(() => {
|
|
25
|
+
startTransition(() => {
|
|
26
|
+
router.refresh();
|
|
27
|
+
});
|
|
19
28
|
}, ms);
|
|
20
29
|
return () => {
|
|
21
|
-
|
|
30
|
+
clearTimeout(t);
|
|
22
31
|
};
|
|
23
|
-
}, [active, ms, router]);
|
|
32
|
+
}, [active, refreshing, ms, router]);
|
|
24
33
|
return null;
|
|
25
34
|
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useState } from "react";
|
|
4
|
+
import { rollBackSettings } from "@/lib/evalkeeper/actions";
|
|
5
|
+
import { Submit } from "./Submit";
|
|
6
|
+
import { Tip } from "./Tip";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Rolls a feature back to an earlier version. The button stays off until
|
|
10
|
+
* there's a note saying why: the note is kept with the version, for
|
|
11
|
+
* whoever reads the history next.
|
|
12
|
+
*/
|
|
13
|
+
export function RollBack({
|
|
14
|
+
suite,
|
|
15
|
+
versionId,
|
|
16
|
+
number,
|
|
17
|
+
}: {
|
|
18
|
+
suite: string;
|
|
19
|
+
versionId: string;
|
|
20
|
+
number: number;
|
|
21
|
+
}) {
|
|
22
|
+
const [reason, setReason] = useState("");
|
|
23
|
+
const missing = reason.trim() === "";
|
|
24
|
+
const button = (
|
|
25
|
+
<Submit variant="quiet" disabled={missing}>
|
|
26
|
+
Roll back to v{number}
|
|
27
|
+
</Submit>
|
|
28
|
+
);
|
|
29
|
+
return (
|
|
30
|
+
<form
|
|
31
|
+
action={rollBackSettings}
|
|
32
|
+
className="flex flex-wrap items-center gap-2"
|
|
33
|
+
>
|
|
34
|
+
<input type="hidden" name="suite" value={suite} />
|
|
35
|
+
<input type="hidden" name="version" value={versionId} />
|
|
36
|
+
<label htmlFor={`why-${versionId}`} className="sr-only">
|
|
37
|
+
Why roll back to v{number}?
|
|
38
|
+
</label>
|
|
39
|
+
<input
|
|
40
|
+
id={`why-${versionId}`}
|
|
41
|
+
name="reason"
|
|
42
|
+
required
|
|
43
|
+
value={reason}
|
|
44
|
+
onChange={(e) => {
|
|
45
|
+
setReason(e.target.value);
|
|
46
|
+
}}
|
|
47
|
+
placeholder="Why roll back?"
|
|
48
|
+
className="rounded-md border border-line bg-card px-2 py-1 text-xs focus:border-accent focus:outline-none"
|
|
49
|
+
/>
|
|
50
|
+
{missing ? (
|
|
51
|
+
<Tip
|
|
52
|
+
text="Say why you're rolling back first. The note is kept with the version, for whoever reads the history next."
|
|
53
|
+
id={`rollback-${versionId}`}
|
|
54
|
+
>
|
|
55
|
+
{button}
|
|
56
|
+
</Tip>
|
|
57
|
+
) : (
|
|
58
|
+
button
|
|
59
|
+
)}
|
|
60
|
+
</form>
|
|
61
|
+
);
|
|
62
|
+
}
|
|
@@ -5,8 +5,7 @@ import {
|
|
|
5
5
|
type HistoryEntry,
|
|
6
6
|
type Settings,
|
|
7
7
|
} from "@crowandraven/evalkeeper";
|
|
8
|
-
import {
|
|
9
|
-
import { Submit } from "./Submit";
|
|
8
|
+
import { RollBack } from "./RollBack";
|
|
10
9
|
import { InfoTip, Tip } from "./Tip";
|
|
11
10
|
import { Chip } from "./ui";
|
|
12
11
|
import { evalsPath } from "@/lib/evalkeeper/paths";
|
|
@@ -163,24 +162,11 @@ export function HistoryEntries({
|
|
|
163
162
|
</Link>
|
|
164
163
|
)}
|
|
165
164
|
{!isLive && (
|
|
166
|
-
<
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
<input type="hidden" name="version" value={e.to.id} />
|
|
172
|
-
<label htmlFor={`why-${e.id}`} className="sr-only">
|
|
173
|
-
Why roll back to v{e.to.number}?
|
|
174
|
-
</label>
|
|
175
|
-
<input
|
|
176
|
-
id={`why-${e.id}`}
|
|
177
|
-
name="reason"
|
|
178
|
-
required
|
|
179
|
-
placeholder="Why roll back?"
|
|
180
|
-
className="rounded-md border border-line bg-card px-2 py-1 text-xs focus:border-accent focus:outline-none"
|
|
181
|
-
/>
|
|
182
|
-
<Submit variant="quiet">Roll back to v{e.to.number}</Submit>
|
|
183
|
-
</form>
|
|
165
|
+
<RollBack
|
|
166
|
+
suite={suite}
|
|
167
|
+
versionId={e.to.id}
|
|
168
|
+
number={e.to.number}
|
|
169
|
+
/>
|
|
184
170
|
)}
|
|
185
171
|
</div>
|
|
186
172
|
</article>
|
|
@@ -3,23 +3,32 @@
|
|
|
3
3
|
import { LoaderCircle } from "lucide-react";
|
|
4
4
|
import { useFormStatus } from "react-dom";
|
|
5
5
|
|
|
6
|
+
/**
|
|
7
|
+
* A button that isn't the page's main action: the app's accent as an
|
|
8
|
+
* outline, so it still reads as a button in the app's colors (set
|
|
9
|
+
* `--accent` in evalkeeper.css).
|
|
10
|
+
*/
|
|
11
|
+
export const QUIET_BUTTON =
|
|
12
|
+
"border border-accent/40 bg-card text-accent hover:bg-accent/10";
|
|
13
|
+
|
|
6
14
|
/** A submit button that spins while its form's action runs. */
|
|
7
15
|
export function Submit({
|
|
8
16
|
children,
|
|
9
17
|
variant = "primary",
|
|
18
|
+
disabled = false,
|
|
10
19
|
}: {
|
|
11
20
|
children: React.ReactNode;
|
|
12
21
|
variant?: "primary" | "quiet";
|
|
22
|
+
/** Off until the form is ready; wrap it in a `<Tip>` saying why. */
|
|
23
|
+
disabled?: boolean;
|
|
13
24
|
}) {
|
|
14
25
|
const { pending } = useFormStatus();
|
|
15
26
|
return (
|
|
16
27
|
<button
|
|
17
28
|
type="submit"
|
|
18
|
-
disabled={pending}
|
|
29
|
+
disabled={pending || disabled}
|
|
19
30
|
className={`inline-flex items-center gap-2 whitespace-nowrap rounded-md px-3 py-1.5 text-sm disabled:opacity-60 ${
|
|
20
|
-
variant === "primary"
|
|
21
|
-
? "bg-accent text-white"
|
|
22
|
-
: "border border-line bg-card text-ink hover:bg-tint"
|
|
31
|
+
variant === "primary" ? "bg-accent text-white" : QUIET_BUTTON
|
|
23
32
|
}`}
|
|
24
33
|
>
|
|
25
34
|
{pending && <Spinner />}
|