chore(web): extract App.tsx's leaf hooks (#2128)

Phase 1 of #2126. Six hooks move out of `clients/web/src/App.tsx`, taking
its component body from 4,891 to 4,267 lines with no behavior change:

- useThemeToggle       — the light/dark toggle
- useTabUiState        — the nine per-screen `ui` objects, the active tab,
                         the pinned-Protocol ids, and the session reset
- useInspectorStores   — the twelve state managers as one create/destroy
                         lifecycle, their `core/react` hooks, and the
                         pagination-mode selection
- useExportActions     — the eleven Clear / Export / Replay handlers
- useProgressToasts    — `notifications/progress` toasts
- useTaskToasts        — task-status toasts, the cancel confirmation, the
                         taskId -> progress map, and the in-flight
                         tool-call taskId ref

Holding the stores in one slot collapses `setupClientForServer`'s and
`onConfirmRemove`'s dependency arrays from twelve entries to two stable
callbacks; `destroyStores` reads the live set through a ref mirror rather
than the state it also writes.

All six hooks are at 100% on all four coverage dimensions, no `v8 ignore`
needed. Also refreshes the now-stale "~4.5k-line" description of App.tsx
in AGENTS.md and the coverage `include` comment.

Signed-off-by: cliffhall <cliff@futurescale.com>
This commit is contained in:
cliffhall 2026-08-26 19:42:16 -04:00
commit e42968ba78
15 changed files with 2382 additions and 773 deletions

View file

@ -1058,7 +1058,7 @@ The top-level **`src/types/`** is a sibling of both and is not the place for new
Cross-directory imports point **one way, `lib → utils`** (infra depends on pure helpers, never the reverse). Keep it that way: if a `utils/` module needs a type currently exported from a `lib/` module, declare the type in `utils/` and re-export it from `lib/` (as `pendingReauth` owns `OAuthResumeAuthKind` and `oauthResume` re-exports it), rather than importing "up" from `utils` into `lib`.
Nothing **enforces** the boundary: no path alias keys off it, and the coverage `include` in `clients/web/vite.config.ts` lists **both** `src/lib/**` and `src/utils/**`, so a move between them is coverage-neutral (this is why the refactor was gate-safe). It's a human-legible signal at import time, valuable in a codebase this test-heavy (the ≥90% per-file gate). Note that `include` is a **whitelist** — it names `components`/`hooks`/`theme`/`lib`/`utils`/`server` (plus the `core/*` runtime; `hooks` and `theme` were added in #1787), so a module placed **outside** those directories (`types/`, `App.tsx`, or a brand-new grab-bag) falls out of the ≥90 gate entirely, silently. The **deliberate, documented** top-level-file exceptions are `src/App.tsx` — a ~4.5k-line composition root at ~42% branch coverage (gating it is a dedicated testing/decomposition effort, not a whitelist tweak) — and the `src/main.tsx` / `src/index.ts` bootstraps (browser `createRoot` render and the bin `runWeb` re-export, the analog of `clients/cli`'s excluded `src/index.ts`). All three are called out in a comment on the `include` array itself rather than left silent. When adding a module, place it by the rule and keep it inside a gated directory; when it genuinely mixes both (e.g. `downloadFile` bundles DOM-side-effect helpers with a couple of pure ones), keep it whole on its dominant side (`lib`) rather than splitting hairs.
Nothing **enforces** the boundary: no path alias keys off it, and the coverage `include` in `clients/web/vite.config.ts` lists **both** `src/lib/**` and `src/utils/**`, so a move between them is coverage-neutral (this is why the refactor was gate-safe). It's a human-legible signal at import time, valuable in a codebase this test-heavy (the ≥90% per-file gate). Note that `include` is a **whitelist** — it names `components`/`hooks`/`theme`/`lib`/`utils`/`server` (plus the `core/*` runtime; `hooks` and `theme` were added in #1787), so a module placed **outside** those directories (`types/`, `App.tsx`, or a brand-new grab-bag) falls out of the ≥90 gate entirely, silently. The **deliberate, documented** top-level-file exceptions are `src/App.tsx` — a ~4.3k-line composition root at ~42% branch coverage (gating it is a dedicated testing/decomposition effort, not a whitelist tweak) — and the `src/main.tsx` / `src/index.ts` bootstraps (browser `createRoot` render and the bin `runWeb` re-export, the analog of `clients/cli`'s excluded `src/index.ts`). All three are called out in a comment on the `include` array itself rather than left silent. When adding a module, place it by the rule and keep it inside a gated directory; when it genuinely mixes both (e.g. `downloadFile` bundles DOM-side-effect helpers with a couple of pure ones), keep it whole on its dominant side (`lib`) rather than splitting hairs.
## React instructions

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,317 @@
import { describe, it, expect, beforeEach, vi } from "vitest";
import { act } from "react";
import type { Tool } from "@modelcontextprotocol/client";
import type { MessageEntry } from "@inspector/core/mcp/types.js";
import type { InspectorClient } from "@inspector/core/mcp/index.js";
import { renderWithMantine } from "../test/renderWithMantine";
import {
useExportActions,
type ExportActions,
type UseExportActionsParams,
} from "./useExportActions";
const { notificationsMock } = vi.hoisted(() => ({
notificationsMock: { show: vi.fn(), update: vi.fn(), hide: vi.fn() },
}));
vi.mock("@mantine/notifications", () => ({
notifications: notificationsMock,
}));
// The download is a DOM side effect; the assertions here are about *what* was
// serialized and under which filename, so the writer itself is spied on.
const { downloadJsonFile } = vi.hoisted(() => ({
downloadJsonFile: vi.fn(),
}));
vi.mock("../lib/downloadFile", async (importOriginal) => ({
...(await importOriginal<typeof import("../lib/downloadFile")>()),
downloadJsonFile,
}));
const { replayProtocolRequest } = vi.hoisted(() => ({
replayProtocolRequest: vi.fn(),
}));
vi.mock("../lib/protocolReplay", () => ({ replayProtocolRequest }));
const request = (id: string, method: string): MessageEntry =>
({
id,
direction: "request",
timestamp: 0,
message: { jsonrpc: "2.0", id: 1, method, params: { a: 1 } },
}) as unknown as MessageEntry;
const logNotification = (id: string): MessageEntry =>
({
id,
direction: "notification",
timestamp: 0,
message: { jsonrpc: "2.0", method: "notifications/message" },
}) as unknown as MessageEntry;
function harness(overrides: Partial<UseExportActionsParams> = {}) {
const messageLogState = { clearMessages: vi.fn() };
const fetchRequestLogState = { clearFetchRequests: vi.fn() };
const stderrLogState = { clearStderrLogs: vi.fn() };
const setPinnedProtocolIds = vi.fn();
const params: UseExportActionsParams = {
activeServerId: "srv",
messageLogState:
messageLogState as unknown as UseExportActionsParams["messageLogState"],
fetchRequestLogState:
fetchRequestLogState as unknown as UseExportActionsParams["fetchRequestLogState"],
stderrLogState:
stderrLogState as unknown as UseExportActionsParams["stderrLogState"],
messages: [],
fetchRequests: [],
logs: [],
stderrLogs: [],
pinnedProtocolIds: new Set<string>(),
setPinnedProtocolIds,
inspectorClient: {} as InspectorClient,
tools: [] as Tool[],
...overrides,
};
let latest: ExportActions | undefined;
function Probe() {
latest = useExportActions(params);
return null;
}
renderWithMantine(<Probe />);
return {
messageLogState,
fetchRequestLogState,
stderrLogState,
setPinnedProtocolIds,
run: (fn: (api: ExportActions) => void) =>
act(() => {
if (!latest) throw new Error("hook did not render");
fn(latest);
}),
/** The JSON handed to the writer, parsed. */
exported: () => JSON.parse(downloadJsonFile.mock.calls[0][1] as string),
filename: () => downloadJsonFile.mock.calls[0][0] as string,
};
}
describe("useExportActions", () => {
beforeEach(() => {
downloadJsonFile.mockClear();
replayProtocolRequest.mockReset();
notificationsMock.show.mockClear();
});
describe("clear", () => {
it("clears only the log notifications from the message log", () => {
const h = harness();
h.run((api) => api.onClearLogs());
const predicate = h.messageLogState.clearMessages.mock.calls[0][0] as (
m: MessageEntry,
) => boolean;
expect(predicate(logNotification("n1"))).toBe(true);
expect(predicate(request("r1", "tools/call"))).toBe(false);
});
it("keeps pinned entries when clearing the protocol panel", () => {
const h = harness({ pinnedProtocolIds: new Set(["keep"]) });
h.run((api) => api.onClearProtocol());
const predicate = h.messageLogState.clearMessages.mock.calls[0][0] as (
m: MessageEntry,
) => boolean;
expect(predicate(request("keep", "ping"))).toBe(false);
expect(predicate(request("other", "ping"))).toBe(true);
});
it("clears one protocol section by pin membership", () => {
const h = harness({ pinnedProtocolIds: new Set(["keep"]) });
h.run((api) => api.onClearProtocolSection("history"));
let predicate = h.messageLogState.clearMessages.mock.calls[0][0] as (
m: MessageEntry,
) => boolean;
expect(predicate(request("keep", "ping"))).toBe(false);
expect(h.setPinnedProtocolIds).not.toHaveBeenCalled();
h.run((api) => api.onClearProtocolSection("pinned"));
predicate = h.messageLogState.clearMessages.mock.calls[1][0] as (
m: MessageEntry,
) => boolean;
expect(predicate(request("keep", "ping"))).toBe(true);
// Clearing the pinned section drops the now-stale id set.
expect(h.setPinnedProtocolIds).toHaveBeenCalledWith(new Set());
});
it("clears the network and console stores", () => {
const h = harness();
h.run((api) => {
api.onClearNetwork();
api.onClearConsole();
});
expect(h.fetchRequestLogState.clearFetchRequests).toHaveBeenCalled();
expect(h.stderrLogState.clearStderrLogs).toHaveBeenCalled();
});
it("no-ops when the stores are absent", () => {
const h = harness({
messageLogState: null,
fetchRequestLogState: null,
stderrLogState: null,
});
h.run((api) => {
api.onClearLogs();
api.onClearProtocol();
api.onClearNetwork();
api.onClearConsole();
api.onClearProtocolSection("pinned");
});
expect(h.messageLogState.clearMessages).not.toHaveBeenCalled();
});
});
describe("export", () => {
it("stamps the server id and the kind into the filename", () => {
const h = harness({ logs: [{ level: "info" }] as never });
h.run((api) => api.onExportLogs());
expect(h.filename()).toMatch(/^inspector-logs-srv-.*\.json$/);
});
it("writes each view's entries", () => {
const cases: [keyof ExportActions, Partial<UseExportActionsParams>][] = [
["onExportProtocol", { messages: [request("r1", "ping")] }],
["onExportNetwork", { fetchRequests: [{ id: "f1" }] as never }],
["onExportLogs", { logs: [{ id: "l1" }] as never }],
["onExportConsole", { stderrLogs: [{ id: "s1" }] as never }],
];
for (const [action, params] of cases) {
downloadJsonFile.mockClear();
const h = harness(params);
h.run((api) => (api[action] as () => void)());
expect(h.exported()).toHaveLength(1);
}
});
it("writes nothing when the view is empty", () => {
const h = harness();
h.run((api) => {
api.onExportLogs();
api.onExportProtocol();
api.onExportNetwork();
api.onExportConsole();
api.onExportProtocolSection("pinned");
api.onExportProtocolSection("history");
});
expect(downloadJsonFile).not.toHaveBeenCalled();
});
it("exports one protocol section by pin membership", () => {
const h = harness({
messages: [request("keep", "ping"), request("other", "ping")],
pinnedProtocolIds: new Set(["keep"]),
});
h.run((api) => api.onExportProtocolSection("pinned"));
expect(h.filename()).toContain("protocol-pinned");
expect(h.exported()).toEqual([expect.objectContaining({ id: "keep" })]);
downloadJsonFile.mockClear();
h.run((api) => api.onExportProtocolSection("history"));
expect(h.filename()).toContain("protocol-unpinned");
expect(h.exported()).toEqual([expect.objectContaining({ id: "other" })]);
});
});
describe("replay", () => {
it("re-issues the entry's own method and params", async () => {
replayProtocolRequest.mockResolvedValue(null);
const h = harness({ messages: [request("r1", "tools/list")] });
h.run((api) => api.onReplayProtocol("r1"));
await vi.waitFor(() => {
expect(replayProtocolRequest).toHaveBeenCalledWith(
expect.anything(),
"tools/list",
{ a: 1 },
[],
);
});
expect(notificationsMock.show).not.toHaveBeenCalled();
});
it("surfaces a pre-flight refusal as a toast", async () => {
replayProtocolRequest.mockResolvedValue("Nope.");
const h = harness({ messages: [request("r1", "tools/list")] });
h.run((api) => api.onReplayProtocol("r1"));
await vi.waitFor(() => {
expect(notificationsMock.show).toHaveBeenCalledWith(
expect.objectContaining({ title: "Can't replay", message: "Nope." }),
);
});
});
it("surfaces a thrown failure as a toast", async () => {
replayProtocolRequest.mockRejectedValue(new Error("boom"));
const h = harness({ messages: [request("r1", "tools/list")] });
h.run((api) => api.onReplayProtocol("r1"));
await vi.waitFor(() => {
expect(notificationsMock.show).toHaveBeenCalledWith(
expect.objectContaining({ title: "Replay failed", message: "boom" }),
);
});
});
it("stringifies a non-Error rejection", async () => {
replayProtocolRequest.mockRejectedValue("plain");
const h = harness({ messages: [request("r1", "tools/list")] });
h.run((api) => api.onReplayProtocol("r1"));
await vi.waitFor(() => {
expect(notificationsMock.show).toHaveBeenCalledWith(
expect.objectContaining({ message: "plain" }),
);
});
});
it("does nothing when disconnected, when the id is unknown, or when the entry carries no method", () => {
const noClient = harness({
inspectorClient: null,
messages: [request("r1", "tools/list")],
});
noClient.run((api) => api.onReplayProtocol("r1"));
const unknown = harness({ messages: [request("r1", "tools/list")] });
unknown.run((api) => api.onReplayProtocol("nope"));
const result = harness({
messages: [
{
id: "r1",
direction: "response",
timestamp: 0,
message: { jsonrpc: "2.0", id: 1, result: {} },
} as unknown as MessageEntry,
],
});
result.run((api) => api.onReplayProtocol("r1"));
expect(replayProtocolRequest).not.toHaveBeenCalled();
});
it("passes no params when the entry declares none", async () => {
replayProtocolRequest.mockResolvedValue(null);
const h = harness({
messages: [
{
id: "r1",
direction: "request",
timestamp: 0,
message: { jsonrpc: "2.0", id: 1, method: "ping" },
} as unknown as MessageEntry,
],
});
h.run((api) => api.onReplayProtocol("r1"));
await vi.waitFor(() => {
expect(replayProtocolRequest).toHaveBeenCalledWith(
expect.anything(),
"ping",
undefined,
[],
);
});
});
});
});

