feat: button states, full prompt extraction, portfolio viewer, param inputs

1. Run buttons: only the triggered button shows spinner, others disabled
2. Backend: enhanced prompt extraction with multiple fallback paths
   (data.messages, data.input.messages, data.input, data.kwargs.messages)
   and raw dump fallback; improved response extraction for edge cases
3. Portfolio viewer: new PortfolioViewer component with holdings table,
   trade history, and summary tabs; portfolio dropdown with auto-load;
   Wallet sidebar icon now navigates to portfolio page
4. Parameter inputs: collapsible panel with date/ticker/portfolio_id;
   validation prevents running without required fields per run type

Co-authored-by: aguzererler <6199053+aguzererler@users.noreply.github.com>
Agent-Logs-Url: https://github.com/aguzererler/TradingAgents/sessions/ffa268c8-e97c-4335-9bce-19bba583bea9
This commit is contained in:
copilot-swe-agent[bot] 2026-03-23 08:46:34 +00:00
parent b08ce7199e
commit 6999da0827
3 changed files with 595 additions and 129 deletions

View File

@ -233,19 +233,35 @@ class LangGraphEngine:
Returns the concatenated content of every message so the user can
inspect the full prompt that was sent to the LLM.
Handles several structures observed across LangChain / LangGraph versions:
- flat list of message objects ``[SystemMessage, HumanMessage, ...]``
- list-of-lists (batched) ``[[SystemMessage, HumanMessage, ...]]``
- list of plain dicts ``[{"role": "system", "content": "..."}]``
- tuple wrapper ``([SystemMessage, ...],)``
"""
if not isinstance(messages, list) or not messages:
if not messages:
return ""
# Unwrap single-element tuple / list-of-lists
items: list = messages if isinstance(messages, list) else list(messages)
if items and isinstance(items[0], (list, tuple)):
items = list(items[0])
parts: list[str] = []
items = messages
# Handle list-of-lists
if isinstance(items[0], list):
items = items[0]
for msg in items:
# LangChain message objects have .content and .type
content = getattr(msg, "content", None)
role = getattr(msg, "type", "unknown")
role = getattr(msg, "type", None)
# Plain-dict messages (e.g. {"role": "user", "content": "..."})
if content is None and isinstance(msg, dict):
content = msg.get("content", "")
role = msg.get("role") or msg.get("type") or "unknown"
if role is None:
role = "unknown"
text = str(content) if content is not None else str(msg)
parts.append(f"[{role}] {text}")
return "\n\n".join(parts)
def _extract_model(self, event: Dict[str, Any]) -> str:
@ -288,13 +304,35 @@ class LangGraphEngine:
if kind == "on_chat_model_start":
starts[node_name] = time.monotonic()
# Extract the full prompt being sent to the LLM
data = event.get("data") or {}
# Extract the full prompt being sent to the LLM.
# Try multiple paths observed in different LangChain versions:
# 1. data.messages (most common)
# 2. data.input.messages (newer LangGraph)
# 3. data.input (if it's a list of messages itself)
# 4. data.kwargs.messages (some providers)
full_prompt = ""
prompt_snippet = ""
messages = (event.get("data") or {}).get("messages")
if messages:
full_prompt = self._extract_all_messages_content(messages)
prompt_snippet = self._truncate(full_prompt.replace("\n", " "))
for source in (
data.get("messages"),
(data.get("input") or {}).get("messages") if isinstance(data.get("input"), dict) else None,
data.get("input") if isinstance(data.get("input"), (list, tuple)) else None,
(data.get("kwargs") or {}).get("messages"),
):
if source:
full_prompt = self._extract_all_messages_content(source)
if full_prompt:
break
# If all structured extractions failed, dump a raw preview
if not full_prompt:
raw_dump = str(data)[:_MAX_FULL_LEN]
if raw_dump and raw_dump != "{}":
full_prompt = f"[raw event data] {raw_dump}"
prompt_snippet = self._truncate(
full_prompt.replace("\n", " "), _MAX_CONTENT_LEN
) if full_prompt else ""
# Remember the full prompt so we can attach it to the result event
prompts[node_name] = full_prompt
@ -377,7 +415,16 @@ class LangGraphEngine:
usage = output.usage_metadata
if hasattr(output, "response_metadata") and output.response_metadata:
model = output.response_metadata.get("model_name") or output.response_metadata.get("model", model)
# Extract the response text handle both message objects and plain dicts
raw = self._extract_content(output)
# If .content was empty or the repr of the whole object, try harder
if not raw or raw.startswith("<") or raw == str(output):
# Some providers wrap in .text or .message
raw = (
getattr(output, "text", "")
or (output.get("content", "") if isinstance(output, dict) else "")
)
if raw:
full_response = raw[:_MAX_FULL_LEN]
response_snippet = self._truncate(raw)

View File

@ -7,6 +7,7 @@ import {
Text,
IconButton,
Button,
Input,
useDisclosure,
Drawer,
DrawerOverlay,
@ -29,15 +30,43 @@ import {
TabPanels,
Tab,
TabPanel,
Tooltip,
Collapse,
useToast,
} from '@chakra-ui/react';
import { LayoutDashboard, Wallet, Settings, Play, Terminal as TerminalIcon, ChevronRight, Eye, Search, BarChart3, Bot } from 'lucide-react';
import { LayoutDashboard, Wallet, Settings, Terminal as TerminalIcon, ChevronRight, Eye, Search, BarChart3, Bot, ChevronDown, ChevronUp } from 'lucide-react';
import { MetricHeader } from './components/MetricHeader';
import { AgentGraph } from './components/AgentGraph';
import { PortfolioViewer } from './components/PortfolioViewer';
import { useAgentStream, AgentEvent } from './hooks/useAgentStream';
import axios from 'axios';
const API_BASE = 'http://127.0.0.1:8088/api';
// ─── Run type definitions with required parameters ────────────────────
type RunType = 'scan' | 'pipeline' | 'portfolio' | 'auto';
interface RunParams {
date: string;
ticker: string;
portfolio_id: string;
}
const RUN_TYPE_LABELS: Record<RunType, string> = {
scan: 'Scan',
pipeline: 'Pipeline',
portfolio: 'Portfolio',
auto: 'Auto',
};
/** Which params each run type needs. */
const REQUIRED_PARAMS: Record<RunType, (keyof RunParams)[]> = {
scan: ['date'],
pipeline: ['ticker', 'date'],
portfolio: ['date', 'portfolio_id'],
auto: ['date', 'ticker'],
};
/** Return the colour token for a given event type. */
const eventColor = (type: AgentEvent['type']): string => {
switch (type) {
@ -256,12 +285,17 @@ const NodeEventsDetail: React.FC<{ nodeId: string; events: AgentEvent[]; onOpenM
);
};
// ─── Sidebar page type ────────────────────────────────────────────────
type Page = 'dashboard' | 'portfolio';
export const Dashboard: React.FC = () => {
const [activePage, setActivePage] = useState<Page>('dashboard');
const [activeRunId, setActiveRunId] = useState<string | null>(null);
const [activeRunType, setActiveRunType] = useState<RunType | null>(null);
const [isTriggering, setIsTriggering] = useState(false);
const [portfolioId, setPortfolioId] = useState<string>("main_portfolio");
const { events, status, clearEvents } = useAgentStream(activeRunId);
const { isOpen, onOpen, onClose } = useDisclosure();
const toast = useToast();
// Event detail modal state
const { isOpen: isModalOpen, onOpen: onModalOpen, onClose: onModalClose } = useDisclosure();
@ -272,6 +306,14 @@ export const Dashboard: React.FC = () => {
const [selectedEvent, setSelectedEvent] = useState<AgentEvent | null>(null);
const [selectedNodeId, setSelectedNodeId] = useState<string | null>(null);
// Parameter inputs
const [showParams, setShowParams] = useState(false);
const [params, setParams] = useState<RunParams>({
date: new Date().toISOString().split('T')[0],
ticker: 'AAPL',
portfolio_id: 'main_portfolio',
});
// Auto-scroll the terminal to the bottom as new events arrive
const terminalEndRef = useRef<HTMLDivElement>(null);
@ -279,21 +321,47 @@ export const Dashboard: React.FC = () => {
terminalEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [events.length]);
// Clear activeRunType when run completes
useEffect(() => {
if (status === 'completed' || status === 'error') {
setActiveRunType(null);
}
}, [status]);
const isRunning = isTriggering || status === 'streaming' || status === 'connecting';
const startRun = async (type: string) => {
const startRun = async (type: RunType) => {
if (isRunning) return;
// Validate required params
const required = REQUIRED_PARAMS[type];
const missing = required.filter((k) => !params[k]?.trim());
if (missing.length > 0) {
toast({
title: `Missing required fields for ${RUN_TYPE_LABELS[type]}`,
description: `Please fill in: ${missing.join(', ')}`,
status: 'warning',
duration: 3000,
isClosable: true,
position: 'top',
});
setShowParams(true);
return;
}
setIsTriggering(true);
setActiveRunType(type);
try {
clearEvents();
const res = await axios.post(`${API_BASE}/run/${type}`, {
portfolio_id: portfolioId,
date: new Date().toISOString().split('T')[0]
portfolio_id: params.portfolio_id,
date: params.date,
ticker: params.ticker,
});
setActiveRunId(res.data.run_id);
} catch (err) {
console.error("Failed to start run:", err);
setActiveRunType(null);
} finally {
setIsTriggering(false);
}
@ -331,125 +399,196 @@ export const Dashboard: React.FC = () => {
{/* Sidebar */}
<VStack w="64px" bg="slate.900" borderRight="1px solid" borderColor="whiteAlpha.100" py={4} spacing={6}>
<Box mb={4}><Text fontWeight="black" color="cyan.400" fontSize="xl">A</Text></Box>
<IconButton aria-label="Dashboard" icon={<LayoutDashboard size={20} />} variant="ghost" color="cyan.400" _hover={{ bg: "whiteAlpha.100" }} />
<IconButton aria-label="Portfolio" icon={<Wallet size={20} />} variant="ghost" color="whiteAlpha.600" _hover={{ bg: "whiteAlpha.100" }} />
<Tooltip label="Dashboard" placement="right">
<IconButton
aria-label="Dashboard"
icon={<LayoutDashboard size={20} />}
variant="ghost"
color={activePage === 'dashboard' ? 'cyan.400' : 'whiteAlpha.600'}
bg={activePage === 'dashboard' ? 'whiteAlpha.100' : 'transparent'}
_hover={{ bg: "whiteAlpha.100" }}
onClick={() => setActivePage('dashboard')}
/>
</Tooltip>
<Tooltip label="Portfolio" placement="right">
<IconButton
aria-label="Portfolio"
icon={<Wallet size={20} />}
variant="ghost"
color={activePage === 'portfolio' ? 'cyan.400' : 'whiteAlpha.600'}
bg={activePage === 'portfolio' ? 'whiteAlpha.100' : 'transparent'}
_hover={{ bg: "whiteAlpha.100" }}
onClick={() => setActivePage('portfolio')}
/>
</Tooltip>
<IconButton aria-label="Settings" icon={<Settings size={20} />} variant="ghost" color="whiteAlpha.600" _hover={{ bg: "whiteAlpha.100" }} />
</VStack>
{/* Main Content */}
<Flex flex="1" direction="column">
{/* Top Metric Header */}
<MetricHeader portfolioId={portfolioId} />
{/* ─── Portfolio Page ────────────────────────────────────────── */}
{activePage === 'portfolio' && (
<Box flex="1">
<PortfolioViewer defaultPortfolioId={params.portfolio_id} />
</Box>
)}
{/* Dashboard Body */}
<Flex flex="1" overflow="hidden">
{/* Left Side: Graph Area */}
<Box flex="1" position="relative" borderRight="1px solid" borderColor="whiteAlpha.100">
<AgentGraph events={events} onNodeClick={openNodeDetail} />
{/* Floating Control Panel */}
<HStack position="absolute" top={4} left={4} bg="blackAlpha.800" p={2} borderRadius="lg" backdropFilter="blur(10px)" border="1px solid" borderColor="whiteAlpha.200" spacing={2} flexWrap="wrap">
<Button
size="sm"
leftIcon={<Search size={14} />}
colorScheme="cyan"
variant="solid"
onClick={() => startRun('scan')}
isLoading={isRunning}
loadingText="Running…"
>
Scan
</Button>
<Button
size="sm"
leftIcon={<BarChart3 size={14} />}
colorScheme="blue"
variant="solid"
onClick={() => startRun('pipeline')}
isLoading={isRunning}
loadingText="Running…"
>
Pipeline
</Button>
<Button
size="sm"
leftIcon={<Wallet size={14} />}
colorScheme="purple"
variant="solid"
onClick={() => startRun('portfolio')}
isLoading={isRunning}
loadingText="Running…"
>
Portfolio
</Button>
<Button
size="sm"
leftIcon={<Bot size={14} />}
colorScheme="green"
variant="solid"
onClick={() => startRun('auto')}
isLoading={isRunning}
loadingText="Running…"
>
Auto
</Button>
<Divider orientation="vertical" h="20px" />
<Tag size="sm" colorScheme={status === 'streaming' ? 'green' : status === 'completed' ? 'blue' : 'gray'}>
{status.toUpperCase()}
</Tag>
</HStack>
</Box>
{/* ─── Dashboard Page ────────────────────────────────────────── */}
{activePage === 'dashboard' && (
<Flex flex="1" direction="column">
{/* Top Metric Header */}
<MetricHeader portfolioId={params.portfolio_id} />
{/* Right Side: Live Terminal */}
<VStack w="400px" bg="blackAlpha.400" align="stretch" spacing={0}>
<Flex p={3} bg="whiteAlpha.50" align="center" gap={2} borderBottom="1px solid" borderColor="whiteAlpha.100">
<TerminalIcon size={16} color="#4fd1c5" />
<Text fontSize="xs" fontWeight="bold" textTransform="uppercase" letterSpacing="wider">Live Terminal</Text>
<Text fontSize="2xs" color="whiteAlpha.400" ml="auto">{events.length} events</Text>
</Flex>
<Box flex="1" overflowY="auto" p={4} sx={{
'&::-webkit-scrollbar': { width: '4px' },
'&::-webkit-scrollbar-track': { background: 'transparent' },
'&::-webkit-scrollbar-thumb': { background: 'whiteAlpha.300' }
}}>
{events.map((evt) => (
<Box
key={evt.id}
mb={2}
fontSize="xs"
fontFamily="mono"
px={2}
py={1}
borderRadius="md"
cursor="pointer"
_hover={{ bg: 'whiteAlpha.100' }}
onClick={() => openEventDetail(evt)}
transition="background 0.15s"
>
<Flex gap={2} align="center">
<Text color="whiteAlpha.400" minW="52px" flexShrink={0}>[{evt.timestamp}]</Text>
<Text flexShrink={0}>{eventLabel(evt.type)}</Text>
<Text color={eventColor(evt.type)} fontWeight="bold" flexShrink={0}>
{evt.agent}
</Text>
<ChevronRight size={10} style={{ flexShrink: 0, opacity: 0.4 }} />
<Text color="whiteAlpha.700" isTruncated>{eventSummary(evt)}</Text>
<Eye size={12} style={{ flexShrink: 0, opacity: 0.3, marginLeft: 'auto' }} />
</Flex>
</Box>
))}
{events.length === 0 && (
<Flex h="100%" align="center" justify="center" direction="column" gap={4} opacity={0.3}>
<TerminalIcon size={48} />
<Text fontSize="sm">Awaiting agent activation...</Text>
</Flex>
)}
<div ref={terminalEndRef} />
{/* Dashboard Body */}
<Flex flex="1" overflow="hidden">
{/* Left Side: Graph Area */}
<Box flex="1" position="relative" borderRight="1px solid" borderColor="whiteAlpha.100">
<AgentGraph events={events} onNodeClick={openNodeDetail} />
{/* Floating Control Panel */}
<VStack position="absolute" top={4} left={4} spacing={2} align="stretch">
{/* Run buttons row */}
<HStack bg="blackAlpha.800" p={2} borderRadius="lg" backdropFilter="blur(10px)" border="1px solid" borderColor="whiteAlpha.200" spacing={2}>
{(['scan', 'pipeline', 'portfolio', 'auto'] as RunType[]).map((type) => {
const isThisRunning = isRunning && activeRunType === type;
const isOtherRunning = isRunning && activeRunType !== type;
const icons: Record<RunType, React.ReactElement> = {
scan: <Search size={14} />,
pipeline: <BarChart3 size={14} />,
portfolio: <Wallet size={14} />,
auto: <Bot size={14} />,
};
const colors: Record<RunType, string> = {
scan: 'cyan',
pipeline: 'blue',
portfolio: 'purple',
auto: 'green',
};
return (
<Button
key={type}
size="sm"
leftIcon={icons[type]}
colorScheme={colors[type]}
variant="solid"
onClick={() => startRun(type)}
isLoading={isThisRunning}
loadingText="Running…"
isDisabled={isOtherRunning}
>
{RUN_TYPE_LABELS[type]}
</Button>
);
})}
<Divider orientation="vertical" h="20px" />
<Tag size="sm" colorScheme={status === 'streaming' ? 'green' : status === 'completed' ? 'blue' : status === 'error' ? 'red' : 'gray'}>
{status.toUpperCase()}
</Tag>
<IconButton
aria-label="Toggle params"
icon={showParams ? <ChevronUp size={14} /> : <ChevronDown size={14} />}
size="xs"
variant="ghost"
color="whiteAlpha.600"
onClick={() => setShowParams(!showParams)}
/>
</HStack>
{/* Collapsible parameter inputs */}
<Collapse in={showParams} animateOpacity>
<Box bg="blackAlpha.800" p={3} borderRadius="lg" backdropFilter="blur(10px)" border="1px solid" borderColor="whiteAlpha.200">
<VStack spacing={2} align="stretch">
<HStack>
<Text fontSize="xs" color="whiteAlpha.600" minW="70px">Date:</Text>
<Input
size="xs"
type="date"
bg="whiteAlpha.100"
borderColor="whiteAlpha.200"
value={params.date}
onChange={(e) => setParams((p) => ({ ...p, date: e.target.value }))}
/>
</HStack>
<HStack>
<Text fontSize="xs" color="whiteAlpha.600" minW="70px">Ticker:</Text>
<Input
size="xs"
placeholder="AAPL"
bg="whiteAlpha.100"
borderColor="whiteAlpha.200"
value={params.ticker}
onChange={(e) => setParams((p) => ({ ...p, ticker: e.target.value.toUpperCase() }))}
/>
</HStack>
<HStack>
<Text fontSize="xs" color="whiteAlpha.600" minW="70px">Portfolio:</Text>
<Input
size="xs"
placeholder="main_portfolio"
bg="whiteAlpha.100"
borderColor="whiteAlpha.200"
value={params.portfolio_id}
onChange={(e) => setParams((p) => ({ ...p, portfolio_id: e.target.value }))}
/>
</HStack>
<Text fontSize="2xs" color="whiteAlpha.400">
Required: Scan date · Pipeline ticker, date · Portfolio date, portfolio · Auto date, ticker
</Text>
</VStack>
</Box>
</Collapse>
</VStack>
</Box>
</VStack>
{/* Right Side: Live Terminal */}
<VStack w="400px" bg="blackAlpha.400" align="stretch" spacing={0}>
<Flex p={3} bg="whiteAlpha.50" align="center" gap={2} borderBottom="1px solid" borderColor="whiteAlpha.100">
<TerminalIcon size={16} color="#4fd1c5" />
<Text fontSize="xs" fontWeight="bold" textTransform="uppercase" letterSpacing="wider">Live Terminal</Text>
<Text fontSize="2xs" color="whiteAlpha.400" ml="auto">{events.length} events</Text>
</Flex>
<Box flex="1" overflowY="auto" p={4} sx={{
'&::-webkit-scrollbar': { width: '4px' },
'&::-webkit-scrollbar-track': { background: 'transparent' },
'&::-webkit-scrollbar-thumb': { background: 'whiteAlpha.300' }
}}>
{events.map((evt) => (
<Box
key={evt.id}
mb={2}
fontSize="xs"
fontFamily="mono"
px={2}
py={1}
borderRadius="md"
cursor="pointer"
_hover={{ bg: 'whiteAlpha.100' }}
onClick={() => openEventDetail(evt)}
transition="background 0.15s"
>
<Flex gap={2} align="center">
<Text color="whiteAlpha.400" minW="52px" flexShrink={0}>[{evt.timestamp}]</Text>
<Text flexShrink={0}>{eventLabel(evt.type)}</Text>
<Text color={eventColor(evt.type)} fontWeight="bold" flexShrink={0}>
{evt.agent}
</Text>
<ChevronRight size={10} style={{ flexShrink: 0, opacity: 0.4 }} />
<Text color="whiteAlpha.700" isTruncated>{eventSummary(evt)}</Text>
<Eye size={12} style={{ flexShrink: 0, opacity: 0.3, marginLeft: 'auto' }} />
</Flex>
</Box>
))}
{events.length === 0 && (
<Flex h="100%" align="center" justify="center" direction="column" gap={4} opacity={0.3}>
<TerminalIcon size={48} />
<Text fontSize="sm">Awaiting agent activation...</Text>
</Flex>
)}
<div ref={terminalEndRef} />
</Box>
</VStack>
</Flex>
</Flex>
</Flex>
)}
{/* Unified Inspector Drawer (single event or all node events) */}
<Drawer isOpen={isOpen} placement="right" onClose={onClose} size="md">

View File

@ -0,0 +1,280 @@
import React, { useState, useEffect, useCallback } from 'react';
import {
Box,
Flex,
VStack,
HStack,
Text,
Badge,
Code,
Spinner,
Select,
Table,
Thead,
Tbody,
Tr,
Th,
Td,
Tabs,
TabList,
TabPanels,
Tab,
TabPanel,
Icon,
} from '@chakra-ui/react';
import { Wallet, ArrowUpRight, ArrowDownRight, RefreshCw } from 'lucide-react';
import axios from 'axios';
const API_BASE = 'http://127.0.0.1:8088/api';
interface Holding {
ticker: string;
quantity: number;
avg_cost: number;
current_price?: number;
market_value?: number;
unrealized_pnl?: number;
sector?: string;
[key: string]: unknown;
}
interface Trade {
id?: string;
ticker: string;
action: string;
quantity: number;
price: number;
executed_at?: string;
rationale?: string;
[key: string]: unknown;
}
interface PortfolioInfo {
id: string;
name?: string;
cash_balance?: number;
[key: string]: unknown;
}
interface PortfolioState {
portfolio: PortfolioInfo;
snapshot: Record<string, unknown> | null;
holdings: Holding[];
recent_trades: Trade[];
}
interface PortfolioViewerProps {
defaultPortfolioId?: string;
}
export const PortfolioViewer: React.FC<PortfolioViewerProps> = ({ defaultPortfolioId = 'main_portfolio' }) => {
const [portfolios, setPortfolios] = useState<PortfolioInfo[]>([]);
const [selectedId, setSelectedId] = useState<string>(defaultPortfolioId);
const [state, setState] = useState<PortfolioState | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
// Fetch portfolio list
useEffect(() => {
const fetchList = async () => {
try {
const res = await axios.get(`${API_BASE}/portfolios/`);
const list = res.data as PortfolioInfo[];
setPortfolios(list);
if (list.length > 0 && !list.find((p) => p.id === selectedId)) {
setSelectedId(list[0].id);
}
} catch {
// Might fail if no DB — use fallback
setPortfolios([{ id: defaultPortfolioId, name: defaultPortfolioId }]);
}
};
fetchList();
}, [defaultPortfolioId, selectedId]);
// Fetch portfolio state when selection changes
const fetchState = useCallback(async () => {
if (!selectedId) return;
setLoading(true);
setError(null);
try {
const res = await axios.get(`${API_BASE}/portfolios/${selectedId}/latest`);
setState(res.data as PortfolioState);
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : 'Failed to load portfolio';
setError(msg);
setState(null);
} finally {
setLoading(false);
}
}, [selectedId]);
useEffect(() => {
fetchState();
}, [fetchState]);
return (
<Flex direction="column" h="100%" bg="slate.950" color="white" overflow="hidden">
{/* Header */}
<Flex p={4} bg="slate.900" borderBottom="1px solid" borderColor="whiteAlpha.100" align="center" gap={3}>
<Icon as={Wallet} color="cyan.400" boxSize={5} />
<Text fontWeight="bold" fontSize="lg">Portfolio Viewer</Text>
<Select
size="sm"
maxW="220px"
ml="auto"
bg="whiteAlpha.100"
borderColor="whiteAlpha.200"
value={selectedId}
onChange={(e) => setSelectedId(e.target.value)}
>
{portfolios.map((p) => (
<option key={p.id} value={p.id} style={{ background: '#0f172a' }}>
{p.name || p.id}
</option>
))}
</Select>
<Box cursor="pointer" onClick={fetchState} opacity={0.6} _hover={{ opacity: 1 }}>
<RefreshCw size={16} />
</Box>
</Flex>
{/* Body */}
{loading && (
<Flex flex="1" align="center" justify="center"><Spinner color="cyan.400" /></Flex>
)}
{error && (
<Flex flex="1" align="center" justify="center" direction="column" gap={2} opacity={0.5}>
<Text fontSize="sm" color="red.300">{error}</Text>
<Text fontSize="xs">Make sure the backend is running and the portfolio exists.</Text>
</Flex>
)}
{!loading && !error && state && (
<Tabs variant="soft-rounded" colorScheme="cyan" size="sm" flex="1" display="flex" flexDirection="column" overflow="hidden">
<TabList px={4} pt={3}>
<Tab>Holdings ({state.holdings.length})</Tab>
<Tab>Trade History ({state.recent_trades.length})</Tab>
<Tab>Summary</Tab>
</TabList>
<TabPanels flex="1" overflow="auto">
{/* Holdings */}
<TabPanel px={4}>
{state.holdings.length === 0 ? (
<Text color="whiteAlpha.500" fontSize="sm" textAlign="center" mt={8}>No holdings found.</Text>
) : (
<Box overflowX="auto">
<Table size="sm" variant="unstyled">
<Thead>
<Tr>
<Th color="whiteAlpha.500" borderBottom="1px solid" borderColor="whiteAlpha.100">Ticker</Th>
<Th color="whiteAlpha.500" borderBottom="1px solid" borderColor="whiteAlpha.100" isNumeric>Qty</Th>
<Th color="whiteAlpha.500" borderBottom="1px solid" borderColor="whiteAlpha.100" isNumeric>Avg Cost</Th>
<Th color="whiteAlpha.500" borderBottom="1px solid" borderColor="whiteAlpha.100" isNumeric>Mkt Value</Th>
<Th color="whiteAlpha.500" borderBottom="1px solid" borderColor="whiteAlpha.100" isNumeric>P&L</Th>
<Th color="whiteAlpha.500" borderBottom="1px solid" borderColor="whiteAlpha.100">Sector</Th>
</Tr>
</Thead>
<Tbody>
{state.holdings.map((h, i) => {
const pnl = h.unrealized_pnl ?? 0;
return (
<Tr key={i} _hover={{ bg: 'whiteAlpha.50' }}>
<Td fontWeight="bold"><Code colorScheme="cyan" fontSize="sm">{h.ticker}</Code></Td>
<Td isNumeric>{h.quantity}</Td>
<Td isNumeric>${(h.avg_cost ?? 0).toFixed(2)}</Td>
<Td isNumeric>${(h.market_value ?? 0).toFixed(2)}</Td>
<Td isNumeric color={pnl >= 0 ? 'green.400' : 'red.400'}>
<HStack justify="flex-end" spacing={1}>
<Icon as={pnl >= 0 ? ArrowUpRight : ArrowDownRight} boxSize={3} />
<Text>${Math.abs(pnl).toFixed(2)}</Text>
</HStack>
</Td>
<Td><Badge variant="outline" fontSize="2xs">{h.sector || '—'}</Badge></Td>
</Tr>
);
})}
</Tbody>
</Table>
</Box>
)}
</TabPanel>
{/* Trade History */}
<TabPanel px={4}>
{state.recent_trades.length === 0 ? (
<Text color="whiteAlpha.500" fontSize="sm" textAlign="center" mt={8}>No trades recorded yet.</Text>
) : (
<VStack align="stretch" spacing={2}>
{state.recent_trades.map((t, i) => (
<Flex
key={i}
bg="whiteAlpha.50"
p={3}
borderRadius="md"
border="1px solid"
borderColor="whiteAlpha.100"
justify="space-between"
align="center"
>
<HStack spacing={3}>
<Badge colorScheme={t.action?.toUpperCase() === 'BUY' ? 'green' : t.action?.toUpperCase() === 'SELL' ? 'red' : 'gray'}>
{t.action?.toUpperCase()}
</Badge>
<Code colorScheme="cyan" fontSize="sm">{t.ticker}</Code>
<Text fontSize="sm">{t.quantity} @ ${(t.price ?? 0).toFixed(2)}</Text>
</HStack>
<VStack align="flex-end" spacing={0}>
<Text fontSize="2xs" color="whiteAlpha.400">{t.executed_at || '—'}</Text>
{t.rationale && (
<Text fontSize="2xs" color="whiteAlpha.500" maxW="200px" isTruncated>{t.rationale}</Text>
)}
</VStack>
</Flex>
))}
</VStack>
)}
</TabPanel>
{/* Summary */}
<TabPanel px={4}>
<VStack align="stretch" spacing={3}>
<HStack>
<Text fontSize="sm" color="whiteAlpha.600" minW="100px">Portfolio ID:</Text>
<Code fontSize="sm">{state.portfolio.id}</Code>
</HStack>
{state.portfolio.cash_balance != null && (
<HStack>
<Text fontSize="sm" color="whiteAlpha.600" minW="100px">Cash Balance:</Text>
<Code colorScheme="green" fontSize="sm">${state.portfolio.cash_balance.toFixed(2)}</Code>
</HStack>
)}
{state.snapshot && (
<Box mt={2}>
<Text fontSize="xs" fontWeight="bold" color="whiteAlpha.600" mb={1}>Latest Snapshot</Text>
<Box bg="blackAlpha.500" p={3} borderRadius="md" border="1px solid" borderColor="whiteAlpha.100" maxH="300px" overflowY="auto">
<Text fontSize="xs" fontFamily="mono" whiteSpace="pre-wrap" wordBreak="break-word" color="whiteAlpha.900">
{JSON.stringify(state.snapshot, null, 2)}
</Text>
</Box>
</Box>
)}
</VStack>
</TabPanel>
</TabPanels>
</Tabs>
)}
{!loading && !error && !state && (
<Flex flex="1" align="center" justify="center" direction="column" gap={4} opacity={0.3}>
<Wallet size={48} />
<Text fontSize="sm">Select a portfolio to view</Text>
</Flex>
)}
</Flex>
);
};