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:
Yujong Lee 2026-10-04 19:50:05 -07:00
parent 61d65176bf
commit 887c115e3a
5 changed files with 139 additions and 50 deletions

View file

@ -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 ?? "");

View file

@ -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>

View file

@ -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>
</>

View file

@ -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) => (

View file

@ -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"
/>
))}