View file

@ -0,0 +1,220 @@
import { useCallback } from "react";
import { notifications } from "@mantine/notifications";
import type { InspectorClient } from "@inspector/core/mcp/index.js";
import type { MessageLogState } from "@inspector/core/mcp/state/messageLogState.js";
import type { FetchRequestLogState } from "@inspector/core/mcp/state/fetchRequestLogState.js";
import type { StderrLogState } from "@inspector/core/mcp/state/stderrLogState.js";
import type { Tool } from "@modelcontextprotocol/client";
import type {
FetchRequestEntry,
MessageEntry,
StderrLogEntry,
} from "@inspector/core/mcp/types.js";
import type { LogEntryData } from "../components/elements/LogEntry/LogEntry";
import { buildExportFilename, downloadJsonFile } from "../lib/downloadFile";
import { replayProtocolRequest } from "../lib/protocolReplay";
/** One Protocol panel section, split by pin membership. */
export type ProtocolSection = "pinned" | "history";
export interface UseExportActionsParams {
/** The active server's id, stamped into every export filename. */
activeServerId?: string;
messageLogState: MessageLogState | null;
fetchRequestLogState: FetchRequestLogState | null;
stderrLogState: StderrLogState | null;
/** The Protocol view's entries (the message log). */
messages: MessageEntry[];
/** The Network view's entries. */
fetchRequests: FetchRequestEntry[];
/** The Logs view's entries. */
logs: LogEntryData[];
/** The Console view's entries. */
stderrLogs: StderrLogEntry[];
/** Protocol entries the user pinned, by entry id. */
pinnedProtocolIds: Set<string>;
setPinnedProtocolIds: (next: Set<string>) => void;
/** Needed to re-issue a replayed request; `null` when disconnected. */
inspectorClient: InspectorClient | null;
/** The tool list a replayed `tools/call` is validated against. */
tools: Tool[];
}
export interface ExportActions {
onClearLogs: () => void;
onClearProtocol: () => void;
onClearNetwork: () => void;
onClearConsole: () => void;
onExportLogs: () => void;
onExportProtocol: () => void;
onExportNetwork: () => void;
onExportConsole: () => void;
onClearProtocolSection: (section: ProtocolSection) => void;
onExportProtocolSection: (section: ProtocolSection) => void;
onReplayProtocol: (id: string) => void;
}
/**
* The Clear / Export / Replay handlers for the four log-ish views (Logs,
* Protocol, Network, Console).
*
* Every export is a no-op on an empty list rather than a download of `[]`, and
* every filename is stamped with the active server so two servers' exports do
* not collide in the download folder.
*/
export function useExportActions({
activeServerId,
messageLogState,
fetchRequestLogState,
stderrLogState,
messages,
fetchRequests,
logs,
stderrLogs,
pinnedProtocolIds,
setPinnedProtocolIds,
inspectorClient,
tools,
}: UseExportActionsParams): ExportActions {
const onClearLogs = useCallback(() => {
if (!messageLogState) return;
// Clear only the log notifications, not the entire request/response
// history (which the Protocol screen renders from the same source).
messageLogState.clearMessages(
(m) =>
m.direction === "notification" &&
"method" in m.message &&
m.message.method === "notifications/message",
);
}, [messageLogState]);
// Panel-level Clear clears the (unpinned) history and keeps pinned entries —
// pinning is the way to protect an entry from Clear. This matches the button's
// `disabled={unpinnedEntries.length === 0}` gating and the per-section model,
// and leaves pinnedProtocolIds valid (the pins it references still exist).
const onClearProtocol = useCallback(() => {
messageLogState?.clearMessages((m) => !pinnedProtocolIds.has(m.id));
}, [messageLogState, pinnedProtocolIds]);
const onClearNetwork = useCallback(() => {
fetchRequestLogState?.clearFetchRequests();
}, [fetchRequestLogState]);
const onClearConsole = useCallback(() => {
stderrLogState?.clearStderrLogs();
}, [stderrLogState]);
const onExportNetwork = useCallback(() => {
if (fetchRequests.length === 0) return;
downloadJsonFile(
buildExportFilename("network", activeServerId),
JSON.stringify(fetchRequests, null, 2),
);
}, [fetchRequests, activeServerId]);
const onExportProtocol = useCallback(() => {
if (messages.length === 0) return;
downloadJsonFile(
buildExportFilename("protocol", activeServerId),
JSON.stringify(messages, null, 2),
);
}, [messages, activeServerId]);
const onExportLogs = useCallback(() => {
if (logs.length === 0) return;
downloadJsonFile(
buildExportFilename("logs", activeServerId),
JSON.stringify(logs, null, 2),
);
}, [logs, activeServerId]);
const onExportConsole = useCallback(() => {
if (stderrLogs.length === 0) return;
downloadJsonFile(
buildExportFilename("console", activeServerId),
JSON.stringify(stderrLogs, null, 2),
);
}, [stderrLogs, activeServerId]);
// Clear just one section: remove its entries from the log by pin membership.
// Clearing the pinned section also drops the (now-stale) pinned id set.
const onClearProtocolSection = useCallback(
(section: ProtocolSection) => {
const isPinned = section === "pinned";
messageLogState?.clearMessages((m) =>
isPinned ? pinnedProtocolIds.has(m.id) : !pinnedProtocolIds.has(m.id),
);
if (isPinned) setPinnedProtocolIds(new Set());
},
[messageLogState, pinnedProtocolIds, setPinnedProtocolIds],
);
// Export just one section's entries (by pin membership) to a JSON file.
const onExportProtocolSection = useCallback(
(section: ProtocolSection) => {
const isPinned = section === "pinned";
const subset = messages.filter((m) =>
isPinned ? pinnedProtocolIds.has(m.id) : !pinnedProtocolIds.has(m.id),
);
if (subset.length === 0) return;
downloadJsonFile(
buildExportFilename(
isPinned ? "protocol-pinned" : "protocol-unpinned",
activeServerId,
),
JSON.stringify(subset, null, 2),
);
},
[messages, pinnedProtocolIds, activeServerId],
);
// Replay a Protocol entry: re-issue its original request so the fresh
// request+response appear as a new Protocol entry (protocol-local). A reason
// string (unsupported method / missing tool) surfaces as a toast; a genuine
// call error already shows up as the replayed entry's Error status, so only a
// pre-flight failure (nothing logged) needs the fallback toast.
const onReplayProtocol = useCallback(
(id: string) => {
if (!inspectorClient) return;
const entry = messages.find((m) => m.id === id);
if (!entry || !("method" in entry.message)) return;
const { method } = entry.message;
const params =
"params" in entry.message
? (entry.message.params as Record<string, unknown> | undefined)
: undefined;
void replayProtocolRequest(inspectorClient, method, params, tools)
.then((reason) => {
if (reason) {
notifications.show({
title: "Can't replay",
message: reason,
color: "yellow",
});
}
})
.catch((err: unknown) => {
notifications.show({
title: "Replay failed",
message: err instanceof Error ? err.message : String(err),
color: "red",
});
});
},
[inspectorClient, messages, tools],
);
return {
onClearLogs,
onClearProtocol,
onClearNetwork,
onClearConsole,
onExportLogs,
onExportProtocol,
onExportNetwork,
onExportConsole,
onClearProtocolSection,
onExportProtocolSection,
onReplayProtocol,
};
}

