Merge pull request #2152 from modelcontextprotocol/v2/chore/2129-use-session-ref

chore(web): collapse App.tsx's six session refs into useSessionRef
This commit is contained in:
Cliff Hall 2026-08-26 22:14:34 -04:00 • committed by GitHub
commit 6c9c149406
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 381 additions and 93 deletions

View file

@ -79,6 +79,7 @@ import { usePaginatedListsOverride } from "./hooks/usePaginatedListsOverride";
import { useValueChange } from "./hooks/useValueChange";
import { useThemeToggle } from "./hooks/useThemeToggle";
import { useTabUiState } from "./hooks/useTabUiState";
import { useSessionRef } from "./hooks/useSessionRef";
import { useInspectorStores } from "./hooks/useInspectorStores";
import { useExportActions } from "./hooks/useExportActions";
import { useProgressToasts } from "./hooks/useProgressToasts";
@ -191,6 +192,7 @@ import { EMPTY_SETTINGS } from "./utils/serverSettingsDefaults";
import {
isEmaStepUp,
isStepUpConfirmation,
type PendingStepUp,
type StepUpSource,
} from "./utils/stepUp";
import {
@ -542,18 +544,10 @@ function App() {
togglePinProtocol,
resetTabUiState,
} = useTabUiState();
const [pendingStepUp, setPendingStepUp] = useState<{
challenge: AuthChallenge;
authorizationUrl: URL;
serverId: string;
source: StepUpSource;
enterpriseManaged?: boolean;
} | null>(null);
const pendingStepUpRef = useRef(pendingStepUp);
const [pendingStepUp, setPendingStepUp] = useState<PendingStepUp | null>(
null,
);
const pendingStepUpRetryRef = useRef<(() => Promise<unknown>) | null>(null);
useEffect(() => {
pendingStepUpRef.current = pendingStepUp;
}, [pendingStepUp]);
const [reAuthBanner, setReAuthBanner] = useState<{
serverId: string;
message: string;
@ -575,27 +569,35 @@ function App() {
const [pendingReauth, setPendingReauth] = useState<PendingReauth | null>(
null,
);
const pendingReauthRef = useRef(pendingReauth);
useEffect(() => {
pendingReauthRef.current = pendingReauth;
}, [pendingReauth]);
// One stable ref mirroring every session value a long-lived callback needs
// to read *currently* rather than as of the render that created it. Declared
// here — ahead of its first reader — because a hook return is not provably
// stable to `react-hooks/exhaustive-deps`, so consumers list it and would hit
// the temporal dead zone if it came later.
const sessionRef = useSessionRef({
activeServerId,
servers,
inspectorClient,
pendingStepUp,
pendingReauth,
});
const reauthResumeInProgressRef = useRef(false);
const stepUpAuthorizeInProgressRef = useRef(false);
useEffect(() => {
const pending = pendingReauthRef.current;
const pending = sessionRef.current.pendingReauth;
if (pending && pending.serverId !== activeServerId) {
setPendingReauth(null);
}
const stepUp = pendingStepUpRef.current;
const stepUp = sessionRef.current.pendingStepUp;
if (stepUp && stepUp.serverId !== activeServerId) {
setPendingStepUp(null);
}
}, [activeServerId]);
}, [sessionRef, activeServerId]);
const trySetPendingStepUp = useCallback(
(next: NonNullable<typeof pendingStepUp>): boolean => {
if (pendingStepUpRef.current !== null) {
if (sessionRef.current.pendingStepUp !== null) {
notifications.show({
title: "Step-up authorization in progress",
message:
@ -608,7 +610,7 @@ function App() {
setPendingStepUp(next);
return true;
},
[],
[sessionRef],
);
// Handshake telemetry. `connectStartRef` is set at the "connecting" edge
@ -860,7 +862,7 @@ function App() {
// leave the control reading Off while every modern request still carries
// the level — visible on the auth-recovery path, which reconnects the same
// client instance rather than rebuilding it (#1629, #1797).
// `activeServerIdRef` is synced in a passive effect, so it still holds the
// The session ref is synced in a passive effect, so it still holds the
// outgoing server's id when this runs from `onDisconnect` — which is what
// lets the re-seed find its settings. Clearing that ref eagerly would take
// the no-server branch below and silently drop this to Off.
@ -868,8 +870,8 @@ function App() {
// is the common case (`mcp.json` written by hand, never opened in Server
// Settings), and there the default is right — it is what the seed and the
// client both use. Only "no server at all" means Off.
const activeServer = serversRef.current.find(
(s) => s.id === activeServerIdRef.current,
const activeServer = sessionRef.current.servers.find(
(s) => s.id === sessionRef.current.activeServerId,
);
setModernLogLevel(
activeServer
@ -882,7 +884,7 @@ function App() {
// Remembered scroll offsets are session-scoped too — drop them so the next
// session's screens start at the top (#1417).
clearScrollMemory();
}, [resetTabUiState, resetTaskProgress]);
}, [sessionRef, resetTabUiState, resetTaskProgress]);
// Reset activeServerId whenever the live session ends. Without this the
// other ServerCards stay `inert` after disconnect — ServerCard dims any
@ -966,26 +968,6 @@ function App() {
activeServer,
]);
// Mirror the active server's name into a ref so a mid-session failure toast
// can still name the server: a transport crash dispatches `disconnect`,
// which clears `activeServerId` (and thus `activeServer`) before the
// `lastError` effect below runs, so the ref is the only surviving handle.
const activeServerNameRef = useRef<string | undefined>(undefined);
const activeServerIdRef = useRef<string | undefined>(activeServerId);
const serversRef = useRef(servers);
// The live client, mirrored for the same reason: a settings write that
// outlives a disconnect/reconnect to the *same* server would otherwise push
// its value into the instance captured when it was issued — which has since
// been destroyed — while the replacement client, built from the stale list
// entry, keeps the value the write replaced (#2089).
const inspectorClientRef = useRef<InspectorClient | null>(inspectorClient);
useEffect(() => {
if (activeServer) activeServerNameRef.current = activeServer.name;
activeServerIdRef.current = activeServerId;
serversRef.current = servers;
inspectorClientRef.current = inspectorClient;
}, [activeServer, activeServerId, servers, inspectorClient]);
// Last connection-level error message, surfaced as `data-error-message` on
// the InspectorView header so an automated driver can read *why* a connect
// failed without scraping a transient toast. Cleared on the next connect
@ -1023,7 +1005,7 @@ function App() {
detail?: unknown,
options?: { reason?: AuthChallengeReason },
) => {
const server = serversRef.current.find((s) => s.id === serverId);
const server = sessionRef.current.servers.find((s) => s.id === serverId);
const message = reAuthBannerMessage({
serverName: server?.name,
detail:
@ -1044,7 +1026,7 @@ function App() {
message,
});
},
[],
[sessionRef],
);
// Surface a mid-session transport failure (stdio crash, SSE drop, HTTP 5xx)
@ -1054,13 +1036,13 @@ function App() {
// `lastError` clears at the next connecting edge, so each failure toasts once.
useEffect(() => {
if (!lastError) return;
const name = activeServerNameRef.current;
const name = sessionRef.current.activeServerName;
notifications.show({
title: name ? `Connection to "${name}" lost` : "Connection lost",
message: lastError,
color: "red",
});
}, [lastError]);
}, [sessionRef, lastError]);
// `config.type` is optional in the schema (a bare `command: ...`
// entry implies stdio), so we materialize the default here rather
@ -1098,7 +1080,7 @@ function App() {
};
const onAmbientAuthChallenge = (): void => {
const name = activeServerNameRef.current;
const name = sessionRef.current.activeServerName;
notifications.show({
title: name
? `Refreshing authorization for "${name}"`
@ -1123,7 +1105,7 @@ function App() {
onAmbientAuthChallenge,
);
};
}, [connectionStatus, inspectorClient]);
}, [sessionRef, connectionStatus, inspectorClient]);
const connectionInfoCanClearOAuth =
connectionStatus === "connected" &&
@ -1176,7 +1158,7 @@ function App() {
// applies to the instance that exists now.
const applyLiveServerSettings = useCallback(
(settings: InspectorServerSettings) => {
const client = inspectorClientRef.current;
const client = sessionRef.current.inspectorClient;
if (!client) return;
// Settings the managed state reads at notification time
// (auto-refresh-on-list-changed) take effect without a reconnect (#1444).
@ -1200,7 +1182,7 @@ function App() {
});
}
},
[fetchLogRef],
[sessionRef, fetchLogRef],
);
// Flush the pre-redirect Network log to backend storage, keyed by the OAuth
@ -1253,7 +1235,7 @@ function App() {
client?: InspectorClient;
}) => {
setReAuthBanner(null);
const server = serversRef.current.find((s) => s.id === serverId);
const server = sessionRef.current.servers.find((s) => s.id === serverId);
const preRedirectToast = oauthPreRedirectToastCopy(authKind, {
serverName: server?.name,
enterpriseManaged: server?.settings?.enterpriseManaged,
@ -1284,7 +1266,7 @@ function App() {
});
void oauthClient?.beginInteractiveAuthorization(authorizationUrl);
},
[inspectorClient, activeTab, ui, onBeforeOAuthRedirect],
[sessionRef, inspectorClient, activeTab, ui, onBeforeOAuthRedirect],
);
const tryApplyStoredAuthRecovery = useCallback(
@ -1320,7 +1302,7 @@ function App() {
authorizationUrl: URL;
source?: StepUpSource;
}) => {
const server = serversRef.current.find((s) => s.id === serverId);
const server = sessionRef.current.servers.find((s) => s.id === serverId);
if (isStepUpConfirmation(challenge, server)) {
trySetPendingStepUp({
challenge,
@ -1338,28 +1320,31 @@ function App() {
recoverySource: source,
});
},
[prepareOAuthRedirect, trySetPendingStepUp],
[sessionRef, prepareOAuthRedirect, trySetPendingStepUp],
);
const deferAmbientReauth = useCallback((pending: PendingReauth) => {
if (pendingReauthRef.current) {
notifications.show({
title: "Authorization update pending",
message:
"A new authorization request replaced the previous deferred recovery.",
color: "yellow",
autoClose: 5000,
});
} else {
notifications.show({
title: "Authorization pending",
message: "Return to this tab to continue authorization.",
color: "blue",
autoClose: 5000,
});
}
setPendingReauth(pending);
}, []);
const deferAmbientReauth = useCallback(
(pending: PendingReauth) => {
if (sessionRef.current.pendingReauth) {
notifications.show({
title: "Authorization update pending",
message:
"A new authorization request replaced the previous deferred recovery.",
color: "yellow",
autoClose: 5000,
});
} else {
notifications.show({
title: "Authorization pending",
message: "Return to this tab to continue authorization.",
color: "blue",
autoClose: 5000,
});
}
setPendingReauth(pending);
},
[sessionRef],
);
const handleCommandScopedAuthRecovery = useCallback(
async (
@ -1373,7 +1358,9 @@ function App() {
if (!inspectorClient) {
return false;
}
const server = serversRef.current.find((s) => s.id === options.serverId);
const server = sessionRef.current.servers.find(
(s) => s.id === options.serverId,
);
const stepUp =
error.emaStepUpConfirm ||
isStepUpConfirmation(error.authChallenge, server);
@ -1418,6 +1405,7 @@ function App() {
return false;
},
[
sessionRef,
inspectorClient,
prepareOAuthRedirect,
tryApplyStoredAuthRecovery,
@ -1505,7 +1493,7 @@ function App() {
if (connectionStatus !== "connected") {
return;
}
if (pending.serverId !== activeServerIdRef.current) {
if (pending.serverId !== sessionRef.current.activeServerId) {
return;
}
@ -1564,6 +1552,7 @@ function App() {
}
},
[
sessionRef,
inspectorClient,
connectionStatus,
tryApplyStoredAuthRecovery,
@ -1582,11 +1571,13 @@ function App() {
): void => {
void (async () => {
const { challenge, authorizationUrl } = event.detail;
const serverId = activeServerIdRef.current;
const serverId = sessionRef.current.activeServerId;
if (!serverId) {
return;
}
const server = serversRef.current.find((s) => s.id === serverId);
const server = sessionRef.current.servers.find(
(s) => s.id === serverId,
);
const authKind: OAuthResumeAuthKind = isStepUpConfirmation(
challenge,
server,
@ -1629,6 +1620,7 @@ function App() {
);
};
}, [
sessionRef,
connectionStatus,
inspectorClient,
tryApplyStoredAuthRecovery,
@ -1638,12 +1630,12 @@ function App() {
useEffect(() => {
return onBrowserTabVisible(() => {
const pending = pendingReauthRef.current;
const pending = sessionRef.current.pendingReauth;
if (pending) {
void resumePendingReauth(pending);
}
});
}, [resumePendingReauth]);
}, [sessionRef, resumePendingReauth]);
// Resume deferred background-tab recovery once the session reconnects.
useEffect(() => {
@ -1653,11 +1645,11 @@ function App() {
if (!isBrowserTabVisible()) {
return;
}
const pending = pendingReauthRef.current;
const pending = sessionRef.current.pendingReauth;
if (pending) {
void resumePendingReauth(pending);
}
}, [connectionStatus, inspectorClient, resumePendingReauth]);
}, [sessionRef, connectionStatus, inspectorClient, resumePendingReauth]);
useEffect(() => {
if (connectionStatus !== "connected" || !inspectorClient) {
@ -1665,7 +1657,7 @@ function App() {
}
const onOAuthError = (event: TypedEvent<"oauthError">): void => {
const serverId = activeServerIdRef.current;
const serverId = sessionRef.current.activeServerId;
if (!serverId) {
return;
}
@ -1676,7 +1668,7 @@ function App() {
return () => {
inspectorClient.removeEventListener("oauthError", onOAuthError);
};
}, [connectionStatus, inspectorClient, showReAuthBanner]);
}, [sessionRef, connectionStatus, inspectorClient, showReAuthBanner]);
// Detect an abandoned full-page OAuth redirect (snapshot left, no callback).
useEffect(() => {
@ -2183,7 +2175,7 @@ function App() {
// addServer/updateServer in the same async tick (e.g. the deep-link
// auto-connect IIFE) sees the freshly-mutated list, not the stale array
// captured by this callback's closure.
const target = serversRef.current.find((s) => s.id === id);
const target = sessionRef.current.servers.find((s) => s.id === id);
if (!target) return;
// Always rebuild the InspectorClient on a (re)connect so the latest
@ -2344,6 +2336,7 @@ function App() {
}
},
[
sessionRef,
activeServerId,
connectionStatus,
inspectorClient,
@ -2372,7 +2365,7 @@ function App() {
// 3. connect — row present AND its config already matches: connect.
// Splitting update and connect across renders (rather than awaiting both in
// one closure) is what makes the connect correct: `onToggleConnection` reads
// the target from `serversRef`, which an earlier passive effect syncs from
// the target from the session ref, which an earlier passive effect syncs from
// `servers` — so connecting only once `servers` reflects the updated config
// guarantees the client is built from the fresh transport, not the stale one.
// The OAuth callback path takes precedence; a deep link on `/oauth/callback`
@ -2456,7 +2449,9 @@ function App() {
// toggle direction here.
if (bannerKind === "lost_authorization_state") {
void (async () => {
const server = serversRef.current.find((s) => s.id === serverId);
const server = sessionRef.current.servers.find(
(s) => s.id === serverId,
);
if (server) {
try {
await clearServerOAuthState({
@ -2523,6 +2518,7 @@ function App() {
void onToggleConnection(serverId);
}, [
sessionRef,
reAuthBanner,
activeServerId,
connectionStatus,
@ -3136,7 +3132,7 @@ function App() {
activeServerId,
next.paginatedLists ?? false,
);
if (activeServerIdRef.current === activeServerId) {
if (sessionRef.current.activeServerId === activeServerId) {
applyLiveServerSettings(next);
}
};
@ -3195,7 +3191,7 @@ function App() {
activeServerId,
baseline.paginatedLists ?? false,
);
if (activeServerIdRef.current === activeServerId) {
if (sessionRef.current.activeServerId === activeServerId) {
applyLiveServerSettings(baseline);
}
notifications.show({
@ -3206,6 +3202,7 @@ function App() {
});
},
[
sessionRef,
servers,
activeServerId,
lastPersistedSettings,
@ -3493,7 +3490,7 @@ function App() {
const settled = write.landed(value);
if (!settled) return;
paginatedListsOverride.record(id, value.paginatedLists ?? false);
if (activeServerIdRef.current === id) {
if (sessionRef.current.activeServerId === id) {
applyLiveServerSettings(value);
}
};
@ -3522,7 +3519,7 @@ function App() {
const baseline = lastPersistedSettings.resolve(id);
if (baseline) {
paginatedListsOverride.record(id, baseline.paginatedLists ?? false);
if (activeServerIdRef.current === id) {
if (sessionRef.current.activeServerId === id) {
applyLiveServerSettings(baseline);
}
}
@ -3909,7 +3906,7 @@ function App() {
};
const handleStepUpCancel = () => {
const stepUp = pendingStepUpRef.current;
const stepUp = sessionRef.current.pendingStepUp;
setPendingStepUp(null);
pendingStepUpRetryRef.current = null;
if (!stepUp) {

View file

@ -0,0 +1,197 @@
import { describe, it, expect } from "vitest";
import { InspectorClient } from "@inspector/core/mcp/index.js";
import type { ServerEntry } from "@inspector/core/mcp/types.js";
import { renderWithMantine } from "../test/renderWithMantine";
import type { PendingReauth } from "../utils/pendingReauth";
import type { PendingStepUp } from "../utils/stepUp";
import {
useSessionRef,
type SessionRef,
type SessionValues,
} from "./useSessionRef";
const entry = (id: string, name = id): ServerEntry => ({
id,
name,
config: { type: "stdio", command: "node" },
connection: { status: "disconnected" },
});
/**
* A distinct client identity per call. `Object.create` does not run the
* constructor, so this is a real `InspectorClient` prototype chain with no
* transport or environment behind it — which is all that is needed here: the
* hook only stores the reference, and every assertion compares identity rather
* than reading a property off it.
*/
const client = (): InspectorClient =>
Object.create(InspectorClient.prototype) as InspectorClient;
const stepUp = (serverId: string): PendingStepUp => ({
serverId,
challenge: { reason: "unauthorized" },
authorizationUrl: new URL("https://as.example/authorize"),
source: "tool",
});
const reauth = (serverId: string): PendingReauth => ({
serverId,
challenge: { reason: "unauthorized" },
authorizationUrl: new URL("https://as.example/authorize"),
authKind: "reauth",
source: "tool",
});
const values = (over: Partial<SessionValues> = {}): SessionValues => ({
activeServerId: undefined,
servers: [],
inspectorClient: null,
pendingStepUp: null,
pendingReauth: null,
...over,
});
interface Harness {
ref: () => SessionRef;
/** Re-render with a new set of session values, as App.tsx does each render. */
update: (next: SessionValues) => void;
}
function harness(initial: SessionValues): Harness {
let latest: SessionRef | undefined;
function Probe({ v }: { v: SessionValues }) {
latest = useSessionRef(v);
return null;
}
const { rerender } = renderWithMantine(<Probe v={initial} />);
return {
ref: () => {
if (!latest) throw new Error("hook did not render");
return latest;
},
update: (next) => rerender(<Probe v={next} />),
};
}
describe("useSessionRef", () => {
it("seeds the snapshot from the first render's values", () => {
const servers = [entry("a")];
const c = client();
const h = harness(
values({ activeServerId: "a", servers, inspectorClient: c }),
);
const snapshot = h.ref().current;
expect(snapshot.activeServerId).toBe("a");
expect(snapshot.servers).toBe(servers);
expect(snapshot.inspectorClient).toBe(c);
expect(snapshot.pendingStepUp).toBeNull();
expect(snapshot.pendingReauth).toBeNull();
});
it("keeps one stable ref identity across renders", () => {
const h = harness(values());
const first = h.ref();
h.update(values({ activeServerId: "a", servers: [entry("a")] }));
expect(h.ref()).toBe(first);
});
it("mirrors every field on a later render", () => {
const h = harness(values());
const servers = [entry("a"), entry("b")];
const c = client();
const up = stepUp("a");
const re = reauth("b");
h.update(
values({
activeServerId: "b",
servers,
inspectorClient: c,
pendingStepUp: up,
pendingReauth: re,
}),
);
expect(h.ref().current).toMatchObject({
activeServerId: "b",
servers,
inspectorClient: c,
pendingStepUp: up,
pendingReauth: re,
});
});
it("clears a value that becomes null or undefined", () => {
const h = harness(
values({
activeServerId: "a",
servers: [entry("a")],
inspectorClient: client(),
pendingStepUp: stepUp("a"),
pendingReauth: reauth("a"),
}),
);
h.update(values());
expect(h.ref().current).toMatchObject({
activeServerId: undefined,
servers: [],
inspectorClient: null,
pendingStepUp: null,
pendingReauth: null,
});
});
describe("activeServerName", () => {
it("stays undefined when no server was ever active", () => {
// It is derived in the sync effect rather than seeded, so it is blank
// until a render actually resolves an active entry.
const h = harness(values({ servers: [entry("a", "Alpha")] }));
expect(h.ref().current.activeServerName).toBeUndefined();
});
it("is populated by the mount's own sync effect", () => {
const h = harness(
values({ activeServerId: "a", servers: [entry("a", "Alpha")] }),
);
expect(h.ref().current.activeServerName).toBe("Alpha");
});
it("tracks the active server's name", () => {
const h = harness(values());
h.update(values({ activeServerId: "a", servers: [entry("a", "Alpha")] }));
expect(h.ref().current.activeServerName).toBe("Alpha");
h.update(
values({
activeServerId: "b",
servers: [entry("a", "Alpha"), entry("b", "Beta")],
}),
);
expect(h.ref().current.activeServerName).toBe("Beta");
});
it("keeps the last active name when the active server goes away", () => {
// The case the stickiness exists for: a transport crash clears
// `activeServerId` before the failure toast is raised, and the name is
// the only surviving handle on which server died.
const h = harness(values());
h.update(values({ activeServerId: "a", servers: [entry("a", "Alpha")] }));
h.update(values({ servers: [entry("a", "Alpha")] }));
expect(h.ref().current.activeServerName).toBe("Alpha");
expect(h.ref().current.activeServerId).toBeUndefined();
});
it("keeps the last active name when the id names no entry", () => {
const h = harness(values());
h.update(values({ activeServerId: "a", servers: [entry("a", "Alpha")] }));
h.update(values({ activeServerId: "gone", servers: [] }));
expect(h.ref().current.activeServerName).toBe("Alpha");
});
it("follows a rename of the active server", () => {
const h = harness(values());
h.update(values({ activeServerId: "a", servers: [entry("a", "Alpha")] }));
h.update(
values({ activeServerId: "a", servers: [entry("a", "Renamed")] }),
);
expect(h.ref().current.activeServerName).toBe("Renamed");
});
});
});

View file

@ -0,0 +1,80 @@
import { useEffect, useRef } from "react";
import type { RefObject } from "react";
import type { InspectorClient } from "@inspector/core/mcp/index.js";
import type { ServerEntry } from "@inspector/core/mcp/types.js";
import type { PendingReauth } from "../utils/pendingReauth";
import type { PendingStepUp } from "../utils/stepUp";
/**
* The session values App.tsx's long-lived callbacks read *currently* rather
* than as of the render that created them.
*
* `activeServerName` is the one field with a rule of its own — see the hook.
*/
export interface SessionSnapshot {
/** Name of the last server that was active; see `useSessionRef`. */
activeServerName: string | undefined;
activeServerId: string | undefined;
servers: ServerEntry[];
/**
* The live client. Mirrored because a settings write that outlives a
* disconnect/reconnect to the *same* server would otherwise push its value
* into the instance captured when it was issued — which has since been
* destroyed — while the replacement client, built from the stale list entry,
* keeps the value the write replaced (#2089).
*/
inspectorClient: InspectorClient | null;
pendingStepUp: PendingStepUp | null;
pendingReauth: PendingReauth | null;
}
/** What the caller supplies each render; `activeServerName` is derived. */
export type SessionValues = Omit<SessionSnapshot, "activeServerName">;
/** A stable handle onto the latest {@link SessionSnapshot}. */
export type SessionRef = RefObject<SessionSnapshot>;
/**
* Mirrors the session values into a single stable ref, so a callback can read
* the current value without listing it as a dependency and being re-created on
* every change.
*
* This is the standard "latest ref" pattern, written once instead of once per
* value — App.tsx previously kept six such refs and three sync effects. It is
* **not** a store: nothing renders off it, nothing subscribes to it, and
* mutating it never schedules a render. It is also not the prop→state sync
* that AGENTS.md forbids, which is about `setState` in an effect; a ref write
* produces no render at all, which is the whole point.
*
* Because the ref identity never changes, a hook that takes it takes a
* *stable* dependency — which is what lets App.tsx's coupled clusters be
* extracted one at a time without each one dragging the others into its
* argument list.
*
* The single non-uniform field is `activeServerName`: it holds the name of the
* last server that *was* active and is never cleared. A transport crash
* dispatches `disconnect`, which clears `activeServerId` — and therefore the
* active entry — before the failure toast is raised, so the sticky name is the
* only surviving handle on which server just died.
*/
export function useSessionRef(values: SessionValues): SessionRef {
const ref = useRef<SessionSnapshot>({
activeServerName: undefined,
...values,
});
// Deliberately un-gated: this is a passive mirror, so re-assigning the same
// values on a render that changed none of them costs nothing and removes the
// dependency list as a place for a newly-added field to be forgotten.
useEffect(() => {
const activeServer = values.servers.find(
(s) => s.id === values.activeServerId,
);
if (activeServer) ref.current.activeServerName = activeServer.name;
ref.current.activeServerId = values.activeServerId;
ref.current.servers = values.servers;
ref.current.inspectorClient = values.inspectorClient;
ref.current.pendingStepUp = values.pendingStepUp;
ref.current.pendingReauth = values.pendingReauth;
});
return ref;
}

View file

@ -29,3 +29,17 @@ export function isStepUpConfirmation(
/** Which in-flight action opened the step-up modal (for scoped cancel UX). */
export type StepUpSource = "tool" | "prompt" | "resource" | "ambient" | "app";
/**
* The in-flight step-up prompt App.tsx holds while the user completes (or
* cancels) an interactive step-up authorization. Named here beside
* `StepUpSource` rather than inline in App.tsx so `useSessionRef`'s snapshot
* can name it without importing from the component that owns the state.
*/
export interface PendingStepUp {
challenge: AuthChallenge;
authorizationUrl: URL;
serverId: string;
source: StepUpSource;
enterpriseManaged?: boolean;
}