diff --git a/clients/web/src/components/elements/ScreenStage/ScreenStage.test.tsx b/clients/web/src/components/elements/ScreenStage/ScreenStage.test.tsx
new file mode 100644
index 00000000..e21b7596
--- /dev/null
+++ b/clients/web/src/components/elements/ScreenStage/ScreenStage.test.tsx
@@ -0,0 +1,32 @@
+import { describe, it, expect } from "vitest";
+import { renderWithMantine, screen } from "../../../test/renderWithMantine";
+import { ScreenStage } from "./ScreenStage";
+
+describe("ScreenStage", () => {
+ it("renders its screen when active", () => {
+ renderWithMantine(
+
+ active screen
+ ,
+ );
+ expect(screen.getByText("active screen")).toBeInTheDocument();
+ });
+
+ it("renders nothing when inactive (outgoing screen unmounts)", () => {
+ renderWithMantine(
+
+ inactive screen
+ ,
+ );
+ expect(screen.queryByText("inactive screen")).toBeNull();
+ });
+
+ it("still renders its screen in the fill variant", () => {
+ renderWithMantine(
+
+ filled screen
+ ,
+ );
+ expect(screen.getByText("filled screen")).toBeInTheDocument();
+ });
+});
diff --git a/clients/web/src/components/elements/ScreenStage/ScreenStage.tsx b/clients/web/src/components/elements/ScreenStage/ScreenStage.tsx
new file mode 100644
index 00000000..23ac4d14
--- /dev/null
+++ b/clients/web/src/components/elements/ScreenStage/ScreenStage.tsx
@@ -0,0 +1,61 @@
+import type { ReactNode } from "react";
+import { Box, Transition } from "@mantine/core";
+
+/** Screen enter / exit durations for the shared `fade-up` stage transition. */
+export const SCREEN_ENTER_MS = 350;
+export const SCREEN_EXIT_MS = 250;
+
+export interface ScreenStageProps {
+ /** True when this stage's screen is the active one. */
+ active: boolean;
+ children: ReactNode;
+ /**
+ * Stretch the stage to fill its relative-positioned parent (adds `bottom: 0`).
+ * Needed where the screen relies on the parent for height (e.g. an inner
+ * ScrollArea in the monitoring column). Off by default so callers whose
+ * screens size themselves keep the top/left/right anchoring.
+ */
+ fill?: boolean;
+}
+
+/**
+ * Wraps a screen in a Mantine `fade-up` Transition so that, on switch, the
+ * incoming screen slides up and fades in while the outgoing one fades down and
+ * out — both mounted at once via absolute positioning. With Transition's default
+ * (`keepMounted={false}`) the outgoing screen unmounts after its exit animation,
+ * resetting any local screen state (search filters, scroll, expanded sections).
+ *
+ * Shared by the primary InspectorView pane and the pinned monitoring column so
+ * both use identical enter/exit motion (#1639-follow-up). Must be rendered
+ * inside a `position: relative` container.
+ */
+export function ScreenStage({
+ active,
+ children,
+ fill = false,
+}: ScreenStageProps) {
+ return (
+
+ {(styles) => (
+ // `style={styles}` is the runtime transition state from Mantine's
+ // Transition API — interpolated values, not static styling.
+
+ {children}
+
+ )}
+
+ );
+}
diff --git a/clients/web/src/components/groups/MonitoringScreen/MonitoringScreen.tsx b/clients/web/src/components/groups/MonitoringScreen/MonitoringScreen.tsx
index 577502e0..1667382e 100644
--- a/clients/web/src/components/groups/MonitoringScreen/MonitoringScreen.tsx
+++ b/clients/web/src/components/groups/MonitoringScreen/MonitoringScreen.tsx
@@ -1,6 +1,7 @@
import type { ReactNode } from "react";
import { Divider, Stack } from "@mantine/core";
import { MonitoringControls } from "../MonitoringControls/MonitoringControls";
+import { ScreenStage } from "../../elements/ScreenStage/ScreenStage";
export interface MonitoringScreenProps {
/** Available monitor tabs (Logs/Protocol/Network, filtered by capability). */
@@ -26,7 +27,10 @@ const ColumnLayout = Stack.withProps({
gap: 0,
});
+// Relative-positioned host so each tab's `ScreenStage` (absolutely positioned)
+// can cross-fade over it. `mih: 0` lets the inner ScrollArea bound its height.
const ScreenSlot = Stack.withProps({
+ pos: "relative",
flex: 1,
mih: 0,
gap: 0,
@@ -57,7 +61,13 @@ export function MonitoringScreen({
onClose={onClose}
/>
- {screens[value]}
+
+ {tabs.map((tab) => (
+
+ {screens[tab]}
+
+ ))}
+
);
}
diff --git a/clients/web/src/components/views/InspectorView/InspectorView.tsx b/clients/web/src/components/views/InspectorView/InspectorView.tsx
index b2a655d1..68264af4 100644
--- a/clients/web/src/components/views/InspectorView/InspectorView.tsx
+++ b/clients/web/src/components/views/InspectorView/InspectorView.tsx
@@ -8,7 +8,6 @@ import {
} from "react";
import {
AppShell,
- Box,
Flex,
Group,
Stack,
@@ -84,6 +83,7 @@ import {
type ConsoleUiState,
} from "../../screens/ConsoleScreen/ConsoleScreen";
import type { SortDirection } from "../../elements/SortToggle/SortToggle";
+import { ScreenStage } from "../../elements/ScreenStage/ScreenStage";
import { MonitoringScreen } from "../../groups/MonitoringScreen/MonitoringScreen";
import { ResizeHandle } from "../../elements/ResizeHandle/ResizeHandle";
import { getServerType } from "@inspector/core/mcp/config.js";
@@ -241,9 +241,6 @@ function serializeMonitorWidth(value: number): string {
return String(value);
}
-const SCREEN_ENTER_MS = 350;
-const SCREEN_EXIT_MS = 250;
-
// Relative-positioned wrapper for the absolutely-positioned `ScreenStage`
// children. `mih: "100%"` requires `AppShell.Main` to provide a definite
// height for the stack itself to fill — the absolute children render
@@ -306,36 +303,6 @@ const MonitorColumnGroup = Group.withProps({
flex: "0 0 auto",
});
-// Wraps each screen in a Mantine Transition. With Transition's default
-// (`keepMounted={false}`), the outgoing screen unmounts after its exit
-// animation — any local screen state (search filters, scroll position,
-// expanded sections) is reset on tab switch.
-function ScreenStage({
- active,
- children,
-}: {
- active: boolean;
- children: ReactNode;
-}) {
- return (
-
- {(styles) => (
- // `style={styles}` is the runtime transition state from Mantine's
- // Transition API — these are interpolated values, not static styling.
-
- {children}
-
- )}
-
- );
-}
-
export interface InspectorViewProps {
// Server list (static config; runtime connection state comes from the
// separate fields below and is merged into each card by this component).