feat(web): add Mantine clear buttons to text inputs app-wide (#1333) (#1448)

* 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>
This commit is contained in:
Cliff Hall 2026-06-12 09:51:05 -04:00 • committed by GitHub
commit 0890c7509a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
33 changed files with 805 additions and 28 deletions

View file

@ -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.

View file

@ -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 ? (
<CloseButton
aria-label="Clear"
onClick={() => onSearchChange("")}
/>
) : null
}
/>
<ScrollArea.Autosize viewportRef={viewportRef} mah={LIST_MAX_HEIGHT}>
<Stack gap="xs">

View file

@ -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 ? (
<CloseButton
aria-label="Clear"
onClick={() => onHeaderChange(index, "", header.value)}
/>
) : null
}
/>
<TextInput
placeholder="Header value"
@ -251,6 +261,15 @@ export function ExperimentalFeaturesPanel({
onChange={(e) =>
onHeaderChange(index, header.key, e.currentTarget.value)
}
rightSectionPointerEvents="auto"
rightSection={
header.value ? (
<CloseButton
aria-label="Clear"
onClick={() => onHeaderChange(index, header.key, "")}
/>
) : null
}
/>
<RemoveIcon onClick={() => onRemoveHeader(index)}>
<Text size="xs">✕</Text>
@ -269,6 +288,15 @@ export function ExperimentalFeaturesPanel({
onChange={(e) => onRequestChange(e.currentTarget.value)}
autosize
minRows={6}
rightSectionPointerEvents="auto"
rightSection={
requestDraft ? (
<CloseButton
aria-label="Clear"
onClick={() => onRequestChange("")}
/>
) : null
}
/>
<Button onClick={onSendRequest}>Send Request</Button>

View file

@ -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(
<HistoryControls
{...baseProps}
searchText="abc"
onSearchChange={onSearchChange}
/>,
);
await user.click(screen.getByRole("button", { name: "Clear" }));
expect(onSearchChange).toHaveBeenCalledWith("");
});
it("renders the method filter placeholder", () => {
renderWithMantine(<HistoryControls {...baseProps} />);
expect(screen.getByPlaceholderText("All methods")).toBeInTheDocument();

View file

@ -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 ? (
<CloseButton
aria-label="Clear"
onClick={() => onSearchChange("")}
/>
) : null
}
/>
<Title order={6}>Filter by Method</Title>

View file

@ -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(
<ImportServerJsonPanel
{...baseHandlers}
onJsonChange={onJsonChange}
onEnvVarChange={onEnvVarChange}
onServerNameChange={onServerNameChange}
draft={{
rawText: "{}",
envOverrides: {},
nameOverride: "Custom Name",
}}
validation={[]}
envVars={envVars}
/>,
);
// 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(
<ImportServerJsonPanel

View file

@ -1,5 +1,6 @@
import {
Button,
CloseButton,
Divider,
Group,
Radio,
@ -88,6 +89,12 @@ export function ImportServerJsonPanel({
autosize
minRows={8}
maxRows={15}
rightSectionPointerEvents="auto"
rightSection={
draft.rawText ? (
<CloseButton aria-label="Clear" onClick={() => onJsonChange("")} />
) : null
}
/>
<Divider />
@ -139,6 +146,15 @@ export function ImportServerJsonPanel({
onChange={(e) =>
onEnvVarChange(envVar.name, e.currentTarget.value)
}
rightSectionPointerEvents="auto"
rightSection={
envVar.value ? (
<CloseButton
aria-label="Clear"
onClick={() => 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 ? (
<CloseButton
aria-label="Clear"
onClick={() => onServerNameChange("")}
/>
) : null
}
/>
<Group justify="flex-end">

View file

@ -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 ? (
<CloseButton
aria-label="Clear"
onClick={() => onFilterChange("")}
/>
) : null
}
/>
<Title order={5}>Set Active Level</Title>

View file

@ -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 ? (
<CloseButton
aria-label="Clear"
onClick={() => onFilterChange("")}
/>
) : null
}
/>
<Group justify="space-between">

View file

@ -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();
},
};

View file

@ -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(
<PendingClientRequestModal {...baseProps} request={samplingContent} />,
);
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(
<PendingClientRequestModal {...baseProps} request={samplingContent} />,
);
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" }),

View file

@ -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}

View file

@ -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(
<PromptArgumentsForm
prompt={promptWithArgs}
argumentValues={{ text: "Hello" }}
onArgumentChange={onArgumentChange}
onGetPrompt={vi.fn()}
/>,
);
// 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();

View file

@ -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] ? (
<CloseButton
aria-label="Clear"
onClick={() => handleChange(arg.name, "")}
/>
) : null
}
/>
),
)}

View file

@ -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 ? (
<CloseButton
aria-label="Clear"
onClick={() => onSearchChange("")}
/>
) : null
}
/>
<ScrollArea.Autosize viewportRef={viewportRef} mah={LIST_MAX_HEIGHT}>
<Stack gap="xs">

View file

@ -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 ? (
<CloseButton
aria-label="Clear"
onClick={() => onSearchChange("")}
/>
) : null
}
/>
<ListToggle compact={!allExpanded} onToggle={handleToggleList} />
</Group>

View file

@ -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(
<ResourceTemplatePanel
template={singleVarTemplate}
onReadResource={vi.fn()}
/>,
);
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(
<ResourceTemplatePanel

View file

@ -2,6 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
Autocomplete,
Button,
CloseButton,
Group,
Stack,
Text,
@ -261,6 +262,15 @@ export function ResourceTemplatePanel({
onChange={(e) =>
handleVariableChange(varName, e.currentTarget.value)
}
rightSectionPointerEvents="auto"
rightSection={
variables[varName] ? (
<CloseButton
aria-label="Clear"
onClick={() => handleVariableChange(varName, "")}
/>
) : null
}
/>
),
)}

View file

@ -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(
<RootsTable
{...baseProps}
newRootDraft={{ name: "n", uri: "u" }}
onNewRootDraftChange={onNewRootDraftChange}
/>,
);
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(<RootsTable {...baseProps} />);
expect(
screen.queryByRole("button", { name: "Clear" }),
).not.toBeInTheDocument();
});
it("renders the current draft values in the inputs", () => {
renderWithMantine(
<RootsTable

View file

@ -2,6 +2,7 @@ import {
ActionIcon,
Alert,
Button,
CloseButton,
Divider,
Group,
Stack,
@ -90,6 +91,17 @@ export function RootsTable({
name: e.currentTarget.value,
})
}
rightSectionPointerEvents="auto"
rightSection={
newRootDraft.name ? (
<CloseButton
aria-label="Clear"
onClick={() =>
onNewRootDraftChange({ ...newRootDraft, name: "" })
}
/>
) : null
}
/>
<TextInput
label="URI"
@ -100,6 +112,15 @@ export function RootsTable({
uri: e.currentTarget.value,
})
}
rightSectionPointerEvents="auto"
rightSection={
newRootDraft.uri ? (
<CloseButton
aria-label="Clear"
onClick={() => onNewRootDraftChange({ ...newRootDraft, uri: "" })}
/>
) : null
}
/>
<Group justify="flex-end">
<Button variant="light" onClick={onBrowse}>

Some files were not shown because too many files have changed in this diff Show more