From 0890c7509a7cadf1d6212b2c7e778fa56e1c7ae8 Mon Sep 17 00:00:00 2001 From: Cliff Hall Date: Fri, 12 Jun 2026 09:51:05 -0400 Subject: [PATCH] feat(web): add Mantine clear buttons to text inputs app-wide (#1333) (#1448) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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) * 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) * 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) --------- Co-authored-by: Claude Opus 4.8 (1M context) --- clients/web/src/App.test.tsx | 2 +- .../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 ++ .../PendingClientRequestModal.stories.tsx | 2 +- .../PendingClientRequestModal.test.tsx | 10 +- .../PendingClientRequestModal.tsx | 5 +- .../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.stories.tsx | 1 - .../SamplingRequestPanel.test.tsx | 47 +++++- .../SamplingRequestPanel.tsx | 29 +++- .../groups/SchemaForm/SchemaForm.tsx | 10 ++ .../ServerConfigModal.test.tsx | 118 +++++++++++++- .../ServerConfigModal/ServerConfigModal.tsx | 61 +++++++ .../ServerSettingsForm.test.tsx | 152 ++++++++++++++++++ .../ServerSettingsForm/ServerSettingsForm.tsx | 70 ++++++++ .../groups/TaskControls/TaskControls.test.tsx | 14 ++ .../groups/TaskControls/TaskControls.tsx | 19 ++- .../groups/ToolControls/ToolControls.tsx | 18 ++- .../ToolsScreen/ToolsScreen.stories.tsx | 1 - clients/web/src/theme/Autocomplete.ts | 5 + 33 files changed, 805 insertions(+), 28 deletions(-) diff --git a/clients/web/src/App.test.tsx b/clients/web/src/App.test.tsx index cf3f0f4c..14c2b1f3 100644 --- a/clients/web/src/App.test.tsx +++ b/clients/web/src/App.test.tsx @@ -659,7 +659,7 @@ describe("App pending server-initiated request modal", () => { // Resolving via the modal calls the queued request's respond() — this is // what unblocks the originating call (the "spinner clears" criterion). - await user.click(screen.getByRole("button", { name: "Auto-respond" })); + await user.click(screen.getByRole("button", { name: "Send Response" })); expect(respond).toHaveBeenCalledTimes(1); // The client clearing its queue (empty event) closes the modal. diff --git a/clients/web/src/components/groups/AppControls/AppControls.tsx b/clients/web/src/components/groups/AppControls/AppControls.tsx index d39d201a..50f77d32 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 81a58507..7b878f67 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 8b26a064..f5bb09d2 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 7dee30b4..12427a95 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 6c5d8c76..4fdb9ffd 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 0fccd395..a189dea9 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 b6a5f1c2..7dbc32aa 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/PendingClientRequestModal/PendingClientRequestModal.stories.tsx b/clients/web/src/components/groups/PendingClientRequestModal/PendingClientRequestModal.stories.tsx index d5bb6312..f4cedb86 100644 --- a/clients/web/src/components/groups/PendingClientRequestModal/PendingClientRequestModal.stories.tsx +++ b/clients/web/src/components/groups/PendingClientRequestModal/PendingClientRequestModal.stories.tsx @@ -71,7 +71,7 @@ export const Sampling: Story = { expect( body.getByText("The server is requesting an LLM completion."), ).toBeInTheDocument(); - await userEvent.click(body.getByRole("button", { name: "Auto-respond" })); + await userEvent.click(body.getByRole("button", { name: "Send Response" })); expect(args.onSamplingRespond).toHaveBeenCalled(); }, }; diff --git a/clients/web/src/components/groups/PendingClientRequestModal/PendingClientRequestModal.test.tsx b/clients/web/src/components/groups/PendingClientRequestModal/PendingClientRequestModal.test.tsx index e1469e39..02ddf4d7 100644 --- a/clients/web/src/components/groups/PendingClientRequestModal/PendingClientRequestModal.test.tsx +++ b/clients/web/src/components/groups/PendingClientRequestModal/PendingClientRequestModal.test.tsx @@ -79,12 +79,12 @@ describe("PendingClientRequestModal", () => { ).toBeInTheDocument(); }); - it("auto-responds with a stub sampling result", async () => { + it("sends the default stub sampling result when the draft is untouched", async () => { const user = userEvent.setup(); renderWithMantine( , ); - await user.click(screen.getByRole("button", { name: "Auto-respond" })); + await user.click(screen.getByRole("button", { name: "Send Response" })); expect(baseProps.onSamplingRespond).toHaveBeenCalledWith({ model: "stub-model", stopReason: "endTurn", @@ -124,10 +124,10 @@ describe("PendingClientRequestModal", () => { renderWithMantine( , ); - const autoRespond = screen.getByRole("button", { name: "Auto-respond" }); - await user.click(autoRespond); + const sendResponse = screen.getByRole("button", { name: "Send Response" }); + await user.click(sendResponse); // After the first dispatch the actions lock (busy); a second click no-ops. - await user.click(autoRespond); + await user.click(sendResponse); expect(baseProps.onSamplingRespond).toHaveBeenCalledTimes(1); expect( screen.getByRole("button", { name: "Send Response" }), diff --git a/clients/web/src/components/groups/PendingClientRequestModal/PendingClientRequestModal.tsx b/clients/web/src/components/groups/PendingClientRequestModal/PendingClientRequestModal.tsx index deb62880..73ee0369 100644 --- a/clients/web/src/components/groups/PendingClientRequestModal/PendingClientRequestModal.tsx +++ b/clients/web/src/components/groups/PendingClientRequestModal/PendingClientRequestModal.tsx @@ -55,8 +55,8 @@ const TitleText = Text.withProps({ fw: 600 }); const QueueLabel = Text.withProps({ size: "xs", c: "dimmed" }); /** - * The stub result pre-filled into a sampling draft. "Auto-respond" sends this - * as-is; "Send Response" sends whatever the user edited it into. + * The stub result pre-filled into a sampling draft. "Send Response" sends this + * as-is when untouched, or whatever the user edited it into. */ function createDefaultSamplingResult(): CreateMessageResult { return { @@ -119,7 +119,6 @@ function SamplingModalBody({ request={request} draftResult={draftResult} onResultChange={setDraftResult} - onAutoRespond={once(() => onRespond(createDefaultSamplingResult()))} onSend={once(() => onRespond(draftResult))} onReject={once(onReject)} busy={responded} diff --git a/clients/web/src/components/groups/PromptArgumentsForm/PromptArgumentsForm.test.tsx b/clients/web/src/components/groups/PromptArgumentsForm/PromptArgumentsForm.test.tsx index 76ef5942..bd5540f8 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 10c1a0b6..cee1d9ab 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 572d6d6a..75c4c08f 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 58eeab51..47b1f80c 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 d449cdbf..8fd292aa 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 769d32c6..f9a80e10 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 + } />