diff --git a/client/src/components/ToolResults.tsx b/client/src/components/ToolResults.tsx index d036298b9..bf8944d8b 100644 --- a/client/src/components/ToolResults.tsx +++ b/client/src/components/ToolResults.tsx @@ -13,8 +13,37 @@ interface ToolResultsProps { resourceContent: Record; onReadResource?: (uri: string) => void; isPollingTask?: boolean; + /** + * Wall-clock time the last `callTool` invocation took, in milliseconds. + * `undefined` means no measurement is available (e.g. legacy result path). + */ + durationMs?: number | null; } +// Format a millisecond duration for display in the tool result header. +// - < 1ms -> "<1 ms" +// - < 1s -> "123 ms" +// - < 1min -> "1.23 s" +// - >= 1m -> "1m 23.4s" +const formatCallDuration = (durationMs: number): string => { + if (!Number.isFinite(durationMs) || durationMs < 0) { + return ""; + } + if (durationMs < 1) { + return "<1 ms"; + } + if (durationMs < 1000) { + return `${Math.round(durationMs)} ms`; + } + if (durationMs < 60_000) { + return `${(durationMs / 1000).toFixed(2)} s`; + } + const totalSeconds = Math.floor(durationMs / 1000); + const minutes = Math.floor(totalSeconds / 60); + const seconds = (durationMs - minutes * 60_000) / 1000; + return `${minutes}m ${seconds.toFixed(1)}s`; +}; + const checkContentCompatibility = ( structuredContent: unknown, unstructuredContent: Array<{ @@ -65,6 +94,7 @@ const ToolResults = ({ resourceContent, onReadResource, isPollingTask, + durationMs, }: ToolResultsProps) => { if (!toolResult) return null; @@ -141,6 +171,15 @@ const ToolResults = ({ ) : ( Success )} + {typeof durationMs === "number" && ( + + ({formatCallDuration(durationMs)}) + + )} {structuredResult.structuredContent && (
diff --git a/client/src/components/ToolsTab.tsx b/client/src/components/ToolsTab.tsx index febea1d8f..42656c56d 100644 --- a/client/src/components/ToolsTab.tsx +++ b/client/src/components/ToolsTab.tsx @@ -207,6 +207,9 @@ const ToolsTab = ({ { id: string; key: string; value: string }[] >([]); const [hasValidationErrors, setHasValidationErrors] = useState(false); + const [lastCallDurationMs, setLastCallDurationMs] = useState( + null, + ); const formRefs = useRef>({}); const { toast } = useToast(); const { copied, setCopied } = useCopy(); @@ -249,6 +252,10 @@ const ToolsTab = ({ // Reset validation errors when switching tools setHasValidationErrors(false); + // Reset the recorded duration so the previous tool's timing doesn't + // appear next to the new tool's result. + setLastCallDurationMs(null); + // Clear form refs for the previous tool formRefs.current = {}; }, [selectedTool, serverSupportsTaskRequests]); @@ -824,12 +831,19 @@ const ToolsTab = ({ } return acc; }, {}); - await callTool( - selectedTool.name, - params, - Object.keys(metadata).length ? metadata : undefined, - runAsTask, - ); + // Measure the call duration so the user can see how + // long the tool took to respond (issue #1284). + const startTime = performance.now(); + try { + await callTool( + selectedTool.name, + params, + Object.keys(metadata).length ? metadata : undefined, + runAsTask, + ); + } finally { + setLastCallDurationMs(performance.now() - startTime); + } } finally { setIsToolRunning(false); } @@ -886,6 +900,7 @@ const ToolsTab = ({ resourceContent={resourceContent} onReadResource={onReadResource} isPollingTask={isPollingTask} + durationMs={lastCallDurationMs} />
) : ( diff --git a/client/src/components/__tests__/ToolsTab.test.tsx b/client/src/components/__tests__/ToolsTab.test.tsx index 5678914d6..ca041aa0f 100644 --- a/client/src/components/__tests__/ToolsTab.test.tsx +++ b/client/src/components/__tests__/ToolsTab.test.tsx @@ -2,7 +2,10 @@ import { render, screen, fireEvent, act } from "@testing-library/react"; import "@testing-library/jest-dom"; import { describe, it, jest, beforeEach } from "@jest/globals"; import ToolsTab, { ExtendedTool } from "../ToolsTab"; -import { Tool } from "@modelcontextprotocol/sdk/types.js"; +import { + CompatibilityCallToolResult, + Tool, +} from "@modelcontextprotocol/sdk/types.js"; import { Tabs } from "../ui/tabs"; import { cacheToolOutputSchemas } from "../../utils/schemaUtils"; import { within } from "@testing-library/react"; @@ -1203,4 +1206,81 @@ describe("ToolsTab", () => { expect(mockCallTool).toHaveBeenCalled(); }); }); + + describe("Tool call duration display", () => { + const successfulResult: CompatibilityCallToolResult = { + content: [{ type: "text", text: "ok" }], + }; + + it("should show the call duration next to the result after running a tool", async () => { + // Add a small but measurable delay so durationMs > 0 in jsdom. + const callToolMock = jest.fn( + () => + new Promise((resolve) => + setTimeout(() => resolve(successfulResult), 25), + ), + ); + + renderToolsTab({ + selectedTool: mockTools[0], + callTool: callToolMock, + toolResult: null, + }); + + const runButton = screen.getByRole("button", { name: /run tool/i }); + await act(async () => { + fireEvent.click(runButton); + }); + + // Re-render with the now-available result so the duration surfaces. + renderToolsTab({ + selectedTool: mockTools[0], + callTool: callToolMock, + toolResult: successfulResult, + }); + + const durationEl = await screen.findByTestId("tool-call-duration"); + expect(durationEl).toBeInTheDocument(); + // The formatted duration is one of "<1 ms", "N ms", "N.NN s", or "Nm N.Ns". + expect(durationEl.textContent).toMatch(/ms|s|m\s/); + }); + + it("should not show the call duration when none has been recorded", () => { + renderToolsTab({ + selectedTool: mockTools[0], + toolResult: successfulResult, + }); + + expect(screen.queryByTestId("tool-call-duration")).not.toBeInTheDocument(); + }); + + it("should clear the recorded duration when switching tools", async () => { + const callToolMock = jest.fn(async () => successfulResult); + + const { rerender } = renderToolsTab({ + selectedTool: mockTools[0], + callTool: callToolMock, + toolResult: null, + }); + + const runButton = screen.getByRole("button", { name: /run tool/i }); + await act(async () => { + fireEvent.click(runButton); + }); + + // Switch to another tool - duration should be reset. + rerender( + + + , + ); + + expect(screen.queryByTestId("tool-call-duration")).not.toBeInTheDocument(); + }); + }); });