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
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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)}
|
||||
|
|
|
|||
Loading…
Reference in a new issue