diff --git a/frontend/src/app/pages/Tools/CustomToolCard.tsx b/frontend/src/app/pages/Tools/CustomToolCard.tsx new file mode 100644 index 00000000..bd097ca4 --- /dev/null +++ b/frontend/src/app/pages/Tools/CustomToolCard.tsx @@ -0,0 +1,495 @@ +import React from 'react'; +import Box from '@mui/material/Box'; +import Typography from '@mui/material/Typography'; +import Button from '@mui/material/Button'; +import Card from '@mui/material/Card'; +import CardContent from '@mui/material/CardContent'; +import Chip from '@mui/material/Chip'; +import CircularProgress from '@mui/material/CircularProgress'; +import Tooltip from '@mui/material/Tooltip'; +import Collapse from '@mui/material/Collapse'; +import Switch from '@mui/material/Switch'; +import IconButton from '@mui/material/IconButton'; +import EditIcon from '@mui/icons-material/Edit'; +import DeleteIcon from '@mui/icons-material/Delete'; +import TerminalIcon from '@mui/icons-material/Terminal'; +import ExtensionIcon from '@mui/icons-material/Extension'; +import SearchIcon from '@mui/icons-material/Search'; +import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown'; +import OpenInNewIcon from '@mui/icons-material/OpenInNew'; +import LinkIcon from '@mui/icons-material/Link'; +import CheckCircleIcon from '@mui/icons-material/CheckCircle'; +import SettingsIcon from '@mui/icons-material/Settings'; +import BlockIcon from '@mui/icons-material/Block'; +import VisibilityIcon from '@mui/icons-material/Visibility'; +import SecurityIcon from '@mui/icons-material/Security'; +import PanToolIcon from '@mui/icons-material/PanTool'; +import RefreshIcon from '@mui/icons-material/Refresh'; +import { ToolDefinition } from '@/shared/state/toolsSlice'; +import { useClaudeTokens } from '@/shared/styles/ThemeContext'; +import { Integration } from './integrations'; + +interface CustomToolCardProps { + tool: ToolDefinition; + ig: Integration | undefined; + isExpanded: boolean; + onToggleExpand: (toolId: string, isExpanded: boolean) => void; + expandedServices: Record; + setExpandedServices: React.Dispatch>>; + expandedSchema: string | null; + setExpandedSchema: React.Dispatch>; + devMode: boolean; + integrationLoading: Record; + discovering: boolean; + onPermissionChange: (toolId: string, toolName: string, policy: string) => void; + onGroupPermissionChange: (toolId: string, names: string[], policy: string) => void; + onBulkReadOnly: (toolId: string) => void; + onResetPermissions: (toolId: string) => void; + onDiscover: (toolId: string) => void; + onIntegrationToggle: (integration: Integration) => void; + onOAuthConnect: (toolId: string) => void; + onDeviceCodeConnect: (toolId: string) => void; + onM365Disconnect: (toolId: string) => void; + onDisconnectIntegration: (toolId: string, integration: Integration) => void; + onOpenCredentialsDialog: (toolId: string, integration: Integration) => void; + onEdit: (tool: ToolDefinition) => void; + onDelete: (toolId: string) => void; +} + +const CustomToolCard: React.FC = ({ + tool, ig, isExpanded, onToggleExpand, + expandedServices, setExpandedServices, expandedSchema, setExpandedSchema, + devMode, integrationLoading, discovering, + onPermissionChange: handlePermissionChange, + onGroupPermissionChange: handleGroupPermissionChange, + onBulkReadOnly: handleBulkReadOnly, + onResetPermissions: handleResetPermissions, + onDiscover: handleDiscover, + onIntegrationToggle: handleIntegrationToggle, + onOAuthConnect: handleOAuthConnect, + onDeviceCodeConnect: handleDeviceCodeConnect, + onM365Disconnect: handleM365Disconnect, + onDisconnectIntegration: handleDisconnectIntegration, + onOpenCredentialsDialog: openCredentialsDialog, + onEdit: openEdit, + onDelete: handleDelete, +}) => { + const c = useClaudeTokens(); + + const isMcp = tool.mcp_config && Object.keys(tool.mcp_config).length > 0; + const isStdio = isMcp && (tool.mcp_config.type === 'stdio' || !!tool.mcp_config.command); + const canDiscover = isMcp; + const perms = tool.tool_permissions || {}; + const services = perms._services as Record | undefined; + const descriptions = (perms._tool_descriptions || {}) as Record; + const schemas = (perms._tool_schemas || {}) as Record; + const serviceNames = services ? Object.keys(services) : []; + const hasPerms = serviceNames.length > 0; + const totalToolCount = serviceNames.reduce((acc, s) => acc + (services![s].read?.length || 0) + (services![s].write?.length || 0), 0); + + const toDisplayName = (name: string, serviceName?: string) => { + let display = name.replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase()); + if (serviceName) { + const svcLower = serviceName.toLowerCase(); + const variants = [svcLower, svcLower.replace(/s$/, '')]; + for (const v of variants) { + display = display.replace(new RegExp(`\\b${v}\\b`, 'gi'), '').trim(); + } + display = display.replace(/\s{2,}/g, ' ').trim(); + } + return display; + }; + + const firstSentence = (desc: string) => { + if (!desc) return ''; + const match = desc.match(/^(.+?(?:\.|$))/); + return match ? match[1].trim() : desc.substring(0, 100); + }; + + const getGroupPolicy = (names: string[]) => { + if (names.length === 0) return 'ask'; + const policies = names.map((n) => perms[n] || 'ask'); + if (policies.every((p) => p === 'always_allow')) return 'always_allow'; + if (policies.every((p) => p === 'deny')) return 'deny'; + if (policies.every((p) => p === 'ask')) return 'ask'; + return 'mixed'; + }; + + const PermToggle = ({ value, onChange, size = 16 }: { value: string; onChange: (v: string) => void; size?: number }) => ( + e.stopPropagation()}> + onChange('always_allow')} sx={{ p: 0.4, borderRadius: 1, bgcolor: value === 'always_allow' ? `${c.status.success}20` : 'transparent', color: value === 'always_allow' ? c.status.success : c.text.ghost, '&:hover': { bgcolor: `${c.status.success}15`, color: c.status.success } }}> + onChange('ask')} sx={{ p: 0.4, borderRadius: 1, bgcolor: value === 'ask' ? `${c.status.warning}20` : 'transparent', color: value === 'ask' ? c.status.warning : c.text.ghost, '&:hover': { bgcolor: `${c.status.warning}15`, color: c.status.warning } }}> + onChange('deny')} sx={{ p: 0.4, borderRadius: 1, bgcolor: value === 'deny' ? `${c.status.error}20` : 'transparent', color: value === 'deny' ? c.status.error : c.text.ghost, '&:hover': { bgcolor: `${c.status.error}15`, color: c.status.error } }}> + + ); + + const ServiceGroup = ({ serviceName, data, isFirstGroup }: { serviceName: string; data: { read?: string[]; write?: string[] }; isFirstGroup?: boolean }) => { + const svcKey = `${tool.id}:${serviceName}`; + const isOpen = expandedServices[svcKey] ?? false; + const allNames = [...(data.read || []), ...(data.write || [])]; + const svcPolicy = getGroupPolicy(allNames); + const count = allNames.length; + const isReddit = + ig?.id === 'reddit' || + tool.name?.toLowerCase() === 'reddit' || + (tool.command || '').toLowerCase().includes('reddit'); + const isYoutube = + ig?.id === 'youtube' || + tool.name?.toLowerCase() === 'youtube' || + (tool.command || '').toLowerCase().includes('youtube'); + const isSubredditsForReddit = + isReddit && /subreddit/i.test(serviceName); + // YouTube marker lands on the first service group since YouTube has no drill-down. + const showPermissionMarker = + isSubredditsForReddit || (isYoutube && isFirstGroup); + + return ( + + setExpandedServices((p) => ({ ...p, [svcKey]: !isOpen }))} + > + + + {serviceName} + + + + handleGroupPermissionChange(tool.id, allNames, v)} /> + + + + + {(data.read?.length || 0) > 0 && ( + + + + + Read-only + + + handleGroupPermissionChange(tool.id, data.read!, v)} size={14} /> + + {data.read!.map((name) => { + const schemaKey = `${tool.id}:${name}`; + const schema = schemas[name]; + const schemaProps = schema?.properties as Record | undefined; + const schemaRequired = (schema?.required || []) as string[]; + return ( + + devMode && schema && setExpandedSchema((p) => p === schemaKey ? null : schemaKey)}> + + {toDisplayName(name, serviceName)} + {descriptions[name] && {firstSentence(descriptions[name])}} + + handlePermissionChange(tool.id, name, v)} size={14} /> + + {devMode && expandedSchema === schemaKey && schemaProps && ( + + Input Parameters + {Object.entries(schemaProps).map(([pName, pDef]: [string, any]) => ( + + {pName} + {pDef?.type || 'any'} + {schemaRequired.includes(pName) && } + {pDef?.description && {pDef.description}} + + ))} + + )} + + ); + })} + + )} + {(data.write?.length || 0) > 0 && ( + + + + + Write / delete + + + handleGroupPermissionChange(tool.id, data.write!, v)} size={14} /> + + {data.write!.map((name) => { + const schemaKey = `${tool.id}:${name}`; + const schema = schemas[name]; + const schemaProps = schema?.properties as Record | undefined; + const schemaRequired = (schema?.required || []) as string[]; + return ( + + devMode && schema && setExpandedSchema((p) => p === schemaKey ? null : schemaKey)}> + + {toDisplayName(name, serviceName)} + {descriptions[name] && {firstSentence(descriptions[name])}} + + handlePermissionChange(tool.id, name, v)} size={14} /> + + {devMode && expandedSchema === schemaKey && schemaProps && ( + + Input Parameters + {Object.entries(schemaProps).map(([pName, pDef]: [string, any]) => ( + + {pName} + {pDef?.type || 'any'} + {schemaRequired.includes(pName) && } + {pDef?.description && {pDef.description}} + + ))} + + )} + + ); + })} + + )} + + + + ); + }; + + const isDisabled = tool.enabled === false; + + // Defensive Reddit detection so onboarding hooks still attach when ig.id lookup fails (legacy/manual installs). + const isReddit = + ig?.id === 'reddit' || + tool.name?.toLowerCase() === 'reddit' || + (tool.command || '').toLowerCase().includes('reddit'); + const isYoutube = + ig?.id === 'youtube' || + tool.name?.toLowerCase() === 'youtube' || + (tool.command || '').toLowerCase().includes('youtube'); + return ( + + + !isDisabled && onToggleExpand(tool.id, isExpanded)} + > + {ig && ( + + {ig.icon} + + )} + + + {tool.name} + {isMcp && } label={isStdio ? 'MCP · stdio' : 'MCP'} size="small" sx={{ bgcolor: `${c.status.warning}20`, color: c.status.warning, fontSize: '0.75rem', height: 24 }} />} + {tool.command && } label={`/${tool.command}`} size="small" sx={{ bgcolor: 'rgba(174,86,48,0.12)', color: c.accent.hover, fontSize: '0.72rem', height: 22 }} />} + {tool.auth_status === 'connected' && !ig && ( + } label={tool.connected_account_email ? `Connected · ${tool.connected_account_email}` : 'Connected'} size="small" sx={{ bgcolor: c.status.successBg, color: c.status.success, fontSize: '0.7rem', height: 20, '& .MuiChip-icon': { color: c.status.success } }} /> + )} + {tool.auth_status === 'configured' && !ig?.credentialFields && ( + } label="Configured" size="small" sx={{ bgcolor: c.status.warningBg, color: c.status.warning, fontSize: '0.7rem', height: 20, '& .MuiChip-icon': { color: c.status.warning } }} /> + )} + {ig && totalToolCount > 0 && ( + + )} + {ig && ( + } label="docs" size="small" sx={{ bgcolor: c.bg.secondary, color: c.text.ghost, fontSize: '0.65rem', height: 18, '& .MuiChip-label': { px: 0.4 }, '& .MuiChip-icon': { ml: 0.4, fontSize: 10 } }} /> + )} + + {tool.description && {tool.description}} + + {!isDisabled && (tool.auth_type === 'oauth2' || ig?.authType === 'oauth2') && (tool.auth_status !== 'connected' || ig?.id === 'discord') && ( + + )} + {!isDisabled && ig?.authType === 'device_code' && tool.auth_status !== 'connected' && ( + + )} + {!isDisabled && ig?.credentialFields && tool.auth_status !== 'connected' && ( + + )} + {!isDisabled && ig && tool.auth_status === 'connected' && ( + + } + label={tool.connected_account_email ? `Connected · ${tool.connected_account_email}` : 'Connected'} + size="small" + onDelete={(ig.credentialFields || ig.authType === 'oauth2' || ig.authType === 'device_code') ? (e: React.SyntheticEvent) => { e.stopPropagation(); ig.authType === 'device_code' ? handleM365Disconnect(tool.id) : handleDisconnectIntegration(tool.id, ig); } : undefined} + onClick={(e) => e.stopPropagation()} + sx={{ bgcolor: c.status.successBg, color: c.status.success, fontSize: '0.7rem', height: 22, '& .MuiChip-icon': { color: c.status.success }, '& .MuiChip-deleteIcon': { color: c.status.success, '&:hover': { color: c.status.error } }, flexShrink: 0 }} + /> + + )} + {ig && ( + e.stopPropagation()} + > + {!!integrationLoading[ig.id] && } + handleIntegrationToggle(ig)} + disabled={!!integrationLoading[ig.id]} + sx={{ + '& .MuiSwitch-switchBase.Mui-checked': { color: ig.color }, + '& .MuiSwitch-switchBase.Mui-checked + .MuiSwitch-track': { bgcolor: ig.color }, + }} + /> + + )} + {!isDisabled && ( + + + {!ig && ( + <> + { e.stopPropagation(); openEdit(tool); }} sx={{ color: c.text.ghost, '&:hover': { color: c.accent.primary } }}> + { e.stopPropagation(); handleDelete(tool.id); }} sx={{ color: c.text.ghost, '&:hover': { color: c.status.error } }}> + + )} + + )} + + + + + + + + + Action Permissions + {hasPerms && } + + + {hasPerms && ( + <> + + + + + + + + )} + + handleDiscover(tool.id)} + disabled={discovering || !canDiscover} + sx={{ color: c.text.ghost, '&:hover': { color: c.accent.primary } }} + > + {discovering ? : } + + + + + + {!hasPerms ? ( + + + No actions discovered yet + + {!canDiscover && ( + Add an MCP configuration to enable action discovery + )} + + ) : ( + + {serviceNames.map((svc, idx) => ( + + ))} + + )} + + {devMode && isMcp && ( + + + Developer Info + + + + MCP Config + + + {JSON.stringify(tool.mcp_config, null, 2)} + + + + + Auth type: + {tool.auth_type || 'none'} + + + Status: + {tool.auth_status || 'none'} + + {tool.connected_account_email && ( + + Account: + {tool.connected_account_email} + + )} + + {tool.credentials && Object.keys(tool.credentials).length > 0 && ( + + Credentials: + {Object.keys(tool.credentials).map((key) => ( + + ))} + + )} + + )} + + + + ); +}; + +export default CustomToolCard; diff --git a/frontend/src/app/pages/Tools/IntegrationGalleryCard.tsx b/frontend/src/app/pages/Tools/IntegrationGalleryCard.tsx new file mode 100644 index 00000000..61f3bad5 --- /dev/null +++ b/frontend/src/app/pages/Tools/IntegrationGalleryCard.tsx @@ -0,0 +1,68 @@ +import React from 'react'; +import Box from '@mui/material/Box'; +import Typography from '@mui/material/Typography'; +import Card from '@mui/material/Card'; +import CardContent from '@mui/material/CardContent'; +import Chip from '@mui/material/Chip'; +import CircularProgress from '@mui/material/CircularProgress'; +import Switch from '@mui/material/Switch'; +import OpenInNewIcon from '@mui/icons-material/OpenInNew'; +import { useClaudeTokens } from '@/shared/styles/ThemeContext'; +import { Integration } from './integrations'; + +interface IntegrationGalleryCardProps { + integration: Integration; + isLoading: boolean; + onToggle: (integration: Integration) => void; +} + +const IntegrationGalleryCard: React.FC = ({ integration: ig, isLoading, onToggle: handleIntegrationToggle }) => { + const c = useClaudeTokens(); + return ( + + + + + {ig.icon} + + + + {ig.name} + } label="docs" size="small" sx={{ bgcolor: c.bg.secondary, color: c.text.ghost, fontSize: '0.65rem', height: 18, '& .MuiChip-label': { px: 0.4 }, '& .MuiChip-icon': { ml: 0.4, fontSize: 10 } }} /> + + {ig.description} + + + {isLoading && } + handleIntegrationToggle(ig)} + disabled={isLoading} + sx={{ + '& .MuiSwitch-switchBase.Mui-checked': { color: ig.color }, + '& .MuiSwitch-switchBase.Mui-checked + .MuiSwitch-track': { bgcolor: ig.color }, + }} + /> + + + + + ); +}; + +export default IntegrationGalleryCard; diff --git a/frontend/src/app/pages/Tools/Tools.tsx b/frontend/src/app/pages/Tools/Tools.tsx index 18e618ed..d9398776 100644 --- a/frontend/src/app/pages/Tools/Tools.tsx +++ b/frontend/src/app/pages/Tools/Tools.tsx @@ -2,62 +2,20 @@ import React, { useEffect, useState, useMemo, useCallback, useRef } from 'react' import Box from '@mui/material/Box'; import Typography from '@mui/material/Typography'; import Button from '@mui/material/Button'; -import Card from '@mui/material/Card'; -import CardContent from '@mui/material/CardContent'; -import Dialog from '@mui/material/Dialog'; -import DialogTitle from '@mui/material/DialogTitle'; -import DialogContent from '@mui/material/DialogContent'; -import DialogActions from '@mui/material/DialogActions'; -import TextField from '@mui/material/TextField'; import MenuItem from '@mui/material/MenuItem'; -import Select from '@mui/material/Select'; -import FormControl from '@mui/material/FormControl'; -import InputLabel from '@mui/material/InputLabel'; -import IconButton from '@mui/material/IconButton'; import Chip from '@mui/material/Chip'; -import CircularProgress from '@mui/material/CircularProgress'; -import Tooltip from '@mui/material/Tooltip'; import Collapse from '@mui/material/Collapse'; import Menu from '@mui/material/Menu'; import Snackbar from '@mui/material/Snackbar'; import Alert from '@mui/material/Alert'; -import InputAdornment from '@mui/material/InputAdornment'; -import Avatar from '@mui/material/Avatar'; -import Switch from '@mui/material/Switch'; import AddIcon from '@mui/icons-material/Add'; -import EditIcon from '@mui/icons-material/Edit'; -import DeleteIcon from '@mui/icons-material/Delete'; -import TerminalIcon from '@mui/icons-material/Terminal'; import BuildIcon from '@mui/icons-material/Build'; -import ExtensionIcon from '@mui/icons-material/Extension'; -import DescriptionIcon from '@mui/icons-material/Description'; -import SearchIcon from '@mui/icons-material/Search'; -import QuestionAnswerIcon from '@mui/icons-material/QuestionAnswer'; import LockIcon from '@mui/icons-material/Lock'; import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown'; import KeyboardArrowRightIcon from '@mui/icons-material/KeyboardArrowRight'; -import ScheduleIcon from '@mui/icons-material/Schedule'; -import MapIcon from '@mui/icons-material/Map'; import HourglassEmptyIcon from '@mui/icons-material/HourglassEmpty'; import StorefrontIcon from '@mui/icons-material/Storefront'; -import OpenInNewIcon from '@mui/icons-material/OpenInNew'; -import DownloadIcon from '@mui/icons-material/Download'; -import StarIcon from '@mui/icons-material/Star'; -import SortIcon from '@mui/icons-material/Sort'; -import CloudIcon from '@mui/icons-material/Cloud'; -import PublicIcon from '@mui/icons-material/Public'; -import ToggleButton from '@mui/material/ToggleButton'; -import ToggleButtonGroup from '@mui/material/ToggleButtonGroup'; import { useAppDispatch, useAppSelector } from '@/shared/hooks'; -import LinkIcon from '@mui/icons-material/Link'; -import CheckCircleIcon from '@mui/icons-material/CheckCircle'; -import SettingsIcon from '@mui/icons-material/Settings'; -import BlockIcon from '@mui/icons-material/Block'; -import VisibilityIcon from '@mui/icons-material/Visibility'; -import SecurityIcon from '@mui/icons-material/Security'; -import PanToolIcon from '@mui/icons-material/PanTool'; -import CallSplitIcon from '@mui/icons-material/CallSplit'; -import RefreshIcon from '@mui/icons-material/Refresh'; import { fetchTools, fetchBuiltinTools, @@ -87,11 +45,13 @@ import { Skeleton } from '@/app/components/Loading'; import { useClaudeTokens } from '@/shared/styles/ThemeContext'; import { API_BASE } from '@/shared/config'; import { Integration, INTEGRATIONS } from './integrations'; -import { CATEGORY_ORDER, ToolForm, emptyForm, cleanServerName, serverToToolForm, serverToMcpConfig } from './toolsHelpers'; +import { CATEGORY_ORDER, ToolForm, emptyForm, serverToToolForm, serverToMcpConfig } from './toolsHelpers'; import ToolSection from './ToolSection'; import BrowserPermissionCard from './BrowserPermissionCard'; import RegistryBrowserDialog from './RegistryBrowserDialog'; import ToolDialogs from './ToolDialogs'; +import CustomToolCard from './CustomToolCard'; +import IntegrationGalleryCard from './IntegrationGalleryCard'; const Tools: React.FC = () => { const c = useClaudeTokens(); @@ -761,472 +721,43 @@ const Tools: React.FC = () => { ) : ( - {uninstalledIntegrations.map((ig) => { - const isLoading = !!integrationLoading[ig.id]; - return ( - - - - - {ig.icon} - - - - {ig.name} - } label="docs" size="small" sx={{ bgcolor: c.bg.secondary, color: c.text.ghost, fontSize: '0.65rem', height: 18, '& .MuiChip-label': { px: 0.4 }, '& .MuiChip-icon': { ml: 0.4, fontSize: 10 } }} /> - - {ig.description} - - - {isLoading && } - handleIntegrationToggle(ig)} - disabled={isLoading} - sx={{ - '& .MuiSwitch-switchBase.Mui-checked': { color: ig.color }, - '& .MuiSwitch-switchBase.Mui-checked + .MuiSwitch-track': { bgcolor: ig.color }, - }} - /> - - - - - ); - })} - {tools.map((tool) => { - const ig = getIntegrationForTool(tool); - const isExpanded = expandedToolId === tool.id; - const isMcp = tool.mcp_config && Object.keys(tool.mcp_config).length > 0; - const isStdio = isMcp && (tool.mcp_config.type === 'stdio' || !!tool.mcp_config.command); - const canDiscover = isMcp; - const perms = tool.tool_permissions || {}; - const services = perms._services as Record | undefined; - const descriptions = (perms._tool_descriptions || {}) as Record; - const schemas = (perms._tool_schemas || {}) as Record; - const serviceNames = services ? Object.keys(services) : []; - const hasPerms = serviceNames.length > 0; - const totalToolCount = serviceNames.reduce((acc, s) => acc + (services![s].read?.length || 0) + (services![s].write?.length || 0), 0); - - const toDisplayName = (name: string, serviceName?: string) => { - let display = name.replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase()); - if (serviceName) { - const svcLower = serviceName.toLowerCase(); - const variants = [svcLower, svcLower.replace(/s$/, '')]; - for (const v of variants) { - display = display.replace(new RegExp(`\\b${v}\\b`, 'gi'), '').trim(); - } - display = display.replace(/\s{2,}/g, ' ').trim(); - } - return display; - }; - - const firstSentence = (desc: string) => { - if (!desc) return ''; - const match = desc.match(/^(.+?(?:\.|$))/); - return match ? match[1].trim() : desc.substring(0, 100); - }; - - const getGroupPolicy = (names: string[]) => { - if (names.length === 0) return 'ask'; - const policies = names.map((n) => perms[n] || 'ask'); - if (policies.every((p) => p === 'always_allow')) return 'always_allow'; - if (policies.every((p) => p === 'deny')) return 'deny'; - if (policies.every((p) => p === 'ask')) return 'ask'; - return 'mixed'; - }; - - const PermToggle = ({ value, onChange, size = 16 }: { value: string; onChange: (v: string) => void; size?: number }) => ( - e.stopPropagation()}> - onChange('always_allow')} sx={{ p: 0.4, borderRadius: 1, bgcolor: value === 'always_allow' ? `${c.status.success}20` : 'transparent', color: value === 'always_allow' ? c.status.success : c.text.ghost, '&:hover': { bgcolor: `${c.status.success}15`, color: c.status.success } }}> - onChange('ask')} sx={{ p: 0.4, borderRadius: 1, bgcolor: value === 'ask' ? `${c.status.warning}20` : 'transparent', color: value === 'ask' ? c.status.warning : c.text.ghost, '&:hover': { bgcolor: `${c.status.warning}15`, color: c.status.warning } }}> - onChange('deny')} sx={{ p: 0.4, borderRadius: 1, bgcolor: value === 'deny' ? `${c.status.error}20` : 'transparent', color: value === 'deny' ? c.status.error : c.text.ghost, '&:hover': { bgcolor: `${c.status.error}15`, color: c.status.error } }}> - - ); - - const ServiceGroup = ({ serviceName, data, isFirstGroup }: { serviceName: string; data: { read?: string[]; write?: string[] }; isFirstGroup?: boolean }) => { - const svcKey = `${tool.id}:${serviceName}`; - const isOpen = expandedServices[svcKey] ?? false; - const allNames = [...(data.read || []), ...(data.write || [])]; - const svcPolicy = getGroupPolicy(allNames); - const count = allNames.length; - const isReddit = - ig?.id === 'reddit' || - tool.name?.toLowerCase() === 'reddit' || - (tool.command || '').toLowerCase().includes('reddit'); - const isYoutube = - ig?.id === 'youtube' || - tool.name?.toLowerCase() === 'youtube' || - (tool.command || '').toLowerCase().includes('youtube'); - const isSubredditsForReddit = - isReddit && /subreddit/i.test(serviceName); - // YouTube marker lands on the first service group since YouTube has no drill-down. - const showPermissionMarker = - isSubredditsForReddit || (isYoutube && isFirstGroup); - - return ( - - setExpandedServices((p) => ({ ...p, [svcKey]: !isOpen }))} - > - - - {serviceName} - - - - handleGroupPermissionChange(tool.id, allNames, v)} /> - - - - - {(data.read?.length || 0) > 0 && ( - - - - - Read-only - - - handleGroupPermissionChange(tool.id, data.read!, v)} size={14} /> - - {data.read!.map((name) => { - const schemaKey = `${tool.id}:${name}`; - const schema = schemas[name]; - const schemaProps = schema?.properties as Record | undefined; - const schemaRequired = (schema?.required || []) as string[]; - return ( - - devMode && schema && setExpandedSchema((p) => p === schemaKey ? null : schemaKey)}> - - {toDisplayName(name, serviceName)} - {descriptions[name] && {firstSentence(descriptions[name])}} - - handlePermissionChange(tool.id, name, v)} size={14} /> - - {devMode && expandedSchema === schemaKey && schemaProps && ( - - Input Parameters - {Object.entries(schemaProps).map(([pName, pDef]: [string, any]) => ( - - {pName} - {pDef?.type || 'any'} - {schemaRequired.includes(pName) && } - {pDef?.description && {pDef.description}} - - ))} - - )} - - ); - })} - - )} - {(data.write?.length || 0) > 0 && ( - - - - - Write / delete - - - handleGroupPermissionChange(tool.id, data.write!, v)} size={14} /> - - {data.write!.map((name) => { - const schemaKey = `${tool.id}:${name}`; - const schema = schemas[name]; - const schemaProps = schema?.properties as Record | undefined; - const schemaRequired = (schema?.required || []) as string[]; - return ( - - devMode && schema && setExpandedSchema((p) => p === schemaKey ? null : schemaKey)}> - - {toDisplayName(name, serviceName)} - {descriptions[name] && {firstSentence(descriptions[name])}} - - handlePermissionChange(tool.id, name, v)} size={14} /> - - {devMode && expandedSchema === schemaKey && schemaProps && ( - - Input Parameters - {Object.entries(schemaProps).map(([pName, pDef]: [string, any]) => ( - - {pName} - {pDef?.type || 'any'} - {schemaRequired.includes(pName) && } - {pDef?.description && {pDef.description}} - - ))} - - )} - - ); - })} - - )} - - - - ); - }; - - const isDisabled = tool.enabled === false; - - // Defensive Reddit detection so onboarding hooks still attach when ig.id lookup fails (legacy/manual installs). - const isReddit = - ig?.id === 'reddit' || - tool.name?.toLowerCase() === 'reddit' || - (tool.command || '').toLowerCase().includes('reddit'); - const isYoutube = - ig?.id === 'youtube' || - tool.name?.toLowerCase() === 'youtube' || - (tool.command || '').toLowerCase().includes('youtube'); - return ( - - - !isDisabled && setExpandedToolId(isExpanded ? null : tool.id)} - > - {ig && ( - - {ig.icon} - - )} - - - {tool.name} - {isMcp && } label={isStdio ? 'MCP · stdio' : 'MCP'} size="small" sx={{ bgcolor: `${c.status.warning}20`, color: c.status.warning, fontSize: '0.75rem', height: 24 }} />} - {tool.command && } label={`/${tool.command}`} size="small" sx={{ bgcolor: 'rgba(174,86,48,0.12)', color: c.accent.hover, fontSize: '0.72rem', height: 22 }} />} - {tool.auth_status === 'connected' && !ig && ( - } label={tool.connected_account_email ? `Connected · ${tool.connected_account_email}` : 'Connected'} size="small" sx={{ bgcolor: c.status.successBg, color: c.status.success, fontSize: '0.7rem', height: 20, '& .MuiChip-icon': { color: c.status.success } }} /> - )} - {tool.auth_status === 'configured' && !ig?.credentialFields && ( - } label="Configured" size="small" sx={{ bgcolor: c.status.warningBg, color: c.status.warning, fontSize: '0.7rem', height: 20, '& .MuiChip-icon': { color: c.status.warning } }} /> - )} - {ig && totalToolCount > 0 && ( - - )} - {ig && ( - } label="docs" size="small" sx={{ bgcolor: c.bg.secondary, color: c.text.ghost, fontSize: '0.65rem', height: 18, '& .MuiChip-label': { px: 0.4 }, '& .MuiChip-icon': { ml: 0.4, fontSize: 10 } }} /> - )} - - {tool.description && {tool.description}} - - {!isDisabled && (tool.auth_type === 'oauth2' || ig?.authType === 'oauth2') && (tool.auth_status !== 'connected' || ig?.id === 'discord') && ( - - )} - {!isDisabled && ig?.authType === 'device_code' && tool.auth_status !== 'connected' && ( - - )} - {!isDisabled && ig?.credentialFields && tool.auth_status !== 'connected' && ( - - )} - {!isDisabled && ig && tool.auth_status === 'connected' && ( - - } - label={tool.connected_account_email ? `Connected · ${tool.connected_account_email}` : 'Connected'} - size="small" - onDelete={(ig.credentialFields || ig.authType === 'oauth2' || ig.authType === 'device_code') ? (e: React.SyntheticEvent) => { e.stopPropagation(); ig.authType === 'device_code' ? handleM365Disconnect(tool.id) : handleDisconnectIntegration(tool.id, ig); } : undefined} - onClick={(e) => e.stopPropagation()} - sx={{ bgcolor: c.status.successBg, color: c.status.success, fontSize: '0.7rem', height: 22, '& .MuiChip-icon': { color: c.status.success }, '& .MuiChip-deleteIcon': { color: c.status.success, '&:hover': { color: c.status.error } }, flexShrink: 0 }} - /> - - )} - {ig && ( - e.stopPropagation()} - > - {!!integrationLoading[ig.id] && } - handleIntegrationToggle(ig)} - disabled={!!integrationLoading[ig.id]} - sx={{ - '& .MuiSwitch-switchBase.Mui-checked': { color: ig.color }, - '& .MuiSwitch-switchBase.Mui-checked + .MuiSwitch-track': { bgcolor: ig.color }, - }} - /> - - )} - {!isDisabled && ( - - - {!ig && ( - <> - { e.stopPropagation(); openEdit(tool); }} sx={{ color: c.text.ghost, '&:hover': { color: c.accent.primary } }}> - { e.stopPropagation(); handleDelete(tool.id); }} sx={{ color: c.text.ghost, '&:hover': { color: c.status.error } }}> - - )} - - )} - - - - - - - - - Action Permissions - {hasPerms && } - - - {hasPerms && ( - <> - - - - - - - - )} - - handleDiscover(tool.id)} - disabled={discovering || !canDiscover} - sx={{ color: c.text.ghost, '&:hover': { color: c.accent.primary } }} - > - {discovering ? : } - - - - - - {!hasPerms ? ( - - - No actions discovered yet - - {!canDiscover && ( - Add an MCP configuration to enable action discovery - )} - - ) : ( - - {serviceNames.map((svc, idx) => ( - - ))} - - )} - - {devMode && isMcp && ( - - - Developer Info - - - - MCP Config - - - {JSON.stringify(tool.mcp_config, null, 2)} - - - - - Auth type: - {tool.auth_type || 'none'} - - - Status: - {tool.auth_status || 'none'} - - {tool.connected_account_email && ( - - Account: - {tool.connected_account_email} - - )} - - {tool.credentials && Object.keys(tool.credentials).length > 0 && ( - - Credentials: - {Object.keys(tool.credentials).map((key) => ( - - ))} - - )} - - )} - - - - ); - })} + {uninstalledIntegrations.map((ig) => ( + + ))} + {tools.map((tool) => ( + setExpandedToolId(wasExpanded ? null : toolId)} + expandedServices={expandedServices} + setExpandedServices={setExpandedServices} + expandedSchema={expandedSchema} + setExpandedSchema={setExpandedSchema} + devMode={devMode} + integrationLoading={integrationLoading} + discovering={discovering} + onPermissionChange={handlePermissionChange} + onGroupPermissionChange={handleGroupPermissionChange} + onBulkReadOnly={handleBulkReadOnly} + onResetPermissions={handleResetPermissions} + onDiscover={handleDiscover} + onIntegrationToggle={handleIntegrationToggle} + onOAuthConnect={handleOAuthConnect} + onDeviceCodeConnect={handleDeviceCodeConnect} + onM365Disconnect={handleM365Disconnect} + onDisconnectIntegration={handleDisconnectIntegration} + onOpenCredentialsDialog={openCredentialsDialog} + onEdit={openEdit} + onDelete={handleDelete} + /> + ))} )}