Spaces:
Running on CPU Upgrade
Running on CPU Upgrade
| import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; | |
| import { Box, Stack, Typography, Chip, Button, TextField, IconButton, Link, CircularProgress } from '@mui/material'; | |
| import CheckCircleOutlineIcon from '@mui/icons-material/CheckCircleOutline'; | |
| import ErrorOutlineIcon from '@mui/icons-material/ErrorOutline'; | |
| 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 BlockIcon from '@mui/icons-material/Block'; | |
| import { useAgentStore } from '@/store/agentStore'; | |
| import { useLayoutStore } from '@/store/layoutStore'; | |
| import { logger } from '@/utils/logger'; | |
| import type { UIMessage } from 'ai'; | |
| // --------------------------------------------------------------------------- | |
| // Type helpers — extract the dynamic-tool part type from UIMessage | |
| // --------------------------------------------------------------------------- | |
| type DynamicToolPart = Extract<UIMessage['parts'][number], { type: 'dynamic-tool' }>; | |
| type ToolPartState = DynamicToolPart['state']; | |
| /** Check if a tool part was cancelled (output-error with cancellation message). */ | |
| function isCancelledTool(tool: DynamicToolPart): boolean { | |
| return tool.state === 'output-error' && | |
| typeof (tool as Record<string, unknown>).errorText === 'string' && | |
| ((tool as Record<string, unknown>).errorText as string).includes('Cancelled by user'); | |
| } | |
| interface ToolCallGroupProps { | |
| tools: DynamicToolPart[]; | |
| approveTools: (approvals: Array<{ tool_call_id: string; approved: boolean; feedback?: string | null; edited_script?: string | null }>) => Promise<boolean>; | |
| } | |
| // --------------------------------------------------------------------------- | |
| // Research sub-steps (inline under the research tool row) | |
| // --------------------------------------------------------------------------- | |
| /** Pretty labels for research sub-agent tool calls */ | |
| function formatResearchStep(step: string): { icon: string; label: string } { | |
| if (step === 'Starting research sub-agent...') return { icon: '🔍', label: 'Starting research' }; | |
| if (step === 'Research complete.') return { icon: '✓', label: 'Research complete' }; | |
| if (step.startsWith('github_find_examples')) return { icon: '📂', label: step.replace('github_find_examples', 'Finding examples') }; | |
| if (step.startsWith('github_read_file')) { | |
| const path = step.match(/\(([^)]+)\)/)?.[1] || ''; | |
| const filename = path.split('/').pop() || path; | |
| return { icon: '📄', label: `Reading ${filename}` }; | |
| } | |
| if (step.startsWith('explore_hf_docs')) return { icon: '📚', label: step.replace('explore_hf_docs', 'Exploring docs') }; | |
| if (step.startsWith('fetch_hf_docs')) return { icon: '📖', label: step.replace('fetch_hf_docs', 'Fetching docs') }; | |
| if (step.startsWith('hf_inspect_dataset')) return { icon: '🗃️', label: step.replace('hf_inspect_dataset', 'Inspecting dataset') }; | |
| if (step.startsWith('hf_papers')) return { icon: '📑', label: 'Searching papers' }; | |
| if (step.startsWith('find_hf_api')) return { icon: '🔌', label: 'Finding API endpoints' }; | |
| if (step.startsWith('hf_repo_files')) return { icon: '📁', label: 'Reading repo files' }; | |
| return { icon: '→', label: step }; | |
| } | |
| function ResearchSteps({ steps, isRunning }: { steps: string[]; isRunning: boolean }) { | |
| // Filter out the "Starting..." and "complete" meta-steps for the list | |
| const toolSteps = steps.filter( | |
| s => s !== 'Starting research sub-agent...' && s !== 'Research complete.', | |
| ); | |
| if (toolSteps.length === 0) return null; | |
| return ( | |
| <Box sx={{ pl: 4.5, pr: 1.5, pb: 1, pt: 0.25 }}> | |
| {toolSteps.map((step, i) => { | |
| const { icon, label } = formatResearchStep(step); | |
| const isLast = i === toolSteps.length - 1; | |
| return ( | |
| <Stack | |
| key={i} | |
| direction="row" | |
| alignItems="center" | |
| spacing={0.75} | |
| sx={{ py: 0.2 }} | |
| > | |
| <Typography sx={{ fontSize: '0.65rem', lineHeight: 1, width: 14, textAlign: 'center', flexShrink: 0 }}> | |
| {isLast && isRunning ? '' : icon} | |
| </Typography> | |
| {isLast && isRunning && ( | |
| <CircularProgress size={10} thickness={5} sx={{ color: 'var(--accent-yellow)', flexShrink: 0 }} /> | |
| )} | |
| <Typography | |
| sx={{ | |
| fontFamily: '"JetBrains Mono", ui-monospace, SFMono-Regular, monospace', | |
| fontSize: '0.68rem', | |
| color: isLast && isRunning ? 'var(--text)' : 'var(--muted-text)', | |
| overflow: 'hidden', | |
| textOverflow: 'ellipsis', | |
| whiteSpace: 'nowrap', | |
| }} | |
| > | |
| {label} | |
| </Typography> | |
| </Stack> | |
| ); | |
| })} | |
| </Box> | |
| ); | |
| } | |
| // Stable reference to avoid infinite re-renders from Zustand selectors | |
| const EMPTY_STEPS: string[] = []; | |
| // --------------------------------------------------------------------------- | |
| // Hardware pricing ($/hr) — from HF Spaces & Jobs pricing | |
| // --------------------------------------------------------------------------- | |
| const HARDWARE_PRICING: Record<string, string> = { | |
| 'cpu-basic': 'free', | |
| 'cpu-upgrade': '$0.03/hr', | |
| 't4-small': '$0.60/hr', | |
| 't4-medium': '$1.00/hr', | |
| 'a10g-small': '$1.05/hr', | |
| 'a10g-large': '$3.15/hr', | |
| 'a10g-largex2': '$6.30/hr', | |
| 'a10g-largex4': '$12.60/hr', | |
| 'a100-large': '$4.13/hr', | |
| 'a100x4': '$16.52/hr', | |
| 'a100x8': '$33.04/hr', | |
| 'l4x1': '$0.80/hr', | |
| 'l4x4': '$3.20/hr', | |
| 'l40sx1': '$1.80/hr', | |
| 'l40sx4': '$7.20/hr', | |
| 'l40sx8': '$14.40/hr', | |
| }; | |
| function costLabel(hardware: string): string | null { | |
| return HARDWARE_PRICING[hardware] || null; | |
| } | |
| // --------------------------------------------------------------------------- | |
| // Visual helpers | |
| // --------------------------------------------------------------------------- | |
| function StatusIcon({ state, cancelled }: { state: ToolPartState; cancelled?: boolean }) { | |
| if (cancelled) { | |
| return <BlockIcon sx={{ fontSize: 16, color: 'var(--muted-text)' }} />; | |
| } | |
| switch (state) { | |
| case 'approval-requested': | |
| return <HourglassEmptyIcon sx={{ fontSize: 16, color: 'var(--accent-yellow)' }} />; | |
| case 'approval-responded': | |
| return <CircularProgress size={14} thickness={5} sx={{ color: 'var(--accent-green)' }} />; | |
| case 'output-available': | |
| return <CheckCircleOutlineIcon sx={{ fontSize: 16, color: 'success.main' }} />; | |
| case 'output-error': | |
| return <ErrorOutlineIcon sx={{ fontSize: 16, color: 'error.main' }} />; | |
| case 'output-denied': | |
| return <BlockIcon sx={{ fontSize: 16, color: 'var(--muted-text)' }} />; | |
| case 'input-streaming': | |
| case 'input-available': | |
| default: | |
| return <CircularProgress size={14} thickness={5} sx={{ color: 'var(--accent-yellow)' }} />; | |
| } | |
| } | |
| function statusLabel(state: ToolPartState): string | null { | |
| switch (state) { | |
| case 'approval-requested': return 'awaiting approval'; | |
| case 'approval-responded': return 'approved'; | |
| case 'input-streaming': | |
| case 'input-available': return 'running'; | |
| case 'output-denied': return 'denied'; | |
| case 'output-error': return 'error'; | |
| default: return null; | |
| } | |
| } | |
| function statusColor(state: ToolPartState): string { | |
| switch (state) { | |
| case 'approval-requested': return 'var(--accent-yellow)'; | |
| case 'approval-responded': return 'var(--accent-green)'; | |
| case 'output-available': return 'var(--accent-green)'; | |
| case 'output-error': return 'var(--accent-red)'; | |
| case 'output-denied': return 'var(--muted-text)'; | |
| default: return 'var(--accent-yellow)'; | |
| } | |
| } | |
| // --------------------------------------------------------------------------- | |
| // Inline approval UI (per-tool) | |
| // --------------------------------------------------------------------------- | |
| function InlineApproval({ | |
| toolCallId, | |
| toolName, | |
| input, | |
| scriptLabel, | |
| onResolve, | |
| }: { | |
| toolCallId: string; | |
| toolName: string; | |
| input: unknown; | |
| scriptLabel: string; | |
| onResolve: (toolCallId: string, approved: boolean, feedback?: string) => void; | |
| }) { | |
| const [feedback, setFeedback] = useState(''); | |
| const args = input as Record<string, unknown> | undefined; | |
| const { setPanel, getEditedScript } = useAgentStore(); | |
| const { setRightPanelOpen, setLeftSidebarOpen } = useLayoutStore(); | |
| const hasEditedScript = !!getEditedScript(toolCallId); | |
| const handleScriptClick = useCallback(() => { | |
| if (toolName === 'hf_jobs' && args?.script) { | |
| const scriptContent = getEditedScript(toolCallId) || String(args.script); | |
| setPanel( | |
| { title: scriptLabel, script: { content: scriptContent, language: 'python' }, parameters: { tool_call_id: toolCallId } }, | |
| 'script', | |
| true, | |
| ); | |
| setRightPanelOpen(true); | |
| setLeftSidebarOpen(false); | |
| } | |
| }, [toolCallId, toolName, args, scriptLabel, setPanel, getEditedScript, setRightPanelOpen, setLeftSidebarOpen]); | |
| return ( | |
| <Box sx={{ px: 1.5, py: 1.5, borderTop: '1px solid var(--tool-border)' }}> | |
| {toolName === 'sandbox_create' && args && (() => { | |
| const hw = String(args.hardware || 'cpu-basic'); | |
| const cost = costLabel(hw); | |
| return ( | |
| <Box sx={{ mb: 1.5 }}> | |
| <Typography variant="body2" sx={{ color: 'var(--muted-text)', fontSize: '0.75rem', mb: 0.5 }}> | |
| Create a remote dev environment on{' '} | |
| <Box component="span" sx={{ fontWeight: 500, color: 'var(--text)' }}> | |
| {hw} | |
| </Box> | |
| {cost && ( | |
| <Box component="span" sx={{ color: cost === 'free' ? 'var(--accent-green)' : 'var(--accent-yellow)', fontWeight: 500 }}> | |
| {' '}({cost}) | |
| </Box> | |
| )} | |
| {!!args.private && ( | |
| <Box component="span" sx={{ color: 'var(--muted-text)' }}>{' (private)'}</Box> | |
| )} | |
| </Typography> | |
| <Typography variant="body2" sx={{ color: 'var(--muted-text)', fontSize: '0.7rem', opacity: 0.7 }}> | |
| Creates a temporary HF Space to develop and test scripts before running jobs. Takes 1-2 min to start. | |
| </Typography> | |
| </Box> | |
| ); | |
| })()} | |
| {toolName === 'hf_jobs' && args && (() => { | |
| const hw = String(args.hardware_flavor || 'cpu-basic'); | |
| const cost = costLabel(hw); | |
| return ( | |
| <Box sx={{ mb: 1.5 }}> | |
| <Typography variant="body2" sx={{ color: 'var(--muted-text)', fontSize: '0.75rem', mb: 1 }}> | |
| Execute <Box component="span" sx={{ color: 'var(--accent-yellow)', fontWeight: 500 }}>{scriptLabel.replace('Script', 'Job')}</Box> on{' '} | |
| <Box component="span" sx={{ fontWeight: 500, color: 'var(--text)' }}> | |
| {hw} | |
| </Box> | |
| {cost && ( | |
| <Box component="span" sx={{ color: cost === 'free' ? 'var(--accent-green)' : 'var(--accent-yellow)', fontWeight: 500 }}> | |
| {' '}({cost}) | |
| </Box> | |
| )} | |
| {!!args.timeout && ( | |
| <> for up to <Box component="span" sx={{ fontWeight: 500, color: 'var(--text)' }}> | |
| {String(args.timeout)} | |
| </Box></> | |
| )} | |
| </Typography> | |
| {typeof args.script === 'string' && args.script && ( | |
| <Box | |
| onClick={handleScriptClick} | |
| sx={{ | |
| mt: 0.5, | |
| p: 1.5, | |
| bgcolor: 'var(--code-panel-bg)', | |
| border: '1px solid var(--tool-border)', | |
| borderRadius: '8px', | |
| cursor: 'pointer', | |
| transition: 'border-color 0.15s ease', | |
| '&:hover': { borderColor: 'var(--accent-yellow)' }, | |
| }} | |
| > | |
| <Box | |
| component="pre" | |
| sx={{ | |
| m: 0, | |
| fontFamily: '"JetBrains Mono", ui-monospace, SFMono-Regular, monospace', | |
| fontSize: '0.7rem', | |
| lineHeight: 1.5, | |
| color: 'var(--text)', | |
| overflow: 'hidden', | |
| display: '-webkit-box', | |
| WebkitLineClamp: 3, | |
| WebkitBoxOrient: 'vertical', | |
| whiteSpace: 'pre-wrap', | |
| wordBreak: 'break-all', | |
| }} | |
| > | |
| {String(args.script).trim()} | |
| </Box> | |
| <Typography | |
| variant="caption" | |
| sx={{ | |
| display: 'flex', | |
| alignItems: 'center', | |
| gap: 0.5, | |
| mt: 1, | |
| fontSize: '0.65rem', | |
| color: 'var(--muted-text)', | |
| '&:hover': { color: 'var(--accent-yellow)' }, | |
| }} | |
| > | |
| Click to view & edit | |
| </Typography> | |
| </Box> | |
| )} | |
| </Box> | |
| ); | |
| })()} | |
| <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: 'var(--hover-bg)', | |
| fontFamily: 'inherit', | |
| fontSize: '0.8rem', | |
| '& fieldset': { borderColor: 'var(--tool-border)' }, | |
| '&:hover fieldset': { borderColor: 'var(--border-hover)' }, | |
| '&.Mui-focused fieldset': { borderColor: 'var(--accent-yellow)' }, | |
| }, | |
| '& .MuiOutlinedInput-input': { | |
| color: 'var(--text)', | |
| '&::placeholder': { color: 'var(--muted-text)', opacity: 0.7 }, | |
| }, | |
| }} | |
| /> | |
| <IconButton | |
| onClick={() => onResolve(toolCallId, false, feedback || 'Rejected by user')} | |
| disabled={!feedback} | |
| size="small" | |
| sx={{ | |
| color: 'var(--accent-red)', | |
| border: '1px solid var(--tool-border)', | |
| borderRadius: '6px', | |
| '&:hover': { bgcolor: 'rgba(224,90,79,0.1)', borderColor: 'var(--accent-red)' }, | |
| '&.Mui-disabled': { color: 'var(--muted-text)', opacity: 0.3 }, | |
| }} | |
| > | |
| <SendIcon sx={{ fontSize: 14 }} /> | |
| </IconButton> | |
| </Box> | |
| <Box sx={{ display: 'flex', gap: 1 }}> | |
| <Button | |
| size="small" | |
| onClick={() => onResolve(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(toolCallId, true)} | |
| sx={{ | |
| flex: 1, | |
| textTransform: 'none', | |
| border: hasEditedScript ? '1px solid var(--accent-green)' : '1px solid rgba(255,255,255,0.05)', | |
| color: 'var(--accent-green)', | |
| fontSize: '0.75rem', | |
| py: 0.75, | |
| borderRadius: '8px', | |
| bgcolor: hasEditedScript ? 'rgba(47,204,113,0.08)' : 'transparent', | |
| '&:hover': { bgcolor: 'rgba(47,204,113,0.05)', borderColor: 'var(--accent-green)' }, | |
| }} | |
| > | |
| {hasEditedScript ? 'Approve (edited)' : 'Approve'} | |
| </Button> | |
| </Box> | |
| </Box> | |
| ); | |
| } | |
| // --------------------------------------------------------------------------- | |
| // Main component | |
| // --------------------------------------------------------------------------- | |
| export default function ToolCallGroup({ tools, approveTools }: ToolCallGroupProps) { | |
| const { setPanel, lockPanel, getJobUrl, getEditedScript, setJobStatus, getJobStatus, setToolError, getToolError } = useAgentStore(); | |
| const researchSteps = useAgentStore(s => { | |
| const activeId = s.activeSessionId; | |
| return activeId ? (s.sessionStates[activeId]?.researchSteps) : undefined; | |
| }) ?? EMPTY_STEPS; | |
| const { setRightPanelOpen, setLeftSidebarOpen } = useLayoutStore(); | |
| // ── Batch approval state ────────────────────────────────────────── | |
| const pendingTools = useMemo( | |
| () => tools.filter(t => t.state === 'approval-requested'), | |
| [tools], | |
| ); | |
| const [decisions, setDecisions] = useState<Record<string, { approved: boolean; feedback?: string }>>({}); | |
| const [isSubmitting, setIsSubmitting] = useState(false); | |
| const submittingRef = useRef(false); | |
| // Track which toolCallIds we've already submitted so we can detect new approval rounds | |
| const submittedIdsRef = useRef<Set<string>>(new Set()); | |
| // Reset submission state when new (unseen) pending tools arrive — e.g. second approval round | |
| useEffect(() => { | |
| if (!isSubmitting || pendingTools.length === 0) return; | |
| const hasNewPending = pendingTools.some(t => !submittedIdsRef.current.has(t.toolCallId)); | |
| if (hasNewPending) { | |
| submittingRef.current = false; | |
| setIsSubmitting(false); | |
| setDecisions({}); | |
| } | |
| }, [pendingTools, isSubmitting]); | |
| // Clean up stale decisions for tools that are no longer pending | |
| useEffect(() => { | |
| const pendingIds = new Set(pendingTools.map(t => t.toolCallId)); | |
| const decisionIds = Object.keys(decisions); | |
| const hasStale = decisionIds.some(id => !pendingIds.has(id)); | |
| if (hasStale) { | |
| setDecisions(prev => { | |
| const cleaned = { ...prev }; | |
| for (const id of decisionIds) { | |
| if (!pendingIds.has(id)) delete cleaned[id]; | |
| } | |
| return cleaned; | |
| }); | |
| } | |
| }, [pendingTools, decisions]); | |
| // Persist error states when tools error | |
| useEffect(() => { | |
| for (const tool of tools) { | |
| const currentlyHasError = tool.state === 'output-error'; | |
| const persistedError = getToolError(tool.toolCallId); | |
| // Persist error state if we detect it and haven't already | |
| if (currentlyHasError && !persistedError) { | |
| setToolError(tool.toolCallId, true); | |
| } | |
| } | |
| }, [tools, setToolError, getToolError]); | |
| const { scriptLabelMap, toolDisplayMap } = useMemo(() => { | |
| const hfJobs = tools.filter(t => t.toolName === 'hf_jobs' && (t.input as Record<string, unknown>)?.script); | |
| const scriptMap: Record<string, string> = {}; | |
| const displayMap: Record<string, string> = {}; | |
| for (let i = 0; i < hfJobs.length; i++) { | |
| const id = hfJobs[i].toolCallId; | |
| if (hfJobs.length > 1) { | |
| scriptMap[id] = `Script ${i + 1}`; | |
| displayMap[id] = `hf_jobs #${i + 1}`; | |
| } else { | |
| scriptMap[id] = 'Script'; | |
| displayMap[id] = 'hf_jobs'; | |
| } | |
| } | |
| // Pretty name for research tool | |
| for (const t of tools) { | |
| if (t.toolName === 'research') { | |
| displayMap[t.toolCallId] = 'research'; | |
| } | |
| } | |
| return { scriptLabelMap: scriptMap, toolDisplayMap: displayMap }; | |
| }, [tools]); | |
| // ── Send all decisions as a single batch ────────────────────────── | |
| const sendBatch = useCallback( | |
| async (batch: Record<string, { approved: boolean; feedback?: string }>) => { | |
| if (submittingRef.current) return; | |
| submittingRef.current = true; | |
| setIsSubmitting(true); | |
| const approvals = Object.entries(batch).map(([toolCallId, d]) => { | |
| const editedScript = d.approved ? (getEditedScript(toolCallId) ?? null) : null; | |
| if (editedScript) { | |
| logger.log(`Sending edited script for ${toolCallId} (${editedScript.length} chars)`); | |
| } | |
| return { | |
| tool_call_id: toolCallId, | |
| approved: d.approved, | |
| feedback: d.approved ? null : (d.feedback || 'Rejected by user'), | |
| edited_script: editedScript, | |
| }; | |
| }); | |
| const ok = await approveTools(approvals); | |
| if (ok) { | |
| // Track which tool IDs were submitted so we can detect new approval rounds | |
| for (const a of approvals) submittedIdsRef.current.add(a.tool_call_id); | |
| lockPanel(); | |
| } else { | |
| logger.error('Batch approval failed'); | |
| submittingRef.current = false; | |
| setIsSubmitting(false); | |
| } | |
| }, | |
| [approveTools, lockPanel, getEditedScript], | |
| ); | |
| const handleApproveAll = useCallback(() => { | |
| const batch: Record<string, { approved: boolean }> = {}; | |
| for (const t of pendingTools) batch[t.toolCallId] = { approved: true }; | |
| sendBatch(batch); | |
| }, [pendingTools, sendBatch]); | |
| const handleRejectAll = useCallback(() => { | |
| const batch: Record<string, { approved: boolean }> = {}; | |
| for (const t of pendingTools) batch[t.toolCallId] = { approved: false }; | |
| sendBatch(batch); | |
| }, [pendingTools, sendBatch]); | |
| const handleIndividualDecision = useCallback( | |
| (toolCallId: string, approved: boolean, feedback?: string) => { | |
| setDecisions(prev => { | |
| const next = { ...prev, [toolCallId]: { approved, feedback } }; | |
| if (pendingTools.every(t => next[t.toolCallId])) { | |
| queueMicrotask(() => sendBatch(next)); | |
| } | |
| return next; | |
| }); | |
| }, | |
| [pendingTools, sendBatch], | |
| ); | |
| const undoDecision = useCallback((toolCallId: string) => { | |
| setDecisions(prev => { | |
| const next = { ...prev }; | |
| delete next[toolCallId]; | |
| return next; | |
| }); | |
| }, []); | |
| // ── Panel click handler ─────────────────────────────────────────── | |
| const handleClick = useCallback( | |
| (tool: DynamicToolPart) => { | |
| const args = tool.input as Record<string, unknown> | undefined; | |
| const displayName = toolDisplayMap[tool.toolCallId] || tool.toolName; | |
| if (tool.toolName === 'hf_jobs' && args?.script) { | |
| const jobOutput = tool.output ?? (tool.state === 'output-error' ? (tool as Record<string, unknown>).errorText : undefined); | |
| const hasOutput = (tool.state === 'output-available' || tool.state === 'output-error') && jobOutput; | |
| const scriptContent = getEditedScript(tool.toolCallId) || String(args.script); | |
| setPanel( | |
| { | |
| title: displayName, | |
| script: { content: scriptContent, language: 'python' }, | |
| ...(hasOutput ? { output: { content: String(jobOutput), language: 'markdown' } } : {}), | |
| parameters: { tool_call_id: tool.toolCallId }, | |
| }, | |
| hasOutput ? 'output' : 'script', | |
| ); | |
| setRightPanelOpen(true); | |
| setLeftSidebarOpen(false); | |
| return; | |
| } | |
| const inputSection = args ? { content: JSON.stringify(args, null, 2), language: 'json' } : undefined; | |
| const outputText = tool.output ?? (tool.state === 'output-error' ? (tool as Record<string, unknown>).errorText : undefined); | |
| if ((tool.state === 'output-available' || tool.state === 'output-error') && outputText) { | |
| let language = 'text'; | |
| const content = String(outputText); | |
| if (content.trim().startsWith('{') || content.trim().startsWith('[')) language = 'json'; | |
| else if (content.includes('```')) language = 'markdown'; | |
| setPanel({ title: displayName, output: { content, language }, input: inputSection }, 'output'); | |
| setRightPanelOpen(true); | |
| } else if (tool.state === 'output-error') { | |
| const content = `Tool \`${tool.toolName}\` returned an error with no output message.`; | |
| setPanel({ title: displayName, output: { content, language: 'markdown' }, input: inputSection }, 'output'); | |
| setRightPanelOpen(true); | |
| } else if (args) { | |
| setPanel({ title: displayName, output: { content: JSON.stringify(args, null, 2), language: 'json' }, input: inputSection }, 'output'); | |
| setRightPanelOpen(true); | |
| } | |
| }, | |
| [toolDisplayMap, setPanel, getEditedScript, setRightPanelOpen, setLeftSidebarOpen], | |
| ); | |
| // ── Parse hf_jobs metadata from output ──────────────────────────── | |
| function parseJobMeta(output: unknown): { jobUrl?: string; jobStatus?: string } { | |
| if (typeof output !== 'string') return {}; | |
| const urlMatch = output.match(/\*\*View at:\*\*\s*(https:\/\/[^\s\n]+)/); | |
| const statusMatch = output.match(/\*\*Final Status:\*\*\s*([^\n]+)/); | |
| return { | |
| jobUrl: urlMatch?.[1], | |
| jobStatus: statusMatch?.[1]?.trim(), | |
| }; | |
| } | |
| // ── Render ──────────────────────────────────────────────────────── | |
| const decidedCount = pendingTools.filter(t => decisions[t.toolCallId]).length; | |
| return ( | |
| <Box | |
| sx={{ | |
| borderRadius: 2, | |
| border: '1px solid var(--tool-border)', | |
| bgcolor: 'var(--tool-bg)', | |
| overflow: 'hidden', | |
| my: 1, | |
| }} | |
| > | |
| {/* Batch approval header — hidden once user starts deciding individually */} | |
| {pendingTools.length > 1 && !isSubmitting && decidedCount === 0 && ( | |
| <Box | |
| sx={{ | |
| display: 'flex', | |
| alignItems: 'center', | |
| gap: 1, | |
| px: 1.5, | |
| py: 1, | |
| borderBottom: '1px solid var(--tool-border)', | |
| }} | |
| > | |
| <Typography | |
| variant="body2" | |
| sx={{ fontSize: '0.72rem', color: 'var(--muted-text)', mr: 'auto', whiteSpace: 'nowrap' }} | |
| > | |
| {`${pendingTools.length} tool${pendingTools.length > 1 ? 's' : ''} pending`} | |
| </Typography> | |
| <Button | |
| size="small" | |
| onClick={handleRejectAll} | |
| sx={{ | |
| textTransform: 'none', | |
| color: 'var(--accent-red)', | |
| border: '1px solid rgba(255,255,255,0.05)', | |
| fontSize: '0.72rem', | |
| py: 0.5, | |
| px: 1.5, | |
| borderRadius: '8px', | |
| '&:hover': { bgcolor: 'rgba(224,90,79,0.05)', borderColor: 'var(--accent-red)' }, | |
| }} | |
| > | |
| Reject all | |
| </Button> | |
| <Button | |
| size="small" | |
| onClick={handleApproveAll} | |
| sx={{ | |
| textTransform: 'none', | |
| color: 'var(--accent-green)', | |
| border: '1px solid var(--accent-green)', | |
| fontSize: '0.72rem', | |
| fontWeight: 600, | |
| py: 0.5, | |
| px: 1.5, | |
| borderRadius: '8px', | |
| '&:hover': { bgcolor: 'rgba(47,204,113,0.1)' }, | |
| }} | |
| > | |
| Approve all{pendingTools.length > 1 ? ` (${pendingTools.length})` : ''} | |
| </Button> | |
| </Box> | |
| )} | |
| {/* Tool list */} | |
| <Stack divider={<Box sx={{ borderBottom: '1px solid var(--tool-border)' }} />}> | |
| {tools.map((tool) => { | |
| const state = tool.state; | |
| const isPending = state === 'approval-requested'; | |
| const clickable = | |
| state === 'output-available' || | |
| state === 'output-error' || | |
| !!tool.input; | |
| const localDecision = decisions[tool.toolCallId]; | |
| const cancelled = isCancelledTool(tool); | |
| const currentlyHasError = state === 'output-error'; | |
| const persistedError = getToolError(tool.toolCallId); | |
| // Use persisted error OR current error (persisting happens in useEffect) | |
| const hasError = persistedError || currentlyHasError; | |
| const displayState = isPending && localDecision | |
| ? (localDecision.approved ? 'input-available' : 'output-denied') | |
| : state; | |
| const label = cancelled ? 'cancelled' | |
| : hasError ? 'error' | |
| : statusLabel(displayState as ToolPartState); | |
| // Parse job metadata from hf_jobs output and store | |
| const jobUrlFromStore = tool.toolName === 'hf_jobs' ? getJobUrl(tool.toolCallId) : undefined; | |
| const jobStatusFromStore = tool.toolName === 'hf_jobs' ? getJobStatus(tool.toolCallId) : undefined; | |
| const jobMetaFromOutput = tool.toolName === 'hf_jobs' && (tool.output || (tool as Record<string, unknown>).errorText) | |
| ? parseJobMeta(tool.output ?? (tool as Record<string, unknown>).errorText) | |
| : {}; | |
| // Store job status if we just parsed it and don't have it stored yet | |
| if (tool.toolName === 'hf_jobs' && jobMetaFromOutput.jobStatus && !jobStatusFromStore) { | |
| setJobStatus(tool.toolCallId, jobMetaFromOutput.jobStatus); | |
| } | |
| // Combine job URL and status from store (persisted) with output metadata (freshly parsed) | |
| // Prefer stored values to ensure they persist across renders | |
| const jobMeta = { | |
| jobUrl: jobUrlFromStore || jobMetaFromOutput.jobUrl, | |
| jobStatus: jobStatusFromStore || jobMetaFromOutput.jobStatus, | |
| }; | |
| return ( | |
| <Box key={tool.toolCallId}> | |
| {/* Main tool row */} | |
| <Stack | |
| direction="row" | |
| alignItems="center" | |
| spacing={1} | |
| onClick={() => !isPending && handleClick(tool)} | |
| sx={{ | |
| px: 1.5, | |
| py: 1, | |
| cursor: isPending ? 'default' : clickable ? 'pointer' : 'default', | |
| transition: 'background-color 0.15s', | |
| '&:hover': clickable && !isPending ? { bgcolor: 'var(--hover-bg)' } : {}, | |
| }} | |
| > | |
| <StatusIcon | |
| cancelled={cancelled} | |
| state={ | |
| hasError | |
| ? 'output-error' | |
| : ((tool.toolName === 'hf_jobs' && jobMeta.jobStatus && ['ERROR', 'FAILED', 'CANCELLED'].includes(jobMeta.jobStatus)) | |
| ? 'output-error' | |
| : displayState as ToolPartState) | |
| } | |
| /> | |
| <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', | |
| }} | |
| > | |
| {toolDisplayMap[tool.toolCallId] || tool.toolName} | |
| </Typography> | |
| {/* Status chip (non hf_jobs, or hf_jobs without final status) */} | |
| {label && !(tool.toolName === 'hf_jobs' && jobMeta.jobStatus) && ( | |
| <Chip | |
| label={label} | |
| size="small" | |
| sx={{ | |
| height: 20, | |
| fontSize: '0.65rem', | |
| fontWeight: 600, | |
| bgcolor: cancelled ? 'rgba(255,255,255,0.05)' | |
| : hasError ? 'rgba(224,90,79,0.12)' | |
| : displayState === 'output-denied' ? 'rgba(255,255,255,0.05)' | |
| : 'var(--accent-yellow-weak)', | |
| color: cancelled ? 'var(--muted-text)' | |
| : hasError ? 'var(--accent-red)' | |
| : statusColor(displayState as ToolPartState), | |
| letterSpacing: '0.03em', | |
| }} | |
| /> | |
| )} | |
| {/* HF Jobs: final status chip from job metadata */} | |
| {tool.toolName === 'hf_jobs' && jobMeta.jobStatus && ( | |
| <Chip | |
| label={jobMeta.jobStatus} | |
| size="small" | |
| sx={{ | |
| height: 20, | |
| fontSize: '0.65rem', | |
| fontWeight: 600, | |
| bgcolor: jobMeta.jobStatus === 'COMPLETED' | |
| ? 'rgba(47,204,113,0.12)' | |
| : ['ERROR', 'FAILED', 'CANCELLED'].includes(jobMeta.jobStatus!) | |
| ? 'rgba(224,90,79,0.12)' | |
| : 'rgba(255,193,59,0.12)', | |
| color: jobMeta.jobStatus === 'COMPLETED' | |
| ? 'var(--accent-green)' | |
| : ['ERROR', 'FAILED', 'CANCELLED'].includes(jobMeta.jobStatus!) | |
| ? 'var(--accent-red)' | |
| : 'var(--accent-yellow)', | |
| letterSpacing: '0.03em', | |
| }} | |
| /> | |
| )} | |
| {/* View on HF link — single place, shown whenever URL is available */} | |
| {tool.toolName === 'hf_jobs' && jobMeta.jobUrl && ( | |
| <Link | |
| href={jobMeta.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', | |
| ml: 0.5, | |
| '&:hover': { textDecoration: 'underline' }, | |
| }} | |
| > | |
| <LaunchIcon sx={{ fontSize: 12 }} /> | |
| View on HF | |
| </Link> | |
| )} | |
| {clickable && !isPending && ( | |
| <OpenInNewIcon sx={{ fontSize: 14, color: 'var(--muted-text)', opacity: 0.6 }} /> | |
| )} | |
| </Stack> | |
| {/* Research sub-agent steps */} | |
| {tool.toolName === 'research' && researchSteps.length > 0 && ( | |
| <ResearchSteps | |
| steps={researchSteps} | |
| isRunning={state === 'input-streaming' || state === 'input-available'} | |
| /> | |
| )} | |
| {/* Per-tool approval: undecided */} | |
| {isPending && !localDecision && !isSubmitting && ( | |
| <InlineApproval | |
| toolCallId={tool.toolCallId} | |
| toolName={tool.toolName} | |
| input={tool.input} | |
| scriptLabel={scriptLabelMap[tool.toolCallId] || 'Script'} | |
| onResolve={handleIndividualDecision} | |
| /> | |
| )} | |
| {/* Per-tool approval: locally decided (undo available) */} | |
| {isPending && localDecision && !isSubmitting && ( | |
| <Box | |
| sx={{ | |
| display: 'flex', | |
| alignItems: 'center', | |
| justifyContent: 'space-between', | |
| px: 1.5, | |
| py: 0.75, | |
| borderTop: '1px solid var(--tool-border)', | |
| }} | |
| > | |
| <Typography variant="body2" sx={{ fontSize: '0.72rem', color: 'var(--muted-text)' }}> | |
| {localDecision.approved | |
| ? 'Marked for approval' | |
| : `Marked for rejection${localDecision.feedback ? `: ${localDecision.feedback}` : ''}`} | |
| </Typography> | |
| <Button | |
| size="small" | |
| onClick={() => undoDecision(tool.toolCallId)} | |
| sx={{ | |
| textTransform: 'none', | |
| fontSize: '0.7rem', | |
| color: 'var(--muted-text)', | |
| minWidth: 'auto', | |
| px: 1, | |
| '&:hover': { color: 'var(--text)' }, | |
| }} | |
| > | |
| Undo | |
| </Button> | |
| </Box> | |
| )} | |
| </Box> | |
| ); | |
| })} | |
| </Stack> | |
| </Box> | |
| ); | |
| } | |