Show each connected account as its own tool, managed in place

A connected tool's card has its switch again and names its account, so
two accounts of one service are two tools you can turn on and off. Manage
connection in its menu opens the connection panel on the Tools page
instead of leaving for Connectors.
This commit is contained in:
arc53-machine committed 2026-09-29 10:05:41 +01:00
1 parent bacab4cfe7
commit f4397a7a65
2 files changed
+95 -54

No files matched your search

+28 -17
View File
@@ -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 ? <div data-testid="drawer">{connector.key}</div> : 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<HTMLElement>('[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<HTMLElement>('h2'))
.find((h) => h.textContent === title)!
.closest<HTMLElement>('[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<HTMLElement>('h2'))
.find((h) => h.textContent === title)!
.closest<HTMLElement>('[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 () => {
+67 -37
View File
@@ -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<UserToolType | null>(
null,
);
// The connection panel, opened here for a connected tool's service.
const [managed, setManaged] = React.useState<ConnectorDefinition | null>(
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')}
</Badge>
)}
{connectionNeedsSignIn(connection) ? (
{connectionNeedsSignIn(connection) && (
<Badge variant="warning">
{t('settings.connectors.health.signInAgain')}
</Badge>
) : !tool.status ? (
<Badge variant="neutral">
{t('settings.tools.off')}
</Badge>
) : null}
)}
{tool.name === 'mcp_tool' &&
!connection &&
mcpStatuses[tool.id] && (
@@ -509,37 +527,36 @@ export default function Tools() {
</div>
</div>
{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.
<CardFooter>
<Button
type="button"
variant="outline"
size="sm"
shape="pill"
onClick={() => handleSettingsClick(tool)}
>
<ConnectorIcon icon={connection.icon} />
{t('settings.connectors.manageConnection')}
</Button>
<span className="mr-12 flex min-w-0 items-center gap-2">
<ConnectorIcon
icon={connection.icon}
className="size-3.5 shrink-0"
/>
<span
className="truncate"
title={accountLine(connection)}
>
{accountLine(connection)}
</span>
</span>
</CardFooter>
)}
{/* A connected tool is turned on and off on its
connection's page, the one home for it. */}
{!connection && (
<div className="absolute right-4 bottom-4">
<Switch
checked={tool.status}
onCheckedChange={(checked) =>
updateToolStatus(tool.id, checked)
}
id={`toolToggle-${index}`}
aria-label={t('settings.tools.toggleToolAria', {
toolName: tool.customName || tool.displayName,
})}
/>
</div>
)}
<div className="absolute right-4 bottom-4">
<Switch
checked={tool.status}
onCheckedChange={(checked) =>
updateToolStatus(tool.id, checked)
}
id={`toolToggle-${index}`}
aria-label={t('settings.tools.toggleToolAria', {
toolName: tool.customName || tool.displayName,
})}
/>
</div>
</Card>
);
})}
@@ -577,6 +594,19 @@ export default function Tools() {
fetchMcpStatuses();
}}
/>
<ConnectionDrawer
connector={managed}
onClose={() => {
setManaged(null);
getUserTools();
dispatch(loadConnectors({ token }));
}}
onConnect={(connector, options) => {
setManaged(null);
launch(connector, options);
}}
/>
{modals}
{toolToShare && (
<ShareToTeamModal
resourceType="tool"