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:
parent
8aba3122c5
commit
da37692485
49 changed files with 1567 additions and 991 deletions
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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("")} />
|
||||
|
|
|
|||
|
|
@ -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'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);
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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)}
|
||||
|
|
|
|||
|
|
@ -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)}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
Loading…
Reference in a new issue