diff --git a/frontend/src/settings/Tools.test.tsx b/frontend/src/settings/Tools.test.tsx index 2a484cc1..dc9180f9 100644 --- a/frontend/src/settings/Tools.test.tsx +++ b/frontend/src/settings/Tools.test.tsx @@ -102,6 +102,16 @@ vi.mock('../api/services/connectorsService', () => ({ }, })); +// The connector panel itself is tested on its own; here it only has to open +// for the right service. +vi.mock('../connectors/ConnectionDrawer', () => ({ + default: ({ connector }: { connector: { key: string } | null }) => + connector ?
{connector.key}
: null, +})); +vi.mock('../connectors/useConnectorLauncher', () => ({ + default: () => ({ launch: vi.fn(), modals: null }), +})); + import connectorsReducer from '../connectors/connectorsSlice'; import notificationsReducer from '../notifications/notificationsSlice'; import Tools from './Tools'; @@ -169,7 +179,12 @@ describe('Tools page', () => { document.body.querySelectorAll('[role="menuitem"]'), ).find((item) => item.textContent?.includes(text)); - it('sends a connected tool to its connector page to manage it', async () => { + const card = (title: string) => + Array.from(container.querySelectorAll('h2')) + .find((h) => h.textContent === title)! + .closest('[data-slot="card"]')!; + + it('opens the connection panel right here to manage a connected tool', async () => { await render(); await openMenu(0); expect(menuItem('settings.tools.edit')).toBeUndefined(); @@ -177,30 +192,26 @@ describe('Tools page', () => { menuItem('settings.connectors.manageConnection')!.click(), ); expect( - document.body.querySelector('[data-testid="where"]')?.textContent, - ).toBe('/settings/connectors?connector=telegram'); + document.body.querySelector('[data-testid="drawer"]')?.textContent, + ).toBe('telegram'); + // Stays on the Tools page. + expect(document.body.querySelector('[data-testid="where"]')).toBeNull(); }); - const card = (title: string) => - Array.from(container.querySelectorAll('h2')) - .find((h) => h.textContent === title)! - .closest('[data-slot="card"]')!; - - it('manages a connected tool on its connector page, not with its own switch', async () => { + it('shows each connected account as its own tool with its own switch', async () => { await render(); const telegram = card('Telegram'); - expect(telegram.querySelector('[role="switch"]')).toBeNull(); - // The catalog's plain description, not the tool's setup notes. + expect(telegram.querySelector('[role="switch"]')).not.toBeNull(); + // Which account this card is, and the catalog's plain description. + expect(telegram.textContent).toContain('…abcd'); expect(telegram.textContent).toContain( 'settings.connectors.descriptions.telegram', ); - const manage = Array.from(telegram.querySelectorAll('button')).find( - (b) => b.textContent === 'settings.connectors.manageConnection', - )!; - await act(async () => manage.click()); expect( - document.body.querySelector('[data-testid="where"]')?.textContent, - ).toBe('/settings/connectors?connector=telegram'); + Array.from(telegram.querySelectorAll('button')).some( + (b) => b.textContent === 'settings.connectors.manageConnection', + ), + ).toBe(false); }); it('says a connected tool needs signing in again, with no raw MCP reconnect', async () => { diff --git a/frontend/src/settings/Tools.tsx b/frontend/src/settings/Tools.tsx index 16410868..82e38d50 100644 --- a/frontend/src/settings/Tools.tsx +++ b/frontend/src/settings/Tools.tsx @@ -21,6 +21,7 @@ import { import { Switch } from '../components/ui/switch'; import { ActionMenu, type MenuOption } from '../components/ui/dropdown-menu'; import { EmptyState } from '../components/ui/empty-state'; +import ConnectionDrawer from '../connectors/ConnectionDrawer'; import ConnectorIcon from '../connectors/ConnectorIcon'; import { connectionNeedsSignIn, @@ -29,6 +30,8 @@ import { selectConnectorCatalog, } from '../connectors/connectorsSlice'; import { connectorDescription } from '../connectors/i18n'; +import type { Connection, ConnectorDefinition } from '../connectors/types'; +import useConnectorLauncher from '../connectors/useConnectorLauncher'; import { useLoaderState } from '../hooks'; import type { AvailableToolType } from '../modals/types'; import AddToolModal from '../modals/AddToolModal'; @@ -72,6 +75,10 @@ export default function Tools() { const [toolToShare, setToolToShare] = React.useState( null, ); + // The connection panel, opened here for a connected tool's service. + const [managed, setManaged] = React.useState( + null, + ); const [mcpStatuses, setMcpStatuses] = React.useState<{ [toolId: string]: string; }>({}); @@ -254,14 +261,29 @@ export default function Tools() { const handleSettingsClick = (tool: UserToolType) => { const connection = connectionOf(tool); if (connection) { - navigate( - `/settings/connectors?connector=${encodeURIComponent(connection.connector_key)}`, - ); + const connector = catalog.find((c) => c.key === connection.connector_key); + if (connector) setManaged(connector); + else + navigate( + `/settings/connectors?connector=${encodeURIComponent(connection.connector_key)}`, + ); return; } setSelectedTool(tool); }; + const { launch, modals } = useConnectorLauncher({ + onConnected: () => getUserTools(), + }); + // What tells two accounts of one service apart on their cards. + const accountLine = (connection: Connection) => + connection.auth_kind === 'api_key' + ? t('settings.connectors.detail.keyEnding', { + hint: connection.account_label, + interpolation: { escapeValue: false }, + }) + : connection.account_label; + const handleGoBack = () => { setSelectedTool(null); getUserTools(); @@ -447,15 +469,11 @@ export default function Tools() { {t('settings.tools.builtIn')} )} - {connectionNeedsSignIn(connection) ? ( + {connectionNeedsSignIn(connection) && ( {t('settings.connectors.health.signInAgain')} - ) : !tool.status ? ( - - {t('settings.tools.off')} - - ) : null} + )} {tool.name === 'mcp_tool' && !connection && mcpStatuses[tool.id] && ( @@ -509,37 +527,36 @@ export default function Tools() { {connection && ( - // The way to the connection's page, marked with - // the service it comes from. + // Which account this is: each account of a + // service is its own tool. mr-12 keeps the line + // clear of the switch. - + + + + {accountLine(connection)} + + )} - {/* A connected tool is turned on and off on its - connection's page, the one home for it. */} - {!connection && ( -
- - updateToolStatus(tool.id, checked) - } - id={`toolToggle-${index}`} - aria-label={t('settings.tools.toggleToolAria', { - toolName: tool.customName || tool.displayName, - })} - /> -
- )} +
+ + updateToolStatus(tool.id, checked) + } + id={`toolToggle-${index}`} + aria-label={t('settings.tools.toggleToolAria', { + toolName: tool.customName || tool.displayName, + })} + /> +
); })} @@ -577,6 +594,19 @@ export default function Tools() { fetchMcpStatuses(); }} /> + { + setManaged(null); + getUserTools(); + dispatch(loadConnectors({ token })); + }} + onConnect={(connector, options) => { + setManaged(null); + launch(connector, options); + }} + /> + {modals} {toolToShare && (