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
3 changed files with 805 additions and 28 deletions

View file

@ -18,7 +18,6 @@ const meta: Meta<typeof SamplingRequestPanel> = {
args: {
draftResult: defaultDraftResult,
onResultChange: fn(),
onAutoRespond: fn(),
onSend: fn(),
onReject: fn(),
},

View file

@ -50,7 +50,6 @@ const imageDraft: CreateMessageResult = {
const baseProps = {
onResultChange: vi.fn(),
onAutoRespond: vi.fn(),
onSend: vi.fn(),
onReject: vi.fn(),
};
@ -201,9 +200,8 @@ describe("SamplingRequestPanel", () => {
expect(onResultChange).toHaveBeenCalled();
});
it("invokes onAutoRespond, onSend, and onReject when their buttons are clicked", async () => {
it("invokes onSend and onReject when their buttons are clicked", async () => {
const user = userEvent.setup();
const onAutoRespond = vi.fn();
const onSend = vi.fn();
const onReject = vi.fn();
renderWithMantine(
@ -211,19 +209,56 @@ describe("SamplingRequestPanel", () => {
{...baseProps}
request={simpleRequest}
draftResult={blankDraft}
onAutoRespond={onAutoRespond}
onSend={onSend}
onReject={onReject}
/>,
);
await user.click(screen.getByRole("button", { name: "Auto-respond" }));
await user.click(screen.getByRole("button", { name: "Send Response" }));
await user.click(screen.getByRole("button", { name: "Reject" }));
expect(onAutoRespond).toHaveBeenCalledTimes(1);
expect(onSend).toHaveBeenCalledTimes(1);
expect(onReject).toHaveBeenCalledTimes(1);
});
it("clears the response textarea via its Clear button", async () => {
const user = userEvent.setup();
const onResultChange = vi.fn();
const filledDraft: CreateMessageResult = {
role: "assistant",
model: "",
content: { type: "text", text: "some response" },
};
renderWithMantine(
<SamplingRequestPanel
{...baseProps}
request={simpleRequest}
draftResult={filledDraft}
onResultChange={onResultChange}
/>,
);
// Only the textarea is populated, so there is a single Clear button.
await user.click(screen.getByRole("button", { name: "Clear" }));
expect(onResultChange).toHaveBeenCalledWith({
...filledDraft,
content: { type: "text", text: "" },
});
});
it("clears the Model Used input via its Clear button", async () => {
const user = userEvent.setup();
const onResultChange = vi.fn();
// model populated, content empty → only the model Clear button renders.
renderWithMantine(
<SamplingRequestPanel
{...baseProps}
request={simpleRequest}
draftResult={blankDraft}
onResultChange={onResultChange}
/>,
);
await user.click(screen.getByRole("button", { name: "Clear" }));
expect(onResultChange).toHaveBeenCalledWith({ ...blankDraft, model: "" });
});
it("displays the existing stopReason in the Select", () => {
renderWithMantine(
<SamplingRequestPanel

View file

@ -1,6 +1,7 @@
import {
Badge,
Button,
CloseButton,
Divider,
Group,
Paper,
@ -21,7 +22,6 @@ export interface SamplingRequestPanelProps {
request: CreateMessageRequestParams;
draftResult: CreateMessageResult;
onResultChange: (result: CreateMessageResult) => void;
onAutoRespond: () => void;
onSend: () => void;
onReject: () => void;
/**
@ -64,7 +64,6 @@ export function SamplingRequestPanel({
request,
draftResult,
onResultChange,
onAutoRespond,
onSend,
onReject,
busy = false,
@ -157,6 +156,20 @@ export function SamplingRequestPanel({
}
autosize
minRows={3}
rightSectionPointerEvents="auto"
rightSection={
draftResult.content.type === "text" && draftResult.content.text ? (
<CloseButton
aria-label="Clear"
onClick={() =>
onResultChange({
...draftResult,
content: { type: "text", text: "" },
})
}
/>
) : null
}
/>
<Group>
<TextInput
@ -165,6 +178,15 @@ export function SamplingRequestPanel({
onChange={(event) =>
onResultChange({ ...draftResult, model: event.currentTarget.value })
}
rightSectionPointerEvents="auto"
rightSection={
draftResult.model ? (
<CloseButton
aria-label="Clear"
onClick={() => onResultChange({ ...draftResult, model: "" })}
/>
) : null
}
/>
<Select
label="Stop Reason"
@ -179,9 +201,6 @@ export function SamplingRequestPanel({
/>
</Group>
<Group justify="flex-end">
<Button variant="light" onClick={onAutoRespond} disabled={busy}>
Auto-respond
</Button>
<RejectButton onClick={onReject} disabled={busy}>
Reject
</RejectButton>