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
6 changed files with 1567 additions and 991 deletions

View file

@ -107,10 +107,13 @@ const SidebarCard = Card.withProps({
// `variant="preview"` (overflow: hidden) keeps the full-height card from
// bleeding past the viewport: the running app's iframe fills it, and the
// app-input form scrolls internally (see AppDetailPanel's PanelScroll).
// `flex: 1` + `h: "100%"` make it fill the screen column (both call sites do).
const ContentCard = Card.withProps({
withBorder: true,
padding: "lg",
variant: "preview",
flex: 1,
h: "100%",
});
const EmptyState = Text.withProps({
@ -152,6 +155,17 @@ const HeaderActions = Group.withProps({
wrap: "nowrap",
});
const BackToInputButton = Button.withProps({
variant: "subtle",
size: "sm",
leftSection: <MdArrowBack aria-hidden size={16} />,
});
const CloseIconButton = ActionIcon.withProps({
variant: "subtle",
"aria-label": "Close",
});
// The host-controlled box the running app sits within. Its size is driven by
// the host's layout (window resize, sidebar toggle, maximize) and NOT by the
// view's reported content height — that drives the inner RendererFrame — so the
@ -552,7 +566,7 @@ export function AppsScreen({
if (!sandboxPath) {
return (
<ScreenLayout>
<ContentCard flex={1} h="100%">
<ContentCard>
<EmptyState>
MCP Apps are unavailable — the sandbox could not be reached.
</EmptyState>
@ -589,8 +603,6 @@ export function AppsScreen({
)}
<ContentCard
flex={1}
h="100%"
data-testid="apps-form"
data-app-status={running ? appStatus : "idle"}
data-app-error={running ? appError?.message : undefined}
@ -608,14 +620,9 @@ export function AppsScreen({
</HeaderLabel>
<HeaderActions>
{running && selectedHasFields && (
<Button
variant="subtle"
size="sm"
leftSection={<MdArrowBack aria-hidden size={16} />}
onClick={handleBackToInput}
>
<BackToInputButton onClick={handleBackToInput}>
Back to Input
</Button>
</BackToInputButton>
)}
{running && (
<Tooltip label={maximized ? "Restore" : "Maximize"}>
@ -633,13 +640,9 @@ export function AppsScreen({
</Tooltip>
)}
<Tooltip label="Close">
<ActionIcon
variant="subtle"
onClick={handleClose}
aria-label="Close"
>
<CloseIconButton onClick={handleClose}>
<MdClose aria-hidden size={20} />
</ActionIcon>
</CloseIconButton>
</Tooltip>
</HeaderActions>
</HeaderRow>

View file

@ -62,6 +62,20 @@ const PanelContainer = Paper.withProps({
variant: "panel",
});
// Header row inside the panel: "Server Console" title beside the sort/clear/
// export controls.
const PanelHeaderRow = Group.withProps({
justify: "space-between",
mb: "sm",
});
// Sidebar filter field; `rightSectionPointerEvents="auto"` lets the clear button
// in the right section stay clickable.
const SearchInput = TextInput.withProps({
placeholder: "Search...",
rightSectionPointerEvents: "auto",
});
const EmptyCenter = Stack.withProps({
flex: 1,
align: "center",
@ -133,13 +147,11 @@ export function ConsoleScreen({
<SidebarCard>
<Stack gap="md">
<Title order={4}>Console</Title>
<TextInput
placeholder="Search..."
<SearchInput
value={filterText}
onChange={(e) =>
onUiChange({ filterText: e.currentTarget.value })
}
rightSectionPointerEvents="auto"
rightSection={
filterText ? (
<ClearButton
@ -153,7 +165,7 @@ export function ConsoleScreen({
</Sidebar>
)}
<PanelContainer>
<Group justify="space-between" mb="sm">
<PanelHeaderRow>
<Title order={4}>Server Console</Title>
<Group>
<SortToggle
@ -176,7 +188,7 @@ export function ConsoleScreen({
Export
</Button>
</Group>
</Group>
</PanelHeaderRow>
{filteredEntries.length > 0 ? (
<EmbeddableScrollArea embedded={embedded} viewportRef={viewportRef}>
<Stack gap="xs">

View file

@ -109,6 +109,18 @@ const EmptyState = Text.withProps({
py: "xl",
});
// Centered loader/status column shown while a prompt is being fetched.
const CenteredStatus = Stack.withProps({
align: "center",
py: "xl",
});
const PromptErrorAlert = Alert.withProps({
color: "red",
variant: "light",
title: "Prompt Error",
});
const SCROLL_MAX_HEIGHT =
"calc(100dvh - var(--app-shell-header-height, 0px) - var(--app-shell-footer-height, 0px) - var(--mantine-spacing-xl) * 2)";
@ -212,10 +224,10 @@ export function PromptsScreen({
onClick={handleClosePreview}
/>
</Group>
<Stack align="center" py="xl">
<CenteredStatus>
<Loader size="sm" />
<Text c="dimmed">Loading prompt...</Text>
</Stack>
</CenteredStatus>
</Stack>
</PreviewCard>
);
@ -230,9 +242,9 @@ export function PromptsScreen({
onClick={handleClosePreview}
/>
</Group>
<Alert color="red" variant="light" title="Prompt Error">
<PromptErrorAlert>
{getPromptState.error ?? "Failed to get prompt"}
</Alert>
</PromptErrorAlert>
</Stack>
</PreviewCard>
);

View file

@ -142,6 +142,18 @@ const EmptyState = Text.withProps({
py: "xl",
});
// Centered loader/status column shown while a resource is being read.
const CenteredStatus = Stack.withProps({
align: "center",
py: "xl",
});
const ReadErrorAlert = Alert.withProps({
color: "red",
variant: "light",
title: "Read Error",
});
const SCROLL_MAX_HEIGHT =
"calc(100dvh - var(--app-shell-header-height, 0px) - var(--app-shell-footer-height, 0px) - var(--mantine-spacing-xl) * 2)";
@ -248,10 +260,10 @@ export function ResourcesScreen({
onClick={handleClosePreview}
/>
</Group>
<Stack align="center" py="xl">
<CenteredStatus>
<Loader size="sm" />
<Text c="dimmed">Reading resource...</Text>
</Stack>
</CenteredStatus>
</Stack>
</PreviewCard>
);
@ -267,9 +279,9 @@ export function ResourcesScreen({
onClick={handleClosePreview}
/>
</Group>
<Alert color="red" variant="light" title="Read Error">
<ReadErrorAlert>
{readState.error ?? "Failed to read resource"}
</Alert>
</ReadErrorAlert>
</Stack>
</PreviewCard>
);

View file

@ -134,13 +134,57 @@ 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;
// Read-only banner shown above the panel when the server list isn't writable.
const ReadOnlyAlert = Alert.withProps({
color: "gray",
variant: "light",
title: "Read-only session",
});
// Semantic h3 (visually h4) so it doesn't skip a level under the disconnected
// header's h2 "MCP Inspector" (heading-order a11y). The other panels render h4
// directly because they only show while connected, when the header carries no
// heading to skip under.
const PanelTitle = Title.withProps({
order: 3,
size: "h4",
});
// The server card grid. Container queries (not viewport) so column count tracks
// the actual space the grid occupies. The 2- and 3-column thresholds (1040px /
// 1560px) keep each card ≥ ~505px wide at the switch point: container =
// N·card + (N−1)·gap with gap = lg spacing (20px), i.e. 1040 = 2·510+20 and
// 1560 = 3·507+40. Below ~500px a connected card's action row (Clone/Edit/Remove
// + Connection Info/Settings, ~440px with padding) wraps and stacks, making that
// card taller than its neighbours; dropping to fewer, wider columns instead keeps
// every card the same height. (#1528)
const ServerGrid = SimpleGrid.withProps({
type: "container",
cols: { base: 1, "1040px": 2, "1560px": 3 },
spacing: "lg",
styles: GRID_SURFACE_STYLES,
});
// Same scrollbar treatment as the Protocol/Network/Logging list panels (#1474):
// reserve a gutter so the bar never overlays the right edge of the server cards
// (occluding their action icons / status badges), and only show it while
// actively scrolling rather than popping in on hover.
const CardScrollArea = ScrollArea.Autosize.withProps({
mah: "100%",
type: "scroll",
offsetScrollbars: true,
});
export function ServerListScreen({
servers,
writable = true,
@ -217,22 +261,7 @@ export function ServerListScreen({
});
const grid = (
<SimpleGrid
// Container queries (not viewport) so column count tracks the actual
// space the grid occupies. The 2- and 3-column thresholds (1040px /
// 1560px) keep each card ≥ ~505px wide at the switch point: container =
// N·card + (N−1)·gap with gap = lg spacing (20px), i.e. 1040 = 2·510+20
// and 1560 = 3·507+40. Below ~500px a connected card's action row
// (Clone/Edit/Remove + Connection Info/Settings, ~440px with padding)
// wraps and stacks, making that card taller than its neighbours; dropping
// to fewer, wider columns instead keeps every card the same height.
// (#1528)
type="container"
cols={{ base: 1, "1040px": 2, "1560px": 3 }}
spacing="lg"
className="grid-align-start"
styles={GRID_SURFACE_STYLES}
>
<ServerGrid>
{servers.map((server) =>
reorderable ? (
<SortableServerCard key={server.id} {...cardProps(server)} />
@ -240,27 +269,21 @@ export function ServerListScreen({
<ServerCard key={server.id} {...cardProps(server)} />
),
)}
</SimpleGrid>
</ServerGrid>
);
return (
<ScreenLayout>
{!writable && (
<Alert color="gray" variant="light" title="Read-only session">
<ReadOnlyAlert>
This server list was launched with <Code>--config</Code> or an ad-hoc
server and can't be edited here. Changes won't be saved. Use{" "}
<Code>--catalog</Code> (or no flag) to manage a writable catalog.
</Alert>
</ReadOnlyAlert>
)}
<PanelContainer>
<PanelHeader>
{/* Semantic h3 (visually h4) so it doesn't skip a level under the
disconnected header's h2 "MCP Inspector" (heading-order a11y). The
other panels render h4 directly because they only show while
connected, when the header carries no heading to skip under. */}
<Title order={3} size="h4">
Servers
</Title>
<PanelTitle>Servers</PanelTitle>
<ServerListControls
serverCount={servers.length}
compact={compact}
@ -281,16 +304,7 @@ export function ServerListScreen({
</EmptyCenter>
) : (
<CardScrollWrap>
<ScrollArea.Autosize
mah="100%"
// Same scrollbar treatment as the Protocol/Network/Logging list
// panels (#1474): reserve a gutter so the bar never overlays the
// right edge of the server cards (occluding their action icons /
// status badges), and only show it while actively scrolling
// rather than popping in on hover.
type="scroll"
offsetScrollbars
>
<CardScrollArea>
{reorderable ? (
<DndContext
sensors={sensors}
@ -305,7 +319,7 @@ export function ServerListScreen({
) : (
grid
)}
</ScrollArea.Autosize>
</CardScrollArea>
</CardScrollWrap>
)}
</PanelContainer>

View file

@ -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<string, unknown> = {};
/* 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 (
<ScreenLayout>
<Sidebar>
@ -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<string, unknown> = {};
/* 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}
/>
</SidebarCard>
</Sidebar>