* 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:
parent
4e8e772aff
commit
dbda70e83b
18 changed files with 613 additions and 440 deletions
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
},
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -37,7 +37,6 @@ const meta: Meta<typeof MonitoringScreen> = {
|
|||
onChange: fn(),
|
||||
searchValue: "",
|
||||
onSearchChange: fn(),
|
||||
onClose: fn(),
|
||||
screens: demoScreens,
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
Loading…
Reference in a new issue