#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>
77 lines
1.9 KiB
TypeScript
77 lines
1.9 KiB
TypeScript
import { useState } from "react";
|
|
import { noopPagination } from "../../../test/fixtures/pagination";
|
|
import type { ComponentProps } from "react";
|
|
import type { Meta, StoryObj } from "@storybook/react-vite";
|
|
import { fn, userEvent, within } from "storybook/test";
|
|
import { ToolControls } from "./ToolControls";
|
|
import { longToolList } from "../../screens/ToolsScreen/ToolsScreen.fixtures";
|
|
|
|
// ToolControls' search text is controlled by the parent (App, via ToolsScreen —
|
|
// see #1417). This wrapper holds that state so the play-driven typing updates
|
|
// the controlled input.
|
|
function StatefulToolControls(args: ComponentProps<typeof ToolControls>) {
|
|
const [searchText, setSearchText] = useState(args.searchText ?? "");
|
|
return (
|
|
<ToolControls
|
|
{...args}
|
|
searchText={searchText}
|
|
onSearchChange={setSearchText}
|
|
/>
|
|
);
|
|
}
|
|
|
|
const meta: Meta<typeof ToolControls> = {
|
|
title: "Groups/ToolControls",
|
|
component: ToolControls,
|
|
args: {
|
|
pagination: noopPagination,
|
|
onRefreshList: fn(),
|
|
onSelectTool: fn(),
|
|
onSearchChange: fn(),
|
|
listChanged: false,
|
|
},
|
|
render: (args) => <StatefulToolControls {...args} />,
|
|
};
|
|
|
|
export default meta;
|
|
type Story = StoryObj<typeof ToolControls>;
|
|
|
|
export const Default: Story = {
|
|
args: {
|
|
tools: longToolList,
|
|
},
|
|
};
|
|
|
|
export const WithSelection: Story = {
|
|
args: {
|
|
tools: longToolList,
|
|
// Row identity is `index:name`, so the selection names the position too.
|
|
selectedKey: "1:query_database",
|
|
},
|
|
};
|
|
|
|
export const WithSearch: Story = {
|
|
args: {
|
|
tools: longToolList,
|
|
},
|
|
play: async ({ canvasElement }) => {
|
|
const canvas = within(canvasElement);
|
|
await userEvent.type(
|
|
await canvas.findByPlaceholderText("Search tools..."),
|
|
"git",
|
|
);
|
|
},
|
|
};
|
|
|
|
export const ListChanged: Story = {
|
|
args: {
|
|
tools: longToolList,
|
|
listChanged: true,
|
|
},
|
|
};
|
|
|
|
export const Empty: Story = {
|
|
args: {
|
|
tools: [],
|
|
},
|
|
};
|