From e779185940acfb3eba09b8f5cb6d8ccbffc017bf Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Tue, 3 Nov 2026 13:47:27 -0800 Subject: [PATCH] fix(connectors): a grant whose key was removed shows the picker with "Choose a key" MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From CodeRabbit review 5759837673 on #3354. When a key grant's key is gone, openDraft leaves connectionId empty: - The connection picker now shows even when only one key remains, so the person can still choose it. Before, it was hidden and Grant stayed off with nothing to pick. - A disabled "Choose a key" placeholder (zh 选择密钥, matching 选择智能体) keeps the select's value at '', so the browser no longer shows the first key as chosen. Choosing it now fires a change. - The agent list and the "select an agent" notice wait until a key is chosen. - submitDraft also refuses an empty connectionId. Tests: a removed key with one key left offers the picker, and choosing it posts [SCOUT]. The two-key test also asserts the empty value and the placeholder. Each fails without its change. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01MDs9DT6tbhKm6UhsmFhLk9 --- frontend/src/i18n/locales/en.json | 3 ++- frontend/src/i18n/locales/zh-CN.json | 3 ++- .../ConnectorsPage.operatorKey.test.tsx | 18 +++++++++++++++++- frontend/src/v2/connectors/ToolPanels.tsx | 12 ++++++++++-- .../src/v2/connectors/useToolConnectors.ts | 2 +- 5 files changed, 32 insertions(+), 6 deletions(-) diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index 4dccb3e4c3..59842da9f5 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -2819,7 +2819,8 @@ "generic": "Could not add the key. Try again." }, "agentsUnavailable": "couldn't read your agents just now · reload to try again" - } + }, + "chooseKey": "Choose a key" }, "agentManage": { "runtime": { diff --git a/frontend/src/i18n/locales/zh-CN.json b/frontend/src/i18n/locales/zh-CN.json index 14063b8d34..57fc27502a 100644 --- a/frontend/src/i18n/locales/zh-CN.json +++ b/frontend/src/i18n/locales/zh-CN.json @@ -2820,7 +2820,8 @@ "generic": "无法添加密钥,请重试。" }, "agentsUnavailable": "暂时无法读取你的智能体,请刷新后重试" - } + }, + "chooseKey": "选择密钥" }, "agentManage": { "runtime": { diff --git a/frontend/src/v2/__tests__/ConnectorsPage.operatorKey.test.tsx b/frontend/src/v2/__tests__/ConnectorsPage.operatorKey.test.tsx index 0c171eca39..7ef4bb4ce2 100644 --- a/frontend/src/v2/__tests__/ConnectorsPage.operatorKey.test.tsx +++ b/frontend/src/v2/__tests__/ConnectorsPage.operatorKey.test.tsx @@ -406,12 +406,28 @@ describe('Connectors page: granting an operator-key connection', () => { it('Grant again on a key grant whose key is gone starts with nobody, never another agent\'s key', async () => { const dead = keyGrant({ grantId: 'grant_gone', installationId: 'conn-deleted', target: { kind: 'pod', id: 'p2' }, effectiveAudience: [REED], ...revoked }); const form = await openGrantAgain([scoutKey, reedKey], dead); - expect(within(form).queryAllByRole('checkbox', { checked: true })).toHaveLength(0); + // The picker says no key is chosen, rather than showing Scout's as picked. + expect(within(form).getByLabelText('connection')).toHaveValue(''); + expect(within(form).getByRole('option', { name: 'Choose a key' })).toBeDisabled(); + expect(within(form).queryAllByRole('checkbox')).toHaveLength(0); expect(within(form).getByRole('button', { name: 'Grant' })).toBeDisabled(); fireEvent.click(within(form).getByRole('button', { name: 'Grant' })); expect(grantPost()).toBeUndefined(); }); + it('a removed key with one key left still offers the picker, and choosing it enables Grant', async () => { + const dead = keyGrant({ grantId: 'grant_gone', installationId: 'conn-deleted', effectiveAudience: [REED], ...revoked }); + const form = await openGrantAgain([scoutKey], dead); + const picker = within(form).getByLabelText('connection'); + expect(picker).toHaveValue(''); + expect(within(form).getByRole('button', { name: 'Grant' })).toBeDisabled(); + fireEvent.change(picker, { target: { value: 'conn-scout' } }); + expect(within(form).getByRole('checkbox', { name: 'Scout' })).toBeChecked(); + fireEvent.click(within(form).getByRole('button', { name: 'Grant' })); + await waitFor(() => expect(grantPost()).toBeDefined()); + expect(grantPost()[1]).toEqual(expect.objectContaining({ connectionId: 'conn-scout', target: { kind: 'pod', id: 'p1' }, audience: [SCOUT] })); + }); + it('in a pod with several agents, the first Grant reaches only the key\'s agent', async () => { const form = await openKeyDraft([scoutKey, reedKey]); // Launch pod holds Scout and Nova; only Scout's checkbox is offered. diff --git a/frontend/src/v2/connectors/ToolPanels.tsx b/frontend/src/v2/connectors/ToolPanels.tsx index a11b9ff44f..b4187aa716 100644 --- a/frontend/src/v2/connectors/ToolPanels.tsx +++ b/frontend/src/v2/connectors/ToolPanels.tsx @@ -530,6 +530,9 @@ export const useToolPanels = (m: ToolConnectorsModel) => { const offeredSeats = draftBoundAgent ? podSeats.filter((seat) => seat.userId === draftBoundAgent) : podSeats; const boundAbsent = Boolean(draftBoundAgent) && offeredSeats.length === 0; const boundName = draftBoundAgent ? (agentNameById(draftBoundAgent) || memberName(draftBoundAgent)) : null; + // A key grant whose key was removed opens with no key chosen (openDraft): + // the picker shows even for one remaining key, and the seats wait for a key. + const keyPending = draftEntry.credentialMode === 'operator_key' && !draft.connectionId; const tools = draftTools(draftEntry, draft.writeMode); const irreversible = irreversibleTools(draftEntry, tools); const asks = draft.writeMode === 'read' @@ -552,7 +555,7 @@ export const useToolPanels = (m: ToolConnectorsModel) => { )} - {draftEntry.connections.length > 1 && ( + {(draftEntry.connections.length > 1 || (keyPending && draftEntry.connections.length > 0)) && (