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

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

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

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

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

Signed-off-by: cliffhall <cliff@futurescale.com>
This commit is contained in:
cliffhall 2026-08-26 19:42:16 -04:00
commit e42968ba78

View file

@ -0,0 +1,72 @@
import { useEffect, useRef } from "react";
import { notifications } from "@mantine/notifications";
import type {
InspectorClient,
InspectorClientEventMap,
} from "@inspector/core/mcp/index.js";
import type { TypedEventGeneric } from "@inspector/core/mcp/typedEventTarget.js";
import {
formatProgressToastMessage,
PROGRESS_TOAST_AUTOCLOSE_MS,
progressToastId,
} from "../utils/toasts/progressToasts";
/**
* Surface incoming `notifications/progress` as toasts so the user can watch a
* long-running tool's progress while staying on the tool view — the v2
* replacement for v1's always-visible "Server Notifications" shelf (#1414).
*
* The full notification history still lives in the Protocol tab; these toasts
* are the at-a-glance, in-context signal. Toasts are keyed by progress stream
* (see `progressToastId`) and replaced per tick, so a chatty server updates one
* toast rather than stacking one per tick.
*/
export function useProgressToasts(inspectorClient: InspectorClient | null) {
// Which progress streams currently have a live toast. Entries are removed
// when their toast closes (auto-dismiss or user). A ref (not state) because
// it is incidental bookkeeping that must not trigger re-renders.
const progressToastIdsRef = useRef<Set<string>>(new Set());
useEffect(() => {
if (!inspectorClient) return;
const liveToastIds = progressToastIdsRef.current;
const onProgress = (
event: TypedEventGeneric<InspectorClientEventMap, "progressNotification">,
) => {
const detail = event.detail;
const id = progressToastId(detail.progressToken);
const message = formatProgressToastMessage(detail);
if (liveToastIds.has(id)) {
notifications.update({
id,
title: "Tool progress",
message,
color: "blue",
autoClose: PROGRESS_TOAST_AUTOCLOSE_MS,
});
return;
}
liveToastIds.add(id);
notifications.show({
id,
title: "Tool progress",
message,
color: "blue",
autoClose: PROGRESS_TOAST_AUTOCLOSE_MS,
onClose: () => liveToastIds.delete(id),
});
};
inspectorClient.addEventListener("progressNotification", onProgress);
return () => {
inspectorClient.removeEventListener("progressNotification", onProgress);
// Dismiss any still-visible progress toasts when the client is swapped
// out, then drop the stream bookkeeping. Hiding them (rather than letting
// them auto-close up to PROGRESS_TOAST_AUTOCLOSE_MS later) keeps a stale
// "Tool progress" toast from lingering into the next session, and avoids
// a race where the lingering toast's `onClose` would later delete an id
// from the *new* session's set and trigger a duplicate-id re-show.
liveToastIds.forEach((id) => notifications.hide(id));
liveToastIds.clear();
};
}, [inspectorClient]);
}