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:
parent
78bad6a421
commit
e42968ba78
15 changed files with 2382 additions and 773 deletions
|
|
@ -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`.
|
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
|
## React instructions
|
||||||
|
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
317
clients/web/src/hooks/useExportActions.test.tsx
Normal file
317
clients/web/src/hooks/useExportActions.test.tsx
Normal 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,
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
220
clients/web/src/hooks/useExportActions.ts
Normal file
220
clients/web/src/hooks/useExportActions.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
302
clients/web/src/hooks/useInspectorStores.test.tsx
Normal file
302
clients/web/src/hooks/useInspectorStores.test.tsx
Normal 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" }]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
353
clients/web/src/hooks/useInspectorStores.ts
Normal file
353
clients/web/src/hooks/useInspectorStores.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
145
clients/web/src/hooks/useProgressToasts.test.tsx
Normal file
145
clients/web/src/hooks/useProgressToasts.test.tsx
Normal 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"),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
72
clients/web/src/hooks/useProgressToasts.ts
Normal file
72
clients/web/src/hooks/useProgressToasts.ts
Normal 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]);
|
||||||
|
}
|
||||||
100
clients/web/src/hooks/useTabUiState.test.tsx
Normal file
100
clients/web/src/hooks/useTabUiState.test.tsx
Normal 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,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
160
clients/web/src/hooks/useTabUiState.ts
Normal file
160
clients/web/src/hooks/useTabUiState.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
268
clients/web/src/hooks/useTaskToasts.test.tsx
Normal file
268
clients/web/src/hooks/useTaskToasts.test.tsx
Normal 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({});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
232
clients/web/src/hooks/useTaskToasts.ts
Normal file
232
clients/web/src/hooks/useTaskToasts.ts
Normal 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 };
|
||||||
|
}
|
||||||
42
clients/web/src/hooks/useThemeToggle.test.tsx
Normal file
42
clients/web/src/hooks/useThemeToggle.test.tsx
Normal 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");
|
||||||
|
});
|
||||||
|
});
|
||||||
19
clients/web/src/hooks/useThemeToggle.ts
Normal file
19
clients/web/src/hooks/useThemeToggle.ts
Normal 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 };
|
||||||
|
}
|
||||||
|
|
@ -189,7 +189,7 @@ export default defineConfig(({ command }) => {
|
||||||
reporter: ["text", "html", "json-summary"],
|
reporter: ["text", "html", "json-summary"],
|
||||||
// Whitelist of gated directories. Deliberate top-level-file omissions
|
// Whitelist of gated directories. Deliberate top-level-file omissions
|
||||||
// (every src *directory* below is gated):
|
// (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,
|
// coverage; gating it is a dedicated testing/decomposition effort,
|
||||||
// not a whitelist tweak.
|
// not a whitelist tweak.
|
||||||
// • `src/main.tsx` / `src/index.ts` — the browser and bin bootstraps
|
// • `src/main.tsx` / `src/index.ts` — the browser and bin bootstraps
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue