feat(web): use the Ace JSON editor everywhere, and restore the raw-JSON toggle
Extracts the Ace integration #2094 carried inline into a shared `elements/JsonEditor`, and renders it on every surface where the web client types or displays JSON. The element is deliberately text in, text out. The two editing contracts above it disagree about what an unparseable draft means — JsonObjectInput keeps the last valid object and says nothing to its parent, SchemaJsonField reports `undefined` and blocks submission (#2020) — so parsing, and the draft/value split that goes with it, stays with each caller. Surfaces: SchemaJsonField (inherited by all four SchemaForm consumers), a new "Edit as JSON" switch for the whole arguments object, the server.json File Contents box, the raw JSON-RPC request box, ContentViewer's JSON branch (read-only, inherited by its eleven consumers), and a new "Edit and replay" modal on a Protocol entry. Ace read-only replaces the CodeHighlight JSON path and the Prism `json` grammar loader is dropped: folding, not colorization, is what it adds, and two highlighters for one language drift. `wrap={false}` callers and untyped text that only looks like JSON keep the plain renderer. Closes #2151 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:
parent
95db736a55
commit
1a2a36f0b8
2 changed files with 2026 additions and 448 deletions
|
|
@ -2,6 +2,7 @@ import { describe, it, expect, vi } from "vitest";
|
|||
import userEvent from "@testing-library/user-event";
|
||||
import type { MessageEntry } from "@inspector/core/mcp/types.js";
|
||||
import { renderWithMantine, screen } from "../../../test/renderWithMantine";
|
||||
import { getAceText, setAceText } from "../../../test/aceEditor";
|
||||
import { ProtocolEntry } from "./ProtocolEntry";
|
||||
|
||||
const successEntry: MessageEntry = {
|
||||
|
|
@ -203,6 +204,84 @@ describe("ProtocolEntry", () => {
|
|||
expect(onReplay).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
// Edit-and-replay is a second way to *supply* the params, not a second replay
|
||||
// implementation — it dispatches through the same `onReplay` (#2151).
|
||||
describe("edit and replay (#2151)", () => {
|
||||
async function openEditor(user: ReturnType<typeof userEvent.setup>) {
|
||||
await user.click(screen.getByRole("button", { name: "Edit and replay" }));
|
||||
}
|
||||
|
||||
it("seeds the editor with the entry's params", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithMantine(<ProtocolEntry {...baseProps} entry={successEntry} />);
|
||||
await openEditor(user);
|
||||
expect(JSON.parse(getAceText())).toEqual({
|
||||
name: "get_weather",
|
||||
arguments: { city: "San Francisco" },
|
||||
});
|
||||
});
|
||||
|
||||
it("replays with the edited params", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onReplay = vi.fn();
|
||||
renderWithMantine(
|
||||
<ProtocolEntry
|
||||
{...baseProps}
|
||||
entry={successEntry}
|
||||
onReplay={onReplay}
|
||||
/>,
|
||||
);
|
||||
await openEditor(user);
|
||||
await setAceText('{"name":"get_weather","arguments":{"city":"Boston"}}');
|
||||
await user.click(screen.getByRole("button", { name: "Send" }));
|
||||
|
||||
expect(onReplay).toHaveBeenCalledWith({
|
||||
name: "get_weather",
|
||||
arguments: { city: "Boston" },
|
||||
});
|
||||
// And the modal is gone — the entry itself is unchanged.
|
||||
expect(screen.queryByRole("button", { name: "Send" })).toBeNull();
|
||||
});
|
||||
|
||||
it("replays verbatim from the plain Replay button", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onReplay = vi.fn();
|
||||
renderWithMantine(
|
||||
<ProtocolEntry
|
||||
{...baseProps}
|
||||
entry={successEntry}
|
||||
onReplay={onReplay}
|
||||
/>,
|
||||
);
|
||||
await user.click(screen.getByRole("button", { name: "Replay" }));
|
||||
// No argument at all: "not edited" has to be distinguishable from
|
||||
// "edited to no params" one level up.
|
||||
expect(onReplay).toHaveBeenCalledWith();
|
||||
});
|
||||
|
||||
it("is offered only where Replay is", () => {
|
||||
const notification: MessageEntry = {
|
||||
id: "note-1",
|
||||
timestamp: new Date(),
|
||||
direction: "notification",
|
||||
message: { jsonrpc: "2.0", method: "notifications/message" },
|
||||
};
|
||||
renderWithMantine(<ProtocolEntry {...baseProps} entry={notification} />);
|
||||
expect(
|
||||
screen.queryByRole("button", { name: "Edit and replay" }),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("is offered in the compact layout too", () => {
|
||||
renderWithMantine(
|
||||
<ProtocolEntry {...baseProps} entry={successEntry} embedded />,
|
||||
);
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Edit and replay" }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("hides the Replay button for a method that can't be replayed", () => {
|
||||
// A notification isn't a replayable client→server request.
|
||||
renderWithMantine(
|
||||
|
|
|
|||
|
|
@ -21,6 +21,8 @@ import { McpErrorBadge } from "../../elements/McpErrorBadge/McpErrorBadge";
|
|||
import { ExpandToggle } from "../../elements/ExpandToggle/ExpandToggle";
|
||||
import { PinToggle } from "../../elements/PinToggle/PinToggle";
|
||||
import { ReplayButton } from "../../elements/ReplayButton/ReplayButton";
|
||||
import { EditReplayButton } from "../../elements/EditReplayButton/EditReplayButton";
|
||||
import { EditReplayModal } from "../EditReplayModal/EditReplayModal";
|
||||
import { useValueChange } from "../../../hooks/useValueChange";
|
||||
import {
|
||||
classifyProtocolSpecError,
|
||||
|
|
@ -32,12 +34,19 @@ import {
|
|||
extractSubscriptionId,
|
||||
} from "../protocolUtils.js";
|
||||
import { isReplayableProtocolMethod } from "../../../utils/replayableProtocolMethods";
|
||||
import type { ReplayParamsOverride } from "../../../lib/protocolReplay";
|
||||
|
||||
export interface ProtocolEntryProps {
|
||||
entry: MessageEntry;
|
||||
isPinned: boolean;
|
||||
isListExpanded: boolean;
|
||||
onReplay: () => void;
|
||||
/**
|
||||
* Re-issue this entry's request. Called with no argument by the Replay
|
||||
* button (the entry's own params, verbatim), and with an edited params object
|
||||
* by the Edit-and-replay modal — one dispatch path, two ways to supply the
|
||||
* params (#2151).
|
||||
*/
|
||||
onReplay: (overrideParams?: ReplayParamsOverride) => void;
|
||||
onTogglePin: () => void;
|
||||
/**
|
||||
* Compact two-line header for the narrow monitoring sidebar (#1616): line 1 is
|
||||
|
|
@ -194,6 +203,17 @@ function formatTimestampCompact(date: Date): string {
|
|||
return date.toISOString().slice(11, 19);
|
||||
}
|
||||
|
||||
// The request's `params` object, which the Edit-and-replay editor is seeded
|
||||
// from and the expanded detail renders. `undefined` for a frame that carries
|
||||
// none — a bare `tools/list`, or a response.
|
||||
function extractParams(
|
||||
entry: MessageEntry,
|
||||
): Record<string, unknown> | undefined {
|
||||
const msg = entry.message;
|
||||
if (!("params" in msg) || !msg.params) return undefined;
|
||||
return msg.params as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function extractTarget(entry: MessageEntry): string | undefined {
|
||||
const msg = entry.message;
|
||||
if (!("params" in msg) || !msg.params) return undefined;
|
||||
|
|
@ -306,10 +326,12 @@ export function ProtocolEntry({
|
|||
correlatedHttpStatus,
|
||||
}: ProtocolEntryProps) {
|
||||
const [isExpanded, setIsExpanded] = useState(isListExpanded);
|
||||
const [isEditingReplay, setIsEditingReplay] = useState(false);
|
||||
const method = extractMethod(entry);
|
||||
const target = extractTarget(entry);
|
||||
const resourceUri = extractResourceUri(entry);
|
||||
const status = extractStatus(entry);
|
||||
const requestParams = extractParams(entry);
|
||||
const canReplay = isReplayableProtocolMethod(method);
|
||||
const resultType = extractResultType(entry);
|
||||
const subscriptionId = extractSubscriptionId(entry);
|
||||
|
|
@ -363,6 +385,14 @@ export function ProtocolEntry({
|
|||
const durationText = entry.duration != null && (
|
||||
<DurationText>{formatDuration(entry.duration)}</DurationText>
|
||||
);
|
||||
// Both replay affordances are gated on the same predicate, so a method that
|
||||
// cannot be replayed does not offer an editor that would fail on Send.
|
||||
const replayControls = canReplay && (
|
||||
<>
|
||||
<ReplayButton onReplay={() => onReplay()} />
|
||||
<EditReplayButton onClick={() => setIsEditingReplay(true)} />
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<EntryContainer>
|
||||
|
|
@ -401,7 +431,7 @@ export function ProtocolEntry({
|
|||
)}
|
||||
</HeaderCluster>
|
||||
<ControlsCluster>
|
||||
{canReplay && <ReplayButton onReplay={onReplay} />}
|
||||
{replayControls}
|
||||
<PinToggle pinned={isPinned} onToggle={onTogglePin} />
|
||||
<ExpandToggle
|
||||
expanded={isExpanded}
|
||||
|
|
@ -436,7 +466,7 @@ export function ProtocolEntry({
|
|||
</HeaderRow>
|
||||
|
||||
<ToggleRow>
|
||||
{canReplay && <ReplayButton onReplay={onReplay} />}
|
||||
{replayControls}
|
||||
<PinToggle pinned={isPinned} onToggle={onTogglePin} />
|
||||
<ExpandToggle
|
||||
expanded={isExpanded}
|
||||
|
|
@ -446,6 +476,16 @@ export function ProtocolEntry({
|
|||
</>
|
||||
)}
|
||||
|
||||
{canReplay && (
|
||||
<EditReplayModal
|
||||
opened={isEditingReplay}
|
||||
method={method}
|
||||
params={requestParams}
|
||||
onSend={onReplay}
|
||||
onClose={() => setIsEditingReplay(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Collapse in={isExpanded}>
|
||||
<Stack gap="sm">
|
||||
<Divider />
|
||||
|
|
@ -464,13 +504,13 @@ export function ProtocolEntry({
|
|||
}
|
||||
/>
|
||||
)}
|
||||
{"params" in entry.message && entry.message.params && (
|
||||
{requestParams && (
|
||||
<Stack gap="xs">
|
||||
<Text size="sm">Parameters:</Text>
|
||||
<ContentViewer
|
||||
block={{
|
||||
type: "text",
|
||||
text: serializeMessage(entry.message.params),
|
||||
text: serializeMessage(requestParams),
|
||||
}}
|
||||
copyable
|
||||
/>
|
||||
|
|
|
|||
Loading…
Reference in a new issue