diff --git a/frontend/src/agents/ApiWriteAllowlist.test.tsx b/frontend/src/agents/ApiWriteAllowlist.test.tsx index 2f292b64..b7458e23 100644 --- a/frontend/src/agents/ApiWriteAllowlist.test.tsx +++ b/frontend/src/agents/ApiWriteAllowlist.test.tsx @@ -98,7 +98,7 @@ describe('ApiWriteAllowlist', () => { const labels = Array.from(container.querySelectorAll('label')).map( (l) => l.textContent, ); - expect(labels).toEqual(['Telegram: telegram_send_message']); + expect(labels).toEqual(['Telegram: Telegram send message']); expect( container .querySelector('button[role="checkbox"]')! diff --git a/frontend/src/agents/ApiWriteAllowlist.tsx b/frontend/src/agents/ApiWriteAllowlist.tsx index 97aaaa09..d08d0ff0 100644 --- a/frontend/src/agents/ApiWriteAllowlist.tsx +++ b/frontend/src/agents/ApiWriteAllowlist.tsx @@ -10,6 +10,7 @@ import { SectionHeader } from '../components/ui/section-header'; import { showActionToast } from '../notifications/actionToastSlice'; import { selectToken } from '../preferences/preferenceSlice'; import type { Agent, AgentConfig } from './types'; +import { actionTitle } from '../connectors/i18n'; type WriteAction = { entry: string; @@ -148,7 +149,7 @@ export default function ApiWriteAllowlist({ diff --git a/frontend/src/connectors/ToolPermissions.test.tsx b/frontend/src/connectors/ToolPermissions.test.tsx new file mode 100644 index 00000000..6d02b363 --- /dev/null +++ b/frontend/src/connectors/ToolPermissions.test.tsx @@ -0,0 +1,136 @@ +import { configureStore } from '@reduxjs/toolkit'; +import { act } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { Provider } from 'react-redux'; + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string, opts?: Record) => + opts?.count !== undefined ? `${key}:${opts.count}` : key, + }), +})); + +const connectors = vi.hoisted(() => ({ setToolPermissions: vi.fn() })); +vi.mock('../api/services/connectorsService', () => ({ default: connectors })); + +import actionToastReducer from '../notifications/actionToastSlice'; +import ToolPermissions from './ToolPermissions'; +import type { ConnectionTool } from './types'; + +Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }); + +const action = ( + name: string, + access: 'read' | 'write', + permission: 'always' | 'ask' | 'off', +) => ({ name, access, permission, description: `Does ${name}.` }); + +const tool = (actions: ReturnType[]): ConnectionTool => + ({ + id: 'tool-1', + name: 'mcp_tool', + display_name: 'Linear', + status: true, + credential_mode: 'owner', + actions, + }) as ConnectionTool; + +const MANY_READS = Array.from({ length: 8 }, (_, i) => + action(`get_issue_${i}`, 'read', 'always'), +); + +describe('ToolPermissions', () => { + let container: HTMLDivElement; + let root: Root; + const store = () => + configureStore({ + reducer: { + actionToast: actionToastReducer, + preference: (state = { token: null }) => state, + }, + }); + + beforeEach(() => { + connectors.setToolPermissions.mockReset(); + connectors.setToolPermissions.mockImplementation(async () => ({ + success: true, + tool: tool([]), + })); + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); + }); + + afterEach(async () => { + await act(async () => root.unmount()); + container.remove(); + }); + + const render = async (value: ConnectionTool) => { + const s = store(); + await act(async () => { + root.render( + + + , + ); + }); + }; + + const group = (access: 'read' | 'write') => + container.querySelector(`[data-access="${access}"]`)!; + const choice = (access: 'read' | 'write', permission: string) => + group(access).querySelector( + `[data-permission="${permission}"]`, + )!; + + it('sets every read at once', async () => { + await render(tool([...MANY_READS, action('create_issue', 'write', 'ask')])); + await act(async () => choice('read', 'ask').click()); + expect(connectors.setToolPermissions).toHaveBeenCalledWith( + 'conn-1', + 'tool-1', + Object.fromEntries(MANY_READS.map((a) => [a.name, 'ask'])), + null, + ); + expect(choice('read', 'ask').getAttribute('data-state')).toBe('on'); + // Writes are untouched. + expect(choice('write', 'ask').getAttribute('data-state')).toBe('on'); + }); + + it('shows no group choice when its actions differ', async () => { + await render( + tool([action('a', 'write', 'ask'), action('b', 'write', 'always')]), + ); + for (const permission of ['always', 'ask', 'off']) + expect(choice('write', permission).getAttribute('data-state')).toBe( + 'off', + ); + }); + + it('keeps a long list folded, then lists actions in words with what they do', async () => { + await render(tool(MANY_READS)); + expect(container.textContent).not.toContain('Get issue 0'); + const customize = Array.from(container.querySelectorAll('button')).find( + (b) => + b.textContent?.startsWith('settings.connectors.permission.customize'), + )!; + await act(async () => customize.click()); + expect(container.textContent).toContain('Get issue 0'); + expect(container.textContent).toContain('Does get_issue_0.'); + }); + + it('follows the tool when its actions change', async () => { + await render(tool([action('old_one', 'read', 'always')])); + await render(tool([action('new_one', 'read', 'always')])); + expect(container.textContent).toContain('New one'); + expect(container.textContent).not.toContain('Old one'); + }); + + it('says what Ask first means outside chat', async () => { + await render(tool([action('create_issue', 'write', 'ask')])); + expect(container.textContent).toContain( + 'settings.connectors.permission.hint', + ); + }); +}); diff --git a/frontend/src/connectors/ToolPermissions.tsx b/frontend/src/connectors/ToolPermissions.tsx index 6d386837..01c09ac7 100644 --- a/frontend/src/connectors/ToolPermissions.tsx +++ b/frontend/src/connectors/ToolPermissions.tsx @@ -1,8 +1,9 @@ -import { useState } from 'react'; +import { useEffect, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { useDispatch, useSelector } from 'react-redux'; import connectorsService from '../api/services/connectorsService'; +import { Button } from '../components/ui/button'; import { Card } from '../components/ui/card'; import { SectionHeader } from '../components/ui/section-header'; import { @@ -12,8 +13,11 @@ import { SelectTrigger, SelectValue, } from '../components/ui/select'; +import { ToggleGroup, ToggleGroupItem } from '../components/ui/toggle-group'; import { showActionToast } from '../notifications/actionToastSlice'; import { selectToken } from '../preferences/preferenceSlice'; +import { formatCount } from '../utils/dateTimeUtils'; +import { actionTitle } from './i18n'; import type { ActionPermission, ConnectionTool, @@ -22,10 +26,14 @@ import type { const PERMISSIONS: ActionPermission[] = ['always', 'ask', 'off']; +/** Longer groups start folded to their one group choice. */ +const FOLD_AFTER = 5; + /** - * One tool's actions, grouped Read and Write, each with Always allow / - * Needs approval / Off. Changes save immediately; a failed save puts the - * previous choice back and says so. + * One tool's actions in two groups, what it looks up and what it does, each + * set at once to Allow / Ask first / Off; single actions can differ under + * Customize. Changes save immediately; a failed save puts the previous + * choices back and says so. */ export default function ToolPermissions({ connectionId, @@ -45,15 +53,21 @@ export default function ToolPermissions({ const dispatch = useDispatch(); const token = useSelector(selectToken); const [actions, setActions] = useState(tool.actions); + const [unfolded, setUnfolded] = useState>({}); + // A refreshed tool brings its new list of actions. + useEffect(() => setActions(tool.actions), [tool.actions]); - const setPermission = (name: string, permission: ActionPermission) => { + const setPermissions = (changes: Record) => { const previous = actions; - const next = actions.map((action) => - action.name === name ? { ...action, permission } : action, + setActions( + actions.map((action) => + changes[action.name] + ? { ...action, permission: changes[action.name] } + : action, + ), ); - setActions(next); connectorsService - .setToolPermissions(connectionId, tool.id, { [name]: permission }, token) + .setToolPermissions(connectionId, tool.id, changes, token) .then((data) => { if (!data?.success) throw new Error('save failed'); onChange?.(data.tool); @@ -78,54 +92,147 @@ export default function ToolPermissions({ return ( - {groups.map((group) => ( -
- -
    - {group.actions.map((action) => ( -
  • - - {action.name} - - -
  • - ))} -
-
- ))} +
+

+ {actionTitle(action.name)} +

+ {action.description && ( +

+ {action.description} +

+ )} +
+ + + ))} + + )} + + ); + })} +

+ {t('settings.connectors.permission.hint')} +

); } diff --git a/frontend/src/connectors/i18n.ts b/frontend/src/connectors/i18n.ts index a1cf2f52..057a8178 100644 --- a/frontend/src/connectors/i18n.ts +++ b/frontend/src/connectors/i18n.ts @@ -17,6 +17,12 @@ export const connectorDescription = ( defaultValue: connector.description, }); +/** An action's name in words: `get_triage_responsibility` → "Get triage responsibility". */ +export const actionTitle = (name: string) => { + const words = name.replace(/[_-]+/g, ' ').trim(); + return words.charAt(0).toUpperCase() + words.slice(1); +}; + /** The connector's name; only the two custom kinds have translated names. */ export const connectorName = ( t: TFunction,