* fix(web): collapse long tool descriptions in ToolDetailPanel (#1381) Long tool descriptions (e.g. sequential-thinking-server's "Sequential Thinking" tool) filled the panel and pushed the form + Execute button off-screen. The internal scroll region for the form/progress was already in place; this adds the collapsible description. - Wrap the description in a Mantine Collapse, collapsed by default. - Expose the trigger as a chevron ActionIcon pinned to the right of the tool-name row (FaChevronRight collapsed / FaChevronDown expanded), with Show/Hide description aria-labels. Rendered only when a description exists. - Reset to collapsed on tool change via the adjust-state-during-render pattern, mirroring how ToolsScreen clears formValues. Tests + stories cover collapsed default, expand/collapse toggle, reset on tool switch, and the no-description case. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(web): show tool description by default, click to hide (#1381) Most tool descriptions are short, so default to showing the description and let the user collapse a long one via the chevron, rather than the reverse. Flips the initial/reset state to open and updates the toggle aria-labels, tests, and stories accordingly. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * refactor(web): use Ri disclosure chevrons in ToolDetailPanel (#1459 review) Swap react-icons/fa FaChevronDown/FaChevronRight for the established react-icons/ri RiArrowDownSLine/RiArrowRightSLine disclosure-chevron convention already used in ResourceLink, keeping chevron visuals consistent across the app. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * a11y(web): wire description toggle as expandable control (#1459 review) Add aria-expanded + aria-controls to the description chevron, tying it to the Collapse region (via useId) so assistive tech announces it as a single expandable control rather than two differently-labelled buttons. Adds a test covering the wiring and the aria-expanded flip. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
6bb49e55ba
commit
92da6c1675
3 changed files with 192 additions and 2 deletions
|
|
@ -1,6 +1,6 @@
|
||||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||||
import type { Tool } from "@modelcontextprotocol/sdk/types.js";
|
import type { Tool } from "@modelcontextprotocol/sdk/types.js";
|
||||||
import { fn } from "storybook/test";
|
import { expect, fn, userEvent, within } from "storybook/test";
|
||||||
import { ToolDetailPanel } from "./ToolDetailPanel";
|
import { ToolDetailPanel } from "./ToolDetailPanel";
|
||||||
|
|
||||||
const meta: Meta<typeof ToolDetailPanel> = {
|
const meta: Meta<typeof ToolDetailPanel> = {
|
||||||
|
|
@ -105,12 +105,70 @@ const batchProcessTool: Tool = {
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Mirrors the gnarly `sequential-thinking-server` case from issue #1381: a very
|
||||||
|
// long description that pushes the form and Execute footer down. Descriptions
|
||||||
|
// show by default; the chevron lets the user hide a long one to reclaim space.
|
||||||
|
const longDescriptionTool: Tool = {
|
||||||
|
name: "sequentialthinking",
|
||||||
|
title: "Sequential Thinking",
|
||||||
|
description: [
|
||||||
|
"A detailed tool for dynamic and reflective problem-solving through thoughts.",
|
||||||
|
"This tool helps analyze problems through a flexible thinking process that can",
|
||||||
|
"adapt and evolve. Each thought can build on, question, or revise previous",
|
||||||
|
"insights as understanding deepens.",
|
||||||
|
"",
|
||||||
|
"When to use this tool: breaking down complex problems into steps, planning and",
|
||||||
|
"design with room for revision, analysis that might need course correction, and",
|
||||||
|
"problems where the full scope might not be clear initially.",
|
||||||
|
].join(" "),
|
||||||
|
inputSchema: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
thought: { type: "string", description: "Your current thinking step" },
|
||||||
|
nextThoughtNeeded: {
|
||||||
|
type: "boolean",
|
||||||
|
description: "Whether another thought step is needed",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
required: ["thought", "nextThoughtNeeded"],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
export const SimpleStringParam: Story = {
|
export const SimpleStringParam: Story = {
|
||||||
args: {
|
args: {
|
||||||
tool: sendMessageTool,
|
tool: sendMessageTool,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Long description shown by default — the chevron offers to hide it.
|
||||||
|
export const LongDescription: Story = {
|
||||||
|
args: {
|
||||||
|
tool: longDescriptionTool,
|
||||||
|
},
|
||||||
|
play: async ({ canvasElement }) => {
|
||||||
|
const canvas = within(canvasElement);
|
||||||
|
await expect(
|
||||||
|
await canvas.findByRole("button", { name: "Hide description" }),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// Clicking the chevron hides the description (toggle flips to "Show").
|
||||||
|
export const LongDescriptionHidden: Story = {
|
||||||
|
args: {
|
||||||
|
tool: longDescriptionTool,
|
||||||
|
},
|
||||||
|
play: async ({ canvasElement }) => {
|
||||||
|
const canvas = within(canvasElement);
|
||||||
|
await userEvent.click(
|
||||||
|
await canvas.findByRole("button", { name: "Hide description" }),
|
||||||
|
);
|
||||||
|
await expect(
|
||||||
|
await canvas.findByRole("button", { name: "Show description" }),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
export const MultipleParams: Story = {
|
export const MultipleParams: Story = {
|
||||||
args: {
|
args: {
|
||||||
tool: createRecordTool,
|
tool: createRecordTool,
|
||||||
|
|
|
||||||
|
|
@ -99,6 +99,92 @@ describe("ToolDetailPanel", () => {
|
||||||
).not.toBeInTheDocument();
|
).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("Collapsible description", () => {
|
||||||
|
it("renders the description shown by default (Hide description toggle)", () => {
|
||||||
|
renderWithMantine(<ToolDetailPanel {...baseProps} tool={titledTool} />);
|
||||||
|
const toggle = screen.getByRole("button", { name: "Hide description" });
|
||||||
|
expect(toggle).toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
screen.queryByRole("button", { name: "Show description" }),
|
||||||
|
).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("hides the description when the toggle is clicked", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderWithMantine(<ToolDetailPanel {...baseProps} tool={titledTool} />);
|
||||||
|
await user.click(
|
||||||
|
screen.getByRole("button", { name: "Hide description" }),
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
screen.getByRole("button", { name: "Show description" }),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
screen.queryByRole("button", { name: "Hide description" }),
|
||||||
|
).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows the description again when the collapsed toggle is clicked", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderWithMantine(<ToolDetailPanel {...baseProps} tool={titledTool} />);
|
||||||
|
await user.click(
|
||||||
|
screen.getByRole("button", { name: "Hide description" }),
|
||||||
|
);
|
||||||
|
await user.click(
|
||||||
|
screen.getByRole("button", { name: "Show description" }),
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
screen.getByRole("button", { name: "Hide description" }),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resets to shown when switching to a different tool", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const { rerender } = renderWithMantine(
|
||||||
|
<ToolDetailPanel {...baseProps} tool={titledTool} />,
|
||||||
|
);
|
||||||
|
await user.click(
|
||||||
|
screen.getByRole("button", { name: "Hide description" }),
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
screen.getByRole("button", { name: "Show description" }),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
|
||||||
|
// Switching tools (different name) should reset the local hidden state.
|
||||||
|
rerender(<ToolDetailPanel {...baseProps} tool={annotatedTool} />);
|
||||||
|
expect(
|
||||||
|
screen.getByRole("button", { name: "Hide description" }),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("wires the toggle as an expandable control (aria-expanded + aria-controls)", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderWithMantine(<ToolDetailPanel {...baseProps} tool={titledTool} />);
|
||||||
|
const toggle = screen.getByRole("button", { name: "Hide description" });
|
||||||
|
expect(toggle).toHaveAttribute("aria-expanded", "true");
|
||||||
|
// aria-controls points at the Collapse region holding the description.
|
||||||
|
const regionId = toggle.getAttribute("aria-controls");
|
||||||
|
expect(regionId).toBeTruthy();
|
||||||
|
expect(document.getElementById(regionId as string)).toContainElement(
|
||||||
|
screen.getByText("Sends a message to the recipient"),
|
||||||
|
);
|
||||||
|
|
||||||
|
await user.click(toggle);
|
||||||
|
expect(
|
||||||
|
screen.getByRole("button", { name: "Show description" }),
|
||||||
|
).toHaveAttribute("aria-expanded", "false");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not render a description toggle when the tool has no description", () => {
|
||||||
|
renderWithMantine(<ToolDetailPanel {...baseProps} tool={simpleTool} />);
|
||||||
|
expect(
|
||||||
|
screen.queryByRole("button", { name: "Show description" }),
|
||||||
|
).not.toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
screen.queryByRole("button", { name: "Hide description" }),
|
||||||
|
).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it("renders all annotation badges when annotations are present", () => {
|
it("renders all annotation badges when annotations are present", () => {
|
||||||
renderWithMantine(<ToolDetailPanel {...baseProps} tool={annotatedTool} />);
|
renderWithMantine(<ToolDetailPanel {...baseProps} tool={annotatedTool} />);
|
||||||
expect(screen.getByText("read-only")).toBeInTheDocument();
|
expect(screen.getByText("read-only")).toBeInTheDocument();
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
import {
|
import {
|
||||||
|
ActionIcon,
|
||||||
Button,
|
Button,
|
||||||
|
Collapse,
|
||||||
Divider,
|
Divider,
|
||||||
Group,
|
Group,
|
||||||
Image,
|
Image,
|
||||||
|
|
@ -8,6 +10,8 @@ import {
|
||||||
Switch,
|
Switch,
|
||||||
Text,
|
Text,
|
||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
|
import { useId, useState } from "react";
|
||||||
|
import { RiArrowDownSLine, RiArrowRightSLine } from "react-icons/ri";
|
||||||
import type {
|
import type {
|
||||||
ProgressNotification,
|
ProgressNotification,
|
||||||
Tool,
|
Tool,
|
||||||
|
|
@ -86,10 +90,21 @@ const ToolIcon = Image.withProps({
|
||||||
fit: "contain",
|
fit: "contain",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// `flex: 1` lets the title absorb the row's slack so the chevron toggle pins
|
||||||
|
// to the right edge of the (nowrap) TitleRow.
|
||||||
const ToolTitle = Text.withProps({
|
const ToolTitle = Text.withProps({
|
||||||
fw: 700,
|
fw: 700,
|
||||||
size: "lg",
|
size: "lg",
|
||||||
truncate: "end",
|
truncate: "end",
|
||||||
|
flex: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Chevron toggle for the collapsible description, pinned to the right of the
|
||||||
|
// title row. `aria-label` is set per-render since it reflects the open state.
|
||||||
|
const DescriptionToggle = ActionIcon.withProps({
|
||||||
|
variant: "subtle",
|
||||||
|
color: "gray",
|
||||||
|
size: "sm",
|
||||||
});
|
});
|
||||||
|
|
||||||
const DescriptionText = Text.withProps({
|
const DescriptionText = Text.withProps({
|
||||||
|
|
@ -145,6 +160,21 @@ export function ToolDetailPanel({
|
||||||
const { name, title, description, icons, annotations, inputSchema } = tool;
|
const { name, title, description, icons, annotations, inputSchema } = tool;
|
||||||
const iconSrc = icons?.[0]?.src;
|
const iconSrc = icons?.[0]?.src;
|
||||||
|
|
||||||
|
// Descriptions are shown by default (most are short); the chevron lets the
|
||||||
|
// user hide a long one to keep the form and Execute footer in view. Reset to
|
||||||
|
// shown when switching tools (React's adjust-state-during-render pattern) so
|
||||||
|
// a prior tool's hidden state doesn't carry over — mirrors how ToolsScreen
|
||||||
|
// clears formValues on change.
|
||||||
|
const [descriptionOpen, setDescriptionOpen] = useState(true);
|
||||||
|
const [prevToolName, setPrevToolName] = useState(name);
|
||||||
|
if (name !== prevToolName) {
|
||||||
|
setPrevToolName(name);
|
||||||
|
setDescriptionOpen(true);
|
||||||
|
}
|
||||||
|
// Ties the toggle to the Collapse region so assistive tech announces it as a
|
||||||
|
// single expandable control (aria-expanded + aria-controls).
|
||||||
|
const descriptionRegionId = useId();
|
||||||
|
|
||||||
// Show the toggle only when the server supports task tool calls and the tool
|
// Show the toggle only when the server supports task tool calls and the tool
|
||||||
// doesn't forbid them. `required` tools are forced on (checked + disabled);
|
// doesn't forbid them. `required` tools are forced on (checked + disabled);
|
||||||
// `optional` tools follow the user's `runAsTask` choice.
|
// `optional` tools follow the user's `runAsTask` choice.
|
||||||
|
|
@ -166,6 +196,18 @@ export function ToolDetailPanel({
|
||||||
<TitleRow>
|
<TitleRow>
|
||||||
{iconSrc && <ToolIcon src={iconSrc} alt="" />}
|
{iconSrc && <ToolIcon src={iconSrc} alt="" />}
|
||||||
<ToolTitle>{resolveDisplayLabel(name, title)}</ToolTitle>
|
<ToolTitle>{resolveDisplayLabel(name, title)}</ToolTitle>
|
||||||
|
{description && (
|
||||||
|
<DescriptionToggle
|
||||||
|
aria-label={
|
||||||
|
descriptionOpen ? "Hide description" : "Show description"
|
||||||
|
}
|
||||||
|
aria-expanded={descriptionOpen}
|
||||||
|
aria-controls={descriptionRegionId}
|
||||||
|
onClick={() => setDescriptionOpen((open) => !open)}
|
||||||
|
>
|
||||||
|
{descriptionOpen ? <RiArrowDownSLine /> : <RiArrowRightSLine />}
|
||||||
|
</DescriptionToggle>
|
||||||
|
)}
|
||||||
</TitleRow>
|
</TitleRow>
|
||||||
{hasAnyAnnotation(annotations) && annotations && (
|
{hasAnyAnnotation(annotations) && annotations && (
|
||||||
<Group gap="xs">
|
<Group gap="xs">
|
||||||
|
|
@ -187,7 +229,11 @@ export function ToolDetailPanel({
|
||||||
|
|
||||||
<BodyScroll>
|
<BodyScroll>
|
||||||
<BodyStack>
|
<BodyStack>
|
||||||
{description && <DescriptionText>{description}</DescriptionText>}
|
{description && (
|
||||||
|
<Collapse in={descriptionOpen} id={descriptionRegionId}>
|
||||||
|
<DescriptionText>{description}</DescriptionText>
|
||||||
|
</Collapse>
|
||||||
|
)}
|
||||||
|
|
||||||
<Divider />
|
<Divider />
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue