feat: AI auto-fix bug tracker with real-time error monitoring

- Real-time error monitoring system with WebSocket
- Auto-fix agent that triggers on browser errors
- Bug tracker dashboard with floating button (🐛)
- Live activity stream showing AI thought process
- Fixed 4 JavaScript errors (SyntaxError, TypeError)
- Fixed SessionPicker API endpoint error
- Enhanced chat input with Monaco editor
- Session picker component for project management

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
uroma
2026-01-21 10:53:11 +00:00
Unverified
parent b765c537fc
commit efb3ecfb19
23 changed files with 7254 additions and 119 deletions

View File

@@ -0,0 +1,663 @@
/**
* Real-Time Bug Tracker Dashboard
* Shows all auto-detected errors and fix progress
*/
(function() {
'use strict';
// Error state storage
window.bugTracker = {
errors: [],
fixesInProgress: new Map(),
fixesCompleted: new Map(),
activityLog: [], // New: stores AI activity stream
addError(error) {
const errorId = this.generateErrorId(error);
const existingError = this.errors.find(e => e.id === errorId);
if (!existingError) {
const errorWithMeta = {
id: errorId,
...error,
detectedAt: new Date().toISOString(),
status: 'detected',
count: 1,
activity: [] // Activity for this error
};
this.errors.push(errorWithMeta);
this.updateDashboard();
// Trigger auto-fix notification
if (typeof showErrorNotification === 'function') {
showErrorNotification(errorWithMeta);
}
} else {
existingError.count++;
existingError.lastSeen = new Date().toISOString();
}
return errorId;
},
startFix(errorId) {
const error = this.errors.find(e => e.id === errorId);
if (error) {
error.status = 'fixing';
error.fixStartedAt = new Date().toISOString();
this.fixesInProgress.set(errorId, true);
this.addActivity(errorId, '🤖', 'AI agent started analyzing error...');
this.updateDashboard();
}
},
// Add activity to error's activity log
addActivity(errorId, icon, message, type = 'info') {
// Add to global activity log
this.activityLog.unshift({
errorId,
icon,
message,
type,
timestamp: new Date().toISOString()
});
// Keep only last 50 global activities
if (this.activityLog.length > 50) {
this.activityLog = this.activityLog.slice(0, 50);
}
// Add to specific error's activity
const error = this.errors.find(e => e.id === errorId);
if (error) {
if (!error.activity) error.activity = [];
error.activity.unshift({
icon,
message,
type,
timestamp: new Date().toISOString()
});
if (error.activity.length > 20) {
error.activity = error.activity.slice(0, 20);
}
}
this.updateActivityStream();
},
// Update the activity stream display
updateActivityStream() {
const stream = document.getElementById('activity-stream');
if (!stream) return;
// Show last 10 activities globally
const recentActivities = this.activityLog.slice(0, 10);
stream.innerHTML = recentActivities.map(activity => {
const timeAgo = this.getTimeAgo(activity.timestamp);
return `
<div class="activity-item activity-${activity.type}">
<span class="activity-icon">${activity.icon}</span>
<span class="activity-message">${this.escapeHtml(activity.message)}</span>
<span class="activity-time">${timeAgo}</span>
</div>
`;
}).join('');
},
completeFix(errorId, fixDetails) {
const error = this.errors.find(e => e.id === errorId);
if (error) {
error.status = 'fixed';
error.fixedAt = new Date().toISOString();
error.fixDetails = fixDetails;
this.fixesInProgress.delete(errorId);
this.fixesCompleted.set(errorId, true);
this.updateDashboard();
}
},
generateErrorId(error) {
const parts = [
error.type,
error.message.substring(0, 50),
error.filename || 'unknown'
];
return btoa(parts.join('::')).substring(0, 20);
},
updateDashboard() {
const dashboard = document.getElementById('bug-tracker-dashboard');
if (!dashboard) return;
const content = dashboard.querySelector('#bug-tracker-content');
const stats = dashboard.querySelector('#bug-tracker-stats');
if (!content) return;
// Update stats
if (stats) {
const totalErrors = this.errors.length;
const activeErrors = this.errors.filter(e => e.status === 'detected').length;
const fixingErrors = this.errors.filter(e => e.status === 'fixing').length;
const fixedErrors = this.errors.filter(e => e.status === 'fixed').length;
stats.innerHTML = `
<div class="stat-item">
<span>Total:</span>
<span class="stat-value">${totalErrors}</span>
</div>
<div class="stat-item" style="color: #ff6b6b;">
<span>🔴 Active:</span>
<span class="stat-value">${activeErrors}</span>
</div>
<div class="stat-item" style="color: #ffa94d;">
<span>🔧 Fixing:</span>
<span class="stat-value">${fixingErrors}</span>
</div>
<div class="stat-item" style="color: #51cf66;">
<span>✅ Fixed:</span>
<span class="stat-value">${fixedErrors}</span>
</div>
`;
}
// Sort errors: fixing first, then detected, then fixed
const sortedErrors = [...this.errors].sort((a, b) => {
const statusOrder = { 'fixing': 0, 'detected': 1, 'fixed': 2 };
return statusOrder[a.status] - statusOrder[b.status];
});
content.innerHTML = this.renderErrors(sortedErrors);
},
renderErrors(errors) {
if (errors.length === 0) {
return `
<div class="bug-tracker-empty">
<div class="empty-icon">✨</div>
<div class="empty-title">No bugs detected!</div>
<div class="empty-subtitle">The code is running smoothly</div>
</div>
`;
}
return errors.map(error => this.renderError(error)).join('');
},
renderError(error) {
const statusIcons = {
'detected': '🔴',
'fixing': '🔧',
'fixed': '✅'
};
const statusClasses = {
'detected': 'status-detected',
'fixing': 'status-fixing',
'fixed': 'status-fixed'
};
const timeAgo = this.getTimeAgo(error.detectedAt || error.timestamp);
return `
<div class="bug-item ${statusClasses[error.status]}" data-error-id="${error.id}">
<div class="bug-header">
<span class="bug-status">${statusIcons[error.status]} ${error.status}</span>
<span class="bug-time">${timeAgo}</span>
${error.count > 1 ? `<span class="bug-count">×${error.count}</span>` : ''}
</div>
<div class="bug-message">${this.escapeHtml(error.message.substring(0, 100))}${error.message.length > 100 ? '...' : ''}</div>
${error.filename ? `<div class="bug-location">📄 ${error.filename.split('/').pop()}:${error.line || ''}</div>` : ''}
${error.fixDetails ? `<div class="bug-fix-details">✨ ${error.fixDetails}</div>` : ''}
${error.status === 'detected' ? `
<button class="bug-fix-btn" onclick="window.bugTracker.triggerManualFix('${error.id}')">
🤖 Fix Now
</button>
` : ''}
</div>
`;
},
triggerManualFix(errorId) {
const error = this.errors.find(e => e.id === errorId);
if (error) {
// Report to server to trigger fix
fetch('/claude/api/log-error', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
...error,
manualTrigger: true
})
});
}
},
getTimeAgo(timestamp) {
const now = new Date();
const then = new Date(timestamp);
const diffMs = now - then;
const diffSecs = Math.floor(diffMs / 1000);
const diffMins = Math.floor(diffSecs / 60);
if (diffSecs < 60) return `${diffSecs}s ago`;
if (diffMins < 60) return `${diffMins}m ago`;
return `${Math.floor(diffMins / 60)}h ago`;
},
escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
},
toggle() {
const dashboard = document.getElementById('bug-tracker-dashboard');
if (dashboard) {
dashboard.classList.toggle('visible');
dashboard.classList.toggle('hidden');
}
}
};
// Create dashboard UI
function createDashboard() {
// Create toggle button
const toggleBtn = document.createElement('button');
toggleBtn.id = 'bug-tracker-toggle';
toggleBtn.className = 'bug-tracker-toggle';
toggleBtn.innerHTML = `
<span class="toggle-icon">🐛</span>
<span class="toggle-badge" id="bug-count-badge">0</span>
`;
toggleBtn.onclick = () => window.bugTracker.toggle();
// Create dashboard
const dashboard = document.createElement('div');
dashboard.id = 'bug-tracker-dashboard';
dashboard.className = 'bug-tracker-dashboard hidden';
dashboard.innerHTML = `
<div class="bug-tracker-header">
<div class="bug-tracker-title">
<span class="title-icon">🤖</span>
<span>AI Auto-Fix Tracker</span>
</div>
<button class="bug-tracker-close" onclick="window.bugTracker.toggle()">×</button>
</div>
<div class="activity-stream-header">
<span class="activity-title">🔴 Live Activity Feed</span>
</div>
<div id="activity-stream" class="activity-stream"></div>
<div class="bug-tracker-stats" id="bug-tracker-stats"></div>
<div id="bug-tracker-content" class="bug-tracker-content"></div>
`;
// Add styles
const style = document.createElement('style');
style.id = 'bug-tracker-styles';
style.textContent = `
.bug-tracker-toggle {
position: fixed;
bottom: 20px;
right: 20px;
width: 60px;
height: 60px;
border-radius: 50%;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border: none;
box-shadow: 0 4px 20px rgba(102, 126, 234, 0.4);
cursor: pointer;
z-index: 9999;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.3s ease;
}
.bug-tracker-toggle:hover {
transform: scale(1.1);
box-shadow: 0 6px 30px rgba(102, 126, 234, 0.6);
}
.toggle-icon {
font-size: 24px;
}
.toggle-badge {
position: absolute;
top: -5px;
right: -5px;
background: #ff6b6b;
color: white;
font-size: 12px;
font-weight: bold;
padding: 2px 8px;
border-radius: 10px;
min-width: 20px;
text-align: center;
}
.bug-tracker-dashboard {
position: fixed;
top: 50%;
right: 20px;
transform: translateY(-50%);
width: 400px;
max-height: 80vh;
background: #1a1a1a;
border: 1px solid #333;
border-radius: 12px;
box-shadow: 0 10px 60px rgba(0, 0, 0, 0.5);
z-index: 9998;
display: flex;
flex-direction: column;
transition: all 0.3s ease;
}
.bug-tracker-dashboard.hidden {
display: none;
}
.bug-tracker-dashboard.visible {
display: flex;
}
.bug-tracker-header {
padding: 16px 20px;
border-bottom: 1px solid #333;
display: flex;
justify-content: space-between;
align-items: center;
}
.bug-tracker-title {
display: flex;
align-items: center;
gap: 10px;
font-size: 16px;
font-weight: 600;
color: #e0e0e0;
}
.bug-tracker-close {
background: none;
border: none;
color: #888;
font-size: 24px;
cursor: pointer;
padding: 0;
width: 30px;
height: 30px;
display: flex;
align-items: center;
justify-content: center;
}
.bug-tracker-close:hover {
color: #e0e0e0;
}
.bug-tracker-content {
flex: 1;
overflow-y: auto;
padding: 16px;
}
.bug-item {
background: #252525;
border: 1px solid #333;
border-radius: 8px;
padding: 12px;
margin-bottom: 12px;
transition: all 0.2s ease;
}
.bug-item:hover {
border-color: #4a9eff;
}
.bug-item.status-fixing {
border-color: #ffa94d;
background: #2a2520;
}
.bug-item.status-fixed {
border-color: #51cf66;
opacity: 0.7;
}
.bug-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
}
.bug-status {
font-size: 12px;
font-weight: 600;
padding: 4px 8px;
border-radius: 4px;
}
.status-detected {
background: rgba(255, 107, 107, 0.2);
color: #ff6b6b;
}
.status-fixing {
background: rgba(255, 169, 77, 0.2);
color: #ffa94d;
}
.status-fixed {
background: rgba(81, 207, 102, 0.2);
color: #51cf66;
}
.bug-time {
font-size: 11px;
color: #888;
}
.bug-count {
background: #ff6b6b;
color: white;
font-size: 10px;
font-weight: bold;
padding: 2px 6px;
border-radius: 10px;
}
.bug-message {
color: #e0e0e0;
font-size: 13px;
margin-bottom: 8px;
}
.bug-location {
color: #888;
font-size: 11px;
font-family: monospace;
}
.bug-fix-details {
color: #51cf66;
font-size: 12px;
margin-top: 8px;
padding: 8px;
background: rgba(81, 207, 102, 0.1);
border-radius: 4px;
}
.bug-fix-btn {
width: 100%;
padding: 8px;
background: linear-gradient(135deg, #4a9eff 0%, #a78bfa 100%);
border: none;
border-radius: 6px;
color: white;
font-size: 13px;
font-weight: 600;
cursor: pointer;
margin-top: 8px;
}
.bug-fix-btn:hover {
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(74, 158, 255, 0.4);
}
.bug-tracker-empty {
text-align: center;
padding: 40px 20px;
}
.empty-icon {
font-size: 48px;
margin-bottom: 16px;
}
.empty-title {
font-size: 18px;
font-weight: 600;
color: #e0e0e0;
margin-bottom: 8px;
}
.empty-subtitle {
font-size: 14px;
color: #888;
}
.activity-stream-header {
padding: 12px 20px;
border-bottom: 1px solid #333;
background: rgba(255, 107, 107, 0.05);
}
.activity-title {
font-size: 13px;
font-weight: 600;
color: #ff6b6b;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.activity-stream {
max-height: 200px;
overflow-y: auto;
padding: 12px;
background: #0d0d0d;
border-bottom: 1px solid #333;
}
.activity-item {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 12px;
margin-bottom: 6px;
background: #1a1a1a;
border-radius: 6px;
border-left: 3px solid #4a9eff;
transition: all 0.2s ease;
animation: slideIn 0.3s ease;
}
@keyframes slideIn {
from {
opacity: 0;
transform: translateX(-10px);
}
to {
opacity: 1;
transform: translateX(0);
}
}
.activity-item:hover {
background: #252525;
border-left-color: #a78bfa;
}
.activity-item.activity-error {
border-left-color: #ff6b6b;
background: rgba(255, 107, 107, 0.05);
}
.activity-item.activity-success {
border-left-color: #51cf66;
background: rgba(81, 207, 102, 0.05);
}
.activity-item.activity-warning {
border-left-color: #ffa94d;
background: rgba(255, 169, 77, 0.05);
}
.activity-icon {
font-size: 16px;
flex-shrink: 0;
}
.activity-message {
flex: 1;
font-size: 12px;
color: #e0e0e0;
line-height: 1.4;
}
.activity-time {
font-size: 10px;
color: #888;
flex-shrink: 0;
}
.bug-tracker-stats {
padding: 12px 20px;
border-bottom: 1px solid #333;
display: flex;
gap: 20px;
font-size: 12px;
background: #151515;
}
.stat-item {
display: flex;
align-items: center;
gap: 6px;
color: #888;
}
.stat-value {
font-weight: 600;
color: #e0e0e0;
}
`;
document.head.appendChild(style);
document.body.appendChild(toggleBtn);
document.body.appendChild(dashboard);
// Auto-update error count badge
setInterval(() => {
const badge = document.getElementById('bug-count-badge');
if (badge) {
const activeErrors = window.bugTracker.errors.filter(e => e.status !== 'fixed').length;
badge.textContent = activeErrors;
badge.style.display = activeErrors > 0 ? 'block' : 'none';
}
}, 1000);
}
// Initialize on DOM ready
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', createDashboard);
} else {
createDashboard();
}
console.log('[BugTracker] Real-time bug tracker initialized');
})();