View file

@ -0,0 +1,302 @@
import { describe, it, expect, beforeEach, vi } from "vitest";
import { act } from "react";
import type { InspectorClient } from "@inspector/core/mcp/index.js";
import { renderWithMantine } from "../test/renderWithMantine";
import {
useInspectorStores,
type UseInspectorStoresResult,
} from "./useInspectorStores";
// Every state manager is replaced by a no-op constructor that records its
// arguments, so these tests are about the *lifecycle* the hook owns — who is
// built, with what, and who is torn down — rather than about the stores.
const { built, destroyed, fakeState, listHook, pagedHook } = vi.hoisted(() => {
const built: { name: string; args: unknown[]; destroy: () => void }[] = [];
const destroyed: string[] = [];
/** A no-op state manager that records how it was constructed. */
const fakeState = (name: string) =>
vi.fn(function (...args: unknown[]) {
const instance = { destroy: vi.fn(() => destroyed.push(name)) };
built.push({ name, args, destroy: instance.destroy });
return instance;
});
/** An aggregate-list `core/react` hook returning a fixed list. */
const listHook = (key: string, items: unknown[]) => () => ({
[key]: items,
error: null,
listChanged: false,
refresh: vi.fn().mockResolvedValue(undefined),
clearListChanged: vi.fn(),
});
/** A paged-list `core/react` hook returning a fixed page. */
const pagedHook = (key: string, items: unknown[]) => () => ({
[key]: items,
nextCursor: undefined,
pageCount: 1,
error: null,
loadPage: vi.fn().mockResolvedValue(undefined),
});
return { built, destroyed, fakeState, listHook, pagedHook };
});
vi.mock("@inspector/core/mcp/state/managedToolsState.js", () => ({
ManagedToolsState: fakeState("managedToolsState"),
}));
vi.mock("@inspector/core/mcp/state/managedPromptsState.js", () => ({
ManagedPromptsState: fakeState("managedPromptsState"),
}));
vi.mock("@inspector/core/mcp/state/managedResourcesState.js", () => ({
ManagedResourcesState: fakeState("managedResourcesState"),
}));
vi.mock("@inspector/core/mcp/state/pagedToolsState.js", () => ({
PagedToolsState: fakeState("pagedToolsState"),
}));
vi.mock("@inspector/core/mcp/state/pagedPromptsState.js", () => ({
PagedPromptsState: fakeState("pagedPromptsState"),
}));
vi.mock("@inspector/core/mcp/state/pagedResourcesState.js", () => ({
PagedResourcesState: fakeState("pagedResourcesState"),
}));
vi.mock("@inspector/core/mcp/state/managedResourceTemplatesState.js", () => ({
ManagedResourceTemplatesState: fakeState("managedResourceTemplatesState"),
}));
vi.mock("@inspector/core/mcp/state/managedRequestorTasksState.js", () => ({
ManagedRequestorTasksState: fakeState("managedRequestorTasksState"),
}));
vi.mock("@inspector/core/mcp/state/resourceSubscriptionsState.js", () => ({
ResourceSubscriptionsState: fakeState("resourceSubscriptionsState"),
}));
vi.mock("@inspector/core/mcp/state/messageLogState.js", () => ({
MessageLogState: fakeState("messageLogState"),
}));
vi.mock("@inspector/core/mcp/state/fetchRequestLogState.js", () => ({
FetchRequestLogState: fakeState("fetchRequestLogState"),
}));
vi.mock("@inspector/core/mcp/state/stderrLogState.js", () => ({
StderrLogState: fakeState("stderrLogState"),
}));
// The `core/react` hooks are the store→React bridge and are tested in core;
// here they are stubs returning fixed lists, so the assertions about *which*
// list surfaces are about this hook's mode selection.
vi.mock("@inspector/core/react/useManagedTools.js", () => ({
useManagedTools: listHook("tools", [{ name: "aggregate-tool" }]),
}));
vi.mock("@inspector/core/react/useManagedPrompts.js", () => ({
useManagedPrompts: listHook("prompts", [{ name: "aggregate-prompt" }]),
}));
vi.mock("@inspector/core/react/useManagedResources.js", () => ({
useManagedResources: listHook("resources", [{ uri: "aggregate://r" }]),
}));
vi.mock("@inspector/core/react/usePagedTools.js", () => ({
usePagedTools: pagedHook("tools", [{ name: "paged-tool" }]),
}));
vi.mock("@inspector/core/react/usePagedPrompts.js", () => ({
usePagedPrompts: pagedHook("prompts", [{ name: "paged-prompt" }]),
}));
vi.mock("@inspector/core/react/usePagedResources.js", () => ({
usePagedResources: pagedHook("resources", [{ uri: "paged://r" }]),
}));
vi.mock("@inspector/core/react/useManagedResourceTemplates.js", () => ({
useManagedResourceTemplates: () => ({
resourceTemplates: [{ uriTemplate: "x://{id}" }],
error: null,
refresh: vi.fn().mockResolvedValue(undefined),
}),
}));
vi.mock("@inspector/core/react/useManagedRequestorTasks.js", () => ({
useManagedRequestorTasks: () => ({
tasks: [{ taskId: "t1" }],
refresh: vi.fn().mockResolvedValue(undefined),
clearCompleted: vi.fn(),
}),
}));
vi.mock("@inspector/core/react/useResourceSubscriptions.js", () => ({
useResourceSubscriptions: () => ({
subscriptions: [{ uri: "sub://r" }],
streamState: "listening",
}),
}));
vi.mock("@inspector/core/react/useMessageLog.js", () => ({
useMessageLog: () => ({ messages: [{ id: "m1" }] }),
}));
vi.mock("@inspector/core/react/useFetchRequestLog.js", () => ({
useFetchRequestLog: () => ({ fetchRequests: [{ id: "f1" }] }),
}));
vi.mock("@inspector/core/react/useStderrLog.js", () => ({
useStderrLog: () => ({ stderrLogs: [{ id: "s1" }] }),
}));
const client = () => ({ id: "client" }) as unknown as InspectorClient;
const fetchLogOptions = {
maxFetchRequests: 42,
sessionId: "sess-1",
};
function harness(paginatedLists = false) {
let latest: UseInspectorStoresResult | undefined;
let renders = 0;
function Probe({ paginated }: { paginated: boolean }) {
latest = useInspectorStores({
inspectorClient: null,
connected: true,
paginatedLists: paginated,
});
renders += 1;
return null;
}
const { rerender } = renderWithMantine(<Probe paginated={paginatedLists} />);
return {
api: () => {
if (!latest) throw new Error("hook did not render");
return latest;
},
renders: () => renders,
run: (fn: (api: UseInspectorStoresResult) => void) =>
act(() => {
if (!latest) throw new Error("hook did not render");
fn(latest);
}),
setPaginated: (next: boolean) =>
act(() => rerender(<Probe paginated={next} />)),
};
}
const STORE_NAMES = [
"managedToolsState",
"managedPromptsState",
"managedResourcesState",
"pagedToolsState",
"pagedPromptsState",
"pagedResourcesState",
"managedResourceTemplatesState",
"managedRequestorTasksState",
"resourceSubscriptionsState",
"messageLogState",
"fetchRequestLogState",
"stderrLogState",
];
describe("useInspectorStores", () => {
beforeEach(() => {
built.length = 0;
destroyed.length = 0;
});
describe("the lifecycle", () => {
it("holds no stores before the first connect", () => {
const h = harness();
expect(h.api().stores).toBeNull();
expect(h.api().fetchLogRef.current).toBeNull();
expect(built).toHaveLength(0);
});
it("builds all twelve stores against the client", () => {
const h = harness();
const c = client();
h.run((api) => api.createStores(c, fetchLogOptions));
expect(built.map((b) => b.name).sort()).toEqual([...STORE_NAMES].sort());
for (const b of built) {
expect(b.args[0]).toBe(c);
}
expect(Object.keys(h.api().stores ?? {}).sort()).toEqual(
[...STORE_NAMES].sort(),
);
});
it("hands the subscription store the resources state it just built", () => {
const h = harness();
h.run((api) => api.createStores(client(), fetchLogOptions));
const subs = built.find((b) => b.name === "resourceSubscriptionsState");
expect(subs?.args[1]).toBe(h.api().stores?.managedResourcesState);
});
it("passes the per-connect fetch-log options straight through", () => {
const h = harness();
h.run((api) => api.createStores(client(), fetchLogOptions));
const log = built.find((b) => b.name === "fetchRequestLogState");
expect(log?.args[1]).toBe(fetchLogOptions);
});
it("points fetchLogRef at the live fetch log", () => {
const h = harness();
h.run((api) => api.createStores(client(), fetchLogOptions));
expect(h.api().fetchLogRef.current).toBe(
h.api().stores?.fetchRequestLogState,
);
});
it("tears the previous set down before building the next", () => {
const h = harness();
h.run((api) => api.createStores(client(), fetchLogOptions));
const first = h.api().stores;
h.run((api) => api.createStores(client(), fetchLogOptions));
expect(destroyed.sort()).toEqual([...STORE_NAMES].sort());
expect(h.api().stores).not.toBe(first);
});
it("destroys and clears on destroyStores", () => {
const h = harness();
h.run((api) => api.createStores(client(), fetchLogOptions));
h.run((api) => api.destroyStores());
expect(destroyed.sort()).toEqual([...STORE_NAMES].sort());
expect(h.api().stores).toBeNull();
expect(h.api().fetchLogRef.current).toBeNull();
});
it("no-ops when destroyStores runs with nothing live", () => {
const h = harness();
h.run((api) => api.destroyStores());
expect(destroyed).toHaveLength(0);
expect(h.api().stores).toBeNull();
});
it("keeps createStores and destroyStores stable across renders", () => {
const h = harness();
const create = h.api().createStores;
const destroy = h.api().destroyStores;
h.run((api) => api.createStores(client(), fetchLogOptions));
expect(h.renders()).toBeGreaterThan(1);
expect(h.api().createStores).toBe(create);
expect(h.api().destroyStores).toBe(destroy);
});
});
describe("the display source", () => {
it("reads the aggregate lists in all-pages mode", () => {
const h = harness(false);
expect(h.api().toolsPagination.items).toEqual([
{ name: "aggregate-tool" },
]);
expect(h.api().promptsPagination.items).toEqual([
{ name: "aggregate-prompt" },
]);
expect(h.api().resourcesPagination.items).toEqual([
{ uri: "aggregate://r" },
]);
});
it("switches to the paged lists when pagination is on", () => {
const h = harness(false);
h.setPaginated(true);
expect(h.api().toolsPagination.items).toEqual([{ name: "paged-tool" }]);
expect(h.api().promptsPagination.items).toEqual([
{ name: "paged-prompt" },
]);
expect(h.api().resourcesPagination.items).toEqual([{ uri: "paged://r" }]);
});
it("surfaces the remaining store-backed views", () => {
const h = harness();
const api = h.api();
expect(api.resourceTemplates).toEqual([{ uriTemplate: "x://{id}" }]);
expect(api.tasks).toEqual([{ taskId: "t1" }]);
expect(api.subscriptions).toEqual([{ uri: "sub://r" }]);
expect(api.subscriptionStreamState).toBe("listening");
expect(api.messages).toEqual([{ id: "m1" }]);
expect(api.fetchRequests).toEqual([{ id: "f1" }]);
expect(api.stderrLogs).toEqual([{ id: "s1" }]);
});
});
});

