Skip to content
23 changes: 23 additions & 0 deletions clients/web/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,9 @@ import { parseDeepLink, deepLinkParseStatus } from "./utils/deepLink";
import type { DeepLink, DeepLinkParseStatus } from "./utils/deepLink";
import { AuthRecoveryRequiredError } from "@inspector/core/auth/challenge.js";
import { getAuthToken } from "./lib/authToken";
import { browserActiveSessionsApi } from "./lib/activeSessions";
import { useActiveSessions } from "./hooks/useActiveSessions";
import { ActiveSessionsModal } from "./components/groups/ActiveSessionsModal/ActiveSessionsModal";
import { messagesToLogEntries } from "./lib/protocolReplay";
import { EMPTY_SETTINGS } from "./utils/serverSettingsDefaults";
import { resolveOAuthClearIdentity } from "./utils/oauthClearKey";
Expand Down Expand Up @@ -1742,6 +1745,8 @@ function App() {
);

const openClientSettings = useCallback(() => setClientSettingsOpen(true), []);
// #2831: every backend session across tabs, so a leaked one can be ended.
const activeSessions = useActiveSessions(browserActiveSessionsApi);
const openConnectionInfo = useCallback(
() => setConnectionInfoModalOpen(true),
[],
Expand Down Expand Up @@ -1833,6 +1838,7 @@ function App() {
onActiveTabChange: setActiveTab,
onToggleTheme,
onOpenClientSettings: openClientSettings,
onOpenActiveSessions: activeSessions.open,
};

const connectionProps: ConnectionProps = {
Expand Down Expand Up @@ -2123,6 +2129,23 @@ function App() {
onEmaIdpLogout={logoutEmaIdp}
secretStorage={secretStorage}
/>
<ActiveSessionsModal
opened={activeSessions.opened}
sessions={activeSessions.sessions}
capturedAt={activeSessions.capturedAt}
loading={activeSessions.loading}
error={activeSessions.error}
terminatingId={activeSessions.terminatingId}
currentSessionId={
// Read only while the modal shows it, not on every App render.
activeSessions.opened
? inspectorClient?.getRemoteBackendSessionId()
: undefined
}
onRefresh={activeSessions.refresh}
onTerminate={activeSessions.terminate}
onClose={activeSessions.close}
/>
{initializeResult && activeServer && (
<ConnectionInfoModal
opened={connectionInfoModalOpen}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
import { AppShell } from "@mantine/core";
import type { Meta, StoryObj } from "@storybook/react-vite";
import { expect, fn, within } from "storybook/test";
import type { RemoteSessionSummary } from "@inspector/core/mcp/remote/index.js";
import {
ActiveSessionsModal,
type ActiveSessionsModalProps,
} from "./ActiveSessionsModal";

const CAPTURED_AT = Date.parse("2026-03-01T01:00:00.000Z");

const sessions: RemoteSessionSummary[] = [
{
sessionId: "3f2c9a1e-7b4d-4c1a-9e2f-0a1b2c3d4e5f",
serverName: "Everything",
transport: "stdio",
createdAt: "2026-03-01T00:30:00.000Z",
consumerAttached: true,
lastConsumerSeenAt: "2026-03-01T01:00:00.000Z",
queuedEvents: 0,
transportDead: false,
},
{
sessionId: "8d7e6f5a-4b3c-4d2e-8f1a-9b8c7d6e5f4a",
serverName: "Filesystem",
transport: "stdio",
createdAt: "2026-03-01T00:05:00.000Z",
consumerAttached: false,
lastConsumerSeenAt: "2026-03-01T00:59:20.000Z",
queuedEvents: 37,
transportDead: false,
},
{
sessionId: "1a2b3c4d-5e6f-4a7b-8c9d-0e1f2a3b4c5d",
serverName: "Remote API",
transport: "streamable-http",
createdAt: "2026-03-01T00:58:00.000Z",
consumerAttached: false,
lastConsumerSeenAt: null,
queuedEvents: 2,
transportDead: true,
},
];

function Render(args: ActiveSessionsModalProps) {
return (
<AppShell>
<AppShell.Main>
<ActiveSessionsModal {...args} />
</AppShell.Main>
</AppShell>
);
}

const meta: Meta<typeof ActiveSessionsModal> = {
title: "Groups/ActiveSessionsModal",
component: ActiveSessionsModal,
parameters: { layout: "fullscreen" },
render: Render,
args: {
opened: true,
sessions,
capturedAt: CAPTURED_AT,
loading: false,
currentSessionId: sessions[0]!.sessionId,
onRefresh: fn(),
onTerminate: fn(),
onClose: fn(),
},
};

export default meta;
type Story = StoryObj<typeof ActiveSessionsModal>;

export const WithSessions: Story = {
play: async ({ canvasElement, args }) => {
const body = within(canvasElement.ownerDocument.body);
await expect(await body.findByText("Active Sessions")).toBeInTheDocument();
await expect(body.getByText("This tab")).toBeInTheDocument();
await expect(body.getByText("Detached")).toBeInTheDocument();
body
.getByRole("button", {
name: `Terminate session ${sessions[1]!.sessionId}`,
})
.click();
await expect(args.onTerminate).toHaveBeenCalledWith(sessions[1]!.sessionId);
},
};

export const Terminating: Story = {
args: { terminatingId: sessions[1]!.sessionId },
};

export const Empty: Story = {
args: { sessions: [], currentSessionId: undefined },
play: async ({ canvasElement }) => {
const body = within(canvasElement.ownerDocument.body);
await expect(
await body.findByText("No active sessions."),
).toBeInTheDocument();
},
};

export const Failed: Story = {
args: {
sessions: [],
error: "Listing sessions failed: Authentication required",
},
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
import { describe, it, expect, vi } from "vitest";
import userEvent from "@testing-library/user-event";
import type { RemoteSessionSummary } from "@inspector/core/mcp/remote/index.js";
import { renderWithMantine, screen } from "../../../test/renderWithMantine";
import {
ActiveSessionsModal,
type ActiveSessionsModalProps,
} from "./ActiveSessionsModal";

const NOW = Date.parse("2026-03-01T01:00:00.000Z");

const attached: RemoteSessionSummary = {
sessionId: "sess-attached",
serverName: "Everything",
transport: "stdio",
createdAt: "2026-03-01T00:30:00.000Z",
consumerAttached: true,
lastConsumerSeenAt: "2026-03-01T01:00:00.000Z",
queuedEvents: 0,
transportDead: false,
};

const detached: RemoteSessionSummary = {
sessionId: "sess-detached",
transport: "streamable-http",
createdAt: "2026-03-01T00:00:00.000Z",
consumerAttached: false,
lastConsumerSeenAt: "2026-03-01T00:55:00.000Z",
queuedEvents: 12,
transportDead: false,
};

const closed: RemoteSessionSummary = {
sessionId: "sess-closed",
serverName: "Crashed",
transport: "stdio",
createdAt: "2026-03-01T00:59:00.000Z",
consumerAttached: false,
lastConsumerSeenAt: null,
queuedEvents: 2,
transportDead: true,
};

function renderModal(overrides: Partial<ActiveSessionsModalProps> = {}) {
const props: ActiveSessionsModalProps = {
opened: true,
sessions: [attached, detached, closed],
capturedAt: NOW,
loading: false,
onRefresh: vi.fn(),
onTerminate: vi.fn(),
onClose: vi.fn(),
...overrides,
};
renderWithMantine(<ActiveSessionsModal {...props} />);
return props;
}

describe("ActiveSessionsModal", () => {
it("renders nothing while closed", () => {
renderModal({ opened: false });
expect(screen.queryByText("Active Sessions")).not.toBeInTheDocument();
});

it("lists each session with its server, status, ages and queue", () => {
renderModal({ currentSessionId: "sess-attached" });
expect(screen.getByText("Active Sessions")).toBeInTheDocument();
expect(screen.getByText("Everything")).toBeInTheDocument();
expect(screen.getByText("Unnamed server")).toBeInTheDocument();
expect(screen.getByText("sess-detached")).toBeInTheDocument();
expect(screen.getByText("This tab")).toBeInTheDocument();
expect(screen.getByText("Attached")).toBeInTheDocument();
expect(screen.getByText("Detached")).toBeInTheDocument();
expect(screen.getByText("Closed")).toBeInTheDocument();
expect(screen.getByText("now")).toBeInTheDocument();
expect(screen.getByText("5m00s ago")).toBeInTheDocument();
expect(screen.getByText("never")).toBeInTheDocument();
expect(screen.getByText("1h00m ago")).toBeInTheDocument();
expect(screen.getByText("12")).toBeInTheDocument();
});

it("says so when there are no sessions", () => {
renderModal({ sessions: [] });
expect(screen.getByText("No active sessions.")).toBeInTheDocument();
});

it("shows a loading hint before any session is known", () => {
renderModal({ sessions: [], loading: true });
expect(screen.getByText("Loading…")).toBeInTheDocument();
});

it("shows the error", () => {
renderModal({ error: "Listing sessions failed: HTTP 500" });
expect(
screen.getByText("Listing sessions failed: HTTP 500"),
).toBeInTheDocument();
});

it("terminates the chosen session", async () => {
const user = userEvent.setup();
const props = renderModal();
await user.click(
screen.getByRole("button", { name: "Terminate session sess-detached" }),
);
expect(props.onTerminate).toHaveBeenCalledWith("sess-detached");
});

it("disables the other rows while one terminate is in flight", () => {
renderModal({ terminatingId: "sess-detached" });
expect(
screen.getByRole("button", { name: "Terminate session sess-attached" }),
).toBeDisabled();
});

it("refreshes on demand", async () => {
const user = userEvent.setup();
const props = renderModal();
await user.click(screen.getByRole("button", { name: "Refresh sessions" }));
expect(props.onRefresh).toHaveBeenCalled();
});
});
Loading
Loading