diff --git a/client/src/App.tsx b/client/src/App.tsx index 12e9a7bd0..3f9d3758e 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -303,6 +303,7 @@ const App = () => { const [selectedTool, setSelectedTool] = useState(null); const [selectedTask, setSelectedTask] = useState(null); const [isPollingTask, setIsPollingTask] = useState(false); + const pollingAbortControllerRef = useRef(null); const [nextResourceCursor, setNextResourceCursor] = useState< string | undefined >(); @@ -1061,6 +1062,8 @@ const App = () => { if (runAsTask && isTaskResult(response)) { const taskId = response.task.taskId; const pollInterval = response.task.pollInterval; + const abortController = new AbortController(); + pollingAbortControllerRef.current = abortController; // Set polling state BEFORE setting tool result for proper UI update setIsPollingTask(true); // Safely extract any _meta from the original response (if present) @@ -1086,10 +1089,23 @@ const App = () => { // Polling loop let taskCompleted = false; - while (!taskCompleted) { + while (!taskCompleted && !abortController.signal.aborted) { try { - // Wait for 1 second before polling - await new Promise((resolve) => setTimeout(resolve, pollInterval)); + // Wait before polling (cancellable) + await new Promise((resolve, reject) => { + if (abortController.signal.aborted) { + reject(new DOMException("Polling cancelled", "AbortError")); + return; + } + const timer = setTimeout(resolve, pollInterval); + const onAbort = () => { + clearTimeout(timer); + reject(new DOMException("Polling cancelled", "AbortError")); + }; + abortController.signal.addEventListener("abort", onAbort, { + once: true, + }); + }); const taskStatus = await sendMCPRequest( { @@ -1165,6 +1181,22 @@ const App = () => { void listTasks(); } } catch (pollingError) { + if ( + pollingError instanceof DOMException && + pollingError.name === "AbortError" + ) { + latestToolResult = { + content: [ + { + type: "text", + text: `Polling cancelled for task ${taskId}. The task may still be running on the server.`, + }, + ], + }; + setToolResult(latestToolResult); + taskCompleted = true; + break; + } console.error("Error polling task status:", pollingError); latestToolResult = { content: [ @@ -1180,6 +1212,7 @@ const App = () => { } } setIsPollingTask(false); + pollingAbortControllerRef.current = null; // Clear any validation errors since tool execution completed setErrors((prev) => ({ ...prev, tools: null })); return latestToolResult; @@ -1576,6 +1609,9 @@ const App = () => { }} toolResult={toolResult} isPollingTask={isPollingTask} + cancelPolling={() => { + pollingAbortControllerRef.current?.abort(); + }} nextCursor={nextToolCursor} error={errors.tools} resourceContent={resourceContentMap} diff --git a/client/src/components/ToolsTab.tsx b/client/src/components/ToolsTab.tsx index febea1d8f..a6e1e3c49 100644 --- a/client/src/components/ToolsTab.tsx +++ b/client/src/components/ToolsTab.tsx @@ -35,6 +35,7 @@ import { AlertCircle, Copy, CheckCheck, + XCircle, } from "lucide-react"; import { useEffect, useState, useRef } from "react"; import ListPane from "./ListPane"; @@ -173,6 +174,7 @@ const ToolsTab = ({ setSelectedTool, toolResult, isPollingTask, + cancelPolling, nextCursor, error, resourceContent, @@ -192,6 +194,7 @@ const ToolsTab = ({ setSelectedTool: (tool: Tool | null) => void; toolResult: CompatibilityCallToolResult | null; isPollingTask?: boolean; + cancelPolling?: () => void; nextCursor: ListToolsResult["nextCursor"]; error: string | null; resourceContent: Record; @@ -855,6 +858,16 @@ const ToolsTab = ({ )} + {isPollingTask && cancelPolling && ( + + )}