Dashboard: - Enhanced terminal components (ClaudeTerminal, GlobalClaudeTerminal) - Improved MarkdownRenderer for better documentation display - Updated convergence plots (ConvergencePlot, PlotlyConvergencePlot) - Refined Home, Analysis, Dashboard, Setup, Results pages - Added StudyContext improvements - Updated vite.config for better dev experience Configuration: - Updated CLAUDE.md with latest instructions - Enhanced launch_dashboard.py - Updated config.py settings 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
417 lines
14 KiB
TypeScript
417 lines
14 KiB
TypeScript
import React, { useEffect, useRef, useState, useCallback } from 'react';
|
|
import { Terminal } from 'xterm';
|
|
import { FitAddon } from '@xterm/addon-fit';
|
|
import { WebLinksAddon } from '@xterm/addon-web-links';
|
|
import 'xterm/css/xterm.css';
|
|
import {
|
|
Terminal as TerminalIcon,
|
|
Maximize2,
|
|
Minimize2,
|
|
X,
|
|
RefreshCw,
|
|
AlertCircle,
|
|
FolderOpen,
|
|
Plus
|
|
} from 'lucide-react';
|
|
import { useStudy } from '../context/StudyContext';
|
|
import { useClaudeTerminal } from '../context/ClaudeTerminalContext';
|
|
|
|
interface ClaudeTerminalProps {
|
|
isExpanded?: boolean;
|
|
onToggleExpand?: () => void;
|
|
onClose?: () => void;
|
|
}
|
|
|
|
export const ClaudeTerminal: React.FC<ClaudeTerminalProps> = ({
|
|
isExpanded = false,
|
|
onToggleExpand,
|
|
onClose
|
|
}) => {
|
|
const { selectedStudy } = useStudy();
|
|
const { setIsConnected: setGlobalConnected } = useClaudeTerminal();
|
|
const terminalRef = useRef<HTMLDivElement>(null);
|
|
const xtermRef = useRef<Terminal | null>(null);
|
|
const fitAddonRef = useRef<FitAddon | null>(null);
|
|
const wsRef = useRef<WebSocket | null>(null);
|
|
const [isConnected, setIsConnectedLocal] = useState(false);
|
|
const [isConnecting, setIsConnecting] = useState(false);
|
|
const [_error, setError] = useState<string | null>(null);
|
|
const [cliAvailable, setCliAvailable] = useState<boolean | null>(null);
|
|
const [contextSet, setContextSet] = useState(false);
|
|
const [settingContext, setSettingContext] = useState(false);
|
|
|
|
// Sync local connection state to global context
|
|
const setIsConnected = useCallback((connected: boolean) => {
|
|
setIsConnectedLocal(connected);
|
|
setGlobalConnected(connected);
|
|
}, [setGlobalConnected]);
|
|
|
|
// Check CLI availability
|
|
useEffect(() => {
|
|
fetch('/api/terminal/status')
|
|
.then(res => res.json())
|
|
.then(data => {
|
|
setCliAvailable(data.available);
|
|
if (!data.available) {
|
|
setError(data.message);
|
|
}
|
|
})
|
|
.catch(() => {
|
|
setCliAvailable(false);
|
|
setError('Could not check Claude Code CLI status');
|
|
});
|
|
}, []);
|
|
|
|
// Initialize terminal
|
|
useEffect(() => {
|
|
if (!terminalRef.current || xtermRef.current) return;
|
|
|
|
const term = new Terminal({
|
|
cursorBlink: true,
|
|
fontSize: 13,
|
|
fontFamily: '"JetBrains Mono", "Fira Code", Consolas, monospace',
|
|
theme: {
|
|
background: '#0f172a',
|
|
foreground: '#e2e8f0',
|
|
cursor: '#60a5fa',
|
|
cursorAccent: '#0f172a',
|
|
selectionBackground: '#334155',
|
|
black: '#1e293b',
|
|
red: '#ef4444',
|
|
green: '#22c55e',
|
|
yellow: '#eab308',
|
|
blue: '#3b82f6',
|
|
magenta: '#a855f7',
|
|
cyan: '#06b6d4',
|
|
white: '#f1f5f9',
|
|
brightBlack: '#475569',
|
|
brightRed: '#f87171',
|
|
brightGreen: '#4ade80',
|
|
brightYellow: '#facc15',
|
|
brightBlue: '#60a5fa',
|
|
brightMagenta: '#c084fc',
|
|
brightCyan: '#22d3ee',
|
|
brightWhite: '#f8fafc',
|
|
},
|
|
allowProposedApi: true,
|
|
});
|
|
|
|
const fitAddon = new FitAddon();
|
|
const webLinksAddon = new WebLinksAddon();
|
|
|
|
term.loadAddon(fitAddon);
|
|
term.loadAddon(webLinksAddon);
|
|
term.open(terminalRef.current);
|
|
|
|
// Initial fit
|
|
setTimeout(() => fitAddon.fit(), 0);
|
|
|
|
xtermRef.current = term;
|
|
fitAddonRef.current = fitAddon;
|
|
|
|
// Welcome message
|
|
term.writeln('\x1b[1;36m╔══════════════════════════════════════════════════════════╗\x1b[0m');
|
|
term.writeln('\x1b[1;36m║\x1b[0m \x1b[1;37mClaude Code Terminal\x1b[0m \x1b[1;36m║\x1b[0m');
|
|
term.writeln('\x1b[1;36m║\x1b[0m \x1b[90mFull Claude Code experience in the Atomizer dashboard\x1b[0m \x1b[1;36m║\x1b[0m');
|
|
term.writeln('\x1b[1;36m╚══════════════════════════════════════════════════════════╝\x1b[0m');
|
|
term.writeln('');
|
|
|
|
if (cliAvailable === false) {
|
|
term.writeln('\x1b[1;31mError:\x1b[0m Claude Code CLI not found.');
|
|
term.writeln('Install with: \x1b[1;33mnpm install -g @anthropic-ai/claude-code\x1b[0m');
|
|
} else {
|
|
term.writeln('\x1b[90mClick "Connect" to start a Claude Code session.\x1b[0m');
|
|
term.writeln('\x1b[90mClaude will have access to CLAUDE.md and .claude/ skills.\x1b[0m');
|
|
}
|
|
term.writeln('');
|
|
|
|
return () => {
|
|
term.dispose();
|
|
xtermRef.current = null;
|
|
};
|
|
}, [cliAvailable]);
|
|
|
|
// Handle resize
|
|
useEffect(() => {
|
|
const handleResize = () => {
|
|
if (fitAddonRef.current) {
|
|
fitAddonRef.current.fit();
|
|
// Send resize to backend
|
|
if (wsRef.current?.readyState === WebSocket.OPEN && xtermRef.current) {
|
|
wsRef.current.send(JSON.stringify({
|
|
type: 'resize',
|
|
cols: xtermRef.current.cols,
|
|
rows: xtermRef.current.rows
|
|
}));
|
|
}
|
|
}
|
|
};
|
|
|
|
window.addEventListener('resize', handleResize);
|
|
// Also fit when expanded state changes
|
|
setTimeout(handleResize, 100);
|
|
|
|
return () => window.removeEventListener('resize', handleResize);
|
|
}, [isExpanded]);
|
|
|
|
// Connect to terminal WebSocket
|
|
const connect = useCallback(() => {
|
|
if (!xtermRef.current || wsRef.current?.readyState === WebSocket.OPEN) return;
|
|
|
|
setIsConnecting(true);
|
|
setError(null);
|
|
|
|
// Let backend determine the working directory (ATOMIZER_ROOT)
|
|
// Pass study_id as parameter so we can inform Claude about the context
|
|
const studyParam = selectedStudy?.id ? `?study_id=${selectedStudy.id}` : '';
|
|
|
|
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
const ws = new WebSocket(`${protocol}//${window.location.host}/api/terminal/claude${studyParam}`);
|
|
|
|
ws.onopen = () => {
|
|
setIsConnected(true);
|
|
setIsConnecting(false);
|
|
xtermRef.current?.clear();
|
|
xtermRef.current?.writeln('\x1b[1;32mConnected to Claude Code\x1b[0m');
|
|
if (selectedStudy?.id) {
|
|
xtermRef.current?.writeln(`\x1b[90mStudy: \x1b[1;33m${selectedStudy.id}\x1b[0m \x1b[90m- Click "Set Context" to initialize\x1b[0m`);
|
|
}
|
|
xtermRef.current?.writeln('');
|
|
|
|
// Send initial resize
|
|
if (xtermRef.current) {
|
|
ws.send(JSON.stringify({
|
|
type: 'resize',
|
|
cols: xtermRef.current.cols,
|
|
rows: xtermRef.current.rows
|
|
}));
|
|
}
|
|
};
|
|
|
|
ws.onmessage = (event) => {
|
|
try {
|
|
const message = JSON.parse(event.data);
|
|
|
|
switch (message.type) {
|
|
case 'output':
|
|
xtermRef.current?.write(message.data);
|
|
break;
|
|
case 'started':
|
|
xtermRef.current?.writeln(`\x1b[90m${message.message}\x1b[0m`);
|
|
break;
|
|
case 'exit':
|
|
xtermRef.current?.writeln('');
|
|
xtermRef.current?.writeln(`\x1b[33mClaude Code exited with code ${message.code}\x1b[0m`);
|
|
setIsConnected(false);
|
|
break;
|
|
case 'error':
|
|
xtermRef.current?.writeln(`\x1b[1;31mError: ${message.message}\x1b[0m`);
|
|
setError(message.message);
|
|
break;
|
|
}
|
|
} catch (e) {
|
|
// Raw output
|
|
xtermRef.current?.write(event.data);
|
|
}
|
|
};
|
|
|
|
ws.onerror = () => {
|
|
setError('WebSocket connection error');
|
|
setIsConnecting(false);
|
|
};
|
|
|
|
ws.onclose = () => {
|
|
setIsConnected(false);
|
|
setIsConnecting(false);
|
|
xtermRef.current?.writeln('');
|
|
xtermRef.current?.writeln('\x1b[90mDisconnected from Claude Code\x1b[0m');
|
|
};
|
|
|
|
// Handle terminal input
|
|
const disposable = xtermRef.current.onData((data) => {
|
|
if (ws.readyState === WebSocket.OPEN) {
|
|
ws.send(JSON.stringify({ type: 'input', data }));
|
|
}
|
|
});
|
|
|
|
wsRef.current = ws;
|
|
|
|
return () => {
|
|
disposable.dispose();
|
|
};
|
|
}, [selectedStudy]);
|
|
|
|
// Disconnect
|
|
const disconnect = useCallback(() => {
|
|
if (wsRef.current) {
|
|
wsRef.current.send(JSON.stringify({ type: 'stop' }));
|
|
wsRef.current.close();
|
|
wsRef.current = null;
|
|
}
|
|
setIsConnected(false);
|
|
setContextSet(false);
|
|
}, []);
|
|
|
|
// Set study context - sends context message to Claude silently
|
|
const setStudyContext = useCallback(() => {
|
|
if (!wsRef.current || wsRef.current.readyState !== WebSocket.OPEN) return;
|
|
|
|
setSettingContext(true);
|
|
|
|
let contextMessage: string;
|
|
if (selectedStudy?.id) {
|
|
// Existing study context
|
|
contextMessage =
|
|
`You are helping with Atomizer optimization. ` +
|
|
`First read: .claude/skills/00_BOOTSTRAP.md for task routing. ` +
|
|
`Then follow the Protocol Execution Framework. ` +
|
|
`Study context: Working on "${selectedStudy.id}" at studies/${selectedStudy.id}/. ` +
|
|
`Use atomizer conda env. Acknowledge briefly.`;
|
|
} else {
|
|
// No study selected - offer to create new study
|
|
contextMessage =
|
|
`You are helping with Atomizer optimization. ` +
|
|
`First read: .claude/skills/00_BOOTSTRAP.md for task routing. ` +
|
|
`No study is currently selected. ` +
|
|
`Read .claude/skills/guided-study-wizard.md and help the user create a new optimization study. ` +
|
|
`Use atomizer conda env. Start the guided wizard by asking what they want to optimize.`;
|
|
}
|
|
|
|
wsRef.current.send(JSON.stringify({ type: 'input', data: contextMessage + '\n' }));
|
|
|
|
// Mark as done after Claude has had time to process
|
|
setTimeout(() => {
|
|
setSettingContext(false);
|
|
setContextSet(true);
|
|
}, 500);
|
|
}, [selectedStudy]);
|
|
|
|
// Cleanup on unmount
|
|
useEffect(() => {
|
|
return () => {
|
|
if (wsRef.current) {
|
|
wsRef.current.close();
|
|
}
|
|
};
|
|
}, []);
|
|
|
|
return (
|
|
<div className={`flex flex-col bg-dark-800 rounded-xl border border-dark-600 overflow-hidden ${
|
|
isExpanded ? 'fixed inset-4 z-50' : 'h-full'
|
|
}`}>
|
|
{/* Header */}
|
|
<div className="px-4 py-3 border-b border-dark-600 flex items-center justify-between bg-dark-800">
|
|
<div className="flex items-center gap-2">
|
|
<div className="w-8 h-8 bg-gradient-to-br from-primary-500 to-primary-700 rounded-lg flex items-center justify-center">
|
|
<TerminalIcon className="w-4 h-4 text-white" />
|
|
</div>
|
|
<div>
|
|
<span className="font-medium text-white">Claude Code</span>
|
|
{selectedStudy && (
|
|
<span className="ml-2 text-xs bg-dark-700 px-2 py-0.5 rounded text-dark-300">
|
|
{selectedStudy.id}
|
|
</span>
|
|
)}
|
|
</div>
|
|
{/* Connection status indicator */}
|
|
<div className={`w-2 h-2 rounded-full ml-2 ${
|
|
isConnected ? 'bg-green-500' : 'bg-dark-500'
|
|
}`} />
|
|
</div>
|
|
|
|
<div className="flex items-center gap-1">
|
|
{/* Connect/Disconnect button */}
|
|
<button
|
|
onClick={isConnected ? disconnect : connect}
|
|
disabled={isConnecting || cliAvailable === false}
|
|
className={`px-3 py-1.5 text-sm rounded-lg transition-colors flex items-center gap-2 ${
|
|
isConnected
|
|
? 'bg-red-600/20 text-red-400 hover:bg-red-600/30'
|
|
: 'bg-green-600/20 text-green-400 hover:bg-green-600/30'
|
|
} disabled:opacity-50 disabled:cursor-not-allowed`}
|
|
>
|
|
{isConnecting ? (
|
|
<RefreshCw className="w-3 h-3 animate-spin" />
|
|
) : null}
|
|
{isConnected ? 'Disconnect' : 'Connect'}
|
|
</button>
|
|
|
|
{/* Set Context button - works for both existing study and new study creation */}
|
|
<button
|
|
onClick={setStudyContext}
|
|
disabled={!isConnected || settingContext || contextSet}
|
|
className={`px-3 py-1.5 text-sm rounded-lg transition-colors flex items-center gap-2 ${
|
|
contextSet
|
|
? 'bg-green-600/20 text-green-400'
|
|
: !isConnected
|
|
? 'bg-dark-600 text-dark-400'
|
|
: selectedStudy?.id
|
|
? 'bg-primary-600/20 text-primary-400 hover:bg-primary-600/30'
|
|
: 'bg-yellow-600/20 text-yellow-400 hover:bg-yellow-600/30'
|
|
} disabled:opacity-50 disabled:cursor-not-allowed`}
|
|
title={
|
|
!isConnected
|
|
? 'Connect first to set context'
|
|
: contextSet
|
|
? 'Context already set'
|
|
: selectedStudy?.id
|
|
? `Set context to study: ${selectedStudy.id}`
|
|
: 'Start guided study creation wizard'
|
|
}
|
|
>
|
|
{settingContext ? (
|
|
<RefreshCw className="w-3 h-3 animate-spin" />
|
|
) : selectedStudy?.id ? (
|
|
<FolderOpen className="w-3 h-3" />
|
|
) : (
|
|
<Plus className="w-3 h-3" />
|
|
)}
|
|
{contextSet ? 'Context Set' : selectedStudy?.id ? 'Set Context' : 'New Study'}
|
|
</button>
|
|
|
|
{onToggleExpand && (
|
|
<button
|
|
onClick={onToggleExpand}
|
|
className="p-2 text-dark-400 hover:text-white hover:bg-dark-700 rounded-lg transition-colors"
|
|
>
|
|
{isExpanded ? <Minimize2 className="w-4 h-4" /> : <Maximize2 className="w-4 h-4" />}
|
|
</button>
|
|
)}
|
|
{onClose && (
|
|
<button
|
|
onClick={onClose}
|
|
className="p-2 text-dark-400 hover:text-white hover:bg-dark-700 rounded-lg transition-colors"
|
|
>
|
|
<X className="w-4 h-4" />
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* CLI not available warning */}
|
|
{cliAvailable === false && (
|
|
<div className="px-4 py-3 bg-yellow-900/20 border-b border-yellow-800/30 flex items-center gap-2">
|
|
<AlertCircle className="w-4 h-4 text-yellow-500" />
|
|
<span className="text-yellow-400 text-sm">
|
|
Claude Code CLI not found. Install with: npm install -g @anthropic-ai/claude-code
|
|
</span>
|
|
</div>
|
|
)}
|
|
|
|
{/* Terminal */}
|
|
<div className="flex-1 p-2 bg-[#0f172a]">
|
|
<div ref={terminalRef} className="h-full" />
|
|
</div>
|
|
|
|
{/* Footer */}
|
|
<div className="px-4 py-2 border-t border-dark-600 bg-dark-800">
|
|
<p className="text-xs text-dark-500 text-center">
|
|
Claude Code has access to CLAUDE.md instructions and .claude/ skills for Atomizer optimization
|
|
</p>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default ClaudeTerminal;
|