Uh oh!
There was an error while loading. Please reload this page.
fix(web): key tool selection by row identity so duplicate tool names are individually inspectable - #2041
Conversation
#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>
There was a problem hiding this comment.
Pull request overview
Keys tool selection by row identity so duplicate names are independently inspectable while preserving protocol names for calls.
Changes:
- Adds shared row-key generation and lookup utilities.
- Threads row keys through controls, screen state, and App state.
- Adds duplicate-name regression tests and updates snapshots/stories.
Reviewed changes
Copilot reviewed 11 out of 11 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
clients/web/src/utils/toolUtils.ts | Adds row-key utilities. |
clients/web/src/utils/toolUtils.test.ts | Tests duplicate-key resolution. |
clients/web/src/lib/oauthResume.test.ts | Updates persisted UI-state fixtures. |
clients/web/src/components/screens/ToolsScreen/ToolsScreen.tsx | Resolves selections by row key. |
clients/web/src/components/screens/ToolsScreen/ToolsScreen.test.tsx | Tests duplicate inspection and execution. |
clients/web/src/components/screens/screenUiState.ts | Renames default selection state. |
clients/web/src/components/groups/ToolControls/ToolControls.tsx | Selects and keys individual rows. |
clients/web/src/components/groups/ToolControls/ToolControls.test.tsx | Tests independent duplicate selection. |
clients/web/src/components/groups/ToolControls/ToolControls.stories.tsx | Updates selected-row story data. |
clients/web/src/App.tsx | Tracks selection changes by row key. |
clients/web/src/App.test.tsx | Updates App test state and mocks. |
💡 Add a code-review agent skill for context-aware, tailored reviews. Learn more in the docs.
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
cliffhall
commented
Aug 17, 2026
Response to Copilot review (round 1)Mirroring the inline replies at PR level, since inline threads go hidden once the fix is pushed. 1. A snapshot lives for exactly one redirect, so a legacy one is only ever read when the app is upgraded mid-flight; and the old What was worth fixing is that the blind 2. A stale key can resolve into a newer tools snapshot — not changing, with reasons. This is the staleness the code already had: the previous lookup was Resetting selection on every new tools snapshot is the part to push back on: persisting selection across a refresh is exactly what the lifted The residual case (a server that reorders duplicate definitions so a same-named tool lands at the same index with a different schema) renders the schema genuinely in the current list at that position; only the form values are stale. If it ever shows up in practice it wants real per-tool identity — a schema digest — not a selection reset. |
A resume snapshot written before this change carries `selectedToolName` where `ToolsUiState` now expects `selectedToolKey`. The blind cast in `restoreTabUiFromSnapshot` carried it through as a stray field that would be re-serialized on the next redirect. Route the Tools entry through a `normalizeToolsUi` that drops it explicitly instead. The name is deliberately not migrated to a row key: a snapshot lives for one redirect (so this only arises when the app is upgraded mid-flight), and the tools list is fetched after reconnect, long after restore — there is nothing to resolve the name against. The selection is lost; search, form values, and the run-as-task toggle restore intact. Signed-off-by: cliffhall <cliff@futurescale.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 12 out of 12 changed files in this pull request and generated no new comments.
Suppressed comments (1)
clients/web/src/components/screens/ToolsScreen/ToolsScreen.tsx:46
- The authoritative V2 specs now contradict this state shape:
specification/v2_ux_interfaces.md:649,719,722,726still definesselectedName/selectedToolName, emits names fromonSelectTool, and prescribestools.find(...), whilespecification/v2_storage.md:307still storesselectedToolName. Please update those sections to document row-key UI identity and the separate protocol name used fortools/call, otherwise future work is explicitly directed back to the bug fixed here.
/**
* The selected row's `toolRowKey` — its position in the list plus its name,
* not the name alone. A `tools/list` may repeat a name, and identifying the
* selection by name highlighted every copy while the detail panel always
* resolved the first, so the later copies could not be inspected (#2001).
* The protocol name a `tools/call` sends still comes from the resolved tool.
*/
selectedToolKey?: string;
`v2_ux_interfaces.md` and `v2_storage.md` still prescribed `selectedName` / `selectedToolName` and `tools.find(t => t.name === …)` — the exact shape that made a duplicated tool name uninspectable. Point them at `toolRowKey` / `findToolByRowKey` and note the split between UI identity (the row key, carried by `onSelectTool`) and wire identity (the protocol name, carried by `onCallTool`). Follow-up to #2001 review feedback. Signed-off-by: cliffhall <cliff@futurescale.com>
cliffhall
commented
Aug 17, 2026
Response to Copilot review (round 2)No new inline comments. The one suppressed comment was a fair catch and is now fixed in 3b995f6. The V2 specs still prescribed the buggy shape — fixed.
|
Closes#2001
#1961 fixed the rendering half of the duplicate-tool-name problem — sidebar rows are keyed by source position, so filtering no longer orphans a child. But everything downstream of that key still identified a tool by
tool.name, so the second copy rendered and then could not be opened:ToolControlsmarkedselected={tool.name === selectedName}, so both duplicates highlighted at once, and itsonClickguard (if (tool.name !== selectedName)) made clicking the other copy a no-op.ToolsScreen'stools.find((t) => t.name === selectedToolName)always resolved to the first match, so the detail panel could never show a later copy even if selection had changed.What changed
Selection is now keyed by the same stable per-row identity the list already computed for its React
key, rather than by name.utils/toolUtils.ts— the keying moves here and becomes the shared vocabulary:toolRowKey(name, sourceIndex)(was a privaterowKeyinToolControls) plusfindToolByRowKey(tools, key), which compares computed keys rather than parsing one, so the two can't drift.ToolControls—selectedName→selectedKey, andonSelectToolemits the row key. Highlight and the click guard both compare keys, so each copy is independently selectable.ToolsScreen—ToolsUiState.selectedToolName→selectedToolKey, and both lookups (the detail-panel tool and the form-defaults seed) go throughfindToolByRowKey. Renamed throughscreenUiState.tsandApp.tsx'sonToolsUiChange.onCallToolstill sendsselectedTool.name— the duplicated name genuinely is the protocol identity; only the UI identity needed to be unique.One adjacent fix while here: excluded (SEP-2243) rows render into the same
Stackas 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 React key space. They now carry anexcluded:prefix.Tests
toolUtils.test.ts—toolRowKeydistinguishes same-named tools;findToolByRowKeyresolves each duplicate to its own entry, and returnsundefinedfor no key, a stale key, and a right-name/wrong-position key.ToolControls.test.tsx— with the leadingget_weatherselected, exactly one row is highlighted and clicking the trailing one reports"2:get_weather".ToolsScreen.test.tsx— clicking the second copy renders its schema (its field present, the first copy's absent), and Execute callsonCallTool("get_weather", …)with the protocol name.Verification
npm run cigreen. Screenshots below are the repro from the issue:test-servers/configs/duplicate-tool-names-http.json, default (legacy) era — select the leadingget_weather, then click the trailing one.Before — both copies highlighted, detail panel stuck on the first
The trailing row is clicked, but
get_weather(the leading copy) is what the panel shows, and both rows read as selected.After — the trailing copy is selected and inspectable
Only
get_weather (duplicate)is highlighted, and the panel shows that row's tool.