Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions frontend/src/i18n/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
1 change: 1 addition & 0 deletions frontend/src/i18n/locales/zh-CN.json
Original file line number Diff line number Diff line change
Expand Up @@ -2597,6 +2597,7 @@
"loadFailed": "无法加载产物。"
},
"tools": {
"linearWriteReconnect": "如果你是在支持写入之前连接的 Linear,需要先断开再重新连接,才能写入。每次写入都需要你批准。",
"granted": "已授权",
"beta": "测试版",
"adminOnce": "需管理员安装一次",
Expand Down
46 changes: 46 additions & 0 deletions frontend/src/v2/__tests__/ConnectorsPage.tools.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
});
2 changes: 2 additions & 0 deletions frontend/src/v2/connectors/ConnectorsPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -463,6 +463,8 @@ const ConnectorsPage: React.FC = () => {
const deadGrants = entryGrants.filter((grant) => isDead(grant, m.now));
return (
<ConnectorDrawer notices={noticeBlock} type={toolEntry.installableId} title={toolEntry.label} onClose={closeTools}>
{/* Ungranted, the offer below carries the note through renderEntryNote. */}
{toolGranted(toolEntry) && tp.renderLinearWriteNote(toolEntry)}
{!toolGranted(toolEntry) && (
<div className="v2-cx-drawer__offer">
<p>{catalogDescription(i18n, toolEntry.installableId, toolEntry.description)}</p>
Expand Down
12 changes: 11 additions & 1 deletion frontend/src/v2/connectors/ToolPanels.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
? <p className="v2-cx-note" role="note">{t('tools.linearWriteReconnect')}</p>
: 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) => {
Expand All @@ -347,6 +356,7 @@ export const useToolPanels = (m: ToolConnectorsModel) => {
: entryModeCopy(entry)}
</p>
)}
{renderLinearWriteNote(entry)}
{canConnectHosted && entry.connectNotice && <p className="v2-cx-note">{entry.connectNotice}</p>}
{isHosted && entry.available && entry.accessPage && <p className="v2-cx-note">{renderAccessLink(entry)}</p>}
{renderManualRevokeNotice(entry)}
Expand Down Expand Up @@ -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 };
};
Loading