[OQI-007] feat: Add ConversationHistory and ContextPanel components

- ConversationHistory: Session list with search, delete, and selection
- ContextPanel: Display trading context (watchlist, risk profile, favorites)
- Update Assistant page to use chatStore for session management
- Wire sidebar to load/create/delete sessions
- Add error banner and responsive design

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Adrian Flores Cortes 2026-01-25 10:17:52 -06:00
parent ee307ee91a
commit 442051f93b
4 changed files with 657 additions and 166 deletions

View File

@ -0,0 +1,263 @@
/**
* ContextPanel Component
* Displays current trading context: watchlist, risk profile, preferences
*/
import React, { useState } from 'react';
import {
ChevronDownIcon,
ChevronUpIcon,
EyeIcon,
ShieldCheckIcon,
AdjustmentsHorizontalIcon,
StarIcon,
} from '@heroicons/react/24/outline';
interface WatchlistItem {
symbol: string;
name: string;
price?: number;
change?: number;
}
interface ContextPanelProps {
watchlist?: WatchlistItem[];
riskProfile?: 'conservative' | 'moderate' | 'aggressive';
preferredSymbols?: string[];
onRiskProfileChange?: (profile: 'conservative' | 'moderate' | 'aggressive') => void;
className?: string;
}
const ContextPanel: React.FC<ContextPanelProps> = ({
watchlist = [],
riskProfile = 'moderate',
preferredSymbols = [],
onRiskProfileChange,
className = '',
}) => {
const [isExpanded, setIsExpanded] = useState(true);
const [activeSection, setActiveSection] = useState<'watchlist' | 'risk' | 'prefs'>('watchlist');
const riskProfiles = [
{
id: 'conservative' as const,
label: 'Conservador',
description: 'Bajo riesgo, retornos estables',
color: 'text-green-500',
bg: 'bg-green-100 dark:bg-green-900/30',
},
{
id: 'moderate' as const,
label: 'Moderado',
description: 'Balance riesgo/retorno',
color: 'text-yellow-500',
bg: 'bg-yellow-100 dark:bg-yellow-900/30',
},
{
id: 'aggressive' as const,
label: 'Agresivo',
description: 'Alto riesgo, alto retorno',
color: 'text-red-500',
bg: 'bg-red-100 dark:bg-red-900/30',
},
];
const currentRiskProfile = riskProfiles.find((p) => p.id === riskProfile) || riskProfiles[1];
return (
<div className={`bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 ${className}`}>
{/* Header */}
<button
onClick={() => setIsExpanded(!isExpanded)}
className="w-full flex items-center justify-between p-3 hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors"
>
<div className="flex items-center gap-2">
<AdjustmentsHorizontalIcon className="w-5 h-5 text-blue-500" />
<span className="font-medium text-sm text-gray-900 dark:text-white">
Contexto de Trading
</span>
</div>
{isExpanded ? (
<ChevronUpIcon className="w-4 h-4 text-gray-400" />
) : (
<ChevronDownIcon className="w-4 h-4 text-gray-400" />
)}
</button>
{/* Content */}
{isExpanded && (
<div className="px-3 pb-3">
{/* Section Tabs */}
<div className="flex items-center gap-1 mb-3 bg-gray-100 dark:bg-gray-700 rounded-lg p-1">
<button
onClick={() => setActiveSection('watchlist')}
className={`flex-1 flex items-center justify-center gap-1 py-1.5 px-2 rounded-md text-xs font-medium transition-colors ${
activeSection === 'watchlist'
? 'bg-white dark:bg-gray-600 text-gray-900 dark:text-white shadow-sm'
: 'text-gray-500 hover:text-gray-700 dark:hover:text-gray-300'
}`}
>
<EyeIcon className="w-3.5 h-3.5" />
Watchlist
</button>
<button
onClick={() => setActiveSection('risk')}
className={`flex-1 flex items-center justify-center gap-1 py-1.5 px-2 rounded-md text-xs font-medium transition-colors ${
activeSection === 'risk'
? 'bg-white dark:bg-gray-600 text-gray-900 dark:text-white shadow-sm'
: 'text-gray-500 hover:text-gray-700 dark:hover:text-gray-300'
}`}
>
<ShieldCheckIcon className="w-3.5 h-3.5" />
Riesgo
</button>
<button
onClick={() => setActiveSection('prefs')}
className={`flex-1 flex items-center justify-center gap-1 py-1.5 px-2 rounded-md text-xs font-medium transition-colors ${
activeSection === 'prefs'
? 'bg-white dark:bg-gray-600 text-gray-900 dark:text-white shadow-sm'
: 'text-gray-500 hover:text-gray-700 dark:hover:text-gray-300'
}`}
>
<StarIcon className="w-3.5 h-3.5" />
Favoritos
</button>
</div>
{/* Watchlist Section */}
{activeSection === 'watchlist' && (
<div className="space-y-2">
{watchlist.length === 0 ? (
<div className="text-center py-4">
<p className="text-xs text-gray-500">Sin simbolos en watchlist</p>
<p className="text-xs text-gray-400 mt-1">
Agrega simbolos desde Trading
</p>
</div>
) : (
watchlist.slice(0, 5).map((item) => (
<div
key={item.symbol}
className="flex items-center justify-between p-2 bg-gray-50 dark:bg-gray-700/50 rounded-lg"
>
<div>
<span className="font-medium text-sm text-gray-900 dark:text-white">
{item.symbol}
</span>
<p className="text-xs text-gray-500">{item.name}</p>
</div>
{item.price && (
<div className="text-right">
<span className="text-sm font-medium text-gray-900 dark:text-white">
${item.price.toFixed(2)}
</span>
{item.change !== undefined && (
<p
className={`text-xs ${
item.change >= 0 ? 'text-green-500' : 'text-red-500'
}`}
>
{item.change >= 0 ? '+' : ''}
{item.change.toFixed(2)}%
</p>
)}
</div>
)}
</div>
))
)}
{watchlist.length > 5 && (
<p className="text-xs text-gray-400 text-center">
+{watchlist.length - 5} mas
</p>
)}
</div>
)}
{/* Risk Profile Section */}
{activeSection === 'risk' && (
<div className="space-y-2">
{riskProfiles.map((profile) => (
<button
key={profile.id}
onClick={() => onRiskProfileChange?.(profile.id)}
className={`w-full flex items-center gap-3 p-2.5 rounded-lg border transition-all ${
riskProfile === profile.id
? `${profile.bg} border-current ${profile.color}`
: 'border-gray-200 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-700/50'
}`}
>
<div
className={`w-3 h-3 rounded-full ${
profile.id === 'conservative'
? 'bg-green-500'
: profile.id === 'moderate'
? 'bg-yellow-500'
: 'bg-red-500'
}`}
/>
<div className="text-left">
<p
className={`text-sm font-medium ${
riskProfile === profile.id
? profile.color
: 'text-gray-900 dark:text-white'
}`}
>
{profile.label}
</p>
<p className="text-xs text-gray-500">{profile.description}</p>
</div>
{riskProfile === profile.id && (
<ShieldCheckIcon className={`w-4 h-4 ml-auto ${profile.color}`} />
)}
</button>
))}
<p className="text-xs text-gray-400 mt-2">
El AI ajustara sus recomendaciones segun tu perfil
</p>
</div>
)}
{/* Preferred Symbols Section */}
{activeSection === 'prefs' && (
<div className="space-y-2">
{preferredSymbols.length === 0 ? (
<div className="text-center py-4">
<p className="text-xs text-gray-500">Sin simbolos favoritos</p>
<p className="text-xs text-gray-400 mt-1">
Marca simbolos como favoritos para analisis rapido
</p>
</div>
) : (
<div className="flex flex-wrap gap-2">
{preferredSymbols.map((symbol) => (
<span
key={symbol}
className="inline-flex items-center gap-1 px-2.5 py-1 bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300 rounded-full text-xs font-medium"
>
<StarIcon className="w-3 h-3" />
{symbol}
</span>
))}
</div>
)}
</div>
)}
{/* Quick Info */}
<div className="mt-3 pt-3 border-t border-gray-200 dark:border-gray-700">
<div className="flex items-center justify-between text-xs">
<span className="text-gray-500">Perfil actual:</span>
<span className={`font-medium ${currentRiskProfile.color}`}>
{currentRiskProfile.label}
</span>
</div>
</div>
</div>
)}
</div>
);
};
export default ContextPanel;