View file

@ -0,0 +1,353 @@
import { useCallback, useRef, useState } from "react";
import type {
Prompt,
Resource,
ResourceTemplateType as ResourceTemplate,
Task,
Tool,
} from "@modelcontextprotocol/client";
import type {
FetchRequestEntry,
InspectorResourceSubscription,
MessageEntry,
ResourceSubscriptionStreamState,
StderrLogEntry,
} from "@inspector/core/mcp/types.js";
import type { InspectorClient } from "@inspector/core/mcp/index.js";
import { ManagedToolsState } from "@inspector/core/mcp/state/managedToolsState.js";
import { ManagedPromptsState } from "@inspector/core/mcp/state/managedPromptsState.js";
import { ManagedResourcesState } from "@inspector/core/mcp/state/managedResourcesState.js";
import { PagedToolsState } from "@inspector/core/mcp/state/pagedToolsState.js";
import { PagedPromptsState } from "@inspector/core/mcp/state/pagedPromptsState.js";
import { PagedResourcesState } from "@inspector/core/mcp/state/pagedResourcesState.js";
import { ManagedResourceTemplatesState } from "@inspector/core/mcp/state/managedResourceTemplatesState.js";
import { ManagedRequestorTasksState } from "@inspector/core/mcp/state/managedRequestorTasksState.js";
import { ResourceSubscriptionsState } from "@inspector/core/mcp/state/resourceSubscriptionsState.js";
import { MessageLogState } from "@inspector/core/mcp/state/messageLogState.js";
import {
FetchRequestLogState,
type FetchRequestLogStateOptions,
} from "@inspector/core/mcp/state/fetchRequestLogState.js";
import { StderrLogState } from "@inspector/core/mcp/state/stderrLogState.js";
import { useManagedTools } from "@inspector/core/react/useManagedTools.js";
import { useManagedPrompts } from "@inspector/core/react/useManagedPrompts.js";
import { useManagedResources } from "@inspector/core/react/useManagedResources.js";
import { usePagedTools } from "@inspector/core/react/usePagedTools.js";
import { usePagedPrompts } from "@inspector/core/react/usePagedPrompts.js";
import { usePagedResources } from "@inspector/core/react/usePagedResources.js";
import { useManagedResourceTemplates } from "@inspector/core/react/useManagedResourceTemplates.js";
import { useManagedRequestorTasks } from "@inspector/core/react/useManagedRequestorTasks.js";
import { useResourceSubscriptions } from "@inspector/core/react/useResourceSubscriptions.js";
import { useMessageLog } from "@inspector/core/react/useMessageLog.js";
import { useFetchRequestLog } from "@inspector/core/react/useFetchRequestLog.js";
import { useStderrLog } from "@inspector/core/react/useStderrLog.js";
import { usePaginatedList, type PaginatedListModel } from "./usePaginatedList";
/**
* The twelve per-session state managers. They are created together (one
* `InspectorClient`, one set of stores) and torn down together, so they are
* held as one slot rather than twelve — a partially-replaced set would leave
* some stores listening to a client the others had already left.
*/
export interface InspectorStores {
managedToolsState: ManagedToolsState;
managedPromptsState: ManagedPromptsState;
managedResourcesState: ManagedResourcesState;
pagedToolsState: PagedToolsState;
pagedPromptsState: PagedPromptsState;
pagedResourcesState: PagedResourcesState;
managedResourceTemplatesState: ManagedResourceTemplatesState;
managedRequestorTasksState: ManagedRequestorTasksState;
resourceSubscriptionsState: ResourceSubscriptionsState;
messageLogState: MessageLogState;
fetchRequestLogState: FetchRequestLogState;
stderrLogState: StderrLogState;
}
export interface UseInspectorStoresParams {
/** The live client, or `null` before the first connect. */
inspectorClient: InspectorClient | null;
/** Whether the client is connected (masks paged progress when not). */
connected: boolean;
/** The active server's `paginatedLists` setting (#1721). */
paginatedLists: boolean;
}
/** The `FetchRequestLogState` options that vary per server / per connect. */
export type FetchLogOptions = Pick<
FetchRequestLogStateOptions,
"sessionStorage" | "logger" | "maxFetchRequests" | "sessionId"
>;
export interface UseInspectorStoresResult {
/** The live stores, or `null` before the first connect / after teardown. */
stores: InspectorStores | null;
/**
* Build a fresh set of stores for `client`, tearing down whatever set is
* live first. Stable, so callers need no dependency on the current stores.
*/
createStores: (
client: InspectorClient,
fetchLogOptions: FetchLogOptions,
) => void;
/**
* Destroy the live stores and clear the slot. Each `destroy()` unsubscribes
* that store from its client's events. Stable, and a no-op when nothing is
* live.
*/
destroyStores: () => void;
/**
* Always points at the live `FetchRequestLogState`, so a synchronous
* pre-redirect hook can read the current Network log without being rebound
* each time the active server (and its log state) changes.
*/
fetchLogRef: React.RefObject<FetchRequestLogState | null>;
// --- Aggregate ("all pages") list sources ---
/** Re-fetch the whole aggregate list. Bypasses the pagination mode split. */
refreshTools: () => Promise<unknown>;
refreshPrompts: () => Promise<unknown>;
refreshResources: () => Promise<unknown>;
/** Fetch one page; `undefined` cursor = page 1 (replaces the paged list). */
loadToolsPage: (cursor?: string) => Promise<unknown>;
loadPromptsPage: (cursor?: string) => Promise<unknown>;
loadResourcesPage: (cursor?: string) => Promise<unknown>;
toolsListChanged: boolean;
clearToolsListChanged: () => void;
promptsListChanged: boolean;
clearPromptsListChanged: () => void;
resourcesListChanged: boolean;
clearResourcesListChanged: () => void;
resourceTemplates: ResourceTemplate[];
resourceTemplatesLoadError: Error | null;
refreshResourceTemplates: () => Promise<unknown>;
// --- The display source for each list, by mode (#1721) ---
toolsPagination: PaginatedListModel<Tool>;
promptsPagination: PaginatedListModel<Prompt>;
resourcesPagination: PaginatedListModel<Resource>;
// --- Everything else the stores expose ---
tasks: Task[];
refreshTasks: () => Promise<unknown>;
clearCompletedTasks: () => void;
subscriptions: InspectorResourceSubscription[];
subscriptionStreamState: ResourceSubscriptionStreamState;
messages: MessageEntry[];
fetchRequests: FetchRequestEntry[];
stderrLogs: StderrLogEntry[];
}
/**
* Owns the per-session state managers and the `core/react` hooks that read
* them, so App.tsx sees one lifecycle (create / destroy) and the finished
* lists rather than twelve state slots and their wiring.
*
* The lists come back already resolved for the active mode: in paginated mode
* the paged stores drive the panels, otherwise the aggregate ones do.
*/
export function useInspectorStores({
inspectorClient,
connected,
paginatedLists,
}: UseInspectorStoresParams): UseInspectorStoresResult {
const [stores, setStores] = useState<InspectorStores | null>(null);
// Mirrors `stores` so `destroyStores` can read the live set without taking a
// dependency on it — which is what keeps every caller's callback stable.
const storesRef = useRef<InspectorStores | null>(null);
const fetchLogRef = useRef<FetchRequestLogState | null>(null);
const destroyStores = useCallback(() => {
const live = storesRef.current;
if (live) {
for (const store of Object.values(live)) {
store.destroy();
}
}
storesRef.current = null;
fetchLogRef.current = null;
setStores(null);
}, []);
const createStores = useCallback(
(client: InspectorClient, fetchLogOptions: FetchLogOptions) => {
destroyStores();
// ResourceSubscriptionsState consults the managed resources list to
// resolve subscribed URIs to full Resource objects (so the subscription
// tile shows the server-supplied name/title), so it is handed the
// freshly created state directly rather than read back from React.
const managedResourcesState = new ManagedResourcesState(client);
const fetchRequestLogState = new FetchRequestLogState(
client,
fetchLogOptions,
);
const next: InspectorStores = {
managedToolsState: new ManagedToolsState(client),
managedPromptsState: new ManagedPromptsState(client),
managedResourcesState,
pagedToolsState: new PagedToolsState(client),
pagedPromptsState: new PagedPromptsState(client),
pagedResourcesState: new PagedResourcesState(client),
managedResourceTemplatesState: new ManagedResourceTemplatesState(
client,
),
managedRequestorTasksState: new ManagedRequestorTasksState(client),
resourceSubscriptionsState: new ResourceSubscriptionsState(
client,
managedResourcesState,
),
messageLogState: new MessageLogState(client),
fetchRequestLogState,
stderrLogState: new StderrLogState(client),
};
storesRef.current = next;
fetchLogRef.current = fetchRequestLogState;
setStores(next);
},
[destroyStores],
);
// Each hook degrades to an empty result while its store is null, so the
// pre-connect render is well-defined without a branch here.
const {
tools: managedTools,
error: toolsLoadError,
listChanged: toolsListChanged,
refresh: refreshTools,
clearListChanged: clearToolsListChanged,
} = useManagedTools(inspectorClient, stores?.managedToolsState ?? null);
const {
prompts: managedPrompts,
error: promptsLoadError,
listChanged: promptsListChanged,
refresh: refreshPrompts,
clearListChanged: clearPromptsListChanged,
} = useManagedPrompts(inspectorClient, stores?.managedPromptsState ?? null);
const {
resources: managedResources,
error: resourcesLoadError,
listChanged: resourcesListChanged,
refresh: refreshResources,
clearListChanged: clearResourcesListChanged,
} = useManagedResources(
inspectorClient,
stores?.managedResourcesState ?? null,
);
const {
resourceTemplates,
error: resourceTemplatesLoadError,
refresh: refreshResourceTemplates,
} = useManagedResourceTemplates(
inspectorClient,
stores?.managedResourceTemplatesState ?? null,
);
// Paged (paginated) list sources. When `paginatedLists` is on the managed
// states skip their all-page walk and these drive the sidebar instead (#1721).
const {
tools: pagedTools,
nextCursor: pagedToolsCursor,
pageCount: pagedToolsPageCount,
error: pagedToolsLoadError,
loadPage: loadToolsPage,
} = usePagedTools(inspectorClient, stores?.pagedToolsState ?? null);
const {
prompts: pagedPrompts,
nextCursor: pagedPromptsCursor,
pageCount: pagedPromptsPageCount,
error: pagedPromptsLoadError,
loadPage: loadPromptsPage,
} = usePagedPrompts(inspectorClient, stores?.pagedPromptsState ?? null);
const {
resources: pagedResources,
nextCursor: pagedResourcesCursor,
pageCount: pagedResourcesPageCount,
error: pagedResourcesLoadError,
loadPage: loadResourcesPage,
} = usePagedResources(inspectorClient, stores?.pagedResourcesState ?? null);
const toolsPagination = usePaginatedList({
connected,
paginated: paginatedLists,
managedItems: managedTools,
managedRefresh: refreshTools,
managedError: toolsLoadError,
pagedItems: pagedTools,
pagedNextCursor: pagedToolsCursor,
pagedPageCount: pagedToolsPageCount,
pagedError: pagedToolsLoadError,
loadPage: loadToolsPage,
});
const promptsPagination = usePaginatedList({
connected,
paginated: paginatedLists,
managedItems: managedPrompts,
managedRefresh: refreshPrompts,
managedError: promptsLoadError,
pagedItems: pagedPrompts,
pagedNextCursor: pagedPromptsCursor,
pagedPageCount: pagedPromptsPageCount,
pagedError: pagedPromptsLoadError,
loadPage: loadPromptsPage,
});
const resourcesPagination = usePaginatedList({
connected,
paginated: paginatedLists,
managedItems: managedResources,
managedRefresh: refreshResources,
managedError: resourcesLoadError,
pagedItems: pagedResources,
pagedNextCursor: pagedResourcesCursor,
pagedPageCount: pagedResourcesPageCount,
pagedError: pagedResourcesLoadError,
loadPage: loadResourcesPage,
});
const {
tasks,
refresh: refreshTasks,
clearCompleted: clearCompletedTasks,
} = useManagedRequestorTasks(
inspectorClient,
stores?.managedRequestorTasksState ?? null,
);
const { subscriptions, streamState: subscriptionStreamState } =
useResourceSubscriptions(stores?.resourceSubscriptionsState ?? null);
const { messages } = useMessageLog(stores?.messageLogState ?? null);
const { fetchRequests } = useFetchRequestLog(
stores?.fetchRequestLogState ?? null,
);
const { stderrLogs } = useStderrLog(stores?.stderrLogState ?? null);
return {
stores,
createStores,
destroyStores,
fetchLogRef,
refreshTools,
refreshPrompts,
refreshResources,
loadToolsPage,
loadPromptsPage,
loadResourcesPage,
toolsListChanged,
clearToolsListChanged,
promptsListChanged,
clearPromptsListChanged,
resourcesListChanged,
clearResourcesListChanged,
resourceTemplates,
resourceTemplatesLoadError,
refreshResourceTemplates,
toolsPagination,
promptsPagination,
resourcesPagination,
tasks,
refreshTasks,
clearCompletedTasks,
subscriptions,
subscriptionStreamState,
messages,
fetchRequests,
stderrLogs,
};
}

