From c356ad18a94c9c0446b74057660223704bfc3c46 Mon Sep 17 00:00:00 2001 From: arc53-machine <232052973+arc53-machine@users.noreply.github.com> Date: Tue, 29 Sep 2026 18:33:21 +0100 Subject: [PATCH] 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. --- frontend/DESIGN.md | 9 +- frontend/src/locale/de.json | 1 - frontend/src/locale/en.json | 1 - frontend/src/locale/es.json | 1 - frontend/src/locale/jp.json | 1 - frontend/src/locale/ru.json | 1 - frontend/src/locale/zh-TW.json | 1 - frontend/src/locale/zh.json | 1 - frontend/src/settings/Sources.test.tsx | 32 ++++- frontend/src/settings/Sources.tsx | 18 --- frontend/src/settings/Tools.test.tsx | 123 ++++-------------- frontend/src/settings/Tools.tsx | 171 +++++-------------------- 12 files changed, 92 insertions(+), 268 deletions(-) diff --git a/frontend/DESIGN.md b/frontend/DESIGN.md index a26fe87a..0ea49eb7 100644 --- a/frontend/DESIGN.md +++ b/frontend/DESIGN.md @@ -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, diff --git a/frontend/src/locale/de.json b/frontend/src/locale/de.json index 5718f930..9f40585f 100644 --- a/frontend/src/locale/de.json +++ b/frontend/src/locale/de.json @@ -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)", diff --git a/frontend/src/locale/en.json b/frontend/src/locale/en.json index 7f2a1d9c..b2bac081 100644 --- a/frontend/src/locale/en.json +++ b/frontend/src/locale/en.json @@ -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)", diff --git a/frontend/src/locale/es.json b/frontend/src/locale/es.json index d1d8bc8c..6f0f19fe 100644 --- a/frontend/src/locale/es.json +++ b/frontend/src/locale/es.json @@ -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)", diff --git a/frontend/src/locale/jp.json b/frontend/src/locale/jp.json index 68becf28..48805126 100644 --- a/frontend/src/locale/jp.json +++ b/frontend/src/locale/jp.json @@ -1588,7 +1588,6 @@ "read": "情報を調べる", "write": "操作を行う" }, - "manageConnection": "接続を管理", "github": { "tokenHint": "使用するリポジトリに対して Contents と Metadata の読み取り権限を持つ fine-grained トークンを作成してください。エージェントに変更を許可する場合は、Issues と Pull requests の読み取りと書き込みの権限も付与してください。", "writes": "エージェントによる変更も許可する(Issue、コメント、プルリクエスト)", diff --git a/frontend/src/locale/ru.json b/frontend/src/locale/ru.json index c0f599ec..7250030f 100644 --- a/frontend/src/locale/ru.json +++ b/frontend/src/locale/ru.json @@ -1680,7 +1680,6 @@ "read": "Ищет информацию", "write": "Выполняет действия" }, - "manageConnection": "Управлять подключением", "github": { "tokenHint": "Создайте детализированный токен с доступом на чтение к Contents и Metadata для нужных репозиториев. Чтобы агенты могли вносить изменения, дайте ему также доступ на чтение и запись к Issues и Pull requests.", "writes": "Разрешить агентам также вносить изменения (задачи, комментарии, пул-реквесты)", diff --git a/frontend/src/locale/zh-TW.json b/frontend/src/locale/zh-TW.json index 6c4d0241..5b08b384 100644 --- a/frontend/src/locale/zh-TW.json +++ b/frontend/src/locale/zh-TW.json @@ -1588,7 +1588,6 @@ "read": "查詢資訊", "write": "執行操作" }, - "manageConnection": "管理連線", "github": { "tokenHint": "為要使用的儲存庫建立細部權杖,並授予 Contents 和 Metadata 的讀取權限。如要讓代理進行變更,還需授予 Issues 和 Pull requests 的讀寫權限。", "writes": "也允許代理進行變更(議題、留言、提取要求)", diff --git a/frontend/src/locale/zh.json b/frontend/src/locale/zh.json index 457c7338..cc01c6ce 100644 --- a/frontend/src/locale/zh.json +++ b/frontend/src/locale/zh.json @@ -1588,7 +1588,6 @@ "read": "查找信息", "write": "执行操作" }, - "manageConnection": "管理连接", "github": { "tokenHint": "为要使用的仓库创建一个细粒度令牌,并授予 Contents 和 Metadata 的读取权限。如需让智能体进行更改,还需授予 Issues 和 Pull requests 的读写权限。", "writes": "也允许智能体进行更改(议题、评论、拉取请求)", diff --git a/frontend/src/settings/Sources.test.tsx b/frontend/src/settings/Sources.test.tsx index fb4a771b..20b4208f 100644 --- a/frontend/src/settings/Sources.test.tsx +++ b/frontend/src/settings/Sources.test.tsx @@ -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[] }, // 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), + ); + 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(); diff --git a/frontend/src/settings/Sources.tsx b/frontend/src/settings/Sources.tsx index a5486e9b..88914bd7 100644 --- a/frontend/src/settings/Sources.tsx +++ b/frontend/src/settings/Sources.tsx @@ -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, diff --git a/frontend/src/settings/Tools.test.tsx b/frontend/src/settings/Tools.test.tsx index 78eb61d9..9deeff06 100644 --- a/frontend/src/settings/Tools.test.tsx +++ b/frontend/src/settings/Tools.test.tsx @@ -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 ? ( -
{`${connector.key}:${initialConnectionId}`}
- ) : 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('[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( - `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 () => { diff --git a/frontend/src/settings/Tools.tsx b/frontend/src/settings/Tools.tsx index ae5be48d..46c78bbf 100644 --- a/frontend/src/settings/Tools.tsx +++ b/frontend/src/settings/Tools.tsx @@ -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( 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() { - {/* 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 && ( - {connection && ( - - - - {accountLine(connection)} - + + + + {accountLine(connection)} - )} - {canAddToolToOwn(tool) && ( - - - - updateToolStatus(tool.id, checked) - } - id={`toolToggle-${index}`} - aria-label={t( - 'settings.tools.useInMyChatsAria', - { - interpolation: { escapeValue: false }, - toolName: - tool.customName || tool.displayName, - }, - )} - /> - - )} + )} @@ -683,20 +584,6 @@ export default function Tools() { fetchMcpStatuses(); }} /> - { - setManaged(null); - getUserTools(); - dispatch(loadConnectors({ token })); - }} - onConnect={(connector, options) => { - setManaged(null); - launch(connector, options); - }} - /> - {modals} {toolToShare && (