feat(web): use the Ace editor for the request-metadata JSON

A textarea is a poor place to hand-write a nested `_meta` payload. Ace closes a
brace when you open one, folds nested objects, and — through its JSON worker —
marks the offending line in the gutter instead of reporting one message for the
whole document.

Three things the swap turned up:

- Ace fires *two* change events for a replace (a remove, then an insert), so
  select-all-and-retype briefly leaves an empty document. Acting on the first
  emitted `{}` and wiped the user's saved metadata the instant they selected
  it. `handleChange` now coalesces the pair in a microtask, and the component
  tracks what it last emitted so the parent echoing that value back is not
  mistaken for an external reset.

- Ace's own `github` themes fail WCAG AA on the gutter — `#AAA` on `#e8e8e8` is
  1.89:1, and the dark theme is 4.13:1. Folding needs the gutter, so the colors
  are overridden with palette tokens (5.82:1 and 4.88:1) rather than hiding it.

- `textInputAriaLabel` alone never reaches the DOM: Ace composes the label as
  "<label>, Cursor at row N" and only recomputes it when the cursor moves, so
  it is forced once on load. Until then the control's only accessible name is a
  position readout.

Testing is split because Ace's input path does not work under happy-dom at all
— `userEvent.type` reaches the textarea and produces no edit, so a test written
that way asserts nothing. Unit tests drive the editor instance through a shared
`src/test/aceEditor.ts` helper; the real keyboard behaviour (`{` then Enter
giving `{\n  \n}`) is asserted by Storybook play functions in Chromium.

`react-ace`/`ace-builds` are declared in `clients/web` rather than the root, per
the rule for a dependency that renders React components.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0177mPHAdECD18nLLCTwR5rh
Signed-off-by: cliffhall <cliff@futurescale.com>
This commit is contained in:
cliffhall 2026-08-23 16:54:58 -04:00
commit ed799a0c0b
2 changed files with 432 additions and 94 deletions

View file

@ -3,6 +3,7 @@ import userEvent from "@testing-library/user-event";
import { within } from "@testing-library/react";
import type { InspectorServerSettings } from "@inspector/core/mcp/types.js";
import { renderWithMantine, screen } from "../../../test/renderWithMantine";
import { getAceText, setAceText } from "../../../test/aceEditor";
import {
ServerSettingsForm,
type ServerSettingsSection,
@ -249,8 +250,8 @@ describe("ServerSettingsForm", () => {
screen.getByText("No custom headers configured"),
).toBeInTheDocument();
// Metadata has no rows to be absent — the editor is always present, and
// "none configured" is spelled `{}` in the box itself.
expect(screen.getByLabelText("Request metadata JSON")).toHaveValue("{}");
// "none configured" is spelled `{}` in it.
expect(getAceText()).toBe("{}");
});
it("invokes onAddHeader when + Add Header is clicked", async () => {
@ -331,17 +332,15 @@ describe("ServerSettingsForm", () => {
expandedSections={["metadata"]}
/>,
);
const editor = screen.getByLabelText("Request metadata JSON");
// The point of #1910: a nested object survives into the editor instead of
// being flattened into a `{ key, value }` string row.
expect(JSON.parse((editor as HTMLTextAreaElement).value)).toEqual({
expect(JSON.parse(getAceText())).toEqual({
userId: "u-1",
tenant: { id: 7, tags: ["a"] },
});
});
it("reports the whole edited object through onMetadataChange", async () => {
const user = userEvent.setup();
const onMetadataChange = vi.fn();
renderWithMantine(
<ServerSettingsForm
@ -351,9 +350,7 @@ describe("ServerSettingsForm", () => {
expandedSections={["metadata"]}
/>,
);
const editor = screen.getByLabelText("Request metadata JSON");
await user.clear(editor);
await user.type(editor, '{{"n":[[1,2]}');
await setAceText('{"n":[1,2]}');
expect(onMetadataChange).toHaveBeenLastCalledWith({ n: [1, 2] });
});

View file

@ -735,8 +735,7 @@ export function ServerSettingsForm({
strings. Leave it as <Code>{"{}"}</Code> to send none.
</HintText>
<JsonObjectInput
aria-label="Request metadata JSON"
placeholder={'{\n "example": { "nested": true }\n}'}
ariaLabel="Request metadata JSON"
value={settings.metadata}
onChange={onMetadataChange}
/>