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`.
|
||||
|
||||
Nothing **enforces** the boundary: no path alias keys off it, and the coverage `include` in `clients/web/vite.config.ts` lists **both** `src/lib/**` and `src/utils/**`, so a move between them is coverage-neutral (this is why the refactor was gate-safe). It's a human-legible signal at import time, valuable in a codebase this test-heavy (the ≥90% per-file gate). Note that `include` is a **whitelist** — it names `components`/`hooks`/`theme`/`lib`/`utils`/`server` (plus the `core/*` runtime; `hooks` and `theme` were added in #1787), so a module placed **outside** those directories (`types/`, `App.tsx`, or a brand-new grab-bag) falls out of the ≥90 gate entirely, silently. The **deliberate, documented** top-level-file exceptions are `src/App.tsx` — a ~4.5k-line composition root at ~42% branch coverage (gating it is a dedicated testing/decomposition effort, not a whitelist tweak) — and the `src/main.tsx` / `src/index.ts` bootstraps (browser `createRoot` render and the bin `runWeb` re-export, the analog of `clients/cli`'s excluded `src/index.ts`). All three are called out in a comment on the `include` array itself rather than left silent. When adding a module, place it by the rule and keep it inside a gated directory; when it genuinely mixes both (e.g. `downloadFile` bundles DOM-side-effect helpers with a couple of pure ones), keep it whole on its dominant side (`lib`) rather than splitting hairs.
|
||||
Nothing **enforces** the boundary: no path alias keys off it, and the coverage `include` in `clients/web/vite.config.ts` lists **both** `src/lib/**` and `src/utils/**`, so a move between them is coverage-neutral (this is why the refactor was gate-safe). It's a human-legible signal at import time, valuable in a codebase this test-heavy (the ≥90% per-file gate). Note that `include` is a **whitelist** — it names `components`/`hooks`/`theme`/`lib`/`utils`/`server` (plus the `core/*` runtime; `hooks` and `theme` were added in #1787), so a module placed **outside** those directories (`types/`, `App.tsx`, or a brand-new grab-bag) falls out of the ≥90 gate entirely, silently. The **deliberate, documented** top-level-file exceptions are `src/App.tsx` — a ~4.3k-line composition root at ~42% branch coverage (gating it is a dedicated testing/decomposition effort, not a whitelist tweak) — and the `src/main.tsx` / `src/index.ts` bootstraps (browser `createRoot` render and the bin `runWeb` re-export, the analog of `clients/cli`'s excluded `src/index.ts`). All three are called out in a comment on the `include` array itself rather than left silent. When adding a module, place it by the rule and keep it inside a gated directory; when it genuinely mixes both (e.g. `downloadFile` bundles DOM-side-effect helpers with a couple of pure ones), keep it whole on its dominant side (`lib`) rather than splitting hairs.
|
||||
|
||||
## React instructions
|
||||
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
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"],
|
||||
// Whitelist of gated directories. Deliberate top-level-file omissions
|
||||
// (every src *directory* below is gated):
|
||||
// • `src/App.tsx` — a ~4.5k-line composition root at ~42% branch
|
||||
// • `src/App.tsx` — a ~4.3k-line composition root at ~42% branch
|
||||
// coverage; gating it is a dedicated testing/decomposition effort,
|
||||
// not a whitelist tweak.
|
||||
// • `src/main.tsx` / `src/index.ts` — the browser and bin bootstraps
|
||||
|
|
|
|||
Loading…
Reference in a new issue