fix(web): seed Edit-and-replay from what replay sends, name each JSON editor

Copilot's fourth pass, one inline plus two suppressed. All three were right.

- The modal presented the whole captured frame as editable, but replay does
  not re-send a frame: it dispatches through the typed InspectorClient
  methods, whose signatures have no room for `_meta` (a captured `tools/call`
  carries `_meta.progressToken`) or for any list-request key but `cursor`. So
  editing `_meta` and pressing Send transmitted something other than what was
  on screen. `replayableParams` now projects the frame down to what the
  dispatcher actually reads, the modal seeds from that, and it names what was
  left out rather than leaving the difference to be discovered. The projection
  lives beside the dispatch switch because the two are halves of one contract.

- `ContentViewer` gains `jsonLabel`, so a read-only JSON editor can say what
  it holds. Every one of them announced "JSON content", and an expanded
  Protocol entry has two while the list has many pairs — leaving a screen
  reader with indistinguishable textboxes and only the visible headings to
  tell them apart. Protocol entries now name theirs per method and direction,
  Network entries per request/response body.

- `isJsonDocument` became `formatJsonDocument`, returning the formatted text
  instead of a boolean. The heuristic path parsed the payload to decide and
  then parsed it again to render; Protocol and Network hand over serialized
  bodies as untyped text and are not virtualized, so a long history paid twice
  per payload per render.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UL9retfvAXRgvi6EWk4SY4
Signed-off-by: cliffhall <cliff@futurescale.com>
This commit is contained in:
cliffhall 2026-08-27 03:37:03 -04:00
commit 5abe289caa
5 changed files with 413 additions and 31 deletions

View file

