From 09432effbecec0c841cf91a3523ccdc42692d777 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Sat, 24 Oct 2026 23:16:36 -0400 Subject: [PATCH 1/2] feat(web): sort the Tools list by displayed label (#2782) Rows in the Tools sidebar now sort alphabetically by the label they show (title, else name), case-insensitively and with numeric digit runs, so a 20-30 tool list can be scanned. Row keys are still stamped from source position before filtering and sorting, so selection and duplicate-name handling (#1957, #2001) are unaffected. Excluded tools sort the same way. Co-Authored-By: Claude Opus 5.5 Signed-off-by: cliffhall --- .../groups/ToolControls/ToolControls.test.tsx | 43 +++++++++++++++++++ .../groups/ToolControls/ToolControls.tsx | 14 +++--- clients/web/src/utils/toolUtils.test.ts | 30 +++++++++++++ clients/web/src/utils/toolUtils.ts | 22 ++++++++++ 4 files changed, 103 insertions(+), 6 deletions(-) diff --git a/clients/web/src/components/groups/ToolControls/ToolControls.test.tsx b/clients/web/src/components/groups/ToolControls/ToolControls.test.tsx index b95c48ed8..43ef549df 100644 --- a/clients/web/src/components/groups/ToolControls/ToolControls.test.tsx +++ b/clients/web/src/components/groups/ToolControls/ToolControls.test.tsx @@ -399,6 +399,49 @@ describe("ToolControls", () => { expect(screen.queryByText("dupe")).not.toBeInTheDocument(); }); + // Asserts the given texts appear in this order in the rendered DOM. + const expectInDomOrder = (texts: string[]) => { + const nodes = texts.map((text) => screen.getByText(text)); + for (let i = 1; i < nodes.length; i++) { + expect( + nodes[i - 1]!.compareDocumentPosition(nodes[i]!) & + Node.DOCUMENT_POSITION_FOLLOWING, + ).toBeTruthy(); + } + }; + + it("lists tools alphabetically by their displayed label (#2782)", () => { + // sampleTools arrives as List Files, Query DB, git_status, git_commit. + renderWithMantine(); + expectInDomOrder(["git_commit", "git_status", "List Files", "Query DB"]); + }); + + it("keeps the sorted order while a search narrows the list (#2782)", () => { + renderWithMantine(); + expectInDomOrder(["git_commit", "git_status"]); + }); + + it("reports the source-position row key for a sorted row (#2782)", async () => { + // Sorting moves git_commit (source index 3) to the top; its key must still + // name its position in the server's list, which is what the parent resolves. + const user = userEvent.setup(); + const onSelectTool = vi.fn(); + renderWithMantine( + , + ); + await user.click(screen.getByText("git_commit")); + expect(onSelectTool).toHaveBeenCalledWith("3:git_commit"); + }); + + it("lists excluded tools alphabetically too (#2782)", () => { + const excluded = ["zed_tool", "alpha_tool"].map((name) => ({ + tool: { name, inputSchema: { type: "object" as const } }, + reason: "invalid x-mcp-header", + })); + renderWithMantine(); + expectInDomOrder(["alpha_tool", "zed_tool"]); + }); + it("renders no load error by default", () => { renderWithMantine(); expect(screen.queryByText(/Couldn't load/)).not.toBeInTheDocument(); diff --git a/clients/web/src/components/groups/ToolControls/ToolControls.tsx b/clients/web/src/components/groups/ToolControls/ToolControls.tsx index e7b5f557a..802db60b8 100644 --- a/clients/web/src/components/groups/ToolControls/ToolControls.tsx +++ b/clients/web/src/components/groups/ToolControls/ToolControls.tsx @@ -23,7 +23,7 @@ import { } from "../../elements/ListPaginationControls/ListPaginationControls"; import { ToolListItem } from "../ToolListItem/ToolListItem"; import { useScrollMemory } from "../../../hooks/useScrollMemory"; -import { toolRowKey } from "../../../utils/toolUtils"; +import { compareToolsByLabel, toolRowKey } from "../../../utils/toolUtils"; import { formatCountedLabel, listCountOf } from "../../../utils/listCount"; export interface ToolControlsProps { @@ -151,22 +151,24 @@ export function ToolControls({ }: ToolControlsProps) { const viewportRef = useScrollMemory("tools-sidebar"); const query = searchText.toLowerCase(); - // Stamp each row's source position before filtering, so the key survives the - // list narrowing (#1957). + // Stamp each row's source position before filtering and sorting, so the key + // survives the list narrowing (#1957) and the reordering (#2782). const filteredTools = tools .map((tool, sourceIndex) => ({ tool, key: toolRowKey(tool.name, sourceIndex), })) - .filter(({ tool }) => !searchText || matchesQuery(tool, query)); + .filter(({ tool }) => !searchText || matchesQuery(tool, query)) + .sort((a, b) => compareToolsByLabel(a.tool, b.tool)); // Excluded tools are searchable too, matching name AND title like the main - // list above, so a filtered view stays consistent. + // list above, and sorted the same way, so a filtered view stays consistent. const filteredExcluded = excludedTools .map((excluded, sourceIndex) => ({ ...excluded, key: excludedRowKey(excluded.tool.name, sourceIndex), })) - .filter(({ tool }) => !searchText || matchesQuery(tool, query)); + .filter(({ tool }) => !searchText || matchesQuery(tool, query)) + .sort((a, b) => compareToolsByLabel(a.tool, b.tool)); // The server's total, not the search-filtered view: the header answers "how // many tools does this server expose" (#2607). Omitted until the first load diff --git a/clients/web/src/utils/toolUtils.test.ts b/clients/web/src/utils/toolUtils.test.ts index 5f7ee4184..e0fe03f89 100644 --- a/clients/web/src/utils/toolUtils.test.ts +++ b/clients/web/src/utils/toolUtils.test.ts @@ -1,6 +1,7 @@ import { describe, it, expect } from "vitest"; import type { Tool } from "@modelcontextprotocol/client"; import { + compareToolsByLabel, findToolByRowKey, hasInputFields, offersRunAsTask, @@ -26,6 +27,35 @@ describe("resolveDisplayLabel", () => { }); }); +describe("compareToolsByLabel (#2782)", () => { + const tool = (name: string, title?: string): Tool => ({ + name, + ...(title === undefined ? {} : { title }), + inputSchema: { type: "object" }, + }); + const labels = (tools: Tool[]) => + [...tools] + .sort(compareToolsByLabel) + .map((t) => resolveDisplayLabel(t.name, t.title)); + + it("orders by the displayed label: the title when present, else the name", () => { + expect( + labels([tool("zeta", "Alpha"), tool("beta"), tool("aaa", "Gamma")]), + ).toEqual(["Alpha", "beta", "Gamma"]); + }); + + it("ignores case and compares digit runs numerically", () => { + expect( + labels([tool("tool_10"), tool("Tool_2"), tool("apple"), tool("Banana")]), + ).toEqual(["apple", "Banana", "Tool_2", "tool_10"]); + }); + + it("treats labels differing only by case as equal, so a stable sort keeps source order", () => { + expect(compareToolsByLabel(tool("Echo"), tool("echo"))).toBe(0); + expect(compareToolsByLabel(tool("echo"), tool("echo"))).toBe(0); + }); +}); + describe("hasInputFields", () => { const baseTool = (inputSchema: Tool["inputSchema"]): Tool => ({ name: "t", diff --git a/clients/web/src/utils/toolUtils.ts b/clients/web/src/utils/toolUtils.ts index 77afb7df9..cb6f14ecd 100644 --- a/clients/web/src/utils/toolUtils.ts +++ b/clients/web/src/utils/toolUtils.ts @@ -16,6 +16,28 @@ export function resolveDisplayLabel(name: string, title?: string): string { return title ?? name; } +// Case-insensitive, with digit runs compared numerically, so `tool_2` sorts +// before `tool_10` and `Git` beside `git` — the order a person scanning the +// list expects, rather than raw code-unit order. +const toolLabelCollator = new Intl.Collator(undefined, { + sensitivity: "base", + numeric: true, +}); + +/** + * Orders tools by the label their row shows ({@link resolveDisplayLabel}: the + * `title` when present, else the `name`), so the Tools sidebar reads + * alphabetically however the server ordered `tools/list` (#2782). Ties return + * 0, and `Array.prototype.sort` is stable, so duplicates keep their source + * order. + */ +export function compareToolsByLabel(a: Tool, b: Tool): number { + return toolLabelCollator.compare( + resolveDisplayLabel(a.name, a.title), + resolveDisplayLabel(b.name, b.title), + ); +} + /** * True when the tool's input schema declares at least one property — used by * App-flow callers to decide whether to render a form or auto-launch. Kept in From 8e789d7e01a2abafe26247ce2bda79b0c0b884d0 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Sat, 24 Oct 2026 23:30:25 -0400 Subject: [PATCH 2/2] fix(web): sort excluded tools by the name they display (#2782) Excluded rows render only tool.name, so sorting them by the resolved title could leave the visible list out of order. Expose the collator as compareToolLabels and sort excluded rows by name. Co-Authored-By: Claude Opus 5.5 Signed-off-by: cliffhall --- .../groups/ToolControls/ToolControls.test.tsx | 11 ++++++++--- .../components/groups/ToolControls/ToolControls.tsx | 11 ++++++++--- clients/web/src/utils/toolUtils.test.ts | 12 ++++++++++++ clients/web/src/utils/toolUtils.ts | 11 ++++++++++- 4 files changed, 38 insertions(+), 7 deletions(-) diff --git a/clients/web/src/components/groups/ToolControls/ToolControls.test.tsx b/clients/web/src/components/groups/ToolControls/ToolControls.test.tsx index 43ef549df..5f644c2eb 100644 --- a/clients/web/src/components/groups/ToolControls/ToolControls.test.tsx +++ b/clients/web/src/components/groups/ToolControls/ToolControls.test.tsx @@ -433,9 +433,14 @@ describe("ToolControls", () => { expect(onSelectTool).toHaveBeenCalledWith("3:git_commit"); }); - it("lists excluded tools alphabetically too (#2782)", () => { - const excluded = ["zed_tool", "alpha_tool"].map((name) => ({ - tool: { name, inputSchema: { type: "object" as const } }, + it("lists excluded tools alphabetically by the name they show (#2782)", () => { + // Titles that would invert the order: an excluded row shows only `name`, + // so sorting by the resolved title would read as unsorted. + const excluded = [ + { name: "zed_tool", title: "Alpha" }, + { name: "alpha_tool", title: "Zulu" }, + ].map((tool) => ({ + tool: { ...tool, inputSchema: { type: "object" as const } }, reason: "invalid x-mcp-header", })); renderWithMantine(); diff --git a/clients/web/src/components/groups/ToolControls/ToolControls.tsx b/clients/web/src/components/groups/ToolControls/ToolControls.tsx index 802db60b8..9364df639 100644 --- a/clients/web/src/components/groups/ToolControls/ToolControls.tsx +++ b/clients/web/src/components/groups/ToolControls/ToolControls.tsx @@ -23,7 +23,11 @@ import { } from "../../elements/ListPaginationControls/ListPaginationControls"; import { ToolListItem } from "../ToolListItem/ToolListItem"; import { useScrollMemory } from "../../../hooks/useScrollMemory"; -import { compareToolsByLabel, toolRowKey } from "../../../utils/toolUtils"; +import { + compareToolLabels, + compareToolsByLabel, + toolRowKey, +} from "../../../utils/toolUtils"; import { formatCountedLabel, listCountOf } from "../../../utils/listCount"; export interface ToolControlsProps { @@ -161,14 +165,15 @@ export function ToolControls({ .filter(({ tool }) => !searchText || matchesQuery(tool, query)) .sort((a, b) => compareToolsByLabel(a.tool, b.tool)); // Excluded tools are searchable too, matching name AND title like the main - // list above, and sorted the same way, so a filtered view stays consistent. + // list above, so a filtered view stays consistent. They sort by `name`, not + // the resolved label, because `name` is all an excluded row shows. const filteredExcluded = excludedTools .map((excluded, sourceIndex) => ({ ...excluded, key: excludedRowKey(excluded.tool.name, sourceIndex), })) .filter(({ tool }) => !searchText || matchesQuery(tool, query)) - .sort((a, b) => compareToolsByLabel(a.tool, b.tool)); + .sort((a, b) => compareToolLabels(a.tool.name, b.tool.name)); // The server's total, not the search-filtered view: the header answers "how // many tools does this server expose" (#2607). Omitted until the first load diff --git a/clients/web/src/utils/toolUtils.test.ts b/clients/web/src/utils/toolUtils.test.ts index e0fe03f89..483e022e1 100644 --- a/clients/web/src/utils/toolUtils.test.ts +++ b/clients/web/src/utils/toolUtils.test.ts @@ -1,6 +1,7 @@ import { describe, it, expect } from "vitest"; import type { Tool } from "@modelcontextprotocol/client"; import { + compareToolLabels, compareToolsByLabel, findToolByRowKey, hasInputFields, @@ -56,6 +57,17 @@ describe("compareToolsByLabel (#2782)", () => { }); }); +describe("compareToolLabels (#2782)", () => { + it("compares raw labels with the same collation the tool comparator uses", () => { + expect(["tool_10", "Tool_2", "apple"].sort(compareToolLabels)).toEqual([ + "apple", + "Tool_2", + "tool_10", + ]); + expect(compareToolLabels("Echo", "echo")).toBe(0); + }); +}); + describe("hasInputFields", () => { const baseTool = (inputSchema: Tool["inputSchema"]): Tool => ({ name: "t", diff --git a/clients/web/src/utils/toolUtils.ts b/clients/web/src/utils/toolUtils.ts index cb6f14ecd..fa0fb0593 100644 --- a/clients/web/src/utils/toolUtils.ts +++ b/clients/web/src/utils/toolUtils.ts @@ -24,6 +24,15 @@ const toolLabelCollator = new Intl.Collator(undefined, { numeric: true, }); +/** + * Orders two row labels the way the Tools sidebar sorts (#2782). Exported for + * rows that show something other than the resolved label — the excluded rows + * show the bare `name` — so each list sorts by the text it actually displays. + */ +export function compareToolLabels(a: string, b: string): number { + return toolLabelCollator.compare(a, b); +} + /** * Orders tools by the label their row shows ({@link resolveDisplayLabel}: the * `title` when present, else the `name`), so the Tools sidebar reads @@ -32,7 +41,7 @@ const toolLabelCollator = new Intl.Collator(undefined, { * order. */ export function compareToolsByLabel(a: Tool, b: Tool): number { - return toolLabelCollator.compare( + return compareToolLabels( resolveDisplayLabel(a.name, a.title), resolveDisplayLabel(b.name, b.title), );