feat(web): wire up MCP task support — Tasks screen, Run-as-task, status toasts (#1422) (#1424)

Wires the ported Tasks screen + core task subsystem through App.tsx: sticky Clear Completed, cancel/refresh error toasts, core-side per-task progress correlation, Run-as-task on the Tools screen (gated on server support), per-server Task TTL, and live task-status toasts.
This commit is contained in:
Cliff Hall 2026-06-04 08:49:47 -04:00 • committed by GitHub
commit ed6eda7492
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 1192 additions and 40 deletions

View file

@ -10,8 +10,11 @@ const meta: Meta<typeof ToolDetailPanel> = {
onFormChange: fn(),
onExecute: fn(),
onCancel: fn(),
onRunAsTaskChange: fn(),
formValues: {},
isExecuting: false,
serverSupportsTaskToolCalls: false,
runAsTask: false,
},
};
@ -142,3 +145,45 @@ export const WithProgress: Story = {
formValues: { batchSize: 100 },
},
};
const optionalTaskTool: Tool = {
name: "summarize_corpus",
description: "Summarizes a large corpus — can run as a background task",
execution: { taskSupport: "optional" },
inputSchema: {
type: "object",
properties: {
corpus: { type: "string", description: "Text to summarize" },
},
},
};
const requiredTaskTool: Tool = {
name: "train_model",
description: "Long-running training job — always runs as a task",
execution: { taskSupport: "required" },
inputSchema: {
type: "object",
properties: {
dataset: { type: "string", description: "Dataset id" },
},
},
};
// Server supports task tool calls + tool is `optional`: the toggle renders
// enabled and off by default.
export const RunAsTaskOptional: Story = {
args: {
tool: optionalTaskTool,
serverSupportsTaskToolCalls: true,
runAsTask: false,
},
};
// Tool is `required`: the toggle is forced on and disabled.
export const RunAsTaskRequired: Story = {
args: {
tool: requiredTaskTool,
serverSupportsTaskToolCalls: true,
},
};

View file

@ -59,6 +59,9 @@ const annotatedTool: Tool = {
const baseProps = {
formValues: {},
isExecuting: false,
serverSupportsTaskToolCalls: false,
runAsTask: false,
onRunAsTaskChange: vi.fn(),
onFormChange: vi.fn(),
onExecute: vi.fn(),
onCancel: vi.fn(),
@ -204,4 +207,144 @@ describe("ToolDetailPanel", () => {
await user.type(input, "hi");
expect(onFormChange).toHaveBeenCalled();
});
describe("Run as task toggle", () => {
const toolWithSupport = (
taskSupport: "forbidden" | "optional" | "required",
): Tool => ({
name: "run_job",
execution: { taskSupport },
inputSchema: { type: "object", properties: {} },
});
it("hides the toggle when the server doesn't support task tool calls", () => {
renderWithMantine(
<ToolDetailPanel
{...baseProps}
tool={toolWithSupport("optional")}
serverSupportsTaskToolCalls={false}
/>,
);
expect(screen.queryByLabelText("Run as task")).not.toBeInTheDocument();
});
it("hides the toggle for a task-forbidden tool even when the server supports tasks", () => {
renderWithMantine(
<ToolDetailPanel
{...baseProps}
tool={toolWithSupport("forbidden")}
serverSupportsTaskToolCalls={true}
/>,
);
expect(screen.queryByLabelText("Run as task")).not.toBeInTheDocument();
});
it("shows an unchecked, enabled toggle for an optional tool (off by default)", () => {
renderWithMantine(
<ToolDetailPanel
{...baseProps}
tool={toolWithSupport("optional")}
serverSupportsTaskToolCalls={true}
runAsTask={false}
/>,
);
const toggle = screen.getByLabelText("Run as task");
expect(toggle).not.toBeChecked();
expect(toggle).not.toBeDisabled();
});
it("reflects the runAsTask prop for an optional tool", () => {
renderWithMantine(
<ToolDetailPanel
{...baseProps}
tool={toolWithSupport("optional")}
serverSupportsTaskToolCalls={true}
runAsTask={true}
/>,
);
expect(screen.getByLabelText("Run as task")).toBeChecked();
});
it("forces the toggle on and disabled for a required tool", () => {
renderWithMantine(
<ToolDetailPanel
{...baseProps}
tool={toolWithSupport("required")}
serverSupportsTaskToolCalls={true}
runAsTask={false}
/>,
);
const toggle = screen.getByLabelText("Run as task");
expect(toggle).toBeChecked();
expect(toggle).toBeDisabled();
});
it("invokes onRunAsTaskChange when an optional toggle is clicked", async () => {
const user = userEvent.setup();
const onRunAsTaskChange = vi.fn();
renderWithMantine(
<ToolDetailPanel
{...baseProps}
tool={toolWithSupport("optional")}
serverSupportsTaskToolCalls={true}
runAsTask={false}
onRunAsTaskChange={onRunAsTaskChange}
/>,
);
await user.click(screen.getByLabelText("Run as task"));
expect(onRunAsTaskChange).toHaveBeenCalledWith(true);
});
it("passes the effective run-as-task decision to onExecute", async () => {
const user = userEvent.setup();
const onExecute = vi.fn();
// optional + runAsTask=true → effective true
renderWithMantine(
<ToolDetailPanel
{...baseProps}
tool={toolWithSupport("optional")}
serverSupportsTaskToolCalls={true}
runAsTask={true}
onExecute={onExecute}
/>,
);
await user.click(screen.getByRole("button", { name: "Execute Tool" }));
expect(onExecute).toHaveBeenCalledWith(true);
});
it("passes runAsTask=true to onExecute for a required tool regardless of the prop", async () => {
const user = userEvent.setup();
const onExecute = vi.fn();
renderWithMantine(
<ToolDetailPanel
{...baseProps}
tool={toolWithSupport("required")}
serverSupportsTaskToolCalls={true}
runAsTask={false}
onExecute={onExecute}
/>,
);
await user.click(screen.getByRole("button", { name: "Execute Tool" }));
expect(onExecute).toHaveBeenCalledWith(true);
});
it("gates onExecute to false when the server lacks task-tool-call support, even with a stale runAsTask", async () => {
const user = userEvent.setup();
const onExecute = vi.fn();
// The toggle is hidden (server doesn't support it), but a leftover
// runAsTask=true must not leak into the effective decision.
renderWithMantine(
<ToolDetailPanel
{...baseProps}
tool={toolWithSupport("optional")}
serverSupportsTaskToolCalls={false}
runAsTask={true}
onExecute={onExecute}
/>,
);
expect(screen.queryByLabelText("Run as task")).not.toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "Execute Tool" }));
expect(onExecute).toHaveBeenCalledWith(false);
});
});
});

View file

@ -5,6 +5,7 @@ import {
Image,
ScrollArea,
Stack,
Switch,
Text,
} from "@mantine/core";
import type {
@ -27,8 +28,14 @@ export interface ToolDetailPanelProps {
formValues: Record<string, unknown>;
isExecuting: boolean;
progress?: ToolProgress;
/** Whether the connected server advertises task-augmented tool calls. */
serverSupportsTaskToolCalls: boolean;
/** User's "Run as task" preference (only meaningful for `optional` tools). */
runAsTask: boolean;
onRunAsTaskChange: (value: boolean) => void;
onFormChange: (values: Record<string, unknown>) => void;
onExecute: () => void;
/** Receives the effective run-as-task decision for this execution. */
onExecute: (runAsTask: boolean) => void;
onCancel: () => void;
}
@ -95,6 +102,24 @@ const CancelButton = Button.withProps({
color: "red",
});
// Left-aligned row hosting the "Run as task" toggle, above the execute footer.
const TaskToggleRow = Group.withProps({
justify: "flex-start",
flex: "0 0 auto",
});
const RunAsTaskSwitch = Switch.withProps({
size: "sm",
label: "Run as task",
});
// A tool's per-tool task support, defaulting to "forbidden" (the SDK default
// when `execution` is absent) so tools that say nothing can't be run as tasks.
type TaskSupport = "forbidden" | "optional" | "required";
function getTaskSupport(tool: Tool): TaskSupport {
return tool.execution?.taskSupport ?? "forbidden";
}
function hasAnyAnnotation(annotations?: ToolAnnotations): boolean {
return !!(
annotations &&
@ -110,6 +135,9 @@ export function ToolDetailPanel({
formValues,
isExecuting,
progress,
serverSupportsTaskToolCalls,
runAsTask,
onRunAsTaskChange,
onFormChange,
onExecute,
onCancel,
@ -117,6 +145,21 @@ export function ToolDetailPanel({
const { name, title, description, icons, annotations, inputSchema } = tool;
const iconSrc = icons?.[0]?.src;
// Show the toggle only when the server supports task tool calls and the tool
// doesn't forbid them. `required` tools are forced on (checked + disabled);
// `optional` tools follow the user's `runAsTask` choice.
const taskSupport = getTaskSupport(tool);
const showRunAsTask =
serverSupportsTaskToolCalls && taskSupport !== "forbidden";
// Gate the effective decision on `showRunAsTask` (which includes
// `serverSupportsTaskToolCalls`): a stale `runAsTask`/`required` value must
// not route through callToolStream when the toggle is hidden because the
// server doesn't advertise task tool calls. Per spec, a tool's taskSupport is
// only considered when the server advertises `tasks.requests.tools.call`.
const effectiveRunAsTask =
showRunAsTask &&
(taskSupport === "required" || (taskSupport === "optional" && runAsTask));
return (
<PanelStack>
<PinnedHeader>
@ -159,11 +202,21 @@ export function ToolDetailPanel({
</BodyStack>
</BodyScroll>
{showRunAsTask && (
<TaskToggleRow>
<RunAsTaskSwitch
checked={effectiveRunAsTask}
disabled={isExecuting || taskSupport === "required"}
onChange={(event) => onRunAsTaskChange(event.currentTarget.checked)}
/>
</TaskToggleRow>
)}
<FooterRow>
{isExecuting && <CancelButton onClick={onCancel}>Cancel</CancelButton>}
<Button
size="md"
onClick={onExecute}
onClick={() => onExecute(effectiveRunAsTask)}
disabled={isExecuting}
loading={isExecuting}
>