fix(web): key tool selection by row identity, not name

#1961 keyed the sidebar's React children by source position, but everything
downstream still identified a tool by `tool.name`. With a `tools/list` that
repeats a name, both copies highlighted at once, the click guard made the
other copy a no-op, and `tools.find((t) => t.name === …)` always resolved to
the first — so a later copy rendered but could never be opened.

Thread the row key through selection instead:

- `utils/toolUtils.ts` now owns `toolRowKey` (moved out of ToolControls) and
  adds `findToolByRowKey`, which compares computed keys rather than parsing
  one so the two can't drift.
- `ToolControls`: `selectedName` → `selectedKey`; the highlight and the click
  guard compare keys, and `onSelectTool` emits the row key.
- `ToolsScreen`: `ToolsUiState.selectedToolName` → `selectedToolKey`, with
  both lookups going through `findToolByRowKey`. Renamed through
  `screenUiState.ts` and `App.tsx`'s `onToolsUiChange`.

`onCallTool` still sends `selectedTool.name` — the duplicated name is the
genuine wire identity; only the UI identity needed to be unique.

Also prefixes the excluded (SEP-2243) rows' keys: they render into the same
`Stack` as the list rows but index within their own list, so an excluded tool
sharing a name and index with a listed one collided in the key space.

Closes #2001

Signed-off-by: cliffhall <cliff@futurescale.com>
This commit is contained in:
cliffhall 2026-08-17 00:02:13 -04:00
commit ab40fb621a

View file

@ -1,6 +1,11 @@
import { describe, it, expect } from "vitest";
import type { Tool } from "@modelcontextprotocol/client";
import { hasInputFields, resolveDisplayLabel } from "./toolUtils";
import {
findToolByRowKey,
hasInputFields,
resolveDisplayLabel,
toolRowKey,
} from "./toolUtils";
describe("resolveDisplayLabel", () => {
it("returns the title when provided", () => {
@ -47,3 +52,37 @@ describe("hasInputFields", () => {
).toBe(true);
});
});
describe("toolRowKey / findToolByRowKey", () => {
const tool = (name: string): Tool => ({
name,
inputSchema: { type: "object" },
});
// A `tools/list` may legitimately repeat a name (#1957/#2001).
const tools: Tool[] = [
tool("get_weather"),
tool("echo"),
tool("get_weather"),
];
it("distinguishes two tools that share a name", () => {
expect(toolRowKey("get_weather", 0)).not.toBe(toolRowKey("get_weather", 2));
});
it("resolves each duplicate to its own entry", () => {
expect(findToolByRowKey(tools, "0:get_weather")).toBe(tools[0]);
// The second copy — the one a name-based lookup could never reach.
expect(findToolByRowKey(tools, "2:get_weather")).toBe(tools[2]);
});
it("returns undefined for no key", () => {
expect(findToolByRowKey(tools, undefined)).toBeUndefined();
});
it("returns undefined for a key naming no current row", () => {
// A stale selection after the list changed underneath it.
expect(findToolByRowKey(tools, "9:gone")).toBeUndefined();
// Right name, wrong position: still not a match.
expect(findToolByRowKey(tools, "1:get_weather")).toBeUndefined();
});
});