From ec652eafde9192275427a7b75f8fad318a8f9928 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Tue, 9 Jun 2026 19:38:02 -0400 Subject: [PATCH] feat(web): add Mantine clear buttons to text inputs app-wide (#1333) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../groups/AppControls/AppControls.tsx | 10 ++ .../ExperimentalFeaturesPanel.tsx | 28 ++++ .../HistoryControls/HistoryControls.test.tsx | 14 ++ .../HistoryControls/HistoryControls.tsx | 11 +- .../ImportServerJsonPanel.test.tsx | 34 ++++ .../ImportServerJsonPanel.tsx | 25 +++ .../groups/LogControls/LogControls.tsx | 10 ++ .../NetworkControls/NetworkControls.tsx | 10 ++ .../PromptArgumentsForm.test.tsx | 17 ++ .../PromptArgumentsForm.tsx | 10 ++ .../groups/PromptControls/PromptControls.tsx | 18 ++- .../ResourceControls/ResourceControls.tsx | 10 ++ .../ResourceTemplatePanel.test.tsx | 16 ++ .../ResourceTemplatePanel.tsx | 10 ++ .../groups/RootsTable/RootsTable.test.tsx | 25 +++ .../groups/RootsTable/RootsTable.tsx | 21 +++ .../SamplingRequestPanel.test.tsx | 40 +++++ .../SamplingRequestPanel.tsx | 24 +++ .../groups/SchemaForm/SchemaForm.tsx | 10 ++ .../ServerConfigModal.test.tsx | 118 +++++++++++++- .../ServerConfigModal/ServerConfigModal.tsx | 55 +++++++ .../ServerSettingsForm.test.tsx | 152 ++++++++++++++++++ .../ServerSettingsForm/ServerSettingsForm.tsx | 70 ++++++++ .../groups/TaskControls/TaskControls.test.tsx | 14 ++ .../groups/TaskControls/TaskControls.tsx | 19 ++- .../groups/ToolControls/ToolControls.tsx | 18 ++- clients/web/src/theme/Autocomplete.ts | 5 + 27 files changed, 789 insertions(+), 5 deletions(-) diff --git a/clients/web/src/components/groups/AppControls/AppControls.tsx b/clients/web/src/components/groups/AppControls/AppControls.tsx index d39d201ad..50f77d325 100644 --- a/clients/web/src/components/groups/AppControls/AppControls.tsx +++ b/clients/web/src/components/groups/AppControls/AppControls.tsx @@ -1,4 +1,5 @@ import { + CloseButton, Group, ScrollArea, Stack, @@ -61,6 +62,15 @@ export function AppControls({ placeholder="Search apps..." value={searchText} onChange={(e) => onSearchChange(e.currentTarget.value)} + rightSectionPointerEvents="auto" + rightSection={ + searchText ? ( + onSearchChange("")} + /> + ) : null + } /> diff --git a/clients/web/src/components/groups/ExperimentalFeaturesPanel/ExperimentalFeaturesPanel.tsx b/clients/web/src/components/groups/ExperimentalFeaturesPanel/ExperimentalFeaturesPanel.tsx index 81a585076..7b878f675 100644 --- a/clients/web/src/components/groups/ExperimentalFeaturesPanel/ExperimentalFeaturesPanel.tsx +++ b/clients/web/src/components/groups/ExperimentalFeaturesPanel/ExperimentalFeaturesPanel.tsx @@ -5,6 +5,7 @@ import { Button, Card, Checkbox, + CloseButton, Divider, Group, Stack, @@ -244,6 +245,15 @@ export function ExperimentalFeaturesPanel({ onChange={(e) => onHeaderChange(index, e.currentTarget.value, header.value) } + rightSectionPointerEvents="auto" + rightSection={ + header.key ? ( + onHeaderChange(index, "", header.value)} + /> + ) : null + } /> onHeaderChange(index, header.key, e.currentTarget.value) } + rightSectionPointerEvents="auto" + rightSection={ + header.value ? ( + onHeaderChange(index, header.key, "")} + /> + ) : null + } /> onRemoveHeader(index)}> @@ -269,6 +288,15 @@ export function ExperimentalFeaturesPanel({ onChange={(e) => onRequestChange(e.currentTarget.value)} autosize minRows={6} + rightSectionPointerEvents="auto" + rightSection={ + requestDraft ? ( + onRequestChange("")} + /> + ) : null + } /> diff --git a/clients/web/src/components/groups/HistoryControls/HistoryControls.test.tsx b/clients/web/src/components/groups/HistoryControls/HistoryControls.test.tsx index 8b26a064c..f5bb09d29 100644 --- a/clients/web/src/components/groups/HistoryControls/HistoryControls.test.tsx +++ b/clients/web/src/components/groups/HistoryControls/HistoryControls.test.tsx @@ -31,6 +31,20 @@ describe("HistoryControls", () => { expect(onSearchChange).toHaveBeenCalledWith("a"); }); + it("clears the search input when the Clear button is clicked", async () => { + const user = userEvent.setup(); + const onSearchChange = vi.fn(); + renderWithMantine( + , + ); + await user.click(screen.getByRole("button", { name: "Clear" })); + expect(onSearchChange).toHaveBeenCalledWith(""); + }); + it("renders the method filter placeholder", () => { renderWithMantine(); expect(screen.getByPlaceholderText("All methods")).toBeInTheDocument(); diff --git a/clients/web/src/components/groups/HistoryControls/HistoryControls.tsx b/clients/web/src/components/groups/HistoryControls/HistoryControls.tsx index 7dee30b4c..12427a95c 100644 --- a/clients/web/src/components/groups/HistoryControls/HistoryControls.tsx +++ b/clients/web/src/components/groups/HistoryControls/HistoryControls.tsx @@ -1,4 +1,4 @@ -import { Select, Stack, TextInput, Title } from "@mantine/core"; +import { CloseButton, Select, Stack, TextInput, Title } from "@mantine/core"; import type { MessageMethod, MessageOrigin, @@ -33,6 +33,15 @@ export function HistoryControls({ placeholder="Search..." value={searchText} onChange={(event) => onSearchChange(event.currentTarget.value)} + rightSectionPointerEvents="auto" + rightSection={ + searchText ? ( + onSearchChange("")} + /> + ) : null + } /> Filter by Method diff --git a/clients/web/src/components/groups/ImportServerJsonPanel/ImportServerJsonPanel.test.tsx b/clients/web/src/components/groups/ImportServerJsonPanel/ImportServerJsonPanel.test.tsx index 6c5d8c763..4fdb9ffd8 100644 --- a/clients/web/src/components/groups/ImportServerJsonPanel/ImportServerJsonPanel.test.tsx +++ b/clients/web/src/components/groups/ImportServerJsonPanel/ImportServerJsonPanel.test.tsx @@ -196,6 +196,40 @@ describe("ImportServerJsonPanel", () => { expect(onServerNameChange).toHaveBeenCalledWith("X"); }); + it("clears the paste textarea, env-var, and name-override fields via Clear buttons", async () => { + const user = userEvent.setup(); + const onJsonChange = vi.fn(); + const onEnvVarChange = vi.fn(); + const onServerNameChange = vi.fn(); + const envVars: EnvVarInfo[] = [ + { name: "DEBUG", required: false, value: "true" }, + ]; + renderWithMantine( + , + ); + // DOM order: paste textarea, env-var input, name-override input. + const clearButtons = screen.getAllByRole("button", { name: "Clear" }); + expect(clearButtons).toHaveLength(3); + await user.click(clearButtons[0]); + expect(onJsonChange).toHaveBeenCalledWith(""); + await user.click(clearButtons[1]); + expect(onEnvVarChange).toHaveBeenCalledWith("DEBUG", ""); + await user.click(clearButtons[2]); + expect(onServerNameChange).toHaveBeenCalledWith(""); + }); + it("renders the existing nameOverride value", () => { renderWithMantine( onJsonChange("")} /> + ) : null + } /> @@ -139,6 +146,15 @@ export function ImportServerJsonPanel({ onChange={(e) => onEnvVarChange(envVar.name, e.currentTarget.value) } + rightSectionPointerEvents="auto" + rightSection={ + envVar.value ? ( + onEnvVarChange(envVar.name, "")} + /> + ) : null + } /> ))} @@ -150,6 +166,15 @@ export function ImportServerJsonPanel({ label="Server Name (optional override)" value={draft.nameOverride ?? ""} onChange={(e) => onServerNameChange(e.currentTarget.value)} + rightSectionPointerEvents="auto" + rightSection={ + draft.nameOverride ? ( + onServerNameChange("")} + /> + ) : null + } /> diff --git a/clients/web/src/components/groups/LogControls/LogControls.tsx b/clients/web/src/components/groups/LogControls/LogControls.tsx index 0fccd3957..a189dea97 100644 --- a/clients/web/src/components/groups/LogControls/LogControls.tsx +++ b/clients/web/src/components/groups/LogControls/LogControls.tsx @@ -1,5 +1,6 @@ import { Button, + CloseButton, Group, Select, Stack, @@ -64,6 +65,15 @@ export function LogControls({ placeholder="Search..." value={filterText} onChange={(e) => onFilterChange(e.currentTarget.value)} + rightSectionPointerEvents="auto" + rightSection={ + filterText ? ( + onFilterChange("")} + /> + ) : null + } /> Set Active Level diff --git a/clients/web/src/components/groups/NetworkControls/NetworkControls.tsx b/clients/web/src/components/groups/NetworkControls/NetworkControls.tsx index b6a5f1c21..7dbc32aa4 100644 --- a/clients/web/src/components/groups/NetworkControls/NetworkControls.tsx +++ b/clients/web/src/components/groups/NetworkControls/NetworkControls.tsx @@ -1,5 +1,6 @@ import { Button, + CloseButton, Group, Stack, Text, @@ -45,6 +46,15 @@ export function NetworkControls({ placeholder="Search..." value={filterText} onChange={(e) => onFilterChange(e.currentTarget.value)} + rightSectionPointerEvents="auto" + rightSection={ + filterText ? ( + onFilterChange("")} + /> + ) : null + } /> diff --git a/clients/web/src/components/groups/PromptArgumentsForm/PromptArgumentsForm.test.tsx b/clients/web/src/components/groups/PromptArgumentsForm/PromptArgumentsForm.test.tsx index 76ef5942c..bd5540f82 100644 --- a/clients/web/src/components/groups/PromptArgumentsForm/PromptArgumentsForm.test.tsx +++ b/clients/web/src/components/groups/PromptArgumentsForm/PromptArgumentsForm.test.tsx @@ -144,6 +144,23 @@ describe("PromptArgumentsForm", () => { expect(onArgumentChange).toHaveBeenCalledWith("text", "h"); }); + it("clears an argument via its Clear button (onArgumentChange with empty value)", async () => { + const user = userEvent.setup(); + const onArgumentChange = vi.fn(); + renderWithMantine( + , + ); + // Non-autocomplete branch (completions unsupported) renders a TextInput + // with a Clear button whenever the value is non-empty. + await user.click(screen.getByRole("button", { name: "Clear" })); + expect(onArgumentChange).toHaveBeenCalledWith("text", ""); + }); + it("invokes onGetPrompt when Get Prompt is clicked", async () => { const user = userEvent.setup(); const onGetPrompt = vi.fn(); diff --git a/clients/web/src/components/groups/PromptArgumentsForm/PromptArgumentsForm.tsx b/clients/web/src/components/groups/PromptArgumentsForm/PromptArgumentsForm.tsx index 10c1a0b62..cee1d9abf 100644 --- a/clients/web/src/components/groups/PromptArgumentsForm/PromptArgumentsForm.tsx +++ b/clients/web/src/components/groups/PromptArgumentsForm/PromptArgumentsForm.tsx @@ -2,6 +2,7 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { Autocomplete, Button, + CloseButton, Group, Stack, Text, @@ -221,6 +222,15 @@ export function PromptArgumentsForm({ onChange={(event) => handleChange(arg.name, event.currentTarget.value) } + rightSectionPointerEvents="auto" + rightSection={ + argumentValues[arg.name] ? ( + handleChange(arg.name, "")} + /> + ) : null + } /> ), )} diff --git a/clients/web/src/components/groups/PromptControls/PromptControls.tsx b/clients/web/src/components/groups/PromptControls/PromptControls.tsx index 572d6d6ae..75c4c08f5 100644 --- a/clients/web/src/components/groups/PromptControls/PromptControls.tsx +++ b/clients/web/src/components/groups/PromptControls/PromptControls.tsx @@ -1,4 +1,11 @@ -import { Group, ScrollArea, Stack, TextInput, Title } from "@mantine/core"; +import { + CloseButton, + Group, + ScrollArea, + Stack, + TextInput, + Title, +} from "@mantine/core"; import type { Prompt } from "@modelcontextprotocol/sdk/types.js"; import { ListChangedIndicator } from "../../elements/ListChangedIndicator/ListChangedIndicator"; import { PromptListItem } from "../PromptListItem/PromptListItem"; @@ -47,6 +54,15 @@ export function PromptControls({ placeholder="Search prompts..." value={searchText} onChange={(e) => onSearchChange(e.currentTarget.value)} + rightSectionPointerEvents="auto" + rightSection={ + searchText ? ( + onSearchChange("")} + /> + ) : null + } /> diff --git a/clients/web/src/components/groups/ResourceControls/ResourceControls.tsx b/clients/web/src/components/groups/ResourceControls/ResourceControls.tsx index 58eeab51f..47b1f80ce 100644 --- a/clients/web/src/components/groups/ResourceControls/ResourceControls.tsx +++ b/clients/web/src/components/groups/ResourceControls/ResourceControls.tsx @@ -1,5 +1,6 @@ import { Accordion, + CloseButton, Group, ScrollArea, Stack, @@ -127,6 +128,15 @@ export function ResourceControls({ placeholder="Search..." value={searchText} onChange={(e) => onSearchChange(e.currentTarget.value)} + rightSectionPointerEvents="auto" + rightSection={ + searchText ? ( + onSearchChange("")} + /> + ) : null + } /> diff --git a/clients/web/src/components/groups/ResourceTemplatePanel/ResourceTemplatePanel.test.tsx b/clients/web/src/components/groups/ResourceTemplatePanel/ResourceTemplatePanel.test.tsx index d449cdbfa..8fd292aa6 100644 --- a/clients/web/src/components/groups/ResourceTemplatePanel/ResourceTemplatePanel.test.tsx +++ b/clients/web/src/components/groups/ResourceTemplatePanel/ResourceTemplatePanel.test.tsx @@ -105,6 +105,22 @@ describe("ResourceTemplatePanel", () => { expect(screen.getByText("file:///users/bob/profile")).toBeInTheDocument(); }); + it("clears a variable via its Clear button (non-autocomplete branch)", async () => { + const user = userEvent.setup(); + renderWithMantine( + , + ); + const input = screen.getByLabelText("userId"); + await user.type(input, "alice"); + expect(input).toHaveValue("alice"); + // The Clear button only renders while the value is non-empty. + await user.click(screen.getByRole("button", { name: "Clear" })); + expect(input).toHaveValue(""); + }); + it("renders annotation badges when present", () => { renderWithMantine( handleVariableChange(varName, e.currentTarget.value) } + rightSectionPointerEvents="auto" + rightSection={ + variables[varName] ? ( + handleVariableChange(varName, "")} + /> + ) : null + } /> ), )} diff --git a/clients/web/src/components/groups/RootsTable/RootsTable.test.tsx b/clients/web/src/components/groups/RootsTable/RootsTable.test.tsx index 769d32c6b..f9a80e109 100644 --- a/clients/web/src/components/groups/RootsTable/RootsTable.test.tsx +++ b/clients/web/src/components/groups/RootsTable/RootsTable.test.tsx @@ -97,6 +97,31 @@ describe("RootsTable", () => { expect(onNewRootDraftChange).toHaveBeenCalledWith({ name: "", uri: "y" }); }); + it("clears the Name and URI inputs via their Clear buttons", async () => { + const user = userEvent.setup(); + const onNewRootDraftChange = vi.fn(); + renderWithMantine( + , + ); + const clearButtons = screen.getAllByRole("button", { name: "Clear" }); + expect(clearButtons).toHaveLength(2); + await user.click(clearButtons[0]); + expect(onNewRootDraftChange).toHaveBeenCalledWith({ name: "", uri: "u" }); + await user.click(clearButtons[1]); + expect(onNewRootDraftChange).toHaveBeenCalledWith({ name: "n", uri: "" }); + }); + + it("renders no Clear buttons when the draft fields are empty", () => { + renderWithMantine(); + expect( + screen.queryByRole("button", { name: "Clear" }), + ).not.toBeInTheDocument(); + }); + it("renders the current draft values in the inputs", () => { renderWithMantine( + onNewRootDraftChange({ ...newRootDraft, name: "" }) + } + /> + ) : null + } /> onNewRootDraftChange({ ...newRootDraft, uri: "" })} + /> + ) : null + } />