diff --git a/client/src/components/DynamicJsonForm.tsx b/client/src/components/DynamicJsonForm.tsx index 90249ab1c..27f42ea23 100644 --- a/client/src/components/DynamicJsonForm.tsx +++ b/client/src/components/DynamicJsonForm.tsx @@ -11,6 +11,7 @@ import { Input } from "@/components/ui/input"; import JsonEditor from "./JsonEditor"; import { updateValueAtPath } from "@/utils/jsonUtils"; import { generateDefaultValue } from "@/utils/schemaUtils"; +import { shouldDeferNumericCommit } from "@/utils/numericInputUtils"; import type { JsonValue, JsonSchemaType, @@ -462,14 +463,15 @@ const DynamicJsonForm = forwardRef( case "number": return ( { const val = e.target.value; updateNumericDraft(path, val); if (!val && !isRequired) { handleFieldChange(path, undefined); - } else { + } else if (!shouldDeferNumericCommit(val)) { const num = Number(val); if (!isNaN(num)) { handleFieldChange(path, num); @@ -507,7 +509,7 @@ const DynamicJsonForm = forwardRef( updateNumericDraft(path, val); if (!val && !isRequired) { handleFieldChange(path, undefined); - } else { + } else if (!shouldDeferNumericCommit(val)) { const num = Number(val); if (!isNaN(num) && Number.isInteger(num)) { handleFieldChange(path, num); diff --git a/client/src/components/ToolsTab.tsx b/client/src/components/ToolsTab.tsx index 15b85fd67..77345f1ea 100644 --- a/client/src/components/ToolsTab.tsx +++ b/client/src/components/ToolsTab.tsx @@ -20,6 +20,7 @@ import { normalizeUnionType, resolveRef, } from "@/utils/schemaUtils"; +import { shouldDeferNumericCommit } from "@/utils/numericInputUtils"; import { CompatibilityCallToolResult, ListToolsResult, @@ -201,6 +202,9 @@ const ToolsTab = ({ serverSupportsTaskRequests: boolean; }) => { const [params, setParams] = useState>({}); + const [numericParamDrafts, setNumericParamDrafts] = useState< + Record + >({}); const [runAsTask, setRunAsTask] = useState(false); const [isToolRunning, setIsToolRunning] = useState(false); const [isOutputSchemaExpanded, setIsOutputSchemaExpanded] = useState(false); @@ -243,6 +247,7 @@ const ToolsTab = ({ ]; }); setParams(Object.fromEntries(params)); + setNumericParamDrafts({}); const toolTaskSupport = serverSupportsTaskRequests ? getTaskSupport(selectedTool) : "forbidden"; @@ -518,25 +523,33 @@ const ToolsTab = ({ ) : prop.type === "number" || prop.type === "integer" ? ( { const value = e.target.value; + setNumericParamDrafts((prev) => ({ + ...prev, + [key]: value, + })); if (value === "") { - // Field cleared - set to undefined setParams({ ...params, [key]: undefined, }); - } else { - // Field has value - try to convert to number, but store input either way + } else if (!shouldDeferNumericCommit(value)) { const num = Number(value); if (!isNaN(num)) { setParams({ @@ -544,7 +557,6 @@ const ToolsTab = ({ [key]: num, }); } else { - // Store invalid input as string - let server validate setParams({ ...params, [key]: value, @@ -552,6 +564,32 @@ const ToolsTab = ({ } } }} + onBlur={(e) => { + const value = e.target.value; + setNumericParamDrafts((prev) => { + if ( + !Object.prototype.hasOwnProperty.call( + prev, + key, + ) + ) { + return prev; + } + const next = { ...prev }; + delete next[key]; + return next; + }); + if (value === "") { + return; + } + const num = Number(value); + if (!isNaN(num)) { + setParams({ + ...params, + [key]: num, + }); + } + }} className="mt-1" /> ) : ( diff --git a/client/src/components/__tests__/DynamicJsonForm.test.tsx b/client/src/components/__tests__/DynamicJsonForm.test.tsx index a273f75ae..8da940c28 100644 --- a/client/src/components/__tests__/DynamicJsonForm.test.tsx +++ b/client/src/components/__tests__/DynamicJsonForm.test.tsx @@ -384,9 +384,9 @@ describe("DynamicJsonForm Number Fields", () => { , ); - const input = screen.getByRole("spinbutton"); - expect(input).toHaveProperty("min", "0.5"); - expect(input).toHaveProperty("max", "99.9"); + const input = screen.getByRole("textbox"); + expect(input).toHaveProperty("type", "text"); + expect(input).toHaveProperty("inputMode", "decimal"); }); it("should accept decimal values", () => { @@ -397,7 +397,7 @@ describe("DynamicJsonForm Number Fields", () => { }; render(); - const input = screen.getByRole("spinbutton"); + const input = screen.getByRole("textbox"); fireEvent.change(input, { target: { value: "98.6" } }); expect(onChange).toHaveBeenCalledWith(98.6); @@ -417,7 +417,7 @@ describe("DynamicJsonForm Number Fields", () => { }; render(); - const input = screen.getByRole("spinbutton") as HTMLInputElement; + const input = screen.getByRole("textbox") as HTMLInputElement; fireEvent.change(input, { target: { value: "-74.0" } }); expect(input.value).toBe("-74.0"); @@ -425,6 +425,29 @@ describe("DynamicJsonForm Number Fields", () => { fireEvent.change(input, { target: { value: "-74.01" } }); expect(input.value).toBe("-74.01"); }); + + it("should preserve 1.0 while typing digit by digit", () => { + const schema: JsonSchemaType = { + type: "number", + description: "Float parameter", + }; + + const WrappedForm = () => { + const [value, setValue] = useState(0); + return ( + + ); + }; + + render(); + const input = screen.getByRole("textbox") as HTMLInputElement; + + fireEvent.change(input, { target: { value: "1." } }); + expect(input.value).toBe("1."); + + fireEvent.change(input, { target: { value: "1.0" } }); + expect(input.value).toBe("1.0"); + }); }); }); diff --git a/client/src/utils/__tests__/numericInputUtils.test.ts b/client/src/utils/__tests__/numericInputUtils.test.ts new file mode 100644 index 000000000..df74d1779 --- /dev/null +++ b/client/src/utils/__tests__/numericInputUtils.test.ts @@ -0,0 +1,19 @@ +import { shouldDeferNumericCommit } from "../numericInputUtils"; + +describe("shouldDeferNumericCommit", () => { + it("defers partial decimal input", () => { + expect(shouldDeferNumericCommit("1.")).toBe(true); + expect(shouldDeferNumericCommit("-74.")).toBe(true); + }); + + it("defers trailing-zero decimals", () => { + expect(shouldDeferNumericCommit("1.0")).toBe(true); + expect(shouldDeferNumericCommit("-74.0")).toBe(true); + expect(shouldDeferNumericCommit("10.50")).toBe(true); + }); + + it("commits complete non-zero-ending decimals", () => { + expect(shouldDeferNumericCommit("98.6")).toBe(false); + expect(shouldDeferNumericCommit("10.5")).toBe(false); + }); +}); diff --git a/client/src/utils/numericInputUtils.ts b/client/src/utils/numericInputUtils.ts new file mode 100644 index 000000000..324d83823 --- /dev/null +++ b/client/src/utils/numericInputUtils.ts @@ -0,0 +1,13 @@ +/** + * Returns true when a numeric text input should defer committing its parsed + * value. This keeps in-progress decimals (e.g. "1." or "1.0") visible while + * typing instead of collapsing to an integer display value. + */ +export function shouldDeferNumericCommit(value: string): boolean { + if (!value || value.endsWith(".")) { + return true; + } + + // Preserve trailing zeros after the decimal point (e.g. "1.0", "-74.0"). + return /^-?\d+\.\d*0$/.test(value); +}