diff --git a/clients/web/src/components/elements/EmbeddableScrollArea/EmbeddableScrollArea.tsx b/clients/web/src/components/elements/EmbeddableScrollArea/EmbeddableScrollArea.tsx
index 814bc7f75..f934d0c66 100644
--- a/clients/web/src/components/elements/EmbeddableScrollArea/EmbeddableScrollArea.tsx
+++ b/clients/web/src/components/elements/EmbeddableScrollArea/EmbeddableScrollArea.tsx
@@ -8,7 +8,7 @@ const FULLSIZE_MAH =
export interface EmbeddableScrollAreaProps {
/**
- * True when rendered inside the pinned monitoring column (#1616): the scroll
+ * True when rendered inside the pinned monitoring sidebar (#1616): the scroll
* region fills its flex parent instead of using the viewport calc. A
* `flex:1 / mih:0` wrapper caps the inner `ScrollArea` at the space remaining
* below the column's controls (via `mah:100%`), so no viewport math is needed
@@ -36,7 +36,7 @@ const CONSTRAIN_CONTENT_STYLES = { content: { minWidth: 0 } } as const;
/**
* The scroll region shared by the Logs / Protocol / Network stream panels, which
- * render both full-size (their own tab) and embedded (the monitoring column).
+ * render both full-size (their own tab) and embedded (the monitoring sidebar).
* Centralizes the one layout difference between those two hosts.
*/
export function EmbeddableScrollArea({
diff --git a/clients/web/src/components/elements/ExpandToggle/ExpandToggle.tsx b/clients/web/src/components/elements/ExpandToggle/ExpandToggle.tsx
index b71cbabe6..d8965012e 100644
--- a/clients/web/src/components/elements/ExpandToggle/ExpandToggle.tsx
+++ b/clients/web/src/components/elements/ExpandToggle/ExpandToggle.tsx
@@ -1,4 +1,4 @@
-import { ActionIcon } from "@mantine/core";
+import { ActionIcon, Tooltip } from "@mantine/core";
import { RiCollapseVerticalLine, RiExpandVerticalLine } from "react-icons/ri";
export interface ExpandToggleProps {
@@ -18,14 +18,16 @@ export function ExpandToggle({ expanded, onToggle }: ExpandToggleProps) {
const Icon = expanded ? RiCollapseVerticalLine : RiExpandVerticalLine;
const label = expanded ? "Collapse" : "Expand";
return (
-
-
-
+
+
+
+
+
);
}
diff --git a/clients/web/src/components/elements/ListToggle/ListToggle.tsx b/clients/web/src/components/elements/ListToggle/ListToggle.tsx
index cd7a03365..b1cf69cd5 100644
--- a/clients/web/src/components/elements/ListToggle/ListToggle.tsx
+++ b/clients/web/src/components/elements/ListToggle/ListToggle.tsx
@@ -1,4 +1,4 @@
-import { ActionIcon } from "@mantine/core";
+import { ActionIcon, Tooltip } from "@mantine/core";
import { RiExpandVerticalLine, RiCollapseVerticalLine } from "react-icons/ri";
export interface ListToggleProps {
@@ -17,28 +17,32 @@ export function ListToggle({
if (variant === "subtle") {
return (
-
-
-
+
+
+
+
+
);
}
// `size={36}` matches the header's theme / client-settings ActionIcons so the
// toolbar's toggle reads as the same size icon button.
return (
-
-
-
+
+
+
+
+
);
}
diff --git a/clients/web/src/components/elements/LogEntry/LogEntry.stories.tsx b/clients/web/src/components/elements/LogEntry/LogEntry.stories.tsx
index 9dc8205d1..3465eef74 100644
--- a/clients/web/src/components/elements/LogEntry/LogEntry.stories.tsx
+++ b/clients/web/src/components/elements/LogEntry/LogEntry.stories.tsx
@@ -1,4 +1,5 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
+import { Box } from "@mantine/core";
import { LogEntry } from "./LogEntry";
const meta: Meta = {
@@ -78,3 +79,27 @@ export const WithJsonData: Story = {
},
},
};
+
+// The narrow monitoring-column layout (#1661): time/level/logger on the first
+// line, the (potentially long) message wrapping on the line below. Rendered in a
+// column-width box so the wrapping is visible.
+export const Compact: Story = {
+ args: {
+ compact: true,
+ entry: {
+ receivedAt: new Date("2026-03-29T20:18:26.000Z"),
+ params: {
+ level: "warning",
+ data: "Connection pool nearing capacity — 92 of 100 connections in use; consider raising the limit",
+ logger: "pool",
+ },
+ },
+ },
+ decorators: [
+ (Story) => (
+
+
+
+ ),
+ ],
+};
diff --git a/clients/web/src/components/elements/LogEntry/LogEntry.test.tsx b/clients/web/src/components/elements/LogEntry/LogEntry.test.tsx
index 0fa627fc9..e61d57314 100644
--- a/clients/web/src/components/elements/LogEntry/LogEntry.test.tsx
+++ b/clients/web/src/components/elements/LogEntry/LogEntry.test.tsx
@@ -36,4 +36,21 @@ describe("LogEntry", () => {
);
expect(container.textContent).toContain("info");
});
+
+ it("renders the compact two-line layout with time, level, logger, and message", () => {
+ renderWithMantine(
+ ,
+ );
+ // All four pieces still render in the compact variant.
+ expect(screen.getByText("warning")).toBeInTheDocument();
+ expect(screen.getByText("[transport]")).toBeInTheDocument();
+ expect(screen.getByText("compact message")).toBeInTheDocument();
+ });
});
diff --git a/clients/web/src/components/elements/LogEntry/LogEntry.tsx b/clients/web/src/components/elements/LogEntry/LogEntry.tsx
index 9f2c1e8e4..7587f9a39 100644
--- a/clients/web/src/components/elements/LogEntry/LogEntry.tsx
+++ b/clients/web/src/components/elements/LogEntry/LogEntry.tsx
@@ -1,4 +1,4 @@
-import { Group, Text } from "@mantine/core";
+import { Group, Stack, Text } from "@mantine/core";
import type {
LoggingLevel,
LoggingMessageNotification,
@@ -12,6 +12,13 @@ export interface LogEntryData {
export interface LogEntryProps {
entry: LogEntryData;
+ /**
+ * Compact two-line layout for the narrow monitoring sidebar (#1661): the
+ * timestamp, level, and logger sit on the first line and the message wraps
+ * onto the line below, so a long message isn't clipped by the column width.
+ * The default (false) is the single-line row used on the full Logs screen.
+ */
+ compact?: boolean;
}
const levelMessageColor: Record = {
@@ -51,18 +58,56 @@ const LoggerText = Text.withProps({
c: "dimmed",
});
-export function LogEntry({ entry }: LogEntryProps) {
+// Single-line message (full Logs screen): sits inline with the meta on one row.
+const MessageText = Text.withProps({
+ size: "sm",
+ ff: "monospace",
+});
+
+// Compact message (monitoring sidebar): wraps over-long lines within the narrow
+// column via the `consoleLine` variant instead of overflowing its width.
+const CompactMessageText = Text.withProps({
+ size: "sm",
+ ff: "monospace",
+ variant: "consoleLine",
+});
+
+// The compact meta row: timestamp + level + logger on one line above the
+// message. `wrap: nowrap` keeps them on a single line; the message wraps below.
+const MetaRow = Group.withProps({
+ gap: "xs",
+ wrap: "nowrap",
+ align: "center",
+});
+
+export function LogEntry({ entry, compact = false }: LogEntryProps) {
const { receivedAt, params } = entry;
const message = formatData(params.data);
+ const logger = params.logger ? (
+ {formatLogger(params.logger)}
+ ) : null;
+
+ if (compact) {
+ return (
+
+
+ {formatTimestamp(receivedAt)}
+
+ {logger}
+
+
+ {message}
+
+
+ );
+ }
return (
{formatTimestamp(receivedAt)}
- {params.logger && {formatLogger(params.logger)}}
-
- {message}
-
+ {logger}
+ {message}
);
}
diff --git a/clients/web/src/components/elements/MonitoringToggle/MonitoringToggle.stories.tsx b/clients/web/src/components/elements/MonitoringToggle/MonitoringToggle.stories.tsx
new file mode 100644
index 000000000..9541812e4
--- /dev/null
+++ b/clients/web/src/components/elements/MonitoringToggle/MonitoringToggle.stories.tsx
@@ -0,0 +1,32 @@
+import type { Meta, StoryObj } from "@storybook/react-vite";
+import { expect, fn, within } from "storybook/test";
+import { MonitoringToggle } from "./MonitoringToggle";
+
+const meta: Meta = {
+ title: "Elements/MonitoringToggle",
+ component: MonitoringToggle,
+ args: { open: false, onToggle: fn() },
+};
+
+export default meta;
+type Story = StoryObj;
+
+export const Closed: Story = {
+ args: { open: false },
+ play: async ({ canvasElement }) => {
+ const canvas = within(canvasElement);
+ expect(
+ canvas.getByRole("button", { name: "Open monitoring sidebar" }),
+ ).toBeInTheDocument();
+ },
+};
+
+export const Open: Story = {
+ args: { open: true },
+ play: async ({ canvasElement }) => {
+ const canvas = within(canvasElement);
+ expect(
+ canvas.getByRole("button", { name: "Close monitoring sidebar" }),
+ ).toBeInTheDocument();
+ },
+};
diff --git a/clients/web/src/components/elements/MonitoringToggle/MonitoringToggle.test.tsx b/clients/web/src/components/elements/MonitoringToggle/MonitoringToggle.test.tsx
new file mode 100644
index 000000000..baa1f3325
--- /dev/null
+++ b/clients/web/src/components/elements/MonitoringToggle/MonitoringToggle.test.tsx
@@ -0,0 +1,36 @@
+import { describe, it, expect, vi } from "vitest";
+import userEvent from "@testing-library/user-event";
+import { renderWithMantine, screen } from "../../../test/renderWithMantine";
+import { MonitoringToggle } from "./MonitoringToggle";
+
+describe("MonitoringToggle", () => {
+ it("renders an open-column button when closed", () => {
+ renderWithMantine();
+ expect(
+ screen.getByRole("button", { name: "Open monitoring sidebar" }),
+ ).toBeInTheDocument();
+ expect(
+ screen.queryByRole("button", { name: "Close monitoring sidebar" }),
+ ).toBeNull();
+ });
+
+ it("renders a close-column button when open", () => {
+ renderWithMantine();
+ expect(
+ screen.getByRole("button", { name: "Close monitoring sidebar" }),
+ ).toBeInTheDocument();
+ expect(
+ screen.queryByRole("button", { name: "Open monitoring sidebar" }),
+ ).toBeNull();
+ });
+
+ it("invokes onToggle when clicked", async () => {
+ const user = userEvent.setup();
+ const onToggle = vi.fn();
+ renderWithMantine();
+ await user.click(
+ screen.getByRole("button", { name: "Open monitoring sidebar" }),
+ );
+ expect(onToggle).toHaveBeenCalledTimes(1);
+ });
+});
diff --git a/clients/web/src/components/elements/MonitoringToggle/MonitoringToggle.tsx b/clients/web/src/components/elements/MonitoringToggle/MonitoringToggle.tsx
new file mode 100644
index 000000000..a7143a59f
--- /dev/null
+++ b/clients/web/src/components/elements/MonitoringToggle/MonitoringToggle.tsx
@@ -0,0 +1,40 @@
+import { ActionIcon, Tooltip } from "@mantine/core";
+import {
+ TbLayoutSidebarRightExpand,
+ TbLayoutSidebarRightCollapse,
+} from "react-icons/tb";
+
+export interface MonitoringToggleProps {
+ /** Whether the monitoring sidebar is currently open. */
+ open: boolean;
+ /** Open the sidebar when closed, close it when open. */
+ onToggle: () => void;
+}
+
+/**
+ * The single header affordance for the monitoring sidebar (#1661). It replaces
+ * the per-screen pin buttons, the server-list open-sidebar button, and the
+ * sidebar's own close button — one toggle, placed in the header to the right of
+ * the theme icon, that opens or closes the sidebar on demand. The glyph and
+ * label reflect the current state (expand when closed, collapse when open). The
+ * caller only mounts it when the sidebar is available (connected, or a failed
+ * connect attempt, on a wide viewport), so it never appears with nothing to
+ * toggle. `size={36}` matches the header's theme / client-settings ActionIcons.
+ */
+export function MonitoringToggle({ open, onToggle }: MonitoringToggleProps) {
+ const Icon = open ? TbLayoutSidebarRightCollapse : TbLayoutSidebarRightExpand;
+ const label = open ? "Close monitoring sidebar" : "Open monitoring sidebar";
+
+ return (
+
+
+
+
+
+ );
+}
diff --git a/clients/web/src/components/elements/PinColumnButton/PinColumnButton.stories.tsx b/clients/web/src/components/elements/PinColumnButton/PinColumnButton.stories.tsx
deleted file mode 100644
index 8d6b7fc33..000000000
--- a/clients/web/src/components/elements/PinColumnButton/PinColumnButton.stories.tsx
+++ /dev/null
@@ -1,21 +0,0 @@
-import type { Meta, StoryObj } from "@storybook/react-vite";
-import { expect, fn, within } from "storybook/test";
-import { PinColumnButton } from "./PinColumnButton";
-
-const meta: Meta = {
- title: "Elements/PinColumnButton",
- component: PinColumnButton,
- args: { onPin: fn() },
-};
-
-export default meta;
-type Story = StoryObj;
-
-export const Default: Story = {
- play: async ({ canvasElement }) => {
- const canvas = within(canvasElement);
- expect(
- canvas.getByRole("button", { name: "Pin as column" }),
- ).toBeInTheDocument();
- },
-};
diff --git a/clients/web/src/components/elements/PinColumnButton/PinColumnButton.test.tsx b/clients/web/src/components/elements/PinColumnButton/PinColumnButton.test.tsx
deleted file mode 100644
index 79ab1449b..000000000
--- a/clients/web/src/components/elements/PinColumnButton/PinColumnButton.test.tsx
+++ /dev/null
@@ -1,31 +0,0 @@
-import { describe, it, expect, vi } from "vitest";
-import userEvent from "@testing-library/user-event";
-import { renderWithMantine, screen } from "../../../test/renderWithMantine";
-import { PinColumnButton } from "./PinColumnButton";
-
-describe("PinColumnButton", () => {
- it("renders a labelled pin-as-column button", () => {
- renderWithMantine();
- expect(
- screen.getByRole("button", { name: "Pin as column" }),
- ).toBeInTheDocument();
- });
-
- it("invokes onPin when clicked", async () => {
- const user = userEvent.setup();
- const onPin = vi.fn();
- renderWithMantine();
- await user.click(screen.getByRole("button", { name: "Pin as column" }));
- expect(onPin).toHaveBeenCalledTimes(1);
- });
-
- it("uses a custom accessible label when provided", () => {
- renderWithMantine(
- ,
- );
- expect(
- screen.getByRole("button", { name: "Open monitoring column" }),
- ).toBeInTheDocument();
- expect(screen.queryByRole("button", { name: "Pin as column" })).toBeNull();
- });
-});
diff --git a/clients/web/src/components/elements/PinColumnButton/PinColumnButton.tsx b/clients/web/src/components/elements/PinColumnButton/PinColumnButton.tsx
deleted file mode 100644
index 33204590a..000000000
--- a/clients/web/src/components/elements/PinColumnButton/PinColumnButton.tsx
+++ /dev/null
@@ -1,29 +0,0 @@
-import { ActionIcon } from "@mantine/core";
-import { TbLayoutSidebarRightExpand } from "react-icons/tb";
-
-export interface PinColumnButtonProps {
- /** Pin this screen into the monitoring column to the right. */
- onPin: () => void;
- /** Accessible label. Defaults to "Pin as column" (its monitor-screen use). */
- label?: string;
-}
-
-/**
- * Toolbar button that opens the resizable monitoring column on the right of the
- * InspectorView (#1616). On a monitor screen (Logs / Protocol / Network) it pins
- * that screen in as a column; on the server list it just opens the column (a
- * different `label`). Distinct from `PinToggle` (which pins individual history
- * entries) — this one opens a side column, so it uses a right-sidebar glyph.
- * `size={36}` matches the header's theme / client-settings ActionIcons and the
- * toolbar's `ListToggle`, so all these icon buttons share one width.
- */
-export function PinColumnButton({
- onPin,
- label = "Pin as column",
-}: PinColumnButtonProps) {
- return (
-
-
-
- );
-}
diff --git a/clients/web/src/components/elements/PinToggle/PinToggle.tsx b/clients/web/src/components/elements/PinToggle/PinToggle.tsx
index 62beaa426..0886f47a6 100644
--- a/clients/web/src/components/elements/PinToggle/PinToggle.tsx
+++ b/clients/web/src/components/elements/PinToggle/PinToggle.tsx
@@ -1,4 +1,4 @@
-import { ActionIcon } from "@mantine/core";
+import { ActionIcon, Tooltip } from "@mantine/core";
import { TiPin, TiPinOutline } from "react-icons/ti";
export interface PinToggleProps {
@@ -16,14 +16,16 @@ export function PinToggle({ pinned, onToggle }: PinToggleProps) {
const Icon = pinned ? TiPin : TiPinOutline;
const label = pinned ? "Unpin" : "Pin";
return (
-
-
-
+
+
+
+
+
);
}
diff --git a/clients/web/src/components/elements/ReplayButton/ReplayButton.tsx b/clients/web/src/components/elements/ReplayButton/ReplayButton.tsx
index 3c8c13e62..7d6f48a55 100644
--- a/clients/web/src/components/elements/ReplayButton/ReplayButton.tsx
+++ b/clients/web/src/components/elements/ReplayButton/ReplayButton.tsx
@@ -1,4 +1,4 @@
-import { ActionIcon } from "@mantine/core";
+import { ActionIcon, Tooltip } from "@mantine/core";
import { MdReplay } from "react-icons/md";
export interface ReplayButtonProps {
@@ -13,14 +13,16 @@ export interface ReplayButtonProps {
*/
export function ReplayButton({ onReplay }: ReplayButtonProps) {
return (
-
-
-
+
+
+
+
+
);
}
diff --git a/clients/web/src/components/elements/ResizeHandle/ResizeHandle.test.tsx b/clients/web/src/components/elements/ResizeHandle/ResizeHandle.test.tsx
index 5ba77b0bc..33a994220 100644
--- a/clients/web/src/components/elements/ResizeHandle/ResizeHandle.test.tsx
+++ b/clients/web/src/components/elements/ResizeHandle/ResizeHandle.test.tsx
@@ -39,9 +39,9 @@ describe("ResizeHandle", () => {
});
it("accepts a custom aria-label", () => {
- setup({ "aria-label": "Resize monitoring column" });
+ setup({ "aria-label": "Resize monitoring sidebar" });
expect(
- screen.getByRole("separator", { name: "Resize monitoring column" }),
+ screen.getByRole("separator", { name: "Resize monitoring sidebar" }),
).toBeInTheDocument();
});
diff --git a/clients/web/src/components/elements/ScreenStage/ScreenStage.tsx b/clients/web/src/components/elements/ScreenStage/ScreenStage.tsx
index 23ac4d145..3f54622c9 100644
--- a/clients/web/src/components/elements/ScreenStage/ScreenStage.tsx
+++ b/clients/web/src/components/elements/ScreenStage/ScreenStage.tsx
@@ -12,7 +12,7 @@ export interface ScreenStageProps {
/**
* 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
+ * ScrollArea in the monitoring sidebar). Off by default so callers whose
* screens size themselves keep the top/left/right anchoring.
*/
fill?: boolean;
@@ -25,7 +25,7 @@ export interface ScreenStageProps {
* (`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
+ * Shared by the primary InspectorView pane and the pinned monitoring sidebar so
* both use identical enter/exit motion (#1639-follow-up). Must be rendered
* inside a `position: relative` container.
*/
diff --git a/clients/web/src/components/groups/LogStreamPanel/LogStreamPanel.tsx b/clients/web/src/components/groups/LogStreamPanel/LogStreamPanel.tsx
index 24fda38ce..c94fb0c8d 100644
--- a/clients/web/src/components/groups/LogStreamPanel/LogStreamPanel.tsx
+++ b/clients/web/src/components/groups/LogStreamPanel/LogStreamPanel.tsx
@@ -7,7 +7,6 @@ import {
SortToggle,
type SortDirection,
} from "../../elements/SortToggle/SortToggle";
-import { PinColumnButton } from "../../elements/PinColumnButton/PinColumnButton";
import { EmbeddableScrollArea } from "../../elements/EmbeddableScrollArea/EmbeddableScrollArea";
import { useScrollMemory } from "../../../hooks/useScrollMemory";
@@ -20,13 +19,7 @@ export interface LogStreamPanelProps {
sortDirection: SortDirection;
onSortChange: (next: SortDirection) => void;
/**
- * When set, renders a "pin as column" button in the toolbar that opens this
- * screen in the monitoring column (#1616). Omitted when the panel is already
- * embedded in that column (or when pinning isn't available).
- */
- onPin?: () => void;
- /**
- * True when this panel is rendered inside the monitoring column. Switches the
+ * True when this panel is rendered inside the monitoring sidebar. Switches the
* scroll region from the viewport-height calc to filling its flex parent, so
* it fits below the column's controls row without viewport math.
*/
@@ -78,7 +71,6 @@ export function LogStreamPanel({
onExport,
sortDirection,
onSortChange,
- onPin,
embedded = false,
}: LogStreamPanelProps) {
const viewportRef = useScrollMemory("logs-stream");
@@ -117,14 +109,15 @@ export function LogStreamPanel({
>
Export
- {onPin ? : null}
{filteredEntries.length > 0 ? (
{filteredEntries.map((entry, index) => (
-
+ // Compact (two-line) layout inside the narrow monitoring sidebar;
+ // the full single-line row on the standalone Logs screen. (#1661)
+
))}
diff --git a/clients/web/src/components/groups/MonitoringControls/MonitoringControls.stories.tsx b/clients/web/src/components/groups/MonitoringControls/MonitoringControls.stories.tsx
index 6a869af48..3a3eb7ffd 100644
--- a/clients/web/src/components/groups/MonitoringControls/MonitoringControls.stories.tsx
+++ b/clients/web/src/components/groups/MonitoringControls/MonitoringControls.stories.tsx
@@ -11,7 +11,6 @@ const meta: Meta = {
onChange: fn(),
searchValue: "",
onSearchChange: fn(),
- onClose: fn(),
},
};
@@ -22,9 +21,6 @@ export const AllTabs: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
expect(canvas.getByRole("radio", { name: "Logs" })).toBeChecked();
- expect(
- canvas.getByRole("button", { name: "Close monitoring column" }),
- ).toBeInTheDocument();
},
};
diff --git a/clients/web/src/components/groups/MonitoringControls/MonitoringControls.test.tsx b/clients/web/src/components/groups/MonitoringControls/MonitoringControls.test.tsx
index 3914ed21c..a3835db4a 100644
--- a/clients/web/src/components/groups/MonitoringControls/MonitoringControls.test.tsx
+++ b/clients/web/src/components/groups/MonitoringControls/MonitoringControls.test.tsx
@@ -15,7 +15,6 @@ function renderControls(overrides: Partial = {}) {
onChange: vi.fn(),
searchValue: "",
onSearchChange: vi.fn(),
- onClose: vi.fn(),
...overrides,
};
renderWithMantine();
@@ -44,7 +43,6 @@ describe("MonitoringControls", () => {
onChange={vi.fn()}
searchValue=""
onSearchChange={vi.fn()}
- onClose={vi.fn()}
/>,
);
expect(
@@ -90,14 +88,4 @@ describe("MonitoringControls", () => {
renderControls({ searchValue: "" });
expect(screen.queryByRole("button", { name: "Clear" })).toBeNull();
});
-
- it("calls onClose when the close button is clicked", async () => {
- const user = userEvent.setup();
- const onClose = vi.fn();
- renderControls({ onClose });
- await user.click(
- screen.getByRole("button", { name: "Close monitoring column" }),
- );
- expect(onClose).toHaveBeenCalledTimes(1);
- });
});
diff --git a/clients/web/src/components/groups/MonitoringControls/MonitoringControls.tsx b/clients/web/src/components/groups/MonitoringControls/MonitoringControls.tsx
index 235753377..5e10a6e1a 100644
--- a/clients/web/src/components/groups/MonitoringControls/MonitoringControls.tsx
+++ b/clients/web/src/components/groups/MonitoringControls/MonitoringControls.tsx
@@ -1,5 +1,4 @@
-import { ActionIcon, Group, SegmentedControl, TextInput } from "@mantine/core";
-import { TbLayoutSidebarRightCollapse } from "react-icons/tb";
+import { Group, SegmentedControl, TextInput } from "@mantine/core";
import { ClearButton } from "../../elements/ClearButton/ClearButton";
export interface MonitoringControlsProps {
@@ -11,13 +10,12 @@ export interface MonitoringControlsProps {
/** Search text for the active screen (shares the screen's own filter state). */
searchValue: string;
onSearchChange: (next: string) => void;
- /** Close the monitoring column, returning its screens to the header menu. */
- onClose: () => void;
}
-// The controls row at the top of the pinned monitoring column: a segmented tab
-// switcher on the left, a search box filling the middle, and a close button on
-// the right that unpins the whole column.
+// The controls row at the top of the pinned monitoring sidebar: a segmented tab
+// switcher on the left and a search box filling the rest. The column is closed
+// from the single header MonitoringToggle (#1661), so there is no close button
+// here.
const ControlsBar = Group.withProps({
wrap: "nowrap",
gap: "sm",
@@ -25,7 +23,7 @@ const ControlsBar = Group.withProps({
});
// Search box, styled to match the `*Controls` search fields; `flex:1`/`miw:0`
-// so it fills the space between the tabs and the close button.
+// so it fills the space between the tabs and the row's edge.
const SearchInput = TextInput.withProps({
placeholder: "Search...",
size: "sm",
@@ -35,12 +33,12 @@ const SearchInput = TextInput.withProps({
});
/**
- * Tab row + search + close control for the pinned monitoring column (#1616).
- * Renders only the currently-available monitor tabs (the caller filters by
- * capability), so it never shows an empty switcher. Selecting a tab swaps the
- * screen below; the search box filters the shown screen (wired by the caller to
- * that screen's own filter state); the close button unpins the column so its
- * screens rejoin the header tab menu.
+ * Tab row + search for the pinned monitoring sidebar (#1616). Renders only the
+ * currently-available monitor tabs (the caller filters by capability), so it
+ * never shows an empty switcher. Selecting a tab swaps the screen below; the
+ * search box filters the shown screen (wired by the caller to that screen's own
+ * filter state). Opening/closing the column is handled by the single header
+ * MonitoringToggle (#1661).
*/
export function MonitoringControls({
tabs,
@@ -48,7 +46,6 @@ export function MonitoringControls({
onChange,
searchValue,
onSearchChange,
- onClose,
}: MonitoringControlsProps) {
return (
@@ -69,14 +66,6 @@ export function MonitoringControls({
) : null
}
/>
-
-
-
);
}
diff --git a/clients/web/src/components/groups/MonitoringScreen/MonitoringScreen.stories.tsx b/clients/web/src/components/groups/MonitoringScreen/MonitoringScreen.stories.tsx
index 2249dfe11..56c1cca21 100644
--- a/clients/web/src/components/groups/MonitoringScreen/MonitoringScreen.stories.tsx
+++ b/clients/web/src/components/groups/MonitoringScreen/MonitoringScreen.stories.tsx
@@ -37,7 +37,6 @@ const meta: Meta = {
onChange: fn(),
searchValue: "",
onSearchChange: fn(),
- onClose: fn(),
screens: demoScreens,
},
};
diff --git a/clients/web/src/components/groups/MonitoringScreen/MonitoringScreen.test.tsx b/clients/web/src/components/groups/MonitoringScreen/MonitoringScreen.test.tsx
index 77ce2c4b4..00b385601 100644
--- a/clients/web/src/components/groups/MonitoringScreen/MonitoringScreen.test.tsx
+++ b/clients/web/src/components/groups/MonitoringScreen/MonitoringScreen.test.tsx
@@ -23,7 +23,6 @@ function renderScreen(overrides: Partial = {}) {
onChange: vi.fn(),
searchValue: "",
onSearchChange: vi.fn(),
- onClose: vi.fn(),
screens: screens(),
...overrides,
};
@@ -42,22 +41,14 @@ describe("MonitoringScreen", () => {
it("renders the controls tab row", () => {
renderScreen();
expect(screen.getByRole("radio", { name: "Logs" })).toBeInTheDocument();
- expect(
- screen.getByRole("button", { name: "Close monitoring column" }),
- ).toBeInTheDocument();
});
- it("forwards tab changes and close from the controls", async () => {
+ it("forwards tab changes from the controls", async () => {
const user = userEvent.setup();
const onChange = vi.fn();
- const onClose = vi.fn();
- renderScreen({ onChange, onClose });
+ renderScreen({ onChange });
await user.click(screen.getByRole("radio", { name: "Network" }));
expect(onChange).toHaveBeenCalledWith("Network");
- await user.click(
- screen.getByRole("button", { name: "Close monitoring column" }),
- );
- expect(onClose).toHaveBeenCalledTimes(1);
});
it("forwards the search value and changes from the controls", async () => {
diff --git a/clients/web/src/components/groups/MonitoringScreen/MonitoringScreen.tsx b/clients/web/src/components/groups/MonitoringScreen/MonitoringScreen.tsx
index 2e577ca98..ea15a8ff8 100644
--- a/clients/web/src/components/groups/MonitoringScreen/MonitoringScreen.tsx
+++ b/clients/web/src/components/groups/MonitoringScreen/MonitoringScreen.tsx
@@ -13,7 +13,6 @@ export interface MonitoringScreenProps {
/** Search text for the active screen; wired by the caller to its filter state. */
searchValue: string;
onSearchChange: (next: string) => void;
- onClose: () => void;
/**
* The embedded screen node for each tab, keyed by tab label. The caller builds
* these (with `embedded` set) so this component stays a pure layout shell.
@@ -38,7 +37,7 @@ const ScreenSlot = Stack.withProps({
});
/**
- * The pinned monitoring column's content (#1616): a `MonitoringControls` tab row
+ * The pinned monitoring sidebar's content (#1616): a `MonitoringControls` tab row
* over the selected monitor screen. Layout-only — it wraps each supplied screen
* node in a `ScreenStage`, so switching tabs cross-fades (only the active tab's
* screen is mounted) the same way the primary pane does.
@@ -49,7 +48,6 @@ export function MonitoringScreen({
onChange,
searchValue,
onSearchChange,
- onClose,
screens,
}: MonitoringScreenProps) {
return (
@@ -60,7 +58,6 @@ export function MonitoringScreen({
onChange={onChange}
searchValue={searchValue}
onSearchChange={onSearchChange}
- onClose={onClose}
/>
diff --git a/clients/web/src/components/groups/NetworkEntry/NetworkEntry.tsx b/clients/web/src/components/groups/NetworkEntry/NetworkEntry.tsx
index 40d8950cf..294a740d8 100644
--- a/clients/web/src/components/groups/NetworkEntry/NetworkEntry.tsx
+++ b/clients/web/src/components/groups/NetworkEntry/NetworkEntry.tsx
@@ -21,7 +21,7 @@ export interface NetworkEntryProps {
entry: FetchRequestEntry;
isListExpanded: boolean;
/**
- * Compact two-line header for the narrow monitoring column (#1616): line 1 is
+ * Compact two-line header for the narrow monitoring sidebar (#1616): line 1 is
* time + method + category + duration + status; line 2 is the URL in a
* horizontal scroll area with the expand toggle on the right.
*/
diff --git a/clients/web/src/components/groups/NetworkStreamPanel/NetworkStreamPanel.tsx b/clients/web/src/components/groups/NetworkStreamPanel/NetworkStreamPanel.tsx
index 9aa7f6e84..6bd6b44cd 100644
--- a/clients/web/src/components/groups/NetworkStreamPanel/NetworkStreamPanel.tsx
+++ b/clients/web/src/components/groups/NetworkStreamPanel/NetworkStreamPanel.tsx
@@ -10,7 +10,6 @@ import {
SortToggle,
type SortDirection,
} from "../../elements/SortToggle/SortToggle";
-import { PinColumnButton } from "../../elements/PinColumnButton/PinColumnButton";
import { EmbeddableScrollArea } from "../../elements/EmbeddableScrollArea/EmbeddableScrollArea";
import { useScrollMemory } from "../../../hooks/useScrollMemory";
@@ -24,8 +23,6 @@ export interface NetworkStreamPanelProps {
onSortChange: (next: SortDirection) => void;
compact: boolean;
onToggleCompact: () => void;
- /** See LogStreamPanel: shows a "pin as column" button when set (#1616). */
- onPin?: () => void;
/** See LogStreamPanel: fills the flex parent instead of the viewport calc. */
embedded?: boolean;
}
@@ -96,7 +93,6 @@ export function NetworkStreamPanel({
onSortChange,
compact,
onToggleCompact,
- onPin,
embedded = false,
}: NetworkStreamPanelProps) {
const viewportRef = useScrollMemory("network-stream");
@@ -133,7 +129,6 @@ export function NetworkStreamPanel({
{hasResults && (
)}
- {onPin ? : null}
diff --git a/clients/web/src/components/groups/ProtocolEntry/ProtocolEntry.tsx b/clients/web/src/components/groups/ProtocolEntry/ProtocolEntry.tsx
index c31ef65bb..52f8c8bb0 100644
--- a/clients/web/src/components/groups/ProtocolEntry/ProtocolEntry.tsx
+++ b/clients/web/src/components/groups/ProtocolEntry/ProtocolEntry.tsx
@@ -24,7 +24,7 @@ export interface ProtocolEntryProps {
onReplay: () => void;
onTogglePin: () => void;
/**
- * Compact two-line header for the narrow monitoring column (#1616): line 1 is
+ * Compact two-line header for the narrow monitoring sidebar (#1616): line 1 is
* time + direction + duration + status; line 2 is the method (and target) with
* the controls — Replay as an icon — on the right.
*/
diff --git a/clients/web/src/components/groups/ProtocolListPanel/ProtocolListPanel.tsx b/clients/web/src/components/groups/ProtocolListPanel/ProtocolListPanel.tsx
index afc996aed..6315f40d3 100644
--- a/clients/web/src/components/groups/ProtocolListPanel/ProtocolListPanel.tsx
+++ b/clients/web/src/components/groups/ProtocolListPanel/ProtocolListPanel.tsx
@@ -20,7 +20,6 @@ import {
SortToggle,
type SortDirection,
} from "../../elements/SortToggle/SortToggle";
-import { PinColumnButton } from "../../elements/PinColumnButton/PinColumnButton";
import { EmbeddableScrollArea } from "../../elements/EmbeddableScrollArea/EmbeddableScrollArea";
import { extractMethod } from "../protocolUtils.js";
import { useScrollMemory } from "../../../hooks/useScrollMemory";
@@ -44,8 +43,6 @@ export interface ProtocolListPanelProps {
onSortChange: (next: SortDirection) => void;
compact: boolean;
onToggleCompact: () => void;
- /** See LogStreamPanel: shows a "pin as column" button when set (#1616). */
- onPin?: () => void;
/** See LogStreamPanel: fills the flex parent instead of the viewport calc. */
embedded?: boolean;
}
@@ -221,7 +218,6 @@ export function ProtocolListPanel({
onSortChange,
compact,
onToggleCompact,
- onPin,
embedded = false,
}: ProtocolListPanelProps) {
const viewportRef = useScrollMemory("protocol-list");
@@ -293,7 +289,6 @@ export function ProtocolListPanel({
{hasResults && (
)}
- {onPin ? : null}
diff --git a/clients/web/src/components/groups/ServerCard/ServerCard.tsx b/clients/web/src/components/groups/ServerCard/ServerCard.tsx
index 03449c4a9..5c763b5a6 100644
--- a/clients/web/src/components/groups/ServerCard/ServerCard.tsx
+++ b/clients/web/src/components/groups/ServerCard/ServerCard.tsx
@@ -158,7 +158,7 @@ export function ServerCard({
}, [highlighted, scrollOnHighlight]);
// Scroll the failed card into view on the disconnected→errored transition
- // (#1621), deferred past the monitoring column's open (`FAILED_CARD_SCROLL_
+ // (#1621), deferred past the monitoring sidebar's open (`FAILED_CARD_SCROLL_
// DELAY_MS`, derived from the column's slide duration) so the grid reflow
// settles before `scrollIntoView` measures the card. Guarded by a ref so a
// re-render while still errored doesn't re-scroll and fight the user if they've
diff --git a/clients/web/src/components/groups/ServerListControls/ServerListControls.test.tsx b/clients/web/src/components/groups/ServerListControls/ServerListControls.test.tsx
index 247ce910b..3cfde7662 100644
--- a/clients/web/src/components/groups/ServerListControls/ServerListControls.test.tsx
+++ b/clients/web/src/components/groups/ServerListControls/ServerListControls.test.tsx
@@ -24,31 +24,6 @@ describe("ServerListControls", () => {
).toBeInTheDocument();
});
- it("hides the open-monitor button unless onOpenMonitor is provided", () => {
- renderWithMantine();
- expect(
- screen.queryByRole("button", { name: "Open monitoring column" }),
- ).toBeNull();
- });
-
- it("shows and wires the open-monitor button when onOpenMonitor is provided", async () => {
- const user = userEvent.setup();
- const onOpenMonitor = vi.fn();
- renderWithMantine(
- ,
- );
- const openBtn = screen.getByRole("button", {
- name: "Open monitoring column",
- });
- expect(openBtn).toBeInTheDocument();
- await user.click(openBtn);
- expect(onOpenMonitor).toHaveBeenCalledTimes(1);
- });
-
it("shows the list toggle alongside Export + Add Servers when servers exist", () => {
renderWithMantine();
expect(screen.getAllByRole("button")).toHaveLength(3);
diff --git a/clients/web/src/components/groups/ServerListControls/ServerListControls.tsx b/clients/web/src/components/groups/ServerListControls/ServerListControls.tsx
index c64c2eaa1..9a3ca0e5b 100644
--- a/clients/web/src/components/groups/ServerListControls/ServerListControls.tsx
+++ b/clients/web/src/components/groups/ServerListControls/ServerListControls.tsx
@@ -1,6 +1,5 @@
import { Button, Group } from "@mantine/core";
import { ListToggle } from "../../elements/ListToggle/ListToggle";
-import { PinColumnButton } from "../../elements/PinColumnButton/PinColumnButton";
import {
ServerAddMenu,
type AddServerMenuProps,
@@ -14,12 +13,6 @@ export interface ServerListControlsProps extends AddServerMenuProps {
onExport: () => void;
/** When false (read-only session), the Add menu is hidden. Defaults to true. */
writable?: boolean;
- /**
- * Open the monitoring column. Provided (by the caller) only when a server is
- * connected, the column can open, and it isn't already shown — otherwise
- * omitted so the open-sidebar affordance is hidden.
- */
- onOpenMonitor?: () => void;
}
export function ServerListControls({
@@ -31,7 +24,6 @@ export function ServerListControls({
onImportServerJson,
onExport,
writable = true,
- onOpenMonitor,
}: ServerListControlsProps) {
return (
// `gap="sm"` matches the header's control spacing (its RightSection group),
@@ -50,9 +42,6 @@ export function ServerListControls({
{serverCount > 0 && (
)}
- {onOpenMonitor && (
-
- )}
);
}
diff --git a/clients/web/src/components/groups/ToolResultPanel/ToolResultPanel.test.tsx b/clients/web/src/components/groups/ToolResultPanel/ToolResultPanel.test.tsx
index 5e2318db1..0874cb1e3 100644
--- a/clients/web/src/components/groups/ToolResultPanel/ToolResultPanel.test.tsx
+++ b/clients/web/src/components/groups/ToolResultPanel/ToolResultPanel.test.tsx
@@ -69,11 +69,11 @@ describe("ToolResultPanel", () => {
);
});
- it("invokes onClear when Clear is clicked", async () => {
+ it("invokes onClear when the close button is clicked", async () => {
const user = userEvent.setup();
const onClear = vi.fn();
renderWithMantine();
- await user.click(screen.getByRole("button", { name: "Clear" }));
+ await user.click(screen.getByRole("button", { name: "Close results" }));
expect(onClear).toHaveBeenCalledTimes(1);
});
});
diff --git a/clients/web/src/components/groups/ToolResultPanel/ToolResultPanel.tsx b/clients/web/src/components/groups/ToolResultPanel/ToolResultPanel.tsx
index 5986d911e..1670109d4 100644
--- a/clients/web/src/components/groups/ToolResultPanel/ToolResultPanel.tsx
+++ b/clients/web/src/components/groups/ToolResultPanel/ToolResultPanel.tsx
@@ -1,6 +1,6 @@
import {
Alert,
- Button,
+ CloseButton,
Group,
ScrollArea,
Stack,
@@ -16,6 +16,11 @@ import { ResourceLink } from "../ResourceLink/ResourceLink";
export interface ToolResultPanelProps {
result: CallToolResult;
+ /**
+ * Dismiss the result and return to the input form (#1661). Mirrors the
+ * Prompts screen: the result replaces the form while present, and the
+ * top-left X flips back to the form so the tool can be re-run.
+ */
onClear: () => void;
/**
* Read-on-demand handler so `resource_link` blocks in the result can fetch
@@ -24,11 +29,6 @@ export interface ToolResultPanelProps {
onReadResource?: (uri: string) => Promise;
}
-const ClearButton = Button.withProps({
- variant: "subtle",
- size: "sm",
-});
-
// Outer column: the header pins (`flex: 0 0 auto`) and the scroll region
// absorbs overflow when the enclosing card hits its `mah`. `mih: 0` lets the
// flex children shrink below their content's intrinsic height.
@@ -43,6 +43,13 @@ const HeaderRow = Group.withProps({
flex: "0 0 auto",
});
+// Close button + title, mirroring PromptMessagesDisplay so the two result
+// panels dismiss the same way.
+const HeaderLeft = Group.withProps({
+ gap: "xs",
+ wrap: "nowrap",
+});
+
// `0 1 auto` + `mih: 0` lets the scroll region shrink (never grow) so a short
// result doesn't reserve height while a long one scrolls within the card cap.
const ResultScroll = ScrollArea.withProps({
@@ -66,8 +73,10 @@ export function ToolResultPanel({
return (
- Results
- Clear
+
+
+ Results
+
diff --git a/clients/web/src/components/groups/ViewHeader/ViewHeader.test.tsx b/clients/web/src/components/groups/ViewHeader/ViewHeader.test.tsx
index 67369b1ae..d5bd12fb4 100644
--- a/clients/web/src/components/groups/ViewHeader/ViewHeader.test.tsx
+++ b/clients/web/src/components/groups/ViewHeader/ViewHeader.test.tsx
@@ -99,6 +99,39 @@ describe("ViewHeader", () => {
);
expect(onToggleTheme).toHaveBeenCalledTimes(1);
});
+
+ it("omits the monitoring toggle when no monitorToggle is provided", () => {
+ renderWithMantine(
+ ,
+ );
+ expect(
+ screen.queryByRole("button", { name: "Open monitoring sidebar" }),
+ ).toBeNull();
+ expect(
+ screen.queryByRole("button", { name: "Close monitoring sidebar" }),
+ ).toBeNull();
+ });
+
+ it("renders and wires the monitoring toggle for a failed connect attempt", async () => {
+ const user = userEvent.setup();
+ const onToggle = vi.fn();
+ renderWithMantine(
+ ,
+ );
+ await user.click(
+ screen.getByRole("button", { name: "Open monitoring sidebar" }),
+ );
+ expect(onToggle).toHaveBeenCalledTimes(1);
+ });
});
describe("when connected", () => {
@@ -122,6 +155,24 @@ describe("ViewHeader", () => {
expect(onToggleTheme).toHaveBeenCalledTimes(1);
});
+ it("renders the monitoring toggle in its open state and toggles it closed", async () => {
+ const user = userEvent.setup();
+ const onToggle = vi.fn();
+ renderWithMantine(
+ ,
+ );
+ expect(
+ screen.queryByRole("button", { name: "Open monitoring sidebar" }),
+ ).toBeNull();
+ await user.click(
+ screen.getByRole("button", { name: "Close monitoring sidebar" }),
+ );
+ expect(onToggle).toHaveBeenCalledTimes(1);
+ });
+
it("invokes onDisconnect when the disconnect control is clicked", async () => {
const user = userEvent.setup();
const onDisconnect = vi.fn();
diff --git a/clients/web/src/components/groups/ViewHeader/ViewHeader.tsx b/clients/web/src/components/groups/ViewHeader/ViewHeader.tsx
index 05f124058..904121619 100644
--- a/clients/web/src/components/groups/ViewHeader/ViewHeader.tsx
+++ b/clients/web/src/components/groups/ViewHeader/ViewHeader.tsx
@@ -10,17 +10,27 @@ import {
Select,
Text,
Title,
+ Tooltip,
Transition,
useComputedColorScheme,
+ type MantineTransition,
} from "@mantine/core";
import { useMediaQuery } from "@mantine/hooks";
import type { Implementation } from "@modelcontextprotocol/sdk/types.js";
import { MdLightMode, MdDarkMode, MdLinkOff, MdSettings } from "react-icons/md";
import type { ConnectionStatus } from "@inspector/core/mcp/types.js";
import { ServerStatusIndicator } from "../../elements/ServerStatusIndicator/ServerStatusIndicator";
+import {
+ MonitoringToggle,
+ type MonitoringToggleProps,
+} from "../../elements/MonitoringToggle/MonitoringToggle";
import mcpLogo from "../../../theme/assets/MCP.svg";
import mcpLogoDark from "../../../theme/assets/MCP-dark.svg";
+// The single monitoring-sidebar affordance (#1661): its open state + toggle
+// callback (`MonitoringToggleProps`, reused as the one source of truth). Present
+// only when the sidebar is available (connected, or a failed connect attempt,
+// on a wide viewport); undefined otherwise so no toggle shows.
interface ConnectedProps {
connected: true;
serverInfo: Implementation;
@@ -32,12 +42,16 @@ interface ConnectedProps {
onDisconnect: () => void;
onToggleTheme: () => void;
onOpenClientSettings: () => void;
+ /** Monitoring-sidebar toggle, shown to the right of the theme icon. */
+ monitorToggle?: MonitoringToggleProps;
}
interface UnconnectedProps {
connected: false;
onToggleTheme: () => void;
onOpenClientSettings: () => void;
+ /** Monitoring-sidebar toggle, shown to the right of the theme icon. */
+ monitorToggle?: MonitoringToggleProps;
}
export type ViewHeaderProps = ConnectedProps | UnconnectedProps;
@@ -192,8 +206,45 @@ const ThemeToggle = ActionIcon.withProps({
"aria-label": "Toggle color scheme",
});
+// Enter/exit animation for the monitoring-sidebar toggle (#1661). It occupies a
+// slot to the right of the theme icon; when it appears it slides in and grows
+// its width from 0 → the 36px icon, which — because RightSection is a flex-end
+// row — pushes the theme + client-settings buttons one slot to the left. On exit
+// it reverses and those buttons slide back over. The negative `marginLeft` in
+// `out` cancels RightSection's `sm` gap while collapsed so the theme icon stays
+// flush to the right edge (no dead gap). `overflow: hidden` clips the icon as
+// the slot narrows so it reads as a slide-in reveal. The 36 matches the icon.
+const TOGGLE_ANIM_MS = 200;
+const toggleSlide: MantineTransition = {
+ in: { opacity: 1, width: 36, marginLeft: 0, transform: "translateX(0)" },
+ out: {
+ opacity: 0,
+ width: 0,
+ marginLeft: "calc(var(--mantine-spacing-sm) * -1)",
+ transform: "translateX(-8px)",
+ },
+ common: { overflow: "hidden", flexShrink: 0 },
+ transitionProperty: "opacity, width, margin-left, transform",
+};
+
export function ViewHeader(props: ViewHeaderProps) {
const colorScheme = useComputedColorScheme();
+ // Retain the latest toggle props so the control keeps rendering its final
+ // state while it slides out after `monitorToggle` clears (the Transition below
+ // keeps it mounted for the exit). Adjust-state-during-render, gated on the
+ // `open` primitive — the parent hands us a fresh object/callback each render,
+ // so comparing by identity would loop; `open` is the only bit that changes the
+ // rendered control, and the exiting toggle isn't interactive anyway.
+ const [lastMonitorToggle, setLastMonitorToggle] = useState(
+ props.monitorToggle,
+ );
+ if (
+ props.monitorToggle &&
+ props.monitorToggle.open !== lastMonitorToggle?.open
+ ) {
+ setLastMonitorToggle(props.monitorToggle);
+ }
+ const monitorToggleForRender = props.monitorToggle ?? lastMonitorToggle;
const ThemeIcon = colorScheme === "dark" ? MdLightMode : MdDarkMode;
const showSegmented = useMediaQuery("(min-width: 992px)");
const showDisconnectLabel = useMediaQuery("(min-width: 768px)");
@@ -389,12 +440,47 @@ export function ViewHeader(props: ViewHeaderProps) {
)
}
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+ {(styles) =>
+ monitorToggleForRender ? (
+ // `style={styles}` is Mantine's runtime Transition interpolation
+ // (width/margin/opacity/transform), not static styling — same
+ // pattern as the column slide in InspectorView.
+
+
+
+ ) : (
+ /* v8 ignore next -- unreachable: the Transition only mounts once a
+ toggle has existed, so the snapshot is always set when rendered */
+ <>>
+ )
+ }
+
);
diff --git a/clients/web/src/components/screens/ConsoleScreen/ConsoleScreen.test.tsx b/clients/web/src/components/screens/ConsoleScreen/ConsoleScreen.test.tsx
index d31eb4bfe..f29bdaf19 100644
--- a/clients/web/src/components/screens/ConsoleScreen/ConsoleScreen.test.tsx
+++ b/clients/web/src/components/screens/ConsoleScreen/ConsoleScreen.test.tsx
@@ -100,15 +100,6 @@ describe("ConsoleScreen", () => {
expect(onExport).toHaveBeenCalledOnce();
});
- it("shows a pin button only when onPin is provided", () => {
- const { rerender } = renderWithMantine();
- expect(screen.queryByRole("button", { name: "Pin as column" })).toBeNull();
- rerender();
- expect(
- screen.getByRole("button", { name: "Pin as column" }),
- ).toBeInTheDocument();
- });
-
it("drops the search sidebar when embedded", () => {
renderWithMantine();
// The embedded column has no in-screen search box (the column supplies it).
diff --git a/clients/web/src/components/screens/ConsoleScreen/ConsoleScreen.tsx b/clients/web/src/components/screens/ConsoleScreen/ConsoleScreen.tsx
index fd00982fe..e69b78dda 100644
--- a/clients/web/src/components/screens/ConsoleScreen/ConsoleScreen.tsx
+++ b/clients/web/src/components/screens/ConsoleScreen/ConsoleScreen.tsx
@@ -16,7 +16,6 @@ import {
SortToggle,
type SortDirection,
} from "../../elements/SortToggle/SortToggle";
-import { PinColumnButton } from "../../elements/PinColumnButton/PinColumnButton";
import { EmbeddableScrollArea } from "../../elements/EmbeddableScrollArea/EmbeddableScrollArea";
import { useScrollMemory } from "../../../hooks/useScrollMemory";
@@ -28,8 +27,6 @@ export interface ConsoleScreenProps {
onExport: () => void;
sortDirection: SortDirection;
onSortChange: (next: SortDirection) => void;
- /** See LoggingScreen: shows a "pin as column" button when set (#1616). */
- onPin?: () => void;
/** See LoggingScreen: fills the parent height and drops the filter sidebar. */
embedded?: boolean;
}
@@ -112,7 +109,6 @@ export function ConsoleScreen({
onExport,
sortDirection,
onSortChange,
- onPin,
embedded = false,
}: ConsoleScreenProps) {
const { filterText } = ui;
@@ -176,7 +172,6 @@ export function ConsoleScreen({
>
Export
- {onPin ? : null}
{filteredEntries.length > 0 ? (
diff --git a/clients/web/src/components/screens/LoggingScreen/LoggingScreen.test.tsx b/clients/web/src/components/screens/LoggingScreen/LoggingScreen.test.tsx
index 675e028b1..942d208c5 100644
--- a/clients/web/src/components/screens/LoggingScreen/LoggingScreen.test.tsx
+++ b/clients/web/src/components/screens/LoggingScreen/LoggingScreen.test.tsx
@@ -132,14 +132,6 @@ describe("LoggingScreen", () => {
expect(onSetLevel).toHaveBeenCalledWith("info");
});
- it("renders a pin-as-column button when onPin is provided and invokes it", async () => {
- const user = userEvent.setup();
- const onPin = vi.fn();
- renderWithMantine();
- await user.click(screen.getByRole("button", { name: "Pin as column" }));
- expect(onPin).toHaveBeenCalledTimes(1);
- });
-
it("drops the filter sidebar when embedded, keeping the stream", () => {
renderWithMantine();
expect(screen.getByText("Log Stream")).toBeInTheDocument();
diff --git a/clients/web/src/components/screens/LoggingScreen/LoggingScreen.tsx b/clients/web/src/components/screens/LoggingScreen/LoggingScreen.tsx
index 924f9b11f..a9c716a8e 100644
--- a/clients/web/src/components/screens/LoggingScreen/LoggingScreen.tsx
+++ b/clients/web/src/components/screens/LoggingScreen/LoggingScreen.tsx
@@ -17,12 +17,7 @@ export interface LoggingScreenProps {
sortDirection: SortDirection;
onSortChange: (next: SortDirection) => void;
/**
- * When set, the stream panel shows a "pin as column" button (#1616). Omitted
- * when already embedded, or when pinning isn't currently available.
- */
- onPin?: () => void;
- /**
- * True when rendered inside the monitoring column: the screen fills its
+ * True when rendered inside the monitoring sidebar: the screen fills its
* parent's height (instead of the viewport calc) and drops the filter
* sidebar so the narrow column is stream-only.
*/
@@ -63,7 +58,6 @@ export function LoggingScreen({
onExport,
sortDirection,
onSortChange,
- onPin,
embedded = false,
}: LoggingScreenProps) {
const { filterText, visibleLevels } = ui;
@@ -110,7 +104,6 @@ export function LoggingScreen({
onExport={onExport}
sortDirection={sortDirection}
onSortChange={onSortChange}
- onPin={onPin}
embedded={embedded}
/>
diff --git a/clients/web/src/components/screens/NetworkScreen/NetworkScreen.test.tsx b/clients/web/src/components/screens/NetworkScreen/NetworkScreen.test.tsx
index 3f67adc7e..e3d633049 100644
--- a/clients/web/src/components/screens/NetworkScreen/NetworkScreen.test.tsx
+++ b/clients/web/src/components/screens/NetworkScreen/NetworkScreen.test.tsx
@@ -164,14 +164,6 @@ describe("NetworkScreen", () => {
expect(screen.getByRole("button", { name: "Export" })).toBeDisabled();
});
- it("renders a pin-as-column button when onPin is provided and invokes it", async () => {
- const user = userEvent.setup();
- const onPin = vi.fn();
- renderWithMantine();
- await user.click(screen.getByRole("button", { name: "Pin as column" }));
- expect(onPin).toHaveBeenCalledTimes(1);
- });
-
it("drops the filter sidebar when embedded, keeping the request list", () => {
renderWithMantine();
// The sidebar (NetworkControls, with its Search box) is not rendered.
diff --git a/clients/web/src/components/screens/NetworkScreen/NetworkScreen.tsx b/clients/web/src/components/screens/NetworkScreen/NetworkScreen.tsx
index 4e66700f1..efde1ef1e 100644
--- a/clients/web/src/components/screens/NetworkScreen/NetworkScreen.tsx
+++ b/clients/web/src/components/screens/NetworkScreen/NetworkScreen.tsx
@@ -21,8 +21,6 @@ export interface NetworkScreenProps {
onSortChange: (next: SortDirection) => void;
compact: boolean;
onToggleCompact: () => void;
- /** See LoggingScreen: shows a "pin as column" button when set (#1616). */
- onPin?: () => void;
/** See LoggingScreen: fills the parent height and drops the filter sidebar. */
embedded?: boolean;
}
@@ -61,7 +59,6 @@ export function NetworkScreen({
onSortChange,
compact,
onToggleCompact,
- onPin,
embedded = false,
}: NetworkScreenProps) {
const { filterText, visibleCategories } = ui;
@@ -113,7 +110,6 @@ export function NetworkScreen({
onSortChange={onSortChange}
compact={compact}
onToggleCompact={onToggleCompact}
- onPin={onPin}
embedded={embedded}
/>
diff --git a/clients/web/src/components/screens/PromptsScreen/PromptsScreen.tsx b/clients/web/src/components/screens/PromptsScreen/PromptsScreen.tsx
index 9d00da369..b138da7c7 100644
--- a/clients/web/src/components/screens/PromptsScreen/PromptsScreen.tsx
+++ b/clients/web/src/components/screens/PromptsScreen/PromptsScreen.tsx
@@ -265,10 +265,10 @@ export function PromptsScreen({
// the resource preview layout (see ResourcesScreen).
{renderPreview()}
) : selectedPrompt && hasArguments(selectedPrompt) ? (
- // Argument-form branch — capped at 40% width so the form doesn't
- // stretch across the viewport on wide displays. Disappears once
- // the user clicks Get Prompt and previewActive flips on.
-
+ // Argument-form branch — fills the content pane's width (like the Tools
+ // input form), replaced by the result once the user clicks Get Prompt
+ // and previewActive flips on.
+
{
);
});
- it("renders a pin-as-column button when onPin is provided and invokes it", async () => {
- const user = userEvent.setup();
- const onPin = vi.fn();
- renderWithMantine();
- await user.click(screen.getByRole("button", { name: "Pin as column" }));
- expect(onPin).toHaveBeenCalledTimes(1);
- });
-
it("drops the filter sidebar when embedded, keeping the request list", () => {
renderWithMantine();
expect(screen.getByText("Messages")).toBeInTheDocument();
diff --git a/clients/web/src/components/screens/ProtocolScreen/ProtocolScreen.tsx b/clients/web/src/components/screens/ProtocolScreen/ProtocolScreen.tsx
index baa13b47a..de396de11 100644
--- a/clients/web/src/components/screens/ProtocolScreen/ProtocolScreen.tsx
+++ b/clients/web/src/components/screens/ProtocolScreen/ProtocolScreen.tsx
@@ -25,8 +25,6 @@ export interface ProtocolScreenProps {
onSortChange: (next: SortDirection) => void;
compact: boolean;
onToggleCompact: () => void;
- /** See LoggingScreen: shows a "pin as column" button when set (#1616). */
- onPin?: () => void;
/** See LoggingScreen: fills the parent height and drops the filter sidebar. */
embedded?: boolean;
}
@@ -73,7 +71,6 @@ export function ProtocolScreen({
onSortChange,
compact,
onToggleCompact,
- onPin,
embedded = false,
}: ProtocolScreenProps) {
const { search, methodFilter, visibleDirections } = ui;
@@ -142,7 +139,6 @@ export function ProtocolScreen({
onSortChange={onSortChange}
compact={compact}
onToggleCompact={onToggleCompact}
- onPin={onPin}
embedded={embedded}
/>
diff --git a/clients/web/src/components/screens/ServerListScreen/ServerListScreen.tsx b/clients/web/src/components/screens/ServerListScreen/ServerListScreen.tsx
index f282d0712..f27b0d33f 100644
--- a/clients/web/src/components/screens/ServerListScreen/ServerListScreen.tsx
+++ b/clients/web/src/components/screens/ServerListScreen/ServerListScreen.tsx
@@ -67,12 +67,6 @@ export interface ServerListScreenProps {
onClearHighlight?: (id: string) => void;
compact: boolean;
onToggleCompact: () => void;
- /**
- * Open the monitoring column. Provided only when a server is connected, the
- * column can open, and it isn't already shown; forwarded to the controls to
- * reveal an open-sidebar button beside the list toggle.
- */
- onOpenMonitor?: () => void;
}
const PageContainer = Stack.withProps({
@@ -105,7 +99,6 @@ export function ServerListScreen({
onClearHighlight,
compact,
onToggleCompact,
- onOpenMonitor,
}: ServerListScreenProps) {
const sensors = useSensors(
useSensor(PointerSensor),
@@ -203,7 +196,6 @@ export function ServerListScreen({
onImportConfig={onImportConfig}
onImportServerJson={onImportServerJson}
onExport={onExport}
- onOpenMonitor={onOpenMonitor}
/>
{
expect(
screen.getByText("Select a tool to view details"),
).toBeInTheDocument();
- expect(screen.getByText("Results will appear here")).toBeInTheDocument();
+ // There is no separate results placeholder pane anymore (#1661) — results
+ // replace the input form in the single content pane when they exist.
+ expect(screen.queryByText("Results will appear here")).toBeNull();
});
it("shows the detail panel when a tool is selected", async () => {
@@ -100,10 +102,10 @@ describe("ToolsScreen", () => {
expect(onCallTool).toHaveBeenCalledWith("gamma", { mode: "slow" }, false);
});
- it("renders selection and result from props (persisted across navigation)", () => {
+ it("shows the result in place of the form when a selected tool has a result", () => {
// App owns selection + result, so a remount after a tab switch re-renders
- // with both still set — the detail and result panels show without any
- // local re-selection.
+ // with both still set. The result replaces the input form in the single
+ // content pane (#1661): the Execute button is gone while the result shows.
renderWithMantine(
{
screen.queryByText("Select a tool to view details"),
).not.toBeInTheDocument();
expect(screen.getByText("Results")).toBeInTheDocument();
+ // The input form is hidden while the result is shown.
+ expect(screen.queryByRole("button", { name: /Execute/ })).toBeNull();
+ });
+
+ it("returns to the input form when the result is dismissed", async () => {
+ // App owns both `ui` and `callState`; this host mirrors that so dismissing
+ // the result (onClearResult → callState cleared) flips the single content
+ // pane back to the input form, with the selection (and thus the form)
+ // preserved for a re-run (#1661).
+ function Host() {
+ const [ui, setUi] = useState({
+ ...EMPTY_TOOLS_UI,
+ selectedToolName: "alpha",
+ });
+ const [callState, setCallState] = useState(
+ {
+ status: "ok",
+ result: { content: [{ type: "text", text: "ok" }] },
+ },
+ );
+ return (
+ setCallState(undefined)}
+ />
+ );
+ }
+ const user = userEvent.setup();
+ renderWithMantine();
+ // A result is present, so the result pane shows and the form is hidden.
+ expect(screen.getByText("Results")).toBeInTheDocument();
+ expect(screen.queryByRole("button", { name: /Execute/ })).toBeNull();
+ // Dismissing the result flips back to the input form (Execute reappears).
+ await user.click(screen.getByRole("button", { name: "Close results" }));
+ expect(
+ await screen.findByRole("button", { name: /Execute/ }),
+ ).toBeInTheDocument();
+ expect(screen.queryByText("Results")).toBeNull();
});
it("renders the result panel when callState has a result", () => {
@@ -152,7 +195,7 @@ describe("ToolsScreen", () => {
expect(onCallTool).toHaveBeenCalledWith("gamma", { mode: "fast" }, false);
});
- it("invokes onClearResult when Clear is clicked on the result panel", async () => {
+ it("invokes onClearResult when the result close button is clicked", async () => {
const user = userEvent.setup();
const onClearResult = vi.fn();
renderWithMantine(
@@ -165,7 +208,7 @@ describe("ToolsScreen", () => {
}}
/>,
);
- await user.click(screen.getByRole("button", { name: "Clear" }));
+ await user.click(screen.getByRole("button", { name: "Close results" }));
expect(onClearResult).toHaveBeenCalledTimes(1);
});
@@ -243,7 +286,7 @@ describe("ToolsScreen", () => {
}}
/>,
);
- await user.click(screen.getByRole("button", { name: "Clear" }));
+ await user.click(screen.getByRole("button", { name: "Close results" }));
expect(screen.getByText("Results")).toBeInTheDocument();
});
});
diff --git a/clients/web/src/components/screens/ToolsScreen/ToolsScreen.tsx b/clients/web/src/components/screens/ToolsScreen/ToolsScreen.tsx
index 1ce4293c1..e62129aa1 100644
--- a/clients/web/src/components/screens/ToolsScreen/ToolsScreen.tsx
+++ b/clients/web/src/components/screens/ToolsScreen/ToolsScreen.tsx
@@ -162,7 +162,19 @@ export function ToolsScreen({
- {selectedTool ? (
+ {callState?.result ? (
+ // Results replace the input form while present, and the panel's
+ // top-left X dismisses them back to the form (#1661) — the Prompts
+ // screen pattern. `formValues` live in the lifted UI state, so the
+ // form is restored intact for a re-run. A call in flight sets a
+ // `pending` state with no `result` (App.tsx), so the executing form
+ // (progress + cancel) shows until the result lands.
+ onClearResult?.()}
+ onReadResource={onReadResource}
+ />
+ ) : selectedTool ? (
-
-
-
- {callState?.result ? (
- onClearResult?.()}
- onReadResource={onReadResource}
- />
- ) : (
- Results will appear here
- )}
-
-
);
}
diff --git a/clients/web/src/components/views/InspectorView/InspectorView.test.tsx b/clients/web/src/components/views/InspectorView/InspectorView.test.tsx
index ab2922ade..2cf66769c 100644
--- a/clients/web/src/components/views/InspectorView/InspectorView.test.tsx
+++ b/clients/web/src/components/views/InspectorView/InspectorView.test.tsx
@@ -20,7 +20,7 @@ import {
} from "../../../test/renderWithMantine";
import { InspectorView, type InspectorViewProps } from "./InspectorView";
-// The monitoring column (#1616) is gated on a 1040px viewport media query.
+// The monitoring sidebar (#1616) is gated on a 1040px viewport media query.
// happy-dom's viewport is 1024px, so that query is really false; make just that
// gate controllable per test. ViewHeader's own 992/768 queries stay "wide" (as
// they are in the real 1024px happy-dom viewport), so header rendering — and
@@ -1298,7 +1298,7 @@ describe("InspectorView", () => {
});
});
- describe("pinned monitoring column (#1616)", () => {
+ describe("pinned monitoring sidebar (#1616)", () => {
const httpServer: ServerEntry = {
id: "beta",
name: "Beta",
@@ -1330,26 +1330,44 @@ describe("InspectorView", () => {
return user;
}
- it("shows the Pin as column button on a monitor screen only when wide", async () => {
+ it("shows the header monitoring toggle only when wide", async () => {
monitorWide.value = false;
const { unmount } = renderWithMantine(
,
);
- await gotoTab("Logs");
expect(
- screen.queryByRole("button", { name: "Pin as column" }),
+ screen.queryByRole("button", { name: "Open monitoring sidebar" }),
).not.toBeInTheDocument();
unmount();
monitorWide.value = true;
renderWithMantine();
- await gotoTab("Logs");
expect(
- await screen.findByRole("button", { name: "Pin as column" }),
+ await screen.findByRole("button", { name: "Open monitoring sidebar" }),
).toBeInTheDocument();
});
- it("opens the monitoring column when a connection is established", async () => {
+ it("hides the header monitoring toggle when there is no connected or failed server", () => {
+ // Disconnected, wide viewport: nothing to monitor, so no toggle appears.
+ monitorWide.value = true;
+ renderWithMantine(
+ ,
+ );
+ expect(
+ screen.queryByRole("button", { name: "Open monitoring sidebar" }),
+ ).toBeNull();
+ expect(
+ screen.queryByRole("button", { name: "Close monitoring sidebar" }),
+ ).toBeNull();
+ });
+
+ it("opens the monitoring sidebar when a connection is established", async () => {
monitorWide.value = true;
const { rerender } = renderWithMantine(
{
);
// Disconnected: the column is closed.
expect(
- screen.queryByRole("button", { name: "Close monitoring column" }),
+ screen.queryByRole("button", { name: "Close monitoring sidebar" }),
).toBeNull();
// Connecting opens it (the disconnected → connected transition).
rerender();
expect(
- await screen.findByRole("button", { name: "Close monitoring column" }),
+ await screen.findByRole("button", { name: "Close monitoring sidebar" }),
).toBeInTheDocument();
});
@@ -1378,7 +1396,7 @@ describe("InspectorView", () => {
monitorWide.value = true;
renderWithMantine();
expect(
- screen.queryByRole("button", { name: "Close monitoring column" }),
+ screen.queryByRole("button", { name: "Close monitoring sidebar" }),
).toBeNull();
});
@@ -1397,7 +1415,7 @@ describe("InspectorView", () => {
});
}
- it("opens the monitoring column on a failed connection attempt (#1621)", async () => {
+ it("opens the monitoring sidebar on a failed connection attempt (#1621)", async () => {
monitorWide.value = true;
const { rerender } = renderWithMantine(
{
);
// Disconnected: the column is closed.
expect(
- screen.queryByRole("button", { name: "Close monitoring column" }),
+ screen.queryByRole("button", { name: "Close monitoring sidebar" }),
).toBeNull();
// A connection failure (→ error) opens it to surface the diagnostics. The
@@ -1433,7 +1451,7 @@ describe("InspectorView", () => {
/>,
);
expect(
- await screen.findByRole("button", { name: "Close monitoring column" }),
+ await screen.findByRole("button", { name: "Close monitoring sidebar" }),
).toBeInTheDocument();
// The failure column leads with Network (the captured request) — the only
@@ -1548,7 +1566,7 @@ describe("InspectorView", () => {
/>,
);
expect(
- screen.queryByRole("button", { name: "Close monitoring column" }),
+ screen.queryByRole("button", { name: "Close monitoring sidebar" }),
).toBeNull();
});
@@ -1604,7 +1622,7 @@ describe("InspectorView", () => {
monitorWide.value = true;
renderWithMantine();
expect(
- screen.queryByRole("button", { name: "Close monitoring column" }),
+ screen.queryByRole("button", { name: "Close monitoring sidebar" }),
).toBeNull();
});
@@ -1621,7 +1639,7 @@ describe("InspectorView", () => {
);
// Connected + pinned: the column is open with the live monitor tabs.
expect(
- await screen.findByRole("button", { name: "Close monitoring column" }),
+ await screen.findByRole("button", { name: "Close monitoring sidebar" }),
).toBeInTheDocument();
// Crash: connected → error, activeServer cleared, NO erroredServerId.
@@ -1638,7 +1656,7 @@ describe("InspectorView", () => {
// failure tabs.
await waitFor(() =>
expect(
- screen.queryByRole("button", { name: "Close monitoring column" }),
+ screen.queryByRole("button", { name: "Close monitoring sidebar" }),
).toBeNull(),
);
});
@@ -1702,15 +1720,15 @@ describe("InspectorView", () => {
it("pins the monitor group into the column and removes it from the header", async () => {
monitorWide.value = true;
+ const user = userEvent.setup();
renderWithMantine();
- const user = await gotoTab("Logs");
await user.click(
- await screen.findByRole("button", { name: "Pin as column" }),
+ await screen.findByRole("button", { name: "Open monitoring sidebar" }),
);
// Column is open (its close control is present).
expect(
- await screen.findByRole("button", { name: "Close monitoring column" }),
+ await screen.findByRole("button", { name: "Close monitoring sidebar" }),
).toBeInTheDocument();
// The monitor group is gone from the header tab bar...
@@ -1736,19 +1754,19 @@ describe("InspectorView", () => {
it("returns the monitor group to the header when the column is closed", async () => {
monitorWide.value = true;
+ const user = userEvent.setup();
renderWithMantine();
- const user = await gotoTab("Logs");
await user.click(
- await screen.findByRole("button", { name: "Pin as column" }),
+ await screen.findByRole("button", { name: "Open monitoring sidebar" }),
);
await user.click(
- await screen.findByRole("button", { name: "Close monitoring column" }),
+ await screen.findByRole("button", { name: "Close monitoring sidebar" }),
);
// The column plays its slide-out animation before unmounting.
await waitFor(() =>
expect(
- screen.queryByRole("button", { name: "Close monitoring column" }),
+ screen.queryByRole("button", { name: "Close monitoring sidebar" }),
).toBeNull(),
);
const header = screen.getByRole("banner");
@@ -1760,9 +1778,12 @@ describe("InspectorView", () => {
it("keeps the current header screen (not the column's) when closed", async () => {
monitorWide.value = true;
renderWithMantine();
+ // Navigate the primary onto a monitor tab (Logs) first, then open the
+ // column — which moves the monitor group out of the header, so the primary
+ // clamps to the first non-Servers header tab (Tools).
const user = await gotoTab("Logs");
await user.click(
- await screen.findByRole("button", { name: "Pin as column" }),
+ await screen.findByRole("button", { name: "Open monitoring sidebar" }),
);
// Pinning moved the primary to the first non-Servers header tab (Tools).
const header = screen.getByRole("banner");
@@ -1773,14 +1794,14 @@ describe("InspectorView", () => {
// Switch the column to Protocol, then close it.
await user.click(await screen.findByRole("radio", { name: "Protocol" }));
await user.click(
- await screen.findByRole("button", { name: "Close monitoring column" }),
+ await screen.findByRole("button", { name: "Close monitoring sidebar" }),
);
// The primary stays on the header's current screen (Tools), not the
// column's Protocol, and the monitor group returns to the header.
await waitFor(() =>
expect(
- screen.queryByRole("button", { name: "Close monitoring column" }),
+ screen.queryByRole("button", { name: "Close monitoring sidebar" }),
).toBeNull(),
);
expect(
@@ -1801,7 +1822,7 @@ describe("InspectorView", () => {
,
);
expect(
- screen.queryByRole("button", { name: "Close monitoring column" }),
+ screen.queryByRole("button", { name: "Close monitoring sidebar" }),
).toBeNull();
expect(
within(screen.getByRole("banner")).getByRole("radio", { name: "Logs" }),
@@ -1812,7 +1833,7 @@ describe("InspectorView", () => {
monitorWide.value = true;
renderWithMantine();
expect(
- screen.getByRole("button", { name: "Close monitoring column" }),
+ screen.getByRole("button", { name: "Close monitoring sidebar" }),
).toBeInTheDocument();
});
@@ -1821,7 +1842,7 @@ describe("InspectorView", () => {
monitorWide.value = true;
renderWithMantine();
expect(
- screen.queryByRole("button", { name: "Close monitoring column" }),
+ screen.queryByRole("button", { name: "Close monitoring sidebar" }),
).toBeNull();
});
@@ -1832,7 +1853,7 @@ describe("InspectorView", () => {
,
);
expect(
- screen.getByRole("button", { name: "Close monitoring column" }),
+ screen.getByRole("button", { name: "Close monitoring sidebar" }),
).toBeInTheDocument();
rerender(
@@ -1847,7 +1868,7 @@ describe("InspectorView", () => {
// The column plays its slide-out animation before unmounting.
await waitFor(() =>
expect(
- screen.queryByRole("button", { name: "Close monitoring column" }),
+ screen.queryByRole("button", { name: "Close monitoring sidebar" }),
).toBeNull(),
);
// Preference is untouched — only the column's close button clears it.
@@ -1871,7 +1892,7 @@ describe("InspectorView", () => {
);
// Column open, but Network is unavailable over stdio.
expect(
- screen.getByRole("button", { name: "Close monitoring column" }),
+ screen.getByRole("button", { name: "Close monitoring sidebar" }),
).toBeInTheDocument();
expect(screen.getByRole("radio", { name: "Logs" })).toBeInTheDocument();
expect(screen.queryByRole("radio", { name: "Network" })).toBeNull();
@@ -1919,7 +1940,7 @@ describe("InspectorView", () => {
.map((r) => r.getAttribute("value")),
).toEqual(["Servers"]);
expect(
- screen.getByRole("button", { name: "Close monitoring column" }),
+ screen.getByRole("button", { name: "Close monitoring sidebar" }),
).toBeInTheDocument();
});
@@ -1982,7 +2003,7 @@ describe("InspectorView", () => {
monitorWide.value = true;
renderWithMantine();
const handle = await screen.findByRole("separator", {
- name: "Resize monitoring column",
+ name: "Resize monitoring sidebar",
});
// ArrowLeft widens by the 16px step (panel is on the right).
fireEvent.keyDown(handle, { key: "ArrowLeft" });
diff --git a/clients/web/src/components/views/InspectorView/InspectorView.tsx b/clients/web/src/components/views/InspectorView/InspectorView.tsx
index 2a9ab41bc..46e97d6ef 100644
--- a/clients/web/src/components/views/InspectorView/InspectorView.tsx
+++ b/clients/web/src/components/views/InspectorView/InspectorView.tsx
@@ -168,7 +168,7 @@ const ALL_TABS: string[] = [
CONSOLE_TAB,
];
-// The screens that can be pinned into the monitoring column (#1616). Pinning is
+// The screens that can be pinned into the monitoring sidebar (#1616). Pinning is
// a group action: opening the column removes all *available* monitor tabs from
// the header and hosts them in the column instead. Console (#1621) is the
// stdio server's stderr stream — mutually exclusive with Network (Console shows
@@ -195,7 +195,7 @@ function isMonitorTab(tab: string): tab is MonitorTab {
// has room for at least one full-width card beside the column.
const MONITOR_WIDE_QUERY = "(min-width: 1040px)";
-// Monitoring column width bounds (px). MIN keeps the stream readable; MAX stops
+// Monitoring sidebar width bounds (px). MIN keeps the stream readable; MAX stops
// the column from crowding out the primary area.
const MONITOR_WIDTH_MIN = 320;
const MONITOR_WIDTH_MAX = 720;
@@ -267,7 +267,7 @@ const SplitRow = Flex.withProps({
w: "100%",
});
-// The pinned monitoring column. Fixed-basis (its width is driven live via the
+// The pinned monitoring sidebar. Fixed-basis (its width is driven live via the
// `w` style prop at the call site); `miw: 0` so its inner ScrollArea can bound.
const MonitoringColumn = Stack.withProps({
flex: "0 0 auto",
@@ -663,7 +663,7 @@ export function InspectorView({
getInitialValueInEffect: false,
});
- // Open the monitoring column when a connection is established (#1616) OR when a
+ // Open the monitoring sidebar when a connection is established (#1616) OR when a
// connect *attempt* fails (#1621). Gated on the *transition into* the target
// status (via the ref) rather than the status itself, so it fires once on an
// actual connect/failure — not on every render, and not on a mount that starts
@@ -765,7 +765,7 @@ export function InspectorView({
appTools,
]);
- // Monitoring column, derived (#1616, #1621). The monitor group is pinned into
+ // Monitoring sidebar, derived (#1616, #1621). The monitor group is pinned into
// the right column only when: the user asked for it, the viewport is wide
// enough, the session is connected OR a connect attempt failed, and at least
// one monitor tab is actually available (capability/stdio aware). Narrowing,
@@ -807,11 +807,14 @@ export function InspectorView({
}
return [];
}, [connected, failed, availableTabs, stderrLogs, network, protocol]);
- const effectivePinned =
- monitorPinned &&
- !!isWide &&
- (connected || failed) &&
- monitorAvailable.length > 0;
+ // The column can exist when: the viewport is wide enough, the session is
+ // connected OR a connect attempt failed, and at least one monitor tab is
+ // actually available. This is the same rule the server list used to gate its
+ // open-sidebar button on, and it now also gates the header MonitoringToggle
+ // (#1661) — the toggle is hidden entirely when the column can't exist.
+ const monitorColumnAvailable =
+ !!isWide && (connected || failed) && monitorAvailable.length > 0;
+ const effectivePinned = monitorPinned && monitorColumnAvailable;
// The header loses the monitor group while the column is open (its screens
// live in the column instead); otherwise it shows every available tab.
@@ -846,14 +849,7 @@ export function InspectorView({
? monitorTab
: (monitorAvailable.find(isMonitorTab) ?? LOGS_TAB);
- // Pinning is a group action: remember which tab was clicked and open the
- // column. Gated by `canPin` so the pin affordance only appears when a click
- // would actually take effect (wide + connected).
- const canPin = !!isWide && connected;
- // Offer an "open monitoring column" affordance on the server list when a
- // connected server has monitor screens to show but the column isn't open yet.
- const canOpenMonitor =
- canPin && monitorAvailable.length > 0 && !effectivePinned;
+ // Pinning is a group action: remember which tab to show and open the column.
function pinMonitor(tab: MonitorTab) {
setMonitorTab(tab);
setMonitorPinned(true);
@@ -872,6 +868,20 @@ export function InspectorView({
onActiveTabChange(activeTab);
setMonitorPinned(false);
}
+ // The single header toggle (#1661): open the column onto the last-used monitor
+ // tab (clamped to what's available) when closed, close it when open.
+ function toggleMonitorColumn() {
+ if (effectivePinned) {
+ closeMonitorColumn();
+ } else {
+ pinMonitor(effectiveMonitorTab);
+ }
+ }
+ // Only offer the toggle while the column can actually exist; otherwise the
+ // header shows nothing to toggle.
+ const monitorToggle = monitorColumnAvailable
+ ? { open: effectivePinned, onToggle: toggleMonitorColumn }
+ : undefined;
function commitMonitorWidth(next: number) {
setMonitorWidth(next);
setDragWidth(null);
@@ -934,7 +944,8 @@ export function InspectorView({
// Shared props for each monitor screen, spread into both its primary
// (header-tab) instance and its embedded (column) instance so the two can't
- // drift. The primary adds `onPin`; the embedded adds `embedded`. (#1616)
+ // drift. The embedded instance adds `embedded`; the column is opened/closed
+ // from the single header MonitoringToggle (#1661), not per-screen. (#1616)
const loggingScreenProps = {
entries: logs,
currentLevel: currentLogLevel,
@@ -1039,12 +1050,14 @@ export function InspectorView({
onDisconnect={onDisconnect}
onToggleTheme={onToggleTheme}
onOpenClientSettings={onOpenClientSettings}
+ monitorToggle={monitorToggle}
/>
) : (
)}
@@ -1072,11 +1085,6 @@ export function InspectorView({
onClearHighlight={onClearHighlight}
compact={serversCompact}
onToggleCompact={() => setServersCompact((c) => !c)}
- onOpenMonitor={
- canOpenMonitor
- ? () => pinMonitor(effectiveMonitorTab)
- : undefined
- }
/>
@@ -1156,28 +1164,16 @@ export function InspectorView({
/>
- pinMonitor(LOGS_TAB) : undefined}
- />
+
- pinMonitor(PROTOCOL_TAB) : undefined}
- />
+
- pinMonitor(NETWORK_TAB) : undefined}
- />
+
- pinMonitor(CONSOLE_TAB) : undefined}
- />
+
diff --git a/clients/web/src/components/views/InspectorView/monitorColumnAnimation.ts b/clients/web/src/components/views/InspectorView/monitorColumnAnimation.ts
index 25017544e..619558b28 100644
--- a/clients/web/src/components/views/InspectorView/monitorColumnAnimation.ts
+++ b/clients/web/src/components/views/InspectorView/monitorColumnAnimation.ts
@@ -1,5 +1,5 @@
/**
- * Duration (ms) of the monitoring column's open/close slide (#1616). Shared so
+ * Duration (ms) of the monitoring sidebar's open/close slide (#1616). Shared so
* dependent timings can't silently drift from the animation:
* - `InspectorView` drives the column's Mantine `Transition` with it.
* - `ServerCard` waits just past it before scrolling a newly-failed card into
diff --git a/clients/web/src/theme/Text.ts b/clients/web/src/theme/Text.ts
index 7a9ae7824..0a84285bd 100644
--- a/clients/web/src/theme/Text.ts
+++ b/clients/web/src/theme/Text.ts
@@ -28,7 +28,7 @@ export const ThemeText = Text.extend({
}
// A line of captured server stderr (#1621): preserve the process's own
// newlines/whitespace (`pre-wrap`) while still wrapping over-long lines
- // inside the narrow monitoring column (`break-word`).
+ // inside the narrow monitoring sidebar (`break-word`).
if (props.variant === "consoleLine") {
return {
root: {