inspector/clients/web/src/components/groups/ToolDetailPanel/ToolDetailPanel.tsx
cliffhall 24a9786337 fix(web): reset a number field's draft text when the form changes entity
Review follow-up. The draft/value re-sync compares parsed numbers, so it
cannot see a reset to an *equal* value. `ToolDetailPanel` is not keyed by
tool, so its fields are reused across selections: type `-` (draft `"-"`,
value `undefined`), switch to a tool with a same-named number field and
no default, and the value is `undefined` on both sides — no divergence is
detected and the stale `-` stays in the box for the new tool.

Add a `resetKey` prop carrying the identity of whatever the form edits,
and vary the number field's React key with it so no in-progress text can
outlive the entity it was typed into. `ToolDetailPanel` passes the tool
name; nested object forms inherit it. The schema object is no substitute
— callers rebuild it every render, so its identity is unstable.

The regression test drives the switch with `fireEvent` rather than
`user.click`: a real click also blurs the input, and Mantine sanitizes an
incomplete value on blur, which masked the defect entirely. A paired test
asserts a *stable* `resetKey` does not remount, since remounting on every
render would wipe the draft and reinstate #1888.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0119LaPQ3v4NyBK3ZTdM9j84
2026-08-11 10:02:59 -04:00

353 lines
11 KiB
TypeScript

