[eric] v1.0.20: add common MCP integrations, mcp OAuth, one-click connect UX, onboarding tools step, ui/ux improvements

This commit is contained in:
ciregenz
2026-03-29 16:33:36 -07:00
parent b75c6f8fe2
commit 0616b0ecbc
78 changed files with 11565 additions and 185 deletions
+154 -5
View File
@@ -1,9 +1,22 @@
import React, { useState, useEffect, useRef } from 'react';
import { Box, Typography, Modal, Button, CircularProgress } from '@mui/material';
import LinkIcon from '@mui/icons-material/Link';
import CheckCircleIcon from '@mui/icons-material/CheckCircle';
import { useAppSelector } from '@/shared/hooks';
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
import { API_BASE } from '@/shared/config';
const ONBOARDING_TOOL_INTEGRATIONS = [
{ name: 'Google Workspace', desc: 'Gmail, Calendar, Drive, Docs, Sheets', color: '#4285F4', oauthProvider: 'google',
mcp_config: { type: 'stdio', command: 'uvx', args: ['--from', 'google-workspace-mcp', 'google-workspace-worker'] } },
{ name: 'GitHub', desc: 'Repos, issues, pull requests', color: '#24292E', oauthProvider: 'github',
mcp_config: { type: 'stdio', command: 'npx', args: ['-y', '@modelcontextprotocol/server-github'] } },
{ name: 'Slack', desc: 'Channels, messages, search', color: '#4A154B', oauthProvider: 'slack',
mcp_config: { type: 'stdio', command: 'npx', args: ['-y', '@modelcontextprotocol/server-slack'] } },
{ name: 'Notion', desc: 'Pages, databases, search', color: '#000000', oauthProvider: 'notion',
mcp_config: { type: 'stdio', command: 'npx', args: ['-y', '@notionhq/notion-mcp-server'] } },
];
const SUBSCRIPTION_PROVIDERS = [
{ id: 'claude', name: 'Claude', desc: 'Sonnet, Opus, Haiku', color: '#E8927A', preview: false },
{ id: 'gemini-cli', name: 'Gemini', desc: 'Gemini 2.5 Pro & Flash', color: '#4285F4', preview: true },
@@ -15,8 +28,10 @@ const OnboardingModal: React.FC = () => {
const c = useClaudeTokens();
const settings = useAppSelector((s) => s.settings);
const [open, setOpen] = useState(false);
const [step, setStep] = useState<'provider' | 'tools'>('provider');
const [connecting, setConnecting] = useState<string | null>(null);
const [nineRouterReady, setNineRouterReady] = useState<boolean | null>(null);
const [connectedTools, setConnectedTools] = useState<Set<string>>(new Set());
const pollTimerRef = useRef<any>(null);
const msgHandlerRef = useRef<any>(null);
@@ -121,7 +136,7 @@ const OnboardingModal: React.FC = () => {
if (pd.success) {
clearInterval(timer);
pollTimerRef.current = null;
dismiss();
advanceToTools();
}
} catch {}
}, 5000);
@@ -144,7 +159,7 @@ const OnboardingModal: React.FC = () => {
window.removeEventListener('message', msgHandlerRef.current);
msgHandlerRef.current = null;
}
dismiss();
advanceToTools();
}
} catch {}
}, 2000);
@@ -172,7 +187,7 @@ const OnboardingModal: React.FC = () => {
}),
});
} catch {}
dismiss();
advanceToTools();
}
};
window.addEventListener('message', msgHandler);
@@ -192,8 +207,78 @@ const OnboardingModal: React.FC = () => {
}
};
const handleApiKey = () => dismiss();
const handleSkip = () => dismiss();
const advanceToTools = () => {
if (pollTimerRef.current) { clearInterval(pollTimerRef.current); pollTimerRef.current = null; }
if (msgHandlerRef.current) { window.removeEventListener('message', msgHandlerRef.current); msgHandlerRef.current = null; }
setConnecting(null);
setStep('tools');
};
const handleToolConnect = async (integration: typeof ONBOARDING_TOOL_INTEGRATIONS[0]) => {
setConnecting(integration.name);
try {
// Create the tool
const createRes = await fetch(`${API_BASE}/tools/create`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: integration.name,
description: integration.desc,
mcp_config: integration.mcp_config,
auth_type: 'oauth2',
auth_status: 'configured',
oauth_provider: integration.oauthProvider,
}),
});
if (!createRes.ok) { setConnecting(null); return; }
const { tool } = await createRes.json();
// Start OAuth
const oauthRes = await fetch(`${API_BASE}/tools/${tool.id}/oauth/start`, { method: 'POST' });
if (!oauthRes.ok) { setConnecting(null); return; }
const { auth_url } = await oauthRes.json();
// Open popup
const popup = window.open(auth_url, 'oauth', 'width=500,height=700,left=200,top=100');
// Listen for completion
const onMsg = (event: MessageEvent) => {
if (event.data?.type === 'oauth_complete' && event.data?.tool_id === tool.id) {
window.removeEventListener('message', onMsg);
setConnectedTools((prev) => new Set(prev).add(integration.name));
setConnecting(null);
// Trigger discovery in background
fetch(`${API_BASE}/tools/${tool.id}/discover`, { method: 'POST' }).catch(() => {});
}
};
window.addEventListener('message', onMsg);
// Fallback: poll for popup close
const poller = setInterval(() => {
if (popup && popup.closed) {
clearInterval(poller);
window.removeEventListener('message', onMsg);
// Check if connected
fetch(`${API_BASE}/tools/${tool.id}`)
.then(r => r.json())
.then(data => {
if (data.tool?.auth_status === 'connected') {
setConnectedTools((prev) => new Set(prev).add(integration.name));
fetch(`${API_BASE}/tools/${tool.id}/discover`, { method: 'POST' }).catch(() => {});
}
})
.catch(() => {});
setConnecting(null);
}
}, 1000);
setTimeout(() => { clearInterval(poller); setConnecting(null); }, 60000);
} catch {
setConnecting(null);
}
};
const handleApiKey = () => advanceToTools();
const handleSkip = () => step === 'tools' ? dismiss() : dismiss();
if (!open) return null;
@@ -204,6 +289,68 @@ const OnboardingModal: React.FC = () => {
border: `1px solid ${c.border.subtle}`, p: 3.5, outline: 'none',
boxShadow: '0 20px 60px rgba(0,0,0,0.4)',
}}>
{step === 'tools' ? (
<>
<Typography sx={{ fontSize: '1.3rem', fontWeight: 700, color: c.text.primary, mb: 0.5, textAlign: 'center' }}>
Connect Your Accounts
</Typography>
<Typography sx={{ fontSize: '0.78rem', color: c.text.muted, mb: 0.5, textAlign: 'center' }}>
10+ tools already active with no setup needed
</Typography>
<Typography sx={{ fontSize: '0.68rem', color: c.text.ghost, mb: 3, textAlign: 'center' }}>
Connect services below for even more capabilities
</Typography>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.75, mb: 2.5 }}>
{ONBOARDING_TOOL_INTEGRATIONS.map((ig) => {
const isConnected = connectedTools.has(ig.name);
const isConnecting = connecting === ig.name;
return (
<Box
key={ig.name}
onClick={() => !isConnected && !isConnecting && !connecting && handleToolConnect(ig)}
sx={{
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
p: 1.5, borderRadius: `${c.radius.md}px`,
border: `1px solid ${isConnected ? `${ig.color}40` : c.border.subtle}`,
cursor: isConnected ? 'default' : connecting ? 'wait' : 'pointer',
bgcolor: isConnected ? `${ig.color}08` : 'transparent',
transition: 'border-color 0.15s, background 0.15s',
...(!isConnected && !connecting && { '&:hover': { borderColor: ig.color, bgcolor: `${ig.color}05` } }),
}}
>
<Box>
<Typography sx={{ fontSize: '0.82rem', fontWeight: 600, color: c.text.primary }}>{ig.name}</Typography>
<Typography sx={{ fontSize: '0.65rem', color: c.text.muted }}>{ig.desc}</Typography>
</Box>
{isConnected ? (
<CheckCircleIcon sx={{ fontSize: 18, color: ig.color }} />
) : (
<Typography sx={{ fontSize: '0.68rem', color: isConnecting ? ig.color : c.text.tertiary }}>
{isConnecting ? 'Connecting...' : 'Connect \u2192'}
</Typography>
)}
</Box>
);
})}
</Box>
<Button
onClick={dismiss}
fullWidth
variant={connectedTools.size > 0 ? 'contained' : 'text'}
sx={{
textTransform: 'none', fontSize: '0.78rem', borderRadius: `${c.radius.md}px`,
...(connectedTools.size > 0
? { bgcolor: c.accent.primary, color: '#fff', '&:hover': { bgcolor: c.accent.hover } }
: { color: c.text.ghost, '&:hover': { bgcolor: 'transparent', color: c.text.muted } }),
}}
>
{connectedTools.size > 0 ? 'Done' : 'Skip for now'}
</Button>
</>
) : (
<>
<Typography sx={{ fontSize: '1.3rem', fontWeight: 700, color: c.text.primary, mb: 0.5, textAlign: 'center' }}>
Welcome to OpenSwarm
</Typography>
@@ -268,6 +415,8 @@ const OnboardingModal: React.FC = () => {
>
Skip for now
</Button>
</>
)}
</Box>
</Modal>
);
+57 -4
View File
@@ -190,6 +190,9 @@ interface Props {
autoFocusInput?: boolean;
cardZOrder?: number;
onBringToFront?: (id: string, type: 'agent' | 'view' | 'browser') => void;
isFocused?: boolean;
onFocusRequest?: (sessionId: string) => void;
onFocusExit?: () => void;
}
const MIN_W = 480;
@@ -207,6 +210,7 @@ const AgentCard: React.FC<Props> = ({
session, expanded, cardX, cardY, cardWidth, cardHeight, zoom = 1, spawnFrom, exitTarget,
isSelected = false, isHighlighted = false, multiDragDelta, onCardSelect, onDragStart, onDragMove, onDragEnd,
onBranch, onMeasuredHeight, snapColumn, autoFocusInput, cardZOrder = 0, onBringToFront,
isFocused = false, onFocusRequest, onFocusExit,
}) => {
const c = useClaudeTokens();
const dispatch = useAppDispatch();
@@ -463,6 +467,51 @@ const AgentCard: React.FC<Props> = ({
}
: { opacity: 0, scale: 0.85, transition: { duration: 0.2 } };
if (isFocused) {
// Focus mode: render as a simple box filling its container (outside canvas transform)
return (
<Box
ref={cardBoxRef}
sx={{
width: '100%',
height: '100%',
bgcolor: c.bg.surface,
border: `1px solid ${c.border.strong}`,
borderRadius: 3,
p: 2,
display: 'flex',
flexDirection: 'column',
overflow: 'hidden',
boxShadow: '0 24px 80px rgba(0,0,0,0.5)',
}}
>
{/* Header with close button */}
<Box
onDoubleClick={(e) => { e.stopPropagation(); onFocusExit?.(); }}
sx={{
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
mb: 1, flexShrink: 0, cursor: 'default',
}}
>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, minWidth: 0 }}>
<Typography sx={{ fontSize: '0.95rem', fontWeight: 600, color: c.text.primary, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{session.name || 'Agent'}
</Typography>
<Chip label={session.status} size="small" sx={{ fontSize: '0.7rem', height: 20, bgcolor: session.status === 'running' ? c.status.info : session.status === 'completed' ? c.status.success : c.bg.elevated, color: c.text.secondary }} />
<Typography sx={{ fontSize: '0.75rem', color: c.text.ghost }}>{session.model} · {formatDuration(session.created_at, undefined, session.status)}</Typography>
</Box>
<IconButton size="small" onClick={() => onFocusExit?.()} sx={{ color: c.text.ghost }}>
<CloseIcon sx={{ fontSize: 18 }} />
</IconButton>
</Box>
{/* Chat fills remaining space */}
<Box sx={{ flex: 1, minHeight: 0, overflow: 'hidden' }}>
<AgentChat sessionId={session.id} autoFocus={true} embedded={true} />
</Box>
</Box>
);
}
return (
<motion.div
layout={false}
@@ -481,7 +530,7 @@ const AgentCard: React.FC<Props> = ({
data-select-type="agent-card"
data-select-id={session.id}
data-select-meta={JSON.stringify({ name: session.name || session.id, status: session.status, model: session.model, mode: session.mode })}
onClick={(e: React.MouseEvent) => {
if (justDraggedRef.current) return;
if (!isSelected && !e.shiftKey) {
@@ -642,8 +691,8 @@ const AgentCard: React.FC<Props> = ({
</Box>
)}
{/* Resize handles: 4 edges + 4 corners */}
{HANDLE_DEFS.map(({ dir, sx }) => (
{/* Resize handles: 4 edges + 4 corners (hidden in focus mode) */}
{!isFocused && HANDLE_DEFS.map(({ dir, sx }) => (
<Box
key={dir}
onPointerDown={handleResizeDown(dir)}
@@ -687,6 +736,10 @@ const AgentCard: React.FC<Props> = ({
onPointerDown={handleDragPointerDown}
onPointerMove={handleDragPointerMove}
onPointerUp={handleDragPointerUp}
onDoubleClick={(e) => {
e.stopPropagation();
onFocusRequest?.(session.id);
}}
sx={{
position: 'relative',
zIndex: 16,
@@ -695,7 +748,7 @@ const AgentCard: React.FC<Props> = ({
px: 2,
pt: 2,
pb: 1.5,
cursor: isDragging ? 'grabbing' : 'grab',
cursor: isFocused ? 'default' : isDragging ? 'grabbing' : 'grab',
touchAction: 'none',
userSelect: 'none',
flexShrink: 0,
+113 -3
View File
@@ -117,6 +117,7 @@ const DashboardInner: React.FC = () => {
const toolbarRef = useRef<HTMLDivElement>(null);
const [toolbarOpen, setToolbarOpen] = useState(false);
const [focusedCardId, setFocusedCardId] = useState<string | null>(null);
const [highlightedCardId, setHighlightedCardId] = useState<string | null>(null);
const highlightTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const [autoFocusSessionId, setAutoFocusSessionId] = useState<string | null>(null);
@@ -531,6 +532,66 @@ const DashboardInner: React.FC = () => {
return () => window.removeEventListener('keydown', handleEnter);
}, [selection.selectedIds, dispatch]);
// Focus mode: pop a card out as full-viewport overlay
const handleFocusRequest = useCallback((sessionId: string) => {
// Auto-expand if collapsed
if (!expandedSessionIds.includes(sessionId)) {
dispatch(toggleExpandSession(sessionId));
}
setFocusedCardId(sessionId);
}, [expandedSessionIds, dispatch]);
const handleFocusExit = useCallback(() => {
setFocusedCardId(null);
}, []);
// Focus mode keyboard: Escape to exit, F to enter
useEffect(() => {
const handleFocusKeys = (e: KeyboardEvent) => {
const tag = (e.target as HTMLElement)?.tagName;
if (tag === 'INPUT' || tag === 'TEXTAREA' || (e.target as HTMLElement)?.isContentEditable) return;
if (e.key === 'Escape' && focusedCardId) {
e.preventDefault();
setFocusedCardId(null);
return;
}
if ((e.key === 'f' || e.key === 'F') && !e.ctrlKey && !e.metaKey && !focusedCardId) {
if (selection.selectedIds.size !== 1) return;
const [id, type] = selection.selectedIds.entries().next().value!;
if (type !== 'agent') return;
e.preventDefault();
handleFocusRequest(id);
}
};
window.addEventListener('keydown', handleFocusKeys);
return () => window.removeEventListener('keydown', handleFocusKeys);
}, [focusedCardId, selection.selectedIds, handleFocusRequest]);
// Auto-zoom to card when it gets expanded (not on initial load)
const prevExpandedRef = useRef<string[]>([]);
useEffect(() => {
if (!layoutInitialized) {
prevExpandedRef.current = expandedSessionIds;
return;
}
const prev = new Set(prevExpandedRef.current);
const newlyExpanded = expandedSessionIds.filter((id) => !prev.has(id));
prevExpandedRef.current = expandedSessionIds;
// Only auto-zoom for single-card expansions (not bulk restore)
if (newlyExpanded.length !== 1) return;
const cardId = newlyExpanded[0];
const card = cards[cardId];
if (!card) return;
setTimeout(() => {
const height = Math.max(EXPANDED_CARD_MIN_H, measuredHeightsRef.current[cardId] || card.height);
canvas.actions.fitToCards([{ x: card.x, y: card.y, width: card.width, height }], 2.0, true);
}, 200);
}, [expandedSessionIds, layoutInitialized, cards, canvas.actions]);
useEffect(() => {
const handleDelete = (e: KeyboardEvent) => {
if (e.key !== 'Backspace' && e.key !== 'Delete') return;
@@ -1303,6 +1364,8 @@ const DashboardInner: React.FC = () => {
{Object.values(cards).map((card) => {
const session = sessions[card.session_id];
if (!session) return null;
// Skip focused card here — rendered outside the canvas transform
if (focusedCardId === session.id) return null;
let origin = spawnOriginsRef.current[session.id];
if (origin) {
@@ -1378,6 +1441,9 @@ const DashboardInner: React.FC = () => {
snapColumn={snapColumn}
autoFocusInput={autoFocusSessionId === session.id}
onBringToFront={handleBringToFront}
isFocused={focusedCardId === session.id}
onFocusRequest={handleFocusRequest}
onFocusExit={handleFocusExit}
/>
);
})}
@@ -1467,9 +1533,53 @@ const DashboardInner: React.FC = () => {
</Box>
{/* Floating zoom controls */}
<Box sx={{ position: 'absolute', bottom: 16, right: 16, zIndex: 10 }}>
<CanvasControls zoom={canvas.zoom} actions={canvas.actions} onTidy={handleTidy} />
</Box>
{!focusedCardId && (
<Box sx={{ position: 'absolute', bottom: 16, right: 16, zIndex: 10 }}>
<CanvasControls zoom={canvas.zoom} actions={canvas.actions} onTidy={handleTidy} />
</Box>
)}
{/* Focus mode: backdrop + card rendered outside canvas transform */}
{focusedCardId && (() => {
const focusedCard = cards[focusedCardId];
const focusedSession = focusedCard ? sessions[focusedCard.session_id] : null;
if (!focusedSession || !focusedCard) return null;
return (
<>
<Box
onClick={handleFocusExit}
sx={{
position: 'fixed',
inset: 0,
bgcolor: 'rgba(0, 0, 0, 0.5)',
zIndex: 1200,
cursor: 'pointer',
}}
/>
<Box sx={{ position: 'fixed', inset: 48, zIndex: 1250 }}>
<AgentCard
session={focusedSession}
expanded={true}
cardX={0}
cardY={0}
cardWidth={0}
cardHeight={0}
cardZOrder={100000}
zoom={1}
isSelected={false}
isHighlighted={false}
onCardSelect={() => {}}
onMeasuredHeight={() => {}}
onBringToFront={() => {}}
isFocused={true}
onFocusRequest={handleFocusRequest}
onFocusExit={handleFocusExit}
autoFocusInput={true}
/>
</Box>
</>
);
})()}
</Box>
</>
);
@@ -1,10 +1,10 @@
import { useState, useCallback, useRef, useEffect, useMemo, RefObject } from 'react';
const MIN_ZOOM = 0.15;
const MIN_ZOOM = 0.5;
const MAX_ZOOM = 3.0;
const ZOOM_IN_FACTOR = 1.1;
const ZOOM_OUT_FACTOR = 1 / ZOOM_IN_FACTOR;
const FIT_PADDING = 200;
const FIT_PADDING = 80;
// Maps the 1100 user setting to an internal multiplier.
// 50 (default) → 0.004, 1 → 0.0004, 100 → 0.008
@@ -80,7 +80,11 @@ export function useCanvasControls(zoomSensitivity: number = 50) {
setState((prev) => {
const delta = e.deltaMode === 1 ? e.deltaY * 40 : e.deltaY;
const factor = Math.pow(2, -delta * sensitivityToMultiplier(sensitivityRef.current));
const newZoom = clamp(prev.zoom * factor, MIN_ZOOM, MAX_ZOOM);
let newZoom = clamp(prev.zoom * factor, MIN_ZOOM, MAX_ZOOM);
// Snap to 100% when crossing through the 0.971.03 band (trackpad only)
if (newZoom > 0.97 && newZoom < 1.03 && (prev.zoom <= 0.97 || prev.zoom >= 1.03)) {
newZoom = 1.0;
}
const ratio = newZoom / prev.zoom;
return {
panX: cx - (cx - prev.panX) * ratio,
+435 -40
View File
@@ -94,6 +94,7 @@ interface CredentialField {
label: string;
placeholder: string;
helpText?: string;
optional?: boolean;
}
interface Integration {
@@ -108,28 +109,15 @@ interface Integration {
connectLabel?: string;
connectInstructions?: string;
authType?: 'none' | 'oauth2' | 'env_vars';
oauthProvider?: string;
comingSoon?: boolean;
}
const INTEGRATIONS: Integration[] = [
{
id: 'xbird',
name: 'xbird',
description: 'Twitter/X research — search tweets, read profiles, threads, timelines.',
mcp_config: { type: 'stdio', command: 'bunx', args: ['@checkra1n/xbird'] },
color: '#1DA1F2',
website: 'https://xbird.dev',
icon: '𝕏',
connectLabel: 'Connect 𝕏',
connectInstructions: 'Open x.com in your browser, press F12 → Application → Cookies → x.com, and copy the values for auth_token and ct0.',
credentialFields: [
{ key: 'TWITTER_AUTH_TOKEN', label: 'auth_token', placeholder: 'Paste auth_token cookie value' },
{ key: 'TWITTER_CT0', label: 'ct0', placeholder: 'Paste ct0 cookie value' },
],
},
{
id: 'reddit',
name: 'Reddit',
description: 'Browse subreddits, search posts, get post details, analyze users. No API keys required.',
description: 'Browse subreddits, search posts, get post details, analyze users.',
mcp_config: { type: 'stdio', command: 'npx', args: ['-y', 'reddit-mcp-buddy'] },
color: '#FF4500',
website: 'https://github.com/karanb192/reddit-mcp-buddy',
@@ -156,6 +144,336 @@ const INTEGRATIONS: Integration[] = [
</svg>
),
authType: 'oauth2',
oauthProvider: 'google',
},
{
id: 'sequential-thinking',
name: 'Sequential Thinking',
description: 'Dynamic, reflective problem-solving through structured thought sequences.',
mcp_config: { type: 'stdio', command: 'npx', args: ['-y', '@modelcontextprotocol/server-sequential-thinking'] },
color: '#8B5CF6',
website: 'https://github.com/modelcontextprotocol/servers/tree/main/src/sequentialthinking',
icon: (
<svg viewBox="0 0 24 24" width="22" height="22">
<circle cx="12" cy="12" r="11" fill="#8B5CF6"/>
<path d="M12 6a4 4 0 0 0-4 4c0 1.5.8 2.8 2 3.5V15h4v-1.5c1.2-.7 2-2 2-3.5a4 4 0 0 0-4-4zm-1 11h2v1h-2z" fill="#fff"/>
</svg>
),
},
{
id: 'memory',
name: 'Memory',
description: 'Persistent memory using a local knowledge graph. Entities, relations, and observations.',
mcp_config: { type: 'stdio', command: 'npx', args: ['-y', '@modelcontextprotocol/server-memory'] },
color: '#06B6D4',
website: 'https://github.com/modelcontextprotocol/servers/tree/main/src/memory',
icon: (
<svg viewBox="0 0 24 24" width="22" height="22">
<circle cx="12" cy="12" r="11" fill="#06B6D4"/>
<path d="M12 4C9.2 4 7 6.2 7 9c0 1.9 1 3.5 2.5 4.3V15h5v-1.7C16 12.5 17 10.9 17 9c0-2.8-2.2-5-5-5zm-1.5 13h3v1h-3zm0 2h3v1h-3z" fill="#fff"/>
</svg>
),
},
{
id: 'filesystem',
name: 'Filesystem',
description: 'Read, write, search, and manage local files and directories.',
mcp_config: { type: 'stdio', command: 'npx', args: ['-y', '@modelcontextprotocol/server-filesystem', '/'] },
color: '#10B981',
website: 'https://github.com/modelcontextprotocol/servers/tree/main/src/filesystem',
icon: (
<svg viewBox="0 0 24 24" width="22" height="22">
<circle cx="12" cy="12" r="11" fill="#10B981"/>
<path d="M6 6h5l2 2h5v10H6V6zm2 2v8h8V10h-4l-2-2H8z" fill="#fff"/>
</svg>
),
},
{
id: 'playwright',
name: 'Playwright',
description: 'Browser automation — navigate, click, fill forms, take screenshots.',
mcp_config: { type: 'stdio', command: 'npx', args: ['-y', '@playwright/mcp'] },
color: '#2EAD33',
website: 'https://github.com/microsoft/playwright-mcp',
icon: (
<svg viewBox="0 0 24 24" width="22" height="22">
<circle cx="12" cy="12" r="11" fill="#2EAD33"/>
<path d="M7 8h10v8H7V8zm2 2v4h6v-4H9zm1 1h4v2h-4v-2z" fill="#fff"/>
</svg>
),
},
{
id: 'context7',
name: 'Context7',
description: 'Live, up-to-date documentation lookup for libraries and frameworks.',
mcp_config: { type: 'stdio', command: 'npx', args: ['-y', '@upstash/context7-mcp@latest'] },
color: '#00E599',
website: 'https://context7.com',
icon: (
<svg viewBox="0 0 24 24" width="22" height="22">
<circle cx="12" cy="12" r="11" fill="#00E599"/>
<text x="12" y="16.5" textAnchor="middle" fill="#fff" fontSize="12" fontWeight="bold">C7</text>
</svg>
),
},
{
id: 'desktop-commander',
name: 'Desktop Commander',
description: 'Terminal commands, file operations, process management, and diff-based editing.',
mcp_config: { type: 'stdio', command: 'npx', args: ['-y', '@wonderwhy-er/desktop-commander'] },
color: '#F59E0B',
website: 'https://github.com/wonderwhy-er/DesktopCommanderMCP',
icon: (
<svg viewBox="0 0 24 24" width="22" height="22">
<circle cx="12" cy="12" r="11" fill="#F59E0B"/>
<path d="M7 8l4 4-4 4M13 16h4" stroke="#fff" strokeWidth="2" fill="none" strokeLinecap="round"/>
</svg>
),
},
{
id: 'git',
name: 'Git',
description: 'Git operations — log, diff, commit, branch, status, and more on local repositories.',
mcp_config: { type: 'stdio', command: 'uvx', args: ['mcp-server-git'] },
color: '#F05032',
website: 'https://github.com/modelcontextprotocol/servers/tree/main/src/git',
icon: (
<svg viewBox="0 0 24 24" width="22" height="22">
<circle cx="12" cy="12" r="11" fill="#F05032"/>
<path d="M12.5 4.5l7 7a.7.7 0 0 1 0 1l-7 7a.7.7 0 0 1-1 0l-7-7a.7.7 0 0 1 0-1l7-7a.7.7 0 0 1 1 0z" fill="none" stroke="#fff" strokeWidth="1.5"/>
<circle cx="12" cy="12" r="1.5" fill="#fff"/>
<circle cx="9" cy="9" r="1.2" fill="#fff"/>
<line x1="10" y1="10" x2="11" y2="11" stroke="#fff" strokeWidth="1"/>
</svg>
),
},
{
id: 'youtube-transcripts',
name: 'YouTube Transcripts',
description: 'Fetch transcripts and captions from YouTube videos.',
comingSoon: true,
mcp_config: { type: 'stdio', command: 'npx', args: ['-y', '@kimtaeyoon83/mcp-server-youtube-transcript'] },
color: '#FF0000',
website: 'https://github.com/kimtaeyoon83/mcp-server-youtube-transcript',
icon: (
<svg viewBox="0 0 24 24" width="22" height="22">
<circle cx="12" cy="12" r="11" fill="#FF0000"/>
<path d="M9.5 8.5v7l6-3.5z" fill="#fff"/>
</svg>
),
},
{
id: 'twitter',
name: 'Twitter / X',
description: 'Fetch tweets, threads, and media from Twitter/X. Read-only.',
mcp_config: { type: 'stdio', command: 'npx', args: ['-y', 'tweetsave-mcp'] },
color: '#000000',
website: 'https://github.com/zezeron/tweetsave-mcp',
icon: (
<svg viewBox="0 0 24 24" width="22" height="22">
<circle cx="12" cy="12" r="11" fill="#000"/>
<text x="12" y="16" textAnchor="middle" fill="#fff" fontSize="13" fontWeight="bold">&#x1D54F;</text>
</svg>
),
},
{
id: 'shopify-dev',
name: 'Shopify Dev',
description: 'Search Shopify docs, explore API schemas, and validate GraphQL queries.',
mcp_config: { type: 'stdio', command: 'npx', args: ['-y', '@shopify/dev-mcp@latest'] },
color: '#96BF48',
website: 'https://github.com/Shopify/dev-mcp',
icon: (
<svg viewBox="0 0 24 24" width="22" height="22">
<circle cx="12" cy="12" r="11" fill="#96BF48"/>
<path d="M15.5 5.5l-1.2-.7c-.1-.1-.2-.1-.3 0l-.5.3c-.3-.2-.7-.3-1-.4l-.2-.8c0-.1-.1-.2-.3-.2h-1.4c-.1 0-.2.1-.3.2l-.2.8c-.4.1-.7.2-1 .4l-.5-.3c-.1-.1-.2-.1-.3 0L7.1 5.5c-.1.1-.1.2 0 .3l.4.5c-.1.3-.2.6-.2 1H6.5c-.2 0-.3.1-.3.3v1.4c0 .2.1.3.3.3h.8c.1.4.2.7.4 1l-.4.5c-.1.1-.1.2 0 .3l1 1c.1.1.2.1.3 0l.5-.4c.3.2.6.3 1 .4l.1.8c0 .1.1.3.3.3h1.4c.2 0 .3-.1.3-.3l.1-.8c.4-.1.7-.2 1-.4l.5.4c.1.1.2.1.3 0l1-1c.1-.1.1-.2 0-.3l-.4-.5c.2-.3.3-.6.4-1h.8c.2 0 .3-.1.3-.3V7.6c0-.2-.1-.3-.3-.3h-.8c-.1-.4-.2-.7-.4-1l.4-.5c.1-.1.1-.2 0-.3zM11.3 10a2 2 0 1 1 0-4 2 2 0 0 1 0 4z" fill="#fff" transform="translate(0.7, 3)"/>
</svg>
),
},
// --- OAuth integrations (click Connect → sign in → done) ---
{
id: 'github',
name: 'GitHub',
description: 'Manage repositories, issues, pull requests, branches, and code search.',
mcp_config: { type: 'stdio', command: 'npx', args: ['-y', '@modelcontextprotocol/server-github'] },
color: '#24292E',
website: 'https://github.com/modelcontextprotocol/servers/tree/main/src/github',
icon: (
<svg viewBox="0 0 24 24" width="22" height="22">
<circle cx="12" cy="12" r="11" fill="#24292E"/>
<path d="M12 5C8.13 5 5 8.13 5 12c0 3.1 2 5.7 4.8 6.6.35.07.48-.15.48-.34v-1.2c-1.95.42-2.36-.94-2.36-.94-.32-.81-.78-1.03-.78-1.03-.64-.43.05-.42.05-.42.7.05 1.07.72 1.07.72.63 1.07 1.64.76 2.04.58.06-.45.24-.76.44-.94-1.56-.18-3.2-.78-3.2-3.46 0-.76.27-1.39.72-1.88-.07-.18-.31-.89.07-1.85 0 0 .59-.19 1.93.72a6.7 6.7 0 0 1 3.5 0c1.34-.91 1.93-.72 1.93-.72.38.96.14 1.67.07 1.85.45.49.72 1.12.72 1.88 0 2.69-1.64 3.28-3.2 3.45.25.22.48.65.48 1.3v1.93c0 .19.13.41.48.34C17 17.7 19 15.1 19 12c0-3.87-3.13-7-7-7z" fill="#fff"/>
</svg>
),
authType: 'oauth2',
oauthProvider: 'github',
connectLabel: 'Connect GitHub',
},
{
id: 'slack',
name: 'Slack',
description: 'Send messages, read channels, search conversations, and manage workspaces.',
mcp_config: { type: 'stdio', command: 'npx', args: ['-y', '@modelcontextprotocol/server-slack'] },
color: '#4A154B',
website: 'https://github.com/modelcontextprotocol/servers/tree/main/src/slack',
icon: (
<svg viewBox="0 0 24 24" width="22" height="22">
<circle cx="12" cy="12" r="11" fill="#4A154B"/>
<path d="M9.1 14.1a1.2 1.2 0 1 1-2.4 0 1.2 1.2 0 0 1 1.2-1.2h1.2v1.2zm.6 0a1.2 1.2 0 1 1 2.4 0v3a1.2 1.2 0 1 1-2.4 0v-3zm1.2-5a1.2 1.2 0 1 1 0-2.4 1.2 1.2 0 0 1 1.2 1.2v1.2H10.9zm0 .6a1.2 1.2 0 1 1 0 2.4h-3a1.2 1.2 0 1 1 0-2.4h3zm5 1.2a1.2 1.2 0 1 1 2.4 0 1.2 1.2 0 0 1-1.2 1.2h-1.2v-1.2zm-.6 0a1.2 1.2 0 1 1-2.4 0v-3a1.2 1.2 0 1 1 2.4 0v3zm-1.2 5a1.2 1.2 0 1 1 0 2.4 1.2 1.2 0 0 1-1.2-1.2v-1.2h1.2zm0-.6a1.2 1.2 0 1 1 0-2.4h3a1.2 1.2 0 1 1 0 2.4h-3z" fill="#fff"/>
</svg>
),
authType: 'oauth2',
oauthProvider: 'slack',
connectLabel: 'Coming Soon',
comingSoon: true,
},
{
id: 'notion',
name: 'Notion',
description: 'Read and write pages, search databases, and manage workspace content.',
mcp_config: { type: 'stdio', command: 'npx', args: ['-y', '@notionhq/notion-mcp-server'] },
color: '#000000',
website: 'https://github.com/makenotion/notion-mcp-server',
icon: (
<svg viewBox="0 0 24 24" width="22" height="22">
<circle cx="12" cy="12" r="11" fill="#000"/>
<path d="M7.5 6.5h5.8l3.2 3.6v7.4H7.5V6.5zm1.2 1.2v8.6h6.6V10.8l-2.6-3.1H8.7z" fill="#fff"/>
<path d="M9.5 10h3M9.5 12h5M9.5 14h4" stroke="#fff" strokeWidth="0.7" fill="none"/>
</svg>
),
authType: 'oauth2',
oauthProvider: 'notion',
connectLabel: 'Coming Soon',
comingSoon: true,
},
{
id: 'spotify',
name: 'Spotify',
description: 'Control playback, search music, manage playlists, and browse your library.',
mcp_config: { type: 'stdio', command: 'npx', args: ['-y', '@tbrgeek/spotify-mcp-server'] },
color: '#1DB954',
website: 'https://github.com/tbrgeek/spotify-mcp-server',
icon: (
<svg viewBox="0 0 24 24" width="22" height="22">
<circle cx="12" cy="12" r="11" fill="#1DB954"/>
<path d="M16.5 10.5c-2.5-1.5-6.5-1.6-8.8-.9-.4.1-.8-.1-.9-.5s.1-.8.5-.9c2.7-.8 7.1-.7 9.9 1 .4.2.5.7.3 1-.2.4-.7.5-1 .3zm-.3 2.7c-.2.3-.6.4-.9.2-2.1-1.3-5.3-1.7-7.7-.9-.3.1-.7 0-.8-.4-.1-.3 0-.7.4-.8 2.8-.9 6.3-.4 8.7 1 .3.2.4.6.3.9zm-1 2.6c-.2.2-.5.3-.7.2-1.8-1.1-4.1-1.4-6.8-.7-.3.1-.5-.1-.6-.4s.1-.5.4-.6c3-.7 5.5-.4 7.5.8.3.2.3.5.2.7z" fill="#fff"/>
</svg>
),
authType: 'oauth2',
oauthProvider: 'spotify',
connectLabel: 'Coming Soon',
comingSoon: true,
},
{
id: 'figma',
name: 'Figma',
description: 'Access design files, inspect components, and extract design data.',
mcp_config: { type: 'stdio', command: 'npx', args: ['-y', 'figma-developer-mcp', '--stdio'] },
color: '#F24E1E',
website: 'https://github.com/anthropics/claude-code-mcp-server-figma',
icon: (
<svg viewBox="0 0 24 24" width="22" height="22">
<circle cx="12" cy="12" r="11" fill="#F24E1E"/>
<path d="M10.5 6h3v3h-3zm0 3h-3v3h3zm0 3h3v3h-3zm3-3h3v3h-3zm-3 6h-3v1.5a1.5 1.5 0 0 0 3 0V18z" fill="#fff" fillOpacity="0.9"/>
</svg>
),
authType: 'oauth2',
oauthProvider: 'figma',
connectLabel: 'Coming Soon',
comingSoon: true,
},
{
id: 'airtable',
name: 'Airtable',
description: 'Read and write records, manage bases, and search structured data.',
mcp_config: { type: 'stdio', command: 'npx', args: ['-y', 'airtable-mcp-server'] },
color: '#18BFFF',
website: 'https://github.com/domdomegg/airtable-mcp-server',
icon: (
<svg viewBox="0 0 24 24" width="22" height="22">
<circle cx="12" cy="12" r="11" fill="#18BFFF"/>
<path d="M6 8h12v2H6zm0 3h5v5H6zm7 0h5v5h-5z" fill="#fff" fillOpacity="0.9"/>
</svg>
),
authType: 'oauth2',
oauthProvider: 'airtable',
connectLabel: 'Coming Soon',
comingSoon: true,
},
{
id: 'hubspot',
name: 'HubSpot',
description: 'Manage contacts, deals, companies, and CRM data.',
mcp_config: { type: 'stdio', command: 'npx', args: ['-y', '@hubspot/mcp-server'] },
color: '#FF7A59',
website: 'https://github.com/HubSpot/hubspot-mcp-server',
icon: (
<svg viewBox="0 0 24 24" width="22" height="22">
<circle cx="12" cy="12" r="11" fill="#FF7A59"/>
<circle cx="12" cy="10.5" r="2.5" fill="none" stroke="#fff" strokeWidth="1.2"/>
<circle cx="16" cy="13.5" r="1.2" fill="#fff"/>
<line x1="13.8" y1="11.8" x2="15" y2="13" stroke="#fff" strokeWidth="1"/>
<path d="M12 13v2.5" stroke="#fff" strokeWidth="1.2"/>
</svg>
),
authType: 'oauth2',
oauthProvider: 'hubspot',
connectLabel: 'Coming Soon',
comingSoon: true,
},
{
id: 'discord',
name: 'Discord',
description: 'Manage servers, send messages, and interact with Discord communities.',
mcp_config: { type: 'stdio', command: 'npx', args: ['-y', 'mcp-discord'] },
color: '#5865F2',
website: 'https://github.com/DiscordMCP/discord-mcp',
icon: (
<svg viewBox="0 0 24 24" width="22" height="22">
<circle cx="12" cy="12" r="11" fill="#5865F2"/>
<path d="M15.5 8.5c-1-.5-2-.8-3.1-.9l-.2.4c1.1.2 2 .6 2.9 1.2a10 10 0 0 0-6.2 0c.9-.6 1.8-1 2.9-1.2l-.2-.4c-1.1.1-2.1.4-3.1.9-2 2.9-2.5 5.7-2.2 8.5 1.2.9 2.4 1.4 3.5 1.8.3-.4.5-.8.7-1.2-.4-.1-.7-.3-1-.5l.3-.2c2.2 1 4.6 1 6.8 0l.3.2c-.3.2-.7.4-1 .5.2.4.5.8.7 1.2 1.1-.4 2.3-.9 3.5-1.8.4-3.2-.6-6-2.6-8.5zM9.7 15c-.7 0-1.3-.7-1.3-1.5s.6-1.5 1.3-1.5 1.3.7 1.3 1.5-.6 1.5-1.3 1.5zm4.6 0c-.7 0-1.3-.7-1.3-1.5s.6-1.5 1.3-1.5 1.3.7 1.3 1.5-.6 1.5-1.3 1.5z" fill="#fff"/>
</svg>
),
connectLabel: 'Coming Soon',
comingSoon: true,
connectInstructions: 'Create a Discord bot at discord.com/developers → New Application → Bot, then copy the bot token.',
credentialFields: [
{ key: 'DISCORD_TOKEN', label: 'Bot Token', placeholder: 'Paste your Discord bot token' },
],
},
{
id: 'zoom',
name: 'Zoom',
description: 'Create, manage, and join Zoom meetings.',
mcp_config: { type: 'stdio', command: 'npx', args: ['-y', '@prathamesh0901/zoom-mcp-server'] },
color: '#2D8CFF',
website: 'https://github.com/pras-ops/Zoom_MCP_Server',
icon: (
<svg viewBox="0 0 24 24" width="22" height="22">
<circle cx="12" cy="12" r="11" fill="#2D8CFF"/>
<path d="M7 9h6.5c.3 0 .5.2.5.5v5c0 .3-.2.5-.5.5H7c-.3 0-.5-.2-.5-.5v-5c0-.3.2-.5.5-.5zm8 1.5l2.5-1.5v6l-2.5-1.5v-3z" fill="#fff"/>
</svg>
),
connectLabel: 'Connect Zoom',
connectInstructions: 'Go to marketplace.zoom.us → Develop → Build App → Server-to-Server OAuth App. Activate it, then copy the Account ID, Client ID, and Client Secret.',
credentialFields: [
{ key: 'ZOOM_ACCOUNT_ID', label: 'Account ID', placeholder: 'Your Zoom Account ID' },
{ key: 'ZOOM_CLIENT_ID', label: 'Client ID', placeholder: 'Your Zoom Client ID' },
{ key: 'ZOOM_CLIENT_SECRET', label: 'Client Secret', placeholder: 'Your Zoom Client Secret' },
],
},
{
id: 'microsoft-365',
name: 'Microsoft 365',
description: 'Outlook email, calendar, OneDrive, contacts, Teams, and more. Sign in via the agent when first used.',
mcp_config: { type: 'stdio', command: 'npx', args: ['-y', '@softeria/ms-365-mcp-server'] },
color: '#0078D4',
website: 'https://github.com/softeria-eu/ms-365-mcp-server',
icon: (
<svg viewBox="0 0 24 24" width="22" height="22">
<circle cx="12" cy="12" r="11" fill="#0078D4"/>
<path d="M6 7h5v5H6V7zm6.5 0H17v5h-4.5V7zM6 13h5v5H6v-5zm6.5 0H17v5h-4.5v-5z" fill="#fff" fillOpacity="0.9"/>
</svg>
),
},
];
@@ -379,8 +697,35 @@ const Tools: React.FC = () => {
const outputItems = useAppSelector((s) => s.outputs.items);
const outputs = useMemo(() => Object.values(outputItems), [outputItems]);
const allTools = Object.values(items);
const tools = allTools;
const uninstalledIntegrations = useMemo(() => INTEGRATIONS.filter((ig) => !allTools.find((t) => t.name === ig.name)), [allTools]);
const tools = useMemo(() => {
return [...allTools].sort((a, b) => {
const aIg = INTEGRATIONS.find(ig => ig.name === a.name);
const bIg = INTEGRATIONS.find(ig => ig.name === b.name);
const aComingSoon = aIg?.comingSoon ? 1 : 0;
const bComingSoon = bIg?.comingSoon ? 1 : 0;
// Coming Soon always at bottom
if (aComingSoon !== bComingSoon) return aComingSoon - bComingSoon;
const aPerms = Object.keys(a.tool_permissions || {}).filter(k => !k.startsWith('_')).length;
const bPerms = Object.keys(b.tool_permissions || {}).filter(k => !k.startsWith('_')).length;
const aConnected = a.auth_status === 'connected' ? 1 : 0;
const bConnected = b.auth_status === 'connected' ? 1 : 0;
const aEnabled = a.enabled !== false ? 1 : 0;
const bEnabled = b.enabled !== false ? 1 : 0;
const aScore = aEnabled * 4 + aConnected * 2 + (aPerms > 0 ? 1 : 0);
const bScore = bEnabled * 4 + bConnected * 2 + (bPerms > 0 ? 1 : 0);
if (bScore !== aScore) return bScore - aScore;
return bPerms - aPerms;
});
}, [allTools]);
const uninstalledIntegrations = useMemo(() => {
const uninstalled = INTEGRATIONS.filter((ig) => !allTools.find((t) => t.name === ig.name));
// Coming Soon goes to the bottom
return uninstalled.sort((a, b) => {
if (a.comingSoon && !b.comingSoon) return 1;
if (!a.comingSoon && b.comingSoon) return -1;
return 0;
});
}, [allTools]);
const getIntegrationForTool = useCallback((tool: ToolDefinition) => INTEGRATIONS.find((ig) => ig.name === tool.name), []);
const [dialogOpen, setDialogOpen] = useState(false);
@@ -465,6 +810,7 @@ const Tools: React.FC = () => {
credentials: {},
auth_type: integration.authType || 'none',
auth_status: 'configured',
...(integration.oauthProvider ? { oauth_provider: integration.oauthProvider } : {}),
}));
if (createTool.fulfilled.match(result)) {
const newTool = result.payload;
@@ -486,6 +832,31 @@ const Tools: React.FC = () => {
}
};
const handleDirectConnect = async (integration: Integration) => {
setIntegrationLoading((p) => ({ ...p, [integration.id]: true }));
try {
const result = await dispatch(createTool({
name: integration.name,
description: integration.description,
command: '',
mcp_config: integration.mcp_config,
credentials: {},
auth_type: integration.authType || 'none',
auth_status: 'configured',
...(integration.oauthProvider ? { oauth_provider: integration.oauthProvider } : {}),
}));
if (!createTool.fulfilled.match(result)) return;
const newTool = result.payload;
if (integration.authType === 'oauth2') {
handleOAuthConnect(newTool.id);
} else if (integration.credentialFields) {
openCredentialsDialog(newTool.id, integration);
}
} finally {
setIntegrationLoading((p) => ({ ...p, [integration.id]: false }));
}
};
const handleDiscover = async (toolId: string) => {
setDiscovering(true);
try {
@@ -730,7 +1101,7 @@ const Tools: React.FC = () => {
auth_type: 'oauth2',
auth_status: 'configured',
}));
setSnackbar({ open: true, message: `Installed "${f.name}" — click "Connect Google" to authorize` });
setSnackbar({ open: true, message: `Installed "${f.name}" — click "Connect" to authorize` });
} else if (hasConfig && mcpConfig.type === 'stdio') {
const result = await dispatch(createTool({
name: f.name,
@@ -773,11 +1144,12 @@ const Tools: React.FC = () => {
const afterConnect = async () => {
const statusResult = await dispatch(fetchToolStatus(toolId));
if (fetchToolStatus.fulfilled.match(statusResult) && statusResult.payload.auth_status === 'connected') {
setSnackbar({ open: true, message: 'Google account connected! Discovering actions…' });
const toolName = allTools.find(t => t.id === toolId)?.name || 'Account';
setSnackbar({ open: true, message: `${toolName} connected! Discovering actions…` });
setExpandedToolId(toolId);
dispatch(discoverTools(toolId));
} else {
setSnackbar({ open: true, message: 'Google account connected!' });
setSnackbar({ open: true, message: `${allTools.find(t => t.id === toolId)?.name || 'Account'} connected!` });
}
};
@@ -797,7 +1169,8 @@ const Tools: React.FC = () => {
}
}, 1000);
} else {
setSnackbar({ open: true, message: 'OAuth failed — make sure GOOGLE_OAUTH_CLIENT_ID is set in backend .env', severity: 'error' });
const errMsg = (result as any)?.payload?.detail || (result as any)?.error?.message || 'OAuth failed — check backend .env for required credentials';
setSnackbar({ open: true, message: errMsg, severity: 'error' });
}
};
@@ -816,7 +1189,7 @@ const Tools: React.FC = () => {
const handleCredentialsSave = async () => {
if (!credDialogToolId || !credDialogIntegration) return;
const hasEmpty = (credDialogIntegration.credentialFields || []).some((f) => !credDialogValues[f.key]?.trim());
const hasEmpty = (credDialogIntegration.credentialFields || []).some((f) => !f.optional && !credDialogValues[f.key]?.trim());
if (hasEmpty) return;
setCredDialogSaving(true);
@@ -1201,16 +1574,34 @@ const Tools: React.FC = () => {
<Typography sx={{ color: c.text.muted, fontSize: '0.84rem' }}>{ig.description}</Typography>
</Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, flexShrink: 0 }}>
{isLoading && <CircularProgress size={16} sx={{ color: ig.color }} />}
<Switch
checked={false}
onChange={() => handleIntegrationToggle(ig)}
disabled={isLoading}
sx={{
'& .MuiSwitch-switchBase.Mui-checked': { color: ig.color },
'& .MuiSwitch-switchBase.Mui-checked + .MuiSwitch-track': { bgcolor: ig.color },
}}
/>
{ig.comingSoon ? (
<Chip label="Coming Soon" size="small" sx={{ bgcolor: `${ig.color}15`, color: ig.color, fontSize: '0.7rem', fontStyle: 'italic', height: 24 }} />
) : (
<>
{(ig.authType === 'oauth2' || ig.credentialFields) && (
<Button
size="small"
variant="outlined"
startIcon={isLoading ? <CircularProgress size={14} /> : <LinkIcon sx={{ fontSize: 14 }} />}
onClick={() => handleDirectConnect(ig)}
disabled={isLoading}
sx={{ borderColor: `${ig.color}40`, color: ig.color, '&:hover': { borderColor: ig.color, bgcolor: `${ig.color}10` }, textTransform: 'none', fontSize: '0.78rem', borderRadius: 1.5, py: 0.5, flexShrink: 0, mr: 0.5 }}
>
{ig.connectLabel || `Connect ${ig.name}`}
</Button>
)}
{isLoading && !(ig.authType === 'oauth2' || ig.credentialFields) && <CircularProgress size={16} sx={{ color: ig.color }} />}
<Switch
checked={false}
onChange={() => handleIntegrationToggle(ig)}
disabled={isLoading}
sx={{
'& .MuiSwitch-switchBase.Mui-checked': { color: ig.color },
'& .MuiSwitch-switchBase.Mui-checked + .MuiSwitch-track': { bgcolor: ig.color },
}}
/>
</>
)}
</Box>
</Box>
</CardContent>
@@ -1379,7 +1770,8 @@ const Tools: React.FC = () => {
);
};
const isDisabled = tool.enabled === false;
const isComingSoon = ig?.comingSoon === true;
const isDisabled = tool.enabled === false || isComingSoon;
return (
<Card key={tool.id} sx={{ bgcolor: c.bg.surface, border: `1px solid ${isExpanded ? c.accent.primary : c.border.subtle}`, borderRadius: 2, boxShadow: c.shadow.sm, '&:hover': { borderColor: isDisabled ? c.border.subtle : c.accent.primary, boxShadow: isDisabled ? undefined : '0 0 0 1px rgba(174,86,48,0.12)' }, transition: 'border-color 0.2s, box-shadow 0.2s' }}>
@@ -1417,7 +1809,10 @@ const Tools: React.FC = () => {
</Box>
{tool.description && <Typography sx={{ color: c.text.muted, fontSize: '0.84rem' }}>{tool.description}</Typography>}
</Box>
{!isDisabled && tool.auth_type === 'oauth2' && tool.auth_status !== 'connected' && (
{isComingSoon && (
<Chip label="Coming Soon" size="small" sx={{ bgcolor: `${ig?.color || c.text.ghost}15`, color: ig?.color || c.text.ghost, fontSize: '0.7rem', fontStyle: 'italic', height: 24, flexShrink: 0 }} />
)}
{!isComingSoon && !isDisabled && tool.auth_type === 'oauth2' && tool.auth_status !== 'connected' && (
<Button
size="small"
variant="outlined"
@@ -1425,10 +1820,10 @@ const Tools: React.FC = () => {
onClick={(e) => { e.stopPropagation(); handleOAuthConnect(tool.id); }}
sx={{ borderColor: `${c.status.info}40`, color: c.status.info, '&:hover': { borderColor: c.status.info, bgcolor: `${c.status.info}10` }, textTransform: 'none', fontSize: '0.78rem', borderRadius: 1.5, py: 0.5, flexShrink: 0 }}
>
Connect Google
{ig?.connectLabel || `Connect ${ig?.name || 'Account'}`}
</Button>
)}
{!isDisabled && ig?.credentialFields && tool.auth_status !== 'connected' && (
{!isComingSoon && !isDisabled && ig?.credentialFields && tool.auth_status !== 'connected' && (
<Button
size="small"
variant="outlined"
@@ -1439,7 +1834,7 @@ const Tools: React.FC = () => {
{ig.connectLabel || 'Connect'}
</Button>
)}
{!isDisabled && ig && tool.auth_status === 'connected' && (
{!isComingSoon && !isDisabled && ig && tool.auth_status === 'connected' && (
<Tooltip title={ig.credentialFields || ig.authType === 'oauth2' ? 'Disconnect' : ''}>
<Chip
icon={<CheckCircleIcon sx={{ fontSize: 12 }} />}
@@ -1451,7 +1846,7 @@ const Tools: React.FC = () => {
/>
</Tooltip>
)}
{ig && (
{ig && !isComingSoon && (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, flexShrink: 0 }} onClick={(e) => e.stopPropagation()}>
{!!integrationLoading[ig.id] && <CircularProgress size={16} sx={{ color: ig.color }} />}
<Switch
@@ -2058,7 +2453,7 @@ const Tools: React.FC = () => {
<Button
variant="contained"
onClick={handleCredentialsSave}
disabled={credDialogSaving || (credDialogIntegration?.credentialFields || []).some((f) => !credDialogValues[f.key]?.trim())}
disabled={credDialogSaving || (credDialogIntegration?.credentialFields || []).some((f) => !f.optional && !credDialogValues[f.key]?.trim())}
startIcon={credDialogSaving ? <CircularProgress size={14} /> : <LinkIcon sx={{ fontSize: 14 }} />}
sx={{ bgcolor: credDialogIntegration?.color || c.accent.primary, '&:hover': { bgcolor: credDialogIntegration?.color || c.accent.pressed, filter: 'brightness(0.9)' }, textTransform: 'none', borderRadius: 2 }}
>
+1 -1
View File
@@ -75,7 +75,7 @@ const initialState: SettingsState = {
anthropic_api_key: null,
browser_homepage: 'https://www.google.com',
auto_select_mode_on_new_agent: false,
expand_new_chats_in_dashboard: false,
expand_new_chats_in_dashboard: true,
auto_reveal_sub_agents: true,
dev_mode: false,
},
+5 -1
View File
@@ -12,6 +12,7 @@ export interface ToolDefinition {
credentials: Record<string, string>;
auth_type: string;
auth_status: string;
oauth_provider?: string;
oauth_tokens: Record<string, any>;
tool_permissions: Record<string, any>;
connected_account_email?: string;
@@ -92,7 +93,10 @@ export const startOAuth = createAsyncThunk(
'tools/startOAuth',
async (toolId: string) => {
const res = await fetch(`${TOOLS_API}/${toolId}/oauth/start`, { method: 'POST' });
if (!res.ok) throw new Error('Failed to start OAuth');
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new Error(err.detail || 'Failed to start OAuth');
}
const data = await res.json();
return data as { auth_url: string };
}