From 948abd425f8185c59e700433a8285e5911b01e7f Mon Sep 17 00:00:00 2001 From: dashitongzhi Date: Thu, 4 Jun 2026 09:14:35 +0800 Subject: [PATCH] feat(tools): show call duration in tool result header (#1284) Displays the round-trip time taken by callTool next to the Success/Error/Task Running badge in the Tools tab. - Tracks performance.now() around the callTool invocation and records the elapsed milliseconds in local state. - Resets the recorded duration when the user switches tools so a previous tool's timing is never shown for a new one. - Formats the value as <1 ms, N ms, N.NN s, or Nm N.Ns. - Adds Jest coverage for show / hide / reset behaviour. Closes #1284. --- client/src/components/ToolResults.tsx | 39 +++++++++ client/src/components/ToolsTab.tsx | 27 ++++-- .../components/__tests__/ToolsTab.test.tsx | 82 ++++++++++++++++++- 3 files changed, 141 insertions(+), 7 deletions(-) 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(); + }); + }); });