Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 5 additions & 3 deletions client/src/components/DynamicJsonForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -462,14 +463,15 @@ const DynamicJsonForm = forwardRef<DynamicJsonFormRef, DynamicJsonFormProps>(
case "number":
return (
<Input
type="number"
type="text"
inputMode="decimal"
value={getNumericDisplayValue(path, currentValue)}
onChange={(e) => {
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);
Expand Down Expand Up @@ -507,7 +509,7 @@ const DynamicJsonForm = forwardRef<DynamicJsonFormRef, DynamicJsonFormProps>(
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);
Expand Down
54 changes: 46 additions & 8 deletions client/src/components/ToolsTab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import {
normalizeUnionType,
resolveRef,
} from "@/utils/schemaUtils";
import { shouldDeferNumericCommit } from "@/utils/numericInputUtils";
import {
CompatibilityCallToolResult,
ListToolsResult,
Expand Down Expand Up @@ -201,6 +202,9 @@ const ToolsTab = ({
serverSupportsTaskRequests: boolean;
}) => {
const [params, setParams] = useState<Record<string, unknown>>({});
const [numericParamDrafts, setNumericParamDrafts] = useState<
Record<string, string>
>({});
const [runAsTask, setRunAsTask] = useState(false);
const [isToolRunning, setIsToolRunning] = useState(false);
const [isOutputSchemaExpanded, setIsOutputSchemaExpanded] = useState(false);
Expand Down Expand Up @@ -243,6 +247,7 @@ const ToolsTab = ({
];
});
setParams(Object.fromEntries(params));
setNumericParamDrafts({});
const toolTaskSupport = serverSupportsTaskRequests
? getTaskSupport(selectedTool)
: "forbidden";
Expand Down Expand Up @@ -518,40 +523,73 @@ const ToolsTab = ({
) : prop.type === "number" ||
prop.type === "integer" ? (
<Input
type="number"
type="text"
inputMode="decimal"
id={key}
name={key}
placeholder={prop.description}
value={
params[key] === undefined
? ""
: String(params[key])
Object.prototype.hasOwnProperty.call(
numericParamDrafts,
key,
)
? numericParamDrafts[key]
: params[key] === undefined
? ""
: String(params[key])
}
onChange={(e) => {
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({
...params,
[key]: num,
});
} else {
// Store invalid input as string - let server validate
setParams({
...params,
[key]: value,
});
}
}
}}
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"
/>
) : (
Expand Down
33 changes: 28 additions & 5 deletions client/src/components/__tests__/DynamicJsonForm.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -384,9 +384,9 @@ describe("DynamicJsonForm Number Fields", () => {
<DynamicJsonForm schema={schema} value={0} onChange={jest.fn()} />,
);

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", () => {
Expand All @@ -397,7 +397,7 @@ describe("DynamicJsonForm Number Fields", () => {
};
render(<DynamicJsonForm schema={schema} value={0} onChange={onChange} />);

const input = screen.getByRole("spinbutton");
const input = screen.getByRole("textbox");
fireEvent.change(input, { target: { value: "98.6" } });

expect(onChange).toHaveBeenCalledWith(98.6);
Expand All @@ -417,14 +417,37 @@ describe("DynamicJsonForm Number Fields", () => {
};

render(<WrappedForm />);
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");

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<number>(0);
return (
<DynamicJsonForm schema={schema} value={value} onChange={setValue} />
);
};

render(<WrappedForm />);
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");
});
});
});

Expand Down
19 changes: 19 additions & 0 deletions client/src/utils/__tests__/numericInputUtils.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
13 changes: 13 additions & 0 deletions client/src/utils/numericInputUtils.ts
Original file line number Diff line number Diff line change
@@ -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);
}