diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index 511ca246f..e909142a9 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -2594,6 +2594,7 @@ "loadFailed": "Could not load artifacts." }, "tools": { + "linearWriteReconnect": "If you connected Linear before it could write, disconnect it and connect it again to turn on writes. Each write asks for your approval first.", "granted": "Granted", "beta": "beta", "adminOnce": "An admin installs this once", diff --git a/frontend/src/i18n/locales/zh-CN.json b/frontend/src/i18n/locales/zh-CN.json index ae5d3ff64..2f61ff121 100644 --- a/frontend/src/i18n/locales/zh-CN.json +++ b/frontend/src/i18n/locales/zh-CN.json @@ -2597,6 +2597,7 @@ "loadFailed": "无法加载产物。" }, "tools": { + "linearWriteReconnect": "如果你是在支持写入之前连接的 Linear,需要先断开再重新连接,才能写入。每次写入都需要你批准。", "granted": "已授权", "beta": "测试版", "adminOnce": "需管理员安装一次", diff --git a/frontend/src/v2/__tests__/ConnectorsPage.tools.test.tsx b/frontend/src/v2/__tests__/ConnectorsPage.tools.test.tsx index d5fb519d7..089f6c79a 100644 --- a/frontend/src/v2/__tests__/ConnectorsPage.tools.test.tsx +++ b/frontend/src/v2/__tests__/ConnectorsPage.tools.test.tsx @@ -1393,3 +1393,49 @@ describe('Connectors page: tools', () => { }); }); }); + +describe('Linear write reconnect note', () => { + afterEach(async () => { await act(async () => { await i18n.changeLanguage('en'); }); }); + + it.each(['en', 'zh-CN'])('a connected Linear drawer says older connections must reconnect to write, in %s', async (language) => { + await i18nReady; + await act(async () => { await i18n.changeLanguage(language); }); + mockTools({ entries: [githubEntry, linearEntry], grants: [grantLinear] }); + renderPage(); + const drawer = await openTool('Linear'); + const note = within(drawer).getByRole('note'); + expect(note).toHaveTextContent(language === 'en' ? 'disconnect it and connect it again to turn on writes' : '先断开再重新连接'); + expect(note).toHaveTextContent(language === 'en' ? 'Each write asks for your approval first' : '每次写入都需要你批准'); + }); + + it('a connected Linear with no grant shows it once in the popover, before Add', async () => { + mockTools({ entries: [githubEntry, linearEntry], grants: [] }); + renderPage(); + const pop = await openToolPopover('Linear'); + expect(within(pop).getAllByRole('note')).toHaveLength(1); + expect(within(pop).getByRole('note')).toHaveTextContent('disconnect it and connect it again to turn on writes'); + }); + + it('a Linear drawer with only an ended grant shows it once, in the offer', async () => { + const ended = { ...grantLinear, revokedAt: iso(-600000), revokedBy: 'u1' }; + mockTools({ entries: [githubEntry, linearEntry], grants: [ended] }); + renderPage(); + const drawer = await openTool('Linear'); + expect(within(drawer).getAllByRole('note')).toHaveLength(1); + }); + + it('no other drawer carries it', async () => { + mockTools({ entries: [githubEntry, linearEntry], grants: [grantLive] }); + renderPage(); + const drawer = await openTool('GitHub'); + expect(within(drawer).queryByRole('note')).not.toBeInTheDocument(); + }); + + it('another connected hosted connector does not carry it', async () => { + const atlassian = { ...linearEntry, installableId: 'atlassian', entryId: 'atlassian', label: 'Atlassian', connections: [{ connectionId: 'conn-atl', owner: '', repo: '' }] }; + mockTools({ entries: [atlassian], grants: [] }); + renderPage(); + const pop = await openToolPopover('Atlassian'); + expect(within(pop).queryByRole('note')).not.toBeInTheDocument(); + }); +}); diff --git a/frontend/src/v2/connectors/ConnectorsPage.tsx b/frontend/src/v2/connectors/ConnectorsPage.tsx index b00075a32..ca7f79f66 100644 --- a/frontend/src/v2/connectors/ConnectorsPage.tsx +++ b/frontend/src/v2/connectors/ConnectorsPage.tsx @@ -463,6 +463,8 @@ const ConnectorsPage: React.FC = () => { const deadGrants = entryGrants.filter((grant) => isDead(grant, m.now)); return ( + {/* Ungranted, the offer below carries the note through renderEntryNote. */} + {toolGranted(toolEntry) && tp.renderLinearWriteNote(toolEntry)} {!toolGranted(toolEntry) && (

{catalogDescription(i18n, toolEntry.installableId, toolEntry.description)}

diff --git a/frontend/src/v2/connectors/ToolPanels.tsx b/frontend/src/v2/connectors/ToolPanels.tsx index b3bbb6aec..e7d55c1c8 100644 --- a/frontend/src/v2/connectors/ToolPanels.tsx +++ b/frontend/src/v2/connectors/ToolPanels.tsx @@ -331,6 +331,15 @@ export const useToolPanels = (m: ToolConnectorsModel) => { ); }; + // A Linear connection made before write access lacks the write scope, so the + // server refuses to grant it write mode until it is connected again. The page + // cannot see a connection's scope, so the note is conditional and always shown. + const renderLinearWriteNote = (entry: ToolCatalogEntry) => ( + entry.connectionType === 'hosted-mcp' && entry.entryId === 'linear' && entry.connections.length > 0 + ?

{t('tools.linearWriteReconnect')}

+ : null + ); + // The one line under an entry's name that says what is left to do before it // works: connect your own account, ask an admin, or the modes it grants. const renderEntryNote = (entry: ToolCatalogEntry) => { @@ -347,6 +356,7 @@ export const useToolPanels = (m: ToolConnectorsModel) => { : entryModeCopy(entry)}

)} + {renderLinearWriteNote(entry)} {canConnectHosted && entry.connectNotice &&

{entry.connectNotice}

} {isHosted && entry.available && entry.accessPage &&

{renderAccessLink(entry)}

} {renderManualRevokeNotice(entry)} @@ -612,5 +622,5 @@ export const useToolPanels = (m: ToolConnectorsModel) => { ); }; - return { renderManualRevokeNotice, renderDisconnect, renderHiddenConnection, renderRow, renderAccessLink, renderEntryActions, renderEntryNote, renderGithubAppSetup, renderDraft, renderAside }; + return { renderManualRevokeNotice, renderLinearWriteNote, renderDisconnect, renderHiddenConnection, renderRow, renderAccessLink, renderEntryActions, renderEntryNote, renderGithubAppSetup, renderDraft, renderAside }; };