Copilot's eighth pass, three comments, all real.
- The precision check now reads the **source text**. Inferring the literal from
`String(value)` was wrong in both directions: it accepted every full-form
integer at or above 1e21 (`1000000000000000000001` parses to `1e21` and is
written back as `1e+21`), and refused `2^54`, which is written out in full
and exactly representable. `hasImpreciseIntegerLiteral` scans the draft for
numeric tokens — skipping strings, so a digit-string value is untouched — and
compares `BigInt(literal)` against `BigInt(Number(literal))`, which is exact.
- `""` is now reported as a surviving cursor for `tools/list` only. It builds
its params with `cursor !== undefined` and carries the empty string
deliberately; the other four list adapters use a truthiness check and drop
it, so the editor showed `{"cursor":""}` while `{}` went out. The asymmetry
looks like a latent bug in those four, but this function's job is to describe
what the dispatch does.
- `callTool` runs every string-valued argument through `convertToolParameters`,
so `{"count":"2"}` against a numeric schema is sent as `{"count":2}`.
`reshapedReplayParam` now takes the tool and detects that by running the same
conversion and comparing, rather than restating its rules — the two cannot
drift. `tools` is threaded to `ProtocolEntry` for it, optional throughout: an
unknown tool skips the check rather than blocking a replay.
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>
Copilot's seventh pass, three comments, all the same class: the editor showing
one value while the wire carries another.
- `JSON.parse` rounds a whole number past 2^53−1 to the nearest double, so a
draft reading `{"id":9007199254740993}` submits `…992`. New
`hasRoundedInteger`, applied by all four JSON-draft parsers — the raw
arguments editor, `SchemaJsonField`, the Edit-and-replay modal, and
`parseJsonObjectDraft`, which Copilot did not flag but shares the defect.
It is narrower than "unsafe integer": it also requires the number's shortest
form to be written out in full, because JS stringifies from 1e21 upward in
exponent form and such a value was typed that way — `{"n":1e308}` is a
legitimate `_meta` value that `parseJsonObjectDraft`'s tests already pin as
accepted. It still refuses the rare full-form integer that happens to be
exactly representable, erring toward refusing a sendable value rather than
misreporting an unsendable one — the trade `toNumericValue` already makes.
- `getPrompt` runs `convertPromptArguments`, which JSON-stringifies every
non-string argument, so `{"count":2}` leaves as `{"count":"2"}` while the
editor still shows a number. `reshapedReplayParam` now rejects a non-string
prompt argument. Not a workaround: the spec types
`GetPromptRequest.params.arguments` as `Record<string, string>`, so a string
is the only thing a prompt argument can be. `tools/call` is untouched —
`callTool` sends its arguments as given.
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>
Copilot's sixth pass generated no new inline comments; both suppressed ones
were right.
- `arguments` surviving the key check is not the same as being sent as
written. `replayProtocolRequest` applies `arguments ?? {}`, so a `null` shown
in the editor reaches the wire as `{}`; an array or scalar is not nullish and
rides the cast into a call whose type says it is a named-argument record.
`reshapedReplayParam` rejects both before Send. `name` and `uri` are
deliberately not checked there — the dispatch already refuses those with a
reason the caller shows as a toast, so they fail visibly rather than
silently.
- `NetworkEntry` never forwarded the body's content type, so the Network tab
could not reach the declared-JSON branch at all and a malformed
`application/json` response read as plain text with no sign it was meant to
be JSON. Forwarded — but only when the declared type *is* JSON: passing it
wholesale would also route an HTML body into the sandboxed frame and a CSV
one into a table, which is a change to what the Network tab is and not one
this makes.
The `NetworkEntry > Auth Success` play function moved off `textContent` for
body assertions. Ace repaints its rows on an animation frame and virtualizes
them, so reading the DOM straight after Reveal returned the previous rows
under load — it passed in isolation and failed in the full run. It now reads
the editors' documents alongside the DOM text, and waits.
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>
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>
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>
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>
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>
- 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>
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>
Step 5 of the phase-2 App.tsx decomposition (#2129, under #2126): the two
sandbox bridge factories, the renderer handle, and the app-rendered
elicitation controller move out of App.tsx into a hook of their own.
Nearly a leaf — it reads the active client and the resource listing and
calls nothing back — so `getListedResourceMeta`, `publishDocument` and
`listedResourcesRef` become internal to the hook rather than App.tsx
locals. The hook is called where `resources` is derived, which is what
lets it own the latest-listing ref write too.
`sandboxUrl` and `initialConfigSettledRef` deliberately stay in App.tsx:
whether the sandbox exists decides whether the client advertises the
nested MCP Apps `elicitation` capability, and that is a
connection-lifecycle question rather than an Apps one.
Inert by construction: no behavior change, and the extraction is covered
100% on all four dimensions plus the existing `smoke:web:app` and
`smoke:web:elicit`, in both Chromium and Firefox.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013hzwzS8UvBsr4yZm7o7sev
Signed-off-by: cliffhall <cliff@futurescale.com>
Three findings, all valid.
**The read/get stages asserted an RPC status, not a render.** #2148 asks
for the resource content and the prompt messages to *render*, and
`data-read-status` / `data-get-status` flip the moment the call resolves
— so the smoke stayed green with the panel deleted. `ResourcePreviewPanel`
and `PromptMessagesDisplay` now carry `data-testid="resource-preview"` /
`"prompt-messages"`, and each stage waits on the status **and** the
rendered panel. Verified by breaking each testid: the smoke fails with
`reading `events` did not render a preview — last observed:
data-read-status="ok"`, which is the finding stated back exactly.
**`smoke:web:tabs` followed ambient `SMOKE_BROWSER`**, so
`SMOKE_BROWSER=firefox npm run ci` would have run the supposedly
Chromium-only smoke under Firefox — contradicting both this PR's stated
scope and the gated-tier invariant in `run-engine-smokes.test.mjs`. The
npm script now pins `chromium` on **both** halves (install and launch)
and the smoke resolves through `resolveRequestedBrowser(argv, env)`, where
an explicit argument wins over the environment; a new case in that same
test file pins it, including that the smoke stays out of `ENGINE_SMOKES`.
**The AGENTS rules were not mirrored into `.github/copilot-instructions.md`.**
Correct, and worse than an omission: that file's engine bullet asserts the
headless web smokes are "engine-parameterized, not Chromium-only", which
this PR made false. It now names `smoke:web:tabs` as the deliberate
exception, and carries the genuinely review-citable new rule — assert
`data-*`, never copy, and never stop at an RPC status where the ask was
that something 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>
- Publish `setupClientForServerRef` from a layout effect rather than during
render. React runs every layout effect before any passive effect of the same
commit, and the /oauth/callback effect is passive, so the seam is still
published before its first read — without the render-phase ref mutation the
compiler rule rejects. This was the one `react-hooks/refs` finding the PR
introduced, so its suppression is gone rather than deferred to #2161 under a
justification that did not describe it.
- Narrow the hook's `fetchLogRef` option to `FetchRequestSource`, the
`getFetchRequests` slice it actually consumes, mirroring
`InspectorClientEventSource` in useProgressToasts. That removes the test
harness's `as unknown as` cast, and the narrowed contract is real: it
immediately rejected the loosely-typed fixture, which is now a properly typed
`FetchRequestEntry`.
- Make the test harness hold both refs across renders and publish them from a
layout effect, as App.tsx does. The deferred-publication test previously
passed because `Probe` built a fresh ref each render and the rerender also
changed `servers`; it now asserts what the guard is actually for — a run that
cannot exchange must not spend the single-use code — and re-triggers on the
`servers` change that carries it in production.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013hzwzS8UvBsr4yZm7o7sev
Signed-off-by: cliffhall <cliff@futurescale.com>
The web client had exactly one connected-flow smoke and it was the Apps
tab. `smoke:web:browser` proves the bundle boots and paints but never
connects; `smoke:web:app` / `smoke:web:elicit` connect but only drive the
Apps surface. So the three highest-traffic tabs were unexercised through
the prod bundle end to end — everything covering them (unit tests,
Storybook, the integration project) mocks or bypasses the prod server, so
a regression living only in the built bundle or in the prod Hono
backend's wiring of a tab passed all of them.
`scripts/smoke-web-tabs.mjs` drives all three against one server on one
browser launch (~5s added wall-clock): Tools runs `list_items` and
asserts its `structuredContent` section rendered (#1908), Resources reads
`events` with the template list asserted separately — `resources/list`
and `resources/templates/list` are different calls and either can fail
alone — and Prompts fetches the argument-less `simple_prompt`.
Every assertion is on a `data-*` attribute, never on copy, mirroring the
`data-app-status` precedent: each screen now carries a readiness contract
(`data-tool-count` / `data-call-status` and its Resources / Prompts
equivalents), tabulated in the web README and pinned by screen unit tests
so a rename fails loudly there rather than as an opaque timeout here.
The deep-link connect step is extracted to `lib/deep-link-connect.mjs`
rather than copied a third time. That matters because `parseDeepLink`
ignores a link it cannot validate rather than reporting one, so a drifted
copy fails as an opaque connect timeout in whichever smoke was not
updated. `smoke:web:elicit`'s hand-rolled copy is retired onto it.
Chromium-only and deliberately not in `ENGINE_SMOKES`: the engine tiers
exist for the MCP Apps sandbox's genuinely divergent primitives, and
these tabs are ordinary React and Mantine. Network, Protocol, Logs and
Tasks remain uncovered — a stated gap, not an oversight.
Closes#2148
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>
Phase 2 step 2 of the App.tsx decomposition (#2129, under #2126). App.tsx goes
from 4,267 to 3,178 lines; no behaviour change.
The new hook owns the step-up slot and its retry operation, the re-auth banner,
the deferred background-tab recovery, the redirect plumbing (webOAuthStorage,
sessionStorageAdapter, onBeforeOAuthRedirect, prepareOAuthRedirect), the
command-scoped recovery wrappers, the four OAuth effects, the abandoned-redirect
check, the /oauth/callback completion, the Connection Info OAuth details, and
the clear/finalize paths. All five once-only latches move with it unchanged.
Three seams could not be a straight move:
- useSessionRef no longer takes pendingStepUp/pendingReauth. They are this
hook's own state, so App.tsx would have had to hold them purely to hand them
back; instead the owner mirrors them into the ref in an effect of its own,
following the shape activeServerName already had.
- setupClientForServer is injected through a ref, because it reads
onBeforeOAuthRedirect and sessionStorageAdapter out of this hook and so
cannot be declared before it. The callback effect reads the ref before
burning its one-shot latch.
- The two clear-OAuth modal adapters and onReauthenticateFromBanner stay in
App.tsx: they bind App-owned state, and the latter drives a reconnect, which
step 3 owns.
Dropping ~1,100 lines took App.tsx under the React Compiler's bail-out, so the
compiler-backed react-hooks rules now analyse it and report nine pre-existing
findings that were previously invisible. Each is suppressed with a narrow
justified disable naming #2161, which is filed to resolve them; the one new
finding (exhaustive-deps on setReAuthBanner) is fixed rather than suppressed.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013hzwzS8UvBsr4yZm7o7sev
Signed-off-by: cliffhall <cliff@futurescale.com>
Addresses Copilot's review on #2152. The client stand-in was
`({ tag }) as unknown as InspectorClient` — a double cast with no
justification, which AGENTS.md rejects.
`Object.create(InspectorClient.prototype)` gives a real prototype chain
without running the constructor, so it needs only a single cast and still
drags in no transport or environment. The tag went with it: every
assertion compares identity, and `Object.create` already returns a
distinct object per call.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MZdpUwhHf943FoprcLVzEm
Signed-off-by: cliffhall <cliff@futurescale.com>
Step 1 of phase 2 of the App.tsx decomposition, landed first and alone
because it is the seam the remaining clusters are extracted onto.
App.tsx kept six "latest value" refs so long-lived callbacks could read a
current value without listing it as a dependency and being re-created on
every change: `activeServerNameRef`, `activeServerIdRef`, `serversRef`,
`inspectorClientRef`, `pendingStepUpRef` and `pendingReauthRef`, plus the
three passive effects that synced them. `useSessionRef` replaces all nine
with one hook returning one stable ref onto a `SessionSnapshot`.
This is not a store: nothing renders off it, nothing subscribes to it, and
mutating it never schedules a render. It is not the prop-to-state sync
AGENTS.md forbids either — that rule is about `setState` in an effect, and
a ref write produces no render at all. It is exactly the pattern the file
already used, written once instead of six times.
What it buys the later steps is a *stable* dependency: a hook taking
`sessionRef` does not drag the other clusters into its argument list, so
`useOAuthRecovery`, `useConnectionLifecycle` and `useServerCommands` can
be extracted one PR at a time.
Behavior is unchanged:
- The snapshot is seeded from the first render's values, matching what
each `useRef(value)` seeded.
- `activeServerName` keeps its one non-uniform rule — it holds the last
server that *was* active and is never cleared, because a transport
crash clears `activeServerId` before the failure toast is raised. It is
derived from `servers`/`activeServerId` rather than taking the
`activeServer` memo, which is the same value by definition and is what
lets the hook be called ahead of that memo.
- The two pending-* syncs already ran ahead of the only effect that reads
them, so merging them changes no effect's view. The other four synced
below every effect that reads them, so hoisting that sync changes none
either.
- The sync effect is deliberately un-gated rather than carrying a
dependency list: re-assigning unchanged values costs nothing and
removes a place for a newly-added field to be forgotten.
`sessionRef` is declared ahead of its first reader because a hook return
is not provably stable to `react-hooks/exhaustive-deps` — consumers must
list it, and a later declaration would put every one of them in the
temporal dead zone. Listing a ref that never changes identity re-runs
nothing.
The inline `pendingStepUp` state shape is named `PendingStepUp` and moved
beside `StepUpSource` in `utils/stepUp.ts`, so the snapshot can name it
without importing from the component that owns the state.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MZdpUwhHf943FoprcLVzEm
Signed-off-by: cliffhall <cliff@futurescale.com>
Copilot's second pass accepted every code fix and re-raised one item:
useProgressToasts is the only extracted hook that returns nothing, which
contradicts #2128's "each hook returns one object" criterion, and the
disagreement was recorded nowhere a reader would find it.
Its ask was either a result object or an explicit exception. Taking the
second: the hook subscribes, shows toasts and unsubscribes, and the only
state it owns is toast-id bookkeeping the caller must not see, so `{}`
would advertise an API that is not there. The criterion exists to stop a
hook handing back a long list of loose values — useTabUiState's
twenty-two is the case it was written against, and that one does return
a bundled object.
Noted in the hook's own doc block (so it reads as a decision rather than
an oversight) and on #2128 itself, so the acceptance criteria and the
implementation agree.
Signed-off-by: cliffhall <cliff@futurescale.com>
Seven of the eight comments, plus the three suppressed ones (the same
double-cast class):
- Narrow the toast hooks to `InspectorClientEventSource`, the client's
typed event-listener surface. A bare `EventTarget` cannot satisfy it
(its listener takes an `Event`, the typed one a `TypedEventGeneric`
with `detail`), so both tests now construct a real
`InspectorClientEventTarget` and dispatch through `dispatchTypedEvent`
rather than casting a stand-in. That failed to compile immediately and
caught two bad fixtures: `task()` was a `Pick<Task, "status">` where
the event map wants a full `Task`, and `toolCallTaskUpdated` was
dispatched with no `task` at all.
- Narrow `useExportActions`'s three store parameters to the single
clearing method each handler calls, so the test doubles are
structurally checked instead of cast away.
- Build real `MessageEntry` fixtures (`timestamp` is a `Date`, not `0`),
removing four more double casts.
- Fix a tautological assertion in useTabUiState.test: it compared a
callback to itself, so it held however the identity changed. Every
callback is now captured before the update and compared to those
references.
- Reword `resetTabUiState`'s doc: `exhaustive-deps` cannot infer
stability across a hook boundary, so the dependency IS named — the
stability buys no churn, not omission.
- Justify the one remaining double cast in place, per the type-safety
rule.
`useProgressToasts` still returns `void` rather than an object,
deliberately: it has nothing to return, and `{}` would imply an API that
is not there. Noted on the PR as the one deviation from #2128's
"returns one object" criterion.
Signed-off-by: cliffhall <cliff@futurescale.com>
Phase 1 of #2126. Six hooks move out of `clients/web/src/App.tsx`, taking
its component body from 4,891 to 4,267 lines with no behavior change:
- useThemeToggle — the light/dark toggle
- useTabUiState — the nine per-screen `ui` objects, the active tab,
the pinned-Protocol ids, and the session reset
- useInspectorStores — the twelve state managers as one create/destroy
lifecycle, their `core/react` hooks, and the
pagination-mode selection
- useExportActions — the eleven Clear / Export / Replay handlers
- useProgressToasts — `notifications/progress` toasts
- useTaskToasts — task-status toasts, the cancel confirmation, the
taskId -> progress map, and the in-flight
tool-call taskId ref
Holding the stores in one slot collapses `setupClientForServer`'s and
`onConfirmRemove`'s dependency arrays from twelve entries to two stable
callbacks; `destroyStores` reads the live set through a ref mirror rather
than the state it also writes.
All six hooks are at 100% on all four coverage dimensions, no `v8 ignore`
needed. Also refreshes the now-stale "~4.5k-line" description of App.tsx
in AGENTS.md and the coverage `include` comment.
Signed-off-by: cliffhall <cliff@futurescale.com>
- Move REPLAYABLE_PROTOCOL_METHODS / isReplayableProtocolMethod out of
components/groups/protocolUtils.ts into utils/replayableProtocolMethods.ts.
It is a pure predicate over a string set, so by the project's own lib-vs-utils
rule it never belonged under components/ — and keeping it there was what made
lib/protocolReplay.ts import 'up' into the component graph. Both consumers now
import from utils/; the predicate keeps its own test.
- Give replayProtocolRequest an exported ReplayClient = Pick<InspectorClient, ...>
naming the nine methods it calls, so a caller (and the test) can satisfy the
real contract instead of casting a stub into a whole client.
- Rebuild the protocolReplay fixtures as plain typed MessageEntry values. The
double cast had been hiding a real defect: MessageEntry.timestamp is a Date
and the fixture passed a number, so the assertion confirmed a shape
messagesToLogEntries cannot produce.
- Replace the window casts in authToken.test.ts with Reflect.set /
Reflect.deleteProperty. No 'as unknown as' remains in either test.
- Drop the stale 'floats top-right' sentence on ReAuthBannerBar, which moved
verbatim and arrived contradicting the paragraph below it.
Declined: re-homing ReAuthBannerBar's transform/zIndex as a theme variant.
'styles' is the Styles API rather than an inline style, Mantine exposes neither
property as a prop, and a styling change is out of scope for a phase whose
safety argument is that nothing was edited.
Signed-off-by: cliffhall <cliff@futurescale.com>
Phase 0 of #2126. Lines 239-668 of clients/web/src/App.tsx were already at
module scope — helpers, formatters, constants, and the toast subcomponents,
none of which close over component state — so the whole block moves verbatim
into files. App.tsx drops from 5,303 to 4,892 lines with no behavior change.
Placement follows the lib-vs-utils rule in AGENTS.md: pure computation to
utils/, anything touching the environment to lib/.
EMPTY_SETTINGS and the two loose toast ids deliberately do NOT land in a new
top-level src/constants.ts, which the issue suggested: the web coverage
include is a whitelist naming components/hooks/theme/lib/utils/server, so a
module there would have fallen out of the >=90 gate silently. They go under
utils/ instead.
ReAuthBannerBar is in the block but not in the issue's table — it was added
after the issue was written (#2108). It moves to components/groups/ReAuthBanner/,
beside the banner it wraps.
Every new module is at 100% on all four coverage dimensions. Two v8 ignores,
each justified in place: getAuthToken's typeof-window guard (a happy-dom
inherent path) and replayProtocolRequest's provably-dead default case.
Signed-off-by: cliffhall <cliff@futurescale.com>
Making every pinned field read-only left an optional one unreachable: the
schema is equally happy with or without it, but the form displayed a value the
user could neither send nor edit. An optional `const` is a yes/no rather than
a fixed answer, so it renders as its single choice, clearable — opting in sends
the schema's own typed value, opting out leaves the property absent. A required
one is still displayed read-only, since there is nothing to decide.
Signed-off-by: cliffhall <cliff@futurescale.com>
- `const` constrains a property that is PRESENT; it neither requires the
property nor acts as a default. It is now supplied automatically only where
the schema also requires the field — in `collectSchemaDefaults`,
`applySchemaConstants` and the TUI's initial values alike — so an optional
`dryRun: { const: true }` no longer turns a valid `{}` call into one that
asks the server to do something. A value that IS present is still corrected
to the constant, whatever supplied it, and the field stays read-only.
- Offerability is judged on the base+member merge rather than the raw member,
so `anyOf: [{ required: ["email"] }, { required: ["phone"] }]` over
root-declared properties is offered. It was declined, which left the gate
checking the base alone and accepting `{}` — which that schema rejects.
- The TUI's branch sections keep the schema's own `required` for the seeding
decision while still rendering every branch field non-required, which is what
keeps a static form submittable.
Signed-off-by: cliffhall <cliff@futurescale.com>
A schema may require a property it never declares: `{ type: "object",
required: ["token"] }` is legal and the tool plainly takes an argument.
declaresAnyFields counted only `properties`, so an App tool shaped that way —
including nested under a composition — was reported input-free and
auto-invoked with `{}`.
Signed-off-by: cliffhall <cliff@futurescale.com>
- branchesKey is built from the resolved alternatives themselves, not from
labels and field names: two branches can share both while pinning different
discriminators or typing a field differently, and a reorder of those went
unnoticed — the form then displayed one branch and submitted another.
- InspectorView gains a deep-link test with root-union appArgs: they name the
second branch, and the assertions pin the picker, the read-only
discriminator, THAT branch's default (not the first branch's), and the
absence of the other branch's field. The old shallow spread fails it.
Signed-off-by: cliffhall <cliff@futurescale.com>
The cross-branch agreement check ignored `const`, so two branches pinned to
`1` and `"1"` both stated no `type`, both matched the text `1`, and
the first one's typed constant was sent. Textually indistinguishable constants
of different types are ambiguous, and the raw string the user typed is the
honest answer.
Signed-off-by: cliffhall <cliff@futurescale.com>
- The seeding effect re-applies constants as well as filling absences: an
in-place schema change can move a `const` the user cannot edit, and the
read-only field then displayed the new value while `values` still held the
old one — which is what would be submitted. Ordinary defaults are still only
ever added, so an edited field keeps what the user put there.
- hasMissingIn checks own-property presence: a required argument legally named
`constructor` resolved to the inherited one and read as supplied, enabling
a submit the schema rejects.
- The TUI keeps the declared name as a branch field's fallback title. The
prefix is an internal field NAME, and buildFields labels from its map key —
so the showcase displayed `__b0__address` where the schema says
`address`.
Signed-off-by: cliffhall <cliff@futurescale.com>
`branchIndex` is a position, and a tool refreshed in place keeps its
`resetKey` — so a union reordered or rewritten with the same number of
branches left the index pointing at a different shape than the one the values
describe: the picker showed SMS while Execute submitted the email arguments.
The selection is now re-derived from the values whenever the alternatives
themselves change, keyed by their labels and declared fields.
Signed-off-by: cliffhall <cliff@futurescale.com>
- The seeding effect is keyed by the fixed values themselves as well as the
entity and branch. A tool refreshed in place keeps its `resetKey` and its
branch while its schema changes underneath, so a newly pinned field was
rendered read-only and never seeded — leaving a required one permanently
unsubmittable. The schema object's identity says nothing (callers rebuild it
every render), which is why the key is derived from what has to be seeded.
- branchLabel stringifies every primitive constant. `hasDiscriminator`
accepts distinct booleans and nulls, so a `true`/`false` union was
discriminated perfectly well and still labelled "Option 1"/"Option 2".
Signed-off-by: cliffhall <cliff@futurescale.com>
- A `const` may be an object or an array, with or without a `type`, and
`String({...})` is "[object Object]" — which no CLI argument can equal, so
the only value such a schema accepts never matched. Supplied text is parsed
and compared structurally through the resolver's own comparison (exported as
`sameJsonValue` so the two cannot disagree), for both branch selection and
the substitution.
- The TUI's one-option const control is never marked required: its option may
legitimately be the empty string, which ink-form's required gate can never
accept, so submission would not even reach `decodeFormValues`. The value is
fixed by the schema, and `missingRequiredFields` still validates the call.
Signed-off-by: cliffhall <cliff@futurescale.com>
The carry filter dropped a name whose value was `undefined`, but clearing a
number or JSON field leaves exactly that: the name present with no value, which
is the user's answer rather than an absence. The incoming branch's defaults
then put the field's default back and undid the clear. Own-property presence
alone is now the test.
Signed-off-by: cliffhall <cliff@futurescale.com>
- coercionProperties collapses each declaration before comparing types. A
nullable schema written as an `anyOf` states no top-level `type`, so
`number | null` and `boolean | null` both read as "no type" and counted
as agreeing — and `value=true` then came back as `NaN` through the first
branch's number.
- admitsNull reads an `enum` offering null, under the same sibling conditions
a `const: null` answers to. Without it `{ enum: [null] }` reported that it
rejects null, and a required read-only field spelled that way stayed
permanently invalid.
Signed-off-by: cliffhall <cliff@futurescale.com>
- convertToolParameters defines its result keys rather than assigning them, so
a discriminator legally named `__proto__` reaches the call instead of the
prototype setter. It also collapses a nullable declaration before dispatching,
so `type: ["number","null"]` coerces as a number rather than being sent
as text.
- typeNameOf sorts an array `type`: JSON Schema reads it as a SET, so
`["number","null"]` and `["null","number"]` are one declaration
and must not read as branches disagreeing.
- nullableUnion's non-asserting keyword set covers `$defs`, `$id`,
`$schema`, `$anchor`, `$dynamicAnchor` and `$vocabulary` — they
identify or declare rather than assert. `$ref` stays out: it applies
whatever it points at.
Signed-off-by: cliffhall <cliff@futurescale.com>
Rendering every `const` read-only assumed the caller had already seeded it,
which SchemaForm never did itself: `InlineElicitationRequest` passes its
values straight through and gates submit on hasMissingRequiredFields, so a
required `const` — a root union's discriminator among them — was displayed
and never supplied, leaving Submit disabled with no way to fix it.
The form now reports the fixed values upward once per entity, in an effect (the
parent's `onChange` cannot be called during our render), adding only names
absent from `values` — so a caller that already seeds sees no call at all and
the report cannot loop.
Signed-off-by: cliffhall <cliff@futurescale.com>
One conflict, in SchemaForm's `resetKey` reset: #2138 added the enlarge-caret
Map to clear there while this branch added the union branch selection. Both
belong in it, so the merged block clears the carets and re-derives the branch
from the incoming values.
Signed-off-by: cliffhall <cliff@futurescale.com>
Two more from review, both about field data the schema controls.
The caret record was a bare object keyed by field name, and field names
come from a server's schema. A field legitimately named `constructor` or
`toString` read back an inherited function rather than undefined, so the
documented end-of-value fallback never fired and that value reached
setSelectionRange. Use a Map, which has no such keys.
JSON Schema counts maxLength in Unicode code points; String.length counts
UTF-16 code units. A field holding one emoji measured 2, so a maxLength:2
field with room for another character was treated as full and refused its
newline. Count code points. The maxLength handed to the DOM input still
carries the HTML attribute's own UTF-16 counting; that predates this and
is left alone.
Signed-off-by: cliffhall <cliff@futurescale.com>
`anyOfAdmitsNull` sent every non-object member to `toBranch` and refused it,
so `{ const: null, anyOf: [true] }` read as non-nullable — a required field
seeded and rendered read-only as `null` and reported missing forever. `true`
is the unconstrained schema and admits every value, `null` among them;
`false` admits none and still does not.
Signed-off-by: cliffhall <cliff@futurescale.com>
- anyOfAdmitsNull recognizes a branch that constrains nothing (`{}`, or
annotations only): it admits every value, null among them, so a field pinned
to `const: null` beside such a union stays submittable. `admitsNull`
cannot say that on its own — an unconstrained schema tells it nothing either
way and it declines rather than guesses — but here the question is narrower.
- convertToolParameters sends a matched `const` as the schema's own typed
value: `kind=2` selects a branch pinned to `const: 2` and was then sent
`"2"`, which that same branch rejects. Only an exact match is
substituted; anything else is the user's input and is left alone.
Signed-off-by: cliffhall <cliff@futurescale.com>
Two faults in the Enter path, both found in review.
The split read `values[fieldName] as string`, a cast over a
Record<string, unknown> fed by whatever a server's schema declares. A
string field holding a non-string default renders as text and then threw
on `.slice`, so one keystroke crashed the panel. Read the control's own
value instead: always a string, and the exact string the selection
offsets index into.
The caret was recorded only when the newline fit, so pressing Enter mid
value in a field at its maxLength enlarged without one and threw the
caret to the end — a second surprise on top of the newline that did not
arrive. Record it on both paths, `start + 1` after an insertion and
`start` otherwise.
Signed-off-by: cliffhall <cliff@futurescale.com>
JSON Schema's boolean form is legal, but only `true` is harmless: it
constrains nothing, while `false` admits no value at all — so a field
declared with it can never be filled, and a required one makes the whole
branch unsatisfiable. Offering it contradicted every other faithfulness check
in the resolver, so such a branch is declined.
Signed-off-by: cliffhall <cliff@futurescale.com>
isComposing covers browsers that fire compositionend after the
committing keydown. WebKit is reported to fire it before, which leaves
isComposing already false and the CJK user back to a stray newline on
every committed word.
Also reject keyCode 229, the pre-isComposing sentinel for a keydown the
IME consumed, which such an event still carries. Deprecated, and kept
anyway: nothing non-deprecated distinguishes that event, and it cannot
swallow a real Enter, which reports 13 — tested both ways so the
sentinel is provably not eating ordinary keystrokes.
The ordering claim is second-hand; the guard is taken on its own merits,
since its downside is bounded at zero and it covers any browser whose
composition events land too late rather than one vendor.
Signed-off-by: cliffhall <cliff@futurescale.com>
- selectBranchIndex asks the supplied NAMES first and falls back to a lone
agreeing constant only when they settle nothing. A matching `const` does
not identify a branch while another candidate leaves that property unpinned:
`{ kind: "email", phone: "555" }` agrees with an email branch whose
`address` is missing while satisfying a phone branch outright.
- isOfferable requires every property VALUE to be readable — JSON Schema's
boolean form is fine, a `null` or an array is not a schema at all and the
web form dereferences one on the way to choosing a widget. Such a branch is
declined rather than handed on to crash a tool panel that would otherwise
have rendered.
Signed-off-by: cliffhall <cliff@futurescale.com>
The handler carried four guards, a preventDefault and a state
transition inline in the TextInput's props, which is what AGENTS.md
rules out ("NEVER use inline code; instead extract to functions in the
same file"). Name it handleEnlargeKeyDown and put it beside
enlargeWithNewline, where the comment can enumerate why each condition
is there rather than sitting between two JSX props.
No behavior change.
Signed-off-by: cliffhall <cliff@futurescale.com>
A distinct `const` does not make `oneOf` alternatives mutually exclusive
unless the property carrying it is required: two branches pinning an OPTIONAL
`kind` both match `{}`, so arguments omitting it satisfy more than one
alternative — exactly what `oneOf` forbids. The property must now be required
by the root or by every branch.
Also makes `propertiesOf` total (an unreadable `properties` reads as empty,
with a separate predicate for the one caller that must tell them apart), which
removes a row of unreachable `?? {}` fallbacks, and covers the value/type
comparisons the const checks rest on.
Signed-off-by: cliffhall <cliff@futurescale.com>
Enter appended its newline to the end of the value regardless of where
the caret was, so pressing it in the middle of `abc|def` produced
`abcdef` with a trailing blank line rather than splitting the text, and
a selected range was kept rather than replaced. That is a rewritten
value, not just a misplaced caret, and no editor behaves that way.
Split at the field's own selection and replace any selected range, then
mount the text area with the caret just after the inserted newline —
EnlargedStringField takes an explicit caretAt for this. Pointer
activation passes none and keeps falling back to end-of-value, since a
click carries no meaningful position. The maxLength check now prices the
resulting value, so replacing a selection can free the room the newline
needs.
The two maxLength tests were asserting against a stub onChange, which
froze the value and made "it did not change" pass whatever the component
did. They now run on a seeded stateful harness, with a no-maxLength
control so the constraint is shown doing the work.
Signed-off-by: cliffhall <cliff@futurescale.com>
Enter is also how a Japanese, Chinese or Korean input method commits the
candidate being composed. That keystroke means "accept this word", not
"new line", so the shortcut fired on every word such a user finished —
enlarging the field and inserting a stray newline each time.
Guard on event.nativeEvent.isComposing. The test pairs the composing case
with a negative control dispatching the same event with the flag off, so
a test that stopped reaching the handler cannot pass for the wrong reason.
Signed-off-by: cliffhall <cliff@futurescale.com>
- matchesConstants checks own-property presence before reading a supplied
value, so an absent argument legally named `constructor` no longer reads the
inherited one and rules out every branch pinning that name.
- anyOfAdmitsNull asks `admitsNull` about each composition-free branch instead
of testing `type` alone, so a branch spelling its nullability as
`{ const: null }` or `{ nullable: true }` is recognized. Refusing composed
branches is what bounds the recursion.
- Drop a double cast from the malformed-applicator fixture.
Signed-off-by: cliffhall <cliff@futurescale.com>
The App deep link seeded defaults and spread its `appArgs` over them, which
replaces a nested object wholesale: `{ config: { kind: "sms" } }` discarded
the nested SMS branch's own defaults, which the form then displayed while the
submitted arguments omitted them. New seedSchemaValues merges the two per
level, picking each nested branch from the nested values, and the supplied
value still wins wherever the two meet.
Signed-off-by: cliffhall <cliff@futurescale.com>