* feat(web): add Mantine clear buttons to text inputs app-wide (#1333) Add a clear (×) affordance to every user-editable text input across the web client so a populated field can be reset in one click. - Autocomplete: enable `clearable` by default via the theme (`theme/Autocomplete.ts`) — covers the prompt-argument and resource-template variable autocomplete branches. - TextInput / Textarea: Mantine v8 does not expose a public `clearable` prop on these primitives (only the combobox-based components do), so add the clear button manually via `rightSection` + `CloseButton` (aria-label="Clear") shown only when the field has a value, wired to reset the field's controlled value to "". Applied to sidebar search inputs (prompts, tools, apps, tasks, history, resources, network, logs), prompt-argument and resource-template variable inputs, the sampling response/model fields, server config & settings forms, roots table, schema form, experimental features panel, and the server.json import panel. Read-only/display Textareas (OutputValidationModal, UrlElicitationErrorModal) are intentionally left untouched. Tests cover the clear behavior on representative inputs from each category (sidebar search, prompt argument, resource-template variable, sampling response, plus the config/settings forms). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(web): disable ServerConfigModal clear buttons while submitting Address review #2 on #1448: the clear CloseButton sat in `rightSection` with `rightSectionPointerEvents="auto"`, so it stayed clickable even while the field was `disabled={submitting}`, letting a user wipe a field mid-submit. Forward `disabled={submitting}` to each clear button so it locks together with its input. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * refactor(web): remove redundant Auto-respond from SamplingRequestPanel The sampling modal pre-loads its draft with the default stub result, and "Send Response" sends that draft — so on an untouched form "Auto-respond" did exactly the same thing as "Send Response", just with a fresh copy of the same defaults. Drop the redundant button and its `onAutoRespond` prop; "Send Response" now covers both the unedited (stub) and edited cases. The InlineSamplingRequest variant keeps its Auto-respond button — there it is the quick path versus "Edit & Send" (which opens this editable modal), not a duplicate of an adjacent send action. Updated PendingClientRequestModal wiring, stories, and tests accordingly. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
324 lines
9.3 KiB
TypeScript
324 lines
9.3 KiB
TypeScript
import { useState } from "react";
|
|
import type { ComponentProps } from "react";
|
|
import type { Meta, StoryObj } from "@storybook/react-vite";
|
|
import type { ElicitRequest, Tool } from "@modelcontextprotocol/sdk/types.js";
|
|
import { Modal } from "@mantine/core";
|
|
import { fn, userEvent, within } from "storybook/test";
|
|
import { ToolsScreen } from "./ToolsScreen";
|
|
import type { ToolCallState, ToolsUiState } from "./ToolsScreen";
|
|
import { EMPTY_TOOLS_UI } from "../screenUiState";
|
|
import { SamplingRequestPanel } from "../../groups/SamplingRequestPanel/SamplingRequestPanel";
|
|
import { ElicitationFormPanel } from "../../groups/ElicitationFormPanel/ElicitationFormPanel";
|
|
import { PendingClientRequests } from "../../groups/PendingClientRequests/PendingClientRequests";
|
|
import { InlineSamplingRequest } from "../../groups/InlineSamplingRequest/InlineSamplingRequest";
|
|
import { InlineElicitationRequest } from "../../groups/InlineElicitationRequest/InlineElicitationRequest";
|
|
|
|
// ToolsScreen is controlled (selection + form values live in the parent as one
|
|
// `ui` object — see #1414). This wrapper holds that state so the play-driven
|
|
// tool clicks still drive the detail panel, mirroring how App owns the state in
|
|
// the real app.
|
|
function StatefulToolsScreen(args: ComponentProps<typeof ToolsScreen>) {
|
|
const [ui, setUi] = useState<ToolsUiState>(args.ui ?? EMPTY_TOOLS_UI);
|
|
return <ToolsScreen {...args} ui={ui} onUiChange={setUi} />;
|
|
}
|
|
|
|
const meta: Meta<typeof ToolsScreen> = {
|
|
title: "Screens/ToolsScreen",
|
|
component: ToolsScreen,
|
|
parameters: { layout: "fullscreen" },
|
|
args: {
|
|
listChanged: false,
|
|
serverSupportsTaskToolCalls: false,
|
|
ui: EMPTY_TOOLS_UI,
|
|
onUiChange: fn(),
|
|
onRefreshList: fn(),
|
|
onCallTool: fn(),
|
|
onCancelCall: fn(),
|
|
onClearResult: fn(),
|
|
},
|
|
// Wrap in a stateful host so selection/form (now parent-owned) update on click.
|
|
render: (args) => <StatefulToolsScreen {...args} />,
|
|
};
|
|
|
|
export default meta;
|
|
type Story = StoryObj<typeof ToolsScreen>;
|
|
|
|
const sampleTools: Tool[] = [
|
|
{
|
|
name: "send_message",
|
|
title: "Send Message",
|
|
inputSchema: { type: "object" },
|
|
},
|
|
{
|
|
name: "create_record",
|
|
title: "Create Record",
|
|
description: "Creates a new record with the given parameters",
|
|
inputSchema: {
|
|
type: "object",
|
|
properties: {
|
|
title: { type: "string", description: "Record title" },
|
|
count: { type: "number", description: "Number of items" },
|
|
enabled: {
|
|
type: "boolean",
|
|
description: "Whether the record is active",
|
|
},
|
|
},
|
|
required: ["title"],
|
|
},
|
|
},
|
|
{ name: "delete_records", inputSchema: { type: "object" } },
|
|
{ name: "list_users", inputSchema: { type: "object" } },
|
|
{ name: "batch_process", inputSchema: { type: "object" } },
|
|
];
|
|
|
|
const resultState: ToolCallState = {
|
|
status: "ok",
|
|
result: {
|
|
content: [
|
|
{
|
|
type: "text",
|
|
text: JSON.stringify(
|
|
{
|
|
id: 42,
|
|
title: "New Record",
|
|
count: 5,
|
|
enabled: true,
|
|
createdAt: "2026-03-17T12:00:00Z",
|
|
},
|
|
null,
|
|
2,
|
|
),
|
|
},
|
|
],
|
|
},
|
|
};
|
|
|
|
async function selectToolByLabel(canvasElement: HTMLElement, label: string) {
|
|
const canvas = within(canvasElement);
|
|
await userEvent.click(await canvas.findByText(label));
|
|
}
|
|
|
|
export const NoSelection: Story = {
|
|
args: {
|
|
tools: sampleTools,
|
|
},
|
|
};
|
|
|
|
export const ToolSelected: Story = {
|
|
args: {
|
|
tools: sampleTools,
|
|
},
|
|
play: async ({ canvasElement }) => {
|
|
await selectToolByLabel(canvasElement, "Create Record");
|
|
},
|
|
};
|
|
|
|
export const WithResult: Story = {
|
|
args: {
|
|
tools: sampleTools,
|
|
callState: resultState,
|
|
},
|
|
play: async ({ canvasElement }) => {
|
|
await selectToolByLabel(canvasElement, "Create Record");
|
|
},
|
|
};
|
|
|
|
export const LongToolName: Story = {
|
|
args: {
|
|
tools: [
|
|
...sampleTools,
|
|
{
|
|
name: "organization_internal_database_complex_multi_table_join_query_with_aggregation_and_filtering",
|
|
description:
|
|
"Executes a complex multi-table join query across the organization's internal database with support for aggregation functions, nested filtering, and pagination of large result sets.",
|
|
annotations: {
|
|
readOnlyHint: true,
|
|
},
|
|
inputSchema: {
|
|
type: "object",
|
|
properties: {
|
|
primary_table_name: {
|
|
type: "string",
|
|
description: "The main table to query from",
|
|
},
|
|
join_configuration: {
|
|
type: "string",
|
|
description: "JSON configuration for table joins",
|
|
},
|
|
aggregation_functions: {
|
|
type: "string",
|
|
description:
|
|
"Comma-separated list of aggregation functions to apply",
|
|
},
|
|
},
|
|
required: ["primary_table_name"],
|
|
},
|
|
},
|
|
],
|
|
},
|
|
play: async ({ canvasElement }) => {
|
|
await selectToolByLabel(
|
|
canvasElement,
|
|
"organization_internal_database_complex_multi_table_join_query_with_aggregation_and_filtering",
|
|
);
|
|
},
|
|
};
|
|
|
|
export const WithError: Story = {
|
|
args: {
|
|
tools: sampleTools,
|
|
callState: {
|
|
status: "error",
|
|
result: {
|
|
isError: true,
|
|
content: [
|
|
{
|
|
type: "text",
|
|
text: 'Error executing tool "create_record": ECONNREFUSED 127.0.0.1:5432 — could not connect to the database server. The server may not be running or may be unreachable at the configured host and port. Please verify that PostgreSQL is started, the connection string is correct, and any firewall rules allow traffic on port 5432. If this is a transient issue, retrying after a short delay may resolve it.',
|
|
},
|
|
],
|
|
},
|
|
},
|
|
},
|
|
play: async ({ canvasElement }) => {
|
|
await selectToolByLabel(canvasElement, "Create Record");
|
|
},
|
|
};
|
|
|
|
export const WithListChanged: Story = {
|
|
args: {
|
|
tools: sampleTools,
|
|
listChanged: true,
|
|
},
|
|
};
|
|
|
|
const elicitFormRequest = {
|
|
message: "Please provide the database credentials for this operation.",
|
|
requestedSchema: {
|
|
type: "object" as const,
|
|
properties: {
|
|
host: { type: "string" as const, title: "Host" },
|
|
port: { type: "string" as const, title: "Port" },
|
|
password: { type: "string" as const, title: "Password" },
|
|
},
|
|
},
|
|
} satisfies ElicitRequest["params"];
|
|
|
|
export const WithSamplingModal: Story = {
|
|
args: {
|
|
tools: sampleTools,
|
|
callState: { status: "pending" },
|
|
},
|
|
play: async ({ canvasElement }) => {
|
|
await selectToolByLabel(canvasElement, "Create Record");
|
|
},
|
|
render: (args) => (
|
|
<>
|
|
<StatefulToolsScreen {...args} />
|
|
<Modal opened={true} onClose={fn()} title="Sampling Request" size="lg">
|
|
<SamplingRequestPanel
|
|
request={{
|
|
messages: [
|
|
{
|
|
role: "user",
|
|
content: {
|
|
type: "text",
|
|
text: "Based on the record parameters, generate a summary description for this new record.",
|
|
},
|
|
},
|
|
],
|
|
maxTokens: 1024,
|
|
modelPreferences: {
|
|
hints: [{ name: "claude-sonnet-4-20250514" }],
|
|
},
|
|
}}
|
|
draftResult={{
|
|
role: "assistant",
|
|
content: { type: "text", text: "" },
|
|
model: "claude-sonnet-4-20250514",
|
|
}}
|
|
onResultChange={fn()}
|
|
onSend={fn()}
|
|
onReject={fn()}
|
|
/>
|
|
</Modal>
|
|
</>
|
|
),
|
|
};
|
|
|
|
export const WithElicitationModal: Story = {
|
|
args: {
|
|
tools: sampleTools,
|
|
callState: { status: "pending" },
|
|
},
|
|
play: async ({ canvasElement }) => {
|
|
await selectToolByLabel(canvasElement, "Create Record");
|
|
},
|
|
render: (args) => (
|
|
<>
|
|
<StatefulToolsScreen {...args} />
|
|
<Modal opened={true} onClose={fn()} title="Elicitation Request" size="lg">
|
|
<ElicitationFormPanel
|
|
request={elicitFormRequest}
|
|
serverName="postgres-server"
|
|
values={{}}
|
|
onChange={fn()}
|
|
onSubmit={fn()}
|
|
onDecline={fn()}
|
|
onCancel={fn()}
|
|
/>
|
|
</Modal>
|
|
</>
|
|
),
|
|
};
|
|
|
|
export const WithPendingRequests: Story = {
|
|
args: {
|
|
tools: sampleTools,
|
|
callState: { status: "pending" },
|
|
},
|
|
play: async ({ canvasElement }) => {
|
|
await selectToolByLabel(canvasElement, "Create Record");
|
|
},
|
|
render: (args) => (
|
|
<>
|
|
<StatefulToolsScreen {...args} />
|
|
<Modal
|
|
opened={true}
|
|
onClose={fn()}
|
|
title="Pending Client Requests"
|
|
size="lg"
|
|
>
|
|
<PendingClientRequests count={2}>
|
|
<InlineSamplingRequest
|
|
request={{
|
|
messages: [
|
|
{
|
|
role: "user",
|
|
content: {
|
|
type: "text",
|
|
text: "Generate a summary description for a new database record.",
|
|
},
|
|
},
|
|
],
|
|
maxTokens: 1024,
|
|
}}
|
|
queuePosition="1 of 2"
|
|
onAutoRespond={fn()}
|
|
onEditAndSend={fn()}
|
|
onReject={fn()}
|
|
onViewDetails={fn()}
|
|
/>
|
|
<InlineElicitationRequest
|
|
request={elicitFormRequest}
|
|
queuePosition="2 of 2"
|
|
values={{}}
|
|
onChange={fn()}
|
|
onSubmit={fn()}
|
|
onCancel={fn()}
|
|
/>
|
|
</PendingClientRequests>
|
|
</Modal>
|
|
</>
|
|
),
|
|
};
|