akseljoonas's picture
akseljoonas HF Staff
removed avatars and made ui improvements
fe018ce
raw
history blame
18.8 kB
import { useRef, useEffect, useMemo, useState, useCallback } from 'react';
import { Box, Stack, Typography, IconButton, Button, Tooltip } from '@mui/material';
import CloseIcon from '@mui/icons-material/Close';
import RadioButtonUncheckedIcon from '@mui/icons-material/RadioButtonUnchecked';
import CheckCircleIcon from '@mui/icons-material/CheckCircle';
import PlayCircleOutlineIcon from '@mui/icons-material/PlayCircleOutline';
import CodeIcon from '@mui/icons-material/Code';
import TerminalIcon from '@mui/icons-material/Terminal';
import ArticleIcon from '@mui/icons-material/Article';
import EditIcon from '@mui/icons-material/Edit';
import UndoIcon from '@mui/icons-material/Undo';
import ContentCopyIcon from '@mui/icons-material/ContentCopy';
import CheckIcon from '@mui/icons-material/Check';
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
import { vscDarkPlus, vs } from 'react-syntax-highlighter/dist/esm/styles/prism';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import { useAgentStore } from '@/store/agentStore';
import { useLayoutStore } from '@/store/layoutStore';
import { processLogs } from '@/utils/logProcessor';
// ── Helpers ──────────────────────────────────────────────────────
function tabIcon(id: string, language?: string) {
if (id === 'script' || language === 'python') return <CodeIcon sx={{ fontSize: 14 }} />;
if (id === 'tool_output' || language === 'markdown' || language === 'json')
return <ArticleIcon sx={{ fontSize: 14 }} />;
return <TerminalIcon sx={{ fontSize: 14 }} />;
}
function PlanStatusIcon({ status }: { status: string }) {
if (status === 'completed') return <CheckCircleIcon sx={{ fontSize: 16, color: 'var(--accent-green)' }} />;
if (status === 'in_progress') return <PlayCircleOutlineIcon sx={{ fontSize: 16, color: 'var(--accent-yellow)' }} />;
return <RadioButtonUncheckedIcon sx={{ fontSize: 16, color: 'var(--muted-text)', opacity: 0.5 }} />;
}
// ── Markdown styles (adapts via CSS vars) ────────────────────────
const markdownSx = {
color: 'var(--text)',
fontSize: '13px',
lineHeight: 1.6,
'& p': { m: 0, mb: 1.5, '&:last-child': { mb: 0 } },
'& pre': {
bgcolor: 'var(--code-bg)',
p: 1.5,
borderRadius: 1,
overflow: 'auto',
fontSize: '12px',
border: '1px solid var(--tool-border)',
},
'& code': {
bgcolor: 'var(--hover-bg)',
px: 0.5,
py: 0.25,
borderRadius: 0.5,
fontSize: '12px',
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, monospace',
},
'& pre code': { bgcolor: 'transparent', p: 0 },
'& a': {
color: 'var(--accent-yellow)',
textDecoration: 'none',
'&:hover': { textDecoration: 'underline' },
},
'& ul, & ol': { pl: 2.5, my: 1 },
'& li': { mb: 0.5 },
'& table': {
borderCollapse: 'collapse',
width: '100%',
my: 2,
fontSize: '12px',
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, monospace',
},
'& th': {
borderBottom: '2px solid var(--border-hover)',
textAlign: 'left',
p: 1,
fontWeight: 600,
},
'& td': {
borderBottom: '1px solid var(--tool-border)',
p: 1,
},
'& h1, & h2, & h3, & h4': { mt: 2, mb: 1, fontWeight: 600 },
'& h1': { fontSize: '1.25rem' },
'& h2': { fontSize: '1.1rem' },
'& h3': { fontSize: '1rem' },
'& blockquote': {
borderLeft: '3px solid var(--accent-yellow)',
pl: 2,
ml: 0,
color: 'var(--muted-text)',
},
} as const;
// ── Component ────────────────────────────────────────────────────
export default function CodePanel() {
const { panelContent, panelTabs, activePanelTab, setActivePanelTab, removePanelTab, plan, updatePanelTabContent, setEditedScript } =
useAgentStore();
const { setRightPanelOpen, themeMode } = useLayoutStore();
const scrollRef = useRef<HTMLDivElement>(null);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const [isEditing, setIsEditing] = useState(false);
const [editedContent, setEditedContent] = useState('');
const [originalContent, setOriginalContent] = useState('');
const [copied, setCopied] = useState(false);
const activeTab = panelTabs.find((t) => t.id === activePanelTab);
const currentContent = activeTab || panelContent;
const hasTabs = panelTabs.length > 0;
const isDark = themeMode === 'dark';
const syntaxTheme = isDark ? vscDarkPlus : vs;
// Check if this is an editable script tab
const isEditableScript = activeTab?.id === 'script' && activeTab?.language === 'python';
const hasUnsavedChanges = isEditing && editedContent !== originalContent;
// Sync edited content when switching tabs or content changes
useEffect(() => {
if (currentContent?.content && isEditableScript) {
setOriginalContent(currentContent.content);
if (!isEditing) {
setEditedContent(currentContent.content);
}
}
}, [currentContent?.content, isEditableScript, isEditing]);
// Exit editing when switching away from script tab
useEffect(() => {
if (!isEditableScript && isEditing) {
setIsEditing(false);
}
}, [isEditableScript, isEditing]);
const handleStartEdit = useCallback(() => {
if (currentContent?.content) {
setEditedContent(currentContent.content);
setOriginalContent(currentContent.content);
setIsEditing(true);
setTimeout(() => textareaRef.current?.focus(), 0);
}
}, [currentContent?.content]);
const handleCancelEdit = useCallback(() => {
setEditedContent(originalContent);
setIsEditing(false);
}, [originalContent]);
const handleSaveEdit = useCallback(() => {
if (activeTab && editedContent !== originalContent) {
updatePanelTabContent(activeTab.id, editedContent);
const toolCallId = activeTab.parameters?.tool_call_id as string | undefined;
if (toolCallId) {
setEditedScript(toolCallId, editedContent);
}
setOriginalContent(editedContent);
}
setIsEditing(false);
}, [activeTab, editedContent, originalContent, updatePanelTabContent, setEditedScript]);
const handleCopy = useCallback(async () => {
const contentToCopy = isEditing ? editedContent : (currentContent?.content || '');
if (contentToCopy) {
try {
await navigator.clipboard.writeText(contentToCopy);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch (err) {
console.error('Failed to copy:', err);
}
}
}, [isEditing, editedContent, currentContent?.content]);
const displayContent = useMemo(() => {
if (!currentContent?.content) return '';
if (!currentContent.language || currentContent.language === 'text') {
return processLogs(currentContent.content);
}
return currentContent.content;
}, [currentContent?.content, currentContent?.language]);
useEffect(() => {
if (scrollRef.current && activePanelTab === 'logs') {
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
}
}, [displayContent, activePanelTab]);
// ── Syntax-highlighted code block (DRY) ────────────────────────
const renderSyntaxBlock = (language: string) => (
<SyntaxHighlighter
language={language}
style={syntaxTheme}
customStyle={{
margin: 0,
padding: 0,
background: 'transparent',
fontSize: '13px',
fontFamily: 'inherit',
}}
wrapLines
wrapLongLines
>
{displayContent}
</SyntaxHighlighter>
);
// ── Content renderer ───────────────────────────────────────────
const renderContent = () => {
if (!currentContent?.content) {
return (
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'center', height: '100%', opacity: 0.5 }}>
<Typography variant="caption">NO CONTENT TO DISPLAY</Typography>
</Box>
);
}
// Editing mode: show textarea
if (isEditing && isEditableScript) {
return (
<textarea
ref={textareaRef}
value={editedContent}
onChange={(e) => setEditedContent(e.target.value)}
spellCheck={false}
style={{
width: '100%',
height: '100%',
background: 'transparent',
border: 'none',
outline: 'none',
resize: 'none',
color: 'var(--text)',
fontFamily: '"JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, Monaco, monospace',
fontSize: '13px',
lineHeight: 1.55,
}}
/>
);
}
if (currentContent.language === 'python') return renderSyntaxBlock('python');
if (currentContent.language === 'json') return renderSyntaxBlock('json');
if (currentContent.language === 'markdown') {
return (
<Box sx={markdownSx}>
<ReactMarkdown remarkPlugins={[remarkGfm]}>{displayContent}</ReactMarkdown>
</Box>
);
}
// Plain text / logs
return (
<Box
component="pre"
sx={{ m: 0, fontFamily: 'inherit', color: 'var(--text)', whiteSpace: 'pre-wrap', wordBreak: 'break-all' }}
>
<code>{displayContent}</code>
</Box>
);
};
return (
<Box sx={{ height: '100%', display: 'flex', flexDirection: 'column', bgcolor: 'var(--panel)' }}>
{/* ── Header (60 px, aligned with top bar) ────────────────── */}
<Box
sx={{
height: 60,
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
px: 2,
borderBottom: '1px solid var(--border)',
flexShrink: 0,
}}
>
{hasTabs ? (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, flexWrap: 'wrap' }}>
{panelTabs.map((tab) => {
const isActive = activePanelTab === tab.id;
return (
<Box
key={tab.id}
onClick={() => setActivePanelTab(tab.id)}
sx={{
display: 'flex',
alignItems: 'center',
gap: 0.5,
px: 1.5,
py: 0.75,
borderRadius: 1,
cursor: 'pointer',
fontSize: '0.7rem',
fontWeight: 600,
textTransform: 'uppercase',
letterSpacing: '0.05em',
color: isActive ? 'var(--text)' : 'var(--muted-text)',
bgcolor: isActive ? 'var(--tab-active-bg)' : 'transparent',
border: '1px solid',
borderColor: isActive ? 'var(--tab-active-border)' : 'transparent',
transition: 'all 0.15s ease',
'&:hover': { bgcolor: 'var(--tab-hover-bg)' },
}}
>
{tabIcon(tab.id, tab.language)}
<span>{tab.title}</span>
<Box
component="span"
onClick={(e) => {
e.stopPropagation();
removePanelTab(tab.id);
}}
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
ml: 0.5,
width: 16,
height: 16,
borderRadius: '50%',
fontSize: '0.65rem',
opacity: 0.5,
'&:hover': { opacity: 1, bgcolor: 'var(--tab-close-hover)' },
}}
>
βœ•
</Box>
</Box>
);
})}
</Box>
) : (
<Typography
variant="caption"
sx={{ fontWeight: 600, color: 'var(--muted-text)', textTransform: 'uppercase', letterSpacing: '0.05em' }}
>
{currentContent?.title || 'Code Panel'}
</Typography>
)}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
{/* Copy button */}
{currentContent?.content && (
<Tooltip title={copied ? 'Copied!' : 'Copy'} placement="top">
<IconButton
size="small"
onClick={handleCopy}
sx={{
color: copied ? 'var(--accent-green)' : 'var(--muted-text)',
'&:hover': {
color: 'var(--accent-yellow)',
bgcolor: 'var(--hover-bg)',
},
}}
>
{copied ? <CheckIcon sx={{ fontSize: 18 }} /> : <ContentCopyIcon sx={{ fontSize: 18 }} />}
</IconButton>
</Tooltip>
)}
{/* Edit controls for script tab */}
{isEditableScript && !isEditing && (
<Button
size="small"
startIcon={<EditIcon sx={{ fontSize: 14 }} />}
onClick={handleStartEdit}
sx={{
textTransform: 'none',
color: 'var(--muted-text)',
fontSize: '0.75rem',
py: 0.5,
'&:hover': {
color: 'var(--accent-yellow)',
bgcolor: 'var(--hover-bg)',
},
}}
>
Edit
</Button>
)}
{isEditing && (
<>
<Button
size="small"
startIcon={<UndoIcon sx={{ fontSize: 14 }} />}
onClick={handleCancelEdit}
sx={{
textTransform: 'none',
color: 'var(--muted-text)',
fontSize: '0.75rem',
py: 0.5,
'&:hover': {
color: 'var(--accent-red)',
bgcolor: 'var(--hover-bg)',
},
}}
>
Cancel
</Button>
<Button
size="small"
variant="contained"
onClick={handleSaveEdit}
disabled={!hasUnsavedChanges}
sx={{
textTransform: 'none',
fontSize: '0.75rem',
py: 0.5,
bgcolor: hasUnsavedChanges ? 'var(--accent-green)' : 'var(--hover-bg)',
color: hasUnsavedChanges ? '#000' : 'var(--muted-text)',
'&:hover': {
bgcolor: hasUnsavedChanges ? 'var(--accent-green)' : 'var(--hover-bg)',
opacity: 0.9,
},
'&.Mui-disabled': {
bgcolor: 'var(--hover-bg)',
color: 'var(--muted-text)',
opacity: 0.5,
},
}}
>
Save
</Button>
</>
)}
<IconButton size="small" onClick={() => setRightPanelOpen(false)} sx={{ color: 'var(--muted-text)' }}>
<CloseIcon fontSize="small" />
</IconButton>
</Box>
</Box>
{/* ── Main content area ─────────────────────────────────── */}
<Box sx={{ flex: 1, overflow: 'hidden', display: 'flex', flexDirection: 'column' }}>
{!currentContent ? (
<Box sx={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', p: 4 }}>
<Typography variant="body2" color="text.secondary" sx={{ opacity: 0.5 }}>
NO DATA LOADED
</Typography>
</Box>
) : (
<Box sx={{ flex: 1, overflow: 'hidden', p: 2 }}>
<Box
ref={scrollRef}
className="code-panel"
sx={{
bgcolor: 'var(--code-panel-bg)',
borderRadius: 'var(--radius-md)',
p: '18px',
border: '1px solid var(--border)',
fontFamily: '"JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, Monaco, monospace',
fontSize: '13px',
lineHeight: 1.55,
height: '100%',
overflow: 'auto',
}}
>
{renderContent()}
</Box>
</Box>
)}
</Box>
{/* ── Plan display (bottom) ─────────────────────────────── */}
{plan && plan.length > 0 && (
<Box
sx={{
borderTop: '1px solid var(--border)',
bgcolor: 'var(--plan-bg)',
maxHeight: '30%',
display: 'flex',
flexDirection: 'column',
}}
>
<Box
sx={{
p: 1.5,
borderBottom: '1px solid var(--border)',
display: 'flex',
alignItems: 'center',
gap: 1,
}}
>
<Typography
variant="caption"
sx={{ fontWeight: 600, color: 'var(--muted-text)', textTransform: 'uppercase', letterSpacing: '0.05em' }}
>
CURRENT PLAN
</Typography>
</Box>
<Stack spacing={1} sx={{ p: 2, overflow: 'auto' }}>
{plan.map((item) => (
<Stack key={item.id} direction="row" alignItems="flex-start" spacing={1.5}>
<Box sx={{ mt: 0.2 }}>
<PlanStatusIcon status={item.status} />
</Box>
<Typography
variant="body2"
sx={{
fontSize: '13px',
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, monospace',
color: item.status === 'completed' ? 'var(--muted-text)' : 'var(--text)',
textDecoration: item.status === 'completed' ? 'line-through' : 'none',
opacity: item.status === 'pending' ? 0.7 : 1,
}}
>
{item.content}
</Typography>
</Stack>
))}
</Stack>
</Box>
)}
</Box>
);
}