Spaces:
Runtime error
Runtime error
| import { useCallback, useState } from 'react'; | |
| import { Box, Stack, Typography, Chip, Button, TextField, IconButton, Link } from '@mui/material'; | |
| import CheckCircleOutlineIcon from '@mui/icons-material/CheckCircleOutline'; | |
| import ErrorOutlineIcon from '@mui/icons-material/ErrorOutline'; | |
| import MoreHorizIcon from '@mui/icons-material/MoreHoriz'; | |
| import OpenInNewIcon from '@mui/icons-material/OpenInNew'; | |
| import HourglassEmptyIcon from '@mui/icons-material/HourglassEmpty'; | |
| import LaunchIcon from '@mui/icons-material/Launch'; | |
| import SendIcon from '@mui/icons-material/Send'; | |
| import { useAgentStore } from '@/store/agentStore'; | |
| import { useLayoutStore } from '@/store/layoutStore'; | |
| import { useSessionStore } from '@/store/sessionStore'; | |
| import { apiFetch } from '@/utils/api'; | |
| import { logger } from '@/utils/logger'; | |
| import type { TraceLog } from '@/types/agent'; | |
| interface ToolCallGroupProps { | |
| tools: TraceLog[]; | |
| } | |
| /** Check if a running tool has been stuck for too long (5 minutes). */ | |
| const TOOL_TIMEOUT_MS = 5 * 60 * 1000; | |
| function isTimedOut(log: TraceLog): boolean { | |
| if (log.completed || log.approvalStatus === 'pending') return false; | |
| const elapsed = Date.now() - new Date(log.timestamp).getTime(); | |
| return elapsed > TOOL_TIMEOUT_MS; | |
| } | |
| // ββ Status icon based on tool state βββββββββββββββββββββββββββββββββ | |
| function StatusIcon({ log }: { log: TraceLog }) { | |
| // Awaiting approval | |
| if (log.approvalStatus === 'pending') { | |
| return <HourglassEmptyIcon sx={{ fontSize: 16, color: 'var(--accent-yellow)' }} />; | |
| } | |
| // Rejected | |
| if (log.approvalStatus === 'rejected') { | |
| return <ErrorOutlineIcon sx={{ fontSize: 16, color: 'error.main' }} />; | |
| } | |
| // Timed out | |
| if (isTimedOut(log)) { | |
| return <ErrorOutlineIcon sx={{ fontSize: 16, color: 'var(--muted-text)' }} />; | |
| } | |
| // Running (not completed yet) | |
| if (!log.completed) { | |
| return ( | |
| <MoreHorizIcon | |
| sx={{ | |
| fontSize: 16, | |
| color: 'var(--muted-text)', | |
| animation: 'pulse 1.5s ease-in-out infinite', | |
| '@keyframes pulse': { | |
| '0%, 100%': { opacity: 0.4 }, | |
| '50%': { opacity: 1 }, | |
| }, | |
| }} | |
| /> | |
| ); | |
| } | |
| // Failed | |
| if (log.success === false) { | |
| return <ErrorOutlineIcon sx={{ fontSize: 16, color: 'error.main' }} />; | |
| } | |
| // Completed successfully | |
| return <CheckCircleOutlineIcon sx={{ fontSize: 16, color: 'success.main' }} />; | |
| } | |
| // ββ Status chip label βββββββββββββββββββββββββββββββββββββββββββββββ | |
| function statusLabel(log: TraceLog): string | null { | |
| if (log.approvalStatus === 'pending') return 'awaiting approval'; | |
| if (log.approvalStatus === 'rejected') return 'rejected'; | |
| if (isTimedOut(log)) return 'timed out'; | |
| if (!log.completed) return 'running'; | |
| return null; | |
| } | |
| function statusColor(log: TraceLog): string { | |
| if (log.approvalStatus === 'pending') return 'var(--accent-yellow)'; | |
| if (log.approvalStatus === 'rejected') return 'var(--accent-red)'; | |
| if (isTimedOut(log)) return 'var(--muted-text)'; | |
| return 'var(--accent-yellow)'; | |
| } | |
| // ββ Inline approval UI ββββββββββββββββββββββββββββββββββββββββββββββ | |
| function InlineApproval({ | |
| log, | |
| onResolve, | |
| }: { | |
| log: TraceLog; | |
| onResolve: (toolCallId: string, approved: boolean, feedback?: string) => void; | |
| }) { | |
| const [feedback, setFeedback] = useState(''); | |
| return ( | |
| <Box sx={{ px: 1.5, py: 1.5, borderTop: '1px solid var(--tool-border)' }}> | |
| {/* Tool description */} | |
| {log.tool === 'hf_jobs' && log.args && ( | |
| <Typography variant="body2" sx={{ color: 'var(--muted-text)', fontSize: '0.75rem', mb: 1.5 }}> | |
| Execute <Box component="span" sx={{ color: 'var(--accent-yellow)', fontWeight: 500 }}>{log.tool}</Box> on{' '} | |
| <Box component="span" sx={{ fontWeight: 500, color: 'var(--text)' }}> | |
| {String(log.args.hardware_flavor || 'default')} | |
| </Box> | |
| {!!log.args.timeout && ( | |
| <> with timeout <Box component="span" sx={{ fontWeight: 500, color: 'var(--text)' }}> | |
| {String(log.args.timeout)} | |
| </Box></> | |
| )} | |
| </Typography> | |
| )} | |
| {/* Feedback + buttons */} | |
| <Box sx={{ display: 'flex', gap: 1, mb: 1 }}> | |
| <TextField | |
| fullWidth | |
| size="small" | |
| placeholder="Feedback (optional)" | |
| value={feedback} | |
| onChange={(e) => setFeedback(e.target.value)} | |
| variant="outlined" | |
| sx={{ | |
| '& .MuiOutlinedInput-root': { | |
| bgcolor: 'rgba(0,0,0,0.15)', | |
| fontFamily: 'inherit', | |
| fontSize: '0.8rem', | |
| }, | |
| }} | |
| /> | |
| <IconButton | |
| onClick={() => onResolve(log.toolCallId || '', false, feedback || 'Rejected by user')} | |
| disabled={!feedback} | |
| size="small" | |
| sx={{ | |
| color: 'var(--accent-red)', | |
| border: '1px solid rgba(255,255,255,0.05)', | |
| borderRadius: '6px', | |
| '&:hover': { bgcolor: 'rgba(224,90,79,0.1)', borderColor: 'var(--accent-red)' }, | |
| '&.Mui-disabled': { color: 'rgba(255,255,255,0.1)' }, | |
| }} | |
| > | |
| <SendIcon sx={{ fontSize: 14 }} /> | |
| </IconButton> | |
| </Box> | |
| <Box sx={{ display: 'flex', gap: 1 }}> | |
| <Button | |
| size="small" | |
| onClick={() => onResolve(log.toolCallId || '', false, feedback || 'Rejected by user')} | |
| sx={{ | |
| flex: 1, | |
| textTransform: 'none', | |
| border: '1px solid rgba(255,255,255,0.05)', | |
| color: 'var(--accent-red)', | |
| fontSize: '0.75rem', | |
| py: 0.75, | |
| borderRadius: '8px', | |
| '&:hover': { bgcolor: 'rgba(224,90,79,0.05)', borderColor: 'var(--accent-red)' }, | |
| }} | |
| > | |
| Reject | |
| </Button> | |
| <Button | |
| size="small" | |
| onClick={() => onResolve(log.toolCallId || '', true)} | |
| sx={{ | |
| flex: 1, | |
| textTransform: 'none', | |
| border: '1px solid rgba(255,255,255,0.05)', | |
| color: 'var(--accent-green)', | |
| fontSize: '0.75rem', | |
| py: 0.75, | |
| borderRadius: '8px', | |
| '&:hover': { bgcolor: 'rgba(47,204,113,0.05)', borderColor: 'var(--accent-green)' }, | |
| }} | |
| > | |
| Approve | |
| </Button> | |
| </Box> | |
| </Box> | |
| ); | |
| } | |
| // ββ Main component ββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| export default function ToolCallGroup({ tools }: ToolCallGroupProps) { | |
| const { showToolOutput, setPanelTab, setActivePanelTab, clearPanelTabs } = useAgentStore(); | |
| const { setRightPanelOpen, setLeftSidebarOpen } = useLayoutStore(); | |
| const { activeSessionId } = useSessionStore(); | |
| const handleClick = useCallback( | |
| (log: TraceLog) => { | |
| // For hf_jobs with scripts, use tab system | |
| if (log.tool === 'hf_jobs' && log.args?.script) { | |
| clearPanelTabs(); | |
| setPanelTab({ | |
| id: 'script', | |
| title: 'Script', | |
| content: String(log.args.script), | |
| language: 'python', | |
| }); | |
| if (log.output) { | |
| setPanelTab({ | |
| id: 'output', | |
| title: 'Output', | |
| content: log.output, | |
| language: 'markdown', | |
| }); | |
| } | |
| if (log.jobLogs) { | |
| setPanelTab({ | |
| id: 'logs', | |
| title: 'Logs', | |
| content: log.jobLogs, | |
| language: 'text', | |
| }); | |
| } | |
| // Default to output if it exists (most useful), otherwise script | |
| setActivePanelTab(log.output ? 'output' : 'script'); | |
| setRightPanelOpen(true); | |
| setLeftSidebarOpen(false); | |
| return; | |
| } | |
| // Show output if completed, or args if still running | |
| if (log.completed && log.output) { | |
| showToolOutput(log); | |
| } else if (log.args) { | |
| const content = JSON.stringify(log.args, null, 2); | |
| showToolOutput({ ...log, output: content }); | |
| } else { | |
| return; | |
| } | |
| setRightPanelOpen(true); | |
| }, | |
| [showToolOutput, setRightPanelOpen, setLeftSidebarOpen, clearPanelTabs, setPanelTab, setActivePanelTab], | |
| ); | |
| const handleApprovalResolve = useCallback( | |
| async (toolCallId: string, approved: boolean, feedback?: string) => { | |
| if (!activeSessionId) return; | |
| try { | |
| const res = await apiFetch('/api/approve', { | |
| method: 'POST', | |
| body: JSON.stringify({ | |
| session_id: activeSessionId, | |
| approvals: [{ | |
| tool_call_id: toolCallId, | |
| approved, | |
| feedback: approved ? null : feedback || 'Rejected by user', | |
| }], | |
| }), | |
| }); | |
| if (res.ok) { | |
| // Optimistic update: immediately reflect approval status in the UI | |
| const { updateTraceLog, updateCurrentTurnTrace, setProcessing } = useAgentStore.getState(); | |
| updateTraceLog(toolCallId, '', { | |
| approvalStatus: approved ? 'approved' : 'rejected', | |
| completed: !approved, // Rejected tools are done; approved ones will run | |
| }); | |
| updateCurrentTurnTrace(activeSessionId); | |
| if (approved) setProcessing(true); | |
| } | |
| } catch (e) { | |
| logger.error('Approval failed:', e); | |
| } | |
| }, | |
| [activeSessionId], | |
| ); | |
| return ( | |
| <Box | |
| sx={{ | |
| borderRadius: 2, | |
| border: '1px solid var(--tool-border)', | |
| bgcolor: 'var(--tool-bg)', | |
| overflow: 'hidden', | |
| my: 1, | |
| }} | |
| > | |
| <Stack divider={<Box sx={{ borderBottom: '1px solid var(--tool-border)' }} />}> | |
| {tools.map((log) => { | |
| const clickable = (log.completed && !!log.output) || !!log.args; | |
| const label = statusLabel(log); | |
| const isPendingApproval = log.approvalStatus === 'pending'; | |
| return ( | |
| <Box key={log.id}> | |
| {/* Main tool row */} | |
| <Stack | |
| direction="row" | |
| alignItems="center" | |
| spacing={1} | |
| onClick={() => !isPendingApproval && handleClick(log)} | |
| sx={{ | |
| px: 1.5, | |
| py: 1, | |
| cursor: isPendingApproval ? 'default' : clickable ? 'pointer' : 'default', | |
| transition: 'background-color 0.15s', | |
| '&:hover': clickable && !isPendingApproval ? { bgcolor: 'var(--hover-bg)' } : {}, | |
| }} | |
| > | |
| <StatusIcon log={log} /> | |
| <Typography | |
| variant="body2" | |
| sx={{ | |
| fontFamily: '"JetBrains Mono", ui-monospace, SFMono-Regular, monospace', | |
| fontWeight: 600, | |
| fontSize: '0.78rem', | |
| color: 'var(--text)', | |
| flex: 1, | |
| minWidth: 0, | |
| overflow: 'hidden', | |
| textOverflow: 'ellipsis', | |
| whiteSpace: 'nowrap', | |
| }} | |
| > | |
| {log.tool} | |
| </Typography> | |
| {label && ( | |
| <Chip | |
| label={label} | |
| size="small" | |
| sx={{ | |
| height: 20, | |
| fontSize: '0.65rem', | |
| fontWeight: 600, | |
| bgcolor: 'var(--accent-yellow-weak)', | |
| color: statusColor(log), | |
| letterSpacing: '0.03em', | |
| }} | |
| /> | |
| )} | |
| {clickable && !isPendingApproval && ( | |
| <OpenInNewIcon sx={{ fontSize: 14, color: 'var(--muted-text)', opacity: 0.6 }} /> | |
| )} | |
| </Stack> | |
| {/* Job status + link row */} | |
| {(log.jobUrl || log.jobStatus) && ( | |
| <Box | |
| sx={{ | |
| display: 'flex', | |
| alignItems: 'center', | |
| gap: 1.5, | |
| px: 1.5, | |
| py: 0.75, | |
| borderTop: '1px solid var(--tool-border)', | |
| }} | |
| > | |
| {log.jobStatus && ( | |
| <Typography | |
| variant="caption" | |
| sx={{ | |
| color: log.success === false ? 'var(--accent-red)' : 'var(--accent-green)', | |
| fontSize: '0.7rem', | |
| fontWeight: 600, | |
| }} | |
| > | |
| {log.jobStatus} | |
| </Typography> | |
| )} | |
| {log.jobUrl && ( | |
| <Link | |
| href={log.jobUrl} | |
| target="_blank" | |
| rel="noopener noreferrer" | |
| onClick={(e) => e.stopPropagation()} | |
| sx={{ | |
| display: 'inline-flex', | |
| alignItems: 'center', | |
| gap: 0.5, | |
| color: 'var(--accent-yellow)', | |
| fontSize: '0.68rem', | |
| textDecoration: 'none', | |
| '&:hover': { textDecoration: 'underline' }, | |
| }} | |
| > | |
| <LaunchIcon sx={{ fontSize: 12 }} /> | |
| View on HF | |
| </Link> | |
| )} | |
| </Box> | |
| )} | |
| {/* Inline approval UI (only when pending) */} | |
| {isPendingApproval && ( | |
| <InlineApproval log={log} onResolve={handleApprovalResolve} /> | |
| )} | |
| </Box> | |
| ); | |
| })} | |
| </Stack> | |
| </Box> | |
| ); | |
| } | |