View File

@@ -46,9 +46,8 @@ function enhanceChatInput() {
// Chat History & Session Management
// ============================================
// Load chat history with sessions
// loadChatHistory is now in chat-functions.js to avoid conflicts
// This file only provides the enhanced features (animations, quick actions, etc.)
// Auto-load chat history when page loads
(async function loadChatHistoryOnLoad() {
try {
const res = await fetch('/claude/api/claude/sessions');
const data = await res.json();
@@ -63,7 +62,7 @@ function enhanceChatInput() {
];
// Sort by creation date (newest first)
allSessions.sort((a, b) => new Date(b.createdAt || b.created_at) - new Date(a.createdAt || b.created_at));
allSessions.sort((a, b) => new Date(b.createdAt || b.created_at) - new Date(a.createdAt || a.created_at));
if (allSessions.length === 0) {
historyList.innerHTML = '<div class="chat-history-empty">No chat history yet</div>';
@@ -75,7 +74,7 @@ function enhanceChatInput() {
session.project ||
session.id.substring(0, 12) + '...';
const date = new Date(session.createdAt || session.created_at).toLocaleDateString();
const isActive = session.id === attachedSessionId;
const isActive = session.id === (window.attachedSessionId || null);
return `
<div class="chat-history-item ${isActive ? 'active' : ''} ${session.status === 'historical' ? 'historical' : ''}"
@@ -98,16 +97,18 @@ function enhanceChatInput() {
}).join('');
} catch (error) {
console.error('Error loading chat history:', error);
console.error('[loadChatHistoryOnLoad] Error loading chat history:', error);
}
}
})();
// Resume historical session
async function resumeSession(sessionId) {
console.log('Resuming historical session:', sessionId);
// Show loading message
appendSystemMessage('📂 Loading historical session...');
if (typeof appendSystemMessage === 'function') {
appendSystemMessage('📂 Loading historical session...');
}
try {
// Load the historical session
@@ -120,7 +121,9 @@ async function resumeSession(sessionId) {
// Handle 404 - session not found
if (res.status === 404) {
appendSystemMessage('❌ Session not found. It may have been deleted or the ID is incorrect.');
if (typeof appendSystemMessage === 'function') {
appendSystemMessage('❌ Session not found. It may have been deleted or the ID is incorrect.');
}
return;
}
@@ -139,48 +142,63 @@ async function resumeSession(sessionId) {
}
if (data.session) {
attachedSessionId = sessionId;
chatSessionId = sessionId;
if (typeof attachToSession === 'function') {
attachToSession(sessionId);
}
// Update UI
document.getElementById('current-session-id').textContent = sessionId;
const sessionIdEl = document.getElementById('current-session-id');
if (sessionIdEl) sessionIdEl.textContent = sessionId;
// Load session messages
clearChatDisplay();
if (typeof clearChatDisplay === 'function') {
clearChatDisplay();
}
// Add historical messages
if (data.session.outputBuffer && data.session.outputBuffer.length > 0) {
data.session.outputBuffer.forEach(entry => {
appendMessage('assistant', entry.content, false);
if (typeof appendMessage === 'function') {
appendMessage('assistant', entry.content, false);
}
});
}
// Show resume message
const sessionDate = new Date(data.session.createdAt || data.session.created_at);
appendSystemMessage('✅ Resumed historical session from ' + sessionDate.toLocaleString());
appendSystemMessage(' This is a read-only historical session. Start a new chat to continue working.');
if (typeof appendSystemMessage === 'function') {
appendSystemMessage('✅ Resumed historical session from ' + sessionDate.toLocaleString());
appendSystemMessage(' This is a read-only historical session. Start a new chat to continue working.');
}
// Update active state in sidebar
loadChatHistory();
if (typeof loadChatHistory === 'function') {
loadChatHistory();
}
// Subscribe to session (for any future updates)
subscribeToSession(sessionId);
if (typeof subscribeToSession === 'function') {
subscribeToSession(sessionId);
}
} else {
throw new Error('No session data in response');
}
} catch (error) {
console.error('Error resuming session:', error);
appendSystemMessage('❌ Failed to resume session: ' + error.message);
if (typeof appendSystemMessage === 'function') {
appendSystemMessage('❌ Failed to resume session: ' + error.message);
// Remove the loading message
const messagesContainer = document.getElementById('chat-messages');
const loadingMessages = messagesContainer.querySelectorAll('.chat-system');
loadingMessages.forEach(msg => {
if (msg.textContent.includes('Loading historical session')) {
msg.remove();
// Remove the loading message
const messagesContainer = document.getElementById('chat-messages');
if (messagesContainer) {
const loadingMessages = messagesContainer.querySelectorAll('.chat-system');
loadingMessages.forEach(msg => {
if (msg.textContent.includes('Loading historical session')) {
msg.remove();
}
});
}
});
}
}
}
@@ -219,7 +237,9 @@ function appendMessageWithAnimation(role, content, animate = true) {
messagesContainer.scrollTop = messagesContainer.scrollHeight;
// Update token usage
updateTokenUsage(content.length);
if (typeof updateTokenUsage === 'function') {
updateTokenUsage(content.length);
}
}
// Strip dyad tags from message for display
@@ -348,7 +368,11 @@ function executeQuickAction(action) {
input.value = prompt;
input.focus();
// Auto-send after short delay
setTimeout(() => sendChatMessage(), 300);
setTimeout(() => {
if (typeof sendChatMessage === 'function') {
sendChatMessage();
}
}, 300);
}
}
}
@@ -371,7 +395,6 @@ document.addEventListener('DOMContentLoaded', () => {
// Add our enhancements
setTimeout(() => {
enhanceChatInput();
loadChatHistory();
focusChatInput();
// Show quick actions on first load
@@ -390,7 +413,6 @@ const observer = new MutationObserver((mutations) => {
mutations.forEach((mutation) => {
if (mutation.target.id === 'chat-view' && mutation.target.classList.contains('active')) {
enhanceChatInput();
loadChatHistory();
focusChatInput();
}
});
@@ -414,9 +436,9 @@ if (document.readyState === 'loading') {
// Export functions
if (typeof window !== 'undefined') {
window.resumeSession = resumeSession;
window.loadChatHistory = loadChatHistory;
window.executeQuickAction = executeQuickAction;
window.showQuickActions = showQuickActions;
window.enhanceChatInput = enhanceChatInput;
window.focusChatInput = focusChatInput;
window.appendMessageWithAnimation = appendMessageWithAnimation;
}

View File

@@ -0,0 +1,422 @@
/**
* Enhanced Chat Interface - Similar to chat.z.ai
* Features: Better input, chat history, session resumption, smooth animations
*/
// ============================================
// Enhanced Chat Input Experience
// ============================================
// Auto-focus chat input when switching to chat view
function focusChatInput() {
setTimeout(() => {
const input = document.getElementById('chat-input');
if (input) {
input.focus();
// Move cursor to end
input.setSelectionRange(input.value.length, input.value.length);
}
}, 100);
}
// Smooth textarea resize with animation
function enhanceChatInput() {
const input = document.getElementById('chat-input');
if (!input) return;
// Auto-resize with smooth transition
input.style.transition = 'height 0.2s ease';
input.addEventListener('input', function() {
this.style.height = 'auto';
const newHeight = Math.min(this.scrollHeight, 200);
this.style.height = newHeight + 'px';
});
// Focus animation
input.addEventListener('focus', function() {
this.parentElement.classList.add('input-focused');
});
input.addEventListener('blur', function() {
this.parentElement.classList.remove('input-focused');
});
}
// ============================================
// Chat History & Session Management
// ============================================
// Load chat history with sessions
// loadChatHistory is now in chat-functions.js to avoid conflicts
// This file only provides the enhanced features (animations, quick actions, etc.)
try {
const res = await fetch('/claude/api/claude/sessions');
const data = await res.json();
const historyList = document.getElementById('chat-history-list');
if (!historyList) return;
// Combine active and historical sessions
const allSessions = [
...(data.active || []).map(s => ({...s, status: 'active'})),
...(data.historical || []).map(s => ({...s, status: 'historical'}))
];
// Sort by creation date (newest first)
allSessions.sort((a, b) => new Date(b.createdAt || b.created_at) - new Date(a.createdAt || b.created_at));
if (allSessions.length === 0) {
historyList.innerHTML = '<div class="chat-history-empty">No chat history yet</div>';
return;
}
historyList.innerHTML = allSessions.map(session => {
const title = session.metadata?.project ||
session.project ||
session.id.substring(0, 12) + '...';
const date = new Date(session.createdAt || session.created_at).toLocaleDateString();
const isActive = session.id === attachedSessionId;
return `
<div class="chat-history-item ${isActive ? 'active' : ''} ${session.status === 'historical' ? 'historical' : ''}"
onclick="${session.status === 'historical' ? `resumeSession('${session.id}')` : `attachToSession('${session.id}')`}">
<div class="chat-history-icon">
${session.status === 'historical' ? '📁' : '💬'}
</div>
<div class="chat-history-content">
<div class="chat-history-title">${title}</div>
<div class="chat-history-meta">
<span class="chat-history-date">${date}</span>
<span class="chat-history-status ${session.status}">
${session.status === 'historical' ? 'Historical' : 'Active'}
</span>
</div>
</div>
${session.status === 'historical' ? '<span class="resume-badge">Resume</span>' : ''}
</div>
`;
}).join('');
} catch (error) {
console.error('Error loading chat history:', error);
}
}
// Resume historical session
async function resumeSession(sessionId) {
console.log('Resuming historical session:', sessionId);
// Show loading message
appendSystemMessage('📂 Loading historical session...');
try {
// Load the historical session
const res = await fetch('/claude/api/claude/sessions/' + sessionId);
// Check if response is OK
if (!res.ok) {
const errorText = await res.text();
console.error('Session fetch error:', res.status, errorText);
// Handle 404 - session not found
if (res.status === 404) {
appendSystemMessage('❌ Session not found. It may have been deleted or the ID is incorrect.');
return;
}
throw new Error(`HTTP ${res.status}: ${errorText}`);
}
// Parse JSON with error handling
let data;
try {
data = await res.json();
} catch (jsonError) {
const responseText = await res.text();
console.error('JSON parse error:', jsonError);
console.error('Response text:', responseText);
throw new Error('Invalid JSON response from server');
}
if (data.session) {
attachedSessionId = sessionId;
chatSessionId = sessionId;
// Update UI
document.getElementById('current-session-id').textContent = sessionId;
// Load session messages
clearChatDisplay();
// Add historical messages
if (data.session.outputBuffer && data.session.outputBuffer.length > 0) {
data.session.outputBuffer.forEach(entry => {
appendMessage('assistant', entry.content, false);
});
}
// Show resume message
const sessionDate = new Date(data.session.createdAt || data.session.created_at);
appendSystemMessage('✅ Resumed historical session from ' + sessionDate.toLocaleString());
appendSystemMessage(' This is a read-only historical session. Start a new chat to continue working.');
// Update active state in sidebar
loadChatHistory();
// Subscribe to session (for any future updates)
subscribeToSession(sessionId);
} else {
throw new Error('No session data in response');
}
} catch (error) {
console.error('Error resuming session:', error);
appendSystemMessage('❌ Failed to resume session: ' + error.message);
// Remove the loading message
const messagesContainer = document.getElementById('chat-messages');
const loadingMessages = messagesContainer.querySelectorAll('.chat-system');
loadingMessages.forEach(msg => {
if (msg.textContent.includes('Loading historical session')) {
msg.remove();
}
});
}
}
// ============================================
// Enhanced Message Rendering
// ============================================
// Enhanced append with animations
function appendMessageWithAnimation(role, content, animate = true) {
const messagesContainer = document.getElementById('chat-messages');
if (!messagesContainer) return;
const messageDiv = document.createElement('div');
messageDiv.className = `chat-message chat-message-${role} ${animate ? 'message-appear' : ''}`;
const avatar = role === 'user' ? '👤' : '🤖';
const label = role === 'user' ? 'You' : 'Claude';
// Strip dyad tags for display
const displayContent = stripDyadTags(content);
messageDiv.innerHTML = `
<div class="message-avatar">${avatar}</div>
<div class="message-content">
<div class="message-header">
<span class="message-label">${label}</span>
<span class="message-time">${new Date().toLocaleTimeString()}</span>
</div>
<div class="message-text">${formatMessageText(displayContent)}</div>
</div>
`;
messagesContainer.appendChild(messageDiv);
// Scroll to bottom
messagesContainer.scrollTop = messagesContainer.scrollHeight;
// Update token usage
updateTokenUsage(content.length);
}
// Strip dyad tags from message for display
function stripDyadTags(content) {
let stripped = content;
// Remove dyad-write tags and replace with placeholder
stripped = stripped.replace(/<dyad-write\s+path="[^"]+">([\s\S]*?)<\/dyad-write>/g, (match, content) => {
return `
<div class="code-operation">
<div class="operation-header">
<span class="operation-icon">📄</span>
<span class="operation-label">Code generated</span>
</div>
<pre class="operation-code"><code>${escapeHtml(content.trim())}</code></pre>
</div>
`;
});
// Remove other dyad tags
stripped = stripped.replace(/<dyad-[^>]+>/g, (match) => {
const tagType = match.match(/dyad-(\w+)/)?.[1] || 'operation';
const icons = {
'rename': '✏️',
'delete': '🗑️',
'add-dependency': '📦',
'command': '⚡'
};
return `<span class="tag-placeholder">${icons[tagType] || '⚙️'} ${tagType}</span>`;
});
return stripped;
}
// Format message text with markdown-like rendering
function formatMessageText(text) {
// Basic markdown-like formatting
let formatted = escapeHtml(text);
// Code blocks
formatted = formatted.replace(/```(\w+)?\n([\s\S]*?)```/g, (match, lang, code) => {
return `<pre><code class="language-${lang || 'text'}">${escapeHtml(code.trim())}</code></pre>`;
});
// Inline code
formatted = formatted.replace(/`([^`]+)`/g, '<code>$1</code>');
// Bold
formatted = formatted.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>');
// Links
formatted = formatted.replace(/https?:\/\/[^\s]+/g, '<a href="$&" target="_blank">$&</a>');
// Line breaks
formatted = formatted.replace(/\n/g, '<br>');
return formatted;
}
// Escape HTML
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
// ============================================
// Quick Actions & Suggestions
// ============================================
// Show quick action suggestions
function showQuickActions() {
const messagesContainer = document.getElementById('chat-messages');
if (!messagesContainer) return;
const quickActions = document.createElement('div');
quickActions.className = 'quick-actions';
quickActions.innerHTML = `
<div class="quick-actions-title">💡 Quick Actions</div>
<div class="quick-actions-grid">
<button class="quick-action-btn" onclick="executeQuickAction('create-react')">
<span class="action-icon">⚛️</span>
<span class="action-label">Create React App</span>
</button>
<button class="quick-action-btn" onclick="executeQuickAction('create-nextjs')">
<span class="action-icon">▲</span>
<span class="action-label">Create Next.js App</span>
</button>
<button class="quick-action-btn" onclick="executeQuickAction('create-vue')">
<span class="action-icon">💚</span>
<span class="action-label">Create Vue App</span>
</button>
<button class="quick-action-btn" onclick="executeQuickAction('create-html')">
<span class="action-icon">📄</span>
<span class="action-label">Create HTML Page</span>
</button>
<button class="quick-action-btn" onclick="executeQuickAction('explain-code')">
<span class="action-icon">📖</span>
<span class="action-label">Explain Codebase</span>
</button>
<button class="quick-action-btn" onclick="executeQuickAction('fix-bug')">
<span class="action-icon">🐛</span>
<span class="action-label">Fix Bug</span>
</button>
</div>
`;
messagesContainer.appendChild(quickActions);
}
// Execute quick action
function executeQuickAction(action) {
const actions = {
'create-react': 'Create a React app with components and routing',
'create-nextjs': 'Create a Next.js app with server-side rendering',
'create-vue': 'Create a Vue 3 app with composition API',
'create-html': 'Create a responsive HTML5 page with modern styling',
'explain-code': 'Explain the codebase structure and main files',
'fix-bug': 'Help me fix a bug in my code'
};
const prompt = actions[action];
if (prompt) {
const input = document.getElementById('chat-input');
if (input) {
input.value = prompt;
input.focus();
// Auto-send after short delay
setTimeout(() => sendChatMessage(), 300);
}
}
}
// ============================================
// Enhanced Chat View Loading
// ============================================
// Hook into loadChatView to add enhancements
document.addEventListener('DOMContentLoaded', () => {
// Wait for chat-functions.js to load
setTimeout(() => {
// Override loadChatView to add enhancements
if (typeof window.loadChatView === 'function') {
const originalLoadChatView = window.loadChatView;
window.loadChatView = async function() {
// Call original function first
await originalLoadChatView.call(this);
// Add our enhancements
setTimeout(() => {
enhanceChatInput();
loadChatHistory();
focusChatInput();
// Show quick actions on first load
const messagesContainer = document.getElementById('chat-messages');
if (messagesContainer && messagesContainer.querySelector('.chat-welcome')) {
showQuickActions();
}
}, 100);
};
}
}, 1000);
});
// Auto-start enhancements when chat view is active
const observer = new MutationObserver((mutations) => {
mutations.forEach((mutation) => {
if (mutation.target.id === 'chat-view' && mutation.target.classList.contains('active')) {
enhanceChatInput();
loadChatHistory();
focusChatInput();
}
});
});
// Start observing after DOM loads
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', () => {
setTimeout(() => {
const chatView = document.getElementById('chat-view');
if (chatView) observer.observe(chatView, { attributes: true });
}, 1500);
});
} else {
setTimeout(() => {
const chatView = document.getElementById('chat-view');
if (chatView) observer.observe(chatView, { attributes: true });
}, 1500);
}
// Export functions
if (typeof window !== 'undefined') {
window.resumeSession = resumeSession;
window.loadChatHistory = loadChatHistory;
window.executeQuickAction = executeQuickAction;
window.showQuickActions = showQuickActions;
window.enhanceChatInput = enhanceChatInput;
window.focusChatInput = focusChatInput;
}

View File

@@ -60,7 +60,11 @@ async function loadChatView() {
}
const data = await res.json();
console.log('[loadChatView] Sessions data received:', data);
console.log('[loadChatView] Raw sessions data:', {
activeCount: (data.active || []).length,
historicalCount: (data.historical || []).length,
activeIds: (data.active || []).map(s => ({ id: s.id, status: s.status }))
});
const sessionsListEl = document.getElementById('chat-history-list');
@@ -72,12 +76,13 @@ async function loadChatView() {
// ONLY show active sessions - no historical sessions in chat view
// Historical sessions are read-only and can't receive new messages
let activeSessions = (data.active || []).filter(s => s.status === 'running');
console.log('[loadChatView] Running sessions after status filter:', activeSessions.length);
// Filter by current project if in project context
const currentProjectDir = window.currentProjectDir;
if (currentProjectDir) {
console.log('[loadChatView] Filtering sessions for project path:', currentProjectDir);
console.log('[loadChatView] Current project dir:', currentProjectDir);
// Filter sessions that belong to this project
activeSessions = activeSessions.filter(session => {
@@ -300,8 +305,10 @@ async function startNewChat() {
// Subscribe to session via WebSocket
subscribeToSession(data.session.id);
// Reload sessions list
loadChatView();
// Give backend time to persist session, then refresh sidebar
// This ensures the new session appears in the list
await new Promise(resolve => setTimeout(resolve, 150));
await loadChatView().catch(err => console.error('[startNewChat] Background refresh failed:', err));
// Hide the creation success message after a short delay
setTimeout(() => {
@@ -363,6 +370,26 @@ function subscribeToSession(sessionId) {
sessionId: sessionId
}));
console.log('Subscribed to session:', sessionId);
} else if (window.ws && window.ws.readyState === WebSocket.CONNECTING) {
// Wait for connection to open, then subscribe
console.log('[subscribeToSession] WebSocket connecting, will subscribe when ready...');
const onOpen = () => {
window.ws.send(JSON.stringify({
type: 'subscribe',
sessionId: sessionId
}));
console.log('[subscribeToSession] Subscribed after connection open:', sessionId);
window.ws.removeEventListener('open', onOpen);
};
window.ws.addEventListener('open', onOpen);
} else {
// WebSocket not connected - try to reconnect
console.warn('[subscribeToSession] WebSocket not connected, attempting to connect...');
if (typeof connectWebSocket === 'function') {
connectWebSocket();
// Retry subscription after connection
setTimeout(() => subscribeToSession(sessionId), 500);
}
}
}
@@ -503,9 +530,29 @@ async function sendChatMessage() {
if (!message) return;
// Auto-create session if none exists (OpenCode/CodeNomad hybrid approach)
if (!attachedSessionId) {
appendSystemMessage('Please start or attach to a session first.');
return;
console.log('[sendChatMessage] No session attached, auto-creating...');
appendSystemMessage('Creating new session...');
try {
await startNewChat();
// After session creation, wait a moment for attachment
await new Promise(resolve => setTimeout(resolve, 500));
// Verify session was created and attached
if (!attachedSessionId) {
appendSystemMessage('❌ Failed to create session. Please try again.');
return;
}
console.log('[sendChatMessage] Session auto-created:', attachedSessionId);
} catch (error) {
console.error('[sendChatMessage] Auto-create session failed:', error);
appendSystemMessage('❌ Failed to create session: ' + error.message);
return;
}
}
// Hide mode suggestion banner
@@ -605,7 +652,24 @@ async function sendChatMessage() {
console.log('Sending with metadata:', payload.metadata);
}
window.ws.send(JSON.stringify(payload));
// Debug logging before sending
console.log('[DEBUG] About to send command payload:', {
type: payload.type,
sessionId: payload.sessionId,
commandLength: payload.command?.length,
wsReady: window.wsReady,
wsState: window.ws?.readyState,
queueLength: window.messageQueue?.length || 0
});
// Use message queue to prevent race conditions
if (typeof queueMessage === 'function') {
queueMessage(payload);
console.log('[DEBUG] Message queued, queue length now:', window.messageQueue?.length);
} else {
window.ws.send(JSON.stringify(payload));
console.log('[DEBUG] Sent directly via WebSocket (no queue function)');
}
console.log('Sent command via WebSocket:', message.substring(0, 50));
} catch (error) {
console.error('Error sending message:', error);
@@ -624,12 +688,12 @@ function setGeneratingState(generating) {
if (generating) {
// Show stop button, hide send button
sendButton.classList.add('hidden');
stopButton.classList.remove('hidden');
if (sendButton) sendButton.classList.add('hidden');
if (stopButton) stopButton.classList.remove('hidden');
} else {
// Show send button, hide stop button
sendButton.classList.remove('hidden');
stopButton.classList.add('hidden');
if (sendButton) sendButton.classList.remove('hidden');
if (stopButton) stopButton.classList.add('hidden');
}
}
@@ -1066,10 +1130,16 @@ function clearInput() {
const wrapper = document.getElementById('chat-input-wrapper');
const charCountBadge = document.getElementById('char-count-badge');
input.value = '';
input.style.height = 'auto';
wrapper.classList.remove('typing');
charCountBadge.textContent = '0 chars';
if (input) {
input.value = '';
input.style.height = 'auto';
}
if (wrapper) {
wrapper.classList.remove('typing');
}
if (charCountBadge) {
charCountBadge.textContent = '0 chars';
}
}
// Update Token Usage

View File

@@ -0,0 +1,340 @@
/**
* Enhanced Chat Input Component Styles
* CodeNomad-style sophisticated prompt input
*/
/* === Chat Input Container === */
.chat-input-wrapper-enhanced {
display: flex;
flex-direction: column;
position: relative;
}
/* === Attachment Chips === */
.attachment-chips {
display: flex;
align-items: center;
gap: 8px;
padding: 8px;
overflow-x: auto;
overflow-y: hidden;
max-height: 120px;
scrollbar-width: thin;
scrollbar-color: #484f58 #161b22;
}
.attachment-chips::-webkit-scrollbar {
height: 6px;
}
.attachment-chips::-webkit-scrollbar-track {
background: #161b22;
border-radius: 3px;
}
.attachment-chips::-webkit-scrollbar-thumb {
background: #484f58;
border-radius: 3px;
}
.attachment-chips::-webkit-scrollbar-thumb:hover {
background: #6e7681;
}
.attachment-chips:empty {
display: none;
}
/* === Attachment Chip === */
.attachment-chip {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 10px;
background: #21262d;
border: 1px solid #30363d;
border-radius: 6px;
font-size: 13px;
color: #c9d1d9;
white-space: nowrap;
flex-shrink: 0;
}
.attachment-chip.image-chip {
padding: 4px;
}
.attachment-chip.image-chip img {
width: 32px;
height: 32px;
object-fit: cover;
border-radius: 4px;
}
.attachment-chip .chip-icon {
font-size: 14px;
}
.attachment-chip .chip-name,
.attachment-chip .chip-label {
font-weight: 500;
}
.attachment-chip .chip-info {
font-size: 11px;
color: #8b949e;
}
.attachment-chip .chip-remove {
display: flex;
align-items: center;
justify-content: center;
width: 18px;
height: 18px;
padding: 0;
background: transparent;
border: none;
color: #8b949e;
cursor: pointer;
border-radius: 3px;
font-size: 16px;
line-height: 1;
transition: all 0.15s ease;
}
.attachment-chip .chip-remove:hover {
background: #484f58;
color: #ffffff;
}
/* === Chat Input Wrapper === */
.chat-input-wrapper-enhanced .chat-input-wrapper {
display: flex;
align-items: flex-end;
gap: 8px;
padding: 8px;
background: #161b22;
border: 1px solid #30363d;
border-radius: 8px;
}
.chat-input-wrapper-enhanced .input-actions-left,
.chat-input-wrapper-enhanced .input-actions-right {
display: flex;
align-items: center;
gap: 4px;
}
.chat-input-wrapper-enhanced textarea {
flex: 1;
min-height: 24px;
max-height: 360px;
padding: 8px 12px;
background: #0d1117;
border: 1px solid #30363d;
border-radius: 6px;
color: #c9d1d9;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif;
font-size: 14px;
line-height: 1.5;
resize: none;
overflow-y: auto;
}
.chat-input-wrapper-enhanced textarea:focus {
outline: none;
border-color: #58a6ff;
box-shadow: 0 0 0 3px rgba(88, 166, 255, 0.1);
}
.chat-input-wrapper-enhanced .btn-attach,
.chat-input-wrapper-enhanced .btn-send {
display: flex;
align-items: center;
justify-content: center;
padding: 8px 16px;
border-radius: 6px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: all 0.15s ease;
}
.chat-input-wrapper-enhanced .btn-attach {
background: #21262d;
border: 1px solid #30363d;
color: #c9d1d9;
}
.chat-input-wrapper-enhanced .btn-attach:hover {
background: #30363d;
}
.chat-input-wrapper-enhanced .btn-send {
background: #1f6feb;
border: 1px solid #1f6feb;
color: #ffffff;
}
.chat-input-wrapper-enhanced .btn-send:hover {
background: #388bfd;
}
/* === Input Info Bar === */
.chat-input-wrapper-enhanced .input-info-bar {
display: flex;
align-items: center;
gap: 16px;
padding: 4px 8px;
font-size: 11px;
color: #8b949e;
}
.chat-input-wrapper-enhanced .token-count,
.chat-input-wrapper-enhanced .char-count {
white-space: nowrap;
}
/* === Unified Picker === */
.unified-picker {
position: absolute;
bottom: 100%;
left: 0;
right: 0;
max-height: 200px;
overflow-y: auto;
background: #161b22;
border: 1px solid #30363d;
border-radius: 8px 8px 0 0;
margin-bottom: 4px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.5);
}
.unified-picker.hidden {
display: none;
}
.picker-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 12px;
cursor: pointer;
transition: background 0.15s ease;
}
.picker-item:hover {
background: #21262d;
}
.picker-item .picker-label {
font-weight: 500;
}
.picker-item .picker-description {
font-size: 12px;
color: #8b949e;
}
/* === Mobile Responsive === */
@media (max-width: 640px) {
.attachment-chips {
max-height: 100px;
}
.attachment-chip {
font-size: 12px;
padding: 4px 8px;
}
.attachment-chip.image-chip img {
width: 24px;
height: 24px;
}
.attachment-chip .chip-remove {
width: 24px;
height: 24px;
}
.chat-input-wrapper-enhanced .chat-input-wrapper {
padding: 6px;
gap: 6px;
}
.chat-input-wrapper-enhanced textarea {
font-size: 16px; /* Prevent zoom on iOS */
}
.chat-input-wrapper-enhanced .btn-attach,
.chat-input-wrapper-enhanced .btn-send {
min-width: 44px;
min-height: 44px;
padding: 12px;
}
.chat-input-wrapper-enhanced .input-info-bar {
flex-wrap: wrap;
gap: 8px;
}
}
/* === Touch Targets === */
@media (hover: none) and (pointer: coarse) {
.attachment-chip .chip-remove {
width: 44px;
height: 44px;
}
}
/* === Focus Styles === */
.chat-input-wrapper-enhanced textarea:focus-visible,
.chat-input-wrapper-enhanced .btn-attach:focus-visible,
.chat-input-wrapper-enhanced .btn-send:focus-visible {
outline: 2px solid #58a6ff;
outline-offset: 2px;
}
/* === Queued Message Indicator === */
.queued-message-indicator {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
background: rgba(255, 107, 107, 0.15);
border: 1px solid rgba(255, 107, 107, 0.3);
border-radius: 6px;
margin-bottom: 8px;
font-size: 13px;
color: #ff6b6b;
animation: slideIn 0.3s ease;
}
.queued-message-indicator .indicator-icon {
font-size: 16px;
animation: pulse 1.5s ease-in-out infinite;
}
.queued-message-indicator .indicator-count {
font-weight: 600;
}
@keyframes slideIn {
from {
opacity: 0;
transform: translateY(-10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes pulse {
0%, 100% {
opacity: 1;
}
50% {
opacity: 0.5;
}
}

View File

@@ -0,0 +1,627 @@
/**
* Enhanced Chat Input Component
* CodeNomad-style sophisticated prompt input
*
* Features:
* - Expandable textarea (2-15 lines desktop, 2-4 mobile)
* - Attachment system (files, images, long text paste)
* - Draft persistence (session-aware localStorage)
* - History navigation (↑↓ arrows)
* - Unified picker (@files, /commands)
* - Shell mode (! prefix)
* - Token/char count
*/
class EnhancedChatInput {
constructor(containerId) {
this.container = document.getElementById(containerId);
if (!this.container) {
console.error('[ChatInput] Container not found:', containerId);
return;
}
this.state = {
value: '',
attachments: [],
drafts: new Map(),
history: [],
historyIndex: -1,
shellMode: false,
isMobile: this.detectMobile()
};
this.loadDrafts();
this.loadHistory();
this.initialize();
}
detectMobile() {
return window.innerWidth < 640 || 'ontouchstart' in window;
}
initialize() {
// Get existing textarea
const existingInput = this.container.querySelector('#chat-input');
if (!existingInput) {
console.error('[ChatInput] #chat-input not found');
return;
}
// Wrap existing input with enhanced UI
const wrapper = existingInput.parentElement;
wrapper.className = 'chat-input-wrapper-enhanced';
// Insert attachment chips container before the input
const chipsContainer = document.createElement('div');
chipsContainer.className = 'attachment-chips';
chipsContainer.id = 'attachment-chips';
wrapper.insertBefore(chipsContainer, existingInput);
// Update textarea attributes
existingInput.setAttribute('rows', '1');
existingInput.setAttribute('data-auto-expand', 'true');
this.textarea = existingInput;
this.chipsContainer = chipsContainer;
// Mobile viewport state
this.state.viewportHeight = window.innerHeight;
this.state.keyboardVisible = false;
this.state.initialViewportHeight = window.innerHeight;
this.setupEventListeners();
this.setupKeyboardDetection();
this.loadCurrentDraft();
}
setupKeyboardDetection() {
if (!this.state.isMobile) return;
// Detect virtual keyboard by tracking viewport changes
let resizeTimeout;
window.addEventListener('resize', () => {
clearTimeout(resizeTimeout);
resizeTimeout = setTimeout(() => {
this.handleViewportChange();
}, 100);
});
// Also listen to visual viewport API (better for mobile keyboards)
if (window.visualViewport) {
window.visualViewport.addEventListener('resize', () => {
this.handleViewportChange();
});
}
}
handleViewportChange() {
const currentHeight = window.innerHeight;
const initialHeight = this.state.initialViewportHeight;
const heightDiff = initialHeight - currentHeight;
// If viewport shrank by more than 150px, keyboard is likely visible
const keyboardVisible = heightDiff > 150;
if (keyboardVisible !== this.state.keyboardVisible) {
this.state.keyboardVisible = keyboardVisible;
console.log(`[ChatInput] Keyboard ${keyboardVisible ? 'visible' : 'hidden'}`);
// Re-calculate max lines when keyboard state changes
this.autoExpand();
}
this.state.viewportHeight = currentHeight;
}
calculateMaxLines() {
if (!this.state.isMobile) {
return 15; // Desktop default
}
// Mobile: Calculate based on available viewport height
const viewportHeight = this.state.viewportHeight;
const keyboardHeight = this.state.keyboardVisible
? (this.state.initialViewportHeight - viewportHeight)
: 0;
// Available height for input area (rough estimate)
// Leave space for: header (~60px), tabs (~50px), messages area, attachments
const availableHeight = viewportHeight - keyboardHeight - 200; // 200px for UI chrome
// Line height is approximately 24px
const lineHeight = 24;
const maxLines = Math.floor(availableHeight / lineHeight);
// Clamp between 2 and 4 lines for mobile
return Math.max(2, Math.min(4, maxLines));
}
setupEventListeners() {
if (!this.textarea) return;
// Auto-expand on input
this.textarea.addEventListener('input', () => {
this.autoExpand();
this.saveDraft();
this.checkTriggers();
this.updateCharCount();
});
// Handle paste events
this.textarea.addEventListener('paste', (e) => this.handlePaste(e));
// Handle keyboard shortcuts
this.textarea.addEventListener('keydown', (e) => {
// History navigation with ↑↓
if (e.key === 'ArrowUp' && !e.shiftKey) {
this.navigateHistory(-1);
e.preventDefault();
} else if (e.key === 'ArrowDown' && !e.shiftKey) {
this.navigateHistory(1);
e.preventDefault();
}
// Send with Enter (Shift+Enter for newline)
else if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
this.send();
}
// Detect shell mode (!)
else if (e.key === '!' && this.textarea.selectionStart === 0) {
this.state.shellMode = true;
this.updatePlaceholder();
}
});
// Handle file attachment button
const attachBtn = this.container.querySelector('.btn-icon[title="Attach file"], .btn-attach');
if (attachBtn) {
attachBtn.addEventListener('click', () => this.attachFile());
}
}
autoExpand() {
if (!this.textarea) return;
const maxLines = this.calculateMaxLines();
const lineHeight = 24; // pixels
const padding = 12; // padding
this.textarea.style.height = 'auto';
const newHeight = this.textarea.scrollHeight;
const minHeight = lineHeight + padding * 2;
const maxHeight = lineHeight * maxLines + padding * 2;
if (newHeight < minHeight) {
this.textarea.style.height = `${minHeight}px`;
} else if (newHeight > maxHeight) {
this.textarea.style.height = `${maxHeight}px`;
this.textarea.style.overflowY = 'auto';
} else {
this.textarea.style.height = `${newHeight}px`;
}
}
handlePaste(event) {
const items = event.clipboardData?.items;
if (!items) return;
// Check for images
for (const item of items) {
if (item.type.startsWith('image/')) {
event.preventDefault();
const file = item.getAsFile();
this.attachImageFile(file);
return;
}
}
// Check for long text paste
const pastedText = event.clipboardData.getData('text');
if (pastedText) {
const lines = pastedText.split('\n').length;
const chars = pastedText.length;
if (chars > 150 || lines > 3) {
event.preventDefault();
this.addPastedText(pastedText);
}
}
}
attachFile() {
const input = document.createElement('input');
input.type = 'file';
input.multiple = true;
input.accept = '*/*';
input.onchange = async (e) => {
const files = e.target.files;
for (const file of files) {
if (file.type.startsWith('image/')) {
await this.attachImageFile(file);
} else {
await this.attachTextFile(file);
}
}
};
input.click();
}
async attachImageFile(file) {
const reader = new FileReader();
reader.onload = (e) => {
const attachment = {
id: Date.now() + Math.random(),
type: 'image',
name: file.name,
size: file.size,
data: e.target.result
};
this.state.attachments.push(attachment);
this.renderAttachments();
this.saveDraft();
};
reader.readAsDataURL(file);
}
async attachTextFile(file) {
const text = await file.text();
const attachment = {
id: Date.now() + Math.random(),
type: 'file',
name: file.name,
size: file.size,
content: text
};
this.state.attachments.push(attachment);
this.renderAttachments();
this.saveDraft();
}
addPastedText(text) {
const attachment = {
id: Date.now() + Math.random(),
type: 'pasted',
label: `pasted #${this.state.attachments.filter(a => a.type === 'pasted').length + 1}`,
content: text,
chars: text.length,
lines: text.split('\n').length
};
this.state.attachments.push(attachment);
this.renderAttachments();
this.saveDraft();
}
removeAttachment(id) {
this.state.attachments = this.state.attachments.filter(a => a.id !== id);
this.renderAttachments();
this.saveDraft();
}
renderAttachments() {
if (!this.chipsContainer) return;
if (this.state.attachments.length === 0) {
this.chipsContainer.innerHTML = '';
return;
}
this.chipsContainer.innerHTML = this.state.attachments.map(a => {
if (a.type === 'image') {
return `
<div class="attachment-chip image-chip" data-id="${a.id}">
<img src="${a.data}" alt="${a.name}" />
<button class="chip-remove" title="Remove">×</button>
</div>
`;
} else if (a.type === 'file') {
return `
<div class="attachment-chip file-chip" data-id="${a.id}">
<span class="chip-icon">📄</span>
<span class="chip-name">${this.escapeHtml(a.name)}</span>
<button class="chip-remove" title="Remove">×</button>
</div>
`;
} else if (a.type === 'pasted') {
return `
<div class="attachment-chip pasted-chip" data-id="${a.id}">
<span class="chip-icon">📋</span>
<span class="chip-label">${this.escapeHtml(a.label)}</span>
<span class="chip-info">${a.chars} chars, ${a.lines} lines</span>
<button class="chip-remove" title="Remove">×</button>
</div>
`;
}
return '';
}).join('');
// Add click handlers
this.chipsContainer.querySelectorAll('.chip-remove').forEach(btn => {
btn.addEventListener('click', (e) => {
const chip = e.target.closest('.attachment-chip');
if (chip) {
this.removeAttachment(parseFloat(chip.dataset.id));
}
});
});
}
checkTriggers() {
if (!this.textarea) return;
const value = this.textarea.value;
const cursorPos = this.textarea.selectionStart;
// Check for @ trigger (file mentions)
const atMatch = value.substring(0, cursorPos).match(/@(\w*)$/);
if (atMatch && atMatch[0].length > 1) {
console.log('[ChatInput] File mention triggered:', atMatch[1]);
// TODO: Show file picker
}
// Check for / trigger (slash commands)
const slashMatch = value.substring(0, cursorPos).match(/\/(\w*)$/);
if (slashMatch && slashMatch[0].length > 1) {
console.log('[ChatInput] Command triggered:', slashMatch[1]);
// TODO: Show command picker
}
}
navigateHistory(direction) {
if (this.state.history.length === 0) return;
let newIndex;
if (direction === -1) {
newIndex = Math.min(this.state.historyIndex + 1, this.state.history.length - 1);
} else {
newIndex = Math.max(this.state.historyIndex - 1, -1);
}
this.state.historyIndex = newIndex;
if (newIndex === -1) {
this.textarea.value = this.state.value;
} else {
const index = this.state.history.length - 1 - newIndex;
this.textarea.value = this.state.history[index];
}
this.autoExpand();
}
// Session-aware draft storage
getDraftKey() {
const sessionId = this.getCurrentSessionId();
return `claude-ide.drafts.${sessionId}`;
}
saveDraft() {
const sessionId = this.getCurrentSessionId();
if (!sessionId) return;
const draft = {
value: this.textarea.value,
attachments: this.state.attachments,
timestamp: Date.now(),
sessionId: sessionId
};
this.state.drafts.set(sessionId, draft);
try {
localStorage.setItem(this.getDraftKey(), JSON.stringify(draft));
// Clean up old drafts from other sessions
this.cleanupOldDrafts(sessionId);
} catch (e) {
console.error('[ChatInput] Failed to save draft:', e);
}
}
cleanupOldDrafts(currentSessionId) {
try {
const allKeys = Object.keys(localStorage);
const draftKeys = allKeys.filter(k => k.startsWith('claude-ide.drafts.'));
// Keep only recent drafts (last 5 sessions)
const drafts = draftKeys.map(key => {
try {
return { key, data: JSON.parse(localStorage.getItem(key)) };
} catch {
return null;
}
}).filter(d => d && d.data.sessionId !== currentSessionId);
// Sort by timestamp
drafts.sort((a, b) => b.data.timestamp - a.data.timestamp);
// Remove old drafts beyond 5
drafts.slice(5).forEach(d => {
localStorage.removeItem(d.key);
});
} catch (e) {
console.error('[ChatInput] Failed to cleanup drafts:', e);
}
}
loadDrafts() {
try {
const allKeys = Object.keys(localStorage);
const draftKeys = allKeys.filter(k => k.startsWith('claude-ide.drafts.'));
draftKeys.forEach(key => {
try {
const draft = JSON.parse(localStorage.getItem(key));
if (draft && draft.sessionId) {
this.state.drafts.set(draft.sessionId, draft);
}
} catch (e) {
// Skip invalid drafts
}
});
} catch (e) {
console.error('[ChatInput] Failed to load drafts:', e);
}
}
loadCurrentDraft() {
const sessionId = this.getCurrentSessionId();
if (!sessionId) return;
const draft = this.state.drafts.get(sessionId);
if (draft) {
this.textarea.value = draft.value || '';
this.state.attachments = draft.attachments || [];
this.renderAttachments();
this.autoExpand();
// Show restore notification if draft is old (> 5 minutes)
const age = Date.now() - draft.timestamp;
if (age > 5 * 60 * 1000 && draft.value) {
this.showDraftRestoreNotification();
}
}
}
showDraftRestoreNotification() {
if (typeof showToast === 'function') {
showToast('Draft restored from previous session', 'info', 3000);
}
}
clearDraft() {
const sessionId = this.getCurrentSessionId();
if (sessionId) {
this.state.drafts.delete(sessionId);
localStorage.removeItem(this.getDraftKey());
}
}
saveHistory() {
const value = this.textarea.value.trim();
if (!value) return;
this.state.history.push(value);
this.state.historyIndex = -1;
// Limit history to 100 items
if (this.state.history.length > 100) {
this.state.history.shift();
}
localStorage.setItem('chat-history', JSON.stringify(this.state.history));
}
loadHistory() {
try {
const stored = localStorage.getItem('chat-history');
if (stored) {
this.state.history = JSON.parse(stored);
}
} catch (e) {
console.error('[ChatInput] Failed to load history:', e);
}
}
getCurrentSessionId() {
return window.attachedSessionId || window.currentSessionId || null;
}
updatePlaceholder() {
if (!this.textarea) return;
if (this.state.shellMode) {
this.textarea.placeholder = 'Shell mode: enter shell command... (Enter to send)';
} else {
this.textarea.placeholder = 'Type your message to Claude Code... (@ for files, / for commands, Enter to send)';
}
}
updateCharCount() {
const value = this.textarea.value;
const charCountEl = this.container.querySelector('#char-count');
if (charCountEl) {
charCountEl.textContent = `${value.length} chars`;
}
// Token count (rough estimation: 1 token ≈ 4 chars)
const tokenCountEl = this.container.querySelector('#token-usage');
if (tokenCountEl) {
const tokens = Math.ceil(value.length / 4);
tokenCountEl.textContent = `${tokens} tokens`;
}
}
send() {
const content = this.textarea.value.trim();
const hasAttachments = this.state.attachments.length > 0;
if (!content && !hasAttachments) return;
// Get the send button and trigger click
const sendBtn = this.container.querySelector('.btn-send, .btn-primary[onclick*="sendChatMessage"]');
if (sendBtn) {
sendBtn.click();
} else if (typeof sendChatMessage === 'function') {
// Call the function directly
sendChatMessage();
}
// Save to history
this.saveHistory();
// Clear input
this.textarea.value = '';
this.state.attachments = [];
this.state.shellMode = false;
this.renderAttachments();
this.clearDraft();
this.autoExpand();
this.updatePlaceholder();
this.updateCharCount();
}
escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
destroy() {
this.saveDraft();
this.state = null;
}
}
// Global instance
let enhancedChatInput = null;
// Initialize when DOM is ready
function initEnhancedChatInput() {
enhancedChatInput = new EnhancedChatInput('chat-input-container');
}
// Export to window
if (typeof window !== 'undefined') {
window.EnhancedChatInput = EnhancedChatInput;
window.enhancedChatInput = null;
// Auto-initialize
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', () => {
initEnhancedChatInput();
window.enhancedChatInput = enhancedChatInput;
});
} else {
initEnhancedChatInput();
window.enhancedChatInput = enhancedChatInput;
}
}
// Export for use in other scripts
if (typeof module !== 'undefined' && module.exports) {
module.exports = { EnhancedChatInput };
}

View File

@@ -0,0 +1,434 @@
/**
* Monaco Editor Component Styles
* Mobile-first responsive design
*/
/* === Monaco Editor Container === */
.monaco-editor-container {
display: flex;
flex-direction: column;
height: 100%;
background: #1e1e1e;
color: #d4d4d4;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif;
overflow: hidden;
}
/* === Editor Header (Tabs + Actions) === */
.editor-tabs-wrapper {
display: flex;
align-items: center;
background: #252526;
border-bottom: 1px solid #3c3c3c;
min-height: 35px;
}
.editor-tabs {
display: flex;
align-items: center;
flex: 1;
overflow-x: auto;
overflow-y: hidden;
scrollbar-width: thin;
scrollbar-color: #5a5a5a #252526;
}
.editor-tabs::-webkit-scrollbar {
height: 8px;
}
.editor-tabs::-webkit-scrollbar-track {
background: #252526;
}
.editor-tabs::-webkit-scrollbar-thumb {
background: #5a5a5a;
border-radius: 4px;
}
.editor-tabs::-webkit-scrollbar-thumb:hover {
background: #6e6e6e;
}
.editor-tabs-actions {
display: flex;
align-items: center;
padding: 0 8px;
gap: 4px;
border-left: 1px solid #3c3c3c;
}
/* === Monaco Editor Tabs === */
.editor-tab {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 12px;
background: transparent;
border: none;
border-right: 1px solid #3c3c3c;
cursor: pointer;
font-size: 13px;
color: #969696;
transition: background 0.15s ease, color 0.15s ease;
white-space: nowrap;
user-select: none;
min-width: fit-content;
}
.editor-tab:hover {
background: #2a2d2e;
color: #d4d4d4;
}
.editor-tab.active {
background: #1e1e1e;
color: #ffffff;
border-top: 1px solid #007acc;
}
.editor-tab.dirty .tab-name {
color: #e3b341;
}
.editor-tab.dirty .tab-dirty-indicator {
color: #e3b341;
}
.tab-name {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
max-width: 200px;
}
.tab-dirty-indicator {
font-size: 10px;
animation: pulse 2s ease-in-out infinite;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
.tab-close {
display: flex;
align-items: center;
justify-content: center;
width: 18px;
height: 18px;
padding: 0;
background: transparent;
border: none;
color: #969696;
cursor: pointer;
border-radius: 3px;
font-size: 16px;
line-height: 1;
transition: all 0.15s ease;
}
.tab-close:hover {
background: #3c3c3c;
color: #ffffff;
}
/* === Editor Content Area === */
.editor-content-wrapper {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
position: relative;
}
.editor-content {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
position: relative;
}
.monaco-editor-instance {
height: 100%;
width: 100%;
overflow: hidden;
}
/* === Editor Placeholder === */
.editor-placeholder {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
color: #6e6e6e;
text-align: center;
padding: 2rem;
}
.placeholder-icon {
font-size: 4rem;
margin-bottom: 1rem;
opacity: 0.5;
}
.editor-placeholder h2 {
font-size: 1.5rem;
font-weight: 500;
margin-bottom: 0.5rem;
color: #858585;
}
.editor-placeholder p {
font-size: 1rem;
color: #6e6e6e;
}
/* === Mobile File View (Fallback) === */
.mobile-file-view {
height: 100%;
overflow-y: auto;
padding: 1rem;
}
.mobile-file-view .file-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 1rem;
background: #252526;
border-radius: 6px;
margin-bottom: 1rem;
}
.mobile-file-view h3 {
font-size: 1rem;
margin: 0;
}
.language-badge {
padding: 4px 8px;
background: #007acc;
color: white;
border-radius: 4px;
font-size: 0.75rem;
}
.mobile-file-view .code-content {
background: #1e1e1e;
border-radius: 6px;
padding: 1rem;
overflow-x: auto;
}
.mobile-file-view code {
font-family: 'Fira Code', 'JetBrains Mono', 'SF Mono', 'Menlo', 'Consolas', monospace;
font-size: 0.875rem;
line-height: 1.6;
color: #d4d4d4;
}
/* === Editor Statusbar === */
.editor-statusbar {
display: flex;
align-items: center;
gap: 1rem;
padding: 4px 12px;
background: #007acc;
color: #ffffff;
font-size: 12px;
min-height: 22px;
}
.statusbar-item {
white-space: nowrap;
}
/* === Action Buttons === */
.btn-icon {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
padding: 0;
background: transparent;
border: none;
color: #969696;
cursor: pointer;
border-radius: 4px;
font-size: 14px;
transition: all 0.15s ease;
}
.btn-icon:hover {
background: #3c3c3c;
color: #ffffff;
}
.btn-icon:active {
transform: scale(0.95);
}
/* === Mobile Responsive === */
@media (max-width: 640px) {
.editor-tabs-wrapper {
flex-direction: column;
align-items: stretch;
}
.editor-tabs {
border-right: none;
border-bottom: 1px solid #3c3c3c;
}
.editor-tabs-actions {
border-left: none;
border-top: 1px solid #3c3c3c;
padding: 4px;
justify-content: center;
}
.editor-tab {
padding: 10px 8px;
font-size: 12px;
}
.tab-name {
max-width: 120px;
}
.tab-close {
width: 28px;
height: 28px;
}
.btn-icon {
width: 32px;
height: 32px;
}
.editor-placeholder h2 {
font-size: 1.25rem;
}
.editor-placeholder p {
font-size: 0.875rem;
}
.editor-statusbar {
flex-wrap: wrap;
gap: 0.5rem;
}
}
/* === Tablet Responsive === */
@media (min-width: 641px) and (max-width: 1024px) {
.tab-name {
max-width: 150px;
}
}
/* === Touch Targets (Mobile) === */
@media (hover: none) and (pointer: coarse) {
.editor-tab {
padding: 12px;
min-height: 44px;
}
.tab-close {
width: 44px;
height: 44px;
}
.btn-icon {
width: 44px;
height: 44px;
}
}
/* === File Error State === */
.file-error {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
padding: 2rem;
color: #f85149;
text-align: center;
}
.file-error h2 {
font-size: 1.5rem;
margin-bottom: 0.5rem;
}
.file-error p {
color: #f85149;
}
/* === Loading Spinner === */
.loading {
width: 40px;
height: 40px;
border: 3px solid #3c3c3c;
border-top-color: #007acc;
border-radius: 50%;
animation: spin 0.8s linear infinite;
margin: 2rem auto;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
/* === Focus Styles for Accessibility === */
.editor-tab:focus-visible,
.tab-close:focus-visible,
.btn-icon:focus-visible {
outline: 2px solid #007acc;
outline-offset: 2px;
}
/* === Dark Mode Scrollbar === */
.monaco-editor-instance ::-webkit-scrollbar {
width: 14px;
height: 14px;
}
.monaco-editor-instance ::-webkit-scrollbar-track {
background: #1e1e1e;
}
.monaco-editor-instance ::-webkit-scrollbar-thumb {
background: #424242;
border-radius: 7px;
border: 3px solid #1e1e1e;
}
.monaco-editor-instance ::-webkit-scrollbar-thumb:hover {
background: #4f4f4f;
}
.monaco-editor-instance ::-webkit-scrollbar-corner {
background: #1e1e1e;
}
/* === Print Styles === */
@media print {
.editor-tabs-wrapper,
.editor-statusbar {
display: none;
}
.editor-content {
height: auto;
overflow: visible;
}
}

View File

@@ -0,0 +1,660 @@
/**
* Monaco Editor Component
* VS Code's editor in the browser with tab system
*
* Features:
* - Tab-based multi-file editing
* - Syntax highlighting for 100+ languages
* - Auto-save on Ctrl+S
* - Dirty state indicators
* - Mobile responsive (CodeMirror fallback on touch devices)
*/
class MonacoEditor {
constructor(containerId) {
this.container = document.getElementById(containerId);
if (!this.container) {
console.error('[MonacoEditor] Container not found:', containerId);
return;
}
this.editors = new Map(); // tabId -> editor instance
this.models = new Map(); // tabId -> model instance
this.tabs = [];
this.activeTab = null;
this.monaco = null;
this.isMobile = this.detectMobile();
this.initialized = false;
}
detectMobile() {
// Check for actual mobile device (not just touch-enabled laptop)
const isMobile = /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent);
// Also check screen width as additional heuristic
const isSmallScreen = window.innerWidth < 768;
return isMobile || isSmallScreen;
}
async initialize() {
if (this.initialized) return;
if (this.isMobile) {
// Use CodeMirror for mobile (touch-friendly)
console.log('[MonacoEditor] Mobile detected, using fallback');
this.initializeFallback();
return;
}
try {
// Wrap AMD loader in promise
await new Promise((resolve, reject) => {
// Configure Monaco loader
require.config({
paths: {
'vs': 'https://unpkg.com/monaco-editor@0.45.0/min/vs'
}
});
// Load Monaco
require(['vs/editor/editor.main'], (monaco) => {
this.monaco = monaco;
this.setupContainer();
this.setupKeyboardShortcuts();
this.loadPersistedTabs();
this.initialized = true;
console.log('[MonacoEditor] Initialized successfully');
resolve();
}, (error) => {
console.error('[MonacoEditor] AMD loader error:', error);
reject(error);
});
});
} catch (error) {
console.error('[MonacoEditor] Failed to initialize:', error);
this.initializeFallback();
this.initialized = true;
}
}
setupContainer() {
this.container.innerHTML = `
<div class="monaco-editor-container">
<div class="editor-tabs-wrapper">
<div class="editor-tabs" id="editor-tabs"></div>
<div class="editor-tabs-actions">
<button class="btn-icon" id="btn-save-current" title="Save (Ctrl+S)" style="display: none;">💾</button>
<button class="btn-icon" id="btn-save-all" title="Save All (Ctrl+Shift+S)">💾</button>
<button class="btn-icon" id="btn-close-all" title="Close All">✕</button>
</div>
</div>
<div class="editor-content-wrapper">
<div class="editor-content" id="editor-content">
<div class="editor-placeholder">
<div class="placeholder-icon">📄</div>
<h2>No file open</h2>
<p>Select a file from the sidebar to start editing</p>
<p style="font-size: 0.9em; opacity: 0.7; margin-top: 8px;">Files are automatically editable</p>
</div>
</div>
</div>
<div class="editor-statusbar">
<span class="statusbar-item" id="statusbar-cursor">Ln 1, Col 1</span>
<span class="statusbar-item" id="statusbar-language">Plain Text</span>
<span class="statusbar-item" id="statusbar-file">No file</span>
<span class="statusbar-item" id="statusbar-editable" style="display: none;">✓ Editable</span>
</div>
</div>
`;
// Event listeners
const saveCurrentBtn = this.container.querySelector('#btn-save-current');
if (saveCurrentBtn) {
saveCurrentBtn.addEventListener('click', () => this.saveCurrentFile());
}
const saveAllBtn = this.container.querySelector('#btn-save-all');
if (saveAllBtn) {
saveAllBtn.addEventListener('click', () => this.saveAllFiles());
}
const closeAllBtn = this.container.querySelector('#btn-close-all');
if (closeAllBtn) {
closeAllBtn.addEventListener('click', () => this.closeAllTabs());
}
}
setupKeyboardShortcuts() {
// Ctrl+S to save
document.addEventListener('keydown', (e) => {
if ((e.ctrlKey || e.metaKey) && e.key === 's') {
e.preventDefault();
this.saveCurrentFile();
}
// Ctrl+W to close tab
if ((e.ctrlKey || e.metaKey) && e.key === 'w') {
e.preventDefault();
this.closeCurrentTab();
}
});
}
getLanguageFromFile(filePath) {
const ext = filePath.split('.').pop().toLowerCase();
const languageMap = {
'js': 'javascript',
'jsx': 'javascript',
'ts': 'typescript',
'tsx': 'typescript',
'py': 'python',
'html': 'html',
'htm': 'html',
'css': 'css',
'scss': 'scss',
'sass': 'scss',
'json': 'json',
'md': 'markdown',
'markdown': 'markdown',
'xml': 'xml',
'yaml': 'yaml',
'yml': 'yaml',
'sql': 'sql',
'sh': 'shell',
'bash': 'shell',
'zsh': 'shell',
'txt': 'plaintext'
};
return languageMap[ext] || 'plaintext';
}
async openFile(filePath, content) {
if (!this.initialized && !this.isMobile) {
await this.initialize();
}
if (this.isMobile) {
this.openFileFallback(filePath, content);
return;
}
// Check if already open
const existingTab = this.tabs.find(tab => tab.path === filePath);
if (existingTab) {
this.activateTab(existingTab.id);
return;
}
// Create new tab
const tabId = `tab-${Date.now()}`;
const tab = {
id: tabId,
path: filePath,
name: filePath.split('/').pop(),
dirty: false,
originalContent: content || ''
};
this.tabs.push(tab);
// Create Monaco model
const language = this.getLanguageFromFile(filePath);
const model = this.monaco.editor.createModel(content || '', language, monaco.Uri.parse(filePath));
this.models.set(tabId, model);
// Create editor instance
const contentArea = this.container.querySelector('#editor-content');
// Remove placeholder
const placeholder = contentArea.querySelector('.editor-placeholder');
if (placeholder) placeholder.remove();
// Create editor container
const editorContainer = document.createElement('div');
editorContainer.className = 'monaco-editor-instance';
editorContainer.style.display = 'none';
contentArea.appendChild(editorContainer);
// Create editor
const editor = this.monaco.editor.create(editorContainer, {
model: model,
theme: 'vs-dark',
automaticLayout: true,
fontSize: 14,
fontFamily: "'Fira Code', 'JetBrains Mono', 'SF Mono', 'Menlo', 'Consolas', monaco",
lineNumbers: 'on',
minimap: { enabled: true },
scrollBeyondLastLine: false,
wordWrap: 'off',
tabSize: 4,
renderWhitespace: 'selection',
cursorStyle: 'line',
folding: true,
bracketPairColorization: { enabled: true },
guides: {
indentation: true,
bracketPairs: true
}
});
// Track cursor position
editor.onDidChangeCursorPosition((e) => {
this.updateCursorPosition(e.position);
});
// Track content changes
model.onDidChangeContent(() => {
this.markDirty(tabId);
});
this.editors.set(tabId, editor);
// Activate the new tab
this.activateTab(tabId);
// Persist tabs
this.saveTabsToStorage();
return tabId;
}
activateTab(tabId) {
if (!this.editors.has(tabId)) {
console.error('[MonacoEditor] Tab not found:', tabId);
return;
}
// Hide all editors
this.editors.forEach((editor, id) => {
const container = editor.getDomNode();
if (container) {
container.style.display = id === tabId ? 'block' : 'none';
}
});
this.activeTab = tabId;
this.renderTabs();
this.updateStatusbar(tabId);
// Show save button for current file and editable indicator
const tab = this.tabs.find(t => t.id === tabId);
const saveCurrentBtn = this.container.querySelector('#btn-save-current');
const editableIndicator = this.container.querySelector('#statusbar-editable');
if (saveCurrentBtn) {
saveCurrentBtn.style.display = 'inline-flex';
saveCurrentBtn.title = `Save ${tab?.name || 'file'} (Ctrl+S)`;
}
if (editableIndicator) {
editableIndicator.style.display = 'inline-flex';
editableIndicator.textContent = tab?.dirty ? '● Unsaved changes' : '✓ Editable';
editableIndicator.style.color = tab?.dirty ? '#f48771' : '#4ec9b0';
}
// Focus the active editor and ensure it's not read-only
const editor = this.editors.get(tabId);
if (editor) {
editor.focus();
editor.updateOptions({ readOnly: false });
}
}
closeTab(tabId) {
const tab = this.tabs.find(t => t.id === tabId);
if (!tab) return;
// Check for unsaved changes
if (tab.dirty) {
const shouldSave = confirm(`Save changes to ${tab.name} before closing?`);
if (shouldSave) {
this.saveFile(tabId);
}
}
// Dispose editor and model
const editor = this.editors.get(tabId);
if (editor) {
editor.dispose();
this.editors.delete(tabId);
}
const model = this.models.get(tabId);
if (model) {
model.dispose();
this.models.delete(tabId);
}
// Remove tab from list
this.tabs = this.tabs.filter(t => t.id !== tabId);
// If we closed the active tab, activate another one
if (this.activeTab === tabId) {
if (this.tabs.length > 0) {
this.activateTab(this.tabs[0].id);
} else {
this.activeTab = null;
this.showPlaceholder();
}
}
this.renderTabs();
this.saveTabsToStorage();
}
closeCurrentTab() {
if (this.activeTab) {
this.closeTab(this.activeTab);
}
}
closeAllTabs() {
if (this.tabs.length === 0) return;
const hasUnsaved = this.tabs.some(t => t.dirty);
if (hasUnsaved) {
const shouldSaveAll = confirm('Some files have unsaved changes. Save all before closing?');
if (shouldSaveAll) {
this.saveAllFiles();
}
}
// Dispose all editors and models
this.editors.forEach(editor => editor.dispose());
this.models.forEach(model => model.dispose());
this.editors.clear();
this.models.clear();
this.tabs = [];
this.activeTab = null;
this.renderTabs();
this.showPlaceholder();
this.saveTabsToStorage();
}
async saveFile(tabId) {
const tab = this.tabs.find(t => t.id === tabId);
if (!tab) return;
const model = this.models.get(tabId);
if (!model) return;
const content = model.getValue();
try {
const response = await fetch(`/claude/api/file/${encodeURIComponent(tab.path)}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ content })
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const data = await response.json();
if (data.error) {
throw new Error(data.error);
}
// Update tab state
tab.dirty = false;
tab.originalContent = content;
this.renderTabs();
// Show success toast
if (typeof showToast === 'function') {
showToast(`✅ Saved ${tab.name}`, 'success', 2000);
}
return true;
} catch (error) {
console.error('[MonacoEditor] Error saving file:', error);
if (typeof showToast === 'function') {
showToast(`❌ Failed to save ${tab.name}: ${error.message}`, 'error', 3000);
}
return false;
}
}
async saveCurrentFile() {
if (this.activeTab) {
await this.saveFile(this.activeTab);
}
}
async saveAllFiles() {
const dirtyTabs = this.tabs.filter(t => t.dirty);
if (dirtyTabs.length === 0) {
if (typeof showToast === 'function') {
showToast('No unsaved changes', 'info', 2000);
}
return;
}
let saved = 0;
let failed = 0;
for (const tab of dirtyTabs) {
const result = await this.saveFile(tab.id);
if (result) {
saved++;
} else {
failed++;
}
}
if (typeof showToast === 'function') {
if (failed === 0) {
showToast(`✅ Saved ${saved} file${saved > 1 ? 's' : ''}`, 'success', 2000);
} else {
showToast(`⚠️ Saved ${saved} file${saved > 1 ? 's' : ''}, ${failed} failed`, 'warning', 3000);
}
}
}
markDirty(tabId) {
const tab = this.tabs.find(t => t.id === tabId);
if (tab && !tab.dirty) {
tab.dirty = true;
this.renderTabs();
}
}
updateCursorPosition(position) {
const cursorEl = this.container.querySelector('#statusbar-cursor');
if (cursorEl && position) {
cursorEl.textContent = `Ln ${position.lineNumber}, Col ${position.column}`;
}
}
updateStatusbar(tabId) {
const tab = this.tabs.find(t => t.id === tabId);
if (!tab) return;
const fileEl = this.container.querySelector('#statusbar-file');
const langEl = this.container.querySelector('#statusbar-language');
if (fileEl) {
fileEl.textContent = tab.path;
}
if (langEl) {
const language = this.getLanguageFromFile(tab.path);
langEl.textContent = language.charAt(0).toUpperCase() + language.slice(1);
}
}
renderTabs() {
const tabsContainer = this.container.querySelector('#editor-tabs');
if (!tabsContainer) return;
if (this.tabs.length === 0) {
tabsContainer.innerHTML = '';
return;
}
tabsContainer.innerHTML = this.tabs.map(tab => `
<div class="editor-tab ${tab.id === this.activeTab ? 'active' : ''} ${tab.dirty ? 'dirty' : ''}"
data-tab-id="${tab.id}"
title="${this.escapeHtml(tab.path)}">
<span class="tab-name">${this.escapeHtml(tab.name)}</span>
${tab.dirty ? '<span class="tab-dirty-indicator">●</span>' : ''}
<button class="tab-close" title="Close tab">×</button>
</div>
`).join('');
// Tab click handlers
tabsContainer.querySelectorAll('.editor-tab').forEach(tabEl => {
tabEl.addEventListener('click', (e) => {
if (!e.target.classList.contains('tab-close')) {
this.activateTab(tabEl.dataset.tabId);
}
});
const closeBtn = tabEl.querySelector('.tab-close');
if (closeBtn) {
closeBtn.addEventListener('click', (e) => {
e.stopPropagation();
this.closeTab(tabEl.dataset.tabId);
});
}
});
}
showPlaceholder() {
const contentArea = this.container.querySelector('#editor-content');
if (contentArea) {
contentArea.innerHTML = `
<div class="editor-placeholder">
<div class="placeholder-icon">📄</div>
<h2>No file open</h2>
<p>Select a file from the sidebar to start editing</p>
</div>
`;
}
}
saveTabsToStorage() {
const tabsData = this.tabs.map(tab => ({
path: tab.path,
name: tab.name,
dirty: tab.dirty,
active: tab.id === this.activeTab
}));
try {
sessionStorage.setItem('monaco-tabs', JSON.stringify(tabsData));
} catch (e) {
console.error('[MonacoEditor] Failed to save tabs:', e);
}
}
loadPersistedTabs() {
try {
const saved = sessionStorage.getItem('monaco-tabs');
if (saved) {
const tabsData = JSON.parse(saved);
console.log('[MonacoEditor] Restoring tabs:', tabsData);
// Note: Files will need to be reloaded from server
// This just restores the tab list structure
}
} catch (e) {
console.error('[MonacoEditor] Failed to load tabs:', e);
}
}
escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
// Fallback for mobile devices
initializeFallback() {
this.setupContainer();
this.isMobile = true;
this.initialized = true;
// Add message about mobile limitation
const contentArea = this.container.querySelector('#editor-content');
if (contentArea) {
contentArea.innerHTML = `
<div class="editor-placeholder">
<div class="placeholder-icon">📱</div>
<h2>Mobile View</h2>
<p>Full code editing coming soon to mobile!</p>
<p>For now, please use a desktop or tablet device.</p>
</div>
`;
}
}
openFileFallback(filePath, content) {
// Mobile fallback - show read-only content
const contentArea = this.container.querySelector('#editor-content');
if (contentArea) {
const language = this.getLanguageFromFile(filePath);
contentArea.innerHTML = `
<div class="mobile-file-view">
<div class="file-header">
<h3>${this.escapeHtml(filePath)}</h3>
<span class="language-badge">${language}</span>
</div>
<pre class="code-content"><code>${this.escapeHtml(content || '')}</code></pre>
</div>
`;
}
}
destroy() {
// Dispose all editors and models
this.editors.forEach(editor => editor.dispose());
this.models.forEach(model => model.dispose());
this.editors.clear();
this.models.clear();
this.tabs = [];
this.activeTab = null;
}
}
// Global instance
let monacoEditor = null;
// Initialize when DOM is ready
async function initMonacoEditor() {
monacoEditor = new MonacoEditor('file-editor');
await monacoEditor.initialize();
return monacoEditor;
}
// Export to window
if (typeof window !== 'undefined') {
window.MonacoEditor = MonacoEditor;
// Auto-initialize
async function autoInit() {
try {
const editor = await initMonacoEditor();
window.monacoEditor = editor;
console.log('[MonacoEditor] Auto-initialization complete');
} catch (error) {
console.error('[MonacoEditor] Auto-initialization failed:', error);
window.monacoEditor = null;
}
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', () => autoInit());
} else {
autoInit();
}
}
// Export for use in other scripts
if (typeof module !== 'undefined' && module.exports) {
module.exports = { MonacoEditor };
}

View File

@@ -0,0 +1,380 @@
/**
* Session Picker Component Styles
* Modal for selecting or creating sessions
*/
/* === Session Picker Modal === */
.session-picker-modal {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.8);
display: flex;
align-items: center;
justify-content: center;
z-index: 10000;
padding: 20px;
}
.session-picker-content {
background: #161b22;
border: 1px solid #30363d;
border-radius: 12px;
max-width: 600px;
width: 100%;
max-height: 80vh;
display: flex;
flex-direction: column;
overflow: hidden;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5);
}
/* === Picker Header === */
.picker-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 20px;
border-bottom: 1px solid #30363d;
}
.picker-header h2 {
margin: 0;
font-size: 1.25rem;
font-weight: 600;
color: #c9d1d9;
}
.picker-header .btn-close {
width: 32px;
height: 32px;
padding: 0;
background: transparent;
border: none;
color: #8b949e;
cursor: pointer;
border-radius: 6px;
font-size: 20px;
line-height: 1;
transition: all 0.15s ease;
}
.picker-header .btn-close:hover {
background: #21262d;
color: #c9d1d9;
}
/* === Picker Tabs === */
.picker-tabs {
display: flex;
gap: 4px;
padding: 12px 20px;
border-bottom: 1px solid #30363d;
}
.picker-tab {
display: flex;
align-items: center;
gap: 6px;
padding: 8px 16px;
background: transparent;
border: none;
border-radius: 6px;
color: #8b949e;
cursor: pointer;
font-size: 14px;
font-weight: 500;
transition: all 0.15s ease;
}
.picker-tab:hover {
background: #21262d;
color: #c9d1d9;
}
.picker-tab.active {
background: #21262d;
color: #58a6ff;
}
.picker-tab .tab-icon {
font-size: 16px;
}
/* === Picker Body === */
.picker-body {
flex: 1;
overflow-y: auto;
padding: 0;
}
.picker-tab-content {
display: none;
padding: 16px 20px;
}
.picker-tab-content.active {
display: block;
}
/* === Session/Project Items === */
.session-item,
.project-item {
display: flex;
align-items: center;
gap: 12px;
padding: 12px;
background: #161b22;
border: 1px solid #30363d;
border-radius: 8px;
margin-bottom: 8px;
cursor: pointer;
transition: all 0.15s ease;
}
.session-item:hover,
.project-item:hover {
background: #21262d;
border-color: #58a6ff;
}
.session-icon,
.project-icon {
font-size: 24px;
flex-shrink: 0;
}
.session-info,
.project-info {
flex: 1;
min-width: 0;
}
.session-title,
.project-name {
font-size: 14px;
font-weight: 500;
color: #c9d1d9;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.session-meta,
.project-meta {
display: flex;
align-items: center;
gap: 8px;
font-size: 12px;
color: #8b949e;
margin-top: 2px;
}
.session-project {
padding: 2px 6px;
background: #21262d;
border-radius: 4px;
}
.session-time {
flex-shrink: 0;
}
.session-arrow,
.project-arrow {
color: #8b949e;
flex-shrink: 0;
}
/* === Empty State === */
.empty-state,
.error-state {
text-align: center;
padding: 40px 20px;
}
.empty-icon {
font-size: 48px;
margin-bottom: 16px;
opacity: 0.5;
}
.empty-state h3,
.error-state h3 {
font-size: 1.125rem;
font-weight: 600;
color: #c9d1d9;
margin-bottom: 8px;
}
.empty-state p,
.error-state p {
font-size: 0.875rem;
color: #8b949e;
margin-bottom: 16px;
}
/* === New Session Form === */
.new-session-form {
display: flex;
flex-direction: column;
gap: 16px;
}
.form-group {
display: flex;
flex-direction: column;
gap: 6px;
}
.form-group label {
font-size: 13px;
font-weight: 500;
color: #c9d1d9;
}
.form-group input {
padding: 8px 12px;
background: #0d1117;
border: 1px solid #30363d;
border-radius: 6px;
color: #c9d1d9;
font-size: 14px;
}
.form-group input:focus {
outline: none;
border-color: #58a6ff;
box-shadow: 0 0 0 3px rgba(88, 166, 255, 0.1);
}
/* === Buttons === */
.btn-primary,
.btn-secondary {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 8px 16px;
border-radius: 6px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: all 0.15s ease;
border: none;
}
.btn-primary {
background: #1f6feb;
color: #ffffff;
}
.btn-primary:hover {
background: #388bfd;
}
.btn-secondary {
background: #21262d;
color: #c9d1d9;
}
.btn-secondary:hover {
background: #30363d;
}
.btn-block {
width: 100%;
}
/* === Loading === */
.loading {
width: 40px;
height: 40px;
border: 3px solid #30363d;
border-top-color: #58a6ff;
border-radius: 50%;
animation: spin 0.8s linear infinite;
margin: 2rem auto;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
/* === Mobile Responsive === */
@media (max-width: 640px) {
.session-picker-modal {
padding: 10px;
}
.session-picker-content {
max-height: 90vh;
border-radius: 8px;
}
.picker-header {
padding: 12px 16px;
}
.picker-header h2 {
font-size: 1.125rem;
}
.picker-tabs {
padding: 8px 16px;
}
.picker-tab {
padding: 6px 12px;
font-size: 13px;
}
.picker-body {
padding: 12px 16px;
}
.session-item,
.project-item {
padding: 10px;
}
.session-icon,
.project-icon {
font-size: 20px;
}
.session-title,
.project-name {
font-size: 13px;
}
.empty-icon {
font-size: 36px;
}
}
/* === Touch Targets === */
@media (hover: none) and (pointer: coarse) {
.picker-tab,
.session-item,
.project-item {
min-height: 44px;
}
.btn-close,
.btn-primary,
.btn-secondary {
min-height: 44px;
min-width: 44px;
}
}
/* === Print Styles === */
@media print {
.session-picker-modal {
display: none;
}
}

View File

@@ -0,0 +1,435 @@
/**
* Session Picker Component
* Show modal on startup to select existing session or create new
*
* Features:
* - Session picker modal on startup
* - Recent sessions list
* - Sessions grouped by project
* - Create new session
* - Session forking support
*/
class SessionPicker {
constructor() {
this.modal = null;
this.sessions = [];
this.initialized = false;
}
async initialize() {
if (this.initialized) return;
// Check URL params first
const urlParams = new URLSearchParams(window.location.search);
const sessionId = urlParams.get('session');
const project = urlParams.get('project');
if (sessionId) {
// Load specific session
console.log('[SessionPicker] Loading session from URL:', sessionId);
await this.loadSession(sessionId);
this.initialized = true;
return;
}
if (project) {
// Create or load session for project
console.log('[SessionPicker] Project context:', project);
await this.ensureSessionForProject(project);
this.initialized = true;
return;
}
// No session or project - show picker
await this.showPicker();
this.initialized = true;
}
async showPicker() {
// Create modal
this.modal = document.createElement('div');
this.modal.className = 'session-picker-modal';
this.modal.innerHTML = `
<div class="session-picker-content">
<div class="picker-header">
<h2>Select a Session</h2>
<button class="btn-close" onclick="window.sessionPicker.close()">×</button>
</div>
<div class="picker-tabs">
<button class="picker-tab active" data-tab="recent" onclick="window.sessionPicker.switchTab('recent')">
<span class="tab-icon">🕐</span>
<span class="tab-label">Recent</span>
</button>
<button class="picker-tab" data-tab="projects" onclick="window.sessionPicker.switchTab('projects')">
<span class="tab-icon">📁</span>
<span class="tab-label">Projects</span>
</button>
<button class="picker-tab" data-tab="new" onclick="window.sessionPicker.switchTab('new')">
<span class="tab-icon"></span>
<span class="tab-label">New Session</span>
</button>
</div>
<div class="picker-body">
<div id="picker-recent" class="picker-tab-content active">
<div class="loading">Loading recent sessions...</div>
</div>
<div id="picker-projects" class="picker-tab-content">
<div class="loading">Loading projects...</div>
</div>
<div id="picker-new" class="picker-tab-content">
<div class="new-session-form">
<div class="form-group">
<label>Session Name</label>
<input type="text" id="new-session-name" placeholder="My Session" />
</div>
<div class="form-group">
<label>Project (optional)</label>
<input type="text" id="new-session-project" placeholder="my-project" />
</div>
<button class="btn-primary btn-block" onclick="window.sessionPicker.createNewSession()">
Create Session
</button>
</div>
</div>
</div>
</div>
`;
document.body.appendChild(this.modal);
document.body.style.overflow = 'hidden'; // Prevent scrolling
// Load recent sessions
await this.loadRecentSessions();
await this.loadProjects();
}
async loadRecentSessions() {
const container = document.getElementById('picker-recent');
try {
const response = await fetch('/claude/api/claude/sessions');
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const data = await response.json();
this.sessions = data.sessions || [];
if (this.sessions.length === 0) {
container.innerHTML = `
<div class="empty-state">
<div class="empty-icon">💬</div>
<h3>No sessions yet</h3>
<p>Create a new session to get started</p>
<button class="btn-primary" onclick="window.sessionPicker.switchTab('new')">
Create Session
</button>
</div>
`;
return;
}
// Sort by last modified
this.sessions.sort((a, b) => {
const dateA = new Date(a.modified || a.created);
const dateB = new Date(b.modified || b.created);
return dateB - dateA;
});
// Show last 10 sessions
const recentSessions = this.sessions.slice(0, 10);
container.innerHTML = recentSessions.map(session => {
const date = new Date(session.modified || session.created);
const timeAgo = this.formatTimeAgo(date);
const title = session.title || session.id;
const project = session.project || 'General';
return `
<div class="session-item" onclick="window.sessionPicker.selectSession('${session.id}')">
<div class="session-icon">💬</div>
<div class="session-info">
<div class="session-title">${this.escapeHtml(title)}</div>
<div class="session-meta">
<span class="session-project">${this.escapeHtml(project)}</span>
<span class="session-time">${timeAgo}</span>
</div>
</div>
<div class="session-arrow">→</div>
</div>
`;
}).join('');
} catch (error) {
console.error('[SessionPicker] Failed to load sessions:', error);
container.innerHTML = `
<div class="error-state">
<h3>Failed to load sessions</h3>
<p>${error.message}</p>
<button class="btn-secondary" onclick="window.sessionPicker.loadRecentSessions()">
Try Again
</button>
</div>
`;
}
}
async loadProjects() {
const container = document.getElementById('picker-projects');
try {
// Use the sessions endpoint to get projects
const response = await fetch('/claude/api/claude/sessions');
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const data = await response.json();
// Group sessions by project
const projectMap = new Map();
const allSessions = [
...(data.active || []),
...(data.historical || [])
];
allSessions.forEach(session => {
const projectName = session.metadata?.project || session.workingDir?.split('/').pop() || 'Untitled';
if (!projectMap.has(projectName)) {
projectMap.set(projectName, {
name: projectName,
sessionCount: 0,
lastSession: session
});
}
const project = projectMap.get(projectName);
project.sessionCount++;
});
const projects = Array.from(projectMap.values());
if (projects.length === 0) {
container.innerHTML = `
<div class="empty-state">
<div class="empty-icon">📁</div>
<h3>No projects yet</h3>
<p>Create a new project to organize your sessions</p>
<button class="btn-primary" onclick="window.sessionPicker.switchTab('new')">
New Session
</button>
</div>
`;
return;
}
// Sort by session count (most used first)
projects.sort((a, b) => b.sessionCount - a.sessionCount);
container.innerHTML = projects.map(project => {
const sessionCount = project.sessionCount || 0;
return `
<div class="project-item" onclick="window.sessionPicker.selectProject('${this.escapeHtml(project.name)}')">
<div class="project-icon">📁</div>
<div class="project-info">
<div class="project-name">${this.escapeHtml(project.name)}</div>
<div class="project-meta">${sessionCount} session${sessionCount !== 1 ? 's' : ''}</div>
</div>
<div class="project-arrow">→</div>
</div>
`;
}).join('');
} catch (error) {
console.error('[SessionPicker] Failed to load projects:', error);
container.innerHTML = `
<div class="error-state">
<h3>Failed to load projects</h3>
<p>${error.message}</p>
</div>
`;
}
}
async selectSession(sessionId) {
await this.loadSession(sessionId);
this.close();
}
async selectProject(projectName) {
await this.ensureSessionForProject(projectName);
this.close();
}
async loadSession(sessionId) {
try {
const response = await fetch(`/claude/api/claude/sessions/${sessionId}`);
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const session = await response.json();
// Attach to session
if (typeof attachToSession === 'function') {
attachToSession(sessionId);
}
console.log('[SessionPicker] Loaded session:', sessionId);
return session;
} catch (error) {
console.error('[SessionPicker] Failed to load session:', error);
if (typeof showToast === 'function') {
showToast(`Failed to load session: ${error.message}`, 'error', 3000);
}
}
}
async ensureSessionForProject(projectName) {
try {
// Check if session exists for this project
const response = await fetch('/claude/api/claude/sessions');
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const data = await response.json();
const sessions = data.sessions || [];
const projectSession = sessions.find(s => s.project === projectName);
if (projectSession) {
return await this.loadSession(projectSession.id);
}
// Create new session for project
return await this.createNewSession(projectName);
} catch (error) {
console.error('[SessionPicker] Failed to ensure session:', error);
}
}
async createNewSession(projectName = null) {
const nameInput = document.getElementById('new-session-name');
const projectInput = document.getElementById('new-session-project');
const name = nameInput?.value || projectName || 'Untitled Session';
const project = projectInput?.value || projectName || '';
try {
const response = await fetch('/claude/api/claude/sessions', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
title: name,
project: project,
source: 'web-ide'
})
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const session = await response.json();
// Attach to new session
if (typeof attachToSession === 'function') {
attachToSession(session.id);
}
console.log('[SessionPicker] Created session:', session.id);
this.close();
return session;
} catch (error) {
console.error('[SessionPicker] Failed to create session:', error);
if (typeof showToast === 'function') {
showToast(`Failed to create session: ${error.message}`, 'error', 3000);
}
}
}
switchTab(tabName) {
// Update tab buttons
this.modal.querySelectorAll('.picker-tab').forEach(tab => {
tab.classList.remove('active');
if (tab.dataset.tab === tabName) {
tab.classList.add('active');
}
});
// Update tab content
this.modal.querySelectorAll('.picker-tab-content').forEach(content => {
content.classList.remove('active');
});
const activeContent = document.getElementById(`picker-${tabName}`);
if (activeContent) {
activeContent.classList.add('active');
}
}
close() {
if (this.modal) {
this.modal.remove();
this.modal = null;
}
document.body.style.overflow = ''; // Restore scrolling
}
formatTimeAgo(date) {
const seconds = Math.floor((new Date() - date) / 1000);
if (seconds < 60) {
return 'Just now';
} else if (seconds < 3600) {
const minutes = Math.floor(seconds / 60);
return `${minutes}m ago`;
} else if (seconds < 86400) {
const hours = Math.floor(seconds / 3600);
return `${hours}h ago`;
} else if (seconds < 604800) {
const days = Math.floor(seconds / 86400);
return `${days}d ago`;
} else {
return date.toLocaleDateString();
}
}
escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
}
// Global instance
let sessionPicker = null;
// Auto-initialize
if (typeof window !== 'undefined') {
window.SessionPicker = SessionPicker;
// Create instance
sessionPicker = new SessionPicker();
window.sessionPicker = sessionPicker;
// Initialize on DOM ready
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', () => {
sessionPicker.initialize();
});
} else {
sessionPicker.initialize();
}
}
// Export for use in other scripts
if (typeof module !== 'undefined' && module.exports) {
module.exports = { SessionPicker };
}

View File

@@ -0,0 +1,169 @@
/**
* Real-Time Error Monitoring
* Captures browser errors and forwards them to the server for Claude to see
*/
(function() {
'use strict';
// Error endpoint
const ERROR_ENDPOINT = '/claude/api/log-error';
// Send error to server
function reportError(errorData) {
// Add to bug tracker
if (window.bugTracker) {
const errorId = window.bugTracker.addError(errorData);
errorData._id = errorId;
}
fetch(ERROR_ENDPOINT, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(errorData)
})
.then(response => response.json())
.then(data => {
if (data.autoFixTriggered && window.bugTracker) {
window.bugTracker.startFix(errorData._id);
showErrorNotification(errorData);
}
})
.catch(err => console.error('[ErrorMonitor] Failed to report error:', err));
}
// Show notification that error is being fixed
function showErrorNotification(errorData) {
// Create notification element
const notification = document.createElement('div');
notification.style.cssText = `
position: fixed;
top: 20px;
right: 20px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
padding: 16px 20px;
border-radius: 12px;
box-shadow: 0 10px 40px rgba(0,0,0,0.3);
z-index: 10000;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
max-width: 400px;
animation: slideIn 0.3s ease-out;
`;
notification.innerHTML = `
<div style="display: flex; align-items: flex-start; gap: 12px;">
<div style="font-size: 24px;">🤖</div>
<div style="flex: 1;">
<div style="font-weight: 600; margin-bottom: 4px;">Auto-Fix Agent Triggered</div>
<div style="font-size: 13px; opacity: 0.9;">
Error detected: ${errorData.message.substring(0, 60)}${errorData.message.length > 60 ? '...' : ''}
</div>
<div style="font-size: 11px; opacity: 0.7; margin-top: 4px;">
Claude is analyzing and preparing a fix...
</div>
</div>
<button onclick="this.parentElement.parentElement.remove()" style="background: none; border: none; color: white; cursor: pointer; font-size: 18px; opacity: 0.7;">×</button>
</div>
`;
// Add animation styles
if (!document.getElementById('error-notification-styles')) {
const style = document.createElement('style');
style.id = 'error-notification-styles';
style.textContent = `
@keyframes slideIn {
from { transform: translateX(400px); opacity: 0; }
to { transform: translateX(0); opacity: 1; }
}
`;
document.head.appendChild(style);
}
document.body.appendChild(notification);
// Auto-remove after 10 seconds
setTimeout(() => {
if (notification.parentElement) {
notification.style.animation = 'slideIn 0.3s ease-out reverse';
setTimeout(() => notification.remove(), 300);
}
}, 10000);
}
// Global error handler
window.addEventListener('error', (event) => {
reportError({
type: 'javascript',
message: event.message,
filename: event.filename,
line: event.lineno,
column: event.colno,
stack: event.error?.stack,
timestamp: new Date().toISOString(),
url: window.location.href,
userAgent: navigator.userAgent
});
});
// Unhandled promise rejection handler
window.addEventListener('unhandledrejection', (event) => {
reportError({
type: 'promise',
message: event.reason?.message || String(event.reason),
stack: event.reason?.stack,
timestamp: new Date().toISOString(),
url: window.location.href,
userAgent: navigator.userAgent
});
});
// Console error interception
const originalError = console.error;
console.error = function(...args) {
originalError.apply(console, args);
reportError({
type: 'console',
message: args.map(arg => {
if (typeof arg === 'object') {
try { return JSON.stringify(arg); }
catch(e) { return String(arg); }
}
return String(arg);
}).join(' '),
timestamp: new Date().toISOString(),
url: window.location.href
});
};
// Resource loading errors
window.addEventListener('error', (event) => {
if (event.target !== window) {
const src = event.target.src || event.target.href || 'unknown';
reportError({
type: 'resource',
message: 'Failed to load: ' + src,
tagName: event.target.tagName,
timestamp: new Date().toISOString(),
url: window.location.href
});
}
}, true);
// Network error monitoring for fetch
const originalFetch = window.fetch;
window.fetch = function(...args) {
return originalFetch.apply(this, args).catch(error => {
reportError({
type: 'network',
message: 'Fetch failed: ' + args[0],
error: error.message,
timestamp: new Date().toISOString(),
url: window.location.href
});
throw error;
});
};
console.log('[ErrorMonitor] Real-time error monitoring initialized');
})();

View File

@@ -115,14 +115,22 @@ function connectWebSocket() {
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) => {
@@ -146,7 +154,7 @@ function connectWebSocket() {
reason: event.reason,
wasClean: event.wasClean
});
// Clear the ws reference
window.wsReady = false;
window.ws = null;
// Attempt to reconnect after 5 seconds
setTimeout(() => {
@@ -156,6 +164,113 @@ function connectWebSocket() {
};
}
// === 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
const messagesToSend = [...window.messageQueue];
window.messageQueue = [];
for (const item of messagesToSend) {
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);
}
}
hideQueuedMessageIndicator();
}
/**
* 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':
@@ -275,6 +390,11 @@ function handleOperationProgress(data) {
}
}
// Streaming message state for accumulating response chunks
let streamingMessageElement = null;
let streamingMessageContent = '';
let streamingTimeout = null;
function handleSessionOutput(data) {
// Handle output for sessions view
if (currentSession && data.sessionId === currentSession.id) {
@@ -283,15 +403,40 @@ function handleSessionOutput(data) {
// Handle output for chat view
if (typeof attachedSessionId !== 'undefined' && data.sessionId === attachedSessionId) {
// Hide streaming indicator
// Hide streaming indicator on first chunk
if (typeof hideStreamingIndicator === 'function') {
hideStreamingIndicator();
}
// Append output as assistant message
if (typeof appendMessage === 'function') {
appendMessage('assistant', data.data.content, true);
const content = data.data.content || '';
// 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);
}
}, 1000);
}
}
@@ -647,6 +792,7 @@ async function continueSessionInChat(sessionId) {
window.pendingSessionId = sessionId;
window.pendingSessionData = session;
hideLoadingOverlay();
switchView('chat');
} catch (error) {
@@ -855,13 +1001,13 @@ async function loadFile(filePath) {
const res = await fetch(`/claude/api/file/${encodeURIComponent(filePath)}`);
const data = await res.json();
// Check if FileEditor component is available
if (window.fileEditor) {
// Use the new CodeMirror-based editor
await window.fileEditor.openFile(filePath, data.content || '');
// 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 the old view if FileEditor is not loaded yet
console.warn('[loadFile] FileEditor not available, using fallback');
// 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');
@@ -872,8 +1018,7 @@ async function loadFile(filePath) {
<div class="file-header">
<h2>${filePath}</h2>
<div class="file-actions">
<button class="btn-secondary btn-sm" onclick="editFile('${filePath}')">Edit</button>
<button class="btn-primary btn-sm" onclick="showHtmlPreview('${filePath}')">👁️ Preview</button>
<button class="btn-secondary btn-sm" onclick="showHtmlPreview('${filePath}')">👁️ Preview</button>
</div>
</div>
<div class="file-content" id="file-content-view">
@@ -882,7 +1027,7 @@ async function loadFile(filePath) {
<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>
<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>
@@ -891,7 +1036,7 @@ async function loadFile(filePath) {
`;
// Store file content for preview
window.currentFileContent = data.content;
window.currentFileContent = data.content || '';
window.currentFilePath = filePath;
// Highlight code
@@ -902,12 +1047,14 @@ async function loadFile(filePath) {
}
} 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><code>${escapeHtml(data.content || '')}</code></pre>
<pre class="code-content"><code>${escapeHtml(data.content || '')}</code></pre>
</div>
`;
}
@@ -926,6 +1073,24 @@ async function loadFile(filePath) {
}
}
// 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');

View File

@@ -10,41 +10,13 @@
<link rel="stylesheet" href="/claude/claude-ide/preview-manager.css">
<link rel="stylesheet" href="/claude/claude-ide/chat-enhanced.css">
<link rel="stylesheet" href="/claude/claude-ide/terminal.css">
<link rel="stylesheet" href="/claude/claude-ide/components/file-editor.css">
<link rel="stylesheet" href="/claude/claude-ide/components/monaco-editor.css">
<link rel="stylesheet" href="/claude/claude-ide/components/enhanced-chat-input.css">
<link rel="stylesheet" href="/claude/claude-ide/components/session-picker.css">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/github-dark.min.css">
<!-- Import Map for CodeMirror 6 -->
<script type="importmap">
{
"imports": {
"@codemirror/state": "/claude/node_modules/@codemirror/state/dist/index.cjs",
"@codemirror/view": "/claude/node_modules/@codemirror/view/dist/index.cjs",
"@codemirror/commands": "/claude/node_modules/@codemirror/commands/dist/index.cjs",
"@codemirror/language": "/claude/node_modules/@codemirror/language/dist/index.cjs",
"@codemirror/autocomplete": "/claude/node_modules/@codemirror/autocomplete/dist/index.cjs",
"@codemirror/search": "/claude/node_modules/@codemirror/search/dist/index.cjs",
"@codemirror/lint": "/claude/node_modules/@codemirror/lint/dist/index.cjs",
"@codemirror/lang-javascript": "/claude/node_modules/@codemirror/lang-javascript/dist/index.cjs",
"@codemirror/lang-python": "/claude/node_modules/@codemirror/lang-python/dist/index.cjs",
"@codemirror/lang-html": "/claude/node_modules/@codemirror/lang-html/dist/index.cjs",
"@codemirror/lang-css": "/claude/node_modules/@codemirror/lang-css/dist/index.cjs",
"@codemirror/lang-json": "/claude/node_modules/@codemirror/lang-json/dist/index.cjs",
"@codemirror/lang-markdown": "/claude/node_modules/@codemirror/lang-markdown/dist/index.cjs",
"@codemirror/gutter": "/claude/node_modules/@codemirror/gutter/dist/index.cjs",
"@codemirror/fold": "/claude/node_modules/@codemirror/fold/dist/index.cjs",
"@codemirror/panel": "/claude/node_modules/@codemirror/panel/dist/index.cjs",
"@lezer/highlight": "/claude/node_modules/@lezer/highlight/dist/index.cjs",
"@lezer/common": "/claude/node_modules/@lezer/common/dist/index.cjs",
"@lezer/javascript": "/claude/node_modules/@lezer/javascript/dist/index.cjs",
"@lezer/python": "/claude/node_modules/@lezer/python/dist/index.cjs",
"@lezer/html": "/claude/node_modules/@lezer/html/dist/index.cjs",
"@lezer/css": "/claude/node_modules/@lezer/css/dist/index.cjs",
"@lezer/json": "/claude/node_modules/@lezer/json/dist/index.cjs",
"@lezer/markdown": "/claude/node_modules/@lezer/markdown/dist/index.cjs",
"@codemirror/basic-setup": "/claude/node_modules/@codemirror/basic-setup/dist/index.cjs"
}
}
</script>
<!-- Monaco Editor (VS Code Editor) - AMD Loader -->
<script src="https://unpkg.com/monaco-editor@0.45.0/min/vs/loader.js"></script>
</head>
<body>
<div id="app">
@@ -215,7 +187,7 @@
</button>
</div>
<div class="chat-input-container">
<div class="chat-input-container" id="chat-input-container">
<div class="chat-input-wrapper">
<textarea id="chat-input"
placeholder="Type your message to Claude Code... (Enter to send, Shift+Enter for new line)"
@@ -372,13 +344,17 @@
</div>
</div>
<script src="/claude/claude-ide/error-monitor.js"></script>
<script src="/claude/claude-ide/bug-tracker.js"></script>
<script src="/claude/claude-ide/ide.js"></script>
<script src="/claude/claude-ide/chat-functions.js"></script>
<script src="/claude/claude-ide/tag-renderer.js"></script>
<script src="/claude/claude-ide/preview-manager.js"></script>
<script src="/claude/claude-ide/chat-enhanced.js"></script>
<script src="/claude/claude-ide/terminal.js"></script>
<script type="module" src="/claude/claude-ide/components/file-editor.js"></script>
<script src="/claude/claude-ide/components/monaco-editor.js"></script>
<script src="/claude/claude-ide/components/enhanced-chat-input.js"></script>
<script src="/claude/claude-ide/components/session-picker.js"></script>
<!-- Debug Panel Toggle Script -->
<script>