Skip to content
14 changes: 14 additions & 0 deletions clients/web/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ import { useManagedResourceTemplates } from "@inspector/core/react/useManagedRes
import { useManagedRequestorTasks } from "@inspector/core/react/useManagedRequestorTasks.js";
import { useResourceSubscriptions } from "@inspector/core/react/useResourceSubscriptions.js";
import { useMessageLog } from "@inspector/core/react/useMessageLog.js";
import { useFetchRequestLog } from "@inspector/core/react/useFetchRequestLog.js";
import { InspectorView } from "./components/views/InspectorView/InspectorView";
import type { ToolCallState } from "./components/screens/ToolsScreen/ToolsScreen";
import type { GetPromptState } from "./components/screens/PromptsScreen/PromptsScreen";
Expand Down Expand Up @@ -268,6 +269,7 @@ function App() {
resourceSubscriptionsState,
);
const { messages } = useMessageLog(messageLogState);
const { fetchRequests } = useFetchRequestLog(fetchRequestLogState);

// Capture observed handshake latency at the connecting → connected edge.
// Reset when the status leaves "connected" so the next connect starts
Expand Down Expand Up @@ -671,6 +673,15 @@ function App() {
messageLogState?.clearMessages();
}, [messageLogState]);

const onClearNetwork = useCallback(() => {
fetchRequestLogState?.clearFetchRequests();
}, [fetchRequestLogState]);

const onExportNetwork = useCallback(() => {
if (fetchRequests.length === 0) return;
downloadJsonFile("network.json", JSON.stringify(fetchRequests, null, 2));
}, [fetchRequests]);

