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,3 +1,5 @@
|
|||
import { useState } from "react";
|
||||
import type { ComponentProps } from "react";
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import type {
|
||||
Resource,
|
||||
|
|
@ -6,13 +8,26 @@ import type {
|
|||
import type { InspectorResourceSubscription } from "../../../../../../core/mcp/types.js";
|
||||
import { fn, userEvent, within } from "storybook/test";
|
||||
import { ResourcesScreen } from "./ResourcesScreen";
|
||||
import type { ReadResourceState } from "./ResourcesScreen";
|
||||
import type { ReadResourceState, ResourcesUiState } from "./ResourcesScreen";
|
||||
import { EMPTY_RESOURCES_UI } from "../screenUiState";
|
||||
|
||||
// ResourcesScreen is controlled (resource/template selection, the originating
|
||||
// template, search text, and open accordion sections live in the parent as one
|
||||
// `ui` object — see #1417). This wrapper holds that state so the play-driven
|
||||
// clicks still drive the detail/preview panels, mirroring how App owns the state
|
||||
// in the real app.
|
||||
function StatefulResourcesScreen(args: ComponentProps<typeof ResourcesScreen>) {
|
||||
const [ui, setUi] = useState<ResourcesUiState>(args.ui ?? EMPTY_RESOURCES_UI);
|
||||
return <ResourcesScreen {...args} ui={ui} onUiChange={setUi} />;
|
||||
}
|
||||
|
||||
const meta: Meta<typeof ResourcesScreen> = {
|
||||
title: "Screens/ResourcesScreen",
|
||||
component: ResourcesScreen,
|
||||
parameters: { layout: "fullscreen" },
|
||||
args: {
|
||||
ui: EMPTY_RESOURCES_UI,
|
||||
onUiChange: fn(),
|
||||
onRefreshList: fn(),
|
||||
onReadResource: fn(),
|
||||
onSubscribeResource: fn(),
|
||||
|
|
@ -23,6 +38,7 @@ const meta: Meta<typeof ResourcesScreen> = {
|
|||
compact: false,
|
||||
onCompactChange: fn(),
|
||||
},
|
||||
render: (args) => <StatefulResourcesScreen {...args} />,
|
||||
};
|
||||
|
||||
export default meta;
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { useState } from "react";
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import type {
|
||||
|
|
@ -6,7 +7,12 @@ import type {
|
|||
ReadResourceResult,
|
||||
} from "@modelcontextprotocol/sdk/types.js";
|
||||
import { renderWithMantine, screen } from "../../../test/renderWithMantine";
|
||||
import { ResourcesScreen } from "./ResourcesScreen";
|
||||
import {
|
||||
ResourcesScreen,
|
||||
type ResourcesScreenProps,
|
||||
type ResourcesUiState,
|
||||
} from "./ResourcesScreen";
|
||||
import { EMPTY_RESOURCES_UI } from "../screenUiState";
|
||||
|
||||
const resources: Resource[] = [
|
||||
{ uri: "file:///x", name: "x.txt" },
|
||||
|
|
@ -22,6 +28,8 @@ const baseProps = {
|
|||
templates,
|
||||
subscriptions: [],
|
||||
listChanged: false,
|
||||
ui: EMPTY_RESOURCES_UI,
|
||||
onUiChange: vi.fn(),
|
||||
onRefreshList: vi.fn(),
|
||||
onReadResource: vi.fn(),
|
||||
onSubscribeResource: vi.fn(),
|
||||
|
|
@ -34,6 +42,32 @@ const okResult: ReadResourceResult = {
|
|||
contents: [{ uri: "file:///x", text: "embedded contents" }],
|
||||
};
|
||||
|
||||
// ResourcesScreen is controlled: the selected resource/template URIs, the
|
||||
// originating-template marker, the sidebar search, and the accordion's open
|
||||
// sections live in the parent (App) as one `ui` object so they persist across
|
||||
// tab navigation (#1417). This host holds that state so clicking a
|
||||
// resource/template, typing into the template form, reading, and closing drive
|
||||
// the panel exactly as App owns it. Props passed in override defaults; the
|
||||
// stateful `ui` wiring is applied last so callers can still observe activity via
|
||||
// the rendered state.
|
||||
function ControlledResourcesScreen(props: Partial<ResourcesScreenProps>) {
|
||||
const [ui, setUi] = useState<ResourcesUiState>({
|
||||
...EMPTY_RESOURCES_UI,
|
||||
...props.ui,
|
||||
});
|
||||
return (
|
||||
<ResourcesScreen
|
||||
{...baseProps}
|
||||
{...props}
|
||||
ui={ui}
|
||||
onUiChange={(next) => {
|
||||
setUi(next);
|
||||
props.onUiChange?.(next);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
describe("ResourcesScreen", () => {
|
||||
it("renders empty preview state when nothing is selected", () => {
|
||||
renderWithMantine(<ResourcesScreen {...baseProps} />);
|
||||
|
|
@ -45,8 +79,7 @@ describe("ResourcesScreen", () => {
|
|||
it("shows the read error alert when error and a resource is selected", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithMantine(
|
||||
<ResourcesScreen
|
||||
{...baseProps}
|
||||
<ControlledResourcesScreen
|
||||
readState={{ status: "error", error: "boom" }}
|
||||
/>,
|
||||
);
|
||||
|
|
@ -58,7 +91,7 @@ describe("ResourcesScreen", () => {
|
|||
it("falls back to default error when error message is missing", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithMantine(
|
||||
<ResourcesScreen {...baseProps} readState={{ status: "error" }} />,
|
||||
<ControlledResourcesScreen readState={{ status: "error" }} />,
|
||||
);
|
||||
await user.click(screen.getByText("x.txt"));
|
||||
expect(screen.getByText("Failed to read resource")).toBeInTheDocument();
|
||||
|
|
@ -67,7 +100,7 @@ describe("ResourcesScreen", () => {
|
|||
it("shows the loading state when reading", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithMantine(
|
||||
<ResourcesScreen {...baseProps} readState={{ status: "pending" }} />,
|
||||
<ControlledResourcesScreen readState={{ status: "pending" }} />,
|
||||
);
|
||||
await user.click(screen.getByText("x.txt"));
|
||||
expect(screen.getByText("Reading resource...")).toBeInTheDocument();
|
||||
|
|
@ -76,8 +109,7 @@ describe("ResourcesScreen", () => {
|
|||
it("renders the preview panel when readState has a result", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithMantine(
|
||||
<ResourcesScreen
|
||||
{...baseProps}
|
||||
<ControlledResourcesScreen
|
||||
readState={{
|
||||
status: "ok",
|
||||
uri: "file:///x",
|
||||
|
|
@ -108,7 +140,7 @@ describe("ResourcesScreen", () => {
|
|||
|
||||
it("renders the template panel when a template is selected", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithMantine(<ResourcesScreen {...baseProps} />);
|
||||
renderWithMantine(<ControlledResourcesScreen />);
|
||||
await user.click(screen.getByText("Templates (1)"));
|
||||
await user.click(screen.getByText("files"));
|
||||
expect(
|
||||
|
|
@ -120,7 +152,7 @@ describe("ResourcesScreen", () => {
|
|||
const user = userEvent.setup();
|
||||
const onReadResource = vi.fn();
|
||||
renderWithMantine(
|
||||
<ResourcesScreen {...baseProps} onReadResource={onReadResource} />,
|
||||
<ControlledResourcesScreen onReadResource={onReadResource} />,
|
||||
);
|
||||
await user.click(screen.getByText("Templates (1)"));
|
||||
await user.click(screen.getByText("files"));
|
||||
|
|
@ -137,7 +169,7 @@ describe("ResourcesScreen", () => {
|
|||
const user = userEvent.setup();
|
||||
const onReadResource = vi.fn();
|
||||
renderWithMantine(
|
||||
<ResourcesScreen {...baseProps} onReadResource={onReadResource} />,
|
||||
<ControlledResourcesScreen onReadResource={onReadResource} />,
|
||||
);
|
||||
await user.click(screen.getByText("x.txt"));
|
||||
expect(onReadResource).toHaveBeenCalledWith("file:///x");
|
||||
|
|
@ -148,8 +180,7 @@ describe("ResourcesScreen", () => {
|
|||
const onReadResource = vi.fn();
|
||||
const onSubscribeResource = vi.fn();
|
||||
renderWithMantine(
|
||||
<ResourcesScreen
|
||||
{...baseProps}
|
||||
<ControlledResourcesScreen
|
||||
onReadResource={onReadResource}
|
||||
onSubscribeResource={onSubscribeResource}
|
||||
readState={{
|
||||
|
|
@ -174,8 +205,7 @@ describe("ResourcesScreen", () => {
|
|||
{ uriTemplate: "file:///{path}", name: "files" },
|
||||
];
|
||||
const { rerender } = renderWithMantine(
|
||||
<ResourcesScreen
|
||||
{...baseProps}
|
||||
<ControlledResourcesScreen
|
||||
templates={templates}
|
||||
onReadResource={onReadResource}
|
||||
/>,
|
||||
|
|
@ -191,8 +221,7 @@ describe("ResourcesScreen", () => {
|
|||
|
||||
// Parent re-renders with the read result; the preview appears.
|
||||
rerender(
|
||||
<ResourcesScreen
|
||||
{...baseProps}
|
||||
<ControlledResourcesScreen
|
||||
templates={templates}
|
||||
onReadResource={onReadResource}
|
||||
readState={{
|
||||
|
|
@ -218,7 +247,7 @@ describe("ResourcesScreen", () => {
|
|||
{ uriTemplate: "demo://resource/dynamic/text/{id}", name: "Dynamic" },
|
||||
];
|
||||
const { rerender } = renderWithMantine(
|
||||
<ResourcesScreen {...baseProps} templates={templates} />,
|
||||
<ControlledResourcesScreen templates={templates} />,
|
||||
);
|
||||
await user.click(screen.getByText("Templates (1)"));
|
||||
await user.click(screen.getByText("Dynamic"));
|
||||
|
|
@ -227,8 +256,7 @@ describe("ResourcesScreen", () => {
|
|||
|
||||
// Server rejects the URI.
|
||||
rerender(
|
||||
<ResourcesScreen
|
||||
{...baseProps}
|
||||
<ControlledResourcesScreen
|
||||
templates={templates}
|
||||
readState={{
|
||||
status: "error",
|
||||
|
|
@ -248,8 +276,7 @@ describe("ResourcesScreen", () => {
|
|||
it("closing the preview for a plain resource returns to the empty state", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithMantine(
|
||||
<ResourcesScreen
|
||||
{...baseProps}
|
||||
<ControlledResourcesScreen
|
||||
readState={{
|
||||
status: "ok",
|
||||
uri: "file:///x",
|
||||
|
|
@ -268,8 +295,7 @@ describe("ResourcesScreen", () => {
|
|||
const user = userEvent.setup();
|
||||
const onUnsubscribeResource = vi.fn();
|
||||
renderWithMantine(
|
||||
<ResourcesScreen
|
||||
{...baseProps}
|
||||
<ControlledResourcesScreen
|
||||
onUnsubscribeResource={onUnsubscribeResource}
|
||||
readState={{
|
||||
status: "ok",
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
import { useState } from "react";
|
||||
import {
|
||||
Alert,
|
||||
Card,
|
||||
|
|
@ -33,8 +32,10 @@ export interface ResourcesScreenProps {
|
|||
templates: ResourceTemplate[];
|
||||
subscriptions: InspectorResourceSubscription[];
|
||||
readState?: ReadResourceState;
|
||||
ui: ResourcesUiState;
|
||||
listChanged: boolean;
|
||||
completionsSupported?: boolean;
|
||||
onUiChange: (next: ResourcesUiState) => void;
|
||||
onRefreshList: () => void;
|
||||
onReadResource: (uri: string) => void;
|
||||
onSubscribeResource: (uri: string) => void;
|
||||
|
|
@ -51,6 +52,19 @@ export interface ResourcesScreenProps {
|
|||
onCompactChange: (next: boolean) => void;
|
||||
}
|
||||
|
||||
// Selection (resource URI, template URI, the originating-template marker), the
|
||||
// sidebar search, and accordion open-sections — controlled by the parent (App)
|
||||
// as one object so they persist across tab navigation within a live session
|
||||
// (#1417). `openSections` undefined → ResourceControls falls back to the
|
||||
// compact-derived default.
|
||||
export interface ResourcesUiState {
|
||||
selectedResourceUri?: string;
|
||||
selectedTemplateUri?: string;
|
||||
originatingTemplateUri?: string;
|
||||
search: string;
|
||||
openSections?: string[];
|
||||
}
|
||||
|
||||
const ScreenLayout = Flex.withProps({
|
||||
variant: "screen",
|
||||
h: "calc(100vh - var(--app-shell-header-height, 0px))",
|
||||
|
|
@ -107,8 +121,10 @@ export function ResourcesScreen({
|
|||
templates,
|
||||
subscriptions,
|
||||
readState,
|
||||
ui,
|
||||
listChanged,
|
||||
completionsSupported,
|
||||
onUiChange,
|
||||
onRefreshList,
|
||||
onReadResource,
|
||||
onSubscribeResource,
|
||||
|
|
@ -117,20 +133,13 @@ export function ResourcesScreen({
|
|||
compact,
|
||||
onCompactChange,
|
||||
}: ResourcesScreenProps) {
|
||||
const [selectedResourceUri, setSelectedResourceUri] = useState<
|
||||
string | undefined
|
||||
>(undefined);
|
||||
const [selectedTemplateUri, setSelectedTemplateUri] = useState<
|
||||
string | undefined
|
||||
>(undefined);
|
||||
// Tracks which template (if any) produced the current preview so that
|
||||
// closing the preview can restore the template form. Cleared when the
|
||||
// user navigates to a non-template resource or picks a different
|
||||
// template directly from the sidebar.
|
||||
const [originatingTemplateUri, setOriginatingTemplateUri] = useState<
|
||||
string | undefined
|
||||
>(undefined);
|
||||
|
||||
const {
|
||||
selectedResourceUri,
|
||||
selectedTemplateUri,
|
||||
originatingTemplateUri,
|
||||
search,
|
||||
openSections,
|
||||
} = ui;
|
||||
const selectedResource = selectedResourceUri
|
||||
? resources.find((r) => r.uri === selectedResourceUri)
|
||||
: undefined;
|
||||
|
|
@ -147,16 +156,22 @@ export function ResourcesScreen({
|
|||
: undefined);
|
||||
|
||||
function handleSelectResource(uri: string) {
|
||||
setSelectedTemplateUri(undefined);
|
||||
setSelectedResourceUri(uri);
|
||||
setOriginatingTemplateUri(undefined);
|
||||
onUiChange({
|
||||
...ui,
|
||||
selectedTemplateUri: undefined,
|
||||
selectedResourceUri: uri,
|
||||
originatingTemplateUri: undefined,
|
||||
});
|
||||
onReadResource(uri);
|
||||
}
|
||||
|
||||
function handleSelectTemplate(uriTemplate: string) {
|
||||
setSelectedResourceUri(undefined);
|
||||
setSelectedTemplateUri(uriTemplate);
|
||||
setOriginatingTemplateUri(undefined);
|
||||
onUiChange({
|
||||
...ui,
|
||||
selectedResourceUri: undefined,
|
||||
selectedTemplateUri: uriTemplate,
|
||||
originatingTemplateUri: undefined,
|
||||
});
|
||||
}
|
||||
|
||||
function handleReadResource(uri: string) {
|
||||
|
|
@ -165,19 +180,25 @@ export function ResourcesScreen({
|
|||
// clearing the template selection hides the template form so only
|
||||
// the rendered resource is shown. We remember the template URI so
|
||||
// closing the preview can restore the form.
|
||||
if (selectedTemplateUri) {
|
||||
setOriginatingTemplateUri(selectedTemplateUri);
|
||||
}
|
||||
setSelectedTemplateUri(undefined);
|
||||
setSelectedResourceUri(uri);
|
||||
onUiChange({
|
||||
...ui,
|
||||
originatingTemplateUri: selectedTemplateUri ?? originatingTemplateUri,
|
||||
selectedTemplateUri: undefined,
|
||||
selectedResourceUri: uri,
|
||||
});
|
||||
onReadResource(uri);
|
||||
}
|
||||
|
||||
function handleClosePreview() {
|
||||
setSelectedResourceUri(undefined);
|
||||
if (originatingTemplateUri) {
|
||||
setSelectedTemplateUri(originatingTemplateUri);
|
||||
setOriginatingTemplateUri(undefined);
|
||||
onUiChange({
|
||||
...ui,
|
||||
selectedResourceUri: undefined,
|
||||
selectedTemplateUri: originatingTemplateUri,
|
||||
originatingTemplateUri: undefined,
|
||||
});
|
||||
} else {
|
||||
onUiChange({ ...ui, selectedResourceUri: undefined });
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -251,8 +272,14 @@ export function ResourcesScreen({
|
|||
subscriptions={subscriptions}
|
||||
selectedUri={selectedResourceUri}
|
||||
selectedTemplateUri={selectedTemplateUri}
|
||||
searchText={search}
|
||||
openSections={openSections}
|
||||
listChanged={listChanged}
|
||||
onRefreshList={onRefreshList}
|
||||
onSearchChange={(value) => onUiChange({ ...ui, search: value })}
|
||||
onOpenSectionsChange={(value) =>
|
||||
onUiChange({ ...ui, openSections: value })
|
||||
}
|
||||
onSelectUri={handleSelectResource}
|
||||
onSelectTemplate={handleSelectTemplate}
|
||||
onUnsubscribeResource={onUnsubscribeResource}
|
||||
|
|
|
|||
Loading…
Reference in a new issue