From 8c1d13dbd729e26d92cca4bc9f6e38260d6d4a71 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Tue, 2 Jun 2026 19:58:56 -0400 Subject: [PATCH 1/6] feat(web): tool progress toasts + persist tool selection/result across navigation (#1414) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- clients/web/src/App.test.tsx | 107 ++++++++++++++++++ clients/web/src/App.tsx | 105 ++++++++++++++++- .../ToolsScreen/ToolsScreen.stories.tsx | 33 +++++- .../screens/ToolsScreen/ToolsScreen.test.tsx | 80 +++++++++++-- .../screens/ToolsScreen/ToolsScreen.tsx | 47 ++++---- .../InspectorView/InspectorView.stories.tsx | 2 + .../InspectorView/InspectorView.test.tsx | 2 + .../views/InspectorView/InspectorView.tsx | 14 +++ 8 files changed, 346 insertions(+), 44 deletions(-) diff --git a/clients/web/src/App.test.tsx b/clients/web/src/App.test.tsx index 19d43ae48..4cff411e7 100644 --- a/clients/web/src/App.test.tsx +++ b/clients/web/src/App.test.tsx @@ -7,6 +7,21 @@ import { } from "./test/renderWithMantine"; import userEvent from "@testing-library/user-event"; +// Spy on the toast layer so the progress-notification tests can assert the +// show/update calls without mounting Mantine's portal. +// `vi.hoisted` lets the mock factory (hoisted above imports) reach the spies. +const { notificationsMock } = vi.hoisted(() => ({ + notificationsMock: { + show: vi.fn(), + update: vi.fn(), + hide: vi.fn(), + clean: vi.fn(), + }, +})); +vi.mock("@mantine/notifications", () => ({ + notifications: notificationsMock, +})); + // App is a wiring component: it owns session-scoped UI state (the per-call // result panels and the optimistic log level) and resets it when the active // InspectorClient emits `disconnect`. These tests exercise that reset in @@ -177,10 +192,12 @@ vi.mock("@inspector/core/react/useSettingsDraft.js", () => ({ vi.mock("./components/views/InspectorView/InspectorView", () => ({ InspectorView: (props: { toolCallState?: { status?: string }; + selectedToolName?: string; getPromptState?: { status?: string }; readResourceState?: { status?: string }; currentLogLevel?: string; onToggleConnection: (id: string) => void; + onSelectTool: (name: string) => void; onCallTool: (name: string, args: Record) => void; onGetPrompt: (name: string, args: Record) => void; onReadResource: (uri: string) => void; @@ -190,6 +207,9 @@ vi.mock("./components/views/InspectorView/InspectorView", () => ({ {props.toolCallState?.status ?? "none"} + + {props.selectedToolName ?? "none"} + {props.getPromptState?.status ?? "none"} @@ -198,6 +218,9 @@ vi.mock("./components/views/InspectorView/InspectorView", () => ({ {props.currentLogLevel} + + + + - - - - diff --git a/clients/web/src/App.tsx b/clients/web/src/App.tsx index 14f94db95..28753d6c0 100644 --- a/clients/web/src/App.tsx +++ b/clients/web/src/App.tsx @@ -15,7 +15,6 @@ import type { LoggingMessageNotification, Progress, ProgressToken, - TaskStatus, Tool, } from "@modelcontextprotocol/sdk/types.js"; import { InspectorClient } from "@inspector/core/mcp/index.js"; @@ -25,11 +24,9 @@ import type { } from "@inspector/core/mcp/index.js"; import type { TypedEventGeneric } from "@inspector/core/mcp/typedEventTarget.js"; import type { - FetchRequestCategory, InspectorServerSettings, MCPServerConfig, MessageEntry, - MessageMethod, ServerEntry, ServerType, } from "@inspector/core/mcp/types.js"; @@ -71,8 +68,16 @@ import { InspectorView } from "./components/views/InspectorView/InspectorView"; import type { ToolCallState } from "./components/screens/ToolsScreen/ToolsScreen"; import type { GetPromptState } from "./components/screens/PromptsScreen/PromptsScreen"; import type { ReadResourceState } from "./components/screens/ResourcesScreen/ResourcesScreen"; -import { ALL_LEVELS_VISIBLE } from "./components/screens/LoggingScreen/logLevels"; -import { ALL_CATEGORIES_VISIBLE } from "./components/screens/NetworkScreen/fetchCategories"; +import { + EMPTY_TOOLS_UI, + EMPTY_PROMPTS_UI, + EMPTY_RESOURCES_UI, + EMPTY_APPS_UI, + EMPTY_TASKS_UI, + EMPTY_LOGS_UI, + EMPTY_HISTORY_UI, + EMPTY_NETWORK_UI, +} from "./components/screens/screenUiState"; import { clearScrollMemory } from "./hooks/useScrollMemory"; import type { AppRendererHandle } from "./components/elements/AppRenderer/AppRenderer"; import { createAppBridgeFactory } from "./components/elements/AppRenderer/createAppBridgeFactory"; @@ -347,18 +352,6 @@ function App() { const [toolCallState, setToolCallState] = useState( undefined, ); - // Selected tool + its form values live here (not inside ToolsScreen) so they - // survive a tab switch: InspectorView unmounts the outgoing screen, so local - // selection would be lost. Persisting them alongside `toolCallState` keeps the - // whole tool context — selection, inputs, and result — in place when the user - // navigates away (e.g. to watch progress) and back, within a live session - // (#1414). All three reset together on disconnect via `resetSessionScopedUiState`. - const [selectedToolName, setSelectedToolName] = useState( - undefined, - ); - const [toolFormValues, setToolFormValues] = useState>( - {}, - ); const [getPromptState, setGetPromptState] = useState< GetPromptState | undefined >(undefined); @@ -366,65 +359,22 @@ function App() { ReadResourceState | undefined >(undefined); - // Per-screen selection / search / filter state. Lifted here (out of the - // individual screens) so it persists across tab navigation within a live - // session — the screens unmount on tab switch, so screen-local state would be - // lost. Cleared only on disconnect (via `resetSessionScopedUiState`) or an - // explicit user action, never on plain navigation (#1417). Grouped by screen. - // Tools (selection/form live above with `toolCallState`): - const [toolSearch, setToolSearch] = useState(""); - // Prompts: - const [selectedPromptName, setSelectedPromptName] = useState< - string | undefined - >(undefined); - const [promptArgumentValues, setPromptArgumentValues] = useState< - Record - >({}); - const [promptSubmittedFor, setPromptSubmittedFor] = useState< - string | undefined - >(undefined); - const [promptSearch, setPromptSearch] = useState(""); - // Resources (`openSections` undefined → screen falls back to compact pref): - const [selectedResourceUri, setSelectedResourceUri] = useState< - string | undefined - >(undefined); - const [selectedTemplateUri, setSelectedTemplateUri] = useState< - string | undefined - >(undefined); - const [originatingTemplateUri, setOriginatingTemplateUri] = useState< - string | undefined - >(undefined); - const [resourceSearch, setResourceSearch] = useState(""); - const [resourceOpenSections, setResourceOpenSections] = useState< - string[] | undefined - >(undefined); - // Apps (selected app + form values; running/maximized stay screen-local): - const [selectedAppName, setSelectedAppName] = useState( - undefined, - ); - const [appFormValues, setAppFormValues] = useState>( - {}, - ); - const [appSearch, setAppSearch] = useState(""); - // Tasks: - const [taskSearch, setTaskSearch] = useState(""); - const [taskStatusFilter, setTaskStatusFilter] = useState< - TaskStatus | undefined - >(undefined); - // Logs: - const [logFilterText, setLogFilterText] = useState(""); - const [logVisibleLevels, setLogVisibleLevels] = - useState>(ALL_LEVELS_VISIBLE); - // History: - const [historySearch, setHistorySearch] = useState(""); - const [historyMethodFilter, setHistoryMethodFilter] = useState< - MessageMethod | undefined - >(undefined); - // Network: - const [networkFilterText, setNetworkFilterText] = useState(""); - const [networkVisibleCategories, setNetworkVisibleCategories] = useState< - Record - >(ALL_CATEGORIES_VISIBLE); + // Per-screen selection / search / filter state, one object per screen. Lifted + // here (out of the individual screens) so it persists across tab navigation + // within a live session — the screens unmount on tab switch, so screen-local + // state would be lost. Cleared only on disconnect (via + // `resetSessionScopedUiState`) or an explicit user action, never on plain + // navigation (#1414/#1417). The in-flight result panels (`toolCallState` / + // `getPromptState` / `readResourceState`) stay separate — they're written by + // the async action handlers below, not by the screens. + const [toolsUi, setToolsUi] = useState(EMPTY_TOOLS_UI); + const [promptsUi, setPromptsUi] = useState(EMPTY_PROMPTS_UI); + const [resourcesUi, setResourcesUi] = useState(EMPTY_RESOURCES_UI); + const [appsUi, setAppsUi] = useState(EMPTY_APPS_UI); + const [tasksUi, setTasksUi] = useState(EMPTY_TASKS_UI); + const [logsUi, setLogsUi] = useState(EMPTY_LOGS_UI); + const [historyUi, setHistoryUi] = useState(EMPTY_HISTORY_UI); + const [networkUi, setNetworkUi] = useState(EMPTY_NETWORK_UI); // Handshake telemetry. `connectStartRef` is set at the "connecting" edge // and consumed at the "connected" edge — a ref (not state) so the @@ -526,31 +476,16 @@ function App() { // Setters are stable, so the callback identity never changes. const resetSessionScopedUiState = useCallback(() => { setToolCallState(undefined); - setSelectedToolName(undefined); - setToolFormValues({}); - setToolSearch(""); setGetPromptState(undefined); - setSelectedPromptName(undefined); - setPromptArgumentValues({}); - setPromptSubmittedFor(undefined); - setPromptSearch(""); setReadResourceState(undefined); - setSelectedResourceUri(undefined); - setSelectedTemplateUri(undefined); - setOriginatingTemplateUri(undefined); - setResourceSearch(""); - setResourceOpenSections(undefined); - setSelectedAppName(undefined); - setAppFormValues({}); - setAppSearch(""); - setTaskSearch(""); - setTaskStatusFilter(undefined); - setLogFilterText(""); - setLogVisibleLevels(ALL_LEVELS_VISIBLE); - setHistorySearch(""); - setHistoryMethodFilter(undefined); - setNetworkFilterText(""); - setNetworkVisibleCategories(ALL_CATEGORIES_VISIBLE); + setToolsUi(EMPTY_TOOLS_UI); + setPromptsUi(EMPTY_PROMPTS_UI); + setResourcesUi(EMPTY_RESOURCES_UI); + setAppsUi(EMPTY_APPS_UI); + setTasksUi(EMPTY_TASKS_UI); + setLogsUi(EMPTY_LOGS_UI); + setHistoryUi(EMPTY_HISTORY_UI); + setNetworkUi(EMPTY_NETWORK_UI); setCurrentLogLevel("info"); // Remembered scroll offsets are session-scoped too — drop them so the next // session's screens start at the top (#1417). @@ -1611,31 +1546,16 @@ function App() { history={messages} network={fetchRequests} toolCallState={toolCallState} - selectedToolName={selectedToolName} - toolFormValues={toolFormValues} - toolSearch={toolSearch} getPromptState={getPromptState} - selectedPromptName={selectedPromptName} - promptArgumentValues={promptArgumentValues} - promptSubmittedFor={promptSubmittedFor} - promptSearch={promptSearch} readResourceState={effectiveReadResourceState} - selectedResourceUri={selectedResourceUri} - selectedTemplateUri={selectedTemplateUri} - originatingTemplateUri={originatingTemplateUri} - resourceSearch={resourceSearch} - resourceOpenSections={resourceOpenSections} - selectedAppName={selectedAppName} - appFormValues={appFormValues} - appSearch={appSearch} - taskSearch={taskSearch} - taskStatusFilter={taskStatusFilter} - logFilterText={logFilterText} - logVisibleLevels={logVisibleLevels} - historySearch={historySearch} - historyMethodFilter={historyMethodFilter} - networkFilterText={networkFilterText} - networkVisibleCategories={networkVisibleCategories} + toolsUi={toolsUi} + promptsUi={promptsUi} + resourcesUi={resourcesUi} + appsUi={appsUi} + tasksUi={tasksUi} + logsUi={logsUi} + historyUi={historyUi} + networkUi={networkUi} currentLogLevel={currentLogLevel} sandboxPath={sandboxUrl} bridgeFactory={sandboxBridgeFactory} @@ -1659,27 +1579,18 @@ function App() { const target = servers.find((s) => s.id === id); if (target) setRemoveTarget(target); }} - onSelectTool={setSelectedToolName} - onToolFormChange={setToolFormValues} - onToolSearchChange={setToolSearch} + onToolsUiChange={setToolsUi} onCallTool={(name, args) => { void onCallTool(name, args); }} onClearToolResult={onClearToolResult} onRefreshTools={onRefreshTools} - onSelectedPromptNameChange={setSelectedPromptName} - onPromptArgumentValuesChange={setPromptArgumentValues} - onPromptSubmittedForChange={setPromptSubmittedFor} - onPromptSearchChange={setPromptSearch} + onPromptsUiChange={setPromptsUi} onGetPrompt={(name, args) => { void onGetPrompt(name, args); }} onRefreshPrompts={onRefreshPrompts} - onSelectedResourceUriChange={setSelectedResourceUri} - onSelectedTemplateUriChange={setSelectedTemplateUri} - onOriginatingTemplateUriChange={setOriginatingTemplateUri} - onResourceSearchChange={setResourceSearch} - onResourceOpenSectionsChange={setResourceOpenSections} + onResourcesUiChange={setResourcesUi} onReadResource={(uri) => { void onReadResource(uri); }} @@ -1688,29 +1599,23 @@ function App() { onRefreshResources={onRefreshResources} onCompleteArgument={onCompleteArgument} completionsSupported={capabilities?.completions !== undefined} - onTaskSearchChange={setTaskSearch} - onTaskStatusFilterChange={setTaskStatusFilter} + onTasksUiChange={setTasksUi} onCancelTask={onCancelTask} onClearCompletedTasks={todoNoop} onRefreshTasks={onRefreshTasks} onSetLogLevel={onSetLogLevel} - onLogFilterChange={setLogFilterText} - onLogVisibleLevelsChange={setLogVisibleLevels} + onLogsUiChange={setLogsUi} onClearLogs={onClearLogs} onExportLogs={onExportLogs} - onHistorySearchChange={setHistorySearch} - onHistoryMethodFilterChange={setHistoryMethodFilter} + onHistoryUiChange={setHistoryUi} onClearHistory={onClearHistory} onExportHistory={onExportHistory} onReplayHistory={todoNoop} onTogglePinHistory={todoNoop} - onNetworkFilterChange={setNetworkFilterText} - onNetworkVisibleCategoriesChange={setNetworkVisibleCategories} + onNetworkUiChange={setNetworkUi} onClearNetwork={onClearNetwork} onExportNetwork={onExportNetwork} - onSelectedAppNameChange={setSelectedAppName} - onAppFormValuesChange={setAppFormValues} - onAppSearchChange={setAppSearch} + onAppsUiChange={setAppsUi} onSelectApp={onSelectApp} onOpenApp={(name, args) => { void onOpenApp(name, args); diff --git a/clients/web/src/components/screens/AppsScreen/AppsScreen.stories.tsx b/clients/web/src/components/screens/AppsScreen/AppsScreen.stories.tsx index 5d963b6fa..a517e8feb 100644 --- a/clients/web/src/components/screens/AppsScreen/AppsScreen.stories.tsx +++ b/clients/web/src/components/screens/AppsScreen/AppsScreen.stories.tsx @@ -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, @@ -81,23 +86,13 @@ const dashboardApp: Tool = { const sampleApps: Tool[] = [cohortApp, weatherApp, dashboardApp]; // AppsScreen is controlled (app selection, form values, and search text live in -// the parent — 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. +// 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 [selectedAppName, setSelectedAppName] = useState(args.selectedAppName); - const [formValues, setFormValues] = useState>( - args.formValues ?? {}, - ); - const [searchText, setSearchText] = useState(args.searchText ?? ""); - return { - selectedAppName, - formValues, - searchText, - onSelectedAppNameChange: setSelectedAppName, - onFormValuesChange: setFormValues, - onSearchChange: setSearchText, - }; + const [ui, setUi] = useState(args.ui ?? EMPTY_APPS_UI); + return { ui, onUiChange: setUi }; } const meta: Meta = { @@ -108,13 +103,12 @@ const meta: Meta = { sandboxPath: PLACEHOLDER_SANDBOX, bridgeFactory: okBridgeFactory, listChanged: false, + ui: EMPTY_APPS_UI, + onUiChange: fn(), onRefreshList: fn(), onSelectApp: fn(), onOpenApp: fn(), onCloseApp: fn(), - onSelectedAppNameChange: fn(), - onFormValuesChange: fn(), - onSearchChange: fn(), }, // Each story uses its own ref so AppRenderer's imperative handle gets a // fresh slot per render (Storybook may keep the canvas mounted across diff --git a/clients/web/src/components/screens/AppsScreen/AppsScreen.test.tsx b/clients/web/src/components/screens/AppsScreen/AppsScreen.test.tsx index a58b0c39b..9ec51b3a4 100644 --- a/clients/web/src/components/screens/AppsScreen/AppsScreen.test.tsx +++ b/clients/web/src/components/screens/AppsScreen/AppsScreen.test.tsx @@ -8,7 +8,12 @@ import { screen, within, } from "../../../test/renderWithMantine"; -import { AppsScreen, type AppsScreenProps } from "./AppsScreen"; +import { + AppsScreen, + type AppsScreenProps, + type AppsUiState, +} from "./AppsScreen"; +import { EMPTY_APPS_UI } from "../screenUiState"; import type { AppRendererHandle, BridgeFactory, @@ -64,53 +69,37 @@ function buildProps(overrides: Partial = {}): AppsScreenProps { sandboxPath: "data:text/html,sandbox", bridgeFactory: okBridgeFactory, rendererRef: createRef(), + ui: EMPTY_APPS_UI, + onUiChange: vi.fn(), onRefreshList: vi.fn(), onSelectApp: vi.fn(), onOpenApp: vi.fn(), onCloseApp: vi.fn(), - onSelectedAppNameChange: vi.fn(), - onFormValuesChange: vi.fn(), - onSearchChange: vi.fn(), ...overrides, }; } // AppsScreen lifts selection, form values, and the sidebar search to the -// parent (App) 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 wiring is applied last so callers can still -// observe activity via the spied callbacks. +// 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 = {}) { const props = buildProps(overrides); - const [selectedAppName, setSelectedAppName] = useState( - props.selectedAppName, - ); - const [formValues, setFormValues] = useState>( - props.formValues ?? {}, - ); - const [searchText, setSearchText] = useState( - props.searchText, - ); + const [ui, setUi] = useState({ + ...EMPTY_APPS_UI, + ...props.ui, + }); return ( { - setSelectedAppName(value); - props.onSelectedAppNameChange(value); - }} - onFormValuesChange={(values) => { - setFormValues(values); - props.onFormValuesChange(values); - }} - onSearchChange={(value) => { - setSearchText(value); - props.onSearchChange(value); + ui={ui} + onUiChange={(next) => { + setUi(next); + props.onUiChange(next); }} /> ); diff --git a/clients/web/src/components/screens/AppsScreen/AppsScreen.tsx b/clients/web/src/components/screens/AppsScreen/AppsScreen.tsx index a776745ee..f60d57b4b 100644 --- a/clients/web/src/components/screens/AppsScreen/AppsScreen.tsx +++ b/clients/web/src/components/screens/AppsScreen/AppsScreen.tsx @@ -39,18 +39,8 @@ export interface AppsScreenProps { sandboxPath?: string; bridgeFactory: BridgeFactory; rendererRef: Ref; - // Selected app, its form values, and the sidebar search are controlled by the - // parent (App) so they persist across tab navigation within a live session - // (#1417). `running`/`maximized` stay local: 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. - selectedAppName?: string; - formValues?: Record; - searchText?: string; - onSelectedAppNameChange: (value: string | undefined) => void; - onFormValuesChange: (values: Record) => void; - onSearchChange: (value: string) => void; + ui: AppsUiState; + onUiChange: (next: AppsUiState) => void; onRefreshList: () => void; onSelectApp: (name: string) => void; onOpenApp: (name: string, args: Record) => void; @@ -59,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; + search: string; +} + const ScreenLayout = Flex.withProps({ variant: "screen", h: "calc(100vh - var(--app-shell-header-height, 0px))", @@ -143,18 +145,15 @@ export function AppsScreen({ sandboxPath, bridgeFactory, rendererRef, - selectedAppName, - formValues = {}, - searchText = "", - onSelectedAppNameChange, - onFormValuesChange, - onSearchChange, + ui, + onUiChange, onRefreshList, onSelectApp, onOpenApp, onCloseApp, onError, }: AppsScreenProps) { + const { selectedAppName, formValues, search } = ui; const [running, setRunning] = useState(false); const [maximized, setMaximized] = useState(false); @@ -167,10 +166,13 @@ export function AppsScreen({ if (name === selectedAppName) return; const next = tools.find((t) => t.name === name); if (!next) return; - onSelectedAppNameChange(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). - onFormValuesChange(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 @@ -191,8 +193,7 @@ export function AppsScreen({ function handleClose() { setRunning(false); - onSelectedAppNameChange(undefined); - onFormValuesChange({}); + onUiChange({ ...ui, selectedAppName: undefined, formValues: {} }); setMaximized(false); onCloseApp(); } @@ -225,10 +226,10 @@ export function AppsScreen({ onUiChange({ ...ui, search: value })} onSelectApp={handleSelect} /> @@ -310,7 +311,9 @@ export function AppsScreen({ tool={selectedTool} formValues={formValues} isOpening={false} - onFormChange={onFormValuesChange} + onFormChange={(values) => + onUiChange({ ...ui, formValues: values }) + } onOpenApp={handleOpen} /> )} diff --git a/clients/web/src/components/screens/HistoryScreen/HistoryScreen.stories.tsx b/clients/web/src/components/screens/HistoryScreen/HistoryScreen.stories.tsx index dc0ce3aa5..b7e23c03c 100644 --- a/clients/web/src/components/screens/HistoryScreen/HistoryScreen.stories.tsx +++ b/clients/web/src/components/screens/HistoryScreen/HistoryScreen.stories.tsx @@ -1,30 +1,18 @@ import { useState } from "react"; import type { ComponentProps } from "react"; import type { Meta, StoryObj } from "@storybook/react-vite"; -import type { - MessageEntry, - MessageMethod, -} from "../../../../../../core/mcp/types.js"; +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 — -// 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. +// 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) { - const [searchText, setSearchText] = useState(args.searchText ?? ""); - const [methodFilter, setMethodFilter] = useState( - args.methodFilter, - ); - return ( - - ); + const [ui, setUi] = useState({ ...EMPTY_HISTORY_UI, ...args.ui }); + return ; } const meta: Meta = { @@ -37,8 +25,8 @@ const meta: Meta = { onExport: fn(), onReplay: fn(), onTogglePin: fn(), - onSearchChange: fn(), - onMethodFilterChange: fn(), + ui: EMPTY_HISTORY_UI, + onUiChange: fn(), sortDirection: "newest-first", onSortChange: fn(), compact: true, diff --git a/clients/web/src/components/screens/HistoryScreen/HistoryScreen.test.tsx b/clients/web/src/components/screens/HistoryScreen/HistoryScreen.test.tsx index 3f79cd32b..bbff1767a 100644 --- a/clients/web/src/components/screens/HistoryScreen/HistoryScreen.test.tsx +++ b/clients/web/src/components/screens/HistoryScreen/HistoryScreen.test.tsx @@ -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,8 +23,8 @@ const sampleEntries: MessageEntry[] = [ const baseProps = { entries: sampleEntries, pinnedIds: new Set(), - onSearchChange: vi.fn(), - onMethodFilterChange: vi.fn(), + ui: EMPTY_HISTORY_UI, + onUiChange: vi.fn(), onClearAll: vi.fn(), onExport: vi.fn(), onReplay: vi.fn(), @@ -54,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(); + 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( + , + ); + 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 }), + ); + }); }); diff --git a/clients/web/src/components/screens/HistoryScreen/HistoryScreen.tsx b/clients/web/src/components/screens/HistoryScreen/HistoryScreen.tsx index 827ff6f3e..5411afc26 100644 --- a/clients/web/src/components/screens/HistoryScreen/HistoryScreen.tsx +++ b/clients/web/src/components/screens/HistoryScreen/HistoryScreen.tsx @@ -9,12 +9,8 @@ import type { SortDirection } from "../../elements/SortToggle/SortToggle"; export interface HistoryScreenProps { entries: MessageEntry[]; pinnedIds: Set; - // Search text + method filter are controlled by the parent (App) so they - // persist across tab navigation within a live session — see #1417. - searchText?: string; - methodFilter?: MessageMethod; - onSearchChange: (value: string) => void; - onMethodFilterChange: (value: MessageMethod | undefined) => void; + ui: HistoryUiState; + onUiChange: (next: HistoryUiState) => void; onClearAll: () => void; onExport: () => void; onReplay: (id: string) => void; @@ -25,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))", @@ -45,10 +48,8 @@ const SidebarCard = Card.withProps({ export function HistoryScreen({ entries, pinnedIds, - searchText = "", - methodFilter, - onSearchChange, - onMethodFilterChange, + ui, + onUiChange, onClearAll, onExport, onReplay, @@ -58,33 +59,37 @@ export function HistoryScreen({ compact, onToggleCompact, }: HistoryScreenProps) { + const { search, methodFilter } = ui; + const availableMethods = useMemo( () => Array.from(new Set(entries.map(extractMethod))).sort(), [entries], ); const handleClearAll = useCallback(() => { - onMethodFilterChange(undefined); + onUiChange({ ...ui, methodFilter: undefined }); onClearAll(); - }, [onMethodFilterChange, onClearAll]); + }, [ui, onUiChange, onClearAll]); return ( onUiChange({ ...ui, search: value })} + onMethodFilterChange={(value) => + onUiChange({ ...ui, methodFilter: value }) + } /> = { onSetLevel: fn(), onClear: fn(), onExport: fn(), - onFilterChange: fn(), - onVisibleLevelsChange: fn(), + ui: EMPTY_LOGS_UI, + onUiChange: fn(), sortDirection: "newest-first", onSortChange: fn(), }, diff --git a/clients/web/src/components/screens/LoggingScreen/LoggingScreen.test.tsx b/clients/web/src/components/screens/LoggingScreen/LoggingScreen.test.tsx index 72c7497c4..19f7c50bc 100644 --- a/clients/web/src/components/screens/LoggingScreen/LoggingScreen.test.tsx +++ b/clients/web/src/components/screens/LoggingScreen/LoggingScreen.test.tsx @@ -1,16 +1,19 @@ import { useState } from "react"; import { describe, it, expect, vi } from "vitest"; import userEvent from "@testing-library/user-event"; -import type { LoggingLevel } from "@modelcontextprotocol/sdk/types.js"; import { renderWithMantine, screen } from "../../../test/renderWithMantine"; -import { LoggingScreen, type LoggingScreenProps } from "./LoggingScreen"; -import { ALL_LEVELS_VISIBLE } from "./logLevels"; +import { + LoggingScreen, + type LoggingScreenProps, + type LogsUiState, +} from "./LoggingScreen"; +import { EMPTY_LOGS_UI } from "../screenUiState"; const baseProps = { entries: [], currentLevel: "info" as const, - onFilterChange: vi.fn(), - onVisibleLevelsChange: vi.fn(), + ui: EMPTY_LOGS_UI, + onUiChange: vi.fn(), onSetLevel: vi.fn(), onClear: vi.fn(), onExport: vi.fn(), @@ -19,28 +22,21 @@ const baseProps = { }; // LoggingScreen is controlled: filter text + visible-level set live in the -// parent (App) 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 filter wiring is -// applied last so callers can still observe changes via the spied callbacks. +// 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) { - const [filterText, setFilterText] = useState(props.filterText ?? ""); - const [visibleLevels, setVisibleLevels] = useState< - Record - >(props.visibleLevels ?? ALL_LEVELS_VISIBLE); + const [ui, setUi] = useState({ ...EMPTY_LOGS_UI, ...props.ui }); return ( { - setFilterText(value); - props.onFilterChange?.(value); - }} - onVisibleLevelsChange={(value) => { - setVisibleLevels(value); - props.onVisibleLevelsChange?.(value); + ui={ui} + onUiChange={(value) => { + setUi(value); + props.onUiChange?.(value); }} /> ); diff --git a/clients/web/src/components/screens/LoggingScreen/LoggingScreen.tsx b/clients/web/src/components/screens/LoggingScreen/LoggingScreen.tsx index 6a11d1728..284043d63 100644 --- a/clients/web/src/components/screens/LoggingScreen/LoggingScreen.tsx +++ b/clients/web/src/components/screens/LoggingScreen/LoggingScreen.tsx @@ -9,12 +9,8 @@ import { ALL_LEVELS_VISIBLE, NO_LEVELS_VISIBLE } from "./logLevels"; export interface LoggingScreenProps { entries: LogEntryData[]; currentLevel: LoggingLevel; - // Filter text + visible-level set are controlled by the parent (App) so they - // persist across tab navigation within a live session — see #1417. - filterText?: string; - visibleLevels?: Record; - onFilterChange: (value: string) => void; - onVisibleLevelsChange: (value: Record) => void; + ui: LogsUiState; + onUiChange: (next: LogsUiState) => void; onSetLevel: (level: LoggingLevel) => void; onClear: () => void; onExport: () => void; @@ -22,6 +18,13 @@ export interface LoggingScreenProps { onSortChange: (next: SortDirection) => void; } +// 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; +} + const ScreenLayout = Flex.withProps({ variant: "screen", h: "calc(100vh - var(--app-shell-header-height, 0px))", @@ -42,23 +45,29 @@ const SidebarCard = Card.withProps({ export function LoggingScreen({ entries, currentLevel, - filterText = "", - visibleLevels = ALL_LEVELS_VISIBLE, - onFilterChange, - onVisibleLevelsChange, + ui, + onUiChange, onSetLevel, onClear, onExport, sortDirection, onSortChange, }: LoggingScreenProps) { + const { filterText, visibleLevels } = ui; + function handleToggleLevel(level: LoggingLevel, visible: boolean) { - onVisibleLevelsChange({ ...visibleLevels, [level]: visible }); + onUiChange({ + ...ui, + visibleLevels: { ...visibleLevels, [level]: visible }, + }); } function handleToggleAllLevels() { const allSelected = Object.values(visibleLevels).every(Boolean); - onVisibleLevelsChange(allSelected ? NO_LEVELS_VISIBLE : ALL_LEVELS_VISIBLE); + onUiChange({ + ...ui, + visibleLevels: allSelected ? NO_LEVELS_VISIBLE : ALL_LEVELS_VISIBLE, + }); } return ( @@ -70,7 +79,7 @@ export function LoggingScreen({ filterText={filterText} visibleLevels={visibleLevels} onSetLevel={onSetLevel} - onFilterChange={onFilterChange} + onFilterChange={(value) => onUiChange({ ...ui, filterText: value })} onToggleLevel={handleToggleLevel} onToggleAllLevels={handleToggleAllLevels} /> diff --git a/clients/web/src/components/screens/NetworkScreen/NetworkScreen.stories.tsx b/clients/web/src/components/screens/NetworkScreen/NetworkScreen.stories.tsx index b7429959f..d80f01063 100644 --- a/clients/web/src/components/screens/NetworkScreen/NetworkScreen.stories.tsx +++ b/clients/web/src/components/screens/NetworkScreen/NetworkScreen.stories.tsx @@ -2,29 +2,17 @@ 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 { - FetchRequestCategory, - FetchRequestEntry, -} from "../../../../../../core/mcp/types.js"; +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 — see #1417). This wrapper holds that state so the play-driven category -// toggle actually hides entries, mirroring how App owns the state. +// 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) { - const [filterText, setFilterText] = useState(args.filterText ?? ""); - const [visibleCategories, setVisibleCategories] = useState< - Record | undefined - >(args.visibleCategories); - return ( - - ); + const [ui, setUi] = useState({ ...EMPTY_NETWORK_UI, ...args.ui }); + return ; } const meta: Meta = { @@ -34,8 +22,8 @@ const meta: Meta = { args: { onClear: fn(), onExport: fn(), - onFilterChange: fn(), - onVisibleCategoriesChange: fn(), + ui: EMPTY_NETWORK_UI, + onUiChange: fn(), sortDirection: "newest-first", onSortChange: fn(), compact: true, diff --git a/clients/web/src/components/screens/NetworkScreen/NetworkScreen.test.tsx b/clients/web/src/components/screens/NetworkScreen/NetworkScreen.test.tsx index 4b29efca9..9d1822906 100644 --- a/clients/web/src/components/screens/NetworkScreen/NetworkScreen.test.tsx +++ b/clients/web/src/components/screens/NetworkScreen/NetworkScreen.test.tsx @@ -1,13 +1,14 @@ import { useState } from "react"; import { describe, it, expect, vi } from "vitest"; import userEvent from "@testing-library/user-event"; -import type { - FetchRequestCategory, - FetchRequestEntry, -} from "@inspector/core/mcp/types.js"; +import type { FetchRequestEntry } from "@inspector/core/mcp/types.js"; import { renderWithMantine, screen } from "../../../test/renderWithMantine"; -import { NetworkScreen, type NetworkScreenProps } from "./NetworkScreen"; -import { ALL_CATEGORIES_VISIBLE } from "./fetchCategories"; +import { + NetworkScreen, + type NetworkScreenProps, + type NetworkUiState, +} from "./NetworkScreen"; +import { EMPTY_NETWORK_UI } from "../screenUiState"; const transportEntry: FetchRequestEntry = { id: "t-1", @@ -46,8 +47,8 @@ const errorEntry: FetchRequestEntry = { const baseProps = { entries: [transportEntry, authEntry, errorEntry], - onFilterChange: vi.fn(), - onVisibleCategoriesChange: vi.fn(), + ui: EMPTY_NETWORK_UI, + onUiChange: vi.fn(), onClear: vi.fn(), onExport: vi.fn(), sortDirection: "newest-first" as const, @@ -57,28 +58,24 @@ const baseProps = { }; // NetworkScreen is controlled: filter text + visible-category set live in the -// parent (App) 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 filter wiring is -// applied last so callers can still observe changes via the spied callbacks. +// 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) { - const [filterText, setFilterText] = useState(props.filterText ?? ""); - const [visibleCategories, setVisibleCategories] = useState< - Record - >(props.visibleCategories ?? ALL_CATEGORIES_VISIBLE); + const [ui, setUi] = useState({ + ...EMPTY_NETWORK_UI, + ...props.ui, + }); return ( { - setFilterText(value); - props.onFilterChange?.(value); - }} - onVisibleCategoriesChange={(value) => { - setVisibleCategories(value); - props.onVisibleCategoriesChange?.(value); + ui={ui} + onUiChange={(value) => { + setUi(value); + props.onUiChange?.(value); }} /> ); diff --git a/clients/web/src/components/screens/NetworkScreen/NetworkScreen.tsx b/clients/web/src/components/screens/NetworkScreen/NetworkScreen.tsx index d9397317f..ebc0996f0 100644 --- a/clients/web/src/components/screens/NetworkScreen/NetworkScreen.tsx +++ b/clients/web/src/components/screens/NetworkScreen/NetworkScreen.tsx @@ -13,14 +13,8 @@ import { export interface NetworkScreenProps { entries: FetchRequestEntry[]; - // Filter text + visible-category set are controlled by the parent (App) so - // they persist across tab navigation within a live session — see #1417. - filterText?: string; - visibleCategories?: Record; - onFilterChange: (value: string) => void; - onVisibleCategoriesChange: ( - value: Record, - ) => void; + ui: NetworkUiState; + onUiChange: (next: NetworkUiState) => void; onClear: () => void; onExport: () => void; sortDirection: SortDirection; @@ -29,6 +23,13 @@ export interface NetworkScreenProps { onToggleCompact: () => void; } +// 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; +} + const ScreenLayout = Flex.withProps({ variant: "screen", h: "calc(100vh - var(--app-shell-header-height, 0px))", @@ -48,10 +49,8 @@ const SidebarCard = Card.withProps({ export function NetworkScreen({ entries, - filterText = "", - visibleCategories = ALL_CATEGORIES_VISIBLE, - onFilterChange, - onVisibleCategoriesChange, + ui, + onUiChange, onClear, onExport, sortDirection, @@ -59,18 +58,26 @@ export function NetworkScreen({ compact, onToggleCompact, }: NetworkScreenProps) { + const { filterText, visibleCategories } = ui; + function handleToggleCategory( category: FetchRequestCategory, visible: boolean, ) { - onVisibleCategoriesChange({ ...visibleCategories, [category]: visible }); + onUiChange({ + ...ui, + visibleCategories: { ...visibleCategories, [category]: visible }, + }); } function handleToggleAllCategories() { const allSelected = Object.values(visibleCategories).every(Boolean); - onVisibleCategoriesChange( - 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({ onUiChange({ ...ui, filterText: value })} onToggleCategory={handleToggleCategory} onToggleAllCategories={handleToggleAllCategories} /> diff --git a/clients/web/src/components/screens/PromptsScreen/PromptsScreen.stories.tsx b/clients/web/src/components/screens/PromptsScreen/PromptsScreen.stories.tsx index 57886a9ac..dae6659a3 100644 --- a/clients/web/src/components/screens/PromptsScreen/PromptsScreen.stories.tsx +++ b/clients/web/src/components/screens/PromptsScreen/PromptsScreen.stories.tsx @@ -4,34 +4,16 @@ 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 — 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. +// 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) { - const [selectedPromptName, setSelectedPromptName] = useState( - args.selectedPromptName, - ); - const [argumentValues, setArgumentValues] = useState>( - args.argumentValues ?? {}, - ); - const [submittedFor, setSubmittedFor] = useState(args.submittedFor); - const [searchText, setSearchText] = useState(args.searchText ?? ""); - return ( - - ); + const [ui, setUi] = useState(args.ui ?? EMPTY_PROMPTS_UI); + return ; } const meta: Meta = { @@ -39,13 +21,11 @@ const meta: Meta = { component: PromptsScreen, parameters: { layout: "fullscreen" }, args: { + ui: EMPTY_PROMPTS_UI, + onUiChange: fn(), onRefreshList: fn(), onGetPrompt: fn(), onCopyMessages: fn(), - onSelectedPromptNameChange: fn(), - onArgumentValuesChange: fn(), - onSubmittedForChange: fn(), - onSearchChange: fn(), listChanged: false, }, render: (args) => , diff --git a/clients/web/src/components/screens/PromptsScreen/PromptsScreen.test.tsx b/clients/web/src/components/screens/PromptsScreen/PromptsScreen.test.tsx index 533045b0b..e9aa45cec 100644 --- a/clients/web/src/components/screens/PromptsScreen/PromptsScreen.test.tsx +++ b/clients/web/src/components/screens/PromptsScreen/PromptsScreen.test.tsx @@ -3,7 +3,12 @@ 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, type PromptsScreenProps } from "./PromptsScreen"; +import { + PromptsScreen, + type PromptsScreenProps, + type PromptsUiState, +} from "./PromptsScreen"; +import { EMPTY_PROMPTS_UI } from "../screenUiState"; const promptsWithArgs: Prompt[] = [ { @@ -26,56 +31,32 @@ const noArgPrompts: Prompt[] = [ const baseProps = { prompts: promptsWithArgs, listChanged: false, + ui: EMPTY_PROMPTS_UI, + onUiChange: vi.fn(), onRefreshList: vi.fn(), onGetPrompt: vi.fn(), - onSelectedPromptNameChange: vi.fn(), - onArgumentValuesChange: vi.fn(), - onSubmittedForChange: vi.fn(), - onSearchChange: vi.fn(), }; // PromptsScreen is controlled: selection, argument values, the submitted -// marker, and the sidebar search live in the parent (App) 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 wiring is -// applied last so callers can still observe activity via the spied callbacks. +// 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) { - const [selectedPromptName, setSelectedPromptName] = useState< - string | undefined - >(props.selectedPromptName); - const [argumentValues, setArgumentValues] = useState>( - props.argumentValues ?? {}, - ); - const [submittedFor, setSubmittedFor] = useState( - props.submittedFor, - ); - const [searchText, setSearchText] = useState( - props.searchText, - ); + const [ui, setUi] = useState({ + ...EMPTY_PROMPTS_UI, + ...props.ui, + }); return ( { - setSelectedPromptName(value); - props.onSelectedPromptNameChange?.(value); - }} - onArgumentValuesChange={(value) => { - setArgumentValues(value); - props.onArgumentValuesChange?.(value); - }} - onSubmittedForChange={(value) => { - setSubmittedFor(value); - props.onSubmittedForChange?.(value); - }} - onSearchChange={(value) => { - setSearchText(value); - props.onSearchChange?.(value); + ui={ui} + onUiChange={(next) => { + setUi(next); + props.onUiChange?.(next); }} /> ); diff --git a/clients/web/src/components/screens/PromptsScreen/PromptsScreen.tsx b/clients/web/src/components/screens/PromptsScreen/PromptsScreen.tsx index 2a90ecab1..d10f1686e 100644 --- a/clients/web/src/components/screens/PromptsScreen/PromptsScreen.tsx +++ b/clients/web/src/components/screens/PromptsScreen/PromptsScreen.tsx @@ -30,19 +30,10 @@ export interface GetPromptState { export interface PromptsScreenProps { prompts: Prompt[]; getPromptState?: GetPromptState; - // Selection, argument values, the "submitted" marker, and the sidebar search - // are controlled by the parent (App) so they persist across tab navigation - // within a live session — see #1417. - selectedPromptName?: string; - argumentValues?: Record; - submittedFor?: string; - searchText?: string; + ui: PromptsUiState; listChanged: boolean; completionsSupported?: boolean; - onSelectedPromptNameChange: (value: string | undefined) => void; - onArgumentValuesChange: (value: Record) => void; - onSubmittedForChange: (value: string | undefined) => void; - onSearchChange: (value: string) => void; + onUiChange: (next: PromptsUiState) => void; onRefreshList: () => void; onGetPrompt: (name: string, args: Record) => void; onCopyMessages?: () => void; @@ -56,6 +47,16 @@ export interface PromptsScreenProps { ) => Promise; } +// 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; + submittedFor?: string; + search: string; +} + const ScreenLayout = Flex.withProps({ variant: "screen", h: "calc(100vh - var(--app-shell-header-height, 0px))", @@ -112,21 +113,16 @@ function hasArguments(prompt: Prompt): boolean { export function PromptsScreen({ prompts, getPromptState, - selectedPromptName, - argumentValues = {}, - submittedFor, - searchText = "", + ui, listChanged, completionsSupported, - onSelectedPromptNameChange, - onArgumentValuesChange, - onSubmittedForChange, - onSearchChange, + onUiChange, onRefreshList, onGetPrompt, onCopyMessages, onCompleteArgument, }: PromptsScreenProps) { + const { selectedPromptName, argumentValues, submittedFor, search } = ui; const selectedPrompt = selectedPromptName ? prompts.find((p) => p.name === selectedPromptName) : undefined; @@ -137,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; - onArgumentValuesChange({}); - onSelectedPromptNameChange(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)) { - onSubmittedForChange(name); - onGetPrompt(name, {}); - } else { - onSubmittedForChange(undefined); - } + const autoFetch = !!target && !hasArguments(target); + onUiChange({ + ...ui, + argumentValues: {}, + selectedPromptName: name, + submittedFor: autoFetch ? name : undefined, + }); + if (autoFetch) onGetPrompt(name, {}); } function handleSubmit() { if (!selectedPrompt) return; - onSubmittedForChange(selectedPrompt.name); + onUiChange({ ...ui, submittedFor: selectedPrompt.name }); onGetPrompt(selectedPrompt.name, argumentValues); } @@ -163,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)) { - onSubmittedForChange(undefined); + onUiChange({ ...ui, submittedFor: undefined }); } else { - onSelectedPromptNameChange(undefined); - onSubmittedForChange(undefined); + onUiChange({ + ...ui, + selectedPromptName: undefined, + submittedFor: undefined, + }); } } @@ -240,10 +239,10 @@ export function PromptsScreen({ onUiChange({ ...ui, search: value })} onSelectPrompt={handleSelectPrompt} /> @@ -263,7 +262,10 @@ export function PromptsScreen({ prompt={selectedPrompt} argumentValues={argumentValues} onArgumentChange={(argName, value) => - onArgumentValuesChange({ ...argumentValues, [argName]: value }) + onUiChange({ + ...ui, + argumentValues: { ...argumentValues, [argName]: value }, + }) } onGetPrompt={handleSubmit} completionsSupported={completionsSupported} diff --git a/clients/web/src/components/screens/ResourcesScreen/ResourcesScreen.stories.tsx b/clients/web/src/components/screens/ResourcesScreen/ResourcesScreen.stories.tsx index ff7417601..ac35aeb00 100644 --- a/clients/web/src/components/screens/ResourcesScreen/ResourcesScreen.stories.tsx +++ b/clients/web/src/components/screens/ResourcesScreen/ResourcesScreen.stories.tsx @@ -8,39 +8,17 @@ 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 — 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. +// 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) { - const [selectedResourceUri, setSelectedResourceUri] = useState( - args.selectedResourceUri, - ); - const [selectedTemplateUri, setSelectedTemplateUri] = useState( - args.selectedTemplateUri, - ); - const [originatingTemplateUri, setOriginatingTemplateUri] = useState( - args.originatingTemplateUri, - ); - const [searchText, setSearchText] = useState(args.searchText ?? ""); - const [openSections, setOpenSections] = useState(args.openSections); - return ( - - ); + const [ui, setUi] = useState(args.ui ?? EMPTY_RESOURCES_UI); + return ; } const meta: Meta = { @@ -48,15 +26,12 @@ const meta: Meta = { component: ResourcesScreen, parameters: { layout: "fullscreen" }, args: { + ui: EMPTY_RESOURCES_UI, + onUiChange: fn(), onRefreshList: fn(), onReadResource: fn(), onSubscribeResource: fn(), onUnsubscribeResource: fn(), - onSelectedResourceUriChange: fn(), - onSelectedTemplateUriChange: fn(), - onOriginatingTemplateUriChange: fn(), - onSearchChange: fn(), - onOpenSectionsChange: fn(), listChanged: false, subscriptions: [], templates: [], diff --git a/clients/web/src/components/screens/ResourcesScreen/ResourcesScreen.test.tsx b/clients/web/src/components/screens/ResourcesScreen/ResourcesScreen.test.tsx index 54e44b492..a94d96338 100644 --- a/clients/web/src/components/screens/ResourcesScreen/ResourcesScreen.test.tsx +++ b/clients/web/src/components/screens/ResourcesScreen/ResourcesScreen.test.tsx @@ -7,7 +7,12 @@ import type { ReadResourceResult, } from "@modelcontextprotocol/sdk/types.js"; import { renderWithMantine, screen } from "../../../test/renderWithMantine"; -import { ResourcesScreen, type ResourcesScreenProps } 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" }, @@ -23,15 +28,12 @@ const baseProps = { templates, subscriptions: [], listChanged: false, + ui: EMPTY_RESOURCES_UI, + onUiChange: vi.fn(), onRefreshList: vi.fn(), onReadResource: vi.fn(), onSubscribeResource: vi.fn(), onUnsubscribeResource: vi.fn(), - onSelectedResourceUriChange: vi.fn(), - onSelectedTemplateUriChange: vi.fn(), - onOriginatingTemplateUriChange: vi.fn(), - onSearchChange: vi.fn(), - onOpenSectionsChange: vi.fn(), compact: false, onCompactChange: vi.fn(), }; @@ -42,55 +44,25 @@ const okResult: ReadResourceResult = { // 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) 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 wiring is applied -// last so callers can still observe activity via the spied callbacks. +// 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) { - const [selectedResourceUri, setSelectedResourceUri] = useState< - string | undefined - >(props.selectedResourceUri); - const [selectedTemplateUri, setSelectedTemplateUri] = useState< - string | undefined - >(props.selectedTemplateUri); - const [originatingTemplateUri, setOriginatingTemplateUri] = useState< - string | undefined - >(props.originatingTemplateUri); - const [searchText, setSearchText] = useState( - props.searchText, - ); - const [openSections, setOpenSections] = useState( - props.openSections, - ); + const [ui, setUi] = useState({ + ...EMPTY_RESOURCES_UI, + ...props.ui, + }); return ( { - setSelectedResourceUri(value); - props.onSelectedResourceUriChange?.(value); - }} - onSelectedTemplateUriChange={(value) => { - setSelectedTemplateUri(value); - props.onSelectedTemplateUriChange?.(value); - }} - onOriginatingTemplateUriChange={(value) => { - setOriginatingTemplateUri(value); - props.onOriginatingTemplateUriChange?.(value); - }} - onSearchChange={(value) => { - setSearchText(value); - props.onSearchChange?.(value); - }} - onOpenSectionsChange={(value) => { - setOpenSections(value); - props.onOpenSectionsChange?.(value); + ui={ui} + onUiChange={(next) => { + setUi(next); + props.onUiChange?.(next); }} /> ); diff --git a/clients/web/src/components/screens/ResourcesScreen/ResourcesScreen.tsx b/clients/web/src/components/screens/ResourcesScreen/ResourcesScreen.tsx index 655a71cea..ac3099b08 100644 --- a/clients/web/src/components/screens/ResourcesScreen/ResourcesScreen.tsx +++ b/clients/web/src/components/screens/ResourcesScreen/ResourcesScreen.tsx @@ -32,22 +32,10 @@ export interface ResourcesScreenProps { templates: ResourceTemplate[]; subscriptions: InspectorResourceSubscription[]; readState?: ReadResourceState; - // Selection (resource URI, template URI, the originating-template marker), - // the sidebar search, and accordion open-sections are controlled by the - // parent (App) so they persist across tab navigation within a live - // session — see #1417. - selectedResourceUri?: string; - selectedTemplateUri?: string; - originatingTemplateUri?: string; - searchText?: string; - openSections?: string[]; + ui: ResourcesUiState; listChanged: boolean; completionsSupported?: boolean; - onSelectedResourceUriChange: (value: string | undefined) => void; - onSelectedTemplateUriChange: (value: string | undefined) => void; - onOriginatingTemplateUriChange: (value: string | undefined) => void; - onSearchChange: (value: string) => void; - onOpenSectionsChange: (value: string[]) => void; + onUiChange: (next: ResourcesUiState) => void; onRefreshList: () => void; onReadResource: (uri: string) => void; onSubscribeResource: (uri: string) => void; @@ -64,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))", @@ -120,18 +121,10 @@ export function ResourcesScreen({ templates, subscriptions, readState, - selectedResourceUri, - selectedTemplateUri, - originatingTemplateUri, - searchText = "", - openSections, + ui, listChanged, completionsSupported, - onSelectedResourceUriChange, - onSelectedTemplateUriChange, - onOriginatingTemplateUriChange, - onSearchChange, - onOpenSectionsChange, + onUiChange, onRefreshList, onReadResource, onSubscribeResource, @@ -140,6 +133,13 @@ export function ResourcesScreen({ compact, onCompactChange, }: ResourcesScreenProps) { + const { + selectedResourceUri, + selectedTemplateUri, + originatingTemplateUri, + search, + openSections, + } = ui; const selectedResource = selectedResourceUri ? resources.find((r) => r.uri === selectedResourceUri) : undefined; @@ -156,16 +156,22 @@ export function ResourcesScreen({ : undefined); function handleSelectResource(uri: string) { - onSelectedTemplateUriChange(undefined); - onSelectedResourceUriChange(uri); - onOriginatingTemplateUriChange(undefined); + onUiChange({ + ...ui, + selectedTemplateUri: undefined, + selectedResourceUri: uri, + originatingTemplateUri: undefined, + }); onReadResource(uri); } function handleSelectTemplate(uriTemplate: string) { - onSelectedResourceUriChange(undefined); - onSelectedTemplateUriChange(uriTemplate); - onOriginatingTemplateUriChange(undefined); + onUiChange({ + ...ui, + selectedResourceUri: undefined, + selectedTemplateUri: uriTemplate, + originatingTemplateUri: undefined, + }); } function handleReadResource(uri: string) { @@ -174,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) { - onOriginatingTemplateUriChange(selectedTemplateUri); - } - onSelectedTemplateUriChange(undefined); - onSelectedResourceUriChange(uri); + onUiChange({ + ...ui, + originatingTemplateUri: selectedTemplateUri ?? originatingTemplateUri, + selectedTemplateUri: undefined, + selectedResourceUri: uri, + }); onReadResource(uri); } function handleClosePreview() { - onSelectedResourceUriChange(undefined); if (originatingTemplateUri) { - onSelectedTemplateUriChange(originatingTemplateUri); - onOriginatingTemplateUriChange(undefined); + onUiChange({ + ...ui, + selectedResourceUri: undefined, + selectedTemplateUri: originatingTemplateUri, + originatingTemplateUri: undefined, + }); + } else { + onUiChange({ ...ui, selectedResourceUri: undefined }); } } @@ -260,12 +272,14 @@ export function ResourcesScreen({ subscriptions={subscriptions} selectedUri={selectedResourceUri} selectedTemplateUri={selectedTemplateUri} - searchText={searchText} + searchText={search} openSections={openSections} listChanged={listChanged} onRefreshList={onRefreshList} - onSearchChange={onSearchChange} - onOpenSectionsChange={onOpenSectionsChange} + onSearchChange={(value) => onUiChange({ ...ui, search: value })} + onOpenSectionsChange={(value) => + onUiChange({ ...ui, openSections: value }) + } onSelectUri={handleSelectResource} onSelectTemplate={handleSelectTemplate} onUnsubscribeResource={onUnsubscribeResource} diff --git a/clients/web/src/components/screens/TasksScreen/TasksScreen.stories.tsx b/clients/web/src/components/screens/TasksScreen/TasksScreen.stories.tsx index f9c28bb9b..558da51af 100644 --- a/clients/web/src/components/screens/TasksScreen/TasksScreen.stories.tsx +++ b/clients/web/src/components/screens/TasksScreen/TasksScreen.stories.tsx @@ -2,6 +2,7 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; import type { Task } from "@modelcontextprotocol/sdk/types.js"; import { fn } from "storybook/test"; import { TasksScreen } from "./TasksScreen"; +import { EMPTY_TASKS_UI } from "../screenUiState"; const meta: Meta = { title: "Screens/TasksScreen", @@ -11,8 +12,8 @@ const meta: Meta = { onRefresh: fn(), onClearCompleted: fn(), onCancel: fn(), - onSearchChange: fn(), - onStatusFilterChange: fn(), + ui: EMPTY_TASKS_UI, + onUiChange: fn(), }, }; diff --git a/clients/web/src/components/screens/TasksScreen/TasksScreen.test.tsx b/clients/web/src/components/screens/TasksScreen/TasksScreen.test.tsx index 4082ff522..41b308bc8 100644 --- a/clients/web/src/components/screens/TasksScreen/TasksScreen.test.tsx +++ b/clients/web/src/components/screens/TasksScreen/TasksScreen.test.tsx @@ -3,6 +3,7 @@ import userEvent from "@testing-library/user-event"; import type { Task } from "@modelcontextprotocol/sdk/types.js"; import { renderWithMantine, screen } from "../../../test/renderWithMantine"; import { TasksScreen } from "./TasksScreen"; +import { EMPTY_TASKS_UI } from "../screenUiState"; const tasks: Task[] = [ { @@ -17,8 +18,8 @@ const tasks: Task[] = [ const baseProps = { tasks, - onSearchChange: vi.fn(), - onStatusFilterChange: vi.fn(), + ui: EMPTY_TASKS_UI, + onUiChange: vi.fn(), onRefresh: vi.fn(), onClearCompleted: vi.fn(), onCancel: vi.fn(), @@ -42,4 +43,34 @@ describe("TasksScreen", () => { await user.click(screen.getByRole("button", { name: "Refresh" })); expect(onRefresh).toHaveBeenCalledTimes(1); }); + + it("emits the search text through onUiChange", async () => { + const user = userEvent.setup(); + const onUiChange = vi.fn(); + renderWithMantine(); + await user.type(screen.getByPlaceholderText("Search..."), "x"); + expect(onUiChange).toHaveBeenCalledWith( + expect.objectContaining({ search: "x" }), + ); + }); + + it("emits the cleared status filter through onUiChange", async () => { + const user = userEvent.setup(); + const onUiChange = vi.fn(); + const { container } = renderWithMantine( + , + ); + const clearButton = container.querySelector( + "button.mantine-InputClearButton-root", + ) as HTMLButtonElement | null; + expect(clearButton).not.toBeNull(); + await user.click(clearButton!); + expect(onUiChange).toHaveBeenCalledWith( + expect.objectContaining({ statusFilter: undefined }), + ); + }); }); diff --git a/clients/web/src/components/screens/TasksScreen/TasksScreen.tsx b/clients/web/src/components/screens/TasksScreen/TasksScreen.tsx index f2583e626..a027f459e 100644 --- a/clients/web/src/components/screens/TasksScreen/TasksScreen.tsx +++ b/clients/web/src/components/screens/TasksScreen/TasksScreen.tsx @@ -7,17 +7,20 @@ import type { TaskProgress } from "../../groups/TaskCard/TaskCard"; export interface TasksScreenProps { tasks: Task[]; progressByTaskId?: Record; - // Search + status filter are controlled by the parent (App) so they persist - // across tab navigation within a live session — see #1417. - searchText?: string; - statusFilter?: TaskStatus; - onSearchChange: (value: string) => void; - onStatusFilterChange: (value: TaskStatus | undefined) => void; + ui: TasksUiState; + onUiChange: (next: TasksUiState) => void; onRefresh: () => void; onClearCompleted: () => void; onCancel: (taskId: string) => void; } +// Search + status filter — controlled by the parent (App) as one object so they +// persist across tab navigation within a live session (#1417). +export interface TasksUiState { + search: string; + statusFilter?: TaskStatus; +} + const ScreenLayout = Flex.withProps({ variant: "screen", h: "calc(100vh - var(--app-shell-header-height, 0px))", @@ -38,23 +41,24 @@ const SidebarCard = Card.withProps({ export function TasksScreen({ tasks, progressByTaskId, - searchText = "", - statusFilter, - onSearchChange, - onStatusFilterChange, + ui, + onUiChange, onRefresh, onClearCompleted, onCancel, }: TasksScreenProps) { + const { search, statusFilter } = ui; return ( onUiChange({ ...ui, search: value })} + onStatusFilterChange={(value) => + onUiChange({ ...ui, statusFilter: value }) + } onRefresh={onRefresh} /> @@ -62,7 +66,7 @@ export function TasksScreen({ ) { - const [selectedToolName, setSelectedToolName] = useState( - args.selectedToolName, - ); - const [formValues, setFormValues] = useState>( - args.formValues ?? {}, - ); - return ( - - ); + const [ui, setUi] = useState(args.ui ?? EMPTY_TOOLS_UI); + return ; } const meta: Meta = { @@ -39,8 +28,8 @@ const meta: Meta = { parameters: { layout: "fullscreen" }, args: { listChanged: false, - onSelectTool: fn(), - onFormChange: fn(), + ui: EMPTY_TOOLS_UI, + onUiChange: fn(), onRefreshList: fn(), onCallTool: fn(), onCancelCall: fn(), diff --git a/clients/web/src/components/screens/ToolsScreen/ToolsScreen.test.tsx b/clients/web/src/components/screens/ToolsScreen/ToolsScreen.test.tsx index d8d09f057..569de39cc 100644 --- a/clients/web/src/components/screens/ToolsScreen/ToolsScreen.test.tsx +++ b/clients/web/src/components/screens/ToolsScreen/ToolsScreen.test.tsx @@ -3,7 +3,12 @@ import { describe, it, expect, vi } from "vitest"; import userEvent from "@testing-library/user-event"; import type { Tool } from "@modelcontextprotocol/sdk/types.js"; import { renderWithMantine, screen } from "../../../test/renderWithMantine"; -import { ToolsScreen, type ToolsScreenProps } from "./ToolsScreen"; +import { + ToolsScreen, + type ToolsScreenProps, + type ToolsUiState, +} from "./ToolsScreen"; +import { EMPTY_TOOLS_UI } from "../screenUiState"; const tools: Tool[] = [ { name: "alpha", inputSchema: { type: "object" } }, @@ -20,46 +25,30 @@ const tools: Tool[] = [ const baseProps = { tools, listChanged: false, - onSelectTool: vi.fn(), - onFormChange: vi.fn(), - onSearchChange: vi.fn(), + ui: EMPTY_TOOLS_UI, + onUiChange: vi.fn(), onRefreshList: vi.fn(), onCallTool: vi.fn(), }; // ToolsScreen is controlled: selection + form values live in the parent (App) -// so they persist across tab navigation (#1414). This host holds that state so -// clicking a tool drives the detail panel, mirroring how App owns it. Props -// passed in override defaults; the stateful selection/form wiring is applied -// last so callers can still observe selections via the spied callbacks. +// as one `ui` object so they persist across tab navigation (#1414). This host +// holds that state so clicking a tool drives the detail panel, mirroring how +// App owns it. Props passed in override defaults; the stateful `ui` wiring is +// applied last so callers can still observe selections via the rendered state. function ControlledToolsScreen(props: Partial) { - const [selectedToolName, setSelectedToolName] = useState( - props.selectedToolName, - ); - const [formValues, setFormValues] = useState>( - props.formValues ?? {}, - ); - const [searchText, setSearchText] = useState( - props.searchText, - ); + const [ui, setUi] = useState({ + ...EMPTY_TOOLS_UI, + ...props.ui, + }); return ( { - setSearchText(value); - props.onSearchChange?.(value); - }} - onSelectTool={(name) => { - setSelectedToolName(name); - props.onSelectTool?.(name); - }} - onFormChange={(values) => { - setFormValues(values); - props.onFormChange?.(values); + ui={ui} + onUiChange={(next) => { + setUi(next); + props.onUiChange?.(next); }} /> ); @@ -83,6 +72,28 @@ describe("ToolsScreen", () => { ).not.toBeInTheDocument(); }); + it("filters the sidebar list as the search text changes", async () => { + const user = userEvent.setup(); + renderWithMantine(); + expect(screen.getByText("beta")).toBeInTheDocument(); + await user.type(screen.getByPlaceholderText("Search tools..."), "alpha"); + expect(screen.getByText("alpha")).toBeInTheDocument(); + expect(screen.queryByText("beta")).not.toBeInTheDocument(); + }); + + it("carries edited form values through to Execute", async () => { + const user = userEvent.setup(); + const onCallTool = vi.fn(); + renderWithMantine(); + await user.click(screen.getByText("gamma")); + // gamma seeds { mode: "fast" }; editing the field flows through onUiChange. + const field = screen.getByLabelText(/mode/i); + await user.clear(field); + await user.type(field, "slow"); + await user.click(screen.getByRole("button", { name: /Execute/ })); + expect(onCallTool).toHaveBeenCalledWith("gamma", { mode: "slow" }); + }); + it("renders selection and result from props (persisted across navigation)", () => { // App owns selection + result, so a remount after a tab switch re-renders // with both still set — the detail and result panels show without any @@ -90,7 +101,7 @@ describe("ToolsScreen", () => { renderWithMantine( { renderWithMantine( , ); @@ -187,7 +198,7 @@ describe("ToolsScreen", () => { renderWithMantine( , diff --git a/clients/web/src/components/screens/ToolsScreen/ToolsScreen.tsx b/clients/web/src/components/screens/ToolsScreen/ToolsScreen.tsx index e3c07dc4c..1e02262f6 100644 --- a/clients/web/src/components/screens/ToolsScreen/ToolsScreen.tsx +++ b/clients/web/src/components/screens/ToolsScreen/ToolsScreen.tsx @@ -15,19 +15,21 @@ export interface ToolCallState { progress?: ToolProgress; } +// Selection, form values, and sidebar search — controlled by the parent (App) +// as one object so they persist across tab navigation within a live session; +// the screen unmounts on tab switch, so local state would be lost (#1414/#1417). +export interface ToolsUiState { + selectedToolName?: string; + formValues: Record; + search: string; +} + export interface ToolsScreenProps { tools: Tool[]; callState?: ToolCallState; - // Selection + form values are controlled by the parent (App) so they persist - // across tab navigation within a live session — the screen unmounts on tab - // switch, so local state would be lost (#1414). - selectedToolName?: string; - formValues?: Record; - searchText?: string; + ui: ToolsUiState; listChanged: boolean; - onSelectTool: (name: string) => void; - onFormChange: (values: Record) => void; - onSearchChange: (value: string) => void; + onUiChange: (next: ToolsUiState) => void; onRefreshList: () => void; onCallTool: (name: string, args: Record) => void; onCancelCall?: () => void; @@ -88,25 +90,19 @@ const EmptyState = Text.withProps({ export function ToolsScreen({ tools, callState, - selectedToolName, - formValues, - searchText = "", + ui, listChanged, - onSelectTool, - onFormChange, - onSearchChange, + onUiChange, onRefreshList, onCallTool, onCancelCall, onClearResult, }: ToolsScreenProps) { + const { selectedToolName, formValues, search } = ui; const selectedTool = selectedToolName ? tools.find((t) => t.name === selectedToolName) : undefined; const isExecuting = callState?.status === "pending"; - // Selection, inputs, and result all live in App now, so they persist when the - // user navigates away and back — no clear-on-unmount needed (#1414). - const values = formValues ?? {}; return ( @@ -115,18 +111,21 @@ export function ToolsScreen({ onUiChange({ ...ui, search: value })} onSelectTool={(name) => { // Seed the form with the tool's schema defaults so default-only // fields the user never edits are still sent on execute (the // form shows defaults via resolveValue, but onChange only writes // edited fields). const tool = tools.find((t) => t.name === name); - onFormChange(tool ? collectSchemaDefaults(tool.inputSchema) : {}); - onSelectTool(name); + onUiChange({ + ...ui, + selectedToolName: name, + formValues: tool ? collectSchemaDefaults(tool.inputSchema) : {}, + }); }} /> @@ -137,11 +136,13 @@ export function ToolsScreen({ {selectedTool ? ( onCallTool(selectedTool.name, values)} + onFormChange={(values) => + onUiChange({ ...ui, formValues: values }) + } + onExecute={() => onCallTool(selectedTool.name, formValues)} onCancel={() => onCancelCall?.()} /> ) : ( diff --git a/clients/web/src/components/screens/screenUiState.ts b/clients/web/src/components/screens/screenUiState.ts new file mode 100644 index 000000000..82ef2afe9 --- /dev/null +++ b/clients/web/src/components/screens/screenUiState.ts @@ -0,0 +1,62 @@ +// Empty-state defaults for each screen's lifted `ui` object — the value App +// seeds its per-screen UI state with and resets to on disconnect (#1417). The +// `*UiState` interfaces live alongside their screens; the defaults are gathered +// here (a non-component module) so the screen files can keep a single component +// export under the react-refresh rule, mirroring logLevels.ts / fetchCategories.ts. +import type { ToolsUiState } from "./ToolsScreen/ToolsScreen"; +import type { PromptsUiState } from "./PromptsScreen/PromptsScreen"; +import type { ResourcesUiState } from "./ResourcesScreen/ResourcesScreen"; +import type { AppsUiState } from "./AppsScreen/AppsScreen"; +import type { TasksUiState } from "./TasksScreen/TasksScreen"; +import type { LogsUiState } from "./LoggingScreen/LoggingScreen"; +import type { HistoryUiState } from "./HistoryScreen/HistoryScreen"; +import type { NetworkUiState } from "./NetworkScreen/NetworkScreen"; +import { ALL_LEVELS_VISIBLE } from "./LoggingScreen/logLevels"; +import { ALL_CATEGORIES_VISIBLE } from "./NetworkScreen/fetchCategories"; + +export const EMPTY_TOOLS_UI: ToolsUiState = { + selectedToolName: undefined, + formValues: {}, + search: "", +}; + +export const EMPTY_PROMPTS_UI: PromptsUiState = { + selectedPromptName: undefined, + argumentValues: {}, + submittedFor: undefined, + search: "", +}; + +export const EMPTY_RESOURCES_UI: ResourcesUiState = { + selectedResourceUri: undefined, + selectedTemplateUri: undefined, + originatingTemplateUri: undefined, + search: "", + openSections: undefined, +}; + +export const EMPTY_APPS_UI: AppsUiState = { + selectedAppName: undefined, + formValues: {}, + search: "", +}; + +export const EMPTY_TASKS_UI: TasksUiState = { + search: "", + statusFilter: undefined, +}; + +export const EMPTY_LOGS_UI: LogsUiState = { + filterText: "", + visibleLevels: ALL_LEVELS_VISIBLE, +}; + +export const EMPTY_HISTORY_UI: HistoryUiState = { + search: "", + methodFilter: undefined, +}; + +export const EMPTY_NETWORK_UI: NetworkUiState = { + filterText: "", + visibleCategories: ALL_CATEGORIES_VISIBLE, +}; diff --git a/clients/web/src/components/views/InspectorView/InspectorView.stories.tsx b/clients/web/src/components/views/InspectorView/InspectorView.stories.tsx index 756f4cba8..aadd0e933 100644 --- a/clients/web/src/components/views/InspectorView/InspectorView.stories.tsx +++ b/clients/web/src/components/views/InspectorView/InspectorView.stories.tsx @@ -16,6 +16,16 @@ import type { import type { Meta, StoryObj } from "@storybook/react-vite"; import { fn } from "storybook/test"; import { InspectorView } from "./InspectorView"; +import { + EMPTY_TOOLS_UI, + EMPTY_APPS_UI, + EMPTY_PROMPTS_UI, + EMPTY_RESOURCES_UI, + EMPTY_TASKS_UI, + EMPTY_LOGS_UI, + EMPTY_HISTORY_UI, + EMPTY_NETWORK_UI, +} from "../../screens/screenUiState"; import { mixedEntries as demoLogs } from "../../screens/LoggingScreen/LoggingScreen.fixtures"; import { longToolList as demoRegularTools } from "../../screens/ToolsScreen/ToolsScreen.fixtures"; import { SUN_ICON_SVG } from "../../../test/fixtures/storyIcons"; @@ -321,6 +331,16 @@ const meta: Meta = { bridgeFactory: noopBridgeFactory, appRendererRef: { current: null }, + // Per-screen UI state (search / filter / selection), one object per screen. + toolsUi: EMPTY_TOOLS_UI, + promptsUi: EMPTY_PROMPTS_UI, + resourcesUi: EMPTY_RESOURCES_UI, + appsUi: EMPTY_APPS_UI, + tasksUi: EMPTY_TASKS_UI, + logsUi: EMPTY_LOGS_UI, + historyUi: EMPTY_HISTORY_UI, + networkUi: EMPTY_NETWORK_UI, + // Callbacks — all wired to storybook spies so play functions can assert // on dispatch. Real wiring routes these to InspectorClient methods (the // app shell at clients/web/src/App.tsx). @@ -336,49 +356,34 @@ const meta: Meta = { onServerEdit: fn(), onServerClone: fn(), onServerRemove: fn(), - onSelectTool: fn(), - onToolFormChange: fn(), - onToolSearchChange: fn(), + onToolsUiChange: fn(), onCallTool: fn(), onRefreshTools: fn(), - onSelectedPromptNameChange: fn(), - onPromptArgumentValuesChange: fn(), - onPromptSubmittedForChange: fn(), - onPromptSearchChange: fn(), + onPromptsUiChange: fn(), onGetPrompt: fn(), onRefreshPrompts: fn(), - onSelectedResourceUriChange: fn(), - onSelectedTemplateUriChange: fn(), - onOriginatingTemplateUriChange: fn(), - onResourceSearchChange: fn(), - onResourceOpenSectionsChange: fn(), + onResourcesUiChange: fn(), onReadResource: fn(), onSubscribeResource: fn(), onUnsubscribeResource: fn(), onRefreshResources: fn(), - onTaskSearchChange: fn(), - onTaskStatusFilterChange: fn(), + onTasksUiChange: fn(), onCancelTask: fn(), onClearCompletedTasks: fn(), onRefreshTasks: fn(), onSetLogLevel: fn(), - onLogFilterChange: fn(), - onLogVisibleLevelsChange: fn(), + onLogsUiChange: fn(), onClearLogs: fn(), onExportLogs: fn(), - onHistorySearchChange: fn(), - onHistoryMethodFilterChange: fn(), + onHistoryUiChange: fn(), onClearHistory: fn(), onExportHistory: fn(), onReplayHistory: fn(), onTogglePinHistory: fn(), - onNetworkFilterChange: fn(), - onNetworkVisibleCategoriesChange: fn(), + onNetworkUiChange: fn(), onClearNetwork: fn(), onExportNetwork: fn(), - onSelectedAppNameChange: fn(), - onAppFormValuesChange: fn(), - onAppSearchChange: fn(), + onAppsUiChange: fn(), onSelectApp: fn(), onOpenApp: fn(), onCloseApp: fn(), diff --git a/clients/web/src/components/views/InspectorView/InspectorView.test.tsx b/clients/web/src/components/views/InspectorView/InspectorView.test.tsx index 5edad2067..db526455f 100644 --- a/clients/web/src/components/views/InspectorView/InspectorView.test.tsx +++ b/clients/web/src/components/views/InspectorView/InspectorView.test.tsx @@ -14,6 +14,17 @@ import { } from "../../../test/renderWithMantine"; import { InspectorView, type InspectorViewProps } from "./InspectorView"; import type { BridgeFactory } from "../../elements/AppRenderer/AppRenderer"; +import type { AppsUiState } from "../../screens/AppsScreen/AppsScreen"; +import { + EMPTY_TOOLS_UI, + EMPTY_APPS_UI, + EMPTY_PROMPTS_UI, + EMPTY_RESOURCES_UI, + EMPTY_TASKS_UI, + EMPTY_LOGS_UI, + EMPTY_HISTORY_UI, + EMPTY_NETWORK_UI, +} from "../../screens/screenUiState"; // Stub bridge factory — AppsScreen mounts the inner iframe and invokes // `bridgeFactory(...)` on selection. The stub keeps that path quiet by @@ -54,6 +65,14 @@ function makeProps( sandboxPath: "about:blank", bridgeFactory: noopBridgeFactory, appRendererRef: { current: null }, + toolsUi: EMPTY_TOOLS_UI, + promptsUi: EMPTY_PROMPTS_UI, + resourcesUi: EMPTY_RESOURCES_UI, + appsUi: EMPTY_APPS_UI, + tasksUi: EMPTY_TASKS_UI, + logsUi: EMPTY_LOGS_UI, + historyUi: EMPTY_HISTORY_UI, + networkUi: EMPTY_NETWORK_UI, onToggleTheme: vi.fn(), onToggleConnection: vi.fn(), onDisconnect: vi.fn(), @@ -66,49 +85,34 @@ function makeProps( onServerEdit: vi.fn(), onServerClone: vi.fn(), onServerRemove: vi.fn(), - onSelectTool: vi.fn(), - onToolFormChange: vi.fn(), - onToolSearchChange: vi.fn(), + onToolsUiChange: vi.fn(), onCallTool: vi.fn(), onRefreshTools: vi.fn(), - onSelectedPromptNameChange: vi.fn(), - onPromptArgumentValuesChange: vi.fn(), - onPromptSubmittedForChange: vi.fn(), - onPromptSearchChange: vi.fn(), + onPromptsUiChange: vi.fn(), onGetPrompt: vi.fn(), onRefreshPrompts: vi.fn(), - onSelectedResourceUriChange: vi.fn(), - onSelectedTemplateUriChange: vi.fn(), - onOriginatingTemplateUriChange: vi.fn(), - onResourceSearchChange: vi.fn(), - onResourceOpenSectionsChange: vi.fn(), + onResourcesUiChange: vi.fn(), onReadResource: vi.fn(), onSubscribeResource: vi.fn(), onUnsubscribeResource: vi.fn(), onRefreshResources: vi.fn(), - onTaskSearchChange: vi.fn(), - onTaskStatusFilterChange: vi.fn(), + onTasksUiChange: vi.fn(), onCancelTask: vi.fn(), onClearCompletedTasks: vi.fn(), onRefreshTasks: vi.fn(), onSetLogLevel: vi.fn(), - onLogFilterChange: vi.fn(), - onLogVisibleLevelsChange: vi.fn(), + onLogsUiChange: vi.fn(), onClearLogs: vi.fn(), onExportLogs: vi.fn(), - onHistorySearchChange: vi.fn(), - onHistoryMethodFilterChange: vi.fn(), + onHistoryUiChange: vi.fn(), onClearHistory: vi.fn(), onExportHistory: vi.fn(), onReplayHistory: vi.fn(), onTogglePinHistory: vi.fn(), - onNetworkFilterChange: vi.fn(), - onNetworkVisibleCategoriesChange: vi.fn(), + onNetworkUiChange: vi.fn(), onClearNetwork: vi.fn(), onExportNetwork: vi.fn(), - onSelectedAppNameChange: vi.fn(), - onAppFormValuesChange: vi.fn(), - onAppSearchChange: vi.fn(), + onAppsUiChange: vi.fn(), onSelectApp: vi.fn(), onOpenApp: vi.fn(), onCloseApp: vi.fn(), @@ -124,18 +128,11 @@ function makeProps( // (#1417). This host holds the App-tab selection/form state and threads it back // in as controlled props, mirroring how App.tsx owns it in the real wiring. function StatefulInspectorView({ props }: { props: InspectorViewProps }) { - const [selectedAppName, setSelectedAppName] = useState(props.selectedAppName); - const [appFormValues, setAppFormValues] = useState>( - props.appFormValues ?? {}, + const [appsUi, setAppsUi] = useState( + props.appsUi ?? EMPTY_APPS_UI, ); return ( - + ); } diff --git a/clients/web/src/components/views/InspectorView/InspectorView.tsx b/clients/web/src/components/views/InspectorView/InspectorView.tsx index 3ec3d02f9..ecbe7a99d 100644 --- a/clients/web/src/components/views/InspectorView/InspectorView.tsx +++ b/clients/web/src/components/views/InspectorView/InspectorView.tsx @@ -8,16 +8,13 @@ import type { Resource, ResourceTemplate, Task, - TaskStatus, Tool, } from "@modelcontextprotocol/sdk/types.js"; import type { ConnectionStatus, - FetchRequestCategory, FetchRequestEntry, InspectorResourceSubscription, MessageEntry, - MessageMethod, ServerEntry, } from "@inspector/core/mcp/types.js"; import { isAppTool } from "@inspector/core/mcp/apps.js"; @@ -26,8 +23,12 @@ import { ServerListScreen } from "../../screens/ServerListScreen/ServerListScree import { ToolsScreen, type ToolCallState, + type ToolsUiState, } from "../../screens/ToolsScreen/ToolsScreen"; -import { AppsScreen } from "../../screens/AppsScreen/AppsScreen"; +import { + AppsScreen, + type AppsUiState, +} from "../../screens/AppsScreen/AppsScreen"; import type { AppRendererHandle, BridgeFactory, @@ -35,17 +36,31 @@ import type { import { PromptsScreen, type GetPromptState, + type PromptsUiState, } from "../../screens/PromptsScreen/PromptsScreen"; import { ResourcesScreen, type ReadResourceState, + type ResourcesUiState, } from "../../screens/ResourcesScreen/ResourcesScreen"; -import { LoggingScreen } from "../../screens/LoggingScreen/LoggingScreen"; +import { + LoggingScreen, + type LogsUiState, +} from "../../screens/LoggingScreen/LoggingScreen"; import type { LogEntryData } from "../../elements/LogEntry/LogEntry"; -import { TasksScreen } from "../../screens/TasksScreen/TasksScreen"; +import { + TasksScreen, + type TasksUiState, +} from "../../screens/TasksScreen/TasksScreen"; import type { TaskProgress } from "../../groups/TaskCard/TaskCard"; -import { HistoryScreen } from "../../screens/HistoryScreen/HistoryScreen"; -import { NetworkScreen } from "../../screens/NetworkScreen/NetworkScreen"; +import { + HistoryScreen, + type HistoryUiState, +} from "../../screens/HistoryScreen/HistoryScreen"; +import { + NetworkScreen, + type NetworkUiState, +} from "../../screens/NetworkScreen/NetworkScreen"; import type { SortDirection } from "../../elements/SortToggle/SortToggle"; import { getServerType } from "@inspector/core/mcp/config.js"; @@ -198,40 +213,18 @@ export interface InspectorViewProps { getPromptState?: GetPromptState; readResourceState?: ReadResourceState; - // Per-screen selection / search / filter state. Owned by the parent (App) so - // it persists across tab navigation within a live session — the screens - // unmount on tab switch, so screen-local state would be lost (#1417). - // Tools: - selectedToolName?: string; - toolFormValues?: Record; - toolSearch?: string; - // Prompts: - selectedPromptName?: string; - promptArgumentValues?: Record; - promptSubmittedFor?: string; - promptSearch?: string; - // Resources: - selectedResourceUri?: string; - selectedTemplateUri?: string; - originatingTemplateUri?: string; - resourceSearch?: string; - resourceOpenSections?: string[]; - // Apps: - selectedAppName?: string; - appFormValues?: Record; - appSearch?: string; - // Tasks: - taskSearch?: string; - taskStatusFilter?: TaskStatus; - // Logs: - logFilterText?: string; - logVisibleLevels?: Record; - // History: - historySearch?: string; - historyMethodFilter?: MessageMethod; - // Network: - networkFilterText?: string; - networkVisibleCategories?: Record; + // Per-screen selection / search / filter state, one object per screen. Owned + // by the parent (App) so it persists across tab navigation within a live + // session — the screens unmount on tab switch, so screen-local state would be + // lost (#1417). Each is paired with an `on{Screen}UiChange` setter below. + toolsUi: ToolsUiState; + promptsUi: PromptsUiState; + resourcesUi: ResourcesUiState; + appsUi: AppsUiState; + tasksUi: TasksUiState; + logsUi: LogsUiState; + historyUi: HistoryUiState; + networkUi: NetworkUiState; // Logging level. The MCP `logging/setLevel` request has no echo // notification, so the parent keeps the optimistic current value. @@ -270,28 +263,19 @@ export interface InspectorViewProps { onServerRemove: (id: string) => void; // Per-primitive actions (route to `inspectorClient` methods / hook refresh). - // The `on*Change` callbacks below persist the lifted per-screen state (#1417). - onSelectTool: (name: string) => void; - onToolFormChange: (values: Record) => void; - onToolSearchChange: (value: string) => void; + // Each `on{Screen}UiChange` persists that screen's lifted UI state (#1417). + onToolsUiChange: (next: ToolsUiState) => void; onCallTool: (name: string, args: Record) => void; onCancelToolCall?: () => void; onClearToolResult?: () => void; onRefreshTools: () => void; - onSelectedPromptNameChange: (value: string | undefined) => void; - onPromptArgumentValuesChange: (value: Record) => void; - onPromptSubmittedForChange: (value: string | undefined) => void; - onPromptSearchChange: (value: string) => void; + onPromptsUiChange: (next: PromptsUiState) => void; onGetPrompt: (name: string, args: Record) => void; onCopyPromptMessages?: () => void; onRefreshPrompts: () => void; - onSelectedResourceUriChange: (value: string | undefined) => void; - onSelectedTemplateUriChange: (value: string | undefined) => void; - onOriginatingTemplateUriChange: (value: string | undefined) => void; - onResourceSearchChange: (value: string) => void; - onResourceOpenSectionsChange: (value: string[]) => void; + onResourcesUiChange: (next: ResourcesUiState) => void; onReadResource: (uri: string) => void; onSubscribeResource: (uri: string) => void; onUnsubscribeResource: (uri: string) => void; @@ -306,35 +290,27 @@ export interface InspectorViewProps { ) => Promise; completionsSupported?: boolean; - onTaskSearchChange: (value: string) => void; - onTaskStatusFilterChange: (value: TaskStatus | undefined) => void; + onTasksUiChange: (next: TasksUiState) => void; onCancelTask: (taskId: string) => void; onClearCompletedTasks: () => void; onRefreshTasks: () => void; onSetLogLevel: (level: LoggingLevel) => void; - onLogFilterChange: (value: string) => void; - onLogVisibleLevelsChange: (value: Record) => void; + onLogsUiChange: (next: LogsUiState) => void; onClearLogs: () => void; onExportLogs: () => void; - onHistorySearchChange: (value: string) => void; - onHistoryMethodFilterChange: (value: MessageMethod | undefined) => void; + onHistoryUiChange: (next: HistoryUiState) => void; onClearHistory: () => void; onExportHistory: () => void; onReplayHistory: (id: string) => void; onTogglePinHistory: (id: string) => void; - onNetworkFilterChange: (value: string) => void; - onNetworkVisibleCategoriesChange: ( - value: Record, - ) => void; + onNetworkUiChange: (next: NetworkUiState) => void; onClearNetwork: () => void; onExportNetwork: () => void; - onSelectedAppNameChange: (value: string | undefined) => void; - onAppFormValuesChange: (values: Record) => void; - onAppSearchChange: (value: string) => void; + onAppsUiChange: (next: AppsUiState) => void; onSelectApp: (name: string) => void; onOpenApp: (name: string, args: Record) => void; onCloseApp: () => void; @@ -361,29 +337,14 @@ export function InspectorView({ toolCallState, getPromptState, readResourceState, - selectedToolName, - toolFormValues, - toolSearch, - selectedPromptName, - promptArgumentValues, - promptSubmittedFor, - promptSearch, - selectedResourceUri, - selectedTemplateUri, - originatingTemplateUri, - resourceSearch, - resourceOpenSections, - selectedAppName, - appFormValues, - appSearch, - taskSearch, - taskStatusFilter, - logFilterText, - logVisibleLevels, - historySearch, - historyMethodFilter, - networkFilterText, - networkVisibleCategories, + toolsUi, + promptsUi, + resourcesUi, + appsUi, + tasksUi, + logsUi, + historyUi, + networkUi, currentLogLevel, sandboxPath, bridgeFactory, @@ -401,54 +362,39 @@ export function InspectorView({ onServerEdit, onServerClone, onServerRemove, - onSelectTool, - onToolFormChange, - onToolSearchChange, + onToolsUiChange, onCallTool, onCancelToolCall, onClearToolResult, onRefreshTools, - onSelectedPromptNameChange, - onPromptArgumentValuesChange, - onPromptSubmittedForChange, - onPromptSearchChange, + onPromptsUiChange, onGetPrompt, onCopyPromptMessages, onRefreshPrompts, - onSelectedResourceUriChange, - onSelectedTemplateUriChange, - onOriginatingTemplateUriChange, - onResourceSearchChange, - onResourceOpenSectionsChange, + onResourcesUiChange, onReadResource, onSubscribeResource, onUnsubscribeResource, onRefreshResources, onCompleteArgument, completionsSupported, - onTaskSearchChange, - onTaskStatusFilterChange, + onTasksUiChange, onCancelTask, onClearCompletedTasks, onRefreshTasks, onSetLogLevel, - onLogFilterChange, - onLogVisibleLevelsChange, + onLogsUiChange, onClearLogs, onExportLogs, - onHistorySearchChange, - onHistoryMethodFilterChange, + onHistoryUiChange, onClearHistory, onExportHistory, onReplayHistory, onTogglePinHistory, - onNetworkFilterChange, - onNetworkVisibleCategoriesChange, + onNetworkUiChange, onClearNetwork, onExportNetwork, - onSelectedAppNameChange, - onAppFormValuesChange, - onAppSearchChange, + onAppsUiChange, onSelectApp, onOpenApp, onCloseApp, @@ -576,13 +522,9 @@ export function InspectorView({ Date: Wed, 3 Jun 2026 09:10:33 -0400 Subject: [PATCH 5/6] fix(web): dismiss visible progress toasts on client teardown (#1414 review) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- clients/web/src/App.test.tsx | 24 ++++++++++++++++++++++++ clients/web/src/App.tsx | 9 +++++++-- 2 files changed, 31 insertions(+), 2 deletions(-) diff --git a/clients/web/src/App.test.tsx b/clients/web/src/App.test.tsx index d872a9229..4de6f2af1 100644 --- a/clients/web/src/App.test.tsx +++ b/clients/web/src/App.test.tsx @@ -394,6 +394,7 @@ describe("App tool progress toasts", () => { clientInstances.length = 0; notificationsMock.show.mockClear(); notificationsMock.update.mockClear(); + notificationsMock.hide.mockClear(); }); it("shows a toast on the first progress tick and updates it on later ticks", async () => { @@ -448,6 +449,29 @@ describe("App tool progress toasts", () => { }); expect(notificationsMock.show.mock.calls[0][0].message).toBe("7"); }); + + it("dismisses still-visible progress toasts when the client is torn down", async () => { + const user = userEvent.setup(); + const { unmount } = renderWithMantine(); + + await user.click(screen.getByText("connect")); + await waitFor(() => expect(clientInstances).toHaveLength(1)); + + act(() => { + clientInstances[0].dispatchEvent( + new CustomEvent("progressNotification", { + detail: { progress: 1, total: 4 }, + }), + ); + }); + const id = notificationsMock.show.mock.calls[0][0].id; + + // Tearing down the client (here via unmount; same path as a server swap) + // hides the live toast so it can't linger into — or race with — the next + // session, rather than waiting out its auto-close window. + unmount(); + expect(notificationsMock.hide).toHaveBeenCalledWith(id); + }); }); describe("App pending server-initiated request modal", () => { diff --git a/clients/web/src/App.tsx b/clients/web/src/App.tsx index 28753d6c0..00ae6b1e7 100644 --- a/clients/web/src/App.tsx +++ b/clients/web/src/App.tsx @@ -556,8 +556,13 @@ function App() { inspectorClient.addEventListener("progressNotification", onProgress); return () => { inspectorClient.removeEventListener("progressNotification", onProgress); - // Drop stream bookkeeping when the client is swapped out; the next - // session starts with no live progress toasts tracked. + // Dismiss any still-visible progress toasts when the client is swapped + // out, then drop the stream bookkeeping. Hiding them (rather than letting + // them auto-close up to PROGRESS_TOAST_AUTOCLOSE_MS later) keeps a stale + // "Tool progress" toast from lingering into the next session, and avoids + // a race where the lingering toast's `onClose` would later delete an id + // from the *new* session's set and trigger a duplicate-id re-show. + liveToastIds.forEach((id) => notifications.hide(id)); liveToastIds.clear(); }; }, [inspectorClient]); From 7fb8354923dddfcdc6166004352caea4da4b567b Mon Sep 17 00:00:00 2001 From: cliffhall Date: Wed, 3 Jun 2026 10:57:41 -0400 Subject: [PATCH 6/6] 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) --- clients/web/src/App.test.tsx | 59 ++++++++++++++++++++++++++++++++++++ clients/web/src/App.tsx | 24 +++++++++++++-- 2 files changed, 81 insertions(+), 2 deletions(-) diff --git a/clients/web/src/App.test.tsx b/clients/web/src/App.test.tsx index 4de6f2af1..c66cb03bf 100644 --- a/clients/web/src/App.test.tsx +++ b/clients/web/src/App.test.tsx @@ -260,6 +260,18 @@ vi.mock("./components/views/InspectorView/InspectorView", () => ({ > select-tool +