fix(ui): let number fields be cleared instead of refilling their current value

Adds a NumberInput that keeps the field empty while editing, wired into the auto-router classifier, adaptive and semantic-matching number fields.

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
tin 2026-08-29 18:17:16 +00:00
parent 2a5d09ee87
commit e19640e496
6 changed files with 155 additions and 28 deletions

View file

@ -1,5 +1,5 @@
import { Card, CardContent } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { NumberInput } from "@/components/shared/NumberInput";
import { Label } from "@/components/ui/label";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import { Slider } from "@/components/ui/slider";
@ -120,12 +120,9 @@ const AdaptiveRoutingConfig: React.FC<AdaptiveRoutingConfigProps> = ({ value, on
{adaptiveEligible === "all" && (
<div>
<strong className="mb-1 block font-semibold">Tier Distance Penalty</strong>
<Input
type="number"
<NumberInput
value={tierDistancePenalty}
onChange={(event) =>
handleTierDistancePenaltyChange(event.target.value === "" ? null : event.target.valueAsNumber)
}
onValueChange={handleTierDistancePenaltyChange}
min={0}
step={0.1}
className="w-full"

View file

@ -4,7 +4,7 @@ import { MultiSelect } from "@/components/shared/MultiSelect";
import { SearchSelect } from "@/components/shared/SearchSelect";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Card, CardContent } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { NumberInput } from "@/components/shared/NumberInput";
import { Label } from "@/components/ui/label";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import { Switch } from "@/components/ui/switch";
@ -367,12 +367,9 @@ const ClassificationMethodConfig: React.FC<ClassificationMethodConfigProps> = ({
</div>
<div>
<strong className="block mb-1 font-semibold">Timeout (ms)</strong>
<Input
type="number"
<NumberInput
value={value.classifier_llm_config?.timeout_ms ?? DEFAULT_CLASSIFIER_TIMEOUT_MS}
onChange={(event) =>
handleClassifierTimeoutChange(event.target.value === "" ? null : event.target.valueAsNumber)
}
onValueChange={handleClassifierTimeoutChange}
min={1}
className="w-full"
/>
@ -481,12 +478,9 @@ const ClassificationMethodConfig: React.FC<ClassificationMethodConfigProps> = ({
</RestrictedSection>
<div>
<strong className="block mb-1 font-semibold">Context Window Size</strong>
<Input
type="number"
<NumberInput
value={value.classifier_context_window_size ?? DEFAULT_CLASSIFIER_CONTEXT_WINDOW_SIZE}
onChange={(event) =>
handleClassifierContextWindowSizeChange(event.target.value === "" ? null : event.target.valueAsNumber)
}
onValueChange={handleClassifierContextWindowSizeChange}
min={0}
className="w-full"
/>
@ -498,12 +492,9 @@ const ClassificationMethodConfig: React.FC<ClassificationMethodConfigProps> = ({
</div>
<div>
<strong className="block mb-1 font-semibold">Context Character Budget</strong>
<Input
type="number"
<NumberInput
value={value.classifier_context_budget_chars ?? DEFAULT_CLASSIFIER_CONTEXT_BUDGET_CHARS}
onChange={(event) =>
handleClassifierContextBudgetCharsChange(event.target.value === "" ? null : event.target.valueAsNumber)
}
onValueChange={handleClassifierContextBudgetCharsChange}
min={0}
className="w-full"
/>

View file

@ -1,3 +1,4 @@
import { useState } from "react";
import { fireEvent, renderWithProviders, screen, within } from "../../../tests/test-utils";
import userEvent from "@testing-library/user-event";
import { vi } from "vitest";
@ -267,6 +268,29 @@ describe("ComplexityRouterConfig", () => {
});
});
it("should let the context window size field be cleared instead of refilling the default", () => {
const StatefulConfig = () => {
const [value, setValue] = useState<ComplexityRouterConfigValue>({
...defaultValue,
classifier_type: "llm",
classifier_llm_config: { model: "gpt-3.5-turbo", timeout_ms: 3000 },
});
return <ComplexityRouterConfig modelInfo={mockModelInfo} value={value} onChange={setValue} />;
};
renderWithProviders(<StatefulConfig />);
fireEvent.click(screen.getByText("Advanced: Classification Method"));
const windowSizeSection = screen.getByText("Context Window Size").closest("div") as HTMLElement;
const input = within(windowSizeSection).getByRole("spinbutton");
fireEvent.change(input, { target: { value: "" } });
expect(input).toHaveValue(null);
fireEvent.change(input, { target: { value: "9" } });
expect(input).toHaveValue(9);
});
it("should render the custom technical keywords field", () => {
renderWithProviders(<ComplexityRouterConfig {...baseProps} />);
fireEvent.click(screen.getByText("Advanced: Classification Method"));

View file

@ -1,7 +1,7 @@
import { Info } from "lucide-react";
import { SimpleTooltip } from "@/components/ui/tooltip";
import { SearchSelect } from "@/components/shared/SearchSelect";
import { Input } from "@/components/ui/input";
import { NumberInput } from "@/components/shared/NumberInput";
import { Switch } from "@/components/ui/switch";
import React from "react";
import { ModelGroup } from "@/components/llm_calls/fetch_models";
@ -72,12 +72,9 @@ const SemanticKeywordMatching: React.FC<SemanticKeywordMatchingProps> = ({
</div>
<div>
<span className="mb-1 block text-sm font-medium">Minimum match score</span>
<Input
type="number"
<NumberInput
value={matchThreshold}
onChange={(event) =>
onMatchThresholdChange(event.target.value === "" ? DEFAULT_MATCH_THRESHOLD : event.target.valueAsNumber)
}
onValueChange={(threshold) => onMatchThresholdChange(threshold ?? DEFAULT_MATCH_THRESHOLD)}
min={0}
max={1}
step={0.05}

View file

@ -0,0 +1,80 @@
import { useState } from "react";
import { fireEvent, render, screen } from "@testing-library/react";
import { vi } from "vitest";
import { NumberInput } from "./NumberInput";
const DEFAULT_VALUE = 3;
const DefaultingHarness = () => {
const [value, setValue] = useState(DEFAULT_VALUE);
return <NumberInput value={value} onValueChange={(next) => setValue(next ?? DEFAULT_VALUE)} min={0} />;
};
describe("NumberInput", () => {
it("should report the typed number", () => {
const onValueChange = vi.fn();
render(<NumberInput value={3} onValueChange={onValueChange} />);
fireEvent.change(screen.getByRole("spinbutton"), { target: { value: "7" } });
expect(onValueChange).toHaveBeenCalledWith(7);
});
it("should report null when the field is cleared", () => {
const onValueChange = vi.fn();
render(<NumberInput value={3} onValueChange={onValueChange} />);
fireEvent.change(screen.getByRole("spinbutton"), { target: { value: "" } });
expect(onValueChange).toHaveBeenCalledWith(null);
});
it("should stay empty after a clear that sends the parent back to its default", () => {
render(<DefaultingHarness />);
const input = screen.getByRole("spinbutton");
fireEvent.change(input, { target: { value: "" } });
expect(input).toHaveValue(null);
});
it("should accept a fresh number typed into the cleared field", () => {
render(<DefaultingHarness />);
const input = screen.getByRole("spinbutton");
fireEvent.change(input, { target: { value: "" } });
fireEvent.change(input, { target: { value: "5" } });
expect(input).toHaveValue(5);
});
it("should show the parent value again once the cleared field is blurred", () => {
render(<DefaultingHarness />);
const input = screen.getByRole("spinbutton");
fireEvent.change(input, { target: { value: "" } });
fireEvent.blur(input);
expect(input).toHaveValue(DEFAULT_VALUE);
});
it("should follow the parent value after the draft is committed", () => {
const { rerender } = render(<NumberInput value={3} onValueChange={vi.fn()} />);
const input = screen.getByRole("spinbutton");
fireEvent.change(input, { target: { value: "" } });
fireEvent.blur(input);
rerender(<NumberInput value={9} onValueChange={vi.fn()} />);
expect(input).toHaveValue(9);
});
it("should call a caller-supplied blur handler", () => {
const onBlur = vi.fn();
render(<NumberInput value={3} onValueChange={vi.fn()} onBlur={onBlur} />);
fireEvent.blur(screen.getByRole("spinbutton"));
expect(onBlur).toHaveBeenCalledTimes(1);
});
});

View file

@ -0,0 +1,38 @@
import * as React from "react";
import { Input } from "@/components/ui/input";
type NumberInputProps = Omit<React.ComponentProps<"input">, "type" | "value" | "onChange"> & {
value: number;
onValueChange: (value: number | null) => void;
};
/**
* Number input whose field can be emptied while editing: the parent's value is only re-displayed on blur, so
* backspacing the last digit leaves the field empty instead of snapping straight back to the current value.
*/
const NumberInput = React.forwardRef<HTMLInputElement, NumberInputProps>(
({ value, onValueChange, onBlur, ...props }, ref) => {
const [draft, setDraft] = React.useState<string | null>(null);
return (
<Input
{...props}
ref={ref}
type="number"
value={draft ?? String(value)}
onChange={(event) => {
setDraft(event.target.value);
onValueChange(Number.isNaN(event.target.valueAsNumber) ? null : event.target.valueAsNumber);
}}
onBlur={(event) => {
setDraft(null);
onBlur?.(event);
}}
/>
);
},
);
NumberInput.displayName = "NumberInput";
export { NumberInput };