View file

@ -0,0 +1,145 @@
import { describe, it, expect, beforeEach, vi } from "vitest";
import { act } from "react";
import type { InspectorClient } from "@inspector/core/mcp/index.js";
import { renderWithMantine } from "../test/renderWithMantine";
import { progressToastId } from "../utils/toasts/progressToasts";
import { useProgressToasts } from "./useProgressToasts";
// Spy on the toast layer so these assert the show/update calls without
// mounting Mantine's <Notifications/> portal.
const { notificationsMock } = vi.hoisted(() => ({
notificationsMock: {
show: vi.fn(),
update: vi.fn(),
hide: vi.fn(),
clean: vi.fn(),
},
}));
vi.mock("@mantine/notifications", () => ({
notifications: notificationsMock,
}));
/**
* The hook only ever calls `add/removeEventListener`, so a bare `EventTarget`
* is a faithful stand-in for the client — the events are real DOM events and
* the listener wiring under test is the real wiring.
*/
function fakeClient(): InspectorClient & EventTarget {
return new EventTarget() as unknown as InspectorClient & EventTarget;
}
function harness(client: InspectorClient & EventTarget) {
function Probe({ c }: { c: InspectorClient | null }) {
useProgressToasts(c);
return null;
}
const { rerender, unmount } = renderWithMantine(<Probe c={client} />);
return {
progress: (progressToken: string, progress: number, total?: number) =>
act(() => {
client.dispatchEvent(
new CustomEvent("progressNotification", {
detail: { progressToken, progress, total },
}),
);
}),
swapClient: (next: InspectorClient | null) =>
act(() => rerender(<Probe c={next} />)),
unmount: () => act(() => unmount()),
};
}
describe("useProgressToasts", () => {
beforeEach(() => {
notificationsMock.show.mockClear();
notificationsMock.update.mockClear();
notificationsMock.hide.mockClear();
});
it("does nothing without a client", () => {
function Probe() {
useProgressToasts(null);
return null;
}
renderWithMantine(<Probe />);
expect(notificationsMock.show).not.toHaveBeenCalled();
});
it("shows one toast for a stream's first tick", () => {
const h = harness(fakeClient());
h.progress("tok-1", 1, 4);
expect(notificationsMock.show).toHaveBeenCalledTimes(1);
expect(notificationsMock.show.mock.calls[0][0]).toMatchObject({
id: progressToastId("tok-1"),
title: "Tool progress",
});
});
it("updates that toast on the next tick rather than stacking a new one", () => {
const h = harness(fakeClient());
h.progress("tok-1", 1, 4);
h.progress("tok-1", 2, 4);
expect(notificationsMock.show).toHaveBeenCalledTimes(1);
expect(notificationsMock.update).toHaveBeenCalledTimes(1);
expect(notificationsMock.update.mock.calls[0][0]).toMatchObject({
id: progressToastId("tok-1"),
});
});
it("gives each stream its own toast", () => {
const h = harness(fakeClient());
h.progress("tok-1", 1);
h.progress("tok-2", 1);
expect(notificationsMock.show).toHaveBeenCalledTimes(2);
expect(notificationsMock.update).not.toHaveBeenCalled();
});
it("re-shows a stream whose toast the user closed", () => {
const h = harness(fakeClient());
h.progress("tok-1", 1);
const { onClose } = notificationsMock.show.mock.calls[0][0] as {
onClose: () => void;
};
onClose();
h.progress("tok-1", 2);
expect(notificationsMock.show).toHaveBeenCalledTimes(2);
expect(notificationsMock.update).not.toHaveBeenCalled();
});
it("hides the live toasts when the client is swapped out", () => {
const h = harness(fakeClient());
h.progress("tok-1", 1);
h.progress("tok-2", 1);
h.swapClient(null);
expect(notificationsMock.hide).toHaveBeenCalledWith(
progressToastId("tok-1"),
);
expect(notificationsMock.hide).toHaveBeenCalledWith(
progressToastId("tok-2"),
);
});
it("stops listening to the old client after a swap", () => {
const first = fakeClient();
const h = harness(first);
h.swapClient(fakeClient());
notificationsMock.show.mockClear();
act(() => {
first.dispatchEvent(
new CustomEvent("progressNotification", {
detail: { progressToken: "tok-1", progress: 1 },
}),
);
});
expect(notificationsMock.show).not.toHaveBeenCalled();
});
it("hides the live toasts on unmount", () => {
const h = harness(fakeClient());
h.progress("tok-1", 1);
h.unmount();
expect(notificationsMock.hide).toHaveBeenCalledWith(
progressToastId("tok-1"),
);
});
});

