feat: fix prompt display, add event detail modal, run buttons, and node animations

Backend:
- Extract full prompt from all LLM messages (not just first)
- Add prompt/response fields to streamed event payloads
- Improve model name extraction with multiple fallback strategies
- Add run_portfolio and run_auto streaming methods
- Wire portfolio/auto in websocket router
- New tool_result event type for tool completion

Frontend:
- Add full event detail modal with tabs (Prompt, Response, Summary, Metrics)
- Show actual prompt content in drawer instead of "Prompting unknown..."
- Add Scan, Pipeline, Portfolio, Auto buttons to control panel
- Fix node animation: completed nodes never revert to running
- Handle tool_result type for marking tool nodes as done
- Drawer events have "Full Detail →" button to open modal

Co-authored-by: aguzererler <6199053+aguzererler@users.noreply.github.com>
Agent-Logs-Url: https://github.com/aguzererler/TradingAgents/sessions/7997c579-ab7e-4071-afd0-18703a8e5618
This commit is contained in:
copilot-swe-agent[bot] 2026-03-23 08:04:53 +00:00
parent 9d2dbf4a43
commit b08ce7199e
5 changed files with 391 additions and 53 deletions

View File

@ -38,7 +38,10 @@ async def websocket_endpoint(
stream_gen = engine.run_scan(run_id, params)
elif run_type == "pipeline":
stream_gen = engine.run_pipeline(run_id, params)
# Add other types as they are implemented in LangGraphEngine
elif run_type == "portfolio":
stream_gen = engine.run_portfolio(run_id, params)
elif run_type == "auto":
stream_gen = engine.run_auto(run_id, params)
if stream_gen:
async for payload in stream_gen:

View File

@ -4,13 +4,17 @@ import time
from typing import Dict, Any, AsyncGenerator
from tradingagents.graph.trading_graph import TradingAgentsGraph
from tradingagents.graph.scanner_graph import ScannerGraph
from tradingagents.graph.portfolio_graph import PortfolioGraph
from tradingagents.default_config import DEFAULT_CONFIG
logger = logging.getLogger("agent_os.engine")
# Maximum characters of prompt/response content to include in streamed events
# Maximum characters of prompt/response content to include in the short message
_MAX_CONTENT_LEN = 300
# Maximum characters of prompt/response for the full fields (generous limit)
_MAX_FULL_LEN = 50_000
class LangGraphEngine:
"""Orchestrates LangGraph pipeline executions and streams events."""
@ -20,6 +24,8 @@ class LangGraphEngine:
self.active_runs: Dict[str, Dict[str, Any]] = {}
# Track node start times per run so we can compute latency
self._node_start_times: Dict[str, Dict[str, float]] = {}
# Track the last prompt per node so we can attach it to result events
self._node_prompts: Dict[str, Dict[str, str]] = {}
# ------------------------------------------------------------------
# Run helpers
@ -55,6 +61,7 @@ class LangGraphEngine:
yield mapped
self._node_start_times.pop(run_id, None)
self._node_prompts.pop(run_id, None)
logger.info("Completed SCAN run=%s", run_id)
async def run_pipeline(
@ -88,8 +95,76 @@ class LangGraphEngine:
yield mapped
self._node_start_times.pop(run_id, None)
self._node_prompts.pop(run_id, None)
logger.info("Completed PIPELINE run=%s", run_id)
async def run_portfolio(
self, run_id: str, params: Dict[str, Any]
) -> AsyncGenerator[Dict[str, Any], None]:
"""Run the portfolio manager workflow and stream events."""
date = params.get("date", time.strftime("%Y-%m-%d"))
portfolio_id = params.get("portfolio_id", "main_portfolio")
logger.info(
"Starting PORTFOLIO run=%s portfolio=%s date=%s",
run_id, portfolio_id, date,
)
yield self._system_log(
f"Starting portfolio manager for {portfolio_id} on {date}"
)
portfolio_graph = PortfolioGraph(config=self.config)
initial_state = {
"portfolio_id": portfolio_id,
"scan_date": date,
"messages": [],
}
self._node_start_times[run_id] = {}
async for event in portfolio_graph.graph.astream_events(
initial_state, version="v2"
):
mapped = self._map_langgraph_event(run_id, event)
if mapped:
yield mapped
self._node_start_times.pop(run_id, None)
self._node_prompts.pop(run_id, None)
logger.info("Completed PORTFOLIO run=%s", run_id)
async def run_auto(
self, run_id: str, params: Dict[str, Any]
) -> AsyncGenerator[Dict[str, Any], None]:
"""Run the full auto pipeline: scan → pipeline → portfolio."""
date = params.get("date", time.strftime("%Y-%m-%d"))
logger.info("Starting AUTO run=%s date=%s", run_id, date)
yield self._system_log(f"Starting full auto workflow for {date}")
# Phase 1: Market scan
yield self._system_log("Phase 1/3: Running market scan…")
async for evt in self.run_scan(f"{run_id}_scan", {"date": date}):
yield evt
# Phase 2: Pipeline analysis (default ticker for now)
ticker = params.get("ticker", "AAPL")
yield self._system_log(f"Phase 2/3: Running analysis pipeline for {ticker}")
async for evt in self.run_pipeline(
f"{run_id}_pipeline", {"ticker": ticker, "date": date}
):
yield evt
# Phase 3: Portfolio management
yield self._system_log("Phase 3/3: Running portfolio manager…")
async for evt in self.run_portfolio(
f"{run_id}_portfolio", {"date": date, **params}
):
yield evt
logger.info("Completed AUTO run=%s", run_id)
# ------------------------------------------------------------------
# Event mapping
# ------------------------------------------------------------------
@ -153,6 +228,51 @@ class LangGraphEngine:
content = getattr(first_item, "content", None)
return str(content) if content is not None else str(first_item)
def _extract_all_messages_content(self, messages: Any) -> str:
"""Extract text from ALL messages in a LangGraph messages payload.
Returns the concatenated content of every message so the user can
inspect the full prompt that was sent to the LLM.
"""
if not isinstance(messages, list) or not messages:
return ""
parts: list[str] = []
items = messages
# Handle list-of-lists
if isinstance(items[0], list):
items = items[0]
for msg in items:
content = getattr(msg, "content", None)
role = getattr(msg, "type", "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:
"""Best-effort extraction of the model name from a LangGraph event."""
data = event.get("data") or {}
# 1. invocation_params (standard LangChain)
inv = data.get("invocation_params") or {}
model = inv.get("model_name") or inv.get("model") or ""
if model:
return model
# 2. Serialized kwargs (OpenRouter / ChatOpenAI)
serialized = event.get("serialized") or data.get("serialized") or {}
kwargs = serialized.get("kwargs") or {}
model = kwargs.get("model_name") or kwargs.get("model") or ""
if model:
return model
# 3. metadata.ls_model_name (LangSmith tracing)
metadata = event.get("metadata") or {}
model = metadata.get("ls_model_name") or ""
if model:
return model
return "unknown"
def _map_langgraph_event(
self, run_id: str, event: Dict[str, Any]
) -> Dict[str, Any] | None:
@ -162,22 +282,24 @@ class LangGraphEngine:
node_name = self._extract_node_name(event)
starts = self._node_start_times.get(run_id, {})
prompts = self._node_prompts.setdefault(run_id, {})
# ------ LLM start ------
if kind == "on_chat_model_start":
starts[node_name] = time.monotonic()
# Extract the prompt being sent to the LLM
# Extract the full prompt being sent to the LLM
full_prompt = ""
prompt_snippet = ""
messages = (event.get("data") or {}).get("messages")
if messages:
raw = self._first_message_content(messages)
if raw:
prompt_snippet = self._truncate(raw)
full_prompt = self._extract_all_messages_content(messages)
prompt_snippet = self._truncate(full_prompt.replace("\n", " "))
model = "unknown"
inv_params = (event.get("data") or {}).get("invocation_params") or {}
model = inv_params.get("model_name") or inv_params.get("model") or "unknown"
# Remember the full prompt so we can attach it to the result event
prompts[node_name] = full_prompt
model = self._extract_model(event)
logger.info(
"LLM start node=%s model=%s run=%s", node_name, model, run_id
@ -191,14 +313,17 @@ class LangGraphEngine:
"agent": node_name.upper(),
"message": f"Prompting {model}"
+ (f" | {prompt_snippet}" if prompt_snippet else ""),
"prompt": full_prompt,
"metrics": {"model": model},
}
# ------ Tool call ------
elif kind == "on_tool_start":
full_input = ""
tool_input = ""
inp = (event.get("data") or {}).get("input")
if inp:
full_input = str(inp)[:_MAX_FULL_LEN]
tool_input = self._truncate(str(inp))
logger.info("Tool start tool=%s node=%s run=%s", name, node_name, run_id)
@ -211,15 +336,19 @@ class LangGraphEngine:
"agent": node_name.upper(),
"message": f"▶ Tool: {name}"
+ (f" | {tool_input}" if tool_input else ""),
"prompt": full_input,
"metrics": {},
}
# ------ Tool result ------
elif kind == "on_tool_end":
full_output = ""
tool_output = ""
out = (event.get("data") or {}).get("output")
if out is not None:
tool_output = self._truncate(self._extract_content(out))
raw = self._extract_content(out)
full_output = raw[:_MAX_FULL_LEN]
tool_output = self._truncate(raw)
logger.info("Tool end tool=%s node=%s run=%s", name, node_name, run_id)
@ -227,10 +356,11 @@ class LangGraphEngine:
"id": f"{event['run_id']}_tool_end",
"node_id": f"tool_{name}",
"parent_node_id": node_name,
"type": "tool",
"type": "tool_result",
"agent": node_name.upper(),
"message": f"✓ Tool result: {name}"
+ (f" | {tool_output}" if tool_output else ""),
"response": full_output,
"metrics": {},
}
@ -240,21 +370,30 @@ class LangGraphEngine:
usage: Dict[str, Any] = {}
model = "unknown"
response_snippet = ""
full_response = ""
if output is not None:
if hasattr(output, "usage_metadata") and output.usage_metadata:
usage = output.usage_metadata
if hasattr(output, "response_metadata") and output.response_metadata:
model = output.response_metadata.get("model_name", model)
model = output.response_metadata.get("model_name") or output.response_metadata.get("model", model)
raw = self._extract_content(output)
if raw:
full_response = raw[:_MAX_FULL_LEN]
response_snippet = self._truncate(raw)
# Fall back to event-level model extraction
if model == "unknown":
model = self._extract_model(event)
latency_ms = 0
start_t = starts.pop(node_name, None)
if start_t is not None:
latency_ms = round((time.monotonic() - start_t) * 1000)
# Retrieve the prompt that started this LLM call
matched_prompt = prompts.pop(node_name, "")
logger.info(
"LLM end node=%s model=%s tokens_in=%s tokens_out=%s latency=%dms run=%s",
node_name,
@ -271,6 +410,8 @@ class LangGraphEngine:
"type": "result",
"agent": node_name.upper(),
"message": response_snippet or "Completed.",
"prompt": matched_prompt,
"response": full_response,
"metrics": {
"model": model,
"tokens_in": usage.get("input_tokens", 0),
@ -292,3 +433,11 @@ class LangGraphEngine:
async def run_pipeline_background(self, run_id: str, params: Dict[str, Any]):
async for _ in self.run_pipeline(run_id, params):
pass
async def run_portfolio_background(self, run_id: str, params: Dict[str, Any]):
async for _ in self.run_portfolio(run_id, params):
pass
async def run_auto_background(self, run_id: str, params: Dict[str, Any]):
async for _ in self.run_auto(run_id, params):
pass

View File

@ -18,8 +18,19 @@ import {
Tag,
Code,
Badge,
Modal,
ModalOverlay,
ModalContent,
ModalHeader,
ModalBody,
ModalCloseButton,
Tabs,
TabList,
TabPanels,
Tab,
TabPanel,
} from '@chakra-ui/react';
import { LayoutDashboard, Wallet, Settings, Play, Terminal as TerminalIcon, ChevronRight, Eye } from 'lucide-react';
import { LayoutDashboard, Wallet, Settings, Play, Terminal as TerminalIcon, ChevronRight, Eye, Search, BarChart3, Bot } from 'lucide-react';
import { MetricHeader } from './components/MetricHeader';
import { AgentGraph } from './components/AgentGraph';
import { useAgentStream, AgentEvent } from './hooks/useAgentStream';
@ -31,6 +42,7 @@ const API_BASE = 'http://127.0.0.1:8088/api';
const eventColor = (type: AgentEvent['type']): string => {
switch (type) {
case 'tool': return 'purple.400';
case 'tool_result': return 'purple.300';
case 'result': return 'green.400';
case 'log': return 'yellow.300';
default: return 'cyan.400';
@ -42,6 +54,7 @@ const eventLabel = (type: AgentEvent['type']): string => {
switch (type) {
case 'thought': return '💭';
case 'tool': return '🔧';
case 'tool_result': return '✅🔧';
case 'result': return '✅';
case 'log': return '';
default: return '●';
@ -53,19 +66,106 @@ const eventSummary = (evt: AgentEvent): string => {
switch (evt.type) {
case 'thought': return `Thinking… (${evt.metrics?.model || 'LLM'})`;
case 'tool': return evt.message.startsWith('✓') ? 'Tool result received' : `Tool call: ${evt.message.replace(/^▶ Tool: /, '').split(' | ')[0]}`;
case 'tool_result': return `Tool done: ${evt.message.replace(/^✓ Tool result: /, '').split(' | ')[0]}`;
case 'result': return 'Completed';
case 'log': return evt.message;
default: return evt.type;
}
};
// ─── Detail drawer for a single event ─────────────────────────────────
const EventDetail: React.FC<{ event: AgentEvent }> = ({ event }) => (
// ─── Full Event Detail Modal ─────────────────────────────────────────
const EventDetailModal: React.FC<{ event: AgentEvent | null; isOpen: boolean; onClose: () => void }> = ({ event, isOpen, onClose }) => {
if (!event) return null;
return (
<Modal isOpen={isOpen} onClose={onClose} size="4xl" scrollBehavior="inside">
<ModalOverlay backdropFilter="blur(6px)" />
<ModalContent bg="slate.900" color="white" maxH="85vh" border="1px solid" borderColor="whiteAlpha.200">
<ModalCloseButton />
<ModalHeader borderBottomWidth="1px" borderColor="whiteAlpha.100">
<HStack>
<Badge colorScheme={event.type === 'result' ? 'green' : event.type === 'tool' || event.type === 'tool_result' ? 'purple' : 'cyan'} fontSize="sm">
{event.type.toUpperCase()}
</Badge>
<Badge variant="outline" fontSize="sm">{event.agent}</Badge>
<Text fontSize="sm" color="whiteAlpha.400" fontWeight="normal">{event.timestamp}</Text>
</HStack>
</ModalHeader>
<ModalBody py={4}>
<Tabs variant="soft-rounded" colorScheme="cyan" size="sm">
<TabList mb={4}>
{event.prompt && <Tab>Prompt / Request</Tab>}
{(event.response || (event.type === 'result' && event.message)) && <Tab>Response</Tab>}
<Tab>Summary</Tab>
{event.metrics && <Tab>Metrics</Tab>}
</TabList>
<TabPanels>
{event.prompt && (
<TabPanel p={0}>
<Box bg="blackAlpha.500" p={4} borderRadius="md" border="1px solid" borderColor="whiteAlpha.100" maxH="60vh" overflowY="auto">
<Text fontSize="xs" fontFamily="mono" whiteSpace="pre-wrap" wordBreak="break-word" color="whiteAlpha.900">
{event.prompt}
</Text>
</Box>
</TabPanel>
)}
{(event.response || (event.type === 'result' && event.message)) && (
<TabPanel p={0}>
<Box bg="blackAlpha.500" p={4} borderRadius="md" border="1px solid" borderColor="whiteAlpha.100" maxH="60vh" overflowY="auto">
<Text fontSize="xs" fontFamily="mono" whiteSpace="pre-wrap" wordBreak="break-word" color="whiteAlpha.900">
{event.response || event.message}
</Text>
</Box>
</TabPanel>
)}
<TabPanel p={0}>
<Box bg="blackAlpha.500" p={4} borderRadius="md" border="1px solid" borderColor="whiteAlpha.100">
<Text fontSize="sm" whiteSpace="pre-wrap" wordBreak="break-word" color="whiteAlpha.900">
{event.message}
</Text>
</Box>
</TabPanel>
{event.metrics && (
<TabPanel p={0}>
<VStack align="stretch" spacing={3}>
{event.metrics.model && event.metrics.model !== 'unknown' && (
<HStack><Text fontSize="sm" color="whiteAlpha.600" minW="80px">Model:</Text><Code colorScheme="blue" fontSize="sm">{event.metrics.model}</Code></HStack>
)}
{event.metrics.tokens_in != null && event.metrics.tokens_in > 0 && (
<HStack><Text fontSize="sm" color="whiteAlpha.600" minW="80px">Tokens In:</Text><Code>{event.metrics.tokens_in}</Code></HStack>
)}
{event.metrics.tokens_out != null && event.metrics.tokens_out > 0 && (
<HStack><Text fontSize="sm" color="whiteAlpha.600" minW="80px">Tokens Out:</Text><Code>{event.metrics.tokens_out}</Code></HStack>
)}
{event.metrics.latency_ms != null && event.metrics.latency_ms > 0 && (
<HStack><Text fontSize="sm" color="whiteAlpha.600" minW="80px">Latency:</Text><Code>{event.metrics.latency_ms}ms</Code></HStack>
)}
{event.node_id && (
<HStack><Text fontSize="sm" color="whiteAlpha.600" minW="80px">Node ID:</Text><Code fontSize="xs">{event.node_id}</Code></HStack>
)}
</VStack>
</TabPanel>
)}
</TabPanels>
</Tabs>
</ModalBody>
</ModalContent>
</Modal>
);
};
// ─── Detail card for a single event in the drawer ─────────────────────
const EventDetail: React.FC<{ event: AgentEvent; onOpenModal?: (evt: AgentEvent) => void }> = ({ event, onOpenModal }) => (
<VStack align="stretch" spacing={4}>
<HStack>
<Badge colorScheme="cyan">{event.type.toUpperCase()}</Badge>
<Badge colorScheme={event.type === 'result' ? 'green' : event.type === 'tool' || event.type === 'tool_result' ? 'purple' : 'cyan'}>{event.type.toUpperCase()}</Badge>
<Badge variant="outline">{event.agent}</Badge>
<Text fontSize="xs" color="whiteAlpha.400">{event.timestamp}</Text>
{onOpenModal && (
<Button size="xs" variant="ghost" colorScheme="cyan" ml="auto" onClick={() => onOpenModal(event)}>
Full Detail
</Button>
)}
</HStack>
{event.metrics?.model && event.metrics.model !== 'unknown' && (
@ -79,7 +179,7 @@ const EventDetail: React.FC<{ event: AgentEvent }> = ({ event }) => (
<Box>
<Text fontSize="xs" fontWeight="bold" color="whiteAlpha.600" mb={1}>Metrics</Text>
<HStack spacing={4} fontSize="sm">
{event.metrics.tokens_in != null && (
{event.metrics.tokens_in != null && event.metrics.tokens_in > 0 && (
<Text>Tokens: <Code>{event.metrics.tokens_in}</Code> in / <Code>{event.metrics.tokens_out}</Code> out</Text>
)}
{event.metrics.latency_ms != null && event.metrics.latency_ms > 0 && (
@ -89,16 +189,41 @@ const EventDetail: React.FC<{ event: AgentEvent }> = ({ event }) => (
</Box>
)}
{/* Show prompt if available */}
{event.prompt && (
<Box>
<Text fontSize="xs" fontWeight="bold" color="whiteAlpha.600" mb={1}>
{event.type === 'thought' ? 'Request / Prompt' : event.type === 'result' ? 'Response' : 'Message'}
<Text fontSize="xs" fontWeight="bold" color="whiteAlpha.600" mb={1}>Request / Prompt</Text>
<Box bg="blackAlpha.500" p={3} borderRadius="md" border="1px solid" borderColor="whiteAlpha.100" maxH="200px" overflowY="auto">
<Text fontSize="xs" fontFamily="mono" whiteSpace="pre-wrap" wordBreak="break-word" color="whiteAlpha.900">
{event.prompt.length > 1000 ? event.prompt.substring(0, 1000) + '…' : event.prompt}
</Text>
</Box>
</Box>
)}
{/* Show response if available (result events) */}
{event.response && (
<Box>
<Text fontSize="xs" fontWeight="bold" color="whiteAlpha.600" mb={1}>Response</Text>
<Box bg="blackAlpha.500" p={3} borderRadius="md" border="1px solid" borderColor="green.900" maxH="200px" overflowY="auto">
<Text fontSize="xs" fontFamily="mono" whiteSpace="pre-wrap" wordBreak="break-word" color="whiteAlpha.900">
{event.response.length > 1000 ? event.response.substring(0, 1000) + '…' : event.response}
</Text>
</Box>
</Box>
)}
{/* Fallback: show message if no prompt/response */}
{!event.prompt && !event.response && (
<Box>
<Text fontSize="xs" fontWeight="bold" color="whiteAlpha.600" mb={1}>Message</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">
{event.message}
</Text>
</Box>
</Box>
)}
{event.node_id && (
<Box>
@ -110,7 +235,7 @@ const EventDetail: React.FC<{ event: AgentEvent }> = ({ event }) => (
);
// ─── Detail drawer showing all events for a given graph node ──────────
const NodeEventsDetail: React.FC<{ nodeId: string; events: AgentEvent[] }> = ({ nodeId, events }) => {
const NodeEventsDetail: React.FC<{ nodeId: string; events: AgentEvent[]; onOpenModal: (evt: AgentEvent) => void }> = ({ nodeId, events, onOpenModal }) => {
const nodeEvents = useMemo(
() => events.filter((e) => e.node_id === nodeId),
[events, nodeId],
@ -124,7 +249,7 @@ const NodeEventsDetail: React.FC<{ nodeId: string; events: AgentEvent[] }> = ({
<VStack align="stretch" spacing={4}>
{nodeEvents.map((evt) => (
<Box key={evt.id} bg="whiteAlpha.50" p={3} borderRadius="md" border="1px solid" borderColor="whiteAlpha.100">
<EventDetail event={evt} />
<EventDetail event={evt} onOpenModal={onOpenModal} />
</Box>
))}
</VStack>
@ -138,6 +263,10 @@ export const Dashboard: React.FC = () => {
const { events, status, clearEvents } = useAgentStream(activeRunId);
const { isOpen, onOpen, onClose } = useDisclosure();
// Event detail modal state
const { isOpen: isModalOpen, onOpen: onModalOpen, onClose: onModalClose } = useDisclosure();
const [modalEvent, setModalEvent] = useState<AgentEvent | null>(null);
// What's shown in the drawer: either a single event or all events for a node
const [drawerMode, setDrawerMode] = useState<'event' | 'node'>('event');
const [selectedEvent, setSelectedEvent] = useState<AgentEvent | null>(null);
@ -150,8 +279,10 @@ export const Dashboard: React.FC = () => {
terminalEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [events.length]);
const isRunning = isTriggering || status === 'streaming' || status === 'connecting';
const startRun = async (type: string) => {
if (isTriggering || status === 'streaming' || status === 'connecting') return;
if (isRunning) return;
setIsTriggering(true);
try {
@ -168,6 +299,12 @@ export const Dashboard: React.FC = () => {
}
};
/** Open the full-screen event detail modal */
const openModal = useCallback((evt: AgentEvent) => {
setModalEvent(evt);
onModalOpen();
}, [onModalOpen]);
/** Open the drawer for a single event (terminal click). */
const openEventDetail = useCallback((evt: AgentEvent) => {
setDrawerMode('event');
@ -211,16 +348,50 @@ export const Dashboard: React.FC = () => {
<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={3}>
<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={<Play size={14} />}
leftIcon={<Search size={14} />}
colorScheme="cyan"
variant="solid"
onClick={() => startRun('scan')}
isLoading={isTriggering || status === 'connecting' || status === 'streaming'}
isLoading={isRunning}
loadingText="Running…"
>
Start Market Scan
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'}>
@ -290,14 +461,17 @@ export const Dashboard: React.FC = () => {
</DrawerHeader>
<DrawerBody py={4}>
{drawerMode === 'event' && selectedEvent && (
<EventDetail event={selectedEvent} />
<EventDetail event={selectedEvent} onOpenModal={openModal} />
)}
{drawerMode === 'node' && selectedNodeId && (
<NodeEventsDetail nodeId={selectedNodeId} events={events} />
<NodeEventsDetail nodeId={selectedNodeId} events={events} onOpenModal={openModal} />
)}
</DrawerBody>
</DrawerContent>
</Drawer>
{/* Full event detail modal */}
<EventDetailModal event={modalEvent} isOpen={isModalOpen} onClose={onModalClose} />
</Flex>
);
};

View File

@ -127,6 +127,9 @@ export const AgentGraph: React.FC<AgentGraphProps> = ({ events, onNodeClick }) =
for (const evt of newEvents) {
if (!evt.node_id || evt.node_id === '__system__') continue;
// Determine if this event means the node is completed
const isCompleted = evt.type === 'result' || evt.type === 'tool_result';
if (!seenNodeIds.current.has(evt.node_id)) {
// New node — create it
seenNodeIds.current.add(evt.node_id);
@ -138,7 +141,7 @@ export const AgentGraph: React.FC<AgentGraphProps> = ({ events, onNodeClick }) =
position: { x: 250, y: nodeCount.current * 150 + 50 },
data: {
agent: evt.agent,
status: evt.type === 'result' ? 'completed' : 'running',
status: isCompleted ? 'completed' : 'running',
metrics: evt.metrics,
},
});
@ -159,8 +162,11 @@ export const AgentGraph: React.FC<AgentGraphProps> = ({ events, onNodeClick }) =
}
} else {
// Existing node — queue a status/metrics update
// Never revert a completed node back to running
const prev = updatedNodeData.get(evt.node_id);
const currentlyCompleted = prev?.status === 'completed';
updatedNodeData.set(evt.node_id, {
status: evt.type === 'result' ? 'completed' : 'running',
status: currentlyCompleted || isCompleted ? 'completed' : 'running',
metrics: evt.metrics,
});
}
@ -178,9 +184,11 @@ export const AgentGraph: React.FC<AgentGraphProps> = ({ events, onNodeClick }) =
prev.map((n) => {
const patch = updatedNodeData.get(n.id);
if (!patch) return n;
// Never revert a completed node back to running
const finalStatus = n.data.status === 'completed' ? 'completed' : patch.status;
return {
...n,
data: { ...n.data, ...patch, metrics: patch.metrics ?? n.data.metrics },
data: { ...n.data, ...patch, status: finalStatus, metrics: patch.metrics ?? n.data.metrics },
};
}),
);

View File

@ -5,8 +5,12 @@ export interface AgentEvent {
timestamp: string;
agent: string;
tier: 'quick' | 'mid' | 'deep';
type: 'thought' | 'tool' | 'result' | 'log' | 'system';
type: 'thought' | 'tool' | 'tool_result' | 'result' | 'log' | 'system';
message: string;
/** Full prompt text (available on thought & result events). */
prompt?: string;
/** Full response text (available on result & tool_result events). */
response?: string;
node_id?: string;
parent_node_id?: string;
metrics?: {