- Modified loadChatHistory() to check for active project before fetching all sessions - When active project exists, use project.sessions instead of fetching from API - Added detailed console logging to debug session filtering - This prevents ALL sessions from appearing in every project's sidebar Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2138 lines
76 KiB
JavaScript
2138 lines
76 KiB
JavaScript
// Claude Code IDE JavaScript
|
||
let currentSession = null;
|
||
let currentProjectName = null;
|
||
let ws = null;
|
||
|
||
// Streaming message state for accumulating response chunks
|
||
// MUST be declared at module scope BEFORE any functions that use them
|
||
let streamingMessageElement = null;
|
||
let streamingMessageContent = '';
|
||
let streamingTimeout = null;
|
||
|
||
// ============================================================
|
||
// REAL-TIME LOGGER WITH AUTO-FIX
|
||
// ============================================================
|
||
// Tier 1: Client-side auto-fix (instant)
|
||
// Tier 2: Escalation to AI agents (systematic-debugging + brainstorming)
|
||
window.AutoFixLogger = (function() {
|
||
const logs = [];
|
||
const maxLogs = 100;
|
||
let panel = null;
|
||
|
||
// Create the logger panel
|
||
function createPanel() {
|
||
if (panel) return;
|
||
|
||
panel = document.createElement('div');
|
||
panel.id = 'autofix-logger-panel';
|
||
panel.innerHTML = `
|
||
<div class="autofix-logger-header">
|
||
<span>🔧 Auto-Fix Logger</span>
|
||
<button onclick="AutoFixLogger.clear()">Clear</button>
|
||
<button onclick="AutoFixLogger.export()">Export</button>
|
||
<button onclick="AutoFixLogger.toggle()">−</button>
|
||
</div>
|
||
<div class="autofix-logger-content" id="autofix-logger-content"></div>
|
||
`;
|
||
|
||
// Add styles
|
||
const style = document.createElement('style');
|
||
style.textContent = `
|
||
#autofix-logger-panel {
|
||
position: fixed;
|
||
bottom: 20px;
|
||
right: 20px;
|
||
width: 400px;
|
||
max-height: 300px;
|
||
background: #1a1a1a;
|
||
border: 1px solid #333;
|
||
border-radius: 12px;
|
||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5);
|
||
z-index: 99999;
|
||
font-family: 'SF Mono', Monaco, monospace;
|
||
font-size: 12px;
|
||
overflow: hidden;
|
||
}
|
||
#autofix-logger-panel.minimized {
|
||
height: 40px;
|
||
}
|
||
.autofix-logger-header {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
padding: 10px 15px;
|
||
background: #252525;
|
||
border-bottom: 1px solid #333;
|
||
font-weight: 600;
|
||
}
|
||
.autofix-logger-header button {
|
||
background: #333;
|
||
border: 1px solid #444;
|
||
color: #e0e0e0;
|
||
padding: 4px 8px;
|
||
border-radius: 4px;
|
||
cursor: pointer;
|
||
font-size: 11px;
|
||
margin-left: 5px;
|
||
}
|
||
.autofix-logger-header button:hover {
|
||
background: #444;
|
||
}
|
||
.autofix-logger-content {
|
||
padding: 10px;
|
||
overflow-y: auto;
|
||
max-height: 250px;
|
||
}
|
||
.autofix-log-entry {
|
||
margin-bottom: 8px;
|
||
padding: 8px;
|
||
border-radius: 6px;
|
||
border-left: 3px solid #666;
|
||
}
|
||
.autofix-log-entry.success {
|
||
background: rgba(34, 197, 94, 0.1);
|
||
border-left-color: #22c55e;
|
||
}
|
||
.autofix-log-entry.error {
|
||
background: rgba(239, 68, 68, 0.1);
|
||
border-left-color: #ef4444;
|
||
}
|
||
.autofix-log-entry.warning {
|
||
background: rgba(234, 179, 8, 0.1);
|
||
border-left-color: #eab308;
|
||
}
|
||
.autofix-log-entry.info {
|
||
background: rgba(74, 158, 255, 0.1);
|
||
border-left-color: #4a9eff;
|
||
}
|
||
.autofix-log-time {
|
||
color: #888;
|
||
font-size: 10px;
|
||
}
|
||
.autofix-log-message {
|
||
margin-top: 4px;
|
||
color: #e0e0e0;
|
||
}
|
||
.autofix-log-detail {
|
||
margin-top: 4px;
|
||
padding: 4px 8px;
|
||
background: #0d0d0d;
|
||
border-radius: 4px;
|
||
font-size: 10px;
|
||
color: #888;
|
||
}
|
||
`;
|
||
|
||
document.head.appendChild(style);
|
||
document.body.appendChild(panel);
|
||
}
|
||
|
||
function addLog(type, message, detail = null) {
|
||
const timestamp = new Date().toLocaleTimeString();
|
||
const log = { timestamp, type, message, detail };
|
||
logs.push(log);
|
||
|
||
if (logs.length > maxLogs) logs.shift();
|
||
|
||
if (!panel) createPanel();
|
||
|
||
const content = document.getElementById('autofix-logger-content');
|
||
const entry = document.createElement('div');
|
||
entry.className = `autofix-log-entry ${type}`;
|
||
entry.innerHTML = `
|
||
<div class="autofix-log-time">${timestamp}</div>
|
||
<div class="autofix-log-message">${message}</div>
|
||
${detail ? `<div class="autofix-log-detail">${typeof detail === 'object' ? JSON.stringify(detail, null, 2) : detail}</div>` : ''}
|
||
`;
|
||
content.appendChild(entry);
|
||
content.scrollTop = content.scrollHeight;
|
||
|
||
// Auto-fix triggers
|
||
checkAndAutoFix();
|
||
}
|
||
|
||
function checkAndAutoFix() {
|
||
const recentLogs = logs.slice(-10);
|
||
|
||
// Detect: Session ID in URL but not attached
|
||
const hasSessionInUrl = window.PRELOAD_SESSION_ID || window.location.pathname.match(/\/claude\/ide\/session\/([^\/]+)$/);
|
||
const showsNoSessions = document.body.textContent.includes('No sessions yet');
|
||
|
||
if (hasSessionInUrl && showsNoSessions) {
|
||
const sessionId = window.PRELOAD_SESSION_ID || window.location.pathname.match(/\/claude\/ide\/session\/([^\/]+)$/)[1];
|
||
|
||
// Check if already attached
|
||
if (typeof attachedSessionId !== 'undefined' && attachedSessionId === sessionId) {
|
||
return; // Already attached, no fix needed
|
||
}
|
||
|
||
addLog('warning', '⚠️ Detected: Session in URL but not attached. Attempting auto-fix...');
|
||
|
||
// Tier 1 Auto-fix: Force attach
|
||
if (typeof attachToSession === 'function') {
|
||
setTimeout(() => {
|
||
attachToSession(sessionId);
|
||
addLog('success', '✅ Auto-fix applied: Force attached to session', { sessionId });
|
||
}, 100);
|
||
}
|
||
}
|
||
|
||
// Detect: API errors
|
||
const apiErrors = recentLogs.filter(l => l.type === 'error' && l.message.includes('API'));
|
||
if (apiErrors.length >= 3) {
|
||
addLog('warning', '⚠️ Multiple API errors detected. Consider reloading page.');
|
||
}
|
||
}
|
||
|
||
function escalateToAgents(issue) {
|
||
addLog('warning', '🤖 Tier 1 auto-fix failed. Escalating to AI agents...');
|
||
|
||
const diagnosticReport = {
|
||
url: window.location.href,
|
||
sessionId: window.PRELOAD_SESSION_ID,
|
||
attachedSessionId: typeof attachedSessionId !== 'undefined' ? attachedSessionId : null,
|
||
timestamp: new Date().toISOString(),
|
||
logs: logs.slice(-20),
|
||
userAgent: navigator.userAgent,
|
||
sessionStorage: { ...sessionStorage },
|
||
localStorage: { ...localStorage }
|
||
};
|
||
|
||
addLog('info', '📋 Diagnostic report generated', { issue, reportKeys: Object.keys(diagnosticReport) });
|
||
|
||
// Store report for agent retrieval
|
||
sessionStorage.setItem('AUTOFIX_DIAGNOSTIC_REPORT', JSON.stringify(diagnosticReport));
|
||
sessionStorage.setItem('AUTOFIX_ISSUE', JSON.stringify(issue));
|
||
|
||
// The actual agent escalation happens server-side via the skill system
|
||
console.log('[AUTOFIX] Diagnostic report ready for agent retrieval');
|
||
console.log('[AUTOFIX] Report:', diagnosticReport);
|
||
|
||
addLog('info', '💡 Tip: Share this diagnostic report with Claude for agent-assisted fix');
|
||
}
|
||
|
||
return {
|
||
init: function() {
|
||
createPanel();
|
||
addLog('info', '🔧 Auto-Fix Logger initialized');
|
||
addLog('info', '✅ PRELOAD_SESSION_ID:', window.PRELOAD_SESSION_ID || 'none');
|
||
|
||
// Start monitoring
|
||
setInterval(checkAndAutoFix, 5000);
|
||
},
|
||
log: function(message, detail = null) {
|
||
addLog('info', message, detail);
|
||
},
|
||
success: function(message, detail = null) {
|
||
addLog('success', '✅ ' + message, detail);
|
||
},
|
||
error: function(message, detail = null) {
|
||
addLog('error', '❌ ' + message, detail);
|
||
},
|
||
warning: function(message, detail = null) {
|
||
addLog('warning', '⚠️ ' + message, detail);
|
||
},
|
||
clear: function() {
|
||
logs.length = 0;
|
||
if (panel) {
|
||
document.getElementById('autofix-logger-content').innerHTML = '';
|
||
}
|
||
},
|
||
export: function() {
|
||
const report = {
|
||
timestamp: new Date().toISOString(),
|
||
url: window.location.href,
|
||
sessionId: window.PRELOAD_SESSION_ID,
|
||
logs: logs
|
||
};
|
||
const blob = new Blob([JSON.stringify(report, null, 2)], { type: 'application/json' });
|
||
const url = URL.createObjectURL(blob);
|
||
const a = document.createElement('a');
|
||
a.href = url;
|
||
a.download = `autofix-log-${Date.now()}.json`;
|
||
a.click();
|
||
},
|
||
toggle: function() {
|
||
if (panel) {
|
||
panel.classList.toggle('minimized');
|
||
}
|
||
},
|
||
escalate: escalateToAgents
|
||
};
|
||
})();
|
||
|
||
// Make ws globally accessible for other scripts
|
||
Object.defineProperty(window, 'ws', {
|
||
get: function() { return ws; },
|
||
set: function(value) { ws = value; },
|
||
enumerable: true,
|
||
configurable: true
|
||
});
|
||
|
||
// Initialize - Use same pattern as session-picker (run immediately if DOM ready, otherwise wait for DOMContentLoaded)
|
||
function ideInit() {
|
||
// ============================================================
|
||
// TRACE: ide.js initialized
|
||
// ============================================================
|
||
if (window.traceExecution) {
|
||
window.traceExecution('ide.js', 'ideInit called', { pathname: window.location.pathname, readyState: document.readyState });
|
||
}
|
||
|
||
// ============================================================
|
||
// Initialize Auto-Fix Logger FIRST
|
||
// ============================================================
|
||
if (window.AutoFixLogger) {
|
||
window.AutoFixLogger.init();
|
||
}
|
||
|
||
initNavigation();
|
||
connectWebSocket();
|
||
|
||
// ============================================================
|
||
// Session ID Extraction - Use PRELOAD_SESSION_ID first
|
||
// ============================================================
|
||
// PRELOAD_SESSION_ID is set by inline script BEFORE any other JS
|
||
// This guarantees it's available when loadChatView() runs
|
||
let sessionId = window.PRELOAD_SESSION_ID || null;
|
||
|
||
if (sessionId) {
|
||
console.log('[Init] Using PRELOAD_SESSION_ID:', sessionId);
|
||
if (window.AutoFixLogger) {
|
||
window.AutoFixLogger.success('Session ID from PRELOAD', { sessionId });
|
||
}
|
||
if (window.traceExecution) {
|
||
window.traceExecution('ide.js', 'Using PRELOAD_SESSION_ID', { sessionId });
|
||
}
|
||
}
|
||
|
||
// Fallback: Extract from route-based URL if PRELOAD didn't work
|
||
if (!sessionId) {
|
||
const pathname = window.location.pathname;
|
||
const sessionMatch = pathname.match(/\/claude\/ide\/session\/([^\/]+)$/);
|
||
if (sessionMatch && sessionMatch[1]) {
|
||
sessionId = sessionMatch[1];
|
||
console.log('[Init] Extracted sessionId from route:', sessionId);
|
||
if (window.traceExecution) {
|
||
window.traceExecution('ide.js', 'Extracted sessionId from URL path', { sessionId, pathname });
|
||
}
|
||
}
|
||
}
|
||
|
||
// Check URL params for session (legacy format), prompt, project, and view
|
||
const urlParams = new URLSearchParams(window.location.search);
|
||
const legacySessionId = urlParams.get('session');
|
||
const prompt = urlParams.get('prompt');
|
||
const project = urlParams.get('project');
|
||
const view = urlParams.get('view');
|
||
|
||
// Use route-based sessionId if available, otherwise fall back to legacy query param
|
||
if (!sessionId && legacySessionId) {
|
||
sessionId = legacySessionId;
|
||
console.log('[Init] Using legacy sessionId from query param:', sessionId);
|
||
}
|
||
|
||
// Parse project parameter if present
|
||
if (project) {
|
||
window.currentProjectDir = decodeURIComponent(project);
|
||
currentProjectName = project.split('/').filter(Boolean).pop() || 'Project';
|
||
console.log('[Init] Project context loaded:', currentProjectName, window.currentProjectDir);
|
||
}
|
||
|
||
if (sessionId || prompt) {
|
||
// CRITICAL: Set pending session attachment flag BEFORE switching view
|
||
// This allows loadChatView() to know a session is about to be attached
|
||
// and show appropriate loading state instead of "No sessions yet"
|
||
if (sessionId) {
|
||
window.pendingSessionAttach = sessionId;
|
||
console.log('[Init] Set pendingSessionAttach:', sessionId);
|
||
if (window.AutoFixLogger) {
|
||
window.AutoFixLogger.log('Set pendingSessionAttach flag', { sessionId });
|
||
}
|
||
if (window.traceExecution) {
|
||
window.traceExecution('ide.js', 'Set pendingSessionAttach flag', { sessionId });
|
||
}
|
||
}
|
||
|
||
// Switch to chat view first
|
||
switchView('chat');
|
||
|
||
// Wait for chat to load, then handle session/prompt
|
||
setTimeout(() => {
|
||
if (sessionId) {
|
||
if (window.AutoFixLogger) {
|
||
window.AutoFixLogger.log('Calling attachToSession...', { sessionId });
|
||
}
|
||
if (window.traceExecution) {
|
||
window.traceExecution('ide.js', 'Calling attachToSession', { sessionId });
|
||
}
|
||
attachToSession(sessionId);
|
||
}
|
||
|
||
if (prompt) {
|
||
setTimeout(() => {
|
||
const input = document.getElementById('chat-input');
|
||
if (input) {
|
||
input.value = decodeURIComponent(prompt);
|
||
sendChatMessage();
|
||
}
|
||
}, 1000);
|
||
}
|
||
}, 500);
|
||
} else if (view) {
|
||
// Switch to the specified view
|
||
switchView('chat');
|
||
} else {
|
||
// Default to chat view
|
||
switchView('chat');
|
||
}
|
||
}
|
||
|
||
// Auto-initialize using same pattern as session-picker
|
||
// Check if DOM is already loaded, if so run immediately, otherwise wait for DOMContentLoaded
|
||
if (typeof window !== 'undefined') {
|
||
if (document.readyState === 'loading') {
|
||
document.addEventListener('DOMContentLoaded', ideInit);
|
||
} else {
|
||
// DOM already loaded, run immediately
|
||
ideInit();
|
||
}
|
||
}
|
||
|
||
// Navigation
|
||
function initNavigation() {
|
||
const navItems = document.querySelectorAll('.nav-item');
|
||
navItems.forEach(item => {
|
||
item.addEventListener('click', () => {
|
||
const view = item.dataset.view;
|
||
switchView(view);
|
||
});
|
||
});
|
||
}
|
||
|
||
function switchView(viewName) {
|
||
if (window.traceExecution) {
|
||
window.traceExecution('ide.js', 'switchView called', { viewName });
|
||
}
|
||
|
||
// Update nav items
|
||
document.querySelectorAll('.nav-item').forEach(item => {
|
||
item.classList.remove('active');
|
||
if (item.dataset.view === viewName) {
|
||
item.classList.add('active');
|
||
}
|
||
});
|
||
|
||
// Update views
|
||
document.querySelectorAll('.view').forEach(view => {
|
||
view.classList.remove('active');
|
||
});
|
||
document.getElementById(`${viewName}-view`).classList.add('active');
|
||
|
||
// Load content for the view
|
||
switch(viewName) {
|
||
case 'dashboard':
|
||
loadDashboard();
|
||
break;
|
||
case 'chat':
|
||
loadChatView();
|
||
break;
|
||
case 'sessions':
|
||
loadSessions();
|
||
break;
|
||
case 'projects':
|
||
loadProjects();
|
||
break;
|
||
case 'files':
|
||
loadFiles();
|
||
break;
|
||
case 'terminal':
|
||
loadTerminal();
|
||
break;
|
||
}
|
||
}
|
||
|
||
// WebSocket Connection
|
||
function connectWebSocket() {
|
||
const wsUrl = `wss://${window.location.host}/claude/api/claude/chat`;
|
||
console.log('Connecting to WebSocket:', wsUrl);
|
||
|
||
window.ws = new WebSocket(wsUrl);
|
||
|
||
// Set ready state to connecting
|
||
window.wsReady = false;
|
||
|
||
window.ws.onopen = () => {
|
||
console.log('WebSocket connected, readyState:', window.ws.readyState);
|
||
window.wsReady = true;
|
||
|
||
// Send a test message to verify connection
|
||
try {
|
||
window.ws.send(JSON.stringify({ type: 'ping' }));
|
||
} catch (error) {
|
||
console.error('Error sending ping:', error);
|
||
}
|
||
|
||
// Flush any queued messages
|
||
flushMessageQueue();
|
||
};
|
||
|
||
window.ws.onmessage = (event) => {
|
||
// Use requestIdleCallback or setTimeout to prevent blocking
|
||
// Priority: requestIdleCallback > setTimeout(0) > setTimeout(4ms)
|
||
if (window.requestIdleCallback) {
|
||
window.requestIdleCallback(() => {
|
||
processWebSocketMessage(event.data);
|
||
}, { timeout: 1000 });
|
||
} else {
|
||
setTimeout(() => {
|
||
processWebSocketMessage(event.data);
|
||
}, 0);
|
||
}
|
||
};
|
||
|
||
window.ws.onerror = (error) => {
|
||
console.error('WebSocket error:', error);
|
||
console.log('WebSocket error details:', {
|
||
type: error.type,
|
||
target: error.target,
|
||
readyState: window.ws?.readyState
|
||
});
|
||
};
|
||
|
||
window.ws.onclose = (event) => {
|
||
console.log('WebSocket disconnected:', {
|
||
code: event.code,
|
||
reason: event.reason,
|
||
wasClean: event.wasClean
|
||
});
|
||
window.wsReady = false;
|
||
window.ws = null;
|
||
// Attempt to reconnect with exponential backoff
|
||
scheduleReconnect();
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Process WebSocket message with error handling and timeout protection
|
||
* @param {string} rawData - Raw message data from WebSocket
|
||
*/
|
||
function processWebSocketMessage(rawData) {
|
||
const startTime = performance.now();
|
||
const MESSAGE_PROCESSING_TIMEOUT = 100; // 100ms max per message
|
||
|
||
try {
|
||
// Add timeout protection for message processing
|
||
const timeoutId = setTimeout(() => {
|
||
console.warn('[WebSocket] Message processing timeout, blocking detected:', {
|
||
dataLength: rawData?.length || 0,
|
||
elapsed: performance.now() - startTime
|
||
});
|
||
}, MESSAGE_PROCESSING_TIMEOUT);
|
||
|
||
const data = JSON.parse(rawData);
|
||
console.log('WebSocket message received:', data.type);
|
||
|
||
// Clear timeout if processing completed in time
|
||
clearTimeout(timeoutId);
|
||
|
||
// Use defer for heavy message handlers
|
||
if (data.type === 'output' && data.data?.content?.length > 10000) {
|
||
// Large message - defer processing
|
||
setTimeout(() => handleWebSocketMessage(data), 0);
|
||
} else {
|
||
handleWebSocketMessage(data);
|
||
}
|
||
} catch (error) {
|
||
console.error('[WebSocket] Failed to parse message:', error, 'Raw data length:', rawData?.length);
|
||
}
|
||
}
|
||
|
||
// Exponential backoff for reconnection
|
||
let reconnectAttempts = 0;
|
||
const MAX_RECONNECT_ATTEMPTS = 10;
|
||
const BASE_RECONNECT_DELAY = 1000; // 1 second
|
||
|
||
function scheduleReconnect() {
|
||
if (reconnectAttempts >= MAX_RECONNECT_ATTEMPTS) {
|
||
console.error('[WebSocket] Max reconnection attempts reached');
|
||
if (typeof appendSystemMessage === 'function') {
|
||
appendSystemMessage('⚠️ WebSocket connection lost. Please refresh the page.');
|
||
}
|
||
return;
|
||
}
|
||
|
||
// Exponential backoff with jitter
|
||
const delay = Math.min(
|
||
BASE_RECONNECT_DELAY * Math.pow(2, reconnectAttempts) + Math.random() * 1000,
|
||
30000 // Max 30 seconds
|
||
);
|
||
|
||
reconnectAttempts++;
|
||
console.log(`[WebSocket] Scheduling reconnect attempt ${reconnectAttempts}/${MAX_RECONNECT_ATTEMPTS} in ${Math.round(delay)}ms`);
|
||
|
||
setTimeout(() => {
|
||
if (!window.ws || window.ws.readyState === WebSocket.CLOSED) {
|
||
console.log('[WebSocket] Attempting to reconnect...');
|
||
connectWebSocket();
|
||
}
|
||
}, delay);
|
||
}
|
||
|
||
// Reset reconnect attempts on successful connection
|
||
window.ws.onopen = () => {
|
||
reconnectAttempts = 0;
|
||
console.log('WebSocket connected, readyState:', window.ws.readyState);
|
||
window.wsReady = true;
|
||
|
||
// Send a test message to verify connection
|
||
try {
|
||
window.ws.send(JSON.stringify({ type: 'ping' }));
|
||
} catch (error) {
|
||
console.error('Error sending ping:', error);
|
||
}
|
||
|
||
// Flush any queued messages
|
||
flushMessageQueue();
|
||
};
|
||
|
||
// === WebSocket State Management ===
|
||
// Message queue for messages sent before WebSocket is ready
|
||
window.messageQueue = [];
|
||
window.wsReady = false;
|
||
|
||
/**
|
||
* Queue a message to be sent when WebSocket is ready
|
||
* @param {Object} message - Message to queue
|
||
*/
|
||
function queueMessage(message) {
|
||
window.messageQueue.push({
|
||
message: message,
|
||
timestamp: Date.now()
|
||
});
|
||
|
||
console.log(`[WebSocket] Message queued (${window.messageQueue.length} in queue):`, {
|
||
type: message.type,
|
||
sessionId: message.sessionId
|
||
});
|
||
showQueuedMessageIndicator();
|
||
|
||
// Try to flush immediately
|
||
console.log('[WebSocket] Attempting immediate flush...');
|
||
flushMessageQueue();
|
||
}
|
||
|
||
/**
|
||
* Flush all queued messages to WebSocket
|
||
*/
|
||
function flushMessageQueue() {
|
||
console.log('[WebSocket] flushMessageQueue called:', {
|
||
wsReady: window.wsReady,
|
||
wsExists: !!window.ws,
|
||
wsReadyState: window.ws?.readyState,
|
||
queueLength: window.messageQueue.length
|
||
});
|
||
|
||
if (!window.wsReady || !window.ws) {
|
||
console.log('[WebSocket] Not ready, keeping messages in queue');
|
||
return;
|
||
}
|
||
|
||
if (window.messageQueue.length === 0) {
|
||
console.log('[WebSocket] Queue is empty, nothing to flush');
|
||
return;
|
||
}
|
||
|
||
console.log(`[WebSocket] Flushing ${window.messageQueue.length} queued messages`);
|
||
|
||
// Send all queued messages in batches to prevent blocking
|
||
const BATCH_SIZE = 10;
|
||
const batches = [];
|
||
for (let i = 0; i < window.messageQueue.length; i += BATCH_SIZE) {
|
||
batches.push(window.messageQueue.slice(i, i + BATCH_SIZE));
|
||
}
|
||
window.messageQueue = [];
|
||
|
||
// Send batches with defer to prevent blocking
|
||
let batchIndex = 0;
|
||
function sendNextBatch() {
|
||
if (batchIndex >= batches.length) {
|
||
hideQueuedMessageIndicator();
|
||
return;
|
||
}
|
||
|
||
const batch = batches[batchIndex];
|
||
for (const item of batch) {
|
||
try {
|
||
const payloadStr = JSON.stringify(item.message);
|
||
console.log('[WebSocket] Sending queued message:', {
|
||
type: item.message.type,
|
||
sessionId: item.message.sessionId,
|
||
payloadLength: payloadStr.length
|
||
});
|
||
window.ws.send(payloadStr);
|
||
console.log('[WebSocket] ✓ Sent queued message:', item.message.type);
|
||
} catch (error) {
|
||
console.error('[WebSocket] ✗ Failed to send queued message:', error);
|
||
// Put it back in the queue
|
||
window.messageQueue.push(item);
|
||
}
|
||
}
|
||
|
||
batchIndex++;
|
||
// Defer next batch to prevent blocking
|
||
setTimeout(sendNextBatch, 0);
|
||
}
|
||
|
||
sendNextBatch();
|
||
}
|
||
|
||
/**
|
||
* Show indicator that messages are queued
|
||
*/
|
||
function showQueuedMessageIndicator() {
|
||
let indicator = document.getElementById('queued-message-indicator');
|
||
if (!indicator) {
|
||
indicator = document.createElement('div');
|
||
indicator.id = 'queued-message-indicator';
|
||
indicator.className = 'queued-message-indicator';
|
||
indicator.innerHTML = `
|
||
<span class="indicator-icon">⏳</span>
|
||
<span class="indicator-text">Message queued...</span>
|
||
`;
|
||
|
||
// Add to chat input area
|
||
const chatContainer = document.getElementById('chat-input-container');
|
||
if (chatContainer) {
|
||
chatContainer.appendChild(indicator);
|
||
}
|
||
}
|
||
|
||
indicator.style.display = 'flex';
|
||
}
|
||
|
||
/**
|
||
* Hide queued message indicator
|
||
*/
|
||
function hideQueuedMessageIndicator() {
|
||
const indicator = document.getElementById('queued-message-indicator');
|
||
if (indicator && window.messageQueue.length === 0) {
|
||
indicator.style.display = 'none';
|
||
}
|
||
}
|
||
|
||
function handleWebSocketMessage(data) {
|
||
switch(data.type) {
|
||
case 'connected':
|
||
console.log(data.message);
|
||
break;
|
||
case 'output':
|
||
handleSessionOutput(data);
|
||
break;
|
||
case 'operations-detected':
|
||
handleOperationsDetected(data);
|
||
break;
|
||
case 'operations-executed':
|
||
handleOperationsExecuted(data);
|
||
break;
|
||
case 'operations-error':
|
||
handleOperationsError(data);
|
||
break;
|
||
case 'operation-progress':
|
||
handleOperationProgress(data);
|
||
break;
|
||
case 'approval-request':
|
||
handleApprovalRequest(data);
|
||
break;
|
||
case 'approval-confirmed':
|
||
handleApprovalConfirmed(data);
|
||
break;
|
||
case 'approval-expired':
|
||
handleApprovalExpired(data);
|
||
break;
|
||
case 'error':
|
||
console.error('WebSocket error:', data.error);
|
||
// Show error in chat if attached
|
||
if (typeof appendSystemMessage === 'function') {
|
||
appendSystemMessage('Error: ' + data.error);
|
||
}
|
||
break;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Handle operations detected event
|
||
*/
|
||
function handleOperationsDetected(data) {
|
||
console.log('Operations detected:', data.operations.length);
|
||
|
||
// Only show if we're attached to this session
|
||
if (data.sessionId !== attachedSessionId) return;
|
||
|
||
// Store response for execution
|
||
window.currentOperationsResponse = data.response;
|
||
|
||
// Use tag renderer to show operations panel
|
||
if (typeof tagRenderer !== 'undefined') {
|
||
tagRenderer.showOperationsPanel(data.operations, data.response);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Handle operations executed event
|
||
*/
|
||
function handleOperationsExecuted(data) {
|
||
console.log('Operations executed:', data.results);
|
||
|
||
// Only handle if we're attached to this session
|
||
if (data.sessionId !== attachedSessionId) return;
|
||
|
||
// Hide progress and show completion
|
||
if (typeof tagRenderer !== 'undefined') {
|
||
tagRenderer.hideProgress();
|
||
tagRenderer.hideOperationsPanel();
|
||
tagRenderer.showCompletion(data.results);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Handle operations error event
|
||
*/
|
||
function handleOperationsError(data) {
|
||
console.error('Operations error:', data.error);
|
||
|
||
// Only handle if we're attached to this session
|
||
if (data.sessionId !== attachedSessionId) return;
|
||
|
||
// Show error
|
||
if (typeof tagRenderer !== 'undefined') {
|
||
tagRenderer.hideProgress();
|
||
tagRenderer.showError(data.error);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Handle operation progress event
|
||
*/
|
||
function handleOperationProgress(data) {
|
||
console.log('Operation progress:', data.progress);
|
||
|
||
// Only handle if we're attached to this session
|
||
if (data.sessionId !== attachedSessionId) return;
|
||
|
||
// Update progress
|
||
if (typeof tagRenderer !== 'undefined') {
|
||
const progress = data.progress;
|
||
let message = '';
|
||
|
||
switch(progress.type) {
|
||
case 'write':
|
||
message = `Creating ${progress.path}...`;
|
||
break;
|
||
case 'rename':
|
||
message = `Renaming ${progress.from} to ${progress.to}...`;
|
||
break;
|
||
case 'delete':
|
||
message = `Deleting ${progress.path}...`;
|
||
break;
|
||
case 'install':
|
||
message = `Installing packages: ${progress.packages.join(', ')}...`;
|
||
break;
|
||
case 'command':
|
||
message = `Executing command: ${progress.command}...`;
|
||
break;
|
||
default:
|
||
message = 'Processing...';
|
||
}
|
||
|
||
tagRenderer.updateProgress(message);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Handle approval request event
|
||
*/
|
||
function handleApprovalRequest(data) {
|
||
console.log('Approval request received:', data);
|
||
|
||
// Only handle if we're attached to this session
|
||
if (data.sessionId !== attachedSessionId) return;
|
||
|
||
// Render approval card in chat
|
||
if (typeof ApprovalCard !== 'undefined' && ApprovalCard.render) {
|
||
const card = ApprovalCard.render(data);
|
||
|
||
// Add to chat messages container
|
||
const messagesContainer = document.getElementById('chat-messages');
|
||
if (messagesContainer) {
|
||
messagesContainer.appendChild(card);
|
||
messagesContainer.scrollTop = messagesContainer.scrollHeight;
|
||
}
|
||
} else {
|
||
console.error('[ApprovalCard] Component not loaded');
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Handle approval confirmed event
|
||
*/
|
||
function handleApprovalConfirmed(data) {
|
||
console.log('Approval confirmed:', data);
|
||
|
||
// Only handle if we're attached to this session
|
||
if (data.sessionId !== attachedSessionId) return;
|
||
|
||
// Show confirmation message
|
||
const message = data.approved
|
||
? (data.customCommand
|
||
? `✅ Approved with custom command: ${data.customCommand}`
|
||
: '✅ Command approved')
|
||
: '❌ Command rejected';
|
||
|
||
if (typeof appendSystemMessage === 'function') {
|
||
appendSystemMessage(message);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Handle approval expired event
|
||
*/
|
||
function handleApprovalExpired(data) {
|
||
console.log('Approval expired:', data);
|
||
|
||
// Update the approval card to show expired state
|
||
if (typeof ApprovalCard !== 'undefined' && ApprovalCard.handleExpired) {
|
||
ApprovalCard.handleExpired(data.id);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Detect approval request in AI response
|
||
* @param {string} content - AI response content
|
||
* @returns {Object|null} - Approval request data or null
|
||
*/
|
||
function detectApprovalRequest(content) {
|
||
if (!content || typeof content !== 'string') return null;
|
||
|
||
const lower = content.toLowerCase();
|
||
|
||
// Common approval request patterns
|
||
const approvalPatterns = [
|
||
// "Would you like me to proceed with X"
|
||
/would\s+(?:you\s+)?like\s+me\s+to\s+proceed\s+(?:with\s+)?(?:executing\s+)?(?:running\s+)?["']?([a-z0-9._\s\-\/]+)["']?/i,
|
||
/should\s+i\s+(?:run|execute)\s+["']?([a-z0-9._\s\-\/]+)["']?/i,
|
||
/do\s+(?:you\s+)?want\s+me\s+to\s+(?:run|execute)\s+["']?([a-z0-9._\s\-\/]+)["']?/i,
|
||
/shall\s+i\s+(?:run|execute)\s+["']?([a-z0-9._\s\-\/]+)["']?/i,
|
||
// "Proceed with X?"
|
||
/proceed\s+(?:with\s+)?["']?([a-z0-9._\s\-\/]+)["']?\?/i,
|
||
// "Execute X?"
|
||
/execute\s+["']?([a-z0-9._\s\-\/]+)["']?\?/i,
|
||
// "Run X?"
|
||
/run\s+["']?([a-z0-9._\s\-\/]+)["']?\?/i
|
||
];
|
||
|
||
for (const pattern of approvalPatterns) {
|
||
const match = content.match(pattern);
|
||
if (match && match[1]) {
|
||
let command = match[1].trim();
|
||
|
||
// Clean up the command
|
||
command = command.replace(/[?\\s.]+$/, '').trim();
|
||
|
||
// Extract explanation from the content
|
||
let explanation = '';
|
||
// Pattern 1: "this will/does X" followed by period, newline, or end
|
||
const thisPattern = content.match(/this\s+(?:will|is)\s+([^.]+?)(?:\.|\n|$)/i);
|
||
// Pattern 2: "operation: X" followed by period, newline, or end
|
||
const operationPattern = content.match(/(?:network\s+operation|file\s+operation|system\s+operation)\s*[:\-]\s*([^.]+?)(?:\.|\n|$)/i);
|
||
|
||
if (thisPattern) {
|
||
explanation = thisPattern[1].trim();
|
||
} else if (operationPattern) {
|
||
explanation = operationPattern[1].trim();
|
||
}
|
||
|
||
// Generate a reasonable explanation if not found
|
||
if (!explanation) {
|
||
if (command.includes('ping')) {
|
||
explanation = 'Network operation - will send ICMP packets to the target host';
|
||
} else if (command.includes('rm') || command.includes('delete')) {
|
||
explanation = 'File deletion operation - files will be permanently removed';
|
||
} else if (command.includes('curl') || command.includes('wget') || command.includes('http')) {
|
||
explanation = 'Network operation - will make HTTP request(s)';
|
||
} else if (command.includes('ssh') || command.includes('scp')) {
|
||
explanation = 'Remote connection operation - will connect to external host';
|
||
} else {
|
||
explanation = `Execute command: ${command}`;
|
||
}
|
||
}
|
||
|
||
return {
|
||
command,
|
||
explanation,
|
||
originalText: content
|
||
};
|
||
}
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
/**
|
||
* Escape HTML to prevent XSS
|
||
* @param {string} text - Text to escape
|
||
* @returns {string} - Escaped text
|
||
*/
|
||
function escapeHtml(text) {
|
||
if (!text) return '';
|
||
const div = document.createElement('div');
|
||
div.textContent = text;
|
||
return div.innerHTML;
|
||
}
|
||
|
||
function handleSessionOutput(data) {
|
||
// Handle output for sessions view
|
||
if (currentSession && data.sessionId === currentSession.id) {
|
||
appendOutput(data.data);
|
||
}
|
||
|
||
// Handle output for chat view
|
||
if (typeof attachedSessionId !== 'undefined' && data.sessionId === attachedSessionId) {
|
||
// Hide streaming indicator on first chunk
|
||
if (typeof hideStreamingIndicator === 'function') {
|
||
hideStreamingIndicator();
|
||
}
|
||
|
||
const content = data.data.content || '';
|
||
|
||
// ============================================================
|
||
// SEMANTIC VALIDATION - Detect confusing UX messages
|
||
// ============================================================
|
||
if (window.semanticValidator && content) {
|
||
const confusingOutput = window.semanticValidator.detectConfusingOutput(content);
|
||
if (confusingOutput) {
|
||
// Report to bug tracker
|
||
window.semanticValidator.reportSemanticError(confusingOutput);
|
||
|
||
// Log for debugging
|
||
console.warn('[SemanticValidator] Confusing UX message detected:', confusingOutput);
|
||
}
|
||
}
|
||
|
||
// ============================================================
|
||
// APPROVAL REQUEST DETECTION - Intercept AI approval requests
|
||
// ============================================================
|
||
// Check if AI is asking for approval
|
||
const approvalRequest = detectApprovalRequest(content);
|
||
if (approvalRequest && window.ApprovalCard) {
|
||
console.log('[ApprovalRequest] Detected:', approvalRequest);
|
||
|
||
// Don't show the raw AI message - show approval card instead
|
||
// But we need to still show the explanation part
|
||
|
||
// Check if we already have a pending approval for this command
|
||
const existingCard = document.querySelector(`[data-pending-command="${escapeHtml(approvalRequest.command)}"]`);
|
||
if (!existingCard) {
|
||
// Generate a unique ID for this approval
|
||
const approvalId = `approval-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
||
|
||
// Render approval card
|
||
const card = window.ApprovalCard.render({
|
||
id: approvalId,
|
||
sessionId: data.sessionId,
|
||
command: approvalRequest.command,
|
||
explanation: approvalRequest.explanation || 'AI agent requests approval to execute this command'
|
||
});
|
||
|
||
// Add tracking attribute
|
||
card.dataset.pendingCommand = approvalRequest.command;
|
||
|
||
// Add to chat
|
||
const messagesContainer = document.getElementById('chat-messages');
|
||
if (messagesContainer) {
|
||
messagesContainer.appendChild(card);
|
||
messagesContainer.scrollTop = messagesContainer.scrollHeight;
|
||
}
|
||
|
||
// Store the approval for later response
|
||
window._pendingApprovals = window._pendingApprovals || {};
|
||
window._pendingApprovals[approvalId] = {
|
||
command: approvalRequest.command,
|
||
sessionId: data.sessionId,
|
||
originalMessage: content
|
||
};
|
||
|
||
// Don't add the AI's raw text to the chat
|
||
// Just skip to next iteration
|
||
return;
|
||
}
|
||
}
|
||
|
||
// Accumulate streaming content
|
||
if (streamingMessageElement && streamingMessageElement.isConnected) {
|
||
// Append to existing message
|
||
streamingMessageContent += content;
|
||
const bubble = streamingMessageElement.querySelector('.chat-message-bubble');
|
||
if (bubble && typeof formatMessage === 'function') {
|
||
bubble.innerHTML = formatMessage(streamingMessageContent);
|
||
}
|
||
} else {
|
||
// Start new streaming message
|
||
streamingMessageContent = content;
|
||
if (typeof appendMessage === 'function') {
|
||
appendMessage('assistant', content, true);
|
||
// Get the message element we just created
|
||
streamingMessageElement = document.querySelector('.chat-message.assistant:last-child');
|
||
}
|
||
}
|
||
|
||
// Reset streaming timeout - if no new chunks for 1 second, consider stream complete
|
||
clearTimeout(streamingTimeout);
|
||
streamingTimeout = setTimeout(() => {
|
||
streamingMessageElement = null;
|
||
streamingMessageContent = '';
|
||
if (typeof setGeneratingState === 'function') {
|
||
setGeneratingState(false);
|
||
}
|
||
|
||
// ============================================================
|
||
// COMMAND TRACKER - Complete command execution
|
||
// ============================================================
|
||
if (window.commandTracker && window._pendingCommandId) {
|
||
// Try to extract exit code from the last output
|
||
const exitCode = extractExitCode(streamingMessageContent);
|
||
|
||
// Complete the command tracking
|
||
window.commandTracker.completeCommand(
|
||
window._pendingCommandId,
|
||
exitCode,
|
||
streamingMessageContent
|
||
);
|
||
|
||
// Clear pending command ID
|
||
window._pendingCommandId = null;
|
||
|
||
console.log('[CommandTracker] Command completed via stream timeout');
|
||
}
|
||
}, 1000);
|
||
}
|
||
|
||
/**
|
||
* Extract exit code from command output
|
||
* @param {string} output - Command output
|
||
* @returns {number|null} - Exit code or null
|
||
*/
|
||
function extractExitCode(output) {
|
||
if (!output) return null;
|
||
|
||
// Look for exit code patterns
|
||
const exitCodeMatch = output.match(/exited with code (\d+|undefined|null)/i);
|
||
if (exitCodeMatch) {
|
||
const code = exitCodeMatch[1];
|
||
if (code === 'undefined' || code === 'null') {
|
||
return null; // Undefined/null exit code
|
||
}
|
||
return parseInt(code, 10);
|
||
}
|
||
|
||
// If no explicit exit code found, assume success (0)
|
||
return 0;
|
||
}
|
||
}
|
||
|
||
// Dashboard
|
||
async function loadDashboard() {
|
||
try {
|
||
// Load stats with timeout protection
|
||
const [sessionsRes, projectsRes] = await Promise.all([
|
||
fetchWithTimeout('/claude/api/claude/sessions', 5000),
|
||
fetchWithTimeout('/claude/api/claude/projects', 5000)
|
||
]);
|
||
|
||
const sessionsData = await sessionsRes.json();
|
||
const projectsData = await projectsRes.json();
|
||
|
||
// Update stats
|
||
document.getElementById('active-sessions-count').textContent =
|
||
sessionsData.active?.length || 0;
|
||
document.getElementById('historical-sessions-count').textContent =
|
||
sessionsData.historical?.length || 0;
|
||
document.getElementById('total-projects-count').textContent =
|
||
projectsData.projects?.length || 0;
|
||
|
||
// Update active sessions list - CLEAR LOADING STATE
|
||
const activeSessionsEl = document.getElementById('active-sessions-list');
|
||
if (sessionsData.active && sessionsData.active.length > 0) {
|
||
activeSessionsEl.innerHTML = sessionsData.active.map(session => `
|
||
<div class="session-item" onclick="viewSession('${session.id}')">
|
||
<div class="session-header">
|
||
<span class="session-id">${session.id.substring(0, 20)}...</span>
|
||
<span class="session-status ${session.status}">${session.status}</span>
|
||
</div>
|
||
<div class="session-meta">
|
||
Working: ${session.workingDir}<br>
|
||
Created: ${new Date(session.createdAt).toLocaleString()}
|
||
</div>
|
||
</div>
|
||
`).join('');
|
||
} else {
|
||
// Clear loading state, show empty state
|
||
activeSessionsEl.innerHTML = '<p class="placeholder">No active sessions</p>';
|
||
}
|
||
|
||
// Update projects list - CLEAR LOADING STATE
|
||
const projectsEl = document.getElementById('recent-projects-list');
|
||
if (projectsData.projects && projectsData.projects.length > 0) {
|
||
projectsEl.innerHTML = projectsData.projects.slice(0, 5).map(project => `
|
||
<div class="project-card" onclick="viewProject('${project.name}')">
|
||
<h3>${project.name}</h3>
|
||
<p class="project-meta">
|
||
Modified: ${new Date(project.modified).toLocaleDateString()}
|
||
</p>
|
||
</div>
|
||
`).join('');
|
||
} else {
|
||
// Clear loading state, show empty state
|
||
projectsEl.innerHTML = '<p class="placeholder">No projects yet</p>';
|
||
}
|
||
} catch (error) {
|
||
console.error('Error loading dashboard:', error);
|
||
// Clear loading states on error
|
||
const activeSessionsEl = document.getElementById('active-sessions-list');
|
||
const projectsEl = document.getElementById('recent-projects-list');
|
||
if (activeSessionsEl) activeSessionsEl.innerHTML = '<p class="placeholder">Error loading sessions</p>';
|
||
if (projectsEl) projectsEl.innerHTML = '<p class="placeholder">Error loading projects</p>';
|
||
}
|
||
}
|
||
|
||
function refreshSessions() {
|
||
loadDashboard();
|
||
}
|
||
|
||
// Sessions
|
||
async function loadSessions() {
|
||
const sessionsListEl = document.getElementById('sessions-list');
|
||
|
||
try {
|
||
// Get current project from URL
|
||
const urlParams = new URLSearchParams(window.location.search);
|
||
const projectPath = urlParams.get('project');
|
||
|
||
// Build API URL with project filter
|
||
let apiUrl = '/claude/api/claude/sessions';
|
||
if (projectPath) {
|
||
apiUrl += `?project=${encodeURIComponent(projectPath)}`;
|
||
console.log('[Sessions] Loading sessions for project:', projectPath);
|
||
}
|
||
|
||
// Show loading state
|
||
sessionsListEl.innerHTML = '<div class="loading">Loading sessions...</div>';
|
||
|
||
const res = await fetchWithTimeout(apiUrl, 5000);
|
||
|
||
// Handle HTTP errors
|
||
if (!res.ok) {
|
||
if (res.status === 401) {
|
||
sessionsListEl.innerHTML = `
|
||
<div class="error-state">
|
||
<p>⚠️ Session expired</p>
|
||
<button class="btn-primary" onclick="location.reload()">Login Again</button>
|
||
</div>
|
||
`;
|
||
return;
|
||
}
|
||
throw new Error(`HTTP ${res.status}: ${res.statusText}`);
|
||
}
|
||
|
||
const data = await res.json();
|
||
|
||
// Handle API errors
|
||
if (data.error) {
|
||
throw new Error(data.error);
|
||
}
|
||
|
||
const allSessions = [
|
||
...(data.active || []).map(s => ({...s, type: 'active'})),
|
||
...(data.historical || []).map(s => ({...s, type: 'historical'}))
|
||
];
|
||
|
||
// Sort by last activity (newest first)
|
||
allSessions.sort((a, b) => {
|
||
const dateA = new Date(a.lastActivity || a.createdAt || a.created_at);
|
||
const dateB = new Date(b.lastActivity || b.createdAt || b.created_at);
|
||
return dateB - dateA;
|
||
});
|
||
|
||
// Empty state - CLEAR LOADING STATE
|
||
if (allSessions.length === 0) {
|
||
const projectName = projectPath ? projectPath.split('/').pop() : 'this project';
|
||
sessionsListEl.innerHTML = `
|
||
<div class="empty-state">
|
||
<div class="empty-icon">📂</div>
|
||
<p>No sessions found for <strong>${escapeHtml(projectName)}</strong></p>
|
||
<button class="btn-primary" onclick="startNewChat()">Create New Session</button>
|
||
</div>
|
||
`;
|
||
return;
|
||
}
|
||
|
||
// Render session list - CLEAR LOADING STATE
|
||
sessionsListEl.innerHTML = allSessions.map(session => {
|
||
const isRunning = session.status === 'running' && session.type === 'active';
|
||
const relativeTime = getRelativeTime(session);
|
||
const messageCount = session.messageCount || session.metadata?.messageCount || 0;
|
||
|
||
return `
|
||
<div class="session-item ${session.type}" onclick="viewSessionDetails('${session.id}')">
|
||
<button class="session-close-btn" onclick="deleteSession('${session.id}', event)" title="Delete session">×</button>
|
||
<div class="session-header">
|
||
<div class="session-info">
|
||
<span class="session-id">${session.id.substring(0, 12)}...</span>
|
||
<span class="session-status ${isRunning ? 'running' : 'stopped'}">
|
||
${isRunning ? '🟢 Running' : '⏸️ ' + (session.type === 'historical' ? 'Historical' : 'Stopped')}
|
||
</span>
|
||
</div>
|
||
<div class="session-time">${relativeTime}</div>
|
||
</div>
|
||
<div class="session-meta">
|
||
<div class="session-path">📁 ${escapeHtml(session.workingDir)}</div>
|
||
<div class="session-stats">
|
||
<span>💬 ${messageCount} messages</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
`;
|
||
}).join('');
|
||
|
||
// CRITICAL FIX: Also update session tabs with the same sessions
|
||
if (window.sessionTabs && typeof window.sessionTabs.setSessions === 'function') {
|
||
window.sessionTabs.setSessions(allSessions);
|
||
console.log('[loadSessions] Updated session tabs with', allSessions.length, 'sessions');
|
||
}
|
||
|
||
} catch (error) {
|
||
console.error('[loadSessions] Error:', error);
|
||
sessionsListEl.innerHTML = `
|
||
<div class="error-state">
|
||
<div class="error-icon">⚠️</div>
|
||
<p>Failed to load sessions</p>
|
||
<p class="error-message">${escapeHtml(error.message)}</p>
|
||
<button class="btn-secondary" onclick="loadSessions()">Try Again</button>
|
||
</div>
|
||
`;
|
||
}
|
||
}
|
||
|
||
function getRelativeTime(session) {
|
||
const date = new Date(session.lastActivity || session.createdAt || session.created_at);
|
||
const now = new Date();
|
||
const diffMins = Math.floor((now - date) / 60000);
|
||
|
||
if (diffMins < 1) return 'Just now';
|
||
if (diffMins < 60) return `${diffMins}m ago`;
|
||
if (diffMins < 1440) return `${Math.floor(diffMins/60)}h ago`;
|
||
return `${Math.floor(diffMins/1440)}d ago`;
|
||
}
|
||
|
||
function escapeHtml(text) {
|
||
const div = document.createElement('div');
|
||
div.textContent = text;
|
||
return div.innerHTML;
|
||
}
|
||
|
||
// Delete Session
|
||
async function deleteSession(sessionId, event) {
|
||
// Prevent triggering the session item click
|
||
if (event) {
|
||
event.stopPropagation();
|
||
event.preventDefault();
|
||
}
|
||
|
||
// Confirm deletion
|
||
const shortId = sessionId.substring(0, 12);
|
||
if (!confirm(`Delete session ${shortId}...?\n\nThis action cannot be undone.`)) {
|
||
return;
|
||
}
|
||
|
||
try {
|
||
console.log('[deleteSession] Deleting session:', sessionId);
|
||
|
||
const res = await fetch(`/claude/api/claude/sessions/${sessionId}`, {
|
||
method: 'DELETE',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
credentials: 'include'
|
||
});
|
||
|
||
if (!res.ok) {
|
||
throw new Error(`HTTP ${res.status}: ${res.statusText}`);
|
||
}
|
||
|
||
const data = await res.json();
|
||
|
||
if (data.success || data.deleted) {
|
||
console.log('[deleteSession] Session deleted successfully');
|
||
|
||
// If the deleted session was the current one, clear current session state
|
||
if (attachedSessionId === sessionId || chatSessionId === sessionId) {
|
||
console.log('[deleteSession] Deleted current session, clearing state');
|
||
attachedSessionId = null;
|
||
chatSessionId = null;
|
||
|
||
// Clear UI
|
||
document.getElementById('current-session-id').textContent = 'None';
|
||
document.getElementById('chat-title').textContent = 'Claude Code IDE';
|
||
clearChatDisplay();
|
||
}
|
||
|
||
// Refresh the session list
|
||
await loadSessions();
|
||
|
||
// Show success message
|
||
if (typeof appendSystemMessage === 'function') {
|
||
appendSystemMessage(`✅ Session ${shortId}... deleted`);
|
||
}
|
||
} else {
|
||
throw new Error(data.error || 'Failed to delete session');
|
||
}
|
||
} catch (error) {
|
||
console.error('[deleteSession] Error:', error);
|
||
if (typeof appendSystemMessage === 'function') {
|
||
appendSystemMessage(`❌ Failed to delete session: ${error.message}`);
|
||
} else {
|
||
alert(`Failed to delete session: ${error.message}`);
|
||
}
|
||
}
|
||
}
|
||
|
||
async function viewSessionDetails(sessionId) {
|
||
const detailEl = document.getElementById('session-detail');
|
||
|
||
try {
|
||
// Show loading state
|
||
detailEl.innerHTML = '<div class="loading">Loading session details...</div>';
|
||
|
||
const res = await fetchWithTimeout(`/claude/api/claude/sessions/${sessionId}`, 5000);
|
||
|
||
// Handle 404 - session not found
|
||
if (res.status === 404) {
|
||
detailEl.innerHTML = `
|
||
<div class="error-state">
|
||
<div class="error-icon">🔍</div>
|
||
<h3>Session Not Found</h3>
|
||
<p>The session <code>${escapeHtml(sessionId)}</code> could not be found.</p>
|
||
<button class="btn-primary" onclick="loadSessions()">Back to Sessions</button>
|
||
</div>
|
||
`;
|
||
return;
|
||
}
|
||
|
||
if (!res.ok) {
|
||
throw new Error(`HTTP ${res.status}: ${res.statusText}`);
|
||
}
|
||
|
||
const data = await res.json();
|
||
|
||
if (data.error) {
|
||
throw new Error(data.error);
|
||
}
|
||
|
||
if (!data.session) {
|
||
throw new Error('No session data in response');
|
||
}
|
||
|
||
const session = data.session;
|
||
const isRunning = session.status === 'running' && session.pid;
|
||
const messageCount = session.outputBuffer?.length || 0;
|
||
|
||
// Render session detail card - CLEAR LOADING STATE
|
||
detailEl.innerHTML = `
|
||
<div class="session-detail-card">
|
||
<div class="session-detail-header">
|
||
<div class="session-title">
|
||
<h2>Session ${session.id.substring(0, 12)}...</h2>
|
||
<span class="session-status-badge ${isRunning ? 'running' : 'stopped'}">
|
||
${isRunning ? '🟢 Running' : '⏸️ Stopped'}
|
||
</span>
|
||
</div>
|
||
<div class="session-detail-actions">
|
||
<button class="btn-primary" onclick="continueSessionInChat('${session.id}')">
|
||
💬 Continue in Chat
|
||
</button>
|
||
<button class="btn-secondary" onclick="duplicateSession('${session.id}')">
|
||
📋 Duplicate
|
||
</button>
|
||
${isRunning ? `
|
||
<button class="btn-danger" onclick="terminateSession('${session.id}')">
|
||
⏹️ Terminate
|
||
</button>
|
||
` : ''}
|
||
</div>
|
||
</div>
|
||
|
||
<div class="session-detail-meta">
|
||
<div class="meta-row">
|
||
<span class="meta-label">Working Directory:</span>
|
||
<span class="meta-value">${escapeHtml(session.workingDir)}</span>
|
||
</div>
|
||
<div class="meta-row">
|
||
<span class="meta-label">Created:</span>
|
||
<span class="meta-value">${new Date(session.createdAt).toLocaleString()}</span>
|
||
</div>
|
||
<div class="meta-row">
|
||
<span class="meta-label">Last Activity:</span>
|
||
<span class="meta-value">${new Date(session.lastActivity).toLocaleString()}</span>
|
||
</div>
|
||
<div class="meta-row">
|
||
<span class="meta-label">Messages:</span>
|
||
<span class="meta-value">${messageCount}</span>
|
||
</div>
|
||
${session.pid ? `
|
||
<div class="meta-row">
|
||
<span class="meta-label">PID:</span>
|
||
<span class="meta-value">${session.pid}</span>
|
||
</div>
|
||
` : ''}
|
||
</div>
|
||
|
||
<div class="session-context">
|
||
<h3>Token Usage</h3>
|
||
<div class="context-bar">
|
||
<div class="context-fill" style="width: ${Math.min(100, (session.context?.totalTokens || 0) / (session.context?.maxTokens || 200000) * 100)}%"></div>
|
||
</div>
|
||
<div class="context-stats">
|
||
<span>${(session.context?.totalTokens || 0).toLocaleString()} / ${(session.context?.maxTokens || 200000).toLocaleString()} tokens</span>
|
||
<span>${Math.round((session.context?.totalTokens || 0) / (session.context?.maxTokens || 200000) * 100)}% used</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="session-output-preview">
|
||
<h3>Session Output (${messageCount} entries)</h3>
|
||
<div class="output-scroll-area">
|
||
${session.outputBuffer?.slice(0, 50).map(entry => `
|
||
<div class="output-entry ${entry.type}">
|
||
<div class="output-header">
|
||
<span class="output-type">${entry.type}</span>
|
||
<span class="output-time">${new Date(entry.timestamp).toLocaleTimeString()}</span>
|
||
</div>
|
||
<div class="output-content">${escapeHtml(entry.content.substring(0, 500))}${entry.content.length > 500 ? '...' : ''}</div>
|
||
</div>
|
||
`).join('') || '<p class="no-output">No output yet</p>'}
|
||
${session.outputBuffer?.length > 50 ? `<p class="output-truncated">...and ${session.outputBuffer.length - 50} more entries</p>` : ''}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
`;
|
||
|
||
currentSession = session;
|
||
|
||
} catch (error) {
|
||
console.error('[viewSessionDetails] Error:', error);
|
||
detailEl.innerHTML = `
|
||
<div class="error-state">
|
||
<div class="error-icon">⚠️</div>
|
||
<h3>Failed to Load Session</h3>
|
||
<p class="error-message">${escapeHtml(error.message)}</p>
|
||
<button class="btn-primary" onclick="loadSessions()">Back to Sessions</button>
|
||
</div>
|
||
`;
|
||
}
|
||
}
|
||
|
||
async function continueSessionInChat(sessionId) {
|
||
console.log('[Sessions] Continuing session in Chat:', sessionId);
|
||
|
||
try {
|
||
showLoadingOverlay('Loading session...');
|
||
|
||
const res = await fetchWithTimeout(`/claude/api/claude/sessions/${sessionId}`, 5000);
|
||
if (!res.ok) {
|
||
throw new Error(`HTTP ${res.status}`);
|
||
}
|
||
|
||
const data = await res.json();
|
||
if (!data.session) {
|
||
throw new Error('Session not found');
|
||
}
|
||
|
||
const session = data.session;
|
||
|
||
// Check if session is runnable
|
||
if (session.status === 'terminated' || session.status === 'stopped') {
|
||
hideLoadingOverlay();
|
||
|
||
if (confirm('This session has ended. Do you want to create a new session with the same working directory?')) {
|
||
await duplicateSession(sessionId);
|
||
}
|
||
return;
|
||
}
|
||
|
||
// Store pending session and switch views
|
||
window.pendingSessionId = sessionId;
|
||
window.pendingSessionData = session;
|
||
|
||
hideLoadingOverlay();
|
||
switchView('chat');
|
||
|
||
} catch (error) {
|
||
console.error('[continueSessionInChat] Error:', error);
|
||
hideLoadingOverlay();
|
||
showToast('❌ Failed to load session: ' + error.message, 'error');
|
||
}
|
||
}
|
||
|
||
async function duplicateSession(sessionId) {
|
||
try {
|
||
const res = await fetchWithTimeout(`/claude/api/claude/sessions/${sessionId}`, 5000);
|
||
const data = await res.json();
|
||
|
||
if (!data.session) {
|
||
throw new Error('Session not found');
|
||
}
|
||
|
||
const workingDir = data.session.workingDir;
|
||
const projectName = workingDir.split('/').pop();
|
||
|
||
showLoadingOverlay('Duplicating session...');
|
||
|
||
const createRes = await fetchWithTimeout('/claude/api/claude/sessions', 5000, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
workingDir,
|
||
metadata: {
|
||
type: 'chat',
|
||
source: 'web-ide',
|
||
project: projectName,
|
||
duplicatedFrom: sessionId
|
||
}
|
||
})
|
||
});
|
||
|
||
if (!createRes.ok) {
|
||
throw new Error(`HTTP ${createRes.status}`);
|
||
}
|
||
|
||
const createData = await createRes.json();
|
||
|
||
hideLoadingOverlay();
|
||
showToast('✅ Session duplicated!', 'success');
|
||
|
||
loadSessions();
|
||
|
||
setTimeout(() => {
|
||
if (confirm('Start chatting in the duplicated session?')) {
|
||
continueSessionInChat(createData.session.id);
|
||
}
|
||
}, 500);
|
||
|
||
} catch (error) {
|
||
console.error('[duplicateSession] Error:', error);
|
||
hideLoadingOverlay();
|
||
showToast('Failed to duplicate session: ' + error.message, 'error');
|
||
}
|
||
}
|
||
|
||
async function terminateSession(sessionId) {
|
||
if (!confirm('Are you sure you want to terminate this session?')) {
|
||
return;
|
||
}
|
||
|
||
try {
|
||
showLoadingOverlay('Terminating session...');
|
||
|
||
const res = await fetchWithTimeout(`/claude/api/claude/sessions/${sessionId}`, 5000, {
|
||
method: 'DELETE'
|
||
});
|
||
|
||
if (!res.ok) {
|
||
throw new Error(`HTTP ${res.status}`);
|
||
}
|
||
|
||
hideLoadingOverlay();
|
||
showToast('✅ Session terminated', 'success');
|
||
|
||
loadSessions();
|
||
|
||
if (currentSession && currentSession.id === sessionId) {
|
||
document.getElementById('session-detail').innerHTML = `
|
||
<div class="placeholder">
|
||
<h2>Session Terminated</h2>
|
||
<p>Select another session from the sidebar</p>
|
||
</div>
|
||
`;
|
||
currentSession = null;
|
||
}
|
||
|
||
} catch (error) {
|
||
console.error('[terminateSession] Error:', error);
|
||
hideLoadingOverlay();
|
||
showToast('Failed to terminate session: ' + error.message, 'error');
|
||
}
|
||
}
|
||
|
||
// Projects
|
||
async function loadProjects() {
|
||
try {
|
||
const res = await fetchWithTimeout('/claude/api/claude/projects', 5000);
|
||
const data = await res.json();
|
||
|
||
const gridEl = document.getElementById('projects-grid');
|
||
if (data.projects && data.projects.length > 0) {
|
||
gridEl.innerHTML = data.projects.map(project => `
|
||
<div class="project-card" onclick="viewProject('${project.name}')">
|
||
<h3>${project.name}</h3>
|
||
<p class="project-meta">
|
||
Modified: ${new Date(project.modified).toLocaleString()}
|
||
</p>
|
||
<p class="project-description">Click to view project details</p>
|
||
</div>
|
||
`).join('');
|
||
} else {
|
||
gridEl.innerHTML = '<p class="placeholder">No projects yet. Create your first project!</p>';
|
||
}
|
||
} catch (error) {
|
||
console.error('Error loading projects:', error);
|
||
}
|
||
}
|
||
|
||
async function viewProject(projectName) {
|
||
// Open the project file in the files view
|
||
const path = `Claude Projects/${projectName}.md`;
|
||
loadFileContent(path);
|
||
switchView('files');
|
||
}
|
||
|
||
// Files
|
||
async function loadFiles() {
|
||
try {
|
||
const res = await fetchWithTimeout('/claude/api/files', 5000);
|
||
const data = await res.json();
|
||
|
||
const treeEl = document.getElementById('file-tree');
|
||
treeEl.innerHTML = renderFileTree(data.tree);
|
||
} catch (error) {
|
||
console.error('Error loading files:', error);
|
||
}
|
||
}
|
||
|
||
async function loadTerminal() {
|
||
// Initialize terminal manager if not already done
|
||
if (!window.terminalManager) {
|
||
window.terminalManager = new TerminalManager();
|
||
await window.terminalManager.initialize();
|
||
}
|
||
|
||
// Set up new terminal button
|
||
const btnNewTerminal = document.getElementById('btn-new-terminal');
|
||
if (btnNewTerminal) {
|
||
btnNewTerminal.onclick = () => {
|
||
window.terminalManager.createTerminal();
|
||
};
|
||
}
|
||
}
|
||
|
||
function renderFileTree(tree, level = 0) {
|
||
return tree.map(item => {
|
||
const padding = level * 1 + 0.5;
|
||
const icon = item.type === 'folder' ? '📁' : '📄';
|
||
|
||
if (item.type === 'folder' && item.children) {
|
||
return `
|
||
<div style="padding-left: ${padding}rem">
|
||
<div class="tree-item folder" onclick="toggleFolder(this)">
|
||
<span>${icon}</span>
|
||
<span>${item.name}</span>
|
||
</div>
|
||
<div class="tree-children" style="display: none;">
|
||
${renderFileTree(item.children, level + 1)}
|
||
</div>
|
||
</div>
|
||
`;
|
||
} else {
|
||
return `
|
||
<div style="padding-left: ${padding}rem">
|
||
<div class="tree-item file" onclick="loadFile('${item.path}')">
|
||
<span>${icon}</span>
|
||
<span>${item.name}</span>
|
||
</div>
|
||
</div>
|
||
`;
|
||
}
|
||
}).join('');
|
||
}
|
||
|
||
function toggleFolder(element) {
|
||
const children = element.parentElement.querySelector('.tree-children');
|
||
const icon = element.querySelector('span:first-child');
|
||
|
||
if (children.style.display === 'none') {
|
||
children.style.display = 'block';
|
||
icon.textContent = '📂';
|
||
} else {
|
||
children.style.display = 'none';
|
||
icon.textContent = '📁';
|
||
}
|
||
}
|
||
|
||
async function loadFile(filePath) {
|
||
try {
|
||
const res = await fetchWithTimeout(`/claude/api/file/${encodeURIComponent(filePath)}`, 5000);
|
||
const data = await res.json();
|
||
|
||
// Check if Monaco Editor component is available
|
||
if (window.monacoEditor) {
|
||
// Use the Monaco-based editor
|
||
await window.monacoEditor.openFile(filePath, data.content || '');
|
||
} else {
|
||
// Fallback to simple view
|
||
console.warn('[loadFile] Monaco Editor not available, using fallback');
|
||
const editorEl = document.getElementById('file-editor');
|
||
|
||
const isHtmlFile = filePath.toLowerCase().endsWith('.html') || filePath.toLowerCase().endsWith('.htm');
|
||
|
||
if (isHtmlFile) {
|
||
// HTML file - show with preview option
|
||
editorEl.innerHTML = `
|
||
<div class="file-header">
|
||
<h2>${filePath}</h2>
|
||
<div class="file-actions">
|
||
<button class="btn-secondary btn-sm" onclick="showHtmlPreview('${filePath}')">👁️ Preview</button>
|
||
</div>
|
||
</div>
|
||
<div class="file-content" id="file-content-view">
|
||
<div class="view-toggle">
|
||
<button class="toggle-btn active" data-view="code" onclick="switchFileView('code')">Code</button>
|
||
<button class="toggle-btn" data-view="preview" onclick="switchFileView('preview')">Preview</button>
|
||
</div>
|
||
<div class="code-view">
|
||
<pre><code class="language-html">${escapeHtml(data.content || '')}</code></pre>
|
||
</div>
|
||
<div class="preview-view" style="display: none;">
|
||
<iframe id="html-preview-frame" sandbox="allow-scripts allow-same-origin allow-forms"></iframe>
|
||
</div>
|
||
</div>
|
||
`;
|
||
|
||
// Store file content for preview
|
||
window.currentFileContent = data.content || '';
|
||
window.currentFilePath = filePath;
|
||
|
||
// Highlight code
|
||
if (window.hljs) {
|
||
document.querySelectorAll('#file-content-view pre code').forEach((block) => {
|
||
hljs.highlightElement(block);
|
||
});
|
||
}
|
||
} else {
|
||
// Non-HTML file - show content
|
||
const language = getLanguageFromFile(filePath);
|
||
editorEl.innerHTML = `
|
||
<div class="file-header">
|
||
<h2>${filePath}</h2>
|
||
<span class="language-badge">${language}</span>
|
||
</div>
|
||
<div class="file-content">
|
||
<pre class="code-content"><code>${escapeHtml(data.content || '')}</code></pre>
|
||
</div>
|
||
`;
|
||
}
|
||
}
|
||
} catch (error) {
|
||
console.error('Error loading file:', error);
|
||
const editorEl = document.getElementById('file-editor');
|
||
if (editorEl) {
|
||
editorEl.innerHTML = `
|
||
<div class="file-error">
|
||
<h2>Error loading file</h2>
|
||
<p>${error.message}</p>
|
||
</div>
|
||
`;
|
||
}
|
||
}
|
||
}
|
||
|
||
// Helper function to get language from file path
|
||
function getLanguageFromFile(filePath) {
|
||
const ext = filePath.split('.').pop().toLowerCase();
|
||
const languageMap = {
|
||
'js': 'JavaScript',
|
||
'jsx': 'JavaScript JSX',
|
||
'ts': 'TypeScript',
|
||
'tsx': 'TypeScript JSX',
|
||
'py': 'Python',
|
||
'html': 'HTML',
|
||
'css': 'CSS',
|
||
'json': 'JSON',
|
||
'md': 'Markdown',
|
||
'txt': 'Plain Text'
|
||
};
|
||
return languageMap[ext] || 'Plain Text';
|
||
}
|
||
|
||
async function loadFileContent(filePath) {
|
||
await loadFile(filePath);
|
||
switchView('files');
|
||
}
|
||
|
||
// HTML Preview Functions
|
||
function showHtmlPreview(filePath) {
|
||
switchFileView('preview');
|
||
}
|
||
|
||
function switchFileView(view) {
|
||
const codeView = document.querySelector('.code-view');
|
||
const previewView = document.querySelector('.preview-view');
|
||
const toggleBtns = document.querySelectorAll('.toggle-btn');
|
||
|
||
// Update buttons
|
||
toggleBtns.forEach(btn => {
|
||
btn.classList.remove('active');
|
||
if (btn.dataset.view === view) {
|
||
btn.classList.add('active');
|
||
}
|
||
});
|
||
|
||
// Show/hide views
|
||
if (view === 'code') {
|
||
codeView.style.display = 'block';
|
||
previewView.style.display = 'none';
|
||
} else if (view === 'preview') {
|
||
codeView.style.display = 'none';
|
||
previewView.style.display = 'block';
|
||
|
||
// Load HTML into iframe using blob URL
|
||
const iframe = document.getElementById('html-preview-frame');
|
||
if (iframe && window.currentFileContent) {
|
||
// Create blob URL from HTML content
|
||
const blob = new Blob([window.currentFileContent], { type: 'text/html' });
|
||
const blobUrl = URL.createObjectURL(blob);
|
||
|
||
// Load blob URL in iframe
|
||
iframe.src = blobUrl;
|
||
|
||
// Clean up blob URL when iframe is unloaded
|
||
iframe.onload = () => {
|
||
// Keep the blob URL active while preview is shown
|
||
};
|
||
}
|
||
}
|
||
}
|
||
|
||
function escapeHtml(text) {
|
||
const div = document.createElement('div');
|
||
div.textContent = text;
|
||
return div.innerHTML;
|
||
}
|
||
|
||
// Modals
|
||
function createNewSession() {
|
||
document.getElementById('modal-overlay').classList.remove('hidden');
|
||
document.getElementById('new-session-modal').classList.remove('hidden');
|
||
}
|
||
|
||
function createNewProject() {
|
||
document.getElementById('modal-overlay').classList.remove('hidden');
|
||
document.getElementById('new-project-modal').classList.remove('hidden');
|
||
}
|
||
|
||
function closeModal() {
|
||
document.getElementById('modal-overlay').classList.add('hidden');
|
||
document.querySelectorAll('.modal').forEach(modal => {
|
||
modal.classList.add('hidden');
|
||
});
|
||
}
|
||
|
||
async function submitNewSession() {
|
||
const workingDir = document.getElementById('session-working-dir').value;
|
||
const project = document.getElementById('session-project').value;
|
||
|
||
try {
|
||
const res = await fetchWithTimeout('/claude/api/claude/sessions', 5000, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
workingDir,
|
||
metadata: { project }
|
||
})
|
||
});
|
||
|
||
const data = await res.json();
|
||
|
||
if (data.success) {
|
||
closeModal();
|
||
viewSession(data.session.id);
|
||
}
|
||
} catch (error) {
|
||
console.error('Error creating session:', error);
|
||
alert('Failed to create session');
|
||
}
|
||
}
|
||
|
||
async function submitNewProject() {
|
||
const name = document.getElementById('project-name').value;
|
||
const description = document.getElementById('project-description').value;
|
||
const type = document.getElementById('project-type').value;
|
||
|
||
if (!name) {
|
||
alert('Please enter a project name');
|
||
return;
|
||
}
|
||
|
||
try {
|
||
const res = await fetchWithTimeout('/claude/api/claude/projects', 5000, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ name, description, type })
|
||
});
|
||
|
||
const data = await res.json();
|
||
|
||
if (data.success) {
|
||
closeModal();
|
||
loadProjects();
|
||
viewProject(name);
|
||
}
|
||
} catch (error) {
|
||
console.error('Error creating project:', error);
|
||
alert('Failed to create project');
|
||
}
|
||
}
|
||
|
||
// Utility functions
|
||
function escapeHtml(text) {
|
||
const div = document.createElement('div');
|
||
div.textContent = text;
|
||
return div.innerHTML;
|
||
}
|
||
|
||
/**
|
||
* Show loading overlay
|
||
* @param {string} message - The message to display
|
||
*/
|
||
function showLoadingOverlay(message = 'Loading...') {
|
||
let overlay = document.getElementById('loading-overlay');
|
||
|
||
if (!overlay) {
|
||
overlay = document.createElement('div');
|
||
overlay.id = 'loading-overlay';
|
||
overlay.className = 'loading-overlay';
|
||
overlay.innerHTML = `
|
||
<div class="loading-spinner"></div>
|
||
<p class="loading-text">${escapeHtml(message)}</p>
|
||
`;
|
||
document.body.appendChild(overlay);
|
||
} else {
|
||
// Update message if provided
|
||
const textElement = overlay.querySelector('.loading-text');
|
||
if (textElement) {
|
||
textElement.textContent = message;
|
||
}
|
||
}
|
||
|
||
overlay.classList.remove('hidden');
|
||
setTimeout(() => {
|
||
overlay.classList.add('visible');
|
||
}, 10);
|
||
}
|
||
|
||
/**
|
||
* Hide loading overlay
|
||
*/
|
||
function hideLoadingOverlay() {
|
||
const overlay = document.getElementById('loading-overlay');
|
||
if (overlay) {
|
||
overlay.classList.remove('visible');
|
||
setTimeout(() => {
|
||
overlay.classList.add('hidden');
|
||
}, 300);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Show toast notification
|
||
* @param {string} message - The message to display
|
||
* @param {string} type - The type of toast: 'success', 'error', 'info'
|
||
* @param {number} duration - Duration in milliseconds (default: 3000)
|
||
*/
|
||
function showToast(message, type = 'info', duration = 3000) {
|
||
// Remove existing toasts
|
||
const existingToasts = document.querySelectorAll('.toast-notification');
|
||
existingToasts.forEach(toast => toast.remove());
|
||
|
||
// Create toast element
|
||
const toast = document.createElement('div');
|
||
toast.className = `toast-notification toast-${type}`;
|
||
toast.innerHTML = `
|
||
<span class="toast-icon">${getToastIcon(type)}</span>
|
||
<span class="toast-message">${escapeHtml(message)}</span>
|
||
`;
|
||
|
||
document.body.appendChild(toast);
|
||
|
||
// Trigger animation
|
||
setTimeout(() => {
|
||
toast.classList.add('visible');
|
||
}, 10);
|
||
|
||
// Auto remove after duration
|
||
setTimeout(() => {
|
||
toast.classList.remove('visible');
|
||
setTimeout(() => {
|
||
toast.remove();
|
||
}, 300);
|
||
}, duration);
|
||
}
|
||
|
||
/**
|
||
* Get toast icon based on type
|
||
*/
|
||
function getToastIcon(type) {
|
||
const icons = {
|
||
success: '✓',
|
||
error: '✕',
|
||
info: 'ℹ',
|
||
warning: '⚠'
|
||
};
|
||
return icons[type] || icons.info;
|
||
}
|
||
|
||
/**
|
||
* Fetch with timeout protection to prevent hanging requests
|
||
* @param {string} url - The URL to fetch
|
||
* @param {number} timeout - Timeout in milliseconds
|
||
* @param {object} options - Fetch options
|
||
* @returns {Promise<Response>}
|
||
*/
|
||
async function fetchWithTimeout(url, timeout = 5000, options = {}) {
|
||
const controller = new AbortController();
|
||
const timeoutId = setTimeout(() => controller.abort(), timeout);
|
||
|
||
try {
|
||
const response = await fetch(url, {
|
||
...options,
|
||
signal: controller.signal
|
||
});
|
||
clearTimeout(timeoutId);
|
||
return response;
|
||
} catch (error) {
|
||
clearTimeout(timeoutId);
|
||
if (error.name === 'AbortError') {
|
||
throw new Error(`Request timeout after ${timeout}ms`);
|
||
}
|
||
throw error;
|
||
}
|
||
}
|
||
|
||
function showProjects() {
|
||
switchView('projects');
|
||
}
|
||
|
||
// Logout
|
||
document.getElementById('logout-btn')?.addEventListener('click', async () => {
|
||
try {
|
||
await fetch('/claude/api/logout', { method: 'POST' });
|
||
window.location.reload();
|
||
} catch (error) {
|
||
console.error('Error logging out:', error);
|
||
}
|
||
});
|