chore(web): move App.tsx's module-level block into files (#2127)
Phase 0 of #2126. Lines 239-668 of clients/web/src/App.tsx were already at module scope — helpers, formatters, constants, and the toast subcomponents, none of which close over component state — so the whole block moves verbatim into files. App.tsx drops from 5,303 to 4,892 lines with no behavior change. Placement follows the lib-vs-utils rule in AGENTS.md: pure computation to utils/, anything touching the environment to lib/. EMPTY_SETTINGS and the two loose toast ids deliberately do NOT land in a new top-level src/constants.ts, which the issue suggested: the web coverage include is a whitelist naming components/hooks/theme/lib/utils/server, so a module there would have fallen out of the >=90 gate silently. They go under utils/ instead. ReAuthBannerBar is in the block but not in the issue's table — it was added after the issue was written (#2108). It moves to components/groups/ReAuthBanner/, beside the banner it wraps. Every new module is at 100% on all four coverage dimensions. Two v8 ignores, each justified in place: getAuthToken's typeof-window guard (a happy-dom inherent path) and replayProtocolRequest's provably-dead default case. Signed-off-by: cliffhall <cliff@futurescale.com>
This commit is contained in:
parent
ca12561f41
commit
b6ed5072f4
12 changed files with 1232 additions and 453 deletions
66
clients/web/src/utils/errorFormat.test.ts
Normal file
66
clients/web/src/utils/errorFormat.test.ts
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { errorCodeOf, errorMessage, formatErrorDetails } from "./errorFormat";
|
||||
|
||||
describe("errorMessage", () => {
|
||||
it("returns an Error's message", () => {
|
||||
expect(errorMessage(new Error("boom"))).toBe("boom");
|
||||
});
|
||||
|
||||
it("stringifies a non-Error", () => {
|
||||
expect(errorMessage("plain string")).toBe("plain string");
|
||||
expect(errorMessage(undefined)).toBe("undefined");
|
||||
expect(errorMessage(42)).toBe("42");
|
||||
});
|
||||
});
|
||||
|
||||
describe("errorCodeOf", () => {
|
||||
it("reads a numeric `code` off a protocol error", () => {
|
||||
expect(
|
||||
errorCodeOf(Object.assign(new Error("nope"), { code: -32602 })),
|
||||
).toBe(-32602);
|
||||
});
|
||||
|
||||
it("returns undefined for a plain Error", () => {
|
||||
expect(errorCodeOf(new Error("plain"))).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns undefined when `code` is not a number", () => {
|
||||
expect(errorCodeOf({ code: "-32602" })).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns undefined for non-objects", () => {
|
||||
expect(errorCodeOf(null)).toBeUndefined();
|
||||
expect(errorCodeOf("boom")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatErrorDetails", () => {
|
||||
it("pretty-prints code/message/data when a code is present", () => {
|
||||
const err = Object.assign(new Error("bad params"), {
|
||||
code: -32602,
|
||||
data: { field: "uri" },
|
||||
});
|
||||
expect(JSON.parse(formatErrorDetails(err))).toEqual({
|
||||
code: -32602,
|
||||
message: "bad params",
|
||||
data: { field: "uri" },
|
||||
});
|
||||
});
|
||||
|
||||
it("pretty-prints when only `data` is present", () => {
|
||||
expect(
|
||||
JSON.parse(formatErrorDetails({ data: [1, 2], message: "m" })),
|
||||
).toEqual({ message: "m", data: [1, 2] });
|
||||
});
|
||||
|
||||
it("falls back to the plain message for a bare Error", () => {
|
||||
expect(formatErrorDetails(new Error("just a message"))).toBe(
|
||||
"just a message",
|
||||
);
|
||||
});
|
||||
|
||||
it("falls back to String() for a non-object", () => {
|
||||
expect(formatErrorDetails("boom")).toBe("boom");
|
||||
expect(formatErrorDetails(null)).toBe("null");
|
||||
});
|
||||
});
|
||||
37
clients/web/src/utils/errorFormat.ts
Normal file
37
clients/web/src/utils/errorFormat.ts
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
// Error-shaping helpers shared by the toast bodies and the detail modals.
|
||||
// Deliberately duck-typed rather than coupled to the SDK's error classes: the
|
||||
// values reaching here come off a `catch`, so their only guaranteed type is
|
||||
// `unknown`.
|
||||
|
||||
export function errorMessage(err: unknown): string {
|
||||
return err instanceof Error ? err.message : String(err);
|
||||
}
|
||||
|
||||
// The numeric JSON-RPC code of a thrown protocol error (e.g. a `ProtocolError`
|
||||
// carrying `-32602`), or undefined for a plain Error. Duck-typed like
|
||||
// `formatErrorDetails` so we don't couple to the SDK's error class here — the
|
||||
// only consumer is the Tools error panel's unknown-tool (`-32602`) hint (#1632).
|
||||
export function errorCodeOf(err: unknown): number | undefined {
|
||||
if (err && typeof err === "object") {
|
||||
const code = (err as { code?: unknown }).code;
|
||||
if (typeof code === "number") return code;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Pretty-print a thrown error for the URL-elicitation details modal: a ProtocolError
|
||||
// carries a `code`/`data` worth showing alongside the message, so include them
|
||||
// when present; otherwise fall back to the plain message.
|
||||
export function formatErrorDetails(err: unknown): string {
|
||||
if (err && typeof err === "object") {
|
||||
const e = err as { code?: unknown; message?: unknown; data?: unknown };
|
||||
if (e.code !== undefined || e.data !== undefined) {
|
||||
return JSON.stringify(
|
||||
{ code: e.code, message: e.message, data: e.data },
|
||||
null,
|
||||
2,
|
||||
);
|
||||
}
|
||||
}
|
||||
return errorMessage(err);
|
||||
}
|
||||
28
clients/web/src/utils/serverSettingsDefaults.test.ts
Normal file
28
clients/web/src/utils/serverSettingsDefaults.test.ts
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
DEFAULT_MAX_FETCH_REQUESTS,
|
||||
DEFAULT_TASK_TTL_MS,
|
||||
} from "@inspector/core/mcp/types.js";
|
||||
import { EMPTY_SETTINGS } from "./serverSettingsDefaults";
|
||||
|
||||
describe("EMPTY_SETTINGS", () => {
|
||||
it("is an empty draft carrying the shared defaults", () => {
|
||||
expect(EMPTY_SETTINGS).toEqual({
|
||||
headers: [],
|
||||
env: [],
|
||||
metadata: {},
|
||||
connectionTimeout: 0,
|
||||
requestTimeout: 0,
|
||||
taskTtl: DEFAULT_TASK_TTL_MS,
|
||||
autoRefreshOnListChanged: false,
|
||||
paginatedLists: false,
|
||||
maxFetchRequests: DEFAULT_MAX_FETCH_REQUESTS,
|
||||
roots: [],
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps one object identity so React sees a stable reference", async () => {
|
||||
const again = await import("./serverSettingsDefaults");
|
||||
expect(again.EMPTY_SETTINGS).toBe(EMPTY_SETTINGS);
|
||||
});
|
||||
});
|
||||
24
clients/web/src/utils/serverSettingsDefaults.ts
Normal file
24
clients/web/src/utils/serverSettingsDefaults.ts
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
import type { InspectorServerSettings } from "@inspector/core/mcp/types.js";
|
||||
import {
|
||||
DEFAULT_MAX_FETCH_REQUESTS,
|
||||
DEFAULT_TASK_TTL_MS,
|
||||
} from "@inspector/core/mcp/types.js";
|
||||
|
||||
// Stable empty-shell for `InspectorServerSettings`. Used both as the
|
||||
// initial draft for a server entry that hasn't been touched yet, and as
|
||||
// the fallback the settings modal renders against when it's closed
|
||||
// (Mantine renders the dialog shell regardless of `opened`). Hoisted to
|
||||
// module scope so both call sites share the same object identity and so
|
||||
// React doesn't re-allocate on every render.
|
||||
export const EMPTY_SETTINGS: InspectorServerSettings = {
|
||||
headers: [],
|
||||
env: [],
|
||||
metadata: {},
|
||||
connectionTimeout: 0,
|
||||
requestTimeout: 0,
|
||||
taskTtl: DEFAULT_TASK_TTL_MS,
|
||||
autoRefreshOnListChanged: false,
|
||||
paginatedLists: false,
|
||||
maxFetchRequests: DEFAULT_MAX_FETCH_REQUESTS,
|
||||
roots: [],
|
||||
};
|
||||
45
clients/web/src/utils/stepUp.test.ts
Normal file
45
clients/web/src/utils/stepUp.test.ts
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import type { AuthChallenge } from "@inspector/core/auth/challenge.js";
|
||||
import type { ServerEntry } from "@inspector/core/mcp/types.js";
|
||||
import { isEmaStepUp, isStepUpConfirmation } from "./stepUp";
|
||||
|
||||
const challenge = (
|
||||
reason: AuthChallenge["reason"] = "insufficient_scope",
|
||||
): AuthChallenge => ({ reason });
|
||||
|
||||
// Only `settings.enterpriseManaged` is read, so the rest of a ServerEntry is
|
||||
// irrelevant here — build the narrow shape the adapters project from.
|
||||
const server = (enterpriseManaged?: boolean): ServerEntry =>
|
||||
({ settings: { enterpriseManaged } }) as ServerEntry;
|
||||
|
||||
describe("isEmaStepUp", () => {
|
||||
it("is true for insufficient_scope on an enterprise-managed server", () => {
|
||||
expect(isEmaStepUp(challenge(), server(true))).toBe(true);
|
||||
});
|
||||
|
||||
it("is false when the server is not enterprise-managed", () => {
|
||||
expect(isEmaStepUp(challenge(), server(false))).toBe(false);
|
||||
});
|
||||
|
||||
it("is false when there is no server at all", () => {
|
||||
expect(isEmaStepUp(challenge(), undefined)).toBe(false);
|
||||
});
|
||||
|
||||
it("is false for a challenge that is not a scope step-up", () => {
|
||||
expect(isEmaStepUp(challenge("unauthorized"), server(true))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isStepUpConfirmation", () => {
|
||||
it("covers both the standard and the enterprise-managed step-up", () => {
|
||||
expect(isStepUpConfirmation(challenge(), server(true))).toBe(true);
|
||||
expect(isStepUpConfirmation(challenge(), server(false))).toBe(true);
|
||||
expect(isStepUpConfirmation(challenge(), undefined)).toBe(true);
|
||||
});
|
||||
|
||||
it("is false for a challenge that is not a scope step-up", () => {
|
||||
expect(isStepUpConfirmation(challenge("unauthorized"), server(true))).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
});
|
||||
31
clients/web/src/utils/stepUp.ts
Normal file
31
clients/web/src/utils/stepUp.ts
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
import type { AuthChallenge } from "@inspector/core/auth/challenge.js";
|
||||
import {
|
||||
isEmaStepUp as isCoreEmaStepUp,
|
||||
isStepUpConfirmation as isCoreStepUpConfirmation,
|
||||
} from "@inspector/core/auth/oauthUx.js";
|
||||
import type { ServerEntry } from "@inspector/core/mcp/types.js";
|
||||
|
||||
// Thin adapters over core's step-up predicates: the web client holds a
|
||||
// `ServerEntry` where core takes only the enterprise-managed settings, so these
|
||||
// project one onto the other at the single place App.tsx asks the question.
|
||||
|
||||
export function isEmaStepUp(
|
||||
challenge: AuthChallenge,
|
||||
server: ServerEntry | undefined,
|
||||
): boolean {
|
||||
return isCoreEmaStepUp(challenge, {
|
||||
enterpriseManaged: server?.settings?.enterpriseManaged,
|
||||
});
|
||||
}
|
||||
|
||||
export function isStepUpConfirmation(
|
||||
challenge: AuthChallenge,
|
||||
server: ServerEntry | undefined,
|
||||
): boolean {
|
||||
return isCoreStepUpConfirmation(challenge, {
|
||||
enterpriseManaged: server?.settings?.enterpriseManaged,
|
||||
});
|
||||
}
|
||||
|
||||
/** Which in-flight action opened the step-up modal (for scoped cancel UX). */
|
||||
export type StepUpSource = "tool" | "prompt" | "resource" | "ambient" | "app";
|
||||
52
clients/web/src/utils/toasts/progressToasts.test.ts
Normal file
52
clients/web/src/utils/toasts/progressToasts.test.ts
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
formatProgressToastMessage,
|
||||
PROGRESS_TOAST_AUTOCLOSE_MS,
|
||||
progressToastId,
|
||||
} from "./progressToasts";
|
||||
|
||||
describe("PROGRESS_TOAST_AUTOCLOSE_MS", () => {
|
||||
it("lingers a few seconds after the last tick", () => {
|
||||
expect(PROGRESS_TOAST_AUTOCLOSE_MS).toBe(5000);
|
||||
});
|
||||
});
|
||||
|
||||
describe("progressToastId", () => {
|
||||
it("keys by the progress token", () => {
|
||||
expect(progressToastId("abc")).toBe("progress-abc");
|
||||
expect(progressToastId(7)).toBe("progress-7");
|
||||
});
|
||||
|
||||
it("shares one id when the server sends no token", () => {
|
||||
expect(progressToastId(undefined)).toBe("progress-default");
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatProgressToastMessage", () => {
|
||||
it("renders the fraction and percentage when a total is present", () => {
|
||||
expect(formatProgressToastMessage({ progress: 1, total: 4 })).toBe(
|
||||
"1 / 4 (25%)",
|
||||
);
|
||||
});
|
||||
|
||||
it("prefixes the server's message when present", () => {
|
||||
expect(
|
||||
formatProgressToastMessage({
|
||||
progress: 3,
|
||||
total: 4,
|
||||
message: "Indexing",
|
||||
}),
|
||||
).toBe("Indexing — 3 / 4 (75%)");
|
||||
});
|
||||
|
||||
it("omits the fraction when there is no total", () => {
|
||||
expect(formatProgressToastMessage({ progress: 9 })).toBe("9");
|
||||
expect(
|
||||
formatProgressToastMessage({ progress: 9, message: "Working" }),
|
||||
).toBe("Working — 9");
|
||||
});
|
||||
|
||||
it("omits the fraction when the total is zero (no divide-by-zero)", () => {
|
||||
expect(formatProgressToastMessage({ progress: 0, total: 0 })).toBe("0");
|
||||
});
|
||||
});
|
||||
29
clients/web/src/utils/toasts/progressToasts.ts
Normal file
29
clients/web/src/utils/toasts/progressToasts.ts
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
import type { Progress, ProgressToken } from "@modelcontextprotocol/client";
|
||||
|
||||
// How long a progress toast lingers after its last tick. Each new tick on the
|
||||
// same progress stream resets this window (via `notifications.update`), so a
|
||||
// steady stream keeps one toast alive; the toast clears a few seconds after
|
||||
// progress stops (i.e. the call finished or went quiet).
|
||||
export const PROGRESS_TOAST_AUTOCLOSE_MS = 5000;
|
||||
|
||||
// Stable toast id for a progress stream. Notifications keyed by this id are
|
||||
// replaced (not stacked) so a chatty server updates one toast per stream
|
||||
// rather than flooding the corner. The injected `progressToken` correlates a
|
||||
// stream with the request that triggered it; when absent (the common case —
|
||||
// the inspector doesn't expose a caller token), all ticks share one toast.
|
||||
export function progressToastId(token: ProgressToken | undefined): string {
|
||||
return `progress-${String(token ?? "default")}`;
|
||||
}
|
||||
|
||||
// One-line toast body: "<message> — <progress> / <total> (NN%)". The fraction
|
||||
// and percentage are omitted when the server sends no `total`.
|
||||
export function formatProgressToastMessage(
|
||||
detail: Progress & { progressToken?: ProgressToken },
|
||||
): string {
|
||||
const { progress, total, message } = detail;
|
||||
const ratio =
|
||||
total !== undefined && total > 0
|
||||
? `${progress} / ${total} (${Math.round((progress / total) * 100)}%)`
|
||||
: `${progress}`;
|
||||
return message ? `${message} — ${ratio}` : ratio;
|
||||
}
|
||||
68
clients/web/src/utils/toasts/taskToasts.test.ts
Normal file
68
clients/web/src/utils/toasts/taskToasts.test.ts
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
formatTaskToastMessage,
|
||||
isTerminalTaskStatus,
|
||||
TASK_CANCELLED_TOAST_AUTOCLOSE_MS,
|
||||
taskToastColor,
|
||||
taskToastId,
|
||||
TERMINAL_TASK_STATUSES,
|
||||
} from "./taskToasts";
|
||||
|
||||
describe("TASK_CANCELLED_TOAST_AUTOCLOSE_MS", () => {
|
||||
it("auto-dismisses the one-shot confirmation", () => {
|
||||
expect(TASK_CANCELLED_TOAST_AUTOCLOSE_MS).toBe(5000);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isTerminalTaskStatus", () => {
|
||||
it("is true for every terminal status", () => {
|
||||
for (const status of TERMINAL_TASK_STATUSES) {
|
||||
expect(isTerminalTaskStatus(status)).toBe(true);
|
||||
}
|
||||
expect([...TERMINAL_TASK_STATUSES].sort()).toEqual([
|
||||
"cancelled",
|
||||
"completed",
|
||||
"failed",
|
||||
]);
|
||||
});
|
||||
|
||||
it("is false for a status that can still change", () => {
|
||||
expect(isTerminalTaskStatus("working")).toBe(false);
|
||||
expect(isTerminalTaskStatus("input_required")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("taskToastId", () => {
|
||||
it("keys one toast per task", () => {
|
||||
expect(taskToastId("t1")).toBe("task-t1");
|
||||
expect(taskToastId("t2")).not.toBe(taskToastId("t1"));
|
||||
});
|
||||
});
|
||||
|
||||
describe("taskToastColor", () => {
|
||||
it("mirrors the TaskStatusBadge mapping", () => {
|
||||
expect(taskToastColor("completed")).toBe("green");
|
||||
expect(taskToastColor("failed")).toBe("red");
|
||||
expect(taskToastColor("cancelled")).toBe("gray");
|
||||
expect(taskToastColor("input_required")).toBe("yellow");
|
||||
});
|
||||
|
||||
it("falls back to blue for an in-flight status", () => {
|
||||
expect(taskToastColor("working")).toBe("blue");
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatTaskToastMessage", () => {
|
||||
it("prefers the server's statusMessage", () => {
|
||||
expect(
|
||||
formatTaskToastMessage({
|
||||
status: "working",
|
||||
statusMessage: "Step 2 of 3",
|
||||
}),
|
||||
).toBe("Step 2 of 3");
|
||||
});
|
||||
|
||||
it("falls back to naming the status", () => {
|
||||
expect(formatTaskToastMessage({ status: "failed" })).toBe("Task failed");
|
||||
});
|
||||
});
|
||||
51
clients/web/src/utils/toasts/taskToasts.ts
Normal file
51
clients/web/src/utils/toasts/taskToasts.ts
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
import type { Task } from "@modelcontextprotocol/client";
|
||||
|
||||
// A task cancellation is a one-shot confirmation (unlike the live status toast,
|
||||
// which stays open while the task runs), so it auto-dismisses after a moment.
|
||||
export const TASK_CANCELLED_TOAST_AUTOCLOSE_MS = 5000;
|
||||
|
||||
// Terminal task states — once a task reaches one of these it can't change, so
|
||||
// its toast is dismissed and its per-task progress entry is pruned.
|
||||
export const TERMINAL_TASK_STATUSES: ReadonlySet<Task["status"]> = new Set([
|
||||
"completed",
|
||||
"failed",
|
||||
"cancelled",
|
||||
]);
|
||||
|
||||
export function isTerminalTaskStatus(status: Task["status"]): boolean {
|
||||
return TERMINAL_TASK_STATUSES.has(status);
|
||||
}
|
||||
|
||||
// Stable toast id per task so live status updates replace one toast rather than
|
||||
// stacking a fresh one per `notifications/tasks/status` tick.
|
||||
export function taskToastId(taskId: string): string {
|
||||
return `task-${taskId}`;
|
||||
}
|
||||
|
||||
// Toast color per task status — mirrors TaskStatusBadge's mapping so the toast
|
||||
// and the Tasks-screen badge read consistently.
|
||||
export function taskToastColor(status: Task["status"]): string {
|
||||
switch (status) {
|
||||
case "completed":
|
||||
return "green";
|
||||
case "failed":
|
||||
return "red";
|
||||
case "cancelled":
|
||||
return "gray";
|
||||
case "input_required":
|
||||
return "yellow";
|
||||
default:
|
||||
return "blue";
|
||||
}
|
||||
}
|
||||
|
||||
// The subset of Task fields the toast layer reads. Both task-event payloads —
|
||||
// the server `taskStatusChange` (full Task) and the client-origin
|
||||
// `requestorTaskUpdated` (Task with optional createdAt) — satisfy this.
|
||||
export type TaskToastInput = Pick<Task, "status"> & { statusMessage?: string };
|
||||
|
||||
// One-line toast body: the task's `statusMessage` when present, else a short
|
||||
// fallback naming the status. The title carries the status itself.
|
||||
export function formatTaskToastMessage(task: TaskToastInput): string {
|
||||
return task.statusMessage ?? `Task ${task.status}`;
|
||||
}
|
||||
21
clients/web/src/utils/toasts/toastIds.test.ts
Normal file
21
clients/web/src/utils/toasts/toastIds.test.ts
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
bodyDroppedToastId,
|
||||
CLIENT_CONFIG_LOAD_ERROR_NOTIFICATION_ID,
|
||||
} from "./toastIds";
|
||||
|
||||
describe("bodyDroppedToastId", () => {
|
||||
it("keys the toast per server so a storm updates one toast", () => {
|
||||
expect(bodyDroppedToastId("srv-1")).toBe("fetch-body-dropped-srv-1");
|
||||
expect(bodyDroppedToastId("srv-1")).toBe(bodyDroppedToastId("srv-1"));
|
||||
expect(bodyDroppedToastId("srv-2")).not.toBe(bodyDroppedToastId("srv-1"));
|
||||
});
|
||||
});
|
||||
|
||||
describe("CLIENT_CONFIG_LOAD_ERROR_NOTIFICATION_ID", () => {
|
||||
it("is a stable id", () => {
|
||||
expect(CLIENT_CONFIG_LOAD_ERROR_NOTIFICATION_ID).toBe(
|
||||
"client-config-load-error",
|
||||
);
|
||||
});
|
||||
});
|
||||
14
clients/web/src/utils/toasts/toastIds.ts
Normal file
14
clients/web/src/utils/toasts/toastIds.ts
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
// Toast ids that aren't owned by a feature-specific formatter module. The
|
||||
// progress and task ids live beside their message formatters in
|
||||
// `progressToasts.ts` / `taskToasts.ts`; these two have no formatter of their
|
||||
// own, so they land here.
|
||||
|
||||
// Stable toast id for the "response body dropped" warning, keyed per server so
|
||||
// a request storm updates one persistent toast rather than stacking thousands
|
||||
// (the drop event can fire rapidly). Mirrors the progress-toast dedupe pattern.
|
||||
export function bodyDroppedToastId(serverId: string): string {
|
||||
return `fetch-body-dropped-${serverId}`;
|
||||
}
|
||||
|
||||
export const CLIENT_CONFIG_LOAD_ERROR_NOTIFICATION_ID =
|
||||
"client-config-load-error";
|
||||
Loading…
Reference in a new issue