View File

@ -0,0 +1,218 @@
/**
* ConversationHistory Component
* Displays list of chat sessions with actions to load, create, and delete
*/
import React, { useEffect, useState } from 'react';
import { format, formatDistanceToNow } from 'date-fns';
import { es } from 'date-fns/locale';
import {
ChatBubbleLeftRightIcon,
PlusIcon,
TrashIcon,
MagnifyingGlassIcon,
} from '@heroicons/react/24/outline';
import { SparklesIcon } from '@heroicons/react/24/solid';
import type { ChatSession } from '../../../types/chat.types';
interface ConversationHistoryProps {
sessions: ChatSession[];
currentSessionId: string | null;
loading: boolean;
onSelectSession: (sessionId: string) => void;
onCreateSession: () => void;
onDeleteSession: (sessionId: string) => void;
}
const ConversationHistory: React.FC<ConversationHistoryProps> = ({
sessions,
currentSessionId,
loading,
onSelectSession,
onCreateSession,
onDeleteSession,
}) => {
const [searchTerm, setSearchTerm] = useState('');
const [deleteConfirm, setDeleteConfirm] = useState<string | null>(null);
// Filter sessions by search term
const filteredSessions = sessions.filter((session) => {
if (!searchTerm.trim()) return true;
const lastMessage = session.messages[session.messages.length - 1];
return (
lastMessage?.content.toLowerCase().includes(searchTerm.toLowerCase()) ||
session.id.toLowerCase().includes(searchTerm.toLowerCase())
);
});
// Get session title from first user message or default
const getSessionTitle = (session: ChatSession): string => {
const firstUserMessage = session.messages.find((m) => m.role === 'user');
if (firstUserMessage) {
return firstUserMessage.content.slice(0, 40) + (firstUserMessage.content.length > 40 ? '...' : '');
}
return 'Nueva conversacion';
};
// Get last message preview
const getLastMessagePreview = (session: ChatSession): string => {
const lastMessage = session.messages[session.messages.length - 1];
if (lastMessage) {
const preview = lastMessage.content.slice(0, 50);
return preview + (lastMessage.content.length > 50 ? '...' : '');
}
return 'Sin mensajes';
};
const handleDelete = (sessionId: string, e: React.MouseEvent) => {
e.stopPropagation();
if (deleteConfirm === sessionId) {
onDeleteSession(sessionId);
setDeleteConfirm(null);
} else {
setDeleteConfirm(sessionId);
// Auto-cancel after 3 seconds
setTimeout(() => setDeleteConfirm(null), 3000);
}
};
return (
<div className="flex flex-col h-full">
{/* Header with New Chat button */}
<div className="p-4 border-b border-gray-200 dark:border-gray-700">
<button
onClick={onCreateSession}
disabled={loading}
className="w-full flex items-center justify-center gap-2 px-4 py-2.5 bg-gradient-to-r from-cyan-500 to-blue-600 hover:from-cyan-600 hover:to-blue-700 text-white rounded-lg transition-all disabled:opacity-50 disabled:cursor-not-allowed shadow-md hover:shadow-lg"
>
<PlusIcon className="w-5 h-5" />
<span className="font-medium">Nueva Conversacion</span>
</button>
</div>
{/* Search */}
<div className="px-4 py-2">
<div className="relative">
<MagnifyingGlassIcon className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
<input
type="text"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
placeholder="Buscar conversaciones..."
className="w-full pl-9 pr-4 py-2 text-sm bg-gray-100 dark:bg-gray-700 border-0 rounded-lg focus:ring-2 focus:ring-blue-500 text-gray-900 dark:text-white placeholder-gray-500"
/>
</div>
</div>
{/* Session List */}
<div className="flex-1 overflow-y-auto px-2 py-2">
{loading && sessions.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12">
<div className="w-8 h-8 border-2 border-blue-500 border-t-transparent rounded-full animate-spin mb-3" />
<p className="text-sm text-gray-500">Cargando conversaciones...</p>
</div>
) : filteredSessions.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12 px-4 text-center">
<div className="w-16 h-16 bg-gray-100 dark:bg-gray-700 rounded-full flex items-center justify-center mb-4">
<ChatBubbleLeftRightIcon className="w-8 h-8 text-gray-400" />
</div>
<p className="text-sm text-gray-500 dark:text-gray-400 mb-1">
{searchTerm ? 'No se encontraron resultados' : 'Sin conversaciones'}
</p>
<p className="text-xs text-gray-400">
{searchTerm ? 'Intenta con otros terminos' : 'Inicia una nueva conversacion'}
</p>
</div>
) : (
<div className="space-y-1">
{filteredSessions.map((session) => {
const isActive = session.id === currentSessionId;
const isDeleting = deleteConfirm === session.id;
return (
<div
key={session.id}
onClick={() => onSelectSession(session.id)}
className={`group relative p-3 rounded-lg cursor-pointer transition-all ${
isActive
? 'bg-blue-50 dark:bg-blue-900/30 border border-blue-200 dark:border-blue-800'
: 'hover:bg-gray-100 dark:hover:bg-gray-700/50 border border-transparent'
}`}
>
<div className="flex items-start gap-3">
{/* Icon */}
<div
className={`flex-shrink-0 w-8 h-8 rounded-full flex items-center justify-center ${
isActive
? 'bg-blue-500 text-white'
: 'bg-gray-200 dark:bg-gray-700 text-gray-500 dark:text-gray-400'
}`}
>
<SparklesIcon className="w-4 h-4" />
</div>
{/* Content */}
<div className="flex-1 min-w-0">
<p
className={`font-medium text-sm truncate ${
isActive
? 'text-blue-900 dark:text-blue-100'
: 'text-gray-900 dark:text-white'
}`}
>
{getSessionTitle(session)}
</p>
<p className="text-xs text-gray-500 truncate mt-0.5">
{getLastMessagePreview(session)}
</p>
<p className="text-xs text-gray-400 mt-1">
{formatDistanceToNow(new Date(session.updatedAt), {
addSuffix: true,
locale: es,
})}
</p>
</div>
{/* Delete Button */}
<button
onClick={(e) => handleDelete(session.id, e)}
className={`flex-shrink-0 p-1.5 rounded-md transition-all ${
isDeleting
? 'bg-red-100 dark:bg-red-900/30 text-red-600'
: 'opacity-0 group-hover:opacity-100 text-gray-400 hover:text-red-500 hover:bg-red-50 dark:hover:bg-red-900/20'
}`}
title={isDeleting ? 'Click para confirmar' : 'Eliminar'}
>
<TrashIcon className="w-4 h-4" />
</button>
</div>
{/* Message count badge */}
{session.messages.length > 0 && (
<div className="absolute bottom-2 right-2">
<span className="text-xs text-gray-400">
{session.messages.length} msg
</span>
</div>
)}
</div>
);
})}
</div>
)}
</div>
{/* Footer stats */}
<div className="p-3 border-t border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800/50">
<div className="flex items-center justify-between text-xs text-gray-500">
<span>{sessions.length} conversaciones</span>
<span>
{sessions.reduce((acc, s) => acc + s.messages.length, 0)} mensajes
</span>
</div>
</div>
</div>
);
};
export default ConversationHistory;

