diff --git a/backend/apps/agents/agent_manager.py b/backend/apps/agents/agent_manager.py
index 85a85580..dceffc58 100644
--- a/backend/apps/agents/agent_manager.py
+++ b/backend/apps/agents/agent_manager.py
@@ -207,6 +207,21 @@ class AgentManager:
sections.append("\n".join(lines))
+ # Add awareness of tools that are installed but not yet connected
+ not_connected = [
+ t for t in all_tools
+ if t.mcp_config and t.enabled
+ and t.auth_type in ("oauth2", "env_vars")
+ and t.auth_status != "connected"
+ ]
+ if not_connected:
+ nc_lines = [
+ "Tools installed but not yet connected (user needs to authorize in Settings → Tools):"
+ ]
+ for t in not_connected:
+ nc_lines.append(f" - {t.name}")
+ sections.append("\n".join(nc_lines))
+
if not sections:
return None
return (
diff --git a/backend/apps/analytics/analytics.py b/backend/apps/analytics/analytics.py
index bb6fa489..8859e715 100644
--- a/backend/apps/analytics/analytics.py
+++ b/backend/apps/analytics/analytics.py
@@ -15,7 +15,7 @@ from backend.apps.analytics.collector import init as init_collector, shutdown as
logger = logging.getLogger(__name__)
-APP_VERSION = "1.0.19"
+APP_VERSION = "1.0.20"
_heartbeat_task: asyncio.Task | None = None
diff --git a/backend/apps/settings/models.py b/backend/apps/settings/models.py
index 130d06eb..2b4c53e2 100644
--- a/backend/apps/settings/models.py
+++ b/backend/apps/settings/models.py
@@ -38,7 +38,7 @@ class AppSettings(BaseModel):
custom_providers: list["CustomProvider"] = Field(default_factory=list)
# Dashboard / UI preferences
auto_select_mode_on_new_agent: bool = False
- expand_new_chats_in_dashboard: bool = False
+ expand_new_chats_in_dashboard: bool = True
auto_reveal_sub_agents: bool = True
dev_mode: bool = False
# Subscription tokens (from CLI tools — alternative to API keys)
diff --git a/backend/apps/tools_lib/models.py b/backend/apps/tools_lib/models.py
index 857ccc3a..010aeee6 100644
--- a/backend/apps/tools_lib/models.py
+++ b/backend/apps/tools_lib/models.py
@@ -65,6 +65,7 @@ class ToolDefinition(BaseModel):
credentials: dict[str, str] = Field(default_factory=dict)
auth_type: str = "none"
auth_status: str = "none"
+ oauth_provider: Optional[str] = None
oauth_tokens: dict[str, Any] = Field(default_factory=dict)
tool_permissions: dict[str, Any] = Field(default_factory=dict)
connected_account_email: Optional[str] = None
@@ -79,6 +80,7 @@ class ToolCreate(BaseModel):
credentials: dict[str, str] = Field(default_factory=dict)
auth_type: str = "none"
auth_status: str = "none"
+ oauth_provider: Optional[str] = None
class ToolUpdate(BaseModel):
@@ -89,6 +91,7 @@ class ToolUpdate(BaseModel):
credentials: Optional[dict[str, str]] = None
auth_type: Optional[str] = None
auth_status: Optional[str] = None
+ oauth_provider: Optional[str] = None
oauth_tokens: Optional[dict[str, Any]] = None
tool_permissions: Optional[dict[str, Any]] = None
connected_account_email: Optional[str] = None
diff --git a/backend/apps/tools_lib/tools_lib.py b/backend/apps/tools_lib/tools_lib.py
index c27bb2eb..316908cc 100644
--- a/backend/apps/tools_lib/tools_lib.py
+++ b/backend/apps/tools_lib/tools_lib.py
@@ -1,11 +1,15 @@
import asyncio
+import base64
+import hashlib
import json
import os
import re
import logging
+import secrets
import shutil
import time
from contextlib import asynccontextmanager
+from dataclasses import dataclass, field
from typing import Any, Optional
from urllib.parse import urlencode
@@ -26,6 +30,26 @@ _DEFAULT_GOOGLE_CLIENT_SECRET = "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"
os.environ.setdefault("GOOGLE_OAUTH_CLIENT_ID", _DEFAULT_GOOGLE_CLIENT_ID)
os.environ.setdefault("GOOGLE_OAUTH_CLIENT_SECRET", _DEFAULT_GOOGLE_CLIENT_SECRET)
+# Default GitHub OAuth credentials for the OpenSwarm project.
+os.environ.setdefault("GITHUB_OAUTH_CLIENT_ID", "Ov23liDcwNJaKMjXY2jI")
+os.environ.setdefault("GITHUB_OAUTH_CLIENT_SECRET", "b25fe39409896aad3fd5155f032e9868440002f8")
+
+# Default Slack OAuth credentials (requires HTTPS redirect — not yet functional)
+os.environ.setdefault("SLACK_CLIENT_ID", "10795695056323.10799999254534")
+os.environ.setdefault("SLACK_CLIENT_SECRET", "d3a85a286bb0205157d7e4963502a91d")
+
+# Default Figma OAuth credentials for the OpenSwarm project.
+os.environ.setdefault("FIGMA_CLIENT_ID", "q6WduT7UuPaO6lM88v6ddN")
+os.environ.setdefault("FIGMA_CLIENT_SECRET", "dhNZdbEuyEWC15cKLwWpqTclyOSplD")
+
+# Default Airtable OAuth credentials for the OpenSwarm project.
+os.environ.setdefault("AIRTABLE_CLIENT_ID", "0699038b-a3a4-46b2-8fa6-690eb76fadfa")
+os.environ.setdefault("AIRTABLE_CLIENT_SECRET", "187fa83c8bab8ebcd11b8f226d75e7a1f14a8174ac0494463c1a53e66a3036d0")
+
+# Default HubSpot MCP Auth App credentials for the OpenSwarm project.
+os.environ.setdefault("HUBSPOT_CLIENT_ID", "6f4a1d4c-6a2f-4336-9b65-2cd84e218ff6")
+os.environ.setdefault("HUBSPOT_CLIENT_SECRET", "5747b5de-0800-4c35-a2da-e0655ee7ea37")
+
from backend.config.paths import BACKEND_DIR, DATA_ROOT, TOOLS_DIR as DATA_DIR, BUILTIN_PERMISSIONS_PATH as BUILTIN_PERMS_PATH
load_dotenv(os.path.join(BACKEND_DIR, ".env"))
@@ -53,7 +77,179 @@ GOOGLE_SCOPES = [
"https://www.googleapis.com/auth/contacts.readonly",
]
+
+# ---------------------------------------------------------------------------
+# Multi-provider OAuth registry
+# ---------------------------------------------------------------------------
+
+@dataclass
+class OAuthProvider:
+ auth_url: str
+ token_url: str
+ scopes: list[str]
+ userinfo_url: str | None
+ userinfo_field: str # JSON field for display name/email
+ client_id_env: str
+ client_secret_env: str
+ token_env_mapping: dict[str, str] # oauth_tokens key -> MCP env var name
+ extra_auth_params: dict[str, str] = field(default_factory=dict)
+ revoke_url: str | None = None
+ # For providers where token response nests the access_token differently
+ token_response_path: str | None = None # e.g. "authed_user.access_token" for Slack
+ # Token exchange auth method: "form" (default), "basic" (Basic Auth header), "basic_json" (Basic Auth + JSON body)
+ token_auth_method: str = "form"
+ # Whether PKCE is required
+ pkce_required: bool = False
+ # Custom transform for env var value (e.g., wrapping token in JSON for Notion)
+ env_value_transform: str | None = None # e.g., "notion_headers"
+ # Extra token response fields to extract (e.g., Slack team_id)
+ extra_token_fields: dict[str, str] = field(default_factory=dict) # response_path -> env_var
+
+
+OAUTH_PROVIDERS: dict[str, OAuthProvider] = {
+ "google": OAuthProvider(
+ auth_url=GOOGLE_AUTH_URL,
+ token_url=GOOGLE_TOKEN_URL,
+ scopes=GOOGLE_SCOPES,
+ userinfo_url=GOOGLE_USERINFO_URL,
+ userinfo_field="email",
+ client_id_env="GOOGLE_OAUTH_CLIENT_ID",
+ client_secret_env="GOOGLE_OAUTH_CLIENT_SECRET",
+ token_env_mapping={
+ "access_token": "OAUTH_ACCESS_TOKEN",
+ "refresh_token": "GOOGLE_WORKSPACE_REFRESH_TOKEN",
+ "_client_id": "GOOGLE_WORKSPACE_CLIENT_ID",
+ "_client_secret": "GOOGLE_WORKSPACE_CLIENT_SECRET",
+ },
+ extra_auth_params={"access_type": "offline", "prompt": "consent"},
+ ),
+ "github": OAuthProvider(
+ auth_url="https://github.com/login/oauth/authorize",
+ token_url="https://github.com/login/oauth/access_token",
+ scopes=["repo", "read:user", "user:email"],
+ userinfo_url="https://api.github.com/user",
+ userinfo_field="login",
+ client_id_env="GITHUB_OAUTH_CLIENT_ID",
+ client_secret_env="GITHUB_OAUTH_CLIENT_SECRET",
+ token_env_mapping={
+ "access_token": "GITHUB_PERSONAL_ACCESS_TOKEN",
+ },
+ ),
+ "slack": OAuthProvider(
+ auth_url="https://slack.com/oauth/v2/authorize",
+ token_url="https://slack.com/api/oauth.v2.access",
+ scopes=[
+ "channels:read", "channels:history", "chat:write",
+ "groups:read", "groups:history", "im:read", "im:history",
+ "mpim:read", "mpim:history", "users:read", "users:read.email",
+ "team:read", "reactions:read", "reactions:write",
+ "files:read", "files:write",
+ ],
+ userinfo_url="https://slack.com/api/auth.test",
+ userinfo_field="user",
+ client_id_env="SLACK_CLIENT_ID",
+ client_secret_env="SLACK_CLIENT_SECRET",
+ token_env_mapping={
+ "access_token": "SLACK_BOT_TOKEN",
+ },
+ extra_token_fields={"team.id": "SLACK_TEAM_ID"},
+ ),
+ "notion": OAuthProvider(
+ auth_url="https://api.notion.com/v1/oauth/authorize",
+ token_url="https://api.notion.com/v1/oauth/token",
+ scopes=[], # Notion doesn't use scopes in the auth URL
+ userinfo_url=None,
+ userinfo_field="owner",
+ client_id_env="NOTION_OAUTH_CLIENT_ID",
+ client_secret_env="NOTION_OAUTH_CLIENT_SECRET",
+ token_env_mapping={
+ "access_token": "OPENAPI_MCP_HEADERS",
+ },
+ extra_auth_params={"owner": "user"},
+ token_auth_method="basic_json",
+ env_value_transform="notion_headers",
+ ),
+ "spotify": OAuthProvider(
+ auth_url="https://accounts.spotify.com/authorize",
+ token_url="https://accounts.spotify.com/api/token",
+ scopes=[
+ "user-read-playback-state", "user-modify-playback-state",
+ "user-read-currently-playing", "playlist-read-private",
+ "playlist-modify-public", "playlist-modify-private",
+ "user-library-read", "user-library-modify",
+ "user-read-recently-played", "user-top-read",
+ ],
+ userinfo_url="https://api.spotify.com/v1/me",
+ userinfo_field="display_name",
+ client_id_env="SPOTIFY_CLIENT_ID",
+ client_secret_env="SPOTIFY_CLIENT_SECRET",
+ token_env_mapping={
+ "access_token": "SPOTIFY_ACCESS_TOKEN",
+ "refresh_token": "SPOTIFY_REFRESH_TOKEN",
+ "_client_id": "SPOTIFY_CLIENT_ID",
+ "_client_secret": "SPOTIFY_CLIENT_SECRET",
+ },
+ token_auth_method="basic",
+ ),
+ "figma": OAuthProvider(
+ auth_url="https://www.figma.com/oauth",
+ token_url="https://api.figma.com/v1/oauth/token",
+ scopes=["current_user:read", "file_content:read", "file_metadata:read", "file_comments:read", "file_comments:write", "file_versions:read", "file_variables:read"],
+ userinfo_url="https://api.figma.com/v1/me",
+ userinfo_field="email",
+ client_id_env="FIGMA_CLIENT_ID",
+ client_secret_env="FIGMA_CLIENT_SECRET",
+ token_env_mapping={
+ "access_token": "FIGMA_API_KEY",
+ },
+ ),
+ "airtable": OAuthProvider(
+ auth_url="https://airtable.com/oauth2/v1/authorize",
+ token_url="https://airtable.com/oauth2/v1/token",
+ scopes=[
+ "data.records:read", "data.records:write",
+ "data.recordComments:read", "data.recordComments:write",
+ "schema.bases:read", "schema.bases:write",
+ "user.email:read", "webhook:manage",
+ ],
+ userinfo_url="https://api.airtable.com/v0/meta/whoami",
+ userinfo_field="email",
+ client_id_env="AIRTABLE_CLIENT_ID",
+ client_secret_env="AIRTABLE_CLIENT_SECRET",
+ token_env_mapping={
+ "access_token": "AIRTABLE_API_KEY",
+ },
+ pkce_required=True,
+ token_auth_method="basic",
+ ),
+ "hubspot": OAuthProvider(
+ auth_url="https://mcp-na2.hubspot.com/oauth/authorize/user",
+ token_url="https://api.hubapi.com/oauth/v1/token",
+ scopes=[], # MCP Auth Apps have preconfigured scopes
+ userinfo_url=None, # HubSpot userinfo requires token-in-path, handle separately
+ userinfo_field="user",
+ client_id_env="HUBSPOT_CLIENT_ID",
+ client_secret_env="HUBSPOT_CLIENT_SECRET",
+ token_env_mapping={
+ "access_token": "PRIVATE_APP_ACCESS_TOKEN",
+ "refresh_token": "HUBSPOT_REFRESH_TOKEN",
+ },
+ pkce_required=True,
+ ),
+}
+
+
+def _resolve_oauth_provider(tool: ToolDefinition) -> OAuthProvider:
+ """Resolve the OAuth provider for a tool, defaulting to Google for backward compat."""
+ key = tool.oauth_provider or "google"
+ provider = OAUTH_PROVIDERS.get(key)
+ if not provider:
+ raise HTTPException(status_code=400, detail=f"Unknown OAuth provider: {key}")
+ return provider
+
+
_pending_oauth: dict[str, str] = {}
+_pending_pkce: dict[str, str] = {} # state -> code_verifier (for PKCE flows)
def _load_all() -> list[ToolDefinition]:
@@ -122,49 +318,108 @@ async def list_tools():
@tools_lib.router.get("/oauth/callback")
async def oauth_callback(code: str = Query(...), state: str = Query("")):
+ # Backward compat: old state was just tool_id, new state is "provider:tool_id"
tool_id = _pending_oauth.pop(state, None)
+ if not tool_id:
+ # Try legacy format (state = tool_id directly)
+ tool_id = _pending_oauth.pop(state.split(":")[-1] if ":" in state else state, None)
if not tool_id:
return HTMLResponse("
Invalid OAuth state
", status_code=400)
tool = _load(tool_id)
- client_id = os.environ.get("GOOGLE_OAUTH_CLIENT_ID", "")
- client_secret = os.environ.get("GOOGLE_OAUTH_CLIENT_SECRET", "")
+ provider = _resolve_oauth_provider(tool)
+
+ client_id = os.environ.get(provider.client_id_env, "")
+ client_secret = os.environ.get(provider.client_secret_env, "")
_port = os.environ.get("OPENSWARM_PORT", "8324")
redirect_uri = f"http://localhost:{_port}/api/tools/oauth/callback"
+ # Build token exchange request based on provider's auth method
+ token_data: dict[str, str] = {
+ "code": code,
+ "redirect_uri": redirect_uri,
+ "grant_type": "authorization_code",
+ }
+ headers: dict[str, str] = {}
+
+ if provider.token_auth_method == "basic":
+ # Spotify, etc: Basic Auth header, credentials in form body
+ creds = base64.b64encode(f"{client_id}:{client_secret}".encode()).decode()
+ headers["Authorization"] = f"Basic {creds}"
+ elif provider.token_auth_method == "basic_json":
+ # Notion: Basic Auth header, JSON body
+ creds = base64.b64encode(f"{client_id}:{client_secret}".encode()).decode()
+ headers["Authorization"] = f"Basic {creds}"
+ headers["Content-Type"] = "application/json"
+ else:
+ # Default: credentials as form data fields
+ token_data["client_id"] = client_id
+ token_data["client_secret"] = client_secret
+
+ # GitHub requires Accept: application/json
+ if (tool.oauth_provider or "google") == "github":
+ headers["Accept"] = "application/json"
+
+ # PKCE: include code_verifier if we stored one
+ code_verifier = _pending_pkce.pop(state, None)
+ if code_verifier:
+ token_data["code_verifier"] = code_verifier
+
async with httpx.AsyncClient(timeout=15.0) as client:
- resp = await client.post(GOOGLE_TOKEN_URL, data={
- "code": code,
- "client_id": client_id,
- "client_secret": client_secret,
- "redirect_uri": redirect_uri,
- "grant_type": "authorization_code",
- })
+ if provider.token_auth_method == "basic_json":
+ resp = await client.post(provider.token_url, json=token_data, headers=headers)
+ else:
+ resp = await client.post(provider.token_url, data=token_data, headers=headers)
if resp.status_code != 200:
logger.warning(f"OAuth token exchange failed: {resp.text}")
return HTMLResponse(f"Token exchange failed
{resp.text}", status_code=400)
tokens = resp.json()
+
+ # Extract access_token, handling nested responses (e.g., Slack)
access_token = tokens.get("access_token", "")
+ if provider.token_response_path and not access_token:
+ # Walk nested path like "authed_user.access_token"
+ obj = tokens
+ for part in provider.token_response_path.split("."):
+ obj = obj.get(part, {}) if isinstance(obj, dict) else ""
+ if isinstance(obj, str) and obj:
+ access_token = obj
+
tool.oauth_tokens = {
"access_token": access_token,
"refresh_token": tokens.get("refresh_token", ""),
"token_expiry": time.time() + tokens.get("expires_in", 3600),
}
+
+ # Extract extra fields (e.g., Slack team_id)
+ for response_path, env_var in provider.extra_token_fields.items():
+ obj: Any = tokens
+ for part in response_path.split("."):
+ obj = obj.get(part, "") if isinstance(obj, dict) else ""
+ if obj:
+ tool.oauth_tokens[env_var] = str(obj)
+
tool.auth_status = "connected"
- if access_token:
+ if access_token and provider.userinfo_url:
try:
async with httpx.AsyncClient(timeout=10.0) as info_client:
info_resp = await info_client.get(
- GOOGLE_USERINFO_URL,
+ provider.userinfo_url,
headers={"Authorization": f"Bearer {access_token}"},
)
if info_resp.status_code == 200:
- tool.connected_account_email = info_resp.json().get("email")
+ tool.connected_account_email = info_resp.json().get(provider.userinfo_field)
except Exception as e:
- logger.warning(f"Failed to fetch Google userinfo: {e}")
+ logger.warning(f"Failed to fetch userinfo for {tool.oauth_provider or 'google'}: {e}")
+
+ # Notion: extract workspace name from token response
+ if (tool.oauth_provider or "google") == "notion" and not tool.connected_account_email:
+ workspace_name = tokens.get("workspace_name")
+ if workspace_name:
+ tool.connected_account_email = workspace_name
_save(tool)
@@ -195,84 +450,17 @@ async def create_tool(body: ToolCreate):
credentials=body.credentials,
auth_type=body.auth_type,
auth_status=body.auth_status,
+ oauth_provider=body.oauth_provider,
)
_save(tool)
return {"ok": True, "tool": tool.model_dump()}
-_XBIRD_CONFIG_DIR = os.path.join(os.path.expanduser("~"), ".config", "xbird")
-_XBIRD_CONFIG_PATH = os.path.join(_XBIRD_CONFIG_DIR, "config.json")
-
-
-async def _fetch_twitter_screen_name(auth_token: str, ct0: str) -> str | None:
- """Fetch the logged-in Twitter/X screen name using session cookies."""
- try:
- async with httpx.AsyncClient(timeout=10.0) as client:
- resp = await client.get(
- "https://api.twitter.com/1.1/account/verify_credentials.json",
- headers={"x-csrf-token": ct0},
- cookies={"auth_token": auth_token, "ct0": ct0},
- )
- if resp.status_code == 200:
- screen_name = resp.json().get("screen_name")
- return f"@{screen_name}" if screen_name else None
- except Exception as e:
- logger.warning("Failed to fetch Twitter screen name: %s", e)
- return None
-
-
-def _sync_external_config(tool: ToolDefinition):
- """Write credentials to external config files for tools that need them.
-
- xbird reads auth from ~/.config/xbird/config.json rather than env vars,
- so we sync credentials there when the user connects via the UI.
- """
- if tool.name == "xbird" and tool.credentials:
- auth_token = tool.credentials.get("TWITTER_AUTH_TOKEN", "")
- ct0 = tool.credentials.get("TWITTER_CT0", "")
- if auth_token and ct0:
- os.makedirs(_XBIRD_CONFIG_DIR, exist_ok=True)
- config = {}
- if os.path.exists(_XBIRD_CONFIG_PATH):
- try:
- with open(_XBIRD_CONFIG_PATH) as f:
- config = json.load(f)
- except Exception:
- pass
- config["auth_token"] = auth_token
- config["ct0"] = ct0
- with open(_XBIRD_CONFIG_PATH, "w") as f:
- json.dump(config, f, indent=2)
- os.chmod(_XBIRD_CONFIG_PATH, 0o600)
- logger.info("Synced xbird credentials to %s", _XBIRD_CONFIG_PATH)
- elif tool.name == "xbird" and not tool.credentials:
- if os.path.exists(_XBIRD_CONFIG_PATH):
- try:
- with open(_XBIRD_CONFIG_PATH) as f:
- config = json.load(f)
- config.pop("auth_token", None)
- config.pop("ct0", None)
- with open(_XBIRD_CONFIG_PATH, "w") as f:
- json.dump(config, f, indent=2)
- logger.info("Cleared xbird credentials from %s", _XBIRD_CONFIG_PATH)
- except Exception:
- pass
-
-
@tools_lib.router.put("/{tool_id}")
async def update_tool(tool_id: str, body: ToolUpdate):
tool = _load(tool_id)
for k, v in body.model_dump(exclude_none=True).items():
setattr(tool, k, v)
- _sync_external_config(tool)
-
- if tool.name == "xbird" and tool.auth_status == "connected" and tool.credentials:
- auth_token = tool.credentials.get("TWITTER_AUTH_TOKEN", "")
- ct0 = tool.credentials.get("TWITTER_CT0", "")
- if auth_token and ct0:
- screen_name = await _fetch_twitter_screen_name(auth_token, ct0)
- if screen_name:
- tool.connected_account_email = screen_name
_save(tool)
return {"ok": True, "tool": tool.model_dump()}
@@ -390,15 +578,37 @@ def derive_mcp_config(tool: ToolDefinition) -> Optional[dict]:
headers["Authorization"] = f"Bearer {tool.oauth_tokens['access_token']}"
else:
env = config.setdefault("env", {})
- env["OAUTH_ACCESS_TOKEN"] = tool.oauth_tokens["access_token"]
- if tool.oauth_tokens.get("refresh_token"):
- env["GOOGLE_WORKSPACE_REFRESH_TOKEN"] = tool.oauth_tokens["refresh_token"]
- client_id = os.environ.get("GOOGLE_OAUTH_CLIENT_ID", "")
- client_secret = os.environ.get("GOOGLE_OAUTH_CLIENT_SECRET", "")
- if client_id:
- env["GOOGLE_WORKSPACE_CLIENT_ID"] = client_id
- if client_secret:
- env["GOOGLE_WORKSPACE_CLIENT_SECRET"] = client_secret
+ provider_key = tool.oauth_provider or "google"
+ provider = OAUTH_PROVIDERS.get(provider_key)
+ if provider:
+ for token_field, env_var in provider.token_env_mapping.items():
+ if token_field.startswith("_client_id"):
+ val = os.environ.get(provider.client_id_env, "")
+ elif token_field.startswith("_client_secret"):
+ val = os.environ.get(provider.client_secret_env, "")
+ else:
+ val = tool.oauth_tokens.get(token_field, "")
+ if val:
+ # Apply value transforms (e.g., Notion needs JSON headers)
+ if provider.env_value_transform == "notion_headers" and token_field == "access_token":
+ val = json.dumps({
+ "Authorization": f"Bearer {val}",
+ "Notion-Version": "2022-06-28",
+ })
+ env[env_var] = val
+ # Inject extra token fields (e.g., Slack SLACK_TEAM_ID)
+ for _, env_var in provider.extra_token_fields.items():
+ val = tool.oauth_tokens.get(env_var, "")
+ if val:
+ env[env_var] = val
+ # Figma: inject token as CLI arg (it doesn't read env vars)
+ if provider_key == "figma" and tool.oauth_tokens.get("access_token"):
+ args = config.get("args", [])
+ if "--figma-api-key" not in args:
+ config["args"] = args + ["--figma-api-key", tool.oauth_tokens["access_token"]]
+ else:
+ # Fallback: inject generic access token
+ env["OAUTH_ACCESS_TOKEN"] = tool.oauth_tokens["access_token"]
if config.get("type") == "stdio":
if config.get("command"):
@@ -455,22 +665,86 @@ _SERVICE_RULES: list[tuple[list[str], str, str]] = [
(["contact"], "Contacts", "Google"),
(["script", "deployment", "version", "trigger"], "Apps Script", "Google"),
(["search_custom", "search_engine"], "Search", "Google"),
- # Reddit (before Twitter so "search_reddit" etc. don't mis-match)
+ # Reddit
(["subreddit"], "Subreddits", "Reddit"),
(["search_reddit"], "Search", "Reddit"),
(["post_detail"], "Posts", "Reddit"),
(["user_analysis"], "Users", "Reddit"),
(["reddit_explain"], "Reference", "Reddit"),
- # Twitter / X
- (["tweet", "thread", "reply", "replies", "quote", "retweet", "article"], "Tweets", "Twitter"),
- (["timeline", "home", "news", "trending"], "Timeline", "Twitter"),
- (["follower", "following", "follow", "unfollow"], "Network", "Twitter"),
- (["like", "unlike", "bookmark"], "Engagement", "Twitter"),
- (["mention"], "Mentions", "Twitter"),
- (["user", "profile"], "Users", "Twitter"),
- (["media", "upload", "image", "video"], "Media", "Twitter"),
- (["search"], "Search", "Twitter"),
- (["list", "list_member"], "Lists", "Twitter"),
+ # Sequential Thinking
+ (["sequentialthinking", "thinking"], "Thinking", "Sequential Thinking"),
+ # Memory (knowledge graph)
+ (["create_entities", "create_relations", "add_observations", "delete_entities",
+ "delete_observations", "delete_relations", "read_graph", "search_nodes",
+ "open_nodes"], "Knowledge Graph", "Memory"),
+ # Filesystem
+ (["read_file", "read_multiple_files", "write_file", "edit_file",
+ "create_directory", "list_directory", "directory_tree", "move_file",
+ "search_files", "get_file_info", "list_allowed_directories"], "Files", "Filesystem"),
+ # Playwright
+ (["browser_navigate", "browser_screenshot", "browser_click", "browser_fill",
+ "browser_select", "browser_hover", "browser_evaluate", "browser_console",
+ "browser_tab", "browser_close", "browser_resize", "browser_snapshot",
+ "browser_wait", "browser_pdf", "browser_drag"], "Browser", "Playwright"),
+ # Git
+ (["git_status", "git_diff", "git_diff_unstaged", "git_diff_staged",
+ "git_commit", "git_log", "git_add", "git_reset", "git_show",
+ "git_create_branch", "git_checkout", "git_list_branches", "git_init",
+ "git_clone"], "Repository", "Git"),
+ # YouTube Transcripts
+ (["get_transcript"], "Transcripts", "YouTube"),
+ # Desktop Commander
+ (["execute_command", "read_output", "force_terminate", "list_sessions",
+ "list_processes", "kill_process", "block_command", "unblock_command",
+ "read_file", "write_file", "search_code", "list_directory",
+ "get_file_info", "edit_block"], "System", "Desktop Commander"),
+ # GitHub
+ (["repository", "issue", "pull_request", "commit", "branch", "fork", "star",
+ "create_issue", "list_issues", "get_issue", "create_pull_request",
+ "list_commits", "search_repositories", "create_repository",
+ "get_file_contents", "push_files", "create_branch",
+ "search_code", "search_issues"], "Repository", "GitHub"),
+ # Slack
+ (["channel", "slack_message", "thread", "reply", "workspace",
+ "list_channels", "post_message", "reply_to_thread", "search_messages",
+ "get_channel_history", "get_thread_replies", "get_users",
+ "get_user_profile"], "Messaging", "Slack"),
+ # Notion
+ (["notion_page", "database", "block", "create_page", "update_page",
+ "search_pages", "get_page", "get_database", "query_database",
+ "create_database", "append_block_children"], "Pages", "Notion"),
+ # Spotify
+ (["play", "pause", "skip", "playlist", "track", "album", "artist",
+ "search_tracks", "get_playlist", "get_currently_playing",
+ "add_to_playlist", "create_playlist", "get_recommendations",
+ "get_top_items"], "Music", "Spotify"),
+ # Figma
+ (["figma", "design", "component", "style", "node",
+ "get_file", "get_file_nodes", "get_image", "get_comments",
+ "get_team_projects", "get_project_files"], "Design", "Figma"),
+ # Airtable
+ (["airtable", "base", "record", "field", "view",
+ "list_records", "get_record", "create_record", "update_record",
+ "delete_record", "list_bases", "get_base_schema"], "Data", "Airtable"),
+ # HubSpot
+ (["hubspot", "contact", "deal", "company", "ticket", "pipeline",
+ "crm", "engagement", "association"], "CRM", "HubSpot"),
+ # Discord
+ (["discord", "guild", "server", "channel_message", "send_message",
+ "get_messages", "get_guilds", "get_channels", "add_reaction"], "Messaging", "Discord"),
+ # Twitter / X (TweetSave)
+ (["tweetsave", "get_tweet", "get_thread", "to_blog", "batch",
+ "extract_media"], "Tweets", "Twitter"),
+ # Shopify Dev
+ (["shopify", "introspect", "graphql", "search_dev_docs", "liquid",
+ "polaris", "admin_api", "storefront_api"], "Developer", "Shopify"),
+ # Zoom
+ (["zoom", "meeting", "recording", "participant", "webinar",
+ "create_meeting", "list_meetings", "get_meeting", "delete_meeting",
+ "update_meeting"], "Meetings", "Zoom"),
+ # Microsoft 365
+ (["outlook", "onedrive", "ms365", "microsoft", "mail_folder",
+ "email", "calendar_event", "contact", "drive_item"], "Mail & Files", "Microsoft 365"),
]
@@ -597,6 +871,7 @@ async def _discover_mcp_tools_stdio(command: str, args: list[str] | None = None,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
env=proc_env,
+ limit=1024 * 1024, # 1MB buffer for large MCP responses (e.g., MS365 with 87 tools)
)
async def _send(msg: dict) -> None:
@@ -765,15 +1040,17 @@ async def oauth_disconnect(tool_id: str):
access_token = tool.oauth_tokens.get("access_token")
if access_token:
+ provider = _resolve_oauth_provider(tool)
+ revoke_url = provider.revoke_url or "https://oauth2.googleapis.com/revoke"
try:
async with httpx.AsyncClient(timeout=10.0) as client:
await client.post(
- "https://oauth2.googleapis.com/revoke",
+ revoke_url,
params={"token": access_token},
headers={"Content-Type": "application/x-www-form-urlencoded"},
)
except Exception as e:
- logger.warning(f"Failed to revoke Google token for tool {tool.id}: {e}")
+ logger.warning(f"Failed to revoke token for tool {tool.id}: {e}")
tool.oauth_tokens = {}
tool.auth_status = "configured"
@@ -784,14 +1061,17 @@ async def oauth_disconnect(tool_id: str):
@tools_lib.router.post("/{tool_id}/oauth/start")
async def oauth_start(tool_id: str):
- _load(tool_id)
- client_id = os.environ.get("GOOGLE_OAUTH_CLIENT_ID", "")
+ tool = _load(tool_id)
+ provider = _resolve_oauth_provider(tool)
+
+ client_id = os.environ.get(provider.client_id_env, "")
if not client_id:
- raise HTTPException(status_code=400, detail="GOOGLE_OAUTH_CLIENT_ID not set in backend .env")
+ raise HTTPException(status_code=400, detail=f"{provider.client_id_env} not set in backend .env")
_port = os.environ.get("OPENSWARM_PORT", "8324")
redirect_uri = f"http://localhost:{_port}/api/tools/oauth/callback"
- state = tool_id
+ provider_key = tool.oauth_provider or "google"
+ state = f"{provider_key}:{tool_id}"
_pending_oauth[state] = tool_id
@@ -799,19 +1079,28 @@ async def oauth_start(tool_id: str):
"client_id": client_id,
"redirect_uri": redirect_uri,
"response_type": "code",
- "scope": " ".join(GOOGLE_SCOPES),
- "access_type": "offline",
- "prompt": "consent",
"state": state,
+ **provider.extra_auth_params,
}
- auth_url = f"{GOOGLE_AUTH_URL}?{urlencode(params)}"
+ if provider.scopes:
+ params["scope"] = " ".join(provider.scopes)
+
+ # PKCE support (required by Airtable, etc.)
+ if provider.pkce_required:
+ code_verifier = secrets.token_urlsafe(64)
+ code_challenge = base64.urlsafe_b64encode(
+ hashlib.sha256(code_verifier.encode()).digest()
+ ).rstrip(b"=").decode()
+ params["code_challenge"] = code_challenge
+ params["code_challenge_method"] = "S256"
+ _pending_pkce[state] = code_verifier
+
+ auth_url = f"{provider.auth_url}?{urlencode(params)}"
return {"auth_url": auth_url}
-
-
-async def refresh_google_token(tool: ToolDefinition) -> Optional[str]:
- """Refresh an expired Google OAuth token. Returns the fresh access_token or None."""
+async def refresh_oauth_token(tool: ToolDefinition) -> Optional[str]:
+ """Refresh an expired OAuth token. Returns the fresh access_token or None."""
if tool.auth_type != "oauth2":
return None
refresh_token = tool.oauth_tokens.get("refresh_token")
@@ -821,14 +1110,15 @@ async def refresh_google_token(tool: ToolDefinition) -> Optional[str]:
if time.time() < expiry - 60:
return tool.oauth_tokens.get("access_token")
- client_id = os.environ.get("GOOGLE_OAUTH_CLIENT_ID", "")
- client_secret = os.environ.get("GOOGLE_OAUTH_CLIENT_SECRET", "")
+ provider = _resolve_oauth_provider(tool)
+ client_id = os.environ.get(provider.client_id_env, "")
+ client_secret = os.environ.get(provider.client_secret_env, "")
if not client_id or not client_secret:
return None
try:
async with httpx.AsyncClient(timeout=15.0) as client:
- resp = await client.post(GOOGLE_TOKEN_URL, data={
+ resp = await client.post(provider.token_url, data={
"client_id": client_id,
"client_secret": client_secret,
"refresh_token": refresh_token,
@@ -840,20 +1130,24 @@ async def refresh_google_token(tool: ToolDefinition) -> Optional[str]:
tool.oauth_tokens["access_token"] = new_token
tool.oauth_tokens["token_expiry"] = time.time() + data.get("expires_in", 3600)
- if not tool.connected_account_email:
+ if not tool.connected_account_email and provider.userinfo_url:
try:
async with httpx.AsyncClient(timeout=10.0) as info_client:
info_resp = await info_client.get(
- GOOGLE_USERINFO_URL,
+ provider.userinfo_url,
headers={"Authorization": f"Bearer {new_token}"},
)
if info_resp.status_code == 200:
- tool.connected_account_email = info_resp.json().get("email")
+ tool.connected_account_email = info_resp.json().get(provider.userinfo_field)
except Exception:
pass
_save(tool)
return new_token
except Exception as e:
- logger.warning(f"Google token refresh failed for tool {tool.id}: {e}")
+ logger.warning(f"OAuth token refresh failed for tool {tool.id}: {e}")
return None
+
+
+# Backward-compatible alias
+refresh_google_token = refresh_oauth_token
diff --git a/electron/package-lock.json b/electron/package-lock.json
index 9309677e..ffbdda44 100644
--- a/electron/package-lock.json
+++ b/electron/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "openswarm",
- "version": "1.0.18",
+ "version": "1.0.20",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "openswarm",
- "version": "1.0.18",
+ "version": "1.0.20",
"hasInstallScript": true,
"dependencies": {
"electron-updater": "^6.3.0",
diff --git a/electron/package.json b/electron/package.json
index c9cfbd91..dad514fc 100644
--- a/electron/package.json
+++ b/electron/package.json
@@ -1,6 +1,6 @@
{
"name": "openswarm",
- "version": "1.0.19",
+ "version": "1.0.20",
"description": "OpenSwarm — AI Agent Orchestrator",
"main": "main.js",
"scripts": {
diff --git a/frontend/src/app/components/OnboardingModal.tsx b/frontend/src/app/components/OnboardingModal.tsx
index 749ce9c5..baac74b7 100644
--- a/frontend/src/app/components/OnboardingModal.tsx
+++ b/frontend/src/app/components/OnboardingModal.tsx
@@ -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(null);
const [nineRouterReady, setNineRouterReady] = useState(null);
+ const [connectedTools, setConnectedTools] = useState>(new Set());
const pollTimerRef = useRef(null);
const msgHandlerRef = useRef(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' ? (
+ <>
+
+ Connect Your Accounts
+
+
+ 10+ tools already active with no setup needed
+
+
+ Connect services below for even more capabilities
+
+
+
+ {ONBOARDING_TOOL_INTEGRATIONS.map((ig) => {
+ const isConnected = connectedTools.has(ig.name);
+ const isConnecting = connecting === ig.name;
+ return (
+ !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` } }),
+ }}
+ >
+
+ {ig.name}
+ {ig.desc}
+
+ {isConnected ? (
+
+ ) : (
+
+ {isConnecting ? 'Connecting...' : 'Connect \u2192'}
+
+ )}
+
+ );
+ })}
+
+
+
+ >
+ ) : (
+ <>
Welcome to OpenSwarm
@@ -268,6 +415,8 @@ const OnboardingModal: React.FC = () => {
>
Skip for now
+ >
+ )}
);
diff --git a/frontend/src/app/pages/Dashboard/AgentCard.tsx b/frontend/src/app/pages/Dashboard/AgentCard.tsx
index dc009af9..43e1616f 100644
--- a/frontend/src/app/pages/Dashboard/AgentCard.tsx
+++ b/frontend/src/app/pages/Dashboard/AgentCard.tsx
@@ -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 = ({
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 = ({
}
: { 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 (
+
+ {/* Header with close button */}
+ { e.stopPropagation(); onFocusExit?.(); }}
+ sx={{
+ display: 'flex', alignItems: 'center', justifyContent: 'space-between',
+ mb: 1, flexShrink: 0, cursor: 'default',
+ }}
+ >
+
+
+ {session.name || 'Agent'}
+
+
+ {session.model} · {formatDuration(session.created_at, undefined, session.status)}
+
+ onFocusExit?.()} sx={{ color: c.text.ghost }}>
+
+
+
+ {/* Chat fills remaining space */}
+
+
+
+
+ );
+ }
+
return (
= ({
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 = ({
)}
- {/* 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 }) => (
= ({
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 = ({
px: 2,
pt: 2,
pb: 1.5,
- cursor: isDragging ? 'grabbing' : 'grab',
+ cursor: isFocused ? 'default' : isDragging ? 'grabbing' : 'grab',
touchAction: 'none',
userSelect: 'none',
flexShrink: 0,
diff --git a/frontend/src/app/pages/Dashboard/Dashboard.tsx b/frontend/src/app/pages/Dashboard/Dashboard.tsx
index d20b6b97..b4f16d21 100644
--- a/frontend/src/app/pages/Dashboard/Dashboard.tsx
+++ b/frontend/src/app/pages/Dashboard/Dashboard.tsx
@@ -117,6 +117,7 @@ const DashboardInner: React.FC = () => {
const toolbarRef = useRef(null);
const [toolbarOpen, setToolbarOpen] = useState(false);
+ const [focusedCardId, setFocusedCardId] = useState(null);
const [highlightedCardId, setHighlightedCardId] = useState(null);
const highlightTimerRef = useRef | null>(null);
const [autoFocusSessionId, setAutoFocusSessionId] = useState(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([]);
+ 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 = () => {
{/* Floating zoom controls */}
-
-
-
+ {!focusedCardId && (
+
+
+
+ )}
+
+ {/* 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 (
+ <>
+
+
+ {}}
+ onMeasuredHeight={() => {}}
+ onBringToFront={() => {}}
+ isFocused={true}
+ onFocusRequest={handleFocusRequest}
+ onFocusExit={handleFocusExit}
+ autoFocusInput={true}
+ />
+
+ >
+ );
+ })()}
>
);
diff --git a/frontend/src/app/pages/Dashboard/useCanvasControls.ts b/frontend/src/app/pages/Dashboard/useCanvasControls.ts
index 71ebf214..1aa83b33 100644
--- a/frontend/src/app/pages/Dashboard/useCanvasControls.ts
+++ b/frontend/src/app/pages/Dashboard/useCanvasControls.ts
@@ -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 1–100 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.97–1.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,
diff --git a/frontend/src/app/pages/Tools/Tools.tsx b/frontend/src/app/pages/Tools/Tools.tsx
index 272b7688..301b49a7 100644
--- a/frontend/src/app/pages/Tools/Tools.tsx
+++ b/frontend/src/app/pages/Tools/Tools.tsx
@@ -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[] = [
),
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: (
+
+ ),
+ },
+ {
+ 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: (
+
+ ),
+ },
+ {
+ 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: (
+
+ ),
+ },
+ {
+ 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: (
+
+ ),
+ },
+ {
+ 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: (
+
+ ),
+ },
+ {
+ 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: (
+
+ ),
+ },
+ {
+ 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: (
+
+ ),
+ },
+ {
+ 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: (
+
+ ),
+ },
+ {
+ 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: (
+
+ ),
+ },
+ {
+ 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: (
+
+ ),
+ },
+ // --- 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: (
+
+ ),
+ 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: (
+
+ ),
+ 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: (
+
+ ),
+ 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: (
+
+ ),
+ 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: (
+
+ ),
+ 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: (
+
+ ),
+ 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: (
+
+ ),
+ 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: (
+
+ ),
+ 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: (
+
+ ),
+ 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: (
+
+ ),
},
];
@@ -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 = () => {
{ig.description}
- {isLoading && }
- handleIntegrationToggle(ig)}
- disabled={isLoading}
- sx={{
- '& .MuiSwitch-switchBase.Mui-checked': { color: ig.color },
- '& .MuiSwitch-switchBase.Mui-checked + .MuiSwitch-track': { bgcolor: ig.color },
- }}
- />
+ {ig.comingSoon ? (
+
+ ) : (
+ <>
+ {(ig.authType === 'oauth2' || ig.credentialFields) && (
+ : }
+ 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}`}
+
+ )}
+ {isLoading && !(ig.authType === 'oauth2' || ig.credentialFields) && }
+ handleIntegrationToggle(ig)}
+ disabled={isLoading}
+ sx={{
+ '& .MuiSwitch-switchBase.Mui-checked': { color: ig.color },
+ '& .MuiSwitch-switchBase.Mui-checked + .MuiSwitch-track': { bgcolor: ig.color },
+ }}
+ />
+ >
+ )}
@@ -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 (
@@ -1417,7 +1809,10 @@ const Tools: React.FC = () => {
{tool.description && {tool.description}}
- {!isDisabled && tool.auth_type === 'oauth2' && tool.auth_status !== 'connected' && (
+ {isComingSoon && (
+
+ )}
+ {!isComingSoon && !isDisabled && tool.auth_type === 'oauth2' && tool.auth_status !== 'connected' && (
)}
- {!isDisabled && ig?.credentialFields && tool.auth_status !== 'connected' && (
+ {!isComingSoon && !isDisabled && ig?.credentialFields && tool.auth_status !== 'connected' && (
)}
- {!isDisabled && ig && tool.auth_status === 'connected' && (
+ {!isComingSoon && !isDisabled && ig && tool.auth_status === 'connected' && (
}
@@ -1451,7 +1846,7 @@ const Tools: React.FC = () => {
/>
)}
- {ig && (
+ {ig && !isComingSoon && (
e.stopPropagation()}>
{!!integrationLoading[ig.id] && }
{