learning_ai_invt_trdg/web/src/components/ChatControl.tsx

784 lines
41 KiB
TypeScript

import { useState, useRef, useEffect, useMemo } from 'react';
import { createPortal } from 'react-dom';
import { tradingRuntime } from '../lib/runtime';
import { getPlatformAccessToken } from '../lib/authSession';
import { createRequestId } from '../../../shared/request-id.js';
import {
Send, X, Bot, User,
Check, Loader2,
Zap, Copy
} from 'lucide-react';
import { Button } from './ui/button';
import { cn } from '../lib/utils';
interface ChatMessage {
id: number;
role: 'user' | 'assistant';
content: string;
profileData?: any;
action?: string;
timestamp: Date;
}
interface ChatControlProps {
profiles: any[];
onApplyProfile: (action: string, profile: any) => Promise<{ success: boolean; error?: string }>;
}
export interface QuickAction {
label: string;
prompt: string;
}
export const BASE_QUICK_ACTIONS: QuickAction[] = [
{ label: 'Conservative Trader', prompt: 'Create a conservative BTC/ETH swing trader with $2000 capital and 1% risk' },
{ label: 'Aggressive Scalper', prompt: 'Build an aggressive scalper for SOL/DOGE with $500 capital, 3% risk and all rules enabled' },
{ label: 'Low Risk Profile', prompt: 'Create a low-risk profile that only trades BTC during London and NY sessions with $5000 capital' },
{ label: 'AI Momentum', prompt: 'Create a momentum strategy with AI analysis enabled, focusing on ETH/SOL with 2% risk' },
{ label: 'What rules?', prompt: 'What rules should I use for a day trading strategy?' },
{ label: 'Modify existing', prompt: 'Show me my existing profiles and suggest improvements' },
];
export const cloneProfileDraft = (profileData: any) => ({
...profileData,
strategy_config: profileData?.strategy_config
? JSON.parse(JSON.stringify(profileData.strategy_config))
: undefined,
});
export const normalizeName = (value: string) => value.toLowerCase().replace(/[^a-z0-9]/g, '');
export const buildQuickActions = (profiles: any[]): QuickAction[] => {
const profileActions: QuickAction[] = [];
const highScalper = profiles.find((profileOption) => {
const normalized = normalizeName(String(profileOption?.name || ''));
return normalized.includes('highriskscalper') || normalized.includes('highscal');
});
const conservativeBag = profiles.find((profileOption) => {
const normalized = normalizeName(String(profileOption?.name || ''));
return normalized.includes('conservativebag');
});
if (highScalper?.name) {
profileActions.push({
label: `Tune ${highScalper.name}`,
prompt: `Review my current profile "${highScalper.name}" and recommend improved parameters before applying an update.`,
});
}
if (conservativeBag?.name) {
profileActions.push({
label: `Tune ${conservativeBag.name}`,
prompt: `Review my current profile "${conservativeBag.name}" and recommend improved parameters before applying an update.`,
});
}
return [...profileActions, ...BASE_QUICK_ACTIONS];
};
export const normalizeProfileForApply = (profileData: any) => ({
...profileData,
name: String(profileData?.name || 'AI Profile').trim(),
allocated_capital: Number(profileData?.allocated_capital || 0),
risk_per_trade_percent: Number(profileData?.risk_per_trade_percent || 0),
symbols: String(profileData?.symbols || '').trim(),
is_active: profileData?.is_active !== false,
});
// 3D Robot SVG Icon
const RobotIcon = ({ size = 32 }: { size?: number }) => (
<svg width={size} height={size} viewBox="0 0 64 64" fill="none" xmlns="http://www.w3.org/2000/svg">
{/* Antenna */}
<line x1="32" y1="6" x2="32" y2="14" stroke="#00ff88" strokeWidth="2.5" strokeLinecap="round" />
<circle cx="32" cy="5" r="3" fill="#00ff88" opacity="0.9">
<animate attributeName="opacity" values="0.5;1;0.5" dur="2s" repeatCount="indefinite" />
</circle>
{/* Head */}
<rect x="14" y="14" width="36" height="28" rx="8" fill="url(#headGrad)" stroke="#00ff88" strokeWidth="1.5" opacity="0.95" />
{/* Eyes */}
<circle cx="24" cy="28" r="4.5" fill="#0a0b10" />
<circle cx="24" cy="28" r="3" fill="#00ff88" opacity="0.9">
<animate attributeName="r" values="3;2.5;3" dur="3s" repeatCount="indefinite" />
</circle>
<circle cx="40" cy="28" r="4.5" fill="#0a0b10" />
<circle cx="40" cy="28" r="3" fill="#00ff88" opacity="0.9">
<animate attributeName="r" values="3;2.5;3" dur="3s" repeatCount="indefinite" />
</circle>
{/* Mouth */}
<rect x="24" y="35" width="16" height="3" rx="1.5" fill="#00ff88" opacity="0.4" />
{/* Body */}
<rect x="18" y="44" width="28" height="14" rx="5" fill="url(#bodyGrad)" stroke="#00ff88" strokeWidth="1" opacity="0.8" />
{/* Body detail */}
<circle cx="32" cy="51" r="3" fill="#00ff88" opacity="0.3" />
{/* Arms */}
<rect x="8" y="46" width="8" height="10" rx="4" fill="url(#headGrad)" stroke="#00ff88" strokeWidth="1" opacity="0.7" />
<rect x="48" y="46" width="8" height="10" rx="4" fill="url(#headGrad)" stroke="#00ff88" strokeWidth="1" opacity="0.7" />
<defs>
<linearGradient id="headGrad" x1="14" y1="14" x2="50" y2="42" gradientUnits="userSpaceOnUse">
<stop stopColor="#1a1b2e" />
<stop offset="1" stopColor="#12131a" />
</linearGradient>
<linearGradient id="bodyGrad" x1="18" y1="44" x2="46" y2="58" gradientUnits="userSpaceOnUse">
<stop stopColor="#1a1b2e" />
<stop offset="1" stopColor="#0f1017" />
</linearGradient>
</defs>
</svg>
);
export const ChatControl = ({ profiles, onApplyProfile }: ChatControlProps) => {
const [isOpen, setIsOpen] = useState(false);
const [messages, setMessages] = useState<ChatMessage[]>([
{
id: 0,
role: 'assistant',
content: "Hi! I'm your trading assistant. Tell me what kind of strategy profile you'd like to create or modify, and I'll generate the configuration for you.\n\nTry: \"Create a conservative BTC scalper with $1000 capital\"",
timestamp: new Date(),
}
]);
const [input, setInput] = useState('');
const [isLoading, setIsLoading] = useState(false);
const [appliedIds, setAppliedIds] = useState<Set<number>>(new Set());
const [cancelledIds, setCancelledIds] = useState<Set<number>>(new Set());
const [editingIds, setEditingIds] = useState<Set<number>>(new Set());
const [draftProfiles, setDraftProfiles] = useState<Record<number, any>>({});
const messagesEndRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLTextAreaElement>(null);
const quickActions = useMemo(() => buildQuickActions(profiles), [profiles]);
const openDraftEditor = (msg: ChatMessage) => {
if (!msg.profileData) return;
setDraftProfiles((prev) => ({ ...prev, [msg.id]: cloneProfileDraft(msg.profileData) }));
setEditingIds((prev) => new Set(prev).add(msg.id));
};
const closeDraftEditor = (msgId: number) => {
setEditingIds((prev) => {
const next = new Set(prev);
next.delete(msgId);
return next;
});
};
const resetDraft = (msg: ChatMessage) => {
if (!msg.profileData) return;
setDraftProfiles((prev) => ({ ...prev, [msg.id]: cloneProfileDraft(msg.profileData) }));
};
const updateDraftField = (msgId: number, field: string, value: any) => {
setDraftProfiles((prev) => ({
...prev,
[msgId]: {
...(prev[msgId] || {}),
[field]: value,
},
}));
};
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [messages]);
useEffect(() => {
if (isOpen) {
setTimeout(() => inputRef.current?.focus(), 300);
}
}, [isOpen]);
const sendMessage = async (text?: string) => {
const msg = text || input.trim();
if (!msg || isLoading) return;
const userMsg: ChatMessage = {
id: Date.now(),
role: 'user',
content: msg,
timestamp: new Date(),
};
setMessages(prev => [...prev, userMsg]);
setInput('');
setIsLoading(true);
try {
const apiUrl = tradingRuntime.tradingApiUrl;
const accessToken = await getPlatformAccessToken();
const res = await fetch(`${apiUrl}/api/chat`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${accessToken}`,
'x-request-id': createRequestId('web-chat')
},
body: JSON.stringify({
message: msg,
context: profiles.map(p => ({
id: p.id,
name: p.name,
allocated_capital: p.allocated_capital,
risk_per_trade_percent: p.risk_per_trade_percent,
symbols: p.symbols,
is_active: p.is_active,
strategy_config: p.strategy_config,
})),
}),
});
if (!res.ok) {
const err = await res.json();
throw new Error(err.error || 'Chat request failed');
}
const data = await res.json();
const assistantMsg: ChatMessage = {
id: Date.now() + 1,
role: 'assistant',
content: data.summary || data.reasoning || 'Profile configuration generated.',
profileData: data.profile || null,
action: data.action,
timestamp: new Date(),
};
if (data.reasoning && data.summary) {
assistantMsg.content = `${data.summary}\n\n${data.reasoning}`;
}
setMessages(prev => [...prev, assistantMsg]);
} catch (err: any) {
setMessages(prev => [...prev, {
id: Date.now() + 1,
role: 'assistant',
content: `Error: ${err.message}`,
timestamp: new Date(),
}]);
}
setIsLoading(false);
};
const handleApply = async (msg: ChatMessage) => {
if (msg.profileData && msg.action) {
const activeDraft = draftProfiles[msg.id] || msg.profileData;
const payload = normalizeProfileForApply(activeDraft);
const result = await onApplyProfile(msg.action, payload);
if (result.success) {
setAppliedIds(prev => new Set(prev).add(msg.id));
closeDraftEditor(msg.id);
setMessages(prev => [...prev, {
id: Date.now(),
role: 'assistant',
content: msg.action === 'create_profile'
? `Profile "${payload.name}" has been created successfully! It's now visible in Strategy Clusters and the bot will pick it up on next sync (~60s).`
: `Profile "${payload.name}" has been updated successfully!`,
timestamp: new Date(),
}]);
} else {
setMessages(prev => [...prev, {
id: Date.now(),
role: 'assistant',
content: `Failed to ${msg.action === 'create_profile' ? 'create' : 'update'} profile: ${result.error || 'Unknown error'}. Please try again or check your permissions.`,
timestamp: new Date(),
}]);
}
}
};
const handleCancel = (msgId: number) => {
setCancelledIds(prev => new Set(prev).add(msgId));
closeDraftEditor(msgId);
setMessages(prev => [...prev, {
id: Date.now(),
role: 'assistant',
content: 'Profile creation cancelled. You can ask me to create a different one or modify the parameters.',
timestamp: new Date(),
}]);
};
const copyJson = (data: any) => {
navigator.clipboard.writeText(JSON.stringify(data, null, 2));
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
sendMessage();
}
};
const assistantTint = 'var(--accent-soft)';
const panelStyle: React.CSSProperties = {
background: 'var(--card)',
border: '1px solid var(--border)',
boxShadow: '0 25px 80px rgba(0,0,0,0.22)',
};
const inputStyle: React.CSSProperties = {
background: 'var(--input)',
border: '1px solid var(--border)',
color: 'var(--foreground)',
boxShadow: 'none',
caretColor: 'var(--ring)',
};
// Floating robot button - bottom right corner (portaled to body to avoid parent CSS issues)
if (!isOpen) {
return createPortal(
<button
onClick={() => setIsOpen(true)}
style={{
position: 'fixed',
bottom: '24px',
right: '24px',
zIndex: 99999,
cursor: 'pointer',
background: 'none',
border: 'none',
padding: 0,
animation: 'robotFloat 3s ease-in-out infinite',
}}
className="group"
>
<div style={{ position: 'relative' }}>
{/* Glow ring */}
<div style={{
position: 'absolute',
inset: '-8px',
borderRadius: '50%',
opacity: 0.4,
background: 'radial-gradient(circle, color-mix(in oklab, var(--ring) 30%, transparent), transparent 70%)',
transition: 'opacity 0.3s',
}} />
{/* Robot container */}
<div style={{
position: 'relative',
width: '56px',
height: '56px',
borderRadius: '16px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
background: 'var(--card)',
border: '1.5px solid var(--border)',
boxShadow: '0 8px 32px rgba(0,0,0,0.18), 0 0 20px color-mix(in oklab, var(--ring) 16%, transparent)',
transition: 'transform 0.2s',
}}>
<RobotIcon size={34} />
</div>
{/* Pulse dot */}
<div style={{
position: 'absolute',
top: '-2px',
right: '-2px',
width: '14px',
height: '14px',
borderRadius: '50%',
background: 'var(--primary)',
border: '2px solid var(--card)',
boxShadow: '0 0 8px color-mix(in oklab, var(--primary) 45%, transparent)',
animation: 'pulseDot 2s ease-in-out infinite',
}} />
</div>
<style>{`
@keyframes robotFloat {
0%, 100% { transform: translateY(0px); }
50% { transform: translateY(-4px); }
}
@keyframes pulseDot {
0%, 100% { transform: scale(1); opacity: 1; }
50% { transform: scale(0.8); opacity: 0.6; }
}
`}</style>
</button>,
document.body
);
}
return createPortal(
<>
{/* Backdrop */}
<div
onClick={() => setIsOpen(false)}
style={{
position: 'fixed',
inset: 0,
zIndex: 999998,
background: 'rgba(15, 23, 42, 0.45)',
backdropFilter: 'blur(6px)',
animation: 'fadeIn 0.15s ease-out',
}}
/>
<div style={{
position: 'fixed',
bottom: '24px',
right: '24px',
zIndex: 999999,
width: '460px',
maxWidth: 'calc(100vw - 48px)',
height: '640px',
maxHeight: 'calc(100vh - 48px)',
display: 'flex',
flexDirection: 'column',
borderRadius: '20px',
overflow: 'hidden',
animation: 'chatSlideUp 0.25s ease-out',
...panelStyle,
}}>
{/* Header */}
<div style={{
background: 'var(--hero-gradient)',
borderBottom: '1px solid var(--border)',
padding: '14px 18px',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
}}>
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-xl flex items-center justify-center" style={{
background: 'var(--card)',
border: '1px solid var(--border)',
boxShadow: '0 2px 8px rgba(0,0,0,0.08)',
}}>
<RobotIcon size={26} />
</div>
<div>
<h3 className="text-[13px] font-bold text-[var(--foreground)] leading-none">AI Strategy Assistant</h3>
<p className="mt-1 text-[10px] text-[var(--muted-foreground)]">Create & manage profiles with natural language</p>
</div>
</div>
<Button
onClick={() => setIsOpen(false)}
variant="ghost"
size="icon"
className="h-8 w-8 rounded-lg"
>
<X size={14} />
</Button>
</div>
{/* Messages */}
<div className="flex-1 overflow-y-auto px-4 py-4 space-y-4" style={{
background: 'var(--background)',
scrollbarWidth: 'thin',
scrollbarColor: 'var(--border) transparent',
}}>
{messages.map(msg => (
<div key={msg.id} className={`flex gap-2.5 ${msg.role === 'user' ? 'flex-row-reverse' : ''}`}>
{/* Avatar */}
<div className={cn('shrink-0 flex h-7 w-7 items-center justify-center rounded-lg border', msg.role === 'user' ? 'bg-blue-500/12 border-blue-500/20' : '')}
style={msg.role === 'user' ? undefined : { background: assistantTint, borderColor: 'var(--border)' }}>
{msg.role === 'user'
? <User size={12} className="text-blue-400" />
: <Bot size={12} className="text-[var(--primary)]" />
}
</div>
{/* Bubble */}
<div className={`max-w-[85%] ${msg.role === 'user' ? 'text-right' : ''}`}>
<div className="rounded-xl px-3.5 py-2.5 text-[12px] leading-relaxed" style={{
background: msg.role === 'user'
? 'linear-gradient(135deg, rgba(59,130,246,0.15), rgba(59,130,246,0.08))'
: 'var(--card)',
border: `1px solid ${msg.role === 'user' ? 'rgba(59,130,246,0.2)' : 'var(--border)'}`,
color: msg.role === 'user' ? '#93c5fd' : 'var(--foreground)',
whiteSpace: 'pre-wrap',
}}>
{msg.content}
</div>
{/* Profile preview card */}
{msg.profileData && msg.action !== 'explain' && (() => {
const activeProfileData = draftProfiles[msg.id] || msg.profileData;
const isEditing = editingIds.has(msg.id);
const activeRules = Array.isArray(activeProfileData?.strategy_config?.rules)
? activeProfileData.strategy_config.rules.filter((r: any) => r?.enabled).length
: 0;
return (
<div className="mt-2 rounded-xl overflow-hidden" style={{
background: 'var(--card-elevated)',
border: '1px solid var(--border)',
boxShadow: '0 2px 8px rgba(0,0,0,0.08)',
}}>
<div className="px-3.5 py-2 flex items-center justify-between" style={{
background: 'var(--accent-soft)',
borderBottom: '1px solid var(--border)',
}}>
<div className="flex items-center gap-2">
<Zap size={10} className="text-[var(--primary)]" />
<span className="text-[10px] font-bold text-[var(--muted-foreground)] uppercase tracking-wider">
{msg.action === 'create_profile' ? 'New Profile' : 'Update Profile'}
</span>
</div>
<button
onClick={() => copyJson(activeProfileData)}
className="text-[var(--muted-foreground)] hover:text-[var(--foreground)] transition-colors"
title="Copy JSON"
>
<Copy size={11} />
</button>
</div>
<div className="px-3.5 py-2.5 space-y-1.5">
<div className="flex justify-between">
<span className="text-[10px] text-[var(--muted-foreground)]">Name</span>
<span className="text-[11px] font-bold text-[var(--foreground)]">{activeProfileData?.name}</span>
</div>
{activeProfileData?.allocated_capital ? (
<div className="flex justify-between">
<span className="text-[10px] text-[var(--muted-foreground)]">Capital</span>
<span className="text-[11px] font-bold text-blue-400 font-mono">${activeProfileData.allocated_capital}</span>
</div>
) : null}
{activeProfileData?.risk_per_trade_percent ? (
<div className="flex justify-between">
<span className="text-[10px] text-[var(--muted-foreground)]">Risk / Trade</span>
<span className="text-[11px] font-bold text-amber-400 font-mono">{activeProfileData.risk_per_trade_percent}%</span>
</div>
) : null}
{activeProfileData?.symbols ? (
<div className="flex justify-between items-center">
<span className="text-[10px] text-[var(--muted-foreground)]">Symbols</span>
<span className="text-[10px] font-mono text-[var(--foreground)]">{activeProfileData.symbols}</span>
</div>
) : null}
{activeRules > 0 ? (
<div className="flex justify-between items-center">
<span className="text-[10px] text-[var(--muted-foreground)]">Rules</span>
<span className="text-[10px] font-mono text-[var(--primary)]">
{activeRules} active
</span>
</div>
) : null}
</div>
{isEditing ? (
<div className="px-3.5 pb-3 space-y-2">
<div className="text-[10px] text-[var(--muted-foreground)] uppercase tracking-wider font-bold">Edit Parameters Before Apply</div>
<input
value={activeProfileData?.name || ''}
onChange={(e) => updateDraftField(msg.id, 'name', e.target.value)}
placeholder="Profile Name"
className="w-full rounded-lg px-2.5 py-1.5 text-[11px] outline-none"
style={inputStyle}
/>
<div className="grid grid-cols-2 gap-2">
<input
type="number"
min="0"
step="1"
value={activeProfileData?.allocated_capital ?? ''}
onChange={(e) => updateDraftField(msg.id, 'allocated_capital', e.target.value)}
placeholder="Capital"
className="w-full rounded-lg px-2.5 py-1.5 text-[11px] outline-none"
style={inputStyle}
/>
<input
type="number"
min="0"
step="0.1"
value={activeProfileData?.risk_per_trade_percent ?? ''}
onChange={(e) => updateDraftField(msg.id, 'risk_per_trade_percent', e.target.value)}
placeholder="Risk %"
className="w-full rounded-lg px-2.5 py-1.5 text-[11px] outline-none"
style={inputStyle}
/>
</div>
<input
value={activeProfileData?.symbols || ''}
onChange={(e) => updateDraftField(msg.id, 'symbols', e.target.value)}
placeholder="Symbols (e.g. BTC/USDT,ETH/USDT)"
className="w-full rounded-lg px-2.5 py-1.5 text-[11px] outline-none"
style={inputStyle}
/>
<div className="flex items-center justify-between rounded-lg px-2.5 py-1.5" style={inputStyle}>
<span className="text-[10px] text-[var(--muted-foreground)] uppercase tracking-wider">Auto Trading</span>
<select
value={activeProfileData?.is_active === false ? 'false' : 'true'}
onChange={(e) => updateDraftField(msg.id, 'is_active', e.target.value === 'true')}
className="rounded px-2 py-1 text-[10px] outline-none"
style={inputStyle}
>
<option value="true">Active</option>
<option value="false">Paused</option>
</select>
</div>
<div className="flex justify-end">
<Button
onClick={() => resetDraft(msg)}
variant="outline"
size="sm"
className="h-8 px-2.5 text-[9px] uppercase tracking-wider"
>
Reset
</Button>
</div>
</div>
) : null}
{!appliedIds.has(msg.id) && !cancelledIds.has(msg.id) ? (
<div className="flex" style={{ borderTop: '1px solid var(--border)' }}>
<button
onClick={() => handleCancel(msg.id)}
className="flex-1 py-2.5 flex items-center justify-center gap-1.5 text-[11px] font-bold transition-all hover:bg-white/[0.03]"
style={{
color: 'var(--destructive)',
borderRight: '1px solid var(--border)',
}}
>
<X size={11} />
Cancel
</button>
<button
onClick={() => isEditing ? closeDraftEditor(msg.id) : openDraftEditor(msg)}
className="flex-1 py-2.5 flex items-center justify-center gap-1.5 text-[11px] font-bold transition-all hover:bg-white/[0.03]"
style={{
color: '#fbbf24',
borderRight: '1px solid var(--border)',
}}
>
<Copy size={11} />
{isEditing ? 'Done Editing' : 'Edit Params'}
</button>
<button
onClick={() => handleApply(msg)}
className="flex-[2] py-2.5 flex items-center justify-center gap-1.5 text-[11px] font-bold transition-all hover:brightness-110"
style={{
background: 'var(--accent-soft)',
color: 'var(--primary)',
}}
>
<Zap size={11} />
Apply to Dashboard
</button>
</div>
) : cancelledIds.has(msg.id) ? (
<div className="w-full py-2 flex items-center justify-center gap-1.5 text-[10px] font-semibold" style={{
borderTop: '1px solid var(--border)',
color: 'var(--muted-foreground)',
}}>
<X size={10} />
Cancelled
</div>
) : (
<div className="w-full py-2.5 flex items-center justify-center gap-2 text-[11px] font-bold" style={{
background: 'var(--accent-soft)',
borderTop: '1px solid var(--border)',
color: 'var(--primary)',
}}>
<Check size={12} />
Applied
</div>
)}
</div>
);
})()}
<span className="mt-1 block text-[9px] text-[var(--muted-foreground)]">
{msg.timestamp.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
</span>
</div>
</div>
))}
{/* Suggested quick actions - shown when only welcome message exists */}
{messages.length <= 1 && !isLoading && (
<div className="mt-2">
<p style={{ fontSize: '9px', color: 'var(--muted-foreground)', textTransform: 'uppercase', letterSpacing: '0.1em', fontWeight: 700, marginBottom: '10px', paddingLeft: '4px' }}>Quick Actions</p>
<div className="grid grid-cols-2 gap-2">
{quickActions.map((action, i) => (
<button
key={i}
onClick={() => sendMessage(action.prompt)}
className="text-left px-3.5 py-3 rounded-xl transition-all"
style={{
background: 'var(--card)',
border: '1px solid var(--border)',
cursor: 'pointer',
}}
onMouseEnter={e => {
e.currentTarget.style.borderColor = 'var(--ring)';
e.currentTarget.style.background = 'var(--accent-soft)';
}}
onMouseLeave={e => {
e.currentTarget.style.borderColor = 'var(--border)';
e.currentTarget.style.background = 'var(--card)';
}}
>
<span style={{ fontSize: '13px', display: 'block', marginBottom: '3px', color: 'var(--foreground)' }}>{action.label}</span>
<span style={{ fontSize: '10px', color: 'var(--muted-foreground)', lineHeight: '1.4', display: 'block' }}>{action.prompt.slice(0, 55)}...</span>
</button>
))}
</div>
</div>
)}
{isLoading && (
<div className="flex gap-2.5">
<div className="flex h-7 w-7 items-center justify-center rounded-lg border" style={{ background: assistantTint, borderColor: 'var(--border)' }}>
<Bot size={12} className="text-[var(--primary)]" />
</div>
<div className="rounded-xl px-3.5 py-2.5 flex items-center gap-2" style={{
background: 'var(--card)',
border: '1px solid var(--border)',
}}>
<Loader2 size={12} className="animate-spin text-[var(--primary)]" />
<span className="text-[11px] text-[var(--muted-foreground)]">Generating configuration...</span>
</div>
</div>
)}
<div ref={messagesEndRef} />
</div>
{/* Input area */}
<div style={{
background: 'var(--card)',
borderTop: '1px solid var(--border)',
padding: '14px 16px',
}}>
<div className="flex items-end gap-2.5">
<div className="flex-1 relative">
<textarea
ref={inputRef}
value={input}
onChange={e => setInput(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Describe a strategy profile..."
disabled={isLoading}
rows={2}
className="w-full rounded-xl py-3 pl-4 pr-12 outline-none disabled:opacity-50 transition-all resize-none"
style={{ ...inputStyle, lineHeight: '1.5', fontFamily: 'inherit', fontSize: '13px' }}
/>
<button
onClick={() => sendMessage()}
disabled={!input.trim() || isLoading}
className="absolute right-2.5 bottom-2.5 w-8 h-8 rounded-lg flex items-center justify-center transition-all disabled:opacity-20 hover:scale-105"
style={{
background: input.trim() ? 'var(--primary)' : 'var(--accent-soft)',
color: input.trim() ? 'var(--primary-foreground)' : 'var(--muted-foreground)',
boxShadow: input.trim() ? '0 2px 10px color-mix(in oklab, var(--primary) 25%, transparent)' : 'none',
}}
>
<Send size={14} />
</button>
</div>
</div>
<p className="mt-1.5 ml-1 text-[9px] text-[var(--muted-foreground)]">Enter to send · Shift+Enter new line</p>
</div>
<style>{`
@keyframes chatSlideUp {
from { transform: translateY(20px) scale(0.97); opacity: 0; }
to { transform: translateY(0) scale(1); opacity: 1; }
}
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
`}</style>
</div>
</>,
document.body
);
};