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

@ -363,7 +363,7 @@ vi.mock("./components/views/InspectorView/InspectorView", () => ({
InspectorView: (props: {
toolCallState?: { status?: string };
toolsUi?: {
selectedToolName?: string;
selectedToolKey?: string;
formValues: Record<string, unknown>;
search: string;
};
@ -384,7 +384,7 @@ vi.mock("./components/views/InspectorView/InspectorView", () => ({
onConnectionInfo: () => void;
onToggleConnection: (id: string) => void;
onToolsUiChange: (next: {
selectedToolName?: string;
selectedToolKey?: string;
formValues: Record<string, unknown>;
search: string;
}) => void;
@ -433,7 +433,7 @@ vi.mock("./components/views/InspectorView/InspectorView", () => ({
{Object.keys(props.progressByTaskId ?? {}).join(",") || "none"}
</span>
<span data-testid="selected-tool">
{props.toolsUi?.selectedToolName ?? "none"}
{props.toolsUi?.selectedToolKey ?? "none"}
</span>
<span data-testid="tool-search">{props.toolsUi?.search || "none"}</span>
<span data-testid="selected-prompt">
@ -469,7 +469,7 @@ vi.mock("./components/views/InspectorView/InspectorView", () => ({
formValues: {},
search: "",
...props.toolsUi,
selectedToolName: "get_acts",
selectedToolKey: "0:get_acts",
})
}
>
@ -481,7 +481,7 @@ vi.mock("./components/views/InspectorView/InspectorView", () => ({
formValues: {},
search: "",
...props.toolsUi,
selectedToolName: "other_tool",
selectedToolKey: "1:other_tool",
})
}
>
@ -869,7 +869,7 @@ describe("App session-scoped state reset on disconnect", () => {
expect(screen.getByTestId("tool-status")).toHaveTextContent("ok"),
);
// A search keystroke leaves `selectedToolName` unchanged, so the result
// A search keystroke leaves `selectedToolKey` unchanged, so the result
// stays put.
await user.click(screen.getByText("set-tool-search"));
await waitFor(() =>