Skip to content
Draft
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
44 changes: 43 additions & 1 deletion client/src/components/ChatTabV2.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, ServerWithName>;
Expand Down Expand Up @@ -76,6 +81,9 @@ export function ChatTabV2({
);
const [elicitationLoading, setElicitationLoading] = useState(false);
const [isWidgetFullscreen, setIsWidgetFullscreen] = useState(false);
const [cancelledToolIds, setCancelledToolIds] = useState<Set<string>>(
new Set(),
);

// Filter to only connected servers
const selectedConnectedServerNames = useMemo(
Expand Down Expand Up @@ -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<string>();
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",
Expand Down Expand Up @@ -395,7 +436,7 @@ export function ChatTabV2({
value: input,
onChange: setInput,
onSubmit,
stop,
stop: handleStop,
disabled: inputDisabled,
isLoading: isStreaming,
placeholder,
Expand Down Expand Up @@ -514,6 +555,7 @@ export function ChatTabV2({
enableFullscreenChatOverlay
fullscreenChatPlaceholder={placeholder}
fullscreenChatDisabled={inputDisabled}
cancelledToolIds={cancelledToolIds}
/>
{errorMessage && (
<div className="px-4 pb-4 pt-4">
Expand Down
4 changes: 4 additions & 0 deletions client/src/components/chat-v2/thread.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ interface ThreadProps {
enableFullscreenChatOverlay?: boolean;
fullscreenChatPlaceholder?: string;
fullscreenChatDisabled?: boolean;
/** Set of tool call IDs that have been cancelled */
cancelledToolIds?: Set<string>;
}

export function Thread({
Expand All @@ -38,6 +40,7 @@ export function Thread({
enableFullscreenChatOverlay = false,
fullscreenChatPlaceholder = "Message…",
fullscreenChatDisabled = false,
cancelledToolIds,
}: ThreadProps) {
const [pipWidgetId, setPipWidgetId] = useState<string | null>(null);
const [fullscreenWidgetId, setFullscreenWidgetId] = useState<string | null>(
Expand Down Expand Up @@ -105,6 +108,7 @@ export function Thread({
onExitFullscreen={handleExitFullscreen}
displayMode={displayMode}
onDisplayModeChange={onDisplayModeChange}
cancelledToolIds={cancelledToolIds}
/>
))}
{isLoading && <ThinkingIndicator model={model} />}
Expand Down
50 changes: 44 additions & 6 deletions client/src/components/chat-v2/thread/mcp-apps-renderer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -171,6 +175,8 @@ export function MCPAppsRenderer({
onDisplayModeChange,
onRequestFullscreen,
onExitFullscreen,
toolCancelled,
toolCancelReason,
}: MCPAppsRendererProps) {
const sandboxRef = useRef<SandboxedIframeHandle>(null);
const themeMode = usePreferencesStore((s) => s.themeMode);
Expand Down Expand Up @@ -322,6 +328,7 @@ export function MCPAppsRenderer({
const lastToolInputRef = useRef<string | null>(null);
const lastToolOutputRef = useRef<string | null>(null);
const lastToolErrorRef = useRef<string | null>(null);
const lastToolCancelledRef = useRef<boolean>(false);
const isReadyRef = useRef(false);

const onSendFollowUpRef = useRef(onSendFollowUp);
Expand All @@ -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;
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Widget fetch missing output-error state causes infinite loading

Medium Severity

The rendering condition at line 976 was expanded to allow output-error state (in addition to input-available and output-available), but the widget HTML fetch condition at line 348 only triggers for input-available or output-available. If a tool's state goes directly to output-error without passing through input-available, the widget HTML fetch never starts, but rendering proceeds past the early-return check, causing the component to show "Preparing MCP App widget..." indefinitely since widgetHtml will never be populated.

Additional Locations (1)

Fix in Cursor Fix in Web

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing cancellation ref reset on CSP mode change

Low Severity

The new lastToolCancelledRef is reset when toolCallId changes (line 934), but it's not reset in the existing CSP mode change effect (lines 450-458) alongside the other refs. When CSP mode changes, the widget reloads as a new iframe instance. The other refs (lastToolInputRef, lastToolOutputRef, lastToolErrorRef) are reset so data can be re-sent to the new widget, but lastToolCancelledRef remains true if the tool was cancelled. This prevents the cancellation notification from being re-sent to the newly loaded widget, leaving it unaware that the tool was cancelled.

Fix in Cursor Fix in Web

// Re-fetch if CSP mode changed (widget needs to reload with new CSP policy)
if (widgetHtml && loadedCspMode === cspMode) return;

Expand Down Expand Up @@ -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;

Expand Down Expand Up @@ -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) => {
Expand Down Expand Up @@ -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 (
<div className="border border-orange-500/40 rounded-md bg-orange-500/10 text-xs text-orange-600 dark:text-orange-400 px-3 py-2">
<div className="font-medium">Tool Cancelled</div>
<div className="text-orange-500/80 mt-1">
{toolCancelReason ?? "Operation was cancelled"}
</div>
</div>
);
}
return (
<div className="border border-border/40 rounded-md bg-muted/30 text-xs text-muted-foreground px-3 py-2">
Waiting for tool to finish executing...
Waiting for tool input...
</div>
);
}
Expand Down
4 changes: 4 additions & 0 deletions client/src/components/chat-v2/thread/message-view.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ export function MessageView({
onExitFullscreen,
displayMode,
onDisplayModeChange,
cancelledToolIds,
}: {
message: UIMessage;
model: ModelDefinition;
Expand All @@ -40,6 +41,7 @@ export function MessageView({
onExitFullscreen: (toolCallId: string) => void;
displayMode?: DisplayMode;
onDisplayModeChange?: (mode: DisplayMode) => void;
cancelledToolIds?: Set<string>;
}) {
const themeMode = usePreferencesStore((s) => s.themeMode);
const logoSrc = getProviderLogoFromModel(model, themeMode);
Expand Down Expand Up @@ -67,6 +69,7 @@ export function MessageView({
onExitFullscreen={onExitFullscreen}
displayMode={displayMode}
onDisplayModeChange={onDisplayModeChange}
cancelledToolIds={cancelledToolIds}
/>
))}
</UserMessageBubble>
Expand Down Expand Up @@ -108,6 +111,7 @@ export function MessageView({
onExitFullscreen={onExitFullscreen}
displayMode={displayMode}
onDisplayModeChange={onDisplayModeChange}
cancelledToolIds={cancelledToolIds}
/>
))}
</div>
Expand Down
8 changes: 8 additions & 0 deletions client/src/components/chat-v2/thread/part-switch.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ export function PartSwitch({
onExitFullscreen,
displayMode,
onDisplayModeChange,
cancelledToolIds,
}: {
part: AnyPart;
role: UIMessage["role"];
Expand All @@ -62,6 +63,7 @@ export function PartSwitch({
onExitFullscreen: (toolCallId: string) => void;
displayMode?: DisplayMode;
onDisplayModeChange?: (mode: DisplayMode) => void;
cancelledToolIds?: Set<string>;
}) {
if (isToolPart(part) || isDynamicTool(part)) {
const toolPart = part as ToolUIPart<UITools> | DynamicToolUIPart;
Expand Down Expand Up @@ -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"
/>
</>
);
Expand Down
3 changes: 3 additions & 0 deletions client/src/components/ui-playground/PlaygroundLeft.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ interface PlaygroundLeftProps {
onToggleField: (name: string, isSet: boolean) => void;
isExecuting: boolean;
onExecute: () => void;
onCancel: () => void;
onSave: () => void;
// Saved requests
savedRequests: SavedRequest[];
Expand Down Expand Up @@ -69,6 +70,7 @@ export function PlaygroundLeft({
onToggleField,
isExecuting,
onExecute,
onCancel,
onSave,
savedRequests,
filteredSavedRequests,
Expand Down Expand Up @@ -166,6 +168,7 @@ export function PlaygroundLeft({
canSave={!!selectedToolName}
fetchingTools={fetchingTools}
onExecute={onExecute}
onCancel={onCancel}
onSave={onSave}
onRefresh={onRefresh}
onClose={onClose}
Expand Down
Loading
Loading