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
3 changed files with 613 additions and 440 deletions

View file

@ -20,7 +20,7 @@ import {
} from "../../../test/renderWithMantine";
import { InspectorView, type InspectorViewProps } from "./InspectorView";
// The monitoring column (#1616) is gated on a 1040px viewport media query.
// The monitoring sidebar (#1616) is gated on a 1040px viewport media query.
// happy-dom's viewport is 1024px, so that query is really false; make just that
// gate controllable per test. ViewHeader's own 992/768 queries stay "wide" (as
// they are in the real 1024px happy-dom viewport), so header rendering — and
@ -1298,7 +1298,7 @@ describe("InspectorView", () => {
});
});
describe("pinned monitoring column (#1616)", () => {
describe("pinned monitoring sidebar (#1616)", () => {
const httpServer: ServerEntry = {
id: "beta",
name: "Beta",
@ -1330,26 +1330,44 @@ describe("InspectorView", () => {
return user;
}
it("shows the Pin as column button on a monitor screen only when wide", async () => {
it("shows the header monitoring toggle only when wide", async () => {
monitorWide.value = false;
const { unmount } = renderWithMantine(
<StatefulInspectorViewHost {...connectedHttp()} />,
);
await gotoTab("Logs");
expect(
screen.queryByRole("button", { name: "Pin as column" }),
screen.queryByRole("button", { name: "Open monitoring sidebar" }),
).not.toBeInTheDocument();
unmount();
monitorWide.value = true;
renderWithMantine(<StatefulInspectorViewHost {...connectedHttp()} />);
await gotoTab("Logs");
expect(
await screen.findByRole("button", { name: "Pin as column" }),
await screen.findByRole("button", { name: "Open monitoring sidebar" }),
).toBeInTheDocument();
});
it("opens the monitoring column when a connection is established", async () => {
it("hides the header monitoring toggle when there is no connected or failed server", () => {
// Disconnected, wide viewport: nothing to monitor, so no toggle appears.
monitorWide.value = true;
renderWithMantine(
<StatefulInspectorViewHost
{...makeProps({
servers: [httpServer],
activeServer: undefined,
connectionStatus: "disconnected",
})}
/>,
);
expect(
screen.queryByRole("button", { name: "Open monitoring sidebar" }),
).toBeNull();
expect(
screen.queryByRole("button", { name: "Close monitoring sidebar" }),
).toBeNull();
});
it("opens the monitoring sidebar when a connection is established", async () => {
monitorWide.value = true;
const { rerender } = renderWithMantine(
<StatefulInspectorViewHost
@ -1362,13 +1380,13 @@ describe("InspectorView", () => {
);
// 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(<StatefulInspectorViewHost {...connectedHttp()} />);
expect(
await screen.findByRole("button", { name: "Close monitoring column" }),
await screen.findByRole("button", { name: "Close monitoring sidebar" }),
).toBeInTheDocument();
});
@ -1378,7 +1396,7 @@ describe("InspectorView", () => {
monitorWide.value = true;
renderWithMantine(<StatefulInspectorViewHost {...connectedHttp()} />);
expect(
screen.queryByRole("button", { name: "Close monitoring column" }),
screen.queryByRole("button", { name: "Close monitoring sidebar" }),
).toBeNull();
});
@ -1397,7 +1415,7 @@ describe("InspectorView", () => {
});
}
it("opens the monitoring column on a failed connection attempt (#1621)", async () => {
it("opens the monitoring sidebar on a failed connection attempt (#1621)", async () => {
monitorWide.value = true;
const { rerender } = renderWithMantine(
<StatefulInspectorViewHost
@ -1410,7 +1428,7 @@ describe("InspectorView", () => {
);
// Disconnected: the column is closed.
expect(
screen.queryByRole("button", { name: "Close monitoring column" }),
screen.queryByRole("button", { name: "Close monitoring sidebar" }),
).toBeNull();
// A connection failure (→ error) opens it to surface the diagnostics. The
@ -1433,7 +1451,7 @@ describe("InspectorView", () => {
/>,
);
expect(
await screen.findByRole("button", { name: "Close monitoring column" }),
await screen.findByRole("button", { name: "Close monitoring sidebar" }),
).toBeInTheDocument();
// The failure column leads with Network (the captured request) — the only
@ -1548,7 +1566,7 @@ describe("InspectorView", () => {
/>,
);
expect(
screen.queryByRole("button", { name: "Close monitoring column" }),
screen.queryByRole("button", { name: "Close monitoring sidebar" }),
).toBeNull();
});
@ -1604,7 +1622,7 @@ describe("InspectorView", () => {
monitorWide.value = true;
renderWithMantine(<StatefulInspectorViewHost {...failedHttp()} />);
expect(
screen.queryByRole("button", { name: "Close monitoring column" }),
screen.queryByRole("button", { name: "Close monitoring sidebar" }),
).toBeNull();
});
@ -1621,7 +1639,7 @@ describe("InspectorView", () => {
);
// Connected + pinned: the column is open with the live monitor tabs.
expect(
await screen.findByRole("button", { name: "Close monitoring column" }),
await screen.findByRole("button", { name: "Close monitoring sidebar" }),
).toBeInTheDocument();
// Crash: connected → error, activeServer cleared, NO erroredServerId.
@ -1638,7 +1656,7 @@ describe("InspectorView", () => {
// failure tabs.
await waitFor(() =>
expect(
screen.queryByRole("button", { name: "Close monitoring column" }),
screen.queryByRole("button", { name: "Close monitoring sidebar" }),
).toBeNull(),
);
});
@ -1702,15 +1720,15 @@ describe("InspectorView", () => {
it("pins the monitor group into the column and removes it from the header", async () => {
monitorWide.value = true;
const user = userEvent.setup();
renderWithMantine(<StatefulInspectorViewHost {...connectedHttp()} />);
const user = await gotoTab("Logs");
await user.click(
await screen.findByRole("button", { name: "Pin as column" }),
await screen.findByRole("button", { name: "Open monitoring sidebar" }),
);
// Column is open (its close control is present).
expect(
await screen.findByRole("button", { name: "Close monitoring column" }),
await screen.findByRole("button", { name: "Close monitoring sidebar" }),
).toBeInTheDocument();
// The monitor group is gone from the header tab bar...
@ -1736,19 +1754,19 @@ describe("InspectorView", () => {
it("returns the monitor group to the header when the column is closed", async () => {
monitorWide.value = true;
const user = userEvent.setup();
renderWithMantine(<StatefulInspectorViewHost {...connectedHttp()} />);
const user = await gotoTab("Logs");
await user.click(
await screen.findByRole("button", { name: "Pin as column" }),
await screen.findByRole("button", { name: "Open monitoring sidebar" }),
);
await user.click(
await screen.findByRole("button", { name: "Close monitoring column" }),
await screen.findByRole("button", { name: "Close monitoring sidebar" }),
);
// The column plays its slide-out animation before unmounting.
await waitFor(() =>
expect(
screen.queryByRole("button", { name: "Close monitoring column" }),
screen.queryByRole("button", { name: "Close monitoring sidebar" }),
).toBeNull(),
);
const header = screen.getByRole("banner");
@ -1760,9 +1778,12 @@ describe("InspectorView", () => {
it("keeps the current header screen (not the column's) when closed", async () => {
monitorWide.value = true;
renderWithMantine(<StatefulInspectorViewHost {...connectedHttp()} />);
// Navigate the primary onto a monitor tab (Logs) first, then open the
// column — which moves the monitor group out of the header, so the primary
// clamps to the first non-Servers header tab (Tools).
const user = await gotoTab("Logs");
await user.click(
await screen.findByRole("button", { name: "Pin as column" }),
await screen.findByRole("button", { name: "Open monitoring sidebar" }),
);
// Pinning moved the primary to the first non-Servers header tab (Tools).
const header = screen.getByRole("banner");
@ -1773,14 +1794,14 @@ describe("InspectorView", () => {
// Switch the column to Protocol, then close it.
await user.click(await screen.findByRole("radio", { name: "Protocol" }));
await user.click(
await screen.findByRole("button", { name: "Close monitoring column" }),
await screen.findByRole("button", { name: "Close monitoring sidebar" }),
);
// The primary stays on the header's current screen (Tools), not the
// column's Protocol, and the monitor group returns to the header.
await waitFor(() =>
expect(
screen.queryByRole("button", { name: "Close monitoring column" }),
screen.queryByRole("button", { name: "Close monitoring sidebar" }),
).toBeNull(),
);
expect(
@ -1801,7 +1822,7 @@ describe("InspectorView", () => {
<StatefulInspectorViewHost {...connectedHttp()} />,
);
expect(
screen.queryByRole("button", { name: "Close monitoring column" }),
screen.queryByRole("button", { name: "Close monitoring sidebar" }),
).toBeNull();
expect(
within(screen.getByRole("banner")).getByRole("radio", { name: "Logs" }),
@ -1812,7 +1833,7 @@ describe("InspectorView", () => {
monitorWide.value = true;
renderWithMantine(<StatefulInspectorViewHost {...connectedHttp()} />);
expect(
screen.getByRole("button", { name: "Close monitoring column" }),
screen.getByRole("button", { name: "Close monitoring sidebar" }),
).toBeInTheDocument();
});
@ -1821,7 +1842,7 @@ describe("InspectorView", () => {
monitorWide.value = true;
renderWithMantine(<StatefulInspectorViewHost {...connectedHttp()} />);
expect(
screen.queryByRole("button", { name: "Close monitoring column" }),
screen.queryByRole("button", { name: "Close monitoring sidebar" }),
).toBeNull();
});
@ -1832,7 +1853,7 @@ describe("InspectorView", () => {
<StatefulInspectorViewHost {...connectedHttp()} />,
);
expect(
screen.getByRole("button", { name: "Close monitoring column" }),
screen.getByRole("button", { name: "Close monitoring sidebar" }),
).toBeInTheDocument();
rerender(
@ -1847,7 +1868,7 @@ describe("InspectorView", () => {
// The column plays its slide-out animation before unmounting.
await waitFor(() =>
expect(
screen.queryByRole("button", { name: "Close monitoring column" }),
screen.queryByRole("button", { name: "Close monitoring sidebar" }),
).toBeNull(),
);
// Preference is untouched — only the column's close button clears it.
@ -1871,7 +1892,7 @@ describe("InspectorView", () => {
);
// Column open, but Network is unavailable over stdio.
expect(
screen.getByRole("button", { name: "Close monitoring column" }),
screen.getByRole("button", { name: "Close monitoring sidebar" }),
).toBeInTheDocument();
expect(screen.getByRole("radio", { name: "Logs" })).toBeInTheDocument();
expect(screen.queryByRole("radio", { name: "Network" })).toBeNull();
@ -1919,7 +1940,7 @@ describe("InspectorView", () => {
.map((r) => r.getAttribute("value")),
).toEqual(["Servers"]);
expect(
screen.getByRole("button", { name: "Close monitoring column" }),
screen.getByRole("button", { name: "Close monitoring sidebar" }),
).toBeInTheDocument();
});
@ -1982,7 +2003,7 @@ describe("InspectorView", () => {
monitorWide.value = true;
renderWithMantine(<StatefulInspectorViewHost {...connectedHttp()} />);
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" });

View file

@ -168,7 +168,7 @@ const ALL_TABS: string[] = [
CONSOLE_TAB,
];
// The screens that can be pinned into the monitoring column (#1616). Pinning is
// The screens that can be pinned into the monitoring sidebar (#1616). Pinning is
// a group action: opening the column removes all *available* monitor tabs from
// the header and hosts them in the column instead. Console (#1621) is the
// stdio server's stderr stream — mutually exclusive with Network (Console shows
@ -195,7 +195,7 @@ function isMonitorTab(tab: string): tab is MonitorTab {
// has room for at least one full-width card beside the column.
const MONITOR_WIDE_QUERY = "(min-width: 1040px)";
// Monitoring column width bounds (px). MIN keeps the stream readable; MAX stops
// Monitoring sidebar width bounds (px). MIN keeps the stream readable; MAX stops
// the column from crowding out the primary area.
const MONITOR_WIDTH_MIN = 320;
const MONITOR_WIDTH_MAX = 720;
@ -267,7 +267,7 @@ const SplitRow = Flex.withProps({
w: "100%",
});
// The pinned monitoring column. Fixed-basis (its width is driven live via the
// The pinned monitoring sidebar. Fixed-basis (its width is driven live via the
// `w` style prop at the call site); `miw: 0` so its inner ScrollArea can bound.
const MonitoringColumn = Stack.withProps({
flex: "0 0 auto",
@ -663,7 +663,7 @@ export function InspectorView({
getInitialValueInEffect: false,
});
// Open the monitoring column when a connection is established (#1616) OR when a
// Open the monitoring sidebar when a connection is established (#1616) OR when a
// connect *attempt* fails (#1621). Gated on the *transition into* the target
// status (via the ref) rather than the status itself, so it fires once on an
// actual connect/failure — not on every render, and not on a mount that starts
@ -765,7 +765,7 @@ export function InspectorView({
appTools,
]);
// Monitoring column, derived (#1616, #1621). The monitor group is pinned into
// Monitoring sidebar, derived (#1616, #1621). The monitor group is pinned into
// the right column only when: the user asked for it, the viewport is wide
// enough, the session is connected OR a connect attempt failed, and at least
// one monitor tab is actually available (capability/stdio aware). Narrowing,
@ -807,11 +807,14 @@ export function InspectorView({
}
return [];
}, [connected, failed, availableTabs, stderrLogs, network, protocol]);
const effectivePinned =
monitorPinned &&
!!isWide &&
(connected || failed) &&
monitorAvailable.length > 0;
// The column can exist when: the viewport is wide enough, the session is
// connected OR a connect attempt failed, and at least one monitor tab is
// actually available. This is the same rule the server list used to gate its
// open-sidebar button on, and it now also gates the header MonitoringToggle
// (#1661) — the toggle is hidden entirely when the column can't exist.
const monitorColumnAvailable =
!!isWide && (connected || failed) && monitorAvailable.length > 0;
const effectivePinned = monitorPinned && monitorColumnAvailable;
// The header loses the monitor group while the column is open (its screens
// live in the column instead); otherwise it shows every available tab.
@ -846,14 +849,7 @@ export function InspectorView({
? monitorTab
: (monitorAvailable.find(isMonitorTab) ?? LOGS_TAB);
// Pinning is a group action: remember which tab was clicked and open the
// column. Gated by `canPin` so the pin affordance only appears when a click
// would actually take effect (wide + connected).
const canPin = !!isWide && connected;
// Offer an "open monitoring column" affordance on the server list when a
// connected server has monitor screens to show but the column isn't open yet.
const canOpenMonitor =
canPin && monitorAvailable.length > 0 && !effectivePinned;
// Pinning is a group action: remember which tab to show and open the column.
function pinMonitor(tab: MonitorTab) {
setMonitorTab(tab);
setMonitorPinned(true);
@ -872,6 +868,20 @@ export function InspectorView({
onActiveTabChange(activeTab);
setMonitorPinned(false);
}
// The single header toggle (#1661): open the column onto the last-used monitor
// tab (clamped to what's available) when closed, close it when open.
function toggleMonitorColumn() {
if (effectivePinned) {
closeMonitorColumn();
} else {
pinMonitor(effectiveMonitorTab);
}
}
// Only offer the toggle while the column can actually exist; otherwise the
// header shows nothing to toggle.
const monitorToggle = monitorColumnAvailable
? { open: effectivePinned, onToggle: toggleMonitorColumn }
: undefined;
function commitMonitorWidth(next: number) {
setMonitorWidth(next);
setDragWidth(null);
@ -934,7 +944,8 @@ export function InspectorView({
// Shared props for each monitor screen, spread into both its primary
// (header-tab) instance and its embedded (column) instance so the two can't
// drift. The primary adds `onPin`; the embedded adds `embedded`. (#1616)
// drift. The embedded instance adds `embedded`; the column is opened/closed
// from the single header MonitoringToggle (#1661), not per-screen. (#1616)
const loggingScreenProps = {
entries: logs,
currentLevel: currentLogLevel,
@ -1039,12 +1050,14 @@ export function InspectorView({
onDisconnect={onDisconnect}
onToggleTheme={onToggleTheme}
onOpenClientSettings={onOpenClientSettings}
monitorToggle={monitorToggle}
/>
) : (
<ViewHeader
connected={false}
onToggleTheme={onToggleTheme}
onOpenClientSettings={onOpenClientSettings}
monitorToggle={monitorToggle}
/>
)}
</AppShell.Header>
@ -1072,11 +1085,6 @@ export function InspectorView({
onClearHighlight={onClearHighlight}
compact={serversCompact}
onToggleCompact={() => setServersCompact((c) => !c)}
onOpenMonitor={
canOpenMonitor
? () => pinMonitor(effectiveMonitorTab)
: undefined
}
/>
</ScreenStage>
<ScreenStage active={activeTab === "Tools"}>
@ -1156,28 +1164,16 @@ export function InspectorView({
/>
</ScreenStage>
<ScreenStage active={activeTab === LOGS_TAB}>
<LoggingScreen
{...loggingScreenProps}
onPin={canPin ? () => pinMonitor(LOGS_TAB) : undefined}
/>
<LoggingScreen {...loggingScreenProps} />
</ScreenStage>
<ScreenStage active={activeTab === PROTOCOL_TAB}>
<ProtocolScreen
{...protocolScreenProps}
onPin={canPin ? () => pinMonitor(PROTOCOL_TAB) : undefined}
/>
<ProtocolScreen {...protocolScreenProps} />
</ScreenStage>
<ScreenStage active={activeTab === NETWORK_TAB}>
<NetworkScreen
{...networkScreenProps}
onPin={canPin ? () => pinMonitor(NETWORK_TAB) : undefined}
/>
<NetworkScreen {...networkScreenProps} />
</ScreenStage>
<ScreenStage active={activeTab === CONSOLE_TAB}>
<ConsoleScreen
{...consoleScreenProps}
onPin={canPin ? () => pinMonitor(CONSOLE_TAB) : undefined}
/>
<ConsoleScreen {...consoleScreenProps} />
</ScreenStage>
</ScreenStageContainer>
<Transition
@ -1198,7 +1194,7 @@ export function InspectorView({
step={MONITOR_WIDTH_STEP}
onChange={setDragWidth}
onCommit={commitMonitorWidth}
aria-label="Resize monitoring column"
aria-label="Resize monitoring sidebar"
/>
<MonitoringColumn w={columnWidth}>
<MonitoringScreen
@ -1207,7 +1203,6 @@ export function InspectorView({
onChange={handleMonitorTabChange}
searchValue={monitorSearch}
onSearchChange={setMonitorSearch}
onClose={closeMonitorColumn}
screens={monitorScreens}
/>
</MonitoringColumn>

View file

@ -1,5 +1,5 @@
/**
* Duration (ms) of the monitoring column's open/close slide (#1616). Shared so
* Duration (ms) of the monitoring sidebar's open/close slide (#1616). Shared so
* dependent timings can't silently drift from the animation:
* - `InspectorView` drives the column's Mantine `Transition` with it.
* - `ServerCard` waits just past it before scrolling a newly-failed card into