refactor(web): sweep AGENTS.md TypeScript + Mantine rule violations (#1771)

Codebase-wide sweep bringing the web client into strict compliance with the AGENTS.md TypeScript and Mantine/React conventions: ~170 inline 2+-static-prop elements extracted to named .withProps() constants across ~77 files, the AGENTS.md rule wording tightened to match (static-prop boundary + Accordion/Transition/data-*/Box exemptions), a real ScreenStage Box->Flex layout regression fixed (direction: column) with a Storybook regression guard, and an App.tsx double-cast tightened. Closes #1762.
This commit is contained in:
Cliff Hall 2026-07-25 11:38:49 -04:00 • committed by GitHub
commit da37692485
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
49 changed files with 1567 additions and 991 deletions

View file

@ -38,6 +38,12 @@ const PanelStack = Stack.withProps({
miw: 0,
});
const OpenAppButton = Button.withProps({
size: "md",
fullWidth: true,
leftSection: <MdPlayArrow aria-hidden size={18} />,
});
export function AppDetailPanel({
tool,
formValues,
@ -72,17 +78,14 @@ export function AppDetailPanel({
disabled={isOpening}
/>
<Button
size="md"
fullWidth
leftSection={<MdPlayArrow aria-hidden size={18} />}
<OpenAppButton
onClick={onOpenApp}
disabled={disabled}
loading={isOpening}
data-testid="open-app"
>
Open App
</Button>
</OpenAppButton>
</PanelStack>
</PanelScroll>
);

View file

@ -38,15 +38,18 @@ const AppIcon = Image.withProps({
fit: "contain",
});
const ListItemButton = UnstyledButton.withProps({
w: "100%",
p: "sm",
variant: "listItem",
});
export function AppListItem({ tool, selected, onClick }: AppListItemProps) {
const { name, title, description, icons } = tool;
const iconSrc = icons?.[0]?.src;
return (
<UnstyledButton
w="100%"
p="sm"
variant="listItem"
<ListItemButton
bg={selected ? "var(--mantine-primary-color-light)" : undefined}
onClick={onClick}
>
@ -62,6 +65,6 @@ export function AppListItem({ tool, selected, onClick }: AppListItemProps) {
size={18}
/>
</Row>
</UnstyledButton>
</ListItemButton>
);
}

View file

@ -51,6 +51,45 @@ const HintText = Text.withProps({
c: "dimmed",
});
const ErrorAlert = Alert.withProps({
color: "red",
variant: "light",
title: "Client registration was rejected",
});
// `rightSectionPointerEvents="auto"` keeps the ClearButton clickable inside the
// input's right section; shared by every clearable field here.
const ClearableTextInput = TextInput.withProps({
rightSectionPointerEvents: "auto",
});
const IdpSessionRow = Group.withProps({
justify: "space-between",
align: "flex-start",
wrap: "nowrap",
});
const IdpSessionStack = Stack.withProps({
gap: 4,
flex: 1,
});
const SectionLabel = Text.withProps({
size: "sm",
fw: 500,
});
const SessionBadge = Badge.withProps({
w: "fit-content",
variant: "light",
});
const SignOutButton = Button.withProps({
variant: "light",
color: "red",
size: "compact-sm",
});
/** Human-readable one-liner for an RFC 7591 DCR rejection. */
function registrationRejectionSummary(error: OAuthRegistrationError): string {
const parts = [
@ -115,11 +154,7 @@ export function ClientSettingsForm({
return (
<Stack gap="md">
{registrationError && (
<Alert
color="red"
variant="light"
title="Client registration was rejected"
>
<ErrorAlert>
<Stack gap={4}>
<Text size="sm">
{registrationRejectionSummary(registrationError)}
@ -132,15 +167,18 @@ export function ClientSettingsForm({
preregistered client instead.
</HintText>
</Stack>
</Alert>
</ErrorAlert>
)}
{/* Stays inline: Accordion is a compound, `multiple`-discriminated generic,
so `.withProps({ multiple: true, ... })` loses its JSX call signature
(same tooling limit as Box). */}
<Accordion
multiple
variant="separated"
value={expandedSections}
onChange={(value) =>
onExpandedSectionsChange(value as ClientSettingsSection[])
}
variant="separated"
>
<Accordion.Item value="ema">
<Accordion.Control>
@ -162,34 +200,32 @@ export function ClientSettingsForm({
? `${window.location.origin}/oauth/callback`
: "http://localhost:6274/oauth/callback"}
</HintText>
<TextInput
<ClearableTextInput
label="Issuer"
description="Your enterprise IdP issuer URL."
value={settings.issuer}
onChange={(e) => patch({ issuer: e.currentTarget.value })}
onBlur={() => setIssuerTouched(true)}
error={showIssuerError}
rightSectionPointerEvents="auto"
rightSection={
settings.issuer ? (
<ClearButton onClick={() => patch({ issuer: "" })} />
) : null
}
/>
<TextInput
<ClearableTextInput
label="IdP Client ID"
description="Client id registered with your enterprise IdP (EMA legs 1–2) — not the per-server resource authorization server credentials, which go in Server Settings → OAuth Settings."
value={settings.clientId}
onChange={(e) => patch({ clientId: e.currentTarget.value })}
error={showClientIdError}
rightSectionPointerEvents="auto"
rightSection={
settings.clientId ? (
<ClearButton onClick={() => patch({ clientId: "" })} />
) : null
}
/>
<TextInput
<ClearableTextInput
label="IdP Client Secret"
description="Client secret for your enterprise IdP, if required (EMA legs 1–2) — not the per-server resource authorization server credentials, which go in Server Settings → OAuth Settings."
type="password"
@ -197,7 +233,6 @@ export function ClientSettingsForm({
onChange={(e) =>
patch({ clientSecret: e.currentTarget.value })
}
rightSectionPointerEvents="auto"
rightSection={
settings.clientSecret ? (
<ClearButton
@ -207,24 +242,12 @@ export function ClientSettingsForm({
}
/>
{settings.issuer.trim() !== "" && (
<Group
justify="space-between"
align="flex-start"
wrap="nowrap"
>
<Stack gap={4} flex={1}>
<Text size="sm" fw={500}>
IdP sign-in
</Text>
<IdpSessionRow>
<IdpSessionStack>
<SectionLabel>IdP sign-in</SectionLabel>
{emaIdpLoginState === "logged_in" ? (
<>
<Badge
w="fit-content"
color="green"
variant="light"
>
Signed in
</Badge>
<SessionBadge color="green">Signed in</SessionBadge>
<HintText>
Your enterprise IdP session is active. Connecting
to EMA-enabled MCP servers will not prompt for IdP
@ -233,13 +256,9 @@ export function ClientSettingsForm({
</>
) : emaIdpLoginState === "expired" ? (
<>
<Badge
w="fit-content"
color="yellow"
variant="light"
>
<SessionBadge color="yellow">
Session expired
</Badge>
</SessionBadge>
<HintText>
Your cached IdP session has expired. The next
connect to an EMA-enabled server will prompt for
@ -252,18 +271,13 @@ export function ClientSettingsForm({
an EMA-enabled MCP server will open IdP login.
</HintText>
)}
</Stack>
</IdpSessionStack>
{showIdpSession && onEmaIdpLogout ? (
<Button
variant="light"
color="red"
size="compact-sm"
onClick={onEmaIdpLogout}
>
<SignOutButton onClick={onEmaIdpLogout}>
Sign out
</Button>
</SignOutButton>
) : null}
</Group>
</IdpSessionRow>
)}
</>
)}
@ -299,7 +313,7 @@ export function ClientSettingsForm({
? `${window.location.origin}/oauth/callback`
: "http://localhost:6274/oauth/callback"}
</HintText>
<TextInput
<ClearableTextInput
label="Client ID metadata document URL"
description="Public HTTPS URL of your OAuth client metadata JSON document."
value={settings.clientMetadataUrl}
@ -308,7 +322,6 @@ export function ClientSettingsForm({
}
onBlur={() => setClientMetadataUrlTouched(true)}
error={showClientMetadataUrlError}
rightSectionPointerEvents="auto"
rightSection={
settings.clientMetadataUrl ? (
<ClearButton

View file

@ -13,6 +13,23 @@ import {
const ALL_SECTIONS: ClientSettingsSection[] = ["ema", "cimd"];
const AppModalLg = Modal.Root.withProps({
size: "lg",
centered: true,
scrollAreaComponent: ScrollArea.Autosize,
});
const ModalHeaderRow = Group.withProps({
justify: "space-between",
wrap: "nowrap",
w: "100%",
});
const CenteredModalTitle = Modal.Title.withProps({
ta: "center",
flex: 1,
});
export interface ClientSettingsModalProps {
opened: boolean;
settings: ClientSettingsFormValues;
@ -66,28 +83,20 @@ export function ClientSettingsModal({
// kept consistent with ServerSettingsModal for when this grows enough to
// scroll). The fade-down transition `<Modal>` defaults to (but `Modal.Root`
// doesn't inherit) is supplied app-wide by `ThemeModalRoot`.
<Modal.Root
opened={opened}
onClose={handleClose}
size="lg"
centered
scrollAreaComponent={ScrollArea.Autosize}
>
<AppModalLg opened={opened} onClose={handleClose}>
<Modal.Overlay />
<Modal.Content>
<Modal.Header>
<Group justify="space-between" wrap="nowrap" w="100%">
<ModalHeaderRow>
<ListToggle
compact={!allExpanded}
variant="subtle"
onToggle={handleToggleAll}
/>
{/* `Modal.Title` names the dialog (wires `aria-labelledby`). */}
<Modal.Title ta="center" flex={1}>
Client Settings
</Modal.Title>
<CenteredModalTitle>Client Settings</CenteredModalTitle>
<CloseButton aria-label="Close" onClick={handleClose} />
</Group>
</ModalHeaderRow>
</Modal.Header>
<Modal.Body>
<ClientSettingsForm
@ -101,6 +110,6 @@ export function ClientSettingsModal({
/>
</Modal.Body>
</Modal.Content>
</Modal.Root>
</AppModalLg>
);
}

View file

@ -79,6 +79,12 @@ const SectionHeading = Title.withProps({
// `scrollable-region-focusable`).
const ValueCode = Code.withProps({ variant: "wrapping" });
const ClearOAuthButton = Button.withProps({
variant: "subtle",
color: "red",
size: "compact-xs",
});
function formatScopes(scopes: string[]): string {
return scopes.join(", ");
}
@ -339,14 +345,9 @@ export function ConnectionInfoContent({
) : (
onClearOAuth && (
<Flex justify="flex-end">
<Button
variant="subtle"
color="red"
size="compact-xs"
onClick={onClearOAuth}
>
<ClearOAuthButton onClick={onClearOAuth}>
{CLEAR_OAUTH_STATE_AND_DISCONNECT_LABEL}
</Button>
</ClearOAuthButton>
</Flex>
)
)}

View file

@ -11,6 +11,18 @@ import {
type OAuthDetails,
} from "../ConnectionInfoContent/ConnectionInfoContent";
const AppModalLg = Modal.Root.withProps({
size: "lg",
centered: true,
scrollAreaComponent: ScrollArea.Autosize,
});
const ModalHeaderRow = Group.withProps({
justify: "space-between",
wrap: "nowrap",
w: "100%",
});
export interface ConnectionInfoModalProps {
opened: boolean;
onClose: () => void;
@ -40,22 +52,16 @@ export function ConnectionInfoModal({
// a long connection-info payload grows the whole modal past the viewport and
// scrolls the header out of view (#1754, same fix as the settings modals in
// #1698). The fade-down transition is supplied app-wide by `ThemeModalRoot`.
<Modal.Root
opened={opened}
onClose={onClose}
size="lg"
centered
scrollAreaComponent={ScrollArea.Autosize}
>
<AppModalLg opened={opened} onClose={onClose}>
<Modal.Overlay />
<Modal.Content>
<Modal.Header>
<Group justify="space-between" wrap="nowrap" w="100%">
<ModalHeaderRow>
{/* `Modal.Title` (not a bare `Title`) registers the modal's
accessible name — it wires the dialog's `aria-labelledby`. */}
<Modal.Title flex={1}>Connection Info</Modal.Title>
<CloseButton aria-label="Close" onClick={onClose} />
</Group>
</ModalHeaderRow>
</Modal.Header>
<Modal.Body>
<ConnectionInfoContent
@ -69,6 +75,6 @@ export function ConnectionInfoModal({
/>
</Modal.Body>
</Modal.Content>
</Modal.Root>
</AppModalLg>
);
}

View file

@ -58,6 +58,16 @@ const FieldScroll = ScrollArea.Autosize.withProps({
offsetScrollbars: true,
});
const WarningAlert = Alert.withProps({
variant: "warning",
title: "Warning",
});
const DeclineButton = Button.withProps({
variant: "light",
color: "red",
});
function formatQuoted(text: string): string {
return `\u201C${text}\u201D`;
}
@ -93,16 +103,14 @@ export function ElicitationFormPanel({
disabled={busy}
/>
</FieldScroll>
<Alert variant="warning" title="Warning">
{formatWarning(serverName)}
</Alert>
<WarningAlert>{formatWarning(serverName)}</WarningAlert>
<Group justify="flex-end">
<Button variant="light" onClick={onCancel} disabled={busy}>
Cancel
</Button>
<Button variant="light" color="red" onClick={onDecline} disabled={busy}>
<DeclineButton onClick={onDecline} disabled={busy}>
Decline
</Button>
</DeclineButton>
<Button onClick={onSubmit} disabled={submitDisabled}>
Submit
</Button>

View file

@ -60,6 +60,11 @@ const MetaText = Text.withProps({
c: "dimmed",
});
const WarningAlert = Alert.withProps({
variant: "warning",
title: "Warning",
});
function formatRequestId(id: string): string {
return `Request ID: ${id}`;
}
@ -99,9 +104,9 @@ export function ElicitationUrlPanel({
</Group>
)}
<MetaText>{formatRequestId(requestId)}</MetaText>
<Alert variant="warning" title="Warning">
<WarningAlert>
This will open an external URL. Verify the domain before proceeding.
</Alert>
</WarningAlert>
<Group justify="flex-end">
<Button variant="light" onClick={onCancel} disabled={busy}>
Cancel

View file

@ -97,6 +97,42 @@ const RemoveIcon = ActionIcon.withProps({
color: "red",
});
const HeaderNameInput = TextInput.withProps({
placeholder: "Header name",
rightSectionPointerEvents: "auto",
});
const HeaderValueInput = TextInput.withProps({
placeholder: "Header value",
rightSectionPointerEvents: "auto",
});
const RequestTextarea = Textarea.withProps({
label: "Request",
ff: "monospace",
autosize: true,
minRows: 6,
rightSectionPointerEvents: "auto",
});
const ResponseTextarea = Textarea.withProps({
"aria-label": "Response",
ff: "monospace",
readOnly: true,
autosize: true,
minRows: 4,
});
const ResponseLabel = Text.withProps({
fw: 500,
size: "sm",
});
const ErrorBadge = Badge.withProps({
color: "red",
size: "sm",
});
function formatDuration(ms: number): string {
return `${ms}ms`;
}
@ -239,13 +275,11 @@ export function ExperimentalFeaturesPanel({
{customHeaders.map((header, index) => (
<Group key={index}>
<TextInput
placeholder="Header name"
<HeaderNameInput
value={header.key}
onChange={(e) =>
onHeaderChange(index, e.currentTarget.value, header.value)
}
rightSectionPointerEvents="auto"
rightSection={
header.key ? (
<ClearButton
@ -254,13 +288,11 @@ export function ExperimentalFeaturesPanel({
) : null
}
/>
<TextInput
placeholder="Header value"
<HeaderValueInput
value={header.value}
onChange={(e) =>
onHeaderChange(index, header.key, e.currentTarget.value)
}
rightSectionPointerEvents="auto"
rightSection={
header.value ? (
<ClearButton
@ -279,14 +311,9 @@ export function ExperimentalFeaturesPanel({
<CompactButton onClick={onAddHeader}>+ Add Header</CompactButton>
</Group>
<Textarea
label="Request"
ff="monospace"
<RequestTextarea
value={requestDraft}
onChange={(e) => onRequestChange(e.currentTarget.value)}
autosize
minRows={6}
rightSectionPointerEvents="auto"
rightSection={
requestDraft ? (
<ClearButton onClick={() => onRequestChange("")} />
@ -299,23 +326,10 @@ export function ExperimentalFeaturesPanel({
{response && (
<>
<Group gap="xs">
<Text fw={500} size="sm">
Response
</Text>
{isErrorResponse(response) && (
<Badge color="red" size="sm">
Error
</Badge>
)}
<ResponseLabel>Response</ResponseLabel>
{isErrorResponse(response) && <ErrorBadge>Error</ErrorBadge>}
</Group>
<Textarea
aria-label="Response"
ff="monospace"
value={formatResponse(response)}
readOnly
autosize
minRows={4}
/>
<ResponseTextarea value={formatResponse(response)} />
<Group>
<CompactButton onClick={onCopyResponse}>Copy</CompactButton>
</Group>

View file

@ -83,6 +83,38 @@ const HintText = Text.withProps({
c: "dimmed",
});
const HeaderRow = Group.withProps({
justify: "space-between",
align: "center",
wrap: "nowrap",
});
const ChooseFileButton = Button.withProps({
variant: "default",
size: "xs",
});
const FileContentsTextarea = Textarea.withProps({
"aria-label": "File Contents",
ff: "monospace",
autosize: true,
minRows: 8,
maxRows: 15,
rightSectionPointerEvents: "auto",
});
const DefaultNameInput = TextInput.withProps({
label: "From configuration",
description: "The id derived from the server.json name.",
readOnly: true,
});
const OverrideNameInput = TextInput.withProps({
label: "Override",
description: "Optional. Used instead of the name above.",
rightSectionPointerEvents: "auto",
});
function formatPackageLabel(pkg: PackageInfo): string {
return `${pkg.registryType}: ${pkg.identifier} (${pkg.runtimeHint})`;
}
@ -109,19 +141,20 @@ export function ImportServerJsonPanel({
const hasContent = draft.rawText.trim().length > 0;
return (
<Stack gap="md">
<Group justify="space-between" align="center" wrap="nowrap">
<HeaderRow>
<HintText>Paste server.json content, or load it from a file:</HintText>
{onPickFile ? (
<FileButton accept="application/json,.json" onChange={onPickFile}>
{(props) => (
<Button {...props} variant="default" size="xs">
Choose file…
</Button>
<ChooseFileButton {...props}>Choose file…</ChooseFileButton>
)}
</FileButton>
) : null}
</Group>
</HeaderRow>
{/* Accordions here stay inline: Accordion is a compound,
`multiple`-discriminated generic, so `.withProps()` loses its JSX call
signature (same tooling limit as Box). */}
<Accordion
variant="separated"
transitionDuration={325}
@ -144,15 +177,9 @@ export function ImportServerJsonPanel({
File Contents
</Accordion.Control>
<Accordion.Panel>
<Textarea
aria-label="File Contents"
<FileContentsTextarea
value={draft.rawText}
onChange={(e) => onJsonChange(e.currentTarget.value)}
ff="monospace"
autosize
minRows={8}
maxRows={15}
rightSectionPointerEvents="auto"
rightSection={
draft.rawText ? (
<ClearButton onClick={() => onJsonChange("")} />
@ -237,18 +264,10 @@ export function ImportServerJsonPanel({
<Accordion.Control>Server Name</Accordion.Control>
<Accordion.Panel>
<Stack gap="sm">
<TextInput
label="From configuration"
description="The id derived from the server.json name."
value={defaultServerName ?? ""}
readOnly
/>
<TextInput
label="Override"
description="Optional. Used instead of the name above."
<DefaultNameInput value={defaultServerName ?? ""} />
<OverrideNameInput
value={draft.nameOverride ?? ""}
onChange={(e) => onServerNameChange(e.currentTarget.value)}
rightSectionPointerEvents="auto"
rightSection={
draft.nameOverride ? (
<ClearButton onClick={() => onServerNameChange("")} />

View file

@ -48,6 +48,16 @@ const HelpText = Text.withProps({
c: "var(--inspector-text-secondary)",
});
const ActiveLevelSelect = Select.withProps({
"aria-label": "Set Active Level",
flex: 1,
});
const PerRequestLevelSelect = Select.withProps({
"aria-label": "Log Level per Request",
allowDeselect: false,
});
const LEVEL_OPTIONS = LOG_LEVELS.map((level) => ({
value: level,
label: level,
@ -85,9 +95,7 @@ const LegacyLevelControl = ({
<>
<Title order={5}>Set Active Level</Title>
<Group wrap="nowrap">
<Select
aria-label="Set Active Level"
flex={1}
<ActiveLevelSelect
data={LEVEL_OPTIONS}
value={currentLevel}
onChange={(value) => {
@ -117,14 +125,12 @@ const ModernLevelControl = ({
choose is stamped on every request, and logs arrive on the originating
request&apos;s stream. Choose Off to stop requesting logs.
</HelpText>
<Select
aria-label="Log Level per Request"
<PerRequestLevelSelect
data={[
{ value: MODERN_OFF_VALUE, label: "Off (no logs)" },
...LEVEL_OPTIONS,
]}
value={modernLogLevel ?? MODERN_OFF_VALUE}
allowDeselect={false}
onChange={(value) => {
if (value === MODERN_OFF_VALUE) {
onSetModernLogLevel?.(null);

View file

@ -39,6 +39,11 @@ const EmptyCenter = Stack.withProps({
justify: "center",
});
const HeaderRow = Group.withProps({
justify: "space-between",
mb: "sm",
});
function formatData(data: unknown): string {
if (data === undefined || data === null) return "";
if (typeof data === "string") return data;
@ -87,7 +92,7 @@ export function LogStreamPanel({
return (
<PanelContainer>
<Group justify="space-between" mb="sm">
<HeaderRow>
<Title order={4}>Log Stream</Title>
<Group>
<SortToggle
@ -110,7 +115,7 @@ export function LogStreamPanel({
Export
</Button>
</Group>
</Group>
</HeaderRow>
{filteredEntries.length > 0 ? (
<EmbeddableScrollArea embedded={embedded} viewportRef={viewportRef}>
<Stack gap="xs">

View file

@ -7,6 +7,13 @@ const SubtleButton = Button.withProps({
size: "xs",
});
// h5 (not h6) to sit one level below the screen's h4 heading (avoids an
// axe `heading-order` skip); `size="h6"` preserves the visual size.
const SectionTitle = Title.withProps({
order: 5,
size: "h6",
});
// The two message directions, in display order. Label + color mirror the
// MessageDirectionBadge: outgoing (client → server) is green, incoming
// (server → client) is violet.
@ -39,11 +46,7 @@ export function MessageDirectionFilter({
return (
<>
<Group justify="space-between">
{/* h5 (not h6) to sit one level below the screen's h4 heading (avoids an
axe `heading-order` skip); `size="h6"` preserves the visual size. */}
<Title order={5} size="h6">
Filter by Message Direction
</Title>
<SectionTitle>Filter by Message Direction</SectionTitle>
<SubtleButton onClick={onToggleAllDirections}>
{Object.values(visibleDirections).every(Boolean)
? "Deselect All"

View file

@ -35,6 +35,11 @@ const SearchInput = TextInput.withProps({
"aria-label": "Search",
});
const ScreenTabs = SegmentedControl.withProps({
size: "sm",
"aria-label": "Monitoring screen",
});
/**
* Tab row + search for the pinned monitoring sidebar (#1616). Renders only the
* currently-available monitor tabs (the caller filters by capability), so it
@ -52,13 +57,7 @@ export function MonitoringControls({
}: MonitoringControlsProps) {
return (
<ControlsBar>
<SegmentedControl
size="sm"
value={value}
onChange={onChange}
data={tabs}
aria-label="Monitoring screen"
/>
<ScreenTabs value={value} onChange={onChange} data={tabs} />
<SearchInput
value={searchValue}
onChange={(e) => onSearchChange(e.currentTarget.value)}

View file

@ -69,6 +69,11 @@ const RoundLabel = Text.withProps({
c: "dimmed",
});
const RoundCountBadge = Badge.withProps({
color: "blue",
variant: "outline",
});
type ConversationStatus = "pending" | "awaiting" | "error" | "complete";
// The conversation's status is that of its final (latest) round: still awaiting
@ -145,14 +150,14 @@ export function MrtrConversation({
<HeaderLeft>
<MethodBadge method={method} />
<MrtrLabel>MRTR</MrtrLabel>
<Badge color="blue" variant="outline">
<RoundCountBadge>
{formatRoundsLabel(ordered.length)}
</Badge>
</RoundCountBadge>
</HeaderLeft>
<HeaderRight>
<Badge
color={statusColor(status)}
variant="status"
color={statusColor(status)}
data-testid="mrtr-status"
>
{statusLabel(status)}

View file

@ -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
@ -188,6 +201,80 @@ const MismatchMarker = Text.withProps({
c: "var(--inspector-danger-text)",
});
// The decoded value plus its optional base64 / mismatch markers, on one line.
const ValueCellRow = Group.withProps({
gap: "xs",
wrap: "nowrap",
align: "center",
});
// Tooltip for a base64 sentinel value or a header/body mismatch.
const SentinelTooltip = Tooltip.withProps({
withArrow: true,
multiline: true,
w: 280,
});
const Base64Badge = Badge.withProps({
size: "xs",
color: "gray",
variant: "light",
});
const HeadersGrid = Table.withProps({
striped: true,
withColumnBorders: true,
fz: "xs",
});
// OAuth flow-phase chip for an `auth`-category request.
const PhaseBadge = Badge.withProps({
color: "violet",
variant: "light",
});
// Compact-header URL row: copy button, horizontal URL scroll, expand toggle.
const CompactUrlRow = Group.withProps({
gap: "xs",
wrap: "nowrap",
justify: "space-between",
});
const UrlScrollArea = ScrollArea.withProps({
scrollbarSize: 6,
flex: 1,
miw: 0,
// The URL scrolls horizontally but has no focusable child, so make the
// viewport itself keyboard-scrollable (WCAG SC 2.1.1). Scrollbar auto-hides
// via the `type="scroll"` theme default.
viewportProps: { tabIndex: 0 },
});
// Wide-header left cluster: timestamp + badges + URL, shrinking to truncate.
const WideHeaderCluster = Group.withProps({
gap: "sm",
wrap: "nowrap",
miw: 0,
flex: 1,
});
// Trailing expand-toggle row in the wide layout.
const ToggleRow = Group.withProps({
gap: "xs",
justify: "flex-end",
});
const MonoSpan = Text.withProps({
span: true,
ff: "monospace",
});
const ErrorText = Text.withProps({
size: "xs",
ff: "monospace",
c: "red",
});
function HeaderValueCell({
name,
value,
@ -205,39 +292,27 @@ function HeaderValueCell({
const mismatch = consistency !== undefined && !consistency.ok;
return (
<Group gap="xs" wrap="nowrap" align="center">
<ValueCellRow>
{mismatch ? (
<MismatchValueText>{decoded.value}</MismatchValueText>
) : (
<HeaderValueText>{decoded.value}</HeaderValueText>
)}
{decoded.encoded && (
<Tooltip
label={`base64 sentinel — raw: ${decoded.raw}`}
withArrow
multiline
w={280}
>
<Badge size="xs" color="gray" variant="light">
base64
</Badge>
</Tooltip>
<SentinelTooltip label={`base64 sentinel — raw: ${decoded.raw}`}>
<Base64Badge>base64</Base64Badge>
</SentinelTooltip>
)}
{mismatch && (
<Tooltip
label={`Expected: ${consistency.expected}`}
withArrow
multiline
w={280}
>
<SentinelTooltip label={`Expected: ${consistency.expected}`}>
<MismatchMarker
aria-label={`Header does not match body; expected ${consistency.expected}`}
>
<RiErrorWarningLine />
</MismatchMarker>
</Tooltip>
</SentinelTooltip>
)}
</Group>
</ValueCellRow>
);
}
@ -251,15 +326,11 @@ function HeadersTable({
}) {
const rows = Object.entries(headers);
if (rows.length === 0) {
return (
<Text size="xs" c="dimmed">
(none)
</Text>
);
return <DimmedNote>(none)</DimmedNote>;
}
const byHeader = new Map((consistency ?? []).map((row) => [row.header, row]));
return (
<Table striped withColumnBorders fz="xs">
<HeadersGrid>
<Table.Tbody>
{rows.map(([name, value]) => (
<Table.Tr key={name}>
@ -280,7 +351,7 @@ function HeadersTable({
</Table.Tr>
))}
</Table.Tbody>
</Table>
</HeadersGrid>
);
}
@ -334,9 +405,9 @@ function BodyPreview({
if (tooLarge) {
return (
<Text size="xs" c="dimmed">
<DimmedNote>
Body too large to preview ({body.length} characters)
</Text>
</DimmedNote>
);
}
@ -348,9 +419,9 @@ function BodyPreview({
return (
<Stack gap="xs">
<Group gap="xs">
<Text size="xs" c="dimmed" aria-live="polite">
<DimmedNote aria-live="polite">
{revealed ? "Secrets revealed" : "Secrets hidden"}
</Text>
</DimmedNote>
<RevealButton
onClick={() => setRevealed((v) => !v)}
aria-label={
@ -408,9 +479,7 @@ export function NetworkEntry({
const oauthPhase =
entry.category === "auth" ? oauthNetworkPhase(entry.url) : undefined;
const phaseBadge = oauthPhase ? (
<Badge color="violet" variant="light">
{oauthNetworkPhaseLabel(oauthPhase)}
</Badge>
<PhaseBadge>{oauthNetworkPhaseLabel(oauthPhase)}</PhaseBadge>
) : null;
// Request header/body cross-checks so a mirrored-header mismatch is visible
@ -458,26 +527,18 @@ export function NetworkEntry({
</HeaderCluster>
<ControlsCluster>{metaBadges}</ControlsCluster>
</HeaderRow>
<Group gap="xs" wrap="nowrap" justify="space-between">
<CompactUrlRow>
<CopyButton value={entry.url} />
<ScrollArea
scrollbarSize={6}
flex={1}
miw={0}
// The URL scrolls horizontally but has no focusable child, so
// make the viewport itself keyboard-scrollable (WCAG SC 2.1.1).
// Scrollbar auto-hides via the `type="scroll"` theme default.
viewportProps={{ tabIndex: 0 }}
>
<UrlScrollArea>
<UrlScroll>{entry.url}</UrlScroll>
</ScrollArea>
</UrlScrollArea>
{expandToggle}
</Group>
</CompactUrlRow>
</Stack>
) : (
<>
<HeaderRow>
<Group gap="sm" wrap="nowrap" miw={0} flex={1}>
<WideHeaderCluster>
<TimestampText>
{formatTimestamp(entry.timestamp)}
</TimestampText>
@ -486,15 +547,11 @@ export function NetworkEntry({
{phaseBadge}
<CopyButton value={entry.url} />
<UrlText>{entry.url}</UrlText>
</Group>
<Group gap="sm" wrap="nowrap">
{metaBadges}
</Group>
</WideHeaderCluster>
<ControlsCluster>{metaBadges}</ControlsCluster>
</HeaderRow>
<Group gap="xs" justify="flex-end">
{expandToggle}
</Group>
<ToggleRow>{expandToggle}</ToggleRow>
</>
)}
@ -506,17 +563,12 @@ export function NetworkEntry({
<Text size="xs">
Cancellation appears as a connection abort — the modern
transport aborts the request stream instead of sending a{" "}
<Text span ff="monospace">
notifications/cancelled
</Text>{" "}
frame (SEP-2575).
<MonoSpan>notifications/cancelled</MonoSpan> frame (SEP-2575).
</Text>
</CancellationAlert>
)}
<Stack gap="xs">
<Text size="sm" fw={500}>
Request Headers
</Text>
<SectionLabel>Request Headers</SectionLabel>
<HeadersTable
headers={entry.requestHeaders}
consistency={headerConsistency}
@ -524,9 +576,7 @@ export function NetworkEntry({
</Stack>
{entry.requestBody && (
<Stack gap="xs">
<Text size="sm" fw={500}>
Request Body
</Text>
<SectionLabel>Request Body</SectionLabel>
<BodyPreview
key={`${entry.requestHeaders["content-type"] ?? ""}|${entry.requestBody}`}
body={entry.requestBody}
@ -536,17 +586,13 @@ export function NetworkEntry({
)}
{entry.responseHeaders && (
<Stack gap="xs">
<Text size="sm" fw={500}>
Response Headers
</Text>
<SectionLabel>Response Headers</SectionLabel>
<HeadersTable headers={entry.responseHeaders} />
</Stack>
)}
{entry.responseStatus !== undefined && (
<Stack gap="xs">
<Text size="sm" fw={500}>
Response Body
</Text>
<SectionLabel>Response Body</SectionLabel>
{entry.responseBody ? (
<BodyPreview
key={`${entry.responseHeaders?.["content-type"] ?? ""}|${entry.responseBody}`}
@ -554,22 +600,18 @@ export function NetworkEntry({
contentType={entry.responseHeaders?.["content-type"]}
/>
) : (
<Text size="xs" c="dimmed">
<DimmedNote>
{isLongLivedStream(entry)
? "Long-lived stream — body not captured"
: "(empty)"}
</Text>
</DimmedNote>
)}
</Stack>
)}
{entry.error && (
<Stack gap="xs">
<Text size="sm" fw={500} c="red">
Error
</Text>
<Text size="xs" ff="monospace" c="red">
{entry.error}
</Text>
<SectionLabel c="red">Error</SectionLabel>
<ErrorText>{entry.error}</ErrorText>
</Stack>
)}
</Stack>

View file

@ -51,6 +51,12 @@ const EmptyState = Text.withProps({
ta: "center",
});
// Panel header: title on the left, action controls on the right.
const HeaderRow = Group.withProps({
justify: "space-between",
mb: "sm",
});
function formatTitle(count: number): string {
return `Requests (${count})`;
}
@ -125,7 +131,7 @@ export function NetworkStreamPanel({
return (
<PanelContainer>
<Group justify="space-between" mb="sm">
<HeaderRow>
<Title order={4}>{formatTitle(filteredEntries.length)}</Title>
<Group gap="xs">
<SortToggle
@ -143,7 +149,7 @@ export function NetworkStreamPanel({
<ListToggle compact={compact} onToggle={onToggleCompact} />
)}
</Group>
</Group>
</HeaderRow>
{!hasResults ? (
<EmptyCenter>

View file

@ -16,6 +16,24 @@ export interface OutputValidationModalProps {
message?: string;
}
const DetailModal = Modal.withProps({
withCloseButton: false,
size: "lg",
centered: true,
});
const HeaderRow = Group.withProps({
justify: "space-between",
wrap: "nowrap",
});
const DimText = Text.withProps({ size: "sm", c: "dimmed" });
const DetailsTextarea = Textarea.withProps({
"aria-label": "Validation details",
readOnly: true,
autosize: true,
minRows: 6,
maxRows: 18,
});
/**
* Shows the full output-schema validation error for an MCP App tool result.
* The inspector still renders the app (the result is forwarded verbatim to the
@ -30,33 +48,20 @@ export function OutputValidationModal({
message,
}: OutputValidationModalProps) {
return (
<Modal
opened={opened}
onClose={onClose}
withCloseButton={false}
size="lg"
centered
>
<DetailModal opened={opened} onClose={onClose}>
<Stack gap="md">
<Group justify="space-between" wrap="nowrap">
<HeaderRow>
{/* `Modal.Title` names the dialog (wires `aria-labelledby`). */}
<Modal.Title flex={1}>Output schema validation</Modal.Title>
<CloseButton aria-label="Close" onClick={onClose} />
</Group>
<Text size="sm" c="dimmed">
</HeaderRow>
<DimText>
{toolName
? `"${toolName}" returned structuredContent that does not match its declared outputSchema. The inspector renders the app anyway, but strict MCP clients may refuse to display it.`
: "The tool result's structuredContent does not match the declared outputSchema. The inspector renders the app anyway, but strict MCP clients may refuse to display it."}
</Text>
<Textarea
aria-label="Validation details"
readOnly
autosize
minRows={6}
maxRows={18}
value={message ?? ""}
/>
</DimText>
<DetailsTextarea value={message ?? ""} />
</Stack>
</Modal>
</DetailModal>
);
}

View file

@ -73,6 +73,13 @@ const TitleText = Text.withProps({ fw: 600 });
const QueueLabel = Text.withProps({ size: "xs", c: "dimmed" });
const PendingRequestModal = Modal.withProps({
withCloseButton: false,
closeOnClickOutside: false,
closeOnEscape: false,
size: "lg",
});
/**
* The stub result pre-filled into a sampling draft. "Send Response" sends this
* as-is when untouched, or whatever the user edited it into.
@ -245,13 +252,9 @@ export function PendingClientRequestModal({
onElicitationRespond,
}: PendingClientRequestModalProps) {
return (
<Modal
<PendingRequestModal
opened={request !== null}
onClose={ignoreClose}
withCloseButton={false}
closeOnClickOutside={false}
closeOnEscape={false}
size="lg"
title={
request && (
<TitleRow>
@ -289,6 +292,6 @@ export function PendingClientRequestModal({
onRespond={onElicitationRespond}
/>
)}
</Modal>
</PendingRequestModal>
);
}

View file

@ -10,13 +10,18 @@ function formatTitle(count: number): string {
return `Pending Client Requests (${count})`;
}
const PendingAlert = Alert.withProps({
color: "blue",
variant: "light",
});
export function PendingClientRequests({
count,
children,
}: PendingClientRequestsProps) {
return (
<Alert color="blue" variant="light" title={formatTitle(count)}>
<PendingAlert title={formatTitle(count)}>
<Stack gap="md">{children}</Stack>
</Alert>
</PendingAlert>
);
}

Some files were not shown because too many files have changed in this diff Show more