Drop the In my chats switch and Manage connection from cards

Tool cards no longer carry the In my chats switch; the chat's Tools
picker (and a connection's drawer) still sets it, for the same tools. The
Manage connection item goes from tool and source menus, with the
in-place connection drawer on Tools: connections are managed on the
Connectors page. The owner's connected tool keeps Share and Delete; a
teammate's still opens the tool editor.
This commit is contained in:
arc53-machine committed 2026-09-29 18:33:21 +01:00
1 parent 371e6d7a17
commit c356ad18a9
12 files changed
+92 -268

No files matched your search

+5 -4
View File
@@ -647,10 +647,11 @@ description use a Switch in a SettingRow instead.
### Switch, TimePicker and Calendar (`ui/switch.tsx`, `ui/time-picker.tsx`, `ui/calendar.tsx`)
`Switch` (Radix) is an on/off setting, placed inside a `SettingRow` that names
it. A tile's own on/off (the tool tile's "In my chats") is the one exception:
a `Label text-muted-foreground text-xs font-normal` + `Switch` pair at the
tile's bottom-right. For a shared tool it is the caller's own preference,
never a switch that turns the tool off for everyone. The track is `primary` when on and `bg-input` when off, with a white
it. A tool row's own on/off in a connection's drawer ("In my chats") is the
one exception: a `Label text-muted-foreground text-xs font-normal` + `Switch`
pair at the row's end. It is the caller's own preference, never a switch that
turns the tool off for everyone. The Tools page's tiles carry no switch; the
chat's Tools picker is where a tool goes in or out of the caller's chats. The track is `primary` when on and `bg-input` when off, with a white
thumb in both themes (see Elevation).
`TimePicker` picks a time of day as two `SelectTrigger`s, hours and minutes,
-1
View File
@@ -1598,7 +1598,6 @@
"read": "Schlägt nach",
"write": "Führt Aktionen aus"
},
"manageConnection": "Verbindung verwalten",
"github": {
"tokenHint": "Erstelle ein feingranulares Token mit Lesezugriff auf „Contents“ und „Metadata“ für die Repositorys, die du nutzen möchtest. Damit Agenten Änderungen vornehmen können, gib ihm außerdem Lese- und Schreibzugriff auf „Issues“ und „Pull requests“.",
"writes": "Agenten auch Änderungen erlauben (Issues, Kommentare, Pull Requests)",
-1
View File
@@ -1604,7 +1604,6 @@
"read": "Looks things up",
"write": "Takes actions"
},
"manageConnection": "Manage connection",
"github": {
"tokenHint": "Create a fine-grained token with read access to Contents and Metadata on the repositories you want to use. For agents to make changes, also give it read and write access to Issues and Pull requests.",
"writes": "Also let agents make changes (issues, comments, pull requests)",
-1
View File
@@ -1598,7 +1598,6 @@
"read": "Consulta datos",
"write": "Realiza acciones"
},
"manageConnection": "Gestionar conexión",
"github": {
"tokenHint": "Crea un token detallado con acceso de lectura a Contents y Metadata en los repositorios que quieras usar. Para que los agentes hagan cambios, dale también acceso de lectura y escritura a Issues y Pull requests.",
"writes": "Permitir también que los agentes hagan cambios (issues, comentarios, pull requests)",
-1
View File
@@ -1588,7 +1588,6 @@
"read": "情報を調べる",
"write": "操作を行う"
},
"manageConnection": "接続を管理",
"github": {
"tokenHint": "使用するリポジトリに対して Contents と Metadata の読み取り権限を持つ fine-grained トークンを作成してください。エージェントに変更を許可する場合は、Issues と Pull requests の読み取りと書き込みの権限も付与してください。",
"writes": "エージェントによる変更も許可する(Issue、コメント、プルリクエスト)",
-1
View File
@@ -1680,7 +1680,6 @@
"read": "Ищет информацию",
"write": "Выполняет действия"
},
"manageConnection": "Управлять подключением",
"github": {
"tokenHint": "Создайте детализированный токен с доступом на чтение к Contents и Metadata для нужных репозиториев. Чтобы агенты могли вносить изменения, дайте ему также доступ на чтение и запись к Issues и Pull requests.",
"writes": "Разрешить агентам также вносить изменения (задачи, комментарии, пул-реквесты)",
-1
View File
@@ -1588,7 +1588,6 @@
"read": "查詢資訊",
"write": "執行操作"
},
"manageConnection": "管理連線",
"github": {
"tokenHint": "為要使用的儲存庫建立細部權杖,並授予 Contents 和 Metadata 的讀取權限。如要讓代理進行變更,還需授予 Issues 和 Pull requests 的讀寫權限。",
"writes": "也允許代理進行變更(議題、留言、提取要求)",
-1
View File
@@ -1588,7 +1588,6 @@
"read": "查找信息",
"write": "执行操作"
},
"manageConnection": "管理连接",
"github": {
"tokenHint": "为要使用的仓库创建一个细粒度令牌,并授予 Contents 和 Metadata 的读取权限。如需让智能体进行更改,还需授予 Issues 和 Pull requests 的读写权限。",
"writes": "也允许智能体进行更改(议题、评论、拉取请求)",
+30 -2
View File
@@ -2,7 +2,8 @@ import { act, useState } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import { MemoryRouter } from 'react-router-dom';
const { dispatch, service, view } = vi.hoisted(() => ({
const { dispatch, service, view, connectors } = vi.hoisted(() => ({
connectors: { connections: [] as Record<string, unknown>[] },
// The heavy children: each view reports the canEdit it was given.
view:
(testId: string) =>
@@ -31,7 +32,7 @@ vi.mock('react-redux', () => ({
preference: { token: null },
upload: { tasks: [] },
graphBuild: { builds: {} },
connectors: { connections: [], loaded: true },
connectors: { connections: connectors.connections, loaded: true },
}),
}));
@@ -170,6 +171,33 @@ describe('Sources access', () => {
]);
});
// The connection is managed on the Connectors page; a synced source's
// menu keeps only what acts on the source itself.
it('a synced source has no Manage connection item', async () => {
connectors.connections = [
{
id: 'conn-1',
connector_key: 'google_drive',
name: 'Google Drive',
icon: 'drive',
status: 'connected',
account_label: 'alex@example.com',
},
];
await render(
doc({
access: 'owner',
allowed_actions: [...OWNER, 'use', 'view_config'],
connectionId: 'conn-1',
} as Partial<Doc>),
);
const items = await menuItems();
connectors.connections = [];
expect(items).not.toContain('settings.connectors.manageConnection');
expect(items).toContain('settings.sources.editConfig');
expect(items).toContain('convTile.delete');
});
it('a source with no access fields is the caller’s own', async () => {
await render(doc());
const items = await menuItems();
-18
View File
@@ -5,7 +5,6 @@ import {
Eye,
HardDrive,
Network,
Plug,
RefreshCw,
Search,
SlidersHorizontal,
@@ -374,23 +373,6 @@ export default function Sources({
},
];
// Synced from a connection: its account, sync and other uses live on
// the connector's page.
const connection = document.connectionId
? connections.find((c) => c.id === document.connectionId)
: undefined;
if (connection) {
actions.push({
icon: Plug,
label: t('settings.connectors.manageConnection'),
onClick: () =>
navigate(
`/settings/connectors?connector=${encodeURIComponent(connection.connector_key)}`,
),
variant: 'default',
});
}
if (canEdit && document.ingestStatus === 'failed') {
actions.push({
icon: RefreshCw,
+28 -95
View File
@@ -78,24 +78,6 @@ vi.mock('../teams/ShareToTeamModal', () => ({
}));
vi.mock('../api/services/devicesService', () => ({ default: {} }));
// 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,
initialConnectionId,
}: {
connector: { key: string } | null;
initialConnectionId?: string;
}) =>
connector ? (
<div data-testid="drawer">{`${connector.key}:${initialConnectionId}`}</div>
) : null,
}));
vi.mock('../connectors/useConnectorLauncher', () => ({
default: () => ({ launch: vi.fn(), modals: null }),
}));
const getUserTools = vi.fn();
const updateToolStatus = vi.fn();
vi.mock('../api/services/userService', () => ({
@@ -216,8 +198,6 @@ describe('Tools', () => {
Array.from(card(id).querySelectorAll('[data-testid="menu"] button')).map(
(b) => b.textContent,
);
const switchOf = (id: string) =>
card(id).querySelector<HTMLButtonElement>('[role="switch"]')!;
it('shows Edit, Reconnect, Share and Delete to the owner', async () => {
await render([ownTool]);
@@ -279,26 +259,13 @@ describe('Tools', () => {
});
});
it('labels the switch "In my chats" and binds it to in_chat', async () => {
await render([ownTool, editorTool]);
const sw = switchOf('ed');
expect(sw.getAttribute('aria-checked')).toBe('false');
expect(switchOf('own').getAttribute('aria-checked')).toBe('true');
const label = card('ed').querySelector<HTMLLabelElement>(
`label[for="${sw.id}"]`,
);
expect(label?.textContent).toBe('settings.tools.inMyChats');
expect(sw.getAttribute('aria-label')).toBe(
'settings.tools.useInMyChatsAria:{"toolName":"ed"}',
);
});
// The tool can't be in the caller's chats at all (the composer picker
// hides it too), so there is no state to show: no switch, no bare grey one.
it('hides the switch and its label for a shared tool without use_in_own', async () => {
await render([viewerTool]);
expect(card('vw').querySelector('[role="switch"]')).toBeNull();
expect(card('vw').textContent).not.toContain('settings.tools.inMyChats');
// Whether a tool is in the caller's own chats is set from the chat's
// Tools picker; the page's cards carry no switch for it.
it('puts no In my chats switch on any card', async () => {
await render([ownTool, editorTool, viewerTool]);
expect(container.querySelector('[role="switch"]')).toBeNull();
expect(container.textContent).not.toContain('settings.tools.inMyChats');
expect(updateToolStatus).not.toHaveBeenCalled();
});
it('shows the role on a shared tile as a neutral Users Badge', async () => {
@@ -313,34 +280,6 @@ describe('Tools', () => {
badge('ed')?.querySelector('svg')?.getAttribute('class'),
).not.toContain('size-3');
});
it('reverts the switch and shows an error toast when the update fails', async () => {
await render([editorTool]);
updateToolStatus.mockImplementation(() =>
jsonResponse({ success: false, message: 'Forbidden' }, false, 403),
);
await act(async () => switchOf('ed').click());
expect(updateToolStatus).toHaveBeenCalledWith(
{ id: 'ed', status: true },
'token',
);
expect(switchOf('ed').getAttribute('aria-checked')).toBe('false');
expect(dispatch).toHaveBeenCalledWith(
expect.objectContaining({
payload: expect.objectContaining({ variant: 'destructive' }),
}),
);
});
it('keeps the new value when the update succeeds', async () => {
await render([editorTool]);
// Mounting loads the connectors; only a toast would come after it.
dispatch.mockClear();
updateToolStatus.mockImplementation(() => jsonResponse({ success: true }));
await act(async () => switchOf('ed').click());
expect(switchOf('ed').getAttribute('aria-checked')).toBe('true');
expect(dispatch).not.toHaveBeenCalled();
});
});
describe('Tools page connections', () => {
@@ -445,36 +384,31 @@ describe('Tools page connections', () => {
),
).find((b) => b.textContent === label);
it('opens the connection panel right here to manage a connected tool', async () => {
// A connected tool is managed on the Connectors page; its card keeps
// what acts on the tool itself.
it('gives the owner of a connected tool Share and Delete, no connection item', async () => {
await renderTools(root);
expect(menuItem('Telegram', 'settings.tools.edit')).toBeUndefined();
await act(async () =>
menuItem('Telegram', 'settings.connectors.manageConnection')!.click(),
);
expect(
document.body.querySelector('[data-testid="drawer"]')?.textContent,
).toBe('telegram:conn-1');
// Stays on the Tools page.
expect(document.body.querySelector('[data-testid="where"]')).toBeNull();
Array.from(
card('Telegram').querySelectorAll('[data-testid="menu"] button'),
).map((b) => b.textContent),
).toEqual(['settings.tools.shareWithTeam', 'settings.tools.delete']);
expect(
menuItem('Telegram', 'settings.connectors.manageConnection'),
).toBeUndefined();
});
it('shows each connected account as its own tool with its own switch', async () => {
it('shows each connected account as its own tool, the account on its footer', async () => {
await renderTools(root);
const telegram = card('Telegram');
expect(telegram.querySelector('[role="switch"]')).not.toBeNull();
expect(telegram.querySelector('[role="switch"]')).toBeNull();
// Which account this card is, and the catalog's plain description.
expect(telegram.textContent).toContain('Alerts bot');
expect(
telegram.querySelector('[data-slot="card-footer"]')?.textContent,
).toBe('Alerts bot');
expect(telegram.textContent).toContain(
'settings.connectors.descriptions.telegram',
);
// Managing the connection lives in the menu, not on the card.
expect(
Array.from(telegram.querySelectorAll('button')).some(
(b) =>
!b.closest('[data-testid="menu"]') &&
b.textContent === 'settings.connectors.manageConnection',
),
).toBe(false);
});
it('says a connected tool needs signing in again, with no raw MCP reconnect', async () => {
@@ -489,13 +423,13 @@ describe('Tools page connections', () => {
it('keeps Edit for a tool that is not from a connection', async () => {
await renderTools(root);
expect(menuItem('My API', 'settings.tools.edit')).toBeDefined();
// No account line and nothing else for the footer: no footer.
expect(
menuItem('My API', 'settings.connectors.manageConnection'),
).toBeUndefined();
expect(card('My API').querySelector('[role="switch"]')).not.toBeNull();
card('My API').querySelector('[data-slot="card-footer"]'),
).toBeNull();
});
it('gives a viewer of a connected tool View, not Manage connection', async () => {
it("gives a viewer of a teammate's connected tool View", async () => {
getUserTools.mockImplementation(() =>
jsonResponse({
tools: [
@@ -513,9 +447,6 @@ describe('Tools page connections', () => {
);
await renderTools(root);
expect(menuItem('Telegram (shared)', 'settings.tools.view')).toBeDefined();
expect(
menuItem('Telegram (shared)', 'settings.connectors.manageConnection'),
).toBeUndefined();
expect(
menuItem('Telegram (shared)', 'settings.tools.reconnect'),
).toBeUndefined();
@@ -541,6 +472,8 @@ describe('Tools page connections', () => {
expect(
menuItem('Linear (mine)', 'settings.tools.reconnect'),
).toBeUndefined();
// Nor the tool editor: the owner manages it on the Connectors page.
expect(menuItem('Linear (mine)', 'settings.tools.edit')).toBeUndefined();
});
it("shows an editor who may share the owner's account, locked, with the write confirmation", async () => {
+29 -142
View File
@@ -1,4 +1,4 @@
import { Eye, Pencil, Plug, RefreshCw, Trash2, Users } from 'lucide-react';
import { Eye, Pencil, RefreshCw, Trash2, Users } from 'lucide-react';
import React from 'react';
import { useTranslation } from 'react-i18next';
import { useDispatch, useSelector } from 'react-redux';
@@ -19,11 +19,8 @@ import {
CardFooter,
CardTitle,
} from '../components/ui/card';
import { Label } from '../components/ui/label';
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,
@@ -32,9 +29,8 @@ import {
selectConnectorCatalog,
} from '../connectors/connectorsSlice';
import { connectorDescription } from '../connectors/i18n';
import type { Connection, ConnectorDefinition } from '../connectors/types';
import type { Connection } from '../connectors/types';
import { toolServiceOf } from '../connectors/toolService';
import useConnectorLauncher from '../connectors/useConnectorLauncher';
import { useLoaderState } from '../hooks';
import type { AvailableToolType } from '../modals/types';
import AddToolModal from '../modals/AddToolModal';
@@ -48,11 +44,7 @@ import ShareToTeamModal, {
type ShareCredentials,
} from '../teams/ShareToTeamModal';
import { can, isOwner, roleOf } from '../utils/accessUtils';
import {
canAddToolToOwn,
isSharedOAuthMcp,
toolInChat,
} from '../utils/toolUtils';
import { isSharedOAuthMcp } from '../utils/toolUtils';
import RemoteDeviceConfig from './RemoteDeviceConfig';
import ToolConfig from './ToolConfig';
import { APIToolType, UserToolType } from './types';
@@ -85,11 +77,6 @@ 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<{
connector: ConnectorDefinition;
connectionId: string;
} | null>(null);
const [mcpStatuses, setMcpStatuses] = React.useState<{
[toolId: string]: string;
}>({});
@@ -187,31 +174,26 @@ export default function Tools() {
const getMenuOptions = (tool: UserToolType): MenuOption[] => {
const canEdit = can(tool, 'edit') || can(tool, 'edit_credentials');
// Only the caller's own connections are loaded, so a connection here
// means the caller owns it and manages it in place.
const connection = connectionOf(tool);
const options: MenuOption[] = [
connection
? {
icon: Plug,
label: t('settings.connectors.manageConnection'),
onClick: () => handleSettingsClick(tool),
variant: 'default',
}
: canEdit
const options: MenuOption[] = [];
// The owner's connected tool (its connection is theirs) is managed on
// the Connectors page, so it has no editor here; a teammate's opens the
// tool editor like any other shared tool.
if (!(tool.connection_id && isOwner(tool)))
options.push(
canEdit
? {
icon: Pencil,
label: t('settings.tools.edit'),
onClick: () => handleSettingsClick(tool),
onClick: () => setSelectedTool(tool),
variant: 'default',
}
: {
icon: Eye,
label: t('settings.tools.view'),
onClick: () => handleSettingsClick(tool),
onClick: () => setSelectedTool(tool),
variant: 'default',
},
];
);
// A connected server reconnects on its connector page, like any other
// connection; only an MCP tool without one keeps the server form. The
// tool's own connection id decides, for everyone: a teammate never sees
@@ -286,63 +268,12 @@ export default function Tools() {
});
};
const setToolInChat = (toolId: string, value: boolean) =>
setUserTools((prevTools) =>
prevTools.map((tool) =>
tool.id !== toolId
? tool
: isOwner(tool)
? { ...tool, status: value, in_chat: value }
: { ...tool, in_chat: value },
),
);
// The switch moves at once and flips back when the server refuses it.
const updateToolStatus = (toolId: string, newStatus: boolean) => {
setToolInChat(toolId, newStatus);
const fail = () => {
setToolInChat(toolId, !newStatus);
dispatch(
showActionToast({
variant: 'destructive',
message: t('settings.tools.statusUpdateFailed'),
}),
);
};
userService
.updateToolStatus({ id: toolId, status: newStatus }, token)
.then((response: Response) => {
if (!response.ok) fail();
})
.catch((error: unknown) => {
console.error('Failed to update tool status:', error);
fail();
});
};
// A connected tool is managed on its connector's page (account, on/off,
// permissions); only other tools open the tool editor here.
// The caller's own connection behind a connected tool (only their own
// connections are loaded): the card names its account.
const connectionOf = (tool: UserToolType) =>
tool.connection_id
? connections.find((c) => c.id === tool.connection_id)
: undefined;
const handleSettingsClick = (tool: UserToolType) => {
const connection = connectionOf(tool);
if (connection) {
const connector = catalog.find((c) => c.key === connection.connector_key);
if (connector) setManaged({ connector, connectionId: connection.id });
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: the name
// the owner gave it, else what identifies it.
const accountLine = (connection: Connection) =>
@@ -597,52 +528,22 @@ export default function Tools() {
</CardDescription>
</div>
</div>
{/* Which account this is (each account of a
service is its own tool) and the caller's own
"In my chats" switch share the meta row. A
shared tool without use_in_own can't be in the
caller's chats at all, so it has no switch. */}
{(connection || canAddToolToOwn(tool)) && (
{/* Which account this is: each account of a
service is its own tool. */}
{connection && (
<CardFooter>
{connection && (
<span className="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 className="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>
)}
{canAddToolToOwn(tool) && (
<span className="ml-auto flex shrink-0 items-center gap-2">
<Label
htmlFor={`toolToggle-${index}`}
className="text-muted-foreground text-xs font-normal"
>
{t('settings.tools.inMyChats')}
</Label>
<Switch
checked={toolInChat(tool)}
onCheckedChange={(checked) =>
updateToolStatus(tool.id, checked)
}
id={`toolToggle-${index}`}
aria-label={t(
'settings.tools.useInMyChatsAria',
{
interpolation: { escapeValue: false },
toolName:
tool.customName || tool.displayName,
},
)}
/>
</span>
)}
</span>
</CardFooter>
)}
</Card>
@@ -683,20 +584,6 @@ export default function Tools() {
fetchMcpStatuses();
}}
/>
<ConnectionDrawer
connector={managed?.connector ?? null}
initialConnectionId={managed?.connectionId}
onClose={() => {
setManaged(null);
getUserTools();
dispatch(loadConnectors({ token }));
}}
onConnect={(connector, options) => {
setManaged(null);
launch(connector, options);
}}
/>
{modals}
{toolToShare && (
<ShareToTeamModal
resourceType="tool"