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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

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

View file

@ -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 <Notifications/> 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,37 @@ vi.mock("@inspector/core/react/useSettingsDraft.js", () => ({
vi.mock("./components/views/InspectorView/InspectorView", () => ({
InspectorView: (props: {
toolCallState?: { status?: string };
toolsUi?: {
selectedToolName?: string;
formValues: Record<string, unknown>;
search: string;
};
promptsUi?: {
selectedPromptName?: string;
argumentValues: Record<string, string>;
submittedFor?: string;
search: string;
};
logsUi?: { filterText: string; visibleLevels: Record<string, boolean> };
getPromptState?: { status?: string };
readResourceState?: { status?: string };
currentLogLevel?: string;
onToggleConnection: (id: string) => void;
onToolsUiChange: (next: {
selectedToolName?: string;
formValues: Record<string, unknown>;
search: string;
}) => void;
onPromptsUiChange: (next: {
selectedPromptName?: string;
argumentValues: Record<string, string>;
submittedFor?: string;
search: string;
}) => void;
onLogsUiChange: (next: {
filterText: string;
visibleLevels: Record<string, boolean>;
}) => void;
onCallTool: (name: string, args: Record<string, unknown>) => void;
onGetPrompt: (name: string, args: Record<string, string>) => void;
onReadResource: (uri: string) => void;
@ -190,6 +232,14 @@ vi.mock("./components/views/InspectorView/InspectorView", () => ({
<span data-testid="tool-status">
{props.toolCallState?.status ?? "none"}
</span>
<span data-testid="selected-tool">
{props.toolsUi?.selectedToolName ?? "none"}
</span>
<span data-testid="tool-search">{props.toolsUi?.search || "none"}</span>
<span data-testid="selected-prompt">
{props.promptsUi?.selectedPromptName ?? "none"}
</span>
<span data-testid="log-filter">{props.logsUi?.filterText || "none"}</span>
<span data-testid="prompt-status">
{props.getPromptState?.status ?? "none"}
</span>
@ -198,6 +248,64 @@ vi.mock("./components/views/InspectorView/InspectorView", () => ({
</span>
<span data-testid="log-level">{props.currentLogLevel}</span>
<button onClick={() => props.onToggleConnection("A")}>connect</button>
<button
onClick={() =>
props.onToolsUiChange({
formValues: {},
search: "",
...props.toolsUi,
selectedToolName: "get_acts",
})
}
>
select-tool
</button>
<button
onClick={() =>
props.onToolsUiChange({
formValues: {},
search: "",
...props.toolsUi,
selectedToolName: "other_tool",
})
}
>
select-other-tool
</button>
<button
onClick={() =>
props.onToolsUiChange({
formValues: {},
...props.toolsUi,
search: "act",
})
}
>
set-tool-search
</button>
<button
onClick={() =>
props.onPromptsUiChange({
argumentValues: {},
search: "",
...props.promptsUi,
selectedPromptName: "greet",
})
}
>
select-prompt
</button>
<button
onClick={() =>
props.onLogsUiChange({
visibleLevels: {},
...props.logsUi,
filterText: "err",
})
}
>
set-log-filter
</button>
<button onClick={() => props.onCallTool("get_acts", {})}>call</button>
<button onClick={() => props.onGetPrompt("greet", {})}>get-prompt</button>
<button onClick={() => props.onReadResource("res://x")}>
@ -238,13 +346,29 @@ describe("App session-scoped state reset on disconnect", () => {
expect(screen.getByTestId("resource-status")).toHaveTextContent("ok");
});
// Set App-owned per-screen UI state (selection + search + filter) — all of
// it persists across navigation, so all of it must reset on disconnect
// (#1417). A representative sample across screens exercises the shared
// `resetSessionScopedUiState` wiring.
await user.click(screen.getByText("select-tool"));
await user.click(screen.getByText("set-tool-search"));
await user.click(screen.getByText("select-prompt"));
await user.click(screen.getByText("set-log-filter"));
await waitFor(() => {
expect(screen.getByTestId("selected-tool")).toHaveTextContent("get_acts");
expect(screen.getByTestId("tool-search")).toHaveTextContent("act");
expect(screen.getByTestId("selected-prompt")).toHaveTextContent("greet");
expect(screen.getByTestId("log-filter")).toHaveTextContent("err");
});
// Bump the optimistic log level off its "info" default.
await user.click(screen.getByText("set-level"));
await waitFor(() =>
expect(screen.getByTestId("log-level")).toHaveTextContent("debug"),
);
// Disconnect: every panel empties and the level returns to "info".
// Disconnect: every panel empties, all per-screen UI state clears, and the
// level returns to "info".
act(() => {
clientInstances[0].dispatchEvent(new Event("disconnect"));
});
@ -254,8 +378,159 @@ describe("App session-scoped state reset on disconnect", () => {
expect(screen.getByTestId("prompt-status")).toHaveTextContent("none");
expect(screen.getByTestId("resource-status")).toHaveTextContent("none");
});
expect(screen.getByTestId("selected-tool")).toHaveTextContent("none");
expect(screen.getByTestId("tool-search")).toHaveTextContent("none");
expect(screen.getByTestId("selected-prompt")).toHaveTextContent("none");
expect(screen.getByTestId("log-filter")).toHaveTextContent("none");
expect(screen.getByTestId("log-level")).toHaveTextContent("info");
});
it("persists the selected tool across navigation within a live session", async () => {
const user = userEvent.setup();
renderWithMantine(<App />);
await user.click(screen.getByText("connect"));
await waitFor(() => expect(clientInstances).toHaveLength(1));
// The selection lives in App (not the unmounting ToolsScreen), so once set
// it stays put through re-renders / tab switches until the session ends.
await user.click(screen.getByText("select-tool"));
await waitFor(() =>
expect(screen.getByTestId("selected-tool")).toHaveTextContent("get_acts"),
);
});
it("drops the previous tool's result when a different tool is selected", async () => {
const user = userEvent.setup();
renderWithMantine(<App />);
await user.click(screen.getByText("connect"));
await waitFor(() => expect(clientInstances).toHaveLength(1));
// Select a tool and run it so the result panel is populated.
await user.click(screen.getByText("select-tool"));
await user.click(screen.getByText("call"));
await waitFor(() =>
expect(screen.getByTestId("tool-status")).toHaveTextContent("ok"),
);
// Selecting a *different* tool clears the stale result so it doesn't linger
// under the new selection.
await user.click(screen.getByText("select-other-tool"));
await waitFor(() => {
expect(screen.getByTestId("selected-tool")).toHaveTextContent(
"other_tool",
);
expect(screen.getByTestId("tool-status")).toHaveTextContent("none");
});
});
it("keeps the result when the same tool stays selected (search/form edits)", async () => {
const user = userEvent.setup();
renderWithMantine(<App />);
await user.click(screen.getByText("connect"));
await waitFor(() => expect(clientInstances).toHaveLength(1));
await user.click(screen.getByText("select-tool"));
await user.click(screen.getByText("call"));
await waitFor(() =>
expect(screen.getByTestId("tool-status")).toHaveTextContent("ok"),
);
// A search keystroke leaves `selectedToolName` unchanged, so the result
// stays put.
await user.click(screen.getByText("set-tool-search"));
await waitFor(() =>
expect(screen.getByTestId("tool-search")).toHaveTextContent("act"),
);
expect(screen.getByTestId("tool-status")).toHaveTextContent("ok");
});
});
describe("App tool progress toasts", () => {
beforeEach(() => {
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 () => {
const user = userEvent.setup();
renderWithMantine(<App />);
await user.click(screen.getByText("connect"));
await waitFor(() => expect(clientInstances).toHaveLength(1));
// First tick of a progress stream → a fresh toast keyed by its stream id.
act(() => {
clientInstances[0].dispatchEvent(
new CustomEvent("progressNotification", {
detail: { progress: 1, total: 4, message: "Working" },
}),
);
});
expect(notificationsMock.show).toHaveBeenCalledTimes(1);
const shown = notificationsMock.show.mock.calls[0][0];
expect(shown.title).toBe("Tool progress");
expect(shown.message).toBe("Working — 1 / 4 (25%)");
// Second tick on the same stream → the existing toast is updated, not
// stacked, so a chatty server doesn't flood the corner.
act(() => {
clientInstances[0].dispatchEvent(
new CustomEvent("progressNotification", {
detail: { progress: 2, total: 4, message: "Working" },
}),
);
});
expect(notificationsMock.show).toHaveBeenCalledTimes(1);
expect(notificationsMock.update).toHaveBeenCalledTimes(1);
expect(notificationsMock.update.mock.calls[0][0].message).toBe(
"Working — 2 / 4 (50%)",
);
});
it("formats a totalless progress tick as the bare count", async () => {
const user = userEvent.setup();
renderWithMantine(<App />);
await user.click(screen.getByText("connect"));
await waitFor(() => expect(clientInstances).toHaveLength(1));
act(() => {
clientInstances[0].dispatchEvent(
new CustomEvent("progressNotification", {
detail: { progress: 7 },
}),
);
});
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(<App />);
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", () => {

View file

@ -13,10 +13,16 @@ import type {
InitializeResult,
LoggingLevel,
LoggingMessageNotification,
Progress,
ProgressToken,
Tool,
} from "@modelcontextprotocol/sdk/types.js";
import { InspectorClient } from "@inspector/core/mcp/index.js";
import type { JsonValue } from "@inspector/core/mcp/index.js";
import type {
InspectorClientEventMap,
JsonValue,
} from "@inspector/core/mcp/index.js";
import type { TypedEventGeneric } from "@inspector/core/mcp/typedEventTarget.js";
import type {
InspectorServerSettings,
MCPServerConfig,
@ -59,9 +65,23 @@ import { useFetchRequestLog } from "@inspector/core/react/useFetchRequestLog.js"
import { useSandboxUrl } from "@inspector/core/react/useSandboxUrl.js";
import { usePendingClientRequests } from "@inspector/core/react/usePendingClientRequests.js";
import { InspectorView } from "./components/views/InspectorView/InspectorView";
import type { ToolCallState } from "./components/screens/ToolsScreen/ToolsScreen";
import type {
ToolCallState,
ToolsUiState,
} from "./components/screens/ToolsScreen/ToolsScreen";
import type { GetPromptState } from "./components/screens/PromptsScreen/PromptsScreen";
import type { ReadResourceState } from "./components/screens/ResourcesScreen/ResourcesScreen";
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";
import type { LogEntryData } from "./components/elements/LogEntry/LogEntry";
@ -195,6 +215,34 @@ const OutputValidationToastMessage = ({
</Stack>
);
// How long a progress toast lingers after its last tick. Each new tick on the
// same progress stream resets this window (via `notifications.update`), so a
// steady stream keeps one toast alive; the toast clears a few seconds after
// progress stops (i.e. the call finished or went quiet).
const PROGRESS_TOAST_AUTOCLOSE_MS = 5000;
// Stable toast id for a progress stream. Notifications keyed by this id are
// replaced (not stacked) so a chatty server updates one toast per stream
// rather than flooding the corner. The injected `progressToken` correlates a
// stream with the request that triggered it; when absent (the common case —
// the inspector doesn't expose a caller token), all ticks share one toast.
function progressToastId(token: ProgressToken | undefined): string {
return `progress-${String(token ?? "default")}`;
}
// One-line toast body: "<message> — <progress> / <total> (NN%)". The fraction
// and percentage are omitted when the server sends no `total`.
function formatProgressToastMessage(
detail: Progress & { progressToken?: ProgressToken },
): string {
const { progress, total, message } = detail;
const ratio =
total !== undefined && total > 0
? `${progress} / ${total} (${Math.round((progress / total) * 100)}%)`
: `${progress}`;
return message ? `${message} — ${ratio}` : ratio;
}
function App() {
// Theme toggle plumbing (preserved from the pre-wire placeholder).
const { setColorScheme } = useMantineColorScheme();
@ -314,6 +362,23 @@ function App() {
ReadResourceState | undefined
>(undefined);
// 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
// intervening rerenders don't reset it.
@ -325,6 +390,12 @@ function App() {
// render; this ref ensures the token exchange fires exactly once per load.
const oauthCallbackHandledRef = useRef(false);
// Tracks which progress streams currently have a live toast, so each new tick
// updates the existing toast instead of stacking a fresh one. Entries are
// removed when their toast closes (auto-dismiss or user). A ref (not state)
// because it's incidental bookkeeping that must not trigger re-renders.
const progressToastIdsRef = useRef<Set<string>>(new Set());
// Hook layer. Each hook subscribes to its respective event source and
// re-renders the App on change. When `inspectorClient` / state managers
// are null, the hooks degrade to empty results.
@ -410,7 +481,18 @@ function App() {
setToolCallState(undefined);
setGetPromptState(undefined);
setReadResourceState(undefined);
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).
clearScrollMemory();
}, []);
// Reset activeServerId whenever the live session ends. Without this the
@ -438,6 +520,56 @@ function App() {
};
}, [inspectorClient, resetSessionScopedUiState]);
// Surface incoming `notifications/progress` as toasts so the user can watch a
// long-running tool's progress while staying on the tool view — the v2
// replacement for v1's always-visible "Server Notifications" shelf (#1414).
// The full notification history still lives in the History tab; these toasts
// are the at-a-glance, in-context signal. Toasts are keyed by progress stream
// (see `progressToastId`) and replaced per tick so a chatty server updates one
// toast rather than stacking one per tick.
useEffect(() => {
if (!inspectorClient) return;
const liveToastIds = progressToastIdsRef.current;
const onProgress = (
event: TypedEventGeneric<InspectorClientEventMap, "progressNotification">,
) => {
const detail = event.detail;
const id = progressToastId(detail.progressToken);
const message = formatProgressToastMessage(detail);
if (liveToastIds.has(id)) {
notifications.update({
id,
title: "Tool progress",
message,
color: "blue",
autoClose: PROGRESS_TOAST_AUTOCLOSE_MS,
});
return;
}
liveToastIds.add(id);
notifications.show({
id,
title: "Tool progress",
message,
color: "blue",
autoClose: PROGRESS_TOAST_AUTOCLOSE_MS,
onClose: () => liveToastIds.delete(id),
});
};
inspectorClient.addEventListener("progressNotification", onProgress);
return () => {
inspectorClient.removeEventListener("progressNotification", onProgress);
// 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]);
// Build the InitializeResult the connected ViewHeader expects from the
// hook's split fields. `protocolVersion` is hard-coded for now — the
// useInspectorClient hook doesn't expose it. TODO(#1324): consume the
@ -929,6 +1061,23 @@ function App() {
setToolCallState(undefined);
}, []);
// Tools UI changes flow through here so selecting a *different* tool also
// drops the previous tool's result — the result panel renders `toolCallState`
// regardless of selection, so without this a stale result would linger under
// the newly-selected tool (which has no result of its own yet). Search and
// form edits keep `selectedToolName` unchanged, so they leave the result be.
// Depends on `selectedToolName` only (not the whole `toolsUi`), so a search
// keystroke doesn't churn the callback identity.
const onToolsUiChange = useCallback(
(next: ToolsUiState) => {
if (next.selectedToolName !== toolsUi.selectedToolName) {
setToolCallState(undefined);
}
setToolsUi(next);
},
[toolsUi.selectedToolName],
);
// --- MCP Apps handlers. Unlike onCallTool (which feeds the Tools panel),
// these route the tool input/result into the running app via the renderer's
// imperative handle. ---
@ -1424,6 +1573,14 @@ function App() {
toolCallState={toolCallState}
getPromptState={getPromptState}
readResourceState={effectiveReadResourceState}
toolsUi={toolsUi}
promptsUi={promptsUi}
resourcesUi={resourcesUi}
appsUi={appsUi}
tasksUi={tasksUi}
logsUi={logsUi}
historyUi={historyUi}
networkUi={networkUi}
currentLogLevel={currentLogLevel}
sandboxPath={sandboxUrl}
bridgeFactory={sandboxBridgeFactory}
@ -1447,15 +1604,18 @@ function App() {
const target = servers.find((s) => s.id === id);
if (target) setRemoveTarget(target);
}}
onToolsUiChange={onToolsUiChange}
onCallTool={(name, args) => {
void onCallTool(name, args);
}}
onClearToolResult={onClearToolResult}
onRefreshTools={onRefreshTools}
onPromptsUiChange={setPromptsUi}
onGetPrompt={(name, args) => {
void onGetPrompt(name, args);
}}
onRefreshPrompts={onRefreshPrompts}
onResourcesUiChange={setResourcesUi}
onReadResource={(uri) => {
void onReadResource(uri);
}}
@ -1464,18 +1624,23 @@ function App() {
onRefreshResources={onRefreshResources}
onCompleteArgument={onCompleteArgument}
completionsSupported={capabilities?.completions !== undefined}
onTasksUiChange={setTasksUi}
onCancelTask={onCancelTask}
onClearCompletedTasks={todoNoop}
onRefreshTasks={onRefreshTasks}
onSetLogLevel={onSetLogLevel}
onLogsUiChange={setLogsUi}
onClearLogs={onClearLogs}
onExportLogs={onExportLogs}
onHistoryUiChange={setHistoryUi}
onClearHistory={onClearHistory}
onExportHistory={onExportHistory}
onReplayHistory={todoNoop}
onTogglePinHistory={todoNoop}
onNetworkUiChange={setNetworkUi}
onClearNetwork={onClearNetwork}
onExportNetwork={onExportNetwork}
onAppsUiChange={setAppsUi}
onSelectApp={onSelectApp}
onOpenApp={(name, args) => {
void onOpenApp(name, args);

View file

@ -1,9 +1,25 @@
import { useState } from "react";
import type { ComponentProps } from "react";
import type { Meta, StoryObj } from "@storybook/react-vite";
import type { Tool } from "@modelcontextprotocol/sdk/types.js";
import { fn, userEvent, within } from "storybook/test";
import { AppControls } from "./AppControls";
import { SUN_ICON_SVG } from "../../../test/fixtures/storyIcons";
// AppControls' search text is controlled by the parent (App, via AppsScreen —
// see #1417). This wrapper holds that state so the play-driven typing updates
// the controlled input.
function StatefulAppControls(args: ComponentProps<typeof AppControls>) {
const [searchText, setSearchText] = useState(args.searchText ?? "");
return (
<AppControls
{...args}
searchText={searchText}
onSearchChange={setSearchText}
/>
);
}
const sampleApps: Tool[] = [
{
name: "get-cohort-data",
@ -41,8 +57,10 @@ const meta: Meta<typeof AppControls> = {
args: {
onRefreshList: fn(),
onSelectApp: fn(),
onSearchChange: fn(),
listChanged: false,
},
render: (args) => <StatefulAppControls {...args} />,
};
export default meta;

View file

@ -1,8 +1,9 @@
import { useState } from "react";
import { describe, it, expect, vi } from "vitest";
import userEvent from "@testing-library/user-event";
import type { Tool } from "@modelcontextprotocol/sdk/types.js";
import { renderWithMantine, screen } from "../../../test/renderWithMantine";
import { AppControls } from "./AppControls";
import { AppControls, type AppControlsProps } from "./AppControls";
const sampleApps: Tool[] = [
{
@ -29,8 +30,27 @@ const baseProps = {
listChanged: false,
onRefreshList: vi.fn(),
onSelectApp: vi.fn(),
onSearchChange: vi.fn(),
};
// The search box is controlled: typing fires onSearchChange but does not
// update the component's own state. This host holds the searchText state so
// interaction tests that filter by typing behave like the real parent.
function ControlledAppControls(props: Partial<AppControlsProps>) {
const [searchText, setSearchText] = useState(props.searchText ?? "");
return (
<AppControls
{...baseProps}
{...props}
searchText={searchText}
onSearchChange={(value) => {
setSearchText(value);
props.onSearchChange?.(value);
}}
/>
);
}
describe("AppControls", () => {
it("renders the title with the app count and a search input", () => {
renderWithMantine(<AppControls {...baseProps} />);
@ -47,7 +67,7 @@ describe("AppControls", () => {
it("filters apps by name when typing in the search input", async () => {
const user = userEvent.setup();
renderWithMantine(<AppControls {...baseProps} />);
renderWithMantine(<ControlledAppControls />);
await user.type(screen.getByPlaceholderText("Search apps..."), "git");
expect(screen.getByText("git_status")).toBeInTheDocument();
expect(screen.queryByText("Weather Widget")).not.toBeInTheDocument();
@ -55,7 +75,7 @@ describe("AppControls", () => {
it("filters apps by title when typing in the search input", async () => {
const user = userEvent.setup();
renderWithMantine(<AppControls {...baseProps} />);
renderWithMantine(<ControlledAppControls />);
await user.type(
screen.getByPlaceholderText("Search apps..."),
"weather widget",
@ -72,7 +92,7 @@ describe("AppControls", () => {
it("shows 'No matching apps' when search yields no results", async () => {
const user = userEvent.setup();
renderWithMantine(<AppControls {...baseProps} />);
renderWithMantine(<ControlledAppControls />);
await user.type(screen.getByPlaceholderText("Search apps..."), "zzz");
expect(screen.getByText("No matching apps")).toBeInTheDocument();
});

View file

@ -1,4 +1,3 @@
import { useState } from "react";
import {
Button,
Group,
@ -11,12 +10,17 @@ import {
import type { Tool } from "@modelcontextprotocol/sdk/types.js";
import { ListChangedIndicator } from "../../elements/ListChangedIndicator/ListChangedIndicator";
import { AppListItem } from "../AppListItem/AppListItem";
import { useScrollMemory } from "../../../hooks/useScrollMemory";
export interface AppControlsProps {
tools: Tool[];
selectedName?: string;
// Search text is controlled by the parent (App, via AppsScreen) so it
// persists across tab navigation within a live session — see #1417.
searchText?: string;
listChanged: boolean;
onRefreshList: () => void;
onSearchChange: (value: string) => void;
onSelectApp: (name: string) => void;
}
@ -37,11 +41,13 @@ const EmptyState = Text.withProps({
export function AppControls({
tools,
selectedName,
searchText = "",
listChanged,
onRefreshList,
onSearchChange,
onSelectApp,
}: AppControlsProps) {
const [searchText, setSearchText] = useState("");
const viewportRef = useScrollMemory("apps-sidebar");
const query = searchText.toLowerCase();
const filteredTools = searchText
? tools.filter(
@ -61,9 +67,9 @@ export function AppControls({
<TextInput
placeholder="Search apps..."
value={searchText}
onChange={(e) => setSearchText(e.currentTarget.value)}
onChange={(e) => onSearchChange(e.currentTarget.value)}
/>
<ScrollArea.Autosize mah={LIST_MAX_HEIGHT}>
<ScrollArea.Autosize viewportRef={viewportRef} mah={LIST_MAX_HEIGHT}>
<Stack gap="xs">
{filteredTools.length === 0 ? (
<EmptyState>

View file

@ -16,6 +16,7 @@ import {
type SortDirection,
} from "../../elements/SortToggle/SortToggle";
import { extractMethod } from "../historyUtils.js";
import { useScrollMemory } from "../../../hooks/useScrollMemory";
export interface HistoryListPanelProps {
entries: MessageEntry[];
@ -84,6 +85,7 @@ export function HistoryListPanel({
compact,
onToggleCompact,
}: HistoryListPanelProps) {
const viewportRef = useScrollMemory("history-list");
const filteredEntries = useMemo(() => {
// `.filter()` returns a fresh array, so sorting in-place is safe.
const sorted = entries
@ -134,7 +136,10 @@ export function HistoryListPanel({
{!hasResults ? (
<EmptyState>No request history</EmptyState>
) : (
<ScrollArea.Autosize mah="calc(100vh - var(--app-shell-header-height, 0px) - 150px)">
<ScrollArea.Autosize
viewportRef={viewportRef}
mah="calc(100vh - var(--app-shell-header-height, 0px) - 150px)"
>
<Stack gap="md">
{pinnedEntries.length > 0 && (
<>

View file

@ -15,6 +15,7 @@ import {
SortToggle,
type SortDirection,
} from "../../elements/SortToggle/SortToggle";
import { useScrollMemory } from "../../../hooks/useScrollMemory";
export interface LogStreamPanelProps {
entries: LogEntryData[];
@ -69,6 +70,7 @@ export function LogStreamPanel({
sortDirection,
onSortChange,
}: LogStreamPanelProps) {
const viewportRef = useScrollMemory("logs-stream");
const filteredEntries = useMemo(() => {
// `.filter()` returns a fresh array, so sorting in-place is safe.
const sorted = entries
@ -105,7 +107,10 @@ export function LogStreamPanel({
</Group>
</Group>
{filteredEntries.length > 0 ? (
<ScrollArea.Autosize mah="calc(100vh - var(--app-shell-header-height, 0px) - 150px)">
<ScrollArea.Autosize
viewportRef={viewportRef}
mah="calc(100vh - var(--app-shell-header-height, 0px) - 150px)"
>
<Stack gap="xs">
{filteredEntries.map((entry, index) => (
<LogEntry key={index} entry={entry} />

View file

@ -18,6 +18,7 @@ import {
SortToggle,
type SortDirection,
} from "../../elements/SortToggle/SortToggle";
import { useScrollMemory } from "../../../hooks/useScrollMemory";
export interface NetworkStreamPanelProps {
entries: FetchRequestEntry[];
@ -95,6 +96,7 @@ export function NetworkStreamPanel({
compact,
onToggleCompact,
}: NetworkStreamPanelProps) {
const viewportRef = useScrollMemory("network-stream");
const filteredEntries = useMemo(() => {
// `.filter()` returns a fresh array, so sorting in-place is safe.
const sorted = entries
@ -132,7 +134,10 @@ export function NetworkStreamPanel({
{!hasResults ? (
<EmptyState>No network requests</EmptyState>
) : (
<ScrollArea.Autosize mah="calc(100vh - var(--app-shell-header-height, 0px) - 150px)">
<ScrollArea.Autosize
viewportRef={viewportRef}
mah="calc(100vh - var(--app-shell-header-height, 0px) - 150px)"
>
<Stack gap="md">
{filteredEntries.map((entry) => (
<NetworkEntry

View file

@ -9,6 +9,7 @@ const meta: Meta<typeof PromptControls> = {
args: {
onRefreshList: fn(),
onSelectPrompt: fn(),
onSearchChange: fn(),
listChanged: false,
},
};

View file

@ -1,8 +1,9 @@
import { useState } from "react";
import { describe, it, expect, vi } from "vitest";
import userEvent from "@testing-library/user-event";
import type { Prompt } from "@modelcontextprotocol/sdk/types.js";
import { renderWithMantine, screen } from "../../../test/renderWithMantine";
import { PromptControls } from "./PromptControls";
import { PromptControls, type PromptControlsProps } from "./PromptControls";
const samplePrompts: Prompt[] = [
{
@ -23,8 +24,27 @@ const baseProps = {
listChanged: false,
onRefreshList: vi.fn(),
onSelectPrompt: vi.fn(),
onSearchChange: vi.fn(),
};
// The search box is controlled: typing fires onSearchChange but does not
// update the component's own state. This host holds the searchText state so
// interaction tests that filter by typing behave like the real parent.
function ControlledPromptControls(props: Partial<PromptControlsProps>) {
const [searchText, setSearchText] = useState(props.searchText ?? "");
return (
<PromptControls
{...baseProps}
{...props}
searchText={searchText}
onSearchChange={(value) => {
setSearchText(value);
props.onSearchChange?.(value);
}}
/>
);
}
describe("PromptControls", () => {
it("renders the title and search input", () => {
renderWithMantine(<PromptControls {...baseProps} />);
@ -45,7 +65,7 @@ describe("PromptControls", () => {
it("filters prompts by name when typing in the search input", async () => {
const user = userEvent.setup();
renderWithMantine(<PromptControls {...baseProps} />);
renderWithMantine(<ControlledPromptControls />);
await user.type(screen.getByPlaceholderText("Search prompts..."), "trans");
expect(screen.getByText("translate")).toBeInTheDocument();
expect(screen.queryByText("summarize")).not.toBeInTheDocument();
@ -54,7 +74,7 @@ describe("PromptControls", () => {
it("filters prompts by description when typing in the search input", async () => {
const user = userEvent.setup();
renderWithMantine(<PromptControls {...baseProps} />);
renderWithMantine(<ControlledPromptControls />);
await user.type(
screen.getByPlaceholderText("Search prompts..."),
"sentiment",
@ -69,9 +89,7 @@ describe("PromptControls", () => {
{ name: "p1", title: "Alpha Title" },
{ name: "p2", title: "Beta Title" },
];
renderWithMantine(
<PromptControls {...baseProps} prompts={promptsWithTitle} />,
);
renderWithMantine(<ControlledPromptControls prompts={promptsWithTitle} />);
await user.type(screen.getByPlaceholderText("Search prompts..."), "alpha");
expect(screen.getByText("Alpha Title")).toBeInTheDocument();
expect(screen.queryByText("Beta Title")).not.toBeInTheDocument();

View file

@ -1,14 +1,18 @@
import { useState } from "react";
import { Group, ScrollArea, Stack, TextInput, Title } from "@mantine/core";
import type { Prompt } from "@modelcontextprotocol/sdk/types.js";
import { ListChangedIndicator } from "../../elements/ListChangedIndicator/ListChangedIndicator";
import { PromptListItem } from "../PromptListItem/PromptListItem";
import { useScrollMemory } from "../../../hooks/useScrollMemory";
export interface PromptControlsProps {
prompts: Prompt[];
selectedName?: string;
// Search text is controlled by the parent (App, via PromptsScreen) so it
// persists across tab navigation within a live session — see #1417.
searchText?: string;
listChanged: boolean;
onRefreshList: () => void;
onSearchChange: (value: string) => void;
onSelectPrompt: (name: string) => void;
}
@ -18,11 +22,13 @@ const LIST_MAX_HEIGHT =
export function PromptControls({
prompts,
selectedName,
searchText = "",
listChanged,
onRefreshList,
onSearchChange,
onSelectPrompt,
}: PromptControlsProps) {
const [searchText, setSearchText] = useState("");
const viewportRef = useScrollMemory("prompts-sidebar");
const query = searchText.toLowerCase();
const filteredPrompts = prompts.filter(
(p) =>
@ -40,9 +46,9 @@ export function PromptControls({
<TextInput
placeholder="Search prompts..."
value={searchText}
onChange={(e) => setSearchText(e.currentTarget.value)}
onChange={(e) => onSearchChange(e.currentTarget.value)}
/>
<ScrollArea.Autosize mah={LIST_MAX_HEIGHT}>
<ScrollArea.Autosize viewportRef={viewportRef} mah={LIST_MAX_HEIGHT}>
<Stack gap="xs">
{filteredPrompts.map((prompt) => (
<PromptListItem

View file

@ -15,6 +15,8 @@ const meta: Meta<typeof ResourceControls> = {
onSelectUri: fn(),
onSelectTemplate: fn(),
onUnsubscribeResource: fn(),
onSearchChange: fn(),
onOpenSectionsChange: fn(),
listChanged: false,
compact: false,
onCompactChange: fn(),

View file

@ -1,3 +1,4 @@
import { useState } from "react";
import { describe, it, expect, vi } from "vitest";
import userEvent from "@testing-library/user-event";
import type {
@ -6,7 +7,10 @@ import type {
} from "@modelcontextprotocol/sdk/types.js";
import type { InspectorResourceSubscription } from "@inspector/core/mcp/types.js";
import { renderWithMantine, screen } from "../../../test/renderWithMantine";
import { ResourceControls } from "./ResourceControls";
import {
ResourceControls,
type ResourceControlsProps,
} from "./ResourceControls";
const sampleResources: Resource[] = [
{ name: "config.json", uri: "file:///config.json" },
@ -30,6 +34,8 @@ const baseProps = {
subscriptions: sampleSubscriptions,
listChanged: false,
onRefreshList: vi.fn(),
onSearchChange: vi.fn(),
onOpenSectionsChange: vi.fn(),
onSelectUri: vi.fn(),
onSelectTemplate: vi.fn(),
onUnsubscribeResource: vi.fn(),
@ -37,6 +43,35 @@ const baseProps = {
onCompactChange: vi.fn(),
};
// ResourceControls is controlled: search text + accordion open-sections live in
// the parent (App, via ResourcesScreen) so they persist across tab navigation
// (#1417). This host holds that state so typing filters the lists and toggling
// the ListToggle drives the accordion, mirroring how App owns it. Props passed
// in override defaults; the stateful search/open-sections wiring is applied last
// so callers can still observe changes via the spied callbacks.
function ControlledResourceControls(props: Partial<ResourceControlsProps>) {
const [searchText, setSearchText] = useState<string>(props.searchText ?? "");
const [openSections, setOpenSections] = useState<string[]>(
props.openSections ?? ["resources", "templates", "subscriptions"],
);
return (
<ResourceControls
{...baseProps}
{...props}
searchText={searchText}
openSections={openSections}
onSearchChange={(value) => {
setSearchText(value);
props.onSearchChange?.(value);
}}
onOpenSectionsChange={(value) => {
setOpenSections(value);
props.onOpenSectionsChange?.(value);
}}
/>
);
}
describe("ResourceControls", () => {
it("renders title and section counts", () => {
renderWithMantine(<ResourceControls {...baseProps} />);
@ -68,7 +103,7 @@ describe("ResourceControls", () => {
it("filters resources, templates, and subscriptions by search text", async () => {
const user = userEvent.setup();
renderWithMantine(<ResourceControls {...baseProps} />);
renderWithMantine(<ControlledResourceControls />);
await user.type(screen.getByPlaceholderText("Search..."), "README");
expect(screen.getByText("URIs (1)")).toBeInTheDocument();
expect(screen.getByText("Templates (0)")).toBeInTheDocument();
@ -172,8 +207,7 @@ describe("ResourceControls", () => {
const user = userEvent.setup();
const onCompactChange = vi.fn();
renderWithMantine(
<ResourceControls
{...baseProps}
<ControlledResourceControls
compact={false}
onCompactChange={onCompactChange}
/>,
@ -195,7 +229,7 @@ describe("ResourceControls", () => {
{ name: "y", uri: "file:///y" },
];
renderWithMantine(
<ResourceControls {...baseProps} resources={resourcesWithTitle} />,
<ControlledResourceControls resources={resourcesWithTitle} />,
);
await user.type(screen.getByPlaceholderText("Search..."), "special");
expect(screen.getByText("URIs (1)")).toBeInTheDocument();

View file

@ -1,4 +1,3 @@
import { useState } from "react";
import {
Accordion,
Group,
@ -23,15 +22,24 @@ export interface ResourceControlsProps {
subscriptions: InspectorResourceSubscription[];
selectedUri?: string;
selectedTemplateUri?: string;
// Search text + accordion open-sections are controlled by the parent (App,
// via ResourcesScreen) so they persist across tab navigation within a live
// session — see #1417. `openSections` is optional: when undefined the
// accordion falls back to the `compact`-derived default below.
searchText?: string;
openSections?: string[];
listChanged: boolean;
onRefreshList: () => void;
onSearchChange: (value: string) => void;
onOpenSectionsChange: (value: string[]) => void;
onSelectUri: (uri: string) => void;
onSelectTemplate: (uriTemplate: string) => void;
onUnsubscribeResource: (uri: string) => void;
/**
* Persisted preference for the ListToggle. Seeds initial accordion state
* on mount; the user can still toggle individual sections during a session
* without affecting this value. Only an explicit ListToggle click updates it.
* (when `openSections` is undefined); the user can still toggle individual
* sections during a session without affecting this value. Only an explicit
* ListToggle click updates it.
*/
compact: boolean;
onCompactChange: (next: boolean) => void;
@ -54,15 +62,18 @@ export function ResourceControls({
subscriptions,
selectedUri,
selectedTemplateUri,
searchText = "",
openSections: controlledOpenSections,
listChanged,
onRefreshList,
onSearchChange,
onOpenSectionsChange,
onSelectUri,
onSelectTemplate,
onUnsubscribeResource,
compact: initialCompact,
onCompactChange,
}: ResourceControlsProps) {
const [searchText, setSearchText] = useState("");
const query = searchText.toLowerCase();
const filteredResources = resources.filter(
(r) =>
@ -84,13 +95,13 @@ export function ResourceControls({
);
const allSections = ["resources", "templates", "subscriptions"];
// Seed the accordion from the persisted preference: empty when the user
// last left the panel compact, all three open when they explicitly
// expanded. Per-section accordion clicks during a session update the
// local list but don't change the persisted preference.
const [openSections, setOpenSections] = useState<string[]>(
initialCompact ? [] : [...allSections],
);
// Open-sections is parent-controlled (persists across navigation). When the
// parent hasn't set it yet (undefined), fall back to the persisted `compact`
// preference: empty when last left compact, all three open when expanded.
// Per-section accordion clicks update the lifted value but don't change the
// persisted preference.
const openSections =
controlledOpenSections ?? (initialCompact ? [] : [...allSections]);
const allExpanded = openSections.length === allSections.length;
const maxHeight = panelMaxHeight(openSections.length);
@ -100,7 +111,7 @@ export function ResourceControls({
// right preference: clicking "expand all" should record `compact=false`
// even if the visible state was already partially expanded.
const nextCompact = allExpanded;
setOpenSections(nextCompact ? [] : [...allSections]);
onOpenSectionsChange(nextCompact ? [] : [...allSections]);
onCompactChange(nextCompact);
}
@ -115,11 +126,11 @@ export function ResourceControls({
flex={1}
placeholder="Search..."
value={searchText}
onChange={(e) => setSearchText(e.currentTarget.value)}
onChange={(e) => onSearchChange(e.currentTarget.value)}
/>
<ListToggle compact={!allExpanded} onToggle={handleToggleList} />
</Group>
<Accordion multiple value={openSections} onChange={setOpenSections}>
<Accordion multiple value={openSections} onChange={onOpenSectionsChange}>
<Accordion.Item value="resources">
<Accordion.Control disabled={filteredResources.length === 0}>
{formatSectionCount("URIs", filteredResources.length)}

View file

@ -12,6 +12,7 @@ import type { Task, TaskStatus } from "@modelcontextprotocol/sdk/types.js";
import { TaskCard } from "../TaskCard/TaskCard";
import type { TaskProgress } from "../TaskCard/TaskCard";
import { ListToggle } from "../../elements/ListToggle/ListToggle";
import { useScrollMemory } from "../../../hooks/useScrollMemory";
export interface TaskListPanelProps {
tasks: Task[];
@ -75,6 +76,7 @@ export function TaskListPanel({
onCancel,
onClearCompleted,
}: TaskListPanelProps) {
const viewportRef = useScrollMemory("tasks-list");
const [compact, setCompact] = useState(false);
const filteredTasks = useMemo(
@ -109,7 +111,10 @@ export function TaskListPanel({
{!hasResults ? (
<EmptyState>No tasks</EmptyState>
) : (
<ScrollArea.Autosize mah="calc(100vh - var(--app-shell-header-height, 0px) - 150px)">
<ScrollArea.Autosize
viewportRef={viewportRef}
mah="calc(100vh - var(--app-shell-header-height, 0px) - 150px)"
>
<Stack gap="md">
{activeTasks.length > 0 && (
<>

View file

@ -1,16 +1,34 @@
import { useState } from "react";
import type { ComponentProps } from "react";
import type { Meta, StoryObj } from "@storybook/react-vite";
import { fn, userEvent, within } from "storybook/test";
import { ToolControls } from "./ToolControls";
import { longToolList } from "../../screens/ToolsScreen/ToolsScreen.fixtures";
// ToolControls' search text is controlled by the parent (App, via ToolsScreen —
// see #1417). This wrapper holds that state so the play-driven typing updates
// the controlled input.
function StatefulToolControls(args: ComponentProps<typeof ToolControls>) {
const [searchText, setSearchText] = useState(args.searchText ?? "");
return (
<ToolControls
{...args}
searchText={searchText}
onSearchChange={setSearchText}
/>
);
}
const meta: Meta<typeof ToolControls> = {
title: "Groups/ToolControls",
component: ToolControls,
args: {
onRefreshList: fn(),
onSelectTool: fn(),
onSearchChange: fn(),
listChanged: false,
},
render: (args) => <StatefulToolControls {...args} />,
};
export default meta;

View file

@ -1,8 +1,9 @@
import { useState } from "react";
import { describe, it, expect, vi } from "vitest";
import userEvent from "@testing-library/user-event";
import type { Tool } from "@modelcontextprotocol/sdk/types.js";
import { renderWithMantine, screen } from "../../../test/renderWithMantine";
import { ToolControls } from "./ToolControls";
import { ToolControls, type ToolControlsProps } from "./ToolControls";
const sampleTools: Tool[] = [
{ name: "list_files", title: "List Files", inputSchema: { type: "object" } },
@ -20,8 +21,27 @@ const baseProps = {
listChanged: false,
onRefreshList: vi.fn(),
onSelectTool: vi.fn(),
onSearchChange: vi.fn(),
};
// The search box is controlled: typing fires onSearchChange but does not
// update the component's own state. This host holds the searchText state so
// interaction tests that filter by typing behave like the real parent.
function ControlledToolControls(props: Partial<ToolControlsProps>) {
const [searchText, setSearchText] = useState(props.searchText ?? "");
return (
<ToolControls
{...baseProps}
{...props}
searchText={searchText}
onSearchChange={(value) => {
setSearchText(value);
props.onSearchChange?.(value);
}}
/>
);
}
describe("ToolControls", () => {
it("renders the title and search input", () => {
renderWithMantine(<ToolControls {...baseProps} />);
@ -39,7 +59,7 @@ describe("ToolControls", () => {
it("filters tools by name when typing in the search input", async () => {
const user = userEvent.setup();
renderWithMantine(<ToolControls {...baseProps} />);
renderWithMantine(<ControlledToolControls />);
await user.type(screen.getByPlaceholderText("Search tools..."), "git");
expect(screen.getByText("git_status")).toBeInTheDocument();
expect(screen.getByText("git_commit")).toBeInTheDocument();
@ -48,7 +68,7 @@ describe("ToolControls", () => {
it("filters tools by title when typing in the search input", async () => {
const user = userEvent.setup();
renderWithMantine(<ToolControls {...baseProps} />);
renderWithMantine(<ControlledToolControls />);
await user.type(screen.getByPlaceholderText("Search tools..."), "query db");
expect(screen.getByText("Query DB")).toBeInTheDocument();
expect(screen.queryByText("List Files")).not.toBeInTheDocument();

View file

@ -1,14 +1,18 @@
import { useState } from "react";
import { Group, ScrollArea, Stack, TextInput, Title } from "@mantine/core";
import type { Tool } from "@modelcontextprotocol/sdk/types.js";
import { ListChangedIndicator } from "../../elements/ListChangedIndicator/ListChangedIndicator";
import { ToolListItem } from "../ToolListItem/ToolListItem";
import { useScrollMemory } from "../../../hooks/useScrollMemory";
export interface ToolControlsProps {
tools: Tool[];
selectedName?: string;
// Search text is controlled by the parent (App, via ToolsScreen) so it
// persists across tab navigation within a live session — see #1417.
searchText?: string;
listChanged: boolean;
onRefreshList: () => void;
onSearchChange: (value: string) => void;
onSelectTool: (name: string) => void;
}
@ -18,11 +22,13 @@ const LIST_MAX_HEIGHT =
export function ToolControls({
tools,
selectedName,
searchText = "",
listChanged,
onRefreshList,
onSearchChange,
onSelectTool,
}: ToolControlsProps) {
const [searchText, setSearchText] = useState("");
const viewportRef = useScrollMemory("tools-sidebar");
const query = searchText.toLowerCase();
const filteredTools = searchText
? tools.filter(
@ -41,9 +47,9 @@ export function ToolControls({
<TextInput
placeholder="Search tools..."
value={searchText}
onChange={(e) => setSearchText(e.currentTarget.value)}
onChange={(e) => onSearchChange(e.currentTarget.value)}
/>
<ScrollArea.Autosize mah={LIST_MAX_HEIGHT}>
<ScrollArea.Autosize viewportRef={viewportRef} mah={LIST_MAX_HEIGHT}>
<Stack gap="xs">
{filteredTools.map((tool) => (
<ToolListItem

View file

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

View file

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

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