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:
parent
6605fbcab6
commit
1ab5496e0a
5 changed files with 469 additions and 24 deletions
|
|
@ -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 */}
|
||||
|
|
|
|||
115
src/components/InferenceTab.tsx
Normal file
115
src/components/InferenceTab.tsx
Normal 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
264
src/components/MCPTab.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
49
src/components/TabbedSidebar.tsx
Normal file
49
src/components/TabbedSidebar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
Loading…
Reference in a new issue