Add tabbed sidebar with MCP server management and resizable interface

- Created tabbed interface with MCP, Conversations, and Inference tabs
- Set MCP tab as default tab
- Added quick connect button for example server with OAuth auth
- Created form to add custom MCP servers with auth type selection
- Implemented server management UI with connect/disconnect/remove actions
- Made sidebar resizable with drag handle (240px-600px range)
- Added comprehensive MCP server status display and tools listing
- Created dedicated Inference tab for OpenRouter connection management

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Jerome 2025-06-12 15:28:20 +01:00
commit 1ab5496e0a
No known key found for this signature in database
5 changed files with 469 additions and 24 deletions

View file

@ -1,38 +1,54 @@
// Main conversation application with sidebar and chat interface
import { useState } from 'react';
import { useState, useRef, useCallback } from 'react';
import { ConversationProvider } from '@/contexts/ConversationContext';
import { ConversationSidebar } from './ConversationSidebar';
import { TabbedSidebar } from './TabbedSidebar';
import { ChatInterface } from './ChatInterface';
import { MCPStatus } from './MCPStatus';
export function ConversationApp() {
const [showMCPStatus, setShowMCPStatus] = useState(false);
const [sidebarWidth, setSidebarWidth] = useState(320);
const isResizing = useRef(false);
const handleMouseDown = useCallback(() => {
isResizing.current = true;
document.addEventListener('mousemove', handleMouseMove);
document.addEventListener('mouseup', handleMouseUp);
document.body.style.cursor = 'col-resize';
document.body.style.userSelect = 'none';
}, []);
const handleMouseMove = useCallback((e: MouseEvent) => {
if (!isResizing.current) return;
const newWidth = e.clientX;
if (newWidth >= 240 && newWidth <= 600) {
setSidebarWidth(newWidth);
}
}, []);
const handleMouseUp = useCallback(() => {
isResizing.current = false;
document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mouseup', handleMouseUp);
document.body.style.cursor = '';
document.body.style.userSelect = '';
}, [handleMouseMove]);
return (
<ConversationProvider>
<div className="h-screen flex bg-gray-100 dark:bg-gray-900">
{/* Left Sidebar - Conversations */}
<div className="w-80 bg-white dark:bg-gray-800 border-r border-gray-200 dark:border-gray-700 flex flex-col">
<div className="p-4 border-b border-gray-200 dark:border-gray-700">
<div className="flex items-center justify-between">
<h1 className="text-lg font-semibold text-gray-900 dark:text-white">
Conversations
</h1>
<button
onClick={() => setShowMCPStatus(!showMCPStatus)}
className="text-sm px-3 py-1 rounded-md bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-600"
>
{showMCPStatus ? 'Hide' : 'Show'} MCP
</button>
</div>
</div>
{/* Left Sidebar - Tabbed Interface */}
<div
className="bg-white dark:bg-gray-800 border-r border-gray-200 dark:border-gray-700 flex flex-col relative"
style={{ width: `${sidebarWidth}px` }}
>
<TabbedSidebar />
{showMCPStatus ? (
<MCPStatus />
) : (
<ConversationSidebar />
)}
{/* Resize Handle */}
<div
className="absolute top-0 right-0 w-1 h-full cursor-col-resize hover:bg-blue-500 hover:bg-opacity-50 transition-colors"
onMouseDown={handleMouseDown}
/>
</div>
{/* Main Chat Area */}

View file

@ -0,0 +1,115 @@
// Inference tab for managing OpenRouter connection
import { useState } from 'react';
import { useInference } from '@/contexts/InferenceContext';
import { OpenRouterOAuthProvider } from '@/providers/openrouter/oauth-provider';
export function InferenceTab() {
const { provider, models, clearProvider, setProvider } = useInference();
const [isConnecting, setIsConnecting] = useState(false);
const handleConnect = async () => {
setIsConnecting(true);
try {
const oauthProvider = new OpenRouterOAuthProvider();
await oauthProvider.authenticate({ type: 'oauth' });
setProvider(oauthProvider);
} catch (error) {
console.error('Failed to connect to OpenRouter:', error);
} finally {
setIsConnecting(false);
}
};
const handleDisconnect = () => {
if (confirm('Are you sure you want to disconnect from OpenRouter? This will clear your authentication.')) {
clearProvider();
}
};
return (
<div className="flex-1 overflow-y-auto p-4">
<div className="space-y-4">
{/* Connection Status */}
<div className="bg-gray-50 dark:bg-gray-700 rounded-lg p-3">
<h3 className="font-medium text-gray-900 dark:text-white mb-2">
Inference Provider
</h3>
{provider ? (
<div className="space-y-2">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-gray-700 dark:text-gray-300">
<span className="text-green-600 dark:text-green-400">🟢</span> Connected to OpenRouter
</p>
<p className="text-xs text-gray-500 dark:text-gray-400">
{models.length} models available
</p>
</div>
<button
onClick={handleDisconnect}
className="px-3 py-1 text-xs bg-red-100 dark:bg-red-900/50 text-red-800 dark:text-red-200 rounded hover:bg-red-200 dark:hover:bg-red-800"
>
Disconnect
</button>
</div>
</div>
) : (
<div className="space-y-2">
<p className="text-sm text-gray-700 dark:text-gray-300">
<span className="text-gray-400">⚪</span> Not connected
</p>
<button
onClick={handleConnect}
disabled={isConnecting}
className="w-full px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:bg-gray-400 disabled:cursor-not-allowed transition-colors"
>
{isConnecting ? 'Connecting...' : 'Connect to OpenRouter'}
</button>
</div>
)}
</div>
{/* Available Models */}
{provider && models.length > 0 && (
<div>
<h3 className="font-medium text-gray-900 dark:text-white mb-3">
Available Models ({models.length})
</h3>
<div className="space-y-1 max-h-64 overflow-y-auto">
{models.map((model) => (
<div
key={model.id}
className="text-xs bg-gray-50 dark:bg-gray-700 rounded px-2 py-2"
>
<div className="font-mono text-gray-900 dark:text-white">
{model.id}
</div>
{model.name && model.name !== model.id && (
<div className="text-gray-600 dark:text-gray-300 mt-1">
{model.name}
</div>
)}
<div className="text-gray-500 dark:text-gray-400 mt-1">
Context: {model.contextLength?.toLocaleString() || 'Unknown'}
</div>
</div>
))}
</div>
</div>
)}
{/* Help Text */}
<div className="text-xs text-gray-500 dark:text-gray-400 space-y-1">
<p>
Connect to OpenRouter to access AI models for conversations.
</p>
<p>
You'll need an OpenRouter account and API key to authenticate.
</p>
</div>
</div>
</div>
);
}

264
src/components/MCPTab.tsx Normal file
View file

@ -0,0 +1,264 @@
// MCP tab with server management and connection functionality
import { useState } from 'react';
import { useMCP } from '@/contexts/MCPContext';
export function MCPTab() {
const { connections, addMcpServer, removeMcpServer, reconnectServer, isLoading } = useMCP();
const [showAddForm, setShowAddForm] = useState(false);
const [newServerName, setNewServerName] = useState('');
const [newServerUrl, setNewServerUrl] = useState('');
const [authType, setAuthType] = useState<'none' | 'oauth'>('none');
const handleAddExampleServer = async () => {
try {
await addMcpServer({
name: 'Example Server',
url: 'https://example-server.modelcontextprotocol.io/sse',
authType: 'oauth',
});
} catch (error) {
console.error('Failed to add example server:', error);
}
};
const handleAddCustomServer = async (e: React.FormEvent) => {
e.preventDefault();
if (!newServerName.trim() || !newServerUrl.trim()) return;
try {
await addMcpServer({
name: newServerName,
url: newServerUrl,
authType,
});
// Reset form
setNewServerName('');
setNewServerUrl('');
setAuthType('none');
setShowAddForm(false);
} catch (error) {
console.error('Failed to add custom server:', error);
}
};
const getStatusColor = (status: string) => {
switch (status) {
case 'connected':
return 'text-green-600 dark:text-green-400';
case 'connecting':
return 'text-yellow-600 dark:text-yellow-400';
case 'failed':
return 'text-red-600 dark:text-red-400';
default:
return 'text-gray-600 dark:text-gray-400';
}
};
const getStatusIcon = (status: string) => {
switch (status) {
case 'connected':
return '🟢';
case 'connecting':
return '🟡';
case 'failed':
return '🔴';
default:
return '⚪';
}
};
const connectedServers = connections.filter(conn => conn.status === 'connected');
const allTools = connections.flatMap(conn => conn.tools);
return (
<div className="flex-1 flex flex-col overflow-hidden">
{/* Summary */}
<div className="p-4 border-b border-gray-200 dark:border-gray-700">
<div className="bg-gray-50 dark:bg-gray-700 rounded-lg p-3">
<h3 className="font-medium text-gray-900 dark:text-white mb-2">
MCP Summary
</h3>
<div className="text-sm space-y-1">
<p className="text-gray-700 dark:text-gray-300">
<span className="font-medium">{connectedServers.length}</span> servers connected
</p>
<p className="text-gray-700 dark:text-gray-300">
<span className="font-medium">{allTools.length}</span> tools available
</p>
</div>
</div>
</div>
{/* Add Servers Section */}
<div className="p-4 border-b border-gray-200 dark:border-gray-700">
<h3 className="font-medium text-gray-900 dark:text-white mb-3">
Add Servers
</h3>
{/* Example Server Button */}
<button
onClick={handleAddExampleServer}
disabled={isLoading || connections.some(conn => conn.name === 'Example Server')}
className="w-full mb-3 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:bg-gray-400 disabled:cursor-not-allowed transition-colors"
>
{connections.some(conn => conn.name === 'Example Server') ? 'Example Server Added' : 'Add Example Server'}
</button>
{/* Custom Server Form Toggle */}
<button
onClick={() => setShowAddForm(!showAddForm)}
className="w-full px-4 py-2 text-sm bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors"
>
{showAddForm ? 'Cancel' : 'Add Custom Server'}
</button>
{/* Custom Server Form */}
{showAddForm && (
<form onSubmit={handleAddCustomServer} className="mt-3 space-y-3">
<div>
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">
Server Name
</label>
<input
type="text"
value={newServerName}
onChange={(e) => setNewServerName(e.target.value)}
placeholder="My MCP Server"
className="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
required
/>
</div>
<div>
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">
Server URL
</label>
<input
type="url"
value={newServerUrl}
onChange={(e) => setNewServerUrl(e.target.value)}
placeholder="https://your-server.com/sse"
className="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
required
/>
</div>
<div>
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">
Authentication
</label>
<select
value={authType}
onChange={(e) => setAuthType(e.target.value as 'none' | 'oauth')}
className="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
>
<option value="none">None</option>
<option value="oauth">OAuth</option>
</select>
</div>
<button
type="submit"
disabled={isLoading || !newServerName.trim() || !newServerUrl.trim()}
className="w-full px-4 py-2 text-sm bg-green-600 text-white rounded-lg hover:bg-green-700 disabled:bg-gray-400 disabled:cursor-not-allowed transition-colors"
>
{isLoading ? 'Adding...' : 'Add Server'}
</button>
</form>
)}
</div>
{/* Server List */}
<div className="flex-1 overflow-y-auto p-4">
<div className="flex items-center justify-between mb-3">
<h3 className="font-medium text-gray-900 dark:text-white">
Servers ({connections.length})
</h3>
</div>
{connections.length === 0 ? (
<p className="text-sm text-gray-500 dark:text-gray-400">
No MCP servers configured. Add a server above to get started.
</p>
) : (
<div className="space-y-3">
{connections.map((connection) => (
<div
key={connection.id}
className="bg-white dark:bg-gray-700 rounded-lg p-3 border border-gray-200 dark:border-gray-600"
>
<div className="flex items-start justify-between mb-2">
<div className="flex-1 min-w-0">
<div className="flex items-center space-x-2">
<span className="text-sm">
{getStatusIcon(connection.status)}
</span>
<h4 className="text-sm font-medium text-gray-900 dark:text-white truncate">
{connection.name}
</h4>
</div>
<p className={`text-xs mt-1 ${getStatusColor(connection.status)}`}>
{connection.status}
</p>
{connection.error && (
<p className="text-xs text-red-600 dark:text-red-400 mt-1">
{connection.error}
</p>
)}
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1 truncate">
{connection.url}
</p>
</div>
<div className="flex space-x-1 ml-2">
{(connection.status === 'failed' || connection.status === 'disconnected') && (
<button
onClick={() => reconnectServer(connection.id)}
className="px-2 py-1 text-xs bg-blue-100 dark:bg-blue-900/50 text-blue-800 dark:text-blue-200 rounded hover:bg-blue-200 dark:hover:bg-blue-800"
title="Reconnect server"
>
Reconnect
</button>
)}
<button
onClick={() => removeMcpServer(connection.id)}
className="px-2 py-1 text-xs bg-red-100 dark:bg-red-900/50 text-red-800 dark:text-red-200 rounded hover:bg-red-200 dark:hover:bg-red-800"
title="Remove server"
>
Remove
</button>
</div>
</div>
{connection.tools.length > 0 && (
<div className="mt-2">
<p className="text-xs text-gray-600 dark:text-gray-400 mb-1">
Tools ({connection.tools.length}):
</p>
<div className="flex flex-wrap gap-1">
{connection.tools.slice(0, 3).map((tool, index) => (
<span
key={index}
className="inline-block px-2 py-1 bg-blue-100 dark:bg-blue-900/50 text-blue-800 dark:text-blue-200 text-xs rounded"
>
{tool.function.name.split('__').pop()}
</span>
))}
{connection.tools.length > 3 && (
<span className="text-xs text-gray-500 dark:text-gray-400">
+{connection.tools.length - 3} more
</span>
)}
</div>
</div>
)}
</div>
))}
</div>
)}
</div>
</div>
);
}

View file

@ -0,0 +1,49 @@
// Tabbed sidebar with Conversations, MCP, and Inference tabs
import { useState } from 'react';
import { ConversationSidebar } from './ConversationSidebar';
import { MCPTab } from './MCPTab';
import { InferenceTab } from './InferenceTab';
type TabType = 'mcp' | 'conversations' | 'inference';
export function TabbedSidebar() {
const [activeTab, setActiveTab] = useState<TabType>('mcp');
const tabs = [
{ id: 'mcp' as const, label: 'MCP', icon: '🔧' },
{ id: 'conversations' as const, label: 'Conversations', icon: '💬' },
{ id: 'inference' as const, label: 'Inference', icon: '🤖' },
];
return (
<div className="flex-1 flex flex-col">
{/* Tab Headers */}
<div className="border-b border-gray-200 dark:border-gray-700">
<div className="flex">
{tabs.map((tab) => (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id)}
className={`flex-1 px-4 py-3 text-sm font-medium transition-colors ${
activeTab === tab.id
? 'text-blue-600 dark:text-blue-400 border-b-2 border-blue-600 dark:border-blue-400 bg-blue-50 dark:bg-blue-900/20'
: 'text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700/50'
}`}
>
<span className="mr-2">{tab.icon}</span>
{tab.label}
</button>
))}
</div>
</div>
{/* Tab Content */}
<div className="flex-1 overflow-hidden">
{activeTab === 'mcp' && <MCPTab />}
{activeTab === 'conversations' && <ConversationSidebar />}
{activeTab === 'inference' && <InferenceTab />}
</div>
</div>
);
}

View file

@ -522,6 +522,7 @@ export class MCPConnectionManager {
try {
console.log('Initializing MCP client...');
this.transport = transport;
this.transport.onmessage = console.log.bind(console, 'MCP Client message received:');
this.client = new Client(
{
name: 'example-remote-client',