+
{/* Tab Content */}
+ {activeTab === 'conversations' &&
}
{activeTab === 'inference' &&
}
{activeTab === 'mcp' &&
}
diff --git a/src/components/ChatInterface.tsx b/src/components/ChatInterface.tsx
new file mode 100644
index 0000000..e1bf3eb
--- /dev/null
+++ b/src/components/ChatInterface.tsx
@@ -0,0 +1,145 @@
+// Main chat interface with message display and input
+
+import React, { useState, useRef, useEffect } from 'react';
+import { useConversation } from '@/contexts/ConversationContext';
+import { useInference } from '@/contexts/InferenceContext';
+import { MessageList } from './MessageList';
+import { MessageInput } from './MessageInput';
+import { InferenceLogin } from './InferenceLogin';
+
+export function ChatInterface() {
+ const {
+ activeConversationId,
+ getConversation,
+ sendMessage,
+ stopAgentLoop,
+ getAgentLoopState,
+ } = useConversation();
+
+ const { currentProvider } = useInference();
+ const [isLoading, setIsLoading] = useState(false);
+ const messagesEndRef = useRef
(null);
+
+ const activeConversation = activeConversationId ? getConversation(activeConversationId) : undefined;
+ const agentLoopState = activeConversationId ? getAgentLoopState(activeConversationId) : undefined;
+
+ // Auto-scroll to bottom when new messages arrive
+ useEffect(() => {
+ messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
+ }, [activeConversation?.messages]);
+
+ const handleSendMessage = async (content: string) => {
+ if (!activeConversationId || !currentProvider?.isAuthenticated) {
+ return;
+ }
+
+ setIsLoading(true);
+ try {
+ await sendMessage(activeConversationId, content);
+ } catch (error) {
+ console.error('Failed to send message:', error);
+ } finally {
+ setIsLoading(false);
+ }
+ };
+
+ const handleStopGeneration = () => {
+ if (activeConversationId) {
+ stopAgentLoop(activeConversationId);
+ }
+ };
+
+ // Show empty state if no conversation is selected
+ if (!activeConversationId || !activeConversation) {
+ return (
+
+
+
Welcome to MCP Chat
+
Start a new conversation to begin chatting with AI.
+
+ The AI has access to both test tools and any connected MCP servers.
+
+
+
+ );
+ }
+
+ // Show auth prompt if not authenticated
+ if (!currentProvider?.isAuthenticated) {
+ return (
+
+
+
+ );
+ }
+
+ const isGenerating = agentLoopState?.isRunning || isLoading;
+
+ return (
+
+ {/* Chat Header */}
+
+
+
+
+ {activeConversation.title}
+
+
+ Model: {currentProvider.selectedModel?.name || 'None selected'}
+
+
+
+ {isGenerating && (
+
+ )}
+
+
+ {/* Status Indicator */}
+ {agentLoopState && (
+
+ {agentLoopState.isRunning && (
+
+
+
+ {agentLoopState.currentStep === 'inference' && 'Thinking...'}
+ {agentLoopState.currentStep === 'tool_execution' && 'Using tools...'}
+ {agentLoopState.currentStep === 'complete' && 'Complete'}
+
+
+ (Step {agentLoopState.iteration + 1}/{agentLoopState.maxIterations})
+
+
+ )}
+
+ )}
+
+
+ {/* Messages Area */}
+
+
+ {/* Input Area */}
+
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/src/components/ConversationApp.tsx b/src/components/ConversationApp.tsx
new file mode 100644
index 0000000..86576aa
--- /dev/null
+++ b/src/components/ConversationApp.tsx
@@ -0,0 +1,45 @@
+// Main conversation application with sidebar and chat interface
+
+import React, { useState } from 'react';
+import { ConversationProvider } from '@/contexts/ConversationContext';
+import { ConversationSidebar } from './ConversationSidebar';
+import { ChatInterface } from './ChatInterface';
+import { MCPStatus } from './MCPStatus';
+
+export function ConversationApp() {
+ const [showMCPStatus, setShowMCPStatus] = useState(false);
+
+ return (
+
+
+ {/* Left Sidebar - Conversations */}
+
+
+
+
+ Conversations
+
+
+
+
+
+ {showMCPStatus ? (
+
+ ) : (
+
+ )}
+
+
+ {/* Main Chat Area */}
+
+
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/src/components/ConversationSidebar.tsx b/src/components/ConversationSidebar.tsx
new file mode 100644
index 0000000..ca2490d
--- /dev/null
+++ b/src/components/ConversationSidebar.tsx
@@ -0,0 +1,120 @@
+// Conversation sidebar with conversation list and management
+
+import React from 'react';
+import { useConversation } from '@/contexts/ConversationContext';
+
+export function ConversationSidebar() {
+ const {
+ conversations,
+ activeConversationId,
+ createConversation,
+ deleteConversation,
+ setActiveConversation,
+ } = useConversation();
+
+ const handleNewConversation = () => {
+ createConversation();
+ };
+
+ const formatDate = (date: Date): string => {
+ const now = new Date();
+ const diff = now.getTime() - date.getTime();
+ const days = Math.floor(diff / (1000 * 60 * 60 * 24));
+
+ if (days === 0) {
+ return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
+ } else if (days === 1) {
+ return 'Yesterday';
+ } else if (days < 7) {
+ return date.toLocaleDateString([], { weekday: 'short' });
+ } else {
+ return date.toLocaleDateString([], { month: 'short', day: 'numeric' });
+ }
+ };
+
+ const getStatusIndicator = (status: string) => {
+ switch (status) {
+ case 'thinking':
+ return '💭';
+ case 'calling_tools':
+ return '🔧';
+ case 'error':
+ return '❌';
+ default:
+ return '';
+ }
+ };
+
+ return (
+
+ {/* New Conversation Button */}
+
+
+
+
+ {/* Conversations List */}
+
+ {conversations.length === 0 ? (
+
+
No conversations yet.
+
Click "New Conversation" to get started.
+
+ ) : (
+
+ {conversations.map((conversation) => (
+
setActiveConversation(conversation.id)}
+ className={`p-3 rounded-lg cursor-pointer transition-colors group ${
+ activeConversationId === conversation.id
+ ? 'bg-blue-100 dark:bg-blue-900/50 border border-blue-200 dark:border-blue-800'
+ : 'hover:bg-gray-100 dark:hover:bg-gray-700'
+ }`}
+ >
+
+
+
+
+ {conversation.title}
+
+
+ {getStatusIndicator(conversation.status)}
+
+
+
+ {formatDate(conversation.updatedAt)}
+
+ {conversation.messages.length > 0 && (
+
+ {conversation.messages[conversation.messages.length - 1]?.content?.[0]?.type === 'text'
+ ? conversation.messages[conversation.messages.length - 1].content[0].text
+ : 'Tool interaction'
+ }
+
+ )}
+
+
+
+
+
+ ))}
+
+ )}
+
+
+ );
+}
\ No newline at end of file
diff --git a/src/components/InferenceLogin.tsx b/src/components/InferenceLogin.tsx
new file mode 100644
index 0000000..dbb2163
--- /dev/null
+++ b/src/components/InferenceLogin.tsx
@@ -0,0 +1,183 @@
+// Inference provider login component for inline authentication
+
+import React, { useState, useMemo } from 'react';
+import { useInference } from '@/contexts/InferenceContext';
+import { OpenRouterApiProvider } from '@/providers/openrouter/api-provider';
+import { OpenRouterOAuthProvider } from '@/providers/openrouter/oauth-provider';
+
+export function InferenceLogin() {
+ const { provider: currentProvider, setProvider, models, refreshAuthState } = useInference();
+ const [selectedProviderId, setSelectedProviderId] = useState(currentProvider?.id || '');
+ const [apiKey, setApiKey] = useState('');
+ const [isLoading, setIsLoading] = useState(false);
+ const [error, setError] = useState(null);
+
+ // Create available providers
+ const availableProviders = useMemo(() => [
+ new OpenRouterApiProvider(),
+ new OpenRouterOAuthProvider(),
+ ], []);
+
+ const selectedProvider = availableProviders.find(p => p.id === selectedProviderId);
+
+ const handleApiKeyLogin = async () => {
+ if (!selectedProvider || !apiKey.trim()) return;
+
+ setIsLoading(true);
+ setError(null);
+
+ try {
+ await selectedProvider.authenticate({
+ type: 'api_key',
+ apiKey: apiKey.trim(),
+ });
+
+ // Load models after authentication
+ await selectedProvider.loadModels();
+
+ // Set this as the active provider
+ setProvider(selectedProvider);
+ refreshAuthState(); // Force re-render to show auth state
+ setApiKey(''); // Clear the API key input
+ } catch (err) {
+ setError(err instanceof Error ? err.message : 'Authentication failed');
+ } finally {
+ setIsLoading(false);
+ }
+ };
+
+ const handleOAuthLogin = async () => {
+ if (!selectedProvider) return;
+
+ setIsLoading(true);
+ setError(null);
+
+ try {
+ await selectedProvider.authenticate({
+ type: 'oauth',
+ });
+
+ // Set this as the active provider
+ setProvider(selectedProvider);
+ refreshAuthState(); // Force re-render to show auth state
+ } catch (err) {
+ setError(err instanceof Error ? err.message : 'OAuth authentication failed');
+ } finally {
+ setIsLoading(false);
+ }
+ };
+
+ const capabilities = selectedProvider?.getCapabilities();
+
+ return (
+
+
+
+ Connect to AI Provider
+
+
+ Choose a provider and authenticate to start chatting
+
+
+
+ {error && (
+
+ )}
+
+ {/* Provider Selection */}
+
+
+
+
+
+ {selectedProvider && capabilities && (
+
+ {/* API Key Authentication */}
+ {capabilities.authMethods.includes('api_key') && (
+
+
+
+ setApiKey(e.target.value)}
+ placeholder="Enter your API key..."
+ className="flex-1 px-3 py-2 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"
+ onKeyDown={(e) => {
+ if (e.key === 'Enter' && apiKey.trim()) {
+ handleApiKeyLogin();
+ }
+ }}
+ />
+
+
+
+ Your API key is stored locally and never sent to our servers
+
+
+ )}
+
+ {/* OAuth Authentication */}
+ {capabilities.authMethods.includes('oauth') && (
+
+ {capabilities.authMethods.includes('api_key') && (
+
+ )}
+
+
+
+ )}
+
+ )}
+
+ {currentProvider?.isAuthenticated && models.length > 0 && (
+
+
+ ✓
+
+ Connected successfully! You can now start chatting.
+
+
+
+ )}
+
+ );
+}
\ No newline at end of file
diff --git a/src/components/MCPStatus.tsx b/src/components/MCPStatus.tsx
new file mode 100644
index 0000000..819d34b
--- /dev/null
+++ b/src/components/MCPStatus.tsx
@@ -0,0 +1,150 @@
+// MCP status display for the conversation sidebar
+
+import React from 'react';
+import { useMCP } from '@/contexts/MCPContext';
+
+export function MCPStatus() {
+ const { connections, getAllTools } = useMCP();
+
+ const connectedServers = connections.filter(conn => conn.status === 'connected');
+ const allTools = getAllTools();
+
+ 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 '⚪';
+ }
+ };
+
+ return (
+
+
+ {/* Summary */}
+
+
+ MCP Summary
+
+
+
+ {connectedServers.length} servers connected
+
+
+ {allTools.length} tools available
+
+
+
+
+ {/* Server List */}
+
+
+ Servers ({connections.length})
+
+
+ {connections.length === 0 ? (
+
+ No MCP servers configured. Go to the "MCP Provider Test" tab to add servers.
+
+ ) : (
+
+ {connections.map((connection) => (
+
+
+
+
+
+ {getStatusIcon(connection.status)}
+
+
+ {connection.name}
+
+
+
+ {connection.status}
+
+ {connection.error && (
+
+ {connection.error}
+
+ )}
+
+
+
+ {connection.tools.length > 0 && (
+
+
+ Tools ({connection.tools.length}):
+
+
+ {connection.tools.slice(0, 3).map((tool, index) => (
+
+ {tool.function.name.split('.').pop()}
+
+ ))}
+ {connection.tools.length > 3 && (
+
+ +{connection.tools.length - 3} more
+
+ )}
+
+
+ )}
+
+ ))}
+
+ )}
+
+
+ {/* Available Tools */}
+ {allTools.length > 0 && (
+
+
+ Available Tools ({allTools.length})
+
+
+ {allTools.map((tool, index) => (
+
+
+ {tool.function.name}
+
+ {tool.function.description && (
+
+ {tool.function.description}
+
+ )}
+
+ ))}
+
+
+ )}
+
+
+ );
+}
\ No newline at end of file
diff --git a/src/components/MessageInput.tsx b/src/components/MessageInput.tsx
new file mode 100644
index 0000000..ba31244
--- /dev/null
+++ b/src/components/MessageInput.tsx
@@ -0,0 +1,98 @@
+// Message input component with send functionality
+
+import React, { useState, useRef, useEffect } from 'react';
+
+interface MessageInputProps {
+ onSendMessage: (content: string) => void;
+ disabled?: boolean;
+ placeholder?: string;
+}
+
+export function MessageInput({
+ onSendMessage,
+ disabled = false,
+ placeholder = "Type your message..."
+}: MessageInputProps) {
+ const [message, setMessage] = useState('');
+ const textareaRef = useRef(null);
+
+ // Auto-resize textarea
+ useEffect(() => {
+ const textarea = textareaRef.current;
+ if (textarea) {
+ textarea.style.height = 'auto';
+ textarea.style.height = `${textarea.scrollHeight}px`;
+ }
+ }, [message]);
+
+ const handleSubmit = (e: React.FormEvent) => {
+ e.preventDefault();
+
+ const trimmedMessage = message.trim();
+ if (!trimmedMessage || disabled) return;
+
+ onSendMessage(trimmedMessage);
+ setMessage('');
+ };
+
+ const handleKeyDown = (e: React.KeyboardEvent) => {
+ // Send on Enter, new line on Shift+Enter
+ if (e.key === 'Enter' && !e.shiftKey) {
+ e.preventDefault();
+ handleSubmit(e);
+ }
+ };
+
+ return (
+
+ );
+}
\ No newline at end of file
diff --git a/src/components/MessageList.tsx b/src/components/MessageList.tsx
new file mode 100644
index 0000000..bfc9200
--- /dev/null
+++ b/src/components/MessageList.tsx
@@ -0,0 +1,200 @@
+// Message list component to display conversation messages with tool calls
+
+import React from 'react';
+import type { ConversationMessage, ToolUseBlock, ToolResultBlock } from '@/types/conversation';
+
+interface MessageListProps {
+ messages: ConversationMessage[];
+ isLoading?: boolean;
+}
+
+export function MessageList({ messages, isLoading }: MessageListProps) {
+ const renderContentBlock = (block: any, messageId: string, blockIndex: number) => {
+ const key = `${messageId}-${blockIndex}`;
+
+ switch (block.type) {
+ case 'text':
+ return (
+
+ );
+
+ case 'tool_use':
+ return (
+
+
+ 🔧
+
+ Using tool: {block.name}
+
+
+
+
+ {JSON.stringify(block.input, null, 2)}
+
+
+
+ );
+
+ case 'tool_result':
+ const isError = block.is_error;
+ return (
+
+
+
+ {isError ? '❌' : '✅'}
+
+
+ Tool result
+
+
+
+ {typeof block.content === 'string' ? (
+
+ {block.content}
+
+ ) : (
+ block.content.map((contentItem: any, idx: number) => (
+
+ {contentItem.text && (
+
{contentItem.text}
+ )}
+ {contentItem.error && (
+
+ Error: {contentItem.error}
+
+ )}
+
+ ))
+ )}
+
+
+ );
+
+ default:
+ return (
+
+ Unknown content type: {block.type}
+
+ );
+ }
+ };
+
+ const formatTime = (date: Date): string => {
+ return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
+ };
+
+ const getRoleColor = (role: string): string => {
+ switch (role) {
+ case 'user':
+ return 'text-blue-600 dark:text-blue-400';
+ case 'assistant':
+ return 'text-green-600 dark:text-green-400';
+ case 'tool':
+ return 'text-purple-600 dark:text-purple-400';
+ default:
+ return 'text-gray-600 dark:text-gray-400';
+ }
+ };
+
+ const getRoleLabel = (role: string): string => {
+ switch (role) {
+ case 'user':
+ return 'You';
+ case 'assistant':
+ return 'Assistant';
+ case 'tool':
+ return 'Tool';
+ default:
+ return role;
+ }
+ };
+
+ if (messages.length === 0 && !isLoading) {
+ return (
+
+
+
Start a conversation
+
+ Ask a question or request help with something. The AI can use tools to help you.
+
+
+
+ );
+ }
+
+ return (
+
+ {messages.map((message) => (
+
+ {/* Avatar */}
+
+
+ {message.role === 'user' ? 'U' : message.role === 'assistant' ? 'A' : 'T'}
+
+
+
+ {/* Message Content */}
+
+
+
+ {getRoleLabel(message.role)}
+
+
+ {formatTime(message.timestamp)}
+
+
+
+ {/* Render content blocks */}
+
+ {message.content.map((block, index) =>
+ renderContentBlock(block, message.id, index)
+ )}
+
+
+
+ ))}
+
+ {/* Loading indicator */}
+ {isLoading && (
+
+
+
+
+
+ Assistant
+
+
+ thinking...
+
+
+
+
+
+ )}
+
+ );
+}
\ No newline at end of file
diff --git a/src/contexts/ConversationContext.tsx b/src/contexts/ConversationContext.tsx
new file mode 100644
index 0000000..d6ba3ec
--- /dev/null
+++ b/src/contexts/ConversationContext.tsx
@@ -0,0 +1,291 @@
+// React context for conversation management and agent loop orchestration
+
+import React, { createContext, useContext, useState, useCallback, useEffect, ReactNode, useRef } from 'react';
+import { v4 as uuidv4 } from 'uuid';
+
+import type {
+ Conversation,
+ ConversationMessage,
+ ConversationContextValue,
+ AgentLoopState,
+ ConversationContentBlock,
+} from '@/types/conversation';
+import { useAgentLoop } from '@/hooks/useAgentLoop';
+
+const ConversationContext = createContext(null);
+
+interface ConversationProviderProps {
+ children: ReactNode;
+}
+
+export function ConversationProvider({ children }: ConversationProviderProps) {
+ const [conversations, setConversations] = useState([]);
+ const [activeConversationId, setActiveConversationId] = useState();
+ const [agentLoopStates, setAgentLoopStates] = useState