// Action stubs — these UI affordances exist but require additional
// wiring (server CRUD, history pinning, app sandbox round-trip, log
// export). Tracked separately; the noop keeps the prop interface
Expand Down Expand Up @@ -857,6 +868,7 @@ function App() {
logs={logs}
tasks={tasks}
history={messages}
network={fetchRequests}
toolCallState={toolCallState}
getPromptState={getPromptState}
readResourceState={effectiveReadResourceState}
Expand Down Expand Up @@ -910,6 +922,8 @@ function App() {
onExportHistory={todoNoop}
onReplayHistory={todoNoop}
onTogglePinHistory={todoNoop}
onClearNetwork={onClearNetwork}
onExportNetwork={onExportNetwork}
onSelectApp={todoNoop}
onOpenApp={todoNoop}
onCloseApp={todoNoop}
Expand Down
23 changes: 17 additions & 6 deletions clients/web/src/components/elements/ListToggle/ListToggle.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,23 +4,34 @@ import userEvent from "@testing-library/user-event";
import { ListToggle } from "./ListToggle";

describe("ListToggle", () => {
it("renders a Button by default", () => {
it("renders a Button by default with 'Expand all' aria-label when compact", () => {
renderWithMantine(<ListToggle compact onToggle={() => {}} />);
expect(screen.getByRole("button")).toBeInTheDocument();
expect(
screen.getByRole("button", { name: "Expand all" }),
).toBeInTheDocument();
});

it("renders an ActionIcon for the subtle variant", () => {
it("renders 'Collapse all' aria-label when not compact", () => {
renderWithMantine(<ListToggle compact={false} onToggle={() => {}} />);
expect(
screen.getByRole("button", { name: "Collapse all" }),
).toBeInTheDocument();
});

it("renders an ActionIcon for the subtle variant and labels it", () => {
renderWithMantine(
<ListToggle compact variant="subtle" onToggle={() => {}} />,
);
expect(screen.getByRole("button")).toBeInTheDocument();
expect(
screen.getByRole("button", { name: "Expand all" }),
).toBeInTheDocument();
});

it("invokes onToggle when clicked (default variant)", async () => {
const user = userEvent.setup();
const onToggle = vi.fn();
renderWithMantine(<ListToggle compact onToggle={onToggle} />);
await user.click(screen.getByRole("button"));
await user.click(screen.getByRole("button", { name: "Expand all" }));
expect(onToggle).toHaveBeenCalledTimes(1);
});

Expand All @@ -30,7 +41,7 @@ describe("ListToggle", () => {
renderWithMantine(
<ListToggle compact={false} variant="subtle" onToggle={onToggle} />,
);
await user.click(screen.getByRole("button"));
await user.click(screen.getByRole("button", { name: "Collapse all" }));
expect(onToggle).toHaveBeenCalledTimes(1);
});
});
11 changes: 9 additions & 2 deletions clients/web/src/components/elements/ListToggle/ListToggle.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,17 +13,24 @@ export function ListToggle({
variant = "default",
}: ListToggleProps) {
const Icon = compact ? RiExpandVerticalLine : RiCollapseVerticalLine;
const label = compact ? "Expand all" : "Collapse all";

if (variant === "subtle") {
return (
<ActionIcon variant="subtle" color="gray" size="md" onClick={onToggle}>
<ActionIcon
variant="subtle"
color="gray"
size="md"
aria-label={label}
onClick={onToggle}
>
<Icon size={16} />
</ActionIcon>
);
}

return (
<Button size="sm" variant="subtle" onClick={onToggle}>
<Button size="sm" variant="subtle" aria-label={label} onClick={onToggle}>
<Icon size={20} />
</Button>
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -224,19 +224,13 @@ describe("HistoryListPanel", () => {
renderWithMantine(
<HistoryListPanel {...baseProps} entries={sampleEntries} />,
);
// Initially expanded — Collapse buttons exist on each entry
// Initially expanded — Collapse buttons exist on each entry, and the
// ListToggle exposes its aria-label as "Collapse all".
expect(
screen.getAllByRole("button", { name: "Collapse" }).length,
).toBeGreaterThan(0);

// Find the ListToggle button (last subtle toolbar button at top — has no accessible name)
const buttons = screen.getAllByRole("button");
const toggle = buttons.find(
(b) =>
b.textContent === "" && b.classList.contains("mantine-Button-root"),
);
expect(toggle).toBeDefined();
await user.click(toggle!);
await user.click(screen.getByRole("button", { name: "Collapse all" }));

// After toggle, entries collapsed — they show Expand
expect(
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { fn } from "storybook/test";
import { NetworkControls } from "./NetworkControls";

const meta: Meta<typeof NetworkControls> = {
title: "Groups/NetworkControls",
component: NetworkControls,
args: {
filterText: "",
visibleCategories: { auth: true, transport: true },
onFilterChange: fn(),
onToggleCategory: fn(),
onToggleAllCategories: fn(),
},
};

export default meta;
type Story = StoryObj<typeof NetworkControls>;

export const AllVisible: Story = {};

export const NoneVisible: Story = {
args: {
visibleCategories: { auth: false, transport: false },
},
};

export const OnlyAuth: Story = {
args: {
visibleCategories: { auth: true, transport: false },
},
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import { describe, it, expect, vi } from "vitest";
import userEvent from "@testing-library/user-event";
import { renderWithMantine, screen } from "../../../test/renderWithMantine";
import { NetworkControls } from "./NetworkControls";

const baseProps = {
filterText: "",
visibleCategories: { auth: true, transport: true } as const,
onFilterChange: vi.fn(),
onToggleCategory: vi.fn(),
onToggleAllCategories: vi.fn(),
};

describe("NetworkControls", () => {
it("renders the title and inputs", () => {
renderWithMantine(<NetworkControls {...baseProps} />);
expect(screen.getByText("Network")).toBeInTheDocument();
expect(screen.getByPlaceholderText("Search...")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "auth" })).toBeInTheDocument();
expect(
screen.getByRole("button", { name: "transport" }),
).toBeInTheDocument();
});

it("fires onFilterChange when the user types", async () => {
const user = userEvent.setup();
const onFilterChange = vi.fn();
renderWithMantine(
<NetworkControls {...baseProps} onFilterChange={onFilterChange} />,
);
await user.type(screen.getByPlaceholderText("Search..."), "x");
expect(onFilterChange).toHaveBeenLastCalledWith("x");
});

it("reflects category visibility via aria-pressed", () => {
renderWithMantine(
<NetworkControls
{...baseProps}
visibleCategories={{ auth: true, transport: false }}
/>,
);
expect(screen.getByRole("button", { name: "auth" })).toHaveAttribute(
"aria-pressed",
"true",
);
expect(screen.getByRole("button", { name: "transport" })).toHaveAttribute(
"aria-pressed",
"false",
);
});

it("fires onToggleCategory with inverted visibility when clicked", async () => {
const user = userEvent.setup();
const onToggleCategory = vi.fn();
renderWithMantine(
<NetworkControls {...baseProps} onToggleCategory={onToggleCategory} />,
);
await user.click(screen.getByRole("button", { name: "auth" }));
expect(onToggleCategory).toHaveBeenCalledWith("auth", false);
});

it("toggles between Select All and Deselect All", () => {
const { rerender } = renderWithMantine(<NetworkControls {...baseProps} />);
expect(
screen.getByRole("button", { name: "Deselect All" }),
).toBeInTheDocument();
rerender(
<NetworkControls
{...baseProps}
visibleCategories={{ auth: false, transport: false }}
/>,
);
expect(
screen.getByRole("button", { name: "Select All" }),
).toBeInTheDocument();
});

it("fires onToggleAllCategories when the toggle button is clicked", async () => {
const user = userEvent.setup();
const onToggleAllCategories = vi.fn();
renderWithMantine(
<NetworkControls
{...baseProps}
onToggleAllCategories={onToggleAllCategories}
/>,
);
await user.click(screen.getByRole("button", { name: "Deselect All" }));
expect(onToggleAllCategories).toHaveBeenCalledTimes(1);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import {
Button,
Group,
Stack,
Text,
TextInput,
Title,
UnstyledButton,
} from "@mantine/core";
import type { FetchRequestCategory } from "@inspector/core/mcp/types.js";

const NETWORK_CATEGORIES: FetchRequestCategory[] = ["auth", "transport"];

const CATEGORY_COLORS: Record<FetchRequestCategory, string> = {
auth: "violet",
transport: "blue",
};

const SubtleButton = Button.withProps({
variant: "subtle",
size: "xs",
});

export interface NetworkControlsProps {
filterText: string;
visibleCategories: Record<FetchRequestCategory, boolean>;
onFilterChange: (text: string) => void;
onToggleCategory: (category: FetchRequestCategory, visible: boolean) => void;
onToggleAllCategories: () => void;
}

export function NetworkControls({
filterText,
visibleCategories,
onFilterChange,
onToggleCategory,
onToggleAllCategories,
}: NetworkControlsProps) {
const allSelected = NETWORK_CATEGORIES.every((c) => visibleCategories[c]);
return (
<Stack gap="md">
<Title order={4}>Network</Title>

<TextInput
placeholder="Search..."
value={filterText}
onChange={(e) => onFilterChange(e.currentTarget.value)}
/>

<Group justify="space-between">
<Title order={5}>Filter by Category</Title>
<SubtleButton onClick={onToggleAllCategories}>
{allSelected ? "Deselect All" : "Select All"}
</SubtleButton>
</Group>
<Stack gap="xs">
{NETWORK_CATEGORIES.map((category) => {
const active = visibleCategories[category];
return (
<UnstyledButton
key={category}
w="100%"
p="sm"
variant="listItem"
aria-pressed={active}
bg={active ? "var(--mantine-primary-color-light)" : undefined}
onClick={() => onToggleCategory(category, !active)}
>
<Text c={CATEGORY_COLORS[category]} ta="center" fw={500}>
{category}
</Text>
</UnstyledButton>
);
})}
</Stack>
</Stack>
);
}
Loading
Loading