feat(web): add an open-monitoring-column button to the server list

When a server is connected, has monitor screens to show, and the monitoring
column isn't open, show an "open monitoring column" button to the right of the
list expand/collapse toggle on the server list. It reuses PinColumnButton (the
same right-sidebar affordance the monitor screens use, now with an optional
label), so opening the column is reachable from the server list — not only from
a monitor screen's pin button. InspectorView gates it on
`canPin && monitorAvailable.length > 0 && !effectivePinned`.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BrihGWcrM9JGRyu41nzZYw
This commit is contained in:
cliffhall 2026-07-10 20:23:27 -04:00
commit 0c6fafbf60
6 changed files with 76 additions and 12 deletions

View file

@ -18,4 +18,14 @@ describe("PinColumnButton", () => {
await user.click(screen.getByRole("button", { name: "Pin as column" })); await user.click(screen.getByRole("button", { name: "Pin as column" }));
expect(onPin).toHaveBeenCalledTimes(1); expect(onPin).toHaveBeenCalledTimes(1);
}); });
it("uses a custom accessible label when provided", () => {
renderWithMantine(
<PinColumnButton onPin={vi.fn()} label="Open monitoring column" />,
);
expect(
screen.getByRole("button", { name: "Open monitoring column" }),
).toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Pin as column" })).toBeNull();
});
}); });

View file

@ -4,23 +4,24 @@ import { TbLayoutSidebarRightExpand } from "react-icons/tb";
export interface PinColumnButtonProps { export interface PinColumnButtonProps {
/** Pin this screen into the monitoring column to the right. */ /** Pin this screen into the monitoring column to the right. */
onPin: () => void; onPin: () => void;
/** Accessible label. Defaults to "Pin as column" (its monitor-screen use). */
label?: string;
} }
/** /**
* Toolbar button that pins the owning monitor screen (Logs / Protocol / Network) * Toolbar button that opens the resizable monitoring column on the right of the
* into the resizable column on the right of the InspectorView (#1616). Distinct * InspectorView (#1616). On a monitor screen (Logs / Protocol / Network) it pins
* from `PinToggle` (which pins individual history entries) — this one opens a * that screen in as a column; on the server list it just opens the column (a
* side column, so it uses a right-sidebar glyph and an "as column" label. Styled * different `label`). Distinct from `PinToggle` (which pins individual history
* to match the panel's expand/collapse `ListToggle` (subtle icon button). * entries) — this one opens a side column, so it uses a right-sidebar glyph.
* Styled to match the panel's expand/collapse `ListToggle` (subtle icon button).
*/ */
export function PinColumnButton({ onPin }: PinColumnButtonProps) { export function PinColumnButton({
onPin,
label = "Pin as column",
}: PinColumnButtonProps) {
return ( return (
<Button <Button size="sm" variant="subtle" aria-label={label} onClick={onPin}>
size="sm"
variant="subtle"
aria-label="Pin as column"
onClick={onPin}
>
<TbLayoutSidebarRightExpand size={20} /> <TbLayoutSidebarRightExpand size={20} />
</Button> </Button>
); );

View file

@ -24,6 +24,31 @@ describe("ServerListControls", () => {
).toBeInTheDocument(); ).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", () => { it("shows the list toggle alongside Export + Add Servers when servers exist", () => {
renderWithMantine(<ServerListControls {...baseProps} serverCount={2} />); renderWithMantine(<ServerListControls {...baseProps} serverCount={2} />);
expect(screen.getAllByRole("button")).toHaveLength(3); expect(screen.getAllByRole("button")).toHaveLength(3);

View file

@ -1,5 +1,6 @@
import { Button, Group } from "@mantine/core"; import { Button, Group } from "@mantine/core";
import { ListToggle } from "../../elements/ListToggle/ListToggle"; import { ListToggle } from "../../elements/ListToggle/ListToggle";
import { PinColumnButton } from "../../elements/PinColumnButton/PinColumnButton";
import { import {
ServerAddMenu, ServerAddMenu,
type AddServerMenuProps, type AddServerMenuProps,
@ -13,6 +14,12 @@ export interface ServerListControlsProps extends AddServerMenuProps {
onExport: () => void; onExport: () => void;
/** When false (read-only session), the Add menu is hidden. Defaults to true. */ /** When false (read-only session), the Add menu is hidden. Defaults to true. */
writable?: boolean; 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({ export function ServerListControls({
@ -24,6 +31,7 @@ export function ServerListControls({
onImportServerJson, onImportServerJson,
onExport, onExport,
writable = true, writable = true,
onOpenMonitor,
}: ServerListControlsProps) { }: ServerListControlsProps) {
return ( return (
<Group justify="flex-end"> <Group justify="flex-end">
@ -40,6 +48,9 @@ export function ServerListControls({
{serverCount > 0 && ( {serverCount > 0 && (
<ListToggle compact={compact} onToggle={onToggleList} /> <ListToggle compact={compact} onToggle={onToggleList} />
)} )}
{onOpenMonitor && (
<PinColumnButton onPin={onOpenMonitor} label="Open monitoring column" />
)}
</Group> </Group>
); );
} }

View file

@ -67,6 +67,12 @@ export interface ServerListScreenProps {
onClearHighlight?: (id: string) => void; onClearHighlight?: (id: string) => void;
compact: boolean; compact: boolean;
onToggleCompact: () => void; 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({ const PageContainer = Stack.withProps({
@ -99,6 +105,7 @@ export function ServerListScreen({
onClearHighlight, onClearHighlight,
compact, compact,
onToggleCompact, onToggleCompact,
onOpenMonitor,
}: ServerListScreenProps) { }: ServerListScreenProps) {
const sensors = useSensors( const sensors = useSensors(
useSensor(PointerSensor), useSensor(PointerSensor),
@ -196,6 +203,7 @@ export function ServerListScreen({
onImportConfig={onImportConfig} onImportConfig={onImportConfig}
onImportServerJson={onImportServerJson} onImportServerJson={onImportServerJson}
onExport={onExport} onExport={onExport}
onOpenMonitor={onOpenMonitor}
/> />
<ScrollArea.Autosize <ScrollArea.Autosize

View file

@ -850,6 +850,10 @@ export function InspectorView({
// column. Gated by `canPin` so the pin affordance only appears when a click // column. Gated by `canPin` so the pin affordance only appears when a click
// would actually take effect (wide + connected). // would actually take effect (wide + connected).
const canPin = !!isWide && 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;
function pinMonitor(tab: MonitorTab) { function pinMonitor(tab: MonitorTab) {
setMonitorTab(tab); setMonitorTab(tab);
setMonitorPinned(true); setMonitorPinned(true);
@ -1068,6 +1072,11 @@ export function InspectorView({
onClearHighlight={onClearHighlight} onClearHighlight={onClearHighlight}
compact={serversCompact} compact={serversCompact}
onToggleCompact={() => setServersCompact((c) => !c)} onToggleCompact={() => setServersCompact((c) => !c)}
onOpenMonitor={
canOpenMonitor
? () => pinMonitor(effectiveMonitorTab)
: undefined
}
/> />
</ScreenStage> </ScreenStage>
<ScreenStage active={activeTab === "Tools"}> <ScreenStage active={activeTab === "Tools"}>