From fd1530e9d578af001253657e023aedf129592c34 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Fri, 24 Jul 2026 22:44:31 -0400 Subject: [PATCH 01/14] refactor(web): sweep AGENTS.md TypeScript + Mantine rule violations (#1762) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codebase-wide audit sweep against the AGENTS.md TypeScript and Mantine/React conventions. The codebase was already in strong shape (post-#1690/#1756), so the actionable set is small and cohesive: Styling - ServerListScreen: fold `align-items: start` into the existing `styles` prop and drop the `.grid-align-start` CSS class (+ its App.css rule). Structure — extract repeated inline elements to named `.withProps()` constants - NetworkEntry: `DimmedNote` (xs/dimmed) and `SectionLabel` (sm/500) replace 9 repeated inline ``s. - ServerImportConfigModal: `GroupHeading` replaces 3 repeated ``s. - ViewHeader: `HeaderStackCell` replaces the twice-repeated animated Box wrapper (mirrors the file's existing `RightConnectedGroup` className-in-withProps convention). - ResourceControls: `TightRow` replaces two ``. Structure — extract inline logic to named functions - ToolsScreen: `handleSelectTool` replaces the ~20-line inline `onSelectTool`. - ServerConfigModal: `setTextField`/`clearTextField` factories replace 6 repeated text-field onChange/clear closures. TypeScript - App.tsx `messagesToLogEntries`: replace an `as unknown as` double cast with a single narrowing cast on just the `params` value. - createRemoteLogger: the `pino/browser.js` `@ts-expect-error` is genuinely unavoidable (TS resolves the real untyped JS file, so an ambient `declare module` can't type it) — kept, with a comment explaining why. Audit found zero `any` types, zero config-level error suppressions, and every remaining non-test `as unknown as` already justified in-comment. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5 --- clients/web/src/App.css | 6 -- clients/web/src/App.tsx | 6 +- .../groups/NetworkEntry/NetworkEntry.tsx | 51 +++++++-------- .../ResourceControls/ResourceControls.tsx | 12 ++-- .../ServerConfigModal/ServerConfigModal.tsx | 65 +++++++++---------- .../ServerImportConfigModal.tsx | 15 +++-- .../groups/ViewHeader/ViewHeader.tsx | 21 +++--- .../ServerListScreen/ServerListScreen.tsx | 6 +- .../screens/ToolsScreen/ToolsScreen.tsx | 39 +++++------ core/mcp/remote/createRemoteLogger.ts | 6 +- 10 files changed, 112 insertions(+), 115 deletions(-) diff --git a/clients/web/src/App.css b/clients/web/src/App.css index 9b2784312..fabc4182e 100644 --- a/clients/web/src/App.css +++ b/clients/web/src/App.css @@ -528,12 +528,6 @@ body { background-color: var(--mantine-primary-color-light-hover); } -/* ── Grid alignment ─────────────────────────────────────── */ - -.grid-align-start { - align-items: start; -} - /* ── Markdown content (third-party HTML from react-markdown) ───── */ .markdown-content { diff --git a/clients/web/src/App.tsx b/clients/web/src/App.tsx index 8faaebfdd..ebb5f7410 100644 --- a/clients/web/src/App.tsx +++ b/clients/web/src/App.tsx @@ -270,10 +270,12 @@ function messagesToLogEntries(messages: MessageEntry[]): LogEntryData[] { for (const m of messages) { if (m.direction !== "notification") continue; // MessageEntry.message is a JSONRPC union; notifications have `method` - // but not `id`. Narrow with an `in` check before the cast. + // but not `id`. Narrow with an `in` check, then confirm the method. if (!("method" in m.message)) continue; if (m.message.method !== "notifications/message") continue; - const params = (m.message as unknown as LoggingMessageNotification).params; + // The method check pins this to a logging notification; its `params` are + // only generically typed on the JSONRPC union, so cast just that value. + const params = m.message.params as LoggingMessageNotification["params"]; out.push({ receivedAt: m.timestamp, params, diff --git a/clients/web/src/components/groups/NetworkEntry/NetworkEntry.tsx b/clients/web/src/components/groups/NetworkEntry/NetworkEntry.tsx index 6d64ffd90..bef145d25 100644 --- a/clients/web/src/components/groups/NetworkEntry/NetworkEntry.tsx +++ b/clients/web/src/components/groups/NetworkEntry/NetworkEntry.tsx @@ -100,6 +100,19 @@ const DurationText = Text.withProps({ c: "dimmed", }); +// Muted note for empty/placeholder states (no headers, empty body, uncaptured +// stream) and the secrets-hidden status line. +const DimmedNote = Text.withProps({ + size: "xs", + c: "dimmed", +}); + +// Heading above each expanded-detail section (Request/Response Headers/Body). +const SectionLabel = Text.withProps({ + size: "sm", + fw: 500, +}); + // Cap is in JS string `.length` units (UTF-16 code units), not bytes — for // multi-byte content the wire size is larger, but the limit's purpose is // to keep the DOM from drowning in a single Code block so character count @@ -251,11 +264,7 @@ function HeadersTable({ }) { const rows = Object.entries(headers); if (rows.length === 0) { - return ( - - (none) - - ); + return (none); } const byHeader = new Map((consistency ?? []).map((row) => [row.header, row])); return ( @@ -334,9 +343,9 @@ function BodyPreview({ if (tooLarge) { return ( - + Body too large to preview ({body.length} characters) - + ); } @@ -348,9 +357,9 @@ function BodyPreview({ return ( - + {revealed ? "Secrets revealed" : "Secrets hidden"} - + setRevealed((v) => !v)} aria-label={ @@ -514,9 +523,7 @@ export function NetworkEntry({ )} - - Request Headers - + Request Headers {entry.requestBody && ( - - Request Body - + Request Body - - Response Headers - + Response Headers )} {entry.responseStatus !== undefined && ( - - Response Body - + Response Body {entry.responseBody ? ( ) : ( - + {isLongLivedStream(entry) ? "Long-lived stream — body not captured" : "(empty)"} - + )} )} {entry.error && ( - - Error - + Error {entry.error} diff --git a/clients/web/src/components/groups/ResourceControls/ResourceControls.tsx b/clients/web/src/components/groups/ResourceControls/ResourceControls.tsx index 3e63ca4c9..284fbc25d 100644 --- a/clients/web/src/components/groups/ResourceControls/ResourceControls.tsx +++ b/clients/web/src/components/groups/ResourceControls/ResourceControls.tsx @@ -21,6 +21,10 @@ import { ListToggle } from "../../elements/ListToggle/ListToggle"; import { ResourceListItem } from "../ResourceListItem/ResourceListItem"; import { ResourceSubscribedItem } from "../ResourceSubscribedItem/ResourceSubscribedItem"; +// A tight, non-wrapping horizontal row (search field + toggle; count + stream +// badge). +const TightRow = Group.withProps({ gap: "xs", wrap: "nowrap" }); + export interface ResourceControlsProps { resources: Resource[]; templates: ResourceTemplate[]; @@ -209,7 +213,7 @@ export function ResourceControls({ Resources - + - + - + {formatSectionCount( "Subscriptions", @@ -312,7 +316,7 @@ export function ResourceControls({ {streamStatus && ( )} - + diff --git a/clients/web/src/components/groups/ServerConfigModal/ServerConfigModal.tsx b/clients/web/src/components/groups/ServerConfigModal/ServerConfigModal.tsx index a76e55653..afb3466ac 100644 --- a/clients/web/src/components/groups/ServerConfigModal/ServerConfigModal.tsx +++ b/clients/web/src/components/groups/ServerConfigModal/ServerConfigModal.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useState } from "react"; +import { useEffect, useMemo, useState, type ChangeEvent } from "react"; import { Button, Group, @@ -48,6 +48,10 @@ interface FormState { url: string; } +// The string-valued FormState fields (everything but `transport`), which all +// share the same text-input update/clear handlers. +type TextField = "id" | "command" | "argsText" | "envText" | "cwd" | "url"; + const SectionStack = Stack.withProps({ gap: "md" }); const FieldGrid = Stack.withProps({ gap: "sm" }); const Actions = Group.withProps({ justify: "flex-end", gap: "sm", mt: "md" }); @@ -165,6 +169,19 @@ export function ServerConfigModal({ const [submitError, setSubmitError] = useState(undefined); const [submitting, setSubmitting] = useState(false); + // The six text fields all update the same way. Capture the value before the + // functional updater closes over it — React nulls SyntheticEvent.currentTarget + // after the synchronous handler returns, so reading it inside `setForm((f) => + // ...)` would throw "Cannot read properties of null". + const setTextField = + (field: TextField) => + (e: ChangeEvent) => { + const next = e.currentTarget.value; + setForm((f) => ({ ...f, [field]: next })); + }; + const clearTextField = (field: TextField) => () => + setForm((f) => ({ ...f, [field]: "" })); + // Reset form whenever the modal opens with new inputs. useEffect(() => { if (opened) { @@ -268,14 +285,7 @@ export function ServerConfigModal({ description="Used as the key in mcp.json. Letters, numbers, hyphens, underscores." placeholder="my-server" value={form.id} - onChange={(e) => { - // Capture before the functional updater closes over it — React - // nulls SyntheticEvent.currentTarget after the synchronous - // handler returns, so reading inside `setForm((f) => ...)` - // throws "Cannot read properties of null". - const next = e.currentTarget.value; - setForm((f) => ({ ...f, id: next })); - }} + onChange={setTextField("id")} error={idError} data-autofocus required @@ -285,7 +295,7 @@ export function ServerConfigModal({ form.id ? ( setForm((f) => ({ ...f, id: "" }))} + onClick={clearTextField("id")} /> ) : null } @@ -315,10 +325,7 @@ export function ServerConfigModal({ label="Command" placeholder="npx" value={form.command} - onChange={(e) => { - const next = e.currentTarget.value; - setForm((f) => ({ ...f, command: next })); - }} + onChange={setTextField("command")} required disabled={submitting} rightSectionPointerEvents="auto" @@ -326,7 +333,7 @@ export function ServerConfigModal({ form.command ? ( setForm((f) => ({ ...f, command: "" }))} + onClick={clearTextField("command")} /> ) : null } @@ -336,10 +343,7 @@ export function ServerConfigModal({ description="One argument per line." placeholder={"-y\n@modelcontextprotocol/server-everything"} value={form.argsText} - onChange={(e) => { - const next = e.currentTarget.value; - setForm((f) => ({ ...f, argsText: next })); - }} + onChange={setTextField("argsText")} autosize minRows={3} disabled={submitting} @@ -348,7 +352,7 @@ export function ServerConfigModal({ form.argsText ? ( setForm((f) => ({ ...f, argsText: "" }))} + onClick={clearTextField("argsText")} /> ) : null } @@ -358,10 +362,7 @@ export function ServerConfigModal({ description="KEY=VALUE per line." placeholder="DEBUG=1" value={form.envText} - onChange={(e) => { - const next = e.currentTarget.value; - setForm((f) => ({ ...f, envText: next })); - }} + onChange={setTextField("envText")} autosize minRows={2} disabled={submitting} @@ -370,7 +371,7 @@ export function ServerConfigModal({ form.envText ? ( setForm((f) => ({ ...f, envText: "" }))} + onClick={clearTextField("envText")} /> ) : null } @@ -379,17 +380,14 @@ export function ServerConfigModal({ label="Working directory" placeholder="(inherit)" value={form.cwd} - onChange={(e) => { - const next = e.currentTarget.value; - setForm((f) => ({ ...f, cwd: next })); - }} + onChange={setTextField("cwd")} disabled={submitting} rightSectionPointerEvents="auto" rightSection={ form.cwd ? ( setForm((f) => ({ ...f, cwd: "" }))} + onClick={clearTextField("cwd")} /> ) : null } @@ -400,10 +398,7 @@ export function ServerConfigModal({ label="URL" placeholder="https://example.com/mcp" value={form.url} - onChange={(e) => { - const next = e.currentTarget.value; - setForm((f) => ({ ...f, url: next })); - }} + onChange={setTextField("url")} required disabled={submitting} rightSectionPointerEvents="auto" @@ -411,7 +406,7 @@ export function ServerConfigModal({ form.url ? ( setForm((f) => ({ ...f, url: "" }))} + onClick={clearTextField("url")} /> ) : null } diff --git a/clients/web/src/components/groups/ServerImportConfigModal/ServerImportConfigModal.tsx b/clients/web/src/components/groups/ServerImportConfigModal/ServerImportConfigModal.tsx index 000af1a1b..b53e735eb 100644 --- a/clients/web/src/components/groups/ServerImportConfigModal/ServerImportConfigModal.tsx +++ b/clients/web/src/components/groups/ServerImportConfigModal/ServerImportConfigModal.tsx @@ -53,6 +53,9 @@ const RowGroup = Group.withProps({ }); const DimText = Text.withProps({ size: "sm", c: "dimmed" }); const ModalTitle = Text.withProps({ fw: 700, span: true }); +// Heading above a review/summary group (New servers / Already exists / Import +// complete). +const GroupHeading = Text.withProps({ fw: 600, size: "sm" }); /** * Source-picker dropdown options, derived from the strategy registry. A leading @@ -174,9 +177,9 @@ export function ServerImportConfigModal({ {plan.additions.length > 0 ? ( - + New servers ({plan.additions.length}) - + {plan.additions.map((a) => ( {a.id} @@ -199,9 +202,9 @@ export function ServerImportConfigModal({ {plan.conflicts.length > 0 ? ( - + Already exists ({plan.conflicts.length}) - + {plan.conflicts.map((conflict) => { const res = vm.resolutions[conflict.id]; return ( @@ -258,9 +261,7 @@ export function ServerImportConfigModal({ {vm.phase === "summary" ? ( - - Import complete - + Import complete {vm.outcomes.map((o) => { const meta = OUTCOME_META[o.status]; return ( diff --git a/clients/web/src/components/groups/ViewHeader/ViewHeader.tsx b/clients/web/src/components/groups/ViewHeader/ViewHeader.tsx index 5fb985568..145c31b66 100644 --- a/clients/web/src/components/groups/ViewHeader/ViewHeader.tsx +++ b/clients/web/src/components/groups/ViewHeader/ViewHeader.tsx @@ -177,6 +177,13 @@ const RightConnectedGroup = Group.withProps({ wrap: "nowrap", }); +// Center crossfade cell: the tab bar and the "MCP Inspector" title share one +// grid cell (`header-stack-cell`) and fade/slide (`header-anim`) so one replaces +// the other in place. The `data-anim` direction is set per render. +const HeaderStackCell = Group.withProps({ + className: "header-anim header-stack-cell", +}); + const DisconnectIcon = ActionIcon.withProps({ variant: "subtle", size: 36, @@ -346,17 +353,14 @@ export function ViewHeader(props: ViewHeaderProps) { > {() => headerData ? ( - + - + ) : ( // Unreachable in practice — the Transition only mounts while // connected, by which point the snapshot is set — but the render @@ -373,12 +377,9 @@ export function ViewHeader(props: ViewHeaderProps) { exitDuration={HEADER_ANIM_MS} > {() => ( - + MCP Inspector - + )} diff --git a/clients/web/src/components/screens/ServerListScreen/ServerListScreen.tsx b/clients/web/src/components/screens/ServerListScreen/ServerListScreen.tsx index 43772d920..33c9b5a12 100644 --- a/clients/web/src/components/screens/ServerListScreen/ServerListScreen.tsx +++ b/clients/web/src/components/screens/ServerListScreen/ServerListScreen.tsx @@ -134,10 +134,13 @@ const EmptyState = Text.withProps({ // Override the card-surface var for the whole grid so every server card (in any // state — the theme's default/highlighted/errored/disabled Card variants all // read `--inspector-surface-card`) picks up the faintly-grey server tone, -// without touching the shared token or the variant logic. +// without touching the shared token or the variant logic. `alignItems: start` +// keeps rows top-aligned so a taller card (e.g. one whose action row wrapped) +// doesn't stretch its shorter row-mates. const GRID_SURFACE_STYLES = { root: { "--inspector-surface-card": "var(--inspector-surface-card-server)", + alignItems: "start", }, } as const; @@ -230,7 +233,6 @@ export function ServerListScreen({ type="container" cols={{ base: 1, "1040px": 2, "1560px": 3 }} spacing="lg" - className="grid-align-start" styles={GRID_SURFACE_STYLES} > {servers.map((server) => diff --git a/clients/web/src/components/screens/ToolsScreen/ToolsScreen.tsx b/clients/web/src/components/screens/ToolsScreen/ToolsScreen.tsx index 7309df33a..51f55c9a4 100644 --- a/clients/web/src/components/screens/ToolsScreen/ToolsScreen.tsx +++ b/clients/web/src/components/screens/ToolsScreen/ToolsScreen.tsx @@ -164,6 +164,23 @@ export function ToolsScreen({ : undefined; const isExecuting = callState?.status === "pending"; + const handleSelectTool = (name: string) => { + // Seed the form with the tool's schema defaults so default-only fields the + // user never edits are still sent on execute (the form shows defaults via + // resolveValue, but onChange only writes edited fields). + const tool = tools.find((t) => t.name === name); + // `name` always comes from the rendered tools list (ToolControls only emits + // names it was given), so the lookup never misses; the empty-object fallback + // is an unreachable defensive default. + let nextFormValues: Record = {}; + /* v8 ignore next -- unreachable: onSelectTool always names a tool in the list */ + if (tool) + nextFormValues = collectSchemaDefaults( + toFormSchema(tool.inputSchema) ?? {}, + ); + onUiChange({ ...ui, selectedToolName: name, formValues: nextFormValues }); + }; + return ( @@ -177,27 +194,7 @@ export function ToolsScreen({ onRefreshList={onRefreshList} pagination={pagination} onSearchChange={(value) => onUiChange({ ...ui, search: value })} - onSelectTool={(name) => { - // Seed the form with the tool's schema defaults so default-only - // fields the user never edits are still sent on execute (the - // form shows defaults via resolveValue, but onChange only writes - // edited fields). - const tool = tools.find((t) => t.name === name); - // `name` always comes from the rendered tools list (ToolControls - // only emits names it was given), so the lookup never misses; the - // empty-object fallback is an unreachable defensive default. - let formValues: Record = {}; - /* v8 ignore next -- unreachable: onSelectTool always names a tool in the list */ - if (tool) - formValues = collectSchemaDefaults( - toFormSchema(tool.inputSchema) ?? {}, - ); - onUiChange({ - ...ui, - selectedToolName: name, - formValues, - }); - }} + onSelectTool={handleSelectTool} /> diff --git a/core/mcp/remote/createRemoteLogger.ts b/core/mcp/remote/createRemoteLogger.ts index e4aae396e..bc7e11a25 100644 --- a/core/mcp/remote/createRemoteLogger.ts +++ b/core/mcp/remote/createRemoteLogger.ts @@ -6,8 +6,10 @@ * Uses pino/browser so transmit works in both Node (tests) and browser. */ -// @ts-expect-error - pino/browser.js exists but TypeScript doesn't have types for the .js extension -// Node.js ESM requires explicit .js extension, and pino exports browser.js +// pino ships a browser build at `pino/browser.js` but no `.d.ts` for it, and TS +// resolves the real JS file (so an ambient `declare module` can't type it) — +// suppress the resulting implicit-any. Node ESM requires the explicit `.js`. +// @ts-expect-error - pino/browser.js exists but has no type declarations import pino from "pino/browser.js"; import type { Logger, LogEvent } from "pino"; From 4e5e022fa995a504fb9d2c78285efde6b393e451 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Fri, 24 Jul 2026 23:02:37 -0400 Subject: [PATCH 02/14] refactor(web): derive TextField from FormState (review nit, #1762) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per review of PR #1771: replace the hand-written `TextField` union in ServerConfigModal with `Exclude`, so it stays in sync with FormState automatically — a new string field is picked up, and a non-string field surfaces as a type error at the setter factory instead of silently drifting from the list. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5 --- .../groups/ServerConfigModal/ServerConfigModal.tsx | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/clients/web/src/components/groups/ServerConfigModal/ServerConfigModal.tsx b/clients/web/src/components/groups/ServerConfigModal/ServerConfigModal.tsx index afb3466ac..067dcc19d 100644 --- a/clients/web/src/components/groups/ServerConfigModal/ServerConfigModal.tsx +++ b/clients/web/src/components/groups/ServerConfigModal/ServerConfigModal.tsx @@ -49,8 +49,11 @@ interface FormState { } // The string-valued FormState fields (everything but `transport`), which all -// share the same text-input update/clear handlers. -type TextField = "id" | "command" | "argsText" | "envText" | "cwd" | "url"; +// share the same text-input update/clear handlers. Derived from FormState so it +// stays in sync automatically — adding a string field picks it up, and adding a +// non-string field would surface as a type error at the factory instead of +// silently drifting. +type TextField = Exclude; const SectionStack = Stack.withProps({ gap: "md" }); const FieldGrid = Stack.withProps({ gap: "sm" }); From 6a7b85d1d00296ba2b0a89d21939bdac8febf314 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Fri, 24 Jul 2026 23:08:06 -0400 Subject: [PATCH 03/14] refactor(web): make TextField enforce string-valued keys (review, #1762) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per re-review of PR #1771: the `Exclude` comment overclaimed — a computed union key widens to an index signature, so a future non-string field would NOT error at the setter factory. Replace with a mapped type that selects keys by value (`PlainStringKeys`), which actually makes passing a non-string key a compile error at the call site and drops the hand-written "transport" exclusion. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5 --- .../ServerConfigModal/ServerConfigModal.tsx | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/clients/web/src/components/groups/ServerConfigModal/ServerConfigModal.tsx b/clients/web/src/components/groups/ServerConfigModal/ServerConfigModal.tsx index 067dcc19d..3b0610269 100644 --- a/clients/web/src/components/groups/ServerConfigModal/ServerConfigModal.tsx +++ b/clients/web/src/components/groups/ServerConfigModal/ServerConfigModal.tsx @@ -48,12 +48,16 @@ interface FormState { url: string; } -// The string-valued FormState fields (everything but `transport`), which all -// share the same text-input update/clear handlers. Derived from FormState so it -// stays in sync automatically — adding a string field picks it up, and adding a -// non-string field would surface as a type error at the factory instead of -// silently drifting. -type TextField = Exclude; +// The `string`-valued FormState keys, which all share the same text-input +// update/clear handlers. Selecting by value type (not by excluding `transport` +// by name) keeps this in sync with FormState automatically: a new string field +// is picked up, and a non-string field is excluded — so passing its key to the +// handler factories is a compile error at the call site rather than a silent +// string-into-non-string write. +type PlainStringKeys = { + [K in keyof T]-?: string extends T[K] ? K : never; +}[keyof T]; +type TextField = PlainStringKeys; const SectionStack = Stack.withProps({ gap: "md" }); const FieldGrid = Stack.withProps({ gap: "sm" }); From 553e87426c2fa09f58111da5fbc68e920354741c Mon Sep 17 00:00:00 2001 From: cliffhall Date: Fri, 24 Jul 2026 23:14:48 -0400 Subject: [PATCH 04/14] refactor(web): fold .header-stack-cell flat CSS into HeaderStackCell (#1762) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per re-review of PR #1771: `.header-stack-cell` was flat-only CSS (grid-area, place-self) with no pseudo-selector/keyframe companion — the same class of finding this sweep fixed when it removed `.grid-align-start`. Fold it into the `HeaderStackCell` withProps constant's `styles.root` and delete the App.css rule, keeping the keyframe-driven `header-anim` class. The reduced-motion block targets only `.header-anim[data-anim]`, so it's unaffected. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5 --- clients/web/src/App.css | 7 ------- .../src/components/groups/ViewHeader/ViewHeader.tsx | 10 ++++++---- 2 files changed, 6 insertions(+), 11 deletions(-) diff --git a/clients/web/src/App.css b/clients/web/src/App.css index fabc4182e..c4895fe7c 100644 --- a/clients/web/src/App.css +++ b/clients/web/src/App.css @@ -343,13 +343,6 @@ body { animation: inspector-fade-slide-out 300ms ease both; } -/* The title and tab bar additionally share one grid cell so one replaces the - other in place during the center crossfade. */ -.header-stack-cell { - grid-area: 1 / 1; - place-self: center; -} - /* Honor reduced-motion for the header animations this PR adds (#1450): skip the fade/slide and the new-tab glow, leaving the elements at their resting state. Selectors match the originals' specificity so they win inside the query. */ diff --git a/clients/web/src/components/groups/ViewHeader/ViewHeader.tsx b/clients/web/src/components/groups/ViewHeader/ViewHeader.tsx index 145c31b66..50f54ba51 100644 --- a/clients/web/src/components/groups/ViewHeader/ViewHeader.tsx +++ b/clients/web/src/components/groups/ViewHeader/ViewHeader.tsx @@ -178,10 +178,12 @@ const RightConnectedGroup = Group.withProps({ }); // Center crossfade cell: the tab bar and the "MCP Inspector" title share one -// grid cell (`header-stack-cell`) and fade/slide (`header-anim`) so one replaces -// the other in place. The `data-anim` direction is set per render. +// grid cell (`gridArea: 1 / 1`, centered) and fade/slide (the keyframe-driven +// `header-anim` class) so one replaces the other in place. The `data-anim` +// direction is set per render. const HeaderStackCell = Group.withProps({ - className: "header-anim header-stack-cell", + className: "header-anim", + styles: { root: { gridArea: "1 / 1", placeSelf: "center" } }, }); const DisconnectIcon = ActionIcon.withProps({ @@ -336,7 +338,7 @@ export function ViewHeader(props: ViewHeaderProps) { {/* CSS grid stack: the title and tab bar cells share one cell (grid-area - 1/1 via `.header-stack-cell`), so on connect/disconnect one + 1/1, set on `HeaderStackCell`), so on connect/disconnect one fades+slides out as the other fades+slides in, in the same place. `flex: 0 0 auto` keeps it from stretching within the header. */} From dba00b5f2a5af6b1ddb79feffb7fe38f235f8ff5 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Fri, 24 Jul 2026 23:58:41 -0400 Subject: [PATCH 05/14] refactor(web): extract all 2+-static-prop inline elements to .withProps() (#1762) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Applies the AGENTS.md subcomponent rule strictly across the entire web client: every inline Mantine element carrying two or more static (literal) props is now a named per-file `.withProps()` constant, with dynamic props passed at the call site. This closes the ~170-instance gap between the rule's wording and prior practice (the earlier sweep only extracted repeated-within-a-file patterns). Scope: ~77 files across components/{elements,groups,screens,views} + App.tsx. Also tightens the AGENTS.md rule wording (in this PR, not a follow-up) to state explicitly that it applies in all cases including single-use elements, and that "static props" means literal-valued props (dynamic props are passed at the call site, not counted). Box handling per the rule: ResizeHandle and ScreenStage Boxes converted to named Flex constants; the App.tsx reauth-banner Box converted to a named Paper constant; the ViewHeader `display:grid` container and the AppRenderer iframe Box left inline with justifying comments (no Mantine primitive fits). Two constructs can't be expressed via `.withProps()` and stay inline with a justifying comment (same class of exemption as Box): `Accordion` (a compound, `multiple`-discriminated generic whose `.withProps()` loses its JSX call signature), and `data-*` attributes (not part of a component's typed props, so passed at the call site). Verified: `npm run ci` green end to end — 3472 unit tests, the per-file >=90 coverage gate (98.58% overall), all smokes incl. the headless-browser boot, and 456 Storybook play-tests. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5 --- AGENTS.md | 2 +- clients/web/src/App.tsx | 52 +++++-- .../AnnotationBadge/AnnotationBadge.tsx | 12 +- .../elements/AppRenderer/AppRenderer.tsx | 2 + .../elements/ContentViewer/ContentViewer.tsx | 15 +- .../elements/CopyButton/CopyButton.tsx | 11 +- .../EmbeddableScrollArea.tsx | 26 ++-- .../elements/ExpandToggle/ExpandToggle.tsx | 13 +- .../FilterToggleButton/FilterToggleButton.tsx | 16 +- .../elements/ListToggle/ListToggle.tsx | 34 ++-- .../components/elements/LogEntry/LogEntry.tsx | 10 +- .../elements/LogLevelBadge/LogLevelBadge.tsx | 14 +- .../elements/McpErrorBadge/McpErrorBadge.tsx | 33 ++-- .../elements/MethodBadge/MethodBadge.tsx | 16 +- .../MonitoringToggle/MonitoringToggle.tsx | 14 +- .../MrtrOriginNote/MrtrOriginNote.tsx | 9 +- .../elements/PinToggle/PinToggle.tsx | 16 +- .../elements/ReplayButton/ReplayButton.tsx | 17 +- .../elements/ResizeHandle/ResizeHandle.tsx | 18 ++- .../elements/ScreenStage/ScreenStage.tsx | 23 +-- .../elements/SortToggle/SortToggle.tsx | 13 +- .../SubscribeButton/SubscribeButton.tsx | 9 +- .../SubscriptionStreamBadge.tsx | 10 +- .../TaskStatusBadge/TaskStatusBadge.tsx | 13 +- .../TransportBadge/TransportBadge.tsx | 11 +- .../groups/AppDetailPanel/AppDetailPanel.tsx | 13 +- .../groups/AppListItem/AppListItem.tsx | 13 +- .../ClientSettingsForm/ClientSettingsForm.tsx | 106 +++++++------ .../ClientSettingsModal.tsx | 35 +++-- .../ConnectionInfoContent.tsx | 15 +- .../ConnectionInfoModal.tsx | 26 ++-- .../ElicitationFormPanel.tsx | 18 ++- .../ElicitationUrlPanel.tsx | 9 +- .../ExperimentalFeaturesPanel.tsx | 70 +++++---- .../ImportServerJsonPanel.tsx | 64 +++++--- .../groups/LogControls/LogControls.tsx | 18 ++- .../groups/LogStreamPanel/LogStreamPanel.tsx | 9 +- .../MessageDirectionFilter.tsx | 13 +- .../MonitoringControls/MonitoringControls.tsx | 13 +- .../MrtrConversation/MrtrConversation.tsx | 11 +- .../groups/NetworkEntry/NetworkEntry.tsx | 145 ++++++++++++------ .../NetworkStreamPanel/NetworkStreamPanel.tsx | 10 +- .../OutputValidationModal.tsx | 45 +++--- .../PendingClientRequestModal.tsx | 15 +- .../PendingClientRequests.tsx | 9 +- .../groups/PromptControls/PromptControls.tsx | 34 ++-- .../groups/PromptListItem/PromptListItem.tsx | 13 +- .../ProtocolControls/ProtocolControls.tsx | 31 ++-- .../groups/ProtocolEntry/ProtocolEntry.tsx | 60 +++++--- .../ProtocolListPanel/ProtocolListPanel.tsx | 10 +- .../groups/ReAuthBanner/ReAuthBanner.tsx | 46 ++++-- .../ResourceControls/ResourceControls.tsx | 42 +++-- .../groups/ResourceLink/ResourceLink.tsx | 14 +- .../ResourceListItem/ResourceListItem.tsx | 13 +- .../SamplingRequestPanel.tsx | 31 ++-- .../groups/SchemaForm/SchemaForm.tsx | 16 +- .../groups/ServerCard/ServerCard.tsx | 18 ++- .../ServerConfigModal/ServerConfigModal.tsx | 54 +++---- .../ServerImportConfigModal.tsx | 32 ++-- .../ServerImportJsonModal.tsx | 7 +- .../ServerListControls/ServerListControls.tsx | 13 +- .../ServerRemoveConfirmModal.tsx | 39 ++--- .../ServerSettingsForm/ServerSettingsForm.tsx | 77 ++++++---- .../ServerSettingsModal.tsx | 35 +++-- .../StepUpAuthModal/StepUpAuthModal.tsx | 48 +++--- .../groups/TaskControls/TaskControls.tsx | 40 +++-- .../groups/TaskListPanel/TaskListPanel.tsx | 9 +- .../groups/ToolControls/ToolControls.tsx | 83 ++++++---- .../groups/ToolListItem/ToolListItem.tsx | 13 +- .../ToolResultPanel/ToolResultPanel.tsx | 25 ++- .../UrlElicitationErrorModal.tsx | 45 +++--- .../groups/ViewHeader/ViewHeader.tsx | 3 + .../screens/AppsScreen/AppsScreen.tsx | 28 ++-- .../screens/ConsoleScreen/ConsoleScreen.tsx | 22 ++- .../screens/PromptsScreen/PromptsScreen.tsx | 20 ++- .../ResourcesScreen/ResourcesScreen.tsx | 20 ++- .../ServerListScreen/ServerListScreen.tsx | 84 +++++----- 77 files changed, 1279 insertions(+), 812 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index df3da320c..50a1ac388 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -251,7 +251,7 @@ gh project item-edit --project-id PVT_kwDOCt2Azc4BJVxt --id "$ITEM_ID" --field-i - NEVER use inline code; instead extract to functions in the same file, exported or located in a shared location if immediately reusable. - In a component's file, for sub-components: - ALWAYS use Mantine components for layout and content, configured with props for styling and behavior. - - ALWAYS declare a meaningfully named subcomponent as a constant using `.withProps()` if a component has two or more props. + - ALWAYS declare a meaningfully named subcomponent as a constant using `.withProps()` if an inline Mantine element carries two or more **static** props. A *static* prop is one whose value is a literal used for styling/layout/behavior (`size="sm"`, `c="dimmed"`, `fw={500}`, `gap="xs"`, `justify="space-between"`, `role="alert"`, `variant="light"`, `withBorder`, …); dynamic props (`value`, `onChange`/`on*`, `children`, `key`, `ref`, and anything whose value is a variable/expression) do **not** count toward the two and are passed at the call site, not baked into the constant. This rule applies in **all** cases: "repeated pattern" is NOT the bar — a single-use element with two or more static props must still be extracted. Bake the static props into the `.withProps()` constant and pass the dynamic ones where it's rendered. - NEVER use `Box` for subcomponent constants — `Box` does not support `.withProps()`. Use `Group`, `Stack`, `Flex`, `Text`, `Paper`, `UnstyledButton`, or `Image` instead. Pick the component that best matches the purpose: `Paper` for bordered/surfaced containers, `Text` for any text or content wrapper, `Stack`/`Group`/`Flex` for layout. - NEVER use a CSS class on a subcomponent constant when the styles can be expressed as a Mantine theme variant instead. Define variants in `src/theme/.ts` using `Component.extend({ styles: (_theme, props) => { ... } })` and reference them with `variant="variantName"` on the component or in `.withProps()`. - CSS classes are ONLY acceptable on subcomponents for styles that cannot be expressed as flat CSS-in-JS properties in the theme — specifically: pseudo-selectors (`:hover`, `:focus`), cross-component hover relationships (`.parent:hover .child`), nested child-element selectors (`.wrapper p`, `.wrapper code`), `@keyframes` definitions, and native HTML elements (`img`, `iframe`) that are not Mantine components. diff --git a/clients/web/src/App.tsx b/clients/web/src/App.tsx index ebb5f7410..5121899fd 100644 --- a/clients/web/src/App.tsx +++ b/clients/web/src/App.tsx @@ -3,6 +3,7 @@ import { Anchor, Box, List, + Paper, Stack, Text, useComputedColorScheme, @@ -383,6 +384,30 @@ function bodyDroppedToastId(serverId: string): string { const CLIENT_CONFIG_LOAD_ERROR_NOTIFICATION_ID = "client-config-load-error"; +// Shared "list of likely causes" styling for the warning-toast bodies below. +const ToastCauseList = List.withProps({ size: "sm", spacing: 2 }); + +// The "open the relevant settings/details" link rendered at the bottom of each +// warning-toast body. Same static shape across all three toasts; each passes +// its own `onClick`. +const ToastLinkButton = Anchor.withProps({ + component: "button", + type: "button", + size: "sm", +}); + +// Sticky re-auth banner bar. Box can't use `.withProps()`, so it's a `Paper` +// baking the static layout/position props; the `reauth-banner-bar` className +// carries the styles that can't be expressed as props. +const ReAuthBannerBar = Paper.withProps({ + className: "reauth-banner-bar", + px: "md", + pt: "xs", + pos: "sticky", + top: 60, + bg: "var(--mantine-color-body)", +}); + // Body of the "response body dropped" warning toast: a one-line summary of what // happened, the likely causes, and a link that opens this server's settings // (on the Options section) so the user can raise the Network Log Size if it's @@ -401,7 +426,7 @@ const FetchBodyDroppedToastMessage = ({ out (the log hit its {maxFetchRequests}-request limit), so the body couldn't be shown. This usually indicates: - + a chatty or misbehaving server (notification storms, rapid polling) @@ -412,10 +437,10 @@ const FetchBodyDroppedToastMessage = ({ the Network Log Size set too low for this server's traffic - - + + Adjust Network Log Size for this server - + ); @@ -433,9 +458,9 @@ const OutputValidationToastMessage = ({ tool's outputSchema. The inspector renders it anyway, but strict MCP clients may not. - + View validation details - + ); @@ -452,9 +477,9 @@ const UrlElicitationErrorToastMessage = ({ The server reported a URLElicitationRequired error but listed no required elicitations, so there's nothing to open. - + View error details - + ); @@ -4185,20 +4210,13 @@ function App() { <> {reAuthBanner ? ( - + setReAuthBanner(null)} /> - + ) : null} = { longRunHint: "yellow", }; +const FilledBadge = Badge.withProps({ + variant: "filled", + fw: 500, + autoContrast: true, +}); + function formatLabel( facet: AnnotationFacet, value: Role[] | number | boolean, @@ -59,9 +65,5 @@ export function AnnotationBadge({ facet, value }: AnnotationBadgeProps) { // fills and the darker dark-mode `-filled` shades — unlike a fixed // scheme→black/white mapping, which inverted the contrast in dark mode. // Amber fills are pinned to shade 5 first (see `filledBadgeColor`). - return ( - - {formatLabel(facet, value)} - - ); + return {formatLabel(facet, value)}; } diff --git a/clients/web/src/components/elements/AppRenderer/AppRenderer.tsx b/clients/web/src/components/elements/AppRenderer/AppRenderer.tsx index bddcade2b..19eb85838 100644 --- a/clients/web/src/components/elements/AppRenderer/AppRenderer.tsx +++ b/clients/web/src/components/elements/AppRenderer/AppRenderer.tsx @@ -522,6 +522,8 @@ export function AppRenderer({ // iframe. Sandboxing this outer frame would block the postMessage bridge // that `AppBridge` relies on. return ( + // Box+iframe is a native element (not a Mantine primitive), so the + // `.withProps()` extraction rule doesn't apply. - {displayText} - + ); } @@ -351,11 +354,11 @@ function BlockContent({ return ( - + {"text" in block.resource ? block.resource.text : `[blob: ${block.resource.uri}]`} - + ); diff --git a/clients/web/src/components/elements/CopyButton/CopyButton.tsx b/clients/web/src/components/elements/CopyButton/CopyButton.tsx index e58b2fba6..7a86ca085 100644 --- a/clients/web/src/components/elements/CopyButton/CopyButton.tsx +++ b/clients/web/src/components/elements/CopyButton/CopyButton.tsx @@ -13,21 +13,24 @@ export interface CopyButtonProps { flush?: boolean; } +const CopyActionIcon = ActionIcon.withProps({ + variant: "subtle", + fz: 24, +}); + export function CopyButton({ value, flush = false }: CopyButtonProps) { return ( {({ copied, copy }) => ( - {copied ? "\u2713" : "\u2398"} - + )} diff --git a/clients/web/src/components/elements/EmbeddableScrollArea/EmbeddableScrollArea.tsx b/clients/web/src/components/elements/EmbeddableScrollArea/EmbeddableScrollArea.tsx index 0ad636754..e4c440869 100644 --- a/clients/web/src/components/elements/EmbeddableScrollArea/EmbeddableScrollArea.tsx +++ b/clients/web/src/components/elements/EmbeddableScrollArea/EmbeddableScrollArea.tsx @@ -34,6 +34,14 @@ export interface EmbeddableScrollAreaProps { // grow wider than its viewport; see `constrainContentWidth`. const CONSTRAIN_CONTENT_STYLES = { content: { minWidth: 0 } } as const; +// Shared scroll region for both hosts; the differing props (viewportRef, mah, +// styles) are passed at each call site. +const StreamScrollArea = ScrollArea.Autosize.withProps({ + type: "scroll", + offsetScrollbars: true, + viewportProps: { tabIndex: 0 }, +}); + /** * The scroll region shared by the Logs / Protocol / Network stream panels, which * render both full-size (their own tab) and embedded (the monitoring sidebar). @@ -49,29 +57,19 @@ export function EmbeddableScrollArea({ if (embedded) { return ( - + {children} - + ); } return ( - {children} - + ); } diff --git a/clients/web/src/components/elements/ExpandToggle/ExpandToggle.tsx b/clients/web/src/components/elements/ExpandToggle/ExpandToggle.tsx index 0d578281d..53b12ebde 100644 --- a/clients/web/src/components/elements/ExpandToggle/ExpandToggle.tsx +++ b/clients/web/src/components/elements/ExpandToggle/ExpandToggle.tsx @@ -22,6 +22,12 @@ export interface ExpandToggleProps { * it replaced); `aria-expanded` exposes the disclosure state and `ariaLabel` * can distinguish sibling toggles. */ +const ExpandActionIcon = ActionIcon.withProps({ + variant: "subtle", + color: "gray", + size: "md", +}); + export function ExpandToggle({ expanded, onToggle, @@ -31,16 +37,13 @@ export function ExpandToggle({ const label = expanded ? "Collapse" : "Expand"; return ( - - + ); } diff --git a/clients/web/src/components/elements/FilterToggleButton/FilterToggleButton.tsx b/clients/web/src/components/elements/FilterToggleButton/FilterToggleButton.tsx index 1f7af2e05..92e217c6d 100644 --- a/clients/web/src/components/elements/FilterToggleButton/FilterToggleButton.tsx +++ b/clients/web/src/components/elements/FilterToggleButton/FilterToggleButton.tsx @@ -17,6 +17,12 @@ const ToggleLabel = Text.withProps({ fw: 500, }); +const ToggleButton = UnstyledButton.withProps({ + w: "100%", + p: "sm", + variant: "filterToggle", +}); + /** * A single full-width filter toggle used by the Logging, Protocol, and Network * controls. The `filterToggle` theme variant + `.filter-toggle` rules own the @@ -32,14 +38,8 @@ export function FilterToggleButton({ onToggle, }: FilterToggleButtonProps) { return ( - onToggle(!active)} - > + onToggle(!active)}> {label} - + ); } diff --git a/clients/web/src/components/elements/ListToggle/ListToggle.tsx b/clients/web/src/components/elements/ListToggle/ListToggle.tsx index b1cf69cd5..5a1efcc62 100644 --- a/clients/web/src/components/elements/ListToggle/ListToggle.tsx +++ b/clients/web/src/components/elements/ListToggle/ListToggle.tsx @@ -7,6 +7,19 @@ export interface ListToggleProps { variant?: "default" | "subtle"; } +const SubtleActionIcon = ActionIcon.withProps({ + variant: "subtle", + color: "gray", + size: "md", +}); + +// `size={36}` matches the header's theme / client-settings ActionIcons so the +// toolbar's toggle reads as the same size icon button. +const ToolbarActionIcon = ActionIcon.withProps({ + variant: "subtle", + size: 36, +}); + export function ListToggle({ compact, onToggle, @@ -18,31 +31,18 @@ export function ListToggle({ if (variant === "subtle") { return ( - + - + ); } - // `size={36}` matches the header's theme / client-settings ActionIcons so the - // toolbar's toggle reads as the same size icon button. return ( - + - + ); } diff --git a/clients/web/src/components/elements/LogEntry/LogEntry.tsx b/clients/web/src/components/elements/LogEntry/LogEntry.tsx index 5f431e480..3fb53a649 100644 --- a/clients/web/src/components/elements/LogEntry/LogEntry.tsx +++ b/clients/web/src/components/elements/LogEntry/LogEntry.tsx @@ -81,6 +81,12 @@ const MetaRow = Group.withProps({ align: "center", }); +// The single-line row (full Logs screen): meta + message on one row. +const LogRow = Group.withProps({ + gap: "sm", + wrap: "nowrap", +}); + export function LogEntry({ entry, compact = false }: LogEntryProps) { const { receivedAt, params } = entry; const message = formatData(params.data); @@ -106,13 +112,13 @@ export function LogEntry({ entry, compact = false }: LogEntryProps) { } return ( - + {formatTimestamp(receivedAt)} {logger} {message} - + ); } diff --git a/clients/web/src/components/elements/LogLevelBadge/LogLevelBadge.tsx b/clients/web/src/components/elements/LogLevelBadge/LogLevelBadge.tsx index 8e3fa30ce..2cbee7f51 100644 --- a/clients/web/src/components/elements/LogLevelBadge/LogLevelBadge.tsx +++ b/clients/web/src/components/elements/LogLevelBadge/LogLevelBadge.tsx @@ -19,19 +19,19 @@ const levelColor: Record = { const boldLevels: Set = new Set(["alert", "emergency"]); +const FilledBadge = Badge.withProps({ + variant: "filled", + autoContrast: true, +}); + export function LogLevelBadge({ level }: LogLevelBadgeProps) { const fw = boldLevels.has(level) ? 500 : undefined; // `autoContrast` keeps the label legible (WCAG AA) on both the light-mode // fills and the darker dark-mode `-filled` shades — see AnnotationBadge. return ( - + {level} - + ); } diff --git a/clients/web/src/components/elements/McpErrorBadge/McpErrorBadge.tsx b/clients/web/src/components/elements/McpErrorBadge/McpErrorBadge.tsx index e7913b224..5e5cd624f 100644 --- a/clients/web/src/components/elements/McpErrorBadge/McpErrorBadge.tsx +++ b/clients/web/src/components/elements/McpErrorBadge/McpErrorBadge.tsx @@ -20,6 +20,21 @@ const COLOR_BY_CODE: Record = { [-32601]: "yellow", // MethodNotFound (modern 404) }; +const SpecErrorBadge = Badge.withProps({ + variant: "filled", + autoContrast: true, + // Keep the spec/SDK identifier's own casing (e.g. "UnsupportedProtocolVersion") + // rather than Mantine's default uppercase, which runs these long + // PascalCase names together and hurts readability. + tt: "none", +}); + +const DescriptionTooltip = Tooltip.withProps({ + multiline: true, + w: 280, + withArrow: true, +}); + /** * Distinct badge for one of the modern Streamable HTTP spec error codes shown in * the Protocol tab. Labels the code and spec name (e.g. "-32020 HeaderMismatch") @@ -31,22 +46,10 @@ const COLOR_BY_CODE: Record = { */ export function McpErrorBadge({ code, name, description }: McpErrorBadgeProps) { const badge = ( - + {code} {name} - + ); if (!description) return badge; - return ( - - {badge} - - ); + return {badge}; } diff --git a/clients/web/src/components/elements/MethodBadge/MethodBadge.tsx b/clients/web/src/components/elements/MethodBadge/MethodBadge.tsx index 130601d84..b1ea6a496 100644 --- a/clients/web/src/components/elements/MethodBadge/MethodBadge.tsx +++ b/clients/web/src/components/elements/MethodBadge/MethodBadge.tsx @@ -5,19 +5,17 @@ export interface MethodBadgeProps { method: string; } +const MethodChip = Badge.withProps({ + autoContrast: false, + bg: "var(--inspector-badge-method-bg)", + c: "var(--inspector-badge-method-fg)", +}); + /** * Badge labelling a Protocol/Network entry's method. A neutral charcoal chip with * light text, driven by `--inspector-badge-method-*` so it stays legible (not a * washed-out pale fill) in dark mode. Shared by `ProtocolEntry` and `NetworkEntry`. */ export function MethodBadge({ method }: MethodBadgeProps) { - return ( - - {method} - - ); + return {method}; } diff --git a/clients/web/src/components/elements/MonitoringToggle/MonitoringToggle.tsx b/clients/web/src/components/elements/MonitoringToggle/MonitoringToggle.tsx index a7143a59f..7d0e740b9 100644 --- a/clients/web/src/components/elements/MonitoringToggle/MonitoringToggle.tsx +++ b/clients/web/src/components/elements/MonitoringToggle/MonitoringToggle.tsx @@ -21,20 +21,20 @@ export interface MonitoringToggleProps { * connect attempt, on a wide viewport), so it never appears with nothing to * toggle. `size={36}` matches the header's theme / client-settings ActionIcons. */ +const MonitoringActionIcon = ActionIcon.withProps({ + variant: "subtle", + size: 36, +}); + export function MonitoringToggle({ open, onToggle }: MonitoringToggleProps) { const Icon = open ? TbLayoutSidebarRightCollapse : TbLayoutSidebarRightExpand; const label = open ? "Close monitoring sidebar" : "Open monitoring sidebar"; return ( - + - + ); } diff --git a/clients/web/src/components/elements/MrtrOriginNote/MrtrOriginNote.tsx b/clients/web/src/components/elements/MrtrOriginNote/MrtrOriginNote.tsx index c4aa7c58d..432d01bf8 100644 --- a/clients/web/src/components/elements/MrtrOriginNote/MrtrOriginNote.tsx +++ b/clients/web/src/components/elements/MrtrOriginNote/MrtrOriginNote.tsx @@ -23,6 +23,11 @@ const NoteText = Text.withProps({ c: "dimmed", }); +const OriginBadge = Badge.withProps({ + variant: "outline", + color: "blue", +}); + // The two modern origins differ in HOW the answer is delivered: an MRTR round // retries the original call with the answer (SEP-2322); a task round submits it // via a separate `tasks/update` request (SEP-2663). The note is accurate to each @@ -50,9 +55,7 @@ export function MrtrOriginNote({ if (!note) return null; return ( - - input_required - + input_required {note} ); diff --git a/clients/web/src/components/elements/PinToggle/PinToggle.tsx b/clients/web/src/components/elements/PinToggle/PinToggle.tsx index 0886f47a6..07248b290 100644 --- a/clients/web/src/components/elements/PinToggle/PinToggle.tsx +++ b/clients/web/src/components/elements/PinToggle/PinToggle.tsx @@ -12,20 +12,20 @@ export interface PinToggleProps { * a filled pin. The aria-label stays "Pin"/"Unpin" so it reads the same as the * text button it replaces. */ +const PinActionIcon = ActionIcon.withProps({ + variant: "subtle", + color: "gray", + size: "md", +}); + export function PinToggle({ pinned, onToggle }: PinToggleProps) { const Icon = pinned ? TiPin : TiPinOutline; const label = pinned ? "Unpin" : "Pin"; return ( - + - + ); } diff --git a/clients/web/src/components/elements/ReplayButton/ReplayButton.tsx b/clients/web/src/components/elements/ReplayButton/ReplayButton.tsx index 7d6f48a55..62b7b4c1c 100644 --- a/clients/web/src/components/elements/ReplayButton/ReplayButton.tsx +++ b/clients/web/src/components/elements/ReplayButton/ReplayButton.tsx @@ -11,18 +11,19 @@ export interface ReplayButtonProps { * layout where the text button is replaced by a replay icon sitting next to the * pin toggle (#1616). Matches PinToggle's subtle gray icon-button styling. */ +const ReplayActionIcon = ActionIcon.withProps({ + variant: "subtle", + color: "gray", + size: "md", + "aria-label": "Replay", +}); + export function ReplayButton({ onReplay }: ReplayButtonProps) { return ( - + - + ); } diff --git a/clients/web/src/components/elements/ResizeHandle/ResizeHandle.tsx b/clients/web/src/components/elements/ResizeHandle/ResizeHandle.tsx index 77545a5e6..5a6100311 100644 --- a/clients/web/src/components/elements/ResizeHandle/ResizeHandle.tsx +++ b/clients/web/src/components/elements/ResizeHandle/ResizeHandle.tsx @@ -4,7 +4,17 @@ import { type KeyboardEvent, type PointerEvent, } from "react"; -import { Box } from "@mantine/core"; +import { Flex } from "@mantine/core"; + +// Childless interactive separator: a single positioned layer, so a Flex +// primitive (Box can't use `.withProps()`). The dynamic aria-value*/aria-label +// and pointer/key handlers are passed at the call site. +const ResizeSeparator = Flex.withProps({ + role: "separator", + "aria-orientation": "vertical", + tabIndex: 0, + className: "resize-handle", +}); export interface ResizeHandleProps { /** Current width (px) of the panel this handle resizes. */ @@ -100,15 +110,11 @@ export function ResizeHandle({ } return ( - ( // `style={styles}` is the runtime transition state from Mantine's // Transition API — interpolated values, not static styling. - + {children} - + )} ); diff --git a/clients/web/src/components/elements/SortToggle/SortToggle.tsx b/clients/web/src/components/elements/SortToggle/SortToggle.tsx index d48272088..de77d345f 100644 --- a/clients/web/src/components/elements/SortToggle/SortToggle.tsx +++ b/clients/web/src/components/elements/SortToggle/SortToggle.tsx @@ -18,6 +18,13 @@ function isSortDirection(value: string | null): value is SortDirection { return value === "oldest-first" || value === "newest-first"; } +const SortSelect = Select.withProps({ + size: "sm", + w: 150, + allowDeselect: false, + withCheckIcon: false, +}); + export function SortToggle({ value, onChange, @@ -25,9 +32,7 @@ export function SortToggle({ }: SortToggleProps) { const Icon = value === "newest-first" ? TbSortDescending2 : TbSortAscending2; return ( -