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
31 changes: 27 additions & 4 deletions client/src/components/AppsTab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -127,9 +127,29 @@ const AppsTab = ({
}, []);

// Function to check if any form has validation errors
const validateJsonParams = useCallback(() => {
const validatedParams = { ...params };
let hasErrors = false;

for (const [key, ref] of Object.entries(formRefs.current)) {
if (!ref) continue;

const result = ref.validateJson();
if (!result.isValid) {
hasErrors = true;
} else if (result.value !== undefined) {
validatedParams[key] = result.value;
}
}

setHasValidationErrors(hasErrors);
if (!hasErrors) setParams(validatedParams);
return { hasErrors, validatedParams };
}, [params]);

const checkValidationErrors = useCallback(() => {
const errors = Object.values(formRefs.current).some(
(ref) => ref && !ref.validateJson().isValid,
(ref) => ref && ref.hasJsonError(),
);
setHasValidationErrors(errors);
return errors;
Expand Down Expand Up @@ -249,12 +269,15 @@ const AppsTab = ({
}, []);

const handleOpenApp = useCallback(async () => {
if (!selectedTool || checkValidationErrors()) {
if (!selectedTool) {
return;
}

await executeToolAndOpenApp(selectedTool, params);
}, [checkValidationErrors, executeToolAndOpenApp, params, selectedTool]);
const { hasErrors, validatedParams } = validateJsonParams();
if (hasErrors) return;

await executeToolAndOpenApp(selectedTool, validatedParams);
}, [executeToolAndOpenApp, selectedTool, validateJsonParams]);

const handleSelectTool = useCallback(
(tool: Tool) => {
Expand Down
12 changes: 8 additions & 4 deletions client/src/components/DynamicJsonForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,11 @@ interface DynamicJsonFormProps {
}

export interface DynamicJsonFormRef {
validateJson: () => { isValid: boolean; error: string | null };
validateJson: () => {
isValid: boolean;
error: string | null;
value?: JsonValue;
};
hasJsonError: () => boolean;
}

Expand Down Expand Up @@ -245,18 +249,18 @@ const DynamicJsonForm = forwardRef<DynamicJsonFormRef, DynamicJsonFormProps>(
};

const validateJson = () => {
if (!isJsonMode) return { isValid: true, error: null };
if (!isJsonMode) return { isValid: true, error: null, value };
try {
const jsonStr = rawJsonValue?.trim();
if (!jsonStr) return { isValid: true, error: null };
if (!jsonStr) return { isValid: true, error: null, value };
const parsed = JSON.parse(jsonStr);
// Clear any pending debounced update and immediately update parent
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
onChange(parsed);
setJsonError(undefined);
return { isValid: true, error: null };
return { isValid: true, error: null, value: parsed };
} catch (err) {
const errorMessage =
err instanceof Error ? err.message : "Invalid JSON";
Expand Down
25 changes: 23 additions & 2 deletions client/src/components/ToolsTab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -224,6 +224,26 @@ const ToolsTab = ({
return errors;
};

const validateJsonParams = () => {
const validatedParams = { ...params };
let hasErrors = false;

for (const [key, ref] of Object.entries(formRefs.current)) {
if (!ref) continue;

const result = ref.validateJson();
if (!result.isValid) {
hasErrors = true;
} else if (result.value !== undefined) {
validatedParams[key] = result.value;
}
}

setHasValidationErrors(hasErrors);
if (!hasErrors) setParams(validatedParams);
return { hasErrors, validatedParams };
};

useEffect(() => {
const params = Object.entries(
selectedTool?.inputSchema.properties ?? [],
Expand Down Expand Up @@ -808,7 +828,8 @@ const ToolsTab = ({
<Button
onClick={async () => {
// Validate JSON inputs before calling tool
if (checkValidationErrors(true)) return;
const { hasErrors, validatedParams } = validateJsonParams();
if (hasErrors) return;

try {
setIsToolRunning(true);
Expand All @@ -828,7 +849,7 @@ const ToolsTab = ({
}, {});
await callTool(
selectedTool.name,
params,
validatedParams,
Object.keys(metadata).length ? metadata : undefined,
runAsTask,
);
Expand Down
33 changes: 33 additions & 0 deletions client/src/components/__tests__/AppsTab.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -662,6 +662,39 @@ describe("AppsTab", () => {
expect(toolInput.query).toBe("first value");
});

it("should open an app with the latest JSON value", async () => {
const jsonApp: Tool = {
name: "jsonApp",
inputSchema: {
type: "object",
properties: {
config: { type: "object", additionalProperties: true },
},
},
_meta: { ui: { resourceUri: "ui://json" } },
} as Tool & { _meta?: { ui?: { resourceUri?: string } } };
const mockCallTool = jest.fn(
async () =>
({
content: [{ type: "text", text: "done" }],
}) as CompatibilityCallToolResult,
);

renderAppsTab({ tools: [jsonApp], callTool: mockCallTool });

fireEvent.click(screen.getByText("jsonApp"));
fireEvent.change(screen.getByRole("textbox"), {
target: { value: '{"key":"updated"}' },
});
fireEvent.click(screen.getByRole("button", { name: /open app/i }));

await waitFor(() => {
expect(mockCallTool).toHaveBeenCalledWith("jsonApp", {
config: { key: "updated" },
});
});
});

it("should keep input view when opening fails and recover button state", async () => {
const toolWithFields: Tool = {
name: "failingApp",
Expand Down
28 changes: 28 additions & 0 deletions client/src/components/__tests__/ToolsTab.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -909,6 +909,34 @@ describe("ToolsTab", () => {
});
});

it("should submit the latest JSON value without waiting for the debounce", async () => {
const jsonTool: Tool = {
name: "jsonTool",
inputSchema: {
type: "object",
properties: {
config: { type: "object", additionalProperties: true },
},
},
};
const callToolMock = jest.fn(async () => {});

renderToolsTab({ selectedTool: jsonTool, callTool: callToolMock });

fireEvent.change(screen.getByRole("textbox"), {
target: { value: '{"key":"updated"}' },
});
fireEvent.click(screen.getByRole("button", { name: /run tool/i }));

await act(async () => {});
expect(callToolMock).toHaveBeenCalledWith(
"jsonTool",
{ config: { key: "updated" } },
undefined,
false,
);
});

describe("Reserved metadata keys", () => {
test.each`
description | value | message
Expand Down