feat(ui): migrate trade plan and chat controls

This commit is contained in:
Saravana Achu Mac 2026-05-06 15:49:04 -07:00
parent 3892093dc4
commit 324e34d537
3 changed files with 318 additions and 305 deletions

View File

@ -4,22 +4,22 @@ import { tradingRuntime } from '../lib/runtime';
import { getPlatformAccessToken } from '../lib/authSession'; import { getPlatformAccessToken } from '../lib/authSession';
import { createRequestId } from '../../../shared/request-id.js'; import { createRequestId } from '../../../shared/request-id.js';
import { import {
Send, X, Bot, User, Send, X, Bot, User,
Check, Loader2, Check, Loader2,
Zap, Copy Zap, Copy
} from 'lucide-react'; } from 'lucide-react';
import { Button } from './ui/button'; import { Button, Input, Select, Textarea } from './ui/Primitives';
import { cn } from '../lib/utils'; import { cn } from '../lib/utils';
interface ChatMessage { interface ChatMessage {
id: number; id: number;
role: 'user' | 'assistant'; role: 'user' | 'assistant';
content: string; content: string;
profileData?: any; profileData?: any;
action?: string; action?: string;
timestamp: Date; timestamp: Date;
} }
interface ChatControlProps { interface ChatControlProps {
profiles: any[]; profiles: any[];
onApplyProfile: (action: string, profile: any) => Promise<{ success: boolean; error?: string }>; onApplyProfile: (action: string, profile: any) => Promise<{ success: boolean; error?: string }>;
@ -84,58 +84,58 @@ export const normalizeProfileForApply = (profileData: any) => ({
symbols: String(profileData?.symbols || '').trim(), symbols: String(profileData?.symbols || '').trim(),
is_active: profileData?.is_active !== false, is_active: profileData?.is_active !== false,
}); });
// 3D Robot SVG Icon // 3D Robot SVG Icon
const RobotIcon = ({ size = 32 }: { size?: number }) => ( 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"> <svg width={size} height={size} viewBox="0 0 64 64" fill="none" xmlns="http://www.w3.org/2000/svg">
{/* Antenna */} {/* Antenna */}
<line x1="32" y1="6" x2="32" y2="14" stroke="#00ff88" strokeWidth="2.5" strokeLinecap="round" /> <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"> <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" /> <animate attributeName="opacity" values="0.5;1;0.5" dur="2s" repeatCount="indefinite" />
</circle> </circle>
{/* Head */} {/* Head */}
<rect x="14" y="14" width="36" height="28" rx="8" fill="url(#headGrad)" stroke="#00ff88" strokeWidth="1.5" opacity="0.95" /> <rect x="14" y="14" width="36" height="28" rx="8" fill="url(#headGrad)" stroke="#00ff88" strokeWidth="1.5" opacity="0.95" />
{/* Eyes */} {/* Eyes */}
<circle cx="24" cy="28" r="4.5" fill="#0a0b10" /> <circle cx="24" cy="28" r="4.5" fill="#0a0b10" />
<circle cx="24" cy="28" r="3" fill="#00ff88" opacity="0.9"> <circle cx="24" cy="28" r="3" fill="#00ff88" opacity="0.9">
<animate attributeName="r" values="3;2.5;3" dur="3s" repeatCount="indefinite" /> <animate attributeName="r" values="3;2.5;3" dur="3s" repeatCount="indefinite" />
</circle> </circle>
<circle cx="40" cy="28" r="4.5" fill="#0a0b10" /> <circle cx="40" cy="28" r="4.5" fill="#0a0b10" />
<circle cx="40" cy="28" r="3" fill="#00ff88" opacity="0.9"> <circle cx="40" cy="28" r="3" fill="#00ff88" opacity="0.9">
<animate attributeName="r" values="3;2.5;3" dur="3s" repeatCount="indefinite" /> <animate attributeName="r" values="3;2.5;3" dur="3s" repeatCount="indefinite" />
</circle> </circle>
{/* Mouth */} {/* Mouth */}
<rect x="24" y="35" width="16" height="3" rx="1.5" fill="#00ff88" opacity="0.4" /> <rect x="24" y="35" width="16" height="3" rx="1.5" fill="#00ff88" opacity="0.4" />
{/* Body */} {/* Body */}
<rect x="18" y="44" width="28" height="14" rx="5" fill="url(#bodyGrad)" stroke="#00ff88" strokeWidth="1" opacity="0.8" /> <rect x="18" y="44" width="28" height="14" rx="5" fill="url(#bodyGrad)" stroke="#00ff88" strokeWidth="1" opacity="0.8" />
{/* Body detail */} {/* Body detail */}
<circle cx="32" cy="51" r="3" fill="#00ff88" opacity="0.3" /> <circle cx="32" cy="51" r="3" fill="#00ff88" opacity="0.3" />
{/* Arms */} {/* Arms */}
<rect x="8" y="46" width="8" height="10" rx="4" fill="url(#headGrad)" stroke="#00ff88" strokeWidth="1" opacity="0.7" /> <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" /> <rect x="48" y="46" width="8" height="10" rx="4" fill="url(#headGrad)" stroke="#00ff88" strokeWidth="1" opacity="0.7" />
<defs> <defs>
<linearGradient id="headGrad" x1="14" y1="14" x2="50" y2="42" gradientUnits="userSpaceOnUse"> <linearGradient id="headGrad" x1="14" y1="14" x2="50" y2="42" gradientUnits="userSpaceOnUse">
<stop stopColor="#1a1b2e" /> <stop stopColor="#1a1b2e" />
<stop offset="1" stopColor="#12131a" /> <stop offset="1" stopColor="#12131a" />
</linearGradient> </linearGradient>
<linearGradient id="bodyGrad" x1="18" y1="44" x2="46" y2="58" gradientUnits="userSpaceOnUse"> <linearGradient id="bodyGrad" x1="18" y1="44" x2="46" y2="58" gradientUnits="userSpaceOnUse">
<stop stopColor="#1a1b2e" /> <stop stopColor="#1a1b2e" />
<stop offset="1" stopColor="#0f1017" /> <stop offset="1" stopColor="#0f1017" />
</linearGradient> </linearGradient>
</defs> </defs>
</svg> </svg>
); );
export const ChatControl = ({ profiles, onApplyProfile }: ChatControlProps) => { export const ChatControl = ({ profiles, onApplyProfile }: ChatControlProps) => {
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const [messages, setMessages] = useState<ChatMessage[]>([ const [messages, setMessages] = useState<ChatMessage[]>([
{ {
id: 0, id: 0,
role: 'assistant', 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\"", 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(), timestamp: new Date(),
} }
]); ]);
const [input, setInput] = useState(''); const [input, setInput] = useState('');
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [appliedIds, setAppliedIds] = useState<Set<number>>(new Set()); const [appliedIds, setAppliedIds] = useState<Set<number>>(new Set());
@ -175,31 +175,31 @@ export const ChatControl = ({ profiles, onApplyProfile }: ChatControlProps) => {
}, },
})); }));
}; };
useEffect(() => { useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }); messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [messages]); }, [messages]);
useEffect(() => { useEffect(() => {
if (isOpen) { if (isOpen) {
setTimeout(() => inputRef.current?.focus(), 300); setTimeout(() => inputRef.current?.focus(), 300);
} }
}, [isOpen]); }, [isOpen]);
const sendMessage = async (text?: string) => { const sendMessage = async (text?: string) => {
const msg = text || input.trim(); const msg = text || input.trim();
if (!msg || isLoading) return; if (!msg || isLoading) return;
const userMsg: ChatMessage = { const userMsg: ChatMessage = {
id: Date.now(), id: Date.now(),
role: 'user', role: 'user',
content: msg, content: msg,
timestamp: new Date(), timestamp: new Date(),
}; };
setMessages(prev => [...prev, userMsg]); setMessages(prev => [...prev, userMsg]);
setInput(''); setInput('');
setIsLoading(true); setIsLoading(true);
try { try {
const apiUrl = tradingRuntime.tradingApiUrl; const apiUrl = tradingRuntime.tradingApiUrl;
const accessToken = await getPlatformAccessToken(); const accessToken = await getPlatformAccessToken();
@ -213,50 +213,50 @@ export const ChatControl = ({ profiles, onApplyProfile }: ChatControlProps) => {
body: JSON.stringify({ body: JSON.stringify({
message: msg, message: msg,
context: profiles.map(p => ({ context: profiles.map(p => ({
id: p.id, id: p.id,
name: p.name, name: p.name,
allocated_capital: p.allocated_capital, allocated_capital: p.allocated_capital,
risk_per_trade_percent: p.risk_per_trade_percent, risk_per_trade_percent: p.risk_per_trade_percent,
symbols: p.symbols, symbols: p.symbols,
is_active: p.is_active, is_active: p.is_active,
strategy_config: p.strategy_config, strategy_config: p.strategy_config,
})), })),
}), }),
}); });
if (!res.ok) { if (!res.ok) {
const err = await res.json(); const err = await res.json();
throw new Error(err.error || 'Chat request failed'); throw new Error(err.error || 'Chat request failed');
} }
const data = await res.json(); const data = await res.json();
const assistantMsg: ChatMessage = { const assistantMsg: ChatMessage = {
id: Date.now() + 1, id: Date.now() + 1,
role: 'assistant', role: 'assistant',
content: data.summary || data.reasoning || 'Profile configuration generated.', content: data.summary || data.reasoning || 'Profile configuration generated.',
profileData: data.profile || null, profileData: data.profile || null,
action: data.action, action: data.action,
timestamp: new Date(), timestamp: new Date(),
}; };
if (data.reasoning && data.summary) { if (data.reasoning && data.summary) {
assistantMsg.content = `${data.summary}\n\n${data.reasoning}`; assistantMsg.content = `${data.summary}\n\n${data.reasoning}`;
} }
setMessages(prev => [...prev, assistantMsg]); setMessages(prev => [...prev, assistantMsg]);
} catch (err: any) { } catch (err: any) {
setMessages(prev => [...prev, { setMessages(prev => [...prev, {
id: Date.now() + 1, id: Date.now() + 1,
role: 'assistant', role: 'assistant',
content: `Error: ${err.message}`, content: `Error: ${err.message}`,
timestamp: new Date(), timestamp: new Date(),
}]); }]);
} }
setIsLoading(false); setIsLoading(false);
}; };
const handleApply = async (msg: ChatMessage) => { const handleApply = async (msg: ChatMessage) => {
if (msg.profileData && msg.action) { if (msg.profileData && msg.action) {
const activeDraft = draftProfiles[msg.id] || msg.profileData; const activeDraft = draftProfiles[msg.id] || msg.profileData;
@ -275,15 +275,15 @@ export const ChatControl = ({ profiles, onApplyProfile }: ChatControlProps) => {
}]); }]);
} else { } else {
setMessages(prev => [...prev, { setMessages(prev => [...prev, {
id: Date.now(), id: Date.now(),
role: 'assistant', role: 'assistant',
content: `Failed to ${msg.action === 'create_profile' ? 'create' : 'update'} profile: ${result.error || 'Unknown error'}. Please try again or check your permissions.`, content: `Failed to ${msg.action === 'create_profile' ? 'create' : 'update'} profile: ${result.error || 'Unknown error'}. Please try again or check your permissions.`,
timestamp: new Date(), timestamp: new Date(),
}]); }]);
} }
} }
}; };
const handleCancel = (msgId: number) => { const handleCancel = (msgId: number) => {
setCancelledIds(prev => new Set(prev).add(msgId)); setCancelledIds(prev => new Set(prev).add(msgId));
closeDraftEditor(msgId); closeDraftEditor(msgId);
@ -291,14 +291,14 @@ export const ChatControl = ({ profiles, onApplyProfile }: ChatControlProps) => {
id: Date.now(), id: Date.now(),
role: 'assistant', role: 'assistant',
content: 'Profile creation cancelled. You can ask me to create a different one or modify the parameters.', content: 'Profile creation cancelled. You can ask me to create a different one or modify the parameters.',
timestamp: new Date(), timestamp: new Date(),
}]); }]);
}; };
const copyJson = (data: any) => { const copyJson = (data: any) => {
navigator.clipboard.writeText(JSON.stringify(data, null, 2)); navigator.clipboard.writeText(JSON.stringify(data, null, 2));
}; };
const handleKeyDown = (e: React.KeyboardEvent) => { const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' && !e.shiftKey) { if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault(); e.preventDefault();
@ -323,21 +323,23 @@ export const ChatControl = ({ profiles, onApplyProfile }: ChatControlProps) => {
// Floating robot button - bottom right corner (portaled to body to avoid parent CSS issues) // Floating robot button - bottom right corner (portaled to body to avoid parent CSS issues)
if (!isOpen) { if (!isOpen) {
return createPortal( return createPortal(
<button <Button
onClick={() => setIsOpen(true)} type="button"
style={{ onClick={() => setIsOpen(true)}
position: 'fixed', variant="ghost"
bottom: '24px', style={{
right: '24px', position: 'fixed',
zIndex: 99999, bottom: '24px',
cursor: 'pointer', right: '24px',
background: 'none', zIndex: 99999,
border: 'none', cursor: 'pointer',
padding: 0, background: 'none',
animation: 'robotFloat 3s ease-in-out infinite', border: 'none',
}} padding: 0,
className="group" animation: 'robotFloat 3s ease-in-out infinite',
> }}
className="group"
>
<div style={{ position: 'relative' }}> <div style={{ position: 'relative' }}>
{/* Glow ring */} {/* Glow ring */}
<div style={{ <div style={{
@ -351,8 +353,8 @@ export const ChatControl = ({ profiles, onApplyProfile }: ChatControlProps) => {
{/* Robot container */} {/* Robot container */}
<div style={{ <div style={{
position: 'relative', position: 'relative',
width: '56px', width: '56px',
height: '56px', height: '56px',
borderRadius: '16px', borderRadius: '16px',
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
@ -365,8 +367,8 @@ export const ChatControl = ({ profiles, onApplyProfile }: ChatControlProps) => {
<RobotIcon size={34} /> <RobotIcon size={34} />
</div> </div>
{/* Pulse dot */} {/* Pulse dot */}
<div style={{ <div style={{
position: 'absolute', position: 'absolute',
top: '-2px', top: '-2px',
right: '-2px', right: '-2px',
width: '14px', width: '14px',
@ -378,24 +380,24 @@ export const ChatControl = ({ profiles, onApplyProfile }: ChatControlProps) => {
animation: 'pulseDot 2s ease-in-out infinite', animation: 'pulseDot 2s ease-in-out infinite',
}} /> }} />
</div> </div>
<style>{` <style>{`
@keyframes robotFloat { @keyframes robotFloat {
0%, 100% { transform: translateY(0px); } 0%, 100% { transform: translateY(0px); }
50% { transform: translateY(-4px); } 50% { transform: translateY(-4px); }
} }
@keyframes pulseDot { @keyframes pulseDot {
0%, 100% { transform: scale(1); opacity: 1; } 0%, 100% { transform: scale(1); opacity: 1; }
50% { transform: scale(0.8); opacity: 0.6; } 50% { transform: scale(0.8); opacity: 0.6; }
} }
`}</style> `}</style>
</button>, </Button>,
document.body document.body
); );
} }
return createPortal( return createPortal(
<> <>
{/* Backdrop */} {/* Backdrop */}
<div <div
onClick={() => setIsOpen(false)} onClick={() => setIsOpen(false)}
style={{ style={{
@ -407,14 +409,14 @@ export const ChatControl = ({ profiles, onApplyProfile }: ChatControlProps) => {
animation: 'fadeIn 0.15s ease-out', animation: 'fadeIn 0.15s ease-out',
}} }}
/> />
<div style={{ <div style={{
position: 'fixed', position: 'fixed',
bottom: '24px', bottom: '24px',
right: '24px', right: '24px',
zIndex: 999999, zIndex: 999999,
width: '460px', width: '460px',
maxWidth: 'calc(100vw - 48px)', maxWidth: 'calc(100vw - 48px)',
height: '640px', height: '640px',
maxHeight: 'calc(100vh - 48px)', maxHeight: 'calc(100vh - 48px)',
display: 'flex', display: 'flex',
flexDirection: 'column', flexDirection: 'column',
@ -448,7 +450,7 @@ export const ChatControl = ({ profiles, onApplyProfile }: ChatControlProps) => {
<Button <Button
onClick={() => setIsOpen(false)} onClick={() => setIsOpen(false)}
variant="ghost" variant="ghost"
size="icon" size="sm"
className="h-8 w-8 rounded-lg" className="h-8 w-8 rounded-lg"
> >
<X size={14} /> <X size={14} />
@ -471,9 +473,9 @@ export const ChatControl = ({ profiles, onApplyProfile }: ChatControlProps) => {
: <Bot size={12} className="text-[var(--primary)]" /> : <Bot size={12} className="text-[var(--primary)]" />
} }
</div> </div>
{/* Bubble */} {/* Bubble */}
<div className={`max-w-[85%] ${msg.role === 'user' ? 'text-right' : ''}`}> <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={{ <div className="rounded-xl px-3.5 py-2.5 text-[12px] leading-relaxed" style={{
background: msg.role === 'user' background: msg.role === 'user'
? 'linear-gradient(135deg, rgba(59,130,246,0.15), rgba(59,130,246,0.08))' ? 'linear-gradient(135deg, rgba(59,130,246,0.15), rgba(59,130,246,0.08))'
@ -484,8 +486,8 @@ export const ChatControl = ({ profiles, onApplyProfile }: ChatControlProps) => {
}}> }}>
{msg.content} {msg.content}
</div> </div>
{/* Profile preview card */} {/* Profile preview card */}
{msg.profileData && msg.action !== 'explain' && (() => { {msg.profileData && msg.action !== 'explain' && (() => {
const activeProfileData = draftProfiles[msg.id] || msg.profileData; const activeProfileData = draftProfiles[msg.id] || msg.profileData;
const isEditing = editingIds.has(msg.id); const isEditing = editingIds.has(msg.id);
@ -509,13 +511,16 @@ export const ChatControl = ({ profiles, onApplyProfile }: ChatControlProps) => {
{msg.action === 'create_profile' ? 'New Profile' : 'Update Profile'} {msg.action === 'create_profile' ? 'New Profile' : 'Update Profile'}
</span> </span>
</div> </div>
<button <Button
type="button"
onClick={() => copyJson(activeProfileData)} onClick={() => copyJson(activeProfileData)}
variant="ghost"
size="sm"
className="text-[var(--muted-foreground)] hover:text-[var(--foreground)] transition-colors" className="text-[var(--muted-foreground)] hover:text-[var(--foreground)] transition-colors"
title="Copy JSON" title="Copy JSON"
> >
<Copy size={11} /> <Copy size={11} />
</button> </Button>
</div> </div>
<div className="px-3.5 py-2.5 space-y-1.5"> <div className="px-3.5 py-2.5 space-y-1.5">
@ -554,7 +559,7 @@ export const ChatControl = ({ profiles, onApplyProfile }: ChatControlProps) => {
{isEditing ? ( {isEditing ? (
<div className="px-3.5 pb-3 space-y-2"> <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> <div className="text-[10px] text-[var(--muted-foreground)] uppercase tracking-wider font-bold">Edit Parameters Before Apply</div>
<input <Input
value={activeProfileData?.name || ''} value={activeProfileData?.name || ''}
onChange={(e) => updateDraftField(msg.id, 'name', e.target.value)} onChange={(e) => updateDraftField(msg.id, 'name', e.target.value)}
placeholder="Profile Name" placeholder="Profile Name"
@ -562,7 +567,7 @@ export const ChatControl = ({ profiles, onApplyProfile }: ChatControlProps) => {
style={inputStyle} style={inputStyle}
/> />
<div className="grid grid-cols-2 gap-2"> <div className="grid grid-cols-2 gap-2">
<input <Input
type="number" type="number"
min="0" min="0"
step="1" step="1"
@ -572,7 +577,7 @@ export const ChatControl = ({ profiles, onApplyProfile }: ChatControlProps) => {
className="w-full rounded-lg px-2.5 py-1.5 text-[11px] outline-none" className="w-full rounded-lg px-2.5 py-1.5 text-[11px] outline-none"
style={inputStyle} style={inputStyle}
/> />
<input <Input
type="number" type="number"
min="0" min="0"
step="0.1" step="0.1"
@ -583,7 +588,7 @@ export const ChatControl = ({ profiles, onApplyProfile }: ChatControlProps) => {
style={inputStyle} style={inputStyle}
/> />
</div> </div>
<input <Input
value={activeProfileData?.symbols || ''} value={activeProfileData?.symbols || ''}
onChange={(e) => updateDraftField(msg.id, 'symbols', e.target.value)} onChange={(e) => updateDraftField(msg.id, 'symbols', e.target.value)}
placeholder="Symbols (e.g. BTC/USDT,ETH/USDT)" placeholder="Symbols (e.g. BTC/USDT,ETH/USDT)"
@ -592,15 +597,16 @@ export const ChatControl = ({ profiles, onApplyProfile }: ChatControlProps) => {
/> />
<div className="flex items-center justify-between rounded-lg px-2.5 py-1.5" 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> <span className="text-[10px] text-[var(--muted-foreground)] uppercase tracking-wider">Auto Trading</span>
<select <Select
value={activeProfileData?.is_active === false ? 'false' : 'true'} value={activeProfileData?.is_active === false ? 'false' : 'true'}
onChange={(e) => updateDraftField(msg.id, 'is_active', e.target.value === 'true')} onChange={(e) => updateDraftField(msg.id, 'is_active', e.target.value === 'true')}
className="rounded px-2 py-1 text-[10px] outline-none" className="rounded px-2 py-1 text-[10px] outline-none"
style={inputStyle} style={inputStyle}
> options={[
<option value="true">Active</option> { value: 'true', label: 'Active' },
<option value="false">Paused</option> { value: 'false', label: 'Paused' },
</select> ]}
/>
</div> </div>
<div className="flex justify-end"> <div className="flex justify-end">
<Button <Button
@ -617,8 +623,10 @@ export const ChatControl = ({ profiles, onApplyProfile }: ChatControlProps) => {
{!appliedIds.has(msg.id) && !cancelledIds.has(msg.id) ? ( {!appliedIds.has(msg.id) && !cancelledIds.has(msg.id) ? (
<div className="flex" style={{ borderTop: '1px solid var(--border)' }}> <div className="flex" style={{ borderTop: '1px solid var(--border)' }}>
<button <Button
type="button"
onClick={() => handleCancel(msg.id)} onClick={() => handleCancel(msg.id)}
variant="ghost"
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]" 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={{ style={{
color: 'var(--destructive)', color: 'var(--destructive)',
@ -627,9 +635,11 @@ export const ChatControl = ({ profiles, onApplyProfile }: ChatControlProps) => {
> >
<X size={11} /> <X size={11} />
Cancel Cancel
</button> </Button>
<button <Button
type="button"
onClick={() => isEditing ? closeDraftEditor(msg.id) : openDraftEditor(msg)} onClick={() => isEditing ? closeDraftEditor(msg.id) : openDraftEditor(msg)}
variant="ghost"
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]" 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={{ style={{
color: '#fbbf24', color: '#fbbf24',
@ -638,9 +648,11 @@ export const ChatControl = ({ profiles, onApplyProfile }: ChatControlProps) => {
> >
<Copy size={11} /> <Copy size={11} />
{isEditing ? 'Done Editing' : 'Edit Params'} {isEditing ? 'Done Editing' : 'Edit Params'}
</button> </Button>
<button <Button
type="button"
onClick={() => handleApply(msg)} onClick={() => handleApply(msg)}
variant="ghost"
className="flex-[2] py-2.5 flex items-center justify-center gap-1.5 text-[11px] font-bold transition-all hover:brightness-110" className="flex-[2] py-2.5 flex items-center justify-center gap-1.5 text-[11px] font-bold transition-all hover:brightness-110"
style={{ style={{
background: 'var(--accent-soft)', background: 'var(--accent-soft)',
@ -649,7 +661,7 @@ export const ChatControl = ({ profiles, onApplyProfile }: ChatControlProps) => {
> >
<Zap size={11} /> <Zap size={11} />
Apply to Dashboard Apply to Dashboard
</button> </Button>
</div> </div>
) : cancelledIds.has(msg.id) ? ( ) : cancelledIds.has(msg.id) ? (
<div className="w-full py-2 flex items-center justify-center gap-1.5 text-[10px] font-semibold" style={{ <div className="w-full py-2 flex items-center justify-center gap-1.5 text-[10px] font-semibold" style={{
@ -677,18 +689,20 @@ export const ChatControl = ({ profiles, onApplyProfile }: ChatControlProps) => {
{msg.timestamp.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })} {msg.timestamp.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
</span> </span>
</div> </div>
</div> </div>
))} ))}
{/* Suggested quick actions - shown when only welcome message exists */} {/* Suggested quick actions - shown when only welcome message exists */}
{messages.length <= 1 && !isLoading && ( {messages.length <= 1 && !isLoading && (
<div className="mt-2"> <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> <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"> <div className="grid grid-cols-2 gap-2">
{quickActions.map((action, i) => ( {quickActions.map((action, i) => (
<button <Button
type="button"
key={i} key={i}
onClick={() => sendMessage(action.prompt)} onClick={() => sendMessage(action.prompt)}
variant="ghost"
className="text-left px-3.5 py-3 rounded-xl transition-all" className="text-left px-3.5 py-3 rounded-xl transition-all"
style={{ style={{
background: 'var(--card)', background: 'var(--card)',
@ -706,11 +720,11 @@ export const ChatControl = ({ profiles, onApplyProfile }: ChatControlProps) => {
> >
<span style={{ fontSize: '13px', display: 'block', marginBottom: '3px', color: 'var(--foreground)' }}>{action.label}</span> <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> <span style={{ fontSize: '10px', color: 'var(--muted-foreground)', lineHeight: '1.4', display: 'block' }}>{action.prompt.slice(0, 55)}...</span>
</button> </Button>
))} ))}
</div> </div>
</div> </div>
)} )}
{isLoading && ( {isLoading && (
<div className="flex gap-2.5"> <div className="flex gap-2.5">
@ -726,10 +740,10 @@ export const ChatControl = ({ profiles, onApplyProfile }: ChatControlProps) => {
</div> </div>
</div> </div>
)} )}
<div ref={messagesEndRef} /> <div ref={messagesEndRef} />
</div> </div>
{/* Input area */} {/* Input area */}
<div style={{ <div style={{
background: 'var(--card)', background: 'var(--card)',
@ -738,20 +752,22 @@ export const ChatControl = ({ profiles, onApplyProfile }: ChatControlProps) => {
}}> }}>
<div className="flex items-end gap-2.5"> <div className="flex items-end gap-2.5">
<div className="flex-1 relative"> <div className="flex-1 relative">
<textarea <Textarea
ref={inputRef} ref={inputRef}
value={input} value={input}
onChange={e => setInput(e.target.value)} onChange={e => setInput(e.target.value)}
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
placeholder="Describe a strategy profile..." placeholder="Describe a strategy profile..."
disabled={isLoading} disabled={isLoading}
rows={2} rows={2}
className="w-full rounded-xl py-3 pl-4 pr-12 outline-none disabled:opacity-50 transition-all resize-none" 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' }} style={{ ...inputStyle, lineHeight: '1.5', fontFamily: 'inherit', fontSize: '13px' }}
/> />
<button <Button
type="button"
onClick={() => sendMessage()} onClick={() => sendMessage()}
disabled={!input.trim() || isLoading} disabled={!input.trim() || isLoading}
variant="ghost"
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" 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={{ style={{
background: input.trim() ? 'var(--primary)' : 'var(--accent-soft)', background: input.trim() ? 'var(--primary)' : 'var(--accent-soft)',
@ -760,24 +776,24 @@ export const ChatControl = ({ profiles, onApplyProfile }: ChatControlProps) => {
}} }}
> >
<Send size={14} /> <Send size={14} />
</button> </Button>
</div> </div>
</div> </div>
<p className="mt-1.5 ml-1 text-[9px] text-[var(--muted-foreground)]">Enter to send · Shift+Enter new line</p> <p className="mt-1.5 ml-1 text-[9px] text-[var(--muted-foreground)]">Enter to send · Shift+Enter new line</p>
</div> </div>
<style>{` <style>{`
@keyframes chatSlideUp { @keyframes chatSlideUp {
from { transform: translateY(20px) scale(0.97); opacity: 0; } from { transform: translateY(20px) scale(0.97); opacity: 0; }
to { transform: translateY(0) scale(1); opacity: 1; } to { transform: translateY(0) scale(1); opacity: 1; }
} }
@keyframes fadeIn { @keyframes fadeIn {
from { opacity: 0; } from { opacity: 0; }
to { opacity: 1; } to { opacity: 1; }
} }
`}</style> `}</style>
</div> </div>
</>, </>,
document.body document.body
); );
}; };

View File

@ -7,9 +7,7 @@ import { tradingRuntime } from '../lib/runtime';
import { createRequestId } from '../../../shared/request-id.js'; import { createRequestId } from '../../../shared/request-id.js';
import { SkeletonBlock } from '../components/Skeleton'; import { SkeletonBlock } from '../components/Skeleton';
import { PageHeader } from '../components/ui/page-header'; import { PageHeader } from '../components/ui/page-header';
import { Button } from '../components/ui/button'; import { Button, Input, Select } from '../components/ui/Primitives';
import { Input } from '../components/ui/input';
import { Select } from '../components/ui/select';
import { Card, CardContent } from '../components/ui/card'; import { Card, CardContent } from '../components/ui/card';
// ─── Types ──────────────────────────────────────────────────────────────────── // ─── Types ────────────────────────────────────────────────────────────────────
@ -198,22 +196,20 @@ export function ScreenerView() {
value={String(capIdx)} value={String(capIdx)}
onChange={e => setCapIdx(Number(e.target.value))} onChange={e => setCapIdx(Number(e.target.value))}
style={{ width: 180 }} style={{ width: 180 }}
> options={CAP_OPTIONS.map((c, i) => ({ value: String(i), label: c.label }))}
{CAP_OPTIONS.map((c, i) => ( />
<option key={c.label} value={i}>{c.label}</option>
))}
</Select>
<div style={{ display: 'flex', gap: 5, flexWrap: 'wrap', alignItems: 'center' }}> <div style={{ display: 'flex', gap: 5, flexWrap: 'wrap', alignItems: 'center' }}>
<SlidersHorizontal size={13} color="var(--muted-foreground)" /> <SlidersHorizontal size={13} color="var(--muted-foreground)" />
{SECTORS.slice(0, 6).map(s => ( {SECTORS.slice(0, 6).map(s => (
<button <Button
key={s} key={s}
onClick={() => setSector(s)} onClick={() => setSector(s)}
variant={sector === s ? 'secondary' : 'outline'}
size="sm"
style={{ style={{
padding: '5px 10px', borderRadius: 20, padding: '5px 10px', borderRadius: 20,
border: '1px solid', fontSize: 11, fontWeight: 600, border: '1px solid', fontSize: 11, fontWeight: 600,
cursor: 'pointer',
borderColor: sector === s ? 'var(--primary)' : 'var(--border)', borderColor: sector === s ? 'var(--primary)' : 'var(--border)',
background: sector === s ? 'var(--accent-soft)' : 'var(--card)', background: sector === s ? 'var(--accent-soft)' : 'var(--card)',
color: sector === s ? 'var(--primary)' : 'var(--muted-foreground)', color: sector === s ? 'var(--primary)' : 'var(--muted-foreground)',
@ -221,12 +217,16 @@ export function ScreenerView() {
}} }}
> >
{s} {s}
</button> </Button>
))} ))}
<Select <Select
aria-label="More sectors" aria-label="More sectors"
value={SECTORS.indexOf(sector) >= 6 ? sector : ''} value={SECTORS.indexOf(sector) >= 6 ? sector : ''}
onChange={e => e.target.value && setSector(e.target.value)} onChange={e => e.target.value && setSector(e.target.value)}
options={[
{ value: '', label: 'More sectors…' },
...SECTORS.slice(6).map(s => ({ value: s, label: s })),
]}
style={{ style={{
width: 140, width: 140,
borderRadius: 999, borderRadius: 999,
@ -235,12 +235,7 @@ export function ScreenerView() {
color: moreSectorSelected ? 'var(--primary)' : 'var(--muted-foreground)', color: moreSectorSelected ? 'var(--primary)' : 'var(--muted-foreground)',
fontWeight: moreSectorSelected ? 700 : 500, fontWeight: moreSectorSelected ? 700 : 500,
}} }}
> />
<option value="">More sectors</option>
{SECTORS.slice(6).map(s => (
<option key={s} value={s}>{s}</option>
))}
</Select>
</div> </div>
</div> </div>
</CardContent> </CardContent>

View File

@ -12,11 +12,9 @@ import {
type ManualEntryPayload, type ManualEntryPayload,
} from '../lib/manualEntriesApi'; } from '../lib/manualEntriesApi';
import { fetchTradeProfiles, type TradeProfilePayload } from '../lib/profileApi'; import { fetchTradeProfiles, type TradeProfilePayload } from '../lib/profileApi';
import { Button } from '../components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '../components/ui/card'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '../components/ui/card';
import { Input } from '../components/ui/input';
import { PageHeader } from '../components/ui/page-header'; import { PageHeader } from '../components/ui/page-header';
import { Select } from '../components/ui/select'; import { Button, Input, Select } from '../components/ui/Primitives';
import { import {
DEFAULT_TRADE_PLANS_UI_STATE, DEFAULT_TRADE_PLANS_UI_STATE,
reduceTradePlansUiState, reduceTradePlansUiState,
@ -1000,13 +998,14 @@ export function SimpleView() {
<CardContent> <CardContent>
<form className="space-y-6" onSubmit={handleSubmit}> <form className="space-y-6" onSubmit={handleSubmit}>
<div className="grid gap-3 md:grid-cols-2"> <div className="grid gap-3 md:grid-cols-2">
<button <Button
type="button" type="button"
onClick={() => { onClick={() => {
dispatch({ type: 'clear-feedback' }); dispatch({ type: 'clear-feedback' });
dispatch({ type: 'set-selected-holding-trade-id', value: null }); dispatch({ type: 'set-selected-holding-trade-id', value: null });
updateDraft('side', 'buy'); updateDraft('side', 'buy');
}} }}
variant="ghost"
className={`rounded-[1.25rem] border px-4 py-4 text-left transition ${ className={`rounded-[1.25rem] border px-4 py-4 text-left transition ${
draft.side === 'buy' draft.side === 'buy'
? 'border-[var(--primary)] bg-[var(--accent-soft)]' ? 'border-[var(--primary)] bg-[var(--accent-soft)]'
@ -1016,8 +1015,8 @@ export function SimpleView() {
<div className="text-[11px] font-black uppercase tracking-[0.24em] text-[var(--muted-foreground)]">Create plan</div> <div className="text-[11px] font-black uppercase tracking-[0.24em] text-[var(--muted-foreground)]">Create plan</div>
<div className="mt-1 text-sm font-semibold text-[var(--foreground)]">New short-term buy plan</div> <div className="mt-1 text-sm font-semibold text-[var(--foreground)]">New short-term buy plan</div>
<div className="mt-1 text-sm text-[var(--muted-foreground)]">Arm a dip-buy trigger and let the app manage the profit exit after fill.</div> <div className="mt-1 text-sm text-[var(--muted-foreground)]">Arm a dip-buy trigger and let the app manage the profit exit after fill.</div>
</button> </Button>
<button <Button
type="button" type="button"
onClick={() => { onClick={() => {
dispatch({ type: 'clear-feedback' }); dispatch({ type: 'clear-feedback' });
@ -1027,6 +1026,7 @@ export function SimpleView() {
updateDraft('side', 'sell'); updateDraft('side', 'sell');
} }
}} }}
variant="ghost"
className={`rounded-[1.25rem] border px-4 py-4 text-left transition ${ className={`rounded-[1.25rem] border px-4 py-4 text-left transition ${
draft.side === 'sell' draft.side === 'sell'
? 'border-[var(--primary)] bg-[var(--accent-soft)]' ? 'border-[var(--primary)] bg-[var(--accent-soft)]'
@ -1036,7 +1036,7 @@ export function SimpleView() {
<div className="text-[11px] font-black uppercase tracking-[0.24em] text-[var(--muted-foreground)]">Manage holding</div> <div className="text-[11px] font-black uppercase tracking-[0.24em] text-[var(--muted-foreground)]">Manage holding</div>
<div className="mt-1 text-sm font-semibold text-[var(--foreground)]">Attach an exit plan</div> <div className="mt-1 text-sm font-semibold text-[var(--foreground)]">Attach an exit plan</div>
<div className="mt-1 text-sm text-[var(--muted-foreground)]">Choose an existing holding and place it back under managed profit-taking.</div> <div className="mt-1 text-sm text-[var(--muted-foreground)]">Choose an existing holding and place it back under managed profit-taking.</div>
</button> </Button>
</div> </div>
{draft.side === 'sell' && ( {draft.side === 'sell' && (
@ -1049,17 +1049,13 @@ export function SimpleView() {
if (selected) applyHoldingToDraft(selected); if (selected) applyHoldingToDraft(selected);
}} }}
disabled={availableSellHoldings.length === 0} disabled={availableSellHoldings.length === 0}
> options={availableSellHoldings.length === 0
{availableSellHoldings.length === 0 ? ( ? [{ value: '', label: 'No eligible holdings available' }]
<option value="">No eligible holdings available</option> : availableSellHoldings.map((holding) => ({
) : ( value: holding.tradeId || '',
availableSellHoldings.map((holding) => ( label: `${holding.symbol} · ${holding.size} @ ${holding.entryPrice.toFixed(4)}`,
<option key={`${holding.symbol}:${holding.tradeId || 'holding'}`} value={holding.tradeId || ''}> }))}
{holding.symbol} · {holding.size} @ {holding.entryPrice.toFixed(4)} />
</option>
))
)}
</Select>
<span className="block text-[11px] text-[var(--muted-foreground)]"> <span className="block text-[11px] text-[var(--muted-foreground)]">
Trade Plans can manage an existing filled holding by attaching a profit exit target to it. Trade Plans can manage an existing filled holding by attaching a profit exit target to it.
</span> </span>
@ -1104,7 +1100,7 @@ export function SimpleView() {
{filteredSymbolSuggestions.length > 0 ? ( {filteredSymbolSuggestions.length > 0 ? (
<div className="flex flex-wrap gap-2 pt-1"> <div className="flex flex-wrap gap-2 pt-1">
{filteredSymbolSuggestions.map((symbol) => ( {filteredSymbolSuggestions.map((symbol) => (
<button <Button
key={symbol} key={symbol}
type="button" type="button"
onClick={() => { onClick={() => {
@ -1121,6 +1117,8 @@ export function SimpleView() {
}, },
}); });
}} }}
variant="ghost"
size="sm"
className={`rounded-full border px-3 py-1 text-[11px] font-semibold transition ${ className={`rounded-full border px-3 py-1 text-[11px] font-semibold transition ${
symbol === normalizedSymbol symbol === normalizedSymbol
? 'border-[var(--primary)] bg-[var(--accent-soft)] text-[var(--primary)]' ? 'border-[var(--primary)] bg-[var(--accent-soft)] text-[var(--primary)]'
@ -1128,7 +1126,7 @@ export function SimpleView() {
}`} }`}
> >
{symbol} {symbol}
</button> </Button>
))} ))}
</div> </div>
) : null} ) : null}
@ -1139,10 +1137,11 @@ export function SimpleView() {
<Select <Select
value={draft.side} value={draft.side}
onChange={(e) => updateDraft('side', e.target.value as SimpleSide)} onChange={(e) => updateDraft('side', e.target.value as SimpleSide)}
> options={[
<option value="buy">Buy the dip + profit exit</option> { value: 'buy', label: 'Buy the dip + profit exit' },
<option value="sell">Manage existing holding at profit</option> { value: 'sell', label: 'Manage existing holding at profit' },
</Select> ]}
/>
</label> </label>
</div> </div>
@ -1194,10 +1193,11 @@ export function SimpleView() {
<Select <Select
value={draft.sizingMode} value={draft.sizingMode}
onChange={(e) => updateDraft('sizingMode', e.target.value as 'quantity' | 'amount')} onChange={(e) => updateDraft('sizingMode', e.target.value as 'quantity' | 'amount')}
> options={[
<option value="quantity">Quantity / fractional shares</option> { value: 'quantity', label: 'Quantity / fractional shares' },
<option value="amount">USD amount</option> { value: 'amount', label: 'USD amount' },
</Select> ]}
/>
</label> </label>
<label className="space-y-2"> <label className="space-y-2">
@ -1255,10 +1255,11 @@ export function SimpleView() {
<Select <Select
value={draft.dropMode} value={draft.dropMode}
onChange={(e) => updateDraft('dropMode', e.target.value as TriggerMode)} onChange={(e) => updateDraft('dropMode', e.target.value as TriggerMode)}
> options={[
<option value="dollar">Dollar drop from current market</option> { value: 'dollar', label: 'Dollar drop from current market' },
<option value="percent">Percent drop from current market</option> { value: 'percent', label: 'Percent drop from current market' },
</Select> ]}
/>
</div> </div>
<label className="space-y-3"> <label className="space-y-3">
<span className="text-[11px] font-black uppercase tracking-[0.24em] text-zinc-700"> <span className="text-[11px] font-black uppercase tracking-[0.24em] text-zinc-700">
@ -1279,10 +1280,11 @@ export function SimpleView() {
<Select <Select
value={draft.profitMode} value={draft.profitMode}
onChange={(e) => updateDraft('profitMode', e.target.value as TriggerMode)} onChange={(e) => updateDraft('profitMode', e.target.value as TriggerMode)}
> options={[
<option value="dollar">Dollar gain from purchase</option> { value: 'dollar', label: 'Dollar gain from purchase' },
<option value="percent">Percent gain from purchase</option> { value: 'percent', label: 'Percent gain from purchase' },
</Select> ]}
/>
</div> </div>
<label className="space-y-3"> <label className="space-y-3">
<span className="text-[11px] font-black uppercase tracking-[0.24em] text-zinc-700"> <span className="text-[11px] font-black uppercase tracking-[0.24em] text-zinc-700">