mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-08 03:08:45 +00:00
feat(ui): drive Lens sampling with a slider and an editable run count
Sample rate and the run cap are one control: the slider scales the run count against the matching runs, and typing a smaller count caps it. Bare inputs also opt out of the forms plugin's border. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
61d65176bf
commit
887c115e3a
5 changed files with 139 additions and 50 deletions
|
|
@ -463,6 +463,47 @@ it.each(["empty", "error"])("blocks a new investigation when its preview is %s",
|
|||
expect(screen.getByRole("button", { name: "Run investigation" })).toBeDisabled();
|
||||
});
|
||||
|
||||
describe("Sample", () => {
|
||||
it("slides the sample rate to scale the run count, caps it by typing, and saves both", async () => {
|
||||
const save = vi.fn().mockResolvedValue(undefined);
|
||||
const user = userEvent.setup();
|
||||
proxy.post.mockResolvedValue({ eligible: 2000, selected: 2000, executions: [run("Run one", 1)] });
|
||||
mockGateway({ keyModels: ["analysis"] });
|
||||
renderWithProviders(<InvestigationSetup mode="new" onClose={vi.fn()} onSave={save} />);
|
||||
const count = screen.getByRole("textbox", { name: "Runs to analyze" });
|
||||
await waitFor(() => expect(count).toHaveValue("2,000"));
|
||||
const slider = screen.getByLabelText("Sample", { selector: "input[type=range]" });
|
||||
slider.focus();
|
||||
await user.keyboard("{Home}{ArrowRight}{ArrowRight}");
|
||||
expect(screen.getByText("3% of 2,000")).toBeInTheDocument();
|
||||
expect(count).toHaveValue("60");
|
||||
await user.clear(count);
|
||||
await user.type(count, "25");
|
||||
expect(count).toHaveValue("25");
|
||||
await user.click(screen.getByRole("button", { name: "Continue" }));
|
||||
await user.click(screen.getByRole("button", { name: "Continue" }));
|
||||
await waitFor(() => expect(screen.getByRole("button", { name: "Run and monitor" })).toBeEnabled());
|
||||
await user.click(screen.getByRole("button", { name: "Run and monitor" }));
|
||||
expect(save).toHaveBeenCalledWith(expect.objectContaining({ sample_percent: 3, sample_size: 25 }));
|
||||
});
|
||||
|
||||
it("drops a cap that no longer limits anything once the field is left", async () => {
|
||||
const user = userEvent.setup();
|
||||
proxy.post.mockResolvedValue({ eligible: 40, selected: 40, executions: [run("Run one", 1)] });
|
||||
renderWithProviders(<InvestigationSetup mode="new" onClose={vi.fn()} onSave={vi.fn()} />);
|
||||
const count = screen.getByRole("textbox", { name: "Runs to analyze" });
|
||||
await waitFor(() => expect(count).toHaveValue("40"));
|
||||
await user.clear(count);
|
||||
await user.type(count, "10");
|
||||
expect(screen.getByRole("button", { name: /Remove cap/ })).toBeInTheDocument();
|
||||
await user.clear(count);
|
||||
await user.type(count, "500");
|
||||
await user.tab();
|
||||
expect(count).toHaveValue("40");
|
||||
expect(screen.queryByRole("button", { name: /Remove cap/ })).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("URL draft", () => {
|
||||
const lastUrl = (onUrlUpdate: ReturnType<typeof vi.fn>) =>
|
||||
new URLSearchParams((onUrlUpdate.mock.lastCall?.[0] as { queryString: string } | undefined)?.queryString ?? "");
|
||||
|
|
|
|||
|
|
@ -153,7 +153,7 @@ function SetupEditor({
|
|||
aria-label="Investigation name"
|
||||
placeholder={TITLES[mode]}
|
||||
autoComplete="off"
|
||||
className="h-8 min-w-0 flex-1 appearance-none rounded-md border-0 bg-transparent px-2 text-lg font-semibold tracking-tight shadow-none ring-0 outline-none placeholder:text-muted-foreground/60 hover:bg-muted/50 focus:bg-muted/50 focus:ring-0 focus:outline-none"
|
||||
className="h-8 min-w-0 flex-1 appearance-none rounded-md border-0 bg-transparent px-2 py-0 text-lg font-semibold tracking-tight shadow-none ring-0 outline-none placeholder:text-muted-foreground/60 hover:bg-muted/50 focus:bg-muted/50 focus:ring-0 focus:outline-none"
|
||||
/>
|
||||
<Button variant="ghost" size="sm" disabled={formState.isSubmitting} onClick={onClose}>
|
||||
Cancel
|
||||
|
|
@ -176,7 +176,7 @@ function SetupEditor({
|
|||
summary={activitySummary(selection, manualSelection)}
|
||||
>
|
||||
<ScopeFields {...activity.scope} />
|
||||
<SampleFields />
|
||||
<SampleFields eligible={activity.preview.page.eligible} />
|
||||
<StepFooter>
|
||||
<Button onClick={() => void next()}>Continue</Button>
|
||||
</StepFooter>
|
||||
|
|
|
|||
|
|
@ -1,22 +1,45 @@
|
|||
"use client";
|
||||
|
||||
import { useId } from "react";
|
||||
import { Controller, useFormContext } from "react-hook-form";
|
||||
import { useState } from "react";
|
||||
import { Controller, useFormContext, useWatch } from "react-hook-form";
|
||||
import { X } from "lucide-react";
|
||||
import { DurationInput } from "@/components/shared/DurationInput";
|
||||
import { FieldError } from "@/components/ui/field";
|
||||
import { InputGroup, InputGroupAddon, InputGroupInput } from "@/components/ui/input-group";
|
||||
import { Slider } from "@/components/ui/slider";
|
||||
import type { InvestigationInput } from "../investigationSchema";
|
||||
|
||||
const optionalNumber = (value: unknown) => (value == null || value === "" ? null : Number(value));
|
||||
/** Runs the sampling percentage keeps out of `eligible`, before any cap. */
|
||||
export const sampledRuns = (eligible: number, percent: number): number => Math.ceil((eligible * percent) / 100);
|
||||
|
||||
export function SampleFields() {
|
||||
const capFromText = (text: string): number | null => {
|
||||
const digits = text.replace(/\D/g, "");
|
||||
return digits ? Number(digits) : null;
|
||||
};
|
||||
|
||||
export interface SampleFieldsProps {
|
||||
/** Runs the search matches; undefined while the preview loads. */
|
||||
readonly eligible: number | undefined;
|
||||
}
|
||||
|
||||
export function SampleFields({ eligible }: SampleFieldsProps) {
|
||||
const {
|
||||
control,
|
||||
register,
|
||||
setValue,
|
||||
formState: { errors },
|
||||
} = useFormContext<InvestigationInput>();
|
||||
const [percentValue, cap] = useWatch({ control, name: ["selection.sample_percent", "selection.sample_size"] });
|
||||
const selectionErrors = errors.selection;
|
||||
const id = useId();
|
||||
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">
|
||||
|
|
@ -29,46 +52,62 @@ export function SampleFields() {
|
|||
/>
|
||||
<FieldError>{selectionErrors?.lookback_hours?.message}</FieldError>
|
||||
</div>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="grid content-start gap-2">
|
||||
<label htmlFor={`${id}-sample`} className="text-sm font-medium">
|
||||
Sample
|
||||
</label>
|
||||
<InputGroup>
|
||||
<InputGroupInput
|
||||
id={`${id}-sample`}
|
||||
{...register("selection.sample_percent", { valueAsNumber: true })}
|
||||
type="number"
|
||||
min="0.01"
|
||||
max="100"
|
||||
step="any"
|
||||
className="tabular-nums"
|
||||
/>
|
||||
<InputGroupAddon aria-hidden="true" align="inline-end">
|
||||
%
|
||||
</InputGroupAddon>
|
||||
</InputGroup>
|
||||
<FieldError>{selectionErrors?.sample_percent?.message}</FieldError>
|
||||
<div className="grid gap-3 rounded-md border px-3 pt-2.5 pb-3.5">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="grid gap-0.5">
|
||||
<span className="text-sm font-medium">Sample</span>
|
||||
<span className="text-xs tabular-nums text-muted-foreground">
|
||||
{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"
|
||||
/>
|
||||
<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>
|
||||
<div className="grid content-start gap-2">
|
||||
<label htmlFor={`${id}-max`} className="text-sm font-medium">
|
||||
At most
|
||||
</label>
|
||||
<InputGroup>
|
||||
<InputGroupInput
|
||||
id={`${id}-max`}
|
||||
{...register("selection.sample_size", { setValueAs: optionalNumber })}
|
||||
type="number"
|
||||
min="1"
|
||||
placeholder="No limit"
|
||||
className="tabular-nums"
|
||||
<Controller
|
||||
control={control}
|
||||
name="selection.sample_percent"
|
||||
render={({ field }) => (
|
||||
<Slider
|
||||
thumbLabel="Sample"
|
||||
min={1}
|
||||
max={100}
|
||||
step={1}
|
||||
value={[Math.min(100, Math.max(1, Math.round(percent)))]}
|
||||
onValueChange={(value) => field.onChange(Array.isArray(value) ? value[0] : value)}
|
||||
/>
|
||||
<InputGroupAddon aria-hidden="true" align="inline-end">
|
||||
runs
|
||||
</InputGroupAddon>
|
||||
</InputGroup>
|
||||
<FieldError>{selectionErrors?.sample_size?.message}</FieldError>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
<FieldError>{selectionErrors?.sample_percent?.message ?? selectionErrors?.sample_size?.message}</FieldError>
|
||||
</div>
|
||||
<FieldError>{selectionErrors?.execution_ids?.message}</FieldError>
|
||||
</>
|
||||
|
|
|
|||
|
|
@ -41,13 +41,13 @@ export function DurationInput({ label, value, onChange, base, max, className, ..
|
|||
step={1 / scale}
|
||||
value={Number.isFinite(value) ? value / scale : ""}
|
||||
onChange={(event) => onChange(event.target.value === "" ? NaN : Number(event.target.value) * scale)}
|
||||
className="min-w-0 flex-1 bg-transparent px-3 text-base tabular-nums outline-none md:text-sm"
|
||||
className="min-w-0 flex-1 border-0 bg-transparent px-3 py-0 shadow-none focus:ring-0 text-base tabular-nums outline-none md:text-sm"
|
||||
/>
|
||||
<div className="relative shrink-0 border-l border-input">
|
||||
<select
|
||||
aria-label={`${label} unit`}
|
||||
value={scale}
|
||||
className="h-full appearance-none rounded-r-md bg-transparent pr-8 pl-3 text-sm text-muted-foreground outline-none hover:text-foreground focus-visible:text-foreground"
|
||||
className="h-full appearance-none rounded-r-md border-0 bg-transparent bg-none py-0 pr-8 pl-3 shadow-none focus:ring-0 text-sm text-muted-foreground outline-none hover:text-foreground focus-visible:text-foreground"
|
||||
onChange={(event) => setScale(Number(event.target.value))}
|
||||
>
|
||||
{units.map((unit) => (
|
||||
|
|
|
|||
|
|
@ -2,7 +2,15 @@ import { Slider as SliderPrimitive } from "@base-ui/react/slider";
|
|||
|
||||
import { cn } from "@/lib/cva.config";
|
||||
|
||||
function Slider({ className, defaultValue, value, min = 0, max = 100, ...props }: SliderPrimitive.Root.Props) {
|
||||
function Slider({
|
||||
className,
|
||||
defaultValue,
|
||||
value,
|
||||
min = 0,
|
||||
max = 100,
|
||||
thumbLabel,
|
||||
...props
|
||||
}: SliderPrimitive.Root.Props & { thumbLabel?: string }) {
|
||||
const _values = Array.isArray(value) ? value : Array.isArray(defaultValue) ? defaultValue : [min, max];
|
||||
|
||||
return (
|
||||
|
|
@ -30,6 +38,7 @@ function Slider({ className, defaultValue, value, min = 0, max = 100, ...props }
|
|||
<SliderPrimitive.Thumb
|
||||
data-slot="slider-thumb"
|
||||
key={index}
|
||||
getAriaLabel={thumbLabel === undefined ? undefined : () => thumbLabel}
|
||||
className="block size-4 shrink-0 rounded-full border border-primary bg-card shadow-sm ring-ring/50 transition-[color,box-shadow] select-none hover:ring-4 focus-visible:ring-4 focus-visible:outline-hidden disabled:pointer-events-none disabled:opacity-50"
|
||||
/>
|
||||
))}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue