refactor(ui): drive Lens sample cap and pick clearing through react-hook-form

The run-count cap is a Controller field instead of an imperative
setValue, and the search and window fields drop saved hand-picked runs
in their own change handlers instead of a form subscription effect.
Adds a minimal react-hook-form skill pointing at the upstream docs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Yujong Lee 2026-10-04 20:30:09 -07:00
parent 34baeb834f
commit 774a32f545
6 changed files with 131 additions and 56 deletions

View file

@ -0,0 +1,8 @@
---
name: react-hook-form
description: Build or change forms in the dashboard with react-hook-form
---
# react-hook-form
Docs: https://github.com/react-hook-form/documentation/tree/master/src/content

View file

@ -129,6 +129,29 @@ describe("Investigation setup", () => {
expect(screen.getByRole("button", { name: "Save changes" })).toBeDisabled();
});
it.each([
["search", () => userEvent.type(screen.getByRole("combobox", { name: "Search runs" }), "x")],
[
"window",
async () =>
fireEvent.change(screen.getByRole("spinbutton", { name: "Review the last" }), { target: { value: "3" } }),
],
])("drops saved hand-picked runs once the %s changes, and only then", async (_, change) => {
proxy.post.mockResolvedValue({ eligible: 2, selected: 2, executions: [run("Saved run", 1), run("Other run", 2)] });
renderWithProviders(
<InvestigationSetup
mode="edit"
initial={{ ...settings, execution_ids: [run("Saved run", 1).id] }}
onClose={vi.fn()}
onSave={vi.fn()}
/>,
);
const preview = within(screen.getByRole("region", { name: "Matching activity" }));
expect(await preview.findByRole("button", { name: "Clear 1 selected runs" })).toBeVisible();
await change();
await waitFor(() => expect(preview.queryByRole("button", { name: /Clear/ })).not.toBeInTheDocument());
});
it("preserves check identity and disabled state when a check is edited", async () => {
const save = vi.fn().mockResolvedValue(undefined);
const user = userEvent.setup();

View file

@ -124,7 +124,7 @@ function SetupEditor({
const [step, setStep] = useSetupStepRoute();
const [error, setError] = useState("");
const [trace, setTrace] = useState<TraceRef | null>(null);
const { control, register, setValue, subscribe, trigger, formState } = form;
const { control, register, trigger, formState } = form;
const [selectedModel, repeat] = useWatch({ control, name: ["selectedModel", "repeat"] });
const activity = useMatchingActivity();
const { executions } = activity.preview.page;
@ -133,17 +133,6 @@ function SetupEditor({
[executions],
);
const model = selectedModel ?? analysis.defaultModel ?? "";
useEffect(
() =>
subscribe({
name: ["selection.q", "selection.lookback_hours"],
formState: { values: true },
callback: ({ values }) => {
if (values.selection.execution_ids.length) setValue("selection.execution_ids", []);
},
}),
[setValue, subscribe],
);
const next = async () => {
const following = nextSetupStep(step);
if (following && (await trigger(investigationStepFields[step]))) setStep(following);

View file

@ -7,6 +7,7 @@ import { DurationInput } from "@/components/shared/DurationInput";
import { FieldError } from "@/components/ui/field";
import { Slider } from "@/components/ui/slider";
import type { InvestigationInput } from "../investigationSchema";
import { useDropPicks } from "../useMatchingActivity";
/** Runs the sampling percentage keeps out of `eligible`, before any cap. */
export const sampledRuns = (eligible: number, percent: number): number => Math.ceil((eligible * percent) / 100);
@ -16,6 +17,62 @@ const capFromText = (text: string): number | null => {
return digits ? Number(digits) : null;
};
interface RunCountProps {
/** The cap the user typed, or null to analyze every sampled run. */
readonly cap: number | null;
/** Sampled runs before any cap; undefined while the preview loads. */
readonly sampled: number | undefined;
readonly onCapChange: (cap: number | null) => void;
readonly onBlur: () => void;
}
/** Shows how many runs will be analyzed; typing a smaller number caps it, and a cap that limits nothing is dropped. */
function RunCount({ cap, sampled, onCapChange, onBlur }: RunCountProps) {
const [typing, setTyping] = useState<string | null>(null);
const analyzed = sampled === undefined ? cap : Math.min(sampled, cap ?? Infinity);
const capped = cap != null && (sampled === undefined || cap < sampled);
const shown = typing ?? analyzed?.toLocaleString() ?? "";
const finishTyping = () => {
setTyping(null);
if (cap != null && sampled !== undefined && cap >= sampled) onCapChange(null);
onBlur();
};
return (
<div className="grid justify-items-end gap-0.5">
<label className="flex items-baseline gap-1.5">
<input
inputMode="numeric"
aria-label="Runs to analyze"
value={shown}
placeholder="All"
onFocus={(event) => event.target.select()}
onChange={(event) => {
setTyping(event.target.value);
onCapChange(capFromText(event.target.value));
}}
onBlur={finishTyping}
style={{ width: `${Math.max((shown || "All").length, 2) + 1}ch` }}
className="-mr-1 rounded-sm border-0 bg-transparent px-1 py-0 shadow-none focus:ring-0 text-right text-xl font-semibold tabular-nums tracking-tight outline-none hover:bg-muted/60 focus:bg-muted/60"
/>
<span className="text-sm text-muted-foreground">runs</span>
</label>
{capped ? (
<button
type="button"
aria-label="Remove cap"
onClick={() => onCapChange(null)}
className="flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground"
>
Capped
<X aria-hidden="true" className="size-3" />
</button>
) : (
<span className="text-xs text-muted-foreground">Type a number to cap</span>
)}
</div>
);
}
export interface SampleFieldsProps {
/** Runs the search matches; undefined while the preview loads. */
readonly eligible: number | undefined;
@ -24,22 +81,13 @@ export interface SampleFieldsProps {
export function SampleFields({ eligible }: SampleFieldsProps) {
const {
control,
setValue,
formState: { errors },
} = useFormContext<InvestigationInput>();
const [percentValue, cap] = useWatch({ control, name: ["selection.sample_percent", "selection.sample_size"] });
const dropPicks = useDropPicks();
const percentValue = useWatch({ control, name: "selection.sample_percent" });
const selectionErrors = errors.selection;
const percent = Number.isFinite(percentValue) ? percentValue : 100;
const sampled = eligible === undefined ? undefined : sampledRuns(eligible, percent);
const capped = cap != null && (sampled === undefined || cap < sampled);
const analyzed = sampled === undefined ? cap : Math.min(sampled, cap ?? Infinity);
const setCap = (next: number | null) => setValue("selection.sample_size", next, { shouldValidate: true });
const [typing, setTyping] = useState<string | null>(null);
const shown = typing ?? analyzed?.toLocaleString() ?? "";
const finishTyping = () => {
setTyping(null);
if (cap != null && sampled !== undefined && cap >= sampled) setCap(null);
};
return (
<>
<div className="grid gap-2">
@ -47,7 +95,15 @@ export function SampleFields({ eligible }: SampleFieldsProps) {
control={control}
name="selection.lookback_hours"
render={({ field }) => (
<DurationInput label="Review the last" value={field.value ?? 24} base="hours" onChange={field.onChange} />
<DurationInput
label="Review the last"
value={field.value ?? 24}
base="hours"
onChange={(hours) => {
if (hours !== field.value) dropPicks();
field.onChange(hours);
}}
/>
)}
/>
<FieldError>{selectionErrors?.lookback_hours?.message}</FieldError>
@ -60,38 +116,18 @@ export function SampleFields({ eligible }: SampleFieldsProps) {
{eligible === undefined ? `${percent}% of matching runs` : `${percent}% of ${eligible.toLocaleString()}`}
</span>
</div>
<div className="grid justify-items-end gap-0.5">
<label className="flex items-baseline gap-1.5">
<input
inputMode="numeric"
aria-label="Runs to analyze"
value={shown}
placeholder="All"
onFocus={(event) => event.target.select()}
onChange={(event) => {
setTyping(event.target.value);
setCap(capFromText(event.target.value));
}}
onBlur={finishTyping}
style={{ width: `${Math.max((shown || "All").length, 2) + 1}ch` }}
className="-mr-1 rounded-sm border-0 bg-transparent px-1 py-0 shadow-none focus:ring-0 text-right text-xl font-semibold tabular-nums tracking-tight outline-none hover:bg-muted/60 focus:bg-muted/60"
<Controller
control={control}
name="selection.sample_size"
render={({ field }) => (
<RunCount
cap={field.value ?? null}
sampled={sampled}
onCapChange={field.onChange}
onBlur={field.onBlur}
/>
<span className="text-sm text-muted-foreground">runs</span>
</label>
{capped ? (
<button
type="button"
aria-label="Remove cap"
onClick={() => setCap(null)}
className="flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground"
>
Capped
<X aria-hidden="true" className="size-3" />
</button>
) : (
<span className="text-xs text-muted-foreground">Type a number to cap</span>
)}
</div>
/>
</div>
<Controller
control={control}

View file

@ -4,11 +4,12 @@ import { Controller, useFormContext } from "react-hook-form";
import { RunSearch } from "../../traces/list/runSearch/RunSearch";
import type { InvestigationInput } from "../investigationSchema";
import type { ScopeOptions } from "../useMatchingActivity";
import { useDropPicks, type ScopeOptions } from "../useMatchingActivity";
/** Which runs to review, written in the same search language as the Traces tab. */
export function ScopeFields({ runs, range }: ScopeOptions) {
const { control } = useFormContext<InvestigationInput>();
const dropPicks = useDropPicks();
return (
<div className="grid gap-2">
<span className="text-sm font-medium">Runs to review</span>
@ -16,7 +17,17 @@ export function ScopeFields({ runs, range }: ScopeOptions) {
<Controller
control={control}
name="selection.q"
render={({ field }) => <RunSearch value={field.value} onChange={field.onChange} runs={runs} range={range} />}
render={({ field }) => (
<RunSearch
value={field.value}
onChange={(q) => {
if (q !== field.value) dropPicks();
field.onChange(q);
}}
runs={runs}
range={range}
/>
)}
/>
</div>
<p className="text-xs text-muted-foreground">

View file

@ -18,6 +18,14 @@ type PreviewPageData = Pick<Sample, "eligible" | "selected">;
export type { Execution };
/** Hand-picked runs belong to the search and window they were picked from, so changing either drops them. */
export function useDropPicks(): () => void {
const { getValues, setValue } = useFormContext<InvestigationInput>();
return useCallback(() => {
if (getValues("selection.execution_ids").length) setValue("selection.execution_ids", []);
}, [getValues, setValue]);
}
/** What the search box needs: the runs already previewed suggest values, and the window bounds the copied query. */
export interface ScopeOptions {
readonly runs: readonly TraceSummary[];