View file

@ -0,0 +1,72 @@
import { useEffect, useRef } from "react";
import { notifications } from "@mantine/notifications";
import type {
InspectorClient,
InspectorClientEventMap,
} from "@inspector/core/mcp/index.js";
import type { TypedEventGeneric } from "@inspector/core/mcp/typedEventTarget.js";
import {
formatProgressToastMessage,
PROGRESS_TOAST_AUTOCLOSE_MS,
progressToastId,
} from "../utils/toasts/progressToasts";
/**
* 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 Protocol 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.
*/
export function useProgressToasts(inspectorClient: InspectorClient | null) {
// Which progress streams currently have a live toast. Entries are removed
// when their toast closes (auto-dismiss or user). A ref (not state) because
// it is incidental bookkeeping that must not trigger re-renders.
const progressToastIdsRef = useRef<Set<string>>(new Set());
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]);
}

View file

@ -0,0 +1,100 @@
import { describe, expect, it } from "vitest";
import { act } from "react";
import { renderWithMantine } from "../test/renderWithMantine";
import { EMPTY_TOOLS_UI } from "../components/screens/screenUiState";
import { INSPECTOR_SERVERS_TAB } from "../utils/inspectorTabs";
import { useTabUiState, type TabUiStateResult } from "./useTabUiState";
function harness() {
let latest: TabUiStateResult | undefined;
let renders = 0;
function Probe() {
latest = useTabUiState();
renders += 1;
return null;
}
renderWithMantine(<Probe />);
return {
api: () => {
if (!latest) throw new Error("hook did not render");
return latest;
},
renders: () => renders,
run: (fn: (api: TabUiStateResult) => void) => {
act(() => {
if (!latest) throw new Error("hook did not render");
fn(latest);
});
},
};
}
describe("useTabUiState", () => {
it("starts every screen empty, on the Servers tab, with no pins", () => {
const h = harness();
expect(h.api().ui.toolsUi).toEqual(EMPTY_TOOLS_UI);
expect(h.api().activeTab).toBe(INSPECTOR_SERVERS_TAB);
expect(h.api().pinnedProtocolIds.size).toBe(0);
});
it("updates one screen's ui without disturbing the others", () => {
const h = harness();
const promptsBefore = h.api().ui.promptsUi;
h.run((api) => api.setUi.setToolsUi({ ...EMPTY_TOOLS_UI, search: "echo" }));
expect(h.api().ui.toolsUi.search).toBe("echo");
expect(h.api().ui.promptsUi).toBe(promptsBefore);
});
it("keeps the setter object identity stable across renders", () => {
const h = harness();
const before = h.api().setUi;
h.run((api) => api.setActiveTab("Tools"));
expect(h.renders()).toBeGreaterThan(1);
expect(h.api().setUi).toBe(before);
});
it("toggles a protocol pin on and back off", () => {
const h = harness();
h.run((api) => api.togglePinProtocol("m1"));
expect([...h.api().pinnedProtocolIds]).toEqual(["m1"]);
h.run((api) => api.togglePinProtocol("m2"));
expect([...h.api().pinnedProtocolIds].sort()).toEqual(["m1", "m2"]);
h.run((api) => api.togglePinProtocol("m1"));
expect([...h.api().pinnedProtocolIds]).toEqual(["m2"]);
});
it("replaces the pin set wholesale via setPinnedProtocolIds", () => {
const h = harness();
h.run((api) => api.setPinnedProtocolIds(new Set(["a", "b"])));
expect([...h.api().pinnedProtocolIds].sort()).toEqual(["a", "b"]);
});
it("resets every screen and the pins, but leaves the active tab alone", () => {
const h = harness();
h.run((api) => {
api.setUi.setToolsUi({ ...EMPTY_TOOLS_UI, search: "echo" });
api.setUi.setConsoleUi({ filterText: "boom" });
api.togglePinProtocol("m1");
api.setActiveTab("Tools");
});
expect(h.api().ui.consoleUi.filterText).toBe("boom");
h.run((api) => api.resetTabUiState());
expect(h.api().ui.toolsUi).toEqual(EMPTY_TOOLS_UI);
expect(h.api().ui.consoleUi.filterText).toBe("");
expect(h.api().pinnedProtocolIds.size).toBe(0);
// The tab the user is on is shell state, reset only on explicit disconnect.
expect(h.api().activeTab).toBe("Tools");
});
it("keeps resetTabUiState stable so a []-dep session reset can call it", () => {
const h = harness();
const before = h.api().resetTabUiState;
h.run((api) => api.setActiveTab("Logs"));
expect(h.api().resetTabUiState).toBe(before);
expect(h.api().togglePinProtocol).toBe(
// togglePin is likewise stable — it is passed straight into the JSX.
h.api().togglePinProtocol,
);
});
});

View file

@ -0,0 +1,160 @@
import { useCallback, useMemo, useState } from "react";
import type { ConsoleUiState } from "../components/screens/ConsoleScreen/ConsoleScreen";
import {
EMPTY_APPS_UI,
EMPTY_CONSOLE_UI,
EMPTY_LOGS_UI,
EMPTY_NETWORK_UI,
EMPTY_PROMPTS_UI,
EMPTY_PROTOCOL_UI,
EMPTY_RESOURCES_UI,
EMPTY_TASKS_UI,
EMPTY_TOOLS_UI,
} from "../components/screens/screenUiState";
import type { LiftedTabUiState, TabUiSetters } from "../lib/oauthResume";
import { INSPECTOR_SERVERS_TAB } from "../utils/inspectorTabs";
/**
* The per-screen `ui` objects, keyed exactly as {@link LiftedTabUiState} keys
* them so the OAuth-resume snapshot can be built straight from this object.
* `consoleUi` is the one addition — the Console screen is not snapshotted
* across an OAuth redirect (its entries survive in `StderrLogState`), but its
* search filter is lifted here with the rest.
*/
export interface TabUiState extends LiftedTabUiState {
consoleUi: ConsoleUiState;
}
/** Matching setters, named as {@link TabUiSetters} names them. */
export interface TabUiStateSetters extends TabUiSetters {
setConsoleUi: (next: ConsoleUiState) => void;
}
export interface TabUiStateResult {
/** Every screen's lifted UI object. */
ui: TabUiState;
/** Stable per-screen setters. The object identity is stable too. */
setUi: TabUiStateSetters;
activeTab: string;
setActiveTab: (next: string) => void;
/** Protocol entries the user pinned, by entry id. */
pinnedProtocolIds: Set<string>;
setPinnedProtocolIds: (next: Set<string>) => void;
/** Pin/unpin one Protocol entry. */
togglePinProtocol: (id: string) => void;
/**
* Reset every screen's UI object and the pin set to their empty defaults.
* Stable, so the session-reset callback that drives it needs no dependency
* on this hook. Deliberately leaves `activeTab` alone: the tab the user is
* on is shell state, reset separately on an *explicit* disconnect.
*/
resetTabUiState: () => void;
}
/**
* Per-screen selection / search / filter state, one object per screen.
*
* Lifted out of the individual screens because the screens unmount on tab
* switch, so screen-local state would be lost on plain navigation. It is
* cleared only on disconnect (via `resetTabUiState`) or an explicit user
* action (#1414/#1417).
*/
export function useTabUiState(): TabUiStateResult {
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 [protocolUi, setProtocolUi] = useState(EMPTY_PROTOCOL_UI);
const [networkUi, setNetworkUi] = useState(EMPTY_NETWORK_UI);
const [consoleUi, setConsoleUi] = useState(EMPTY_CONSOLE_UI);
// Session-scoped — the ids reference message-log entries, which clear on
// disconnect, so this resets with the rest of the per-screen state.
const [pinnedProtocolIds, setPinnedProtocolIds] = useState<Set<string>>(
() => new Set(),
);
const [activeTab, setActiveTab] = useState<string>(INSPECTOR_SERVERS_TAB);
const ui: TabUiState = useMemo(
() => ({
toolsUi,
promptsUi,
resourcesUi,
appsUi,
tasksUi,
logsUi,
protocolUi,
networkUi,
consoleUi,
}),
[
toolsUi,
promptsUi,
resourcesUi,
appsUi,
tasksUi,
logsUi,
protocolUi,
networkUi,
consoleUi,
],
);
// `useState` setters are stable, so this object is built once and its
// identity never changes — safe to pass straight into a memo dependency.
const setUi: TabUiStateSetters = useMemo(
() => ({
setToolsUi,
setPromptsUi,
setResourcesUi,
setAppsUi,
setTasksUi,
setLogsUi,
setProtocolUi,
setNetworkUi,
setConsoleUi,
}),
[],
);
// ProtocolListPanel sorts pinned entries to the top.
const togglePinProtocol = useCallback((id: string) => {
setPinnedProtocolIds((prev) => {
const next = new Set(prev);
if (next.has(id)) {
next.delete(id);
} else {
next.add(id);
}
return next;
});
}, []);
const resetTabUiState = useCallback(() => {
setToolsUi(EMPTY_TOOLS_UI);
setPromptsUi(EMPTY_PROMPTS_UI);
setResourcesUi(EMPTY_RESOURCES_UI);
setAppsUi(EMPTY_APPS_UI);
setTasksUi(EMPTY_TASKS_UI);
setLogsUi(EMPTY_LOGS_UI);
setProtocolUi(EMPTY_PROTOCOL_UI);
setPinnedProtocolIds(new Set());
setNetworkUi(EMPTY_NETWORK_UI);
// Only the search filter resets here; the stderr entries themselves live in
// StderrLogState, which deliberately survives connect/disconnect so a failed
// launch's output stays visible for diagnosis (#1621).
setConsoleUi(EMPTY_CONSOLE_UI);
}, []);
return {
ui,
setUi,
activeTab,
setActiveTab,
pinnedProtocolIds,
setPinnedProtocolIds,
togglePinProtocol,
resetTabUiState,
};
}

