From 2a8f27b011fe4c342f1d45dc61c8c561503ba279 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Sat, 11 Jul 2026 15:59:25 -0400 Subject: [PATCH 1/4] feat(web): single header MonitoringToggle + Prompts-style Tools results (#1661) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Consolidate the monitoring column's open/close affordances into one header toggle, and align the Tools/Prompts result panes and the sidebar log layout. - Add a single `MonitoringToggle` in the header, to the right of the theme icon. It opens/closes the monitoring column and only shows when a server is connected OR a connect attempt failed (wide viewport + a monitor tab available) — the same availability rule the column uses. - Remove the scattered pin/close icons: delete `PinColumnButton`; drop `onPin` from the Logs/Protocol/Network/Console screens and their stream panels; drop `onOpenMonitor` from the server list; remove the close button from `MonitoringControls`/`MonitoringScreen`. - Tools screen: remove the "Results will appear here" placeholder pane. Results now replace the input form in a single content pane (the Prompts pattern), with a top-left X (ToolResultPanel) that dismisses them back to the form; form values are preserved for a re-run. - Prompts screen: the argument form fills the content pane width (drop the 40% cap), matching the Tools input. - LogEntry: add a `compact` two-line layout (time/level/logger on line one, message wrapping below) used by the embedded monitoring-column Logs. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_015sMW1YZXkM7fDkdPtHpE95 --- .../elements/LogEntry/LogEntry.stories.tsx | 25 +++++++ .../elements/LogEntry/LogEntry.test.tsx | 17 +++++ .../components/elements/LogEntry/LogEntry.tsx | 57 ++++++++++++++-- .../MonitoringToggle.stories.tsx | 32 +++++++++ .../MonitoringToggle.test.tsx | 36 ++++++++++ .../MonitoringToggle/MonitoringToggle.tsx | 38 +++++++++++ .../PinColumnButton.stories.tsx | 21 ------ .../PinColumnButton/PinColumnButton.test.tsx | 31 --------- .../PinColumnButton/PinColumnButton.tsx | 29 -------- .../groups/LogStreamPanel/LogStreamPanel.tsx | 13 +--- .../MonitoringControls.stories.tsx | 4 -- .../MonitoringControls.test.tsx | 12 ---- .../MonitoringControls/MonitoringControls.tsx | 33 +++------ .../MonitoringScreen.stories.tsx | 1 - .../MonitoringScreen.test.tsx | 13 +--- .../MonitoringScreen/MonitoringScreen.tsx | 3 - .../NetworkStreamPanel/NetworkStreamPanel.tsx | 5 -- .../ProtocolListPanel/ProtocolListPanel.tsx | 5 -- .../ServerListControls.test.tsx | 25 ------- .../ServerListControls/ServerListControls.tsx | 11 --- .../ToolResultPanel/ToolResultPanel.test.tsx | 4 +- .../ToolResultPanel/ToolResultPanel.tsx | 25 ++++--- .../groups/ViewHeader/ViewHeader.test.tsx | 51 ++++++++++++++ .../groups/ViewHeader/ViewHeader.tsx | 19 ++++++ .../ConsoleScreen/ConsoleScreen.test.tsx | 9 --- .../screens/ConsoleScreen/ConsoleScreen.tsx | 5 -- .../LoggingScreen/LoggingScreen.test.tsx | 8 --- .../screens/LoggingScreen/LoggingScreen.tsx | 7 -- .../NetworkScreen/NetworkScreen.test.tsx | 8 --- .../screens/NetworkScreen/NetworkScreen.tsx | 4 -- .../screens/PromptsScreen/PromptsScreen.tsx | 8 +-- .../ProtocolScreen/ProtocolScreen.test.tsx | 8 --- .../screens/ProtocolScreen/ProtocolScreen.tsx | 4 -- .../ServerListScreen/ServerListScreen.tsx | 8 --- .../screens/ToolsScreen/ToolsScreen.test.tsx | 57 ++++++++++++++-- .../screens/ToolsScreen/ToolsScreen.tsx | 28 ++++---- .../InspectorView/InspectorView.test.tsx | 41 +++++++++--- .../views/InspectorView/InspectorView.tsx | 67 +++++++++---------- 38 files changed, 433 insertions(+), 339 deletions(-) create mode 100644 clients/web/src/components/elements/MonitoringToggle/MonitoringToggle.stories.tsx create mode 100644 clients/web/src/components/elements/MonitoringToggle/MonitoringToggle.test.tsx create mode 100644 clients/web/src/components/elements/MonitoringToggle/MonitoringToggle.tsx delete mode 100644 clients/web/src/components/elements/PinColumnButton/PinColumnButton.stories.tsx delete mode 100644 clients/web/src/components/elements/PinColumnButton/PinColumnButton.test.tsx delete mode 100644 clients/web/src/components/elements/PinColumnButton/PinColumnButton.tsx 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..165abd28f 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 column (#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 column): 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..be4154e2a --- /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 column" }), + ).toBeInTheDocument(); + }, +}; + +export const Open: Story = { + args: { open: true }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + expect( + canvas.getByRole("button", { name: "Close monitoring column" }), + ).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..2ce7793f1 --- /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 column" }), + ).toBeInTheDocument(); + expect( + screen.queryByRole("button", { name: "Close monitoring column" }), + ).toBeNull(); + }); + + it("renders a close-column button when open", () => { + renderWithMantine(); + expect( + screen.getByRole("button", { name: "Close monitoring column" }), + ).toBeInTheDocument(); + expect( + screen.queryByRole("button", { name: "Open monitoring column" }), + ).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 column" }), + ); + 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..4eaf30300 --- /dev/null +++ b/clients/web/src/components/elements/MonitoringToggle/MonitoringToggle.tsx @@ -0,0 +1,38 @@ +import { ActionIcon } from "@mantine/core"; +import { + TbLayoutSidebarRightExpand, + TbLayoutSidebarRightCollapse, +} from "react-icons/tb"; + +export interface MonitoringToggleProps { + /** Whether the monitoring column is currently open. */ + open: boolean; + /** Open the column when closed, close it when open. */ + onToggle: () => void; +} + +/** + * The single header affordance for the monitoring column (#1661). It replaces + * the per-screen "pin as column" buttons, the server-list open-sidebar button, + * and the column's own close button — one toggle, placed in the header to the + * right of the theme icon, that opens or closes the column on demand. The glyph + * and label reflect the current state (expand when closed, collapse when open). + * The caller only mounts it when the column 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 column" : "Open monitoring column"; + + 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/groups/LogStreamPanel/LogStreamPanel.tsx b/clients/web/src/components/groups/LogStreamPanel/LogStreamPanel.tsx index 24fda38ce..858c8bc66 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"; @@ -19,12 +18,6 @@ export interface LogStreamPanelProps { onExport: () => void; 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 * scroll region from the viewport-height calc to filling its flex parent, so @@ -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 column; + // 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..bf8c2bb6f 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. +// 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 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). 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..2640c69c0 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. @@ -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/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/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/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..2e71958d0 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 column" }), + ).toBeNull(); + expect( + screen.queryByRole("button", { name: "Close monitoring column" }), + ).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 column" }), + ); + 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 column" }), + ).toBeNull(); + await user.click( + screen.getByRole("button", { name: "Close monitoring column" }), + ); + 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..a643fded4 100644 --- a/clients/web/src/components/groups/ViewHeader/ViewHeader.tsx +++ b/clients/web/src/components/groups/ViewHeader/ViewHeader.tsx @@ -18,9 +18,18 @@ 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 } from "../../elements/MonitoringToggle/MonitoringToggle"; import mcpLogo from "../../../theme/assets/MCP.svg"; import mcpLogoDark from "../../../theme/assets/MCP-dark.svg"; +// The single monitoring-column affordance (#1661): its open state and toggle +// callback. Present only when the column is available (connected, or a failed +// connect attempt, on a wide viewport); undefined otherwise so no toggle shows. +export interface MonitorToggleProps { + open: boolean; + onToggle: () => void; +} + interface ConnectedProps { connected: true; serverInfo: Implementation; @@ -32,12 +41,16 @@ interface ConnectedProps { onDisconnect: () => void; onToggleTheme: () => void; onOpenClientSettings: () => void; + /** Monitoring-column toggle, shown to the right of the theme icon. */ + monitorToggle?: MonitorToggleProps; } interface UnconnectedProps { connected: false; onToggleTheme: () => void; onOpenClientSettings: () => void; + /** Monitoring-column toggle, shown to the right of the theme icon. */ + monitorToggle?: MonitorToggleProps; } export type ViewHeaderProps = ConnectedProps | UnconnectedProps; @@ -395,6 +408,12 @@ export function ViewHeader(props: ViewHeaderProps) { + {props.monitorToggle ? ( + + ) : null} ); 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..c7b800a59 100644 --- a/clients/web/src/components/screens/LoggingScreen/LoggingScreen.tsx +++ b/clients/web/src/components/screens/LoggingScreen/LoggingScreen.tsx @@ -16,11 +16,6 @@ export interface LoggingScreenProps { onExport: () => void; 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 * parent's height (instead of the viewport calc) and drops the filter @@ -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..30dcaf412 100644 --- a/clients/web/src/components/views/InspectorView/InspectorView.test.tsx +++ b/clients/web/src/components/views/InspectorView/InspectorView.test.tsx @@ -1330,25 +1330,43 @@ 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 column" }), ).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 column" }), ).toBeInTheDocument(); }); + 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 column" }), + ).toBeNull(); + expect( + screen.queryByRole("button", { name: "Close monitoring column" }), + ).toBeNull(); + }); + it("opens the monitoring column when a connection is established", async () => { monitorWide.value = true; const { rerender } = renderWithMantine( @@ -1702,10 +1720,10 @@ 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 column" }), ); // Column is open (its close control is present). @@ -1736,10 +1754,10 @@ 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 column" }), ); await user.click( await screen.findByRole("button", { name: "Close monitoring column" }), @@ -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 column" }), ); // Pinning moved the primary to the first non-Servers header tab (Tools). const header = screen.getByRole("banner"); diff --git a/clients/web/src/components/views/InspectorView/InspectorView.tsx b/clients/web/src/components/views/InspectorView/InspectorView.tsx index 2a9ab41bc..22094a613 100644 --- a/clients/web/src/components/views/InspectorView/InspectorView.tsx +++ b/clients/web/src/components/views/InspectorView/InspectorView.tsx @@ -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} - /> + From 048c6563415d7cab8839ed631302c73ebbb07f94 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Sat, 11 Jul 2026 16:16:59 -0400 Subject: [PATCH 2/4] =?UTF-8?q?polish(web):=20tooltips=20on=20icon=20butto?= =?UTF-8?q?ns=20+=20rename=20"monitoring=20column"=20=E2=86=92=20"monitori?= =?UTF-8?q?ng=20sidebar"=20(#1661)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add Mantine `Tooltip`s to the icon-only controls: Client settings, Theme (dynamic light/dark label), the monitoring-sidebar toggle, the list-level and per-entry Expand/Collapse toggles, the Pin/Unpin toggle, and the Replay button. Labels mirror each control's existing aria-label. - Rename the feature term "monitoring column" → "monitoring sidebar" across the user-facing labels (Open/Close/Resize), comments, and tests, so the UI calls it a sidebar consistently. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_015sMW1YZXkM7fDkdPtHpE95 --- .../EmbeddableScrollArea.tsx | 4 +- .../elements/ExpandToggle/ExpandToggle.tsx | 22 ++++--- .../elements/ListToggle/ListToggle.tsx | 40 ++++++----- .../components/elements/LogEntry/LogEntry.tsx | 4 +- .../MonitoringToggle.stories.tsx | 4 +- .../MonitoringToggle.test.tsx | 10 +-- .../MonitoringToggle/MonitoringToggle.tsx | 26 ++++---- .../elements/PinToggle/PinToggle.tsx | 22 ++++--- .../elements/ReplayButton/ReplayButton.tsx | 22 ++++--- .../ResizeHandle/ResizeHandle.test.tsx | 4 +- .../elements/ScreenStage/ScreenStage.tsx | 4 +- .../groups/LogStreamPanel/LogStreamPanel.tsx | 4 +- .../MonitoringControls/MonitoringControls.tsx | 4 +- .../MonitoringScreen/MonitoringScreen.tsx | 2 +- .../groups/NetworkEntry/NetworkEntry.tsx | 2 +- .../groups/ProtocolEntry/ProtocolEntry.tsx | 2 +- .../groups/ServerCard/ServerCard.tsx | 2 +- .../groups/ViewHeader/ViewHeader.test.tsx | 10 +-- .../groups/ViewHeader/ViewHeader.tsx | 23 +++++-- .../screens/LoggingScreen/LoggingScreen.tsx | 2 +- .../InspectorView/InspectorView.test.tsx | 66 +++++++++---------- .../views/InspectorView/InspectorView.tsx | 12 ++-- .../InspectorView/monitorColumnAnimation.ts | 2 +- clients/web/src/theme/Text.ts | 2 +- 24 files changed, 159 insertions(+), 136 deletions(-) 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.tsx b/clients/web/src/components/elements/LogEntry/LogEntry.tsx index 165abd28f..7587f9a39 100644 --- a/clients/web/src/components/elements/LogEntry/LogEntry.tsx +++ b/clients/web/src/components/elements/LogEntry/LogEntry.tsx @@ -13,7 +13,7 @@ export interface LogEntryData { export interface LogEntryProps { entry: LogEntryData; /** - * Compact two-line layout for the narrow monitoring column (#1661): the + * 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. @@ -64,7 +64,7 @@ const MessageText = Text.withProps({ ff: "monospace", }); -// Compact message (monitoring column): wraps over-long lines within the narrow +// 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", diff --git a/clients/web/src/components/elements/MonitoringToggle/MonitoringToggle.stories.tsx b/clients/web/src/components/elements/MonitoringToggle/MonitoringToggle.stories.tsx index be4154e2a..9541812e4 100644 --- a/clients/web/src/components/elements/MonitoringToggle/MonitoringToggle.stories.tsx +++ b/clients/web/src/components/elements/MonitoringToggle/MonitoringToggle.stories.tsx @@ -16,7 +16,7 @@ export const Closed: Story = { play: async ({ canvasElement }) => { const canvas = within(canvasElement); expect( - canvas.getByRole("button", { name: "Open monitoring column" }), + canvas.getByRole("button", { name: "Open monitoring sidebar" }), ).toBeInTheDocument(); }, }; @@ -26,7 +26,7 @@ export const Open: Story = { play: async ({ canvasElement }) => { const canvas = within(canvasElement); expect( - canvas.getByRole("button", { name: "Close monitoring column" }), + 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 index 2ce7793f1..baa1f3325 100644 --- a/clients/web/src/components/elements/MonitoringToggle/MonitoringToggle.test.tsx +++ b/clients/web/src/components/elements/MonitoringToggle/MonitoringToggle.test.tsx @@ -7,20 +7,20 @@ describe("MonitoringToggle", () => { it("renders an open-column button when closed", () => { renderWithMantine(); expect( - screen.getByRole("button", { name: "Open monitoring column" }), + screen.getByRole("button", { name: "Open monitoring sidebar" }), ).toBeInTheDocument(); expect( - screen.queryByRole("button", { name: "Close monitoring column" }), + screen.queryByRole("button", { name: "Close monitoring sidebar" }), ).toBeNull(); }); it("renders a close-column button when open", () => { renderWithMantine(); expect( - screen.getByRole("button", { name: "Close monitoring column" }), + screen.getByRole("button", { name: "Close monitoring sidebar" }), ).toBeInTheDocument(); expect( - screen.queryByRole("button", { name: "Open monitoring column" }), + screen.queryByRole("button", { name: "Open monitoring sidebar" }), ).toBeNull(); }); @@ -29,7 +29,7 @@ describe("MonitoringToggle", () => { const onToggle = vi.fn(); renderWithMantine(); await user.click( - screen.getByRole("button", { name: "Open monitoring column" }), + 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 index 4eaf30300..2abee59f8 100644 --- a/clients/web/src/components/elements/MonitoringToggle/MonitoringToggle.tsx +++ b/clients/web/src/components/elements/MonitoringToggle/MonitoringToggle.tsx @@ -1,18 +1,18 @@ -import { ActionIcon } from "@mantine/core"; +import { ActionIcon, Tooltip } from "@mantine/core"; import { TbLayoutSidebarRightExpand, TbLayoutSidebarRightCollapse, } from "react-icons/tb"; export interface MonitoringToggleProps { - /** Whether the monitoring column is currently open. */ + /** Whether the monitoring sidebar is currently open. */ open: boolean; /** Open the column when closed, close it when open. */ onToggle: () => void; } /** - * The single header affordance for the monitoring column (#1661). It replaces + * The single header affordance for the monitoring sidebar (#1661). It replaces * the per-screen "pin as column" buttons, the server-list open-sidebar button, * and the column's own close button — one toggle, placed in the header to the * right of the theme icon, that opens or closes the column on demand. The glyph @@ -23,16 +23,18 @@ export interface MonitoringToggleProps { */ export function MonitoringToggle({ open, onToggle }: MonitoringToggleProps) { const Icon = open ? TbLayoutSidebarRightCollapse : TbLayoutSidebarRightExpand; - const label = open ? "Close monitoring column" : "Open monitoring column"; + const label = open ? "Close monitoring sidebar" : "Open monitoring sidebar"; 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 858c8bc66..c94fb0c8d 100644 --- a/clients/web/src/components/groups/LogStreamPanel/LogStreamPanel.tsx +++ b/clients/web/src/components/groups/LogStreamPanel/LogStreamPanel.tsx @@ -19,7 +19,7 @@ export interface LogStreamPanelProps { sortDirection: SortDirection; onSortChange: (next: SortDirection) => 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. */ @@ -115,7 +115,7 @@ export function LogStreamPanel({ {filteredEntries.map((entry, index) => ( - // Compact (two-line) layout inside the narrow monitoring column; + // 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.tsx b/clients/web/src/components/groups/MonitoringControls/MonitoringControls.tsx index bf8c2bb6f..5e10a6e1a 100644 --- a/clients/web/src/components/groups/MonitoringControls/MonitoringControls.tsx +++ b/clients/web/src/components/groups/MonitoringControls/MonitoringControls.tsx @@ -12,7 +12,7 @@ export interface MonitoringControlsProps { onSearchChange: (next: string) => void; } -// The controls row at the top of the pinned monitoring column: a segmented tab +// 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. @@ -33,7 +33,7 @@ const SearchInput = TextInput.withProps({ }); /** - * Tab row + search for the pinned monitoring column (#1616). Renders only the + * 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 diff --git a/clients/web/src/components/groups/MonitoringScreen/MonitoringScreen.tsx b/clients/web/src/components/groups/MonitoringScreen/MonitoringScreen.tsx index 2640c69c0..ea15a8ff8 100644 --- a/clients/web/src/components/groups/MonitoringScreen/MonitoringScreen.tsx +++ b/clients/web/src/components/groups/MonitoringScreen/MonitoringScreen.tsx @@ -37,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. 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/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/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/ViewHeader/ViewHeader.test.tsx b/clients/web/src/components/groups/ViewHeader/ViewHeader.test.tsx index 2e71958d0..d5bd12fb4 100644 --- a/clients/web/src/components/groups/ViewHeader/ViewHeader.test.tsx +++ b/clients/web/src/components/groups/ViewHeader/ViewHeader.test.tsx @@ -109,10 +109,10 @@ describe("ViewHeader", () => { />, ); expect( - screen.queryByRole("button", { name: "Open monitoring column" }), + screen.queryByRole("button", { name: "Open monitoring sidebar" }), ).toBeNull(); expect( - screen.queryByRole("button", { name: "Close monitoring column" }), + screen.queryByRole("button", { name: "Close monitoring sidebar" }), ).toBeNull(); }); @@ -128,7 +128,7 @@ describe("ViewHeader", () => { />, ); await user.click( - screen.getByRole("button", { name: "Open monitoring column" }), + screen.getByRole("button", { name: "Open monitoring sidebar" }), ); expect(onToggle).toHaveBeenCalledTimes(1); }); @@ -165,10 +165,10 @@ describe("ViewHeader", () => { />, ); expect( - screen.queryByRole("button", { name: "Open monitoring column" }), + screen.queryByRole("button", { name: "Open monitoring sidebar" }), ).toBeNull(); await user.click( - screen.getByRole("button", { name: "Close monitoring column" }), + screen.getByRole("button", { name: "Close monitoring sidebar" }), ); expect(onToggle).toHaveBeenCalledTimes(1); }); diff --git a/clients/web/src/components/groups/ViewHeader/ViewHeader.tsx b/clients/web/src/components/groups/ViewHeader/ViewHeader.tsx index a643fded4..23392aaa8 100644 --- a/clients/web/src/components/groups/ViewHeader/ViewHeader.tsx +++ b/clients/web/src/components/groups/ViewHeader/ViewHeader.tsx @@ -10,6 +10,7 @@ import { Select, Text, Title, + Tooltip, Transition, useComputedColorScheme, } from "@mantine/core"; @@ -402,12 +403,22 @@ export function ViewHeader(props: ViewHeaderProps) { ) } - - - - - - + + + + + + + + + + {props.monitorToggle ? ( 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. */ diff --git a/clients/web/src/components/views/InspectorView/InspectorView.test.tsx b/clients/web/src/components/views/InspectorView/InspectorView.test.tsx index 30dcaf412..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", @@ -1336,14 +1336,14 @@ describe("InspectorView", () => { , ); expect( - screen.queryByRole("button", { name: "Open monitoring column" }), + screen.queryByRole("button", { name: "Open monitoring sidebar" }), ).not.toBeInTheDocument(); unmount(); monitorWide.value = true; renderWithMantine(); expect( - await screen.findByRole("button", { name: "Open monitoring column" }), + await screen.findByRole("button", { name: "Open monitoring sidebar" }), ).toBeInTheDocument(); }); @@ -1360,14 +1360,14 @@ describe("InspectorView", () => { />, ); expect( - screen.queryByRole("button", { name: "Open monitoring column" }), + screen.queryByRole("button", { name: "Open monitoring sidebar" }), ).toBeNull(); expect( - screen.queryByRole("button", { name: "Close monitoring column" }), + screen.queryByRole("button", { name: "Close monitoring sidebar" }), ).toBeNull(); }); - it("opens the monitoring column when a connection is established", async () => { + 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(); }); @@ -1396,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(); }); @@ -1415,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 @@ -1451,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 @@ -1566,7 +1566,7 @@ describe("InspectorView", () => { />, ); expect( - screen.queryByRole("button", { name: "Close monitoring column" }), + screen.queryByRole("button", { name: "Close monitoring sidebar" }), ).toBeNull(); }); @@ -1622,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(); }); @@ -1639,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. @@ -1656,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(), ); }); @@ -1723,12 +1723,12 @@ describe("InspectorView", () => { const user = userEvent.setup(); renderWithMantine(); await user.click( - await screen.findByRole("button", { name: "Open monitoring 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... @@ -1757,16 +1757,16 @@ describe("InspectorView", () => { const user = userEvent.setup(); renderWithMantine(); await user.click( - await screen.findByRole("button", { name: "Open monitoring 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"); @@ -1783,7 +1783,7 @@ describe("InspectorView", () => { // clamps to the first non-Servers header tab (Tools). const user = await gotoTab("Logs"); await user.click( - await screen.findByRole("button", { name: "Open monitoring 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"); @@ -1794,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( @@ -1822,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" }), @@ -1833,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(); }); @@ -1842,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(); }); @@ -1853,7 +1853,7 @@ describe("InspectorView", () => { , ); expect( - screen.getByRole("button", { name: "Close monitoring column" }), + screen.getByRole("button", { name: "Close monitoring sidebar" }), ).toBeInTheDocument(); rerender( @@ -1868,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. @@ -1892,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(); @@ -1940,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(); }); @@ -2003,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 22094a613..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, @@ -1194,7 +1194,7 @@ export function InspectorView({ step={MONITOR_WIDTH_STEP} onChange={setDragWidth} onCommit={commitMonitorWidth} - aria-label="Resize monitoring column" + aria-label="Resize monitoring sidebar" /> Date: Sat, 11 Jul 2026 17:05:11 -0400 Subject: [PATCH 3/4] review: consistent sidebar wording + reuse MonitoringToggleProps (#1661) Address the two optional notes from the PR #1662 review: - Make the MonitoringToggle JSDoc voice consistent ("sidebar" throughout, no lingering "column"). - Drop ViewHeader's duplicate `MonitorToggleProps` and reuse the element's exported `MonitoringToggleProps` as the single source of truth. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_015sMW1YZXkM7fDkdPtHpE95 --- .../MonitoringToggle/MonitoringToggle.tsx | 12 ++++----- .../groups/ViewHeader/ViewHeader.tsx | 25 +++++++++---------- 2 files changed, 18 insertions(+), 19 deletions(-) diff --git a/clients/web/src/components/elements/MonitoringToggle/MonitoringToggle.tsx b/clients/web/src/components/elements/MonitoringToggle/MonitoringToggle.tsx index 2abee59f8..a7143a59f 100644 --- a/clients/web/src/components/elements/MonitoringToggle/MonitoringToggle.tsx +++ b/clients/web/src/components/elements/MonitoringToggle/MonitoringToggle.tsx @@ -7,17 +7,17 @@ import { export interface MonitoringToggleProps { /** Whether the monitoring sidebar is currently open. */ open: boolean; - /** Open the column when closed, close it when open. */ + /** 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 as column" buttons, the server-list open-sidebar button, - * and the column's own close button — one toggle, placed in the header to the - * right of the theme icon, that opens or closes the column on demand. The glyph - * and label reflect the current state (expand when closed, collapse when open). - * The caller only mounts it when the column is available (connected, or a failed + * 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. */ diff --git a/clients/web/src/components/groups/ViewHeader/ViewHeader.tsx b/clients/web/src/components/groups/ViewHeader/ViewHeader.tsx index 23392aaa8..77c013df4 100644 --- a/clients/web/src/components/groups/ViewHeader/ViewHeader.tsx +++ b/clients/web/src/components/groups/ViewHeader/ViewHeader.tsx @@ -19,18 +19,17 @@ 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 } from "../../elements/MonitoringToggle/MonitoringToggle"; +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-column affordance (#1661): its open state and toggle -// callback. Present only when the column is available (connected, or a failed -// connect attempt, on a wide viewport); undefined otherwise so no toggle shows. -export interface MonitorToggleProps { - open: boolean; - onToggle: () => void; -} - +// 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; @@ -42,16 +41,16 @@ interface ConnectedProps { onDisconnect: () => void; onToggleTheme: () => void; onOpenClientSettings: () => void; - /** Monitoring-column toggle, shown to the right of the theme icon. */ - monitorToggle?: MonitorToggleProps; + /** Monitoring-sidebar toggle, shown to the right of the theme icon. */ + monitorToggle?: MonitoringToggleProps; } interface UnconnectedProps { connected: false; onToggleTheme: () => void; onOpenClientSettings: () => void; - /** Monitoring-column toggle, shown to the right of the theme icon. */ - monitorToggle?: MonitorToggleProps; + /** Monitoring-sidebar toggle, shown to the right of the theme icon. */ + monitorToggle?: MonitoringToggleProps; } export type ViewHeaderProps = ConnectedProps | UnconnectedProps; From d7a6d780ef718426cafd7d5b529ddd4f54cc4b43 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Sat, 11 Jul 2026 17:18:46 -0400 Subject: [PATCH 4/4] feat(web): slide-in/out animation for the monitoring-sidebar toggle (#1661) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The header MonitoringToggle now animates in/out via a Mantine Transition whose in/out styles drive width + margin-left (plus opacity/translateX): as its slot grows from 0 → the 36px icon, the flex-end header row reflows and pushes the theme + client-settings buttons one slot to the left; the exit reverses and they slide back over. A negative margin-left while collapsed cancels the row gap so the theme icon stays flush-right when the toggle is absent. Only appearance/ disappearance (connect / disconnect / narrow viewport) animates — opening or closing the sidebar leaves the toggle in place. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_015sMW1YZXkM7fDkdPtHpE95 --- .../groups/ViewHeader/ViewHeader.tsx | 69 +++++++++++++++++-- 1 file changed, 63 insertions(+), 6 deletions(-) diff --git a/clients/web/src/components/groups/ViewHeader/ViewHeader.tsx b/clients/web/src/components/groups/ViewHeader/ViewHeader.tsx index 77c013df4..904121619 100644 --- a/clients/web/src/components/groups/ViewHeader/ViewHeader.tsx +++ b/clients/web/src/components/groups/ViewHeader/ViewHeader.tsx @@ -13,6 +13,7 @@ import { Tooltip, Transition, useComputedColorScheme, + type MantineTransition, } from "@mantine/core"; import { useMediaQuery } from "@mantine/hooks"; import type { Implementation } from "@modelcontextprotocol/sdk/types.js"; @@ -205,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)"); @@ -418,12 +456,31 @@ export function ViewHeader(props: ViewHeaderProps) { - {props.monitorToggle ? ( - - ) : null} + + {(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 */ + <> + ) + } + );