Monitoring sidebar toggle, Prompts-style Tools results, and icon-button UI polish (#1661) (#1662)

* feat(web): single header MonitoringToggle + Prompts-style Tools results (#1661)

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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015sMW1YZXkM7fDkdPtHpE95

* polish(web): tooltips on icon buttons + rename "monitoring column" → "monitoring sidebar" (#1661)

- 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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015sMW1YZXkM7fDkdPtHpE95

* 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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015sMW1YZXkM7fDkdPtHpE95

* feat(web): slide-in/out animation for the monitoring-sidebar toggle (#1661)

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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015sMW1YZXkM7fDkdPtHpE95

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Cliff Hall 2026-07-11 17:30:16 -04:00 • committed by GitHub
commit dbda70e83b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
18 changed files with 613 additions and 440 deletions

View file

@ -7,7 +7,6 @@ import {
SortToggle,
type SortDirection,
} from "../../elements/SortToggle/SortToggle";
import { PinColumnButton } from "../../elements/PinColumnButton/PinColumnButton";
import { EmbeddableScrollArea } from "../../elements/EmbeddableScrollArea/EmbeddableScrollArea";
import { useScrollMemory } from "../../../hooks/useScrollMemory";
@ -20,13 +19,7 @@ export interface LogStreamPanelProps {
sortDirection: SortDirection;
onSortChange: (next: SortDirection) => void;
/**
* When set, renders a "pin as column" button in the toolbar that opens this
* screen in the monitoring column (#1616). Omitted when the panel is already
* embedded in that column (or when pinning isn't available).
*/
onPin?: () => void;
/**
* True when this panel is rendered inside the monitoring column. Switches the
* True when this panel is rendered inside the monitoring sidebar. Switches the
* scroll region from the viewport-height calc to filling its flex parent, so
* it fits below the column's controls row without viewport math.
*/
@ -78,7 +71,6 @@ export function LogStreamPanel({
onExport,
sortDirection,
onSortChange,
onPin,
embedded = false,
}: LogStreamPanelProps) {
const viewportRef = useScrollMemory("logs-stream");
@ -117,14 +109,15 @@ export function LogStreamPanel({
>
Export
</Button>
{onPin ? <PinColumnButton onPin={onPin} /> : null}
</Group>
</Group>
{filteredEntries.length > 0 ? (
<EmbeddableScrollArea embedded={embedded} viewportRef={viewportRef}>
<Stack gap="xs">
{filteredEntries.map((entry, index) => (
<LogEntry key={index} entry={entry} />
// Compact (two-line) layout inside the narrow monitoring sidebar;
// the full single-line row on the standalone Logs screen. (#1661)
<LogEntry key={index} entry={entry} compact={embedded} />
))}
</Stack>
</EmbeddableScrollArea>

View file

@ -11,7 +11,6 @@ const meta: Meta<typeof MonitoringControls> = {
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();
},
};

View file

@ -15,7 +15,6 @@ function renderControls(overrides: Partial<MonitoringControlsProps> = {}) {
onChange: vi.fn(),
searchValue: "",
onSearchChange: vi.fn(),
onClose: vi.fn(),
...overrides,
};
renderWithMantine(<MonitoringControls {...props} />);
@ -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);
});
});

View file

@ -1,5 +1,4 @@
import { ActionIcon, Group, SegmentedControl, TextInput } from "@mantine/core";
import { TbLayoutSidebarRightCollapse } from "react-icons/tb";
import { Group, SegmentedControl, TextInput } from "@mantine/core";
import { ClearButton } from "../../elements/ClearButton/ClearButton";
export interface MonitoringControlsProps {
@ -11,13 +10,12 @@ export interface MonitoringControlsProps {
/** Search text for the active screen (shares the screen's own filter state). */
searchValue: string;
onSearchChange: (next: string) => void;
/** Close the monitoring column, returning its screens to the header menu. */
onClose: () => void;
}
// The controls row at the top of the pinned monitoring column: a segmented tab
// switcher on the left, a search box filling the middle, and a close button on
// the right that unpins the whole column.
// The controls row at the top of the pinned monitoring sidebar: a segmented tab
// switcher on the left and a search box filling the rest. The column is closed
// from the single header MonitoringToggle (#1661), so there is no close button
// here.
const ControlsBar = Group.withProps({
wrap: "nowrap",
gap: "sm",
@ -25,7 +23,7 @@ const ControlsBar = Group.withProps({
});
// Search box, styled to match the `*Controls` search fields; `flex:1`/`miw:0`
// so it fills the space between the tabs and the close button.
// so it fills the space between the tabs and the row's edge.
const SearchInput = TextInput.withProps({
placeholder: "Search...",
size: "sm",
@ -35,12 +33,12 @@ const SearchInput = TextInput.withProps({
});
/**
* Tab row + search + close control for the pinned monitoring column (#1616).
* Renders only the currently-available monitor tabs (the caller filters by
* capability), so it never shows an empty switcher. Selecting a tab swaps the
* screen below; the search box filters the shown screen (wired by the caller to
* that screen's own filter state); the close button unpins the column so its
* screens rejoin the header tab menu.
* Tab row + search for the pinned monitoring sidebar (#1616). Renders only the
* currently-available monitor tabs (the caller filters by capability), so it
* never shows an empty switcher. Selecting a tab swaps the screen below; the
* search box filters the shown screen (wired by the caller to that screen's own
* filter state). Opening/closing the column is handled by the single header
* MonitoringToggle (#1661).
*/
export function MonitoringControls({
tabs,
@ -48,7 +46,6 @@ export function MonitoringControls({
onChange,
searchValue,
onSearchChange,
onClose,
}: MonitoringControlsProps) {
return (
<ControlsBar>
@ -69,14 +66,6 @@ export function MonitoringControls({
) : null
}
/>
<ActionIcon
variant="subtle"
size={36}
aria-label="Close monitoring column"
onClick={onClose}
>
<TbLayoutSidebarRightCollapse size={20} />
</ActionIcon>
</ControlsBar>
);
}

View file

@ -37,7 +37,6 @@ const meta: Meta<typeof MonitoringScreen> = {
onChange: fn(),
searchValue: "",
onSearchChange: fn(),
onClose: fn(),
screens: demoScreens,
},
};

View file

@ -23,7 +23,6 @@ function renderScreen(overrides: Partial<MonitoringScreenProps> = {}) {
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 () => {

View file

@ -13,7 +13,6 @@ export interface MonitoringScreenProps {
/** Search text for the active screen; wired by the caller to its filter state. */
searchValue: string;
onSearchChange: (next: string) => void;
onClose: () => void;
/**
* The embedded screen node for each tab, keyed by tab label. The caller builds
* these (with `embedded` set) so this component stays a pure layout shell.
@ -38,7 +37,7 @@ const ScreenSlot = Stack.withProps({
});
/**
* The pinned monitoring column's content (#1616): a `MonitoringControls` tab row
* The pinned monitoring sidebar's content (#1616): a `MonitoringControls` tab row
* over the selected monitor screen. Layout-only — it wraps each supplied screen
* node in a `ScreenStage`, so switching tabs cross-fades (only the active tab's
* screen is mounted) the same way the primary pane does.
@ -49,7 +48,6 @@ export function MonitoringScreen({
onChange,
searchValue,
onSearchChange,
onClose,
screens,
}: MonitoringScreenProps) {
return (
@ -60,7 +58,6 @@ export function MonitoringScreen({
onChange={onChange}
searchValue={searchValue}
onSearchChange={onSearchChange}
onClose={onClose}
/>
<Divider />
<ScreenSlot>

View file

@ -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.
*/

View file

@ -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 && (
<ListToggle compact={compact} onToggle={onToggleCompact} />
)}
{onPin ? <PinColumnButton onPin={onPin} /> : null}
</Group>
</Group>

View file

@ -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.
*/

View file

@ -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 && (
<ListToggle compact={compact} onToggle={onToggleCompact} />
)}
{onPin ? <PinColumnButton onPin={onPin} /> : null}
</Group>
</Group>

View file

@ -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

View file

@ -24,31 +24,6 @@ describe("ServerListControls", () => {
).toBeInTheDocument();
});
it("hides the open-monitor button unless onOpenMonitor is provided", () => {
renderWithMantine(<ServerListControls {...baseProps} serverCount={2} />);
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(
<ServerListControls
{...baseProps}
serverCount={2}
onOpenMonitor={onOpenMonitor}
/>,
);
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(<ServerListControls {...baseProps} serverCount={2} />);
expect(screen.getAllByRole("button")).toHaveLength(3);

View file

@ -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 && (
<ListToggle compact={compact} onToggle={onToggleList} />
)}
{onOpenMonitor && (
<PinColumnButton onPin={onOpenMonitor} label="Open monitoring column" />
)}
</Group>
);
}

View file

@ -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(<ToolResultPanel result={okResult} onClear={onClear} />);
await user.click(screen.getByRole("button", { name: "Clear" }));
await user.click(screen.getByRole("button", { name: "Close results" }));
expect(onClear).toHaveBeenCalledTimes(1);
});
});

View file

@ -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<ReadResourceResult>;
}
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 (
<PanelStack>
<HeaderRow>
<Title order={4}>Results</Title>
<ClearButton onClick={onClear}>Clear</ClearButton>
<HeaderLeft>
<CloseButton aria-label="Close results" onClick={onClear} />
<Title order={4}>Results</Title>
</HeaderLeft>
</HeaderRow>
<ResultScroll>
<ResultStack>

View file

@ -99,6 +99,39 @@ describe("ViewHeader", () => {
);
expect(onToggleTheme).toHaveBeenCalledTimes(1);
});
it("omits the monitoring toggle when no monitorToggle is provided", () => {
renderWithMantine(
<ViewHeader
connected={false}
onToggleTheme={vi.fn()}
onOpenClientSettings={vi.fn()}
/>,
);
expect(
screen.queryByRole("button", { name: "Open monitoring sidebar" }),
).toBeNull();
expect(
screen.queryByRole("button", { name: "Close monitoring sidebar" }),
).toBeNull();
});
it("renders and wires the monitoring toggle for a failed connect attempt", async () => {
const user = userEvent.setup();
const onToggle = vi.fn();
renderWithMantine(
<ViewHeader
connected={false}
onToggleTheme={vi.fn()}
onOpenClientSettings={vi.fn()}
monitorToggle={{ open: false, onToggle }}
/>,
);
await user.click(
screen.getByRole("button", { name: "Open monitoring sidebar" }),
);
expect(onToggle).toHaveBeenCalledTimes(1);
});
});
describe("when connected", () => {
@ -122,6 +155,24 @@ describe("ViewHeader", () => {
expect(onToggleTheme).toHaveBeenCalledTimes(1);
});
it("renders the monitoring toggle in its open state and toggles it closed", async () => {
const user = userEvent.setup();
const onToggle = vi.fn();
renderWithMantine(
<ViewHeader
{...connectedProps}
monitorToggle={{ open: true, onToggle }}
/>,
);
expect(
screen.queryByRole("button", { name: "Open monitoring sidebar" }),
).toBeNull();
await user.click(
screen.getByRole("button", { name: "Close monitoring sidebar" }),
);
expect(onToggle).toHaveBeenCalledTimes(1);
});
it("invokes onDisconnect when the disconnect control is clicked", async () => {
const user = userEvent.setup();
const onDisconnect = vi.fn();

View file

@ -10,17 +10,27 @@ import {
Select,
Text,
Title,
Tooltip,
Transition,
useComputedColorScheme,
type MantineTransition,
} from "@mantine/core";
import { useMediaQuery } from "@mantine/hooks";
import type { Implementation } from "@modelcontextprotocol/sdk/types.js";
import { MdLightMode, MdDarkMode, MdLinkOff, MdSettings } from "react-icons/md";
import type { ConnectionStatus } from "@inspector/core/mcp/types.js";
import { ServerStatusIndicator } from "../../elements/ServerStatusIndicator/ServerStatusIndicator";
import {
MonitoringToggle,
type MonitoringToggleProps,
} from "../../elements/MonitoringToggle/MonitoringToggle";
import mcpLogo from "../../../theme/assets/MCP.svg";
import mcpLogoDark from "../../../theme/assets/MCP-dark.svg";
// The single monitoring-sidebar affordance (#1661): its open state + toggle
// callback (`MonitoringToggleProps`, reused as the one source of truth). Present
// only when the sidebar is available (connected, or a failed connect attempt,
// on a wide viewport); undefined otherwise so no toggle shows.
interface ConnectedProps {
connected: true;
serverInfo: Implementation;
@ -32,12 +42,16 @@ interface ConnectedProps {
onDisconnect: () => void;
onToggleTheme: () => void;
onOpenClientSettings: () => void;
/** Monitoring-sidebar toggle, shown to the right of the theme icon. */
monitorToggle?: MonitoringToggleProps;
}
interface UnconnectedProps {
connected: false;
onToggleTheme: () => void;
onOpenClientSettings: () => void;
/** Monitoring-sidebar toggle, shown to the right of the theme icon. */
monitorToggle?: MonitoringToggleProps;
}
export type ViewHeaderProps = ConnectedProps | UnconnectedProps;
@ -192,8 +206,45 @@ const ThemeToggle = ActionIcon.withProps({
"aria-label": "Toggle color scheme",
});
// Enter/exit animation for the monitoring-sidebar toggle (#1661). It occupies a
// slot to the right of the theme icon; when it appears it slides in and grows
// its width from 0 → the 36px icon, which — because RightSection is a flex-end
// row — pushes the theme + client-settings buttons one slot to the left. On exit
// it reverses and those buttons slide back over. The negative `marginLeft` in
// `out` cancels RightSection's `sm` gap while collapsed so the theme icon stays
// flush to the right edge (no dead gap). `overflow: hidden` clips the icon as
// the slot narrows so it reads as a slide-in reveal. The 36 matches the icon.
const TOGGLE_ANIM_MS = 200;
const toggleSlide: MantineTransition = {
in: { opacity: 1, width: 36, marginLeft: 0, transform: "translateX(0)" },
out: {
opacity: 0,
width: 0,
marginLeft: "calc(var(--mantine-spacing-sm) * -1)",
transform: "translateX(-8px)",
},
common: { overflow: "hidden", flexShrink: 0 },
transitionProperty: "opacity, width, margin-left, transform",
};
export function ViewHeader(props: ViewHeaderProps) {
const colorScheme = useComputedColorScheme();
// Retain the latest toggle props so the control keeps rendering its final
// state while it slides out after `monitorToggle` clears (the Transition below
// keeps it mounted for the exit). Adjust-state-during-render, gated on the
// `open` primitive — the parent hands us a fresh object/callback each render,
// so comparing by identity would loop; `open` is the only bit that changes the
// rendered control, and the exiting toggle isn't interactive anyway.
const [lastMonitorToggle, setLastMonitorToggle] = useState(
props.monitorToggle,
);
if (
props.monitorToggle &&
props.monitorToggle.open !== lastMonitorToggle?.open
) {
setLastMonitorToggle(props.monitorToggle);
}
const monitorToggleForRender = props.monitorToggle ?? lastMonitorToggle;
const ThemeIcon = colorScheme === "dark" ? MdLightMode : MdDarkMode;
const showSegmented = useMediaQuery("(min-width: 992px)");
const showDisconnectLabel = useMediaQuery("(min-width: 768px)");
@ -389,12 +440,47 @@ export function ViewHeader(props: ViewHeaderProps) {
)
}
</Transition>
<ClientSettingsToggle onClick={props.onOpenClientSettings}>
<MdSettings size={20} />
</ClientSettingsToggle>
<ThemeToggle onClick={props.onToggleTheme}>
<ThemeIcon size={20} />
</ThemeToggle>
<Tooltip label="Client settings">
<ClientSettingsToggle onClick={props.onOpenClientSettings}>
<MdSettings size={20} />
</ClientSettingsToggle>
</Tooltip>
<Tooltip
label={
colorScheme === "dark"
? "Switch to light theme"
: "Switch to dark theme"
}
>
<ThemeToggle onClick={props.onToggleTheme}>
<ThemeIcon size={20} />
</ThemeToggle>
</Tooltip>
<Transition
mounted={!!props.monitorToggle}
transition={toggleSlide}
duration={TOGGLE_ANIM_MS}
exitDuration={TOGGLE_ANIM_MS}
timingFunction="ease"
>
{(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.
<Box style={styles}>
<MonitoringToggle
open={monitorToggleForRender.open}
onToggle={monitorToggleForRender.onToggle}
/>
</Box>
) : (
/* v8 ignore next -- unreachable: the Transition only mounts once a
toggle has existed, so the snapshot is always set when rendered */
<></>
)
}
</Transition>
</RightSection>
</HeaderBar>
);