View file

@ -0,0 +1,268 @@
import { describe, it, expect, beforeEach, vi } from "vitest";
import { act } from "react";
import type { Task } from "@modelcontextprotocol/client";
import type { InspectorClient } from "@inspector/core/mcp/index.js";
import { renderWithMantine } from "../test/renderWithMantine";
import { taskToastId } from "../utils/toasts/taskToasts";
import { useTaskToasts, type TaskToasts } from "./useTaskToasts";
const { notificationsMock } = vi.hoisted(() => ({
notificationsMock: {
show: vi.fn(),
update: vi.fn(),
hide: vi.fn(),
clean: vi.fn(),
},
}));
vi.mock("@mantine/notifications", () => ({
notifications: notificationsMock,
}));
/** The hook only calls `add/removeEventListener`, so a bare EventTarget does. */
function fakeClient(): InspectorClient & EventTarget {
return new EventTarget() as unknown as InspectorClient & EventTarget;
}
const task = (status: Task["status"]): Pick<Task, "status"> => ({ status });
function harness(client: InspectorClient & EventTarget) {
let latest: TaskToasts | undefined;
function Probe({ c }: { c: InspectorClient | null }) {
latest = useTaskToasts(c);
return null;
}
const { rerender, unmount } = renderWithMantine(<Probe c={client} />);
const fire = (type: string, detail: unknown) =>
act(() => {
client.dispatchEvent(new CustomEvent(type, { detail }));
});
return {
api: () => {
if (!latest) throw new Error("hook did not render");
return latest;
},
taskProgress: (taskId: string, progress: number, total?: number) =>
fire("requestorTaskProgress", {
taskId,
progress: { progress, total },
}),
statusChange: (taskId: string, status: Task["status"]) =>
fire("taskStatusChange", { taskId, task: task(status) }),
requestorUpdate: (taskId: string, status: Task["status"]) =>
fire("requestorTaskUpdated", { taskId, task: task(status) }),
cancelled: (taskId: string) => fire("taskCancelled", { taskId }),
toolCallTask: (taskId: string) => fire("toolCallTaskUpdated", { taskId }),
run: (fn: (api: TaskToasts) => void) =>
act(() => {
if (!latest) throw new Error("hook did not render");
fn(latest);
}),
swapClient: (next: InspectorClient | null) =>
act(() => rerender(<Probe c={next} />)),
unmount: () => act(() => unmount()),
};
}
describe("useTaskToasts", () => {
beforeEach(() => {
notificationsMock.show.mockClear();
notificationsMock.update.mockClear();
notificationsMock.hide.mockClear();
});
it("does nothing without a client", () => {
function Probe() {
useTaskToasts(null);
return null;
}
renderWithMantine(<Probe />);
expect(notificationsMock.show).not.toHaveBeenCalled();
});
describe("the progress map", () => {
it("records a task's progress", () => {
const h = harness(fakeClient());
h.taskProgress("t1", 2, 5);
expect(h.api().progressByTaskId).toEqual({
t1: { progress: 2, total: 5, message: undefined },
});
});
it("prunes an entry when its task reaches a terminal status", () => {
const h = harness(fakeClient());
h.taskProgress("t1", 2, 5);
h.statusChange("t1", "working");
h.statusChange("t1", "completed");
expect(h.api().progressByTaskId).toEqual({});
});
it("leaves the map alone when a terminal status names no tracked task", () => {
const h = harness(fakeClient());
h.taskProgress("t1", 2, 5);
const before = h.api().progressByTaskId;
h.statusChange("t2", "completed");
expect(h.api().progressByTaskId).toBe(before);
});
it("prunes an entry on cancel, tracked or not", () => {
const h = harness(fakeClient());
h.taskProgress("t1", 2, 5);
h.cancelled("t1");
expect(h.api().progressByTaskId).toEqual({});
const before = h.api().progressByTaskId;
h.cancelled("t-unknown");
expect(h.api().progressByTaskId).toBe(before);
});
it("drops every entry on resetTaskProgress", () => {
const h = harness(fakeClient());
h.taskProgress("t1", 1);
h.taskProgress("t2", 1);
h.run((api) => api.resetTaskProgress());
expect(h.api().progressByTaskId).toEqual({});
});
});
describe("the status toasts", () => {
it("shows one non-auto-closing toast for a running task", () => {
const h = harness(fakeClient());
h.statusChange("t1", "working");
expect(notificationsMock.show).toHaveBeenCalledTimes(1);
expect(notificationsMock.show.mock.calls[0][0]).toMatchObject({
id: taskToastId("t1"),
title: "Task working",
autoClose: false,
});
});
it("replaces that toast on the next tick", () => {
const h = harness(fakeClient());
h.statusChange("t1", "working");
h.requestorUpdate("t1", "input_required");
expect(notificationsMock.show).toHaveBeenCalledTimes(1);
expect(notificationsMock.update.mock.calls[0][0]).toMatchObject({
id: taskToastId("t1"),
title: "Task input_required",
});
});
it("updates then hides the toast when the task finishes", () => {
const h = harness(fakeClient());
h.statusChange("t1", "working");
h.statusChange("t1", "completed");
expect(notificationsMock.update).toHaveBeenCalledTimes(1);
expect(notificationsMock.hide).toHaveBeenCalledWith(taskToastId("t1"));
});
it("shows nothing for a task first seen already terminal", () => {
const h = harness(fakeClient());
h.statusChange("t1", "completed");
expect(notificationsMock.show).not.toHaveBeenCalled();
expect(notificationsMock.update).not.toHaveBeenCalled();
});
it("re-shows a task whose toast the user closed", () => {
const h = harness(fakeClient());
h.statusChange("t1", "working");
const { onClose } = notificationsMock.show.mock.calls[0][0] as {
onClose: () => void;
};
onClose();
h.statusChange("t1", "working");
expect(notificationsMock.show).toHaveBeenCalledTimes(2);
});
});
describe("cancellation", () => {
it("converts a live status toast into the cancelled confirmation", () => {
const h = harness(fakeClient());
h.statusChange("t1", "working");
h.cancelled("t1");
expect(notificationsMock.update).toHaveBeenCalledTimes(1);
expect(notificationsMock.update.mock.calls[0][0]).toMatchObject({
id: taskToastId("t1"),
title: "Task cancelled",
});
// Dropped from the live set, so a trailing "cancelled" status tick must
// not re-show it.
notificationsMock.show.mockClear();
h.statusChange("t1", "cancelled");
expect(notificationsMock.show).not.toHaveBeenCalled();
});
it("shows a fresh confirmation when no status toast was open", () => {
const h = harness(fakeClient());
h.cancelled("t1");
expect(notificationsMock.show).toHaveBeenCalledTimes(1);
expect(notificationsMock.show.mock.calls[0][0]).toMatchObject({
title: "Task cancelled",
});
});
});
describe("the in-flight tool-call taskId", () => {
it("captures the id the client dispatches mid-call", () => {
const h = harness(fakeClient());
expect(h.api().activeToolCallTaskIdRef.current).toBeUndefined();
h.toolCallTask("t1");
expect(h.api().activeToolCallTaskIdRef.current).toBe("t1");
});
it("stops capturing after the client is swapped out", () => {
const first = fakeClient();
const h = harness(first);
h.swapClient(null);
act(() => {
first.dispatchEvent(
new CustomEvent("toolCallTaskUpdated", { detail: { taskId: "t9" } }),
);
});
expect(h.api().activeToolCallTaskIdRef.current).toBeUndefined();
});
});
describe("teardown", () => {
it("hides the live toasts when the client is swapped out", () => {
const h = harness(fakeClient());
h.statusChange("t1", "working");
h.swapClient(null);
expect(notificationsMock.hide).toHaveBeenCalledWith(taskToastId("t1"));
});
it("stops listening to the old client after a swap", () => {
const first = fakeClient();
const h = harness(first);
h.swapClient(fakeClient());
notificationsMock.show.mockClear();
act(() => {
first.dispatchEvent(
new CustomEvent("taskStatusChange", {
detail: { taskId: "t1", task: task("working") },
}),
);
});
expect(notificationsMock.show).not.toHaveBeenCalled();
});
it("hides the live toasts on unmount", () => {
const h = harness(fakeClient());
h.statusChange("t1", "working");
h.unmount();
expect(notificationsMock.hide).toHaveBeenCalledWith(taskToastId("t1"));
});
it("stops recording progress after the client is swapped out", () => {
const first = fakeClient();
const h = harness(first);
h.swapClient(null);
act(() => {
first.dispatchEvent(
new CustomEvent("requestorTaskProgress", {
detail: { taskId: "t1", progress: { progress: 1 } },
}),
);
});
expect(h.api().progressByTaskId).toEqual({});
});
});
});

View file

