| | 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[];
|
| | }
|
| |
|
| |
|
| | 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;
|
| | }
|
| |
|
| |
|
| | function StatusIcon({ log }: { log: TraceLog }) {
|
| |
|
| | if (log.approvalStatus === 'pending') {
|
| | return <HourglassEmptyIcon sx={{ fontSize: 16, color: 'var(--accent-yellow)' }} />;
|
| | }
|
| |
|
| | if (log.approvalStatus === 'rejected') {
|
| | return <ErrorOutlineIcon sx={{ fontSize: 16, color: 'error.main' }} />;
|
| | }
|
| |
|
| | if (isTimedOut(log)) {
|
| | return <ErrorOutlineIcon sx={{ fontSize: 16, color: 'var(--muted-text)' }} />;
|
| | }
|
| |
|
| | 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 },
|
| | },
|
| | }}
|
| | />
|
| | );
|
| | }
|
| |
|
| | if (log.success === false) {
|
| | return <ErrorOutlineIcon sx={{ fontSize: 16, color: 'error.main' }} />;
|
| | }
|
| |
|
| | return <CheckCircleOutlineIcon sx={{ fontSize: 16, color: 'success.main' }} />;
|
| | }
|
| |
|
| |
|
| | 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)';
|
| | }
|
| |
|
| |
|
| | 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>
|
| | )}
|
| |
|
| | {}
|
| | <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>
|
| | );
|
| | }
|
| |
|
| |
|
| | export default function ToolCallGroup({ tools }: ToolCallGroupProps) {
|
| | const { showToolOutput, setPanelTab, setActivePanelTab, clearPanelTabs } = useAgentStore();
|
| | const { setRightPanelOpen, setLeftSidebarOpen } = useLayoutStore();
|
| | const { activeSessionId } = useSessionStore();
|
| |
|
| | const handleClick = useCallback(
|
| | (log: TraceLog) => {
|
| |
|
| | 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',
|
| | });
|
| | }
|
| |
|
| | setActivePanelTab(log.output ? 'output' : 'script');
|
| | setRightPanelOpen(true);
|
| | setLeftSidebarOpen(false);
|
| | return;
|
| | }
|
| |
|
| |
|
| | 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) {
|
| |
|
| | const { updateTraceLog, updateCurrentTurnTrace, setProcessing } = useAgentStore.getState();
|
| | updateTraceLog(toolCallId, '', {
|
| | approvalStatus: approved ? 'approved' : 'rejected',
|
| | completed: !approved,
|
| | });
|
| | 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>
|
| | );
|
| | }
|
| |
|