mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-08 03:08:45 +00:00
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:
parent
34baeb834f
commit
774a32f545
6 changed files with 131 additions and 56 deletions
|
|
@ -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
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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[];
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue