inspector/clients/web/src/components/screens/ConsoleScreen/ConsoleScreen.tsx
Cliff Hall da37692485
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.
2026-07-25 11:38:49 -04:00

213 lines
5.9 KiB
TypeScript

import { useMemo } from "react";
import {
Button,
Card,
Flex,
Group,
Paper,
Stack,
Text,
TextInput,
Title,
} from "@mantine/core";
import type { StderrLogEntry } from "@inspector/core/mcp/types.js";
import { ClearButton } from "../../elements/ClearButton/ClearButton";
import {
SortToggle,
type SortDirection,
} from "../../elements/SortToggle/SortToggle";
import { EmbeddableScrollArea } from "../../elements/EmbeddableScrollArea/EmbeddableScrollArea";
import { useScrollMemory } from "../../../hooks/useScrollMemory";
export interface ConsoleScreenProps {
entries: StderrLogEntry[];
ui: ConsoleUiState;
onUiChange: (next: ConsoleUiState) => void;
onClear: () => void;
onExport: () => void;
sortDirection: SortDirection;
onSortChange: (next: SortDirection) => void;
/** See LoggingScreen: fills the parent height and drops the filter sidebar. */
embedded?: boolean;
}
// Just the search text — stderr has no levels/categories to filter, so this is
// the whole of the Console screen's lifted UI state. Controlled by the parent
// (App) so it survives tab navigation within a live session (#1417).
export interface ConsoleUiState {
filterText: string;
}
const ScreenLayout = Flex.withProps({
variant: "screen",
h: "calc(100dvh - var(--app-shell-header-height, 0px) - var(--app-shell-footer-height, 0px))",
gap: "md",
p: "xl",
});
const Sidebar = Stack.withProps({
w: 340,
flex: "0 0 auto",
});
const SidebarCard = Card.withProps({
withBorder: true,
padding: "lg",
});
const PanelContainer = Paper.withProps({
withBorder: true,
p: "lg",
flex: 1,
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",
justify: "center",
});
// A single stderr line: a dimmed monospace timestamp beside the process's raw
// output. `align: flex-start` keeps the timestamp on the first wrapped row.
const EntryRow = Group.withProps({
gap: "sm",
wrap: "nowrap",
align: "flex-start",
});
const TimestampText = Text.withProps({
size: "sm",
ff: "monospace",
c: "dimmed",
flex: "0 0 auto",
});
// `consoleLine` (theme variant) preserves the process's own newlines/whitespace
// while wrapping over-long lines inside the narrow column.
const MessageText = Text.withProps({
size: "sm",
ff: "monospace",
variant: "consoleLine",
flex: 1,
});
function formatTimestamp(date: Date): string {
return date.toLocaleTimeString();
}
function matchesFilter(entry: StderrLogEntry, filterText: string): boolean {
if (!filterText) return true;
return entry.message.toLowerCase().includes(filterText.toLowerCase());
}
export function ConsoleScreen({
entries,
ui,
onUiChange,
onClear,
onExport,
sortDirection,
onSortChange,
embedded = false,
}: ConsoleScreenProps) {
const { filterText } = ui;
const viewportRef = useScrollMemory("console-stream");
const filteredEntries = useMemo(() => {
// `.filter()` returns a fresh array, so sorting in-place is safe.
const sorted = entries
.filter((e) => matchesFilter(e, filterText))
.sort((a, b) => a.timestamp.getTime() - b.timestamp.getTime());
if (sortDirection === "newest-first") sorted.reverse();
return sorted;
}, [entries, filterText, sortDirection]);
return (
// See LoggingScreen: only override `h` when embedded, so the standalone
// screen keeps ScreenLayout's default full-screen height (a `h={undefined}`
// would clobber it and collapse an empty screen to its controls' height).
<ScreenLayout {...(embedded ? { h: "100%", pt: "md" } : {})}>
{embedded ? null : (
<Sidebar>
<SidebarCard>
<Stack gap="md">
<Title order={4}>Console</Title>
<SearchInput
value={filterText}
onChange={(e) =>
onUiChange({ filterText: e.currentTarget.value })
}
rightSection={
filterText ? (
<ClearButton
onClick={() => onUiChange({ filterText: "" })}
/>
) : null
}
/>
</Stack>
</SidebarCard>
</Sidebar>
)}
<PanelContainer>
<PanelHeaderRow>
<Title order={4}>Server Console</Title>
<Group>
<SortToggle
value={sortDirection}
onChange={onSortChange}
aria-label="Console sort direction"
/>
<Button
variant="default"
onClick={onClear}
disabled={entries.length === 0}
>
Clear
</Button>
<Button
variant="default"
onClick={onExport}
disabled={entries.length === 0}
>
Export
</Button>
</Group>
</PanelHeaderRow>
{filteredEntries.length > 0 ? (
<EmbeddableScrollArea embedded={embedded} viewportRef={viewportRef}>
<Stack gap="xs">
{filteredEntries.map((entry, index) => (
<EntryRow key={`${entry.timestamp.getTime()}-${index}`}>
<TimestampText>
{formatTimestamp(entry.timestamp)}
</TimestampText>
<MessageText>{entry.message}</MessageText>
</EntryRow>
))}
</Stack>
</EmbeddableScrollArea>
) : (
<EmptyCenter>
<Text c="dimmed">No console output</Text>
</EmptyCenter>
)}
</PanelContainer>
</ScreenLayout>
);
}