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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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