@ -0,0 +1,232 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { notifications } from "@mantine/notifications";
import type {
InspectorClient,
InspectorClientEventMap,
} from "@inspector/core/mcp/index.js";
import type { TypedEventGeneric } from "@inspector/core/mcp/typedEventTarget.js";
import type { TaskProgress } from "../components/groups/TaskCard/TaskCard";
import {
formatTaskToastMessage,
isTerminalTaskStatus,
TASK_CANCELLED_TOAST_AUTOCLOSE_MS,
taskToastColor,
taskToastId,
type TaskToastInput,
} from "../utils/toasts/taskToasts";
export interface TaskToasts {
/**
* Per-task progress, keyed by taskId. Fed to the Tasks screen so
* `TaskCard`'s progress bar renders for active tasks. Entries are pruned on
* terminal status and on cancel.
*/
progressByTaskId: Record<string, TaskProgress>;
/** Drop every progress entry. Stable; called from the session reset. */
resetTaskProgress: () => void;
/**
* The taskId of the in-flight task-augmented tool call, captured from the
* `toolCallTaskUpdated` event `callToolStream` dispatches. Lets the Tool
* detail panel's Cancel button cancel the underlying task on the server
* (#1455) instead of no-op'ing. `onCallTool` clears it at the start of every
* call, so an ordinary (non-task) call leaves it undefined and its Cancel
* doesn't fire a stray task cancellation. A ref (not state) because it is
* only read at the moment Cancel is clicked and must not re-render.
*/
activeToolCallTaskIdRef: React.RefObject<string | undefined>;
}
/**
* Surface live task status as per-task toasts — the v2 replacement for
* v1/v1.5's inline "Task status: … Polling…" line under the Tool Result
* (#1422, consistent with #1414) — and maintain the taskId → progress map the
* Tasks screen renders from.
*
* One toast per taskId, replaced per tick, dismissed on terminal status (which
* also prunes the task's progress entry) and on client teardown. The full
* status history still lives in the Protocol view.
*/
export function useTaskToasts(
inspectorClient: InspectorClient | null,
): TaskToasts {
// One live task-status toast per taskId, so each `notifications/tasks/status`
// tick replaces the existing toast rather than stacking a fresh one.
const taskToastIdsRef = useRef<Set<string>>(new Set());
const activeToolCallTaskIdRef = useRef<string | undefined>(undefined);
const [progressByTaskId, setProgressByTaskId] = useState<
Record<string, TaskProgress>
>({});
const resetTaskProgress = useCallback(() => {
setProgressByTaskId({});
}, []);
// Correlate task-call progress to the task it belongs to. `callToolStream`
// emits `requestorTaskProgress` tagged with the taskId it owns (the generic
// `progressNotification` carries only the caller's progressToken), so we
// build a taskId → progress map the Tasks screen reads to render each active
// task's progress bar.
useEffect(() => {
if (!inspectorClient) return;
const onTaskProgress = (
event: TypedEventGeneric<
InspectorClientEventMap,
"requestorTaskProgress"
>,
) => {
const { taskId, progress } = event.detail;
setProgressByTaskId((prev) => ({
...prev,
[taskId]: {
progress: progress.progress,
total: progress.total,
message: progress.message,
},
}));
};
inspectorClient.addEventListener("requestorTaskProgress", onTaskProgress);
return () => {
inspectorClient.removeEventListener(
"requestorTaskProgress",
onTaskProgress,
);
};
}, [inspectorClient]);
// Capture the in-flight task-augmented tool call's taskId so the detail
// panel's Cancel button can cancel the task on the server (#1455). The id
// only becomes known mid-call, when `callToolStream` dispatches
// `toolCallTaskUpdated`, so we stash the latest into the ref the cancel
// handler reads.
useEffect(() => {
if (!inspectorClient) return;
const onToolCallTaskUpdated = (
event: TypedEventGeneric<InspectorClientEventMap, "toolCallTaskUpdated">,
) => {
activeToolCallTaskIdRef.current = event.detail.taskId;
};
inspectorClient.addEventListener(
"toolCallTaskUpdated",
onToolCallTaskUpdated,
);
return () => {
inspectorClient.removeEventListener(
"toolCallTaskUpdated",
onToolCallTaskUpdated,
);
};
}, [inspectorClient]);
// Subscribes to `taskStatusChange` (server `notifications/tasks/status`) and
// `requestorTaskUpdated` (client-origin updates from `callToolStream`) — the
// same sources the managed task store consumes; `toolCallTaskUpdated` is
// redundant with `requestorTaskUpdated` so we skip it to avoid double-firing.
useEffect(() => {
if (!inspectorClient) return;
const liveToastIds = taskToastIdsRef.current;
const handleTaskUpdate = (taskId: string, task: TaskToastInput) => {
const id = taskToastId(taskId);
const terminal = isTerminalTaskStatus(task.status);
const title = `Task ${task.status}`;
const message = formatTaskToastMessage(task);
const color = taskToastColor(task.status);
if (terminal) {
// Drop the task's progress entry now that it can't change.
setProgressByTaskId((prev) => {
if (!(taskId in prev)) return prev;
const next = { ...prev };
delete next[taskId];
return next;
});
}
if (liveToastIds.has(id)) {
notifications.update({ id, title, message, color });
if (terminal) {
notifications.hide(id);
liveToastIds.delete(id);
}
return;
}
// A first sighting that's already terminal needs no toast at all.
if (terminal) return;
liveToastIds.add(id);
notifications.show({
id,
title,
message,
color,
autoClose: false,
onClose: () => liveToastIds.delete(id),
});
};
const onTaskStatusChange = (
event: TypedEventGeneric<InspectorClientEventMap, "taskStatusChange">,
) => {
handleTaskUpdate(event.detail.taskId, event.detail.task);
};
const onRequestorTaskUpdated = (
event: TypedEventGeneric<InspectorClientEventMap, "requestorTaskUpdated">,
) => {
handleTaskUpdate(event.detail.taskId, event.detail.task);
};
// A cancel goes out as `taskCancelled` (dispatched by cancelRequestorTask),
// not as a status notification, so it would otherwise leave the running
// task's live "Task <status>" toast hanging with no confirmation. Replace
// that toast (or show a fresh one) with a short "Task cancelled" toast, and
// prune the now-dead progress entry. Covers both cancel paths — the Tasks
// screen and the Tool detail panel — since both route through
// cancelRequestorTask (#1455).
const onTaskCancelled = (
event: TypedEventGeneric<InspectorClientEventMap, "taskCancelled">,
) => {
const { taskId } = event.detail;
setProgressByTaskId((prev) => {
if (!(taskId in prev)) return prev;
const next = { ...prev };
delete next[taskId];
return next;
});
const id = taskToastId(taskId);
const toast = {
id,
title: "Task cancelled",
message: "The task was cancelled.",
color: taskToastColor("cancelled"),
autoClose: TASK_CANCELLED_TOAST_AUTOCLOSE_MS,
};
if (liveToastIds.has(id)) {
// Convert the open status toast into the auto-closing confirmation; drop
// it from the live set so a trailing "cancelled" status tick (if the
// server sends one) doesn't re-show it.
notifications.update(toast);
liveToastIds.delete(id);
} else {
notifications.show(toast);
}
};
inspectorClient.addEventListener("taskStatusChange", onTaskStatusChange);
inspectorClient.addEventListener(
"requestorTaskUpdated",
onRequestorTaskUpdated,
);
inspectorClient.addEventListener("taskCancelled", onTaskCancelled);
return () => {
inspectorClient.removeEventListener(
"taskStatusChange",
onTaskStatusChange,
);
inspectorClient.removeEventListener(
"requestorTaskUpdated",
onRequestorTaskUpdated,
);
inspectorClient.removeEventListener("taskCancelled", onTaskCancelled);
// Hide any still-visible task toasts on client swap so they don't linger
// into the next session, then drop the bookkeeping (mirrors the progress-
// toast teardown).
liveToastIds.forEach((id) => notifications.hide(id));
liveToastIds.clear();
};
}, [inspectorClient]);
return { progressByTaskId, resetTaskProgress, activeToolCallTaskIdRef };
}

View file

@ -0,0 +1,42 @@
import { describe, expect, it } from "vitest";
import { act } from "react";
import { useComputedColorScheme } from "@mantine/core";
import { renderWithMantine } from "../test/renderWithMantine";
import { useThemeToggle } from "./useThemeToggle";
/**
* Renders the hook alongside the resolved scheme, so each assertion reads what
* the user would actually be looking at rather than what was stored.
*/
function harness(colorScheme?: "light" | "dark") {
let toggle: (() => void) | undefined;
let scheme = "";
function Probe() {
toggle = useThemeToggle().onToggleTheme;
scheme = useComputedColorScheme("light");
return null;
}
renderWithMantine(<Probe />, colorScheme ? { colorScheme } : undefined);
return {
scheme: () => scheme,
toggle: () => {
act(() => toggle?.());
},
};
}
describe("useThemeToggle", () => {
it("switches a light scheme to dark", () => {
const h = harness();
expect(h.scheme()).toBe("light");
h.toggle();
expect(h.scheme()).toBe("dark");
});
it("switches a dark scheme back to light", () => {
const h = harness("dark");
expect(h.scheme()).toBe("dark");
h.toggle();
expect(h.scheme()).toBe("light");
});
});

View file

@ -0,0 +1,19 @@
import { useCallback } from "react";
import { useComputedColorScheme, useMantineColorScheme } from "@mantine/core";
/**
* Light/dark toggle for the view header.
*
* Reads `useComputedColorScheme` rather than the stored preference, so "auto"
* resolves to the scheme the user is actually looking at — which is what makes
* a single toggle button flip the right way under the system default.
*/
export function useThemeToggle(): { onToggleTheme: () => void } {
const { setColorScheme } = useMantineColorScheme();
const computedColorScheme = useComputedColorScheme("light");
const isDark = computedColorScheme === "dark";
const onToggleTheme = useCallback(() => {
setColorScheme(isDark ? "light" : "dark");
}, [isDark, setColorScheme]);
return { onToggleTheme };
}

View file

@ -189,7 +189,7 @@ export default defineConfig(({ command }) => {
reporter: ["text", "html", "json-summary"],
// Whitelist of gated directories. Deliberate top-level-file omissions
// (every src *directory* below is gated):
// • `src/App.tsx` — a ~4.5k-line composition root at ~42% branch
// • `src/App.tsx` — a ~4.3k-line composition root at ~42% branch
// coverage; gating it is a dedicated testing/decomposition effort,
// not a whitelist tweak.
// • `src/main.tsx` / `src/index.ts` — the browser and bin bootstraps