feat(web): tool progress toasts + persist all per-screen state across navigation (#1414, #1417) (#1420)

* feat(web): tool progress toasts + persist tool selection/result across navigation (#1414)

Surface incoming notifications/progress as toasts so the user can watch a
long-running tool's progress without leaving the tool view — the v2 replacement
for v1's always-visible "Server Notifications" shelf. Toasts are keyed by
progress stream and updated (not stacked) per tick so a chatty server updates
one toast rather than flooding the corner. Progress notifications still land in
the History tab via the existing transport-level tracking.

Companion change: lift the selected tool and its form values into App.tsx
alongside the already-lifted toolCallState, make ToolsScreen controlled, and
drop the clear-on-unmount effect. Leaving the Tools tab and returning now
preserves selection, inputs, and result. All three reset together on
disconnect via resetSessionScopedUiState, preserving the #1368/#1394 behavior.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(web): persist per-screen selection/search/filter across navigation (#1417)

Lift every screen's selection, form, search, and filter state out of the
screen components into session-scoped state owned by App, passed down as
controlled props. The screens unmount on tab switch, so this is what keeps
the user's context in place when they navigate away and back within a live
session. State is cleared only on disconnect (resetSessionScopedUiState) or
an explicit user action — never on plain navigation.

Lifted per screen:
- Tools: sidebar search (selection/form/result were lifted in #1414)
- Prompts: selected prompt, argument values, submitted marker, search
- Resources: selected resource/template URI, originating-template marker,
  search, accordion open-sections
- Apps: selected app, form values, search (running/maximized stay local —
  they're tied to the live iframe/bridge that is torn down on unmount)
- Tasks: search + status filter
- Logs: filter text + visible-level set
- History: search + method filter
- Network: filter text + visible-category set

The four sidebar *Controls (Tool/Prompt/App/Resource) become controlled for
search; ResourceControls also controls accordion open-sections. Level/category
default maps move to logLevels.ts / fetchCategories.ts so the screen files
keep a single component export.

Pagination already persists (it lives in the App-level Paged*State managers),
so no work was needed there.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(web): persist list scroll position across navigation + reset tests (#1417)

Add useScrollMemory: a module-keyed hook that remembers a Mantine ScrollArea's
offset across unmount/remount (the screens unmount on tab switch). Scroll lives
in a module-scope map rather than threaded App state — it's ephemeral DOM state,
and threading ~16 more props would balloon the surface for no benefit. App calls
clearScrollMemory() in resetSessionScopedUiState so offsets clear on disconnect
alongside the lifted selection/filter state.

Wired into the four main list panels (Logs, History, Network, Tasks) and the
Tools/Prompts/Apps sidebar lists. Pagination already persists via the App-level
Paged*State managers.

Tests: useScrollMemory unit test (save/restore, key isolation, clear), and the
App disconnect test now also exercises representative lifted UI state (tool
search, prompt selection, log filter) clearing on disconnect.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(web): collapse per-screen lifted state into one {screen}Ui object

Replace the ~46 individual selection/search/filter props threaded App →
InspectorView → screen with one `{screen}Ui` object + one `on{Screen}UiChange`
setter per screen (8 pairs). Each screen reads its fields from `ui` and updates
via `onUiChange({ ...ui, field })`; the in-flight result panels
(toolCallState / getPromptState / readResourceState) stay separate since
they're written by App's async action handlers, not the screens.

The `*UiState` interfaces live with their screens; the empty-state defaults are
gathered in screenUiState.ts so the screen files keep a single component export
(react-refresh), mirroring logLevels.ts / fetchCategories.ts.

Trims InspectorView's prop surface substantially and keeps the persistence
behavior identical. Tests/stories collapse their stateful wrappers to a single
ui object; added Tools/Tasks/History interaction tests to keep per-file
coverage above the gate.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(web): dismiss visible progress toasts on client teardown (#1414 review)

The progressNotification effect cleanup cleared its id-tracking set but left
on-screen "Tool progress" toasts to auto-close up to 5s later — they could
linger into the next session, and the lingering toast's onClose could delete an
id from the new session's set and trigger a duplicate-id re-show. Hide each
tracked toast in cleanup so both gaps close. Addresses @claude review note #2.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(web): clear the tool result when a different tool is selected

The Tools result panel renders `toolCallState` regardless of which tool is
selected, so selecting a new tool used to leave the previous tool's result
showing beneath it. Route tools UI changes through an App handler that drops
`toolCallState` whenever `selectedToolName` changes; search/form edits keep the
selection (and the result) intact. Tests cover both the clear-on-switch and
keep-on-search paths.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Cliff Hall 2026-06-03 12:46:37 -04:00 • committed by GitHub
commit 8923a9bbf9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
27 changed files with 1741 additions and 316 deletions

View file

@ -4,7 +4,12 @@ import type { Meta, StoryObj } from "@storybook/react-vite";
import type { CallToolResult, Tool } from "@modelcontextprotocol/sdk/types.js";
import type { AppBridge } from "@modelcontextprotocol/ext-apps/app-bridge";
import { expect, fn, userEvent, waitFor, within } from "storybook/test";
import { AppsScreen, type AppsScreenProps } from "./AppsScreen";
import {
AppsScreen,
type AppsScreenProps,
type AppsUiState,
} from "./AppsScreen";
import { EMPTY_APPS_UI } from "../screenUiState";
import type {
AppRendererHandle,
BridgeFactory,
@ -80,6 +85,16 @@ const dashboardApp: Tool = {
const sampleApps: Tool[] = [cohortApp, weatherApp, dashboardApp];
// AppsScreen is controlled (app selection, form values, and search text live in
// the parent as one `ui` object — running/maximized stay internal; see #1417).
// This hook holds the lifted state so the play-driven select/type/open
// interactions still drive the detail panel, mirroring how App owns the state in
// the real app.
function useLiftedAppState(args: AppsScreenProps) {
const [ui, setUi] = useState<AppsUiState>(args.ui ?? EMPTY_APPS_UI);
return { ui, onUiChange: setUi };
}
const meta: Meta<typeof AppsScreen> = {
title: "Screens/AppsScreen",
component: AppsScreen,
@ -88,6 +103,8 @@ const meta: Meta<typeof AppsScreen> = {
sandboxPath: PLACEHOLDER_SANDBOX,
bridgeFactory: okBridgeFactory,
listChanged: false,
ui: EMPTY_APPS_UI,
onUiChange: fn(),
onRefreshList: fn(),
onSelectApp: fn(),
onOpenApp: fn(),
@ -98,7 +115,8 @@ const meta: Meta<typeof AppsScreen> = {
// arg edits, but the ref itself is owned by the wrapping component).
render: function StoryRender(args: AppsScreenProps) {
const ref = useRef<AppRendererHandle>(null);
return <AppsScreen {...args} rendererRef={ref} />;
const lifted = useLiftedAppState(args);
return <AppsScreen {...args} {...lifted} rendererRef={ref} />;
},
};
@ -175,6 +193,7 @@ export const EchoRunning: Story = {
render: function EchoRender(args: AppsScreenProps) {
const ref = useRef<AppRendererHandle>(null);
const [echo, setEcho] = useState<string | null>(null);
const lifted = useLiftedAppState(args);
const bridgeFactory: BridgeFactory = useCallback(() => {
let onInitialized: (() => void) | undefined;
@ -227,6 +246,7 @@ export const EchoRunning: Story = {
</Text>
<AppsScreen
{...args}
{...lifted}
rendererRef={ref}
bridgeFactory={bridgeFactory}
onOpenApp={onOpenApp}

View file

@ -1,4 +1,4 @@
import { createRef } from "react";
import { createRef, useState } from "react";
import { describe, it, expect, vi } from "vitest";
import userEvent from "@testing-library/user-event";
import type { Tool } from "@modelcontextprotocol/sdk/types.js";
@ -8,7 +8,12 @@ import {
screen,
within,
} from "../../../test/renderWithMantine";
import { AppsScreen } from "./AppsScreen";
import {
AppsScreen,
type AppsScreenProps,
type AppsUiState,
} from "./AppsScreen";
import { EMPTY_APPS_UI } from "../screenUiState";
import type {
AppRendererHandle,
BridgeFactory,
@ -54,7 +59,7 @@ const okBridgeFactory: BridgeFactory = () =>
close: async () => {},
}) as unknown as AppBridge;
function buildProps(overrides: Partial<Parameters<typeof AppsScreen>[0]> = {}) {
function buildProps(overrides: Partial<AppsScreenProps> = {}): AppsScreenProps {
return {
tools: [fieldedApp, noFieldsApp, cohortApp] as Tool[],
listChanged: false,
@ -64,6 +69,8 @@ function buildProps(overrides: Partial<Parameters<typeof AppsScreen>[0]> = {}) {
sandboxPath: "data:text/html,<title>sandbox</title>",
bridgeFactory: okBridgeFactory,
rendererRef: createRef<AppRendererHandle>(),
ui: EMPTY_APPS_UI,
onUiChange: vi.fn(),
onRefreshList: vi.fn(),
onSelectApp: vi.fn(),
onOpenApp: vi.fn(),
@ -72,6 +79,32 @@ function buildProps(overrides: Partial<Parameters<typeof AppsScreen>[0]> = {}) {
};
}
// AppsScreen lifts selection, form values, and the sidebar search to the
// parent (App) as one `ui` object so they persist across tab navigation
// (#1417), while `running`/`maximized` stay local to the screen. This host
// holds the lifted state so clicking an app, typing into its form/search, and
// closing drive the panel exactly as App owns it. The internal running/maximized
// state handles auto-launch, open, maximize, and back-to-input on its own. Props
// passed in override defaults; the stateful `ui` wiring is applied last so
// callers can still observe activity via the rendered state.
function ControlledAppsScreen(overrides: Partial<AppsScreenProps> = {}) {
const props = buildProps(overrides);
const [ui, setUi] = useState<AppsUiState>({
...EMPTY_APPS_UI,
...props.ui,
});
return (
<AppsScreen
{...props}
ui={ui}
onUiChange={(next) => {
setUi(next);
props.onUiChange(next);
}}
/>
);
}
describe("AppsScreen", () => {
it("renders the empty selection state", () => {
renderWithMantine(<AppsScreen {...buildProps()} />);
@ -101,8 +134,9 @@ describe("AppsScreen", () => {
throw new Error("no connected MCP client");
};
renderWithMantine(
<AppsScreen
{...buildProps({ bridgeFactory: throwingFactory, onError })}
<ControlledAppsScreen
bridgeFactory={throwingFactory}
onError={onError}
/>,
);
// The no-fields app auto-launches on selection, mounting the renderer,
@ -117,7 +151,7 @@ describe("AppsScreen", () => {
it("filters the list via the search input", async () => {
const user = userEvent.setup();
renderWithMantine(<AppsScreen {...buildProps()} />);
renderWithMantine(<ControlledAppsScreen />);
await user.type(screen.getByPlaceholderText("Search apps..."), "weather");
expect(screen.getByText("Weather Widget")).toBeInTheDocument();
expect(screen.queryByText("Ops Dashboard")).not.toBeInTheDocument();
@ -125,7 +159,7 @@ describe("AppsScreen", () => {
it("shows 'No matching apps' when search yields no results", async () => {
const user = userEvent.setup();
renderWithMantine(<AppsScreen {...buildProps()} />);
renderWithMantine(<ControlledAppsScreen />);
await user.type(screen.getByPlaceholderText("Search apps..."), "zzz");
expect(screen.getByText("No matching apps")).toBeInTheDocument();
});
@ -135,7 +169,7 @@ describe("AppsScreen", () => {
const onSelectApp = vi.fn();
const onOpenApp = vi.fn();
renderWithMantine(
<AppsScreen {...buildProps({ onSelectApp, onOpenApp })} />,
<ControlledAppsScreen onSelectApp={onSelectApp} onOpenApp={onOpenApp} />,
);
await user.click(screen.getByText("Weather Widget"));
expect(onSelectApp).toHaveBeenCalledWith("weather");
@ -148,7 +182,7 @@ describe("AppsScreen", () => {
it("auto-launches a no-fields app on selection", async () => {
const user = userEvent.setup();
const onOpenApp = vi.fn();
renderWithMantine(<AppsScreen {...buildProps({ onOpenApp })} />);
renderWithMantine(<ControlledAppsScreen onOpenApp={onOpenApp} />);
await user.click(screen.getByText("Ops Dashboard"));
expect(onOpenApp).toHaveBeenCalledWith("ops", {});
// Renderer iframe replaces the form; Open App button is gone.
@ -161,7 +195,7 @@ describe("AppsScreen", () => {
it("invokes onOpenApp with form values when Open App is clicked", async () => {
const user = userEvent.setup();
const onOpenApp = vi.fn();
renderWithMantine(<AppsScreen {...buildProps({ onOpenApp })} />);
renderWithMantine(<ControlledAppsScreen onOpenApp={onOpenApp} />);
await user.click(screen.getByText("Weather Widget"));
const cityField = screen.getByRole("textbox", { name: /city/i });
await user.type(cityField, "Reykjavik");
@ -173,7 +207,7 @@ describe("AppsScreen", () => {
it("seeds schema defaults so untouched fields are sent on Open App", async () => {
const user = userEvent.setup();
const onOpenApp = vi.fn();
renderWithMantine(<AppsScreen {...buildProps({ onOpenApp })} />);
renderWithMantine(<ControlledAppsScreen onOpenApp={onOpenApp} />);
await user.click(screen.getByText("Cohort Data"));
// Open without editing the metric field: its default must still be sent.
await user.click(screen.getByRole("button", { name: /Open App/ }));
@ -182,7 +216,7 @@ describe("AppsScreen", () => {
it("returns to the input form when 'Back to Input' is clicked", async () => {
const user = userEvent.setup();
renderWithMantine(<AppsScreen {...buildProps()} />);
renderWithMantine(<ControlledAppsScreen />);
await user.click(screen.getByText("Weather Widget"));
await user.type(
screen.getByRole("textbox", { name: /city/i }),
@ -197,7 +231,7 @@ describe("AppsScreen", () => {
it("does not show 'Back to Input' for a no-fields app", async () => {
const user = userEvent.setup();
renderWithMantine(<AppsScreen {...buildProps()} />);
renderWithMantine(<ControlledAppsScreen />);
await user.click(screen.getByText("Ops Dashboard"));
expect(
screen.queryByRole("button", { name: /Back to Input/ }),
@ -206,7 +240,7 @@ describe("AppsScreen", () => {
it("toggles maximize, hiding the sidebar", async () => {
const user = userEvent.setup();
renderWithMantine(<AppsScreen {...buildProps()} />);
renderWithMantine(<ControlledAppsScreen />);
await user.click(screen.getByText("Ops Dashboard"));
expect(screen.getByText("MCP Apps (3)")).toBeInTheDocument();
await user.click(screen.getByLabelText("Maximize"));
@ -218,7 +252,7 @@ describe("AppsScreen", () => {
it("calls onCloseApp and clears selection on Close", async () => {
const user = userEvent.setup();
const onCloseApp = vi.fn();
renderWithMantine(<AppsScreen {...buildProps({ onCloseApp })} />);
renderWithMantine(<ControlledAppsScreen onCloseApp={onCloseApp} />);
await user.click(screen.getByText("Ops Dashboard"));
await user.click(screen.getByLabelText("Close"));
expect(onCloseApp).toHaveBeenCalledTimes(1);
@ -228,7 +262,7 @@ describe("AppsScreen", () => {
it("ignores re-clicking the same app (no duplicate onSelectApp)", async () => {
const user = userEvent.setup();
const onSelectApp = vi.fn();
renderWithMantine(<AppsScreen {...buildProps({ onSelectApp })} />);
renderWithMantine(<ControlledAppsScreen onSelectApp={onSelectApp} />);
// After the first click "Weather Widget" appears both in the sidebar
// list item and the right-pane header, so target the sidebar entry
// explicitly via the list-item button role.
@ -240,7 +274,7 @@ describe("AppsScreen", () => {
it("resets form state when switching to a different app", async () => {
const user = userEvent.setup();
renderWithMantine(<AppsScreen {...buildProps()} />);
renderWithMantine(<ControlledAppsScreen />);
await user.click(screen.getByText("Weather Widget"));
await user.type(
screen.getByRole("textbox", { name: /city/i }),
@ -287,7 +321,7 @@ describe("AppsScreen", () => {
inputSchema: { type: "object" },
_meta: { ui: { resourceUri: "ui://apps/weather-iconed" } },
};
renderWithMantine(<AppsScreen {...buildProps({ tools: [iconedApp] })} />);
renderWithMantine(<ControlledAppsScreen tools={[iconedApp]} />);
await user.click(screen.getByText("Weather (Iconed)"));
const headerImg = screen
.getAllByRole("presentation")
@ -299,15 +333,16 @@ describe("AppsScreen", () => {
const user = userEvent.setup();
const onSelectApp = vi.fn();
const { rerender } = renderWithMantine(
<AppsScreen {...buildProps({ onSelectApp })} />,
<ControlledAppsScreen onSelectApp={onSelectApp} />,
);
// Click an item, then re-render with a tools list that no longer
// contains it: the selection state stays put, but a follow-up click
// on the same name no-ops because the lookup fails.
await user.click(screen.getByText("Weather Widget"));
rerender(
<AppsScreen
{...buildProps({ onSelectApp, tools: [noFieldsApp, cohortApp] })}
<ControlledAppsScreen
onSelectApp={onSelectApp}
tools={[noFieldsApp, cohortApp]}
/>,
);
// Sidebar no longer shows Weather Widget; the right pane falls back

View file

@ -39,6 +39,8 @@ export interface AppsScreenProps {
sandboxPath?: string;
bridgeFactory: BridgeFactory;
rendererRef: Ref<AppRendererHandle>;
ui: AppsUiState;
onUiChange: (next: AppsUiState) => void;
onRefreshList: () => void;
onSelectApp: (name: string) => void;
onOpenApp: (name: string, args: Record<string, unknown>) => void;
@ -47,6 +49,18 @@ export interface AppsScreenProps {
onError?: (err: Error) => void;
}
// Selected app, its form values, and the sidebar search — controlled by the
// parent (App) as one object so they persist across tab navigation within a
// live session (#1417). `running`/`maximized` stay local to the screen: they're
// tied to the live iframe and bridge, which are torn down on unmount, so
// persisting them would restore a flag without its runtime. On return the
// selected app's input form (with its values) is shown, ready to re-open.
export interface AppsUiState {
selectedAppName?: string;
formValues: Record<string, unknown>;
search: string;
}
const ScreenLayout = Flex.withProps({
variant: "screen",
h: "calc(100vh - var(--app-shell-header-height, 0px))",
@ -131,16 +145,15 @@ export function AppsScreen({
sandboxPath,
bridgeFactory,
rendererRef,
ui,
onUiChange,
onRefreshList,
onSelectApp,
onOpenApp,
onCloseApp,
onError,
}: AppsScreenProps) {
const [selectedAppName, setSelectedAppName] = useState<string | undefined>(
undefined,
);
const [formValues, setFormValues] = useState<Record<string, unknown>>({});
const { selectedAppName, formValues, search } = ui;
const [running, setRunning] = useState(false);
const [maximized, setMaximized] = useState(false);
@ -153,10 +166,13 @@ export function AppsScreen({
if (name === selectedAppName) return;
const next = tools.find((t) => t.name === name);
if (!next) return;
setSelectedAppName(name);
// Seed schema defaults so default-only fields are sent on Open App (parity
// with the form's resolveValue display, which onChange doesn't capture).
setFormValues(collectSchemaDefaults(next.inputSchema));
onUiChange({
...ui,
selectedAppName: name,
formValues: collectSchemaDefaults(next.inputSchema),
});
setMaximized(false);
onSelectApp(name);
// No-input apps auto-launch on selection so the user lands directly in
@ -177,8 +193,7 @@ export function AppsScreen({
function handleClose() {
setRunning(false);
setSelectedAppName(undefined);
setFormValues({});
onUiChange({ ...ui, selectedAppName: undefined, formValues: {} });
setMaximized(false);
onCloseApp();
}
@ -211,8 +226,10 @@ export function AppsScreen({
<AppControls
tools={tools}
selectedName={selectedAppName}
searchText={search}
listChanged={listChanged}
onRefreshList={onRefreshList}
onSearchChange={(value) => onUiChange({ ...ui, search: value })}
onSelectApp={handleSelect}
/>
</SidebarCard>
@ -294,7 +311,9 @@ export function AppsScreen({
tool={selectedTool}
formValues={formValues}
isOpening={false}
onFormChange={setFormValues}
onFormChange={(values) =>
onUiChange({ ...ui, formValues: values })
}
onOpenApp={handleOpen}
/>
)}

View file

@ -1,7 +1,19 @@
import { useState } from "react";
import type { ComponentProps } from "react";
import type { Meta, StoryObj } from "@storybook/react-vite";
import type { MessageEntry } from "../../../../../../core/mcp/types.js";
import { expect, fn, screen, userEvent, within } from "storybook/test";
import { HistoryScreen } from "./HistoryScreen";
import { EMPTY_HISTORY_UI } from "../screenUiState";
// HistoryScreen is controlled (search text + method filter live in the parent
// as one `ui` object — see #1417). This wrapper holds that state so the
// play-driven filter selection and clear-all reset are observable, mirroring
// how App owns the state.
function StatefulHistoryScreen(args: ComponentProps<typeof HistoryScreen>) {
const [ui, setUi] = useState({ ...EMPTY_HISTORY_UI, ...args.ui });
return <HistoryScreen {...args} ui={ui} onUiChange={setUi} />;
}
const meta: Meta<typeof HistoryScreen> = {
title: "Screens/HistoryScreen",
@ -13,11 +25,14 @@ const meta: Meta<typeof HistoryScreen> = {
onExport: fn(),
onReplay: fn(),
onTogglePin: fn(),
ui: EMPTY_HISTORY_UI,
onUiChange: fn(),
sortDirection: "newest-first",
onSortChange: fn(),
compact: true,
onToggleCompact: fn(),
},
render: (args) => <StatefulHistoryScreen {...args} />,
};
export default meta;

View file

@ -3,6 +3,7 @@ import userEvent from "@testing-library/user-event";
import type { MessageEntry } from "@inspector/core/mcp/types.js";
import { renderWithMantine, screen } from "../../../test/renderWithMantine";
import { HistoryScreen } from "./HistoryScreen";
import { EMPTY_HISTORY_UI } from "../screenUiState";
const sampleEntries: MessageEntry[] = [
{
@ -22,6 +23,8 @@ const sampleEntries: MessageEntry[] = [
const baseProps = {
entries: sampleEntries,
pinnedIds: new Set<string>(),
ui: EMPTY_HISTORY_UI,
onUiChange: vi.fn(),
onClearAll: vi.fn(),
onExport: vi.fn(),
onReplay: vi.fn(),
@ -52,4 +55,34 @@ describe("HistoryScreen", () => {
await user.click(clearButton);
expect(onClearAll).toHaveBeenCalled();
});
it("emits the search text through onUiChange", async () => {
const user = userEvent.setup();
const onUiChange = vi.fn();
renderWithMantine(<HistoryScreen {...baseProps} onUiChange={onUiChange} />);
await user.type(screen.getByPlaceholderText("Search..."), "t");
expect(onUiChange).toHaveBeenCalledWith(
expect.objectContaining({ search: "t" }),
);
});
it("emits the cleared method filter through onUiChange", async () => {
const user = userEvent.setup();
const onUiChange = vi.fn();
const { container } = renderWithMantine(
<HistoryScreen
{...baseProps}
ui={{ ...EMPTY_HISTORY_UI, methodFilter: "tools/list" }}
onUiChange={onUiChange}
/>,
);
const clearButton = container.querySelector(
"button.mantine-InputClearButton-root",
) as HTMLButtonElement | null;
expect(clearButton).not.toBeNull();
await user.click(clearButton!);
expect(onUiChange).toHaveBeenCalledWith(
expect.objectContaining({ methodFilter: undefined }),
);
});
});

View file

@ -1,4 +1,4 @@
import { useCallback, useMemo, useState } from "react";
import { useCallback, useMemo } from "react";
import { Card, Flex, Stack } from "@mantine/core";
import type { MessageEntry, MessageMethod } from "@inspector/core/mcp/types.js";
import { HistoryControls } from "../../groups/HistoryControls/HistoryControls";
@ -9,6 +9,8 @@ import type { SortDirection } from "../../elements/SortToggle/SortToggle";
export interface HistoryScreenProps {
entries: MessageEntry[];
pinnedIds: Set<string>;
ui: HistoryUiState;
onUiChange: (next: HistoryUiState) => void;
onClearAll: () => void;
onExport: () => void;
onReplay: (id: string) => void;
@ -19,6 +21,13 @@ export interface HistoryScreenProps {
onToggleCompact: () => void;
}
// Search text + method filter — controlled by the parent (App) as one object so
// they persist across tab navigation within a live session (#1417).
export interface HistoryUiState {
search: string;
methodFilter?: MessageMethod;
}
const ScreenLayout = Flex.withProps({
variant: "screen",
h: "calc(100vh - var(--app-shell-header-height, 0px))",
@ -39,6 +48,8 @@ const SidebarCard = Card.withProps({
export function HistoryScreen({
entries,
pinnedIds,
ui,
onUiChange,
onClearAll,
onExport,
onReplay,
@ -48,8 +59,7 @@ export function HistoryScreen({
compact,
onToggleCompact,
}: HistoryScreenProps) {
const [searchText, setSearchText] = useState("");
const [methodFilter, setMethodFilter] = useState<MessageMethod | undefined>();
const { search, methodFilter } = ui;
const availableMethods = useMemo(
() => Array.from(new Set(entries.map(extractMethod))).sort(),
@ -57,27 +67,29 @@ export function HistoryScreen({
);
const handleClearAll = useCallback(() => {
setMethodFilter(undefined);
onUiChange({ ...ui, methodFilter: undefined });
onClearAll();
}, [onClearAll]);
}, [ui, onUiChange, onClearAll]);
return (
<ScreenLayout>
<Sidebar>
<SidebarCard>
<HistoryControls
searchText={searchText}
searchText={search}
methodFilter={methodFilter}
availableMethods={availableMethods}
onSearchChange={setSearchText}
onMethodFilterChange={setMethodFilter}
onSearchChange={(value) => onUiChange({ ...ui, search: value })}
onMethodFilterChange={(value) =>
onUiChange({ ...ui, methodFilter: value })
}
/>
</SidebarCard>
</Sidebar>
<HistoryListPanel
entries={entries}
pinnedIds={pinnedIds}
searchText={searchText}
searchText={search}
methodFilter={methodFilter}
onClearAll={handleClearAll}
onExport={onExport}

View file

@ -1,6 +1,7 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { fn } from "storybook/test";
import { LoggingScreen } from "./LoggingScreen";
import { EMPTY_LOGS_UI } from "../screenUiState";
import { mixedEntries } from "./LoggingScreen.fixtures";
import type { LogEntryData } from "../../elements/LogEntry/LogEntry";
@ -13,6 +14,8 @@ const meta: Meta<typeof LoggingScreen> = {
onSetLevel: fn(),
onClear: fn(),
onExport: fn(),
ui: EMPTY_LOGS_UI,
onUiChange: fn(),
sortDirection: "newest-first",
onSortChange: fn(),
},

View file

@ -1,11 +1,19 @@
import { useState } from "react";
import { describe, it, expect, vi } from "vitest";
import userEvent from "@testing-library/user-event";
import { renderWithMantine, screen } from "../../../test/renderWithMantine";
import { LoggingScreen } from "./LoggingScreen";
import {
LoggingScreen,
type LoggingScreenProps,
type LogsUiState,
} from "./LoggingScreen";
import { EMPTY_LOGS_UI } from "../screenUiState";
const baseProps = {
entries: [],
currentLevel: "info" as const,
ui: EMPTY_LOGS_UI,
onUiChange: vi.fn(),
onSetLevel: vi.fn(),
onClear: vi.fn(),
onExport: vi.fn(),
@ -13,6 +21,27 @@ const baseProps = {
onSortChange: vi.fn(),
};
// LoggingScreen is controlled: filter text + visible-level set live in the
// parent (App) as one `ui` object so they persist across tab navigation
// (#1417). This host holds that state so typing/toggling drives the rendered
// list, mirroring how App owns it. Props passed in override defaults; the
// stateful `ui` wiring is applied last so callers can still observe changes
// via the spied `onUiChange` callback.
function ControlledLoggingScreen(props: Partial<LoggingScreenProps>) {
const [ui, setUi] = useState<LogsUiState>({ ...EMPTY_LOGS_UI, ...props.ui });
return (
<LoggingScreen
{...baseProps}
{...props}
ui={ui}
onUiChange={(value) => {
setUi(value);
props.onUiChange?.(value);
}}
/>
);
}
describe("LoggingScreen", () => {
it("renders the log stream panel", () => {
renderWithMantine(<LoggingScreen {...baseProps} />);
@ -58,7 +87,7 @@ describe("LoggingScreen", () => {
const entries = [
{ receivedAt: new Date(), params: { level: "info" as const, data: "x" } },
];
renderWithMantine(<LoggingScreen {...baseProps} entries={entries} />);
renderWithMantine(<ControlledLoggingScreen entries={entries} />);
expect(screen.getByText("x")).toBeInTheDocument();
const debugButton = screen.getByRole("button", { name: "info" });
await user.click(debugButton);
@ -70,7 +99,7 @@ describe("LoggingScreen", () => {
const entries = [
{ receivedAt: new Date(), params: { level: "info" as const, data: "x" } },
];
renderWithMantine(<LoggingScreen {...baseProps} entries={entries} />);
renderWithMantine(<ControlledLoggingScreen entries={entries} />);
await user.click(screen.getByRole("button", { name: "Deselect All" }));
expect(screen.queryByText("x")).not.toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "Select All" }));
@ -89,7 +118,7 @@ describe("LoggingScreen", () => {
params: { level: "info" as const, data: "beta" },
},
];
renderWithMantine(<LoggingScreen {...baseProps} entries={entries} />);
renderWithMantine(<ControlledLoggingScreen entries={entries} />);
await user.type(screen.getByPlaceholderText("Search..."), "alpha");
expect(screen.getByText("alpha")).toBeInTheDocument();
expect(screen.queryByText("beta")).not.toBeInTheDocument();

View file

@ -1,14 +1,16 @@
import { useState } from "react";
import { Card, Flex, Stack } from "@mantine/core";
import type { LoggingLevel } from "@modelcontextprotocol/sdk/types.js";
import { LogControls } from "../../groups/LogControls/LogControls";
import { LogStreamPanel } from "../../groups/LogStreamPanel/LogStreamPanel";
import type { LogEntryData } from "../../elements/LogEntry/LogEntry";
import type { SortDirection } from "../../elements/SortToggle/SortToggle";
import { ALL_LEVELS_VISIBLE, NO_LEVELS_VISIBLE } from "./logLevels";
export interface LoggingScreenProps {
entries: LogEntryData[];
currentLevel: LoggingLevel;
ui: LogsUiState;
onUiChange: (next: LogsUiState) => void;
onSetLevel: (level: LoggingLevel) => void;
onClear: () => void;
onExport: () => void;
@ -16,27 +18,12 @@ export interface LoggingScreenProps {
onSortChange: (next: SortDirection) => void;
}
const ALL_LEVELS_VISIBLE: Record<LoggingLevel, boolean> = {
debug: true,
info: true,
notice: true,
warning: true,
error: true,
critical: true,
alert: true,
emergency: true,
};
const NO_LEVELS_VISIBLE: Record<LoggingLevel, boolean> = {
debug: false,
info: false,
notice: false,
warning: false,
error: false,
critical: false,
alert: false,
emergency: false,
};
// Filter text + visible-level set — controlled by the parent (App) as one
// object so they persist across tab navigation within a live session (#1417).
export interface LogsUiState {
filterText: string;
visibleLevels: Record<LoggingLevel, boolean>;
}
const ScreenLayout = Flex.withProps({
variant: "screen",
@ -58,23 +45,29 @@ const SidebarCard = Card.withProps({
export function LoggingScreen({
entries,
currentLevel,
ui,
onUiChange,
onSetLevel,
onClear,
onExport,
sortDirection,
onSortChange,
}: LoggingScreenProps) {
const [filterText, setFilterText] = useState("");
const [visibleLevels, setVisibleLevels] =
useState<Record<LoggingLevel, boolean>>(ALL_LEVELS_VISIBLE);
const { filterText, visibleLevels } = ui;
function handleToggleLevel(level: LoggingLevel, visible: boolean) {
setVisibleLevels((prev) => ({ ...prev, [level]: visible }));
onUiChange({
...ui,
visibleLevels: { ...visibleLevels, [level]: visible },
});
}
function handleToggleAllLevels() {
const allSelected = Object.values(visibleLevels).every(Boolean);
setVisibleLevels(allSelected ? NO_LEVELS_VISIBLE : ALL_LEVELS_VISIBLE);
onUiChange({
...ui,
visibleLevels: allSelected ? NO_LEVELS_VISIBLE : ALL_LEVELS_VISIBLE,
});
}
return (
@ -86,7 +79,7 @@ export function LoggingScreen({
filterText={filterText}
visibleLevels={visibleLevels}
onSetLevel={onSetLevel}
onFilterChange={setFilterText}
onFilterChange={(value) => onUiChange({ ...ui, filterText: value })}
onToggleLevel={handleToggleLevel}
onToggleAllLevels={handleToggleAllLevels}
/>

View file

@ -0,0 +1,27 @@
import type { LoggingLevel } from "@modelcontextprotocol/sdk/types.js";
// Default visible-level filter: every level on. Shared by LoggingScreen (its
// fallback when the parent hasn't set `visibleLevels`) and App, which seeds and
// resets the lifted filter state from it (#1417). Lives in its own module so
// the screen file only exports a component (react-refresh constraint).
export const ALL_LEVELS_VISIBLE: Record<LoggingLevel, boolean> = {
debug: true,
info: true,
notice: true,
warning: true,
error: true,
critical: true,
alert: true,
emergency: true,
};
export const NO_LEVELS_VISIBLE: Record<LoggingLevel, boolean> = {
debug: false,
info: false,
notice: false,
warning: false,
error: false,
critical: false,
alert: false,
emergency: false,
};

View file

@ -1,7 +1,19 @@
import { useState } from "react";
import type { ComponentProps } from "react";
import type { Meta, StoryObj } from "@storybook/react-vite";
import { expect, fn, userEvent, within } from "storybook/test";
import type { FetchRequestEntry } from "../../../../../../core/mcp/types.js";
import { NetworkScreen } from "./NetworkScreen";
import { EMPTY_NETWORK_UI } from "../screenUiState";
// NetworkScreen is controlled (filter text + visible categories live in the
// parent as one `ui` object — see #1417). This wrapper holds that state so the
// play-driven category toggle actually hides entries, mirroring how App owns
// the state.
function StatefulNetworkScreen(args: ComponentProps<typeof NetworkScreen>) {
const [ui, setUi] = useState({ ...EMPTY_NETWORK_UI, ...args.ui });
return <NetworkScreen {...args} ui={ui} onUiChange={setUi} />;
}
const meta: Meta<typeof NetworkScreen> = {
title: "Screens/NetworkScreen",
@ -10,11 +22,14 @@ const meta: Meta<typeof NetworkScreen> = {
args: {
onClear: fn(),
onExport: fn(),
ui: EMPTY_NETWORK_UI,
onUiChange: fn(),
sortDirection: "newest-first",
onSortChange: fn(),
compact: true,
onToggleCompact: fn(),
},
render: (args) => <StatefulNetworkScreen {...args} />,
};
export default meta;

View file

@ -1,8 +1,14 @@
import { useState } from "react";
import { describe, it, expect, vi } from "vitest";
import userEvent from "@testing-library/user-event";
import type { FetchRequestEntry } from "@inspector/core/mcp/types.js";
import { renderWithMantine, screen } from "../../../test/renderWithMantine";
import { NetworkScreen } from "./NetworkScreen";
import {
NetworkScreen,
type NetworkScreenProps,
type NetworkUiState,
} from "./NetworkScreen";
import { EMPTY_NETWORK_UI } from "../screenUiState";
const transportEntry: FetchRequestEntry = {
id: "t-1",
@ -41,6 +47,8 @@ const errorEntry: FetchRequestEntry = {
const baseProps = {
entries: [transportEntry, authEntry, errorEntry],
ui: EMPTY_NETWORK_UI,
onUiChange: vi.fn(),
onClear: vi.fn(),
onExport: vi.fn(),
sortDirection: "newest-first" as const,
@ -49,6 +57,30 @@ const baseProps = {
onToggleCompact: vi.fn(),
};
// NetworkScreen is controlled: filter text + visible-category set live in the
// parent (App) as one `ui` object so they persist across tab navigation
// (#1417). This host holds that state so typing/toggling drives the rendered
// list, mirroring how App owns it. Props passed in override defaults; the
// stateful `ui` wiring is applied last so callers can still observe changes via
// the spied `onUiChange` callback.
function ControlledNetworkScreen(props: Partial<NetworkScreenProps>) {
const [ui, setUi] = useState<NetworkUiState>({
...EMPTY_NETWORK_UI,
...props.ui,
});
return (
<NetworkScreen
{...baseProps}
{...props}
ui={ui}
onUiChange={(value) => {
setUi(value);
props.onUiChange?.(value);
}}
/>
);
}
describe("NetworkScreen", () => {
it("renders the network controls and panel", () => {
renderWithMantine(<NetworkScreen {...baseProps} />);
@ -77,7 +109,7 @@ describe("NetworkScreen", () => {
it("filters by category when a category is toggled off", async () => {
const user = userEvent.setup();
renderWithMantine(<NetworkScreen {...baseProps} />);
renderWithMantine(<ControlledNetworkScreen />);
expect(
screen.getByText("https://example.com/oauth/token"),
).toBeInTheDocument();
@ -89,7 +121,7 @@ describe("NetworkScreen", () => {
it("Deselect All hides every entry; Select All restores them", async () => {
const user = userEvent.setup();
renderWithMantine(<NetworkScreen {...baseProps} />);
renderWithMantine(<ControlledNetworkScreen />);
await user.click(screen.getByRole("button", { name: "Deselect All" }));
expect(screen.getByText("No network requests")).toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "Select All" }));
@ -100,7 +132,7 @@ describe("NetworkScreen", () => {
it("filters by search text across URL, method, status, and headers", async () => {
const user = userEvent.setup();
renderWithMantine(<NetworkScreen {...baseProps} />);
renderWithMantine(<ControlledNetworkScreen />);
await user.type(screen.getByPlaceholderText("Search..."), "oauth");
expect(
screen.getByText("https://example.com/oauth/token"),

View file

@ -1,4 +1,3 @@
import { useState } from "react";
import { Card, Flex, Stack } from "@mantine/core";
import type {
FetchRequestCategory,
@ -7,9 +6,15 @@ import type {
import { NetworkControls } from "../../groups/NetworkControls/NetworkControls";
import { NetworkStreamPanel } from "../../groups/NetworkStreamPanel/NetworkStreamPanel";
import type { SortDirection } from "../../elements/SortToggle/SortToggle";
import {
ALL_CATEGORIES_VISIBLE,
NO_CATEGORIES_VISIBLE,
} from "./fetchCategories";
export interface NetworkScreenProps {
entries: FetchRequestEntry[];
ui: NetworkUiState;
onUiChange: (next: NetworkUiState) => void;
onClear: () => void;
onExport: () => void;
sortDirection: SortDirection;
@ -18,15 +23,12 @@ export interface NetworkScreenProps {
onToggleCompact: () => void;
}
const ALL_CATEGORIES_VISIBLE: Record<FetchRequestCategory, boolean> = {
auth: true,
transport: true,
};
const NO_CATEGORIES_VISIBLE: Record<FetchRequestCategory, boolean> = {
auth: false,
transport: false,
};
// Filter text + visible-category set — controlled by the parent (App) as one
// object so they persist across tab navigation within a live session (#1417).
export interface NetworkUiState {
filterText: string;
visibleCategories: Record<FetchRequestCategory, boolean>;
}
const ScreenLayout = Flex.withProps({
variant: "screen",
@ -47,6 +49,8 @@ const SidebarCard = Card.withProps({
export function NetworkScreen({
entries,
ui,
onUiChange,
onClear,
onExport,
sortDirection,
@ -54,23 +58,26 @@ export function NetworkScreen({
compact,
onToggleCompact,
}: NetworkScreenProps) {
const [filterText, setFilterText] = useState("");
const [visibleCategories, setVisibleCategories] = useState<
Record<FetchRequestCategory, boolean>
>(ALL_CATEGORIES_VISIBLE);
const { filterText, visibleCategories } = ui;
function handleToggleCategory(
category: FetchRequestCategory,
visible: boolean,
) {
setVisibleCategories((prev) => ({ ...prev, [category]: visible }));
onUiChange({
...ui,
visibleCategories: { ...visibleCategories, [category]: visible },
});
}
function handleToggleAllCategories() {
const allSelected = Object.values(visibleCategories).every(Boolean);
setVisibleCategories(
allSelected ? NO_CATEGORIES_VISIBLE : ALL_CATEGORIES_VISIBLE,
);
onUiChange({
...ui,
visibleCategories: allSelected
? NO_CATEGORIES_VISIBLE
: ALL_CATEGORIES_VISIBLE,
});
}
return (
@ -80,7 +87,7 @@ export function NetworkScreen({
<NetworkControls
filterText={filterText}
visibleCategories={visibleCategories}
onFilterChange={setFilterText}
onFilterChange={(value) => onUiChange({ ...ui, filterText: value })}
onToggleCategory={handleToggleCategory}
onToggleAllCategories={handleToggleAllCategories}
/>

View file

@ -0,0 +1,15 @@
import type { FetchRequestCategory } from "@inspector/core/mcp/types.js";
// Default visible-category filter: every category on. Shared by NetworkScreen
// (its fallback when the parent hasn't set `visibleCategories`) and App, which
// seeds and resets the lifted filter state from it (#1417). Lives in its own
// module so the screen file only exports a component (react-refresh constraint).
export const ALL_CATEGORIES_VISIBLE: Record<FetchRequestCategory, boolean> = {
auth: true,
transport: true,
};
export const NO_CATEGORIES_VISIBLE: Record<FetchRequestCategory, boolean> = {
auth: false,
transport: false,
};

View file

@ -1,19 +1,34 @@
import { useState } from "react";
import type { ComponentProps } from "react";
import type { Meta, StoryObj } from "@storybook/react-vite";
import type { Prompt } from "@modelcontextprotocol/sdk/types.js";
import { fn, userEvent, within } from "storybook/test";
import { PromptsScreen } from "./PromptsScreen";
import type { GetPromptState } from "./PromptsScreen";
import type { GetPromptState, PromptsUiState } from "./PromptsScreen";
import { EMPTY_PROMPTS_UI } from "../screenUiState";
// PromptsScreen is controlled (selection, argument values, submitted prompt, and
// search text live in the parent as one `ui` object — see #1417). This wrapper
// holds that state so the play-driven prompt clicks still drive the detail
// panel, mirroring how App owns the state in the real app.
function StatefulPromptsScreen(args: ComponentProps<typeof PromptsScreen>) {
const [ui, setUi] = useState<PromptsUiState>(args.ui ?? EMPTY_PROMPTS_UI);
return <PromptsScreen {...args} ui={ui} onUiChange={setUi} />;
}
const meta: Meta<typeof PromptsScreen> = {
title: "Screens/PromptsScreen",
component: PromptsScreen,
parameters: { layout: "fullscreen" },
args: {
ui: EMPTY_PROMPTS_UI,
onUiChange: fn(),
onRefreshList: fn(),
onGetPrompt: fn(),
onCopyMessages: fn(),
listChanged: false,
},
render: (args) => <StatefulPromptsScreen {...args} />,
};
export default meta;

View file

@ -1,8 +1,14 @@
import { useState } from "react";
import { describe, it, expect, vi } from "vitest";
import userEvent from "@testing-library/user-event";
import type { Prompt } from "@modelcontextprotocol/sdk/types.js";
import { renderWithMantine, screen } from "../../../test/renderWithMantine";
import { PromptsScreen } from "./PromptsScreen";
import {
PromptsScreen,
type PromptsScreenProps,
type PromptsUiState,
} from "./PromptsScreen";
import { EMPTY_PROMPTS_UI } from "../screenUiState";
const promptsWithArgs: Prompt[] = [
{
@ -25,10 +31,37 @@ const noArgPrompts: Prompt[] = [
const baseProps = {
prompts: promptsWithArgs,
listChanged: false,
ui: EMPTY_PROMPTS_UI,
onUiChange: vi.fn(),
onRefreshList: vi.fn(),
onGetPrompt: vi.fn(),
};
// PromptsScreen is controlled: selection, argument values, the submitted
// marker, and the sidebar search live in the parent (App) as one `ui` object so
// they persist across tab navigation (#1417). This host holds that state so
// clicking a prompt, typing arguments, submitting, and closing drive the panel
// exactly as App owns it. Props passed in override defaults; the stateful `ui`
// wiring is applied last so callers can still observe activity via the rendered
// state.
function ControlledPromptsScreen(props: Partial<PromptsScreenProps>) {
const [ui, setUi] = useState<PromptsUiState>({
...EMPTY_PROMPTS_UI,
...props.ui,
});
return (
<PromptsScreen
{...baseProps}
{...props}
ui={ui}
onUiChange={(next) => {
setUi(next);
props.onUiChange?.(next);
}}
/>
);
}
describe("PromptsScreen", () => {
it("renders the empty state when no prompt is selected", () => {
renderWithMantine(<PromptsScreen {...baseProps} />);
@ -39,7 +72,7 @@ describe("PromptsScreen", () => {
it("shows the argument form when a prompt with arguments is selected", async () => {
const user = userEvent.setup();
renderWithMantine(<PromptsScreen {...baseProps} />);
renderWithMantine(<ControlledPromptsScreen />);
await user.click(screen.getByText("summarize"));
expect(
screen.getByRole("button", { name: "Get Prompt" }),
@ -50,8 +83,7 @@ describe("PromptsScreen", () => {
const user = userEvent.setup();
const onGetPrompt = vi.fn();
renderWithMantine(
<PromptsScreen
{...baseProps}
<ControlledPromptsScreen
prompts={noArgPrompts}
onGetPrompt={onGetPrompt}
/>,
@ -68,8 +100,7 @@ describe("PromptsScreen", () => {
const user = userEvent.setup();
const onGetPrompt = vi.fn();
const { rerender } = renderWithMantine(
<PromptsScreen
{...baseProps}
<ControlledPromptsScreen
prompts={noArgPrompts}
onGetPrompt={onGetPrompt}
/>,
@ -79,8 +110,7 @@ describe("PromptsScreen", () => {
// Parent re-renders with a fresh pending state — the fetch must not
// re-fire just because props changed.
rerender(
<PromptsScreen
{...baseProps}
<ControlledPromptsScreen
prompts={noArgPrompts}
onGetPrompt={onGetPrompt}
getPromptState={{ status: "pending", promptName: "ping" }}
@ -93,8 +123,7 @@ describe("PromptsScreen", () => {
const user = userEvent.setup();
const onGetPrompt = vi.fn();
renderWithMantine(
<PromptsScreen
{...baseProps}
<ControlledPromptsScreen
onGetPrompt={onGetPrompt}
getPromptState={{
status: "ok",
@ -119,8 +148,7 @@ describe("PromptsScreen", () => {
it("shows pending state once the user has submitted", async () => {
const user = userEvent.setup();
renderWithMantine(
<PromptsScreen
{...baseProps}
<ControlledPromptsScreen
getPromptState={{ status: "pending", promptName: "summarize" }}
/>,
);
@ -133,8 +161,7 @@ describe("PromptsScreen", () => {
it("shows error state once the user has submitted", async () => {
const user = userEvent.setup();
renderWithMantine(
<PromptsScreen
{...baseProps}
<ControlledPromptsScreen
getPromptState={{
status: "error",
promptName: "summarize",
@ -152,8 +179,7 @@ describe("PromptsScreen", () => {
it("falls back to a default error message when none is provided", async () => {
const user = userEvent.setup();
renderWithMantine(
<PromptsScreen
{...baseProps}
<ControlledPromptsScreen
getPromptState={{ status: "error", promptName: "summarize" }}
/>,
);
@ -166,8 +192,7 @@ describe("PromptsScreen", () => {
it("ignores a stale getPromptState whose name does not match the selection", async () => {
const user = userEvent.setup();
renderWithMantine(
<PromptsScreen
{...baseProps}
<ControlledPromptsScreen
// Stale result for a prompt the user is no longer viewing.
getPromptState={{
status: "ok",
@ -190,8 +215,7 @@ describe("PromptsScreen", () => {
const user = userEvent.setup();
const onGetPrompt = vi.fn();
renderWithMantine(
<PromptsScreen
{...baseProps}
<ControlledPromptsScreen
prompts={noArgPrompts}
onGetPrompt={onGetPrompt}
/>,
@ -210,9 +234,7 @@ describe("PromptsScreen", () => {
{ name: "alpha", arguments: [{ name: "x" }] },
{ name: "beta", arguments: [{ name: "y" }] },
];
renderWithMantine(
<PromptsScreen {...baseProps} prompts={arglessTwoStep} />,
);
renderWithMantine(<ControlledPromptsScreen prompts={arglessTwoStep} />);
await user.click(screen.getByText("alpha"));
await user.type(screen.getByPlaceholderText("Enter x..."), "value");
await user.click(screen.getByText("beta"));
@ -222,8 +244,7 @@ describe("PromptsScreen", () => {
it("closing the preview from the error state brings the form back", async () => {
const user = userEvent.setup();
renderWithMantine(
<PromptsScreen
{...baseProps}
<ControlledPromptsScreen
getPromptState={{
status: "error",
promptName: "summarize",
@ -244,8 +265,7 @@ describe("PromptsScreen", () => {
it("closing the preview for an arg-bearing prompt brings the form back", async () => {
const user = userEvent.setup();
renderWithMantine(
<PromptsScreen
{...baseProps}
<ControlledPromptsScreen
getPromptState={{
status: "ok",
promptName: "summarize",
@ -270,8 +290,7 @@ describe("PromptsScreen", () => {
it("closing the preview for a no-arg prompt drops the selection", async () => {
const user = userEvent.setup();
renderWithMantine(
<PromptsScreen
{...baseProps}
<ControlledPromptsScreen
prompts={noArgPrompts}
getPromptState={{
status: "ok",
@ -305,8 +324,7 @@ describe("PromptsScreen", () => {
>()
.mockResolvedValue([]);
renderWithMantine(
<PromptsScreen
{...baseProps}
<ControlledPromptsScreen
completionsSupported
onCompleteArgument={onCompleteArgument}
/>,

View file

@ -1,4 +1,3 @@
import { useState } from "react";
import {
Alert,
Card,
@ -31,8 +30,10 @@ export interface GetPromptState {
export interface PromptsScreenProps {
prompts: Prompt[];
getPromptState?: GetPromptState;
ui: PromptsUiState;
listChanged: boolean;
completionsSupported?: boolean;
onUiChange: (next: PromptsUiState) => void;
onRefreshList: () => void;
onGetPrompt: (name: string, args: Record<string, string>) => void;
onCopyMessages?: () => void;
@ -46,6 +47,16 @@ export interface PromptsScreenProps {
) => Promise<string[]>;
}
// Selection, argument values, the "submitted" marker, and the sidebar search —
// controlled by the parent (App) as one object so they persist across tab
// navigation within a live session (#1417).
export interface PromptsUiState {
selectedPromptName?: string;
argumentValues: Record<string, string>;
submittedFor?: string;
search: string;
}
const ScreenLayout = Flex.withProps({
variant: "screen",
h: "calc(100vh - var(--app-shell-header-height, 0px))",
@ -102,26 +113,16 @@ function hasArguments(prompt: Prompt): boolean {
export function PromptsScreen({
prompts,
getPromptState,
ui,
listChanged,
completionsSupported,
onUiChange,
onRefreshList,
onGetPrompt,
onCopyMessages,
onCompleteArgument,
}: PromptsScreenProps) {
const [selectedPromptName, setSelectedPromptName] = useState<
string | undefined
>(undefined);
const [argumentValues, setArgumentValues] = useState<Record<string, string>>(
{},
);
// Track which prompt the user has already submitted in this session so
// the form pane disappears once the user clicks Get Prompt. Cleared on
// sidebar switch so the form re-appears for a freshly-selected prompt.
const [submittedFor, setSubmittedFor] = useState<string | undefined>(
undefined,
);
const { selectedPromptName, argumentValues, submittedFor, search } = ui;
const selectedPrompt = selectedPromptName
? prompts.find((p) => p.name === selectedPromptName)
: undefined;
@ -132,23 +133,23 @@ export function PromptsScreen({
// for navigation, ✕ is for dismiss. Closing-then-reselecting is
// its own thing (the close handler clears submittedFor).
if (name === selectedPromptName) return;
setArgumentValues({});
setSelectedPromptName(name);
// Auto-fetch no-argument prompts the moment they're selected — the
// form pane would otherwise just render a bare Get Prompt button
// with nothing to fill in. Prompts with arguments wait for submit.
const target = prompts.find((p) => p.name === name);
if (target && !hasArguments(target)) {
setSubmittedFor(name);
onGetPrompt(name, {});
} else {
setSubmittedFor(undefined);
}
const autoFetch = !!target && !hasArguments(target);
onUiChange({
...ui,
argumentValues: {},
selectedPromptName: name,
submittedFor: autoFetch ? name : undefined,
});
if (autoFetch) onGetPrompt(name, {});
}
function handleSubmit() {
if (!selectedPrompt) return;
setSubmittedFor(selectedPrompt.name);
onUiChange({ ...ui, submittedFor: selectedPrompt.name });
onGetPrompt(selectedPrompt.name, argumentValues);
}
@ -158,10 +159,13 @@ export function PromptsScreen({
// prompts there's no form to return to, so drop the selection and
// fall back to the empty state.
if (selectedPrompt && hasArguments(selectedPrompt)) {
setSubmittedFor(undefined);
onUiChange({ ...ui, submittedFor: undefined });
} else {
setSelectedPromptName(undefined);
setSubmittedFor(undefined);
onUiChange({
...ui,
selectedPromptName: undefined,
submittedFor: undefined,
});
}
}
@ -235,8 +239,10 @@ export function PromptsScreen({
<PromptControls
prompts={prompts}
selectedName={selectedPromptName}
searchText={search}
listChanged={listChanged}
onRefreshList={onRefreshList}
onSearchChange={(value) => onUiChange({ ...ui, search: value })}
onSelectPrompt={handleSelectPrompt}
/>
</SidebarCard>
@ -256,7 +262,10 @@ export function PromptsScreen({
prompt={selectedPrompt}
argumentValues={argumentValues}
onArgumentChange={(argName, value) =>
setArgumentValues((prev) => ({ ...prev, [argName]: value }))
onUiChange({
...ui,
argumentValues: { ...argumentValues, [argName]: value },
})
}
onGetPrompt={handleSubmit}
completionsSupported={completionsSupported}

View file

@ -1,3 +1,5 @@
import { useState } from "react";
import type { ComponentProps } from "react";
import type { Meta, StoryObj } from "@storybook/react-vite";
import type {
Resource,
@ -6,13 +8,26 @@ import type {
import type { InspectorResourceSubscription } from "../../../../../../core/mcp/types.js";
import { fn, userEvent, within } from "storybook/test";
import { ResourcesScreen } from "./ResourcesScreen";
import type { ReadResourceState } from "./ResourcesScreen";
import type { ReadResourceState, ResourcesUiState } from "./ResourcesScreen";
import { EMPTY_RESOURCES_UI } from "../screenUiState";
// ResourcesScreen is controlled (resource/template selection, the originating
// template, search text, and open accordion sections live in the parent as one
// `ui` object — see #1417). This wrapper holds that state so the play-driven
// clicks still drive the detail/preview panels, mirroring how App owns the state
// in the real app.
function StatefulResourcesScreen(args: ComponentProps<typeof ResourcesScreen>) {
const [ui, setUi] = useState<ResourcesUiState>(args.ui ?? EMPTY_RESOURCES_UI);
return <ResourcesScreen {...args} ui={ui} onUiChange={setUi} />;
}
const meta: Meta<typeof ResourcesScreen> = {
title: "Screens/ResourcesScreen",
component: ResourcesScreen,
parameters: { layout: "fullscreen" },
args: {
ui: EMPTY_RESOURCES_UI,
onUiChange: fn(),
onRefreshList: fn(),
onReadResource: fn(),
onSubscribeResource: fn(),
@ -23,6 +38,7 @@ const meta: Meta<typeof ResourcesScreen> = {
compact: false,
onCompactChange: fn(),
},
render: (args) => <StatefulResourcesScreen {...args} />,
};
export default meta;

View file

@ -1,3 +1,4 @@
import { useState } from "react";
import { describe, it, expect, vi } from "vitest";
import userEvent from "@testing-library/user-event";
import type {
@ -6,7 +7,12 @@ import type {
ReadResourceResult,
} from "@modelcontextprotocol/sdk/types.js";
import { renderWithMantine, screen } from "../../../test/renderWithMantine";
import { ResourcesScreen } from "./ResourcesScreen";
import {
ResourcesScreen,
type ResourcesScreenProps,
type ResourcesUiState,
} from "./ResourcesScreen";
import { EMPTY_RESOURCES_UI } from "../screenUiState";
const resources: Resource[] = [
{ uri: "file:///x", name: "x.txt" },
@ -22,6 +28,8 @@ const baseProps = {
templates,
subscriptions: [],
listChanged: false,
ui: EMPTY_RESOURCES_UI,
onUiChange: vi.fn(),
onRefreshList: vi.fn(),
onReadResource: vi.fn(),
onSubscribeResource: vi.fn(),
@ -34,6 +42,32 @@ const okResult: ReadResourceResult = {
contents: [{ uri: "file:///x", text: "embedded contents" }],
};
// ResourcesScreen is controlled: the selected resource/template URIs, the
// originating-template marker, the sidebar search, and the accordion's open
// sections live in the parent (App) as one `ui` object so they persist across
// tab navigation (#1417). This host holds that state so clicking a
// resource/template, typing into the template form, reading, and closing drive
// the panel exactly as App owns it. Props passed in override defaults; the
// stateful `ui` wiring is applied last so callers can still observe activity via
// the rendered state.
function ControlledResourcesScreen(props: Partial<ResourcesScreenProps>) {
const [ui, setUi] = useState<ResourcesUiState>({
...EMPTY_RESOURCES_UI,
...props.ui,
});
return (
<ResourcesScreen
{...baseProps}
{...props}
ui={ui}
onUiChange={(next) => {
setUi(next);
props.onUiChange?.(next);
}}
/>
);
}
describe("ResourcesScreen", () => {
it("renders empty preview state when nothing is selected", () => {
renderWithMantine(<ResourcesScreen {...baseProps} />);
@ -45,8 +79,7 @@ describe("ResourcesScreen", () => {
it("shows the read error alert when error and a resource is selected", async () => {
const user = userEvent.setup();
renderWithMantine(
<ResourcesScreen
{...baseProps}
<ControlledResourcesScreen
readState={{ status: "error", error: "boom" }}
/>,
);
@ -58,7 +91,7 @@ describe("ResourcesScreen", () => {
it("falls back to default error when error message is missing", async () => {
const user = userEvent.setup();
renderWithMantine(
<ResourcesScreen {...baseProps} readState={{ status: "error" }} />,
<ControlledResourcesScreen readState={{ status: "error" }} />,
);
await user.click(screen.getByText("x.txt"));
expect(screen.getByText("Failed to read resource")).toBeInTheDocument();
@ -67,7 +100,7 @@ describe("ResourcesScreen", () => {
it("shows the loading state when reading", async () => {
const user = userEvent.setup();
renderWithMantine(
<ResourcesScreen {...baseProps} readState={{ status: "pending" }} />,
<ControlledResourcesScreen readState={{ status: "pending" }} />,
);
await user.click(screen.getByText("x.txt"));
expect(screen.getByText("Reading resource...")).toBeInTheDocument();
@ -76,8 +109,7 @@ describe("ResourcesScreen", () => {
it("renders the preview panel when readState has a result", async () => {
const user = userEvent.setup();
renderWithMantine(
<ResourcesScreen
{...baseProps}
<ControlledResourcesScreen
readState={{
status: "ok",
uri: "file:///x",
@ -108,7 +140,7 @@ describe("ResourcesScreen", () => {
it("renders the template panel when a template is selected", async () => {
const user = userEvent.setup();
renderWithMantine(<ResourcesScreen {...baseProps} />);
renderWithMantine(<ControlledResourcesScreen />);
await user.click(screen.getByText("Templates (1)"));
await user.click(screen.getByText("files"));
expect(
@ -120,7 +152,7 @@ describe("ResourcesScreen", () => {
const user = userEvent.setup();
const onReadResource = vi.fn();
renderWithMantine(
<ResourcesScreen {...baseProps} onReadResource={onReadResource} />,
<ControlledResourcesScreen onReadResource={onReadResource} />,
);
await user.click(screen.getByText("Templates (1)"));
await user.click(screen.getByText("files"));
@ -137,7 +169,7 @@ describe("ResourcesScreen", () => {
const user = userEvent.setup();
const onReadResource = vi.fn();
renderWithMantine(
<ResourcesScreen {...baseProps} onReadResource={onReadResource} />,
<ControlledResourcesScreen onReadResource={onReadResource} />,
);
await user.click(screen.getByText("x.txt"));
expect(onReadResource).toHaveBeenCalledWith("file:///x");
@ -148,8 +180,7 @@ describe("ResourcesScreen", () => {
const onReadResource = vi.fn();
const onSubscribeResource = vi.fn();
renderWithMantine(
<ResourcesScreen
{...baseProps}
<ControlledResourcesScreen
onReadResource={onReadResource}
onSubscribeResource={onSubscribeResource}
readState={{
@ -174,8 +205,7 @@ describe("ResourcesScreen", () => {
{ uriTemplate: "file:///{path}", name: "files" },
];
const { rerender } = renderWithMantine(
<ResourcesScreen
{...baseProps}
<ControlledResourcesScreen
templates={templates}
onReadResource={onReadResource}
/>,
@ -191,8 +221,7 @@ describe("ResourcesScreen", () => {
// Parent re-renders with the read result; the preview appears.
rerender(
<ResourcesScreen
{...baseProps}
<ControlledResourcesScreen
templates={templates}
onReadResource={onReadResource}
readState={{
@ -218,7 +247,7 @@ describe("ResourcesScreen", () => {
{ uriTemplate: "demo://resource/dynamic/text/{id}", name: "Dynamic" },
];
const { rerender } = renderWithMantine(
<ResourcesScreen {...baseProps} templates={templates} />,
<ControlledResourcesScreen templates={templates} />,
);
await user.click(screen.getByText("Templates (1)"));
await user.click(screen.getByText("Dynamic"));
@ -227,8 +256,7 @@ describe("ResourcesScreen", () => {
// Server rejects the URI.
rerender(
<ResourcesScreen
{...baseProps}
<ControlledResourcesScreen
templates={templates}
readState={{
status: "error",
@ -248,8 +276,7 @@ describe("ResourcesScreen", () => {
it("closing the preview for a plain resource returns to the empty state", async () => {
const user = userEvent.setup();
renderWithMantine(
<ResourcesScreen
{...baseProps}
<ControlledResourcesScreen
readState={{
status: "ok",
uri: "file:///x",
@ -268,8 +295,7 @@ describe("ResourcesScreen", () => {
const user = userEvent.setup();
const onUnsubscribeResource = vi.fn();
renderWithMantine(
<ResourcesScreen
{...baseProps}
<ControlledResourcesScreen
onUnsubscribeResource={onUnsubscribeResource}
readState={{
status: "ok",

View file

@ -1,4 +1,3 @@
import { useState } from "react";
import {
Alert,
Card,
@ -33,8 +32,10 @@ export interface ResourcesScreenProps {
templates: ResourceTemplate[];
subscriptions: InspectorResourceSubscription[];
readState?: ReadResourceState;
ui: ResourcesUiState;
listChanged: boolean;
completionsSupported?: boolean;
onUiChange: (next: ResourcesUiState) => void;
onRefreshList: () => void;
onReadResource: (uri: string) => void;
onSubscribeResource: (uri: string) => void;
@ -51,6 +52,19 @@ export interface ResourcesScreenProps {
onCompactChange: (next: boolean) => void;
}
// Selection (resource URI, template URI, the originating-template marker), the
// sidebar search, and accordion open-sections — controlled by the parent (App)
// as one object so they persist across tab navigation within a live session
// (#1417). `openSections` undefined → ResourceControls falls back to the
// compact-derived default.
export interface ResourcesUiState {
selectedResourceUri?: string;
selectedTemplateUri?: string;
originatingTemplateUri?: string;
search: string;
openSections?: string[];
}
const ScreenLayout = Flex.withProps({
variant: "screen",
h: "calc(100vh - var(--app-shell-header-height, 0px))",
@ -107,8 +121,10 @@ export function ResourcesScreen({
templates,
subscriptions,
readState,
ui,
listChanged,
completionsSupported,
onUiChange,
onRefreshList,
onReadResource,
onSubscribeResource,
@ -117,20 +133,13 @@ export function ResourcesScreen({
compact,
onCompactChange,
}: ResourcesScreenProps) {
const [selectedResourceUri, setSelectedResourceUri] = useState<
string | undefined
>(undefined);
const [selectedTemplateUri, setSelectedTemplateUri] = useState<
string | undefined
>(undefined);
// Tracks which template (if any) produced the current preview so that
// closing the preview can restore the template form. Cleared when the
// user navigates to a non-template resource or picks a different
// template directly from the sidebar.
const [originatingTemplateUri, setOriginatingTemplateUri] = useState<
string | undefined
>(undefined);
const {
selectedResourceUri,
selectedTemplateUri,
originatingTemplateUri,
search,
openSections,
} = ui;
const selectedResource = selectedResourceUri
? resources.find((r) => r.uri === selectedResourceUri)
: undefined;
@ -147,16 +156,22 @@ export function ResourcesScreen({
: undefined);
function handleSelectResource(uri: string) {
setSelectedTemplateUri(undefined);
setSelectedResourceUri(uri);
setOriginatingTemplateUri(undefined);
onUiChange({
...ui,
selectedTemplateUri: undefined,
selectedResourceUri: uri,
originatingTemplateUri: undefined,
});
onReadResource(uri);
}
function handleSelectTemplate(uriTemplate: string) {
setSelectedResourceUri(undefined);
setSelectedTemplateUri(uriTemplate);
setOriginatingTemplateUri(undefined);
onUiChange({
...ui,
selectedResourceUri: undefined,
selectedTemplateUri: uriTemplate,
originatingTemplateUri: undefined,
});
}
function handleReadResource(uri: string) {
@ -165,19 +180,25 @@ export function ResourcesScreen({
// clearing the template selection hides the template form so only
// the rendered resource is shown. We remember the template URI so
// closing the preview can restore the form.
if (selectedTemplateUri) {
setOriginatingTemplateUri(selectedTemplateUri);
}
setSelectedTemplateUri(undefined);
setSelectedResourceUri(uri);
onUiChange({
...ui,
originatingTemplateUri: selectedTemplateUri ?? originatingTemplateUri,
selectedTemplateUri: undefined,
selectedResourceUri: uri,
});
onReadResource(uri);
}
function handleClosePreview() {
setSelectedResourceUri(undefined);
if (originatingTemplateUri) {
setSelectedTemplateUri(originatingTemplateUri);
setOriginatingTemplateUri(undefined);
onUiChange({
...ui,
selectedResourceUri: undefined,
selectedTemplateUri: originatingTemplateUri,
originatingTemplateUri: undefined,
});
} else {
onUiChange({ ...ui, selectedResourceUri: undefined });
}
}
@ -251,8 +272,14 @@ export function ResourcesScreen({
subscriptions={subscriptions}
selectedUri={selectedResourceUri}
selectedTemplateUri={selectedTemplateUri}
searchText={search}
openSections={openSections}
listChanged={listChanged}
onRefreshList={onRefreshList}
onSearchChange={(value) => onUiChange({ ...ui, search: value })}
onOpenSectionsChange={(value) =>
onUiChange({ ...ui, openSections: value })
}
onSelectUri={handleSelectResource}
onSelectTemplate={handleSelectTemplate}
onUnsubscribeResource={onUnsubscribeResource}

Some files were not shown because too many files have changed in this diff Show more