feat(web): use the Ace JSON editor everywhere, and restore the raw-JSON toggle - #2170
feat(web): use the Ace JSON editor everywhere, and restore the raw-JSON toggle#2170cliffhall wants to merge 15 commits into
Conversation
…ON 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>
There was a problem hiding this comment.
Pull request overview
Introduces a shared Ace-based JSON editor across the web client, restores whole-argument JSON editing, and adds editable protocol replay.
Changes:
- Centralizes editable and read-only JSON rendering in
JsonEditor. - Adds SchemaForm raw-JSON mode and Protocol “Edit and replay.”
- Updates affected tests, stories, styling, and documentation.
Reviewed changes
Copilot reviewed 46 out of 46 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
clients/web/README.md |
Documents JSON editor architecture. |
clients/web/src/App.css |
Hides read-only Ace cursors. |
clients/web/src/test/aceEditor.ts |
Adds labelled Ace test helpers. |
clients/web/src/lib/protocolReplay.ts |
Defines replay override states. |
clients/web/src/hooks/useExportActions.ts |
Dispatches edited replay parameters. |
clients/web/src/hooks/useExportActions.test.tsx |
Tests replay overrides. |
clients/web/src/components/views/InspectorView/InspectorView.tsx |
Propagates replay override typing. |
clients/web/src/components/screens/ProtocolScreen/ProtocolScreen.tsx |
Propagates replay override typing. |
clients/web/src/components/elements/JsonEditor/JsonEditor.tsx |
Implements shared Ace editor. |
clients/web/src/components/elements/JsonEditor/JsonEditor.test.tsx |
Tests editor behavior and accessibility. |
clients/web/src/components/elements/JsonEditor/JsonEditor.stories.tsx |
Adds editor stories. |
clients/web/src/components/elements/JsonObjectInput/JsonObjectInput.tsx |
Refactors object input onto JsonEditor. |
clients/web/src/components/elements/JsonObjectInput/JsonObjectInput.test.tsx |
Updates object-editor tests. |
clients/web/src/components/elements/EditReplayButton/EditReplayButton.tsx |
Adds replay-edit action. |
clients/web/src/components/elements/EditReplayButton/EditReplayButton.test.tsx |
Tests replay-edit action. |
clients/web/src/components/elements/EditReplayButton/EditReplayButton.stories.tsx |
Adds replay-edit stories. |
clients/web/src/components/elements/ContentViewer/ContentViewer.tsx |
Uses Ace for JSON display. |
clients/web/src/components/elements/ContentViewer/ContentViewer.test.tsx |
Tests JSON rendering paths. |
clients/web/src/components/elements/ContentViewer/contentViewerUtils.ts |
Adds strict JSON-document detection. |
clients/web/src/components/elements/ContentViewer/contentViewerUtils.test.ts |
Tests JSON detection. |
clients/web/src/components/elements/CodeHighlight/CodeHighlight.tsx |
Removes Prism JSON loading. |
clients/web/src/components/elements/CodeHighlight/CodeHighlight.test.tsx |
Retargets highlighter tests to YAML. |
clients/web/src/components/elements/CodeHighlight/CodeHighlight.stories.tsx |
Replaces JSON story with YAML. |
clients/web/src/components/groups/SchemaForm/SchemaForm.tsx |
Adds Ace fields and raw mode. |
clients/web/src/components/groups/SchemaForm/SchemaForm.test.tsx |
Covers raw-JSON behavior. |
clients/web/src/components/groups/SchemaForm/SchemaForm.stories.tsx |
Demonstrates raw-JSON switching. |
clients/web/src/components/groups/EditReplayModal/EditReplayModal.tsx |
Implements editable replay modal. |
clients/web/src/components/groups/EditReplayModal/EditReplayModal.test.tsx |
Tests replay editing and validation. |
clients/web/src/components/groups/EditReplayModal/EditReplayModal.stories.tsx |
Adds replay-modal stories. |
clients/web/src/components/groups/ProtocolEntry/ProtocolEntry.tsx |
Integrates edit-and-replay controls. |
clients/web/src/components/groups/ProtocolEntry/ProtocolEntry.test.tsx |
Tests protocol replay editing. |
clients/web/src/components/groups/ProtocolListPanel/ProtocolListPanel.tsx |
Forwards replay overrides. |
clients/web/src/components/groups/ProtocolListPanel/ProtocolListPanel.test.tsx |
Updates replay callback assertions. |
clients/web/src/components/groups/MrtrConversation/MrtrConversation.tsx |
Forwards replay overrides for rounds. |
clients/web/src/components/groups/ImportServerJsonPanel/ImportServerJsonPanel.tsx |
Replaces file textarea with Ace. |
clients/web/src/components/groups/ImportServerJsonPanel/ImportServerJsonPanel.test.tsx |
Updates import editor tests. |
clients/web/src/components/groups/ServerImportJsonModal/ServerImportJsonModal.test.tsx |
Drives imports through Ace helpers. |
clients/web/src/components/groups/ExperimentalFeaturesPanel/ExperimentalFeaturesPanel.tsx |
Replaces request textarea with Ace. |
clients/web/src/components/groups/ExperimentalFeaturesPanel/ExperimentalFeaturesPanel.test.tsx |
Updates request editor tests. |
clients/web/src/components/groups/AppDetailPanel/AppDetailPanel.test.tsx |
Updates JSON validity tests. |
clients/web/src/components/groups/ToolDetailPanel/ToolDetailPanel.test.tsx |
Updates tool JSON tests. |
clients/web/src/components/groups/ToolResultPanel/ToolResultPanel.test.tsx |
Reads virtualized JSON through Ace. |
clients/web/src/components/groups/StructuredOutputPanel/StructuredOutputPanel.test.tsx |
Verifies Ace-rendered output. |
clients/web/src/components/groups/ResourcePreviewPanel/ResourcePreviewPanel.test.tsx |
Verifies inferred JSON rendering. |
clients/web/src/components/groups/ResourceLink/ResourceLink.test.tsx |
Updates inline resource assertions. |
clients/web/src/components/groups/ResourceLink/ResourceLink.stories.tsx |
Updates large-result interaction checks. |
clients/web/src/components/groups/NetworkEntry/NetworkEntry.test.tsx |
Updates masked-body assertions. |
💡 Add a code-review agent skill for context-aware, tailored reviews. Learn more in the docs.
- Cap the read-only editor's `maxLines` at 200. Uncapped, Ace renders every line of a payload and its viewport virtualization never engages — which matters because the Protocol and Network lists are not virtualized and keep each entry's payload mounted inside its `Collapse`. 200 is chosen so no real payload nests a scrollbar inside its host's while the pathological one stays bounded; the StructuredOutputPanel `Large` story now pins both halves (full payload in the document, only part of it in the DOM). - Take a disabled editor out of the tab order and give it `aria-disabled`. Ace has no disabled state, so its hidden textarea kept `tabindex="0"` and a keyboard user could tab into a field every neighbouring control had dropped — landing somewhere that looks editable and swallows what they type. A merely read-only editor stays focusable, since a payload has to be keyboard-scrollable (WCAG SC 2.1.1). - Reword a confusing comment in the EditReplayModal test. The `Disabled` story additionally scopes the axe `color-contrast` rule off: WCAG 1.4.3 exempts inactive components, and axe cannot see that the dimmed gutter belongs to one because the disabled state lives on Ace's hidden textarea. Scoped to that one story so the rule still covers everything else. 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>
Review round 1 — all three comments addressed (c9fb461)Mirrored here because inline replies get folded away once the fix is pushed and the threads go outdated. 1. The reasoning I had written into that comment was wrong on the part that matters. I chose
Not done, on the record rather than silently dismissed: capping bounds the per-payload DOM but not the per-payload instance count — a long Protocol history still constructs an Ace editor per collapsed payload. Deferring that mount means changing when 2.
3. "sitting" typo — fixed. One thing I added that Copilot did not ask for, because CI caught it: the new
|
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 47 out of 47 changed files in this pull request and generated 1 comment.
Suppressed comments (2)
Previously missed (2) — in code that hasn't changed since the last review.
clients/web/src/components/groups/EditReplayModal/EditReplayModal.tsx:52
JSON.parseaccepts an overflowing number such as1e400asInfinity;isJsonObjectthen accepts the containing object, so Send remains enabled even though the edited params are not JSON-serializable and will be rejected or serialized asnull. Apply the existingisSerializableJsonguard before returning success, matchingjsonObjectDraft.ts.
let parsed: unknown;
try {
parsed = JSON.parse(text);
} catch {
return { ok: false, error: "Not valid JSON — this cannot be sent" };
}
if (!isJsonObject(parsed)) {
return { ok: false, error: "Params must be a JSON object (`{ … }`)" };
clients/web/src/components/groups/SchemaForm/SchemaForm.tsx:582
JSON.parseaccepts overflowing literals such as1e400asInfinity, and this object-only check then treats the draft as valid. The form enables submission and emits a value that cannot be represented as JSON (JSON.stringifychanges it tonull). Validate with the existingisSerializableJsonhelper, asjsonObjectDraft.tsdoes, and keep the raw draft invalid when a number is non-finite.
…the raw draft
Two more from Copilot's second pass, both real.
- Every JSON-draft parser now rejects a value `JSON.stringify` cannot write
back. `JSON.parse("1e400")` yields `Infinity`, which serializes as `null` —
so an editor showing `1e400` would have sent `null`, which is exactly the
misreport `isSerializableJson` was written for. `parseJsonObjectDraft`
already guarded; the raw-arguments editor and the Edit-and-replay modal did
not, and neither did `SchemaJsonField`'s `parseJsonDraft` — the last
predates this PR but is the same defect in the same family, so it is fixed
with them rather than left as the odd one out.
- Editing a root union's discriminator in the raw document now moves the
Variant picker. Nothing in `values` names a branch, so the index is held by
the form and was only re-derived on `resetKey` or a rewritten union; a raw
edit had no other signal, so `{"kind":"email"}` → `{"kind":"sms",…}` left
the picker on Email and switching back to the widgets rendered the Email
branch over SMS values. The current branch is kept when the values identify
none, since the discriminator is absent every time it is retyped.
The raw editor is keyed by entity only, not by entity-and-branch like the
per-field editors: it holds the whole arguments object, so a branch move must
re-sync it rather than remount it — a remount would reformat the text being
typed and drop the caret to the top.
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>
Review round 2 — both comments addressed (b4083ee)Including the two in the Suppressed comments block, which are the more valuable half of this round. 1. Raw JSON edits didn't move the Variant picker (inline) — fixed. Nothing in
Fixing it surfaced a second problem I'd otherwise have shipped: the raw editor was keyed by 2 & 3. This is the sharpest finding of the review, because it is the failure mode this codebase names explicitly: the editor shows Both now have it. I also applied it to Three new tests cover the picker (moves / holds / doesn't reformat) and two cover the overflow rejection.
Still outstanding from round 1, deliberately not in this PR: capping |
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 47 out of 47 changed files in this pull request and generated no new comments.
Suppressed comments (3)
Previously missed (3) — in code that hasn't changed since the last review.
clients/web/src/components/elements/JsonEditor/JsonEditor.tsx:200
ariaLabelis only forced onto Ace’s hidden textarea inonLoad. If this prop changes while the editor remains mounted (for example, an in-place schema refresh changes a field title),setOptionsupdates Ace’s option but the DOM label is not recomputed until the cursor moves, so the visible label and accessible name disagree. Reapply the option and calltextInput.setAriaLabel()from the wiring effect wheneverariaLabelchanges.
}, [wrapperId, hasError, description, disabled]);
clients/web/README.md:288
- This universal claim is inaccurate:
ExperimentalFeaturesPanelstill displays its JSON-RPC response throughResponseTextarea(ExperimentalFeaturesPanel.tsx:333), and decoded JWT JSON is rendered byOAuthTokenField.tsx:125. Narrow this to the JSON editing surfaces and theContentViewerJSON branch unless those remaining displays are also migrated.
Every surface in this client where JSON is typed or displayed renders one element: **`elements/JsonEditor`**, an **Ace** editor (`react-ace` + `ace-builds`, declared in this client because they render React). Ace brings code folding, line numbers, brace auto-closing, and per-line error annotation from its JSON worker — the last three are why hand-writing a nested payload in a bare textarea was the actual pain ([#2151](https://github.com/modelcontextprotocol/inspector/issues/2151)).
clients/web/README.md:301
ContentVieweris not the route for every read-only JSON payload in the client: the experimental JSON-RPC response and decoded JWT display bypass it. This architectural documentation should describe onlyContentViewerconsumers so it does not send future maintainers to the wrong abstraction.
**Read-only mode is what `ContentViewer` renders JSON as**, and through it every JSON payload in the app: Protocol and Network entries, tool results, structured output, resource previews, server cards. Highlighting is *not* what that buys — JSON already highlighted, via the lazily-imported Prism grammar `CodeHighlight` loads. What Ace adds is **folding**, line numbers and a gutter on a large payload. The Prism `json` grammar was dropped in the same change rather than kept beside it: two highlighters for one language drift, and nothing else asks for `json`. Two cases stay on the plain renderer — a `wrap={false}` caller (the server card's fixed-height, single-line box) and untyped text that only *looks* like JSON but does not parse, which in an editor would frame a server's prose as a malformed document.
…ME's scope Copilot's third pass generated no new inline comments; all three of its suppressed ones were right. - `ariaLabel` is now re-applied from the wiring effect, not only at mount. `setOptions` updates Ace's option when the prop changes, but Ace recomputes the textarea's `aria-label` from it inside `TextInput.setAriaLabel()`, which it calls when the cursor moves — so a name that changed while the editor stayed mounted (a tool refreshed in place under a new field title, which `SchemaJsonField` passes straight through) left the visible label and the accessible name disagreeing until the user clicked into the box. `onLoad` keeps doing it too, since an effect runs after paint and this is the control's only name. - The README claimed this editor is on "every surface where JSON is typed or displayed", which is false in two places it now names instead: `ExperimentalFeaturesPanel`'s JSON-RPC *response* is still a read-only `Textarea` (only its request box moved), and `OAuthTokenField` renders a decoded JWT in a `Code` block beside the raw token. Both bypass `ContentViewer` and so are not reached by its JSON branch. Documenting them as exceptions is what keeps the section from sending a maintainer to the wrong abstraction. 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>
Review round 3 — all three suppressed comments addressed (12c00d8)No new inline comments this pass; the whole round was in the Suppressed comments block, and all three were right. 1.
The wiring effect now re-applies it, with 2 & 3. The README overclaimed its own scope — fixed. Checked both, and both are real:
Neither goes through I did not migrate them. Both are outside #2151's enumerated surfaces, and the JWT one in particular is a field with its own toggle rather than a payload viewer, so it deserves its own decision rather than being swept in at the end of a review round.
|
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 47 out of 47 changed files in this pull request and generated 1 comment.
Suppressed comments (2)
Previously missed (2) — in code that hasn't changed since the last review.
clients/web/src/components/elements/ContentViewer/ContentViewer.tsx:194
- This labels every focusable read-only Ace instance as “JSON content”. An expanded Protocol entry can contain both Parameters and Response editors (and the list can contain many entries), so screen-reader users tab through indistinguishable textboxes even though the visible sections have distinct names. Please let callers provide a contextual accessible name (for example, “Request parameters JSON” and “Response JSON”) and associate it with this editor.
<JsonEditor
ariaLabel="JSON content"
value={formatJson(text)}
readOnly
clients/web/src/components/elements/ContentViewer/ContentViewer.tsx:234
isJsonDocumentparses the payload here, thenJsonContentimmediately parses it again throughformatJson. Protocol and Network entries pass their serialized bodies as untyped text, so a large, non-virtualized history now performs two full parses per JSON payload on each render. Return the formatted text from the validation step (or otherwise memoize a single parse/format result) before mounting Ace.
if (wrap && isJsonDocument(text)) {
return <JsonContent text={text} copyable={copyable} />;
… 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>
Review round 4 — all three addressed (5abe289)One inline, two suppressed. All three were right, and the inline one is the best catch of the review. 1. Edit-and-replay showed params that Send would drop (inline) — fixed.
Of the two options offered I took the second (restrict the editor), because threading New
A non-string 2. Every read-only JSON editor was named "JSON content" (suppressed) — fixed.
3. The payload was parsed twice per render (suppressed) — fixed.
|
Copilot's fifth pass: one comment, and the completion of the previous round's fix. Seeding the editor from `replayableParams` stopped it *offering* a key the dispatcher would drop, but nothing stopped one being typed in — adding `_meta` to a `tools/list` draft still let Send close the modal and discard it. `parseParamsDraft` now takes the method and rejects any key the dispatch would not read, so Send is disabled with the offending names rather than silently dropping them. It asks the same `replayableParams` the seed is built from, so the editor and the dispatcher cannot disagree about which keys survive. 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>
Review round 5 — the one comment addressed (4e7537e)One inline comment, no suppressed ones. Edits weren't projected, only the seed was — fixed. Last round I stopped the editor offering a key the dispatcher would drop; this is the same defect one step later, and the example given is exact: a
const { dropped } = replayableParams(method, parsed);
if (dropped.length > 0) {
return { ok: false, error: `${formatDroppedKeys(dropped)} not carried by ${method} — remove …` };
}Deriving both from I chose to disable Send over stripping on submit — consistent with the modal's other invalid states (unparseable, non-object, non-finite number), and because stripping would restore exactly the property this exists to remove: the editor showing something other than what is sent. It costs a user pasting a whole captured frame one deletion, which the note above the editor already predicts. Three tests: the key is rejected, removing it re-enables Send, and — since the check is per method rather than a fixed key list —
|
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 49 out of 49 changed files in this pull request and generated no new comments.
Suppressed comments (2)
Previously missed (1) — in code that hasn't changed since the last review.
clients/web/src/components/groups/NetworkEntry/NetworkEntry.tsx:428
- A declared JSON body does not reach the MIME-based JSON branch because
contentTypeis not forwarded. As a result, malformedapplication/jsonbodies still use the plain-text renderer, even though this change explicitly treats declared JSON as JSON regardless of parse success. Pass the available MIME type toContentViewer.
<ContentViewer
block={{ type: "text", text: body }}
copyable
jsonLabel={`${label} JSON`}
/>
clients/web/src/components/groups/EditReplayModal/EditReplayModal.tsx:88
- This accepts a present non-object
arguments, so valid JSON such as{"name":"x","arguments":null}enables Send.replayProtocolRequestthen appliesparams.arguments ?? {}and sends{}, not the value shown in the editor; arrays/scalars can likewise reach the typed call through a cast. Reject a present non-objectargumentsfortools/callandprompts/getbefore enabling Send.
if (!isSerializableJson(parsed)) {
Review round 9 — all three addressed (03bf9ec)Two of the three were consequences of round 8's own changes. 1. The tool was resolved from the entry's name, not the draft's — fixed. I threaded the tool but looked it up in the wrong place: the modal lets 2. The error message contradicted its own check — fixed. Written for the value-based version and not revisited when the rewrite changed what the rule is. Renamed 3. "Not valid JSON" misdiagnosed a valid document — fixed. The worst direction for a message to be wrong in: the user reads about syntax while looking at syntactically fine JSON. Worth reporting, because it is the kind of thing this PR keeps turning up: the first version of fix 3 had a bug every assertion missed. Routing the value through the reason check meant empty text — not a problem, since an untouched optional field is fine — fell through to
|
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 52 out of 52 changed files in this pull request and generated no new comments.
Suppressed comments (2)
Previously missed (2) — in code that hasn't changed since the last review.
clients/web/src/components/elements/ContentViewer/contentViewerUtils.ts:165
- This formatter can change the value being inspected. For example,
{"id":9007199254740993}is rendered with9007199254740992, and{"limit":1e400}is rendered asnull, even though the copy action still contains the original text. Preserve the source for overflowing/imprecise numeric literals (the newhasImpreciseIntegerLiteraland existing serializability check can gate pretty-printing), or use a lossless JSON formatter; apply the same rule to the declared-JSONformatJsonpath so both read-only branches remain faithful to the wire payload.
return JSON.stringify(JSON.parse(text), null, 2);
clients/web/src/utils/jsonObjectDraft.ts:48
- Skipping every fractional or exponent-form token still permits the editor/wire mismatch this guard is meant to prevent. For example,
{"n":0.10000000000000001}parses and serializes as{"n":0.1}, so the editor keeps digits that submission drops; long exponent-form mantissas have the same problem. Compare the exact decimal value of every numeric token with the parsed number's serialized value, regardless of notation, and only accept it when they are equivalent.
…ferent one
Copilot's tenth pass generated no new inline comments. Of its two suppressed
ones, this fixes the first and declines the second.
`ContentViewer` pretty-prints by `JSON.parse` then `JSON.stringify`, which is a
round trip through JavaScript's number type and is not lossless. A server
sending `{"id":9007199254740993}` was shown `…992`, and one sending
`{"limit":1e400}` was shown `null`. Neither is a formatting difference: both
are the viewer telling the reader something untrue about the payload it
received, which for an inspector is the worst kind of bug. It predates this PR
— `formatJson` has always round-tripped — but this PR is where the read-only
JSON path was rebuilt, and the same two predicates the draft parsers use answer
it exactly.
Both formatters now display the payload as it arrived when reindenting would
alter a value: unindented, but honest. The copy button already copied the
original text either way, so this makes the screen agree with it.
Not changed: a literal whose *spelling* is normalized but whose value is not.
`0.10000000000000001` and `0.1` are the same double, as are `1e2` and `100`, so
those still format. Rejecting them would mean refusing any literal not already
in JS's shortest form — `1.50`, `1e2`, `0.5000` — which blocks drafts that send
exactly what they mean. The integer guard stays because there the digits name a
value the double is not.
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>
Review round 10 — first suppressed comment fixed, second declined with reasons (390e0c4)No new inline comments. Two suppressed; I've taken one and am pushing back on the other. Fixed: the read-only viewer could reformat a payload into a different oneThis is a good catch and the sharpest kind for this project. It predates this PR ( Declined: rejecting literals whose spelling changes but whose value does not
The comparison suggested — the token against the parsed number's serialized form — is a test for "already in JS's shortest form", and it rejects There is no middle option, either: any "exact decimal value vs the double" test rejects The integer guard stays because it is a different thing. If you'd rather surface the normalization without blocking, the honest shape would be a non-blocking note ("this will be sent as
|
| if (text.trim() === "") return { ok: true, value: null }; | ||
| let parsed: unknown; | ||
| try { | ||
| parsed = JSON.parse(text); |
Copilot's eleventh pass: three comments, one defect in three places.
`JSON.parse` accepts duplicate member names and keeps the last without
complaint, so `{"role":"user","role":"admin"}` becomes `{"role":"admin"}` —
a document that renders, and submits, as *less* than it says. In the read-only
viewer that is the Inspector hiding what the server sent; in a draft it is the
editor showing more than the wire will carry. Nothing downstream could catch
it, because by the time `JSON.parse` returns the evidence is gone.
New `findDuplicateObjectKey` scans the source, scoped per object so
`{"a":{"a":1}}` and `[{"a":1},{"a":2}]` are fine, and compares names by decoded
value so `"a"` and `"a"` collide as they should. It shares its
escape-handling with the number-literal scanner, which now reads strings
through the same helper rather than skipping them inline.
Wired into all four JSON-draft parsers and into the viewer's faithfulness
check, so the same document is refused, or shown unindented, everywhere.
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>
Review round 11 — all three addressed (1b28fc8)Three comments, one defect in three places, and a good one.
New
It shares its escape handling with the number-literal scanner, which now reads strings through the same helper instead of skipping them inline — one place to get backslash-before-quote right. Wired into all four draft parsers and into the viewer's faithfulness check, so the same document is refused, or shown unindented, everywhere. Twelve new tests.
A note on where this is heading, since it is round eleven. Every finding since round 7 has been an instance of one principle — what the editor shows must be what the wire carries, and what the viewer shows must be what the wire carried — and each round has found a subtler instance: rounded integers, then exponent-form literals, then string coercion per schema, then the retargeted tool, and now duplicate keys. That has been genuinely valuable and I have taken all of it. But the remaining surface is JSON-parsing exotica rather than anything this feature introduced, and the guards now live in one shared place with tests. If the next round surfaces something in that same family, I'd suggest it lands as a follow-up issue against |
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 52 out of 52 changed files in this pull request and generated 1 comment.
Suppressed comments (1)
Previously missed (1) — in code that hasn't changed since the last review.
clients/web/src/utils/jsonObjectDraft.ts:51
- Negative-zero literals bypass this comparison:
BigInt("-0")andBigInt(Number("-0"))are both0n, butJSON.stringify(JSON.parse('{"n":-0}'))emits{"n":0}. As a result, the raw form and replay editor can show-0while sending0, andContentViewercan rewrite a received-0during pretty-printing. DetectObject.is(Number(literal), -0)for all numeric spellings (including-0.0/-0e1) before the integer-only check, and use a diagnostic that covers serialization loss as well as rounding.
…erialized Copilot's twelfth pass. The suppressed comment is right and is fixed here; the inline one is real too, but needs a core API change and is filed as #2171. `-0` is represented exactly, so the digit comparison had nothing to say about it — but `JSON.stringify` writes it as `0`, so the loss happens on the way *out* rather than on the way in. The editor showed `-0` and the wire carried `0`, and the viewer rewrote a received `-0` while pretty-printing. `hasImpreciseIntegerLiteral` becomes `findUnsendableNumberLiteral`, checking `Object.is(Number(literal), -0)` for every numeric spelling — `-0`, `-0.0`, `-0e1` — before the integer-only digit check. Returning the offending literal rather than a boolean lets the message name it, which is more use than either of the two rules behind it and matches how the duplicate-key message reads. 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>
Review round 12 — one fixed here (8aa6f01), one filed as #2171Negative-zero literals (suppressed) — fixed.
Raw-mode arguments are still coerced by the schema (inline) — filed as #2171, not fixed here. This one is real, and the second-order effect is the sharper half: I've deliberately not fixed it in this PR, and the issue records why. The change spans
This is where I'd suggest the review lands. Twelve rounds have produced 28 findings, all taken except two I've argued against on the record (normalized number spellings, which change no value) and this one, which is now tracked. Everything the PR itself introduced is fixed; what is left is a shared-core decision with an issue behind it. |
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 52 out of 52 changed files in this pull request and generated 1 comment.
Suppressed comments (1)
Previously missed (1) — in code that hasn't changed since the last review.
clients/web/src/components/elements/ContentViewer/ContentViewer.tsx:478
- The new JSON renderer is propagated only through the
textblock branch. Embedded-resourceContentBlocks take the separateresourcecase below, which ignoresblock.resource.mimeType; therefore a prompt message containing{ type: "resource", resource: { mimeType: "application/json", text: "{\"a\":1}" } }still renders as plainCode. Route embedded text resources through the same MIME/JSON dispatch so allContentViewerconsumers actually inherit this migration.
mimeType={mimeType}
copyable={copyable}
wrap={wrap}
jsonLabel={jsonLabel}
…ources Copilot's thirteenth pass, two comments, both real. - `1e-400` has a nonzero mantissa and parses to `0`. The digit comparison skips it for being exponent-form and it is not negative zero, so nothing saw a document that says one number and sends none of it. Caught now by pairing `Number(literal) === 0` with a nonzero digit in the mantissa, which leaves a literal written as zero (`0e-400`) and a subnormal that still parses to something (`1e-320`) alone. - The `resource` content-block branch rendered every embedded resource in a plain code block, ignoring `resource.mimeType`, so a prompt message carrying JSON did not inherit this migration even though the text-block branch did. An embedded *text* resource declaring JSON now goes through the same dispatch. Narrowed to JSON, as the Network tab's bodies are: routing every declared type through would also put an embedded `text/html` resource into the sandboxed frame and a `text/csv` one into a table. The Resources screen already renders resource contents that way, so doing it here too is defensible — but it is a change to what a tool result *is*, and belongs to whoever decides that. 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>
Review round 13 — both addressed (684eb9c)1. A nonzero mantissa that parses to if (Number(literal) === 0 && /[1-9]/.test(mantissaOf(literal))) return literal;Pairing the two conditions is what keeps it from over-reaching: a literal written as zero in exponent form ( 2. Embedded resource blocks bypassed the dispatch (suppressed) — fixed. Right, and it undercut a claim this PR makes: the Narrowed to JSON, on the same reasoning as the Network tab's bodies: routing every declared type through would also put an embedded
|
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 52 out of 52 changed files in this pull request and generated 1 comment.
Suppressed comments (2)
Previously missed (2) — in code that hasn't changed since the last review.
clients/web/src/lib/protocolReplay.ts:188
- An omitted
argumentskey is also reshaped: both replay branches passparams?.arguments ?? {}, and the client emits that empty object on the wire. Thus the editor can show{"name":"echo"}while Send transmits{"name":"echo","arguments":{}}, despite this helper's contract that accepted params survive intact. Seed the editor witharguments: {}when replay will add it, or preserve omission in the dispatcher.
clients/web/src/components/groups/ImportServerJsonPanel/ImportServerJsonPanel.tsx:152 - Using
hasContenthere removes the Clear action for a whitespace-only draft because that flag trims the text. The editor still contains the whitespace (and the previous textarea showed Clear for any non-empty string), so this is a small regression. Keep the trimmed flag for validation/display logic, but show Clear wheneverdraft.rawText.length > 0.
{hasContent && (
| if (Number(literal) === 0 && /[1-9]/.test(mantissaOf(literal))) { | ||
| return literal; | ||
| } | ||
| if (!/^-?\d+$/.test(literal)) continue; |
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 52 out of 52 changed files in this pull request and generated no new comments.
Suppressed comments (1)
clients/web/src/utils/jsonObjectDraft.ts:62
- This skips every fraction/exponent token, but those spellings can still encode an exact integer that
JSON.parserounds. For example, both9007199254740993e0and9007199254740993.0become9007199254740992, so the raw editor/replay path sends a different value andContentViewerpretty-prints a payload as the wrong value despite this guard. Please normalize mathematically integral decimal/exponent literals and compare their exact value with the parsed number too, while continuing to accept genuinely fractional/exponent values that round-trip as intended.
Review loop completeTwo further review requests (10:27Z and 11:27Z) were dequeued without a review; the last one posted was round 13's, and every round before it landed within 5–21 minutes. Recording it here so the silence reads as "nothing further" rather than "still pending". Thirteen rounds, 30 findings. All were taken except three, each argued on the record rather than dropped:
The findings converged on one principle and kept finding subtler instances of it: what the editor shows must be what the wire carries, and what the viewer shows must be what the wire carried. Rounded integers, then full-form literals at ≥1e21, then per-schema string coercion, then the retargeted tool, then duplicate keys, then negative zero, then underflow. Two of those were bugs in my own earlier fixes, and one — the read-only viewer rewriting a received payload — was the most valuable catch of the review, because it predates this PR and affects reading rather than sending. Everything this PR introduced is fixed. |
Closes #2151
Puts the Ace editor from #2094 behind every place the web client types or displays JSON, and restores v1's raw-JSON toggle on tool forms.
What changed
A shared element,
elements/JsonEditor.JsonObjectInput(#2094) carried the Ace integration inline, on an object-shaped contractSchemaJsonFieldcannot use. The Ace wiring — the module-scoped worker URL, the theme, the label/aria-describedby/aria-invalidwiring onto Ace's hidden textarea, and the coalescing of Ace's paired remove/insert change events — moves intoJsonEditor, which is deliberately text in, text out.That split is the design decision worth reviewing. The two editing contracts disagree about what an unparseable draft means —
JsonObjectInputkeeps the last valid object and says nothing to its parent (there is no Save button to gate), whileSchemaJsonFieldreportsundefinedand blocks submission (#2020) — and no component that decides for them can serve both. So parsing, and the draft/value split that goes with it, stays with each caller; only the editing surface is shared.JsonObjectInputis now a thin wrapper over it and behaves exactly as before.The six surfaces:
SchemaJsonField— the object/array/union fallback inSchemaFormSchemaFormconsumers: Tools, Apps, and both elicitation panels.SchemaFormImportServerJsonPanel→ File ContentsExperimentalFeaturesPanel→ RequestContentViewer's JSON branchreplayProtocolRequest.The decision the issue asked to be stated on the PR
Ace read-only replaces the
CodeHighlightJSON path, and the Prismjsongrammar loader is dropped. Colorization was never the win — JSON already highlighted through the lazily-imported Prism grammar. What Ace adds is folding, line numbers and a gutter on a large payload. Keeping both would leave two highlighters for one language, differing in appearance and drifting apart, which is exactly what the issue flagged. Nothing else asks forjson, so the loader entry is now dead code and is removed;CodeHighlightkeepsmarkup/css/yaml/markdownand its tests were re-pointed atyaml.Two cases deliberately stay on the plain renderer:
wrap={false}callers (the server card's fixed-height, single-line, ellipsis-clipped box), as the issue requires.looksLikeJsontests the first character, which is enough to decide whether to try pretty-printing — a failed parse falls back and nothing is lost. It is not enough to decide to open an editor: a note beginning{would be presented as a document, in a JSON gutter, with the server's prose framed as malformed. A newisJsonDocumentguards that path by actually parsing. A declaredapplication/jsonis different — there the server said what it sent — so its broken payload does render as JSON.The copy overlay is unchanged:
ContentVieweroverlays it above whichever renderer it picked, socopyablekeeps working across the swap.Things that needed care
undefined, which is what the plain Replay button passes to mean "unedited". A newReplayParamsOverride(Record | null | undefined) makes the three states distinct; collapsingnullintoundefinedwould make an emptied editor silently re-send the original params.invalidFields. Field names come straight out of a server's schema, so any sentinel name the form invented for the raw editor could collide with a real argument and clear a block the user cannot see.container.textContentnow go through the editor instance (getAceText), andtest/aceEditor.tsgains*ByLabelvariants for a screen holding more than one editor.userEvent.typereaches its offscreen textarea and produces no edit at all. Unit tests drive the editor through its API; real keyboard behaviour stays in the Storybook play functions, which run in Chromium. Two former keystroke-by-keystroke unit tests were rewritten to assert the same mechanism (each settled draft displayed verbatim) step by step.Testing
JsonEditor,EditReplayButton,EditReplayModal(tests + stories),SchemaForm raw JSON (#2151),ProtocolEntry > edit and replay (#2151),isJsonDocument, and twouseExportActionsoverride cases.npm run cipasses locally: 6,934 tests, per-file coverage gate clean, build gate, bundle-externals, smokes, Storybook.clients/web/README.md's "Code editing" section is rewritten as "JSON editing and display (JsonEditor)", with the contract table and the five load-bearing integration details.Screenshots
Driven headlessly against a composable test server whose
echotool declares anarray argument and a property-less object argument — both of which fall through
to the JSON editor.
A tool's JSON argument
Before, the array default sits in a bare
JsonInput; after, it has linenumbers, highlighting and fold widgets, and the form carries the Edit as
JSON switch. (The
Optionsfield below it is the property-less object,opening empty.)
Edit as JSON
The switch replaces the widgets with one editor over the whole arguments
object, seeded from what the form holds.
A tool result
A Protocol entry's payloads
ContentViewer's JSON branch, read-only. Note the before column renders thepayload as unhighlighted preformatted text — the copy button is in the same
place in both.
Edit and replay
New. Seeded from the entry, Send disabled while the draft does not parse.