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:
parent
8ebd55723f
commit
8923a9bbf9
3 changed files with 1741 additions and 316 deletions
|
|
@ -1,26 +1,42 @@
|
|||
import { useState } from "react";
|
||||
import type { ComponentProps } from "react";
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import type { ElicitRequest, Tool } from "@modelcontextprotocol/sdk/types.js";
|
||||
import { Modal } from "@mantine/core";
|
||||
import { fn, userEvent, within } from "storybook/test";
|
||||
import { ToolsScreen } from "./ToolsScreen";
|
||||
import type { ToolCallState } from "./ToolsScreen";
|
||||
import type { ToolCallState, ToolsUiState } from "./ToolsScreen";
|
||||
import { EMPTY_TOOLS_UI } from "../screenUiState";
|
||||
import { SamplingRequestPanel } from "../../groups/SamplingRequestPanel/SamplingRequestPanel";
|
||||
import { ElicitationFormPanel } from "../../groups/ElicitationFormPanel/ElicitationFormPanel";
|
||||
import { PendingClientRequests } from "../../groups/PendingClientRequests/PendingClientRequests";
|
||||
import { InlineSamplingRequest } from "../../groups/InlineSamplingRequest/InlineSamplingRequest";
|
||||
import { InlineElicitationRequest } from "../../groups/InlineElicitationRequest/InlineElicitationRequest";
|
||||
|
||||
// ToolsScreen is controlled (selection + form values live in the parent as one
|
||||
// `ui` object — see #1414). This wrapper holds that state so the play-driven
|
||||
// tool clicks still drive the detail panel, mirroring how App owns the state in
|
||||
// the real app.
|
||||
function StatefulToolsScreen(args: ComponentProps<typeof ToolsScreen>) {
|
||||
const [ui, setUi] = useState<ToolsUiState>(args.ui ?? EMPTY_TOOLS_UI);
|
||||
return <ToolsScreen {...args} ui={ui} onUiChange={setUi} />;
|
||||
}
|
||||
|
||||
const meta: Meta<typeof ToolsScreen> = {
|
||||
title: "Screens/ToolsScreen",
|
||||
component: ToolsScreen,
|
||||
parameters: { layout: "fullscreen" },
|
||||
args: {
|
||||
listChanged: false,
|
||||
ui: EMPTY_TOOLS_UI,
|
||||
onUiChange: fn(),
|
||||
onRefreshList: fn(),
|
||||
onCallTool: fn(),
|
||||
onCancelCall: fn(),
|
||||
onClearResult: fn(),
|
||||
},
|
||||
// Wrap in a stateful host so selection/form (now parent-owned) update on click.
|
||||
render: (args) => <StatefulToolsScreen {...args} />,
|
||||
};
|
||||
|
||||
export default meta;
|
||||
|
|
@ -197,7 +213,7 @@ export const WithSamplingModal: Story = {
|
|||
},
|
||||
render: (args) => (
|
||||
<>
|
||||
<ToolsScreen {...args} />
|
||||
<StatefulToolsScreen {...args} />
|
||||
<Modal opened={true} onClose={fn()} title="Sampling Request" size="lg">
|
||||
<SamplingRequestPanel
|
||||
request={{
|
||||
|
|
@ -240,7 +256,7 @@ export const WithElicitationModal: Story = {
|
|||
},
|
||||
render: (args) => (
|
||||
<>
|
||||
<ToolsScreen {...args} />
|
||||
<StatefulToolsScreen {...args} />
|
||||
<Modal opened={true} onClose={fn()} title="Elicitation Request" size="lg">
|
||||
<ElicitationFormPanel
|
||||
request={elicitFormRequest}
|
||||
|
|
@ -266,7 +282,7 @@ export const WithPendingRequests: Story = {
|
|||
},
|
||||
render: (args) => (
|
||||
<>
|
||||
<ToolsScreen {...args} />
|
||||
<StatefulToolsScreen {...args} />
|
||||
<Modal
|
||||
opened={true}
|
||||
onClose={fn()}
|
||||
|
|
|
|||
|
|
@ -1,8 +1,14 @@
|
|||
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 { ToolsScreen } from "./ToolsScreen";
|
||||
import {
|
||||
ToolsScreen,
|
||||
type ToolsScreenProps,
|
||||
type ToolsUiState,
|
||||
} from "./ToolsScreen";
|
||||
import { EMPTY_TOOLS_UI } from "../screenUiState";
|
||||
|
||||
const tools: Tool[] = [
|
||||
{ name: "alpha", inputSchema: { type: "object" } },
|
||||
|
|
@ -19,10 +25,35 @@ const tools: Tool[] = [
|
|||
const baseProps = {
|
||||
tools,
|
||||
listChanged: false,
|
||||
ui: EMPTY_TOOLS_UI,
|
||||
onUiChange: vi.fn(),
|
||||
onRefreshList: vi.fn(),
|
||||
onCallTool: vi.fn(),
|
||||
};
|
||||
|
||||
// ToolsScreen is controlled: selection + form values live in the parent (App)
|
||||
// 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<ToolsScreenProps>) {
|
||||
const [ui, setUi] = useState<ToolsUiState>({
|
||||
...EMPTY_TOOLS_UI,
|
||||
...props.ui,
|
||||
});
|
||||
return (
|
||||
<ToolsScreen
|
||||
{...baseProps}
|
||||
{...props}
|
||||
ui={ui}
|
||||
onUiChange={(next) => {
|
||||
setUi(next);
|
||||
props.onUiChange?.(next);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
describe("ToolsScreen", () => {
|
||||
it("renders the empty selection state", () => {
|
||||
renderWithMantine(<ToolsScreen {...baseProps} />);
|
||||
|
|
@ -34,13 +65,55 @@ describe("ToolsScreen", () => {
|
|||
|
||||
it("shows the detail panel when a tool is selected", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithMantine(<ToolsScreen {...baseProps} />);
|
||||
renderWithMantine(<ControlledToolsScreen />);
|
||||
await user.click(screen.getByText("alpha"));
|
||||
expect(
|
||||
screen.queryByText("Select a tool to view details"),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("filters the sidebar list as the search text changes", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithMantine(<ControlledToolsScreen />);
|
||||
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(<ControlledToolsScreen onCallTool={onCallTool} />);
|
||||
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
|
||||
// local re-selection.
|
||||
renderWithMantine(
|
||||
<ToolsScreen
|
||||
{...baseProps}
|
||||
ui={{ ...EMPTY_TOOLS_UI, selectedToolName: "alpha" }}
|
||||
callState={{
|
||||
status: "ok",
|
||||
result: { content: [{ type: "text", text: "ok" }] },
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
expect(
|
||||
screen.queryByText("Select a tool to view details"),
|
||||
).not.toBeInTheDocument();
|
||||
expect(screen.getByText("Results")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders the result panel when callState has a result", () => {
|
||||
renderWithMantine(
|
||||
<ToolsScreen
|
||||
|
|
@ -57,7 +130,7 @@ describe("ToolsScreen", () => {
|
|||
it("invokes onCallTool with form values on Execute", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onCallTool = vi.fn();
|
||||
renderWithMantine(<ToolsScreen {...baseProps} onCallTool={onCallTool} />);
|
||||
renderWithMantine(<ControlledToolsScreen onCallTool={onCallTool} />);
|
||||
await user.click(screen.getByText("alpha"));
|
||||
await user.click(screen.getByRole("button", { name: /Execute/ }));
|
||||
expect(onCallTool).toHaveBeenCalledWith("alpha", {});
|
||||
|
|
@ -66,7 +139,7 @@ describe("ToolsScreen", () => {
|
|||
it("seeds schema defaults so untouched fields are sent on Execute", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onCallTool = vi.fn();
|
||||
renderWithMantine(<ToolsScreen {...baseProps} onCallTool={onCallTool} />);
|
||||
renderWithMantine(<ControlledToolsScreen onCallTool={onCallTool} />);
|
||||
await user.click(screen.getByText("gamma"));
|
||||
// Execute without editing the form: the default must still be sent.
|
||||
await user.click(screen.getByRole("button", { name: /Execute/ }));
|
||||
|
|
@ -90,7 +163,9 @@ describe("ToolsScreen", () => {
|
|||
expect(onClearResult).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("clears the persisted result when the screen unmounts", () => {
|
||||
it("does not clear the result when the screen unmounts", () => {
|
||||
// The result is owned by App and must survive a tab switch (which unmounts
|
||||
// the screen), so unmounting must NOT call onClearResult — see #1414.
|
||||
const onClearResult = vi.fn();
|
||||
const { unmount } = renderWithMantine(
|
||||
<ToolsScreen
|
||||
|
|
@ -102,17 +177,18 @@ describe("ToolsScreen", () => {
|
|||
}}
|
||||
/>,
|
||||
);
|
||||
expect(onClearResult).not.toHaveBeenCalled();
|
||||
unmount();
|
||||
expect(onClearResult).toHaveBeenCalledTimes(1);
|
||||
expect(onClearResult).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("treats pending callState as executing", async () => {
|
||||
const user = userEvent.setup();
|
||||
it("treats pending callState as executing", () => {
|
||||
renderWithMantine(
|
||||
<ToolsScreen {...baseProps} callState={{ status: "pending" }} />,
|
||||
<ToolsScreen
|
||||
{...baseProps}
|
||||
ui={{ ...EMPTY_TOOLS_UI, selectedToolName: "alpha" }}
|
||||
callState={{ status: "pending" }}
|
||||
/>,
|
||||
);
|
||||
await user.click(screen.getByText("alpha"));
|
||||
expect(screen.getByRole("button", { name: /Cancel/ })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
|
|
@ -122,11 +198,11 @@ describe("ToolsScreen", () => {
|
|||
renderWithMantine(
|
||||
<ToolsScreen
|
||||
{...baseProps}
|
||||
ui={{ ...EMPTY_TOOLS_UI, selectedToolName: "alpha" }}
|
||||
onCancelCall={onCancelCall}
|
||||
callState={{ status: "pending" }}
|
||||
/>,
|
||||
);
|
||||
await user.click(screen.getByText("alpha"));
|
||||
await user.click(screen.getByRole("button", { name: /Cancel/ }));
|
||||
expect(onCancelCall).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
import { useEffect, useRef, useState } from "react";
|
||||
import { Card, Flex, Stack, Text } from "@mantine/core";
|
||||
import type { CallToolResult, Tool } from "@modelcontextprotocol/sdk/types.js";
|
||||
import { ToolControls } from "../../groups/ToolControls/ToolControls";
|
||||
|
|
@ -16,10 +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<string, unknown>;
|
||||
search: string;
|
||||
}
|
||||
|
||||
export interface ToolsScreenProps {
|
||||
tools: Tool[];
|
||||
callState?: ToolCallState;
|
||||
ui: ToolsUiState;
|
||||
listChanged: boolean;
|
||||
onUiChange: (next: ToolsUiState) => void;
|
||||
onRefreshList: () => void;
|
||||
onCallTool: (name: string, args: Record<string, unknown>) => void;
|
||||
onCancelCall?: () => void;
|
||||
|
|
@ -80,37 +90,20 @@ const EmptyState = Text.withProps({
|
|||
export function ToolsScreen({
|
||||
tools,
|
||||
callState,
|
||||
ui,
|
||||
listChanged,
|
||||
onUiChange,
|
||||
onRefreshList,
|
||||
onCallTool,
|
||||
onCancelCall,
|
||||
onClearResult,
|
||||
}: ToolsScreenProps) {
|
||||
const [selectedToolName, setSelectedToolName] = useState<string | undefined>(
|
||||
undefined,
|
||||
);
|
||||
const [formValues, setFormValues] = useState<Record<string, unknown>>({});
|
||||
const { selectedToolName, formValues, search } = ui;
|
||||
const selectedTool = selectedToolName
|
||||
? tools.find((t) => t.name === selectedToolName)
|
||||
: undefined;
|
||||
const isExecuting = callState?.status === "pending";
|
||||
|
||||
// The result lives in App so it survives pending → ok/error without
|
||||
// remounting the screen. But the screen's selection is local and resets when
|
||||
// the screen unmounts on tab switch — leaving the Results panel showing a
|
||||
// result with no selected tool. Clear the result on unmount so returning to
|
||||
// the screen starts from a clean slate. A ref keeps the latest handler so the
|
||||
// effect can stay mount/unmount-only without re-running mid-session.
|
||||
const onClearResultRef = useRef(onClearResult);
|
||||
useEffect(() => {
|
||||
onClearResultRef.current = onClearResult;
|
||||
}, [onClearResult]);
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
onClearResultRef.current?.();
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<ScreenLayout>
|
||||
<Sidebar>
|
||||
|
|
@ -118,18 +111,21 @@ export function ToolsScreen({
|
|||
<ToolControls
|
||||
tools={tools}
|
||||
selectedName={selectedToolName}
|
||||
searchText={search}
|
||||
listChanged={listChanged}
|
||||
onRefreshList={onRefreshList}
|
||||
onSearchChange={(value) => 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);
|
||||
setFormValues(
|
||||
tool ? collectSchemaDefaults(tool.inputSchema) : {},
|
||||
);
|
||||
setSelectedToolName(name);
|
||||
onUiChange({
|
||||
...ui,
|
||||
selectedToolName: name,
|
||||
formValues: tool ? collectSchemaDefaults(tool.inputSchema) : {},
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</SidebarCard>
|
||||
|
|
@ -143,7 +139,9 @@ export function ToolsScreen({
|
|||
formValues={formValues}
|
||||
isExecuting={isExecuting}
|
||||
progress={callState?.progress}
|
||||
onFormChange={setFormValues}
|
||||
onFormChange={(values) =>
|
||||
onUiChange({ ...ui, formValues: values })
|
||||
}
|
||||
onExecute={() => onCallTool(selectedTool.name, formValues)}
|
||||
onCancel={() => onCancelCall?.()}
|
||||
/>
|
||||
|
|
|
|||
Loading…
Reference in a new issue