refine InlineError, ServerCard, HistoryEntry, and LoggingScreen

- InlineError: show more/less toggle on same line as message, collapsible
  details, doc link as button
- ServerCard: use ContentViewer with CopyButton for command, remove
  onCopyCommand prop, combine action rows, add responsive Card variant
- HistoryEntry: use ContentViewer with CopyButton for JSON blocks, swap
  duration and status badge positions
- LoggingScreen: stretch log stream panel to full height, center empty
  message vertically

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
cliffhall 2026-03-23 12:28:15 -04:00
commit 3c25bdfbd5
9 changed files with 136 additions and 105 deletions

View file

@ -103,3 +103,15 @@
.inspector-spin { .inspector-spin {
animation: inspector-spin 1s linear infinite; animation: inspector-spin 1s linear infinite;
} }
/* ── Responsive card ────────────────────────────────────── */
.card-responsive {
width: 100%;
}
@media (min-width: 62em) {
.card-responsive {
width: 50%;
}
}

View file

@ -1,11 +1,11 @@
import { Alert, Anchor, Group, Spoiler, Stack, Text } from "@mantine/core"; import { Alert, Button, Collapse, Group, Stack, Text } from "@mantine/core";
import { useState } from "react";
export interface InlineErrorProps { export interface InlineErrorProps {
message: string; message: string;
details?: string; details?: string;
retryCount?: number; retryCount?: number;
maxRetries?: number; maxRetries?: number;
onShowMore?: () => void;
docLink?: string; docLink?: string;
} }
@ -16,33 +16,52 @@ export function InlineError({
maxRetries, maxRetries,
docLink, docLink,
}: InlineErrorProps) { }: InlineErrorProps) {
const hasDetails = const [expanded, setExpanded] = useState(false);
retryCount !== undefined || details !== undefined || docLink !== undefined; const hasExpandable = details !== undefined || docLink !== undefined;
return ( return (
<Alert color="red" variant="light" title={message}> <Alert color="red" variant="light">
{hasDetails && ( <Stack gap="xs">
<Stack gap="xs"> <Group justify="space-between" wrap="nowrap">
{retryCount !== undefined && ( <Group gap="sm" wrap="nowrap">
<Text size="sm"> <Text size="sm" fw={500} c="red">
Retry attempt {retryCount} {message}
{maxRetries !== undefined && ` of ${maxRetries}`}
</Text> </Text>
{retryCount !== undefined && (
<Text size="sm" c="dimmed">
Retry attempt {retryCount}
{maxRetries !== undefined && ` of ${maxRetries}`}
</Text>
)}
</Group>
{hasExpandable && (
<Button
variant="transparent"
size="compact-xs"
c="dark"
onClick={() => setExpanded((v) => !v)}
>
{expanded ? "Show less" : "Show more"}
</Button>
)} )}
{details && ( </Group>
<Spoiler maxHeight={0} showLabel="Show more" hideLabel="Show less"> <Collapse in={expanded}>
<Text size="sm">{details}</Text> <Stack gap="xs">
</Spoiler> {details && <Text size="sm">{details}</Text>}
)} {docLink && (
{docLink && ( <Button
<Group> variant="light"
<Anchor href={docLink} target="_blank" size="sm"> size="xs"
View Troubleshooting Guide &rarr; component="a"
</Anchor> href={docLink}
</Group> target="_blank"
)} >
</Stack> View Troubleshooting Guide
)} </Button>
)}
</Stack>
</Collapse>
</Stack>
</Alert> </Alert>
); );
} }

View file

