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:
parent
e9a581d912
commit
ab40fb621a
2 changed files with 210 additions and 48 deletions
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -19,3 +19,35 @@ export function resolveDisplayLabel(name: string, title?: string): string {
|
|||
export function hasInputFields(tool: Tool): boolean {
|
||||
return Object.keys(tool.inputSchema.properties ?? {}).length > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* A tool's stable per-row identity in the Tools sidebar.
|
||||
*
|
||||
* A server may return the same tool name more than once, so the name alone
|
||||
* identifies neither a React child nor a selection: colliding keys let a
|
||||
* filtered-out row survive reconciliation (#1957), and a name-keyed selection
|
||||
* highlights both copies at once while the detail panel can only ever resolve
|
||||
* the first (#2001). The tool's position in the *unfiltered* list disambiguates
|
||||
* duplicates and stays stable while the search narrows, since it is captured
|
||||
* before filtering.
|
||||
*
|
||||
* This is a UI identity only — the wire identity is still `tool.name`, which is
|
||||
* what a `tools/call` must send.
|
||||
*/
|
||||
export function toolRowKey(name: string, sourceIndex: number): string {
|
||||
return `${sourceIndex}:${name}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the tool a {@link toolRowKey} refers to, or `undefined` when the key
|
||||
* names no row in the current list (e.g. the list changed under a stale
|
||||
* selection). Compares computed keys rather than parsing the key, so the two
|
||||
* stay in lockstep if the format ever changes.
|
||||
*/
|
||||
export function findToolByRowKey(
|
||||
tools: Tool[],
|
||||
key: string | undefined,
|
||||
): Tool | undefined {
|
||||
if (key === undefined) return undefined;
|
||||
return tools.find((tool, index) => toolRowKey(tool.name, index) === key);
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue