diff --git a/client/src/components/ChatTabV2.tsx b/client/src/components/ChatTabV2.tsx index 3395488db..afc0f27b0 100644 --- a/client/src/components/ChatTabV2.tsx +++ b/client/src/components/ChatTabV2.tsx @@ -28,6 +28,11 @@ import { import { useJsonRpcPanelVisibility } from "@/hooks/use-json-rpc-panel"; import { CollapsedPanelStrip } from "@/components/ui/collapsed-panel-strip"; import { useChatSession } from "@/hooks/use-chat-session"; +import { + isToolPart, + isDynamicTool, + getToolInfo, +} from "@/components/chat-v2/thread/thread-helpers"; interface ChatTabProps { connectedServerConfigs: Record; @@ -76,6 +81,9 @@ export function ChatTabV2({ ); const [elicitationLoading, setElicitationLoading] = useState(false); const [isWidgetFullscreen, setIsWidgetFullscreen] = useState(false); + const [cancelledToolIds, setCancelledToolIds] = useState>( + new Set(), + ); // Filter to only connected servers const selectedConnectedServerNames = useMemo( @@ -122,9 +130,42 @@ export function ChatTabV2({ onReset: () => { setInput(""); setWidgetStateQueue([]); + setCancelledToolIds(new Set()); }, }); + // Wrapped stop function that tracks cancelled tool IDs + const handleStop = useCallback(() => { + // Find all in-progress tool calls and mark them as cancelled + const inProgressToolIds = new Set(); + for (const msg of messages) { + for (const part of msg.parts ?? []) { + if (isToolPart(part) || isDynamicTool(part)) { + const toolInfo = getToolInfo(part); + if ( + toolInfo.toolCallId && + toolInfo.toolState !== "output-available" && + toolInfo.toolState !== "output-error" + ) { + inProgressToolIds.add(toolInfo.toolCallId); + } + } + } + } + + if (inProgressToolIds.size > 0) { + setCancelledToolIds((prev) => { + const next = new Set(prev); + for (const id of inProgressToolIds) { + next.add(id); + } + return next; + }); + } + + stop(); + }, [messages, stop]); + // Check if thread is empty const isThreadEmpty = !messages.some( (msg) => msg.role === "user" || msg.role === "assistant", @@ -395,7 +436,7 @@ export function ChatTabV2({ value: input, onChange: setInput, onSubmit, - stop, + stop: handleStop, disabled: inputDisabled, isLoading: isStreaming, placeholder, @@ -514,6 +555,7 @@ export function ChatTabV2({ enableFullscreenChatOverlay fullscreenChatPlaceholder={placeholder} fullscreenChatDisabled={inputDisabled} + cancelledToolIds={cancelledToolIds} /> {errorMessage && (
diff --git a/client/src/components/chat-v2/thread.tsx b/client/src/components/chat-v2/thread.tsx index c322b75ef..7c9cf6919 100644 --- a/client/src/components/chat-v2/thread.tsx +++ b/client/src/components/chat-v2/thread.tsx @@ -22,6 +22,8 @@ interface ThreadProps { enableFullscreenChatOverlay?: boolean; fullscreenChatPlaceholder?: string; fullscreenChatDisabled?: boolean; + /** Set of tool call IDs that have been cancelled */ + cancelledToolIds?: Set; } export function Thread({ @@ -38,6 +40,7 @@ export function Thread({ enableFullscreenChatOverlay = false, fullscreenChatPlaceholder = "Message…", fullscreenChatDisabled = false, + cancelledToolIds, }: ThreadProps) { const [pipWidgetId, setPipWidgetId] = useState(null); const [fullscreenWidgetId, setFullscreenWidgetId] = useState( @@ -105,6 +108,7 @@ export function Thread({ onExitFullscreen={handleExitFullscreen} displayMode={displayMode} onDisplayModeChange={onDisplayModeChange} + cancelledToolIds={cancelledToolIds} /> ))} {isLoading && } diff --git a/client/src/components/chat-v2/thread/mcp-apps-renderer.tsx b/client/src/components/chat-v2/thread/mcp-apps-renderer.tsx index 6db7ff08d..6d28d6a1e 100644 --- a/client/src/components/chat-v2/thread/mcp-apps-renderer.tsx +++ b/client/src/components/chat-v2/thread/mcp-apps-renderer.tsx @@ -90,6 +90,10 @@ interface MCPAppsRendererProps { onDisplayModeChange?: (mode: DisplayMode) => void; onRequestFullscreen?: (toolCallId: string) => void; onExitFullscreen?: (toolCallId: string) => void; + /** Whether the tool was cancelled (SEP-1865 ui/notifications/tool-cancelled) */ + toolCancelled?: boolean; + /** Reason for tool cancellation */ + toolCancelReason?: string; } class LoggingTransport implements Transport { @@ -171,6 +175,8 @@ export function MCPAppsRenderer({ onDisplayModeChange, onRequestFullscreen, onExitFullscreen, + toolCancelled, + toolCancelReason, }: MCPAppsRendererProps) { const sandboxRef = useRef(null); const themeMode = usePreferencesStore((s) => s.themeMode); @@ -322,6 +328,7 @@ export function MCPAppsRenderer({ const lastToolInputRef = useRef(null); const lastToolOutputRef = useRef(null); const lastToolErrorRef = useRef(null); + const lastToolCancelledRef = useRef(false); const isReadyRef = useRef(false); const onSendFollowUpRef = useRef(onSendFollowUp); @@ -335,9 +342,10 @@ export function MCPAppsRenderer({ const toolCallIdRef = useRef(toolCallId); const pipWidgetIdRef = useRef(pipWidgetId); - // Fetch widget HTML when tool output is available or CSP mode changes + // Fetch widget HTML when tool input is available (for cancellation support) or output is available + // Loading early allows us to send cancellation notifications to the widget during execution useEffect(() => { - if (toolState !== "output-available") return; + if (toolState !== "input-available" && toolState !== "output-available") return; // Re-fetch if CSP mode changed (widget needs to reload with new CSP policy) if (widgetHtml && loadedCspMode === cspMode) return; @@ -860,8 +868,10 @@ export function MCPAppsRenderer({ bridge.setHostContext(hostContext); }, [hostContext, isReady]); + // Send tool input when available (works with input-available for early widget loading) useEffect(() => { - if (!isReady || toolState !== "output-available") return; + if (!isReady) return; + if (toolState !== "input-available" && toolState !== "output-available") return; const bridge = bridgeRef.current; if (!bridge || lastToolInputRef.current !== null) return; @@ -902,10 +912,26 @@ export function MCPAppsRenderer({ }); }, [isReady, toolErrorText, toolOutput, toolState]); + // SEP-1865: Send tool cancellation notification + useEffect(() => { + if (!isReady || !toolCancelled) return; + const bridge = bridgeRef.current; + if (!bridge) return; + + // Prevent duplicate cancellation notifications + if (lastToolCancelledRef.current) return; + lastToolCancelledRef.current = true; + + bridge.sendToolCancelled?.({ + reason: toolCancelReason ?? "Tool execution was cancelled", + }); + }, [isReady, toolCancelled, toolCancelReason]); + useEffect(() => { lastToolInputRef.current = null; lastToolOutputRef.current = null; lastToolErrorRef.current = null; + lastToolCancelledRef.current = false; }, [toolCallId]); const handleSandboxMessage = (event: MessageEvent) => { @@ -945,11 +971,23 @@ export function MCPAppsRenderer({ ); }; - // Loading states - if (toolState !== "output-available") { + // Loading states - show placeholder only during input-streaming (before input is complete) + // Once input-available, we load the widget for cancellation support + if (toolState !== "input-available" && toolState !== "output-available" && toolState !== "output-error") { + // Show cancelled state if tool was cancelled before input was available + if (toolCancelled) { + return ( +
+
Tool Cancelled
+
+ {toolCancelReason ?? "Operation was cancelled"} +
+
+ ); + } return (
- Waiting for tool to finish executing... + Waiting for tool input...
); } diff --git a/client/src/components/chat-v2/thread/message-view.tsx b/client/src/components/chat-v2/thread/message-view.tsx index e0938d20c..e0c0b5077 100644 --- a/client/src/components/chat-v2/thread/message-view.tsx +++ b/client/src/components/chat-v2/thread/message-view.tsx @@ -25,6 +25,7 @@ export function MessageView({ onExitFullscreen, displayMode, onDisplayModeChange, + cancelledToolIds, }: { message: UIMessage; model: ModelDefinition; @@ -40,6 +41,7 @@ export function MessageView({ onExitFullscreen: (toolCallId: string) => void; displayMode?: DisplayMode; onDisplayModeChange?: (mode: DisplayMode) => void; + cancelledToolIds?: Set; }) { const themeMode = usePreferencesStore((s) => s.themeMode); const logoSrc = getProviderLogoFromModel(model, themeMode); @@ -67,6 +69,7 @@ export function MessageView({ onExitFullscreen={onExitFullscreen} displayMode={displayMode} onDisplayModeChange={onDisplayModeChange} + cancelledToolIds={cancelledToolIds} /> ))} @@ -108,6 +111,7 @@ export function MessageView({ onExitFullscreen={onExitFullscreen} displayMode={displayMode} onDisplayModeChange={onDisplayModeChange} + cancelledToolIds={cancelledToolIds} /> ))}
diff --git a/client/src/components/chat-v2/thread/part-switch.tsx b/client/src/components/chat-v2/thread/part-switch.tsx index 48b5d5eb8..aad5f7446 100644 --- a/client/src/components/chat-v2/thread/part-switch.tsx +++ b/client/src/components/chat-v2/thread/part-switch.tsx @@ -47,6 +47,7 @@ export function PartSwitch({ onExitFullscreen, displayMode, onDisplayModeChange, + cancelledToolIds, }: { part: AnyPart; role: UIMessage["role"]; @@ -62,6 +63,7 @@ export function PartSwitch({ onExitFullscreen: (toolCallId: string) => void; displayMode?: DisplayMode; onDisplayModeChange?: (mode: DisplayMode) => void; + cancelledToolIds?: Set; }) { if (isToolPart(part) || isDynamicTool(part)) { const toolPart = part as ToolUIPart | DynamicToolUIPart; @@ -134,6 +136,12 @@ export function PartSwitch({ onDisplayModeChange={onDisplayModeChange} onRequestFullscreen={onRequestFullscreen} onExitFullscreen={onExitFullscreen} + toolCancelled={ + toolInfo.toolCallId + ? cancelledToolIds?.has(toolInfo.toolCallId) + : false + } + toolCancelReason="Operation was cancelled by user" /> ); diff --git a/client/src/components/ui-playground/PlaygroundLeft.tsx b/client/src/components/ui-playground/PlaygroundLeft.tsx index ddd5d9535..075755ec4 100644 --- a/client/src/components/ui-playground/PlaygroundLeft.tsx +++ b/client/src/components/ui-playground/PlaygroundLeft.tsx @@ -41,6 +41,7 @@ interface PlaygroundLeftProps { onToggleField: (name: string, isSet: boolean) => void; isExecuting: boolean; onExecute: () => void; + onCancel: () => void; onSave: () => void; // Saved requests savedRequests: SavedRequest[]; @@ -69,6 +70,7 @@ export function PlaygroundLeft({ onToggleField, isExecuting, onExecute, + onCancel, onSave, savedRequests, filteredSavedRequests, @@ -166,6 +168,7 @@ export function PlaygroundLeft({ canSave={!!selectedToolName} fetchingTools={fetchingTools} onExecute={onExecute} + onCancel={onCancel} onSave={onSave} onRefresh={onRefresh} onClose={onClose} diff --git a/client/src/components/ui-playground/PlaygroundMain.tsx b/client/src/components/ui-playground/PlaygroundMain.tsx index cfdcac4f5..a15c7fbdf 100644 --- a/client/src/components/ui-playground/PlaygroundMain.tsx +++ b/client/src/components/ui-playground/PlaygroundMain.tsx @@ -39,6 +39,11 @@ import { formatErrorMessage } from "@/components/chat-v2/shared/chat-helpers"; import { ErrorBox } from "@/components/chat-v2/error"; import { ConfirmChatResetDialog } from "@/components/chat-v2/chat-input/dialogs/confirm-chat-reset-dialog"; import { useChatSession } from "@/hooks/use-chat-session"; +import { + isToolPart, + isDynamicTool, + getToolInfo, +} from "@/components/chat-v2/thread/thread-helpers"; import { Button } from "@/components/ui/button"; import { Tooltip, @@ -183,6 +188,8 @@ interface PlaygroundMainProps { // Timezone (IANA) per SEP-1865 timeZone?: string; onTimeZoneChange?: (timeZone: string) => void; + // Callback to expose the stop function to parent + onStopReady?: (stopFn: () => void) => void; } function ScrollToBottomButton() { @@ -245,6 +252,7 @@ export function PlaygroundMain({ onLocaleChange, timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC", onTimeZoneChange, + onStopReady, }: PlaygroundMainProps) { const { signUp } = useAuth(); const posthog = usePostHog(); @@ -260,6 +268,9 @@ export function PlaygroundMain({ const [localePopoverOpen, setLocalePopoverOpen] = useState(false); const [cspPopoverOpen, setCspPopoverOpen] = useState(false); const [timezonePopoverOpen, setTimezonePopoverOpen] = useState(false); + const [cancelledToolIds, setCancelledToolIds] = useState>( + new Set(), + ); // Custom viewport from store const customViewport = useUIPlaygroundStore((s) => s.customViewport); @@ -324,9 +335,47 @@ export function PlaygroundMain({ selectedServers, onReset: () => { setInput(""); + setCancelledToolIds(new Set()); }, }); + // Wrapped stop function that tracks cancelled tool IDs + const handleStop = useCallback(() => { + // Find all in-progress tool calls and mark them as cancelled + const inProgressToolIds = new Set(); + for (const msg of messages) { + for (const part of msg.parts ?? []) { + if (isToolPart(part) || isDynamicTool(part)) { + const toolInfo = getToolInfo(part); + if ( + toolInfo.toolCallId && + toolInfo.toolState !== "output-available" && + toolInfo.toolState !== "output-error" + ) { + inProgressToolIds.add(toolInfo.toolCallId); + } + } + } + } + + if (inProgressToolIds.size > 0) { + setCancelledToolIds((prev) => { + const next = new Set(prev); + for (const id of inProgressToolIds) { + next.add(id); + } + return next; + }); + } + + stop(); + }, [messages, stop]); + + // Expose stop function to parent + useEffect(() => { + onStopReady?.(handleStop); + }, [onStopReady, handleStop]); + // Set playground active flag for widget renderers to read const setPlaygroundActive = useUIPlaygroundStore( (s) => s.setPlaygroundActive, @@ -483,7 +532,7 @@ export function PlaygroundMain({ value: input, onChange: setInput, onSubmit, - stop, + stop: handleStop, disabled: inputDisabled, isLoading: isStreaming, placeholder, @@ -573,6 +622,7 @@ export function PlaygroundMain({ displayMode={displayMode} onDisplayModeChange={onDisplayModeChange} onFullscreenChange={setIsWidgetFullscreen} + cancelledToolIds={cancelledToolIds} /> {/* Invoking indicator while tool execution is in progress */} {isExecuting && executingToolName && ( diff --git a/client/src/components/ui-playground/TabHeader.tsx b/client/src/components/ui-playground/TabHeader.tsx index 6c8d70350..6cc15ba59 100644 --- a/client/src/components/ui-playground/TabHeader.tsx +++ b/client/src/components/ui-playground/TabHeader.tsx @@ -4,7 +4,7 @@ * Header with tabs (Tools/Saved) and action buttons (Run, Save, Refresh, Close) */ -import { RefreshCw, Play, Save, PanelLeftClose } from "lucide-react"; +import { RefreshCw, Play, Save, PanelLeftClose, Square } from "lucide-react"; import { Button } from "../ui/button"; interface TabHeaderProps { @@ -17,6 +17,7 @@ interface TabHeaderProps { canSave: boolean; fetchingTools: boolean; onExecute: () => void; + onCancel: () => void; onSave: () => void; onRefresh: () => void; onClose?: () => void; @@ -32,6 +33,7 @@ export function TabHeader({ canSave, fetchingTools, onExecute, + onCancel, onSave, onRefresh, onClose, @@ -108,20 +110,28 @@ export function TabHeader({ )} - {/* Run button */} - + ) : ( + + Run + + )} ); diff --git a/client/src/components/ui-playground/UIPlaygroundTab.tsx b/client/src/components/ui-playground/UIPlaygroundTab.tsx index 30a8bd612..1569f7c3a 100644 --- a/client/src/components/ui-playground/UIPlaygroundTab.tsx +++ b/client/src/components/ui-playground/UIPlaygroundTab.tsx @@ -6,7 +6,7 @@ * allowing users to execute tools and then chat about the results. */ -import { useEffect, useCallback, useMemo, useState } from "react"; +import { useEffect, useCallback, useMemo, useState, useRef } from "react"; import type { Tool } from "@modelcontextprotocol/sdk/types.js"; import { Wrench } from "lucide-react"; import { @@ -122,6 +122,20 @@ export function UIPlaygroundTab({ setToolResponseMetadata, }); + // Ref to store the stop function from PlaygroundMain + const stopFnRef = useRef<(() => void) | null>(null); + + // Handler to receive stop function from PlaygroundMain + const handleStopReady = useCallback((stopFn: () => void) => { + stopFnRef.current = stopFn; + }, []); + + // Cancel handler for the sidebar + const handleCancel = useCallback(() => { + stopFnRef.current?.(); + setIsExecuting(false); + }, [setIsExecuting]); + // Saved requests hook const savedRequestsHook = useSavedRequests({ serverKey, @@ -294,6 +308,7 @@ export function UIPlaygroundTab({ onToggleField={updateFormFieldIsSet} isExecuting={isExecuting} onExecute={executeTool} + onCancel={handleCancel} onSave={savedRequestsHook.openSaveDialog} savedRequests={savedRequestsHook.savedRequests} filteredSavedRequests={filteredSavedRequests} @@ -338,6 +353,7 @@ export function UIPlaygroundTab({ onLocaleChange={handleLocaleChange} timeZone={globals.timeZone} onTimeZoneChange={handleTimeZoneChange} + onStopReady={handleStopReady} />