@ -20,7 +20,6 @@ type Story = StoryObj<typeof HomeLayout>;
function makeServerCallbacks(): Pick< function makeServerCallbacks(): Pick<
ServerCardProps, ServerCardProps,
| "onToggleConnection" | "onToggleConnection"
| "onCopyCommand"
| "onServerInfo" | "onServerInfo"
| "onSettings" | "onSettings"
| "onEdit" | "onEdit"
@ -29,7 +28,6 @@ function makeServerCallbacks(): Pick<
> { > {
return { return {
onToggleConnection: fn(), onToggleConnection: fn(),
onCopyCommand: fn(),
onServerInfo: fn(), onServerInfo: fn(),
onSettings: fn(), onSettings: fn(),
onEdit: fn(), onEdit: fn(),

View file

@ -2,12 +2,12 @@ import {
Badge, Badge,
Button, Button,
Card, Card,
Code,
Collapse, Collapse,
Group, Group,
Stack, Stack,
Text, Text,
} from "@mantine/core"; } from "@mantine/core";
import { ContentViewer } from "../../atoms/ContentViewer/ContentViewer";
export interface HistoryChildEntry { export interface HistoryChildEntry {
timestamp: string; timestamp: string;
@ -66,12 +66,12 @@ export function HistoryEntry({
)} )}
</Group> </Group>
<Group gap="sm"> <Group gap="sm">
<Badge color={status === "success" ? "green" : "red"}>
{status === "success" ? "OK" : "Error"}
</Badge>
<Text size="sm" c="dimmed"> <Text size="sm" c="dimmed">
{durationMs}ms {durationMs}ms
</Text> </Text>
<Badge color={status === "success" ? "green" : "red"}>
{status === "success" ? "OK" : "Error"}
</Badge>
</Group> </Group>
</Group> </Group>
@ -93,13 +93,21 @@ export function HistoryEntry({
{parameters && ( {parameters && (
<Stack gap="xs"> <Stack gap="xs">
<Text size="sm">Parameters:</Text> <Text size="sm">Parameters:</Text>
<Code block>{JSON.stringify(parameters, null, 2)}</Code> <ContentViewer
type="json"
content={JSON.stringify(parameters)}
copyable
/>
</Stack> </Stack>
)} )}
{response && ( {response && (
<Stack gap="xs"> <Stack gap="xs">
<Text size="sm">Response:</Text> <Text size="sm">Response:</Text>
<Code block>{JSON.stringify(response, null, 2)}</Code> <ContentViewer
type="json"
content={JSON.stringify(response)}
copyable
/>
</Stack> </Stack>
)} )}
{childEntries && childEntries.length > 0 && ( {childEntries && childEntries.length > 0 && (

View file

@ -7,7 +7,6 @@ const meta: Meta<typeof ServerCard> = {
component: ServerCard, component: ServerCard,
args: { args: {
onToggleConnection: fn(), onToggleConnection: fn(),
onCopyCommand: fn(),
onServerInfo: fn(), onServerInfo: fn(),
onSettings: fn(), onSettings: fn(),
onEdit: fn(), onEdit: fn(),

View file

@ -2,7 +2,6 @@ import {
Badge, Badge,
Button, Button,
Card, Card,
Code,
Group, Group,
Menu, Menu,
Stack, Stack,
@ -11,7 +10,7 @@ import {
import { StatusIndicator } from "../../atoms/StatusIndicator/StatusIndicator"; import { StatusIndicator } from "../../atoms/StatusIndicator/StatusIndicator";
import { TransportBadge } from "../../atoms/TransportBadge/TransportBadge"; import { TransportBadge } from "../../atoms/TransportBadge/TransportBadge";
import { ConnectionToggle } from "../../atoms/ConnectionToggle/ConnectionToggle"; import { ConnectionToggle } from "../../atoms/ConnectionToggle/ConnectionToggle";
import { CopyButton } from "../../atoms/CopyButton/CopyButton"; import { ContentViewer } from "../../atoms/ContentViewer/ContentViewer";
import { InlineError } from "../../atoms/InlineError/InlineError"; import { InlineError } from "../../atoms/InlineError/InlineError";
export interface ServerCardProps { export interface ServerCardProps {
@ -25,7 +24,6 @@ export interface ServerCardProps {
error?: { message: string; details?: string }; error?: { message: string; details?: string };
canTestClientFeatures: boolean; canTestClientFeatures: boolean;
onToggleConnection: (connect: boolean) => void; onToggleConnection: (connect: boolean) => void;
onCopyCommand: () => void;
onServerInfo: () => void; onServerInfo: () => void;
onSettings: () => void; onSettings: () => void;
onEdit: () => void; onEdit: () => void;
@ -76,7 +74,7 @@ export function ServerCard({
return ( return (
<Card withBorder padding="lg"> <Card withBorder padding="lg">
<Stack gap="sm"> <Stack gap="sm">
<Group justify="space-between"> <Group justify="space-between" wrap="wrap">
<HeaderLeft> <HeaderLeft>
<Text fw={600} size="lg"> <Text fw={600} size="lg">
{name} {name}
@ -101,14 +99,7 @@ export function ServerCard({
</Text> </Text>
</Group> </Group>
<Group gap="xs"> <ContentViewer type="text" content={command} copyable />
<Code>
<Text lineClamp={1} size="sm">
{command}
</Text>
</Code>
<CopyButton value={command} />
</Group>
{error && ( {error && (
<InlineError <InlineError
@ -118,49 +109,50 @@ export function ServerCard({
/> />
)} )}
<ActionsRow> <Group justify="space-between">
<Button variant="subtle" size="xs" onClick={onServerInfo}> <ActionsRow>
Server Info <Button variant="subtle" size="xs" onClick={onClone}>
</Button> Clone
<Button variant="subtle" size="xs" onClick={onSettings}> </Button>
Settings <Button variant="subtle" size="xs" onClick={onEdit}>
</Button> Edit
{canTestClientFeatures && ( </Button>
<Menu> <Button variant="subtle" size="xs" color="red" onClick={onRemove}>
<Menu.Target> Remove
<Button variant="subtle" size="xs"> </Button>
Test Client Features &#x25BE; </ActionsRow>
</Button> <ActionsRow>
</Menu.Target> <Button variant="subtle" size="xs" onClick={onServerInfo}>
<Menu.Dropdown> Server Info
<Menu.Item onClick={onTestSampling}> </Button>
Simulate Sampling Request <Button variant="subtle" size="xs" onClick={onSettings}>
</Menu.Item> Settings
<Menu.Item onClick={onTestElicitationForm}> </Button>
Simulate Elicitation (Form) {canTestClientFeatures && (
</Menu.Item> <Menu>
<Menu.Item onClick={onTestElicitationUrl}> <Menu.Target>
Simulate Elicitation (URL) <Button variant="subtle" size="xs">
</Menu.Item> Test Client Features &#x25BE;
<Menu.Item onClick={onConfigureRoots}> </Button>
Configure Roots </Menu.Target>
</Menu.Item> <Menu.Dropdown>
</Menu.Dropdown> <Menu.Item onClick={onTestSampling}>
</Menu> Simulate Sampling Request
)} </Menu.Item>
</ActionsRow> <Menu.Item onClick={onTestElicitationForm}>
Simulate Elicitation (Form)
<ActionsRow> </Menu.Item>
<Button variant="subtle" size="xs" onClick={onClone}> <Menu.Item onClick={onTestElicitationUrl}>
Clone Simulate Elicitation (URL)
</Button> </Menu.Item>
<Button variant="subtle" size="xs" onClick={onEdit}> <Menu.Item onClick={onConfigureRoots}>
Edit Configure Roots
</Button> </Menu.Item>
<Button variant="subtle" size="xs" color="red" onClick={onRemove}> </Menu.Dropdown>
Remove </Menu>
</Button> )}
</ActionsRow> </ActionsRow>
</Group>
</Stack> </Stack>
</Card> </Card>
); );

View file

@ -30,15 +30,15 @@ export function LoggingScreen({
onCopyAll, onCopyAll,
}: LoggingScreenProps) { }: LoggingScreenProps) {
return ( return (
<Grid> <Grid align="stretch">
<Grid.Col span={3}> <Grid.Col span={3}>
<Paper withBorder p="md"> <Paper withBorder p="md" h="100%">
<LogControls {...controls} /> <LogControls {...controls} />
</Paper> </Paper>
</Grid.Col> </Grid.Col>
<Grid.Col span={9}> <Grid.Col span={9}>
<Paper withBorder p="md"> <Paper withBorder p="md" h="100%">
<Stack gap="sm"> <Stack gap="sm" h="100%">
<Group justify="space-between"> <Group justify="space-between">
<Title order={4}>Log Stream</Title> <Title order={4}>Log Stream</Title>
<Group> <Group>
@ -52,17 +52,18 @@ export function LoggingScreen({
</Button> </Button>
</Group> </Group>
</Group> </Group>
<ScrollArea h={500}> {entries.length > 0 ? (
<Stack gap="xs"> <ScrollArea flex={1}>
{entries.map((entry, index) => ( <Stack gap="xs">
<LogEntry key={index} {...entry} /> {entries.map((entry, index) => (
))} <LogEntry key={index} {...entry} />
))}
</Stack>
</ScrollArea>
) : (
<Stack flex={1} align="center" justify="center">
<Text c="dimmed">No log entries</Text>
</Stack> </Stack>
</ScrollArea>
{entries.length === 0 && (
<Text c="dimmed" ta="center">
No log entries
</Text>
)} )}
</Stack> </Stack>
</Paper> </Paper>

View file

@ -19,7 +19,6 @@ type Story = StoryObj<typeof ServerListScreen>;
function makeServerCallbacks(): Pick< function makeServerCallbacks(): Pick<
ServerCardProps, ServerCardProps,
| "onToggleConnection" | "onToggleConnection"
| "onCopyCommand"
| "onServerInfo" | "onServerInfo"
| "onSettings" | "onSettings"
| "onEdit" | "onEdit"
@ -32,7 +31,6 @@ function makeServerCallbacks(): Pick<
> { > {
return { return {
onToggleConnection: fn(), onToggleConnection: fn(),
onCopyCommand: fn(),
onServerInfo: fn(), onServerInfo: fn(),
onSettings: fn(), onSettings: fn(),
onEdit: fn(), onEdit: fn(),

View file

@ -6,4 +6,8 @@ export const ThemeCard = Card.extend({
radius: "md", radius: "md",
withBorder: true, withBorder: true,
}, },
classNames: (_theme, props) => {
if (props.variant === "responsive") return { root: "card-responsive" };
return {};
},
}); });