View File

@ -0,0 +1,16 @@
/**
* Assistant Components Index
* Export all LLM copilot components
*/
export { default as ChatMessage } from './ChatMessage';
export type { Message } from './ChatMessage';
export { default as ChatInput } from './ChatInput';
export { default as SignalCard } from './SignalCard';
export type { TradingSignal } from './SignalCard';
export { default as ConversationHistory } from './ConversationHistory';
export { default as ContextPanel } from './ContextPanel';

View File

@ -1,195 +1,153 @@
import React, { useState, useRef, useEffect, useCallback } from 'react';
/**
* Assistant Page
* Full-page LLM trading copilot with conversation history and context
*/
import React, { useEffect, useRef, useCallback } from 'react';
import {
SparklesIcon,
Cog6ToothIcon,
TrashIcon,
ArrowPathIcon,
Bars3Icon,
XMarkIcon,
} from '@heroicons/react/24/outline';
import ChatMessage, { Message } from '../components/ChatMessage';
import ChatMessage from '../components/ChatMessage';
import ChatInput from '../components/ChatInput';
import SignalCard, { TradingSignal } from '../components/SignalCard';
import ConversationHistory from '../components/ConversationHistory';
import ContextPanel from '../components/ContextPanel';
import { useChatStore } from '../../../stores/chatStore';
// TODO: Integrate with tradingStore for real watchlist data
// import { useTradingStore } from '../../../stores/tradingStore';
// API base URL
const LLM_API_URL = import.meta.env.VITE_LLM_URL || 'http://localhost:8003';
interface ConversationInfo {
id: string;
title: string;
lastMessage: string;
timestamp: Date;
}
// Mock watchlist data (would come from tradingStore in production)
const mockWatchlist = [
{ symbol: 'BTC/USD', name: 'Bitcoin', price: 43250.00, change: 2.34 },
{ symbol: 'ETH/USD', name: 'Ethereum', price: 2280.50, change: -1.12 },
{ symbol: 'XAUUSD', name: 'Gold', price: 2045.30, change: 0.45 },
{ symbol: 'EURUSD', name: 'Euro/Dollar', price: 1.0875, change: -0.22 },
];
const Assistant: React.FC = () => {
const [messages, setMessages] = useState<Message[]>([
{
id: '1',
role: 'assistant',
content: 'Hello! I\'m your OrbiQuant AI trading assistant. I can help you with:\n\n- **Market Analysis**: Get signals and analysis for any symbol\n- **Portfolio Overview**: Check your positions and P&L\n- **Trading Education**: Learn about indicators, patterns, and strategies\n- **AMD Phases**: Understand market cycles\n\nWhat would you like to know?',
timestamp: new Date(),
},
]);
const [isLoading, setIsLoading] = useState(false);
const [isStreaming, setIsStreaming] = useState(false);
const [conversationId, setConversationId] = useState<string>(() =>
`conv-${Date.now()}`
);
const [signal, setSignal] = useState<TradingSignal | null>(null);
const [conversations, setConversations] = useState<ConversationInfo[]>([]);
const [showSidebar, setShowSidebar] = useState(true);
// Chat store
const {
sessions,
currentSessionId,
messages,
loading,
error,
loadSessions,
loadSession,
createNewSession,
sendMessage,
deleteSession,
clearError,
} = useChatStore();
// UI State
const [showSidebar, setShowSidebar] = React.useState(true);
const [showContext, setShowContext] = React.useState(true);
const [signal, setSignal] = React.useState<TradingSignal | null>(null);
const [riskProfile, setRiskProfile] = React.useState<'conservative' | 'moderate' | 'aggressive'>('moderate');
const [isStreaming, setIsStreaming] = React.useState(false);
const messagesEndRef = useRef<HTMLDivElement>(null);
// Load sessions on mount
useEffect(() => {
loadSessions();
}, [loadSessions]);
// Scroll to bottom on new messages
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [messages]);
// Generate unique message ID
const generateId = () => `msg-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
// Send message to LLM API
const sendMessage = useCallback(async (content: string) => {
// Handle send message
const handleSendMessage = useCallback(async (content: string) => {
if (!content.trim()) return;
// Add user message
const userMessage: Message = {
id: generateId(),
role: 'user',
content,
timestamp: new Date(),
};
setMessages((prev) => [...prev, userMessage]);
setIsLoading(true);
try {
const response = await fetch(`${LLM_API_URL}/api/v1/chat`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
user_id: 'user-1', // TODO: Get from auth context
conversation_id: conversationId,
message: content,
user_plan: 'pro',
stream: false,
}),
});
if (!response.ok) {
throw new Error(`API error: ${response.status}`);
}
const data = await response.json();
// Add assistant message
const assistantMessage: Message = {
id: generateId(),
role: 'assistant',
content: data.response || data.message || 'I apologize, but I couldn\'t process that request.',
timestamp: new Date(),
tools_used: data.tools_used || [],
};
setMessages((prev) => [...prev, assistantMessage]);
// Check if response contains a signal
if (data.signal) {
setSignal(data.signal);
}
} catch (error) {
console.error('Chat error:', error);
// Add error message
const errorMessage: Message = {
id: generateId(),
role: 'assistant',
content: 'I apologize, but I\'m having trouble connecting to my services. Please try again in a moment.\n\n*Tip: Make sure the LLM service is running on port 8003*',
timestamp: new Date(),
};
setMessages((prev) => [...prev, errorMessage]);
} finally {
setIsLoading(false);
}
}, [conversationId]);
await sendMessage(content);
}, [sendMessage]);
// Stop streaming (for future SSE implementation)
const stopStreaming = useCallback(() => {
setIsStreaming(false);
setIsLoading(false);
}, []);
// Clear conversation
// Clear current conversation
const clearConversation = useCallback(() => {
const newConvId = `conv-${Date.now()}`;
setConversationId(newConvId);
setMessages([
{
id: '1',
role: 'assistant',
content: 'Conversation cleared. How can I help you?',
timestamp: new Date(),
},
]);
createNewSession();
setSignal(null);
}, []);
}, [createNewSession]);
// Handle trade from signal card
const handleTrade = useCallback((sig: TradingSignal) => {
// Navigate to trading page or open order modal
window.location.href = `/trading?symbol=${sig.symbol}&signal=${sig.signal_id}`;
}, []);
// Handle session selection
const handleSelectSession = useCallback((sessionId: string) => {
loadSession(sessionId);
}, [loadSession]);
// Handle session deletion
const handleDeleteSession = useCallback((sessionId: string) => {
deleteSession(sessionId);
}, [deleteSession]);
// Convert store messages to component format
const displayMessages = messages.map((msg) => ({
id: msg.id,
role: msg.role,
content: msg.content,
timestamp: new Date(msg.timestamp),
tools_used: msg.toolsUsed,
}));
// Add welcome message if no messages
const allMessages = displayMessages.length === 0
? [{
id: 'welcome',
role: 'assistant' as const,
content: 'Hola! Soy tu asistente de trading OrbiQuant AI. Puedo ayudarte con:\n\n- **Analisis de mercado**: Senales y analisis para cualquier simbolo\n- **Tu portfolio**: Revisa tus posiciones y P&L\n- **Educacion**: Aprende sobre indicadores, patrones y estrategias\n- **Fases AMD**: Entiende los ciclos del mercado\n\nQue te gustaria saber?',
timestamp: new Date(),
tools_used: [],
}]
: displayMessages;
return (
<div className="h-[calc(100vh-64px)] flex bg-gray-50 dark:bg-gray-900">
{/* Sidebar - Conversation History */}
{showSidebar && (
<div className="w-64 bg-white dark:bg-gray-800 border-r border-gray-200 dark:border-gray-700 flex flex-col">
{/* Header */}
<div className="p-4 border-b border-gray-200 dark:border-gray-700">
<button
onClick={clearConversation}
className="w-full flex items-center justify-center gap-2 px-4 py-2 bg-primary-600 hover:bg-primary-700 text-white rounded-lg transition-colors"
<div
className={`${
showSidebar ? 'w-72' : 'w-0'
} transition-all duration-300 overflow-hidden bg-white dark:bg-gray-800 border-r border-gray-200 dark:border-gray-700 flex-shrink-0`}
>
<SparklesIcon className="w-5 h-5" />
<span>New Chat</span>
</button>
<ConversationHistory
sessions={sessions}
currentSessionId={currentSessionId}
loading={loading}
onSelectSession={handleSelectSession}
onCreateSession={createNewSession}
onDeleteSession={handleDeleteSession}
/>
</div>
{/* Conversation list */}
<div className="flex-1 overflow-y-auto p-2">
{conversations.length === 0 ? (
<div className="text-center text-gray-500 text-sm py-8">
No previous conversations
</div>
) : (
conversations.map((conv) => (
<button
key={conv.id}
className="w-full text-left p-3 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors mb-1"
>
<p className="font-medium text-gray-900 dark:text-white truncate">
{conv.title}
</p>
<p className="text-xs text-gray-500 truncate">{conv.lastMessage}</p>
</button>
))
)}
</div>
{/* Settings */}
<div className="p-4 border-t border-gray-200 dark:border-gray-700">
<button className="flex items-center gap-2 text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white transition-colors">
<Cog6ToothIcon className="w-5 h-5" />
<span className="text-sm">Settings</span>
</button>
</div>
</div>
)}
{/* Main Chat Area */}
<div className="flex-1 flex flex-col">
<div className="flex-1 flex flex-col min-w-0">
{/* Header */}
<div className="flex items-center justify-between px-4 py-3 bg-white dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700">
<div className="flex items-center gap-3">
<button
onClick={() => setShowSidebar(!showSidebar)}
className="p-2 text-gray-500 hover:text-gray-700 dark:hover:text-gray-300 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors lg:hidden"
>
{showSidebar ? (
<XMarkIcon className="w-5 h-5" />
) : (
<Bars3Icon className="w-5 h-5" />
)}
</button>
<div className="w-10 h-10 rounded-full bg-gradient-to-r from-cyan-500 to-blue-500 flex items-center justify-center">
<SparklesIcon className="w-6 h-6 text-white" />
</div>
@ -201,37 +159,72 @@ const Assistant: React.FC = () => {
<div className="flex items-center gap-2">
<button
onClick={() => setShowSidebar(!showSidebar)}
className="p-2 text-gray-500 hover:text-gray-700 dark:hover:text-gray-300 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
className="hidden lg:flex p-2 text-gray-500 hover:text-gray-700 dark:hover:text-gray-300 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
title={showSidebar ? 'Ocultar historial' : 'Mostrar historial'}
>
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 12h16M4 18h16" />
</svg>
<Bars3Icon className="w-5 h-5" />
</button>
<button
onClick={() => setShowContext(!showContext)}
className="p-2 text-gray-500 hover:text-gray-700 dark:hover:text-gray-300 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
title={showContext ? 'Ocultar contexto' : 'Mostrar contexto'}
>
<Cog6ToothIcon className="w-5 h-5" />
</button>
<button
onClick={clearConversation}
className="p-2 text-gray-500 hover:text-gray-700 dark:hover:text-gray-300 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
title="Clear conversation"
title="Nueva conversacion"
>
<TrashIcon className="w-5 h-5" />
</button>
<button
className="p-2 text-gray-500 hover:text-gray-700 dark:hover:text-gray-300 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
title="Regenerate response"
title="Regenerar respuesta"
>
<ArrowPathIcon className="w-5 h-5" />
</button>
</div>
</div>
{/* Error Banner */}
{error && (
<div className="mx-4 mt-4 p-3 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg flex items-center justify-between">
<p className="text-sm text-red-600 dark:text-red-400">{error}</p>
<button
onClick={clearError}
className="text-red-500 hover:text-red-700 p-1"
>
<XMarkIcon className="w-4 h-4" />
</button>
</div>
)}
{/* Messages Area */}
<div className="flex-1 overflow-y-auto">
<div className="max-w-3xl mx-auto p-4">
{/* Context Panel (collapsible) */}
{showContext && (
<div className="mb-4">
<ContextPanel
watchlist={mockWatchlist}
riskProfile={riskProfile}
preferredSymbols={['BTC/USD', 'XAUUSD', 'EURUSD']}
onRiskProfileChange={setRiskProfile}
/>
</div>
)}
{/* Messages */}
<div className="flex-1 overflow-y-auto p-4">
<div className="max-w-3xl mx-auto">
{messages.map((message) => (
<ChatMessage key={message.id} message={message} />
{allMessages.map((message) => (
<ChatMessage
key={message.id}
message={message}
/>
))}
{/* Loading indicator */}
{isLoading && (
{loading && (
<div className="flex justify-start mb-4">
<div className="bg-gray-100 dark:bg-gray-800 rounded-2xl rounded-bl-sm px-4 py-3">
<div className="flex items-center gap-2">
@ -240,7 +233,7 @@ const Assistant: React.FC = () => {
<span className="w-2 h-2 bg-gray-400 rounded-full animate-bounce" style={{ animationDelay: '150ms' }} />
<span className="w-2 h-2 bg-gray-400 rounded-full animate-bounce" style={{ animationDelay: '300ms' }} />
</div>
<span className="text-sm text-gray-500">Thinking...</span>
<span className="text-sm text-gray-500">Pensando...</span>
</div>
</div>
</div>
@ -259,10 +252,11 @@ const Assistant: React.FC = () => {
{/* Input */}
<ChatInput
onSendMessage={sendMessage}
onSendMessage={handleSendMessage}
onStopStreaming={stopStreaming}
isLoading={isLoading}
isLoading={loading}
isStreaming={isStreaming}
placeholder={`Pregunta sobre ${mockWatchlist[0]?.symbol || 'cualquier simbolo'}...`}
/>
</div>
</div>