From 92da6c1675ed7f5bb9ebc792c382abe7b6cdfda3 Mon Sep 17 00:00:00 2001 From: Cliff Hall Date: Sat, 13 Jun 2026 19:13:29 -0400 Subject: [PATCH] =?UTF-8?q?fix(web):=20collapsible=20tool=20description=20?= =?UTF-8?q?in=20ToolDetailPanel=20=E2=80=94=20shown=20by=20default=20(#138?= =?UTF-8?q?1)=20(#1459)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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) * 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) * 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) * 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) --------- Co-authored-by: Claude Opus 4.8 (1M context) --- .../ToolDetailPanel.stories.tsx | 60 ++++++++++++- .../ToolDetailPanel/ToolDetailPanel.test.tsx | 86 +++++++++++++++++++ .../ToolDetailPanel/ToolDetailPanel.tsx | 48 ++++++++++- 3 files changed, 192 insertions(+), 2 deletions(-) diff --git a/clients/web/src/components/groups/ToolDetailPanel/ToolDetailPanel.stories.tsx b/clients/web/src/components/groups/ToolDetailPanel/ToolDetailPanel.stories.tsx index c9607099..e1838cb1 100644 --- a/clients/web/src/components/groups/ToolDetailPanel/ToolDetailPanel.stories.tsx +++ b/clients/web/src/components/groups/ToolDetailPanel/ToolDetailPanel.stories.tsx @@ -1,6 +1,6 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; 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"; const meta: Meta = { @@ -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 = { args: { 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 = { args: { tool: createRecordTool, diff --git a/clients/web/src/components/groups/ToolDetailPanel/ToolDetailPanel.test.tsx b/clients/web/src/components/groups/ToolDetailPanel/ToolDetailPanel.test.tsx index 9a931314..21c2b52b 100644 --- a/clients/web/src/components/groups/ToolDetailPanel/ToolDetailPanel.test.tsx +++ b/clients/web/src/components/groups/ToolDetailPanel/ToolDetailPanel.test.tsx @@ -99,6 +99,92 @@ describe("ToolDetailPanel", () => { ).not.toBeInTheDocument(); }); + describe("Collapsible description", () => { + it("renders the description shown by default (Hide description toggle)", () => { + renderWithMantine(); + 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(); + 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(); + 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( + , + ); + 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(); + 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(); + 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(); + 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", () => { renderWithMantine(); expect(screen.getByText("read-only")).toBeInTheDocument(); diff --git a/clients/web/src/components/groups/ToolDetailPanel/ToolDetailPanel.tsx b/clients/web/src/components/groups/ToolDetailPanel/ToolDetailPanel.tsx index 4eeec9b5..cf048a2d 100644 --- a/clients/web/src/components/groups/ToolDetailPanel/ToolDetailPanel.tsx +++ b/clients/web/src/components/groups/ToolDetailPanel/ToolDetailPanel.tsx @@ -1,5 +1,7 @@ import { + ActionIcon, Button, + Collapse, Divider, Group, Image, @@ -8,6 +10,8 @@ import { Switch, Text, } from "@mantine/core"; +import { useId, useState } from "react"; +import { RiArrowDownSLine, RiArrowRightSLine } from "react-icons/ri"; import type { ProgressNotification, Tool, @@ -86,10 +90,21 @@ const ToolIcon = Image.withProps({ 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({ fw: 700, size: "lg", 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({ @@ -145,6 +160,21 @@ export function ToolDetailPanel({ const { name, title, description, icons, annotations, inputSchema } = tool; 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 // doesn't forbid them. `required` tools are forced on (checked + disabled); // `optional` tools follow the user's `runAsTask` choice. @@ -166,6 +196,18 @@ export function ToolDetailPanel({ {iconSrc && } {resolveDisplayLabel(name, title)} + {description && ( + setDescriptionOpen((open) => !open)} + > + {descriptionOpen ? : } + + )} {hasAnyAnnotation(annotations) && annotations && ( @@ -187,7 +229,11 @@ export function ToolDetailPanel({ - {description && {description}} + {description && ( + + {description} + + )}