import {
ActionIcon,
Button,
Code,
Collapse,
Divider,
Group,
Image,
ScrollArea,
Stack,
Switch,
Text,
} from "@mantine/core";
import { useId, useState } from "react";
import { RiArrowDownSLine, RiArrowRightSLine } from "react-icons/ri";
import type {
ProgressNotification,
Tool,
ToolAnnotations,
} from "@modelcontextprotocol/client";
import { resolveDisplayLabel } from "../../../utils/toolUtils";
import { toFormSchema } from "../../../utils/jsonUtils";
import { getMirroredHeaderParams } from "@inspector/core/json/xMcpHeader.js";
import { AnnotationBadge } from "../../elements/AnnotationBadge/AnnotationBadge";
import { ProgressDisplay } from "../../elements/ProgressDisplay/ProgressDisplay";
import { SchemaForm } from "../SchemaForm/SchemaForm";
export type ToolProgress = Pick<
ProgressNotification["params"],
"progress" | "total" | "message"
>;
export interface ToolDetailPanelProps {
tool: Tool;
formValues: Record<string, unknown>;
isExecuting: boolean;
progress?: ToolProgress;
/** Whether the connected server advertises task-augmented tool calls. */
serverSupportsTaskToolCalls: boolean;
/**
* Modern (2026-07-28) connection with the `io.modelcontextprotocol/tasks`
* extension negotiated (SEP-2663). Task creation is server-directed there, so
* "Run as task" is offered for ANY tool (not just ones declaring per-tool
* `taskSupport`, which is the legacy mechanism). Defaults to false (legacy).
*/
modernTasks?: boolean;
/** User's "Run as task" preference (meaningful for `optional` tools and, on
* modern connections, any tool). */
runAsTask: boolean;
onRunAsTaskChange: (value: boolean) => void;
onFormChange: (values: Record<string, unknown>) => void;
/** Receives the effective run-as-task decision for this execution. */
onExecute: (runAsTask: boolean) => void;
onCancel: () => void;
}
// Outer column: title/annotations pin at top, the Execute footer pins at the
// bottom, and the middle (description + form + progress) scrolls when the
// enclosing card hits its `mah`. `mih: 0` lets the flex children shrink.
const PanelStack = Stack.withProps({
gap: "md",
miw: 0,
mih: 0,
});
const PinnedHeader = Stack.withProps({
gap: "md",
flex: "0 0 auto",
});
// `0 1 auto` + `mih: 0`: shrinks to the available space and scrolls; a short
// form doesn't reserve extra height, keeping Execute snug below it.
const BodyScroll = ScrollArea.withProps({
flex: "0 1 auto",
miw: 0,
mih: 0,
type: "auto",
scrollbars: "y",
offsetScrollbars: true,
});
const BodyStack = Stack.withProps({
gap: "md",
});
// Left-aligned (Execute first, Cancel after) so the primary action sits closest
// to the sidebar controls / the form fields above — shortest pointer travel.
const FooterRow = Group.withProps({
justify: "flex-start",
flex: "0 0 auto",
});
const TitleRow = Group.withProps({
gap: "sm",
wrap: "nowrap",
align: "center",
miw: 0,
});
const ToolIcon = Image.withProps({
w: 24,
h: 24,
fit: "contain",
});
// `flex: 1` lets the title absorb the row's slack so the chevron toggle pins
// to the right edge of the (nowrap) TitleRow.
const ToolTitle = Text.withProps({
fw: 700,
size: "lg",
truncate: "end",
flex: 1,
});
// Chevron toggle for the collapsible description, pinned to the right of the
// title row. `aria-label` is set per-render since it reflects the open state.
const DescriptionToggle = ActionIcon.withProps({
variant: "subtle",
color: "gray",
size: "sm",
});
const DescriptionText = Text.withProps({
size: "sm",
c: "dimmed",
});
const CancelButton = Button.withProps({
variant: "subtle",
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",
});
// Header-mirroring section (SEP-2243): lists which args mirror their value into
// an `Mcp-Param-{Name}` header on `tools/call`. `Stack` (not `Box`) so the
// constant can carry props; the heading + note pin above the mapping rows.
const HeaderParamsSection = Stack.withProps({
gap: "xs",
});
const HeaderParamsTitle = Text.withProps({
size: "sm",
fw: 600,
});
const HeaderParamsNote = Text.withProps({
size: "xs",
c: "var(--inspector-text-secondary)",
});
// One `arg → Mcp-Param-{Name}` mapping row.
const HeaderParamRow = Group.withProps({
gap: "xs",
wrap: "nowrap",
});
const HeaderParamArrow = Text.withProps({
size: "sm",
c: "var(--inspector-text-secondary)",
});
// 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 &&
(annotations.readOnlyHint ||
annotations.destructiveHint ||
annotations.idempotentHint ||
annotations.openWorldHint)
);
}
export function ToolDetailPanel({
tool,
formValues,
isExecuting,
progress,
serverSupportsTaskToolCalls,
modernTasks = false,
runAsTask,
onRunAsTaskChange,
onFormChange,
onExecute,
onCancel,
}: ToolDetailPanelProps) {
const { name, title, description, icons, annotations, inputSchema } = tool;
// Narrow the SDK protocol schema to the form renderer's schema type.
const formSchema = toFormSchema(inputSchema) ?? {};
const iconSrc = icons?.[0]?.src;
// SEP-2243: args this tool declares as `x-mcp-header` — their values mirror
// into `Mcp-Param-{Name}` headers on a `tools/call` (#1632).
const mirroredParams = getMirroredHeaderParams(tool);
// Descriptions are shown by default (most are short); the chevron lets the
// user hide a long one to keep the form and Execute footer in view. Reset to
// shown when switching tools (React's adjust-state-during-render pattern) so
// a prior tool's hidden state doesn't carry over — mirrors how ToolsScreen
// clears formValues on change.
const [descriptionOpen, setDescriptionOpen] = useState(true);
const [prevToolName, setPrevToolName] = useState(name);
if (name !== prevToolName) {
setPrevToolName(name);
setDescriptionOpen(true);
}
// Ties the toggle to the Collapse region so assistive tech announces it as a
// single expandable control (aria-expanded + aria-controls).
const descriptionRegionId = useId();
// Show the toggle when the server supports task tool calls and either the
// connection is modern (task creation is server-directed there, so any tool
// may become a task) or the tool doesn't forbid per-tool task support
// (legacy). `required` tools are forced on (checked + disabled); `optional`
// and (on modern) any tool follow the user's `runAsTask` choice.
//
// NOTE: on modern, a per-tool `taskSupport: "forbidden"` is DELIBERATELY
// ignored. Under SEP-2663 task creation is decided by the server per request,
// not declared per tool, so `taskSupport` (a legacy 2025-11-25 concept) does
// not gate the affordance — the server may return a task for any call. The
// toggle just declares intent to poll a returned handle.
const taskSupport = getTaskSupport(tool);
const showRunAsTask =
serverSupportsTaskToolCalls && (modernTasks || taskSupport !== "forbidden");
// Gate the effective decision on `showRunAsTask`: a stale `runAsTask`/`required`
// value must not route through callToolStream when the toggle is hidden. On
// legacy, a tool's taskSupport is only considered when the server advertises
// `tasks.requests.tools.call`; on modern, the user's choice governs any tool.
const effectiveRunAsTask =
showRunAsTask &&
(taskSupport === "required" ||
((taskSupport === "optional" || modernTasks) && runAsTask));
return (
<PanelStack>
<PinnedHeader>
<TitleRow>
{iconSrc && <ToolIcon src={iconSrc} alt="" />}
<ToolTitle>{resolveDisplayLabel(name, title)}</ToolTitle>
{description && (
<DescriptionToggle
aria-label={
descriptionOpen ? "Hide description" : "Show description"
}
aria-expanded={descriptionOpen}
aria-controls={descriptionRegionId}
onClick={() => setDescriptionOpen((open) => !open)}
>
{descriptionOpen ? <RiArrowDownSLine /> : <RiArrowRightSLine />}
</DescriptionToggle>
)}
</TitleRow>
{hasAnyAnnotation(annotations) && annotations && (
<Group gap="xs">
{annotations.readOnlyHint && (
<AnnotationBadge facet="readOnlyHint" value={true} />
)}
{annotations.destructiveHint && (
<AnnotationBadge facet="destructiveHint" value={true} />
)}
{annotations.idempotentHint && (
<AnnotationBadge facet="idempotentHint" value={true} />
)}
{annotations.openWorldHint && (
<AnnotationBadge facet="openWorldHint" value={true} />
)}
</Group>
)}
</PinnedHeader>
<BodyScroll>
<BodyStack>
{description && (
<Collapse in={descriptionOpen} id={descriptionRegionId}>
<DescriptionText>{description}</DescriptionText>
</Collapse>
)}
<Divider />
{mirroredParams.length > 0 && (
<HeaderParamsSection>
<HeaderParamsTitle>
Mirrored request headers (SEP-2243)
</HeaderParamsTitle>
{mirroredParams.map((param) => (
<HeaderParamRow key={param.path}>
<Code>{param.path}</Code>
<HeaderParamArrow>→</HeaderParamArrow>
<Code>{param.header}</Code>
</HeaderParamRow>
))}
<HeaderParamsNote>
These argument values are mirrored into HTTP headers on the
call. In the web client the <Code>Mcp-Param-*</Code> headers are
applied by the Node backend that issues the upstream request.
</HeaderParamsNote>
</HeaderParamsSection>
)}
<SchemaForm
schema={formSchema}
values={formValues}
onChange={onFormChange}
disabled={isExecuting}
// This panel is reused across tool selections rather than remounted,
// so the form needs the tool name to drop another tool's
// in-progress field text. See SchemaFormProps.resetKey.
resetKey={name}
/>
{progress && <ProgressDisplay params={progress} />}
</BodyStack>
</BodyScroll>
{showRunAsTask && (
<TaskToggleRow>
<RunAsTaskSwitch
checked={effectiveRunAsTask}
disabled={isExecuting || taskSupport === "required"}
onChange={(event) => onRunAsTaskChange(event.currentTarget.checked)}
/>
</TaskToggleRow>
)}
<FooterRow>
<Button
size="md"
onClick={() => onExecute(effectiveRunAsTask)}
disabled={isExecuting}
loading={isExecuting}
>
Execute Tool
</Button>
{isExecuting && <CancelButton onClick={onCancel}>Cancel</CancelButton>}
</FooterRow>
</PanelStack>
);
}