@ -98,6 +98,44 @@ describe("EditReplayModal", () => {
expect(screen.getByText(/Numbers must be finite/)).toBeInTheDocument();
});
// Replay dispatches through the typed client methods, so a param they have no
// room for is dropped. Saying which, rather than seeding it into the editor,
// is what keeps the modal from inviting an edit that Send discards.
it("names the params replay cannot re-send", () => {
renderWithMantine(
<EditReplayModal
{...baseProps}
params={{ name: "echo" }}
droppedParamKeys={["_meta"]}
/>,
);
expect(screen.getByText(/`_meta` is not re-sent/)).toBeInTheDocument();
});
it("says nothing when every param survives", () => {
renderWithMantine(
<EditReplayModal
{...baseProps}
params={{ name: "echo" }}
droppedParamKeys={[]}
/>,
);
expect(screen.queryByText(/not re-sent/)).toBeNull();
});
it("agrees with its verb for several dropped params", () => {
renderWithMantine(
<EditReplayModal
{...baseProps}
params={{ cursor: "abc" }}
droppedParamKeys={["_meta", "extra"]}
/>,
);
expect(
screen.getByText(/`_meta`, `extra` are not re-sent/),
).toBeInTheDocument();
});
it("closes without sending when cancelled", async () => {
const user = userEvent.setup();
const onSend = vi.fn();

View file

@ -1,5 +1,5 @@
import { useState } from "react";
import { Button, Group, Modal, Stack, Text } from "@mantine/core";
import { Alert, Button, Group, Modal, Stack, Text } from "@mantine/core";
import { JsonEditor } from "../../elements/JsonEditor/JsonEditor";
import { isJsonObject } from "../../../utils/jsonObjectDraft";
import { isSerializableJson } from "@inspector/core/json/jsonUtils.js";
@ -8,8 +8,22 @@ export interface EditReplayModalProps {
opened: boolean;
/** The method being replayed, shown so the editor is unambiguous. */
method: string;
/** The originating entry's params, seeded into the editor. */
/**
* The originating entry's params, seeded into the editor — already reduced to
* the ones replay will actually read. See {@link droppedParamKeys}.
*/
params?: Record<string, unknown>;
/**
* Param names the entry carried that replay cannot re-send, so the modal can
* say so rather than leave the difference to be discovered.
*
* Replay dispatches through the typed `InspectorClient` methods rather than
* re-sending the captured frame, and those signatures have no room for
* `_meta` (or for anything but `cursor` on a list request). Seeding the
* editor with them would invite an edit that Send silently discards, which is
* the one thing an inspector must not do.
*/
droppedParamKeys?: string[];
/**
* Re-issue the request with these params. `null` means "send no params",
* which is a request an empty editor can legitimately express — see
@ -27,6 +41,17 @@ const MethodText = Text.withProps({
const Footer = Group.withProps({ justify: "flex-end", gap: "sm" });
// Names the params replay cannot carry. `light` and gray rather than a warning
// colour: nothing is wrong and there is nothing for the user to fix — it is a
// property of how replay dispatches.
const DroppedNote = Alert.withProps({ variant: "light", color: "gray" });
/** "`a`, `b` are" / "`a` is" — the list, agreeing with its verb. */
function formatDroppedKeys(keys: string[]): string {
const names = keys.map((key) => `\`${key}\``).join(", ");
return `${names} ${keys.length === 1 ? "is" : "are"}`;
}
/**
* What the editor's text currently means: the params to send, or why they
* cannot be sent.
@ -75,6 +100,7 @@ function parseParamsDraft(
function EditReplayForm({
method,
params,
droppedParamKeys,
onSend,
onClose,
}: Omit<EditReplayModalProps, "opened">) {
@ -86,6 +112,15 @@ function EditReplayForm({
return (
<Stack gap="sm">
<MethodText>{method}</MethodText>
{droppedParamKeys && droppedParamKeys.length > 0 && (
<DroppedNote>
<Text size="xs">
{`Replay re-issues this request through the client's own ${method}
method, which carries only the fields below, so
${formatDroppedKeys(droppedParamKeys)} not re-sent.`}
</Text>
</DroppedNote>
)}
<JsonEditor
ariaLabel="Request params"
label="Params"
@ -130,6 +165,7 @@ export function EditReplayModal({
opened,
method,
params,
droppedParamKeys,
onSend,
onClose,
}: EditReplayModalProps) {
@ -138,6 +174,7 @@ export function EditReplayModal({
<EditReplayForm
method={method}
params={params}
droppedParamKeys={droppedParamKeys}
onSend={onSend}
onClose={onClose}
/>

View file

@ -371,9 +371,16 @@ const RevealButton = Button.withProps({
function BodyPreview({
body,
contentType,
label,
}: {
body: string;
contentType?: string;
/**
* What this body *is* ("Request body", "Response body"), so a JSON payload's
* editor is named. An expanded entry holds both, and the list holds many
* pairs, so unnamed they all announce identically to a screen reader.
*/
label: string;
}) {
// Reveal state for masked secrets. Hooks run before any early return so the
// order stays stable across the too-large / has-secrets branches. The reveal
@ -413,7 +420,13 @@ function BodyPreview({
}
if (!hasSecrets) {
return <ContentViewer block={{ type: "text", text: body }} copyable />;
return (
<ContentViewer
block={{ type: "text", text: body }}
copyable
jsonLabel={`${label} JSON`}
/>
);
}
const shown = revealed ? body : masked;
@ -432,7 +445,11 @@ function BodyPreview({
{revealed ? "Hide" : "Reveal"}
</RevealButton>
</Group>
<ContentViewer block={{ type: "text", text: shown }} copyable />
<ContentViewer
block={{ type: "text", text: shown }}
copyable
jsonLabel={`${label} JSON`}
/>
</Stack>
);
}
@ -587,6 +604,7 @@ export function NetworkEntry({
key={`${entry.requestHeaders["content-type"] ?? ""}|${entry.requestBody}`}
body={entry.requestBody}
contentType={entry.requestHeaders["content-type"]}
label="Request body"
/>
</Stack>
)}
@ -604,6 +622,7 @@ export function NetworkEntry({
key={`${entry.responseHeaders?.["content-type"] ?? ""}|${entry.responseBody}`}
body={entry.responseBody}
contentType={entry.responseHeaders?.["content-type"]}
label="Response body"
/>
) : (
<DimmedNote>

View file

@ -221,6 +221,28 @@ describe("ProtocolEntry", () => {
});
});
// The entry's frame carries `_meta.progressToken`, which replay cannot
// re-send — so it must not appear in the editor as though it could.
it("seeds only the params replay will actually read", async () => {
const user = userEvent.setup();
const withMeta: MessageEntry = {
...successEntry,
message: {
...successEntry.message,
params: {
name: "get_weather",
arguments: { city: "San Francisco" },
_meta: { progressToken: 2 },
},
},
} as MessageEntry;
renderWithMantine(<ProtocolEntry {...baseProps} entry={withMeta} />);
await openEditor(user);
expect(getAceText()).not.toContain("_meta");
expect(screen.getByText(/`_meta` is not re-sent/)).toBeInTheDocument();
});
it("replays with the edited params", async () => {
const user = userEvent.setup();
const onReplay = vi.fn();
@ -419,6 +441,24 @@ describe("ProtocolEntry", () => {
).toBeInTheDocument();
});
// Both payloads render the same kind of control, so without distinct names a
// screen reader announces them identically.
it("names the parameters and response editors distinctly", () => {
renderWithMantine(
<ProtocolEntry
{...baseProps}
entry={successEntry}
isListExpanded={true}
/>,
);
expect(
screen.getByLabelText(/tools\/call request parameters JSON/),
).toBeInTheDocument();
expect(
screen.getByLabelText(/tools\/call response JSON/),
).toBeInTheDocument();
});
it("does not render the Parameters section when message has no params", () => {
renderWithMantine(
<ProtocolEntry

View file

@ -34,7 +34,10 @@ import {
extractSubscriptionId,
} from "../protocolUtils.js";
import { isReplayableProtocolMethod } from "../../../utils/replayableProtocolMethods";
import type { ReplayParamsOverride } from "../../../lib/protocolReplay";
import {
replayableParams,
type ReplayParamsOverride,
} from "../../../lib/protocolReplay";
export interface ProtocolEntryProps {
entry: MessageEntry;
@ -333,6 +336,7 @@ export function ProtocolEntry({
const status = extractStatus(entry);
const requestParams = extractParams(entry);
const canReplay = isReplayableProtocolMethod(method);
const editableParams = replayableParams(method, requestParams);
const resultType = extractResultType(entry);
const subscriptionId = extractSubscriptionId(entry);
@ -480,7 +484,10 @@ export function ProtocolEntry({
<EditReplayModal
opened={isEditingReplay}
method={method}
params={requestParams}
// Seeded with what replay will actually read, not the whole frame:
// the editor must not offer a field that Send would drop.
params={editableParams.params}
droppedParamKeys={editableParams.dropped}
onSend={onReplay}
onClose={() => setIsEditingReplay(false)}
/>
@ -513,6 +520,10 @@ export function ProtocolEntry({
text: serializeMessage(requestParams),
}}
copyable
// Named, because an expanded entry holds two of these and a
// list holds many pairs — unnamed they all announce the same
// thing, leaving only the visible headings to tell them apart.
jsonLabel={`${method} request parameters JSON`}
/>
</Stack>
)}
@ -525,6 +536,7 @@ export function ProtocolEntry({
text: serializeMessage(entry.response),
}}
copyable
jsonLabel={`${method} response JSON`}
/>
</Stack>
)}