diff --git a/README.md b/README.md index ac0b2ca..e6817e6 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ ## Features - **Projects & Workspaces** — Persistent workspaces with knowledge graphs, file trees, and cross-conversation memory. Research accumulates across chats. -- **Interactive terminal** — Built-in terminal connected to the project workspace. Run commands directly alongside AI-driven research. +- **Interactive terminal** — Built-in terminal tab connected to the project workspace. Run commands directly alongside AI-driven research. - **Plan + Execute modes** — Plan mode gathers context; Execute mode does the work. Toggle with `Cmd+M`. - **Paper research** — OpenAlex, Semantic Scholar, arXiv, CrossRef, Papers With Code. Reads full papers, crawls citation graphs. - **Paper writing** — Section-by-section drafting with auto-save. Export to Markdown/LaTeX. @@ -28,7 +28,7 @@ - **Background jobs** — Celery + Redis. Close the browser, come back later. - **Multi-provider LLMs** — OpenAI, Anthropic, OpenRouter, plus local models (Ollama, LM Studio). Add custom providers with OpenAI SDK, Anthropic SDK, OpenRouter, or LiteLLM compatibility. - **Model picker** — Browse models grouped by provider with logos, sorted by release date. Recently used models at the top. Fetches live from [models.dev](https://models.dev). -- **MCP servers** — Connect external tools via the Model Context Protocol. +- **MCP servers** — Connect remote HTTP/HTTPS MCP servers with custom authentication (Bearer, API key, headers). - **Onboarding flow** — Guided setup when no LLM provider is configured. ## Quick Start diff --git a/backend/openmlr/app.py b/backend/openmlr/app.py index 3d485f6..4b05060 100644 --- a/backend/openmlr/app.py +++ b/backend/openmlr/app.py @@ -84,6 +84,7 @@ async def lifespan(app: FastAPI): from .routes.compute import router as compute_router from .routes.health import router as health_router from .routes.keys import router as keys_router +from .routes.mcp import router as mcp_router from .routes.projects import router as projects_router from .routes.settings import router as settings_router from .routes.terminal import router as terminal_router @@ -94,6 +95,7 @@ async def lifespan(app: FastAPI): app.include_router(health_router) app.include_router(keys_router) app.include_router(compute_router) +app.include_router(mcp_router) app.include_router(projects_router) app.include_router(terminal_router) diff --git a/backend/openmlr/dependencies.py b/backend/openmlr/dependencies.py index a584066..7ca4811 100644 --- a/backend/openmlr/dependencies.py +++ b/backend/openmlr/dependencies.py @@ -57,3 +57,19 @@ async def get_current_user( ) return user + + +async def get_current_user_optional( + credentials: HTTPAuthorizationCredentials = Depends(security), + db: AsyncSession = Depends(get_db), +) -> User | None: + """Like get_current_user but returns None instead of raising.""" + if credentials is None: + return None + payload = decode_access_token(credentials.credentials) + if payload is None: + return None + result = await db.execute( + select(User).where(User.id == int(payload["sub"]), User.is_active == True) + ) + return result.scalar_one_or_none() diff --git a/backend/openmlr/models.py b/backend/openmlr/models.py index 7a2f9d2..35c8870 100644 --- a/backend/openmlr/models.py +++ b/backend/openmlr/models.py @@ -75,6 +75,7 @@ class MessageSend(BaseModel): mode: Literal["plan", "execute"] | None = ( None # per-message mode; only plan or execute accepted ) + request_id: str | None = None # client-generated idempotency key class ApprovalRequest(BaseModel): diff --git a/backend/openmlr/routes/agent.py b/backend/openmlr/routes/agent.py index ec23863..e364d02 100644 --- a/backend/openmlr/routes/agent.py +++ b/backend/openmlr/routes/agent.py @@ -360,6 +360,32 @@ async def clear_conversation_compute( # ── Messaging ──────────────────────────────────────────── +# In-memory set of recently seen request IDs (with TTL-based eviction) +_recent_request_ids: dict[str, float] = {} +_REQUEST_ID_TTL = 30.0 # seconds + + +def _check_and_record_request_id(request_id: str | None) -> bool: + """Return True if this request_id is a duplicate. Records new IDs.""" + import time + + if not request_id: + return False # No idempotency key — allow through + + now = time.monotonic() + + # Evict expired entries (keep the dict small) + expired = [k for k, t in _recent_request_ids.items() if now - t > _REQUEST_ID_TTL] + for k in expired: + _recent_request_ids.pop(k, None) + + if request_id in _recent_request_ids: + return True # Duplicate + + _recent_request_ids[request_id] = now + return False + + @router.post("/message") async def send_message( body: MessageSend, @@ -369,6 +395,10 @@ async def send_message( ): from ..services.job_manager import USE_BACKGROUND_JOBS, get_job_manager + # Reject duplicate requests (idempotency guard) + if _check_and_record_request_id(body.request_id): + return {"ok": True, "duplicate": True} + sm = _sm(request) event_bus = _bus(request) job_manager = get_job_manager() diff --git a/backend/openmlr/routes/mcp.py b/backend/openmlr/routes/mcp.py new file mode 100644 index 0000000..7237dab --- /dev/null +++ b/backend/openmlr/routes/mcp.py @@ -0,0 +1,64 @@ +"""MCP server management routes — test connections and get status.""" + +import logging +from typing import Annotated + +from fastapi import APIRouter, Depends, HTTPException +from pydantic import BaseModel +from sqlalchemy.ext.asyncio import AsyncSession + +from ..db import operations as ops +from ..db.engine import get_db +from ..db.models import User +from ..dependencies import get_current_user +from ..tools.mcp import test_mcp_connection + +log = logging.getLogger(__name__) +router = APIRouter(prefix="/api/mcp", tags=["mcp"]) + + +class TestRequest(BaseModel): + url: str + headers: dict[str, str] | None = None + params: dict[str, str] | None = None + + +@router.post("/test", responses={400: {"description": "Invalid URL scheme"}}) +async def test_connection( + body: TestRequest, + user: Annotated[User, Depends(get_current_user)], +): + """Test an MCP server connection without saving it.""" + if not body.url.startswith(("http://", "https://")): + raise HTTPException(status_code=400, detail="Only http/https URLs are supported") + + result = await test_mcp_connection( + url=body.url, + headers=body.headers, + params=body.params, + ) + return result + + +@router.get("/status") +async def get_status( + user: Annotated[User, Depends(get_current_user)], + db: Annotated[AsyncSession, Depends(get_db)], +): + """Get configured MCP servers with their enabled/disabled state.""" + user_settings = await ops.get_all_settings(db, user.id, category="mcp") + mcp_settings = user_settings.get("mcp", {}) + servers_config = mcp_settings.get("servers", {}) + + servers = [] + for name, config in servers_config.items(): + servers.append( + { + "name": name, + "url": config.get("url", ""), + "enabled": config.get("enabled", True), + "connected": False, # Will be updated via SSE in real-time + } + ) + + return {"servers": servers} diff --git a/backend/openmlr/routes/projects.py b/backend/openmlr/routes/projects.py index 5cbc25f..9f5cc91 100644 --- a/backend/openmlr/routes/projects.py +++ b/backend/openmlr/routes/projects.py @@ -16,15 +16,18 @@ import shutil import uuid as uuid_mod from pathlib import Path +from typing import Annotated -from fastapi import APIRouter, Depends, HTTPException, Request, UploadFile +from fastapi import APIRouter, Depends, HTTPException, Query, Request, UploadFile from fastapi.responses import FileResponse +from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession +from ..auth.security import decode_access_token from ..db import operations as ops from ..db.engine import get_db from ..db.models import User -from ..dependencies import get_current_user +from ..dependencies import get_current_user, get_current_user_optional router = APIRouter(prefix="/api/projects", tags=["projects"]) @@ -434,15 +437,72 @@ async def list_files( return {"path": path, "entries": entries} -@router.get("/{project_uuid}/files/{file_path:path}") +async def _resolve_user( + token: str | None, + user: User | None, + db: AsyncSession, +) -> User: + """Resolve authenticated user from Bearer header or query-string token.""" + if user is not None: + return user + if token: + payload = decode_access_token(token) + if payload: + result = await db.execute( + select(User).where(User.id == int(payload["sub"]), User.is_active == True) + ) + found = result.scalar_one_or_none() + if found: + return found + raise HTTPException(status_code=401, detail="Not authenticated") + + +def _validate_symlink(target: Path, workspace_path: str) -> None: + """Reject symlinks that escape the workspace.""" + if not target.is_symlink(): + return + try: + target.resolve().relative_to(Path(workspace_path).resolve()) + except ValueError: + raise HTTPException(status_code=400, detail="Symlink points outside workspace") + + +def _try_read_text(target: Path, file_path: str) -> dict | None: + """Try to read a file as text. Returns JSON dict or None for binary.""" + mime, _ = mimetypes.guess_type(str(target)) + is_text = ( + mime is None + or mime.startswith("text/") + or mime in ("application/json", "application/xml", "application/x-yaml") + ) + if not is_text: + return None + try: + content = target.read_text(encoding="utf-8", errors="replace") + if len(content) > 500_000: + content = content[:500_000] + "\n\n[... truncated at 500KB ...]" + return {"path": file_path, "content": content, "size": target.stat().st_size} + except Exception: + return None + + +@router.get( + "/{project_uuid}/files/{file_path:path}", + responses={401: {"description": "Not authenticated"}}, +) async def read_file( project_uuid: str, file_path: str, - user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db), + token: str | None = Query(None), + user: Annotated[User | None, Depends(get_current_user_optional)] = None, + db: Annotated[AsyncSession, Depends(get_db)] = None, ): - """Read a file from the project workspace.""" - project = await ops.get_project_by_uuid(db, project_uuid, user.id) + """Read a file from the project workspace. + + Supports auth via Bearer header or ?token= query param (for tags). + """ + authed_user = await _resolve_user(token, user, db) + project = await ops.get_project_by_uuid(db, project_uuid, authed_user.id) if not project or not project.workspace_path: raise HTTPException(status_code=404, detail="Project not found") @@ -450,35 +510,13 @@ async def read_file( if not target.exists(): raise HTTPException(status_code=404, detail="File not found") if target.is_dir(): - return await list_files(project_uuid, file_path, user, db) + return await list_files(project_uuid, file_path, authed_user, db) - # Reject symlinks that point outside workspace - if target.is_symlink(): - try: - target.resolve().relative_to(Path(project.workspace_path).resolve()) - except ValueError: - raise HTTPException(status_code=400, detail="Symlink points outside workspace") + _validate_symlink(target, project.workspace_path) - # For text files, return content as JSON - mime, _ = mimetypes.guess_type(str(target)) - is_text = ( - mime is None - or mime.startswith("text/") - or mime in ("application/json", "application/xml", "application/x-yaml") - ) - - if is_text: - try: - content = target.read_text(encoding="utf-8", errors="replace") - if len(content) > 500_000: - content = content[:500_000] + "\n\n[... truncated at 500KB ...]" - return { - "path": file_path, - "content": content, - "size": target.stat().st_size, - } - except Exception: - pass + text_response = _try_read_text(target, file_path) + if text_response is not None: + return text_response return FileResponse(str(target), filename=target.name) diff --git a/backend/openmlr/services/session_manager.py b/backend/openmlr/services/session_manager.py index e3eb967..ccdc255 100644 --- a/backend/openmlr/services/session_manager.py +++ b/backend/openmlr/services/session_manager.py @@ -45,7 +45,7 @@ def __init__(self, event_bus: EventBus, default_config: AgentConfig): self.event_bus = event_bus self.default_config = default_config self.current_conversation_id: int | None = None - self._is_processing: bool = False + self._processing: set[int] = set() # per-conversation processing locks self._message_queues: dict[int, list[str]] = {} def get_session(self, conversation_id: int) -> ActiveSession | None: @@ -314,14 +314,15 @@ async def process_message( message: str, mode: str = None, ) -> None: - """Queue and process a user message.""" + """Queue and process a user message (per-conversation locking).""" queue = self._message_queues.setdefault(conversation_id, []) queue.append((message, mode)) - if self._is_processing: + # Per-conversation lock: if this conversation is already processing, just queue + if conversation_id in self._processing: return - self._is_processing = True + self._processing.add(conversation_id) await self.event_bus.broadcast( AgentEvent(event_type="status", data={"status": "thinking..."}) ) @@ -340,7 +341,7 @@ async def process_message( AgentEvent(event_type="error", data={"error": str(e)}) ) finally: - self._is_processing = False + self._processing.discard(conversation_id) await self.event_bus.broadcast( AgentEvent(event_type="status", data={"status": "ready"}) ) diff --git a/backend/openmlr/tasks/agent_tasks.py b/backend/openmlr/tasks/agent_tasks.py index c0abe86..277925f 100644 --- a/backend/openmlr/tasks/agent_tasks.py +++ b/backend/openmlr/tasks/agent_tasks.py @@ -12,7 +12,7 @@ logger = logging.getLogger("openmlr.tasks") -@celery_app.task(bind=True, name="openmlr.tasks.agent_tasks.process_agent_message") +@celery_app.task(bind=True, name="openmlr.tasks.agent_tasks.process_agent_message", acks_late=False) def process_agent_message( self, job_id: str, @@ -82,19 +82,34 @@ async def _async_process_message( # Get worker-specific session factory to avoid event loop conflicts worker_session = get_worker_session() - # Update job status to running + # Idempotency guard: check if this job was already started (Celery redelivery). + # If the worker crashed and Celery redelivers the task, the user message was + # already persisted on the first attempt — we must not save it again. async with worker_session() as db: + job = await ops.get_agent_job(db, job_id) + if job and job.status in ("completed", "failed"): + # Job already finished on a previous attempt — nothing to do + logger.warning( + f"Job {job_id}: redelivery detected (status={job.status}), skipping entirely" + ) + return + is_redelivery = job is not None and job.status == "running" + if is_redelivery: + logger.warning( + f"Job {job_id}: redelivery detected (status=running), skipping user message save" + ) + + # Update job status to running await ops.update_job_status(db, job_id, "running", worker_id=worker_id) # Load existing messages for context messages = await ops.get_messages(db, conversation_id) existing_messages = [{"role": m.role, "content": m.content} for m in messages] - # Increment user message count - await ops.increment_user_message_count(db, conversation_id) - - # Add user message to database - await ops.add_message(db, conversation_id, "user", message) + if not is_redelivery: + # First execution: persist the user message + await ops.increment_user_message_count(db, conversation_id) + await ops.add_message(db, conversation_id, "user", message) # Broadcast that we're processing await publish_event( @@ -140,8 +155,14 @@ async def _async_process_message( username="user", ) - # Load existing messages into context - for msg in existing_messages: + # Load existing messages into context. + # On redelivery, existing_messages already includes the user message we saved + # on the first attempt. Exclude the trailing user message so run_agent_turn + # can add it without duplication (it always adds the user message to context). + history = existing_messages + if is_redelivery and history and history[-1].get("role") == "user": + history = history[:-1] + for msg in history: session.context_manager.add_message(msg) # Wire event broadcasting to Redis pub/sub diff --git a/backend/openmlr/tools/mcp.py b/backend/openmlr/tools/mcp.py index ebb3048..87bd888 100644 --- a/backend/openmlr/tools/mcp.py +++ b/backend/openmlr/tools/mcp.py @@ -1,4 +1,8 @@ -"""MCP (Model Context Protocol) server integration.""" +"""MCP (Model Context Protocol) server integration. + +Only HTTP/HTTPS MCP servers are supported. Each server config can include +custom authentication via headers or query parameters. +""" import logging import os @@ -32,15 +36,50 @@ def process_mcp_config(config: dict) -> dict: return processed +def _build_url_with_params(url: str, params: dict[str, str] | None) -> str: + """Append query parameters to a URL.""" + if not params: + return url + from urllib.parse import parse_qs, urlencode, urlparse, urlunparse + + parsed = urlparse(url) + existing = parse_qs(parsed.query, keep_blank_values=True) + for k, v in params.items(): + existing[k] = [v] + new_query = urlencode({k: v[0] for k, v in existing.items()}) + return urlunparse(parsed._replace(query=new_query)) + + +def _create_mcp_client(url: str, headers: dict | None = None): + """Create a fastmcp Client with optional custom headers on the transport. + + Uses StreamableHttpTransport by default (POST-based, newer standard). + Falls back to SSETransport for URLs ending in /sse (legacy convention). + """ + from fastmcp import Client as MCPClient + from fastmcp.client.transports.http import StreamableHttpTransport + from fastmcp.client.transports.sse import SSETransport + + if headers: + # Pick transport based on URL convention (same logic as fastmcp's infer_transport) + if url.rstrip("/").endswith("/sse"): + transport = SSETransport(url=url, headers=headers) + else: + transport = StreamableHttpTransport(url=url, headers=headers) + return MCPClient(transport) + return MCPClient(url) + + class MCPManager: """ Manages MCP server connections for a session. - Supports connecting to multiple servers and registering their tools. + Only HTTP/HTTPS MCP servers are supported. """ def __init__(self): self._clients: dict[str, object] = {} # server_name -> client self._connected: set[str] = set() + self._configs: dict[str, dict] = {} # server_name -> processed config async def connect_servers( self, @@ -59,7 +98,7 @@ async def connect_servers( total_registered = 0 try: - from fastmcp import Client as MCPClient + from fastmcp import Client as _MCPClient # noqa: F401 — validates import except ImportError: log.warning("fastmcp not installed — MCP servers will not be available") return 0 @@ -67,6 +106,7 @@ async def connect_servers( for server_name, server_config in mcp_configs.items(): # Skip disabled servers if not server_config.get("enabled", True): + self._configs[server_name] = process_mcp_config(server_config) continue # Skip already connected servers @@ -74,25 +114,31 @@ async def connect_servers( continue config = process_mcp_config(server_config) - transport = config.get("transport", "http") + self._configs[server_name] = config url = config.get("url", "") - command = config.get("command", "") + + if not url: + log.warning(f"MCP server {server_name}: missing URL") + continue + + # Validate URL scheme + if not url.startswith(("http://", "https://")): + log.warning( + f"MCP server {server_name}: only http/https URLs are supported, got {url[:20]}" + ) + continue try: - if transport == "http" and url: - client = MCPClient(url) - elif transport == "stdio" and command: - args = config.get("args", []) - env = config.get("env", {}) - # Merge environment variables - full_env = {**os.environ, **env} if env else None - client = MCPClient(command, args=args, env=full_env) - else: - log.warning(f"MCP server {server_name}: invalid config (transport={transport})") - continue + # Build URL with query params if configured + params = config.get("params") + final_url = _build_url_with_params(url, params) + + # Build headers dict if configured + headers = config.get("headers") or None + + client = _create_mcp_client(final_url, headers) # Connect and register tools - # Note: We keep the client connection open for the session await client.__aenter__() count = await tool_router.register_mcp_tools( client, @@ -121,12 +167,54 @@ async def disconnect_all(self): self._clients.clear() self._connected.clear() + self._configs.clear() @property def connected_servers(self) -> set[str]: """Return names of connected MCP servers.""" return self._connected.copy() + def get_server_statuses(self) -> list[dict]: + """Return status info for all known servers.""" + statuses = [] + for name, config in self._configs.items(): + statuses.append( + { + "name": name, + "url": config.get("url", ""), + "enabled": config.get("enabled", True), + "connected": name in self._connected, + } + ) + return statuses + + +async def test_mcp_connection( + url: str, headers: dict | None = None, params: dict | None = None +) -> dict: + """ + Test an MCP server connection. + Returns {"ok": True, "tools": int} on success or {"ok": False, "error": str} on failure. + """ + try: + from fastmcp import Client as _MCPClient # noqa: F401 — validates import + except ImportError: + return {"ok": False, "error": "fastmcp not installed on server"} + + final_url = _build_url_with_params(url, params) + + try: + client = _create_mcp_client(final_url, headers) + await client.__aenter__() + try: + tools = await client.list_tools() + tool_count = len(tools) if tools else 0 + return {"ok": True, "tools": tool_count} + finally: + await client.__aexit__(None, None, None) + except Exception as e: + return {"ok": False, "error": str(e)} + # Legacy function for backward compatibility async def connect_mcp_servers( diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 59f5673..d6fbdfb 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -45,6 +45,9 @@ dependencies = [ # Background jobs "celery>=5.4.0", "redis>=5.0.0", + + # MCP (Model Context Protocol) + "fastmcp>=2.0.0", ] [project.optional-dependencies] diff --git a/backend/tests/test_session_manager.py b/backend/tests/test_session_manager.py index c9a4386..70120a8 100644 --- a/backend/tests/test_session_manager.py +++ b/backend/tests/test_session_manager.py @@ -32,7 +32,7 @@ def session_manager(event_bus, config): class TestSessionManager: async def test_initial_state(self, session_manager): assert session_manager.current_conversation_id is None - assert session_manager.is_processing is False + assert len(session_manager._processing) == 0 assert session_manager.get_current_session() is None async def test_get_session_nonexistent(self, session_manager): diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 4c51401..cdc0285 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,12 +1,12 @@ import { useState, useCallback, useEffect, useRef } from 'react'; import { Routes, Route, Navigate, useNavigate, useParams } from 'react-router-dom'; -import { Copy, Check } from 'lucide-react'; +import { Copy, Check, Menu, PanelRightOpen } from 'lucide-react'; import { ComputeSelector } from './components/ComputeSelector'; import { ProjectSelector } from './components/ProjectSelector'; import { useSSE } from './hooks/useSSE'; import { useJobStatus } from './hooks/useJobStatus'; import { api } from './api'; -import type { AgentEvent, Message, Conversation, User, QuestionsPayload, PlanTask, Resource, ContextUsage, SearchBudget, Project, TodoApprovalPayload, OpenFile } from './types'; +import type { AgentEvent, Message, Conversation, User, QuestionsPayload, PlanTask, Resource, ContextUsage, SearchBudget, Project, TodoApprovalPayload, OpenFile, McpServerStatus } from './types'; import { MessageList } from './components/MessageList'; import { InputArea, type Mode } from './components/InputArea'; import { Sidebar } from './components/Sidebar'; @@ -29,6 +29,7 @@ import { McpSettings } from './components/settings/McpSettings'; import { ComputeSettings } from './components/settings/ComputeSettings'; import { WritingSettings } from './components/settings/WritingSettings'; import { EditorPanel } from './components/EditorPanel'; +import { ImageViewer } from './components/ImageViewer'; let msgId = 0; const nextId = () => `msg-${++msgId}`; @@ -41,7 +42,14 @@ function findLastIndex(arr: T[], predicate: (item: T) => boolean): number { return -1; } type ConvStatus = 'idle' | 'processing' | 'waiting_approval' | 'waiting_input'; -type MainTab = 'agent' | 'editor'; +type MainTab = 'agent' | 'editor' | 'terminal' | 'image'; + +const IMAGE_EXTENSIONS = new Set(['.png', '.jpg', '.jpeg', '.gif', '.svg', '.webp', '.bmp', '.ico']); + +function isImageFile(path: string): boolean { + const ext = '.' + (path.split('.').pop()?.toLowerCase() || ''); + return IMAGE_EXTENSIONS.has(ext); +} /** Map file extensions to Monaco language IDs. */ function detectLanguage(path: string): string { @@ -128,13 +136,16 @@ function ChatUI({ const [activeProject, setActiveProject] = useState(null); const [showProjectModal, setShowProjectModal] = useState(false); const [showManageProjects, setShowManageProjects] = useState(false); - const [terminalOpen, setTerminalOpen] = useState(false); const [terminalConnected, setTerminalConnected] = useState(false); const [todoApprovalPayload, setTodoApprovalPayload] = useState(null); const [fileTreeRefreshKey, setFileTreeRefreshKey] = useState(0); const [mainTab, setMainTab] = useState('agent'); const [openFiles, setOpenFiles] = useState([]); const [activeFilePath, setActiveFilePath] = useState(null); + const [imageTab, setImageTab] = useState<{ path: string; url: string } | null>(null); + const [mcpServers, setMcpServers] = useState([]); + const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false); + const [mobileRightOpen, setMobileRightOpen] = useState(false); // Refs to always have current values in SSE callback (avoids stale closure) const currentConvUuidRef = useRef(currentConvUuid); @@ -146,6 +157,10 @@ function ChatUI({ const switchSeqRef = useRef(0); // Timer ref so pending reload timeouts can be cleared on conversation switch const reloadTimerRef = useRef | null>(null); + // Guard against concurrent/duplicate message sends + const sendingRef = useRef(false); + // Debounce timer for reloadConversationMessages + const reloadDebounceRef = useRef | null>(null); // ── Derived per-conversation processing state ───────── const currentStatus = currentConvUuid ? (convStatuses[currentConvUuid] || 'idle') : 'idle'; @@ -188,6 +203,15 @@ function ChatUI({ } }, []); + const loadMcpServers = useCallback(async () => { + try { + const data = await api.getMcpStatus(); + setMcpServers(data.servers || []); + } catch { + setMcpServers([]); + } + }, []); + const loadActiveCompute = useCallback(async (uuid: string) => { try { const data = await api.getConversationCompute(uuid); @@ -203,6 +227,7 @@ function ChatUI({ const [, projData] = await Promise.all([ loadComputeNodes(), api.listProjects().catch(() => ({ projects: [] })), + loadMcpServers(), ]); const allProjects: Project[] = projData.projects || []; setProjects(allProjects); @@ -375,8 +400,8 @@ function ChatUI({ } catch { /* */ } }, [currentConvUuid, loadActiveCompute]); - // Helper to reload messages from DB for a given conversation - const reloadConversationMessages = useCallback(async (uuid: string) => { + // Helper to reload messages from DB for a given conversation (raw, no debounce) + const _doReloadMessages = useCallback(async (uuid: string) => { try { const data = await api.getConversation(uuid); // Guard: only apply if this is still the active conversation @@ -398,6 +423,15 @@ function ChatUI({ } catch { /* ignore */ } }, []); + // Debounced version: collapses rapid successive reloads into one (300ms window) + const reloadConversationMessages = useCallback((uuid: string) => { + if (reloadDebounceRef.current) clearTimeout(reloadDebounceRef.current); + reloadDebounceRef.current = setTimeout(() => { + reloadDebounceRef.current = null; + _doReloadMessages(uuid); + }, 300); + }, [_doReloadMessages]); + // ── Auto-compact when context usage > 90% ───────────── const lastCompactRef = useRef(0); useEffect(() => { @@ -565,6 +599,8 @@ function ChatUI({ case 'todo_approval_required': setTodoApprovalPayload(data as TodoApprovalPayload); setCurrentConvStatus('waiting_approval'); break; case 'turn_complete': setApprovalEvent(null); setTodoApprovalPayload(null); + // Cancel any pending job_complete reload — SSE events already updated state + if (reloadTimerRef.current) { clearTimeout(reloadTimerRef.current); reloadTimerRef.current = null; } setMessages((prev) => { const c = prev.filter((m) => !(m.role === 'system' && m.content === '::thinking::')); const last = c[c.length - 1]; @@ -642,22 +678,37 @@ function ChatUI({ }, [currentConvUuid, jobProcessing, connected]); const sendMessage = useCallback(async (text: string, mode: string) => { + // Prevent concurrent/duplicate sends + if (sendingRef.current) return; + sendingRef.current = true; + setInputText(''); setMessages((prev) => [...prev, { id: nextId(), role: 'user', content: text, metadata: { tool: mode } }]); setCurrentConvStatus('processing'); - try { await api.sendMessage(text, mode); } catch (err: any) { + try { + await api.sendMessage(text, mode); + } catch (err: any) { setCurrentConvStatus('idle'); setMessages((prev) => [...prev, { id: nextId(), role: 'error', content: `Failed to send: ${err.message}` }]); + } finally { + sendingRef.current = false; } }, [setCurrentConvStatus]); const handleFileOpen = useCallback((path: string, content: string) => { + // Images open in a dedicated Image tab + if (isImageFile(path) && activeProject?.uuid) { + const url = api.fileUrl(activeProject.uuid, path); + setImageTab({ path, url }); + setMainTab('image'); + return; + } setOpenFiles((prev) => { if (prev.some((f) => f.path === path)) return prev; return [...prev, { path, content, language: detectLanguage(path) }]; }); setActiveFilePath(path); setMainTab('editor'); - }, []); + }, [activeProject]); const handleCloseFile = useCallback((path: string) => { setOpenFiles((prev) => { @@ -688,8 +739,16 @@ function ChatUI({ {/* Header */}
+ {/* Mobile sidebar toggle */} + OpenMLR - OpenMLR + OpenMLR + {/* Mobile right panel toggle */} +
@@ -718,19 +785,17 @@ function ChatUI({ setTerminalOpen((v) => !v)} + onMobileClose={() => setMobileSidebarOpen(false)} />
- {/* Agent / Editor tab bar */} + {/* Agent / Editor / Terminal tab bar */}
+ + {/* Closable Image tab */} + {imageTab && ( +
+ + +
+ )}
{/* Agent tab */} @@ -819,20 +916,28 @@ function ChatUI({ onCloseFile={handleCloseFile} /> )} + + {/* Terminal tab */} + {mainTab === 'terminal' && ( + + )} + + {/* Image tab */} + {mainTab === 'image' && imageTab && ( + + )}
{/* RightPanel is fixed position, doesn't affect flex layout */} - setRightPanelOpen((v) => !v)} onViewReport={(r) => setViewingReport(r)} onFileOpen={handleFileOpen} onSearchBudgetChange={(newMax) => setSearchBudget((prev) => prev ? { ...prev, max: newMax } : prev)} /> + setRightPanelOpen((v) => !v)} onMobileClose={() => setMobileRightOpen(false)} onViewReport={(r) => setViewingReport(r)} onFileOpen={handleFileOpen} onSearchBudgetChange={(newMax) => setSearchBudget((prev) => prev ? { ...prev, max: newMax } : prev)} /> - - {/* Terminal panel */} - setTerminalOpen((v) => !v)} - onConnectionChange={setTerminalConnected} - rightOffset={rightPanelOpen ? 288 : 48} - /> {viewingReport && setViewingReport(null)} />} {showProjectModal && setShowProjectModal(false)} onCreate={async (p) => { diff --git a/frontend/src/__tests__/FileTree.test.tsx b/frontend/src/__tests__/FileTree.test.tsx index c32f370..01998db 100644 --- a/frontend/src/__tests__/FileTree.test.tsx +++ b/frontend/src/__tests__/FileTree.test.tsx @@ -35,13 +35,6 @@ describe('FileTree', () => { expect(screen.getByText('Loading files...')).toBeInTheDocument(); }); - it('renders refresh button', async () => { - render(); - await waitFor(() => { - expect(screen.getByTitle('Refresh')).toBeInTheDocument(); - }); - }); - it('shows "No files yet" when directory is empty', async () => { const { api } = await import('../api'); vi.mocked(api.listFiles).mockResolvedValueOnce({ entries: [] }); @@ -121,11 +114,4 @@ describe('FileTree', () => { expect(onFileSelect).not.toHaveBeenCalled(); }); - it('renders Files header', async () => { - render(); - - await waitFor(() => { - expect(screen.getByText('Files')).toBeInTheDocument(); - }); - }); }); diff --git a/frontend/src/__tests__/RightPanel.test.tsx b/frontend/src/__tests__/RightPanel.test.tsx index 14b3803..40020c8 100644 --- a/frontend/src/__tests__/RightPanel.test.tsx +++ b/frontend/src/__tests__/RightPanel.test.tsx @@ -1,7 +1,7 @@ import { describe, it, expect, vi } from 'vitest'; import { render, screen } from '@testing-library/react'; import { RightPanel } from '../components/RightPanel'; -import type { PlanTask, Resource, ContextUsage, SearchBudget } from '../types'; +import type { PlanTask, Resource, ContextUsage, SearchBudget, McpServerStatus } from '../types'; vi.mock('../api', () => ({ api: { @@ -24,19 +24,23 @@ describe('RightPanel', () => { const mockContext: ContextUsage = { used: 50000, max: 200000, ratio: 0.25 }; const mockSearchBudget: SearchBudget = { used: 5, max: 25 }; + const mockMcpServers: McpServerStatus[] = [ + { name: 'test-server', url: 'https://mcp.example.com', enabled: true, connected: true }, + ]; + + const baseProps = { + resources: [] as Resource[], + contextUsage: null as ContextUsage | null, + searchBudget: null as SearchBudget | null, + mcpServers: [] as McpServerStatus[], + projectUuid: null as string | null, + onToggle: vi.fn(), + onViewReport: vi.fn(), + }; it('renders collapsed rail with expand button when not visible', () => { render( - + ); expect(screen.getByTitle('Expand panel')).toBeInTheDocument(); expect(screen.getByTitle('Todos')).toBeInTheDocument(); @@ -44,16 +48,7 @@ describe('RightPanel', () => { it('renders tasks when visible', () => { render( - + ); expect(screen.getByText('Read papers')).toBeInTheDocument(); expect(screen.getByText('Implement model')).toBeInTheDocument(); @@ -62,49 +57,21 @@ describe('RightPanel', () => { it('shows task completion count badge', () => { render( - + ); - // CollapsiblePanel renders badge with "done/total" expect(screen.getByText('1/3')).toBeInTheDocument(); }); it('shows "No tasks yet" when empty', () => { render( - + ); expect(screen.getByText('No tasks yet')).toBeInTheDocument(); }); it('renders context gauge with data', () => { render( - + ); expect(screen.getByText(/50k/)).toBeInTheDocument(); expect(screen.getByText(/200k/)).toBeInTheDocument(); @@ -112,101 +79,48 @@ describe('RightPanel', () => { it('renders context gauge placeholder when null', () => { render( - + ); expect(screen.getByText('Context: --')).toBeInTheDocument(); }); it('renders search budget gauge', () => { render( - + ); expect(screen.getByText(/Searches:/)).toBeInTheDocument(); }); it('renders default search budget when null', () => { render( - + ); expect(screen.getByText('Searches: 0 / 25')).toBeInTheDocument(); }); it('does not render resources section (resources are now in FileTree)', () => { render( - + ); - // Resources section was removed — resources now appear as files in the workspace expect(screen.queryByText('No resources yet')).not.toBeInTheDocument(); }); it('does not render paper export buttons (papers are now in FileTree)', () => { render( ); - // Paper export buttons were removed — papers are now files in workspace expect(screen.queryByText('.md')).not.toBeInTheDocument(); expect(screen.queryByText('.tex')).not.toBeInTheDocument(); }); it('shows task count badge on collapsed rail', () => { render( - + ); const todosButton = screen.getByTitle('Todos'); const badge = todosButton.querySelector('span'); @@ -215,17 +129,30 @@ describe('RightPanel', () => { it('has search budget settings button', () => { render( - + ); expect(screen.getByTitle('Change search budget')).toBeInTheDocument(); }); + + it('renders MCP servers section when servers are configured', () => { + render( + + ); + expect(screen.getByText('MCP Servers')).toBeInTheDocument(); + expect(screen.getByText('test-server')).toBeInTheDocument(); + }); + + it('shows MCP server count badge on collapsed rail', () => { + render( + + ); + expect(screen.getByTitle('MCP Servers')).toBeInTheDocument(); + }); + + it('does not render MCP section when no servers configured', () => { + render( + + ); + expect(screen.queryByText('MCP Servers')).not.toBeInTheDocument(); + }); }); diff --git a/frontend/src/__tests__/Sidebar.test.tsx b/frontend/src/__tests__/Sidebar.test.tsx index 690e90b..7f86fd6 100644 --- a/frontend/src/__tests__/Sidebar.test.tsx +++ b/frontend/src/__tests__/Sidebar.test.tsx @@ -47,13 +47,9 @@ const defaultProps = { currentUuid: null as string | null, user: mockUser, convStatuses: {} as Record, - terminalOpen: false, - terminalConnected: false, - terminalSessionCount: 0, onSwitch: vi.fn(), onNew: vi.fn(), onDelete: vi.fn(), - onTerminalToggle: vi.fn(), }; describe('Sidebar', () => { @@ -149,44 +145,4 @@ describe('Sidebar', () => { expect(onNew).toHaveBeenCalled(); }); - it('renders terminal button with Closed status when terminal is not open', () => { - render( - - - - ); - expect(screen.getByText('Terminal')).toBeInTheDocument(); - expect(screen.getByText('Closed')).toBeInTheDocument(); - }); - - it('renders terminal button with Connected status when terminal is open and connected', () => { - render( - - - - ); - expect(screen.getByText('Terminal')).toBeInTheDocument(); - expect(screen.getByText('Connected')).toBeInTheDocument(); - expect(screen.getByText('1')).toBeInTheDocument(); - }); - - it('renders terminal button with Disconnected when terminal is open but not connected', () => { - render( - - - - ); - expect(screen.getByText('Disconnected')).toBeInTheDocument(); - }); - - it('calls onTerminalToggle when terminal button clicked', () => { - const onTerminalToggle = vi.fn(); - render( - - - - ); - fireEvent.click(screen.getByText('Terminal')); - expect(onTerminalToggle).toHaveBeenCalled(); - }); }); diff --git a/frontend/src/__tests__/api.test.ts b/frontend/src/__tests__/api.test.ts index 15400b8..381adc5 100644 --- a/frontend/src/__tests__/api.test.ts +++ b/frontend/src/__tests__/api.test.ts @@ -53,7 +53,10 @@ describe('api.sendMessage', () => { const [url, opts] = fetchMock.mock.calls[0]; expect(url).toBe('/api/message'); expect(opts.method).toBe('POST'); - expect(JSON.parse(opts.body)).toEqual({ message: 'Hello', mode: 'general' }); + const body = JSON.parse(opts.body); + expect(body.message).toBe('Hello'); + expect(body.mode).toBe('general'); + expect(body.request_id).toBeDefined(); }); }); diff --git a/frontend/src/api.ts b/frontend/src/api.ts index 7fd7afb..cf9a6ba 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -70,7 +70,8 @@ export const api = { getMe: () => get('/api/auth/me'), // Messages - sendMessage: (message: string, mode?: string) => post('/api/message', { message, mode }), + sendMessage: (message: string, mode?: string) => + post('/api/message', { message, mode, request_id: crypto.randomUUID() }), submitAnswers: (answers: Record) => post('/api/answers', { answers }), interrupt: () => post('/api/interrupt', {}), sendApproval: (approvals: Record) => post('/api/approval', { approvals }), @@ -136,6 +137,12 @@ export const api = { get(`/api/projects/${projectUuid}/files${path ? `?path=${encodeURIComponent(path)}` : ''}`), readFile: (projectUuid: string, filePath: string) => get(`/api/projects/${projectUuid}/files/${encodeURIComponent(filePath)}`), + /** Build an authenticated URL for directly loading a binary file (e.g. images). */ + fileUrl: (projectUuid: string, filePath: string): string => { + const token = getToken(); + const base = `/api/projects/${projectUuid}/files/${encodeURIComponent(filePath)}`; + return token ? `${base}?token=${token}` : base; + }, writeFile: (projectUuid: string, filePath: string, content: string) => put(`/api/projects/${projectUuid}/files/${encodeURIComponent(filePath)}`, { content }), deleteFile: (projectUuid: string, filePath: string) => @@ -152,4 +159,9 @@ export const api = { post('/api/compute/test', { type, config }), probeComputeNode: (id: number) => post(`/api/compute/nodes/${id}/probe`, {}), setDefaultComputeNode: (id: number) => post(`/api/compute/nodes/${id}/set-default`, {}), + + // MCP Servers + getMcpStatus: () => get('/api/mcp/status'), + testMcpServer: (url: string, headers?: Record, params?: Record) => + post('/api/mcp/test', { url, headers: headers || null, params: params || null }), }; diff --git a/frontend/src/components/FileTree.tsx b/frontend/src/components/FileTree.tsx index 7b6aa93..526000b 100644 --- a/frontend/src/components/FileTree.tsx +++ b/frontend/src/components/FileTree.tsx @@ -29,6 +29,8 @@ interface TreeNode extends FileNode { expanded?: boolean; } +const IMAGE_EXTS = new Set(['.png', '.jpg', '.jpeg', '.gif', '.svg', '.webp', '.bmp', '.ico']); + const FILE_ICONS: Record = { '.py': , '.js': , @@ -240,6 +242,12 @@ export function FileTree({ projectUuid, refreshKey, onFileSelect }: Props) { }, [loadDirectory]); const handleSelect = useCallback(async (path: string) => { + const ext = '.' + (path.split('.').pop()?.toLowerCase() || ''); + if (IMAGE_EXTS.has(ext)) { + // For image files, pass through with empty content — the parent handles the URL + onFileSelect?.(path, ''); + return; + } try { const data = await api.readFile(projectUuid, path); if (data.content !== undefined) { @@ -250,13 +258,16 @@ export function FileTree({ projectUuid, refreshKey, onFileSelect }: Props) { } }, [projectUuid, onFileSelect]); - const handleRefresh = useCallback(async () => { - setLoading(true); - setError(null); + // Auto-refresh every 10 seconds + const refreshFiles = useCallback(async () => { const entries = await loadDirectory(''); - setNodes(entries); - setLoading(false); - }, [loadDirectory]); + setNodes((prev) => mergeWithPreviousState(entries, prev)); + }, [loadDirectory, mergeWithPreviousState]); + + useEffect(() => { + const interval = setInterval(refreshFiles, 10000); + return () => clearInterval(interval); + }, [refreshFiles]); if (loading) { return ( @@ -278,18 +289,6 @@ export function FileTree({ projectUuid, refreshKey, onFileSelect }: Props) { return (
- {/* Header */} -
- Files - -
- {/* Tree */}
{nodes.length === 0 ? ( diff --git a/frontend/src/components/ImageViewer.tsx b/frontend/src/components/ImageViewer.tsx new file mode 100644 index 0000000..36f9089 --- /dev/null +++ b/frontend/src/components/ImageViewer.tsx @@ -0,0 +1,145 @@ + +import { useState, useRef, useCallback, useEffect } from 'react'; +import { ZoomIn, ZoomOut, RotateCw, Maximize2, Minimize2 } from 'lucide-react'; + +interface Props { + readonly src: string; + readonly filename: string; +} + +export function ImageViewer({ src, filename }: Props) { + const [zoom, setZoom] = useState(1); + const [rotation, setRotation] = useState(0); + const [dragging, setDragging] = useState(false); + const [offset, setOffset] = useState({ x: 0, y: 0 }); + const [dragStart, setDragStart] = useState({ x: 0, y: 0 }); + const [naturalSize, setNaturalSize] = useState<{ w: number; h: number } | null>(null); + const [fitMode, setFitMode] = useState(true); + const containerRef = useRef(null); + + const handleWheel = useCallback((e: React.WheelEvent) => { + e.preventDefault(); + setZoom((z) => Math.min(Math.max(z + (e.deltaY > 0 ? -0.1 : 0.1), 0.1), 10)); + setFitMode(false); + }, []); + + const handleMouseDown = useCallback((e: React.MouseEvent) => { + if (e.button !== 0) return; + setDragging(true); + setDragStart({ x: e.clientX - offset.x, y: e.clientY - offset.y }); + }, [offset]); + + const handleMouseMove = useCallback((e: React.MouseEvent) => { + if (!dragging) return; + setOffset({ x: e.clientX - dragStart.x, y: e.clientY - dragStart.y }); + }, [dragging, dragStart]); + + const handleMouseUp = useCallback(() => { + setDragging(false); + }, []); + + const resetView = useCallback(() => { + setZoom(1); + setRotation(0); + setOffset({ x: 0, y: 0 }); + setFitMode(true); + }, []); + + const handleLoad = useCallback((e: React.SyntheticEvent) => { + const img = e.currentTarget; + setNaturalSize({ w: img.naturalWidth, h: img.naturalHeight }); + }, []); + + // Reset view when src changes + useEffect(() => { + resetView(); + }, [src, resetView]); + + return ( +
+ {/* Toolbar */} +
+
+ {filename} + {naturalSize && ( + + {naturalSize.w} x {naturalSize.h} + + )} +
+
+ + {Math.round(zoom * 100)}% + + + + +
+
+ + {/* Image canvas */} +
{ + if (e.key === '+' || e.key === '=') { setZoom((z) => Math.min(z + 0.25, 10)); setFitMode(false); } + if (e.key === '-') { setZoom((z) => Math.max(z - 0.25, 0.1)); setFitMode(false); } + if (e.key === '0') resetView(); + }} + > + {filename} +
+
+ ); +} diff --git a/frontend/src/components/RightPanel.tsx b/frontend/src/components/RightPanel.tsx index 3b6849c..cc3d07e 100644 --- a/frontend/src/components/RightPanel.tsx +++ b/frontend/src/components/RightPanel.tsx @@ -10,21 +10,25 @@ import { Settings, PanelRightClose, PanelRightOpen, + Plug, } from 'lucide-react'; import { api } from '../api'; import { FileTree } from './FileTree'; import { CollapsiblePanel } from './CollapsiblePanel'; -import type { PlanTask, Resource, ContextUsage, SearchBudget } from '../types'; +import type { PlanTask, Resource, ContextUsage, SearchBudget, McpServerStatus } from '../types'; interface Props { readonly tasks: readonly PlanTask[]; readonly resources: readonly Resource[]; readonly contextUsage: ContextUsage | null; readonly searchBudget: SearchBudget | null; + readonly mcpServers: readonly McpServerStatus[]; readonly visible: boolean; + readonly mobileOpen?: boolean; readonly projectUuid: string | null; readonly fileTreeRefreshKey?: number; onToggle: () => void; + onMobileClose?: () => void; onViewReport: (resource: Resource) => void; onFileOpen?: (path: string, content: string) => void; onSearchBudgetChange?: (newMax: number) => void; @@ -104,7 +108,13 @@ function SearchBudgetDialog({ currentMax, onSave, onClose }: { currentMax: numbe ); } -export function RightPanel({ tasks, resources: _resources, contextUsage, searchBudget, visible, projectUuid, fileTreeRefreshKey, onToggle, onViewReport: _onViewReport, onFileOpen, onSearchBudgetChange }: Props) { +function mcpDotColor(server: McpServerStatus): string { + if (server.connected) return 'bg-success'; + if (server.enabled) return 'bg-warning'; + return 'bg-text-dim'; +} + +export function RightPanel({ tasks, resources: _resources, contextUsage, searchBudget, mcpServers, visible, mobileOpen, projectUuid, fileTreeRefreshKey, onToggle, onMobileClose, onViewReport: _onViewReport, onFileOpen, onSearchBudgetChange }: Props) { const [showBudgetDialog, setShowBudgetDialog] = useState(false); const done = tasks.filter((t) => t.status === 'completed').length; @@ -114,49 +124,9 @@ export function RightPanel({ tasks, resources: _resources, contextUsage, searchB const budgetMax = searchBudget?.max ?? 25; const budgetPct = budgetMax > 0 ? Math.round((budgetUsed / budgetMax) * 100) : 0; - // Collapsed state: show narrow icon rail - if (!visible) { - return ( - - ); - } - - // Expanded state — no tabs, everything stacked - return ( - + + ); + } + + // Collapsed state: show narrow icon rail + if (!visible) { + return ( + + ); + } + + // Expanded state — no tabs, everything stacked + return ( + ); } diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx index d01d960..0c38ee3 100644 --- a/frontend/src/components/Sidebar.tsx +++ b/frontend/src/components/Sidebar.tsx @@ -12,7 +12,7 @@ import { Settings, LogOut, Trash2, - Terminal, + X, } from 'lucide-react'; type ConvStatus = 'idle' | 'processing' | 'waiting_approval' | 'waiting_input'; @@ -22,13 +22,11 @@ interface Props { currentUuid: string | null; user: User | null; convStatuses: Record; - terminalOpen: boolean; - terminalConnected: boolean; - terminalSessionCount: number; + mobileOpen?: boolean; onSwitch: (uuid: string) => void; onNew: (mode?: string) => void; onDelete: (uuid: string) => void; - onTerminalToggle: () => void; + onMobileClose?: () => void; } function groupByDate(conversations: readonly Conversation[]) { @@ -58,24 +56,41 @@ function ConvIcon({ status }: { status: ConvStatus }) { return ; } -function TerminalStatusLabel({ terminalOpen, terminalConnected }: { terminalOpen: boolean; terminalConnected: boolean }) { - if (!terminalOpen) { - return ( - - Closed - - ); - } +function ConvItem({ conv, isCurrent, status, onSwitch, onDelete }: { + conv: Conversation; + isCurrent: boolean; + status: ConvStatus; + onSwitch: () => void; + onDelete: () => void; +}) { return ( - - {terminalConnected ? 'Connected' : 'Disconnected'} - +
+ +
); } -export function Sidebar({ conversations, currentUuid, user, convStatuses, terminalOpen, terminalConnected, terminalSessionCount, onSwitch, onNew, onDelete, onTerminalToggle }: Props) { +export function Sidebar({ conversations, currentUuid, user, convStatuses, mobileOpen, onSwitch, onNew, onDelete, onMobileClose }: Props) { const navigate = useNavigate(); const [pendingDelete, setPendingDelete] = useState<{ uuid: string; title: string } | null>(null); const [search, setSearch] = useState(''); @@ -89,57 +104,31 @@ export function Sidebar({ conversations, currentUuid, user, convStatuses, termin const groups = useMemo(() => groupByDate(filtered), [filtered]); - // Terminal status dot color for collapsed rail - const getTermDotColor = (open: boolean, connected: boolean): string => { - if (!open) return 'bg-text-dim'; - return connected ? 'bg-success' : 'bg-error'; - }; - const termDotColor = getTermDotColor(terminalOpen, terminalConnected); - - if (collapsed) { - return ( - - ); - } - - return ( -
))} @@ -205,26 +173,10 @@ export function Sidebar({ conversations, currentUuid, user, convStatuses, termin {/* Footer */}
- {/* Terminal button */} - -
+ +
+ + ); + } + + return ( + ); } diff --git a/frontend/src/components/Terminal.tsx b/frontend/src/components/Terminal.tsx index 41a3ba6..13b97f5 100644 --- a/frontend/src/components/Terminal.tsx +++ b/frontend/src/components/Terminal.tsx @@ -2,7 +2,6 @@ import { useEffect, useRef, useState, useCallback } from 'react'; import { Terminal as TerminalIcon, - X, Maximize2, Minimize2, } from 'lucide-react'; @@ -14,12 +13,10 @@ import '@xterm/xterm/css/xterm.css'; interface Props { readonly projectUuid: string | null; readonly visible: boolean; - readonly onToggle: () => void; readonly onConnectionChange?: (connected: boolean) => void; - readonly rightOffset?: number; } -export function Terminal({ projectUuid, visible, onToggle, onConnectionChange, rightOffset = 0 }: Props) { +export function Terminal({ projectUuid, visible, onConnectionChange }: Props) { const [connected, setConnected] = useState(false); const [maximized, setMaximized] = useState(false); const wsRef = useRef(null); @@ -194,10 +191,9 @@ export function Terminal({ projectUuid, visible, onToggle, onConnectionChange, r return (
{/* Header */}
@@ -222,13 +218,6 @@ export function Terminal({ projectUuid, visible, onToggle, onConnectionChange, r > {maximized ? : } -
diff --git a/frontend/src/components/settings/McpSettings.tsx b/frontend/src/components/settings/McpSettings.tsx index 5c46ecb..4dccdb1 100644 --- a/frontend/src/components/settings/McpSettings.tsx +++ b/frontend/src/components/settings/McpSettings.tsx @@ -1,47 +1,50 @@ - import { useState, useEffect, useCallback } from 'react'; import { api } from '../../api'; +import { + Plus, + Pencil, + Trash2, + CheckCircle2, + XCircle, + Loader2, + ExternalLink, +} from 'lucide-react'; interface McpServer { name: string; - transport: 'http' | 'stdio'; - url?: string; - command?: string; - args?: string[]; - env?: Record; + url: string; + headers?: Record; + params?: Record; enabled: boolean; } -const DEFAULT_SERVER: McpServer = { - name: '', - transport: 'http', - url: '', - enabled: true, -}; +const EMPTY_SERVER: McpServer = { name: '', url: '', enabled: true }; export function McpSettings() { const [servers, setServers] = useState([]); const [saving, setSaving] = useState(false); const [saveMsg, setSaveMsg] = useState(''); - const [editingIndex, setEditingIndex] = useState(null); - const [editForm, setEditForm] = useState(DEFAULT_SERVER); + const [modalOpen, setModalOpen] = useState(false); + const [editingIndex, setEditingIndex] = useState(null); // null=add, number=edit + const [form, setForm] = useState(EMPTY_SERVER); + const [jsonConfig, setJsonConfig] = useState(''); + const [jsonError, setJsonError] = useState(''); + const [testing, setTesting] = useState(false); + const [testResult, setTestResult] = useState<{ ok: boolean; tools?: number; error?: string } | null>(null); useEffect(() => { api.getSettings().then((d) => { const s = d.settings || {}; if (s.mcp?.servers) { - // Convert from stored format to array const serverList: McpServer[] = []; for (const [name, config] of Object.entries(s.mcp.servers as Record)) { const cfg = config as Record; serverList.push({ name, - transport: (cfg.transport as 'http' | 'stdio') || 'http', - url: cfg.url as string || '', - command: cfg.command as string || '', - args: cfg.args as string[] || [], - env: cfg.env as Record || {}, + url: (cfg.url as string) || '', + headers: (cfg.headers as Record) || undefined, + params: (cfg.params as Record) || undefined, enabled: cfg.enabled !== false, }); } @@ -52,23 +55,26 @@ export function McpSettings() { const flash = useCallback((msg: string) => { setSaveMsg(msg); - setTimeout(() => setSaveMsg(''), 2000); + setTimeout(() => setSaveMsg(''), 2500); }, []); const saveServers = async (serverList: McpServer[]) => { setSaving(true); try { - // Convert to object format for storage const serversObj: Record = {}; for (const server of serverList) { if (!server.name) continue; - serversObj[server.name] = { - transport: server.transport, - ...(server.transport === 'http' ? { url: server.url } : {}), - ...(server.transport === 'stdio' ? { command: server.command, args: server.args } : {}), - ...(Object.keys(server.env || {}).length > 0 ? { env: server.env } : {}), + const entry: Record = { + url: server.url, enabled: server.enabled, }; + if (server.headers && Object.keys(server.headers).length > 0) { + entry.headers = server.headers; + } + if (server.params && Object.keys(server.params).length > 0) { + entry.params = server.params; + } + serversObj[server.name] = entry; } await api.updateSetting('mcp', 'servers', serversObj); flash('Saved'); @@ -79,49 +85,118 @@ export function McpSettings() { } }; - const startEdit = (index: number) => { - setEditForm({ ...servers[index] }); - setEditingIndex(index); + const openAddModal = () => { + setForm({ ...EMPTY_SERVER }); + setJsonConfig(JSON.stringify({ headers: {}, params: {} }, null, 2)); + setJsonError(''); + setTestResult(null); + setEditingIndex(null); + setModalOpen(true); }; - const startAdd = () => { - setEditForm({ ...DEFAULT_SERVER }); - setEditingIndex(-1); // -1 means adding new + const openEditModal = (index: number) => { + const server = servers[index]; + setForm({ ...server }); + const configObj: Record = {}; + if (server.headers && Object.keys(server.headers).length > 0) { + configObj.headers = server.headers; + } + if (server.params && Object.keys(server.params).length > 0) { + configObj.params = server.params; + } + setJsonConfig( + Object.keys(configObj).length > 0 + ? JSON.stringify(configObj, null, 2) + : JSON.stringify({ headers: {}, params: {} }, null, 2) + ); + setJsonError(''); + setTestResult(null); + setEditingIndex(index); + setModalOpen(true); }; - const cancelEdit = () => { + const closeModal = () => { + setModalOpen(false); setEditingIndex(null); - setEditForm(DEFAULT_SERVER); + setTestResult(null); + setJsonError(''); + }; + + /** Parse JSON config and merge into form */ + const parseJsonConfig = (): { headers?: Record; params?: Record } | null => { + const trimmed = jsonConfig.trim(); + if (!trimmed || trimmed === '{}') return {}; + try { + const parsed = JSON.parse(trimmed); + if (typeof parsed !== 'object' || Array.isArray(parsed)) { + setJsonError('Config must be a JSON object'); + return null; + } + setJsonError(''); + return { + headers: parsed.headers || undefined, + params: parsed.params || undefined, + }; + } catch (e: any) { + setJsonError(`Invalid JSON: ${e.message}`); + return null; + } }; - const saveEdit = async () => { - if (!editForm.name.trim()) { - flash('Name is required'); + const handleTest = async () => { + if (!form.url.trim()) { + flash('URL is required'); + return; + } + if (!form.url.startsWith('http://') && !form.url.startsWith('https://')) { + flash('URL must start with http:// or https://'); return; } - if (editForm.transport === 'http' && !editForm.url?.trim()) { - flash('URL is required for HTTP transport'); + const config = parseJsonConfig(); + if (config === null) return; + + setTesting(true); + setTestResult(null); + try { + const result = await api.testMcpServer(form.url, config.headers, config.params); + setTestResult(result); + } catch (e: any) { + setTestResult({ ok: false, error: e.message || 'Connection failed' }); + } finally { + setTesting(false); + } + }; + + const handleSave = async () => { + if (!form.name.trim()) { + flash('Server name is required'); return; } - if (editForm.transport === 'stdio' && !editForm.command?.trim()) { - flash('Command is required for stdio transport'); + if (!form.url.trim()) { + flash('URL is required'); return; } + const config = parseJsonConfig(); + if (config === null) return; + + const server: McpServer = { + name: form.name.trim(), + url: form.url.trim(), + headers: config.headers, + params: config.params, + enabled: form.enabled, + }; let newServers: McpServer[]; - if (editingIndex === -1) { - // Adding new - newServers = [...servers, { ...editForm, name: editForm.name.trim() }]; + if (editingIndex !== null) { + newServers = servers.map((s, i) => (i === editingIndex ? server : s)); } else { - // Editing existing - newServers = servers.map((s, i) => - i === editingIndex ? { ...editForm, name: editForm.name.trim() } : s - ); + newServers = [...servers, server]; } - + setServers(newServers); await saveServers(newServers); - cancelEdit(); + closeModal(); }; const deleteServer = async (index: number) => { @@ -131,7 +206,7 @@ export function McpSettings() { }; const toggleServer = async (index: number) => { - const newServers = servers.map((s, i) => + const newServers = servers.map((s, i) => i === index ? { ...s, enabled: !s.enabled } : s ); setServers(newServers); @@ -145,199 +220,233 @@ export function McpSettings() { {saveMsg}
)} - +

- Configure MCP (Model Context Protocol) servers to extend the agent with additional tools. - MCP servers can provide custom tools, data sources, and integrations. + Configure remote MCP (Model Context Protocol) servers to extend the agent with additional tools. + Only HTTP/HTTPS servers are supported. Add authentication via headers or query parameters.

- {/* Server list */} + {/* Server list — horizontal cards */}
- {servers.length === 0 && editingIndex === null && ( + {servers.length === 0 && !modalOpen && (
No MCP servers configured. Add one to extend the agent's capabilities.
)} - + {servers.map((server, index) => ( - editingIndex === index ? null : ( -
+ {/* Status dot */} + -
-
- {server.name} - - {server.transport.toUpperCase()} - - - {server.enabled ? 'Enabled' : 'Disabled'} - -
-
- - - -
+ title={server.enabled ? 'Enabled' : 'Disabled'} + /> + + {/* Server info */} +
+
+ {server.url}
-
- {server.transport === 'http' && server.url} - {server.transport === 'stdio' && `${server.command} ${(server.args || []).join(' ')}`} +
+ {server.name} + {server.headers && Object.keys(server.headers).length > 0 && ( + + Auth + + )}
- ) + + {/* Actions */} +
+ + + +
+
))} +
+ + - {/* Edit/Add form */} - {editingIndex !== null && ( -
-

- {editingIndex === -1 ? 'Add MCP Server' : 'Edit MCP Server'} -

- -
+ {/* Help section */} +
+

Popular MCP Servers

+
    +
  • Composio: https://mcp.composio.dev/...
  • +
  • Zapier MCP: https://actions.zapier.com/mcp/...
  • +
  • Browserbase: https://mcp.browserbase.com
  • +
+

+ Learn more at{' '} + + modelcontextprotocol.io + +

+
+ + {/* Add/Edit Modal */} + {modalOpen && ( + { if (e.target === e.currentTarget) closeModal(); }} + onKeyDown={(e) => { if (e.key === 'Escape') closeModal(); }} + > +
e.stopPropagation()} + > + {/* Modal header */} +
+

+ {editingIndex !== null ? 'Edit MCP Server' : 'Add MCP Server'} +

+
+ + {/* Modal body */} +
- + setEditForm((f) => ({ ...f, name: e.target.value }))} + value={form.name} + onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))} />
- - -
- - {editForm.transport === 'http' && ( -
- + setEditForm((f) => ({ ...f, url: e.target.value }))} - /> -
- )} + className="w-full bg-bg border border-border rounded-lg px-4 py-2.5 text-text placeholder-text-dim focus:border-primary focus:outline-none font-mono text-sm" + placeholder="https://mcp-server.example.com/sse" + value={form.url} + onChange={(e) => setForm((f) => ({ ...f, url: e.target.value }))} + /> +
+ +
+ +

+ Optional headers and query params for authentication. +

+