From 774a32f545434a40ec2ef88bdb5515bdaea434b2 Mon Sep 17 00:00:00 2001 From: Yujong Lee Date: Sun, 4 Oct 2026 20:30:09 -0700 Subject: [PATCH] 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 --- .../.agents/skills/react-hook-form/SKILL.md | 8 ++ .../InvestigationSetup.integration.test.tsx | 23 ++++ .../lens/setup/InvestigationSetup.tsx | 13 +- .../lens/setup/fields/SampleFields.tsx | 120 ++++++++++++------ .../lens/setup/fields/ScopeFields.tsx | 15 ++- .../lens/setup/useMatchingActivity.ts | 8 ++ 6 files changed, 131 insertions(+), 56 deletions(-) create mode 100644 ui/litellm-dashboard/.agents/skills/react-hook-form/SKILL.md diff --git a/ui/litellm-dashboard/.agents/skills/react-hook-form/SKILL.md b/ui/litellm-dashboard/.agents/skills/react-hook-form/SKILL.md new file mode 100644 index 00000000000..98ede27c841 --- /dev/null +++ b/ui/litellm-dashboard/.agents/skills/react-hook-form/SKILL.md @@ -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 diff --git a/ui/litellm-dashboard/src/components/lens/setup/InvestigationSetup.integration.test.tsx b/ui/litellm-dashboard/src/components/lens/setup/InvestigationSetup.integration.test.tsx index 29e9dc4d3d8..9006ae94000 100644 --- a/ui/litellm-dashboard/src/components/lens/setup/InvestigationSetup.integration.test.tsx +++ b/ui/litellm-dashboard/src/components/lens/setup/InvestigationSetup.integration.test.tsx @@ -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( + , + ); + 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(); diff --git a/ui/litellm-dashboard/src/components/lens/setup/InvestigationSetup.tsx b/ui/litellm-dashboard/src/components/lens/setup/InvestigationSetup.tsx index 7ce94866b9b..18f9609befb 100644 --- a/ui/litellm-dashboard/src/components/lens/setup/InvestigationSetup.tsx +++ b/ui/litellm-dashboard/src/components/lens/setup/InvestigationSetup.tsx @@ -124,7 +124,7 @@ function SetupEditor({ const [step, setStep] = useSetupStepRoute(); const [error, setError] = useState(""); const [trace, setTrace] = useState(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); diff --git a/ui/litellm-dashboard/src/components/lens/setup/fields/SampleFields.tsx b/ui/litellm-dashboard/src/components/lens/setup/fields/SampleFields.tsx index b5e53054559..cadc7b5490b 100644 --- a/ui/litellm-dashboard/src/components/lens/setup/fields/SampleFields.tsx +++ b/ui/litellm-dashboard/src/components/lens/setup/fields/SampleFields.tsx @@ -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(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 ( +
+ + {capped ? ( + + ) : ( + Type a number to cap + )} +
+ ); +} + 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(); - 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(null); - const shown = typing ?? analyzed?.toLocaleString() ?? ""; - const finishTyping = () => { - setTyping(null); - if (cap != null && sampled !== undefined && cap >= sampled) setCap(null); - }; return ( <>
@@ -47,7 +95,15 @@ export function SampleFields({ eligible }: SampleFieldsProps) { control={control} name="selection.lookback_hours" render={({ field }) => ( - + { + if (hours !== field.value) dropPicks(); + field.onChange(hours); + }} + /> )} /> {selectionErrors?.lookback_hours?.message} @@ -60,38 +116,18 @@ export function SampleFields({ eligible }: SampleFieldsProps) { {eligible === undefined ? `${percent}% of matching runs` : `${percent}% of ${eligible.toLocaleString()}`}
-
- - {capped ? ( - - ) : ( - Type a number to cap )} -
+ /> (); + const dropPicks = useDropPicks(); return (
Runs to review @@ -16,7 +17,17 @@ export function ScopeFields({ runs, range }: ScopeOptions) { } + render={({ field }) => ( + { + if (q !== field.value) dropPicks(); + field.onChange(q); + }} + runs={runs} + range={range} + /> + )} />

diff --git a/ui/litellm-dashboard/src/components/lens/setup/useMatchingActivity.ts b/ui/litellm-dashboard/src/components/lens/setup/useMatchingActivity.ts index 8384e717c16..4c417d68734 100644 --- a/ui/litellm-dashboard/src/components/lens/setup/useMatchingActivity.ts +++ b/ui/litellm-dashboard/src/components/lens/setup/useMatchingActivity.ts @@ -18,6 +18,14 @@ type PreviewPageData = Pick; 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(); + 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[];